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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user