@things-factory/figure-service 10.1.111 → 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 (44) 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/figure-history.d.ts +1 -0
  7. package/dist-server/service/figure/figure-history.js +5 -0
  8. package/dist-server/service/figure/figure-history.js.map +1 -1
  9. package/dist-server/service/figure/figure-mutation.js +11 -8
  10. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  11. package/dist-server/service/figure/figure-replaced-draft.d.ts +1 -0
  12. package/dist-server/service/figure/figure-replaced-draft.js +5 -0
  13. package/dist-server/service/figure/figure-replaced-draft.js.map +1 -1
  14. package/dist-server/service/figure/figure-revert.js +9 -4
  15. package/dist-server/service/figure/figure-revert.js.map +1 -1
  16. package/dist-server/service/figure/figure-thumbnail-store.d.ts +35 -0
  17. package/dist-server/service/figure/figure-thumbnail-store.js +96 -0
  18. package/dist-server/service/figure/figure-thumbnail-store.js.map +1 -0
  19. package/dist-server/service/figure/figure-thumbnail-upgrade.d.ts +8 -0
  20. package/dist-server/service/figure/figure-thumbnail-upgrade.js +56 -0
  21. package/dist-server/service/figure/figure-thumbnail-upgrade.js.map +1 -0
  22. package/dist-server/service/figure/figure-write.d.ts +14 -10
  23. package/dist-server/service/figure/figure-write.js +9 -4
  24. package/dist-server/service/figure/figure-write.js.map +1 -1
  25. package/dist-server/service/figure/figure.d.ts +1 -0
  26. package/dist-server/service/figure/figure.js +5 -0
  27. package/dist-server/service/figure/figure.js.map +1 -1
  28. package/dist-server/service/figure/index.d.ts +1 -1
  29. package/dist-server/service/index.d.ts +1 -1
  30. package/dist-server/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +3 -2
  32. package/server/index.ts +15 -0
  33. package/server/routers/figure-thumbnail-router.ts +40 -36
  34. package/server/service/figure/figure-history.ts +9 -0
  35. package/server/service/figure/figure-mutation.ts +11 -9
  36. package/server/service/figure/figure-replaced-draft.ts +9 -0
  37. package/server/service/figure/figure-revert-db.test.ts +6 -5
  38. package/server/service/figure/figure-revert.ts +9 -4
  39. package/server/service/figure/figure-thumbnail-db.test.ts +218 -0
  40. package/server/service/figure/figure-thumbnail-store.ts +107 -0
  41. package/server/service/figure/figure-thumbnail-upgrade.pg.test.ts +93 -0
  42. package/server/service/figure/figure-thumbnail-upgrade.ts +70 -0
  43. package/server/service/figure/figure-write.ts +15 -4
  44. package/server/service/figure/figure.ts +9 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.111",
3
+ "version": "10.1.112",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -28,6 +28,7 @@
28
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/attachment-base": "^10.1.112",
31
32
  "@things-factory/auth-base": "^10.1.111",
32
33
  "@things-factory/env": "^10.1.20",
33
34
  "@things-factory/shell": "^10.1.106",
@@ -35,5 +36,5 @@
35
36
  "graphql-upload": "^15.0.2",
36
37
  "koa-router": "^7.4.0"
37
38
  },
