@things-factory/figure-service 10.1.107 → 10.1.109

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.107",
3
+ "version": "10.1.109",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -25,7 +25,7 @@
25
25
  "test:templates": "node --test scripts/templates.test.mts"
26
26
  },
27
27
  "dependencies": {
28
- "@hatiolab/figure-model": "^0.2.7",
28
+ "@hatiolab/figure-model": "^0.2.8",
29
29
  "@operato/typeorm-history": "^10.0.0",
30
30
  "@things-factory/ai-client-base": "^10.1.81",
31
31
  "@things-factory/auth-base": "^10.1.107",
@@ -35,5 +35,5 @@
35
35
  "graphql-upload": "^15.0.2",
36
36
  "koa-router": "^7.4.0"
37
37
  },
38
- "gitHead": "65a3d5889e687d8122a65f029b926b1a4769d1c0"
38
+ "gitHead": "4d51757c50d8347e83c96aeba73d770bdde55b73"
39
39
  }
@@ -13,8 +13,19 @@ import { FigureHistory } from './figure-history.js'
13
13
  * **발행한 것**이므로 그때만 베낀다. board 가 같은 규칙으로 돈다(`board-service` 의 같은 이름 파일).
14
14
  *
15
15
  * 발행 메모는 Figure 의 칸이 아니다. `releaseFigure` 가 저장할 엔티티에 `__releaseComment` 로 얹어
16
- * 두면 여기서 판본의 `comment` 로 옮긴다 — 메모는 그 판의 것이지 도형의 것이 아니다.
16
+ * 두면 여기서 판본의 `comment` 로 옮긴다 — 메모는 그 판의 것이지 도형의 것이 아니다. 정본 형식
17
+ * (`__releaseFormat`)과 직전 판에 견준 변경(`__releaseChanges`)도 같은 길로 옮긴다(ADR-0104).
17
18
  */
19
+ /** What `releaseFigure` laid on the entity for this release, moved onto the history row. */
20
+ function releaseRecordOnto(history: FigureHistory, entity: Figure): FigureHistory {
21
+ const e = entity as any
22
+ ;(history as any).comment = e.__releaseComment ?? null
23
+ history.assetFormat = e.__releaseFormat ?? null
24
+ /* [] stays "[]" (nothing changed); null stays null (not compared). */
25
+ history.changes = e.__releaseChanges == null ? null : JSON.stringify(e.__releaseChanges)
26
+ return history
27
+ }
28
+
18
29
  @EventSubscriber()
