Export product version with controlled docs (#10586)

* Export product version with controlled docs

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix tests

Signed-off-by: Artem Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-03-04 14:48:33 +07:00
committed by GitHub
parent e06c427762
commit 6bd7da2c4a
11 changed files with 200 additions and 26 deletions
@@ -150,6 +150,21 @@ export interface Relation extends Doc {
association: Ref<Association>
}
/**
* Describes an existing class field with reference to other document: which relations to follow when building documents graph.
* @public
*/
export interface RelationMetadata extends Doc {
/** Class (source) */
sourceClass: Ref<Class<Doc>>
/** Class referenced by the field (target) */
targetClass: Ref<Class<Doc>>
/** Field on the source class */
field: string
/** Whether this is a forward (source→target) or inverse (target→source) relation */
direction?: 'forward' | 'inverse'
}
/**
* @public
*/
@@ -52,6 +52,7 @@ import type {
RefTo,
RelatedDocument,
Relation,
RelationMetadata,
Role,
Sequence,
Space,
@@ -175,6 +176,7 @@ export default plugin(coreId, {
FullTextSearchContext: '' as Ref<Mixin<FullTextSearchContext>>,
Association: '' as Ref<Class<Association>>,
Relation: '' as Ref<Class<Relation>>,
RelationMetadata: '' as Ref<Class<RelationMetadata>>,
Sequence: '' as Ref<Class<Sequence>>,
CustomSequence: '' as Ref<Class<CustomSequence>>,
ClassCollaborators: '' as Ref<Class<ClassCollaborators<Doc>>>,
+28 -18
View File
@@ -26,7 +26,7 @@ import documentsPlugin, {
type ChangeControl,
type DocumentRequest
} from '@hcengineering/controlled-documents'
import exportPlugin, { type RelationDefinition } from '@hcengineering/export'
import exportPlugin from '@hcengineering/export'
import { type Builder } from '@hcengineering/model'
import chunter from '@hcengineering/model-chunter'
import core from '@hcengineering/model-core'
@@ -79,6 +79,31 @@ import {
export { documentsId } from '@hcengineering/controlled-documents/src/index'
export * from './types'
function defineRelationMetadata (builder: Builder): void {
const rel = (
ref: Ref<Class<Doc>>,
field: string,
targetClass: Ref<Class<Doc>>,
direction: 'forward' | 'inverse' = 'forward'
): void => {
builder.createDoc(core.class.RelationMetadata, core.space.Model, {
sourceClass: ref,
field,
targetClass,
direction
})
}
rel(documents.class.Document, 'category', documents.class.DocumentCategory, 'forward')
rel(documents.class.Document, 'template', documents.class.Document, 'forward')
rel(documents.class.Document, 'meta', documents.class.ProjectMeta, 'inverse')
rel(documents.class.Document, 'document', documents.class.ProjectDocument, 'inverse')
rel(documents.class.HierarchyDocument, 'attachedTo', documents.class.DocumentMeta, 'forward')
rel(documents.class.ControlledDocument, 'changeControl', documents.class.ChangeControl, 'forward')
rel(documents.class.ProjectMeta, 'meta', documents.class.ProjectMeta, 'inverse')
rel(documents.class.ProjectMeta, 'document', documents.class.ProjectDocument, 'inverse')
}
export function createModel (builder: Builder): void {
builder.createModel(
TDocumentSpace,
@@ -109,6 +134,8 @@ export function createModel (builder: Builder): void {
TDocumentComment
)
defineRelationMetadata(builder)
builder.mixin(documents.class.ControlledDocument, core.class.Class, view.mixin.ObjectTitle, {
titleProvider: documents.function.ControlledDocumentTitleProvider
})
@@ -847,19 +874,6 @@ export function createModel (builder: Builder): void {
documents.action.TransferDocument
)
const relations: RelationDefinition[] = [
// Forward relations - migrate referenced documents first
{ field: 'attachedTo', class: documents.class.DocumentMeta },
{ field: 'changeControl', class: documents.class.ChangeControl },
{ field: 'category', class: documents.class.DocumentCategory },
{ field: 'template', class: documents.class.Document },
// Inverse relations - find documents that reference this one
// ProjectMeta references DocumentMeta via 'meta' field - must be migrated before ProjectDocument
{ field: 'meta', class: documents.class.ProjectMeta, direction: 'inverse' },
// ProjectDocument references ControlledDocument via 'document' field
{ field: 'document', class: documents.class.ProjectDocument, direction: 'inverse' }
]
createAction(
builder,
{
@@ -868,9 +882,6 @@ export function createModel (builder: Builder): void {
component: exportPlugin.component.ExportToWorkspaceModal,
fillProps: {
_objects: 'value'
},
props: {
relations
}
},
label: exportPlugin.string.ExportToWorkspace,
@@ -896,7 +907,6 @@ export function createModel (builder: Builder): void {
_object: 'value'
},
props: {
relations,
spaceExport: true,
docClass: documents.class.ControlledDocument
}
+17
View File
@@ -53,6 +53,7 @@ import {
type Ref,
type RefTo,
type Relation,
type RelationMetadata,
type Sequence,
type Space,
type Timestamp,
@@ -161,6 +162,22 @@ export class TRelation extends TDoc implements Relation {
association!: Ref<Association>
}
@Model(core.class.RelationMetadata, core.class.Doc, DOMAIN_MODEL)
export class TRelationMetadata extends TDoc implements RelationMetadata {
@Prop(TypeRef(core.class.Class), core.string.Class)
@Index(IndexKind.Indexed)
sourceClass!: Ref<Class<Doc>>
@Prop(TypeRef(core.class.Class), core.string.TargetClass)
targetClass!: Ref<Class<Doc>>
@Prop(TypeString(), core.string.ClassPropertyLabel)
field!: string
@Prop(TypeString(), core.string.Description)
direction?: 'forward' | 'inverse'
}
@Model(core.class.Blob, core.class.Doc, DOMAIN_BLOB)
@UX(core.string.Object)
export class TBlob extends TDoc implements Blob {
+2
View File
@@ -52,6 +52,7 @@ import {
TPluginConfiguration,
TRefTo,
TRelation,
TRelationMetadata,
TSequence,
TTransientConfiguration,
TType,
@@ -177,6 +178,7 @@ export function createModel (builder: Builder): void {
TMigrationState,
TBlob,
TRelation,
TRelationMetadata,
TAssociation,
TDomainIndexConfiguration,
TBenchmarkDoc,
+31 -1
View File
@@ -22,7 +22,17 @@ import { type Attachment } from '@hcengineering/attachment'
import contact from '@hcengineering/contact'
import chunter from '@hcengineering/chunter'
import { getRoleAttributeProps } from '@hcengineering/setting'
import type { Type, Ref, CollectionSize, Markup, RolesAssignment, Permission, Role } from '@hcengineering/core'
import type {
Type,
Ref,
CollectionSize,
Markup,
RolesAssignment,
Permission,
Role,
Class,
Doc
} from '@hcengineering/core'
import { IndexKind, AccountUuid } from '@hcengineering/core'
import {
type Builder,
@@ -418,6 +428,25 @@ function defineProductVersionState (builder: Builder): void {
})
}
function defineRelationMetadata (builder: Builder): void {
const rel = (
sourceClass: Ref<Class<Doc>>,
field: string,
targetClass: Ref<Class<Doc>>,
direction: 'forward' | 'inverse' = 'forward'
): void => {
builder.createDoc(core.class.RelationMetadata, core.space.Model, {
sourceClass,
field,
targetClass,
direction
})
}
// Product → ProductVersion via `space` (inverse: versions that belong to this product)
rel(products.class.Product, 'space', products.class.ProductVersion, 'inverse')
}
function defineApplication (builder: Builder): void {
builder.createDoc(
workbench.class.Application,
@@ -467,6 +496,7 @@ export function createModel (builder: Builder): void {
defineProduct(builder)
defineProductVersion(builder)
defineProductVersionState(builder)
defineRelationMetadata(builder)
defineApplication(builder)
}
@@ -28,7 +28,7 @@
import { DropdownLabels, DropdownLabelsIntl, Label } from '@hcengineering/ui'
import { getResource } from '@hcengineering/platform'
import login from '@hcengineering/login'
import { type RelationDefinition, shouldSkipDocument, isEffectiveDocument } from '@hcengineering/export'
import { shouldSkipDocument, isEffectiveDocument } from '@hcengineering/export'
import { createEventDispatcher } from 'svelte'
@@ -37,7 +37,6 @@
export let query: DocumentQuery<Doc> | undefined = undefined
export let value: Doc | Doc[] | Space
export let relations: RelationDefinition[] | undefined = undefined
export let docClass: Ref<Class<Doc>> | undefined = undefined
export let spaceExport: boolean | undefined = false
@@ -127,7 +126,7 @@
exportQuery,
filteredSelectedDocs,
targetWorkspace,
relations,
undefined,
exportFilterMode === 'skipArchivedObsolete',
exportFilterMode === 'effectiveOnly'
)
+4 -2
View File
@@ -24,7 +24,7 @@ export async function exportToWorkspace (
query: DocumentQuery<Doc> | undefined,
selectedDocs: Doc[],
targetWorkspace: string | undefined,
relations: RelationDefinition[] | undefined,
relations?: RelationDefinition[] | undefined,
skipDeletedObsolete?: boolean,
exportOnlyEffective?: boolean
): Promise<void> {
@@ -66,11 +66,13 @@ export async function exportToWorkspace (
const body: any = {
targetWorkspace,
_class,
relations,
fieldMappers,
skipDeletedObsolete,
exportOnlyEffective
}
if (relations != null) {
body.relations = relations
}
body.query =
selectedDocs.length > 0
@@ -135,6 +135,15 @@ export class DocumentExporter {
sourceHierarchy,
sourceLowLevel
)
await this.exportSpaceRelations(
doc,
doc.space,
conflictStrategy,
includeAttachments,
sourceHierarchy,
sourceLowLevel,
relations
)
// Handle attachments
if (includeAttachments) {
@@ -157,6 +166,49 @@ export class DocumentExporter {
}
}
private async exportSpaceRelations (
doc: Doc,
space: Ref<Space>,
conflictStrategy: 'skip' | 'duplicate',
includeAttachments: boolean,
sourceHierarchy: Hierarchy,
sourceLowLevel: LowLevelStorage,
relations: RelationDefinition[]
): Promise<void> {
try {
if (this.relationExporter === undefined) {
return
}
const spaceDomain = sourceHierarchy.findDomain(core.class.Space)
if (spaceDomain === undefined) {
this.context.warn('Space domain not found')
return
}
const sourceSpaces = await sourceLowLevel.rawFindAll<Space>(spaceDomain, { _id: space })
const spaceDoc = sourceSpaces[0]
if (spaceDoc == null) {
this.context.warn(`Source space ${space} not found for exporting relations`)
return
}
await this.relationExporter.exportAllRelations(
spaceDoc,
relations,
conflictStrategy,
includeAttachments,
sourceHierarchy,
sourceLowLevel
)
} catch (err: any) {
this.context.error(`Failed to export relations for space ${space}:`, {
error: err instanceof Error ? err.message : String(err),
docId: doc._id
})
}
}
private async createDocument (
sourceDoc: Doc,
data: any,
@@ -119,6 +119,32 @@ export class RelationExporter {
}
}
async exportAllRelations (
doc: Doc,
relations: RelationDefinition[],
conflictStrategy: 'skip' | 'duplicate',
includeAttachments: boolean,
sourceHierarchy: Hierarchy,
sourceLowLevel: LowLevelStorage
): Promise<void> {
await this.exportForwardRelations(
doc,
relations,
conflictStrategy,
includeAttachments,
sourceHierarchy,
sourceLowLevel
)
await this.exportInverseRelations(
doc,
relations,
conflictStrategy,
includeAttachments,
sourceHierarchy,
sourceLowLevel
)
}
private async exportForwardRelation (
doc: Doc,
relation: RelationDefinition,
@@ -13,7 +13,7 @@
// limitations under the License.
//
import {
import core, {
type AccountUuid,
type Doc,
type MeasureContext,
@@ -178,6 +178,25 @@ export class CrossWorkspaceExporter {
throw new Error('Low level storage not available')
}
// Resolve relations from RelationMetadata when not provided
let resolvedRelations = relations
try {
if (resolvedRelations.length === 0) {
const relations = await sourcePipeline.findAll(this.context, core.class.RelationMetadata, {})
resolvedRelations = relations.map((doc) => ({
field: doc.field,
class: doc.targetClass,
direction: (doc.direction ?? 'forward') as 'forward' | 'inverse'
}))
}
} catch (err: any) {
this.context.error('Failed to get relations:', {
error: err instanceof Error ? err.message : String(err)
})
}
const relationsCount = resolvedRelations != null ? resolvedRelations.length : 0
this.context.info(`Number of document relations: ${relationsCount}`)
// Get domain for the class
const domain = hierarchy.findDomain(_class)
if (domain === undefined) {
@@ -235,7 +254,7 @@ export class CrossWorkspaceExporter {
hierarchy,
lowLevelStorage,
existingDocsMap,
relations
resolvedRelations
)
if (exported) {
result.exportedCount++