@things-factory/figure-service 10.1.116 → 10.1.121

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 (50) 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/recipes/duct-fan/models/default.json +1 -1
  31. package/scripts/rename-figures-to-full-names.mjs +211 -0
  32. package/server/routers/figure-package-router.ts +53 -0
  33. package/server/routes.ts +3 -0
  34. package/server/service/figure/figure-deprecation-db.test.ts +13 -6
  35. package/server/service/figure/figure-history.ts +4 -0
  36. package/server/service/figure/figure-mutation.ts +28 -4
  37. package/server/service/figure/figure-namespace-db.test.ts +181 -0
  38. package/server/service/figure/figure-namespace.ts +59 -0
  39. package/server/service/figure/figure-package-export.ts +116 -0
  40. package/server/service/figure/figure-proposal-db.test.ts +9 -2
  41. package/server/service/figure/figure-query.ts +74 -1
  42. package/server/service/figure/figure-release-record-db.test.ts +13 -6
  43. package/server/service/figure/figure-rename-db.test.ts +19 -11
  44. package/server/service/figure/figure-rename.test.ts +12 -12
  45. package/server/service/figure/figure-rename.ts +1 -1
  46. package/server/service/figure/figure-thumbnail-db.test.ts +3 -0
  47. package/server/service/figure/figure-thumbnail-store.ts +4 -1
  48. package/server/service/figure/figure.ts +8 -0
  49. package/translations/en.json +10 -1
  50. package/translations/ko.json +10 -1
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Full figure names on the authoring server (ADR-0114): the namespace a figure is created under, the batch door
3
+ * `figureBlueprints`, and the package a board server imports. sqlite, history subscriber on.
4
+ */
5
+ import type { DataSource } from 'typeorm'
6
+
7
+ const { DataSource: TypeOrmDataSource } = require('typeorm')
8
+ const { createHash } = require('crypto')
9
+ const JSZip = require('jszip')
10
+ const shell = require('@things-factory/shell')
11
+ const authBase = require('@things-factory/auth-base')
12
+ const settingBase = require('@things-factory/setting-base')
13
+ const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy, readFigurePackage } = require('@hatiolab/figure-model')
14
+
15
+ let dataSource: DataSource | null = null
16
+ let Figure: any
17
+ let FigureMutation: any
18
+ let FigureQuery: any
19
+ let figurePackageOf: any
20
+
21
+ function loadCompiled(modulePath: string): any {
22
+ const { existsSync } = require('fs')
23
+ const { join } = require('path')
24
+ const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
25
+ if (!existsSync(resolved))
26
+ throw new Error(`figure-service 산출물이 없다: ${resolved}\n먼저 빌드할 것:\n yarn workspace @things-factory/figure-service build:server`)
27
+ return require(resolved)
28
+ }
29
+
30
+ beforeAll(async () => {
31
+ Figure = loadCompiled('service/figure/figure').Figure
32
+ const FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
33
+ const Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
34
+ ;({ FigureMutation } = loadCompiled('service/figure/figure-mutation'))
35
+ ;({ FigureQuery } = loadCompiled('service/figure/figure-query'))
36
+ ;({ figurePackageOf } = loadCompiled('service/figure/figure-package-export'))
37
+ dataSource = new TypeOrmDataSource({
38
+ type: 'sqlite',
39
+ database: ':memory:',
40
+ synchronize: true,
41
+ dropSchema: true,
42
+ namingStrategy: new shell.NamingStrategy(),
43
+ entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory],
44
+ subscribers: [Subscriber],
45
+ logging: false
46
+ })
47
+ await dataSource.initialize()
48
+ shell.addDataSource('default', dataSource)
49
+ shell.addDataSource('tx', dataSource)
50
+ })
51
+
52
+ afterAll(async () => {
53
+ await dataSource?.destroy()
54
+ dataSource = null
55
+ })
56
+
57
+ const releasable = (label = 'Conveyor') => {
58
+ let a = addFigureBox(createFigureAsset(label), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
59
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } })
60
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a, { over: 'range' }), placement: 'floor' }))
61
+ }
62
+
63
+ let seq = 0
64
+ async function domainWith(namespace: string | null) {
65
+ const name = `ns-${++seq}`
66
+ const domain = await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name })
67
+ if (namespace !== null) {
68
+ await dataSource!.getRepository(settingBase.Setting).save({ domain, name: 'figure-namespace', category: 'figure', value: namespace })
69
+ }
70
+ const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
71
+ return { domain, context }
72
+ }
73
+
74
+ const creationId = () => require('crypto').randomUUID()
75
+ const setNamespace = (domain: any, value: string) =>
76
+ dataSource!.getRepository(settingBase.Setting).update({ domain: { id: domain.id }, name: 'figure-namespace' }, { value })
77
+
78
+ async function releasedFigure(context: any, type: string) {
79
+ const figure = await new FigureMutation().saveFigure(releasable(type), context, undefined, undefined, creationId(), undefined, type)
80
+ await new FigureMutation().releaseFigure(figure.id, 'first', context)
81
+ return figure
82
+ }
83
+
84
+ describe('the namespace a figure is created under', () => {
85
+ it('is copied from the domain setting onto a new figure', async () => {
86
+ const { context } = await domainWith('hatiolab.com')
87
+ const figure = await new FigureMutation().saveFigure(releasable(), context, undefined, undefined, creationId(), undefined, 'conveyor')
88
+ expect(figure.namespace).toBe('hatiolab.com')
89
+ expect(figure.type).toBe('conveyor')
90
+ })
91
+
92
+ it('is refused by name when the domain has none, or an invalid one', async () => {
93
+ for (const value of [null, 'Hatiolab.COM']) {
94
+ const { context } = await domainWith(value)
95
+ await expect(
96
+ new FigureMutation().saveFigure(releasable(), context, undefined, undefined, creationId(), undefined, 'conveyor')
97
+ ).rejects.toMatchObject({ code: 'figure-namespace-missing' })
98
+ }
99
+ })
100
+
101
+ it('cannot change: a new figure or a release is refused while the setting disagrees with the figures already made', async () => {
102
+ const { domain, context } = await domainWith('hatiolab.com')
103
+ const draft = await new FigureMutation().saveFigure(releasable(), context, undefined, undefined, creationId(), undefined, 'conveyor')
104
+ await setNamespace(domain, 'other.com')
105
+ await expect(
106
+ new FigureMutation().saveFigure(releasable(), context, undefined, undefined, creationId(), undefined, 'lifter')
107
+ ).rejects.toMatchObject({ code: 'figure-namespace-changed' })
108
+ await expect(new FigureMutation().releaseFigure(draft.id, 'c', context)).rejects.toMatchObject({ code: 'figure-namespace-changed' })
109
+
110
+ await setNamespace(domain, 'hatiolab.com')
111
+ await expect(new FigureMutation().releaseFigure(draft.id, 'c', context)).resolves.toMatchObject({ version: 1 })
112
+ })
113
+
114
+ it('refuses an upper-case type when creating directly', async () => {
115
+ const { context } = await domainWith('hatiolab.com')
116
+ await expect(
117
+ new FigureMutation().createFigure({ type: 'CONVEYOR', name: 'c', source: releasable() }, context)
118
+ ).rejects.toMatchObject({ code: 'figure-type-malformed' })
119
+ })
120
+ })
121
+
122
+ describe('figureBlueprints answers each full name, an item or a refusal', () => {
123
+ it('gives released figures by full name and refuses the rest by cause, none taking another away', async () => {
124
+ const { context } = await domainWith('hatiolab.com')
125
+ await releasedFigure(context, 'conveyor')
126
+ await new FigureMutation().saveFigure(releasable(), context, undefined, undefined, creationId(), undefined, 'draft-only')
127
+
128
+ const answer = await new FigureQuery().figureBlueprints(
129
+ ['hatiolab.com/conveyor', 'hatiolab.com/draft-only', 'other.com/conveyor', 'CONVEYOR', 'hatiolab.com/conveyor'],
130
+ context
131
+ )
132
+
133
+ expect(answer.release).toBeNull()
134
+ expect(answer.items.map((item: any) => [item.type, item.ref])).toEqual([['hatiolab.com/conveyor', 'hatiolab.com/conveyor@1']])
135
+ expect(answer.items[0].asset.version).toBe('figure-asset-1')
136
+ expect(answer.refused.map((r: any) => [r.type, r.code])).toEqual([
137
+ ['hatiolab.com/draft-only', 'figure-not-released'],
138
+ ['other.com/conveyor', 'figure-not-released'],
139
+ ['CONVEYOR', 'figure-name-malformed']
140
+ ])
141
+ })
142
+ })
143
+
144
+ describe('the figure package', () => {
145
+ const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
146
+
147
+ it('carries the release a board server reads back whole, and names the server it came from', async () => {
148
+ const { domain, context } = await domainWith('hatiolab.com')
149
+ const figure = await releasedFigure(context, 'trailer-22')
150
+
151
+ const { name, bytes } = await figurePackageOf({
152
+ refs: ['hatiolab.com/trailer-22@1'],
153
+ domain,
154
+ exportedBy: 'author',
155
+ sourceUrl: 'https://figure.hatiolab.com',
156
+ files: { put: async () => ({}), send: async () => {}, read: async () => Buffer.alloc(0) }
157
+ })
158
+ expect(name).toBe('hatiolab.com_trailer-22_1.figpkg')
159
+
160
+ const zip = await JSZip.loadAsync(bytes)
161
+ const files = new Map<string, Uint8Array>()
162
+ for (const path of Object.keys(zip.files)) if (!zip.files[path].dir) files.set(path, await zip.files[path].async('uint8array'))
163
+ const read = readFigurePackage(files, sha256)
164
+
165
+ expect(read.problems).toEqual([])
166
+ expect(read.refused).toEqual([])
167
+ expect(read.manifest.source).toEqual({ url: 'https://figure.hatiolab.com', domain: domain.subdomain })
168
+ expect(read.releases.map((r: any) => r.ref)).toEqual(['hatiolab.com/trailer-22@1'])
169
+ const stored = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
170
+ expect(read.releases[0].source).toBe(stored.source)
171
+ })
172
+
173
+ it('refuses a release this domain cannot read, and a malformed reference', async () => {
174
+ const { domain, context } = await domainWith('hatiolab.com')
175
+ await releasedFigure(context, 'conveyor')
176
+ const ask = (refs: string[]) => figurePackageOf({ refs, domain, exportedBy: null, sourceUrl: 'x' })
177
+ await expect(ask(['hatiolab.com/conveyor@2'])).rejects.toMatchObject({ code: 'figure-package-release-not-found' })
178
+ await expect(ask(['other.com/conveyor@1'])).rejects.toMatchObject({ code: 'figure-package-release-not-found' })
179
+ await expect(ask(['CONVEYOR@1'])).rejects.toMatchObject({ code: 'figure-ref-malformed' })
180
+ })
181
+ })
@@ -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