@things-factory/figure-service 10.1.117 → 10.1.122

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 (62) hide show
  1. package/dist-server/routers/figure-package-router.d.ts +7 -0
  2. package/dist-server/routers/figure-package-router.js +48 -0
  3. package/dist-server/routers/figure-package-router.js.map +1 -0
  4. package/dist-server/routes.js +3 -0
  5. package/dist-server/routes.js.map +1 -1
  6. package/dist-server/service/figure/figure-history.d.ts +1 -0
  7. package/dist-server/service/figure/figure-history.js +5 -0
  8. package/dist-server/service/figure/figure-history.js.map +1 -1
  9. package/dist-server/service/figure/figure-mutation.js +17 -2
  10. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  11. package/dist-server/service/figure/figure-namespace.d.ts +17 -0
  12. package/dist-server/service/figure/figure-namespace.js +48 -0
  13. package/dist-server/service/figure/figure-namespace.js.map +1 -0
  14. package/dist-server/service/figure/figure-package-export.d.ts +26 -0
  15. package/dist-server/service/figure/figure-package-export.js +90 -0
  16. package/dist-server/service/figure/figure-package-export.js.map +1 -0
  17. package/dist-server/service/figure/figure-query.d.ts +8 -0
  18. package/dist-server/service/figure/figure-query.js +61 -0
  19. package/dist-server/service/figure/figure-query.js.map +1 -1
  20. package/dist-server/service/figure/figure-rename.js +1 -1
  21. package/dist-server/service/figure/figure-rename.js.map +1 -1
  22. package/dist-server/service/figure/figure-thumbnail-store.d.ts +2 -0
  23. package/dist-server/service/figure/figure-thumbnail-store.js +2 -1
  24. package/dist-server/service/figure/figure-thumbnail-store.js.map +1 -1
  25. package/dist-server/service/figure/figure.d.ts +5 -0
  26. package/dist-server/service/figure/figure.js +5 -0
  27. package/dist-server/service/figure/figure.js.map +1 -1
  28. package/dist-server/tsconfig.tsbuildinfo +1 -1
  29. package/package.json +8 -6
  30. package/scripts/rename-figures-to-full-names.mjs +211 -0
  31. package/server/routers/figure-package-router.ts +53 -0
  32. package/server/routes.ts +3 -0
  33. package/server/service/figure/figure-deprecation-db.test.ts +13 -6
  34. package/server/service/figure/figure-history.ts +4 -0
  35. package/server/service/figure/figure-mutation.ts +28 -4
  36. package/server/service/figure/figure-namespace-db.test.ts +181 -0
  37. package/server/service/figure/figure-namespace.ts +59 -0
  38. package/server/service/figure/figure-package-export.ts +116 -0
  39. package/server/service/figure/figure-proposal-db.test.ts +9 -2
  40. package/server/service/figure/figure-query.ts +74 -1
  41. package/server/service/figure/figure-release-record-db.test.ts +13 -6
  42. package/server/service/figure/figure-rename-db.test.ts +19 -11
  43. package/server/service/figure/figure-rename.test.ts +12 -12
  44. package/server/service/figure/figure-rename.ts +1 -1
  45. package/server/service/figure/figure-thumbnail-db.test.ts +3 -0
  46. package/server/service/figure/figure-thumbnail-store.ts +4 -1
  47. package/server/service/figure/figure.ts +8 -0
  48. package/translations/en.json +10 -1
  49. package/translations/ko.json +10 -1
  50. package/coverage/clover.xml +0 -6
  51. package/coverage/coverage-final.json +0 -1
  52. package/coverage/lcov-report/base.css +0 -224
  53. package/coverage/lcov-report/block-navigation.js +0 -87
  54. package/coverage/lcov-report/favicon.png +0 -0
  55. package/coverage/lcov-report/index.html +0 -101
  56. package/coverage/lcov-report/prettify.css +0 -1
  57. package/coverage/lcov-report/prettify.js +0 -2
  58. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  59. package/coverage/lcov-report/sorter.js +0 -210
  60. package/coverage/lcov.info +0 -0
  61. package/logs/.08636eb59927f12972f6774f5947c8507b3564c2-audit.json +0 -15
  62. package/logs/application-2026-09-14-23.log +0 -0