19
30
  export class FigureHistoryEntitySubscriber extends HistoryEntitySubscriber<Figure, FigureHistory> {
20
31
  public get entity() {
@@ -26,13 +37,11 @@ export class FigureHistoryEntitySubscriber extends HistoryEntitySubscriber<Figur
26
37
  }
27
38
 
28
39
  public beforeInsertHistory(history: FigureHistory, entity: Figure): FigureHistory {
29
- ;(history as any).comment = (entity as any).__releaseComment ?? null
30
- return history
40
+ return releaseRecordOnto(history, entity)
31
41
  }
32
42
 
33
43
  public beforeUpdateHistory(history: FigureHistory, entity: Figure): FigureHistory {
34
- ;(history as any).comment = (entity as any).__releaseComment ?? null
35
- return history
44
+ return releaseRecordOnto(history, entity)
36
45
  }
37
46
 
38
47
  public async afterInsert(event: any): Promise<void> {
@@ -9,7 +9,7 @@
9
9
  import { createFigureAsset, addFigureBox, addFigurePrimitive, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy, placeholderType, figureScoreOf } from '@hatiolab/figure-model'
10
10
  import type { FigureAsset } from '@hatiolab/figure-model'
11
11
 
12
- import { FigureSourceError, consumablePayloadOf, inspectSource, parseSource } from './figure-format'
12
+ import { FigureSourceError, consumablePayloadOf, inspectSource, parseSource, releaseRecordOf } from './figure-format'
13
13
 
14
14
  /** 온전한 자산 하나: 바닥에 선 상자와, 부품에서 제안받아 확정한 점유 부피. */
15
15
  function figureAsset(): FigureAsset {
@@ -320,4 +320,33 @@ describe('figure-format — 판을 알아보는 문', () => {
320
320
  expect(() => consumablePayloadOf(JSON.stringify(broken))).toThrow()
321
321
  })
322
322
  })
323
+
324
+ /* ADR-0104 decision 5: a release records its format and what changed against the last one, once, as it was then. */
325
+ describe('발행이 스스로 남기는 것 — 형식과 직전 판에 견준 변경', () => {
326
+ const wider = () => {
327
+ let asset = createFigureAsset('Mixer')
328
+ asset = addFigureBox(asset, { id: 'body', size: { x: 300, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
329
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } })
330
+ return serializeFigureAsset(applyFigureAuthoring(asset, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(asset, { over: 'range' }), placement: 'floor' }))
331
+ }
332
+ it('a first release: its format, and nothing changed', () => {
333
+ expect(releaseRecordOf(serializeFigureAsset(figureAsset()))).toEqual({ format: 'figure-asset-1', changes: [] })
334
+ })
335
+ it('the same again changes nothing; a wider room is said and does not block', () => {
336
+ const v1 = serializeFigureAsset(figureAsset())
337
+ expect(releaseRecordOf(v1, v1).changes).toEqual([])
338
+ const room = releaseRecordOf(wider(), v1).changes!.find(c => c.code === 'occupancy-changed')!
339
+ expect(room.blocking).toBe(false)
340
+ expect(String(room.values.after)).toMatch(/x -150…150/)
341
+ })
342
+ it('a last release that cannot be read is "not compared" (null), never "nothing changed" ([])', () => {
343
+ expect(releaseRecordOf(serializeFigureAsset(figureAsset()), '{ not json').changes).toBeNull()
344
+ })
345
+ it('every new change has its sentence in both languages', () => {
346
+ const KO = require('../../../translations/ko.json') as Record<string, string>
347
+ const EN = require('../../../translations/en.json') as Record<string, string>
348
+ for (const code of ['occupancy-changed', 'contact-face-changed', 'public-input-gone', 'public-input-range-narrowed', 'public-input-added'])
349
+ for (const dict of [KO, EN]) for (const field of ['message', 'why']) expect(dict[`figure.gate.contract.${code}.${field}`]).toBeTruthy()
350
+ })
351
+ })
323
352
  })
@@ -420,7 +420,51 @@ function figureContractWording(change: FigureContractChange, say: Say): Pick<Gat
420
420
  message: say(key('message'), v, `The capability '${v.name}' is new.`),
421
421
  why: say(key('why'), {}, 'It adds what the figure can do. Placed instances stand as they are.')
422
422
  }
423
+ /* Said, never refused (ADR-0104): a consumer that pins a release reads these before it moves to this one. */
424
+ case 'occupancy-changed':
425
+ return {
426
+ message: say(key('message'), v, `The room the figure takes changed from ${v.before || 'none'} to ${v.after || 'none'}.`),
427
+ why: say(key('why'), {}, 'A consumer places the figure by this room. One that moves to this release sees it take the new room.')
428
+ }
429
+ case 'contact-face-changed':
430
+ return {
431
+ message: say(key('message'), v, 'The face the figure is mounted by changed.'),
432
+ why: say(key('why'), {}, 'A consumer that moves to this release sees it stand on the new face.')
433
+ }
434
+ case 'public-input-gone':
435
+ return {
436
+ message: say(key('message'), { ...v, input: change.subject ?? '' }, `'${change.subject}' is no longer set on a board.`),
437
+ why: say(key('why'), {}, 'Values placed instances give it do nothing after this release.')
438
+ }
439
+ case 'public-input-range-narrowed':
440
+ return {
441
+ message: say(key('message'), { ...v, input: change.subject ?? '' }, `The range of '${change.subject}' narrowed from ${v.before} to ${v.after}.`),
442
+ why: say(key('why'), {}, 'An instance may have set a value outside the new range. Which values are placed is not recorded.')
443
+ }
444
+ case 'public-input-added':
445
+ return {
446
+ message: say(key('message'), { ...v, input: change.subject ?? '' }, `'${change.subject}' can now be set on a board.`),
447
+ why: say(key('why'), {}, 'Placed instances that give no value stand at its default.')
448
+ }
449
+ }
450
+ }
451
+
452
+ /**
453
+ * What a release records about itself (ADR-0104 decision 5): the format its asset is written in, and what changed
454
+ * against the previous release -- the kernel's own comparison, kept as it was then so a consumer that pins releases
455
+ * reads it rather than computing it again. `[]` for a first release; `null` when the previous release could not be
456
+ * read, which is a different fact from "nothing changed".
457
+ */
458
+ export function releaseRecordOf(sourceJson: string, releasedJson?: string | null): { format: string; changes: FigureContractChange[] | null } {
459
+ const asset = parseFigureAsset(sourceJson)
460
+ if (!releasedJson) return { format: asset.version, changes: [] }
461
+ let before: FigureAsset
462
+ try {
463
+ before = parseFigureAsset(releasedJson)
464
+ } catch {
465
+ return { format: asset.version, changes: null }
423
466
  }
467
+ return { format: asset.version, changes: figureContractChanges(before, asset) }
424
468
  }
