@things-factory/figure-service 10.1.178 → 10.1.180

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 (82) hide show
  1. package/dist-server/index.d.ts +2 -0
  2. package/dist-server/index.js +6 -1
  3. package/dist-server/index.js.map +1 -1
  4. package/dist-server/routers/figure-mesh-router.js +4 -1
  5. package/dist-server/routers/figure-mesh-router.js.map +1 -1
  6. package/dist-server/routers/figure-thumbnail-router.js +3 -7
  7. package/dist-server/routers/figure-thumbnail-router.js.map +1 -1
  8. package/dist-server/service/figure/figure-categories.d.ts +10 -0
  9. package/dist-server/service/figure/figure-categories.js +53 -0
  10. package/dist-server/service/figure/figure-categories.js.map +1 -0
  11. package/dist-server/service/figure/figure-format.d.ts +18 -1
  12. package/dist-server/service/figure/figure-format.js +28 -1
  13. package/dist-server/service/figure/figure-format.js.map +1 -1
  14. package/dist-server/service/figure/figure-mutation.d.ts +8 -2
  15. package/dist-server/service/figure/figure-mutation.js +83 -34
  16. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  17. package/dist-server/service/figure/figure-namespace-entity.d.ts +24 -0
  18. package/dist-server/service/figure/figure-namespace-entity.js +87 -0
  19. package/dist-server/service/figure/figure-namespace-entity.js.map +1 -0
  20. package/dist-server/service/figure/figure-namespace-resolver.d.ts +5 -8
  21. package/dist-server/service/figure/figure-namespace-resolver.js +52 -36
  22. package/dist-server/service/figure/figure-namespace-resolver.js.map +1 -1
  23. package/dist-server/service/figure/figure-namespace.d.ts +28 -28
  24. package/dist-server/service/figure/figure-namespace.js +105 -63
  25. package/dist-server/service/figure/figure-namespace.js.map +1 -1
  26. package/dist-server/service/figure/figure-package-export.js +4 -2
  27. package/dist-server/service/figure/figure-package-export.js.map +1 -1
  28. package/dist-server/service/figure/figure-public.d.ts +19 -0
  29. package/dist-server/service/figure/figure-public.js +54 -0
  30. package/dist-server/service/figure/figure-public.js.map +1 -0
  31. package/dist-server/service/figure/figure-query.d.ts +7 -0
  32. package/dist-server/service/figure/figure-query.js +59 -5
  33. package/dist-server/service/figure/figure-query.js.map +1 -1
  34. package/dist-server/service/figure/figure-rename.d.ts +25 -2
  35. package/dist-server/service/figure/figure-rename.js +21 -2
  36. package/dist-server/service/figure/figure-rename.js.map +1 -1
  37. package/dist-server/service/figure/figure-tools.js +1 -1
  38. package/dist-server/service/figure/figure-tools.js.map +1 -1
  39. package/dist-server/service/figure/figure-type.d.ts +1 -0
  40. package/dist-server/service/figure/figure-type.js +4 -0
  41. package/dist-server/service/figure/figure-type.js.map +1 -1
  42. package/dist-server/service/figure/figure.d.ts +1 -0
  43. package/dist-server/service/figure/figure.js +5 -0
  44. package/dist-server/service/figure/figure.js.map +1 -1
  45. package/dist-server/service/figure/index.d.ts +2 -1
  46. package/dist-server/service/figure/index.js +2 -1
  47. package/dist-server/service/figure/index.js.map +1 -1
  48. package/dist-server/service/index.d.ts +1 -1
  49. package/dist-server/tsconfig.tsbuildinfo +1 -1
  50. package/package.json +7 -7
  51. package/server/index.ts +2 -0
  52. package/server/routers/figure-mesh-router.ts +5 -1
  53. package/server/routers/figure-thumbnail-router.ts +3 -5
  54. package/server/service/figure/figure-categories.ts +50 -0
  55. package/server/service/figure/figure-category-db.test.ts +127 -0
  56. package/server/service/figure/figure-deprecation-db.test.ts +110 -6
  57. package/server/service/figure/figure-format.ts +43 -1
  58. package/server/service/figure/figure-mesh-db.test.ts +4 -2
  59. package/server/service/figure/figure-mutation.ts +79 -37
  60. package/server/service/figure/figure-namespace-db.test.ts +140 -74
  61. package/server/service/figure/figure-namespace-entity.ts +67 -0
  62. package/server/service/figure/figure-namespace-resolver.ts +39 -26
  63. package/server/service/figure/figure-namespace.ts +122 -81
  64. package/server/service/figure/figure-package-export.ts +6 -3
  65. package/server/service/figure/figure-proposal-db.test.ts +5 -3
  66. package/server/service/figure/figure-public-db.test.ts +180 -0
  67. package/server/service/figure/figure-public.ts +60 -0
  68. package/server/service/figure/figure-query.ts +48 -5
  69. package/server/service/figure/figure-release-check.test.ts +63 -0
  70. package/server/service/figure/figure-release-record-db.test.ts +7 -5
  71. package/server/service/figure/figure-rename-db.test.ts +50 -4
  72. package/server/service/figure/figure-rename.ts +37 -3
  73. package/server/service/figure/figure-tools.ts +1 -1
  74. package/server/service/figure/figure-type.ts +3 -0
  75. package/server/service/figure/figure.ts +8 -0
  76. package/server/service/figure/index.ts +2 -1
  77. package/translations/en.json +12 -3
  78. package/translations/es.json +12 -3
  79. package/translations/ja.json +12 -3
  80. package/translations/ko.json +12 -3
  81. package/translations/ms.json +12 -3
  82. package/translations/zh.json +12 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.178",
