mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
fix(core): Show data table artifact after row mutations (no-changelog) (#28314)
This commit is contained in:
parent
316d5bda80
commit
bb310661ce
9 changed files with 348 additions and 11 deletions
|
|
@ -0,0 +1,98 @@
|
|||
import { DEFAULT_INSTANCE_AI_PERMISSIONS } from '@n8n/api-types';
|
||||
|
||||
import type { InstanceAiContext } from '../../../types';
|
||||
import { createInsertDataTableRowsTool } from '../insert-data-table-rows.tool';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMockContext(overrides?: Partial<InstanceAiContext>): InstanceAiContext {
|
||||
return {
|
||||
userId: 'test-user',
|
||||
workflowService: {
|
||||
list: jest.fn(),
|
||||
get: jest.fn(),
|
||||
getAsWorkflowJSON: jest.fn(),
|
||||
createFromWorkflowJSON: jest.fn(),
|
||||
updateFromWorkflowJSON: jest.fn(),
|
||||
archive: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
publish: jest.fn(),
|
||||
unpublish: jest.fn(),
|
||||
},
|
||||
executionService: {
|
||||
list: jest.fn(),
|
||||
run: jest.fn(),
|
||||
getStatus: jest.fn(),
|
||||
getResult: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
getDebugInfo: jest.fn(),
|
||||
getNodeOutput: jest.fn(),
|
||||
},
|
||||
credentialService: {
|
||||
list: jest.fn(),
|
||||
get: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
test: jest.fn(),
|
||||
},
|
||||
nodeService: {
|
||||
listAvailable: jest.fn(),
|
||||
getDescription: jest.fn(),
|
||||
listSearchable: jest.fn(),
|
||||
},
|
||||
dataTableService: {
|
||||
list: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
getSchema: jest.fn(),
|
||||
addColumn: jest.fn(),
|
||||
deleteColumn: jest.fn(),
|
||||
renameColumn: jest.fn(),
|
||||
queryRows: jest.fn(),
|
||||
insertRows: jest.fn(),
|
||||
updateRows: jest.fn(),
|
||||
deleteRows: jest.fn(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('createInsertDataTableRowsTool', () => {
|
||||
let context: InstanceAiContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createMockContext({
|
||||
permissions: {
|
||||
...DEFAULT_INSTANCE_AI_PERMISSIONS,
|
||||
mutateDataTableRows: 'always_allow',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns artifact metadata (dataTableId, tableName, projectId) in result', async () => {
|
||||
(context.dataTableService.insertRows as jest.Mock).mockResolvedValue({
|
||||
insertedCount: 3,
|
||||
dataTableId: 'dt-1',
|
||||
tableName: 'Orders',
|
||||
projectId: 'proj-1',
|
||||
});
|
||||
|
||||
const tool = createInsertDataTableRowsTool(context);
|
||||
const result = (await tool.execute!(
|
||||
{ dataTableId: 'dt-1', rows: [{ name: 'a' }, { name: 'b' }, { name: 'c' }] },
|
||||
{ agent: { suspend: jest.fn(), resumeData: undefined } } as never,
|
||||
)) as Record<string, unknown>;
|
||||
|
||||
expect(result).toEqual({
|
||||
insertedCount: 3,
|
||||
dataTableId: 'dt-1',
|
||||
tableName: 'Orders',
|
||||
projectId: 'proj-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -37,6 +37,9 @@ export function createDeleteDataTableRowsTool(context: InstanceAiContext) {
|
|||
outputSchema: z.object({
|
||||
success: z.boolean(),
|
||||
deletedCount: z.number().optional(),
|
||||
dataTableId: z.string().optional(),
|
||||
tableName: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
denied: z.boolean().optional(),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
|
|
@ -84,7 +87,13 @@ export function createDeleteDataTableRowsTool(context: InstanceAiContext) {
|
|||
|
||||
// State 3: Approved or always_allow — execute
|
||||
const result = await context.dataTableService.deleteRows(input.dataTableId, input.filter);
|
||||
return { success: true, deletedCount: result.deletedCount };
|
||||
return {
|
||||
success: true,
|
||||
deletedCount: result.deletedCount,
|
||||
dataTableId: result.dataTableId,
|
||||
tableName: result.tableName,
|
||||
projectId: result.projectId,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ export function createInsertDataTableRowsTool(context: InstanceAiContext) {
|
|||
inputSchema: insertDataTableRowsInputSchema,
|
||||
outputSchema: z.object({
|
||||
insertedCount: z.number().optional(),
|
||||
dataTableId: z.string().optional(),
|
||||
tableName: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
denied: z.boolean().optional(),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ export function createUpdateDataTableRowsTool(context: InstanceAiContext) {
|
|||
inputSchema: updateDataTableRowsInputSchema,
|
||||
outputSchema: z.object({
|
||||
updatedCount: z.number().optional(),
|
||||
dataTableId: z.string().optional(),
|
||||
tableName: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
denied: z.boolean().optional(),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -371,13 +371,16 @@ export interface InstanceAiDataTableService {
|
|||
insertRows(
|
||||
dataTableId: string,
|
||||
rows: Array<Record<string, unknown>>,
|
||||
): Promise<{ insertedCount: number }>;
|
||||
): Promise<{ insertedCount: number; dataTableId: string; tableName: string; projectId: string }>;
|
||||
updateRows(
|
||||
dataTableId: string,
|
||||
filter: DataTableFilterInput,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<{ updatedCount: number }>;
|
||||
deleteRows(dataTableId: string, filter: DataTableFilterInput): Promise<{ deletedCount: number }>;
|
||||
): Promise<{ updatedCount: number; dataTableId: string; tableName: string; projectId: string }>;
|
||||
deleteRows(
|
||||
dataTableId: string,
|
||||
filter: DataTableFilterInput,
|
||||
): Promise<{ deletedCount: number; dataTableId: string; tableName: string; projectId: string }>;
|
||||
}
|
||||
|
||||
// ── Web Research ────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -724,7 +724,9 @@ function createDataTableAdapterForTests(overrides?: {
|
|||
};
|
||||
|
||||
const mockDataTableRepository = {
|
||||
findOneByOrFail: jest.fn().mockResolvedValue({ id: 'dt-1', projectId: 'team-project-id' }),
|
||||
findOneByOrFail: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'dt-1', name: 'Orders', projectId: 'team-project-id' }),
|
||||
};
|
||||
|
||||
const mockSourceControlPreferencesService = {
|
||||
|
|
@ -847,6 +849,63 @@ describe('createDataTableAdapter', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('mutation result metadata', () => {
|
||||
it('insertRows returns dataTableId, tableName, and projectId', async () => {
|
||||
const { adapter, mockDataTableService } = createDataTableAdapterForTests();
|
||||
(mockDataTableService as unknown as Record<string, jest.Mock>).insertRows = jest
|
||||
.fn()
|
||||
.mockResolvedValue(5);
|
||||
|
||||
const result = await adapter.insertRows('dt-1', [{ col: 'val' }]);
|
||||
|
||||
expect(result).toEqual({
|
||||
insertedCount: 5,
|
||||
dataTableId: 'dt-1',
|
||||
tableName: 'Orders',
|
||||
projectId: 'team-project-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('updateRows returns dataTableId, tableName, and projectId', async () => {
|
||||
const { adapter, mockDataTableService } = createDataTableAdapterForTests();
|
||||
(mockDataTableService as unknown as Record<string, jest.Mock>).updateRows = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'row-1' }, { id: 'row-2' }]);
|
||||
|
||||
const result = await adapter.updateRows(
|
||||
'dt-1',
|
||||
{ type: 'and', filters: [{ columnName: 'status', condition: 'eq', value: 'pending' }] },
|
||||
{ status: 'done' },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
updatedCount: 2,
|
||||
dataTableId: 'dt-1',
|
||||
tableName: 'Orders',
|
||||
projectId: 'team-project-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('deleteRows returns dataTableId, tableName, and projectId', async () => {
|
||||
const { adapter, mockDataTableService } = createDataTableAdapterForTests();
|
||||
(mockDataTableService as unknown as Record<string, jest.Mock>).deleteRows = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'row-1' }]);
|
||||
|
||||
const result = await adapter.deleteRows('dt-1', {
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'id', condition: 'eq', value: 'row-1' }],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
deletedCount: 1,
|
||||
dataTableId: 'dt-1',
|
||||
tableName: 'Orders',
|
||||
projectId: 'team-project-id',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('instance read-only mode', () => {
|
||||
it('blocks write operations when instance is in read-only mode', async () => {
|
||||
const { adapter } = createDataTableAdapterForTests({ branchReadOnly: true });
|
||||
|
|
|
|||
|
|
@ -1127,6 +1127,16 @@ export class InstanceAiAdapterService {
|
|||
return table.projectId;
|
||||
};
|
||||
|
||||
// Like resolveProjectIdForTable but also returns the table name for artifact display
|
||||
const resolveTableMeta = async (scopes: Scope[], dataTableId: string) => {
|
||||
const allowed = await userHasScopes(user, scopes, false, { dataTableId });
|
||||
if (!allowed) {
|
||||
throw new Error(`Data table "${dataTableId}" not found`);
|
||||
}
|
||||
const table = await dataTableRepository.findOneByOrFail({ id: dataTableId });
|
||||
return { projectId: table.projectId, tableName: table.name };
|
||||
};
|
||||
|
||||
return {
|
||||
async list(options) {
|
||||
const projectId = await resolveProjectId(['dataTable:listProject'], options?.projectId);
|
||||
|
|
@ -1217,38 +1227,62 @@ export class InstanceAiAdapterService {
|
|||
|
||||
async insertRows(dataTableId, rows) {
|
||||
assertNotReadOnly();
|
||||
const projectId = await resolveProjectIdForTable(['dataTable:writeRow'], dataTableId);
|
||||
const { projectId, tableName } = await resolveTableMeta(
|
||||
['dataTable:writeRow'],
|
||||
dataTableId,
|
||||
);
|
||||
const result = await dataTableService.insertRows(
|
||||
dataTableId,
|
||||
projectId,
|
||||
rows as DataTableRows,
|
||||
'count',
|
||||
);
|
||||
return { insertedCount: typeof result === 'number' ? result : rows.length };
|
||||
return {
|
||||
insertedCount: typeof result === 'number' ? result : rows.length,
|
||||
dataTableId,
|
||||
tableName,
|
||||
projectId,
|
||||
};
|
||||
},
|
||||
|
||||
async updateRows(dataTableId, filter, data) {
|
||||
assertNotReadOnly();
|
||||
const projectId = await resolveProjectIdForTable(['dataTable:writeRow'], dataTableId);
|
||||
const { projectId, tableName } = await resolveTableMeta(
|
||||
['dataTable:writeRow'],
|
||||
dataTableId,
|
||||
);
|
||||
const result = await dataTableService.updateRows(
|
||||
dataTableId,
|
||||
projectId,
|
||||
{ filter: filter as DataTableFilter, data: data as DataTableRow },
|
||||
true,
|
||||
);
|
||||
return { updatedCount: Array.isArray(result) ? result.length : 0 };
|
||||
return {
|
||||
updatedCount: Array.isArray(result) ? result.length : 0,
|
||||
dataTableId,
|
||||
tableName,
|
||||
projectId,
|
||||
};
|
||||
},
|
||||
|
||||
async deleteRows(dataTableId, filter) {
|
||||
assertNotReadOnly();
|
||||
const projectId = await resolveProjectIdForTable(['dataTable:writeRow'], dataTableId);
|
||||
const { projectId, tableName } = await resolveTableMeta(
|
||||
['dataTable:writeRow'],
|
||||
dataTableId,
|
||||
);
|
||||
const result = await dataTableService.deleteRows(
|
||||
dataTableId,
|
||||
projectId,
|
||||
{ filter: filter as DataTableFilter },
|
||||
true,
|
||||
);
|
||||
return { deletedCount: Array.isArray(result) ? result.length : 0 };
|
||||
return {
|
||||
deletedCount: Array.isArray(result) ? result.length : 0,
|
||||
dataTableId,
|
||||
tableName,
|
||||
projectId,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -316,4 +316,108 @@ describe('useResourceRegistry', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data table mutation artifact metadata', () => {
|
||||
test.each(['insert-data-table-rows', 'update-data-table-rows', 'delete-data-table-rows'])(
|
||||
'registers data table from %s result with name and projectId',
|
||||
async (toolName) => {
|
||||
const { messages, registry } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolName,
|
||||
result: {
|
||||
dataTableId: 'dt-mut-1',
|
||||
tableName: 'Orders',
|
||||
projectId: 'proj-1',
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
const entry = registry.value.get('orders');
|
||||
expect(entry).toEqual({
|
||||
type: 'data-table',
|
||||
id: 'dt-mut-1',
|
||||
name: 'Orders',
|
||||
projectId: 'proj-1',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('enriches existing registry entry with projectId from mutation result', async () => {
|
||||
const { messages, registry } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
toolCalls: [
|
||||
// First: create-data-table registers the table (without projectId)
|
||||
makeToolCall({
|
||||
toolName: 'create-data-table',
|
||||
result: { table: { id: 'dt-enrich', name: 'Signups' } },
|
||||
}),
|
||||
// Then: insert-data-table-rows adds projectId
|
||||
makeToolCall({
|
||||
toolName: 'insert-data-table-rows',
|
||||
result: {
|
||||
insertedCount: 5,
|
||||
dataTableId: 'dt-enrich',
|
||||
tableName: 'Signups',
|
||||
projectId: 'proj-2',
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
const entry = registry.value.get('signups');
|
||||
expect(entry).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'data-table',
|
||||
id: 'dt-enrich',
|
||||
name: 'Signups',
|
||||
projectId: 'proj-2',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('uses dataTableId as fallback name when tableName is missing', async () => {
|
||||
const { messages, registry } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolName: 'insert-data-table-rows',
|
||||
result: {
|
||||
insertedCount: 1,
|
||||
dataTableId: 'dt-no-name',
|
||||
projectId: 'proj-3',
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
const entry = registry.value.get('dt-no-name');
|
||||
expect(entry).toEqual({
|
||||
type: 'data-table',
|
||||
id: 'dt-no-name',
|
||||
name: 'dt-no-name',
|
||||
projectId: 'proj-3',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ const ARTIFACT_TOOLS = new Set([
|
|||
'setup-credentials',
|
||||
'create-data-table',
|
||||
'data-table-agent',
|
||||
'insert-data-table-rows',
|
||||
'update-data-table-rows',
|
||||
'delete-data-table-rows',
|
||||
]);
|
||||
|
||||
function extractFromToolCall(tc: InstanceAiToolCallState, map: Map<string, ResourceEntry>): void {
|
||||
|
|
@ -107,6 +110,27 @@ function extractFromToolCall(tc: InstanceAiToolCallState, map: Map<string, Resou
|
|||
if (result.table && typeof result.table === 'object') {
|
||||
registerResource(map, 'data-table', result.table as Record<string, unknown>);
|
||||
}
|
||||
|
||||
// Data table mutation results (insert/update/delete-data-table-rows)
|
||||
// These return { dataTableId, projectId } without a nested table object.
|
||||
// Merge projectId into existing registry entry or create a minimal one.
|
||||
if (typeof result.dataTableId === 'string' && typeof result.projectId === 'string') {
|
||||
const existingEntry = [...map.values()].find(
|
||||
(e) => e.type === 'data-table' && e.id === result.dataTableId,
|
||||
);
|
||||
if (existingEntry) {
|
||||
existingEntry.projectId = result.projectId as string;
|
||||
} else {
|
||||
const tableName =
|
||||
typeof result.tableName === 'string' ? result.tableName : (result.dataTableId as string);
|
||||
map.set(tableName.toLowerCase(), {
|
||||
type: 'data-table',
|
||||
id: result.dataTableId as string,
|
||||
name: tableName,
|
||||
projectId: result.projectId as string,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectFromAgentNode(node: InstanceAiAgentNode, map: Map<string, ResourceEntry>): void {
|
||||
|
|
|
|||
Loading…
Reference in a new issue