@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
@@ -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
+ })
@@ -0,0 +1,107 @@
1
+ import { createHash } from 'crypto'
2
+ import { Readable } from 'stream'
3
+
4
+ import { Attachment, STORAGE } from '@things-factory/attachment-base'
5
+
6
+ /*
7
+ * A figure's picture, kept by attachment-base and named by its content (ADR-0104 decision 6, chief architect's ruling
8
+ * 2026-10-02).
9
+ *
10
+ * stored by attachment-base's storage (file, S3, database -- whichever the install configures), one attachment
11
+ * per (domain, content): refType `figure-thumbnail`, refBy the SHA-256 of the bytes. A figure, its
12
+ * releases and the drafts a revert kept carry that hash, never the bytes.
13
+ * served only through `/figure-thumbnail/:id`, behind the figure query permission -- never through attachment-
14
+ * base's public `/attachment/:path`, which checks nothing (a known gap, recorded in ADR-0104).
15
+ * the same content twice in one domain is one attachment, read by its hash. Two writers that both find none both
16
+ * write one; either serves the same bytes, so the result is the same (ruling, condition 2).
17
+ *
18
+ * The file is written before the row that names it: a file cannot be rolled back, so it goes first, and a row that
19
+ * fails to commit leaves a file the next write of the same content writes again.
20
+ */
21
+
22
+ export const THUMBNAIL_REF_TYPE = 'figure-thumbnail'
23
+
24
+ /** `data:image/webp;base64,....` as its type and bytes; undefined when it is not one. */
25
+ export function decodeDataUrl(dataUrl: string): { type: string; bytes: Buffer } | undefined {
26
+ const matched = /^data:([^;,]+);base64,(.*)$/s.exec(dataUrl)
27
+ return matched ? { type: matched[1]!, bytes: Buffer.from(matched[2]!, 'base64') } : undefined
28
+ }
29
+
30
+ export const sha256Of = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex')
31
+
32
+ /** Where the bytes go: attachment-base's storage. Tests give another. */
33
+ export interface ThumbnailFiles {
34
+ put(bytes: Buffer, mimetype: string, name: string, context: any): Promise<{ id: string; path: string; size: number; contents?: Buffer }>
35
+ send(koa: any, path: string, next: () => Promise<any>): Promise<void>
36
+ }
37
+
38
+ export const attachmentFiles: ThumbnailFiles = {
39
+ async put(bytes, mimetype, name, context) {
40
+ const file = Promise.resolve({
41
+ createReadStream: () => Readable.from([bytes]),
42
+ filename: name,
43
+ mimetype,
44
+ encoding: 'binary'
45
+ })
46
+ const { id, path, size, contents } = await STORAGE.uploadFile({ file, context })
47
+ return { id, path, size, contents }
48
+ },
49
+ send: (koa, path, next) => STORAGE.sendFile(koa, path, next)
50
+ }
51
+
52
+ /*
53
+ * The files in use: attachment-base's storage, unless a test gives its own. One seam for every writer and the router,
54
+ * so a test of a save or a revert need not reach into each.
55
+ */
56
+ let inUse: ThumbnailFiles = attachmentFiles
57
+ export const thumbnailFiles = (): ThumbnailFiles => inUse
58
+ /** For tests: use these files until set back; returns the ones it replaced. */
59
+ export function useThumbnailFiles(files: ThumbnailFiles): ThumbnailFiles {
60
+ const was = inUse
61
+ inUse = files
62
+ return was
63
+ }
64
+
65
+ type Tx = { getRepository: (target: any) => any }
66
+
67
+ /** The attachment holding this content in this domain, or undefined. */
68
+ export async function thumbnailAttachmentOf(tx: Tx, domainId: string, hash: string): Promise<Attachment | undefined> {
69
+ return (await tx.getRepository(Attachment).findOne({ where: { domain: { id: domainId }, refType: THUMBNAIL_REF_TYPE, refBy: hash } })) ?? undefined
70
+ }
71
+
72
+ /**
73
+ * Keeps a picture and gives its hash. Refused when it is not a data URL -- a picture the server cannot read is one it
74
+ * must not keep under a name.
75
+ */
76
+ export async function keepThumbnail(
77
+ tx: Tx,
78
+ domain: { id: string },
79
+ user: unknown,
80
+ dataUrl: string,
81
+ files: ThumbnailFiles = thumbnailFiles()
82
+ ): Promise<string> {
83
+ const decoded = decodeDataUrl(dataUrl)
84
+ if (!decoded) throw new Error('썸네일은 data URL(data:image/...;base64,...) 이어야 한다')
85
+ const hash = sha256Of(decoded.bytes)
86
+ if (await thumbnailAttachmentOf(tx, domain.id, hash)) return hash
87
+ const ext = decoded.type.split('/')[1]?.split('+')[0] || 'bin'
88
+ const name = `${hash}.${ext}`
89
+ /* The file first: it cannot be rolled back. The row that names it comes after, in the caller's transaction. */
90
+ const put = await files.put(decoded.bytes, decoded.type, name, { state: { domain, user, tx } })
91
+ await tx.getRepository(Attachment).save({
92
+ domain,
93
+ creator: user,
94
+ updater: user,
95
+ id: put.id,
96
+ name,
97
+ mimetype: decoded.type,
98
+ encoding: 'binary',
99
+ refType: THUMBNAIL_REF_TYPE,
100
+ refBy: hash,
101
+ category: 'image',
102
+ size: String(put.size),
103
+ path: put.path,
104
+ ...(put.contents ? { contents: put.contents } : {})
105
+ })
106
+ return hash
107
+ }
@@ -0,0 +1,93 @@
1
+ import { join } from 'path'
2
+
3
+ /**
4
+ * Two starts moving the same install's pictures at once, on postgres (chief architect's ruling 2026-10-02, condition 2:
5
+ * the same hash written twice must come to the same result). sqlite holds one connection and cannot show this. Runs
6
+ * only when FIGURE_PG_URL names a server:
7
+ *
8
+ * docker run -d --name fig-revert-pg -e POSTGRES_PASSWORD=figrv -e POSTGRES_USER=figrv -e POSTGRES_DB=figrv \
9
+ * -p 55441:5432 postgres:14-alpine
10
+ * yarn workspace @things-factory/figure-service build:server
11
+ * FIGURE_PG_URL=postgres://figrv:figrv@localhost:55441/figrv \
12
+ * node_modules/.bin/jest --selectProjects figure-service-ts --coverage=false figure-thumbnail-upgrade
13
+ *
14
+ * Every round must end with every picture moved exactly as it was: each row names its own content's hash, no base64
15
+ * is left, and every hash has an attachment holding those bytes. A row emptied without its hash, or a hash with no
16
+ * file, is the defect.
17
+ */
18
+
19
+ const url = process.env.FIGURE_PG_URL
20
+ const maybe = url ? describe : describe.skip
21
+ const ROUNDS = 6
22
+ const ROWS = 24
23
+
24
+ maybe('two starts moving pictures at once, on postgres', () => {
25
+ /*
26
+ * attachment-base picks its `contents` column type from `ormconfig.type` when it is loaded; the test process has no
27
+ * ormconfig, which reads as sqlite's blob. This file alone sees postgres, before anything loads attachment-base.
28
+ */
29
+ jest.doMock('@things-factory/env', () => {
30
+ const real = jest.requireActual('@things-factory/env')
31
+ return { ...real, config: { ...real.config, get: (key: string, fallback?: unknown) => (key === 'ormconfig' ? { type: 'postgres' } : real.config.get(key, fallback)) } }
32
+ })
33
+ const { DataSource } = require('typeorm')
34
+ const { entityClosure } = require(join(__dirname, '..', '..', '..', '..', 'ops-master', 'test', 'support', 'database.cjs'))
35
+ const { NamingStrategy } = require('@things-factory/shell/dist-server/initializers/naming-strategy.js')
36
+ const { Domain } = require('@things-factory/shell')
37
+ const { Attachment } = require('@things-factory/attachment-base')
38
+ const DIST = join(__dirname, '..', '..', '..', 'dist-server', 'service', 'figure')
39
+ const { Figure } = require(join(DIST, 'figure.js'))
40
+ const { FigureHistory } = require(join(DIST, 'figure-history.js'))
41
+ const { FigureReplacedDraft } = require(join(DIST, 'figure-replaced-draft.js'))
42
+ const { moveBase64Thumbnails } = require(join(DIST, 'figure-thumbnail-upgrade.js'))
43
+ const { sha256Of } = require(join(DIST, 'figure-thumbnail-store.js'))
44
+
45
+ const kept = new Map<string, Buffer>()
46
+ let n = 0
47
+ const files = {
48
+ async put(bytes: Buffer, _m: string, name: string) {
49
+ const id = require('crypto').randomUUID()
50
+ const path = `${id}.${name.split('.').pop()}`
51
+ kept.set(path, bytes)
52
+ n++
53
+ return { id, path, size: bytes.length }
54
+ },
55
+ async send() {}
56
+ }
57
+ const open = async (dropSchema: boolean) => {
58
+ const db = new DataSource({ type: 'postgres', url, synchronize: true, dropSchema, entities: entityClosure([Figure, FigureHistory, FigureReplacedDraft, Attachment]), namingStrategy: new NamingStrategy() })
59
+ await db.initialize()
60
+ return db
61
+ }
62
+ let one: any, two: any
63
+ beforeAll(async () => {
64
+ one = await open(true)
65
+ two = await open(false)
66
+ }, 60000)
67
+ afterAll(async () => {
68
+ await one?.destroy()
69
+ await two?.destroy()
70
+ })
71
+
72
+ const picture = (k: number) => `data:image/webp;base64,${Buffer.from([0x52, 0x49, k % 7, k % 5, 9]).toString('base64')}`
73
+
74
+ it('every round: each row moved once, to its own hash, with its bytes kept', async () => {
75
+ for (let round = 0; round < ROUNDS; round++) {
76
+ const domain = await one.getRepository(Domain).save({ name: `pg-${round}`, subdomain: `pg-${round}` })
77
+ const ids: string[] = []
78
+ for (let k = 0; k < ROWS; k++) {
79
+ const r = await one.createQueryBuilder().insert().into(Figure).values({ type: `T${round}_${k}`, name: 'x', source: '{}', state: 'draft', version: 0, thumbnail: picture(k), domain: { id: domain.id } }).callListeners(false).execute()
80
+ ids.push(r.identifiers[0].id)
81
+ }
82
+ await Promise.all([moveBase64Thumbnails(one, files), moveBase64Thumbnails(two, files)])
83
+ for (const [k, id] of ids.entries()) {
84
+ const row = await one.getRepository(Figure).findOneOrFail({ where: { id } })
85
+ const want = sha256Of(Buffer.from(picture(k).split(',')[1], 'base64'))
86
+ expect([row.thumbnail, row.thumbnailHash]).toEqual([null, want])
87
+ const holders = await one.getRepository(Attachment).find({ where: { domain: { id: domain.id }, refType: 'figure-thumbnail', refBy: want } })
88
+ expect(holders.length).toBeGreaterThan(0)
89
+ for (const h of holders) expect(sha256Of(kept.get(h.path)!)).toBe(want)
90
+ }
91
+ }
92
+ }, 120000)
93
+ })
@@ -0,0 +1,70 @@
1
+ import { IsNull, Not } from 'typeorm'
2
+
3
+ import { Figure } from './figure.js'
4
+ import { FigureHistory } from './figure-history.js'
5
+ import { FigureReplacedDraft } from './figure-replaced-draft.js'
6
+ import { keepThumbnail, thumbnailFiles, type ThumbnailFiles } from './figure-thumbnail-store.js'
7
+
8
+ /*
9
+ * Moves the pictures an install kept as base64 into attachment-base, by their content (ADR-0104 decision 6; chief
10
+ * architect's ruling 2026-10-02, condition 3). Run at start, in the boot's one line (`inBootTurn`).
11
+ *
12
+ * per row the file first -- it cannot be rolled back -- then, in one transaction, the attachment row, the hash,
13
+ * and the base64 emptied. The update only takes a row whose base64 is still the one read, so two starts
14
+ * at once, or a save in between, never empty a picture that was not moved.
15
+ * again safe: a row already moved has no base64 and is not read; a picture already kept is found by its hash.
16
+ * kept a picture that is not a data URL, or a row whose domain cannot be found, keeps its base64 and is
17
+ * counted -- nothing is emptied that was not moved.
18
+ */
19
+
20
+ type Moved = { table: string; moved: number; kept: number }
21
+
22
+ const TABLES = [
23
+ { table: 'figure', entity: Figure },
24
+ { table: 'figure_history', entity: FigureHistory },
25
+ { table: 'figure_replaced_draft', entity: FigureReplacedDraft }
26
+ ] as const
27
+
28
+ export async function moveBase64Thumbnails(dataSource: any, files: ThumbnailFiles = thumbnailFiles()): Promise<Moved[]> {
29
+ const out: Moved[] = []
30
+ for (const { table, entity } of TABLES) {
31
+ const rows: any[] = await dataSource.getRepository(entity).find({
32
+ where: { thumbnail: Not(IsNull()) },
33
+ relations: { domain: true },
34
+ select: { id: true, thumbnail: true, domain: { id: true }, ...(entity === FigureHistory ? { originalId: true } : {}) },
35
+ withDeleted: true
36
+ })
37
+ let moved = 0,
38
+ kept = 0
39
+ for (const row of rows) {
40
+ /* A release written before releases carried their domain names it through the figure it belongs to. */
41
+ const domainId: string | undefined =
42
+ row.domain?.id ??
43
+ (entity === FigureHistory && row.originalId
44
+ ? (await dataSource.getRepository(Figure).findOne({ where: { id: row.originalId }, relations: { domain: true }, select: { id: true, domain: { id: true } }, withDeleted: true }))?.domain?.id
45
+ : undefined)
46
+ if (!domainId) {
47
+ kept++
48
+ continue
49
+ }
50
+ try {
51
+ await dataSource.transaction(async (tx: any) => {
52
+ const hash = await keepThumbnail(tx, { id: domainId }, undefined, row.thumbnail, files)
53
+ await tx
54
+ .createQueryBuilder()
55
+ .update(entity)
56
+ .set({ thumbnailHash: hash, thumbnail: null })
57
+ .where('id = :id AND thumbnail = :was', { id: row.id, was: row.thumbnail })
58
+ .callListeners(false)
59
+ .execute()
60
+ })
61
+ moved++
62
+ } catch (error) {
63
+ console.error(`[figure-service] ${table} ${row.id} 의 썸네일을 옮기지 못했다 — base64 를 그대로 둔다: ${(error as Error)?.message ?? error}`)
64
+ kept++
65
+ }
66
+ }
67
+ out.push({ table, moved, kept })
68
+ }
69
+ return out
70
+ }
@@ -1,5 +1,6 @@
1
1
  import { FigureReplacedDraft } from './figure-replaced-draft.js'