3
+ "version": "10.1.180",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -27,16 +27,16 @@
27
27
  "dependencies": {
28
28
  "@hatiolab/figure-model": "^0.7.2",
29
29
  "@operato/typeorm-history": "^10.0.0",
30
- "@things-factory/ai-client-base": "^10.1.178",
31
- "@things-factory/attachment-base": "^10.1.178",
32
- "@things-factory/auth-base": "^10.1.178",
30
+ "@things-factory/ai-client-base": "^10.1.179",
31
+ "@things-factory/attachment-base": "^10.1.179",
32
+ "@things-factory/auth-base": "^10.1.179",
33
33
  "@things-factory/env": "^10.1.149",
34
- "@things-factory/setting-base": "^10.1.178",
35
- "@things-factory/shell": "^10.1.178",
34
+ "@things-factory/setting-base": "^10.1.179",
35
+ "@things-factory/shell": "^10.1.179",
36
36
  "graphql-scalars": "^1.22.4",
37
37
  "graphql-upload": "^15.0.2",
38
38
  "jszip": "^3.10.1",
39
39
  "koa-router": "^7.4.0"
40
40
  },
41
- "gitHead": "d57448d3f3045f72a9f24b877a3e306eb34cbaf1"
41
+ "gitHead": "2549e50c0790c05a75781d133f5d975cff3b02f9"
42
42
  }
