@things-factory/figure-service 10.1.13 → 10.1.16

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.
@@ -0,0 +1,15 @@
1
+ {
2
+ "keep": {
3
+ "days": true,
4
+ "amount": 2
5
+ },
6
+ "auditLog": "logs/.08636eb59927f12972f6774f5947c8507b3564c2-audit.json",
7
+ "files": [
8
+ {
9
+ "date": 1789397538290,
10
+ "name": "logs/application-2026-09-14-23.log",
11
+ "hash": "69c3188e63ed8031b901bd7314adfe3a91152378397ce14edd2f4fafb5a8770e"
12
+ }
13
+ ],
14
+ "hashType": "sha256"
15
+ }
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.13",
3
+ "version": "10.1.16",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -23,14 +23,15 @@
23
23
  "migration:create": "node ../../node_modules/typeorm/cli.js migration:create ./server/migrations/migration"
24
24
  },
25
25
  "dependencies": {
26
- "@hatiolab/figure-model": "^0.1.16",
26
+ "@hatiolab/figure-model": "^0.1.17",
27
27
  "@operato/typeorm-history": "^10.0.0",
28
28
  "@things-factory/ai-client-base": "^10.1.8",
29
- "@things-factory/auth-base": "^10.1.7",
29
+ "@things-factory/auth-base": "^10.1.16",
30
30
  "@things-factory/env": "^10.1.3",
31
- "@things-factory/shell": "^10.1.7",
31
+ "@things-factory/shell": "^10.1.16",
32
+ "graphql-scalars": "^1.22.4",
32
33
  "graphql-upload": "^15.0.2",
33
34
  "koa-router": "^7.4.0"
34
35
  },
35
- "gitHead": "f2cd3d27e32e71cca1d3af5499ef54249b1cc111"
36
+ "gitHead": "aff86bc162b42c1d933711f7bc85a073f1ddcd29"
36
37
  }
