[sync] [T2308] rework v2 delete table side effects (#1421)

Synced from teableio/teable-ee@da6e555
This commit is contained in:
nichenqin 2026-03-15 06:08:15 +00:00 committed by teable-bot
parent b0ef11296d
commit 2c7efe8f72
88 changed files with 5147 additions and 285 deletions

View File

@ -1,7 +1,18 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
import type { IBaseNodeTreeVo, IBaseNodeVo, IDeleteBaseNodeVo } from '@teable/openapi';
import {
Body,
Controller,
Delete,
Get,
Headers,
Param,
Post,
Put,
Res,
UseGuards,
} from '@nestjs/common';
import {
BaseNodeResourceType,
moveBaseNodeRoSchema,
createBaseNodeRoSchema,
duplicateBaseNodeRoSchema,
@ -10,7 +21,11 @@ import {
IMoveBaseNodeRo,
updateBaseNodeRoSchema,
IUpdateBaseNodeRo,
type IBaseNodeTreeVo,
type IBaseNodeVo,
type IDeleteBaseNodeVo,
} from '@teable/openapi';
import type { Response } from 'express';
import { ClsService } from 'nestjs-cls';
import { EmitControllerEvent } from '../../event-emitter/decorators/emit-controller-event.decorator';
import { Events } from '../../event-emitter/events';
@ -20,6 +35,11 @@ import { AllowAnonymous, AllowAnonymousType } from '../auth/decorators/allow-ano
import { BaseNodePermissions } from '../auth/decorators/base-node-permissions.decorator';
import { Permissions } from '../auth/decorators/permissions.decorator';
import { BaseNodePermissionGuard } from '../auth/guard/base-node-permission.guard';
import {
X_TEABLE_V2_FEATURE_HEADER,
X_TEABLE_V2_HEADER,
X_TEABLE_V2_REASON_HEADER,
} from '../canary/interceptors/v2-indicator.interceptor';
import { checkBaseNodePermission } from './base-node.permission.helper';
import { BaseNodeService } from './base-node.service';
import { BaseNodeAction } from './types';
@ -28,6 +48,8 @@ import { BaseNodeAction } from './types';
@UseGuards(BaseNodePermissionGuard)
@AllowAnonymous(AllowAnonymousType.RESOURCE)
export class BaseNodeController {
protected static readonly deleteTableV2Feature = 'deleteTable';
constructor(
private readonly baseNodeService: BaseNodeService,
private readonly cls: ClsService<IClsStore>
@ -167,8 +189,11 @@ export class BaseNodeController {
@EmitControllerEvent(Events.BASE_NODE_DELETE)
async delete(
@Param('baseId') baseId: string,
@Param('nodeId') nodeId: string
@Param('nodeId') nodeId: string,
@Headers('x-window-id') windowId: string | undefined,
@Res({ passthrough: true }) response: Response
): Promise<IDeleteBaseNodeVo> {
await this.prepareDeleteTableCanary(baseId, nodeId, response, windowId);
return this.baseNodeService.delete(baseId, nodeId);
}
@ -178,12 +203,44 @@ export class BaseNodeController {
@EmitControllerEvent(Events.BASE_NODE_DELETE)
async permanentDelete(
@Param('baseId') baseId: string,
@Param('nodeId') nodeId: string
@Param('nodeId') nodeId: string,
@Headers('x-window-id') windowId: string | undefined,
@Res({ passthrough: true }) response: Response
): Promise<IDeleteBaseNodeVo> {
await this.prepareDeleteTableCanary(baseId, nodeId, response, windowId);
const result = await this.baseNodeService.delete(baseId, nodeId, true);
return { ...result, permanent: true };
}
protected async prepareDeleteTableCanary(
baseId: string,
nodeId: string,
response: Response,
windowId?: string
): Promise<void> {
if (windowId) {
this.cls.set('windowId', windowId);
}
const node = await this.baseNodeService.getNode(baseId, nodeId);
if (node.resourceType !== BaseNodeResourceType.Table) {
return;
}
const decision = await this.baseNodeService.getDeleteTableV2Decision(baseId, nodeId);
if (!decision) {
return;
}
this.cls.set('useV2', decision.useV2);
this.cls.set('v2Feature', BaseNodeController.deleteTableV2Feature);
this.cls.set('v2Reason', decision.reason);
response.setHeader(X_TEABLE_V2_HEADER, decision.useV2 ? 'true' : 'false');
response.setHeader(X_TEABLE_V2_FEATURE_HEADER, BaseNodeController.deleteTableV2Feature);
response.setHeader(X_TEABLE_V2_REASON_HEADER, decision.reason);
}
protected async getPermissionContext(_baseId: string) {
const permissions = this.cls.get('permissions');
const permissionSet = new Set(permissions);

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { ShareDbModule } from '../../share-db/share-db.module';
import { BaseNodePermissionGuard } from '../auth/guard/base-node-permission.guard';
import { CanaryModule } from '../canary/canary.module';
import { DashboardModule } from '../dashboard/dashboard.module';
import { FieldDuplicateModule } from '../field/field-duplicate/field-duplicate.module';
import { FieldOpenApiModule } from '../field/open-api/field-open-api.module';
@ -15,6 +16,7 @@ import { BaseNodeFolderModule } from './folder/base-node-folder.module';
imports: [
BaseNodeFolderModule,
ShareDbModule,
CanaryModule,
DashboardModule,
TableOpenApiModule,
TableModule,

View File

@ -38,7 +38,10 @@ import type { IPerformanceCacheStore } from '../../performance-cache/types';
import { ShareDbService } from '../../share-db/share-db.service';
import type { IClsStore } from '../../types/cls';
import { updateOrder } from '../../utils/update-order';
import type { IV2Decision } from '../canary/canary.service';
import { CanaryService } from '../canary/canary.service';
import { DashboardService } from '../dashboard/dashboard.service';
import { TableOpenApiV2Service } from '../table/open-api/table-open-api-v2.service';
import { TableOpenApiService } from '../table/open-api/table-open-api.service';
import { prepareCreateTableRo } from '../table/open-api/table.pipe.helper';
import { TableDuplicateService } from '../table/table-duplicate.service';
@ -67,7 +70,9 @@ export class BaseNodeService {
@ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,
private readonly cls: ClsService<IClsStore & { ignoreBaseNodeListener?: boolean }>,
private readonly baseNodeFolderService: BaseNodeFolderService,
private readonly canaryService: CanaryService,
private readonly tableOpenApiService: TableOpenApiService,
private readonly tableOpenApiV2Service: TableOpenApiV2Service,
private readonly tableDuplicateService: TableDuplicateService,
private readonly dashboardService: DashboardService
) {}
@ -119,6 +124,28 @@ export class BaseNodeService {
};
}
async getDeleteTableV2Decision(baseId: string, nodeId: string): Promise<IV2Decision | undefined> {
const node = await this.prismaService.baseNode.findFirst({
where: { baseId, id: nodeId },
select: { resourceType: true },
});
if (node?.resourceType !== BaseNodeResourceType.Table) {
return undefined;
}
const base = await this.prismaService.txClient().base.findUnique({
where: { id: baseId, deletedTime: null },
select: { spaceId: true },
});
if (!base?.spaceId) {
return { useV2: false, reason: 'disabled' };
}
return this.canaryService.shouldUseV2WithReason(base.spaceId, 'deleteTable');
}
private generateDefaultUrl(
baseId: string,
resourceType: BaseNodeResourceType,
@ -750,6 +777,14 @@ export class BaseNodeService {
await this.baseNodeFolderService.deleteFolder(baseId, id);
break;
case BaseNodeResourceType.Table:
if (this.cls.get('useV2')) {
await this.tableOpenApiV2Service.deleteTable(
baseId,
id,
permanent ? 'permanent' : undefined
);
break;
}
if (permanent) {
await this.tableOpenApiService.permanentDeleteTables(baseId, [id]);
} else {

View File

@ -0,0 +1,63 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { executeDeleteTableEndpoint } from '@teable/v2-contract-http-implementation/handlers';
import { v2CoreTokens } from '@teable/v2-core';
import type { ICommandBus } from '@teable/v2-core';
import { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exception';
import { V2ContainerService } from '../../v2/v2-container.service';
import { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory';
const internalServerError = 'Internal server error';
@Injectable()
export class TableOpenApiV2Service {
constructor(
private readonly v2ContainerService: V2ContainerService,
private readonly v2ContextFactory: V2ExecutionContextFactory
) {}
private throwV2Error(
error: {
code: string;
message: string;
tags?: ReadonlyArray<string>;
details?: Readonly<Record<string, unknown>>;
},
status: number
): never {
throw new CustomHttpException(error.message, getDefaultCodeByStatus(status), {
domainCode: error.code,
domainTags: error.tags,
details: error.details,
});
}
async deleteTable(
baseId: string,
tableId: string,
mode: 'soft' | 'permanent' = 'soft'
): Promise<void> {
const container = await this.v2ContainerService.getContainer();
const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);
const context = await this.v2ContextFactory.createContext();
const result = await executeDeleteTableEndpoint(
context,
{
baseId,
tableId,
mode,
},
commandBus
);
if (result.status === 200 && result.body.ok) {
return;
}
if (!result.body.ok) {
this.throwV2Error(result.body.error, result.status);
}
throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);
}
}

View File

@ -1,5 +1,17 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
Query,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import type {
IDuplicateTableVo,
IGetAbnormalVo,
@ -26,15 +38,23 @@ import {
duplicateTableRoSchema,
IDuplicateTableRo,
} from '@teable/openapi';
import { ClsService } from 'nestjs-cls';
import type { IClsStore } from '../../../types/cls';
import { ZodValidationPipe } from '../../../zod.validation.pipe';
import { AllowAnonymous } from '../../auth/decorators/allow-anonymous.decorator';
import { Permissions } from '../../auth/decorators/permissions.decorator';
import { UseV2Feature } from '../../canary/decorators/use-v2-feature.decorator';
import { V2FeatureGuard } from '../../canary/guards/v2-feature.guard';
import { V2IndicatorInterceptor } from '../../canary/interceptors/v2-indicator.interceptor';
import { TableIndexService } from '../table-index.service';
import { TablePermissionService } from '../table-permission.service';
import { TableService } from '../table.service';
import { TableOpenApiV2Service } from './table-open-api-v2.service';
import { TableOpenApiService } from './table-open-api.service';
import { TablePipe } from './table.pipe';
@UseGuards(V2FeatureGuard)
@UseInterceptors(V2IndicatorInterceptor)
@Controller('api/base/:baseId/table')
@AllowAnonymous()
export class TableController {
@ -42,7 +62,9 @@ export class TableController {
private readonly tableService: TableService,
private readonly tableOpenApiService: TableOpenApiService,
private readonly tableIndexService: TableIndexService,
private readonly tablePermissionService: TablePermissionService
private readonly tablePermissionService: TablePermissionService,
private readonly tableOpenApiV2Service: TableOpenApiV2Service,
private readonly cls: ClsService<IClsStore>
) {}
@Permissions('table|read')
@ -145,15 +167,25 @@ export class TableController {
return await this.tableOpenApiService.duplicateTable(baseId, tableId, duplicateTableRo);
}
@UseV2Feature('deleteTable')
@Delete(':tableId')
@Permissions('table|delete')
async archiveTable(@Param('baseId') baseId: string, @Param('tableId') tableId: string) {
if (this.cls.get('useV2')) {
await this.tableOpenApiV2Service.deleteTable(baseId, tableId);
return;
}
return await this.tableOpenApiService.deleteTable(baseId, tableId);
}
@UseV2Feature('deleteTable')
@Delete(':tableId/permanent')
@Permissions('table|delete')
permanentDeleteTable(@Param('baseId') baseId: string, @Param('tableId') tableId: string) {
async permanentDeleteTable(@Param('baseId') baseId: string, @Param('tableId') tableId: string) {
if (this.cls.get('useV2')) {
await this.tableOpenApiV2Service.deleteTable(baseId, tableId, 'permanent');
return;
}
return this.tableOpenApiService.permanentDeleteTables(baseId, [tableId]);
}

View File

@ -2,16 +2,19 @@ import { Module } from '@nestjs/common';
import { DbProvider } from '../../../db-provider/db.provider';
import { ShareDbModule } from '../../../share-db/share-db.module';
import { CalculationModule } from '../../calculation/calculation.module';
import { CanaryModule } from '../../canary/canary.module';
import { FieldCalculateModule } from '../../field/field-calculate/field-calculate.module';
import { FieldDuplicateModule } from '../../field/field-duplicate/field-duplicate.module';
import { FieldOpenApiModule } from '../../field/open-api/field-open-api.module';
import { GraphModule } from '../../graph/graph.module';
import { RecordOpenApiModule } from '../../record/open-api/record-open-api.module';
import { RecordModule } from '../../record/record.module';
import { V2Module } from '../../v2/v2.module';
import { ViewOpenApiModule } from '../../view/open-api/view-open-api.module';
import { TableDuplicateService } from '../table-duplicate.service';
import { TableIndexService } from '../table-index.service';
import { TableModule } from '../table.module';
import { TableOpenApiV2Service } from './table-open-api-v2.service';
import { TableController } from './table-open-api.controller';
import { TableOpenApiService } from './table-open-api.service';
@ -27,9 +30,17 @@ import { TableOpenApiService } from './table-open-api.service';
ShareDbModule,
CalculationModule,
GraphModule,
V2Module,
CanaryModule,
],
controllers: [TableController],
providers: [DbProvider, TableOpenApiService, TableIndexService, TableDuplicateService],
exports: [TableOpenApiService, TableDuplicateService],
providers: [
DbProvider,
TableOpenApiService,
TableOpenApiV2Service,
TableIndexService,
TableDuplicateService,
],
exports: [TableOpenApiService, TableOpenApiV2Service, TableDuplicateService],
})
export class TableOpenApiModule {}

View File

@ -0,0 +1,110 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const useV2Feature = () => () => undefined;
vi.mock('../table.service', () => ({
TableService: class TableService {},
}));
vi.mock('./table-open-api.service', () => ({
TableOpenApiService: class TableOpenApiService {},
}));
vi.mock('../table-index.service', () => ({
TableIndexService: class TableIndexService {},
}));
vi.mock('../table-permission.service', () => ({
TablePermissionService: class TablePermissionService {},
}));
vi.mock('./table-open-api-v2.service', () => ({
TableOpenApiV2Service: class TableOpenApiV2Service {},
}));
vi.mock('../../canary/decorators/use-v2-feature.decorator', () => ({
UseV2Feature: useV2Feature,
}));
vi.mock('../../canary/guards/v2-feature.guard', () => ({
V2FeatureGuard: class V2FeatureGuard {},
}));
vi.mock('../../canary/interceptors/v2-indicator.interceptor', () => ({
V2IndicatorInterceptor: class V2IndicatorInterceptor {},
}));
vi.mock('@teable/db-main-prisma', () => ({
PrismaService: class PrismaService {},
}));
let tableControllerClass: new (...args: unknown[]) => {
archiveTable: (baseId: string, tableId: string) => Promise<unknown>;
permanentDeleteTable: (baseId: string, tableId: string) => Promise<unknown>;
};
describe('TableController.archiveTable', () => {
beforeAll(async () => {
const module = await import('./table-open-api.controller');
tableControllerClass = module.TableController as typeof tableControllerClass;
});
const createController = (useV2: boolean) => {
const tableOpenApiService = {
deleteTable: vi.fn(),
permanentDeleteTables: vi.fn(),
};
const tableOpenApiV2Service = {
deleteTable: vi.fn(),
};
const cls = {
get: vi.fn((key: string) => (key === 'useV2' ? useV2 : undefined)),
};
const controller = new tableControllerClass(
{} as never,
tableOpenApiService as never,
{} as never,
{} as never,
tableOpenApiV2Service as never,
cls as never
);
return {
controller,
tableOpenApiService,
tableOpenApiV2Service,
};
};
beforeEach(() => {
vi.restoreAllMocks();
});
it('routes delete-table through v2 when useV2 is enabled', async () => {
const { controller, tableOpenApiService, tableOpenApiV2Service } = createController(true);
await controller.archiveTable('bse1', 'tbl1');
expect(tableOpenApiV2Service.deleteTable).toHaveBeenCalledWith('bse1', 'tbl1');
expect(tableOpenApiService.deleteTable).not.toHaveBeenCalled();
});
it('keeps the legacy delete-table path when useV2 is disabled', async () => {
const { controller, tableOpenApiService, tableOpenApiV2Service } = createController(false);
await controller.archiveTable('bse1', 'tbl1');
expect(tableOpenApiService.deleteTable).toHaveBeenCalledWith('bse1', 'tbl1');
expect(tableOpenApiV2Service.deleteTable).not.toHaveBeenCalled();
});
it('routes permanent delete through v2 when useV2 is enabled', async () => {
const { controller, tableOpenApiService, tableOpenApiV2Service } = createController(true);
await controller.permanentDeleteTable('bse1', 'tbl1');
expect(tableOpenApiV2Service.deleteTable).toHaveBeenCalledWith('bse1', 'tbl1', 'permanent');
expect(tableOpenApiService.permanentDeleteTables).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,63 @@
import { ResourceType } from '@teable/openapi';
import { ActorId, BaseId, TableId, TableName, TableTrashed } from '@teable/v2-core';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@teable/db-main-prisma', () => ({
PrismaModule: class PrismaModule {},
PrismaService: class PrismaService {},
}));
import { V2TableTrashedProjection } from './v2-table-trash.service';
describe('V2TableTrashedProjection', () => {
it('writes a table trash entry for soft-deleted tables', async () => {
const deletedTime = new Date('2026-03-12T00:00:00.000Z');
const prisma = {
tableMeta: {
findUnique: vi.fn().mockResolvedValue({
baseId: 'bseaaaaaaaaaaaaaaaa',
deletedTime,
}),
},
trash: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue({}),
},
};
const projection = new V2TableTrashedProjection(prisma as never);
const context = {
actorId: ActorId.create('usrTestUserId')._unsafeUnwrap(),
};
const event = TableTrashed.create({
tableId: TableId.create('tblaaaaaaaaaaaaaaaa')._unsafeUnwrap(),
baseId: BaseId.create('bseaaaaaaaaaaaaaaaa')._unsafeUnwrap(),
tableName: TableName.create('Trash Me')._unsafeUnwrap(),
fieldIds: [],
viewIds: [],
});
const result = await projection.handle(context, event);
expect(result._unsafeUnwrap()).toBeUndefined();
expect(prisma.tableMeta.findUnique).toHaveBeenCalledWith({
where: { id: 'tblaaaaaaaaaaaaaaaa' },
select: { baseId: true, deletedTime: true },
});
expect(prisma.trash.deleteMany).toHaveBeenCalledWith({
where: {
resourceId: 'tblaaaaaaaaaaaaaaaa',
resourceType: ResourceType.Table,
},
});
expect(prisma.trash.create).toHaveBeenCalledWith({
data: {
resourceId: 'tblaaaaaaaaaaaaaaaa',
resourceType: ResourceType.Table,
parentId: 'bseaaaaaaaaaaaaaaaa',
deletedTime,
deletedBy: 'usrTestUserId',
},
});
});
});

View File

@ -2,9 +2,12 @@ import type { OnModuleInit } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import type { IRecord } from '@teable/core';
import { generateOperationId } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import { ResourceType } from '@teable/openapi';
import {
ProjectionHandler,
RecordsDeleted,
TableTrashed,
TableQueryService,
ok,
v2CoreTokens,
@ -22,7 +25,7 @@ import { TableTrashListener } from './listener/table-trash.listener';
import { resolveV2TrashRecordDisplayName } from './v2-trash-record-name';
@ProjectionHandler(RecordsDeleted)
class V2RecordsDeletedTableTrashProjection implements IEventHandler<RecordsDeleted> {
export class V2RecordsDeletedTableTrashProjection implements IEventHandler<RecordsDeleted> {
constructor(
private readonly tableTrashListener: TableTrashListener,
private readonly tableQueryService: TableQueryService
@ -77,7 +80,7 @@ class V2RecordsDeletedTableTrashProjection implements IEventHandler<RecordsDelet
}
@ProjectionHandler(RecordsDeleted)
class V2RecordsDeletedAttachmentProjection implements IEventHandler<RecordsDeleted> {
export class V2RecordsDeletedAttachmentProjection implements IEventHandler<RecordsDeleted> {
constructor(private readonly attachmentsTableService: AttachmentsTableService) {}
async handle(
@ -97,6 +100,44 @@ class V2RecordsDeletedAttachmentProjection implements IEventHandler<RecordsDelet
}
}
@ProjectionHandler(TableTrashed)
export class V2TableTrashedProjection implements IEventHandler<TableTrashed> {
constructor(private readonly prisma: PrismaService) {}
async handle(
context: IExecutionContext,
event: TableTrashed
): Promise<Result<void, DomainError>> {
const table = await this.prisma.tableMeta.findUnique({
where: { id: event.tableId.toString() },
select: { baseId: true, deletedTime: true },
});
if (!table?.deletedTime) {
return ok(undefined);
}
await this.prisma.trash.deleteMany({
where: {
resourceId: event.tableId.toString(),
resourceType: ResourceType.Table,
},
});
await this.prisma.trash.create({
data: {
resourceId: event.tableId.toString(),
resourceType: ResourceType.Table,
parentId: table.baseId,
deletedTime: table.deletedTime,
deletedBy: context.actorId.toString(),
},
});
return ok(undefined);
}
}
@Injectable()
export class V2TableTrashService implements IV2ProjectionRegistrar, OnModuleInit {
private readonly logger = new Logger(V2TableTrashService.name);
@ -104,7 +145,8 @@ export class V2TableTrashService implements IV2ProjectionRegistrar, OnModuleInit
constructor(
private readonly v2ContainerService: V2ContainerService,
private readonly tableTrashListener: TableTrashListener,
private readonly attachmentsTableService: AttachmentsTableService
private readonly attachmentsTableService: AttachmentsTableService,
private readonly prisma: PrismaService
) {}
onModuleInit(): void {
@ -112,7 +154,7 @@ export class V2TableTrashService implements IV2ProjectionRegistrar, OnModuleInit
}
registerProjections(container: DependencyContainer): void {
this.logger.log('Registering V2 record delete projections');
this.logger.log('Registering V2 trash projections');
const tableQueryService = container.resolve<TableQueryService>(v2CoreTokens.tableQueryService);
@ -125,5 +167,6 @@ export class V2TableTrashService implements IV2ProjectionRegistrar, OnModuleInit
V2RecordsDeletedAttachmentProjection,
new V2RecordsDeletedAttachmentProjection(this.attachmentsTableService)
);
container.registerInstance(V2TableTrashedProjection, new V2TableTrashedProjection(this.prisma));
}
}

View File

@ -3,6 +3,7 @@ import type { INestApplication } from '@nestjs/common';
import { FieldType, Role, ViewType } from '@teable/core';
import type { IBaseNodeTableResourceMeta, IBaseNodeVo } from '@teable/openapi';
import {
axios,
createBaseNode,
getBaseNodeTree,
getBaseNode,
@ -37,6 +38,8 @@ const originalName = 'Original Name';
const testFolder = 'Test Folder';
const updatedName = 'Updated Name';
const testTableName = 'Test Table';
const windowIdHeader = 'x-window-id';
const isForceV2 = process.env.FORCE_V2_ALL === 'true';
describe('BaseNodeController (e2e) /api/base/:baseId/node', () => {
let app: INestApplication;
@ -399,6 +402,32 @@ describe('BaseNodeController (e2e) /api/base/:baseId/node', () => {
const error = await getError(() => deleteBaseNode(baseId, folder.id));
expect(error?.status).toBeGreaterThanOrEqual(400);
});
it('should expose delete-table canary headers when deleting a table node', async () => {
const table = await createBaseNode(baseId, {
resourceType: BaseNodeResourceType.Table,
name: 'Delete Via Node Route',
fields: [{ name: 'Name', type: FieldType.SingleLineText }],
views: [{ name: 'Grid view', type: ViewType.Grid }],
});
const response = await axios.delete(
urlBuilder(DELETE_BASE_NODE, { baseId, nodeId: table.data.id }),
{
headers: {
[windowIdHeader]: 'win-base-node-delete-table',
},
}
);
expect(response.status).toBe(200);
expect(response.headers['x-teable-v2']).toBe(isForceV2 ? 'true' : 'false');
expect(response.headers['x-teable-v2-feature']).toBe('deleteTable');
expect(response.headers['x-teable-v2-reason']).toBeTruthy();
const error = await getError(() => getBaseNode(baseId, table.data.id));
expect(error?.status).toBeGreaterThanOrEqual(400);
});
});
describe('PUT /api/base/:baseId/node/:nodeId/move - Move node', () => {

View File

@ -10,6 +10,8 @@ import {
updateTableName,
deleteTable as apiDeleteTable,
} from '@teable/openapi';
import { v2RecordRepositoryPostgresTokens } from '@teable/v2-adapter-table-repository-postgres';
import type { ComputedUpdateWorker } from '@teable/v2-adapter-table-repository-postgres';
import { DB_PROVIDER_SYMBOL } from '../src/db-provider/db.provider';
import type { IDbProvider } from '../src/db-provider/db.provider.interface';
import { Events } from '../src/event-emitter/events';
@ -19,6 +21,7 @@ import type {
ViewCreateEvent,
RecordCreateEvent,
} from '../src/event-emitter/events';
import { V2ContainerService } from '../src/features/v2/v2-container.service';
import {
createField,
createRecords,
@ -31,6 +34,9 @@ import {
updateRecord,
} from './utils/init-app';
const isForceV2 = process.env.FORCE_V2_ALL === 'true';
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const assertData: ICreateTableRo = {
name: 'Project Management',
description: 'A table for managing projects',
@ -120,6 +126,7 @@ describe('OpenAPI TableController (e2e)', () => {
let tableId = '';
let dbProvider: IDbProvider;
let event: EventEmitter2;
let v2ContainerService: V2ContainerService;
const baseId = globalThis.testConfig.baseId;
beforeAll(async () => {
@ -127,6 +134,7 @@ describe('OpenAPI TableController (e2e)', () => {
app = appCtx.app;
dbProvider = app.get(DB_PROVIDER_SYMBOL);
event = app.get(EventEmitter2);
v2ContainerService = app.get(V2ContainerService);
});
afterAll(async () => {
@ -137,6 +145,80 @@ describe('OpenAPI TableController (e2e)', () => {
await permanentDeleteTable(baseId, tableId);
});
async function processV2Outbox(times = 1): Promise<void> {
if (!isForceV2) return;
const container = await v2ContainerService.getContainer();
const worker = container.resolve<ComputedUpdateWorker>(
v2RecordRepositoryPostgresTokens.computedUpdateWorker
);
for (let i = 0; i < times; i++) {
const maxIterations = 100;
let iterations = 0;
while (iterations < maxIterations) {
const result = await worker.runOnce({
workerId: 'table-delete-test-worker',
limit: 100,
});
if (result.isErr()) {
throw new Error(`Outbox processing failed: ${result.error.message}`);
}
if (result.value === 0) {
break;
}
iterations++;
}
}
}
async function waitForDeleteTableCleanup(
targetTableId: string,
options: {
twoWayLinkFieldId: string;
oneWayLinkFieldId: string;
lookupFieldId: string;
rollupFieldId: string;
}
) {
const maxRetries = isForceV2 ? 40 : 1;
for (let i = 0; i < maxRetries; i++) {
if (isForceV2) {
await processV2Outbox();
}
const fields = await getFields(targetTableId);
const { records } = await getRecords(targetTableId, { fieldKeyType: FieldKeyType.Id });
const twoWayLinkField = fields.find((field) => field.id === options.twoWayLinkFieldId);
const oneWayLinkField = fields.find((field) => field.id === options.oneWayLinkFieldId);
const lookupField = fields.find((field) => field.id === options.lookupFieldId);
const rollupField = fields.find((field) => field.id === options.rollupFieldId);
const deleteSettled =
twoWayLinkField?.type === FieldType.SingleLineText &&
oneWayLinkField?.type === FieldType.SingleLineText &&
records[0]?.fields[options.twoWayLinkFieldId] === 'A' &&
records[0]?.fields[options.oneWayLinkFieldId] === 'A' &&
Boolean(lookupField?.hasError) &&
Boolean(rollupField?.hasError);
if (deleteSettled) {
return { fields, records };
}
await sleep(100);
}
const fields = await getFields(targetTableId);
const { records } = await getRecords(targetTableId, { fieldKeyType: FieldKeyType.Id });
return { fields, records };
}
it('/api/table/ (POST) with assertData data', async () => {
let eventCount = 0;
event.once(Events.TABLE_CREATE, async (payload: TableCreateEvent) => {
@ -349,8 +431,10 @@ describe('OpenAPI TableController (e2e)', () => {
},
};
await createField(table2.id, lookupFieldRo);
await createField(table2.id, rollupFieldRo);
const lookupField = await createField(table2.id, lookupFieldRo);
const rollupField = await createField(table2.id, rollupFieldRo);
const lookupFieldId = lookupField.id;
const rollupFieldId = rollupField.id;
await updateRecord(table2.id, table2.records[0].id, {
record: {
@ -364,12 +448,30 @@ describe('OpenAPI TableController (e2e)', () => {
await apiDeleteTable(baseId, table1.id);
const fields = await getFields(table2.id);
const { records } = await getRecords(table2.id, { fieldKeyType: FieldKeyType.Id });
const { fields, records } = await waitForDeleteTableCleanup(table2.id, {
twoWayLinkFieldId: twoWayLink.id,
oneWayLinkFieldId: oneWayLink.id,
lookupFieldId,
rollupFieldId,
});
const twoWayLinkField = fields.find((field) => field.id === twoWayLink.id);
const oneWayLinkField = fields.find((field) => field.id === oneWayLink.id);
const refreshedLookupField = fields.find((field) => field.id === lookupFieldId);
const refreshedRollupField = fields.find((field) => field.id === rollupFieldId);
expect(fields[1].type).toEqual(FieldType.SingleLineText);
expect(records[0].fields[fields[1].id]).toEqual('A');
expect(fields[2].hasError).toBeTruthy();
expect(fields[3].hasError).toBeTruthy();
if (!isForceV2) {
expect(fields[1].type).toEqual(FieldType.SingleLineText);
expect(records[0].fields[fields[1].id]).toEqual('A');
expect(fields[2].hasError).toBeTruthy();
expect(fields[3].hasError).toBeTruthy();
return;
}
expect(twoWayLinkField?.type).toEqual(FieldType.SingleLineText);
expect(oneWayLinkField?.type).toEqual(FieldType.SingleLineText);
expect(records[0].fields[twoWayLink.id]).toEqual('A');
expect(records[0].fields[oneWayLink.id]).toEqual('A');
expect(refreshedLookupField?.hasError).toBeTruthy();
expect(refreshedRollupField?.hasError).toBeTruthy();
});
});

View File

@ -25,6 +25,21 @@ import {
createField,
} from './utils/init-app';
const isForceV2 = process.env.FORCE_V2_ALL === 'true';
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const waitForBaseTrashItems = async (baseId: string, expectedCount = 1, maxRetries = 100) => {
for (let i = 0; i < maxRetries; i++) {
const result = await getTrashItems({ resourceId: baseId, resourceType: ResourceType.Base });
if (result.data.trashItems.length >= expectedCount) {
return result;
}
await sleep(100);
}
return await getTrashItems({ resourceId: baseId, resourceType: ResourceType.Base });
};
describe('Trash (e2e)', () => {
let app: INestApplication;
let eventEmitterService: EventEmitterService;
@ -32,6 +47,8 @@ describe('Trash (e2e)', () => {
let awaitWithSpaceEvent: <T>(fn: () => Promise<T>) => Promise<T>;
let awaitWithBaseEvent: <T>(fn: () => Promise<T>) => Promise<T>;
let awaitWithTableEvent: <T>(fn: () => Promise<T>) => Promise<T>;
const awaitWithTableDeleteSync = async <T>(fn: () => Promise<T>) =>
isForceV2 ? await fn() : awaitWithTableEvent(fn);
beforeAll(async () => {
const appCtx = await initApp();
@ -83,9 +100,9 @@ describe('Trash (e2e)', () => {
it('should retrieve trash items for base when a table is deleted', async () => {
const tableId = (await createTable(baseId, {})).id;
await awaitWithTableEvent(() => deleteTable(baseId, tableId));
await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId));
const res = await getTrashItems({ resourceId: baseId, resourceType: ResourceType.Base });
const res = await waitForBaseTrashItems(baseId, 1);
expect(res.data.trashItems.length).toBe(1);
expect((res.data.trashItems[0] as ITrashItemVo).resourceId).toBe(tableId);
@ -103,9 +120,9 @@ describe('Trash (e2e)', () => {
},
});
await awaitWithTableEvent(() => deleteTable(baseId, foreignTableId));
await awaitWithTableDeleteSync(() => deleteTable(baseId, foreignTableId));
const res = await getTrashItems({ resourceId: baseId, resourceType: ResourceType.Base });
const res = await waitForBaseTrashItems(baseId, 1);
expect(res.data.trashItems.length).toBe(1);
expect((res.data.trashItems[0] as ITrashItemVo).resourceId).toBe(foreignTableId);
@ -150,10 +167,9 @@ describe('Trash (e2e)', () => {
});
it('should restore table successfully', async () => {
await awaitWithTableEvent(() => deleteTable(baseId, tableId));
await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId));
const trash = (await getTrashItems({ resourceId: baseId, resourceType: ResourceType.Base }))
.data;
const trash = (await waitForBaseTrashItems(baseId, 1)).data;
const restored = await restoreTrash(trash.trashItems[0].id);
expect(restored.status).toEqual(201);
@ -182,12 +198,11 @@ describe('Trash (e2e)', () => {
const tableId2 = (await createTable(baseId, {})).id;
const tableId3 = (await createTable(baseId, {})).id;
await awaitWithTableEvent(() => deleteTable(baseId, tableId1));
await awaitWithTableEvent(() => deleteTable(baseId, tableId2));
await awaitWithTableEvent(() => deleteTable(baseId, tableId3));
await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId1));
await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId2));
await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId3));
const trash = (await getTrashItems({ resourceId: baseId, resourceType: ResourceType.Base }))
.data;
const trash = (await waitForBaseTrashItems(baseId, 3)).data;
expect(trash.trashItems.length).toEqual(3);

View File

@ -656,6 +656,7 @@ export const v2FeatureSchema = z.enum([
'importRecords',
'createField',
'deleteField',
'deleteTable',
'duplicateField',
'updateField',
'convertField',

View File

@ -556,6 +556,124 @@ describe('PostgresTableRepository (pg)', () => {
}
});
it('finds host tables by incoming references across bases', async () => {
const c = container.createChildContainer();
const db = await createPgDb(pgContainer.getConnectionUri());
await registerV2PostgresStateAdapter(c, {
db,
ensureSchema: true,
});
const repo = c.resolve<ITableRepository>(v2CoreTokens.tableRepository);
try {
const foreignBaseId = BaseId.generate()._unsafeUnwrap();
const hostBaseId = BaseId.generate()._unsafeUnwrap();
const actorId = ActorId.create('system')._unsafeUnwrap();
const context = { actorId };
const spaceId = `spc${getRandomString(16)}`;
await db
.insertInto('space')
.values({ id: spaceId, name: 'Reference Space', created_by: actorId.toString() })
.execute();
await db
.insertInto('base')
.values([
{
id: foreignBaseId.toString(),
space_id: spaceId,
name: 'Foreign Base',
order: 1,
created_by: actorId.toString(),
},
{
id: hostBaseId.toString(),
space_id: spaceId,
name: 'Host Base',
order: 2,
created_by: actorId.toString(),
},
])
.execute();
const foreignBuilder = Table.builder()
.withBaseId(foreignBaseId)
.withName(TableName.create('Foreign')._unsafeUnwrap());
foreignBuilder
.field()
.singleLineText()
.withName(FieldName.create('Title')._unsafeUnwrap())
.primary()
.done();
foreignBuilder.view().defaultGrid().done();
const foreignTable = foreignBuilder.build()._unsafeUnwrap();
(await repo.insert(context, foreignTable))._unsafeUnwrap();
const linkFieldId = FieldId.generate()._unsafeUnwrap();
const hostBuilder = Table.builder()
.withBaseId(hostBaseId)
.withName(TableName.create('Host')._unsafeUnwrap());
hostBuilder
.field()
.singleLineText()
.withName(FieldName.create('Name')._unsafeUnwrap())
.primary()
.done();
hostBuilder
.field()
.link()
.withId(linkFieldId)
.withName(FieldName.create('Foreign Link')._unsafeUnwrap())
.withConfig(
LinkFieldConfig.create({
baseId: foreignBaseId.toString(),
relationship: 'manyMany',
foreignTableId: foreignTable.id().toString(),
lookupFieldId: foreignTable.primaryFieldId().toString(),
isOneWay: true,
})._unsafeUnwrap()
)
.done();
hostBuilder.view().defaultGrid().done();
const hostTable = hostBuilder.build()._unsafeUnwrap();
(await repo.insert(context, hostTable))._unsafeUnwrap();
const unrelatedBuilder = Table.builder()
.withBaseId(hostBaseId)
.withName(TableName.create('Unrelated')._unsafeUnwrap());
unrelatedBuilder
.field()
.singleLineText()
.withName(FieldName.create('Title 2')._unsafeUnwrap())
.primary()
.done();
unrelatedBuilder.view().defaultGrid().done();
const unrelatedTable = unrelatedBuilder.build()._unsafeUnwrap();
(await repo.insert(context, unrelatedTable))._unsafeUnwrap();
await db
.insertInto('reference')
.values({
id: `ref_${getRandomString(21)}`,
from_field_id: foreignTable.primaryFieldId().toString(),
to_field_id: linkFieldId.toString(),
})
.onConflict((oc) => oc.columns(['to_field_id', 'from_field_id']).doNothing())
.execute();
const specResult = Table.specs().byIncomingReferenceToTable(foreignTable.id()).build();
specResult._unsafeUnwrap();
const tables = (await repo.find(context, specResult._unsafeUnwrap()))._unsafeUnwrap();
expect(tables.map((table) => table.id().toString())).toEqual([hostTable.id().toString()]);
expect(tables.some((table) => table.id().equals(unrelatedTable.id()))).toBe(false);
} finally {
await db.destroy();
}
});
it('normalizes legacy dateRange filter values in view filters', async () => {
const c = container.createChildContainer();
const db = await createPgDb(pgContainer.getConnectionUri());

View File

@ -21,6 +21,9 @@ import {
const formatSpecDetails = (specInfo: TableWhereSpecInfo): string => {
const parts: string[] = [];
if (specInfo.tableId) parts.push(`tableId=${specInfo.tableId}`);
if (specInfo.incomingReferenceToTableId) {
parts.push(`incomingReferenceToTableId=${specInfo.incomingReferenceToTableId}`);
}
if (specInfo.baseId) parts.push(`baseId=${specInfo.baseId}`);
if (specInfo.tableIds?.length) parts.push(`tableIds=${specInfo.tableIds.join(',')}`);
if (specInfo.tableName) parts.push(`tableName=${specInfo.tableName}`);
@ -497,9 +500,10 @@ export class PostgresTableRepository implements core.ITableRepository {
@core.TraceSpan()
async findOne(
context: core.IExecutionContext,
spec: core.ISpecification<core.Table, core.ITableSpecVisitor>
spec: core.ISpecification<core.Table, core.ITableSpecVisitor>,
options?: Pick<core.TableFindOptions, 'state'>
): Promise<Result<core.Table, DomainError>> {
const visitor = new TableWhereVisitor();
const visitor = new TableWhereVisitor(options?.state);
const acceptResult = spec.accept(visitor);
if (acceptResult.isErr()) return err(acceptResult.error);
@ -516,6 +520,9 @@ export class PostgresTableRepository implements core.ITableRepository {
if (specInfo.tableId) {
attributes[core.TeableSpanAttributes.TABLE_ID] = specInfo.tableId;
}
if (specInfo.incomingReferenceToTableId) {
attributes['teable.incoming_reference_to_table_id'] = specInfo.incomingReferenceToTableId;
}
if (specInfo.baseId) {
attributes['teable.base_id'] = specInfo.baseId;
}
@ -533,48 +540,63 @@ export class PostgresTableRepository implements core.ITableRepository {
try {
const db = resolvePostgresDb(this.db, context);
const effectiveState = options?.state ?? 'active';
const fieldsLateral = db
.selectNoFrom((eb) => [
jsonArrayFrom(
eb
.selectFrom('field')
.select([
'id',
'name',
'description',
'type',
'options',
'meta',
'ai_config',
'cell_value_type',
'is_multiple_cell_value',
'not_null',
'unique',
'is_primary',
'is_computed',
'is_lookup',
'is_conditional_lookup',
'has_error',
'lookup_linked_field_id',
'lookup_options',
'db_field_name',
'db_field_type',
])
.where(sql<boolean>`${sql.ref('field.table_id')} = ${sql.ref('table_meta.id')}`)
.where('deleted_time', 'is', null)
.orderBy('order')
(() => {
let query = eb
.selectFrom('field')
.select([
'id',
'name',
'description',
'type',
'options',
'meta',
'ai_config',
'cell_value_type',
'is_multiple_cell_value',
'not_null',
'unique',
'is_primary',
'is_computed',
'is_lookup',
'is_conditional_lookup',
'has_error',
'lookup_linked_field_id',
'lookup_options',
'db_field_name',
'db_field_type',
])
.where(sql<boolean>`${sql.ref('field.table_id')} = ${sql.ref('table_meta.id')}`)
.orderBy('order');
if (effectiveState === 'active') {
query = query.where('deleted_time', 'is', null);
} else if (effectiveState === 'deleted') {
query = query.where('deleted_time', 'is not', null);
}
return query;
})()
).as('fields'),
])
.as('fields');
const viewsLateral = db
.selectNoFrom((eb) => [
jsonArrayFrom(
eb
.selectFrom('view')
.select(['id', 'name', 'type', 'column_meta', 'sort', 'filter', 'group'])
.where(sql<boolean>`${sql.ref('view.table_id')} = ${sql.ref('table_meta.id')}`)
.where('deleted_time', 'is', null)
.orderBy('order')
(() => {
let query = eb
.selectFrom('view')
.select(['id', 'name', 'type', 'column_meta', 'sort', 'filter', 'group'])
.where(sql<boolean>`${sql.ref('view.table_id')} = ${sql.ref('table_meta.id')}`)
.orderBy('order');
if (effectiveState === 'active') {
query = query.where('deleted_time', 'is', null);
} else if (effectiveState === 'deleted') {
query = query.where('deleted_time', 'is not', null);
}
return query;
})()
).as('views'),
])
.as('views');
@ -617,9 +639,9 @@ export class PostgresTableRepository implements core.ITableRepository {
async find(
context: core.IExecutionContext,
spec: core.ISpecification<core.Table, core.ITableSpecVisitor>,
options?: core.IFindOptions<core.TableSortKey>
options?: core.TableFindOptions
): Promise<Result<ReadonlyArray<core.Table>, DomainError>> {
const visitor = new TableWhereVisitor();
const visitor = new TableWhereVisitor(options?.state);
const acceptResult = spec.accept(visitor);
if (acceptResult.isErr()) return err(acceptResult.error);
@ -629,48 +651,63 @@ export class PostgresTableRepository implements core.ITableRepository {
try {
const db = resolvePostgresDb(this.db, context);
const effectiveState = options?.state ?? 'active';
const fieldsLateral = db
.selectNoFrom((eb) => [
jsonArrayFrom(
eb
.selectFrom('field')
.select([
'id',
'name',
'description',
'type',
'options',
'meta',
'ai_config',
'cell_value_type',
'is_multiple_cell_value',
'not_null',
'unique',
'is_primary',
'is_computed',
'is_lookup',
'is_conditional_lookup',
'has_error',
'lookup_linked_field_id',
'lookup_options',
'db_field_name',
'db_field_type',
])
.where(sql<boolean>`${sql.ref('field.table_id')} = ${sql.ref('table_meta.id')}`)
.where('deleted_time', 'is', null)
.orderBy('order')
(() => {
let query = eb
.selectFrom('field')
.select([
'id',
'name',
'description',
'type',
'options',
'meta',
'ai_config',
'cell_value_type',
'is_multiple_cell_value',
'not_null',
'unique',
'is_primary',
'is_computed',
'is_lookup',
'is_conditional_lookup',
'has_error',
'lookup_linked_field_id',
'lookup_options',
'db_field_name',
'db_field_type',
])
.where(sql<boolean>`${sql.ref('field.table_id')} = ${sql.ref('table_meta.id')}`)
.orderBy('order');
if (effectiveState === 'active') {
query = query.where('deleted_time', 'is', null);
} else if (effectiveState === 'deleted') {
query = query.where('deleted_time', 'is not', null);
}
return query;
})()
).as('fields'),
])
.as('fields');
const viewsLateral = db
.selectNoFrom((eb) => [
jsonArrayFrom(
eb
.selectFrom('view')
.select(['id', 'name', 'type', 'column_meta', 'sort', 'filter', 'group'])
.where(sql<boolean>`${sql.ref('view.table_id')} = ${sql.ref('table_meta.id')}`)
.where('deleted_time', 'is', null)
.orderBy('order')
(() => {
let query = eb
.selectFrom('view')
.select(['id', 'name', 'type', 'column_meta', 'sort', 'filter', 'group'])
.where(sql<boolean>`${sql.ref('view.table_id')} = ${sql.ref('table_meta.id')}`)
.orderBy('order');
if (effectiveState === 'active') {
query = query.where('deleted_time', 'is', null);
} else if (effectiveState === 'deleted') {
query = query.where('deleted_time', 'is not', null);
}
return query;
})()
).as('views'),
])
.as('views');
@ -842,14 +879,52 @@ export class PostgresTableRepository implements core.ITableRepository {
@core.TraceSpan()
async delete(
context: core.IExecutionContext,
table: core.Table
table: core.Table,
options?: core.TableDeleteOptions
): Promise<Result<void, DomainError>> {
const now = new Date();
const actorId = context.actorId.toString();
const tableId = table.id().toString();
const mode = options?.mode ?? 'soft';
try {
const db = resolvePostgresDb(this.db, context);
if (mode === 'permanent') {
const statements: CompiledQuery[] = [
sql`
DELETE FROM "reference"
WHERE "from_field_id" IN (SELECT "id" FROM "field" WHERE "table_id" = ${tableId})
OR "to_field_id" IN (SELECT "id" FROM "field" WHERE "table_id" = ${tableId})
`.compile(db),
];
if (await relationExists(db, 'public.record_trash')) {
statements.push(
sql`DELETE FROM "record_trash" WHERE "table_id" = ${tableId}`.compile(db)
);
}
if (await relationExists(db, 'public.table_trash')) {
statements.push(sql`DELETE FROM "table_trash" WHERE "table_id" = ${tableId}`.compile(db));
}
if (await relationExists(db, 'public.trash')) {
statements.push(
sql`
DELETE FROM "trash"
WHERE "resource_id" = ${tableId} AND "resource_type" = 'table'
`.compile(db)
);
}
statements.push(
sql`DELETE FROM "view" WHERE "table_id" = ${tableId}`.compile(db),
sql`DELETE FROM "field" WHERE "table_id" = ${tableId}`.compile(db),
sql`DELETE FROM "table_meta" WHERE "id" = ${tableId}`.compile(db)
);
await executeCompiledQueries(db, statements);
return ok(undefined);
}
const tableUpdate = await db
.updateTable('table_meta')
.set({
@ -1790,3 +1865,13 @@ const executeCompiledQueries = async <DB>(
await db.executeQuery(statement);
}
};
const relationExists = async <DB>(
db: Kysely<DB> | Transaction<DB>,
relationName: string
): Promise<boolean> => {
const result = await db.executeQuery<{ exists: boolean }>(
sql`SELECT to_regclass(${relationName}) IS NOT NULL as "exists"`.compile(db)
);
return result.rows[0]?.exists === true;
};

View File

@ -6,6 +6,7 @@ import {
TableRemoveFieldSpec,
TableByBaseIdSpec,
TableByIdSpec,
TableByIncomingReferenceToTableSpec,
TableByIdsSpec,
TableByNameLikeSpec,
TableByNameSpec,
@ -276,6 +277,16 @@ export class TableMetaUpdateVisitor
);
}
visitTableByIncomingReferenceToTable(
_: TableByIncomingReferenceToTableSpec
): Result<ReadonlyArray<TableUpdateBuilder>, DomainError> {
return err(
domainError.validation({
message: 'TableByIncomingReferenceToTableSpec is not supported for table updates',
})
);
}
visitTableByIds(_: TableByIdsSpec): Result<ReadonlyArray<TableUpdateBuilder>, DomainError> {
return err(
domainError.validation({ message: 'TableByIdsSpec is not supported for table updates' })

View File

@ -10,6 +10,7 @@ import {
TableRenameSpec,
TableByBaseIdSpec,
TableByIdSpec,
TableByIncomingReferenceToTableSpec,
TableByIdsSpec,
TableByNameLikeSpec,
TableByNameSpec,
@ -62,9 +63,10 @@ import {
type UpdateRollupShowAsSpec,
type UpdateRollupTimeZoneSpec,
type RemoveSymmetricLinkFieldSpec,
type TableQueryState,
} from '@teable/v2-core';
import type { V1TeableDatabase } from '@teable/v2-postgres-schema';
import type { Expression, ExpressionBuilder, SqlBool } from 'kysely';
import { sql, type Expression, type ExpressionBuilder, type SqlBool } from 'kysely';
import { err } from 'neverthrow';
import type { Result } from 'neverthrow';
@ -75,6 +77,7 @@ export type ITableMetaWhere = (
export type TableWhereSpecInfo = {
readonly specName?: string;
readonly tableId?: string;
readonly incomingReferenceToTableId?: string;
readonly baseId?: string;
readonly tableIds?: ReadonlyArray<string>;
readonly tableName?: string;
@ -87,9 +90,13 @@ export class TableWhereVisitor
{
private specInfo: TableWhereSpecInfo = {};
constructor() {
constructor(private readonly state: TableQueryState = 'active') {
super();
this.addCond((eb) => eb.eb('deleted_time', 'is', null));
if (state === 'active') {
this.addCond((eb) => eb.eb('deleted_time', 'is', null));
} else if (state === 'deleted') {
this.addCond((eb) => eb.eb('deleted_time', 'is not', null));
}
}
describe(): TableWhereSpecInfo {
@ -169,6 +176,32 @@ export class TableWhereVisitor
return this.addCond(cond).map(() => cond);
}
visitTableByIncomingReferenceToTable(
spec: TableByIncomingReferenceToTableSpec
): Result<ITableMetaWhere, DomainError> {
const incomingReferenceToTableId = spec.tableId().toString();
const targetFieldDeletedPredicate =
this.state === 'deleted'
? sql`"target_field"."deleted_time" is not null`
: sql`"target_field"."deleted_time" is null`;
const cond: ITableMetaWhere = () => sql<boolean>`
exists (
select 1
from "reference"
inner join "field" as "source_field" on "source_field"."id" = "reference"."from_field_id"
inner join "field" as "target_field" on "target_field"."id" = "reference"."to_field_id"
where "source_field"."table_id" = ${incomingReferenceToTableId}
and ${targetFieldDeletedPredicate}
and "target_field"."table_id" = ${sql.ref('table_meta.id')}
)
`;
this.mergeSpecInfo({
specName: 'TableByIncomingReferenceToTableSpec',
incomingReferenceToTableId,
});
return this.addCond(cond).map(() => cond);
}
visitTableByIds(spec: TableByIdsSpec): Result<ITableMetaWhere, DomainError> {
const ids = spec.tableIds().map((id) => id.toString());
if (ids.length === 0)
@ -633,7 +666,7 @@ export class TableWhereVisitor
}
clone(): this {
return new TableWhereVisitor() as this;
return new TableWhereVisitor(this.state) as this;
}
and(left: ITableMetaWhere, right: ITableMetaWhere): ITableMetaWhere {

View File

@ -561,7 +561,15 @@ export class PostgresTableSchemaRepository implements ITableSchemaRepository {
}
@TraceSpan()
async delete(context: IExecutionContext, table: Table): Promise<Result<void, DomainError>> {
async delete(
context: IExecutionContext,
table: Table,
options?: { mode?: 'soft' | 'permanent' }
): Promise<Result<void, DomainError>> {
if ((options?.mode ?? 'soft') !== 'permanent') {
return ok(undefined);
}
const repository = this;
return await safeTry<void, DomainError>(async function* () {
const { schema, tableName } = yield* table

View File

@ -7,6 +7,7 @@ import type {
TableAddSelectOptionsSpec,
TableByBaseIdSpec,
TableByIdSpec,
TableByIncomingReferenceToTableSpec,
TableByIdsSpec,
TableByNameLikeSpec,
TableByNameSpec,
@ -150,6 +151,12 @@ export class DependencyChangeDetectorVisitor implements ITableSpecVisitor<void>
return ok(undefined);
}
visitTableByIncomingReferenceToTable(
_spec: TableByIncomingReferenceToTableSpec
): Result<void, DomainError> {
return ok(undefined);
}
visitTableByIds(_spec: TableByIdsSpec): Result<void, DomainError> {
return ok(undefined);
}

View File

@ -7,6 +7,7 @@ import type {
TableAddSelectOptionsSpec,
TableByBaseIdSpec,
TableByIdSpec,
TableByIncomingReferenceToTableSpec,
TableByIdsSpec,
TableByNameLikeSpec,
TableByNameSpec,
@ -159,6 +160,12 @@ export class FieldValueChangeCollectorVisitor implements ITableSpecVisitor<void>
return ok(undefined);
}
visitTableByIncomingReferenceToTable(
_spec: TableByIncomingReferenceToTableSpec
): Result<void, DomainError> {
return ok(undefined);
}
visitTableByIds(_spec: TableByIdsSpec): Result<void, DomainError> {
return ok(undefined);
}

View File

@ -7,6 +7,7 @@ import type {
TableAddSelectOptionsSpec,
TableByBaseIdSpec,
TableByIdSpec,
TableByIncomingReferenceToTableSpec,
TableByIdsSpec,
TableByNameLikeSpec,
TableByNameSpec,
@ -116,6 +117,12 @@ export class TableAddFieldCollectorVisitor implements ITableSpecVisitor<void> {
return ok(undefined);
}
visitTableByIncomingReferenceToTable(
_spec: TableByIncomingReferenceToTableSpec
): Result<void, DomainError> {
return ok(undefined);
}
visitTableByIds(_spec: TableByIdsSpec): Result<void, DomainError> {
return ok(undefined);
}

View File

@ -12,6 +12,7 @@ import type {
TableRemoveFieldSpec,
TableByBaseIdSpec,
TableByIdSpec,
TableByIncomingReferenceToTableSpec,
TableByIdsSpec,
TableByNameLikeSpec,
TableByNameSpec,
@ -524,6 +525,16 @@ export class TableSchemaUpdateVisitor
);
}
visitTableByIncomingReferenceToTable(
_: TableByIncomingReferenceToTableSpec
): Result<ReadonlyArray<TableSchemaStatementBuilder>, DomainError> {
return err(
domainError.validation({
message: 'TableByIncomingReferenceToTableSpec is not supported for table schema updates',
})
);
}
visitTableByIds(
_: TableByIdsSpec
): Result<ReadonlyArray<TableSchemaStatementBuilder>, DomainError> {

View File

@ -877,6 +877,19 @@ describe('FieldTypeConversionVisitor', () => {
expect(mappingSql).toContain('WHERE f."__id" = src.foreign_id');
});
it('should push link -> text conversion into a single SQL mapping statement', () => {
const sqls = getConversionSqls(mkManyOneLinkField(), mkTextField());
const mappingSql = sqls.find((sql) => sql.includes('DO $v2_link_to_text$'));
expect(mappingSql).toBeDefined();
expect(mappingSql).toContain('WITH parsed AS');
expect(mappingSql).toContain('mapped AS');
expect(mappingSql).toContain('reduced AS');
expect(mappingSql).toContain('JOIN %I.%I AS f ON f.__id = p.link_id');
expect(mappingSql).toContain('string_agg(title');
expect(mappingSql).not.toContain('tmp_computed_dirty');
expect(mappingSql).not.toContain('record_id');
});
it('should preserve title-based mapping for link -> link when foreign table changes', () => {
const oldLink = mkManyOneLinkFieldWithForeign('srcLink', `tbl${'b'.repeat(16)}`);
const newLink = mkManyOneLinkFieldWithForeign('tgtLink', `tbl${'c'.repeat(16)}`);

View File

@ -0,0 +1,156 @@
import { inject, injectable } from '@teable/v2-di';
import { err, ok, safeTry } from 'neverthrow';
import type { Result } from 'neverthrow';
import {
DeleteTableCommand,
DeleteTableHandler,
FieldCrossTableUpdateSideEffectService,
FieldUpdateSideEffectService,
LinkFieldUpdateSideEffectService,
NoopLogger,
TableByIdSpec,
TableDeletionSideEffectService,
type DomainError,
type IExecutionContext,
type ITableRepository,
v2CoreTokens,
} from '@teable/v2-core';
import {
v2RecordRepositoryPostgresTokens,
type ComputedUpdatePlanner,
} from '@teable/v2-adapter-table-repository-postgres';
import { formulaSqlPgTokens, type IPgTypeValidationStrategy } from '@teable/v2-formula-sql-pg';
import type { Kysely } from 'kysely';
import type { V1TeableDatabase } from '@teable/v2-postgres-schema';
import type { ICommandAnalyzer } from './ICommandAnalyzer';
import {
buildFieldSqlExplains,
createFieldExplainDryRunEnvironment,
} from './FieldCommandAnalyzeHelpers';
import type { CommandExplainInfo, ExplainOptions, ExplainResult } from '../types';
import { DEFAULT_EXPLAIN_OPTIONS } from '../types';
import { v2CommandExplainTokens } from '../di/tokens';
import { ComplexityCalculator } from '../utils/ComplexityCalculator';
import { NoopEventBus, NoopUnitOfWork } from '../utils/FieldCommandExplainHarness';
import { SqlExplainRunner } from '../utils/SqlExplainRunner';
@injectable()
export class DeleteTableAnalyzer implements ICommandAnalyzer<DeleteTableCommand> {
constructor(
@inject(v2RecordRepositoryPostgresTokens.db)
private readonly db: Kysely<V1TeableDatabase>,
@inject(v2CoreTokens.tableRepository)
private readonly tableRepository: ITableRepository,
@inject(v2RecordRepositoryPostgresTokens.computedUpdatePlanner)
private readonly computedUpdatePlanner: ComputedUpdatePlanner,
@inject(v2CommandExplainTokens.sqlExplainRunner)
private readonly sqlExplainRunner: SqlExplainRunner,
@inject(v2CommandExplainTokens.complexityCalculator)
private readonly complexityCalculator: ComplexityCalculator,
@inject(formulaSqlPgTokens.typeValidationStrategy)
private readonly typeValidationStrategy: IPgTypeValidationStrategy
) {}
async analyze(
context: IExecutionContext,
command: DeleteTableCommand,
options: ExplainOptions,
startTime: number
): Promise<Result<ExplainResult, DomainError>> {
const analyzer = this;
const mergedOptions = { ...DEFAULT_EXPLAIN_OPTIONS, ...options };
return safeTry<ExplainResult, DomainError>(async function* () {
const beforeTableResult = await analyzer.tableRepository.findOne(
context,
TableByIdSpec.create(command.tableId)
);
if (beforeTableResult.isErr()) {
return err(beforeTableResult.error);
}
const beforeTable = beforeTableResult.value;
const dryRun = createFieldExplainDryRunEnvironment({
db: analyzer.db,
tableRepository: analyzer.tableRepository,
computedUpdatePlanner: analyzer.computedUpdatePlanner,
typeValidationStrategy: analyzer.typeValidationStrategy,
});
const fieldCrossTableUpdateSideEffectService = new FieldCrossTableUpdateSideEffectService(
dryRun.overlayTableRepository,
dryRun.tableUpdateFlow
);
const linkFieldUpdateSideEffectService = new LinkFieldUpdateSideEffectService(
dryRun.tableUpdateFlow
);
const fieldUpdateSideEffectService = new FieldUpdateSideEffectService(
dryRun.tableUpdateFlow,
dryRun.overlayTableRepository,
linkFieldUpdateSideEffectService,
fieldCrossTableUpdateSideEffectService
);
const tableDeletionSideEffectService = new TableDeletionSideEffectService(
dryRun.overlayTableRepository,
dryRun.tableUpdateFlow,
fieldUpdateSideEffectService
);
const handler = new DeleteTableHandler(
dryRun.overlayTableRepository,
dryRun.captureTableSchemaRepository,
tableDeletionSideEffectService,
new NoopEventBus(),
new NoopLogger(),
new NoopUnitOfWork()
);
const commandResult = await handler.handle(context, command);
if (commandResult.isErr()) {
return err(commandResult.error);
}
const deletedTable = commandResult.value.table;
const commandInfo: CommandExplainInfo = {
type: 'DeleteTable',
tableId: deletedTable.id().toString(),
tableName: beforeTable.name().toString(),
recordIds: [],
changeType: 'delete',
};
const sqlExplainStartTime = Date.now();
const sqlExplains = mergedOptions.includeSql
? await buildFieldSqlExplains(
analyzer.sqlExplainRunner,
analyzer.db,
dryRun.captureTableSchemaRepository.getStatements(),
mergedOptions.analyze
)
: [];
const sqlExplainMs = Date.now() - sqlExplainStartTime;
const complexity = analyzer.complexityCalculator.calculate({
commandInfo,
computedImpact: null,
sqlExplains,
});
return ok({
command: commandInfo,
computedImpact: null,
computedLocks: null,
linkLocks: null,
sqlExplains,
complexity,
timing: {
totalMs: Date.now() - startTime,
dependencyGraphMs: 0,
planningMs: 0,
sqlExplainMs,
},
});
});
}
}

View File

@ -4,5 +4,6 @@ export * from './UpdateRecordAnalyzer';
export * from './CreateRecordAnalyzer';
export * from './UpdateFieldAnalyzer';
export * from './DeleteFieldAnalyzer';
export * from './DeleteTableAnalyzer';
export * from './DeleteRecordsAnalyzer';
export * from './PasteCommandAnalyzer';

View File

@ -10,6 +10,7 @@ import { UpdateRecordAnalyzer } from '../analyzers/UpdateRecordAnalyzer';
import { CreateRecordAnalyzer } from '../analyzers/CreateRecordAnalyzer';
import { UpdateFieldAnalyzer } from '../analyzers/UpdateFieldAnalyzer';
import { DeleteFieldAnalyzer } from '../analyzers/DeleteFieldAnalyzer';
import { DeleteTableAnalyzer } from '../analyzers/DeleteTableAnalyzer';
import { DeleteRecordsAnalyzer } from '../analyzers/DeleteRecordsAnalyzer';
import { PasteCommandAnalyzer } from '../analyzers/PasteCommandAnalyzer';
@ -35,6 +36,9 @@ export const registerCommandExplainModule = (container: DependencyContainer): vo
container.register(v2CommandExplainTokens.deleteFieldAnalyzer, DeleteFieldAnalyzer, {
lifecycle: Lifecycle.Singleton,
});
container.register(v2CommandExplainTokens.deleteTableAnalyzer, DeleteTableAnalyzer, {
lifecycle: Lifecycle.Singleton,
});
container.register(v2CommandExplainTokens.updateRecordAnalyzer, UpdateRecordAnalyzer, {
lifecycle: Lifecycle.Singleton,
});

View File

@ -8,6 +8,7 @@ export const v2CommandExplainTokens = {
createFieldAnalyzer: Symbol('v2.commandExplain.createFieldAnalyzer'),
updateFieldAnalyzer: Symbol('v2.commandExplain.updateFieldAnalyzer'),
deleteFieldAnalyzer: Symbol('v2.commandExplain.deleteFieldAnalyzer'),
deleteTableAnalyzer: Symbol('v2.commandExplain.deleteTableAnalyzer'),
updateRecordAnalyzer: Symbol('v2.commandExplain.updateRecordAnalyzer'),
createRecordAnalyzer: Symbol('v2.commandExplain.createRecordAnalyzer'),
deleteRecordsAnalyzer: Symbol('v2.commandExplain.deleteRecordsAnalyzer'),

View File

@ -6,6 +6,7 @@ import {
type IExecutionContext,
domainError,
DeleteFieldCommand,
DeleteTableCommand,
UpdateRecordCommand,
CreateRecordCommand,
CreateFieldCommand,
@ -22,6 +23,7 @@ import type { UpdateRecordAnalyzer } from '../analyzers/UpdateRecordAnalyzer';
import type { CreateRecordAnalyzer } from '../analyzers/CreateRecordAnalyzer';
import type { UpdateFieldAnalyzer } from '../analyzers/UpdateFieldAnalyzer';
import type { DeleteFieldAnalyzer } from '../analyzers/DeleteFieldAnalyzer';
import type { DeleteTableAnalyzer } from '../analyzers/DeleteTableAnalyzer';
import type { DeleteRecordsAnalyzer } from '../analyzers/DeleteRecordsAnalyzer';
import type { PasteCommandAnalyzer } from '../analyzers/PasteCommandAnalyzer';
@ -49,6 +51,8 @@ export class ExplainService implements IExplainService {
private readonly updateFieldAnalyzer: UpdateFieldAnalyzer,
@inject(v2CommandExplainTokens.deleteFieldAnalyzer)
private readonly deleteFieldAnalyzer: DeleteFieldAnalyzer,
@inject(v2CommandExplainTokens.deleteTableAnalyzer)
private readonly deleteTableAnalyzer: DeleteTableAnalyzer,
@inject(v2CommandExplainTokens.updateRecordAnalyzer)
private readonly updateRecordAnalyzer: UpdateRecordAnalyzer,
@inject(v2CommandExplainTokens.createRecordAnalyzer)
@ -88,6 +92,10 @@ export class ExplainService implements IExplainService {
return this.deleteFieldAnalyzer.analyze(context, command, mergedOptions, startTime);
}
if (command instanceof DeleteTableCommand) {
return this.deleteTableAnalyzer.analyze(context, command, mergedOptions, startTime);
}
if (command instanceof UpdateRecordCommand) {
return this.updateRecordAnalyzer.analyze(context, command, mergedOptions, startTime);
}

View File

@ -10,6 +10,7 @@ export type CommandExplainInfo = {
| 'CreateField'
| 'UpdateField'
| 'DeleteField'
| 'DeleteTable'
| 'CreateRecord'
| 'UpdateRecord'
| 'DeleteRecords'

View File

@ -276,7 +276,31 @@ export class CaptureTableSchemaRepository implements ITableSchemaRepository {
});
}
async delete(_context: IExecutionContext, _table: Table): Promise<Result<void, DomainError>> {
async delete(
_context: IExecutionContext,
table: Table,
options?: { mode?: 'soft' | 'permanent' }
): Promise<Result<void, DomainError>> {
if ((options?.mode ?? 'soft') !== 'permanent') {
return ok(undefined);
}
const dbTableNameResult = table
.dbTableName()
.andThen((name) => name.split({ defaultSchema: null }));
if (dbTableNameResult.isErr()) {
return err(dbTableNameResult.error);
}
const { schema, tableName } = dbTableNameResult.value;
const schemaBuilder = schema
? this.options.db.schema.withSchema(schema)
: this.options.db.schema;
this.captureCompiledStatement(
`Schema delete: table ${table.name().toString()}`,
schemaBuilder.dropTable(tableName).ifExists().compile()
);
return ok(undefined);
}

View File

@ -6,6 +6,7 @@ import {
CreateRecordCommand,
DeleteFieldCommand,
DeleteRecordsCommand,
DeleteTableCommand,
RecordId,
TableId,
UpdateFieldCommand,
@ -75,6 +76,15 @@ export interface IExplainDeleteRecordsInput {
includeLocks?: boolean;
}
export interface IExplainDeleteTableInput {
baseId: string;
tableId: string;
analyze?: boolean;
includeSql?: boolean;
includeGraph?: boolean;
includeLocks?: boolean;
}
export const executeExplainCreateFieldEndpoint = async (
context: IExecutionContext,
input: IExplainCreateFieldInput,
@ -189,6 +199,44 @@ export const executeExplainDeleteFieldEndpoint = async (
};
};
export const executeExplainDeleteTableEndpoint = async (
context: IExecutionContext,
input: IExplainDeleteTableInput,
explainService: IExplainService
): Promise<IExplainEndpointResult> => {
const commandResult = DeleteTableCommand.create(input);
if (commandResult.isErr()) {
const error = commandResult.error;
return {
status: mapDomainErrorToHttpStatus(error),
body: { ok: false, error: mapDomainErrorToHttpError(error) },
};
}
const result = await explainService.explain(context, commandResult.value, {
analyze: input.analyze ?? false,
includeSql: input.includeSql ?? true,
includeGraph: input.includeGraph ?? false,
includeLocks: input.includeLocks ?? true,
});
if (result.isErr()) {
const error = result.error;
return {
status: mapDomainErrorToHttpStatus(error),
body: { ok: false, error: mapDomainErrorToHttpError(error) },
};
}
return {
status: 200,
body: {
ok: true,
data: result.value as IExplainResultDto,
},
};
};
export const executeExplainCreateRecordEndpoint = async (
context: IExecutionContext,
input: IExplainCreateRecordInput,

View File

@ -13,20 +13,23 @@ import {
import { executeCreateBaseEndpoint } from './handlers/bases/createBase';
import { executeListBasesEndpoint } from './handlers/bases/listBases';
import { executeClearEndpoint } from './handlers/tables/clear';
import { executeCreateFieldEndpoint } from './handlers/tables/createField';
import { executeUpdateFieldEndpoint } from './handlers/tables/updateField';
import { executeCreateRecordEndpoint } from './handlers/tables/createRecord';
import { executeSubmitRecordEndpoint } from './handlers/tables/submitRecord';
import { executeCreateRecordsEndpoint } from './handlers/tables/createRecords';
import { executeCreateTableEndpoint } from './handlers/tables/createTable';
import { executeCreateTablesEndpoint } from './handlers/tables/createTables';
import { executeDeleteByRangeEndpoint } from './handlers/tables/deleteByRange';
import { executeDeleteFieldEndpoint } from './handlers/tables/deleteField';
import { executeDeleteRecordsEndpoint } from './handlers/tables/deleteRecords';
import { executeDeleteTableEndpoint } from './handlers/tables/deleteTable';
import { executeDuplicateFieldEndpoint } from './handlers/tables/duplicateField';
import { executeDuplicateRecordEndpoint } from './handlers/tables/duplicateRecord';
import {
executeExplainCreateFieldEndpoint,
executeExplainCreateRecordEndpoint,
executeExplainDeleteFieldEndpoint,
executeExplainDeleteTableEndpoint,
executeExplainDeleteRecordsEndpoint,
executeExplainUpdateFieldEndpoint,
executeExplainUpdateRecordEndpoint,
@ -38,14 +41,12 @@ import { executeImportRecordsEndpoint } from './handlers/tables/importRecords';
import { executeListTableRecordsEndpoint } from './handlers/tables/listTableRecords';
import { executeListTablesEndpoint } from './handlers/tables/listTables';
import { executePasteEndpoint } from './handlers/tables/paste';
import { executeClearEndpoint } from './handlers/tables/clear';
import { executeDeleteByRangeEndpoint } from './handlers/tables/deleteByRange';
import { executeRenameTableEndpoint } from './handlers/tables/renameTable';
import { executeReorderRecordsEndpoint } from './handlers/tables/reorderRecords';
import { executeSubmitRecordEndpoint } from './handlers/tables/submitRecord';
import { executeUpdateFieldEndpoint } from './handlers/tables/updateField';
import { executeUpdateRecordEndpoint } from './handlers/tables/updateRecord';
import { executeUpdateRecordsEndpoint } from './handlers/tables/updateRecords';
import { executeReorderRecordsEndpoint } from './handlers/tables/reorderRecords';
import { executeDuplicateFieldEndpoint } from './handlers/tables/duplicateField';
import { executeDuplicateRecordEndpoint } from './handlers/tables/duplicateRecord';
export interface IV2OrpcRouterOptions {
createContainer?: () => IHandlerResolver | Promise<IHandlerResolver>;
@ -986,6 +987,36 @@ export const createV2OrpcRouter = (options: IV2OrpcRouterOptions = {}) => {
throwDomainError('INTERNAL_SERVER_ERROR', result.body.error);
});
const tablesExplainDeleteTable = os.tables.explainDeleteTable.handler(async ({ input }) => {
const container = await resolveContainer();
let executionContext: IExecutionContext;
try {
executionContext = await createExecutionContext();
} catch {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: executionContextErrorMessage,
});
}
const explainService = container.resolve<IExplainService>(
v2CommandExplainTokens.explainService
);
const result = await executeExplainDeleteTableEndpoint(executionContext, input, explainService);
if (result.status === 200) return result.body;
if (result.status === 400) {
throwDomainError('BAD_REQUEST', result.body.error);
}
if (result.status === 404) {
throwDomainError('NOT_FOUND', result.body.error);
}
throwDomainError('INTERNAL_SERVER_ERROR', result.body.error);
});
const tablesExplainUpdateRecord = os.tables.explainUpdateRecord.handler(async ({ input }) => {
const container = await resolveContainer();
@ -1080,6 +1111,7 @@ export const createV2OrpcRouter = (options: IV2OrpcRouterOptions = {}) => {
deleteRecords: tablesDeleteRecords,
deleteField: tablesDeleteField,
explainDeleteField: tablesExplainDeleteField,
explainDeleteTable: tablesExplainDeleteTable,
delete: tablesDelete,
getById: tablesGetById,
getRecord: tablesGetRecord,

View File

@ -48,6 +48,7 @@ import {
explainCreateFieldInputSchema,
explainCreateRecordInputSchema,
explainDeleteFieldInputSchema,
explainDeleteTableInputSchema,
explainDeleteRecordsInputSchema,
explainOkResponseSchema,
explainUpdateFieldInputSchema,
@ -83,6 +84,7 @@ const TABLES_EXPLAIN_CREATE_RECORD_PATH = '/tables/explainCreateRecord';
const TABLES_EXPLAIN_UPDATE_FIELD_PATH = '/tables/explainUpdateField';
const TABLES_EXPLAIN_UPDATE_RECORD_PATH = '/tables/explainUpdateRecord';
const TABLES_EXPLAIN_DELETE_FIELD_PATH = '/tables/explainDeleteField';
const TABLES_EXPLAIN_DELETE_TABLE_PATH = '/tables/explainDeleteTable';
const TABLES_EXPLAIN_DELETE_RECORDS_PATH = '/tables/explainDeleteRecords';
const TABLES_GET_PATH = '/tables/get';
const TABLES_GET_RECORD_PATH = '/tables/getRecord';
@ -255,6 +257,16 @@ export const v2Contract: AnyContractRouter = {
})
.input(explainDeleteFieldInputSchema)
.output(explainOkResponseSchema),
explainDeleteTable: oc
.route({
method: 'POST',
path: TABLES_EXPLAIN_DELETE_TABLE_PATH,
successStatus: 200,
summary: 'Explain delete table',
tags: ['tables'],
})
.input(explainDeleteTableInputSchema)
.output(explainOkResponseSchema),
delete: oc
.route({
method: 'DELETE',

View File

@ -1,9 +1,10 @@
import { z } from 'zod';
import {
createFieldInputSchema,
deleteFieldInputSchema,
deleteTableInputSchema,
updateFieldInputSchema,
} from '@teable/v2-core';
import { z } from 'zod';
import {
apiOkResponseDtoSchema,
@ -36,6 +37,13 @@ export const explainDeleteFieldInputSchema = deleteFieldInputSchema.extend({
includeLocks: z.boolean().optional().default(true),
});
export const explainDeleteTableInputSchema = deleteTableInputSchema.extend({
analyze: z.boolean().optional().default(false),
includeSql: z.boolean().optional().default(true),
includeGraph: z.boolean().optional().default(false),
includeLocks: z.boolean().optional().default(true),
});
export const explainCreateRecordInputSchema = z.object({
tableId: z.string(),
fields: z.record(z.string(), z.unknown()),
@ -67,6 +75,7 @@ export const explainDeleteRecordsInputSchema = z.object({
export type IExplainCreateFieldInput = z.infer<typeof explainCreateFieldInputSchema>;
export type IExplainUpdateFieldInput = z.infer<typeof explainUpdateFieldInputSchema>;
export type IExplainDeleteFieldInput = z.infer<typeof explainDeleteFieldInputSchema>;
export type IExplainDeleteTableInput = z.infer<typeof explainDeleteTableInputSchema>;
export type IExplainCreateRecordInput = z.infer<typeof explainCreateRecordInputSchema>;
export type IExplainUpdateRecordInput = z.infer<typeof explainUpdateRecordInputSchema>;
export type IExplainDeleteRecordsInput = z.infer<typeof explainDeleteRecordsInputSchema>;
@ -344,6 +353,7 @@ const commandExplainInfoSchema = z.object({
'CreateField',
'UpdateField',
'DeleteField',
'DeleteTable',
'CreateRecord',
'UpdateRecord',
'DeleteRecords',

View File

@ -17,6 +17,9 @@ Declaration: If the folder I belong to changes, please update me, especially cor
effects after field deletion (e.g. remove symmetric link fields).
- `ForeignTableLoaderService.ts` - Role: application service; Purpose: load foreign tables once and
validate missing references.
- `TableDeletionSideEffectService.ts` - Role: application service; Purpose: dispatch explicit
`OnTeableTableDeleted` reactions in other tables before deleting a table, including link-to-text
conversion and dependent metadata cleanup.
- `TableQueryService.ts` - Role: application service; Purpose: common table lookup operations
(getById, getByIdInBase, exists) used across CommandHandlers and QueryHandlers.
- `TableUpdateFlow.ts` - Role: application service; Purpose: shared table update workflow (mutate + persist + publish).

View File

@ -0,0 +1,445 @@
import { err, ok } from 'neverthrow';
import type { Result } from 'neverthrow';
import { describe, expect, it } from 'vitest';
import { BaseId } from '../../domain/base/BaseId';
import { ActorId } from '../../domain/shared/ActorId';
import { domainError, type DomainError } from '../../domain/shared/DomainError';
import type { IDomainEvent } from '../../domain/shared/DomainEvent';
import type { ISpecification } from '../../domain/shared/specification/ISpecification';
import { FieldId } from '../../domain/table/fields/FieldId';
import { FieldName } from '../../domain/table/fields/FieldName';
import { ConditionalLookupField } from '../../domain/table/fields/types/ConditionalLookupField';
import { ConditionalLookupOptions } from '../../domain/table/fields/types/ConditionalLookupOptions';
import { ConditionalRollupConfig } from '../../domain/table/fields/types/ConditionalRollupConfig';
import { ConditionalRollupField } from '../../domain/table/fields/types/ConditionalRollupField';
import { LinkField } from '../../domain/table/fields/types/LinkField';
import { LinkFieldConfig } from '../../domain/table/fields/types/LinkFieldConfig';
import { LookupField } from '../../domain/table/fields/types/LookupField';
import { LookupOptions } from '../../domain/table/fields/types/LookupOptions';
import { RollupExpression } from '../../domain/table/fields/types/RollupExpression';
import { RollupField } from '../../domain/table/fields/types/RollupField';
import { RollupFieldConfig } from '../../domain/table/fields/types/RollupFieldConfig';
import { SingleLineTextField } from '../../domain/table/fields/types/SingleLineTextField';
import type { ITableSpecVisitor } from '../../domain/table/specs/ITableSpecVisitor';
import { Table as TableAggregate } from '../../domain/table/Table';
import type { Table } from '../../domain/table/Table';
import { TableName } from '../../domain/table/TableName';
import type { TableSortKey } from '../../domain/table/TableSortKey';
import type { IEventBus } from '../../ports/EventBus';
import type { IExecutionContext, IUnitOfWorkTransaction } from '../../ports/ExecutionContext';
import type { IFindOptions } from '../../ports/RepositoryQuery';
import type { ITableRepository } from '../../ports/TableRepository';
import type { ITableSchemaRepository } from '../../ports/TableSchemaRepository';
import type { IUnitOfWork, UnitOfWorkOperation } from '../../ports/UnitOfWork';
import { FieldCrossTableUpdateSideEffectService } from './FieldCrossTableUpdateSideEffectService';
import { FieldUpdateSideEffectService } from './FieldUpdateSideEffectService';
import { LinkFieldUpdateSideEffectService } from './LinkFieldUpdateSideEffectService';
import { TableDeletionSideEffectService } from './TableDeletionSideEffectService';
import { TableUpdateFlow } from './TableUpdateFlow';
const createContext = (): IExecutionContext => ({
actorId: ActorId.create('system')._unsafeUnwrap(),
});
const createDeletedTable = () => {
const baseId = BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap();
const table = TableAggregate.builder()
.withBaseId(baseId)
.withName(TableName.create('Source')._unsafeUnwrap());
table
.field()
.singleLineText()
.withName(FieldName.create('Name')._unsafeUnwrap())
.primary()
.done();
table.field().singleLineText().withName(FieldName.create('Value')._unsafeUnwrap()).done();
table.field().number().withName(FieldName.create('Score')._unsafeUnwrap()).done();
table.view().defaultGrid().done();
return table.build()._unsafeUnwrap();
};
const createHostTable = (
deletedTable: Table,
options?: {
baseId?: BaseId;
crossBase?: boolean;
}
) => {
const primaryFieldId = deletedTable.primaryFieldId();
const valueField = deletedTable.getFields((field) => field.name().toString() === 'Value').at(0);
const scoreField = deletedTable.getFields((field) => field.name().toString() === 'Score').at(0);
const linkFieldId = FieldId.create(`fld${'b'.repeat(16)}`)._unsafeUnwrap();
const lookupFieldId = FieldId.create(`fld${'c'.repeat(16)}`)._unsafeUnwrap();
const lookupInnerFieldId = FieldId.create(`fld${'d'.repeat(16)}`)._unsafeUnwrap();
const rollupFieldId = FieldId.create(`fld${'e'.repeat(16)}`)._unsafeUnwrap();
const conditionalLookupFieldId = FieldId.create(`fld${'f'.repeat(16)}`)._unsafeUnwrap();
const conditionalLookupInnerFieldId = FieldId.create(`fld${'g'.repeat(16)}`)._unsafeUnwrap();
const conditionalRollupFieldId = FieldId.create(`fld${'h'.repeat(16)}`)._unsafeUnwrap();
if (!valueField || !scoreField) throw new Error('Missing deleted source fields');
const hostBaseId = options?.baseId ?? deletedTable.baseId();
const isCrossBase = options?.crossBase ?? false;
const host = TableAggregate.builder()
.withBaseId(hostBaseId)
.withName(TableName.create('Host')._unsafeUnwrap());
host
.field()
.singleLineText()
.withName(FieldName.create('Host Name')._unsafeUnwrap())
.primary()
.done();
host.view().defaultGrid().done();
let builtHost = host.build()._unsafeUnwrap();
builtHost = builtHost
.addField(
LinkField.create({
id: linkFieldId,
name: FieldName.create('Link')._unsafeUnwrap(),
config: LinkFieldConfig.create({
...(isCrossBase ? { baseId: deletedTable.baseId().toString() } : {}),
relationship: 'manyMany',
foreignTableId: deletedTable.id().toString(),
lookupFieldId: primaryFieldId.toString(),
isOneWay: true,
})._unsafeUnwrap(),
})._unsafeUnwrap(),
{ foreignTables: [deletedTable] }
)
._unsafeUnwrap();
builtHost = builtHost
.addField(
LookupField.create({
id: lookupFieldId,
name: FieldName.create('Lookup')._unsafeUnwrap(),
innerField: SingleLineTextField.create({
id: lookupInnerFieldId,
name: FieldName.create('Lookup Inner')._unsafeUnwrap(),
})._unsafeUnwrap(),
lookupOptions: LookupOptions.create({
linkFieldId: linkFieldId.toString(),
foreignTableId: deletedTable.id().toString(),
lookupFieldId: valueField.id().toString(),
})._unsafeUnwrap(),
})._unsafeUnwrap(),
{ foreignTables: [deletedTable] }
)
._unsafeUnwrap();
builtHost = builtHost
.addField(
RollupField.create({
id: rollupFieldId,
name: FieldName.create('Rollup')._unsafeUnwrap(),
config: RollupFieldConfig.create({
linkFieldId: linkFieldId.toString(),
foreignTableId: deletedTable.id().toString(),
lookupFieldId: valueField.id().toString(),
})._unsafeUnwrap(),
expression: RollupExpression.create('countall({values})')._unsafeUnwrap(),
valuesField: valueField,
})._unsafeUnwrap(),
{ foreignTables: [deletedTable] }
)
._unsafeUnwrap();
builtHost = builtHost
.addField(
ConditionalLookupField.create({
id: conditionalLookupFieldId,
name: FieldName.create('Conditional Lookup')._unsafeUnwrap(),
innerField: SingleLineTextField.create({
id: conditionalLookupInnerFieldId,
name: FieldName.create('Conditional Lookup Inner')._unsafeUnwrap(),
})._unsafeUnwrap(),
conditionalLookupOptions: ConditionalLookupOptions.create({
foreignTableId: deletedTable.id().toString(),
lookupFieldId: valueField.id().toString(),
condition: {
filter: {
conjunction: 'and',
filterSet: [{ fieldId: valueField.id().toString(), operator: 'isNotEmpty' }],
},
sort: { fieldId: scoreField.id().toString(), order: 'asc' },
limit: 1,
},
})._unsafeUnwrap(),
})._unsafeUnwrap(),
{ foreignTables: [deletedTable] }
)
._unsafeUnwrap();
builtHost = builtHost
.addField(
ConditionalRollupField.create({
id: conditionalRollupFieldId,
name: FieldName.create('Conditional Rollup')._unsafeUnwrap(),
config: ConditionalRollupConfig.create({
foreignTableId: deletedTable.id().toString(),
lookupFieldId: scoreField.id().toString(),
condition: {
filter: {
conjunction: 'and',
filterSet: [{ fieldId: valueField.id().toString(), operator: 'isNotEmpty' }],
},
sort: { fieldId: scoreField.id().toString(), order: 'asc' },
limit: 1,
},
})._unsafeUnwrap(),
expression: RollupExpression.create('sum({values})')._unsafeUnwrap(),
valuesField: scoreField,
})._unsafeUnwrap(),
{ foreignTables: [deletedTable] }
)
._unsafeUnwrap();
return builtHost;
};
class FakeTableRepository implements ITableRepository {
constructor(private readonly tablesById: Map<string, Table>) {}
async insert(_: IExecutionContext, table: Table): Promise<Result<Table, DomainError>> {
this.tablesById.set(table.id().toString(), table);
return ok(table);
}
async insertMany(
_: IExecutionContext,
tables: ReadonlyArray<Table>
): Promise<Result<ReadonlyArray<Table>, DomainError>> {
tables.forEach((table) => this.tablesById.set(table.id().toString(), table));
return ok([...tables]);
}
async findOne(
_: IExecutionContext,
spec: ISpecification<Table, ITableSpecVisitor>
): Promise<Result<Table, DomainError>> {
const table = [...this.tablesById.values()].find((candidate) => spec.isSatisfiedBy(candidate));
if (!table) return err(domainError.notFound({ message: 'Table not found' }));
return ok(table);
}
async find(
_: IExecutionContext,
spec: ISpecification<Table, ITableSpecVisitor>,
__?: IFindOptions<TableSortKey>
): Promise<Result<ReadonlyArray<Table>, DomainError>> {
return ok([...this.tablesById.values()].filter((candidate) => spec.isSatisfiedBy(candidate)));
}
async updateOne(
_: IExecutionContext,
table: Table,
__: ISpecification<Table, ITableSpecVisitor>
): Promise<Result<void, DomainError>> {
this.tablesById.set(table.id().toString(), table);
return ok(undefined);
}
async delete(_: IExecutionContext, table: Table): Promise<Result<void, DomainError>> {
this.tablesById.delete(table.id().toString());
return ok(undefined);
}
}
class FakeTableSchemaRepository implements ITableSchemaRepository {
async insert(_: IExecutionContext, __: Table): Promise<Result<void, DomainError>> {
return ok(undefined);
}
async insertMany(
_: IExecutionContext,
__: ReadonlyArray<Table>
): Promise<Result<void, DomainError>> {
return ok(undefined);
}
async update(
_: IExecutionContext,
table: Table,
__: ISpecification<Table, ITableSpecVisitor>
): Promise<Result<Table, DomainError>> {
return ok(table);
}
async delete(_: IExecutionContext, __: Table): Promise<Result<void, DomainError>> {
return ok(undefined);
}
}
class FakeEventBus implements IEventBus {
async publish(_: IExecutionContext, __: IDomainEvent): Promise<Result<void, DomainError>> {
return ok(undefined);
}
async publishMany(
_: IExecutionContext,
__: ReadonlyArray<IDomainEvent>
): Promise<Result<void, DomainError>> {
return ok(undefined);
}
}
class FakeUnitOfWork implements IUnitOfWork {
async withTransaction<T>(
context: IExecutionContext,
work: UnitOfWorkOperation<T>
): Promise<Result<T, DomainError>> {
const transaction: IUnitOfWorkTransaction = { kind: 'unitOfWorkTransaction' };
return work({ ...context, transaction });
}
}
class CountingUnitOfWork extends FakeUnitOfWork {
transactionCount = 0;
override async withTransaction<T>(
context: IExecutionContext,
work: UnitOfWorkOperation<T>
): Promise<Result<T, DomainError>> {
this.transactionCount += 1;
return super.withTransaction(context, work);
}
}
describe('TableDeletionSideEffectService', () => {
it('converts incoming links to text and marks all foreign-table dependents errored', async () => {
const deletedTable = createDeletedTable();
const hostTable = createHostTable(deletedTable);
const tableRepository = new FakeTableRepository(
new Map([
[deletedTable.id().toString(), deletedTable],
[hostTable.id().toString(), hostTable],
])
);
const tableUpdateFlow = new TableUpdateFlow(
tableRepository,
new FakeTableSchemaRepository(),
new FakeEventBus(),
new FakeUnitOfWork()
);
const fieldUpdateSideEffectService = new FieldUpdateSideEffectService(
tableUpdateFlow,
tableRepository,
new LinkFieldUpdateSideEffectService(tableUpdateFlow),
new FieldCrossTableUpdateSideEffectService(tableRepository, tableUpdateFlow)
);
const service = new TableDeletionSideEffectService(
tableRepository,
tableUpdateFlow,
fieldUpdateSideEffectService
);
const result = await service.execute(createContext(), { table: deletedTable });
const updatedTables = result._unsafeUnwrap().updatedTables;
const updatedHost = updatedTables.find((table) => table.id().equals(hostTable.id()));
expect(updatedHost).toBeDefined();
if (!updatedHost) return;
const linkField = updatedHost.getFields((field) => field.name().toString() === 'Link').at(0);
const lookupField = updatedHost
.getFields((field) => field.name().toString() === 'Lookup')
.at(0);
const rollupField = updatedHost
.getFields((field) => field.name().toString() === 'Rollup')
.at(0);
const conditionalLookupField = updatedHost
.getFields((field) => field.name().toString() === 'Conditional Lookup')
.at(0);
const conditionalRollupField = updatedHost
.getFields((field) => field.name().toString() === 'Conditional Rollup')
.at(0);
expect(linkField?.type().toString()).toBe('singleLineText');
expect(lookupField?.hasError().isError()).toBe(true);
expect(rollupField?.hasError().isError()).toBe(true);
expect(conditionalLookupField?.hasError().isError()).toBe(true);
expect(conditionalRollupField?.hasError().isError()).toBe(true);
});
it('keeps hook-bearing link conversion isolated and batches the remaining delete-table reactions', async () => {
const deletedTable = createDeletedTable();
const hostTable = createHostTable(deletedTable);
const tableRepository = new FakeTableRepository(
new Map([
[deletedTable.id().toString(), deletedTable],
[hostTable.id().toString(), hostTable],
])
);
const unitOfWork = new CountingUnitOfWork();
const tableUpdateFlow = new TableUpdateFlow(
tableRepository,
new FakeTableSchemaRepository(),
new FakeEventBus(),
unitOfWork
);
const fieldUpdateSideEffectService = new FieldUpdateSideEffectService(
tableUpdateFlow,
tableRepository,
new LinkFieldUpdateSideEffectService(tableUpdateFlow),
new FieldCrossTableUpdateSideEffectService(tableRepository, tableUpdateFlow)
);
const service = new TableDeletionSideEffectService(
tableRepository,
tableUpdateFlow,
fieldUpdateSideEffectService
);
const result = await service.execute(createContext(), { table: deletedTable });
expect(result.isOk()).toBe(true);
expect(unitOfWork.transactionCount).toBe(4);
});
it('reacts to cross-base incoming references when deleting a foreign table', async () => {
const deletedTable = createDeletedTable();
const otherBaseId = BaseId.create(`bse${'z'.repeat(16)}`)._unsafeUnwrap();
const hostTable = createHostTable(deletedTable, {
baseId: otherBaseId,
crossBase: true,
});
const tableRepository = new FakeTableRepository(
new Map([
[deletedTable.id().toString(), deletedTable],
[hostTable.id().toString(), hostTable],
])
);
const tableUpdateFlow = new TableUpdateFlow(
tableRepository,
new FakeTableSchemaRepository(),
new FakeEventBus(),
new FakeUnitOfWork()
);
const fieldUpdateSideEffectService = new FieldUpdateSideEffectService(
tableUpdateFlow,
tableRepository,
new LinkFieldUpdateSideEffectService(tableUpdateFlow),
new FieldCrossTableUpdateSideEffectService(tableRepository, tableUpdateFlow)
);
const service = new TableDeletionSideEffectService(
tableRepository,
tableUpdateFlow,
fieldUpdateSideEffectService
);
const result = await service.execute(createContext(), { table: deletedTable });
const updatedTables = result._unsafeUnwrap().updatedTables;
const updatedHost = updatedTables.find((table) => table.id().equals(hostTable.id()));
expect(updatedHost).toBeDefined();
if (!updatedHost) return;
const linkField = updatedHost.getFields((field) => field.name().toString() === 'Link').at(0);
const lookupField = updatedHost
.getFields((field) => field.name().toString() === 'Lookup')
.at(0);
expect(updatedHost.baseId().equals(otherBaseId)).toBe(true);
expect(linkField?.type().toString()).toBe('singleLineText');
expect(lookupField?.hasError().isError()).toBe(true);
});
});

View File

@ -0,0 +1,337 @@
import { inject, injectable } from '@teable/v2-di';
import { err, ok, safeTry } from 'neverthrow';
import type { Result } from 'neverthrow';
import type { DomainError } from '../../domain/shared/DomainError';
import type { IDomainEvent } from '../../domain/shared/DomainEvent';
import { composeAndSpecs } from '../../domain/shared/specification/composeAndSpecs';
import type { ISpecification } from '../../domain/shared/specification/ISpecification';
import type { Field } from '../../domain/table/fields/Field';
import type { FieldId } from '../../domain/table/fields/FieldId';
import { FieldType } from '../../domain/table/fields/FieldType';
import {
implementsOnTeableTableDeleted,
type TableDeletionAfterPersistHook,
type TableDeletionContext,
type TableDeletionReaction,
} from '../../domain/table/OnTeableTableDeleted';
import type { ITableSpecVisitor } from '../../domain/table/specs/ITableSpecVisitor';
import { Table as TableAggregate } from '../../domain/table/Table';
import type { Table } from '../../domain/table/Table';
import type { TableId } from '../../domain/table/TableId';
import { TableUpdateResult } from '../../domain/table/TableMutator';
import * as ExecutionContextPort from '../../ports/ExecutionContext';
import * as TableRepositoryPort from '../../ports/TableRepository';
import { v2CoreTokens } from '../../ports/tokens';
import { TraceSpan } from '../../ports/TraceSpan';
import { FieldUpdateSideEffectService } from './FieldUpdateSideEffectService';
import { TableUpdateFlow } from './TableUpdateFlow';
export type TableDeletionSideEffectServiceInput = {
table: Table;
};
export type TableDeletionSideEffectServiceResult = {
events: ReadonlyArray<IDomainEvent>;
postPersistEvents: ReadonlyArray<IDomainEvent>;
updatedTables: ReadonlyArray<Table>;
};
type TableDeletionTypeReaction = {
reaction: TableDeletionReactionWithAfterPersist;
};
type TableDeletionReactionCollection = {
isolatedReactions: ReadonlyArray<TableDeletionTypeReaction>;
batchableSpecs: ReadonlyArray<ISpecification<Table, ITableSpecVisitor>>;
};
type TableDeletionReactionWithAfterPersist = TableDeletionReaction & {
readonly afterPersist: TableDeletionAfterPersistHook;
};
@injectable()
// Application service: orchestrates cross-table side effects before a table is deleted.
// Data changes still flow through explicit table-deletion hooks and table specs so adapters can keep work in SQL.
export class TableDeletionSideEffectService {
constructor(
@inject(v2CoreTokens.tableRepository)
private readonly tableRepository: TableRepositoryPort.ITableRepository,
@inject(v2CoreTokens.tableUpdateFlow)
private readonly tableUpdateFlow: TableUpdateFlow,
@inject(v2CoreTokens.fieldUpdateSideEffectService)
private readonly fieldUpdateSideEffectService: FieldUpdateSideEffectService
) {}
@TraceSpan()
async execute(
context: ExecutionContextPort.IExecutionContext,
input: TableDeletionSideEffectServiceInput
): Promise<Result<TableDeletionSideEffectServiceResult, DomainError>> {
const service = this;
return safeTry<TableDeletionSideEffectServiceResult, DomainError>(async function* () {
const candidateTables = yield* await service.loadCandidateTables(context, input.table);
if (candidateTables.length === 0) {
return ok({ events: [], postPersistEvents: [], updatedTables: [] });
}
const updatedTables: Table[] = [];
const events: IDomainEvent[] = [];
const postPersistEvents: IDomainEvent[] = [];
for (const candidateTable of candidateTables) {
let latestTable = candidateTable;
const reactingFieldIds = service.prioritizeReactingFieldIds(latestTable, input.table);
const deletionContext = service.createDeletionContext(latestTable);
const reactions = yield* service.collectDeletionReactions(
latestTable,
reactingFieldIds,
input.table,
deletionContext
);
for (const reaction of reactions.isolatedReactions) {
const reactionResult = yield* await service.reactWithAfterPersistReaction(
context,
latestTable,
input.table,
reaction.reaction
);
if (!reactionResult) {
continue;
}
latestTable = reactionResult.table;
events.push(...reactionResult.events);
postPersistEvents.push(...reactionResult.postPersistEvents);
}
const refreshedBatchableReactions = yield* service.collectDeletionReactions(
latestTable,
service.prioritizeReactingFieldIds(latestTable, input.table),
input.table,
service.createDeletionContext(latestTable)
);
const batchReactionResult = yield* await service.reactWithBatchableSpecs(
context,
latestTable,
refreshedBatchableReactions.batchableSpecs
);
if (batchReactionResult) {
latestTable = batchReactionResult.table;
events.push(...batchReactionResult.events);
postPersistEvents.push(...batchReactionResult.postPersistEvents);
}
updatedTables.push(latestTable);
}
return ok({
events,
postPersistEvents,
updatedTables,
});
});
}
private async loadCandidateTables(
context: ExecutionContextPort.IExecutionContext,
deletedTable: Table
): Promise<Result<ReadonlyArray<Table>, DomainError>> {
const specResult = TableAggregate.specs()
.byIncomingReferenceToTable(deletedTable.id())
.not((builder) => builder.byId(deletedTable.id()))
.build();
if (specResult.isErr()) {
return err(specResult.error);
}
const tablesResult = await this.tableRepository.find(context, specResult.value);
if (tablesResult.isErr()) {
return err(tablesResult.error);
}
return ok(tablesResult.value);
}
private collectDeletionReactions(
table: Table,
reactingFieldIds: ReadonlyArray<FieldId>,
deletedTable: Table,
deletionContext: TableDeletionContext
): Result<TableDeletionReactionCollection, DomainError> {
const isolatedReactions: TableDeletionTypeReaction[] = [];
const batchableSpecs: Array<ISpecification<Table, ITableSpecVisitor>> = [];
for (const fieldId of reactingFieldIds) {
const field = table.getField((candidate) => candidate.id().equals(fieldId));
if (field.isErr() || !implementsOnTeableTableDeleted(field.value)) {
continue;
}
const reactionResult = field.value.onTableDeleted(deletedTable, deletionContext);
if (reactionResult.isErr()) {
return err(reactionResult.error);
}
if (!reactionResult.value) {
continue;
}
if (this.hasAfterPersist(reactionResult.value)) {
isolatedReactions.push({
reaction: reactionResult.value,
});
continue;
}
batchableSpecs.push(reactionResult.value.spec);
}
return ok({
isolatedReactions,
batchableSpecs,
});
}
private prioritizeReactingFieldIds(table: Table, deletedTable: Table): ReadonlyArray<FieldId> {
return [...table.getFields()]
.sort(
(left, right) =>
this.reactionPriority(left, deletedTable) - this.reactionPriority(right, deletedTable)
)
.map((field) => field.id());
}
private reactionPriority(field: Field, deletedTable: Table): number {
const foreignTableId = this.getForeignTableId(field);
if (!foreignTableId || !foreignTableId.equals(deletedTable.id())) {
return 2;
}
return field.type().equals(FieldType.link()) ? 0 : 1;
}
private hasAfterPersist(
reaction: TableDeletionReaction
): reaction is TableDeletionReactionWithAfterPersist {
return reaction.afterPersist != null;
}
private getForeignTableId(field: Field): TableId | undefined {
const candidate = field as Field & {
foreignTableId?: () => TableId;
};
if (typeof candidate.foreignTableId !== 'function') {
return undefined;
}
return candidate.foreignTableId();
}
private async reactWithAfterPersistReaction(
context: ExecutionContextPort.IExecutionContext,
table: Table,
deletedTable: Table,
reaction: TableDeletionReactionWithAfterPersist
): Promise<
Result<
| {
table: Table;
events: ReadonlyArray<IDomainEvent>;
postPersistEvents: ReadonlyArray<IDomainEvent>;
}
| undefined,
DomainError
>
> {
const updateResult = await this.tableUpdateFlow.execute(
context,
{ table },
(candidate) =>
reaction.spec
.mutate(candidate)
.map((updated) => TableUpdateResult.create(updated, reaction.spec)),
{
hooks: {
afterPersist: (transactionContext, updatedTable) =>
reaction.afterPersist(transactionContext, updatedTable, deletedTable),
},
publishEvents: false,
}
);
return updateResult.map((result) => result);
}
private createDeletionContext(table: Table): TableDeletionContext {
return {
table,
hooks: {
createFieldUpdateAfterPersistHook: (fieldId, updateSpec) => {
return async (context, updatedTable, currentDeletedTable) => {
const updatedField = updatedTable.getField((candidate) =>
candidate.id().equals(fieldId)
);
if (updatedField.isErr()) {
return err(updatedField.error);
}
const sideEffectResult = await this.fieldUpdateSideEffectService.execute(context, {
table: updatedTable,
updatedField: updatedField.value,
updateSpecs: [updateSpec],
foreignTables: [currentDeletedTable],
});
if (sideEffectResult.isErr()) {
return err(sideEffectResult.error);
}
return ok({
events: sideEffectResult.value.events,
table: sideEffectResult.value.updatedTable,
});
};
},
},
};
}
private async reactWithBatchableSpecs(
context: ExecutionContextPort.IExecutionContext,
table: Table,
specs: ReadonlyArray<ISpecification<Table, ITableSpecVisitor>>
): Promise<
Result<
| {
table: Table;
events: ReadonlyArray<IDomainEvent>;
postPersistEvents: ReadonlyArray<IDomainEvent>;
}
| undefined,
DomainError
>
> {
if (specs.length === 0) {
return ok(undefined);
}
const composedSpec = composeAndSpecs(specs);
if (composedSpec.isErr()) {
return err(composedSpec.error);
}
const updateResult = await this.tableUpdateFlow.execute(
context,
{ table },
(candidate) =>
composedSpec.value
.mutate(candidate)
.map((updated) => TableUpdateResult.create(updated, composedSpec.value)),
{ publishEvents: false }
);
return updateResult.map((result) => result);
}
}

View File

@ -65,6 +65,7 @@ type TableUpdateFlowHooks = {
export type TableUpdateFlowResult = {
table: Table;
events: ReadonlyArray<IDomainEvent>;
postPersistEvents: ReadonlyArray<IDomainEvent>;
};
const normalizeHookResult = (
@ -184,7 +185,7 @@ export class TableUpdateFlow {
yield* await handler.eventBus.publishMany(context, postPersistEvents);
}
}
return ok({ table: latestTable, events: normalizedEvents });
return ok({ table: latestTable, events: normalizedEvents, postPersistEvents });
});
}

View File

@ -16,6 +16,8 @@ import { OccurredAt } from '../domain/shared/OccurredAt';
import type { ISpecification } from '../domain/shared/specification/ISpecification';
import { FieldId } from '../domain/table/fields/FieldId';
import { FieldName } from '../domain/table/fields/FieldName';
import { LinkField } from '../domain/table/fields/types/LinkField';
import { LinkFieldConfig } from '../domain/table/fields/types/LinkFieldConfig';
import type { ITableSpecVisitor } from '../domain/table/specs/ITableSpecVisitor';
import { Table } from '../domain/table/Table';
import { TableId } from '../domain/table/TableId';
@ -41,8 +43,20 @@ const noopUndoRedoService = {
},
} as unknown as UndoRedoService;
const noopFieldUndoRedoSnapshotService = {
async capture(_context: IExecutionContext, _table: Table, fieldId: FieldId) {
class FakeFieldUndoRedoSnapshotService {
captured: Array<{ tableId: TableId; fieldId: FieldId; includeRecords?: boolean }> = [];
async capture(
_context: IExecutionContext,
table: Table,
fieldId: FieldId,
options?: { includeRecords?: boolean }
) {
this.captured.push({
tableId: table.id(),
fieldId,
includeRecords: options?.includeRecords,
});
return ok({
field: {
id: fieldId.toString(),
@ -51,8 +65,8 @@ const noopFieldUndoRedoSnapshotService = {
},
views: [],
});
},
} as unknown as FieldUndoRedoSnapshotService;
}
}
const buildTable = () => {
const baseId = BaseId.create(`bse${'d'.repeat(16)}`)._unsafeUnwrap();
@ -245,6 +259,7 @@ describe('DeleteFieldHandler', () => {
sideEffectService.events = [buildEvent()];
const foreignTableLoader = new FakeForeignTableLoaderService();
const fieldUndoRedoSnapshotService = new FakeFieldUndoRedoSnapshotService();
const handler = new DeleteFieldHandler(
tableRepository,
@ -252,7 +267,7 @@ describe('DeleteFieldHandler', () => {
sideEffectService as unknown as FieldDeletionSideEffectService,
foreignTableLoader as unknown as ForeignTableLoaderService,
noopUndoRedoService,
noopFieldUndoRedoSnapshotService
fieldUndoRedoSnapshotService as unknown as FieldUndoRedoSnapshotService
);
const commandResult = DeleteFieldCommand.create({
@ -270,6 +285,7 @@ describe('DeleteFieldHandler', () => {
expect(eventBus.published.length).toBeGreaterThan(0);
expect(unitOfWork.transactions.length).toBe(1);
expect(foreignTableLoader.lastBaseId?.equals(baseId)).toBe(true);
expect(fieldUndoRedoSnapshotService.captured).toHaveLength(1);
});
it('returns not found when field is missing', async () => {
@ -288,7 +304,7 @@ describe('DeleteFieldHandler', () => {
new FakeFieldDeletionSideEffectService() as unknown as FieldDeletionSideEffectService,
new FakeForeignTableLoaderService() as unknown as ForeignTableLoaderService,
noopUndoRedoService,
noopFieldUndoRedoSnapshotService
new FakeFieldUndoRedoSnapshotService() as unknown as FieldUndoRedoSnapshotService
);
const commandResult = DeleteFieldCommand.create({
@ -300,4 +316,112 @@ describe('DeleteFieldHandler', () => {
const result = await handler.handle(createContext(), commandResult._unsafeUnwrap());
expect(result._unsafeUnwrapErr().message).toBe('Field not found');
});
it('captures related undo snapshots from explicit field deletion reactions', async () => {
const baseId = BaseId.create(`bse${'f'.repeat(16)}`)._unsafeUnwrap();
const sourceTableId = TableId.create(`tbl${'g'.repeat(16)}`)._unsafeUnwrap();
const hostTableId = TableId.create(`tbl${'h'.repeat(16)}`)._unsafeUnwrap();
const sourcePrimaryFieldId = FieldId.create(`fld${'i'.repeat(16)}`)._unsafeUnwrap();
const sourceDisplayFieldId = FieldId.create(`fld${'j'.repeat(16)}`)._unsafeUnwrap();
const hostPrimaryFieldId = FieldId.create(`fld${'k'.repeat(16)}`)._unsafeUnwrap();
const hostLinkFieldId = FieldId.create(`fld${'l'.repeat(16)}`)._unsafeUnwrap();
const sourceBuilder = Table.builder()
.withId(sourceTableId)
.withBaseId(baseId)
.withName(TableName.create('Source')._unsafeUnwrap());
sourceBuilder
.field()
.singleLineText()
.withId(sourcePrimaryFieldId)
.withName(FieldName.create('Title')._unsafeUnwrap())
.primary()
.done();
sourceBuilder
.field()
.singleLineText()
.withId(sourceDisplayFieldId)
.withName(FieldName.create('Display')._unsafeUnwrap())
.done();
sourceBuilder.view().defaultGrid().done();
const sourceTable = sourceBuilder.build()._unsafeUnwrap();
const hostBuilder = Table.builder()
.withId(hostTableId)
.withBaseId(baseId)
.withName(TableName.create('Host')._unsafeUnwrap());
hostBuilder
.field()
.singleLineText()
.withId(hostPrimaryFieldId)
.withName(FieldName.create('Host Title')._unsafeUnwrap())
.primary()
.done();
hostBuilder.view().defaultGrid().done();
const hostBaseTable = hostBuilder.build()._unsafeUnwrap();
const hostLinkField = LinkField.create({
id: hostLinkFieldId,
name: FieldName.create('Source Link')._unsafeUnwrap(),
config: LinkFieldConfig.create({
relationship: 'oneOne',
foreignTableId: sourceTableId.toString(),
lookupFieldId: sourceDisplayFieldId.toString(),
isOneWay: true,
fkHostTableName: 'host_source_link',
selfKeyName: '__id',
foreignKeyName: '__fk_source_link',
})._unsafeUnwrap(),
})._unsafeUnwrap();
const hostTable = hostBaseTable
.addField(hostLinkField, { foreignTables: [sourceTable] })
._unsafeUnwrap();
const tableRepository = new FakeTableRepository();
tableRepository.tables.push(sourceTable, hostTable);
const snapshotService = new FakeFieldUndoRedoSnapshotService();
const handlerWithSnapshots = new DeleteFieldHandler(
tableRepository,
new TableUpdateFlow(
tableRepository,
new FakeTableSchemaRepository(),
new FakeEventBus(),
new FakeUnitOfWork()
),
new FakeFieldDeletionSideEffectService() as unknown as FieldDeletionSideEffectService,
new FakeForeignTableLoaderService() as unknown as ForeignTableLoaderService,
noopUndoRedoService,
snapshotService as unknown as FieldUndoRedoSnapshotService
);
const command = DeleteFieldCommand.create({
baseId: baseId.toString(),
tableId: sourceTableId.toString(),
fieldId: sourceDisplayFieldId.toString(),
})._unsafeUnwrap();
const result = await handlerWithSnapshots.handle(createContext(), command);
expect(result.isOk()).toBe(true);
expect(
snapshotService.captured.map(({ tableId, fieldId, includeRecords }) => ({
tableId: tableId.toString(),
fieldId: fieldId.toString(),
includeRecords,
}))
).toEqual([
{
tableId: sourceTableId.toString(),
fieldId: sourceDisplayFieldId.toString(),
includeRecords: undefined,
},
{
tableId: hostTableId.toString(),
fieldId: hostLinkFieldId.toString(),
includeRecords: false,
},
]);
});
});

View File

@ -2,28 +2,24 @@ import { inject, injectable } from '@teable/v2-di';
import { err, ok, safeTry } from 'neverthrow';
import type { Result } from 'neverthrow';
import { FieldUndoRedoSnapshotService } from '../application/services/FieldUndoRedoSnapshotService';
import { FieldDeletionSideEffectService } from '../application/services/FieldDeletionSideEffectService';
import { FieldUndoRedoSnapshotService } from '../application/services/FieldUndoRedoSnapshotService';
import { ForeignTableLoaderService } from '../application/services/ForeignTableLoaderService';
import { TableUpdateFlow } from '../application/services/TableUpdateFlow';
import { UndoRedoService } from '../application/services/UndoRedoService';
import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError';
import type { IDomainEvent } from '../domain/shared/DomainEvent';
import {
composeAndSpecsOrUndefined,
flattenAndSpecs,
} from '../domain/shared/specification/composeAndSpecs';
import { composeAndSpecsOrUndefined } from '../domain/shared/specification/composeAndSpecs';
import type { ISpecification } from '../domain/shared/specification/ISpecification';
import { UpdateLinkConfigSpec } from '../domain/table/specs/field-updates/UpdateLinkConfigSpec';
import { Field } from '../domain/table/fields/Field';
import type { FieldId } from '../domain/table/fields/FieldId';
import { LinkForeignTableReferenceVisitor } from '../domain/table/fields/visitors/LinkForeignTableReferenceVisitor';
import {
implementsOnTeableFieldDeleted,
type FieldDeletionContext,
type FieldDeletionReaction,
} from '../domain/table/OnTeableFieldDeleted';
import { Field } from '../domain/table/fields/Field';
import { LinkForeignTableReferenceVisitor } from '../domain/table/fields/visitors/LinkForeignTableReferenceVisitor';
import type { ITableSpecVisitor } from '../domain/table/specs/ITableSpecVisitor';
import { TableUpdateFieldHasErrorSpec } from '../domain/table/specs/TableUpdateFieldHasErrorSpec';
import { TableUpdateFieldTypeSpec } from '../domain/table/specs/TableUpdateFieldTypeSpec';
import { TableUpdateViewColumnMetaSpec } from '../domain/table/specs/TableUpdateViewColumnMetaSpec';
import { TableUpdateViewQueryDefaultsSpec } from '../domain/table/specs/TableUpdateViewQueryDefaultsSpec';
import { Table as TableAggregate } from '../domain/table/Table';
@ -61,6 +57,11 @@ export class DeleteFieldResult {
}
}
type FieldDeletionCleanup = {
readonly spec?: ISpecification<Table, ITableSpecVisitor>;
readonly relatedFieldIds: ReadonlyArray<FieldId>;
};
@CommandHandler(DeleteFieldCommand)
@injectable()
export class DeleteFieldHandler implements ICommandHandler<DeleteFieldCommand, DeleteFieldResult> {
@ -242,13 +243,13 @@ export class DeleteFieldHandler implements ICommandHandler<DeleteFieldCommand, D
? latestSourceTable
: table;
const cleanupSpecResult = handler.buildDeletionCleanupSpecs(candidateTable, deletedField, {
const cleanupResult = handler.buildDeletionCleanup(candidateTable, deletedField, {
table: candidateTable,
sourceTable: latestSourceTable,
previousSourceTable,
});
if (cleanupSpecResult.isErr()) return err(cleanupSpecResult.error);
const cleanupSpec = cleanupSpecResult.value;
if (cleanupResult.isErr()) return err(cleanupResult.error);
const cleanupSpec = cleanupResult.value.spec;
if (!cleanupSpec) {
continue;
}
@ -304,36 +305,16 @@ export class DeleteFieldHandler implements ICommandHandler<DeleteFieldCommand, D
}> = [];
for (const candidateTable of orderedTables) {
const cleanupSpecResult = handler.buildDeletionCleanupSpecs(candidateTable, deletedField, {
const cleanupResult = handler.buildDeletionCleanup(candidateTable, deletedField, {
table: candidateTable,
sourceTable,
previousSourceTable: sourceTable,
});
if (cleanupSpecResult.isErr()) {
return err(cleanupSpecResult.error);
if (cleanupResult.isErr()) {
return err(cleanupResult.error);
}
const relatedFieldIds = new Map(
flattenAndSpecs(cleanupSpecResult.value)
.filter(
(
spec
): spec is
| TableUpdateFieldHasErrorSpec
| UpdateLinkConfigSpec
| TableUpdateFieldTypeSpec =>
spec instanceof TableUpdateFieldHasErrorSpec ||
spec instanceof UpdateLinkConfigSpec ||
spec instanceof TableUpdateFieldTypeSpec
)
.map((spec) => {
const fieldId =
spec instanceof TableUpdateFieldTypeSpec ? spec.oldField().id() : spec.fieldId();
return [fieldId.toString(), fieldId] as const;
})
);
for (const relatedFieldId of relatedFieldIds.values()) {
for (const relatedFieldId of cleanupResult.value.relatedFieldIds) {
const snapshot = yield* await handler.fieldUndoRedoSnapshotService.capture(
context,
candidateTable,
@ -352,12 +333,13 @@ export class DeleteFieldHandler implements ICommandHandler<DeleteFieldCommand, D
});
}
private buildDeletionCleanupSpecs(
private buildDeletionCleanup(
candidateTable: Table,
deletedField: Field,
context: FieldDeletionContext
): Result<ISpecification<Table, ITableSpecVisitor> | undefined, DomainError> {
): Result<FieldDeletionCleanup, DomainError> {
const specs: Array<ISpecification<Table, ITableSpecVisitor>> = [];
const relatedFieldIds = new Map<string, FieldId>();
for (const view of candidateTable.views()) {
if (!implementsOnTeableViewFieldDeleted(view)) {
@ -395,10 +377,23 @@ export class DeleteFieldHandler implements ICommandHandler<DeleteFieldCommand, D
const result = field.onFieldDeleted(deletedField, context);
if (result.isErr()) return err(result.error);
if (result.value) {
specs.push(result.value);
specs.push(result.value.spec);
this.collectRelatedFieldIds(relatedFieldIds, result.value);
}
}
return ok(composeAndSpecsOrUndefined(specs));
return ok({
spec: composeAndSpecsOrUndefined(specs),
relatedFieldIds: [...relatedFieldIds.values()],
});
}
private collectRelatedFieldIds(
accumulator: Map<string, FieldId>,
reaction: FieldDeletionReaction
): void {
for (const fieldId of reaction.relatedFieldIds) {
accumulator.set(fieldId.toString(), fieldId);
}
}
}

View File

@ -19,10 +19,23 @@ describe('DeleteTableCommand', () => {
baseId: baseIdResult._unsafeUnwrap().toString(),
tableId: tableIdResult._unsafeUnwrap().toString(),
});
commandResult._unsafeUnwrap();
expect(commandResult._unsafeUnwrap().mode).toBe('soft');
});
it('rejects invalid input', () => {
DeleteTableCommand.create({ baseId: 'bad', tableId: 'bad' })._unsafeUnwrapErr();
});
it('accepts an explicit permanent mode', () => {
const baseId = createBaseId('b')._unsafeUnwrap();
const tableId = createTableId('b')._unsafeUnwrap();
const command = DeleteTableCommand.create({
baseId: baseId.toString(),
tableId: tableId.toString(),
mode: 'permanent',
})._unsafeUnwrap();
expect(command.mode).toBe('permanent');
});
});

View File

@ -6,17 +6,22 @@ import { BaseId } from '../domain/base/BaseId';
import { domainError, type DomainError } from '../domain/shared/DomainError';
import { TableId } from '../domain/table/TableId';
export const deleteTableModeSchema = z.enum(['soft', 'permanent']);
export const deleteTableInputSchema = z.object({
baseId: z.string(),
tableId: z.string(),
mode: deleteTableModeSchema.default('soft'),
});
export type IDeleteTableCommandInput = z.input<typeof deleteTableInputSchema>;
export type DeleteTableMode = z.infer<typeof deleteTableModeSchema>;
export class DeleteTableCommand {
private constructor(
readonly baseId: BaseId,
readonly tableId: TableId
readonly tableId: TableId,
readonly mode: DeleteTableMode
) {}
static create(raw: unknown): Result<DeleteTableCommand, DomainError> {
@ -25,7 +30,9 @@ export class DeleteTableCommand {
return err(domainError.validation({ message: 'Invalid DeleteTableCommand input' }));
return BaseId.create(parsed.data.baseId).andThen((baseId) =>
TableId.create(parsed.data.tableId).map((tableId) => new DeleteTableCommand(baseId, tableId))
TableId.create(parsed.data.tableId).map(
(tableId) => new DeleteTableCommand(baseId, tableId, parsed.data.mode)
)
);
}
}

View File

@ -2,12 +2,15 @@ import { err, ok } from 'neverthrow';
import type { Result } from 'neverthrow';
import { describe, expect, it } from 'vitest';
import type { TableDeletionSideEffectServiceResult } from '../application/services/TableDeletionSideEffectService';
import { BaseId } from '../domain/base/BaseId';
import { ActorId } from '../domain/shared/ActorId';
import { domainError, type DomainError } from '../domain/shared/DomainError';
import type { IDomainEvent } from '../domain/shared/DomainEvent';
import type { ISpecification } from '../domain/shared/specification/ISpecification';
import { TableActionTriggerRequested } from '../domain/table/events/TableActionTriggerRequested';
import { TableDeleted } from '../domain/table/events/TableDeleted';
import { TableTrashed } from '../domain/table/events/TableTrashed';
import { FieldName } from '../domain/table/fields/FieldName';
import type { ITableSpecVisitor } from '../domain/table/specs/ITableSpecVisitor';
import type { Table } from '../domain/table/Table';
@ -18,7 +21,11 @@ import type { IEventBus } from '../ports/EventBus';
import type { IExecutionContext, IUnitOfWorkTransaction } from '../ports/ExecutionContext';
import type { ILogger, LogContext } from '../ports/Logger';
import type { IFindOptions } from '../ports/RepositoryQuery';
import type { ITableRepository } from '../ports/TableRepository';
import type {
ITableRepository,
TableDeleteOptions,
TableFindOptions,
} from '../ports/TableRepository';
import type { ITableSchemaRepository } from '../ports/TableSchemaRepository';
import type { IUnitOfWork, UnitOfWorkOperation } from '../ports/UnitOfWork';
import { DeleteTableCommand } from './DeleteTableCommand';
@ -43,6 +50,8 @@ const buildTable = (baseIdSeed: string): Table => {
class FakeTableRepository implements ITableRepository {
tables: Table[] = [];
deleted: Table[] = [];
deleteModes: Array<'soft' | 'permanent'> = [];
deletedTableIds = new Set<string>();
failDelete: DomainError | undefined;
async insert(_: IExecutionContext, table: Table): Promise<Result<Table, DomainError>> {
@ -60,9 +69,16 @@ class FakeTableRepository implements ITableRepository {
async findOne(
_: IExecutionContext,
spec: ISpecification<Table, ITableSpecVisitor>
spec: ISpecification<Table, ITableSpecVisitor>,
options?: Pick<TableFindOptions, 'state'>
): Promise<Result<Table, DomainError>> {
const found = this.tables.find((table) => spec.isSatisfiedBy(table));
const state = options?.state ?? 'active';
const found = this.tables.find((table) => {
const isDeleted = this.deletedTableIds.has(table.id().toString());
if (state === 'active' && isDeleted) return false;
if (state === 'deleted' && !isDeleted) return false;
return spec.isSatisfiedBy(table);
});
if (!found) return err(domainError.notFound({ message: 'Not found' }));
return ok(found);
}
@ -83,15 +99,22 @@ class FakeTableRepository implements ITableRepository {
return err(domainError.notImplemented({ message: 'Not implemented' }));
}
async delete(_: IExecutionContext, table: Table): Promise<Result<void, DomainError>> {
async delete(
_: IExecutionContext,
table: Table,
options?: TableDeleteOptions
): Promise<Result<void, DomainError>> {
if (this.failDelete) return err(this.failDelete);
this.deleted.push(table);
this.deleteModes.push(options?.mode ?? 'soft');
this.deletedTableIds.add(table.id().toString());
return ok(undefined);
}
}
class FakeTableSchemaRepository implements ITableSchemaRepository {
deleted: Table[] = [];
deleteModes: Array<'soft' | 'permanent'> = [];
failDelete: DomainError | undefined;
async insert(_: IExecutionContext, __: Table): Promise<Result<void, DomainError>> {
@ -113,9 +136,16 @@ class FakeTableSchemaRepository implements ITableSchemaRepository {
return ok(table);
}
async delete(_: IExecutionContext, table: Table): Promise<Result<void, DomainError>> {
async delete(
_: IExecutionContext,
table: Table,
options?: TableDeleteOptions
): Promise<Result<void, DomainError>> {
if (this.failDelete) return err(this.failDelete);
this.deleted.push(table);
this.deleteModes.push(options?.mode ?? 'soft');
if ((options?.mode ?? 'soft') === 'permanent') {
this.deleted.push(table);
}
return ok(undefined);
}
}
@ -137,6 +167,23 @@ class FakeEventBus implements IEventBus {
}
}
class FakeTableDeletionSideEffectService {
events: IDomainEvent[] = [];
postPersistEvents: IDomainEvent[] = [];
calls = 0;
failExecute: DomainError | undefined;
async execute(): Promise<Result<TableDeletionSideEffectServiceResult, DomainError>> {
this.calls += 1;
if (this.failExecute) return err(this.failExecute);
return ok({
events: [...this.events],
postPersistEvents: [...this.postPersistEvents],
updatedTables: [],
});
}
}
class FakeLogger implements ILogger {
readonly messages: string[] = [];
@ -180,12 +227,13 @@ class FakeUnitOfWork implements IUnitOfWork {
}
describe('DeleteTableHandler', () => {
it('deletes tables and publishes events', async () => {
it('soft deletes tables without dropping schema and publishes TableTrashed', async () => {
const table = buildTable('a');
const repo = new FakeTableRepository();
repo.tables.push(table);
const schemaRepo = new FakeTableSchemaRepository();
const eventBus = new FakeEventBus();
const sideEffectService = new FakeTableDeletionSideEffectService();
const logger = new FakeLogger();
const unitOfWork = new FakeUnitOfWork();
@ -195,22 +243,135 @@ describe('DeleteTableHandler', () => {
});
commandResult._unsafeUnwrap();
const handler = new DeleteTableHandler(repo, schemaRepo, eventBus, logger, unitOfWork);
const handler = new DeleteTableHandler(
repo,
schemaRepo,
sideEffectService as never,
eventBus,
logger,
unitOfWork
);
const result = await handler.handle(createContext(), commandResult._unsafeUnwrap());
result._unsafeUnwrap();
expect(schemaRepo.deleted).toHaveLength(1);
expect(schemaRepo.deleted).toHaveLength(0);
expect(schemaRepo.deleteModes).toEqual(['soft']);
expect(repo.deleted).toHaveLength(1);
expect(eventBus.published.some((event) => event instanceof TableDeleted)).toBe(true);
expect(repo.deleteModes).toEqual(['soft']);
expect(eventBus.published.some((event) => event instanceof TableTrashed)).toBe(true);
expect(eventBus.published.some((event) => event instanceof TableDeleted)).toBe(false);
expect(unitOfWork.transactions.length).toBe(1);
});
it('publishes side-effect post-persist events without returning them in the response payload', async () => {
const table = buildTable('s');
const repo = new FakeTableRepository();
repo.tables.push(table);
const schemaRepo = new FakeTableSchemaRepository();
const eventBus = new FakeEventBus();
const sideEffectService = new FakeTableDeletionSideEffectService();
sideEffectService.postPersistEvents = [
TableActionTriggerRequested.create({
tableId: table.id(),
baseId: table.baseId(),
actionKey: 'setRecord',
payload: { tableId: table.id().toString(), fieldIds: [] },
}),
];
const handler = new DeleteTableHandler(
repo,
schemaRepo,
sideEffectService as never,
eventBus,
new FakeLogger(),
new FakeUnitOfWork()
);
const command = DeleteTableCommand.create({
baseId: table.baseId().toString(),
tableId: table.id().toString(),
})._unsafeUnwrap();
const result = await handler.handle(createContext(), command);
const payload = result._unsafeUnwrap();
const responseEventNames = payload.events.map((event) => event.name.toString());
const publishedEventNames = eventBus.published.map((event) => event.name.toString());
expect(responseEventNames).not.toContain('TableActionTriggerRequested');
expect(publishedEventNames).toContain('TableActionTriggerRequested');
expect(publishedEventNames).toContain('TableTrashed');
});
it('permanently deletes tables and publishes TableDeleted', async () => {
const table = buildTable('p');
const repo = new FakeTableRepository();
repo.tables.push(table);
const schemaRepo = new FakeTableSchemaRepository();
const eventBus = new FakeEventBus();
const handler = new DeleteTableHandler(
repo,
schemaRepo,
new FakeTableDeletionSideEffectService() as never,
eventBus,
new FakeLogger(),
new FakeUnitOfWork()
);
const command = DeleteTableCommand.create({
baseId: table.baseId().toString(),
tableId: table.id().toString(),
mode: 'permanent',
})._unsafeUnwrap();
const result = await handler.handle(createContext(), command);
result._unsafeUnwrap();
expect(schemaRepo.deleted).toHaveLength(1);
expect(schemaRepo.deleteModes).toEqual(['permanent']);
expect(repo.deleteModes).toEqual(['permanent']);
expect(eventBus.published.some((event) => event instanceof TableDeleted)).toBe(true);
});
it('permanently deletes an already trashed table without rerunning side effects', async () => {
const table = buildTable('q');
const repo = new FakeTableRepository();
repo.tables.push(table);
repo.deletedTableIds.add(table.id().toString());
const schemaRepo = new FakeTableSchemaRepository();
const eventBus = new FakeEventBus();
const sideEffectService = new FakeTableDeletionSideEffectService();
const handler = new DeleteTableHandler(
repo,
schemaRepo,
sideEffectService as never,
eventBus,
new FakeLogger(),
new FakeUnitOfWork()
);
const command = DeleteTableCommand.create({
baseId: table.baseId().toString(),
tableId: table.id().toString(),
mode: 'permanent',
})._unsafeUnwrap();
const result = await handler.handle(createContext(), command);
result._unsafeUnwrap();
expect(sideEffectService.calls).toBe(0);
expect(schemaRepo.deleteModes).toEqual(['permanent']);
expect(repo.deleteModes).toEqual(['permanent']);
expect(eventBus.published.some((event) => event instanceof TableDeleted)).toBe(true);
});
it('returns not found when table is missing', async () => {
const table = buildTable('b');
const repo = new FakeTableRepository();
const handler = new DeleteTableHandler(
repo,
new FakeTableSchemaRepository(),
new FakeTableDeletionSideEffectService() as never,
new FakeEventBus(),
new FakeLogger(),
new FakeUnitOfWork()
@ -232,11 +393,13 @@ describe('DeleteTableHandler', () => {
const repo = new FakeTableRepository();
repo.tables.push(table);
const schemaRepo = new FakeTableSchemaRepository();
const sideEffectService = new FakeTableDeletionSideEffectService();
const eventBus = new FakeEventBus();
const handler = new DeleteTableHandler(
repo,
schemaRepo,
sideEffectService as never,
eventBus,
new FakeLogger(),
new FakeUnitOfWork()
@ -248,6 +411,11 @@ describe('DeleteTableHandler', () => {
});
commandResult._unsafeUnwrap();
sideEffectService.failExecute = domainError.unexpected({ message: 'side effect failed' });
const sideEffectResult = await handler.handle(createContext(), commandResult._unsafeUnwrap());
expect(sideEffectResult._unsafeUnwrapErr().message).toBe('side effect failed');
sideEffectService.failExecute = undefined;
schemaRepo.failDelete = domainError.unexpected({ message: 'schema delete failed' });
const schemaResult = await handler.handle(createContext(), commandResult._unsafeUnwrap());
expect(schemaResult._unsafeUnwrapErr().message).toBe('schema delete failed');

View File

@ -2,6 +2,7 @@ import { inject, injectable } from '@teable/v2-di';
import { err, ok, safeTry } from 'neverthrow';
import type { Result } from 'neverthrow';
import { TableDeletionSideEffectService } from '../application/services/TableDeletionSideEffectService';
import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError';
import type { IDomainEvent } from '../domain/shared/DomainEvent';
import type { Table } from '../domain/table/Table';
@ -36,6 +37,8 @@ export class DeleteTableHandler implements ICommandHandler<DeleteTableCommand, D
private readonly tableRepository: TableRepositoryPort.ITableRepository,
@inject(v2CoreTokens.tableSchemaRepository)
private readonly tableSchemaRepository: TableSchemaRepositoryPort.ITableSchemaRepository,
@inject(v2CoreTokens.tableDeletionSideEffectService)
private readonly tableDeletionSideEffectService: TableDeletionSideEffectService,
@inject(v2CoreTokens.eventBus)
private readonly eventBus: EventBusPort.IEventBus,
@inject(v2CoreTokens.logger)
@ -52,6 +55,7 @@ export class DeleteTableHandler implements ICommandHandler<DeleteTableCommand, D
const logger = this.logger.scope('command', { name: DeleteTableHandler.name }).child({
baseId: command.baseId.toString(),
tableId: command.tableId.toString(),
mode: command.mode,
});
logger.debug('DeleteTableHandler.start', {
actorId: context.actorId.toString(),
@ -59,11 +63,24 @@ export class DeleteTableHandler implements ICommandHandler<DeleteTableCommand, D
const tableRepository = this.tableRepository;
const tableSchemaRepository = this.tableSchemaRepository;
const tableDeletionSideEffectService = this.tableDeletionSideEffectService;
const unitOfWork = this.unitOfWork;
const eventBus = this.eventBus;
const result = await safeTry<DeleteTableResult, DomainError>(async function* () {
const specResult = yield* TableAggregate.specs(command.baseId).byId(command.tableId).build();
const tableResult = await tableRepository.findOne(context, specResult);
const activeTableResult = await tableRepository.findOne(context, specResult);
let tableResult = activeTableResult;
let shouldRunSideEffects = activeTableResult.isOk();
if (
command.mode === 'permanent' &&
activeTableResult.isErr() &&
isNotFoundError(activeTableResult.error)
) {
tableResult = await tableRepository.findOne(context, specResult, { state: 'all' });
shouldRunSideEffects = false;
}
if (tableResult.isErr()) {
if (isNotFoundError(tableResult.error)) {
return err(domainError.notFound({ code: 'table.not_found', message: 'Table not found' }));
@ -71,18 +88,39 @@ export class DeleteTableHandler implements ICommandHandler<DeleteTableCommand, D
return err(tableResult.error);
}
const table = tableResult.value;
const sideEffectEvents: IDomainEvent[] = [];
const sideEffectPostPersistEvents: IDomainEvent[] = [];
yield* await unitOfWork.withTransaction(context, async (transactionContext) => {
const resultAsync = safeTry<void, DomainError>(async function* () {
yield* await tableSchemaRepository.delete(transactionContext, table);
yield* await tableRepository.delete(transactionContext, table);
if (shouldRunSideEffects) {
const sideEffectResult = yield* await tableDeletionSideEffectService.execute(
transactionContext,
{ table }
);
sideEffectEvents.push(...sideEffectResult.events);
sideEffectPostPersistEvents.push(...sideEffectResult.postPersistEvents);
}
yield* await tableSchemaRepository.delete(transactionContext, table, {
mode: command.mode,
});
yield* await tableRepository.delete(transactionContext, table, {
mode: command.mode,
});
return ok(undefined);
});
return await resultAsync;
});
yield* table.markDeleted();
const events = table.pullDomainEvents();
yield* await eventBus.publishMany(context, events);
return ok(DeleteTableResult.create(table, events));
if (command.mode === 'permanent') {
yield* table.markDeleted();
} else {
yield* table.markTrashed();
}
const responseEvents = [...sideEffectEvents, ...table.pullDomainEvents()];
yield* await eventBus.publishMany(context, [
...responseEvents,
...sideEffectPostPersistEvents,
]);
return ok(DeleteTableResult.create(table, responseEvents));
});
if (result.isOk()) {
logger.debug('DeleteTableHandler.success', {

View File

@ -17,6 +17,7 @@ import { RecordMutationSpecResolverService } from '../application/services/Recor
import { RecordWriteSideEffectService } from '../application/services/RecordWriteSideEffectService';
import { RecordWriteUndoRedoPlanService } from '../application/services/RecordWriteUndoRedoPlanService';
import { TableCreationService } from '../application/services/TableCreationService';
import { TableDeletionSideEffectService } from '../application/services/TableDeletionSideEffectService';
import { TableQueryService } from '../application/services/TableQueryService';
import { TableUpdateFlow } from '../application/services/TableUpdateFlow';
import { UndoRedoService } from '../application/services/UndoRedoService';
@ -49,6 +50,7 @@ import { v2CoreTokens } from '../ports/tokens';
* | fieldCreationSideEffectService | FieldCreationSideEffectService | Cross-table field creation side effects |
* | fieldDeletionSideEffectService | FieldDeletionSideEffectService | Cross-table field deletion side effects |
* | fieldUpdateSideEffectService | FieldUpdateSideEffectService | Cascading field updates for dependent fields |
* | tableDeletionSideEffectService | TableDeletionSideEffectService | Cross-table cleanup before table deletion |
* | foreignTableLoaderService | ForeignTableLoaderService | Load and validate foreign table references |
* | linkTitleResolverService | LinkTitleResolverService | Resolve link titles to record IDs |
* | attachmentValueResolverService | AttachmentValueResolverService | Resolve attachment values on writes |
@ -141,6 +143,16 @@ export const registerV2CoreServices = (
});
}
if (!container.isRegistered(v2CoreTokens.tableDeletionSideEffectService)) {
container.register(
v2CoreTokens.tableDeletionSideEffectService,
TableDeletionSideEffectService,
{
lifecycle,
}
);
}
if (!container.isRegistered(v2CoreTokens.fieldUndoRedoSnapshotService)) {
container.register(v2CoreTokens.fieldUndoRedoSnapshotService, FieldUndoRedoSnapshotService, {
lifecycle,

View File

@ -26,6 +26,10 @@ export class DomainEventName extends ValueObject {
return new DomainEventName('TableDeleted');
}
static tableTrashed(): DomainEventName {
return new DomainEventName('TableTrashed');
}
static tableRenamed(): DomainEventName {
return new DomainEventName('TableRenamed');
}

View File

@ -3,9 +3,15 @@ import type { Result } from 'neverthrow';
import type { DomainError } from '../shared/DomainError';
import type { ISpecification } from '../shared/specification/ISpecification';
import type { Field } from './fields/Field';
import type { FieldId } from './fields/FieldId';
import type { ITableSpecVisitor } from './specs/ITableSpecVisitor';
import type { Table } from './Table';
export type FieldDeletionReaction = {
readonly spec: ISpecification<Table, ITableSpecVisitor>;
readonly relatedFieldIds: ReadonlyArray<FieldId>;
};
/**
* Context provided to entities when processing field-deletion side effects.
*/
@ -32,7 +38,7 @@ export interface OnTeableFieldDeleted {
onFieldDeleted(
deletedField: Field,
context: FieldDeletionContext
): Result<ISpecification<Table, ITableSpecVisitor> | undefined, DomainError>;
): Result<FieldDeletionReaction | undefined, DomainError>;
}
/**

View File

@ -0,0 +1,66 @@
import type { Result } from 'neverthrow';
import type { IExecutionContext } from '../../ports/ExecutionContext';
import type { DomainError } from '../shared/DomainError';
import type { IDomainEvent } from '../shared/DomainEvent';
import type { ISpecification } from '../shared/specification/ISpecification';
import type { FieldId } from './fields/FieldId';
import type { ITableSpecVisitor } from './specs/ITableSpecVisitor';
import type { Table } from './Table';
export type TableDeletionAfterPersistHook = (
context: IExecutionContext,
updatedTable: Table,
deletedTable: Table
) => Promise<Result<{ events: ReadonlyArray<IDomainEvent>; table: Table }, DomainError>>;
export type TableDeletionReaction = {
readonly spec: ISpecification<Table, ITableSpecVisitor>;
readonly afterPersist?: TableDeletionAfterPersistHook;
};
export type TableDeletionHookFactory = {
readonly createFieldUpdateAfterPersistHook: (
fieldId: FieldId,
updateSpec: ISpecification<Table, ITableSpecVisitor>
) => TableDeletionAfterPersistHook;
};
/**
* Context provided to entities when processing table-deletion side effects.
*/
export type TableDeletionContext = {
/** The table containing the entity that is handling the deletion */
readonly table: Table;
/** Hook factory implemented by the application layer */
readonly hooks: TableDeletionHookFactory;
};
/**
* Interface for entities that need to respond when a table is deleted.
*/
export interface OnTeableTableDeleted {
/**
* Called when a table is deleted.
*
* @param deletedTable The table that is being deleted
* @param context Additional context including the host table state
* @returns A composed spec to apply in response to deletion, or undefined
*/
onTableDeleted(
deletedTable: Table,
context: TableDeletionContext
): Result<TableDeletionReaction | undefined, DomainError>;
}
/**
* Type guard to check if an entity implements OnTeableTableDeleted.
*/
export function implementsOnTeableTableDeleted(entity: unknown): entity is OnTeableTableDeleted {
return (
entity != null &&
typeof entity === 'object' &&
'onTableDeleted' in entity &&
typeof (entity as OnTeableTableDeleted).onTableDeleted === 'function'
);
}

View File

@ -7,6 +7,7 @@ import { FieldDeleted } from './events/FieldDeleted';
import { FieldUpdated } from './events/FieldUpdated';
import { TableCreated } from './events/TableCreated';
import { TableDeleted } from './events/TableDeleted';
import { TableTrashed } from './events/TableTrashed';
import { TableRenamed } from './events/TableRenamed';
import { DbFieldName } from './fields/DbFieldName';
import { Field } from './fields/Field';
@ -111,6 +112,31 @@ describe('Table', () => {
expect(events[0]).toBeInstanceOf(TableDeleted);
});
it('emits TableTrashed when marking trashed', () => {
const baseIdResult = createBaseId('y');
const tableNameResult = TableName.create('Trash');
const fieldNameResult = FieldName.create('Title');
const viewNameResult = ViewName.create('Grid');
[baseIdResult, tableNameResult, fieldNameResult, viewNameResult].forEach((r) =>
r._unsafeUnwrap()
);
const builder = Table.builder()
.withBaseId(baseIdResult._unsafeUnwrap())
.withName(tableNameResult._unsafeUnwrap());
builder.field().singleLineText().withName(fieldNameResult._unsafeUnwrap()).done();
builder.view().grid().withName(viewNameResult._unsafeUnwrap()).done();
const table = builder.build()._unsafeUnwrap();
table.pullDomainEvents();
table.markTrashed()._unsafeUnwrap();
const events = table.pullDomainEvents();
expect(events.length).toBe(1);
expect(events[0]).toBeInstanceOf(TableTrashed);
});
it('rehydrates without emitting events', () => {
const baseIdResult = createBaseId('b');
const tableIdResult = createTableId('b');

View File

@ -16,6 +16,7 @@ import type { RecordCreateSource } from './events/RecordFieldValuesDTO';
import { TableActionTriggerRequested } from './events/TableActionTriggerRequested';
import { TableCreated } from './events/TableCreated';
import { TableDeleted } from './events/TableDeleted';
import { TableTrashed } from './events/TableTrashed';
import type { DbFieldName } from './fields/DbFieldName';
import type { Field } from './fields/Field';
import type { FieldId } from './fields/FieldId';
@ -584,6 +585,19 @@ export class Table extends AggregateRoot<TableId> {
return ok(undefined);
}
markTrashed(): Result<void, DomainError> {
this.addDomainEvent(
TableTrashed.create({
tableId: this.id(),
baseId: this.baseIdValue,
tableName: this.nameValue,
fieldIds: this.fieldIds(),
viewIds: this.viewIds(),
})
);
return ok(undefined);
}
requestActionTrigger(params: {
actionKey: ITableActionKey;
payload?: Record<string, unknown>;

View File

@ -0,0 +1,37 @@
import type { BaseId } from '../../base/BaseId';
import type { IDomainEvent } from '../../shared/DomainEvent';
import { DomainEventName } from '../../shared/DomainEventName';
import { OccurredAt } from '../../shared/OccurredAt';
import type { FieldId } from '../fields/FieldId';
import type { TableId } from '../TableId';
import type { TableName } from '../TableName';
import type { ViewId } from '../views/ViewId';
export class TableTrashed implements IDomainEvent {
readonly name = DomainEventName.tableTrashed();
readonly occurredAt = OccurredAt.now();
private constructor(
readonly tableId: TableId,
readonly baseId: BaseId,
readonly tableName: TableName,
readonly fieldIds: ReadonlyArray<FieldId>,
readonly viewIds: ReadonlyArray<ViewId>
) {}
static create(params: {
tableId: TableId;
baseId: BaseId;
tableName: TableName;
fieldIds: ReadonlyArray<FieldId>;
viewIds: ReadonlyArray<ViewId>;
}): TableTrashed {
return new TableTrashed(
params.tableId,
params.baseId,
params.tableName,
[...params.fieldIds],
[...params.viewIds]
);
}
}

View File

@ -1,3 +1,4 @@
import { ok } from 'neverthrow';
import { describe, expect, it } from 'vitest';
import { BaseId } from '../../../base/BaseId';
@ -14,6 +15,7 @@ import { CellValueMultiplicity } from './CellValueMultiplicity';
import { CellValueType } from './CellValueType';
import { ConditionalLookupField } from './ConditionalLookupField';
import { ConditionalLookupOptions } from './ConditionalLookupOptions';
import { FieldHasError } from './FieldHasError';
import { FormulaExpression } from './FormulaExpression';
import { FormulaField } from './FormulaField';
import { LongTextField } from './LongTextField';
@ -649,9 +651,13 @@ describe('ConditionalLookupField.onDependencyUpdated', () => {
previousSourceTable: foreignTable,
});
expect(result.isOk()).toBe(true);
expect(result._unsafeUnwrap()).toBeInstanceOf(TableUpdateFieldTypeSpec);
const reaction = result._unsafeUnwrap();
expect(reaction?.spec).toBeInstanceOf(TableUpdateFieldTypeSpec);
expect(reaction?.relatedFieldIds.map((id) => id.toString())).toEqual([
conditionalLookup.id().toString(),
]);
const spec = result._unsafeUnwrap() as TableUpdateFieldTypeSpec;
const spec = reaction?.spec as TableUpdateFieldTypeSpec;
const nextField = spec.newField() as ConditionalLookupField;
const nextCondition = nextField.conditionalLookupOptions().condition().toDto();
@ -662,4 +668,142 @@ describe('ConditionalLookupField.onDependencyUpdated', () => {
filterSet: [{ fieldId: lookupFieldId.toString(), operator: 'isNotEmpty' }],
});
});
it('preserves existing error when the foreign sort field is deleted', () => {
const foreignTableId = createTableId('q');
const lookupFieldId = createFieldId('r');
const sortFieldId = createFieldId('s');
const hostPrimaryFieldId = createFieldId('t');
const conditionalLookupFieldId = createFieldId('u');
const conditionalLookup = ConditionalLookupField.create({
id: conditionalLookupFieldId,
name: FieldName.create('Conditional Lookup Sorted')._unsafeUnwrap(),
innerField: SingleLineTextField.create({
id: createFieldId('v'),
name: FieldName.create('Title')._unsafeUnwrap(),
})._unsafeUnwrap(),
conditionalLookupOptions: ConditionalLookupOptions.create({
foreignTableId: foreignTableId.toString(),
lookupFieldId: lookupFieldId.toString(),
condition: {
filter: {
conjunction: 'and',
filterSet: [{ fieldId: lookupFieldId.toString(), operator: 'isNotEmpty' }],
},
sort: { fieldId: sortFieldId.toString(), order: 'desc' },
limit: 2,
},
})._unsafeUnwrap(),
})._unsafeUnwrap();
const hostTableBuilder = Table.builder()
.withId(createTableId('w'))
.withBaseId(createBaseId('x'))
.withName(TableName.create('Host')._unsafeUnwrap());
hostTableBuilder
.field()
.singleLineText()
.withId(hostPrimaryFieldId)
.withName(FieldName.create('Host Primary')._unsafeUnwrap())
.primary()
.done();
hostTableBuilder.view().defaultGrid().done();
const hostTable = hostTableBuilder.build()._unsafeUnwrap();
const foreignTableBuilder = Table.builder()
.withId(foreignTableId)
.withBaseId(createBaseId('x'))
.withName(TableName.create('Foreign')._unsafeUnwrap());
foreignTableBuilder
.field()
.singleLineText()
.withId(lookupFieldId)
.withName(FieldName.create('Lookup')._unsafeUnwrap())
.primary()
.done();
foreignTableBuilder
.field()
.number()
.withId(sortFieldId)
.withName(FieldName.create('Score')._unsafeUnwrap())
.done();
foreignTableBuilder.view().defaultGrid().done();
const foreignTable = foreignTableBuilder.build()._unsafeUnwrap();
const hostWithConditionalLookup = hostTable
.addField(conditionalLookup, { foreignTables: [foreignTable] })
._unsafeUnwrap();
const fieldInHost = hostWithConditionalLookup
.getField((field) => field.id().equals(conditionalLookupFieldId))
._unsafeUnwrap() as ConditionalLookupField;
fieldInHost.setHasError(FieldHasError.error());
const deletedField = foreignTable.getFields().find((field) => field.id().equals(sortFieldId));
expect(deletedField).toBeDefined();
if (!deletedField) return;
const result = fieldInHost.onFieldDeleted(deletedField, {
table: hostWithConditionalLookup,
sourceTable: foreignTable,
previousSourceTable: foreignTable,
});
expect(result.isOk()).toBe(true);
expect(result._unsafeUnwrap()?.relatedFieldIds.map((id) => id.toString())).toEqual([
conditionalLookupFieldId.toString(),
]);
const updatedTable = result._unsafeUnwrap()?.spec.mutate(hostWithConditionalLookup);
expect(updatedTable?.isOk()).toBe(true);
const updatedField = updatedTable
?._unsafeUnwrap()
.getField((field) => field.id().equals(conditionalLookupFieldId));
expect(updatedField?.isOk()).toBe(true);
const nextField = updatedField?._unsafeUnwrap() as ConditionalLookupField;
expect(nextField.hasError().isError()).toBe(true);
expect(nextField.conditionalLookupOptions().condition().toDto().sort).toBeUndefined();
});
it('sets hasError when the foreign table is deleted', () => {
const foreignTableId = createTableId('y');
const lookupFieldId = createFieldId('z');
const sortFieldId = createFieldId('a');
const conditionalLookup = ConditionalLookupField.create({
id: createFieldId('b'),
name: FieldName.create('Conditional Lookup')._unsafeUnwrap(),
innerField: SingleLineTextField.create({
id: createFieldId('c'),
name: FieldName.create('Lookup Inner')._unsafeUnwrap(),
})._unsafeUnwrap(),
conditionalLookupOptions: ConditionalLookupOptions.create({
foreignTableId: foreignTableId.toString(),
lookupFieldId: lookupFieldId.toString(),
condition: {
filter: {
conjunction: 'and',
filterSet: [{ fieldId: lookupFieldId.toString(), operator: 'isNotEmpty' }],
},
sort: { fieldId: sortFieldId.toString(), order: 'desc' },
limit: 2,
},
})._unsafeUnwrap(),
})._unsafeUnwrap();
const result = conditionalLookup.onTableDeleted({ id: () => foreignTableId } as never, {
table: {} as never,
hooks: {
createFieldUpdateAfterPersistHook: () => async () =>
ok({
events: [],
table: {} as never,
}),
},
});
expect(result.isOk()).toBe(true);
const reaction = result._unsafeUnwrap();
expect(reaction?.spec).toBeInstanceOf(TableUpdateFieldHasErrorSpec);
expect(reaction?.afterPersist).toBeUndefined();
});
});

View File

@ -5,7 +5,16 @@ import { domainError, type DomainError } from '../../../shared/DomainError';
import { composeAndSpecsOrUndefined } from '../../../shared/specification/composeAndSpecs';
import type { ISpecification } from '../../../shared/specification/ISpecification';
import { ForeignTable } from '../../ForeignTable';
import type { FieldDeletionContext, OnTeableFieldDeleted } from '../../OnTeableFieldDeleted';
import type {
FieldDeletionContext,
FieldDeletionReaction,
OnTeableFieldDeleted,
} from '../../OnTeableFieldDeleted';
import type {
OnTeableTableDeleted,
TableDeletionContext,
TableDeletionReaction,
} from '../../OnTeableTableDeleted';
import type { ITableSpecVisitor } from '../../specs/ITableSpecVisitor';
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
@ -64,7 +73,11 @@ import { SingleSelectField } from './SingleSelectField';
*/
export class ConditionalLookupField
extends Field
implements ForeignTableRelatedField, OnTeableFieldUpdated, OnTeableFieldDeleted
implements
ForeignTableRelatedField,
OnTeableFieldUpdated,
OnTeableFieldDeleted,
OnTeableTableDeleted
{
private innerFieldValue: Field | undefined;
private innerOptionsPatchValue: Readonly<Record<string, unknown>> | undefined;
@ -574,7 +587,7 @@ export class ConditionalLookupField
onFieldDeleted(
deletedField: Field,
context: FieldDeletionContext
): Result<ISpecification<Table, ITableSpecVisitor> | undefined, DomainError> {
): Result<FieldDeletionReaction | undefined, DomainError> {
const deletedFromHostTable = context.sourceTable.id().equals(context.table.id());
const deletedFromForeignTable = context.sourceTable.id().equals(this.foreignTableId());
const optionsDto = this.conditionalLookupOptionsValue.toDto();
@ -626,7 +639,22 @@ export class ConditionalLookupField
return err(nextFieldResult.error);
}
return ok(TableUpdateFieldTypeSpec.create(this, nextFieldResult.value));
const specs: Array<ISpecification<Table, ITableSpecVisitor>> = [
TableUpdateFieldTypeSpec.create(this, nextFieldResult.value),
];
if (this.hasError().isError()) {
specs.push(TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()));
}
const spec = composeAndSpecsOrUndefined(specs);
if (!spec) {
return ok(undefined);
}
return ok({
spec,
relatedFieldIds: [this.id()],
});
}
const shouldSetError =
@ -638,7 +666,23 @@ export class ConditionalLookupField
return ok(undefined);
}
return ok(TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()));
return ok({
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
relatedFieldIds: [this.id()],
});
}
onTableDeleted(
deletedTable: Table,
_context: TableDeletionContext
): Result<TableDeletionReaction | undefined, DomainError> {
if (!deletedTable.id().equals(this.foreignTableId()) || this.hasError().isError()) {
return ok(undefined);
}
return ok({
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
});
}
private ensureForeignTable(foreignTable: ForeignTable): Result<void, DomainError> {

View File

@ -1,16 +1,21 @@
import { ok } from 'neverthrow';
import { describe, expect, it } from 'vitest';
import { BaseId } from '../../../base/BaseId';
import { UpdateSingleSelectOptionsSpec } from '../../specs/field-updates/UpdateSingleSelectOptionsSpec';
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
import { Table } from '../../Table';
import { TableId } from '../../TableId';
import { TableName } from '../../TableName';
import { DbFieldName } from '../DbFieldName';
import { FieldId } from '../FieldId';
import { FieldName } from '../FieldName';
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
import { UpdateSingleSelectOptionsSpec } from '../../specs/field-updates/UpdateSingleSelectOptionsSpec';
import { TableId } from '../../TableId';
import { CellValueMultiplicity } from './CellValueMultiplicity';
import { CellValueType } from './CellValueType';
import { ConditionalRollupConfig } from './ConditionalRollupConfig';
import { ConditionalRollupField } from './ConditionalRollupField';
import { FieldHasError } from './FieldHasError';
import { LongTextField } from './LongTextField';
import { NumberField } from './NumberField';
import { RollupExpression } from './RollupExpression';
@ -20,6 +25,7 @@ import { SingleSelectField } from './SingleSelectField';
const createFieldId = (seed: string) => FieldId.create(`fld${seed.repeat(16)}`)._unsafeUnwrap();
const createTableId = (seed: string) => TableId.create(`tbl${seed.repeat(16)}`)._unsafeUnwrap();
const createBaseId = (seed: string) => BaseId.create(`bse${seed.repeat(16)}`)._unsafeUnwrap();
const createConditionalRollupField = (statusFieldId: FieldId) => {
const config = ConditionalRollupConfig.create({
@ -322,9 +328,11 @@ describe('ConditionalRollupField.onFieldDeleted', () => {
});
expect(result.isOk()).toBe(true);
expect(result._unsafeUnwrap()).toBeInstanceOf(TableUpdateFieldTypeSpec);
const reaction = result._unsafeUnwrap();
expect(reaction?.spec).toBeInstanceOf(TableUpdateFieldTypeSpec);
expect(reaction?.relatedFieldIds.map((id) => id.toString())).toEqual([field.id().toString()]);
const spec = result._unsafeUnwrap() as TableUpdateFieldTypeSpec;
const spec = reaction?.spec as TableUpdateFieldTypeSpec;
const nextField = spec.newField() as ConditionalRollupField;
const nextCondition = nextField.config().condition().toDto();
expect(nextCondition.filter).toEqual({
@ -334,4 +342,144 @@ describe('ConditionalRollupField.onFieldDeleted', () => {
expect(nextCondition.sort).toBeUndefined();
expect(nextCondition.limit).toBe(1);
});
it('preserves existing error when the foreign sort field is deleted', () => {
const foreignTableId = createTableId('d');
const lookupFieldId = createFieldId('e');
const sortFieldId = createFieldId('f');
const hostPrimaryFieldId = createFieldId('g');
const conditionalRollupFieldId = createFieldId('h');
const conditionalRollup = ConditionalRollupField.createPending({
id: conditionalRollupFieldId,
name: FieldName.create('Conditional Rollup Sorted')._unsafeUnwrap(),
config: ConditionalRollupConfig.create({
foreignTableId: foreignTableId.toString(),
lookupFieldId: lookupFieldId.toString(),
condition: {
filter: {
conjunction: 'and',
filterSet: [{ fieldId: lookupFieldId.toString(), operator: 'is', value: 'Active' }],
},
sort: { fieldId: sortFieldId.toString(), order: 'asc' },
limit: 1,
},
})._unsafeUnwrap(),
expression: RollupExpression.default(),
resultType: {
cellValueType: CellValueType.number(),
isMultipleCellValue: CellValueMultiplicity.single(),
},
})._unsafeUnwrap();
const hostTableBuilder = Table.builder()
.withId(createTableId('i'))
.withBaseId(createBaseId('j'))
.withName(TableName.create('Host')._unsafeUnwrap());
hostTableBuilder
.field()
.singleLineText()
.withId(hostPrimaryFieldId)
.withName(FieldName.create('Host Primary')._unsafeUnwrap())
.primary()
.done();
hostTableBuilder.view().defaultGrid().done();
const hostTable = hostTableBuilder.build()._unsafeUnwrap();
const foreignTableBuilder = Table.builder()
.withId(foreignTableId)
.withBaseId(createBaseId('j'))
.withName(TableName.create('Foreign')._unsafeUnwrap());
foreignTableBuilder
.field()
.singleLineText()
.withId(lookupFieldId)
.withName(FieldName.create('Lookup')._unsafeUnwrap())
.primary()
.done();
foreignTableBuilder
.field()
.number()
.withId(sortFieldId)
.withName(FieldName.create('Score')._unsafeUnwrap())
.done();
foreignTableBuilder.view().defaultGrid().done();
const foreignTable = foreignTableBuilder.build()._unsafeUnwrap();
const hostWithConditionalRollup = hostTable
.addField(conditionalRollup, { foreignTables: [foreignTable] })
._unsafeUnwrap();
const fieldInHost = hostWithConditionalRollup
.getField((field) => field.id().equals(conditionalRollupFieldId))
._unsafeUnwrap() as ConditionalRollupField;
fieldInHost.setHasError(FieldHasError.error());
const deletedField = foreignTable.getFields().find((field) => field.id().equals(sortFieldId));
expect(deletedField).toBeDefined();
if (!deletedField) return;
const result = fieldInHost.onFieldDeleted(deletedField, {
table: hostWithConditionalRollup,
sourceTable: foreignTable,
previousSourceTable: foreignTable,
});
expect(result.isOk()).toBe(true);
expect(result._unsafeUnwrap()?.relatedFieldIds.map((id) => id.toString())).toEqual([
conditionalRollupFieldId.toString(),
]);
const updatedTable = result._unsafeUnwrap()?.spec.mutate(hostWithConditionalRollup);
expect(updatedTable?.isOk()).toBe(true);
const updatedField = updatedTable
?._unsafeUnwrap()
.getField((field) => field.id().equals(conditionalRollupFieldId));
expect(updatedField?.isOk()).toBe(true);
const nextField = updatedField?._unsafeUnwrap() as ConditionalRollupField;
expect(nextField.hasError().isError()).toBe(true);
expect(nextField.config().condition().toDto().sort).toBeUndefined();
});
it('sets hasError when the foreign table is deleted', () => {
const foreignTableId = createTableId('k');
const lookupFieldId = createFieldId('l');
const sortFieldId = createFieldId('m');
const conditionalRollup = ConditionalRollupField.create({
id: createFieldId('n'),
name: FieldName.create('Conditional Rollup')._unsafeUnwrap(),
config: ConditionalRollupConfig.create({
foreignTableId: foreignTableId.toString(),
lookupFieldId: lookupFieldId.toString(),
condition: {
filter: {
conjunction: 'and',
filterSet: [{ fieldId: lookupFieldId.toString(), operator: 'is', value: 'Active' }],
},
sort: { fieldId: sortFieldId.toString(), order: 'asc' },
limit: 1,
},
})._unsafeUnwrap(),
expression: RollupExpression.create('sum({values})')._unsafeUnwrap(),
valuesField: NumberField.create({
id: lookupFieldId,
name: FieldName.create('Amount')._unsafeUnwrap(),
})._unsafeUnwrap(),
})._unsafeUnwrap();
const result = conditionalRollup.onTableDeleted({ id: () => foreignTableId } as never, {
table: {} as never,
hooks: {
createFieldUpdateAfterPersistHook: () => async () =>
ok({
events: [],
table: {} as never,
}),
},
});
expect(result.isOk()).toBe(true);
const reaction = result._unsafeUnwrap();
expect(reaction?.spec).toBeInstanceOf(TableUpdateFieldHasErrorSpec);
expect(reaction?.afterPersist).toBeUndefined();
});
});

View File

@ -4,8 +4,17 @@ import type { Result } from 'neverthrow';
import { domainError, type DomainError } from '../../../shared/DomainError';
import { composeAndSpecsOrUndefined } from '../../../shared/specification/composeAndSpecs';
import type { ISpecification } from '../../../shared/specification/ISpecification';
import type { FieldDeletionContext, OnTeableFieldDeleted } from '../../OnTeableFieldDeleted';
import { ForeignTable } from '../../ForeignTable';
import type {
FieldDeletionContext,
FieldDeletionReaction,
OnTeableFieldDeleted,
} from '../../OnTeableFieldDeleted';
import type {
OnTeableTableDeleted,
TableDeletionContext,
TableDeletionReaction,
} from '../../OnTeableTableDeleted';
import type { ITableSpecVisitor } from '../../specs/ITableSpecVisitor';
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
@ -77,7 +86,11 @@ type ConditionalRollupValuesType = {
*/
export class ConditionalRollupField
extends Field
implements ForeignTableRelatedField, OnTeableFieldUpdated, OnTeableFieldDeleted
implements
ForeignTableRelatedField,
OnTeableFieldUpdated,
OnTeableFieldDeleted,
OnTeableTableDeleted
{
private constructor(
id: FieldId,
@ -618,7 +631,7 @@ export class ConditionalRollupField
onFieldDeleted(
deletedField: Field,
context: FieldDeletionContext
): Result<ISpecification<Table, ITableSpecVisitor> | undefined, DomainError> {
): Result<FieldDeletionReaction | undefined, DomainError> {
const deletedFromHostTable = context.sourceTable.id().equals(context.table.id());
const deletedFromForeignTable = context.sourceTable.id().equals(this.foreignTableId());
const configDto = this.configValue.toDto();
@ -666,7 +679,22 @@ export class ConditionalRollupField
return err(nextFieldResult.error);
}
return ok(TableUpdateFieldTypeSpec.create(this, nextFieldResult.value));
const specs: Array<ISpecification<Table, ITableSpecVisitor>> = [
TableUpdateFieldTypeSpec.create(this, nextFieldResult.value),
];
if (this.hasError().isError()) {
specs.push(TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()));
}
const spec = composeAndSpecsOrUndefined(specs);
if (!spec) {
return ok(undefined);
}
return ok({
spec,
relatedFieldIds: [this.id()],
});
}
const shouldSetError =
@ -678,7 +706,23 @@ export class ConditionalRollupField
return ok(undefined);
}
return ok(TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()));
return ok({
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
relatedFieldIds: [this.id()],
});
}
onTableDeleted(
deletedTable: Table,
_context: TableDeletionContext
): Result<TableDeletionReaction | undefined, DomainError> {
if (!deletedTable.id().equals(this.foreignTableId()) || this.hasError().isError()) {
return ok(undefined);
}
return ok({
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
});
}
private ensureForeignTable(foreignTable: ForeignTable): Result<void, DomainError> {

View File

@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { BaseId } from '../../../base/BaseId';
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
import { Table } from '../../Table';
import { TableId } from '../../TableId';
import { TableName } from '../../TableName';
import { FieldId } from '../FieldId';
import { FieldName } from '../FieldName';
import { CellValueMultiplicity } from './CellValueMultiplicity';
@ -237,4 +242,60 @@ describe('FormulaField', () => {
const notPersisted = fieldWithoutMetaValue.isPersistedAsGeneratedColumn();
expect(notPersisted._unsafeUnwrap()).toBe(false);
});
it('returns field deletion reaction metadata when a dependency is deleted', () => {
const baseId = BaseId.create(`bse${'q'.repeat(16)}`)._unsafeUnwrap();
const tableId = TableId.create(`tbl${'r'.repeat(16)}`)._unsafeUnwrap();
const primaryFieldId = createFieldId('s')._unsafeUnwrap();
const deletedFieldId = createFieldId('t')._unsafeUnwrap();
const formulaFieldId = createFieldId('u')._unsafeUnwrap();
const builder = Table.builder()
.withId(tableId)
.withBaseId(baseId)
.withName(TableName.create('Formula Host')._unsafeUnwrap());
builder
.field()
.singleLineText()
.withId(primaryFieldId)
.withName(FieldName.create('Title')._unsafeUnwrap())
.primary()
.done();
builder
.field()
.number()
.withId(deletedFieldId)
.withName(FieldName.create('Amount')._unsafeUnwrap())
.done();
builder.view().defaultGrid().done();
const baseTable = builder.build()._unsafeUnwrap();
const formulaField = FormulaField.create({
id: formulaFieldId,
name: FieldName.create('Derived')._unsafeUnwrap(),
expression: FormulaExpression.create('1')._unsafeUnwrap(),
timeZone: TimeZone.default(),
resultType: {
cellValueType: CellValueType.number(),
isMultipleCellValue: CellValueMultiplicity.single(),
},
dependencies: [deletedFieldId],
})._unsafeUnwrap();
const deletedField = baseTable
.getField((field) => field.id().equals(deletedFieldId))
._unsafeUnwrap();
const result = formulaField.onFieldDeleted(deletedField, {
table: baseTable,
sourceTable: baseTable,
previousSourceTable: baseTable,
});
expect(result.isOk()).toBe(true);
expect(result._unsafeUnwrap()?.spec).toBeInstanceOf(TableUpdateFieldHasErrorSpec);
expect(result._unsafeUnwrap()?.relatedFieldIds.map((id) => id.toString())).toEqual([
formulaFieldId.toString(),
]);
});
});

View File

@ -4,7 +4,11 @@ import type { Result } from 'neverthrow';
import { domainError, type DomainError } from '../../../shared/DomainError';
import { composeAndSpecsOrUndefined } from '../../../shared/specification/composeAndSpecs';
import type { ISpecification } from '../../../shared/specification/ISpecification';
import type { FieldDeletionContext, OnTeableFieldDeleted } from '../../OnTeableFieldDeleted';
import type {
FieldDeletionContext,
FieldDeletionReaction,
OnTeableFieldDeleted,
} from '../../OnTeableFieldDeleted';
import { UpdateFormulaExpressionSpec } from '../../specs/field-updates/UpdateFormulaExpressionSpec';
import type { ITableSpecVisitor } from '../../specs/ITableSpecVisitor';
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
@ -343,7 +347,7 @@ export class FormulaField extends Field implements OnTeableFieldUpdated, OnTeabl
onFieldDeleted(
deletedField: Field,
context: FieldDeletionContext
): Result<ISpecification<Table, ITableSpecVisitor> | undefined, DomainError> {
): Result<FieldDeletionReaction | undefined, DomainError> {
const deletedFromHostTable = context.sourceTable.id().equals(context.table.id());
if (!deletedFromHostTable || this.hasError().isError()) {
return ok(undefined);
@ -354,7 +358,10 @@ export class FormulaField extends Field implements OnTeableFieldUpdated, OnTeabl
return ok(undefined);
}
return ok(TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()));
return ok({
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
relatedFieldIds: [this.id()],
});
}
private dependsOnDeletedField(

View File

@ -1,4 +1,4 @@
import type { Result } from 'neverthrow';
import { ok, type Result } from 'neverthrow';
import { describe, expect, it } from 'vitest';
import { BaseId } from '../../../base/BaseId';
@ -6,6 +6,7 @@ import type { DomainError } from '../../../shared/DomainError';
import { ForeignTable } from '../../ForeignTable';
import { UpdateLinkConfigSpec } from '../../specs/field-updates/UpdateLinkConfigSpec';
import { UpdateSingleSelectOptionsSpec } from '../../specs/field-updates/UpdateSingleSelectOptionsSpec';
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
import { Table } from '../../Table';
import { TableId } from '../../TableId';
import { TableName } from '../../TableName';
@ -13,6 +14,7 @@ import { ViewId } from '../../views/ViewId';
import { DbFieldName } from '../DbFieldName';
import { FieldId } from '../FieldId';
import { FieldName } from '../FieldName';
import { FieldHasError } from './FieldHasError';
import { LinkField } from './LinkField';
import { LinkFieldConfig } from './LinkFieldConfig';
import { LinkFieldMeta } from './LinkFieldMeta';
@ -989,9 +991,13 @@ describe('LinkField', () => {
previousSourceTable: foreignTable,
});
expect(result.isOk()).toBe(true);
expect(result._unsafeUnwrap()).toBeInstanceOf(UpdateLinkConfigSpec);
const reaction = result._unsafeUnwrap();
expect(reaction?.spec).toBeInstanceOf(UpdateLinkConfigSpec);
expect(reaction?.relatedFieldIds.map((id) => id.toString())).toEqual([
linkFieldId.toString(),
]);
const spec = result._unsafeUnwrap() as UpdateLinkConfigSpec;
const spec = reaction?.spec as UpdateLinkConfigSpec;
expect(spec.nextConfig().lookupFieldId().equals(foreignPrimaryFieldId)).toBe(true);
});
@ -1072,11 +1078,103 @@ describe('LinkField', () => {
previousSourceTable: foreignTable,
});
expect(result.isOk()).toBe(true);
expect(result._unsafeUnwrap()).toBeInstanceOf(UpdateLinkConfigSpec);
const reaction = result._unsafeUnwrap();
expect(reaction?.spec).toBeInstanceOf(UpdateLinkConfigSpec);
expect(reaction?.relatedFieldIds.map((id) => id.toString())).toEqual([
linkFieldId.toString(),
]);
const spec = result._unsafeUnwrap() as UpdateLinkConfigSpec;
const spec = reaction?.spec as UpdateLinkConfigSpec;
expect(spec.nextConfig().filter()).toBeNull();
expect(spec.nextConfig().visibleFieldIds()).toBeNull();
});
});
describe('onTableDeleted', () => {
it('converts a link field to singleLineText when its foreign table is deleted', () => {
const baseId = createBaseId('m')._unsafeUnwrap();
const hostTableId = createTableId('n')._unsafeUnwrap();
const foreignTableId = createTableId('o')._unsafeUnwrap();
const hostPrimaryFieldId = createFieldId('p')._unsafeUnwrap();
const foreignPrimaryFieldId = createFieldId('q')._unsafeUnwrap();
const linkFieldId = createFieldId('r')._unsafeUnwrap();
const foreignBuilder = Table.builder()
.withId(foreignTableId)
.withBaseId(baseId)
.withName(TableName.create('Foreign Delete')._unsafeUnwrap());
foreignBuilder
.field()
.singleLineText()
.withId(foreignPrimaryFieldId)
.withName(FieldName.create('Name')._unsafeUnwrap())
.primary()
.done();
foreignBuilder.view().defaultGrid().done();
const foreignTable = foreignBuilder.build()._unsafeUnwrap();
const hostBuilder = Table.builder()
.withId(hostTableId)
.withBaseId(baseId)
.withName(TableName.create('Host Delete')._unsafeUnwrap());
hostBuilder
.field()
.singleLineText()
.withId(hostPrimaryFieldId)
.withName(FieldName.create('Title')._unsafeUnwrap())
.primary()
.done();
hostBuilder.view().defaultGrid().done();
const hostTable = hostBuilder.build()._unsafeUnwrap();
const linkField = LinkField.create({
id: linkFieldId,
name: FieldName.create('Foreign Link')._unsafeUnwrap(),
config: LinkFieldConfig.create({
relationship: 'manyMany',
foreignTableId: foreignTableId.toString(),
lookupFieldId: foreignPrimaryFieldId.toString(),
isOneWay: true,
fkHostTableName: 'delete_link_table',
selfKeyName: '__id',
foreignKeyName: '__fk_delete_link',
})._unsafeUnwrap(),
})._unsafeUnwrap();
linkField.setHasError(FieldHasError.error());
linkField.setDescription('preserve me')._unsafeUnwrap();
const hostWithLink = hostTable
.addField(linkField, { foreignTables: [foreignTable] })
._unsafeUnwrap();
const fieldInHost = hostWithLink
.getField((field) => field.id().equals(linkFieldId))
._unsafeUnwrap() as LinkField;
const result = fieldInHost.onTableDeleted(foreignTable, {
table: hostWithLink,
hooks: {
createFieldUpdateAfterPersistHook: () => async () =>
ok({
events: [],
table: hostWithLink,
}),
},
});
expect(result.isOk()).toBe(true);
const reaction = result._unsafeUnwrap();
expect(reaction?.spec).toBeInstanceOf(TableUpdateFieldTypeSpec);
expect(typeof reaction?.afterPersist).toBe('function');
const updatedTable = reaction?.spec.mutate(hostWithLink);
expect(updatedTable?.isOk()).toBe(true);
const updatedField = updatedTable
?._unsafeUnwrap()
.getField((field) => field.id().equals(linkFieldId));
expect(updatedField?.isOk()).toBe(true);
expect(updatedField?._unsafeUnwrap().type().toString()).toBe('singleLineText');
expect(updatedField?._unsafeUnwrap().hasError().isError()).toBe(true);
expect(updatedField?._unsafeUnwrap().description()).toBe('preserve me');
});
});
});

View File

@ -5,12 +5,22 @@ import type { BaseId } from '../../../base/BaseId';
import { domainError, type DomainError } from '../../../shared/DomainError';
import { composeAndSpecsOrUndefined } from '../../../shared/specification/composeAndSpecs';
import type { ISpecification } from '../../../shared/specification/ISpecification';
import type { FieldDeletionContext, OnTeableFieldDeleted } from '../../OnTeableFieldDeleted';
import { DbTableName } from '../../DbTableName';
import { ForeignTable } from '../../ForeignTable';
import type {
FieldDeletionContext,
FieldDeletionReaction,
OnTeableFieldDeleted,
} from '../../OnTeableFieldDeleted';
import type {
OnTeableTableDeleted,
TableDeletionContext,
TableDeletionReaction,
} from '../../OnTeableTableDeleted';
import { UpdateLinkConfigSpec } from '../../specs/field-updates/UpdateLinkConfigSpec';
import type { ITableSpecVisitor } from '../../specs/ITableSpecVisitor';
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
import type { Table } from '../../Table';
import type { TableId } from '../../TableId';
import type { ViewId } from '../../views/ViewId';
@ -41,10 +51,15 @@ import {
} from './LinkFieldConfig';
import { LinkFieldMeta, type LinkFieldMetaValue } from './LinkFieldMeta';
import type { LinkRelationship } from './LinkRelationship';
import { SingleLineTextField } from './SingleLineTextField';
export class LinkField
extends Field
implements ForeignTableRelatedField, OnTeableFieldUpdated, OnTeableFieldDeleted
implements
ForeignTableRelatedField,
OnTeableFieldUpdated,
OnTeableFieldDeleted,
OnTeableTableDeleted
{
private constructor(
id: FieldId,
@ -365,7 +380,7 @@ export class LinkField
onFieldDeleted(
deletedField: Field,
context: FieldDeletionContext
): Result<ISpecification<Table, ITableSpecVisitor> | undefined, DomainError> {
): Result<FieldDeletionReaction | undefined, DomainError> {
const deletedFromHostTable = context.sourceTable.id().equals(context.table.id());
const deletedFromForeignTable = context.sourceTable.id().equals(this.foreignTableId());
const filter = this.configValue.filter();
@ -400,7 +415,10 @@ export class LinkField
if (this.hasError().isError()) {
return ok(undefined);
}
return ok(TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()));
return ok({
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
relatedFieldIds: [this.id()],
});
}
return this.configDto().andThen((currentDto) =>
@ -409,14 +427,23 @@ export class LinkField
lookupFieldId: fallbackLookupFieldId.toString(),
...(shouldCleanForeignFilter ? { filter: nextFilter } : {}),
...(shouldCleanVisibleFieldIds ? { visibleFieldIds: normalizedVisibleFieldIds } : {}),
}).map((nextConfig) =>
composeAndSpecsOrUndefined([
UpdateLinkConfigSpec.create(this.id(), this.configValue, nextConfig),
...(this.hasError().isError()
? [TableUpdateFieldHasErrorSpec.clearError(this.id(), this.hasError())]
: []),
])
)
})
.map((nextConfig) =>
composeAndSpecsOrUndefined([
UpdateLinkConfigSpec.create(this.id(), this.configValue, nextConfig),
...(this.hasError().isError()
? [TableUpdateFieldHasErrorSpec.clearError(this.id(), this.hasError())]
: []),
])
)
.map((spec) =>
spec
? {
spec,
relatedFieldIds: [this.id()],
}
: undefined
)
);
}
@ -433,11 +460,20 @@ export class LinkField
...currentDto,
...(shouldCleanForeignFilter ? { filter: nextFilter } : {}),
...(shouldCleanVisibleFieldIds ? { visibleFieldIds: normalizedVisibleFieldIds } : {}),
}).map((nextConfig) =>
composeAndSpecsOrUndefined([
UpdateLinkConfigSpec.create(this.id(), this.configValue, nextConfig),
])
)
})
.map((nextConfig) =>
composeAndSpecsOrUndefined([
UpdateLinkConfigSpec.create(this.id(), this.configValue, nextConfig),
])
)
.map((spec) =>
spec
? {
spec,
relatedFieldIds: [this.id()],
}
: undefined
)
);
}
@ -450,7 +486,52 @@ export class LinkField
return ok(undefined);
}
return ok(TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()));
return ok({
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
relatedFieldIds: [this.id()],
});
}
onTableDeleted(
deletedTable: Table,
context: TableDeletionContext
): Result<TableDeletionReaction | undefined, DomainError> {
if (!deletedTable.id().equals(this.foreignTableId())) {
return ok(undefined);
}
const nextFieldResult = SingleLineTextField.create({
id: this.id(),
name: this.name(),
});
if (nextFieldResult.isErr()) {
return err(nextFieldResult.error);
}
const nextField = nextFieldResult.value;
const setDescriptionResult = nextField.setDescription(this.description());
if (setDescriptionResult.isErr()) {
return err(setDescriptionResult.error);
}
const setAiConfigResult = nextField.setAiConfig(this.aiConfig());
if (setAiConfigResult.isErr()) {
return err(setAiConfigResult.error);
}
const setNotNullResult = nextField.setNotNull(this.notNull());
if (setNotNullResult.isErr()) {
return err(setNotNullResult.error);
}
const setUniqueResult = nextField.setUnique(this.unique());
if (setUniqueResult.isErr()) {
return err(setUniqueResult.error);
}
nextField.setHasError(this.hasError());
const updateSpec = TableUpdateFieldTypeSpec.create(this, nextField);
return ok({
spec: updateSpec,
afterPersist: context.hooks.createFieldUpdateAfterPersistHook(this.id(), updateSpec),
});
}
setDbConfig(params: LinkFieldDbConfig): Result<void, DomainError> {

View File

@ -1,17 +1,17 @@
import type { Result } from 'neverthrow';
import { ok, type Result } from 'neverthrow';
import { describe, expect, it } from 'vitest';
import { BaseId } from '../../../base/BaseId';
import type { DomainError } from '../../../shared/DomainError';
import { DbFieldName } from '../DbFieldName';
import { UpdateLinkRelationshipSpec } from '../../specs/field-updates/UpdateLinkRelationshipSpec';
import { UpdateLookupOptionsSpec } from '../../specs/field-updates/UpdateLookupOptionsSpec';
import { UpdateSingleSelectOptionsSpec } from '../../specs/field-updates/UpdateSingleSelectOptionsSpec';
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
import { Table } from '../../Table';
import { TableId } from '../../TableId';
import { TableName } from '../../TableName';
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
import { UpdateLookupOptionsSpec } from '../../specs/field-updates/UpdateLookupOptionsSpec';
import { UpdateSingleSelectOptionsSpec } from '../../specs/field-updates/UpdateSingleSelectOptionsSpec';
import { UpdateLinkRelationshipSpec } from '../../specs/field-updates/UpdateLinkRelationshipSpec';
import { DbFieldName } from '../DbFieldName';
import type { Field } from '../Field';
import { FieldId } from '../FieldId';
import { FieldName } from '../FieldName';
@ -1552,4 +1552,42 @@ describe('LookupField', () => {
expect(isMultiple.isMultiple()).toBe(false);
});
});
describe('onTableDeleted', () => {
it('sets hasError when the foreign table is deleted', () => {
const linkFieldId = createFieldId('j')._unsafeUnwrap();
const foreignTableId = createTableId('k')._unsafeUnwrap();
const lookupTargetId = createFieldId('l')._unsafeUnwrap();
const lookupField = LookupField.create({
id: createFieldId('m')._unsafeUnwrap(),
name: FieldName.create('Lookup')._unsafeUnwrap(),
innerField: SingleLineTextField.create({
id: createFieldId('n')._unsafeUnwrap(),
name: FieldName.create('Lookup Inner')._unsafeUnwrap(),
})._unsafeUnwrap(),
lookupOptions: LookupOptions.create({
linkFieldId: linkFieldId.toString(),
foreignTableId: foreignTableId.toString(),
lookupFieldId: lookupTargetId.toString(),
})._unsafeUnwrap(),
})._unsafeUnwrap();
const result = lookupField.onTableDeleted({ id: () => foreignTableId } as never, {
table: {} as never,
hooks: {
createFieldUpdateAfterPersistHook: () => async () =>
ok({
events: [],
table: {} as never,
}),
},
});
expect(result.isOk()).toBe(true);
const reaction = result._unsafeUnwrap();
expect(reaction?.spec).toBeInstanceOf(TableUpdateFieldHasErrorSpec);
expect(reaction?.afterPersist).toBeUndefined();
});
});
});

View File

@ -4,16 +4,25 @@ import type { Result } from 'neverthrow';
import { domainError, type DomainError } from '../../../shared/DomainError';
import { composeAndSpecsOrUndefined } from '../../../shared/specification/composeAndSpecs';
import type { ISpecification } from '../../../shared/specification/ISpecification';
import type { FieldDeletionContext, OnTeableFieldDeleted } from '../../OnTeableFieldDeleted';
import { ForeignTable } from '../../ForeignTable';
import { UpdateLookupOptionsSpec } from '../../specs/field-updates/UpdateLookupOptionsSpec';
import type {
FieldDeletionContext,
FieldDeletionReaction,
OnTeableFieldDeleted,
} from '../../OnTeableFieldDeleted';
import type {
OnTeableTableDeleted,
TableDeletionContext,
TableDeletionReaction,
} from '../../OnTeableTableDeleted';
import { UpdateLinkRelationshipSpec } from '../../specs/field-updates/UpdateLinkRelationshipSpec';
import { UpdateLookupOptionsSpec } from '../../specs/field-updates/UpdateLookupOptionsSpec';
import type { ITableSpecVisitor } from '../../specs/ITableSpecVisitor';
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
import type { Table } from '../../Table';
import type { TableId } from '../../TableId';
import { DbFieldName } from '../DbFieldName';
import type { DbFieldName } from '../DbFieldName';
import { Field } from '../Field';
import type { FieldDuplicateParams } from '../Field';
import type { FieldId } from '../FieldId';
@ -56,7 +65,11 @@ import { SingleSelectField } from './SingleSelectField';
*/
export class LookupField
extends Field
implements ForeignTableRelatedField, OnTeableFieldUpdated, OnTeableFieldDeleted
implements
ForeignTableRelatedField,
OnTeableFieldUpdated,
OnTeableFieldDeleted,
OnTeableTableDeleted
{
private innerFieldValue: Field | undefined;
private innerOptionsPatchValue: Readonly<Record<string, unknown>> | undefined;
@ -629,7 +642,7 @@ export class LookupField
onFieldDeleted(
deletedField: Field,
context: FieldDeletionContext
): Result<ISpecification<Table, ITableSpecVisitor> | undefined, DomainError> {
): Result<FieldDeletionReaction | undefined, DomainError> {
const deletedFromHostTable = context.sourceTable.id().equals(context.table.id());
const deletedFromForeignTable = context.sourceTable.id().equals(this.foreignTableId());
const condition = this.lookupOptionsValue.condition();
@ -644,7 +657,23 @@ export class LookupField
return ok(undefined);
}
return ok(TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()));
return ok({
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
relatedFieldIds: [this.id()],
});
}
onTableDeleted(
deletedTable: Table,
_context: TableDeletionContext
): Result<TableDeletionReaction | undefined, DomainError> {
if (!deletedTable.id().equals(this.foreignTableId()) || this.hasError().isError()) {
return ok(undefined);
}
return ok({
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
});
}
private ensureForeignTable(foreignTable: ForeignTable): Result<void, DomainError> {

View File

@ -1,10 +1,11 @@
import { ok } from 'neverthrow';
import { describe, expect, it } from 'vitest';
import { FieldId } from '../FieldId';
import { FieldName } from '../FieldName';
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
import { TableId } from '../../TableId';
import { FieldId } from '../FieldId';
import { FieldName } from '../FieldName';
import { LongTextField } from './LongTextField';
import { NumberField } from './NumberField';
import { RollupExpression } from './RollupExpression';
@ -90,4 +91,42 @@ describe('RollupField.onDependencyUpdated', () => {
expect(result.isOk()).toBe(true);
expect(result._unsafeUnwrap()).toBeInstanceOf(TableUpdateFieldHasErrorSpec);
});
it('sets hasError when the foreign table is deleted', () => {
const linkFieldId = createFieldId('i');
const lookupFieldId = createFieldId('j');
const foreignTableId = createTableId('k');
const valuesField = NumberField.create({
id: lookupFieldId,
name: FieldName.create('Amount')._unsafeUnwrap(),
})._unsafeUnwrap();
const rollupField = RollupField.create({
id: createFieldId('l'),
name: FieldName.create('Amount Sum')._unsafeUnwrap(),
config: RollupFieldConfig.create({
linkFieldId: linkFieldId.toString(),
foreignTableId: foreignTableId.toString(),
lookupFieldId: lookupFieldId.toString(),
})._unsafeUnwrap(),
expression: RollupExpression.create('sum({values})')._unsafeUnwrap(),
valuesField,
})._unsafeUnwrap();
const result = rollupField.onTableDeleted({ id: () => foreignTableId } as never, {
table: {} as never,
hooks: {
createFieldUpdateAfterPersistHook: () => async () =>
ok({
events: [],
table: {} as never,
}),
},
});
expect(result.isOk()).toBe(true);
const reaction = result._unsafeUnwrap();
expect(reaction?.spec).toBeInstanceOf(TableUpdateFieldHasErrorSpec);
expect(reaction?.afterPersist).toBeUndefined();
});
});

View File

@ -4,8 +4,17 @@ import type { Result } from 'neverthrow';
import { domainError, type DomainError } from '../../../shared/DomainError';
import { composeAndSpecsOrUndefined } from '../../../shared/specification/composeAndSpecs';
import type { ISpecification } from '../../../shared/specification/ISpecification';
import type { FieldDeletionContext, OnTeableFieldDeleted } from '../../OnTeableFieldDeleted';
import { ForeignTable } from '../../ForeignTable';
import type {
FieldDeletionContext,
FieldDeletionReaction,
OnTeableFieldDeleted,
} from '../../OnTeableFieldDeleted';
import type {
OnTeableTableDeleted,
TableDeletionContext,
TableDeletionReaction,
} from '../../OnTeableTableDeleted';
import type { ITableSpecVisitor } from '../../specs/ITableSpecVisitor';
import { TableUpdateFieldHasErrorSpec } from '../../specs/TableUpdateFieldHasErrorSpec';
import { TableUpdateFieldTypeSpec } from '../../specs/TableUpdateFieldTypeSpec';
@ -56,7 +65,11 @@ type RollupValuesType = {
export class RollupField
extends Field
implements ForeignTableRelatedField, OnTeableFieldUpdated, OnTeableFieldDeleted
implements
ForeignTableRelatedField,
OnTeableFieldUpdated,
OnTeableFieldDeleted,
OnTeableTableDeleted
{
private constructor(
id: FieldId,
@ -547,7 +560,7 @@ export class RollupField
onFieldDeleted(
deletedField: Field,
context: FieldDeletionContext
): Result<ISpecification<Table, ITableSpecVisitor> | undefined, DomainError> {
): Result<FieldDeletionReaction | undefined, DomainError> {
const deletedFromHostTable = context.sourceTable.id().equals(context.table.id());
const deletedFromForeignTable = context.sourceTable.id().equals(this.foreignTableId());
@ -559,7 +572,23 @@ export class RollupField
return ok(undefined);
}
return ok(TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()));
return ok({
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
relatedFieldIds: [this.id()],
});
}
onTableDeleted(
deletedTable: Table,
_context: TableDeletionContext
): Result<TableDeletionReaction | undefined, DomainError> {
if (!deletedTable.id().equals(this.foreignTableId()) || this.hasError().isError()) {
return ok(undefined);
}
return ok({
spec: TableUpdateFieldHasErrorSpec.setError(this.id(), this.hasError()),
});
}
private ensureForeignTable(foreignTable: ForeignTable): Result<void, DomainError> {

View File

@ -47,14 +47,15 @@ import type { TableAddSelectOptionsSpec } from './TableAddSelectOptionsSpec';
import type { TableByBaseIdSpec } from './TableByBaseIdSpec';
import type { TableByIdSpec } from './TableByIdSpec';
import type { TableByIdsSpec } from './TableByIdsSpec';
import type { TableByIncomingReferenceToTableSpec } from './TableByIncomingReferenceToTableSpec';
import type { TableByNameLikeSpec } from './TableByNameLikeSpec';
import type { TableByNameSpec } from './TableByNameSpec';
import type { TableDuplicateFieldSpec } from './TableDuplicateFieldSpec';
import type { TableRemoveFieldSpec } from './TableRemoveFieldSpec';
import type { TableRenameSpec } from './TableRenameSpec';
import type { TableUpdateFieldAiConfigSpec } from './TableUpdateFieldAiConfigSpec';
import type { TableUpdateFieldConstraintsSpec } from './TableUpdateFieldConstraintsSpec';
import type { TableUpdateFieldDbFieldNameSpec } from './TableUpdateFieldDbFieldNameSpec';
import type { TableUpdateFieldAiConfigSpec } from './TableUpdateFieldAiConfigSpec';
import type { TableUpdateFieldDescriptionSpec } from './TableUpdateFieldDescriptionSpec';
import type { TableUpdateFieldHasErrorSpec } from './TableUpdateFieldHasErrorSpec';
import type { TableUpdateFieldNameSpec } from './TableUpdateFieldNameSpec';
@ -75,6 +76,9 @@ export interface ITableSpecVisitor<TResult = unknown> extends ISpecVisitor {
visitTableRename(spec: TableRenameSpec): Result<TResult, DomainError>;
visitTableByBaseId(spec: TableByBaseIdSpec): Result<TResult, DomainError>;
visitTableById(spec: TableByIdSpec): Result<TResult, DomainError>;
visitTableByIncomingReferenceToTable(
spec: TableByIncomingReferenceToTableSpec
): Result<TResult, DomainError>;
visitTableByIds(spec: TableByIdsSpec): Result<TResult, DomainError>;
visitTableByName(spec: TableByNameSpec): Result<TResult, DomainError>;
visitTableByNameLike(spec: TableByNameLikeSpec): Result<TResult, DomainError>;

View File

@ -0,0 +1,44 @@
import { ok } from 'neverthrow';
import type { Result } from 'neverthrow';
import type { DomainError } from '../../shared/DomainError';
import type { ISpecification } from '../../shared/specification/ISpecification';
import { LinkForeignTableReferenceVisitor } from '../fields/visitors/LinkForeignTableReferenceVisitor';
import type { Table } from '../Table';
import type { TableId } from '../TableId';
import type { ITableSpecVisitor } from './ITableSpecVisitor';
export class TableByIncomingReferenceToTableSpec<V extends ITableSpecVisitor = ITableSpecVisitor>
implements ISpecification<Table, V>
{
private readonly linkReferenceVisitor = new LinkForeignTableReferenceVisitor();
private constructor(private readonly tableIdValue: TableId) {}
static create(tableId: TableId): TableByIncomingReferenceToTableSpec {
return new TableByIncomingReferenceToTableSpec(tableId);
}
tableId(): TableId {
return this.tableIdValue;
}
isSatisfiedBy(t: Table): boolean {
const referencesResult = this.linkReferenceVisitor.collect(t.getFields());
if (referencesResult.isErr()) {
return false;
}
return referencesResult.value.some((reference) =>
reference.foreignTableId.equals(this.tableIdValue)
);
}
mutate(t: Table): Result<Table, DomainError> {
return ok(t);
}
accept(v: V): Result<void, DomainError> {
return v.visitTableByIncomingReferenceToTable(this).map(() => undefined);
}
}

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { BaseId } from '../../base/BaseId';
import { FieldName } from '../fields/FieldName';
import { LinkFieldConfig } from '../fields/types/LinkFieldConfig';
import { Table } from '../Table';
import { TableName } from '../TableName';
@ -19,6 +20,24 @@ const buildTable = (baseId: BaseId, name: TableName) => {
return tableResult._unsafeUnwrap();
};
const buildHostTableReferencing = (hostBaseId: BaseId, foreignTable: Table, name: TableName) => {
const hostNameField = FieldName.create('Host Name')._unsafeUnwrap();
const linkFieldName = FieldName.create('Foreign Link')._unsafeUnwrap();
const linkConfig = LinkFieldConfig.create({
baseId: foreignTable.baseId().toString(),
relationship: 'manyMany',
foreignTableId: foreignTable.id().toString(),
lookupFieldId: foreignTable.primaryFieldId().toString(),
isOneWay: true,
})._unsafeUnwrap();
const builder = Table.builder().withBaseId(hostBaseId).withName(name);
builder.field().singleLineText().withName(hostNameField).primary().done();
builder.field().link().withName(linkFieldName).withConfig(linkConfig).done();
builder.view().defaultGrid().done();
return builder.build()._unsafeUnwrap();
};
describe('TableSpecBuilder', () => {
it('includes base id spec by default', () => {
const baseIdResult = BaseId.create(`bse${'a'.repeat(16)}`);
@ -153,4 +172,23 @@ describe('TableSpecBuilder', () => {
expect(specResult._unsafeUnwrap().isSatisfiedBy(table)).toBe(true);
expect(specResult._unsafeUnwrap().isSatisfiedBy(otherTable)).toBe(false);
});
it('supports incoming-reference specs across bases', () => {
const foreignBaseId = BaseId.create(`bse${'i'.repeat(16)}`)._unsafeUnwrap();
const hostBaseId = BaseId.create(`bse${'j'.repeat(16)}`)._unsafeUnwrap();
const foreignName = TableName.create('Foreign')._unsafeUnwrap();
const hostName = TableName.create('Host')._unsafeUnwrap();
const unrelatedName = TableName.create('Unrelated')._unsafeUnwrap();
const foreignTable = buildTable(foreignBaseId, foreignName);
const hostTable = buildHostTableReferencing(hostBaseId, foreignTable, hostName);
const unrelatedTable = buildTable(hostBaseId, unrelatedName);
const specResult = Table.specs().byIncomingReferenceToTable(foreignTable.id()).build();
specResult._unsafeUnwrap();
expect(specResult._unsafeUnwrap().isSatisfiedBy(foreignTable)).toBe(false);
expect(specResult._unsafeUnwrap().isSatisfiedBy(hostTable)).toBe(true);
expect(specResult._unsafeUnwrap().isSatisfiedBy(unrelatedTable)).toBe(false);
});
});

View File

@ -12,6 +12,7 @@ import type { ITableSpecVisitor } from './ITableSpecVisitor';
import { TableByBaseIdSpec } from './TableByBaseIdSpec';
import { TableByIdSpec } from './TableByIdSpec';
import { TableByIdsSpec } from './TableByIdsSpec';
import { TableByIncomingReferenceToTableSpec } from './TableByIncomingReferenceToTableSpec';
import { TableByNameLikeSpec } from './TableByNameLikeSpec';
import { TableByNameSpec } from './TableByNameSpec';
@ -52,6 +53,11 @@ export class TableSpecBuilder extends SpecBuilder<Table, ITableSpecVisitor, Tabl
return this;
}
byIncomingReferenceToTable(tableId: TableId): TableSpecBuilder {
this.addSpec(TableByIncomingReferenceToTableSpec.create(tableId));
return this;
}
byIds(tableIds: ReadonlyArray<TableId>): TableSpecBuilder {
this.addSpec(TableByIdsSpec.create(tableIds));
return this;

View File

@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
import { BaseId } from '../../base/BaseId';
import type { ISpecification } from '../../shared/specification/ISpecification';
import { FieldName } from '../fields/FieldName';
import { LinkFieldConfig } from '../fields/types/LinkFieldConfig';
import { Table } from '../Table';
import { TableName } from '../TableName';
import type {
@ -52,13 +53,15 @@ import type { TableAddSelectOptionsSpec } from './TableAddSelectOptionsSpec';
import { TableByBaseIdSpec } from './TableByBaseIdSpec';
import { TableByIdSpec } from './TableByIdSpec';
import { TableByIdsSpec } from './TableByIdsSpec';
import { TableByIncomingReferenceToTableSpec } from './TableByIncomingReferenceToTableSpec';
import { TableByNameLikeSpec } from './TableByNameLikeSpec';
import { TableByNameSpec } from './TableByNameSpec';
import type { TableDuplicateFieldSpec } from './TableDuplicateFieldSpec';
import type { TableRemoveFieldSpec } from './TableRemoveFieldSpec';
import type { TableRenameSpec } from './TableRenameSpec';
import type { TableUpdateFieldConstraintsSpec } from './TableUpdateFieldConstraintsSpec';
import type { TableUpdateFieldAiConfigSpec } from './TableUpdateFieldAiConfigSpec';
import type { TableUpdateFieldConstraintsSpec } from './TableUpdateFieldConstraintsSpec';
import type { TableUpdateFieldDbFieldNameSpec } from './TableUpdateFieldDbFieldNameSpec';
import type { TableUpdateFieldDescriptionSpec } from './TableUpdateFieldDescriptionSpec';
import type { TableUpdateFieldHasErrorSpec } from './TableUpdateFieldHasErrorSpec';
import type { TableUpdateFieldNameSpec } from './TableUpdateFieldNameSpec';
@ -123,6 +126,13 @@ class SpyVisitor implements ITableSpecVisitor {
return ok(undefined);
}
visitTableByIncomingReferenceToTable(
_: TableByIncomingReferenceToTableSpec
): ReturnType<ITableSpecVisitor['visitTableByIncomingReferenceToTable']> {
this.calls.push('TableByIncomingReferenceToTableSpec');
return ok(undefined);
}
visitTableByIds(_: TableByIdsSpec): ReturnType<ITableSpecVisitor['visitTableByIds']> {
this.calls.push('TableByIdsSpec');
return ok(undefined);
@ -153,7 +163,7 @@ class SpyVisitor implements ITableSpecVisitor {
}
visitTableUpdateFieldDbFieldName(
_: any
_: TableUpdateFieldDbFieldNameSpec
): ReturnType<ITableSpecVisitor['visitTableUpdateFieldDbFieldName']> {
this.calls.push('TableUpdateFieldDbFieldNameSpec');
return ok(undefined);
@ -490,6 +500,24 @@ const buildTable = (baseId: BaseId, name: TableName) => {
return tableResult._unsafeUnwrap();
};
const buildHostTableReferencing = (hostBaseId: BaseId, foreignTable: Table, name: TableName) => {
const hostNameField = FieldName.create('Host Title')._unsafeUnwrap();
const linkFieldName = FieldName.create('Foreign Link')._unsafeUnwrap();
const linkConfig = LinkFieldConfig.create({
baseId: foreignTable.baseId().toString(),
relationship: 'manyMany',
foreignTableId: foreignTable.id().toString(),
lookupFieldId: foreignTable.primaryFieldId().toString(),
isOneWay: true,
})._unsafeUnwrap();
const builder = Table.builder().withBaseId(hostBaseId).withName(name);
builder.field().singleLineText().withName(hostNameField).primary().done();
builder.field().link().withName(linkFieldName).withConfig(linkConfig).done();
builder.view().defaultGrid().done();
return builder.build()._unsafeUnwrap();
};
describe('Table specs', () => {
it('evaluates base id spec', () => {
const baseIdResult = BaseId.create(`bse${'a'.repeat(16)}`);
@ -578,4 +606,25 @@ describe('Table specs', () => {
spec.accept(visitor)._unsafeUnwrap();
expect(visitor.calls).toContain('TableByNameLikeSpec');
});
it('evaluates incoming-reference specs', () => {
const foreignBaseId = BaseId.create(`bse${'f'.repeat(16)}`)._unsafeUnwrap();
const hostBaseId = BaseId.create(`bse${'g'.repeat(16)}`)._unsafeUnwrap();
const foreignName = TableName.create('Foreign')._unsafeUnwrap();
const hostName = TableName.create('Host')._unsafeUnwrap();
const unrelatedName = TableName.create('Other')._unsafeUnwrap();
const foreignTable = buildTable(foreignBaseId, foreignName);
const hostTable = buildHostTableReferencing(hostBaseId, foreignTable, hostName);
const unrelatedTable = buildTable(hostBaseId, unrelatedName);
const spec = TableByIncomingReferenceToTableSpec.create(foreignTable.id());
expect(spec.isSatisfiedBy(foreignTable)).toBe(false);
expect(spec.isSatisfiedBy(hostTable)).toBe(true);
expect(spec.isSatisfiedBy(unrelatedTable)).toBe(false);
const visitor = new SpyVisitor();
spec.accept(visitor)._unsafeUnwrap();
expect(visitor.calls).toContain('TableByIncomingReferenceToTableSpec');
});
});

View File

@ -62,6 +62,7 @@ import type { TableAddSelectOptionsSpec } from '../TableAddSelectOptionsSpec';
import type { TableByBaseIdSpec } from '../TableByBaseIdSpec';
import type { TableByIdSpec } from '../TableByIdSpec';
import type { TableByIdsSpec } from '../TableByIdsSpec';
import type { TableByIncomingReferenceToTableSpec } from '../TableByIncomingReferenceToTableSpec';
import type { TableByNameLikeSpec } from '../TableByNameLikeSpec';
import type { TableByNameSpec } from '../TableByNameSpec';
import type { TableDuplicateFieldSpec } from '../TableDuplicateFieldSpec';
@ -213,6 +214,12 @@ export class TableEventGeneratingSpecVisitor implements ITableSpecVisitor<void>
return ok(undefined);
}
visitTableByIncomingReferenceToTable(
_spec: TableByIncomingReferenceToTableSpec
): Result<void, DomainError> {
return ok(undefined);
}
visitTableByIds(_spec: TableByIdsSpec): Result<void, DomainError> {
return ok(undefined);
}

View File

@ -57,14 +57,15 @@ import type { TableAddSelectOptionsSpec } from '../TableAddSelectOptionsSpec';
import type { TableByBaseIdSpec } from '../TableByBaseIdSpec';
import type { TableByIdSpec } from '../TableByIdSpec';
import type { TableByIdsSpec } from '../TableByIdsSpec';
import type { TableByIncomingReferenceToTableSpec } from '../TableByIncomingReferenceToTableSpec';
import type { TableByNameLikeSpec } from '../TableByNameLikeSpec';
import type { TableByNameSpec } from '../TableByNameSpec';
import type { TableDuplicateFieldSpec } from '../TableDuplicateFieldSpec';
import type { TableRemoveFieldSpec } from '../TableRemoveFieldSpec';
import type { TableRenameSpec } from '../TableRenameSpec';
import type { TableUpdateFieldAiConfigSpec } from '../TableUpdateFieldAiConfigSpec';
import type { TableUpdateFieldConstraintsSpec } from '../TableUpdateFieldConstraintsSpec';
import type { TableUpdateFieldDbFieldNameSpec } from '../TableUpdateFieldDbFieldNameSpec';
import type { TableUpdateFieldAiConfigSpec } from '../TableUpdateFieldAiConfigSpec';
import type { TableUpdateFieldDescriptionSpec } from '../TableUpdateFieldDescriptionSpec';
import type { TableUpdateFieldHasErrorSpec } from '../TableUpdateFieldHasErrorSpec';
import type { TableUpdateFieldNameSpec } from '../TableUpdateFieldNameSpec';
@ -246,6 +247,13 @@ export class TableSpecEventVisitor implements ITableSpecVisitor<void> {
return ok(undefined);
}
visitTableByIncomingReferenceToTable(
_spec: TableByIncomingReferenceToTableSpec<ITableSpecVisitor<void>>
): Result<void, DomainError> {
// Query-only spec, no events generated
return ok(undefined);
}
visitTableByIds(_spec: TableByIdsSpec<ITableSpecVisitor<void>>): Result<void, DomainError> {
// Query-only spec, no events generated
return ok(undefined);

View File

@ -1,28 +1,29 @@
import { describe, expect, it } from 'vitest';
import { BaseId } from '../../../../base/BaseId';
import { FieldId } from '../../../fields/FieldId';
import { FieldName } from '../../../fields/FieldName';
import { NumberFormatting, NumberFormattingType } from '../../../fields/types/NumberFormatting';
import { Table } from '../../../Table';
import { TableName } from '../../../TableName';
import { FieldCreated } from '../../../events/FieldCreated';
import { FieldDeleted } from '../../../events/FieldDeleted';
import { FieldUpdated } from '../../../events/FieldUpdated';
import { TableRenamed } from '../../../events/TableRenamed';
import { FieldId } from '../../../fields/FieldId';
import { FieldName } from '../../../fields/FieldName';
import { NumberFormatting, NumberFormattingType } from '../../../fields/types/NumberFormatting';
import { SelectOption } from '../../../fields/types/SelectOption';
import { SingleLineTextField } from '../../../fields/types/SingleLineTextField';
import { SingleSelectField } from '../../../fields/types/SingleSelectField';
import { Table } from '../../../Table';
import { TableName } from '../../../TableName';
import { UpdateNumberFormattingSpec } from '../../field-updates/UpdateNumberFormattingSpec';
import { TableAddFieldSpec } from '../../TableAddFieldSpec';
import { TableByBaseIdSpec } from '../../TableByBaseIdSpec';
import { TableByIdSpec } from '../../TableByIdSpec';
import { TableByIncomingReferenceToTableSpec } from '../../TableByIncomingReferenceToTableSpec';
import { TableRemoveFieldSpec } from '../../TableRemoveFieldSpec';
import { TableRenameSpec } from '../../TableRenameSpec';
import { TableByIdSpec } from '../../TableByIdSpec';
import { TableByBaseIdSpec } from '../../TableByBaseIdSpec';
import { TableUpdateFieldDescriptionSpec } from '../../TableUpdateFieldDescriptionSpec';
import { TableUpdateFieldNameSpec } from '../../TableUpdateFieldNameSpec';
import { TableUpdateFieldTypeSpec } from '../../TableUpdateFieldTypeSpec';
import { UpdateNumberFormattingSpec } from '../../field-updates/UpdateNumberFormattingSpec';
import { TableEventGeneratingSpecVisitor } from '../TableEventGeneratingSpecVisitor';
import { SingleLineTextField } from '../../../fields/types/SingleLineTextField';
import { SingleSelectField } from '../../../fields/types/SingleSelectField';
import { SelectOption } from '../../../fields/types/SelectOption';
const createBaseId = (seed: string) => BaseId.create(`bse${seed.repeat(16)}`)._unsafeUnwrap();
const createFieldId = (seed: string) => FieldId.create(`fld${seed.repeat(16)}`)._unsafeUnwrap();
@ -196,6 +197,9 @@ describe('TableEventGeneratingSpecVisitor', () => {
const byBaseIdSpec = TableByBaseIdSpec.create(table.baseId());
byBaseIdSpec.accept(visitor)._unsafeUnwrap();
const byIncomingReferenceSpec = TableByIncomingReferenceToTableSpec.create(table.id());
byIncomingReferenceSpec.accept(visitor)._unsafeUnwrap();
const events = visitor.getEvents();
expect(events.length).toBe(0);
});

View File

@ -17,6 +17,7 @@ import { TableAddSelectOptionsSpec } from '../../TableAddSelectOptionsSpec';
import { TableByBaseIdSpec } from '../../TableByBaseIdSpec';
import { TableByIdSpec } from '../../TableByIdSpec';
import { TableByIdsSpec } from '../../TableByIdsSpec';
import { TableByIncomingReferenceToTableSpec } from '../../TableByIncomingReferenceToTableSpec';
import { TableByNameLikeSpec } from '../../TableByNameLikeSpec';
import { TableByNameSpec } from '../../TableByNameSpec';
import { TableDuplicateFieldSpec } from '../../TableDuplicateFieldSpec';
@ -47,6 +48,18 @@ const protoInstance = <T extends object>(
overrides: Record<string, unknown> = {}
): T => Object.assign(Object.create(ctor.prototype) as T, overrides as Partial<T>);
type UnsafeUnwrapResult = {
_unsafeUnwrap(): void;
};
type AcceptableSpec = {
accept(visitor: TableSpecEventVisitor): UnsafeUnwrapResult;
};
const acceptSpec = (visitor: TableSpecEventVisitor, spec: AcceptableSpec) => {
spec.accept(visitor)._unsafeUnwrap();
};
const createBaseId = (seed: string) => BaseId.create(`bse${seed.repeat(16)}`)._unsafeUnwrap();
const createFieldId = (seed: string) => FieldId.create(`fld${seed.repeat(16)}`)._unsafeUnwrap();
@ -405,7 +418,7 @@ describe('TableSpecEventVisitor', () => {
const fieldId = table.getFields()[0].id();
const spec = build(fieldId);
((spec as any).accept(visitor) as { _unsafeUnwrap: () => void })._unsafeUnwrap();
acceptSpec(visitor, spec);
const events = visitor.collectedEvents();
expect(events).toHaveLength(1);
@ -430,7 +443,7 @@ describe('TableSpecEventVisitor', () => {
nextUnique: () => comparable(true),
});
((spec as any).accept(visitor) as { _unsafeUnwrap: () => void })._unsafeUnwrap();
acceptSpec(visitor, spec);
expect(visitor.collectedEvents()).toHaveLength(0);
});
@ -443,7 +456,7 @@ describe('TableSpecEventVisitor', () => {
fieldId: () => fieldId,
});
((spec as any).accept(visitor) as { _unsafeUnwrap: () => void })._unsafeUnwrap();
acceptSpec(visitor, spec);
const events = visitor.collectedEvents();
expect(events).toHaveLength(1);
@ -476,7 +489,7 @@ describe('TableSpecEventVisitor', () => {
},
],
});
((viewMetaSpec as any).accept(visitor) as { _unsafeUnwrap: () => void })._unsafeUnwrap();
acceptSpec(visitor, viewMetaSpec);
const events = visitor.collectedEvents();
expect(events.some((event) => event instanceof FieldCreated)).toBe(true);
@ -495,11 +508,12 @@ describe('TableSpecEventVisitor', () => {
protoInstance(TableUpdateViewQueryDefaultsSpec),
protoInstance(TableByBaseIdSpec),
protoInstance(TableByIdSpec),
protoInstance(TableByIncomingReferenceToTableSpec),
protoInstance(TableByIdsSpec),
protoInstance(TableByNameSpec),
protoInstance(TableByNameLikeSpec),
].forEach((spec) => {
((spec as any).accept(visitor) as { _unsafeUnwrap: () => void })._unsafeUnwrap();
acceptSpec(visitor, spec);
});
expect(visitor.collectedEvents()).toHaveLength(0);

View File

@ -31,6 +31,7 @@ export * from './application/services/FieldCrossTableUpdateSideEffectService';
export * from './application/services/ForeignTableLoaderService';
export * from './application/services/LinkFieldUpdateSideEffectService';
export * from './application/services/LinkTitleResolverService';
export * from './application/services/TableDeletionSideEffectService';
export * from './application/services/AttachmentValueResolverService';
export * from './application/services/RecordMutationSpecResolverService';
export * from './application/services/RecordWriteSideEffectService';
@ -227,6 +228,7 @@ export * from './domain/table/fields/visitors/FieldDefaultValueVisitor';
export * from './domain/table/fields/visitors/SetFieldValueSpecFactoryVisitor';
export * from './domain/table/events/TableCreated';
export * from './domain/table/events/TableDeleted';
export * from './domain/table/events/TableTrashed';
export * from './domain/table/events/TableRenamed';
export * from './domain/table/events/FieldCreated';
export * from './domain/table/events/FieldDeleted';
@ -243,6 +245,7 @@ export * from './domain/table/events/RecordsBatchUpdated';
export * from './domain/table/events/RecordsDeleted';
export * from './domain/table/events/TableActionTriggerRequested';
export * from './domain/table/specs/TableByIdSpec';
export * from './domain/table/specs/TableByIncomingReferenceToTableSpec';
export * from './domain/table/specs/TableRenameSpec';
export * from './domain/table/specs/TableByIdsSpec';
export * from './domain/table/specs/TableByNameLikeSpec';
@ -278,6 +281,7 @@ export * from './domain/table/fields/visitors/FieldDeletionSideEffectVisitor';
export * from './domain/table/fields/visitors/LinkFieldUpdateSideEffectVisitor';
export * from './domain/table/fields/OnTeableFieldUpdated';
export * from './domain/table/OnTeableFieldDeleted';
export * from './domain/table/OnTeableTableDeleted';
export * from './domain/table/fields/visitors/FieldValueTypeVisitor';
export * from './domain/table/fields/visitors/LinkForeignTableReferenceVisitor';
export type { AttachmentField } from './domain/table/fields/types/AttachmentField';

View File

@ -8,6 +8,12 @@ import type { TableSortKey } from '../domain/table/TableSortKey';
import type { IExecutionContext } from './ExecutionContext';
import type { IFindOptions } from './RepositoryQuery';
export type TableQueryState = 'active' | 'deleted' | 'all';
export type TableFindOptions = IFindOptions<TableSortKey> & {
state?: TableQueryState;
};
export type FieldVersionChange = {
fieldId: string;
oldVersion: number;
@ -18,6 +24,12 @@ export type TableUpdatePersistResult = {
fieldVersionChanges?: ReadonlyArray<FieldVersionChange>;
};
export type TableDeleteMode = 'soft' | 'permanent';
export type TableDeleteOptions = {
mode?: TableDeleteMode;
};
export interface ITableRepository {
insert(context: IExecutionContext, table: Table): Promise<Result<Table, DomainError>>;
insertMany(
@ -26,12 +38,13 @@ export interface ITableRepository {
): Promise<Result<ReadonlyArray<Table>, DomainError>>;
findOne(
context: IExecutionContext,
spec: ISpecification<Table, ITableSpecVisitor>
spec: ISpecification<Table, ITableSpecVisitor>,
options?: Pick<TableFindOptions, 'state'>
): Promise<Result<Table, DomainError>>;
find(
context: IExecutionContext,
spec: ISpecification<Table, ITableSpecVisitor>,
options?: IFindOptions<TableSortKey>
options?: TableFindOptions
): Promise<Result<ReadonlyArray<Table>, DomainError>>;
// table identifies the row, mutateSpec drives update values via visitors.
updateOne(
@ -39,5 +52,9 @@ export interface ITableRepository {
table: Table,
mutateSpec: ISpecification<Table, ITableSpecVisitor>
): Promise<Result<TableUpdatePersistResult | void, DomainError>>;
delete(context: IExecutionContext, table: Table): Promise<Result<void, DomainError>>;
delete(
context: IExecutionContext,
table: Table,
options?: TableDeleteOptions
): Promise<Result<void, DomainError>>;
}

View File

@ -5,6 +5,7 @@ import type { ISpecification } from '../domain/shared/specification/ISpecificati
import type { ITableSpecVisitor } from '../domain/table/specs/ITableSpecVisitor';
import type { Table } from '../domain/table/Table';
import type { IExecutionContext } from './ExecutionContext';
import type { TableDeleteOptions } from './TableRepository';
export interface ITableSchemaRepository {
insert(context: IExecutionContext, table: Table): Promise<Result<void, DomainError>>;
@ -17,5 +18,9 @@ export interface ITableSchemaRepository {
table: Table,
mutateSpec: ISpecification<Table, ITableSpecVisitor>
): Promise<Result<Table, DomainError>>;
delete(context: IExecutionContext, table: Table): Promise<Result<void, DomainError>>;
delete(
context: IExecutionContext,
table: Table,
options?: TableDeleteOptions
): Promise<Result<void, DomainError>>;
}

View File

@ -15,6 +15,7 @@ export const v2CoreTokens = {
fieldCrossTableUpdateSideEffectService: Symbol('v2.core.fieldCrossTableUpdateSideEffectService'),
linkFieldUpdateSideEffectService: Symbol('v2.core.linkFieldUpdateSideEffectService'),
foreignTableLoaderService: Symbol('v2.core.foreignTableLoaderService'),
tableDeletionSideEffectService: Symbol('v2.core.tableDeletionSideEffectService'),
linkTitleResolverService: Symbol('v2.core.linkTitleResolverService'),
recordMutationSpecResolverService: Symbol('v2.core.recordMutationSpecResolverService'),
recordWriteSideEffectService: Symbol('v2.core.recordWriteSideEffectService'),

View File

@ -0,0 +1,52 @@
import { Command, Options } from '@effect/cli';
import { Effect, Option } from 'effect';
import { CommandExplain } from '../../services/CommandExplain';
import { Output } from '../../services/Output';
import { analyzeOption, baseIdOptionalOption, connectionOption, tableIdOption } from '../shared';
const deleteModeOption = Options.choice('mode', ['soft', 'permanent']).pipe(
Options.optional,
Options.withDescription('Delete mode: soft or permanent')
);
const handler = (args: {
readonly connection: Option.Option<string>;
readonly baseId: Option.Option<string>;
readonly tableId: string;
readonly mode: Option.Option<string>;
readonly analyze: boolean;
}) =>
Effect.gen(function* () {
const commandExplain = yield* CommandExplain;
const output = yield* Output;
const input = {
baseId: Option.getOrUndefined(args.baseId),
tableId: args.tableId,
mode: Option.getOrUndefined(args.mode) as 'soft' | 'permanent' | undefined,
analyze: args.analyze,
};
const result = yield* commandExplain.explainDeleteTable(input).pipe(
Effect.catchAll((error) =>
Effect.gen(function* () {
yield* output.error('explain.delete-table', input, error);
return yield* Effect.fail(error);
})
)
);
yield* output.success('explain.delete-table', input, result);
});
export const explainDeleteTable = Command.make(
'delete-table',
{
connection: connectionOption,
baseId: baseIdOptionalOption,
tableId: tableIdOption,
mode: deleteModeOption,
analyze: analyzeOption,
},
handler
).pipe(Command.withDescription('Explain DeleteTable command execution plan'));

View File

@ -4,4 +4,5 @@ export { explainDelete } from './delete';
export { explainCreateField } from './create-field';
export { explainUpdateField } from './update-field';
export { explainDeleteField } from './delete-field';
export { explainDeleteTable } from './delete-table';
export { explainPaste } from './paste';

View File

@ -7,6 +7,7 @@ import {
explainCreateField,
explainUpdateField,
explainDeleteField,
explainDeleteTable,
explainPaste,
} from './explain';
import { mockGenerate } from './mock';
@ -33,6 +34,7 @@ export const explain = Command.make('explain').pipe(
explainCreateField,
explainUpdateField,
explainDeleteField,
explainDeleteTable,
explainPaste,
])
);

View File

@ -11,6 +11,7 @@ import {
UpdateFieldCommand,
DeleteRecordsCommand,
DeleteFieldCommand,
DeleteTableCommand,
PasteCommand,
ActorId,
TableByIdSpec,
@ -26,6 +27,7 @@ import {
type ExplainCreateFieldInput,
type ExplainCreateInput,
type ExplainDeleteFieldInput,
type ExplainDeleteTableInput,
type ExplainDeleteInput,
type ExplainPasteInput,
type ExplainUpdateFieldInput,
@ -164,6 +166,37 @@ export const CommandExplainLive = Layer.effect(
});
}),
explainDeleteTable: (
input: ExplainDeleteTableInput
): Effect.Effect<ExplainResult, CliError> =>
Effect.gen(function* () {
const context = yield* createContext();
const baseId = input.baseId ?? (yield* resolveBaseId(input.tableId));
const commandResult = DeleteTableCommand.create({
baseId,
tableId: input.tableId,
mode: input.mode,
});
if (commandResult.isErr()) {
return yield* Effect.fail(CliError.fromUnknown(commandResult.error));
}
return yield* Effect.tryPromise({
try: async () => {
const result = await explainService.explain(context, commandResult.value, {
analyze: input.analyze,
includeSql: true,
includeGraph: false,
includeLocks: true,
});
if (result.isErr()) throw result.error;
return result.value;
},
catch: (e) => CliError.fromUnknown(e),
});
}),
explainCreate: (input: ExplainCreateInput): Effect.Effect<ExplainResult, CliError> =>
Effect.gen(function* () {
const context = yield* createContext();

View File

@ -2,6 +2,7 @@ import type { ExplainResult } from '@teable/v2-command-explain';
import type {
ICreateFieldCommandInput,
IDeleteFieldCommandInput,
IDeleteTableCommandInput,
IFieldUpdateInput,
IPasteCommandInput,
} from '@teable/v2-core';
@ -27,6 +28,13 @@ export interface ExplainDeleteFieldInput {
readonly analyze: boolean;
}
export interface ExplainDeleteTableInput {
readonly baseId?: IDeleteTableCommandInput['baseId'];
readonly tableId: IDeleteTableCommandInput['tableId'];
readonly mode?: IDeleteTableCommandInput['mode'];
readonly analyze: boolean;
}
export interface ExplainCreateInput {
readonly tableId: string;
readonly fields: Record<string, unknown>;
@ -62,6 +70,9 @@ export class CommandExplain extends Context.Tag('CommandExplain')<
readonly explainDeleteField: (
input: ExplainDeleteFieldInput
) => Effect.Effect<ExplainResult, CliError>;
readonly explainDeleteTable: (
input: ExplainDeleteTableInput
) => Effect.Effect<ExplainResult, CliError>;
readonly explainCreate: (input: ExplainCreateInput) => Effect.Effect<ExplainResult, CliError>;
readonly explainUpdate: (input: ExplainUpdateInput) => Effect.Effect<ExplainResult, CliError>;
readonly explainDelete: (input: ExplainDeleteInput) => Effect.Effect<ExplainResult, CliError>;

View File

@ -36,7 +36,7 @@ describe('create-field: conditionalLookup cross-base', () => {
const response = await fetch(`${ctx.baseUrl}/tables/delete`, {
method: 'DELETE',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseId, tableId }),
body: JSON.stringify({ baseId, tableId, mode: 'permanent' }),
});
if (!response.ok) {
const errorText = await response.text();

View File

@ -36,7 +36,7 @@ describe('create-field: conditionalRollup cross-base', () => {
const response = await fetch(`${ctx.baseUrl}/tables/delete`, {
method: 'DELETE',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseId, tableId }),
body: JSON.stringify({ baseId, tableId, mode: 'permanent' }),
});
if (!response.ok) {
const errorText = await response.text();

View File

@ -36,7 +36,7 @@ describe('create-field: lookup cross-base', () => {
const response = await fetch(`${ctx.baseUrl}/tables/delete`, {
method: 'DELETE',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseId, tableId }),
body: JSON.stringify({ baseId, tableId, mode: 'permanent' }),
});
if (!response.ok) {
const errorText = await response.text();

View File

@ -36,7 +36,7 @@ describe('create-field: rollup cross-base', () => {
const response = await fetch(`${ctx.baseUrl}/tables/delete`, {
method: 'DELETE',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseId, tableId }),
body: JSON.stringify({ baseId, tableId, mode: 'permanent' }),
});
if (!response.ok) {
const errorText = await response.text();

File diff suppressed because it is too large Load Diff

View File

@ -96,7 +96,7 @@ export interface SharedTestContext {
payload: IUpdateFieldCommandInput
) => Promise<ReturnType<typeof parseUpdateFieldResponse>>;
deleteField: (payload: { tableId: string; fieldId: string }) => Promise<void>;
deleteTable: (tableId: string) => Promise<void>;
deleteTable: (tableId: string, options?: { mode?: 'soft' | 'permanent' }) => Promise<void>;
renameTable: (
tableId: string,
name: string
@ -441,11 +441,11 @@ const initSharedContext = async (): Promise<SharedTestContext> => {
}
};
const deleteTable = async (tableId: string) => {
const deleteTable = async (tableId: string, options?: { mode?: 'soft' | 'permanent' }) => {
const response = await fetch(`${baseUrl}/tables/delete`, {
method: 'DELETE',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseId, tableId }),
body: JSON.stringify({ baseId, tableId, mode: options?.mode ?? 'permanent' }),
});
if (!response.ok) {
const errorText = await response.text();

View File

@ -32,7 +32,7 @@ describe('update-field: link conversion cross-base bulk', () => {
const response = await fetch(`${ctx.baseUrl}/tables/delete`, {
method: 'DELETE',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseId, tableId }),
body: JSON.stringify({ baseId, tableId, mode: 'permanent' }),
});
if (!response.ok) {
const errorText = await response.text();