package/server/index.ts CHANGED
@@ -42,3 +42,5 @@ process.on('bootstrap-module-start' as any, () => {
42
42
  export * from './service/index.js'
43
43
  export * from './migrations/index.js'
44
44
  export { registerFigureToolCategories } from './service/figure/figure-tools.js'
45
+ export { registerFigureCategories, figureCategories } from './service/figure/figure-categories.js'
46
+ export { registerFigureReleaseCheck, type FigureReleaseCheck, type FigureReleaseCheckInput, type GateFinding } from './service/figure/figure-format.js'
@@ -6,6 +6,7 @@ import { getRepository } from '@things-factory/shell'
6
6
  import { meshAttachmentOf } from '../service/figure/figure-mesh-store.js'
7
7
  import { GLB_MIMETYPE } from '../service/figure/figure-glb.js'
8
8
  import { thumbnailFiles, type ThumbnailFiles } from '../service/figure/figure-thumbnail-store.js'
9
+ import { publicDomainsOfFile } from '../service/figure/figure-public.js'
9
10
 
10
11
  /**
11
12
  * A mesh part's file, by its hash (ADR-0129 decision 8): what the modeller and a board draw a mesh part from, on the
@@ -38,7 +39,10 @@ export async function sendFigureMesh(
38
39
  context.status = 404
39
40
  return
40
41
  }
41
- const kept = await meshAttachmentOf({ getRepository }, [domain.id, domain.parentId].filter(Boolean) as string[], hash)
42
+ /* Its own or its parent's, or kept by a domain whose public figure's releases draw from it (G2). */
43
+ const kept =
44
+ (await meshAttachmentOf({ getRepository }, [domain.id, domain.parentId].filter(Boolean) as string[], hash)) ??
45
+ (await meshAttachmentOf({ getRepository }, await publicDomainsOfFile(hash, domain), hash))
42
46
  if (!kept) {
43
47
  context.status = 404
44
48
  return
@@ -9,6 +9,7 @@ import { readFileSync } from 'fs'
9
9
  import { Figure } from '../service/figure/figure.js'
10
10
  import { thumbnailAttachmentOf, thumbnailFiles, type ThumbnailFiles } from '../service/figure/figure-thumbnail-store.js'
11
11
  import { recipeTemplates } from '../service/figure-recipe/figure-recipe-templates.js'
12
+ import { readableFigure } from '../service/figure/figure-public.js'
12
13
 
13
14
  /**
14
15
  * 썸네일을 **그림으로** 내보내는 길.
@@ -80,11 +81,8 @@ export async function sendFigureThumbnail(
80
81
  읽기는 부모 도메인까지 본다 — `figure` 질의와 같은 범위다. 물려받은 형상을 목록에는 보여 주고
81
82
  그림만 404 를 내면, 화면은 「그림이 아직 없다」고 거짓말하게 된다.
82
83
  */
83
- const found = await getRepository(Figure).findOne({
84
- where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id },
85
- relations: { domain: true },
86
- select: { id: true, thumbnailHash: true, thumbnailUpdatedAt: true, domain: { id: true } }
87
- })
84
+ /* Another domain's public figure is read too (G2): its card is what a reader chooses it by. */
85
+ const found = await readableFigure(domain, id, { id: true, thumbnailHash: true, thumbnailUpdatedAt: true, public: true, domain: { id: true } })
88
86
  if (!found?.thumbnailHash) {
89
87
  context.status = 404
90
88
  return
@@ -0,0 +1,50 @@
1
+ import { Refusal } from '@things-factory/shell'
2
+
3
+ /*
4
+ * The categories an app allows (LV figure-server design G5). Without a registration a category is free text, as
5
+ * operato-figure has it. An app that registers its vocabulary -- the LV figure-server: EQUIPMENT, CARRIER, UTILITY,
6
+ * COMPONENT -- gets three rules: a category outside it is refused, a category once set is not changed, and a figure
7
+ * is not released without one. The vocabulary is the app's; this module does not know what the words mean.
8
+ */
9
+
10
+ let registered: readonly string[] | null = null
11
+
12
+ /** Registers the categories this server allows. Called once at the top of the app's server module. */
13
+ export function registerFigureCategories(codes: readonly string[]): void {
14
+ const cleaned = codes.map(code => (typeof code === 'string' ? code.trim() : ''))
15
+ if (!cleaned.length || cleaned.some(code => !code) || new Set(cleaned).size !== cleaned.length) {
16
+ throw new Error(`registerFigureCategories needs distinct non-empty codes, not ${JSON.stringify(codes)}.`)
17
+ }
18
+ registered = Object.freeze([...cleaned])
19
+ }
20
+
21
+ /** The categories registered, or null when a category is free text. */
22
+ export function figureCategories(): readonly string[] | null {
23
+ return registered
24
+ }
25
+
26
+ /** For tests: back to free text. */
27
+ export function clearFigureCategories(): void {
28
+ registered = null
29
+ }
30
+
31
+ /** A category being set on a figure: refused outside the vocabulary, and when the figure already has another. */
32
+ export function assertFigureCategory(next: string | null | undefined, current?: string | null): void {
33
+ if (!registered || next === undefined) return
34
+ const wanted = (next ?? '').trim()
35
+ const had = (current ?? '').trim()
36
+ if (had && wanted !== had) {
37
+ throw new Refusal('figure-category-fixed', { category: had }, 'the category {category} is set once and does not change.')
38
+ }
39
+ if (wanted && !registered.includes(wanted)) {
40
+ throw new Refusal('figure-category-unknown', { category: wanted, allowed: registered.join(', ') }, '{category} is not a category here. use one of {allowed}.')
41
+ }
42
+ }
43
+
44
+ /** A figure going out: with a vocabulary registered it carries one of its categories. */
45
+ export function assertReleasableCategory(category: string | null | undefined, type: string): void {
46
+ if (!registered) return
47
+ if (!(category ?? '').trim()) {
48
+ throw new Refusal('figure-category-missing', { type, allowed: registered.join(', ') }, '{type} has no category. choose one of {allowed} before releasing.')
49
+ }
50
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * An app's category vocabulary (LV figure-server design G5), on sqlite: without one a category is free text; with one,
3
+ * a category outside it is refused, a category once set does not change, and a figure goes out only with one.
4
+ */
5
+ import type { DataSource } from 'typeorm'
6
+
7
+ const { DataSource: TypeOrmDataSource } = require('typeorm')
8
+ const shell = require('@things-factory/shell')
9
+ const authBase = require('@things-factory/auth-base')
10
+ const settingBase = require('@things-factory/setting-base')
11
+ const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy } = require('@hatiolab/figure-model')
12
+
13
+ let dataSource: DataSource | null = null
14
+ let Figure: any
15
+ let FigureHistory: any
16
+ let FigureNamespace: any
17
+ let FigureMutation: any
18
+ let FigureQuery: any
19
+ let categories: any
20
+
21
+ function loadCompiled(modulePath: string): any {
22
+ const { existsSync } = require('fs')
23
+ const { join } = require('path')
24
+ const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
25
+ if (!existsSync(resolved))
26
+ throw new Error(`figure-service 산출물이 없다: ${resolved}\n먼저 빌드할 것:\n yarn workspace @things-factory/figure-service build:server`)
27
+ return require(resolved)
28
+ }
29
+
30
+ beforeAll(async () => {
31
+ Figure = loadCompiled('service/figure/figure').Figure
32
+ FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
33
+ FigureNamespace = loadCompiled('service/figure/figure-namespace-entity').FigureNamespace
34
+ const Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
35
+ ;({ FigureMutation } = loadCompiled('service/figure/figure-mutation'))
36
+ ;({ FigureQuery } = loadCompiled('service/figure/figure-query'))
37
+ categories = loadCompiled('service/figure/figure-categories')
38
+ dataSource = new TypeOrmDataSource({
39
+ type: 'sqlite',
40
+ database: ':memory:',
41
+ synchronize: true,
42
+ dropSchema: true,
43
+ namingStrategy: new shell.NamingStrategy(),
44
+ entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory, FigureNamespace],
45
+ subscribers: [Subscriber],
46
+ logging: false
47
+ })
48
+ await dataSource.initialize()
49
+ shell.addDataSource('default', dataSource)
50
+ shell.addDataSource('tx', dataSource)
51
+ })
52
+
53
+ afterAll(async () => {
54
+ categories?.clearFigureCategories()
55
+ await dataSource?.destroy()
56
+ dataSource = null
57
+ })
58
+ afterEach(() => categories.clearFigureCategories())
59
+
60
+ const releasable = () => {
61
+ let a = addFigureBox(createFigureAsset('Mixer'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
62
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } })
63
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a, { over: 'range' }), placement: 'floor' }))
64
+ }
65
+ let seq = 0
66
+ async function context() {
67
+ const name = `cat-${++seq}`
68
+ const domain = await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name })
69
+ return { state: { domain, user: undefined, tx: dataSource!.manager } }
70
+ }
71
+ const draft = (ctx: any, type: string) =>
72
+ new FigureMutation().saveFigure(releasable(), ctx, undefined, undefined, require('crypto').randomUUID(), undefined, type)
73
+ const LV = ['EQUIPMENT', 'CARRIER', 'UTILITY', 'COMPONENT']
74
+
75
+ describe('categories', () => {
76
+ it('are free text without a vocabulary: any, changed at will, not needed to release', async () => {
77
+ const ctx = await context()
78
+ expect(await new FigureQuery().figureCategories()).toBeNull()
79
+ const figure = await draft(ctx, 'free')
80
+ await new FigureMutation().updateFigure(figure.id, { category: 'conveyors' }, ctx)
81
+ await new FigureMutation().updateFigure(figure.id, { category: 'lines' }, ctx)
82
+ await expect(new FigureMutation().releaseFigure(figure.id, 'one', ctx)).resolves.toMatchObject({ version: 1 })
83
+ })
84
+
85
+ it('with a vocabulary: one of it, set once, and needed to release', async () => {
86
+ categories.registerFigureCategories(LV)
87
+ expect(await new FigureQuery().figureCategories()).toEqual(LV)
88
+ const ctx = await context()
89
+ const figure = await draft(ctx, 'mixer')
90
+ await expect(new FigureMutation().releaseFigure(figure.id, 'one', ctx)).rejects.toMatchObject({ code: 'figure-category-missing' })
91
+ await expect(new FigureMutation().updateFigure(figure.id, { category: 'conveyors' }, ctx)).rejects.toMatchObject({ code: 'figure-category-unknown' })
92
+ await new FigureMutation().updateFigure(figure.id, { category: 'EQUIPMENT' }, ctx)
93
+ await expect(new FigureMutation().updateFigure(figure.id, { category: 'CARRIER' }, ctx)).rejects.toMatchObject({ code: 'figure-category-fixed' })
94
+ await expect(new FigureMutation().updateFigure(figure.id, { category: 'EQUIPMENT', description: 'same category again' }, ctx)).resolves.toBeTruthy()
95
+ await expect(new FigureMutation().releaseFigure(figure.id, 'one', ctx)).resolves.toMatchObject({ version: 1 })
96
+ })
97
+
98
+ it('with a vocabulary, creating directly refuses a category outside it', async () => {
99
+ categories.registerFigureCategories(LV)
100
+ const ctx = await context()
101
+ await expect(new FigureMutation().createFigure({ type: 'odd', name: 'o', source: releasable(), category: 'misc' }, ctx)).rejects.toMatchObject({ code: 'figure-category-unknown' })
102
+ const made = await new FigureMutation().createFigure({ type: 'carrier-roll', name: 'r', source: releasable(), category: 'CARRIER' }, ctx)
103
+ expect(made.figure.category).toBe('CARRIER')
104
+ })
105
+
106
+ it('a vocabulary is distinct non-empty codes', () => {
107
+ expect(() => categories.registerFigureCategories([])).toThrow()
108
+ expect(() => categories.registerFigureCategories(['A', 'A'])).toThrow()
109
+ expect(() => categories.registerFigureCategories(['A', ' '])).toThrow()
110
+ })
111
+ })
112
+
113
+ describe('an app release check, on the way out', () => {
114
+ it('a blocking finding refuses the release; taken off, it goes out', async () => {
115
+ const { registerFigureReleaseCheck } = loadCompiled('service/figure/figure-format')
116
+ const take = registerFigureReleaseCheck(() => [{ code: 'app-origin-off', message: 'the origin is off the middle', blocking: true }])
117
+ try {
118
+ const ctx = await context()
119
+ const figure = await draft(ctx, 'checked')
120
+ await expect(new FigureMutation().releaseFigure(figure.id, 'one', ctx)).rejects.toThrow(/origin is off the middle/)
121
+ take()
122
+ await expect(new FigureMutation().releaseFigure(figure.id, 'one', ctx)).resolves.toMatchObject({ version: 1 })
123
+ } finally {
124
+ take()
125
+ }
126
+ })
127
+ })
@@ -10,6 +10,7 @@ const authBase = require('@things-factory/auth-base')
10
10
  const settingBase = require('@things-factory/setting-base')