@@ -0,0 +1,59 @@
1
+ import { EntityManager, Not } from 'typeorm'
2
+
3
+ import { isFigureNamespace } from '@hatiolab/figure-model'
4
+ import { Domain, Refusal } from '@things-factory/shell'
5
+ import { Setting } from '@things-factory/setting-base'
6
+
7
+ import { Figure } from './figure.js'
8
+
9
+ /** The domain setting that names the namespace this domain's figures are published under (ADR-0114). */
10
+ export const FIGURE_NAMESPACE_SETTING = 'figure-namespace'
11
+
12
+ /**
13
+ * The namespace a new figure is created under.
14
+ *
15
+ * It is read from the domain setting and copied onto the figure, so the full name `{namespace}/{type}` a board stores
16
+ * never moves. The setting is set once: a value that differs from the namespace this domain's figures already carry
17
+ * is refused, so editing it on the settings screen cannot rename anything.
18
+ */
19
+ export async function namespaceForNewFigure(tx: EntityManager, domain: Domain): Promise<string> {
20
+ const setting = await tx.getRepository(Setting).findOne({
21
+ where: { domain: { id: domain.id }, name: FIGURE_NAMESPACE_SETTING }
22
+ })
23
+ const namespace = setting?.value?.trim() ?? ''
24
+ if (!isFigureNamespace(namespace)) {
25
+ throw new Refusal(
26
+ 'figure-namespace-missing',
27
+ { setting: FIGURE_NAMESPACE_SETTING, value: namespace },
28
+ 'this domain has no valid figure namespace. set {setting} first, for example hatiolab.com.'
29
+ )
30
+ }
31
+ await assertNamespaceUnchanged(tx, domain, namespace)
32
+ return namespace
33
+ }
34
+
35
+ /** Refuses when the domain's figures already carry another namespace than the setting now says. */
36
+ export async function assertNamespaceUnchanged(tx: EntityManager, domain: Domain, namespace: string): Promise<void> {
37
+ const other = await tx.getRepository(Figure).findOne({
38
+ where: { domain: { id: domain.id }, namespace: Not(namespace) }
39
+ })
40
+ if (other?.namespace) {
41
+ throw new Refusal(
42
+ 'figure-namespace-changed',
43
+ { setting: FIGURE_NAMESPACE_SETTING, value: namespace, namespace: other.namespace },
44
+ 'the figure namespace of this domain is {namespace} and cannot change. set {setting} back to {namespace}.'
45
+ )
46
+ }
47
+ }
48
+
49
+ /** The namespace a figure already carries, refused when it has none (a figure made before namespaces). */
50
+ export function namespaceOf(figure: Figure): string {
51
+ if (!figure.namespace) {
52
+ throw new Refusal(
53
+ 'figure-namespace-missing',
54
+ { setting: FIGURE_NAMESPACE_SETTING, value: '' },
55
+ 'this figure has no namespace. run the figure name migration first.'
56
+ )
57
+ }
58
+ return figure.namespace
59
+ }
@@ -0,0 +1,116 @@
1
+ import JSZip from 'jszip'
2
+ import { In } from 'typeorm'
3
+
4
+ import {
5
+ formatFigureRef,
6
+ parseFigureRef,
7
+ writeFigurePackage,
8
+ type FigurePackageBlobFile,
9
+ type FigurePackageRelease,
10
+ type FigureRef
11
+ } from '@hatiolab/figure-model'
12
+ import { getRepository, Refusal } from '@things-factory/shell'
13
+
14
+ import { Figure } from './figure.js'
15
+ import { FigureHistory } from './figure-history.js'
16
+ import { sha256Of, thumbnailAttachmentOf, thumbnailFiles, type ThumbnailFiles } from './figure-thumbnail-store.js'
17
+
18
+ /** The most releases one package carries. */
19
+ export const FIGURE_PACKAGE_MAX = 200
20
+
21
+ export interface FigurePackageRequest {
22
+ readonly refs: readonly string[]
23
+ readonly domain: { id: string; parentId?: string; subdomain?: string }
24
+ readonly exportedBy: string | null
25
+ /** The address a board server can get these releases from again (`figure.publicUrl`). */
26
+ readonly sourceUrl: string
27
+ readonly now?: Date
28
+ readonly files?: ThumbnailFiles
29
+ }
30
+
31
+ /**
32
+ * Released figures as one package file a board server imports (ADR-0114 결정 3·16).
33
+ *
34
+ * Only released versions travel, read from the release history -- the draft being edited is never exported. The
35
+ * figures are the ones this domain can read: its own and its parent's, the scope of `figureBlueprint`.
36
+ */
37
+ export async function figurePackageOf(request: FigurePackageRequest): Promise<{ name: string; bytes: Buffer }> {
38
+ const refs = [...new Set(request.refs)]
39
+ if (refs.length === 0 || refs.length > FIGURE_PACKAGE_MAX) {
40
+ throw new Refusal('figure-package-size', { count: refs.length, max: FIGURE_PACKAGE_MAX }, 'a package carries 1 to {max} releases, not {count}.')
41
+ }
42
+
43
+ const files = request.files ?? thumbnailFiles()
44
+ const releases: FigurePackageRelease[] = []
45
+ const blobs = new Map<string, FigurePackageBlobFile>()
46
+
47
+ for (const text of refs) {
48
+ const ref = parseFigureRef(text)
49
+ if (!ref) throw new Refusal('figure-ref-malformed', { ref: text }, '{ref} is not a release reference of the form namespace/type@version.')
50
+ const { figure, history } = await releaseOf(ref, request.domain)
51
+ if (history.thumbnailHash && !blobs.has(history.thumbnailHash)) {
52
+ const picture = await thumbnailAttachmentOf({ getRepository }, figure.domainId!, history.thumbnailHash)
53
+ if (picture) {
54
+ const bytes = await files.read(picture.path)
55
+ if (sha256Of(bytes) === history.thumbnailHash) blobs.set(history.thumbnailHash, { bytes, contentType: picture.mimetype })
56
+ }
57
+ }
58
+ releases.push(packageReleaseOf(ref, figure, history, blobs))
59
+ }
60
+
61
+ const packed = writeFigurePackage(
62
+ {
63
+ source: { url: request.sourceUrl, domain: request.domain.subdomain ?? '' },
64
+ exportedAt: (request.now ?? new Date()).toISOString(),
65
+ exportedBy: request.exportedBy,
66
+ releases,
67
+ blobs
68
+ },
69
+ bytes => sha256Of(Buffer.from(bytes))
70
+ )
71
+
72
+ const zip = new JSZip()
73
+ for (const [path, bytes] of packed) zip.file(path, bytes, { createFolders: false })
74
+ const bytes = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
75
+ const name = refs.length === 1 ? `${refs[0]!.replace(/[/@]/g, '_')}.figpkg` : `figures-${refs.length}.figpkg`
76
+ return { name, bytes }
77
+ }
78
+
79
+ async function releaseOf(ref: FigureRef, domain: FigurePackageRequest['domain']): Promise<{ figure: Figure; history: FigureHistory }> {
80
+ const figure = await getRepository(Figure).findOne({
81
+ where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, type: ref.type, namespace: ref.namespace }
82
+ })
83
+ const history = figure
84
+ ? await getRepository(FigureHistory).findOne({ where: { originalId: figure.id, state: 'released', version: ref.version } })
85
+ : null
86
+ if (!figure || !history?.source) {
87
+ throw new Refusal('figure-package-release-not-found', { ref: formatFigureRef(ref) }, '{ref} is not a release this domain can read.')
88
+ }
89
+ return { figure, history }
90
+ }
91
+
92
+ function packageReleaseOf(
93
+ ref: FigureRef,
94
+ figure: Figure,
95
+ history: FigureHistory,
96
+ blobs: ReadonlyMap<string, FigurePackageBlobFile>
97
+ ): FigurePackageRelease {
98
+ const json = (text: string | null | undefined) => (text ? JSON.parse(text) : null)
99
+ return {
100
+ ref: formatFigureRef(ref),
101
+ name: history.name ?? figure.name ?? formatFigureRef(ref),
102
+ names: history.names ?? null,
103
+ description: history.description ?? null,
104
+ category: history.category ?? null,
105
+ source: history.source!,
106
+ assetFormat: history.assetFormat ?? json(history.source)?.version ?? '',
107
+ kernelVersion: history.kernelVersion ?? null,
108
+ metrics: json(history.metrics),
109
+ topView: json(history.topView),
110
+ changes: json(history.changes),
111
+ thumbnail: history.thumbnailHash && blobs.has(history.thumbnailHash) ? history.thumbnailHash : null,
112
+ deprecated: !!figure.deprecated,
113
+ supersededBy: figure.supersededBy && figure.namespace ? `${figure.namespace}/${figure.supersededBy}` : null,
114
+ releasedAt: new Date(history.createdAt ?? Date.now()).toISOString()
115
+ }
116
+ }
@@ -8,6 +8,7 @@ import type { DataSource } from 'typeorm'
8
8
  const { DataSource: TypeOrmDataSource } = require('typeorm')
