@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.
- 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/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
- package/coverage/clover.xml +0 -6
- package/coverage/coverage-final.json +0 -1
- package/coverage/lcov-report/base.css +0 -224
- package/coverage/lcov-report/block-navigation.js +0 -87
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +0 -101
- package/coverage/lcov-report/prettify.css +0 -1
- package/coverage/lcov-report/prettify.js +0 -2
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +0 -210
- package/coverage/lcov.info +0 -0
- package/logs/.08636eb59927f12972f6774f5947c8507b3564c2-audit.json +0 -15
- 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
|
-
|
|
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
|
|
|
@@ -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: '
|
|
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: '
|
|
29
|
+
const result = renameDraftType({ type: 'figure-abcdef12', source }, 'roller-conveyor', never)
|
|
30
30
|
|
|
31
|
-
expect(result.type).toBe('
|
|
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(), '
|
|
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(), '
|
|
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(), '
|
|
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(), '
|
|
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('
|
|
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(['
|
|
112
|
-
expect(() => renameDraftType({ 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(['
|
|
115
|
-
expect(renameDraftType({ type: '
|
|
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}」 는
|
|
68
|
+
`type 「${next}」 는 소문자로 시작하고 소문자·숫자·하이픈 하나씩만 쓰며 ${TYPE_LENGTH.min}~${TYPE_LENGTH.max}자여야 한다`
|
|
69
69
|
)
|
|
70
70
|
}
|
|
71
71
|
|
|
@@ -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
|
package/translations/en.json
CHANGED
|
@@ -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
|
}
|
package/translations/ko.json
CHANGED
|
@@ -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
|
}
|
package/coverage/clover.xml
DELETED
|
@@ -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
|
-
{}
|