11
11
  const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy } = require('@hatiolab/figure-model')
12
12
 
13
+ let FigureNamespace: any
13
14
  let dataSource: DataSource | null = null
14
15
  let Figure: any
15
16
  let FigureHistory: any
@@ -28,6 +29,7 @@ function loadCompiled(modulePath: string): any {
28
29
 
29
30
  beforeAll(async () => {
30
31
  Figure = loadCompiled('service/figure/figure').Figure
32
+ FigureNamespace = loadCompiled('service/figure/figure-namespace-entity').FigureNamespace
31
33
  FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
32
34
  Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
33
35
  ;({ FigureMutation, assertNames } = loadCompiled('service/figure/figure-mutation'))
@@ -37,7 +39,7 @@ beforeAll(async () => {
37
39
  synchronize: true,
38
40
  dropSchema: true,
39
41
  namingStrategy: new shell.NamingStrategy(),
40
- entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory],
42
+ entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory, FigureNamespace],
41
43
  subscribers: [Subscriber],
42
44
  logging: false
43
45
  })
@@ -49,9 +51,9 @@ afterAll(async () => {
49
51
  dataSource = null
50
52
  })
51
53
 
52
- /** The domain setting every figure here is created under (ADR-0114). */
54
+ /** The domain's own default namespace, its subdomain, that every figure here is created and released under (G1). */
53
55
  async function namespaced(domain: any) {
54
- await dataSource!.getRepository(settingBase.Setting).save({ domain, name: 'figure-namespace', category: 'figure', value: 'system' })
56
+ await dataSource!.getRepository(FigureNamespace).save({ domain, name: domain.subdomain, state: 'active', isDefault: true })
55
57
  return domain
56
58
  }
57
59
 
@@ -66,7 +68,7 @@ async function released(type: string) {
66
68
  const name = `retire-${++seq}`
67
69
  const domain = await namespaced(await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name }))