38
- "gitHead": "f33122c15e5b3e45393129e9418e2c367a273ade"
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
@@ -80,6 +80,15 @@ export class FigureHistory implements HistoryEntityInterface<Figure> {
80
80
  @Field({ nullable: true, description: 'The thumbnail as it looked at this version.' })
81
81
  thumbnail?: string
82
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
+
83
92
  /*
84
93
  * 그때의 비용이다. 오늘 다시 계산하면 오늘의 형식으로 나온다 — 그 판을 설명하지 못한다.
85
94
  */
@@ -65,7 +65,7 @@ export class FigureMutation {
65
65
  if (!id) {
66
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))
67
67
  throw Error('새 모델 저장에는 생성 요청 식별자가 필요합니다.')
68
- const prepared = { ...prepareFigureWrite(source), ...pictureOf(thumbnail) }
68
+ const prepared = { ...prepareFigureWrite(source), ...(await pictureOf(tx, domain, user, thumbnail)) }
69
69
  const previous = await repository.findOne({ where: { id: creationId, domain: { id: domain.id } } })
70
70
  if (previous) {
71
71
  if (previous.source !== prepared.source) throw Error('이 생성 요청은 이미 다른 내용으로 저장되었습니다. 저장된 모델을 다시 열어 주세요.')
@@ -100,7 +100,7 @@ export class FigureMutation {
100
100
  // Exact tenant ownership: an inherited catalog entry is not writable in the child domain.
101
101
  const figure = await repository.findOne({ where: { id, domain: { id: domain.id } } })
102
102
  if (!figure) throw Error('편집할 Figure를 찾을 수 없습니다.')
103
- const prepared = { ...prepareFigureWrite(source, figure, expectedSource), ...pictureOf(thumbnail, figure) }
103
+ const prepared = { ...prepareFigureWrite(source, figure, expectedSource), ...(await pictureOf(tx, domain, user, thumbnail, figure)) }
104
104
  // Compare-and-swap in the UPDATE itself: supported by SQLite as well as PostgreSQL.
105
105
  // A pessimistic row lock is not supported by the product's SQLite deployment.
106
106
  await compareAndSwapFigure(criteria => repository.update(criteria, { ...prepared, updater: user }),
@@ -128,12 +128,13 @@ export class FigureMutation {
128
128
  // 형식을 어기면 여기서 던진다. 파생값은 서버가 계산한다.
129
129
  const { source, derived, violations } = parseSource(figure.source)
130
130
 
131
+ const { thumbnail, ...rest } = figure
131
132
  const saved = await repository.save({
132
- ...figure,
133
+ ...rest,
133
134
  source,
134
135
  ...derived,
135
136
  state: figure.state ?? 'draft',
136
- thumbnailUpdatedAt: figure.thumbnail ? new Date() : undefined,
137
+ ...(await pictureOf(tx, domain, user, thumbnail)),
137
138
  domain,
138
139
  creator: user,
139
140
  updater: user
@@ -184,6 +185,9 @@ export class FigureMutation {
184
185
  행이 다른 이름을 말하고, 발행할 때 어느 쪽이 도면에 적히는지가 갈린다.
185
186
  */
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)
187
191
 
188
192
  let renamed: string | undefined
189
193
  if (patch.type !== undefined && patch.type !== figure.type) {
@@ -206,7 +210,7 @@ export class FigureMutation {
206
210
  */
207
211
  domain,
208
212
  ...figure,
209
- ...patch,
213
+ ...patchRest,
210
214
  /* 앞뒤 공백을 떼고 겹침까지 본 이름. `patch.type` 을 그대로 두면 다듬기 전 값이 들어간다. */
211
215
  ...(renamed !== undefined ? { type: renamed } : {}),
212
216
  source,
@@ -220,10 +224,8 @@ export class FigureMutation {
220
224
  state: 'draft',
221
225
  ...(derived ?? {}),
222
226
  // 썸네일이 **썸네일로서** 바뀐 때만 시각을 갱신한다. 이름·설명 변경에는 안 건드린다.
223
- thumbnailUpdatedAt:
224
- patch.thumbnail !== undefined && patch.thumbnail !== figure.thumbnail
225
- ? new Date()
226
- : figure.thumbnailUpdatedAt,
227
+ thumbnail: figure.thumbnail ?? null,
228
+ ...picture,
227
229
  updater: user
228
230
  })
229
231
 
@@ -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
@@ -60,8 +60,8 @@ beforeEach(async () => {
60
60
  })
61
61
 
62
62
  async function figureWithRelease() {
63
- const figure = await dataSource!.getRepository(Figure).save({ type: 'REVERT_PROBE', name: 'probe', source: DRAFT, state: 'draft', version: 1, thumbnail: 'draft-picture', domain: DOMAIN })
64
- await dataSource!.getRepository(FigureHistory).save({ originalId: figure.id, version: 1, type: 'REVERT_PROBE', name: 'probe', source: V1, state: 'released', thumbnail: 'v1-picture', domain: DOMAIN })
63
+ const figure = await dataSource!.getRepository(Figure).save({ type: 'REVERT_PROBE', name: 'probe', source: DRAFT, state: 'draft', version: 1, thumbnailHash: 'draft-picture', domain: DOMAIN })
64
+ await dataSource!.getRepository(FigureHistory).save({ originalId: figure.id, version: 1, type: 'REVERT_PROBE', name: 'probe', source: V1, state: 'released', thumbnailHash: 'v1-picture', domain: DOMAIN })
65
65
  return figure
66
66
  }
67
67
  const uuid = () => require('crypto').randomUUID()
@@ -74,10 +74,11 @@ describe('a revert keeps the draft it writes over', () => {
74
74
  const figure = await figureWithRelease()
75
75
  const result = await inTx(tx => revertKeepingDraft(tx, DOMAIN, undefined, { id: figure.id, version: 1, requestId: uuid(), expectedSource: DRAFT }))
76
76
  expect(JSON.parse((await row(figure.id)).source).name).toBe('Released one')
77
- expect((await row(figure.id)).thumbnail).toBe('v1-picture')
77
+ /* The picture goes by its hash (ADR-0104 decision 6): the release's own comes back with its source. */
78
+ expect((await row(figure.id)).thumbnailHash).toBe('v1-picture')
78
79
  const [one] = await kept()
79
80
  expect(one.source).toBe(DRAFT)
80
- expect(one.thumbnail).toBe('draft-picture')
81
+ expect(one.thumbnailHash).toBe('draft-picture')
81
82
  expect(one.revertedToVersion).toBe(1)
82
83
  expect(result.kept.id).toBe(one.id)
83
84
  /* Not a release: the released versions are as they were. */
@@ -133,7 +134,7 @@ describe('an undo brings the kept draft back only while nothing changed since',
133
134
  const requestId = uuid()
134
135
  await inTx(tx => undoRevert(tx, DOMAIN, undefined, { keptId: reverted.kept.id, requestId }))
135
136
  expect((await row(figure.id)).source).toBe(DRAFT)
136
- expect((await row(figure.id)).thumbnail).toBe('draft-picture')
137
+ expect((await row(figure.id)).thumbnailHash).toBe('draft-picture')
137
138
  const records = await kept()
138
139
  expect(records.map((k: any) => k.kind).sort()).toEqual(['revert', 'undo'])
139
140
  expect(records.find((k: any) => k.kind === 'revert').restoredAt).toBeTruthy()
@@ -86,6 +86,7 @@ export async function revertKeepingDraft(
86
86
  source: figure.source,
87
87
  properties: figure.properties,
88
88
  thumbnail: figure.thumbnail,
89
+ thumbnailHash: figure.thumbnailHash,
89
90
  state: figure.state,
90
91
  version: figure.version,
91
92
  revertedToVersion: request.version,
@@ -101,8 +102,10 @@ export async function revertKeepingDraft(
101
102
  source,
102
103
  ...derived,
103
104
  properties: history.properties,
104
- thumbnail: history.thumbnail,
105
- thumbnailUpdatedAt: history.thumbnail === figure.thumbnail ? figure.thumbnailUpdatedAt : new Date(),
105
+ /* The picture goes by its hash (ADR-0104 decision 6); the release's own, as the source is. */
106
+ thumbnail: null,
107
+ thumbnailHash: history.thumbnailHash ?? null,
108
+ thumbnailUpdatedAt: history.thumbnailHash === figure.thumbnailHash ? figure.thumbnailUpdatedAt : new Date(),
106
109
  state: 'draft',
107
110
  ...(user ? { updater: { id: user.id } } : {})
108
111
  }
@@ -144,6 +147,7 @@ export async function undoRevert(
144
147
  source: figure.source,
145
148
  properties: figure.properties,
146
149
  thumbnail: figure.thumbnail,
150
+ thumbnailHash: figure.thumbnailHash,
147
151
  state: figure.state,
148
152
  version: figure.version,
149
153
  writtenSource: record.source,
@@ -158,8 +162,9 @@ export async function undoRevert(
158
162
  source: record.source,
159
163
  ...derived,
160
164
  properties: record.properties,
161
- thumbnail: record.thumbnail,
162
- thumbnailUpdatedAt: record.thumbnail === figure.thumbnail ? figure.thumbnailUpdatedAt : new Date(),
165
+ thumbnail: null,
166
+ thumbnailHash: record.thumbnailHash ?? null,
167
+ thumbnailUpdatedAt: record.thumbnailHash === figure.thumbnailHash ? figure.thumbnailUpdatedAt : new Date(),
163
168
  state: 'draft',
164
169
  ...(user ? { updater: { id: user.id } } : {})
165
170
  }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * A figure's picture kept by attachment-base under its content hash (ADR-0104 decision 6; chief architect's ruling
3
+ * 2026-10-02): written once per content per domain, served only behind the figure permission from the figure's own
4
+ * domain, and the base64 an install already holds moved at start without losing one. On sqlite with the history
5
+ * subscriber on; the files go to a map instead of the configured storage.
6
+ */
7
+ import type { DataSource } from 'typeorm'
8
+
9
+ const { DataSource: TypeOrmDataSource } = require('typeorm')
10
+ const shell = require('@things-factory/shell')
11
+ const authBase = require('@things-factory/auth-base')
12
+ const attachmentBase = require('@things-factory/attachment-base')
13
+ const { createFigureAsset, addFigureBox, serializeFigureAsset } = require('@hatiolab/figure-model')
14
+
15
+ let dataSource: DataSource | null = null
16
+ let Figure: any, FigureHistory: any, FigureReplacedDraft: any, Subscriber: any, FigureMutation: any
17
+ let keepThumbnail: any, sha256Of: any, useThumbnailFiles: any, moveBase64Thumbnails: any, sendFigureThumbnail: 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
+ /** Files in a map: put keeps the bytes under a new id, send hands them back. */
29
+ function mapFiles() {
30
+ const kept = new Map<string, Buffer>()
31
+ let n = 0
32
+ return {
33
+ kept,
34
+ async put(bytes: Buffer, _mimetype: string, name: string) {
35
+ const id = `00000000-0000-4000-8000-${String(++n).padStart(12, '0')}`
36
+ const path = `${id}.${name.split('.').pop()}`
37
+ kept.set(path, bytes)
38
+ return { id, path, size: bytes.length }
39
+ },
40
+ async send(koa: any, path: string) {
41
+ koa.body = kept.get(path)
42
+ }
43
+ }
44
+ }
45
+
46
+ const picture = (colour: number) => `data:image/webp;base64,${Buffer.from([0x52, 0x49, 0x46, 0x46, colour, colour, 1, 2]).toString('base64')}`
47
+ const bytesOf = (dataUrl: string) => Buffer.from(dataUrl.split(',')[1]!, 'base64')
48
+ const SOURCE = serializeFigureAsset(addFigureBox(createFigureAsset('Probe'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } }))
49
+
50
+ beforeAll(async () => {
51
+ Figure = loadCompiled('service/figure/figure').Figure
52
+ FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
53
+ FigureReplacedDraft = loadCompiled('service/figure/figure-replaced-draft').FigureReplacedDraft
54
+ Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
55
+ FigureMutation = loadCompiled('service/figure/figure-mutation').FigureMutation
56
+ ;({ keepThumbnail, sha256Of, useThumbnailFiles } = loadCompiled('service/figure/figure-thumbnail-store'))
57
+ ;({ moveBase64Thumbnails } = loadCompiled('service/figure/figure-thumbnail-upgrade'))
58
+ ;({ sendFigureThumbnail } = loadCompiled('routers/figure-thumbnail-router'))
59
+ dataSource = new TypeOrmDataSource({
60
+ type: 'sqlite',
61
+ database: ':memory:',
62
+ synchronize: true,
63
+ dropSchema: true,
64
+ namingStrategy: new shell.NamingStrategy(),
65
+ entities: [...shell.entities, ...authBase.entities, ...attachmentBase.entities, Figure, FigureHistory, FigureReplacedDraft],
66
+ subscribers: [Subscriber],
67
+ logging: false
68
+ })
69
+ await dataSource.initialize()
70
+ shell.addDataSource('default', dataSource)
71
+ shell.addDataSource('tx', dataSource)
72
+ })
73
+
74
+ afterAll(async () => {
75
+ shell.removeDataSource('default')
76
+ shell.removeDataSource('tx')
77
+ await dataSource?.destroy()
78
+ dataSource = null
79
+ })
80
+
81
+ let seq = 0
82
+ const newDomain = async (parent?: any) => {
83
+ const name = `thumb-${++seq}`
84
+ const saved = await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name, ...(parent ? { parent } : {}) })
85
+ return dataSource!.getRepository(shell.Domain).findOneOrFail({ where: { id: saved.id } })
86
+ }
87
+ const attachments = (domainId?: string) =>
88
+ dataSource!.getRepository(attachmentBase.Attachment).find({ where: { refType: 'figure-thumbnail', ...(domainId ? { domain: { id: domainId } } : {}) } })
89
+ function koa(ifNoneMatch?: string) {
90
+ const headers: Record<string, string> = {}
91
+ return {
92
+ status: 200,
93
+ type: '',
94
+ body: undefined as any,
95
+ headers,
96
+ set(k: string, v: string) {
97
+ headers[k] = v
98
+ },
99
+ get fresh() {
100
+ return !!ifNoneMatch && ifNoneMatch === headers['ETag']
101
+ }
102
+ }
103
+ }
104
+
105
+ describe('a picture kept by its content', () => {
106
+ it('the same picture twice in a domain is one attachment; in another domain, its own', async () => {
107
+ const files = mapFiles()
108
+ const a = await newDomain(),
109
+ b = await newDomain()
110
+ const tx = dataSource!.manager
111
+ const one = await keepThumbnail(tx, a, undefined, picture(7), files)
112
+ expect(await keepThumbnail(tx, a, undefined, picture(7), files)).toBe(one)
113
+ expect(one).toBe(sha256Of(bytesOf(picture(7))))
114
+ expect((await attachments(a.id)).length).toBe(1)
115
+ await keepThumbnail(tx, b, undefined, picture(7), files)
116
+ expect((await attachments(b.id)).length).toBe(1)
117
+ await expect(keepThumbnail(tx, a, undefined, 'not a picture', files)).rejects.toThrow(/data URL/)
118
+ })
119
+
120
+ it('a save names the picture by its hash and never writes the bytes onto the row', async () => {
121
+ const was = useThumbnailFiles(mapFiles())
122
+ const domain = await newDomain()
123
+ const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
124
+ const figure = await dataSource!.getRepository(Figure).save({ type: 'SAVED', name: 'saved', source: SOURCE, state: 'draft', version: 0, domain })
125
+ await new FigureMutation().updateFigure(figure.id, { thumbnail: picture(9) }, context)
126
+ const row = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
127
+ expect([row.thumbnail, row.thumbnailHash]).toEqual([null, sha256Of(bytesOf(picture(9)))])
128
+ expect(row.thumbnailUpdatedAt).toBeTruthy()
129
+ useThumbnailFiles(was)
130
+ })
131
+ })
132
+
133
+ describe('served behind the permission, from the figure\'s own domain', () => {
134
+ it('an inherited figure\'s picture is the parent\'s, even when the child holds the same hash elsewhere', async () => {
135
+ const files = mapFiles()
136
+ const parent = await newDomain()
137
+ const child = await newDomain(parent)
138
+ const hash = await keepThumbnail(dataSource!.manager, parent, undefined, picture(3), files)
139
+ const figure = await dataSource!.getRepository(Figure).save({ type: 'INHERITED', name: 'x', source: SOURCE, state: 'released', version: 1, thumbnailHash: hash, thumbnailUpdatedAt: new Date(), domain: parent })
140
+ /* The child holds an attachment under the same hash, pointing at other bytes: it must not be picked. */
141
+ files.kept.set('child-copy.webp', Buffer.from('not the parent picture'))
142
+ await dataSource!.getRepository(attachmentBase.Attachment).save({ domain: child, name: 'x', mimetype: 'image/webp', encoding: 'binary', refType: 'figure-thumbnail', refBy: hash, category: 'image', size: '1', path: 'child-copy.webp' })
143
+ const ctx = koa()
144
+ await sendFigureThumbnail(ctx, async () => {}, figure.id, child, files)
145
+ expect(ctx.status).toBe(200)
146
+ expect(Buffer.compare(ctx.body, bytesOf(picture(3)))).toBe(0)
147
+ expect(ctx.headers['Cache-Control']).toBe('private, max-age=31536000')
148
+ expect(ctx.headers['ETag']).toBe(`"${hash}"`)
149
+ const again = koa(`"${hash}"`)
150
+ await sendFigureThumbnail(again, async () => {}, figure.id, child, files)
151
+ expect([again.status, again.body]).toEqual([304, undefined])
152
+ })
153
+
154
+ it('a figure with no picture, or of a domain the caller cannot read, is 404', async () => {
155
+ const files = mapFiles()
156
+ const mine = await newDomain(),
157
+ other = await newDomain()
158
+ const bare = await dataSource!.getRepository(Figure).save({ type: 'BARE', name: 'x', source: SOURCE, state: 'draft', version: 0, domain: mine })
159
+ const ctx = koa()
160
+ await sendFigureThumbnail(ctx, async () => {}, bare.id, mine, files)
161
+ expect(ctx.status).toBe(404)
162
+ const hash = await keepThumbnail(dataSource!.manager, other, undefined, picture(5), files)
163
+ const theirs = await dataSource!.getRepository(Figure).save({ type: 'THEIRS', name: 'x', source: SOURCE, state: 'draft', version: 0, thumbnailHash: hash, domain: other })
164
+ const denied = koa()
165
+ await sendFigureThumbnail(denied, async () => {}, theirs.id, mine, files)
166
+ expect(denied.status).toBe(404)
167
+ })
168
+ })
169
+
170
+ /*
171
+ * The upgrade, for every shape an install may hold: a figure with base64 only, its releases (with a domain, and an old
172
+ * one without), a draft a revert kept, a row with no picture, and a picture that is not a data URL. Run twice.
173
+ */
174
+ describe('the base64 an install holds, moved at start without losing one', () => {
175
+ it('every shape moves once; what cannot move keeps its base64; a second start changes nothing', async () => {
176
+ const files = mapFiles()
177
+ const domain = await newDomain()
178
+ const figures = dataSource!.getRepository(Figure)
179
+ const histories = dataSource!.getRepository(FigureHistory)
180
+ /* Old rows are written as an install wrote them: straight to the table, no subscriber. */
181
+ const insert = (entity: any, values: any) => dataSource!.createQueryBuilder().insert().into(entity).values(values).callListeners(false).execute()
182
+ const figureId = '11111111-1111-4111-8111-111111111111'
183
+ await insert(Figure, { id: figureId, type: 'OLD', name: 'old', source: SOURCE, state: 'draft', version: 2, thumbnail: picture(1), domain: { id: domain.id } })
184
+ await insert(FigureHistory, { originalId: figureId, version: 1, type: 'OLD', state: 'released', thumbnail: picture(2), domain: { id: domain.id }, action: 'updated' })
185
+ await insert(FigureHistory, { originalId: figureId, version: 2, type: 'OLD', state: 'released', thumbnail: picture(1), action: 'updated' })
186
+ await insert(FigureReplacedDraft, { figureId, kind: 'revert', requestId: 'r-1', source: SOURCE, thumbnail: picture(4), state: 'draft', version: 2, writtenSource: SOURCE, writtenSourceHash: 'x', domain: { id: domain.id } })
187
+ await insert(Figure, { type: 'NONE', name: 'none', source: SOURCE, state: 'draft', version: 0, domain: { id: domain.id } })
188
+ await insert(Figure, { type: 'BROKEN', name: 'broken', source: SOURCE, state: 'draft', version: 0, thumbnail: 'garbage', domain: { id: domain.id } })
189
+
190
+ const first = await moveBase64Thumbnails(dataSource, files)
191
+ expect(first).toEqual([
192
+ { table: 'figure', moved: 1, kept: 1 },
193
+ { table: 'figure_history', moved: 2, kept: 0 },
194
+ { table: 'figure_replaced_draft', moved: 1, kept: 0 }
195
+ ])
196
+ const row = await figures.findOneOrFail({ where: { id: figureId } })
197
+ expect([row.thumbnail, row.thumbnailHash]).toEqual([null, sha256Of(bytesOf(picture(1)))])
198
+ const releases = await histories.find({ where: { originalId: figureId }, order: { version: 'ASC' } })
199
+ expect(releases.map((r: any) => [r.thumbnail, r.thumbnailHash])).toEqual([
200
+ [null, sha256Of(bytesOf(picture(2)))],
201
+ [null, sha256Of(bytesOf(picture(1)))]
202
+ ])
203
+ const [kept] = await dataSource!.getRepository(FigureReplacedDraft).find()
204
+ expect([kept.thumbnail, kept.thumbnailHash]).toEqual([null, sha256Of(bytesOf(picture(4)))])
205
+ expect((await figures.findOneOrFail({ where: { type: 'BROKEN' } })).thumbnail).toBe('garbage')
206
+ expect((await figures.findOneOrFail({ where: { type: 'NONE' } })).thumbnailHash).toBeNull()
207
+ /* Three contents, three attachments: the figure and its second release share one. */
208
+ expect((await attachments(domain.id)).length).toBe(3)
209
+
210
+ const second = await moveBase64Thumbnails(dataSource, files)
211
+ expect(second).toEqual([
212
+ { table: 'figure', moved: 0, kept: 1 },
213
+ { table: 'figure_history', moved: 0, kept: 0 },
214
+ { table: 'figure_replaced_draft', moved: 0, kept: 0 }
215
+ ])
216
+ expect((await attachments(domain.id)).length).toBe(3)
217
+ })
218
+ })