@things-factory/figure-service 10.1.81 → 10.1.84

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 (183) hide show
  1. package/dist-server/service/figure/figure-format.js +4 -3
  2. package/dist-server/service/figure/figure-format.js.map +1 -1
  3. package/dist-server/service/figure/figure-mutation.d.ts +5 -3
  4. package/dist-server/service/figure/figure-mutation.js +43 -41
  5. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  6. package/dist-server/service/figure/figure-proposal-record.d.ts +16 -0
  7. package/dist-server/service/figure/figure-proposal-record.js +51 -0
  8. package/dist-server/service/figure/figure-proposal-record.js.map +1 -0
  9. package/dist-server/service/figure/figure-proposal.d.ts +14 -0
  10. package/dist-server/service/figure/figure-proposal.js +39 -0
  11. package/dist-server/service/figure/figure-proposal.js.map +1 -0
  12. package/dist-server/service/figure/figure-propose-steps.js +12 -0
  13. package/dist-server/service/figure/figure-propose-steps.js.map +1 -1
  14. package/dist-server/service/figure/figure-query.d.ts +6 -1
  15. package/dist-server/service/figure/figure-query.js +33 -0
  16. package/dist-server/service/figure/figure-query.js.map +1 -1
  17. package/dist-server/service/figure/figure-rename.d.ts +2 -2
  18. package/dist-server/service/figure/figure-rename.js +8 -20
  19. package/dist-server/service/figure/figure-rename.js.map +1 -1
  20. package/dist-server/service/figure/figure-replaced-draft.d.ts +33 -0
  21. package/dist-server/service/figure/figure-replaced-draft.js +114 -0
  22. package/dist-server/service/figure/figure-replaced-draft.js.map +1 -0
  23. package/dist-server/service/figure/figure-revert.d.ts +36 -0
  24. package/dist-server/service/figure/figure-revert.js +155 -0
  25. package/dist-server/service/figure/figure-revert.js.map +1 -0
  26. package/dist-server/service/figure/figure-tools.js +7 -0
  27. package/dist-server/service/figure/figure-tools.js.map +1 -1
  28. package/dist-server/service/figure/figure-type.d.ts +14 -0
  29. package/dist-server/service/figure/figure-type.js +44 -1
  30. package/dist-server/service/figure/figure-type.js.map +1 -1
  31. package/dist-server/service/figure/figure-write.d.ts +7 -0
  32. package/dist-server/service/figure/figure-write.js +12 -2
  33. package/dist-server/service/figure/figure-write.js.map +1 -1
  34. package/dist-server/service/figure/index.d.ts +3 -1
  35. package/dist-server/service/figure/index.js +3 -1
  36. package/dist-server/service/figure/index.js.map +1 -1
  37. package/dist-server/service/index.d.ts +1 -1
  38. package/dist-server/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +3 -3
  40. package/recipes/air-cylinder/models/default.json +1 -1
  41. package/recipes/air-shower/models/default.json +1 -1
  42. package/recipes/barrier-gate/models/default.json +1 -1
  43. package/recipes/base-frame/models/default.json +1 -1
  44. package/recipes/belt-surface/models/default.json +1 -1
  45. package/recipes/bin-row/models/default.json +1 -1
  46. package/recipes/bollard-row/models/default.json +1 -1
  47. package/recipes/buzzer/models/default.json +1 -1
  48. package/recipes/cabinet-doors/models/default.json +1 -1
  49. package/recipes/cabinet-doors/models/doors=1.json +1 -1
  50. package/recipes/cabinet-doors/models/doors=2.json +1 -1
  51. package/recipes/cable-tray/models/default.json +1 -1
  52. package/recipes/camera-mount/models/default.json +1 -1
  53. package/recipes/canopy/models/default.json +1 -1
  54. package/recipes/cart/models/default.json +1 -1
  55. package/recipes/cart-handle/models/default.json +1 -1
  56. package/recipes/casters-four/models/default.json +1 -1
  57. package/recipes/ceiling-rail-carriage/models/default.json +1 -1
  58. package/recipes/chain-rails/models/default.json +1 -1
  59. package/recipes/charging-station/models/default.json +1 -1
  60. package/recipes/column-row/models/default.json +1 -1
  61. package/recipes/control-cabinet/models/default.json +1 -1
  62. package/recipes/conveyor-skirts/models/default.json +1 -1
  63. package/recipes/counter-display/models/default.json +1 -1
  64. package/recipes/dock-bumpers/models/default.json +1 -1
  65. package/recipes/dock-leveler/models/default.json +1 -1
  66. package/recipes/double-hinged-door/models/default.json +1 -1
  67. package/recipes/drawer-stack/models/default.json +1 -1
  68. package/recipes/drawer-stack/models/drawers=2.json +1 -1
  69. package/recipes/drawer-stack/models/drawers=3.json +1 -1
  70. package/recipes/drawer-stack/models/drawers=4.json +1 -1
  71. package/recipes/drip-tray/models/default.json +1 -1
  72. package/recipes/duct-fan/models/default.json +1 -1
  73. package/recipes/emergency-stop/models/default.json +1 -1
  74. package/recipes/end-covers/models/default.json +1 -1
  75. package/recipes/ffu-row/models/default.json +1 -1
  76. package/recipes/fire-extinguisher/models/default.json +1 -1
  77. package/recipes/first-aid-box/models/default.json +1 -1
  78. package/recipes/floor-zone/models/default.json +1 -1
  79. package/recipes/folding-door/models/default.json +1 -1
  80. package/recipes/fork/models/default.json +1 -1
  81. package/recipes/gantry-frame/models/default.json +1 -1
  82. package/recipes/gripper-2f/models/default.json +1 -1
  83. package/recipes/guard-cover/models/default.json +1 -1
  84. package/recipes/handrail/models/default.json +1 -1
  85. package/recipes/hatch/models/default.json +1 -1
  86. package/recipes/hinged-door/models/default.json +1 -1
  87. package/recipes/hinged-door/models/hinge=left.json +1 -1
  88. package/recipes/hinged-door/models/hinge=right.json +1 -1
  89. package/recipes/hmi-monitor/models/default.json +1 -1
  90. package/recipes/hopper/models/default.json +1 -1
  91. package/recipes/interlock-switch/models/default.json +1 -1
  92. package/recipes/junction-box/models/default.json +1 -1
  93. package/recipes/ladder/models/default.json +1 -1
  94. package/recipes/legs-four/models/default.json +1 -1
  95. package/recipes/leveling-feet/models/default.json +1 -1
  96. package/recipes/lift-updown/models/default.json +1 -1
  97. package/recipes/light-curtain/models/default.json +1 -1
  98. package/recipes/limit-switch/models/default.json +1 -1
  99. package/recipes/linear-guide/models/default.json +1 -1
  100. package/recipes/locker-row/models/default.json +1 -1
  101. package/recipes/motor-box/models/default.json +1 -1
  102. package/recipes/observation-window/models/default.json +1 -1
  103. package/recipes/operator-panel/models/buttons=2.json +1 -1
  104. package/recipes/operator-panel/models/buttons=3.json +1 -1
  105. package/recipes/operator-panel/models/buttons=4.json +1 -1
  106. package/recipes/operator-panel/models/buttons=5.json +1 -1
  107. package/recipes/operator-panel/models/default.json +1 -1
  108. package/recipes/pallet/models/default.json +1 -1
  109. package/recipes/partition-wall/models/default.json +1 -1
  110. package/recipes/pass-box/models/default.json +1 -1
  111. package/recipes/peg-board/models/default.json +1 -1
  112. package/recipes/photo-sensor-pair/models/default.json +1 -1
  113. package/recipes/pipe-run/models/default.json +1 -1
  114. package/recipes/platform/models/default.json +1 -1
  115. package/recipes/power-box/models/default.json +1 -1
  116. package/recipes/pressure-gauge/models/default.json +1 -1
  117. package/recipes/pull-cord/models/default.json +1 -1
  118. package/recipes/pump/models/default.json +1 -1
  119. package/recipes/pusher/models/default.json +1 -1
  120. package/recipes/robot-plinth/models/default.json +1 -1
  121. package/recipes/roll-up-shutter/models/default.json +1 -1
  122. package/recipes/roller-row/models/default.json +1 -1
  123. package/recipes/room-shell/models/default.json +1 -1
  124. package/recipes/rotating-beacon/models/default.json +1 -1
  125. package/recipes/safety-fence/models/default.json +1 -1
  126. package/recipes/safety-mat/models/default.json +1 -1
  127. package/recipes/safety-sign-stand/models/default.json +1 -1
  128. package/recipes/scanner-mount/models/default.json +1 -1
  129. package/recipes/selector-switch/models/default.json +1 -1
  130. package/recipes/shelf-tiers/models/default.json +1 -1
  131. package/recipes/shelf-tiers/models/tiers=2.json +1 -1
  132. package/recipes/shelf-tiers/models/tiers=3.json +1 -1
  133. package/recipes/shelf-tiers/models/tiers=4.json +1 -1
  134. package/recipes/shelf-tiers/models/tiers=5.json +1 -1
  135. package/recipes/side-guides/models/default.json +1 -1
  136. package/recipes/sign-board/models/default.json +1 -1
  137. package/recipes/signal-tower-3/models/default.json +1 -1
  138. package/recipes/sliding-door/models/default.json +1 -1
  139. package/recipes/sliding-door/models/leaves=1,lamp=false.json +1 -1
  140. package/recipes/sliding-door/models/leaves=1,lamp=true.json +1 -1
  141. package/recipes/sliding-door/models/leaves=2,lamp=false.json +1 -1
  142. package/recipes/sliding-door/models/leaves=2,lamp=true.json +1 -1
  143. package/recipes/stairs/models/default.json +1 -1
  144. package/recipes/status-lamp/models/default.json +1 -1
  145. package/recipes/stopper/models/default.json +1 -1
  146. package/recipes/suction-pads/models/default.json +1 -1
  147. package/recipes/tank/models/default.json +1 -1
  148. package/recipes/telescopic-arm/models/default.json +1 -1
  149. package/recipes/toe-board/models/default.json +1 -1
  150. package/recipes/tote/models/default.json +1 -1
  151. package/recipes/traffic-cones/models/default.json +1 -1
  152. package/recipes/transfer-belts/models/default.json +1 -1
  153. package/recipes/tray-stack/models/default.json +1 -1
  154. package/recipes/tray-stack/models/trays=3.json +1 -1
  155. package/recipes/tray-stack/models/trays=4.json +1 -1
  156. package/recipes/tray-stack/models/trays=5.json +1 -1
  157. package/recipes/trough/models/default.json +1 -1
  158. package/recipes/turntable/models/default.json +1 -1
  159. package/recipes/valve-handle/models/default.json +1 -1
  160. package/recipes/waste-bin/models/default.json +1 -1
  161. package/recipes/work-light/models/default.json +1 -1
  162. package/recipes/workbench-top/models/default.json +1 -1
  163. package/server/service/figure/figure-format.ts +4 -3
  164. package/server/service/figure/figure-mutation.ts +48 -42
  165. package/server/service/figure/figure-proposal-db.test.ts +108 -0
  166. package/server/service/figure/figure-proposal-record.ts +38 -0
  167. package/server/service/figure/figure-proposal.ts +39 -0
  168. package/server/service/figure/figure-propose-clips.test.ts +73 -0
  169. package/server/service/figure/figure-propose-steps.ts +12 -0
  170. package/server/service/figure/figure-query.ts +22 -1
  171. package/server/service/figure/figure-rename.test.ts +13 -2
  172. package/server/service/figure/figure-rename.ts +13 -1
  173. package/server/service/figure/figure-replaced-draft.ts +90 -0
  174. package/server/service/figure/figure-revert-db.test.ts +159 -0
  175. package/server/service/figure/figure-revert-race.pg.test.ts +168 -0
  176. package/server/service/figure/figure-revert.ts +181 -0
  177. package/server/service/figure/figure-tools.ts +7 -0
  178. package/server/service/figure/figure-type.ts +30 -0
  179. package/server/service/figure/figure-write.test.ts +15 -0
  180. package/server/service/figure/figure-write.ts +12 -2
  181. package/server/service/figure/index.ts +3 -1
  182. package/translations/en.json +3 -3
  183. package/translations/ko.json +3 -3
