@things-factory/figure-service 10.1.66 → 10.1.67
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/README.md +5 -8
- package/dist-server/service/figure/figure-format.d.ts +49 -31
- package/dist-server/service/figure/figure-format.js +47 -90
- package/dist-server/service/figure/figure-format.js.map +1 -1
- package/dist-server/service/figure/figure-history.js +1 -1
- package/dist-server/service/figure/figure-history.js.map +1 -1
- package/dist-server/service/figure/figure-mutation.js +5 -5
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-propose-v3.d.ts +1 -1
- package/dist-server/service/figure/figure-propose-v3.js +1 -1
- package/dist-server/service/figure/figure-propose-v3.js.map +1 -1
- package/dist-server/service/figure/figure-query.d.ts +6 -11
- package/dist-server/service/figure/figure-query.js +12 -40
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-rename.js +2 -11
- package/dist-server/service/figure/figure-rename.js.map +1 -1
- package/dist-server/service/figure/figure-tools.js +32 -112
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/service/figure/figure-type.d.ts +0 -17
- package/dist-server/service/figure/figure-type.js +4 -71
- package/dist-server/service/figure/figure-type.js.map +1 -1
- package/dist-server/service/figure/figure-v3-write.d.ts +1 -1
- package/dist-server/service/figure/figure-v3-write.js +2 -3
- package/dist-server/service/figure/figure-v3-write.js.map +1 -1
- package/dist-server/service/figure/figure.d.ts +5 -10
- package/dist-server/service/figure/figure.js +6 -11
- package/dist-server/service/figure/figure.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/service/index.js +1 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/server/service/figure/figure-blueprint-query.test.ts +29 -48
- package/server/service/figure/figure-format.test.ts +54 -110
- package/server/service/figure/figure-format.ts +77 -87
- package/server/service/figure/figure-history.ts +1 -1
- package/server/service/figure/figure-inspect-db.test.ts +1 -11
- package/server/service/figure/figure-mutation.ts +7 -7
- package/server/service/figure/figure-propose-v3.test.ts +2 -2
- package/server/service/figure/figure-propose-v3.ts +1 -1
- package/server/service/figure/figure-query.ts +14 -36
- package/server/service/figure/figure-rename-db.test.ts +1 -1
- package/server/service/figure/figure-rename.test.ts +4 -18
- package/server/service/figure/figure-rename.ts +2 -12
- package/server/service/figure/figure-tools.test.ts +17 -80
- package/server/service/figure/figure-tools.ts +30 -125
- package/server/service/figure/figure-type.ts +3 -52
- package/server/service/figure/figure-v3-write.ts +3 -4
- package/server/service/figure/figure.ts +7 -12
- package/server/service/index.ts +1 -1
- package/dist-server/service/figure/figure-gate.d.ts +0 -93
- package/dist-server/service/figure/figure-gate.js +0 -302
- package/dist-server/service/figure/figure-gate.js.map +0 -1
- package/dist-server/service/figure/figure-source.d.ts +0 -45
- package/dist-server/service/figure/figure-source.js +0 -59
- package/dist-server/service/figure/figure-source.js.map +0 -1
- package/server/service/figure/figure-gate.test.ts +0 -155
- package/server/service/figure/figure-gate.ts +0 -392
- package/server/service/figure/figure-source.test.ts +0 -149
- package/server/service/figure/figure-source.ts +0 -94
|
@@ -1,44 +1,62 @@
|
|
|
1
1
|
/*
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* The cutover (ADR-0087) converts assets to V3 and the `source` column holds one or the other while that
|
|
7
|
-
* happens. Every server path that reads a source — saving, publishing, restoring a version, handing a
|
|
8
|
-
* blueprint to a board — had exactly one implementation, the V2 one, so a V3 asset saved through
|
|
9
|
-
* `saveFigureV3` could not be published, restored or drawn. Branching at each of those four places would put
|
|
10
|
-
* the same question in four voices; it is asked once, here.
|
|
11
|
-
*
|
|
12
|
-
* ## How the format is known
|
|
13
|
-
*
|
|
14
|
-
* The asset says so. A V2 source carries `version: 2`; a V3 asset carries `version: 'figure-v3-asset-1'`.
|
|
15
|
-
* Nothing is guessed from the shape of the object: a source that says neither is refused by name, because a
|
|
16
|
-
* source the server cannot identify is one it must not save.
|
|
17
|
-
*
|
|
18
|
-
* ## What is not here
|
|
19
|
-
*
|
|
20
|
-
* No conversion. This door reads what is stored; turning a V2 asset into a V3 one is `convertV2ToV3`, run
|
|
21
|
-
* deliberately against a recoverable copy (the designer's step 3), never as a side effect of a save.
|
|
2
|
+
* The doors a stored source goes through: saving (`parseSource`), publishing (`inspectSource`) and handing it to a
|
|
3
|
+
* board (`consumablePayloadOf`). The format is `figure-v3-asset-1`, and the asset says so in its `version`; a source
|
|
4
|
+
* that does not is refused by name, because a source the server cannot identify is one it must not save.
|
|
22
5
|
*/
|
|
23
|
-
import {
|
|
6
|
+
import { compileV3Asset, inspectV3Asset, isPlaceholderType, parseV3Asset, serializeV3Asset, v3ContractChanges, v3CostOf, v3ScoreOf } from '@hatiolab/figure-model'
|
|
24
7
|
import type { V3Asset, V3ContractChange, V3ContractError, V3Violation } from '@hatiolab/figure-model'
|
|
25
8
|
|
|
26
|
-
import type {
|
|
27
|
-
import { inspectSource } from './figure-gate.js'
|
|
28
|
-
import { FigureSourceError, parseSource } from './figure-source.js'
|
|
29
|
-
import type { ParsedSource } from './figure-source.js'
|
|
30
|
-
|
|
31
|
-
export type FigureFormat = 'v2' | 'v3'
|
|
9
|
+
import type { FigureFinding } from './figure-type.js'
|
|
32
10
|
|
|
33
|
-
/** The version
|
|
11
|
+
/** The version the format stamps on its own source. */
|
|
34
12
|
export const V3_ASSET_VERSION = 'figure-v3-asset-1'
|
|
35
13
|
|
|
14
|
+
/** A source that passed the save door: what is stored, and the numbers the catalogue shows, computed here. */
|
|
15
|
+
export interface ParsedSource {
|
|
16
|
+
/** The source to store, as the kernel serializes it. */
|
|
17
|
+
source: string
|
|
18
|
+
/** Computed from the source, never taken from the client -- a client's numbers could lie to the catalogue. */
|
|
19
|
+
derived: { score: number; triangles: number; groups: number }
|
|
20
|
+
/** Findings that do not block the save; the caller returns them with the answer. */
|
|
21
|
+
violations: FigureFinding[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The source breaks the format. It is not stored. */
|
|
25
|
+
export class FigureSourceError extends Error {
|
|
26
|
+
readonly findings: FigureFinding[]
|
|
27
|
+
|
|
28
|
+
constructor(findings: FigureFinding[]) {
|
|
29
|
+
super(`정본이 형식을 어겼다 — ${findings.length}건: ${findings.map(f => f.code).join(', ')}`)
|
|
30
|
+
this.name = 'FigureSourceError'
|
|
31
|
+
this.findings = findings
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One thing the release gate found. `blocking` true means the figure cannot be published. */
|
|
36
|
+
export interface GateFinding {
|
|
37
|
+
code: string
|
|
38
|
+
message: string
|
|
39
|
+
/** **Why it matters.** A measurement alone leaves the reader at "so what?". */
|
|
40
|
+
why?: string
|
|
41
|
+
/** **What to change.** By part name and value. */
|
|
42
|
+
how?: string
|
|
43
|
+
/** Where it points: the parts, or the path in the source. */
|
|
44
|
+
at?: string
|
|
45
|
+
blocking: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Inspection {
|
|
49
|
+
findings: GateFinding[]
|
|
50
|
+
/** Whether anything blocks. The screen locks the button with this. */
|
|
51
|
+
blocked: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
36
54
|
/**
|
|
37
|
-
*
|
|
55
|
+
* That the source is JSON and names the format.
|
|
38
56
|
*
|
|
39
|
-
* @throws {FigureSourceError} when the text is not JSON, or
|
|
57
|
+
* @throws {FigureSourceError} when the text is not JSON, or does not carry `version: 'figure-v3-asset-1'`.
|
|
40
58
|
*/
|
|
41
|
-
|
|
59
|
+
function assertFormat(sourceJson: string): void {
|
|
42
60
|
let parsed: unknown
|
|
43
61
|
try {
|
|
44
62
|
parsed = JSON.parse(sourceJson)
|
|
@@ -47,31 +65,21 @@ export function formatOf(sourceJson: string): FigureFormat {
|
|
|
47
65
|
{ code: 'source-not-json', message: `정본이 JSON 이 아니다: ${(e as Error).message}`, at: 'source' }
|
|
48
66
|
])
|
|
49
67
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
{
|
|
55
|
-
code: 'source-format-unknown',
|
|
56
|
-
message: `정본의 판을 알 수 없다 — V2 는 숫자 version 을, V3 자산은 '${V3_ASSET_VERSION}' 을 적는다`,
|
|
57
|
-
at: 'version'
|
|
58
|
-
}
|
|
59
|
-
])
|
|
68
|
+
if ((parsed as { version?: unknown } | null)?.version !== V3_ASSET_VERSION)
|
|
69
|
+
throw new FigureSourceError([
|
|
70
|
+
{ code: 'source-format-unknown', message: `정본의 판을 알 수 없다 — 도형 자산은 version '${V3_ASSET_VERSION}' 을 적는다`, at: 'version' }
|
|
71
|
+
])
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
/**
|
|
63
|
-
* Validates a source
|
|
75
|
+
* Validates a source and computes what the catalogue shows: the cost from `v3CostOf`, and the score from `v3ScoreOf`
|
|
76
|
+
* -- the headroom against placing `LIMITS.screenInstances` of it on one screen (chief architect, 2026-09-24), the
|
|
77
|
+
* same number the modeller's maturity tab shows. An asset that draws nothing has no score, not a full one.
|
|
64
78
|
*
|
|
65
|
-
*
|
|
66
|
-
* budget columns mean one thing across the two formats. Its `score` is `v3ScoreOf`'s -- the headroom against
|
|
67
|
-
* placing `LIMITS.screenInstances` of it on one screen (chief architect, 2026-09-24), the same number the
|
|
68
|
-
* modeller's maturity tab shows. An asset that draws nothing has no score, not a full one.
|
|
69
|
-
*
|
|
70
|
-
* @throws {FigureSourceError} when the source breaks its own format.
|
|
79
|
+
* @throws {FigureSourceError} when the source breaks the format.
|
|
71
80
|
*/
|
|
72
|
-
export function
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
export function parseSource(sourceJson: string): ParsedSource {
|
|
82
|
+
assertFormat(sourceJson)
|
|
75
83
|
let asset
|
|
76
84
|
try {
|
|
77
85
|
asset = parseV3Asset(sourceJson)
|
|
@@ -85,7 +93,6 @@ export function parseAnySource(sourceJson: string): ParsedSource & { format: Fig
|
|
|
85
93
|
const cost = v3CostOf(asset)
|
|
86
94
|
const score = v3ScoreOf(asset)
|
|
87
95
|
return {
|
|
88
|
-
format: 'v3',
|
|
89
96
|
source: serializeV3Asset(asset),
|
|
90
97
|
derived: { score: (score?.score ?? null) as unknown as number, triangles: cost.triangles, groups: cost.groups },
|
|
91
98
|
violations: []
|
|
@@ -95,7 +102,7 @@ export function parseAnySource(sourceJson: string): ParsedSource & { format: Fig
|
|
|
95
102
|
/**
|
|
96
103
|
* Whether this source may be published, and why not.
|
|
97
104
|
*
|
|
98
|
-
*
|
|
105
|
+
* The judgement is `inspectV3Asset`: every part inside the volume the asset declares, over the whole
|
|
99
106
|
* state range, and the face it says it is mounted on lying in that plane. A verdict reached by sampling is
|
|
100
107
|
* reported as a finding of its own rather than hidden — an author releasing a figure the gate could only
|
|
101
108
|
* sample should be told so, because nothing proved it holds between the states that were checked.
|
|
@@ -113,8 +120,7 @@ export type Translate = (key: string, options?: Record<string, unknown>) => stri
|
|
|
113
120
|
* rather than as the key printed raw.
|
|
114
121
|
*
|
|
115
122
|
* The words are the modeller's: 「공간 선언」 for the volume a figure takes up and 「장착면」 for the face it stands on
|
|
116
|
-
* (`figure.text.mounting-face-explained`).
|
|
117
|
-
* the way out — because at ADR-0087 step 5 these become the only findings an author sees.
|
|
123
|
+
* (`figure.text.mounting-face-explained`). A fact, then the way out.
|
|
118
124
|
*/
|
|
119
125
|
function v3Wording(violation: V3Violation, say: Say, hasParts: boolean): Pick<GateFinding, 'message' | 'why' | 'how'> {
|
|
120
126
|
const part = violation.part ?? ''
|
|
@@ -164,7 +170,7 @@ function v3Wording(violation: V3Violation, say: Say, hasParts: boolean): Pick<Ga
|
|
|
164
170
|
case 'PARTS_SPLIT_ON_RESIZE': {
|
|
165
171
|
/*
|
|
166
172
|
What comes off is every piece but the largest — the figure as the author sees it is the big piece, and the
|
|
167
|
-
small ones are what they will see hanging in the air (
|
|
173
|
+
small ones are what they will see hanging in the air (2026-09-24).
|
|
168
174
|
*/
|
|
169
175
|
const resize = violation.resize!
|
|
170
176
|
const pieces = [...resize.pieces].sort((a, b) => b.length - a.length)
|
|
@@ -203,23 +209,22 @@ function sayerOf(t?: Translate): Say {
|
|
|
203
209
|
t ? t(key, { ...params, defaultValue: english, interpolation: { escapeValue: false } }) : english
|
|
204
210
|
}
|
|
205
211
|
|
|
206
|
-
export function
|
|
212
|
+
export function inspectSource(
|
|
207
213
|
sourceJson?: string | null,
|
|
208
214
|
releasedJson?: string | null,
|
|
209
215
|
t?: Translate,
|
|
210
|
-
/** The row's `type`
|
|
216
|
+
/** The row's `type` -- the only place the name lives, since the asset carries none. */
|
|
211
217
|
rowType?: string
|
|
212
218
|
): Inspection {
|
|
213
|
-
if (!sourceJson)
|
|
214
|
-
|
|
215
|
-
|
|
219
|
+
if (!sourceJson) {
|
|
220
|
+
return { blocked: true, findings: [{ code: 'no-source', message: '저장된 정본이 없다. 저장하면 발행할 수 있다', blocking: true }] }
|
|
221
|
+
}
|
|
216
222
|
try {
|
|
217
|
-
|
|
223
|
+
assertFormat(sourceJson)
|
|
218
224
|
} catch (e) {
|
|
219
225
|
const findings = (e as FigureSourceError).findings ?? [{ code: 'source-unreadable', message: String(e) }]
|
|
220
226
|
return { blocked: true, findings: findings.map(f => ({ ...f, blocking: true })) }
|
|
221
227
|
}
|
|
222
|
-
if (format === 'v2') return inspectSource(sourceJson, releasedJson)
|
|
223
228
|
|
|
224
229
|
let inspection
|
|
225
230
|
let asset: V3Asset | undefined
|
|
@@ -245,10 +250,9 @@ export function inspectAnySource(
|
|
|
245
250
|
blocking: true
|
|
246
251
|
}))
|
|
247
252
|
/*
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
refusal stands here, read off the row.
|
|
253
|
+
A machine-made name is refused at release. The asset has no `type`, so the name is read off the row; a new figure
|
|
254
|
+
was once published as `FIGURE_<uuid>` (:3300, 2026-09-24). Publishing freezes the name (ADR-0063 decision 4) and
|
|
255
|
+
boards call a figure by it.
|
|
252
256
|
*/
|
|
253
257
|
if (rowType && isPlaceholderType(rowType)) {
|
|
254
258
|
findings.push({
|
|
@@ -283,23 +287,12 @@ export function inspectAnySource(
|
|
|
283
287
|
* The last release against this one — what the drawings that already hold this type lean on (ADR-0063 decision 3).
|
|
284
288
|
*
|
|
285
289
|
* figure-model names the changes and whether each blocks (`v3ContractChanges`); the sentence is made here. When
|
|
286
|
-
* the last release
|
|
287
|
-
* is, or it no longer parses — the gate says so rather than passing in silence: a release that was not checked
|
|
290
|
+
* the last release no longer parses, the gate says so rather than passing in silence: a release that was not checked
|
|
288
291
|
* against the last one is a different fact from one that was checked and found safe.
|
|
289
292
|
*/
|
|
290
293
|
function v3ContractFindings(asset: V3Asset, releasedJson: string, say: Say): GateFinding[] {
|
|
291
294
|
let before: V3Asset
|
|
292
295
|
try {
|
|
293
|
-
if (formatOf(releasedJson) === 'v2') {
|
|
294
|
-
return [
|
|
295
|
-
{
|
|
296
|
-
code: 'released-unreadable',
|
|
297
|
-
message: say('figure.gate.v3-released-was-v2.message', {}, 'The last release is a V2 figure, so this release was not compared with it.'),
|
|
298
|
-
why: say('figure.gate.v3-released-not-compared.why', {}, 'Whether this release breaks the instances already placed has not been checked.'),
|
|
299
|
-
blocking: false
|
|
300
|
-
}
|
|
301
|
-
]
|
|
302
|
-
}
|
|
303
296
|
before = parseV3Asset(releasedJson)
|
|
304
297
|
} catch (e) {
|
|
305
298
|
return [
|
|
@@ -430,15 +423,12 @@ function v3ContractWording(change: V3ContractChange, say: Say): Pick<GateFinding
|
|
|
430
423
|
}
|
|
431
424
|
|
|
432
425
|
/**
|
|
433
|
-
* What a consumer is handed for a released figure
|
|
434
|
-
*
|
|
435
|
-
* V2 hands over a compiled blueprint; V3 hands over the asset itself, because the thing that draws a V3 asset
|
|
436
|
-
* — `registerFigureV3` in things-scene — takes the asset and compiles it with the kernel. The format travels
|
|
437
|
-
* with it so the consumer calls the right registration rather than sniffing the payload a second time.
|
|
426
|
+
* What a consumer is handed for a released figure: the asset itself, because what draws it -- `registerFigureV3` in
|
|
427
|
+
* things-scene -- takes the asset and compiles it with the kernel.
|
|
438
428
|
*/
|
|
439
|
-
export function consumablePayloadOf(sourceJson: string): {
|
|
440
|
-
|
|
429
|
+
export function consumablePayloadOf(sourceJson: string): { asset: V3Asset } {
|
|
430
|
+
assertFormat(sourceJson)
|
|
441
431
|
const asset = parseV3Asset(sourceJson)
|
|
442
432
|
compileV3Asset(asset)
|
|
443
|
-
return {
|
|
433
|
+
return { asset }
|
|
444
434
|
}
|
|
@@ -64,7 +64,7 @@ export class FigureHistory implements HistoryEntityInterface<Figure> {
|
|
|
64
64
|
state?: 'draft' | 'released'
|
|
65
65
|
|
|
66
66
|
@Column({ nullable: true, ...longTextColumn() })
|
|
67
|
-
@Field({ nullable: true, description: 'The
|
|
67
|
+
@Field({ nullable: true, description: 'The figure asset JSON (figure-v3-asset-1) at this version.' })
|
|
68
68
|
source?: string
|
|
69
69
|
|
|
70
70
|
@Column({ nullable: true, ...longTextColumn() })
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* The inspect doors, read against a real row.
|
|
3
3
|
*
|
|
4
|
-
* `
|
|
4
|
+
* `inspectSource` is tested on its own in figure-format.test.ts, and that is where the temporary-type refusal was
|
|
5
5
|
* first pinned. It still did not reach the release panel: `inspectFigure` selected only `id` and `source`, so the row's
|
|
6
6
|
* `type` arrived as `undefined` and the refusal never ran. The panel enabled 「발행하기」 and the release mutation —
|
|
7
7
|
* which loads the whole row — refused on the press (2026-09-24). A test of the function could not see that; this
|
|
@@ -128,13 +128,3 @@ describe('inspectFigureDraft — the verdict while editing', () => {
|
|
|
128
128
|
expect(declaration).toContain('category: "figure", privilege: "query"')
|
|
129
129
|
})
|
|
130
130
|
})
|
|
131
|
-
|
|
132
|
-
describe('format — which modeller a screen opens', () => {
|
|
133
|
-
it('reads the format off the stored source; a source it cannot identify has none', () => {
|
|
134
|
-
const query = new FigureQuery()
|
|
135
|
-
expect(query.format({ source: sound() } as any)).toBe('v3')
|
|
136
|
-
expect(query.format({ source: JSON.stringify({ version: 2, parts: [] }) } as any)).toBe('v2')
|
|
137
|
-
expect(query.format({ source: 'not json' } as any)).toBeNull()
|
|
138
|
-
expect(query.format({} as any)).toBeNull()
|
|
139
|
-
})
|
|
140
|
-
})
|
|
@@ -8,9 +8,9 @@ import { Figure } from './figure.js'
|
|
|
8
8
|
import { FigureHistory } from './figure-history.js'
|
|
9
9
|
import { FigurePatch, FigureSaveResult, NewFigure } from './figure-type.js'
|
|
10
10
|
import { FigureProposal, ProposeRequest } from './figure-propose-type.js'
|
|
11
|
-
import {
|
|
11
|
+
import { inspectSource } from './figure-format.js'
|
|
12
12
|
import { renameDraftType, renameFacts } from './figure-rename.js'
|
|
13
|
-
import {
|
|
13
|
+
import { parseSource } from './figure-format.js'
|
|
14
14
|
import { ProposeV3Failure, proposeFigureV3 } from './figure-propose-v3.js'
|
|
15
15
|
import { prepareV3Write, compareAndSwapV3, pictureOf } from './figure-v3-write.js'
|
|
16
16
|
|
|
@@ -47,7 +47,7 @@ export class FigureMutation {
|
|
|
47
47
|
— seen on :3300, 2026-09-24. A retry is still safe: the row id is the creation id, not the name.
|
|
48
48
|
*/
|
|
49
49
|
/*
|
|
50
|
-
The author names it on the first save
|
|
50
|
+
The author names it on the first save (2026-09-25): created under that
|
|
51
51
|
name or not at all -- a taken name refuses the save, rather than saving under the temporary one and failing a
|
|
52
52
|
rename after. Without a name (a caller that has none to give) it is the temporary one, renamed later.
|
|
53
53
|
*/
|
|
@@ -95,7 +95,7 @@ export class FigureMutation {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
// 형식을 어기면 여기서 던진다. 파생값은 서버가 계산한다.
|
|
98
|
-
const { source, derived, violations } =
|
|
98
|
+
const { source, derived, violations } = parseSource(figure.source)
|
|
99
99
|
|
|
100
100
|
const saved = await repository.save({
|
|
101
101
|
...figure,
|
|
@@ -136,7 +136,7 @@ export class FigureMutation {
|
|
|
136
136
|
let source = figure.source
|
|
137
137
|
|
|
138
138
|
if (patch.source !== undefined && patch.source !== figure.source) {
|
|
139
|
-
const parsed =
|
|
139
|
+
const parsed = parseSource(patch.source)
|
|
140
140
|
source = parsed.source
|
|
141
141
|
derived = parsed.derived
|
|
142
142
|
violations = parsed.violations
|
|
@@ -287,7 +287,7 @@ export class FigureMutation {
|
|
|
287
287
|
.limit(1)
|
|
288
288
|
.getOne()
|
|
289
289
|
|
|
290
|
-
const inspection =
|
|
290
|
+
const inspection = inspectSource(figure.source, released?.source, context.t, figure.type)
|
|
291
291
|
if (inspection.blocked) {
|
|
292
292
|
const reasons = inspection.findings
|
|
293
293
|
.filter(finding => finding.blocking)
|
|
@@ -361,7 +361,7 @@ export class FigureMutation {
|
|
|
361
361
|
}
|
|
362
362
|
|
|
363
363
|
/* 형식 검사도 다시 받는다 — 옛 판이 오늘의 형식을 어길 수 있고, 그것을 조용히 들이지 않는다. */
|
|
364
|
-
const { source, derived, violations } =
|
|
364
|
+
const { source, derived, violations } = parseSource(history.source)
|
|
365
365
|
|
|
366
366
|
const saved = await repository.save({
|
|
367
367
|
/*
|
|
@@ -47,11 +47,11 @@ const liftTable = [
|
|
|
47
47
|
const withoutRoom = liftTable.slice(0, -1)
|
|
48
48
|
const wrongFace = liftTable.map((s, i) => (i === 4 ? { ...s, face: 'left' } : s))
|
|
49
49
|
|
|
50
|
-
describe('the model is told the kernel\'s commands and the open draft
|
|
50
|
+
describe('the model is told the kernel\'s commands and the open draft', () => {
|
|
51
51
|
it('teaches every word of the grammar, in its own shape', () => {
|
|
52
52
|
const rules = rulesPromptV3()
|
|
53
53
|
for (const word of V3_PROPOSAL_GRAMMAR) expect(rules).toContain(word.shape)
|
|
54
|
-
expect(rules).not.toMatch(/
|
|
54
|
+
expect(rules).not.toMatch(/"parts"\s*:\s*\[|anchor/i)
|
|
55
55
|
})
|
|
56
56
|
|
|
57
57
|
it('a measure asked to be the same as another is linked, not copied (:3300, 2026-09-26: 「몸통 지름과 같게」 came as a fixed 100)', () => {
|
|
@@ -76,7 +76,7 @@ export function rulesPromptV3(): string {
|
|
|
76
76
|
].join('\n')
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
/** The request, and the open draft's
|
|
79
|
+
/** The request, and the open draft's source when revising. */
|
|
80
80
|
export function taskPromptV3(input: ProposeV3Input): string {
|
|
81
81
|
const lines = [`Request: ${input.prompt}`]
|
|
82
82
|
if (input.base) {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { In } from 'typeorm'
|
|
2
|
-
import { Args, Arg, Ctx, Directive,
|
|
2
|
+
import { Args, Arg, Ctx, Directive, Query, Resolver, UseMiddleware } from 'type-graphql'
|
|
3
3
|
import { GraphQLJSON } from 'graphql-scalars'
|
|
4
|
-
import { compile } from '@hatiolab/figure-model'
|
|
5
4
|
|
|
6
5
|
import {
|
|
7
6
|
Domain,
|
|
@@ -14,29 +13,12 @@ import {
|
|
|
14
13
|
|
|
15
14
|
import { Figure } from './figure.js'
|
|
16
15
|
import { FigureHistory } from './figure-history.js'
|
|
17
|
-
import { consumablePayloadOf,
|
|
16
|
+
import { consumablePayloadOf, inspectSource } from './figure-format.js'
|
|
18
17
|
import { FigureInspection, FigureList } from './figure-type.js'
|
|
19
18
|
|
|
20
19
|
@Resolver(Figure)
|
|
21
20
|
@UseMiddleware(SpeakRefusals)
|
|
22
21
|
export class FigureQuery {
|
|
23
|
-
/**
|
|
24
|
-
* Which format the stored source is in — so a screen opens the modeller that can edit it.
|
|
25
|
-
*
|
|
26
|
-
* The catalogue sent every figure to the V2 modeller. A converted figure there fails to open, and the V3 modeller
|
|
27
|
-
* could only be reached by typing its address (2026-09-24). The row does not say; the source does (`formatOf`).
|
|
28
|
-
* A source the server cannot identify has no format rather than a guessed one.
|
|
29
|
-
*/
|
|
30
|
-
@FieldResolver(type => String, { nullable: true, description: "The format of the stored source: 'v2' or 'v3'." })
|
|
31
|
-
format(@Root() figure: Figure): string | null {
|
|
32
|
-
if (!figure.source) return null
|
|
33
|
-
try {
|
|
34
|
-
return formatOf(figure.source)
|
|
35
|
-
} catch {
|
|
36
|
-
return null
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
22
|
@Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
|
|
41
23
|
@Query(returns => Figure, { nullable: true, description: 'Finds a single figure by its ID.' })
|
|
42
24
|
async figure(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<Figure> {
|
|
@@ -154,7 +136,7 @@ export class FigureQuery {
|
|
|
154
136
|
.limit(1)
|
|
155
137
|
.getOne()
|
|
156
138
|
|
|
157
|
-
return
|
|
139
|
+
return inspectSource(figure.source, released?.source, context.t, figure.type)
|
|
158
140
|
}
|
|
159
141
|
|
|
160
142
|
/**
|
|
@@ -173,7 +155,7 @@ export class FigureQuery {
|
|
|
173
155
|
@Arg('id', { nullable: true }) id?: string
|
|
174
156
|
): Promise<FigureInspection> {
|
|
175
157
|
const { domain } = context.state
|
|
176
|
-
if (!id) return
|
|
158
|
+
if (!id) return inspectSource(source, null, context.t)
|
|
177
159
|
|
|
178
160
|
const figure = await getRepository(Figure).findOne({
|
|
179
161
|
where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id },
|
|
@@ -188,7 +170,7 @@ export class FigureQuery {
|
|
|
188
170
|
.limit(1)
|
|
189
171
|
.getOne()
|
|
190
172
|
|
|
191
|
-
return
|
|
173
|
+
return inspectSource(source, released?.source, context.t, figure.type)
|
|
192
174
|
}
|
|
193
175
|
|
|
194
176
|
@Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
|
|
@@ -205,13 +187,16 @@ export class FigureQuery {
|
|
|
205
187
|
}
|
|
206
188
|
|
|
207
189
|
/**
|
|
208
|
-
* 소비처(operato-board, operato-twin, things-scene)
|
|
190
|
+
* 소비처(operato-board, operato-twin, things-scene)가 발행된 도형을 받아 가는 문.
|
|
191
|
+
*
|
|
192
|
+
* 이름은 보드와의 계약이라 그대로 둔다(바꾸는 일은 따로 한다). 돌려주는 것은 자산이다 —
|
|
193
|
+
* `{ format: 'v3', type, asset }`. 그리는 쪽(`registerFigureV3`)이 커널로 컴파일한다.
|
|
209
194
|
*
|
|
210
195
|
* ## 규칙
|
|
211
196
|
* 1. 소비처는 초안(draft)을 볼 수 없다 — **반드시 발행된(released) 판만 전달**한다.
|
|
212
|
-
* 2. 현재 Figure가 released 상태이면 현재 source를
|
|
197
|
+
* 2. 현재 Figure가 released 상태이면 현재 source를 넘긴다.
|
|
213
198
|
* 3. 현재 Figure가 저작 중(draft)이더라도 이전에 발행된 이력(FigureHistory)이 있으면
|
|
214
|
-
* 가장 최근의 released 판본을
|
|
199
|
+
* 가장 최근의 released 판본을 넘긴다.
|
|
215
200
|
* 4. 한 번도 발행된 적 없는 초안 자산이거나 존재하지 않는 타입인 경우 null을 반환하여
|
|
216
201
|
* 미완성 자산이 씬 화면에 유출되는 것을 엄격히 차단한다.
|
|
217
202
|
* 5. 도메인 상속 시 자식 도메인의 타입이 부모 도메인의 동일 타입을 항상 오버라이드한다.
|
|
@@ -236,7 +221,7 @@ export class FigureQuery {
|
|
|
236
221
|
@Directive('@privilege(category: "figure-published", privilege: "query", domainOwnerGranted: true)')
|
|
237
222
|
@Query(returns => GraphQLJSON, {
|
|
238
223
|
nullable: true,
|
|
239
|
-
description: '
|
|
224
|
+
description: 'A released figure for its consumers (board, twin, things-scene): { format, type, asset } with the figure asset. Only returns released figures.'
|
|
240
225
|
})
|
|
241
226
|
async figureBlueprint(
|
|
242
227
|
@Arg('type') type: string,
|
|
@@ -261,17 +246,10 @@ export class FigureQuery {
|
|
|
261
246
|
const { figure, source: sourceToCompile, version } = found
|
|
262
247
|
|
|
263
248
|
try {
|
|
264
|
-
/*
|
|
265
|
-
A V2 figure hands over its compiled blueprint, as it always did. A V3 asset hands over the asset
|
|
266
|
-
itself under a `format` tag, because the thing that draws it — `registerFigureV3` — takes the asset
|
|
267
|
-
and compiles it with the kernel. A V2 blueprint carries no `format`, so a consumer that has not been
|
|
268
|
-
taught about V3 keeps working and a V3 payload is recognised rather than guessed at (ADR-0087).
|
|
269
|
-
*/
|
|
270
249
|
const text = typeof sourceToCompile === 'string' ? sourceToCompile : JSON.stringify(sourceToCompile)
|
|
271
|
-
|
|
272
|
-
return payload.format === 'v3' ? { format: 'v3', type, asset: payload.asset } : payload.blueprint
|
|
250
|
+
return { format: 'v3', type, asset: consumablePayloadOf(text).asset }
|
|
273
251
|
} catch (err) {
|
|
274
|
-
console.error(`[figureBlueprint] 타입 '${type}' (figure id: ${figure.id})
|
|
252
|
+
console.error(`[figureBlueprint] 타입 '${type}' (figure id: ${figure.id}) 자산 컴파일 실패:`, err)
|
|
275
253
|
|
|
276
254
|
/*
|
|
277
255
|
Null says one thing here: nobody published this type (ADR-0063 결정 2 보탬). A released figure
|
|
@@ -138,7 +138,7 @@ describe('이름을 바꾸기 전에 읽는 사실', () => {
|
|
|
138
138
|
})
|
|
139
139
|
|
|
140
140
|
/*
|
|
141
|
-
* The first save names the figure (
|
|
141
|
+
* The first save names the figure (the modeller once lost the question, 2026-09-25).
|
|
142
142
|
* Through the door itself, on sqlite: created under that name or not at all.
|
|
143
143
|
*/
|
|
144
144
|
describe('처음 저장에 타입 이름을 받는다', () => {
|
|
@@ -13,25 +13,18 @@ import { FigureRenameRefused, renameDraftType } from './figure-rename'
|
|
|
13
13
|
|
|
14
14
|
const draft = (over: Record<string, unknown> = {}) => ({
|
|
15
15
|
type: 'FIGURE_MU6RJH47',
|
|
16
|
-
source:
|
|
16
|
+
source: serializeV3Asset(createV3Asset('새 Figure')),
|
|
17
17
|
...over
|
|
18
18
|
})
|
|
19
19
|
|
|
20
20
|
const never = { version: 0, hasHistory: false, takenByAnother: false }
|
|
21
21
|
|
|
22
22
|
describe('초안의 type 고치기', () => {
|
|
23
|
-
it('★ 행과 정본이 함께 바뀐다 — 한쪽만 바뀌면 발행 때 어느 이름이 나갈지 갈린다', () => {
|
|
24
|
-
const result = renameDraftType(draft(), 'ROBOT_ARM', never)
|
|
25
|
-
|
|
26
|
-
expect(result.type).toBe('ROBOT_ARM')
|
|
27
|
-
expect(JSON.parse(result.source).type).toBe('ROBOT_ARM')
|
|
28
|
-
})
|
|
29
|
-
|
|
30
23
|
/*
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
자산에는 type 칸이 없고 형식은 모르는 칸을 거절한다. 원본에 이름을 써 넣었더니 그 도형을 다시 열 수
|
|
25
|
+
없었다(`parseV3Asset` — "unknown type"). 행만 바뀐다.
|
|
33
26
|
*/
|
|
34
|
-
it('★
|
|
27
|
+
it('★ 행만 바뀌고, 원본은 그대로 다시 열린다', () => {
|
|
35
28
|
const source = serializeV3Asset(createV3Asset('새 Figure'))
|
|
36
29
|
const result = renameDraftType({ type: 'FIGURE_ABCDEF12', source }, 'ROLLER_CONVEYOR', never)
|
|
37
30
|
|
|
@@ -40,13 +33,6 @@ describe('초안의 type 고치기', () => {
|
|
|
40
33
|
expect(() => parseV3Asset(result.source)).not.toThrow()
|
|
41
34
|
})
|
|
42
35
|
|
|
43
|
-
it('정본의 다른 값은 건드리지 않는다', () => {
|
|
44
|
-
const before = JSON.parse(draft().source)
|
|
45
|
-
const after = JSON.parse(renameDraftType(draft(), 'ROBOT_ARM', never).source)
|
|
46
|
-
|
|
47
|
-
expect({ ...after, type: before.type }).toEqual(before)
|
|
48
|
-
})
|
|
49
|
-
|
|
50
36
|
it('앞뒤 공백을 뗀다. 대소문자는 저작자가 적은 대로 둔다', () => {
|
|
51
37
|
expect(renameDraftType(draft(), ' ROBOT_ARM ', never).type).toBe('ROBOT_ARM')
|
|
52
38
|
expect(renameDraftType(draft(), 'Robot_Arm', never).type).toBe('Robot_Arm')
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
* 그래야 규칙을 DB 없이 시험한다. 문(`figure-mutation.ts`)이 그 셋을 읽어 넘긴다.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { V3_ASSET_VERSION } from './figure-format.js'
|
|
23
22
|
|
|
24
23
|
/** 이름을 바꿀 때 알아야 하는 사실. 부르는 쪽이 저장소에서 읽어 온다. */
|
|
25
24
|
export interface RenameFacts {
|
|
@@ -69,17 +68,8 @@ export function renameDraftType(
|
|
|
69
68
|
throw new FigureRenameRefused('type-taken', `type 「${next}」 는 이 도메인에서 이미 쓰고 있다`)
|
|
70
69
|
}
|
|
71
70
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
/*
|
|
75
|
-
A V3 asset carries no `type`: the name a board uses is the row's alone, and the V3 schema refuses a field it
|
|
76
|
-
does not know. Writing it in, as V2 needs, left a V3 figure that could no longer be opened — `parseV3Asset`
|
|
77
|
-
answered "unknown type" (checked 2026-09-24). So only the row changes for V3.
|
|
78
|
-
*/
|
|
79
|
-
if (parsed.version === V3_ASSET_VERSION) return { type: next, source: current.source }
|
|
80
|
-
|
|
81
|
-
parsed.type = next
|
|
82
|
-
return { type: next, source: JSON.stringify(parsed) }
|
|
71
|
+
/* The asset carries no `type`: the name a board uses is the row's alone, so only the row changes. */
|
|
72
|
+
return { type: next, source: current.source }
|
|
83
73
|
}
|
|
84
74
|
|
|
85
75
|
/**
|