68
70
  const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
69
- const figure = await dataSource!.getRepository(Figure).save({ namespace: 'system', type, name: 'mixer', source: releasable(), state: 'draft', version: 0, domain })
71
+ const figure = await dataSource!.getRepository(Figure).save({ namespace: domain.subdomain, type, name: 'mixer', source: releasable(), state: 'draft', version: 0, domain })
70
72
  await new FigureMutation().releaseFigure(figure.id, 'one', context)
71
73
  return { figure, context, domain }
72
74
  }
@@ -75,7 +77,7 @@ const historyCount = (id: string) => dataSource!.getRepository(FigureHistory).co
75
77
  describe('retiring a figure', () => {
76
78
  it('stays released, writes no history row, and names what to use instead', async () => {
77
79
  const { figure, context, domain } = await released('OLD_MIXER')
78
- await dataSource!.getRepository(Figure).save({ namespace: 'system', type: 'NEW_MIXER', name: 'new', source: releasable(), state: 'draft', version: 0, domain })
80
+ await dataSource!.getRepository(Figure).save({ namespace: domain.subdomain, type: 'NEW_MIXER', name: 'new', source: releasable(), state: 'draft', version: 0, domain })
79
81
  const retired = await new FigureMutation().setFigureDeprecation(figure.id, true, context, 'NEW_MIXER')
80
82
  expect([retired.deprecated, retired.supersededBy, retired.state, retired.version]).toEqual([true, 'NEW_MIXER', 'released', 1])
81
83
  expect(await historyCount(figure.id)).toBe(1)
@@ -104,7 +106,7 @@ describe('names by language', () => {
104
106
  const name = `names-${++seq}`
105
107
  const domain = await namespaced(await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name }))
106
108
  const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
107
- const figure = await dataSource!.getRepository(Figure).save({ namespace: 'system', type: 'NAMED', name: 'mixer', source: releasable(), state: 'draft', version: 0, domain })
109
+ const figure = await dataSource!.getRepository(Figure).save({ namespace: domain.subdomain, type: 'NAMED', name: 'mixer', source: releasable(), state: 'draft', version: 0, domain })
108
110
  await new FigureMutation().updateFigure(figure.id, { names: { en: 'Mixer', ko: '믹서' } }, context)
109
111
  await new FigureMutation().releaseFigure(figure.id, 'one', context)
110
112
  const [row] = await dataSource!.getRepository(FigureHistory).find({ where: { originalId: figure.id } })
@@ -112,3 +114,105 @@ describe('names by language', () => {
112
114
  await expect(new FigureMutation().updateFigure(figure.id, { names: { English: 'x' } }, context)).rejects.toThrow(/English/)
113
115
  })
114
116
  })
117
+
118
+ describe('deleting a figure', () => {
119
+ const draftOnly = async (type: string) => {
120
+ const name = `delete-${++seq}`
121
+ const domain = await namespaced(await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name }))
122
+ const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
123
+ const figure = await dataSource!.getRepository(Figure).save({ namespace: domain.subdomain, type, name: 'mixer', source: releasable(), state: 'draft', version: 0, domain })
124
+ return { figure, context }
125
+ }
126
+ const stillThere = (id: string) => dataSource!.getRepository(Figure).findOne({ where: { id } })
127
+
128
+ it('deletes a figure that has never been released', async () => {
129
+ const { figure, context } = await draftOnly('NEVER_RELEASED')
130
+ expect(await new FigureMutation().deleteFigure(figure.id, context)).toBe(true)
131
+ expect(await stillThere(figure.id)).toBeNull()
132
+ const row = await dataSource!.getRepository(Figure).findOne({ where: { id: figure.id }, withDeleted: true })
133
+ expect(row?.deletedAt).toBeTruthy()
134
+ })
135
+
136
+ it('refuses a released figure and leaves it and its release as they were', async () => {
137
+ const { figure, context } = await released('SHIPPED_MIXER')
138
+ await expect(new FigureMutation().deleteFigure(figure.id, context)).rejects.toMatchObject({
139
+ code: 'figure-released-not-deletable',
140
+ params: { type: 'SHIPPED_MIXER' }
141
+ })
142
+ expect((await stillThere(figure.id))?.version).toBe(1)
143
+ expect(await historyCount(figure.id)).toBe(1)
144
+ })
145
+
146
+ it('refuses a released figure whose draft was edited again', async () => {
147
+ const { figure, context } = await released('EDITED_MIXER')
148
+ await new FigureMutation().updateFigure(figure.id, { description: 'changed after release' }, context)
149
+ expect((await stillThere(figure.id))?.state).toBe('draft')
150
+ await expect(new FigureMutation().deleteFigure(figure.id, context)).rejects.toMatchObject({ code: 'figure-released-not-deletable' })
151
+ expect(await stillThere(figure.id)).not.toBeNull()
152
+ })
153
+
154
+ it('refuses by the release rows even when the figure says version 0', async () => {
155
+ const { figure, context } = await released('ROWS_SAY_RELEASED')
156
+ await dataSource!.getRepository(Figure).createQueryBuilder().update(Figure).set({ version: 0 }).where('id = :id', { id: figure.id }).callListeners(false).execute()
157
+ await expect(new FigureMutation().deleteFigure(figure.id, context)).rejects.toMatchObject({ code: 'figure-released-not-deletable' })
158
+ })
159
+ })
160
+
161
+ /*
162
+ * A domain reads its parent's figures and writes only its own (LV figure-server design G3): updating, releasing and
163
+ * retiring a parent's figure from a child domain are refused as not found, as saving its draft always was.
164
+ */
165
+ describe('writes stay in the domain that owns the figure', () => {
166
+ async function parentAndChild() {
167
+ const parent = await namespaced(await dataSource!.getRepository(shell.Domain).save({ name: `parent-${++seq}`, subdomain: `parent-${seq}` }))
168
+ const saved = await dataSource!.getRepository(shell.Domain).save({ name: `child-${seq}`, subdomain: `child-${seq}`, parent })
169
+ const child = await namespaced(await dataSource!.getRepository(shell.Domain).findOneOrFail({ where: { id: saved.id } }))
170
+ expect(child.parentId).toBe(parent.id)
171
+ const figure = await dataSource!.getRepository(Figure).save({ namespace: parent.subdomain, type: `parents-${seq}`, name: 'mixer', source: releasable(), state: 'draft', version: 0, domain: parent })
172
+ const as = (domain: any) => ({ state: { domain, user: undefined, tx: dataSource!.manager } })
173
+ return { figure, parentContext: as(parent), childContext: as(child) }
174
+ }
175
+
176
+ it('a child domain cannot update, release or retire its parent figure', async () => {
177
+ const { figure, childContext } = await parentAndChild()
178
+ await expect(new FigureMutation().updateFigure(figure.id, { description: 'from the child' }, childContext)).rejects.toThrow(/찾을 수 없다/)
179
+ await expect(new FigureMutation().releaseFigure(figure.id, 'from the child', childContext)).rejects.toThrow(/찾을 수 없다/)
180
+ await expect(new FigureMutation().setFigureDeprecation(figure.id, true, childContext)).rejects.toThrow(/찾을 수 없다/)
181
+ const row = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
182
+ expect([row.description ?? null, row.version, row.deprecated]).toEqual([null, 0, false])
183
+ expect(await historyCount(figure.id)).toBe(0)
184
+ })
185
+
186
+ it('the owning domain still does all three', async () => {
187
+ const { figure, parentContext } = await parentAndChild()
188
+ await new FigureMutation().updateFigure(figure.id, { description: 'from the owner' }, parentContext)
189
+ await new FigureMutation().releaseFigure(figure.id, 'one', parentContext)
190
+ const retired = await new FigureMutation().setFigureDeprecation(figure.id, true, parentContext)
191
+ expect([retired.description, retired.version, retired.deprecated]).toEqual(['from the owner', 1, true])
192
+ })
193
+
194
+ it('a child figure may still name a parent figure to use instead', async () => {
195
+ const { figure: parentFigure, childContext } = await parentAndChild()
196
+ const child = childContext.state.domain
197
+ const own = await dataSource!.getRepository(Figure).save({ namespace: child.subdomain, type: `childs-${seq}`, name: 'own', source: releasable(), state: 'draft', version: 0, domain: child })
198
+ await new FigureMutation().releaseFigure(own.id, 'one', childContext)
199
+ const retired = await new FigureMutation().setFigureDeprecation(own.id, true, childContext, parentFigure.type)
200
+ expect(retired.supersededBy).toBe(parentFigure.type)
201
+ })
202
+ })
203
+
204
+ /* Releasing and retiring are their own gate (LV figure-server design G6); editing a draft stays under figure:mutation. */
205
+ describe('who may release', () => {
206
+ const declarationOf = (method: string) => {
207
+ const { readFileSync } = require('fs')
208
+ const { join } = require('path')
209
+ const source = readFileSync(join(__dirname, 'figure-mutation.ts'), 'utf-8')
210
+ const preceding = source.slice(0, source.indexOf(`async ${method}(`))
211
+ return preceding.slice(preceding.lastIndexOf("@Directive('@privilege("))
212
+ }
213
+
214
+ it('releasing and retiring ask figure-release, editing asks figure', () => {
215
+ for (const method of ['releaseFigure', 'setFigureDeprecation']) expect(declarationOf(method)).toContain('category: "figure-release", privilege: "mutation"')
216
+ for (const method of ['saveFigure', 'createFigure', 'updateFigure', 'deleteFigure', 'revertFigureVersion']) expect(declarationOf(method)).toContain('category: "figure", privilege: "mutation"')
217
+ })
218
+ })
@@ -51,6 +51,45 @@ export interface Inspection {
51
51
  blocked: boolean
52
52
  }
