@things-factory/figure-service 10.1.205 → 10.1.206

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.
Files changed (77) hide show
  1. package/dist-server/service/figure/event-subscriber.js +5 -0
  2. package/dist-server/service/figure/event-subscriber.js.map +1 -1
  3. package/dist-server/service/figure/figure-format.d.ts +7 -0
  4. package/dist-server/service/figure/figure-format.js +25 -3
  5. package/dist-server/service/figure/figure-format.js.map +1 -1
  6. package/dist-server/service/figure/figure-mutation.js +2 -0
  7. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  8. package/dist-server/service/figure/figure-query.js +3 -5
  9. package/dist-server/service/figure/figure-query.js.map +1 -1
  10. package/dist-server/service/figure/figure-revert.js +1 -1
  11. package/dist-server/service/figure/figure-revert.js.map +1 -1
  12. package/dist-server/tsconfig.tsbuildinfo +1 -1
  13. package/package.json +8 -8
  14. package/recipes/base-frame/recipe.json +4 -8
  15. package/recipes/belt-surface/recipe.json +2 -4
  16. package/recipes/bin-row/recipe.json +1 -2
  17. package/recipes/bollard-row/recipe.json +1 -2
  18. package/recipes/cabinet-doors/recipe.json +5 -10
  19. package/recipes/cable-tray/recipe.json +3 -6
  20. package/recipes/canopy/recipe.json +2 -4
  21. package/recipes/cart-handle/recipe.json +1 -2
  22. package/recipes/ceiling-rail-carriage/recipe.json +1 -2
  23. package/recipes/chain-rails/recipe.json +2 -4
  24. package/recipes/column-row/recipe.json +1 -2
  25. package/recipes/conveyor-skirts/recipe.json +4 -8
  26. package/recipes/double-hinged-door/recipe.json +6 -12
  27. package/recipes/drawer-stack/recipe.json +13 -26
  28. package/recipes/drip-tray/recipe.json +2 -4
  29. package/recipes/end-covers/recipe.json +4 -8
  30. package/recipes/ffu-row/recipe.json +1 -2
  31. package/recipes/floor-zone/recipe.json +4 -8
  32. package/recipes/folding-door/recipe.json +3 -6
  33. package/recipes/gantry-frame/recipe.json +1 -2
  34. package/recipes/guard-cover/recipe.json +2 -4
  35. package/recipes/handrail/recipe.json +2 -4
  36. package/recipes/hatch/recipe.json +4 -8
  37. package/recipes/hinged-door/recipe.json +4 -8
  38. package/recipes/hopper/recipe.json +1 -2
  39. package/recipes/ladder/recipe.json +3 -6
  40. package/recipes/lift-updown/recipe.json +2 -4
  41. package/recipes/linear-guide/recipe.json +1 -2
  42. package/recipes/locker-row/recipe.json +1 -2
  43. package/recipes/observation-window/recipe.json +4 -8
  44. package/recipes/operator-panel/recipe.json +4 -8
  45. package/recipes/partition-wall/recipe.json +3 -6
  46. package/recipes/peg-board/facts.md +1 -1
  47. package/recipes/peg-board/recipe.json +1 -2
  48. package/recipes/pipe-run/recipe.json +1 -2
  49. package/recipes/platform/recipe.json +2 -4
  50. package/recipes/pull-cord/recipe.json +1 -2
  51. package/recipes/roll-up-shutter/recipe.json +3 -6
  52. package/recipes/roller-row/recipe.json +2 -4
  53. package/recipes/room-shell/recipe.json +6 -12
  54. package/recipes/safety-fence/recipe.json +2 -4
  55. package/recipes/safety-mat/recipe.json +1 -2
  56. package/recipes/shelf-tiers/recipe.json +5 -10
  57. package/recipes/side-guides/recipe.json +2 -4
  58. package/recipes/sign-board/recipe.json +1 -2
  59. package/recipes/sliding-door/recipe.json +7 -14
  60. package/recipes/stairs/recipe.json +1 -2
  61. package/recipes/stopper/recipe.json +1 -2
  62. package/recipes/suction-pads/recipe.json +1 -2
  63. package/recipes/tank/recipe.json +2 -4
  64. package/recipes/toe-board/recipe.json +1 -2
  65. package/recipes/traffic-cones/recipe.json +1 -2
  66. package/recipes/transfer-belts/recipe.json +2 -4
  67. package/recipes/trough/recipe.json +1 -2
  68. package/recipes/turntable/recipe.json +1 -2
  69. package/recipes/workbench-top/recipe.json +2 -4
  70. package/server/service/figure/event-subscriber.ts +4 -0
  71. package/server/service/figure/figure-blueprint-query.test.ts +3 -0
  72. package/server/service/figure/figure-context.test.ts +1 -1
  73. package/server/service/figure/figure-format.ts +27 -5
  74. package/server/service/figure/figure-mutation.ts +2 -0
  75. package/server/service/figure/figure-query.ts +3 -6
  76. package/server/service/figure/figure-release-size-db.test.ts +156 -0
  77. package/server/service/figure/figure-revert.ts +2 -2
