@things-factory/figure-service 10.1.178 → 10.1.179
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/index.d.ts +2 -0
- package/dist-server/index.js +6 -1
- package/dist-server/index.js.map +1 -1
- package/dist-server/routers/figure-mesh-router.js +4 -1
- package/dist-server/routers/figure-mesh-router.js.map +1 -1
- package/dist-server/routers/figure-thumbnail-router.js +3 -7
- package/dist-server/routers/figure-thumbnail-router.js.map +1 -1
- package/dist-server/service/figure/figure-categories.d.ts +10 -0
- package/dist-server/service/figure/figure-categories.js +53 -0
- package/dist-server/service/figure/figure-categories.js.map +1 -0
- package/dist-server/service/figure/figure-format.d.ts +18 -1
- package/dist-server/service/figure/figure-format.js +28 -1
- package/dist-server/service/figure/figure-format.js.map +1 -1
- package/dist-server/service/figure/figure-mutation.d.ts +8 -2
- package/dist-server/service/figure/figure-mutation.js +83 -34
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-namespace-entity.d.ts +24 -0
- package/dist-server/service/figure/figure-namespace-entity.js +87 -0
- package/dist-server/service/figure/figure-namespace-entity.js.map +1 -0
- package/dist-server/service/figure/figure-namespace-resolver.d.ts +5 -8
- package/dist-server/service/figure/figure-namespace-resolver.js +52 -36
- package/dist-server/service/figure/figure-namespace-resolver.js.map +1 -1
- package/dist-server/service/figure/figure-namespace.d.ts +24 -28
- package/dist-server/service/figure/figure-namespace.js +99 -64
- package/dist-server/service/figure/figure-namespace.js.map +1 -1
- package/dist-server/service/figure/figure-package-export.js +4 -2
- package/dist-server/service/figure/figure-package-export.js.map +1 -1
- package/dist-server/service/figure/figure-public.d.ts +19 -0
- package/dist-server/service/figure/figure-public.js +54 -0
- package/dist-server/service/figure/figure-public.js.map +1 -0
- package/dist-server/service/figure/figure-query.d.ts +7 -0
- package/dist-server/service/figure/figure-query.js +59 -5
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-rename.d.ts +25 -2
- package/dist-server/service/figure/figure-rename.js +21 -2
- package/dist-server/service/figure/figure-rename.js.map +1 -1
- package/dist-server/service/figure/figure-tools.js +1 -1
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/service/figure/figure-type.d.ts +1 -0
- package/dist-server/service/figure/figure-type.js +4 -0
- package/dist-server/service/figure/figure-type.js.map +1 -1
- package/dist-server/service/figure/figure.d.ts +1 -0
- package/dist-server/service/figure/figure.js +5 -0
- package/dist-server/service/figure/figure.js.map +1 -1
- package/dist-server/service/figure/index.d.ts +2 -1
- package/dist-server/service/figure/index.js +2 -1
- package/dist-server/service/figure/index.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/server/index.ts +2 -0
- package/server/routers/figure-mesh-router.ts +5 -1
- package/server/routers/figure-thumbnail-router.ts +3 -5
- package/server/service/figure/figure-categories.ts +50 -0
- package/server/service/figure/figure-category-db.test.ts +127 -0
- package/server/service/figure/figure-deprecation-db.test.ts +110 -6
- package/server/service/figure/figure-format.ts +43 -1
- package/server/service/figure/figure-mesh-db.test.ts +4 -2
- package/server/service/figure/figure-mutation.ts +79 -37
- package/server/service/figure/figure-namespace-db.test.ts +120 -74
- package/server/service/figure/figure-namespace-entity.ts +67 -0
- package/server/service/figure/figure-namespace-resolver.ts +39 -26
- package/server/service/figure/figure-namespace.ts +116 -82
- package/server/service/figure/figure-package-export.ts +6 -3
- package/server/service/figure/figure-proposal-db.test.ts +5 -3
- package/server/service/figure/figure-public-db.test.ts +180 -0
- package/server/service/figure/figure-public.ts +60 -0
- package/server/service/figure/figure-query.ts +48 -5
- package/server/service/figure/figure-release-check.test.ts +63 -0
- package/server/service/figure/figure-release-record-db.test.ts +7 -5
- package/server/service/figure/figure-rename-db.test.ts +50 -4
- package/server/service/figure/figure-rename.ts +37 -3
- package/server/service/figure/figure-tools.ts +1 -1
- package/server/service/figure/figure-type.ts +3 -0
- package/server/service/figure/figure.ts +8 -0
- package/server/service/figure/index.ts +2 -1
- package/translations/en.json +12 -3
- package/translations/es.json +12 -3
- package/translations/ja.json +12 -3
- package/translations/ko.json +12 -3
- package/translations/ms.json +12 -3
- package/translations/zh.json +12 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-service",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.179",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -27,16 +27,16 @@
|
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@hatiolab/figure-model": "^0.7.2",
|
|
29
29
|
"@operato/typeorm-history": "^10.0.0",
|
|
30
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
31
|
-
"@things-factory/attachment-base": "^10.1.
|
|
32
|
-
"@things-factory/auth-base": "^10.1.
|
|
30
|
+
"@things-factory/ai-client-base": "^10.1.179",
|
|
31
|
+
"@things-factory/attachment-base": "^10.1.179",
|
|
32
|
+
"@things-factory/auth-base": "^10.1.179",
|
|
33
33
|
"@things-factory/env": "^10.1.149",
|
|
34
|
-
"@things-factory/setting-base": "^10.1.
|
|
35
|
-
"@things-factory/shell": "^10.1.
|
|
34
|
+
"@things-factory/setting-base": "^10.1.179",
|
|
35
|
+
"@things-factory/shell": "^10.1.179",
|
|
36
36
|
"graphql-scalars": "^1.22.4",
|
|
37
37
|
"graphql-upload": "^15.0.2",
|
|
38
38
|
"jszip": "^3.10.1",
|
|
39
39
|
"koa-router": "^7.4.0"
|
|
40
40
|
},
|
|
41
|
-
"gitHead": "
|
|
41
|
+
"gitHead": "4c8263b08e06b2b15f11dc0575c61a280a0d9e27"
|
|
42
42
|
}
|
package/server/index.ts
CHANGED
|
@@ -42,3 +42,5 @@ process.on('bootstrap-module-start' as any, () => {
|
|
|
42
42
|
export * from './service/index.js'
|
|
43
43
|
export * from './migrations/index.js'
|
|
44
44
|
export { registerFigureToolCategories } from './service/figure/figure-tools.js'
|
|
45
|
+
export { registerFigureCategories, figureCategories } from './service/figure/figure-categories.js'
|
|
46
|
+
export { registerFigureReleaseCheck, type FigureReleaseCheck, type FigureReleaseCheckInput, type GateFinding } from './service/figure/figure-format.js'
|
|
@@ -6,6 +6,7 @@ import { getRepository } from '@things-factory/shell'
|
|
|
6
6
|
import { meshAttachmentOf } from '../service/figure/figure-mesh-store.js'
|
|
7
7
|
import { GLB_MIMETYPE } from '../service/figure/figure-glb.js'
|
|
8
8
|
import { thumbnailFiles, type ThumbnailFiles } from '../service/figure/figure-thumbnail-store.js'
|
|
9
|
+
import { publicDomainsOfFile } from '../service/figure/figure-public.js'
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* A mesh part's file, by its hash (ADR-0129 decision 8): what the modeller and a board draw a mesh part from, on the
|
|
@@ -38,7 +39,10 @@ export async function sendFigureMesh(
|
|
|
38
39
|
context.status = 404
|
|
39
40
|
return
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
+
/* Its own or its parent's, or kept by a domain whose public figure's releases draw from it (G2). */
|
|
43
|
+
const kept =
|
|
44
|
+
(await meshAttachmentOf({ getRepository }, [domain.id, domain.parentId].filter(Boolean) as string[], hash)) ??
|
|
45
|
+
(await meshAttachmentOf({ getRepository }, await publicDomainsOfFile(hash, domain), hash))
|
|
42
46
|
if (!kept) {
|
|
43
47
|
context.status = 404
|
|
44
48
|
return
|
|
@@ -9,6 +9,7 @@ import { readFileSync } from 'fs'
|
|
|
9
9
|
import { Figure } from '../service/figure/figure.js'
|
|
10
10
|
import { thumbnailAttachmentOf, thumbnailFiles, type ThumbnailFiles } from '../service/figure/figure-thumbnail-store.js'
|
|
11
11
|
import { recipeTemplates } from '../service/figure-recipe/figure-recipe-templates.js'
|
|
12
|
+
import { readableFigure } from '../service/figure/figure-public.js'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* 썸네일을 **그림으로** 내보내는 길.
|
|
@@ -80,11 +81,8 @@ export async function sendFigureThumbnail(
|
|
|
80
81
|
읽기는 부모 도메인까지 본다 — `figure` 질의와 같은 범위다. 물려받은 형상을 목록에는 보여 주고
|
|
81
82
|
그림만 404 를 내면, 화면은 「그림이 아직 없다」고 거짓말하게 된다.
|
|
82
83
|
*/
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
relations: { domain: true },
|
|
86
|
-
select: { id: true, thumbnailHash: true, thumbnailUpdatedAt: true, domain: { id: true } }
|
|
87
|
-
})
|
|
84
|
+
/* Another domain's public figure is read too (G2): its card is what a reader chooses it by. */
|
|
85
|
+
const found = await readableFigure(domain, id, { id: true, thumbnailHash: true, thumbnailUpdatedAt: true, public: true, domain: { id: true } })
|
|
88
86
|
if (!found?.thumbnailHash) {
|
|
89
87
|
context.status = 404
|
|
90
88
|
return
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Refusal } from '@things-factory/shell'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The categories an app allows (LV figure-server design G5). Without a registration a category is free text, as
|
|
5
|
+
* operato-figure has it. An app that registers its vocabulary -- the LV figure-server: EQUIPMENT, CARRIER, UTILITY,
|
|
6
|
+
* COMPONENT -- gets three rules: a category outside it is refused, a category once set is not changed, and a figure
|
|
7
|
+
* is not released without one. The vocabulary is the app's; this module does not know what the words mean.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
let registered: readonly string[] | null = null
|
|
11
|
+
|
|
12
|
+
/** Registers the categories this server allows. Called once at the top of the app's server module. */
|
|
13
|
+
export function registerFigureCategories(codes: readonly string[]): void {
|
|
14
|
+
const cleaned = codes.map(code => (typeof code === 'string' ? code.trim() : ''))
|
|
15
|
+
if (!cleaned.length || cleaned.some(code => !code) || new Set(cleaned).size !== cleaned.length) {
|
|
16
|
+
throw new Error(`registerFigureCategories needs distinct non-empty codes, not ${JSON.stringify(codes)}.`)
|
|
17
|
+
}
|
|
18
|
+
registered = Object.freeze([...cleaned])
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The categories registered, or null when a category is free text. */
|
|
22
|
+
export function figureCategories(): readonly string[] | null {
|
|
23
|
+
return registered
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** For tests: back to free text. */
|
|
27
|
+
export function clearFigureCategories(): void {
|
|
28
|
+
registered = null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A category being set on a figure: refused outside the vocabulary, and when the figure already has another. */
|
|
32
|
+
export function assertFigureCategory(next: string | null | undefined, current?: string | null): void {
|
|
33
|
+
if (!registered || next === undefined) return
|
|
34
|
+
const wanted = (next ?? '').trim()
|
|
35
|
+
const had = (current ?? '').trim()
|
|
36
|
+
if (had && wanted !== had) {
|
|
37
|
+
throw new Refusal('figure-category-fixed', { category: had }, 'the category {category} is set once and does not change.')
|
|
38
|
+
}
|
|
39
|
+
if (wanted && !registered.includes(wanted)) {
|
|
40
|
+
throw new Refusal('figure-category-unknown', { category: wanted, allowed: registered.join(', ') }, '{category} is not a category here. use one of {allowed}.')
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A figure going out: with a vocabulary registered it carries one of its categories. */
|
|
45
|
+
export function assertReleasableCategory(category: string | null | undefined, type: string): void {
|
|
46
|
+
if (!registered) return
|
|
47
|
+
if (!(category ?? '').trim()) {
|
|
48
|
+
throw new Refusal('figure-category-missing', { type, allowed: registered.join(', ') }, '{type} has no category. choose one of {allowed} before releasing.')
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An app's category vocabulary (LV figure-server design G5), on sqlite: without one a category is free text; with one,
|
|
3
|
+
* a category outside it is refused, a category once set does not change, and a figure goes out only with one.
|
|
4
|
+
*/
|
|
5
|
+
import type { DataSource } from 'typeorm'
|
|
6
|
+
|
|
7
|
+
const { DataSource: TypeOrmDataSource } = require('typeorm')
|
|
8
|
+
const shell = require('@things-factory/shell')
|
|
9
|
+
const authBase = require('@things-factory/auth-base')
|
|
10
|
+
const settingBase = require('@things-factory/setting-base')
|
|
11
|
+
const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy } = require('@hatiolab/figure-model')
|
|
12
|
+
|
|
13
|
+
let dataSource: DataSource | null = null
|
|
14
|
+
let Figure: any
|
|
15
|
+
let FigureHistory: any
|
|
16
|
+
let FigureNamespace: any
|
|
17
|
+
let FigureMutation: any
|
|
18
|
+
let FigureQuery: any
|
|
19
|
+
let categories: 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
|
+
FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
|
|
33
|
+
FigureNamespace = loadCompiled('service/figure/figure-namespace-entity').FigureNamespace
|
|
34
|
+
const Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
|
|
35
|
+
;({ FigureMutation } = loadCompiled('service/figure/figure-mutation'))
|
|
36
|
+
;({ FigureQuery } = loadCompiled('service/figure/figure-query'))
|
|
37
|
+
categories = loadCompiled('service/figure/figure-categories')
|
|
38
|
+
dataSource = new TypeOrmDataSource({
|
|
39
|
+
type: 'sqlite',
|
|
40
|
+
database: ':memory:',
|
|
41
|
+
synchronize: true,
|
|
42
|
+
dropSchema: true,
|
|
43
|
+
namingStrategy: new shell.NamingStrategy(),
|
|
44
|
+
entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory, FigureNamespace],
|
|
45
|
+
subscribers: [Subscriber],
|
|
46
|
+
logging: false
|
|
47
|
+
})
|
|
48
|
+
await dataSource.initialize()
|
|
49
|
+
shell.addDataSource('default', dataSource)
|
|
50
|
+
shell.addDataSource('tx', dataSource)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
afterAll(async () => {
|
|
54
|
+
categories?.clearFigureCategories()
|
|
55
|
+
await dataSource?.destroy()
|
|
56
|
+
dataSource = null
|
|
57
|
+
})
|
|
58
|
+
afterEach(() => categories.clearFigureCategories())
|
|
59
|
+
|
|
60
|
+
const releasable = () => {
|
|
61
|
+
let a = addFigureBox(createFigureAsset('Mixer'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
62
|
+
a = applyFigureAuthoring(a, { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } })
|
|
63
|
+
return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a, { over: 'range' }), placement: 'floor' }))
|
|
64
|
+
}
|
|
65
|
+
let seq = 0
|
|
66
|
+
async function context() {
|
|
67
|
+
const name = `cat-${++seq}`
|
|
68
|
+
const domain = await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name })
|
|
69
|
+
return { state: { domain, user: undefined, tx: dataSource!.manager } }
|
|
70
|
+
}
|
|
71
|
+
const draft = (ctx: any, type: string) =>
|
|
72
|
+
new FigureMutation().saveFigure(releasable(), ctx, undefined, undefined, require('crypto').randomUUID(), undefined, type)
|
|
73
|
+
const LV = ['EQUIPMENT', 'CARRIER', 'UTILITY', 'COMPONENT']
|
|
74
|
+
|
|
75
|
+
describe('categories', () => {
|
|
76
|
+
it('are free text without a vocabulary: any, changed at will, not needed to release', async () => {
|
|
77
|
+
const ctx = await context()
|
|
78
|
+
expect(await new FigureQuery().figureCategories()).toBeNull()
|
|
79
|
+
const figure = await draft(ctx, 'free')
|
|
80
|
+
await new FigureMutation().updateFigure(figure.id, { category: 'conveyors' }, ctx)
|
|
81
|
+
await new FigureMutation().updateFigure(figure.id, { category: 'lines' }, ctx)
|
|
82
|
+
await expect(new FigureMutation().releaseFigure(figure.id, 'one', ctx)).resolves.toMatchObject({ version: 1 })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('with a vocabulary: one of it, set once, and needed to release', async () => {
|
|
86
|
+
categories.registerFigureCategories(LV)
|
|
87
|
+
expect(await new FigureQuery().figureCategories()).toEqual(LV)
|
|
88
|
+
const ctx = await context()
|
|
89
|
+
const figure = await draft(ctx, 'mixer')
|
|
90
|
+
await expect(new FigureMutation().releaseFigure(figure.id, 'one', ctx)).rejects.toMatchObject({ code: 'figure-category-missing' })
|
|
91
|
+
await expect(new FigureMutation().updateFigure(figure.id, { category: 'conveyors' }, ctx)).rejects.toMatchObject({ code: 'figure-category-unknown' })
|
|
92
|
+
await new FigureMutation().updateFigure(figure.id, { category: 'EQUIPMENT' }, ctx)
|
|
93
|
+
await expect(new FigureMutation().updateFigure(figure.id, { category: 'CARRIER' }, ctx)).rejects.toMatchObject({ code: 'figure-category-fixed' })
|
|
94
|
+
await expect(new FigureMutation().updateFigure(figure.id, { category: 'EQUIPMENT', description: 'same category again' }, ctx)).resolves.toBeTruthy()
|
|
95
|
+
await expect(new FigureMutation().releaseFigure(figure.id, 'one', ctx)).resolves.toMatchObject({ version: 1 })
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('with a vocabulary, creating directly refuses a category outside it', async () => {
|
|
99
|
+
categories.registerFigureCategories(LV)
|
|
100
|
+
const ctx = await context()
|
|
101
|
+
await expect(new FigureMutation().createFigure({ type: 'odd', name: 'o', source: releasable(), category: 'misc' }, ctx)).rejects.toMatchObject({ code: 'figure-category-unknown' })
|
|
102
|
+
const made = await new FigureMutation().createFigure({ type: 'carrier-roll', name: 'r', source: releasable(), category: 'CARRIER' }, ctx)
|
|
103
|
+
expect(made.figure.category).toBe('CARRIER')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('a vocabulary is distinct non-empty codes', () => {
|
|
107
|
+
expect(() => categories.registerFigureCategories([])).toThrow()
|
|
108
|
+
expect(() => categories.registerFigureCategories(['A', 'A'])).toThrow()
|
|
109
|
+
expect(() => categories.registerFigureCategories(['A', ' '])).toThrow()
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('an app release check, on the way out', () => {
|
|
114
|
+
it('a blocking finding refuses the release; taken off, it goes out', async () => {
|
|
115
|
+
const { registerFigureReleaseCheck } = loadCompiled('service/figure/figure-format')
|
|
116
|
+
const take = registerFigureReleaseCheck(() => [{ code: 'app-origin-off', message: 'the origin is off the middle', blocking: true }])
|
|
117
|
+
try {
|
|
118
|
+
const ctx = await context()
|
|
119
|
+
const figure = await draft(ctx, 'checked')
|
|
120
|
+
await expect(new FigureMutation().releaseFigure(figure.id, 'one', ctx)).rejects.toThrow(/origin is off the middle/)
|
|
121
|
+
take()
|
|
122
|
+
await expect(new FigureMutation().releaseFigure(figure.id, 'one', ctx)).resolves.toMatchObject({ version: 1 })
|
|
123
|
+
} finally {
|
|
124
|
+
take()
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -10,6 +10,7 @@ const authBase = require('@things-factory/auth-base')
|
|
|
10
10
|
const settingBase = require('@things-factory/setting-base')
|
|
11
11
|
const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy } = require('@hatiolab/figure-model')
|
|
12
12
|
|
|
13
|
+
let FigureNamespace: any
|
|
13
14
|
let dataSource: DataSource | null = null
|
|
14
15
|
let Figure: any
|
|
15
16
|
let FigureHistory: any
|
|
@@ -28,6 +29,7 @@ function loadCompiled(modulePath: string): any {
|
|
|
28
29
|
|
|
29
30
|
beforeAll(async () => {
|
|
30
31
|
Figure = loadCompiled('service/figure/figure').Figure
|
|
32
|
+
FigureNamespace = loadCompiled('service/figure/figure-namespace-entity').FigureNamespace
|
|
31
33
|
FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
|
|
32
34
|
Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
|
|
33
35
|
;({ FigureMutation, assertNames } = loadCompiled('service/figure/figure-mutation'))
|
|
@@ -37,7 +39,7 @@ beforeAll(async () => {
|
|
|
37
39
|
synchronize: true,
|
|
38
40
|
dropSchema: true,
|
|
39
41
|
namingStrategy: new shell.NamingStrategy(),
|
|
40
|
-
entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory],
|
|
42
|
+
entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory, FigureNamespace],
|
|
41
43
|
subscribers: [Subscriber],
|
|
42
44
|
logging: false
|
|
43
45
|
})
|
|
@@ -49,9 +51,9 @@ afterAll(async () => {
|
|
|
49
51
|
dataSource = null
|
|
50
52
|
})
|
|
51
53
|
|
|
52
|
-
/** The domain
|
|
54
|
+
/** The domain's own default namespace, its subdomain, that every figure here is created and released under (G1). */
|
|
53
55
|
async function namespaced(domain: any) {
|
|
54
|
-
await dataSource!.getRepository(
|
|
56
|
+
await dataSource!.getRepository(FigureNamespace).save({ domain, name: domain.subdomain, state: 'active', isDefault: true })
|
|
55
57
|
return domain
|
|
56
58
|
}
|
|
57
59
|
|
|
@@ -66,7 +68,7 @@ async function released(type: string) {
|
|
|
66
68
|
const name = `retire-${++seq}`
|
|
67
69
|
const domain = await namespaced(await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name }))
|
|
68
70
|
const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
|
|
69
|
-
const figure = await dataSource!.getRepository(Figure).save({ namespace:
|
|
71
|
+
const figure = await dataSource!.getRepository(Figure).save({ namespace: domain.subdomain, type, name: 'mixer', source: releasable(), state: 'draft', version: 0, domain })
|
|
70
72
|
await new FigureMutation().releaseFigure(figure.id, 'one', context)
|
|
71
73
|
return { figure, context, domain }
|
|
72
74
|
}
|
|
@@ -75,7 +77,7 @@ const historyCount = (id: string) => dataSource!.getRepository(FigureHistory).co
|
|
|
75
77
|
describe('retiring a figure', () => {
|
|
76
78
|
it('stays released, writes no history row, and names what to use instead', async () => {
|
|
77
79
|
const { figure, context, domain } = await released('OLD_MIXER')
|
|
78
|
-
await dataSource!.getRepository(Figure).save({ namespace:
|
|
80
|
+
await dataSource!.getRepository(Figure).save({ namespace: domain.subdomain, type: 'NEW_MIXER', name: 'new', source: releasable(), state: 'draft', version: 0, domain })
|
|
79
81
|
const retired = await new FigureMutation().setFigureDeprecation(figure.id, true, context, 'NEW_MIXER')
|
|
80
82
|
expect([retired.deprecated, retired.supersededBy, retired.state, retired.version]).toEqual([true, 'NEW_MIXER', 'released', 1])
|
|
81
83
|
expect(await historyCount(figure.id)).toBe(1)
|
|
@@ -104,7 +106,7 @@ describe('names by language', () => {
|
|
|
104
106
|
const name = `names-${++seq}`
|
|
105
107
|
const domain = await namespaced(await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name }))
|
|
106
108
|
const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
|
|
107
|
-
const figure = await dataSource!.getRepository(Figure).save({ namespace:
|
|
109
|
+
const figure = await dataSource!.getRepository(Figure).save({ namespace: domain.subdomain, type: 'NAMED', name: 'mixer', source: releasable(), state: 'draft', version: 0, domain })
|
|
108
110
|
await new FigureMutation().updateFigure(figure.id, { names: { en: 'Mixer', ko: '믹서' } }, context)
|
|
109
111
|
await new FigureMutation().releaseFigure(figure.id, 'one', context)
|
|
110
112
|
const [row] = await dataSource!.getRepository(FigureHistory).find({ where: { originalId: figure.id } })
|
|
@@ -112,3 +114,105 @@ describe('names by language', () => {
|
|
|
112
114
|
await expect(new FigureMutation().updateFigure(figure.id, { names: { English: 'x' } }, context)).rejects.toThrow(/English/)
|
|
113
115
|
})
|
|
114
116
|
})
|
|
117
|
+
|
|
118
|
+
describe('deleting a figure', () => {
|
|
119
|
+
const draftOnly = async (type: string) => {
|
|
120
|
+
const name = `delete-${++seq}`
|
|
121
|
+
const domain = await namespaced(await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name }))
|
|
122
|
+
const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
|
|
123
|
+
const figure = await dataSource!.getRepository(Figure).save({ namespace: domain.subdomain, type, name: 'mixer', source: releasable(), state: 'draft', version: 0, domain })
|
|
124
|
+
return { figure, context }
|
|
125
|
+
}
|
|
126
|
+
const stillThere = (id: string) => dataSource!.getRepository(Figure).findOne({ where: { id } })
|
|
127
|
+
|
|
128
|
+
it('deletes a figure that has never been released', async () => {
|
|
129
|
+
const { figure, context } = await draftOnly('NEVER_RELEASED')
|
|
130
|
+
expect(await new FigureMutation().deleteFigure(figure.id, context)).toBe(true)
|
|
131
|
+
expect(await stillThere(figure.id)).toBeNull()
|
|
132
|
+
const row = await dataSource!.getRepository(Figure).findOne({ where: { id: figure.id }, withDeleted: true })
|
|
133
|
+
expect(row?.deletedAt).toBeTruthy()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('refuses a released figure and leaves it and its release as they were', async () => {
|
|
137
|
+
const { figure, context } = await released('SHIPPED_MIXER')
|
|
138
|
+
await expect(new FigureMutation().deleteFigure(figure.id, context)).rejects.toMatchObject({
|
|
139
|
+
code: 'figure-released-not-deletable',
|
|
140
|
+
params: { type: 'SHIPPED_MIXER' }
|
|
141
|
+
})
|
|
142
|
+
expect((await stillThere(figure.id))?.version).toBe(1)
|
|
143
|
+
expect(await historyCount(figure.id)).toBe(1)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('refuses a released figure whose draft was edited again', async () => {
|
|
147
|
+
const { figure, context } = await released('EDITED_MIXER')
|
|
148
|
+
await new FigureMutation().updateFigure(figure.id, { description: 'changed after release' }, context)
|
|
149
|
+
expect((await stillThere(figure.id))?.state).toBe('draft')
|
|
150
|
+
await expect(new FigureMutation().deleteFigure(figure.id, context)).rejects.toMatchObject({ code: 'figure-released-not-deletable' })
|
|
151
|
+
expect(await stillThere(figure.id)).not.toBeNull()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('refuses by the release rows even when the figure says version 0', async () => {
|
|
155
|
+
const { figure, context } = await released('ROWS_SAY_RELEASED')
|
|
156
|
+
await dataSource!.getRepository(Figure).createQueryBuilder().update(Figure).set({ version: 0 }).where('id = :id', { id: figure.id }).callListeners(false).execute()
|
|
157
|
+
await expect(new FigureMutation().deleteFigure(figure.id, context)).rejects.toMatchObject({ code: 'figure-released-not-deletable' })
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
/*
|
|
162
|
+
* A domain reads its parent's figures and writes only its own (LV figure-server design G3): updating, releasing and
|
|
163
|
+
* retiring a parent's figure from a child domain are refused as not found, as saving its draft always was.
|
|
164
|
+
*/
|
|
165
|
+
describe('writes stay in the domain that owns the figure', () => {
|
|
166
|
+
async function parentAndChild() {
|
|
167
|
+
const parent = await namespaced(await dataSource!.getRepository(shell.Domain).save({ name: `parent-${++seq}`, subdomain: `parent-${seq}` }))
|
|
168
|
+
const saved = await dataSource!.getRepository(shell.Domain).save({ name: `child-${seq}`, subdomain: `child-${seq}`, parent })
|
|
169
|
+
const child = await namespaced(await dataSource!.getRepository(shell.Domain).findOneOrFail({ where: { id: saved.id } }))
|
|
170
|
+
expect(child.parentId).toBe(parent.id)
|
|
171
|
+
const figure = await dataSource!.getRepository(Figure).save({ namespace: parent.subdomain, type: `parents-${seq}`, name: 'mixer', source: releasable(), state: 'draft', version: 0, domain: parent })
|
|
172
|
+
const as = (domain: any) => ({ state: { domain, user: undefined, tx: dataSource!.manager } })
|
|
173
|
+
return { figure, parentContext: as(parent), childContext: as(child) }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
it('a child domain cannot update, release or retire its parent figure', async () => {
|
|
177
|
+
const { figure, childContext } = await parentAndChild()
|
|
178
|
+
await expect(new FigureMutation().updateFigure(figure.id, { description: 'from the child' }, childContext)).rejects.toThrow(/찾을 수 없다/)
|
|
179
|
+
await expect(new FigureMutation().releaseFigure(figure.id, 'from the child', childContext)).rejects.toThrow(/찾을 수 없다/)
|
|
180
|
+
await expect(new FigureMutation().setFigureDeprecation(figure.id, true, childContext)).rejects.toThrow(/찾을 수 없다/)
|
|
181
|
+
const row = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
|
|
182
|
+
expect([row.description ?? null, row.version, row.deprecated]).toEqual([null, 0, false])
|
|
183
|
+
expect(await historyCount(figure.id)).toBe(0)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('the owning domain still does all three', async () => {
|
|
187
|
+
const { figure, parentContext } = await parentAndChild()
|
|
188
|
+
await new FigureMutation().updateFigure(figure.id, { description: 'from the owner' }, parentContext)
|
|
189
|
+
await new FigureMutation().releaseFigure(figure.id, 'one', parentContext)
|
|
190
|
+
const retired = await new FigureMutation().setFigureDeprecation(figure.id, true, parentContext)
|
|
191
|
+
expect([retired.description, retired.version, retired.deprecated]).toEqual(['from the owner', 1, true])
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('a child figure may still name a parent figure to use instead', async () => {
|
|
195
|
+
const { figure: parentFigure, childContext } = await parentAndChild()
|
|
196
|
+
const child = childContext.state.domain
|
|
197
|
+
const own = await dataSource!.getRepository(Figure).save({ namespace: child.subdomain, type: `childs-${seq}`, name: 'own', source: releasable(), state: 'draft', version: 0, domain: child })
|
|
198
|
+
await new FigureMutation().releaseFigure(own.id, 'one', childContext)
|
|
199
|
+
const retired = await new FigureMutation().setFigureDeprecation(own.id, true, childContext, parentFigure.type)
|
|
200
|
+
expect(retired.supersededBy).toBe(parentFigure.type)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
/* Releasing and retiring are their own gate (LV figure-server design G6); editing a draft stays under figure:mutation. */
|
|
205
|
+
describe('who may release', () => {
|
|
206
|
+
const declarationOf = (method: string) => {
|
|
207
|
+
const { readFileSync } = require('fs')
|
|
208
|
+
const { join } = require('path')
|
|
209
|
+
const source = readFileSync(join(__dirname, 'figure-mutation.ts'), 'utf-8')
|
|
210
|
+
const preceding = source.slice(0, source.indexOf(`async ${method}(`))
|
|
211
|
+
return preceding.slice(preceding.lastIndexOf("@Directive('@privilege("))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
it('releasing and retiring ask figure-release, editing asks figure', () => {
|
|
215
|
+
for (const method of ['releaseFigure', 'setFigureDeprecation']) expect(declarationOf(method)).toContain('category: "figure-release", privilege: "mutation"')
|
|
216
|
+
for (const method of ['saveFigure', 'createFigure', 'updateFigure', 'deleteFigure', 'revertFigureVersion']) expect(declarationOf(method)).toContain('category: "figure", privilege: "mutation"')
|
|
217
|
+
})
|
|
218
|
+
})
|
|
@@ -51,6 +51,45 @@ export interface Inspection {
|
|
|
51
51
|
blocked: boolean
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** What an app's release check is shown: the figure's asset, its row's type and category, and the reader's words. */
|
|
55
|
+
export interface FigureReleaseCheckInput {
|
|
56
|
+
asset: FigureAsset
|
|
57
|
+
type?: string
|
|
58
|
+
category?: string | null
|
|
59
|
+
t?: Translate
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A check an app adds to releasing (LV figure-server design G10): findings in the gate's own shape, blocking or said.
|
|
64
|
+
* The LV figure-server refuses an origin off the middle of the room, which operato-figure only says (ADR-0104
|
|
65
|
+
* decision 4: refusing is the receiving side's), and says its carrier and budget advice.
|
|
66
|
+
*/
|
|
67
|
+
export type FigureReleaseCheck = (input: FigureReleaseCheckInput) => GateFinding[]
|
|
68
|
+
|
|
69
|
+
const releaseChecks: FigureReleaseCheck[] = []
|
|
70
|
+
|
|
71
|
+
/** Adds a check to every release verdict (`inspectFigure`, `releaseFigure`). Returns the way to take it off again. */
|
|
72
|
+
export function registerFigureReleaseCheck(check: FigureReleaseCheck): () => void {
|
|
73
|
+
releaseChecks.push(check)
|
|
74
|
+
return () => {
|
|
75
|
+
const at = releaseChecks.indexOf(check)
|
|
76
|
+
if (at >= 0) releaseChecks.splice(at, 1)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The app's checks on an asset the kernel passed. A check that throws blocks, by name: a check is never skipped. */
|
|
81
|
+
function appFindings(input: FigureReleaseCheckInput): GateFinding[] {
|
|
82
|
+
const found: GateFinding[] = []
|
|
83
|
+
for (const check of releaseChecks) {
|
|
84
|
+
try {
|
|
85
|
+
found.push(...check(input))
|
|
86
|
+
} catch (e) {
|
|
87
|
+
found.push({ code: 'release-check-failed', message: `a release check failed: ${(e as Error)?.message ?? String(e)}`, blocking: true })
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return found
|
|
91
|
+
}
|
|
92
|
+
|
|
54
93
|
/**
|
|
55
94
|
* That the source is JSON and names the format.
|
|
56
95
|
*
|
|
@@ -214,7 +253,9 @@ export function inspectSource(
|
|
|
214
253
|
releasedJson?: string | null,
|
|
215
254
|
t?: Translate,
|
|
216
255
|
/** The row's `type` -- the only place the name lives, since the asset carries none. */
|
|
217
|
-
rowType?: string
|
|
256
|
+
rowType?: string,
|
|
257
|
+
/** The row's category, for an app's release checks (G10). */
|
|
258
|
+
rowCategory?: string | null
|
|
218
259
|
): Inspection {
|
|
219
260
|
if (!sourceJson) {
|
|
220
261
|
return { blocked: true, findings: [{ code: 'no-source', message: '저장된 정본이 없다. 저장하면 발행할 수 있다', blocking: true }] }
|
|
@@ -296,6 +337,7 @@ export function inspectSource(
|
|
|
296
337
|
})
|
|
297
338
|
}
|
|
298
339
|
if (releasedJson) findings.push(...figureContractFindings(asset!, releasedJson, say))
|
|
340
|
+
findings.push(...appFindings({ asset: asset!, type: rowType, category: rowCategory, t }))
|
|
299
341
|
return { blocked: findings.some(finding => finding.blocking), findings }
|
|
300
342
|
}
|
|
301
343
|
|
|
@@ -14,6 +14,7 @@ const settingBase = require('@things-factory/setting-base')
|
|
|
14
14
|
const JSZip = require('jszip')
|
|
15
15
|
const { createFigureAsset, addFigurePrimitive, serializeFigureAsset, readFigurePackage } = require('@hatiolab/figure-model')
|
|
16
16
|
|
|
17
|
+
let FigureNamespace: any
|
|
17
18
|
let dataSource: DataSource | null = null
|
|
18
19
|
let Figure: any, FigureHistory: any, FigureReplacedDraft: any, FigureMutation: any
|
|
19
20
|
let keepMesh: any, useThumbnailFiles: any, sha256Of: any, sendFigureMesh: any, figurePackageOf: any
|
|
@@ -87,6 +88,7 @@ let files: ReturnType<typeof mapFiles>
|
|
|
87
88
|
let restore: any
|
|
88
89
|
beforeAll(async () => {
|
|
89
90
|
Figure = loadCompiled('service/figure/figure').Figure
|
|
91
|
+
FigureNamespace = loadCompiled('service/figure/figure-namespace-entity').FigureNamespace
|
|
90
92
|
FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
|
|
91
93
|
FigureReplacedDraft = loadCompiled('service/figure/figure-replaced-draft').FigureReplacedDraft
|
|
92
94
|
FigureMutation = loadCompiled('service/figure/figure-mutation').FigureMutation
|
|
@@ -100,7 +102,7 @@ beforeAll(async () => {
|
|
|
100
102
|
synchronize: true,
|
|
101
103
|
dropSchema: true,
|
|
102
104
|
namingStrategy: new shell.NamingStrategy(),
|
|
103
|
-
entities: [...shell.entities, ...authBase.entities, ...attachmentBase.entities, ...settingBase.entities, Figure, FigureHistory, FigureReplacedDraft],
|
|
105
|
+
entities: [...shell.entities, ...authBase.entities, ...attachmentBase.entities, ...settingBase.entities, Figure, FigureHistory, FigureReplacedDraft, FigureNamespace],
|
|
104
106
|
logging: false
|
|
105
107
|
})
|
|
106
108
|
await dataSource.initialize()
|
|
@@ -126,7 +128,7 @@ const newDomain = async (parent?: any) => {
|
|
|
126
128
|
const name = `mesh-${++seq}`
|
|
127
129
|
const saved = await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name, ...(parent ? { parent } : {}) })
|
|
128
130
|
const domain = await dataSource!.getRepository(shell.Domain).findOneOrFail({ where: { id: saved.id } })
|
|
129
|
-
await dataSource!.getRepository(
|
|
131
|
+
await dataSource!.getRepository(FigureNamespace).save({ domain, name: domain.subdomain, state: 'active', isDefault: true })
|
|
130
132
|
return domain
|
|
131
133
|
}
|
|
132
134
|
const meshRows = (domainId: string) => dataSource!.getRepository(attachmentBase.Attachment).find({ where: { refType: 'figure-mesh', domain: { id: domainId } } })
|