Merge branch 'main' into next

This commit is contained in:
nocobase[bot]
2026-05-16 10:50:20 +00:00
7 changed files with 461 additions and 18 deletions
@@ -9,6 +9,7 @@
import Database, { Model, Repository } from '@nocobase/database';
import { createMockServer, MockServer } from '@nocobase/test';
import CleanOrphanFlowRouteModelsMigration from '../migrations/202605121200-clean-orphan-flow-route-models';
describe('desktopRoutes:listAccessible', () => {
let app: MockServer;
@@ -290,3 +291,188 @@ describe('desktopRoutes', async () => {
expect(rolesDesktopRoutes.length).toBe(0);
});
});
describe('desktopRoutes flow model cleanup', () => {
let app: MockServer, db: Database, desktopRoutesRepo: Repository;
let flowRepo: any, usageRepo: any;
const buildReferenceOptions = (templateUid: string) => ({
use: 'ReferenceBlockModel',
stepParams: {
referenceSettings: {
useTemplate: {
templateUid,
templateName: `name-${templateUid}`,
mode: 'reference',
},
},
},
});
const createTemplate = async (uid: string) => {
await app
.agent()
.resource('flowModelTemplates')
.create({
values: {
uid,
name: uid,
targetUid: 'target-block',
},
});
};
const saveRouteTreeWithReferenceBlock = async (routeUid: string, referenceUid: string, templateUid: string) => {
await app
.agent()
.resource('flowModels')
.save({
values: {
uid: routeUid,
use: 'RouteModel',
subModels: {
page: {
uid: `${routeUid}-page`,
use: 'RootPageModel',
subModels: {
grid: {
uid: referenceUid,
...buildReferenceOptions(templateUid),
},
},
},
},
},
});
};
const createSchemaRouteRoot = async (routeUid: string) => {
await flowRepo.create({
values: {
uid: routeUid,
name: routeUid,
schema: {
use: 'RouteModel',
},
},
});
};
const countUsage = (filter: Record<string, any>) => {
return usageRepo.count({ filter });
};
beforeEach(async () => {
app = await createMockServer({
registerActions: true,
plugins: [
'error-handler',
'client',
'field-sort',
'acl',
'ui-schema-storage',
'system-settings',
'data-source-main',
'data-source-manager',
'flow-engine',
'ui-templates',
],
});
db = app.db;
desktopRoutesRepo = db.getRepository('desktopRoutes');
flowRepo = db.getRepository('flowModels');
usageRepo = db.getRepository('flowModelTemplateUsages');
await flowRepo.create({
values: {
uid: 'target-block',
options: {
use: 'TargetBlock',
},
},
});
});
afterEach(async () => {
await app.destroy();
});
it('should remove child route flow model tree and template usages when deleting a parent desktop route', async () => {
await createTemplate('tpl-route-cleanup');
const group = await desktopRoutesRepo.create({
values: {
title: 'group',
type: 'group',
},
});
await desktopRoutesRepo.create({
values: {
title: 'child page',
type: 'flowPage',
parentId: group.get('id'),
schemaUid: 'route-cleanup',
},
});
await saveRouteTreeWithReferenceBlock('route-cleanup', 'ref-route-cleanup', 'tpl-route-cleanup');
expect(await countUsage({ templateUid: 'tpl-route-cleanup', modelUid: 'ref-route-cleanup' })).toBe(1);
await desktopRoutesRepo.destroy({
filterByTk: group.get('id'),
});
expect(await flowRepo.findOne({ filter: { uid: 'route-cleanup' } })).toBeFalsy();
expect(await flowRepo.findOne({ filter: { uid: 'route-cleanup-page' } })).toBeFalsy();
expect(await flowRepo.findOne({ filter: { uid: 'ref-route-cleanup' } })).toBeFalsy();
expect(await countUsage({ templateUid: 'tpl-route-cleanup', modelUid: 'ref-route-cleanup' })).toBe(0);
const destroyTemplateResp = await app.agent().resource('flowModelTemplates').destroy({
filterByTk: 'tpl-route-cleanup',
});
expect(destroyTemplateResp.status).toBe(200);
});
it('should clean orphan RouteModel trees and stale usages in migration', async () => {
await createTemplate('tpl-orphan-route');
await desktopRoutesRepo.create({
values: {
title: 'active page',
type: 'flowPage',
schemaUid: 'active-route',
},
});
await desktopRoutesRepo.create({
values: {
title: 'active schema page',
type: 'flowPage',
schemaUid: 'active-schema-route',
},
});
await saveRouteTreeWithReferenceBlock('active-route', 'ref-active-route', 'tpl-orphan-route');
await saveRouteTreeWithReferenceBlock('orphan-route', 'ref-orphan-route', 'tpl-orphan-route');
await createSchemaRouteRoot('orphan-schema-route');
await usageRepo.create({
values: {
uid: 'usage-missing-route-model',
templateUid: 'tpl-orphan-route',
modelUid: 'missing-route-model',
},
});
expect(await countUsage({ templateUid: 'tpl-orphan-route' })).toBe(3);
const migration = new CleanOrphanFlowRouteModelsMigration({ db, app } as any);
await migration.up();
expect(await flowRepo.findOne({ filter: { uid: 'active-route' } })).toBeTruthy();
expect(await flowRepo.findOne({ filter: { uid: 'active-schema-route' } })).toBeTruthy();
expect(await flowRepo.findOne({ filter: { uid: 'ref-active-route' } })).toBeTruthy();
expect(await flowRepo.findOne({ filter: { uid: 'orphan-route' } })).toBeFalsy();
expect(await flowRepo.findOne({ filter: { uid: 'orphan-schema-route' } })).toBeFalsy();
expect(await flowRepo.findOne({ filter: { uid: 'ref-orphan-route' } })).toBeFalsy();
expect(await countUsage({ templateUid: 'tpl-orphan-route', modelUid: 'ref-orphan-route' })).toBe(0);
expect(await countUsage({ templateUid: 'tpl-orphan-route', modelUid: 'missing-route-model' })).toBe(0);
expect(await countUsage({ templateUid: 'tpl-orphan-route', modelUid: 'ref-active-route' })).toBe(1);
});
});
@@ -0,0 +1,117 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Migration } from '@nocobase/server';
import * as _ from 'lodash';
const parseOptions = (value: any) => {
if (!value) return {};
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch (error) {
return {};
}
}
return value;
};
const isRouteModelOptions = (value: any) => {
const options = parseOptions(value);
return options?.use === 'RouteModel' || options?.schema?.use === 'RouteModel';
};
export default class extends Migration {
on = 'afterLoad';
async up() {
if (
!this.db.hasCollection('desktopRoutes') ||
!this.db.hasCollection('flowModels') ||
!this.db.hasCollection('flowModelTreePath')
) {
return;
}
await this.db.sequelize.transaction(async (transaction) => {
const desktopRoutesRepo = this.db.getRepository('desktopRoutes');
const flowRepo = this.db.getRepository('flowModels') as any;
const treePathRepo = this.db.getRepository('flowModelTreePath');
const desktopRoutes = await desktopRoutesRepo.find({ fields: ['schemaUid'], transaction });
const routeSchemaUids = new Set(desktopRoutes.map((route) => route.get('schemaUid')).filter(Boolean));
const flowModels = await flowRepo.find({ fields: ['uid', 'options'], transaction });
const routeModelUids = flowModels
.filter((model) => isRouteModelOptions(model.get('options')))
.map((model) => model.get('uid'))
.filter(Boolean);
if (routeModelUids.length) {
const childRouteModels = new Set<string>();
for (const chunk of _.chunk(routeModelUids, 500)) {
const parentPaths = await treePathRepo.find({
filter: {
descendant: {
$in: chunk,
},
depth: 1,
},
fields: ['descendant'],
transaction,
});
for (const path of parentPaths) {
const descendant = path.get('descendant');
if (descendant) {
childRouteModels.add(descendant);
}
}
}
const orphanRouteModelUids = routeModelUids.filter(
(uid) => !routeSchemaUids.has(uid) && !childRouteModels.has(uid),
);
for (const uid of orphanRouteModelUids) {
await flowRepo.remove(uid, { transaction });
}
}
if (this.db.hasCollection('flowModelTemplateUsages')) {
const usageRepo = this.db.getRepository('flowModelTemplateUsages');
const usages = await usageRepo.find({ fields: ['modelUid'], transaction });
const usageModelUids = _.uniq(usages.map((usage) => usage.get('modelUid')).filter(Boolean));
for (const chunk of _.chunk(usageModelUids, 500)) {
const existingModels = await flowRepo.find({
fields: ['uid'],
filter: {
uid: {
$in: chunk,
},
},
transaction,
});
const existingUids = new Set(existingModels.map((model) => model.get('uid')).filter(Boolean));
const missingUids = chunk.filter((uid) => !existingUids.has(uid));
if (missingUids.length) {
await usageRepo.destroy({
filter: {
modelUid: {
$in: missingUids,
},
},
transaction,
});
}
}
}
});
}
}
@@ -188,15 +188,53 @@ export class PluginClientServer extends Plugin {
instance.allowNewMenu === undefined ? ['admin', 'member'].includes(instance.name) : !!instance.allowNewMenu,
);
});
this.db.on('desktopRoutes.afterDestroy', async (instance: Model, { transaction }) => {
const r = this.db.getRepository('flowModels');
if (r) {
await r.destroy({
const collectDesktopRouteSchemaUids = async (instance: Model, transaction?: Transaction) => {
const routeRepo = this.db.getRepository('desktopRoutes');
const schemaUids = new Set<string>();
const pendingIds: Array<string | number> = [];
const rootId = instance.get('id');
const rootSchemaUid = instance.get('schemaUid');
if (rootSchemaUid) {
schemaUids.add(rootSchemaUid);
}
if (rootId) {
pendingIds.push(rootId);
}
while (pendingIds.length) {
const routes = await routeRepo.find({
fields: ['id', 'schemaUid'],
filter: {
uid: instance.get('schemaUid'),
parentId: {
$in: pendingIds.splice(0, pendingIds.length),
},
},
transaction,
});
for (const route of routes) {
const schemaUid = route.get('schemaUid');
const id = route.get('id');
if (schemaUid) {
schemaUids.add(schemaUid);
}
if (id) {
pendingIds.push(id);
}
}
}
return Array.from(schemaUids);
};
this.db.on('desktopRoutes.beforeDestroy', async (instance: Model, { transaction }) => {
const r = this.db.getRepository('flowModels') as any;
if (r?.remove) {
const schemaUids = await collectDesktopRouteSchemaUids(instance, transaction);
for (const schemaUid of schemaUids) {
await r.remove(schemaUid, { transaction });
}
}
});
this.db.on('desktopRoutes.afterCreate', async (instance: Model, { transaction }) => {
@@ -456,6 +456,29 @@ export class FlowModelRepository extends Repository {
return;
}
const descendants = (await this.database.sequelize.query(
this.sqlAdapter(`SELECT descendant FROM ${this.flowModelTreePathTableName} WHERE ancestor = :uid`),
{
type: 'SELECT',
replacements: {
uid,
},
transaction,
},
)) as Array<{ descendant?: string }>;
const uids = descendants.map((row) => row.descendant).filter(Boolean) as string[];
if (uids.length) {
await this.database.emitAsync(
`${this.collection.name}.beforeRemoveTree`,
{
rootUid: uid,
uids,
},
options,
);
}
await this.database.sequelize.query(
this.sqlAdapter(`DELETE FROM ${this.flowModelsTableName} WHERE "uid" IN (
SELECT descendant FROM ${this.flowModelTreePathTableName} WHERE ancestor = :uid
@@ -150,6 +150,65 @@ describe('ui templates and usages', () => {
expect(destroyResp2.status).toBe(200);
});
it('should remove target tree usages when template is destroyed', async () => {
const agent = app.agent();
await agent.resource('flowModelTemplates').create({
values: {
uid: 'tpl-block',
name: 'Block Template',
targetUid: 'target-block',
},
});
const savePopupTargetResp = await agent.resource('flowModels').save({
values: {
uid: 'popup-template-root',
use: 'PopupActionModel',
subModels: {
content: [
{
uid: 'popup-ref-block',
...buildOptions('tpl-block'),
},
],
},
},
});
expect(savePopupTargetResp.status).toBe(200);
expect(await countUsage({ templateUid: 'tpl-block', modelUid: 'popup-ref-block' })).toBe(1);
const blockTplResp = await agent.resource('flowModelTemplates').get({
filterByTk: 'tpl-block',
});
expect(blockTplResp.status).toBe(200);
const blockTpl = blockTplResp.body?.data || blockTplResp.body;
expect(blockTpl?.usageCount).toBe(1);
const popupTplResp = await agent.resource('flowModelTemplates').create({
values: {
uid: 'tpl-popup',
name: 'Popup Template',
targetUid: 'popup-template-root',
type: 'popup',
},
});
expect(popupTplResp.status).toBe(200);
const destroyResp = await agent.resource('flowModelTemplates').destroy({
filterByTk: 'tpl-popup',
});
expect(destroyResp.status).toBe(200);
expect(await countUsage({ templateUid: 'tpl-block', modelUid: 'popup-ref-block' })).toBe(0);
expect(await flowRepo.findNodesById('popup-template-root', { includeAsyncNode: true })).toHaveLength(0);
const blockTplRespAfterDestroy = await agent.resource('flowModelTemplates').get({
filterByTk: 'tpl-block',
});
expect(blockTplRespAfterDestroy.status).toBe(200);
const blockTplAfterDestroy = blockTplRespAfterDestroy.body?.data || blockTplRespAfterDestroy.body;
expect(blockTplAfterDestroy?.usageCount).toBe(0);
});
it('should sync template name/description to reference blocks on template update', async () => {
const agent = app.agent();
await agent.resource('flowModelTemplates').create({
@@ -121,23 +121,36 @@ export class PluginBlockReferenceServer extends Plugin {
}
};
const removeUsagesByInstance = async (
instanceUid: string,
_options: any,
{ transaction }: { transaction?: any } = {},
) => {
const removeUsagesByInstances = async (instanceUids: string[], { transaction }: { transaction?: any } = {}) => {
const usageRepo = this.db.getRepository('flowModelTemplateUsages');
await usageRepo.destroy({ filter: { modelUid: instanceUid }, transaction });
const uids = _.uniq(instanceUids.map(String).filter(Boolean));
for (const chunk of _.chunk(uids, 500)) {
await usageRepo.destroy({
filter: {
modelUid: {
$in: chunk,
},
},
transaction,
});
}
};
// 区块删除时自动清理 usage 记录,避免垃圾数据
this.db.on('flowModels.afterDestroy', async (instance: any, { transaction }: any = {}) => {
const instanceUid = instance?.get?.('uid') || instance?.uid;
if (!instanceUid) return;
const options = parseOptions(instance?.get?.('options') || instance?.options);
await removeUsagesByInstance(instanceUid, options, { transaction });
await removeUsagesByInstances([instanceUid], { transaction });
});
this.db.on(
'flowModels.beforeRemoveTree',
async ({ uids }: { rootUid?: string; uids?: string[] } = {}, options: { transaction?: any } = {}) => {
if (!uids?.length) return;
await removeUsagesByInstances(uids, { transaction: options?.transaction });
},
);
// 区块保存时维护 usage
// - referenceSettings.useTemplate.templateUid(且 mode!==copy
// - popupSettings.openView.popupTemplateUid + popupTemplateMode!==copy
@@ -201,11 +214,7 @@ export class PluginBlockReferenceServer extends Plugin {
for (const node of nodes) {
const instanceUid = node?.uid;
if (!instanceUid) continue;
const options = parseOptions(node?.options || {});
if (node?.parent && !options?.parentId) {
options.parentId = node.parent;
}
await removeUsagesByInstance(instanceUid, options, { transaction: ctx.transaction });
await removeUsagesByInstances([instanceUid], { transaction: ctx.transaction });
}
return;
}
@@ -238,6 +238,13 @@ export default {
data: { usageCount },
});
}
const templateRepo = ctx.db.getRepository('flowModelTemplates');
const template = await templateRepo.findOne({
filter: { uid: templateUid },
transaction: ctx.transaction,
context: ctx,
});
const targetUid = template?.get?.('targetUid') || template?.targetUid;
await actions.destroy(ctx, next);
// 兜底清理孤立 usage(正常情况下 usageCount 已为 0
await usageRepo.destroy({
@@ -246,6 +253,10 @@ export default {
},
context: ctx,
});
if (targetUid) {
const flowRepo = ctx.db.getRepository('flowModels') as FlowModelRepository;
await flowRepo.remove(targetUid, { transaction: ctx.transaction });
}
},
},
};