@things-factory/figure-service 10.1.109 → 10.1.111
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/service/figure/event-subscriber.js +4 -1
- package/dist-server/service/figure/event-subscriber.js.map +1 -1
- package/dist-server/service/figure/figure-format.d.ts +5 -3
- package/dist-server/service/figure/figure-format.js +21 -5
- package/dist-server/service/figure/figure-format.js.map +1 -1
- package/dist-server/service/figure/figure-history.d.ts +3 -0
- package/dist-server/service/figure/figure-history.js +23 -1
- package/dist-server/service/figure/figure-history.js.map +1 -1
- package/dist-server/service/figure/figure-mutation.d.ts +10 -0
- package/dist-server/service/figure/figure-mutation.js +54 -1
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-query.d.ts +1 -1
- package/dist-server/service/figure/figure-query.js +23 -6
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-type.d.ts +1 -0
- package/dist-server/service/figure/figure-type.js +5 -0
- package/dist-server/service/figure/figure-type.js.map +1 -1
- package/dist-server/service/figure/figure.d.ts +3 -0
- package/dist-server/service/figure/figure.js +16 -0
- package/dist-server/service/figure/figure.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/server/service/figure/event-subscriber.ts +4 -1
- package/server/service/figure/figure-blueprint-query.test.ts +38 -0
- package/server/service/figure/figure-deprecation-db.test.ts +107 -0
- package/server/service/figure/figure-format.test.ts +19 -1
- package/server/service/figure/figure-format.ts +27 -8
- package/server/service/figure/figure-history.ts +21 -1
- package/server/service/figure/figure-mutation.ts +45 -1
- package/server/service/figure/figure-query.ts +30 -6
- package/server/service/figure/figure-release-record-db.test.ts +5 -0
- package/server/service/figure/figure-type.ts +5 -0
- package/server/service/figure/figure.ts +21 -0
- package/translations/en.json +5 -1
- package/translations/ko.json +5 -1
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.111",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -25,15 +25,15 @@
|
|
|
25
25
|
"test:templates": "node --test scripts/templates.test.mts"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@hatiolab/figure-model": "^0.2.
|
|
28
|
+
"@hatiolab/figure-model": "^0.2.10",
|
|
29
29
|
"@operato/typeorm-history": "^10.0.0",
|
|
30
30
|
"@things-factory/ai-client-base": "^10.1.81",
|
|
31
|
-
"@things-factory/auth-base": "^10.1.
|
|
31
|
+
"@things-factory/auth-base": "^10.1.111",
|
|
32
32
|
"@things-factory/env": "^10.1.20",
|
|
33
33
|
"@things-factory/shell": "^10.1.106",
|
|
34
34
|
"graphql-scalars": "^1.22.4",
|
|
35
35
|
"graphql-upload": "^15.0.2",
|
|
36
36
|
"koa-router": "^7.4.0"
|
|
37
37
|
},
|
|
38
|
-
"gitHead": "
|
|
38
|
+
"gitHead": "f33122c15e5b3e45393129e9418e2c367a273ade"
|
|
39
39
|
}
|
|
@@ -14,7 +14,8 @@ import { FigureHistory } from './figure-history.js'
|
|
|
14
14
|
*
|
|
15
15
|
* 발행 메모는 Figure 의 칸이 아니다. `releaseFigure` 가 저장할 엔티티에 `__releaseComment` 로 얹어
|
|
16
16
|
* 두면 여기서 판본의 `comment` 로 옮긴다 — 메모는 그 판의 것이지 도형의 것이 아니다. 정본 형식
|
|
17
|
-
* (`__releaseFormat`)
|
|
17
|
+
* (`__releaseFormat`), 직전 판에 견준 변경(`__releaseChanges`), 발행 때 잰 값(`__releaseMetrics`)과
|
|
18
|
+
* 윗모습(`__releaseTopView`)도 같은 길로 옮긴다(ADR-0104).
|
|
18
19
|
*/
|
|
19
20
|
/** What `releaseFigure` laid on the entity for this release, moved onto the history row. */
|
|
20
21
|
function releaseRecordOnto(history: FigureHistory, entity: Figure): FigureHistory {
|
|
@@ -23,6 +24,8 @@ function releaseRecordOnto(history: FigureHistory, entity: Figure): FigureHistor
|
|
|
23
24
|
history.assetFormat = e.__releaseFormat ?? null
|
|
24
25
|
/* [] stays "[]" (nothing changed); null stays null (not compared). */
|
|
25
26
|
history.changes = e.__releaseChanges == null ? null : JSON.stringify(e.__releaseChanges)
|
|
27
|
+
history.metrics = e.__releaseMetrics == null ? null : JSON.stringify(e.__releaseMetrics)
|
|
28
|
+
history.topView = e.__releaseTopView == null ? null : JSON.stringify(e.__releaseTopView)
|
|
26
29
|
return history
|
|
27
30
|
}
|
|
28
31
|
|
|
@@ -374,6 +374,44 @@ describe('figureBlueprint 질의 — 소비처(board, twin, things-scene)에 발
|
|
|
374
374
|
expect(source.slice(0, at)).toContain('@UseMiddleware(SpeakRefusals)')
|
|
375
375
|
})
|
|
376
376
|
|
|
377
|
+
/* ADR-0104 decision 2: a consumer that pins a release asks for it by number; every answer carries its number. */
|
|
378
|
+
describe('a release asked for by number', () => {
|
|
379
|
+
async function threeReleases(type: string) {
|
|
380
|
+
const figure = await dataSource!.getRepository(Figure).save({ domain: testDomain, type, name: 'pinned', state: 'draft', source: boxNamed(type, 'unreleased'), version: 3 })
|
|
381
|
+
for (const version of [1, 2, 3])
|
|
382
|
+
await dataSource!.getRepository(FigureHistory).save({ domain: testDomain, originalId: figure.id, type, name: `v${version}`, state: 'released', version, source: boxNamed(type, `v${version}`) })
|
|
383
|
+
return figure
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
it('answers the latest with its number when no number is asked', async () => {
|
|
387
|
+
await threeReleases('PINNED')
|
|
388
|
+
const result = await new FigureQuery().figureBlueprint('PINNED', makeContext())
|
|
389
|
+
expect([result.version, partsOf(result)]).toEqual([3, ['v3']])
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('answers an older release by its number, not the latest', async () => {
|
|
393
|
+
await threeReleases('PINNED')
|
|
394
|
+
const result = await new FigureQuery().figureBlueprint('PINNED', makeContext(), 1)
|
|
395
|
+
expect([result.version, partsOf(result)]).toEqual([1, ['v1']])
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('refuses a number that was never released by name, never answering another release', async () => {
|
|
399
|
+
await threeReleases('PINNED')
|
|
400
|
+
await expect(new FigureQuery().figureBlueprint('PINNED', makeContext(), 7)).rejects.toMatchObject({ code: 'figure-release-not-found' })
|
|
401
|
+
for (const lang of ['ko', 'en']) expect(require(`../../../translations/${lang}.json`)['error.figure-release-not-found']).toMatch(/\{latest\}/)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('a type nobody released is still null, whatever number is asked', async () => {
|
|
405
|
+
expect(await new FigureQuery().figureBlueprint('NOBODY', makeContext(), 1)).toBeNull()
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('a released row is answered with its own number', async () => {
|
|
409
|
+
await dataSource!.getRepository(Figure).save({ domain: testDomain, type: 'ROW', name: 'row', state: 'released', source: sampleSource, version: 4 })
|
|
410
|
+
expect((await new FigureQuery().figureBlueprint('ROW', makeContext())).version).toBe(4)
|
|
411
|
+
expect((await new FigureQuery().figureBlueprint('ROW', makeContext(), 4)).version).toBe(4)
|
|
412
|
+
})
|
|
413
|
+
})
|
|
414
|
+
|
|
377
415
|
describe('a child domain shadows its parent only with a released version', () => {
|
|
378
416
|
const boxOf = (_type: string, part: string) => box(part)
|
|
379
417
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retiring a figure and its names by language (ADR-0104 decision 6), on sqlite with the history subscriber on.
|
|
3
|
+
* A retired figure stays released and is served as before; retiring writes no history row.
|
|
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 { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy } = require('@hatiolab/figure-model')
|
|
11
|
+
|
|
12
|
+
let dataSource: DataSource | null = null
|
|
13
|
+
let Figure: any
|
|
14
|
+
let FigureHistory: any
|
|
15
|
+
let Subscriber: any
|
|
16
|
+
let FigureMutation: any
|
|
17
|
+
let assertNames: any
|
|
18
|
+
|
|
19
|
+
function loadCompiled(modulePath: string): any {
|
|
20
|
+
const { existsSync } = require('fs')
|
|
21
|
+
const { join } = require('path')
|
|
22
|
+
const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
|
|
23
|
+
if (!existsSync(resolved))
|
|
24
|
+
throw new Error(`figure-service 산출물이 없다: ${resolved}\n먼저 빌드할 것:\n yarn workspace @things-factory/figure-service build:server`)
|
|
25
|
+
return require(resolved)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
Figure = loadCompiled('service/figure/figure').Figure
|
|
30
|
+
FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
|
|
31
|
+
Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
|
|
32
|
+
;({ FigureMutation, assertNames } = loadCompiled('service/figure/figure-mutation'))
|
|
33
|
+
dataSource = new TypeOrmDataSource({
|
|
34
|
+
type: 'sqlite',
|
|
35
|
+
database: ':memory:',
|
|
36
|
+
synchronize: true,
|
|
37
|
+
dropSchema: true,
|
|
38
|
+
namingStrategy: new shell.NamingStrategy(),
|
|
39
|
+
entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory],
|
|
40
|
+
subscribers: [Subscriber],
|
|
41
|
+
logging: false
|
|
42
|
+
})
|
|
43
|
+
await dataSource.initialize()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
afterAll(async () => {
|
|
47
|
+
await dataSource?.destroy()
|
|
48
|
+
dataSource = null
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const releasable = () => {
|
|
52
|
+
let a = addFigureBox(createFigureAsset('Mixer'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
53
|
+
a = applyFigureAuthoring(a, { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } })
|
|
54
|
+
return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a, { over: 'range' }), placement: 'floor' }))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let seq = 0
|
|
58
|
+
async function released(type: string) {
|
|
59
|
+
const name = `retire-${++seq}`
|
|
60
|
+
const domain = await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name })
|
|
61
|
+
const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
|
|
62
|
+
const figure = await dataSource!.getRepository(Figure).save({ type, name: 'mixer', source: releasable(), state: 'draft', version: 0, domain })
|
|
63
|
+
await new FigureMutation().releaseFigure(figure.id, 'one', context)
|
|
64
|
+
return { figure, context, domain }
|
|
65
|
+
}
|
|
66
|
+
const historyCount = (id: string) => dataSource!.getRepository(FigureHistory).count({ where: { originalId: id } })
|
|
67
|
+
|
|
68
|
+
describe('retiring a figure', () => {
|
|
69
|
+
it('stays released, writes no history row, and names what to use instead', async () => {
|
|
70
|
+
const { figure, context, domain } = await released('OLD_MIXER')
|
|
71
|
+
await dataSource!.getRepository(Figure).save({ type: 'NEW_MIXER', name: 'new', source: releasable(), state: 'draft', version: 0, domain })
|
|
72
|
+
const retired = await new FigureMutation().setFigureDeprecation(figure.id, true, context, 'NEW_MIXER')
|
|
73
|
+
expect([retired.deprecated, retired.supersededBy, retired.state, retired.version]).toEqual([true, 'NEW_MIXER', 'released', 1])
|
|
74
|
+
expect(await historyCount(figure.id)).toBe(1)
|
|
75
|
+
const back = await new FigureMutation().setFigureDeprecation(figure.id, false, context, 'NEW_MIXER')
|
|
76
|
+
expect([back.deprecated, back.supersededBy, back.state]).toEqual([false, null, 'released'])
|
|
77
|
+
expect(await historyCount(figure.id)).toBe(1)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('refuses a replacement that is not there, or itself', async () => {
|
|
81
|
+
const { figure, context } = await released('LONE_MIXER')
|
|
82
|
+
await expect(new FigureMutation().setFigureDeprecation(figure.id, true, context, 'NOWHERE')).rejects.toThrow(/NOWHERE/)
|
|
83
|
+
await expect(new FigureMutation().setFigureDeprecation(figure.id, true, context, 'LONE_MIXER')).rejects.toThrow(/자기 자신/)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('names by language', () => {
|
|
88
|
+
it('a language code to a name of 1-128 characters', () => {
|
|
89
|
+
expect(() => assertNames({ en: 'Mixer', 'zh-Hans': '搅拌机' })).not.toThrow()
|
|
90
|
+
expect(() => assertNames({ English: 'Mixer' })).toThrow(/English/)
|
|
91
|
+
expect(() => assertNames({ en: '' })).toThrow(/en/)
|
|
92
|
+
expect(() => assertNames({ en: 'x'.repeat(129) })).toThrow(/en/)
|
|
93
|
+
expect(() => assertNames(['en'])).toThrow()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('kept on the figure and copied onto the release it goes out with', async () => {
|
|
97
|
+
const name = `names-${++seq}`
|
|
98
|
+
const domain = await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name })
|
|
99
|
+
const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
|
|
100
|
+
const figure = await dataSource!.getRepository(Figure).save({ type: 'NAMED', name: 'mixer', source: releasable(), state: 'draft', version: 0, domain })
|
|
101
|
+
await new FigureMutation().updateFigure(figure.id, { names: { en: 'Mixer', ko: '믹서' } }, context)
|
|
102
|
+
await new FigureMutation().releaseFigure(figure.id, 'one', context)
|
|
103
|
+
const [row] = await dataSource!.getRepository(FigureHistory).find({ where: { originalId: figure.id } })
|
|
104
|
+
expect(row.names).toEqual({ en: 'Mixer', ko: '믹서' })
|
|
105
|
+
await expect(new FigureMutation().updateFigure(figure.id, { names: { English: 'x' } }, context)).rejects.toThrow(/English/)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -330,7 +330,12 @@ describe('figure-format — 판을 알아보는 문', () => {
|
|
|
330
330
|
return serializeFigureAsset(applyFigureAuthoring(asset, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(asset, { over: 'range' }), placement: 'floor' }))
|
|
331
331
|
}
|
|
332
332
|
it('a first release: its format, and nothing changed', () => {
|
|
333
|
-
|
|
333
|
+
const record = releaseRecordOf(serializeFigureAsset(figureAsset()))
|
|
334
|
+
expect([record.format, record.changes]).toEqual(['figure-asset-1', []])
|
|
335
|
+
/* Not sized from the instance: nothing measured across sizes, only where its origin is. Its plan is its one body from above. */
|
|
336
|
+
expect(record.metrics).toEqual({ origin: { centred: true, off: [] } })
|
|
337
|
+
expect(record.topView.shapes.map(s => s.id)).toEqual(['body'])
|
|
338
|
+
expect(record.topView.size).toBeNull()
|
|
334
339
|
})
|
|
335
340
|
it('the same again changes nothing; a wider room is said and does not block', () => {
|
|
336
341
|
const v1 = serializeFigureAsset(figureAsset())
|
|
@@ -349,4 +354,17 @@ describe('figure-format — 판을 알아보는 문', () => {
|
|
|
349
354
|
for (const dict of [KO, EN]) for (const field of ['message', 'why']) expect(dict[`figure.gate.contract.${code}.${field}`]).toBeTruthy()
|
|
350
355
|
})
|
|
351
356
|
})
|
|
357
|
+
|
|
358
|
+
/* ADR-0104 decision 4: off the middle of its room is said at release, never refused. */
|
|
359
|
+
it('an origin off the middle of the room is a warning, and the release goes on', () => {
|
|
360
|
+
let asset = createFigureAsset('Door')
|
|
361
|
+
asset = addFigureBox(asset, { id: 'leaf', size: { x: 100, y: 200, z: 40 }, position: { x: 50, y: 100, z: 0 } })
|
|
362
|
+
asset = applyFigureAuthoring(asset, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(asset), placement: 'floor' })
|
|
363
|
+
const result = inspectSource(serializeFigureAsset(asset), null, undefined, 'DOOR')
|
|
364
|
+
const off = result.findings.find(f => f.code === 'origin-off-centre')!
|
|
365
|
+
expect(off.blocking).toBe(false)
|
|
366
|
+
expect(off.message).toMatch(/X 50 mm/)
|
|
367
|
+
expect(result.findings.filter(f => f.blocking).map(f => f.code)).not.toContain('origin-off-centre')
|
|
368
|
+
expect(inspectSource(serializeFigureAsset(figureAsset()), null, undefined, 'MIXER').findings.map(f => f.code)).not.toContain('origin-off-centre')
|
|
369
|
+
})
|
|
352
370
|
})
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* board (`consumablePayloadOf`). The format is `figure-asset-1`, and the asset says so in its `version`; a source
|
|
4
4
|
* that does not is refused by name, because a source the server cannot identify is one it must not save.
|
|
5
5
|
*/
|
|
6
|
-
import { compileFigureAsset, inspectFigureAsset, isPlaceholderType, parseFigureAsset, serializeFigureAsset, figureContractChanges, figureCostOf, figureScoreOf } from '@hatiolab/figure-model'
|
|
7
|
-
import type { FigureAsset, FigureContractChange, FigureContractError, FigureViolation } from '@hatiolab/figure-model'
|
|
6
|
+
import { compileFigureAsset, inspectFigureAsset, isPlaceholderType, parseFigureAsset, serializeFigureAsset, figureContractChanges, figureCostOf, figureOriginCentredOf, figureReleaseMetricsOf, figureScoreOf, figureTopViewOf } from '@hatiolab/figure-model'
|
|
7
|
+
import type { FigureAsset, FigureContractChange, FigureContractError, FigureReleaseMetrics, FigureTopView, FigureViolation } from '@hatiolab/figure-model'
|
|
8
8
|
|
|
9
9
|
import type { FigureFinding } from './figure-type.js'
|
|
10
10
|
|
|
@@ -280,6 +280,21 @@ export function inspectSource(
|
|
|
280
280
|
blocking: false
|
|
281
281
|
})
|
|
282
282
|
}
|
|
283
|
+
/*
|
|
284
|
+
Said, never refused (ADR-0104 decision 4): the origin is where the figure is fastened, and a door fastened at its
|
|
285
|
+
hinge is a right model. A drawing that places figures by the middle of their room reads this before it pins one.
|
|
286
|
+
*/
|
|
287
|
+
const origin = figureOriginCentredOf(asset!)
|
|
288
|
+
if (origin && !origin.centred) {
|
|
289
|
+
const off = origin.off.map(o => `${o.axis.toUpperCase()} ${o.mm} mm`).join(', ')
|
|
290
|
+
findings.push({
|
|
291
|
+
code: 'origin-off-centre',
|
|
292
|
+
message: say('figure.gate.origin-off-centre.message', { off }, `The origin is off the middle of the declared room: ${off}.`),
|
|
293
|
+
why: say('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.'),
|
|
294
|
+
how: say('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.'),
|
|
295
|
+
blocking: false
|
|
296
|
+
})
|
|
297
|
+
}
|
|
283
298
|
if (releasedJson) findings.push(...figureContractFindings(asset!, releasedJson, say))
|
|
284
299
|
return { blocked: findings.some(finding => finding.blocking), findings }
|
|
285
300
|
}
|
|
@@ -450,21 +465,25 @@ function figureContractWording(change: FigureContractChange, say: Say): Pick<Gat
|
|
|
450
465
|
}
|
|
451
466
|
|
|
452
467
|
/**
|
|
453
|
-
* What a release records about itself (ADR-0104 decision 5): the format its asset is written in,
|
|
454
|
-
* against the previous release -- the kernel's own comparison, kept as it was then so a consumer that pins releases
|
|
468
|
+
* What a release records about itself (ADR-0104 decision 5): the format its asset is written in, what it measured
|
|
469
|
+
* (its cost at its largest, whether resizing is stretching), its plan from above, and what changed against the previous release -- the kernel's own comparison, kept as it was then so a consumer that pins releases
|
|
455
470
|
* reads it rather than computing it again. `[]` for a first release; `null` when the previous release could not be
|
|
456
471
|
* read, which is a different fact from "nothing changed".
|
|
457
472
|
*/
|
|
458
|
-
export function releaseRecordOf(
|
|
473
|
+
export function releaseRecordOf(
|
|
474
|
+
sourceJson: string,
|
|
475
|
+
releasedJson?: string | null
|
|
476
|
+
): { format: string; changes: FigureContractChange[] | null; metrics: FigureReleaseMetrics; topView: FigureTopView } {
|
|
459
477
|
const asset = parseFigureAsset(sourceJson)
|
|
460
|
-
|
|
478
|
+
const measured = { format: asset.version, metrics: figureReleaseMetricsOf(asset), topView: figureTopViewOf(asset, null) }
|
|
479
|
+
if (!releasedJson) return { ...measured, changes: [] }
|
|
461
480
|
let before: FigureAsset
|
|
462
481
|
try {
|
|
463
482
|
before = parseFigureAsset(releasedJson)
|
|
464
483
|
} catch {
|
|
465
|
-
return {
|
|
484
|
+
return { ...measured, changes: null }
|
|
466
485
|
}
|
|
467
|
-
return {
|
|
486
|
+
return { ...measured, changes: figureContractChanges(before, asset) }
|
|
468
487
|
}
|
|
469
488
|
|
|
470
489
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Field, ID, ObjectType, Float, Int } from 'type-graphql'
|
|
2
|
+
import { GraphQLJSON } from 'graphql-scalars'
|
|
2
3
|
import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, RelationId } from 'typeorm'
|
|
3
4
|
|
|
4
5
|
import {
|
|
@@ -55,6 +56,10 @@ export class FigureHistory implements HistoryEntityInterface<Figure> {
|
|
|
55
56
|
@Field({ nullable: true, description: 'The description at this version.' })
|
|
56
57
|
description?: string
|
|
57
58
|
|
|
59
|
+
@Column('simple-json', { nullable: true, default: null })
|
|
60
|
+
@Field(type => GraphQLJSON, { nullable: true, description: 'Display names by language at this version.' })
|
|
61
|
+
names?: Record<string, string>
|
|
62
|
+
|
|
58
63
|
@Column({ nullable: true })
|
|
59
64
|
@Field({ nullable: true, description: 'The classification code at this version.' })
|
|
60
65
|
category?: string
|
|
@@ -106,10 +111,25 @@ export class FigureHistory implements HistoryEntityInterface<Figure> {
|
|
|
106
111
|
@Field({
|
|
107
112
|
nullable: true,
|
|
108
113
|
description:
|
|
109
|
-
'What changed against the previous release, as JSON: [{ code, blocking, subject?, values, detail }]. [] for the first release
|
|
114
|
+
'What changed against the previous release, as JSON: [{ code, blocking, subject?, values, detail }]. [] for the first release. null when it was not compared: the previous release could not be read, or this release was made before releases recorded it (figure-service 10.1.109).'
|
|
110
115
|
})
|
|
111
116
|
changes?: string
|
|
112
117
|
|
|
118
|
+
@Column({ nullable: true, ...longTextColumn() })
|
|
119
|
+
@Field({
|
|
120
|
+
nullable: true,
|
|
121
|
+
description:
|
|
122
|
+
'What this version measured once, as JSON (figure-model figureReleaseMetricsOf): { atLargest?: { size, triangles, groups }, linear?: { scalable, parts, probe } }. Only for a figure that takes its size from the instance; {} otherwise.'
|
|
123
|
+
})
|
|
124
|
+
metrics?: string
|
|
125
|
+
|
|
126
|
+
@Column({ nullable: true, ...longTextColumn() })
|
|
127
|
+
@Field({
|
|
128
|
+
nullable: true,
|
|
129
|
+
description: 'The plan from above at the authored size, as JSON (figure-model figureTopViewOf): { shapes: [{ id, color, outline, holes, top, transparent }], size }.'
|
|
130
|
+
})
|
|
131
|
+
topView?: string
|
|
132
|
+
|
|
113
133
|
@Column({ nullable: true })
|
|
114
134
|
@Field({ nullable: true, description: 'When the original figure was created.' })
|
|
115
135
|
createdAt?: Date
|
|
@@ -17,6 +17,15 @@ import { parseSource } from './figure-format.js'
|
|
|
17
17
|
import { ProposeFigureFailure, proposeFigureSteps } from './figure-propose-steps.js'
|
|
18
18
|
import { prepareFigureWrite, compareAndSwapFigure, pictureOf } from './figure-write.js'
|
|
19
19
|
|
|
20
|
+
/** Display names by language: a language code to a name of 1-128 characters. Refused whole, with the bad entry named. */
|
|
21
|
+
export function assertNames(names: unknown): asserts names is Record<string, string> {
|
|
22
|
+
if (!names || typeof names !== 'object' || Array.isArray(names)) throw new Error('names 는 { 언어: 이름 } 이어야 한다')
|
|
23
|
+
for (const [lang, name] of Object.entries(names)) {
|
|
24
|
+
if (!/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(lang)) throw new Error(`'${lang}' 는 언어 코드가 아니다`)
|
|
25
|
+
if (typeof name !== 'string' || !name.trim() || name.length > 128) throw new Error(`'${lang}' 의 이름은 1~128자 글이어야 한다`)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
@Resolver(Figure)
|
|
21
30
|
export class FigureMutation {
|
|
22
31
|
@Directive('@transaction')
|
|
@@ -174,6 +183,8 @@ export class FigureMutation {
|
|
|
174
183
|
행과 정본 둘 다 고친다. 정본 안에도 `type` 이 있어서 한쪽만 고치면 저장된 JSON 과
|
|
175
184
|
행이 다른 이름을 말하고, 발행할 때 어느 쪽이 도면에 적히는지가 갈린다.
|
|
176
185
|
*/
|
|
186
|
+
if (patch.names !== undefined) assertNames(patch.names)
|
|
187
|
+
|
|
177
188
|
let renamed: string | undefined
|
|
178
189
|
if (patch.type !== undefined && patch.type !== figure.type) {
|
|
179
190
|
const change = await this.renameType(figure, domain, patch.type, source, tx)
|
|
@@ -338,7 +349,9 @@ export class FigureMutation {
|
|
|
338
349
|
/* 판본 구독자가 이것들을 판본의 comment · assetFormat · changes 로 옮긴다. Figure 의 칸이 아니다. */
|
|
339
350
|
__releaseComment: comment ?? null,
|
|
340
351
|
__releaseFormat: record.format,
|
|
341
|
-
__releaseChanges: record.changes
|
|
352
|
+
__releaseChanges: record.changes,
|
|
353
|
+
__releaseMetrics: record.metrics,
|
|
354
|
+
__releaseTopView: record.topView
|
|
342
355
|
} as any)
|
|
343
356
|
}
|
|
344
357
|
|
|
@@ -408,6 +421,37 @@ export class FigureMutation {
|
|
|
408
421
|
return true
|
|
409
422
|
}
|
|
410
423
|
|
|
424
|
+
/**
|
|
425
|
+
* 퇴역시키거나 되살린다(ADR-0104 결정 6).
|
|
426
|
+
*
|
|
427
|
+
* 표시일 뿐이다. 발행판은 그대로 남고 받는 쪽은 그대로 받는다 — 발행판은 지우지 않는다. 그래서 **발행을 풀지 않는다**:
|
|
428
|
+
* 행을 엔티티로 저장하지 않고 질의로 고친다. 엔티티로 저장하면 발행된 행이라 판본 구독자가 같은 번호로 판본을 또
|
|
429
|
+
* 뜬다. 대신 쓸 것(`supersededBy`)은 같은 도메인(또는 상위)에 있는 다른 type 이어야 한다.
|
|
430
|
+
*/
|
|
431
|
+
@Directive('@transaction')
|
|
432
|
+
@Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
|
|
433
|
+
@Mutation(returns => Figure, { description: 'Retires a figure or brings it back. It stays released and served; supersededBy names the type to use instead.' })
|
|
434
|
+
async setFigureDeprecation(
|
|
435
|
+
@Arg('id') id: string,
|
|
436
|
+
@Arg('deprecated') deprecated: boolean,
|
|
437
|
+
@Ctx() context: ResolverContext,
|
|
438
|
+
@Arg('supersededBy', { nullable: true }) supersededBy?: string
|
|
439
|
+
): Promise<Figure> {
|
|
440
|
+
const { domain, tx } = context.state
|
|
441
|
+
const repository = tx.getRepository(Figure)
|
|
442
|
+
const scope = { id: In([domain.id, domain.parentId].filter(Boolean)) }
|
|
443
|
+
const figure = await repository.findOne({ where: { domain: scope, id } })
|
|
444
|
+
if (!figure) throw new Error(`Figure ${id} 를 찾을 수 없다`)
|
|
445
|
+
const instead = deprecated ? supersededBy?.trim() || null : null
|
|
446
|
+
if (instead) {
|
|
447
|
+
if (instead === figure.type) throw new Error(`Figure '${figure.type}' 는 자기 자신으로 대신할 수 없다`)
|
|
448
|
+
const other = await repository.findOne({ where: { domain: scope, type: instead } })
|
|
449
|
+
if (!other) throw new Error(`대신 쓸 Figure '${instead}' 가 이 도메인에 없다`)
|
|
450
|
+
}
|
|
451
|
+
await repository.createQueryBuilder().update(Figure).set({ deprecated, supersededBy: instead }).where('id = :id', { id }).callListeners(false).execute()
|
|
452
|
+
return await repository.findOneOrFail({ where: { id } })
|
|
453
|
+
}
|
|
454
|
+
|
|
411
455
|
/**
|
|
412
456
|
* 저작 보조에게 제안을 시킨다(ADR-0094).
|
|
413
457
|
*
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { In } from 'typeorm'
|
|
2
|
-
import { Args, Arg, Ctx, Directive, Query, Resolver, UseMiddleware } from 'type-graphql'
|
|
2
|
+
import { Args, Arg, Ctx, Directive, Int, Query, Resolver, UseMiddleware } from 'type-graphql'
|
|
3
3
|
import { GraphQLJSON } from 'graphql-scalars'
|
|
4
4
|
|
|
5
5
|
import {
|
|
@@ -242,11 +242,13 @@ export class FigureQuery {
|
|
|
242
242
|
@Directive('@privilege(category: "figure-published", privilege: "query", domainOwnerGranted: true)')
|
|
243
243
|
@Query(returns => GraphQLJSON, {
|
|
244
244
|
nullable: true,
|
|
245
|
-
description:
|
|
245
|
+
description:
|
|
246
|
+
'A released figure for its consumers (board, twin, things-scene): { type, version, asset }. The latest release, or the one numbered `version` for a consumer that pins releases (ADR-0104). Only returns released figures.'
|
|
246
247
|
})
|
|
247
248
|
async figureBlueprint(
|
|
248
249
|
@Arg('type') type: string,
|
|
249
|
-
@Ctx() context: ResolverContext
|
|
250
|
+
@Ctx() context: ResolverContext,
|
|
251
|
+
@Arg('version', type => Int, { nullable: true, description: 'A release number. Absent: the latest release.' }) pinned?: number
|
|
250
252
|
): Promise<any> {
|
|
251
253
|
const { domain } = context.state
|
|
252
254
|
|
|
@@ -257,18 +259,32 @@ export class FigureQuery {
|
|
|
257
259
|
architect ruling, 2026-09-17). This used to skip the fallback as soon as a child row existed and
|
|
258
260
|
return null.
|
|
259
261
|
*/
|
|
260
|
-
const
|
|
262
|
+
const latest =
|
|
261
263
|
(await releasedSourceOf(domain.id, type)) ?? (domain.parentId ? await releasedSourceOf(domain.parentId, type) : null)
|
|
262
264
|
|
|
263
|
-
if (!
|
|
265
|
+
if (!latest) {
|
|
264
266
|
return null
|
|
265
267
|
}
|
|
266
268
|
|
|
269
|
+
/*
|
|
270
|
+
A pinned release is read from the same figure the latest comes from -- the shadowing rule decides whose
|
|
271
|
+
releases these are, then the number picks one. Releases are never deleted, so a number once handed out is
|
|
272
|
+
always there; a number that never was is refused by name rather than answered with another release.
|
|
273
|
+
*/
|
|
274
|
+
const found = pinned === undefined || pinned === null || pinned === latest.version ? latest : await releaseNumbered(latest.figure, pinned)
|
|
275
|
+
if (!found) {
|
|
276
|
+
throw new Refusal(
|
|
277
|
+
'figure-release-not-found',
|
|
278
|
+
{ type, version: pinned, latest: latest.version },
|
|
279
|
+
'{type} has no release {version}. its latest release is {latest}.'
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
267
283
|
const { figure, source: sourceToCompile, version } = found
|
|
268
284
|
|
|
269
285
|
try {
|
|
270
286
|
const text = typeof sourceToCompile === 'string' ? sourceToCompile : JSON.stringify(sourceToCompile)
|
|
271
|
-
return { type, asset: consumablePayloadOf(text).asset }
|
|
287
|
+
return { type, version, asset: consumablePayloadOf(text).asset }
|
|
272
288
|
} catch (err) {
|
|
273
289
|
console.error(`[figureBlueprint] 타입 '${type}' (figure id: ${figure.id}) 자산 컴파일 실패:`, err)
|
|
274
290
|
|
|
@@ -290,6 +306,14 @@ export class FigureQuery {
|
|
|
290
306
|
}
|
|
291
307
|
}
|
|
292
308
|
|
|
309
|
+
/** One numbered release of a figure, or null when it has none by that number. */
|
|
310
|
+
async function releaseNumbered(figure: Figure, version: number): Promise<{ figure: Figure; source: string; version: number } | null> {
|
|
311
|
+
const row = await getRepository(FigureHistory).findOne({
|
|
312
|
+
where: { originalId: figure.id, state: 'released', version }
|
|
313
|
+
})
|
|
314
|
+
return row?.source ? { figure, source: row.source, version: row.version } : null
|
|
315
|
+
}
|
|
316
|
+
|
|
293
317
|
/**
|
|
294
318
|
* Finds the released source of a type in one domain, or null.
|
|
295
319
|
*
|
|
@@ -63,6 +63,8 @@ describe('a release record on its history row', () => {
|
|
|
63
63
|
expect(row.assetFormat).toBe('figure-asset-1')
|
|
64
64
|
expect(JSON.parse(row.changes)).toEqual(changes)
|
|
65
65
|
expect(row.comment).toBe('first')
|
|
66
|
+
expect(row.metrics).toBeNull()
|
|
67
|
+
expect(row.topView).toBeNull()
|
|
66
68
|
})
|
|
67
69
|
it('[] stays nothing changed, null stays not compared', async () => {
|
|
68
70
|
expect((await release({ __releaseFormat: 'figure-asset-1', __releaseChanges: [] })).changes).toBe('[]')
|
|
@@ -90,5 +92,8 @@ describe('releaseFigure records it', () => {
|
|
|
90
92
|
expect(rows[0].changes).toBe('[]')
|
|
91
93
|
const codes = JSON.parse(rows[1].changes).map((c: any) => c.code)
|
|
92
94
|
expect(codes).toContain('occupancy-changed')
|
|
95
|
+
/* What each release measured, and its plan from above, are on its own row. */
|
|
96
|
+
expect(JSON.parse(rows[0].metrics)).toEqual({ origin: { centred: true, off: [] } })
|
|
97
|
+
expect(JSON.parse(rows[1].topView).shapes.map((s: any) => s.id)).toEqual(['body'])
|
|
93
98
|
})
|
|
94
99
|
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { FigureReplacedDraft } from './figure-replaced-draft.js'
|
|
2
2
|
import { Field, InputType, Int, ObjectType } from 'type-graphql'
|
|
3
|
+
import { GraphQLJSON } from 'graphql-scalars'
|
|
3
4
|
|
|
4
5
|
import { Figure } from './figure.js'
|
|
5
6
|
|
|
@@ -61,6 +62,10 @@ export class FigurePatch {
|
|
|
61
62
|
@Field({ nullable: true, description: 'The new description.' })
|
|
62
63
|
description?: string
|
|
63
64
|
|
|
65
|
+
/* 언어별 표시 이름. 안 준 것은 그대로, `{}` 는 전부 지운다. */
|
|
66
|
+
@Field(type => GraphQLJSON, { nullable: true, description: 'Display names by language. {} clears them; omitting the field leaves them.' })
|
|
67
|
+
names?: Record<string, string>
|
|
68
|
+
|
|
64
69
|
@Field({ nullable: true, description: 'The new classification code.' })
|
|
65
70
|
category?: string
|
|
66
71
|
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
UpdateDateColumn
|
|
11
11
|
} from 'typeorm'
|
|
12
12
|
import { Field, Float, ID, Int, ObjectType } from 'type-graphql'
|
|
13
|
+
import { GraphQLJSON } from 'graphql-scalars'
|
|
13
14
|
|
|
14
15
|
import { User } from '@things-factory/auth-base'
|
|
15
16
|
import { Domain, longTextColumn } from '@things-factory/shell'
|
|
@@ -84,6 +85,26 @@ export class Figure {
|
|
|
84
85
|
@Field({ nullable: true, description: 'A detailed description for the figure.' })
|
|
85
86
|
description?: string
|
|
86
87
|
|
|
88
|
+
/*
|
|
89
|
+
언어별 표시 이름(ADR-0104 결정 6). 없는 언어는 `name` 을 보인다. 식별자는 여전히 `type` 이다 — 이름은 몇 번을
|
|
90
|
+
바꿔도 도면이 가리키는 것이 달라지지 않는다.
|
|
91
|
+
*/
|
|
92
|
+
@Column('simple-json', { nullable: true, default: null })
|
|
93
|
+
@Field(type => GraphQLJSON, { nullable: true, description: 'Display names by language, e.g. { "en": "...", "ko": "..." }. A language not here shows `name`.' })
|
|
94
|
+
names?: Record<string, string>
|
|
95
|
+
|
|
96
|
+
/*
|
|
97
|
+
퇴역(ADR-0104 결정 6). 표시일 뿐이다 — 발행판은 지우지 않고 받는 쪽은 그대로 받는다. 무엇으로 대신하라는지는
|
|
98
|
+
`supersededBy`(그 도메인의 type)가 말한다. 퇴역은 저장이 아니라 `setFigureDeprecation` 이 정하고, 발행을 풀지 않는다.
|
|
99
|
+
*/
|
|
100
|
+
@Column({ nullable: true, default: false })
|
|
101
|
+
@Field({ nullable: true, description: 'Retired: still released and still served, but not to be chosen for new work.' })
|
|
102
|
+
deprecated?: boolean
|
|
103
|
+
|
|
104
|
+
@Column({ nullable: true })
|
|
105
|
+
@Field({ nullable: true, description: 'The type to use instead of a retired figure.' })
|
|
106
|
+
supersededBy?: string
|
|
107
|
+
|
|
87
108
|
/**
|
|
88
109
|
* 분류 하나. **팔레트에서 묶어 보이는 기준이다.**
|
|
89
110
|
*
|
package/translations/en.json
CHANGED
|
@@ -74,5 +74,9 @@
|
|
|
74
74
|
"figure.gate.contract.public-input-range-narrowed.message": "The range of '{input}' narrowed: {before} → {after}.",
|
|
75
75
|
"figure.gate.contract.public-input-range-narrowed.why": "An instance may have set a value outside the new range. Which values are placed is not recorded.",
|
|
76
76
|
"figure.gate.contract.public-input-added.message": "'{input}' can now be set on a board.",
|
|
77
|
-
"figure.gate.contract.public-input-added.why": "Placed instances that give no value stand at its default."
|
|
77
|
+
"figure.gate.contract.public-input-added.why": "Placed instances that give no value stand at its default.",
|
|
78
|
+
"error.figure-release-not-found": "{type} has no release {version}. its latest release is {latest}.",
|
|
79
|
+
"figure.gate.origin-off-centre.message": "The origin is off the middle of the declared room: {off}.",
|
|
80
|
+
"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.",
|
|
81
|
+
"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."
|
|
78
82
|
}
|
package/translations/ko.json
CHANGED
|
@@ -74,5 +74,9 @@
|
|
|
74
74
|
"figure.gate.contract.public-input-range-narrowed.message": "'{input}' 값의 범위가 좁아졌습니다: {before} → {after}.",
|
|
75
75
|
"figure.gate.contract.public-input-range-narrowed.why": "새 범위 밖의 값을 준 인스턴스가 있을 수 있습니다. 어떤 값이 놓였는지는 기록하지 않습니다.",
|
|
76
76
|
"figure.gate.contract.public-input-added.message": "'{input}' 값을 이제 보드에서 정할 수 있습니다.",
|
|
77
|
-
"figure.gate.contract.public-input-added.why": "값을 주지 않은 인스턴스는 기본값으로 섭니다."
|
|
77
|
+
"figure.gate.contract.public-input-added.why": "값을 주지 않은 인스턴스는 기본값으로 섭니다.",
|
|
78
|
+
"error.figure-release-not-found": "{type} 에는 {version} 번 발행판이 없습니다. 최신 발행판은 {latest} 번입니다.",
|
|
79
|
+
"figure.gate.origin-off-centre.message": "원점이 차지 공간 가운데에서 비켜 있습니다: {off}.",
|
|
80
|
+
"figure.gate.origin-off-centre.why": "공간 가운데로 도형을 놓는 도면에서는 그린 자리에서 그만큼 떨어져 놓입니다.",
|
|
81
|
+
"figure.gate.origin-off-centre.how": "문의 경첩처럼 붙이는 자리라면 그대로 둡니다. 공간 가운데로 놓이게 하려면 원점을 가운데로 옮깁니다."
|
|
78
82
|
}
|