diff --git a/packages/cli/src/modules/data-table/__tests__/data-table-touch-updated-at.integration.test.ts b/packages/cli/src/modules/data-table/__tests__/data-table-touch-updated-at.integration.test.ts new file mode 100644 index 00000000000..709c1c5df29 --- /dev/null +++ b/packages/cli/src/modules/data-table/__tests__/data-table-touch-updated-at.integration.test.ts @@ -0,0 +1,408 @@ +import type { AddDataTableColumnDto } from '@n8n/api-types'; +import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; +import type { Project } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { DataTableRepository } from '../data-table.repository'; +import { DataTableService } from '../data-table.service'; +import { mockDataTableSizeValidator } from './test-helpers'; + +beforeAll(async () => { + await testModules.loadModules(['data-table']); + await testDb.init(); + mockDataTableSizeValidator(); +}); + +beforeEach(async () => { + await testDb.truncate(['DataTable', 'DataTableColumn']); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('DataTable touchUpdatedAt', () => { + let dataTableService: DataTableService; + let dataTableRepository: DataTableRepository; + let project: Project; + + beforeAll(() => { + dataTableService = Container.get(DataTableService); + dataTableRepository = Container.get(DataTableRepository); + }); + + beforeEach(async () => { + project = await createTeamProject(); + }); + + afterEach(async () => { + await dataTableService.deleteDataTableAll(); + }); + + describe('Row operations', () => { + it('should update updatedAt timestamp when inserting rows', async () => { + // Create a data table with columns + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + ], + }); + + // Get initial updatedAt + const initialDataTable = await dataTableRepository.findOneBy({ id: dataTable.id }); + const initialUpdatedAt = initialDataTable!.updatedAt; + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Insert rows + await dataTableService.insertRows(dataTable.id, project.id, [ + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 25 }, + ]); + + // Check that updatedAt was updated + const updatedDataTable = await dataTableRepository.findOneBy({ id: dataTable.id }); + expect(updatedDataTable!.updatedAt.getTime()).toBeGreaterThan(initialUpdatedAt.getTime()); + }); + + it('should update updatedAt timestamp when upserting rows', async () => { + // Create a data table with columns + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [ + { name: 'email', type: 'string' }, + { name: 'name', type: 'string' }, + ], + }); + + // Insert initial row + await dataTableService.insertRows(dataTable.id, project.id, [ + { email: 'test@example.com', name: 'Alice' }, + ]); + + // Get updatedAt after insert + const afterInsert = await dataTableRepository.findOneBy({ id: dataTable.id }); + const afterInsertUpdatedAt = afterInsert!.updatedAt; + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Upsert (update) + await dataTableService.upsertRow( + dataTable.id, + project.id, + { + filter: { + type: 'and', + filters: [{ columnName: 'email', condition: 'eq', value: 'test@example.com' }], + }, + data: { name: 'Alice Updated' }, + }, + false, + false, + ); + + // Check that updatedAt was updated + const afterUpsert = await dataTableRepository.findOneBy({ id: dataTable.id }); + expect(afterUpsert!.updatedAt.getTime()).toBeGreaterThan(afterInsertUpdatedAt.getTime()); + }); + + it('should update updatedAt timestamp when updating rows', async () => { + // Create a data table with columns + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [ + { name: 'email', type: 'string' }, + { name: 'status', type: 'string' }, + ], + }); + + // Insert initial row + await dataTableService.insertRows(dataTable.id, project.id, [ + { email: 'test@example.com', status: 'active' }, + ]); + + // Get updatedAt after insert + const afterInsert = await dataTableRepository.findOneBy({ id: dataTable.id }); + const afterInsertUpdatedAt = afterInsert!.updatedAt; + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Update rows + await dataTableService.updateRows( + dataTable.id, + project.id, + { + filter: { + type: 'and', + filters: [{ columnName: 'email', condition: 'eq', value: 'test@example.com' }], + }, + data: { status: 'inactive' }, + }, + false, + false, + ); + + // Check that updatedAt was updated + const afterUpdate = await dataTableRepository.findOneBy({ id: dataTable.id }); + expect(afterUpdate!.updatedAt.getTime()).toBeGreaterThan(afterInsertUpdatedAt.getTime()); + }); + + it('should update updatedAt timestamp when deleting rows', async () => { + // Create a data table with columns + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [ + { name: 'email', type: 'string' }, + { name: 'name', type: 'string' }, + ], + }); + + // Insert rows + await dataTableService.insertRows(dataTable.id, project.id, [ + { email: 'test1@example.com', name: 'User 1' }, + { email: 'test2@example.com', name: 'User 2' }, + ]); + + // Get updatedAt after insert + const afterInsert = await dataTableRepository.findOneBy({ id: dataTable.id }); + const afterInsertUpdatedAt = afterInsert!.updatedAt; + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Delete rows + await dataTableService.deleteRows( + dataTable.id, + project.id, + { + filter: { + type: 'and', + filters: [{ columnName: 'email', condition: 'eq', value: 'test1@example.com' }], + }, + }, + false, + false, + ); + + // Check that updatedAt was updated + const afterDelete = await dataTableRepository.findOneBy({ id: dataTable.id }); + expect(afterDelete!.updatedAt.getTime()).toBeGreaterThan(afterInsertUpdatedAt.getTime()); + }); + }); + + describe('Column operations', () => { + it('should update updatedAt timestamp when adding a column', async () => { + // Create a data table + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [{ name: 'name', type: 'string' }], + }); + + // Get initial updatedAt + const initialDataTable = await dataTableRepository.findOneBy({ id: dataTable.id }); + const initialUpdatedAt = initialDataTable!.updatedAt; + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Add a column + const addColumnDto: AddDataTableColumnDto = { + name: 'age', + type: 'number', + }; + await dataTableService.addColumn(dataTable.id, project.id, addColumnDto); + + // Check that updatedAt was updated + const updatedDataTable = await dataTableRepository.findOneBy({ id: dataTable.id }); + expect(updatedDataTable!.updatedAt.getTime()).toBeGreaterThan(initialUpdatedAt.getTime()); + }); + + it('should update updatedAt timestamp when deleting a column', async () => { + // Create a data table with multiple columns + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + ], + }); + + // Get columns + const columns = await dataTableService.getColumns(dataTable.id, project.id); + const ageColumn = columns.find((c) => c.name === 'age'); + + // Get initial updatedAt + const initialDataTable = await dataTableRepository.findOneBy({ id: dataTable.id }); + const initialUpdatedAt = initialDataTable!.updatedAt; + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Delete a column + await dataTableService.deleteColumn(dataTable.id, project.id, ageColumn!.id); + + // Check that updatedAt was updated + const updatedDataTable = await dataTableRepository.findOneBy({ id: dataTable.id }); + expect(updatedDataTable!.updatedAt.getTime()).toBeGreaterThan(initialUpdatedAt.getTime()); + }); + }); + + describe('Error handling', () => { + it('should not fail row operations even if touchUpdatedAt is called with invalid ID', async () => { + // Create a data table + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [{ name: 'name', type: 'string' }], + }); + + // Insert initial data + await dataTableService.insertRows(dataTable.id, project.id, [{ name: 'Initial' }]); + + // Mock touchUpdatedAt to simulate an error scenario + const touchSpy = jest.spyOn(dataTableRepository, 'touchUpdatedAt'); + + // Insert rows - operation should succeed + const result = await dataTableService.insertRows(dataTable.id, project.id, [ + { name: 'Alice' }, + ]); + expect(result).toBeDefined(); + + // Verify touchUpdatedAt was called + expect(touchSpy).toHaveBeenCalledWith(dataTable.id); + + touchSpy.mockRestore(); + }); + + it('should not fail column operations even if touchUpdatedAt is called', async () => { + // Create a data table + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [{ name: 'name', type: 'string' }], + }); + + // Mock touchUpdatedAt to verify it's called + const touchSpy = jest.spyOn(dataTableRepository, 'touchUpdatedAt'); + + // Add column - operation should succeed + const addColumnDto: AddDataTableColumnDto = { + name: 'age', + type: 'number', + }; + const result = await dataTableService.addColumn(dataTable.id, project.id, addColumnDto); + expect(result).toBeDefined(); + + // Verify touchUpdatedAt was called + expect(touchSpy).toHaveBeenCalledWith(dataTable.id); + + touchSpy.mockRestore(); + }); + + it('should handle errors gracefully in touchUpdatedAt', async () => { + // This test verifies that touchUpdatedAt has error handling + // by checking it doesn't throw when given an invalid ID + + // Spy on the logger + const loggerSpy = jest.spyOn(dataTableRepository['logger'], 'debug'); + + // Call touchUpdatedAt with a non-existent ID + // The method should not throw, even though the update will affect 0 rows + await expect(dataTableRepository.touchUpdatedAt('non-existent-id')).resolves.toBeUndefined(); + + // The method completes without throwing + // Note: In a real error scenario (e.g., DB connection failure), the logger would be called + // but with a non-existent ID, the update just affects 0 rows without error + + loggerSpy.mockRestore(); + }); + }); + + describe('Dry run mode', () => { + it('should NOT update updatedAt when updating rows in dry run mode', async () => { + // Create a data table with columns + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [ + { name: 'email', type: 'string' }, + { name: 'status', type: 'string' }, + ], + }); + + // Insert initial row + await dataTableService.insertRows(dataTable.id, project.id, [ + { email: 'test@example.com', status: 'active' }, + ]); + + // Get updatedAt after insert + const afterInsert = await dataTableRepository.findOneBy({ id: dataTable.id }); + const afterInsertUpdatedAt = afterInsert!.updatedAt; + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Update rows in DRY RUN mode + await dataTableService.updateRows( + dataTable.id, + project.id, + { + filter: { + type: 'and', + filters: [{ columnName: 'email', condition: 'eq', value: 'test@example.com' }], + }, + data: { status: 'inactive' }, + }, + false, + true, // dryRun = true + ); + + // Check that updatedAt was NOT updated (same timestamp) + const afterDryRun = await dataTableRepository.findOneBy({ id: dataTable.id }); + expect(afterDryRun!.updatedAt.getTime()).toBe(afterInsertUpdatedAt.getTime()); + }); + + it('should NOT update updatedAt when deleting rows in dry run mode', async () => { + // Create a data table with columns + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [ + { name: 'email', type: 'string' }, + { name: 'name', type: 'string' }, + ], + }); + + // Insert rows + await dataTableService.insertRows(dataTable.id, project.id, [ + { email: 'test@example.com', name: 'User 1' }, + ]); + + // Get updatedAt after insert + const afterInsert = await dataTableRepository.findOneBy({ id: dataTable.id }); + const afterInsertUpdatedAt = afterInsert!.updatedAt; + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Delete rows in DRY RUN mode + await dataTableService.deleteRows( + dataTable.id, + project.id, + { + filter: { + type: 'and', + filters: [{ columnName: 'email', condition: 'eq', value: 'test@example.com' }], + }, + }, + false, + true, // dryRun = true + ); + + // Check that updatedAt was NOT updated (same timestamp) + const afterDryRun = await dataTableRepository.findOneBy({ id: dataTable.id }); + expect(afterDryRun!.updatedAt.getTime()).toBe(afterInsertUpdatedAt.getTime()); + }); + }); +}); diff --git a/packages/cli/src/modules/data-table/data-table.repository.ts b/packages/cli/src/modules/data-table/data-table.repository.ts index b53f1883a32..fa59ce5a210 100644 --- a/packages/cli/src/modules/data-table/data-table.repository.ts +++ b/packages/cli/src/modules/data-table/data-table.repository.ts @@ -3,6 +3,7 @@ import { type DataTableCreateColumnSchema, type ListDataTableQueryDto, } from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import { Project, withTransaction } from '@n8n/db'; import { Service } from '@n8n/di'; @@ -24,10 +25,26 @@ export class DataTableRepository extends Repository { dataSource: DataSource, private ddlService: DataTableDDLService, private readonly globalConfig: GlobalConfig, + private readonly logger: Logger, ) { super(DataTable, dataSource.manager); } + /** + * Updates the updatedAt timestamp for a data table without modifying any other fields. + * This is used to track when the table's content (rows/columns) has changed. + * + * Note: This method logs but does not throw errors to ensure that timestamp + * update failures don't affect the primary data operations. + */ + async touchUpdatedAt(dataTableId: string, trx?: EntityManager) { + await withTransaction(this.manager, trx, async (em) => { + await em.update(DataTable, { id: dataTableId }, { updatedAt: new Date() }); + }).catch((error) => { + this.logger.warn('Failed to update DataTable timestamp', { dataTableId, error }); + }); + } + async createDataTable( projectId: string, name: string, diff --git a/packages/cli/src/modules/data-table/data-table.service.ts b/packages/cli/src/modules/data-table/data-table.service.ts index d08712ff73c..5ce91ef8226 100644 --- a/packages/cli/src/modules/data-table/data-table.service.ts +++ b/packages/cli/src/modules/data-table/data-table.service.ts @@ -171,7 +171,11 @@ export class DataTableService { async addColumn(dataTableId: string, projectId: string, dto: AddDataTableColumnDto) { await this.validateDataTableExists(dataTableId, projectId); - return await this.dataTableColumnRepository.addColumn(dataTableId, dto); + const result = await this.dataTableColumnRepository.addColumn(dataTableId, dto); + + await this.dataTableRepository.touchUpdatedAt(dataTableId); + + return result; } async moveColumn( @@ -194,6 +198,8 @@ export class DataTableService { await this.dataTableColumnRepository.deleteColumn(dataTableId, existingColumn); + await this.dataTableRepository.touchUpdatedAt(dataTableId); + return true; } @@ -262,6 +268,8 @@ export class DataTableService { this.dataTableSizeValidator.reset(); + await this.dataTableRepository.touchUpdatedAt(dataTableId); + return result; } @@ -336,6 +344,8 @@ export class DataTableService { if (!dryRun) { this.dataTableSizeValidator.reset(); + + await this.dataTableRepository.touchUpdatedAt(dataTableId); } return result; @@ -421,6 +431,8 @@ export class DataTableService { if (!dryRun) { this.dataTableSizeValidator.reset(); + + await this.dataTableRepository.touchUpdatedAt(dataTableId); } return result; @@ -479,6 +491,8 @@ export class DataTableService { if (!dryRun) { this.dataTableSizeValidator.reset(); + + await this.dataTableRepository.touchUpdatedAt(dataTableId); } return result;