@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.
Files changed (60) hide show
  1. package/README.md +5 -8
  2. package/dist-server/service/figure/figure-format.d.ts +49 -31
  3. package/dist-server/service/figure/figure-format.js +47 -90
  4. package/dist-server/service/figure/figure-format.js.map +1 -1
  5. package/dist-server/service/figure/figure-history.js +1 -1
  6. package/dist-server/service/figure/figure-history.js.map +1 -1
  7. package/dist-server/service/figure/figure-mutation.js +5 -5
  8. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  9. package/dist-server/service/figure/figure-propose-v3.d.ts +1 -1
  10. package/dist-server/service/figure/figure-propose-v3.js +1 -1
  11. package/dist-server/service/figure/figure-propose-v3.js.map +1 -1
  12. package/dist-server/service/figure/figure-query.d.ts +6 -11
  13. package/dist-server/service/figure/figure-query.js +12 -40
  14. package/dist-server/service/figure/figure-query.js.map +1 -1
  15. package/dist-server/service/figure/figure-rename.js +2 -11
  16. package/dist-server/service/figure/figure-rename.js.map +1 -1
  17. package/dist-server/service/figure/figure-tools.js +32 -112
  18. package/dist-server/service/figure/figure-tools.js.map +1 -1
  19. package/dist-server/service/figure/figure-type.d.ts +0 -17
  20. package/dist-server/service/figure/figure-type.js +4 -71
  21. package/dist-server/service/figure/figure-type.js.map +1 -1
  22. package/dist-server/service/figure/figure-v3-write.d.ts +1 -1
  23. package/dist-server/service/figure/figure-v3-write.js +2 -3
  24. package/dist-server/service/figure/figure-v3-write.js.map +1 -1
  25. package/dist-server/service/figure/figure.d.ts +5 -10
  26. package/dist-server/service/figure/figure.js +6 -11
  27. package/dist-server/service/figure/figure.js.map +1 -1
  28. package/dist-server/service/index.d.ts +1 -1
  29. package/dist-server/service/index.js +1 -1
  30. package/dist-server/service/index.js.map +1 -1
  31. package/dist-server/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +3 -3
  33. package/server/service/figure/figure-blueprint-query.test.ts +29 -48
  34. package/server/service/figure/figure-format.test.ts +54 -110
  35. package/server/service/figure/figure-format.ts +77 -87
  36. package/server/service/figure/figure-history.ts +1 -1
  37. package/server/service/figure/figure-inspect-db.test.ts +1 -11
  38. package/server/service/figure/figure-mutation.ts +7 -7
  39. package/server/service/figure/figure-propose-v3.test.ts +2 -2
  40. package/server/service/figure/figure-propose-v3.ts +1 -1
  41. package/server/service/figure/figure-query.ts +14 -36
  42. package/server/service/figure/figure-rename-db.test.ts +1 -1
  43. package/server/service/figure/figure-rename.test.ts +4 -18
  44. package/server/service/figure/figure-rename.ts +2 -12
  45. package/server/service/figure/figure-tools.test.ts +17 -80
  46. package/server/service/figure/figure-tools.ts +30 -125
  47. package/server/service/figure/figure-type.ts +3 -52
  48. package/server/service/figure/figure-v3-write.ts +3 -4
  49. package/server/service/figure/figure.ts +7 -12
  50. package/server/service/index.ts +1 -1
  51. package/dist-server/service/figure/figure-gate.d.ts +0 -93
  52. package/dist-server/service/figure/figure-gate.js +0 -302
  53. package/dist-server/service/figure/figure-gate.js.map +0 -1
  54. package/dist-server/service/figure/figure-source.d.ts +0 -45
  55. package/dist-server/service/figure/figure-source.js +0 -59
  56. package/dist-server/service/figure/figure-source.js.map +0 -1
  57. package/server/service/figure/figure-gate.test.ts +0 -155
  58. package/server/service/figure/figure-gate.ts +0 -392
  59. package/server/service/figure/figure-source.test.ts +0 -149
  60. package/server/service/figure/figure-source.ts +0 -94
@@ -1,44 +1,62 @@
1
1
  /*
2
- * Which format a stored source is written in, and one door per job that reads either.
3
- *
4
- * ## Why this exists
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 { compile, compileV3Asset, inspectV3Asset, isPlaceholderType, parseV3Asset, serializeV3Asset, v3ContractChanges, v3CostOf, v3ScoreOf } from '@hatiolab/figure-model'
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 { GateFinding, Inspection } from './figure-gate.js'
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 each format stamps on its own source. */
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
- * Which format this source is written in, read from the `version` the asset carries.
55
+ * That the source is JSON and names the format.
38
56
  *
39
- * @throws {FigureSourceError} when the text is not JSON, or carries no version this server knows.
57
+ * @throws {FigureSourceError} when the text is not JSON, or does not carry `version: 'figure-v3-asset-1'`.
40
58
  */
