feat(bases): cascade-clean view configs when a property is deleted

This commit is contained in:
Philipinho
2026-05-24 12:41:14 +01:00
parent 46c5960e99
commit b8192e69d1
3 changed files with 182 additions and 0 deletions
@@ -0,0 +1,77 @@
import { stripPropertyFromViewConfig } from '../strip-property-from-view-config';
describe('stripPropertyFromViewConfig', () => {
it('returns the config unchanged when no references exist', () => {
const config = {
sorts: [{ propertyId: 'p-other', direction: 'asc' as const }],
filter: {
op: 'and' as const,
children: [{ propertyId: 'p-other', op: 'eq' as const, value: 1 }],
},
};
expect(stripPropertyFromViewConfig(config, 'p-deleted')).toEqual(config);
});
it('drops sort entries pointing at the deleted property', () => {
const config = {
sorts: [
{ propertyId: 'p-deleted', direction: 'asc' as const },
{ propertyId: 'p-keep', direction: 'desc' as const },
],
};
expect(stripPropertyFromViewConfig(config, 'p-deleted')).toEqual({
sorts: [{ propertyId: 'p-keep', direction: 'desc' as const }],
});
});
it('prunes filter clauses pointing at the deleted property', () => {
const config = {
filter: {
op: 'and' as const,
children: [
{ propertyId: 'p-deleted', op: 'eq' as const, value: 1 },
{ propertyId: 'p-keep', op: 'eq' as const, value: 2 },
],
},
};
expect(stripPropertyFromViewConfig(config, 'p-deleted')).toEqual({
filter: {
op: 'and' as const,
children: [{ propertyId: 'p-keep', op: 'eq' as const, value: 2 }],
},
});
});
it('drops nested groups that become empty after pruning', () => {
const config = {
filter: {
op: 'and' as const,
children: [
{
op: 'or' as const,
children: [{ propertyId: 'p-deleted', op: 'eq' as const, value: 1 }],
},
{ propertyId: 'p-keep', op: 'eq' as const, value: 2 },
],
},
};
expect(stripPropertyFromViewConfig(config, 'p-deleted')).toEqual({
filter: {
op: 'and' as const,
children: [{ propertyId: 'p-keep', op: 'eq' as const, value: 2 }],
},
});
});
it('clears kanban fields when groupByPropertyId matches', () => {
const config = {
groupByPropertyId: 'p-deleted',
hiddenChoiceIds: ['c1'],
choiceOrder: ['c1', 'c2'],
sorts: [{ propertyId: 'p-keep', direction: 'asc' as const }],
};
expect(stripPropertyFromViewConfig(config, 'p-deleted')).toEqual({
sorts: [{ propertyId: 'p-keep', direction: 'asc' as const }],
});
});
});
@@ -16,6 +16,8 @@ import { executeTx } from '@docmost/db/utils';
import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo';
import { stripPropertyFromViewConfig } from './strip-property-from-view-config';
import { CreatePropertyDto } from '../dto/create-property.dto';
import {
UpdatePropertyDto,
@@ -42,6 +44,7 @@ import {
BasePropertyReorderedEvent,
BasePropertyUpdatedEvent,
BaseSchemaBumpedEvent,
BaseViewUpdatedEvent,
} from '../events/base-events';
import { processBaseTypeConversion } from '../tasks/base-type-conversion.task';
import { FormulaService } from '../formula/formula.service';
@@ -67,6 +70,7 @@ export class BasePropertyService {
private readonly basePropertyRepo: BasePropertyRepo,
private readonly baseRowRepo: BaseRowRepo,
private readonly baseRepo: BaseRepo,
private readonly baseViewRepo: BaseViewRepo,
@InjectQueue(QueueName.BASE_QUEUE) private readonly baseQueue: Queue,
private readonly eventEmitter: EventEmitter2,
private readonly formulaService: FormulaService,
@@ -517,11 +521,55 @@ export class BasePropertyService {
// Soft-delete so queries filter the property out immediately, then
// enqueue cell-gc to scrub cell keys and hard-delete. If the enqueue
// fails, revert the soft-delete so the property isn't orphaned.
// In the same transaction, strip the deleted property's id out of
// every view's config (sorts, filters, groupBy) so views don't render
// dangling references after the delete commits.
const updatedViewIds: string[] = [];
await executeTx(this.db, async (trx) => {
await this.basePropertyRepo.softDelete(dto.propertyId, trx);
await this.baseRepo.bumpSchemaVersion(dto.pageId, trx);
const views = await this.baseViewRepo.findByPageId(dto.pageId, {
workspaceId,
trx,
});
for (const view of views) {
const before = (view.config ?? {}) as Record<string, unknown>;
const next = stripPropertyFromViewConfig(
view.config as any,
dto.propertyId,
);
const after = next as Record<string, unknown>;
if (
Object.keys(before).length === Object.keys(after).length &&
Object.keys(before).every((k) => k in after) &&
JSON.stringify(before) === JSON.stringify(after)
) {
continue;
}
await this.baseViewRepo.updateView(
view.id,
{ config: next as any },
{ workspaceId, trx },
);
updatedViewIds.push(view.id);
}
});
for (const viewId of updatedViewIds) {
const fresh = await this.baseViewRepo.findById(viewId, { workspaceId });
if (fresh) {
const event: BaseViewUpdatedEvent = {
pageId: dto.pageId,
workspaceId,
actorId: actorId ?? null,
requestId: dto.requestId ?? null,
view: fresh,
};
this.eventEmitter.emit(EventName.BASE_VIEW_UPDATED, event);
}
}
const payload: IBaseCellGcJob = {
pageId: dto.pageId,
propertyId: dto.propertyId,
@@ -0,0 +1,57 @@
import { ViewConfig } from '../base.schemas';
type FilterCondition = {
propertyId: string;
op: string;
value?: unknown;
};
type FilterGroup = {
op: 'and' | 'or';
children: Array<FilterCondition | FilterGroup>;
};
type FilterNode = FilterCondition | FilterGroup;
function isGroup(node: FilterNode): node is FilterGroup {
return 'children' in node;
}
function pruneFilter(
node: FilterNode,
propertyId: string,
): FilterNode | null {
if (isGroup(node)) {
const kept = node.children
.map((c) => pruneFilter(c, propertyId))
.filter((c): c is FilterNode => c !== null);
return kept.length === 0 ? null : { op: node.op, children: kept };
}
return node.propertyId === propertyId ? null : node;
}
export function stripPropertyFromViewConfig(
config: ViewConfig | undefined | null,
propertyId: string,
): ViewConfig {
if (!config) return {};
const next: Record<string, unknown> = { ...config };
if (config.sorts) {
const sorts = config.sorts.filter((s) => s.propertyId !== propertyId);
if (sorts.length > 0) next.sorts = sorts;
else delete next.sorts;
}
if (config.filter) {
const pruned = pruneFilter(config.filter, propertyId);
if (pruned) next.filter = pruned;
else delete next.filter;
}
if (config.groupByPropertyId === propertyId) {
delete next.groupByPropertyId;
delete next.hiddenChoiceIds;
delete next.choiceOrder;
}
return next as ViewConfig;
}