@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
@@ -13,8 +13,9 @@ import { revertKeepingDraft, undoRevert } from './figure-revert.js'
13
13
  import { recordProposals } from './figure-proposal.js'
14
14
  import { FigureProposal, ProposeRequest } from './figure-propose-type.js'
15
15
  import { inspectSource, releaseRecordOf } from './figure-format.js'
16
- import { renameDraftType, renameFacts } from './figure-rename.js'
17
- import { namespaceForNewFigure, namespaceOf } from './figure-namespace.js'
16
+ import { releasedBeforeMessage, renameDraftType, renameFacts, typeReleasedBefore } from './figure-rename.js'
17
+ import { assertReleasableNamespace, namespaceForNewFigure, namespaceOf } from './figure-namespace.js'
18
+ import { assertFigureCategory, assertReleasableCategory } from './figure-categories.js'
18
19
  import { parseSource } from './figure-format.js'
19
20
  import { ProposeFigureFailure, proposalFailureForPerson, proposeFigureSteps } from './figure-propose-steps.js'
20
21
  import { prepareFigureWrite, compareAndSwapFigure, pictureOf } from './figure-write.js'
@@ -48,6 +49,19 @@ export function assertNames(names: unknown): asserts names is Record<string, str
48
49
  }
49
50
  }
50
51
 
52
+ /**
53
+ * The figure a write may touch: one of this domain's own (LV figure-server design G3). A domain reads its parent's
54
+ * figures but does not change them -- updating, releasing or retiring one from a child domain is refused as not found,
55
+ * the same answer saving its draft has always given.
56
+ */
57
+ async function ownFigure(repository: { findOne(options: any): Promise<Figure | null> }, domain: { id: string }, id: string): Promise<Figure> {
58
+ const figure = await repository.findOne({ where: { domain: { id: domain.id }, id } })
59
+ if (!figure) {
60
+ throw new Error(`Figure ${id} 를 찾을 수 없다`)
61
+ }
62
+ return figure
63
+ }
64
+
51
65
  @Resolver(Figure)
52
66
  @UseMiddleware(SpeakRefusals)
53
67
  export class FigureMutation {
@@ -67,9 +81,11 @@ export class FigureMutation {
67
81
  nullable: true,
68
82
  description: "Keys of the AI proposals whose steps this source holds: recorded with the save, in its transaction."
69
83
  })
70
- proposalKeys?: string[]
84
+ proposalKeys?: string[],
85
+ @Arg('namespace', { nullable: true, description: "The domain's namespace a new figure is created under; its default when none. Ignored for an existing one." })
86
+ namespace?: string
71
87
  ): Promise<Figure> {
72
- const saved = await this.writeSource(context, source, id, expectedSource, creationId, thumbnail, type)
88
+ const saved = await this.writeSource(context, source, id, expectedSource, creationId, thumbnail, type, namespace)
73
89
  await recordProposals(context.state.tx, context.state.domain, saved.id!, proposalKeys)
74
90
  return saved
75
91
  }