53
53
 
54
+ /** What an app's release check is shown: the figure's asset, its row's type and category, and the reader's words. */
55
+ export interface FigureReleaseCheckInput {
56
+ asset: FigureAsset
57
+ type?: string
58
+ category?: string | null
59
+ t?: Translate
60
+ }
61
+
62
+ /**
63
+ * A check an app adds to releasing (LV figure-server design G10): findings in the gate's own shape, blocking or said.
64
+ * The LV figure-server refuses an origin off the middle of the room, which operato-figure only says (ADR-0104
65
+ * decision 4: refusing is the receiving side's), and says its carrier and budget advice.
66
+ */
67
+ export type FigureReleaseCheck = (input: FigureReleaseCheckInput) => GateFinding[]
68
+
69
+ const releaseChecks: FigureReleaseCheck[] = []
70
+
71
+ /** Adds a check to every release verdict (`inspectFigure`, `releaseFigure`). Returns the way to take it off again. */
72
+ export function registerFigureReleaseCheck(check: FigureReleaseCheck): () => void {
73
+ releaseChecks.push(check)
74
+ return () => {
75
+ const at = releaseChecks.indexOf(check)
76
+ if (at >= 0) releaseChecks.splice(at, 1)
77
+ }
78
+ }
79
+
80
+ /** The app's checks on an asset the kernel passed. A check that throws blocks, by name: a check is never skipped. */
81
+ function appFindings(input: FigureReleaseCheckInput): GateFinding[] {
82
+ const found: GateFinding[] = []
83
+ for (const check of releaseChecks) {
84
+ try {
85
+ found.push(...check(input))
86
+ } catch (e) {
87
+ found.push({ code: 'release-check-failed', message: `a release check failed: ${(e as Error)?.message ?? String(e)}`, blocking: true })
88
+ }
89
+ }
90
+ return found
91
+ }
92
+
54
93
  /**
55
94
  * That the source is JSON and names the format.
56
95
  *
@@ -214,7 +253,9 @@ export function inspectSource(
214
253
  releasedJson?: string | null,
215
254
  t?: Translate,
216
255
  /** The row's `type` -- the only place the name lives, since the asset carries none. */
