feat(core): Add Data Store Backend API (no-changelog) (#17824)

This commit is contained in:
Charlie Kolb
2025-08-12 14:54:24 +02:00
committed by GitHub
parent d06581ef3f
commit 98dc71e6a7
48 changed files with 3491 additions and 30 deletions

View File

@@ -0,0 +1,5 @@
import { Z } from 'zod-class';
import { dataStoreCreateColumnSchema } from '../../schemas/data-store.schema';
export class AddDataStoreColumnDto extends Z.class(dataStoreCreateColumnSchema.shape) {}

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
import { Z } from 'zod-class';
import { dataStoreColumnNameSchema } from '../../schemas/data-store.schema';
export class AddDataStoreRowsDto extends Z.class({
data: z.array(z.record(dataStoreColumnNameSchema, z.any())),
}) {}

View File

@@ -0,0 +1,11 @@
import { Z } from 'zod-class';
import {
dataStoreColumnNameSchema,
dataStoreColumnTypeSchema,
} from '../../schemas/data-store.schema';
export class CreateDataStoreColumnDto extends Z.class({
name: dataStoreColumnNameSchema,
type: dataStoreColumnTypeSchema,
}) {}

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
import { Z } from 'zod-class';
import { CreateDataStoreColumnDto } from './create-data-store-column.dto';
import { dataStoreNameSchema } from '../../schemas/data-store.schema';
export class CreateDataStoreDto extends Z.class({
name: dataStoreNameSchema,
columns: z.array(CreateDataStoreColumnDto),
}) {}

View File

@@ -0,0 +1,105 @@
import { jsonParse } from 'n8n-workflow';
import { z } from 'zod';
import { Z } from 'zod-class';
import { dataStoreColumnNameSchema } from '../../schemas/data-store.schema';
import { paginationSchema } from '../pagination/pagination.dto';
const FilterConditionSchema = z.union([z.literal('eq'), z.literal('neq')]);
export type ListDataStoreContentFilterConditionType = z.infer<typeof FilterConditionSchema>;
const filterRecord = z.object({
columnName: dataStoreColumnNameSchema,
condition: FilterConditionSchema.default('eq'),
value: z.union([z.string(), z.number(), z.boolean(), z.date()]),
});
const chainedFilterSchema = z.union([z.literal('and'), z.literal('or')]);
export type ListDataStoreContentFilter = z.infer<typeof filterSchema>;
// ---------------------
// Parameter Validators
// ---------------------
const filterSchema = z.object({
type: chainedFilterSchema.default('and'),
filters: z.array(filterRecord).default([]),
});
// Filter parameter validation
const filterValidator = z
.string()
.optional()
.transform((val, ctx) => {
if (!val) return undefined;
try {
const parsed: unknown = jsonParse(val);
try {
return filterSchema.parse(parsed);
} catch (e) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid filter fields',
path: ['filter'],
});
return z.NEVER;
}
} catch (e) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid filter format',
path: ['filter'],
});
return z.NEVER;
}
});
// SortBy parameter validation
const sortByValidator = z
.string()
.optional()
.transform((val, ctx) => {
if (val === undefined) return val;
if (!val.includes(':')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid sort format, expected <columnName>:<asc/desc>',
path: ['sort'],
});
return z.NEVER;
}
let [column, direction] = val.split(':');
try {
column = dataStoreColumnNameSchema.parse(column);
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid sort columnName',
path: ['sort'],
});
return z.NEVER;
}
direction = direction?.toUpperCase();
if (direction !== 'ASC' && direction !== 'DESC') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid sort direction',
path: ['sort'],
});
return z.NEVER;
}
return [column, direction] as const;
});
export class ListDataStoreContentQueryDto extends Z.class({
take: paginationSchema.take.optional(),
skip: paginationSchema.skip.optional(),
filter: filterValidator.optional(),
sortBy: sortByValidator.optional(),
}) {}

View File

@@ -0,0 +1,70 @@
import { jsonParse } from 'n8n-workflow';
import { z } from 'zod';
import { Z } from 'zod-class';
import { paginationSchema } from '../pagination/pagination.dto';
const VALID_SORT_OPTIONS = [
'name:asc',
'name:desc',
'createdAt:asc',
'createdAt:desc',
'updatedAt:asc',
'updatedAt:desc',
'sizeBytes:asc',
'sizeBytes:desc',
] as const;
export type ListDataStoreQuerySortOptions = (typeof VALID_SORT_OPTIONS)[number];
const FILTER_OPTIONS = {
id: z.union([z.string(), z.array(z.string())]).optional(),
name: z.union([z.string(), z.array(z.string())]).optional(),
projectId: z.union([z.string(), z.array(z.string())]).optional(),
// todo: can probably include others here as well?
};
// Filter schema - only allow specific properties
const filterSchema = z.object(FILTER_OPTIONS).strict();
// ---------------------
// Parameter Validators
// ---------------------
// Filter parameter validation
const filterValidator = z
.string()
.optional()
.transform((val, ctx) => {
if (!val) return undefined;
try {
const parsed: unknown = jsonParse(val);
try {
return filterSchema.parse(parsed);
} catch (e) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid filter fields',
path: ['filter'],
});
return z.NEVER;
}
} catch (e) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid filter format',
path: ['filter'],
});
return z.NEVER;
}
});
// SortBy parameter validation
const sortByValidator = z
.enum(VALID_SORT_OPTIONS, { message: `sortBy must be one of: ${VALID_SORT_OPTIONS.join(', ')}` })
.optional();
export class ListDataStoreQueryDto extends Z.class({
...paginationSchema,
filter: filterValidator,
sortBy: sortByValidator,
}) {}

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class MoveDataStoreColumnDto extends Z.class({
targetIndex: z.number(),
}) {}

