@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,181 @@
1
+ import { createHash } from 'crypto'
2
+ import { In, type EntityManager } from 'typeorm'
3
+ import { GraphQLError } from 'graphql'
4
+
5
+ import { Figure } from './figure.js'
6
+ import { FigureHistory } from './figure-history.js'
7
+ import { FigureReplacedDraft } from './figure-replaced-draft.js'
8
+ import { parseSource } from './figure-format.js'
9
+ import { sourceConflict } from './figure-write.js'
10
+
11
+ /*
12
+ * Reverting a figure to a released version, keeping the draft it writes over (ADR-0097 §5.6, designer 2026-09-27;
13
+ * chief architect's ruling on the storage the same day).
14
+ *
15
+ * - One transaction: the draft is kept, then the figure is written over -- conditional on it being what the reverter
16
+ * saw (`expectedSource`). If either fails, neither stays: the caller's transaction is rolled back.
17
+ * - One request, one revert: `requestId` is unique in the domain. A retry -- the answer lost -- finds the revert it
18
+ * asked for and answers with it; nothing is kept or written twice.
19
+ * - An undo brings a kept draft back only while the figure still holds exactly what that revert wrote
20
+ * (`WHERE id = ? AND source = ?`, as a save's compare-and-swap); edited since, it is refused and the kept draft stays.
21
+ * It keeps what it replaces the same way.
22
+ *
23
+ * Every function here runs on the caller's transaction (`tx`); the resolvers open it (`@transaction`).
24
+ */
25
+
26
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
27
+ const hashOf = (text: string) => createHash('sha256').update(text).digest('hex')
28
+ const refused = (code: string, message: string) => new GraphQLError(message, { extensions: { code } })
29
+
30
+ /*
31
+ * The same request arriving twice at once: the second waits on the first's requestId and then meets the unique index.
32
+ * Named, not a raw database error -- asked again after the first has finished, it gets the first's answer.
33
+ */
34
+ async function keep(kept: { save: (row: any) => Promise<any> }, row: any) {
35
+ try {
36
+ return await kept.save(row)
37
+ } catch (error: any) {
38
+ const unique = error?.code === '23505' || error?.driverError?.code === '23505' || /UNIQUE constraint failed/.test(String(error?.message))
39
+ if (unique) throw refused('FIGURE_REVERT_IN_FLIGHT', '같은 요청이 처리 중입니다. 잠시 뒤 다시 확인해 주세요.')
40
+ throw error
41
+ }
42
+ }
43
+
44
+ export interface RevertRequest {
45
+ id: string
46
+ version: number
47
+ requestId: string
48
+ /** The figure's source as the reverter saw it; the revert goes on only while the figure still holds it. */
49
+ expectedSource: string
50
+ }
51
+
52
+ export async function revertKeepingDraft(
53
+ tx: EntityManager,
54
+ domain: { id: string; parentId?: string | null },
55
+ user: { id: string } | undefined,
56
+ request: RevertRequest
57
+ ) {
58
+ if (!UUID.test(request.requestId ?? '')) throw refused('FIGURE_REVERT_REQUEST', '되돌리기 요청 식별자가 필요합니다.')
59
+ const figures = tx.getRepository(Figure)
60
+ const kept = tx.getRepository(FigureReplacedDraft)
61
+
62
+ /* The same request again: the revert it made, answered again. */
63
+ const before = await kept.findOne({ where: { domain: { id: domain.id }, requestId: request.requestId } })
64
+ if (before) {
65
+ if (before.figureId !== request.id || before.revertedToVersion !== request.version || before.kind !== 'revert')
66
+ throw refused('FIGURE_REVERT_REQUEST', '이 되돌리기 요청 식별자는 다른 되돌리기에 이미 쓰였습니다.')
67
+ return { figure: await figures.findOneOrFail({ where: { id: request.id, domain: { id: domain.id } } }), violations: [], kept: before }
68
+ }
69
+
70
+ /* Exact tenant ownership, as a save: an inherited figure is not written from a child domain. */
71
+ const figure = await figures.findOne({ where: { id: request.id, domain: { id: domain.id } } })
72
+ if (!figure) throw refused('FIGURE_NOT_FOUND', `Figure ${request.id} 를 찾을 수 없습니다.`)
73
+ if (figure.source !== request.expectedSource) throw sourceConflict('다른 편집에서 모델이 변경되었습니다. 최신 모델을 다시 연 뒤 되돌려 주세요.')
74
+
75
+ const history = await tx.getRepository(FigureHistory).findOne({
76
+ where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean) as string[]) }, originalId: request.id, version: request.version }
77
+ })
78
+ if (!history) throw refused('FIGURE_VERSION_NOT_FOUND', `Figure '${figure.type}' 에 ${request.version} 판이 없습니다.`)
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)
81
+
82
+ const record = await keep(kept, {
83
+ domain: { id: domain.id },
84
+ figureId: figure.id,
85
+ kind: 'revert' as const,
86
+ source: figure.source,
87
+ properties: figure.properties,
88
+ thumbnail: figure.thumbnail,
89
+ state: figure.state,
90
+ version: figure.version,
91
+ revertedToVersion: request.version,
92
+ writtenSource: source,
93
+ writtenSourceHash: hashOf(source),
94
+ requestId: request.requestId,
95
+ ...(user ? { creator: { id: user.id } } : {})
96
+ })
97
+
98
+ const written = await figures.update(
99
+ { id: figure.id, domain: { id: domain.id }, source: request.expectedSource },
100
+ {
101
+ source,
102
+ ...derived,
103
+ properties: history.properties,
104
+ thumbnail: history.thumbnail,
105
+ thumbnailUpdatedAt: history.thumbnail === figure.thumbnail ? figure.thumbnailUpdatedAt : new Date(),
106
+ state: 'draft',
107
+ ...(user ? { updater: { id: user.id } } : {})
108
+ }
109
+ )
110
+ if (written.affected !== 1) throw sourceConflict('다른 편집에서 모델이 변경되었습니다. 최신 모델을 다시 연 뒤 되돌려 주세요.')
111
+
112
+ return { figure: await figures.findOneOrFail({ where: { id: figure.id, domain: { id: domain.id } } }), violations, kept: record }
113
+ }
114
+
115
+ /** Brings a kept draft back -- only while the figure holds exactly what that revert wrote. */
116
+ export async function undoRevert(
117
+ tx: EntityManager,
118
+ domain: { id: string },
119
+ user: { id: string } | undefined,
120
+ request: { keptId: string; requestId: string }
121
+ ) {
122
+ if (!UUID.test(request.requestId ?? '')) throw refused('FIGURE_REVERT_REQUEST', '되돌리기 취소 요청 식별자가 필요합니다.')
123
+ const figures = tx.getRepository(Figure)
124
+ const kept = tx.getRepository(FigureReplacedDraft)
125
+
126
+ const record = await kept.findOne({ where: { id: request.keptId, domain: { id: domain.id } } })
127
+ if (!record) throw refused('FIGURE_KEPT_NOT_FOUND', '되살릴 초안을 찾을 수 없습니다.')
128
+ if (record.restoredAt) {
129
+ /* Asked again with the same request: the undo it made. Another request for a draft already back: refused. */
130
+ if (record.restoreRequestId === request.requestId)
131
+ return { figure: await figures.findOneOrFail({ where: { id: record.figureId, domain: { id: domain.id } } }), kept: record }
132
+ throw refused('FIGURE_KEPT_RESTORED', '이 초안은 이미 되살렸습니다.')
133
+ }
134
+
135
+ const figure = await figures.findOne({ where: { id: record.figureId, domain: { id: domain.id } } })
136
+ if (!figure) throw refused('FIGURE_NOT_FOUND', `Figure ${record.figureId} 를 찾을 수 없습니다.`)
137
+
138
+ /* What the undo replaces is kept too -- the same rule as a revert. */
139
+ const { derived } = parseSource(record.source!)
140
+ await keep(kept, {
141
+ domain: { id: domain.id },
142
+ figureId: figure.id,
143
+ kind: 'undo' as const,
144
+ source: figure.source,
145
+ properties: figure.properties,
146
+ thumbnail: figure.thumbnail,
147
+ state: figure.state,
148
+ version: figure.version,
149
+ writtenSource: record.source,
150
+ writtenSourceHash: hashOf(record.source!),
151
+ requestId: request.requestId,
152
+ ...(user ? { creator: { id: user.id } } : {})
153
+ })
154
+
155
+ const written = await figures.update(
156
+ { id: figure.id, domain: { id: domain.id }, source: record.writtenSource },
157
+ {
158
+ source: record.source,
159
+ ...derived,
160
+ properties: record.properties,
161
+ thumbnail: record.thumbnail,
162
+ thumbnailUpdatedAt: record.thumbnail === figure.thumbnail ? figure.thumbnailUpdatedAt : new Date(),
163
+ state: 'draft',
164
+ ...(user ? { updater: { id: user.id } } : {})
165
+ }
166
+ )
167
+ if (written.affected !== 1)
168
+ throw sourceConflict('되돌린 뒤 모델이 다시 바뀌어 되돌리기를 취소하지 않았습니다. 보관한 초안은 그대로 남아 있습니다.')
169
+
170
+ await kept.update({ id: record.id }, { restoredAt: new Date(), restoreRequestId: request.requestId })
171
+ return { figure: await figures.findOneOrFail({ where: { id: figure.id, domain: { id: domain.id } } }), kept: { ...record, restoredAt: new Date(), restoreRequestId: request.requestId } }
172
+ }
173
+
174
+ /** The drafts reverts kept for a figure, newest first -- found again after a reload. */
175
+ export async function replacedDraftsOf(tx: EntityManager, domain: { id: string }, figureId: string) {
176
+ return await tx.getRepository(FigureReplacedDraft).find({
177
+ where: { domain: { id: domain.id }, figureId },
178
+ order: { createdAt: 'DESC' },
179
+ relations: ['creator']
180
+ })
181
+ }
@@ -34,6 +34,7 @@
34
34
  * 사람이 목록에서 고르는 길에서는 드러나지 않지만, 모델은 대화 내용에 이끌려 임의의 id 를 넘길 수
