@things-factory/figure-service 10.1.28 → 10.1.31
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-mutation.d.ts +7 -0
- package/dist-server/service/figure/figure-mutation.js +30 -0
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-propose.js +1 -1
- package/dist-server/service/figure/figure-propose.js.map +1 -1
- package/dist-server/service/figure/figure-query.js +19 -5
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-rename.d.ts +69 -0
- package/dist-server/service/figure/figure-rename.js +69 -0
- package/dist-server/service/figure/figure-rename.js.map +1 -0
- package/dist-server/service/figure/figure-tools.js +6 -4
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/service/figure/figure-type.d.ts +8 -2
- package/dist-server/service/figure/figure-type.js +12 -3
- package/dist-server/service/figure/figure-type.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/service/figure/figure-blueprint-query.test.ts +269 -1
- package/server/service/figure/figure-e2e-smoke.test.ts +1 -1
- package/server/service/figure/figure-mutation.ts +44 -0
- package/server/service/figure/figure-propose.ts +1 -1
- package/server/service/figure/figure-query.ts +36 -7
- package/server/service/figure/figure-rename-db.test.ts +138 -0
- package/server/service/figure/figure-rename.test.ts +99 -0
- package/server/service/figure/figure-rename.ts +98 -0
- package/server/service/figure/figure-tool-doors.test.ts +98 -9
- package/server/service/figure/figure-tools.test.ts +66 -0
- package/server/service/figure/figure-tools.ts +6 -4
- package/server/service/figure/figure-type.ts +11 -3
- package/translations/en.json +3 -0
- package/translations/ko.json +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-service",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.31",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -23,15 +23,15 @@
|
|
|
23
23
|
"migration:create": "node ../../node_modules/typeorm/cli.js migration:create ./server/migrations/migration"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@hatiolab/figure-model": "^0.1.
|
|
26
|
+
"@hatiolab/figure-model": "^0.1.27",
|
|
27
27
|
"@operato/typeorm-history": "^10.0.0",
|
|
28
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
29
|
-
"@things-factory/auth-base": "^10.1.
|
|
28
|
+
"@things-factory/ai-client-base": "^10.1.31",
|
|
29
|
+
"@things-factory/auth-base": "^10.1.31",
|
|
30
30
|
"@things-factory/env": "^10.1.20",
|
|
31
|
-
"@things-factory/shell": "^10.1.
|
|
31
|
+
"@things-factory/shell": "^10.1.31",
|
|
32
32
|
"graphql-scalars": "^1.22.4",
|
|
33
33
|
"graphql-upload": "^15.0.2",
|
|
34
34
|
"koa-router": "^7.4.0"
|
|
35
35
|
},
|
|
36
|
-
"gitHead": "
|
|
36
|
+
"gitHead": "5213e6f9913b40ea2618a2111d9884b792dbaa01"
|
|
37
37
|
}
|
|
@@ -40,6 +40,12 @@ const sampleSource = JSON.stringify({
|
|
|
40
40
|
]
|
|
41
41
|
})
|
|
42
42
|
|
|
43
|
+
/** The same box under a different type and part name, so a test can tell the sources apart. */
|
|
44
|
+
const boxNamed = (type: string, part: string) => {
|
|
45
|
+
const parsed = JSON.parse(sampleSource)
|
|
46
|
+
return JSON.stringify({ ...parsed, type, parts: [{ ...parsed.parts[0], name: part }] })
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
beforeAll(async () => {
|
|
44
50
|
Figure = loadCompiled('service/figure/figure').Figure
|
|
45
51
|
FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
|
|
@@ -176,13 +182,227 @@ describe('figureBlueprint 질의 — 소비처(board, twin, things-scene)를 위
|
|
|
176
182
|
expect(result.groups[0].members[0].transform.size.x).toBe(100)
|
|
177
183
|
})
|
|
178
184
|
|
|
185
|
+
/*
|
|
186
|
+
The test above proves a release is reached through history, with one history row. It cannot tell
|
|
187
|
+
`version: 'DESC'` from `'ASC'`, nor from no ordering at all — with a single row every one of them
|
|
188
|
+
answers the same. Consumers are promised the LATEST release, so the ordering is the claim, and it
|
|
189
|
+
needs more than one row to be a claim at all. The rows go in out of order because an ordering bug
|
|
190
|
+
that reads insertion order would otherwise pass.
|
|
191
|
+
*/
|
|
192
|
+
it('answers the highest released version when a type has several', async () => {
|
|
193
|
+
const repo = dataSource!.getRepository(Figure)
|
|
194
|
+
const historyRepo = dataSource!.getRepository(FigureHistory)
|
|
195
|
+
|
|
196
|
+
const figure = await repo.save({
|
|
197
|
+
domain: testDomain,
|
|
198
|
+
type: 'MULTI',
|
|
199
|
+
name: 'in authoring',
|
|
200
|
+
state: 'draft',
|
|
201
|
+
source: boxNamed('MULTI', 'unreleased-draft'),
|
|
202
|
+
version: 3
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
for (const version of [1, 3, 2]) {
|
|
206
|
+
await historyRepo.save({
|
|
207
|
+
domain: testDomain,
|
|
208
|
+
originalId: figure.id,
|
|
209
|
+
type: 'MULTI',
|
|
210
|
+
name: `v${version}`,
|
|
211
|
+
state: 'released',
|
|
212
|
+
version,
|
|
213
|
+
source: boxNamed('MULTI', `v${version}`)
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const result = await new FigureQuery().figureBlueprint('MULTI', makeContext())
|
|
218
|
+
|
|
219
|
+
expect(result).not.toBeNull()
|
|
220
|
+
expect(result.groups[0].members[0].name).toBe('v3')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
/*
|
|
224
|
+
Every edit to a released figure writes a history row, and those rows are not all releases. A
|
|
225
|
+
draft row sitting at a higher version than the last release is the ordinary state of a figure
|
|
226
|
+
being worked on, so reading the newest row rather than the newest RELEASED row would hand
|
|
227
|
+
consumers unreleased work — the leak the whole door exists to stop.
|
|
228
|
+
*/
|
|
229
|
+
it('skips a draft history that sits above the last release', async () => {
|
|
230
|
+
const repo = dataSource!.getRepository(Figure)
|
|
231
|
+
const historyRepo = dataSource!.getRepository(FigureHistory)
|
|
232
|
+
|
|
233
|
+
const figure = await repo.save({
|
|
234
|
+
domain: testDomain,
|
|
235
|
+
type: 'WIP',
|
|
236
|
+
name: 'in authoring',
|
|
237
|
+
state: 'draft',
|
|
238
|
+
source: boxNamed('WIP', 'unreleased-draft'),
|
|
239
|
+
version: 5
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
await historyRepo.save({
|
|
243
|
+
domain: testDomain,
|
|
244
|
+
originalId: figure.id,
|
|
245
|
+
type: 'WIP',
|
|
246
|
+
name: 'v2',
|
|
247
|
+
state: 'released',
|
|
248
|
+
version: 2,
|
|
249
|
+
source: boxNamed('WIP', 'released-v2')
|
|
250
|
+
})
|
|
251
|
+
await historyRepo.save({
|
|
252
|
+
domain: testDomain,
|
|
253
|
+
originalId: figure.id,
|
|
254
|
+
type: 'WIP',
|
|
255
|
+
name: 'v5',
|
|
256
|
+
state: 'draft',
|
|
257
|
+
version: 5,
|
|
258
|
+
source: boxNamed('WIP', 'draft-v5')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
const result = await new FigureQuery().figureBlueprint('WIP', makeContext())
|
|
262
|
+
|
|
263
|
+
expect(result).not.toBeNull()
|
|
264
|
+
expect(result.groups[0].members[0].name).toBe('released-v2')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
/*
|
|
268
|
+
History rows are looked up by originalId, which is a figure id and so already unique. That makes
|
|
269
|
+
the domain in the same where-clause look redundant, and redundant-looking filters get deleted.
|
|
270
|
+
It is not redundant: it is the tenant boundary on a door that consumers call, so a test holds it.
|
|
271
|
+
*/
|
|
272
|
+
it('does not reach a history row that belongs to another domain', async () => {
|
|
273
|
+
const domainRepo = dataSource!.getRepository(shell.Domain)
|
|
274
|
+
const other = await domainRepo.save({ name: 'other-domain', subdomain: 'other-domain' })
|
|
275
|
+
|
|
276
|
+
const repo = dataSource!.getRepository(Figure)
|
|
277
|
+
const historyRepo = dataSource!.getRepository(FigureHistory)
|
|
278
|
+
|
|
279
|
+
const figure = await repo.save({
|
|
280
|
+
domain: testDomain,
|
|
281
|
+
type: 'LEAKY',
|
|
282
|
+
name: 'never released here',
|
|
283
|
+
state: 'draft',
|
|
284
|
+
source: boxNamed('LEAKY', 'unreleased-draft'),
|
|
285
|
+
version: 0
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
await historyRepo.save({
|
|
289
|
+
domain: other,
|
|
290
|
+
originalId: figure.id,
|
|
291
|
+
type: 'LEAKY',
|
|
292
|
+
name: 'released elsewhere',
|
|
293
|
+
state: 'released',
|
|
294
|
+
version: 1,
|
|
295
|
+
source: boxNamed('LEAKY', 'other-domain-release')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
const result = await new FigureQuery().figureBlueprint('LEAKY', makeContext())
|
|
299
|
+
|
|
300
|
+
expect(result).toBeNull()
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
/*
|
|
304
|
+
This used to answer null, and that was the defect. Null says one thing on this door — nobody
|
|
305
|
+
published this type — and the board reads it that way: it draws the stand-in of ADR-0063 결정 1 and
|
|
306
|
+
the scene opens. A released figure whose stored source no longer compiles is a different fact. The
|
|
307
|
+
author published it, the catalogue says released, and with null the only trace was a console line
|
|
308
|
+
on the server, so the author saw "my figure does not show on the board" and nothing more. That is
|
|
309
|
+
the silence ADR-0053 refuses (chief architect, ADR-0063 결정 2 보탬).
|
|
310
|
+
|
|
311
|
+
So it refuses with a name, and the name carries what someone needs to find the figure: the type,
|
|
312
|
+
its id, and the edition that does not compile. The sentence is built where the request's language
|
|
313
|
+
is known, which is why the gate throws no sentence of its own (ADR-0054).
|
|
314
|
+
*/
|
|
315
|
+
it('refuses with a named refusal when the stored source cannot be compiled', async () => {
|
|
316
|
+
const repo = dataSource!.getRepository(Figure)
|
|
317
|
+
const saved = await repo.save({
|
|
318
|
+
domain: testDomain,
|
|
319
|
+
type: 'BROKEN',
|
|
320
|
+
name: 'corrupted',
|
|
321
|
+
state: 'released',
|
|
322
|
+
source: '{ this is not json',
|
|
323
|
+
version: 4
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
const query = new FigureQuery()
|
|
327
|
+
const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
328
|
+
try {
|
|
329
|
+
await expect(query.figureBlueprint('BROKEN', makeContext())).rejects.toMatchObject({
|
|
330
|
+
name: 'Refusal',
|
|
331
|
+
code: 'figure-release-does-not-compile',
|
|
332
|
+
params: { type: 'BROKEN', figureId: saved.id, version: 4 }
|
|
333
|
+
})
|
|
334
|
+
} finally {
|
|
335
|
+
spy.mockRestore()
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
/*
|
|
340
|
+
The edition named in that refusal is the one the board was handed, which is not the row's own
|
|
341
|
+
version once the author starts editing again: the row moves on to the next draft while the board
|
|
342
|
+
keeps drawing the last release. Naming the draft would send someone to look at a source that is
|
|
343
|
+
not the one failing.
|
|
344
|
+
*/
|
|
345
|
+
it('the refusal names the released edition, not the draft being edited', async () => {
|
|
346
|
+
const repo = dataSource!.getRepository(Figure)
|
|
347
|
+
const historyRepo = dataSource!.getRepository(FigureHistory)
|
|
348
|
+
|
|
349
|
+
const figure = await repo.save({
|
|
350
|
+
domain: testDomain,
|
|
351
|
+
type: 'BROKEN_HISTORY',
|
|
352
|
+
name: 'in authoring again',
|
|
353
|
+
state: 'draft',
|
|
354
|
+
source: boxNamed('BROKEN_HISTORY', 'healthy-draft'),
|
|
355
|
+
version: 9
|
|
356
|
+
})
|
|
357
|
+
await historyRepo.save({
|
|
358
|
+
domain: testDomain,
|
|
359
|
+
originalId: figure.id,
|
|
360
|
+
type: 'BROKEN_HISTORY',
|
|
361
|
+
name: 'v2',
|
|
362
|
+
state: 'released',
|
|
363
|
+
version: 2,
|
|
364
|
+
source: '{ this is not json'
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
368
|
+
try {
|
|
369
|
+
await expect(new FigureQuery().figureBlueprint('BROKEN_HISTORY', makeContext())).rejects.toMatchObject({
|
|
370
|
+
code: 'figure-release-does-not-compile',
|
|
371
|
+
params: { version: 2 }
|
|
372
|
+
})
|
|
373
|
+
} finally {
|
|
374
|
+
spy.mockRestore()
|
|
375
|
+
}
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
/*
|
|
379
|
+
A refusal only becomes a sentence in the caller's language by passing through `SpeakRefusals`,
|
|
380
|
+
which the class declares and which a direct call like the ones above never runs. Without that
|
|
381
|
+
declaration this refusal reaches the board as Apollo's INTERNAL_SERVER_ERROR carrying the English
|
|
382
|
+
fallback, and `extensions.refusal` — the thing that tells a client the server refused on purpose —
|
|
383
|
+
is absent. The tests above stay green either way, so the declaration is read from the source.
|
|
384
|
+
*/
|
|
385
|
+
it('declares SpeakRefusals on the class, or the refusal arrives as a server error', () => {
|
|
386
|
+
const { readFileSync } = require('fs')
|
|
387
|
+
const { join } = require('path')
|
|
388
|
+
const source = readFileSync(join(__dirname, 'figure-query.ts'), 'utf-8')
|
|
389
|
+
|
|
390
|
+
const at = source.indexOf('export class FigureQuery')
|
|
391
|
+
expect(at).toBeGreaterThan(-1)
|
|
392
|
+
expect(source.slice(0, at)).toContain('@UseMiddleware(SpeakRefusals)')
|
|
393
|
+
})
|
|
394
|
+
|
|
179
395
|
describe('a child domain shadows its parent only with a released version', () => {
|
|
180
396
|
const boxOf = (type: string, part: string) =>
|
|
181
397
|
JSON.stringify({ ...JSON.parse(sampleSource), type, parts: [{ ...JSON.parse(sampleSource).parts[0], name: part }] })
|
|
182
398
|
|
|
399
|
+
let childSeq = 0
|
|
400
|
+
|
|
183
401
|
async function childOf(parent: any) {
|
|
184
402
|
const repo = dataSource!.getRepository(shell.Domain)
|
|
185
|
-
|
|
403
|
+
/* A chain needs a distinct subdomain per level; the column is unique. */
|
|
404
|
+
const name = `child-domain-${++childSeq}`
|
|
405
|
+
const saved = await repo.save({ name, subdomain: name, parent })
|
|
186
406
|
return repo.findOne({ where: { id: saved.id } })
|
|
187
407
|
}
|
|
188
408
|
|
|
@@ -213,6 +433,54 @@ describe('figureBlueprint 질의 — 소비처(board, twin, things-scene)를 위
|
|
|
213
433
|
|
|
214
434
|
expect(result.groups[0].members[0].name).toBe('tenant')
|
|
215
435
|
})
|
|
436
|
+
|
|
437
|
+
/*
|
|
438
|
+
The two cases above give the child a row whose own state decides the answer. This is the case
|
|
439
|
+
between them: the child released once and is editing again, so its row says draft while a
|
|
440
|
+
release of its own exists. The fallback must not read "the child row is a draft" as "the child
|
|
441
|
+
has nothing" — a tenant that starts a second edit would then see the standard figure come back
|
|
442
|
+
in place of the one it published.
|
|
443
|
+
*/
|
|
444
|
+
it('uses the child own release when the child row is a draft again', async () => {
|
|
445
|
+
const child = await childOf(testDomain)
|
|
446
|
+
|
|
447
|
+
const repo = dataSource!.getRepository(Figure)
|
|
448
|
+
const historyRepo = dataSource!.getRepository(FigureHistory)
|
|
449
|
+
|
|
450
|
+
await repo.save({ domain: testDomain, type: 'CONVEYOR', name: '표준', state: 'released', source: boxOf('CONVEYOR', 'standard'), version: 1 })
|
|
451
|
+
const tenant = await repo.save({ domain: child, type: 'CONVEYOR', name: '테넌트', state: 'draft', source: boxOf('CONVEYOR', 'tenant-wip'), version: 1 })
|
|
452
|
+
await historyRepo.save({
|
|
453
|
+
domain: child,
|
|
454
|
+
originalId: tenant.id,
|
|
455
|
+
type: 'CONVEYOR',
|
|
456
|
+
name: '테넌트 v1',
|
|
457
|
+
state: 'released',
|
|
458
|
+
version: 1,
|
|
459
|
+
source: boxOf('CONVEYOR', 'tenant-v1')
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
const result = await new FigureQuery().figureBlueprint('CONVEYOR', contextIn(child))
|
|
463
|
+
|
|
464
|
+
expect(result).not.toBeNull()
|
|
465
|
+
expect(result.groups[0].members[0].name).toBe('tenant-v1')
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
/*
|
|
469
|
+
The lookup consults the caller's domain and its parent, and stops there. A grandchild therefore
|
|
470
|
+
does not inherit, which is the current rule rather than an oversight — pinning it here means
|
|
471
|
+
the day someone needs deeper inheritance they change this test on purpose and take the ruling
|
|
472
|
+
with them, instead of finding the depth by accident in a scene that renders nothing.
|
|
473
|
+
*/
|
|
474
|
+
it('does not climb past the immediate parent', async () => {
|
|
475
|
+
const mid = await childOf(testDomain)
|
|
476
|
+
const leaf = await childOf(mid)
|
|
477
|
+
expect(leaf.parentId).toBe(mid.id)
|
|
478
|
+
|
|
479
|
+
const repo = dataSource!.getRepository(Figure)
|
|
480
|
+
await repo.save({ domain: testDomain, type: 'CONVEYOR', name: '표준', state: 'released', source: boxOf('CONVEYOR', 'standard'), version: 1 })
|
|
481
|
+
|
|
482
|
+
expect(await new FigureQuery().figureBlueprint('CONVEYOR', contextIn(leaf))).toBeNull()
|
|
483
|
+
})
|
|
216
484
|
})
|
|
217
485
|
|
|
218
486
|
/*
|
|
@@ -62,7 +62,7 @@ describeIf('AI 로 만든 figure 가 발행까지 가나', () => {
|
|
|
62
62
|
test('움직이는 것을 만든다 — 문', async () => {
|
|
63
63
|
const { made, gate } = await walk(
|
|
64
64
|
'자동문',
|
|
65
|
-
'a
|
|
65
|
+
'a two-panel automatic door in a frame. add an animation clip that opens both panels together by sliding them apart.',
|
|
66
66
|
'AUTO_DOOR_E2E'
|
|
67
67
|
)
|
|
68
68
|
expect(gate.blocked).toBe(false)
|
|
@@ -8,6 +8,7 @@ import { FigureHistory } from './figure-history.js'
|
|
|
8
8
|
import { FigurePatch, FigureSaveResult, NewFigure } from './figure-type.js'
|
|
9
9
|
import { FigureProposal, ProposeRequest } from './figure-propose-type.js'
|
|
10
10
|
import { inspectSource } from './figure-gate.js'
|
|
11
|
+
import { renameDraftType, renameFacts } from './figure-rename.js'
|
|
11
12
|
import { parseSource } from './figure-source.js'
|
|
12
13
|
import { ProposeFailure, proposeFigure } from './figure-propose.js'
|
|
13
14
|
|
|
@@ -78,6 +79,23 @@ export class FigureMutation {
|
|
|
78
79
|
violations = parsed.violations
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
/*
|
|
83
|
+
**타입은 발행 전에만 바뀐다** — ADR-0063 결정 4.
|
|
84
|
+
|
|
85
|
+
발행된 뒤에는 이미 배치된 도면이 이 이름을 가리키고, 바꾸면 그 도면이 아무 오류 없이
|
|
86
|
+
빈 자리가 된다. 발행된 적 없는 도형은 가리키는 도면이 있을 수 없다 — 도면은 최신
|
|
87
|
+
발행판을 그리므로 그려질 자리가 없다.
|
|
88
|
+
|
|
89
|
+
행과 정본 둘 다 고친다. 정본 안에도 `type` 이 있어서 한쪽만 고치면 저장된 JSON 과
|
|
90
|
+
행이 다른 이름을 말하고, 발행할 때 어느 쪽이 도면에 적히는지가 갈린다.
|
|
91
|
+
*/
|
|
92
|
+
let renamed: string | undefined
|
|
93
|
+
if (patch.type !== undefined && patch.type !== figure.type) {
|
|
94
|
+
const change = await this.renameType(figure, domain, patch.type, source, tx)
|
|
95
|
+
renamed = change.type
|
|
96
|
+
source = change.source
|
|
97
|
+
}
|
|
98
|
+
|
|
81
99
|
/*
|
|
82
100
|
**판 번호는 여기서 손대지 않는다.** 저장은 저작이고, 판은 발행에서 생긴다(`releaseFigure`).
|
|
83
101
|
board 의 `updateBoard` 도 같다 — 저장 경로에는 version 이 없다.
|
|
@@ -93,6 +111,8 @@ export class FigureMutation {
|
|
|
93
111
|
domain,
|
|
94
112
|
...figure,
|
|
95
113
|
...patch,
|
|
114
|
+
/* 앞뒤 공백을 떼고 겹침까지 본 이름. `patch.type` 을 그대로 두면 다듬기 전 값이 들어간다. */
|
|
115
|
+
...(renamed !== undefined ? { type: renamed } : {}),
|
|
96
116
|
source,
|
|
97
117
|
/*
|
|
98
118
|
**고치면 발행이 풀린다.**
|
|
@@ -114,6 +134,30 @@ export class FigureMutation {
|
|
|
114
134
|
return { figure: saved, violations }
|
|
115
135
|
}
|
|
116
136
|
|
|
137
|
+
/**
|
|
138
|
+
* 초안의 타입을 고친다 — 규칙은 `figure-rename.ts`, 여기서는 그 규칙이 볼 **사실만** 읽는다.
|
|
139
|
+
*
|
|
140
|
+
* 겹침은 DB 인덱스(`ix_figure_1`)도 잡지만 먼저 읽어 본다. 인덱스가 던지는 말은 저작자가
|
|
141
|
+
* 읽을 것이 못 되고, 어느 이름이 이미 쓰였는지도 안 알려 준다.
|
|
142
|
+
*/
|
|
143
|
+
private async renameType(
|
|
144
|
+
figure: Figure,
|
|
145
|
+
domain: { id: string },
|
|
146
|
+
wanted: string,
|
|
147
|
+
source: string,
|
|
148
|
+
tx: { getRepository: <T>(target: new () => T) => any }
|
|
149
|
+
): Promise<{ type: string; source: string }> {
|
|
150
|
+
/* 읽어 온 엔티티에는 관계가 안 실려 있다 — 도메인은 문맥에서 온 것을 쓴다. */
|
|
151
|
+
const facts = await renameFacts(
|
|
152
|
+
{ figures: tx.getRepository(Figure), histories: tx.getRepository(FigureHistory) },
|
|
153
|
+
figure,
|
|
154
|
+
domain,
|
|
155
|
+
wanted
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return renameDraftType({ type: figure.type, source }, wanted, facts)
|
|
159
|
+
}
|
|
160
|
+
|
|
117
161
|
/**
|
|
118
162
|
* 발행한다 — **판이 생기는 유일한 자리.**
|
|
119
163
|
*
|
|
@@ -188,7 +188,7 @@ export function rulesPrompt(palette: string[]): string {
|
|
|
188
188
|
'Hard limits — exceeding these makes the figure unusable at scale:',
|
|
189
189
|
` parts by detailLevel: ${partLimits}`,
|
|
190
190
|
` material groups (distinct token+preset+slot combinations) <= ${LIMITS.materialGroups}`,
|
|
191
|
-
`
|
|
191
|
+
` independently transformed parts <= ${LIMITS.independentParts}`,
|
|
192
192
|
` transparent materials <= ${LIMITS.transparentMaterials}`,
|
|
193
193
|
'',
|
|
194
194
|
'Geometry rules:',
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { In } from 'typeorm'
|
|
2
|
-
import { Args, Arg, Ctx, Directive, Query, Resolver } from 'type-graphql'
|
|
2
|
+
import { Args, Arg, Ctx, Directive, Query, Resolver, UseMiddleware } from 'type-graphql'
|
|
3
3
|
import { GraphQLJSON } from 'graphql-scalars'
|
|
4
4
|
import { compile } from '@hatiolab/figure-model'
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
Domain,
|
|
8
|
+
getQueryBuilderFromListParams,
|
|
9
|
+
getRepository,
|
|
10
|
+
ListParam,
|
|
11
|
+
Refusal,
|
|
12
|
+
SpeakRefusals
|
|
13
|
+
} from '@things-factory/shell'
|
|
7
14
|
|
|
8
15
|
import { Figure } from './figure.js'
|
|
9
16
|
import { FigureHistory } from './figure-history.js'
|
|
@@ -11,6 +18,7 @@ import { inspectSource } from './figure-gate.js'
|
|
|
11
18
|
import { FigureInspection, FigureList } from './figure-type.js'
|
|
12
19
|
|
|
13
20
|
@Resolver(Figure)
|
|
21
|
+
@UseMiddleware(SpeakRefusals)
|
|
14
22
|
export class FigureQuery {
|
|
15
23
|
@Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
|
|
16
24
|
@Query(returns => Figure, { nullable: true, description: 'Finds a single figure by its ID.' })
|
|
@@ -194,14 +202,28 @@ export class FigureQuery {
|
|
|
194
202
|
return null
|
|
195
203
|
}
|
|
196
204
|
|
|
197
|
-
const { figure, source: sourceToCompile } = found
|
|
205
|
+
const { figure, source: sourceToCompile, version } = found
|
|
198
206
|
|
|
199
207
|
try {
|
|
200
208
|
const parsed = typeof sourceToCompile === 'string' ? JSON.parse(sourceToCompile) : sourceToCompile
|
|
201
209
|
return compile(parsed)
|
|
202
210
|
} catch (err) {
|
|
203
211
|
console.error(`[figureBlueprint] 타입 '${type}' (figure id: ${figure.id}) 청사진 컴파일 실패:`, err)
|
|
204
|
-
|
|
212
|
+
|
|
213
|
+
/*
|
|
214
|
+
Null says one thing here: nobody published this type (ADR-0063 결정 2 보탬). A released figure
|
|
215
|
+
whose stored source no longer compiles is a different fact, and answering null for it makes the
|
|
216
|
+
board draw a stand-in with the reason nowhere on screen — the author published, the catalogue
|
|
217
|
+
says released, and the only trace is the line above. That is the silence ADR-0053 refuses.
|
|
218
|
+
|
|
219
|
+
The board asks per type inside a Promise.all and collects a rejection into `failed`, so this
|
|
220
|
+
refusal costs that one figure its drawing and the scene still opens.
|
|
221
|
+
*/
|
|
222
|
+
throw new Refusal(
|
|
223
|
+
'figure-release-does-not-compile',
|
|
224
|
+
{ type, figureId: figure.id, version },
|
|
225
|
+
'the released source of {type} does not compile. open it in the modeller and release it again.'
|
|
226
|
+
)
|
|
205
227
|
}
|
|
206
228
|
}
|
|
207
229
|
}
|
|
@@ -212,12 +234,15 @@ export class FigureQuery {
|
|
|
212
234
|
* A released row gives its own source; a draft row gives the source of its latest released history.
|
|
213
235
|
* A figure that was never released gives null, because drafts never reach consumers.
|
|
214
236
|
*/
|
|
215
|
-
async function releasedSourceOf(
|
|
237
|
+
async function releasedSourceOf(
|
|
238
|
+
domainId: string,
|
|
239
|
+
type: string
|
|
240
|
+
): Promise<{ figure: Figure; source: string; version: number } | null> {
|
|
216
241
|
const figure = await getRepository(Figure).findOne({ where: { domain: { id: domainId }, type } })
|
|
217
242
|
if (!figure) return null
|
|
218
243
|
|
|
219
244
|
if (figure.state === 'released' && figure.source) {
|
|
220
|
-
return { figure, source: figure.source }
|
|
245
|
+
return { figure, source: figure.source, version: figure.version }
|
|
221
246
|
}
|
|
222
247
|
|
|
223
248
|
const latestReleased = await getRepository(FigureHistory).findOne({
|
|
@@ -225,5 +250,9 @@ async function releasedSourceOf(domainId: string, type: string): Promise<{ figur
|
|
|
225
250
|
order: { version: 'DESC' }
|
|
226
251
|
})
|
|
227
252
|
|
|
228
|
-
|
|
253
|
+
/* The version travels with the source: a refusal names the edition that does not compile, and the
|
|
254
|
+
row's own version is the draft being edited, not the one the board was handed. */
|
|
255
|
+
return latestReleased?.source
|
|
256
|
+
? { figure, source: latestReleased.source, version: latestReleased.version }
|
|
257
|
+
: null
|
|
229
258
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 이름을 바꾸기 전에 읽는 사실 셋 — **sqlite 를 실제로 들고** 확인한다.
|
|
3
|
+
*
|
|
4
|
+
* 규칙(`renameDraftType`)은 사실을 받아 판단하므로 DB 없이 시험한다. 그런데 그 사실을 읽는
|
|
5
|
+
* 질의가 틀리면 저작자에게는 저장 실패로만 보인다. 실제로 그랬다 — 판본을 `figure: { id }` 로
|
|
6
|
+
* 물었고 `FigureHistory` 는 관계 칸이 아니라 `originalId` 로 가리킨다. TypeORM 이 「그런 속성이
|
|
7
|
+
* 없다」로 던졌고, 순수 시험은 전부 초록이었다.
|
|
8
|
+
*
|
|
9
|
+
* 그래서 여기서는 질의를 **돌려 본다.**
|
|
10
|
+
*/
|
|
11
|
+
import type { DataSource } from 'typeorm'
|
|
12
|
+
|
|
13
|
+
const { DataSource: TypeOrmDataSource } = require('typeorm')
|
|
14
|
+
const shell = require('@things-factory/shell')
|
|
15
|
+
const authBase = require('@things-factory/auth-base')
|
|
16
|
+
|
|
17
|
+
let dataSource: DataSource | null = null
|
|
18
|
+
let Figure: any
|
|
19
|
+
let FigureHistory: any
|
|
20
|
+
let renameFacts: any
|
|
21
|
+
|
|
22
|
+
function loadCompiled(modulePath: string): any {
|
|
23
|
+
const { existsSync } = require('fs')
|
|
24
|
+
const { join } = require('path')
|
|
25
|
+
const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
|
|
26
|
+
|
|
27
|
+
if (!existsSync(resolved)) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`figure-service 산출물이 없다: ${resolved}\n` +
|
|
30
|
+
`이 시험은 tsc 산출물을 든다. 먼저 빌드할 것:\n` +
|
|
31
|
+
` yarn workspace @things-factory/figure-service build:server`
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return require(resolved)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
Figure = loadCompiled('service/figure/figure').Figure
|
|
40
|
+
FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
|
|
41
|
+
renameFacts = loadCompiled('service/figure/figure-rename').renameFacts
|
|
42
|
+
|
|
43
|
+
dataSource = new TypeOrmDataSource({
|
|
44
|
+
type: 'sqlite',
|
|
45
|
+
database: ':memory:',
|
|
46
|
+
synchronize: true,
|
|
47
|
+
dropSchema: true,
|
|
48
|
+
namingStrategy: new shell.NamingStrategy(),
|
|
49
|
+
entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory],
|
|
50
|
+
logging: false
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
await dataSource.initialize()
|
|
54
|
+
shell.addDataSource('default', dataSource)
|
|
55
|
+
shell.addDataSource('tx', dataSource)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
beforeEach(async () => {
|
|
59
|
+
await dataSource!.synchronize(true)
|
|
60
|
+
await seedDomain()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
afterAll(async () => {
|
|
64
|
+
if (!dataSource) return
|
|
65
|
+
shell.removeDataSource('default')
|
|
66
|
+
shell.removeDataSource('tx')
|
|
67
|
+
await dataSource.destroy()
|
|
68
|
+
dataSource = null
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
let DOMAIN: { id: string }
|
|
72
|
+
|
|
73
|
+
/* 도형이 도메인을 가리킨다 — 행이 없으면 외래키가 먼저 막는다. */
|
|
74
|
+
async function seedDomain() {
|
|
75
|
+
const saved = await dataSource!.getRepository(shell.Domain).save({ name: 'rename-probe', subdomain: 'rename-probe' })
|
|
76
|
+
DOMAIN = { id: saved.id }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function seedFigure(over: Record<string, unknown> = {}) {
|
|
80
|
+
return await dataSource!.getRepository(Figure).save({
|
|
81
|
+
type: 'FIGURE_MU6SZ5DL',
|
|
82
|
+
name: '초안',
|
|
83
|
+
source: '{}',
|
|
84
|
+
state: 'draft',
|
|
85
|
+
version: 0,
|
|
86
|
+
domain: DOMAIN,
|
|
87
|
+
...over
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const facts = async (figure: any, wanted: string) =>
|
|
92
|
+
await renameFacts(
|
|
93
|
+
{ figures: dataSource!.getRepository(Figure), histories: dataSource!.getRepository(FigureHistory) },
|
|
94
|
+
figure,
|
|
95
|
+
DOMAIN,
|
|
96
|
+
wanted
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
describe('이름을 바꾸기 전에 읽는 사실', () => {
|
|
100
|
+
it('★ 판본 질의가 실제로 돈다 — 칸 이름이 틀리면 여기서 던진다', async () => {
|
|
101
|
+
const figure = await seedFigure()
|
|
102
|
+
|
|
103
|
+
expect(await facts(figure, 'ROBOT_ARM')).toEqual({ version: 0, hasHistory: false, takenByAnother: false })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('★ 판본이 있으면 그것을 읽어 온다', async () => {
|
|
107
|
+
const figure = await seedFigure()
|
|
108
|
+
await dataSource!.getRepository(FigureHistory).save({
|
|
109
|
+
originalId: figure.id,
|
|
110
|
+
version: 1,
|
|
111
|
+
type: figure.type,
|
|
112
|
+
name: figure.name,
|
|
113
|
+
source: '{}',
|
|
114
|
+
domain: DOMAIN
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
expect((await facts(figure, 'ROBOT_ARM')).hasHistory).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('같은 도메인에서 그 이름을 쓰는 다른 도형을 찾는다', async () => {
|
|
121
|
+
const figure = await seedFigure()
|
|
122
|
+
await seedFigure({ type: 'CONVEYOR', name: '컨베이어' })
|
|
123
|
+
|
|
124
|
+
expect((await facts(figure, 'CONVEYOR')).takenByAnother).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('자기 이름은 겹침이 아니다', async () => {
|
|
128
|
+
const figure = await seedFigure()
|
|
129
|
+
|
|
130
|
+
expect((await facts(figure, figure.type)).takenByAnother).toBe(false)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('빈 이름은 겹침을 묻지 않는다 — 규칙이 먼저 거절한다', async () => {
|
|
134
|
+
const figure = await seedFigure()
|
|
135
|
+
|
|
136
|
+
expect((await facts(figure, ' ')).takenByAnother).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
})
|