@things-factory/figure-service 10.1.111 → 10.1.113

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 (49) hide show
  1. package/dist-server/index.d.ts +1 -0
  2. package/dist-server/index.js +17 -0
  3. package/dist-server/index.js.map +1 -1
  4. package/dist-server/routers/figure-thumbnail-router.d.ts +12 -3
  5. package/dist-server/routers/figure-thumbnail-router.js +44 -31
  6. package/dist-server/routers/figure-thumbnail-router.js.map +1 -1
  7. package/dist-server/service/figure/figure-history.d.ts +1 -0
  8. package/dist-server/service/figure/figure-history.js +5 -0
  9. package/dist-server/service/figure/figure-history.js.map +1 -1
  10. package/dist-server/service/figure/figure-mutation.js +11 -8
  11. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  12. package/dist-server/service/figure/figure-replaced-draft.d.ts +1 -0
  13. package/dist-server/service/figure/figure-replaced-draft.js +5 -0
  14. package/dist-server/service/figure/figure-replaced-draft.js.map +1 -1
  15. package/dist-server/service/figure/figure-revert.js +9 -4
  16. package/dist-server/service/figure/figure-revert.js.map +1 -1
  17. package/dist-server/service/figure/figure-thumbnail-reader.d.ts +7 -0
  18. package/dist-server/service/figure/figure-thumbnail-reader.js +21 -0
  19. package/dist-server/service/figure/figure-thumbnail-reader.js.map +1 -0
  20. package/dist-server/service/figure/figure-thumbnail-store.d.ts +35 -0
  21. package/dist-server/service/figure/figure-thumbnail-store.js +96 -0
  22. package/dist-server/service/figure/figure-thumbnail-store.js.map +1 -0
  23. package/dist-server/service/figure/figure-thumbnail-upgrade.d.ts +8 -0
  24. package/dist-server/service/figure/figure-thumbnail-upgrade.js +56 -0
  25. package/dist-server/service/figure/figure-thumbnail-upgrade.js.map +1 -0
  26. package/dist-server/service/figure/figure-write.d.ts +14 -10
  27. package/dist-server/service/figure/figure-write.js +9 -4
  28. package/dist-server/service/figure/figure-write.js.map +1 -1
  29. package/dist-server/service/figure/figure.d.ts +1 -0
  30. package/dist-server/service/figure/figure.js +5 -0
  31. package/dist-server/service/figure/figure.js.map +1 -1
  32. package/dist-server/service/figure/index.d.ts +1 -1
  33. package/dist-server/service/index.d.ts +1 -1
  34. package/dist-server/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +4 -3
  36. package/server/index.ts +17 -0
  37. package/server/routers/figure-thumbnail-router.ts +48 -37
  38. package/server/service/figure/figure-history.ts +9 -0
  39. package/server/service/figure/figure-mutation.ts +11 -9
  40. package/server/service/figure/figure-replaced-draft.ts +9 -0
  41. package/server/service/figure/figure-revert-db.test.ts +6 -5
  42. package/server/service/figure/figure-revert.ts +9 -4
  43. package/server/service/figure/figure-thumbnail-db.test.ts +236 -0
  44. package/server/service/figure/figure-thumbnail-reader.ts +20 -0
  45. package/server/service/figure/figure-thumbnail-store.ts +108 -0
  46. package/server/service/figure/figure-thumbnail-upgrade.pg.test.ts +93 -0
  47. package/server/service/figure/figure-thumbnail-upgrade.ts +70 -0
  48. package/server/service/figure/figure-write.ts +15 -4
  49. package/server/service/figure/figure.ts +9 -0
@@ -0,0 +1,108 @@
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
+ }
108
+
@@ -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,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
  }
@@ -176,6 +176,15 @@ export class Figure {
176
176
  @Field({ nullable: true, description: 'A base64 encoded thumbnail image of the figure.' })
177
177
  thumbnail?: string
178
178
 
179
+ /*
180
+ * 그림의 내용 해시(SHA-256). 그림 자체는 attachment-base 가 갖고(refType `figure-thumbnail`, refBy 이 해시),
181
+ * `/figure-thumbnail/:id` 가 권한을 본 뒤 내준다(ADR-0104 결정 6). `thumbnail` 칸은 옛 base64 가 남은 행을
182
+ * 기동 때 옮기기 위한 칸이다 — 옮긴 뒤에는 비어 있고, 새 그림은 그 칸에 쓰지 않는다.
183
+ */
184
+ @Column({ nullable: true })
185
+ @Field({ nullable: true, description: 'SHA-256 of the thumbnail picture; the picture is served by /figure-thumbnail/:id.' })
186
+ thumbnailHash?: string
187
+
179
188
  /**
180
189
  * 썸네일이 **썸네일로서** 마지막으로 갱신된 시각. `updatedAt` 과는 별개 도메인이다 —
181
190
  * 이름·설명만 고쳤을 때는 갱신되지 않는다. `Board` 와 같은 방식.