@@ -0,0 +1,207 @@
1
+ import type { DataSource } from 'typeorm'
2
+
3
+ const { DataSource: TypeOrmDataSource } = require('typeorm')
4
+ const shell = require('@things-factory/shell')
5
+ const authBase = require('@things-factory/auth-base')
6
+
7
+ let dataSource: DataSource | null = null
8
+ let Figure: any
9
+ let FigureHistory: any
10
+ let FigureQuery: any
11
+ let testDomain: any
12
+
13
+ function loadCompiled(modulePath: string): any {
14
+ const { existsSync } = require('fs')
15
+ const { join } = require('path')
16
+ const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
17
+
18
+ if (!existsSync(resolved)) {
19
+ throw new Error(
20
+ `figure-service 산출물이 없다: ${resolved}\n` +
21
+ `이 시험은 tsc 산출물을 든다. 먼저 빌드할 것:\n` +
22
+ ` yarn workspace @things-factory/figure-service build:server`
23
+ )
24
+ }
25
+
26
+ return require(resolved)
27
+ }
28
+
29
+ const sampleSource = JSON.stringify({
30
+ version: 1,
31
+ type: 'TEST_BOX',
32
+ base: { x: 100, y: 100, z: 100 },
33
+ parts: [
34
+ {
35
+ name: 'box-1',
36
+ primitive: 'cube',
37
+ transform: { position: { x: 0, y: 0, z: 0 }, size: { x: 100, y: 100, z: 100 } },
38
+ material: { token: 'palette.primary' }
39
+ }
40
+ ]
41
+ })
42
+
43
+ beforeAll(async () => {
44
+ Figure = loadCompiled('service/figure/figure').Figure
45
+ FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
46
+ FigureQuery = loadCompiled('service/figure/figure-query').FigureQuery
47
+
48
+ dataSource = new TypeOrmDataSource({
49
+ type: 'sqlite',
50
+ database: ':memory:',
51
+ synchronize: true,
52
+ dropSchema: true,
53
+ namingStrategy: new shell.NamingStrategy(),
54
+ entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory],
55
+ logging: false
56
+ })
57
+
58
+ await dataSource.initialize()
59
+ shell.addDataSource('default', dataSource)
60
+ shell.addDataSource('tx', dataSource)
61
+ })
62
+
63
+ beforeEach(async () => {
64
+ await dataSource!.synchronize(true)
65
+ testDomain = await dataSource!.getRepository(shell.Domain).save({
66
+ name: 'test-domain',
67
+ subdomain: 'test-domain'
68
+ })
69
+ })
70
+
71
+ afterAll(async () => {
72
+ if (!dataSource) return
73
+ shell.removeDataSource('default')
74
+ shell.removeDataSource('tx')
75
+ await dataSource.destroy()
76
+ dataSource = null
77
+ })
78
+
79
+ function makeContext() {
80
+ return {
81
+ state: {
82
+ domain: testDomain,
83
+ user: { id: 'user-1' }
84
+ }
85
+ } as any
86
+ }
87
+
88
+ describe('figureBlueprint 질의 — 소비처(board, twin, things-scene)를 위한 청사진 전달', () => {
89
+ it('존재하지 않는 타입은 null을 반환한다', async () => {
90
+ const query = new FigureQuery()
91
+ const result = await query.figureBlueprint('NON_EXISTENT', makeContext())
92
+ expect(result).toBeNull()
93
+ })
94
+
95
+ it('초안(draft) 상태이고 발행 이력이 없는 Figure는 null을 반환한다 (초안 유출 차단)', async () => {
96
+ const repo = dataSource!.getRepository(Figure)
97
+ await repo.save({
98
+ domain: testDomain,
99
+ type: 'DRAFT_FIGURE',
100
+ name: '초안 도형',
101
+ state: 'draft',
102
+ source: sampleSource,
103
+ version: 0
104
+ })
105
+
106
+ const query = new FigureQuery()
107
+ const result = await query.figureBlueprint('DRAFT_FIGURE', makeContext())
108
+ expect(result).toBeNull()
109
+ })
110
+
111
+ it('발행된(released) Figure는 올바르게 컴파일된 Blueprint를 반환한다', async () => {
112
+ const repo = dataSource!.getRepository(Figure)
113
+ await repo.save({
114
+ domain: testDomain,
115
+ type: 'RELEASED_FIGURE',
116
+ name: '발행 도형',
117
+ state: 'released',
118
+ source: sampleSource,
119
+ version: 1
120
+ })
121
+
122
+ const query = new FigureQuery()
123
+ const result = await query.figureBlueprint('RELEASED_FIGURE', makeContext())
124
+ expect(result).not.toBeNull()
125
+ expect(result.type).toBe('TEST_BOX')
126
+ expect(result.groups).toBeDefined()
127
+ expect(result.groups.length).toBe(1)
128
+ expect(result.groups[0].members[0].name).toBe('box-1')
129
+ })
130
+
131
+ it('현재 저작 중(draft)이더라도 과거 발행판(FigureHistory)이 있으면 최신 발행판을 컴파일해 반환한다', async () => {
132
+ const repo = dataSource!.getRepository(Figure)
133
+ const historyRepo = dataSource!.getRepository(FigureHistory)
134
+
135
+ const v1Source = sampleSource
136
+ const v2DraftSource = JSON.stringify({
137
+ version: 1,
138
+ type: 'UPDATING_FIGURE',
139
+ base: { x: 200, y: 200, z: 200 },
140
+ parts: [
141
+ {
142
+ name: 'box-modified',
143
+ primitive: 'cube',
144
+ transform: { position: { x: 0, y: 0, z: 0 }, size: { x: 200, y: 200, z: 200 } },
145
+ material: { token: 'palette.primary' }
146
+ }
147
+ ]
148
+ })
149
+
150
+ const figure = await repo.save({
151
+ domain: testDomain,
152
+ type: 'UPDATING_FIGURE',
153
+ name: '수정 중인 도형',
154
+ state: 'draft',
155
+ source: v2DraftSource,
156
+ version: 1
157
+ })
158
+
159
+ // 과거 발행된 v1 이력
160
+ await historyRepo.save({
161
+ domain: testDomain,
162
+ originalId: figure.id,
163
+ type: 'UPDATING_FIGURE',
164
+ name: 'v1 발행판',
165
+ state: 'released',
166
+ version: 1,
167
+ source: v1Source
168
+ })
169
+
170
+ const query = new FigureQuery()
171
+ const result = await query.figureBlueprint('UPDATING_FIGURE', makeContext())
172
+
173
+ expect(result).not.toBeNull()
174
+ // 소비처는 수정 중인 초안(box-modified, size 200)이 아니라 기발행된 판(box-1, size 100)을 본다!
175
+ expect(result.groups[0].members[0].name).toBe('box-1')
176
+ expect(result.groups[0].members[0].transform.size.x).toBe(100)
177
+ })
178
+
179
+ /*
180
+ Every test above calls the resolver method directly, so none of them passes through the
181
+ `@privilege` directive — that is attached when the schema is assembled. They stay green
182
+ whether the declaration is there or not.
183
+
184
+ Without a declaration this field is open to callers who have not even signed in
185
+ (`auth-rebuild.md` §1), so that gap is the thing worth guarding. The check reads the source
186
+ rather than the compiled output because the declaration is authored there, and it pins the
187
+ category: `figure` is the authoring permission, and requiring it here would leave board and
188
+ twin users with an empty palette. Someone who hits that wall will be tempted to delete this
189
+ line; the failure message is for them.
190
+ */
191
+ it('declares figure-published on the consumer door, not the authoring permission', () => {
192
+ const { readFileSync } = require('fs')
193
+ const { join } = require('path')
194
+ const source = readFileSync(join(__dirname, 'figure-query.ts'), 'utf-8')
195
+
196
+ const at = source.indexOf('async figureBlueprint')
197
+ expect(at).toBeGreaterThan(-1)
198
+
199
+ const preceding = source.slice(0, at)
200
+ const directive = preceding.lastIndexOf("@Directive('@privilege(")
201
+ const declaration = preceding.slice(directive, preceding.indexOf('\n', directive))
202
+
203
+ expect(declaration).toContain('category: "figure-published"')
204
+ expect(declaration).toContain('privilege: "query"')
205
+ expect(declaration).toContain('domainOwnerGranted: true')
206
+ })
207
+ })
@@ -7,6 +7,7 @@ import {
7
7
  PRIMITIVE_KINDS,
8
8
  SEGMENT_PRESETS,
9
9
  SIZING_RULES,
10
+ FIGURE_PLACEMENTS,
10
11
  compile,
11
12
  costOf,
12
13
  scoreOf,
@@ -131,13 +132,14 @@ export function rulesPrompt(palette: string[]): string {
131
132
  'Answer with a single JSON object and nothing else — no prose, no code fence.',
132
133
  '',
133
134
  'The JSON is a FigureSource:',
134
- ' { type, base: {x,y,z}, detailLevel, parts: [...] }',
135
+ ' { type, base: {x,y,z}, placement?, detailLevel, parts: [...] }',
135
136
  '',
136
137
  'A part is:',
137
138
  ' { name, primitive, transform: { position: {x,y,z}, size: {x,y,z}, rotation?: {x,y,z} },',
138
139
  ' material: {token, preset?}, segments?, shape?, sizing? }',
139
140
  '',
140
141
  'Hard vocabulary — anything outside these is rejected:',
142
+ ` placement ${FIGURE_PLACEMENTS.join(' | ')} (floor: default, ground equipment; ceiling: overhead hoist/OHT; center: airborne/drone/sensor)`,
141
143
  ` primitive ${PRIMITIVE_KINDS.join(' | ')}`,
142
144
  ` segments ${SEGMENT_PRESETS.join(' | ')} (only for cylinder and sphere)`,
143
145
  ` material.token ${palette.join(' | ')}`,
@@ -1,5 +1,7 @@
1
1
  import { In } from 'typeorm'
2
2
  import { Args, Arg, Ctx, Directive, Query, Resolver } from 'type-graphql'
3
+ import { GraphQLJSON } from 'graphql-scalars'
4
+ import { compile } from '@hatiolab/figure-model'
3
5
 
4
6
  import { Domain, getQueryBuilderFromListParams, getRepository, ListParam } from '@things-factory/shell'
5
7
 
@@ -126,4 +128,86 @@ export class FigureQuery {
126
128
 
127
129
  return rows.map(r => r.type).filter(Boolean)
128
130
  }
131
+
132
+ /**
133
+ * 소비처(operato-board, operato-twin, things-scene)를 위한 컴파일된 청사진(FigureBlueprint) 질의.
134
+ *
135
+ * ## 규칙
136
+ * 1. 소비처는 초안(draft)을 볼 수 없다 — **반드시 발행된(released) 판만 전달**한다.
137
+ * 2. 현재 Figure가 released 상태이면 현재 source를 컴파일한다.
138
+ * 3. 현재 Figure가 저작 중(draft)이더라도 이전에 발행된 이력(FigureHistory)이 있으면
139
+ * 가장 최근의 released 판본을 컴파일하여 하위 호환성을 유지한다.
140
+ * 4. 한 번도 발행된 적 없는 초안 자산이거나 존재하지 않는 타입인 경우 null을 반환하여
141
+ * 미완성 자산이 씬 화면에 유출되는 것을 엄격히 차단한다.
142
+ * 5. 도메인 상속 시 자식 도메인의 타입이 부모 도메인의 동일 타입을 항상 오버라이드한다.
143
+ * 6. 권한은 저작(`figure`)과 **다른 category** 인 `figure-published` 다. 소비처 사용자에게
144
+ * 저작 권한을 요구하면 board·twin 의 팔레트가 안 뜨고, 그렇다고 선언을 비우면 이 집에서는
145
+ * 로그인조차 안 한 사람에게 열린다(`auth-rebuild.md` §1). `<module>-published` 는
146
+ * 「그 모듈이 발행한 것을 남이 읽는 문」이라는 규약이다 — 아키텍트 판정(2026-09-15).
147
+ *
148
+ * ## 열거는 막혀 있다 — 지금은 우연이다
149
+ *
150
+ * 이 문의 열쇠는 `type` 하나뿐이라 이름을 모르면 못 꺼낸다. 그런데 그것이 **설계가 아니다** —
151
+ * 목록을 내는 `figures`·`figureTypeNames` 가 `figure` category 로 잠겨 있어서 결과적으로
152
+ * 그런 것뿐이다. 나중에 누가 소비처용 목록 문을 하나 더 열고 `figure-published` 를 붙이면
153
+ * 열거가 함께 열리고, 「이름을 알아야 한다」에 기대고 있던 가정이 조용히 사라진다.
154
+ * 그 문을 낼 사람이 이 단락을 읽고 그때 정하면 된다.
155
+ */
156
+ /*
157
+ This directive is attached by schema assembly, so a test that calls the resolver method
158
+ directly never exercises it. `figure-blueprint-query.test.ts` reads this source for the
159
+ declaration instead — see the test named for it there.
160
+ */
161
+ @Directive('@privilege(category: "figure-published", privilege: "query", domainOwnerGranted: true)')
162
+ @Query(returns => GraphQLJSON, {
163
+ nullable: true,
164
+ description: 'Compiled FigureBlueprint for external consumers (board, twin, things-scene). Only returns released figures.'
165
+ })
166
+ async figureBlueprint(
167
+ @Arg('type') type: string,
168
+ @Ctx() context: ResolverContext
169
+ ): Promise<any> {
170
+ const { domain } = context.state
171
+
172
+ // 자식 도메인 자산 우선 탐색 후, 부모 도메인 표준 자산으로 폴백 (오버라이드 보장)
173
+ let figure = await getRepository(Figure).findOne({
174
+ where: { domain: { id: domain.id }, type }
175
+ })
176
+ if (!figure && domain.parentId) {
177
+ figure = await getRepository(Figure).findOne({
178
+ where: { domain: { id: domain.parentId }, type }
179
+ })
180
+ }
181
+
182
+ if (!figure) {
183
+ return null
184
+ }
185
+
186
+ let sourceToCompile: string | null = null
187
+
188
+ if (figure.state === 'released' && figure.source) {
189
+ sourceToCompile = figure.source
190
+ } else if (figure.id) {
191
+ // 저작 중(draft)인 경우 최근 발행 이력(FigureHistory)을 확인한다
192
+ const latestReleased = await getRepository(FigureHistory).findOne({
193
+ where: { domain: { id: figure.domain?.id ?? domain.id }, originalId: figure.id, state: 'released' },
194
+ order: { version: 'DESC' }
195
+ })
196
+ if (latestReleased && latestReleased.source) {
197
+ sourceToCompile = latestReleased.source
198
+ }
199
+ }
200
+
201
+ if (!sourceToCompile) {
202
+ return null
203
+ }
204
+
205
+ try {
206
+ const parsed = typeof sourceToCompile === 'string' ? JSON.parse(sourceToCompile) : sourceToCompile
207
+ return compile(parsed)
208
+ } catch (err) {
209
+ console.error(`[figureBlueprint] 타입 '${type}' (figure id: ${figure.id}) 청사진 컴파일 실패:`, err)
210
+ return null
211
+ }
212
+ }
129
213
  }
@@ -24,6 +24,7 @@ function part(over: Record<string, unknown> = {}) {
24
24
  /** 온전한 정본 하나. */
25
25
  function source(over: Record<string, unknown> = {}) {
26
26
  return {
27
+ version: 1,
27
28
  type: 'MIXER',
28
29
  base: { x: 300, y: 800, z: 300 },
29
30
  detailLevel: 'M',
@@ -59,22 +60,21 @@ describe('figure-source — 들어오는 문', () => {
59
60
  it('형식을 어기면 던지고, figure-model 의 코드를 그대로 전한다', () => {
60
61
  let thrown: FigureSourceError | undefined
61
62
  try {
62
- // 부품이 없는 정본
63
63
  parseSource(json(source({ parts: [] })))
64
64
  } catch (e) {
65
65
  thrown = e as FigureSourceError
66
66
  }
67
67
 
68
68
  expect(thrown).toBeInstanceOf(FigureSourceError)
69
- expect(thrown!.findings.length).toBeGreaterThan(0)
70
- // 코드를 서버가 다시 짓지 않는다 — figure-model 의 것을 그대로 쓴다
71
- for (const f of thrown!.findings) {
72
- expect(typeof f.code).toBe('string')
73
- expect(f.code.length).toBeGreaterThan(0)
74
- }
69
+ expect(thrown!.findings[0].code).toBe('missing-parts')
70
+ })
71
+
72
+ it('필수 필드가 빠지면 던진다', () => {
73
+ // type 없음
74
+ expect(() => parseSource(json({ base: { x: 1, y: 1, z: 1 }, parts: [part()] }))).toThrow(FigureSourceError)
75
75
  })
76
76
 
77
- it('어디서 났는지(path)를 함께 전한다 — 화면이 그 자리를 가리킬 수 있게', () => {
77
+ it('오류의 위치(at)를 알린다 — 어느 부품인지 저작 화면이 짚을 수 있다', () => {
78
78
  let thrown: FigureSourceError | undefined
79
79
  try {
80
80
  parseSource(json(source({ parts: [part({ transform: { position: { x: 0, y: 0, z: 0 }, size: { x: -1, y: 1, z: 1 } } })] })))
@@ -94,10 +94,16 @@ describe('figure-source — 들어오는 문', () => {
94
94
  })
95
95
 
96
96
  it('정본을 다시 직렬화하지 않는다 — 들어온 문자열을 그대로 저장한다', () => {
97
- // 다시 만들면 키 순서·공백이 바뀌어 「고치지 않았는데 바뀌었다」가 된다
97
+ // 이미 version이 있는 정본은 키 순서·공백을 그대로 보존한다
98
98
  const raw = json(source())
99
99
  expect(parseSource(raw).source).toBe(raw)
100
100
  })
101
+
102
+ it('version이 누락된 구형/외부 정본은 FIGURE_SOURCE_VERSION으로 보정 직렬화한다', () => {
103
+ const withoutVersion = json({ type: 'MIXER', base: { x: 300, y: 800, z: 300 }, detailLevel: 'M', parts: [part()] })
104
+ const parsed = JSON.parse(parseSource(withoutVersion).source)
105
+ expect(parsed.version).toBe(1)
106
+ })
101
107
  })
102
108
 
103
109
  describe('파생값은 서버가 계산한다', () => {
@@ -1,4 +1,4 @@
1
- import { compile, costOf, scoreOf, validate } from '@hatiolab/figure-model'
1
+ import { FIGURE_SOURCE_VERSION, compile, costOf, scoreOf, validate } from '@hatiolab/figure-model'
2
2
 
3
3
  import { FigureFinding } from './figure-type.js'
4
4
 
@@ -24,7 +24,7 @@ import { FigureFinding } from './figure-type.js'
24
24
  * 목록 화면이 실물과 다른 것을 보여 준다. 정본에서 계산한다.
25
25
  */
26
26
  export interface ParsedSource {
27
- /** 저장할 정본 JSON 문자열. 들어온 것을 그대로 쓴다 — 다시 직렬화하지 않는다. */
27
+ /** 저장할 정본 JSON 문자열. 들어온 것을 그대로 쓴다 — 단, version 누락 시에만 정규화 직렬화한다. */
28
28
  source: string
29
29
  /** 목록 화면이 정렬·필터에 쓰는 파생값. */
30
30
  derived: { score: number; triangles: number; groups: number }
@@ -65,6 +65,14 @@ export function parseSource(sourceJson: string): ParsedSource {
65
65
  )
66
66
  }
67
67
 
68
+ // version이 누락된 구형/외부 입력의 경우 FIGURE_SOURCE_VERSION을 부여하여 스키마를 정규화한다.
69
+ // 이미 version이 있는 정본은 포맷 훼손을 막기 위해 sourceJson 원본을 그대로 보존한다.
70
+ let normalizedSource = sourceJson
71
+ if (parsed && typeof parsed === 'object' && (parsed as any).version === undefined) {
72
+ (parsed as any).version = FIGURE_SOURCE_VERSION
73
+ normalizedSource = JSON.stringify(parsed, null, 2)
74
+ }
75
+
68
76
  // 여기까지 왔으면 형식은 맞다. 청사진을 만들어 셈에 쓴다.
69
77
  // 청사진 자체는 **저장하지 않는다** — 파생 캐시이므로 소비하는 쪽이 다시 만든다.
70
78
  const blueprint = compile(parsed as any)
@@ -72,7 +80,7 @@ export function parseSource(sourceJson: string): ParsedSource {
72
80
  const score = scoreOf(blueprint)
73
81
 
74
82
  return {
75
- source: sourceJson,
83
+ source: normalizedSource,
76
84
  derived: {
77
85
  score: score.score,
78
86
  triangles: cost.triangles,
@@ -246,6 +246,27 @@ describe('대화면 문맥에서 대상을 해소한다', () => {
246
246
  )
247
247
  ).rejects.toThrow(/does not match Figure/)
248
248
  })
249
+
250
+ it('재현: 기존 도형 편집 중 미저장 초안의 type이 FIGURE여도 현재 도형 초안으로 허용해야 한다', async () => {
251
+ // DB의 row는 MIXER(또는 OHT)이지만, 캔버스/도크의 초안 type은 일반 'FIGURE'일 수 있음
252
+ const result = await toolNamed('getFigureSource').builder(
253
+ {},
254
+ ctx({ figureId: 'fig-1', palette: ['palette.primary'], draftSource: { ...SOURCE, type: 'FIGURE' } })
255
+ )
256
+ expect(result.draft).toBe(true)
257
+ expect(result.type).toBe('MIXER')
258
+ })
259
+
260
+ it('재현: LLM이 새 미저장 모델의 임시 타입(FIGURE_MTZO5R10)을 figureId로 전달해도 초안을 읽어야 한다', async () => {
261
+ const tempDraft = { ...SOURCE, type: 'FIGURE_MTZO5R10' }
262
+ const host = { editorMode: 'new', draftSource: tempDraft, palette: ['palette.primary'] }
263
+ const result = await toolNamed('getFigureSource').builder(
264
+ { figureId: 'FIGURE_MTZO5R10' },
265
+ ctx(host)
266
+ )
267
+ expect(result.draft).toBe(true)
268
+ expect(result.type).toBe('FIGURE_MTZO5R10')
269
+ })
249
270
  })
250
271
 
251
272
  /*