diff --git a/apps/nestjs-backend/package.json b/apps/nestjs-backend/package.json index 3c446261f4..efdecfa5d6 100644 --- a/apps/nestjs-backend/package.json +++ b/apps/nestjs-backend/package.json @@ -220,6 +220,7 @@ "is-port-reachable": "3.1.0", "joi": "17.12.2", "jschardet": "3.1.3", + "kysely": "0.28.9", "keyv": "4.5.4", "knex": "3.1.0", "lodash": "4.17.21", diff --git a/apps/nestjs-backend/src/cache/redis-native.service.ts b/apps/nestjs-backend/src/cache/redis-native.service.ts index 1d2f8fd155..2b645996c1 100644 --- a/apps/nestjs-backend/src/cache/redis-native.service.ts +++ b/apps/nestjs-backend/src/cache/redis-native.service.ts @@ -96,6 +96,16 @@ export class RedisNativeService { await this.client.expire(key, seconds); } + /** + * Get remaining TTL (in seconds) for a key. + * Redis semantics: + * - -2: key does not exist + * - -1: key exists but has no associated expire + */ + async ttl(key: string): Promise { + return this.client.ttl(key); + } + /** * Delete a key. * @param key - Redis key to delete diff --git a/apps/nestjs-backend/src/features/ai/ai.service.ts b/apps/nestjs-backend/src/features/ai/ai.service.ts index 562d0a48cf..b197a7a608 100644 --- a/apps/nestjs-backend/src/features/ai/ai.service.ts +++ b/apps/nestjs-backend/src/features/ai/ai.service.ts @@ -63,6 +63,27 @@ export class AiService { return { type, model, name }; } + /** + * Resolve the model key by matching a body model ID against chatModel lg/md/sm values. + * Model keys are in format type@modelId@name — we compare the modelId segment. + * Falls back to lg if no match is found. + */ + public resolveModelKeyFromBody( + chatModel: { lg?: string; md?: string; sm?: string } | undefined, + bodyModel?: string + ): string | undefined { + if (bodyModel) { + const sizes = ['lg', 'md', 'sm'] as const; + for (const size of sizes) { + const key = chatModel?.[size]; + if (key && this.parseModelKey(key).model === bodyModel) { + return key; + } + } + } + return chatModel?.lg; + } + /** * Check if modelKey is an AI Gateway model * Format: aiGateway@@teable @@ -359,20 +380,6 @@ export class AiService { }; } - async getToolApiKeys(baseId: string) { - const { appConfig } = await this.settingService.getSetting([SettingKey.APP_CONFIG]); - const { spaceId } = await this.prismaService.base.findUniqueOrThrow({ - where: { id: baseId }, - }); - const aiIntegration = await this.prismaService.integration.findFirst({ - where: { resourceId: spaceId, type: IntegrationType.AI }, - }); - const aiIntegrationConfig = aiIntegration?.config ? JSON.parse(aiIntegration.config) : null; - return { - v0ApiKey: aiIntegrationConfig?.appConfig?.apiKey || appConfig?.apiKey, - }; - } - async getSimplifiedAIConfig(baseId: string) { try { const config = await this.getAIConfig(baseId); @@ -554,6 +561,8 @@ export class AiService { ability: chatModel?.ability, isInstance, lgModelKey: chatModel.lg, + mdModelKey: chatModel.md, + smModelKey: chatModel.sm, }; } diff --git a/apps/nestjs-backend/src/features/ai/util.ts b/apps/nestjs-backend/src/features/ai/util.ts index 17187eca81..60cb373dd0 100644 --- a/apps/nestjs-backend/src/features/ai/util.ts +++ b/apps/nestjs-backend/src/features/ai/util.ts @@ -88,6 +88,28 @@ const createOpenAICompatibleWrapper = ( }); }; +const createClaudeCodeWrapper = ( + options: Parameters[0] +): ReturnType => { + const baseFetch = createFixingFetch(); + const claudeCodeDefaultUa = 'claude-cli/2.1.71 (external, cli)'; + return createAnthropic({ + ...options, + fetch: async (input, init) => { + const initHeaders = (init?.headers ?? {}) as Record; + const ua = initHeaders['user-agent']; + return baseFetch(input, { + ...init, + headers: { + ...init?.headers, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'user-agent': ua?.includes('claude-cli') ? ua : claudeCodeDefaultUa, + }, + }); + }, + }); +}; + export const modelProviders = { [LLMProviderType.OPENAI]: createOpenAI, [LLMProviderType.ANTHROPIC]: createAnthropic, @@ -105,6 +127,7 @@ export const modelProviders = { [LLMProviderType.AMAZONBEDROCK]: createAmazonBedrock, [LLMProviderType.OPENROUTER]: createOpenRouter, [LLMProviderType.OPENAI_COMPATIBLE]: createOpenAICompatibleWrapper, + [LLMProviderType.CLAUDE_CODE]: createClaudeCodeWrapper, // AI_GATEWAY is handled separately in ai.service.ts using createGateway from 'ai' } as const; diff --git a/apps/nestjs-backend/src/features/auth/auth.service.ts b/apps/nestjs-backend/src/features/auth/auth.service.ts index 43f543e404..f1fcf8fe55 100644 --- a/apps/nestjs-backend/src/features/auth/auth.service.ts +++ b/apps/nestjs-backend/src/features/auth/auth.service.ts @@ -39,11 +39,11 @@ export class AuthService { } } - async getTempToken() { + async getTempToken(expiresIn: string = '10m', userId?: string, allowSystemUser?: boolean) { const payload: IJwtAuthInfo = { - userId: this.cls.get('user.id'), + userId: userId ?? this.cls.get('user.id'), + ...(allowSystemUser ? { allowSystemUser: true } : {}), }; - const expiresIn = '10m'; return { accessToken: await this.jwtService.signAsync(payload, { expiresIn }), expiresTime: new Date(Date.now() + ms(expiresIn)).toISOString(), diff --git a/apps/nestjs-backend/src/features/auth/guard/permission.guard.ts b/apps/nestjs-backend/src/features/auth/guard/permission.guard.ts index 177a76fe00..b940472820 100644 --- a/apps/nestjs-backend/src/features/auth/guard/permission.guard.ts +++ b/apps/nestjs-backend/src/features/auth/guard/permission.guard.ts @@ -155,12 +155,15 @@ export class PermissionGuard { resourceId, permissions ); - // Set user to anonymous for share context - this.cls.set('user', { - id: ANONYMOUS_USER_ID, - name: ANONYMOUS_USER_ID, - email: '', - }); + // Preserve logged-in user identity for allowEdit; fall back to anonymous + const currentUserId = this.cls.get('user.id'); + if (!currentUserId || isAnonymous(currentUserId)) { + this.cls.set('user', { + id: ANONYMOUS_USER_ID, + name: ANONYMOUS_USER_ID, + email: '', + }); + } this.cls.set('permissions', ownPermissions); return true; } @@ -297,6 +300,15 @@ export class PermissionGuard { if (!shareId) { return undefined; } + // Skip share path for endpoints without @Permissions (e.g. /user/me), + // otherwise baseSharePermissionCheck throws ForbiddenException. + const permissions = this.reflector.getAllAndOverride(PERMISSIONS_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!permissions?.length) { + return undefined; + } return await this.baseSharePermissionCheck(context, shareId); } @@ -383,9 +395,10 @@ export class PermissionGuard { * * Priority flow: * 1. RESOURCE-level: exclusively use resource-specific auth (base share > template) - * 2. Early base share check for PUBLIC or anonymous requests when header is present + * 2. Share link check — when share header is present, share permissions are the ceiling + * for ALL users (anonymous or authenticated), so personal role never exceeds the link * 3. Anonymous user handling (template / USER-level) - * 4. Authenticated user: standard check, with fallback for PUBLIC endpoints + * 4. Authenticated user: standard check, with PUBLIC fallback */ protected async permissionCheckWithPublicFallback( context: ExecutionContext, @@ -406,10 +419,8 @@ export class PermissionGuard { // No valid resource auth header — fall through to normal checks } - // 2. Early base share check for PUBLIC or anonymous requests - const shouldTryBaseShareEarly = - baseShareHeader && (allowAnonymousType === AllowAnonymousType.PUBLIC || this.isAnonymous()); - if (shouldTryBaseShareEarly) { + // 2. Share link — permissions are bounded by the link, regardless of user role + if (baseShareHeader) { const result = await this.tryBaseSharePermissionCheck(context, baseShareHeader); if (result !== undefined) return result; } @@ -419,7 +430,7 @@ export class PermissionGuard { return this.resolveAnonymousPermission(context, allowAnonymousType); } - // 4. Authenticated user: standard check, with fallback for PUBLIC endpoints + // 4. Authenticated user: standard check, with PUBLIC fallback try { return await permissionCheck(); } catch (error) { diff --git a/apps/nestjs-backend/src/features/auth/permission.service.ts b/apps/nestjs-backend/src/features/auth/permission.service.ts index 9c899d1d01..325113feeb 100644 --- a/apps/nestjs-backend/src/features/auth/permission.service.ts +++ b/apps/nestjs-backend/src/features/auth/permission.service.ts @@ -4,6 +4,7 @@ import type { IBaseRole, Action } from '@teable/core'; import { HttpErrorCode, IdPrefix, + Role, TemplatePermissions, getPermissions, isAnonymous, @@ -27,6 +28,18 @@ interface IBaseNodeCacheItem { const notAllowedOperationI18nKey = 'httpErrors.permission.notAllowedOperation'; +/** + * Permissions that must never be granted via share links, + * even when allowEdit is enabled with a logged-in user. + */ +const SHARE_EXCLUDED_PERMISSIONS = new Set([ + 'view|share', + 'space|invite_email', + 'base|invite_email', + 'user|email_read', + 'user|integrations', +]); + @Injectable() export class PermissionService { private readonly logger = new Logger(PermissionService.name); @@ -627,7 +640,13 @@ export class PermissionService { // Set base share in cls for downstream services to use this.cls.set('baseShare', { baseId, nodeId }); - // Return template permissions (read-only), with record|copy if allowCopy is enabled + // When allowEdit is enabled and user is logged in, grant editor-level permissions + // excluding invite/share/privacy-sensitive actions + if (baseShare.allowEdit && !this.isAnonymous()) { + return getPermissions(Role.Editor).filter((p) => !SHARE_EXCLUDED_PERMISSIONS.has(p)); + } + + // Otherwise return template permissions (read-only), with record|copy if allowCopy is enabled const permissions = [...TemplatePermissions]; if (baseShare.allowCopy) { permissions.push('record|copy'); diff --git a/apps/nestjs-backend/src/features/auth/strategies/jwt.strategy.ts b/apps/nestjs-backend/src/features/auth/strategies/jwt.strategy.ts index 9446a8bc45..4a588c02c5 100644 --- a/apps/nestjs-backend/src/features/auth/strategies/jwt.strategy.ts +++ b/apps/nestjs-backend/src/features/auth/strategies/jwt.strategy.ts @@ -90,7 +90,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_TOKEN_STRATEGY_N throw new UnauthorizedException('Your account has been deactivated by the administrator'); } - if (user.isSystem) { + if (user.isSystem && payload.allowSystemUser !== true) { throw new UnauthorizedException('User is system user'); } diff --git a/apps/nestjs-backend/src/features/auth/strategies/types.ts b/apps/nestjs-backend/src/features/auth/strategies/types.ts index a6ab13abc0..8a3ef9b49e 100644 --- a/apps/nestjs-backend/src/features/auth/strategies/types.ts +++ b/apps/nestjs-backend/src/features/auth/strategies/types.ts @@ -9,6 +9,7 @@ export type IFromExtractor = (req: Request) => string | null; export interface IJwtAuthInfo { userId: string; + allowSystemUser?: boolean; } export enum JwtAuthInternalType { diff --git a/apps/nestjs-backend/src/features/base-share/base-share-auth.service.ts b/apps/nestjs-backend/src/features/base-share/base-share-auth.service.ts index f218bc0ed5..7796b2fc4f 100644 --- a/apps/nestjs-backend/src/features/base-share/base-share-auth.service.ts +++ b/apps/nestjs-backend/src/features/base-share/base-share-auth.service.ts @@ -10,6 +10,7 @@ export interface IBaseShareInfo { nodeId: string; allowSave: boolean | null; allowCopy: boolean | null; + allowEdit: boolean | null; } export interface IJwtBaseShareInfo { @@ -84,6 +85,7 @@ export class BaseShareAuthService { nodeId: share.nodeId, allowSave: share.allowSave, allowCopy: share.allowCopy, + allowEdit: share.allowEdit, }; } diff --git a/apps/nestjs-backend/src/features/base-share/base-share-open.controller.ts b/apps/nestjs-backend/src/features/base-share/base-share-open.controller.ts index 2cbc512f93..8e4a685ee7 100644 --- a/apps/nestjs-backend/src/features/base-share/base-share-open.controller.ts +++ b/apps/nestjs-backend/src/features/base-share/base-share-open.controller.ts @@ -61,7 +61,7 @@ export class BaseShareOpenController { @Request() req: Express.Request & { baseShareInfo: IBaseShareInfo } ): Promise { const shareInfo = req.baseShareInfo; - const { baseId, nodeId, allowSave, allowCopy } = shareInfo; + const { baseId, nodeId, allowSave, allowCopy, allowEdit } = shareInfo; // Build default URL for redirect const defaultUrl = await this.buildDefaultUrl(baseId, nodeId); @@ -73,6 +73,7 @@ export class BaseShareOpenController { nodeId, allowSave, allowCopy, + allowEdit, }, defaultUrl, }; diff --git a/apps/nestjs-backend/src/features/base-share/base-share.service.ts b/apps/nestjs-backend/src/features/base-share/base-share.service.ts index 066e55bde2..c426623ab7 100644 --- a/apps/nestjs-backend/src/features/base-share/base-share.service.ts +++ b/apps/nestjs-backend/src/features/base-share/base-share.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { generateShareId, HttpErrorCode } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import type { ICreateBaseShareRo, IUpdateBaseShareRo, IBaseShareVo } from '@teable/openapi'; +import { BaseNodeResourceType } from '@teable/openapi'; import { ClsService } from 'nestjs-cls'; import { CustomHttpException } from '../../custom.exception'; import { PerformanceCache, PerformanceCacheService } from '../../performance-cache'; @@ -24,6 +25,30 @@ export class BaseShareService { await this.performanceCacheService.del(generateBaseShareListCacheKey(baseId)); } + private async isTableNode(nodeId: string): Promise { + const node = await this.prismaService.baseNode.findFirst({ + where: { id: nodeId }, + select: { resourceType: true }, + }); + return node?.resourceType === BaseNodeResourceType.Table; + } + + /** + * allowEdit and allowSave are mutually exclusive: + * allowEdit=true → allowSave must be false + * allowSave=true → allowEdit must be false + */ + private resolveEditSaveFlags( + allowEdit: boolean | null | undefined, + allowSave: boolean | null | undefined + ): { allowEdit: boolean | null; allowSave: boolean | null } { + const edit = allowEdit ?? null; + const save = allowSave ?? null; + if (edit) return { allowEdit: true, allowSave: false }; + if (save) return { allowEdit: false, allowSave: true }; + return { allowEdit: edit, allowSave: save }; + } + private formatBaseShareVo(share: { baseId: string; shareId: string; @@ -31,6 +56,7 @@ export class BaseShareService { nodeId: string; allowSave: boolean | null; allowCopy: boolean | null; + allowEdit: boolean | null; enabled: boolean; }): IBaseShareVo { return { @@ -40,11 +66,20 @@ export class BaseShareService { nodeId: share.nodeId, allowSave: share.allowSave, allowCopy: share.allowCopy, + allowEdit: share.allowEdit, enabled: share.enabled, }; } async createBaseShare(baseId: string, data: ICreateBaseShareRo): Promise { + // allowEdit is only valid for table nodes + if (data.allowEdit && !(await this.isTableNode(data.nodeId))) { + throw new CustomHttpException( + 'allowEdit is only supported for table nodes', + HttpErrorCode.VALIDATION_ERROR + ); + } + const userId = this.cls.get('user.id'); // Check if a share already exists for this node @@ -54,13 +89,25 @@ export class BaseShareService { if (existingShare) { // If existing share is disabled, re-enable it if (!existingShare.enabled) { + const resolvedEdit = data.allowEdit ?? existingShare.allowEdit; + if (resolvedEdit && !(await this.isTableNode(data.nodeId))) { + throw new CustomHttpException( + 'allowEdit is only supported for table nodes', + HttpErrorCode.VALIDATION_ERROR + ); + } + const { allowEdit, allowSave } = this.resolveEditSaveFlags( + resolvedEdit, + data.allowSave ?? existingShare.allowSave + ); const updated = await this.prismaService.baseShare.update({ where: { id: existingShare.id }, data: { enabled: true, password: data.password || existingShare.password, - allowSave: data.allowSave ?? existingShare.allowSave, + allowSave, allowCopy: data.allowCopy ?? existingShare.allowCopy, + allowEdit, }, }); // Invalidate cache when re-enabling share @@ -79,14 +126,16 @@ export class BaseShareService { } const shareId = generateShareId(); + const { allowEdit, allowSave } = this.resolveEditSaveFlags(data.allowEdit, data.allowSave); const share = await this.prismaService.baseShare.create({ data: { baseId, shareId, password: data.password || null, nodeId: data.nodeId, - allowSave: data.allowSave, + allowSave, allowCopy: data.allowCopy, + allowEdit, createdBy: userId, }, }); @@ -144,12 +193,25 @@ export class BaseShareService { }); } + if (data.allowEdit && !(await this.isTableNode(share.nodeId))) { + throw new CustomHttpException( + 'allowEdit is only supported for table nodes', + HttpErrorCode.VALIDATION_ERROR + ); + } + + const { allowEdit, allowSave } = this.resolveEditSaveFlags( + data.allowEdit !== undefined ? data.allowEdit : share.allowEdit, + data.allowSave !== undefined ? data.allowSave : share.allowSave + ); + const updated = await this.prismaService.baseShare.update({ where: { id: share.id }, data: { password: data.password !== undefined ? data.password : share.password, - allowSave: data.allowSave !== undefined ? data.allowSave : share.allowSave, + allowSave, allowCopy: data.allowCopy !== undefined ? data.allowCopy : share.allowCopy, + allowEdit, enabled: data.enabled !== undefined ? data.enabled : share.enabled, }, }); diff --git a/apps/nestjs-backend/src/features/canary/interceptors/v2-indicator.interceptor.ts b/apps/nestjs-backend/src/features/canary/interceptors/v2-indicator.interceptor.ts index bb77499759..8a5ecd8d74 100644 --- a/apps/nestjs-backend/src/features/canary/interceptors/v2-indicator.interceptor.ts +++ b/apps/nestjs-backend/src/features/canary/interceptors/v2-indicator.interceptor.ts @@ -65,6 +65,14 @@ export class V2IndicatorInterceptor implements NestInterceptor { constructor(private readonly cls: ClsService) {} + private setHeaderIfPossible(response: Response, name: string, value: string) { + if (response.headersSent || response.writableEnded || response.destroyed) { + return; + } + + response.setHeader(name, value); + } + intercept(context: ExecutionContext, next: CallHandler): Observable { const useV2 = this.cls.get('useV2'); const v2Reason = this.cls.get('v2Reason'); @@ -75,12 +83,12 @@ export class V2IndicatorInterceptor implements NestInterceptor { // Add V2 indicator headers regardless of useV2 value // This allows clients to understand why V2 was or wasn't used - response.setHeader(X_TEABLE_V2_HEADER, useV2 ? 'true' : 'false'); + this.setHeaderIfPossible(response, X_TEABLE_V2_HEADER, useV2 ? 'true' : 'false'); if (v2Reason) { - response.setHeader(X_TEABLE_V2_REASON_HEADER, v2Reason); + this.setHeaderIfPossible(response, X_TEABLE_V2_REASON_HEADER, v2Reason); } if (v2Feature) { - response.setHeader(X_TEABLE_V2_FEATURE_HEADER, v2Feature); + this.setHeaderIfPossible(response, X_TEABLE_V2_FEATURE_HEADER, v2Feature); } // Mirror V2 indicators into Sentry tags so issue search can distinguish v1/v2 requests. diff --git a/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts b/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts index e08145bb77..be5cfbe6a6 100644 --- a/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts +++ b/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts @@ -3,7 +3,11 @@ import type { IV2BaseSchemaIntegrityRepairRo, IV2SchemaIntegrityFilterStatus, IV2SchemaIntegrityCheckResult, + IV2SchemaIntegrityI18nMessage, + IV2SchemaIntegrityManualRepairSchema, + IV2SchemaIntegrityManualRepairSchemaProperty, IV2SchemaIntegrityRepairResult, + IV2SchemaIntegrityRepairCapability, IV2SchemaIntegrityRepairRo, } from '@teable/openapi'; import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg'; @@ -13,6 +17,7 @@ import { PostgresSchemaIntrospector, type SchemaCheckResult, type SchemaRepairResult, + type SchemaRuleRepairHint, } from '@teable/v2-adapter-table-repository-postgres'; import { BaseId, @@ -67,6 +72,7 @@ export class IntegrityV2Service { table, repairer.repairRule(table, repairRo.fieldId, repairRo.ruleId, { dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, }), repairRo.statuses ); @@ -77,6 +83,7 @@ export class IntegrityV2Service { table, repairer.repairField(table, repairRo.fieldId, { dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, }), repairRo.statuses ); @@ -86,6 +93,7 @@ export class IntegrityV2Service { table, repairer.repairTable(table, { dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, }), repairRo.statuses ); @@ -206,7 +214,10 @@ export class IntegrityV2Service { for (const table of tables) { yield* this.decorateRepairStream( table, - repairer.repairTable(table, { dryRun: repairRo.dryRun }), + repairer.repairTable(table, { + dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, + }), repairRo.statuses ); } @@ -261,9 +272,12 @@ export class IntegrityV2Service { details: result.details ? { missing: this.toMutableArray(result.details.missing), + missingItems: this.toMutableDetailItems(result.details.missingItems), extra: this.toMutableArray(result.details.extra), + extraItems: this.toMutableDetailItems(result.details.extraItems), } : undefined, + repair: result.repair ? this.toMutableRepairHint(result.repair) : undefined, required: result.required, timestamp: result.timestamp, dependencies: result.dependencies.map((depId) => this.createScopedResultId(table, depId)), @@ -289,10 +303,13 @@ export class IntegrityV2Service { details: result.details ? { missing: this.toMutableArray(result.details.missing), + missingItems: this.toMutableDetailItems(result.details.missingItems), extra: this.toMutableArray(result.details.extra), + extraItems: this.toMutableDetailItems(result.details.extraItems), statementCount: result.details.statementCount, } : undefined, + repair: result.repair ? this.toMutableRepairHint(result.repair) : undefined, required: result.required, timestamp: result.timestamp, dependencies: result.dependencies.map((depId) => this.createScopedResultId(table, depId)), @@ -308,6 +325,127 @@ export class IntegrityV2Service { return values ? [...values] : undefined; } + private toMutableDetailItems( + items?: ReadonlyArray<{ + code?: string; + message: { + key?: string; + values?: Readonly>; + fallback?: string; + }; + description?: { + key?: string; + values?: Readonly>; + fallback?: string; + }; + }> + ) { + return items?.map((item) => ({ + code: item.code, + message: { + key: item.message.key, + values: item.message.values ? { ...item.message.values } : undefined, + fallback: item.message.fallback, + }, + description: item.description + ? { + key: item.description.key, + values: item.description.values ? { ...item.description.values } : undefined, + fallback: item.description.fallback, + } + : undefined, + })); + } + + private toMutableRepairHint(result: SchemaRuleRepairHint) { + const toMutableMessage = (message?: { + key?: string; + values?: Readonly>; + fallback?: string; + }): IV2SchemaIntegrityI18nMessage | undefined => { + if (!message) { + return undefined; + } + + return { + key: message.key, + values: message.values ? { ...message.values } : undefined, + fallback: message.fallback, + }; + }; + + const toMutableManualRepairProperty = (property: { + type: 'string' | 'boolean'; + widget?: 'select' | 'text' | 'textarea' | 'checkbox'; + title?: { + key?: string; + values?: Readonly>; + fallback?: string; + }; + description?: { + key?: string; + values?: Readonly>; + fallback?: string; + }; + options?: ReadonlyArray<{ + value: string; + label: { + key?: string; + values?: Readonly>; + fallback?: string; + }; + description?: { + key?: string; + values?: Readonly>; + fallback?: string; + }; + }>; + defaultValue?: string | boolean; + }): IV2SchemaIntegrityManualRepairSchemaProperty => ({ + type: property.type, + widget: property.widget, + title: toMutableMessage(property.title), + description: toMutableMessage(property.description), + options: property.options?.map((option) => ({ + value: option.value, + label: { + key: option.label.key, + values: option.label.values ? { ...option.label.values } : undefined, + fallback: option.label.fallback, + }, + description: toMutableMessage(option.description), + })), + defaultValue: property.defaultValue, + }); + + const manualRepairSchema: IV2SchemaIntegrityManualRepairSchema | undefined = + result.manualRepairSchema + ? { + type: result.manualRepairSchema.type, + title: toMutableMessage(result.manualRepairSchema.title), + description: toMutableMessage(result.manualRepairSchema.description), + submitLabel: toMutableMessage(result.manualRepairSchema.submitLabel), + required: result.manualRepairSchema.required + ? [...result.manualRepairSchema.required] + : undefined, + properties: Object.fromEntries( + Object.entries(result.manualRepairSchema.properties).map(([key, property]) => [ + key, + toMutableManualRepairProperty(property), + ]) + ), + } + : undefined; + + return { + available: result.available, + mode: result.mode, + reason: toMutableMessage(result.reason), + description: toMutableMessage(result.description), + manualRepairSchema, + } satisfies IV2SchemaIntegrityRepairCapability; + } + private createStatusFilterSet(statuses?: IV2SchemaIntegrityFilterStatus[]) { return statuses?.length ? new Set(statuses) : undefined; } diff --git a/apps/nestjs-backend/src/features/integrity/link-integrity.service.ts b/apps/nestjs-backend/src/features/integrity/link-integrity.service.ts index 41344aa19f..f86ae63fbd 100644 --- a/apps/nestjs-backend/src/features/integrity/link-integrity.service.ts +++ b/apps/nestjs-backend/src/features/integrity/link-integrity.service.ts @@ -2,11 +2,19 @@ import { Injectable, Logger } from '@nestjs/common'; import { FieldType, - type ILinkFieldOptions, CellValueType, DbFieldType, Relationship, DriverClient, + getValidFilterOperators, + FieldOpBuilder, +} from '@teable/core'; +import type { + IFilter, + IFilterItem, + IFilterSet, + ILinkFieldOptions, + IOtOperation, } from '@teable/core'; import type { Field } from '@teable/db-main-prisma'; import { Prisma, PrismaService } from '@teable/db-main-prisma'; @@ -16,6 +24,7 @@ import { InjectModel } from 'nest-knexjs'; import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; import { LinkFieldQueryService } from '../field/field-calculate/link-field-query.service'; +import { FieldService } from '../field/field.service'; import { createFieldInstanceByRaw } from '../field/model/factory'; import type { LinkFieldDto } from '../field/model/field-dto/link-field.dto'; import { TableDomainQueryService } from '../table-domain'; @@ -34,6 +43,7 @@ export class LinkIntegrityService { private readonly uniqueIndexService: UniqueIndexService, private readonly tableDomainQueryService: TableDomainQueryService, private readonly linkFieldQueryService: LinkFieldQueryService, + private readonly fieldService: FieldService, @InjectDbProvider() private readonly dbProvider: IDbProvider, @InjectModel('CUSTOM_KNEX') private readonly knex: Knex ) {} @@ -142,6 +152,15 @@ export class LinkIntegrityService { } } + const filterIssues = await this.checkInvalidFilterOperators(baseId); + if (filterIssues.length > 0) { + linkFieldIssues.push({ + baseId: mainBase.id, + baseName: mainBase.name, + issues: filterIssues, + }); + } + return { hasIssues: linkFieldIssues.length > 0, linkFieldIssues, @@ -835,6 +854,11 @@ export class LinkIntegrityService { result && fixResults.push(result); break; } + case IntegrityIssueType.InvalidFilterOperator: { + const result = await this.fixInvalidFilterOperator(issue.fieldId); + result && fixResults.push(result); + break; + } default: break; } @@ -930,4 +954,247 @@ export class LinkIntegrityService { message: 'Empty string cell value fixed', }; } + + private async checkInvalidFilterOperators(baseId: string): Promise { + const issues: IIntegrityIssue[] = []; + + const tableIds = await this.prismaService.tableMeta.findMany({ + where: { baseId, deletedTime: null }, + select: { id: true }, + }); + + const allFields = await this.prismaService.field.findMany({ + where: { + tableId: { in: tableIds.map((t) => t.id) }, + deletedTime: null, + }, + select: { + id: true, + name: true, + type: true, + cellValueType: true, + isMultipleCellValue: true, + options: true, + lookupOptions: true, + tableId: true, + }, + }); + + const fieldMap = new Map(allFields.map((f) => [f.id, f])); + + for (const field of allFields) { + const filters: { filter: IFilter; source: 'options' | 'lookupOptions' }[] = []; + + if (field.options) { + try { + const options = JSON.parse(field.options); + if (options.filter?.filterSet) { + filters.push({ filter: options.filter, source: 'options' }); + } + } catch { + /* skip */ + } + } + + if (field.lookupOptions) { + try { + const lookupOptions = JSON.parse(field.lookupOptions); + if (lookupOptions.filter?.filterSet) { + filters.push({ filter: lookupOptions.filter, source: 'lookupOptions' }); + } + } catch { + /* skip */ + } + } + + for (const { filter } of filters) { + const invalidOps = this.findInvalidFilterOperators(filter, fieldMap); + if (invalidOps.length > 0) { + const details = invalidOps + .map((inv) => `"${inv.operator}" on "${inv.targetFieldName}"`) + .join(', '); + issues.push({ + type: IntegrityIssueType.InvalidFilterOperator, + fieldId: field.id, + tableId: field.tableId, + message: `Field "${field.name}" has invalid filter operators: ${details}`, + }); + break; + } + } + } + + return issues; + } + + private findInvalidFilterOperators( + filter: IFilter | IFilterSet, + fieldMap: Map< + string, + { + name: string; + type: string; + cellValueType: string | null; + isMultipleCellValue: boolean | null; + } + > + ): Array<{ targetFieldId: string; targetFieldName: string; operator: string }> { + const results: Array<{ targetFieldId: string; targetFieldName: string; operator: string }> = []; + + if (!filter?.filterSet) return results; + + for (const item of filter.filterSet) { + if ('filterSet' in item) { + results.push(...this.findInvalidFilterOperators(item as IFilterSet, fieldMap)); + continue; + } + + const filterItem = item as IFilterItem; + const targetField = fieldMap.get(filterItem.fieldId); + if (!targetField) continue; + + const validOps = getValidFilterOperators({ + cellValueType: targetField.cellValueType as CellValueType, + type: targetField.type as FieldType, + isMultipleCellValue: targetField.isMultipleCellValue ?? undefined, + }); + + if (!(validOps as string[]).includes(filterItem.operator as string)) { + results.push({ + targetFieldId: filterItem.fieldId, + targetFieldName: targetField.name ?? filterItem.fieldId, + operator: filterItem.operator, + }); + } + } + + return results; + } + + private async fixInvalidFilterOperator(fieldId: string): Promise { + const fieldRaw = await this.prismaService.field.findFirst({ + where: { id: fieldId, deletedTime: null }, + }); + + if (!fieldRaw) return; + + // Get all fields in the same base to validate filter operators + const tableMeta = await this.prismaService.tableMeta.findFirst({ + where: { id: fieldRaw.tableId, deletedTime: null }, + select: { baseId: true }, + }); + if (!tableMeta) return; + + const tablesInBase = await this.prismaService.tableMeta.findMany({ + where: { baseId: tableMeta.baseId, deletedTime: null }, + select: { id: true }, + }); + + const allFields = await this.prismaService.field.findMany({ + where: { + tableId: { in: tablesInBase.map((t) => t.id) }, + deletedTime: null, + }, + select: { + id: true, + type: true, + cellValueType: true, + isMultipleCellValue: true, + }, + }); + + const fieldMap = new Map(allFields.map((f) => [f.id, f])); + const ops: IOtOperation[] = []; + + if (fieldRaw.options) { + try { + const options = JSON.parse(fieldRaw.options); + if (options.filter?.filterSet) { + const cleaned = this.removeInvalidFilterItems(options.filter, fieldMap); + const newFilter = cleaned?.filterSet?.length ? cleaned : null; + if (JSON.stringify(newFilter) !== JSON.stringify(options.filter)) { + ops.push( + FieldOpBuilder.editor.setFieldProperty.build({ + key: 'options', + oldValue: options, + newValue: { ...options, filter: newFilter }, + }) + ); + } + } + } catch { + /* skip */ + } + } + + if (fieldRaw.lookupOptions) { + try { + const lookupOptions = JSON.parse(fieldRaw.lookupOptions); + if (lookupOptions.filter?.filterSet) { + const cleaned = this.removeInvalidFilterItems(lookupOptions.filter, fieldMap); + const newFilter = cleaned?.filterSet?.length ? cleaned : null; + if (JSON.stringify(newFilter) !== JSON.stringify(lookupOptions.filter)) { + ops.push( + FieldOpBuilder.editor.setFieldProperty.build({ + key: 'lookupOptions', + oldValue: lookupOptions, + newValue: { ...lookupOptions, filter: newFilter }, + }) + ); + } + } + } catch { + /* skip */ + } + } + + if (!ops.length) return; + + await this.fieldService.batchUpdateFields(fieldRaw.tableId, [{ fieldId, ops }]); + + return { + type: IntegrityIssueType.InvalidFilterOperator, + fieldId, + message: `Removed invalid filter operators from field "${fieldRaw.name}"`, + }; + } + + private removeInvalidFilterItems( + filter: IFilterSet, + fieldMap: Map< + string, + { + type: string; + cellValueType: string | null; + isMultipleCellValue: boolean | null; + } + > + ): IFilterSet { + const filterSet: (IFilterItem | IFilterSet)[] = []; + + for (const item of filter.filterSet) { + if ('filterSet' in item) { + const nested = this.removeInvalidFilterItems(item, fieldMap); + if (nested.filterSet.length > 0) { + filterSet.push(nested); + } + continue; + } + + const targetField = fieldMap.get(item.fieldId); + if (!targetField) continue; + + const validOps = getValidFilterOperators({ + cellValueType: targetField.cellValueType as CellValueType, + type: targetField.type as FieldType, + isMultipleCellValue: targetField.isMultipleCellValue ?? undefined, + }); + + if ((validOps as string[]).includes(item.operator as string)) { + filterSet.push(item); + } + } + + return { ...filter, filterSet }; + } } diff --git a/apps/nestjs-backend/src/features/record/open-api/field-key.pipe.ts b/apps/nestjs-backend/src/features/record/open-api/field-key.pipe.ts index b93c8960e0..ada1ca08dd 100644 --- a/apps/nestjs-backend/src/features/record/open-api/field-key.pipe.ts +++ b/apps/nestjs-backend/src/features/record/open-api/field-key.pipe.ts @@ -40,7 +40,10 @@ export class FieldKeyPipe implements PipeTransform { } const fields = await this.dataLoaderService.field.load(tableId); - const fieldMap = keyBy(fields, fieldKeyType); + const fieldMap = { + ...keyBy(fields, fieldKeyType), + ...keyBy(fields, FieldKeyType.Id), + }; const transformedValue = { ...value }; diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.spec.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.spec.ts index 7657501ea7..3bc7978e4f 100644 --- a/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.spec.ts +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.spec.ts @@ -1,8 +1,14 @@ -import { CellValueType, FieldKeyType, FieldType, SortFunc, TimeFormatting } from '@teable/core'; +import { CellValueType, FieldKeyType, FieldType, SortFunc } from '@teable/core'; import { + CreateRecordResult, + CreateRecordsResult, + DuplicateRecordResult, ListTableRecordsQuery, ListTableRecordsResult, + UpdateRecordResult, UpdateRecordsResult, + TableRecord, + TableId, v2CoreTokens, } from '@teable/v2-core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -11,6 +17,9 @@ import { RecordOpenApiV2Service } from './record-open-api-v2.service'; describe('RecordOpenApiV2Service', () => { const createdTimeIso = '2026-03-19T01:02:03.000Z'; + const statusFieldId = `fld${'s'.repeat(16)}`; + const noteFieldId = `fld${'n'.repeat(16)}`; + const countFieldId = `fld${'c'.repeat(16)}`; const getDocIdsByQuery = vi.fn(); const getSnapshotBulkWithPermission = vi.fn(); const createContext = vi.fn(); @@ -26,6 +35,94 @@ describe('RecordOpenApiV2Service', () => { let service: RecordOpenApiV2Service; + const createUpdateRecordResult = (params: { + recordId: string; + tableId: string; + fields: Record; + fieldKeyMapping?: Map; + }) => { + const record = TableRecord.fromRawFieldValues({ + id: params.recordId, + tableId: TableId.create(params.tableId)._unsafeUnwrap(), + fields: params.fields, + })._unsafeUnwrap(); + + return UpdateRecordResult.create(record, [], params.fieldKeyMapping ?? new Map()); + }; + + const createUpdateRecordsResult = (params: { + tableId: string; + records: Array<{ + id: string; + fields: Record; + }>; + fieldKeyMapping?: Map; + }) => { + const records = params.records.map(({ id, fields }) => + TableRecord.fromRawFieldValues({ + id, + tableId: TableId.create(params.tableId)._unsafeUnwrap(), + fields, + })._unsafeUnwrap() + ); + + return UpdateRecordsResult.create( + records.length, + [], + records, + params.fieldKeyMapping ?? new Map() + ); + }; + + const createCreateRecordResult = (params: { + recordId: string; + tableId: string; + fields: Record; + fieldKeyMapping?: Map; + }) => { + const record = TableRecord.fromRawFieldValues({ + id: params.recordId, + tableId: TableId.create(params.tableId)._unsafeUnwrap(), + fields: params.fields, + })._unsafeUnwrap(); + + return CreateRecordResult.create(record, [], params.fieldKeyMapping ?? new Map()); + }; + + const createCreateRecordsResult = (params: { + tableId: string; + records: Array<{ + id: string; + fields: Record; + }>; + fieldKeyMapping?: Map; + }) => { + const records = params.records.map(({ id, fields }) => + TableRecord.fromRawFieldValues({ + id, + tableId: TableId.create(params.tableId)._unsafeUnwrap(), + fields, + })._unsafeUnwrap() + ); + + return CreateRecordsResult.create(records, [], params.fieldKeyMapping ?? new Map()); + }; + + const createDuplicateRecordResult = (params: { + recordId: string; + tableId: string; + fields: Record; + fieldKeyMapping?: Map; + }) => { + const record = TableRecord.fromRawFieldValues({ + id: params.recordId, + tableId: TableId.create(params.tableId)._unsafeUnwrap(), + fields: params.fields, + })._unsafeUnwrap(); + + return DuplicateRecordResult.create(record, [], params.fieldKeyMapping ?? new Map()); + }; + beforeEach(() => { vi.clearAllMocks(); @@ -224,6 +321,10 @@ describe('RecordOpenApiV2Service', () => { }, }, ]); + expect(getSnapshotBulkWithPermission).toHaveBeenCalledTimes(1); + expect(getFieldsByQuery).toHaveBeenCalledWith(`tbl${'c'.repeat(16)}`, { + projection: ['fldCreatedTime0001'], + }); }); it('does not normalize system datetime fields when they are not part of the active sort', async () => { @@ -247,19 +348,108 @@ describe('RecordOpenApiV2Service', () => { }, }, ]); + + const result = await service.getRecords(`tbl${'c'.repeat(16)}`, { + fieldKeyType: FieldKeyType.Name, + skip: 0, + take: 1, + }); + + expect(result.records).toEqual([ + { + id: 'rec1111111111111111', + createdTime: createdTimeIso, + fields: { + createdTime: createdTimeIso, + }, + }, + ]); + expect(getSnapshotBulkWithPermission).toHaveBeenCalledTimes(1); + expect(getFieldsByQuery).not.toHaveBeenCalled(); + }); + + it('reuses enabled field ids from the read source for snapshot projection', async () => { + getReadQuerySource.mockResolvedValue({ + tableName: 'test_table', + cteName: 'view_cte', + cteSql: 'select 1', + enabledFieldIds: ['fldVisible0000000001'], + }); + execute.mockResolvedValue({ + isErr: () => false, + value: ListTableRecordsResult.create( + [{ id: 'rec1111111111111111', fields: {}, version: 1 }], + 1, + 0, + 1 + ), + }); + getSnapshotBulkWithPermission.mockResolvedValue([ + { + data: { + id: 'rec1111111111111111', + fields: { + Visible: 'alpha', + }, + }, + }, + ]); getFieldsByQuery.mockResolvedValue([ { - id: 'fldCreatedTime0001', - name: 'createdTime', - type: FieldType.CreatedTime, - cellValueType: CellValueType.DateTime, + id: 'fldVisible0000000001', + name: 'Visible', + type: FieldType.SingleLineText, + cellValueType: CellValueType.String, isMultipleCellValue: false, - dbFieldType: 'timestamp', - options: { - formatting: { - date: 'YYYY-MM-DD', - time: TimeFormatting.None, - timeZone: 'UTC', + dbFieldType: 'text', + }, + ]); + + const result = await service.getRecords(`tbl${'c'.repeat(16)}`, { + fieldKeyType: FieldKeyType.Name, + skip: 0, + take: 1, + viewId: `viw${'v'.repeat(16)}`, + }); + + expect(result.records).toEqual([ + { + id: 'rec1111111111111111', + fields: { + Visible: 'alpha', + }, + }, + ]); + expect(getFieldsByQuery).toHaveBeenCalledWith(`tbl${'c'.repeat(16)}`, { + projection: ['fldVisible0000000001'], + }); + expect(getSnapshotBulkWithPermission).toHaveBeenCalledWith( + `tbl${'c'.repeat(16)}`, + ['rec1111111111111111'], + { Visible: true }, + FieldKeyType.Name, + undefined, + true + ); + }); + + it('keeps snapshot fallback when an explicit projection is requested', async () => { + execute.mockResolvedValue({ + isErr: () => false, + value: ListTableRecordsResult.create( + [{ id: 'rec1111111111111111', fields: { Title: 'Alpha' }, version: 1 }], + 1, + 0, + 1 + ), + }); + getSnapshotBulkWithPermission.mockResolvedValue([ + { + data: { + id: 'rec1111111111111111', + name: 'Alpha', + fields: { + Title: 'Alpha', }, }, }, @@ -267,6 +457,7 @@ describe('RecordOpenApiV2Service', () => { const result = await service.getRecords(`tbl${'c'.repeat(16)}`, { fieldKeyType: FieldKeyType.Name, + projection: ['Title'], skip: 0, take: 1, }); @@ -274,25 +465,33 @@ describe('RecordOpenApiV2Service', () => { expect(result.records).toEqual([ { id: 'rec1111111111111111', - createdTime: createdTimeIso, + name: 'Alpha', fields: { - createdTime: createdTimeIso, + Title: 'Alpha', }, }, ]); + expect(getSnapshotBulkWithPermission).toHaveBeenCalledTimes(1); }); it('routes explicit batch field updates through native v2 updateRecords', async () => { - getSnapshotBulkWithPermission.mockResolvedValue([ - { data: { id: 'rec1111111111111111', fields: { fldStatus: 'Done' } } }, - { data: { id: 'rec2222222222222222', fields: { fldStatus: 'Open' } } }, - ]); + commandExecute.mockResolvedValueOnce({ + isErr: () => false, + value: createUpdateRecordsResult({ + tableId: `tbl${'c'.repeat(16)}`, + records: [ + { id: 'rec1111111111111111', fields: { [statusFieldId]: 'Done' } }, + { id: 'rec2222222222222222', fields: { [statusFieldId]: 'Open' } }, + ], + fieldKeyMapping: new Map([[statusFieldId, statusFieldId]]), + }), + }); const result = await service.updateRecords(`tbl${'c'.repeat(16)}`, { fieldKeyType: FieldKeyType.Id, records: [ - { id: 'rec1111111111111111', fields: { fldStatus: 'Done' } }, - { id: 'rec2222222222222222', fields: { fldStatus: 'Open' } }, + { id: 'rec1111111111111111', fields: { [statusFieldId]: 'Done' } }, + { id: 'rec2222222222222222', fields: { [statusFieldId]: 'Open' } }, ], }); @@ -301,20 +500,71 @@ describe('RecordOpenApiV2Service', () => { expect(commandExecute.mock.calls[0]?.[1].records?.[0]?.recordId.toString()).toBe( 'rec1111111111111111' ); - expect(commandExecute.mock.calls[0]?.[1].records?.[1]?.fieldValues.get('fldStatus')).toBe( + expect(commandExecute.mock.calls[0]?.[1].records?.[1]?.fieldValues.get(statusFieldId)).toBe( 'Open' ); expect(commandExecute.mock.calls[0]?.[1].order).toBeUndefined(); expect(result).toEqual([ - { id: 'rec1111111111111111', fields: { fldStatus: 'Done' } }, - { id: 'rec2222222222222222', fields: { fldStatus: 'Open' } }, + { id: 'rec1111111111111111', fields: { [statusFieldId]: 'Done' } }, + { id: 'rec2222222222222222', fields: { [statusFieldId]: 'Open' } }, ]); + expect(getSnapshotBulkWithPermission).not.toHaveBeenCalled(); + expect(cacheDel).toHaveBeenCalledWith( + `operations:engine:usr${'h'.repeat(16)}:tbl${'c'.repeat(16)}:win${'i'.repeat(16)}` + ); + }); + + it('returns the v2 updateRecord payload directly without reloading legacy snapshots', async () => { + commandExecute.mockResolvedValueOnce({ + isErr: () => false, + value: createUpdateRecordResult({ + recordId: 'rec1111111111111111', + tableId: `tbl${'c'.repeat(16)}`, + fields: { + [`fld${'s'.repeat(16)}`]: 'Done', + [countFieldId]: '1', + }, + fieldKeyMapping: new Map([ + [`fld${'s'.repeat(16)}`, 'status'], + [countFieldId, countFieldId], + ]), + }), + }); + + const result = await service.updateRecord(`tbl${'c'.repeat(16)}`, 'rec1111111111111111', { + fieldKeyType: FieldKeyType.Name, + record: { + fields: { + status: 'Done', + }, + }, + }); + + expect(result).toEqual({ + id: 'rec1111111111111111', + fields: { + status: 'Done', + [countFieldId]: '1', + }, + }); + expect(getSnapshotBulkWithPermission).not.toHaveBeenCalled(); expect(cacheDel).toHaveBeenCalledWith( `operations:engine:usr${'h'.repeat(16)}:tbl${'c'.repeat(16)}:win${'i'.repeat(16)}` ); }); it('passes batch order through native v2 updateRecords', async () => { + commandExecute.mockResolvedValueOnce({ + isErr: () => false, + value: createUpdateRecordsResult({ + tableId: `tbl${'c'.repeat(16)}`, + records: [ + { id: 'rec1111111111111111', fields: { fldStatus: 'Done' } }, + { id: 'rec2222222222222222', fields: { fldStatus: 'Open' } }, + ], + }), + }); + await service.updateRecords(`tbl${'c'.repeat(16)}`, { fieldKeyType: FieldKeyType.Id, records: [ @@ -333,17 +583,63 @@ describe('RecordOpenApiV2Service', () => { expect(commandExecute.mock.calls[0]?.[1].order?.position).toBe('after'); }); - it('merges duplicate record updates before calling native v2 updateRecords', async () => { - getSnapshotBulkWithPermission.mockResolvedValue([ - { data: { id: 'rec1111111111111111', fields: { fldStatus: 'Done', fldNote: 'latest' } } }, + it('returns reorder-only batch updates from the native v2 payload without reloading snapshots', async () => { + commandExecute.mockResolvedValueOnce({ + isErr: () => false, + value: createUpdateRecordsResult({ + tableId: `tbl${'c'.repeat(16)}`, + records: [ + { id: 'rec1111111111111111', fields: { [statusFieldId]: 'Done' } }, + { id: 'rec2222222222222222', fields: { [statusFieldId]: 'Open' } }, + ], + fieldKeyMapping: new Map([[statusFieldId, 'status']]), + }), + }); + + const result = await service.updateRecords(`tbl${'c'.repeat(16)}`, { + fieldKeyType: FieldKeyType.Name, + records: [ + { id: 'rec1111111111111111', fields: {} }, + { id: 'rec2222222222222222', fields: {} }, + ], + order: { + viewId: `viw${'c'.repeat(16)}`, + anchorId: 'rec1111111111111111', + position: 'after', + }, + }); + + expect(result).toEqual([ + { id: 'rec1111111111111111', fields: { status: 'Done' } }, + { id: 'rec2222222222222222', fields: { status: 'Open' } }, ]); + expect(getSnapshotBulkWithPermission).not.toHaveBeenCalled(); + }); + + it('merges duplicate record updates before calling native v2 updateRecords', async () => { + commandExecute.mockResolvedValueOnce({ + isErr: () => false, + value: createUpdateRecordsResult({ + tableId: `tbl${'c'.repeat(16)}`, + records: [ + { + id: 'rec1111111111111111', + fields: { [statusFieldId]: 'Done', [noteFieldId]: 'latest' }, + }, + ], + fieldKeyMapping: new Map([ + [statusFieldId, statusFieldId], + [noteFieldId, noteFieldId], + ]), + }), + }); const result = await service.updateRecords(`tbl${'c'.repeat(16)}`, { fieldKeyType: FieldKeyType.Id, records: [ - { id: 'rec1111111111111111', fields: { fldStatus: 'Open', fldNote: 'first' } }, - { id: 'rec1111111111111111', fields: { fldStatus: 'Done' } }, - { id: 'rec1111111111111111', fields: { fldNote: 'latest' } }, + { id: 'rec1111111111111111', fields: { [statusFieldId]: 'Open', [noteFieldId]: 'first' } }, + { id: 'rec1111111111111111', fields: { [statusFieldId]: 'Done' } }, + { id: 'rec1111111111111111', fields: { [noteFieldId]: 'latest' } }, ], }); @@ -352,22 +648,131 @@ describe('RecordOpenApiV2Service', () => { expect(commandExecute.mock.calls[0]?.[1].records?.[0]?.recordId.toString()).toBe( 'rec1111111111111111' ); - expect(commandExecute.mock.calls[0]?.[1].records?.[0]?.fieldValues.get('fldStatus')).toBe( + expect(commandExecute.mock.calls[0]?.[1].records?.[0]?.fieldValues.get(statusFieldId)).toBe( 'Done' ); - expect(commandExecute.mock.calls[0]?.[1].records?.[0]?.fieldValues.get('fldNote')).toBe( + expect(commandExecute.mock.calls[0]?.[1].records?.[0]?.fieldValues.get(noteFieldId)).toBe( 'latest' ); - expect(getSnapshotBulkWithPermission).toHaveBeenCalledWith( - `tbl${'c'.repeat(16)}`, - ['rec1111111111111111'], - undefined, - FieldKeyType.Id, - undefined, - true - ); + expect(getSnapshotBulkWithPermission).not.toHaveBeenCalled(); expect(result).toEqual([ - { id: 'rec1111111111111111', fields: { fldStatus: 'Done', fldNote: 'latest' } }, + { id: 'rec1111111111111111', fields: { [statusFieldId]: 'Done', [noteFieldId]: 'latest' } }, ]); }); + + it('returns the v2 createRecords payload directly without reloading legacy snapshots', async () => { + commandExecute.mockResolvedValueOnce({ + isErr: () => false, + value: createCreateRecordsResult({ + tableId: `tbl${'c'.repeat(16)}`, + records: [ + { id: 'rec1111111111111111', fields: { [statusFieldId]: 'Done' } }, + { id: 'rec2222222222222222', fields: { [statusFieldId]: 'Open' } }, + ], + fieldKeyMapping: new Map([[statusFieldId, 'status']]), + }), + }); + + const result = await service.createRecords(`tbl${'c'.repeat(16)}`, { + fieldKeyType: FieldKeyType.Name, + records: [{ fields: { status: 'Done' } }, { fields: { status: 'Open' } }], + }); + + expect(result).toEqual({ + records: [ + { id: 'rec1111111111111111', fields: { status: 'Done' } }, + { id: 'rec2222222222222222', fields: { status: 'Open' } }, + ], + }); + expect(getSnapshotBulkWithPermission).not.toHaveBeenCalled(); + expect(cacheDel).toHaveBeenCalledWith( + `operations:engine:usr${'h'.repeat(16)}:tbl${'c'.repeat(16)}:win${'i'.repeat(16)}` + ); + }); + + it('returns the v2 formSubmit payload directly without reloading legacy snapshots', async () => { + commandExecute.mockResolvedValueOnce({ + isErr: () => false, + value: createCreateRecordResult({ + recordId: 'rec1111111111111111', + tableId: `tbl${'c'.repeat(16)}`, + fields: { [statusFieldId]: 'Done' }, + fieldKeyMapping: new Map([[statusFieldId, 'status']]), + }), + }); + + const result = await service.formSubmit(`tbl${'c'.repeat(16)}`, { + viewId: `viw${'c'.repeat(16)}`, + fields: { status: 'Done' }, + }); + + expect(result).toEqual({ + id: 'rec1111111111111111', + fields: { status: 'Done' }, + }); + expect(getSnapshotBulkWithPermission).not.toHaveBeenCalled(); + expect(cacheDel).toHaveBeenCalledWith( + `operations:engine:usr${'h'.repeat(16)}:tbl${'c'.repeat(16)}:win${'i'.repeat(16)}` + ); + }); + + it('returns the v2 duplicateRecord payload directly without reloading legacy snapshots', async () => { + commandExecute.mockResolvedValueOnce({ + isErr: () => false, + value: createDuplicateRecordResult({ + recordId: 'rec2222222222222222', + tableId: `tbl${'c'.repeat(16)}`, + fields: { [statusFieldId]: 'Copied' }, + fieldKeyMapping: new Map([[statusFieldId, 'status']]), + }), + }); + + const result = await service.duplicateRecord(`tbl${'c'.repeat(16)}`, 'rec1111111111111111', { + viewId: `viw${'c'.repeat(16)}`, + anchorId: 'rec1111111111111111', + position: 'after', + }); + + expect(result).toEqual({ + id: 'rec2222222222222222', + fields: { status: 'Copied' }, + }); + expect(getSnapshotBulkWithPermission).not.toHaveBeenCalled(); + expect(cacheDel).toHaveBeenCalledWith( + `operations:engine:usr${'h'.repeat(16)}:tbl${'c'.repeat(16)}:win${'i'.repeat(16)}` + ); + }); + + it('routes reorder-only single-record updates through native v2 updateRecord without reloading snapshots', async () => { + commandExecute.mockResolvedValueOnce({ + isErr: () => false, + value: createUpdateRecordResult({ + recordId: 'rec1111111111111111', + tableId: `tbl${'c'.repeat(16)}`, + fields: { [statusFieldId]: 'Done' }, + fieldKeyMapping: new Map([[statusFieldId, 'status']]), + }), + }); + + const result = await service.updateRecord(`tbl${'c'.repeat(16)}`, 'rec1111111111111111', { + fieldKeyType: FieldKeyType.Name, + record: { + fields: {}, + }, + order: { + viewId: `viw${'c'.repeat(16)}`, + anchorId: 'rec1111111111111111', + position: 'after', + }, + }); + + expect(result).toEqual({ + id: 'rec1111111111111111', + fields: { status: 'Done' }, + }); + expect(commandExecute).toHaveBeenCalledTimes(1); + expect(commandExecute.mock.calls[0]?.[1].fieldValues.size).toBe(0); + expect(commandExecute.mock.calls[0]?.[1].order?.viewId.toString()).toBe(`viw${'c'.repeat(16)}`); + expect(getSnapshotBulkWithPermission).not.toHaveBeenCalled(); + }); }); diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.ts index 7184013da9..f39c223d5f 100644 --- a/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.ts +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.ts @@ -16,6 +16,10 @@ import { type IFilterSet, } from '@teable/core'; import type { + IClearSelectionStreamEvent, + IDeleteSelectionStreamEvent, + IDuplicateSelectionStreamEvent, + IPasteSelectionStreamEvent, IUpdateRecordRo, IFormSubmitRo, IRecord, @@ -30,6 +34,7 @@ import type { IUpdateRecordsRo, } from '@teable/openapi'; import { RangeType } from '@teable/openapi'; +import { mapDomainErrorToHttpError, mapDomainErrorToHttpStatus } from '@teable/v2-contract-http'; import { executeCreateRecordsEndpoint, executeSubmitRecordEndpoint, @@ -40,21 +45,29 @@ import { executeUpdateRecordEndpoint, executeUpdateRecordsEndpoint, executeDuplicateRecordEndpoint, - executeReorderRecordsEndpoint, executeListTableRecordsEndpoint, } from '@teable/v2-contract-http-implementation/handlers'; import { v2CoreTokens } from '@teable/v2-core'; -import type { - ICommandBus, - IExecutionContext, - IListTableRecordsQueryInput, - IQueryBus, - RecordFilter, - RecordFilterDateValue, - RecordFilterGroup, - RecordFilterNode, - RecordFilterOperator, - RecordFilterValue, +import { + ClearStreamCommand, + DeleteByRangeStreamCommand, + DuplicateRecordsStreamCommand, + PasteStreamCommand, + type ClearStreamResult, + type DeleteByRangeStreamResult, + type DuplicateRecordsStreamResult, + type ICommandBus, + type IExecutionContext, + type IListTableRecordsQueryInput, + type IPasteCommandInput, + type IQueryBus, + type PasteStreamResult, + type RecordFilter, + type RecordFilterDateValue, + type RecordFilterGroup, + type RecordFilterNode, + type RecordFilterOperator, + type RecordFilterValue, } from '@teable/v2-core'; import { ClsService } from 'nestjs-cls'; import { CacheService } from '../../../cache/cache.service'; @@ -142,6 +155,23 @@ export class RecordOpenApiV2Service { await this.cacheService.del(key); } + private wrapStreamAndClearPreference( + stream: AsyncIterable, + tableId: string + ): AsyncIterable { + const clearUndoRedoEnginePreference = this.clearUndoRedoEnginePreference.bind(this); + return { + async *[Symbol.asyncIterator]() { + for await (const event of stream) { + if (event.id === 'done') { + await clearUndoRedoEnginePreference(tableId).catch(() => undefined); + } + yield event; + } + }, + }; + } + private mergeDuplicateRecordUpdates( records: NonNullable ): NonNullable { @@ -203,7 +233,8 @@ export class RecordOpenApiV2Service { const snapshotProjection = await this.resolveSnapshotProjection( tableId, query, - requestedFieldKeyType + requestedFieldKeyType, + enabledFieldIds ); const normalizedFilter = await this.normalizeFilterForV2(tableId, query.filter); const sortWithGroupFallback = this.mergeGroupByIntoSort( @@ -303,7 +334,9 @@ export class RecordOpenApiV2Service { } const sortedFieldIdSet = new Set(sortedFieldIds); - const fields = await this.fieldService.getFieldsByQuery(tableId); + const fields = await this.fieldService.getFieldsByQuery(tableId, { + projection: Array.from(sortedFieldIdSet), + }); const formatters = fields.flatMap((field) => { if (!sortedFieldIdSet.has(field.id)) { return []; @@ -382,7 +415,8 @@ export class RecordOpenApiV2Service { private async resolveSnapshotProjection( tableId: string, query: IGetRecordsRo, - fieldKeyType: FieldKeyType + fieldKeyType: FieldKeyType, + enabledFieldIds?: string[] ): Promise | undefined> { const explicitProjection = this.toProjectionMap( query.projection as unknown as string | string[] @@ -391,6 +425,26 @@ export class RecordOpenApiV2Service { return explicitProjection; } + if (enabledFieldIds?.length) { + if (fieldKeyType === FieldKeyType.Id) { + return this.toProjectionMap(enabledFieldIds); + } + + const visibleFields = await this.fieldService.getFieldsByQuery(tableId, { + projection: enabledFieldIds, + }); + const projectionKeys = visibleFields + .map((field) => { + if (fieldKeyType === FieldKeyType.Name) { + return field.name; + } + return field.dbFieldName || field.name; + }) + .filter((key): key is string => Boolean(key)); + + return this.toProjectionMap(projectionKeys); + } + if (query.ignoreViewQuery || !query.viewId) { return undefined; } @@ -526,7 +580,7 @@ export class RecordOpenApiV2Service { const commandBus = container.resolve(v2CoreTokens.commandBus); const context = await this.v2ContextFactory.createContext(); - if (hasFields) { + if (hasFields || (hasOrder && order)) { // Convert v1 input format to v2 format // v1: { record: { fields: { fieldKey: value } } } // v2: { tableId, recordId, fields: { fieldId: value } } @@ -558,47 +612,8 @@ export class RecordOpenApiV2Service { } await this.clearUndoRedoEnginePreference(tableId); - } - - if (!hasFields && hasOrder && order) { - const reorderResult = await executeReorderRecordsEndpoint( - context, - { - tableId, - recordIds: [recordId], - order: { - viewId: order.viewId, - anchorId: order.anchorId, - position: order.position, - }, - }, - commandBus - ); - if (!(reorderResult.status === 200 && reorderResult.body.ok)) { - if (!reorderResult.body.ok) { - this.throwV2Error(reorderResult.body.error, reorderResult.status); - } - throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); - } - - await this.clearUndoRedoEnginePreference(tableId); - } - - if (hasFields || hasOrder) { - const snapshots = await this.recordService.getSnapshotBulkWithPermission( - tableId, - [recordId], - undefined, - updateRecordRo.fieldKeyType || FieldKeyType.Name, - undefined, - true - ); - if (snapshots.length === 1) { - return snapshots[0].data as IRecord; - } - - throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + return result.body.data.record; } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); } @@ -653,36 +668,21 @@ export class RecordOpenApiV2Service { await this.clearUndoRedoEnginePreference(tableId); - const snapshots = await this.recordService.getSnapshotBulkWithPermission( - tableId, - recordIds, - undefined, - updateRecordsRo.fieldKeyType || FieldKeyType.Name, - undefined, - true - ); - - if (snapshots.length !== recordIds.length) { + if (!updateResult.body.data.records) { throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); } - const snapshotMap = new Map(snapshots.map((snapshot) => [snapshot.data.id, snapshot.data])); - const resultRecords = recordIds - .map((recordId) => snapshotMap.get(recordId)) - .filter((record): record is IRecord => Boolean(record)); - - if (resultRecords.length !== recordIds.length) { - throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); - } - - routeSpan?.setAttribute('record.update.response.recordCount', resultRecords.length); - return resultRecords; + routeSpan?.setAttribute( + 'record.update.response.recordCount', + updateResult.body.data.records.length + ); + return updateResult.body.data.records; } async createRecords( tableId: string, createRecordsRo: ICreateRecordsRo, - isAiInternal?: string + _isAiInternal?: string ): Promise { const container = await this.v2ContainerService.getContainer(); const commandBus = container.resolve(v2CoreTokens.commandBus); @@ -705,35 +705,9 @@ export class RecordOpenApiV2Service { if (result.status === 201 && result.body.ok) { await this.clearUndoRedoEnginePreference(tableId); - - const recordIds = result.body.data.records.map((record) => record.id); - if (recordIds.length === 0) { - return { records: [] }; - } - - const snapshots = await this.recordService.getSnapshotBulkWithPermission( - tableId, - recordIds, - undefined, - createRecordsRo.fieldKeyType || FieldKeyType.Name, - undefined, - true - ); - - if (snapshots.length !== recordIds.length) { - throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); - } - - const snapshotMap = new Map(snapshots.map((snapshot) => [snapshot.data.id, snapshot.data])); - const resultRecords = recordIds - .map((recordId) => snapshotMap.get(recordId)) - .filter((record): record is IRecord => Boolean(record)); - - if (resultRecords.length !== recordIds.length) { - throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); - } - - return { records: resultRecords }; + return { + records: result.body.data.records as IRecord[], + }; } if (!result.body.ok) { @@ -761,22 +735,81 @@ export class RecordOpenApiV2Service { if (result.status === 201 && result.body.ok) { await this.clearUndoRedoEnginePreference(tableId); + return result.body.data.record as IRecord; + } - const recordId = result.body.data.record.id; - const snapshots = await this.recordService.getSnapshotBulkWithPermission( - tableId, - [recordId], - undefined, - FieldKeyType.Id, - undefined, - true - ); + if (!result.body.ok) { + this.throwV2Error(result.body.error, result.status); + } - if (snapshots.length !== 1) { - throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + + async paste( + tableId: string, + pasteRo: IPasteRo, + options?: { + updateFilter?: IFilterSet | null; + windowId?: string; + allowFieldExpansion?: boolean; + allowRecordExpansion?: boolean; + } + ): Promise { + const container = await this.v2ContainerService.getContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(); + ( + context as IExecutionContext & { + [V2_RECORD_PASTE_AUDIT_CONTEXT_KEY]?: boolean; + } + )[V2_RECORD_PASTE_AUDIT_CONTEXT_KEY] = true; + const preparedPaste = await this.preparePasteCommandInput(tableId, pasteRo, options); + const result = await executePasteEndpoint(context, preparedPaste.commandInput, commandBus); + + if (result.status === 200 && result.body.ok) { + await this.clearUndoRedoEnginePreference(tableId); + + // V2 returns { updatedCount, createdCount, createdRecordIds } + // V1 expects { ranges: [[startCol, startRow], [endCol, endRow]] } + // Use truncatedRows (content size) for range calculation, not operation count, + // because some rows may be skipped due to permission filters + const finalCols = preparedPaste.finalContent[0]?.length ?? 1; + + // Note: Record creation and schema expansion undo/redo are handled by V2. + + // Best-effort: normalize v1 range formats (cell/rows/columns) into a cell range. + // v1 "ranges" uses `cellSchema` for all modes: + // - default: [col, row] + // - columns: [startCol, endCol] + // - rows: [startRow, endRow] + if (preparedPaste.type === 'columns') { + const endCol = preparedPaste.startCol + finalCols - 1; + return { + ranges: [ + [preparedPaste.startCol, 0], + [endCol, Math.max(preparedPaste.truncatedRows - 1, 0)], + ], + }; } - return snapshots[0].data as IRecord; + if (preparedPaste.type === 'rows') { + const endRow = preparedPaste.ranges[0]![1]; + return { + ranges: [ + [0, preparedPaste.startRow], + [Math.max(finalCols - 1, 0), endRow], + ], + }; + } + + const endRow = preparedPaste.startRow + Math.max(preparedPaste.truncatedRows - 1, 0); + const endCol = preparedPaste.startCol + finalCols - 1; + return { + ranges: [ + [preparedPaste.startCol, preparedPaste.startRow], + [endCol, Math.max(endRow, preparedPaste.startRow)], + ], + }; } if (!result.body.ok) { @@ -786,7 +819,7 @@ export class RecordOpenApiV2Service { throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); } - async paste( + async pasteStream( tableId: string, pasteRo: IPasteRo, options?: { @@ -795,7 +828,7 @@ export class RecordOpenApiV2Service { allowFieldExpansion?: boolean; allowRecordExpansion?: boolean; } - ): Promise { + ): Promise> { const container = await this.v2ContainerService.getContainer(); const commandBus = container.resolve(v2CoreTokens.commandBus); const context = await this.v2ContextFactory.createContext(); @@ -804,15 +837,48 @@ export class RecordOpenApiV2Service { [V2_RECORD_PASTE_AUDIT_CONTEXT_KEY]?: boolean; } )[V2_RECORD_PASTE_AUDIT_CONTEXT_KEY] = true; - const windowId = options?.windowId; - const tracer = trace.getTracer('default'); - // Convert v1 input format to v2 format - // v1 ranges format depends on type: - // - default (cell range): [[startCol, startRow], [endCol, endRow]] - // - columns: [[startCol, endCol]] - single element array - // - rows: [[startRow, endRow]] - single element array - // v2 now supports type parameter directly and handles the conversion internally + const preparedPaste = await this.preparePasteCommandInput(tableId, pasteRo, options); + const commandResult = PasteStreamCommand.create(preparedPaste.commandInput); + if (commandResult.isErr()) { + this.throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + this.throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + + return this.wrapStreamAndClearPreference(result.value, tableId); + } + + private async preparePasteCommandInput( + tableId: string, + pasteRo: IPasteRo, + options?: { + updateFilter?: IFilterSet | null; + allowFieldExpansion?: boolean; + allowRecordExpansion?: boolean; + } + ): Promise<{ + commandInput: IPasteCommandInput; + finalContent: unknown[][]; + startCol: number; + startRow: number; + truncatedRows: number; + type: IPasteRo['type']; + ranges: IPasteRo['ranges']; + }> { + const tracer = trace.getTracer('default'); const { ranges, content, @@ -828,26 +894,17 @@ export class RecordOpenApiV2Service { ignoreViewQuery, } = pasteRo; - let v2Input: unknown; - let finalContent: unknown[][] = []; - let startCol = 0; - let startRow = 0; - let truncatedRows = 0; - - await tracer.startActiveSpan('teable.paste.v2.prepare', async (span) => { + return tracer.startActiveSpan('teable.paste.v2.prepare', async (span) => { try { - // Parse content if it's a string (tab-separated values) let parsedContent: unknown[][] = typeof content === 'string' ? this.parseCopyContent(content) : content; - // Get permissions to check for field|create and record|create const permissions = this.cls.get('permissions') ?? []; const hasFieldCreatePermission = options?.allowFieldExpansion ?? permissions.includes('field|create'); const hasRecordCreatePermission = options?.allowRecordExpansion ?? permissions.includes('record|create'); - // Get table size to calculate expansion needs const rangeQuery = await this.normalizeRangeQuery(tableId, { viewId, filter, @@ -877,22 +934,19 @@ export class RecordOpenApiV2Service { tableId, queryRo ); - const tableSize: [number, number] = [fields.length, rowCountInView]; - // Calculate start cell based on range type + let startCol = 0; + let startRow = 0; if (type === 'columns') { startCol = ranges[0]![0]; - startRow = 0; } else if (type === 'rows') { - startCol = 0; startRow = ranges[0]![0]; } else { startCol = ranges[0]![0]; startRow = ranges[0]![1]; } - // Expand paste content to fill selection (matches V1 behavior) parsedContent = this.expandPasteContent( parsedContent, type, @@ -905,23 +959,16 @@ export class RecordOpenApiV2Service { const contentCols = parsedContent[0]?.length ?? 0; const contentRows = parsedContent.length; - - // Calculate expansion needs const numColsToExpand = Math.max(0, startCol + contentCols - tableSize[0]); const numRowsToExpand = Math.max(0, startRow + contentRows - tableSize[1]); - - // Apply permission-based limits (like V1's calculateExpansion) const effectiveColsToExpand = hasFieldCreatePermission ? numColsToExpand : 0; const effectiveRowsToExpand = hasRecordCreatePermission ? numRowsToExpand : 0; - - // Truncate content if expansion is not allowed - finalContent = parsedContent; const maxCols = tableSize[0] - startCol + effectiveColsToExpand; const maxRows = tableSize[1] - startRow + effectiveRowsToExpand; - // Track if we need to adjust ranges due to truncation let truncatedCols = contentCols; - truncatedRows = contentRows; + let truncatedRows = contentRows; + let finalContent = parsedContent; if (contentCols > maxCols || contentRows > maxRows) { truncatedRows = Math.min(contentRows, maxRows); @@ -931,19 +978,14 @@ export class RecordOpenApiV2Service { .map((row) => row.slice(0, truncatedCols)); } - // Adjust ranges to match truncated content (prevents V2 core from re-expanding) let adjustedRanges = ranges; if (type === undefined && finalContent.length > 0 && finalContent[0]?.length > 0) { - // For cell type, adjust end position to match truncated content - const adjustedEndCol = startCol + truncatedCols - 1; - const adjustedEndRow = startRow + truncatedRows - 1; adjustedRanges = [ [startCol, startRow], - [adjustedEndCol, adjustedEndRow], + [startCol + truncatedCols - 1, startRow + truncatedRows - 1], ]; } - // Convert header to sourceFields format if provided const sourceFields = header?.map((field) => ({ name: field.name, type: field.type, @@ -953,7 +995,6 @@ export class RecordOpenApiV2Service { isMultipleCellValue: field.isMultipleCellValue, options: field.options, })); - const normalizedFilter = await this.normalizeFilterForV2(tableId, queryRo.filter); const normalizedUpdateFilter = options?.updateFilter ? await this.normalizeFilterForV2(tableId, options.updateFilter) @@ -962,89 +1003,38 @@ export class RecordOpenApiV2Service { rangeQuery.groupBy, rangeQuery.orderBy ); - v2Input = { - tableId, - viewId: rangeQuery.viewId, - ranges: adjustedRanges, - content: finalContent, - typecast: true, - sourceFields, - type, // Pass type to v2 for internal handling - projection, - // Let v2 core interpret the legacy search tuple via RecordSearch so - // search-aware row mapping and field/operator compatibility stay aligned. - filter: normalizedFilter, - search: rangeQuery.search, - updateFilter: normalizedUpdateFilter, - sort: sortWithGroupFallback, - groupBy: rangeQuery.groupBy?.map((item) => ({ - fieldId: item.fieldId, - order: item.order, - })), - ignoreViewQuery: rangeQuery.ignoreViewQuery, + + return { + commandInput: { + tableId, + viewId: rangeQuery.viewId, + ranges: adjustedRanges, + content: finalContent, + typecast: true, + sourceFields, + type, + projection, + filter: normalizedFilter, + search: rangeQuery.search, + updateFilter: normalizedUpdateFilter, + sort: sortWithGroupFallback, + groupBy: rangeQuery.groupBy?.map((item) => ({ + fieldId: item.fieldId, + order: item.order, + })), + ignoreViewQuery: rangeQuery.ignoreViewQuery, + }, + finalContent, + startCol, + startRow, + truncatedRows, + type, + ranges, }; } finally { span.end(); } }); - - if (!v2Input) { - throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); - } - - const result = await executePasteEndpoint(context, v2Input, commandBus); - - if (result.status === 200 && result.body.ok) { - await this.clearUndoRedoEnginePreference(tableId); - - // V2 returns { updatedCount, createdCount, createdRecordIds } - // V1 expects { ranges: [[startCol, startRow], [endCol, endRow]] } - // Use truncatedRows (content size) for range calculation, not operation count, - // because some rows may be skipped due to permission filters - const finalCols = finalContent[0]?.length ?? 1; - - // Note: Record creation and schema expansion undo/redo are handled by V2. - - // Best-effort: normalize v1 range formats (cell/rows/columns) into a cell range. - // v1 "ranges" uses `cellSchema` for all modes: - // - default: [col, row] - // - columns: [startCol, endCol] - // - rows: [startRow, endRow] - if (type === 'columns') { - const endCol = startCol + finalCols - 1; - return { - ranges: [ - [startCol, 0], - [endCol, Math.max(truncatedRows - 1, 0)], - ], - }; - } - - if (type === 'rows') { - const endRow = ranges[0]![1]; - return { - ranges: [ - [0, startRow], - [Math.max(finalCols - 1, 0), endRow], - ], - }; - } - - const endRow = startRow + Math.max(truncatedRows - 1, 0); - const endCol = startCol + finalCols - 1; - return { - ranges: [ - [startCol, startRow], - [endCol, Math.max(endRow, startRow)], - ], - }; - } - - if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); - } - - throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); } /** @@ -1146,6 +1136,54 @@ export class RecordOpenApiV2Service { throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); } + async clearStream( + tableId: string, + rangesRo: IRangesRo + ): Promise> { + const container = await this.v2ContainerService.getContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(); + + const rangeQuery = await this.normalizeRangeQuery(tableId, rangesRo); + const normalizedFilter = await this.normalizeFilterForV2(tableId, rangeQuery.filter); + const sortWithGroupFallback = this.mergeGroupByIntoSort(rangeQuery.groupBy, rangeQuery.orderBy); + + const commandResult = ClearStreamCommand.create({ + tableId, + viewId: rangeQuery.viewId, + ranges: rangesRo.ranges, + type: rangesRo.type, + projection: rangesRo.projection, + filter: normalizedFilter, + search: rangeQuery.search, + sort: sortWithGroupFallback, + groupBy: rangeQuery.groupBy?.map((item) => ({ + fieldId: item.fieldId, + order: item.order, + })), + ignoreViewQuery: rangeQuery.ignoreViewQuery, + }); + if (commandResult.isErr()) { + this.throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + this.throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + + return this.wrapStreamAndClearPreference(result.value, tableId); + } + /** * Get record IDs from ranges for undo/redo support and permission checks. * This method queries the record IDs that will be affected by a range-based operation. @@ -1273,6 +1311,104 @@ export class RecordOpenApiV2Service { throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); } + async deleteByRangeStream( + tableId: string, + rangesRo: IRangesRo + ): Promise> { + const container = await this.v2ContainerService.getContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(); + + const rangeQuery = await this.normalizeRangeQuery(tableId, rangesRo); + const sortWithGroupFallback = this.mergeGroupByIntoSort(rangeQuery.groupBy, rangeQuery.orderBy); + + const commandResult = DeleteByRangeStreamCommand.create({ + tableId, + viewId: rangeQuery.viewId, + ranges: rangesRo.ranges, + type: rangesRo.type, + filter: await this.normalizeFilterForV2(tableId, rangeQuery.filter), + sort: sortWithGroupFallback?.map((item) => ({ + fieldId: item.fieldId, + order: item.order, + })), + search: rangeQuery.search, + groupBy: rangeQuery.groupBy?.map((item) => ({ + fieldId: item.fieldId, + order: item.order, + })), + ignoreViewQuery: rangeQuery.ignoreViewQuery, + }); + if (commandResult.isErr()) { + this.throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + this.throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + + return this.wrapStreamAndClearPreference(result.value, tableId); + } + + async duplicateByRangeStream( + tableId: string, + rangesRo: IRangesRo + ): Promise> { + const container = await this.v2ContainerService.getContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(); + + const rangeQuery = await this.normalizeRangeQuery(tableId, rangesRo); + const sortWithGroupFallback = this.mergeGroupByIntoSort(rangeQuery.groupBy, rangeQuery.orderBy); + + const commandResult = DuplicateRecordsStreamCommand.create({ + tableId, + viewId: rangeQuery.viewId, + ranges: rangesRo.ranges, + type: rangesRo.type, + filter: await this.normalizeFilterForV2(tableId, rangeQuery.filter), + sort: sortWithGroupFallback?.map((item) => ({ + fieldId: item.fieldId, + order: item.order, + })), + search: rangeQuery.search, + groupBy: rangeQuery.groupBy?.map((item) => ({ + fieldId: item.fieldId, + order: item.order, + })), + ignoreViewQuery: rangeQuery.ignoreViewQuery, + }); + if (commandResult.isErr()) { + this.throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + + const result = await commandBus.execute< + DuplicateRecordsStreamCommand, + DuplicateRecordsStreamResult + >(context, commandResult.value); + if (result.isErr()) { + this.throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + + return this.wrapStreamAndClearPreference(result.value, tableId); + } + async deleteRecords( tableId: string, recordIds: string[], @@ -1848,24 +1984,7 @@ export class RecordOpenApiV2Service { if (result.status === 201 && result.body.ok) { await this.clearUndoRedoEnginePreference(tableId); - - const duplicatedRecordId = result.body.data.record.id; - - // Use V1 to get the full record with proper field key mapping - const snapshots = await this.recordService.getSnapshotBulkWithPermission( - tableId, - [duplicatedRecordId], - undefined, - FieldKeyType.Name, - undefined, - true - ); - - if (snapshots.length !== 1 || !snapshots[0]) { - throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); - } - - return snapshots[0].data as IRecord; + return result.body.data.record as IRecord; } if (!result.body.ok) { diff --git a/apps/nestjs-backend/src/features/record/query-builder/record-query-builder.service.ts b/apps/nestjs-backend/src/features/record/query-builder/record-query-builder.service.ts index ab1a29daba..53736f6bff 100644 --- a/apps/nestjs-backend/src/features/record/query-builder/record-query-builder.service.ts +++ b/apps/nestjs-backend/src/features/record/query-builder/record-query-builder.service.ts @@ -325,9 +325,9 @@ export class RecordQueryBuilderService implements IRecordQueryBuilder { const rawChoices = (field.options as { choices?: { name: string }[] } | undefined)?.choices; const choices = Array.isArray(rawChoices) ? rawChoices : []; if (choices.length) { - const arrayLiteral = `ARRAY[${choices - .map(({ name }) => this.knex.raw('?', [name]).toQuery()) - .join(', ')}]`; + const choiceNames = choices.map(({ name }) => name); + const placeholders = choiceNames.map(() => '?').join(', '); + const arrayLiteral = `ARRAY[${placeholders}]`; if (field.type === FieldType.MultipleSelect) { const firstIndexExpr = `CASE @@ -336,7 +336,11 @@ export class RecordQueryBuilderService implements IRecordQueryBuilder { THEN ARRAY_POSITION(${arrayLiteral}, jsonb_path_query_first(${orderableSelection}::jsonb, '$[0]') #>> '{}') ELSE ARRAY_POSITION(${arrayLiteral}, ${orderableSelection}::text) END`; - qb.orderByRaw(`${firstIndexExpr} ${direction} ${nullOrdering}`); + // arrayLiteral appears twice in firstIndexExpr, so duplicate bindings + qb.orderByRaw(`${firstIndexExpr} ${direction} ${nullOrdering}`, [ + ...choiceNames, + ...choiceNames, + ]); qb.orderByRaw(`${orderableSelection}::jsonb::text ${direction} ${nullOrdering}`); return; } else { @@ -345,7 +349,7 @@ export class RecordQueryBuilderService implements IRecordQueryBuilder { field.dbFieldType ); const arrayPositionExpr = `ARRAY_POSITION(${arrayLiteral}, ${normalizedExpr})`; - qb.orderByRaw(`${arrayPositionExpr} ${direction} ${nullOrdering}`); + qb.orderByRaw(`${arrayPositionExpr} ${direction} ${nullOrdering}`, choiceNames); return; } } diff --git a/apps/nestjs-backend/src/features/record/record.service.ts b/apps/nestjs-backend/src/features/record/record.service.ts index 58cacebb34..ef308691fa 100644 --- a/apps/nestjs-backend/src/features/record/record.service.ts +++ b/apps/nestjs-backend/src/features/record/record.service.ts @@ -2120,20 +2120,19 @@ export class RecordService { return searchArr.includes(field.id); }) .filter((field) => { - if ( - [CellValueType.Boolean, CellValueType.DateTime].includes(field.cellValueType) && - isSearchAllFields - ) { + if (field.type === FieldType.Button) { return false; } if (field.cellValueType === CellValueType.Boolean) { return false; } - return true; - }) - .filter((field) => { - if (field.type === FieldType.Button) { - return false; + if (isSearchAllFields) { + if (field.cellValueType === CellValueType.DateTime) { + return false; + } + if (field.cellValueType === CellValueType.Number && isNaN(Number(search[0]))) { + return false; + } } return true; }) diff --git a/apps/nestjs-backend/src/features/selection/selection.controller.spec.ts b/apps/nestjs-backend/src/features/selection/selection.controller.spec.ts deleted file mode 100644 index 84201025eb..0000000000 --- a/apps/nestjs-backend/src/features/selection/selection.controller.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { TestingModule } from '@nestjs/testing'; -import { Test } from '@nestjs/testing'; -import { SelectionController } from './selection.controller'; - -describe('SelectionController', () => { - let controller: SelectionController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [SelectionController], - }).compile(); - - controller = module.get(SelectionController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/apps/nestjs-backend/src/features/selection/selection.controller.test.ts b/apps/nestjs-backend/src/features/selection/selection.controller.test.ts new file mode 100644 index 0000000000..f778f36881 --- /dev/null +++ b/apps/nestjs-backend/src/features/selection/selection.controller.test.ts @@ -0,0 +1,589 @@ +import type { + IClearSelectionStreamEvent, + IDeleteSelectionStreamEvent, + IDuplicateSelectionStreamEvent, + IPasteSelectionStreamEvent, + IRangesRo, + IPasteRo, +} from '@teable/openapi'; +import { IdReturnType, RangeType } from '@teable/openapi'; +import type { Response } from 'express'; +import type { ClsService } from 'nestjs-cls'; +import type { Mocked } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IClsStore } from '../../types/cls'; +import { + X_TEABLE_V2_FEATURE_HEADER, + X_TEABLE_V2_HEADER, + X_TEABLE_V2_REASON_HEADER, +} from '../canary/interceptors/v2-indicator.interceptor'; +import type { RecordOpenApiV2Service } from '../record/open-api/record-open-api-v2.service'; +import type { RecordOpenApiService } from '../record/open-api/record-open-api.service'; +import { SelectionController } from './selection.controller'; +import type { SelectionService } from './selection.service'; + +describe('SelectionController', () => { + let controller: SelectionController; + let selectionService: Mocked< + Pick + >; + let recordOpenApiService: Mocked>; + let recordOpenApiV2Service: Mocked< + Pick< + RecordOpenApiV2Service, + 'clearStream' | 'deleteByRangeStream' | 'duplicateByRangeStream' | 'pasteStream' + > + >; + let cls: Mocked, 'get'>>; + + const rangesRo: IRangesRo = { + viewId: 'viwTest', + type: RangeType.Rows, + ranges: [[0, 1]], + }; + const pasteRo: IPasteRo = { + viewId: 'viwTest', + ranges: [ + [0, 0], + [0, 1], + ], + content: [['A'], ['B']], + }; + + const createMockSseResponse = () => + ({ + headersSent: false, + writableEnded: false, + destroyed: false, + setHeader: vi.fn(), + flushHeaders: vi.fn(), + write: vi.fn(), + end: vi.fn(), + on: vi.fn(), + flush: vi.fn(), + }) as unknown as Response & { + setHeader: ReturnType; + flushHeaders: ReturnType; + write: ReturnType; + end: ReturnType; + on: ReturnType; + }; + + const collectSseEvents = (response: ReturnType) => { + return response.write.mock.calls + .map(([chunk]) => String(chunk)) + .filter((chunk) => chunk.startsWith('data: ')) + .map((chunk) => JSON.parse(chunk.slice(6).trim())); + }; + + beforeEach(() => { + selectionService = { + clear: vi.fn(), + delete: vi.fn(), + getIdsFromRanges: vi.fn(), + paste: vi.fn(), + }; + recordOpenApiService = { + duplicateRecord: vi.fn(), + }; + recordOpenApiV2Service = { + clearStream: vi.fn(), + deleteByRangeStream: vi.fn(), + duplicateByRangeStream: vi.fn(), + pasteStream: vi.fn(), + }; + cls = { + get: vi.fn(), + }; + + controller = new SelectionController( + selectionService as unknown as SelectionService, + recordOpenApiService as unknown as RecordOpenApiService, + recordOpenApiV2Service as unknown as RecordOpenApiV2Service, + cls as unknown as ClsService + ); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('streams the legacy synchronous delete result when useV2 is false', async () => { + cls.get.mockImplementation((key) => (key === 'useV2' ? false : undefined)); + selectionService.delete.mockResolvedValue({ + ids: ['recLegacy1', 'recLegacy2'], + }); + const response = createMockSseResponse(); + + await controller.deleteStream('tblLegacy', rangesRo, 'window-1', response as never); + const events = collectSseEvents(response); + + expect(selectionService.delete).toHaveBeenCalledWith('tblLegacy', rangesRo, { + windowId: 'window-1', + }); + expect(recordOpenApiV2Service.deleteByRangeStream).not.toHaveBeenCalled(); + expect(response.flushHeaders).toHaveBeenCalled(); + expect(response.end).toHaveBeenCalled(); + expect(events).toEqual([ + { + id: 'progress', + phase: 'preparing', + batchIndex: -1, + totalCount: 2, + deletedCount: 0, + batchDeletedCount: 0, + }, + { + id: 'done', + totalCount: 2, + deletedCount: 2, + data: { + deletedCount: 2, + deletedRecordIds: ['recLegacy1', 'recLegacy2'], + }, + }, + ]); + }); + + it('streams the legacy synchronous clear result when useV2 is false', async () => { + cls.get.mockImplementation((key) => (key === 'useV2' ? false : undefined)); + selectionService.getIdsFromRanges.mockResolvedValue({ + recordIds: ['recLegacy1', 'recLegacy2'], + } as never); + selectionService.clear.mockResolvedValue(null as never); + const response = createMockSseResponse(); + + await controller.clearStream('tblLegacy', rangesRo, 'window-1', response as never); + const events = collectSseEvents(response); + + expect(selectionService.getIdsFromRanges).toHaveBeenCalledWith('tblLegacy', { + ...rangesRo, + returnType: IdReturnType.RecordId, + }); + expect(selectionService.clear).toHaveBeenCalledWith('tblLegacy', rangesRo, { + windowId: 'window-1', + }); + expect(recordOpenApiV2Service.clearStream).not.toHaveBeenCalled(); + expect(events).toEqual([ + { + id: 'progress', + phase: 'preparing', + batchIndex: -1, + totalCount: 2, + processedCount: 0, + clearedCount: 0, + batchProcessedCount: 0, + batchClearedCount: 0, + }, + { + id: 'done', + totalCount: 2, + processedCount: 2, + clearedCount: 2, + data: { + clearedCount: 2, + clearedRecordIds: [], + }, + }, + ]); + }); + + it('streams v2 clear events when useV2 is true', async () => { + cls.get.mockImplementation((key) => { + const values: Record = { + useV2: true, + v2Reason: 'canary', + v2Feature: 'clear', + }; + return typeof key === 'string' ? values[key] : undefined; + }); + const response = createMockSseResponse(); + + async function* createStream(): AsyncIterable { + yield { + id: 'progress', + phase: 'clearing', + batchIndex: 0, + totalCount: 2, + processedCount: 1, + clearedCount: 1, + batchProcessedCount: 1, + batchClearedCount: 1, + }; + yield { + id: 'done', + totalCount: 2, + processedCount: 2, + clearedCount: 2, + data: { + clearedCount: 2, + clearedRecordIds: ['recV21', 'recV22'], + }, + }; + } + + recordOpenApiV2Service.clearStream.mockResolvedValue(createStream()); + + await controller.clearStream('tblV2', rangesRo, undefined, response as never); + const events = collectSseEvents(response); + + expect(recordOpenApiV2Service.clearStream).toHaveBeenCalledWith('tblV2', rangesRo); + expect(selectionService.clear).not.toHaveBeenCalled(); + expect(response.setHeader).toHaveBeenCalledWith(X_TEABLE_V2_HEADER, 'true'); + expect(response.setHeader).toHaveBeenCalledWith(X_TEABLE_V2_REASON_HEADER, 'canary'); + expect(response.setHeader).toHaveBeenCalledWith(X_TEABLE_V2_FEATURE_HEADER, 'clear'); + expect(events).toEqual([ + { + id: 'progress', + phase: 'clearing', + batchIndex: 0, + totalCount: 2, + processedCount: 1, + clearedCount: 1, + batchProcessedCount: 1, + batchClearedCount: 1, + }, + { + id: 'done', + totalCount: 2, + processedCount: 2, + clearedCount: 2, + data: { + clearedCount: 2, + clearedRecordIds: ['recV21', 'recV22'], + }, + }, + ]); + }); + + it('streams v2 delete events when useV2 is true', async () => { + cls.get.mockImplementation((key) => { + const values: Record = { + useV2: true, + v2Reason: 'canary', + v2Feature: 'deleteRecord', + }; + return values[key]; + }); + const response = createMockSseResponse(); + + async function* createStream(): AsyncIterable { + yield { + id: 'progress', + phase: 'deleting', + batchIndex: 0, + totalCount: 2, + deletedCount: 1, + batchDeletedCount: 1, + }; + yield { + id: 'done', + totalCount: 2, + deletedCount: 2, + data: { + deletedCount: 2, + deletedRecordIds: ['recV21', 'recV22'], + }, + }; + } + + recordOpenApiV2Service.deleteByRangeStream.mockResolvedValue(createStream()); + + await controller.deleteStream('tblV2', rangesRo, undefined, response as never); + const events = collectSseEvents(response); + + expect(recordOpenApiV2Service.deleteByRangeStream).toHaveBeenCalledWith('tblV2', rangesRo); + expect(selectionService.delete).not.toHaveBeenCalled(); + expect(response.flushHeaders).toHaveBeenCalled(); + expect(response.setHeader).toHaveBeenCalledWith(X_TEABLE_V2_HEADER, 'true'); + expect(response.setHeader).toHaveBeenCalledWith(X_TEABLE_V2_REASON_HEADER, 'canary'); + expect(response.setHeader).toHaveBeenCalledWith(X_TEABLE_V2_FEATURE_HEADER, 'deleteRecord'); + expect(events).toEqual([ + { + id: 'progress', + phase: 'deleting', + batchIndex: 0, + totalCount: 2, + deletedCount: 1, + batchDeletedCount: 1, + }, + { + id: 'done', + totalCount: 2, + deletedCount: 2, + data: { + deletedCount: 2, + deletedRecordIds: ['recV21', 'recV22'], + }, + }, + ]); + }); + + it('converts stream failures into error events', async () => { + cls.get.mockImplementation((key) => (key === 'useV2' ? true : undefined)); + + async function* createFailingStream(): AsyncIterable { + yield* []; + throw new Error('stream failed'); + } + + recordOpenApiV2Service.deleteByRangeStream.mockResolvedValue(createFailingStream()); + const response = createMockSseResponse(); + + await controller.deleteStream('tblV2', rangesRo, undefined, response as never); + const events = collectSseEvents(response); + + expect(events).toEqual([ + { + id: 'error', + phase: 'deleting', + batchIndex: -1, + totalCount: 0, + deletedCount: 0, + recordIds: [], + message: 'stream failed', + }, + ]); + }); + + it('streams the legacy synchronous duplicate result when useV2 is false', async () => { + cls.get.mockImplementation((key) => (key === 'useV2' ? false : undefined)); + selectionService.getIdsFromRanges.mockResolvedValue({ + recordIds: ['recSource1', 'recSource2'], + }); + recordOpenApiService.duplicateRecord + .mockResolvedValueOnce({ id: 'recCopy1' } as never) + .mockResolvedValueOnce({ id: 'recCopy2' } as never); + const response = createMockSseResponse(); + + await controller.duplicateStream('tblLegacy', rangesRo, response as never); + const events = collectSseEvents(response); + + expect(selectionService.getIdsFromRanges).toHaveBeenCalledWith('tblLegacy', { + ...rangesRo, + returnType: IdReturnType.RecordId, + }); + expect(recordOpenApiService.duplicateRecord).toHaveBeenNthCalledWith( + 1, + 'tblLegacy', + 'recSource1', + { + viewId: rangesRo.viewId, + anchorId: 'recSource2', + position: 'after', + }, + undefined + ); + expect(recordOpenApiService.duplicateRecord).toHaveBeenNthCalledWith( + 2, + 'tblLegacy', + 'recSource2', + { + viewId: rangesRo.viewId, + anchorId: 'recCopy1', + position: 'after', + }, + undefined + ); + expect(recordOpenApiV2Service.duplicateByRangeStream).not.toHaveBeenCalled(); + expect(events).toEqual([ + { + id: 'progress', + phase: 'preparing', + batchIndex: -1, + totalCount: 2, + duplicatedCount: 0, + batchDuplicatedCount: 0, + }, + { + id: 'progress', + phase: 'duplicating', + batchIndex: 0, + totalCount: 2, + duplicatedCount: 1, + batchDuplicatedCount: 1, + }, + { + id: 'progress', + phase: 'duplicating', + batchIndex: 1, + totalCount: 2, + duplicatedCount: 2, + batchDuplicatedCount: 1, + }, + { + id: 'done', + totalCount: 2, + duplicatedCount: 2, + data: { + duplicatedCount: 2, + duplicatedRecordIds: ['recCopy1', 'recCopy2'], + }, + }, + ]); + }); + + it('streams v2 duplicate events when useV2 is true', async () => { + cls.get.mockImplementation((key) => (key === 'useV2' ? true : undefined)); + + async function* createStream(): AsyncIterable { + yield { + id: 'progress', + phase: 'duplicating', + batchIndex: 0, + totalCount: 2, + duplicatedCount: 1, + batchDuplicatedCount: 1, + }; + yield { + id: 'done', + totalCount: 2, + duplicatedCount: 2, + data: { + duplicatedCount: 2, + duplicatedRecordIds: ['recCopy1', 'recCopy2'], + }, + }; + } + + recordOpenApiV2Service.duplicateByRangeStream.mockResolvedValue(createStream()); + const response = createMockSseResponse(); + + await controller.duplicateStream('tblV2', rangesRo, response as never); + const events = collectSseEvents(response); + + expect(recordOpenApiV2Service.duplicateByRangeStream).toHaveBeenCalledWith('tblV2', rangesRo); + expect(events).toEqual([ + { + id: 'progress', + phase: 'duplicating', + batchIndex: 0, + totalCount: 2, + duplicatedCount: 1, + batchDuplicatedCount: 1, + }, + { + id: 'done', + totalCount: 2, + duplicatedCount: 2, + data: { + duplicatedCount: 2, + duplicatedRecordIds: ['recCopy1', 'recCopy2'], + }, + }, + ]); + }); + + it('streams the legacy synchronous paste result when useV2 is false', async () => { + cls.get.mockImplementation((key) => (key === 'useV2' ? false : undefined)); + selectionService.paste.mockResolvedValue([ + [0, 0], + [0, 1], + ]); + const response = createMockSseResponse(); + + await controller.pasteStream('tblLegacy', pasteRo, 'window-2', response as never); + const events = collectSseEvents(response); + + expect(selectionService.paste).toHaveBeenCalledWith('tblLegacy', pasteRo, { + windowId: 'window-2', + }); + expect(recordOpenApiV2Service.pasteStream).not.toHaveBeenCalled(); + expect(events).toEqual([ + { + id: 'progress', + phase: 'preparing', + batchIndex: -1, + totalCount: 2, + processedCount: 0, + updatedCount: 0, + createdCount: 0, + batchProcessedCount: 0, + }, + { + id: 'done', + totalCount: 2, + processedCount: 2, + updatedCount: 0, + createdCount: 0, + data: { + updatedCount: 0, + createdCount: 0, + createdRecordIds: [], + ranges: [ + [0, 0], + [0, 1], + ], + }, + }, + ]); + }); + + it('streams v2 paste events when useV2 is true', async () => { + cls.get.mockImplementation((key) => { + const values: Record = { + useV2: true, + }; + return values[key]; + }); + + async function* createStream(): AsyncIterable { + yield { + id: 'progress', + phase: 'pasting', + batchIndex: 0, + totalCount: 2, + processedCount: 1, + updatedCount: 1, + createdCount: 0, + batchProcessedCount: 1, + }; + yield { + id: 'done', + totalCount: 2, + processedCount: 2, + updatedCount: 1, + createdCount: 1, + data: { + updatedCount: 1, + createdCount: 1, + createdRecordIds: ['recPaste1'], + }, + }; + } + + recordOpenApiV2Service.pasteStream.mockResolvedValue(createStream()); + const response = createMockSseResponse(); + + await controller.pasteStream('tblV2', pasteRo, undefined, response as never); + const events = collectSseEvents(response); + + expect(recordOpenApiV2Service.pasteStream).toHaveBeenCalledWith('tblV2', pasteRo, { + windowId: undefined, + }); + expect(events).toEqual([ + { + id: 'progress', + phase: 'pasting', + batchIndex: 0, + totalCount: 2, + processedCount: 1, + updatedCount: 1, + createdCount: 0, + batchProcessedCount: 1, + }, + { + id: 'done', + totalCount: 2, + processedCount: 2, + updatedCount: 1, + createdCount: 1, + data: { + updatedCount: 1, + createdCount: 1, + createdRecordIds: ['recPaste1'], + }, + }, + ]); + }); +}); diff --git a/apps/nestjs-backend/src/features/selection/selection.controller.ts b/apps/nestjs-backend/src/features/selection/selection.controller.ts index a925d12de7..ebc65554f6 100644 --- a/apps/nestjs-backend/src/features/selection/selection.controller.ts +++ b/apps/nestjs-backend/src/features/selection/selection.controller.ts @@ -4,15 +4,20 @@ import { Controller, Delete, Get, + Headers, Param, Patch, Query, - Headers, + Res, UseGuards, UseInterceptors, } from '@nestjs/common'; import type { + IClearSelectionStreamEvent, ICopyVo, + IDeleteSelectionStreamEvent, + IDuplicateSelectionStreamEvent, + IPasteSelectionStreamEvent, IRangesToIdVo, IPasteVo, IDeleteVo, @@ -28,15 +33,27 @@ import { IRangesRo, temporaryPasteRoSchema, ITemporaryPasteRo, + IdReturnType, } from '@teable/openapi'; +import { Response } from 'express'; import { ClsService } from 'nestjs-cls'; +import { + applyTraceResponseHeaders, + setResponseHeaderIfPossible, +} from '../../tracing/trace-response-headers'; import type { IClsStore } from '../../types/cls'; import { ZodValidationPipe } from '../../zod.validation.pipe'; 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 { + V2IndicatorInterceptor, + X_TEABLE_V2_FEATURE_HEADER, + X_TEABLE_V2_HEADER, + X_TEABLE_V2_REASON_HEADER, +} from '../canary/interceptors/v2-indicator.interceptor'; import { RecordOpenApiV2Service } from '../record/open-api/record-open-api-v2.service'; +import { RecordOpenApiService } from '../record/open-api/record-open-api.service'; import { TqlPipe } from '../record/open-api/tql.pipe'; import { SelectionService } from './selection.service'; @@ -46,10 +63,94 @@ import { SelectionService } from './selection.service'; export class SelectionController { constructor( private selectionService: SelectionService, + private readonly recordOpenApiService: RecordOpenApiService, private readonly recordOpenApiV2Service: RecordOpenApiV2Service, private readonly cls: ClsService ) {} + protected applySelectionStreamResponseHeaders(response?: Response) { + if (!response) { + return; + } + + const useV2 = this.cls.get('useV2'); + const v2Reason = this.cls.get('v2Reason'); + const v2Feature = this.cls.get('v2Feature'); + + setResponseHeaderIfPossible(response, X_TEABLE_V2_HEADER, useV2 ? 'true' : 'false'); + if (v2Reason) { + setResponseHeaderIfPossible(response, X_TEABLE_V2_REASON_HEADER, v2Reason); + } + if (v2Feature) { + setResponseHeaderIfPossible(response, X_TEABLE_V2_FEATURE_HEADER, v2Feature); + } + + applyTraceResponseHeaders(response); + } + + protected prepareSelectionStreamResponse(response: Response) { + response.setHeader('Content-Type', 'text/event-stream'); + response.setHeader('Cache-Control', 'no-cache, no-transform'); + response.setHeader('Connection', 'keep-alive'); + response.setHeader('X-Accel-Buffering', 'no'); + this.applySelectionStreamResponseHeaders(response); + response.flushHeaders(); + } + + protected isSelectionStreamClosed(response: Response) { + return response.writableEnded || response.destroyed; + } + + protected sendSelectionSseEvent(response: Response, data: T) { + if (this.isSelectionStreamClosed(response)) { + return; + } + + response.write(`data: ${JSON.stringify(data)}\n\n`); + (response as Response & { flush?: () => void }).flush?.(); + } + + protected startSelectionHeartbeat(response: Response) { + const heartbeat = setInterval(() => { + if (this.isSelectionStreamClosed(response)) { + return; + } + + response.write(': ping\n\n'); + (response as Response & { flush?: () => void }).flush?.(); + }, 15_000); + + response.on('close', () => clearInterval(heartbeat)); + return heartbeat; + } + + protected async streamSelectionResponse( + response: Response, + stream: AsyncIterable, + createErrorEvent: (message: string) => T + ) { + this.prepareSelectionStreamResponse(response); + const heartbeat = this.startSelectionHeartbeat(response); + + try { + for await (const event of stream) { + if (this.isSelectionStreamClosed(response)) { + break; + } + + this.sendSelectionSseEvent(response, event); + } + } catch (error) { + this.sendSelectionSseEvent( + response, + createErrorEvent(error instanceof Error ? error.message : 'Selection stream failed') + ); + } finally { + clearInterval(heartbeat); + response.end(); + } + } + @Permissions('record|read') @Get('/range-to-id') async getIdsFromRanges( @@ -116,6 +217,31 @@ export class SelectionController { return null; } + @UseV2Feature('clear') + @Permissions('record|update') + @Patch('/clear-stream') + async clearStream( + @Param('tableId') tableId: string, + @Body(new ZodValidationPipe(rangesRoSchema), TqlPipe) rangesRo: IRangesRo, + @Headers('x-window-id') windowId: string | undefined, + @Res() response: Response + ): Promise { + const stream = this.cls.get('useV2') + ? await this.recordOpenApiV2Service.clearStream(tableId, rangesRo) + : this.createLegacyClearSelectionStream(tableId, rangesRo, windowId); + + await this.streamSelectionResponse(response, stream, (message) => ({ + id: 'error', + phase: 'clearing', + batchIndex: -1, + totalCount: 0, + processedCount: 0, + clearedCount: 0, + recordIds: [], + message, + })); + } + @UseV2Feature('deleteRecord') @Permissions('record|delete') @Delete('/delete') @@ -133,4 +259,273 @@ export class SelectionController { windowId, }); } + + protected async *createLegacyDeleteSelectionStream( + tableId: string, + rangesRo: IRangesRo, + windowId?: string + ): AsyncIterable { + const result = await this.selectionService.delete(tableId, rangesRo, { + windowId, + }); + yield { + id: 'progress', + phase: 'preparing', + batchIndex: -1, + totalCount: result.ids.length, + deletedCount: 0, + batchDeletedCount: 0, + }; + yield { + id: 'done', + totalCount: result.ids.length, + deletedCount: result.ids.length, + data: { + deletedCount: result.ids.length, + deletedRecordIds: result.ids, + }, + }; + } + + protected async *createLegacyClearSelectionStream( + tableId: string, + rangesRo: IRangesRo, + windowId?: string + ): AsyncIterable { + const idsResult = await this.selectionService.getIdsFromRanges(tableId, { + ...rangesRo, + returnType: IdReturnType.RecordId, + }); + const totalCount = idsResult.recordIds?.length ?? 0; + + yield { + id: 'progress', + phase: 'preparing', + batchIndex: -1, + totalCount, + processedCount: 0, + clearedCount: 0, + batchProcessedCount: 0, + batchClearedCount: 0, + }; + + await this.selectionService.clear(tableId, rangesRo, { + windowId, + }); + + yield { + id: 'done', + totalCount, + processedCount: totalCount, + clearedCount: totalCount, + data: { + clearedCount: totalCount, + clearedRecordIds: [], + }, + }; + } + + protected async *createLegacyDuplicateSelectionStream( + tableId: string, + rangesRo: IRangesRo, + projection?: string[] + ): AsyncIterable { + const selectionResult = await this.selectionService.getIdsFromRanges(tableId, { + ...rangesRo, + returnType: IdReturnType.RecordId, + ...(projection ? { projection } : {}), + }); + const sourceRecordIds = selectionResult.recordIds ?? []; + + yield { + id: 'progress', + phase: 'preparing', + batchIndex: -1, + totalCount: sourceRecordIds.length, + duplicatedCount: 0, + batchDuplicatedCount: 0, + }; + + if (!sourceRecordIds.length) { + yield { + id: 'done', + totalCount: 0, + duplicatedCount: 0, + data: { + duplicatedCount: 0, + duplicatedRecordIds: [], + }, + }; + return; + } + + const duplicatedRecordIds: string[] = []; + let anchorId = sourceRecordIds.at(-1); + + for (const [index, recordId] of sourceRecordIds.entries()) { + const duplicatedRecord = await this.recordOpenApiService.duplicateRecord( + tableId, + recordId, + anchorId && rangesRo.viewId + ? { + viewId: rangesRo.viewId, + anchorId, + position: 'after', + } + : undefined, + projection + ); + + duplicatedRecordIds.push(duplicatedRecord.id); + anchorId = duplicatedRecord.id; + + yield { + id: 'progress', + phase: 'duplicating', + batchIndex: index, + totalCount: sourceRecordIds.length, + duplicatedCount: duplicatedRecordIds.length, + batchDuplicatedCount: 1, + }; + } + + yield { + id: 'done', + totalCount: sourceRecordIds.length, + duplicatedCount: duplicatedRecordIds.length, + data: { + duplicatedCount: duplicatedRecordIds.length, + duplicatedRecordIds, + }, + }; + } + + protected getLegacyPasteStreamTotalCount(pasteRo: IPasteRo) { + if (Array.isArray(pasteRo.content)) { + return pasteRo.content.length; + } + + const content = pasteRo.content.trim(); + if (!content) { + return 0; + } + + return content.split(/\r?\n/).length; + } + + protected async *createLegacyPasteSelectionStream( + tableId: string, + pasteRo: IPasteRo, + windowId?: string + ): AsyncIterable { + const totalCount = this.getLegacyPasteStreamTotalCount(pasteRo); + + yield { + id: 'progress', + phase: 'preparing', + batchIndex: -1, + totalCount, + processedCount: 0, + updatedCount: 0, + createdCount: 0, + batchProcessedCount: 0, + }; + + const ranges = await this.selectionService.paste(tableId, pasteRo, { windowId }); + + yield { + id: 'done', + totalCount, + processedCount: totalCount, + updatedCount: 0, + createdCount: 0, + data: { + updatedCount: 0, + createdCount: 0, + createdRecordIds: [], + ranges, + }, + }; + } + + @UseV2Feature('deleteRecord') + @Permissions('record|delete') + @Get('/delete-stream') + async deleteStream( + @Param('tableId') tableId: string, + @Query(new ZodValidationPipe(rangesQuerySchema), TqlPipe) rangesRo: IRangesRo, + @Headers('x-window-id') windowId: string | undefined, + @Res() response: Response + ): Promise { + const stream = this.cls.get('useV2') + ? await this.recordOpenApiV2Service.deleteByRangeStream(tableId, rangesRo) + : this.createLegacyDeleteSelectionStream(tableId, rangesRo, windowId); + + await this.streamSelectionResponse( + response, + stream, + (message) => ({ + id: 'error', + phase: 'deleting', + batchIndex: -1, + totalCount: 0, + deletedCount: 0, + recordIds: [], + message, + }) + ); + } + + @UseV2Feature('paste') + @Permissions('record|update') + @Patch('/paste-stream') + async pasteStream( + @Param('tableId') tableId: string, + @Body(new ZodValidationPipe(pasteRoSchema), TqlPipe) pasteRo: IPasteRo, + @Headers('x-window-id') windowId: string | undefined, + @Res() response: Response + ): Promise { + const stream = this.cls.get('useV2') + ? await this.recordOpenApiV2Service.pasteStream(tableId, pasteRo, { windowId }) + : this.createLegacyPasteSelectionStream(tableId, pasteRo, windowId); + + await this.streamSelectionResponse(response, stream, (message) => ({ + id: 'error', + phase: 'pasting', + batchIndex: -1, + totalCount: 0, + processedCount: 0, + updatedCount: 0, + createdCount: 0, + recordIds: [], + message, + })); + } + + @UseV2Feature('duplicateRecord') + @Permissions('record|read', 'record|create') + @Get('/duplicate-stream') + async duplicateStream( + @Param('tableId') tableId: string, + @Query(new ZodValidationPipe(rangesQuerySchema), TqlPipe) rangesRo: IRangesRo, + @Res() response: Response + ): Promise { + const stream = this.cls.get('useV2') + ? await this.recordOpenApiV2Service.duplicateByRangeStream(tableId, rangesRo) + : this.createLegacyDuplicateSelectionStream(tableId, rangesRo); + + await this.streamSelectionResponse( + response, + stream, + (message) => ({ + id: 'error', + phase: 'duplicating', + batchIndex: -1, + totalCount: 0, + duplicatedCount: 0, + recordIds: [], + message, + }) + ); + } } diff --git a/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.ts b/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.ts index 27ee429bcd..c3db2f9589 100644 --- a/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.ts +++ b/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.ts @@ -137,7 +137,7 @@ export class SettingOpenApiService { capabilities: aiConfig?.capabilities, gatewayModels: aiConfig?.gatewayModels, }, - appGenerationEnabled: Boolean(appConfig?.apiKey), + appGenerationEnabled: Boolean(appConfig?.vercelToken), availableIntegrationProviders, }; } @@ -850,7 +850,7 @@ export class SettingOpenApiService { } /** - * Test API key validity for AI Gateway or v0 + * Test API key validity for AI Gateway * Optionally also tests attachment transfer modes (URL and Base64) * When testAttachment is true, results are automatically saved to appConfig */ @@ -877,8 +877,6 @@ export class SettingOpenApiService { ...keyResult, attachmentTest: attachmentResult, }; - } else if (type === 'v0') { - return this.testV0Key(apiKey, baseUrl); } else if (type === 'vercel') { return this.testVercelToken(apiKey, baseUrl); } @@ -1199,37 +1197,6 @@ export class SettingOpenApiService { return 'unknown'; } - private async testV0Key(apiKey: string, baseUrl?: string): Promise { - const url = `${baseUrl || 'https://api.v0.dev/v1'}/projects`; - - try { - await axios.get(url, { - headers: { Authorization: `Bearer ${apiKey}` }, - }); - return { success: true }; - } catch (error) { - if (!axios.isAxiosError(error)) { - return this.parseApiKeyError(error, 'v0'); - } - - const status = error.response?.status; - const data = error.response?.data as { - error?: { type?: string; code?: string; message?: string }; - }; - const detailedMessage = data?.error?.message || error.message; - - this.logger.error('v0 key test failed: status=%s, message=%s', status, detailedMessage); - - // No response = network error - if (!error.response) { - return { success: false, error: { code: 'network_error', message: detailedMessage } }; - } - - const code = this.getV0ErrorCode(status, data, detailedMessage); - return { success: false, error: { code, message: detailedMessage } }; - } - } - private async testVercelToken(token: string, baseUrl?: string): Promise { const apiBase = baseUrl || 'https://api.vercel.com'; @@ -1260,30 +1227,6 @@ export class SettingOpenApiService { } } - private getV0ErrorCode( - status: number | undefined, - data: { error?: { type?: string; code?: string; message?: string } } | undefined, - message: string - ): 'unauthorized' | 'forbidden' | 'insufficient_quota' | 'unknown' { - if (status === 401) return 'unauthorized'; - if (status === 403) return 'forbidden'; - - const errorType = data?.error?.type?.toLowerCase() || ''; - const errorCode = data?.error?.code?.toLowerCase() || ''; - const errorMsg = message.toLowerCase(); - - if ( - errorType.includes('insufficient') || - errorCode.includes('insufficient') || - errorMsg.includes('insufficient') || - errorMsg.includes('quota') - ) { - return 'insufficient_quota'; - } - - return 'unknown'; - } - /** * Get available models from AI Gateway * Returns empty array if gateway is not configured diff --git a/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts b/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts index 0de407f41a..cb26e83b34 100644 --- a/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts +++ b/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts @@ -763,15 +763,22 @@ export class TableOpenApiService { async getPermission(baseId: string, tableId: string): Promise { const baseShare = this.cls.get('baseShare'); - if ( - this.cls.get('template') || - this.cls.get('template.baseId') === baseId || - baseShare?.baseId === baseId - ) { + if (this.cls.get('template') || this.cls.get('template.baseId') === baseId) { return this.getPermissionByPermissionMap( TemplateRolePermission as Record ); } + if (baseShare?.baseId === baseId) { + const clsPermissions = new Set(this.cls.get('permissions')); + // Build permission map from CLS permissions (already curated by permission service) + const permissionMap = { ...TemplateRolePermission } as Record; + for (const perm of Object.keys(permissionMap) as BasePermission[]) { + if (clsPermissions.has(perm)) { + permissionMap[perm as BasePermission] = true; + } + } + return this.getPermissionByPermissionMap(permissionMap); + } let role: IRole | null = await this.permissionService.getRoleByBaseId(baseId); if (!role) { const { spaceId } = await this.permissionService.getUpperIdByBaseId(baseId); diff --git a/apps/nestjs-backend/src/features/trash/trash.module.ts b/apps/nestjs-backend/src/features/trash/trash.module.ts index 1aa8870115..322a2d9222 100644 --- a/apps/nestjs-backend/src/features/trash/trash.module.ts +++ b/apps/nestjs-backend/src/features/trash/trash.module.ts @@ -13,6 +13,7 @@ import { ViewModule } from '../view/view.module'; import { TableTrashListener } from './listener/table-trash.listener'; import { TrashController } from './trash.controller'; import { TrashService } from './trash.service'; +import { V2RecordTrashService } from './v2-record-trash.service'; import { V2TableTrashService } from './v2-table-trash.service'; @Module({ @@ -30,7 +31,7 @@ import { V2TableTrashService } from './v2-table-trash.service'; ViewModule, ], controllers: [TrashController], - providers: [TrashService, TableTrashListener, V2TableTrashService], + providers: [TrashService, TableTrashListener, V2RecordTrashService, V2TableTrashService], exports: [TrashService], }) export class TrashModule {} diff --git a/apps/nestjs-backend/src/features/trash/trash.service.ts b/apps/nestjs-backend/src/features/trash/trash.service.ts index 18daad8451..323bbc4613 100644 --- a/apps/nestjs-backend/src/features/trash/trash.service.ts +++ b/apps/nestjs-backend/src/features/trash/trash.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import type { FieldType, IFieldVo } from '@teable/core'; import { FieldKeyType, HttpErrorCode, IdPrefix, Role } from '@teable/core'; -import { PrismaService } from '@teable/db-main-prisma'; +import { PrismaService, type Prisma } from '@teable/db-main-prisma'; import type { IResetTrashItemsRo, IResourceMapVo, @@ -751,6 +751,7 @@ export class TrashService { tableId, resourceType, snapshot: originSnapshot, + createdTime, } = await this.prismaService.tableTrash .findUniqueOrThrow({ where: { id: trashId }, @@ -758,6 +759,7 @@ export class TrashService { tableId: true, resourceType: true, snapshot: true, + createdTime: true, }, }) .catch(() => { @@ -808,11 +810,43 @@ export class TrashService { break; } case TableTrashType.Record: { - const originRecords = await prisma.recordTrash.findMany({ - where: { tableId, recordId: { in: snapshot } }, - select: { snapshot: true }, + const recordIds = snapshot as string[]; + type IRecordTrashSnapshotRow = Prisma.RecordTrashGetPayload<{ + select: { + id: true; + recordId: true; + snapshot: true; + createdTime: true; + }; + }>; + const recordTrashRows = await prisma.recordTrash.findMany({ + where: { tableId, recordId: { in: recordIds } }, + select: { + id: true, + recordId: true, + snapshot: true, + createdTime: true, + }, + orderBy: [{ recordId: 'asc' }, { createdTime: 'desc' }, { id: 'desc' }], }); - const records = originRecords.map(({ snapshot }) => JSON.parse(snapshot)); + + // A record can be deleted, restored through undo, then deleted again with the same id. + // Restore should use the snapshot that belongs to this trash item, not every historical + // record_trash row for the same record id. + const latestSnapshotsByRecordId = recordTrashRows.reduce< + Map + >((acc, row) => { + if (row.createdTime <= createdTime && !acc.has(row.recordId)) { + acc.set(row.recordId, row); + } + return acc; + }, new Map()); + + const matchedRecordTrashRows = recordIds + .map((recordId) => latestSnapshotsByRecordId.get(recordId)) + .filter((row): row is IRecordTrashSnapshotRow => row != null); + const records = matchedRecordTrashRows.map(({ snapshot }) => JSON.parse(snapshot)); + await this.recordOpenApiService.multipleCreateRecords( tableId, { @@ -823,7 +857,7 @@ export class TrashService { true ); await prisma.recordTrash.deleteMany({ - where: { tableId, recordId: { in: snapshot } }, + where: { id: { in: matchedRecordTrashRows.map(({ id }) => id) } }, }); break; } diff --git a/apps/nestjs-backend/src/features/trash/v2-record-trash.service.ts b/apps/nestjs-backend/src/features/trash/v2-record-trash.service.ts new file mode 100644 index 0000000000..f4f7bf0faf --- /dev/null +++ b/apps/nestjs-backend/src/features/trash/v2-record-trash.service.ts @@ -0,0 +1,129 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { Injectable } from '@nestjs/common'; +import { generateRecordTrashId } from '@teable/core'; +import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import type { IExecutionContext } from '@teable/v2-core'; +import type { IDeleteRecordsPayload } from '../undo-redo/operations/delete-records.operation'; +import { V2ContainerService } from '../v2/v2-container.service'; + +interface ITableTrashInsert { + id: string; + table_id: string; + resource_type: string; + snapshot: string; + created_by: string; +} + +interface IRecordTrashInsert { + id: string; + table_id: string; + record_id: string; + snapshot: string; + created_by: string; +} + +type TrashDbTransaction = { + insertInto(table: 'table_trash'): { + values(value: ITableTrashInsert): { + executeTakeFirst(): Promise; + }; + }; + insertInto(table: 'record_trash'): { + values(values: IRecordTrashInsert[]): { + execute(): Promise; + }; + }; +}; + +type TrashDbClient = { + transaction(): { + execute(callback: (trx: TrashDbTransaction) => Promise): Promise; + }; +}; + +const RECORD_TRASH_BATCH_SIZE = 5000; +const RECORD_TRASH_RESOURCE_TYPE = 'record'; + +@Injectable() +export class V2RecordTrashService { + constructor(private readonly v2ContainerService: V2ContainerService) {} + + async persistDeletedRecords( + payload: IDeleteRecordsPayload, + context?: Pick + ): Promise { + const { operationId, tableId, userId, records } = payload; + if (records.length === 0) { + return; + } + + const container = await this.v2ContainerService.getContainer(); + const db = container.resolve(v2PostgresDbTokens.db) as TrashDbClient; + const recordIds = records.map((record) => record.id); + + await this.runInSpan( + context, + 'teable.V2RecordTrashService.persistDeletedRecords', + { + 'teable.table_id': tableId, + 'teable.record_count': records.length, + }, + async () => + db.transaction().execute(async (trx) => { + await trx + .insertInto('table_trash') + .values({ + id: operationId, + table_id: tableId, + resource_type: RECORD_TRASH_RESOURCE_TYPE, + snapshot: JSON.stringify(recordIds), + created_by: userId, + }) + .executeTakeFirst(); + + for (let i = 0; i < records.length; i += RECORD_TRASH_BATCH_SIZE) { + const batch = records.slice(i, i + RECORD_TRASH_BATCH_SIZE); + await trx + .insertInto('record_trash') + .values( + batch.map((record) => ({ + id: generateRecordTrashId(), + table_id: tableId, + record_id: record.id, + snapshot: JSON.stringify(record), + created_by: userId, + })) + ) + .execute(); + } + }) + ); + } + + private async runInSpan( + context: Pick | undefined, + name: `teable.${string}`, + attributes: Record, + callback: () => Promise + ): Promise { + const tracer = context?.tracer; + const span = tracer?.startSpan(name, { + 'teable.version': 'v2', + 'teable.component': 'service', + 'teable.operation': name.replace(/^teable\./, ''), + ...attributes, + }); + + if (!tracer || !span) { + return callback(); + } + + return tracer.withSpan(span, async () => { + try { + return await callback(); + } finally { + span.end(); + } + }); + } +} diff --git a/apps/nestjs-backend/src/features/trash/v2-table-trash.service.spec.ts b/apps/nestjs-backend/src/features/trash/v2-table-trash.service.spec.ts index 27ce6ab696..65ff9b18dd 100644 --- a/apps/nestjs-backend/src/features/trash/v2-table-trash.service.spec.ts +++ b/apps/nestjs-backend/src/features/trash/v2-table-trash.service.spec.ts @@ -1,5 +1,16 @@ import { ResourceType } from '@teable/openapi'; -import { ActorId, BaseId, TableId, TableName, TableRestored, TableTrashed } from '@teable/v2-core'; +import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import { + ActorId, + BaseId, + type IExecutionContext, + RecordId, + RecordsDeleted, + TableId, + TableName, + TableRestored, + TableTrashed, +} from '@teable/v2-core'; import { describe, expect, it, vi } from 'vitest'; vi.mock('@teable/db-main-prisma', () => ({ @@ -7,25 +18,98 @@ vi.mock('@teable/db-main-prisma', () => ({ PrismaService: class PrismaService {}, })); -import { V2TableRestoredProjection, V2TableTrashedProjection } from './v2-table-trash.service'; +import type { IDeleteRecordsPayload } from '../undo-redo/operations/delete-records.operation'; +import { V2RecordTrashService } from './v2-record-trash.service'; +import { + V2RecordsDeletedAttachmentProjection, + V2RecordsDeletedTableTrashProjection, + V2TableRestoredProjection, + V2TableTrashedProjection, +} from './v2-table-trash.service'; + +class FakeSpan { + end = () => undefined; + recordError = (_message: string) => undefined; + setAttribute = (_key: string, _value: string | number | boolean) => undefined; + setAttributes = (_attributes: Record) => undefined; +} + +class FakeTracer { + readonly spans: Array<{ name: string; attributes?: Record }> = + []; + + startSpan(name: string, attributes?: Record) { + this.spans.push({ name, attributes }); + return new FakeSpan(); + } + + async withSpan(_span: FakeSpan, callback: () => Promise): Promise { + return callback(); + } + + getActiveSpan() { + return undefined; + } +} + +interface IRecordTrashInsertRow { + /* eslint-disable @typescript-eslint/naming-convention */ + record_id: string; +} + +const createV2ContainerService = () => { + const deleteQuery = { + where: vi.fn().mockReturnThis(), + execute: vi.fn().mockResolvedValue(undefined), + }; + const insertQuery = { + values: vi.fn().mockReturnThis(), + execute: vi.fn().mockResolvedValue(undefined), + }; + const selectQuery = { + where: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + executeTakeFirst: vi.fn().mockResolvedValue({ + base_id: 'bseaaaaaaaaaaaaaaaa', + deleted_time: new Date('2026-03-12T00:00:00.000Z'), + }), + }; + const db = { + deleteFrom: vi.fn().mockReturnValue(deleteQuery), + insertInto: vi.fn().mockReturnValue(insertQuery), + selectFrom: vi.fn().mockReturnValue(selectQuery), + }; + const container = { + resolve: vi.fn((token: symbol) => { + if (token !== v2PostgresDbTokens.db) { + throw new Error(`Unexpected token ${String(token)}`); + } + return db; + }), + }; + + return { + db, + deleteQuery, + insertQuery, + selectQuery, + service: { + getContainer: vi.fn().mockResolvedValue(container), + }, + }; +}; 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 { + db, + deleteQuery, + insertQuery, + selectQuery, + service: v2ContainerService, + } = createV2ContainerService(); + const projection = new V2TableTrashedProjection(v2ContainerService as never); const context = { actorId: ActorId.create('usrTestUserId')._unsafeUnwrap(), }; @@ -40,37 +124,28 @@ describe('V2TableTrashedProjection', () => { 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', - }, + expect(db.selectFrom).toHaveBeenCalledWith('table_meta'); + expect(selectQuery.where).toHaveBeenCalledWith('id', '=', 'tblaaaaaaaaaaaaaaaa'); + expect(selectQuery.select).toHaveBeenCalledWith(['base_id', 'deleted_time']); + expect(db.deleteFrom).toHaveBeenCalledWith('trash'); + expect(deleteQuery.where).toHaveBeenNthCalledWith(1, 'resource_id', '=', 'tblaaaaaaaaaaaaaaaa'); + expect(deleteQuery.where).toHaveBeenNthCalledWith(2, 'resource_type', '=', ResourceType.Table); + expect(db.insertInto).toHaveBeenCalledWith('trash'); + expect(insertQuery.values).toHaveBeenCalledWith({ + id: expect.any(String), + resource_id: 'tblaaaaaaaaaaaaaaaa', + resource_type: ResourceType.Table, + parent_id: 'bseaaaaaaaaaaaaaaaa', + deleted_time: deletedTime, + deleted_by: 'usrTestUserId', }); }); }); describe('V2TableRestoredProjection', () => { it('removes a table trash entry after restore', async () => { - const prisma = { - trash: { - deleteMany: vi.fn().mockResolvedValue({ count: 1 }), - }, - }; - - const projection = new V2TableRestoredProjection(prisma as never); + const { db, deleteQuery, service: v2ContainerService } = createV2ContainerService(); + const projection = new V2TableRestoredProjection(v2ContainerService as never); const context = { actorId: ActorId.create('usrTestUserId')._unsafeUnwrap(), }; @@ -85,11 +160,174 @@ describe('V2TableRestoredProjection', () => { const result = await projection.handle(context, event); expect(result._unsafeUnwrap()).toBeUndefined(); - expect(prisma.trash.deleteMany).toHaveBeenCalledWith({ - where: { - resourceId: 'tblaaaaaaaaaaaaaaaa', - resourceType: ResourceType.Table, + expect(db.deleteFrom).toHaveBeenCalledWith('trash'); + expect(deleteQuery.where).toHaveBeenNthCalledWith(1, 'resource_id', '=', 'tblaaaaaaaaaaaaaaaa'); + expect(deleteQuery.where).toHaveBeenNthCalledWith(2, 'resource_type', '=', ResourceType.Table); + }); +}); + +describe('V2RecordTrashService', () => { + it('persists deleted records through the v2 Kysely db transaction', async () => { + const operations: Array<{ table: string; values: unknown }> = []; + const trx = { + insertInto: vi.fn((table: string) => ({ + values: (values: unknown) => ({ + execute: vi.fn(async () => { + operations.push({ table, values }); + }), + executeTakeFirst: vi.fn(async () => { + operations.push({ table, values }); + return undefined; + }), + }), + })), + }; + const db = { + transaction: vi.fn(() => ({ + execute: async (callback: (trx: typeof trx) => Promise) => callback(trx), + })), + }; + const container = { + resolve: vi.fn().mockReturnValue(db), + }; + const v2ContainerService = { + getContainer: vi.fn().mockResolvedValue(container), + }; + const service = new V2RecordTrashService(v2ContainerService as never); + const tracer = new FakeTracer(); + const payload: IDeleteRecordsPayload = { + operationId: 'oprTestTrashPersist', + tableId: 'tblaaaaaaaaaaaaaaaa', + userId: 'usrTestUserId', + records: [ + { + id: 'recFirstRecordId01', + fields: { fldText: 'A' }, + }, + { + id: 'recSecondRecordId2', + fields: { fldText: 'B' }, + }, + ], + }; + + await service.persistDeletedRecords(payload, { tracer } as Pick); + + expect(v2ContainerService.getContainer).toHaveBeenCalled(); + expect(db.transaction).toHaveBeenCalled(); + expect(operations).toHaveLength(2); + expect(operations[0]).toEqual({ + table: 'table_trash', + values: { + id: 'oprTestTrashPersist', + table_id: 'tblaaaaaaaaaaaaaaaa', + resource_type: 'record', + snapshot: JSON.stringify(['recFirstRecordId01', 'recSecondRecordId2']), + created_by: 'usrTestUserId', + }, + }); + expect(operations[1].table).toBe('record_trash'); + expect(Array.isArray(operations[1].values)).toBe(true); + expect((operations[1].values as IRecordTrashInsertRow[]).map((row) => row.record_id)).toEqual([ + 'recFirstRecordId01', + 'recSecondRecordId2', + ]); + expect(tracer.spans.map((span) => span.name)).toContain( + 'teable.V2RecordTrashService.persistDeletedRecords' + ); + }); +}); + +describe('V2RecordsDeletedTableTrashProjection', () => { + it('uses display names carried by delete events without loading table metadata', async () => { + const v2RecordTrashService = { + persistDeletedRecords: vi.fn().mockResolvedValue(undefined), + }; + const projection = new V2RecordsDeletedTableTrashProjection(v2RecordTrashService as never); + const tracer = new FakeTracer(); + const context = { + actorId: ActorId.create('usrTestUserId')._unsafeUnwrap(), + windowId: 'winTestWindowId', + tracer, + }; + const event = RecordsDeleted.create({ + tableId: TableId.create('tblaaaaaaaaaaaaaaaa')._unsafeUnwrap(), + baseId: BaseId.create('bseaaaaaaaaaaaaaaaa')._unsafeUnwrap(), + recordIds: [RecordId.create(`rec${'a'.repeat(16)}`)._unsafeUnwrap()], + recordSnapshots: [ + { + id: 'recFirstRecordId01', + fields: { fldText: 'A' }, + displayName: 'Record A', + }, + ], + orchestration: { + operationId: 'reqDeleteOperation01', + totalRecordCount: 1, + totalChunkCount: 1, + chunkIndex: 0, + scope: 'operation', + }, + }); + + const result = await projection.handle(context, event); + + expect(result._unsafeUnwrap()).toBeUndefined(); + expect(v2RecordTrashService.persistDeletedRecords).toHaveBeenCalledWith( + { + operationId: expect.any(String), + windowId: 'winTestWindowId', + tableId: 'tblaaaaaaaaaaaaaaaa', + userId: 'usrTestUserId', + records: [ + { + id: 'recFirstRecordId01', + fields: { fldText: 'A' }, + name: 'Record A', + }, + ], + }, + context + ); + expect(tracer.spans.map((span) => span.name)).toEqual( + expect.arrayContaining([ + 'teable.V2RecordsDeletedTableTrashProjection.buildTrashPayload', + 'teable.V2RecordsDeletedTableTrashProjection.persistDeletedRecords', + ]) + ); + }); +}); + +describe('V2RecordsDeletedAttachmentProjection', () => { + it('deletes attachment rows for deleted records through the v2 db container', async () => { + const { db, deleteQuery, service: v2ContainerService } = createV2ContainerService(); + const projection = new V2RecordsDeletedAttachmentProjection(v2ContainerService as never); + const event = RecordsDeleted.create({ + tableId: TableId.create('tblaaaaaaaaaaaaaaaa')._unsafeUnwrap(), + baseId: BaseId.create('bseaaaaaaaaaaaaaaaa')._unsafeUnwrap(), + recordIds: [ + RecordId.create(`rec${'a'.repeat(16)}`)._unsafeUnwrap(), + RecordId.create(`rec${'b'.repeat(16)}`)._unsafeUnwrap(), + ], + recordSnapshots: [], + orchestration: { + operationId: 'reqDeleteOperation02', + totalRecordCount: 2, + totalChunkCount: 1, + chunkIndex: 0, + scope: 'operation', }, }); + + const result = await projection.handle({} as never, event); + + expect(result._unsafeUnwrap()).toBeUndefined(); + expect(db.deleteFrom).toHaveBeenCalledWith('attachments_table'); + expect(deleteQuery.where).toHaveBeenNthCalledWith(1, 'table_id', '=', 'tblaaaaaaaaaaaaaaaa'); + expect(deleteQuery.where).toHaveBeenNthCalledWith(2, 'record_id', 'in', [ + `rec${'a'.repeat(16)}`, + `rec${'b'.repeat(16)}`, + ]); + expect(deleteQuery.execute).toHaveBeenCalled(); }); }); diff --git a/apps/nestjs-backend/src/features/trash/v2-table-trash.service.ts b/apps/nestjs-backend/src/features/trash/v2-table-trash.service.ts index 0089e45dc6..e6ece7e8ef 100644 --- a/apps/nestjs-backend/src/features/trash/v2-table-trash.service.ts +++ b/apps/nestjs-backend/src/features/trash/v2-table-trash.service.ts @@ -1,34 +1,53 @@ 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 { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg'; import { ProjectionHandler, RecordsDeleted, TableRestored, TableTrashed, - TableQueryService, ok, - v2CoreTokens, type DomainError, type IEventHandler, type IExecutionContext, type Result, } from '@teable/v2-core'; import type { DependencyContainer } from '@teable/v2-di'; -import { AttachmentsTableService } from '../attachments/attachments-table.service'; +import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; +import type { Kysely } from 'kysely'; +import { nanoid } from 'nanoid'; import type { IDeleteRecordsPayload } from '../undo-redo/operations/delete-records.operation'; +import { V2ContainerService } from '../v2/v2-container.service'; import { V2ProjectionRegistrar, type IV2ProjectionRegistrar } from '../v2/v2-projection-registrar'; -import { TableTrashListener } from './listener/table-trash.listener'; -import { resolveV2TrashRecordDisplayName } from './v2-trash-record-name'; +import { V2RecordTrashService } from './v2-record-trash.service'; + +/* eslint-disable @typescript-eslint/naming-convention */ +type IAttachmentsTableDb = V1TeableDatabase & { + attachments_table: { + table_id: string; + record_id: string; + }; + table_meta: { + id: string; + base_id: string; + deleted_time: Date | null; + }; + trash: { + id: string; + resource_id: string; + resource_type: string; + parent_id: string | null; + deleted_time: Date; + deleted_by: string; + }; +}; +/* eslint-enable @typescript-eslint/naming-convention */ @ProjectionHandler(RecordsDeleted) export class V2RecordsDeletedTableTrashProjection implements IEventHandler { - constructor( - private readonly tableTrashListener: TableTrashListener, - private readonly tableQueryService: TableQueryService - ) {} + constructor(private readonly v2RecordTrashService: V2RecordTrashService) {} async handle( context: IExecutionContext, @@ -38,49 +57,93 @@ export class V2RecordsDeletedTableTrashProjection implements IEventHandler { - const record: IDeleteRecordsPayload['records'][number] = { - id: snapshot.id, - fields: snapshot.fields as IRecord['fields'], - autoNumber: snapshot.autoNumber, - createdTime: snapshot.createdTime, - createdBy: snapshot.createdBy, - lastModifiedTime: snapshot.lastModifiedTime, - lastModifiedBy: snapshot.lastModifiedBy, - order: snapshot.orders, - }; - - if (table) { - const nameResult = resolveV2TrashRecordDisplayName(table, { - id: snapshot.id, - fields: snapshot.fields, - }); - if (nameResult.isOk() && nameResult.value) { - record.name = nameResult.value; - } - } + const buildPayloadAttributes = { + teableTableId: event.tableId.toString(), + teableRecordCount: event.recordSnapshots.length, + } satisfies Record; - return record; - }); + const records = await this.runInSpan( + context, + 'teable.V2RecordsDeletedTableTrashProjection.buildTrashPayload', + buildPayloadAttributes, + async () => + event.recordSnapshots.map((snapshot) => { + const record: IDeleteRecordsPayload['records'][number] = { + id: snapshot.id, + fields: snapshot.fields as IRecord['fields'], + autoNumber: snapshot.autoNumber, + createdTime: snapshot.createdTime, + createdBy: snapshot.createdBy, + lastModifiedTime: snapshot.lastModifiedTime, + lastModifiedBy: snapshot.lastModifiedBy, + order: snapshot.orders, + }; - await this.tableTrashListener.recordDeleteListener({ - operationId: generateOperationId(), - windowId: context.windowId, - tableId: event.tableId.toString(), - userId: context.actorId.toString(), - records, - }); + if (snapshot.displayName) { + record.name = snapshot.displayName; + } + + return record; + }) + ); + + const persistAttributes = { + teableTableId: event.tableId.toString(), + teableRecordCount: records.length, + } satisfies Record; + + await this.runInSpan( + context, + 'teable.V2RecordsDeletedTableTrashProjection.persistDeletedRecords', + persistAttributes, + async () => + this.v2RecordTrashService.persistDeletedRecords( + { + operationId: generateOperationId(), + windowId: context.windowId, + tableId: event.tableId.toString(), + userId: context.actorId.toString(), + records, + }, + context + ) + ); return ok(undefined); } + + private async runInSpan( + context: IExecutionContext, + name: `teable.${string}`, + attributes: Record, + callback: () => Promise + ): Promise { + const tracer = context.tracer; + const spanAttributes: Record = { + teableVersion: 'v2', + teableComponent: 'projection', + teableOperation: name.replace(/^teable\./, ''), + ...attributes, + }; + const span = tracer?.startSpan(name, spanAttributes); + + if (!tracer || !span) { + return callback(); + } + + return tracer.withSpan(span, async () => { + try { + return await callback(); + } finally { + span.end(); + } + }); + } } @ProjectionHandler(RecordsDeleted) export class V2RecordsDeletedAttachmentProjection implements IEventHandler { - constructor(private readonly attachmentsTableService: AttachmentsTableService) {} + constructor(private readonly v2ContainerService: V2ContainerService) {} async handle( _context: IExecutionContext, @@ -90,10 +153,18 @@ export class V2RecordsDeletedAttachmentProjection implements IEventHandler id.toString()) - ); + const container = await this.v2ContainerService.getContainer(); + const db = container.resolve>(v2PostgresDbTokens.db); + + await db + .deleteFrom('attachments_table') + .where('table_id', '=', event.tableId.toString()) + .where( + 'record_id', + 'in', + event.recordIds.map((id) => id.toString()) + ) + .execute(); return ok(undefined); } @@ -101,37 +172,41 @@ export class V2RecordsDeletedAttachmentProjection implements IEventHandler { - constructor(private readonly prisma: PrismaService) {} + constructor(private readonly v2ContainerService: V2ContainerService) {} async handle( context: IExecutionContext, event: TableTrashed ): Promise> { - const table = await this.prisma.tableMeta.findUnique({ - where: { id: event.tableId.toString() }, - select: { baseId: true, deletedTime: true }, - }); + const container = await this.v2ContainerService.getContainer(); + const db = container.resolve>(v2PostgresDbTokens.db); + const table = await db + .selectFrom('table_meta') + .where('id', '=', event.tableId.toString()) + .select(['base_id', 'deleted_time']) + .executeTakeFirst(); - if (!table?.deletedTime) { + if (!table?.deleted_time) { return ok(undefined); } - await this.prisma.trash.deleteMany({ - where: { - resourceId: event.tableId.toString(), - resourceType: ResourceType.Table, - }, - }); + await db + .deleteFrom('trash') + .where('resource_id', '=', event.tableId.toString()) + .where('resource_type', '=', ResourceType.Table) + .execute(); - await this.prisma.trash.create({ - data: { - resourceId: event.tableId.toString(), - resourceType: ResourceType.Table, - parentId: table.baseId, - deletedTime: table.deletedTime, - deletedBy: context.actorId.toString(), - }, - }); + await db + .insertInto('trash') + .values({ + id: nanoid(), + resource_id: event.tableId.toString(), + resource_type: ResourceType.Table, + parent_id: table.base_id, + deleted_time: table.deleted_time, + deleted_by: context.actorId.toString(), + }) + .execute(); return ok(undefined); } @@ -139,18 +214,19 @@ export class V2TableTrashedProjection implements IEventHandler { @ProjectionHandler(TableRestored) export class V2TableRestoredProjection implements IEventHandler { - constructor(private readonly prisma: PrismaService) {} + constructor(private readonly v2ContainerService: V2ContainerService) {} async handle( _context: IExecutionContext, event: TableRestored ): Promise> { - await this.prisma.trash.deleteMany({ - where: { - resourceId: event.tableId.toString(), - resourceType: ResourceType.Table, - }, - }); + const container = await this.v2ContainerService.getContainer(); + const db = container.resolve>(v2PostgresDbTokens.db); + await db + .deleteFrom('trash') + .where('resource_id', '=', event.tableId.toString()) + .where('resource_type', '=', ResourceType.Table) + .execute(); return ok(undefined); } @@ -162,29 +238,29 @@ export class V2TableTrashService implements IV2ProjectionRegistrar { private readonly logger = new Logger(V2TableTrashService.name); constructor( - private readonly tableTrashListener: TableTrashListener, - private readonly attachmentsTableService: AttachmentsTableService, - private readonly prisma: PrismaService + private readonly v2RecordTrashService: V2RecordTrashService, + private readonly v2ContainerService: V2ContainerService ) {} registerProjections(container: DependencyContainer): void { this.logger.log('Registering V2 trash projections'); - const tableQueryService = container.resolve(v2CoreTokens.tableQueryService); - container.registerInstance( V2RecordsDeletedTableTrashProjection, - new V2RecordsDeletedTableTrashProjection(this.tableTrashListener, tableQueryService) + new V2RecordsDeletedTableTrashProjection(this.v2RecordTrashService) ); container.registerInstance( V2RecordsDeletedAttachmentProjection, - new V2RecordsDeletedAttachmentProjection(this.attachmentsTableService) + new V2RecordsDeletedAttachmentProjection(this.v2ContainerService) + ); + container.registerInstance( + V2TableTrashedProjection, + new V2TableTrashedProjection(this.v2ContainerService) ); - container.registerInstance(V2TableTrashedProjection, new V2TableTrashedProjection(this.prisma)); container.registerInstance( V2TableRestoredProjection, - new V2TableRestoredProjection(this.prisma) + new V2TableRestoredProjection(this.v2ContainerService) ); } } diff --git a/apps/nestjs-backend/src/features/v2/v2-action-trigger.service.spec.ts b/apps/nestjs-backend/src/features/v2/v2-action-trigger.service.spec.ts index 4d0fc2ca81..829204ebc8 100644 --- a/apps/nestjs-backend/src/features/v2/v2-action-trigger.service.spec.ts +++ b/apps/nestjs-backend/src/features/v2/v2-action-trigger.service.spec.ts @@ -4,7 +4,10 @@ import { FieldCreated, FieldId, FieldUpdated, + RecordId, + RecordsBatchCreated, RecordsBatchUpdated, + RecordsDeleted, TableActionTriggerRequested, TableId, type IExecutionContext, @@ -20,6 +23,7 @@ type IPresencePayload = Array<{ actionKey: string; payload?: Record { await new Promise((resolve) => { @@ -294,6 +298,160 @@ describe('V2ActionTriggerService', () => { ]); }); + it('emits deleteRecord payload with record ids when large delete skips realtime fan-out', async () => { + let submitted: IPresencePayload | undefined; + + const shareDbService = { + connect: () => ({ + getPresence: () => ({ + create: () => ({ + submit: (data: IPresencePayload, cb?: (error?: unknown) => void) => { + submitted = data; + cb?.(); + }, + }), + }), + }), + } as unknown as ShareDbService; + + const registered: Array<{ instance: unknown }> = []; + const container = { + registerInstance: (_token: unknown, instance: unknown) => { + registered.push({ instance }); + return container; + }, + } as unknown as DependencyContainer; + + const service = new V2ActionTriggerService(shareDbService); + service.registerProjections(container); + + const projection = registered.find( + (item) => + (item.instance as { constructor?: { name?: string } }).constructor?.name === + 'V2RecordsDeletedActionTriggerProjection' + )?.instance as IEventHandler | undefined; + + expect(projection).toBeDefined(); + + const { baseId, tableId } = createIds(); + const recordIds = Array.from({ length: 1001 }, (_, index) => + RecordId.create(`rec${index.toString().padStart(16, '0')}`)._unsafeUnwrap() + ); + const event = RecordsDeleted.create({ + baseId, + tableId, + recordIds, + recordSnapshots: recordIds.map((recordId) => ({ + id: recordId.toString(), + fields: {}, + })), + orchestration: { + operationId: streamedDeleteOperationId, + groupId: streamedDeleteOperationId, + totalRecordCount: recordIds.length, + totalChunkCount: 3, + chunkIndex: 1, + scope: 'chunk', + }, + }); + + const result = await projection?.handle({} as IExecutionContext, event); + expect(result?.isOk()).toBe(true); + await waitForPresenceFlush(); + + expect(submitted).toEqual([ + { + actionKey: 'deleteRecord', + payload: { + tableId: tableId.toString(), + recordIds: recordIds.map((recordId) => recordId.toString()), + skipRealtime: true, + operationId: streamedDeleteOperationId, + groupId: streamedDeleteOperationId, + totalRecordCount: recordIds.length, + totalChunkCount: 3, + chunkIndex: 1, + scope: 'chunk', + }, + }, + ]); + }); + + it('emits addRecord payload with record ids when streamed create reaches the total threshold', async () => { + let submitted: IPresencePayload | undefined; + + const shareDbService = { + connect: () => ({ + getPresence: () => ({ + create: () => ({ + submit: (data: IPresencePayload, cb?: (error?: unknown) => void) => { + submitted = data; + cb?.(); + }, + }), + }), + }), + } as unknown as ShareDbService; + + const registered: Array<{ instance: unknown }> = []; + const container = { + registerInstance: (_token: unknown, instance: unknown) => { + registered.push({ instance }); + return container; + }, + } as unknown as DependencyContainer; + + const service = new V2ActionTriggerService(shareDbService); + service.registerProjections(container); + + const projection = registered.find( + (item) => + (item.instance as { constructor?: { name?: string } }).constructor?.name === + 'V2RecordsBatchCreatedActionTriggerProjection' + )?.instance as IEventHandler | undefined; + + expect(projection).toBeDefined(); + + const { baseId, tableId } = createIds(); + const event = RecordsBatchCreated.create({ + baseId, + tableId, + records: Array.from({ length: 200 }, (_, index) => ({ + recordId: `rec${index.toString().padStart(16, '0')}`, + fields: [], + })), + orchestration: { + operationId: 'streamed-create-operation', + groupId: 'streamed-create-group', + totalRecordCount: 1000, + totalChunkCount: 5, + chunkIndex: 0, + scope: 'chunk', + }, + }); + + const result = await projection?.handle({} as IExecutionContext, event); + expect(result?.isOk()).toBe(true); + await waitForPresenceFlush(); + + expect(submitted).toEqual([ + { + actionKey: 'addRecord', + payload: { + tableId: tableId.toString(), + recordIds: event.records.map((record) => record.recordId), + skipRealtime: true, + operationId: 'streamed-create-operation', + groupId: 'streamed-create-group', + totalRecordCount: 1000, + totalChunkCount: 5, + chunkIndex: 0, + scope: 'chunk', + }, + }, + ]); + }); + it('does not emit setField action for unrelated field property updates', async () => { let submitted: IPresencePayload | undefined; @@ -474,6 +632,14 @@ describe('V2ActionTriggerService', () => { }, ], })), + orchestration: { + operationId: 'large-update-operation', + groupId: 'large-update-group', + totalRecordCount: 1001, + totalChunkCount: 3, + chunkIndex: 1, + scope: 'chunk', + }, }); const result = await projection?.handle({} as IExecutionContext, event); @@ -487,6 +653,99 @@ describe('V2ActionTriggerService', () => { payload: { tableId: tableId.toString(), fieldIds: [fieldId.toString()], + recordIds: event.updates.map((update) => update.recordId), + skipRealtime: true, + operationId: 'large-update-operation', + groupId: 'large-update-group', + totalRecordCount: 1001, + totalChunkCount: 3, + chunkIndex: 1, + scope: 'chunk', + }, + }, + ]); + }); + + it('emits setRecord presence payload with fieldIds when streamed updates reach the total threshold', async () => { + let submitted: IPresencePayload | undefined; + + const shareDbService = { + connect: () => ({ + getPresence: () => ({ + create: () => ({ + submit: (data: IPresencePayload, cb?: (error?: unknown) => void) => { + submitted = data; + cb?.(); + }, + }), + }), + }), + } as unknown as ShareDbService; + + const registered: Array<{ instance: unknown }> = []; + const container = { + registerInstance: (_token: unknown, instance: unknown) => { + registered.push({ instance }); + return container; + }, + } as unknown as DependencyContainer; + + const service = new V2ActionTriggerService(shareDbService); + service.registerProjections(container); + + const projection = registered.find( + (item) => + (item.instance as { constructor?: { name?: string } }).constructor?.name === + 'V2RecordsBatchUpdatedActionTriggerProjection' + )?.instance as IEventHandler | undefined; + + expect(projection).toBeDefined(); + + const { baseId, tableId, fieldId } = createIds(); + const event = RecordsBatchUpdated.create({ + baseId, + tableId, + source: 'user', + updates: Array.from({ length: 200 }, (_, index) => ({ + recordId: `rec${index.toString().padStart(16, '0')}`, + oldVersion: 1, + newVersion: 2, + changes: [ + { + fieldId: fieldId.toString(), + oldValue: `old-${index}`, + newValue: `new-${index}`, + }, + ], + })), + orchestration: { + operationId: 'streamed-update-operation', + groupId: 'streamed-update-group', + totalRecordCount: 1000, + totalChunkCount: 5, + chunkIndex: 0, + scope: 'chunk', + }, + }); + + const result = await projection?.handle({} as IExecutionContext, event); + expect(result?.isOk()).toBe(true); + await waitForPresenceFlush(); + + expect(submitted).toEqual([ + { + actionKey: 'setRecord', + payload: { + tableId: tableId.toString(), + fieldIds: [fieldId.toString()], + recordIds: event.updates.map((update) => update.recordId), + skipRealtime: true, + operationId: 'streamed-update-operation', + groupId: 'streamed-update-group', + totalRecordCount: 1000, + totalChunkCount: 5, + chunkIndex: 0, + scope: 'chunk', }, }, ]); diff --git a/apps/nestjs-backend/src/features/v2/v2-action-trigger.service.ts b/apps/nestjs-backend/src/features/v2/v2-action-trigger.service.ts index ad25199d42..d5f55b05bc 100644 --- a/apps/nestjs-backend/src/features/v2/v2-action-trigger.service.ts +++ b/apps/nestjs-backend/src/features/v2/v2-action-trigger.service.ts @@ -15,7 +15,7 @@ import { ProjectionHandler, ok, serializeFieldUpdatedValue, - isLargeRecordBatchMutation, + shouldSkipRealtimeBatchMutation, } from '@teable/v2-core'; import type { IExecutionContext, IEventHandler, DomainError, Result } from '@teable/v2-core'; import type { DependencyContainer } from '@teable/v2-di'; @@ -169,7 +169,28 @@ class V2RecordsBatchCreatedActionTriggerProjection implements IEventHandler> { - emitActionTrigger(this.shareDbService, event.tableId.toString(), [{ actionKey: 'addRecord' }]); + const orchestration = event.orchestration; + const totalRecordCount = orchestration?.totalRecordCount ?? event.records.length; + const skipRealtime = shouldSkipRealtimeBatchMutation(totalRecordCount, orchestration); + + emitActionTrigger(this.shareDbService, event.tableId.toString(), [ + { + actionKey: 'addRecord', + payload: skipRealtime + ? { + tableId: event.tableId.toString(), + recordIds: event.records.map((record) => record.recordId), + skipRealtime: true, + operationId: orchestration?.operationId, + groupId: orchestration?.groupId, + totalRecordCount, + totalChunkCount: orchestration?.totalChunkCount, + chunkIndex: orchestration?.chunkIndex, + scope: orchestration?.scope, + } + : undefined, + }, + ]); return ok(undefined); } } @@ -201,7 +222,10 @@ class V2RecordsBatchUpdatedActionTriggerProjection implements IEventHandler> { - if (isLargeRecordBatchMutation(event.updates.length)) { + const orchestration = event.orchestration; + const totalRecordCount = orchestration?.totalRecordCount ?? event.updates.length; + + if (shouldSkipRealtimeBatchMutation(totalRecordCount, orchestration)) { const fieldIds = collectChangedFieldIds(event.updates); emitActionTrigger(this.shareDbService, event.tableId.toString(), [ { @@ -209,6 +233,14 @@ class V2RecordsBatchUpdatedActionTriggerProjection implements IEventHandler update.recordId), + skipRealtime: true, + operationId: orchestration?.operationId, + groupId: orchestration?.groupId, + totalRecordCount, + totalChunkCount: orchestration?.totalChunkCount, + chunkIndex: orchestration?.chunkIndex, + scope: orchestration?.scope, }, }, ]); @@ -247,8 +279,26 @@ class V2RecordsDeletedActionTriggerProjection implements IEventHandler> { + const orchestration = event.orchestration; + const totalRecordCount = orchestration?.totalRecordCount ?? event.recordIds.length; + const skipRealtime = shouldSkipRealtimeBatchMutation(totalRecordCount, orchestration); emitActionTrigger(this.shareDbService, event.tableId.toString(), [ - { actionKey: 'deleteRecord' }, + { + actionKey: 'deleteRecord', + payload: skipRealtime + ? { + tableId: event.tableId.toString(), + recordIds: event.recordIds.map((recordId) => recordId.toString()), + skipRealtime: true, + operationId: orchestration?.operationId, + groupId: orchestration?.groupId, + totalRecordCount, + totalChunkCount: orchestration?.totalChunkCount, + chunkIndex: orchestration?.chunkIndex, + scope: orchestration?.scope, + } + : undefined, + }, ]); return ok(undefined); } diff --git a/apps/nestjs-backend/src/features/v2/v2-base-node-compat.service.spec.ts b/apps/nestjs-backend/src/features/v2/v2-base-node-compat.service.spec.ts new file mode 100644 index 0000000000..30aac80fd9 --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-base-node-compat.service.spec.ts @@ -0,0 +1,99 @@ +import { + BaseId, + TableCreated, + TableDeleted, + TableId, + TableName, + TableRestored, + TableTrashed, +} from '@teable/v2-core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateBaseNodeListCacheKey } from '../../performance-cache/generate-keys'; +import { V2TableBaseNodeProjection } from './v2-base-node-compat.service'; + +vi.mock('../../performance-cache', () => ({ + PerformanceCacheService: class PerformanceCacheService {}, +})); + +vi.mock('../../share-db/share-db.service', () => ({ + ShareDbService: class ShareDbService {}, +})); + +const createLocalPresence = () => ({ + submit: vi.fn(), + destroy: vi.fn(), +}); + +describe('V2TableBaseNodeProjection', () => { + const baseId = 'bseaaaaaaaaaaaaaaaa'; + const tableId = 'tblaaaaaaaaaaaaaaaa'; + + const createEvent = ( + factory: typeof TableCreated | typeof TableTrashed | typeof TableDeleted | typeof TableRestored + ) => + factory.create({ + tableId: TableId.create(tableId)._unsafeUnwrap(), + baseId: BaseId.create(baseId)._unsafeUnwrap(), + tableName: TableName.create('Test Table')._unsafeUnwrap(), + fieldIds: [], + viewIds: [], + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + ['create', () => createEvent(TableCreated)], + ['trash', () => createEvent(TableTrashed)], + ['delete', () => createEvent(TableDeleted)], + ['restore', () => createEvent(TableRestored)], + ])('invalidates base-node cache and flushes presence on %s', async (_name, buildEvent) => { + const localPresence = createLocalPresence(); + const performanceCacheService = { + del: vi.fn(), + }; + const shareDbService = { + shareDbAdapter: { closed: false }, + connect: vi.fn().mockReturnValue({ + getPresence: vi.fn().mockReturnValue({ + create: vi.fn().mockReturnValue(localPresence), + }), + }), + }; + + const projection = new V2TableBaseNodeProjection( + performanceCacheService as never, + shareDbService as never + ); + + const result = await projection.handle({} as never, buildEvent()); + + expect(result._unsafeUnwrap()).toBeUndefined(); + expect(performanceCacheService.del).toHaveBeenCalledWith(generateBaseNodeListCacheKey(baseId)); + expect(shareDbService.connect).toHaveBeenCalled(); + expect(localPresence.submit).toHaveBeenCalledWith({ event: 'flush' }); + expect(localPresence.destroy).toHaveBeenCalled(); + }); + + it('only invalidates cache when sharedb is closed', async () => { + const performanceCacheService = { + del: vi.fn(), + }; + const shareDbService = { + shareDbAdapter: { closed: true }, + connect: vi.fn(), + }; + + const projection = new V2TableBaseNodeProjection( + performanceCacheService as never, + shareDbService as never + ); + + const result = await projection.handle({} as never, createEvent(TableTrashed)); + + expect(result._unsafeUnwrap()).toBeUndefined(); + expect(performanceCacheService.del).toHaveBeenCalledWith(generateBaseNodeListCacheKey(baseId)); + expect(shareDbService.connect).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/nestjs-backend/src/features/v2/v2-base-node-compat.service.ts b/apps/nestjs-backend/src/features/v2/v2-base-node-compat.service.ts index da9a13416c..6f496a594f 100644 --- a/apps/nestjs-backend/src/features/v2/v2-base-node-compat.service.ts +++ b/apps/nestjs-backend/src/features/v2/v2-base-node-compat.service.ts @@ -3,6 +3,9 @@ import type { IBaseNodePresenceFlushPayload } from '@teable/openapi'; import { ProjectionHandler, TableCreated, + TableDeleted, + TableRestored, + TableTrashed, ok, type DomainError, type IEventHandler, @@ -17,7 +20,12 @@ import { presenceHandler } from '../base-node/helper'; import { V2ProjectionRegistrar, type IV2ProjectionRegistrar } from './v2-projection-registrar'; @ProjectionHandler(TableCreated) -class V2TableCreatedBaseNodeProjection implements IEventHandler { +@ProjectionHandler(TableTrashed) +@ProjectionHandler(TableDeleted) +@ProjectionHandler(TableRestored) +export class V2TableBaseNodeProjection + implements IEventHandler +{ constructor( private readonly performanceCacheService: PerformanceCacheService, private readonly shareDbService: ShareDbService @@ -25,7 +33,7 @@ class V2TableCreatedBaseNodeProjection implements IEventHandler { async handle( _context: IExecutionContext, - event: TableCreated + event: TableCreated | TableTrashed | TableDeleted | TableRestored ): Promise> { const baseId = event.baseId.toString(); this.performanceCacheService.del(generateBaseNodeListCacheKey(baseId)); @@ -58,8 +66,8 @@ export class V2BaseNodeCompatService implements IV2ProjectionRegistrar { this.logger.log('Registering V2 base-node compatibility projections'); container.registerInstance( - V2TableCreatedBaseNodeProjection, - new V2TableCreatedBaseNodeProjection(this.performanceCacheService, this.shareDbService) + V2TableBaseNodeProjection, + new V2TableBaseNodeProjection(this.performanceCacheService, this.shareDbService) ); } } diff --git a/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.spec.ts b/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.spec.ts new file mode 100644 index 0000000000..679d7ee74e --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.spec.ts @@ -0,0 +1,135 @@ +import { ViewOpBuilder } from '@teable/core'; +import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import { describe, expect, it, vi } from 'vitest'; +import { V2_FIELD_DELETE_COMPAT_CONTEXT_KEY } from './v2-field-delete-compat.constants'; +import { V2FieldDeletedCompatProjection } from './v2-field-delete-compat.service'; + +const createInsertDb = () => { + const query = { + values: vi.fn().mockReturnThis(), + execute: vi.fn().mockResolvedValue(undefined), + }; + const db = { + insertInto: vi.fn().mockReturnValue(query), + }; + + return { db, query }; +}; + +const createV2ContainerService = (db: unknown) => ({ + getContainer: vi.fn().mockResolvedValue({ + resolve: vi.fn((token: symbol) => { + if (token !== v2PostgresDbTokens.db) { + throw new Error(`Unexpected token ${String(token)}`); + } + + return db; + }), + }), +}); + +describe('V2FieldDeletedCompatProjection', () => { + it('waits until the last deleted field before running compat updates', async () => { + const { db, query } = createInsertDb(); + const projection = new V2FieldDeletedCompatProjection( + createV2ContainerService(db) as never, + { + batchUpdateViewByOps: vi.fn(), + } as never + ); + const compatContext = { + tableId: 'tblCompatTable0001', + userId: 'usrCompatWriter00001', + operationId: 'opCompatDelete000001', + remainingFieldIds: new Set(['fldCompatA00000001', 'fldCompatB00000001']), + frozenFieldOps: { + viwCompat000000001: [ + ViewOpBuilder.editor.setViewProperty.build({ + key: 'options', + oldValue: { frozenFieldId: 'fldCompatA00000001' }, + newValue: { frozenFieldId: 'fldCompatB00000001' }, + }), + ], + }, + legacyDeletePayload: { + fields: [{ id: 'fldCompatA00000001' }], + records: [{ id: 'recCompat000000001' }], + }, + }; + + const result = await projection.handle( + { + [V2_FIELD_DELETE_COMPAT_CONTEXT_KEY]: compatContext, + } as never, + { + tableId: { toString: () => 'tblCompatTable0001' }, + fieldId: { toString: () => 'fldCompatA00000001' }, + } as never + ); + + expect(result._unsafeUnwrap()).toBeUndefined(); + expect(compatContext.completed).toBeUndefined(); + expect(compatContext.remainingFieldIds.has('fldCompatB00000001')).toBe(true); + expect(db.insertInto).not.toHaveBeenCalled(); + expect(query.values).not.toHaveBeenCalled(); + }); + + it('uses v2 view compat and table_trash writes when the final field is deleted', async () => { + const { db, query } = createInsertDb(); + const v2ViewCompatService = { + batchUpdateViewByOps: vi.fn().mockResolvedValue(undefined), + }; + const projection = new V2FieldDeletedCompatProjection( + createV2ContainerService(db) as never, + v2ViewCompatService as never + ); + const compatContext = { + tableId: 'tblCompatTable0001', + userId: 'usrCompatWriter00001', + operationId: 'opCompatDelete000001', + remainingFieldIds: new Set(['fldCompatA00000001']), + frozenFieldOps: { + viwCompat000000001: [ + ViewOpBuilder.editor.setViewProperty.build({ + key: 'options', + oldValue: { frozenFieldId: 'fldCompatA00000001' }, + newValue: { frozenFieldId: 'fldCompatB00000001' }, + }), + ], + }, + legacyDeletePayload: { + fields: [{ id: 'fldCompatA00000001' }], + records: [{ id: 'recCompat000000001' }], + }, + }; + + const result = await projection.handle( + { + [V2_FIELD_DELETE_COMPAT_CONTEXT_KEY]: compatContext, + } as never, + { + tableId: { toString: () => 'tblCompatTable0001' }, + fieldId: { toString: () => 'fldCompatA00000001' }, + } as never + ); + + expect(result._unsafeUnwrap()).toBeUndefined(); + expect(compatContext.completed).toBe(true); + expect(v2ViewCompatService.batchUpdateViewByOps).toHaveBeenCalledWith( + 'tblCompatTable0001', + compatContext.frozenFieldOps + ); + expect(db.insertInto).toHaveBeenCalledWith('table_trash'); + expect(query.values).toHaveBeenCalledWith({ + id: 'opCompatDelete000001', + table_id: 'tblCompatTable0001', + created_by: 'usrCompatWriter00001', + resource_type: 'field', + snapshot: JSON.stringify({ + fields: [{ id: 'fldCompatA00000001' }], + records: [{ id: 'recCompat000000001' }], + }), + }); + expect(query.execute).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.ts b/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.ts index 93aa126378..20b30f6780 100644 --- a/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.ts +++ b/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.ts @@ -1,13 +1,28 @@ import { Injectable, Logger } from '@nestjs/common'; -import { PrismaService } from '@teable/db-main-prisma'; import { ResourceType } from '@teable/openapi'; +import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg'; import { FieldDeleted, ProjectionHandler, ok } from '@teable/v2-core'; import type { DomainError, IEventHandler, IExecutionContext, Result } from '@teable/v2-core'; import type { DependencyContainer } from '@teable/v2-di'; -import { ViewService } from '../view/view.service'; +import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; +import type { Kysely } from 'kysely'; +import { V2ContainerService } from './v2-container.service'; import { V2_FIELD_DELETE_COMPAT_CONTEXT_KEY } from './v2-field-delete-compat.constants'; import type { IV2FieldDeleteCompatContext } from './v2-field-delete-compat.constants'; import { V2ProjectionRegistrar, type IV2ProjectionRegistrar } from './v2-projection-registrar'; +import { V2ViewCompatService } from './v2-view-compat.service'; + +/* eslint-disable @typescript-eslint/naming-convention */ +type IV2FieldDeleteCompatDb = V1TeableDatabase & { + table_trash: { + id: string; + table_id: string; + resource_type: string; + snapshot: string; + created_by: string; + }; +}; +/* eslint-enable @typescript-eslint/naming-convention */ const getFieldDeleteCompatContext = ( context: IExecutionContext, @@ -31,10 +46,10 @@ const getFieldDeleteCompatContext = ( }; @ProjectionHandler(FieldDeleted) -class V2FieldDeletedCompatProjection implements IEventHandler { +export class V2FieldDeletedCompatProjection implements IEventHandler { constructor( - private readonly prisma: PrismaService, - private readonly viewService: ViewService + private readonly v2ContainerService: V2ContainerService, + private readonly v2ViewCompatService: V2ViewCompatService ) {} async handle( @@ -59,24 +74,28 @@ class V2FieldDeletedCompatProjection implements IEventHandler { compatContext.completed = true; if (Object.keys(compatContext.frozenFieldOps).length > 0) { - await this.viewService.batchUpdateViewByOps( + await this.v2ViewCompatService.batchUpdateViewByOps( compatContext.tableId, compatContext.frozenFieldOps ); } - await this.prisma.tableTrash.create({ - data: { + const container = await this.v2ContainerService.getContainer(); + const db = container.resolve>(v2PostgresDbTokens.db); + + await db + .insertInto('table_trash') + .values({ id: compatContext.operationId, - tableId: compatContext.tableId, - createdBy: compatContext.userId, - resourceType: ResourceType.Field, + table_id: compatContext.tableId, + created_by: compatContext.userId, + resource_type: ResourceType.Field, snapshot: JSON.stringify({ fields: compatContext.legacyDeletePayload.fields, records: compatContext.legacyDeletePayload.records, }), - }, - }); + }) + .execute(); return ok(undefined); } @@ -88,15 +107,15 @@ export class V2FieldDeleteCompatService implements IV2ProjectionRegistrar { private readonly logger = new Logger(V2FieldDeleteCompatService.name); constructor( - private readonly prisma: PrismaService, - private readonly viewService: ViewService + private readonly v2ContainerService: V2ContainerService, + private readonly v2ViewCompatService: V2ViewCompatService ) {} registerProjections(container: DependencyContainer): void { this.logger.debug('Registering V2 field delete compatibility projections'); container.registerInstance( V2FieldDeletedCompatProjection, - new V2FieldDeletedCompatProjection(this.prisma, this.viewService) + new V2FieldDeletedCompatProjection(this.v2ContainerService, this.v2ViewCompatService) ); } } diff --git a/apps/nestjs-backend/src/features/v2/v2-record-history.service.spec.ts b/apps/nestjs-backend/src/features/v2/v2-record-history.service.spec.ts new file mode 100644 index 0000000000..cee991f1f8 --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-record-history.service.spec.ts @@ -0,0 +1,217 @@ +import { FieldType as CoreFieldType } from '@teable/core'; +import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import { describe, expect, it, vi } from 'vitest'; +import { Events } from '../../event-emitter/events'; +import { + V2RecordsBatchUpdatedHistoryProjection, + V2RecordUpdatedHistoryProjection, +} from './v2-record-history.service'; + +const okResult = (value: T) => ({ + isErr: () => false, + isOk: () => true, + value, +}); + +const errResult = () => ({ + isErr: () => true, + isOk: () => false, +}); + +const createTextField = (fieldId: string, name: string) => ({ + id: () => ({ + equals: (other: { toString(): string }) => other.toString() === fieldId, + }), + type: () => ({ + toString: () => CoreFieldType.SingleLineText, + }), + name: () => ({ + toString: () => name, + }), + computed: () => ({ + toBoolean: () => false, + }), + accept: (visitor: { visitSingleLineTextField(): unknown }) => visitor.visitSingleLineTextField(), +}); + +const createTable = (fields: Array>) => ({ + getField: (predicate: (field: (typeof fields)[number]) => boolean) => { + const field = fields.find(predicate); + return field ? okResult(field) : errResult(); + }, +}); + +const createV2ContainerService = () => { + const query = { + values: vi.fn().mockReturnThis(), + execute: vi.fn().mockResolvedValue(undefined), + }; + const db = { + insertInto: vi.fn().mockReturnValue(query), + }; + const container = { + resolve: vi.fn((token: symbol) => { + if (token !== v2PostgresDbTokens.db) { + throw new Error(`Unexpected token ${String(token)}`); + } + + return db; + }), + }; + + return { + db, + query, + service: { + getContainer: vi.fn().mockResolvedValue(container), + }, + }; +}; + +describe('V2RecordUpdatedHistoryProjection', () => { + it('writes record history entries through the v2 db container', async () => { + const { db, query, service: v2ContainerService } = createV2ContainerService(); + const cls = { + get: vi.fn().mockReturnValue('usrHistWriter00000001'), + }; + const tableQueryService = { + getById: vi + .fn() + .mockResolvedValue(okResult(createTable([createTextField('fldHistField0000001', 'Name')]))), + }; + const eventEmitterService = { + emit: vi.fn(), + }; + const projection = new V2RecordUpdatedHistoryProjection( + v2ContainerService as never, + cls as never, + { recordHistoryDisabled: false } as never, + tableQueryService as never, + eventEmitterService as never + ); + + const result = await projection.handle( + {} as never, + { + source: 'user', + tableId: { toString: () => 'tblHistTable0000001' }, + recordId: { toString: () => 'recHistRecord000001' }, + changes: [ + { + fieldId: 'fldHistField0000001', + oldValue: 'before', + newValue: 'after', + }, + ], + } as never + ); + + expect(result._unsafeUnwrap()).toBeUndefined(); + expect(db.insertInto).toHaveBeenCalledWith('record_history'); + const [rows] = query.values.mock.calls[0] as [Array>]; + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + table_id: 'tblHistTable0000001', + record_id: 'recHistRecord000001', + field_id: 'fldHistField0000001', + created_by: 'usrHistWriter00000001', + }); + expect(JSON.parse(rows[0].before)).toEqual({ + meta: { + type: CoreFieldType.SingleLineText, + name: 'Name', + options: null, + cellValueType: 'string', + }, + data: 'before', + }); + expect(JSON.parse(rows[0].after)).toEqual({ + meta: { + type: CoreFieldType.SingleLineText, + name: 'Name', + options: null, + cellValueType: 'string', + }, + data: 'after', + }); + expect(query.execute).toHaveBeenCalledTimes(1); + expect(eventEmitterService.emit).toHaveBeenCalledWith(Events.RECORD_HISTORY_CREATE, { + recordIds: ['recHistRecord000001'], + }); + }); +}); + +describe('V2RecordsBatchUpdatedHistoryProjection', () => { + it('writes batch record history entries through the v2 db container', async () => { + const { db, query, service: v2ContainerService } = createV2ContainerService(); + const cls = { + get: vi.fn().mockReturnValue('usrBatchWriter0000001'), + }; + const tableQueryService = { + getById: vi + .fn() + .mockResolvedValue(okResult(createTable([createTextField('fldHistField0000001', 'Name')]))), + }; + const eventEmitterService = { + emit: vi.fn(), + }; + const projection = new V2RecordsBatchUpdatedHistoryProjection( + v2ContainerService as never, + cls as never, + { recordHistoryDisabled: false } as never, + tableQueryService as never, + eventEmitterService as never + ); + + const result = await projection.handle( + {} as never, + { + source: 'user', + tableId: { toString: () => 'tblHistTable0000001' }, + updates: [ + { + recordId: 'recHistRecord000001', + changes: [ + { + fieldId: 'fldHistField0000001', + oldValue: 'before-1', + newValue: 'after-1', + }, + ], + }, + { + recordId: 'recHistRecord000002', + changes: [ + { + fieldId: 'fldHistField0000001', + oldValue: 'before-2', + newValue: 'after-2', + }, + ], + }, + ], + } as never + ); + + expect(result._unsafeUnwrap()).toBeUndefined(); + expect(db.insertInto).toHaveBeenCalledWith('record_history'); + const [rows] = query.values.mock.calls[0] as [Array>]; + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + table_id: 'tblHistTable0000001', + record_id: 'recHistRecord000001', + field_id: 'fldHistField0000001', + created_by: 'usrBatchWriter0000001', + }); + expect(rows[1]).toMatchObject({ + table_id: 'tblHistTable0000001', + record_id: 'recHistRecord000002', + field_id: 'fldHistField0000001', + created_by: 'usrBatchWriter0000001', + }); + expect(query.execute).toHaveBeenCalledTimes(1); + expect(eventEmitterService.emit).toHaveBeenCalledWith(Events.RECORD_HISTORY_CREATE, { + recordIds: ['recHistRecord000001', 'recHistRecord000002'], + }); + }); +}); diff --git a/apps/nestjs-backend/src/features/v2/v2-record-history.service.ts b/apps/nestjs-backend/src/features/v2/v2-record-history.service.ts index 4ca200e7d2..12454d3399 100644 --- a/apps/nestjs-backend/src/features/v2/v2-record-history.service.ts +++ b/apps/nestjs-backend/src/features/v2/v2-record-history.service.ts @@ -4,7 +4,7 @@ import { Injectable, Logger } from '@nestjs/common'; import type { ISelectFieldOptions } from '@teable/core'; import { FieldType as CoreFieldType, generateRecordHistoryId } from '@teable/core'; -import { PrismaService } from '@teable/db-main-prisma'; +import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg'; import { FieldId, FieldValueTypeVisitor, @@ -26,14 +26,15 @@ import type { SingleSelectField, } from '@teable/v2-core'; import type { DependencyContainer } from '@teable/v2-di'; -import { Knex } from 'knex'; +import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; +import type { ColumnType, Kysely } from 'kysely'; import { isEqual, isString } from 'lodash'; -import { InjectModel } from 'nest-knexjs'; import { ClsService } from 'nestjs-cls'; import { BaseConfig, IBaseConfig } from '../../configs/base.config'; import { EventEmitterService } from '../../event-emitter/event-emitter.service'; import { Events } from '../../event-emitter/events'; import type { IClsStore } from '../../types/cls'; +import { V2ContainerService } from './v2-container.service'; import { V2ProjectionRegistrar, type IV2ProjectionRegistrar } from './v2-projection-registrar'; const SELECT_FIELD_TYPE_SET = new Set([CoreFieldType.SingleSelect, CoreFieldType.MultipleSelect]); @@ -56,6 +57,30 @@ interface IFieldHistoryMeta { isComputed: boolean; } +type IRecordHistoryDb = V1TeableDatabase & { + record_history: IRecordHistoryEntry & { + created_time: ColumnType; + }; +}; + +const getRecordHistoryDb = async ( + v2ContainerService: V2ContainerService +): Promise> => { + const container = await v2ContainerService.getContainer(); + return container.resolve>(v2PostgresDbTokens.db); +}; + +const insertRecordHistoryEntries = async ( + db: Kysely, + recordHistoryList: IRecordHistoryEntry[] +): Promise => { + if (!recordHistoryList.length) { + return; + } + + await db.insertInto('record_history').values(recordHistoryList).execute(); +}; + /** * Visitor to extract field options for record history. * Returns options in a format compatible with V1 record history. @@ -214,12 +239,11 @@ const buildHistoryValue = ( * V2 projection handler that writes record history for individual record update events. */ @ProjectionHandler(RecordUpdated) -class V2RecordUpdatedHistoryProjection implements IEventHandler { +export class V2RecordUpdatedHistoryProjection implements IEventHandler { constructor( - private readonly prisma: PrismaService, + private readonly v2ContainerService: V2ContainerService, private readonly cls: ClsService, private readonly baseConfig: IBaseConfig, - private readonly knex: Knex, private readonly tableQueryService: TableQueryService, private readonly eventEmitterService: EventEmitterService ) {} @@ -291,10 +315,8 @@ class V2RecordUpdatedHistoryProjection implements IEventHandler { } // Insert history records - if (recordHistoryList.length > 0) { - const query = this.knex.insert(recordHistoryList).into('record_history').toQuery(); - await this.prisma.$executeRawUnsafe(query); - } + const db = await getRecordHistoryDb(this.v2ContainerService); + await insertRecordHistoryEntries(db, recordHistoryList); // Emit RECORD_HISTORY_CREATE event for compatibility this.eventEmitterService.emit(Events.RECORD_HISTORY_CREATE, { @@ -310,12 +332,11 @@ class V2RecordUpdatedHistoryProjection implements IEventHandler { * RecordsBatchUpdated is used by paste operations. */ @ProjectionHandler(RecordsBatchUpdated) -class V2RecordsBatchUpdatedHistoryProjection implements IEventHandler { +export class V2RecordsBatchUpdatedHistoryProjection implements IEventHandler { constructor( - private readonly prisma: PrismaService, + private readonly v2ContainerService: V2ContainerService, private readonly cls: ClsService, private readonly baseConfig: IBaseConfig, - private readonly knex: Knex, private readonly tableQueryService: TableQueryService, private readonly eventEmitterService: EventEmitterService ) {} @@ -401,12 +422,10 @@ class V2RecordsBatchUpdatedHistoryProjection implements IEventHandler 0) { - const query = this.knex.insert(batch).into('record_history').toQuery(); - await this.prisma.$executeRawUnsafe(query); - } + await insertRecordHistoryEntries(db, batch); } // Emit RECORD_HISTORY_CREATE event for compatibility @@ -430,10 +449,9 @@ export class V2RecordHistoryService implements IV2ProjectionRegistrar { private readonly logger = new Logger(V2RecordHistoryService.name); constructor( - private readonly prisma: PrismaService, + private readonly v2ContainerService: V2ContainerService, private readonly cls: ClsService, @BaseConfig() private readonly baseConfig: IBaseConfig, - @InjectModel('CUSTOM_KNEX') private readonly knex: Knex, private readonly eventEmitterService: EventEmitterService ) {} @@ -450,10 +468,9 @@ export class V2RecordHistoryService implements IV2ProjectionRegistrar { container.registerInstance( V2RecordUpdatedHistoryProjection, new V2RecordUpdatedHistoryProjection( - this.prisma, + this.v2ContainerService, this.cls, this.baseConfig, - this.knex, tableQueryService, this.eventEmitterService ) @@ -462,10 +479,9 @@ export class V2RecordHistoryService implements IV2ProjectionRegistrar { container.registerInstance( V2RecordsBatchUpdatedHistoryProjection, new V2RecordsBatchUpdatedHistoryProjection( - this.prisma, + this.v2ContainerService, this.cls, this.baseConfig, - this.knex, tableQueryService, this.eventEmitterService ) diff --git a/apps/nestjs-backend/src/features/v2/v2-view-compat.service.spec.ts b/apps/nestjs-backend/src/features/v2/v2-view-compat.service.spec.ts new file mode 100644 index 0000000000..41ad1f03b1 --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-view-compat.service.spec.ts @@ -0,0 +1,83 @@ +import { IdPrefix, ViewOpBuilder } from '@teable/core'; +import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import { describe, expect, it, vi } from 'vitest'; +import { V2ViewCompatService } from './v2-view-compat.service'; + +const createV2ContainerService = (db: unknown) => ({ + getContainer: vi.fn().mockResolvedValue({ + resolve: vi.fn((token: symbol) => { + if (token !== v2PostgresDbTokens.db) { + throw new Error(`Unexpected token ${String(token)}`); + } + + return db; + }), + }), +}); + +describe('V2ViewCompatService', () => { + it('updates matching views through the v2 db and stores raw ops in cls state', async () => { + const executeSelect = vi.fn().mockResolvedValue([{ id: 'viwCompat000000001', version: 3 }]); + const selectQuery = { + where: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + execute: executeSelect, + }; + const executeUpdate = vi.fn().mockResolvedValue(undefined); + const updateQuery = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + execute: executeUpdate, + }; + const db = { + selectFrom: vi.fn().mockReturnValue(selectQuery), + updateTable: vi.fn().mockReturnValue(updateQuery), + }; + const v2ContainerService = createV2ContainerService(db); + const clsState = new Map(); + const cls = { + getId: vi.fn().mockReturnValue('cls-request-id'), + get: vi.fn((key: string) => { + if (key === 'user.id') { + return 'usrCompatWriter00001'; + } + + return clsState.get(key); + }), + set: vi.fn((key: string, value: unknown) => { + clsState.set(key, value); + }), + }; + const service = new V2ViewCompatService(v2ContainerService as never, cls as never); + const ops = [ + ViewOpBuilder.editor.setViewProperty.build({ + key: 'options', + oldValue: { frozenFieldId: 'fldOldFrozen00001' }, + newValue: { frozenFieldId: 'fldNewFrozen00001' }, + }), + ]; + + await service.batchUpdateViewByOps('tblCompatTable0001', { + viwCompat000000001: ops, + }); + + expect(db.selectFrom).toHaveBeenCalledWith('view'); + expect(db.updateTable).toHaveBeenCalledWith('view'); + expect(updateQuery.set).toHaveBeenCalledWith({ + options: JSON.stringify({ frozenFieldId: 'fldNewFrozen00001' }), + version: 4, + last_modified_by: 'usrCompatWriter00001', + }); + expect(executeUpdate).toHaveBeenCalledTimes(1); + + const rawOpMaps = clsState.get('tx.rawOpMaps') as Array< + Record> + >; + expect(rawOpMaps).toHaveLength(1); + expect(Object.keys(rawOpMaps[0])).toEqual([`${IdPrefix.View}_tblCompatTable0001`]); + expect(rawOpMaps[0][`${IdPrefix.View}_tblCompatTable0001`].viwCompat000000001).toMatchObject({ + op: ops, + v: 3, + }); + }); +}); diff --git a/apps/nestjs-backend/src/features/v2/v2-view-compat.service.ts b/apps/nestjs-backend/src/features/v2/v2-view-compat.service.ts new file mode 100644 index 0000000000..3b7fe40815 --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-view-compat.service.ts @@ -0,0 +1,183 @@ +import { Injectable } from '@nestjs/common'; +import { + HttpErrorCode, + IdPrefix, + OpName, + ViewOpBuilder, + viewVoSchema, + type IOtOperation, + type ISetViewPropertyOpContext, +} from '@teable/core'; +import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; +import type { Kysely } from 'kysely'; +import { snakeCase } from 'lodash'; +import { ClsService } from 'nestjs-cls'; +import { fromZodError } from 'zod-validation-error'; +import { CustomHttpException } from '../../custom.exception'; +import type { IRawOp, IRawOpMap } from '../../share-db/interface'; +import type { IClsStore } from '../../types/cls'; +import { V2ContainerService } from './v2-container.service'; + +/* eslint-disable @typescript-eslint/naming-convention */ +type IV2ViewCompatDb = V1TeableDatabase & { + view: { + id: string; + table_id: string; + version: number; + deleted_time: Date | null; + last_modified_by: string | null; + options: string | null; + filter: string | null; + group: string | null; + sort: string | null; + share_id: string | null; + share_meta: string | null; + enable_share: boolean | null; + is_locked: boolean | null; + }; +}; +/* eslint-enable @typescript-eslint/naming-convention */ + +@Injectable() +export class V2ViewCompatService { + constructor( + private readonly v2ContainerService: V2ContainerService, + private readonly cls: ClsService + ) {} + + private async getDb(): Promise> { + const container = await this.v2ContainerService.getContainer(); + return container.resolve>(v2PostgresDbTokens.db); + } + + private mergeSetViewPropertyByOpContexts(opContexts: ISetViewPropertyOpContext[]) { + const result: Record = {}; + for (const opContext of opContexts) { + const { key, newValue } = opContext; + const parseResult = viewVoSchema.partial().safeParse({ [key]: newValue }); + if (!parseResult.success) { + throw new CustomHttpException( + fromZodError(parseResult.error).message, + HttpErrorCode.VALIDATION_ERROR, + { + localization: { + i18nKey: 'httpErrors.view.propertyParseError', + }, + } + ); + } + + const parsedValue = parseResult.data[key]; + result[key] = + parsedValue == null + ? null + : typeof parsedValue === 'object' + ? JSON.stringify(parsedValue) + : parsedValue; + } + + return result; + } + + private getUpdateViewProperties(ops: IOtOperation[]) { + const setPropertyOpContexts = ops.flatMap((op) => { + const context = ViewOpBuilder.detect(op); + if (!context) { + throw new CustomHttpException(`unknown view editing op`, HttpErrorCode.VALIDATION_ERROR, { + localization: { + i18nKey: 'httpErrors.custom.invalidOperation', + }, + }); + } + + if (context.name !== OpName.SetViewProperty) { + return []; + } + + return [context as ISetViewPropertyOpContext]; + }); + + return this.mergeSetViewPropertyByOpContexts(setPropertyOpContexts); + } + + private saveRawOps( + tableId: string, + dataList: { docId: string; version: number; data?: unknown }[] + ): IRawOpMap { + const collection = `${IdPrefix.View}_${tableId}`; + const rawOpMap: IRawOpMap = { [collection]: {} }; + const baseRaw = { + src: this.cls.getId() || 'unknown', + seq: 1, + m: { + ts: Date.now(), + }, + }; + + dataList.forEach(({ docId, version, data }) => { + rawOpMap[collection][docId] = { + ...baseRaw, + op: data as IOtOperation[], + v: version, + } as IRawOp; + }); + + const prevMap = this.cls.get('tx.rawOpMaps') || []; + prevMap.push(rawOpMap); + this.cls.set('tx.rawOpMaps', prevMap); + return rawOpMap; + } + + async batchUpdateViewByOps(tableId: string, opsMap: { [viewId: string]: IOtOperation[] }) { + const updatedViewIds = Object.keys(opsMap); + if (!updatedViewIds.length) { + return; + } + + const db = await this.getDb(); + const views = await db + .selectFrom('view') + .where('id', 'in', updatedViewIds) + .where('table_id', '=', tableId) + .where('deleted_time', 'is', null) + .select(['id', 'version']) + .execute(); + + const userId = this.cls.get('user.id') ?? null; + const updatedViews: { docId: string; version: number; data: IOtOperation[] }[] = []; + + for (const view of views) { + const properties = this.getUpdateViewProperties(opsMap[view.id] ?? []); + if (!Object.keys(properties).length) { + continue; + } + + const dbValues = Object.fromEntries( + Object.entries(properties).map(([key, value]) => [snakeCase(key), value]) + ); + + await db + .updateTable('view') + .set({ + ...dbValues, + version: view.version + 1, + last_modified_by: userId, + }) + .where('id', '=', view.id) + .execute(); + + updatedViews.push({ + docId: view.id, + version: view.version, + data: opsMap[view.id] ?? [], + }); + } + + if (!updatedViews.length) { + return; + } + + this.saveRawOps(tableId, updatedViews); + } +} diff --git a/apps/nestjs-backend/src/features/v2/v2.module.ts b/apps/nestjs-backend/src/features/v2/v2.module.ts index 09f7c4dd44..4ac5ce534f 100644 --- a/apps/nestjs-backend/src/features/v2/v2.module.ts +++ b/apps/nestjs-backend/src/features/v2/v2.module.ts @@ -9,12 +9,13 @@ import { ViewModule } from '../view/view.module'; import { V2ActionTriggerService } from './v2-action-trigger.service'; import { V2BaseNodeCompatService } from './v2-base-node-compat.service'; import { V2ContainerService } from './v2-container.service'; -import { V2Controller } from './v2.controller'; import { V2ExecutionContextFactory } from './v2-execution-context.factory'; import { V2FieldDeleteCompatService } from './v2-field-delete-compat.service'; import { V2OpenApiController } from './v2-openapi.controller'; import { V2RecordHistoryService } from './v2-record-history.service'; import { V2UserRenamePropagationService } from './v2-user-rename-propagation.service'; +import { V2ViewCompatService } from './v2-view-compat.service'; +import { V2Controller } from './v2.controller'; const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; @@ -105,6 +106,7 @@ const toErrorMessage = (body: unknown): string => { V2UserRenamePropagationService, V2FieldDeleteCompatService, V2RecordHistoryService, + V2ViewCompatService, UndoRedoStackService, ], exports: [V2ContainerService, V2ExecutionContextFactory, V2UserRenamePropagationService], diff --git a/apps/nestjs-backend/src/tracing.ts b/apps/nestjs-backend/src/tracing.ts index 0d7baf60a5..c390745869 100644 --- a/apps/nestjs-backend/src/tracing.ts +++ b/apps/nestjs-backend/src/tracing.ts @@ -302,33 +302,27 @@ const buckets = (boundaries: number[]) => ({ type: AggregationType.EXPLICIT_BUCKET_HISTOGRAM, boundaries }) as const; const metricViews = [ - // Drop old semconv duplicates (replaced by http.*.request.duration) + // Drop inbound HTTP metrics — 200+ routes cause cardinality explosion; + // traces already provide per-request latency/status via SigNoz APM. { instrumentName: 'http.server.duration', aggregation: drop }, { instrumentName: 'http.client.duration', aggregation: drop }, + { instrumentName: 'http.server.request.duration', aggregation: drop }, - // Reduce high-cardinality auto-instrumented histograms from 14 → 5~6 buckets - // 1ms=cached, 5ms=indexed, 25ms=scan, 100ms=slow, 1s=very-slow - // Keep only operation name + system; drop db.namespace, server.address/port, etc. - { - instrumentName: 'db.client.operation.duration', - aggregation: buckets([0.001, 0.005, 0.025, 0.1, 1]), - attributeKeys: ['db.operation.name', 'db.system'], - }, - // 50ms=fast, 250ms=normal, 1s=slow, 5s=very-slow, 30s=timeout + // Outbound HTTP — keep but drop server.address to cap cardinality + // (50+ webhook hosts and growing). Only method + status remain. { instrumentName: 'http.client.request.duration', aggregation: buckets([0.05, 0.25, 1, 5, 30]), - attributeKeys: ['http.request.method', 'server.address', 'http.response.status_code'], + attributeKeys: ['http.request.method', 'http.response.status_code'], }, - // 10ms=static, 50ms=fast-api, 250ms=normal, 1s=slow, 5s=very-slow, 10s=timeout - // Whitelist only route + method + status; drop url.scheme, server.address, - // server.port, network.protocol.version, error.type to slash cardinality. - // ~200 routes × 5 methods × 10 statuses × 10 ts = ~100k (vs ~2M without filter). + // Reduce high-cardinality auto-instrumented histograms from 14 → 5 buckets + // 1ms=cached, 5ms=indexed, 25ms=scan, 100ms=slow, 1s=very-slow + // Keep only operation name + system; drop db.namespace, server.address/port, etc. { - instrumentName: 'http.server.request.duration', - aggregation: buckets([0.01, 0.05, 0.25, 1, 5, 10]), - attributeKeys: ['http.route', 'http.request.method', 'http.response.status_code'], + instrumentName: 'db.client.operation.duration', + aggregation: buckets([0.001, 0.005, 0.025, 0.1, 1]), + attributeKeys: ['db.operation.name', 'db.system'], }, ]; diff --git a/apps/nestjs-backend/src/tracing/route-tracing.interceptor.ts b/apps/nestjs-backend/src/tracing/route-tracing.interceptor.ts index 48681c5c88..0c16c789eb 100644 --- a/apps/nestjs-backend/src/tracing/route-tracing.interceptor.ts +++ b/apps/nestjs-backend/src/tracing/route-tracing.interceptor.ts @@ -2,20 +2,10 @@ import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common'; import { Inject, Injectable, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { trace, TraceFlags } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; import type { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; - -const buildTraceLink = (traceId: string, baseUrl?: string) => { - const normalizedBaseUrl = baseUrl?.replace(/\/+$/, ''); - if (!normalizedBaseUrl) return null; - return `${normalizedBaseUrl}/trace/${traceId}?uiEmbed=v0`; -}; - -const buildTraceparent = (traceId: string, spanId: string, traceFlags: TraceFlags) => { - const sampled = (traceFlags & TraceFlags.SAMPLED) === TraceFlags.SAMPLED; - return `00-${traceId}-${spanId}-${sampled ? '01' : '00'}`; -}; +import { applyTraceResponseHeaders } from './trace-response-headers'; @Injectable() export class RouteTracingInterceptor implements NestInterceptor { @@ -53,17 +43,7 @@ export class RouteTracingInterceptor implements NestInterceptor { const spanName = `${httpMethod} ${route}`; span.updateName(spanName); - - // Set trace response headers - const spanContext = span.spanContext(); - response.setHeader( - 'traceparent', - buildTraceparent(spanContext.traceId, spanContext.spanId, spanContext.traceFlags) - ); - const traceLink = buildTraceLink(spanContext.traceId, this.traceLinkBaseUrl); - if (traceLink) { - response.setHeader('Link', `<${traceLink}>; rel="trace"`); - } + applyTraceResponseHeaders(response, this.traceLinkBaseUrl); } return next.handle().pipe( diff --git a/apps/nestjs-backend/src/tracing/trace-response-headers.spec.ts b/apps/nestjs-backend/src/tracing/trace-response-headers.spec.ts new file mode 100644 index 0000000000..b40b1e067f --- /dev/null +++ b/apps/nestjs-backend/src/tracing/trace-response-headers.spec.ts @@ -0,0 +1,64 @@ +import { TraceFlags } from '@opentelemetry/api'; +import { describe, expect, it, vi } from 'vitest'; +import { + applyTraceResponseHeaders, + buildTraceparent, + setResponseHeaderIfPossible, +} from './trace-response-headers'; + +const { getActiveSpan } = vi.hoisted(() => ({ + getActiveSpan: vi.fn(), +})); + +vi.mock('@opentelemetry/api', async () => { + const actual = await vi.importActual('@opentelemetry/api'); + return { + ...actual, + trace: { + ...actual.trace, + getActiveSpan, + }, + }; +}); + +describe('trace-response-headers', () => { + it('writes traceparent and Link when an active span is present', () => { + const response = { + headersSent: false, + writableEnded: false, + destroyed: false, + setHeader: vi.fn(), + }; + getActiveSpan.mockReturnValue({ + spanContext: () => ({ + traceId: '6193d505b7487e6a6481c164d8431217', + spanId: '454291e68f397f75', + traceFlags: TraceFlags.SAMPLED, + }), + }); + + applyTraceResponseHeaders(response, 'https://jaeger-pr-cloud-1560.sealoshzh.site'); + + expect(response.setHeader).toHaveBeenCalledWith( + 'traceparent', + buildTraceparent('6193d505b7487e6a6481c164d8431217', '454291e68f397f75', TraceFlags.SAMPLED) + ); + expect(response.setHeader).toHaveBeenCalledWith( + 'Link', + '; rel="trace"' + ); + }); + + it('does not write headers after the response has started', () => { + const response = { + headersSent: true, + writableEnded: false, + destroyed: false, + setHeader: vi.fn(), + }; + + setResponseHeaderIfPossible(response, 'Link', 'value'); + + expect(response.setHeader).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/nestjs-backend/src/tracing/trace-response-headers.ts b/apps/nestjs-backend/src/tracing/trace-response-headers.ts new file mode 100644 index 0000000000..4b6c7f82e1 --- /dev/null +++ b/apps/nestjs-backend/src/tracing/trace-response-headers.ts @@ -0,0 +1,48 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { trace, TraceFlags } from '@opentelemetry/api'; +import type { Response } from 'express'; + +export const buildTraceLink = (traceId: string, baseUrl?: string) => { + const normalizedBaseUrl = baseUrl?.replace(/\/+$/, ''); + if (!normalizedBaseUrl) return null; + return `${normalizedBaseUrl}/trace/${traceId}?uiEmbed=v0`; +}; + +export const buildTraceparent = (traceId: string, spanId: string, traceFlags: TraceFlags) => { + const sampled = (traceFlags & TraceFlags.SAMPLED) === TraceFlags.SAMPLED; + return `00-${traceId}-${spanId}-${sampled ? '01' : '00'}`; +}; + +export const setResponseHeaderIfPossible = ( + response: Pick, + name: string, + value: string +) => { + if (response.headersSent || response.writableEnded || response.destroyed) { + return; + } + + response.setHeader(name, value); +}; + +export const applyTraceResponseHeaders = ( + response: Pick, + traceLinkBaseUrl = process.env.TRACE_LINK_BASE_URL +) => { + const span = trace.getActiveSpan(); + if (!span) { + return; + } + + const spanContext = span.spanContext(); + setResponseHeaderIfPossible( + response, + 'traceparent', + buildTraceparent(spanContext.traceId, spanContext.spanId, spanContext.traceFlags) + ); + + const traceLink = buildTraceLink(spanContext.traceId, traceLinkBaseUrl); + if (traceLink) { + setResponseHeaderIfPossible(response, 'Link', `<${traceLink}>; rel="trace"`); + } +}; diff --git a/apps/nestjs-backend/src/types/i18n.generated.ts b/apps/nestjs-backend/src/types/i18n.generated.ts index 846d16e18a..fd38d21729 100644 --- a/apps/nestjs-backend/src/types/i18n.generated.ts +++ b/apps/nestjs-backend/src/types/i18n.generated.ts @@ -256,6 +256,7 @@ export type I18nTranslations = { "switchBase": string; "getMore": string; "copySuccess": string; + "clear": string; "download": string; "retry": string; "copyLink": string; @@ -678,6 +679,9 @@ export type I18nTranslations = { "description": string; "viewDetail": string; }; + "app": { + "previewAppError": string; + }; }; "help": { "title": string; @@ -773,6 +777,15 @@ export type I18nTranslations = { "chatModels": { "lg": string; "lgDescription": string; + "md": string; + "mdDescription": string; + "sm": string; + "smDescription": string; + "inheritHint": string; + "modelTiers": string; + "modelTiersDescription": string; + "allInheriting": string; + "customized": string; }; "actions": { "title": string; @@ -783,6 +796,7 @@ export type I18nTranslations = { "aiChat": { "title": string; "description": string; + "sandboxWarning": string; }; }; "chatModelTest": { @@ -1056,14 +1070,12 @@ export type I18nTranslations = { }; "app": { "domain": string; - "v0ApiKey": string; "customDomain": string; "customDomainDescription": string; "vercelToken": string; "vercelTokenDescription": string; "apiProxy": string; "apiProxyDescription": string; - "v0BaseUrl": string; "vercelBaseUrl": string; "aiGateway": string; "aiGatewayDescription": string; @@ -1104,11 +1116,6 @@ export type I18nTranslations = { "description": string; "errorTips": string; }; - "app": { - "title": string; - "description": string; - "errorTips": string; - }; "webSearch": { "title": string; "description": string; @@ -1135,16 +1142,15 @@ export type I18nTranslations = { "title": string; "description": string; }; - "appBuilderV0": { - "title": string; - "description": string; - }; "appBuilderDomain": { "title": string; "description": string; }; "appBuilderApiProxy": { "title": string; + }; + "sandboxVercel": { + "title": string; "description": string; }; }; @@ -1610,12 +1616,6 @@ export type I18nTranslations = { "id": string; }; "noPermissionToCreateBase": string; - "app": { - "title": string; - "description": string; - "previewAppError": string; - "sendErrorToAI": string; - }; "chat": { "serverError": string; "serverErrorHint": string; @@ -4097,15 +4097,37 @@ export type I18nTranslations = { "deleteFieldConfirmTitle": string; "deleting": string; "deleteSuccessful": string; + "deleteStream": { + "preparing": string; + "deleting": string; + "descriptionWithIssues": string; + "completedWithIssues": string; + "issuesBadge": string; + "chunkFailureTitle": string; + "chunkFailureSummary": string; + "chunkLabel": string; + "rowsLabel": string; + "partialFailureDescription": string; + "phaseLabel": { + "preparing": string; + "guarding": string; + "deleting": string; + "publishing": string; + "finalizing": string; + }; + }; "pasteFileFailed": string; "copyError": { "noFocus": string; "noPermission": string; }; + "clearFailed": string; "clearConfirmTitle": string; "clearConfirmDescription": string; "deleteRecordConfirmTitle": string; "deleteRecordConfirmDescription": string; + "duplicateRecordsConfirmTitle": string; + "duplicateRecordsConfirmDescription": string; "pasteConfirmTitle": string; "pasteConfirmDescription": string; "expandCommonDescription": string; @@ -4115,6 +4137,69 @@ export type I18nTranslations = { "deleteRecord": string; "clear": string; "conjunction": string; + "duplicating": string; + "deleteFailed": string; + "duplicateFailed": string; + "duplicateSuccessful": string; + "duplicateRecords": string; + "duplicateStream": { + "preparing": string; + "duplicating": string; + "descriptionWithIssues": string; + "completedWithIssues": string; + "issuesBadge": string; + "chunkFailureTitle": string; + "chunkFailureSummary": string; + "chunkLabel": string; + "rowsLabel": string; + "partialFailureDescription": string; + "phaseLabel": { + "preparing": string; + "guarding": string; + "processing": string; + "publishing": string; + "finalizing": string; + }; + }; + "pasteStream": { + "preparing": string; + "pasting": string; + "descriptionWithIssues": string; + "completedWithIssues": string; + "issuesBadge": string; + "chunkFailureTitle": string; + "chunkFailureSummary": string; + "chunkLabel": string; + "rowsLabel": string; + "partialFailureDescription": string; + "phaseLabel": { + "preparing": string; + "guarding": string; + "processing": string; + "publishing": string; + "finalizing": string; + }; + }; + "clearStream": { + "confirmDescription": string; + "preparing": string; + "clearing": string; + "descriptionWithIssues": string; + "completedWithIssues": string; + "issuesBadge": string; + "chunkFailureTitle": string; + "chunkFailureSummary": string; + "chunkLabel": string; + "rowsLabel": string; + "partialFailureDescription": string; + "phaseLabel": { + "preparing": string; + "guarding": string; + "processing": string; + "publishing": string; + "finalizing": string; + }; + }; "pasing": string; }; "graph": { @@ -4136,6 +4221,18 @@ export type I18nTranslations = { "runCheck": string; "recheck": string; "repair": string; + "repairWarnings": string; + "repairWarningsAndErrors": string; + "repairRule": string; + "repairUnavailable": string; + "manual": string; + "manualRepairNotice": string; + "manualRepairNoticeWithCount": string; + "manualRepairDialogTitle": string; + "manualRepairDialogDescription": string; + "manualRepairDialogReason": string; + "manualRepairDialogHint": string; + "manualRepairDialogClose": string; "checking": string; "repairing": string; "streamError": string; @@ -4165,6 +4262,8 @@ export type I18nTranslations = { "baseCheckCompleted": string; "repairCompleted": string; "baseRepairCompleted": string; + "skippedStatusNotSelected": string; + "skippedRepairUnavailable": string; }; "rule": { "column": string; @@ -4204,6 +4303,31 @@ export type I18nTranslations = { "systemColumnUnique": string; "systemColumnPrimaryKey": string; "systemColumnDefault": string; + "foreignKeyOrphanRows": string; + "foreignKeyOrphanRowsDescription": string; + "junctionForeignKeyMissing": string; + "junctionForeignKeyMissingDescription": string; + "junctionForeignKeyTargetTableMissing": string; + "junctionForeignKeyTargetTableMissingDescription": string; + "junctionForeignKeyOrphanRows": string; + "junctionForeignKeyOrphanRowsDescription": string; + "columnUniqueMissing": string; + "columnUniqueMissingDescription": string; + "columnUniqueIndexMismatch": string; + "columnUniqueIndexMismatchDescription": string; + "foreignKeyMissing": string; + "foreignKeyMissingDescription": string; + "foreignKeyTargetTableMissing": string; + "foreignKeyTargetTableMissingDescription": string; + "referenceMissing": string; + "referenceMissingDescription": string; + "symmetricFieldTargetMissing": string; + "symmetricFieldWrongType": string; + "symmetricFieldInvalidOptions": string; + "symmetricFieldMissingBackReference": string; + "symmetricFieldWrongBackReference": string; + "symmetricFieldDuplicateUsage": string; + "symmetricFieldDuplicateUsageDescription": string; }; "phase": { "check": string; @@ -4233,6 +4357,42 @@ export type I18nTranslations = { "manual": string; "skipped": string; }; + "repairMeta": { + "reason": { + "alreadyValid": string; + "manualRule": string; + "statementGenerationFailed": string; + "noStatements": string; + "symmetricFieldConflict": string; + "foreignKeyTargetTableMissing": string; + "foreignKeyOrphanRows": string; + "junctionForeignKeyTargetTableMissing": string; + "junctionForeignKeyOrphanRows": string; + }; + "description": { + "symmetricFieldConflict": string; + "foreignKeyTargetTableMissing": string; + "foreignKeyOrphanRows": string; + "junctionForeignKeyTargetTableMissing": string; + "junctionForeignKeyOrphanRows": string; + }; + "manual": { + "apply": string; + "symmetricField": { + "title": string; + "description": string; + "resolutionLabel": string; + "resolutionDescription": string; + "option": { + "keepCurrent": string; + "keepDuplicate": string; + "convertDuplicate": string; + }; + }; + }; + }; + "manualRepairPreview": string; + "manualRepairPreviewTip": string; }; "type": string; "message": string; @@ -4247,6 +4407,7 @@ export type I18nTranslations = { "ReferenceFieldNotFound": string; "UniqueIndexNotFound": string; "EmptyString": string; + "InvalidFilterOperator": string; }; }; "index": { @@ -4452,6 +4613,7 @@ export type I18nTranslations = { "expandAllGroups": string; "collapseAllGroups": string; "addToChat": string; + "duplicateRecords": string; "duplicateField": string; "downloadAllAttachments": string; }; @@ -4535,8 +4697,12 @@ export type I18nTranslations = { "shareLink": string; "linkHolderLabel": string; "linkHolderCanView": string; + "linkHolderCanViewDesc": string; "linkHolderCanEdit": string; + "linkHolderCanEditDesc": string; "linkHolderCanCopyAndSave": string; + "linkHolderCanCopyAndSaveDesc": string; + "editRequiresLogin": string; "passwordProtection": string; "enterPassword": string; "selectNodes": string; @@ -5155,6 +5321,13 @@ export type I18nTranslations = { "queueFull": string; "messageQueued": string; }; + "effort": { + "title": string; + "low": string; + "medium": string; + "high": string; + "max": string; + }; }; "download": { "allAttachments": { diff --git a/apps/nestjs-backend/test/base-share.e2e-spec.ts b/apps/nestjs-backend/test/base-share.e2e-spec.ts index bfd548a307..978cc04d63 100644 --- a/apps/nestjs-backend/test/base-share.e2e-spec.ts +++ b/apps/nestjs-backend/test/base-share.e2e-spec.ts @@ -1,7 +1,7 @@ import type { INestApplication } from '@nestjs/common'; import type { IFieldRo, ILookupOptionsRo } from '@teable/core'; import { FieldType, Relationship } from '@teable/core'; -import type { IBaseNodeVo, IGetBaseShareVo } from '@teable/openapi'; +import type { IBaseNodeVo, IGetBaseShareVo, ITablePermissionVo } from '@teable/openapi'; import { BASE_SHARE_AUTH, BASE_SHARE_ID_HEADER, @@ -10,13 +10,16 @@ import { createBase, createBaseNode, createBaseShare, + CREATE_RECORD, createField, createSpace, + DELETE_RECORD_URL, deleteBaseShare, deleteSpace, GET_BASE_NODE_LIST, GET_BASE_NODE_TREE, GET_BASE_SHARE, + GET_TABLE_PERMISSION, getBaseNodeList, getBaseShareByNodeId, getFields, @@ -24,10 +27,13 @@ import { listBaseShare, moveBaseNode, refreshBaseShare, + UPDATE_RECORD, updateBaseShare, urlBuilder, } from '@teable/openapi'; +import type { AxiosInstance } from 'axios'; import { createAnonymousUserAxios } from './utils/axios-instance/anonymous-user'; +import { createNewUserAxios } from './utils/axios-instance/new-user'; import { getError } from './utils/get-error'; import { createTable, @@ -1279,4 +1285,253 @@ describe('BaseShareController (e2e)', () => { expect(nodeIds.has(rootTableNodeId)).toBe(false); }); }); + + describe('BaseShare - allowEdit permission', () => { + let editBaseId: string; + let editTableId: string; + let editTableNodeId: string; + let editFolderNodeId: string; + let loggedInUser: AxiosInstance; + const createdShareIds: string[] = []; + + beforeAll(async () => { + const base = await createBase({ + name: 'allowEdit-e2e', + spaceId: globalThis.testConfig.spaceId, + }).then((res) => res.data); + editBaseId = base.id; + + const table = await createTable(editBaseId, { name: 'edit-table' }); + editTableId = table.id; + + const folder = await createBaseNode(editBaseId, { + resourceType: BaseNodeResourceType.Folder, + name: 'edit-folder', + }); + editFolderNodeId = folder.data.id; + + const nodeList = await getBaseNodeList(editBaseId); + const tableNode = nodeList.data.find((n) => n.resourceId === editTableId); + if (!tableNode) throw new Error('Table node not found'); + editTableNodeId = tableNode.id; + + loggedInUser = await createNewUserAxios({ + email: 'allow-edit-e2e@test.com', + password: 'TestPassword123!', + }); + }); + + afterAll(async () => { + await permanentDeleteBase(editBaseId); + }); + + afterEach(async () => { + for (const shareId of createdShareIds) { + await deleteBaseShare(editBaseId, shareId).catch(() => undefined); + } + createdShareIds.length = 0; + }); + + it('should create share with allowEdit for table node', async () => { + const res = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: true, + }); + createdShareIds.push(res.data.shareId); + expect(res.status).toEqual(201); + expect(res.data.allowEdit).toBe(true); + // allowEdit implies allowSave is false (mutually exclusive) + expect(res.data.allowSave).toBe(false); + }); + + it('should reject allowEdit for folder node', async () => { + const error = await getError(() => + createBaseShare(editBaseId, { + nodeId: editFolderNodeId, + allowEdit: true, + }) + ); + expect(error?.status).toEqual(400); + }); + + it('should enforce allowEdit/allowSave mutual exclusivity on create', async () => { + const res = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: true, + allowSave: true, + }); + createdShareIds.push(res.data.shareId); + // allowEdit takes precedence + expect(res.data.allowEdit).toBe(true); + expect(res.data.allowSave).toBe(false); + }); + + it('should enforce allowEdit/allowSave mutual exclusivity on update', async () => { + const share = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: false, + allowSave: true, + }); + createdShareIds.push(share.data.shareId); + expect(share.data.allowSave).toBe(true); + expect(share.data.allowEdit).toBe(false); + + // Switch to allowEdit + const updated = await updateBaseShare(editBaseId, share.data.shareId, { + allowEdit: true, + }); + expect(updated.data.allowEdit).toBe(true); + expect(updated.data.allowSave).toBe(false); + }); + + it('should re-enable soft-deleted share and inherit old settings', async () => { + // Create a share with specific settings + const share = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: true, + allowCopy: true, + }); + createdShareIds.push(share.data.shareId); + expect(share.data.allowEdit).toBe(true); + expect(share.data.allowCopy).toBe(true); + + // Soft-delete it + await deleteBaseShare(editBaseId, share.data.shareId); + + // Re-create with same nodeId — should re-enable and inherit old settings + const reEnabled = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + }); + createdShareIds.push(reEnabled.data.shareId); + expect(reEnabled.data.enabled).toBe(true); + expect(reEnabled.data.allowEdit).toBe(true); + expect(reEnabled.data.allowCopy).toBe(true); + }); + + it('should grant editor-level permissions to logged-in user with allowEdit', async () => { + const share = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: true, + }); + createdShareIds.push(share.data.shareId); + + const permRes = await loggedInUser.get( + urlBuilder(GET_TABLE_PERMISSION, { baseId: editBaseId, tableId: editTableId }), + { headers: { [BASE_SHARE_ID_HEADER]: share.data.shareId } } + ); + expect(permRes.status).toEqual(200); + // Editor-level: can create/update/delete records + expect(permRes.data.record['record|create']).toBe(true); + expect(permRes.data.record['record|update']).toBe(true); + expect(permRes.data.record['record|delete']).toBe(true); + // Excluded: view|share must be denied + expect(permRes.data.view['view|share']).toBeFalsy(); + }); + + it('should only grant read-only permissions to anonymous user even with allowEdit', async () => { + const share = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: true, + }); + createdShareIds.push(share.data.shareId); + + const permRes = await anonymousUser.get( + urlBuilder(GET_TABLE_PERMISSION, { baseId: editBaseId, tableId: editTableId }), + { headers: { [BASE_SHARE_ID_HEADER]: share.data.shareId } } + ); + expect(permRes.status).toEqual(200); + // Anonymous user should NOT have write permissions + expect(permRes.data.record['record|create']).toBeFalsy(); + expect(permRes.data.record['record|update']).toBeFalsy(); + expect(permRes.data.record['record|delete']).toBeFalsy(); + }); + + it('should allow logged-in user to create records via allowEdit share', async () => { + const share = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: true, + }); + createdShareIds.push(share.data.shareId); + + const fields = await getFields(editTableId); + const firstField = fields.data[0]; + + const createRes = await loggedInUser.post( + urlBuilder(CREATE_RECORD, { tableId: editTableId }), + { records: [{ fields: { [firstField.id]: 'share-edit-test' } }], fieldKeyType: 'id' }, + { headers: { [BASE_SHARE_ID_HEADER]: share.data.shareId } } + ); + expect(createRes.status).toEqual(201); + expect(createRes.data.records).toHaveLength(1); + + const recordId = createRes.data.records[0].id; + + // Update the record + const updateRes = await loggedInUser.patch( + urlBuilder(UPDATE_RECORD, { tableId: editTableId, recordId }), + { record: { fields: { [firstField.id]: 'updated-via-share' } }, fieldKeyType: 'id' }, + { headers: { [BASE_SHARE_ID_HEADER]: share.data.shareId } } + ); + expect(updateRes.status).toEqual(200); + + // Delete the record + const deleteRes = await loggedInUser.delete( + urlBuilder(DELETE_RECORD_URL, { tableId: editTableId, recordId }), + { headers: { [BASE_SHARE_ID_HEADER]: share.data.shareId } } + ); + expect(deleteRes.status).toEqual(200); + }); + + it('should deny anonymous user record creation even with allowEdit', async () => { + const share = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: true, + }); + createdShareIds.push(share.data.shareId); + + const fields = await getFields(editTableId); + const firstField = fields.data[0]; + + const error = await getError(() => + anonymousUser.post( + urlBuilder(CREATE_RECORD, { tableId: editTableId }), + { records: [{ fields: { [firstField.id]: 'should-fail' } }], fieldKeyType: 'id' }, + { headers: { [BASE_SHARE_ID_HEADER]: share.data.shareId } } + ) + ); + expect(error?.status).toEqual(403); + }); + + it('should cap permissions at share level even for base owner', async () => { + // The default test user is the base owner + const share = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: true, + }); + createdShareIds.push(share.data.shareId); + + // Access via share header — should get editor-level, not owner-level + const permRes = await loggedInUser.get( + urlBuilder(GET_TABLE_PERMISSION, { baseId: editBaseId, tableId: editTableId }), + { headers: { [BASE_SHARE_ID_HEADER]: share.data.shareId } } + ); + expect(permRes.status).toEqual(200); + // view|share is excluded from share permissions, even though owner normally has it + expect(permRes.data.view['view|share']).toBeFalsy(); + }); + + it('should include allowEdit in shareMeta via public API', async () => { + const share = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: true, + }); + createdShareIds.push(share.data.shareId); + + const res = await anonymousUser.get( + urlBuilder(GET_BASE_SHARE, { shareId: share.data.shareId }) + ); + expect(res.status).toEqual(200); + expect(res.data.shareMeta.allowEdit).toBe(true); + }); + }); }); diff --git a/apps/nestjs-backend/test/conditional-rollup.e2e-spec.ts b/apps/nestjs-backend/test/conditional-rollup.e2e-spec.ts index 9907fbcfe7..4356001c48 100644 --- a/apps/nestjs-backend/test/conditional-rollup.e2e-spec.ts +++ b/apps/nestjs-backend/test/conditional-rollup.e2e-spec.ts @@ -4366,138 +4366,4 @@ describe('OpenAPI Conditional Rollup field (e2e)', () => { expect(bobSummary.fields[sumWithoutExcludedField.id]).toEqual(7); }); }); - - describe('v2 update field hasError propagation', () => { - const isForceV2 = process.env.FORCE_V2_ALL === 'true'; - const itV2Only = isForceV2 ? it : it.skip; - - itV2Only('marks conditional rollup as errored when filter field is deleted', async () => { - const foreign = await createTable(baseId, { - name: 'V2CondRollupFilterDel_Foreign', - fields: [ - { name: 'Title', type: FieldType.SingleLineText } as IFieldRo, - { name: 'Amount', type: FieldType.Number } as IFieldRo, - { name: 'Status', type: FieldType.SingleLineText } as IFieldRo, - ], - records: [ - { fields: { Title: 'Alpha', Amount: 2, Status: 'Active' } }, - { fields: { Title: 'Beta', Amount: 4, Status: 'Active' } }, - { fields: { Title: 'Gamma', Amount: 6, Status: 'Inactive' } }, - ], - }); - const host = await createTable(baseId, { - name: 'V2CondRollupFilterDel_Host', - fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo], - records: [{ fields: { Label: 'Row 1' } }], - }); - const amountId = foreign.fields.find((f) => f.name === 'Amount')!.id; - const statusId = foreign.fields.find((f) => f.name === 'Status')!.id; - - try { - // Create conditional rollup without filter - let rollupField = await createField(host.id, { - name: 'Filtered Sum', - type: FieldType.ConditionalRollup, - options: { - foreignTableId: foreign.id, - lookupFieldId: amountId, - expression: 'sum({values})', - }, - } as IFieldRo); - - // Convert to add a filter referencing statusId - rollupField = await convertField(host.id, rollupField.id, { - name: 'Filtered Sum', - type: FieldType.ConditionalRollup, - options: { - foreignTableId: foreign.id, - lookupFieldId: amountId, - expression: 'sum({values})', - filter: { - conjunction: 'and', - filterSet: [{ fieldId: statusId, operator: 'is', value: 'Active' }], - }, - }, - } as IFieldRo); - - const hostRecord = await getRecord(host.id, host.records[0].id); - expect(hostRecord.fields[rollupField.id]).toEqual(6); - - // Delete the filter field from the foreign table - await deleteField(foreign.id, statusId); - - const hostFields = await getFields(host.id); - const erroredField = hostFields.find((f) => f.id === rollupField.id)!; - expect(erroredField.hasError).toBe(true); - } finally { - await permanentDeleteTable(baseId, host.id); - await permanentDeleteTable(baseId, foreign.id); - } - }); - - itV2Only( - 'marks conditional rollup as errored when lookup field type becomes incompatible', - async () => { - const foreign = await createTable(baseId, { - name: 'V2CondRollupTypeErr_Foreign', - fields: [ - { name: 'Title', type: FieldType.SingleLineText } as IFieldRo, - { name: 'Amount', type: FieldType.Number } as IFieldRo, - ], - records: [ - { fields: { Title: 'Alpha', Amount: 2 } }, - { fields: { Title: 'Beta', Amount: 4 } }, - { fields: { Title: 'Gamma', Amount: 6 } }, - ], - }); - const host = await createTable(baseId, { - name: 'V2CondRollupTypeErr_Host', - fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo], - records: [{ fields: { Label: 'Row 1' } }], - }); - const amountId = foreign.fields.find((f) => f.name === 'Amount')!.id; - const hostRecordId = host.records[0].id; - - try { - const rollupField = await createField(host.id, { - name: 'Sum Amount', - type: FieldType.ConditionalRollup, - options: { - foreignTableId: foreign.id, - lookupFieldId: amountId, - expression: 'sum({values})', - }, - } as IFieldRo); - - const baseline = await getRecord(host.id, hostRecordId); - expect(baseline.fields[rollupField.id]).toEqual(12); - - // Convert numeric lookup field to SingleSelect (incompatible with sum) - await convertField(foreign.id, amountId, { - name: 'Amount (Select)', - type: FieldType.SingleSelect, - options: { - choices: [ - { name: '2', color: Colors.Blue }, - { name: '4', color: Colors.Green }, - { name: '6', color: Colors.Orange }, - ], - }, - } as IFieldRo); - - let erroredField: IFieldVo | undefined; - for (let attempt = 0; attempt < 10; attempt++) { - const fieldsAfterConversion = await getFields(host.id); - erroredField = fieldsAfterConversion.find((f) => f.id === rollupField.id); - if (erroredField?.hasError) break; - await new Promise((resolve) => setTimeout(resolve, 200)); - } - expect(erroredField?.hasError).toBe(true); - } finally { - await permanentDeleteTable(baseId, host.id); - await permanentDeleteTable(baseId, foreign.id); - } - } - ); - }); }); diff --git a/apps/nestjs-backend/test/field-converting.e2e-spec.ts b/apps/nestjs-backend/test/field-converting.e2e-spec.ts index ca9aa28bcf..458da47be6 100644 --- a/apps/nestjs-backend/test/field-converting.e2e-spec.ts +++ b/apps/nestjs-backend/test/field-converting.e2e-spec.ts @@ -3866,95 +3866,6 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => { } ); - it.skipIf(!canRunCanaryV2)( - 'should remove conditional lookup sort and limit when convert payload omits them in v2', - async () => { - const statusField = await createField(table2.id, { - name: 'Status', - type: FieldType.SingleLineText, - }); - const scoreField = await createField(table2.id, { - name: 'Score', - type: FieldType.Number, - }); - const statusFilterField = await createField(table1.id, { - name: 'Status Filter', - type: FieldType.SingleLineText, - }); - - await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'row-1'); - await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'row-2'); - await updateRecordByApi(table2.id, table2.records[0].id, statusField.id, 'Active'); - await updateRecordByApi(table2.id, table2.records[1].id, statusField.id, 'Active'); - await updateRecordByApi(table2.id, table2.records[0].id, scoreField.id, 10); - await updateRecordByApi(table2.id, table2.records[1].id, scoreField.id, 20); - await updateRecordByApi(table1.id, table1.records[0].id, statusFilterField.id, 'Active'); - - const lookupField = await createField(table1.id, { - type: FieldType.SingleLineText, - isLookup: true, - isConditionalLookup: true, - lookupOptions: { - foreignTableId: table2.id, - lookupFieldId: table2.fields[0].id, - filter: { - conjunction: 'and', - filterSet: [ - { - fieldId: statusField.id, - operator: 'is', - value: { type: 'field', fieldId: statusFilterField.id }, - }, - ], - }, - sort: { - fieldId: scoreField.id, - order: SortFunc.Desc, - }, - limit: 1, - }, - }); - - const beforeRecord = await getRecord(table1.id, table1.records[0].id); - expect(beforeRecord.fields[lookupField.id]).toEqual(['row-2']); - - const updatedField = await convertFieldByCanaryV2(table1.id, lookupField.id, { - type: FieldType.SingleLineText, - isLookup: true, - isConditionalLookup: true, - lookupOptions: { - foreignTableId: table2.id, - lookupFieldId: table2.fields[0].id, - filter: { - conjunction: 'and', - filterSet: [ - { - fieldId: statusField.id, - operator: 'is', - value: { type: 'field', fieldId: statusFilterField.id }, - }, - ], - }, - }, - }); - - const updatedLookupOptions = updatedField.lookupOptions as IConditionalLookupOptions; - expect(updatedLookupOptions.sort).toBeUndefined(); - expect(updatedLookupOptions.limit).toBeUndefined(); - - const refreshedField = await getField(table1.id, lookupField.id); - const refreshedLookupOptions = refreshedField.lookupOptions as IConditionalLookupOptions; - expect(refreshedLookupOptions.sort).toBeUndefined(); - expect(refreshedLookupOptions.limit).toBeUndefined(); - - const afterRecord = await getRecord(table1.id, table1.records[0].id); - expect([...(afterRecord.fields[lookupField.id] as string[])].sort()).toEqual([ - 'row-1', - 'row-2', - ]); - } - ); - it.skipIf(!canRunCanaryV2)( 'should remove conditional lookup sort and limit for formula inner type when switch is off in v2', async () => { @@ -4089,6 +4000,52 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => { } ); + it.skipIf(!canRunCanaryV2)( + 'should remove link filter options when convert payload omits them in v2', + async () => { + const statusField = await createField(table2.id, { + name: 'Status', + type: FieldType.SingleLineText, + }); + await updateRecordByApi(table2.id, table2.records[0].id, statusField.id, 'Active'); + + const linkField = await createField(table1.id, { + type: FieldType.Link, + options: { + relationship: Relationship.ManyOne, + foreignTableId: table2.id, + lookupFieldId: table2.fields[0].id, + filterByViewId: table2.defaultViewId, + visibleFieldIds: [table2.fields[0].id], + filter: { + conjunction: 'and', + filterSet: [{ fieldId: statusField.id, operator: 'is', value: 'Active' }], + }, + }, + }); + + const updatedField = await convertFieldByCanaryV2(table1.id, linkField.id, { + type: FieldType.Link, + options: { + relationship: Relationship.ManyOne, + foreignTableId: table2.id, + lookupFieldId: table2.fields[0].id, + }, + }); + + const updatedOptions = updatedField.options as ILinkFieldOptions; + expect(updatedOptions.filterByViewId).toBeUndefined(); + expect(updatedOptions.visibleFieldIds).toBeUndefined(); + expect(updatedOptions.filter).toBeUndefined(); + + const refreshedField = await getField(table1.id, linkField.id); + const refreshedOptions = refreshedField.options as ILinkFieldOptions; + expect(refreshedOptions.filterByViewId).toBeUndefined(); + expect(refreshedOptions.visibleFieldIds).toBeUndefined(); + expect(refreshedOptions.filter).toBeUndefined(); + } + ); + it.skipIf(!canRunCanaryV2)( 'should preserve formula datetime formatting when converting conditional lookup inner type in v2', async () => { @@ -4234,52 +4191,6 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => { } ); - it.skipIf(!canRunCanaryV2)( - 'should remove link filter options when convert payload omits them in v2', - async () => { - const statusField = await createField(table2.id, { - name: 'Status', - type: FieldType.SingleLineText, - }); - await updateRecordByApi(table2.id, table2.records[0].id, statusField.id, 'Active'); - - const linkField = await createField(table1.id, { - type: FieldType.Link, - options: { - relationship: Relationship.ManyOne, - foreignTableId: table2.id, - lookupFieldId: table2.fields[0].id, - filterByViewId: table2.defaultViewId, - visibleFieldIds: [table2.fields[0].id], - filter: { - conjunction: 'and', - filterSet: [{ fieldId: statusField.id, operator: 'is', value: 'Active' }], - }, - }, - }); - - const updatedField = await convertFieldByCanaryV2(table1.id, linkField.id, { - type: FieldType.Link, - options: { - relationship: Relationship.ManyOne, - foreignTableId: table2.id, - lookupFieldId: table2.fields[0].id, - }, - }); - - const updatedOptions = updatedField.options as ILinkFieldOptions; - expect(updatedOptions.filterByViewId).toBeUndefined(); - expect(updatedOptions.visibleFieldIds).toBeUndefined(); - expect(updatedOptions.filter).toBeUndefined(); - - const refreshedField = await getField(table1.id, linkField.id); - const refreshedOptions = refreshedField.options as ILinkFieldOptions; - expect(refreshedOptions.filterByViewId).toBeUndefined(); - expect(refreshedOptions.visibleFieldIds).toBeUndefined(); - expect(refreshedOptions.filter).toBeUndefined(); - } - ); - it('should change lookupField from link to text', async () => { const linkFieldRo: IFieldRo = { type: FieldType.Link, @@ -4701,93 +4612,6 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => { await convertField(table2.id, rollupField.id, rollupFieldRo2); }); - - it.skipIf(!canRunCanaryV2)( - 'should remove conditional rollup sort and limit when convert payload omits them in v2', - async () => { - const statusField = await createField(table2.id, { - name: 'Status', - type: FieldType.SingleLineText, - }); - const scoreField = await createField(table2.id, { - name: 'Score', - type: FieldType.Number, - }); - const statusFilterField = await createField(table1.id, { - name: 'Status Filter', - type: FieldType.SingleLineText, - }); - - await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'row-1'); - await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'row-2'); - await updateRecordByApi(table2.id, table2.records[0].id, statusField.id, 'Active'); - await updateRecordByApi(table2.id, table2.records[1].id, statusField.id, 'Active'); - await updateRecordByApi(table2.id, table2.records[0].id, scoreField.id, 10); - await updateRecordByApi(table2.id, table2.records[1].id, scoreField.id, 20); - await updateRecordByApi(table1.id, table1.records[0].id, statusFilterField.id, 'Active'); - - const conditionalRollupField = await createField(table1.id, { - type: FieldType.ConditionalRollup, - options: { - foreignTableId: table2.id, - lookupFieldId: table2.fields[0].id, - expression: 'array_compact({values})', - filter: { - conjunction: 'and', - filterSet: [ - { - fieldId: statusField.id, - operator: 'is', - value: { type: 'field', fieldId: statusFilterField.id }, - }, - ], - }, - sort: { - fieldId: scoreField.id, - order: SortFunc.Desc, - }, - limit: 1, - } as IConditionalRollupFieldOptions, - }); - - const beforeRecord = await getRecord(table1.id, table1.records[0].id); - expect(beforeRecord.fields[conditionalRollupField.id]).toEqual(['row-2']); - - const updatedField = await convertFieldByCanaryV2(table1.id, conditionalRollupField.id, { - type: FieldType.ConditionalRollup, - options: { - foreignTableId: table2.id, - lookupFieldId: table2.fields[0].id, - expression: 'array_compact({values})', - filter: { - conjunction: 'and', - filterSet: [ - { - fieldId: statusField.id, - operator: 'is', - value: { type: 'field', fieldId: statusFilterField.id }, - }, - ], - }, - } as IConditionalRollupFieldOptions, - }); - - const updatedOptions = updatedField.options as IConditionalRollupFieldOptions; - expect(updatedOptions.sort).toBeUndefined(); - expect(updatedOptions.limit).toBeUndefined(); - - const refreshedField = await getField(table1.id, conditionalRollupField.id); - const refreshedOptions = refreshedField.options as IConditionalRollupFieldOptions; - expect(refreshedOptions.sort).toBeUndefined(); - expect(refreshedOptions.limit).toBeUndefined(); - - const afterRecord = await getRecord(table1.id, table1.records[0].id); - expect([...(afterRecord.fields[conditionalRollupField.id] as string[])].sort()).toEqual([ - 'row-1', - 'row-2', - ]); - } - ); }); describe('rollup conversion regressions', () => { diff --git a/apps/nestjs-backend/test/field.e2e-spec.ts b/apps/nestjs-backend/test/field.e2e-spec.ts index 4b68dae6fe..705562bc48 100644 --- a/apps/nestjs-backend/test/field.e2e-spec.ts +++ b/apps/nestjs-backend/test/field.e2e-spec.ts @@ -21,7 +21,6 @@ import { } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import type { ITableFullVo } from '@teable/openapi'; -import { convertField } from '@teable/openapi'; import type { Knex } from 'knex'; import type { FieldCreateEvent } from '../src/event-emitter/events'; import { Events } from '../src/event-emitter/events'; @@ -38,7 +37,19 @@ import { getRecords, } from './utils/init-app'; -const isForceV2 = process.env.FORCE_V2_ALL === 'true'; +const withForceV2All = async (callback: () => Promise) => { + const previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + try { + return await callback(); + } finally { + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + } +}; describe('OpenAPI FieldController (e2e)', () => { let app: INestApplication; @@ -315,79 +326,79 @@ describe('OpenAPI FieldController (e2e)', () => { }); describe('v2 lookup option sync', () => { - const itIfForceV2 = isForceV2 ? it : it.skip; - - itIfForceV2('ignores API-supplied choices for lookup-backed single select fields', async () => { + it('ignores API-supplied choices for lookup-backed single select fields', async () => { let hostTable: ITableFullVo | undefined; let foreignTable: ITableFullVo | undefined; try { - foreignTable = await createTable(baseId, { - name: 'lookup-option-sync-foreign', - fields: [ - { name: 'Title', type: FieldType.SingleLineText }, - { - name: 'Importance', - type: FieldType.SingleSelect, - options: { - choices: [ - { id: 'choLookupCore', name: '核心', color: Colors.Blue }, - { id: 'choLookupImportant', name: '重要', color: Colors.Green }, - { id: 'choLookupReference', name: '参考', color: Colors.Orange }, - ], + await withForceV2All(async () => { + foreignTable = await createTable(baseId, { + name: 'lookup-option-sync-foreign', + fields: [ + { name: 'Title', type: FieldType.SingleLineText }, + { + name: 'Importance', + type: FieldType.SingleSelect, + options: { + choices: [ + { id: 'choLookupCore', name: '核心', color: Colors.Blue }, + { id: 'choLookupImportant', name: '重要', color: Colors.Green }, + { id: 'choLookupReference', name: '参考', color: Colors.Orange }, + ], + }, }, - }, - ], - }); - hostTable = await createTable(baseId, { - name: 'lookup-option-sync-host', - fields: [{ name: 'Name', type: FieldType.SingleLineText }], - }); + ], + }); + hostTable = await createTable(baseId, { + name: 'lookup-option-sync-host', + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + }); - const foreignImportanceField = foreignTable.fields.find( - (field) => field.name === 'Importance' - )!; - const expectedChoices = ( - foreignImportanceField.options as { - choices: Array<{ id: string; name: string; color: string }>; - } - ).choices; - - const linkField = await createField(hostTable.id, { - name: 'Related', - type: FieldType.Link, - options: { - relationship: Relationship.ManyMany, - foreignTableId: foreignTable.id, - } as ILinkFieldOptionsRo, - }); + const foreignImportanceField = foreignTable.fields.find( + (field) => field.name === 'Importance' + )!; + const expectedChoices = ( + foreignImportanceField.options as { + choices: Array<{ id: string; name: string; color: string }>; + } + ).choices; - const createdLookupField = await createField(hostTable.id, { - name: '章节重要程度', - type: FieldType.SingleSelect, - isLookup: true, - lookupOptions: { - foreignTableId: foreignTable.id, - lookupFieldId: foreignImportanceField.id, - linkFieldId: linkField.id, - } as ILookupOptionsRo, - options: { - choices: [ - { id: 'choBroken1', name: 'Option 1', color: Colors.Blue }, - { id: 'choBroken2', name: 'Option 2', color: Colors.Green }, - ], - }, - }); + const linkField = await createField(hostTable.id, { + name: 'Related', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: foreignTable.id, + } as ILinkFieldOptionsRo, + }); - expect(createdLookupField.options).toEqual({ - choices: expectedChoices, - }); + const createdLookupField = await createField(hostTable.id, { + name: '章节重要程度', + type: FieldType.SingleSelect, + isLookup: true, + lookupOptions: { + foreignTableId: foreignTable.id, + lookupFieldId: foreignImportanceField.id, + linkFieldId: linkField.id, + } as ILookupOptionsRo, + options: { + choices: [ + { id: 'choBroken1', name: 'Option 1', color: Colors.Blue }, + { id: 'choBroken2', name: 'Option 2', color: Colors.Green }, + ], + }, + }); - const persistedLookupField = (await getFields(hostTable.id)).find( - (field) => field.id === createdLookupField.id - ); - expect(persistedLookupField?.options).toEqual({ - choices: expectedChoices, + expect(createdLookupField.options).toEqual({ + choices: expectedChoices, + }); + + const persistedLookupField = (await getFields(hostTable.id)).find( + (field) => field.id === createdLookupField.id + ); + expect(persistedLookupField?.options).toEqual({ + choices: expectedChoices, + }); }); } finally { if (hostTable) { @@ -399,13 +410,12 @@ describe('OpenAPI FieldController (e2e)', () => { } }); - itIfForceV2( - 'ignores API-supplied choices for conditional lookup-backed single select fields', - async () => { - let hostTable: ITableFullVo | undefined; - let foreignTable: ITableFullVo | undefined; + it('ignores API-supplied choices for conditional lookup-backed single select fields', async () => { + let hostTable: ITableFullVo | undefined; + let foreignTable: ITableFullVo | undefined; - try { + try { + await withForceV2All(async () => { foreignTable = await createTable(baseId, { name: 'conditional-lookup-option-sync-foreign', fields: [ @@ -484,185 +494,16 @@ describe('OpenAPI FieldController (e2e)', () => { expect(persistedConditionalLookupField?.options).toEqual({ choices: expectedChoices, }); - } finally { - if (hostTable) { - await permanentDeleteTable(baseId, hostTable.id); - } - if (foreignTable) { - await permanentDeleteTable(baseId, foreignTable.id); - } - } - } - ); - }); - - describe('long text markdown showAs API', () => { - const itIfForceV2 = isForceV2 ? it : it.skip; - - itIfForceV2('should update and clear long text showAs via convert field API', async () => { - let table: ITableFullVo | undefined; - - try { - table = await createTable(baseId, { - name: 'long-text-show-as-update-api', - fields: [{ name: 'Name', type: FieldType.SingleLineText }], - }); - - const longTextField = await createField(table.id, { - name: 'Body', - type: FieldType.LongText, }); - - const markdownUpdatedResponse = await convertField(table.id, longTextField.id, { - name: longTextField.name, - type: FieldType.LongText, - options: { - showAs: { - type: 'markdown', - }, - }, - }); - expect(markdownUpdatedResponse.status).toBe(200); - - const persistedAfterEnable = (await getFields(table.id)).find( - (field) => field.id === longTextField.id - )!; - expect(persistedAfterEnable.options).toMatchObject({ - showAs: { - type: 'markdown', - }, - }); - - const clearedResponse = await convertField(table.id, longTextField.id, { - name: longTextField.name, - type: FieldType.LongText, - options: { - showAs: null, - }, - }); - expect(clearedResponse.status).toBe(200); - - const persistedAfterClear = (await getFields(table.id)).find( - (field) => field.id === longTextField.id - )!; - expect((persistedAfterClear.options as { showAs?: unknown }).showAs).toBeUndefined(); } finally { - if (table) { - await permanentDeleteTable(baseId, table.id); + if (hostTable) { + await permanentDeleteTable(baseId, hostTable.id); } - } - }); - - itIfForceV2( - 'should keep lookup long text showAs cleared when API attempts to set markdown', - async () => { - let hostTable: ITableFullVo | undefined; - let foreignTable: ITableFullVo | undefined; - - try { - foreignTable = await createTable(baseId, { - name: 'lookup-long-text-show-as-foreign', - fields: [ - { name: 'Title', type: FieldType.SingleLineText }, - { - name: 'Foreign Long Text', - type: FieldType.LongText, - options: { - showAs: { - type: 'markdown', - }, - }, - }, - ], - }); - - hostTable = await createTable(baseId, { - name: 'lookup-long-text-show-as-host', - fields: [{ name: 'Name', type: FieldType.SingleLineText }], - }); - - const foreignLongTextField = foreignTable.fields.find( - (field) => field.name === 'Foreign Long Text' - )!; - - const linkField = await createField(hostTable.id, { - name: 'Related', - type: FieldType.Link, - options: { - relationship: Relationship.ManyMany, - foreignTableId: foreignTable.id, - } as ILinkFieldOptionsRo, - }); - - const lookupLongTextField = await createField(hostTable.id, { - name: 'Lookup Long Text', - type: FieldType.LongText, - isLookup: true, - lookupOptions: { - foreignTableId: foreignTable.id, - lookupFieldId: foreignLongTextField.id, - linkFieldId: linkField.id, - } as ILookupOptionsRo, - }); - - expect(lookupLongTextField.options).toMatchObject({ - showAs: { - type: 'markdown', - }, - }); - - const lookupOptions = lookupLongTextField.lookupOptions as ILookupOptionsRo; - const clearedLookupResponse = await convertField(hostTable.id, lookupLongTextField.id, { - name: lookupLongTextField.name, - type: FieldType.LongText, - isLookup: true, - lookupOptions: { - foreignTableId: lookupOptions.foreignTableId, - lookupFieldId: lookupOptions.lookupFieldId, - linkFieldId: lookupOptions.linkFieldId, - }, - options: { - showAs: null, - }, - }); - expect(clearedLookupResponse.status).toBe(200); - - const persistedAfterClear = (await getFields(hostTable.id)).find( - (field) => field.id === lookupLongTextField.id - )!; - expect((persistedAfterClear.options as { showAs?: unknown }).showAs).toBeUndefined(); - - const updatedLookupResponse = await convertField(hostTable.id, lookupLongTextField.id, { - name: lookupLongTextField.name, - type: FieldType.LongText, - isLookup: true, - lookupOptions: { - foreignTableId: lookupOptions.foreignTableId, - lookupFieldId: lookupOptions.lookupFieldId, - linkFieldId: lookupOptions.linkFieldId, - }, - options: { - showAs: { - type: 'markdown', - }, - }, - }); - expect(updatedLookupResponse.status).toBe(200); - - const persistedLookupField = (await getFields(hostTable.id)).find( - (field) => field.id === lookupLongTextField.id - )!; - expect((persistedLookupField.options as { showAs?: unknown }).showAs).toBeUndefined(); - } finally { - if (hostTable) { - await permanentDeleteTable(baseId, hostTable.id); - } - if (foreignTable) { - await permanentDeleteTable(baseId, foreignTable.id); - } + if (foreignTable) { + await permanentDeleteTable(baseId, foreignTable.id); } } - ); + }); }); describe('should decide whether to create field validation rules based on the field type', () => { diff --git a/apps/nestjs-backend/test/filter.e2e-spec.ts b/apps/nestjs-backend/test/filter.e2e-spec.ts index d10454bfb1..d41d408ac7 100644 --- a/apps/nestjs-backend/test/filter.e2e-spec.ts +++ b/apps/nestjs-backend/test/filter.e2e-spec.ts @@ -15,6 +15,20 @@ afterAll(async () => { await app.close(); }); +const withForceV2All = async (callback: () => Promise) => { + const previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + try { + return await callback(); + } finally { + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + } +}; + async function updateViewFilter(tableId: string, viewId: string, filterRo: IFilterRo) { try { const result = await apiSetViewFilter(tableId, viewId, filterRo); @@ -80,18 +94,17 @@ describe('OpenAPI ViewController (e2e) option (PUT)', () => { }); // V1 does not normalize is/isNot+null through the domain FieldConditionSpecBuilder, -// so this test only applies to V2. -describe.skipIf(process.env.FORCE_V2_ALL !== 'true')( - 'View filter with is/isNot null value (e2e)', - () => { - let tableId: string; - let viewId: string; +// so this test must force the V2 path explicitly inside the single integration run. +describe('View filter with is/isNot null value (e2e)', () => { + let tableId: string; + let viewId: string; - afterAll(async () => { - await permanentDeleteTable(baseId, tableId); - }); + afterAll(async () => { + await permanentDeleteTable(baseId, tableId); + }); - it('should apply view filter with is+null (checkbox) and isNotEmpty via API viewId query', async () => { + it('should apply view filter with is+null (checkbox) and isNotEmpty via API viewId query', async () => { + await withForceV2All(async () => { // Create table with checkbox and text fields const table = await createTable(baseId, { name: 'View Filter Null Test', @@ -144,5 +157,5 @@ describe.skipIf(process.env.FORCE_V2_ALL !== 'true')( const names = records.map((r) => r.fields.Name).sort(); expect(names).toEqual(['row2', 'row3']); }); - } -); + }); +}); diff --git a/apps/nestjs-backend/test/group.e2e-spec.ts b/apps/nestjs-backend/test/group.e2e-spec.ts index 6d7d599e53..3631669ead 100644 --- a/apps/nestjs-backend/test/group.e2e-spec.ts +++ b/apps/nestjs-backend/test/group.e2e-spec.ts @@ -695,3 +695,118 @@ describe('Lookup multiple select respects choice order when sorting groups', () expect(recordCategories).toEqual([choiceOrder[0], choiceOrder[1], choiceOrder[2]]); }); }); + +describe('Single select grouping with special characters in choice names', () => { + const choiceOrder = ['Pending?', 'Done!', 'N/A'] as const; + const choiceDefinitions = choiceOrder.map((name, index) => ({ + id: `sc-choice-${index}`, + name, + color: index === 0 ? Colors.Red : index === 1 ? Colors.Green : Colors.Blue, + })); + const statusFieldName = 'Status'; + const itemFieldName = 'Item'; + + let table: ITableFullVo; + let statusField: IFieldRo; + + beforeAll(async () => { + table = await createTable(baseId, { + name: 'group_special_char_choices', + fields: [ + { name: itemFieldName, type: FieldType.SingleLineText }, + { + name: statusFieldName, + type: FieldType.SingleSelect, + options: { choices: choiceDefinitions }, + }, + ], + records: [ + { fields: { [itemFieldName]: 'r1', [statusFieldName]: 'Pending?' } }, + { fields: { [itemFieldName]: 'r2', [statusFieldName]: 'Done!' } }, + { fields: { [itemFieldName]: 'r3', [statusFieldName]: 'N/A' } }, + ], + }); + statusField = table.fields!.find( + ({ name, type }) => name === statusFieldName && type === FieldType.SingleSelect + ) as IFieldRo; + }); + + afterAll(async () => { + await permanentDeleteTable(baseId, table.id); + }); + + it('groups correctly when choice name contains ? character', async () => { + const query: IGetRecordsRo = { + fieldKeyType: FieldKeyType.Id, + groupBy: [{ fieldId: statusField.id!, order: SortFunc.Asc }], + }; + const { records, extra } = await getRecords(table.id, query); + + const headerValues = + extra?.groupPoints + ?.filter((point): point is IGroupHeaderPoint => point.type === GroupPointType.Header) + .map((point) => point.value as string) ?? []; + + expect(headerValues).toEqual([...choiceOrder]); + expect(records).toHaveLength(3); + + const statusSequence = records.map((record) => record.fields?.[statusField.id!] as string); + expect(statusSequence).toEqual([...choiceOrder]); + }); +}); + +describe('Multiple select grouping with special characters in choice names', () => { + const choiceOrder = ['Alpha?', 'Beta!', 'Gamma'] as const; + const choiceDefinitions = choiceOrder.map((name, index) => ({ + id: `ms-choice-${index}`, + name, + color: index === 0 ? Colors.Red : index === 1 ? Colors.Green : Colors.Blue, + })); + const tagFieldName = 'Tags'; + const itemFieldName = 'Item'; + + let table: ITableFullVo; + let tagField: IFieldRo; + + beforeAll(async () => { + table = await createTable(baseId, { + name: 'group_multi_select_special_char', + fields: [ + { name: itemFieldName, type: FieldType.SingleLineText }, + { + name: tagFieldName, + type: FieldType.MultipleSelect, + options: { choices: choiceDefinitions }, + }, + ], + records: [ + { fields: { [itemFieldName]: 'r1', [tagFieldName]: ['Alpha?'] } }, + { fields: { [itemFieldName]: 'r2', [tagFieldName]: ['Beta!'] } }, + { fields: { [itemFieldName]: 'r3', [tagFieldName]: ['Gamma'] } }, + ], + }); + tagField = table.fields!.find( + ({ name, type }) => name === tagFieldName && type === FieldType.MultipleSelect + ) as IFieldRo; + }); + + afterAll(async () => { + await permanentDeleteTable(baseId, table.id); + }); + + it('groups correctly when multiple select choice name contains ? character', async () => { + const query: IGetRecordsRo = { + fieldKeyType: FieldKeyType.Id, + groupBy: [{ fieldId: tagField.id!, order: SortFunc.Asc }], + }; + const { records, extra } = await getRecords(table.id, query); + + const headerValues = + extra?.groupPoints + ?.filter((point): point is IGroupHeaderPoint => point.type === GroupPointType.Header) + .map((point) => point.value) ?? []; + + expect(headerValues).toHaveLength(3); + expect(records).toHaveLength(3); + }); +}); diff --git a/apps/nestjs-backend/test/integrity.e2e-spec.ts b/apps/nestjs-backend/test/integrity.e2e-spec.ts index d6431469a9..55fe3b4446 100644 --- a/apps/nestjs-backend/test/integrity.e2e-spec.ts +++ b/apps/nestjs-backend/test/integrity.e2e-spec.ts @@ -949,6 +949,103 @@ describe('OpenAPI integrity (e2e)', () => { }); }); + describe('fix invalid filter operator', () => { + let baseId1: string; + let base1table1: ITableFullVo; + let base1table2: ITableFullVo; + beforeEach(async () => { + baseId1 = (await createBase({ spaceId, name: 'base1' })).data.id; + base1table1 = await createTable(baseId1, { name: 'base1table1' }); + base1table2 = await createTable(baseId1, { name: 'base1table2' }); + }); + + afterEach(async () => { + await permanentDeleteTable(baseId1, base1table1.id); + await permanentDeleteTable(baseId1, base1table2.id); + await deleteBase(baseId1); + }); + + it('should detect and fix invalid filter operator in lookupOptions', async () => { + // Create a link field from table1 to table2 + const linkFieldRo: IFieldRo = { + name: 'link field', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: base1table2.id, + }, + }; + const linkField = await createField(base1table1.id, linkFieldRo); + + // Create a number field in table2 for filtering + const numberFieldRo: IFieldRo = { + name: 'score', + type: FieldType.Number, + }; + const numberField = await createField(base1table2.id, numberFieldRo); + + // Create a lookup field on table1 that looks up the primary field of table2 + // with a valid filter: score isGreater 10 + const lookupFieldRo: IFieldRo = { + name: 'lookup with filter', + type: FieldType.SingleLineText, + isLookup: true, + lookupOptions: { + foreignTableId: base1table2.id, + lookupFieldId: base1table2.fields[0].id, + linkFieldId: linkField.id, + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: numberField.id, + operator: 'isGreater', + value: 10, + }, + ], + }, + }, + }; + const lookupField = await createField(base1table1.id, lookupFieldRo); + + // Verify no issues initially + const integrity1 = await checkBaseIntegrity(baseId1, base1table1.id); + expect(integrity1.data.hasIssues).toEqual(false); + + // Directly inject an invalid operator ("contains" is not valid for number fields) + const fieldRow = await prisma.txClient().field.findFirstOrThrow({ + where: { id: lookupField.id }, + }); + const lookupOptions = JSON.parse(fieldRow.lookupOptions!); + lookupOptions.filter.filterSet[0].operator = 'contains'; + await prisma.txClient().field.update({ + where: { id: lookupField.id }, + data: { lookupOptions: JSON.stringify(lookupOptions) }, + }); + + // Check should detect the invalid operator + const integrity2 = await checkBaseIntegrity(baseId1, base1table1.id); + expect(integrity2.data.hasIssues).toEqual(true); + const issues = integrity2.data.linkFieldIssues.flatMap((i) => i.issues); + expect(issues.some((i) => i.type === IntegrityIssueType.InvalidFilterOperator)).toEqual(true); + + // Fix should remove the invalid filter item + await fixBaseIntegrity(baseId1, base1table1.id); + + // After fix, no more issues + const integrity3 = await checkBaseIntegrity(baseId1, base1table1.id); + expect(integrity3.data.hasIssues).toEqual(false); + + // Verify the filter was cleaned up in DB + const fieldRowAfter = await prisma.txClient().field.findFirstOrThrow({ + where: { id: lookupField.id }, + }); + const lookupOptionsAfter = JSON.parse(fieldRowAfter.lookupOptions!); + // The invalid filter item was removed, filterSet should be empty or filter null + expect(lookupOptionsAfter.filter).toBeNull(); + }); + }); + describe('fix empty string cell value', () => { let baseId1: string; let base1table: ITableFullVo; diff --git a/apps/nestjs-backend/test/record-filter-is-with-in.e2e-spec.ts b/apps/nestjs-backend/test/record-filter-is-with-in.e2e-spec.ts new file mode 100644 index 0000000000..1ca90b6f62 --- /dev/null +++ b/apps/nestjs-backend/test/record-filter-is-with-in.e2e-spec.ts @@ -0,0 +1,81 @@ +import type { INestApplication } from '@nestjs/common'; +import { FieldKeyType } from '@teable/core'; +import { getRecords as apiGetRecords } from '@teable/openapi'; +import { x_20 } from './data-helpers/20x'; +import { createTable, initApp, permanentDeleteTable } from './utils/init-app'; + +let app: INestApplication; +const baseId = globalThis.testConfig.baseId; + +const withForceV2All = async (callback: () => Promise) => { + const previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + try { + return await callback(); + } finally { + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + } +}; + +beforeAll(async () => { + const appCtx = await initApp(); + app = appCtx.app; +}); + +afterAll(async () => { + await app.close(); +}); + +describe('Record filter isWithIn today (e2e)', () => { + let tableId: string; + let dateFieldId: string; + + beforeAll(async () => { + const table = await createTable(baseId, { + name: 'record_query_is_with_in_today', + fields: x_20.fields, + records: x_20.records, + }); + tableId = table.id; + dateFieldId = table.fields[3].id; + }); + + afterAll(async () => { + await permanentDeleteTable(baseId, tableId); + }); + + const queryTodayFilter = async () => { + const result = await apiGetRecords(tableId, { + fieldKeyType: FieldKeyType.Name, + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: dateFieldId, + operator: 'isWithIn', + value: { + mode: 'today', + timeZone: 'Asia/Singapore', + }, + }, + ], + }, + }); + + return result.data.records.map((record) => record.fields['text field']); + }; + + it('matches the current day on the legacy path', async () => { + await expect(queryTodayFilter()).resolves.toEqual(['Text Field 20']); + }); + + it('matches the current day on the force-v2 compatibility path', async () => { + await withForceV2All(async () => { + await expect(queryTodayFilter()).resolves.toEqual(['Text Field 20']); + }); + }); +}); diff --git a/apps/nestjs-backend/test/record-search-query.e2e-spec.ts b/apps/nestjs-backend/test/record-search-query.e2e-spec.ts index 0452682c62..9a6ab7ea33 100644 --- a/apps/nestjs-backend/test/record-search-query.e2e-spec.ts +++ b/apps/nestjs-backend/test/record-search-query.e2e-spec.ts @@ -321,6 +321,75 @@ describe('OpenAPI Record-Search-Query (e2e)', async () => { }); }); + describe('global search should skip number fields for non-numeric queries', () => { + let table: ITableFullVo; + beforeAll(async () => { + table = await createTable(baseId, { + name: 'number_skip_test', + fields: [ + { + name: 'text', + type: FieldType.SingleLineText, + }, + { + name: 'amount', + type: FieldType.Number, + options: { + formatting: { type: 'decimal', precision: 0 }, + }, + }, + ], + records: [ + { fields: { text: 'apple', amount: 100 } }, + { fields: { text: 'banana', amount: 200 } }, + { fields: { text: '100 items', amount: 300 } }, + ], + }); + }); + + afterAll(async () => { + await permanentDeleteTable(baseId, table.id); + }); + + it('should not match number fields when searching non-numeric text globally', async () => { + const { records } = ( + await apiGetRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + viewId: table.views[0].id, + search: ['apple', '', true], + }) + ).data; + // should only match the text field, not scan number fields + expect(records.length).toBe(1); + expect(records[0].fields[table.fields[0].id]).toBe('apple'); + }); + + it('should match number fields when searching numeric text globally', async () => { + const { records } = ( + await apiGetRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + viewId: table.views[0].id, + search: ['100', '', true], + }) + ).data; + // should match both text "100 items" and number 100 + expect(records.length).toBe(2); + }); + + it('should still search number fields when targeting a specific field', async () => { + const numberFieldId = table.fields[1].id; + const { records } = ( + await apiGetRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + viewId: table.views[0].id, + search: ['apple', numberFieldId, true], + }) + ).data; + // no number value matches "apple" + expect(records.length).toBe(0); + }); + }); + describe('search value with special characters', () => { let table: ITableFullVo; beforeAll(async () => { diff --git a/apps/nestjs-backend/test/rollup.e2e-spec.ts b/apps/nestjs-backend/test/rollup.e2e-spec.ts index 30701842fd..11097b5663 100644 --- a/apps/nestjs-backend/test/rollup.e2e-spec.ts +++ b/apps/nestjs-backend/test/rollup.e2e-spec.ts @@ -1389,63 +1389,4 @@ describe('OpenAPI Rollup field (e2e)', () => { expect(record1.fields[rollup1.id]).toEqual(0); }); }); - - describe('v2 update field hasError propagation', () => { - const isForceV2 = process.env.FORCE_V2_ALL === 'true'; - const itV2Only = isForceV2 ? it : it.skip; - - itV2Only( - 'marks rollup as errored when foreign lookup field type becomes incompatible via v2 convert', - async () => { - const foreign = await createTable(baseId, { - name: 'V2RollupHasError_Foreign', - fields: [{ name: 'Amount', type: FieldType.Number } as IFieldRo], - }); - const host = await createTable(baseId, { - name: 'V2RollupHasError_Host', - fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo], - }); - const amountFieldId = foreign.fields.find((field) => field.name === 'Amount')!.id; - - try { - const linkField = await createField(host.id, { - name: 'Link to Foreign', - type: FieldType.Link, - options: { - relationship: Relationship.OneMany, - foreignTableId: foreign.id, - }, - } as IFieldRo); - - const rollupField = await createField(host.id, { - name: 'Sum Amount', - type: FieldType.Rollup, - options: { - expression: 'sum({values})', - }, - lookupOptions: { - foreignTableId: foreign.id, - linkFieldId: linkField.id, - lookupFieldId: amountFieldId, - } as ILookupOptionsRo, - } as IFieldRo); - - expect((await getField(host.id, rollupField.id)).hasError).toBeFalsy(); - - // Convert the foreign lookup field to an incompatible type via v2 - await convertField(foreign.id, amountFieldId, { - name: 'Amount', - type: FieldType.SingleLineText, - options: {}, - } as IFieldRo); - - const afterConvert = await getField(host.id, rollupField.id); - expect(afterConvert.hasError).toBe(true); - } finally { - await permanentDeleteTable(baseId, host.id); - await permanentDeleteTable(baseId, foreign.id); - } - } - ); - }); }); diff --git a/apps/nestjs-backend/test/selection.e2e-spec.ts b/apps/nestjs-backend/test/selection.e2e-spec.ts index bbce12d353..58e1e211f9 100644 --- a/apps/nestjs-backend/test/selection.e2e-spec.ts +++ b/apps/nestjs-backend/test/selection.e2e-spec.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable sonarjs/no-duplicate-string */ +/* eslint-disable sonarjs/cognitive-complexity */ import type { INestApplication } from '@nestjs/common'; import { Colors, @@ -17,8 +18,12 @@ import { RangeType, IdReturnType, CLEAR_URL, + CLEAR_STREAM_URL, + DELETE_STREAM_URL, + DUPLICATE_STREAM_URL, DELETE_URL, PASTE_URL, + PASTE_STREAM_URL, X_CANARY_HEADER, axios, getIdsFromRanges as apiGetIdsFromRanges, @@ -37,6 +42,7 @@ import { getRecords, urlBuilder, } from '@teable/openapi'; +import { RecordOpenApiV2Service } from '../src/features/record/open-api/record-open-api-v2.service'; import { createNewUserAxios } from './utils/axios-instance/new-user'; import { permanentDeleteBase, @@ -52,12 +58,14 @@ import { describe('OpenAPI SelectionController (e2e)', () => { let app: INestApplication; let table: ITableFullVo; + let cookie: string; const baseId = globalThis.testConfig.baseId; const isForceV2 = process.env.FORCE_V2_ALL === 'true'; beforeAll(async () => { const appCtx = await initApp(); app = appCtx.app; + cookie = appCtx.cookie; }); beforeEach(async () => { @@ -129,6 +137,442 @@ describe('OpenAPI SelectionController (e2e)', () => { ); }; + const deleteStreamWithCanary = async ( + tableId: string, + rangesRo: { + viewId: string; + type: RangeType; + ranges: Array<[number, number]>; + }, + useV2: boolean + ) => { + const streamUrl = axios.getUri({ + baseURL: axios.defaults.baseURL, + url: urlBuilder(DELETE_STREAM_URL, { + tableId, + }), + params: { + viewId: rangesRo.viewId, + type: rangesRo.type, + ranges: JSON.stringify(rangesRo.ranges), + }, + }); + + const response = await fetch(streamUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + Cookie: cookie, + [X_CANARY_HEADER]: useV2 ? 'true' : 'false', + }, + }); + + expect(response.ok).toBe(true); + expect(response.headers.get('content-type')).toContain('text/event-stream'); + + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + const progressEvents: Array<{ phase: string; deletedCount: number; totalCount: number }> = []; + let doneEvent: + | { id: 'done'; data: { deletedRecordIds: string[] }; deletedCount: number } + | undefined; + const errorEvents: Array<{ + id: 'error'; + message: string; + batchIndex: number; + phase: string; + recordIds: string[]; + }> = []; + + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const jsonStr = line.slice(5).trim(); + if (!jsonStr || jsonStr === '[DONE]') continue; + const event = JSON.parse(jsonStr) as { + id: string; + phase?: string; + deletedCount?: number; + totalCount?: number; + message?: string; + batchIndex?: number; + recordIds?: string[]; + data?: { deletedRecordIds: string[] }; + }; + + if (event.id === 'progress') { + progressEvents.push({ + phase: event.phase ?? 'preparing', + deletedCount: event.deletedCount ?? 0, + totalCount: event.totalCount ?? 0, + }); + } + + if (event.id === 'done') { + doneEvent = event as typeof doneEvent; + } + + if (event.id === 'error') { + errorEvents.push({ + id: 'error', + message: event.message ?? '', + batchIndex: event.batchIndex ?? -1, + phase: event.phase ?? 'deleting', + recordIds: event.recordIds ?? [], + }); + } + } + } + + return { + headers: { + contentType: response.headers.get('content-type'), + xAccelBuffering: response.headers.get('x-accel-buffering'), + xTeableV2: response.headers.get('x-teable-v2'), + xTeableV2Reason: response.headers.get('x-teable-v2-reason'), + xTeableV2Feature: response.headers.get('x-teable-v2-feature'), + link: response.headers.get('link'), + traceparent: response.headers.get('traceparent'), + }, + progressEvents, + doneEvent, + errorEvents, + }; + }; + + const duplicateStreamWithCanary = async ( + tableId: string, + rangesRo: { + viewId: string; + type: RangeType; + ranges: Array<[number, number]>; + }, + useV2: boolean + ) => { + const streamUrl = axios.getUri({ + baseURL: axios.defaults.baseURL, + url: urlBuilder(DUPLICATE_STREAM_URL, { + tableId, + }), + params: { + viewId: rangesRo.viewId, + type: rangesRo.type, + ranges: JSON.stringify(rangesRo.ranges), + }, + }); + + const response = await fetch(streamUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + Cookie: cookie, + [X_CANARY_HEADER]: useV2 ? 'true' : 'false', + }, + }); + + expect(response.ok).toBe(true); + expect(response.headers.get('content-type')).toContain('text/event-stream'); + + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + const progressEvents: Array<{ phase: string; duplicatedCount: number; totalCount: number }> = + []; + let doneEvent: + | { id: 'done'; data: { duplicatedRecordIds: string[] }; duplicatedCount: number } + | undefined; + const errorEvents: Array<{ + id: 'error'; + message: string; + batchIndex: number; + phase: string; + recordIds: string[]; + }> = []; + + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const jsonStr = line.slice(5).trim(); + if (!jsonStr || jsonStr === '[DONE]') continue; + const event = JSON.parse(jsonStr) as { + id: string; + phase?: string; + duplicatedCount?: number; + totalCount?: number; + message?: string; + batchIndex?: number; + recordIds?: string[]; + data?: { duplicatedRecordIds: string[] }; + }; + + if (event.id === 'progress') { + progressEvents.push({ + phase: event.phase ?? 'preparing', + duplicatedCount: event.duplicatedCount ?? 0, + totalCount: event.totalCount ?? 0, + }); + } + + if (event.id === 'done') { + doneEvent = event as typeof doneEvent; + } + + if (event.id === 'error') { + errorEvents.push({ + id: 'error', + message: event.message ?? '', + batchIndex: event.batchIndex ?? -1, + phase: event.phase ?? 'duplicating', + recordIds: event.recordIds ?? [], + }); + } + } + } + + return { + progressEvents, + doneEvent, + errorEvents, + }; + }; + + const clearStreamWithCanary = async ( + tableId: string, + rangesRo: { + viewId: string; + type: RangeType; + ranges: Array<[number, number]>; + }, + useV2: boolean + ) => { + const streamUrl = axios.getUri({ + baseURL: axios.defaults.baseURL, + url: urlBuilder(CLEAR_STREAM_URL, { + tableId, + }), + }); + + const response = await fetch(streamUrl, { + method: 'PATCH', + headers: { + Accept: 'text/event-stream', + 'Content-Type': 'application/json', + Cookie: cookie, + [X_CANARY_HEADER]: useV2 ? 'true' : 'false', + }, + body: JSON.stringify(rangesRo), + }); + + expect(response.ok).toBe(true); + expect(response.headers.get('content-type')).toContain('text/event-stream'); + + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + const progressEvents: Array<{ + phase: string; + processedCount: number; + clearedCount: number; + totalCount: number; + }> = []; + let doneEvent: + | { + id: 'done'; + processedCount: number; + clearedCount: number; + data: { clearedRecordIds: string[] }; + } + | undefined; + const errorEvents: Array<{ + id: 'error'; + message: string; + batchIndex: number; + phase: string; + recordIds: string[]; + }> = []; + + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const jsonStr = line.slice(5).trim(); + if (!jsonStr || jsonStr === '[DONE]') continue; + const event = JSON.parse(jsonStr) as { + id: string; + phase?: string; + processedCount?: number; + clearedCount?: number; + totalCount?: number; + message?: string; + batchIndex?: number; + recordIds?: string[]; + data?: { clearedRecordIds: string[] }; + }; + + if (event.id === 'progress') { + progressEvents.push({ + phase: event.phase ?? 'preparing', + processedCount: event.processedCount ?? 0, + clearedCount: event.clearedCount ?? 0, + totalCount: event.totalCount ?? 0, + }); + } + + if (event.id === 'done') { + doneEvent = event as typeof doneEvent; + } + + if (event.id === 'error') { + errorEvents.push({ + id: 'error', + message: event.message ?? '', + batchIndex: event.batchIndex ?? -1, + phase: event.phase ?? 'clearing', + recordIds: event.recordIds ?? [], + }); + } + } + } + + return { + progressEvents, + doneEvent, + errorEvents, + }; + }; + + const pasteStreamWithCanary = async (tableId: string, pasteRo: IPasteRo, useV2: boolean) => { + const streamUrl = axios.getUri({ + baseURL: axios.defaults.baseURL, + url: urlBuilder(PASTE_STREAM_URL, { + tableId, + }), + }); + + const response = await fetch(streamUrl, { + method: 'PATCH', + headers: { + Accept: 'text/event-stream', + 'Content-Type': 'application/json', + Cookie: cookie, + [X_CANARY_HEADER]: useV2 ? 'true' : 'false', + }, + body: JSON.stringify(pasteRo), + }); + + expect(response.ok).toBe(true); + expect(response.headers.get('content-type')).toContain('text/event-stream'); + + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + const progressEvents: Array<{ + phase: string; + processedCount: number; + updatedCount: number; + createdCount: number; + totalCount: number; + }> = []; + let doneEvent: + | { + id: 'done'; + processedCount: number; + updatedCount: number; + createdCount: number; + data: { createdRecordIds: string[]; ranges?: [[number, number], [number, number]] }; + } + | undefined; + const errorEvents: Array<{ + id: 'error'; + message: string; + batchIndex: number; + phase: string; + recordIds: string[]; + }> = []; + + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const jsonStr = line.slice(5).trim(); + if (!jsonStr || jsonStr === '[DONE]') continue; + const event = JSON.parse(jsonStr) as { + id: string; + phase?: string; + processedCount?: number; + updatedCount?: number; + createdCount?: number; + totalCount?: number; + message?: string; + batchIndex?: number; + recordIds?: string[]; + data?: { createdRecordIds: string[]; ranges?: [[number, number], [number, number]] }; + }; + + if (event.id === 'progress') { + progressEvents.push({ + phase: event.phase ?? 'preparing', + processedCount: event.processedCount ?? 0, + updatedCount: event.updatedCount ?? 0, + createdCount: event.createdCount ?? 0, + totalCount: event.totalCount ?? 0, + }); + } + + if (event.id === 'done') { + doneEvent = event as typeof doneEvent; + } + + if (event.id === 'error') { + errorEvents.push({ + id: 'error', + message: event.message ?? '', + batchIndex: event.batchIndex ?? -1, + phase: event.phase ?? 'pasting', + recordIds: event.recordIds ?? [], + }); + } + } + } + + return { + progressEvents, + doneEvent, + errorEvents, + }; + }; + describe('getIdsFromRanges', () => { it('should return all ids for cell range ', async () => { const viewId = table.views[0].id; @@ -1706,6 +2150,502 @@ describe('OpenAPI SelectionController (e2e)', () => { ); }); + describe('api/table/:tableId/selection/delete-stream (SSE)', () => { + it('should stream v2 delete progress and return the deleted ids', async () => { + const streamTable = await createTable(baseId, { + name: 'delete-stream', + fields: [ + { name: 'name', type: FieldType.SingleLineText }, + { name: 'number', type: FieldType.Number }, + ], + records: [ + { fields: { name: 'stream-1', number: 1 } }, + { fields: { name: 'stream-2', number: 2 } }, + { fields: { name: 'stream-3', number: 3 } }, + ], + }); + + try { + const { progressEvents, doneEvent, errorEvents } = await deleteStreamWithCanary( + streamTable.id, + { + viewId: streamTable.views[0].id, + type: RangeType.Rows, + ranges: [[0, 2]], + }, + true + ); + + expect(errorEvents).toHaveLength(0); + expect(doneEvent?.data.deletedRecordIds).toEqual( + streamTable.records.map((record) => record.id) + ); + expect(progressEvents.some((event) => event.totalCount === 3)).toBe(true); + expect(progressEvents.some((event) => event.deletedCount > 0)).toBe(true); + + const recordsAfter = await getRecords(streamTable.id, { + fieldKeyType: FieldKeyType.Id, + }); + expect(recordsAfter.data.records).toHaveLength(0); + } finally { + await permanentDeleteTable(baseId, streamTable.id); + } + }); + + it('should expose stream response headers for v2 delete', async () => { + const streamTable = await createTable(baseId, { + name: 'delete-stream-headers', + fields: [{ name: 'name', type: FieldType.SingleLineText }], + records: [{ fields: { name: 'stream-headers-1' } }], + }); + + try { + const { headers } = await deleteStreamWithCanary( + streamTable.id, + { + viewId: streamTable.views[0].id, + type: RangeType.Rows, + ranges: [[0, 0]], + }, + true + ); + + expect(headers.contentType).toContain('text/event-stream'); + expect(headers.xAccelBuffering).toBe('no'); + expect(headers.xTeableV2).toBe('true'); + expect(headers.xTeableV2Feature).toBe('deleteRecord'); + } finally { + await permanentDeleteTable(baseId, streamTable.id); + } + }); + + it('should allow delete-stream when v2 canary is disabled and fall back to v1 synchronous delete', async () => { + const streamTable = await createTable(baseId, { + name: 'delete-stream-v1-fallback', + fields: [ + { name: 'name', type: FieldType.SingleLineText }, + { name: 'number', type: FieldType.Number }, + ], + records: [ + { fields: { name: 'stream-v1-1', number: 1 } }, + { fields: { name: 'stream-v1-2', number: 2 } }, + { fields: { name: 'stream-v1-3', number: 3 } }, + ], + }); + + try { + const { progressEvents, doneEvent, errorEvents } = await deleteStreamWithCanary( + streamTable.id, + { + viewId: streamTable.views[0].id, + type: RangeType.Rows, + ranges: [[0, 2]], + }, + false + ); + + expect(errorEvents).toHaveLength(0); + expect(progressEvents.length).toBeGreaterThan(0); + expect(progressEvents[0]).toMatchObject({ + phase: 'preparing', + deletedCount: 0, + }); + expect(progressEvents.at(-1)).toMatchObject({ + totalCount: 3, + }); + expect(doneEvent?.data.deletedRecordIds).toEqual( + streamTable.records.map((record) => record.id) + ); + expect(doneEvent?.deletedCount).toBe(3); + + const recordsAfter = await getRecords(streamTable.id, { + fieldKeyType: FieldKeyType.Id, + }); + expect(recordsAfter.data.records).toHaveLength(0); + } finally { + await permanentDeleteTable(baseId, streamTable.id); + } + }); + + it('should keep streaming after chunk error events and still deliver the final done event', async () => { + const streamTable = await createTable(baseId, { + name: 'delete-stream-partial-error', + fields: [ + { name: 'name', type: FieldType.SingleLineText }, + { name: 'number', type: FieldType.Number }, + ], + records: [ + { fields: { name: 'stream-error-1', number: 1 } }, + { fields: { name: 'stream-error-2', number: 2 } }, + { fields: { name: 'stream-error-3', number: 3 } }, + ], + }); + + const recordOpenApiV2Service = app.get(RecordOpenApiV2Service); + const deleteByRangeStreamSpy = vi + .spyOn(recordOpenApiV2Service, 'deleteByRangeStream') + .mockImplementation(async function* () { + yield { + id: 'progress', + phase: 'deleting', + batchIndex: 0, + totalCount: 3, + deletedCount: 1, + batchDeletedCount: 1, + }; + yield { + id: 'error', + phase: 'deleting', + batchIndex: 1, + totalCount: 3, + deletedCount: 1, + recordIds: [streamTable.records[1]!.id], + message: 'chunk 2 failed', + code: 'unexpected', + }; + yield { + id: 'done', + totalCount: 3, + deletedCount: 2, + data: { + deletedCount: 2, + deletedRecordIds: [streamTable.records[0]!.id, streamTable.records[2]!.id], + }, + }; + }); + + try { + const { progressEvents, doneEvent, errorEvents } = await deleteStreamWithCanary( + streamTable.id, + { + viewId: streamTable.views[0].id, + type: RangeType.Rows, + ranges: [[0, 2]], + }, + true + ); + + expect(progressEvents).toHaveLength(1); + expect(errorEvents).toEqual([ + { + id: 'error', + message: 'chunk 2 failed', + batchIndex: 1, + phase: 'deleting', + recordIds: [streamTable.records[1]!.id], + }, + ]); + expect(doneEvent).toMatchObject({ + id: 'done', + deletedCount: 2, + data: { + deletedRecordIds: [streamTable.records[0]!.id, streamTable.records[2]!.id], + }, + }); + } finally { + deleteByRangeStreamSpy.mockRestore(); + await permanentDeleteTable(baseId, streamTable.id); + } + }); + }); + + describe('api/table/:tableId/selection/clear-stream (SSE)', () => { + it('should stream v2 clear progress and clear the selected cells', async () => { + const streamTable = await createTable(baseId, { + name: 'clear-stream', + fields: [ + { name: 'name', type: FieldType.SingleLineText }, + { name: 'number', type: FieldType.Number }, + ], + records: [ + { fields: { name: 'stream-1', number: 1 } }, + { fields: { name: 'stream-2', number: 2 } }, + { fields: { name: 'stream-3', number: 3 } }, + ], + }); + + try { + const nameFieldId = streamTable.fields.find((field) => field.name === 'name')!.id; + const numberFieldId = streamTable.fields.find((field) => field.name === 'number')!.id; + + const { progressEvents, doneEvent, errorEvents } = await clearStreamWithCanary( + streamTable.id, + { + viewId: streamTable.views[0].id, + type: RangeType.Rows, + ranges: [[0, 2]], + }, + true + ); + + expect(errorEvents).toHaveLength(0); + expect(doneEvent?.processedCount).toBe(3); + expect(doneEvent?.clearedCount).toBe(3); + expect(progressEvents.some((event) => event.totalCount === 3)).toBe(true); + expect(progressEvents.some((event) => event.clearedCount > 0)).toBe(true); + + const recordsAfter = await getRecords(streamTable.id, { + fieldKeyType: FieldKeyType.Id, + }); + expect(recordsAfter.data.records).toHaveLength(3); + expect(recordsAfter.data.records.map((record) => record.fields[nameFieldId])).toEqual([ + undefined, + undefined, + undefined, + ]); + expect(recordsAfter.data.records.map((record) => record.fields[numberFieldId])).toEqual([ + undefined, + undefined, + undefined, + ]); + } finally { + await permanentDeleteTable(baseId, streamTable.id); + } + }); + + it('should allow clear-stream when v2 canary is disabled and fall back to v1 clear', async () => { + const streamTable = await createTable(baseId, { + name: 'clear-stream-v1-fallback', + fields: [ + { name: 'name', type: FieldType.SingleLineText }, + { name: 'number', type: FieldType.Number }, + ], + records: [ + { fields: { name: 'stream-v1-1', number: 1 } }, + { fields: { name: 'stream-v1-2', number: 2 } }, + ], + }); + + try { + const nameFieldId = streamTable.fields.find((field) => field.name === 'name')!.id; + + const { progressEvents, doneEvent, errorEvents } = await clearStreamWithCanary( + streamTable.id, + { + viewId: streamTable.views[0].id, + type: RangeType.Rows, + ranges: [[0, 1]], + }, + false + ); + + expect(errorEvents).toHaveLength(0); + expect(progressEvents[0]).toMatchObject({ + phase: 'preparing', + processedCount: 0, + }); + expect(doneEvent?.processedCount).toBe(2); + expect(doneEvent?.clearedCount).toBe(2); + + const recordsAfter = await getRecords(streamTable.id, { + fieldKeyType: FieldKeyType.Id, + }); + expect(recordsAfter.data.records.map((record) => record.fields[nameFieldId])).toEqual([ + undefined, + undefined, + ]); + } finally { + await permanentDeleteTable(baseId, streamTable.id); + } + }); + }); + + describe('api/table/:tableId/selection/duplicate-stream (SSE)', () => { + it('should stream v2 duplicate progress and return the duplicated ids', async () => { + const streamTable = await createTable(baseId, { + name: 'duplicate-stream', + fields: [ + { name: 'name', type: FieldType.SingleLineText }, + { name: 'number', type: FieldType.Number }, + ], + records: [ + { fields: { name: 'stream-1', number: 1 } }, + { fields: { name: 'stream-2', number: 2 } }, + ], + }); + + try { + const { progressEvents, doneEvent, errorEvents } = await duplicateStreamWithCanary( + streamTable.id, + { + viewId: streamTable.views[0].id, + type: RangeType.Rows, + ranges: [[0, 1]], + }, + true + ); + + expect(errorEvents).toHaveLength(0); + expect(doneEvent?.duplicatedCount).toBe(2); + expect(doneEvent?.data.duplicatedRecordIds).toHaveLength(2); + expect(progressEvents.some((event) => event.totalCount === 2)).toBe(true); + expect(progressEvents.some((event) => event.duplicatedCount > 0)).toBe(true); + + const recordsAfter = await getRecords(streamTable.id, { + fieldKeyType: FieldKeyType.Id, + }); + expect(recordsAfter.data.records).toHaveLength(4); + } finally { + await permanentDeleteTable(baseId, streamTable.id); + } + }); + + it('should allow duplicate-stream when v2 canary is disabled and fall back to v1 duplication', async () => { + const streamTable = await createTable(baseId, { + name: 'duplicate-stream-v1-fallback', + fields: [ + { name: 'name', type: FieldType.SingleLineText }, + { name: 'number', type: FieldType.Number }, + ], + records: [ + { fields: { name: 'stream-v1-1', number: 1 } }, + { fields: { name: 'stream-v1-2', number: 2 } }, + ], + }); + + try { + const { progressEvents, doneEvent, errorEvents } = await duplicateStreamWithCanary( + streamTable.id, + { + viewId: streamTable.views[0].id, + type: RangeType.Rows, + ranges: [[0, 1]], + }, + false + ); + + expect(errorEvents).toHaveLength(0); + expect(progressEvents.length).toBeGreaterThan(0); + expect(progressEvents[0]).toMatchObject({ + phase: 'preparing', + duplicatedCount: 0, + }); + expect(doneEvent?.duplicatedCount).toBe(2); + + const recordsAfter = await getRecords(streamTable.id, { + fieldKeyType: FieldKeyType.Id, + }); + expect(recordsAfter.data.records).toHaveLength(4); + } finally { + await permanentDeleteTable(baseId, streamTable.id); + } + }); + }); + + describe('api/table/:tableId/selection/paste-stream (SSE)', () => { + it('should stream v2 paste progress and return the created ids', async () => { + const streamTable = await createTable(baseId, { + name: 'paste-stream', + fields: [ + { name: 'name', type: FieldType.SingleLineText }, + { name: 'number', type: FieldType.Number }, + ], + records: [ + { fields: { name: 'stream-1', number: 1 } }, + { fields: { name: 'stream-2', number: 2 } }, + ], + }); + + try { + const nameFieldId = streamTable.fields.find((field) => field.name === 'name')!.id; + + const { progressEvents, doneEvent, errorEvents } = await pasteStreamWithCanary( + streamTable.id, + { + viewId: streamTable.views[0].id, + ranges: [ + [0, 0], + [1, 2], + ], + content: [ + ['updated-1', 11], + ['updated-2', 22], + ['created-3', 33], + ], + }, + true + ); + + expect(errorEvents).toHaveLength(0); + expect(doneEvent?.processedCount).toBe(3); + expect(doneEvent?.updatedCount).toBe(2); + expect(doneEvent?.createdCount).toBe(1); + expect(doneEvent?.data.createdRecordIds).toHaveLength(1); + expect(progressEvents.some((event) => event.totalCount === 3)).toBe(true); + expect(progressEvents.some((event) => event.processedCount > 0)).toBe(true); + + const recordsAfter = await getRecords(streamTable.id, { + fieldKeyType: FieldKeyType.Id, + }); + expect(recordsAfter.data.records).toHaveLength(3); + expect(recordsAfter.data.records.map((record) => record.fields[nameFieldId])).toEqual([ + 'updated-1', + 'updated-2', + 'created-3', + ]); + } finally { + await permanentDeleteTable(baseId, streamTable.id); + } + }); + + it.skipIf(isForceV2)( + 'should allow paste-stream when v2 canary is disabled and fall back to v1 paste', + async () => { + const streamTable = await createTable(baseId, { + name: 'paste-stream-v1-fallback', + fields: [ + { name: 'name', type: FieldType.SingleLineText }, + { name: 'number', type: FieldType.Number }, + ], + records: [{ fields: { name: 'stream-v1-1', number: 1 } }], + }); + + try { + const nameFieldId = streamTable.fields.find((field) => field.name === 'name')!.id; + + const { progressEvents, doneEvent, errorEvents } = await pasteStreamWithCanary( + streamTable.id, + { + viewId: streamTable.views[0].id, + ranges: [ + [0, 0], + [1, 1], + ], + content: [ + ['fallback-1', 11], + ['fallback-2', 22], + ], + }, + false + ); + + expect(errorEvents).toHaveLength(0); + expect(progressEvents.length).toBeGreaterThan(0); + expect(progressEvents[0]).toMatchObject({ + phase: 'preparing', + processedCount: 0, + }); + expect(doneEvent?.processedCount).toBe(2); + expect(doneEvent?.data.ranges).toEqual([ + [0, 0], + [1, 1], + ]); + + const recordsAfter = await getRecords(streamTable.id, { + fieldKeyType: FieldKeyType.Id, + }); + expect(recordsAfter.data.records).toHaveLength(2); + expect(recordsAfter.data.records.map((record) => record.fields[nameFieldId])).toEqual([ + 'fallback-1', + 'fallback-2', + ]); + } finally { + await permanentDeleteTable(baseId, streamTable.id); + } + } + ); + }); + describe('paste user', () => { let spaceId: string; let baseId: string; diff --git a/apps/nestjs-backend/test/table-trash.e2e-spec.ts b/apps/nestjs-backend/test/table-trash.e2e-spec.ts index 1fd0c69f3a..5a0f8ce26c 100644 --- a/apps/nestjs-backend/test/table-trash.e2e-spec.ts +++ b/apps/nestjs-backend/test/table-trash.e2e-spec.ts @@ -1,7 +1,7 @@ /* eslint-disable sonarjs/no-duplicate-string */ import { faker } from '@faker-js/faker'; import type { INestApplication } from '@nestjs/common'; -import { FieldKeyType, FieldType, ViewType } from '@teable/core'; +import { FieldKeyType, FieldType, ViewType, generateRecordTrashId } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import type { ITableTrashItemVo } from '@teable/openapi'; import { @@ -397,6 +397,70 @@ describe('Trash (e2e)', () => { expect(restored.status).toEqual(201); }); + it('should restore records from the latest matching snapshots when historical record trash exists', async () => { + const createRes = await createRecords(tableId, { + records: [ + { + fields: { + SingleLineText: `restore-record-trash-${Date.now()}-1`, + }, + }, + { + fields: { + SingleLineText: `restore-record-trash-${Date.now()}-2`, + }, + }, + ], + fieldKeyType: FieldKeyType.Name, + }); + const recordIds = createRes.data.records.map((record) => record.id); + + await deleteRecords(tableId, recordIds); + + const trashItemsRes = await waitForTableTrashItems(tableId, 1); + const recordTrashItem = trashItemsRes.data.trashItems.find( + (item) => (item as ITableTrashItemVo).resourceType === ResourceType.Record + ) as ITableTrashItemVo | undefined; + + expect(recordTrashItem).toBeTruthy(); + + const existingRecordTrashRows = await prisma.recordTrash.findMany({ + where: { + tableId, + recordId: { in: recordIds }, + }, + select: { + recordId: true, + snapshot: true, + createdBy: true, + createdTime: true, + }, + }); + + await prisma.recordTrash.createMany({ + data: existingRecordTrashRows.map((row) => ({ + id: generateRecordTrashId(), + tableId, + recordId: row.recordId, + snapshot: row.snapshot, + createdBy: row.createdBy, + createdTime: new Date(row.createdTime.getTime() - 60_000), + })), + }); + + const restored = await restoreTrash(recordTrashItem!.id); + expect(restored.status).toEqual(201); + + const recordsAfterRestore = await getRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + }); + expect( + recordIds.every((recordId) => + recordsAfterRestore.records.some((record) => record.id === recordId) + ) + ).toBe(true); + }); + it('should restore field when some records were deleted after field deletion', async () => { const field = await createField(tableId, { name: 'restore field', diff --git a/apps/nestjs-backend/test/undo-redo.e2e-spec.ts b/apps/nestjs-backend/test/undo-redo.e2e-spec.ts index 2de48d8b91..cd2804445c 100644 --- a/apps/nestjs-backend/test/undo-redo.e2e-spec.ts +++ b/apps/nestjs-backend/test/undo-redo.e2e-spec.ts @@ -23,7 +23,9 @@ import { deleteRecord, deleteRecords, deleteSelection, + deleteSelectionStream, deleteView, + duplicateSelectionStream, getField, getFields, getRecord, @@ -33,6 +35,7 @@ import { getView, getViewList, paste, + RangeType, redo, undo, updateRecord, @@ -44,6 +47,7 @@ import { updateViewName, updateViewOrder, X_CANARY_HEADER, + ensureUndoRedoWindowIdHeader, } from '@teable/openapi'; import type { ITableFullVo } from '@teable/openapi'; import { EventEmitterService } from '../src/event-emitter/event-emitter.service'; @@ -71,20 +75,21 @@ const waitForTableTrashCount = async (tableId: string, expectedCount: number, ma describe('Undo Redo (e2e)', () => { let app: INestApplication; + let cookie: string; let table: ITableFullVo; let eventEmitterService: EventEmitterService; let awaitWithEvent: (fn: () => Promise) => Promise; + let windowId: string; const baseId = globalThis.testConfig.baseId; + const windowIdHeader = 'X-Window-Id'; beforeAll(async () => { const appCtx = await initApp(); app = appCtx.app; + cookie = appCtx.cookie; eventEmitterService = app.get(EventEmitterService); - const windowId = 'win' + getRandomString(8); - axios.interceptors.request.use((config) => { - config.headers['X-Window-Id'] = windowId; - return config; - }); + windowId = 'win' + getRandomString(8); + ensureUndoRedoWindowIdHeader(windowId); awaitWithEvent = isForceV2 ? async (action: () => Promise) => await action() : createAwaitWithEvent(eventEmitterService, Events.OPERATION_PUSH); @@ -259,6 +264,139 @@ describe('Undo Redo (e2e)', () => { expect(allRecordsAfterRedo.data.records.find((r) => r.id === record1.id)).toBeUndefined(); }); + it.skipIf(!canRunCanaryV2)( + 'should undo streamed delete selection with the same window undo stack', + async () => { + const previousWindowId = windowId; + const previousCanaryHeader = axios.defaults.headers.common[X_CANARY_HEADER]; + const streamWindowId = 'win' + getRandomString(8); + + windowId = streamWindowId; + axios.defaults.headers.common[windowIdHeader] = streamWindowId; + axios.defaults.headers.common[X_CANARY_HEADER] = 'true'; + + try { + const record1 = ( + await createRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + records: [{ fields: { [table.fields[0].id]: 'record1-stream' } }], + order: { + viewId: table.views[0].id, + anchorId: table.records[0].id, + position: 'after', + }, + }) + ).data.records[0]; + + const deleteResult = await deleteSelectionStream( + table.id, + { + viewId: table.views[0].id, + type: RangeType.Rows, + ranges: [[1, 1]], + }, + { + headers: { + Cookie: cookie, + }, + } + ); + + expect(deleteResult.data.ids).toEqual([record1.id]); + + const allRecords = await getRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + viewId: table.views[0].id, + }); + expect(allRecords.data.records.find((r) => r.id === record1.id)).toBeUndefined(); + + const undoRes = await undo(table.id); + expect(undoRes.data.status).toEqual('fulfilled'); + expect(undoRes.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + + const allRecordsAfterUndo = await getRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + viewId: table.views[0].id, + }); + expect(allRecordsAfterUndo.data.records.find((r) => r.id === record1.id)).toBeDefined(); + } finally { + windowId = previousWindowId; + if (previousCanaryHeader == null) { + delete axios.defaults.headers.common[X_CANARY_HEADER]; + } else { + axios.defaults.headers.common[X_CANARY_HEADER] = previousCanaryHeader; + } + axios.defaults.headers.common[windowIdHeader] = previousWindowId; + } + } + ); + + it.skipIf(!canRunCanaryV2)( + 'should undo streamed duplicate selection with the same window undo stack', + async () => { + const previousWindowId = windowId; + const previousCanaryHeader = axios.defaults.headers.common[X_CANARY_HEADER]; + const streamWindowId = 'win' + getRandomString(8); + + windowId = streamWindowId; + axios.defaults.headers.common[windowIdHeader] = streamWindowId; + axios.defaults.headers.common[X_CANARY_HEADER] = 'true'; + + try { + const beforeRecords = await getRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + viewId: table.views[0].id, + }); + + const duplicateResult = await duplicateSelectionStream( + table.id, + { + viewId: table.views[0].id, + type: RangeType.Rows, + ranges: [[0, 1]], + }, + { + headers: { + Cookie: cookie, + }, + } + ); + + expect(duplicateResult.errors).toHaveLength(0); + expect(duplicateResult.done.duplicatedCount).toBe(2); + + const allRecords = await getRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + viewId: table.views[0].id, + }); + expect(allRecords.data.records).toHaveLength(beforeRecords.data.records.length + 2); + + const undoRes = await undo(table.id); + expect(undoRes.data.status).toEqual('fulfilled'); + expect(undoRes.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + + const allRecordsAfterUndo = await getRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + viewId: table.views[0].id, + }); + expect(allRecordsAfterUndo.data.records).toHaveLength(beforeRecords.data.records.length); + expect( + allRecordsAfterUndo.data.records.some((record) => + duplicateResult.done.data.duplicatedRecordIds.includes(record.id) + ) + ).toBe(false); + } finally { + windowId = previousWindowId; + if (previousCanaryHeader == null) { + delete axios.defaults.headers.common[X_CANARY_HEADER]; + } else { + axios.defaults.headers.common[X_CANARY_HEADER] = previousCanaryHeader; + } + axios.defaults.headers.common[windowIdHeader] = previousWindowId; + } + } + ); + it.skipIf(!canRunCanaryV2)( 'should remove v2 record trash after undo restores deleted records', async () => { diff --git a/apps/nextjs-app/next.config.js b/apps/nextjs-app/next.config.js index 8684cbfda0..a5cde655f2 100644 --- a/apps/nextjs-app/next.config.js +++ b/apps/nextjs-app/next.config.js @@ -297,7 +297,6 @@ const nextConfig = { env: { APP_NAME: packageJson.name ?? 'not-in-package.json', APP_VERSION: packageJson.version ?? 'not-in-package.json', - BUILD_TIME: new Date().toISOString(), // Note: Sentry debug/tracing variables are handled via webpack DefinePlugin // and cannot be set via Next.js env config (reserved key format) }, diff --git a/apps/nextjs-app/sentry.client.config.ts b/apps/nextjs-app/sentry.client.config.ts index 11936099da..b819d651af 100644 --- a/apps/nextjs-app/sentry.client.config.ts +++ b/apps/nextjs-app/sentry.client.config.ts @@ -6,12 +6,12 @@ import * as Sentry from '@sentry/nextjs'; declare global { interface Window { - __TE__: { sentryDsn: string }; + __TE__: { sentryDsn?: string; buildVersion?: string }; } } Sentry.init({ - release: process.env.NEXT_PUBLIC_BUILD_VERSION, + release: window.__TE__?.buildVersion ?? process.env.APP_VERSION, dsn: process.env.SENTRY_DSN || window.__TE__.sentryDsn, // Adjust this value in production, or use tracesSampler for greater control tracesSampleRate: 1, diff --git a/apps/nextjs-app/sentry.server.config.ts b/apps/nextjs-app/sentry.server.config.ts index c3f6b62006..0f32cdb636 100644 --- a/apps/nextjs-app/sentry.server.config.ts +++ b/apps/nextjs-app/sentry.server.config.ts @@ -4,7 +4,7 @@ import * as Sentry from '@sentry/nextjs'; Sentry.init({ - release: process.env.NEXT_PUBLIC_BUILD_VERSION, + release: process.env.BUILD_VERSION || process.env.APP_VERSION, dsn: process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN, tracesSampleRate: 1, debug: false, diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx index f8d9413164..e18804e356 100644 --- a/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx @@ -115,10 +115,10 @@ export const SettingPage = (props: ISettingPageProps) => { path: '/admin/ai-setting?anchor=llm', }, { - title: t('admin.configuration.list.app.title'), + title: t('admin.configuration.list.appBuilderDomain.title'), key: 'app' as const, isRequired: true, - isComplete: Boolean(setting?.appConfig?.apiKey), + isComplete: Boolean(setting?.appConfig?.vercelToken), group: 'appBuilder' as const, path: '/admin/ai-setting?anchor=app', }, diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ConfigurationList.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ConfigurationList.tsx index 563c94a8b1..ad61926b19 100644 --- a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ConfigurationList.tsx +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ConfigurationList.tsx @@ -20,9 +20,9 @@ export interface IList { | 'aiModelPool' | 'aiChatModel' | 'app' - | 'appBuilderV0' | 'appBuilderDomain' | 'appBuilderApiProxy' + | 'sandboxVercel' | 'email'; anchor?: RefObject; values?: Record; diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/CopyInstance.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/CopyInstance.tsx index 19debbca3a..8fab75e31c 100644 --- a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/CopyInstance.tsx +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/CopyInstance.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'next-i18next'; import { CopyButton } from '@/features/app/components/CopyButton'; +import { useEnv } from '@/features/app/hooks/useEnv'; interface ICopyInstanceProps { instanceId: string; @@ -8,6 +9,9 @@ interface ICopyInstanceProps { export const CopyInstance = (props: ICopyInstanceProps) => { const { instanceId } = props; const { t } = useTranslation('common'); + const { buildVersion, gitCommitSha, previewTag } = useEnv(); + const shortGitCommitSha = gitCommitSha?.slice(0, 12); + const displayBuildVersion = buildVersion ?? process.env.APP_VERSION ?? 'develop'; return (
@@ -16,9 +20,17 @@ export const CopyInstance = (props: ICopyInstanceProps) => { {t('noun.instanceId')} {instanceId} -

- {t('settings.setting.version')}: {process.env.NEXT_PUBLIC_BUILD_VERSION} -

+
+

+ {t('settings.setting.version')}: {displayBuildVersion} +

+ {previewTag && ( +

+ {`preview: ${previewTag}`} + {shortGitCommitSha ? ` · commit: ${shortGitCommitSha}` : ''} +

+ )} +
void; } @@ -48,7 +49,7 @@ const TooltipWrap = ({ }; const SwitchList = (props: SwitchListProps) => { - const { onChange, disableActions, instanceDisableActions = [] } = props; + const { onChange, disableActions, instanceDisableActions = [], sandboxConfigured } = props; const { t } = useTranslation('common'); const AIFeatureListNameMap = useMemo(() => { @@ -105,6 +106,11 @@ const SwitchList = (props: SwitchListProps) => { + {key === AIActions.AIChat && sandboxConfigured === false && ( + + + + )} { export const AIControlCard = ({ disableActions, instanceDisableActions, + sandboxConfigured, onChange, }: { disableActions: string[]; instanceDisableActions?: string[]; + sandboxConfigured?: boolean; onChange: (value: { disableActions: string[] }) => void; }) => { const { t } = useTranslation('common'); @@ -138,6 +146,7 @@ export const AIControlCard = ({ onChange={onChange} disableActions={disableActions} instanceDisableActions={instanceDisableActions} + sandboxConfigured={sandboxConfigured} /> diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/CodingModels.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/CodingModels.tsx index 703a75a9d2..8cb0f5a88f 100644 --- a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/CodingModels.tsx +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/CodingModels.tsx @@ -3,14 +3,17 @@ import { chatModelAbilityType } from '@teable/openapi'; import type { IAIIntegrationConfig, IChatModelAbility, IAbilityDetail } from '@teable/openapi'; import { cn, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@teable/ui-lib/shadcn'; -import { Cpu } from 'lucide-react'; +import { ChevronRight, Cpu } from 'lucide-react'; import { useTranslation } from 'next-i18next'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { AIModelSelect, type IModelOption } from './AiModelSelect'; // Helper to check if ability is supported (handles both boolean and detailed format) @@ -52,6 +55,10 @@ export const CodingModels = ({ placeholder?: string; }) => { const { t } = useTranslation('common'); + const [tiersOpen, setTiersOpen] = useState( + () => + Boolean(value?.md && value.md !== value?.lg) || Boolean(value?.sm && value.sm !== value?.lg) + ); const abilityIconMap = useMemo(() => { return { @@ -74,15 +81,38 @@ export const CodingModels = ({ return selectedModel?.capabilities as IChatModelAbility | undefined; }, [value?.lg, value?.ability, models]); - const handleModelChange = (model: string) => { + const handleLgChange = (model: string) => { // Get ability from the model's capabilities (already tested) const selectedModel = models?.find((m) => m.modelKey === model); const ability = (selectedModel?.capabilities as IChatModelAbility) || {}; - // Set all sizes to the same model (simplified selection) - onChange({ ...value, lg: model, md: model, sm: model, ability }); + // Update lg; clear md/sm if they were inheriting (same as old lg) + const next: IAIIntegrationConfig['chatModel'] = { ...value, lg: model, ability }; + if (value?.md === value?.lg) next.md = undefined; + if (value?.sm === value?.lg) next.sm = undefined; + onChange(next); + }; + + const handleMdChange = (model: string) => { + onChange({ ...value, md: model || undefined }); + }; + + const handleSmChange = (model: string) => { + onChange({ ...value, sm: model || undefined }); }; + // Display name of the lg model for inherit hint + const lgModelLabel = useMemo(() => { + if (!value?.lg || !models) return ''; + const m = models.find((m) => m.modelKey === value.lg); + return m?.label || value.lg; + }, [value?.lg, models]); + + const inheritPlaceholder = useMemo( + () => t('admin.setting.ai.chatModels.inheritHint', { model: lgModelLabel }), + [t, lgModelLabel] + ); + // Icon for chat model selection const chatModelIcon = useMemo(() => , []); @@ -124,12 +154,20 @@ export const CodingModels = ({ return missing.length > 0 ? missing : null; }, [value?.lg, isModelTested, selectedModelAbility, t]); + // Count how many tiers have a custom (non-inherited) model + const customizedCount = useMemo(() => { + let count = 0; + if (value?.md && value.md !== value?.lg) count++; + if (value?.sm && value.sm !== value?.lg) count++; + return count; + }, [value?.lg, value?.md, value?.sm]); + // Abilities to test and display const testableAbilities = chatModelAbilityType.options; return (
- {/* Chat model selection - simplified to one model */} + {/* LG - Primary chat model (required) */}
{chatModelIcon} @@ -142,7 +180,7 @@ export const CodingModels = ({ )}
+ + {/* Model tiers - collapsible */} + {value?.lg && ( + + + + {t('admin.setting.ai.chatModels.modelTiers')} + {!tiersOpen && ( + + {customizedCount > 0 + ? t('admin.setting.ai.chatModels.customized', { count: customizedCount }) + : t('admin.setting.ai.chatModels.allInheriting')} + + )} + + +
+ {t('admin.setting.ai.chatModels.modelTiersDescription')} +
+
+ {/* MD - Standard */} +
+
+ {t('admin.setting.ai.chatModels.md')} + + {t('admin.setting.ai.chatModels.mdDescription')} + +
+ +
+ {/* SM - Lightweight */} +
+
+ {t('admin.setting.ai.chatModels.sm')} + + {t('admin.setting.ai.chatModels.smDescription')} + +
+ +
+
+
+
+ )}
); }; diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/DefaultModelsStep.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/DefaultModelsStep.tsx index 1085e30891..7ad8ae1bb6 100644 --- a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/DefaultModelsStep.tsx +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/DefaultModelsStep.tsx @@ -3,13 +3,18 @@ import { Zap, MessageSquare, Star, HelpCircle } from '@teable/icons'; import { Button, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + cn, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@teable/ui-lib/shadcn'; +import { ChevronRight } from 'lucide-react'; import { useTranslation } from 'next-i18next'; -import { useCallback } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import type { IModelOption } from './AiModelSelect'; import { AIModelSelect } from './AiModelSelect'; @@ -33,6 +38,18 @@ export function DefaultModelsStep({ disabled, }: IDefaultModelsStepProps) { const { t } = useTranslation('common'); + const [tiersOpen, setTiersOpen] = useState( + () => + Boolean(chatModel?.md && chatModel.md !== chatModel?.lg) || + Boolean(chatModel?.sm && chatModel.sm !== chatModel?.lg) + ); + + const customizedCount = useMemo(() => { + let count = 0; + if (chatModel?.md && chatModel.md !== chatModel?.lg) count++; + if (chatModel?.sm && chatModel.sm !== chatModel?.lg) count++; + return count; + }, [chatModel?.lg, chatModel?.md, chatModel?.sm]); // Filter to only text models (not image models) const textModels = models.filter((m) => !m.isImageModel); @@ -40,27 +57,49 @@ export function DefaultModelsStep({ // Find a recommended default (first gateway model, or first model) const recommendedDefault = textModels.find((m) => m.isGateway) || textModels[0]; + const lgModelLabel = useMemo(() => { + if (!chatModel?.lg) return ''; + const m = textModels.find((m) => m.modelKey === chatModel.lg); + return m?.label || chatModel.lg; + }, [chatModel?.lg, textModels]); + + const inheritPlaceholder = useMemo( + () => t('admin.setting.ai.chatModels.inheritHint', { model: lgModelLabel }), + [t, lgModelLabel] + ); + const handleUseRecommended = useCallback(() => { if (recommendedDefault) { - // Set the same model for all sizes onChange({ + ...chatModel, lg: recommendedDefault.modelKey, - md: recommendedDefault.modelKey, - sm: recommendedDefault.modelKey, }); } - }, [recommendedDefault, onChange]); + }, [recommendedDefault, chatModel, onChange]); - const handleModelChange = useCallback( + const handleLgChange = useCallback( (value: string) => { - // Set all sizes to the same model for simplicity - onChange({ - lg: value, - md: value, - sm: value, - }); + const next: IChatModel = { ...chatModel, lg: value }; + // Clear md/sm if they were inheriting from the old lg + if (chatModel?.md === chatModel?.lg) next.md = undefined; + if (chatModel?.sm === chatModel?.lg) next.sm = undefined; + onChange(next); + }, + [chatModel, onChange] + ); + + const handleMdChange = useCallback( + (value: string) => { + onChange({ ...chatModel, md: value || undefined }); }, - [onChange] + [chatModel, onChange] + ); + + const handleSmChange = useCallback( + (value: string) => { + onChange({ ...chatModel, sm: value || undefined }); + }, + [chatModel, onChange] ); if (disabled) { @@ -107,7 +146,7 @@ export function DefaultModelsStep({
)} - {/* Model Selection - simplified to one model */} + {/* Model Selection */}
@@ -126,10 +165,70 @@ export function DefaultModelsStep({ + + {/* Model tiers - collapsible */} + {chatModel?.lg && ( + + + + {t('admin.setting.ai.chatModels.modelTiers')} + {!tiersOpen && ( + + {customizedCount > 0 + ? t('admin.setting.ai.chatModels.customized', { count: customizedCount }) + : t('admin.setting.ai.chatModels.allInheriting')} + + )} + + +
+ {t('admin.setting.ai.chatModels.modelTiersDescription')} +
+
+
+
+ + {t('admin.setting.ai.chatModels.md')} + + + {t('admin.setting.ai.chatModels.mdDescription')} + +
+ +
+
+
+ + {t('admin.setting.ai.chatModels.sm')} + + + {t('admin.setting.ai.chatModels.smDescription')} + +
+ +
+
+
+
+ )}
{/* Status */} diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select/ModelSelectTrigger.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select/ModelSelectTrigger.tsx index 9fbf8e0bab..cd22ae0b32 100644 --- a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select/ModelSelectTrigger.tsx +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select/ModelSelectTrigger.tsx @@ -40,7 +40,9 @@ export const ModelSelectTrigger = forwardRef
{!currentModel ? ( - placeholder ?? t('admin.setting.ai.selectModel') + + {placeholder ?? t('admin.setting.ai.selectModel')} + ) : ( <> {Icon && } diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/constant.ts b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/constant.ts index eb05cae38f..cfa90d5861 100644 --- a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/constant.ts +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/constant.ts @@ -54,6 +54,7 @@ export const LLM_PROVIDER_ICONS = { [LLMProviderType.OPENROUTER]: OpenRouter, [LLMProviderType.OPENAI_COMPATIBLE]: Openai, [LLMProviderType.AI_GATEWAY]: Zap, // AI Gateway uses Zap icon + [LLMProviderType.CLAUDE_CODE]: Anthropic, }; export const LLM_PROVIDERS = [ @@ -170,6 +171,13 @@ export const LLM_PROVIDERS = [ modelsPlaceholder: 'gpt-4.1,o3,gpt-4.1-mini', Icon: LLM_PROVIDER_ICONS[LLMProviderType.OPENAI_COMPATIBLE], }, + { + value: LLMProviderType.CLAUDE_CODE, + label: 'Claude Code', + baseUrlPlaceholder: 'https://api.anthropic.com/v1', + modelsPlaceholder: 'claude-sonnet-4-6,claude-opus-4-6', + Icon: LLM_PROVIDER_ICONS[LLMProviderType.CLAUDE_CODE], + }, ] as const; // Gateway provider icons (owned_by field from AI Gateway API) diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeMore.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeMore.tsx index 0369fa4f02..063b43f95c 100644 --- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeMore.tsx +++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeMore.tsx @@ -1,17 +1,7 @@ /* eslint-disable sonarjs/no-identical-functions */ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { getUniqName } from '@teable/core'; -import { - Copy, - FileCsv, - FileExcel, - Pencil, - History, - Code2, - Trash2, - Download, - Share2, -} from '@teable/icons'; +import { FileCsv, FileExcel, History, Code2, Download, Share2 } from '@teable/icons'; import type { IBaseNodeVo, IDuplicateBaseNodeRo } from '@teable/openapi'; import { BaseNodeResourceType, SUPPORTEDTYPE } from '@teable/openapi'; import { RecordHistory } from '@teable/sdk/components/expand-record/RecordHistory'; @@ -45,7 +35,7 @@ import { Switch, } from '@teable/ui-lib/shadcn'; import { toast } from '@teable/ui-lib/shadcn/ui/sonner'; -import { FileInputIcon } from 'lucide-react'; +import { CopyPlus, FileInputIcon, Pen, Trash } from 'lucide-react'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import { useCallback, useMemo, useRef, useState } from 'react'; @@ -219,14 +209,14 @@ const CommonOperation = (props: ICommonOperationProps) => { <> {canRename && ( } + icon={} label={t('table:table.rename')} onClick={() => onRename?.()} /> )} {canDuplicate && ( } + icon={} label={t('table:import.menu.duplicate')} onClick={handleDuplicateClick} /> @@ -240,7 +230,7 @@ const CommonOperation = (props: ICommonOperationProps) => { )} {canPermanentDelete && ( } + icon={} label={t('common:actions.permanentDelete')} onClick={() => onDelete?.(true)} destructive @@ -248,7 +238,7 @@ const CommonOperation = (props: ICommonOperationProps) => { )} {canDelete && ( } + icon={} label={t('common:actions.delete')} onClick={() => onDelete?.(false)} /> @@ -278,13 +268,13 @@ const CommonOperation = (props: ICommonOperationProps) => { > {canRename && ( onRename?.()}> - + {t('table:table.rename')} )} {canDuplicate && ( - + {t('table:import.menu.duplicate')} )} @@ -299,7 +289,7 @@ const CommonOperation = (props: ICommonOperationProps) => { className="text-destructive focus:text-destructive" onClick={() => onDelete?.(true)} > - + {t('common:actions.permanentDelete')} )} @@ -308,7 +298,7 @@ const CommonOperation = (props: ICommonOperationProps) => { className="text-destructive focus:text-destructive" onClick={() => onDelete?.(false)} > - + {t('common:actions.delete')} )} @@ -626,7 +616,7 @@ export const TableOperation = (props: IBaseNodeMoreProps) => { <> {menuPermission.duplicateTable && ( } + icon={} label={t('table:import.menu.duplicate')} onClick={() => setDuplicateSetting(true)} /> @@ -693,7 +683,7 @@ export const TableOperation = (props: IBaseNodeMoreProps) => { variant="ghost" className="h-auto w-full justify-start gap-3 rounded-none border-b p-3" > - + {t('table:tableTrash.title')} @@ -718,7 +708,7 @@ export const TableOperation = (props: IBaseNodeMoreProps) => { )} {menuPermission.deleteTable && ( } + icon={} label={t('common:actions.delete')} onClick={() => setDeleteConfirm(true)} destructive @@ -742,13 +732,13 @@ export const TableOperation = (props: IBaseNodeMoreProps) => { > {menuPermission.updateTable && ( onRename?.()}> - + {t('table:table.rename')} )} {menuPermission.duplicateTable && ( setDuplicateSetting(true)}> - + {t('table:import.menu.duplicate')} )} @@ -821,7 +811,7 @@ export const TableOperation = (props: IBaseNodeMoreProps) => { setTableTrashDialogOpen(true); }} > - + {t('table:tableTrash.title')} )} @@ -842,7 +832,7 @@ export const TableOperation = (props: IBaseNodeMoreProps) => { className="text-destructive focus:text-destructive" onClick={() => setDeleteConfirm(true)} > - + {t('common:actions.delete')} )} diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSidebarHeaderLeft.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSidebarHeaderLeft.tsx index df20ae8a65..d69dcde711 100644 --- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSidebarHeaderLeft.tsx +++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSidebarHeaderLeft.tsx @@ -133,14 +133,16 @@ const BaseDropdownMenu = ({
)} - setOpen(false)} closeOnSuccess={false}> - e.preventDefault()}> -
- - {t('space:publishBase.publishToCommunity')} -
-
-
+ {showRename && ( + setOpen(false)} closeOnSuccess={false}> + e.preventDefault()}> +
+ + {t('space:publishBase.publishToCommunity')} +
+
+
+ )} diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/NodeShareContent.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/NodeShareContent.tsx index 11bb39be80..9231b8d23c 100644 --- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/NodeShareContent.tsx +++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/NodeShareContent.tsx @@ -48,7 +48,7 @@ import { TooltipTrigger, } from '@teable/ui-lib/shadcn'; import { toast } from '@teable/ui-lib/shadcn/ui/sonner'; -import { Check, ChevronDown, ChevronRight, Eye, HelpCircle } from 'lucide-react'; +import { Check, ChevronDown, ChevronRight, Eye } from 'lucide-react'; import { useTranslation } from 'next-i18next'; import { QRCodeSVG } from 'qrcode.react'; import { useMemo, useState } from 'react'; @@ -403,42 +403,68 @@ export const NodeShareContent = ({ {isShareEnabled && share && ( <>
-
- {t('table:baseShare.linkHolderLabel')} - - - - - - handleUpdateSetting({ allowSave: false })} - > - {!share.allowSave ? ( - - ) : ( - - )} - {t('table:baseShare.linkHolderCanView')} - - handleUpdateSetting({ allowSave: true })} - > - {share.allowSave ? ( - - ) : ( - - )} - {t('table:baseShare.linkHolderCanCopyAndSave')} - - - +
+
+ + {t('table:baseShare.linkHolderLabel')} + + + + + + + {[ + { + active: !share.allowSave && !share.allowEdit, + label: t('table:baseShare.linkHolderCanView'), + desc: t('table:baseShare.linkHolderCanViewDesc'), + onClick: () => handleUpdateSetting({ allowSave: false, allowEdit: false }), + }, + node.resourceType === BaseNodeResourceType.Table && { + active: !!share.allowEdit, + label: t('table:baseShare.linkHolderCanEdit'), + desc: t('table:baseShare.linkHolderCanEditDesc'), + onClick: () => handleUpdateSetting({ allowEdit: true, allowSave: false }), + }, + { + active: !!share.allowSave, + label: t('table:baseShare.linkHolderCanCopyAndSave'), + desc: t('table:baseShare.linkHolderCanCopyAndSaveDesc'), + onClick: () => handleUpdateSetting({ allowSave: true, allowEdit: false }), + }, + ] + .filter((item): item is Exclude => Boolean(item)) + .map((item) => ( + +
+ {item.active ? ( + + ) : ( + + )} +
+ {item.label} + + {item.desc} + +
+
+
+ ))} +
+
+
diff --git a/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Components.tsx b/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Components.tsx index 39f8270a81..facbc2195e 100644 --- a/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Components.tsx +++ b/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Components.tsx @@ -1,22 +1,62 @@ -import { Badge, Button, ToggleGroup, ToggleGroupItem, cn } from '@teable/ui-lib/shadcn'; +import { FieldType } from '@teable/core'; +import { useFieldStaticGetter } from '@teable/sdk/hooks'; +import { + Alert, + AlertDescription, + Badge, + Button, + Checkbox, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Textarea, + ToggleGroup, + ToggleGroupItem, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, + cn, +} from '@teable/ui-lib/shadcn'; import { AlertTriangle, + ChevronDown, CheckCircle2, Clock, + Columns3, + Info, Loader2, RefreshCcw, + Table2, Wrench, XCircle, } from 'lucide-react'; import { useTranslation } from 'next-i18next'; +import { useEffect, useMemo, useState, type ComponentType } from 'react'; import { getLocalizedDetailItems, + getLocalizedRepairDescription, + getLocalizedRepairReason, getLocalizedResultMessage, getLocalizedRuleDescription, getGroupDisplayName, getGroupDisplayState, integrityFilterStatuses, getPhaseText, + translateIntegrityMessage, type GroupDisplayState, type IntegrityFilterStatus, type IntegrityPhase, @@ -76,14 +116,462 @@ const OutcomeBadge = ({ result }: { result: IntegrityResult }) => { ); }; -const RuleResultItem = ({ result }: { result: IntegrityResult }) => { +const SYSTEM_FIELD_TYPE_MAP: Record = { + __id: FieldType.SingleLineText, + __auto_number: FieldType.AutoNumber, + __created_time: FieldType.CreatedTime, + __last_modified_time: FieldType.LastModifiedTime, + __created_by: FieldType.CreatedBy, + __last_modified_by: FieldType.LastModifiedBy, + __version: FieldType.Number, +}; + +const getSystemFieldType = (fieldId: string) => { + if (!fieldId.startsWith('__system__:')) { + return undefined; + } + + return SYSTEM_FIELD_TYPE_MAP[fieldId.replace('__system__:', '')]; +}; + +const getRuleType = (ruleId: string) => ruleId.split(':')[0]; + +const getColumnDataType = (ruleDescription: string) => { + const match = ruleDescription.match(/\(([^()]+)\)\s*$/); + return match?.[1]?.toLowerCase(); +}; + +const DB_TYPE_TO_FIELD_TYPE: Record = { + text: FieldType.SingleLineText, + varchar: FieldType.SingleLineText, + 'character varying': FieldType.SingleLineText, + integer: FieldType.Number, + bigint: FieldType.Number, + numeric: FieldType.Number, + real: FieldType.Number, + 'double precision': FieldType.Number, + timestamptz: FieldType.Date, + timestamp: FieldType.Date, + date: FieldType.Date, + boolean: FieldType.Checkbox, +}; + +const inferFieldTypeFromReferenceRule = (description: string) => { + if (description.includes('conditional rollup')) { + return { type: FieldType.ConditionalRollup }; + } + + if (description.includes('rollup')) { + return { type: FieldType.Rollup }; + } + + if (description.includes('conditional lookup')) { + return { type: FieldType.Link, isLookup: true, isConditionalLookup: true }; + } + + if (description.includes('lookup field') || description.includes('lookup-link field')) { + return { type: FieldType.Link, isLookup: true }; + } + + return undefined; +}; + +const inferFieldTypeFromRule = (result: IntegrityResult) => { + const ruleType = getRuleType(result.ruleId); + const description = result.ruleDescription.toLowerCase(); + + if ( + ruleType === 'link_value_column' || + ruleType === 'fk_column' || + ruleType === 'order_column' || + ruleType === 'field_meta' || + ruleType === 'symmetric_field' + ) { + return { type: FieldType.Link }; + } + + if (ruleType === 'reference') { + return inferFieldTypeFromReferenceRule(description); + } + + if (ruleType === 'generated_column' || ruleType === 'generated_meta') { + return { type: FieldType.Formula }; + } + + if (ruleType === 'column' || ruleType === 'system_column') { + const columnDataType = getColumnDataType(result.ruleDescription); + if (columnDataType && DB_TYPE_TO_FIELD_TYPE[columnDataType]) { + return { type: DB_TYPE_TO_FIELD_TYPE[columnDataType] }; + } + } + + return undefined; +}; + +const inferFieldTypeFromGroup = (group: ResultGroup) => { + const systemFieldType = getSystemFieldType(group.fieldId); + if (systemFieldType) { + return { type: systemFieldType }; + } + + for (const result of group.results) { + const inferredFieldType = inferFieldTypeFromRule(result); + if (inferredFieldType) { + return inferredFieldType; + } + } + + return undefined; +}; + +type ManualRepairSchema = NonNullable['manualRepairSchema']>; +type ManualRepairProperty = ManualRepairSchema['properties'][string]; + +const getManualRepairDefaultValues = (manualRepairSchema?: ManualRepairSchema) => { + return Object.fromEntries( + Object.entries(manualRepairSchema?.properties || {}).map(([key, property]) => [ + key, + property.defaultValue ?? (property.type === 'boolean' ? false : ''), + ]) + ) as Record; +}; + +const getManualRepairWidget = (property: ManualRepairProperty) => { + return ( + property.widget ?? + (property.options?.length ? 'select' : property.type === 'boolean' ? 'checkbox' : 'text') + ); +}; + +const ManualRepairFieldInput = ({ + property, + value, + onChange, + t, +}: { + property: ManualRepairProperty; + value: string | boolean | undefined; + onChange: (value: string | boolean) => void; + t: Translate; +}) => { + const widget = getManualRepairWidget(property); + + if (widget === 'select') { + return ( + + ); + } + + if (widget === 'textarea') { + return ( +