@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
@@ -0,0 +1,60 @@
1
+ import { In, Like, Not } from 'typeorm'
2
+
3
+ import { getRepository } from '@things-factory/shell'
4
+
5
+ import { Figure } from './figure.js'
6
+ import { FigureHistory } from './figure-history.js'
7
+
8
+ /*
9
+ * Public figures (LV figure-server design G2): a figure its domain made public is read by every domain -- its releases,
10
+ * its picture, the files its releases draw from -- and drawn by its full name {namespace}/{type}, which points at one
11
+ * figure on the server because a namespace belongs to one domain (G1). Its draft stays its domain's. Writing it stays
12
+ * its domain's (G3). Each reader asks its own domain and its parent first; a public figure is the last place looked.
13
+ */
14
+
15
+ type Reader = { id: string; parentId?: string | null }
16
+
17
+ /** The domains a reader reads as its own: itself and its parent. */
18
+ export const ownScope = (domain: Reader) => In([domain.id, domain.parentId].filter(Boolean) as string[])
19
+
20
+ /** A figure the reader may read the releases of: its own or its parent's, or any domain's public one. */
21
+ export async function readableFigure(domain: Reader, id: string, select?: Record<string, unknown>): Promise<Figure | null> {
22
+ return getRepository(Figure).findOne({
23
+ where: [
24
+ { domain: { id: ownScope(domain) }, id },
25
+ { public: true, id }
26
+ ],
27
+ relations: { domain: true },
28
+ ...(select ? { select } : {})
29
+ })
30
+ }
31
+
32
+ /** Whether the figure is another domain's public one: the reader sees its releases, never its draft. */
33
+ export function foreignPublic(domain: Reader, figure: Figure): boolean {
34
+ const owner = figure.domain?.id ?? figure.domainId
35
+ return !!figure.public && owner !== domain.id && owner !== domain.parentId
36
+ }
37
+
38
+ /** The public figure of a full name, in whatever domain owns its namespace. */
39
+ export async function publicFigureOfName(namespace: string, type: string): Promise<Figure | null> {
40
+ return getRepository(Figure).findOne({ where: { namespace, type, public: true }, relations: { domain: true } })
41
+ }
42
+
43
+ /**
44
+ * The domains whose kept file this is, among the public figures whose releases draw from it -- the places a reader may
45
+ * be given that file from beyond its own domain and its parent.
46
+ */
47
+ export async function publicDomainsOfFile(hash: string, reader: Reader): Promise<string[]> {
48
+ const figures = await getRepository(Figure).find({
49
+ where: { public: true, domain: { id: Not(ownScope(reader)) } },
50
+ relations: { domain: true },
51
+ select: { id: true, domain: { id: true } }
52
+ })
53
+ if (!figures.length) return []
54
+ const histories = await getRepository(FigureHistory).find({
55
+ where: { originalId: In(figures.map(figure => figure.id)), state: 'released', source: Like(`%${hash}%`) },
56
+ select: { originalId: true }
57
+ })
58
+ const drawing = new Set(histories.map(history => history.originalId))
59
+ return [...new Set(figures.filter(figure => drawing.has(figure.id)).map(figure => figure.domain!.id))]
60
+ }
@@ -21,6 +21,8 @@ import { FigureReplacedDraft } from './figure-replaced-draft.js'
21
21
  import { replacedDraftsOf } from './figure-revert.js'
22
22
  import { proposalOutcomeOf } from './figure-proposal.js'
23
23
  import { meshSummaryOf } from './figure-mesh-store.js'
24
+ import { readableFigure, publicFigureOfName } from './figure-public.js'
25
+ import { figureCategories } from './figure-categories.js'
24
26
 
25
27
  @Resolver(Figure)
26
28
  @UseMiddleware(SpeakRefusals)
@@ -61,6 +63,28 @@ export class FigureQuery {
61
63
  return { items, total }
62
64
  }
63
65
 
