improved cascade soft delete for DB

This commit is contained in:
Dmitri 2026-07-02 11:38:21 +02:00
parent 5be478542d
commit 9130efdb6b
3 changed files with 268 additions and 0 deletions

View File

@ -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;
}
}
}

View File

@ -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 {

View File

@ -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 = {