425
469
 
426
470
  /**
@@ -94,6 +94,22 @@ export class FigureHistory implements HistoryEntityInterface<Figure> {
94
94
  @Field({ nullable: true, description: 'The note left when this version was released.' })
95
95
  comment?: string
96
96
 
97
+ /*
98
+ * 이 판의 정본 형식(`figure-asset-1`)과, 직전 판에 견준 변경(ADR-0104 결정 5). 발행할 때 한 번 정하고 다시
99
+ * 계산하지 않는다 — 판을 고정해 쓰는 쪽은 다음 판으로 옮기기 전에 이것을 읽는다.
100
+ */
101
+ @Column({ nullable: true })
102
+ @Field({ nullable: true, description: 'The asset format this version was written in (figure-asset-1).' })
103
+ assetFormat?: string
104
+
105
+ @Column({ nullable: true, ...longTextColumn() })
106
+ @Field({
107
+ nullable: true,
108
+ description:
109
+ 'What changed against the previous release, as JSON: [{ code, blocking, subject?, values, detail }]. [] for the first release; null when the previous release could not be read.'
110
+ })
111
+ changes?: string
112
+
97
113
  @Column({ nullable: true })
98
114
  @Field({ nullable: true, description: 'When the original figure was created.' })
99
115
  createdAt?: Date
@@ -11,7 +11,7 @@ import { FigureReplacedDraft } from './figure-replaced-draft.js'
11
11
  import { revertKeepingDraft, undoRevert } from './figure-revert.js'
12
12
  import { recordProposals } from './figure-proposal.js'
13
13
  import { FigureProposal, ProposeRequest } from './figure-propose-type.js'
14
- import { inspectSource } from './figure-format.js'
14
+ import { inspectSource, releaseRecordOf } from './figure-format.js'
15
15
  import { renameDraftType, renameFacts } from './figure-rename.js'
16
16
  import { parseSource } from './figure-format.js'
17
17
  import { ProposeFigureFailure, proposeFigureSteps } from './figure-propose-steps.js'
@@ -319,6 +319,8 @@ export class FigureMutation {
319
319
  }
320
320
 
321
321
  const nextVersion = released?.version ? Number(released.version) + 1 : 1
322
+ /* What this release records about itself: its format, and what changed against the last one (ADR-0104). */
323
+ const record = releaseRecordOf(figure.source!, released?.source)
322
324
 
323
325
  return await repository.save({
324
326
  /*
@@ -333,8 +335,10 @@ export class FigureMutation {
333
335
  version: nextVersion,
334
336
  state: 'released',
335
337
  updater: user,
336
- /* 판본 구독자가 이것을 판본의 comment 로 옮긴다. Figure 의 칸이 아니다. */
337
- __releaseComment: comment ?? null
338
+ /* 판본 구독자가 이것들을 판본의 comment · assetFormat · changes 로 옮긴다. Figure 의 칸이 아니다. */
339
+ __releaseComment: comment ?? null,
340
+ __releaseFormat: record.format,
341
+ __releaseChanges: record.changes
338
342
  } as any)
339
343
  }
340
344
 
