improved cascade soft delete for DB
This commit is contained in:
parent
5be478542d
commit
9130efdb6b
@ -1,7 +1,10 @@
|
||||
import AssetsDBApi from '../db/api/assets.ts';
|
||||
import { createEntityService } from '../factories/service.factory.ts';
|
||||
import db from '../db/models/index.ts';
|
||||
import {
|
||||
assertCreateOptions,
|
||||
assertDeleteByIdsOptions,
|
||||
assertIdOptions,
|
||||
assertUpdateOptions,
|
||||
} from '../contracts/entity-options.ts';
|
||||
import ValidationError from './notifications/errors/validation.ts';
|
||||
@ -11,13 +14,16 @@ import { logger } from '../utils/logger.ts';
|
||||
import type {
|
||||
AssetCreateOptions,
|
||||
AssetData,
|
||||
AssetDeleteByIdsOptions,
|
||||
AssetEmbedUrlResult,
|
||||
AssetFindByOptions,
|
||||
AssetListFilter,
|
||||
AssetMimePatternMap,
|
||||
AssetMimeValidationResult,
|
||||
AssetRemoveOptions,
|
||||
AssetRecord,
|
||||
AssetUpdateOptions,
|
||||
ServiceOptions,
|
||||
} from '../types/index.ts';
|
||||
|
||||
const VALID_MIME_PATTERNS: AssetMimePatternMap = {
|
||||
@ -182,6 +188,35 @@ function buildAssetFindByOptions(options: AssetUpdateOptions): AssetFindByOption
|
||||
return findByOptions;
|
||||
}
|
||||
|
||||
function getCurrentUserId(
|
||||
options: ServiceOptions,
|
||||
): string | null {
|
||||
return options.currentUser?.id ?? null;
|
||||
}
|
||||
|
||||
async function softDeleteAssetVariants(
|
||||
assetIds: readonly string[],
|
||||
options: ServiceOptions & Required<Pick<ServiceOptions, 'transaction'>>,
|
||||
): Promise<void> {
|
||||
if (assetIds.length === 0) return;
|
||||
|
||||
const now = new Date();
|
||||
await db.asset_variants.update(
|
||||
{
|
||||
deletedAt: now,
|
||||
updatedAt: now,
|
||||
updatedById: getCurrentUserId(options),
|
||||
},
|
||||
{
|
||||
where: {
|
||||
assetId: assetIds,
|
||||
deletedAt: null,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const BaseService = createEntityService<
|
||||
AssetRecord,
|
||||
AssetData,
|
||||
@ -313,4 +348,57 @@ export default class AssetsService extends BaseService {
|
||||
...serviceOptions,
|
||||
});
|
||||
}
|
||||
|
||||
static override async deleteByIds(
|
||||
options: AssetDeleteByIdsOptions,
|
||||
): Promise<AssetRecord[]> {
|
||||
assertDeleteByIdsOptions(options, 'Service');
|
||||
|
||||
const { transaction: externalTransaction } = options;
|
||||
const transaction =
|
||||
externalTransaction || (await db.sequelize.transaction());
|
||||
const ownsTransaction = !externalTransaction;
|
||||
|
||||
try {
|
||||
const result = await super.deleteByIds({
|
||||
...options,
|
||||
transaction,
|
||||
});
|
||||
await softDeleteAssetVariants(
|
||||
result.map((asset) => asset.id),
|
||||
{ ...options, transaction },
|
||||
);
|
||||
|
||||
if (ownsTransaction) await transaction.commit();
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (ownsTransaction) await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static override async remove(
|
||||
options: AssetRemoveOptions,
|
||||
): Promise<AssetRecord> {
|
||||
assertIdOptions(options, 'Service', 'remove');
|
||||
|
||||
const { transaction: externalTransaction } = options;
|
||||
const transaction =
|
||||
externalTransaction || (await db.sequelize.transaction());
|
||||
const ownsTransaction = !externalTransaction;
|
||||
|
||||
try {
|
||||
const result = await super.remove({
|
||||
...options,
|
||||
transaction,
|
||||
});
|
||||
await softDeleteAssetVariants([result.id], { ...options, transaction });
|
||||
|
||||
if (ownsTransaction) await transaction.commit();
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (ownsTransaction) await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -148,6 +148,20 @@ export interface ProjectCloneVariantModel {
|
||||
data: ProjectCloneVariantPayload,
|
||||
options: { transaction: Transaction },
|
||||
): Promise<ProjectCloneJsonRecord>;
|
||||
update(
|
||||
data: {
|
||||
deletedAt: Date;
|
||||
updatedAt: Date;
|
||||
updatedById: string | null;
|
||||
},
|
||||
options: {
|
||||
where: {
|
||||
assetId: readonly string[];
|
||||
deletedAt: null;
|
||||
};
|
||||
transaction: Transaction;
|
||||
},
|
||||
): Promise<[number]>;
|
||||
}
|
||||
|
||||
export interface ProjectCloneCollectionModel {
|
||||
|
||||
@ -3,11 +3,15 @@ import test from 'node:test';
|
||||
|
||||
import db from '../src/db/models/index.ts';
|
||||
import AssetsDBApi from '../src/db/api/assets.ts';
|
||||
import AssetsService from '../src/services/assets.ts';
|
||||
import GenericDBApi from '../src/db/api/base.api.ts';
|
||||
import ProjectElementDefaultsDBApi from '../src/db/api/project_element_defaults.ts';
|
||||
import { createEntityService } from '../src/factories/service.factory.ts';
|
||||
import type {
|
||||
CurrentUser,
|
||||
AssetDeleteByIdsOptions,
|
||||
AssetRecord,
|
||||
AssetRemoveOptions,
|
||||
DbData,
|
||||
EntityRecord,
|
||||
EntityServiceDbApi,
|
||||
@ -53,6 +57,7 @@ interface UpdateContractCalls {
|
||||
serviceCreate?: unknown;
|
||||
serviceDeleteByIds?: unknown;
|
||||
serviceRemove?: unknown;
|
||||
variantSoftDelete?: { payload: DbData; options: unknown };
|
||||
associationThis?: TestAssociationRecord;
|
||||
committed?: true;
|
||||
rolledBack?: true;
|
||||
@ -102,6 +107,63 @@ function replaceSequelizeTransaction(
|
||||
};
|
||||
}
|
||||
|
||||
function replaceAssetVariantUpdate(
|
||||
calls: UpdateContractCalls,
|
||||
): () => void {
|
||||
const originalUpdate = db.asset_variants.update.bind(db.asset_variants);
|
||||
|
||||
Object.defineProperty(db.asset_variants, 'update', {
|
||||
configurable: true,
|
||||
value: (payload: DbData, options: unknown) => {
|
||||
calls.variantSoftDelete = { payload, options };
|
||||
return Promise.resolve([1]);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
Object.defineProperty(db.asset_variants, 'update', {
|
||||
configurable: true,
|
||||
value: originalUpdate,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function replaceAssetsDbApiRemove(
|
||||
value: (options: AssetRemoveOptions) => Promise<AssetRecord>,
|
||||
): () => void {
|
||||
const original = AssetsDBApi.remove.bind(AssetsDBApi);
|
||||
|
||||
Object.defineProperty(AssetsDBApi, 'remove', {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
|
||||
return () => {
|
||||
Object.defineProperty(AssetsDBApi, 'remove', {
|
||||
configurable: true,
|
||||
value: original,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function replaceAssetsDbApiDeleteByIds(
|
||||
value: (options: AssetDeleteByIdsOptions) => Promise<AssetRecord[]>,
|
||||
): () => void {
|
||||
const original = AssetsDBApi.deleteByIds.bind(AssetsDBApi);
|
||||
|
||||
Object.defineProperty(AssetsDBApi, 'deleteByIds', {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
|
||||
return () => {
|
||||
Object.defineProperty(AssetsDBApi, 'deleteByIds', {
|
||||
configurable: true,
|
||||
value: original,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createServiceDbApi(
|
||||
calls: UpdateContractCalls,
|
||||
): EntityServiceDbApi<TestEntity, TestEntityData, TestEntityData> {
|
||||
@ -421,6 +483,110 @@ void test('AssetsDBApi mapping keeps explicit project relation', () => {
|
||||
assert.equal(mapped.projectId, 'project-1');
|
||||
});
|
||||
|
||||
void test('AssetsService.remove soft-deletes asset variants when removing asset', async () => {
|
||||
const calls: UpdateContractCalls = {};
|
||||
const transaction: TestManagedTransaction = {
|
||||
id: 'tx-asset-remove',
|
||||
commit() {
|
||||
calls.committed = true;
|
||||
return Promise.resolve();
|
||||
},
|
||||
rollback() {
|
||||
calls.rolledBack = true;
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
const currentUser = createCurrentUser();
|
||||
const restoreTransaction = replaceSequelizeTransaction(transaction);
|
||||
const restoreVariantUpdate = replaceAssetVariantUpdate(calls);
|
||||
const restoreRemove = replaceAssetsDbApiRemove((options) => {
|
||||
calls.serviceRemove = options;
|
||||
return Promise.resolve({ id: options.id });
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await AssetsService.remove({
|
||||
id: 'asset-1',
|
||||
currentUser,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { id: 'asset-1' });
|
||||
assert.equal(calls.variantSoftDelete?.payload.updatedById, 'user-1');
|
||||
assert.ok(calls.variantSoftDelete?.payload.deletedAt instanceof Date);
|
||||
assert.ok(calls.variantSoftDelete?.payload.updatedAt instanceof Date);
|
||||
assert.deepEqual(calls.variantSoftDelete?.options, {
|
||||
where: {
|
||||
assetId: ['asset-1'],
|
||||
deletedAt: null,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
assert.deepEqual(calls.serviceRemove, {
|
||||
id: 'asset-1',
|
||||
currentUser,
|
||||
transaction,
|
||||
});
|
||||
assert.equal(calls.committed, true);
|
||||
assert.equal(calls.rolledBack, undefined);
|
||||
} finally {
|
||||
restoreRemove();
|
||||
restoreVariantUpdate();
|
||||
restoreTransaction();
|
||||
}
|
||||
});
|
||||
|
||||
void test('AssetsService.deleteByIds soft-deletes variants for deleted assets', async () => {
|
||||
const calls: UpdateContractCalls = {};
|
||||
const transaction: TestManagedTransaction = {
|
||||
id: 'tx-assets-delete',
|
||||
commit() {
|
||||
calls.committed = true;
|
||||
return Promise.resolve();
|
||||
},
|
||||
rollback() {
|
||||
calls.rolledBack = true;
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
const currentUser = createCurrentUser();
|
||||
const restoreTransaction = replaceSequelizeTransaction(transaction);
|
||||
const restoreVariantUpdate = replaceAssetVariantUpdate(calls);
|
||||
const restoreDeleteByIds = replaceAssetsDbApiDeleteByIds((options) => {
|
||||
calls.serviceDeleteByIds = options;
|
||||
return Promise.resolve(options.ids.map((id) => ({ id })));
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await AssetsService.deleteByIds({
|
||||
ids: ['asset-1', 'asset-2'],
|
||||
currentUser,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, [{ id: 'asset-1' }, { id: 'asset-2' }]);
|
||||
assert.equal(calls.variantSoftDelete?.payload.updatedById, 'user-1');
|
||||
assert.ok(calls.variantSoftDelete?.payload.deletedAt instanceof Date);
|
||||
assert.ok(calls.variantSoftDelete?.payload.updatedAt instanceof Date);
|
||||
assert.deepEqual(calls.variantSoftDelete?.options, {
|
||||
where: {
|
||||
assetId: ['asset-1', 'asset-2'],
|
||||
deletedAt: null,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
assert.deepEqual(calls.serviceDeleteByIds, {
|
||||
ids: ['asset-1', 'asset-2'],
|
||||
currentUser,
|
||||
transaction,
|
||||
});
|
||||
assert.equal(calls.committed, true);
|
||||
assert.equal(calls.rolledBack, undefined);
|
||||
} finally {
|
||||
restoreDeleteByIds();
|
||||
restoreVariantUpdate();
|
||||
restoreTransaction();
|
||||
}
|
||||
});
|
||||
|
||||
void test('createEntityService update uses object signature and manages own transaction', async () => {
|
||||
const calls: UpdateContractCalls = {};
|
||||
const transaction: TestManagedTransaction = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user