@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.
- package/dist-server/routers/figure-package-router.d.ts +7 -0
- package/dist-server/routers/figure-package-router.js +48 -0
- package/dist-server/routers/figure-package-router.js.map +1 -0
- package/dist-server/routes.js +3 -0
- package/dist-server/routes.js.map +1 -1
- package/dist-server/service/figure/figure-history.d.ts +1 -0
- package/dist-server/service/figure/figure-history.js +5 -0
- package/dist-server/service/figure/figure-history.js.map +1 -1
- package/dist-server/service/figure/figure-mutation.js +17 -2
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-namespace.d.ts +17 -0
- package/dist-server/service/figure/figure-namespace.js +48 -0
- package/dist-server/service/figure/figure-namespace.js.map +1 -0
- package/dist-server/service/figure/figure-package-export.d.ts +26 -0
- package/dist-server/service/figure/figure-package-export.js +90 -0
- package/dist-server/service/figure/figure-package-export.js.map +1 -0
- package/dist-server/service/figure/figure-query.d.ts +8 -0
- package/dist-server/service/figure/figure-query.js +61 -0
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-rename.js +1 -1
- package/dist-server/service/figure/figure-rename.js.map +1 -1
- package/dist-server/service/figure/figure-thumbnail-store.d.ts +2 -0
- package/dist-server/service/figure/figure-thumbnail-store.js +2 -1
- package/dist-server/service/figure/figure-thumbnail-store.js.map +1 -1
- package/dist-server/service/figure/figure.d.ts +5 -0
- package/dist-server/service/figure/figure.js +5 -0
- package/dist-server/service/figure/figure.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -6
- package/recipes/duct-fan/models/default.json +1 -1
- package/scripts/rename-figures-to-full-names.mjs +211 -0
- package/server/routers/figure-package-router.ts +53 -0
- package/server/routes.ts +3 -0
- package/server/service/figure/figure-deprecation-db.test.ts +13 -6
- package/server/service/figure/figure-history.ts +4 -0
- package/server/service/figure/figure-mutation.ts +28 -4
- package/server/service/figure/figure-namespace-db.test.ts +181 -0
- package/server/service/figure/figure-namespace.ts +59 -0
- package/server/service/figure/figure-package-export.ts +116 -0
- package/server/service/figure/figure-proposal-db.test.ts +9 -2
- package/server/service/figure/figure-query.ts +74 -1
- package/server/service/figure/figure-release-record-db.test.ts +13 -6
- package/server/service/figure/figure-rename-db.test.ts +19 -11
- package/server/service/figure/figure-rename.test.ts +12 -12
- package/server/service/figure/figure-rename.ts +1 -1
- package/server/service/figure/figure-thumbnail-db.test.ts +3 -0
- package/server/service/figure/figure-thumbnail-store.ts +4 -1
- package/server/service/figure/figure.ts +8 -0
- package/translations/en.json +10 -1
- 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
|
-
|
|
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, '
|
|
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, '
|
|
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: '
|
|
130
|
+
await seedFigure({ type: 'conveyor', name: '컨베이어' })
|
|
123
131
|
|
|
124
|
-
expect((await facts(figure, '
|
|
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], '
|
|
169
|
-
expect(saved.type).toBe('
|
|
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: '
|
|
174
|
-
await expect(save(ids[1], '
|
|
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
|
|