9
9
  const shell = require('@things-factory/shell')
10
10
  const authBase = require('@things-factory/auth-base')
11
+ const settingBase = require('@things-factory/setting-base')
11
12
  const { createFigureAsset, addFigureBox, serializeFigureAsset } = require('@hatiolab/figure-model')
12
13
 
13
14
  let dataSource: DataSource | null = null
@@ -38,7 +39,7 @@ beforeAll(async () => {
38
39
  synchronize: true,
39
40
  dropSchema: true,
40
41
  namingStrategy: new shell.NamingStrategy(),
41
- entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory, FigureProposalRecord],
42
+ entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory, FigureProposalRecord],
42
43
  logging: false
43
44
  })
44
45
  await dataSource.initialize()
@@ -49,10 +50,16 @@ afterAll(async () => {
49
50
  dataSource = null
50
51
  })
51
52
 
53
+ /** The domain setting every figure here is created under (ADR-0114). */
54
+ async function namespaced(domain: any) {
55
+ await dataSource!.getRepository(settingBase.Setting).save({ domain, name: 'figure-namespace', category: 'figure', value: 'system' })
56
+ return domain
57
+ }
58
+
52
59
  let DOMAIN: { id: string }
53
60
  beforeEach(async () => {
54
61
  await dataSource!.synchronize(true)
55
- DOMAIN = { id: (await dataSource!.getRepository(shell.Domain).save({ name: 'proposal-probe', subdomain: 'proposal-probe' })).id }
62
+ DOMAIN = { id: (await namespaced(await dataSource!.getRepository(shell.Domain).save({ name: 'proposal-probe', subdomain: 'proposal-probe' }))).id }
56
63
  })
57
64
 
58
65
  const text = (name: string, height = 100) =>
@@ -8,8 +8,10 @@ import {
8
8
  getRepository,
9
9
  ListParam,
10
10
  Refusal,
11
- SpeakRefusals
11
+ SpeakRefusals,
12
+ refusalKey
12
13
  } from '@things-factory/shell'
14
+ import { formatFigureRef, parseFigureName, type FigureName } from '@hatiolab/figure-model'
13
15
 
14
16
  import { Figure } from './figure.js'
15
17
  import { FigureHistory } from './figure-history.js'
@@ -304,6 +306,77 @@ export class FigureQuery {
304
306
  )
305
307
  }
306
308
  }
