Skip to content

Commit 1ae2a0f

Browse files
committed
clarify project data imports and recovery
1 parent ad365f0 commit 1ae2a0f

3 files changed

Lines changed: 248 additions & 135 deletions

File tree

‎packages/server/src/api/routes/tests/projectData.spec.ts‎

Lines changed: 175 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
QuotaUsageType,
1111
RelationshipType,
1212
StaticQuotaName,
13+
type ImportProjectRequest,
1314
type ImportProjectResponse,
1415
type Row,
1516
type RowAttachment,
@@ -172,14 +173,96 @@ describe("Project data export and import", () => {
172173
})
173174
})
174175

175-
it.each([undefined, "example-password"])(
176-
"imports related rows and attachments twice independently (password: %s)",
177-
async encryptPassword => {
176+
const importProjectData = async ({
177+
archive,
178+
options,
179+
}: {
180+
archive: Buffer
181+
options: ImportProjectRequest
182+
}) => {
183+
const response = await config.api.project.import(archive, options)
184+
return { response, ...(await readImportedData(response)) }
185+
}
186+
187+
const packageFormats = [
188+
{ format: "unencrypted", options: {} },
189+
{ format: "encrypted", options: { encryptPassword: "example-password" } },
190+
]
191+
192+
const readAttachmentContent = async (attachment: RowAttachment) => {
193+
const { stream } = await objectStore.getReadStream(
194+
ObjectStoreBuckets.APPS,
195+
attachment.key!
196+
)
197+
return await buffer(stream)
198+
}
199+
200+
it.each(packageFormats)(
201+
"imports related rows and attachment content from an $format package",
202+
async ({ options }) => {
178203
await withProjectsEnabled(async () => {
179204
const source = await createDataProject()
180205
const archive = await config.api.project.export(source.project._id, {
181206
includeRows: true,
182-
encryptPassword,
207+
...options,
208+
})
209+
const destination = await config.api.workspace.create({
210+
name: "Destination",
211+
})
212+
await config.withHeaders(
213+
{ [Header.WORKSPACE_ID]: destination.appId },
214+
async () => {
215+
const imported = await importProjectData({ archive, options })
216+
const openCategory = imported.categoryRows.find(
217+
row => row.name === "Open"
218+
)!
219+
const attachment: RowAttachment = imported.rows[0].attachment
220+
221+
expect(imported.response.dataImport).toEqual({
222+
tables: 2,
223+
rows: 3,
224+
relationships: 1,
225+
attachments: 1,
226+
})
227+
expect(imported.rows).toEqual([
228+
expect.objectContaining({
229+
name: "First task",
230+
description: source.description,
231+
autoId: 42,
232+
tableId: imported.tasks._id,
233+
category: [expect.objectContaining({ _id: openCategory._id })],
234+
attachment: expect.objectContaining({
235+
name: source.attachment.name,
236+
size: attachmentContent.length,
237+
}),
238+
gallery: [expect.objectContaining({ key: attachment.key })],
239+
}),
240+
])
241+
expect(imported.categoryRows.map(row => row.name).sort()).toEqual([
242+
"Closed",
243+
"Open",
244+
])
245+
expect(openCategory.tasks).toEqual([
246+
expect.objectContaining({ _id: imported.rows[0]._id }),
247+
])
248+
expect(attachment.key).not.toBe(source.attachment.key)
249+
expect(await readAttachmentContent(attachment)).toEqual(
250+
attachmentContent
251+
)
252+
}
253+
)
254+
})
255+
}
256+
)
257+
258+
it.each(packageFormats)(
259+
"re-imports an $format package independently, preserving existing data and counting new rows without triggering automations",
260+
async ({ options }) => {
261+
await withProjectsEnabled(async () => {
262+
const source = await createDataProject()
263+
const archive = await config.api.project.export(source.project._id, {
264+
includeRows: true,
265+
...options,
183266
})
184267
const destination = await config.api.workspace.create({
185268
name: "Destination",
@@ -203,72 +286,75 @@ describe("Project data export and import", () => {
203286
)
204287
const emitRow = jest.spyOn(BudibaseEmitter.prototype, "emitRow")
205288
try {
206-
const imports = []
207-
for (let i = 0; i < 2; i++) {
208-
const imported = await config.api.project.import(
209-
archive,
210-
encryptPassword ? { encryptPassword } : undefined
211-
)
212-
const data = await readImportedData(imported)
213-
const openCategory = data.categoryRows.find(
214-
row => row.name === "Open"
215-
)!
216-
const attachment: RowAttachment = data.rows[0].attachment
217-
const { stream } = await objectStore.getReadStream(
218-
ObjectStoreBuckets.APPS,
219-
attachment.key!
220-
)
221-
222-
expect(imported.dataImport).toEqual({
223-
tables: 2,
224-
rows: 3,
225-
relationships: 1,
226-
attachments: 1,
227-
})
228-
expect(data.rows).toEqual([
229-
expect.objectContaining({
230-
name: "First task",
231-
description: source.description,
232-
autoId: 42,
233-
tableId: data.tasks._id,
234-
category: [
235-
expect.objectContaining({ _id: openCategory._id }),
236-
],
237-
attachment: expect.objectContaining({
238-
name: source.attachment.name,
239-
size: attachmentContent.length,
240-
}),
241-
gallery: [expect.objectContaining({ key: attachment.key })],
242-
}),
243-
])
244-
expect(data.categoryRows.map(row => row.name).sort()).toEqual([
245-
"Closed",
246-
"Open",
247-
])
248-
expect(openCategory.tasks).toEqual([
249-
expect.objectContaining({ _id: data.rows[0]._id }),
250-
])
251-
expect(attachment.key).not.toBe(source.attachment.key)
252-
expect(await buffer(stream)).toEqual(attachmentContent)
253-
imports.push(data)
254-
}
289+
const firstImport = await importProjectData({ archive, options })
290+
const secondImport = await importProjectData({ archive, options })
291+
const firstOpenCategory = firstImport.categoryRows.find(
292+
row => row.name === "Open"
293+
)!
294+
const secondOpenCategory = secondImport.categoryRows.find(
295+
row => row.name === "Open"
296+
)!
297+
const firstAttachment: RowAttachment =
298+
firstImport.rows[0].attachment
299+
const secondAttachment: RowAttachment =
300+
secondImport.rows[0].attachment
301+
const usageAfter = await config.doInContext(
302+
destination.appId,
303+
() =>
304+
quotas.getCurrentUsageValues(
305+
QuotaUsageType.STATIC,
306+
StaticQuotaName.ROWS
307+
)
308+
)
255309

256310
expect(
257311
new Set([
258312
source.tasks._id,
259-
imports[0].tasks._id,
260-
imports[1].tasks._id,
313+
firstImport.tasks._id,
314+
secondImport.tasks._id,
261315
]).size
262316
).toBe(3)
263317
expect(
264318
new Set([
265319
source.task._id,
266-
imports[0].rows[0]._id,
267-
imports[1].rows[0]._id,
320+
firstImport.rows[0]._id,
321+
secondImport.rows[0]._id,
322+
]).size
323+
).toBe(3)
324+
expect(
325+
new Set([
326+
source.categories._id,
327+
firstImport.categories._id,
328+
secondImport.categories._id,
268329
]).size
269330
).toBe(3)
270-
expect(imports[0].rows[0].attachment.key).not.toBe(
271-
imports[1].rows[0].attachment.key
331+
expect(
332+
new Set([
333+
source.category._id,
334+
firstOpenCategory._id,
335+
secondOpenCategory._id,
336+
]).size
337+
).toBe(3)
338+
expect(firstImport.rows[0].category).toEqual([
339+
expect.objectContaining({ _id: firstOpenCategory._id }),
340+
])
341+
expect(secondImport.rows[0].category).toEqual([
342+
expect.objectContaining({ _id: secondOpenCategory._id }),
343+
])
344+
expect(firstOpenCategory.tasks).toEqual([
345+
expect.objectContaining({ _id: firstImport.rows[0]._id }),
346+
])
347+
expect(secondOpenCategory.tasks).toEqual([
348+
expect.objectContaining({ _id: secondImport.rows[0]._id }),
349+
])
350+
expect(firstAttachment.key).not.toBe(source.attachment.key)
351+
expect(secondAttachment.key).not.toBe(source.attachment.key)
352+
expect(firstAttachment.key).not.toBe(secondAttachment.key)
353+
expect(await readAttachmentContent(firstAttachment)).toEqual(
354+
attachmentContent
355+
)
356+
expect(await readAttachmentContent(secondAttachment)).toEqual(
357+
attachmentContent
272358
)
273359
expect(await config.api.row.fetch(existing._id!)).toEqual([
274360
expect.objectContaining({
@@ -277,14 +363,6 @@ describe("Project data export and import", () => {
277363
}),
278364
])
279365
expect(emitRow).not.toHaveBeenCalled()
280-
const usageAfter = await config.doInContext(
281-
destination.appId,
282-
() =>
283-
quotas.getCurrentUsageValues(
284-
QuotaUsageType.STATIC,
285-
StaticQuotaName.ROWS
286-
)
287-
)
288366
expect(usageAfter.total - usageBefore.total).toBe(6)
289367
expect(usageAfter.app! - usageBefore.app!).toBe(6)
290368
} finally {
@@ -357,6 +435,33 @@ describe("Project data export and import", () => {
357435
),
358436
}))
359437

438+
const failRowImportAfterFirstWrite = ({
439+
workspaceId,
440+
}: {
441+
workspaceId: string
442+
}) => {
443+
const originalBulkDocs = DatabaseImpl.prototype.bulkDocs
444+
return jest
445+
.spyOn(DatabaseImpl.prototype, "bulkDocs")
446+
.mockImplementation(async function (this: DatabaseImpl, docs) {
447+
if (
448+
context.getWorkspaceId() === workspaceId &&
449+
docs.some(doc => doc._id?.startsWith("ro_") && !doc._deleted)
450+
) {
451+
const saved = await this.put(docs[0])
452+
return [
453+
{ id: saved.id, rev: saved.rev },
454+
...docs.slice(1).map(doc => ({
455+
id: doc._id!,
456+
error: "conflict",
457+
reason: "import failed",
458+
})),
459+
]
460+
}
461+
return await originalBulkDocs.call(this, docs)
462+
})
463+
}
464+
360465
it("rolls back partial row writes and uploaded attachments without changing existing data", async () => {
361466
await withProjectsEnabled(async () => {
362467
const source = await createDataProject()
@@ -365,26 +470,9 @@ describe("Project data export and import", () => {
365470
})
366471
const destination = await createDestination()
367472
const before = await snapshotWorkspace(destination.appId)
368-
const originalBulkDocs = DatabaseImpl.prototype.bulkDocs
369-
const bulkDocs = jest
370-
.spyOn(DatabaseImpl.prototype, "bulkDocs")
371-
.mockImplementation(async function (this: DatabaseImpl, docs) {
372-
if (
373-
context.getWorkspaceId() === destination.appId &&
374-
docs.some(doc => doc._id?.startsWith("ro_") && !doc._deleted)
375-
) {
376-
const saved = await this.put(docs[0])
377-
return [
378-
{ id: saved.id, rev: saved.rev },
379-
...docs.slice(1).map(doc => ({
380-
id: doc._id!,
381-
error: "conflict",
382-
reason: "import failed",
383-
})),
384-
]
385-
}
386-
return await originalBulkDocs.call(this, docs)
387-
})
473+
const bulkDocs = failRowImportAfterFirstWrite({
474+
workspaceId: destination.appId,
475+
})
388476

389477
try {
390478
await config.withHeaders(

‎packages/server/src/sdk/workspace/projects/backups/data.spec.ts‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -209,17 +209,26 @@ describe("Project package data", () => {
209209
expect(first.attachments[0].key).not.toEqual(sourceKey)
210210
})
211211

212-
it.each(["duplicate", "external", "users"])("rejects %s rows", kind => {
212+
it("rejects duplicate rows", () => {
213213
const test = fixture()
214-
if (kind === "duplicate") {
215-
test.data.rows.push(test.data.rows[0])
216-
} else if (kind === "external") {
217-
test.tables[0].sourceId = "ds_external"
218-
} else {
219-
test.tables[0]._id = InternalTable.USER_METADATA
220-
test.data.rows[0].tableId = InternalTable.USER_METADATA
221-
test.data.rows[0]._id = db.generateUserMetadataID("us_author")
222-
}
214+
test.data.rows.push(test.data.rows[0])
215+
216+
expect(() => validate(test)).toThrow("invalid or duplicate row")
217+
})
218+
219+
it("rejects rows from external tables", () => {
220+
const test = fixture()
221+
test.tables[0].sourceId = "ds_external"
222+
223+
expect(() => validate(test)).toThrow("invalid or duplicate row")
224+
})
225+
226+
it("rejects user metadata rows", () => {
227+
const test = fixture()
228+
test.tables[0]._id = InternalTable.USER_METADATA
229+
test.data.rows[0].tableId = InternalTable.USER_METADATA
230+
test.data.rows[0]._id = db.generateUserMetadataID("us_author")
231+
223232
expect(() => validate(test)).toThrow("invalid or duplicate row")
224233
})
225234

0 commit comments

Comments
 (0)