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 AssetsDBApi from '../db/api/assets.ts';
import { createEntityService } from '../factories/service.factory.ts'; import { createEntityService } from '../factories/service.factory.ts';
import db from '../db/models/index.ts';
import { import {
assertCreateOptions, assertCreateOptions,
assertDeleteByIdsOptions,
assertIdOptions,
assertUpdateOptions, assertUpdateOptions,
} from '../contracts/entity-options.ts'; } from '../contracts/entity-options.ts';
import ValidationError from './notifications/errors/validation.ts'; import ValidationError from './notifications/errors/validation.ts';
@ -11,13 +14,16 @@ import { logger } from '../utils/logger.ts';
import type { import type {
AssetCreateOptions, AssetCreateOptions,
AssetData, AssetData,
AssetDeleteByIdsOptions,
AssetEmbedUrlResult, AssetEmbedUrlResult,
AssetFindByOptions, AssetFindByOptions,
AssetListFilter, AssetListFilter,
AssetMimePatternMap, AssetMimePatternMap,
AssetMimeValidationResult, AssetMimeValidationResult,
AssetRemoveOptions,
AssetRecord, AssetRecord,
AssetUpdateOptions, AssetUpdateOptions,
ServiceOptions,
} from '../types/index.ts'; } from '../types/index.ts';
const VALID_MIME_PATTERNS: AssetMimePatternMap = { const VALID_MIME_PATTERNS: AssetMimePatternMap = {
@ -182,6 +188,35 @@ function buildAssetFindByOptions(options: AssetUpdateOptions): AssetFindByOption
return findByOptions; 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< const BaseService = createEntityService<
AssetRecord, AssetRecord,
AssetData, AssetData,
@ -313,4 +348,57 @@ export default class AssetsService extends BaseService {
...serviceOptions, ...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, data: ProjectCloneVariantPayload,
options: { transaction: Transaction }, options: { transaction: Transaction },
): Promise<ProjectCloneJsonRecord>; ): 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 { export interface ProjectCloneCollectionModel {

View File

@ -3,11 +3,15 @@ import test from 'node:test';
import db from '../src/db/models/index.ts'; import db from '../src/db/models/index.ts';
import AssetsDBApi from '../src/db/api/assets.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 GenericDBApi from '../src/db/api/base.api.ts';
import ProjectElementDefaultsDBApi from '../src/db/api/project_element_defaults.ts'; import ProjectElementDefaultsDBApi from '../src/db/api/project_element_defaults.ts';
import { createEntityService } from '../src/factories/service.factory.ts'; import { createEntityService } from '../src/factories/service.factory.ts';
import type { import type {
CurrentUser, CurrentUser,
AssetDeleteByIdsOptions,
AssetRecord,
AssetRemoveOptions,
DbData, DbData,
EntityRecord, EntityRecord,
EntityServiceDbApi, EntityServiceDbApi,
@ -53,6 +57,7 @@ interface UpdateContractCalls {
serviceCreate?: unknown; serviceCreate?: unknown;
serviceDeleteByIds?: unknown; serviceDeleteByIds?: unknown;
serviceRemove?: unknown; serviceRemove?: unknown;
variantSoftDelete?: { payload: DbData; options: unknown };
associationThis?: TestAssociationRecord; associationThis?: TestAssociationRecord;
committed?: true; committed?: true;
rolledBack?: 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( function createServiceDbApi(
calls: UpdateContractCalls, calls: UpdateContractCalls,
): EntityServiceDbApi<TestEntity, TestEntityData, TestEntityData> { ): EntityServiceDbApi<TestEntity, TestEntityData, TestEntityData> {
@ -421,6 +483,110 @@ void test('AssetsDBApi mapping keeps explicit project relation', () => {
assert.equal(mapped.projectId, 'project-1'); 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 () => { void test('createEntityService update uses object signature and manages own transaction', async () => {
const calls: UpdateContractCalls = {}; const calls: UpdateContractCalls = {};
const transaction: TestManagedTransaction = { const transaction: TestManagedTransaction = {