2
2
  import { Field, InputType, Int, ObjectType } from 'type-graphql'
3
+ import { GraphQLJSON } from 'graphql-scalars'
3
4
 
4
5
  import { Figure } from './figure.js'
5
6
 
@@ -61,6 +62,10 @@ export class FigurePatch {
61
62
  @Field({ nullable: true, description: 'The new description.' })
62
63
  description?: string
63
64
 
65
+ /* 언어별 표시 이름. 안 준 것은 그대로, `{}` 는 전부 지운다. */
66
+ @Field(type => GraphQLJSON, { nullable: true, description: 'Display names by language. {} clears them; omitting the field leaves them.' })
67
+ names?: Record<string, string>
68
+
64
69
  @Field({ nullable: true, description: 'The new classification code.' })
65
70
  category?: string
66
71
 
@@ -1,3 +1,4 @@
1
+ import { keepThumbnail, type ThumbnailFiles } from './figure-thumbnail-store.js'
1
2
  import { GraphQLError } from 'graphql'
2
3
  import { parseFigureAsset, serializeFigureAsset, compileFigureAsset, figureCostOf, figureScoreOf } from '@hatiolab/figure-model'
3
4
 
@@ -48,13 +49,23 @@ export function prepareFigureWrite(source: string, existing?: { source?: string
48
49
  }
49
50
 
50
51
  /**
51
- * The picture a card shows, when the modeller sent one.
52
+ * The picture a card shows, when the modeller sent one: kept by its content (`keepThumbnail`) and named on the row by
53
+ * its hash.
52
54
  *
53
55
  * Absent means the author saved without one — a tab in the background cannot draw, and the source matters more
54
56
  * than the picture, so the save goes on and the modeller fills it in next time. The timestamp moves only when
55
57
  * the picture itself is new, so renaming a figure does not make every card look freshly drawn.
56
58
  */
57
- export function pictureOf(thumbnail?: string, existing?: { thumbnail?: string }) {
58
- if (thumbnail === undefined || thumbnail === existing?.thumbnail) return {}
59
- return { thumbnail, thumbnailUpdatedAt: new Date() }
59
+ export async function pictureOf(
60
+ tx: { getRepository: (target: any) => any },
61
+ domain: { id: string },
62
+ user: unknown,
63
+ thumbnail?: string,
64
+ existing?: { thumbnailHash?: string | null },
65
+ files?: ThumbnailFiles
66
+ ): Promise<{ thumbnailHash?: string; thumbnail?: null; thumbnailUpdatedAt?: Date }> {
67
+ if (thumbnail === undefined) return {}
68
+ const thumbnailHash = await keepThumbnail(tx, domain, user, thumbnail, files)
69
+ if (thumbnailHash === existing?.thumbnailHash) return {}
70
+ return { thumbnailHash, thumbnail: null, thumbnailUpdatedAt: new Date() }
60
71
  }