@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.
- package/dist-server/index.d.ts +2 -0
- package/dist-server/index.js +6 -1
- package/dist-server/index.js.map +1 -1
- package/dist-server/routers/figure-mesh-router.js +4 -1
- package/dist-server/routers/figure-mesh-router.js.map +1 -1
- package/dist-server/routers/figure-thumbnail-router.js +3 -7
- package/dist-server/routers/figure-thumbnail-router.js.map +1 -1
- package/dist-server/service/figure/figure-categories.d.ts +10 -0
- package/dist-server/service/figure/figure-categories.js +53 -0
- package/dist-server/service/figure/figure-categories.js.map +1 -0
- package/dist-server/service/figure/figure-format.d.ts +18 -1
- package/dist-server/service/figure/figure-format.js +28 -1
- package/dist-server/service/figure/figure-format.js.map +1 -1
- package/dist-server/service/figure/figure-mutation.d.ts +8 -2
- package/dist-server/service/figure/figure-mutation.js +83 -34
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-namespace-entity.d.ts +24 -0
- package/dist-server/service/figure/figure-namespace-entity.js +87 -0
- package/dist-server/service/figure/figure-namespace-entity.js.map +1 -0
- package/dist-server/service/figure/figure-namespace-resolver.d.ts +5 -8
- package/dist-server/service/figure/figure-namespace-resolver.js +52 -36
- package/dist-server/service/figure/figure-namespace-resolver.js.map +1 -1
- package/dist-server/service/figure/figure-namespace.d.ts +28 -28
- package/dist-server/service/figure/figure-namespace.js +105 -63
- package/dist-server/service/figure/figure-namespace.js.map +1 -1
- package/dist-server/service/figure/figure-package-export.js +4 -2
- package/dist-server/service/figure/figure-package-export.js.map +1 -1
- package/dist-server/service/figure/figure-public.d.ts +19 -0
- package/dist-server/service/figure/figure-public.js +54 -0
- package/dist-server/service/figure/figure-public.js.map +1 -0
- package/dist-server/service/figure/figure-query.d.ts +7 -0
- package/dist-server/service/figure/figure-query.js +59 -5
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-rename.d.ts +25 -2
- package/dist-server/service/figure/figure-rename.js +21 -2
- package/dist-server/service/figure/figure-rename.js.map +1 -1
- package/dist-server/service/figure/figure-tools.js +1 -1
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/service/figure/figure-type.d.ts +1 -0
- package/dist-server/service/figure/figure-type.js +4 -0
- package/dist-server/service/figure/figure-type.js.map +1 -1
- package/dist-server/service/figure/figure.d.ts +1 -0
- package/dist-server/service/figure/figure.js +5 -0
- package/dist-server/service/figure/figure.js.map +1 -1
- package/dist-server/service/figure/index.d.ts +2 -1
- package/dist-server/service/figure/index.js +2 -1
- package/dist-server/service/figure/index.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/server/index.ts +2 -0
- package/server/routers/figure-mesh-router.ts +5 -1
- package/server/routers/figure-thumbnail-router.ts +3 -5
- package/server/service/figure/figure-categories.ts +50 -0
- package/server/service/figure/figure-category-db.test.ts +127 -0
- package/server/service/figure/figure-deprecation-db.test.ts +110 -6
- package/server/service/figure/figure-format.ts +43 -1
- package/server/service/figure/figure-mesh-db.test.ts +4 -2
- package/server/service/figure/figure-mutation.ts +79 -37
- package/server/service/figure/figure-namespace-db.test.ts +140 -74
- package/server/service/figure/figure-namespace-entity.ts +67 -0
- package/server/service/figure/figure-namespace-resolver.ts +39 -26
- package/server/service/figure/figure-namespace.ts +122 -81
- package/server/service/figure/figure-package-export.ts +6 -3
- package/server/service/figure/figure-proposal-db.test.ts +5 -3
- package/server/service/figure/figure-public-db.test.ts +180 -0
- package/server/service/figure/figure-public.ts +60 -0
- package/server/service/figure/figure-query.ts +48 -5
- package/server/service/figure/figure-release-check.test.ts +63 -0
- package/server/service/figure/figure-release-record-db.test.ts +7 -5
- package/server/service/figure/figure-rename-db.test.ts +50 -4
- package/server/service/figure/figure-rename.ts +37 -3
- package/server/service/figure/figure-tools.ts +1 -1
- package/server/service/figure/figure-type.ts +3 -0
- package/server/service/figure/figure.ts +8 -0
- package/server/service/figure/index.ts +2 -1
- package/translations/en.json +12 -3
- package/translations/es.json +12 -3
- package/translations/ja.json +12 -3
- package/translations/ko.json +12 -3
- package/translations/ms.json +12 -3
- 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,
|
|
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 {
|
|
5
|
+
import { FigureNamespace } from './figure-namespace-entity.js'
|
|
6
|
+
import { createFigureNamespace, namespacesOf, setDefaultFigureNamespace, setFigureNamespaceState } from './figure-namespace.js'
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
27
|
-
async
|
|
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 (
|
|
19
|
+
return namespacesOf(tx ?? getDataSource().manager, domain)
|
|
30
20
|
}
|
|
31
21
|
|
|
32
22
|
@Directive('@transaction')
|
|
33
|
-
@Directive('@privilege(category: "
|
|
23
|
+
@Directive('@privilege(category: "figure-namespace", privilege: "mutation", domainOwnerGranted: true)')
|
|
34
24
|
@Mutation(returns => FigureNamespace, {
|
|
35
|
-
description:
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
10
|
-
|
|
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
|
|
23
|
-
if (
|
|
24
|
-
|
|
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
|
|
31
|
-
|
|
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
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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-
|
|
53
|
-
{
|
|
54
|
-
'
|
|
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
|
-
|
|
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
|
-
/**
|
|
61
|
-
async function
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
namespace
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
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(
|
|
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
|
+
})
|