@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.
- package/dist-server/service/figure/figure-format.js +4 -3
- package/dist-server/service/figure/figure-format.js.map +1 -1
- package/dist-server/service/figure/figure-mutation.d.ts +5 -3
- package/dist-server/service/figure/figure-mutation.js +43 -41
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-proposal-record.d.ts +16 -0
- package/dist-server/service/figure/figure-proposal-record.js +51 -0
- package/dist-server/service/figure/figure-proposal-record.js.map +1 -0
- package/dist-server/service/figure/figure-proposal.d.ts +14 -0
- package/dist-server/service/figure/figure-proposal.js +39 -0
- package/dist-server/service/figure/figure-proposal.js.map +1 -0
- package/dist-server/service/figure/figure-propose-steps.js +12 -0
- package/dist-server/service/figure/figure-propose-steps.js.map +1 -1
- package/dist-server/service/figure/figure-query.d.ts +6 -1
- package/dist-server/service/figure/figure-query.js +33 -0
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-rename.d.ts +2 -2
- package/dist-server/service/figure/figure-rename.js +8 -20
- package/dist-server/service/figure/figure-rename.js.map +1 -1
- package/dist-server/service/figure/figure-replaced-draft.d.ts +33 -0
- package/dist-server/service/figure/figure-replaced-draft.js +114 -0
- package/dist-server/service/figure/figure-replaced-draft.js.map +1 -0
- package/dist-server/service/figure/figure-revert.d.ts +36 -0
- package/dist-server/service/figure/figure-revert.js +155 -0
- package/dist-server/service/figure/figure-revert.js.map +1 -0
- package/dist-server/service/figure/figure-tools.js +7 -0
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/service/figure/figure-type.d.ts +14 -0
- package/dist-server/service/figure/figure-type.js +44 -1
- package/dist-server/service/figure/figure-type.js.map +1 -1
- package/dist-server/service/figure/figure-write.d.ts +7 -0
- package/dist-server/service/figure/figure-write.js +12 -2
- package/dist-server/service/figure/figure-write.js.map +1 -1
- package/dist-server/service/figure/index.d.ts +3 -1
- package/dist-server/service/figure/index.js +3 -1
- package/dist-server/service/figure/index.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/recipes/air-cylinder/models/default.json +1 -1
- package/recipes/air-shower/models/default.json +1 -1
- package/recipes/barrier-gate/models/default.json +1 -1
- package/recipes/base-frame/models/default.json +1 -1
- package/recipes/belt-surface/models/default.json +1 -1
- package/recipes/bin-row/models/default.json +1 -1
- package/recipes/bollard-row/models/default.json +1 -1
- package/recipes/buzzer/models/default.json +1 -1
- package/recipes/cabinet-doors/models/default.json +1 -1
- package/recipes/cabinet-doors/models/doors=1.json +1 -1
- package/recipes/cabinet-doors/models/doors=2.json +1 -1
- package/recipes/cable-tray/models/default.json +1 -1
- package/recipes/camera-mount/models/default.json +1 -1
- package/recipes/canopy/models/default.json +1 -1
- package/recipes/cart/models/default.json +1 -1
- package/recipes/cart-handle/models/default.json +1 -1
- package/recipes/casters-four/models/default.json +1 -1
- package/recipes/ceiling-rail-carriage/models/default.json +1 -1
- package/recipes/chain-rails/models/default.json +1 -1
- package/recipes/charging-station/models/default.json +1 -1
- package/recipes/column-row/models/default.json +1 -1
- package/recipes/control-cabinet/models/default.json +1 -1
- package/recipes/conveyor-skirts/models/default.json +1 -1
- package/recipes/counter-display/models/default.json +1 -1
- package/recipes/dock-bumpers/models/default.json +1 -1
- package/recipes/dock-leveler/models/default.json +1 -1
- package/recipes/double-hinged-door/models/default.json +1 -1
- package/recipes/drawer-stack/models/default.json +1 -1
- package/recipes/drawer-stack/models/drawers=2.json +1 -1
- package/recipes/drawer-stack/models/drawers=3.json +1 -1
- package/recipes/drawer-stack/models/drawers=4.json +1 -1
- package/recipes/drip-tray/models/default.json +1 -1
- package/recipes/duct-fan/models/default.json +1 -1
- package/recipes/emergency-stop/models/default.json +1 -1
- package/recipes/end-covers/models/default.json +1 -1
- package/recipes/ffu-row/models/default.json +1 -1
- package/recipes/fire-extinguisher/models/default.json +1 -1
- package/recipes/first-aid-box/models/default.json +1 -1
- package/recipes/floor-zone/models/default.json +1 -1
- package/recipes/folding-door/models/default.json +1 -1
- package/recipes/fork/models/default.json +1 -1
- package/recipes/gantry-frame/models/default.json +1 -1
- package/recipes/gripper-2f/models/default.json +1 -1
- package/recipes/guard-cover/models/default.json +1 -1
- package/recipes/handrail/models/default.json +1 -1
- package/recipes/hatch/models/default.json +1 -1
- package/recipes/hinged-door/models/default.json +1 -1
- package/recipes/hinged-door/models/hinge=left.json +1 -1
- package/recipes/hinged-door/models/hinge=right.json +1 -1
- package/recipes/hmi-monitor/models/default.json +1 -1
- package/recipes/hopper/models/default.json +1 -1
- package/recipes/interlock-switch/models/default.json +1 -1
- package/recipes/junction-box/models/default.json +1 -1
- package/recipes/ladder/models/default.json +1 -1
- package/recipes/legs-four/models/default.json +1 -1
- package/recipes/leveling-feet/models/default.json +1 -1
- package/recipes/lift-updown/models/default.json +1 -1
- package/recipes/light-curtain/models/default.json +1 -1
- package/recipes/limit-switch/models/default.json +1 -1
- package/recipes/linear-guide/models/default.json +1 -1
- package/recipes/locker-row/models/default.json +1 -1
- package/recipes/motor-box/models/default.json +1 -1
- package/recipes/observation-window/models/default.json +1 -1
- package/recipes/operator-panel/models/buttons=2.json +1 -1
- package/recipes/operator-panel/models/buttons=3.json +1 -1
- package/recipes/operator-panel/models/buttons=4.json +1 -1
- package/recipes/operator-panel/models/buttons=5.json +1 -1
- package/recipes/operator-panel/models/default.json +1 -1
- package/recipes/pallet/models/default.json +1 -1
- package/recipes/partition-wall/models/default.json +1 -1
- package/recipes/pass-box/models/default.json +1 -1
- package/recipes/peg-board/models/default.json +1 -1
- package/recipes/photo-sensor-pair/models/default.json +1 -1
- package/recipes/pipe-run/models/default.json +1 -1
- package/recipes/platform/models/default.json +1 -1
- package/recipes/power-box/models/default.json +1 -1
- package/recipes/pressure-gauge/models/default.json +1 -1
- package/recipes/pull-cord/models/default.json +1 -1
- package/recipes/pump/models/default.json +1 -1
- package/recipes/pusher/models/default.json +1 -1
- package/recipes/robot-plinth/models/default.json +1 -1
- package/recipes/roll-up-shutter/models/default.json +1 -1
- package/recipes/roller-row/models/default.json +1 -1
- package/recipes/room-shell/models/default.json +1 -1
- package/recipes/rotating-beacon/models/default.json +1 -1
- package/recipes/safety-fence/models/default.json +1 -1
- package/recipes/safety-mat/models/default.json +1 -1
- package/recipes/safety-sign-stand/models/default.json +1 -1
- package/recipes/scanner-mount/models/default.json +1 -1
- package/recipes/selector-switch/models/default.json +1 -1
- package/recipes/shelf-tiers/models/default.json +1 -1
- package/recipes/shelf-tiers/models/tiers=2.json +1 -1
- package/recipes/shelf-tiers/models/tiers=3.json +1 -1
- package/recipes/shelf-tiers/models/tiers=4.json +1 -1
- package/recipes/shelf-tiers/models/tiers=5.json +1 -1
- package/recipes/side-guides/models/default.json +1 -1
- package/recipes/sign-board/models/default.json +1 -1
- package/recipes/signal-tower-3/models/default.json +1 -1
- package/recipes/sliding-door/models/default.json +1 -1
- package/recipes/sliding-door/models/leaves=1,lamp=false.json +1 -1
- package/recipes/sliding-door/models/leaves=1,lamp=true.json +1 -1
- package/recipes/sliding-door/models/leaves=2,lamp=false.json +1 -1
- package/recipes/sliding-door/models/leaves=2,lamp=true.json +1 -1
- package/recipes/stairs/models/default.json +1 -1
- package/recipes/status-lamp/models/default.json +1 -1
- package/recipes/stopper/models/default.json +1 -1
- package/recipes/suction-pads/models/default.json +1 -1
- package/recipes/tank/models/default.json +1 -1
- package/recipes/telescopic-arm/models/default.json +1 -1
- package/recipes/toe-board/models/default.json +1 -1
- package/recipes/tote/models/default.json +1 -1
- package/recipes/traffic-cones/models/default.json +1 -1
- package/recipes/transfer-belts/models/default.json +1 -1
- package/recipes/tray-stack/models/default.json +1 -1
- package/recipes/tray-stack/models/trays=3.json +1 -1
- package/recipes/tray-stack/models/trays=4.json +1 -1
- package/recipes/tray-stack/models/trays=5.json +1 -1
- package/recipes/trough/models/default.json +1 -1
- package/recipes/turntable/models/default.json +1 -1
- package/recipes/valve-handle/models/default.json +1 -1
- package/recipes/waste-bin/models/default.json +1 -1
- package/recipes/work-light/models/default.json +1 -1
- package/recipes/workbench-top/models/default.json +1 -1
- package/server/service/figure/figure-format.ts +4 -3
- package/server/service/figure/figure-mutation.ts +48 -42
- package/server/service/figure/figure-proposal-db.test.ts +108 -0
- package/server/service/figure/figure-proposal-record.ts +38 -0
- package/server/service/figure/figure-proposal.ts +39 -0
- package/server/service/figure/figure-propose-clips.test.ts +73 -0
- package/server/service/figure/figure-propose-steps.ts +12 -0
- package/server/service/figure/figure-query.ts +22 -1
- package/server/service/figure/figure-rename.test.ts +13 -2
- package/server/service/figure/figure-rename.ts +13 -1
- package/server/service/figure/figure-replaced-draft.ts +90 -0
- package/server/service/figure/figure-revert-db.test.ts +159 -0
- package/server/service/figure/figure-revert-race.pg.test.ts +168 -0
- package/server/service/figure/figure-revert.ts +181 -0
- package/server/service/figure/figure-tools.ts +7 -0
- package/server/service/figure/figure-type.ts +30 -0
- package/server/service/figure/figure-write.test.ts +15 -0
- package/server/service/figure/figure-write.ts +12 -2
- package/server/service/figure/index.ts +3 -1
- package/translations/en.json +3 -3
- 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
|
-
|
|
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
|
+
})
|