fix(core): Show data table artifact after row mutations (no-changelog) (#28314)

This commit is contained in:
Jaakko Husso 2026-04-13 12:10:40 +03:00 committed by GitHub
parent 316d5bda80
commit bb310661ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 348 additions and 11 deletions

View file

@ -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',
});
});
});

View file

@ -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,
};
},
});
}

View file

@ -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(),
}),

View file

@ -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(),
}),

View file

@ -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 ────────────────────────────────────────────────────────────

View file

@ -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 });

View file

@ -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,
};
},
};
}

View file

@ -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',
});
});
});
});

View file

@ -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 {