Fix product version export (#10866)

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-05-19 21:10:33 +05:00
committed by GitHub
parent f55cc3adc6
commit e94a1ef4df
8 changed files with 610 additions and 3 deletions
+3
View File
@@ -38292,6 +38292,9 @@ importers:
'@hcengineering/postgres':
specifier: workspace:^0.7.22
version: link:../../../foundations/server/packages/postgres
'@hcengineering/products':
specifier: workspace:^0.7.0
version: link:../../../plugins/products
'@hcengineering/server-client':
specifier: workspace:^0.7.16
version: link:../../../foundations/server/packages/client
+1
View File
@@ -85,6 +85,7 @@
"ws": "^8.18.2",
"@hcengineering/drive": "workspace:^0.7.0",
"@hcengineering/export": "workspace:^0.7.0",
"@hcengineering/products": "workspace:^0.7.0",
"uuid": "^8.3.2",
"tar": "^7.4.3",
"archiver": "^7.0.1",
@@ -0,0 +1,424 @@
/* eslint-disable @typescript-eslint/unbound-method */
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import {
type AttachedDoc,
type Class,
type Doc,
type Hierarchy,
type LowLevelStorage,
type MeasureContext,
type Ref,
type Space,
type TxOperations
} from '@hcengineering/core'
import productsPlugin, { ProductVersionState, type Product, type ProductVersion } from '@hcengineering/products'
import { createProductVersionHandler } from '../handlers/product-version-handler'
import { DocumentExporter } from '../workspace/document-exporter'
import { type CustomExportHandler, type CustomExportHandlerContext, type ExportState } from '../workspace/types'
import { type DataMapper } from '../workspace/data-mapper'
import { type RelationExporter } from '../workspace/relation-exporter'
import { type SpaceExporter } from '../workspace/space-exporter'
import { type AttachmentExporter } from '../workspace/attachment-exporter'
const sourceProductId = 'test:product:source-A' as Ref<Product>
const otherSourceProductId = 'test:product:source-B' as Ref<Product>
const targetProductIdA = 'test:product:target-A' as Ref<Product>
const targetProductIdB = 'test:product:target-B' as Ref<Product>
function makeContext (): MeasureContext {
return {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn()
} as unknown as MeasureContext
}
function makeState (): ExportState {
return {
idMapping: new Map(),
spaceMapping: new Map(),
processingDocs: new Set(),
uniqueFieldValues: new Map()
}
}
function productVersion (id: string, space: Ref<Product>, extras: Partial<ProductVersion> = {}): ProductVersion {
const base: ProductVersion = {
_id: id as Ref<ProductVersion>,
_class: productsPlugin.class.ProductVersion,
space,
name: '1.0.0',
readonly: false,
major: 1,
minor: 0,
patch: 0,
description: '',
state: ProductVersionState.Active,
parent: productsPlugin.ids.NoParentVersion,
modifiedOn: 0,
modifiedBy: 'test:account:user' as any
}
return { ...base, ...extras }
}
/**
* Tiny in-memory TxOperations stub. Only implements findOne/createDoc and
* getHierarchy() — the handler does not call anything else.
*/
function makeTargetClient (existing: ProductVersion[] = []): {
client: TxOperations
created: ProductVersion[]
} {
const docs: ProductVersion[] = [...existing]
const created: ProductVersion[] = []
const client = {
findOne: jest.fn(async <T extends Doc>(classRef: Ref<Class<T>>, query: any): Promise<T | undefined> => {
for (const d of docs) {
if (d._class !== classRef) continue
let ok = true
for (const [k, v] of Object.entries(query)) {
if ((d as any)[k] !== v) {
ok = false
break
}
}
if (ok) return d as unknown as T
}
return undefined
}),
createDoc: jest.fn(
async (
classRef: Ref<Class<Doc>>,
space: Ref<Space>,
data: Record<string, any>,
id: Ref<Doc>
): Promise<Ref<Doc>> => {
const doc = { _id: id, _class: classRef, space, ...data } as unknown as ProductVersion
docs.push(doc)
created.push(doc)
return id
}
),
addCollection: jest.fn(),
updateDoc: jest.fn(),
findAll: jest.fn(async () => []),
getHierarchy: jest.fn(() => makeTargetHierarchy())
} as unknown as TxOperations
return { client, created }
}
function makeTargetHierarchy (): Hierarchy {
return {
isDerived: jest.fn((cls: Ref<Class<Doc>>, base: Ref<Class<Doc>>) => cls === base),
findDomain: jest.fn(() => 'domain:products'),
getAllAttributes: jest.fn(() => new Map()),
isMixin: jest.fn(() => false),
hasMixin: jest.fn(() => undefined),
getClass: jest.fn(() => ({ label: 'ProductVersion' }))
} as unknown as Hierarchy
}
/**
* Build the ctx object the handler's resolve() receives. The spaceExporter
* stub maps source Product ids onto preconfigured target Product ids so we
* can assert dedup happens after space mapping.
*/
function makeHandlerCtx (
targetClient: TxOperations,
spaceMap: Record<string, Ref<Product>>
): {
ctx: CustomExportHandlerContext
spaceExporter: { getOrCreateTargetSpace: jest.Mock }
} {
const spaceExporter = {
getOrCreateTargetSpace: jest.fn(async (sourceSpaceId: Ref<Space>) => {
const target = spaceMap[sourceSpaceId as unknown as string]
if (target === undefined) {
throw new Error(`no target mapping for source space ${sourceSpaceId}`)
}
return target
})
}
const ctx: CustomExportHandlerContext = {
context: makeContext(),
targetClient,
state: makeState(),
spaceExporter,
sourceHierarchy: {} as unknown as Hierarchy,
sourceLowLevel: {} as unknown as LowLevelStorage
}
return { ctx, spaceExporter }
}
describe('createProductVersionHandler', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('creates a new 1.0.0 ProductVersion when none exists in the target Product', async () => {
const { client, created } = makeTargetClient()
const { ctx, spaceExporter } = makeHandlerCtx(client, { [sourceProductId]: targetProductIdA })
const handler = createProductVersionHandler()
const sourceVersion = productVersion('test:version:s1', sourceProductId, {
major: 3,
minor: 7,
patch: 2,
state: ProductVersionState.Released
})
const targetId = await handler.resolve(sourceVersion, ctx)
expect(targetId).toBeDefined()
expect(spaceExporter.getOrCreateTargetSpace).toHaveBeenCalledWith(
sourceProductId,
ctx.sourceHierarchy,
ctx.sourceLowLevel
)
expect(client.findOne).toHaveBeenCalledWith(productsPlugin.class.ProductVersion, {
space: targetProductIdA,
major: 1,
minor: 0,
patch: 0
})
expect(client.createDoc).toHaveBeenCalledTimes(1)
expect(created).toHaveLength(1)
expect(created[0]).toMatchObject({
_class: productsPlugin.class.ProductVersion,
space: targetProductIdA,
name: '1.0.0',
major: 1,
minor: 0,
patch: 0,
state: ProductVersionState.Active,
readonly: false,
parent: productsPlugin.ids.NoParentVersion
})
expect(targetId).toEqual(created[0]._id)
})
it('reuses an existing 1.0.0 ProductVersion in the target Product without creating a new one', async () => {
const existing = productVersion('test:version:existing', targetProductIdA)
const { client, created } = makeTargetClient([existing])
const { ctx } = makeHandlerCtx(client, { [sourceProductId]: targetProductIdA })
const handler = createProductVersionHandler()
const sourceVersion = productVersion('test:version:s1', sourceProductId, { major: 2, minor: 5, patch: 1 })
const targetId = await handler.resolve(sourceVersion, ctx)
expect(targetId).toEqual(existing._id)
expect(client.createDoc).not.toHaveBeenCalled()
expect(created).toHaveLength(0)
})
it('caches the target version across multiple source versions of the same Product', async () => {
const { client, created } = makeTargetClient()
const { ctx, spaceExporter } = makeHandlerCtx(client, { [sourceProductId]: targetProductIdA })
const handler = createProductVersionHandler()
const v1 = productVersion('test:version:s1', sourceProductId, { major: 1 })
const v2 = productVersion('test:version:s2', sourceProductId, { major: 2 })
const v3 = productVersion('test:version:s3', sourceProductId, { major: 3 })
const t1 = await handler.resolve(v1, ctx)
const t2 = await handler.resolve(v2, ctx)
const t3 = await handler.resolve(v3, ctx)
// All three source versions collapse onto the single target id.
expect(t1).toEqual(t2)
expect(t2).toEqual(t3)
// findOne and createDoc happen exactly once across all source versions.
expect(client.findOne).toHaveBeenCalledTimes(1)
expect(client.createDoc).toHaveBeenCalledTimes(1)
expect(created).toHaveLength(1)
// The space exporter is still queried for each source version (it has its
// own cache via state.spaceMapping in the real implementation).
expect(spaceExporter.getOrCreateTargetSpace).toHaveBeenCalledTimes(3)
})
it('creates a separate 1.0.0 per distinct target Product', async () => {
const { client, created } = makeTargetClient()
const { ctx } = makeHandlerCtx(client, {
[sourceProductId]: targetProductIdA,
[otherSourceProductId]: targetProductIdB
})
const handler = createProductVersionHandler()
const va = productVersion('test:version:a', sourceProductId)
const vb = productVersion('test:version:b', otherSourceProductId)
const ta = await handler.resolve(va, ctx)
const tb = await handler.resolve(vb, ctx)
expect(ta).not.toEqual(tb)
expect(client.createDoc).toHaveBeenCalledTimes(2)
expect(created).toHaveLength(2)
expect(created[0].space).toEqual(targetProductIdA)
expect(created[1].space).toEqual(targetProductIdB)
})
it('exposes ProductVersion as the matched class', () => {
const handler = createProductVersionHandler()
expect(handler.class).toEqual(productsPlugin.class.ProductVersion)
})
})
describe('DocumentExporter custom handler dispatch', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('short-circuits ProductVersion export through the registered handler and records idMapping', async () => {
const state = makeState()
const context = makeContext()
const fakeTargetVersionId = 'test:version:target-fixed' as Ref<ProductVersion>
// Hand-rolled handler so the test does not depend on real createDoc behavior.
const handler: CustomExportHandler<ProductVersion> = {
class: productsPlugin.class.ProductVersion,
resolve: jest.fn(async () => fakeTargetVersionId)
}
const targetClient = {
getHierarchy: () => ({
isDerived: (cls: Ref<Class<Doc>>, base: Ref<Class<Doc>>) =>
cls === base || (cls === productsPlugin.class.ProductVersion && base === productsPlugin.class.ProductVersion)
})
} as unknown as TxOperations
const spaceExporter = {
getOrCreateTargetSpace: jest.fn()
} as unknown as SpaceExporter
// These should never be invoked when the handler short-circuits.
const dataMapper = { prepareDocumentData: jest.fn() } as unknown as DataMapper
const attachmentExporter = {
exportAttachments: jest.fn(),
exportCollaborativeContent: jest.fn()
} as unknown as AttachmentExporter
const relationExporter = {
exportForwardRelations: jest.fn(),
exportInverseRelations: jest.fn(),
exportAllRelations: jest.fn()
} as unknown as RelationExporter
const exporter = new DocumentExporter(context, targetClient, state, dataMapper, spaceExporter, attachmentExporter)
exporter.setRelationExporter(relationExporter)
exporter.setCustomHandlers([handler as unknown as CustomExportHandler])
const sourceVersion = productVersion('test:version:source-from-doc-exporter', sourceProductId)
const result = await exporter.exportDocument(
sourceVersion,
'duplicate',
true,
{} as unknown as Hierarchy,
{} as unknown as LowLevelStorage,
new Map(),
[]
)
expect(result).toBe(true)
expect(handler.resolve).toHaveBeenCalledTimes(1)
expect(state.idMapping.get(sourceVersion._id as Ref<Doc>)).toEqual(fakeTargetVersionId)
// The default flow's createDoc/dataMapper/relations paths must not run.
expect(dataMapper.prepareDocumentData).not.toHaveBeenCalled()
expect(spaceExporter.getOrCreateTargetSpace).not.toHaveBeenCalled()
expect(relationExporter.exportForwardRelations).not.toHaveBeenCalled()
expect(relationExporter.exportInverseRelations).not.toHaveBeenCalled()
})
it('falls through to the default flow when no handler matches the doc class', async () => {
const state = makeState()
const context = makeContext()
const handler: CustomExportHandler<ProductVersion> = {
class: productsPlugin.class.ProductVersion,
resolve: jest.fn(async () => 'should-not-be-used' as Ref<Doc>)
}
const targetClient = {
getHierarchy: () => ({
isDerived: (_cls: Ref<Class<Doc>>, _base: Ref<Class<Doc>>) => false
})
} as unknown as TxOperations
const spaceExporter = {
getOrCreateTargetSpace: jest.fn(async () => 'test:target:space' as Ref<Space>)
} as unknown as SpaceExporter
const dataMapper = { prepareDocumentData: jest.fn(async () => ({})) } as unknown as DataMapper
const attachmentExporter = {
exportAttachments: jest.fn(),
exportCollaborativeContent: jest.fn()
} as unknown as AttachmentExporter
const relationExporter = {
exportForwardRelations: jest.fn(),
exportInverseRelations: jest.fn(),
exportAllRelations: jest.fn()
} as unknown as RelationExporter
// Provide a target client with the createDoc stub the default flow uses.
;(targetClient as any).createDoc = jest.fn(async () => 'test:doc:created' as Ref<Doc>)
;(targetClient as any).addCollection = jest.fn(async () => 'test:doc:attached' as Ref<AttachedDoc>)
const exporter = new DocumentExporter(context, targetClient, state, dataMapper, spaceExporter, attachmentExporter)
exporter.setRelationExporter(relationExporter)
exporter.setCustomHandlers([handler as unknown as CustomExportHandler])
const unrelatedClass = 'test:class:Unrelated' as Ref<Class<Doc>>
const unrelatedDoc: Doc = {
_id: 'test:doc:unrelated' as Ref<Doc>,
_class: unrelatedClass,
space: 'test:source:space' as Ref<Space>,
modifiedOn: 0,
modifiedBy: 'test:account:user' as any
}
const sourceHierarchy = {
isDerived: jest.fn(() => false),
findDomain: jest.fn(() => 'domain:test'),
getAllAttributes: jest.fn(() => new Map())
} as unknown as Hierarchy
const sourceLowLevel = {
rawFindAll: jest.fn(async () => [])
} as unknown as LowLevelStorage
const result = await exporter.exportDocument(
unrelatedDoc,
'duplicate',
true,
sourceHierarchy,
sourceLowLevel,
new Map(),
[]
)
expect(result).toBe(true)
expect(handler.resolve).not.toHaveBeenCalled()
// Default flow ran: space exporter, data mapper, and createDoc were invoked.
expect(spaceExporter.getOrCreateTargetSpace).toHaveBeenCalledTimes(1)
expect(dataMapper.prepareDocumentData).toHaveBeenCalledTimes(1)
expect((targetClient as any).createDoc).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,83 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { generateId, type Data, type Ref } from '@hcengineering/core'
import productsPlugin, { ProductVersionState, type Product, type ProductVersion } from '@hcengineering/products'
import { type CustomExportHandler } from '../workspace/types'
/**
* Custom export handler that consolidates all source ProductVersions of a
* given Product onto a single 1.0.0 ProductVersion in the target Product.
*/
export function createProductVersionHandler (): CustomExportHandler {
const cache = new Map<Ref<Product>, Ref<ProductVersion>>()
return {
class: productsPlugin.class.ProductVersion,
resolve: async (sourceDoc, ctx) => {
const sourceVersion = sourceDoc as ProductVersion
const targetSpace = await ctx.spaceExporter.getOrCreateTargetSpace(
sourceVersion.space,
ctx.sourceHierarchy,
ctx.sourceLowLevel
)
const targetProductId = targetSpace as Ref<Product>
const cached = cache.get(targetProductId)
if (cached !== undefined) {
ctx.context.info(`Mapping source ProductVersion ${sourceVersion._id} onto cached target 1.0.0 ${cached}`)
return cached
}
const existing = await ctx.targetClient.findOne(productsPlugin.class.ProductVersion, {
space: targetProductId,
major: 1,
minor: 0,
patch: 0
})
let targetVersionId: Ref<ProductVersion>
if (existing !== undefined) {
targetVersionId = existing._id
ctx.context.info(
`Reusing existing 1.0.0 ProductVersion ${targetVersionId} in product ${targetProductId} ` +
`(source version ${sourceVersion._id})`
)
} else {
targetVersionId = generateId<ProductVersion>()
const data: Data<ProductVersion> = {
name: '1.0.0',
readonly: false,
major: 1,
minor: 0,
patch: 0,
description: '',
state: ProductVersionState.Active,
parent: productsPlugin.ids.NoParentVersion
}
await ctx.targetClient.createDoc(productsPlugin.class.ProductVersion, targetProductId, data, targetVersionId)
ctx.context.info(
`Created 1.0.0 ProductVersion ${targetVersionId} in product ${targetProductId} ` +
`(consolidating source version ${sourceVersion._id})`
)
}
cache.set(targetProductId, targetVersionId)
return targetVersionId
}
}
}
+3 -1
View File
@@ -78,6 +78,7 @@ import envConfig from './config'
import { ApiError } from './error'
import { ExportFormat, WorkspaceExporter } from './exporter'
import { CrossWorkspaceExporter, type ExportOptions, type ExportResult } from './workspace'
import { createProductVersionHandler } from './handlers/product-version-handler'
const extractCookieToken = (cookie?: string): string | null => {
if (cookie === undefined || cookie === null) {
@@ -541,7 +542,8 @@ export function createServer (
relations,
fieldMappers,
skipDeletedObsolete: skipDeletedObsolete ?? true,
exportOnlyEffective: exportOnlyEffective ?? false
exportOnlyEffective: exportOnlyEffective ?? false,
customHandlers: [createProductVersionHandler()]
}
const exportResult: ExportResult = await exporter.export(options)
@@ -32,7 +32,7 @@ import { type AttachmentExporter } from './attachment-exporter'
import { type DataMapper } from './data-mapper'
import { type RelationExporter } from './relation-exporter'
import { type SpaceExporter } from './space-exporter'
import { type ExportState, type RelationDefinition } from './types'
import { type CustomExportHandler, type ExportState, type RelationDefinition } from './types'
/**
* Handles document export logic
@@ -40,6 +40,7 @@ import { type ExportState, type RelationDefinition } from './types'
export class DocumentExporter {
private relationExporter: RelationExporter | undefined
private dataMapper: DataMapper
private customHandlers: CustomExportHandler[] = []
constructor (
private readonly context: MeasureContext,
@@ -60,6 +61,10 @@ export class DocumentExporter {
this.dataMapper = dataMapper
}
setCustomHandlers (handlers: CustomExportHandler[]): void {
this.customHandlers = handlers
}
/**
* Export a single document
*/
@@ -80,6 +85,13 @@ export class DocumentExporter {
return false
}
// Class-specific custom handlers take precedence over the default flow.
const customTargetId = await this.runCustomHandlers(doc, sourceHierarchy, sourceLowLevel)
if (customTargetId !== undefined) {
this.state.idMapping.set(doc._id, customTargetId)
return true
}
if (conflictStrategy === 'skip') {
const existingDoc = existingDocsMap.get(doc._id)
if (existingDoc !== undefined) {
@@ -166,6 +178,51 @@ export class DocumentExporter {
}
}
/**
* Run any registered custom handlers whose class matches `doc._class`.
* Returns the target id from the first handler that resolves the doc.
* Returns `undefined` when no handler matches or all handlers fall through.
*/
private async runCustomHandlers (
doc: Doc,
sourceHierarchy: Hierarchy,
sourceLowLevel: LowLevelStorage
): Promise<Ref<Doc> | undefined> {
if (this.customHandlers.length === 0) {
return undefined
}
const targetHierarchy = this.targetClient.getHierarchy()
for (const handler of this.customHandlers) {
if (!targetHierarchy.isDerived(doc._class, handler.class)) {
continue
}
try {
const targetId = await handler.resolve(doc, {
context: this.context,
targetClient: this.targetClient,
state: this.state,
spaceExporter: this.spaceExporter,
sourceHierarchy,
sourceLowLevel
})
if (targetId !== undefined) {
return targetId
}
} catch (err: any) {
this.context.error(`Custom export handler for ${handler.class} failed on ${doc._id}:`, {
error: err instanceof Error ? err.message : String(err),
docId: doc._id
})
throw err
}
}
return undefined
}
private async exportSpaceRelations (
doc: Doc,
space: Ref<Space>,
@@ -17,15 +17,46 @@ import {
type Class,
type Doc,
type DocumentQuery,
type Hierarchy,
type LowLevelStorage,
type MeasureContext,
type Ref,
type Space,
type TxOperations,
type WorkspaceIds
} from '@hcengineering/core'
import { type Pipeline } from '@hcengineering/server-core'
export type PipelineFactory = (ctx: MeasureContext, workspace: WorkspaceIds) => Promise<Pipeline>
export interface CustomExportHandlerContext {
context: MeasureContext
targetClient: TxOperations
state: ExportState
spaceExporter: {
getOrCreateTargetSpace: (
sourceSpaceId: Ref<Space>,
sourceHierarchy: Hierarchy,
sourceLowLevel: LowLevelStorage
) => Promise<Ref<Space>>
}
sourceHierarchy: Hierarchy
sourceLowLevel: LowLevelStorage
}
/**
* Hook for class-specific export behavior.
*
* When the exporter encounters a document derived from `class`, it calls
* `resolve` instead of the default duplicate-and-remap flow. The handler is
* responsible for finding or creating the appropriate target document and
* returning its id.
*/
export interface CustomExportHandler<T extends Doc = Doc> {
class: Ref<Class<T>>
resolve: (sourceDoc: T, ctx: CustomExportHandlerContext) => Promise<Ref<Doc> | undefined>
}
export interface RelationDefinition {
/** When set, this relation applies only to documents of this class (or its subclasses). */
sourceClass?: Ref<Class<Doc>>
@@ -53,6 +84,9 @@ export interface ExportOptions {
skipDeletedObsolete?: boolean
// Whether to export only documents with effective status
exportOnlyEffective?: boolean
// Class-specific export handlers, applied before the default flow. Useful
// for collapsing or deduplicating documents (e.g. ProductVersion).
customHandlers?: CustomExportHandler[]
}
export interface ExportResult {
@@ -127,7 +127,8 @@ export class CrossWorkspaceExporter {
relations = [],
fieldMappers = {},
skipDeletedObsolete = true,
exportOnlyEffective = false
exportOnlyEffective = false,
customHandlers = []
} = options
// Store field mappers
@@ -142,6 +143,8 @@ export class CrossWorkspaceExporter {
)
// Update document exporter with new data mapper
this.documentExporter.setDataMapper(this.dataMapper)
// Register custom export handlers for this run
this.documentExporter.setCustomHandlers(customHandlers)
// Pre-fetch current account's employee ID if available
if (this.currentAccount !== undefined) {