@@ -0,0 +1,94 @@
1
+ /**
2
+ * A release keeps its format and what changed against the last one on its history row -- on sqlite, through the
3
+ * subscriber that writes history (ADR-0104 decision 5). `[]` and `null` stay apart: nothing changed is not "not compared".
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
+
18
+ function loadCompiled(modulePath: string): any {
19
+ const { existsSync } = require('fs')
20
+ const { join } = require('path')
21
+ const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
22
+ if (!existsSync(resolved))
23
+ throw new Error(`figure-service 산출물이 없다: ${resolved}\n먼저 빌드할 것:\n yarn workspace @things-factory/figure-service build:server`)
24
+ return require(resolved)
25
+ }
26
+
27
+ beforeAll(async () => {
28
+ Figure = loadCompiled('service/figure/figure').Figure
29
+ FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
30
+ Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
31
+ FigureMutation = loadCompiled('service/figure/figure-mutation').FigureMutation
32
+ dataSource = new TypeOrmDataSource({
33
+ type: 'sqlite',
34
+ database: ':memory:',
35
+ synchronize: true,
36
+ dropSchema: true,
37
+ namingStrategy: new shell.NamingStrategy(),
38
+ entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory],
39
+ subscribers: [Subscriber],
40
+ logging: false
41
+ })
42
+ await dataSource.initialize()
43
+ })
44
+
45
+ afterAll(async () => {
46
+ await dataSource?.destroy()
47
+ dataSource = null
48
+ })
49
+
50
+ const SOURCE = serializeFigureAsset(addFigureBox(createFigureAsset('Probe'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } }))
51
+
52
+ async function release(extra: Record<string, unknown>) {
53
+ const domain = await dataSource!.getRepository(shell.Domain).save({ name: `d-${Math.random()}`, subdomain: `d${Date.now()}${Math.random()}` })
54
+ const figure = await dataSource!.getRepository(Figure).save({ type: 'RECORD_PROBE', name: 'probe', source: SOURCE, state: 'draft', version: 0, domain })
55
+ await dataSource!.getRepository(Figure).save({ ...figure, domain, version: 1, state: 'released', __releaseComment: 'first', ...extra })
56
+ return dataSource!.getRepository(FigureHistory).findOneOrFail({ where: { originalId: figure.id } })
57
+ }
58
+
59
+ describe('a release record on its history row', () => {
60
+ it('keeps the format and the changes as they were', async () => {
61
+ const changes = [{ code: 'occupancy-changed', blocking: false, values: { before: 'x -50…50', after: 'x -150…150' }, detail: 'declared room changed' }]
62
+ const row = await release({ __releaseFormat: 'figure-asset-1', __releaseChanges: changes })
63
+ expect(row.assetFormat).toBe('figure-asset-1')
64
+ expect(JSON.parse(row.changes)).toEqual(changes)
65
+ expect(row.comment).toBe('first')
66
+ })
67
+ it('[] stays nothing changed, null stays not compared', async () => {
68
+ expect((await release({ __releaseFormat: 'figure-asset-1', __releaseChanges: [] })).changes).toBe('[]')
69
+ expect((await release({ __releaseFormat: 'figure-asset-1', __releaseChanges: null })).changes).toBeNull()
70
+ })
71
+ })
72
+
73
+ /* The whole door, called as the resolver is: releaseFigure works the record out and the row carries it. */
74
+ describe('releaseFigure records it', () => {
75
+ const releasable = (width: number) => {
76
+ let a = addFigureBox(createFigureAsset('Mixer'), { id: 'body', size: { x: width, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
77
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } })
78
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a, { over: 'range' }), placement: 'floor' }))
79
+ }
80
+ it('first release: [], the second: the wider room', async () => {
81
+ const domain = await dataSource!.getRepository(shell.Domain).save({ name: 'release-door', subdomain: 'release-door' })
82
+ const tx = dataSource!.manager
83
+ const context = { state: { domain, user: undefined, tx } }
84
+ const figure = await dataSource!.getRepository(Figure).save({ type: 'RELEASE_DOOR', name: 'door', source: releasable(100), state: 'draft', version: 0, domain })
85
+ await new FigureMutation().releaseFigure(figure.id, 'one', context)
86
+ await dataSource!.getRepository(Figure).save({ ...(await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })), domain, source: releasable(300), state: 'draft' })
87
+ await new FigureMutation().releaseFigure(figure.id, 'two', context)
88
+ const rows = await dataSource!.getRepository(FigureHistory).find({ where: { originalId: figure.id }, order: { version: 'ASC' } })
89
+ expect(rows.map((r: any) => [r.version, r.assetFormat, r.comment])).toEqual([[1, 'figure-asset-1', 'one'], [2, 'figure-asset-1', 'two']])
90
+ expect(rows[0].changes).toBe('[]')
91
+ const codes = JSON.parse(rows[1].changes).map((c: any) => c.code)
92
+ expect(codes).toContain('occupancy-changed')
93
+ })
94
+ })
@@ -64,5 +64,15 @@
64
64
  "figure.gate.contract.capability-gone.message": "The capability '{name}' is gone.",
