@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,67 @@
1
+ import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, RelationId, UpdateDateColumn } from 'typeorm'
2
+ import { Field, ID, ObjectType } from 'type-graphql'
3
+
4
+ import { User } from '@things-factory/auth-base'
5
+ import { Domain } from '@things-factory/shell'
6
+
7
+ /**
8
+ * A namespace a domain owns (LV figure-server design G1, ADR-0114). The first half of a figure's full name
9
+ * `{namespace}/{type}`: one namespace belongs to one domain, unique across the server, and its name never changes, so a
10
+ * full name points at one figure whichever domain asks. A domain may own several and creates figures under its default
11
+ * one unless it names another. A suspended namespace takes no new figure and no release; its released figures stay as
12
+ * they are. There is no way to delete one.
13
+ */
14
+ @Entity()
15
+ @Index('ix_figure_namespace_0', (namespace: FigureNamespace) => [namespace.name], { unique: true })
16
+ @Index('ix_figure_namespace_1', (namespace: FigureNamespace) => [namespace.domain])
17
+ @ObjectType({ description: 'A figure namespace a domain owns: the first half of the full name {namespace}/{type}.' })
18
+ export class FigureNamespace {
19
+ @PrimaryGeneratedColumn('uuid')
20
+ @Field(type => ID, { description: 'Unique identifier of the namespace.' })
21
+ readonly id?: string
22
+
23
+ @ManyToOne(type => Domain)
24
+ @Field(type => Domain, { nullable: true, description: 'The domain that owns the namespace.' })
25
+ domain?: Domain
26
+
27
+ @RelationId((namespace: FigureNamespace) => namespace.domain)
28
+ domainId?: string
29
+
30
+ @Column()
31
+ @Field({ description: 'The namespace, in lower-case domain-name form. Unique across the server; never changes.' })
32
+ name?: string
33
+
34
+ @Column({ nullable: true })
35
+ @Field({ nullable: true, description: 'What the namespace is for.' })
36
+ description?: string
37
+
38
+ @Column({ default: 'active' })
39
+ @Field({ description: "'active', or 'suspended': takes no new figure and no release." })
40
+ state?: 'active' | 'suspended'
41
+
42
+ @Column({ default: false })
43
+ @Field({ description: 'The namespace new figures of the domain are created under when none is named. One per domain.' })
44
+ isDefault?: boolean
45
+
46
+ @CreateDateColumn()
47
+ @Field({ nullable: true, description: 'When the namespace was created.' })
48
+ createdAt?: Date
49
+
50
+ @UpdateDateColumn()
51
+ @Field({ nullable: true, description: 'When the namespace was last changed.' })
52
+ updatedAt?: Date
53
+
54
+ @ManyToOne(type => User, { nullable: true })
55
+ @Field(type => User, { nullable: true, description: 'Who created the namespace.' })
56
+ creator?: User
57
+
58
+ @RelationId((namespace: FigureNamespace) => namespace.creator)
59
+ creatorId?: string
60
+
61
+ @ManyToOne(type => User, { nullable: true })
62
+ @Field(type => User, { nullable: true, description: 'Who last changed the namespace.' })
63
+ updater?: User
64
+
65
+ @RelationId((namespace: FigureNamespace) => namespace.updater)
66
+ updaterId?: string
67
+ }
@@ -1,41 +1,54 @@
1
- import { Arg, Ctx, Directive, Field, Mutation, ObjectType, Query, Resolver, UseMiddleware } from 'type-graphql'
1
+ import { Arg, Ctx, Directive, Mutation, Query, Resolver, UseMiddleware } from 'type-graphql'
2
2
 
3
3
  import { getDataSource, SpeakRefusals } from '@things-factory/shell'
4
4
 
5
- import { figureNamespaceStateOf, setFigureNamespace } from './figure-namespace.js'
5
+ import { FigureNamespace } from './figure-namespace-entity.js'
6
+ import { createFigureNamespace, namespacesOf, setDefaultFigureNamespace, setFigureNamespaceState } from './figure-namespace.js'
6
7
 