35
35
  * 있다. 그래서 호출자 도메인 소속을 먼저 확인한다.
36
36
  */
37
+ import { randomUUID } from 'crypto'
37
38
  import { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
38
39
 
39
40
  /*
@@ -376,6 +377,12 @@ function figureToolSpecs(): ToolSpec[] {
376
377
  const name = result.source.name || 'Figure'
377
378
  return {
378
379
  proposed: true,
380
+ /*
381
+ The proposal's own name, given once here (ADR-0097 §5.6 item 5; AI lane, 09-27): the dock keys its card by
382
+ it, a new figure made from it is created under it (the row id), and a save records it -- so what became of
383
+ it can be asked after a reload, and the same proposal never makes two figures.
384
+ */
385
+ key: randomUUID(),
379
386
  icon: 'view_in_ar',
380
387
  label: create ? `${name} 만들기` : `${name} 고치기`,
381
388
  reason:
@@ -1,3 +1,4 @@
1
+ import { FigureReplacedDraft } from './figure-replaced-draft.js'
1
2
  import { Field, InputType, Int, ObjectType } from 'type-graphql'
2
3
 
3
4
  import { Figure } from './figure.js'
@@ -129,6 +130,35 @@ export class FigureSaveResult {
129
130
  violations: FigureFinding[]
130
131
  }
131
132
 
133
+ /** An AI proposal saved into a figure, with the figure's names as they are now (ADR-0097 §5.6 item 5). */
134
+ @ObjectType({ description: 'A figure an AI proposal was saved into.' })
135
+ export class FigureProposalOutcome {
136
+ @Field({ description: 'The figure it was saved into.' })
137
+ figureId: string
138
+
139
+ @Field({ nullable: true, description: "The figure's type name now." })
140
+ type?: string
141
+
142
+ @Field({ nullable: true, description: "The figure's display name now." })
143
+ name?: string
144
+
145
+ @Field({ nullable: true, description: 'When the save that held it was made.' })
146
+ recordedAt?: Date
147
+ }
148
+
149
+ /** A revert's outcome: the figure as it now is, and the draft kept for it (ADR-0097 §5.6). */
150
+ @ObjectType({ description: 'The result of a revert, or of an undo of one.' })
151
+ export class FigureRevertResult {
152
+ @Field(type => Figure, { description: 'The figure, as it now is.' })
153
+ figure: Figure
154
+
155
+ @Field(type => [FigureFinding], { description: 'Non-blocking findings on the source written.' })
156
+ violations: FigureFinding[]
157
+
158
+ @Field(type => FigureReplacedDraft, { description: 'The draft kept for this revert -- or, for an undo, the one brought back.' })
159
+ kept: FigureReplacedDraft
160
+ }
161
+
132
162
  /**
133
163
  * 발행 판정 한 줄.
134
164
  *
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A stale expected source is refused with a code of its own (FIGURE_SOURCE_CONFLICT), so the modeller can tell it from a
3
+ * refusal on grounds and read the row back before saying its save failed (D, designer 2026-09-27).
4
+ */
5
+ import { compareAndSwapFigure, prepareFigureWrite } from './figure-write'
6
+
7
+ describe('a write whose expected source is not the row', () => {
8
+ it('is refused as a source conflict, by code', async () => {
9
+ await expect(compareAndSwapFigure(async () => ({ affected: 0 }), 'id', 'domain', 'old')).rejects.toMatchObject({ extensions: { code: 'FIGURE_SOURCE_CONFLICT' } })
10
+ expect(() => prepareFigureWrite('{}', { source: 'theirs' }, 'mine')).toThrow(expect.objectContaining({ extensions: { code: 'FIGURE_SOURCE_CONFLICT' } }))
11
+ })
12
+ it('passes when the row is what was expected', async () => {
13
+ await expect(compareAndSwapFigure(async () => ({ affected: 1 }), 'id', 'domain', 'same')).resolves.toBeUndefined()
14
+ })
15
+ })
@@ -1,5 +1,15 @@
1
+ import { GraphQLError } from 'graphql'
1
2
  import { parseFigureAsset, serializeFigureAsset, compileFigureAsset, figureCostOf, figureScoreOf } from '@hatiolab/figure-model'
2
3
 
4
+ /**
5
+ * The row is not what the writer expected: another edit, or the writer's own earlier save whose answer it never got.
6
+ * Coded, so a client can tell it from a refusal on grounds (permission, validation) and read the row back before
7
+ * saying its save failed (D, designer 2026-09-27).
8
+ */
9
+ export function sourceConflict(message: string) {
10
+ return new GraphQLError(message, { extensions: { code: 'FIGURE_SOURCE_CONFLICT' } })
11
+ }
12
+
3
13
  export interface FigureWriteCriteria {
4
14
  id: string
5
15
  domain: { id: string }
@@ -13,13 +23,13 @@ export async function compareAndSwapFigure(
13
23
  ) {
14
24
  if (expectedSource === undefined) throw Error('수정 전 정본이 필요합니다.')
15
25
  const result = await update({ id, domain: { id: domainId }, source: expectedSource })
16
- if (result.affected !== 1) throw Error('다른 편집에서 모델이 변경되었습니다. 최신 모델을 다시 열어 주세요.')
26
+ if (result.affected !== 1) throw sourceConflict('다른 편집에서 모델이 변경되었습니다. 최신 모델을 다시 열어 주세요.')
17
27
  }
18
28
 
19
29
  /** Early validation only. The repository must also compare expectedSource in its atomic UPDATE. */
20
30
  export function prepareFigureWrite(source: string, existing?: { source?: string }, expectedSource?: string) {
21
31
  if (existing && (expectedSource === undefined || existing.source !== expectedSource)) {
22
- throw Error('다른 편집에서 모델이 변경되었습니다. 최신 모델을 다시 연 뒤 적용해 주세요.')
32
+ throw sourceConflict('다른 편집에서 모델이 변경되었습니다. 최신 모델을 다시 연 뒤 적용해 주세요.')
23
33
  }
24
34
  const asset = parseFigureAsset(source)
25
35
  compileFigureAsset(asset)
@@ -1,9 +1,11 @@
1
1
  import { Figure } from './figure.js'
2
2
  import { FigureHistory } from './figure-history.js'
3
+ import { FigureReplacedDraft } from './figure-replaced-draft.js'
4
+ import { FigureProposalRecord } from './figure-proposal-record.js'
3
5
  import { FigureQuery } from './figure-query.js'
4
6
  import { FigureMutation } from './figure-mutation.js'
5
7
  import { FigureHistoryEntitySubscriber } from './event-subscriber.js'
6
8
 
7
- export const entities = [Figure, FigureHistory]
9
+ export const entities = [Figure, FigureHistory, FigureReplacedDraft, FigureProposalRecord]
8
10
  export const resolvers = [FigureQuery, FigureMutation]
9
11
  export const subscribers = [FigureHistoryEntitySubscriber]
@@ -20,9 +20,9 @@
20
20
  "figure.gate.placement-face-tilted.how": "Choose a mounting face parallel to the plane, or turn '{part}' upright.",
21
21
  "figure.gate.placement-face-tilted.message": "The mounting face of '{part}' is tilted {degrees}° from the plane the figure stands on.",
22
22
  "figure.gate.placement-face-tilted.why": "A tilted face cannot lie on the plane, so how the figure stands is not settled.",
23
- "figure.gate.state-range-sampled.how": "Re-author the motion that could not be proven, and the whole range can be proven.",
24
- "figure.gate.state-range-sampled.message": "The whole range of motion could not be proven, so {states} poses were checked.",
25
- "figure.gate.state-range-sampled.why": "Between the poses that were checked, a part may still go past the declared space.",
23
+ "figure.gate.state-range-sampled.how": "To cover the whole range, re-author what could not be proven.",
24
+ "figure.gate.state-range-sampled.message": "The whole range of sizes and motion could not be proven, so {states} combinations of size and pose were checked.",
25
+ "figure.gate.state-range-sampled.why": "Between the combinations that were checked, a part may still go past the declared space.",
26
26
  "figure.gate.type-is-placeholder.how": "Set the type in the type field, then publish.",
27
27
  "figure.gate.type-is-placeholder.message": "'{type}' is a temporary type given when the figure was first saved.",
28
28
  "figure.gate.type-is-placeholder.why": "Boards call a figure by its type, and it cannot change once the figure is published.",
@@ -20,9 +20,9 @@
20
20
  "figure.gate.placement-face-tilted.how": "평면과 나란한 면을 장착면으로 다시 정하거나, '{part}' 부품을 바로 세우세요.",
21
21
  "figure.gate.placement-face-tilted.message": "장착면으로 정한 '{part}' 부품의 면이 놓이는 평면에서 {degrees}° 기울어 있습니다.",
22
22
  "figure.gate.placement-face-tilted.why": "기운 면은 평면에 닿을 수 없어서, 도형이 어떤 자세로 놓이는지 정해지지 않습니다.",
23
- "figure.gate.state-range-sampled.how": "증명하지 못한 움직임을 다시 저작하면 범위 전체를 증명할 수 있습니다.",
24
- "figure.gate.state-range-sampled.message": "움직이는 범위 전체를 증명하지 못해 자세 {states}개만 검사했습니다.",
25
- "figure.gate.state-range-sampled.why": "검사한 자세 사이에서 부품이 공간 선언을 넘을 수 있습니다.",
23
+ "figure.gate.state-range-sampled.how": "범위 전체를 보장하려면 증명하지 못한 항목을 다시 저작하세요.",
24
+ "figure.gate.state-range-sampled.message": "크기와 움직임의 범위 전체를 증명하지 못해 크기·자세 조합 {states}개만 검사했습니다.",
25
+ "figure.gate.state-range-sampled.why": "검사한 조합 사이에서 부품이 공간 선언을 넘을 수 있습니다.",
26
26
  "figure.gate.type-is-placeholder.how": "타입 칸에서 타입을 정한 뒤 발행하세요.",
27
27
  "figure.gate.type-is-placeholder.message": "'{type}' 타입은 처음 저장할 때 붙은 임시 타입입니다.",
28
28
  "figure.gate.type-is-placeholder.why": "보드는 이 타입으로 도형을 부르고, 발행한 뒤에는 바꿀 수 없습니다.",