65
65
  "figure.gate.contract.capability-gone.why": "Scenes attach behaviour by this name. Placed instances lose what it did.",
66
66
  "figure.gate.contract.capability-added.message": "The capability '{name}' is new.",
67
- "figure.gate.contract.capability-added.why": "It adds what the figure can do. Placed instances stand as they are."
67
+ "figure.gate.contract.capability-added.why": "It adds what the figure can do. Placed instances stand as they are.",
68
+ "figure.gate.contract.occupancy-changed.message": "The room the figure takes changed: {before} → {after}.",
69
+ "figure.gate.contract.occupancy-changed.why": "A consumer places the figure by this room. One that moves to this release sees it take the new room.",
70
+ "figure.gate.contract.contact-face-changed.message": "The face the figure is mounted by changed.",
71
+ "figure.gate.contract.contact-face-changed.why": "A consumer that moves to this release sees it stand on the new face.",
72
+ "figure.gate.contract.public-input-gone.message": "'{input}' is no longer set on a board.",
73
+ "figure.gate.contract.public-input-gone.why": "Values placed instances give it do nothing after this release.",
74
+ "figure.gate.contract.public-input-range-narrowed.message": "The range of '{input}' narrowed: {before} → {after}.",
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
+ "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."
68
78
  }
@@ -64,5 +64,15 @@
64
64
  "figure.gate.contract.capability-gone.message": "'{name}' 능력이 없어졌습니다.",
65
65
  "figure.gate.contract.capability-gone.why": "씬이 이 이름으로 동작을 붙입니다. 놓인 인스턴스가 그 동작을 잃습니다.",
66
66
  "figure.gate.contract.capability-added.message": "'{name}' 능력이 새로 생겼습니다.",
67
- "figure.gate.contract.capability-added.why": "도형이 할 수 있는 일이 늘어납니다. 놓인 인스턴스는 그대로 서 있습니다."
67
+ "figure.gate.contract.capability-added.why": "도형이 할 수 있는 일이 늘어납니다. 놓인 인스턴스는 그대로 서 있습니다.",
68
+ "figure.gate.contract.occupancy-changed.message": "도형이 차지하는 공간이 바뀌었습니다: {before} → {after}.",
69
+ "figure.gate.contract.occupancy-changed.why": "받는 쪽은 이 공간으로 도형을 놓습니다. 이 발행본으로 옮기면 새 공간으로 놓입니다.",
70
+ "figure.gate.contract.contact-face-changed.message": "도형이 붙는 면이 바뀌었습니다.",
71
+ "figure.gate.contract.contact-face-changed.why": "이 발행본으로 옮기면 새 면으로 섭니다.",
72
+ "figure.gate.contract.public-input-gone.message": "'{input}' 값은 이제 보드에서 정할 수 없습니다.",
73
+ "figure.gate.contract.public-input-gone.why": "놓인 인스턴스가 준 값은 이 발행본부터 아무 일도 하지 않습니다.",
74
+ "figure.gate.contract.public-input-range-narrowed.message": "'{input}' 값의 범위가 좁아졌습니다: {before} → {after}.",
75
+ "figure.gate.contract.public-input-range-narrowed.why": "새 범위 밖의 값을 준 인스턴스가 있을 수 있습니다. 어떤 값이 놓였는지는 기록하지 않습니다.",
76
+ "figure.gate.contract.public-input-added.message": "'{input}' 값을 이제 보드에서 정할 수 있습니다.",
77
+ "figure.gate.contract.public-input-added.why": "값을 주지 않은 인스턴스는 기본값으로 섭니다."
68
78
  }