309
+
310
+ /**
311
+ * Released figures a board asks for together, by full name `{namespace}/{type}` (ADR-0114 결정 14).
312
+ *
313
+ * One answer per name: an item, or a refusal that names why. A refusal never takes another item away -- the board
314
+ * draws what it got and a stand-in with the reason for the rest. This server keeps its policy: the latest release,
315
+ * shadowed by the child domain as `figureBlueprint` does. `release` is null; a type map is the board server's.
316
+ */
317
+ @Directive('@privilege(category: "figure-published", privilege: "query", domainOwnerGranted: true)')
318
+ @Query(returns => GraphQLJSON, {
319
+ description:
320
+ 'Released figures asked together by full name {namespace}/{type}: { release: null, items: [{ type, ref, asset }], refused: [{ type, code, message }] }. The latest release of each; one refusal never removes another item.'
321
+ })
322
+ async figureBlueprints(
323
+ @Arg('types', type => [String], { description: 'Full figure names {namespace}/{type}. At most 500.' }) types: string[],
324
+ @Ctx() context: ResolverContext
325
+ ): Promise<any> {
326
+ const { domain } = context.state
327
+ const names = [...new Set(types)]
328
+ if (names.length > FIGURE_BLUEPRINTS_MAX) {
329
+ throw new Refusal('figure-names-too-many', { count: names.length, max: FIGURE_BLUEPRINTS_MAX }, 'at most {max} figure names can be asked at once, not {count}.')
330
+ }
331
+
332
+ const items: { type: string; ref: string; asset: unknown }[] = []
333
+ const refused: { type: string; code: string; message: string }[] = []
334
+ /* Plain text: a board draws the reason on a canvas, so a name with `/` is not escaped. */
335
+ const refuse = (type: string, refusal: Refusal) =>
336
+ refused.push({
337
+ type,
338
+ code: refusal.code,
339
+ message: context.t
340
+ ? context.t(refusalKey(refusal.code), { ...refusal.params, defaultValue: refusal.message, interpolation: { escapeValue: false } })
341
+ : refusal.message
342
+ })
343
+
344
+ for (const type of names) {
345
+ const name = parseFigureName(type)
346
+ if (!name) {
347
+ refuse(type, new Refusal('figure-name-malformed', { type }, '{type} is not a full figure name of the form namespace/type.'))
348
+ continue
349
+ }
350
+ const found =
351
+ (await releasedSourceOfName(domain.id, name)) ?? (domain.parentId ? await releasedSourceOfName(domain.parentId, name) : null)
352
+ if (!found) {
353
+ refuse(type, new Refusal('figure-not-released', { type }, 'no release is published here.'))
354
+ continue
355
+ }
356
+ try {
357
+ items.push({ type, ref: formatFigureRef({ ...name, version: found.version }), asset: consumablePayloadOf(found.source).asset })
358
+ } catch {
359
+ refuse(
360
+ type,
361
+ new Refusal(
362
+ 'figure-release-does-not-compile',
363
+ { type, figureId: found.figure.id, version: found.version },
364
+ 'the released source of {type} does not compile. open it in the modeller and release it again.'
365
+ )
366
+ )
367
+ }
368
+ }
369
+
370
+ return { release: null, items, refused }
371
+ }
372
+ }
373
+
374
+ const FIGURE_BLUEPRINTS_MAX = 500
375
+
376
+ /** The released source of a full name in one domain: the type there, published under that namespace. */
377
+ async function releasedSourceOfName(domainId: string, name: FigureName) {
378
+ const found = await releasedSourceOf(domainId, name.type)
379
+ return found && found.figure.namespace === name.namespace ? found : null
307
380
  }
308
381
 
309
382
  /** One numbered release of a figure, or null when it has none by that number. */
@@ -7,6 +7,7 @@ import type { DataSource } from 'typeorm'
7
7
  const { DataSource: TypeOrmDataSource } = require('typeorm')
8
8
  const shell = require('@things-factory/shell')
9
9
  const authBase = require('@things-factory/auth-base')
10
+ const settingBase = require('@things-factory/setting-base')
10
11
  const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy, palletizerRobotFigure, FIGURE_KERNEL_VERSION } = require('@hatiolab/figure-model')
11
12
 
12
13
  let dataSource: DataSource | null = null
@@ -37,7 +38,7 @@ beforeAll(async () => {
37
38
  synchronize: true,
38
39
  dropSchema: true,
39
40
  namingStrategy: new shell.NamingStrategy(),
40
- entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory],
41
+ entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory],
41
42
  subscribers: [Subscriber],
42
43
  logging: false
43
44
  })
@@ -49,11 +50,17 @@ afterAll(async () => {
49
50
  dataSource = null
50
51
  })
51
52
 
53
+ /** The domain setting every figure here is created under (ADR-0114). */
54
+ async function namespaced(domain: any) {
55
+ await dataSource!.getRepository(settingBase.Setting).save({ domain, name: 'figure-namespace', category: 'figure', value: 'system' })
56
+ return domain
57
+ }
58
+
52
59
  const SOURCE = serializeFigureAsset(addFigureBox(createFigureAsset('Probe'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } }))
53
60
 
