@things-factory/figure-service 10.1.178 → 10.1.179
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 +24 -28
- package/dist-server/service/figure/figure-namespace.js +99 -64
- 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 +120 -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 +116 -82
- 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
|
@@ -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,149 @@
|
|
|
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
|
-
export async function
|
|
36
|
-
|
|
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
|
-
}
|
|
45
|
+
/** A release promises boards `{namespace}/{type}`: it goes out only under a namespace the domain owns and has active. */
|
|
46
|
+
export async function assertReleasableNamespace(tx: EntityManager, domain: Domain, namespace: string): Promise<void> {
|
|
47
|
+
await activeOwn(tx, domain, namespace)
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
/** The namespace a figure already carries, refused when it has none (a figure made before namespaces). */
|
|
49
51
|
export function namespaceOf(figure: Figure): string {
|
|
50
52
|
if (!figure.namespace) {
|
|
53
|
+
throw new Refusal('figure-namespace-missing', { domain: '' }, 'this figure has no namespace. run the figure name migration first.')
|
|
54
|
+
}
|
|
55
|
+
return figure.namespace
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Adds a namespace to the domain. The domain's first is its default; `isDefault` makes another the default. */
|
|
59
|
+
export async function createFigureNamespace(
|
|
60
|
+
tx: EntityManager,
|
|
61
|
+
domain: Domain,
|
|
62
|
+
user: Actor,
|
|
63
|
+
input: { name: string; description?: string | null; isDefault?: boolean | null }
|
|
64
|
+
): Promise<FigureNamespace> {
|
|
65
|
+
const name = (input.name ?? '').trim()
|
|
66
|
+
if (!isFigureNamespace(name)) {
|
|
51
67
|
throw new Refusal(
|
|
52
|
-
'figure-namespace-
|
|
53
|
-
{
|
|
54
|
-
'
|
|
68
|
+
'figure-namespace-invalid',
|
|
69
|
+
{ value: name },
|
|
70
|
+
'{value} is not a figure namespace. use lower-case letters, digits, dots and hyphens, for example hatiolab.com.'
|
|
55
71
|
)
|
|
56
72
|
}
|
|
57
|
-
|
|
73
|
+
const repository = tx.getRepository(FigureNamespace)
|
|
74
|
+
if (await repository.findOne({ where: { name } })) {
|
|
75
|
+
throw new Refusal('figure-namespace-taken', { namespace: name }, 'the figure namespace {namespace} is already taken on this server.')
|
|
76
|
+
}
|
|
77
|
+
const first = (await repository.count({ where: { domain: { id: domain.id } } })) === 0
|
|
78
|
+
const isDefault = first || !!input.isDefault
|
|
79
|
+
if (isDefault && !first) await clearDefault(tx, domain)
|
|
80
|
+
const saved = await repository.save({
|
|
81
|
+
domain,
|
|
82
|
+
name,
|
|
83
|
+
description: input.description?.trim() || null,
|
|
84
|
+
state: 'active',
|
|
85
|
+
isDefault,
|
|
86
|
+
creator: user as any,
|
|
87
|
+
updater: user as any
|
|
88
|
+
})
|
|
89
|
+
return repository.findOneOrFail({ where: { id: saved.id } })
|
|
58
90
|
}
|
|
59
91
|
|
|
60
|
-
/**
|
|
61
|
-
async function
|
|
62
|
-
|
|
92
|
+
/** Makes one of the domain's active namespaces its default. */
|
|
93
|
+
export async function setDefaultFigureNamespace(tx: EntityManager, domain: Domain, user: Actor, name: string): Promise<FigureNamespace> {
|
|
94
|
+
const namespace = await activeOwn(tx, domain, name)
|
|
95
|
+
await clearDefault(tx, domain)
|
|
96
|
+
await tx.getRepository(FigureNamespace).update({ id: namespace.id }, { isDefault: true, updater: user as any })
|
|
97
|
+
return tx.getRepository(FigureNamespace).findOneOrFail({ where: { id: namespace.id } })
|
|
63
98
|
}
|
|
64
99
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
100
|
+
/** Suspends a namespace or makes it active again. The default namespace is not suspended: make another the default first. */
|
|
101
|
+
export async function setFigureNamespaceState(
|
|
102
|
+
tx: EntityManager,
|
|
103
|
+
domain: Domain,
|
|
104
|
+
user: Actor,
|
|
105
|
+
name: string,
|
|
106
|
+
state: 'active' | 'suspended'
|
|
107
|
+
): Promise<FigureNamespace> {
|
|
108
|
+
if (state !== 'active' && state !== 'suspended') {
|
|
109
|
+
throw new Refusal('figure-namespace-state', { state: String(state) }, '{state} is not a namespace state. use active or suspended.')
|
|
110
|
+
}
|
|
111
|
+
const namespace = await own(tx, domain, name)
|
|
112
|
+
if (state === 'suspended' && namespace.isDefault) {
|
|
113
|
+
throw new Refusal('figure-namespace-default-suspended', { namespace: namespace.name! }, 'the default namespace {namespace} cannot be suspended. make another the default first.')
|
|
114
|
+
}
|
|
115
|
+
await tx.getRepository(FigureNamespace).update({ id: namespace.id }, { state, updater: user as any })
|
|
116
|
+
return tx.getRepository(FigureNamespace).findOneOrFail({ where: { id: namespace.id } })
|
|
70
117
|
}
|
|
71
118
|
|
|
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
|
|
119
|
+
/** One of the domain's namespaces by name; refused when the domain does not own it. */
|
|
120
|
+
async function own(tx: EntityManager, domain: { id: string }, name: string): Promise<FigureNamespace> {
|
|
121
|
+
const namespace = await tx.getRepository(FigureNamespace).findOne({ where: { domain: { id: domain.id }, name } })
|
|
122
|
+
if (!namespace) {
|
|
123
|
+
throw new Refusal('figure-namespace-not-owned', { namespace: name }, 'this domain does not own the figure namespace {namespace}.')
|
|
124
|
+
}
|
|
125
|
+
return namespace
|
|
81
126
|
}
|
|
82
127
|
|
|
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
|
|
128
|
+
async function activeOwn(tx: EntityManager, domain: { id: string }, name: string): Promise<FigureNamespace> {
|
|
129
|
+
const namespace = await own(tx, domain, name)
|
|
130
|
+
if (namespace.state !== 'active') {
|
|
131
|
+
throw new Refusal('figure-namespace-suspended', { namespace: name }, 'the figure namespace {namespace} is suspended.')
|
|
93
132
|
}
|
|
133
|
+
return namespace
|
|
94
134
|
}
|
|
95
135
|
|
|
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
|
-
)
|
|
136
|
+
async function clearDefault(tx: EntityManager, domain: { id: string }): Promise<void> {
|
|
137
|
+
const repository = tx.getRepository(FigureNamespace)
|
|
138
|
+
for (const namespace of await repository.find({ where: { domain: { id: domain.id }, isDefault: true } })) {
|
|
139
|
+
await repository.update({ id: namespace.id }, { isDefault: false })
|
|
109
140
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The domain's first namespace, made from its subdomain; none when the subdomain is not a namespace or is taken. */
|
|
144
|
+
async function firstNamespace(tx: EntityManager, domain: Domain, user: Actor): Promise<FigureNamespace | undefined> {
|
|
145
|
+
const name = (domain.subdomain ?? '').trim()
|
|
146
|
+
if (!isFigureNamespace(name)) return undefined
|
|
147
|
+
if (await tx.getRepository(FigureNamespace).findOne({ where: { name } })) return undefined
|
|
148
|
+
return createFigureNamespace(tx, domain, user, { name })
|
|
115
149
|
}
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { In, Like, Not } from 'typeorm'
|
|
2
|
+
|
|
3
|
+
import { getRepository } from '@things-factory/shell'
|
|
4
|
+
|
|
5
|
+
import { Figure } from './figure.js'
|
|
6
|
+
import { FigureHistory } from './figure-history.js'
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* Public figures (LV figure-server design G2): a figure its domain made public is read by every domain -- its releases,
|
|
10
|
+
* its picture, the files its releases draw from -- and drawn by its full name {namespace}/{type}, which points at one
|
|
11
|
+
* figure on the server because a namespace belongs to one domain (G1). Its draft stays its domain's. Writing it stays
|
|
12
|
+
* its domain's (G3). Each reader asks its own domain and its parent first; a public figure is the last place looked.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
type Reader = { id: string; parentId?: string | null }
|
|
16
|
+
|
|
17
|
+
/** The domains a reader reads as its own: itself and its parent. */
|
|
18
|
+
export const ownScope = (domain: Reader) => In([domain.id, domain.parentId].filter(Boolean) as string[])
|
|
19
|
+
|
|
20
|
+
/** A figure the reader may read the releases of: its own or its parent's, or any domain's public one. */
|
|
21
|
+
export async function readableFigure(domain: Reader, id: string, select?: Record<string, unknown>): Promise<Figure | null> {
|
|
22
|
+
return getRepository(Figure).findOne({
|
|
23
|
+
where: [
|
|
24
|
+
{ domain: { id: ownScope(domain) }, id },
|
|
25
|
+
{ public: true, id }
|
|
26
|
+
],
|
|
27
|
+
relations: { domain: true },
|
|
28
|
+
...(select ? { select } : {})
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether the figure is another domain's public one: the reader sees its releases, never its draft. */
|
|
33
|
+
export function foreignPublic(domain: Reader, figure: Figure): boolean {
|
|
34
|
+
const owner = figure.domain?.id ?? figure.domainId
|
|
35
|
+
return !!figure.public && owner !== domain.id && owner !== domain.parentId
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The public figure of a full name, in whatever domain owns its namespace. */
|
|
39
|
+
export async function publicFigureOfName(namespace: string, type: string): Promise<Figure | null> {
|
|
40
|
+
return getRepository(Figure).findOne({ where: { namespace, type, public: true }, relations: { domain: true } })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The domains whose kept file this is, among the public figures whose releases draw from it -- the places a reader may
|
|
45
|
+
* be given that file from beyond its own domain and its parent.
|
|
46
|
+
*/
|
|
47
|
+
export async function publicDomainsOfFile(hash: string, reader: Reader): Promise<string[]> {
|
|
48
|
+
const figures = await getRepository(Figure).find({
|
|
49
|
+
where: { public: true, domain: { id: Not(ownScope(reader)) } },
|
|
50
|
+
relations: { domain: true },
|
|
51
|
+
select: { id: true, domain: { id: true } }
|
|
52
|
+
})
|
|
53
|
+
if (!figures.length) return []
|
|
54
|
+
const histories = await getRepository(FigureHistory).find({
|
|
55
|
+
where: { originalId: In(figures.map(figure => figure.id)), state: 'released', source: Like(`%${hash}%`) },
|
|
56
|
+
select: { originalId: true }
|
|
57
|
+
})
|
|
58
|
+
const drawing = new Set(histories.map(history => history.originalId))
|
|
59
|
+
return [...new Set(figures.filter(figure => drawing.has(figure.id)).map(figure => figure.domain!.id))]
|
|
60
|
+
}
|