@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
|
@@ -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,
|
|
64
|
-
await dataSource!.getRepository(FigureHistory).save({ originalId: figure.id, version: 1, type: 'REVERT_PROBE', name: 'probe', source: V1, state: 'released',
|
|
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
|
-
|
|
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.
|
|
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)).
|
|
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
|
-
|
|
105
|
-
|
|
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:
|
|
162
|
-
|
|
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(
|
|
58
|
-
|
|
59
|
-
|
|
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
|
}
|