@@ -81,8 +81,7 @@
81
81
  "param": "pitch"
82
82
  }
83
83
  }
84
- },
85
- "narrowRange": true
84
+ }
86
85
  },
87
86
  {
88
87
  "kind": "set-joint",
@@ -65,8 +65,7 @@
65
65
  "of": "part",
66
66
  "part": "{target}",
67
67
  "axis": "z"
68
- },
69
- "narrowRange": true
68
+ }
70
69
  },
71
70
  {
72
71
  "kind": "link-dimension",
@@ -76,8 +75,7 @@
76
75
  "of": "part",
77
76
  "part": "{target}",
78
77
  "axis": "z"
79
- },
80
- "narrowRange": true
78
+ }
81
79
  },
82
80
  {
83
81
  "kind": "set-joint",
@@ -42,8 +42,7 @@
42
42
  "of": "part",
43
43
  "part": "{target}",
44
44
  "axis": "x"
45
- },
46
- "narrowRange": true
45
+ }
47
46
  },
48
47
  {
49
48
  "kind": "set-joint",
@@ -66,8 +66,7 @@
66
66
  },
67
67
  "times": {
68
68
  "param": "size"
69
- },
70
- "narrowRange": true
69
+ }
71
70
  },
72
71
  {
73
72
  "kind": "set-joint",
@@ -55,8 +55,7 @@
55
55
  "of": "part",
56
56
  "part": "{target}",
57
57
  "axis": "x"
58
- },
59
- "narrowRange": true
58
+ }
60
59
  },
61
60
  {
62
61
  "kind": "link-dimension",
@@ -66,8 +65,7 @@
66
65
  "of": "part",
67
66
  "part": "{target}",
68
67
  "axis": "z"
69
- },
70
- "narrowRange": true
68
+ }
71
69
  },