41
- export function formatOf(sourceJson: string): FigureFormat {
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
- const version = (parsed as { version?: unknown } | null)?.version
51
- if (version === V3_ASSET_VERSION) return 'v3'
52
- if (typeof version === 'number') return 'v2'
53
- throw new FigureSourceError([
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 of either format and computes what the catalogue shows.
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
- * A V3 asset is measured with `v3CostOf`, which charges the same per-shape estimate V2 charges, so the
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 parseAnySource(sourceJson: string): ParsedSource & { format: FigureFormat } {
73
- if (formatOf(sourceJson) === 'v2') return { ...parseSource(sourceJson), format: 'v2' }
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
- * For V3 the judgement is `inspectV3Asset`: every part inside the volume the asset declares, over the whole
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`). The same register as the V2 blockers since 2026-09-19 — a fact, then
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 (V2's sizing-splits, carried to V3 on 2026-09-24).
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 inspectAnySource(
212
+ export function inspectSource(
207
213
  sourceJson?: string | null,
208
214
  releasedJson?: string | null,
209
215
  t?: Translate,
210
- /** The row's `type` — for V3 the only place the name lives, since the asset carries none. */
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) return inspectSource(sourceJson, releasedJson)
214
-
215
- let format: FigureFormat
219
+ if (!sourceJson) {
220
+ return { blocked: true, findings: [{ code: 'no-source', message: '저장된 정본이 없다. 저장하면 발행할 수 있다', blocking: true }] }
221
+ }
216
222
  try {
217
- format = formatOf(sourceJson)
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
- V2 refuses a machine-made name at release (`type-is-placeholder`, from the source's own `type`). A V3 asset has
249
- no `type`, so that check never ran and a new V3 figure was published as `FIGURE_<uuid>` — seen on :3300,
250
- 2026-09-24. Publishing freezes the name (ADR-0063 decision 4) and boards call a figure by it, so the same
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 cannot be compared — it was a V2 source, which is what every converted figure's 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): { format: FigureFormat; blueprint?: unknown; asset?: unknown } {
440
- if (formatOf(sourceJson) === 'v2') return { format: 'v2', blueprint: compile(JSON.parse(sourceJson)) }
429
+ export function consumablePayloadOf(sourceJson: string): { asset: V3Asset } {
430
+ assertFormat(sourceJson)
441
431
  const asset = parseV3Asset(sourceJson)
442
432
  compileV3Asset(asset)
443
- return { format: 'v3', asset }
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 canonical FigureSource JSON at this version.' })
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
- * `inspectAnySource` is tested on its own in figure-format.test.ts, and that is where the temporary-type refusal was
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 { inspectAnySource } from './figure-format.js'
11
+ import { inspectSource } from './figure-format.js'
12
12
  import { renameDraftType, renameFacts } from './figure-rename.js'
13
- import { parseAnySource } from './figure-format.js'
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, as V2 asked (lost in the move to V3, 2026-09-25): created under that
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 } = parseAnySource(figure.source)
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 = parseAnySource(patch.source)
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 = inspectAnySource(figure.source, released?.source, context.t, figure.type)
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 } = parseAnySource(history.source)
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, never V2', () => {
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(/figure-v1|"parts"\s*:\s*\[|anchor/i)
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 V3 source when revising. Never V2. */
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, FieldResolver, Query, Resolver, Root, UseMiddleware } from 'type-graphql'
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, formatOf, inspectAnySource } from './figure-format.js'
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 inspectAnySource(figure.source, released?.source, context.t, figure.type)
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 inspectAnySource(source, null, context.t)
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 inspectAnySource(source, released?.source, context.t, figure.type)
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)를 위한 컴파일된 청사진(FigureBlueprint) 질의.
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: 'Compiled FigureBlueprint for external consumers (board, twin, things-scene). Only returns released figures.'
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
- const payload = consumablePayloadOf(text)
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}) 청사진 컴파일 실패:`, err)
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 (V2 asked for the type on the first save; the V3 modeller lost it, 2026-09-25).
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: JSON.stringify({ version: 2, type: 'FIGURE_MU6RJH47', base: { x: 1, y: 1, z: 1 }, parts: [] }),
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
- V3 자산에는 type 칸이 없고 V3 형식은 모르는 칸을 거절한다. V2 처럼 원본에 이름을 써 넣었더니
32
- 그 도형을 다시 열 수 없었다(`parseV3Asset` — "unknown type"). V3 는 행만 바뀐다.
24
+ 자산에는 type 칸이 없고 형식은 모르는 칸을 거절한다. 원본에 이름을 써 넣었더니 그 도형을 다시 열 수
25
+ 없었다(`parseV3Asset` — "unknown type"). 행만 바뀐다.
33
26
  */
34
- it('★ V3 도형은 행만 바뀌고, 원본은 그대로 다시 열린다', () => {
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
- const parsed = JSON.parse(current.source) as { type?: string; version?: unknown }
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
  /**