chore: Migrate data-tables table-level endpoints to PublicApiController (#38639)

Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Sam Wooler <swooler592@gmail.com>
This commit is contained in:
n8n-cat-bot[bot] 2026-09-25 12:18:43 +00:00 committed by GitHub
parent 3f4d63ca8a
commit 88c3962423
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1033 additions and 710 deletions

View File

@ -0,0 +1,64 @@
import '../../openapi-extend';
import { z } from 'zod';
import {
createDataTableColumnFieldDocs,
createDataTableFieldDocs,
dataTableColumnFieldDocs,
dataTableFieldDocs,
dataTableListFieldDocs,
updateDataTableFieldDocs,
} from './data-table-public.openapi';
import {
dataTableColumnNameSchema,
dataTableColumnTypeSchema,
dataTableNameSchema,
} from '../../schemas/data-table.schema';
import { Z } from '../../zod-class';
const dataTableColumnPublicSchema = z.object({
id: z.string().openapi(dataTableColumnFieldDocs.id),
name: z.string().openapi(dataTableColumnFieldDocs.name),
type: z.string().openapi(dataTableColumnFieldDocs.type),
index: z.number().openapi(dataTableColumnFieldDocs.index),
createdAt: z.string().datetime().openapi(dataTableColumnFieldDocs.createdAt),
updatedAt: z.string().datetime().openapi(dataTableColumnFieldDocs.updatedAt),
});
export const dataTablePublicSchema = z.object({
id: z.string().openapi(dataTableFieldDocs.id),
name: z.string().openapi(dataTableFieldDocs.name),
columns: z.array(dataTableColumnPublicSchema).openapi(dataTableFieldDocs.columns),
projectId: z.string().openapi(dataTableFieldDocs.projectId),
createdAt: z.string().datetime().openapi(dataTableFieldDocs.createdAt),
updatedAt: z.string().datetime().openapi(dataTableFieldDocs.updatedAt),
sizeBytes: z.number().openapi(dataTableFieldDocs.sizeBytes),
});
export type DataTablePublic = z.infer<typeof dataTablePublicSchema>;
export class DataTablePublicDto extends Z.class(dataTablePublicSchema.shape) {}
export class DataTableListPublicDto extends Z.class({
data: z.array(dataTablePublicSchema),
nextCursor: z.string().nullable().openapi(dataTableListFieldDocs.nextCursor),
}) {}
const createDataTableColumnPublicSchema = z.object({
name: dataTableColumnNameSchema.openapi(dataTableColumnFieldDocs.name),
type: dataTableColumnTypeSchema.openapi(dataTableColumnFieldDocs.type),
csvColumnName: z.string().optional().openapi(createDataTableColumnFieldDocs.csvColumnName),
});
export class CreateDataTablePublicDto extends Z.class({
name: dataTableNameSchema.openapi(createDataTableFieldDocs.name),
columns: z.array(createDataTableColumnPublicSchema).openapi(createDataTableFieldDocs.columns),
projectId: z.string().optional().openapi(createDataTableFieldDocs.projectId),
fileId: z.string().optional().openapi(createDataTableFieldDocs.fileId),
hasHeaders: z.boolean().optional().openapi(createDataTableFieldDocs.hasHeaders),
}) {}
export class UpdateDataTablePublicDto extends Z.class({
name: dataTableNameSchema.openapi(updateDataTableFieldDocs.name),
}) {}

View File

@ -0,0 +1,87 @@
import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi';
import { dataTableColumnTypeSchema } from '../../schemas/data-table.schema';
export const dataTableFieldDocs = {
id: { description: 'Unique identifier for the data table' },
name: { description: 'Name of the data table' },
columns: { description: 'Column definitions' },
projectId: { description: 'ID of the project this table belongs to' },
createdAt: { description: 'Timestamp when the table was created' },
updatedAt: { description: 'Timestamp when the table was last updated' },
sizeBytes: {
type: 'integer',
description:
'Physical storage in bytes, including indexes and internal overhead. Not reduced ' +
'immediately by row deletion, and may be a few seconds stale.',
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const dataTableColumnFieldDocs = {
id: { description: 'Column ID' },
name: { description: 'Column name' },
type: { enum: [...dataTableColumnTypeSchema.options], description: 'Column data type' },
index: { type: 'integer', description: 'Column position' },
createdAt: { description: 'Timestamp when the column was created' },
updatedAt: { description: 'Timestamp when the column was last updated' },
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const dataTableListFieldDocs = {
nextCursor: {
description:
'Paginate through data tables by setting the cursor parameter to a nextCursor attribute ' +
'returned by a previous request. Default value fetches the first "page" of the collection.',
example: 'MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA',
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const createDataTableColumnFieldDocs = {
csvColumnName: {
description:
'Name of the CSV column to read the values from. If you do not set it, n8n maps the ' +
'CSV columns by position.',
example: 'Email Address',
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const createDataTableFieldDocs = {
name: { description: 'Name of the data table', example: 'customers' },
columns: { description: 'Column definitions for the table' },
projectId: {
description:
"ID of the project to create the table in. When omitted, the table is created in the user's personal project.",
example: 'a1b2c3d4',
},
fileId: {
description:
'ID of a CSV file that you uploaded in the n8n editor. If you set it, n8n fills the new ' +
'table with the rows from that file. Only a session-authenticated caller can upload a ' +
'file, so an API-key caller cannot use this field.',
},
hasHeaders: {
description:
'Set to true if the first row of the CSV file holds the column names. Applies only when ' +
'you set `fileId`. The default is true.',
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const updateDataTableFieldDocs = {
name: { description: 'New name for the data table', example: 'updated-customers' },
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const listDataTablesQueryDocs = {
filter: {
type: 'string',
format: 'jsonString',
param: {
description: 'JSON string of filter conditions',
example: '{"name":"my-table"}',
},
},
sortBy: {
param: {
description: 'Sort format: field:asc or field:desc',
example: 'name:asc',
},
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;

View File

@ -1,6 +1,9 @@
import '../../openapi-extend';
import { jsonParse } from 'n8n-workflow';
import { z } from 'zod';
import { listDataTablesQueryDocs } from './data-table-public.openapi';
import { Z } from '../../zod-class';
import { paginationSchema, publicApiPaginationSchema } from '../pagination/pagination.dto';
@ -70,7 +73,8 @@ export class ListDataTableQueryDto extends Z.class({
}) {}
export class PublicApiListDataTableQueryDto extends Z.class({
...publicApiPaginationSchema,
filter: publicApiFilterValidator,
sortBy: sortByValidator,
limit: publicApiPaginationSchema.limit,
cursor: z.string().optional(),
filter: publicApiFilterValidator.openapi(listDataTablesQueryDocs.filter),
sortBy: sortByValidator.openapi(listDataTablesQueryDocs.sortBy),
}) {}

View File

@ -1,13 +0,0 @@
import { z } from 'zod';
import { CreateDataTableColumnDto } from './create-data-table-column.dto';
import { dataTableNameSchema } from '../../schemas/data-table.schema';
import { Z } from '../../zod-class';
export class PublicApiCreateDataTableDto extends Z.class({
name: dataTableNameSchema,
columns: z.array(CreateDataTableColumnDto.schema),
fileId: z.string().optional(),
hasHeaders: z.boolean().optional(),
projectId: z.string().optional(),
}) {}

View File

@ -404,7 +404,14 @@ export { TestOidcConfigResponseDto } from './oidc/test-oidc-config-response.dto'
export { CreateDataTableDto } from './data-table/create-data-table.dto';
export { UpdateDataTableDto } from './data-table/update-data-table.dto';
export { PublicApiCreateDataTableDto } from './data-table/public-api-create-data-table.dto';
export {
CreateDataTablePublicDto,
DataTableListPublicDto,
DataTablePublicDto,
UpdateDataTablePublicDto,
dataTablePublicSchema,
type DataTablePublic,
} from './data-table/data-table-public.dto';
export { UpdateDataTableRowDto } from './data-table/update-data-table-row.dto';
export { DeleteDataTableRowsDto } from './data-table/delete-data-table-rows.dto';
export { UpsertDataTableRowDto } from './data-table/upsert-data-table-row.dto';

View File

@ -156,6 +156,7 @@ export {
credentialTypeNameParamSchema,
credentialTypePolicyIdParamSchema,
credentialTypePolicyScopeIdParamSchema,
dataTableIdParamSchema,
executionIdParamSchema,
nodeTypePolicyIdParamSchema,
nodeTypePolicyScopeIdParamSchema,

View File

@ -52,6 +52,11 @@ export const credentialIdParamSchema = stringIdParamSchema('The ID of the creden
export const credentialTypeNameParamSchema = stringIdParamSchema(
'The credential type name that you want to get the schema for',
);
export const dataTableIdParamSchema = z
.string()
.regex(/^[A-Za-z0-9]{16}$/, 'must match format "nanoid"')
.openapi({ format: 'nanoid', param: { description: 'The ID of the data table' } });
export const variableIdParamSchema = stringIdParamSchema('The ID of the variable.');
export const nodeTypePolicyIdParamSchema = stringIdParamSchema(
'The ID of the node type policy document.',

View File

@ -132,18 +132,6 @@ export default defineConfig(
'n8n-local-rules/require-public-api-controller': 'error',
},
},
{
// Ratchet allowlist: handlers/services still reaching a repository directly, pending
// migration to the `@PublicApiController` + service pattern (API-70). NEVER add to this
// list — a new violation must fail CI. Entries are removed as each file migrates.
files: [
'./src/public-api/v1/handlers/data-tables/data-tables.handler.ts',
'./src/public-api/v1/handlers/data-tables/data-tables.service.ts',
],
rules: {
'n8n-local-rules/no-repository-in-public-api-handler': 'off',
},
},
{
// Ratchet allowlist: legacy `export =` handler tuples pending migration to
// `@PublicApiController` classes (API-70). NEVER add to this list — a new tuple handler
@ -153,7 +141,6 @@ export default defineConfig(
'./src/public-api/v1/handlers/community-packages/community-packages.handler.ts',
'./src/public-api/v1/handlers/credentials/credentials.handler.ts',
'./src/public-api/v1/handlers/data-tables/data-tables.columns.handler.ts',
'./src/public-api/v1/handlers/data-tables/data-tables.handler.ts',
'./src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts',
'./src/public-api/v1/handlers/evaluations/evaluations.handler.ts',
'./src/public-api/v1/handlers/ldap/ldap.handler.ts',

View File

@ -136,18 +136,6 @@ export default defineConfig({
'n8n-local-rules/require-public-api-controller': 'error',
},
},
{
// Ratchet allowlist: handlers/services still reaching a repository directly, pending
// migration to the `@PublicApiController` + service pattern (API-70). NEVER add to this
// list — a new violation must fail CI. Entries are removed as each file migrates.
files: [
'./src/public-api/v1/handlers/data-tables/data-tables.handler.ts',
'./src/public-api/v1/handlers/data-tables/data-tables.service.ts',
],
rules: {
'n8n-local-rules/no-repository-in-public-api-handler': 'off',
},
},
{
// Ratchet allowlist: legacy `export =` handler tuples pending migration to
// `@PublicApiController` classes (API-70). NEVER add to this list — a new tuple handler
@ -157,7 +145,6 @@ export default defineConfig({
'./src/public-api/v1/handlers/community-packages/community-packages.handler.ts',
'./src/public-api/v1/handlers/credentials/credentials.handler.ts',
'./src/public-api/v1/handlers/data-tables/data-tables.columns.handler.ts',
'./src/public-api/v1/handlers/data-tables/data-tables.handler.ts',
'./src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts',
'./src/public-api/v1/handlers/discover/discover.handler.ts',
'./src/public-api/v1/handlers/evaluations/evaluations.handler.ts',

View File

@ -1,8 +1,6 @@
import type {
AddDataTableColumnDto,
AddDataTableRowsDto,
PublicApiCreateDataTableDto,
UpdateDataTableDto,
UpdateDataTableColumnDto,
UpdateDataTableRowDto,
UpsertDataTableRowDto,
@ -135,27 +133,6 @@ export interface IJsonSchema {
// ----------------------------------
export declare namespace DataTableRequest {
type List = AuthenticatedRequest<
{},
{},
{},
{
limit?: number;
cursor?: string;
offset?: number;
filter?: string;
sortBy?: string;
}
>;
type Create = AuthenticatedRequest<{}, {}, PublicApiCreateDataTableDto, {}>;
type Get = AuthenticatedRequest<{ dataTableId: string }, {}, {}, {}>;
type Update = AuthenticatedRequest<{ dataTableId: string }, {}, UpdateDataTableDto, {}>;
type Delete = AuthenticatedRequest<{ dataTableId: string }, {}, {}, {}>;
type GetRows = AuthenticatedRequest<
{ dataTableId: string },
{},

View File

@ -0,0 +1,240 @@
import {
CreateDataTablePublicDto,
DataTableListPublicDto,
DataTablePublicDto,
PublicApiListDataTableQueryDto,
UpdateDataTablePublicDto,
dataTableIdParamSchema,
} from '@n8n/api-types';
import type { AuthenticatedRequest } from '@n8n/db';
import {
ApiDescription,
ApiErrorResponse,
ApiKeyScope,
ApiResponse,
ApiSummary,
ApiTags,
Body,
Delete,
Get,
Param,
Patch,
Post,
ProjectScope,
PublicApiController,
Query,
} from '@n8n/decorators';
import type { Response } from 'express';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { DataTableAggregateService } from '@/modules/data-table/data-table-aggregate.service';
import type { DataTable } from '@/modules/data-table/data-table.entity';
import { DataTableService } from '@/modules/data-table/data-table.service';
import { DataTableAccessDeniedError } from '@/modules/data-table/errors/data-table-access-denied.error';
import { DataTableNameConflictError } from '@/modules/data-table/errors/data-table-name-conflict.error';
import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error';
import { DataTableValidationError } from '@/modules/data-table/errors/data-table-validation.error';
import {
encodeNextCursor,
resolveOffsetPagination,
} from '@/public-api/v1/shared/services/pagination.service';
import { ProjectNotFoundError } from '@/services/project.service.ee';
const tags = ['DataTable'];
/** The domain errors the data table service raises, mapped to the public statuses the eov handler sent. */
function handleError(error: unknown): never {
if (error instanceof DataTableValidationError) {
throw new BadRequestError(error.message);
}
if (error instanceof ProjectNotFoundError) {
throw new BadRequestError(`Project with ID "${error.projectId}" not found`);
}
if (error instanceof DataTableNotFoundError) {
throw new NotFoundError(error.message);
}
if (error instanceof DataTableAccessDeniedError) {
throw new ForbiddenError();
}
if (error instanceof DataTableNameConflictError) {
throw new ConflictError(error.message);
}
throw error;
}
const toDataTablePublicDto = (dataTable: DataTable, sizeBytes: number): DataTablePublicDto => ({
id: dataTable.id,
name: dataTable.name,
columns: (dataTable.columns ?? []).map((column) => ({
id: column.id,
name: column.name,
type: column.type,
index: column.index,
createdAt: column.createdAt.toISOString(),
updatedAt: column.updatedAt.toISOString(),
})),
projectId: dataTable.projectId,
createdAt: dataTable.createdAt.toISOString(),
updatedAt: dataTable.updatedAt.toISOString(),
sizeBytes,
});
@PublicApiController('/data-tables')
export class DataTablesPublicController {
constructor(
private readonly dataTableService: DataTableService,
private readonly dataTableAggregateService: DataTableAggregateService,
) {}
@Get('/')
@ApiKeyScope('dataTable:list')
@ApiSummary('List all data tables')
@ApiDescription(
'Retrieve a list of all data tables with optional filtering, sorting, and pagination. ' +
'Each table includes `sizeBytes`, the value the `size` sort option orders by.',
)
@ApiTags(tags)
@ApiResponse(200, DataTableListPublicDto)
async listDataTables(
req: AuthenticatedRequest,
_res: Response,
@Query query: PublicApiListDataTableQueryDto,
): Promise<DataTableListPublicDto> {
const { offset, limit } = resolveOffsetPagination(query);
try {
const { data, count } = await this.dataTableAggregateService.getManyAndCount(req.user, {
skip: offset,
take: limit,
filter: query.filter,
sortBy: query.sortBy,
});
const sizes = await this.dataTableService.getCachedSizeBytesByIds(
data.map((dataTable) => dataTable.id),
);
return {
data: data.map((dataTable) =>
toDataTablePublicDto(dataTable, sizes.get(dataTable.id) ?? 0),
),
nextCursor: encodeNextCursor({ offset, limit, numberOfTotalRecords: count }),
};
} catch (error) {
return handleError(error);
}
}
@Post('/')
@ApiKeyScope('dataTable:create')
@ApiSummary('Create a new data table')
@ApiDescription(
'Create a new data table in your personal project or a team project you have access to.',
)
@ApiTags(tags)
@ApiResponse(201, DataTablePublicDto)
@ApiErrorResponse(409)
async createDataTable(
req: AuthenticatedRequest,
_res: Response,
@Body body: CreateDataTablePublicDto,
): Promise<DataTablePublicDto> {
try {
const owningProjectId = await this.dataTableService.resolveOwningProjectId(
req.user,
body.projectId,
);
const dataTable = await this.dataTableService.createDataTable(owningProjectId, {
name: body.name,
columns: body.columns,
fileId: body.fileId,
hasHeaders: body.hasHeaders,
});
return await this.withSize(dataTable);
} catch (error) {
return handleError(error);
}
}
@Get('/:dataTableId')
@ApiKeyScope('dataTable:read')
@ProjectScope('dataTable:read')
@ApiSummary('Get a data table')
@ApiDescription('Retrieve a specific data table by ID.')
@ApiTags(tags)
@ApiResponse(200, DataTablePublicDto)
@ApiErrorResponse(404)
async getDataTable(
_req: AuthenticatedRequest,
_res: Response,
@Param('dataTableId', dataTableIdParamSchema) dataTableId: string,
): Promise<DataTablePublicDto> {
try {
const projectId = await this.dataTableService.getProjectIdForDataTable(dataTableId);
return await this.withSize(await this.dataTableService.getOne(dataTableId, projectId));
} catch (error) {
return handleError(error);
}
}
@Patch('/:dataTableId')
@ApiKeyScope('dataTable:update')
@ProjectScope('dataTable:update')
@ApiSummary('Update a data table')
@ApiDescription("Update a data table's name.")
@ApiTags(tags)
@ApiResponse(200, DataTablePublicDto)
@ApiErrorResponse(404)
@ApiErrorResponse(409)
async updateDataTable(
_req: AuthenticatedRequest,
_res: Response,
@Param('dataTableId', dataTableIdParamSchema) dataTableId: string,
@Body body: UpdateDataTablePublicDto,
): Promise<DataTablePublicDto> {
try {
const projectId = await this.dataTableService.getProjectIdForDataTable(dataTableId);
await this.dataTableService.updateDataTable(dataTableId, projectId, { name: body.name });
return await this.withSize(await this.dataTableService.getOne(dataTableId, projectId));
} catch (error) {
return handleError(error);
}
}
@Delete('/:dataTableId')
@ApiKeyScope('dataTable:delete')
@ProjectScope('dataTable:delete')
@ApiSummary('Delete a data table')
@ApiDescription('Delete a data table. This will also delete all rows in the table.')
@ApiTags(tags)
@ApiResponse(204)
@ApiErrorResponse(404)
async deleteDataTable(
_req: AuthenticatedRequest,
_res: Response,
@Param('dataTableId', dataTableIdParamSchema) dataTableId: string,
): Promise<void> {
try {
const projectId = await this.dataTableService.getProjectIdForDataTable(dataTableId);
await this.dataTableService.deleteDataTable(dataTableId, projectId);
} catch (error) {
handleError(error);
}
}
private async withSize(dataTable: DataTable): Promise<DataTablePublicDto> {
const sizes = await this.dataTableService.getCachedSizeBytesByIds([dataTable.id]);
return toDataTablePublicDto(dataTable, sizes.get(dataTable.id) ?? 0);
}
}

View File

@ -6,6 +6,7 @@
import './community-packages.public.controller';
import './credential-type-policies.public.controller';
import './credentials.public.controller';
import './data-tables.public.controller';
import './discover.public.controller';
import './evaluations.public.controller';
import './executions.public.controller';

View File

@ -11,7 +11,6 @@ import { DataTableService } from '@/modules/data-table/data-table.service';
import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error';
import type { DataTableRequest } from '@/public-api/types';
import * as middlewares from '@/public-api/v1/shared/middlewares/global.middleware';
import { ProjectNotFoundError } from '@/services/project.service.ee';
// Mock middleware before requiring handler
const mockMiddleware = vi.fn(async (_req, _res, next) => next()) as any;
@ -21,11 +20,9 @@ vi.spyOn(middlewares, 'validCursor').mockReturnValue(mockMiddleware);
// Loaded after the middleware spies above are installed; typed loosely so the
// suite can invoke individual route entries by index.
let mainHandler: Record<string, Array<(...args: unknown[]) => unknown>>;
let handler: Record<string, Array<(...args: unknown[]) => unknown>>;
beforeAll(async () => {
mainHandler = (await import('../data-tables.handler.js')) as unknown as typeof mainHandler;
handler = (await import('../data-tables.rows.handler.js')) as unknown as typeof handler;
});
@ -71,188 +68,6 @@ describe('DataTable Handler', () => {
vi.clearAllMocks();
});
describe('createDataTable', () => {
it('should create in personal project when no projectId provided', async () => {
const req = {
body: { name: 'test-table', columns: [{ name: 'col1', type: 'string' }] },
user: makeUser(),
} as unknown as DataTableRequest.Create;
mockDataTableService.resolveOwningProjectId.mockResolvedValue(projectId);
mockDataTableService.createDataTable.mockResolvedValue({
id: dataTableId,
name: 'test-table',
columns: [],
project: { id: projectId },
} as never);
await mainHandler.createDataTable[1](req, mockResponse as Response);
expect(mockDataTableService.resolveOwningProjectId).toHaveBeenCalledWith(req.user, undefined);
expect(mockDataTableService.createDataTable).toHaveBeenCalledWith(projectId, {
name: 'test-table',
columns: [{ name: 'col1', type: 'string' }],
fileId: undefined,
hasHeaders: undefined,
});
expect(mockResponse.status).toHaveBeenCalledWith(201);
});
it('should map a missing project to BadRequestError', async () => {
const req = {
body: { name: 'test-table', columns: [], projectId },
user: makeUser(),
} as unknown as DataTableRequest.Create;
mockDataTableService.resolveOwningProjectId.mockRejectedValue(
new ProjectNotFoundError(projectId),
);
const handlerFn = mainHandler.createDataTable[1];
let caught: unknown;
try {
await handlerFn(req, mockResponse as Response);
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(BadRequestError);
expect(caught).toMatchObject({
message: `Project with ID "${projectId}" not found`,
httpStatusCode: 400,
});
});
});
describe('listDataTables', () => {
it('should list via the aggregate service for a regular user', async () => {
const req = { query: {}, user: makeUser() } as unknown as DataTableRequest.List;
mockDataTableAggregateService.getManyAndCount.mockResolvedValue({
data: [],
count: 0,
} as never);
await mainHandler.listDataTables[2](req, mockResponse as Response);
expect(mockDataTableAggregateService.getManyAndCount).toHaveBeenCalledWith(
req.user,
expect.objectContaining({ skip: 0, take: 100 }),
);
});
it('should list via the aggregate service for a user with dataTable:listProject', async () => {
const req = {
query: {},
user: makeUser(['dataTable:listProject']),
} as unknown as DataTableRequest.List;
mockDataTableAggregateService.getManyAndCount.mockResolvedValue({
data: [],
count: 0,
} as never);
await mainHandler.listDataTables[2](req, mockResponse as Response);
expect(mockDataTableAggregateService.getManyAndCount).toHaveBeenCalledWith(
req.user,
expect.objectContaining({ skip: 0, take: 100, filter: undefined }),
);
});
});
describe('sizeBytes', () => {
const otherDataTableId = 'other-data-table-id';
const makeDataTable = (id: string) => ({
id,
name: `table-${id}`,
columns: [],
project: { id: projectId },
});
beforeEach(() => {
mockDataTableService.getCachedSizeBytesByIds.mockResolvedValue(
new Map([
[dataTableId, 4096],
[otherDataTableId, 8192],
]),
);
});
it('should attach sizeBytes to every item when listing', async () => {
const req = {
query: {},
user: makeUser(['dataTable:listProject']),
} as unknown as DataTableRequest.List;
mockDataTableAggregateService.getManyAndCount.mockResolvedValue({
data: [makeDataTable(dataTableId), makeDataTable(otherDataTableId)],
count: 2,
} as never);
await mainHandler.listDataTables[2](req, mockResponse as Response);
const { data } = (mockResponse.json as Mock).mock.calls[0][0];
expect(data).toHaveLength(2);
expect(data[0]).toMatchObject({ id: dataTableId, sizeBytes: 4096 });
expect(data[1]).toMatchObject({ id: otherDataTableId, sizeBytes: 8192 });
});
it('should include sizeBytes on the create response', async () => {
const req = {
body: { name: 'test-table', columns: [] },
user: makeUser(),
} as unknown as DataTableRequest.Create;
mockDataTableService.resolveOwningProjectId.mockResolvedValue(projectId);
mockDataTableService.createDataTable.mockResolvedValue(makeDataTable(dataTableId) as never);
await mainHandler.createDataTable[1](req, mockResponse as Response);
expect(mockResponse.status).toHaveBeenCalledWith(201);
expect((mockResponse.json as Mock).mock.calls[0][0]).toMatchObject({ sizeBytes: 4096 });
});
it('should include sizeBytes when reading a single data table', async () => {
const req = {
params: { dataTableId },
user: makeUser(),
} as unknown as DataTableRequest.Get;
mockDataTableService.getOne.mockResolvedValue(makeDataTable(dataTableId) as never);
await mainHandler.getDataTable[2](req, mockResponse as Response);
expect((mockResponse.json as Mock).mock.calls[0][0]).toMatchObject({
id: dataTableId,
sizeBytes: 4096,
});
});
it('should include sizeBytes on the update response', async () => {
const req = {
params: { dataTableId },
body: { name: 'renamed' },
user: makeUser(),
} as unknown as DataTableRequest.Update;
mockDataTableService.getOne.mockResolvedValue(makeDataTable(dataTableId) as never);
await mainHandler.updateDataTable[2](req, mockResponse as Response);
expect((mockResponse.json as Mock).mock.calls[0][0]).toMatchObject({
id: dataTableId,
sizeBytes: 4096,
});
});
it('should serialise a table missing from the size map as 0, not undefined', async () => {
mockDataTableService.getCachedSizeBytesByIds.mockResolvedValue(new Map());
const req = {
params: { dataTableId },
user: makeUser(),
} as unknown as DataTableRequest.Get;
mockDataTableService.getOne.mockResolvedValue(makeDataTable(dataTableId) as never);
await mainHandler.getDataTable[2](req, mockResponse as Response);
expect((mockResponse.json as Mock).mock.calls[0][0].sizeBytes).toBe(0);
});
});
describe('getDataTableRows', () => {
it('should retrieve rows successfully', async () => {
// Arrange

View File

@ -1,203 +0,0 @@
import {
PublicApiListDataTableQueryDto,
PublicApiCreateDataTableDto,
UpdateDataTableDto,
} from '@n8n/api-types';
import { Container } from '@n8n/di';
import type { DataTableRequest } from '../../../types';
import type { PublicAPIEndpoint } from '../../shared/handler.types';
import {
publicApiScope,
projectScope,
validCursor,
} from '../../shared/middlewares/global.middleware';
import { encodeNextCursor } from '../../shared/services/pagination.service';
import { stringifyQuery } from './data-tables.utils';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { DataTableAggregateService } from '@/modules/data-table/data-table-aggregate.service';
import { DataTableService } from '@/modules/data-table/data-table.service';
import { DataTableAccessDeniedError } from '@/modules/data-table/errors/data-table-access-denied.error';
import { DataTableNameConflictError } from '@/modules/data-table/errors/data-table-name-conflict.error';
import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error';
import { DataTableValidationError } from '@/modules/data-table/errors/data-table-validation.error';
import { ProjectNotFoundError } from '@/services/project.service.ee';
const handleError = (error: unknown) => {
if (error instanceof DataTableValidationError) {
throw new BadRequestError(error.message);
}
if (error instanceof ProjectNotFoundError) {
throw new BadRequestError(`Project with ID "${error.projectId}" not found`);
}
if (error instanceof DataTableNotFoundError) {
throw new NotFoundError(error.message);
}
if (error instanceof DataTableAccessDeniedError) {
throw new ForbiddenError();
}
if (error instanceof DataTableNameConflictError) {
throw new ConflictError(error.message);
}
throw error;
};
function toPublicDataTable<T extends { project?: unknown }>(dataTable: T) {
const { project: _project, ...rest } = dataTable;
return rest;
}
const attachSize = async <T extends { id: string }>(dataTable: T) => {
const sizes = await Container.get(DataTableService).getCachedSizeBytesByIds([dataTable.id]);
return { ...dataTable, sizeBytes: sizes.get(dataTable.id) ?? 0 };
};
type DataTableHandlers = {
listDataTables: PublicAPIEndpoint<DataTableRequest.List>;
createDataTable: PublicAPIEndpoint<DataTableRequest.Create>;
getDataTable: PublicAPIEndpoint<DataTableRequest.Get>;
updateDataTable: PublicAPIEndpoint<DataTableRequest.Update>;
deleteDataTable: PublicAPIEndpoint<DataTableRequest.Delete>;
};
const dataTableHandlers: DataTableHandlers = {
listDataTables: [
publicApiScope('dataTable:list'),
validCursor,
async (req, res) => {
try {
const payload = PublicApiListDataTableQueryDto.safeParse(stringifyQuery(req.query));
if (!payload.success) {
throw new BadRequestError(payload.error.errors[0]?.message || 'Invalid query parameters');
}
const { offset, limit, filter, sortBy } = payload.data;
const result = await Container.get(DataTableAggregateService).getManyAndCount(req.user, {
skip: offset,
take: limit,
filter,
sortBy,
});
const sizes = await Container.get(DataTableService).getCachedSizeBytesByIds(
result.data.map((dataTable) => dataTable.id),
);
const data = result.data.map((dataTable) => ({
...toPublicDataTable(dataTable),
sizeBytes: sizes.get(dataTable.id) ?? 0,
}));
return res.json({
data,
nextCursor: encodeNextCursor({
offset,
limit,
numberOfTotalRecords: result.count,
}),
});
} catch (error) {
return handleError(error);
}
},
],
createDataTable: [
publicApiScope('dataTable:create'),
async (req, res) => {
const payload = PublicApiCreateDataTableDto.safeParse(req.body);
if (!payload.success) {
throw new BadRequestError(payload.error.errors[0]?.message || 'Invalid request body');
}
const { projectId, name, columns, fileId, hasHeaders } = payload.data;
const dataTableService = Container.get(DataTableService);
try {
const owningProjectId = await dataTableService.resolveOwningProjectId(req.user, projectId);
const result = await dataTableService.createDataTable(owningProjectId, {
name,
columns,
fileId,
hasHeaders,
});
return res.status(201).json(await attachSize(toPublicDataTable(result)));
} catch (error) {
return handleError(error);
}
},
],
getDataTable: [
publicApiScope('dataTable:read'),
projectScope('dataTable:read', 'dataTable'),
async (req, res) => {
try {
const { dataTableId } = req.params;
const projectId =
await Container.get(DataTableService).getProjectIdForDataTable(dataTableId);
const result = await Container.get(DataTableService).getOne(dataTableId, projectId);
return res.json(await attachSize(toPublicDataTable(result)));
} catch (error) {
return handleError(error);
}
},
],
updateDataTable: [
publicApiScope('dataTable:update'),
projectScope('dataTable:update', 'dataTable'),
async (req, res) => {
try {
const { dataTableId } = req.params;
const payload = UpdateDataTableDto.safeParse(req.body);
if (!payload.success) {
throw new BadRequestError(payload.error.errors[0]?.message || 'Invalid request body');
}
const dataTableService = Container.get(DataTableService);
const projectId = await dataTableService.getProjectIdForDataTable(dataTableId);
await dataTableService.updateDataTable(dataTableId, projectId, payload.data);
const result = await dataTableService.getOne(dataTableId, projectId);
return res.json(await attachSize(toPublicDataTable(result)));
} catch (error) {
return handleError(error);
}
},
],
deleteDataTable: [
publicApiScope('dataTable:delete'),
projectScope('dataTable:delete', 'dataTable'),
async (req, res) => {
try {
const { dataTableId } = req.params;
const projectId =
await Container.get(DataTableService).getProjectIdForDataTable(dataTableId);
await Container.get(DataTableService).deleteDataTable(dataTableId, projectId);
return res.status(204).send();
} catch (error) {
return handleError(error);
}
},
],
};
export = dataTableHandlers;

View File

@ -0,0 +1,79 @@
operationId: createDataTable
tags:
- DataTable
summary: Create a new data table
description: Create a new data table in your personal project or a team project you have access to.
x-required-scope: dataTable:create
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
minLength: 1
maxLength: 128
description: Name of the data table
example: customers
columns:
type: array
items:
type: object
properties:
name:
type: string
minLength: 1
maxLength: 63
pattern: ^[a-zA-Z][a-zA-Z0-9_]*$
description: Column name
type:
type: string
enum:
- string
- number
- boolean
- date
description: Column data type
csvColumnName:
type: string
description: Name of the CSV column to read the values from. If you do not set it, n8n maps the CSV columns by position.
example: Email Address
required:
- name
- type
description: Column definitions for the table
projectId:
type: string
description: ID of the project to create the table in. When omitted, the table is created in the user's personal project.
example: a1b2c3d4
fileId:
type: string
description: ID of a CSV file that you uploaded in the n8n editor. If you set it, n8n fills the new table with the rows from that file. Only a session-authenticated caller can upload a file, so an API-key caller cannot use this field.
hasHeaders:
type: boolean
description: Set to true if the first row of the CSV file holds the column names. Applies only when you set `fileId`. The default is true.
required:
- name
- columns
responses:
'201':
description: Operation successful.
content:
application/json:
schema:
$ref: ../../../../shared/spec/schemas/dataTablePublicDto.generated.yml
'400':
$ref: ../../../../shared/spec/responses/badRequest.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'409':
$ref: ../../../../shared/spec/responses/conflict.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml

View File

@ -1,82 +0,0 @@
get:
x-eov-operation-id: getDataTable
x-required-scope: dataTable:read
x-eov-operation-handler: v1/handlers/data-tables/data-tables.handler
tags:
- DataTable
summary: Get a data table
description: Retrieve a specific data table by ID.
operationId: get-data-table
parameters:
- $ref: '../schemas/parameters/dataTableId.yml'
responses:
'200':
description: Successfully retrieved data table
content:
application/json:
schema:
$ref: '../schemas/dataTable.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'
security:
- ApiKeyAuth: []
patch:
x-eov-operation-id: updateDataTable
x-required-scope: dataTable:update
x-eov-operation-handler: v1/handlers/data-tables/data-tables.handler
tags:
- DataTable
summary: Update a data table
description: Update a data table's name.
operationId: update-data-table
parameters:
- $ref: '../schemas/parameters/dataTableId.yml'
requestBody:
required: true
content:
application/json:
schema:
$ref: '../schemas/updateDataTableRequest.yml'
example:
name: 'updated-customers'
responses:
'200':
description: Data table updated successfully
content:
application/json:
schema:
$ref: '../schemas/dataTable.yml'
'400':
$ref: '../../../../shared/spec/responses/badRequest.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'
'409':
$ref: '../../../../shared/spec/responses/conflict.yml'
security:
- ApiKeyAuth: []
delete:
x-eov-operation-id: deleteDataTable
x-required-scope: dataTable:delete
x-eov-operation-handler: v1/handlers/data-tables/data-tables.handler
tags:
- DataTable
summary: Delete a data table
description: Delete a data table. This will also delete all rows in the table.
operationId: delete-data-table
parameters:
- $ref: '../schemas/parameters/dataTableId.yml'
responses:
'204':
description: Data table deleted successfully
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'
security:
- ApiKeyAuth: []

View File

@ -1,93 +0,0 @@
get:
x-eov-operation-id: listDataTables
x-required-scope: dataTable:list
x-eov-operation-handler: v1/handlers/data-tables/data-tables.handler
tags:
- DataTable
summary: List all data tables
description: Retrieve a list of all data tables with optional filtering, sorting, and pagination. Each table includes `sizeBytes`, the value the `size` sort option orders by.
operationId: list-data-tables
parameters:
- $ref: '../../../../shared/spec/parameters/limit.yml'
- $ref: '../../../../shared/spec/parameters/cursor.yml'
- name: filter
in: query
description: JSON string of filter conditions
schema:
type: string
format: jsonString
example: '{"name":"my-table"}'
- name: sortBy
in: query
description: 'Sort format: field:asc or field:desc'
schema:
type: string
example: 'name:asc'
responses:
'200':
description: Successfully retrieved data tables
content:
application/json:
schema:
$ref: '../schemas/dataTableList.yml'
'400':
$ref: '../../../../shared/spec/responses/badRequest.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
security:
- ApiKeyAuth: []
post:
x-eov-operation-id: createDataTable
x-required-scope: dataTable:create
x-eov-operation-handler: v1/handlers/data-tables/data-tables.handler
tags:
- DataTable
summary: Create a new data table
description: Create a new data table in your personal project or a team project you have access to.
operationId: create-data-table
requestBody:
required: true
content:
application/json:
schema:
$ref: '../schemas/createDataTableRequest.yml'
examples:
personalProject:
summary: Default (personal project)
value:
name: 'customers'
columns:
- name: 'email'
type: 'string'
- name: 'status'
type: 'string'
- name: 'age'
type: 'number'
scopedProject:
summary: Explicit project
value:
name: 'customers'
projectId: 'a1b2c3d4'
columns:
- name: 'email'
type: 'string'
- name: 'status'
type: 'string'
responses:
'201':
description: Data table created successfully
content:
application/json:
schema:
$ref: '../schemas/dataTable.yml'
'400':
$ref: '../../../../shared/spec/responses/badRequest.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'403':
$ref: '../../../../shared/spec/responses/forbidden.yml'
'409':
$ref: '../../../../shared/spec/responses/conflict.yml'
security:
- ApiKeyAuth: []

View File

@ -0,0 +1,27 @@
operationId: deleteDataTable
tags:
- DataTable
summary: Delete a data table
description: Delete a data table. This will also delete all rows in the table.
x-required-scope: dataTable:delete
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
parameters:
- schema:
type: string
format: nanoid
pattern: ^[A-Za-z0-9]{16}$
required: true
description: The ID of the data table
name: dataTableId
in: path
responses:
'204':
description: Operation successful.
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml

View File

@ -0,0 +1,31 @@
operationId: getDataTable
tags:
- DataTable
summary: Get a data table
description: Retrieve a specific data table by ID.
x-required-scope: dataTable:read
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
parameters:
- schema:
type: string
format: nanoid
pattern: ^[A-Za-z0-9]{16}$
required: true
description: The ID of the data table
name: dataTableId
in: path
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: ../../../../shared/spec/schemas/dataTablePublicDto.generated.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml

View File

@ -0,0 +1,129 @@
operationId: listDataTables
tags:
- DataTable
summary: List all data tables
description: Retrieve a list of all data tables with optional filtering, sorting, and pagination. Each table includes `sizeBytes`, the value the `size` sort option orders by.
x-required-scope: dataTable:list
parameters:
- $ref: ../../../../shared/spec/parameters/limit.yml
- $ref: ../../../../shared/spec/parameters/cursor.yml
- schema:
type: string
format: jsonString
required: false
description: JSON string of filter conditions
example: '{"name":"my-table"}'
name: filter
in: query
- schema:
type: string
enum:
- name:asc
- name:desc
- createdAt:asc
- createdAt:desc
- updatedAt:asc
- updatedAt:desc
- size:asc
- size:desc
required: false
description: 'Sort format: field:asc or field:desc'
example: name:asc
name: sortBy
in: query
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
id:
type: string
description: Unique identifier for the data table
name:
type: string
description: Name of the data table
columns:
type: array
items:
type: object
properties:
id:
type: string
description: Column ID
name:
type: string
description: Column name
type:
type: string
description: Column data type
enum:
- string
- number
- boolean
- date
index:
type: integer
description: Column position
createdAt:
type: string
format: date-time
description: Timestamp when the column was created
updatedAt:
type: string
format: date-time
description: Timestamp when the column was last updated
required:
- id
- name
- type
- index
- createdAt
- updatedAt
description: Column definitions
projectId:
type: string
description: ID of the project this table belongs to
createdAt:
type: string
format: date-time
description: Timestamp when the table was created
updatedAt:
type: string
format: date-time
description: Timestamp when the table was last updated
sizeBytes:
type: integer
description: Physical storage in bytes, including indexes and internal overhead. Not reduced immediately by row deletion, and may be a few seconds stale.
required:
- id
- name
- columns
- projectId
- createdAt
- updatedAt
- sizeBytes
nextCursor:
type: string
nullable: true
description: Paginate through data tables by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection.
example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA
required:
- data
- nextCursor
'400':
$ref: ../../../../shared/spec/responses/badRequest.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml

View File

@ -0,0 +1,52 @@
operationId: updateDataTable
tags:
- DataTable
summary: Update a data table
description: Update a data table's name.
x-required-scope: dataTable:update
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
parameters:
- schema:
type: string
format: nanoid
pattern: ^[A-Za-z0-9]{16}$
required: true
description: The ID of the data table
name: dataTableId
in: path
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
minLength: 1
maxLength: 128
description: New name for the data table
example: updated-customers
required:
- name
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: ../../../../shared/spec/schemas/dataTablePublicDto.generated.yml
'400':
$ref: ../../../../shared/spec/responses/badRequest.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
'409':
$ref: ../../../../shared/spec/responses/conflict.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml

View File

@ -1,31 +0,0 @@
type: object
properties:
name:
type: string
description: Name of the data table
minLength: 1
maxLength: 128
columns:
type: array
description: Column definitions for the table
items:
type: object
properties:
name:
type: string
description: Column name
minLength: 1
type:
type: string
enum: [string, number, boolean, date, json]
description: Column data type
required:
- name
- type
projectId:
type: string
description: |
ID of the project to create the table in. When omitted, the table is created in the user's personal project.
required:
- name
- columns

View File

@ -1,11 +0,0 @@
type: object
properties:
data:
type: array
items:
$ref: './dataTable.yml'
nextCursor:
type: string
description: Paginate through data tables by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection.
nullable: true
example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA

View File

@ -1,9 +0,0 @@
type: object
properties:
name:
type: string
description: New name for the data table
minLength: 1
maxLength: 128
required:
- name

View File

@ -52,6 +52,18 @@ paths:
/credentials/schema/{credentialTypeName}:
get:
$ref: ./handlers/credentials/spec/paths/getCredentialType.generated.yml
/data-tables:
get:
$ref: ./handlers/data-tables/spec/paths/listDataTables.generated.yml
post:
$ref: ./handlers/data-tables/spec/paths/createDataTable.generated.yml
/data-tables/{dataTableId}:
get:
$ref: ./handlers/data-tables/spec/paths/getDataTable.generated.yml
patch:
$ref: ./handlers/data-tables/spec/paths/updateDataTable.generated.yml
delete:
$ref: ./handlers/data-tables/spec/paths/deleteDataTable.generated.yml
/discover:
get:
$ref: ./handlers/discover/spec/paths/getDiscover.generated.yml

View File

@ -97,10 +97,6 @@ paths:
$ref: './handlers/workflows/spec/paths/workflows.id.versionId.yml'
/workflows/{id}/test-runs/{runId}/cancel:
$ref: './handlers/evaluations/spec/paths/workflows.id.test-runs.runId.cancel.yml'
/data-tables:
$ref: './handlers/data-tables/spec/paths/data-tables.yml'
/data-tables/{dataTableId}:
$ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.yml'
/data-tables/{dataTableId}/rows:
$ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.rows.yml'
/data-tables/{dataTableId}/rows/update:

View File

@ -8,7 +8,6 @@ properties:
description: Name of the data table
columns:
type: array
description: Column definitions
items:
type: object
properties:
@ -20,11 +19,31 @@ properties:
description: Column name
type:
type: string
enum: [string, number, boolean, date]
description: Column data type
enum:
- string
- number
- boolean
- date
index:
type: integer
description: Column position
createdAt:
type: string
format: date-time
description: Timestamp when the column was created
updatedAt:
type: string
format: date-time
description: Timestamp when the column was last updated
required:
- id
- name
- type
- index
- createdAt
- updatedAt
description: Column definitions
projectId:
type: string
description: ID of the project this table belongs to

View File

@ -75,11 +75,39 @@ const testWithAPIKey =
expect(response.statusCode).toBe(401);
};
// The API key scope check runs before RBAC, so a key scoped to an unrelated resource is rejected
// even for an instance owner.
const createUnscopedAgent = async () => {
const unscopedOwner = await createOwnerWithApiKey({ scopes: ['tag:list'] });
return testServer.publicApiAgentFor(unscopedOwner);
};
describe('GET /data-tables', () => {
test('should fail due to missing API Key', testWithAPIKey('get', '/data-tables', null));
test('should fail due to invalid API Key', testWithAPIKey('get', '/data-tables', 'abcXYZ'));
test('should reject listing without dataTable:list', async () => {
const unscopedAgent = await createUnscopedAgent();
const response = await unscopedAgent.get('/data-tables');
expect(response.statusCode).toBe(403);
});
test('should reject an undecodable cursor', async () => {
const response = await authOwnerAgent.get('/data-tables').query({ cursor: 'not-a-cursor' });
expect(response.statusCode).toBe(400);
expect(response.body).toHaveProperty('message', 'An invalid cursor was provided');
});
test('should reject a filter that is not valid JSON', async () => {
const response = await authOwnerAgent.get('/data-tables').query({ filter: '{not json' });
expect(response.statusCode).toBe(400);
expect(response.body).toHaveProperty('message');
});
test('should list data tables', async () => {
await createDataTable(ownerPersonalProject, {
name: 'table1',
@ -93,16 +121,48 @@ describe('GET /data-tables', () => {
const response = await authOwnerAgent.get('/data-tables');
expect(response.statusCode).toBe(200);
expect(response.body).toHaveProperty('data');
expect(response.body).toHaveProperty('nextCursor');
expect(response.body.data).toHaveLength(2);
expect(response.body.nextCursor).toBeNull();
expect(response.body.data[0]).toHaveProperty('id');
expect(response.body.data[0]).toHaveProperty('name');
expect(response.body.data[0]).toHaveProperty('columns');
for (const dataTable of response.body.data) {
expect(typeof dataTable.sizeBytes).toBe('number');
}
// Default order is updatedAt DESC, so the most recently created table comes first.
expect(response.body).toStrictEqual({
data: [
{
id: expect.any(String),
name: 'table2',
columns: [
{
id: expect.any(String),
name: 'age',
type: 'number',
index: expect.any(Number),
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
],
projectId: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
sizeBytes: expect.any(Number),
},
{
id: expect.any(String),
name: 'table1',
columns: [
{
id: expect.any(String),
name: 'name',
type: 'string',
index: expect.any(Number),
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
],
projectId: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
sizeBytes: expect.any(Number),
},
],
nextCursor: null,
});
});
test('should paginate data tables', async () => {
@ -348,6 +408,24 @@ describe('POST /data-tables', () => {
test('should fail due to invalid API Key', testWithAPIKey('post', '/data-tables', 'abcXYZ'));
test('should reject creating without dataTable:create', async () => {
const unscopedAgent = await createUnscopedAgent();
const response = await unscopedAgent
.post('/data-tables')
.send({ name: 'blocked', columns: [{ name: 'col1', type: 'string' }] });
expect(response.statusCode).toBe(403);
});
test('should reject a create body with no name', async () => {
const response = await authOwnerAgent
.post('/data-tables')
.send({ columns: [{ name: 'col1', type: 'string' }] });
expect(response.statusCode).toBe(400);
expect(response.body.message).toContain("request/body must have required property 'name'");
});
test('should create a data table', async () => {
const response = await authOwnerAgent.post('/data-tables').send({
name: 'my-table',
@ -358,12 +436,34 @@ describe('POST /data-tables', () => {
});
expect(response.statusCode).toBe(201);
expect(response.body).toHaveProperty('id');
expect(response.body).toHaveProperty('name', 'my-table');
expect(response.body).toHaveProperty('columns');
expect(response.body.columns).toHaveLength(2);
expect(response.body).toHaveProperty('projectId', ownerPersonalProject.id);
expect(typeof response.body.sizeBytes).toBe('number');
// Columns aren't guaranteed to come back in index order, so sort before comparing.
const columns = [...response.body.columns].sort((a, b) => a.index - b.index);
expect({ ...response.body, columns }).toStrictEqual({
id: expect.any(String),
name: 'my-table',
columns: [
{
id: expect.any(String),
name: 'email',
type: 'string',
index: 0,
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
{
id: expect.any(String),
name: 'age',
type: 'number',
index: 1,
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
],
projectId: ownerPersonalProject.id,
createdAt: expect.any(String),
updatedAt: expect.any(String),
sizeBytes: expect.any(Number),
});
});
test('should fail with duplicate name', async () => {
@ -415,6 +515,40 @@ describe('POST /data-tables', () => {
expect(response.statusCode).toBe(400);
expect(response.body).toHaveProperty('message');
});
test('forwards fileId to the import', async () => {
const response = await authOwnerAgent.post('/data-tables').send({
name: 'csv-table',
columns: [{ name: 'email', type: 'string', csvColumnName: 'Email Address' }],
fileId: 'no-such-file',
hasHeaders: true,
});
expect(response.statusCode).toBe(400);
expect(response.body.message).toContain('Error uploading file');
});
test('does not keep the table when the import fails', async () => {
await authOwnerAgent.post('/data-tables').send({
name: 'csv-table',
columns: [{ name: 'email', type: 'string' }],
fileId: 'no-such-file',
});
const response = await authOwnerAgent.get('/data-tables');
expect(response.body.data).toHaveLength(0);
});
test('accepts csvColumnName on a create that has no fileId', async () => {
const response = await authOwnerAgent.post('/data-tables').send({
name: 'plain-table',
columns: [{ name: 'email', type: 'string', csvColumnName: 'Email Address' }],
});
expect(response.statusCode).toBe(201);
expect(response.body.columns).toHaveLength(1);
});
});
describe('GET /data-tables/:dataTableId', () => {
@ -433,6 +567,25 @@ describe('GET /data-tables/:dataTableId', () => {
);
});
test('should reject a malformed data table id', async () => {
const response = await authOwnerAgent.get('/data-tables/not-a-nanoid');
expect(response.statusCode).toBe(400);
expect(response.body).toHaveProperty('message');
});
test('should reject reading without dataTable:read', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
name: 'scope-table',
columns: [{ name: 'col1', type: 'string' }],
});
const unscopedAgent = await createUnscopedAgent();
const response = await unscopedAgent.get(`/data-tables/${dataTable.id}`);
expect(response.statusCode).toBe(403);
});
test('should get a data table', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
name: 'test-table',
@ -445,11 +598,34 @@ describe('GET /data-tables/:dataTableId', () => {
const response = await authOwnerAgent.get(`/data-tables/${dataTable.id}`);
expect(response.statusCode).toBe(200);
expect(response.body).toHaveProperty('id', dataTable.id);
expect(response.body).toHaveProperty('name', 'test-table');
expect(response.body).toHaveProperty('columns');
expect(response.body.columns).toHaveLength(2);
expect(typeof response.body.sizeBytes).toBe('number');
// Columns aren't guaranteed to come back in index order, so sort before comparing.
const columns = [...response.body.columns].sort((a, b) => a.index - b.index);
expect({ ...response.body, columns }).toStrictEqual({
id: dataTable.id,
name: 'test-table',
columns: [
{
id: expect.any(String),
name: 'name',
type: 'string',
index: 0,
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
{
id: expect.any(String),
name: 'age',
type: 'number',
index: 1,
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
],
projectId: ownerPersonalProject.id,
createdAt: expect.any(String),
updatedAt: expect.any(String),
sizeBytes: expect.any(Number),
});
});
test('should return 403 when user does not have access to the data table', async () => {
@ -496,6 +672,41 @@ describe('PATCH /data-tables/:dataTableId', () => {
);
});
test('should reject a malformed data table id', async () => {
const response = await authOwnerAgent
.patch('/data-tables/not-a-nanoid')
.send({ name: 'whatever' });
expect(response.statusCode).toBe(400);
expect(response.body).toHaveProperty('message');
});
test('should reject an update body with an empty name', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
name: 'validation-table',
columns: [{ name: 'col1', type: 'string' }],
});
const response = await authOwnerAgent.patch(`/data-tables/${dataTable.id}`).send({ name: '' });
expect(response.statusCode).toBe(400);
expect(response.body).toHaveProperty('message');
});
test('should reject updating without dataTable:update', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
name: 'scope-table',
columns: [{ name: 'col1', type: 'string' }],
});
const unscopedAgent = await createUnscopedAgent();
const response = await unscopedAgent
.patch(`/data-tables/${dataTable.id}`)
.send({ name: 'blocked' });
expect(response.statusCode).toBe(403);
});
test('should update a data table name', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
name: 'old-name',
@ -507,9 +718,24 @@ describe('PATCH /data-tables/:dataTableId', () => {
});
expect(response.statusCode).toBe(200);
expect(response.body).toHaveProperty('id', dataTable.id);
expect(response.body).toHaveProperty('name', 'new-name');
expect(typeof response.body.sizeBytes).toBe('number');
expect(response.body).toStrictEqual({
id: dataTable.id,
name: 'new-name',
columns: [
{
id: expect.any(String),
name: 'col1',
type: 'string',
index: expect.any(Number),
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
],
projectId: ownerPersonalProject.id,
createdAt: expect.any(String),
updatedAt: expect.any(String),
sizeBytes: expect.any(Number),
});
});
test('should fail with duplicate name', async () => {
@ -564,6 +790,25 @@ describe('DELETE /data-tables/:dataTableId', () => {
);
});
test('should reject a malformed data table id', async () => {
const response = await authOwnerAgent.delete('/data-tables/not-a-nanoid');
expect(response.statusCode).toBe(400);
expect(response.body).toHaveProperty('message');
});
test('should reject deleting without dataTable:delete', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
name: 'scope-table',
columns: [{ name: 'col1', type: 'string' }],
});
const unscopedAgent = await createUnscopedAgent();
const response = await unscopedAgent.delete(`/data-tables/${dataTable.id}`);
expect(response.statusCode).toBe(403);
});
test('should delete a data table', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
name: 'table-to-delete',