feat: bases - WIP

This commit is contained in:
Philipinho
2026-03-08 00:56:24 +00:00
parent 0aeaa43112
commit 94ee1e80fb
83 changed files with 9243 additions and 38 deletions
+2 -1
View File
@@ -106,7 +106,8 @@
"tseep": "^1.3.1",
"typesense": "^2.1.0",
"ws": "^8.19.0",
"yauzl": "^3.2.0"
"yauzl": "^3.2.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "^9.20.0",
+21
View File
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { BaseController } from './controllers/base.controller';
import { BasePropertyController } from './controllers/base-property.controller';
import { BaseRowController } from './controllers/base-row.controller';
import { BaseViewController } from './controllers/base-view.controller';
import { BaseService } from './services/base.service';
import { BasePropertyService } from './services/base-property.service';
import { BaseRowService } from './services/base-row.service';
import { BaseViewService } from './services/base-view.service';
@Module({
controllers: [
BaseController,
BasePropertyController,
BaseRowController,
BaseViewController,
],
providers: [BaseService, BasePropertyService, BaseRowService, BaseViewService],
exports: [BaseService, BasePropertyService, BaseRowService, BaseViewService],
})
export class BaseModule {}
+270
View File
@@ -0,0 +1,270 @@
import { z } from 'zod';
export const BasePropertyType = {
TEXT: 'text',
NUMBER: 'number',
SELECT: 'select',
STATUS: 'status',
MULTI_SELECT: 'multiSelect',
DATE: 'date',
PERSON: 'person',
FILE: 'file',
CHECKBOX: 'checkbox',
URL: 'url',
EMAIL: 'email',
CREATED_AT: 'createdAt',
LAST_EDITED_AT: 'lastEditedAt',
LAST_EDITED_BY: 'lastEditedBy',
} as const;
const SYSTEM_PROPERTY_TYPES: Set<string> = new Set([
BasePropertyType.CREATED_AT,
BasePropertyType.LAST_EDITED_AT,
BasePropertyType.LAST_EDITED_BY,
]);
export function isSystemPropertyType(type: string): boolean {
return SYSTEM_PROPERTY_TYPES.has(type);
}
export type BasePropertyTypeValue =
(typeof BasePropertyType)[keyof typeof BasePropertyType];
export const BASE_PROPERTY_TYPES = Object.values(BasePropertyType);
export const choiceSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
color: z.string(),
category: z.enum(['todo', 'inProgress', 'complete']).optional(),
});
export const selectTypeOptionsSchema = z
.object({
choices: z.array(choiceSchema).default([]),
choiceOrder: z.array(z.string().uuid()).default([]),
disableColors: z.boolean().optional(),
defaultValue: z
.union([z.string().uuid(), z.array(z.string().uuid())])
.nullable()
.optional(),
})
.passthrough();
export const numberTypeOptionsSchema = z
.object({
format: z
.enum(['plain', 'currency', 'percent', 'progress'])
.optional()
.default('plain'),
precision: z.number().int().min(0).max(10).optional(),
currencySymbol: z.string().max(5).optional(),
defaultValue: z.number().nullable().optional(),
})
.passthrough();
export const dateTypeOptionsSchema = z
.object({
dateFormat: z.string().optional(),
timeFormat: z.enum(['12h', '24h']).optional(),
includeTime: z.boolean().optional(),
defaultValue: z.string().nullable().optional(),
})
.passthrough();
export const textTypeOptionsSchema = z
.object({
richText: z.boolean().optional(),
defaultValue: z.string().nullable().optional(),
})
.passthrough();
export const checkboxTypeOptionsSchema = z
.object({
defaultValue: z.boolean().optional(),
})
.passthrough();
export const urlTypeOptionsSchema = z
.object({
defaultValue: z.string().nullable().optional(),
})
.passthrough();
export const emailTypeOptionsSchema = z
.object({
defaultValue: z.string().nullable().optional(),
})
.passthrough();
export const emptyTypeOptionsSchema = z.object({}).passthrough();
const typeOptionsSchemaMap: Record<BasePropertyTypeValue, z.ZodType> = {
[BasePropertyType.TEXT]: textTypeOptionsSchema,
[BasePropertyType.NUMBER]: numberTypeOptionsSchema,
[BasePropertyType.SELECT]: selectTypeOptionsSchema,
[BasePropertyType.STATUS]: selectTypeOptionsSchema,
[BasePropertyType.MULTI_SELECT]: selectTypeOptionsSchema,
[BasePropertyType.DATE]: dateTypeOptionsSchema,
[BasePropertyType.PERSON]: emptyTypeOptionsSchema,
[BasePropertyType.FILE]: emptyTypeOptionsSchema,
[BasePropertyType.CHECKBOX]: checkboxTypeOptionsSchema,
[BasePropertyType.URL]: urlTypeOptionsSchema,
[BasePropertyType.EMAIL]: emailTypeOptionsSchema,
[BasePropertyType.CREATED_AT]: emptyTypeOptionsSchema,
[BasePropertyType.LAST_EDITED_AT]: emptyTypeOptionsSchema,
[BasePropertyType.LAST_EDITED_BY]: emptyTypeOptionsSchema,
};
export function validateTypeOptions(
type: BasePropertyTypeValue,
typeOptions: unknown,
): z.SafeParseReturnType<unknown, unknown> {
const schema = typeOptionsSchemaMap[type];
if (!schema) {
return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: ['type'] }]) } as z.SafeParseError<unknown>;
}
return schema.safeParse(typeOptions ?? {});
}
export function parseTypeOptions(
type: BasePropertyTypeValue,
typeOptions: unknown,
): unknown {
const result = validateTypeOptions(type, typeOptions);
if (!result.success) {
throw result.error;
}
return result.data;
}
const cellValueSchemaMap: Partial<Record<BasePropertyTypeValue, z.ZodType>> = {
[BasePropertyType.TEXT]: z.string(),
[BasePropertyType.NUMBER]: z.number(),
[BasePropertyType.SELECT]: z.string().uuid(),
[BasePropertyType.STATUS]: z.string().uuid(),
[BasePropertyType.MULTI_SELECT]: z.array(z.string().uuid()),
[BasePropertyType.DATE]: z.string(),
[BasePropertyType.PERSON]: z.array(z.string().uuid()),
[BasePropertyType.FILE]: z.array(z.string().uuid()),
[BasePropertyType.CHECKBOX]: z.boolean(),
[BasePropertyType.URL]: z.string().url(),
[BasePropertyType.EMAIL]: z.string().email(),
};
export function getCellValueSchema(
type: BasePropertyTypeValue,
): z.ZodType | undefined {
return cellValueSchemaMap[type];
}
export function validateCellValue(
type: BasePropertyTypeValue,
value: unknown,
): z.SafeParseReturnType<unknown, unknown> {
const schema = cellValueSchemaMap[type];
if (!schema) {
return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: [] }]) } as z.SafeParseError<unknown>;
}
return schema.safeParse(value);
}
export function attemptCellConversion(
fromType: BasePropertyTypeValue,
toType: BasePropertyTypeValue,
value: unknown,
): { converted: boolean; value: unknown } {
if (value === null || value === undefined) {
return { converted: true, value: null };
}
const targetSchema = cellValueSchemaMap[toType];
if (!targetSchema) {
return { converted: false, value: null };
}
const directResult = targetSchema.safeParse(value);
if (directResult.success) {
return { converted: true, value: directResult.data };
}
if (toType === BasePropertyType.TEXT) {
return { converted: true, value: String(value) };
}
if (toType === BasePropertyType.NUMBER && typeof value === 'string') {
const num = Number(value);
if (!isNaN(num)) {
return { converted: true, value: num };
}
}
if (toType === BasePropertyType.CHECKBOX) {
if (typeof value === 'string') {
const lower = value.toLowerCase();
if (lower === 'true' || lower === '1' || lower === 'yes') {
return { converted: true, value: true };
}
if (lower === 'false' || lower === '0' || lower === 'no' || lower === '') {
return { converted: true, value: false };
}
}
if (typeof value === 'number') {
return { converted: true, value: value !== 0 };
}
}
if (
toType === BasePropertyType.MULTI_SELECT &&
fromType === BasePropertyType.SELECT &&
typeof value === 'string'
) {
return { converted: true, value: [value] };
}
if (
toType === BasePropertyType.SELECT &&
fromType === BasePropertyType.MULTI_SELECT &&
Array.isArray(value) &&
value.length > 0
) {
return { converted: true, value: value[0] };
}
return { converted: false, value: null };
}
export const viewSortSchema = z.object({
propertyId: z.string().uuid(),
direction: z.enum(['asc', 'desc']),
});
export const viewFilterSchema = z.object({
propertyId: z.string().uuid(),
operator: z.enum([
'equals',
'notEquals',
'contains',
'notContains',
'isEmpty',
'isNotEmpty',
'greaterThan',
'lessThan',
'before',
'after',
]),
value: z.unknown().optional(),
});
export const viewConfigSchema = z
.object({
sorts: z.array(viewSortSchema).optional(),
filters: z.array(viewFilterSchema).optional(),
visiblePropertyIds: z.array(z.string().uuid()).optional(),
hiddenPropertyIds: z.array(z.string().uuid()).optional(),
propertyWidths: z.record(z.string(), z.number().positive()).optional(),
propertyOrder: z.array(z.string().uuid()).optional(),
})
.passthrough();
export type ViewConfig = z.infer<typeof viewConfigSchema>;
@@ -0,0 +1,105 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { BasePropertyService } from '../services/base-property.service';
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
import { CreatePropertyDto } from '../dto/create-property.dto';
import {
UpdatePropertyDto,
DeletePropertyDto,
ReorderPropertyDto,
} from '../dto/update-property.dto';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
@UseGuards(JwtAuthGuard)
@Controller('bases/properties')
export class BasePropertyController {
constructor(
private readonly basePropertyService: BasePropertyService,
private readonly baseRepo: BaseRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@HttpCode(HttpStatus.OK)
@Post('create')
async create(
@Body() dto: CreatePropertyDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.basePropertyService.create(workspace.id, dto);
}
@HttpCode(HttpStatus.OK)
@Post('update')
async update(@Body() dto: UpdatePropertyDto, @AuthUser() user: User) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.basePropertyService.update(dto);
}
@HttpCode(HttpStatus.OK)
@Post('delete')
async delete(@Body() dto: DeletePropertyDto, @AuthUser() user: User) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
await this.basePropertyService.delete(dto);
}
@HttpCode(HttpStatus.OK)
@Post('reorder')
async reorder(@Body() dto: ReorderPropertyDto, @AuthUser() user: User) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
await this.basePropertyService.reorder(dto);
}
}
@@ -0,0 +1,144 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { BaseRowService } from '../services/base-row.service';
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
import { CreateRowDto } from '../dto/create-row.dto';
import {
UpdateRowDto,
DeleteRowDto,
RowIdDto,
ListRowsDto,
ReorderRowDto,
} from '../dto/update-row.dto';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { User, Workspace } from '@docmost/db/types/entity.types';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
@UseGuards(JwtAuthGuard)
@Controller('bases/rows')
export class BaseRowController {
constructor(
private readonly baseRowService: BaseRowService,
private readonly baseRepo: BaseRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@HttpCode(HttpStatus.OK)
@Post('create')
async create(
@Body() dto: CreateRowDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseRowService.create(user.id, workspace.id, dto);
}
@HttpCode(HttpStatus.OK)
@Post('info')
async getRow(@Body() dto: RowIdDto, @AuthUser() user: User) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseRowService.getRowInfo(dto.rowId, dto.baseId);
}
@HttpCode(HttpStatus.OK)
@Post('update')
async update(@Body() dto: UpdateRowDto, @AuthUser() user: User) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseRowService.update(dto, user.id);
}
@HttpCode(HttpStatus.OK)
@Post('delete')
async delete(@Body() dto: DeleteRowDto, @AuthUser() user: User) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
await this.baseRowService.delete(dto.rowId, dto.baseId);
}
@HttpCode(HttpStatus.OK)
@Post('list')
async list(
@Body() dto: ListRowsDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseRowService.list(dto, pagination);
}
@HttpCode(HttpStatus.OK)
@Post('reorder')
async reorder(@Body() dto: ReorderRowDto, @AuthUser() user: User) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
await this.baseRowService.reorder(dto);
}
}
@@ -0,0 +1,102 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { BaseViewService } from '../services/base-view.service';
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
import { CreateViewDto } from '../dto/create-view.dto';
import { UpdateViewDto, DeleteViewDto } from '../dto/update-view.dto';
import { BaseIdDto } from '../dto/base.dto';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
@UseGuards(JwtAuthGuard)
@Controller('bases/views')
export class BaseViewController {
constructor(
private readonly baseViewService: BaseViewService,
private readonly baseRepo: BaseRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@HttpCode(HttpStatus.OK)
@Post('create')
async create(
@Body() dto: CreateViewDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseViewService.create(user.id, workspace.id, dto);
}
@HttpCode(HttpStatus.OK)
@Post('update')
async update(@Body() dto: UpdateViewDto, @AuthUser() user: User) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseViewService.update(dto);
}
@HttpCode(HttpStatus.OK)
@Post('delete')
async delete(@Body() dto: DeleteViewDto, @AuthUser() user: User) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
await this.baseViewService.delete(dto);
}
@HttpCode(HttpStatus.OK)
@Post('list')
async list(@Body() dto: BaseIdDto, @AuthUser() user: User) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseViewService.listByBaseId(dto.baseId);
}
}
@@ -0,0 +1,111 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { BaseService } from '../services/base.service';
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
import { CreateBaseDto } from '../dto/create-base.dto';
import { UpdateBaseDto } from '../dto/update-base.dto';
import { BaseIdDto } from '../dto/base.dto';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { User, Workspace } from '@docmost/db/types/entity.types';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
import { SpaceIdDto } from '../../space/dto/space-id.dto';
@UseGuards(JwtAuthGuard)
@Controller('bases')
export class BaseController {
constructor(
private readonly baseService: BaseService,
private readonly baseRepo: BaseRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@HttpCode(HttpStatus.OK)
@Post('create')
async create(
@Body() dto: CreateBaseDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseService.create(user.id, workspace.id, dto);
}
@HttpCode(HttpStatus.OK)
@Post('info')
async getBase(@Body() dto: BaseIdDto, @AuthUser() user: User) {
const base = await this.baseService.getBaseInfo(dto.baseId);
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return base;
}
@HttpCode(HttpStatus.OK)
@Post('update')
async update(@Body() dto: UpdateBaseDto, @AuthUser() user: User) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseService.update(dto);
}
@HttpCode(HttpStatus.OK)
@Post('delete')
async delete(@Body() dto: BaseIdDto, @AuthUser() user: User) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
await this.baseService.delete(dto.baseId);
}
@HttpCode(HttpStatus.OK)
@Post('list')
async list(
@Body() dto: SpaceIdDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
) {
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseService.listBySpaceId(dto.spaceId, pagination);
}
}
@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class BaseIdDto {
@IsUUID()
baseId: string;
}
@@ -0,0 +1,22 @@
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateBaseDto {
@IsString()
@IsNotEmpty()
name: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
icon?: string;
@IsOptional()
@IsUUID()
pageId?: string;
@IsUUID()
spaceId: string;
}
@@ -0,0 +1,25 @@
import {
IsIn,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { BASE_PROPERTY_TYPES } from '../base.schemas';
export class CreatePropertyDto {
@IsUUID()
baseId: string;
@IsString()
@IsNotEmpty()
name: string;
@IsIn(BASE_PROPERTY_TYPES)
type: string;
@IsOptional()
@IsObject()
typeOptions?: Record<string, unknown>;
}
@@ -0,0 +1,14 @@
import { IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateRowDto {
@IsUUID()
baseId: string;
@IsOptional()
@IsObject()
cells?: Record<string, unknown>;
@IsOptional()
@IsString()
afterRowId?: string;
}
@@ -0,0 +1,25 @@
import {
IsIn,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class CreateViewDto {
@IsUUID()
baseId: string;
@IsString()
@IsNotEmpty()
name: string;
@IsOptional()
@IsIn(['table', 'kanban', 'calendar'])
type?: string;
@IsOptional()
@IsObject()
config?: Record<string, unknown>;
}
@@ -0,0 +1,19 @@
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateBaseDto {
@IsUUID()
baseId: string;
@IsOptional()
@IsString()
@IsNotEmpty()
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
icon?: string;
}
@@ -0,0 +1,50 @@
import {
IsIn,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { BASE_PROPERTY_TYPES } from '../base.schemas';
export class UpdatePropertyDto {
@IsUUID()
propertyId: string;
@IsUUID()
baseId: string;
@IsOptional()
@IsString()
@IsNotEmpty()
name?: string;
@IsOptional()
@IsIn(BASE_PROPERTY_TYPES)
type?: string;
@IsOptional()
@IsObject()
typeOptions?: Record<string, unknown>;
}
export class DeletePropertyDto {
@IsUUID()
propertyId: string;
@IsUUID()
baseId: string;
}
export class ReorderPropertyDto {
@IsUUID()
propertyId: string;
@IsUUID()
baseId: string;
@IsString()
@IsNotEmpty()
position: string;
}
@@ -0,0 +1,49 @@
import { IsNotEmpty, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateRowDto {
@IsUUID()
rowId: string;
@IsUUID()
baseId: string;
@IsObject()
cells: Record<string, unknown>;
}
export class DeleteRowDto {
@IsUUID()
rowId: string;
@IsUUID()
baseId: string;
}
export class RowIdDto {
@IsUUID()
rowId: string;
@IsUUID()
baseId: string;
}
export class ListRowsDto {
@IsUUID()
baseId: string;
@IsOptional()
@IsUUID()
viewId?: string;
}
export class ReorderRowDto {
@IsUUID()
rowId: string;
@IsUUID()
baseId: string;
@IsString()
@IsNotEmpty()
position: string;
}
@@ -0,0 +1,37 @@
import {
IsIn,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class UpdateViewDto {
@IsUUID()
viewId: string;
@IsUUID()
baseId: string;
@IsOptional()
@IsString()
@IsNotEmpty()
name?: string;
@IsOptional()
@IsIn(['table', 'kanban', 'calendar'])
type?: string;
@IsOptional()
@IsObject()
config?: Record<string, unknown>;
}
export class DeleteViewDto {
@IsUUID()
viewId: string;
@IsUUID()
baseId: string;
}
@@ -0,0 +1,216 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
import { CreatePropertyDto } from '../dto/create-property.dto';
import {
UpdatePropertyDto,
DeletePropertyDto,
ReorderPropertyDto,
} from '../dto/update-property.dto';
import {
BasePropertyTypeValue,
parseTypeOptions,
attemptCellConversion,
validateTypeOptions,
isSystemPropertyType,
} from '../base.schemas';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
@Injectable()
export class BasePropertyService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly basePropertyRepo: BasePropertyRepo,
private readonly baseRowRepo: BaseRowRepo,
) {}
async create(workspaceId: string, dto: CreatePropertyDto) {
const type = dto.type as BasePropertyTypeValue;
let validatedTypeOptions = null;
if (dto.typeOptions) {
validatedTypeOptions = parseTypeOptions(type, dto.typeOptions);
} else {
validatedTypeOptions = parseTypeOptions(type, {});
}
const lastPosition = await this.basePropertyRepo.getLastPosition(
dto.baseId,
);
const position = generateJitteredKeyBetween(lastPosition, null);
return this.basePropertyRepo.insertProperty({
baseId: dto.baseId,
name: dto.name,
type: dto.type,
position,
typeOptions: validatedTypeOptions as any,
workspaceId,
});
}
async update(dto: UpdatePropertyDto) {
const property = await this.basePropertyRepo.findById(dto.propertyId);
if (!property) {
throw new NotFoundException('Property not found');
}
if (property.baseId !== dto.baseId) {
throw new BadRequestException('Property does not belong to this base');
}
const isTypeChange = dto.type && dto.type !== property.type;
const newType = (dto.type ?? property.type) as BasePropertyTypeValue;
let validatedTypeOptions = property.typeOptions;
if (dto.typeOptions !== undefined) {
validatedTypeOptions = parseTypeOptions(newType, dto.typeOptions) as any;
} else if (isTypeChange) {
const result = validateTypeOptions(newType, {});
validatedTypeOptions = result.success ? (result.data as any) : null;
}
let conversionSummary: {
converted: number;
cleared: number;
total: number;
} | null = null;
if (isTypeChange) {
const involvesSystem =
isSystemPropertyType(property.type) || isSystemPropertyType(newType);
if (involvesSystem) {
conversionSummary = await this.clearCellValues(
dto.baseId,
dto.propertyId,
);
} else {
conversionSummary = await this.convertCellValues(
dto.baseId,
dto.propertyId,
property.type as BasePropertyTypeValue,
newType,
);
}
}
await this.basePropertyRepo.updateProperty(dto.propertyId, {
...(dto.name !== undefined && { name: dto.name }),
...(dto.type !== undefined && { type: dto.type }),
typeOptions: validatedTypeOptions,
});
const updatedProperty = await this.basePropertyRepo.findById(
dto.propertyId,
);
return { property: updatedProperty, conversionSummary };
}
async delete(dto: DeletePropertyDto) {
const property = await this.basePropertyRepo.findById(dto.propertyId);
if (!property) {
throw new NotFoundException('Property not found');
}
if (property.baseId !== dto.baseId) {
throw new BadRequestException('Property does not belong to this base');
}
if (property.isPrimary) {
throw new BadRequestException('Cannot delete the primary property');
}
await executeTx(this.db, async (trx) => {
await this.basePropertyRepo.deleteProperty(dto.propertyId, trx);
await this.baseRowRepo.removeCellKey(dto.baseId, dto.propertyId, trx);
});
}
async reorder(dto: ReorderPropertyDto) {
const property = await this.basePropertyRepo.findById(dto.propertyId);
if (!property) {
throw new NotFoundException('Property not found');
}
if (property.baseId !== dto.baseId) {
throw new BadRequestException('Property does not belong to this base');
}
await this.basePropertyRepo.updateProperty(dto.propertyId, {
position: dto.position,
});
}
private async clearCellValues(
baseId: string,
propertyId: string,
): Promise<{ converted: number; cleared: number; total: number }> {
const rows = await this.baseRowRepo.findAllByBaseId(baseId);
const updates: Array<{ id: string; cells: Record<string, unknown> }> = [];
for (const row of rows) {
const cells = row.cells as Record<string, unknown>;
if (propertyId in cells) {
updates.push({ id: row.id, cells: { [propertyId]: null } });
}
}
if (updates.length > 0) {
await executeTx(this.db, async (trx) => {
await this.baseRowRepo.batchUpdateCells(updates, trx);
});
}
return { converted: 0, cleared: updates.length, total: updates.length };
}
private async convertCellValues(
baseId: string,
propertyId: string,
fromType: BasePropertyTypeValue,
toType: BasePropertyTypeValue,
): Promise<{ converted: number; cleared: number; total: number }> {
const rows = await this.baseRowRepo.findAllByBaseId(baseId);
let converted = 0;
let cleared = 0;
let total = 0;
const updates: Array<{ id: string; cells: Record<string, unknown> }> = [];
for (const row of rows) {
const cells = row.cells as Record<string, unknown>;
if (!(propertyId in cells)) {
continue;
}
total++;
const currentValue = cells[propertyId];
const result = attemptCellConversion(fromType, toType, currentValue);
if (result.converted) {
converted++;
updates.push({ id: row.id, cells: { [propertyId]: result.value } });
} else {
cleared++;
updates.push({ id: row.id, cells: { [propertyId]: null } });
}
}
if (updates.length > 0) {
await executeTx(this.db, async (trx) => {
await this.baseRowRepo.batchUpdateCells(updates, trx);
});
}
return { converted, cleared, total };
}
}
@@ -0,0 +1,162 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo';
import { CreateRowDto } from '../dto/create-row.dto';
import {
UpdateRowDto,
ListRowsDto,
ReorderRowDto,
} from '../dto/update-row.dto';
import {
BasePropertyTypeValue,
validateCellValue,
isSystemPropertyType,
} from '../base.schemas';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { BaseProperty } from '@docmost/db/types/entity.types';
@Injectable()
export class BaseRowService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly baseRowRepo: BaseRowRepo,
private readonly basePropertyRepo: BasePropertyRepo,
private readonly baseViewRepo: BaseViewRepo,
) {}
async create(userId: string, workspaceId: string, dto: CreateRowDto) {
let position: string;
if (dto.afterRowId) {
const afterRow = await this.baseRowRepo.findById(dto.afterRowId);
if (!afterRow || afterRow.baseId !== dto.baseId) {
throw new BadRequestException('Invalid afterRowId');
}
position = generateJitteredKeyBetween(afterRow.position, null);
} else {
const lastPosition = await this.baseRowRepo.getLastPosition(dto.baseId);
position = generateJitteredKeyBetween(lastPosition, null);
}
let validatedCells: Record<string, unknown> = {};
if (dto.cells && Object.keys(dto.cells).length > 0) {
const properties = await this.basePropertyRepo.findByBaseId(dto.baseId);
validatedCells = this.validateCells(dto.cells, properties);
}
return this.baseRowRepo.insertRow({
baseId: dto.baseId,
cells: validatedCells as any,
position,
creatorId: userId,
workspaceId,
});
}
async getRowInfo(rowId: string, baseId: string) {
const row = await this.baseRowRepo.findById(rowId);
if (!row || row.baseId !== baseId) {
throw new NotFoundException('Row not found');
}
return row;
}
async update(dto: UpdateRowDto, userId?: string) {
const row = await this.baseRowRepo.findById(dto.rowId);
if (!row || row.baseId !== dto.baseId) {
throw new NotFoundException('Row not found');
}
const properties = await this.basePropertyRepo.findByBaseId(dto.baseId);
const validatedCells = this.validateCells(dto.cells, properties);
await this.baseRowRepo.updateCells(dto.rowId, validatedCells, userId);
return this.baseRowRepo.findById(dto.rowId);
}
async delete(rowId: string, baseId: string) {
const row = await this.baseRowRepo.findById(rowId);
if (!row || row.baseId !== baseId) {
throw new NotFoundException('Row not found');
}
await this.baseRowRepo.softDelete(rowId);
}
async list(dto: ListRowsDto, pagination: PaginationOptions) {
return this.baseRowRepo.findByBaseId(dto.baseId, pagination);
}
async reorder(dto: ReorderRowDto) {
const row = await this.baseRowRepo.findById(dto.rowId);
if (!row || row.baseId !== dto.baseId) {
throw new NotFoundException('Row not found');
}
try {
generateJitteredKeyBetween(dto.position, null);
} catch {
throw new BadRequestException('Invalid position value');
}
await this.baseRowRepo.updatePosition(dto.rowId, dto.position);
}
private validateCells(
cells: Record<string, unknown>,
properties: BaseProperty[],
): Record<string, unknown> {
const propertyMap = new Map(properties.map((p) => [p.id, p]));
const validatedCells: Record<string, unknown> = {};
const errors: string[] = [];
for (const [propertyId, value] of Object.entries(cells)) {
const property = propertyMap.get(propertyId);
if (!property) {
errors.push(`Unknown property: ${propertyId}`);
continue;
}
if (isSystemPropertyType(property.type)) {
continue;
}
if (value === null || value === undefined) {
validatedCells[propertyId] = null;
continue;
}
const result = validateCellValue(
property.type as BasePropertyTypeValue,
value,
);
if (!result.success) {
errors.push(
`Invalid value for property "${property.name}" (${property.type}): ${result.error.issues[0]?.message}`,
);
continue;
}
validatedCells[propertyId] = result.data;
}
if (errors.length > 0) {
throw new BadRequestException({
message: 'Cell validation failed',
errors,
});
}
return validatedCells;
}
}
@@ -0,0 +1,95 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo';
import { CreateViewDto } from '../dto/create-view.dto';
import { UpdateViewDto, DeleteViewDto } from '../dto/update-view.dto';
import { viewConfigSchema } from '../base.schemas';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
@Injectable()
export class BaseViewService {
constructor(private readonly baseViewRepo: BaseViewRepo) {}
async create(userId: string, workspaceId: string, dto: CreateViewDto) {
let validatedConfig = {};
if (dto.config) {
const result = viewConfigSchema.safeParse(dto.config);
if (!result.success) {
throw new BadRequestException({
message: 'Invalid view config',
errors: result.error.issues.map((i) => i.message),
});
}
validatedConfig = result.data;
}
const lastPosition = await this.baseViewRepo.getLastPosition(dto.baseId);
const position = generateJitteredKeyBetween(lastPosition, null);
return this.baseViewRepo.insertView({
baseId: dto.baseId,
name: dto.name,
type: dto.type ?? 'table',
position,
config: validatedConfig as any,
workspaceId,
creatorId: userId,
});
}
async update(dto: UpdateViewDto) {
const view = await this.baseViewRepo.findById(dto.viewId);
if (!view) {
throw new NotFoundException('View not found');
}
if (view.baseId !== dto.baseId) {
throw new BadRequestException('View does not belong to this base');
}
let validatedConfig = undefined;
if (dto.config !== undefined) {
const result = viewConfigSchema.safeParse(dto.config);
if (!result.success) {
throw new BadRequestException({
message: 'Invalid view config',
errors: result.error.issues.map((i) => i.message),
});
}
validatedConfig = result.data;
}
await this.baseViewRepo.updateView(dto.viewId, {
...(dto.name !== undefined && { name: dto.name }),
...(dto.type !== undefined && { type: dto.type }),
...(validatedConfig !== undefined && { config: validatedConfig as any }),
});
return this.baseViewRepo.findById(dto.viewId);
}
async delete(dto: DeleteViewDto) {
const view = await this.baseViewRepo.findById(dto.viewId);
if (!view) {
throw new NotFoundException('View not found');
}
if (view.baseId !== dto.baseId) {
throw new BadRequestException('View does not belong to this base');
}
const viewCount = await this.baseViewRepo.countByBaseId(dto.baseId);
if (viewCount <= 1) {
throw new BadRequestException('Cannot delete the last view');
}
await this.baseViewRepo.deleteView(dto.viewId);
}
async listByBaseId(baseId: string) {
return this.baseViewRepo.findByBaseId(baseId);
}
}
@@ -0,0 +1,115 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo';
import { CreateBaseDto } from '../dto/create-base.dto';
import { UpdateBaseDto } from '../dto/update-base.dto';
import { BasePropertyType } from '../base.schemas';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
@Injectable()
export class BaseService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly baseRepo: BaseRepo,
private readonly basePropertyRepo: BasePropertyRepo,
private readonly baseViewRepo: BaseViewRepo,
) {}
async create(userId: string, workspaceId: string, dto: CreateBaseDto) {
return executeTx(this.db, async (trx) => {
const base = await this.baseRepo.insertBase(
{
name: dto.name,
description: dto.description,
icon: dto.icon,
pageId: dto.pageId,
spaceId: dto.spaceId,
workspaceId,
creatorId: userId,
},
trx,
);
const firstPosition = generateJitteredKeyBetween(null, null);
await this.basePropertyRepo.insertProperty(
{
baseId: base.id,
name: 'Title',
type: BasePropertyType.TEXT,
position: firstPosition,
isPrimary: true,
workspaceId,
},
trx,
);
await this.baseViewRepo.insertView(
{
baseId: base.id,
name: 'Table View 1',
type: 'table',
position: firstPosition,
workspaceId,
creatorId: userId,
},
trx,
);
return this.baseRepo.findById(base.id, {
includeProperties: true,
includeViews: true,
trx,
});
});
}
async getBaseInfo(baseId: string) {
const base = await this.baseRepo.findById(baseId, {
includeProperties: true,
includeViews: true,
});
if (!base) {
throw new NotFoundException('Base not found');
}
return base;
}
async update(dto: UpdateBaseDto) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
await this.baseRepo.updateBase(dto.baseId, {
...(dto.name !== undefined && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.icon !== undefined && { icon: dto.icon }),
});
return this.baseRepo.findById(dto.baseId);
}
async delete(baseId: string) {
const base = await this.baseRepo.findById(baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
await this.baseRepo.softDelete(baseId);
}
async listBySpaceId(spaceId: string, pagination: PaginationOptions) {
return this.baseRepo.findBySpaceId(spaceId, pagination);
}
}
@@ -46,6 +46,7 @@ function buildSpaceAdminAbility() {
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Base);
return build();
}
@@ -57,6 +58,7 @@ function buildSpaceWriterAbility() {
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Base);
return build();
}
@@ -68,5 +70,6 @@ function buildSpaceReaderAbility() {
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
can(SpaceCaslAction.Read, SpaceCaslSubject.Page);
can(SpaceCaslAction.Read, SpaceCaslSubject.Share);
can(SpaceCaslAction.Read, SpaceCaslSubject.Base);
return build();
}
@@ -10,10 +10,12 @@ export enum SpaceCaslSubject {
Member = 'member',
Page = 'page',
Share = 'share',
Base = 'base',
}
export type ISpaceAbility =
| [SpaceCaslAction, SpaceCaslSubject.Settings]
| [SpaceCaslAction, SpaceCaslSubject.Member]
| [SpaceCaslAction, SpaceCaslSubject.Page]
| [SpaceCaslAction, SpaceCaslSubject.Share];
| [SpaceCaslAction, SpaceCaslSubject.Share]
| [SpaceCaslAction, SpaceCaslSubject.Base];
+2
View File
@@ -18,6 +18,7 @@ import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { ShareModule } from './share/share.module';
import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module';
import { BaseModule } from './base/base.module';
@Module({
imports: [
@@ -34,6 +35,7 @@ import { WatcherModule } from './watcher/watcher.module';
ShareModule,
NotificationModule,
WatcherModule,
BaseModule,
],
})
export class CoreModule implements NestModule {
@@ -27,6 +27,10 @@ import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { PageListener } from '@docmost/db/listeners/page.listener';
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo';
import { PostgresJSDialect } from 'kysely-postgres-js';
import * as postgres from 'postgres';
import { normalizePostgresUrl } from '../common/helpers';
@@ -85,6 +89,10 @@ import { normalizePostgresUrl } from '../common/helpers';
NotificationRepo,
WatcherRepo,
PageListener,
BaseRepo,
BasePropertyRepo,
BaseRowRepo,
BaseViewRepo,
],
exports: [
WorkspaceRepo,
@@ -102,6 +110,10 @@ import { normalizePostgresUrl } from '../common/helpers';
ShareRepo,
NotificationRepo,
WatcherRepo,
BaseRepo,
BasePropertyRepo,
BaseRowRepo,
BaseViewRepo,
],
})
export class DatabaseModule
@@ -0,0 +1,154 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('bases')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('name', 'varchar', (col) => col.notNull())
.addColumn('description', 'varchar')
.addColumn('icon', 'varchar')
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade'),
)
.addColumn('space_id', 'uuid', (col) =>
col.references('spaces.id').onDelete('cascade').notNull(),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz')
.execute();
await db.schema
.createIndex('idx_bases_space_id')
.on('bases')
.column('space_id')
.execute();
await db.schema
.createTable('base_properties')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('base_id', 'uuid', (col) =>
col.references('bases.id').onDelete('cascade').notNull(),
)
.addColumn('name', 'varchar', (col) => col.notNull())
.addColumn('type', 'varchar', (col) => col.notNull())
.addColumn('position', 'varchar', (col) => col.notNull())
.addColumn('type_options', 'jsonb')
.addColumn('is_primary', 'boolean', (col) =>
col.notNull().defaultTo(false),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex('idx_base_properties_base_id')
.on('base_properties')
.column('base_id')
.execute();
await db.schema
.createTable('base_rows')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('base_id', 'uuid', (col) =>
col.references('bases.id').onDelete('cascade').notNull(),
)
.addColumn('cells', 'jsonb', (col) =>
col.notNull().defaultTo(sql`'{}'::jsonb`),
)
.addColumn('position', 'varchar', (col) => col.notNull())
.addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('last_updated_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz')
.execute();
await db.schema
.createIndex('idx_base_rows_base_id')
.on('base_rows')
.column('base_id')
.execute();
await db.schema
.createIndex('idx_base_rows_cells_gin')
.on('base_rows')
.using('gin')
.column('cells')
.execute();
await db.schema
.createTable('base_views')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('base_id', 'uuid', (col) =>
col.references('bases.id').onDelete('cascade').notNull(),
)
.addColumn('name', 'varchar', (col) => col.notNull())
.addColumn('type', 'varchar', (col) => col.notNull().defaultTo('table'))
.addColumn('position', 'varchar', (col) => col.notNull())
.addColumn('config', 'jsonb', (col) =>
col.notNull().defaultTo(sql`'{}'::jsonb`),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex('idx_base_views_base_id')
.on('base_views')
.column('base_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('base_views').execute();
await db.schema.dropTable('base_rows').execute();
await db.schema.dropTable('base_properties').execute();
await db.schema.dropTable('bases').execute();
}
@@ -0,0 +1,91 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
BaseProperty,
InsertableBaseProperty,
UpdatableBaseProperty,
} from '@docmost/db/types/entity.types';
import { sql } from 'kysely';
@Injectable()
export class BasePropertyRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(
propertyId: string,
opts?: { trx?: KyselyTransaction },
): Promise<BaseProperty | undefined> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('baseProperties')
.selectAll()
.where('id', '=', propertyId)
.executeTakeFirst() as Promise<BaseProperty | undefined>;
}
async findByBaseId(
baseId: string,
opts?: { trx?: KyselyTransaction },
): Promise<BaseProperty[]> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('baseProperties')
.selectAll()
.where('baseId', '=', baseId)
.orderBy('position', 'asc')
.execute() as Promise<BaseProperty[]>;
}
async getLastPosition(
baseId: string,
trx?: KyselyTransaction,
): Promise<string | null> {
const db = dbOrTx(this.db, trx);
const result = await db
.selectFrom('baseProperties')
.select('position')
.where('baseId', '=', baseId)
.orderBy(sql`position COLLATE "C"`, sql`DESC`)
.limit(1)
.executeTakeFirst();
return result?.position ?? null;
}
async insertProperty(
property: InsertableBaseProperty,
trx?: KyselyTransaction,
): Promise<BaseProperty> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('baseProperties')
.values(property)
.returningAll()
.executeTakeFirstOrThrow() as Promise<BaseProperty>;
}
async updateProperty(
propertyId: string,
data: UpdatableBaseProperty,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('baseProperties')
.set({ ...data, updatedAt: new Date() })
.where('id', '=', propertyId)
.execute();
}
async deleteProperty(
propertyId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('baseProperties')
.where('id', '=', propertyId)
.execute();
}
}
@@ -0,0 +1,172 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
BaseRow,
InsertableBaseRow,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { sql } from 'kysely';
@Injectable()
export class BaseRowRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(
rowId: string,
opts?: { trx?: KyselyTransaction },
): Promise<BaseRow | undefined> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('baseRows')
.selectAll()
.where('id', '=', rowId)
.where('deletedAt', 'is', null)
.executeTakeFirst() as Promise<BaseRow | undefined>;
}
async findByBaseId(
baseId: string,
pagination: PaginationOptions,
opts?: { trx?: KyselyTransaction },
) {
const db = dbOrTx(this.db, opts?.trx);
const query = db
.selectFrom('baseRows')
.selectAll()
.where('baseId', '=', baseId)
.where('deletedAt', 'is', null);
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'position', direction: 'asc' },
{ expression: 'id', direction: 'asc' },
],
parseCursor: (cursor) => ({
position: cursor.position,
id: cursor.id,
}),
});
}
async getLastPosition(
baseId: string,
trx?: KyselyTransaction,
): Promise<string | null> {
const db = dbOrTx(this.db, trx);
const result = await db
.selectFrom('baseRows')
.select('position')
.where('baseId', '=', baseId)
.where('deletedAt', 'is', null)
.orderBy(sql`position COLLATE "C"`, sql`DESC`)
.limit(1)
.executeTakeFirst();
return result?.position ?? null;
}
async insertRow(
row: InsertableBaseRow,
trx?: KyselyTransaction,
): Promise<BaseRow> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('baseRows')
.values(row)
.returningAll()
.executeTakeFirstOrThrow() as Promise<BaseRow>;
}
async updateCells(
rowId: string,
cells: Record<string, unknown>,
userId?: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('baseRows')
.set({
cells: sql`cells || ${cells}`,
updatedAt: new Date(),
lastUpdatedById: userId ?? null,
})
.where('id', '=', rowId)
.where('deletedAt', 'is', null)
.execute();
}
async updatePosition(
rowId: string,
position: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('baseRows')
.set({ position, updatedAt: new Date() })
.where('id', '=', rowId)
.execute();
}
async softDelete(rowId: string, trx?: KyselyTransaction): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('baseRows')
.set({ deletedAt: new Date() })
.where('id', '=', rowId)
.execute();
}
async removeCellKey(
baseId: string,
propertyId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('baseRows')
.set({
cells: sql`cells - ${propertyId}`,
updatedAt: new Date(),
})
.where('baseId', '=', baseId)
.execute();
}
async findAllByBaseId(
baseId: string,
trx?: KyselyTransaction,
): Promise<BaseRow[]> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('baseRows')
.selectAll()
.where('baseId', '=', baseId)
.where('deletedAt', 'is', null)
.execute() as Promise<BaseRow[]>;
}
async batchUpdateCells(
updates: Array<{ id: string; cells: Record<string, unknown> }>,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
for (const update of updates) {
await db
.updateTable('baseRows')
.set({
cells: sql`cells || ${update.cells}`,
updatedAt: new Date(),
})
.where('id', '=', update.id)
.execute();
}
}
}
@@ -0,0 +1,104 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
BaseView,
InsertableBaseView,
UpdatableBaseView,
} from '@docmost/db/types/entity.types';
import { sql } from 'kysely';
@Injectable()
export class BaseViewRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(
viewId: string,
opts?: { trx?: KyselyTransaction },
): Promise<BaseView | undefined> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('baseViews')
.selectAll()
.where('id', '=', viewId)
.executeTakeFirst() as Promise<BaseView | undefined>;
}
async findByBaseId(
baseId: string,
opts?: { trx?: KyselyTransaction },
): Promise<BaseView[]> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('baseViews')
.selectAll()
.where('baseId', '=', baseId)
.orderBy('position', 'asc')
.execute() as Promise<BaseView[]>;
}
async countByBaseId(
baseId: string,
trx?: KyselyTransaction,
): Promise<number> {
const db = dbOrTx(this.db, trx);
const result = await db
.selectFrom('baseViews')
.select((eb) => eb.fn.countAll<number>().as('count'))
.where('baseId', '=', baseId)
.executeTakeFirstOrThrow();
return Number(result.count);
}
async getLastPosition(
baseId: string,
trx?: KyselyTransaction,
): Promise<string | null> {
const db = dbOrTx(this.db, trx);
const result = await db
.selectFrom('baseViews')
.select('position')
.where('baseId', '=', baseId)
.orderBy(sql`position COLLATE "C"`, sql`DESC`)
.limit(1)
.executeTakeFirst();
return result?.position ?? null;
}
async insertView(
view: InsertableBaseView,
trx?: KyselyTransaction,
): Promise<BaseView> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('baseViews')
.values(view)
.returningAll()
.executeTakeFirstOrThrow() as Promise<BaseView>;
}
async updateView(
viewId: string,
data: UpdatableBaseView,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('baseViews')
.set({ ...data, updatedAt: new Date() })
.where('id', '=', viewId)
.execute();
}
async deleteView(
viewId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('baseViews')
.where('id', '=', viewId)
.execute();
}
}
@@ -0,0 +1,142 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
Base,
InsertableBase,
UpdatableBase,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
@Injectable()
export class BaseRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
private baseFields: Array<keyof Base> = [
'id',
'name',
'description',
'icon',
'pageId',
'spaceId',
'workspaceId',
'creatorId',
'createdAt',
'updatedAt',
'deletedAt',
];
async findById(
baseId: string,
opts?: {
includeProperties?: boolean;
includeViews?: boolean;
trx?: KyselyTransaction;
},
): Promise<Base | undefined> {
const db = dbOrTx(this.db, opts?.trx);
let query = db
.selectFrom('bases')
.select(this.baseFields)
.where('id', '=', baseId)
.where('deletedAt', 'is', null);
if (opts?.includeProperties) {
query = query.select((eb) => this.withProperties(eb));
}
if (opts?.includeViews) {
query = query.select((eb) => this.withViews(eb));
}
return query.executeTakeFirst() as Promise<Base | undefined>;
}
async findBySpaceId(
spaceId: string,
pagination: PaginationOptions,
opts?: { trx?: KyselyTransaction },
) {
const db = dbOrTx(this.db, opts?.trx);
const query = db
.selectFrom('bases')
.select(this.baseFields)
.where('spaceId', '=', spaceId)
.where('deletedAt', 'is', null);
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'createdAt', direction: 'desc' },
{ expression: 'id', direction: 'desc' },
],
parseCursor: (cursor) => ({
createdAt: new Date(cursor.createdAt),
id: cursor.id,
}),
});
}
async insertBase(
base: InsertableBase,
trx?: KyselyTransaction,
): Promise<Base> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('bases')
.values(base)
.returningAll()
.executeTakeFirstOrThrow() as Promise<Base>;
}
async updateBase(
baseId: string,
data: UpdatableBase,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('bases')
.set({ ...data, updatedAt: new Date() })
.where('id', '=', baseId)
.execute();
}
async softDelete(baseId: string, trx?: KyselyTransaction): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('bases')
.set({ deletedAt: new Date() })
.where('id', '=', baseId)
.execute();
}
private withProperties(eb: ExpressionBuilder<DB, 'bases'>) {
return jsonArrayFrom(
eb
.selectFrom('baseProperties')
.selectAll('baseProperties')
.whereRef('baseProperties.baseId', '=', 'bases.id')
.orderBy('baseProperties.position', 'asc'),
).as('properties');
}
private withViews(eb: ExpressionBuilder<DB, 'bases'>) {
return jsonArrayFrom(
eb
.selectFrom('baseViews')
.selectAll('baseViews')
.whereRef('baseViews.baseId', '=', 'bases.id')
.orderBy('baseViews.position', 'asc'),
).as('views');
}
}
+57
View File
@@ -390,9 +390,66 @@ export interface Watchers {
createdAt: Generated<Timestamp>;
}
export interface Bases {
id: Generated<string>;
name: string;
description: string | null;
icon: string | null;
pageId: string | null;
spaceId: string;
workspaceId: string;
creatorId: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
}
export interface BaseProperties {
id: Generated<string>;
baseId: string;
name: string;
type: string;
position: string;
typeOptions: Json | null;
isPrimary: Generated<boolean>;
workspaceId: string;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface BaseRows {
id: Generated<string>;
baseId: string;
cells: Generated<Json>;
position: string;
creatorId: string | null;
lastUpdatedById: string | null;
workspaceId: string;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
}
export interface BaseViews {
id: Generated<string>;
baseId: string;
name: string;
type: Generated<string>;
position: string;
config: Generated<Json>;
workspaceId: string;
creatorId: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface DB {
apiKeys: ApiKeys;
attachments: Attachments;
baseProperties: BaseProperties;
baseRows: BaseRows;
baseViews: BaseViews;
bases: Bases;
authAccounts: AuthAccounts;
authProviders: AuthProviders;
backlinks: Backlinks;
@@ -4,6 +4,10 @@ import {
AuthAccounts,
AuthProviders,
Backlinks,
BaseProperties,
BaseRows,
BaseViews,
Bases,
Billing,
Comments,
FileTasks,
@@ -27,6 +31,10 @@ import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
export interface DbInterface {
attachments: Attachments;
authAccounts: AuthAccounts;
baseProperties: BaseProperties;
baseRows: BaseRows;
baseViews: BaseViews;
bases: Bases;
authProviders: AuthProviders;
backlinks: Backlinks;
billing: Billing;
@@ -1,6 +1,10 @@
import { Insertable, Selectable, Updateable } from 'kysely';
import {
Attachments,
BaseProperties,
BaseRows,
BaseViews,
Bases,
Comments,
Groups,
Notifications,
@@ -143,3 +147,23 @@ export type UpdatableNotification = Updateable<Omit<Notifications, 'id'>>;
export type Watcher = Selectable<Watchers>;
export type InsertableWatcher = Insertable<Watchers>;
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
// Base
export type Base = Selectable<Bases>;
export type InsertableBase = Insertable<Bases>;
export type UpdatableBase = Updateable<Omit<Bases, 'id'>>;
// Base Property
export type BaseProperty = Selectable<BaseProperties>;
export type InsertableBaseProperty = Insertable<BaseProperties>;
export type UpdatableBaseProperty = Updateable<Omit<BaseProperties, 'id'>>;
// Base Row
export type BaseRow = Selectable<BaseRows>;
export type InsertableBaseRow = Insertable<BaseRows>;
export type UpdatableBaseRow = Updateable<Omit<BaseRows, 'id'>>;
// Base View
export type BaseView = Selectable<BaseViews>;
export type InsertableBaseView = Insertable<BaseViews>;
export type UpdatableBaseView = Updateable<Omit<BaseViews, 'id'>>;
+212
View File
@@ -0,0 +1,212 @@
import * as path from 'path';
import * as dotenv from 'dotenv';
import { Kysely, sql } from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
import postgres from 'postgres';
import { v7 as uuid7 } from 'uuid';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
const BASE_ID = '019c69a5-1d84-7985-a7f6-8ee2871d8669';
const TOTAL_ROWS = 100_000;
const BATCH_SIZE = 2000;
const envFilePath = path.resolve(process.cwd(), '..', '..', '.env');
dotenv.config({ path: envFilePath });
function normalizePostgresUrl(url: string): string {
const parsed = new URL(url);
const newParams = new URLSearchParams();
for (const [key, value] of parsed.searchParams) {
if (key === 'sslmode' && value === 'no-verify') continue;
if (key === 'schema') continue;
newParams.append(key, value);
}
parsed.search = newParams.toString();
return parsed.toString();
}
const db = new Kysely<any>({
dialect: new PostgresJSDialect({
postgres: postgres(normalizePostgresUrl(process.env.DATABASE_URL!)),
}),
});
const SKIP_TYPES = new Set([
'createdAt',
'lastEditedAt',
'lastEditedBy',
'person',
'file',
]);
const WORDS = [
'Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf',
'Hotel', 'India', 'Juliet', 'Kilo', 'Lima', 'Mike', 'November',
'Oscar', 'Papa', 'Quebec', 'Romeo', 'Sierra', 'Tango', 'Uniform',
'Victor', 'Whiskey', 'X-ray', 'Yankee', 'Zulu', 'Report', 'Analysis',
'Summary', 'Review', 'Update', 'Draft', 'Final', 'Proposal', 'Budget',
'Timeline', 'Milestone', 'Objective', 'Strategy', 'Initiative',
];
function randomWords(min: number, max: number): string {
const count = min + Math.floor(Math.random() * (max - min + 1));
const result: string[] = [];
for (let i = 0; i < count; i++) {
result.push(WORDS[Math.floor(Math.random() * WORDS.length)]);
}
return result.join(' ');
}
type CellGenerator = () => unknown;
function buildCellGenerator(property: any): CellGenerator | null {
if (SKIP_TYPES.has(property.type)) return null;
const typeOptions = property.type_options;
switch (property.type) {
case 'text':
return () => randomWords(2, 6);
case 'number':
return () => Math.round(Math.random() * 10000 * 100) / 100;
case 'select':
case 'status': {
const choices = typeOptions?.choices ?? [];
if (choices.length === 0) return null;
return () => choices[Math.floor(Math.random() * choices.length)].id;
}
case 'multiSelect': {
const choices = typeOptions?.choices ?? [];
if (choices.length === 0) return () => [];
return () => {
const count = 1 + Math.floor(Math.random() * Math.min(3, choices.length));
const shuffled = [...choices].sort(() => Math.random() - 0.5);
return shuffled.slice(0, count).map((c: any) => c.id);
};
}
case 'date': {
const start = new Date(2020, 0, 1).getTime();
const range = new Date(2026, 0, 1).getTime() - start;
return () => new Date(start + Math.random() * range).toISOString();
}
case 'checkbox':
return () => Math.random() > 0.5;
case 'url':
return () => `https://example.com/page/${Math.floor(Math.random() * 100000)}`;
case 'email':
return () => `user${Math.floor(Math.random() * 100000)}@example.com`;
default:
return null;
}
}
async function main() {
console.log(`Seeding ${TOTAL_ROWS.toLocaleString()} rows for base ${BASE_ID}\n`);
const base = await db
.selectFrom('bases')
.selectAll()
.where('id', '=', BASE_ID)
.executeTakeFirstOrThrow();
const workspaceId = base.workspace_id;
console.log(`Workspace: ${workspaceId}`);
const user = await db
.selectFrom('users')
.select('id')
.limit(1)
.executeTakeFirst();
const creatorId = user?.id ?? null;
console.log(`Creator: ${creatorId ?? '(none)'}`);
const properties = await db
.selectFrom('base_properties')
.selectAll()
.where('base_id', '=', BASE_ID)
.execute();
console.log(`Properties: ${properties.length}`);
for (const p of properties) {
console.log(` - ${p.name} (${p.type})${SKIP_TYPES.has(p.type) ? ' [skipped]' : ''}`);
}
const generators: Array<{ propertyId: string; generate: CellGenerator }> = [];
for (const prop of properties) {
const gen = buildCellGenerator(prop);
if (gen) {
generators.push({ propertyId: prop.id, generate: gen });
}
}
console.log(`\nGenerating ${TOTAL_ROWS.toLocaleString()} positions...`);
const lastRow = await db
.selectFrom('base_rows')
.select('position')
.where('base_id', '=', BASE_ID)
.where('deleted_at', 'is', null)
.orderBy(sql`position COLLATE "C"`, sql`desc`)
.limit(1)
.executeTakeFirst();
let lastPosition: string | null = lastRow?.position ?? null;
const positions: string[] = new Array(TOTAL_ROWS);
for (let i = 0; i < TOTAL_ROWS; i++) {
lastPosition = generateJitteredKeyBetween(lastPosition, null);
positions[i] = lastPosition;
}
console.log(`Positions generated (last: ${positions[positions.length - 1]})\n`);
const startTime = Date.now();
const totalBatches = Math.ceil(TOTAL_ROWS / BATCH_SIZE);
for (let batchStart = 0; batchStart < TOTAL_ROWS; batchStart += BATCH_SIZE) {
const batchEnd = Math.min(batchStart + BATCH_SIZE, TOTAL_ROWS);
const rows: any[] = [];
for (let i = batchStart; i < batchEnd; i++) {
const cells: Record<string, unknown> = {};
for (const { propertyId, generate } of generators) {
cells[propertyId] = generate();
}
rows.push({
id: uuid7(),
base_id: BASE_ID,
cells,
position: positions[i],
creator_id: creatorId,
workspace_id: workspaceId,
created_at: new Date(),
updated_at: new Date(),
});
}
await db.insertInto('base_rows').values(rows).execute();
const batchNum = Math.floor(batchStart / BATCH_SIZE) + 1;
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`Batch ${batchNum}/${totalBatches} inserted (${batchEnd.toLocaleString()} rows, ${elapsed}s elapsed)`);
}
const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`\nDone. Inserted ${TOTAL_ROWS.toLocaleString()} rows in ${totalElapsed}s`);
await db.destroy();
process.exit(0);
}
main().catch((err) => {
console.error('Seed script failed:', err);
db.destroy().finally(() => process.exit(1));
});