66
+ /**
67
+ * Other domains' public figures (LV figure-server design G2): what this domain may read, package and draw besides its
68
+ * own and its parent's. The rows carry their domain, so a screen says whose each is. Their drafts are not opened.
69
+ */
70
+ @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
71
+ @Query(returns => FigureList, { description: "Other domains' public figures. Read only: their releases, never their drafts." })
72
+ async publicFigures(@Args() params: ListParam, @Ctx() context: ResolverContext): Promise<FigureList> {
73
+ const { domain } = context.state
74
+ const mine = [domain.id, domain.parentId].filter(Boolean)
75
+ const queryBuilder = getQueryBuilderFromListParams({
76
+ repository: getRepository(Figure),
77
+ params,
78
+ alias: 'figure',
79
+ searchables: ['type', 'name', 'description', 'category', 'tags']
80
+ })
81
+ .leftJoinAndSelect('figure.domain', 'owner')
82
+ .andWhere('figure.public = :public', { public: true })
83
+ .andWhere('owner.id NOT IN (:...mine)', { mine })
84
+ const [items, total] = await queryBuilder.getManyAndCount()
85
+ return { items, total }
86
+ }
87
+
64
88
  /**
65
89
  * 등록된 타입 이름 목록.
66
90
  *
@@ -80,8 +104,10 @@ export class FigureQuery {
80
104
  async figureVersions(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<FigureHistory[]> {
81
105
  const { domain } = context.state
82
106
 
107
+ /* Its own, its parent's, or another domain's public figure (G2): releases are what a public figure shows. */
108
+ if (!(await readableFigure(domain, id, { id: true, public: true, domain: { id: true } }))) return []
83
109
  return await getRepository(FigureHistory).find({
84
- where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, originalId: id },
110
+ where: { originalId: id },
85
111
  relations: ['updater'],
86
112
  order: { version: 'DESC' },
87
113
  take: 10
@@ -125,8 +151,9 @@ export class FigureQuery {
125
151
  async figureReleased(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<FigureHistory> {
126
152
  const { domain } = context.state
127
153
 
154
+ if (!(await readableFigure(domain, id, { id: true, public: true, domain: { id: true } }))) return null
128
155
  return await getRepository(FigureHistory).findOne({
129
- where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, originalId: id },
156
+ where: { originalId: id },
130
157
  order: { version: 'DESC' }
131
158
  })
132
159
  }
@@ -168,7 +195,7 @@ export class FigureQuery {
168
195
  .limit(1)
169
196
  .getOne()
170
197
 
171
- return inspectSource(figure.source, released?.source, context.t, figure.type)
198
+ return inspectSource(figure.source, released?.source, context.t, figure.type, figure.category)
172
199
  }
173
200
 
174
201
  /**
@@ -202,7 +229,15 @@ export class FigureQuery {
202
229
  .limit(1)
203
230
  .getOne()
204
231
 
205
- return inspectSource(source, released?.source, context.t, figure.type)
232
+ return inspectSource(source, released?.source, context.t, figure.type, figure.category)
233
+ }
234
+
235
+ /** The categories this server allows (G5), or null when a category is free text: the modeller offers them to choose from. */
236
+ @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
237
+ @Query(returns => [String], { nullable: true, description: 'The categories this server allows, or null when a category is free text.' })
238
+ async figureCategories(): Promise<string[] | null> {
239
+ const codes = figureCategories()
240
+ return codes ? [...codes] : null
206
241
  }
207
242
 
208
243
  @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
@@ -357,7 +392,9 @@ export class FigureQuery {
357
392
  continue
358
393
  }
359
394
  const found =
360
- (await releasedSourceOfName(domain.id, name)) ?? (domain.parentId ? await releasedSourceOfName(domain.parentId, name) : null)
395
+ (await releasedSourceOfName(domain.id, name)) ??
396
+ (domain.parentId ? await releasedSourceOfName(domain.parentId, name) : null) ??
397
+ (await publicReleasedSourceOf(name))
361
398
  if (!found) {
362
399
  refuse(type, new Refusal('figure-not-released', { type }, 'no release is published here.'))
363
400
  continue
@@ -388,6 +425,12 @@ async function releasedSourceOfName(domainId: string, name: FigureName) {
388
425
  return found && found.figure.namespace === name.namespace ? found : null
389
426
  }
390
427
 
428
+ /** The released source of another domain's public figure by full name (G2): looked at after the reader's own. */
429
+ async function publicReleasedSourceOf(name: FigureName) {
430
+ const figure = await publicFigureOfName(name.namespace, name.type)
431
+ return figure?.domain ? releasedSourceOfName(figure.domain.id, name) : null
432
+ }
433
+
391
434
  /** One numbered release of a figure, or null when it has none by that number. */
392
435
  async function releaseNumbered(figure: Figure, version: number): Promise<{ figure: Figure; source: string; version: number } | null> {
393
436
  const row = await getRepository(FigureHistory).findOne({
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Release checks an app adds (LV figure-server design G10): they see the asset, the row's type and category and the
3
+ * reader's words, and their findings join the gate's -- a blocking one blocks, a said one does not; a check that
4
+ * throws blocks by name rather than being skipped; one taken off is gone.
5
+ */
6
+ import { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy } from '@hatiolab/figure-model'
7
+
8
+ import { inspectSource, registerFigureReleaseCheck } from './figure-format'
9
+
10
+ const source = () => {
11
+ let asset = addFigureBox(createFigureAsset('Mixer'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
12
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } })
13
+ return serializeFigureAsset(applyFigureAuthoring(asset, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(asset, { over: 'range' }), placement: 'floor' }))
14
+ }
15
+ const codes = (inspection: { findings: { code: string; blocking: boolean }[] }) => inspection.findings.map(f => [f.code, f.blocking])
16
+
17
+ describe('release checks an app adds', () => {
18
+ const off: Array<() => void> = []
19
+ afterEach(() => off.splice(0).forEach(take => take()))
20
+
21
+ it('none: the gate as it was', () => {
22
+ const inspection = inspectSource(source(), null, undefined, 'mixer', 'EQUIPMENT')
23
+ expect(inspection.blocked).toBe(false)
24
+ expect(inspection.findings.filter(f => f.code.startsWith('app-'))).toEqual([])
25
+ })
26
+
27
+ it('see the asset, the type and the category; a blocking finding blocks, a said one does not', () => {
28
+ const seen: unknown[] = []
29
+ off.push(
30
+ registerFigureReleaseCheck(input => {
31
+ seen.push([input.asset.version, input.type, input.category])
32
+ return input.category === 'CARRIER'
33
+ ? [{ code: 'app-carrier-one-part', message: 'a carrier is one part', blocking: false }]
34
+ : [{ code: 'app-origin-off', message: 'the origin is off the middle', blocking: true }]
35
+ })
36
+ )
37
+ const equipment = inspectSource(source(), null, undefined, 'mixer', 'EQUIPMENT')
38
+ expect(codes(equipment)).toContainEqual(['app-origin-off', true])
39
+ expect(equipment.blocked).toBe(true)
40
+ const carrier = inspectSource(source(), null, undefined, 'roll', 'CARRIER')
41
+ expect(codes(carrier)).toContainEqual(['app-carrier-one-part', false])
42
+ expect(carrier.blocked).toBe(false)
43
+ expect(seen).toEqual([['figure-asset-1', 'mixer', 'EQUIPMENT'], ['figure-asset-1', 'roll', 'CARRIER']])
44
+ })
45
+
46
+ it('a check that throws blocks by name; one taken off is gone', () => {
47
+ const take = registerFigureReleaseCheck(() => {
48
+ throw new Error('no budget table')
49
+ })
50
+ const inspection = inspectSource(source(), null, undefined, 'mixer', 'EQUIPMENT')
51
+ expect(codes(inspection)).toContainEqual(['release-check-failed', true])
52
+ expect(inspection.findings.find(f => f.code === 'release-check-failed')?.message).toMatch(/no budget table/)
53
+ take()
54
+ expect(inspectSource(source(), null, undefined, 'mixer', 'EQUIPMENT').blocked).toBe(false)
55
+ })
56
+
57
+ it('a source the kernel refuses is not shown to them', () => {
58
+ let called = false
59
+ off.push(registerFigureReleaseCheck(() => ((called = true), [])))
60
+ expect(inspectSource('{"version":"figure-asset-1"}', null, undefined, 'x', null).blocked).toBe(true)
61
+ expect(called).toBe(false)
62
+ })
63
+ })
@@ -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, palletizerRobotFigure, FIGURE_KERNEL_VERSION } = 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 = loadCompiled('service/figure/figure-mutation').FigureMutation
@@ -38,7 +40,7 @@ beforeAll(async () => {
38
40
  synchronize: true,
39
41
  dropSchema: true,
40
42
  namingStrategy: new shell.NamingStrategy(),
41
- entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory],
43
+ entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory, FigureNamespace],
42
44
  subscribers: [Subscriber],
43
45
  logging: false
44
46
  })
@@ -50,9 +52,9 @@ afterAll(async () => {
50
52
  dataSource = null
51
53
  })
52
54
 
53
- /** The domain setting every figure here is created under (ADR-0114). */
55
+ /** The domain's own default namespace, its subdomain, that every figure here is created and released under (G1). */
54
56
  async function namespaced(domain: any) {
55
- await dataSource!.getRepository(settingBase.Setting).save({ domain, name: 'figure-namespace', category: 'figure', value: 'system' })
57
+ await dataSource!.getRepository(FigureNamespace).save({ domain, name: domain.subdomain, state: 'active', isDefault: true })
56
58
  return domain
57
59
  }
58
60
 
@@ -94,7 +96,7 @@ describe('releaseFigure records it', () => {
94
96
  const domain = await namespaced(await dataSource!.getRepository(shell.Domain).save({ name: 'release-door', subdomain: 'release-door' }))
95
97
  const tx = dataSource!.manager
96
98
  const context = { state: { domain, user: undefined, tx } }
97
- const figure = await dataSource!.getRepository(Figure).save({ namespace: 'system', type: 'RELEASE_DOOR', name: 'door', source: releasable(100), state: 'draft', version: 0, domain })
99
+ const figure = await dataSource!.getRepository(Figure).save({ namespace: domain.subdomain, type: 'RELEASE_DOOR', name: 'door', source: releasable(100), state: 'draft', version: 0, domain })
98
100
  await new FigureMutation().releaseFigure(figure.id, 'one', context)
99
101
  await dataSource!.getRepository(Figure).save({ ...(await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })), domain, source: releasable(300), state: 'draft' })
100
102
  await new FigureMutation().releaseFigure(figure.id, 'two', context)
@@ -125,7 +127,7 @@ describe('a release of a figure that moves keeps its joint list', () => {
125
127
  it('schema 2, the wrist following -(j2 + j3), controls with both units; another axis is refused', async () => {
126
128
  const domain = await namespaced(await dataSource!.getRepository(shell.Domain).save({ name: 'release-joints', subdomain: 'release-joints' }))
127
129
  const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
128
- const figure = await dataSource!.getRepository(Figure).save({ namespace: 'system', type: 'PALLETIZER', name: 'palletizer', source: palletizer(), state: 'draft', version: 0, domain })
130
+ const figure = await dataSource!.getRepository(Figure).save({ namespace: domain.subdomain, type: 'PALLETIZER', name: 'palletizer', source: palletizer(), state: 'draft', version: 0, domain })
129
131
  await new FigureMutation().releaseFigure(figure.id, 'one', context)
130
132
 
131
133
  const [row] = await dataSource!.getRepository(FigureHistory).find({ where: { originalId: figure.id } })
@@ -15,6 +15,7 @@ const shell = require('@things-factory/shell')
15
15
  const authBase = require('@things-factory/auth-base')
16
16
  const settingBase = require('@things-factory/setting-base')
17
17
 
18
+ let FigureNamespace: any
18
19
  let dataSource: DataSource | null = null
19
20
  let Figure: any
20
21
  let FigureHistory: any
@@ -38,6 +39,7 @@ function loadCompiled(modulePath: string): any {
38
39
 
39
40
  beforeAll(async () => {
40
41
  Figure = loadCompiled('service/figure/figure').Figure
42
+ FigureNamespace = loadCompiled('service/figure/figure-namespace-entity').FigureNamespace
41
43
  FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
42
44
  renameFacts = loadCompiled('service/figure/figure-rename').renameFacts
43
45
 
@@ -47,7 +49,7 @@ beforeAll(async () => {
47
49
  synchronize: true,
48
50
  dropSchema: true,
49
51
  namingStrategy: new shell.NamingStrategy(),
50
- entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory],
52
+ entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory, FigureNamespace],
51
53
  logging: false
52
54
  })
53
55
 
@@ -69,9 +71,9 @@ afterAll(async () => {
69
71
  dataSource = null
70
72
  })
71
73
 
72
- /** The domain setting every figure here is created under (ADR-0114). */
74
+ /** The domain's own default namespace, its subdomain, that every figure here is created and released under (G1). */
73
75
  async function namespaced(domain: any) {
74
- await dataSource!.getRepository(settingBase.Setting).save({ domain, name: 'figure-namespace', category: 'figure', value: 'system' })
76
+ await dataSource!.getRepository(FigureNamespace).save({ domain, name: domain.subdomain, state: 'active', isDefault: true })
75
77
  return domain
76
78
  }
77
79
 
@@ -108,7 +110,7 @@ describe('이름을 바꾸기 전에 읽는 사실', () => {
108
110
  it('★ 판본 질의가 실제로 돈다 — 칸 이름이 틀리면 여기서 던진다', async () => {
109
111
  const figure = await seedFigure()
110
112
 
111
- expect(await facts(figure, 'robot-arm')).toEqual({ version: 0, hasHistory: false, takenByAnother: false })
113
+ expect(await facts(figure, 'robot-arm')).toEqual({ version: 0, hasHistory: false, takenByAnother: false, releasedBefore: false })
112
114
  })
113
115
 
114
116
  it('★ 판본이 있으면 그것을 읽어 온다', async () => {
@@ -189,3 +191,47 @@ describe('처음 저장에 타입 이름을 받는다', () => {
189
191
  expect(model.isPlaceholderType(saved.type)).toBe(true)
190
192
  })
191
193
  })
194
+
195
+ /*
196
+ * A type released and then deleted before deleting a released figure was refused (LV figure-server design G4): taking that
197
+ * name again would number from 1, and one reference `ns/type@n` would name two contents.
198
+ */
199
+ describe('발행한 뒤 지운 이름은 다시 쓰지 않는다', () => {
200
+ let FigureMutation: any
201
+ let FIGURE_SOURCE: string
202
+ beforeAll(() => {
203
+ FigureMutation = loadCompiled('service/figure/figure-mutation').FigureMutation
204
+ const model = require('@hatiolab/figure-model')
205
+ FIGURE_SOURCE = model.serializeFigureAsset(
206
+ model.addFigureBox(model.createFigureAsset('상자'), { id: 'box', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
207
+ )
208
+ })
209
+ const releasedThenDeleted = async (type: string) => {
210
+ const figure = await seedFigure({ type, name: type, version: 1, state: 'released' })
211
+ await dataSource!.getRepository(FigureHistory).save({ originalId: figure.id, version: 1, type, name: type, source: '{}', domain: DOMAIN })
212
+ await dataSource!.getRepository(Figure).softDelete({ id: figure.id })
213
+ return figure
214
+ }
215
+ const context = () => ({ state: { domain: DOMAIN, user: undefined, tx: dataSource!.manager } })
216
+
217
+ it('사실: 발행한 뒤 지운 같은 이름이 있으면 releasedBefore, 발행 없이 지운 것은 아니다', async () => {
218
+ await releasedThenDeleted('shipped-gone')
219
+ const draftGone = await seedFigure({ type: 'draft-gone', name: 'draft-gone' })
220
+ await dataSource!.getRepository(Figure).softDelete({ id: draftGone.id })
221
+ const fresh = await seedFigure({ type: 'fresh-one', name: 'fresh-one' })
222
+ expect((await facts(fresh, 'shipped-gone')).releasedBefore).toBe(true)
223
+ expect((await facts(fresh, 'draft-gone')).releasedBefore).toBe(false)
224
+ expect((await facts(fresh, 'never-used')).releasedBefore).toBe(false)
225
+ })
226
+
227
+ it('이름 바꾸기로도, 처음 저장으로도, 새로 만들기로도 그 이름을 받지 않는다', async () => {
228
+ await releasedThenDeleted('gone-mixer')
229
+ const draft = await seedFigure({ type: 'mixer-draft', name: 'mixer-draft' })
230
+ await expect(new FigureMutation().updateFigure(draft.id, { type: 'gone-mixer' }, context())).rejects.toMatchObject({ code: 'type-released-before' })
231
+ await expect(
232
+ new FigureMutation().saveFigure(FIGURE_SOURCE, context(), undefined, undefined, '7c3d4e5f-6a7b-4c8d-9e0f-a1b2c3d4e5f6', undefined, 'gone-mixer')
233
+ ).rejects.toThrow(/발행한 뒤 지운/)
234
+ await expect(new FigureMutation().createFigure({ type: 'gone-mixer', name: 'again', source: FIGURE_SOURCE }, context())).rejects.toThrow(/발행한 뒤 지운/)
235
+ expect(await dataSource!.getRepository(Figure).count({ where: { type: 'gone-mixer' } })).toBe(0)
236
+ })
237
+ })
@@ -1,3 +1,4 @@
1
+ import { In, IsNull, Not } from 'typeorm'
1
2
  import { TYPE_LENGTH, TYPE_PATTERN } from '@hatiolab/figure-model'
2
3
  /**
3
4
  * 초안의 타입을 고치는 규칙 — ADR-0063 결정 4.
@@ -29,11 +30,38 @@ export interface RenameFacts {
29
30
  hasHistory: boolean
30
31
  /** 같은 도메인에서 그 이름을 이미 쓰는 **다른** 도형이 있나. */
31
32
  takenByAnother: boolean
33
+ /**
34
+ * 같은 도메인에서 그 이름으로 발행한 뒤 지워진 도형이 있나. 발행한 도형을 지울 수 없게 되기(G4) 전에 지워진 것이다.
35
+ * 그 이름을 다시 쓰면 판 번호가 1부터 다시 매겨져 같은 `ns/type@n` 이 다른 내용을 가리킨다.
36
+ */
37
+ releasedBefore?: boolean
32
38
  }
33
39
 
40
+ /** Whether a figure of this type was released and then deleted in the domain (deleted before G4 refused it). */
41
+ export async function typeReleasedBefore(
42
+ repositories: {
43
+ figures: { find(options: any): Promise<{ id?: string }[]> }
44
+ histories: { count(options: any): Promise<number> }
45
+ },
46
+ domain: { id: string },
47
+ type: string
48
+ ): Promise<boolean> {
49
+ const deleted = await repositories.figures.find({
50
+ where: { domain: { id: domain.id }, type, deletedAt: Not(IsNull()) },
51
+ withDeleted: true,
52
+ select: ['id']
53
+ })
54
+ const ids = deleted.map(figure => figure.id).filter(Boolean)
55
+ return ids.length > 0 && (await repositories.histories.count({ where: { originalId: In(ids) } })) > 0
56
+ }
57
+
58
+ /** Said when a type was released and deleted before: a new figure may not take it. */
59
+ export const releasedBeforeMessage = (type: string) =>
60
+ `type 「${type}」 는 이 도메인에서 발행한 뒤 지운 도형의 이름이다 — 다시 쓰면 같은 판 번호가 다른 내용을 가리키므로 다른 이름을 고른다`
61
+
34
62
  export class FigureRenameRefused extends Error {
35
63
  constructor(
36
- readonly code: 'empty-type' | 'malformed-type' | 'already-released' | 'type-taken',
64
+ readonly code: 'empty-type' | 'malformed-type' | 'already-released' | 'type-taken' | 'type-released-before',
37
65
  message: string
38
66
  ) {
39
67
  super(message)
@@ -80,6 +108,10 @@ export function renameDraftType(
80
108
  throw new FigureRenameRefused('type-taken', `type 「${next}」 는 이 도메인에서 이미 쓰고 있다`)
81
109
  }
82
110
 
111
+ if (facts.releasedBefore) {
112
+ throw new FigureRenameRefused('type-released-before', releasedBeforeMessage(next))
113
+ }
114
+
83
115
  /* The asset carries no `type`: the name a board uses is the row's alone, so only the row changes. */
84
116
  return { type: next, source: current.source }
85
117
  }
@@ -94,7 +126,7 @@ export function renameDraftType(
94
126
  */
95
127
  export async function renameFacts(
96
128
  repositories: {
97
- figures: { findOne(options: any): Promise<{ id?: string } | null> }
129
+ figures: { findOne(options: any): Promise<{ id?: string } | null>; find(options: any): Promise<{ id?: string }[]> }
98
130
  histories: { count(options: any): Promise<number> }
99
131
  },
100
132
  figure: { id?: string; version?: number },
@@ -105,5 +137,7 @@ export async function renameFacts(
105
137
  const hasHistory = (await repositories.histories.count({ where: { originalId: figure.id } })) > 0
106
138
  const taken = next ? await repositories.figures.findOne({ where: { domain: { id: domain.id }, type: next } }) : null
107
139
 
108
- return { version: figure.version, hasHistory, takenByAnother: !!taken && taken.id !== figure.id }
140
+ const releasedBefore = next ? await typeReleasedBefore(repositories, domain, next) : false
141
+
142
+ return { version: figure.version, hasHistory, takenByAnother: !!taken && taken.id !== figure.id, releasedBefore }
109
143
  }
@@ -415,7 +415,7 @@ function figureToolSpecs(): ToolSpec[] {
415
415
  const current = sourceForTool(row, ctx?.host)
416
416
  const { inspectSource } = getDeps()
417
417
  /* Inspected whole, here; only what the model is told is held to its budget. */
418
- return { ...findingsWithinBudget(inspectSource(current.source ? JSON.stringify(current.source) : null, null, undefined, row.type)), draft: current.draft }
418
+ return { ...findingsWithinBudget(inspectSource(current.source ? JSON.stringify(current.source) : null, null, undefined, row.type, row.category)), draft: current.draft }
419
419
  }
420
420
  },
421
421
  {
@@ -38,6 +38,9 @@ export class NewFigure {
38
38
 
39
39
  @Field({ nullable: true, description: 'A base64 encoded thumbnail image.' })
40
40
  thumbnail?: string
41
+
42
+ @Field({ nullable: true, description: "The domain's namespace to create it under; the domain's default when none." })
43
+ namespace?: string
41
44
  }
42
45
 
43
46
  /**
@@ -113,6 +113,14 @@ export class Figure {
113
113
  @Field({ nullable: true, description: 'The type to use instead of a retired figure.' })
114
114
  supersededBy?: string
115
115
 
116
+ /*
117
+ 공개(LV figure-server design G2). 다른 도메인도 이 figure 의 **발행본**을 읽고 꾸러미에 담고 도면에서 정식 이름으로
118
+ 부른다. 초안은 소유 도메인만 본다. 고치고 발행하는 것은 소유 도메인뿐이다(G3). `setFigurePublic` 이 정한다.
119
+ */
120
+ @Column({ nullable: true, default: false })
121
+ @Field({ nullable: true, description: 'Public: other domains read its releases, put them in packages and draw them by full name. Only its domain changes it.' })
122
+ public?: boolean
123
+
116
124
  /**
117
125
  * 분류 하나. **팔레트에서 묶어 보이는 기준이다.**
118
126
  *
@@ -2,11 +2,12 @@ import { Figure } from './figure.js'
2
2
  import { FigureHistory } from './figure-history.js'
3
3
  import { FigureReplacedDraft } from './figure-replaced-draft.js'
4
4
  import { FigureProposalRecord } from './figure-proposal-record.js'
5
+ import { FigureNamespace } from './figure-namespace-entity.js'
5
6
  import { FigureQuery } from './figure-query.js'
6
7
  import { FigureMutation } from './figure-mutation.js'
7
8
  import { FigureNamespaceResolver } from './figure-namespace-resolver.js'
8
9
  import { FigureHistoryEntitySubscriber } from './event-subscriber.js'
9
10
 
10
- export const entities = [Figure, FigureHistory, FigureReplacedDraft, FigureProposalRecord]
11
+ export const entities = [Figure, FigureHistory, FigureReplacedDraft, FigureProposalRecord, FigureNamespace]
11
12
  export const resolvers = [FigureQuery, FigureMutation, FigureNamespaceResolver]
12
13
  export const subscribers = [FigureHistoryEntitySubscriber]
@@ -97,8 +97,7 @@
97
97
  "figure.gate.origin-off-centre.message": "The origin is off the middle of the declared room: {off}.",
98
98
  "figure.gate.origin-off-centre.why": "A drawing that places figures by the middle of their room puts this one that far from where it is drawn.",
99
99
  "figure.gate.origin-off-centre.how": "Keep it when the figure is fastened there, as a door at its hinge. Move the origin to the middle to place it by its room.",
100
- "error.figure-namespace-missing": "this domain has no valid figure namespace ({value}). set {setting} first, for example hatiolab.com.",
101
- "error.figure-namespace-changed": "the figure namespace of this domain is {namespace} and cannot change. set {setting} back to {namespace}.",
100
+ "error.figure-namespace-missing": "this domain has no default figure namespace. add one first.",
102
101
  "error.figure-namespace-invalid": "{value} is not a figure namespace. use lower-case letters, digits, dots and hyphens, for example hatiolab.com.",
103
102
  "error.figure-type-malformed": "{type} is not a figure type. use lower-case letters, digits and single hyphens, {min} to {max} characters, starting with a letter.",
104
103
  "error.figure-name-malformed": "{type} is not a full figure name of the form namespace/type.",
@@ -152,5 +151,15 @@
152
151
  "figure.gate.contract.slot-anchor-moved.why": "Every slot on it, and what sits there, stands on the new face after this release.",
153
152
  "figure.proposal.label-create": "Make {name}",
154
153
  "figure.proposal.label-revise": "Revise {name}",
155
- "figure.proposal.reason": "{steps} commands · grade {grade} · {triangles} triangles · made in {attempts} tries"
154
+ "figure.proposal.reason": "{steps} commands · grade {grade} · {triangles} triangles · made in {attempts} tries",
155
+ "error.figure-released-not-deletable": "{type} has been released and cannot be deleted.",
156
+ "error.figure-namespace-taken": "the figure namespace {namespace} is already taken on this server.",
157
+ "error.figure-namespace-not-owned": "this domain does not own the figure namespace {namespace}.",
158
+ "error.figure-namespace-suspended": "the figure namespace {namespace} is suspended.",
159
+ "error.figure-namespace-default-suspended": "the default namespace {namespace} cannot be suspended. make another the default first.",
160
+ "error.figure-namespace-state": "{state} is not a namespace state. use active or suspended.",
161
+ "error.figure-public-unreleased": "{type} has no release yet: only released figures are made public.",
162
+ "error.figure-category-fixed": "the category {category} is set once and does not change.",
163
+ "error.figure-category-unknown": "{category} is not a category here. use one of {allowed}.",
164
+ "error.figure-category-missing": "{type} has no category. choose one of {allowed} before releasing."
156
165
  }
@@ -97,8 +97,7 @@
97
97
  "figure.gate.origin-off-centre.message": "El origen está desplazado del centro del espacio declarado: {off}.",
98
98
  "figure.gate.origin-off-centre.why": "Un plano que coloca las figuras por el centro de su espacio sitúa esta a esa distancia de donde está dibujada.",
99
99
  "figure.gate.origin-off-centre.how": "Manténgalo si la figura se fija en ese punto, como una puerta por su bisagra. Mueva el origen al centro para colocarla por su espacio.",
100
- "error.figure-namespace-missing": "este dominio no tiene un namespace de figura válido ({value}). Defina primero {setting}, por ejemplo hatiolab.com.",
101
- "error.figure-namespace-changed": "el namespace de figura de este dominio es {namespace} y no se puede cambiar. Restablezca {setting} a {namespace}.",
100
+ "error.figure-namespace-missing": "este dominio no tiene un namespace de figura predeterminado. agregue uno primero.",
102
101
  "error.figure-namespace-invalid": "{value} no es un namespace de Figure. Use minúsculas, dígitos, puntos y guiones, por ejemplo hatiolab.com.",
103
102
  "error.figure-type-malformed": "{type} no es un tipo de figura. Use letras minúsculas, dígitos y guiones simples, de {min} a {max} caracteres, empezando por una letra.",
104
103
  "error.figure-name-malformed": "{type} no es un nombre completo de figura con el formato namespace/type.",
@@ -152,5 +151,15 @@
152
151
  "figure.gate.contract.slot-anchor-moved.why": "Cada ranura sobre él, y lo que contiene, queda en la nueva cara tras esta publicación.",
153
152
  "figure.proposal.label-create": "Crear {name}",
154
153
  "figure.proposal.label-revise": "Corregir {name}",
155
- "figure.proposal.reason": "{steps} comandos · grado {grade} · {triangles} triángulos · hecho en {attempts} intentos"
154
+ "figure.proposal.reason": "{steps} comandos · grado {grade} · {triangles} triángulos · hecho en {attempts} intentos",
155
+ "error.figure-released-not-deletable": "{type} ya se publicó y no se puede eliminar.",
156
+ "error.figure-namespace-taken": "el namespace de figura {namespace} ya está en uso en este servidor.",
157
+ "error.figure-namespace-not-owned": "este dominio no posee el namespace de figura {namespace}.",
158
+ "error.figure-namespace-suspended": "el namespace de figura {namespace} está suspendido.",
159
+ "error.figure-namespace-default-suspended": "el namespace predeterminado {namespace} no se puede suspender. primero haga predeterminado otro.",
160
+ "error.figure-namespace-state": "{state} no es un estado de namespace. use active o suspended.",
161
+ "error.figure-public-unreleased": "{type} aún no se ha publicado: solo las figuras publicadas se pueden hacer públicas.",
162
+ "error.figure-category-fixed": "la categoría {category} se fija una vez y no cambia.",
163
+ "error.figure-category-unknown": "{category} no es una categoría aquí. use una de {allowed}.",
164
+ "error.figure-category-missing": "{type} no tiene categoría. elija una de {allowed} antes de publicar."
156
165
  }
@@ -97,8 +97,7 @@
97
97
  "figure.gate.origin-off-centre.message": "原点が宣言された空間の中央からずれています: {off}。",
98
98
  "figure.gate.origin-off-centre.why": "空間の中央で Figure を配置する図面では、描画した位置からその分だけ離れて配置されます。",
99
99
  "figure.gate.origin-off-centre.how": "ドアの蝶番のように、Figure をそこで取り付ける場合はそのままにしてください。空間の中央で配置するには、原点を中央に移動してください。",
100
- "error.figure-namespace-missing": "このドメインには有効な Figure namespace がありません({value})。先に {setting} を設定してください。例: hatiolab.com。",
101
- "error.figure-namespace-changed": "このドメインの Figure namespace は {namespace} で、変更できません。{setting} を {namespace} に戻してください。",
100
+ "error.figure-namespace-missing": "このドメインには既定の Figure namespace がありません。先に namespace を追加してください。",
102
101
  "error.figure-namespace-invalid": "Figure namespace の形式ではありません({value})。英小文字・数字・ドット・ハイフンで入力してください。例: hatiolab.com",
103
102
  "error.figure-type-malformed": "{type} は Figure type ではありません。英小文字で始め、英小文字・数字・単一のハイフンのみを使って {min}〜{max} 文字で指定してください。",
104
103
  "error.figure-name-malformed": "{type} は namespace/type 形式の正式な Figure 名ではありません。",
@@ -152,5 +151,15 @@
152
151
  "figure.gate.contract.slot-anchor-moved.why": "その上のすべてのスロットと置かれたものは、このリリース以降新しい面に立ちます。",
153
152
  "figure.proposal.label-create": "{name} を作る",
154
153
  "figure.proposal.label-revise": "{name} を直す",
155
- "figure.proposal.reason": "コマンド {steps} 件 · 等級 {grade} · 三角形 {triangles} · {attempts} 回で作成"
154
+ "figure.proposal.reason": "コマンド {steps} 件 · 等級 {grade} · 三角形 {triangles} · {attempts} 回で作成",
155
+ "error.figure-released-not-deletable": "{type} は発行済みの Figure のため削除できません。",
156
+ "error.figure-namespace-taken": "Figure namespace {namespace} はこのサーバーで既に使われています。",
157
+ "error.figure-namespace-not-owned": "このドメインは Figure namespace {namespace} を所有していません。",
158
+ "error.figure-namespace-suspended": "Figure namespace {namespace} は停止中です。",
159
+ "error.figure-namespace-default-suspended": "既定の namespace {namespace} は停止できません。先に別の namespace を既定にしてください。",
160
+ "error.figure-namespace-state": "{state} は namespace の状態ではありません。active か suspended を使ってください。",
161
+ "error.figure-public-unreleased": "{type} はまだ発行されていません。公開できるのは発行済みの Figure だけです。",
162
+ "error.figure-category-fixed": "分類 {category} は一度決めたら変えません。",
163
+ "error.figure-category-unknown": "{category} はここで使う分類ではありません。{allowed} のいずれかを選んでください。",
164
+ "error.figure-category-missing": "{type} に分類がありません。発行する前に {allowed} のいずれかを選んでください。"
156
165
  }
@@ -97,8 +97,7 @@
97
97
  "figure.gate.origin-off-centre.message": "원점이 차지 공간 가운데에서 비켜 있습니다: {off}.",
98
98
  "figure.gate.origin-off-centre.why": "공간 가운데로 도형을 놓는 도면에서는 그린 자리에서 그만큼 떨어져 놓입니다.",
99
99
  "figure.gate.origin-off-centre.how": "문의 경첩처럼 붙이는 자리라면 그대로 둡니다. 공간 가운데로 놓이게 하려면 원점을 가운데로 옮깁니다.",
100
- "error.figure-namespace-missing": "이 도메인에 Figure namespace 가 없거나 형식이 맞지 않습니다({value}). 설정 {setting} 에 예를 들어 hatiolab.com 을 먼저 입력해 주세요.",
101
- "error.figure-namespace-changed": "이 도메인의 Figure namespace 는 {namespace} 이며 바꿀 수 없습니다. 설정 {setting} 을 {namespace} 로 되돌려 주세요.",
100
+ "error.figure-namespace-missing": "이 도메인에는 기본 Figure namespace 가 없습니다. 먼저 namespace 를 추가해 주세요.",
102
101
  "error.figure-namespace-invalid": "Figure namespace 형식이 아닙니다({value}). 영문 소문자·숫자·점·하이픈으로 입력해 주세요. 예: hatiolab.com",
103
102
  "error.figure-type-malformed": "{type} 은 Figure type 이 아닙니다. 소문자로 시작하고 소문자·숫자·하이픈 하나씩만 써서 {min}~{max}자로 정해 주세요.",
104
103
  "error.figure-name-malformed": "{type} 은 namespace/type 형식의 정식 이름이 아닙니다.",
@@ -152,5 +151,15 @@
152
151
  "figure.gate.contract.slot-anchor-moved.why": "발행 후 해당 기준면의 모든 슬롯과 배치된 항목이 새 기준면으로 이동합니다.",
153
152
  "figure.proposal.label-create": "{name} 만들기",
154
153
  "figure.proposal.label-revise": "{name} 고치기",
155
- "figure.proposal.reason": "명령 {steps}개 · 등급 {grade} · 삼각형 {triangles} · {attempts}번에 만들었다"
154
+ "figure.proposal.reason": "명령 {steps}개 · 등급 {grade} · 삼각형 {triangles} · {attempts}번에 만들었다",
155
+ "error.figure-released-not-deletable": "{type} 은 발행한 Figure 라 지울 수 없습니다.",
156
+ "error.figure-namespace-taken": "Figure namespace {namespace} 는 이 서버에서 이미 쓰고 있습니다.",
157
+ "error.figure-namespace-not-owned": "이 도메인은 Figure namespace {namespace} 를 갖고 있지 않습니다.",
158
+ "error.figure-namespace-suspended": "Figure namespace {namespace} 는 쉬는 중입니다.",
159
+ "error.figure-namespace-default-suspended": "기본 namespace {namespace} 는 쉬게 할 수 없습니다. 먼저 다른 namespace 를 기본으로 정해 주세요.",
160
+ "error.figure-namespace-state": "{state} 는 namespace 상태가 아닙니다. active 나 suspended 를 쓰세요.",
161
+ "error.figure-public-unreleased": "{type} 은 아직 발행하지 않았습니다. 발행한 Figure 만 공개할 수 있습니다.",
162
+ "error.figure-category-fixed": "분류 {category} 는 한 번 정하면 바꾸지 않습니다.",
163
+ "error.figure-category-unknown": "{category} 는 여기서 쓰는 분류가 아닙니다. {allowed} 가운데 하나를 고르세요.",
164
+ "error.figure-category-missing": "{type} 에 분류가 없습니다. 발행하기 전에 {allowed} 가운데 하나를 고르세요."
156
165
  }