54
61
  async function release(extra: Record<string, unknown>) {
55
62
  const domain = await dataSource!.getRepository(shell.Domain).save({ name: `d-${Math.random()}`, subdomain: `d${Date.now()}${Math.random()}` })
56
- const figure = await dataSource!.getRepository(Figure).save({ type: 'RECORD_PROBE', name: 'probe', source: SOURCE, state: 'draft', version: 0, domain })
63
+ const figure = await dataSource!.getRepository(Figure).save({ namespace: 'system', type: 'RECORD_PROBE', name: 'probe', source: SOURCE, state: 'draft', version: 0, domain })
57
64
  await dataSource!.getRepository(Figure).save({ ...figure, domain, version: 1, state: 'released', __releaseComment: 'first', ...extra })
58
65
  return dataSource!.getRepository(FigureHistory).findOneOrFail({ where: { originalId: figure.id } })
59
66
  }
@@ -84,10 +91,10 @@ describe('releaseFigure records it', () => {
84
91
  return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a, { over: 'range' }), placement: 'floor' }))
85
92
  }
86
93
  it('first release: [], the second: the wider room', async () => {
87
- const domain = await dataSource!.getRepository(shell.Domain).save({ name: 'release-door', subdomain: 'release-door' })
94
+ const domain = await namespaced(await dataSource!.getRepository(shell.Domain).save({ name: 'release-door', subdomain: 'release-door' }))
88
95
  const tx = dataSource!.manager
89
96
  const context = { state: { domain, user: undefined, tx } }
90
- const figure = await dataSource!.getRepository(Figure).save({ type: 'RELEASE_DOOR', name: 'door', source: releasable(100), state: 'draft', version: 0, domain })
97
+ const figure = await dataSource!.getRepository(Figure).save({ namespace: 'system', type: 'RELEASE_DOOR', name: 'door', source: releasable(100), state: 'draft', version: 0, domain })
91
98
  await new FigureMutation().releaseFigure(figure.id, 'one', context)
92
99
  await dataSource!.getRepository(Figure).save({ ...(await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })), domain, source: releasable(300), state: 'draft' })
93
100
  await new FigureMutation().releaseFigure(figure.id, 'two', context)