@@ -0,0 +1,39 @@
1
+ import type { EntityManager } from 'typeorm'
2
+ import { In } from 'typeorm'
3
+
4
+ import { Figure } from './figure.js'
5
+ import { FigureProposalRecord } from './figure-proposal-record.js'
6
+
7
+ /*
8
+ * What became of an AI proposal (ADR-0097 §5.6 item 5; AI lane, 2026-09-27): the saves that held its step. The key is the
9
+ * one `proposeFigureCandidate` gave it; a new figure made from it is created under that key, so the same proposal
10
+ * taken twice meets the first figure, never a second one.
11
+ */
12
+
13
+ const KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
14
+
15
+ /** Records, in the save's transaction, the proposals its source holds. Keys not of the tool's making are not recorded. */
16
+ export async function recordProposals(tx: EntityManager, domain: { id: string }, figureId: string, keys?: readonly string[]) {
17
+ const wanted = [...new Set((keys ?? []).filter(key => KEY.test(key)))]
18
+ if (!wanted.length) return
19
+ const records = tx.getRepository(FigureProposalRecord)
20
+ const already = new Set(
21
+ (await records.find({ where: { domain: { id: domain.id }, figureId, proposalKey: In(wanted) } })).map(record => record.proposalKey)
22
+ )
23
+ const fresh = wanted.filter(key => !already.has(key))
24
+ if (fresh.length) await records.insert(fresh.map(proposalKey => ({ domain: { id: domain.id }, figureId, proposalKey })))
25
+ }
26
+
27
+ /** The figures a proposal was saved into, with their names as they are now. None: not saved -- or not known here. */
28
+ export async function proposalOutcomeOf(tx: EntityManager, domain: { id: string }, proposalKey: string) {
29
+ const records = await tx.getRepository(FigureProposalRecord).find({ where: { domain: { id: domain.id }, proposalKey }, order: { recordedAt: 'ASC' } })
30
+ if (!records.length) return []
31
+ const figures = await tx.getRepository(Figure).find({ where: { domain: { id: domain.id }, id: In(records.map(r => r.figureId!)) } })
32
+ const byId = new Map(figures.map(f => [f.id, f]))
33
+ return records.map(record => ({
34
+ figureId: record.figureId!,
35
+ recordedAt: record.recordedAt,
36
+ type: byId.get(record.figureId!)?.type,
37
+ name: byId.get(record.figureId!)?.name
38
+ }))
39
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * figure-propose-steps and clips (designer 2026-09-27): an AI answer that would stop a clip is refused whole, and the
3
+ * model is told it may not -- never how to agree. An answer that stops none keeps every clip. Consent the model writes
4
+ * into its answer is read as nothing.
5
+ */
6
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset, editFigurePart } from '@hatiolab/figure-model'
7
+ import type { FigureAsset } from '@hatiolab/figure-model'
8
+ import { setDefaultAIClient } from '@things-factory/ai-client-base'
9
+ import type { AIClient } from '@things-factory/ai-client-base'
10
+
11
+ import { ProposeFigureFailure, judgeFigureAnswer, proposeFigureSteps } from './figure-propose-steps'
12
+
13
+ function stubClient(answers: unknown[]): AIClient & { calls: number; sent: any[][] } {
14
+ const client = {
15
+ id: 'stub',
16
+ calls: 0,
17
+ sent: [] as any[][],
18
+ async chat() {
19
+ return ''
20
+ },
21
+ async generateJSON(messages: any[]) {
22
+ client.sent.push(JSON.parse(JSON.stringify(messages)))
23
+ return answers[Math.min(client.calls++, answers.length - 1)]
24
+ }
25
+ }
26
+ return client as unknown as AIClient & { calls: number; sent: any[][] }
27
+ }
28
+
29
+ afterEach(() => setDefaultAIClient(undefined))
30
+
31
+ function door(): FigureAsset {
32
+ let a = createFigureAsset('Door')
33
+ a = addFigureBox(a, { id: 'frame', size: { x: 1000, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
34
+ a = addFigureBox(a, { id: 'leaf', size: { x: 900, y: 1800, z: 40 }, position: { x: 0, y: 1000, z: 0 } })
35
+ a = addFigureBox(a, { id: 'sign', size: { x: 200, y: 100, z: 10 }, position: { x: 0, y: 2200, z: 200 } })
36
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'leaf', face: 'bottom', to: { part: 'frame', face: 'top' } })
37
+ const j = a.joints!.find(k => k.body1.part === 'leaf')!
38
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Y', state: { id: 'open' } } })
39
+ return editFigurePart(a, { kind: 'set-driver', state: 'open', clip: 'swing', duration: 2, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 80 }] })
40
+ }
41
+ const lossy = { steps: [{ kind: 'rename', name: 'Door by AI' }, { kind: 'remove-part', target: 'leaf' }] }
42
+
43
+ describe('an AI answer and the clips of the draft it revises', () => {
44
+ it('an answer that would stop a clip is refused whole, and the model is told it may not -- not how to agree', () => {
45
+ const base = door()
46
+ const judged = judgeFigureAnswer(base, lossy, true)
47
+ expect(judged.source).toBeUndefined()
48
+ expect(judged.reasons.join('\n')).toMatch(/CLIPS_DROPPED: .*swing.*may never remove a clip; only the person can/)
49
+ expect(judged.reasons.join('\n')).not.toMatch(/dropClips/)
50
+ expect(base.drivers?.map(d => d.clip)).toEqual(['swing'])
51
+ })
52
+
53
+ it('asked three times and three times lossy, nothing comes back: no half-made figure to save', async () => {
54
+ const client = stubClient([lossy])
55
+ setDefaultAIClient(client)
56
+ await expect(proposeFigureSteps({ prompt: '문을 없애 줘', base: door() })).rejects.toBeInstanceOf(ProposeFigureFailure)
57
+ expect(client.calls).toBe(3)
58
+ expect(JSON.stringify(client.sent)).not.toMatch(/dropClips/)
59
+ })
60
+
61
+ it('consent the model writes into its answer is read as nothing', () => {
62
+ const judged = judgeFigureAnswer(door(), { ...lossy, dropClips: ['swing/open'], steps: lossy.steps.map(s => ({ ...s, dropClips: ['swing/open'] })) }, true)
63
+ expect(judged.source).toBeUndefined()
64
+ expect(judged.reasons.join('\n')).toMatch(/^CLIPS_DROPPED/)
65
+ })
66
+
67
+ it('an answer that stops no clip keeps every clip, a rename of the part it hangs from included', async () => {
68
+ const base = door()
69
+ setDefaultAIClient(stubClient([{ steps: [{ kind: 'rename', name: 'Door 2' }, { kind: 'nudge-part', part: 'sign', by: { x: 50 } }, { kind: 'rename-part', target: 'frame', name: 'jamb' }] }]))
70
+ const made = await proposeFigureSteps({ prompt: '이름을 바꿔 줘', base })
71
+ expect(made.source.drivers).toEqual(base.drivers)
72
+ })
73
+ })
@@ -114,8 +114,20 @@ export function judgeFigureAnswer(base: FigureAsset, answer: unknown, revising:
114
114
 
115
115
  let source: FigureAsset
116
116
  try {
117
+ /* Never with consent: only the person may let a clip go (designer 2026-09-27). */
117
118
  source = applyFigureProposal(base, steps as FigureProposalStep[])
118
119
  } catch (e) {
120
+ /*
121
+ The kernel's refusal tells a person how to agree; the model is told it may not, and what to keep instead -- it
122
+ must not learn to write a consent (designer 2026-09-27).
123
+ */
124
+ const clips = (e as { code?: string; clips?: Array<{ clip: string; state: string }> }).code === 'CLIPS_DROPPED' ? (e as { clips: Array<{ clip: string; state: string }> }).clips : undefined
125
+ if (clips)
126
+ return {
127
+ reasons: [
128
+ `CLIPS_DROPPED: these steps would stop the clips ${clips.map(c => `${c.clip} (plays ${c.state})`).join(', ')}. A proposal may never remove a clip; only the person can. Keep the parts and the motions those clips play as they are.`
129
+ ]
130
+ }
119
131
  return { reasons: [(e as Error).message] }
120
132
  }
121
133
 
@@ -14,7 +14,10 @@ import {
14
14
  import { Figure } from './figure.js'
15
15
  import { FigureHistory } from './figure-history.js'
16
16
  import { consumablePayloadOf, inspectSource } from './figure-format.js'
17
- import { FigureInspection, FigureList } from './figure-type.js'
17
+ import { FigureInspection, FigureList, FigureProposalOutcome } from './figure-type.js'
18
+ import { FigureReplacedDraft } from './figure-replaced-draft.js'
19
+ import { replacedDraftsOf } from './figure-revert.js'
20
+ import { proposalOutcomeOf } from './figure-proposal.js'
18
21
 
19
22
  @Resolver(Figure)
20
23
  @UseMiddleware(SpeakRefusals)
@@ -88,6 +91,24 @@ export class FigureQuery {
88
91
  * 도형의 현재 정본은 저작 중일 수 있다(초안). 배치된 것이 보는 것은 **마지막으로 발행된 판**이고,
89
92
  * 그것을 이 자리가 답한다.
90
93
  */
94
+ /** What became of an AI proposal: the figures a save put it into (ADR-0097 §5.6 item 5). */
95
+ @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
96
+ @Query(returns => [FigureProposalOutcome], {
97
+ description: 'The figures an AI proposal was saved into, oldest first. None: it was not saved, or not in this domain.'
98
+ })
99
+ async figureProposalOutcome(@Arg('proposalKey') proposalKey: string, @Ctx() context: ResolverContext): Promise<FigureProposalOutcome[]> {
100
+ const { domain, tx } = context.state
101
+ return await proposalOutcomeOf(getRepository(Figure, tx).manager, domain, proposalKey)
102
+ }
103
+
104
+ /** The drafts reverts kept for a figure, newest first -- apart from its released versions (ADR-0097 §5.6). */
105
+ @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
106
+ @Query(returns => [FigureReplacedDraft], { description: 'Drafts kept when a revert wrote over them, newest first. Not released versions.' })
107
+ async figureReplacedDrafts(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<FigureReplacedDraft[]> {
108
+ const { domain, tx } = context.state
109
+ return await replacedDraftsOf(getRepository(FigureReplacedDraft, tx).manager, domain, id)
110
+ }
111
+
91
112
  @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
92
113
  @Query(returns => FigureHistory, { nullable: true, description: 'The most recently released version of a figure.' })
93
114
  async figureReleased(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<FigureHistory> {
@@ -33,9 +33,10 @@ describe('초안의 type 고치기', () => {
33
33
  expect(() => parseFigureAsset(result.source)).not.toThrow()
34
34
  })
35
35
 
36
- it('앞뒤 공백을 뗀다. 대소문자는 저작자가 적은 대로 둔다', () => {
36
+ it('앞뒤 공백을 뗀다. 모양은 모델러가 묻는 규칙 그대로 — 대문자로 적지 않으면 거절한다(Q-D3, 2026-09-27)', () => {
37
37
  expect(renameDraftType(draft(), ' ROBOT_ARM ', never).type).toBe('ROBOT_ARM')
38
- expect(renameDraftType(draft(), 'Robot_Arm', never).type).toBe('Robot_Arm')
38
+ /* It was kept as written; the modeller always sent it upper-cased, and every stored type already is (56/56). */
39
+ expect(() => renameDraftType(draft(), 'Robot_Arm', never)).toThrow(expect.objectContaining({ code: 'malformed-type' }))
39
40
  })
40
41
 
41
42
  it('★ 한 번이라도 발행됐으면 거절한다 — 판 번호로', () => {
@@ -104,3 +105,13 @@ describe('문이 이 규칙을 지난다', () => {
104
105
  expect(/class FigurePatch \{[\s\S]{0,400}type\?: string/.test(type)).toBe(true)
105
106
  })
106
107
  })
108
+
109
+ describe('a type being set keeps the shape the modeller asks for (Q-D3, designer 2026-09-27)', () => {
110
+ const free = { version: 0, hasHistory: false, takenByAnother: false }
111
+ it.each(['lower_case', 'X1', 'A'.repeat(33), '1ABC', 'AB-CD', 'AB__CD', '한글'])('refuses %s as malformed', wanted => {
112
+ expect(() => renameDraftType({ type: 'OLD', source: '{}' }, wanted, free)).toThrow(expect.objectContaining({ code: 'malformed-type' }))
113
+ })
114
+ it.each(['FIG_K3F9A2', 'CONVEYOR', 'LIFT_TABLE_2'])('takes %s', wanted => {
115
+ expect(renameDraftType({ type: 'OLD', source: '{}' }, wanted, free).type).toBe(wanted)
116
+ })
117
+ })
@@ -1,3 +1,4 @@
1
+ import { TYPE_LENGTH, TYPE_PATTERN } from '@hatiolab/figure-model'
1
2
  /**
2
3
  * 초안의 타입을 고치는 규칙 — ADR-0063 결정 4.
3
4
  *
@@ -32,7 +33,7 @@ export interface RenameFacts {
32
33
 
33
34
  export class FigureRenameRefused extends Error {
34
35
  constructor(
35
- readonly code: 'empty-type' | 'already-released' | 'type-taken',
36
+ readonly code: 'empty-type' | 'malformed-type' | 'already-released' | 'type-taken',
36
37
  message: string
37
38
  ) {
38
39
  super(message)
@@ -57,6 +58,17 @@ export function renameDraftType(
57
58
  throw new FigureRenameRefused('empty-type', 'type 이 비어 있다 — 도면에 적히는 이름이라 비울 수 없다')
58
59
  }
59
60
 
61
+ /*
62
+ The shape the modeller has always asked for, now asked here too (designer 2026-09-27, Q-D3): only a type being set
63
+ is held to it -- every type in the development data already keeps it (56 of 56, 2026-09-27), and none is renamed.
64
+ */
65
+ if (!TYPE_PATTERN.test(next) || next.length < TYPE_LENGTH.min || next.length > TYPE_LENGTH.max) {
66
+ throw new FigureRenameRefused(
67
+ 'malformed-type',
68
+ `type 「${next}」 는 대문자로 시작하고 대문자·숫자·밑줄만 쓰며 ${TYPE_LENGTH.min}~${TYPE_LENGTH.max}자여야 한다`
69
+ )
70
+ }
71
+
60
72
  if ((facts.version ?? 0) > 0 || facts.hasHistory) {
61
73
  throw new FigureRenameRefused(
62
74
  'already-released',
@@ -0,0 +1,90 @@
1
+ import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, RelationId } from 'typeorm'
2
+ import { Field, ID, Int, ObjectType } from 'type-graphql'
3
+
4
+ import { User } from '@things-factory/auth-base'
5
+ import { Domain, longTextColumn } from '@things-factory/shell'
6
+
7
+ /**
8
+ * A figure's draft as it stood before a revert wrote over it -- kept, so what was saved just before a revert can be
9
+ * brought back, after a reload too (ADR-0097 §5.6, designer 2026-09-27; table chosen with the chief architect).
10
+ *
11
+ * Not a released version: it has no version number of its own, is not listed with the releases, and never changes
12
+ * what a board uses. Kept in the same transaction as the revert that replaces the draft -- kept, or neither happens.
13
+ * `requestId` makes a retried revert (its answer lost) the same revert, not a second one.
14
+ *
15
+ * An undo of a revert is a revert too (`kind: 'undo'`): it keeps what it replaces the same way.
16
+ */
17
+ @Entity()
18
+ @Index('ix_figure_replaced_draft_1', (kept: FigureReplacedDraft) => [kept.domain, kept.requestId], { unique: true })
19
+ @Index('ix_figure_replaced_draft_2', (kept: FigureReplacedDraft) => [kept.domain, kept.figureId])
20
+ @ObjectType({ description: 'A figure draft kept when a revert wrote over it. Not a released version.' })
21
+ export class FigureReplacedDraft {
22
+ @PrimaryGeneratedColumn('uuid')
23
+ @Field(type => ID, { description: 'Unique identifier of the kept draft.' })
24
+ readonly id?: string
25
+
26
+ @ManyToOne(type => Domain)
27
+ domain?: Domain
28
+
29
+ @RelationId((kept: FigureReplacedDraft) => kept.domain)
30
+ domainId?: string
31
+
32
+ @Column()
33
+ @Field({ description: 'The figure whose draft this was.' })
34
+ figureId?: string
35
+
36
+ @Column({ default: 'revert' })
37
+ @Field({ description: "What replaced it: 'revert' (a released version written over it) or 'undo' (an undo of a revert)." })
38
+ kind?: 'revert' | 'undo'
39
+
40
+ @Column({ nullable: true, ...longTextColumn() })
41
+ @Field({ nullable: true, description: 'The figure asset JSON the draft held.' })
42
+ source?: string
43
+
44
+ @Column({ nullable: true, ...longTextColumn() })
45
+ properties?: string
46
+
47
+ @Column({ nullable: true, ...longTextColumn() })
48
+ thumbnail?: string
49
+
50
+ @Column({ nullable: true })
51
+ @Field({ nullable: true, description: 'The state the figure was in.' })
52
+ state?: string
53
+
54
+ @Column({ nullable: true })
55
+ @Field(type => Int, { nullable: true, description: 'The released version number the figure had then.' })
56
+ version?: number
57
+
58
+ @Column({ nullable: true })
59
+ @Field(type => Int, { nullable: true, description: 'The released version the revert wrote over it; none for an undo.' })
60
+ revertedToVersion?: number
61
+
62
+ /* What replaced it, whole: an undo proceeds only while the figure still holds exactly this (compare-and-swap). */
63
+ @Column({ nullable: true, ...longTextColumn() })
64
+ writtenSource?: string
65
+
66
+ /* Its content hash, for finding and retrying without reading the whole text (chief architect, 2026-09-27). */
67
+ @Column()
68
+ writtenSourceHash?: string
69
+
70
+ @Column()
71
+ requestId?: string
72
+
73
+ @Column({ nullable: true })
74
+ @Field({ nullable: true, description: 'When this draft was brought back by an undo.' })
75
+ restoredAt?: Date
76
+
77
+ @Column({ nullable: true })
78
+ restoreRequestId?: string
79
+
80
+ @CreateDateColumn()
81
+ @Field({ nullable: true, description: 'When it was kept -- when the revert replaced it.' })
82
+ createdAt?: Date
83
+
84
+ @ManyToOne(type => User, { nullable: true })
85
+ @Field(type => User, { nullable: true, description: 'Who reverted.' })
86
+ creator?: User
87
+
88
+ @RelationId((kept: FigureReplacedDraft) => kept.creator)
89
+ creatorId?: string
90
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Reverting keeps the draft it writes over -- on sqlite, for real (ADR-0097 §5.6, designer 2026-09-27; chief architect's
3
+ * ruling on the storage). One transaction, one request one revert, an undo only while nothing changed since.
4
+ */
5
+ import type { DataSource, EntityManager } 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 } = require('@hatiolab/figure-model')
11
+
12
+ let dataSource: DataSource | null = null
13
+ let Figure: any
14
+ let FigureHistory: any
15
+ let FigureReplacedDraft: any
16
+ let revertKeepingDraft: any
17
+ let undoRevert: any
18
+ let replacedDraftsOf: any
19
+
20
+ function loadCompiled(modulePath: string): any {
21
+ const { existsSync } = require('fs')
22
+ const { join } = require('path')
23
+ const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
24
+ if (!existsSync(resolved))
25
+ throw new Error(`figure-service 산출물이 없다: ${resolved}\n먼저 빌드할 것:\n yarn workspace @things-factory/figure-service build:server`)
26
+ return require(resolved)
27
+ }
28
+
29
+ beforeAll(async () => {
30
+ Figure = loadCompiled('service/figure/figure').Figure
31
+ FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
32
+ FigureReplacedDraft = loadCompiled('service/figure/figure-replaced-draft').FigureReplacedDraft
33
+ ;({ revertKeepingDraft, undoRevert, replacedDraftsOf } = loadCompiled('service/figure/figure-revert'))
34
+ dataSource = new TypeOrmDataSource({
35
+ type: 'sqlite',
36
+ database: ':memory:',
37
+ synchronize: true,
38
+ dropSchema: true,
39
+ namingStrategy: new shell.NamingStrategy(),
40
+ entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory, FigureReplacedDraft],
41
+ logging: false
42
+ })
43
+ await dataSource.initialize()
44
+ })
45
+
46
+ afterAll(async () => {
47
+ await dataSource?.destroy()
48
+ dataSource = null
49
+ })
50
+
51
+ let DOMAIN: { id: string }
52
+ const text = (name: string, height = 100) =>
53
+ serializeFigureAsset(addFigureBox(createFigureAsset(name), { id: 'body', size: { x: 100, y: height, z: 100 }, position: { x: 0, y: height / 2, z: 0 } }))
54
+ const V1 = text('Released one', 100)
55
+ const DRAFT = text('Draft after it', 300)
56
+
57
+ beforeEach(async () => {
58
+ await dataSource!.synchronize(true)
59
+ DOMAIN = { id: (await dataSource!.getRepository(shell.Domain).save({ name: 'revert-probe', subdomain: 'revert-probe' })).id }
60
+ })
61
+
62
+ async function figureWithRelease() {
63
+ const figure = await dataSource!.getRepository(Figure).save({ type: 'REVERT_PROBE', name: 'probe', source: DRAFT, state: 'draft', version: 1, thumbnail: 'draft-picture', domain: DOMAIN })
64
+ await dataSource!.getRepository(FigureHistory).save({ originalId: figure.id, version: 1, type: 'REVERT_PROBE', name: 'probe', source: V1, state: 'released', thumbnail: 'v1-picture', domain: DOMAIN })
65
+ return figure
66
+ }
67
+ const uuid = () => require('crypto').randomUUID()
68
+ const inTx = <T>(work: (tx: EntityManager) => Promise<T>) => dataSource!.transaction(work)
69
+ const row = async (id: string) => dataSource!.getRepository(Figure).findOneOrFail({ where: { id } })
70
+ const kept = async () => dataSource!.getRepository(FigureReplacedDraft).find()
71
+
72
+ describe('a revert keeps the draft it writes over', () => {
73
+ it('keeps the whole draft, apart from the releases, and writes the version over it', async () => {
74
+ const figure = await figureWithRelease()
75
+ const result = await inTx(tx => revertKeepingDraft(tx, DOMAIN, undefined, { id: figure.id, version: 1, requestId: uuid(), expectedSource: DRAFT }))
76
+ expect(JSON.parse((await row(figure.id)).source).name).toBe('Released one')
77
+ expect((await row(figure.id)).thumbnail).toBe('v1-picture')
78
+ const [one] = await kept()
79
+ expect(one.source).toBe(DRAFT)
80
+ expect(one.thumbnail).toBe('draft-picture')
81
+ expect(one.revertedToVersion).toBe(1)
82
+ expect(result.kept.id).toBe(one.id)
83
+ /* Not a release: the released versions are as they were. */
84
+ expect(await dataSource!.getRepository(FigureHistory).count()).toBe(1)
85
+ /* Found again from the store, as after a reload. */
86
+ expect((await inTx(tx => replacedDraftsOf(tx, DOMAIN, figure.id))).map((k: any) => k.id)).toEqual([one.id])
87
+ })
88
+
89
+ it('keeps nothing and writes nothing when the write fails -- one transaction', async () => {
90
+ const figure = await figureWithRelease()
91
+ /* The figure changes after the check but before the write: the conditional write finds no row, and all rolls back. */
92
+ await expect(
93
+ inTx(async tx => {
94
+ const repo = tx.getRepository(Figure)
95
+ const update = repo.update.bind(repo)
96
+ repo.update = async (where: any, set: any) => (await tx.query(`UPDATE figures SET source = 'moved' WHERE id = ?`, [figure.id]), update(where, set))
97
+ const real = tx.getRepository.bind(tx)
98
+ tx.getRepository = ((target: any) => (target === Figure ? repo : real(target))) as any
99
+ return revertKeepingDraft(tx, DOMAIN, undefined, { id: figure.id, version: 1, requestId: uuid(), expectedSource: DRAFT })
100
+ })
101
+ ).rejects.toMatchObject({ extensions: { code: 'FIGURE_SOURCE_CONFLICT' } })
102
+ expect(await kept()).toEqual([])
103
+ expect((await row(figure.id)).source).toBe(DRAFT)
104
+ })
105
+
106
+ it('refuses a revert of a figure changed since it was seen, and keeps nothing', async () => {
107
+ const figure = await figureWithRelease()
108
+ await expect(inTx(tx => revertKeepingDraft(tx, DOMAIN, undefined, { id: figure.id, version: 1, requestId: uuid(), expectedSource: 'stale' }))).rejects.toMatchObject({
109
+ extensions: { code: 'FIGURE_SOURCE_CONFLICT' }
110
+ })
111
+ expect(await kept()).toEqual([])
112
+ })
113
+
114
+ it('one request, one revert: asked again, the same answer, nothing kept or written twice', async () => {
115
+ const figure = await figureWithRelease()
116
+ const requestId = uuid()
117
+ const first = await inTx(tx => revertKeepingDraft(tx, DOMAIN, undefined, { id: figure.id, version: 1, requestId, expectedSource: DRAFT }))
118
+ /* The answer was lost; asked again with what the reverter still believes. */
119
+ const again = await inTx(tx => revertKeepingDraft(tx, DOMAIN, undefined, { id: figure.id, version: 1, requestId, expectedSource: DRAFT }))
120
+ expect(again.kept.id).toBe(first.kept.id)
121
+ expect(await kept()).toHaveLength(1)
122
+ /* The same id for another revert is refused. */
123
+ await expect(inTx(tx => revertKeepingDraft(tx, DOMAIN, undefined, { id: figure.id, version: 2, requestId, expectedSource: DRAFT }))).rejects.toMatchObject({
124
+ extensions: { code: 'FIGURE_REVERT_REQUEST' }
125
+ })
126
+ })
127
+ })
128
+
129
+ describe('an undo brings the kept draft back only while nothing changed since', () => {
130
+ it('brings it back, keeping what it replaces; asked again, the same undo', async () => {
131
+ const figure = await figureWithRelease()
132
+ const reverted = await inTx(tx => revertKeepingDraft(tx, DOMAIN, undefined, { id: figure.id, version: 1, requestId: uuid(), expectedSource: DRAFT }))
133
+ const requestId = uuid()
134
+ await inTx(tx => undoRevert(tx, DOMAIN, undefined, { keptId: reverted.kept.id, requestId }))
135
+ expect((await row(figure.id)).source).toBe(DRAFT)
136
+ expect((await row(figure.id)).thumbnail).toBe('draft-picture')
137
+ const records = await kept()
138
+ expect(records.map((k: any) => k.kind).sort()).toEqual(['revert', 'undo'])
139
+ expect(records.find((k: any) => k.kind === 'revert').restoredAt).toBeTruthy()
140
+ /* The answer lost: the same undo again changes nothing more. */
141
+ await inTx(tx => undoRevert(tx, DOMAIN, undefined, { keptId: reverted.kept.id, requestId }))
142
+ expect(await kept()).toHaveLength(2)
143
+ /* Another request for a draft already back is refused. */
144
+ await expect(inTx(tx => undoRevert(tx, DOMAIN, undefined, { keptId: reverted.kept.id, requestId: uuid() }))).rejects.toMatchObject({ extensions: { code: 'FIGURE_KEPT_RESTORED' } })
145
+ })
146
+
147
+ it('refuses when the figure was edited after the revert, and the kept draft stays', async () => {
148
+ const figure = await figureWithRelease()
149
+ const reverted = await inTx(tx => revertKeepingDraft(tx, DOMAIN, undefined, { id: figure.id, version: 1, requestId: uuid(), expectedSource: DRAFT }))
150
+ await dataSource!.getRepository(Figure).update({ id: figure.id }, { source: text('Edited after', 500) })
151
+ await expect(inTx(tx => undoRevert(tx, DOMAIN, undefined, { keptId: reverted.kept.id, requestId: uuid() }))).rejects.toMatchObject({
152
+ extensions: { code: 'FIGURE_SOURCE_CONFLICT' }
153
+ })
154
+ expect(JSON.parse((await row(figure.id)).source).name).toBe('Edited after')
155
+ const records = await kept()
156
+ expect(records).toHaveLength(1)
157
+ expect(records[0].restoredAt).toBeFalsy()
158
+ })
159
+ })
@@ -0,0 +1,168 @@
1
+ import { join } from 'path'
2
+
3
+ /**
4
+ * Reverting, undoing and saving at the same moment, on postgres (ADR-0097 §5.6; the chief architect asked for this
5
+ * before the revert is turned back on in the screen).
6
+ *
7
+ * sqlite holds one connection and cannot run two transactions side by side, so it says nothing about this. This needs
8
+ * a real server, and runs only when FIGURE_PG_URL names one:
9
+ *
10
+ * docker run -d --name fig-revert-pg -e POSTGRES_PASSWORD=figrv -e POSTGRES_USER=figrv -e POSTGRES_DB=figrv \
11
+ * -p 55441:5432 postgres:14-alpine
12
+ * yarn workspace @things-factory/figure-service build:server
13
+ * FIGURE_PG_URL=postgres://figrv:figrv@localhost:55441/figrv \
14
+ * node_modules/.bin/jest --selectProjects figure-service-ts --coverage=false figure-revert-race
15
+ *
16
+ * Each round must end one way: one of the two won, the other was refused by name, and what was replaced is kept
17
+ * exactly once. Both succeeding, a draft replaced without being kept, or a kept draft for a revert that did not
18
+ * happen is the defect.
19
+ */
20
+
21
+ const url = process.env.FIGURE_PG_URL
22
+ const maybe = url ? describe : describe.skip
23
+ const ROUNDS = 40
24
+
25
+ maybe('reverting at once, on postgres', () => {
26
+ const { DataSource } = require('typeorm')
27
+ const { randomUUID } = require('crypto')
28
+ const { entityClosure } = require(join(__dirname, '..', '..', '..', '..', 'ops-master', 'test', 'support', 'database.cjs'))
29
+ const { NamingStrategy } = require('@things-factory/shell/dist-server/initializers/naming-strategy.js')
30
+ const { Domain } = require('@things-factory/shell')
31
+ const { createFigureAsset, addFigureBox, serializeFigureAsset } = require('@hatiolab/figure-model')
32
+ const DIST = join(__dirname, '..', '..', '..', 'dist-server', 'service', 'figure')
33
+ const { Figure } = require(join(DIST, 'figure.js'))
34
+ const { FigureHistory } = require(join(DIST, 'figure-history.js'))
35
+ const { FigureReplacedDraft } = require(join(DIST, 'figure-replaced-draft.js'))
36
+ const { revertKeepingDraft, undoRevert } = require(join(DIST, 'figure-revert.js'))
37
+ const { compareAndSwapFigure } = require(join(DIST, 'figure-write.js'))
38
+
39
+ let db: any
40
+ let domain: { id: string }
41
+
42
+ const text = (name: string, height: number) =>
43
+ serializeFigureAsset(addFigureBox(createFigureAsset(name), { id: 'body', size: { x: 100, y: height, z: 100 }, position: { x: 0, y: height / 2, z: 0 } }))
44
+ const V1 = text('Released one', 100)
45
+ const DRAFT = text('Draft after it', 300)
46
+ const EDIT = text('Saved meanwhile', 500)
47
+
48
+ beforeAll(async () => {
49
+ db = new DataSource({
50
+ type: 'postgres',
51
+ url,
52
+ synchronize: true,
53
+ dropSchema: true,
54
+ entities: entityClosure([Figure, FigureHistory, FigureReplacedDraft]),
55
+ namingStrategy: new NamingStrategy()
56
+ })
57
+ await db.initialize()
58
+ domain = { id: (await db.getRepository(Domain).save({ name: 'race', subdomain: 'race' })).id }
59
+ }, 60000)
60
+
61
+ afterAll(async () => db?.destroy())
62
+
63
+ const aFigure = async (round: number) => {
64
+ const figure = await db.getRepository(Figure).save({ type: `RACE_${round}`, name: `race ${round}`, source: DRAFT, state: 'draft', version: 1, domain })
65
+ await db.getRepository(FigureHistory).save({ originalId: figure.id, version: 1, type: figure.type, name: figure.name, source: V1, state: 'released', domain })
66
+ return figure
67
+ }
68
+ const revert = (figure: any, requestId = randomUUID()) =>
69
+ db.transaction((tx: any) => revertKeepingDraft(tx, domain, undefined, { id: figure.id, version: 1, requestId, expectedSource: DRAFT }))
70
+ const undo = (keptId: string, requestId = randomUUID()) => db.transaction((tx: any) => undoRevert(tx, domain, undefined, { keptId, requestId }))
71
+ const save = (figure: any, expected: string) =>
72
+ db.transaction((tx: any) => compareAndSwapFigure((criteria: any) => tx.getRepository(Figure).update(criteria, { source: EDIT }), figure.id, domain.id, expected))
73
+
74
+ const sourceOf = async (figure: any) => (await db.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })).source
75
+ const keptOf = (figure: any) => db.getRepository(FigureReplacedDraft).find({ where: { figureId: figure.id } })
76
+ /* A few milliseconds either way, so each side wins some rounds -- the revert does more before its write. */
77
+ const later = <T>(work: () => Promise<T>) => new Promise<T>((done, fail) => setTimeout(() => work().then(done, fail), Math.random() * 12))
78
+ const codeOf = (settled: PromiseSettledResult<any>) => (settled.status === 'rejected' ? settled.reason?.extensions?.code ?? String(settled.reason) : 'ok')
79
+
80
+ it('two reverts of the same draft: one reverts and keeps it once, the other is refused', async () => {
81
+ for (let round = 0; round < ROUNDS; round++) {
82
+ const figure = await aFigure(round)
83
+ const results = await Promise.allSettled([revert(figure), revert(figure)])
84
+ expect(results.map(codeOf).sort()).toEqual(['FIGURE_SOURCE_CONFLICT', 'ok'])
85
+ expect(await sourceOf(figure)).toBe(V1)
86
+ const kept = await keptOf(figure)
87
+ expect(kept).toHaveLength(1)
88
+ expect(kept[0].source).toBe(DRAFT)
89
+ }
90
+ }, 60000)
91
+
92
+ it('the same revert sent twice at once: it happens once, and asked again later answers with it', async () => {
93
+ for (let round = 0; round < ROUNDS; round++) {
94
+ const figure = await aFigure(ROUNDS + round)
95
+ const requestId = randomUUID()
96
+ const results = await Promise.allSettled([revert(figure, requestId), revert(figure, requestId)])
97
+ /* The second either found the first's revert, or met it in flight and was told so -- never a second revert. */
98
+ for (const code of results.map(codeOf)) expect(['ok', 'FIGURE_REVERT_IN_FLIGHT']).toContain(code)
99
+ expect(results.map(codeOf)).toContain('ok')
100
+ const kept = await keptOf(figure)
101
+ expect(kept).toHaveLength(1)
102
+ expect(await sourceOf(figure)).toBe(V1)
103
+ expect((await revert(figure, requestId)).kept.id).toBe(kept[0].id)
104
+ }
105
+ }, 60000)
106
+
107
+ it('a revert and a save at once: one wins; a lost revert keeps nothing', async () => {
108
+ const wins: string[] = []
109
+ for (let round = 0; round < ROUNDS; round++) {
110
+ const figure = await aFigure(2 * ROUNDS + round)
111
+ const [reverted, saved] = await Promise.allSettled([revert(figure), later(() => save(figure, DRAFT))])
112
+ const kept = await keptOf(figure)
113
+ if (reverted.status === 'fulfilled') {
114
+ expect(codeOf(saved)).toBe('FIGURE_SOURCE_CONFLICT')
115
+ expect(await sourceOf(figure)).toBe(V1)
116
+ expect(kept.map((k: any) => k.source)).toEqual([DRAFT])
117
+ wins.push('revert')
118
+ } else {
119
+ expect(codeOf(reverted)).toBe('FIGURE_SOURCE_CONFLICT')
120
+ expect(saved.status).toBe('fulfilled')
121
+ expect(await sourceOf(figure)).toBe(EDIT)
122
+ expect(kept).toEqual([])
123
+ wins.push('save')
124
+ }
125
+ }
126
+ /* Both endings must be seen, or one branch above was never checked. */
127
+ expect(new Set(wins)).toEqual(new Set(['revert', 'save']))
128
+ }, 60000)
129
+
130
+ it('an undo and a save at once: one wins; the kept draft stays until an undo succeeds', async () => {
131
+ const wins: string[] = []
132
+ for (let round = 0; round < ROUNDS; round++) {
133
+ const figure = await aFigure(3 * ROUNDS + round)
134
+ const { kept } = await revert(figure)
135
+ const [undone, saved] = await Promise.allSettled([undo(kept.id), later(() => save(figure, V1))])
136
+ const records = await keptOf(figure)
137
+ const original = records.find((k: any) => k.id === kept.id)
138
+ if (undone.status === 'fulfilled') {
139
+ expect(codeOf(saved)).toBe('FIGURE_SOURCE_CONFLICT')
140
+ expect(await sourceOf(figure)).toBe(DRAFT)
141
+ expect(original.restoredAt).toBeTruthy()
142
+ expect(records.filter((k: any) => k.kind === 'undo').map((k: any) => k.source)).toEqual([V1])
143
+ wins.push('undo')
144
+ } else {
145
+ expect(codeOf(undone)).toBe('FIGURE_SOURCE_CONFLICT')
146
+ expect(saved.status).toBe('fulfilled')
147
+ expect(await sourceOf(figure)).toBe(EDIT)
148
+ expect(original.restoredAt).toBeFalsy()
149
+ expect(records.filter((k: any) => k.kind === 'undo')).toEqual([])
150
+ wins.push('save')
151
+ }
152
+ }
153
+ expect(new Set(wins)).toEqual(new Set(['undo', 'save']))
154
+ }, 60000)
155
+
156
+ it('two undos of the same kept draft: it comes back once', async () => {
157
+ for (let round = 0; round < ROUNDS; round++) {
158
+ const figure = await aFigure(4 * ROUNDS + round)
159
+ const { kept } = await revert(figure)
160
+ const results = await Promise.allSettled([undo(kept.id), undo(kept.id)])
161
+ const codes = results.map(codeOf)
162
+ expect(codes.filter(code => code === 'ok')).toHaveLength(1)
163
+ for (const code of codes) expect(['ok', 'FIGURE_SOURCE_CONFLICT', 'FIGURE_KEPT_RESTORED']).toContain(code)
164
+ expect(await sourceOf(figure)).toBe(DRAFT)
165
+ expect((await keptOf(figure)).filter((k: any) => k.kind === 'undo')).toHaveLength(1)
166
+ }
167
+ }, 60000)
168
+ })