72
70
  {
73
71
  "kind": "set-joint",
@@ -12,6 +12,8 @@ import { FigureHistory } from './figure-history.js'
12
12
  * 그 전부를 남기면 판본 목록이 「내가 오늘 몇 번 눌렀나」가 된다. 사람이 판이라고 부르는 것은
13
13
  * **발행한 것**이므로 그때만 베낀다. board 가 같은 규칙으로 돈다(`board-service` 의 같은 이름 파일).
14
14
  *
15
+ * 발행판의 정본(`__releaseSource`)은 크기 범위를 적은 발행판 사본이다 — Figure 행에는 작성자가 좁힌 끝만 남는다.
16
+ *
15
17
  * 발행 메모는 Figure 의 칸이 아니다. `releaseFigure` 가 저장할 엔티티에 `__releaseComment` 로 얹어
16
18
  * 두면 여기서 판본의 `comment` 로 옮긴다 — 메모는 그 판의 것이지 도형의 것이 아니다. 정본 형식
17
19
  * (`__releaseFormat`), 잰 커널의 판(`__releaseKernel`, ADR-0109 C1), 직전 판에 견준 변경(`__releaseChanges`), 발행 때 잰 값(`__releaseMetrics`)과
@@ -20,6 +22,8 @@ import { FigureHistory } from './figure-history.js'
20
22
  /** What `releaseFigure` laid on the entity for this release, moved onto the history row. */
21
23
  function releaseRecordOnto(history: FigureHistory, entity: Figure): FigureHistory {
22
24
  const e = entity as any
25
+ /* What a board reads of this release: its own copy, with the size range written down (ADR-0087 decision 6). */
26
+ if (e.__releaseSource) history.source = e.__releaseSource
23
27
  ;(history as any).comment = e.__releaseComment ?? null
24
28
  history.assetFormat = e.__releaseFormat ?? null
25
29
  history.kernelVersion = e.__releaseKernel ?? null
@@ -44,6 +44,8 @@ beforeAll(async () => {
44
44
  Figure = loadCompiled('service/figure/figure').Figure
45
45
  FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
46
46
  FigureQuery = loadCompiled('service/figure/figure-query').FigureQuery
47
+ /* A released row is saved with its history row, as releasing does: a board reads the release's own copy there (ADR-0087 decision 6). */
48
+ const Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
47
49
 
48
50
  dataSource = new TypeOrmDataSource({
49
51
  type: 'sqlite',
@@ -52,6 +54,7 @@ beforeAll(async () => {
52
54
  dropSchema: true,
53
55
  namingStrategy: new shell.NamingStrategy(),
54
56
  entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory],
57
+ subscribers: [Subscriber],
55
58
  logging: false
56
59
  })
57
60
 
@@ -193,7 +193,7 @@ describe('a proposal changes only what it was shown', () => {
193
193
  it('a shown part may change, a new part may be fastened to any part, the figure as a whole may change', () => {
194
194
  const steps = [
195
195
  { kind: 'set-material', target: 'shelf-2', material: { token: 'palette.primary' } },
196
- { kind: 'add-part', part: { id: 'lamp', kind: 'sphere', dimensions: { radiusX: 40, radiusY: 40, radiusZ: 40 } } },
196
+ { kind: 'add-part', part: { id: 'lamp', kind: 'sphere', dimensions: { radius: 40, radiusY: 40 } } },
197
197
  { kind: 'attach', part: 'lamp', to: { part: 'shelf-9', face: 'top' } },
198
198
  { kind: 'link-dimension', part: 'lamp', axis: 'x', source: { of: 'part', part: 'shelf-7', axis: 'x' }, times: 0.1 },
199
199
  { kind: 'fit-room', placement: 'floor' }
@@ -1,9 +1,10 @@
1
1
  /*
2
- * The doors a stored source goes through: saving (`parseSource`), publishing (`inspectSource`) and handing it to a
3
- * board (`consumablePayloadOf`). The format is `figure-asset-1`, and the asset says so in its `version`; a source
2
+ * The doors a stored source goes through: saving (`parseSource`), publishing (`inspectSource`, and the release's own
3
+ * copy in `releaseRecordOf`), bringing a release back as a draft (`draftSourceOfRelease`) and handing it to a board
4
+ * (`consumablePayloadOf`). The format is `figure-asset-1`, and the asset says so in its `version`; a source
4
5
  * that does not is refused by name, because a source the server cannot identify is one it must not save.
5
6
  */
6
- import { FIGURE_KERNEL_VERSION, compileFigureAsset, inspectFigureAsset, isPlaceholderType, parseFigureAsset, serializeFigureAsset, figureContractChanges, figureCostOf, figureOriginCentredOf, figurePlaneGapOf, figureReleaseMetricsOf, figureScoreOf, figureTopViewOf } from '@hatiolab/figure-model'
7
+ import { FIGURE_KERNEL_VERSION, compileFigureAsset, inspectFigureAsset, isPlaceholderType, parseFigureAsset, serializeFigureAsset, figureContractChanges, figureCostOf, figureOriginCentredOf, figurePlaneGapOf, figureReleaseMetricsOf, figureScoreOf, figureTopViewOf, forgetRecordedInstanceSize, recordFigureInstanceSize } from '@hatiolab/figure-model'
7
8
  import type { FigureAsset, FigureContractChange, FigureContractError, FigureReleaseMetrics, FigureTopView, FigureViolation } from '@hatiolab/figure-model'
8
9
 
9
10
  import type { FigureFinding } from './figure-type.js'
@@ -711,9 +712,20 @@ function figureContractWording(change: FigureContractChange, say: Say): Pick<Gat
711
712
  export function releaseRecordOf(
712
713
  sourceJson: string,
713
714
  releasedJson?: string | null
714
- ): { format: string; kernel: string; changes: FigureContractChange[] | null; metrics: FigureReleaseMetrics; topView: FigureTopView } {
715
+ ): { source: string; format: string; kernel: string; changes: FigureContractChange[] | null; metrics: FigureReleaseMetrics; topView: FigureTopView } {
715
716
  const asset = parseFigureAsset(sourceJson)
716
- const measured = { format: asset.version, kernel: FIGURE_KERNEL_VERSION, metrics: figureReleaseMetricsOf(asset), topView: figureTopViewOf(asset, null) }
717
+ /*
718
+ The release's own copy carries its size range as it is now: the author's ends, and the rest as far as the parts
719
+ allow (ADR-0087 decision 6). A board reads the ends from it and does not measure; the figure's row keeps only what
720
+ the author narrowed.
721
+ */
722
+ const measured = {
723
+ source: serializeFigureAsset(recordFigureInstanceSize(asset)),
724
+ format: asset.version,
725
+ kernel: FIGURE_KERNEL_VERSION,
726
+ metrics: figureReleaseMetricsOf(asset),
727
+ topView: figureTopViewOf(asset, null)
728
+ }
717
729
  if (!releasedJson) return { ...measured, changes: [] }
718
730
  let before: FigureAsset
719
731
  try {
@@ -724,6 +736,16 @@ export function releaseRecordOf(
724
736
  return { ...measured, changes: figureContractChanges(before, asset) }
725
737
  }
726
738
 
739
+ /**
740
+ * A release's source made a draft's again (a revert): the size ends its release wrote down, as far as the parts allow,
741
+ * are opened, and the author's own are kept (ADR-0087 decision 6). An author's end that equals the computed one is
742
+ * opened with them.
743
+ */
744
+ export function draftSourceOfRelease(releasedJson: string): string {
745
+ assertFormat(releasedJson)
746
+ return serializeFigureAsset(forgetRecordedInstanceSize(parseFigureAsset(releasedJson)))
747
+ }
748
+
727
749
  /**
728
750
  * What a consumer is handed for a released figure: the asset itself, because what draws it -- `registerFigure` in
729
751
  * things-scene -- takes the asset and compiles it with the kernel.
@@ -447,6 +447,8 @@ export class FigureMutation {
447
447
  state: 'released',
448
448
  updater: user,
449
449
  /* 판본 구독자가 이것들을 판본의 comment · assetFormat · kernelVersion · changes 로 옮긴다. Figure 의 칸이 아니다. */
450
+ /* The release's own copy, its size range written down; the figure keeps the author's (ADR-0087 decision 6). */
451
+ __releaseSource: record.source,
450
452
  __releaseComment: comment ?? null,
451
453
  __releaseFormat: record.format,
452
454
  __releaseKernel: record.kernel,
@@ -442,8 +442,9 @@ async function releaseNumbered(figure: Figure, version: number): Promise<{ figur
442
442
  /**
443
443
  * Finds the released source of a type in one domain, or null.
444
444
  *
445
- * A released row gives its own source; a draft row gives the source of its latest released history.
446
- * A figure that was never released gives null, because drafts never reach consumers.
445
+ * Always the latest release's own copy, from history: it carries the size range written down at release (ADR-0087
446
+ * decision 6), and the figure's row -- released or a draft -- keeps only the ends its author narrowed. A figure that
447
+ * was never released gives null, because drafts never reach consumers.
447
448
  */
448
449
  async function releasedSourceOf(
449
450
  domainId: string,
@@ -452,10 +453,6 @@ async function releasedSourceOf(
452
453
  const figure = await getRepository(Figure).findOne({ where: { domain: { id: domainId }, type } })
453
454
  if (!figure) return null
454
455
 
455
- if (figure.state === 'released' && figure.source) {
456
- return { figure, source: figure.source, version: figure.version }
457
- }
458
-
459
456
  const latestReleased = await getRepository(FigureHistory).findOne({
460
457
  where: { domain: { id: domainId }, originalId: figure.id, state: 'released' },
461
458
  order: { version: 'DESC' }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * The size range a board reads is the release's own copy (ADR-0087 decision 10, user 2026-10-21). The figure's row keeps
3
+ * only the ends its author narrowed; a release writes the rest down as far as the parts allow, on its history row. Every
4
+ * door a release leaves by -- one figure for a board, several by full name, the package another server imports -- hands
5
+ * out that copy, never the row. A release made a draft again opens what the release wrote down.
6
+ */
7
+ import type { DataSource } from 'typeorm'
8
+
9
+ const { DataSource: TypeOrmDataSource } = require('typeorm')
10
+ const { createHash } = require('crypto')
11
+ const JSZip = require('jszip')
12
+ const shell = require('@things-factory/shell')
13
+ const authBase = require('@things-factory/auth-base')
14
+ const settingBase = require('@things-factory/setting-base')
15
+ const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy, readFigurePackage } = require('@hatiolab/figure-model')
16
+
17
+ let dataSource: DataSource | null = null
18
+ let Figure: any
19
+ let FigureMutation: any
20
+ let FigureQuery: any
21
+ let figurePackageOf: any
22
+ let revertKeepingDraft: any
23
+ let FigureHistory: any
24
+ let FigureNamespace: any
25
+
26
+ function loadCompiled(modulePath: string): any {
27
+ const { existsSync } = require('fs')
28
+ const { join } = require('path')
29
+ const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
30
+ if (!existsSync(resolved))
31
+ throw new Error(`figure-service 산출물이 없다: ${resolved}\n먼저 빌드할 것:\n yarn workspace @things-factory/figure-service build:server`)
32
+ return require(resolved)
33
+ }
34
+
35
+ beforeAll(async () => {
36
+ Figure = loadCompiled('service/figure/figure').Figure
37
+ FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
38
+ FigureNamespace = loadCompiled('service/figure/figure-namespace-entity').FigureNamespace
39
+ const FigureReplacedDraft = loadCompiled('service/figure/figure-replaced-draft').FigureReplacedDraft
40
+ const Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
41
+ ;({ FigureMutation } = loadCompiled('service/figure/figure-mutation'))
42
+ ;({ FigureQuery } = loadCompiled('service/figure/figure-query'))
43
+ ;({ figurePackageOf } = loadCompiled('service/figure/figure-package-export'))
44
+ ;({ revertKeepingDraft } = loadCompiled('service/figure/figure-revert'))
45
+ dataSource = new TypeOrmDataSource({
46
+ type: 'sqlite',
47
+ database: ':memory:',
48
+ synchronize: true,
49
+ dropSchema: true,
50
+ namingStrategy: new shell.NamingStrategy(),
51
+ entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory, FigureNamespace, FigureReplacedDraft],
52
+ subscribers: [Subscriber],
53
+ logging: false
54
+ })
55
+ await dataSource.initialize()
56
+ shell.addDataSource('default', dataSource)
57
+ shell.addDataSource('tx', dataSource)
58
+ })
59
+
60
+ afterAll(async () => {
61
+ await dataSource?.destroy()
62
+ dataSource = null
63
+ })
64
+
65
+ /*
66
+ The user's figure (2026-10-21): a box that follows the figure's size, with a cavity ticked on it. Below twice its wall it
67
+ has no inside, so its width has a smallest size its parts set; nothing limits how large it grows. The height is
68
+ narrowed by the author, 50 to 400 mm.
69
+ */
70
+ const buttonBox = () => {
71
+ let a = addFigureBox(createFigureAsset('Button box'), { id: 'box', size: { x: 120, y: 60, z: 80 }, position: { x: 0, y: 30, z: 0 } })
72
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'box', to: { plane: 'mounting-plane' } })
73
+ a = applyFigureAuthoring(a, { kind: 'follow-size', part: 'box' })
74
+ a = applyFigureAuthoring(a, { kind: 'set-cavity', part: 'box', cavity: { wall: 10, start: { closed: 10 }, end: 'open' } })
75
+ a = applyFigureAuthoring(a, { kind: 'declare-instance-size', range: { y: { min: 50, max: 400 } }, replace: true })
76
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a, { over: 'range' }), placement: 'floor' }))
77
+ }
78
+ const sizeOf = (source: string | object, axis: 'x' | 'y' | 'z') => {
79
+ const asset = typeof source === 'string' ? JSON.parse(source) : source
80
+ const { min, max } = asset.document.model.inputs.find((i: any) => i.id === `size.${axis}`)
81
+ return { min, max }
82
+ }
83
+ /** The width's smallest size as the parts set it: twice the wall, and the kernel's numerical step above it. */
84
+ const widthFloor = (min: number | undefined) => typeof min === 'number' && min > 20 && min < 20.001
85
+
86
+ let seq = 0
87
+ async function domainWith(namespace: string) {
88
+ const name = `size-${++seq}`
89
+ const domain = await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name })
90
+ await dataSource!.getRepository(FigureNamespace).save({ domain, name: namespace, state: 'active', isDefault: true })
91
+ return { domain, context: { state: { domain, user: undefined, tx: dataSource!.manager } } }
92
+ }
93
+ const creationId = () => require('crypto').randomUUID()
94
+
95
+ async function released(type: string) {
96
+ const ns = `size-${++seq}.hatiolab.com`
97
+ const { domain, context } = await domainWith(ns)
98
+ const saved = await new FigureMutation().saveFigure(buttonBox(), context, undefined, undefined, creationId(), undefined, type, undefined, undefined)
99
+ await new FigureMutation().releaseFigure(saved.id, 'first', context)
100
+ const figure = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: saved.id } })
101
+ const history = await dataSource!.getRepository(FigureHistory).findOneOrFail({ where: { originalId: saved.id, version: 1 } })
102
+ return { ns, domain, context, figure, history }
103
+ }
104
+
105
+ describe('the size range a release carries', () => {
106
+ it('is written on the release, and the figure keeps only what its author narrowed', async () => {
107
+ const { figure, history } = await released('button-box')
108
+ expect(figure.state).toBe('released')
109
+ expect(sizeOf(figure.source, 'x')).toEqual({ min: undefined, max: undefined })
110
+ expect(sizeOf(figure.source, 'y')).toEqual({ min: 50, max: 400 })
111
+ const x = sizeOf(history.source, 'x')
112
+ expect(widthFloor(x.min)).toBe(true)
113
+ expect(x.max).toBeUndefined()
114
+ expect(sizeOf(history.source, 'y')).toEqual({ min: 50, max: 400 })
115
+ })
116
+
117
+ it('is what every door a release leaves by hands out: one for a board, several by full name, a package', async () => {
118
+ const { ns, domain, context, history } = await released('button-box')
119
+ const one = await new FigureQuery().figureBlueprint('button-box', context)
120
+ expect(widthFloor(sizeOf(one.asset, 'x').min)).toBe(true)
121
+ const pinned = await new FigureQuery().figureBlueprint('button-box', context, 1)
122
+ expect(widthFloor(sizeOf(pinned.asset, 'x').min)).toBe(true)
123
+ const several = await new FigureQuery().figureBlueprints([`${ns}/button-box`], context)
124
+ expect(widthFloor(sizeOf(several.items[0].asset, 'x').min)).toBe(true)
125
+
126
+ const { bytes } = await figurePackageOf({
127
+ refs: [`${ns}/button-box@1`],
128
+ domain,
129
+ exportedBy: 'author',
130
+ sourceUrl: 'https://figure.hatiolab.com',
131
+ files: { put: async () => ({}), send: async () => {}, read: async () => Buffer.alloc(0) }
132
+ })
133
+ const zip = await JSZip.loadAsync(bytes)
134
+ const files = new Map<string, Uint8Array>()
135
+ for (const path of Object.keys(zip.files)) if (!zip.files[path].dir) files.set(path, await zip.files[path].async('uint8array'))
136
+ const read = readFigurePackage(files, (b: Uint8Array) => createHash('sha256').update(b).digest('hex'))
137
+ expect(read.releases[0].source).toBe(history.source)
138
+ })
139
+
140
+ it('released again unchanged, nothing is said to have narrowed', async () => {
141
+ const { figure, context } = await released('button-box')
142
+ await dataSource!.getRepository(Figure).save({ ...figure, state: 'draft' })
143
+ await new FigureMutation().releaseFigure(figure.id, 'again', context)
144
+ const second = await dataSource!.getRepository(FigureHistory).findOneOrFail({ where: { originalId: figure.id, version: 2 } })
145
+ expect(JSON.parse(second.changes)).toEqual([])
146
+ })
147
+
148
+ it('made a draft again by a revert, what the release wrote down is opened and the author keeps theirs', async () => {
149
+ const { figure, domain } = await released('button-box')
150
+ const draft = await dataSource!.getRepository(Figure).save({ ...figure, state: 'draft' })
151
+ await dataSource!.transaction(tx => revertKeepingDraft(tx, domain, undefined, { id: figure.id, version: 1, requestId: creationId(), expectedSource: draft.source }))
152
+ const back = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
153
+ expect(sizeOf(back.source, 'x')).toEqual({ min: undefined, max: undefined })
154
+ expect(sizeOf(back.source, 'y')).toEqual({ min: 50, max: 400 })
155
+ })
156
+ })
@@ -5,7 +5,7 @@ import { GraphQLError } from 'graphql'
5
5
  import { Figure } from './figure.js'
6
6
  import { FigureHistory } from './figure-history.js'
7
7
  import { FigureReplacedDraft } from './figure-replaced-draft.js'
8
- import { parseSource } from './figure-format.js'
8
+ import { draftSourceOfRelease, parseSource } from './figure-format.js'
9
9
  import { sourceConflict } from './figure-write.js'
10
10
 
11
11
  /*
@@ -77,7 +77,7 @@ export async function revertKeepingDraft(
77
77
  })
78
78
  if (!history) throw refused('FIGURE_VERSION_NOT_FOUND', `Figure '${figure.type}' 에 ${request.version} 판이 없습니다.`)
79
79
  /* The format is checked again: an old version may break today's rules, and is not let in quietly. */
80
- const { source, derived, violations } = parseSource(history.source)
80
+ const { source, derived, violations } = parseSource(draftSourceOfRelease(history.source!))
81
81
 
82
82
  const record = await keep(kept, {
83
83
  domain: { id: domain.id },