View File

@@ -0,0 +1,7 @@
import { Z } from 'zod-class';
import { dataStoreNameSchema } from '../../schemas/data-store.schema';
export class UpdateDataStoreDto extends Z.class({
name: dataStoreNameSchema,
}) {}

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
import { Z } from 'zod-class';
import { dataStoreColumnNameSchema } from '../../schemas/data-store.schema';
const dataStoreValueSchema = z.union([z.string(), z.number(), z.boolean(), z.date(), z.null()]);
const upsertDataStoreRowsShape = {
rows: z.array(z.record(dataStoreValueSchema)),
matchFields: z.array(dataStoreColumnNameSchema).min(1),
};
export class UpsertDataStoreRowsDto extends Z.class(upsertDataStoreRowsShape) {}

View File

@@ -79,3 +79,16 @@ export {
} from './user/users-list-filter.dto';
export { OidcConfigDto } from './oidc/config.dto';
export { CreateDataStoreDto } from './data-store/create-data-store.dto';
export { UpdateDataStoreDto } from './data-store/update-data-store.dto';
export { UpsertDataStoreRowsDto } from './data-store/upsert-data-store-rows.dto';
export { ListDataStoreQueryDto } from './data-store/list-data-store-query.dto';
export {
ListDataStoreContentQueryDto,
ListDataStoreContentFilter,
} from './data-store/list-data-store-content-query.dto';
export { CreateDataStoreColumnDto } from './data-store/create-data-store-column.dto';
export { AddDataStoreRowsDto } from './data-store/add-data-store-rows.dto';
export { AddDataStoreColumnDto } from './data-store/add-data-store-column.dto';
export { MoveDataStoreColumnDto } from './data-store/move-data-store-column.dto';

View File

@@ -45,3 +45,15 @@ export {
type UsersList,
usersListSchema,
} from './schemas/user.schema';
export {
DATA_STORE_COLUMN_REGEX,
type DataStore,
type DataStoreColumn,
type DataStoreCreateColumnSchema,
type DataStoreColumnJsType,
type DataStoreListFilter,
type DataStoreRows,
type DataStoreListOptions,
type DataStoreUserTableName,
} from './schemas/data-store.schema';

View File

@@ -0,0 +1,56 @@
import { z } from 'zod';
import type { ListDataStoreQueryDto } from '../dto';
export const dataStoreNameSchema = z.string().trim().min(1).max(128);
export const dataStoreIdSchema = z.string().max(36);
export const DATA_STORE_COLUMN_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/;
export const dataStoreColumnNameSchema = z
.string()
.trim()
.min(1)
.max(128)
.regex(
DATA_STORE_COLUMN_REGEX,
'Only alphanumeric characters and non-leading dashes are allowed for column names',
);
export const dataStoreColumnTypeSchema = z.enum(['string', 'number', 'boolean', 'date']);
export const dataStoreCreateColumnSchema = z.object({
name: dataStoreColumnNameSchema,
type: dataStoreColumnTypeSchema,
index: z.number().optional(),
});
export type DataStoreCreateColumnSchema = z.infer<typeof dataStoreCreateColumnSchema>;
export const dataStoreColumnSchema = dataStoreCreateColumnSchema.extend({
dataStoreId: dataStoreIdSchema,
});
export const dataStoreSchema = z.object({
id: dataStoreIdSchema,
name: dataStoreNameSchema,
columns: z.array(dataStoreColumnSchema),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export type DataStore = z.infer<typeof dataStoreSchema>;
export type DataStoreColumn = z.infer<typeof dataStoreColumnSchema>;
export type DataStoreUserTableName = `data_store_user_${string}`;
export type DataStoreListFilter = {
id?: string | string[];
projectId?: string | string[];
name?: string;
};
export type DataStoreListOptions = Partial<ListDataStoreQueryDto> & {
filter: { projectId: string };
};
export type DataStoreColumnJsType = string | number | boolean | Date;
export type DataStoreRows = Array<Record<string, DataStoreColumnJsType | null>>;