@@ -81,7 +97,8 @@ export class FigureMutation {
81
97
  expectedSource?: string,
82
98
  creationId?: string,
83
99
  thumbnail?: string,
84
- type?: string
100
+ type?: string,
101
+ chosenNamespace?: string
85
102
  ): Promise<Figure> {
86
103
  const { domain, user, tx } = context.state
87
104
  const repository = tx.getRepository(Figure)
@@ -107,7 +124,7 @@ export class FigureMutation {
107
124
  name or not at all -- a taken name refuses the save, rather than saving under the temporary one and failing a
108
125
  rename after. Without a name (a caller that has none to give) it is the temporary one, renamed later.
109
126
  */
110
- const namespace = await namespaceForNewFigure(tx, domain)
127
+ const namespace = await namespaceForNewFigure(tx, domain, chosenNamespace, user)
111
128
  const named = type?.trim()
112
129
  if (named) {
113
130
  const facts = await renameFacts(
@@ -179,7 +196,8 @@ export class FigureMutation {
179
196
  '{type} is not a figure type. use lower-case letters, digits and single hyphens, {min} to {max} characters, starting with a letter.'
180
197
  )
181
198
  }
182
- const namespace = await namespaceForNewFigure(tx, domain)
199
+ assertFigureCategory(figure.category)
200
+ const namespace = await namespaceForNewFigure(tx, domain, figure.namespace, user)
183
201
 
184
202
  // 타입 이름은 도메인 안에서 유일하다. 유니크 인덱스가 있지만 여기서 먼저 본다 —
185
203
  // DB 오류 문구는 저작자에게 아무 말도 해 주지 못한다.
@@ -187,6 +205,9 @@ export class FigureMutation {
187
205
  if (taken) {
188
206
  throw new Error(`타입 이름 '${figure.type}' 은 이미 쓰이고 있다. 만든 뒤에는 고칠 수 없으므로 다른 이름을 고른다`)
189
207
  }
208
+ if (await typeReleasedBefore({ figures: repository, histories: tx.getRepository(FigureHistory) }, domain, figure.type!)) {
209
+ throw new Error(releasedBeforeMessage(figure.type!))
210
+ }
190
211
 
191
212
  // 형식을 어기면 여기서 던진다. 파생값은 서버가 계산한다.
192
213
  const { source, derived, violations } = parseSource(figure.source)
@@ -219,12 +240,7 @@ export class FigureMutation {
219
240
  const { domain, user, tx } = context.state
220
241
  const repository = tx.getRepository(Figure)
221
242
 
222
- const figure = await repository.findOne({
223
- where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id }
224
- })
225
- if (!figure) {
226
- throw new Error(`Figure ${id} 를 찾을 수 없다`)
227
- }
243
+ const figure = await ownFigure(repository, domain, id)
228
244
 
229
245
  /*
230
246
  **그림만 바꾸면 발행이 풀리지 않는다.** 미리보기에서 사람이 돌려 놓은 각도로 찍은 그림이 그렇게 온다.
@@ -264,6 +280,8 @@ export class FigureMutation {
264
280
  행이 다른 이름을 말하고, 발행할 때 어느 쪽이 도면에 적히는지가 갈린다.
265
281
  */
266
282
  if (patch.names !== undefined) assertNames(patch.names)
283
+ /* An app's category vocabulary (G5): a category outside it is refused, and one once set does not change. */
284
+ assertFigureCategory(patch.category, figure.category)
267
285
  /* The picture is kept by its content; the patch's data URL is never written to the row. */
268
286
  const { thumbnail: picturePatch, ...patchRest } = patch
269
287
  const picture = await pictureOf(tx, domain, user, picturePatch, figure)
@@ -360,7 +378,8 @@ export class FigureMutation {
360
378
  * `inspectFigure` 로 미리 물어 이유를 보여 준다.
361
379
  */
362
380
  @Directive('@transaction')
363
- @Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
381
+ /* Releasing, retiring and making public are their own gate (LV figure-server design G6): an author who edits drafts need not be one who sends releases out. */
382
+ @Directive('@privilege(category: "figure-release", privilege: "mutation", domainOwnerGranted: true)')
364
383
  @Mutation(returns => Figure, { description: 'Releases a figure — raises its version and keeps the released state as history.' })
365
384
  async releaseFigure(
366
385
  @Arg('id') id: string,
@@ -371,27 +390,16 @@ export class FigureMutation {
371
390
  const repository = tx.getRepository(Figure)
372
391
  const historyRepository = tx.getRepository(FigureHistory)
373
392
 
374
- const figure = await repository.findOne({
375
- where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id }
376
- })
377
- if (!figure) {
378
- throw new Error(`Figure ${id} 를 찾을 수 없다`)
379
- }
393
+ const figure = await ownFigure(repository, domain, id)
380
394
 
381
395
  if (figure.state === 'released') {
382
396
  throw new Error(`Figure '${figure.type}' 은 이미 발행되어 있다. 고쳐서 다시 내려면 초안으로 되돌린 뒤 발행한다`)
383
397
  }
384
398
 
385
- /* A release promises boards the full name {namespace}/{type}; it is refused while the domain setting disagrees. */
399
+ /* A release promises boards the full name {namespace}/{type}: it goes out only under a namespace the domain owns and has active. */
386
400
  const namespace = namespaceOf(figure)
387
- const current = await namespaceForNewFigure(tx, domain)
388
- if (current !== namespace) {
389
- throw new Refusal(
390
- 'figure-namespace-changed',
391
- { setting: 'figure-namespace', value: current, namespace },
392
- 'the figure namespace of this domain is {namespace} and cannot change. set {setting} back to {namespace}.'
393
- )
394
- }
401
+ await assertReleasableNamespace(tx, domain, namespace, user)
402
+ assertReleasableCategory(figure.category, figure.type)
395
403
 
396
404
  /*
397
405
  ── 게이트는 여기 있어야 한다 ────────────────────────────────────────────
@@ -412,7 +420,7 @@ export class FigureMutation {
412
420
  .limit(1)
413
421
  .getOne()
414
422
 
415
- const inspection = inspectSource(figure.source, released?.source, context.t, figure.type)
423
+ const inspection = inspectSource(figure.source, released?.source, context.t, figure.type, figure.category)
416
424
  if (inspection.blocked) {
417
425
  const reasons = inspection.findings
418
426
  .filter(finding => finding.blocking)
@@ -503,12 +511,24 @@ export class FigureMutation {
503
511
 
504
512
  @Directive('@transaction')
505
513
  @Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
506
- @Mutation(returns => Boolean, { description: 'Soft-deletes a figure.' })
514
+ @Mutation(returns => Boolean, { description: 'Soft-deletes a figure that has never been released. A released figure is refused: it is retired instead.' })
507
515
  async deleteFigure(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<boolean> {
508
516
  const { domain, tx } = context.state
509
517
 
510
- // 소프트 삭제다 — 이미 배치된 도면이 이 타입을 참조할 수 있으므로 행을 지우지
511
- // 않는다. 되살릴 길을 남긴다.
518
+ /*
519
+ A figure once released is not deleted, only retired (LV figure-server design G4, fmsim design 01 D5). Its releases
520
+ have gone out in packages as `ns/type@n`; deleting it would let the same type be made again and numbered from 1,
521
+ and the same reference would then name other content, which an importing server refuses.
522
+ */
523
+ const figure = await tx.getRepository(Figure).findOne({ where: { domain: { id: domain.id }, id } })
524
+ if (figure) {
525
+ const releases = await tx.getRepository(FigureHistory).count({ where: { originalId: figure.id } })
526
+ if (releases > 0 || Number(figure.version) > 0) {
527
+ throw new Refusal('figure-released-not-deletable', { type: figure.type }, '{type} has been released and cannot be deleted.')
528
+ }
529
+ }
530
+
531
+ // 소프트 삭제다 — 행을 지우지 않고 되살릴 길을 남긴다.
512
532
  await tx.getRepository(Figure).softDelete({ domain: { id: domain.id }, id })
513
533
 
514
534
  return true
@@ -519,10 +539,10 @@ export class FigureMutation {
519
539
  *
520
540
  * 표시일 뿐이다. 발행판은 그대로 남고 받는 쪽은 그대로 받는다 — 발행판은 지우지 않는다. 그래서 **발행을 풀지 않는다**:
521
541
  * 행을 엔티티로 저장하지 않고 질의로 고친다. 엔티티로 저장하면 발행된 행이라 판본 구독자가 같은 번호로 판본을 또
522
- * 뜬다. 대신 쓸 것(`supersededBy`)은 같은 도메인(또는 상위)에 있는 다른 type 이어야 한다.
542
+ * 뜬다. 대신 쓸 것(`supersededBy`)은 같은 도메인(또는 상위)에 있는 다른 type 이어야 한다. 퇴역은 소유 도메인의 figure 만 한다.
523
543
  */
524
544
  @Directive('@transaction')
525
- @Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
545
+ @Directive('@privilege(category: "figure-release", privilege: "mutation", domainOwnerGranted: true)')
526
546
  @Mutation(returns => Figure, { description: 'Retires a figure or brings it back. It stays released and served; supersededBy names the type to use instead.' })
527
547
  async setFigureDeprecation(
528
548
  @Arg('id') id: string,
@@ -532,9 +552,9 @@ export class FigureMutation {
532
552
  ): Promise<Figure> {
533
553
  const { domain, tx } = context.state
534
554
  const repository = tx.getRepository(Figure)
555
+ const figure = await ownFigure(repository, domain, id)
556
+ /* What to use instead may be the parent domain's: a domain reads its parent's figures, it writes only its own. */
535
557
  const scope = { id: In([domain.id, domain.parentId].filter(Boolean)) }
536
- const figure = await repository.findOne({ where: { domain: scope, id } })
537
- if (!figure) throw new Error(`Figure ${id} 를 찾을 수 없다`)
538
558
  const instead = deprecated ? supersededBy?.trim() || null : null
539
559
  if (instead) {
540
560
  if (instead === figure.type) throw new Error(`Figure '${figure.type}' 는 자기 자신으로 대신할 수 없다`)
@@ -545,6 +565,28 @@ export class FigureMutation {
545
565
  return await repository.findOneOrFail({ where: { id } })
546
566
  }
547
567
 
568
+ /**
569
+ * Makes a released figure public, or private again (LV figure-server design G2). Public, other domains read its
570
+ * releases, package them and draw it by full name; its draft stays this domain's, and only this domain changes it.
571
+ * Like retiring, it is a mark on the row: no release is written and the release state is not undone.
572
+ */
573
+ @Directive('@transaction')
574
+ @Directive('@privilege(category: "figure-release", privilege: "mutation", domainOwnerGranted: true)')
575
+ @Mutation(returns => Figure, { description: 'Makes a released figure public to other domains, or private again. Its draft is never shown to them.' })
576
+ async setFigurePublic(@Arg('id') id: string, @Arg('public') isPublic: boolean, @Ctx() context: ResolverContext): Promise<Figure> {
577
+ const { domain, tx } = context.state
578
+ const repository = tx.getRepository(Figure)
579
+ const figure = await ownFigure(repository, domain, id)
580
+ if (isPublic) {
581
+ const releases = await tx.getRepository(FigureHistory).count({ where: { originalId: figure.id } })
582
+ if (!releases && !(Number(figure.version) > 0)) {
583
+ throw new Refusal('figure-public-unreleased', { type: figure.type }, '{type} has no release yet: only released figures are made public.')
584
+ }
585
+ }
586
+ await repository.createQueryBuilder().update(Figure).set({ public: isPublic }).where('id = :id', { id }).callListeners(false).execute()
587
+ return await repository.findOneOrFail({ where: { id } })
588
+ }
589
+
548
590
  /**
549
591
  * 저작 보조에게 제안을 시킨다(ADR-0094).
550
592
  *
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Full figure names on the authoring server (ADR-0114): the namespace a figure is created under, the batch door
2
+ * Full figure names on the authoring server (ADR-0114, LV figure-server design G1): the namespaces a domain owns and the one a figure is created under, the batch door
3
3
  * `figureBlueprints`, and the package a board server imports. sqlite, history subscriber on.
4
4
  */
5
5
  import type { DataSource } from 'typeorm'
@@ -18,6 +18,7 @@ let FigureMutation: any
18
18
  let FigureQuery: any
19
19
  let figurePackageOf: any
20
20
  let FigureHistory: any
21
+ let FigureNamespace: any
21
22
 
22
23
  function loadCompiled(modulePath: string): any {
23
24
  const { existsSync } = require('fs')
@@ -31,6 +32,7 @@ function loadCompiled(modulePath: string): any {
31
32
  beforeAll(async () => {
32
33
  Figure = loadCompiled('service/figure/figure').Figure
33
34
  FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
35
+ FigureNamespace = loadCompiled('service/figure/figure-namespace-entity').FigureNamespace
34
36
  const Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
35
37
  ;({ FigureMutation } = loadCompiled('service/figure/figure-mutation'))
36
38
  ;({ FigureQuery } = loadCompiled('service/figure/figure-query'))
@@ -41,7 +43,7 @@ beforeAll(async () => {
41
43
  synchronize: true,
42
44
  dropSchema: true,
43
45
  namingStrategy: new shell.NamingStrategy(),
44
- entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory],
46
+ entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory, FigureNamespace],
45
47
  subscribers: [Subscriber],
46
48
  logging: false
47
49
  })
@@ -62,119 +64,160 @@ const releasable = (label = 'Conveyor') => {
62
64
  }
63
65
 
64
66
  let seq = 0
67
+ /** A domain owning one namespace, its default (or none). Namespaces are unique on the server, so each test names its own. */
65
68
  async function domainWith(namespace: string | null) {
66
69
  const name = `ns-${++seq}`
67
70
  const domain = await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name })
68
71
  if (namespace !== null) {
69
- await dataSource!.getRepository(settingBase.Setting).save({ domain, name: 'figure-namespace', category: 'figure', value: namespace })
72
+ await dataSource!.getRepository(FigureNamespace).save({ domain, name: namespace, state: 'active', isDefault: true })
70
73
  }
71
74
  const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
72
- return { domain, context }
75
+ return { domain, context, namespace: namespace ?? '' }
73
76
  }
77
+ const unique = (stem: string) => `${stem}-${++seq}.hatiolab.com`
74
78
 
75
79
  const creationId = () => require('crypto').randomUUID()
76
- const setNamespace = (domain: any, value: string) =>
77
- dataSource!.getRepository(settingBase.Setting).update({ domain: { id: domain.id }, name: 'figure-namespace' }, { value })
80
+ const save = (context: any, type: string, namespace?: string) =>
81
+ new FigureMutation().saveFigure(releasable(type), context, undefined, undefined, creationId(), undefined, type, undefined, namespace)
78
82
 
79
83
  async function releasedFigure(context: any, type: string) {
80
- const figure = await new FigureMutation().saveFigure(releasable(type), context, undefined, undefined, creationId(), undefined, type)
84
+ const figure = await save(context, type)
81
85
  await new FigureMutation().releaseFigure(figure.id, 'first', context)
82
86
  return figure
83
87
  }
84
88
 
89
+ const resolver = () => new (loadCompiled('service/figure/figure-namespace-resolver').FigureNamespaceResolver)()
90
+
85
91
  describe('the namespace a figure is created under', () => {
86
- it('is copied from the domain setting onto a new figure', async () => {
87
- const { context } = await domainWith('hatiolab.com')
88
- const figure = await new FigureMutation().saveFigure(releasable(), context, undefined, undefined, creationId(), undefined, 'conveyor')
89
- expect(figure.namespace).toBe('hatiolab.com')
90
- expect(figure.type).toBe('conveyor')
91
- })
92
-
93
- it('with no setting, or an empty one, is the subdomain', async () => {
94
- for (const value of [null, '', ' ']) {
95
- const { domain, context } = await domainWith(value)
96
- const figure = await new FigureMutation().saveFigure(releasable(), context, undefined, undefined, creationId(), undefined, 'conveyor')
97
- expect(figure.namespace).toBe(domain.subdomain)
98
- }
92
+ it("is the domain's default", async () => {
93
+ const ns = unique('default')
94
+ const { context } = await domainWith(ns)
95
+ const figure = await save(context, 'conveyor')
96
+ expect([figure.namespace, figure.type]).toEqual([ns, 'conveyor'])
99
97
  })
100
98
 
101
- it('is refused by name when the setting is not a namespace, or there is none and the subdomain is not one either', async () => {
102
- const { context } = await domainWith('Hatiolab.COM')
103
- await expect(
104
- new FigureMutation().saveFigure(releasable(), context, undefined, undefined, creationId(), undefined, 'conveyor')
105
- ).rejects.toMatchObject({ code: 'figure-namespace-missing' })
99
+ it('in a domain owning none, the first figure makes the subdomain its default namespace', async () => {
100
+ const { domain, context } = await domainWith(null)
101
+ const figure = await save(context, 'conveyor')
102
+ expect(figure.namespace).toBe(domain.subdomain)
103
+ expect((await resolver().figureNamespaces(context)).map((n: any) => [n.name, n.isDefault, n.state])).toEqual([[domain.subdomain, true, 'active']])
104
+ })
105
+
106
+ it('is refused by name when the domain owns none and its subdomain is not a namespace', async () => {
106
107
  const odd = await dataSource!.getRepository(shell.Domain).save({ name: `odd-${++seq}`, subdomain: 'Odd_Sub' })
107
- await expect(
108
- new FigureMutation().saveFigure(releasable(), { state: { domain: odd, user: undefined, tx: dataSource!.manager } }, undefined, undefined, creationId(), undefined, 'conveyor')
109
- ).rejects.toMatchObject({ code: 'figure-namespace-missing' })
108
+ await expect(save({ state: { domain: odd, user: undefined, tx: dataSource!.manager } }, 'conveyor')).rejects.toMatchObject({ code: 'figure-namespace-missing' })
110
109
  })
111
110
 
112
- it('cannot change: a new figure or a release is refused while the setting disagrees with the figures already made', async () => {
113
- const { domain, context } = await domainWith('hatiolab.com')
114
- const draft = await new FigureMutation().saveFigure(releasable(), context, undefined, undefined, creationId(), undefined, 'conveyor')
115
- await setNamespace(domain, 'other.com')
116
- await expect(
117
- new FigureMutation().saveFigure(releasable(), context, undefined, undefined, creationId(), undefined, 'lifter')
118
- ).rejects.toMatchObject({ code: 'figure-namespace-changed' })
119
- await expect(new FigureMutation().releaseFigure(draft.id, 'c', context)).rejects.toMatchObject({ code: 'figure-namespace-changed' })
111
+ it('may be another namespace the domain owns, named when it is created', async () => {
112
+ const { context } = await domainWith(unique('first'))
113
+ const second = unique('second')
114
+ await resolver().createFigureNamespace(second, context)
115
+ expect((await save(context, 'lifter', second)).namespace).toBe(second)
116
+ const created = await new FigureMutation().createFigure({ type: 'shuttle', name: 's', source: releasable(), namespace: second }, context)
117
+ expect(created.figure.namespace).toBe(second)
118
+ })
119
+
120
+ it("is refused when named and not the domain's own, or suspended", async () => {
121
+ const { context } = await domainWith(unique('mine'))
122
+ const theirs = unique('theirs')
123
+ await domainWith(theirs)
124
+ await expect(save(context, 'conveyor', theirs)).rejects.toMatchObject({ code: 'figure-namespace-not-owned' })
125
+ const resting = unique('resting')
126
+ await resolver().createFigureNamespace(resting, context)
127
+ await resolver().setFigureNamespaceState(resting, 'suspended', context)
128
+ await expect(save(context, 'conveyor', resting)).rejects.toMatchObject({ code: 'figure-namespace-suspended' })
129
+ })
120
130
 
121
- await setNamespace(domain, 'hatiolab.com')
131
+ it('a release goes out only under an active namespace of the domain', async () => {
132
+ const { context } = await domainWith(unique('main'))
133
+ const side = unique('side')
134
+ await resolver().createFigureNamespace(side, context)
135
+ const draft = await save(context, 'conveyor', side)
136
+ await resolver().setFigureNamespaceState(side, 'suspended', context)
137
+ await expect(new FigureMutation().releaseFigure(draft.id, 'c', context)).rejects.toMatchObject({ code: 'figure-namespace-suspended' })
138
+ await resolver().setFigureNamespaceState(side, 'active', context)
122
139
  await expect(new FigureMutation().releaseFigure(draft.id, 'c', context)).resolves.toMatchObject({ version: 1 })
123
140
  })
124
141
 
125
142
  it('refuses an upper-case type when creating directly', async () => {
126
- const { context } = await domainWith('hatiolab.com')
143
+ const { context } = await domainWith(unique('upper'))
127
144
  await expect(
128
145
  new FigureMutation().createFigure({ type: 'CONVEYOR', name: 'c', source: releasable() }, context)
129
146
  ).rejects.toMatchObject({ code: 'figure-type-malformed' })
130
147
  })
131
148
  })
132
149
 
133
- describe('the namespace setting, as the setting screen reads and writes it', () => {
134
- const resolver = () => new (loadCompiled('service/figure/figure-namespace-resolver').FigureNamespaceResolver)()
150
+ describe('the namespaces a domain owns', () => {
151
+ it('adds one: not a namespace, or another domain\'s, is refused; the first is the default and the list starts with it', async () => {
152
+ const { context } = await domainWith(null)
153
+ await expect(resolver().createFigureNamespace('Hatiolab.COM', context)).rejects.toMatchObject({ code: 'figure-namespace-invalid' })
154
+ const taken = unique('taken')
155
+ await domainWith(taken)
156
+ await expect(resolver().createFigureNamespace(taken, context)).rejects.toMatchObject({ code: 'figure-namespace-taken' })
157
+ const a = unique('a')
158
+ const b = unique('b')
159
+ expect(await resolver().createFigureNamespace(` ${a} `, context, 'first one')).toMatchObject({ name: a, isDefault: true, state: 'active', description: 'first one' })
160
+ expect(await resolver().createFigureNamespace(b, context)).toMatchObject({ name: b, isDefault: false })
161
+ expect((await resolver().figureNamespaces(context)).map((n: any) => n.name)).toEqual([a, b])
162
+ })
135
163
 
136
- it('reads the subdomain when nothing is written, and is not fixed before a figure is made', async () => {
137
- const { domain, context } = await domainWith(null)
138
- expect(await resolver().figureNamespace(context)).toEqual({ namespace: domain.subdomain, written: '', subdomain: domain.subdomain, fixed: false })
164
+ it('moves the default when a new one asks for it, or when another is made the default', async () => {
165
+ const a = unique('a')
166
+ const { context } = await domainWith(a)
167
+ const b = unique('b')
168
+ await resolver().createFigureNamespace(b, context, undefined, true)
169
+ expect((await resolver().figureNamespaces(context)).map((n: any) => [n.name, n.isDefault])).toEqual([[b, true], [a, false]])
170
+ await resolver().setDefaultFigureNamespace(a, context)
171
+ expect((await resolver().figureNamespaces(context)).map((n: any) => [n.name, n.isDefault])).toEqual([[a, true], [b, false]])
172
+ expect((await save(context, 'conveyor')).namespace).toBe(a)
139
173
  })
140
174
 
141
- it('writes a namespace, and empty goes back to the subdomain; a new figure takes what it says', async () => {
142
- const { domain, context } = await domainWith(null)
143
- expect(await resolver().setFigureNamespace(' hatiolab.com ', context)).toMatchObject({ namespace: 'hatiolab.com', written: 'hatiolab.com', fixed: false })
144
- expect(await resolver().setFigureNamespace('', context)).toMatchObject({ namespace: domain.subdomain, written: '', fixed: false })
145
- await resolver().setFigureNamespace('hatiolab.com', context)
146
- const figure = await new FigureMutation().saveFigure(releasable(), context, undefined, undefined, creationId(), undefined, 'conveyor')
147
- expect(figure.namespace).toBe('hatiolab.com')
148
- expect(await resolver().figureNamespace(context)).toMatchObject({ namespace: 'hatiolab.com', fixed: true })
149
- })
150
-
151
- it('refuses what is not a namespace, and another namespace once figures carry one; the setting is left as it was', async () => {
152
- const { context } = await domainWith('hatiolab.com')
153
- await expect(resolver().setFigureNamespace('Hatiolab.COM', context)).rejects.toMatchObject({ code: 'figure-namespace-invalid' })
154
- await new FigureMutation().saveFigure(releasable(), context, undefined, undefined, creationId(), undefined, 'conveyor')
155
- await expect(resolver().setFigureNamespace('other.com', context)).rejects.toMatchObject({ code: 'figure-namespace-changed' })
156
- expect(await resolver().figureNamespace(context)).toMatchObject({ namespace: 'hatiolab.com', written: 'hatiolab.com', fixed: true })
157
- /* The same one again is not a change. */
158
- await expect(resolver().setFigureNamespace('hatiolab.com', context)).resolves.toMatchObject({ namespace: 'hatiolab.com' })
175
+ it('suspends and activates one; the default is not suspended, nor made default while suspended; another domain\'s is not touched', async () => {
176
+ const a = unique('a')
177
+ const { context } = await domainWith(a)
178
+ const b = unique('b')
179
+ await resolver().createFigureNamespace(b, context)
180
+ await expect(resolver().setFigureNamespaceState(a, 'suspended', context)).rejects.toMatchObject({ code: 'figure-namespace-default-suspended' })
181
+ await expect(resolver().setFigureNamespaceState(b, 'resting', context)).rejects.toMatchObject({ code: 'figure-namespace-state' })
182
+ expect(await resolver().setFigureNamespaceState(b, 'suspended', context)).toMatchObject({ name: b, state: 'suspended' })
183
+ await expect(resolver().setDefaultFigureNamespace(b, context)).rejects.toMatchObject({ code: 'figure-namespace-suspended' })
184
+ const theirs = unique('theirs')
185
+ await domainWith(theirs)
186
+ await expect(resolver().setFigureNamespaceState(theirs, 'suspended', context)).rejects.toMatchObject({ code: 'figure-namespace-not-owned' })
187
+ await expect(resolver().setDefaultFigureNamespace(theirs, context)).rejects.toMatchObject({ code: 'figure-namespace-not-owned' })
188
+ })
189
+
190
+ it('reading them is authoring; changing them is figure-namespace:mutation', () => {
191
+ const { readFileSync } = require('fs')
192
+ const { join } = require('path')
193
+ const source = readFileSync(join(__dirname, 'figure-namespace-resolver.ts'), 'utf-8')
194
+ const declarationOf = (method: string) => {
195
+ const preceding = source.slice(0, source.indexOf(`async ${method}(`))
196
+ return preceding.slice(preceding.lastIndexOf("@Directive('@privilege("))
197
+ }
198
+ expect(declarationOf('figureNamespaces')).toContain('category: "figure", privilege: "query"')
199
+ for (const method of ['createFigureNamespace', 'setDefaultFigureNamespace', 'setFigureNamespaceState'])
200
+ expect(declarationOf(method)).toContain('category: "figure-namespace", privilege: "mutation"')
159
201
  })
160
202
  })
161
203
 
162
204
  describe('figureBlueprints answers each full name, an item or a refusal', () => {
163
205
  it('gives released figures by full name and refuses the rest by cause, none taking another away', async () => {
164
- const { context } = await domainWith('hatiolab.com')
206
+ const ns = unique('blue')
207
+ const { context } = await domainWith(ns)
165
208
  await releasedFigure(context, 'conveyor')
166
- await new FigureMutation().saveFigure(releasable(), context, undefined, undefined, creationId(), undefined, 'draft-only')
209
+ await save(context, 'draft-only')
167
210
 
168
211
  const answer = await new FigureQuery().figureBlueprints(
169
- ['hatiolab.com/conveyor', 'hatiolab.com/draft-only', 'other.com/conveyor', 'CONVEYOR', 'hatiolab.com/conveyor'],
212
+ [`${ns}/conveyor`, `${ns}/draft-only`, 'other.com/conveyor', 'CONVEYOR', `${ns}/conveyor`],
170
213
  context
171
214
  )
172
215
 
173
216
  expect(Object.keys(answer).sort()).toEqual(['items', 'refused'])
174
- expect(answer.items.map((item: any) => [item.type, item.ref])).toEqual([['hatiolab.com/conveyor', 'hatiolab.com/conveyor@1']])
217
+ expect(answer.items.map((item: any) => [item.type, item.ref])).toEqual([[`${ns}/conveyor`, `${ns}/conveyor@1`]])
175
218
  expect(answer.items[0].asset.version).toBe('figure-asset-1')
176
219
  expect(answer.refused.map((r: any) => [r.type, r.code])).toEqual([
177
- ['hatiolab.com/draft-only', 'figure-not-released'],
220
+ [`${ns}/draft-only`, 'figure-not-released'],
178
221
  ['other.com/conveyor', 'figure-not-released'],
179
222
  ['CONVEYOR', 'figure-name-malformed']
180
223
  ])
@@ -185,17 +228,18 @@ describe('the figure package', () => {
185
228
  const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
186
229
 
187
230
  it('carries the release a board server reads back whole, and names the server it came from', async () => {
188
- const { domain, context } = await domainWith('hatiolab.com')
231
+ const ns = unique('pkg')
232
+ const { domain, context } = await domainWith(ns)
189
233
  const figure = await releasedFigure(context, 'trailer-22')
190
234
 
191
235
  const { name, bytes } = await figurePackageOf({
192
- refs: ['hatiolab.com/trailer-22@1'],
236
+ refs: [`${ns}/trailer-22@1`],
193
237
  domain,
194
238
  exportedBy: 'author',
195
239
  sourceUrl: 'https://figure.hatiolab.com',
196
240
  files: { put: async () => ({}), send: async () => {}, read: async () => Buffer.alloc(0) }
197
241
  })
198
- expect(name).toBe('hatiolab.com_trailer-22_1.figpkg')
242
+ expect(name).toBe(`${ns}_trailer-22_1.figpkg`)
199
243
 
200
244
  const zip = await JSZip.loadAsync(bytes)
201
245
  const files = new Map<string, Uint8Array>()
@@ -205,13 +249,14 @@ describe('the figure package', () => {
205
249
  expect(read.problems).toEqual([])
206
250
  expect(read.refused).toEqual([])
207
251
  expect(read.manifest.source).toEqual({ url: 'https://figure.hatiolab.com', domain: domain.subdomain })
208
- expect(read.releases.map((r: any) => r.ref)).toEqual(['hatiolab.com/trailer-22@1'])
252
+ expect(read.releases.map((r: any) => r.ref)).toEqual([`${ns}/trailer-22@1`])
209
253
  const stored = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
210
254
  expect(read.releases[0].source).toBe(stored.source)
211
255
  })
212
256
 
213
257
  it('dates each release when it was released, not when the figure was made, and carries its note', async () => {
214
- const { domain, context } = await domainWith('hatiolab.com')
258
+ const ns = unique('pkg')
259
+ const { domain, context } = await domainWith(ns)
215
260
  const figure = await releasedFigure(context, 'trailer-23')
216
261
  /* sqlite keeps these times to the second */
217
262
  await new Promise(resolve => setTimeout(resolve, 1100))
@@ -220,7 +265,7 @@ describe('the figure package', () => {
220
265
  await new FigureMutation().releaseFigure(figure.id, 'wider body', context)
221
266
 
222
267
  const { bytes } = await figurePackageOf({
223
- refs: ['hatiolab.com/trailer-23@1', 'hatiolab.com/trailer-23@2'],
268
+ refs: [`${ns}/trailer-23@1`, `${ns}/trailer-23@2`],
224
269
  domain,
225
270
  exportedBy: 'author',
226
271
  sourceUrl: 'x',
@@ -239,11 +284,32 @@ describe('the figure package', () => {
239
284
  })
240
285
 
241
286
  it('refuses a release this domain cannot read, and a malformed reference', async () => {
242
- const { domain, context } = await domainWith('hatiolab.com')
287
+ const ns = unique('pkg')
288
+ const { domain, context } = await domainWith(ns)
243
289
  await releasedFigure(context, 'conveyor')
244
290
  const ask = (refs: string[]) => figurePackageOf({ refs, domain, exportedBy: null, sourceUrl: 'x' })
245
- await expect(ask(['hatiolab.com/conveyor@2'])).rejects.toMatchObject({ code: 'figure-package-release-not-found' })
291
+ await expect(ask([`${ns}/conveyor@2`])).rejects.toMatchObject({ code: 'figure-package-release-not-found' })
246
292
  await expect(ask(['other.com/conveyor@1'])).rejects.toMatchObject({ code: 'figure-package-release-not-found' })
247
293
  await expect(ask(['CONVEYOR@1'])).rejects.toMatchObject({ code: 'figure-ref-malformed' })
248
294
  })
249
295
  })
296
+
297
+ /*
298
+ * A domain whose figures were made before namespaces were rows (the setting days): it owns no namespace row, and its
299
+ * figures carry its subdomain. Releasing one is the first need of a namespace, so the subdomain becomes its first, as
300
+ * creating a figure does -- seen on :3300 after 10.1.179, where the system domain owned none.
301
+ */
302
+ describe('a domain that owns no namespace row yet', () => {
303
+ it('releases a figure carrying its subdomain, and the subdomain becomes its default', async () => {
304
+ const { domain, context } = await domainWith(null)
305
+ const figure = await dataSource!.getRepository(Figure).save({ namespace: domain.subdomain, type: 'older', name: 'older', source: releasable(), state: 'draft', version: 0, domain })
306
+ await expect(new FigureMutation().releaseFigure(figure.id, 'first', context)).resolves.toMatchObject({ version: 1 })
307
+ expect((await resolver().figureNamespaces(context)).map((n: any) => [n.name, n.isDefault])).toEqual([[domain.subdomain, true]])
308
+ })
309
+
310
+ it('a figure carrying another namespace than its subdomain is still refused by name', async () => {
311
+ const { domain, context } = await domainWith(null)
312
+ const figure = await dataSource!.getRepository(Figure).save({ namespace: unique('elsewhere'), type: 'stray', name: 'stray', source: releasable(), state: 'draft', version: 0, domain })
313
+ await expect(new FigureMutation().releaseFigure(figure.id, 'first', context)).rejects.toMatchObject({ code: 'figure-namespace-not-owned' })
314
+ })
315
+ })