7
- @ObjectType({ description: "A domain's figure namespace, as the setting screen shows it (ADR-0114)." })
8
- export class FigureNamespace {
9
- @Field({ nullable: true, description: 'The namespace new figures are created under; null when neither the setting nor the subdomain is one.' })
10
- namespace?: string
11
-
12
- @Field({ description: 'The figure-namespace setting as written; empty when the subdomain is used.' })
13
- written: string
14
-
15
- @Field({ description: "The domain's subdomain, used when the setting is empty." })
16
- subdomain: string
17
-
18
- @Field({ description: "True once the domain's figures carry the namespace: it cannot be set any more." })
19
- fixed: boolean
20
- }
21
-
22
- @Resolver()
8
+ /*
9
+ * The namespaces a domain owns (LV figure-server design G1). Reading them is part of authoring; adding one, choosing
10
+ * the default and suspending one are their own gate, figure-namespace:mutation.
11
+ */
12
+ @Resolver(FigureNamespace)
23
13
  @UseMiddleware(SpeakRefusals)
24
14
  export class FigureNamespaceResolver {
25
15
  @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
26
- @Query(returns => FigureNamespace, { description: "The domain's figure namespace and whether it can still be set." })
27
- async figureNamespace(@Ctx() context: ResolverContext): Promise<FigureNamespace> {
16
+ @Query(returns => [FigureNamespace], { description: "The domain's figure namespaces, its default first. None until it adds one or makes its first figure." })
17
+ async figureNamespaces(@Ctx() context: ResolverContext): Promise<FigureNamespace[]> {
28
18
  const { domain, tx } = context.state
29
- return (await figureNamespaceStateOf(tx ?? getDataSource().manager, domain)) as FigureNamespace
19
+ return namespacesOf(tx ?? getDataSource().manager, domain)
30
20
  }
31
21
 
32
22
  @Directive('@transaction')
33
- @Directive('@privilege(category: "setting", privilege: "mutation", domainOwnerGranted: true)')
23
+ @Directive('@privilege(category: "figure-namespace", privilege: "mutation", domainOwnerGranted: true)')
34
24
  @Mutation(returns => FigureNamespace, {
35
- description: "Sets the domain's figure namespace. Empty takes the subdomain. Refused when it is not a namespace, or when the domain's figures already carry another."
25
+ description: 'Adds a figure namespace to the domain. Refused when it is not a namespace or another domain has it. The first is the default.'
36
26
  })
37
- async setFigureNamespace(@Arg('namespace') namespace: string, @Ctx() context: ResolverContext): Promise<FigureNamespace> {
38
- const { domain, tx } = context.state
39
- return (await setFigureNamespace(tx, domain, namespace)) as FigureNamespace
27
+ async createFigureNamespace(
28
+ @Arg('name') name: string,
29
+ @Ctx() context: ResolverContext,
30
+ @Arg('description', { nullable: true }) description?: string,
31
+ @Arg('isDefault', { nullable: true, description: 'Make it the default namespace of the domain.' }) isDefault?: boolean
32
+ ): Promise<FigureNamespace> {
33
+ const { domain, user, tx } = context.state
34
+ return createFigureNamespace(tx, domain, user, { name, description, isDefault })
35
+ }
36
+
37
+ @Directive('@transaction')
38
+ @Directive('@privilege(category: "figure-namespace", privilege: "mutation", domainOwnerGranted: true)')
39
+ @Mutation(returns => FigureNamespace, { description: 'Makes one of the domain\'s active namespaces the default new figures are created under.' })
40
+ async setDefaultFigureNamespace(@Arg('name') name: string, @Ctx() context: ResolverContext): Promise<FigureNamespace> {
41
+ const { domain, user, tx } = context.state
42
+ return setDefaultFigureNamespace(tx, domain, user, name)
43
+ }
44
+
45
+ @Directive('@transaction')
46
+ @Directive('@privilege(category: "figure-namespace", privilege: "mutation", domainOwnerGranted: true)')
47
+ @Mutation(returns => FigureNamespace, {
48
+ description: "Suspends one of the domain's namespaces or makes it active again. A suspended namespace takes no new figure and no release; the default is not suspended."
49
+ })
50
+ async setFigureNamespaceState(@Arg('name') name: string, @Arg('state') state: string, @Ctx() context: ResolverContext): Promise<FigureNamespace> {
51
+ const { domain, user, tx } = context.state
52
+ return setFigureNamespaceState(tx, domain, user, name, state as 'active' | 'suspended')
40
53
  }
41
54
  }
@@ -1,115 +1,156 @@
1
- import { EntityManager, IsNull, Not } from 'typeorm'
1
+ import { EntityManager } from 'typeorm'
2
2
 
3
3
  import { isFigureNamespace } from '@hatiolab/figure-model'
4
4
  import { Domain, Refusal } from '@things-factory/shell'
5
- import { Setting } from '@things-factory/setting-base'
6
5
 
7
6
  import { Figure } from './figure.js'
7
+ import { FigureNamespace } from './figure-namespace-entity.js'
8
8
 
9
- /** The domain setting that names the namespace this domain's figures are published under (ADR-0114). */
10
- export const FIGURE_NAMESPACE_SETTING = 'figure-namespace'
9
+ /*
10
+ * The namespaces a domain owns (LV figure-server design G1, ADR-0114). A full name `{namespace}/{type}` points at one
11
+ * figure on the whole server because a namespace belongs to one domain and is unique across the server. A domain that
12
+ * owns none gets its subdomain as its first, default namespace when it first needs one (user, 2026-10-13: 「기본적으로는
13
+ * subdomain 명을 사용하면 되는데」).
14
+ */
15
+
16
+ type Actor = { id?: string } | undefined
17
+
18
+ const ORDER = { isDefault: 'DESC', name: 'ASC' } as const
19
+
20
+ /** The domain's namespaces, its default first. */
21
+ export async function namespacesOf(tx: EntityManager, domain: { id: string }): Promise<FigureNamespace[]> {
22
+ return tx.getRepository(FigureNamespace).find({ where: { domain: { id: domain.id } }, order: ORDER })
23
+ }
11
24
 
12
25
  /**
13
- * The namespace a new figure is created under.
14
- *
15
- * It is read from the domain setting and copied onto the figure, so the full name `{namespace}/{type}` a board stores
16
- * never moves. A domain with no setting, or an empty one, takes its subdomain (user, 2026-10-13: 「기본적으로는
17
- * subdomain 명을 사용하면 되는데」) -- refused only when the subdomain is not a namespace either. A value written in
18
- * the setting is used as it is, and refused when it is not a namespace. Either way it is set once: a value that differs
19
- * from the namespace this domain's figures already carry is refused, so editing the setting cannot rename anything.
26
+ * The namespace a new figure is created under: the one named, when it is the domain's own and active; otherwise the
27
+ * domain's default, made from the subdomain when the domain owns none yet.
20
28
  */
21
- export async function namespaceForNewFigure(tx: EntityManager, domain: Domain): Promise<string> {
22
- const namespace = await chosenNamespace(tx, domain)
23
- if (!isFigureNamespace(namespace)) {
24
- throw new Refusal(
25
- 'figure-namespace-missing',
26
- { setting: FIGURE_NAMESPACE_SETTING, value: namespace },
27
- 'this domain has no valid figure namespace. set {setting} first, for example hatiolab.com.'
28
- )
29
+ export async function namespaceForNewFigure(tx: EntityManager, domain: Domain, chosen?: string | null, user?: Actor): Promise<string> {
30
+ const named = chosen?.trim()
31
+ if (named) {
32
+ return (await activeOwn(tx, domain, named)).name!
29
33
  }
30
- await assertNamespaceUnchanged(tx, domain, namespace)
31
- return namespace
34
+ const owned = await namespacesOf(tx, domain)
35
+ const fallback = owned.length ? owned.find(namespace => namespace.isDefault) : await firstNamespace(tx, domain, user)
36
+ if (!fallback) {
37
+ throw new Refusal('figure-namespace-missing', { domain: domain.subdomain ?? '' }, 'this domain has no default figure namespace. add one first.')
38
+ }
39
+ if (fallback.state !== 'active') {
40
+ throw new Refusal('figure-namespace-suspended', { namespace: fallback.name! }, 'the figure namespace {namespace} is suspended.')
41
+ }
42
+ return fallback.name!
32
43
  }
33
44
 
34
- /** Refuses when the domain's figures already carry another namespace than the setting now says. */
35
- export async function assertNamespaceUnchanged(tx: EntityManager, domain: Domain, namespace: string): Promise<void> {
36
- const other = await tx.getRepository(Figure).findOne({
37
- where: { domain: { id: domain.id }, namespace: Not(namespace) }
38
- })
39
- if (other?.namespace) {
40
- throw new Refusal(
41
- 'figure-namespace-changed',
42
- { setting: FIGURE_NAMESPACE_SETTING, value: namespace, namespace: other.namespace },
43
- 'the figure namespace of this domain is {namespace} and cannot change. set {setting} back to {namespace}.'
44
- )
45
+ /**
46
+ * A release promises boards `{namespace}/{type}`: it goes out only under a namespace the domain owns and has active. A
47
+ * domain that owns none yet -- its figures were made before namespaces were rows -- gets its subdomain as its first,
48
+ * as creating a figure does; a figure carrying another namespace is still refused by name.
49
+ */
50
+ export async function assertReleasableNamespace(tx: EntityManager, domain: Domain, namespace: string, user?: Actor): Promise<void> {
51
+ if ((await tx.getRepository(FigureNamespace).count({ where: { domain: { id: domain.id } } })) === 0) {
52
+ await firstNamespace(tx, domain, user)
45
53
  }
54
+ await activeOwn(tx, domain, namespace)
46
55
  }
47
56
 
48
57
  /** The namespace a figure already carries, refused when it has none (a figure made before namespaces). */
49
58
  export function namespaceOf(figure: Figure): string {
50
59
  if (!figure.namespace) {
60
+ throw new Refusal('figure-namespace-missing', { domain: '' }, 'this figure has no namespace. run the figure name migration first.')
61
+ }
62
+ return figure.namespace
63
+ }
64
+
65
+ /** Adds a namespace to the domain. The domain's first is its default; `isDefault` makes another the default. */
66
+ export async function createFigureNamespace(
67
+ tx: EntityManager,
68
+ domain: Domain,
69
+ user: Actor,
70
+ input: { name: string; description?: string | null; isDefault?: boolean | null }
71
+ ): Promise<FigureNamespace> {
72
+ const name = (input.name ?? '').trim()
73
+ if (!isFigureNamespace(name)) {
51
74
  throw new Refusal(
52
- 'figure-namespace-missing',
53
- { setting: FIGURE_NAMESPACE_SETTING, value: '' },
54
- 'this figure has no namespace. run the figure name migration first.'
75
+ 'figure-namespace-invalid',
76
+ { value: name },
77
+ '{value} is not a figure namespace. use lower-case letters, digits, dots and hyphens, for example hatiolab.com.'
55
78
  )
56
79
  }
57
- return figure.namespace
80
+ const repository = tx.getRepository(FigureNamespace)
81
+ if (await repository.findOne({ where: { name } })) {
82
+ throw new Refusal('figure-namespace-taken', { namespace: name }, 'the figure namespace {namespace} is already taken on this server.')
83
+ }
84
+ const first = (await repository.count({ where: { domain: { id: domain.id } } })) === 0
85
+ const isDefault = first || !!input.isDefault
86
+ if (isDefault && !first) await clearDefault(tx, domain)
87
+ const saved = await repository.save({
88
+ domain,
89
+ name,
90
+ description: input.description?.trim() || null,
91
+ state: 'active',
92
+ isDefault,
93
+ creator: user as any,
94
+ updater: user as any
95
+ })
96
+ return repository.findOneOrFail({ where: { id: saved.id } })
58
97
  }
59
98
 
60
- /** What the setting says, or the subdomain when it says nothing: the namespace new figures are created under. */
61
- async function chosenNamespace(tx: EntityManager, domain: Domain): Promise<string> {
62
- return (await writtenNamespace(tx, domain)) || (domain.subdomain ?? '').trim()
99
+ /** Makes one of the domain's active namespaces its default. */
100
+ export async function setDefaultFigureNamespace(tx: EntityManager, domain: Domain, user: Actor, name: string): Promise<FigureNamespace> {
101
+ const namespace = await activeOwn(tx, domain, name)
102
+ await clearDefault(tx, domain)
103
+ await tx.getRepository(FigureNamespace).update({ id: namespace.id }, { isDefault: true, updater: user as any })
104
+ return tx.getRepository(FigureNamespace).findOneOrFail({ where: { id: namespace.id } })
63
105
  }
64
106
 
65
- async function writtenNamespace(tx: EntityManager, domain: Domain): Promise<string> {
66
- const setting = await tx.getRepository(Setting).findOne({
67
- where: { domain: { id: domain.id }, name: FIGURE_NAMESPACE_SETTING }
68
- })
69
- return setting?.value?.trim() ?? ''
107
+ /** Suspends a namespace or makes it active again. The default namespace is not suspended: make another the default first. */
108
+ export async function setFigureNamespaceState(
109
+ tx: EntityManager,
110
+ domain: Domain,
111
+ user: Actor,
112
+ name: string,
113
+ state: 'active' | 'suspended'
114
+ ): Promise<FigureNamespace> {
115
+ if (state !== 'active' && state !== 'suspended') {
116
+ throw new Refusal('figure-namespace-state', { state: String(state) }, '{state} is not a namespace state. use active or suspended.')
117
+ }
118
+ const namespace = await own(tx, domain, name)
119
+ if (state === 'suspended' && namespace.isDefault) {
120
+ throw new Refusal('figure-namespace-default-suspended', { namespace: namespace.name! }, 'the default namespace {namespace} cannot be suspended. make another the default first.')
121
+ }
122
+ await tx.getRepository(FigureNamespace).update({ id: namespace.id }, { state, updater: user as any })
123
+ return tx.getRepository(FigureNamespace).findOneOrFail({ where: { id: namespace.id } })
70
124
  }
71
125
 
72
- /** A domain's figure namespace as the setting screen shows it. */
73
- export interface FigureNamespaceState {
74
- /** What new figures get, or null when neither the setting nor the subdomain is a namespace. */
75
- namespace: string | null
76
- /** The setting as written; empty when the subdomain is used. */
77
- written: string
78
- subdomain: string
79
- /** The domain's figures already carry the namespace, so it cannot be set any more. */
80
- fixed: boolean
126
+ /** One of the domain's namespaces by name; refused when the domain does not own it. */
127
+ async function own(tx: EntityManager, domain: { id: string }, name: string): Promise<FigureNamespace> {
128
+ const namespace = await tx.getRepository(FigureNamespace).findOne({ where: { domain: { id: domain.id }, name } })
129
+ if (!namespace) {
130
+ throw new Refusal('figure-namespace-not-owned', { namespace: name }, 'this domain does not own the figure namespace {namespace}.')
131
+ }
132
+ return namespace
81
133
  }
82
134
 
83
- export async function figureNamespaceStateOf(tx: EntityManager, domain: Domain): Promise<FigureNamespaceState> {
84
- const written = await writtenNamespace(tx, domain)
85
- const subdomain = (domain.subdomain ?? '').trim()
86
- const carried = await tx.getRepository(Figure).findOne({ where: { domain: { id: domain.id }, namespace: Not(IsNull()) } })
87
- const chosen = written || subdomain
88
- return {
89
- namespace: carried?.namespace ?? (isFigureNamespace(chosen) ? chosen : null),
90
- written,
91
- subdomain,
92
- fixed: !!carried?.namespace
135
+ async function activeOwn(tx: EntityManager, domain: { id: string }, name: string): Promise<FigureNamespace> {
136
+ const namespace = await own(tx, domain, name)
137
+ if (namespace.state !== 'active') {
138
+ throw new Refusal('figure-namespace-suspended', { namespace: name }, 'the figure namespace {namespace} is suspended.')
93
139
  }
140
+ return namespace
94
141
  }
95
142
 
96
- /**
97
- * Writes the domain's figure namespace (ADR-0114). Empty takes the subdomain. Refused when the value, or the subdomain
98
- * it falls back to, is not a namespace, and when the domain's figures already carry another one.
99
- */
100
- export async function setFigureNamespace(tx: EntityManager, domain: Domain, value: string): Promise<FigureNamespaceState> {
101
- const written = value.trim()
102
- const namespace = written || (domain.subdomain ?? '').trim()
103
- if (!isFigureNamespace(namespace)) {
104
- throw new Refusal(
105
- 'figure-namespace-invalid',
106
- { value: namespace },
107
- '{value} is not a figure namespace. use lower-case letters, digits, dots and hyphens, for example hatiolab.com.'
108
- )
143
+ async function clearDefault(tx: EntityManager, domain: { id: string }): Promise<void> {
144
+ const repository = tx.getRepository(FigureNamespace)
145
+ for (const namespace of await repository.find({ where: { domain: { id: domain.id }, isDefault: true } })) {
146
+ await repository.update({ id: namespace.id }, { isDefault: false })
109
147
  }
110
- await assertNamespaceUnchanged(tx, domain, namespace)
111
- const repository = tx.getRepository(Setting)
112
- const setting = await repository.findOne({ where: { domain: { id: domain.id }, name: FIGURE_NAMESPACE_SETTING } })
113
- await repository.save({ ...setting, domain, name: FIGURE_NAMESPACE_SETTING, category: 'figure', value: written })
114
- return figureNamespaceStateOf(tx, domain)
148
+ }
149
+
150
+ /** The domain's first namespace, made from its subdomain; none when the subdomain is not a namespace or is taken. */
151
+ async function firstNamespace(tx: EntityManager, domain: Domain, user: Actor): Promise<FigureNamespace | undefined> {
152
+ const name = (domain.subdomain ?? '').trim()
153
+ if (!isFigureNamespace(name)) return undefined
154
+ if (await tx.getRepository(FigureNamespace).findOne({ where: { name } })) return undefined
155
+ return createFigureNamespace(tx, domain, user, { name })
115
156
  }
@@ -17,6 +17,7 @@ import { FigureHistory } from './figure-history.js'
17
17
  import { sha256Of, thumbnailAttachmentOf, thumbnailFiles, type ThumbnailFiles } from './figure-thumbnail-store.js'
18
18
  import { meshAttachmentOf } from './figure-mesh-store.js'
19
19
  import { GLB_MIMETYPE } from './figure-glb.js'
20
+ import { publicFigureOfName } from './figure-public.js'
20
21
 
21
22
  /** The most releases one package carries. */
22
23
  export const FIGURE_PACKAGE_MAX = 200
@@ -92,9 +93,11 @@ export async function figurePackageOf(request: FigurePackageRequest): Promise<{
92
93
  }
93
94
 
94
95
  async function releaseOf(ref: FigureRef, domain: FigurePackageRequest['domain']): Promise<{ figure: Figure; history: FigureHistory }> {
95
- const figure = await getRepository(Figure).findOne({
96
- where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, type: ref.type, namespace: ref.namespace }
97
- })
96
+ /* Its own or its parent's, or another domain's public figure (G2): a package is made of what this domain may read. */
97
+ const figure =
98
+ (await getRepository(Figure).findOne({
99
+ where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, type: ref.type, namespace: ref.namespace }
100
+ })) ?? (await publicFigureOfName(ref.namespace, ref.type))
98
101
  const history = figure
99
102
  ? await getRepository(FigureHistory).findOne({ where: { originalId: figure.id, state: 'released', version: ref.version } })
100
103
  : null
@@ -11,6 +11,7 @@ const authBase = require('@things-factory/auth-base')
11
11
  const settingBase = require('@things-factory/setting-base')
12
12
  const { createFigureAsset, addFigureBox, serializeFigureAsset } = require('@hatiolab/figure-model')
13
13
 
14
+ let FigureNamespace: any
14
15
  let dataSource: DataSource | null = null
15
16
  let Figure: any
16
17
  let FigureHistory: any
@@ -29,6 +30,7 @@ function loadCompiled(modulePath: string): any {
29
30
 
30
31
  beforeAll(async () => {
31
32
  Figure = loadCompiled('service/figure/figure').Figure
33
+ FigureNamespace = loadCompiled('service/figure/figure-namespace-entity').FigureNamespace
32
34
  FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
33
35
  FigureProposalRecord = loadCompiled('service/figure/figure-proposal-record').FigureProposalRecord
34
36
  FigureMutation = loadCompiled('service/figure/figure-mutation').FigureMutation
@@ -39,7 +41,7 @@ beforeAll(async () => {
39
41
  synchronize: true,
40
42
  dropSchema: true,
41
43
  namingStrategy: new shell.NamingStrategy(),
42
- entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory, FigureProposalRecord],
44
+ entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory, FigureProposalRecord, FigureNamespace],
43
45
  logging: false
44
46
  })
45
47
  await dataSource.initialize()
@@ -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
 
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Public figures (LV figure-server design G2), on sqlite: a domain makes a released figure public, and every other
3
+ * domain then reads its releases, draws it by full name, puts it in a package and is given its files -- never its
4
+ * draft, never a write. Only released figures are made public, and only by their own domain.
5
+ */
6
+ import type { DataSource } from 'typeorm'
7
+
8
+ const { DataSource: TypeOrmDataSource } = require('typeorm')
9
+ const { createHash } = require('crypto')
10
+ const JSZip = require('jszip')
11
+ const shell = require('@things-factory/shell')
12
+ const authBase = require('@things-factory/auth-base')
13
+ const settingBase = require('@things-factory/setting-base')
14
+ const attachmentBase = require('@things-factory/attachment-base')
15
+ const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy, readFigurePackage } = require('@hatiolab/figure-model')
16
+
17
+ let dataSource: DataSource | null = null
18
+ let Figure: any
19
+ let FigureHistory: any
20
+ let FigureNamespace: any
21
+ let FigureMutation: any
22
+ let FigureQuery: any
23
+ let figurePackageOf: any
24
+ let publicDomainsOfFile: any
25
+ let readableFigure: any
26
+
27
+ function loadCompiled(modulePath: string): any {
28
+ const { existsSync } = require('fs')
29
+ const { join } = require('path')
30
+ const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
31
+ if (!existsSync(resolved))
32
+ throw new Error(`figure-service 산출물이 없다: ${resolved}\n먼저 빌드할 것:\n yarn workspace @things-factory/figure-service build:server`)
33
+ return require(resolved)
34
+ }
35
+
36
+ beforeAll(async () => {
37
+ Figure = loadCompiled('service/figure/figure').Figure
38
+ FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
39
+ FigureNamespace = loadCompiled('service/figure/figure-namespace-entity').FigureNamespace
40
+ const Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
41
+ ;({ FigureMutation } = loadCompiled('service/figure/figure-mutation'))
42
+ ;({ FigureQuery } = loadCompiled('service/figure/figure-query'))
43
+ ;({ figurePackageOf } = loadCompiled('service/figure/figure-package-export'))
44
+ ;({ publicDomainsOfFile, readableFigure } = loadCompiled('service/figure/figure-public'))
45
+ dataSource = new TypeOrmDataSource({
46
+ type: 'sqlite',
47
+ database: ':memory:',
48
+ synchronize: true,
49
+ dropSchema: true,
50
+ namingStrategy: new shell.NamingStrategy(),
51
+ entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, ...attachmentBase.entities, Figure, FigureHistory, FigureNamespace],
52
+ subscribers: [Subscriber],
53
+ logging: false
54
+ })
55
+ await dataSource.initialize()
56
+ shell.addDataSource('default', dataSource)
57
+ shell.addDataSource('tx', dataSource)
58
+ })
59
+
60
+ afterAll(async () => {
61
+ await dataSource?.destroy()
62
+ dataSource = null
63
+ })
64
+
65
+ const releasable = (label = 'Mixer') => {
66
+ let a = addFigureBox(createFigureAsset(label), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
67
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } })
68
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a, { over: 'range' }), placement: 'floor' }))
69
+ }
70
+
71
+ let seq = 0
72
+ async function domain() {
73
+ const name = `pub-${++seq}`
74
+ const saved = await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name })
75
+ await dataSource!.getRepository(FigureNamespace).save({ domain: saved, name, state: 'active', isDefault: true })
76
+ return { domain: saved, context: { state: { domain: saved, user: undefined, tx: dataSource!.manager } }, namespace: name }
77
+ }
78
+ async function released(owner: any, type: string) {
79
+ const figure = await new FigureMutation().saveFigure(releasable(type), owner.context, undefined, undefined, require('crypto').randomUUID(), undefined, type)
80
+ await new FigureMutation().releaseFigure(figure.id, 'first', owner.context)
81
+ return figure
82
+ }
83
+ const noFiles = { put: async () => ({}), send: async () => {}, read: async () => Buffer.alloc(0) }
84
+ const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
85
+
86
+ describe('making a figure public', () => {
87
+ it('only a released figure, only by its own domain; it can be made private again', async () => {
88
+ const owner = await domain()
89
+ const reader = await domain()
90
+ const draft = await new FigureMutation().saveFigure(releasable(), owner.context, undefined, undefined, require('crypto').randomUUID(), undefined, 'sketch')
91
+ await expect(new FigureMutation().setFigurePublic(draft.id, true, owner.context)).rejects.toMatchObject({ code: 'figure-public-unreleased' })
92
+ const figure = await released(owner, 'mixer')
93
+ await expect(new FigureMutation().setFigurePublic(figure.id, true, reader.context)).rejects.toThrow(/찾을 수 없다/)
94
+ expect((await new FigureMutation().setFigurePublic(figure.id, true, owner.context)).public).toBe(true)
95
+ expect((await new FigureMutation().setFigurePublic(figure.id, false, owner.context)).public).toBe(false)
96
+ expect(await dataSource!.getRepository(FigureHistory).count({ where: { originalId: figure.id } })).toBe(1)
97
+ })
98
+
99
+ it('asks figure-release:mutation, as releasing does', () => {
100
+ const { readFileSync } = require('fs')
101
+ const { join } = require('path')
102
+ const source = readFileSync(join(__dirname, 'figure-mutation.ts'), 'utf-8')
103
+ const preceding = source.slice(0, source.indexOf('async setFigurePublic('))
104
+ expect(preceding.slice(preceding.lastIndexOf("@Directive('@privilege("))).toContain('category: "figure-release", privilege: "mutation"')
105
+ })
106
+ })
107
+
108
+ describe('what another domain reads of a public figure', () => {
109
+ it('its releases, by id and by full name, once it is public -- never its draft', async () => {
110
+ const owner = await domain()
111
+ const reader = await domain()
112
+ const figure = await released(owner, 'conveyor')
113
+ const name = `${owner.namespace}/conveyor`
114
+ const query = new FigureQuery()
115
+
116
+ expect(await query.figureReleased(figure.id, reader.context)).toBeNull()
117
+ expect(await query.figureVersions(figure.id, reader.context)).toEqual([])
118
+ expect((await query.figureBlueprints([name], reader.context)).refused.map((r: any) => r.code)).toEqual(['figure-not-released'])
119
+
120
+ await new FigureMutation().setFigurePublic(figure.id, true, owner.context)
121
+ expect((await query.figureReleased(figure.id, reader.context))?.version).toBe(1)
122
+ expect((await query.figureVersions(figure.id, reader.context)).map((v: any) => v.version)).toEqual([1])
123
+ const answer = await query.figureBlueprints([name], reader.context)
124
+ expect(answer.items.map((i: any) => [i.type, i.ref])).toEqual([[name, `${name}@1`]])
125
+ expect(await query.figure(figure.id, reader.context)).toBeNull()
126
+ })
127
+
128
+ it('lists other domains\' public figures with their domain; not its own, not a private one', async () => {
129
+ const owner = await domain()
130
+ const reader = await domain()
131
+ const shown = await released(owner, 'lifter')
132
+ await released(owner, 'kept-private')
133
+ const own = await released(reader, 'own-public')
134
+ await new FigureMutation().setFigurePublic(shown.id, true, owner.context)
135
+ await new FigureMutation().setFigurePublic(own.id, true, reader.context)
136
+ const { items } = await new FigureQuery().publicFigures({ filters: [{ name: 'type', operator: 'in', value: ['lifter', 'kept-private', 'own-public'] }] }, reader.context)
137
+ expect(items.map((f: any) => [f.type, f.domain?.id])).toEqual([['lifter', owner.domain.id]])
138
+ })
139
+
140
+ it('puts its releases in a package', async () => {
141
+ const owner = await domain()
142
+ const reader = await domain()
143
+ const figure = await released(owner, 'shuttle')
144
+ const ref = `${owner.namespace}/shuttle@1`
145
+ const ask = () => figurePackageOf({ refs: [ref], domain: reader.domain, exportedBy: null, sourceUrl: 'x', files: noFiles })
146
+ await expect(ask()).rejects.toMatchObject({ code: 'figure-package-release-not-found' })
147
+ await new FigureMutation().setFigurePublic(figure.id, true, owner.context)
148
+ const zip = await JSZip.loadAsync((await ask()).bytes)
149
+ const files = new Map<string, Uint8Array>()
150
+ for (const path of Object.keys(zip.files)) if (!zip.files[path].dir) files.set(path, await zip.files[path].async('uint8array'))
151
+ expect(readFigurePackage(files, sha256).releases.map((r: any) => r.ref)).toEqual([ref])
152
+ })
153
+
154
+ it('is given its picture and the files its releases draw from, from the domain that keeps them', async () => {
155
+ const owner = await domain()
156
+ const reader = await domain()
157
+ const figure = await released(owner, 'crane')
158
+ const hash = 'a'.repeat(64)
159
+ await dataSource!.getRepository(FigureHistory).update({ originalId: figure.id }, { source: `{"mesh":"sha256-${hash}"}` })
160
+ expect(await readableFigure(reader.domain, figure.id)).toBeNull()
161
+ expect(await publicDomainsOfFile(hash, reader.domain)).toEqual([])
162
+ await new FigureMutation().setFigurePublic(figure.id, true, owner.context)
163
+ expect((await readableFigure(reader.domain, figure.id))?.id).toBe(figure.id)
164
+ expect(await publicDomainsOfFile(hash, reader.domain)).toEqual([owner.domain.id])
165
+ expect(await publicDomainsOfFile('b'.repeat(64), reader.domain)).toEqual([])
166
+ /* Its own domain does not reach for its own files this way: it keeps them. */
167
+ expect(await publicDomainsOfFile(hash, owner.domain)).toEqual([])
168
+ })
169
+
170
+ it('no write reaches it from another domain', async () => {
171
+ const owner = await domain()
172
+ const reader = await domain()
173
+ const figure = await released(owner, 'robot')
174
+ await new FigureMutation().setFigurePublic(figure.id, true, owner.context)
175
+ await expect(new FigureMutation().updateFigure(figure.id, { description: 'theirs' }, reader.context)).rejects.toThrow(/찾을 수 없다/)
176
+ await expect(new FigureMutation().setFigureDeprecation(figure.id, true, reader.context)).rejects.toThrow(/찾을 수 없다/)
177
+ await expect(new FigureMutation().deleteFigure(figure.id, reader.context)).resolves.toBe(true)
178
+ expect(await dataSource!.getRepository(Figure).findOne({ where: { id: figure.id } })).not.toBeNull()
179
+ })
180
+ })