217
- rowType?: string
256
+ rowType?: string,
257
+ /** The row's category, for an app's release checks (G10). */
258
+ rowCategory?: string | null
218
259
  ): Inspection {
219
260
  if (!sourceJson) {
220
261
  return { blocked: true, findings: [{ code: 'no-source', message: '저장된 정본이 없다. 저장하면 발행할 수 있다', blocking: true }] }
@@ -296,6 +337,7 @@ export function inspectSource(
296
337
  })
297
338
  }
298
339
  if (releasedJson) findings.push(...figureContractFindings(asset!, releasedJson, say))
340
+ findings.push(...appFindings({ asset: asset!, type: rowType, category: rowCategory, t }))
299
341
  return { blocked: findings.some(finding => finding.blocking), findings }
300
342
  }
301
343
 
@@ -14,6 +14,7 @@ const settingBase = require('@things-factory/setting-base')
14
14
  const JSZip = require('jszip')
15
15
  const { createFigureAsset, addFigurePrimitive, serializeFigureAsset, readFigurePackage } = require('@hatiolab/figure-model')
16
16
 
17
+ let FigureNamespace: any
17
18
  let dataSource: DataSource | null = null
18
19
  let Figure: any, FigureHistory: any, FigureReplacedDraft: any, FigureMutation: any
