Fix missing collaborative content in export (#10432)

* Fix missing template content

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

* Clean up

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

* Fix warnings

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

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-01-23 09:43:02 +07:00
committed by GitHub
parent 5f00f623df
commit 90af23b9c7
3 changed files with 782 additions and 1 deletions
@@ -0,0 +1,654 @@
//
// Copyright © 2025 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 Blob,
type Class,
type Doc,
type Hierarchy,
type MeasureContext,
type Mixin,
type Ref,
type TxOperations,
type WorkspaceIds,
generateId
} from '@hcengineering/core'
import core from '@hcengineering/model-core'
import { type StorageAdapter } from '@hcengineering/server-core'
import { Buffer } from 'buffer'
import { AttachmentExporter } from '../workspace/attachment-exporter'
function createMockMeasureContext (): MeasureContext {
return {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
contextData: {},
newChild: jest.fn(),
with: jest.fn(),
withSync: jest.fn(),
extractMeta: jest.fn(),
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
logOperation: jest.fn(),
childLogger: jest.fn(),
close: jest.fn()
},
measure: jest.fn(),
end: jest.fn(),
getParams: jest.fn()
} as unknown as MeasureContext
}
function createMockStorageAdapter (): StorageAdapter {
return {
read: jest.fn(),
stat: jest.fn(),
put: jest.fn(),
remove: jest.fn(),
list: jest.fn(),
exists: jest.fn()
} as unknown as StorageAdapter
}
function createMockHierarchy (): Hierarchy {
return {
getAllAttributes: jest.fn(() => new Map()),
isMixin: jest.fn(() => false),
hasMixin: jest.fn(() => undefined),
findDomain: jest.fn(),
isDerived: jest.fn(),
getClass: jest.fn(),
findAttribute: jest.fn()
} as unknown as Hierarchy
}
function createMockTxOperations (): TxOperations {
return {
findAll: jest.fn(),
findOne: jest.fn(),
getHierarchy: jest.fn(() => createMockHierarchy()),
createDoc: jest.fn(),
addCollection: jest.fn(),
updateDoc: jest.fn()
} as unknown as TxOperations
}
describe('AttachmentExporter - exportCollaborativeContent', () => {
let mockContext: MeasureContext
let mockStorage: StorageAdapter
let mockClient: TxOperations
let mockHierarchy: Hierarchy
let sourceWorkspace: WorkspaceIds
let targetWorkspace: WorkspaceIds
let exporter: AttachmentExporter
beforeEach(() => {
mockContext = createMockMeasureContext()
mockStorage = createMockStorageAdapter()
mockClient = createMockTxOperations()
mockHierarchy = createMockHierarchy()
sourceWorkspace = { uuid: 'source-ws' as any, url: 'ws://source' }
targetWorkspace = { uuid: 'target-ws' as any, url: 'ws://target' }
exporter = new AttachmentExporter(mockContext, mockClient, mockStorage, sourceWorkspace, targetWorkspace)
jest.clearAllMocks()
})
describe('Success cases', () => {
it('should export collaborative content blob for a document with collaborative field', async () => {
const blobRef = 'blob-123' as Ref<Blob>
const blobData = Buffer.from('test content')
const blobSize = blobData.length
const contentType = 'application/json'
const sourceDoc: Doc = {
_id: generateId(),
_class: 'test:class:Document' as Ref<Class<Doc>>,
space: generateId(),
modifiedOn: Date.now(),
modifiedBy: generateId(),
content: blobRef
} as any
const attributes = new Map([
[
'content',
{
_id: 'test:attr:content' as any,
label: 'Content',
type: { _class: core.class.TypeCollaborativeDoc }
}
]
])
;(mockHierarchy.getAllAttributes as jest.Mock).mockReturnValue(attributes)
;(mockStorage.read as jest.Mock).mockResolvedValue([blobData])
;(mockStorage.stat as jest.Mock).mockResolvedValue({
size: blobSize,
contentType
})
;(mockStorage.put as jest.Mock).mockResolvedValue(undefined)
await exporter.exportCollaborativeContent(sourceDoc, mockHierarchy)
expect(mockStorage.read).toHaveBeenCalledWith(mockContext, sourceWorkspace, blobRef)
expect(mockStorage.stat).toHaveBeenCalledWith(mockContext, sourceWorkspace, blobRef)
expect(mockStorage.put).toHaveBeenCalledWith(
mockContext,
targetWorkspace,
blobRef,
blobData,
contentType,
blobSize
)
expect(mockContext.info).toHaveBeenCalledWith(
expect.stringContaining(`Copied collaborative content blob ${blobRef}`)
)
})
it('should handle multiple collaborative content fields', async () => {
const blobRef1 = 'blob-1' as Ref<Blob>
const blobRef2 = 'blob-2' as Ref<Blob>
const blobData1 = Buffer.from('content 1')
const blobData2 = Buffer.from('content 2')
const sourceDoc: Doc = {
_id: generateId(),
_class: 'test:class:Document' as Ref<Class<Doc>>,
space: generateId(),
modifiedOn: Date.now(),
modifiedBy: generateId(),
content: blobRef1,
description: blobRef2
} as any
const attributes = new Map([
[
'content',
{
_id: 'test:attr:content' as any,
label: 'Content',
type: { _class: core.class.TypeCollaborativeDoc }
}
],
[
'description',
{
_id: 'test:attr:description' as any,
label: 'Description',
type: { _class: core.class.TypeCollaborativeDoc }
}
]
])
;(mockHierarchy.getAllAttributes as jest.Mock).mockReturnValue(attributes)
;(mockStorage.read as jest.Mock).mockResolvedValueOnce([blobData1]).mockResolvedValueOnce([blobData2])
;(mockStorage.stat as jest.Mock)
.mockResolvedValueOnce({ size: blobData1.length, contentType: 'application/json' })
.mockResolvedValueOnce({ size: blobData2.length, contentType: 'application/json' })
;(mockStorage.put as jest.Mock).mockResolvedValue(undefined)
await exporter.exportCollaborativeContent(sourceDoc, mockHierarchy)
expect(mockStorage.put).toHaveBeenCalledTimes(2)
expect(mockContext.info).toHaveBeenCalledTimes(2)
})
it('should skip non-collaborative fields', async () => {
const sourceDoc: Doc = {
_id: generateId(),
_class: 'test:class:Document' as Ref<Class<Doc>>,
space: generateId(),
modifiedOn: Date.now(),
modifiedBy: generateId(),
title: 'Test Title'
} as any
const attributes = new Map([
[
'title',
{
_id: 'test:attr:title' as any,
label: 'Title',
type: { _class: 'core:class:TypeString' as Ref<Class<Doc>> }
}
]
])
;(mockHierarchy.getAllAttributes as jest.Mock).mockReturnValue(attributes)
await exporter.exportCollaborativeContent(sourceDoc, mockHierarchy)
expect(mockStorage.read).not.toHaveBeenCalled()
expect(mockStorage.put).not.toHaveBeenCalled()
})
it('should skip null or undefined blob references', async () => {
const sourceDoc: Doc = {
_id: generateId(),
_class: 'test:class:Document' as Ref<Class<Doc>>,
space: generateId(),
modifiedOn: Date.now(),
modifiedBy: generateId(),
content: null
} as any
const attributes = new Map([
[
'content',
{
_id: 'test:attr:content' as any,
label: 'Content',
type: { _class: core.class.TypeCollaborativeDoc }
}
]
])
;(mockHierarchy.getAllAttributes as jest.Mock).mockReturnValue(attributes)
await exporter.exportCollaborativeContent(sourceDoc, mockHierarchy)
expect(mockStorage.read).not.toHaveBeenCalled()
expect(mockStorage.put).not.toHaveBeenCalled()
})
it('should handle mixin attributes with collaborative content', async () => {
const blobRef = 'blob-mixin' as Ref<Blob>
const blobData = Buffer.from('mixin content')
const mixinName = 'test:mixin:CustomMixin' as Ref<Mixin<Doc>>
const sourceDoc: Doc = {
_id: generateId(),
_class: 'test:class:Document' as Ref<Class<Doc>>,
space: generateId(),
modifiedOn: Date.now(),
modifiedBy: generateId(),
[mixinName]: {
customContent: blobRef
}
} as any
const attributes = new Map()
const mixinAttrs = new Map([
[
'customContent',
{
_id: 'test:attr:customContent' as any,
label: 'Custom Content',
type: { _class: core.class.TypeCollaborativeDoc }
}
]
])
;(mockHierarchy.getAllAttributes as jest.Mock).mockReturnValue(attributes)
;(mockHierarchy.isMixin as jest.Mock).mockImplementation((key: string) => key === mixinName)
;(mockHierarchy.hasMixin as jest.Mock).mockReturnValue(true)
;(mockHierarchy.getAllAttributes as jest.Mock).mockImplementation((classRef: any) => {
if (classRef === mixinName) {
return mixinAttrs
}
return attributes
})
;(mockStorage.read as jest.Mock).mockResolvedValue([blobData])
;(mockStorage.stat as jest.Mock).mockResolvedValue({
size: blobData.length,
contentType: 'application/json'
})
;(mockStorage.put as jest.Mock).mockResolvedValue(undefined)
await exporter.exportCollaborativeContent(sourceDoc, mockHierarchy)
expect(mockStorage.read).toHaveBeenCalledWith(mockContext, sourceWorkspace, blobRef)
expect(mockStorage.put).toHaveBeenCalled()
expect(mockContext.info).toHaveBeenCalledWith(
expect.stringContaining(`Copied collaborative content blob ${blobRef}`)
)
})
})
describe('Error handling', () => {
it('should handle storage read errors gracefully', async () => {
const blobRef = 'blob-error' as Ref<Blob>
const readError = new Error('Storage read failed')
const sourceDoc: Doc = {
_id: generateId(),
_class: 'test:class:Document' as Ref<Class<Doc>>,
space: generateId(),
modifiedOn: Date.now(),
modifiedBy: generateId(),
content: blobRef
} as any
const attributes = new Map([
[
'content',
{
_id: 'test:attr:content' as any,
label: 'Content',
type: { _class: core.class.TypeCollaborativeDoc }
}
]
])
;(mockHierarchy.getAllAttributes as jest.Mock).mockReturnValue(attributes)
;(mockStorage.read as jest.Mock).mockRejectedValue(readError)
await exporter.exportCollaborativeContent(sourceDoc, mockHierarchy)
expect(mockContext.error).toHaveBeenCalledWith(
expect.stringContaining(`Failed to copy collaborative content blob ${blobRef}`),
expect.objectContaining({
error: readError.message,
blobRef,
field: 'content'
})
)
})
it('should handle missing blob data gracefully', async () => {
const blobRef = 'blob-missing' as Ref<Blob>
const sourceDoc: Doc = {
_id: generateId(),
_class: 'test:class:Document' as Ref<Class<Doc>>,
space: generateId(),
modifiedOn: Date.now(),
modifiedBy: generateId(),
content: blobRef
} as any
const attributes = new Map([
[
'content',
{
_id: 'test:attr:content' as any,
label: 'Content',
type: { _class: core.class.TypeCollaborativeDoc }
}
]
])
;(mockHierarchy.getAllAttributes as jest.Mock).mockReturnValue(attributes)
;(mockStorage.read as jest.Mock).mockResolvedValue(undefined)
await exporter.exportCollaborativeContent(sourceDoc, mockHierarchy)
expect(mockContext.warn).toHaveBeenCalledWith(
expect.stringContaining(`Blob ${blobRef} (field content) not found in source workspace`)
)
expect(mockStorage.put).not.toHaveBeenCalled()
})
it('should handle missing blob metadata gracefully', async () => {
const blobRef = 'blob-no-metadata' as Ref<Blob>
const blobData = Buffer.from('test')
const sourceDoc: Doc = {
_id: generateId(),
_class: 'test:class:Document' as Ref<Class<Doc>>,
space: generateId(),
modifiedOn: Date.now(),
modifiedBy: generateId(),
content: blobRef
} as any
const attributes = new Map([
[
'content',
{
_id: 'test:attr:content' as any,
label: 'Content',
type: { _class: core.class.TypeCollaborativeDoc }
}
]
])
;(mockHierarchy.getAllAttributes as jest.Mock).mockReturnValue(attributes)
;(mockStorage.read as jest.Mock).mockResolvedValue([blobData])
;(mockStorage.stat as jest.Mock).mockResolvedValue(undefined)
await exporter.exportCollaborativeContent(sourceDoc, mockHierarchy)
expect(mockContext.warn).toHaveBeenCalledWith(
expect.stringContaining(`Blob metadata not found for ${blobRef} (field content)`)
)
expect(mockStorage.put).not.toHaveBeenCalled()
})
it('should handle storage put errors gracefully', async () => {
const blobRef = 'blob-put-error' as Ref<Blob>
const blobData = Buffer.from('test')
const putError = new Error('Storage put failed')
const sourceDoc: Doc = {
_id: generateId(),
_class: 'test:class:Document' as Ref<Class<Doc>>,
space: generateId(),
modifiedOn: Date.now(),
modifiedBy: generateId(),
content: blobRef
} as any
const attributes = new Map([
[
'content',
{
_id: 'test:attr:content' as any,
label: 'Content',
type: { _class: core.class.TypeCollaborativeDoc }
}
]
])
;(mockHierarchy.getAllAttributes as jest.Mock).mockReturnValue(attributes)
;(mockStorage.read as jest.Mock).mockResolvedValue([blobData])
;(mockStorage.stat as jest.Mock).mockResolvedValue({
size: blobData.length,
contentType: 'application/json'
})
;(mockStorage.put as jest.Mock).mockRejectedValue(putError)
await exporter.exportCollaborativeContent(sourceDoc, mockHierarchy)
expect(mockContext.error).toHaveBeenCalledWith(
expect.stringContaining(`Failed to copy collaborative content blob ${blobRef}`),
expect.objectContaining({
error: putError.message,
blobRef,
field: 'content'
})
)
})
it('should handle errors in getAllAttributes and re-throw', async () => {
const sourceDoc: Doc = {
_id: generateId(),
_class: 'test:class:Document' as Ref<Class<Doc>>,
space: generateId(),
modifiedOn: Date.now(),
modifiedBy: generateId()
} as any
const hierarchyError = new Error('Hierarchy error')
;(mockHierarchy.getAllAttributes as jest.Mock).mockImplementation(() => {
throw hierarchyError
})
await expect(exporter.exportCollaborativeContent(sourceDoc, mockHierarchy)).rejects.toThrow(hierarchyError)
expect(mockContext.error).toHaveBeenCalledWith(
expect.stringContaining(`Failed to export collaborative content for document ${sourceDoc._id}`),
expect.objectContaining({
error: hierarchyError.message,
docId: sourceDoc._id,
docClass: sourceDoc._class
})
)
})
it('should handle multiple buffers correctly', async () => {
const blobRef = 'blob-multi' as Ref<Blob>
const buffer1 = Buffer.from('chunk1')
const buffer2 = Buffer.from('chunk2')
const buffer3 = Buffer.from('chunk3')
const totalSize = buffer1.length + buffer2.length + buffer3.length
const combinedBuffer = Buffer.concat([buffer1, buffer2, buffer3])
const sourceDoc: Doc = {
_id: generateId(),
_class: 'test:class:Document' as Ref<Class<Doc>>,
space: generateId(),
modifiedOn: Date.now(),
modifiedBy: generateId(),
content: blobRef
} as any
const attributes = new Map([
[
'content',
{
_id: 'test:attr:content' as any,
label: 'Content',
type: { _class: core.class.TypeCollaborativeDoc }
}
]
])
;(mockHierarchy.getAllAttributes as jest.Mock).mockReturnValue(attributes)
;(mockStorage.read as jest.Mock).mockResolvedValue([buffer1, buffer2, buffer3])
;(mockStorage.stat as jest.Mock).mockResolvedValue({
size: totalSize,
contentType: 'application/json'
})
;(mockStorage.put as jest.Mock).mockResolvedValue(undefined)
await exporter.exportCollaborativeContent(sourceDoc, mockHierarchy)
expect(mockStorage.put).toHaveBeenCalledWith(
mockContext,
targetWorkspace,
blobRef,
combinedBuffer,
'application/json',
totalSize
)
})
it('should use default contentType when not provided', async () => {
const blobRef = 'blob-default-ct' as Ref<Blob>
const blobData = Buffer.from('test')
const sourceDoc: Doc = {
_id: generateId(),
_class: 'test:class:Document' as Ref<Class<Doc>>,
space: generateId(),
modifiedOn: Date.now(),
modifiedBy: generateId(),
content: blobRef
} as any
const attributes = new Map([
[
'content',
{
_id: 'test:attr:content' as any,
label: 'Content',
type: { _class: core.class.TypeCollaborativeDoc }
}
]
])
;(mockHierarchy.getAllAttributes as jest.Mock).mockReturnValue(attributes)
;(mockStorage.read as jest.Mock).mockResolvedValue([blobData])
;(mockStorage.stat as jest.Mock).mockResolvedValue({
size: blobData.length,
contentType: null
})
;(mockStorage.put as jest.Mock).mockResolvedValue(undefined)
await exporter.exportCollaborativeContent(sourceDoc, mockHierarchy)
expect(mockStorage.put).toHaveBeenCalledWith(
mockContext,
targetWorkspace,
blobRef,
blobData,
'application/json',
blobData.length
)
})
})
describe('Edge cases', () => {
it('should handle empty document with no attributes', async () => {
const sourceDoc: Doc = {
_id: generateId(),
_class: 'test:class:Document' as Ref<Class<Doc>>,
space: generateId(),
modifiedOn: Date.now(),
modifiedBy: generateId()
} as any
;(mockHierarchy.getAllAttributes as jest.Mock).mockReturnValue(new Map())
await exporter.exportCollaborativeContent(sourceDoc, mockHierarchy)
expect(mockStorage.read).not.toHaveBeenCalled()
expect(mockStorage.put).not.toHaveBeenCalled()
})
it('should skip mixin keys that are not actual mixins', async () => {
const sourceDoc: Doc = {
_id: generateId(),
_class: 'test:class:Document' as Ref<Class<Doc>>,
space: generateId(),
modifiedOn: Date.now(),
modifiedBy: generateId(),
regularField: 'not a mixin'
} as any
;(mockHierarchy.getAllAttributes as jest.Mock).mockReturnValue(new Map())
;(mockHierarchy.isMixin as jest.Mock).mockReturnValue(false)
await exporter.exportCollaborativeContent(sourceDoc, mockHierarchy)
expect(mockStorage.read).not.toHaveBeenCalled()
})
it('should skip mixin data that is not an object', async () => {
const mixinName = 'test:mixin:CustomMixin' as Ref<Mixin<Doc>>
const sourceDoc: Doc = {
_id: generateId(),
_class: 'test:class:Document' as Ref<Class<Doc>>,
space: generateId(),
modifiedOn: Date.now(),
modifiedBy: generateId(),
[mixinName]: 'not an object'
} as any
;(mockHierarchy.getAllAttributes as jest.Mock).mockReturnValue(new Map())
;(mockHierarchy.isMixin as jest.Mock).mockImplementation((key: string) => key === mixinName)
;(mockHierarchy.hasMixin as jest.Mock).mockReturnValue(true)
await exporter.exportCollaborativeContent(sourceDoc, mockHierarchy)
expect(mockStorage.read).not.toHaveBeenCalled()
})
})
})
@@ -18,13 +18,15 @@ import {
type Blob,
type Class,
type Doc,
type Hierarchy,
Hierarchy,
type LowLevelStorage,
type MeasureContext,
type Mixin,
type Ref,
type TxOperations,
type WorkspaceIds
} from '@hcengineering/core'
import core from '@hcengineering/model-core'
import { type StorageAdapter } from '@hcengineering/server-core'
import { Buffer } from 'buffer'
@@ -86,6 +88,128 @@ export class AttachmentExporter {
}
}
/**
* Export collaborative content blobs for a document.
* Finds all TypeCollaborativeDoc fields and copies their blob references.
*/
async exportCollaborativeContent (sourceDoc: Doc, sourceHierarchy: Hierarchy): Promise<void> {
try {
const attributes = sourceHierarchy.getAllAttributes(sourceDoc._class)
for (const [key, attr] of Array.from(attributes)) {
if (attr.type._class !== core.class.TypeCollaborativeDoc) {
continue
}
const blobRef = (sourceDoc as any)[key] as Ref<Blob> | undefined | null
if (blobRef === undefined || blobRef === null || typeof blobRef !== 'string') {
continue
}
try {
const blobBuffers = await this.storage.read(this.context, this.sourceWorkspace, blobRef as string)
if (blobBuffers !== undefined && blobBuffers.length > 0) {
const sourceBlob = await this.storage.stat(this.context, this.sourceWorkspace, blobRef as string)
if (sourceBlob !== undefined) {
const totalSize = blobBuffers.reduce((sum, buf) => sum + buf.length, 0)
const combinedBuffer = Buffer.concat(blobBuffers)
await this.storage.put(
this.context,
this.targetWorkspace,
blobRef as string,
combinedBuffer,
sourceBlob.contentType ?? 'application/json',
totalSize
)
this.context.info(`Copied collaborative content blob ${blobRef} (${totalSize} bytes) for field ${key}`)
} else {
this.context.warn(`Blob metadata not found for ${blobRef} (field ${key}), skipping blob copy`)
}
} else {
this.context.warn(`Blob ${blobRef} (field ${key}) not found in source workspace, skipping blob copy`)
}
} catch (err: any) {
this.context.error(`Failed to copy collaborative content blob ${blobRef} (field ${key}):`, {
error: err instanceof Error ? err.message : String(err),
blobRef,
field: key
})
}
}
for (const key of Object.keys(sourceDoc)) {
if (key === '_id' || key === '_class' || key === 'space') {
continue
}
if (
sourceHierarchy.isMixin(key as Ref<Mixin<Doc>>) &&
sourceHierarchy.hasMixin(sourceDoc, key as Ref<Mixin<Doc>>)
) {
const mixinAttrs = sourceHierarchy.getAllAttributes(key as Ref<Mixin<Doc>>)
const mixinData = (sourceDoc as any)[key]
if (mixinData === undefined || typeof mixinData !== 'object') {
continue
}
for (const [mixinKey, attr] of Array.from(mixinAttrs)) {
if (attr.type._class !== core.class.TypeCollaborativeDoc) {
continue
}
const blobRef = mixinData[mixinKey] as Ref<Blob> | undefined | null
if (blobRef === undefined || blobRef === null || typeof blobRef !== 'string') {
continue
}
try {
const blobBuffers = await this.storage.read(this.context, this.sourceWorkspace, blobRef as string)
if (blobBuffers !== undefined && blobBuffers.length > 0) {
const sourceBlob = await this.storage.stat(this.context, this.sourceWorkspace, blobRef as string)
if (sourceBlob !== undefined) {
const totalSize = blobBuffers.reduce((sum, buf) => sum + buf.length, 0)
const combinedBuffer = Buffer.concat(blobBuffers)
await this.storage.put(
this.context,
this.targetWorkspace,
blobRef as string,
combinedBuffer,
sourceBlob.contentType ?? 'application/json',
totalSize
)
this.context.info(
`Copied collaborative content blob ${blobRef} (${totalSize} bytes) for mixin ${key} field ${mixinKey}`
)
}
}
} catch (err: any) {
this.context.error(
`Failed to copy collaborative content blob ${blobRef} (mixin ${key}, field ${mixinKey}):`,
{
error: err instanceof Error ? err.message : String(err),
blobRef,
mixin: key,
field: mixinKey
}
)
}
}
}
}
} catch (err: any) {
this.context.error(`Failed to export collaborative content for document ${sourceDoc._id}:`, {
error: err instanceof Error ? err.message : String(err),
docId: sourceDoc._id,
docClass: sourceDoc._class
})
throw err
}
}
/**
* Export a single attachment, including copying blob data
*/
@@ -141,6 +141,9 @@ export class DocumentExporter {
await this.attachmentExporter.exportAttachments(doc._id, targetId, doc._class, sourceHierarchy, sourceLowLevel)
}
// Handle collaborative content blobs (e.g., document content)
await this.attachmentExporter.exportCollaborativeContent(doc, sourceHierarchy)
// Handle collections (child documents)
await this.exportCollections(doc, targetId, sourceHierarchy, sourceLowLevel, relations)