@@ -116,9 +123,9 @@ describe('a release of a figure that moves keeps its joint list', () => {
116
123
  return serializeFigureAsset(applyFigureAuthoring(asset, { kind: 'set-joint', joint: { ...j4, axis: j4Axis } }))
117
124
  }
118
125
  it('schema 2, the wrist following -(j2 + j3), controls with both units; another axis is refused', async () => {
119
- const domain = await dataSource!.getRepository(shell.Domain).save({ name: 'release-joints', subdomain: 'release-joints' })
126
+ const domain = await namespaced(await dataSource!.getRepository(shell.Domain).save({ name: 'release-joints', subdomain: 'release-joints' }))
120
127
  const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
121
- const figure = await dataSource!.getRepository(Figure).save({ type: 'PALLETIZER', name: 'palletizer', source: palletizer(), state: 'draft', version: 0, domain })
128
+ const figure = await dataSource!.getRepository(Figure).save({ namespace: 'system', type: 'PALLETIZER', name: 'palletizer', source: palletizer(), state: 'draft', version: 0, domain })
122
129
  await new FigureMutation().releaseFigure(figure.id, 'one', context)
123
130
 
124
131
  const [row] = await dataSource!.getRepository(FigureHistory).find({ where: { originalId: figure.id } })
@@ -13,6 +13,7 @@ import type { DataSource } from 'typeorm'
13
13
  const { DataSource: TypeOrmDataSource } = require('typeorm')
14
14
  const shell = require('@things-factory/shell')
15
15
  const authBase = require('@things-factory/auth-base')
16
+ const settingBase = require('@things-factory/setting-base')
16
17
 
17
18
  let dataSource: DataSource | null = null
18
19
  let Figure: any
@@ -46,7 +47,7 @@ beforeAll(async () => {
46
47
  synchronize: true,
47
48
  dropSchema: true,
48
49
  namingStrategy: new shell.NamingStrategy(),
49
- entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory],
50
+ entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory],
50
51
  logging: false
51
52
  })
52
53
 
@@ -68,17 +69,24 @@ afterAll(async () => {
68
69
  dataSource = null
69
70
  })
70
71
 
72
+ /** The domain setting every figure here is created under (ADR-0114). */
73
+ async function namespaced(domain: any) {
74
+ await dataSource!.getRepository(settingBase.Setting).save({ domain, name: 'figure-namespace', category: 'figure', value: 'system' })
75
+ return domain
76
+ }
77
+
71
78
  let DOMAIN: { id: string }
72
79
 
73
80
  /* 도형이 도메인을 가리킨다 — 행이 없으면 외래키가 먼저 막는다. */
74
81
  async function seedDomain() {
75
- const saved = await dataSource!.getRepository(shell.Domain).save({ name: 'rename-probe', subdomain: 'rename-probe' })
82
+ const saved = await namespaced(await dataSource!.getRepository(shell.Domain).save({ name: 'rename-probe', subdomain: 'rename-probe' }))
76
83
  DOMAIN = { id: saved.id }
77
84
  }
78
85
 
79
86
  async function seedFigure(over: Record<string, unknown> = {}) {
80
87
  return await dataSource!.getRepository(Figure).save({
81
- type: 'FIGURE_MU6SZ5DL',
88
+ namespace: 'system',
89
+ type: 'figure-mu6sz5dl',
82
90
  name: '초안',
83
91
  source: '{}',
84
92
  state: 'draft',
@@ -100,7 +108,7 @@ describe('이름을 바꾸기 전에 읽는 사실', () => {
100
108
  it('★ 판본 질의가 실제로 돈다 — 칸 이름이 틀리면 여기서 던진다', async () => {
101
109
  const figure = await seedFigure()
102
110
 
103
- expect(await facts(figure, 'ROBOT_ARM')).toEqual({ version: 0, hasHistory: false, takenByAnother: false })
111
+ expect(await facts(figure, 'robot-arm')).toEqual({ version: 0, hasHistory: false, takenByAnother: false })
104
112
  })
105
113
 
106
114
  it('★ 판본이 있으면 그것을 읽어 온다', async () => {
@@ -114,14 +122,14 @@ describe('이름을 바꾸기 전에 읽는 사실', () => {
114
122
  domain: DOMAIN
115
123
  })
116
124
 
117
- expect((await facts(figure, 'ROBOT_ARM')).hasHistory).toBe(true)
125
+ expect((await facts(figure, 'robot-arm')).hasHistory).toBe(true)
118
126
  })
119
127
 
120
128
  it('같은 도메인에서 그 이름을 쓰는 다른 도형을 찾는다', async () => {
121
129
  const figure = await seedFigure()
122
- await seedFigure({ type: 'CONVEYOR', name: '컨베이어' })
130
+ await seedFigure({ type: 'conveyor', name: '컨베이어' })
123
131
 
124
- expect((await facts(figure, 'CONVEYOR')).takenByAnother).toBe(true)
132
+ expect((await facts(figure, 'conveyor')).takenByAnother).toBe(true)
125
133
  })
126
134
 
127
135
  it('자기 이름은 겹침이 아니다', async () => {
@@ -165,13 +173,13 @@ describe('처음 저장에 타입 이름을 받는다', () => {
165
173
  const ids = ['8a1f2c3d-4b5e-4f60-8a71-92b3c4d5e6f7', '9b2e3d4c-5a6f-4e71-9b82-a3c4d5e6f708']
166
174
 
167
175
  it('준 이름으로 만든다 — 앞뒤 빈칸은 뺀다', async () => {
168
- const saved = await save(ids[0], ' ROBOT_ARM ')
169
- expect(saved.type).toBe('ROBOT_ARM')
176
+ const saved = await save(ids[0], ' robot-arm ')
177
+ expect(saved.type).toBe('robot-arm')
170
178
  })
171
179
 
172
180
  it('★ 이미 쓰는 이름이면 저장하지 않는다 — 임시 이름으로 남기지 않는다', async () => {
173
- await seedFigure({ type: 'CONVEYOR', name: '컨베이어' })
174
- await expect(save(ids[1], 'CONVEYOR')).rejects.toThrow(/이미 쓰고 있다/)
181
+ await seedFigure({ type: 'conveyor', name: '컨베이어' })
182
+ await expect(save(ids[1], 'conveyor')).rejects.toThrow(/이미 쓰고 있다/)
175
183
  expect(await dataSource!.getRepository(Figure).count({ where: { id: ids[1] } })).toBe(0)
176
184
  })
177
185
 
@@ -12,7 +12,7 @@ import { createFigureAsset, parseFigureAsset, serializeFigureAsset } from '@hati
12
12
  import { FigureRenameRefused, renameDraftType } from './figure-rename'
13
13
 
14
14
  const draft = (over: Record<string, unknown> = {}) => ({
15
- type: 'FIGURE_MU6RJH47',
15
+ type: 'figure-mu6rjh47',
16
16
  source: serializeFigureAsset(createFigureAsset('새 Figure')),
17
17
  ...over
18
18
  })
@@ -26,26 +26,26 @@ describe('초안의 type 고치기', () => {
26
26
  */
27
27
  it('★ 행만 바뀌고, 원본은 그대로 다시 열린다', () => {
28
28
  const source = serializeFigureAsset(createFigureAsset('새 Figure'))
29
- const result = renameDraftType({ type: 'FIGURE_ABCDEF12', source }, 'ROLLER_CONVEYOR', never)
29
+ const result = renameDraftType({ type: 'figure-abcdef12', source }, 'roller-conveyor', never)
30
30
 
31
- expect(result.type).toBe('ROLLER_CONVEYOR')
31
+ expect(result.type).toBe('roller-conveyor')
32
32
  expect(result.source).toBe(source)
33
33
  expect(() => parseFigureAsset(result.source)).not.toThrow()
34
34
  })
35
35
 
36
36
  it('앞뒤 공백을 뗀다. 모양은 모델러가 묻는 규칙 그대로 — 대문자로 적지 않으면 거절한다(Q-D3, 2026-09-27)', () => {
37
- expect(renameDraftType(draft(), ' ROBOT_ARM ', never).type).toBe('ROBOT_ARM')
37
+ expect(renameDraftType(draft(), ' robot-arm ', never).type).toBe('robot-arm')
38
38
  /* It was kept as written; the modeller always sent it upper-cased, and every stored type already is (56/56). */
39
39
  expect(() => renameDraftType(draft(), 'Robot_Arm', never)).toThrow(expect.objectContaining({ code: 'malformed-type' }))
40
40
  })
41
41
 
42
42
  it('★ 한 번이라도 발행됐으면 거절한다 — 판 번호로', () => {
43
- expect(() => renameDraftType(draft(), 'ROBOT_ARM', { ...never, version: 1 })).toThrow(FigureRenameRefused)
43
+ expect(() => renameDraftType(draft(), 'robot-arm', { ...never, version: 1 })).toThrow(FigureRenameRefused)
44
44
  })
45
45
 
46
46
  it('★ 판 번호가 도로 0 이어도 이력이 있으면 거절한다 — 되돌리기가 그 자리를 만든다', () => {
47
47
  try {
48
- renameDraftType(draft(), 'ROBOT_ARM', { ...never, hasHistory: true })
48
+ renameDraftType(draft(), 'robot-arm', { ...never, hasHistory: true })
49
49
  throw new Error('거절하지 않았다')
50
50
  } catch (e) {
51
51
  expect((e as FigureRenameRefused).code).toBe('already-released')
@@ -54,11 +54,11 @@ describe('초안의 type 고치기', () => {
54
54
 
55
55
  it('같은 도메인에서 쓰는 이름이면 거절하고, 어느 이름인지 말한다', () => {
56
56
  try {
57
- renameDraftType(draft(), 'CONVEYOR', { ...never, takenByAnother: true })
57
+ renameDraftType(draft(), 'conveyor', { ...never, takenByAnother: true })
58
58
  throw new Error('거절하지 않았다')
59
59
  } catch (e) {
60
60
  expect((e as FigureRenameRefused).code).toBe('type-taken')
61
- expect((e as Error).message).toContain('CONVEYOR')
61
+ expect((e as Error).message).toContain('conveyor')
62
62
  }
63
63
  })
64
64
 
@@ -108,10 +108,10 @@ describe('문이 이 규칙을 지난다', () => {
108
108
 
109
109
  describe('a type being set keeps the shape the modeller asks for (Q-D3, designer 2026-09-27)', () => {
110
110
  const free = { version: 0, hasHistory: false, takenByAnother: false }
111
- it.each(['lower_case', 'X1', 'A'.repeat(33), '1ABC', 'AB-CD', 'AB__CD', '한글'])('refuses %s as malformed', wanted => {
112
- expect(() => renameDraftType({ type: 'OLD', source: '{}' }, wanted, free)).toThrow(expect.objectContaining({ code: 'malformed-type' }))
111
+ it.each(['UPPER', 'snake_case', 'x', 'a'.repeat(65), '1abc', 'ab--cd', 'ab-', '한글'])('refuses %s as malformed', wanted => {
112
+ expect(() => renameDraftType({ type: 'old', source: '{}' }, wanted, free)).toThrow(expect.objectContaining({ code: 'malformed-type' }))
113
113
  })
114
- it.each(['FIG_K3F9A2', 'CONVEYOR', 'LIFT_TABLE_2'])('takes %s', wanted => {
115
- expect(renameDraftType({ type: 'OLD', source: '{}' }, wanted, free).type).toBe(wanted)
114
+ it.each(['fig-k3f9a2', 'conveyor', 'lift-table-2'])('takes %s', wanted => {
115
+ expect(renameDraftType({ type: 'old', source: '{}' }, wanted, free).type).toBe(wanted)
116
116
  })
117
117
  })
@@ -65,7 +65,7 @@ export function renameDraftType(
65
65
  if (!TYPE_PATTERN.test(next) || next.length < TYPE_LENGTH.min || next.length > TYPE_LENGTH.max) {
66
66
  throw new FigureRenameRefused(
67
67
  'malformed-type',
68
- `type 「${next}」 는 대문자로 시작하고 대문자·숫자·밑줄만 쓰며 ${TYPE_LENGTH.min}~${TYPE_LENGTH.max}자여야 한다`
68
+ `type 「${next}」 는 소문자로 시작하고 소문자·숫자·하이픈 하나씩만 쓰며 ${TYPE_LENGTH.min}~${TYPE_LENGTH.max}자여야 한다`
69
69
  )
70
70
  }
71
71
 
@@ -42,6 +42,9 @@ function mapFiles() {
42
42
  koa.set('ETag', '"storage-md5"')
43
43
  koa.set('Cache-Control', 'public, max-age=31536000')
44
44
  koa.body = kept.get(path)
45
+ },
46
+ async read(path: string) {
47
+ return kept.get(path)!
45
48
  }
46
49
  }
47
50
  }
@@ -33,6 +33,8 @@ export const sha256Of = (bytes: Buffer) => createHash('sha256').update(bytes).di
33
33
  export interface ThumbnailFiles {
34
34
  put(bytes: Buffer, mimetype: string, name: string, context: any): Promise<{ id: string; path: string; size: number; contents?: Buffer }>
35
35
  send(koa: any, path: string, next: () => Promise<any>): Promise<void>
36
+ /** The bytes kept at a path -- what a figure package carries. */
37
+ read(path: string): Promise<Buffer>
36
38
  }
37
39
 
38
40
  export const attachmentFiles: ThumbnailFiles = {
@@ -46,7 +48,8 @@ export const attachmentFiles: ThumbnailFiles = {
46
48
  const { id, path, size, contents } = await STORAGE.uploadFile({ file, context })
47
49
  return { id, path, size, contents }
48
50
  },
49
- send: (koa, path, next) => STORAGE.sendFile(koa, path, next)
51
+ send: (koa, path, next) => STORAGE.sendFile(koa, path, next),
52
+ read: async path => Buffer.from(await STORAGE.readFile(path))
50
53
  }
51
54
 
52
55
  /*
@@ -77,6 +77,14 @@ export class Figure {
77
77
  @Field({ description: 'Component type name. A stored identifier — unique per domain and immutable once created.' })
78
78
  type?: string
79
79
 
80
+ /**
81
+ * The namespace the figure is published under, copied from the domain setting `figure-namespace` when the figure is
82
+ * made. With the type it forms the full name `{namespace}/{type}` boards store (ADR-0114). Never changes.
83
+ */
84
+ @Column({ nullable: true })
85
+ @Field({ nullable: true, description: 'Namespace of the full name {namespace}/{type}. Copied from the domain setting when created; never changes.' })
86
+ namespace?: string
87
+
80
88
  @Column()
81
89
  @Field({ description: 'Display name shown to authors. Safe to rename.' })
82
90
  name?: string
@@ -89,5 +89,14 @@
89
89
  "error.figure-release-not-found": "{type} has no release {version}. its latest release is {latest}.",
90
90
  "figure.gate.origin-off-centre.message": "The origin is off the middle of the declared room: {off}.",
91
91
  "figure.gate.origin-off-centre.why": "A drawing that places figures by the middle of their room puts this one that far from where it is drawn.",
92
- "figure.gate.origin-off-centre.how": "Keep it when the figure is fastened there, as a door at its hinge. Move the origin to the middle to place it by its room."
92
+ "figure.gate.origin-off-centre.how": "Keep it when the figure is fastened there, as a door at its hinge. Move the origin to the middle to place it by its room.",
93
+ "error.figure-namespace-missing": "this domain has no valid figure namespace ({value}). set {setting} first, for example hatiolab.com.",
94
+ "error.figure-namespace-changed": "the figure namespace of this domain is {namespace} and cannot change. set {setting} back to {namespace}.",
95
+ "error.figure-type-malformed": "{type} is not a figure type. use lower-case letters, digits and single hyphens, {min} to {max} characters, starting with a letter.",
96
+ "error.figure-name-malformed": "{type} is not a full figure name of the form namespace/type.",
97
+ "error.figure-not-released": "no release is published here.",
98
+ "error.figure-names-too-many": "at most {max} figure names can be asked at once, not {count}.",
99
+ "error.figure-package-size": "a package carries 1 to {max} releases, not {count}.",
100
+ "error.figure-ref-malformed": "{ref} is not a release reference of the form namespace/type@version.",
101
+ "error.figure-package-release-not-found": "{ref} is not a release this domain can read."
93
102
  }
@@ -89,5 +89,14 @@
89
89
  "error.figure-release-not-found": "{type} 에는 {version} 번 발행판이 없습니다. 최신 발행판은 {latest} 번입니다.",
90
90
  "figure.gate.origin-off-centre.message": "원점이 차지 공간 가운데에서 비켜 있습니다: {off}.",
91
91
  "figure.gate.origin-off-centre.why": "공간 가운데로 도형을 놓는 도면에서는 그린 자리에서 그만큼 떨어져 놓입니다.",
92
- "figure.gate.origin-off-centre.how": "문의 경첩처럼 붙이는 자리라면 그대로 둡니다. 공간 가운데로 놓이게 하려면 원점을 가운데로 옮깁니다."
92
+ "figure.gate.origin-off-centre.how": "문의 경첩처럼 붙이는 자리라면 그대로 둡니다. 공간 가운데로 놓이게 하려면 원점을 가운데로 옮깁니다.",
93
+ "error.figure-namespace-missing": "이 도메인에 Figure namespace 가 없거나 형식이 맞지 않습니다({value}). 설정 {setting} 에 예를 들어 hatiolab.com 을 먼저 넣어 주세요.",
94
+ "error.figure-namespace-changed": "이 도메인의 Figure namespace 는 {namespace} 이며 바꿀 수 없습니다. 설정 {setting} 을 {namespace} 로 되돌려 주세요.",
95
+ "error.figure-type-malformed": "{type} 은 Figure type 이 아닙니다. 소문자로 시작하고 소문자·숫자·하이픈 하나씩만 써서 {min}~{max}자로 정해 주세요.",
96
+ "error.figure-name-malformed": "{type} 은 namespace/type 형식의 정식 이름이 아닙니다.",
97
+ "error.figure-not-released": "이 도메인에 발행판이 없습니다.",
98
+ "error.figure-names-too-many": "Figure 이름은 한 번에 {max}개까지 물을 수 있습니다({count}개).",
99
+ "error.figure-package-size": "꾸러미에는 발행판을 1~{max}개 담을 수 있습니다({count}개).",
100
+ "error.figure-ref-malformed": "{ref} 은 namespace/type@버전 형식의 발행판 참조가 아닙니다.",
101
+ "error.figure-package-release-not-found": "{ref} 은 이 도메인에서 읽을 수 있는 발행판이 아닙니다."
93
102
  }
@@ -1,6 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <coverage generated="1789051204527" clover="3.2.0">
3
- <project timestamp="1789051204527" name="All files">
4
- <metrics statements="0" coveredstatements="0" conditionals="0" coveredconditionals="0" methods="0" coveredmethods="0" elements="0" coveredelements="0" complexity="0" loc="0" ncloc="0" packages="0" files="0" classes="0"/>
5
- </project>
6
- </coverage>
@@ -1 +0,0 @@
1
- {}