diff --git a/packages/@n8n/api-types/src/dto/data-store/add-data-store-rows.dto.ts b/packages/@n8n/api-types/src/dto/data-store/add-data-store-rows.dto.ts index a752b0d115..5fa3909820 100644 --- a/packages/@n8n/api-types/src/dto/data-store/add-data-store-rows.dto.ts +++ b/packages/@n8n/api-types/src/dto/data-store/add-data-store-rows.dto.ts @@ -1,8 +1,11 @@ import { z } from 'zod'; import { Z } from 'zod-class'; -import { dataStoreColumnNameSchema } from '../../schemas/data-store.schema'; +import { + dataStoreColumnNameSchema, + dataStoreColumnValueSchema, +} from '../../schemas/data-store.schema'; export class AddDataStoreRowsDto extends Z.class({ - data: z.array(z.record(dataStoreColumnNameSchema, z.any())), + data: z.array(z.record(dataStoreColumnNameSchema, dataStoreColumnValueSchema)), }) {} diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 618c8bec50..f9f141c80f 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -56,4 +56,5 @@ export { type DataStoreRows, type DataStoreListOptions, type DataStoreUserTableName, + dateTimeSchema, } from './schemas/data-store.schema'; diff --git a/packages/@n8n/api-types/src/schemas/data-store.schema.ts b/packages/@n8n/api-types/src/schemas/data-store.schema.ts index 7325397f95..eda3040f09 100644 --- a/packages/@n8n/api-types/src/schemas/data-store.schema.ts +++ b/packages/@n8n/api-types/src/schemas/data-store.schema.ts @@ -51,6 +51,15 @@ export type DataStoreListOptions = Partial & { filter: { projectId: string }; }; +export const dateTimeSchema = z + .string() + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .pipe(z.date()); + +// Dates are received as date strings and validated before insertion +export const dataStoreColumnValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); + export type DataStoreColumnJsType = string | number | boolean | Date; export type DataStoreRows = Array>; diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index a334188606..ff1374bb7f 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -119,6 +119,7 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = ` "dataStore:list", "dataStore:readRow", "dataStore:writeRow", + "dataStore:listProject", "dataStore:*", "*", ] diff --git a/packages/cli/src/modules/data-store/__tests__/data-store.controller.test.ts b/packages/cli/src/modules/data-store/__tests__/data-store.controller.test.ts index 916636bd8f..c4364f2f0c 100644 --- a/packages/cli/src/modules/data-store/__tests__/data-store.controller.test.ts +++ b/packages/cli/src/modules/data-store/__tests__/data-store.controller.test.ts @@ -1940,6 +1940,214 @@ describe('POST /projects/:projectId/data-stores/:dataStoreId/insert', () => { const rowsInDb = await dataStoreRowsRepository.getManyAndCount(toTableName(dataStore.id), {}); expect(rowsInDb.count).toBe(0); }); + + test('should insert columns with dates', async () => { + const dataStore = await createDataStore(memberProject, { + columns: [ + { + name: 'a', + type: 'date', + }, + { + name: 'b', + type: 'date', + }, + ], + }); + + const payload = { + data: [ + { + a: '2025-08-15T09:48:14.259Z', + b: '2025-08-15T12:34:56+02:00', + }, + ], + }; + + await authMemberAgent + .post(`/projects/${memberProject.id}/data-stores/${dataStore.id}/insert`) + .send(payload) + .expect(200); + + const readResponse = await authMemberAgent + .get(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`) + .expect(200); + + expect(readResponse.body.data.count).toBe(1); + expect(readResponse.body.data.data[0]).toMatchObject({ + a: '2025-08-15T09:48:14.259Z', + b: '2025-08-15T10:34:56.000Z', + }); + }); + + test('should insert columns with strings', async () => { + const dataStore = await createDataStore(memberProject, { + columns: [ + { + name: 'a', + type: 'string', + }, + { + name: 'b', + type: 'string', + }, + { + name: 'c', + type: 'string', + }, + ], + }); + + const payload = { + data: [ + { + a: 'some string', + b: '', + c: '2025-08-15T09:48:14.259Z', + }, + ], + }; + + await authMemberAgent + .post(`/projects/${memberProject.id}/data-stores/${dataStore.id}/insert`) + .send(payload) + .expect(200); + + const readResponse = await authMemberAgent + .get(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`) + .expect(200); + + expect(readResponse.body.data.count).toBe(1); + expect(readResponse.body.data.data[0]).toMatchObject(payload.data[0]); + }); + + test('should insert columns with booleans', async () => { + const dataStore = await createDataStore(memberProject, { + columns: [ + { + name: 'a', + type: 'boolean', + }, + { + name: 'b', + type: 'boolean', + }, + ], + }); + + const payload = { + data: [ + { + a: true, + b: false, + }, + ], + }; + + await authMemberAgent + .post(`/projects/${memberProject.id}/data-stores/${dataStore.id}/insert`) + .send(payload) + .expect(200); + + const readResponse = await authMemberAgent + .get(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`) + .expect(200); + + expect(readResponse.body.data.count).toBe(1); + expect(readResponse.body.data.data[0]).toMatchObject(payload.data[0]); + }); + + test('should insert columns with numbers', async () => { + const dataStore = await createDataStore(memberProject, { + columns: [ + { + name: 'a', + type: 'number', + }, + { + name: 'b', + type: 'number', + }, + { + name: 'c', + type: 'number', + }, + { + name: 'd', + type: 'number', + }, + ], + }); + + const payload = { + data: [ + { + a: 1, + b: 0, + c: -1, + d: 0.2340439341231259, + }, + ], + }; + + await authMemberAgent + .post(`/projects/${memberProject.id}/data-stores/${dataStore.id}/insert`) + .send(payload) + .expect(200); + + const readResponse = await authMemberAgent + .get(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`) + .expect(200); + + expect(readResponse.body.data.count).toBe(1); + expect(readResponse.body.data.data[0]).toMatchObject(payload.data[0]); + }); + + test('should insert columns with null values', async () => { + const dataStore = await createDataStore(memberProject, { + columns: [ + { + name: 'a', + type: 'string', + }, + { + name: 'b', + type: 'number', + }, + { + name: 'c', + type: 'boolean', + }, + { + name: 'd', + type: 'date', + }, + ], + }); + + const payload = { + data: [ + { + a: null, + b: null, + c: null, + d: null, + }, + ], + }; + + await authMemberAgent + .post(`/projects/${memberProject.id}/data-stores/${dataStore.id}/insert`) + .send(payload) + .expect(200); + + const readResponse = await authMemberAgent + .get(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`) + .expect(200); + + expect(readResponse.body.data.count).toBe(1); + expect(readResponse.body.data.data[0]).toMatchObject(payload.data[0]); + }); }); describe('DELETE /projects/:projectId/data-stores/:dataStoreId/rows', () => { diff --git a/packages/cli/src/modules/data-store/__tests__/data-store.service.test.ts b/packages/cli/src/modules/data-store/__tests__/data-store.service.test.ts index 1aba433c87..ab916a1962 100644 --- a/packages/cli/src/modules/data-store/__tests__/data-store.service.test.ts +++ b/packages/cli/src/modules/data-store/__tests__/data-store.service.test.ts @@ -875,6 +875,12 @@ describe('dataStore', () => { { c1: 3, c2: true, c3: new Date(), c4: 'hello?' }, { c1: 4, c2: false, c3: new Date(), c4: 'hello!' }, { c1: 5, c2: true, c3: new Date(), c4: 'hello.' }, + { + c1: 1, + c2: true, + c3: '2025-08-15T09:48:14.259Z', + c4: 'iso 8601 date strings are okay too', + }, ]; const result = await dataStoreService.insertRows(dataStoreId, project1.id, rows); @@ -886,7 +892,7 @@ describe('dataStore', () => { project1.id, {}, ); - expect(count).toEqual(3); + expect(count).toEqual(4); expect(data).toEqual( rows.map((row, i) => ({ ...row, @@ -998,6 +1004,26 @@ describe('dataStore', () => { await expect(result).rejects.toThrow(new DataStoreValidationError('unknown column name')); }); + it('rejects a invalid date string to date column', async () => { + // ARRANGE + const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, { + name: 'dataStore', + columns: [{ name: 'c1', type: 'date' }], + }); + + // ACT + const result = dataStoreService.insertRows(dataStoreId, project1.id, [ + { c1: '2025-99-15T09:48:14.259Z' }, + ]); + + // ASSERT + await expect(result).rejects.toThrow( + new DataStoreValidationError( + "value '2025-99-15T09:48:14.259Z' does not match column type 'date'", + ), + ); + }); + it('rejects unknown data store id', async () => { // ARRANGE await dataStoreService.createDataStore(project1.id, { diff --git a/packages/cli/src/modules/data-store/data-store.service.ts b/packages/cli/src/modules/data-store/data-store.service.ts index 59e26b179a..3f2eafc21f 100644 --- a/packages/cli/src/modules/data-store/data-store.service.ts +++ b/packages/cli/src/modules/data-store/data-store.service.ts @@ -1,3 +1,4 @@ +import { dateTimeSchema } from '@n8n/api-types'; import type { AddDataStoreColumnDto, CreateDataStoreDto, @@ -172,29 +173,38 @@ export class DataStoreService { if (cell === null) continue; switch (columnTypeMap.get(key)) { case 'boolean': - if (typeof cell !== 'boolean') + if (typeof cell !== 'boolean') { throw new DataStoreValidationError( `value '${cell.toString()}' does not match column type 'boolean'`, ); + } break; case 'date': - if (!(cell instanceof Date)) - throw new DataStoreValidationError( - `value '${cell}' does not match column type 'date'`, - ); - row[key] = cell.toISOString(); - break; + if (typeof cell === 'string') { + const validated = dateTimeSchema.safeParse(cell); + if (validated.success) { + row[key] = validated.data.toISOString(); + break; + } + } else if (cell instanceof Date) { + row[key] = cell.toISOString(); + break; + } + + throw new DataStoreValidationError(`value '${cell}' does not match column type 'date'`); case 'string': - if (typeof cell !== 'string') + if (typeof cell !== 'string') { throw new DataStoreValidationError( `value '${cell.toString()}' does not match column type 'string'`, ); + } break; case 'number': - if (typeof cell !== 'number') + if (typeof cell !== 'number') { throw new DataStoreValidationError( `value '${cell.toString()}' does not match column type 'number'`, ); + } break; } }