19
20
  let keepMesh: any, useThumbnailFiles: any, sha256Of: any, sendFigureMesh: any, figurePackageOf: any
@@ -87,6 +88,7 @@ let files: ReturnType<typeof mapFiles>
87
88
  let restore: any
88
89
  beforeAll(async () => {
89
90
  Figure = loadCompiled('service/figure/figure').Figure
91
+ FigureNamespace = loadCompiled('service/figure/figure-namespace-entity').FigureNamespace
90
92
  FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
91
93
  FigureReplacedDraft = loadCompiled('service/figure/figure-replaced-draft').FigureReplacedDraft
92
94
  FigureMutation = loadCompiled('service/figure/figure-mutation').FigureMutation
@@ -100,7 +102,7 @@ beforeAll(async () => {
100
102
  synchronize: true,
101
103
  dropSchema: true,
102
104
  namingStrategy: new shell.NamingStrategy(),
103
- entities: [...shell.entities, ...authBase.entities, ...attachmentBase.entities, ...settingBase.entities, Figure, FigureHistory, FigureReplacedDraft],
105
+ entities: [...shell.entities, ...authBase.entities, ...attachmentBase.entities, ...settingBase.entities, Figure, FigureHistory, FigureReplacedDraft, FigureNamespace],
104
106
  logging: false
105
107
  })
106
108
  await dataSource.initialize()
@@ -126,7 +128,7 @@ const newDomain = async (parent?: any) => {
126
128
  const name = `mesh-${++seq}`
127
129
  const saved = await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name, ...(parent ? { parent } : {}) })
128
130
  const domain = await dataSource!.getRepository(shell.Domain).findOneOrFail({ where: { id: saved.id } })
129
- await dataSource!.getRepository(settingBase.Setting).save({ domain, name: 'figure-namespace', category: 'figure', value: 'system' })
131
+ await dataSource!.getRepository(FigureNamespace).save({ domain, name: domain.subdomain, state: 'active', isDefault: true })
130
132
  return domain
131
133
  }
132
134
  const meshRows = (domainId: string) => dataSource!.getRepository(attachmentBase.Attachment).find({ where: { refType: 'figure-mesh', domain: { id: domainId } } })