@things-factory/figure-service 10.1.81 → 10.1.82

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 (60) 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/server/service/figure/figure-format.ts +4 -3
  41. package/server/service/figure/figure-mutation.ts +48 -42
  42. package/server/service/figure/figure-proposal-db.test.ts +108 -0
  43. package/server/service/figure/figure-proposal-record.ts +38 -0
  44. package/server/service/figure/figure-proposal.ts +39 -0
  45. package/server/service/figure/figure-propose-clips.test.ts +73 -0
  46. package/server/service/figure/figure-propose-steps.ts +12 -0
  47. package/server/service/figure/figure-query.ts +22 -1
  48. package/server/service/figure/figure-rename.test.ts +13 -2
  49. package/server/service/figure/figure-rename.ts +13 -1
  50. package/server/service/figure/figure-replaced-draft.ts +90 -0
  51. package/server/service/figure/figure-revert-db.test.ts +159 -0
  52. package/server/service/figure/figure-revert-race.pg.test.ts +168 -0
  53. package/server/service/figure/figure-revert.ts +181 -0
  54. package/server/service/figure/figure-tools.ts +7 -0
  55. package/server/service/figure/figure-type.ts +30 -0
  56. package/server/service/figure/figure-write.test.ts +15 -0
  57. package/server/service/figure/figure-write.ts +12 -2
  58. package/server/service/figure/index.ts +3 -1
  59. package/translations/en.json +3 -3
  60. package/translations/ko.json +3 -3
@@ -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
+ })
@@ -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.",