diff --git a/apps/nestjs-backend/package.json b/apps/nestjs-backend/package.json index 583ceaed14..eb3a2eb313 100644 --- a/apps/nestjs-backend/package.json +++ b/apps/nestjs-backend/package.json @@ -160,21 +160,21 @@ "@nestjs/websockets": "10.3.5", "@openrouter/ai-sdk-provider": "2.8.1", "@opentelemetry/api": "1.9.0", - "@opentelemetry/context-async-hooks": "2.5.0", - "@opentelemetry/exporter-logs-otlp-http": "0.201.1", - "@opentelemetry/exporter-metrics-otlp-http": "0.201.1", - "@opentelemetry/exporter-trace-otlp-http": "0.201.1", - "@opentelemetry/instrumentation-express": "0.50.0", - "@opentelemetry/instrumentation-http": "0.201.1", - "@opentelemetry/instrumentation-ioredis": "0.49.0", - "@opentelemetry/instrumentation-nestjs-core": "0.49.0", - "@opentelemetry/instrumentation-pg": "0.49.0", - "@opentelemetry/instrumentation-pino": "0.54.0", - "@opentelemetry/instrumentation-runtime-node": "0.24.0", - "@opentelemetry/resources": "2.0.1", - "@opentelemetry/sdk-node": "0.201.1", - "@opentelemetry/sdk-trace-base": "2.0.1", - "@opentelemetry/semantic-conventions": "1.34.0", + "@opentelemetry/context-async-hooks": "2.10.0", + "@opentelemetry/exporter-logs-otlp-http": "0.221.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.221.0", + "@opentelemetry/exporter-trace-otlp-http": "0.221.0", + "@opentelemetry/instrumentation-express": "0.69.0", + "@opentelemetry/instrumentation-http": "0.221.0", + "@opentelemetry/instrumentation-ioredis": "0.69.0", + "@opentelemetry/instrumentation-nestjs-core": "0.67.0", + "@opentelemetry/instrumentation-pg": "0.73.0", + "@opentelemetry/instrumentation-pino": "0.67.0", + "@opentelemetry/instrumentation-runtime-node": "0.34.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/sdk-node": "0.221.0", + "@opentelemetry/sdk-trace-base": "2.10.0", + "@opentelemetry/semantic-conventions": "1.43.0", "@orpc/nest": "1.13.0", "@prisma/client": "6.2.1", "@prisma/instrumentation": "6.2.1", @@ -265,6 +265,7 @@ "passport-openidconnect": "0.1.2", "pause": "0.1.0", "pdf-parse": "2.4.5", + "permessage-deflate": "0.1.7", "pg": "8.11.5", "pino-http": "10.5.0", "pino-pretty": "11.0.0", diff --git a/apps/nestjs-backend/src/app.module.ts b/apps/nestjs-backend/src/app.module.ts index bb40194e46..4fc8be8440 100644 --- a/apps/nestjs-backend/src/app.module.ts +++ b/apps/nestjs-backend/src/app.module.ts @@ -40,6 +40,7 @@ import { PluginModule } from './features/plugin/plugin.module'; import { PluginContextMenuModule } from './features/plugin-context-menu/plugin-context-menu.module'; import { PluginPanelModule } from './features/plugin-panel/plugin-panel.module'; import { RecordHistoryColdModule } from './features/record-history-cold/record-history-cold.module'; +import { RecordRemovalColdModule } from './features/record-removal-cold/record-removal-cold.module'; import { SelectionModule } from './features/selection/selection.module'; import { AdminOpenApiModule } from './features/setting/open-api/admin-open-api.module'; import { SettingOpenApiModule } from './features/setting/open-api/setting-open-api.module'; @@ -102,10 +103,11 @@ export const appModules = { AiModule, PluginModule, PluginPanelModule, - // the ONLY mount of the cold queue CONSUMER: feature modules import - // RecordHistoryColdCoreModule (services only), so auxiliary entrypoints - // composing them never become competing cold-queue workers + // the ONLY mount of the cold queue CONSUMERS: feature modules import + // the Core modules (services only), so auxiliary entrypoints composing + // them never become competing cold-queue workers RecordHistoryColdModule, + RecordRemovalColdModule, PluginContextMenuModule, PluginChartModule, ObservabilityModule, diff --git a/apps/nestjs-backend/src/bootstrap.ts b/apps/nestjs-backend/src/bootstrap.ts index 515124e1aa..76de7bfe40 100644 --- a/apps/nestjs-backend/src/bootstrap.ts +++ b/apps/nestjs-backend/src/bootstrap.ts @@ -4,6 +4,7 @@ import type { INestApplication } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; +import { isDomainError, toError } from '@teable/v2-core'; import { json, urlencoded } from 'express'; import helmet from 'helmet'; import isPortReachable from 'is-port-reachable'; @@ -84,9 +85,13 @@ export async function bootstrap() { logger.log(`> System Time Zone: ${timeZone}`); logger.log(`> Current System Time: ${now.toString()}`); - process.on('unhandledRejection', (reason: string, promise: Promise) => { - logger.error(`Unhandled Rejection at: ${promise}, reason: ${reason}`); - throw reason; + process.on('unhandledRejection', (reason: unknown, promise: Promise) => { + // DomainError is intentionally a POJO (Result-based, not thrown). If one + // still escapes as an unhandled rejection, wrap it so Sentry gets a real + // stack-bearing Error instead of collapsing into activeSpanWrapper. + const normalized = isDomainError(reason) ? toError(reason) : reason; + logger.error(`Unhandled Rejection at: ${promise}, reason: ${normalized}`); + throw normalized; }); process.on('uncaughtException', (error) => { diff --git a/apps/nestjs-backend/src/cache/redis-native.service.ts b/apps/nestjs-backend/src/cache/redis-native.service.ts index c38a14d404..6442e5e9ae 100644 --- a/apps/nestjs-backend/src/cache/redis-native.service.ts +++ b/apps/nestjs-backend/src/cache/redis-native.service.ts @@ -323,6 +323,30 @@ export class RedisNativeService { }); } + /** + * Batch ZCOUNT via pipeline — single network roundtrip for multiple sorted-set keys. + * @param keys - Array of Redis sorted set keys + * @param min - Minimum score (number or '-inf') + * @param max - Maximum score (number or '+inf') + * @returns Array of counts (0 for missing keys) + * @throws on any per-command error — unknown must not read as zero + */ + async zcountMulti(keys: string[], min: number | string, max: number | string): Promise { + if (keys.length === 0) return []; + const pipe = this.client.pipeline(); + for (const key of keys) { + pipe.zcount(key, min, max); + } + const replies = await pipe.exec(); + return keys.map((key, i) => { + const reply = replies?.[i]; + if (!reply) throw new Error(`zcountMulti got no reply for ${key}`); + const [err, count] = reply; + if (err) throw err; + return (count as number) ?? 0; + }); + } + /** * Batch SCARD via pipeline — single network roundtrip for multiple set keys. * @param keys - Array of Redis set keys diff --git a/apps/nestjs-backend/src/cache/types.ts b/apps/nestjs-backend/src/cache/types.ts index 2e956f682f..cf45c6aec5 100644 --- a/apps/nestjs-backend/src/cache/types.ts +++ b/apps/nestjs-backend/src/cache/types.ts @@ -14,6 +14,10 @@ export interface ICacheStore { [key: `auth:session-store:${string}`]: ISessionData; [key: `auth:session-user:${string}`]: Record; [key: `auth:session-expire:${string}`]: boolean; + // Epoch seconds of the user's last clearByUserId, kept for the session ttl: + // distinguishes "revoked by sign-out-everywhere" from "lost the concurrent + // read-modify-write on the per-user session map". + [key: `auth:session-user-cleared:${string}`]: number; [key: `oauth2:${string}`]: IOauth2State; [key: `reset-password-email:${string}`]: IResetPasswordEmailCache; [key: `workflow:running:${string}`]: string; @@ -125,6 +129,7 @@ export enum OperationName { UpdateView = 'updateView', CreateRecords = 'createRecords', DeleteRecords = 'deleteRecords', + ArchiveRecords = 'archiveRecords', UpdateRecords = 'updateRecords', UpdateRecordsOrder = 'updateRecordsOrder', CreateFields = 'createFields', @@ -191,6 +196,19 @@ export interface IDeleteRecordsOperation extends Omit { if (!value) { @@ -13,17 +15,23 @@ const getCookieSecure = (value: string | undefined) => { return value === 'true'; }; +// Secret resolution and policy live in ./secrets (secret-specs.ts is the +// single source of truth; secrets-policy.ts enforces production policy). export const authConfig = registerAs('auth', () => ({ jwt: { - secret: - process.env.BACKEND_JWT_SECRET ?? process.env.SECRET_KEY ?? '533Cr3tK3yF0rH4sh1nGJ4W773k3n$', + secret: resolveSecret(SECRET_SPECS.jwtSecret), + // Verify-only fallback for PLANNED rotations of BACKEND_JWT_SECRET: + // TeableJwtService signs with `secret` and verifies against both. A leaked + // secret must be hard-cut (never listed here) — see features/auth/jwt. + oldSecret: process.env.BACKEND_JWT_SECRET_OLD, expiresIn: process.env.BACKEND_JWT_EXPIRES_IN ?? '20d', }, session: { - secret: - process.env.BACKEND_SESSION_SECRET ?? - process.env.SECRET_KEY ?? - 'dafea6be69af1c1c3b8caf2b609342f6eb4540b554e19539f7643b75b480c932', + secret: resolveSecret(SECRET_SPECS.sessionSecret), + // Verify-only fallback accepted while rotating BACKEND_SESSION_SECRET. + // express-session validates a signed cookie against every secret in the + // array (first entry signs new cookies), so existing sessions survive. + oldSecret: process.env.BACKEND_SESSION_SECRET_OLD, expiresIn: process.env.BACKEND_SESSION_EXPIRES_IN ?? '7d', cookie: { secure: getCookieSecure(process.env.BACKEND_SESSION_COOKIE_SECURE), @@ -33,8 +41,8 @@ export const authConfig = registerAs('auth', () => ({ prefix: 'teable', encryption: { algorithm: process.env.BACKEND_ACCESS_TOKEN_ENCRYPTION_ALGORITHM ?? 'aes-128-cbc', - key: process.env.BACKEND_ACCESS_TOKEN_ENCRYPTION_KEY ?? 'ie21hOKjlXUiGDx9', - iv: process.env.BACKEND_ACCESS_TOKEN_ENCRYPTION_IV ?? 'i0vKGXBWkzyAoGf4', + key: resolveSecret(SECRET_SPECS.accessTokenEncryptionKey), + iv: resolveSecret(SECRET_SPECS.accessTokenEncryptionIv), }, }, resetPasswordEmailExpiresIn: diff --git a/apps/nestjs-backend/src/configs/base.config.ts b/apps/nestjs-backend/src/configs/base.config.ts index 7743e7ea7c..1f67935cd3 100644 --- a/apps/nestjs-backend/src/configs/base.config.ts +++ b/apps/nestjs-backend/src/configs/base.config.ts @@ -2,12 +2,14 @@ import { Inject } from '@nestjs/common'; import type { ConfigType } from '@nestjs/config'; import { registerAs } from '@nestjs/config'; +import { resolveSecret } from './secrets/resolve-secret'; +import { SECRET_SPECS } from './secrets/secret-specs'; export const baseConfig = registerAs('base', () => ({ isCloud: process.env.NEXT_BUILD_ENV_EDITION?.toUpperCase() === 'CLOUD', publicOrigin: process.env.PUBLIC_ORIGIN, storagePrefix: process.env.STORAGE_PREFIX ?? process.env.PUBLIC_ORIGIN, - secretKey: process.env.SECRET_KEY ?? 'defaultSecretKey', + secretKey: resolveSecret(SECRET_SPECS.secretKey), publicDatabaseProxy: process.env.PUBLIC_DATABASE_PROXY, defaultMaxBaseDBConnections: Number(process.env.DEFAULT_MAX_BASE_DB_CONNECTIONS ?? 20), templateSpaceId: process.env.TEMPLATE_SPACE_ID, diff --git a/apps/nestjs-backend/src/configs/computed-outbox-trigger.config.ts b/apps/nestjs-backend/src/configs/computed-outbox-trigger.config.ts index c4631cdb83..2904c17b74 100644 --- a/apps/nestjs-backend/src/configs/computed-outbox-trigger.config.ts +++ b/apps/nestjs-backend/src/configs/computed-outbox-trigger.config.ts @@ -36,6 +36,14 @@ export const computedOutboxTriggerConfig = registerAs('computedOutboxTrigger', ( process.env.V2_COMPUTED_OUTBOX_MONITOR_INTERVAL_MS, 30_000 ), + // Caps how many wakeups one redrive scan publishes per target. A resumed + // pause or long outage can leave weeks of backlog; draining it in a single + // sweep floods the claim path and the data-db pool. The remainder is + // picked up by the following reconcile cycles. + redriveMaxPublishPerTarget: readPositiveInteger( + process.env.V2_COMPUTED_OUTBOX_REDRIVE_MAX_PUBLISH_PER_TARGET, + 1000 + ), }; }); diff --git a/apps/nestjs-backend/src/configs/config.module.ts b/apps/nestjs-backend/src/configs/config.module.ts index 314f35e5fc..d427fdd72f 100644 --- a/apps/nestjs-backend/src/configs/config.module.ts +++ b/apps/nestjs-backend/src/configs/config.module.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import fs from 'fs'; import path from 'path'; import type { DynamicModule } from '@nestjs/common'; import { Logger, Module } from '@nestjs/common'; @@ -13,6 +14,7 @@ import { loggerConfig } from './logger.config'; import { mailConfig } from './mail.config'; import { oauthConfig } from './oauth.config'; import { riskControlConfig } from './risk-control.config'; +import { enforceSecretsPolicy } from './secrets/secrets-policy'; import { storageConfig } from './storage'; import { thresholdConfig } from './threshold.config'; import { trashConfig } from './trash.config'; @@ -32,24 +34,42 @@ const configurations = [ riskControlConfig, ]; +// The env files live in the nextjs-app package. NEXTJS_DIR is relative to the +// backend package dir, but the process may be started from the repo root (make, +// IDE run configs) — probe the known anchors instead of trusting cwd, since a +// silently unresolved path now means missing secrets instead of falling back +// to (removed) source-code defaults. +const resolveEnvFileDir = (): string => { + const nextJsDir = nextJsConfig().dir; + const candidates = [ + path.join(process.cwd(), nextJsDir), + path.join(process.cwd(), 'community/apps/nextjs-app'), + ]; + return candidates.find((dir) => fs.existsSync(dir)) ?? candidates[0]; +}; + @Module({}) export class ConfigModule { static register(): DynamicModule { - return BaseConfigModule.forRoot({ + const envDir = resolveEnvFileDir(); + const dynamicModule = BaseConfigModule.forRoot({ isGlobal: true, cache: true, expandVariables: true, load: configurations, envFilePath: ['.env.development.local', '.env.development', '.env'].map((str) => { - const nextJsDir = nextJsConfig().dir; - const envDir = nextJsDir ? path.join(process.cwd(), nextJsDir, str) : str; + const envFile = path.join(envDir, str); Logger.attachBuffer(); - Logger.log(`[Env File Path]: ${envDir}`); + Logger.log(`[Env File Path]: ${envFile}`); Logger.detachBuffer(); - return envDir; + return envFile; }), validationSchema: envValidationSchema, }); + // forRoot has synchronously merged the env files into process.env; enforce + // the secrets policy now, before any config factory resolves a secret. + enforceSecretsPolicy(); + return dynamicModule; } } diff --git a/apps/nestjs-backend/src/configs/env.validation.schema.ts b/apps/nestjs-backend/src/configs/env.validation.schema.ts index c53b979e7a..33253b1948 100644 --- a/apps/nestjs-backend/src/configs/env.validation.schema.ts +++ b/apps/nestjs-backend/src/configs/env.validation.schema.ts @@ -24,6 +24,22 @@ export const envValidationSchema = Joi.object({ PUBLIC_ORIGIN: Joi.string().uri().required(), + // secrets — shape only; production requirements and migration teaching are + // enforced by enforceSecretsPolicy (configs/secrets/secrets-policy.ts) + SECRET_KEY: Joi.string().optional(), + BACKEND_JWT_SECRET: Joi.string().optional(), + BACKEND_JWT_SECRET_OLD: Joi.string().optional(), + BACKEND_SESSION_SECRET: Joi.string().optional(), + BACKEND_SESSION_SECRET_OLD: Joi.string().optional(), + BACKEND_ACCESS_TOKEN_ENCRYPTION_KEY: Joi.string().optional(), + BACKEND_ACCESS_TOKEN_ENCRYPTION_IV: Joi.string().optional(), + BACKEND_DATA_DB_URL_ENCRYPTION_KEY: Joi.string().optional(), + BACKEND_DATA_DB_URL_ENCRYPTION_IV: Joi.string().optional(), + BACKEND_MAIL_ENCRYPTION_KEY: Joi.string().optional(), + BACKEND_MAIL_ENCRYPTION_IV: Joi.string().optional(), + BACKEND_STORAGE_ENCRYPTION_KEY: Joi.string().optional(), + BACKEND_STORAGE_ENCRYPTION_IV: Joi.string().optional(), + // Express `trust proxy`: 'true' | 'false' | hop count | IP/CIDR/preset list. // Unset = trust private-network proxies (see parseTrustProxy in bootstrap.config). BACKEND_TRUST_PROXY: Joi.string().optional(), @@ -49,6 +65,7 @@ export const envValidationSchema = Joi.object({ V2_COMPUTED_OUTBOX_TRIGGER_PUBLISH_TIMEOUT_MS: Joi.number().integer().positive().default(1000), V2_COMPUTED_OUTBOX_MONITOR_CONCURRENCY: Joi.number().integer().positive().default(4), V2_COMPUTED_OUTBOX_MONITOR_INTERVAL_MS: Joi.number().integer().positive().default(30000), + // Computed stage budget overrides (0 disables that dimension; all 0 = no staging) // per-space scheduling default concurrency limits SPACE_AI_FIELD_GENERATION_DEFAULT_LIMIT: Joi.number().integer().positive().optional(), diff --git a/apps/nestjs-backend/src/configs/mail.config.ts b/apps/nestjs-backend/src/configs/mail.config.ts index 9888e46a91..4cf6de021f 100644 --- a/apps/nestjs-backend/src/configs/mail.config.ts +++ b/apps/nestjs-backend/src/configs/mail.config.ts @@ -2,6 +2,8 @@ import { Inject } from '@nestjs/common'; import type { ConfigType } from '@nestjs/config'; import { registerAs } from '@nestjs/config'; +import { resolveSecret } from './secrets/resolve-secret'; +import { SECRET_SPECS } from './secrets/secret-specs'; export const mailConfig = registerAs('mail', () => { const host = process.env.BACKEND_MAIL_HOST; @@ -38,8 +40,8 @@ export const mailConfig = registerAs('mail', () => { dnsTimeout: parseInt(process.env.BACKEND_MAIL_DNS_TIMEOUT ?? '5000', 10), encryption: { algorithm: 'aes-128-cbc', - key: process.env.BACKEND_MAIL_ENCRYPTION_KEY ?? 'ie21hOKjlXUiGDx1', - iv: process.env.BACKEND_MAIL_ENCRYPTION_IV ?? 'i0vKGXBWkzyAoGf1', + key: resolveSecret(SECRET_SPECS.mailEncryptionKey), + iv: resolveSecret(SECRET_SPECS.mailEncryptionIv), encoding: 'base64' as BufferEncoding, }, }; diff --git a/apps/nestjs-backend/src/configs/secrets/resolve-secret.ts b/apps/nestjs-backend/src/configs/secrets/resolve-secret.ts new file mode 100644 index 0000000000..a7091f8964 --- /dev/null +++ b/apps/nestjs-backend/src/configs/secrets/resolve-secret.ts @@ -0,0 +1,45 @@ +import { createHash } from 'crypto'; +import type { ISecretSpec } from './secret-specs'; + +/** + * Per-purpose derivation from the root SECRET_KEY: every purpose yields a + * distinct 16-char value, so sharing one root secret never shares key material + * across subsystems. + */ +const deriveFromSecretKey = (purpose: string): string | undefined => { + const secretKey = process.env.SECRET_KEY; + if (!secretKey) return undefined; + return createHash('sha256').update(`${secretKey}:teable:${purpose}`).digest('hex').slice(0, 16); +}; + +/** + * Resolve a secret: dedicated env var → fallback env vars → SECRET_KEY + * derivation. Config factories call this, which runs when ConfigModule loads — + * BEFORE any module init — so a missing secret fails fast with the env name + * instead of leaking `undefined` into providers. Policy (what production + * requires, migration teaching, weak-value warnings) lives in + * secrets-policy.ts, not here. + * + * The SECRET_KEY derivation never yields a value anything CONSUMES in a + * running app: for required specs, enforceSecretsPolicy refuses to boot on a + * missing dedicated var regardless of SECRET_KEY (booting on derivation would + * silently change an existing deployment's keys); only requiredWhen-exempt + * specs (the storage pair on cloud providers) reach this branch, and there + * the resolved config value is read by nothing. + */ +export const resolveSecret = (spec: ISecretSpec): string => { + const resolved = + process.env[spec.envKey] ?? + spec.fallbackEnvKeys?.map((key) => process.env[key]).find((value) => value) ?? + (spec.derivePurpose ? deriveFromSecretKey(spec.derivePurpose) : undefined); + if (!resolved) { + const alternatives = [ + ...(spec.fallbackEnvKeys ?? []), + ...(spec.derivePurpose ? ['SECRET_KEY'] : []), + ]; + throw new Error( + `Missing secret configuration: set ${spec.envKey}${alternatives.length ? ` (or ${[...new Set(alternatives)].join(' / ')})` : ''}` + ); + } + return resolved; +}; diff --git a/apps/nestjs-backend/src/configs/secrets/secret-specs.ts b/apps/nestjs-backend/src/configs/secrets/secret-specs.ts new file mode 100644 index 0000000000..0974d8ec40 --- /dev/null +++ b/apps/nestjs-backend/src/configs/secrets/secret-specs.ts @@ -0,0 +1,125 @@ +/** + * Single source of truth for every server secret. + * + * Each secret resolves in two layers (resolve-secret.ts): its dedicated env + * var, then a value derived from the instance-wide SECRET_KEY. Source code + * carries NO secret literals as fallbacks — development reads explicit values + * from .env.development, unit tests seed placeholders in vitest.setup.ts, and + * enforceSecretsPolicy() refuses to boot production without the required env + * vars, printing the pin instructions declared here. + * + * `legacyDefault` is the PUBLIC value that used to be hardcoded in source (it + * lives in git history, so printing it is harmless). It serves two purposes: + * the boot error teaches existing deployments to pin it, and the guard warns + * loudly when a production instance is still running on it. Secrets whose + * previous effective value was NOT a public constant use `pinInstruction` + * instead — never print derived key material. + */ + +export interface ISecretSpec { + /** dedicated env var */ + envKey: string; + /** additional env vars that satisfy the requirement (e.g. SECRET_KEY umbrella) */ + fallbackEnvKeys?: string[]; + /** derive from SECRET_KEY under this purpose when the dedicated var is unset */ + derivePurpose?: string; + usedFor: string; + /** public literal that used to be the source-code default */ + legacyDefault?: string; + /** printed in the boot error when the previous value is not a public constant */ + pinInstruction?: string; + /** + * Required only while this predicate holds (default: always). For secrets + * whose sole consumer is itself selected by boot-time env — demanding them + * unconditionally would force dead config on deployments that never read + * them and undermine the boot error's credibility. + */ + requiredWhen?: (env: Record) => boolean; +} + +export const SECRET_SPECS = { + secretKey: { + envKey: 'SECRET_KEY', + usedFor: 'root secret every unset secret derives from', + // 'defaultSecretKey' was only ever the dev fallback for EE env-variable + // encryption; deployments that relied on it must pin it to keep decrypting. + legacyDefault: 'defaultSecretKey', + pinInstruction: + "generate one: `openssl rand -base64 32`. Existing deployments that stored EE app env-variables WITHOUT SECRET_KEY set must pin SECRET_KEY='defaultSecretKey' to keep decrypting them (rotate afterwards).", + }, + jwtSecret: { + envKey: 'BACKEND_JWT_SECRET', + fallbackEnvKeys: ['SECRET_KEY'], + usedFor: 'signing auth / share / plugin JWTs', + legacyDefault: '533Cr3tK3yF0rH4sh1nGJ4W773k3n$', + }, + sessionSecret: { + envKey: 'BACKEND_SESSION_SECRET', + fallbackEnvKeys: ['SECRET_KEY'], + usedFor: 'signing login session cookies', + legacyDefault: 'dafea6be69af1c1c3b8caf2b609342f6eb4540b554e19539f7643b75b480c932', + }, + mailEncryptionKey: { + envKey: 'BACKEND_MAIL_ENCRYPTION_KEY', + derivePurpose: 'mail-key', + usedFor: 'encrypting email unsubscribe-link tokens', + legacyDefault: 'ie21hOKjlXUiGDx1', + }, + mailEncryptionIv: { + envKey: 'BACKEND_MAIL_ENCRYPTION_IV', + derivePurpose: 'mail-iv', + usedFor: 'encrypting email unsubscribe-link tokens', + legacyDefault: 'i0vKGXBWkzyAoGf1', + }, + // The pair is only read by the local storage adapter (it mints expiring + // attachment-URL tokens); s3/minio/aliyun hand out presigned URLs instead. + // Same default-to-local criterion as storage.config's provider field, and + // the provider is fixed at boot — switching to 'local' later fails fast + // with the pin instructions at that restart. + storageEncryptionKey: { + envKey: 'BACKEND_STORAGE_ENCRYPTION_KEY', + derivePurpose: 'storage-key', + usedFor: 'encrypting attachment access tokens (local storage provider only)', + legacyDefault: '73b00476e456323e', + requiredWhen: (env) => (env.BACKEND_STORAGE_PROVIDER ?? 'local') === 'local', + }, + storageEncryptionIv: { + envKey: 'BACKEND_STORAGE_ENCRYPTION_IV', + derivePurpose: 'storage-iv', + usedFor: 'encrypting attachment access tokens (local storage provider only)', + legacyDefault: '8c9183e4c175f63c', + requiredWhen: (env) => (env.BACKEND_STORAGE_PROVIDER ?? 'local') === 'local', + }, + accessTokenEncryptionKey: { + envKey: 'BACKEND_ACCESS_TOKEN_ENCRYPTION_KEY', + derivePurpose: 'access-token-key', + usedFor: 'encrypting personal access tokens', + legacyDefault: 'ie21hOKjlXUiGDx9', + }, + accessTokenEncryptionIv: { + envKey: 'BACKEND_ACCESS_TOKEN_ENCRYPTION_IV', + derivePurpose: 'access-token-iv', + usedFor: 'encrypting personal access tokens', + legacyDefault: 'i0vKGXBWkzyAoGf4', + }, + // BYODB keys keep their historical resolution chain (dedicated var, then the + // access-token key, then sha256(SECRET_KEY) with key == iv) — see + // data-db-url-secret.ts. Their previous effective value depends on the + // deployment's own env, so the boot error teaches how to compute it and + // never prints derived key material. + dataDbUrlEncryptionKey: { + envKey: 'BACKEND_DATA_DB_URL_ENCRYPTION_KEY', + usedFor: 'encrypting BYODB database URLs', + pinInstruction: + "previous effective value: your BACKEND_ACCESS_TOKEN_ENCRYPTION_KEY if it was set, otherwise compute `node -e \"console.log(require('crypto').createHash('sha256').update(process.env.SECRET_KEY).digest('hex').slice(0,16))\"`. New deployments: `openssl rand -hex 8`.", + }, + dataDbUrlEncryptionIv: { + envKey: 'BACKEND_DATA_DB_URL_ENCRYPTION_IV', + usedFor: 'encrypting BYODB database URLs', + pinInstruction: + 'previous effective value: your BACKEND_ACCESS_TOKEN_ENCRYPTION_IV if it was set, otherwise the SAME sha256(SECRET_KEY) value as the KEY above (a historical quirk). New deployments: `openssl rand -hex 8`.', + }, +} as const satisfies Record; + +/** Every spec, for iteration by the guard. */ +export const ALL_SECRET_SPECS: readonly ISecretSpec[] = Object.values(SECRET_SPECS); diff --git a/apps/nestjs-backend/src/configs/secrets/secrets-policy.spec.ts b/apps/nestjs-backend/src/configs/secrets/secrets-policy.spec.ts new file mode 100644 index 0000000000..065449e505 --- /dev/null +++ b/apps/nestjs-backend/src/configs/secrets/secrets-policy.spec.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { ALL_SECRET_SPECS, SECRET_SPECS } from './secret-specs'; +import { + buildMissingSecretsMessage, + buildPublicDefaultsWarning, + enforceSecretsPolicy, + findMissingSecrets, + findPublicDefaultSecrets, +} from './secrets-policy'; + +const fullEnv = { + SECRET_KEY: 'strong-secret', + BACKEND_JWT_SECRET: 'jwt-secret', + BACKEND_SESSION_SECRET: 'session-secret', + BACKEND_MAIL_ENCRYPTION_KEY: 'k'.repeat(16), + BACKEND_MAIL_ENCRYPTION_IV: 'v'.repeat(16), + BACKEND_STORAGE_ENCRYPTION_KEY: 'k'.repeat(16), + BACKEND_STORAGE_ENCRYPTION_IV: 'v'.repeat(16), + BACKEND_ACCESS_TOKEN_ENCRYPTION_KEY: 'k'.repeat(16), + BACKEND_ACCESS_TOKEN_ENCRYPTION_IV: 'v'.repeat(16), + BACKEND_DATA_DB_URL_ENCRYPTION_KEY: 'k'.repeat(16), + BACKEND_DATA_DB_URL_ENCRYPTION_IV: 'v'.repeat(16), +}; + +describe('secrets guard', () => { + it('accepts a fully configured environment', () => { + expect(findMissingSecrets(ALL_SECRET_SPECS, fullEnv)).toEqual([]); + }); + + it('reports every secret missing on an empty environment, with pin teaching', () => { + const missing = findMissingSecrets(ALL_SECRET_SPECS, {}); + expect(missing).toHaveLength(ALL_SECRET_SPECS.length); + + const message = buildMissingSecretsMessage(missing); + expect(message).toContain('BACKEND_JWT_SECRET (or SECRET_KEY)'); + expect(message).toContain('533Cr3tK3yF0rH4sh1nGJ4W773k3n$'); + expect(message).toContain('dafea6be69af1c1c3b8caf2b609342f6eb4540b554e19539f7643b75b480c932'); + expect(message).toContain('ie21hOKjlXUiGDx9'); + expect(message).toContain('SECRET_KEY: root secret'); + }); + + it('teaches how to COMPUTE the data-db-url values instead of printing derived key material', () => { + const missing = findMissingSecrets(ALL_SECRET_SPECS, {}); + const message = buildMissingSecretsMessage(missing); + expect(message).toContain('BACKEND_DATA_DB_URL_ENCRYPTION_KEY'); + expect(message).toContain("createHash('sha256')"); + }); + + it('accepts SECRET_KEY as an umbrella for jwt and session only', () => { + const missing = findMissingSecrets(ALL_SECRET_SPECS, { SECRET_KEY: 'strong-secret' }); + const keys = missing.map((s) => s.envKey); + expect(keys).not.toContain('BACKEND_JWT_SECRET'); + expect(keys).not.toContain('BACKEND_SESSION_SECRET'); + expect(keys).not.toContain('SECRET_KEY'); + // Encryption vars stay required: booting on SECRET_KEY derivation would + // silently change the effective keys of an existing deployment. + expect(keys).toContain('BACKEND_MAIL_ENCRYPTION_KEY'); + expect(keys).toContain('BACKEND_ACCESS_TOKEN_ENCRYPTION_KEY'); + expect(keys).toContain('BACKEND_DATA_DB_URL_ENCRYPTION_KEY'); + }); + + it('requires the storage encryption pair only for the local storage provider', () => { + const { + BACKEND_STORAGE_ENCRYPTION_KEY: _key, + BACKEND_STORAGE_ENCRYPTION_IV: _iv, + ...withoutStoragePair + } = fullEnv; + + // Unset provider defaults to local (same criterion as storage.config) — + // the pair stays required. + expect(findMissingSecrets(ALL_SECRET_SPECS, withoutStoragePair).map((s) => s.envKey)).toEqual([ + 'BACKEND_STORAGE_ENCRYPTION_KEY', + 'BACKEND_STORAGE_ENCRYPTION_IV', + ]); + + // Cloud providers never read the pair (presigned URLs) — the boot check + // must not demand dead config. + for (const provider of ['s3', 'minio', 'aliyun']) { + expect( + findMissingSecrets(ALL_SECRET_SPECS, { + ...withoutStoragePair, + BACKEND_STORAGE_PROVIDER: provider, + }) + ).toEqual([]); + } + expect(() => + enforceSecretsPolicy({ ...withoutStoragePair, BACKEND_STORAGE_PROVIDER: 's3' }) + ).not.toThrow(); + }); + + it('still requires SECRET_KEY when every dedicated var is set', () => { + const { SECRET_KEY: _omitted, ...withoutRoot } = fullEnv; + const missing = findMissingSecrets(ALL_SECRET_SPECS, withoutRoot); + expect(missing.map((s) => s.envKey)).toEqual(['SECRET_KEY']); + }); + + it('flags secrets pinned to their public former defaults, and only those', () => { + const pinnedEnv = { + ...fullEnv, + BACKEND_JWT_SECRET: SECRET_SPECS.jwtSecret.legacyDefault, + BACKEND_MAIL_ENCRYPTION_KEY: SECRET_SPECS.mailEncryptionKey.legacyDefault, + }; + const flagged = findPublicDefaultSecrets(ALL_SECRET_SPECS, pinnedEnv); + expect(flagged.map((s) => s.envKey).sort()).toEqual([ + 'BACKEND_JWT_SECRET', + 'BACKEND_MAIL_ENCRYPTION_KEY', + ]); + + const warning = buildPublicDefaultsWarning(flagged); + expect(warning).toContain('PUBLICLY KNOWN'); + expect(warning).toContain('BACKEND_JWT_SECRET'); + // The warning names the vars but never re-prints the secret values. + expect(warning).not.toContain(SECRET_SPECS.jwtSecret.legacyDefault as string); + }); + + it('does not flag freshly generated values', () => { + expect(findPublicDefaultSecrets(ALL_SECRET_SPECS, fullEnv)).toEqual([]); + }); + + describe('enforceSecretsPolicy', () => { + it('throws the aggregated teaching message with copy-pastable blocks when secrets are missing', () => { + expect(() => enforceSecretsPolicy({})).toThrow(/EXISTING deployment/); + // Both remediation paths are complete copy-pastable env lines. + expect(() => enforceSecretsPolicy({})).toThrow( + /BACKEND_MAIL_ENCRYPTION_KEY='ie21hOKjlXUiGDx1'/ + ); + expect(() => enforceSecretsPolicy({})).toThrow( + /BACKEND_MAIL_ENCRYPTION_KEY=\$\(openssl rand -hex 8\)/ + ); + }); + + it('boots but logs a warning when running on public defaults', () => { + const logged: string[] = []; + enforceSecretsPolicy( + { ...fullEnv, BACKEND_JWT_SECRET: SECRET_SPECS.jwtSecret.legacyDefault }, + (message) => logged.push(message) + ); + expect(logged).toHaveLength(1); + expect(logged[0]).toContain('PUBLICLY KNOWN'); + }); + + it('behaves identically in every environment (local IS production)', () => { + expect(() => enforceSecretsPolicy({ NODE_ENV: 'development' })).toThrow( + /EXISTING deployment/ + ); + expect(() => enforceSecretsPolicy({ NODE_ENV: 'development', ...fullEnv })).not.toThrow(); + }); + }); +}); diff --git a/apps/nestjs-backend/src/configs/secrets/secrets-policy.ts b/apps/nestjs-backend/src/configs/secrets/secrets-policy.ts new file mode 100644 index 0000000000..5f6263ad59 --- /dev/null +++ b/apps/nestjs-backend/src/configs/secrets/secrets-policy.ts @@ -0,0 +1,111 @@ +import type { ISecretSpec } from './secret-specs'; +import { ALL_SECRET_SPECS } from './secret-specs'; + +type IEnv = Record; + +const isConfigured = (spec: ISecretSpec, env: IEnv): boolean => + [spec.envKey, ...(spec.fallbackEnvKeys ?? [])].some((key) => { + const value = env[key]; + return typeof value === 'string' && value !== ''; + }); + +/** Required specs (per requiredWhen) whose dedicated/fallback env vars are all unset. */ +export const findMissingSecrets = ( + specs: readonly ISecretSpec[], + env: IEnv = process.env +): ISecretSpec[] => + specs.filter((spec) => (spec.requiredWhen?.(env) ?? true) && !isConfigured(spec, env)); + +/** Specs still running on their publicly known former source default. */ +export const findPublicDefaultSecrets = ( + specs: readonly ISecretSpec[], + env: IEnv = process.env +): ISecretSpec[] => + specs.filter( + (spec) => spec.legacyDefault !== undefined && env[spec.envKey] === spec.legacyDefault + ); + +// aes-128-cbc slots need exactly 16 chars; everything else takes any strong value. +const isAes16Slot = (envKey: string) => /_ENCRYPTION_(?:KEY|IV)$/.test(envKey); + +export const buildMissingSecretsMessage = (missing: ISecretSpec[]): string => { + const list = missing + .map( + (s) => + ` - ${s.fallbackEnvKeys?.length ? `${s.envKey} (or ${s.fallbackEnvKeys.join(' / ')})` : s.envKey}: ${s.usedFor}` + ) + .join('\n'); + // Directly copy-pastable blocks — never send the operator off to another file. + const existingBlock = missing + .map((s) => + s.legacyDefault !== undefined && s.pinInstruction === undefined + ? ` ${s.envKey}='${s.legacyDefault}'` + : ` # ${s.envKey} — ${s.pinInstruction}` + ) + .join('\n'); + const newBlock = missing + .map( + (s) => ` ${s.envKey}=$(openssl rand ${isAes16Slot(s.envKey) ? '-hex 8' : '-base64 32'})` + ) + .join('\n'); + return [ + 'Missing required secret environment variable(s):', + '', + list, + '', + 'Teable no longer ships built-in fallback secrets — the old ones are publicly known, so every instance must configure its own.', + '', + 'EXISTING deployment (was running without these variables): it was implicitly using the old built-in values. Add EXACTLY the block below to keep current sessions, tokens and encrypted data working, then plan a proper rotation (mind `$` escaping in your env format):', + '', + existingBlock, + '', + 'NEW deployment: generate fresh values instead:', + '', + newBlock, + '', + 'Planned JWT secret rotation later on: put the new value in BACKEND_JWT_SECRET and keep the previous one in BACKEND_JWT_SECRET_OLD until outstanding tokens expire (~30d), then remove it.', + ].join('\n'); +}; + +export const buildPublicDefaultsWarning = (onDefaults: ISecretSpec[]): string => + [ + 'SECURITY WARNING: the following secrets are set to their PUBLICLY KNOWN former source-code defaults (they live in the public git history, so anyone can forge tokens / decrypt data protected by them):', + '', + ...onDefaults.map((s) => ` - ${s.envKey} (${s.usedFor})`), + '', + 'The instance keeps running so existing data stays accessible, but plan a rotation to freshly generated values as soon as possible.', + ].join('\n'); + +/** + * The single policy checkpoint for secret configuration. ConfigModule calls it + * right after BaseConfigModule.forRoot() has synchronously loaded the env + * files and BEFORE any config factory runs — so on an instance with missing + * secrets, the aggregated pin instructions below are the FIRST and only error + * the operator sees (resolve-secret.ts would otherwise fail first with a + * single-variable message and no migration teaching). + * + * Deliberately environment-agnostic so local behavior IS production behavior + * (delete a var locally and you see exactly what a deployment would): + * + * - missing secrets → refuse to boot, printing per-secret pin instructions so + * an existing deployment can keep its data. Development and tests pass + * because .env.development / vitest.setup.ts supply every value; + * - secrets set to their public former defaults → loud error log, but boot + * (a hard stop would strand deployments that pinned the defaults per the + * instructions above and have no rotation tooling yet). Development runs on + * those defaults BY DESIGN, so expect this warning on every local boot. + */ +export const enforceSecretsPolicy = ( + env: Record = process.env, + // console.error on purpose: this runs before the Nest logger exists. + logError: (message: string) => void = (message) => console.error(message) +): void => { + const missing = findMissingSecrets(ALL_SECRET_SPECS, env); + if (missing.length > 0) { + throw new Error(`\n${buildMissingSecretsMessage(missing)}`); + } + const onDefaults = findPublicDefaultSecrets(ALL_SECRET_SPECS, env); + if (onDefaults.length > 0) { + logError(buildPublicDefaultsWarning(onDefaults)); + } +}; diff --git a/apps/nestjs-backend/src/configs/storage.ts b/apps/nestjs-backend/src/configs/storage.ts index a42a502da9..f017e31126 100644 --- a/apps/nestjs-backend/src/configs/storage.ts +++ b/apps/nestjs-backend/src/configs/storage.ts @@ -2,6 +2,8 @@ import { Inject } from '@nestjs/common'; import type { ConfigType } from '@nestjs/config'; import { registerAs } from '@nestjs/config'; +import { resolveSecret } from './secrets/resolve-secret'; +import { SECRET_SPECS } from './secrets/secret-specs'; export const storageConfig = registerAs('storage', () => ({ provider: (process.env.BACKEND_STORAGE_PROVIDER ?? 'local') as @@ -50,8 +52,8 @@ export const storageConfig = registerAs('storage', () => ({ uploadMethod: process.env.BACKEND_STORAGE_UPLOAD_METHOD ?? 'put', encryption: { algorithm: process.env.BACKEND_STORAGE_ENCRYPTION_ALGORITHM ?? 'aes-128-cbc', - key: process.env.BACKEND_STORAGE_ENCRYPTION_KEY ?? '73b00476e456323e', - iv: process.env.BACKEND_STORAGE_ENCRYPTION_IV ?? '8c9183e4c175f63c', + key: resolveSecret(SECRET_SPECS.storageEncryptionKey), + iv: resolveSecret(SECRET_SPECS.storageEncryptionIv), }, // must be less than 7 days tokenExpireIn: process.env.BACKEND_STORAGE_TOKEN_EXPIRE_IN ?? '6d', diff --git a/apps/nestjs-backend/src/db-provider/filter-query/__tests__/field-reference.spec.ts b/apps/nestjs-backend/src/db-provider/filter-query/__tests__/field-reference.spec.ts index 24d0334f52..8e8e68430e 100644 --- a/apps/nestjs-backend/src/db-provider/filter-query/__tests__/field-reference.spec.ts +++ b/apps/nestjs-backend/src/db-provider/filter-query/__tests__/field-reference.spec.ts @@ -207,6 +207,48 @@ describe('field reference filters', () => { expect(sql).toMatch(expectedSql); }); + it('degrades unsupported field-reference operators to match-all when opted in', () => { + // AJ regression (T6526 preview validation): a conditional filter using + // 'contains' against another field made EVERY record write on the host + // table fail with 400 via ensureLiteralValue -> handleCompilerError. + // Assert the handler branch directly: default rethrows; the affected-set + // opt-in degrades to match-all (compiled as TRUE by the caller). + const field = createTextField('fldsourcetext0001', 'source_text'); + const value = { type: 'field', fieldId: 'fldreftext0000001' }; + const error = new Error("Operator 'contains' does not support comparing against another field"); + class ExposedFilterQuery extends FilterQueryPostgres { + handle(): 'match-all' | undefined { + return ( + this as unknown as { + handleCompilerError: ( + e: unknown, + f: FieldCore, + o: string, + v: unknown + ) => 'match-all' | undefined; + } + ).handleCompilerError(error, field, 'contains', value); + } + } + const build = (behavior?: 'match-all') => + new ExposedFilterQuery( + knexBuilder('main'), + { [field.id]: field }, + undefined, + undefined, + dbProviderStub, + { + selectionMap: new Map(), + ...(behavior ? { unsupportedFieldReferenceBehavior: behavior } : {}), + } + ); + + // Default stays strict: user-issued queries must not silently change meaning. + expect(() => build().handle()).toThrow(/does not support comparing against another field/); + // Opted-in degradation reports match-all instead of failing the write. + expect(build('match-all').handle()).toBe('match-all'); + }); + it('supports hasAnyOf against multi-user field references', () => { const field = createUserField('fld_multi_user', 'multi_user_col', true); const reference = createUserField('fld_multi_user_ref', 'multi_user_ref_col', true); diff --git a/apps/nestjs-backend/src/db-provider/filter-query/filter-query.abstract.ts b/apps/nestjs-backend/src/db-provider/filter-query/filter-query.abstract.ts index 9093afb27a..0a8ae6b99d 100644 --- a/apps/nestjs-backend/src/db-provider/filter-query/filter-query.abstract.ts +++ b/apps/nestjs-backend/src/db-provider/filter-query/filter-query.abstract.ts @@ -89,7 +89,11 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface { return queryBuilder; } - if (this.shouldSkipInvalidFilterItem(field, filterMeta, path)) { + const skipDecision = this.shouldSkipInvalidFilterItem(field, filterMeta, path); + if (skipDecision === 'match-all') { + return queryBuilder[conjunction].whereRaw('1 = 1'); + } + if (skipDecision === 'skip') { return queryBuilder; } @@ -97,7 +101,9 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface { const validFilterOperators = Object.keys(getFilterOperatorMapping(field)); if (!includes(validFilterOperators, convertOperator)) { - this.throwIfFilterReferencesInvalidOperator(field, value); + if (this.throwIfFilterReferencesInvalidOperator(field, value) === 'match-all') { + return queryBuilder[conjunction].whereRaw('1 = 1'); + } this.logger.warn( `Skip filter item: field=${field.id}(${field.name}) operator='${convertOperator}' not in [${validFilterOperators.join(',')}]` ); @@ -114,12 +120,20 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface { this.dbProvider! ); } catch (error) { - this.handleCompilerError(error, field, convertOperator, value); + if (this.handleCompilerError(error, field, convertOperator, value) === 'match-all') { + // The compiler bailed before appending (ensureLiteralValue runs first), + // so the pending conjunction still applies to this raw TRUE. + queryBuilder.whereRaw('1 = 1'); + } } return queryBuilder; } - private shouldSkipInvalidFilterItem(field: FieldCore, filterMeta: IFilterItem, path: number[]) { + private shouldSkipInvalidFilterItem( + field: FieldCore, + filterMeta: IFilterItem, + path: number[] + ): false | 'skip' | 'match-all' { const validationIssues = this.getFilterItemValidationIssues(path); if (validationIssues.length === 0) { return false; @@ -128,8 +142,11 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface { const hasInvalidOperator = validationIssues.some( (issue) => issue.code === 'OPERATOR_NOT_ALLOWED' ); - if (hasInvalidOperator) { - this.throwIfFilterReferencesInvalidOperator(field, filterMeta.value); + if ( + hasInvalidOperator && + this.throwIfFilterReferencesInvalidOperator(field, filterMeta.value) === 'match-all' + ) { + return 'match-all'; } this.logger.warn( @@ -137,7 +154,7 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface { .map((issue) => issue.code) .join(',')}]` ); - return true; + return 'skip'; } private getConvertedOperator(field: FieldCore, operator: string, isSymbol?: boolean) { @@ -148,10 +165,27 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface { return invert(getFilterOperatorMapping(field))[operator] as IFilterOperator; } - private throwIfFilterReferencesInvalidOperator(field: FieldCore, value: unknown) { + /** + * Returns 'match-all' when the item references another field, the operator + * cannot support that, and the caller opted into degradation — the item must + * then be compiled as TRUE (never silently dropped: under an OR conjunction a + * dropped item would SHRINK the result set, and affected-set machinery must + * only ever widen). + */ + private throwIfFilterReferencesInvalidOperator( + field: FieldCore, + value: unknown + ): 'match-all' | undefined { const referenceFieldId = this.extractFieldReferenceFieldId(value); if (!referenceFieldId) { - return; + return undefined; + } + if (this.context?.unsupportedFieldReferenceBehavior === 'match-all') { + this.logger.warn( + `Field-reference filter on field=${field.id}(${field.name}) is not supported here; ` + + `treating the condition as match-all` + ); + return 'match-all'; } const referenceName = this.fields?.[referenceFieldId]?.name ?? referenceFieldId; @@ -164,11 +198,25 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface { field: FieldCore, convertOperator: IFilterOperator, value: unknown - ) { - if (error instanceof FieldReferenceCompatibilityException) { - throw error; - } - if (this.extractFieldReferenceFieldId(value)) { + ): 'match-all' | undefined { + const isFieldReferenceItem = + error instanceof FieldReferenceCompatibilityException || + Boolean(this.extractFieldReferenceFieldId(value)); + if (isFieldReferenceItem) { + if (this.context?.unsupportedFieldReferenceBehavior === 'match-all') { + // The compiler rejects this operator + field-reference combination + // (e.g. 'contains' against another field). For affected-set derivation + // the conservative degradation is to treat the condition as TRUE: + // including extra rows is safe, while throwing here fails the record + // WRITE that merely touched this table. User-issued queries keep the + // default 'throw' behavior. + const reason = error instanceof Error ? error.message : String(error); + this.logger.warn( + `Treat filter item as match-all: field=${field.id}(${field.name}) ` + + `operator='${convertOperator}' unsupported field reference: ${reason}` + ); + return 'match-all'; + } throw error; } if (!this.isSkippableCompilerError(error)) { diff --git a/apps/nestjs-backend/src/event-emitter/decorators/emit-controller-event.decorator.ts b/apps/nestjs-backend/src/event-emitter/decorators/emit-controller-event.decorator.ts index 6c330b1ea8..b10a7eafd7 100644 --- a/apps/nestjs-backend/src/event-emitter/decorators/emit-controller-event.decorator.ts +++ b/apps/nestjs-backend/src/event-emitter/decorators/emit-controller-event.decorator.ts @@ -5,10 +5,15 @@ import type { Events } from '../events'; import { EventMiddleware } from '../interceptor/event.Interceptor'; export const EMIT_EVENT_NAME = 'EMIT_EVENT_NAME'; +export const SKIP_EVENT_WHEN_V2 = 'SKIP_EVENT_WHEN_V2'; -export function EmitControllerEvent(name: Events): MethodDecorator { +export function EmitControllerEvent( + name: Events, + options?: { skipWhenV2?: boolean } +): MethodDecorator { return (target: any, key: string | symbol, descriptor: TypedPropertyDescriptor) => { SetMetadata(EMIT_EVENT_NAME, name)(target, key, descriptor); + SetMetadata(SKIP_EVENT_WHEN_V2, options?.skipWhenV2 === true)(target, key, descriptor); UseInterceptors(EventMiddleware)(target, key, descriptor); }; } diff --git a/apps/nestjs-backend/src/event-emitter/event-emitter.service.ts b/apps/nestjs-backend/src/event-emitter/event-emitter.service.ts index 939b7d25e7..51c5c215fe 100644 --- a/apps/nestjs-backend/src/event-emitter/event-emitter.service.ts +++ b/apps/nestjs-backend/src/event-emitter/event-emitter.service.ts @@ -211,6 +211,7 @@ export class EventEmitterService { private createExtendPlainContext(docId: string, id: string) { const user = this.cls.get('user'); const entry = this.cls.get('entry'); + const recordRemovalReason = this.cls.get('recordRemovalReason'); return { baseId: docId, tableId: id.startsWith(IdPrefix.Table) ? id : docId, @@ -220,6 +221,7 @@ export class EventEmitterService { context: { user, entry, + recordRemovalReason, }, }; } diff --git a/apps/nestjs-backend/src/event-emitter/events/core-event.ts b/apps/nestjs-backend/src/event-emitter/events/core-event.ts index b5616f9b8b..025e28f0ab 100644 --- a/apps/nestjs-backend/src/event-emitter/events/core-event.ts +++ b/apps/nestjs-backend/src/event-emitter/events/core-event.ts @@ -1,6 +1,7 @@ import type { IncomingHttpHeaders } from 'http'; import type { OpName } from '@teable/core'; import type { IUserInfoVo } from '@teable/openapi'; +import type { IRecordRemovalReason } from '@teable/v2-core'; import { nanoid } from 'nanoid'; import type { Events } from './event.enum'; @@ -14,6 +15,9 @@ export interface IEventContext { type: string; id: string; }; + // 'archived' removals keep their attachments_table reference rows (the archive snapshot + // still references the files and they must keep counting toward attachment usage). + recordRemovalReason?: IRecordRemovalReason; headers?: Record | IncomingHttpHeaders; opMeta?: { name: OpName; diff --git a/apps/nestjs-backend/src/event-emitter/events/event.enum.ts b/apps/nestjs-backend/src/event-emitter/events/event.enum.ts index 63cdfecac1..8904a2dc53 100644 --- a/apps/nestjs-backend/src/event-emitter/events/event.enum.ts +++ b/apps/nestjs-backend/src/event-emitter/events/event.enum.ts @@ -35,6 +35,7 @@ export enum Events { OPERATION_RECORDS_CREATE = 'operation.records.create', OPERATION_RECORDS_DELETE = 'operation.records.delete', + OPERATION_RECORDS_ARCHIVE = 'operation.records.archive', OPERATION_RECORDS_UPDATE = 'operation.records.update', OPERATION_RECORDS_ORDER_UPDATE = 'operation.records.order.update', OPERATION_FIELDS_CREATE = 'operation.fields.create', diff --git a/apps/nestjs-backend/src/event-emitter/interceptor/event.Interceptor.ts b/apps/nestjs-backend/src/event-emitter/interceptor/event.Interceptor.ts index f127217ccb..e59e4b634b 100644 --- a/apps/nestjs-backend/src/event-emitter/interceptor/event.Interceptor.ts +++ b/apps/nestjs-backend/src/event-emitter/interceptor/event.Interceptor.ts @@ -6,7 +6,7 @@ import type { Request } from 'express'; import type { Observable } from 'rxjs'; import { tap } from 'rxjs'; import { match, P } from 'ts-pattern'; -import { EMIT_EVENT_NAME } from '../decorators/emit-controller-event.decorator'; +import { EMIT_EVENT_NAME, SKIP_EVENT_WHEN_V2 } from '../decorators/emit-controller-event.decorator'; import { EventEmitterService } from '../event-emitter.service'; import type { IEventContext } from '../events'; import { @@ -29,9 +29,13 @@ export class EventMiddleware implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const req = context.switchToHttp().getRequest(); const emitEventName = this.reflector.get(EMIT_EVENT_NAME, context.getHandler()); + const skipWhenV2 = this.reflector.get(SKIP_EVENT_WHEN_V2, context.getHandler()); return next.handle().pipe( tap((data) => { + if (skipWhenV2 && (req as Request & { useV2?: boolean }).useV2) { + return; + } const interceptContext = this.interceptContext(req, data); const event = this.createEvent(emitEventName, interceptContext); diff --git a/apps/nestjs-backend/src/event-emitter/listeners/attachment.listener.ts b/apps/nestjs-backend/src/event-emitter/listeners/attachment.listener.ts index 76eee263ed..41b03bc144 100644 --- a/apps/nestjs-backend/src/event-emitter/listeners/attachment.listener.ts +++ b/apps/nestjs-backend/src/event-emitter/listeners/attachment.listener.ts @@ -30,7 +30,14 @@ export class AttachmentListener { async recordDeleteListener(listenerEvent: RecordDeleteEvent) { const { payload: { tableId, recordId }, + context, } = listenerEvent; + // Archived records keep their reference rows: the archive snapshot still references + // the files and they keep counting toward attachment usage. The archive flow manages + // these rows on restore / permanent delete. + if (context.recordRemovalReason === 'archived') { + return; + } await this.attachmentsTableService.deleteRecords( tableId, Array.isArray(recordId) ? recordId : [recordId] diff --git a/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.controller.ts b/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.controller.ts index 86beba965b..1cd657fbae 100644 --- a/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.controller.ts @@ -39,6 +39,7 @@ import { ZodValidationPipe } from '../../../zod.validation.pipe'; import { AllowAnonymous } from '../../auth/decorators/allow-anonymous.decorator'; import { Permissions } from '../../auth/decorators/permissions.decorator'; import { TqlPipe } from '../../record/open-api/tql.pipe'; +import { SpaceDataDbMigrationGuardService } from '../../space/space-data-db-migration-guard.service'; import { AggregationOpenApiService } from './aggregation-open-api.service'; @Controller('api/table/:tableId/aggregation') @@ -48,7 +49,8 @@ export class AggregationOpenApiController { private readonly aggregationOpenApiService: AggregationOpenApiService, private readonly prismaService: PrismaService, private readonly cls: ClsService, - private readonly performanceCacheService: PerformanceCacheService + private readonly performanceCacheService: PerformanceCacheService, + protected readonly spaceDataDbMigrationGuardService: SpaceDataDbMigrationGuardService ) {} private async getAggregationWithCache( @@ -139,6 +141,8 @@ export class AggregationOpenApiController { @Param('tableId') tableId: string, @Query(new ZodValidationPipe(searchCountRoSchema), TqlPipe) query: ISearchCountRo ): Promise { + await this.spaceDataDbMigrationGuardService.assertTableRecordSearchReadable(tableId, query); + return await this.getAggregationWithCache('search_count', tableId, query, () => this.aggregationOpenApiService.getSearchCount(tableId, query) ); @@ -150,6 +154,8 @@ export class AggregationOpenApiController { @Param('tableId') tableId: string, @Query(new ZodValidationPipe(searchIndexByQueryRoSchema), TqlPipe) query: ISearchIndexByQueryRo ): Promise { + await this.spaceDataDbMigrationGuardService.assertTableRecordSearchReadable(tableId, query); + return await this.getAggregationWithCache('search_index', tableId, query, () => this.aggregationOpenApiService.getRecordIndexBySearchOrder(tableId, query) ); diff --git a/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.module.ts b/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.module.ts index d4dd88ba46..c11944d275 100644 --- a/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.module.ts +++ b/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { RecordModule } from '../../record/record.module'; +import { SpaceDataDbMigrationGuardModule } from '../../space/space-data-db-migration-guard.module'; import { AggregationModule } from '../aggregation.module'; import { AggregationOpenApiController } from './aggregation-open-api.controller'; import { AggregationOpenApiService } from './aggregation-open-api.service'; @Module({ controllers: [AggregationOpenApiController], - imports: [AggregationModule, RecordModule], + imports: [AggregationModule, RecordModule, SpaceDataDbMigrationGuardModule], providers: [AggregationOpenApiService], exports: [AggregationOpenApiService], }) diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-import.service.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-import.service.ts index fcfa5fd828..a20259a67a 100644 --- a/apps/nestjs-backend/src/features/airtable-import/airtable-import.service.ts +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-import.service.ts @@ -20,6 +20,7 @@ import type { IImportAirtableVo, } from '@teable/openapi'; import { CollaboratorType, PrincipalType, UploadType } from '@teable/openapi'; +import { mapWithConcurrency } from '../../utils/map-with-concurrency'; import { AiService } from '../ai/ai.service'; import { AttachmentsService } from '../attachments/attachments.service'; import StorageAdapter from '../attachments/plugins/adapter'; @@ -94,26 +95,6 @@ interface IViewConfigTarget { viewName: string; } -/** Runs tasks with bounded concurrency, preserving the result order. */ -const mapWithConcurrency = async ( - items: T[], - limit: number, - task: (item: T) => Promise -): Promise => { - const results: R[] = new Array(items.length); - let next = 0; - const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { - // eslint-disable-next-line no-constant-condition - while (true) { - const index = next++; - if (index >= items.length) return; - results[index] = await task(items[index]); - } - }); - await Promise.all(workers); - return results; -}; - @Injectable() export class AirtableImportService { private readonly logger = new Logger(AirtableImportService.name); diff --git a/apps/nestjs-backend/src/features/attachments/plugins/adapter.ts b/apps/nestjs-backend/src/features/attachments/plugins/adapter.ts index 168e170588..abe41d173b 100644 --- a/apps/nestjs-backend/src/features/attachments/plugins/adapter.ts +++ b/apps/nestjs-backend/src/features/attachments/plugins/adapter.ts @@ -26,6 +26,7 @@ export default abstract class StorageAdapter { case UploadType.ChatFile: case UploadType.Automation: case UploadType.RecordHistory: + case UploadType.RecordRemoval: return storageConfig().privateBucket; case UploadType.Avatar: case UploadType.OAuth: @@ -79,6 +80,8 @@ export default abstract class StorageAdapter { return 'record-history'; case UploadType.SpaceAvatar: return 'space-avatar'; + case UploadType.RecordRemoval: + return 'record-removal'; default: throw new CustomHttpException('Invalid upload type', HttpErrorCode.VALIDATION_ERROR, { localization: { diff --git a/apps/nestjs-backend/src/features/auth/auth.module.ts b/apps/nestjs-backend/src/features/auth/auth.module.ts index cc7e487608..c47e65aa56 100644 --- a/apps/nestjs-backend/src/features/auth/auth.module.ts +++ b/apps/nestjs-backend/src/features/auth/auth.module.ts @@ -1,9 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { Module } from '@nestjs/common'; import { ConditionalModule } from '@nestjs/config'; -import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; -import { authConfig, type IAuthConfig } from '../../configs/auth.config'; import { AccessTokenModule } from '../access-token/access-token.module'; import { DeleteUserModule } from '../user/delete-user/delete-user.module'; import { UserModule } from '../user/user.module'; @@ -40,15 +38,6 @@ const CONDITIONAL_MODULE_TIMEOUT = process.env.CI ? 30000 : 5000; SocialModule, PermissionModule, TurnstileModule, - JwtModule.registerAsync({ - useFactory: (config: IAuthConfig) => ({ - secret: config.jwt.secret, - signOptions: { - expiresIn: config.jwt.expiresIn, - }, - }), - inject: [authConfig.KEY], - }), DeleteUserModule, ], providers: [ diff --git a/apps/nestjs-backend/src/features/auth/auth.service.ts b/apps/nestjs-backend/src/features/auth/auth.service.ts index f1fcf8fe55..8e089b75f8 100644 --- a/apps/nestjs-backend/src/features/auth/auth.service.ts +++ b/apps/nestjs-backend/src/features/auth/auth.service.ts @@ -1,11 +1,11 @@ /* eslint-disable sonarjs/no-duplicate-string */ import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; import { type IUserInfoVo, type IUserMeVo } from '@teable/openapi'; import { omit, pick } from 'lodash'; import ms from 'ms'; import { ClsService } from 'nestjs-cls'; import type { IClsStore } from '../../types/cls'; +import { TeableJwtService } from './jwt/teable-jwt.service'; import { PermissionService } from './permission.service'; import { JwtAuthInternalType } from './strategies/types'; import type { IJwtAuthInternalInfo, IJwtAuthInfo } from './strategies/types'; @@ -15,7 +15,7 @@ export class AuthService { constructor( private readonly cls: ClsService, private readonly permissionService: PermissionService, - private readonly jwtService: JwtService + private readonly jwtService: TeableJwtService ) {} async getUserInfo(user: IUserMeVo): Promise { diff --git a/apps/nestjs-backend/src/features/auth/jwt/teable-jwt.module.ts b/apps/nestjs-backend/src/features/auth/jwt/teable-jwt.module.ts new file mode 100644 index 0000000000..3dc0e81c90 --- /dev/null +++ b/apps/nestjs-backend/src/features/auth/jwt/teable-jwt.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { TeableJwtService } from './teable-jwt.service'; + +@Global() +@Module({ + providers: [TeableJwtService], + exports: [TeableJwtService], +}) +export class TeableJwtModule {} diff --git a/apps/nestjs-backend/src/features/auth/jwt/teable-jwt.service.spec.ts b/apps/nestjs-backend/src/features/auth/jwt/teable-jwt.service.spec.ts new file mode 100644 index 0000000000..ff49a2d50d --- /dev/null +++ b/apps/nestjs-backend/src/features/auth/jwt/teable-jwt.service.spec.ts @@ -0,0 +1,125 @@ +import { JwtService, TokenExpiredError } from '@nestjs/jwt'; +import { describe, expect, it } from 'vitest'; +import type { IAuthConfig } from '../../../configs/auth.config'; +import { TeableJwtService } from './teable-jwt.service'; + +const makeService = (secret: string, oldSecret?: string) => + new TeableJwtService({ jwt: { secret, oldSecret, expiresIn: '1h' } } as IAuthConfig); + +describe('TeableJwtService', () => { + it('signs with the primary secret', async () => { + const service = makeService('primary', 'old'); + const token = await service.signAsync({ a: 1 }, { expiresIn: '1h' }); + await expect(new JwtService({ secret: 'primary' }).verifyAsync(token)).resolves.toMatchObject({ + a: 1, + }); + await expect(new JwtService({ secret: 'old' }).verifyAsync(token)).rejects.toThrow(); + }); + + it('applies the configured default expiry when the sign site passes none', async () => { + const service = makeService('primary'); + const token = await service.signAsync({ sub: 'x' }); + const payload = await service.verifyAsync<{ exp: number; iat: number }>(token); + expect(payload.exp - payload.iat).toBe(3600); + }); + + it('does not inject the default expiry when the payload carries an absolute exp', async () => { + // The ai-proxy api-key JWT sets exp directly from the DB row; jsonwebtoken + // rejects expiresIn when the payload already has exp. + const service = makeService('primary'); + const exp = Math.floor(Date.now() / 1000) + 999; + const token = await service.signAsync({ sub: 'x', exp }, { noTimestamp: true }); + const payload = await service.verifyAsync<{ exp: number }>(token); + expect(payload.exp).toBe(exp); + }); + + it('verifies tokens signed with any listed secret', async () => { + const service = makeService('primary', 'old'); + const oldToken = await new JwtService({ secret: 'old' }).signAsync( + { b: 2 }, + { expiresIn: '1h' } + ); + await expect(service.verifyAsync(oldToken)).resolves.toMatchObject({ b: 2 }); + }); + + it('rejects tokens signed with an unlisted secret', async () => { + const service = makeService('primary', 'old'); + const foreign = await new JwtService({ secret: 'leaked' }).signAsync({ c: 3 }); + await expect(service.verifyAsync(foreign)).rejects.toThrow(); + }); + + it('stops accepting old-secret tokens once oldSecret is dropped (hard cut)', async () => { + const withOld = makeService('primary', 'old'); + const oldToken = await new JwtService({ secret: 'old' }).signAsync( + { d: 4 }, + { expiresIn: '1h' } + ); + await expect(withOld.verifyAsync(oldToken)).resolves.toBeDefined(); + const hardCut = makeService('primary'); + await expect(hardCut.verifyAsync(oldToken)).rejects.toThrow(); + }); + + it('surfaces expiry of a primary-signed token instead of retrying older secrets', async () => { + const service = makeService('primary', 'old'); + const expired = await new JwtService({ secret: 'primary' }).signAsync( + { e: 5 }, + { expiresIn: '-1s' } + ); + await expect(service.verifyAsync(expired)).rejects.toBeInstanceOf(TokenExpiredError); + }); + + it('classifySigningSecret attributes tokens to their signing secret, ignoring expiry', async () => { + const service = makeService('primary', 'old'); + const current = await service.signAsync({ a: 1 }); + const oldToken = await new JwtService({ secret: 'old' }).signAsync({ b: 2 }); + // An EXPIRED old-secret token must still classify as 'old' — rotation + // tooling needs signature attribution, not validity. + const expiredOld = await new JwtService({ secret: 'old' }).signAsync( + { c: 3 }, + { expiresIn: '-1s' } + ); + const foreign = await new JwtService({ secret: 'leaked' }).signAsync({ d: 4 }); + await expect(service.classifySigningSecret(current)).resolves.toBe('current'); + await expect(service.classifySigningSecret(oldToken)).resolves.toBe('old'); + await expect(service.classifySigningSecret(expiredOld)).resolves.toBe('old'); + await expect(service.classifySigningSecret(foreign)).resolves.toBe('none'); + }); + + it('passportSecretProvider hands back whichever secret verifies', async () => { + const service = makeService('primary', 'old'); + const oldToken = await new JwtService({ secret: 'old' }).signAsync( + { f: 6 }, + { expiresIn: '1h' } + ); + const provider = service.passportSecretProvider(); + const secret = await new Promise((resolve, reject) => + provider(null, oldToken, (err, s) => (err ? reject(err) : resolve(s))) + ); + expect(secret).toBe('old'); + }); + + it('passportSecretProvider attributes an EXPIRED old-secret token to the old secret', async () => { + // Attribution ignores expiry: passport re-verifies with the returned + // secret, so the client sees "jwt expired" instead of "invalid signature". + const service = makeService('primary', 'old'); + const expiredOld = await new JwtService({ secret: 'old' }).signAsync( + { h: 8 }, + { expiresIn: '-1s' } + ); + const provider = service.passportSecretProvider(); + const secret = await new Promise((resolve, reject) => + provider(null, expiredOld, (err, s) => (err ? reject(err) : resolve(s))) + ); + expect(secret).toBe('old'); + }); + + it('passportSecretProvider falls back to the primary for unverifiable tokens', async () => { + const service = makeService('primary', 'old'); + const foreign = await new JwtService({ secret: 'leaked' }).signAsync({ g: 7 }); + const provider = service.passportSecretProvider(); + const secret = await new Promise((resolve, reject) => + provider(null, foreign, (err, s) => (err ? reject(err) : resolve(s))) + ); + expect(secret).toBe('primary'); + }); +}); diff --git a/apps/nestjs-backend/src/features/auth/jwt/teable-jwt.service.ts b/apps/nestjs-backend/src/features/auth/jwt/teable-jwt.service.ts new file mode 100644 index 0000000000..2689c78918 --- /dev/null +++ b/apps/nestjs-backend/src/features/auth/jwt/teable-jwt.service.ts @@ -0,0 +1,130 @@ +import { Injectable } from '@nestjs/common'; +import type { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt'; +import { JwtService, NotBeforeError, TokenExpiredError } from '@nestjs/jwt'; +import { AuthConfig, IAuthConfig } from '../../../configs/auth.config'; + +/** + * The single JWT facade for everything signed with the instance JWT secret, + * with express-session-style secret-array semantics: index 0 signs every new + * token, and a token verifies if ANY listed secret matches. A planned rotation + * (new BACKEND_JWT_SECRET, previous value in BACKEND_JWT_SECRET_OLD) therefore + * keeps outstanding tokens valid until they expire, across every consumer. + * + * Rotation discipline: this array is for PLANNED rotations only. A leaked + * secret must be dropped from the list entirely (hard cut) — keeping it + * verifiable would let the holder forge any token, including self-contained + * payloads like email verification codes and temp tokens. + * + * Tokens expire after BACKEND_JWT_EXPIRES_IN by default; sign sites either + * pass their own expiresIn or put an absolute `exp` claim in the payload + * (jsonwebtoken rejects expiresIn when the payload already carries exp). + */ +@Injectable() +export class TeableJwtService { + private readonly bare = new JwtService({}); + /** index 0 signs; every entry verifies */ + private readonly secrets: readonly string[]; + private readonly defaultExpiresIn: string; + + constructor(@AuthConfig() authConfig: IAuthConfig) { + const { secret, oldSecret, expiresIn } = authConfig.jwt; + this.secrets = oldSecret ? [secret, oldSecret] : [secret]; + this.defaultExpiresIn = expiresIn; + } + + private signOptions(payload: Buffer | object, options?: JwtSignOptions): JwtSignOptions { + const merged: JwtSignOptions = { ...options, secret: this.secrets[0] }; + if (merged.expiresIn === undefined && !(Buffer.isBuffer(payload) || 'exp' in payload)) { + merged.expiresIn = this.defaultExpiresIn; + } + return merged; + } + + sign(payload: Buffer | object, options?: JwtSignOptions): string { + return this.bare.sign(payload, this.signOptions(payload, options)); + } + + signAsync(payload: Buffer | object, options?: JwtSignOptions): Promise { + return this.bare.signAsync(payload, this.signOptions(payload, options)); + } + + verify(token: string, options?: JwtVerifyOptions): T { + let lastError: unknown; + for (const secret of this.secrets) { + try { + return this.bare.verify(token, { ...options, secret }); + } catch (error) { + // Expired / not-before means the signature DID match this secret; + // older secrets cannot make such a token valid — surface it as-is. + if (error instanceof TokenExpiredError || error instanceof NotBeforeError) { + throw error; + } + lastError = error; + } + } + throw lastError; + } + + async verifyAsync(token: string, options?: JwtVerifyOptions): Promise { + let lastError: unknown; + for (const secret of this.secrets) { + try { + return await this.bare.verifyAsync(token, { ...options, secret }); + } catch (error) { + // Same expiry short-circuit as verify() above. + if (error instanceof TokenExpiredError || error instanceof NotBeforeError) { + throw error; + } + lastError = error; + } + } + throw lastError; + } + + /** + * Which listed secret signed this token, ignoring expiry — rotation tooling + * uses it to find stored long-lived credentials (e.g. App.accessToken) that + * still depend on the previous secret and must be re-minted before + * BACKEND_JWT_SECRET_OLD is removed. + */ + async classifySigningSecret(token: string): Promise<'current' | 'old' | 'none'> { + for (const [index, secret] of this.secrets.entries()) { + try { + await this.bare.verifyAsync(token, { secret, ignoreExpiration: true }); + return index === 0 ? 'current' : 'old'; + } catch { + // try the next secret + } + } + return 'none'; + } + + /** + * passport-jwt secretOrKeyProvider with the same array semantics: attributes + * the raw token to the listed secret that SIGNED it (ignoring expiry, like + * classifySigningSecret), falling back to the primary for tokens no listed + * secret signed. Validity is passport's job — it re-verifies fully with the + * returned secret, so an expired old-secret token is reported as expired + * instead of as a bad signature against the primary. + */ + passportSecretProvider() { + return ( + _req: unknown, + rawJwtToken: string, + done: (err: unknown, secretOrKey?: string) => void + ): void => { + void (async () => { + for (const secret of this.secrets) { + try { + await this.bare.verifyAsync(rawJwtToken, { secret, ignoreExpiration: true }); + done(null, secret); + return; + } catch { + // try the next secret + } + } + done(null, this.secrets[0]); + })(); + }; + } +} diff --git a/apps/nestjs-backend/src/features/auth/local-auth/local-auth.module.ts b/apps/nestjs-backend/src/features/auth/local-auth/local-auth.module.ts index e5b50138f4..a4a5223354 100644 --- a/apps/nestjs-backend/src/features/auth/local-auth/local-auth.module.ts +++ b/apps/nestjs-backend/src/features/auth/local-auth/local-auth.module.ts @@ -1,7 +1,4 @@ import { Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; -import type { IAuthConfig } from '../../../configs/auth.config'; -import { authConfig } from '../../../configs/auth.config'; import { MailSenderModule } from '../../mail-sender/mail-sender.module'; import { SettingModule } from '../../setting/setting.module'; import { UserModule } from '../../user/user.module'; @@ -13,22 +10,7 @@ import { LocalAuthController } from './local-auth.controller'; import { LocalAuthService } from './local-auth.service'; @Module({ - imports: [ - TurnstileModule, - SettingModule, - UserModule, - SessionModule, - MailSenderModule.register(), - JwtModule.registerAsync({ - useFactory: (config: IAuthConfig) => ({ - secret: config.jwt.secret, - signOptions: { - expiresIn: config.jwt.expiresIn, - }, - }), - inject: [authConfig.KEY], - }), - ], + imports: [TurnstileModule, SettingModule, UserModule, SessionModule, MailSenderModule.register()], providers: [LocalStrategy, LocalAuthService, SessionStoreService], controllers: [LocalAuthController], exports: [LocalAuthService], diff --git a/apps/nestjs-backend/src/features/auth/local-auth/local-auth.service.ts b/apps/nestjs-backend/src/features/auth/local-auth/local-auth.service.ts index 29534ee2dc..fc14892764 100644 --- a/apps/nestjs-backend/src/features/auth/local-auth/local-auth.service.ts +++ b/apps/nestjs-backend/src/features/auth/local-auth/local-auth.service.ts @@ -1,6 +1,5 @@ /* eslint-disable sonarjs/no-duplicate-string */ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; import { generateUserId, getRandomString, HttpErrorCode, RandomType } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import { EmailVerifyCodeType, MailTransporterType, MailType } from '@teable/openapi'; @@ -23,6 +22,7 @@ import { second } from '../../../utils/second'; import { MailSenderService } from '../../mail-sender/mail-sender.service'; import { SettingService } from '../../setting/setting.service'; import { UserService } from '../../user/user.service'; +import { TeableJwtService } from '../jwt/teable-jwt.service'; import { SessionStoreService } from '../session/session-store.service'; import { TurnstileService } from '../turnstile/turnstile.service'; @@ -42,7 +42,7 @@ export class LocalAuthService { @MailConfig() private readonly mailConfig: IMailConfig, @BaseConfig() private readonly baseConfig: IBaseConfig, @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig, - private readonly jwtService: JwtService, + private readonly jwtService: TeableJwtService, private readonly settingService: SettingService, private readonly turnstileService: TurnstileService ) {} diff --git a/apps/nestjs-backend/src/features/auth/permission.module.ts b/apps/nestjs-backend/src/features/auth/permission.module.ts index c5b45e4897..075577844a 100644 --- a/apps/nestjs-backend/src/features/auth/permission.module.ts +++ b/apps/nestjs-backend/src/features/auth/permission.module.ts @@ -1,22 +1,10 @@ import { Global, Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; -import { authConfig, type IAuthConfig } from '../../configs/auth.config'; import { PermissionGuard } from './guard/permission.guard'; import { PermissionService } from './permission.service'; @Global() @Module({ - imports: [ - JwtModule.registerAsync({ - useFactory: (config: IAuthConfig) => ({ - secret: config.jwt.secret, - signOptions: { - expiresIn: config.jwt.expiresIn, - }, - }), - inject: [authConfig.KEY], - }), - ], + imports: [], providers: [PermissionService, PermissionGuard], exports: [PermissionService, PermissionGuard], }) diff --git a/apps/nestjs-backend/src/features/auth/permission.service.ts b/apps/nestjs-backend/src/features/auth/permission.service.ts index 5f2c9fdebc..c9167231ec 100644 --- a/apps/nestjs-backend/src/features/auth/permission.service.ts +++ b/apps/nestjs-backend/src/features/auth/permission.service.ts @@ -1,5 +1,4 @@ import { Injectable, Logger } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; import type { IBaseRole, Action, IShareViewMeta } from '@teable/core'; import { HttpErrorCode, @@ -20,6 +19,7 @@ import type { IClsStore } from '../../types/cls'; import { getMaxLevelRole } from '../../utils/get-max-level-role'; import { CollaboratorModel } from '../model/collaborator'; import { TemplateModel } from '../model/template'; +import { TeableJwtService } from './jwt/teable-jwt.service'; interface IBaseNodeCacheItem { id: string; @@ -57,7 +57,7 @@ export class PermissionService { private readonly cls: ClsService, private readonly collaboratorModel: CollaboratorModel, private readonly templateModel: TemplateModel, - private readonly jwtService: JwtService + private readonly jwtService: TeableJwtService ) {} private getDepartmentIds() { diff --git a/apps/nestjs-backend/src/features/auth/session/session-handle.service.ts b/apps/nestjs-backend/src/features/auth/session/session-handle.service.ts index 30e2e620ac..82947abb87 100644 --- a/apps/nestjs-backend/src/features/auth/session/session-handle.service.ts +++ b/apps/nestjs-backend/src/features/auth/session/session-handle.service.ts @@ -13,9 +13,12 @@ export class SessionHandleService { private readonly sessionStoreService: SessionStoreService, @AuthConfig() private readonly authConfig: IAuthConfig ) { + const { secret, oldSecret } = this.authConfig.session; this.sessionMiddleware = session({ name: AUTH_SESSION_COOKIE_NAME, - secret: this.authConfig.session.secret, + // Array form: the first secret signs new cookies, every secret validates + // existing ones — so rotating BACKEND_SESSION_SECRET keeps live sessions. + secret: oldSecret ? [secret, oldSecret] : secret, resave: false, saveUninitialized: false, cookie: { diff --git a/apps/nestjs-backend/src/features/auth/session/session-store.service.spec.ts b/apps/nestjs-backend/src/features/auth/session/session-store.service.spec.ts index 03be07884a..0f5ac7160b 100644 --- a/apps/nestjs-backend/src/features/auth/session/session-store.service.spec.ts +++ b/apps/nestjs-backend/src/features/auth/session/session-store.service.spec.ts @@ -143,21 +143,43 @@ describe('SessionStoreService', () => { expect(result).toBeNull(); }); - it('should return undefined and delete session if user session is not found', async () => { - // Mock the necessary cacheService methods + it('repairs the user-session map when the entry was lost without a clear', async () => { + // expire flag, session store, user map (entry lost), no clear tombstone + cacheService.get.mockResolvedValueOnce(undefined); + cacheService.get.mockResolvedValueOnce(sessionData); + cacheService.get.mockResolvedValueOnce(undefined); + cacheService.get.mockResolvedValueOnce(undefined); + + const result = await sessionStoreService['getCache'](sid); + + expect(cacheService.get).toHaveBeenCalledWith(`auth:session-user-cleared:user-id`); + // A concurrent signin/touch clobbered the map entry — the session must be + // re-registered, not destroyed. + expect(cacheService.set).toHaveBeenCalledWith( + `auth:session-user:user-id`, + expect.objectContaining({ [sid]: expect.any(Number) }), + expect.any(Number) + ); + expect(cacheService.del).not.toHaveBeenCalled(); + expect(result).toBe(sessionData); + }); + + it('deletes the session on a lost map entry when the user sessions were cleared', async () => { + // expire flag, session store, user map (entry lost), clear tombstone in + // the future relative to the session's renewal time cacheService.get.mockResolvedValueOnce(undefined); cacheService.get.mockResolvedValueOnce(sessionData); cacheService.get.mockResolvedValueOnce(undefined); + cacheService.get.mockResolvedValueOnce(Math.floor(Date.now() / 1000) + 60); cacheService.del.mockResolvedValueOnce(true); const result = await sessionStoreService['getCache'](sid); - // Verify that cacheService.get and cacheService.del were called with the expected parameters expect(cacheService.get).toHaveBeenCalledWith(`auth:session-expire:${sid}`); expect(cacheService.get).toHaveBeenCalledWith(`auth:session-store:${sid}`); expect(cacheService.get).toHaveBeenCalledWith(`auth:session-user:user-id`); + expect(cacheService.get).toHaveBeenCalledWith(`auth:session-user-cleared:user-id`); expect(cacheService.del).toHaveBeenCalledWith(`auth:session-store:${sid}`); - // Verify that the result is null and session is deleted when user session is not found expect(result).toBeNull(); }); diff --git a/apps/nestjs-backend/src/features/auth/session/session-store.service.ts b/apps/nestjs-backend/src/features/auth/session/session-store.service.ts index 9717b64155..bcaa72f8b5 100644 --- a/apps/nestjs-backend/src/features/auth/session/session-store.service.ts +++ b/apps/nestjs-backend/src/features/auth/session/session-store.service.ts @@ -55,9 +55,22 @@ export class SessionStoreService extends Store { const userId = session.passport.user.id; const userSessions = (await this.cacheService.get(`auth:session-user:${userId}`)) ?? {}; if (!userSessions[sid]) { - this.logger.log(`Session ${sid} not found in userSessions`); - await this.cacheService.del(`auth:session-store:${sid}`); - return null; + // The per-user map is updated with an unlocked read-modify-write, so two + // concurrent signins/touches for the same user (multiple devices, + // parallel e2e workers) can clobber each other's entry. A missing entry + // therefore only means "revoked" when a clearByUserId actually happened + // and this session predates it; otherwise repair the map instead of + // destroying a session the user still holds. + const clearedAtSec = await this.cacheService.get(`auth:session-user-cleared:${userId}`); + if (clearedAtSec && this.sessionRenewedAtSec(session) <= clearedAtSec) { + this.logger.log(`Session ${sid} not found in userSessions`); + await this.cacheService.del(`auth:session-store:${sid}`); + return null; + } + this.logger.log(`Session ${sid} restored into userSessions after a lost map update`); + userSessions[sid] = Math.floor(Date.now() / 1000) + this.userSessionExpire; + await this.cacheService.set(`auth:session-user:${userId}`, userSessions, this.ttl); + return session; } // The expiration time is greater than the session cache time, // so that the user session does not expire while the session is still alive. @@ -121,7 +134,29 @@ export class SessionStoreService extends Store { } } + /** + * A session's last issue/renewal time: cookie.expires is stamped now+ttl on + * save and on every rolling touch. Unknown expiry is treated as renewed + * "now" so a fresh post-clear session is never mistaken for a revoked one. + */ + private sessionRenewedAtSec(session: ISessionData): number { + const expires = session.cookie?.expires; + const expiresMs = + expires instanceof Date ? expires.getTime() : expires ? new Date(expires).getTime() : NaN; + if (!Number.isFinite(expiresMs)) { + return Math.floor(Date.now() / 1000); + } + return Math.floor(expiresMs / 1000) - this.ttl; + } + async clearByUserId(userId: string) { + // Mark the clear before deleting anything so the getCache repair path + // (lost-map-update recovery) cannot resurrect the sessions being revoked. + await this.cacheService.set( + `auth:session-user-cleared:${userId}`, + Math.floor(Date.now() / 1000), + this.userSessionExpire + ); const userSessions = (await this.cacheService.get(`auth:session-user:${userId}`)) ?? {}; for (const sid of Object.keys(userSessions)) { // Preventing competition diff --git a/apps/nestjs-backend/src/features/auth/session/session.service.ts b/apps/nestjs-backend/src/features/auth/session/session.service.ts index 30ae863144..d83bc37dc8 100644 --- a/apps/nestjs-backend/src/features/auth/session/session.service.ts +++ b/apps/nestjs-backend/src/features/auth/session/session.service.ts @@ -2,8 +2,8 @@ import { Injectable } from '@nestjs/common'; import { ClsService } from 'nestjs-cls'; import { Events } from '../../../event-emitter/events'; import type { IClsStore } from '../../../types/cls'; -import { Audit } from '../../audit/audit.decorator'; import { AuditScope } from '../../audit/audit-scope'; +import { Audit } from '../../audit/audit.decorator'; @Injectable() export class SessionService { 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 5c24616d0d..91eda1addc 100644 --- a/apps/nestjs-backend/src/features/auth/strategies/jwt.strategy.ts +++ b/apps/nestjs-backend/src/features/auth/strategies/jwt.strategy.ts @@ -1,14 +1,12 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { ConfigType } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { AUTOMATION_ROBOT_USER, APP_ROBOT_USER } from '@teable/core'; import type { Request } from 'express'; import { ClsService } from 'nestjs-cls'; import { ExtractJwt, Strategy } from 'passport-jwt'; -import type { authConfig } from '../../../configs/auth.config'; -import { AuthConfig } from '../../../configs/auth.config'; import type { IClsStore } from '../../../types/cls'; import { UserService } from '../../user/user.service'; +import { TeableJwtService } from '../jwt/teable-jwt.service'; import { pickUserMe } from '../utils'; import { JWT_TOKEN_STRATEGY_NAME } from './constant'; import type { IJwtAuthInternalInfo, IJwtAuthInfo } from './types'; @@ -17,14 +15,17 @@ import { JwtAuthInternalType } from './types'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, JWT_TOKEN_STRATEGY_NAME) { constructor( - @AuthConfig() readonly config: ConfigType, + teableJwtService: TeableJwtService, private readonly userService: UserService, private readonly cls: ClsService ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: config.jwt.secret, + // Array semantics (current secret signs, current + _OLD verify) so + // long-lived internal JWTs — e.g. App.accessToken — survive a planned + // BACKEND_JWT_SECRET rotation like every other verify site. + secretOrKeyProvider: teableJwtService.passportSecretProvider(), passReqToCallback: true, }); } diff --git a/apps/nestjs-backend/src/features/base-node/base-node.service.ts b/apps/nestjs-backend/src/features/base-node/base-node.service.ts index 440d7aeed2..e03ec93683 100644 --- a/apps/nestjs-backend/src/features/base-node/base-node.service.ts +++ b/apps/nestjs-backend/src/features/base-node/base-node.service.ts @@ -785,7 +785,7 @@ export class BaseNodeService { if (name) { await this.tableOpenApiService.updateName(baseId, id, name); } - if (icon) { + if (icon !== undefined) { await this.tableOpenApiService.updateIcon(baseId, id, icon); } break; 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 f82325b2fd..7f6263a6f2 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 @@ -1,8 +1,8 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; import { HttpErrorCode } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import { CustomHttpException } from '../../custom.exception'; +import { TeableJwtService } from '../auth/jwt/teable-jwt.service'; export interface IBaseShareInfo { shareId: string; @@ -22,7 +22,7 @@ export interface IJwtBaseShareInfo { export class BaseShareAuthService { constructor( private readonly prismaService: PrismaService, - private readonly jwtService: JwtService + private readonly jwtService: TeableJwtService ) {} async validateJwtToken(token: string) { @@ -59,7 +59,8 @@ export class BaseShareAuthService { } async authToken(jwtShareInfo: IJwtBaseShareInfo) { - return await this.jwtService.signAsync(jwtShareInfo); + // Same lifetime the BaseShareModule JwtModule registration used to apply. + return await this.jwtService.signAsync(jwtShareInfo, { expiresIn: '7d' }); } async getBaseShareInfo(shareId: string): Promise { diff --git a/apps/nestjs-backend/src/features/base-share/base-share.module.ts b/apps/nestjs-backend/src/features/base-share/base-share.module.ts index d7242201d7..57783445b9 100644 --- a/apps/nestjs-backend/src/features/base-share/base-share.module.ts +++ b/apps/nestjs-backend/src/features/base-share/base-share.module.ts @@ -1,6 +1,4 @@ import { Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; -import { authConfig } from '../../configs/auth.config'; import { AuthModule } from '../auth/auth.module'; import { PermissionModule } from '../auth/permission.module'; import { BaseModule } from '../base/base.module'; @@ -25,14 +23,6 @@ import { BaseShareJwtStrategy } from './strategies/jwt.strategy'; FieldModule, ShortLinkModule, ViewModule, - JwtModule.registerAsync({ - useFactory: () => ({ - secret: authConfig().jwt.secret, - signOptions: { - expiresIn: '7d', - }, - }), - }), ], controllers: [BaseShareController, BaseShareOpenController], providers: [ diff --git a/apps/nestjs-backend/src/features/base-share/strategies/jwt.strategy.ts b/apps/nestjs-backend/src/features/base-share/strategies/jwt.strategy.ts index 3fcec4a8db..620f088786 100644 --- a/apps/nestjs-backend/src/features/base-share/strategies/jwt.strategy.ts +++ b/apps/nestjs-backend/src/features/base-share/strategies/jwt.strategy.ts @@ -1,11 +1,9 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { ConfigType } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import cookie from 'cookie'; import type { Request } from 'express'; import { ExtractJwt, Strategy } from 'passport-jwt'; -import type { authConfig } from '../../../configs/auth.config'; -import { AuthConfig } from '../../../configs/auth.config'; +import { TeableJwtService } from '../../auth/jwt/teable-jwt.service'; import type { IJwtBaseShareInfo } from '../base-share-auth.service'; import { BaseShareAuthService } from '../base-share-auth.service'; import { BASE_SHARE_JWT_STRATEGY } from '../guard/constant'; @@ -13,13 +11,13 @@ import { BASE_SHARE_JWT_STRATEGY } from '../guard/constant'; @Injectable() export class BaseShareJwtStrategy extends PassportStrategy(Strategy, BASE_SHARE_JWT_STRATEGY) { constructor( - @AuthConfig() readonly config: ConfigType, + teableJwtService: TeableJwtService, private readonly baseShareAuthService: BaseShareAuthService ) { super({ jwtFromRequest: ExtractJwt.fromExtractors([BaseShareJwtStrategy.fromAuthCookieAsToken]), ignoreExpiration: false, - secretOrKey: config.jwt.secret, + secretOrKeyProvider: teableJwtService.passportSecretProvider(), }); } diff --git a/apps/nestjs-backend/src/features/base/base-data-db-move.service.ts b/apps/nestjs-backend/src/features/base/base-data-db-move.service.ts index 1f5ec992b1..82afe37900 100644 --- a/apps/nestjs-backend/src/features/base/base-data-db-move.service.ts +++ b/apps/nestjs-backend/src/features/base/base-data-db-move.service.ts @@ -324,6 +324,7 @@ export class BaseDataDbMoveService { spaceIds: [], baseIds: [inventory.baseId], tableIds: inventory.tableIds, + includePauseScopes: true, includeSpacePauseScopes: false, }); const sharedResults = await this.copyService.copySharedTables(sharedPlans); diff --git a/apps/nestjs-backend/src/features/canary/guards/v2-feature.guard.ts b/apps/nestjs-backend/src/features/canary/guards/v2-feature.guard.ts index a91c9f0ce7..e06ba8d211 100644 --- a/apps/nestjs-backend/src/features/canary/guards/v2-feature.guard.ts +++ b/apps/nestjs-backend/src/features/canary/guards/v2-feature.guard.ts @@ -75,6 +75,7 @@ export class V2FeatureGuard implements CanActivate { // 2. Resolve base context when possible. Marked new bases are V2-first and bypass rollout config. const base = await this.getBaseV2DecisionContext(context); const decision = await this.canaryService.shouldUseV2ForBaseWithReason(base, feature); + req.useV2 = decision.useV2; this.cls.set('useV2', decision.useV2); this.cls.set('v2Feature', feature); this.cls.set('v2Reason', decision.reason); @@ -102,13 +103,14 @@ export class V2FeatureGuard implements CanActivate { /** * Extract base V2 decision context from request context. * Supports: spaceId (direct), baseId (lookup), tableId (lookup via base), - * and share routes where ShareAuthGuard has already set req.shareInfo.tableId. + * and share routes by resolving their narrow Table ownership before authentication. */ private async getBaseV2DecisionContext( context: ExecutionContext ): Promise { const req = context.switchToHttp().getRequest(); - const shareTableId = + const routeShareTableId = await this.getShareTableId(req.params.shareId); + const hydratedShareTableId = req.shareInfo && typeof req.shareInfo.tableId === 'string' ? req.shareInfo.tableId : undefined; @@ -116,7 +118,8 @@ export class V2FeatureGuard implements CanActivate { req.params.spaceId || req.params.baseId || req.params.tableId || - shareTableId || + routeShareTableId || + hydratedShareTableId || this.getStringResourceId(req.body, ['spaceId', 'baseId', 'tableId']); if (!resourceId) { @@ -156,6 +159,34 @@ export class V2FeatureGuard implements CanActivate { return undefined; } + private async getShareTableId(shareId: unknown): Promise { + if (typeof shareId !== 'string') { + return undefined; + } + + if (shareId.startsWith(IdPrefix.Field)) { + const field = await this.prismaService.txClient().field.findFirst({ + where: { id: shareId, deletedTime: null }, + select: { options: true }, + }); + if (!field?.options) { + return undefined; + } + try { + const options = JSON.parse(field.options) as { foreignTableId?: unknown }; + return typeof options.foreignTableId === 'string' ? options.foreignTableId : undefined; + } catch { + return undefined; + } + } + + const view = await this.prismaService.txClient().view.findFirst({ + where: { shareId, enableShare: true, deletedTime: null }, + select: { tableId: true }, + }); + return view?.tableId; + } + private getStringResourceId(source: unknown, keys: string[]): string | undefined { if (!source || typeof source !== 'object') { return undefined; diff --git a/apps/nestjs-backend/src/features/cold-archive/bloom.ts b/apps/nestjs-backend/src/features/cold-archive/bloom.ts new file mode 100644 index 0000000000..16f7ad3405 --- /dev/null +++ b/apps/nestjs-backend/src/features/cold-archive/bloom.ts @@ -0,0 +1,54 @@ +export interface IRecordBloom { + /** bit count */ + m: number; + /** hash count */ + k: number; + /** base64 bit array */ + b64: string; +} + +const BLOOM_BITS_PER_ELEMENT = 10; // ≈0.8% fpr with k=7 +const BLOOM_HASHES = 7; +const BLOOM_MIN_BITS = 64; + +const fnv1a = (value: string, seed: number): number => { + let hash = (0x811c9dc5 ^ seed) >>> 0; + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash >>> 0; +}; + +/** double hashing; the step must be odd so every bit stays reachable */ +const bloomBitPositions = (value: string, m: number, k: number): number[] => { + const h1 = fnv1a(value, 0); + // `| 1` alone coerces to a SIGNED int32 (negative for hashes ≥ 2^31), making + // the modulo negative and the bit write a silent no-op — a false-negative factory + const h2 = (fnv1a(value, 0x9e3779b9) | 1) >>> 0; + const positions: number[] = []; + for (let i = 0; i < k; i++) { + positions.push((h1 + i * h2) % m); + } + return positions; +}; + +export const buildRecordBloom = (recordIds: Iterable, count: number): IRecordBloom => { + const m = Math.max(BLOOM_MIN_BITS, Math.ceil(count * BLOOM_BITS_PER_ELEMENT)); + const bytes = Buffer.alloc(Math.ceil(m / 8)); + for (const recordId of recordIds) { + for (const position of bloomBitPositions(recordId, m, BLOOM_HASHES)) { + bytes[position >> 3] |= 1 << (position & 7); + } + } + return { m, k: BLOOM_HASHES, b64: bytes.toString('base64') }; +}; + +/** false only when the record is DEFINITELY absent — safe to prune on false */ +export const bloomMightContain = (bloom: IRecordBloom, recordId: string): boolean => { + const bytes = Buffer.from(bloom.b64, 'base64'); + for (const position of bloomBitPositions(recordId, bloom.m, bloom.k)) { + if ((bytes[position >> 3] & (1 << (position & 7))) === 0) return false; + } + return true; +}; diff --git a/apps/nestjs-backend/src/features/cold-archive/bucket-coverage.ts b/apps/nestjs-backend/src/features/cold-archive/bucket-coverage.ts new file mode 100644 index 0000000000..2dce251798 --- /dev/null +++ b/apps/nestjs-backend/src/features/cold-archive/bucket-coverage.ts @@ -0,0 +1,83 @@ +/** + * Bucket-coverage planning for an incremental flush: a bucket whose cold parts + * already account for exactly the rows PG still holds is skipped, its buffer + * rows deleted without a rewrite. The check needs BOTH a live key listing and + * PG's own GROUP BY, because stats alone can name parts a concurrent run has + * since replaced. + */ + +/** per-bucket rollup of a table's `_stats.json` entries */ +export interface IBucketStatsAgg { + keys: Set; + rows: number; + min: string; + max: string; +} + +/** + * `bucketIdOfKey` carries the subsystem's key grammar (undefined for a key it + * cannot parse); `boundsOf` names its timestamp columns. + */ +export const groupStatsByBucket = ( + parts: Record, + bucketIdOfKey: (key: string) => string | undefined, + boundsOf: (entry: TEntry) => { min: string; max: string } +): Map => { + const byBucket = new Map(); + for (const [key, entry] of Object.entries(parts)) { + const id = bucketIdOfKey(key); + if (id === undefined) continue; + const bounds = boundsOf(entry); + const agg = byBucket.get(id) ?? { + keys: new Set(), + rows: 0, + min: bounds.min, + max: bounds.max, + }; + agg.keys.add(key); + agg.rows += entry.rows; + if (bounds.min < agg.min) agg.min = bounds.min; + if (bounds.max > agg.max) agg.max = bounds.max; + byBucket.set(id, agg); + } + return byBucket; +}; + +export const isBucketCovered = ( + agg: IBucketStatsAgg | undefined, + listed: Set | undefined, + bucket: { count: string; min: Date; max: Date } +): boolean => { + return ( + agg !== undefined && + listed !== undefined && + agg.keys.size === listed.size && + [...agg.keys].every((key) => listed.has(key)) && + agg.rows === Number(bucket.count) && + agg.min === bucket.min.toISOString() && + agg.max === bucket.max.toISOString() + ); +}; + +/** canonical time range of a bucket, clamped to the day-window boundary and cutoff */ +export const bucketRange = ( + bucket: { yyyymm: string; dd: string | null }, + cutoff: Date, + dayWindowStart: Date +): { lo: Date; hi: Date } => { + const year = Number(bucket.yyyymm.slice(0, 4)); + const month = Number(bucket.yyyymm.slice(4, 6)); + if (bucket.dd) { + const dayStart = new Date(Date.UTC(year, month - 1, Number(bucket.dd))); + const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); + return { + lo: dayStart > dayWindowStart ? dayStart : dayWindowStart, + hi: dayEnd < cutoff ? dayEnd : cutoff, + }; + } + const monthStart = new Date(Date.UTC(year, month - 1, 1)); + const nextMonth = new Date(Date.UTC(year, month, 1)); + let hi = nextMonth < dayWindowStart ? nextMonth : dayWindowStart; + if (cutoff < hi) hi = cutoff; + return { lo: monthStart, hi }; +}; diff --git a/apps/nestjs-backend/src/features/cold-archive/bucket-merge-feeder.ts b/apps/nestjs-backend/src/features/cold-archive/bucket-merge-feeder.ts new file mode 100644 index 0000000000..785895037e --- /dev/null +++ b/apps/nestjs-backend/src/features/cold-archive/bucket-merge-feeder.ts @@ -0,0 +1,114 @@ +import type { IPartBucket } from './bucket'; +import type { IColdRowCodec, SortMemoryBudget } from './external-sort'; +import { ColdRowSorter } from './external-sort'; + +/** the part-writer surface a feeder drives (see each subsystem's PartWriter) */ +export interface IColdPartWriter { + readonly bucket: IPartBucket; + readonly metrics: IColdPartWriteMetrics; + add(row: TRow): Promise; + finish(): Promise; +} + +export interface IColdPartWriteMetrics { + parts: number; + rows: number; + uncompressedBytes: number; + compressedBytes: number; +} + +/** the storage surface a feeder reads existing parts through */ +export interface IColdRowSource { + iterateRows(key: string): AsyncGenerator<{ row?: TRow }>; +} + +/** + * Feeds a bucket's PartWriter with the deduplicated union of the live buffer + * rows and the bucket's EXISTING cold parts, in the subsystem's canonical order. + * + * Why a full external sort instead of a streaming merge: + * - a bucket can legitimately be flushed more than once with disjoint row sets + * (a run at the horizon boundary covers only part of a day), so existing + * parts must be folded back in, never clobbered; + * - no input order can be trusted, and a streaming merge under mismatched + * orders silently emits duplicates; + * - each existing part is read to EOF immediately: dozens of half-open + * downloads interleaved with uploads deadlock the shared HTTP client + * (observed on the big-table e2e run). + * + * Buffer reads can keep every bucket feeder of a table live at once, so each + * feeder's run charges the one shared SortMemoryBudget — a per-feeder cap made + * peak memory O(#buckets x run size) and OOM'd the 2026-07-08 drain. + */ +export class ColdBucketMergeFeeder { + private readonly sorter: ColdRowSorter; + private initialized = false; + /** rows folded back in from existing parts, not counted as flushed buffer rows */ + mergedExistingRows = 0; + + constructor( + private readonly writer: IColdPartWriter, + private readonly existingParts: readonly { key: string }[], + private readonly coldStorage: IColdRowSource, + codec: IColdRowCodec, + sortBudget?: SortMemoryBudget, + mergeFanIn?: number, + /** repair for rows read back from parts written before the truncation caps */ + private readonly heal?: (row: TRow) => TRow + ) { + this.sorter = new ColdRowSorter(codec, undefined, sortBudget, mergeFanIn); + } + + get bucket(): IPartBucket { + return this.writer.bucket; + } + + get metrics(): IColdPartWriteMetrics { + return this.writer.metrics; + } + + /** + * the keys this feeder folded in — the only ones a heal pass may delete + * afterwards, since a key that appeared concurrently belongs to another run + */ + get consumedKeys(): Set { + return new Set(this.existingParts.map((part) => part.key)); + } + + async push(row: TRow): Promise { + await this.ensureInitialized(); + await this.sorter.add(row); + } + + async finish(): Promise { + try { + await this.ensureInitialized(); + await this.sorter.drainTo((row) => this.writer.add(row)); + return await this.writer.finish(); + } finally { + await this.sorter.cleanup(); + } + } + + /** + * Release the sorter's budget charge, temp files and registry entry without + * emitting anything — for a flush that dies after opening feeders but before + * their finish loop. Idempotent, and safe whether or not finish() ran. + */ + async abort(): Promise { + await this.sorter.cleanup(); + } + + private async ensureInitialized(): Promise { + if (this.initialized) return; + this.initialized = true; + for (const part of this.existingParts) { + for await (const item of this.coldStorage.iterateRows(part.key)) { + if (!item.row) continue; + const row = this.heal ? this.heal(item.row) : item.row; + await this.sorter.add(row); + this.mergedExistingRows += 1; + } + } + } +} diff --git a/apps/nestjs-backend/src/features/cold-archive/bucket.ts b/apps/nestjs-backend/src/features/cold-archive/bucket.ts new file mode 100644 index 0000000000..a84a9c9c4f --- /dev/null +++ b/apps/nestjs-backend/src/features/cold-archive/bucket.ts @@ -0,0 +1,17 @@ +export interface IPartBucket { + yyyymm: string; + kind: 'day' | 'month'; + /** two digit day, only for kind=day */ + dd?: string; +} + +export const bucketOfDate = (date: Date, kind: 'day' | 'month'): IPartBucket => { + const yyyymm = `${date.getUTCFullYear()}${String(date.getUTCMonth() + 1).padStart(2, '0')}`; + if (kind === 'month') return { yyyymm, kind }; + return { yyyymm, kind, dd: String(date.getUTCDate()).padStart(2, '0') }; +}; + +export const bucketId = (bucket: IPartBucket) => + bucket.kind === 'month' ? `${bucket.yyyymm}/m` : `${bucket.yyyymm}/${bucket.dd}`; + +export const padSeq = (seq: number) => String(seq).padStart(4, '0'); diff --git a/apps/nestjs-backend/src/features/cold-archive/compression.ts b/apps/nestjs-backend/src/features/cold-archive/compression.ts new file mode 100644 index 0000000000..cfadf17ba1 --- /dev/null +++ b/apps/nestjs-backend/src/features/cold-archive/compression.ts @@ -0,0 +1,43 @@ +import * as zlib from 'node:zlib'; + +const zlibWithZstd = zlib as typeof zlib & { + createZstdCompress?: (options?: unknown) => zlib.Gzip; + createZstdDecompress?: (options?: unknown) => zlib.Gunzip; +}; + +export const hasZstd = typeof zlibWithZstd.createZstdCompress === 'function'; + +/** + * Writing prefers zstd when the runtime has it (node >= 22.15); reading always + * handles both formats. A `.zst` KEY still needs a zstd-capable reader, so a + * fleet on mixed node versions forces gzip through the subsystem's + * `..._COMPRESSION=gzip`. + * + * `envName` is a parameter so each subsystem keeps its own variable, and the + * read stays per call: env files may load after module evaluation. + */ +const writeZstd = (envName: string) => hasZstd && process.env[envName] !== 'gzip'; + +export const partFileSuffixFor = (envName: string) => + writeZstd(envName) ? '.ndjson.zst' : '.ndjson.gz'; + +export const createPartCompressorFor = (envName: string) => { + if (writeZstd(envName)) { + return zlibWithZstd.createZstdCompress!({ + params: { + [zlib.constants.ZSTD_c_compressionLevel]: 3, + }, + }); + } + return zlib.createGzip({ level: 6 }); +}; + +export const createPartDecompressor = (key: string) => { + if (key.endsWith('.zst')) { + if (!hasZstd) { + throw new Error(`cannot decompress ${key}: node runtime lacks zstd support`); + } + return zlibWithZstd.createZstdDecompress!(); + } + return zlib.createGunzip(); +}; diff --git a/apps/nestjs-backend/src/features/cold-archive/env.ts b/apps/nestjs-backend/src/features/cold-archive/env.ts new file mode 100644 index 0000000000..da7a10f04d --- /dev/null +++ b/apps/nestjs-backend/src/features/cold-archive/env.ts @@ -0,0 +1,19 @@ +export const readBoolEnv = (name: string): boolean => { + const value = process.env[name]?.trim().toLowerCase(); + return value === '1' || value === 'true' || value === 'on'; +}; + +export const readPositiveIntEnv = (name: string, defaultValue: number): number => { + const raw = process.env[name]; + if (raw === undefined) return defaultValue; + const value = Number(raw); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : defaultValue; +}; + +/** like readPositiveIntEnv but 0 is a valid value (used for "disabled") */ +export const readNonNegativeIntEnv = (name: string, defaultValue: number): number => { + const raw = process.env[name]; + if (raw === undefined) return defaultValue; + const value = Number(raw); + return Number.isFinite(value) && value >= 0 ? Math.floor(value) : defaultValue; +}; diff --git a/apps/nestjs-backend/src/features/cold-archive/external-sort.ts b/apps/nestjs-backend/src/features/cold-archive/external-sort.ts new file mode 100644 index 0000000000..60b1b9f163 --- /dev/null +++ b/apps/nestjs-backend/src/features/cold-archive/external-sort.ts @@ -0,0 +1,396 @@ +import { randomBytes } from 'node:crypto'; +import { createReadStream, createWriteStream } from 'node:fs'; +import { unlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { createGunzip, createGzip } from 'node:zlib'; +import { iterateNdjsonLines } from './ndjson'; + +/** rows per in-memory run before spilling (secondary, count-based cap) */ +const DEFAULT_RUN_SIZE = 50_000; +/** + * run files a merge may open at once. Each open reader holds one decoded row + * plus its line buffer, and a cold row can be tens of MB, so an unbounded + * fan-in OOM'd the 2026-07-08 drain. Above this the merge goes multi-pass. + */ +const DEFAULT_MERGE_FAN_IN = 16; +const MIN_MERGE_FAN_IN = 2; + +/** what a sorter needs to know about the rows of one cold subsystem */ +export interface IColdRowCodec { + /** the subsystem's canonical part order */ + compare: (a: TRow, b: TRow) => number; + /** approximate serialized bytes; real heap cost is ~2-3x (UTF-16 + headers) */ + sizeOf: (row: TRow) => number; + /** tmpdir filename prefix, kept distinct per subsystem for spill triage */ + tmpPrefix: string; +} + +/** the only surface SortMemoryBudget needs from the runs it evicts */ +export interface IEvictable { + readonly pendingBytes: number; + evict(): Promise; +} + +/** + * Shared cap on the bytes ALL live sorters may hold in memory together. + * + * A table flush opens one sorter per bucket and buffer reads can keep every + * bucket live at once, so a per-sorter cap puts peak memory at O(#buckets x + * run size). On the 2026-07-08 drain a 21-month table (x4 table concurrency) + * turned that into 2-3GB of heap and a V8 OOM. Charging every add against one + * run-wide budget and evicting the largest run restores a constant bound. + * + * Bytes stay charged until an evicted run's spill WRITE lands, not merely + * until the rows leave the array: the in-flight gzip write still references + * them. enforce() therefore waits on in-flight spills when nothing is + * evictable, which is the backpressure that bounds total memory. + */ +export class SortMemoryBudget { + private used = 0; + private readonly sorters = new Set(); + private readonly inflight = new Set>(); + + constructor(private readonly maxBytes: number) {} + + get usedBytes(): number { + return this.used; + } + + register(sorter: IEvictable): void { + this.sorters.add(sorter); + } + + /** stop offering this run for eviction; its bytes stay charged until released */ + unregister(sorter: IEvictable): void { + this.sorters.delete(sorter); + } + + charge(bytes: number): void { + this.used += bytes; + } + + release(bytes: number): void { + this.used = Math.max(0, this.used - bytes); + } + + trackInflight(write: Promise): void { + this.inflight.add(write); + const drop = (): void => { + this.inflight.delete(write); + }; + write.then(drop, drop); + } + + async enforce(): Promise { + while (this.used > this.maxBytes) { + let largest: IEvictable | undefined; + for (const sorter of this.sorters) { + if (!largest || sorter.pendingBytes > largest.pendingBytes) largest = sorter; + } + if (largest && largest.pendingBytes > 0) { + try { + await largest.evict(); + } catch { + // the evicted sorter records its own failure and fails its own table + // loudly; the swap already freed memory, so this loop still progresses + } + continue; + } + if (this.inflight.size > 0) { + await Promise.race([...this.inflight].map((write) => write.catch(() => undefined))); + continue; + } + // the remainder is pinned by sorters mid-drain (released at cleanup); + // overshoot is bounded by one run, so return instead of spinning + return; + } + } +} + +/** + * Disk-backed sort + dedup for bucket rewrites. + * + * No input order can be trusted: the buffer stream follows the db collation + * (mixed-case cuids order differently than bytes, and a timestamp tiebreak + * need not match the comparator either), and existing parts folded back in may + * carry that order too. Rows collect into in-memory runs, each sorted with the + * codec's comparator and spilled to a gzipped temp file; a bounded-fan-in + * k-way merge with adjacent id dedup emits one stream in the canonical order — + * the only order the part keys and the read path understand. + * + * A run spills at DEFAULT_RUN_SIZE rows, or earlier when the shared budget + * evicts it: the count bounds one sorter, only the budget bounds all of them. + */ +export class ColdRowSorter implements IEvictable { + private run: TRow[] = []; + private runBytes = 0; + private runFiles: string[] = []; + private rowsAdded = 0; + private readonly pendingSpills = new Set>(); + /** first spill failure; every later add()/drainTo() rethrows it */ + private spillError: unknown; + private draining = false; + + private readonly mergeFanIn: number; + + constructor( + private readonly codec: IColdRowCodec, + private readonly runSize = DEFAULT_RUN_SIZE, + private readonly budget?: SortMemoryBudget, + mergeFanIn = DEFAULT_MERGE_FAN_IN + ) { + // a pass of 1->1 never shrinks the file count, so the merge would spin + this.mergeFanIn = Math.max(MIN_MERGE_FAN_IN, mergeFanIn); + budget?.register(this); + } + + get added(): number { + return this.rowsAdded; + } + + /** bytes held by the in-memory run — the budget's eviction key */ + get pendingBytes(): number { + return this.runBytes; + } + + /** + * A failed spill means accepted rows are gone, so the output would be + * incomplete: fail fast rather than let the owner delete buffer rows that + * were never written. + */ + async add(row: TRow): Promise { + if (this.spillError) throw this.spillError; + const bytes = this.codec.sizeOf(row); + this.run.push(row); + this.rowsAdded += 1; + this.runBytes += bytes; + this.budget?.charge(bytes); + if (this.run.length >= this.runSize) { + await this.spill(); + return; + } + await this.budget?.enforce(); + } + + /** + * Merge all runs in canonical order, deduped by row id, into `emit`. + * + * The draining gate, the unregister and the settle all happen BEFORE the + * in-memory/merge choice: an eviction racing this drain would otherwise + * leave its rows in a file the merge never sees, and the caller would then + * delete buffer rows that never reached a part. + */ + async drainTo(emit: (row: TRow) => Promise): Promise { + try { + this.draining = true; + this.budget?.unregister(this); + await this.settleSpills(); + if (this.runFiles.length === 0) { + await this.drainInMemory(emit); + return; + } + await this.spill(); + await this.mergeSpilledRuns(emit); + } finally { + await this.cleanup(); + } + } + + private async drainInMemory(emit: (row: TRow) => Promise): Promise { + this.run.sort(this.codec.compare); + let lastId: string | undefined; + for (const row of this.run) { + if (row.id === lastId) continue; + lastId = row.id; + await emit(row); + } + this.run = []; + } + + /** + * Multi-pass k-way merge that never opens more than mergeFanIn readers at + * once: each pass merges groups of up-to-K runs into one, deleting inputs as + * it goes, until a final group of <=K streams into `emit`. + */ + private async mergeSpilledRuns(emit: (row: TRow) => Promise): Promise { + while (this.runFiles.length > this.mergeFanIn) { + const inputs = this.runFiles; + const outputs: string[] = []; + for (let i = 0; i < inputs.length; i += this.mergeFanIn) { + const group = inputs.slice(i, i + this.mergeFanIn); + const merged = await this.mergeGroupToFile(group); + outputs.push(merged); + // track inputs AND outputs so a throw mid-pass still unlinks every file + this.runFiles = [...inputs, ...outputs]; + } + for (const file of inputs) { + await unlink(file).catch(() => undefined); + } + this.runFiles = outputs; + } + for await (const row of this.mergeFiles(this.runFiles)) { + await emit(row); + } + } + + private async mergeGroupToFile(files: string[]): Promise { + const file = this.tmpFile('merge'); + try { + await pipeline( + Readable.from(this.mergeFilesToLines(files)), + createGzip({ level: 1 }), + createWriteStream(file) + ); + } catch (error) { + this.spillError ??= error; + await unlink(file).catch(() => undefined); + throw error; + } + return file; + } + + private async *mergeFilesToLines(files: string[]): AsyncGenerator { + for await (const row of this.mergeFiles(files)) { + yield `${JSON.stringify(row)}\n`; + } + } + + /** opens exactly files.length readers, so callers must keep that <= fan-in */ + private async *mergeFiles(files: string[]): AsyncGenerator { + const heads: IMergeHead[] = []; + try { + for (const file of files) { + const iterator = readRunRows(file); + const first = await iterator.next(); + if (!first.done) heads.push({ row: first.value, iterator }); + else await iterator.return?.(undefined); + } + let lastId: string | undefined; + while (heads.length > 0) { + const minIndex = this.pickMinRow(heads); + const head = heads[minIndex]; + if (head.row.id !== lastId) { + lastId = head.row.id; + yield head.row; + } + const next = await head.iterator.next(); + if (next.done) heads.splice(minIndex, 1); + else head.row = next.value; + } + } finally { + // on early return or throw, release file handles and decompressor buffers + for (const head of heads) { + await head.iterator.return?.(undefined).catch(() => undefined); + } + } + } + + private pickMinRow(heads: IMergeHead[]): number { + let minIndex = 0; + for (let i = 1; i < heads.length; i++) { + if (this.codec.compare(heads[i].row, heads[minIndex].row) < 0) minIndex = i; + } + return minIndex; + } + + async cleanup(): Promise { + // settle first, or an in-flight spill's file leaks into tmpdir once + // runFiles is cleared + await Promise.allSettled([...this.pendingSpills]); + this.budget?.release(this.runBytes); + this.runBytes = 0; + this.run = []; + this.budget?.unregister(this); + for (const file of this.runFiles) { + await unlink(file).catch(() => undefined); + } + this.runFiles = []; + } + + /** + * Eviction entry point for the shared budget. A no-op once draining started: + * an eviction picked moments before the unregister must not swap rows out + * from under the emitter. + */ + async evict(): Promise { + if (this.draining) return; + await this.spill(); + } + + /** + * Sort + write the current run to a gzipped temp file. + * + * The swap happens BEFORE any await: a budget sweep may spill this sorter + * between its owner's adds, and a row pushed during the write must open the + * next run — landing in an already-sorted file would break the merge order. + * The charge is released only when the write LANDS, so a large-row producer + * cannot race ahead of the disk. + */ + async spill(): Promise { + if (this.run.length === 0) return; + const rows = this.run; + const bytes = this.runBytes; + this.run = []; + this.runBytes = 0; + const tracked: Promise = this.writeRun(rows).finally(() => { + this.pendingSpills.delete(tracked); + this.budget?.release(bytes); + }); + this.pendingSpills.add(tracked); + this.budget?.trackInflight(tracked); + await tracked; + } + + private async settleSpills(): Promise { + await Promise.allSettled([...this.pendingSpills]); + if (this.spillError) throw this.spillError; + } + + private async writeRun(rows: TRow[]): Promise { + rows.sort(this.codec.compare); + const file = this.tmpFile('run'); + try { + // level 1: ~4-6x on this JSON for a few % CPU. The budget makes runs + // smaller and more numerous, so this keeps their disk footprint below + // what the uncompressed big runs cost. + await pipeline( + Readable.from(serializeRunRows(rows)), + createGzip({ level: 1 }), + createWriteStream(file) + ); + } catch (error) { + this.spillError ??= error; + await unlink(file).catch(() => undefined); + throw error; + } + this.runFiles.push(file); + } + + private tmpFile(kind: 'run' | 'merge'): string { + return join( + tmpdir(), + `${this.codec.tmpPrefix}-${kind}-${process.pid}-${randomBytes(6).toString('hex')}.ndjson.gz` + ); + } +} + +interface IMergeHead { + row: TRow; + iterator: AsyncGenerator; +} + +function* serializeRunRows(rows: TRow[]): Generator { + for (const row of rows) { + yield `${JSON.stringify(row)}\n`; + } +} + +async function* readRunRows(file: string): AsyncGenerator { + const stream = createReadStream(file).pipe(createGunzip()); + for await (const line of iterateNdjsonLines(stream)) { + yield JSON.parse(line) as TRow; + } +} diff --git a/apps/nestjs-backend/src/features/cold-archive/ndjson.ts b/apps/nestjs-backend/src/features/cold-archive/ndjson.ts new file mode 100644 index 0000000000..b58ed2aa6d --- /dev/null +++ b/apps/nestjs-backend/src/features/cold-archive/ndjson.ts @@ -0,0 +1,50 @@ +import type { Readable } from 'node:stream'; + +const NEWLINE = 0x0a; + +/** + * Split a byte stream into NDJSON lines WITHOUT node:readline. + * + * readline flattens its growing internal ConsString and runs a line-ending + * regex on every chunk, so one multi-megabyte line (a cold row whose payload + * JSON is tens of MB — real on the ai fleet) becomes an O(n^2) rope-flatten + * storm. That OOM'd the 2026-07-08 cold drain. Here partial chunks accumulate + * in an array and concatenate exactly once, when the newline arrives. + */ +export async function* iterateNdjsonLines(stream: Readable): AsyncGenerator { + const pending: Buffer[] = []; + let pendingLen = 0; + try { + for await (const chunk of stream as AsyncIterable) { + let start = 0; + let nl = chunk.indexOf(NEWLINE, start); + while (nl !== -1) { + const slice = chunk.subarray(start, nl); + let line: Buffer; + if (pendingLen > 0) { + pending.push(slice); + line = Buffer.concat(pending, pendingLen + slice.length); + pending.length = 0; + pendingLen = 0; + } else { + line = slice; + } + if (line.length > 0) yield line.toString('utf8'); + start = nl + 1; + nl = chunk.indexOf(NEWLINE, start); + } + if (start < chunk.length) { + // copy: the source buffer may be recycled before the next iteration + const rest = Buffer.from(chunk.subarray(start)); + pending.push(rest); + pendingLen += rest.length; + } + } + if (pendingLen > 0) { + const line = Buffer.concat(pending, pendingLen).toString('utf8'); + if (line.length > 0) yield line; + } + } finally { + stream.destroy(); + } +} diff --git a/apps/nestjs-backend/src/features/cold-archive/part-byte-cache.spec.ts b/apps/nestjs-backend/src/features/cold-archive/part-byte-cache.spec.ts new file mode 100644 index 0000000000..1446f1b80d --- /dev/null +++ b/apps/nestjs-backend/src/features/cold-archive/part-byte-cache.spec.ts @@ -0,0 +1,20 @@ +import { Readable } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { ColdPartByteCache } from './part-byte-cache'; + +describe('cold part byte cache', () => { + const cacheOf = () => + new ColdPartByteCache(async () => Readable.from(Buffer.alloc(0))) as unknown as { + put: (cacheKey: string, buffer: Buffer) => void; + bytes: number; + entries: Map; + }; + + it('re-caching the same key under concurrent misses does not leak phantom bytes', () => { + const cache = cacheOf(); + cache.put('k@etag1', Buffer.alloc(1024, 1)); + cache.put('k@etag1', Buffer.alloc(1024, 2)); + expect(cache.bytes).toBe(1024); + expect(cache.entries.size).toBe(1); + }); +}); diff --git a/apps/nestjs-backend/src/features/cold-archive/part-byte-cache.ts b/apps/nestjs-backend/src/features/cold-archive/part-byte-cache.ts new file mode 100644 index 0000000000..b9b404d704 --- /dev/null +++ b/apps/nestjs-backend/src/features/cold-archive/part-byte-cache.ts @@ -0,0 +1,85 @@ +import { Readable } from 'node:stream'; + +const PART_CACHE_MAX_TOTAL_BYTES = 64 * 1024 * 1024; +const PART_CACHE_MAX_ENTRY_BYTES = 16 * 1024 * 1024; + +/** thrown when a part download outlives the caller's read deadline */ +export class ColdReadDeadlineError extends Error {} + +/** + * Etag-keyed LRU of compressed part bytes for the READ paths. + * + * The etag is what makes caching safe: the flusher/compactor run in another + * process, so a key-addressed cache could serve clobbered content, whereas an + * in-place rewrite changes the etag and misses by construction. WRITE paths + * must not use this — they read parts they are about to replace. + * + * The deadline also bounds the buffering download itself, which would + * otherwise run to completion before the caller's per-row checks see a byte. + */ +export class ColdPartByteCache { + private readonly entries = new Map(); + private bytes = 0; + + constructor(private readonly download: (key: string) => Promise) {} + + /** + * The part's compressed bytes, from cache when the version is cacheable and + * already held. An uncacheable part (no etag, or over the entry cap) is + * still buffered to honor a deadline; only a deadline-less caller streams + * straight through, never materializing the part. + */ + async streamFor( + key: string, + version: { etag?: string; size?: number }, + deadline?: number + ): Promise { + if (!version.etag || (version.size ?? Infinity) > PART_CACHE_MAX_ENTRY_BYTES) { + if (deadline === undefined) return this.download(key); + return Readable.from(await this.downloadWithDeadline(key, deadline)); + } + const cacheKey = `${key}@${version.etag}`; + const cached = this.entries.get(cacheKey); + if (cached) { + // re-insert to refresh the LRU position + this.entries.delete(cacheKey); + this.entries.set(cacheKey, cached); + return Readable.from(cached); + } + const buffer = await this.downloadWithDeadline(key, deadline); + this.put(cacheKey, buffer); + return Readable.from(buffer); + } + + private async downloadWithDeadline(key: string, deadline?: number): Promise { + const stream = await this.download(key); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + if (deadline !== undefined && Date.now() > deadline) { + stream.destroy(); + throw new ColdReadDeadlineError(`download of ${key} exceeded the cold read budget`); + } + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks); + } + + private put(cacheKey: string, buffer: Buffer) { + if (buffer.length > PART_CACHE_MAX_ENTRY_BYTES) return; + // two requests can miss the same key concurrently: replacing without + // reclaiming the first entry's bytes leaves phantom bytes in the counter + const existing = this.entries.get(cacheKey); + if (existing) { + this.bytes -= existing.length; + this.entries.delete(cacheKey); + } + this.entries.set(cacheKey, buffer); + this.bytes += buffer.length; + while (this.bytes > PART_CACHE_MAX_TOTAL_BYTES && this.entries.size > 0) { + const oldest = this.entries.keys().next().value as string; + const evicted = this.entries.get(oldest); + this.entries.delete(oldest); + this.bytes -= evicted?.length ?? 0; + } + } +} diff --git a/apps/nestjs-backend/src/features/cold-archive/part-line.ts b/apps/nestjs-backend/src/features/cold-archive/part-line.ts new file mode 100644 index 0000000000..10cc3418d8 --- /dev/null +++ b/apps/nestjs-backend/src/features/cold-archive/part-line.ts @@ -0,0 +1,67 @@ +import { createHash } from 'node:crypto'; +import type { Readable } from 'node:stream'; +import { createPartDecompressor } from './compression'; +import { iterateNdjsonLines } from './ndjson'; + +export interface IPartFooter { + t: 'f'; + rows: number; + sha256: string; +} + +export const serializeFooter = (rows: number, sha256: string): string => + JSON.stringify({ t: 'f', rows, sha256 } satisfies IPartFooter); + +export const createRowHasher = () => { + const hash = createHash('sha256'); + return { + update(rowLine: string) { + hash.update(rowLine); + hash.update('\n'); + }, + digest() { + return hash.digest('hex'); + }, + }; +}; + +const parsePartLine = ( + line: string +): { header?: unknown; footer?: IPartFooter; row?: TRow; raw: string } | undefined => { + if (!line) return undefined; + const value = JSON.parse(line) as { t?: string }; + if (value.t === 'h') return { header: value, raw: line }; + if (value.t === 'f') return { footer: value as IPartFooter, raw: line }; + return { row: value as unknown as TRow, raw: line }; +}; + +/** + * Stream-decode a compressed part into rows: download stream → decompressor → + * NDJSON line splitter, so memory stays O(line) however large the part is. The + * caller may stop early by breaking out of the async iterator. + */ +export async function* decodePartRows( + key: string, + compressed: Readable +): AsyncGenerator<{ row?: TRow; footer?: IPartFooter; rowLine?: string }> { + const decompressor = createPartDecompressor(key); + // a bare zlib error names no part and is undebuggable + decompressor.on('error', (error: Error & { partKey?: string }) => { + error.partKey = key; + error.message = `${error.message} (part ${key})`; + }); + try { + for await (const line of iterateNdjsonLines(compressed.pipe(decompressor))) { + const parsed = parsePartLine(line); + if (!parsed) continue; + if (parsed.header) continue; + if (parsed.footer) { + yield { footer: parsed.footer }; + continue; + } + yield { row: parsed.row, rowLine: parsed.raw }; + } + } finally { + compressed.destroy(); + } +} diff --git a/apps/nestjs-backend/src/features/cold-archive/read-batch.ts b/apps/nestjs-backend/src/features/cold-archive/read-batch.ts new file mode 100644 index 0000000000..9b80a3a383 --- /dev/null +++ b/apps/nestjs-backend/src/features/cold-archive/read-batch.ts @@ -0,0 +1,22 @@ +const READ_BATCH_TARGET_BYTES = 8 * 1024 * 1024; +/** a single multi-MB row must stay readable one at a time */ +const READ_BATCH_MIN_ROWS = 1; + +/** + * First batch of a table probes the row weight before trusting the full cap. + * Kept small: a table can average 500KB/row (real on the ai fleet), so a large + * probe materializes hundreds of MB before the adaptive limit kicks in. + */ +export const READ_BATCH_PROBE_ROWS = 64; + +/** + * Rows for the next batch so ~READ_BATCH_TARGET_BYTES come back whatever the + * row weight: a row-count LIMIT alone lets one fat-JSON table materialize + * gigabytes in a single batch. `cap` stays the hard ceiling, so an operator who + * lowered readBatchSize to cut memory pressure keeps it. + */ +export const nextReadBatchLimit = (batchBytes: number, batchRows: number, cap: number): number => { + const avgRowBytes = Math.max(1, Math.ceil(batchBytes / Math.max(1, batchRows))); + const target = Math.floor(READ_BATCH_TARGET_BYTES / avgRowBytes); + return Math.min(cap, Math.max(READ_BATCH_MIN_ROWS, target)); +}; diff --git a/apps/nestjs-backend/src/features/field/open-api/field-open-api-v2.service.spec.ts b/apps/nestjs-backend/src/features/field/open-api/field-open-api-v2.service.spec.ts index 4eb64c9820..0d0ee7d32c 100644 --- a/apps/nestjs-backend/src/features/field/open-api/field-open-api-v2.service.spec.ts +++ b/apps/nestjs-backend/src/features/field/open-api/field-open-api-v2.service.spec.ts @@ -24,6 +24,18 @@ vi.mock('@teable/v2-contract-http-implementation/handlers', () => ({ executeUpdateRecordEndpoint, })); +vi.mock('@teable/v2-contract-http', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + mapFieldToDto: (field: unknown, primaryFieldId?: unknown) => { + const testDto = (field as { __testDto?: Record }).__testDto; + if (testDto) return { isErr: () => false, value: testDto }; + return original.mapFieldToDto(field as never, primaryFieldId as never); + }, + }; +}); + import { FieldOpenApiV2Service } from './field-open-api-v2.service'; type ITestFieldOpenApiV2Service = { @@ -222,25 +234,24 @@ describe('FieldOpenApiV2Service updateField', () => { body: { ok: true }, }); const commandBus = {}; - const tableQueryService = { - getById: vi.fn().mockResolvedValue({ - isErr: () => false, - value: {}, - }), + const domainField = { + id: () => ({ toString: () => fieldId }), + __testDto: fieldDto, }; - const tableMapper = { - toDTO: vi.fn().mockReturnValue({ + const queryBus = { + execute: vi.fn().mockResolvedValue({ isErr: () => false, value: { - fields: [fieldDto], + fields: [domainField], + primaryFieldId: undefined, + view: undefined, }, }), }; const container = { resolve: vi.fn((token: symbol) => { if (token === v2CoreTokens.commandBus) return commandBus; - if (token === v2CoreTokens.tableQueryService) return tableQueryService; - if (token === v2CoreTokens.tableMapper) return tableMapper; + if (token === v2CoreTokens.queryBus) return queryBus; throw new Error(`Unexpected token ${String(token)}`); }), }; @@ -1750,39 +1761,61 @@ describe('FieldOpenApiV2Service normalizeFieldVo', () => { expect(vo.options).toEqual({}); }); - it('extracts field vo directly from returned table dto and preserves lookup link metadata', async () => { - const service = createNormalizeService(); - const vo = await service.extractFieldVoFromTableDto( + it('reads a field through the v2 field list and preserves lookup link metadata', async () => { + const fieldDtos = [ { - fields: [ - { - id: 'fldLink000000000001', - name: 'Link', - type: 'link', - options: { - relationship: 'manyMany', - foreignTableId: 'tblForeign00000001', - fkHostTableName: 'bseBase.tblJunction', - selfKeyName: '__fk_self', - foreignKeyName: '__fk_foreign', - }, - }, - { - id: 'fldLookup000000001', - name: 'Lookup', - type: 'singleLineText', - isLookup: true, - lookupOptions: { - linkFieldId: 'fldLink000000000001', - foreignTableId: 'tblForeign00000001', - lookupFieldId: 'fldSource000000001', - }, - options: null, - }, - ], + id: 'fldLink000000000001', + name: 'Link', + type: 'link', + options: { + relationship: 'manyMany', + foreignTableId: 'tblForeign00000001', + fkHostTableName: 'bseBase.tblJunction', + selfKeyName: '__fk_self', + foreignKeyName: '__fk_foreign', + }, }, - 'fldLookup000000001' - ); + { + id: 'fldLookup000000001', + name: 'Lookup', + type: 'singleLineText', + isLookup: true, + lookupOptions: { + linkFieldId: 'fldLink000000000001', + foreignTableId: 'tblForeign00000001', + lookupFieldId: 'fldSource000000001', + }, + options: null, + }, + ]; + const queryBus = { + execute: vi.fn().mockResolvedValue({ + isErr: () => false, + value: { + fields: fieldDtos.map((dto) => ({ + id: () => ({ toString: () => dto.id }), + __testDto: dto, + })), + primaryFieldId: undefined, + view: undefined, + }, + }), + }; + const container = { resolve: vi.fn(() => queryBus) }; + const service = new FieldOpenApiV2Service( + { getContainerForTable: vi.fn().mockResolvedValue(container) } as never, + { createContext: vi.fn().mockResolvedValue({}) } as never, + {} as never, + {} as never, + {} as never, + createFieldSupplementService() as never, + {} as never + ) as unknown as ITestFieldOpenApiV2Service; + const vo = await ( + service as unknown as { + getFieldFromV2: (tableId: string, fieldId: string) => Promise; + } + ).getFieldFromV2('tbl3sYKYH4tDz0IEg91', 'fldLookup000000001'); expect(vo.lookupOptions).toMatchObject({ linkFieldId: 'fldLink000000000001', @@ -1837,11 +1870,6 @@ describe('FieldOpenApiV2Service createField', () => { name: 'Created Field', type: 'singleLineText', } as IFieldVo); - const extractFieldVoFromTableDto = vi.spyOn( - service as object, - 'extractFieldVoFromTableDto' as never - ); - const createdField = await service.createField('tbl3sYKYH4tDz0IEg91', { type: 'singleLineText', name: 'Created Field', @@ -1858,7 +1886,6 @@ describe('FieldOpenApiV2Service createField', () => { expect.stringMatching(/^fld/), { requestId: 'reqTestId' } ); - expect(extractFieldVoFromTableDto).not.toHaveBeenCalled(); }); it('falls back to v2 field read for lookup fields to preserve legacy response shape', async () => { diff --git a/apps/nestjs-backend/src/features/field/open-api/field-open-api-v2.service.ts b/apps/nestjs-backend/src/features/field/open-api/field-open-api-v2.service.ts index d7f029af92..f58bfce1c3 100644 --- a/apps/nestjs-backend/src/features/field/open-api/field-open-api-v2.service.ts +++ b/apps/nestjs-backend/src/features/field/open-api/field-open-api-v2.service.ts @@ -12,6 +12,7 @@ import { type IConvertFieldRo, type IFieldRo, type IFieldVo, + type IGetFieldsQuery, type IUpdateFieldRo, } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; @@ -38,12 +39,14 @@ import { FieldId, type ICommandBus, type IExecutionContext, + type IQueryBus, type ISpan, - type ITableMapper, type ITracer, extractLookupDisplayOptionsPatch, LinkFieldConfig, LinkRelationship, + ListFieldsQuery, + type ListFieldsResult, stripLookupFormulaExecutableOptions, TableId, type Table, @@ -55,10 +58,12 @@ import { instanceToPlain } from 'class-transformer'; import { ClsService } from 'nestjs-cls'; import { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exception'; import type { IClsStore } from '../../../types/cls'; +import { isNotHiddenField } from '../../../utils/is-not-hidden-field'; import type { IOpsMap } from '../../calculation/utils/compose-maps'; import { DataLoaderService } from '../../data-loader/data-loader.service'; import { V2ContainerService } from '../../v2/v2-container.service'; import { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory'; +import { throwV2Error } from '../../v2/v2-http-error'; import { FieldSupplementService } from '../field-calculate/field-supplement.service'; import { FieldOpenApiService } from './field-open-api.service'; @@ -69,10 +74,6 @@ type ConvertFieldExecutionOptions = { undoRedoMode?: 'undo' | 'redo' | 'normal'; }; -type ITableDtoWithFields = { - fields: ReadonlyArray>; -}; - type IPreparedLegacyCreateField = { v2Field: Record; hasAiConfig: boolean; @@ -219,22 +220,6 @@ export class FieldOpenApiV2Service { this.dataLoaderService.field.invalidateTables(ids); } - private throwV2Error( - error: { - code: string; - message: string; - tags?: ReadonlyArray; - details?: Readonly>; - }, - status: number - ): never { - throw new CustomHttpException(error.message, getDefaultCodeByStatus(status), { - domainCode: error.code, - domainTags: error.tags, - details: error.details, - }); - } - private normalizeFieldVo(field: unknown): IFieldVo { const vo = instanceToPlain(field, { excludePrefixes: ['_'] }) as IFieldVo; const raw = vo as Record; @@ -262,6 +247,28 @@ export class FieldOpenApiV2Service { } } + // Translate the flattened conditional-lookup DTO shape produced by + // mapFieldToDto ({ type: innerType, isLookup, conditionalLookupOptions }) + // to the v1 API format ({ isConditionalLookup, lookupOptions }). + if (raw.conditionalLookupOptions && typeof raw.conditionalLookupOptions === 'object') { + const conditionalOptions = raw.conditionalLookupOptions as Record; + const condition = conditionalOptions.condition as Record | undefined; + const lookupOptions: Record = {}; + if (conditionalOptions.foreignTableId != null) + lookupOptions.foreignTableId = conditionalOptions.foreignTableId; + if (conditionalOptions.lookupFieldId != null) + lookupOptions.lookupFieldId = conditionalOptions.lookupFieldId; + if (condition) { + if (condition.filter !== undefined) lookupOptions.filter = condition.filter; + if (condition.sort !== undefined) lookupOptions.sort = condition.sort; + if (condition.limit !== undefined) lookupOptions.limit = condition.limit; + } + vo.isLookup = true; + vo.isConditionalLookup = true; + raw.lookupOptions = lookupOptions; + delete raw.conditionalLookupOptions; + } + // Translate v2 conditionalLookup DTO to v1 API format. // v2 stores: { type: 'conditionalLookup', options: { foreignTableId, lookupFieldId, condition }, innerType, innerOptions } // v1 expects: { type: innerType, isLookup: true, isConditionalLookup: true, lookupOptions: { foreignTableId, lookupFieldId, filter, sort, limit }, options: innerOptions } @@ -487,37 +494,121 @@ export class FieldOpenApiV2Service { ); } - private async getFieldFromV2( + private async listDomainFields( tableId: string, - fieldId: string, + viewId?: string, context?: IExecutionContext - ): Promise { + ): Promise<{ result: ListFieldsResult; context: IExecutionContext }> { const container = await this.v2ContainerService.getContainerForTable(tableId); - const tableQueryService = container.resolve(v2CoreTokens.tableQueryService); - const tableMapper = container.resolve(v2CoreTokens.tableMapper); - const tableIdResult = TableId.create(tableId); - if (tableIdResult.isErr()) { - throw new HttpException('Invalid table id', HttpStatus.BAD_REQUEST); + const queryBus = container.resolve(v2CoreTokens.queryBus); + const queryContext = context ?? (await this.v2ContextFactory.createContext(container)); + const queryResult = ListFieldsQuery.create({ tableId, viewId }); + if (queryResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(queryResult.error), + mapDomainErrorToHttpStatus(queryResult.error) + ); } - const queryContext = context ?? (await this.v2ContextFactory.createContext(container)); - const tableResult = await tableQueryService.getById(queryContext, tableIdResult.value); - if (tableResult.isErr()) { - const errMsg = tableResult.error.message ?? 'Table not found'; - const isNotFound = - tableResult.error.code === 'table.not_found' || errMsg.includes('not found'); - throw new HttpException( - `v2 getFieldFromV2: ${errMsg}`, - isNotFound ? HttpStatus.NOT_FOUND : HttpStatus.INTERNAL_SERVER_ERROR + const result = await queryBus.execute( + queryContext, + queryResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) ); } + return { result: result.value, context: queryContext }; + } + + async getFields(tableId: string, query: IGetFieldsQuery = {}): Promise { + const { result, context } = await this.listDomainFields(tableId, query.viewId); + const fieldDtoById = new Map( + result.fields.map((field) => { + const dto = mapFieldToDto(field, result.primaryFieldId); + if (dto.isErr()) { + throw new HttpException(dto.error.message, HttpStatus.INTERNAL_SERVER_ERROR); + } + return [field.id().toString(), dto.value as Record] as const; + }) + ); + const fields = await Promise.all( + result.fields.map(async (field) => { + const vo = this.normalizeFieldVo(fieldDtoById.get(field.id().toString())); + this.enrichLookupLinkMetadata(vo, (linkFieldId) => fieldDtoById.get(linkFieldId)); + await this.hydrateLookupFieldVo(vo, context); + return vo; + }) + ); + + if (query.projection) { + const fieldById = new Map(fields.map((field) => [field.id, field] as const)); + return query.projection + .map((fieldId) => fieldById.get(fieldId)) + .filter((field): field is IFieldVo => field != null); + } - const tableDtoResult = tableMapper.toDTO(tableResult.value); - if (tableDtoResult.isErr()) { - throw new HttpException(tableDtoResult.error.message, HttpStatus.INTERNAL_SERVER_ERROR); + const view = result.view; + if (!view) return fields; + const columnMetaResult = view.columnMeta(); + if (columnMetaResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(columnMetaResult.error), + mapDomainErrorToHttpStatus(columnMetaResult.error) + ); } + const columnMeta = columnMetaResult.value.toDto(); + const viewProjection = { + type: view.type().toString(), + options: view.options(), + columnMeta, + } as Parameters[1]; + const visibleFields = query.filterHidden + ? fields.filter((field) => isNotHiddenField(field.id, viewProjection)) + : fields; + + return [...visibleFields].sort((left, right) => { + const leftOrder = columnMeta[left.id]?.order; + const rightOrder = columnMeta[right.id]?.order; + if (leftOrder == null && rightOrder == null) return 0; + if (leftOrder == null) return 1; + if (rightOrder == null) return -1; + return leftOrder - rightOrder; + }); + } - return this.extractFieldVoFromTableDto(tableDtoResult.value, fieldId, queryContext); + private async getFieldFromV2( + tableId: string, + fieldId: string, + context?: IExecutionContext + ): Promise { + const { result, context: queryContext } = await this.listDomainFields( + tableId, + undefined, + context + ); + const field = result.fields.find((candidate) => candidate.id().toString() === fieldId); + if (!field) { + throw new HttpException(`Field ${fieldId} not found`, HttpStatus.NOT_FOUND); + } + const fieldDtoById = new Map>(); + for (const candidate of result.fields) { + const dtoResult = mapFieldToDto(candidate, result.primaryFieldId); + if (dtoResult.isErr()) { + throw new HttpException(dtoResult.error.message, HttpStatus.INTERNAL_SERVER_ERROR); + } + fieldDtoById.set(candidate.id().toString(), dtoResult.value as Record); + } + const fieldDto = fieldDtoById.get(fieldId); + if (!fieldDto) { + throw new HttpException(`Field ${fieldId} not found`, HttpStatus.NOT_FOUND); + } + const vo = this.normalizeFieldVo(fieldDto); + this.enrichLookupLinkMetadata(vo, (linkFieldId) => fieldDtoById.get(linkFieldId)); + await this.hydrateLookupFieldVo(vo, queryContext); + return vo; } private mapDomainFieldToDto(table: Table, field: Field): Record { @@ -643,27 +734,6 @@ export class FieldOpenApiV2Service { } } - private async extractFieldVoFromTableDto( - tableDto: ITableDtoWithFields, - fieldId: string, - queryContext?: IExecutionContext - ): Promise { - const field = tableDto.fields.find((item) => item.id === fieldId); - if (!field) { - throw new HttpException(`Field ${fieldId} not found`, HttpStatus.NOT_FOUND); - } - - const vo = this.normalizeFieldVo(field); - - this.enrichLookupLinkMetadata(vo, (linkFieldId) => - tableDto.fields.find((f) => f.id === linkFieldId) - ); - - await this.hydrateLookupFieldVo(vo, queryContext); - - return vo; - } - private async extractFieldVoFromDomainTable( table: Table, fieldId: string, @@ -947,8 +1017,11 @@ export class FieldOpenApiV2Service { const cellValueType = raw.cellValueType; const isMultipleCellValue = raw.isMultipleCellValue; + // The v2 command validates the pair together — always forward both; + // a lone cellValueType is rejected with 'requires cellValueType and + // isMultipleCellValue'. if (typeof cellValueType === 'string' && typeof isMultipleCellValue === 'boolean') { - return isMultipleCellValue ? { cellValueType, isMultipleCellValue } : { cellValueType }; + return { cellValueType, isMultipleCellValue }; } return {}; } @@ -1421,7 +1494,7 @@ export class FieldOpenApiV2Service { ); if (commandResult.isErr()) { - this.throwV2Error( + throwV2Error( mapDomainErrorToHttpError(commandResult.error), mapDomainErrorToHttpStatus(commandResult.error) ); @@ -1437,7 +1510,7 @@ export class FieldOpenApiV2Service { ); if (result.isErr()) { - this.throwV2Error( + throwV2Error( mapDomainErrorToHttpError(result.error), mapDomainErrorToHttpStatus(result.error) ); @@ -1517,7 +1590,7 @@ export class FieldOpenApiV2Service { }); if (commandResult.isErr()) { - this.throwV2Error( + throwV2Error( mapDomainErrorToHttpError(commandResult.error), mapDomainErrorToHttpStatus(commandResult.error) ); @@ -1529,7 +1602,7 @@ export class FieldOpenApiV2Service { ); if (result.isErr()) { - this.throwV2Error( + throwV2Error( mapDomainErrorToHttpError(result.error), mapDomainErrorToHttpStatus(result.error) ); @@ -1645,7 +1718,7 @@ export class FieldOpenApiV2Service { if (!(duplicateResult.status === 200 && duplicateResult.body.ok)) { if (!duplicateResult.body.ok) { - this.throwV2Error(duplicateResult.body.error, duplicateResult.status); + throwV2Error(duplicateResult.body.error, duplicateResult.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); } @@ -1694,7 +1767,7 @@ export class FieldOpenApiV2Service { } if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + throwV2Error(result.body.error, result.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); @@ -1727,7 +1800,7 @@ export class FieldOpenApiV2Service { fieldIds, }); if (commandResult.isErr()) { - this.throwV2Error( + throwV2Error( { code: commandResult.error.code, message: commandResult.error.message, @@ -1740,7 +1813,7 @@ export class FieldOpenApiV2Service { const result = await commandBus.execute(context, commandResult.value); if (result.isErr()) { - this.throwV2Error( + throwV2Error( { code: result.error.code, message: result.error.message, @@ -1783,7 +1856,7 @@ export class FieldOpenApiV2Service { } if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + throwV2Error(result.body.error, result.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); @@ -1854,7 +1927,7 @@ export class FieldOpenApiV2Service { } if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + throwV2Error(result.body.error, result.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); @@ -1903,7 +1976,7 @@ export class FieldOpenApiV2Service { if (!(result.status === 200 && result.body.ok)) { if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + throwV2Error(result.body.error, result.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); } @@ -2025,7 +2098,13 @@ export class FieldOpenApiV2Service { ? { isMultipleCellValue: roRecord.isMultipleCellValue } : typeof currentIsMultipleCellValue === 'boolean' ? { isMultipleCellValue: currentIsMultipleCellValue } - : {}), + : typeof roRecord.cellValueType === 'string' || + (currentCellValueType && !shouldSkipFormulaStringFallback) + ? // The v1 vo omits isMultipleCellValue when false; the v2 command + // validates the result-type pair together, so make the default + // explicit whenever a cellValueType is forwarded. + { isMultipleCellValue: false } + : {}), options: { ...(lookupOpts && shouldUpdateCondition ? { diff --git a/apps/nestjs-backend/src/features/import/open-api/import-open-api-v2.service.ts b/apps/nestjs-backend/src/features/import/open-api/import-open-api-v2.service.ts index 41411ae987..92c6e41161 100644 --- a/apps/nestjs-backend/src/features/import/open-api/import-open-api-v2.service.ts +++ b/apps/nestjs-backend/src/features/import/open-api/import-open-api-v2.service.ts @@ -22,7 +22,7 @@ import { difference } from 'lodash'; import { ClsService } from 'nestjs-cls'; import { z } from 'zod'; import { BaseConfig, type IBaseConfig } from '../../../configs/base.config'; -import { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exception'; +import { CustomHttpException } from '../../../custom.exception'; import { Events } from '../../../event-emitter/events'; import type { IClsStore } from '../../../types/cls'; import { AuditScope } from '../../audit/audit-scope'; @@ -32,6 +32,7 @@ import { SpaceDataDbMigrationGuardService } from '../../space/space-data-db-migr import { TableOpenApiService } from '../../table/open-api/table-open-api.service'; import { V2ContainerService } from '../../v2/v2-container.service'; import { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory'; +import { throwV2Error } from '../../v2/v2-http-error'; /** * V2 Import Open API Service @@ -77,22 +78,6 @@ export class ImportOpenApiV2Service { return `http://localhost:${port}${trimmedUrl}`; } - private throwV2Error( - error: { - code: string; - message: string; - tags?: ReadonlyArray; - details?: Readonly>; - }, - status: number - ): never { - throw new CustomHttpException(error.message, getDefaultCodeByStatus(status), { - domainCode: error.code, - domainTags: error.tags, - details: error.details, - }); - } - /** * Create a new table from a CSV file using V2 architecture via CommandBus. * @@ -150,7 +135,7 @@ export class ImportOpenApiV2Service { maxRowCount: normalizedMaxRowCount, }); if (commandResult.isErr()) { - this.throwV2Error(commandResult.error, mapDomainErrorToHttpStatus(commandResult.error)); + throwV2Error(commandResult.error, mapDomainErrorToHttpStatus(commandResult.error)); } const result = await commandBus.execute( @@ -164,7 +149,7 @@ export class ImportOpenApiV2Service { status: 'failed', error: result.error.message, }); - this.throwV2Error(result.error, mapDomainErrorToHttpStatus(result.error)); + throwV2Error(result.error, mapDomainErrorToHttpStatus(result.error)); } const tableId = result.value.table.id().toString(); @@ -302,7 +287,7 @@ export class ImportOpenApiV2Service { ? HttpStatus.NOT_FOUND : HttpStatus.INTERNAL_SERVER_ERROR; - this.throwV2Error(result.error, status); + throwV2Error(result.error, status); } // No manual audit emit: ImportRecordsHandler publishes RecordsBatchCreated per batch. diff --git a/apps/nestjs-backend/src/features/oauth/oauth-server.service.spec.ts b/apps/nestjs-backend/src/features/oauth/oauth-server.service.spec.ts index 205857dd82..26968047ab 100644 --- a/apps/nestjs-backend/src/features/oauth/oauth-server.service.spec.ts +++ b/apps/nestjs-backend/src/features/oauth/oauth-server.service.spec.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable sonarjs/no-duplicate-string */ import { BadRequestException, UnauthorizedException } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; import { HttpErrorCode } from '@teable/core'; @@ -11,6 +10,7 @@ import { mockDeep } from 'vitest-mock-extended'; import { CacheService } from '../../cache/cache.service'; import { CustomHttpException } from '../../custom.exception'; import { GlobalModule } from '../../global/global.module'; +import { TeableJwtService } from '../auth/jwt/teable-jwt.service'; import { OAuthServerService } from './oauth-server.service'; import { OAuthModule } from './oauth.module'; @@ -18,7 +18,7 @@ describe('OAuthServerService', () => { let service: OAuthServerService; const prismaService = mockDeep(); const cacheService = mockDeep(); - const jwtService = mockDeep(); + const jwtService = mockDeep(); beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -28,7 +28,7 @@ describe('OAuthServerService', () => { .useValue(prismaService) .overrideProvider(CacheService) .useValue(cacheService) - .overrideProvider(JwtService) + .overrideProvider(TeableJwtService) .useValue(jwtService) .compile(); diff --git a/apps/nestjs-backend/src/features/oauth/oauth-server.service.ts b/apps/nestjs-backend/src/features/oauth/oauth-server.service.ts index b434de6974..8cdbcb2130 100644 --- a/apps/nestjs-backend/src/features/oauth/oauth-server.service.ts +++ b/apps/nestjs-backend/src/features/oauth/oauth-server.service.ts @@ -6,7 +6,6 @@ import { NotFoundException, UnauthorizedException, } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; import { getRandomString, HttpErrorCode, nullsToUndefined } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import type { DecisionInfoGetVo } from '@teable/openapi'; @@ -28,6 +27,7 @@ import { IOAuthConfig, OAuthConfig } from '../../configs/oauth.config'; import { CustomHttpException } from '../../custom.exception'; import { second } from '../../utils/second'; import { AccessTokenService } from '../access-token/access-token.service'; +import { TeableJwtService } from '../auth/jwt/teable-jwt.service'; import { OAuthTxStore } from './oauth-tx-store'; import { PkceService } from './pkce.service'; import type { IAuthorizeClient, ITokenClient, IOAuth2Server, IAuthorizeRequest } from './types'; @@ -41,7 +41,7 @@ export class OAuthServerService { private readonly prismaService: PrismaService, private readonly cacheService: CacheService, private readonly accessTokenService: AccessTokenService, - private readonly jwtService: JwtService, + private readonly jwtService: TeableJwtService, private readonly oauthTxStore: OAuthTxStore, private readonly pkceService: PkceService, @OAuthConfig() private readonly oauth2Config: IOAuthConfig diff --git a/apps/nestjs-backend/src/features/oauth/oauth.module.ts b/apps/nestjs-backend/src/features/oauth/oauth.module.ts index e11e78fcb0..0cfddd7fd2 100644 --- a/apps/nestjs-backend/src/features/oauth/oauth.module.ts +++ b/apps/nestjs-backend/src/features/oauth/oauth.module.ts @@ -1,7 +1,5 @@ import { Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; -import { authConfig, type IAuthConfig } from '../../configs/auth.config'; import { DistributedLockModule } from '../../distributed-lock'; import { AccessTokenModule } from '../access-token/access-token.module'; import { OAuthAppInitService } from './oauth-app-init.service'; @@ -15,20 +13,7 @@ import { OAuthClientStrategy } from './strategies/oauth2-client.strategies'; import { OAuthPkceClientStrategy } from './strategies/oauth2-pkce-client.strategy'; @Module({ - imports: [ - AccessTokenModule, - DistributedLockModule, - PassportModule.register({ session: true }), - JwtModule.registerAsync({ - useFactory: (config: IAuthConfig) => ({ - secret: config.jwt.secret, - signOptions: { - expiresIn: config.jwt.expiresIn, - }, - }), - inject: [authConfig.KEY], - }), - ], + imports: [AccessTokenModule, DistributedLockModule, PassportModule.register({ session: true })], controllers: [OAuthController, OAuthServerController], providers: [ OAuthServerService, diff --git a/apps/nestjs-backend/src/features/pin/pin.controller.ts b/apps/nestjs-backend/src/features/pin/pin.controller.ts index 8d51c3ffa4..7e11052751 100644 --- a/apps/nestjs-backend/src/features/pin/pin.controller.ts +++ b/apps/nestjs-backend/src/features/pin/pin.controller.ts @@ -1,5 +1,5 @@ import { Body, Controller, Delete, Get, Post, Put, Query } from '@nestjs/common'; -import type { IGetPinListVo } from '@teable/openapi'; +import type { IPinEntryMapVo, IGetPinListVo } from '@teable/openapi'; import { AddPinRo, DeletePinRo, @@ -30,6 +30,11 @@ export class PinController { return this.pinService.getList(); } + @Get('entry-map') + async getEntryMap(): Promise { + return this.pinService.getEntryMap(); + } + @Put('order') async updateOrder(@Body(new ZodValidationPipe(updatePinOrderRoSchema)) body: UpdatePinOrderRo) { return this.pinService.updateOrder(body); diff --git a/apps/nestjs-backend/src/features/pin/pin.module.ts b/apps/nestjs-backend/src/features/pin/pin.module.ts index 21ae6c991e..d7bc63b64b 100644 --- a/apps/nestjs-backend/src/features/pin/pin.module.ts +++ b/apps/nestjs-backend/src/features/pin/pin.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { LastVisitModule } from '../user/last-visit/last-visit.module'; import { PinController } from './pin.controller'; import { PinService } from './pin.service'; @Module({ + imports: [LastVisitModule], providers: [PinService], controllers: [PinController], }) diff --git a/apps/nestjs-backend/src/features/pin/pin.service.ts b/apps/nestjs-backend/src/features/pin/pin.service.ts index 1d63a2d13b..1565ceb272 100644 --- a/apps/nestjs-backend/src/features/pin/pin.service.ts +++ b/apps/nestjs-backend/src/features/pin/pin.service.ts @@ -3,7 +3,13 @@ import { Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { HttpErrorCode, nullsToUndefined, type ViewType } from '@teable/core'; import { Prisma, PrismaService } from '@teable/db-main-prisma'; -import type { IGetPinListVo, AddPinRo, DeletePinRo, UpdatePinOrderRo } from '@teable/openapi'; +import type { + IGetPinListVo, + IPinEntryMapVo, + AddPinRo, + DeletePinRo, + UpdatePinOrderRo, +} from '@teable/openapi'; import { PinType } from '@teable/openapi'; import { Knex } from 'knex'; import { keyBy } from 'lodash'; @@ -23,13 +29,15 @@ import { Events } from '../../event-emitter/events'; import type { IClsStore } from '../../types/cls'; import { updateOrder } from '../../utils/update-order'; import { getPublicFullStorageUrl } from '../attachments/plugins/utils'; +import { LastVisitService } from '../user/last-visit/last-visit.service'; @Injectable() export class PinService { constructor( private readonly prismaService: PrismaService, private readonly cls: ClsService, - @InjectModel('CUSTOM_KNEX') private readonly knex: Knex + @InjectModel('CUSTOM_KNEX') private readonly knex: Knex, + private readonly lastVisitService: LastVisitService ) {} private async getMaxOrder(where: Prisma.PinResourceWhereInput) { @@ -153,6 +161,39 @@ export class PinService { .filter(Boolean) as IGetPinListVo; } + /** + * Entry URL per pinned base (its last visited table/view, keyed by baseId) + * and pinned table (its last visited view, keyed by tableId), resolved + * purely from the user's own visit history — independent of getList so the + * pin list itself is never coupled to entry resolution. + */ + async getEntryMap(): Promise { + const userId = this.cls.get('user.id'); + const pins = await this.prismaService.pinResource.findMany({ + where: { + createdBy: userId, + type: { in: [PinType.Base, PinType.Table] }, + }, + select: { resourceId: true, type: true }, + }); + const baseIds = pins.filter((pin) => pin.type === PinType.Base).map((pin) => pin.resourceId); + const tableIds = pins.filter((pin) => pin.type === PinType.Table).map((pin) => pin.resourceId); + const tables = tableIds.length + ? await this.prismaService.tableMeta.findMany({ + where: { id: { in: tableIds }, deletedTime: null }, + select: { id: true, baseId: true }, + }) + : []; + const [baseEntryMap, tableEntryMap] = await Promise.all([ + this.lastVisitService.getBaseEntryMap(userId, baseIds), + this.lastVisitService.getTableEntryUrls( + userId, + tables.map((table) => ({ tableId: table.id, baseId: table.baseId })) + ), + ]); + return { ...baseEntryMap, ...tableEntryMap }; + } + private async fetchBases(ids?: string[]) { if (!ids?.length) return []; return this.prismaService.base.findMany({ diff --git a/apps/nestjs-backend/src/features/plugin/plugin-auth.service.spec.ts b/apps/nestjs-backend/src/features/plugin/plugin-auth.service.spec.ts index cd7284d7f1..a02b6c4336 100644 --- a/apps/nestjs-backend/src/features/plugin/plugin-auth.service.spec.ts +++ b/apps/nestjs-backend/src/features/plugin/plugin-auth.service.spec.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { JwtService } from '@nestjs/jwt'; import { HttpErrorCode } from '@teable/core'; import type { PrismaService } from '@teable/db-main-prisma'; import { PluginPosition, pluginGetTokenRoSchema, type IPluginGetTokenRo } from '@teable/openapi'; @@ -7,6 +6,7 @@ import type { ClsService } from 'nestjs-cls'; import type { CacheService } from '../../cache/cache.service'; import type { IClsStore } from '../../types/cls'; import type { AccessTokenService } from '../access-token/access-token.service'; +import type { TeableJwtService } from '../auth/jwt/teable-jwt.service'; import { PluginAuthService } from './plugin-auth.service'; describe('PluginAuthService', () => { @@ -45,7 +45,7 @@ describe('PluginAuthService', () => { set: setAuthCode, } as unknown as CacheService; const accessTokenService = {} as AccessTokenService; - const jwtService = { verifyAsync: verifyRefreshToken } as unknown as JwtService; + const jwtService = { verifyAsync: verifyRefreshToken } as unknown as TeableJwtService; const cls = { get: getCls } as unknown as ClsService; const pluginId = 'plgTest'; diff --git a/apps/nestjs-backend/src/features/plugin/plugin-auth.service.ts b/apps/nestjs-backend/src/features/plugin/plugin-auth.service.ts index c6f301fa64..69ff03abca 100644 --- a/apps/nestjs-backend/src/features/plugin/plugin-auth.service.ts +++ b/apps/nestjs-backend/src/features/plugin/plugin-auth.service.ts @@ -1,6 +1,5 @@ /* eslint-disable sonarjs/no-duplicate-string */ import { Injectable } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; import { getRandomString, HttpErrorCode } from '@teable/core'; import type { Prisma } from '@teable/db-main-prisma'; import { PrismaService } from '@teable/db-main-prisma'; @@ -20,6 +19,7 @@ import { CustomHttpException } from '../../custom.exception'; import type { IClsStore } from '../../types/cls'; import { second } from '../../utils/second'; import { AccessTokenService } from '../access-token/access-token.service'; +import { TeableJwtService } from '../auth/jwt/teable-jwt.service'; import { validateSecret } from './utils'; interface IRefreshTokenInput { @@ -43,7 +43,7 @@ export class PluginAuthService { private readonly prismaService: PrismaService, private readonly cacheService: CacheService, private readonly accessTokenService: AccessTokenService, - private readonly jwtService: JwtService, + private readonly jwtService: TeableJwtService, private readonly cls: ClsService ) {} diff --git a/apps/nestjs-backend/src/features/plugin/plugin.module.ts b/apps/nestjs-backend/src/features/plugin/plugin.module.ts index a3f03f10fb..fe124f24ba 100644 --- a/apps/nestjs-backend/src/features/plugin/plugin.module.ts +++ b/apps/nestjs-backend/src/features/plugin/plugin.module.ts @@ -1,6 +1,4 @@ import { Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; -import { authConfig, type IAuthConfig } from '../../configs/auth.config'; import { AccessTokenModule } from '../access-token/access-token.module'; import { StorageModule } from '../attachments/plugins/storage.module'; import { UserModule } from '../user/user.module'; @@ -10,20 +8,7 @@ import { PluginController } from './plugin.controller'; import { PluginService } from './plugin.service'; @Module({ - imports: [ - UserModule, - AccessTokenModule, - StorageModule, - JwtModule.registerAsync({ - useFactory: (config: IAuthConfig) => ({ - secret: config.jwt.secret, - signOptions: { - expiresIn: config.jwt.expiresIn, - }, - }), - inject: [authConfig.KEY], - }), - ], + imports: [UserModule, AccessTokenModule, StorageModule], providers: [PluginService, PluginAuthService, OfficialPluginInitService], controllers: [PluginController], }) diff --git a/apps/nestjs-backend/src/features/record-history-cold/bucket-merge-feeder.ts b/apps/nestjs-backend/src/features/record-history-cold/bucket-merge-feeder.ts index c5e5989d0b..a4b0527799 100644 --- a/apps/nestjs-backend/src/features/record-history-cold/bucket-merge-feeder.ts +++ b/apps/nestjs-backend/src/features/record-history-cold/bucket-merge-feeder.ts @@ -1,106 +1,29 @@ +import { ColdBucketMergeFeeder } from '../cold-archive/bucket-merge-feeder'; import type { SortMemoryBudget } from './external-sort'; -import { ExternalRowSorter } from './external-sort'; +import { HISTORY_ROW_CODEC } from './external-sort'; import type { IColdHistoryRow, IParsedPartKey, IPartStatsEntry } from './part-codec'; import { truncateColdRow } from './part-codec'; import type { PartWriter } from './part-writer'; import type { RecordHistoryColdStorageService } from './record-history-cold-storage.service'; -/** - * Feeds a bucket's PartWriter with the deduplicated union of the live buffer - * rows and the bucket's EXISTING cold parts, in byte order. - * - * Why a full external sort instead of a streaming merge: - * - a bucket can legitimately be flushed more than once with disjoint row - * sets (the daily run at the horizon boundary covers only part of a day), - * so existing parts must be folded back in — never clobbered; - * - NO input order can be trusted: the buffer stream follows the db - * collation, which orders mixed-case cuids differently than the byte - * comparator the part keys and read-path pruning use (a streaming merge - * under mismatched orders silently emits duplicates); - * - each existing part is read to EOF immediately (short-lived GET) — dozens - * of half-open download streams interleaved with uploads on one HTTP - * client deadlock it (observed on the big-table e2e run). - * - * Record-major buffer reads keep ALL of a table's bucket feeders live at - * once, so every feeder's in-memory run must charge the one shared - * SortMemoryBudget — a per-feeder cap alone made peak memory O(#buckets x - * run size) and OOM'd the 2026-07-08 cn drain. - */ -export class BucketMergeFeeder { - private readonly sorter: ExternalRowSorter; - private initialized = false; - /** rows folded back in from existing parts (not counted as flushed buffer rows) */ - mergedExistingRows = 0; - +export class BucketMergeFeeder extends ColdBucketMergeFeeder { constructor( - private readonly writer: PartWriter, - private readonly existingParts: IParsedPartKey[], - private readonly coldStorage: RecordHistoryColdStorageService, + writer: PartWriter, + existingParts: IParsedPartKey[], + coldStorage: RecordHistoryColdStorageService, sortBudget?: SortMemoryBudget, mergeFanIn?: number, - private readonly truncateValueUnits = 0 + truncateValueUnits = 0 ) { - this.sorter = new ExternalRowSorter(undefined, sortBudget, mergeFanIn); - } - - get bucket() { - return this.writer.bucket; - } - - get metrics() { - return this.writer.metrics; - } - - /** - * the pre-existing part keys this feeder folded into the rewrite — the only - * keys a heal pass may delete afterwards (a key that appeared concurrently - * belongs to another run and must survive) - */ - get consumedKeys(): Set { - return new Set(this.existingParts.map((part) => part.key)); - } - - async push(row: IColdHistoryRow): Promise { - await this.ensureInitialized(); - await this.sorter.add(row); - } - - async finish(): Promise { - try { - await this.ensureInitialized(); - await this.sorter.drainTo((row) => this.writer.add(row)); - return await this.writer.finish(); - } finally { - await this.sorter.cleanup(); - } - } - - /** - * release the sorter's budget charge, temp files and registry entry without - * emitting anything — for a table flush that dies after opening feeders but - * before their finish loop, whose feeders would otherwise stay charged - * against the run-wide budget (and stay evictable) for the rest of the run. - * Idempotent and safe to call whether or not finish() ran. - */ - async abort(): Promise { - await this.sorter.cleanup(); - } - - private async ensureInitialized(): Promise { - if (this.initialized) return; - this.initialized = true; - for (const part of this.existingParts) { - for await (const item of this.coldStorage.iterateRows(part.key)) { - if (!item.row) continue; - // existing parts predate the truncation, so heal them on read-back: - // the rewritten part carries the marker and the sorter never holds a - // multi-MB legacy value folded in from S3 - const row = this.truncateValueUnits - ? truncateColdRow(item.row, this.truncateValueUnits) - : item.row; - await this.sorter.add(row); - this.mergedExistingRows += 1; - } - } + super( + writer, + existingParts, + coldStorage, + HISTORY_ROW_CODEC, + sortBudget, + mergeFanIn, + // parts written before the cap still hold multi-MB values; heal on read-back + truncateValueUnits ? (row) => truncateColdRow(row, truncateValueUnits) : undefined + ); } } diff --git a/apps/nestjs-backend/src/features/record-history-cold/external-sort.ts b/apps/nestjs-backend/src/features/record-history-cold/external-sort.ts index cce97ee0d3..a087898a9a 100644 --- a/apps/nestjs-backend/src/features/record-history-cold/external-sort.ts +++ b/apps/nestjs-backend/src/features/record-history-cold/external-sort.ts @@ -1,36 +1,11 @@ -import { randomBytes } from 'node:crypto'; -import { createReadStream, createWriteStream } from 'node:fs'; -import { unlink } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { Readable } from 'node:stream'; -import { pipeline } from 'node:stream/promises'; -import { createGunzip, createGzip } from 'node:zlib'; +import type { IColdRowCodec, SortMemoryBudget } from '../cold-archive/external-sort'; +import { ColdRowSorter } from '../cold-archive/external-sort'; import type { IColdHistoryRow } from './part-codec'; -import { compareRowAsc, iterateNdjsonLines } from './part-codec'; +import { compareRowAsc } from './part-codec'; -/** rows per in-memory run before spilling to disk (secondary, count-based cap) */ -const DEFAULT_RUN_SIZE = 50_000; -/** - * default cap on run files opened at once during a merge. A single history row - * can be tens of MB (up to 15MB observed on the ai fleet), and the merge holds - * one decoded row per open reader plus that reader's line buffer, so an - * unbounded fan-in over a big bucket's runs OOM'd the 2026-07-08 drain. Above - * this the merge goes multi-pass. - */ -const DEFAULT_MERGE_FAN_IN = 16; -/** - * a merge must combine at least two runs per pass or the file count never - * shrinks and the multi-pass loop spins forever — clamp any smaller - * configured value (env allows 1) up to this floor - */ -const MIN_MERGE_FAN_IN = 2; +export { SortMemoryBudget } from '../cold-archive/external-sort'; -/** - * approximate serialized bytes of a row — the budgeting unit for sort runs - * and read batches; actual JS heap cost is ~2-3x this (UTF-16 strings plus - * object headers) - */ +/** the budgeting unit for sort runs and read batches */ export const approxColdRowBytes = (row: IColdHistoryRow): number => 64 + row.id.length + @@ -41,389 +16,14 @@ export const approxColdRowBytes = (row: IColdHistoryRow): number => row.createdTime.length + row.createdBy.length; -/** - * Shared cap on the bytes ALL live sorters may hold in memory together. - * - * A table flush opens one sorter per bucket, and record-major buffer reads - * keep every bucket of the table live at once — so a per-sorter cap alone - * puts peak memory at O(#buckets x run size). On the 2026-07-08 cn drain a - * 21-month table (x4 table concurrency) multiplied that into 2-3GB of heap - * and a V8 OOM at the 2304MB default cap. Charging every add against one - * run-wide budget and evicting the largest run restores a constant bound no - * matter how many buckets or tables are in flight. - * - * The budget stays charged for an evicted run until its spill WRITE lands (not - * merely until the rows leave the in-memory array): the rows are still - * referenced by the in-flight gzip write, so releasing early let a fast - * large-row producer race ahead of the disk and pile up in-flight writes. - * enforce() therefore also waits on in-flight spills when nothing is - * evictable, which is the backpressure that bounds total memory. - */ -export class SortMemoryBudget { - private used = 0; - private readonly sorters = new Set(); - private readonly inflight = new Set>(); +export const HISTORY_ROW_CODEC: IColdRowCodec = { + compare: compareRowAsc, + sizeOf: approxColdRowBytes, + tmpPrefix: 'rh-cold', +}; - constructor(private readonly maxBytes: number) {} - - get usedBytes(): number { - return this.used; - } - - register(sorter: ExternalRowSorter): void { - this.sorters.add(sorter); - } - - /** stop offering this sorter's run for eviction (bytes stay charged until released) */ - unregister(sorter: ExternalRowSorter): void { - this.sorters.delete(sorter); - } - - charge(bytes: number): void { - this.used += bytes; - } - - release(bytes: number): void { - this.used = Math.max(0, this.used - bytes); - } - - /** track a spill write so enforce() can wait on it; auto-removed on settle */ - trackInflight(write: Promise): void { - this.inflight.add(write); - const drop = (): void => { - this.inflight.delete(write); - }; - write.then(drop, drop); - } - - /** evict the largest live run(s) until the total fits the budget again */ - async enforce(): Promise { - while (this.used > this.maxBytes) { - let largest: ExternalRowSorter | undefined; - for (const sorter of this.sorters) { - if (!largest || sorter.pendingBytes > largest.pendingBytes) largest = sorter; - } - if (largest && largest.pendingBytes > 0) { - try { - await largest.evict(); - } catch { - // a cross-table eviction failure is NOT this caller's error: the - // evicted sorter recorded it and its own table fails loudly at the - // next add()/drainTo() instead of deleting rows it never wrote. - // The swap already freed the memory, so the loop still progresses. - } - continue; - } - // nothing evictable in memory: the overage is all in-flight spill - // writes. Wait for one to land (releasing its bytes) before letting the - // caller add more — this is the backpressure that stops a large-row - // firehose from outrunning the disk and piling up in-flight writes. - if (this.inflight.size > 0) { - await Promise.race([...this.inflight].map((write) => write.catch(() => undefined))); - continue; - } - // nothing evictable, nothing in flight: the remainder is pinned by - // sorters mid-drain (released at cleanup). Overshoot bounded by one - // run; do not spin. - return; - } - } -} - -/** - * Disk-backed sort + dedup for bucket rewrites. - * - * Nothing about the inputs' order can be trusted: the buffer stream follows - * the db collation (mixed-case cuids order differently than bytes), and - * legacy parts may carry that order too. Rows are collected into in-memory - * runs, each run sorted with the byte comparator and spilled to a gzipped - * temp file, and a bounded-fan-in k-way merge (with adjacent row-id dedup) - * emits one clean byte-ordered stream — the only order the part keys and the - * read-path pruning understand. - * - * A run spills at DEFAULT_RUN_SIZE rows, or earlier when the shared - * SortMemoryBudget evicts it — the count alone bounds one sorter, only the - * budget bounds all of them together. - */ -export class ExternalRowSorter { - private run: IColdHistoryRow[] = []; - private runBytes = 0; - private runFiles: string[] = []; - private rowsAdded = 0; - /** spill writes still in flight (budget evictions the owner never awaits) */ - private readonly pendingSpills = new Set>(); - /** first spill failure; every later add()/drainTo() rethrows it */ - private spillError: unknown; - private draining = false; - - private readonly mergeFanIn: number; - - constructor( - private readonly runSize = DEFAULT_RUN_SIZE, - private readonly budget?: SortMemoryBudget, - mergeFanIn = DEFAULT_MERGE_FAN_IN - ) { - // fan-in of 1 would loop forever (a pass of 1->1 never shrinks the count) - this.mergeFanIn = Math.max(MIN_MERGE_FAN_IN, mergeFanIn); - budget?.register(this); - } - - get added(): number { - return this.rowsAdded; - } - - /** bytes currently held by the in-memory run (the budget's eviction key) */ - get pendingBytes(): number { - return this.runBytes; - } - - async add(row: IColdHistoryRow): Promise { - // fail fast: a failed spill means rows this sorter accepted are gone, - // so its output is incomplete — the owning table must error out (and - // skip its buffer delete), not keep feeding a sorter that cannot deliver - if (this.spillError) throw this.spillError; - const bytes = approxColdRowBytes(row); - this.run.push(row); - this.rowsAdded += 1; - this.runBytes += bytes; - this.budget?.charge(bytes); - if (this.run.length >= this.runSize) { - await this.spill(); - return; - } - await this.budget?.enforce(); - } - - /** merge all runs in byte order, deduped by row id, into `emit` */ - async drainTo(emit: (row: IColdHistoryRow) => Promise): Promise { - try { - // from here on the output set is frozen: no eviction may touch this - // sorter again (draining gate + unregister), and every in-flight - // eviction write must land in runFiles — or fail loudly — BEFORE we - // choose between the in-memory and merge paths. Skipping the settle - // would let a budget eviction racing this drain leave its rows in a - // file the merge never sees, and the caller would then delete buffer - // rows that were never written to a part. - this.draining = true; - this.budget?.unregister(this); - await this.settleSpills(); - if (this.runFiles.length === 0) { - await this.drainInMemory(emit); - return; - } - await this.spill(); - await this.mergeSpilledRuns(emit); - } finally { - await this.cleanup(); - } - } - - /** common case: everything fit in one in-memory run */ - private async drainInMemory(emit: (row: IColdHistoryRow) => Promise): Promise { - this.run.sort(compareRowAsc); - let lastId: string | undefined; - for (const row of this.run) { - if (row.id === lastId) continue; - lastId = row.id; - await emit(row); - } - this.run = []; - } - - /** - * Multi-pass k-way merge that never opens more than mergeFanIn readers at - * once. Each pass merges groups of up-to-K run files into one deduped run, - * deleting inputs as it goes, until a final group of <=K remains to stream - * into `emit`. this.runFiles always lists the live temp files so cleanup() - * unlinks them on any throw. - */ - private async mergeSpilledRuns(emit: (row: IColdHistoryRow) => Promise): Promise { - while (this.runFiles.length > this.mergeFanIn) { - const inputs = this.runFiles; - const outputs: string[] = []; - for (let i = 0; i < inputs.length; i += this.mergeFanIn) { - const group = inputs.slice(i, i + this.mergeFanIn); - const merged = await this.mergeGroupToFile(group); - outputs.push(merged); - // keep both the untouched inputs and the new outputs tracked so a - // throw mid-pass still cleans every temp file up - this.runFiles = [...inputs, ...outputs]; - } - for (const file of inputs) { - await unlink(file).catch(() => undefined); - } - this.runFiles = outputs; - } - for await (const row of this.mergeFiles(this.runFiles)) { - await emit(row); - } - } - - /** k-way merge a group of run files into a fresh gzipped run file (deduped) */ - private async mergeGroupToFile(files: string[]): Promise { - const file = join( - tmpdir(), - `rh-cold-merge-${process.pid}-${randomBytes(6).toString('hex')}.ndjson.gz` - ); - try { - await pipeline( - Readable.from(this.mergeFilesToLines(files)), - createGzip({ level: 1 }), - createWriteStream(file) - ); - } catch (error) { - this.spillError ??= error; - await unlink(file).catch(() => undefined); - throw error; - } - return file; - } - - private async *mergeFilesToLines(files: string[]): AsyncGenerator { - for await (const row of this.mergeFiles(files)) { - yield `${JSON.stringify(row)}\n`; - } - } - - /** - * k-way merge the given run files into one byte-ordered, id-deduped stream. - * Opens exactly files.length readers, so callers must keep that <= fan-in. - */ - private async *mergeFiles(files: string[]): AsyncGenerator { - const heads: IMergeHead[] = []; - try { - for (const file of files) { - const iterator = readRunRows(file); - const first = await iterator.next(); - if (!first.done) heads.push({ row: first.value, iterator }); - else await iterator.return?.(undefined); - } - let lastId: string | undefined; - while (heads.length > 0) { - const minIndex = pickMinRow(heads); - const head = heads[minIndex]; - if (head.row.id !== lastId) { - lastId = head.row.id; - yield head.row; - } - const next = await head.iterator.next(); - if (next.done) heads.splice(minIndex, 1); - else head.row = next.value; - } - } finally { - // early return / throw: close any readers still open so their file - // handles and decompressor buffers are released promptly - for (const head of heads) { - await head.iterator.return?.(undefined).catch(() => undefined); - } - } - } - - async cleanup(): Promise { - // let in-flight spill writes land first so their files are unlinked - // below instead of leaking into tmpdir after runFiles was cleared - await Promise.allSettled([...this.pendingSpills]); - this.budget?.release(this.runBytes); - this.runBytes = 0; - this.run = []; - this.budget?.unregister(this); - for (const file of this.runFiles) { - await unlink(file).catch(() => undefined); - } - this.runFiles = []; - } - - /** - * eviction entry point for the shared budget — a no-op once the owner - * started draining: the drain froze the output set, and an eviction picked - * from the registry moments before the unregister must not swap rows out - * from under the emitter - */ - async evict(): Promise { - if (this.draining) return; - await this.spill(); - } - - /** - * sort + write the current run to a gzipped temp file. The swap happens - * BEFORE any await: a budget sweep may spill this sorter while its owner is - * between adds, and a row pushed during the file write must open the next - * run — landing inside a file whose contents were already sorted would - * silently break the merge order. The budget charge is released only when - * the write LANDS (the rows stay referenced by the in-flight write until - * then), so a large-row producer cannot race ahead of the disk. - */ - async spill(): Promise { - if (this.run.length === 0) return; - const rows = this.run; - const bytes = this.runBytes; - this.run = []; - this.runBytes = 0; - const tracked: Promise = this.writeRun(rows).finally(() => { - this.pendingSpills.delete(tracked); - this.budget?.release(bytes); - }); - this.pendingSpills.add(tracked); - this.budget?.trackInflight(tracked); - await tracked; - } - - /** every in-flight spill has landed (or the first failure is rethrown) */ - private async settleSpills(): Promise { - await Promise.allSettled([...this.pendingSpills]); - if (this.spillError) throw this.spillError; - } - - private async writeRun(rows: IColdHistoryRow[]): Promise { - rows.sort(compareRowAsc); - const file = join( - tmpdir(), - `rh-cold-run-${process.pid}-${randomBytes(6).toString('hex')}.ndjson.gz` - ); - try { - // gzip level 1: ~4-6x on this JSON for a few % CPU — the budget makes - // runs smaller and more numerous, this keeps their disk footprint (and - // spill I/O) below what the uncompressed big runs used to cost - await pipeline( - Readable.from(serializeRunRows(rows)), - createGzip({ level: 1 }), - createWriteStream(file) - ); - } catch (error) { - this.spillError ??= error; - await unlink(file).catch(() => undefined); - throw error; - } - this.runFiles.push(file); - } -} - -interface IMergeHead { - row: IColdHistoryRow; - iterator: AsyncGenerator; -} - -/** index of the byte-smallest head row across the open run readers */ -function pickMinRow(heads: IMergeHead[]): number { - let minIndex = 0; - for (let i = 1; i < heads.length; i++) { - if (compareRowAsc(heads[i].row, heads[minIndex].row) < 0) minIndex = i; - } - return minIndex; -} - -function* serializeRunRows(rows: IColdHistoryRow[]): Generator { - for (const row of rows) { - yield `${JSON.stringify(row)}\n`; - } -} - -async function* readRunRows(file: string): AsyncGenerator { - const stream = createReadStream(file).pipe(createGunzip()); - // iterateNdjsonLines avoids readline's regex/ConsString flatten on huge - // lines (a single 15MB history row is one line) and destroys the stream - // on early return - for await (const line of iterateNdjsonLines(stream)) { - yield JSON.parse(line) as IColdHistoryRow; +export class ExternalRowSorter extends ColdRowSorter { + constructor(runSize?: number, budget?: SortMemoryBudget, mergeFanIn?: number) { + super(HISTORY_ROW_CODEC, runSize, budget, mergeFanIn); } } diff --git a/apps/nestjs-backend/src/features/record-history-cold/part-codec.ts b/apps/nestjs-backend/src/features/record-history-cold/part-codec.ts index 7f5413a592..e1b8613fac 100644 --- a/apps/nestjs-backend/src/features/record-history-cold/part-codec.ts +++ b/apps/nestjs-backend/src/features/record-history-cold/part-codec.ts @@ -1,6 +1,9 @@ -import { createHash } from 'node:crypto'; import type { Readable } from 'node:stream'; -import * as zlib from 'node:zlib'; +import type { IRecordBloom } from '../cold-archive/bloom'; +import type { IPartBucket } from '../cold-archive/bucket'; +import { padSeq } from '../cold-archive/bucket'; +import { createPartCompressorFor, partFileSuffixFor } from '../cold-archive/compression'; +import { decodePartRows } from '../cold-archive/part-line'; /** * Cold-part layout (see record-history-cold-storage-plan.md): @@ -14,6 +17,13 @@ import * as zlib from 'node:zlib'; * (recordId, createdTime, id). */ +export { iterateNdjsonLines } from '../cold-archive/ndjson'; +export { bloomMightContain, buildRecordBloom } from '../cold-archive/bloom'; +export { bucketId, bucketOfDate } from '../cold-archive/bucket'; +export type { IPartBucket } from '../cold-archive/bucket'; +export { createRowHasher, serializeFooter } from '../cold-archive/part-line'; +export type { IPartFooter } from '../cold-archive/part-line'; + export const RECORD_HISTORY_COLD_VERSION = 'v1'; export interface IColdHistoryRow { @@ -29,13 +39,6 @@ export interface IColdHistoryRow { createdBy: string; } -export interface IPartBucket { - yyyymm: string; - kind: 'day' | 'month'; - /** two digit day, only for kind=day */ - dd?: string; -} - export interface IPartHeader { t: 'h'; v: 1; @@ -43,12 +46,6 @@ export interface IPartHeader { bucket: IPartBucket; } -export interface IPartFooter { - t: 'f'; - rows: number; - sha256: string; -} - export interface IParsedPartKey extends IPartBucket { tableId: string; seq: number; @@ -57,15 +54,6 @@ export interface IParsedPartKey extends IPartBucket { key: string; } -export interface IRecordBloom { - /** bit count */ - m: number; - /** hash count */ - k: number; - /** base64 bit array */ - b64: string; -} - export interface IPartStatsEntry { key: string; rows: number; @@ -96,45 +84,11 @@ export interface ITableColdStats { */ export const STATS_SET_CAP = 500; -const zlibWithZstd = zlib as typeof zlib & { - createZstdCompress?: (options?: unknown) => zlib.Gzip; - createZstdDecompress?: (options?: unknown) => zlib.Gunzip; -}; - -export const hasZstd = typeof zlibWithZstd.createZstdCompress === 'function'; - -/** - * Writing prefers zstd when the runtime has it (node >= 22.15). Reading - * always handles both formats, but a `.zst` KEY needs a zstd-capable reader — - * on a fleet with mixed node versions (engines allow >= 22.0), force gzip - * with BACKEND_RECORD_HISTORY_COLD_COMPRESSION=gzip so every process can - * read freshly written parts. Checked per call: env files may load after - * module evaluation. - */ -const writeZstd = () => hasZstd && process.env.BACKEND_RECORD_HISTORY_COLD_COMPRESSION !== 'gzip'; - -export const partFileSuffix = () => (writeZstd() ? '.ndjson.zst' : '.ndjson.gz'); +const COLD_COMPRESSION_ENV = 'BACKEND_RECORD_HISTORY_COLD_COMPRESSION'; -export const createPartCompressor = () => { - if (writeZstd()) { - return zlibWithZstd.createZstdCompress!({ - params: { - [zlib.constants.ZSTD_c_compressionLevel]: 3, - }, - }); - } - return zlib.createGzip({ level: 6 }); -}; +export const partFileSuffix = () => partFileSuffixFor(COLD_COMPRESSION_ENV); -export const createPartDecompressor = (key: string) => { - if (key.endsWith('.zst')) { - if (!hasZstd) { - throw new Error(`cannot decompress ${key}: node runtime lacks zstd support`); - } - return zlibWithZstd.createZstdDecompress!(); - } - return zlib.createGunzip(); -}; +export const createPartCompressor = () => createPartCompressorFor(COLD_COMPRESSION_ENV); export const coldRootDir = (rootDir: string) => `${rootDir}/${RECORD_HISTORY_COLD_VERSION}`; @@ -147,8 +101,6 @@ export const monthPrefix = (rootDir: string, tableId: string, yyyymm: string) => export const statsKey = (rootDir: string, tableId: string) => `${tablePrefix(rootDir, tableId)}_stats.json`; -const padSeq = (seq: number) => String(seq).padStart(4, '0'); - export const buildPartKey = ( rootDir: string, tableId: string, @@ -194,15 +146,6 @@ export const parsePartKey = (rootDir: string, key: string): IParsedPartKey | und }; }; -export const bucketOfDate = (date: Date, kind: 'day' | 'month'): IPartBucket => { - const yyyymm = `${date.getUTCFullYear()}${String(date.getUTCMonth() + 1).padStart(2, '0')}`; - if (kind === 'month') return { yyyymm, kind }; - return { yyyymm, kind, dd: String(date.getUTCDate()).padStart(2, '0') }; -}; - -export const bucketId = (bucket: IPartBucket) => - bucket.kind === 'month' ? `${bucket.yyyymm}/m` : `${bucket.yyyymm}/${bucket.dd}`; - export const serializeHeader = (tableId: string, bucket: IPartBucket): string => JSON.stringify({ t: 'h', v: 1, tableId, bucket } satisfies IPartHeader); @@ -241,119 +184,8 @@ export const truncateColdRow = (row: IColdHistoryRow, maxUnits: number): IColdHi }; }; -export const serializeFooter = (rows: number, sha256: string): string => - JSON.stringify({ t: 'f', rows, sha256 } satisfies IPartFooter); - -export const createRowHasher = () => { - const hash = createHash('sha256'); - return { - update(rowLine: string) { - hash.update(rowLine); - hash.update('\n'); - }, - digest() { - return hash.digest('hex'); - }, - }; -}; - -export interface IParsedPartLine { - header?: IPartHeader; - footer?: IPartFooter; - row?: IColdHistoryRow; - raw: string; -} - -export const parsePartLine = (line: string): IParsedPartLine | undefined => { - if (!line) return undefined; - const value = JSON.parse(line) as { t?: string }; - if (value.t === 'h') return { header: value as IPartHeader, raw: line }; - if (value.t === 'f') return { footer: value as IPartFooter, raw: line }; - return { row: value as unknown as IColdHistoryRow, raw: line }; -}; - -const NEWLINE = 0x0a; - -/** - * Split a byte stream into NDJSON line strings WITHOUT node:readline. - * - * readline flattens its growing internal ConsString and runs a line-ending - * regex on every chunk, so a single multi-megabyte line (a history row whose - * before/after JSON is tens of MB — real on the ai fleet, up to 15MB) becomes - * an O(n^2) rope-flatten storm that OOM'd the 2026-07-08 cold drain - * (RegExpImpl::IrregexpExec / String::SlowFlatten at the top of the abort - * stack). Here partial-line chunks accumulate in an array and concatenate - * exactly once, when the newline arrives — O(total bytes), one allocation per - * line, no regex. - */ -export async function* iterateNdjsonLines(stream: Readable): AsyncGenerator { - const pending: Buffer[] = []; - let pendingLen = 0; - try { - for await (const chunk of stream as AsyncIterable) { - let start = 0; - let nl = chunk.indexOf(NEWLINE, start); - while (nl !== -1) { - const slice = chunk.subarray(start, nl); - let line: Buffer; - if (pendingLen > 0) { - pending.push(slice); - line = Buffer.concat(pending, pendingLen + slice.length); - pending.length = 0; - pendingLen = 0; - } else { - line = slice; - } - if (line.length > 0) yield line.toString('utf8'); - start = nl + 1; - nl = chunk.indexOf(NEWLINE, start); - } - if (start < chunk.length) { - // copy: the source buffer may be recycled before the next iteration - const rest = Buffer.from(chunk.subarray(start)); - pending.push(rest); - pendingLen += rest.length; - } - } - if (pendingLen > 0) { - const line = Buffer.concat(pending, pendingLen).toString('utf8'); - if (line.length > 0) yield line; - } - } finally { - stream.destroy(); - } -} - -/** - * Stream-decode a compressed part into rows. Memory stays O(line): download - * stream → decompressor → NDJSON line splitter. The caller may stop early by - * breaking out of the async iterator. - */ -export async function* iteratePartRows( - key: string, - compressed: Readable -): AsyncGenerator<{ row?: IColdHistoryRow; footer?: IPartFooter; rowLine?: string }> { - const decompressor = createPartDecompressor(key); - // decode failures must name the part; a bare zlib error is undebuggable - decompressor.on('error', (error: Error & { partKey?: string }) => { - error.partKey = key; - error.message = `${error.message} (part ${key})`; - }); - try { - for await (const line of iterateNdjsonLines(compressed.pipe(decompressor))) { - const parsed = parsePartLine(line); - if (!parsed) continue; - if (parsed.header) continue; - if (parsed.footer) { - yield { footer: parsed.footer }; - continue; - } - yield { row: parsed.row, rowLine: parsed.raw }; - } - } finally { - compressed.destroy(); - } -} +export const iteratePartRows = (key: string, compressed: Readable) => + decodePartRows(key, compressed); export const compareRowAsc = ( a: Pick, @@ -365,58 +197,6 @@ export const compareRowAsc = ( return 0; }; -/* ------------------------------------------------------------------ * - * record-id bloom filter (double hashing, ~1% target false positives) * - * ------------------------------------------------------------------ */ - -const BLOOM_BITS_PER_ELEMENT = 10; // ≈0.8% fpr with k=7 -const BLOOM_HASHES = 7; -const BLOOM_MIN_BITS = 64; - -const fnv1a = (value: string, seed: number): number => { - let hash = (0x811c9dc5 ^ seed) >>> 0; - for (let i = 0; i < value.length; i++) { - hash ^= value.charCodeAt(i); - hash = Math.imul(hash, 0x01000193) >>> 0; - } - return hash >>> 0; -}; - -const bloomBitPositions = (value: string, m: number, k: number): number[] => { - const h1 = fnv1a(value, 0); - // odd step so all bits stay reachable; `| 1` alone would coerce to a SIGNED - // 32-bit int (negative for hashes ≥ 2^31), making the modulo negative and - // the buffer write a silent out-of-range no-op — a false-negative factory - const h2 = (fnv1a(value, 0x9e3779b9) | 1) >>> 0; - const positions: number[] = []; - for (let i = 0; i < k; i++) { - // both operands are non-negative and well under 2^53, so % stays in [0, m) - positions.push((h1 + i * h2) % m); - } - return positions; -}; - -/** build a bloom over the part's distinct record ids */ -export const buildRecordBloom = (recordIds: Iterable, count: number): IRecordBloom => { - const m = Math.max(BLOOM_MIN_BITS, Math.ceil(count * BLOOM_BITS_PER_ELEMENT)); - const bytes = Buffer.alloc(Math.ceil(m / 8)); - for (const recordId of recordIds) { - for (const position of bloomBitPositions(recordId, m, BLOOM_HASHES)) { - bytes[position >> 3] |= 1 << (position & 7); - } - } - return { m, k: BLOOM_HASHES, b64: bytes.toString('base64') }; -}; - -/** false only when the record is DEFINITELY absent — safe to prune on false */ -export const bloomMightContain = (bloom: IRecordBloom, recordId: string): boolean => { - const bytes = Buffer.from(bloom.b64, 'base64'); - for (const position of bloomBitPositions(recordId, bloom.m, bloom.k)) { - if ((bytes[position >> 3] & (1 << (position & 7))) === 0) return false; - } - return true; -}; - /** descending (createdTime, id) — the merged read order of record history */ export const compareRowByTimeDesc = ( a: Pick, diff --git a/apps/nestjs-backend/src/features/record-history-cold/record-history-cold-storage.service.ts b/apps/nestjs-backend/src/features/record-history-cold/record-history-cold-storage.service.ts index 0bac1e9d43..c0979b2e1b 100644 --- a/apps/nestjs-backend/src/features/record-history-cold/record-history-cold-storage.service.ts +++ b/apps/nestjs-backend/src/features/record-history-cold/record-history-cold-storage.service.ts @@ -3,6 +3,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { UploadType } from '@teable/openapi'; import StorageAdapter from '../attachments/plugins/adapter'; import { InjectStorageAdapter } from '../attachments/plugins/storage'; +import { ColdPartByteCache } from '../cold-archive/part-byte-cache'; import type { IColdHistoryRow, IParsedPartKey, IPartFooter, ITableColdStats } from './part-codec'; import { coldRootDir, @@ -14,11 +15,7 @@ import { } from './part-codec'; import type { IPartStore } from './part-writer'; -const PART_CACHE_MAX_TOTAL_BYTES = 64 * 1024 * 1024; -const PART_CACHE_MAX_ENTRY_BYTES = 16 * 1024 * 1024; - -/** thrown when a part download outlives the caller's read deadline */ -export class ColdReadDeadlineError extends Error {} +export { ColdReadDeadlineError } from '../cold-archive/part-byte-cache'; /** * Storage facade for record-history cold parts on the private bucket: @@ -34,8 +31,9 @@ export class ColdReadDeadlineError extends Error {} @Injectable() export class RecordHistoryColdStorageService { private readonly logger = new Logger(RecordHistoryColdStorageService.name); - private readonly partCache = new Map(); - private partCacheBytes = 0; + private readonly partCache = new ColdPartByteCache((key) => + this.storageAdapter.downloadFile(this.bucket, key) + ); constructor(@InjectStorageAdapter() private readonly storageAdapter: StorageAdapter) {} @@ -171,62 +169,7 @@ export class RecordHistoryColdStorageService { version: { etag?: string; size?: number }, deadline?: number ): AsyncGenerator<{ row?: IColdHistoryRow; footer?: IPartFooter; rowLine?: string }> { - if (!version.etag || (version.size ?? Infinity) > PART_CACHE_MAX_ENTRY_BYTES) { - // uncacheable (no version, or over the entry cap) — still honor the - // deadline via a transient buffer; only a deadline-less caller (write - // paths) streams straight through - if (deadline !== undefined) { - yield* iteratePartRows(key, Readable.from(await this.downloadWithDeadline(key, deadline))); - } else { - yield* this.iterateRows(key); - } - return; - } - const cacheKey = `${key}@${version.etag}`; - const cached = this.partCache.get(cacheKey); - if (cached) { - // refresh LRU position - this.partCache.delete(cacheKey); - this.partCache.set(cacheKey, cached); - yield* iteratePartRows(key, Readable.from(cached)); - return; - } - const buffer = await this.downloadWithDeadline(key, deadline); - this.cachePart(cacheKey, buffer); - yield* iteratePartRows(key, Readable.from(buffer)); - } - - private async downloadWithDeadline(key: string, deadline?: number): Promise { - const stream = await this.storageAdapter.downloadFile(this.bucket, key); - const chunks: Buffer[] = []; - for await (const chunk of stream) { - if (deadline !== undefined && Date.now() > deadline) { - stream.destroy(); - throw new ColdReadDeadlineError(`download of ${key} exceeded the cold read budget`); - } - chunks.push(chunk as Buffer); - } - return Buffer.concat(chunks); - } - - private cachePart(cacheKey: string, buffer: Buffer) { - if (buffer.length > PART_CACHE_MAX_ENTRY_BYTES) return; - // two requests can miss the same key concurrently and both land here; - // replacing without reclaiming the first entry's bytes would inflate - // the counter with phantom bytes and evict the rest of the cache early - const existing = this.partCache.get(cacheKey); - if (existing) { - this.partCacheBytes -= existing.length; - this.partCache.delete(cacheKey); - } - this.partCache.set(cacheKey, buffer); - this.partCacheBytes += buffer.length; - while (this.partCacheBytes > PART_CACHE_MAX_TOTAL_BYTES && this.partCache.size > 0) { - const oldest = this.partCache.keys().next().value as string; - const evicted = this.partCache.get(oldest); - this.partCache.delete(oldest); - this.partCacheBytes -= evicted?.length ?? 0; - } + yield* iteratePartRows(key, await this.partCache.streamFor(key, version, deadline)); } async deleteKeys(keys: string[]): Promise { diff --git a/apps/nestjs-backend/src/features/record-history-cold/record-history-cold.config.ts b/apps/nestjs-backend/src/features/record-history-cold/record-history-cold.config.ts index 877055c1c9..5adae3e4fe 100644 --- a/apps/nestjs-backend/src/features/record-history-cold/record-history-cold.config.ts +++ b/apps/nestjs-backend/src/features/record-history-cold/record-history-cold.config.ts @@ -1,22 +1,4 @@ -const readBoolEnv = (name: string): boolean => { - const value = process.env[name]?.trim().toLowerCase(); - return value === '1' || value === 'true' || value === 'on'; -}; - -const readPositiveIntEnv = (name: string, defaultValue: number): number => { - const raw = process.env[name]; - if (raw === undefined) return defaultValue; - const value = Number(raw); - return Number.isFinite(value) && value > 0 ? Math.floor(value) : defaultValue; -}; - -/** like readPositiveIntEnv but 0 is a valid value (used for "disabled") */ -const readNonNegativeIntEnv = (name: string, defaultValue: number): number => { - const raw = process.env[name]; - if (raw === undefined) return defaultValue; - const value = Number(raw); - return Number.isFinite(value) && value >= 0 ? Math.floor(value) : defaultValue; -}; +import { readBoolEnv, readNonNegativeIntEnv, readPositiveIntEnv } from '../cold-archive/env'; export interface IRecordHistoryColdConfig { /** daily BullMQ flush scheduler (on unless disabled) */ @@ -87,7 +69,8 @@ export interface IRecordHistoryColdConfig { * operator action, no data movement step, backlog drains itself under the * per-run row budget. * - * BACKEND_RECORD_HISTORY_COLD_DISABLED=true is the single kill switch and + * BACKEND_STORAGE_COLD_ARCHIVE_DISABLED=true is the single kill switch + * shared by every cold-archive feature (record history, record trash) and * it stops the MIGRATION PROCESS only (flush scheduler, compaction, * deletion). Merged reads are unconditional — reading is not part of the * migration, it is how migrated data stays visible — so a switched-off @@ -97,7 +80,7 @@ export interface IRecordHistoryColdConfig { * switch ON permanently and let exactly one environment own the migration. */ export const recordHistoryColdConfig = (): IRecordHistoryColdConfig => { - const disabled = readBoolEnv('BACKEND_RECORD_HISTORY_COLD_DISABLED'); + const disabled = readBoolEnv('BACKEND_STORAGE_COLD_ARCHIVE_DISABLED'); return { flushSchedulerEnabled: !disabled, compactSchedulerEnabled: !disabled, @@ -136,21 +119,3 @@ export const recordHistoryColdConfig = (): IRecordHistoryColdConfig => { ), }; }; - -export const mapWithConcurrency = async ( - items: readonly TItem[], - concurrency: number, - mapper: (item: TItem, index: number) => Promise -): Promise => { - const results: TResult[] = new Array(items.length); - let next = 0; - const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, () => - (async () => { - for (let index = next++; index < items.length; index = next++) { - results[index] = await mapper(items[index], index); - } - })() - ); - await Promise.all(workers); - return results; -}; diff --git a/apps/nestjs-backend/src/features/record-history-cold/record-history-cold.spec.ts b/apps/nestjs-backend/src/features/record-history-cold/record-history-cold.spec.ts index 0559b69be4..e48677b364 100644 --- a/apps/nestjs-backend/src/features/record-history-cold/record-history-cold.spec.ts +++ b/apps/nestjs-backend/src/features/record-history-cold/record-history-cold.spec.ts @@ -219,21 +219,6 @@ describe('record-history cold storage', () => { }); }); - describe('part byte cache accounting', () => { - it('re-caching the same key under concurrent misses does not leak phantom bytes', () => { - const internals = storage as unknown as { - cachePart: (cacheKey: string, buffer: Buffer) => void; - partCacheBytes: number; - partCache: Map; - }; - const buf = Buffer.alloc(1024, 1); - internals.cachePart('k@etag1', buf); - internals.cachePart('k@etag1', Buffer.alloc(1024, 2)); - expect(internals.partCacheBytes).toBe(1024); - expect(internals.partCache.size).toBe(1); - }); - }); - describe('cursor codec', () => { it('round-trips and rejects legacy cursors', () => { const cursor = encodeColdCursor(new Date('2026-05-10T10:00:00.000Z'), 'rh1'); @@ -1209,7 +1194,7 @@ describe('record-history cold storage', () => { }; beforeEach(() => { - delete process.env.BACKEND_RECORD_HISTORY_COLD_DISABLED; + delete process.env.BACKEND_STORAGE_COLD_ARCHIVE_DISABLED; }); it('chains a catch-up job with a colon-free id when the budget is exhausted', async () => { diff --git a/apps/nestjs-backend/src/features/record-history-cold/record-history-flusher.service.ts b/apps/nestjs-backend/src/features/record-history-cold/record-history-flusher.service.ts index 96b9019bbf..751af0bf86 100644 --- a/apps/nestjs-backend/src/features/record-history-cold/record-history-flusher.service.ts +++ b/apps/nestjs-backend/src/features/record-history-cold/record-history-flusher.service.ts @@ -3,13 +3,16 @@ import { DataPrismaService } from '@teable/db-data-prisma'; import { PrismaService } from '@teable/db-main-prisma'; import { DataDbClientManager } from '../../global/data-db-client-manager.service'; import { DatabaseRouter } from '../../global/database-router.service'; +import { mapWithConcurrency } from '../../utils/map-with-concurrency'; +import { bucketRange, groupStatsByBucket, isBucketCovered } from '../cold-archive/bucket-coverage'; +import { nextReadBatchLimit, READ_BATCH_PROBE_ROWS } from '../cold-archive/read-batch'; import { BucketMergeFeeder } from './bucket-merge-feeder'; import { approxColdRowBytes, SortMemoryBudget } from './external-sort'; import type { IColdHistoryRow, IPartBucket, IPartStatsEntry, ITableColdStats } from './part-codec'; import { bucketId, bucketOfDate, parsePartKey } from './part-codec'; import { PartWriter } from './part-writer'; import { RecordHistoryColdStorageService } from './record-history-cold-storage.service'; -import { mapWithConcurrency, recordHistoryColdConfig } from './record-history-cold.config'; +import { recordHistoryColdConfig } from './record-history-cold.config'; export interface IColdFlushOptions { mode: 'incremental' | 'backfill'; @@ -83,31 +86,7 @@ interface ITouchedBucket { const quoteIdent = (name: string) => `"${name.replace(/"/g, '""')}"`; -/** target bytes per buffer read batch; the row LIMIT adapts to hit this */ -const READ_BATCH_TARGET_BYTES = 8 * 1024 * 1024; -/** - * first batch of a table probes the row weight before trusting the full cap. - * Kept small: a table can average 500KB/row (real on the ai fleet), so a - * large first probe materializes hundreds of MB before the adaptive limit - * kicks in — worse when several tables probe concurrently. - */ -const READ_BATCH_PROBE_ROWS = 64; -/** floor of 1: a single multi-MB row must be readable one at a time */ -const READ_BATCH_MIN_ROWS = 1; - -/** - * rows for the next batch so ~READ_BATCH_TARGET_BYTES come back whatever the - * row weight: a row-count LIMIT alone lets one fat-JSON table materialize - * gigabytes in a single batch. The configured cap is the hard upper bound — - * an operator who lowered readBatchSize below the fat-row floor to cut memory - * pressure keeps that ceiling, so the floor only applies while it stays under - * the cap. - */ -export const nextReadBatchLimit = (batchBytes: number, batchRows: number, cap: number): number => { - const avgRowBytes = Math.max(1, Math.ceil(batchBytes / Math.max(1, batchRows))); - const target = Math.floor(READ_BATCH_TARGET_BYTES / avgRowBytes); - return Math.min(cap, Math.max(READ_BATCH_MIN_ROWS, target)); -}; +export { nextReadBatchLimit } from '../cold-archive/read-batch'; /** * Flushes record_history buffer rows older than the horizon into cold parts. @@ -740,10 +719,10 @@ export class RecordHistoryFlusherService { const streamRanges: { lo: Date; hi: Date }[] = []; for (const bucket of buckets) { const id = bucket.dd ? `${bucket.yyyymm}/${bucket.dd}` : `${bucket.yyyymm}/m`; - if (this.isBucketCovered(statsByBucket.get(id), listedByBucket.get(id), bucket)) { + if (isBucketCovered(statsByBucket.get(id), listedByBucket.get(id), bucket)) { coveredRows += Number(bucket.count); } else { - streamRanges.push(this.bucketRange(bucket, cutoff, dayWindowStart)); + streamRanges.push(bucketRange(bucket, cutoff, dayWindowStart)); } } @@ -758,27 +737,14 @@ export class RecordHistoryFlusherService { } private groupStatsByBucket(stats: ITableColdStats) { - const byBucket = new Map< - string, - { keys: Set; rows: number; min: string; max: string } - >(); - for (const [key, entry] of Object.entries(stats.parts)) { - const parsed = parsePartKey(this.coldStorage.rootDir, key); - if (!parsed) continue; - const id = bucketId(parsed); - const agg = byBucket.get(id) ?? { - keys: new Set(), - rows: 0, - min: entry.minCreatedTime, - max: entry.maxCreatedTime, - }; - agg.keys.add(key); - agg.rows += entry.rows; - if (entry.minCreatedTime < agg.min) agg.min = entry.minCreatedTime; - if (entry.maxCreatedTime > agg.max) agg.max = entry.maxCreatedTime; - byBucket.set(id, agg); - } - return byBucket; + return groupStatsByBucket( + stats.parts, + (key) => { + const parsed = parsePartKey(this.coldStorage.rootDir, key); + return parsed ? bucketId(parsed) : undefined; + }, + (entry) => ({ min: entry.minCreatedTime, max: entry.maxCreatedTime }) + ); } private async listPartsByBucket(tableId: string, months: string[]) { @@ -794,45 +760,6 @@ export class RecordHistoryFlusherService { return byBucket; } - private isBucketCovered( - agg: { keys: Set; rows: number; min: string; max: string } | undefined, - listed: Set | undefined, - bucket: { count: string; min: Date; max: Date } - ): boolean { - return ( - agg !== undefined && - listed !== undefined && - agg.keys.size === listed.size && - [...agg.keys].every((key) => listed.has(key)) && - agg.rows === Number(bucket.count) && - agg.min === bucket.min.toISOString() && - agg.max === bucket.max.toISOString() - ); - } - - /** canonical time range of a bucket, clamped to the day-window boundary and cutoff */ - private bucketRange( - bucket: { yyyymm: string; dd: string | null }, - cutoff: Date, - dayWindowStart: Date - ): { lo: Date; hi: Date } { - const year = Number(bucket.yyyymm.slice(0, 4)); - const month = Number(bucket.yyyymm.slice(4, 6)); - if (bucket.dd) { - const dayStart = new Date(Date.UTC(year, month - 1, Number(bucket.dd))); - const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); - return { - lo: dayStart > dayWindowStart ? dayStart : dayWindowStart, - hi: dayEnd < cutoff ? dayEnd : cutoff, - }; - } - const monthStart = new Date(Date.UTC(year, month - 1, 1)); - const nextMonth = new Date(Date.UTC(year, month, 1)); - let hi = nextMonth < dayWindowStart ? nextMonth : dayWindowStart; - if (cutoff < hi) hi = cutoff; - return { lo: monthStart, hi }; - } - private async qualifiedHistoryTable(tableId: string): Promise { const url = await this.dataDbClientManager.getDataDatabaseUrlForTable(tableId); const schema = new URL(url).searchParams.get('schema') || 'public'; diff --git a/apps/nestjs-backend/src/features/record-removal-cold/bucket-merge-feeder.ts b/apps/nestjs-backend/src/features/record-removal-cold/bucket-merge-feeder.ts new file mode 100644 index 0000000000..8a0e1d8e8c --- /dev/null +++ b/apps/nestjs-backend/src/features/record-removal-cold/bucket-merge-feeder.ts @@ -0,0 +1,32 @@ +import { ColdBucketMergeFeeder } from '../cold-archive/bucket-merge-feeder'; +import type { SortMemoryBudget } from './external-sort'; +import { REMOVAL_ROW_CODEC } from './external-sort'; +import type { IColdRemovalRow, IParsedPartKey, IPartStatsEntry } from './part-codec'; +import { truncateRemovalRow } from './part-codec'; +import type { PartWriter } from './part-writer'; +import type { RecordRemovalColdStorageService } from './record-removal-cold-storage.service'; + +export class BucketMergeFeeder extends ColdBucketMergeFeeder { + constructor( + writer: PartWriter, + existingParts: IParsedPartKey[], + coldStorage: RecordRemovalColdStorageService, + sortBudget?: SortMemoryBudget, + mergeFanIn?: number, + truncateFieldUnits = 0, + truncateRowUnits = 0 + ) { + super( + writer, + existingParts, + coldStorage, + REMOVAL_ROW_CODEC, + sortBudget, + mergeFanIn, + // parts written before the caps still hold multi-MB snapshots; heal on read-back + truncateFieldUnits || truncateRowUnits + ? (row) => truncateRemovalRow(row, truncateFieldUnits, truncateRowUnits) + : undefined + ); + } +} diff --git a/apps/nestjs-backend/src/features/record-removal-cold/external-sort.ts b/apps/nestjs-backend/src/features/record-removal-cold/external-sort.ts new file mode 100644 index 0000000000..a2b9995034 --- /dev/null +++ b/apps/nestjs-backend/src/features/record-removal-cold/external-sort.ts @@ -0,0 +1,33 @@ +import type { IColdRowCodec, SortMemoryBudget } from '../cold-archive/external-sort'; +import { ColdRowSorter } from '../cold-archive/external-sort'; +import type { IColdRemovalRow } from './part-codec'; +import { compareRemovalRowDesc } from './part-codec'; + +export { SortMemoryBudget } from '../cold-archive/external-sort'; + +// the budgeting unit for sort runs and read batches +export const approxRemovalRowBytes = (row: IColdRemovalRow): number => + 64 + + row.id.length + + row.recordId.length + + row.snapshot.length + + row.reason.length + + row.removedTime.length + + row.removedBy.length + + (row.operationId?.length ?? 0) + + (row.recordCreatedTime?.length ?? 0) + + (row.recordCreatedBy?.length ?? 0) + + (row.recordLastModifiedTime?.length ?? 0) + + (row.recordLastModifiedBy?.length ?? 0); + +export const REMOVAL_ROW_CODEC: IColdRowCodec = { + compare: compareRemovalRowDesc, + sizeOf: approxRemovalRowBytes, + tmpPrefix: 'rr-cold', +}; + +export class ExternalRowSorter extends ColdRowSorter { + constructor(runSize?: number, budget?: SortMemoryBudget, mergeFanIn?: number) { + super(REMOVAL_ROW_CODEC, runSize, budget, mergeFanIn); + } +} diff --git a/apps/nestjs-backend/src/features/record-removal-cold/part-codec.ts b/apps/nestjs-backend/src/features/record-removal-cold/part-codec.ts new file mode 100644 index 0000000000..c034560ca0 --- /dev/null +++ b/apps/nestjs-backend/src/features/record-removal-cold/part-codec.ts @@ -0,0 +1,282 @@ +import type { Readable } from 'node:stream'; +import type { IRecordBloom } from '../cold-archive/bloom'; +import type { IPartBucket } from '../cold-archive/bucket'; +import { padSeq } from '../cold-archive/bucket'; +import { createPartCompressorFor, partFileSuffixFor } from '../cold-archive/compression'; +import { decodePartRows } from '../cold-archive/part-line'; + +// Cold-part layout (see record-removal-cold p3 design): +// +// record-removal/v1/{tableId}/{reason}/{yyyymm}/{dd}-p{seq}-r{runToken}.ndjson.zst flusher day part +// record-removal/v1/{tableId}/{reason}/{yyyymm}/m-p{seq}-r{runToken}.ndjson.zst compactor month part +// record-removal/v1/{tableId}/{reason}/_stats.json per-(table,reason) pruning stats +// +// A part is NDJSON: one header line, N data rows, one footer line, compressed +// as a single zstd (or gzip fallback) stream. Rows inside a part are sorted by +// (removedTime DESC, id DESC) — the archive default page order, so a reader +// stops as soon as its page is full. Unlike record history there is no +// minRecordId in the key: record-id point queries prune via the per-part bloom +// in `_stats.json` instead. + +export { iterateNdjsonLines } from '../cold-archive/ndjson'; +export { bloomMightContain, buildRecordBloom } from '../cold-archive/bloom'; +export { bucketId, bucketOfDate } from '../cold-archive/bucket'; +export type { IPartBucket } from '../cold-archive/bucket'; +export { createRowHasher, serializeFooter } from '../cold-archive/part-line'; +export type { IPartFooter } from '../cold-archive/part-line'; + +export const RECORD_REMOVAL_COLD_VERSION = 'v1'; + +// the removal reasons are key-path segments — a frozen storage contract. They +// mirror IRecordRemovalReason (@teable/v2-core) by value, but deliberately do +// NOT derive from it: a domain-type change must never silently reshape keys. +export const COLD_REMOVAL_REASONS = ['deleted', 'archived'] as const; + +export type ColdRemovalReason = (typeof COLD_REMOVAL_REASONS)[number]; + +export const isColdRemovalReason = (value: string): value is ColdRemovalReason => + (COLD_REMOVAL_REASONS as readonly string[]).includes(value); + +export interface IColdRemovalRow { + id: string; + recordId: string; + // record snapshot JSON text as stored in record_trash.snapshot, after + // truncateRemovalRow — never the raw form (see the truncation section below) + snapshot: string; + reason: ColdRemovalReason; + // ISO string (= record_trash.created_time, the moment of removal) + removedTime: string; + removedBy: string; + operationId?: string; + recordCreatedTime?: string; + recordCreatedBy?: string; + recordLastModifiedTime?: string; + recordLastModifiedBy?: string; +} + +export interface IPartHeader { + t: 'h'; + v: 1; + tableId: string; + reason: ColdRemovalReason; + bucket: IPartBucket; +} + +export interface IParsedPartKey extends IPartBucket { + tableId: string; + reason: ColdRemovalReason; + seq: number; + compression: 'zstd' | 'gzip'; + key: string; +} + +export interface IPartStatsEntry { + key: string; + rows: number; + sha256: string; + minRemovedTime: string; + maxRemovedTime: string; + // the record-meta dims are optional on the row, so their bounds/sets cover + // only rows that carry them — pruning on these dims only skips rows a + // dim-equality filter could never match anyway + minRecordCreatedTime?: string; + maxRecordCreatedTime?: string; + minRecordLastModifiedTime?: string; + maxRecordLastModifiedTime?: string; + // distinct record creators in the part; null when over the cap (must scan) + recordCreatedBys: string[] | null; + // distinct last modifiers in the part; null when over the cap (must scan) + recordLastModifiedBys: string[] | null; + // record-id bloom filter: "definitely not here" prunes the part safely + recordBloom?: IRecordBloom; +} + +export interface ITableColdStats { + version: 1; + tableId: string; + reason: ColdRemovalReason; + parts: Record; +} + +// explicit-set cap for per-part recordCreatedBys/recordLastModifiedBys in +// `_stats.json`; beyond this the set is stored as null (= must scan). 500 +// matches the record-history stats cap: worst case ≈ 10KB per part entry, and +// only for parts that actually touch that many distinct actors. +export const STATS_SET_CAP = 500; + +const COLD_COMPRESSION_ENV = 'BACKEND_RECORD_REMOVAL_COLD_COMPRESSION'; + +export const partFileSuffix = () => partFileSuffixFor(COLD_COMPRESSION_ENV); + +export const createPartCompressor = () => createPartCompressorFor(COLD_COMPRESSION_ENV); + +export const coldRootDir = (rootDir: string) => `${rootDir}/${RECORD_REMOVAL_COLD_VERSION}`; + +export const tablePrefix = (rootDir: string, tableId: string) => + `${coldRootDir(rootDir)}/${tableId}/`; + +export const reasonPrefix = (rootDir: string, tableId: string, reason: ColdRemovalReason) => + `${tablePrefix(rootDir, tableId)}${reason}/`; + +export const monthPrefix = ( + rootDir: string, + tableId: string, + reason: ColdRemovalReason, + yyyymm: string +) => `${reasonPrefix(rootDir, tableId, reason)}${yyyymm}/`; + +export const statsKey = (rootDir: string, tableId: string, reason: ColdRemovalReason) => + `${reasonPrefix(rootDir, tableId, reason)}_stats.json`; + +export const buildPartKey = ( + rootDir: string, + tableId: string, + reason: ColdRemovalReason, + bucket: IPartBucket, + seq: number, + runToken: string +) => { + const base = monthPrefix(rootDir, tableId, reason, bucket.yyyymm); + const lead = bucket.kind === 'month' ? 'm' : bucket.dd!; + // the run token makes concurrent rewrites of the same bucket collision-free: + // two runs computing the same startSeq from the same listing still produce + // distinct keys, so neither can overwrite (or verification-cleanup-delete) + // the other's part; read-side id-dedup absorbs the duplication + return `${base}${lead}-p${padSeq(seq)}-r${runToken}${partFileSuffix()}`; +}; + +// filename: {m|dd}-p{seq}-r{runToken}.ndjson.{zst|gz} +const PART_FILE_RE = /^(m|\d{2})-p(\d+)-r[a-z0-9]+\.ndjson\.(zst|gz)$/; + +export const parsePartKey = (rootDir: string, key: string): IParsedPartKey | undefined => { + const root = coldRootDir(rootDir); + if (!key.startsWith(`${root}/`)) return undefined; + const rest = key.slice(root.length + 1); + const segments = rest.split('/'); + if (segments.length !== 4) return undefined; + const [tableId, reason, yyyymm, fileName] = segments; + if (!isColdRemovalReason(reason)) return undefined; + if (!/^\d{6}$/.test(yyyymm)) return undefined; + const match = PART_FILE_RE.exec(fileName); + if (!match) return undefined; + const [, lead, seq, compression] = match; + return { + tableId, + reason, + yyyymm, + kind: lead === 'm' ? 'month' : 'day', + dd: lead === 'm' ? undefined : lead, + seq: Number(seq), + compression: compression === 'zst' ? 'zstd' : 'gzip', + key, + }; +}; + +export const serializeHeader = ( + tableId: string, + reason: ColdRemovalReason, + bucket: IPartBucket +): string => JSON.stringify({ t: 'h', v: 1, tableId, reason, bucket } satisfies IPartHeader); + +export const serializeRow = (row: IColdRemovalRow): string => JSON.stringify(row); + +// A snapshot value over the caps is a legacy anomaly: values this large (up to +// 15MB observed on the ai fleet history data) make the cold flush/merge OOM no +// matter how the memory is bounded, so they are replaced with a compact marker +// at every point a row enters the sorter (flusher hot-window read, feeder +// fold-back, compactor) — the pipeline never holds a multi-MB value. Rows +// still inside the PG hot window are untouched, so restores from PG stay full +// fidelity; only the S3 copy is capped. +// +// Both caps are measured in UTF-16 units (O(1), the proxy for the V8 heap +// cost that OOMs): `fieldUnits` against each field VALUE's serialized JSON +// inside the snapshot's `fields` map (the default sits ~16x above the product +// cell-value maximum, so a legitimately max-size cell is never truncated), and +// `rowUnits` against the whole snapshot as a fallback (many capped-but-large +// fields, or an unparseable snapshot). A truncated field restores as empty. +export interface IColdTruncationMarker { + // eslint-disable-next-line @typescript-eslint/naming-convention + _truncated: true; + units: number; +} + +// marker replacing an oversized field value (object form) or a whole +// oversized snapshot (its JSON text form); `units` is the size of the +// replaced JSON +export const coldTruncationMarker = (units: number): IColdTruncationMarker => ({ + _truncated: true, + units, +}); + +// replace field values over fieldCap inside the snapshot's `fields` map; +// returns undefined when nothing changed — the caller then keeps the ORIGINAL +// string, so an untouched snapshot stays byte-exact (a re-serialize could +// normalize it and break fidelity) +const truncateSnapshotFields = (snapshot: string, fieldCap: number): string | undefined => { + let parsed: unknown; + try { + parsed = JSON.parse(snapshot); + } catch { + // unparseable snapshot: skip the field pass, the row cap still applies + return undefined; + } + if (typeof parsed !== 'object' || parsed === null) return undefined; + const fields = (parsed as { fields?: unknown }).fields; + if (typeof fields !== 'object' || fields === null) return undefined; + const fieldMap = fields as Record; + let changed = false; + for (const [fieldId, value] of Object.entries(fieldMap)) { + if (value === undefined) continue; + const serialized = JSON.stringify(value); + if (serialized !== undefined && serialized.length > fieldCap) { + fieldMap[fieldId] = coldTruncationMarker(serialized.length); + changed = true; + } + } + return changed ? JSON.stringify(parsed) : undefined; +}; + +// truncate a row's snapshot in place-free fashion; returns the same ref when +// nothing changed (incl. both caps <= 0 = disabled). The parse only runs for +// rows already over a cap — the fast path skips every normal-sized row. +export const truncateRemovalRow = ( + row: IColdRemovalRow, + fieldUnits: number, + rowUnits: number +): IColdRemovalRow => { + const fieldCap = fieldUnits > 0 ? fieldUnits : Infinity; + const rowCap = rowUnits > 0 ? rowUnits : Infinity; + if (row.snapshot.length <= Math.min(fieldCap, rowCap)) return row; + let snapshot = row.snapshot; + if (snapshot.length > fieldCap) { + snapshot = truncateSnapshotFields(snapshot, fieldCap) ?? snapshot; + } + if (snapshot.length > rowCap) { + // whole-snapshot fallback: keep an empty record shell around the marker — the + // restore paths parse the snapshot itself (v2 reads record.id, v1 iterates + // record.fields), so a bare marker would fail the whole restore batch + snapshot = JSON.stringify({ + id: row.recordId, + fields: {}, + ...coldTruncationMarker(snapshot.length), + }); + } + return snapshot === row.snapshot ? row : { ...row, snapshot }; +}; + +export const iteratePartRows = (key: string, compressed: Readable) => + decodePartRows(key, compressed); + +// descending (removedTime, id) — the one canonical order: rows are written +// into parts this way AND merged reads page this way. The id tiebreak is a +// raw UTF-16 code-unit comparison (byte order for these ASCII ids); ordering +// must never cross into a db collation — PG-side reads sort with COLLATE "C" +// so both sides agree on the same total order. +export const compareRemovalRowDesc = ( + a: Pick, + b: Pick +) => { + if (a.removedTime !== b.removedTime) return a.removedTime < b.removedTime ? 1 : -1; + if (a.id !== b.id) return a.id < b.id ? 1 : -1; + return 0; +}; diff --git a/apps/nestjs-backend/src/features/record-removal-cold/part-writer.ts b/apps/nestjs-backend/src/features/record-removal-cold/part-writer.ts new file mode 100644 index 0000000000..e92eac4fe1 --- /dev/null +++ b/apps/nestjs-backend/src/features/record-removal-cold/part-writer.ts @@ -0,0 +1,261 @@ +import { randomBytes } from 'node:crypto'; +import { once } from 'node:events'; +import { PassThrough, Transform } from 'node:stream'; +import type { Readable } from 'node:stream'; +import type { + ColdRemovalReason, + IColdRemovalRow, + IPartBucket, + IPartStatsEntry, +} from './part-codec'; +import { + buildPartKey, + buildRecordBloom, + createPartCompressor, + createRowHasher, + iteratePartRows, + serializeFooter, + serializeHeader, + serializeRow, + STATS_SET_CAP, +} from './part-codec'; + +// minimal storage surface so the writer is unit-testable without a real bucket +export interface IPartStore { + upload(key: string, stream: Readable): Promise; + download(key: string): Promise; + delete(key: string): Promise; +} + +export interface IPartWriterOptions { + store: IPartStore; + rootDir: string; + tableId: string; + reason: ColdRemovalReason; + bucket: IPartBucket; + // cut a new part once this many uncompressed bytes are written + partUncompressedBytes: number; + startSeq?: number; +} + +export interface IPartWriteMetrics { + parts: number; + rows: number; + uncompressedBytes: number; + compressedBytes: number; +} + +interface IOpenPart { + key: string; + seq: number; + input: PassThrough; + uploadPromise: Promise; + hasher: ReturnType; + rows: number; + uncompressedBytes: number; + compressedBytes: { value: number }; + minRemovedTime: string; + maxRemovedTime: string; + minRecordCreatedTime?: string; + maxRecordCreatedTime?: string; + minRecordLastModifiedTime?: string; + maxRecordLastModifiedTime?: string; + recordCreatedBys: Set | null; + recordLastModifiedBys: Set | null; + // distinct record ids for the bloom. The input is removedTime-major, NOT + // record-major, so a record's rows are not adjacent — boundary tracking + // (the record-history trick) would over-count; a Set is required here. + recordIds: Set; +} + +const minOf = (a: string | undefined, b: string | undefined): string | undefined => { + if (a === undefined) return b; + if (b === undefined) return a; + return a < b ? a : b; +}; + +const maxOf = (a: string | undefined, b: string | undefined): string | undefined => { + if (a === undefined) return b; + if (b === undefined) return a; + return a > b ? a : b; +}; + +// fold a value into an explicit set with the null-over-cap semantics; rows +// missing the (optional) dim contribute nothing +const addCapped = (set: Set | null, value: string | undefined): Set | null => { + if (!set || value === undefined) return set; + set.add(value); + return set.size > STATS_SET_CAP ? null : set; +}; + +// Streams rows (already sorted by removedTime DESC, id DESC) into ~fixed-size +// compressed NDJSON parts: open upload on first row, cut on the uncompressed +// threshold, verify each uploaded part by re-downloading and re-counting. +// Memory stays O(stream buffers + distinct record ids), independent of table +// size. +export class PartWriter { + private seq: number; + private current: IOpenPart | undefined; + private readonly entries: IPartStatsEntry[] = []; + // per-writer key component: concurrent same-bucket runs never collide + private readonly runToken = randomBytes(3).toString('hex'); + readonly metrics: IPartWriteMetrics = { + parts: 0, + rows: 0, + uncompressedBytes: 0, + compressedBytes: 0, + }; + + constructor(private readonly options: IPartWriterOptions) { + this.seq = options.startSeq ?? 0; + } + + get bucket() { + return this.options.bucket; + } + + async add(row: IColdRemovalRow): Promise { + if (!this.current) { + this.current = this.openPart(row); + } + const part = this.current; + const line = serializeRow(row); + part.hasher.update(line); + part.rows += 1; + part.uncompressedBytes += Buffer.byteLength(line) + 1; + part.recordIds.add(row.recordId); + if (row.removedTime < part.minRemovedTime) part.minRemovedTime = row.removedTime; + if (row.removedTime > part.maxRemovedTime) part.maxRemovedTime = row.removedTime; + part.minRecordCreatedTime = minOf(part.minRecordCreatedTime, row.recordCreatedTime); + part.maxRecordCreatedTime = maxOf(part.maxRecordCreatedTime, row.recordCreatedTime); + part.minRecordLastModifiedTime = minOf( + part.minRecordLastModifiedTime, + row.recordLastModifiedTime + ); + part.maxRecordLastModifiedTime = maxOf( + part.maxRecordLastModifiedTime, + row.recordLastModifiedTime + ); + part.recordCreatedBys = addCapped(part.recordCreatedBys, row.recordCreatedBy); + part.recordLastModifiedBys = addCapped(part.recordLastModifiedBys, row.recordLastModifiedBy); + await this.write(part, `${line}\n`); + if (part.uncompressedBytes >= this.options.partUncompressedBytes) { + await this.closeCurrent(); + } + } + + // flush the open part (if any) and return the stats entries of all parts written + async finish(): Promise { + await this.closeCurrent(); + return this.entries; + } + + private openPart(firstRow: IColdRemovalRow): IOpenPart { + const { store, rootDir, tableId, reason, bucket } = this.options; + const seq = this.seq++; + const key = buildPartKey(rootDir, tableId, reason, bucket, seq, this.runToken); + const input = new PassThrough(); + const compressor = createPartCompressor(); + const compressedBytes = { value: 0 }; + const counter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + compressedBytes.value += chunk.length; + callback(null, chunk); + }, + }); + const uploadPromise = store.upload(key, input.pipe(compressor).pipe(counter)); + // surface upload failures at closeCurrent() while unblocking any writer + // currently awaiting backpressure drain on the input stream + uploadPromise.catch((error) => { + input.destroy(error instanceof Error ? error : new Error(String(error))); + }); + const part: IOpenPart = { + key, + seq, + input, + uploadPromise, + hasher: createRowHasher(), + rows: 0, + uncompressedBytes: 0, + compressedBytes, + minRemovedTime: firstRow.removedTime, + maxRemovedTime: firstRow.removedTime, + recordCreatedBys: new Set(), + recordLastModifiedBys: new Set(), + recordIds: new Set(), + }; + // header is not part of the row hash + part.input.write(`${serializeHeader(tableId, reason, bucket)}\n`); + return part; + } + + private async write(part: IOpenPart, chunk: string): Promise { + if (!part.input.write(chunk)) { + await once(part.input, 'drain'); + } + } + + private async closeCurrent(): Promise { + const part = this.current; + if (!part) return; + this.current = undefined; + const sha256 = part.hasher.digest(); + part.input.end(`${serializeFooter(part.rows, sha256)}\n`); + await part.uploadPromise; + try { + await this.verifyPart(part.key, part.rows, sha256); + } catch (error) { + // readers and rewrites discover parts by listing keys, so a part that + // failed verification must not stay under the live prefix + await this.options.store.delete(part.key).catch(() => undefined); + throw error; + } + this.entries.push({ + key: part.key, + rows: part.rows, + sha256, + minRemovedTime: part.minRemovedTime, + maxRemovedTime: part.maxRemovedTime, + minRecordCreatedTime: part.minRecordCreatedTime, + maxRecordCreatedTime: part.maxRecordCreatedTime, + minRecordLastModifiedTime: part.minRecordLastModifiedTime, + maxRecordLastModifiedTime: part.maxRecordLastModifiedTime, + recordCreatedBys: part.recordCreatedBys ? [...part.recordCreatedBys].sort() : null, + recordLastModifiedBys: part.recordLastModifiedBys + ? [...part.recordLastModifiedBys].sort() + : null, + recordBloom: buildRecordBloom(part.recordIds, part.recordIds.size), + }); + this.metrics.parts += 1; + this.metrics.rows += part.rows; + this.metrics.uncompressedBytes += part.uncompressedBytes; + this.metrics.compressedBytes += part.compressedBytes.value; + } + + private async verifyPart(key: string, expectedRows: number, expectedSha: string): Promise { + const stream = await this.options.store.download(key); + const hasher = createRowHasher(); + let rows = 0; + let footerRows: number | undefined; + let footerSha: string | undefined; + for await (const item of iteratePartRows(key, stream)) { + if (item.footer) { + footerRows = item.footer.rows; + footerSha = item.footer.sha256; + continue; + } + if (item.rowLine !== undefined) { + rows += 1; + hasher.update(item.rowLine); + } + } + const sha = hasher.digest(); + if (rows !== expectedRows || sha !== expectedSha || footerRows !== rows || footerSha !== sha) { + throw new Error( + `record-removal cold part verification failed for ${key}: ` + + `rows local=${expectedRows} remote=${rows} footer=${footerRows}, ` + + `sha local=${expectedSha.slice(0, 12)} remote=${sha.slice(0, 12)} footer=${footerSha?.slice(0, 12)}` + ); + } + } +} diff --git a/apps/nestjs-backend/src/features/record-removal-cold/record-removal-cold-read.service.ts b/apps/nestjs-backend/src/features/record-removal-cold/record-removal-cold-read.service.ts new file mode 100644 index 0000000000..195d3b4013 --- /dev/null +++ b/apps/nestjs-backend/src/features/record-removal-cold/record-removal-cold-read.service.ts @@ -0,0 +1,815 @@ +import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import type { + ColdRemovalReason, + IColdRemovalRow, + IParsedPartKey, + IPartStatsEntry, + ITableColdStats, +} from './part-codec'; +import { bloomMightContain, compareRemovalRowDesc } from './part-codec'; +import { + ColdReadDeadlineError, + RecordRemovalColdStorageService, +} from './record-removal-cold-storage.service'; +import { recordRemovalColdConfig } from './record-removal-cold.config'; + +// Cold-side reader for the archive list merge (P3.2): serves the tail of an +// archive page from record-removal cold parts once the caller's PG +// record_trash zone runs out. The PG half of the seam lives in the EE +// ArchiveService — this service only sees an exclusive boundary (the last PG +// row, or a decoded rms1: cursor) and continues strictly after it in the +// requested serving order. + +// sort dimensions the archive list pages on; 'removedTime' matches the part +// physical order (fast path with early stops), the record-meta dims force a +// bounded full scan (slow path — see fillBySecondary) +export type IRemovalColdOrderBy = 'removedTime' | 'recordCreatedTime' | 'recordLastModifiedTime'; + +export type IRemovalColdDirection = 'asc' | 'desc'; + +// exclusive resume point in the serving order: (orderBy sort key, row id) of +// the last row the caller already served +export interface IRemovalColdBoundary { + // ISO value of the orderBy dimension + k: string; + id: string; +} + +export interface IRemovalColdFilters { + recordCreatedBys?: string[]; + recordLastModifiedBys?: string[]; + removedTimeStart?: string; + removedTimeEnd?: string; + recordCreatedTimeStart?: string; + recordCreatedTimeEnd?: string; +} + +export interface ICollectArchivedRowsInput { + tableId: string; + reason: ColdRemovalReason; + // rows to return; the reader over-fetches one internally to detect whether + // a next page exists + limit: number; + orderBy: IRemovalColdOrderBy; + direction: IRemovalColdDirection; + boundary?: IRemovalColdBoundary; + filters?: IRemovalColdFilters; + // caller-supplied row filter (e.g. the EE archive search matcher): rows + // failing it do not count toward the page, so cold pages stay full while + // matches remain + rowPredicate?: (row: IColdRemovalRow) => boolean; + // tombstone filter: rows restored/purged AFTER sinking must vanish from + // cold reads. Receives (recordId, removedTime) so the caller can apply the + // time-qualified rule (see isTombstonedAt in the tombstone service — a + // record re-archived after its tombstone sinks legitimate NEWER rows). + // Absent = "never tombstoned". + isTombstoned?: (recordId: string, removedTime: string) => boolean; + // row-id dedup across the PG/cold seam: the caller seeds it with the PG + // page's row ids (the sunk-but-not-yet-deleted overlap window); this call + // adds every emitted cold row id + seenIds: Set; + // overrides the config default (s3ReadTimeoutMs) for the whole call + deadlineMs?: number; +} + +export interface ICollectArchivedRowsResult { + rows: IColdRemovalRow[]; + // rms1: cursor after the last returned row; null = the cold tail is done + nextCursor: string | null; +} + +const COLD_CURSOR_PREFIX = 'rms1:'; + +// rms1 cold cursor: base64url(JSON { k, id }) — the exclusive (sort key, id) +// resume point. The { k: null, id: null } form means "cold zone, from the +// top": the EE seam hands it out when the PG zone ended without a usable +// sort-key boundary (all-null secondary keys) or as a retryable cursor after +// a cold timeout on a fresh boundary-less page. +export const encodeRemovalColdCursor = (boundary: IRemovalColdBoundary | undefined): string => { + const payload = boundary ? { k: boundary.k, id: boundary.id } : { k: null, id: null }; + return `${COLD_CURSOR_PREFIX}${Buffer.from(JSON.stringify(payload)).toString('base64url')}`; +}; + +// undefined = not a cold cursor (PG zone); { boundary: undefined } = cold +// zone from the top; { boundary } = cold zone resume point +export const decodeRemovalColdCursor = ( + cursor: string +): { boundary?: IRemovalColdBoundary } | undefined => { + if (!cursor.startsWith(COLD_CURSOR_PREFIX)) return undefined; + try { + const parsed = JSON.parse( + Buffer.from(cursor.slice(COLD_CURSOR_PREFIX.length), 'base64url').toString('utf8') + ) as { k?: string | null; id?: string | null }; + if (typeof parsed.k === 'string' && typeof parsed.id === 'string') { + return { boundary: { k: parsed.k, id: parsed.id } }; + } + if (parsed.k === null && parsed.id === null) return {}; + } catch { + // malformed payload: fall through — treated as garbage by the caller + } + return undefined; +}; + +const sortKeyOf = (row: IColdRemovalRow, orderBy: IRemovalColdOrderBy): string | undefined => { + if (orderBy === 'removedTime') return row.removedTime; + return orderBy === 'recordCreatedTime' ? row.recordCreatedTime : row.recordLastModifiedTime; +}; + +const toIso = (value: string | undefined): string | undefined => + value === undefined ? undefined : new Date(value).toISOString(); + +// filter bounds normalized to the canonical ISO form once per call, so every +// comparison against row values (already ISO with milliseconds + 'Z', see the +// flusher's to_char) is a plain lexicographic string compare +interface INormalizedFilters { + removedTimeStart?: string; + removedTimeEnd?: string; + recordCreatedTimeStart?: string; + recordCreatedTimeEnd?: string; + recordCreatedBys?: string[]; + recordLastModifiedBys?: string[]; +} + +const normalizeFilters = (filters: IRemovalColdFilters | undefined): INormalizedFilters => ({ + removedTimeStart: toIso(filters?.removedTimeStart), + removedTimeEnd: toIso(filters?.removedTimeEnd), + recordCreatedTimeStart: toIso(filters?.recordCreatedTimeStart), + recordCreatedTimeEnd: toIso(filters?.recordCreatedTimeEnd), + recordCreatedBys: filters?.recordCreatedBys, + recordLastModifiedBys: filters?.recordLastModifiedBys, +}); + +// entry sets are advisory: unknown (null = over the stats cap) or no filter → +// cannot prune +const setsIntersect = (entrySet: string[] | null, queryList: string[] | undefined): boolean => { + if (!entrySet || !queryList?.length) return true; + return entrySet.some((value) => queryList.includes(value)); +}; + +interface IPartCandidate extends IParsedPartKey { + size?: number; + etag?: string; +} + +// a key from a listing can vanish mid-read when a flusher/compactor heal pass +// supersedes it — shared by the page scan and the point lookup, both of which +// resolve the race with one fresh re-list + rescan +const isMissingPartError = (error: unknown): boolean => { + const candidate = error as { name?: string; code?: string; message?: string } | undefined; + const signature = `${candidate?.name ?? ''} ${candidate?.code ?? ''} ${candidate?.message ?? ''}`; + return /NoSuchKey|NotFound|ENOENT|does not exist|404/i.test(signature); +}; + +// point lookup over the cold parts of one (tableId, reason) prefix — the +// archive restore fallback and the purge-of-sunk-rows path resolve recordIds +// with no PG row through this +export interface ILookupArchivedRowsInput { + tableId: string; + reason: ColdRemovalReason; + recordIds: string[]; + // same time-qualified tombstone predicate as the page reader: suppressed + // rows are treated as nonexistent, so an id whose every cold row is + // tombstoned simply comes back "not found" + isTombstoned?: (recordId: string, removedTime: string) => boolean; + // overrides the config default (s3ReadTimeoutMs) for the whole call + deadlineMs?: number; +} + +@Injectable() +export class RecordRemovalColdReadService { + private readonly logger = new Logger(RecordRemovalColdReadService.name); + + constructor(private readonly coldStorage: RecordRemovalColdStorageService) {} + + async collectArchivedRows(input: ICollectArchivedRowsInput): Promise { + const config = recordRemovalColdConfig(); + // a limit of 0 would make the +1 probe unpoppable; the seam never asks + // for empty pages (it hands out a boundary cursor instead), so clamp + const limit = Math.max(1, Math.floor(input.limit)); + const deadline = Date.now() + (input.deadlineMs ?? config.s3ReadTimeoutMs); + const scan = new ArchiveColdScan( + this.coldStorage, + input, + normalizeFilters(input.filters), + deadline, + this.logger + ); + + const want = limit + 1; + const out: IColdRemovalRow[] = []; + const timedOut = + input.orderBy === 'removedTime' + ? await scan.fillByRemovedTime(want, out) + : await scan.fillBySecondary(want, out); + + let nextCursor: string | null = null; + if (out.length > limit) { + const probe = out.pop()!; + // the probe row is served on the NEXT page — it must stay deduplicable + input.seenIds.delete(probe.id); + nextCursor = this.cursorAfter(out, input.orderBy); + } else if (timedOut && out.length > 0) { + // partial page under the S3 budget: fast-path months are collected + // atomically (a partially scanned month contributes nothing), so the + // last emitted row is a safe resume point — hand back a cursor so the + // client continues where the scan stopped + nextCursor = this.cursorAfter(out, input.orderBy); + } else if (timedOut) { + // nothing collected before the budget ran out: an empty page here would + // read as "no more archives" and silently truncate — fail loudly + // instead; retries make progress because scanned parts land in the + // part byte cache + throw new ServiceUnavailableException( + 'record removal cold storage read timed out; please retry' + ); + } + return { rows: out, nextCursor }; + } + + private cursorAfter(out: IColdRemovalRow[], orderBy: IRemovalColdOrderBy): string { + const last = out[out.length - 1]; + // rows emitted under a secondary sort always carry the dim (missing-dim + // rows are excluded), so the sort key is never undefined here + return encodeRemovalColdCursor({ k: sortKeyOf(last, orderBy)!, id: last.id }); + } + + // Point-look up the LATEST cold row of each requested recordId. + // + // COST PROFILE: months are walked newest→oldest; per month one LIST plus a + // scan of only the parts whose stats recordBloom might contain a still- + // missing id ("definitely absent" parts are skipped; no stats/bloom = must + // scan). Rows bucket by removedTime, so the newest month containing a + // record holds its latest row — a month is finalized once all its candidate + // parts were scanned, found ids leave the missing set, and the walk stops + // early when it is empty. With stats present a K-id lookup typically + // downloads the few parts that actually hold the records plus ~0.8% bloom + // false positives; an id that never existed costs the month LISTs alone. + // + // ALL-OR-NOTHING under the time budget: a partial scan could hand back an + // OLDER copy of a record whose latest row sits in an unscanned month (a + // restore would then resurrect stale data), so exceeding the budget throws + // instead of returning what was found — retries make progress through the + // part byte cache. + async lookupArchivedRowsByRecordIds( + input: ILookupArchivedRowsInput + ): Promise> { + const config = recordRemovalColdConfig(); + const deadline = Date.now() + (input.deadlineMs ?? config.s3ReadTimeoutMs); + const found = new Map(); + const missing = new Set(input.recordIds); + if (missing.size === 0) return found; + + const months = await this.coldStorage.listMonths(input.tableId, input.reason); + this.assertLookupBudget(deadline); + if (months.length === 0) return found; + const stats = await this.coldStorage.readStats(input.tableId, input.reason); + this.assertLookupBudget(deadline); + + // listMonths is newest→oldest already + for (const yyyymm of months) { + if (missing.size === 0) break; + try { + await this.lookupMonth(input, yyyymm, stats, missing, found, deadline); + } catch (error) { + if (!isMissingPartError(error)) throw error; + this.logger.warn( + `cold removal part vanished under a concurrent rewrite in ${input.tableId}/${input.reason}/${yyyymm}; re-listing` + ); + await this.lookupMonth(input, yyyymm, stats, missing, found, deadline); + } + // month fully scanned: everything found so far is final (older months + // only hold strictly older removedTimes) + for (const recordId of found.keys()) missing.delete(recordId); + } + return found; + } + + private async lookupMonth( + input: ILookupArchivedRowsInput, + yyyymm: string, + stats: ITableColdStats | undefined, + missing: Set, + found: Map, + deadline: number + ): Promise { + const parts = await this.coldStorage.listMonthParts(input.tableId, input.reason, yyyymm); + this.assertLookupBudget(deadline); + const candidates = parts.filter((part) => + RecordRemovalColdReadService.bloomAllowsAny(stats?.parts[part.key], missing) + ); + for (const candidate of candidates) { + await this.scanPartForRecords(input, candidate, missing, found, deadline); + } + } + + // stats are advisory: no entry / no bloom → must scan; with a bloom the part + // is skipped only when EVERY still-missing id is definitely absent + private static bloomAllowsAny(entry: IPartStatsEntry | undefined, missing: Set): boolean { + const bloom = entry?.recordBloom; + if (!bloom) return true; + for (const recordId of missing) { + if (bloomMightContain(bloom, recordId)) return true; + } + return false; + } + + private async scanPartForRecords( + input: ILookupArchivedRowsInput, + candidate: IPartCandidate, + missing: Set, + found: Map, + deadline: number + ): Promise { + let scanned = 0; + try { + for await (const item of this.coldStorage.iterateRowsCached( + candidate.key, + { etag: candidate.etag, size: candidate.size }, + deadline + )) { + if ((scanned++ & 1023) === 0) this.assertLookupBudget(deadline); + const row = item.row; + if (!row || !missing.has(row.recordId)) continue; + if (input.isTombstoned?.(row.recordId, row.removedTime)) continue; + const best = found.get(row.recordId); + // keep the max-(removedTime, id) row; day/month part overlap during a + // compaction transition can surface the same row twice — equal rows + // compare 0 and the first copy wins + if (!best || compareRemovalRowDesc(row, best) < 0) { + found.set(row.recordId, row); + } + } + } catch (error) { + if (!(error instanceof ColdReadDeadlineError)) throw error; + this.throwLookupTimeout(); + } + } + + private assertLookupBudget(deadline: number): void { + if (Date.now() > deadline) this.throwLookupTimeout(); + } + + private throwLookupTimeout(): never { + throw new ServiceUnavailableException( + 'record removal cold storage lookup timed out; please retry' + ); + } +} + +// One page's scan state over the cold months of a (tableId, reason) prefix. +// +// Fast path (orderBy=removedTime): months are walked in serving order and +// collected atomically; inside a month the candidate parts (pruned by bucket +// dims and _stats) are scanned with early stops — parts are physically +// (removedTime DESC, id DESC) sorted, so a desc reader stops the moment its +// page quota is met (unlike record-history, whose record-major parts always +// need a full scan). +// +// Slow path (secondary sort keys): parts are removedTime-ordered, so there is +// no early stop — every candidate part streams fully through a bounded top-K. +class ArchiveColdScan { + private months: string[] | undefined; + private stats: ITableColdStats | undefined; + private statsLoaded = false; + private timedOut = false; + + constructor( + private readonly coldStorage: RecordRemovalColdStorageService, + private readonly input: ICollectArchivedRowsInput, + private readonly filters: INormalizedFilters, + private readonly deadline: number, + private readonly logger: Logger + ) {} + + // ---------------------------------------------------------------- fast path + + async fillByRemovedTime(want: number, out: IColdRemovalRow[]): Promise { + if (!(await this.ensureMonthMetadata()) || !this.months) return this.timedOut; + // listMonths returns newest→oldest; asc serves oldest months first + const ordered = this.input.direction === 'desc' ? this.months : [...this.months].reverse(); + for (const yyyymm of ordered) { + if (out.length >= want) break; + const verdict = this.classifyMonth(yyyymm); + if (verdict === 'stop') break; + if (verdict === 'skip') continue; + const rows = await this.collectMonth(yyyymm, want - out.length, 'fast'); + if (this.timedOut) break; + this.emit(rows, want, out); + } + return this.timedOut; + } + + // undefined = scan this month; 'skip' = try the next one; 'stop' = every + // remaining month (in iteration order) is out of the window + private classifyMonth(yyyymm: string): 'skip' | 'stop' | undefined { + const { lo, hi } = ArchiveColdScan.monthRange(yyyymm); + return this.input.direction === 'desc' + ? this.classifyMonthDesc(lo, hi) + : this.classifyMonthAsc(lo, hi); + } + + // iterating newest→oldest: once a month falls below the start bound, all + // remaining months are older still + private classifyMonthDesc(lo: string, hi: string): 'skip' | 'stop' | undefined { + const f = this.filters; + const boundary = this.input.boundary; + if (f.removedTimeStart && hi <= f.removedTimeStart) return 'stop'; + if (f.removedTimeEnd && lo > f.removedTimeEnd) return 'skip'; + if (boundary && lo > boundary.k) return 'skip'; + return undefined; + } + + // iterating oldest→newest: once a month rises above the end bound, all + // remaining months are newer still + private classifyMonthAsc(lo: string, hi: string): 'skip' | 'stop' | undefined { + const f = this.filters; + const boundary = this.input.boundary; + if (f.removedTimeEnd && lo > f.removedTimeEnd) return 'stop'; + if (f.removedTimeStart && hi <= f.removedTimeStart) return 'skip'; + if (boundary && hi <= boundary.k) return 'skip'; + return undefined; + } + + // ---------------------------------------------------------------- slow path + + // SECONDARY-SORT COST PROFILE: parts are removedTime-ordered, so a page on + // recordCreatedTime/recordLastModifiedTime cannot stop early — every + // candidate part (after bucket + _stats pruning on the removedTime filters + // and the secondary-dim ranges) streams fully through a bounded top-K + // (k = limit+1, compacted at k*8). One page costs O(candidate cold rows) + // scanned with O(k) held; later pages re-scan but hit the part byte cache. + // Acceptable: secondary sorts are an explicit user action on the archive + // list, never its default order. + // + // The scan is all-or-nothing under the time budget: a partially scanned key + // space could emit rows that unscanned parts should have preceded, and the + // resume cursor would then skip them forever — so a timeout here + // contributes zero rows (the caller degrades or fails loudly). + async fillBySecondary(want: number, out: IColdRemovalRow[]): Promise { + if (!(await this.ensureMonthMetadata()) || !this.months) return this.timedOut; + const collected: IColdRemovalRow[] = []; + for (const yyyymm of this.months) { + // months only bound removedTime, which is orthogonal to the secondary + // sort: they prune by the removedTime FILTERS alone, in any order + const { lo, hi } = ArchiveColdScan.monthRange(yyyymm); + if (this.filters.removedTimeStart && hi <= this.filters.removedTimeStart) continue; + if (this.filters.removedTimeEnd && lo > this.filters.removedTimeEnd) continue; + collected.push(...(await this.collectMonth(yyyymm, want, 'slow'))); + if (this.timedOut) return true; + if (collected.length > want * 8) { + this.trimTopK(collected, want); + } + } + this.trimTopK(collected, want); + this.emit(collected, want, out); + return false; + } + + // ------------------------------------------------------------- month scans + + // a key from our listing can vanish mid-read when a flusher/compactor heal + // pass supersedes it — the replacement part exists but is invisible to our + // stale listing. One fresh re-list + rescan resolves the race; rows double- + // collected across the retry are deduplicated by id. A second miss (or one + // during the retry) propagates. + private async collectMonth( + yyyymm: string, + k: number, + mode: 'fast' | 'slow' + ): Promise { + try { + return await this.collectMonthOnce(yyyymm, k, mode); + } catch (error) { + if (!isMissingPartError(error)) throw error; + this.logger.warn( + `cold removal part vanished under a concurrent rewrite in ${this.input.tableId}/${this.input.reason}/${yyyymm}; re-listing` + ); + return await this.collectMonthOnce(yyyymm, k, mode); + } + } + + private async collectMonthOnce( + yyyymm: string, + k: number, + mode: 'fast' | 'slow' + ): Promise { + const { input } = this; + const parts = await this.coldStorage.listMonthParts(input.tableId, input.reason, yyyymm); + if (this.budgetSpent()) return []; + const candidates = parts.filter((part) => this.bucketAllows(part) && this.statsAllowPart(part)); + const collected: IColdRemovalRow[] = []; + for (const candidate of candidates) { + if (Date.now() > this.deadline) this.timedOut = true; + if (this.timedOut) { + // set here or mid-scan inside the part scan: a partially scanned + // month must contribute nothing (its rows would be incomplete) + this.logger.warn( + `record-removal cold read hit the S3 time budget at ${input.tableId}/${input.reason}/${yyyymm}; returning a partial page` + ); + return []; + } + // only the desc removedTime scan can exploit the physical part order; + // the asc fast path and the secondary sorts share the bounded keep-k + // full scan (asc: the best/oldest rows sit at the part's END — slow-ish + // but bounded: O(part rows) scanned, O(k) held) + collected.push( + ...(mode === 'fast' && input.direction === 'desc' + ? await this.scanPartRemovedTimeDesc(candidate, k) + : await this.scanPartKeepK(candidate, k)) + ); + // one request consumes at most k rows, so anything beyond the k best + // can never be read — compact periodically to keep a month with many + // parts from allocating parts × k rows at once + if (collected.length > k * 8) { + this.trimTopK(collected, k); + } + } + if (this.timedOut) return []; + this.trimTopK(collected, k); + return collected; + } + + // ------------------------------------------------------------- part scans + + // stream one part's rows with the shared safety rails: the deadline must + // hold WITHIN a part too (a slow download or a large part would otherwise + // be read to completion long past the budget — checked every 1024 rows), + // and a download that outlived the budget is a timeout, not a failure. + // Stopping (return/break by the consumer) closes the underlying stream. + private async *iteratePart(candidate: IPartCandidate): AsyncGenerator { + let scanned = 0; + try { + for await (const item of this.coldStorage.iterateRowsCached( + candidate.key, + { etag: candidate.etag, size: candidate.size }, + this.deadline + )) { + if ((scanned++ & 1023) === 0 && Date.now() > this.deadline) { + this.timedOut = true; + return; + } + if (item.row) yield item.row; + } + } catch (error) { + if (!(error instanceof ColdReadDeadlineError)) throw error; + this.timedOut = true; + } + } + + // fast-path desc: the part is physically (removedTime DESC, id DESC) + // sorted, so matching rows arrive in serving order — stop at the page + // quota or below the oldest bound + private async scanPartRemovedTimeDesc( + candidate: IPartCandidate, + k: number + ): Promise { + const collected: IColdRemovalRow[] = []; + for await (const row of this.iteratePart(candidate)) { + // physical desc order: below the oldest bound nothing later matches + if (this.filters.removedTimeStart && row.removedTime < this.filters.removedTimeStart) { + break; + } + if (!this.matchesRow(row)) continue; + collected.push(row); + // page-fill early stop: later rows are strictly worse + if (collected.length >= k) break; + } + return collected; + } + + // full stream keeping the k best rows under the serving order — used by + // the asc fast path and by the secondary sorts, where no early stop is + // possible (see the cost profile note) + private async scanPartKeepK(candidate: IPartCandidate, k: number): Promise { + const collected: IColdRemovalRow[] = []; + for await (const row of this.iteratePart(candidate)) { + if (!this.matchesRow(row)) continue; + collected.push(row); + if (collected.length > k * 8) this.trimTopK(collected, k); + } + this.trimTopK(collected, k); + return collected; + } + + // --------------------------------------------------------------- filtering + + private matchesRow(row: IColdRemovalRow): boolean { + const { input } = this; + // seam dedup: the caller seeds seenIds with its PG page (the flush + // overlap window) and emitted rows accumulate here — a seen row must + // never consume top-K space + if (input.seenIds.has(row.id)) return false; + if (!this.withinTimeFilters(row) || !this.matchesActorFilters(row)) return false; + // secondary sorts exclude rows missing the sort dim entirely (the PG side + // orders its NULLs per Prisma default inside its own zone — see the + // archive seam note in the EE service) + const key = sortKeyOf(row, input.orderBy); + if (key === undefined) return false; + if (input.boundary && !this.afterBoundary(key, row.id)) return false; + // tombstoned rows (restored/purged after sinking) vanish from cold reads + if (input.isTombstoned?.(row.recordId, row.removedTime)) return false; + return input.rowPredicate ? input.rowPredicate(row) : true; + } + + private withinTimeFilters(row: IColdRemovalRow): boolean { + const f = this.filters; + if (f.removedTimeStart && row.removedTime < f.removedTimeStart) return false; + if (f.removedTimeEnd && row.removedTime > f.removedTimeEnd) return false; + // a range filter on an absent dim can never match — SQL NULL comparison + // semantics, identical to the PG side of the seam + if ( + f.recordCreatedTimeStart && + (row.recordCreatedTime === undefined || row.recordCreatedTime < f.recordCreatedTimeStart) + ) { + return false; + } + if ( + f.recordCreatedTimeEnd && + (row.recordCreatedTime === undefined || row.recordCreatedTime > f.recordCreatedTimeEnd) + ) { + return false; + } + return true; + } + + private matchesActorFilters(row: IColdRemovalRow): boolean { + const f = this.filters; + if ( + f.recordCreatedBys?.length && + (!row.recordCreatedBy || !f.recordCreatedBys.includes(row.recordCreatedBy)) + ) { + return false; + } + if ( + f.recordLastModifiedBys?.length && + (!row.recordLastModifiedBy || !f.recordLastModifiedBys.includes(row.recordLastModifiedBy)) + ) { + return false; + } + return true; + } + + // exclusive boundary in serving order; the id tie-break is a raw UTF-16 + // code-unit comparison (byte order for these ASCII ids) — the same total + // order the parts are written in, never a locale/db collation + private afterBoundary(key: string, id: string): boolean { + const boundary = this.input.boundary!; + if (key !== boundary.k) { + return this.input.direction === 'desc' ? key < boundary.k : key > boundary.k; + } + return this.input.direction === 'desc' ? id < boundary.id : id > boundary.id; + } + + // ----------------------------------------------------------------- pruning + + // key-level pruning from the bucket dims alone (works without stats): a + // day part covers [dd 00:00, dd+1 00:00) UTC, a month part the whole month + private bucketAllows(part: IParsedPartKey): boolean { + const { lo, hi } = ArchiveColdScan.bucketRange(part); + const f = this.filters; + if (f.removedTimeStart && hi <= f.removedTimeStart) return false; + if (f.removedTimeEnd && lo > f.removedTimeEnd) return false; + if (this.input.orderBy === 'removedTime' && this.input.boundary) { + // hi is exclusive: rows < hi <= boundary can never sort after it (asc) + if (this.input.direction === 'desc' && lo > this.input.boundary.k) return false; + if (this.input.direction === 'asc' && hi <= this.input.boundary.k) return false; + } + return true; + } + + // stats are advisory: no entry → must scan + private statsAllowPart(part: IPartCandidate): boolean { + const entry = this.stats?.parts[part.key]; + if (!entry) return true; + const f = this.filters; + if (f.removedTimeStart && entry.maxRemovedTime < f.removedTimeStart) return false; + if (f.removedTimeEnd && entry.minRemovedTime > f.removedTimeEnd) return false; + // the record-meta bounds cover only rows carrying the dim; rows without + // it can never match a range filter (NULL semantics) nor serve a + // secondary sort, so pruning against them is exact — and an ABSENT bound + // means the part has zero dim-carrying rows, prunable whenever the dim is + // range-filtered + if ( + f.recordCreatedTimeStart && + (entry.maxRecordCreatedTime === undefined || + entry.maxRecordCreatedTime < f.recordCreatedTimeStart) + ) { + return false; + } + if ( + f.recordCreatedTimeEnd && + (entry.minRecordCreatedTime === undefined || + entry.minRecordCreatedTime > f.recordCreatedTimeEnd) + ) { + return false; + } + if (!this.orderBoundsAllow(entry)) return false; + return ( + setsIntersect(entry.recordCreatedBys, f.recordCreatedBys) && + setsIntersect(entry.recordLastModifiedBys, f.recordLastModifiedBys) + ); + } + + // boundary pruning on the ordering dim, plus the secondary-sort "no + // dim-carrying rows at all" case + private orderBoundsAllow(entry: IPartStatsEntry): boolean { + const { orderBy, direction, boundary } = this.input; + let min: string | undefined = entry.minRemovedTime; + let max: string | undefined = entry.maxRemovedTime; + if (orderBy !== 'removedTime') { + min = + orderBy === 'recordCreatedTime' + ? entry.minRecordCreatedTime + : entry.minRecordLastModifiedTime; + max = + orderBy === 'recordCreatedTime' + ? entry.maxRecordCreatedTime + : entry.maxRecordLastModifiedTime; + // every row of this part misses the secondary sort dim → none servable + if (min === undefined || max === undefined) return false; + } + if (!boundary) return true; + if (direction === 'desc') return min! <= boundary.k; + return max! >= boundary.k; + } + + // -------------------------------------------------------------- assembling + + private compareServing(a: IColdRemovalRow, b: IColdRemovalRow): number { + const { orderBy, direction } = this.input; + // collected rows always carry the sort dim (matchesRow excluded the rest) + const ka = sortKeyOf(a, orderBy)!; + const kb = sortKeyOf(b, orderBy)!; + const sign = direction === 'desc' ? -1 : 1; + if (ka !== kb) return ka < kb ? -sign : sign; + if (a.id !== b.id) return a.id < b.id ? -sign : sign; + return 0; + } + + // sort into serving order, drop adjacent id-duplicates (day/month part + // overlap during a compaction transition, or a concurrent re-flush), keep + // only the k best — in place + private trimTopK(collected: IColdRemovalRow[], k: number): void { + collected.sort((a, b) => this.compareServing(a, b)); + let write = 0; + for (let read = 0; read < collected.length && write < k; read++) { + if (write === 0 || collected[write - 1].id !== collected[read].id) { + collected[write++] = collected[read]; + } + } + collected.length = Math.min(write, k); + } + + private emit(rows: IColdRemovalRow[], want: number, out: IColdRemovalRow[]): void { + for (const row of rows) { + if (out.length >= want) return; + if (this.input.seenIds.has(row.id)) continue; + this.input.seenIds.add(row.id); + out.push(row); + } + } + + // ---------------------------------------------------------------- metadata + + // metadata awaits count against the budget too; sets timedOut when spent + private budgetSpent(): boolean { + if (Date.now() > this.deadline) this.timedOut = true; + return this.timedOut; + } + + // loads the month list + stats once; false when the budget ran out doing so + private async ensureMonthMetadata(): Promise { + if (!this.months) { + this.months = await this.coldStorage.listMonths(this.input.tableId, this.input.reason); + if (this.budgetSpent()) return false; + } + if (!this.statsLoaded && this.months.length > 0) { + this.statsLoaded = true; + this.stats = await this.coldStorage.readStats(this.input.tableId, this.input.reason); + if (this.budgetSpent()) return false; + } + return true; + } + + // [lo, hi) ISO range of a month dir + private static monthRange(yyyymm: string): { lo: string; hi: string } { + const year = Number(yyyymm.slice(0, 4)); + const month = Number(yyyymm.slice(4, 6)); + return { + lo: new Date(Date.UTC(year, month - 1, 1)).toISOString(), + hi: new Date(Date.UTC(year, month, 1)).toISOString(), + }; + } + + // [lo, hi) ISO range of a part's bucket + private static bucketRange(part: IParsedPartKey): { lo: string; hi: string } { + if (part.kind !== 'day') return ArchiveColdScan.monthRange(part.yyyymm); + const year = Number(part.yyyymm.slice(0, 4)); + const month = Number(part.yyyymm.slice(4, 6)); + const day = Number(part.dd); + return { + lo: new Date(Date.UTC(year, month - 1, day)).toISOString(), + hi: new Date(Date.UTC(year, month - 1, day + 1)).toISOString(), + }; + } +} diff --git a/apps/nestjs-backend/src/features/record-removal-cold/record-removal-cold-storage.service.ts b/apps/nestjs-backend/src/features/record-removal-cold/record-removal-cold-storage.service.ts new file mode 100644 index 0000000000..f1d6c300f9 --- /dev/null +++ b/apps/nestjs-backend/src/features/record-removal-cold/record-removal-cold-storage.service.ts @@ -0,0 +1,211 @@ +import { Readable } from 'node:stream'; +import { Injectable, Logger } from '@nestjs/common'; +import { UploadType } from '@teable/openapi'; +import { mapWithConcurrency } from '../../utils/map-with-concurrency'; +import StorageAdapter from '../attachments/plugins/adapter'; +import { InjectStorageAdapter } from '../attachments/plugins/storage'; +import { ColdPartByteCache } from '../cold-archive/part-byte-cache'; +import type { + ColdRemovalReason, + IColdRemovalRow, + IParsedPartKey, + IPartFooter, + ITableColdStats, +} from './part-codec'; +import { + coldRootDir, + iteratePartRows, + monthPrefix, + parsePartKey, + reasonPrefix, + statsKey, + tablePrefix, +} from './part-codec'; +import type { IPartStore } from './part-writer'; + +const DELETE_CONCURRENCY = 8; + +export { ColdReadDeadlineError } from '../cold-archive/part-byte-cache'; + +// Storage facade for record-removal cold parts on the private bucket: +// key listing (per tableId+reason, two-level: month prefixes → parts of a +// month), `_stats.json` maintenance, and prefix deletion for table purges. +// +// Listings/downloads used by the WRITE paths are cache-free: parts are +// rewritten by the flusher/compactor running in another process, so a +// key-addressed byte cache can serve clobbered content. The READ path may +// use `iterateRowsCached`, which is keyed by key@etag from a live listing — +// a rewrite changes the etag and misses the cache by construction. +@Injectable() +export class RecordRemovalColdStorageService { + private readonly logger = new Logger(RecordRemovalColdStorageService.name); + private readonly partCache = new ColdPartByteCache((key) => + this.storageAdapter.downloadFile(this.bucket, key) + ); + + constructor(@InjectStorageAdapter() private readonly storageAdapter: StorageAdapter) {} + + get bucket(): string { + return StorageAdapter.getBucket(UploadType.RecordRemoval); + } + + get rootDir(): string { + return StorageAdapter.getDir(UploadType.RecordRemoval); + } + + // the minimal store surface used by PartWriter (upload + verify + cleanup) + get partStore(): IPartStore { + return { + upload: async (key, stream) => { + await this.storageAdapter.uploadFileStream(this.bucket, key, stream, { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Content-Type': 'application/x-ndjson', + }); + }, + download: (key) => this.storageAdapter.downloadFile(this.bucket, key), + delete: async (key) => { + await this.storageAdapter.deleteFile(this.bucket, key); + }, + }; + } + + // every table that has cold data (top-level prefixes under the version + // root); the reasons under a table are not listed — COLD_REMOVAL_REASONS is + // a closed set, callers enumerate it + async listTables(): Promise { + const { prefixes } = await this.storageAdapter.listObjects( + this.bucket, + `${coldRootDir(this.rootDir)}/`, + { delimiter: '/' } + ); + return prefixes + .map((prefix) => /\/(tbl[A-Za-z0-9]+)\/$/.exec(prefix)?.[1]) + .filter((tableId): tableId is string => Boolean(tableId)); + } + + // always a live LIST: the flusher/compactor run in a different process + // than the readers, so any cross-request cache here would hide a freshly + // created month dir (right after its buffer rows were deleted). Reads only + // reach S3 when the buffer cannot fill the page, so the LIST is rare. + async listMonths(tableId: string, reason: ColdRemovalReason): Promise { + const { prefixes } = await this.storageAdapter.listObjects( + this.bucket, + reasonPrefix(this.rootDir, tableId, reason), + { delimiter: '/' } + ); + return prefixes + .map((prefix) => /\/(\d{6})\/$/.exec(prefix)?.[1]) + .filter((month): month is string => Boolean(month)) + .sort() + .reverse(); + } + + async listMonthParts( + tableId: string, + reason: ColdRemovalReason, + yyyymm: string + ): Promise> { + const { objects } = await this.storageAdapter.listObjects( + this.bucket, + monthPrefix(this.rootDir, tableId, reason, yyyymm) + ); + const parts: Array = []; + for (const object of objects) { + const parsed = parsePartKey(this.rootDir, object.key); + if (!parsed) continue; + const part: IParsedPartKey & { size: number; etag?: string } = { + ...parsed, + size: object.size, + }; + if (object.etag !== undefined) part.etag = object.etag; + parts.push(part); + } + return parts; + } + + async readStats( + tableId: string, + reason: ColdRemovalReason + ): Promise { + try { + const stream = await this.storageAdapter.downloadFile( + this.bucket, + statsKey(this.rootDir, tableId, reason) + ); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(chunk as Buffer); + } + const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')) as ITableColdStats; + return parsed.version === 1 ? parsed : undefined; + } catch (error) { + // stats are an advisory cache: any miss/corruption degrades to part scans + this.logger.debug( + `no readable cold stats for table ${tableId} reason ${reason}: ${error instanceof Error ? error.message : error}` + ); + return undefined; + } + } + + async writeStats( + tableId: string, + reason: ColdRemovalReason, + stats: ITableColdStats + ): Promise { + const body = Buffer.from(JSON.stringify(stats)); + await this.storageAdapter.uploadFileStream( + this.bucket, + statsKey(this.rootDir, tableId, reason), + Readable.from(body), + { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Content-Type': 'application/json', + } + ); + } + + // stream-decode a part's rows straight off the storage stream + async *iterateRows( + key: string + ): AsyncGenerator<{ row?: IColdRemovalRow; footer?: IPartFooter; rowLine?: string }> { + const stream = await this.storageAdapter.downloadFile(this.bucket, key); + yield* iteratePartRows(key, stream); + } + + // read-path variant with an etag-keyed LRU of compressed bytes: paging + // over the same parts skips repeated downloads, and an in-place rewrite + // (new etag from the live listing) misses the cache by construction. + // The optional deadline also bounds the buffering download itself — a + // slow GET would otherwise run to completion before the caller's + // per-row deadline checks ever see a byte. + async *iterateRowsCached( + key: string, + version: { etag?: string; size?: number }, + deadline?: number + ): AsyncGenerator<{ row?: IColdRemovalRow; footer?: IPartFooter; rowLine?: string }> { + yield* iteratePartRows(key, await this.partCache.streamFor(key, version, deadline)); + } + + async deleteKeys(keys: string[]): Promise { + await mapWithConcurrency(keys, DELETE_CONCURRENCY, (key) => + this.storageAdapter.deleteFile(this.bucket, key) + ); + } + + // remove the whole cold prefix of a table — BOTH reason subtrees at once + // (table permanent deletion) + async deleteTablePrefix(tableId: string): Promise { + const prefix = tablePrefix(this.rootDir, tableId).replace(/\/$/, ''); + await this.storageAdapter.deleteDir(this.bucket, prefix, false); + } + + // remove ONE reason subtree of a table, parts and _stats.json alike (e.g. + // an archive reset drains PG then wipes archived/ while deleted/ stays); + // full-table purges keep using deleteTablePrefix for both reasons at once. + // Failures must propagate: resets rely on the prefix being gone (no + // tombstones), so a swallowed error resurfaces cold rows as ghosts. + async deleteReasonPrefix(tableId: string, reason: ColdRemovalReason): Promise { + const prefix = reasonPrefix(this.rootDir, tableId, reason).replace(/\/$/, ''); + await this.storageAdapter.deleteDir(this.bucket, prefix); + } +} diff --git a/apps/nestjs-backend/src/features/record-removal-cold/record-removal-cold.config.ts b/apps/nestjs-backend/src/features/record-removal-cold/record-removal-cold.config.ts new file mode 100644 index 0000000000..4ded67f210 --- /dev/null +++ b/apps/nestjs-backend/src/features/record-removal-cold/record-removal-cold.config.ts @@ -0,0 +1,120 @@ +import { readBoolEnv, readNonNegativeIntEnv, readPositiveIntEnv } from '../cold-archive/env'; + +export interface IRecordRemovalColdConfig { + // daily BullMQ flush scheduler (on unless disabled) + flushSchedulerEnabled: boolean; + // monthly BullMQ compaction scheduler (on unless disabled) + compactSchedulerEnabled: boolean; + // delete flushed rows from the PG buffer (on unless disabled) + deleteEnabled: boolean; + // reason='archived' rows older than this are flushed (default 30d): the + // archive UI merges PG + S3, so its hot window only needs to cover the + // interactive-read sweet spot + archiveFlushHorizonMs: number; + // reason='deleted' rows older than this are flushed (default 30d): the + // recycle bin's record reads merge PG + S3 exactly like the archive UI, so + // the hot window only needs to cover the interactive-read sweet spot. The + // plan read window (14/365/1095d) is a read-time filter over the merged + // stream, not a residency requirement. + deletedFlushHorizonMs: number; + // rows younger than this go to day files during backfill (default 30d) + backfillDayWindowMs: number; + // cut part at this many uncompressed bytes (default 32MB ≈ 4-8MB compressed) + partUncompressedBytes: number; + // concurrent tables per flush run + tableConcurrency: number; + // soft row budget per flush run (checked between tables): a fresh upgrade + // with years of record_trash backlog drains gradually across chained runs + // instead of one marathon inside the app process; 0 disables the budget + maxRowsPerRun: number; + // pause between chained catch-up runs. The budget bounds each RUN's blast + // radius (memory, transaction size, job-slot occupancy) — waiting between + // hops adds nothing, so the default is a token breather; each hop is its + // own queue job and lands on whatever worker is free + catchupDelayMs: number; + // keyset batch size for buffer reads (upper bound; adapts down by bytes) + readBatchSize: number; + // shared in-memory cap (approximate serialized bytes) for ALL sort runs of + // one flush or compaction run. Buffer reads can keep every bucket sorter of + // a table alive at once, so the bound must be global — a per-sorter cap + // alone multiplies by bucket count (the 2026-07-08 history cn drain OOM). + // JS heap cost is ~2-3x this figure. + sortMemoryBudgetBytes: number; + // max run files a merge opens at once (multi-pass above this). Each open + // reader holds one decoded row plus its line buffer, and a removal snapshot + // can be tens of MB, so an unbounded fan-in over a big bucket's runs OOMs. + // Lower on tiny-heap deployments (effective minimum is 2 — a merge must + // combine at least two runs per pass or it never converges). + sortMergeFanIn: number; + // a field VALUE inside the snapshot's `fields` map longer than this (UTF-16 + // units of its serialized JSON) is replaced with a marker before the row + // enters the sort pipeline — only the pre-cap anomalies (multi-MB legacy + // values) that OOM the flush. The 4MB default sits ~16x above the product + // cell-value maximum, so no legitimate max-size cell is ever truncated; + // rows still in the PG hot window restore full fidelity. 0 disables. + truncateFieldUnits: number; + // whole-snapshot fallback cap (UTF-16 units) after the field pass — catches + // many capped-but-large fields summing past the bound, and unparseable + // snapshots the field pass cannot walk. 0 disables. + truncateRowUnits: number; + // overall budget for the S3 segment of a removal cold read + s3ReadTimeoutMs: number; +} + +// The feature ships ON by default and migrates transparently: the flush run +// moves record_trash rows past their reason's horizon to cold parts (both +// reasons at ~30d — archive and recycle-bin reads alike merge PG + S3), +// deletes the covered buffer rows, and backlog drains itself under the +// per-run row budget — no operator action, no data movement step. +// +// BACKEND_STORAGE_COLD_ARCHIVE_DISABLED=true is the single kill switch shared +// by every cold-archive feature (record trash, record history); it stops the +// MIGRATION PROCESS only (flush scheduler, compaction, deletion). +// Merged reads are unconditional — reading is not part of the migration, it +// is how migrated data stays visible — so a switched-off process (a staging +// environment sharing the production database, or a rolled-back fleet) still +// serves archived rows from buffer + bucket. An environment that shares its +// database with another one should keep the switch ON permanently and let +// exactly one environment own the migration. +export const recordRemovalColdConfig = (): IRecordRemovalColdConfig => { + const disabled = readBoolEnv('BACKEND_STORAGE_COLD_ARCHIVE_DISABLED'); + return { + flushSchedulerEnabled: !disabled, + compactSchedulerEnabled: !disabled, + deleteEnabled: !disabled, + archiveFlushHorizonMs: readPositiveIntEnv( + 'BACKEND_RECORD_REMOVAL_COLD_ARCHIVE_HORIZON_MS', + 30 * 24 * 60 * 60 * 1000 + ), + deletedFlushHorizonMs: readPositiveIntEnv( + 'BACKEND_RECORD_REMOVAL_COLD_DELETED_HORIZON_MS', + 30 * 24 * 60 * 60 * 1000 + ), + backfillDayWindowMs: readPositiveIntEnv( + 'BACKEND_RECORD_REMOVAL_COLD_BACKFILL_DAY_WINDOW_MS', + 30 * 24 * 60 * 60 * 1000 + ), + partUncompressedBytes: readPositiveIntEnv( + 'BACKEND_RECORD_REMOVAL_COLD_PART_UNCOMPRESSED_BYTES', + 32 * 1024 * 1024 + ), + tableConcurrency: readPositiveIntEnv('BACKEND_RECORD_REMOVAL_COLD_TABLE_CONCURRENCY', 4), + maxRowsPerRun: readNonNegativeIntEnv('BACKEND_RECORD_REMOVAL_COLD_MAX_ROWS_PER_RUN', 2_000_000), + catchupDelayMs: readNonNegativeIntEnv('BACKEND_RECORD_REMOVAL_COLD_CATCHUP_DELAY_MS', 5_000), + readBatchSize: readPositiveIntEnv('BACKEND_RECORD_REMOVAL_COLD_READ_BATCH_SIZE', 5000), + sortMemoryBudgetBytes: readPositiveIntEnv( + 'BACKEND_RECORD_REMOVAL_COLD_SORT_MEMORY_BYTES', + 64 * 1024 * 1024 + ), + sortMergeFanIn: readPositiveIntEnv('BACKEND_RECORD_REMOVAL_COLD_SORT_MERGE_FAN_IN', 16), + truncateFieldUnits: readNonNegativeIntEnv( + 'BACKEND_RECORD_REMOVAL_COLD_TRUNCATE_FIELD_UNITS', + 4 * 1024 * 1024 + ), + truncateRowUnits: readNonNegativeIntEnv( + 'BACKEND_RECORD_REMOVAL_COLD_TRUNCATE_ROW_UNITS', + 16 * 1024 * 1024 + ), + s3ReadTimeoutMs: readPositiveIntEnv('BACKEND_RECORD_REMOVAL_COLD_S3_READ_TIMEOUT_MS', 10_000), + }; +}; diff --git a/apps/nestjs-backend/src/features/record-removal-cold/record-removal-cold.module.ts b/apps/nestjs-backend/src/features/record-removal-cold/record-removal-cold.module.ts new file mode 100644 index 0000000000..ecb3d5f90e --- /dev/null +++ b/apps/nestjs-backend/src/features/record-removal-cold/record-removal-cold.module.ts @@ -0,0 +1,45 @@ +import { Module } from '@nestjs/common'; +import { EventJobModule } from '../../event-emitter/event-job/event-job.module'; +import { StorageModule } from '../attachments/plugins/storage.module'; +import { RecordRemovalColdReadService } from './record-removal-cold-read.service'; +import { RecordRemovalColdStorageService } from './record-removal-cold-storage.service'; +import { + RECORD_REMOVAL_COLD_QUEUE, + RecordRemovalColdProcessor, +} from './record-removal-cold.processor'; +import { RecordRemovalCompactorService } from './record-removal-compactor.service'; +import { RecordRemovalFlusherService } from './record-removal-flusher.service'; +import { RecordRemovalTombstoneService } from './record-removal-tombstone.service'; + +// services only — no queue, no worker. EVERY importer except the app root +// belongs here: feature modules (trash/archive readers), one-off tools (the +// EE CLI runner), and auxiliary worker entrypoints that compose feature +// modules. Importing the full module below instead silently turns the host +// process into a competing cold-queue consumer — on 2026-07-08 the BYODB +// migration worker picked up a record-history flush that way while still +// running old code mid-rolling-deploy, and broke the catch-up chain. +@Module({ + imports: [StorageModule], + providers: [ + RecordRemovalColdStorageService, + RecordRemovalColdReadService, + RecordRemovalFlusherService, + RecordRemovalCompactorService, + RecordRemovalTombstoneService, + ], + exports: [ + RecordRemovalColdStorageService, + RecordRemovalColdReadService, + RecordRemovalFlusherService, + RecordRemovalCompactorService, + RecordRemovalTombstoneService, + ], +}) +export class RecordRemovalColdCoreModule {} + +@Module({ + imports: [RecordRemovalColdCoreModule, EventJobModule.registerQueue(RECORD_REMOVAL_COLD_QUEUE)], + providers: [RecordRemovalColdProcessor], + exports: [RecordRemovalColdCoreModule], +}) +export class RecordRemovalColdModule {} diff --git a/apps/nestjs-backend/src/features/record-removal-cold/record-removal-cold.processor.ts b/apps/nestjs-backend/src/features/record-removal-cold/record-removal-cold.processor.ts new file mode 100644 index 0000000000..ddcc9d8f86 --- /dev/null +++ b/apps/nestjs-backend/src/features/record-removal-cold/record-removal-cold.processor.ts @@ -0,0 +1,202 @@ +import { InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq'; +import { Injectable, Logger } from '@nestjs/common'; +import type { Job } from 'bullmq'; +import { Queue } from 'bullmq'; +import { RecordRemovalColdStorageService } from './record-removal-cold-storage.service'; +import { recordRemovalColdConfig } from './record-removal-cold.config'; +import type { ICompactMonthResult } from './record-removal-compactor.service'; +import { RecordRemovalCompactorService } from './record-removal-compactor.service'; +import type { IColdFlushRunResult } from './record-removal-flusher.service'; +import { RecordRemovalFlusherService } from './record-removal-flusher.service'; + +// NEVER share record-history's queue: the two subsystems kill-switch, scale +// and roll back independently +export const RECORD_REMOVAL_COLD_QUEUE = 'record-removal-cold-queue'; + +const FLUSH_JOB_ID = 'record-removal-cold:flush'; +const FLUSH_INTERVAL_MS = 24 * 60 * 60 * 1000; +const COMPACT_JOB_ID = 'record-removal-cold:compact'; +// 04:40 UTC on the 3rd of each month: every closed month has fully flushed, +// and the slot is offset from record-history's compaction ('10 4 2 * *') so +// the two subsystems' month merges never contend for the same worker window +const COMPACT_CRON = '40 4 3 * *'; +// BullMQ accepts ':' in scheduler ids and job NAMES (both proven in prod) but +// rejects it in CUSTOM job ids ("Custom Id cannot contain :"), so every id +// passed to queue.add() below must stay colon-free +const CATCHUP_JOB_ID_PREFIX = 'record-removal-cold-flush-catchup'; + +// Daily incremental flush plus monthly compaction of the record_trash +// write buffer / cold parts. Both schedulers are env-gated so only +// deployments that opted in run them. +@Injectable() +@Processor(RECORD_REMOVAL_COLD_QUEUE) +export class RecordRemovalColdProcessor extends WorkerHost { + private readonly logger = new Logger(RecordRemovalColdProcessor.name); + + constructor( + private readonly flusher: RecordRemovalFlusherService, + private readonly compactor: RecordRemovalCompactorService, + private readonly coldStorage: RecordRemovalColdStorageService, + @InjectQueue(RECORD_REMOVAL_COLD_QUEUE) private readonly queue: Queue + ) { + super(); + } + + async onApplicationBootstrap() { + const config = recordRemovalColdConfig(); + if (!config.flushSchedulerEnabled && !config.compactSchedulerEnabled) { + // kill-switched process: consume nothing (a paused worker on a shared + // redis leaves the jobs to any still-enabled pods) + if (typeof this.worker?.pause === 'function') { + await this.worker.pause(true); + this.logger.log('record-removal cold worker paused (cold feature disabled here)'); + } + // deliberately NO scheduler removal here: no process can tell "the + // feature was disabled everywhere" from "other pods still run it", + // and removing from the shared redis would tear down their schedule. + // With a fleet-wide kill switch the schedulers' jobs are skipped at + // execution by process() and sit as at most a couple of delayed jobs + // per day until re-enable (or a manual scheduler cleanup). + return; + } + // the redis-less fallback queue has no job schedulers; skip silently there + if (typeof this.queue.upsertJobScheduler !== 'function') { + this.logger.warn('record-removal cold schedulers unavailable without redis'); + return; + } + // schedulers are only ever ADDED here, never removed: no process can + // tell a fleet-wide rollback from "that scheduler belongs to another + // pod" (API pods, or flush/compact split across worker pods), and a + // removal on restart would silently tear down a peer's schedule. A + // rolled-back flag is neutralized by the execution-time gate in + // process(); clearing the leftover scheduler entry is a manual op. + try { + if (config.flushSchedulerEnabled) { + // creating the scheduler fires its first run immediately (that is + // what starts the migration on a fresh install); on upgrade deploys + // the next slot is at most a day away. Deliberately NO boot-time + // kick beyond that: a fixed-id kick job needs a fleet-wide dedupe + // marker, and BullMQ's lazy retention pruning turns that marker + // into a footgun (see the 2026-07-08 record-history stalls). If a + // backlog must drain sooner than the next daily slot, run the EE + // cold runner once (flush --max-rows=0) — a deliberate op, not + // boot magic. + await this.queue.upsertJobScheduler( + FLUSH_JOB_ID, + { every: FLUSH_INTERVAL_MS }, + { name: FLUSH_JOB_ID } + ); + this.logger.log(`record-removal cold flush scheduled (every ${FLUSH_INTERVAL_MS / 1000}s)`); + } + if (config.compactSchedulerEnabled) { + await this.queue.upsertJobScheduler( + COMPACT_JOB_ID, + { pattern: COMPACT_CRON }, + { name: COMPACT_JOB_ID } + ); + this.logger.log(`record-removal cold compaction scheduled (cron ${COMPACT_CRON})`); + } + } catch (error) { + this.logger.error('failed to register record-removal cold schedulers', error); + } + } + + async process(job: Job): Promise { + // execution gate: an enabled process executes WHATEVER cold job it + // receives (per-name gating would let a pod "complete" a peer's job + // without running it); a kill-switched process skips everything, so a + // stale scheduler or an already-enqueued job cannot outlive a fleet-wide + // disable + const config = recordRemovalColdConfig(); + if (!config.flushSchedulerEnabled && !config.compactSchedulerEnabled) { + this.logger.warn( + 'skipping removal cold maintenance job: this process has no cold scheduler flags' + ); + return undefined; + } + if (job.name === COMPACT_JOB_ID) { + return this.runCompaction(); + } + // monthly safety sweep: on the 1st the daily run ignores the BYODB + // bookmarks, so a space whose activity signal was ever missed — and any + // rows that aged past their horizon while the space sat idle — is + // stranded for at most a month instead of forever + const result = await this.flusher.runFlush({ + mode: 'incremental', + ignoreBookmarks: new Date().getUTCDate() === 1, + }); + this.logger.log( + `record-removal cold flush: tables=${result.tables.length} rows=${result.totalRows} ` + + `parts=${result.totalParts} bytes=${result.totalCompressedBytes} in ${result.durationMs}ms` + + (result.totalTruncatedRows ? ` truncated=${result.totalTruncatedRows}` : '') + + (result.leftoverTables ? ` (deferred ${result.leftoverTables} unit(s))` : '') + ); + if (result.budgetExhausted) { + await this.chainCatchupFlush(job); + } + return result; + } + + // backlog drain (e.g. right after an upgrade): chain a catch-up run + // instead of one marathon. The jobId carries the hop number because BullMQ + // dedups an .add() whose id matches ANY existing job INCLUDING the one + // currently executing — a fixed id would end the chain at hop one. Unique + // ids alone would let a daily run spawn a second chain next to a live one + // (its hop numbering restarts), so before adding we check the queue for + // any other pending/active catch-up and skip if one exists. + private async chainCatchupFlush(job: Job): Promise { + try { + const queue = this.queue as Queue & { + getJobs?: (types: string[]) => Promise<({ id?: string } | undefined)[]>; + }; + if (typeof queue.getJobs === 'function') { + const existing = (await queue.getJobs(['delayed', 'waiting', 'active'])).filter( + (other) => other?.id?.startsWith(CATCHUP_JOB_ID_PREFIX) && other.id !== job.id + ); + if (existing.length > 0) { + this.logger.log('catch-up flush already chained; not starting a second chain'); + return; + } + } + const hop = ((job.data as { catchupHop?: number } | undefined)?.catchupHop ?? 0) + 1; + await this.queue.add( + FLUSH_JOB_ID, + { catchupHop: hop }, + { + // near-immediate: the budget bounds each run's blast radius, so + // there is nothing to gain by idling between hops — the backlog + // drains continuously, one budget-sized, crash-safe run at a time. + // budgetExhausted implies >= maxRows of progress, so the chain can + // never hot-loop without work. + delay: recordRemovalColdConfig().catchupDelayMs, + jobId: `${CATCHUP_JOB_ID_PREFIX}-${hop}`, + removeOnComplete: true, + removeOnFail: true, + } + ); + } catch (error) { + this.logger.warn(`failed to chain catch-up flush: ${error}`); + } + } + + // compact every cold table's closed months, both reasons (day parts → month parts) + private async runCompaction(): Promise { + const tables = await this.coldStorage.listTables(); + const results: ICompactMonthResult[] = []; + for (const tableId of tables) { + try { + results.push(...(await this.compactor.compactTable(tableId))); + } catch (error) { + this.logger.error( + `record-removal compaction failed for ${tableId}: ${error instanceof Error ? error.stack : error}` + ); + } + } + const merged = results.filter((result) => !result.skippedReason); + this.logger.log( + `record-removal cold compaction: tables=${tables.length} monthsMerged=${merged.length} ` + + `rows=${merged.reduce((sum, item) => sum + item.rows, 0)}` + ); + return results; + } +} diff --git a/apps/nestjs-backend/src/features/record-removal-cold/record-removal-cold.spec.ts b/apps/nestjs-backend/src/features/record-removal-cold/record-removal-cold.spec.ts new file mode 100644 index 0000000000..ea5225a768 --- /dev/null +++ b/apps/nestjs-backend/src/features/record-removal-cold/record-removal-cold.spec.ts @@ -0,0 +1,2015 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable sonarjs/cognitive-complexity */ +import { Readable } from 'node:stream'; +import { ServiceUnavailableException } from '@nestjs/common'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type StorageAdapter from '../attachments/plugins/adapter'; +import { BucketMergeFeeder } from './bucket-merge-feeder'; +import { ExternalRowSorter, SortMemoryBudget } from './external-sort'; +import type { + ColdRemovalReason, + IColdRemovalRow, + IColdTruncationMarker, + IParsedPartKey, + IPartBucket, +} from './part-codec'; +import { + bloomMightContain, + buildPartKey, + buildRecordBloom, + compareRemovalRowDesc, + iterateNdjsonLines, + parsePartKey, + partFileSuffix, + statsKey, + truncateRemovalRow, +} from './part-codec'; +import type { IPartStore } from './part-writer'; +import { PartWriter } from './part-writer'; +import type { ICollectArchivedRowsInput } from './record-removal-cold-read.service'; +import { + decodeRemovalColdCursor, + encodeRemovalColdCursor, + RecordRemovalColdReadService, +} from './record-removal-cold-read.service'; +import { RecordRemovalColdStorageService } from './record-removal-cold-storage.service'; +import { recordRemovalColdConfig } from './record-removal-cold.config'; +import { RecordRemovalColdProcessor } from './record-removal-cold.processor'; +import { RecordRemovalCompactorService } from './record-removal-compactor.service'; +import type { IColdFlushRunResult } from './record-removal-flusher.service'; +import { RecordRemovalFlusherService } from './record-removal-flusher.service'; +import { isTombstonedAt, RecordRemovalTombstoneService } from './record-removal-tombstone.service'; + +const ROOT = 'record-removal'; +const DAY_MS = 24 * 60 * 60 * 1000; + +class FakeStorageAdapter { + objects = new Map(); + + async uploadFileStream(_bucket: string, path: string, stream: Buffer | Readable) { + const chunks: Buffer[] = []; + if (Buffer.isBuffer(stream)) { + chunks.push(stream); + } else { + for await (const chunk of stream) { + chunks.push(chunk as Buffer); + } + } + this.objects.set(path, Buffer.concat(chunks)); + return { hash: '', path }; + } + + async downloadFile(_bucket: string, path: string): Promise { + const body = this.objects.get(path); + if (!body) throw new Error(`NoSuchKey: ${path}`); + return Readable.from(body); + } + + async listObjects(_bucket: string, prefix: string, options?: { delimiter?: string }) { + const objects: { key: string; size: number }[] = []; + const prefixes = new Set(); + for (const [key, body] of this.objects) { + if (!key.startsWith(prefix)) continue; + if (options?.delimiter) { + const rest = key.slice(prefix.length); + const idx = rest.indexOf(options.delimiter); + if (idx >= 0) { + prefixes.add(prefix + rest.slice(0, idx + 1)); + continue; + } + } + objects.push({ key, size: body.length }); + } + objects.sort((a, b) => (a.key < b.key ? -1 : 1)); + return { objects, prefixes: [...prefixes].sort() }; + } + + async deleteFile(_bucket: string, path: string) { + this.objects.delete(path); + } + + async deleteDir(_bucket: string, path: string) { + const prefix = path.endsWith('/') ? path : `${path}/`; + for (const key of [...this.objects.keys()]) { + if (key.startsWith(prefix)) this.objects.delete(key); + } + } +} + +const makeRow = (overrides: Partial): IColdRemovalRow => ({ + id: 'rms0000000000000000000000', + recordId: 'recA', + snapshot: JSON.stringify({ id: 'recA', fields: { fldA: 'value' } }), + reason: 'archived', + removedTime: '2026-05-10T10:00:00.000Z', + removedBy: 'usr1', + ...overrides, +}); + +const sortDesc = (rows: IColdRemovalRow[]) => [...rows].sort(compareRemovalRowDesc); + +const seedParts = async ( + storage: RecordRemovalColdStorageService, + tableId: string, + reason: ColdRemovalReason, + bucket: IPartBucket, + rows: IColdRemovalRow[], + partUncompressedBytes = 1024 * 1024 +) => { + const writer = new PartWriter({ + store: storage.partStore, + rootDir: storage.rootDir, + tableId, + reason, + bucket, + partUncompressedBytes, + }); + for (const row of sortDesc(rows)) { + await writer.add(row); + } + return writer.finish(); +}; + +const decodeParts = async (storage: RecordRemovalColdStorageService, keys: string[]) => { + const rows: IColdRemovalRow[] = []; + for (const key of keys) { + for await (const item of storage.iterateRows(key)) { + if (item.row) rows.push(item.row); + } + } + return rows; +}; + +describe('record-removal cold storage', () => { + let fake: FakeStorageAdapter; + let storage: RecordRemovalColdStorageService; + + beforeEach(() => { + fake = new FakeStorageAdapter(); + storage = new RecordRemovalColdStorageService(fake as unknown as StorageAdapter); + }); + + describe('part key codec', () => { + it('builds and parses day and month keys with the reason segment', () => { + const day = buildPartKey( + ROOT, + 'tblX', + 'archived', + { yyyymm: '202605', kind: 'day', dd: '07' }, + 3, + 'a1b2c3' + ); + expect(day).toBe( + `record-removal/v1/tblX/archived/202605/07-p0003-ra1b2c3${partFileSuffix()}` + ); + expect(parsePartKey(ROOT, day)).toMatchObject({ + tableId: 'tblX', + reason: 'archived', + yyyymm: '202605', + kind: 'day', + dd: '07', + seq: 3, + }); + + const month = buildPartKey( + ROOT, + 'tblX', + 'deleted', + { yyyymm: '202605', kind: 'month' }, + 0, + 'ffee00' + ); + const parsedMonth = parsePartKey(ROOT, month); + expect(parsedMonth).toMatchObject({ reason: 'deleted', kind: 'month', seq: 0 }); + expect(parsedMonth?.dd).toBeUndefined(); + }); + + it('scopes the stats key per (tableId, reason)', () => { + expect(statsKey(ROOT, 'tblX', 'archived')).toBe( + 'record-removal/v1/tblX/archived/_stats.json' + ); + expect(statsKey(ROOT, 'tblX', 'deleted')).toBe('record-removal/v1/tblX/deleted/_stats.json'); + }); + + it('rejects malformed keys', () => { + const good = buildPartKey( + ROOT, + 'tblX', + 'archived', + { yyyymm: '202605', kind: 'month' }, + 1, + 'abc123' + ); + expect(parsePartKey(ROOT, good)).toBeDefined(); + // stats files are not parts + expect(parsePartKey(ROOT, 'record-removal/v1/tblX/archived/_stats.json')).toBeUndefined(); + // the reason segment is mandatory: a history-layout key must not parse + expect( + parsePartKey(ROOT, 'record-removal/v1/tblX/202605/07-p0003-rabc123.ndjson.zst') + ).toBeUndefined(); + // unknown reason + expect( + parsePartKey(ROOT, 'record-removal/v1/tblX/purged/202605/07-p0003-rabc123.ndjson.zst') + ).toBeUndefined(); + // bad month / bad day / missing run token / wrong root + expect( + parsePartKey(ROOT, 'record-removal/v1/tblX/archived/20265/07-p0003-rabc.ndjson.zst') + ).toBeUndefined(); + expect( + parsePartKey(ROOT, 'record-removal/v1/tblX/archived/202605/7-p0003-rabc.ndjson.zst') + ).toBeUndefined(); + expect( + parsePartKey(ROOT, 'record-removal/v1/tblX/archived/202605/07-p0003.ndjson.zst') + ).toBeUndefined(); + expect(parsePartKey('other-root', good)).toBeUndefined(); + }); + }); + + describe('PartWriter', () => { + it('cuts multiple verified parts under the reason prefix and round-trips all rows', async () => { + const rows = Array.from({ length: 50 }, (_, i) => + makeRow({ + id: `rms${String(i).padStart(4, '0')}`, + recordId: `rec${String(i % 7).padStart(2, '0')}`, + removedTime: `2026-05-10T10:${String(i % 60).padStart(2, '0')}:00.000Z`, + }) + ); + // one distinctive multi-byte snapshot to assert byte-exact round-tripping + rows[0].snapshot = JSON.stringify({ id: 'recX', fields: { fldA: '值-ünïq' } }); + const entries = await seedParts( + storage, + 'tblW', + 'deleted', + { yyyymm: '202605', kind: 'day', dd: '10' }, + rows, + 2048 // force multiple parts + ); + expect(entries.length).toBeGreaterThan(1); + expect(entries.reduce((sum, e) => sum + e.rows, 0)).toBe(50); + + const decoded = await decodeParts( + storage, + entries.map((e) => e.key) + ); + expect(decoded).toHaveLength(50); + expect(new Set(decoded.map((r) => r.id)).size).toBe(50); + // snapshot text survives byte-exact + expect(decoded.find((r) => r.id === 'rms0000')!.snapshot).toBe(rows[0].snapshot); + for (const entry of entries) { + const parsed = parsePartKey(ROOT, entry.key)!; + expect(parsed).toMatchObject({ + tableId: 'tblW', + reason: 'deleted', + yyyymm: '202605', + kind: 'day', + dd: '10', + }); + } + // seqs are contiguous from 0 in write order + expect(entries.map((e) => parsePartKey(ROOT, e.key)!.seq)).toEqual(entries.map((_, i) => i)); + }); + + it('deletes a part whose post-upload verification fails', async () => { + const tamper: IPartStore = { + upload: async (key, stream) => { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(chunk as Buffer); + } + const body = Buffer.concat(chunks); + // drop the tail: the read-back decode / row+sha re-count must fail + await storage.partStore.upload( + key, + Readable.from(body.subarray(0, Math.max(1, body.length - 12))) + ); + }, + download: (key) => storage.partStore.download(key), + delete: (key) => storage.partStore.delete(key), + }; + const writer = new PartWriter({ + store: tamper, + rootDir: storage.rootDir, + tableId: 'tblBad', + reason: 'archived', + bucket: { yyyymm: '202605', kind: 'month' }, + partUncompressedBytes: 1024 * 1024, + }); + await writer.add(makeRow({ id: 'rms01' })); + await expect(writer.finish()).rejects.toThrow(); + // readers discover parts by listing: the corrupt part must not survive + expect([...fake.objects.keys()].filter((key) => parsePartKey(ROOT, key))).toEqual([]); + }); + + it('stats entries carry removal-time bounds and the optional record-meta dims', async () => { + const rows = [ + makeRow({ + id: 'rms03', + removedTime: '2026-05-12T10:00:00.000Z', + recordCreatedTime: '2026-01-05T00:00:00.000Z', + recordCreatedBy: 'usrC1', + recordLastModifiedTime: '2026-04-01T00:00:00.000Z', + recordLastModifiedBy: 'usrM2', + }), + // carries no record-meta dims: contributes nothing to those bounds + makeRow({ id: 'rms02', removedTime: '2026-05-11T10:00:00.000Z' }), + makeRow({ + id: 'rms01', + removedTime: '2026-05-10T10:00:00.000Z', + recordCreatedTime: '2026-02-01T00:00:00.000Z', + recordCreatedBy: 'usrC2', + recordLastModifiedTime: '2026-03-01T00:00:00.000Z', + recordLastModifiedBy: 'usrM1', + }), + ]; + const entries = await seedParts( + storage, + 'tblS', + 'deleted', + { yyyymm: '202605', kind: 'month' }, + rows + ); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + rows: 3, + minRemovedTime: '2026-05-10T10:00:00.000Z', + maxRemovedTime: '2026-05-12T10:00:00.000Z', + minRecordCreatedTime: '2026-01-05T00:00:00.000Z', + maxRecordCreatedTime: '2026-02-01T00:00:00.000Z', + minRecordLastModifiedTime: '2026-03-01T00:00:00.000Z', + maxRecordLastModifiedTime: '2026-04-01T00:00:00.000Z', + recordCreatedBys: ['usrC1', 'usrC2'], + recordLastModifiedBys: ['usrM1', 'usrM2'], + }); + }); + + it('actor sets over the 500 cap collapse to null (must-scan), per dim independently', async () => { + const rows = Array.from({ length: 501 }, (_, i) => + makeRow({ + id: `rms${String(i).padStart(4, '0')}`, + recordCreatedBy: `usr${i}`, + recordLastModifiedBy: 'usrSame', + }) + ); + const entries = await seedParts( + storage, + 'tblCap', + 'archived', + { yyyymm: '202605', kind: 'month' }, + rows, + 64 * 1024 * 1024 + ); + expect(entries).toHaveLength(1); + expect(entries[0].rows).toBe(501); + expect(entries[0].recordCreatedBys).toBeNull(); + expect(entries[0].recordLastModifiedBys).toEqual(['usrSame']); + }); + + it('counts DISTINCT record ids for the bloom under removedTime-major interleaving', async () => { + // removal parts are removedTime-major, so a record's rows are NOT + // adjacent — record-history's boundary trick would count 40 here + const rows = Array.from({ length: 40 }, (_, i) => + makeRow({ + id: `rms${String(i).padStart(3, '0')}`, + recordId: `rec${String(i % 10).padStart(2, '0')}`, + removedTime: `2026-05-10T10:${String(59 - i).padStart(2, '0')}:00.000Z`, + }) + ); + const entries = await seedParts( + storage, + 'tblB', + 'archived', + { yyyymm: '202605', kind: 'month' }, + rows + ); + expect(entries).toHaveLength(1); + const bloom = entries[0].recordBloom!; + // sized from the 10 distinct ids (10 bits each, floor 64): over-counting + // occurrences would give 400 bits, under-counting fewer than 100 + expect(bloom.m).toBe(100); + for (let record = 0; record < 10; record++) { + expect(bloomMightContain(bloom, `rec${String(record).padStart(2, '0')}`)).toBe(true); + } + }); + }); + + describe('record bloom', () => { + it('never yields false negatives — incl. high-bit hash ids — and prunes foreign ids', () => { + const ids = [ + // h2 with the sign bit set: the `| 1`-without-`>>> 0` regression id + 'recZNamfOGgQuUXi2ez', + ...Array.from( + { length: 400 }, + (_, i) => `rec${i.toString(36)}${((i * 2654435761) % 4294967296).toString(36)}` + ), + ]; + const bloom = buildRecordBloom(ids, ids.length); + for (const id of ids) { + expect(bloomMightContain(bloom, id)).toBe(true); + } + const foreign = Array.from({ length: 1000 }, (_, i) => `recForeign${i}`); + const falsePositives = foreign.filter((id) => bloomMightContain(bloom, id)).length; + expect(falsePositives).toBeLessThan(30); // ~0.8% target, generous bound + }); + + it('prunes ids that were never added', () => { + const bloom = buildRecordBloom(['recOnlyOne'], 1); + // tiny bloom (64-bit floor): a definite miss must return false + const misses = Array.from({ length: 50 }, (_, i) => `recMiss${i}`).filter((id) => + bloomMightContain(bloom, id) + ); + expect(misses.length).toBeLessThan(10); + expect(bloomMightContain(bloom, 'recOnlyOne')).toBe(true); + }); + }); + + describe('canonical sort order', () => { + it('orders removedTime DESC with an id byte-order DESC tiebreak', () => { + const t = '2026-05-10T10:00:00.000Z'; + const newer = { removedTime: '2026-05-10T11:00:00.000Z', id: 'rms01' }; + const older = { removedTime: t, id: 'rms99' }; + expect(compareRemovalRowDesc(newer, older)).toBeLessThan(0); + expect(compareRemovalRowDesc(older, newer)).toBeGreaterThan(0); + // byte order, never a collation: lowercase 'a' (0x61) > uppercase 'Z' + // (0x5a), so 'recaAA' sorts FIRST under id DESC + const lower = { removedTime: t, id: 'recaAA' }; + const upper = { removedTime: t, id: 'recZZZ' }; + expect(compareRemovalRowDesc(lower, upper)).toBeLessThan(0); + expect(compareRemovalRowDesc(upper, lower)).toBeGreaterThan(0); + expect(compareRemovalRowDesc(lower, { ...lower })).toBe(0); + }); + + it('the sorter emits an id exactly once when duplicates share a removedTime', async () => { + const sorter = new ExternalRowSorter(); + const dup = makeRow({ id: 'rms02', removedTime: '2026-05-10T10:02:00.000Z' }); + await sorter.add(makeRow({ id: 'rms01', removedTime: '2026-05-10T10:01:00.000Z' })); + await sorter.add(dup); + await sorter.add({ ...dup }); + await sorter.add(makeRow({ id: 'rms03', removedTime: '2026-05-10T10:03:00.000Z' })); + const out: string[] = []; + await sorter.drainTo(async (row) => { + out.push(row.id); + }); + expect(out).toEqual(['rms03', 'rms02', 'rms01']); + }); + }); + + describe('oversized snapshot truncation', () => { + it('replaces a field value over the cap with a marker and keeps the rest', () => { + const big = 'x'.repeat(300); + const row = makeRow({ + snapshot: JSON.stringify({ id: 'recA', fields: { fldBig: big, fldSmall: 'ok' } }), + }); + const capped = truncateRemovalRow(row, 256, 0); + expect(capped).not.toBe(row); + const parsed = JSON.parse(capped.snapshot) as { + fields: Record; + }; + expect(parsed.fields.fldBig).toEqual({ _truncated: true, units: JSON.stringify(big).length }); + expect(parsed.fields.fldSmall).toBe('ok'); + // only the snapshot changed + expect(capped.id).toBe(row.id); + expect(capped.removedTime).toBe(row.removedTime); + }); + + it('falls back to a whole-snapshot marker when the row cap is exceeded', () => { + // every field under the field cap, but the row total over the row cap + const fields = Object.fromEntries( + Array.from({ length: 5 }, (_, i) => [`fld${i}`, 'y'.repeat(150)]) + ); + const row = makeRow({ snapshot: JSON.stringify({ id: 'recA', fields }) }); + const originalLength = row.snapshot.length; + const capped = truncateRemovalRow(row, 300, 600); + // the marker keeps a restorable record shell — id from the row column + expect(JSON.parse(capped.snapshot)).toEqual({ + id: row.recordId, + fields: {}, + _truncated: true, + units: originalLength, + }); + }); + + it('returns the same ref when nothing changed', () => { + const row = makeRow({ id: 'rmsSmall' }); + expect(truncateRemovalRow(row, 256, 1024)).toBe(row); + }); + + it('caps of 0 disable truncation', () => { + const row = makeRow({ id: 'rmsHuge', snapshot: 'z'.repeat(5_000_000) }); + expect(truncateRemovalRow(row, 0, 0)).toBe(row); + }); + + it('a non-JSON snapshot skips the field pass but still honors the row cap', () => { + // under the row cap: unchanged, same ref + const smallish = makeRow({ snapshot: 'x'.repeat(400) }); + expect(truncateRemovalRow(smallish, 300, 600)).toBe(smallish); + // over the row cap: whole-snapshot marker despite being unparseable + const oversized = makeRow({ snapshot: 'x'.repeat(700) }); + const capped = truncateRemovalRow(oversized, 300, 600); + expect(JSON.parse(capped.snapshot)).toEqual({ + id: oversized.recordId, + fields: {}, + _truncated: true, + units: 700, + }); + }); + }); + + describe('external row sorter', () => { + it('drains newest-first and deduped across gzip-spilled runs', async () => { + const sorter = new ExternalRowSorter(3); // tiny run size => several spill files + const at = (minute: number) => `2026-05-10T10:0${minute}:00.000Z`; + const rows = [ + makeRow({ id: 'rms05', removedTime: at(5) }), + makeRow({ id: 'rms01', removedTime: at(1) }), + makeRow({ id: 'rms04', removedTime: at(4) }), + makeRow({ id: 'rms02', removedTime: at(2) }), + makeRow({ id: 'rms03', removedTime: at(3) }), + makeRow({ id: 'rms03', removedTime: at(3) }), // duplicate id straddling runs + makeRow({ id: 'rms00', removedTime: at(0) }), + ]; + for (const row of rows) { + await sorter.add(row); + } + const out: IColdRemovalRow[] = []; + await sorter.drainTo(async (row) => { + out.push(row); + }); + expect(out.map((r) => r.id)).toEqual(['rms05', 'rms04', 'rms03', 'rms02', 'rms01', 'rms00']); + // rows survive the gzip spill byte-for-byte + expect(out[5]).toEqual(rows[6]); + }); + + it('a shared budget evicts the largest run while smaller ones stay in memory', async () => { + const budget = new SortMemoryBudget(2700); + const fat = new ExternalRowSorter(undefined, budget); + const thin = new ExternalRowSorter(undefined, budget); + await fat.add(makeRow({ id: 'rmsfat', recordId: 'recB', snapshot: 'x'.repeat(2500) })); + expect(fat.pendingBytes).toBeGreaterThan(0); // fits alone + await thin.add(makeRow({ id: 'rmsthin' })); + // the joint total went over budget: the LARGEST run spilled, not the adder + expect(fat.pendingBytes).toBe(0); + expect(thin.pendingBytes).toBeGreaterThan(0); + expect(budget.usedBytes).toBe(thin.pendingBytes); + + const fatOut: string[] = []; + await fat.drainTo(async (row) => { + fatOut.push(row.id); + }); + const thinOut: string[] = []; + await thin.drainTo(async (row) => { + thinOut.push(row.id); + }); + expect(fatOut).toEqual(['rmsfat']); + expect(thinOut).toEqual(['rmsthin']); + expect(budget.usedBytes).toBe(0); // drains released every charge + }); + + it('multi-pass merge stays correct when runs exceed the fan-in', async () => { + // fan-in 2 with a tiny run size forces several spilled runs and >1 pass + const sorter = new ExternalRowSorter(2, undefined, 2); + const at = (minute: number) => `2026-05-10T10:${String(minute).padStart(2, '0')}:00.000Z`; + const order = [7, 2, 5, 0, 9, 3, 6, 1, 8, 4]; + for (const n of order) { + await sorter.add(makeRow({ id: `rms0${n}`, removedTime: at(n) })); + } + // a duplicate id in a separate run must dedup across passes + await sorter.add(makeRow({ id: 'rms04', removedTime: at(4) })); + const out: string[] = []; + await sorter.drainTo(async (row) => { + out.push(row.id); + }); + expect(out).toEqual(Array.from({ length: 10 }, (_, i) => `rms0${9 - i}`)); + }); + + it('a fan-in of 1 is clamped so the multi-pass merge still converges', async () => { + // env allows FAN_IN=1; without the floor the pass groups 1->1 forever + const sorter = new ExternalRowSorter(2, undefined, 1); + const at = (minute: number) => `2026-05-10T10:0${minute}:00.000Z`; + for (const n of [3, 1, 4, 0, 2]) { + await sorter.add(makeRow({ id: `rms0${n}`, removedTime: at(n) })); + } + const out: string[] = []; + await sorter.drainTo(async (row) => { + out.push(row.id); + }); + expect(out).toEqual(['rms04', 'rms03', 'rms02', 'rms01', 'rms00']); + }); + }); + + describe('NDJSON line splitting', () => { + it('splits a multi-MB single line without readline', async () => { + // one ~2MB "row" plus small neighbours, delivered in small chunks: the + // readline path would rope-flatten + regex this repeatedly (the OOM); + // the buffer splitter must return each line intact + const big = 'x'.repeat(2 * 1024 * 1024); + const lines = [ + JSON.stringify({ id: 'a', v: 1 }), + JSON.stringify({ id: 'b', v: big }), + JSON.stringify({ id: 'c', v: 3 }), + ]; + const payload = Buffer.from(lines.join('\n') + '\n', 'utf8'); + const stream = Readable.from( + (function* () { + for (let i = 0; i < payload.length; i += 64 * 1024) { + yield payload.subarray(i, i + 64 * 1024); + } + })() + ); + const decoded: { id: string; v: unknown }[] = []; + for await (const line of iterateNdjsonLines(stream)) { + decoded.push(JSON.parse(line)); + } + expect(decoded.map((r) => r.id)).toEqual(['a', 'b', 'c']); + expect((decoded[1].v as string).length).toBe(big.length); + }); + }); + + describe('bucket merge feeder', () => { + it('re-flushing a bucket folds existing parts in without loss and exposes consumedKeys', async () => { + const tableId = 'tblF'; + const reason: ColdRemovalReason = 'archived'; + const bucket: IPartBucket = { yyyymm: '202607', kind: 'day', dd: '07' }; + const at = (hour: number) => `2026-07-07T0${hour}:00:00.000Z`; + const firstBatch = Array.from({ length: 5 }, (_, i) => + makeRow({ id: `rms0${i}`, removedTime: at(i) }) + ); + await seedParts(storage, tableId, reason, bucket, firstBatch); + + // second run: only 2 new rows remain in the buffer (first 5 already + // deleted); one of them duplicates an existing row (overlap window) + const existing = (await storage.listMonthParts(tableId, reason, '202607')).filter( + (part) => part.kind === 'day' && part.dd === '07' + ); + expect(existing.length).toBeGreaterThan(0); + const writer = new PartWriter({ + store: storage.partStore, + rootDir: storage.rootDir, + tableId, + reason, + bucket, + partUncompressedBytes: 1024 * 1024, + startSeq: existing.reduce((max, part) => Math.max(max, part.seq + 1), 0), + }); + const feeder = new BucketMergeFeeder(writer, existing, storage); + await feeder.push(makeRow({ id: 'rms04', removedTime: at(4) })); // dup + await feeder.push(makeRow({ id: 'rms05', removedTime: at(5) })); + await feeder.push(makeRow({ id: 'rms06', removedTime: at(6) })); + const entries = await feeder.finish(); + + expect(feeder.mergedExistingRows).toBe(5); + // exactly the folded pre-existing keys — the only healable set + expect(feeder.consumedKeys).toEqual(new Set(existing.map((part) => part.key))); + for (const entry of entries) { + expect(feeder.consumedKeys.has(entry.key)).toBe(false); + } + + // no row lost, overlap deduped, canonical (removedTime DESC) order kept + const decoded = await decodeParts( + storage, + entries.map((entry) => entry.key) + ); + expect(decoded.map((r) => r.id)).toEqual([ + 'rms06', + 'rms05', + 'rms04', + 'rms03', + 'rms02', + 'rms01', + 'rms00', + ]); + }); + }); + + describe('archive cold read (collectArchivedRows)', () => { + const tableId = 'tblRead'; + const reason: ColdRemovalReason = 'archived'; + let readService: RecordRemovalColdReadService; + let downloadedPartKeys: string[]; + + beforeEach(() => { + readService = new RecordRemovalColdReadService(storage); + downloadedPartKeys = []; + const original = fake.downloadFile.bind(fake); + fake.downloadFile = async (bucket: string, path: string) => { + if (path.includes('.ndjson.')) downloadedPartKeys.push(path); + return original(bucket, path); + }; + }); + + const collect = (overrides: Partial) => + readService.collectArchivedRows({ + tableId, + reason, + limit: 10, + orderBy: 'removedTime', + direction: 'desc', + seenIds: new Set(), + ...overrides, + }); + + const writeStatsFor = async (...entryLists: Awaited>[]) => { + const flat = entryLists.flat(); + await storage.writeStats(tableId, reason, { + version: 1, + tableId, + reason, + parts: Object.fromEntries(flat.map((entry) => [entry.key, entry])), + }); + }; + + it('fills desc pages across months with stats pruning and reason isolation', async () => { + const mayNew = await seedParts( + storage, + tableId, + reason, + { yyyymm: '202605', kind: 'day', dd: '20' }, + [ + makeRow({ + id: 'rmsB1', + removedTime: '2026-05-20T01:00:00.000Z', + recordCreatedBy: 'usrB', + }), + makeRow({ + id: 'rmsB2', + removedTime: '2026-05-20T02:00:00.000Z', + recordCreatedBy: 'usrB', + }), + ] + ); + const mayOld = await seedParts( + storage, + tableId, + reason, + { yyyymm: '202605', kind: 'day', dd: '10' }, + [ + makeRow({ + id: 'rmsA1', + removedTime: '2026-05-10T01:00:00.000Z', + recordCreatedBy: 'usrA', + }), + makeRow({ + id: 'rmsA2', + removedTime: '2026-05-10T02:00:00.000Z', + recordCreatedBy: 'usrA', + }), + makeRow({ + id: 'rmsA3', + removedTime: '2026-05-10T03:00:00.000Z', + recordCreatedBy: 'usrA', + }), + ] + ); + const april = await seedParts(storage, tableId, reason, { yyyymm: '202604', kind: 'month' }, [ + makeRow({ id: 'rmsC1', removedTime: '2026-04-05T01:00:00.000Z', recordCreatedBy: 'usrA' }), + makeRow({ id: 'rmsC2', removedTime: '2026-04-05T02:00:00.000Z', recordCreatedBy: 'usrA' }), + ]); + // deleted-reason rows in the same months must never be touched by the archive read + await seedParts(storage, tableId, 'deleted', { yyyymm: '202605', kind: 'day', dd: '20' }, [ + makeRow({ id: 'rmsD1', reason: 'deleted', removedTime: '2026-05-20T03:00:00.000Z' }), + ]); + await writeStatsFor(mayNew, mayOld, april); + // drop the writer's own post-upload verification downloads: only the + // READ path's downloads matter below + downloadedPartKeys.length = 0; + + const page1 = await collect({ limit: 4 }); + expect(page1.rows.map((r) => r.id)).toEqual(['rmsB2', 'rmsB1', 'rmsA3', 'rmsA2']); + expect(page1.nextCursor).toMatch(/^rms1:/); + + const page2 = await collect({ + limit: 4, + boundary: decodeRemovalColdCursor(page1.nextCursor!)?.boundary, + }); + expect(page2.rows.map((r) => r.id)).toEqual(['rmsA1', 'rmsC2', 'rmsC1']); + expect(page2.nextCursor).toBeNull(); + expect(downloadedPartKeys.every((key) => key.includes('/archived/'))).toBe(true); + + // stats actor-set pruning: a usrB filter downloads ONLY the day-20 part + downloadedPartKeys.length = 0; + const filtered = await collect({ filters: { recordCreatedBys: ['usrB'] } }); + expect(filtered.rows.map((r) => r.id)).toEqual(['rmsB2', 'rmsB1']); + expect(filtered.nextCursor).toBeNull(); + expect(new Set(downloadedPartKeys)).toEqual(new Set(mayNew.map((entry) => entry.key))); + }); + + it('seenIds dedups the PG overlap window without consuming quota and releases the probe row', async () => { + const at = '2026-05-10T10:00:00.000Z'; + await seedParts(storage, tableId, reason, { yyyymm: '202605', kind: 'day', dd: '10' }, [ + makeRow({ id: 'rmsS1', removedTime: at }), + makeRow({ id: 'rmsS2', removedTime: at }), + makeRow({ id: 'rmsS3', removedTime: at }), + makeRow({ id: 'rmsS4', removedTime: at }), + makeRow({ id: 'rmsS5', removedTime: at }), + ]); + + // PG served s5/s4 (the boundary) and — simulating a collation-order + // divergence in the overlap window — also s3, which byte order places + // after the boundary; it must be skipped WITHOUT eating page quota + const seenIds = new Set(['rmsS5', 'rmsS4', 'rmsS3']); + const page = await collect({ limit: 2, boundary: { k: at, id: 'rmsS4' }, seenIds }); + expect(page.rows.map((r) => r.id)).toEqual(['rmsS2', 'rmsS1']); + expect(page.nextCursor).toBeNull(); + expect(seenIds.has('rmsS2') && seenIds.has('rmsS1')).toBe(true); + + // the limit+1 probe row is served on the NEXT page: its id must leave + // the seen set when it is popped + const probeSeen = new Set(['rmsS5', 'rmsS4']); + const probePage = await collect({ + limit: 1, + boundary: { k: at, id: 'rmsS4' }, + seenIds: probeSeen, + }); + expect(probePage.rows.map((r) => r.id)).toEqual(['rmsS3']); + expect(decodeRemovalColdCursor(probePage.nextCursor!)?.boundary).toEqual({ + k: at, + id: 'rmsS3', + }); + expect(probeSeen.has('rmsS3')).toBe(true); + expect(probeSeen.has('rmsS2')).toBe(false); + }); + + it('serves secondary-sort pages via bounded top-K with missing-dim exclusion and cursor handoff', async () => { + const may = await seedParts(storage, tableId, reason, { yyyymm: '202605', kind: 'month' }, [ + makeRow({ + id: 'rmsR1', + removedTime: '2026-05-10T00:00:00.000Z', + recordCreatedTime: '2026-01-05T00:00:00.000Z', + }), + // no recordCreatedTime → excluded from this sort entirely + makeRow({ id: 'rmsR3', removedTime: '2026-05-11T00:00:00.000Z' }), + makeRow({ + id: 'rmsR5', + removedTime: '2026-05-01T00:00:00.000Z', + recordCreatedTime: '2026-02-01T00:00:00.000Z', + }), + ]); + const april = await seedParts(storage, tableId, reason, { yyyymm: '202604', kind: 'month' }, [ + makeRow({ + id: 'rmsR2', + removedTime: '2026-04-15T00:00:00.000Z', + recordCreatedTime: '2026-03-01T00:00:00.000Z', + }), + makeRow({ + id: 'rmsR4', + removedTime: '2026-04-01T00:00:00.000Z', + recordCreatedTime: '2026-02-01T00:00:00.000Z', + }), + ]); + await writeStatsFor(may, april); + + const page1 = await collect({ limit: 2, orderBy: 'recordCreatedTime' }); + // r2 (03-01), then the 02-01 tie broken by id byte order desc (R5 > R4) + expect(page1.rows.map((r) => r.id)).toEqual(['rmsR2', 'rmsR5']); + const boundary = decodeRemovalColdCursor(page1.nextCursor!)?.boundary; + expect(boundary).toEqual({ k: '2026-02-01T00:00:00.000Z', id: 'rmsR5' }); + + const page2 = await collect({ limit: 2, orderBy: 'recordCreatedTime', boundary }); + expect(page2.rows.map((r) => r.id)).toEqual(['rmsR4', 'rmsR1']); + expect(page2.nextCursor).toBeNull(); + }); + + it('round-trips rms1 cursors including the boundary-less form and rejects garbage', () => { + const boundary = { k: '2026-05-01T00:00:00.000Z', id: 'rmsX' }; + const cursor = encodeRemovalColdCursor(boundary); + expect(cursor.startsWith('rms1:')).toBe(true); + expect(decodeRemovalColdCursor(cursor)?.boundary).toEqual(boundary); + + // { k: null, id: null } = cold zone from the top (EE seam/retry cursor) + const topCursor = encodeRemovalColdCursor(undefined); + const decodedTop = decodeRemovalColdCursor(topCursor); + expect(decodedTop).toBeDefined(); + expect(decodedTop?.boundary).toBeUndefined(); + + // a PG row-id cursor and malformed payloads are "not a cold cursor" + expect(decodeRemovalColdCursor('cl9xyzrowid')).toBeUndefined(); + expect(decodeRemovalColdCursor('rms1:%%%not-base64%%%')).toBeUndefined(); + expect( + decodeRemovalColdCursor(`rms1:${Buffer.from('{"k":5,"id":true}').toString('base64url')}`) + ).toBeUndefined(); + }); + + it('returns a partial page plus retry cursor on mid-scan timeout and fails loudly with zero rows', async () => { + await seedParts(storage, tableId, reason, { yyyymm: '202606', kind: 'day', dd: '05' }, [ + makeRow({ id: 'rmsM1', removedTime: '2026-06-05T01:00:00.000Z' }), + makeRow({ id: 'rmsM2', removedTime: '2026-06-05T02:00:00.000Z' }), + ]); + await seedParts(storage, tableId, reason, { yyyymm: '202605', kind: 'day', dd: '05' }, [ + makeRow({ id: 'rmsO1', removedTime: '2026-05-05T01:00:00.000Z' }), + ]); + + // the SECOND month's part listing stalls past the deadline: June is + // already collected atomically, May contributes nothing → partial page + let partListCalls = 0; + const slowStorage = new Proxy(storage, { + get(target, prop, receiver) { + if (prop === 'listMonthParts') { + return async (...args: [string, ColdRemovalReason, string]) => { + partListCalls += 1; + if (partListCalls > 1) await new Promise((resolve) => setTimeout(resolve, 600)); + return target.listMonthParts(...args); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + const partialService = new RecordRemovalColdReadService(slowStorage as never); + const partial = await partialService.collectArchivedRows({ + tableId, + reason, + limit: 10, + orderBy: 'removedTime', + direction: 'desc', + seenIds: new Set(), + deadlineMs: 400, + }); + expect(partial.rows.map((r) => r.id)).toEqual(['rmsM2', 'rmsM1']); + expect(decodeRemovalColdCursor(partial.nextCursor!)?.boundary?.id).toBe('rmsM1'); + + // budget spent before anything was collected → loud failure, never an + // empty "no more archives" page + const stalledStorage = new Proxy(storage, { + get(target, prop, receiver) { + if (prop === 'listMonths') { + return async (...args: [string, ColdRemovalReason]) => { + await new Promise((resolve) => setTimeout(resolve, 600)); + return target.listMonths(...args); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + const stalledService = new RecordRemovalColdReadService(stalledStorage as never); + await expect( + stalledService.collectArchivedRows({ + tableId, + reason, + limit: 10, + orderBy: 'removedTime', + direction: 'desc', + seenIds: new Set(), + deadlineMs: 400, + }) + ).rejects.toBeInstanceOf(ServiceUnavailableException); + }); + + it('tombstoned rows vanish from cold pages while newer re-archived rows survive', async () => { + await seedParts(storage, tableId, reason, { yyyymm: '202605', kind: 'day', dd: '10' }, [ + makeRow({ id: 'rmsT1', recordId: 'recTomb', removedTime: '2026-05-10T01:00:00.000Z' }), + makeRow({ id: 'rmsK1', recordId: 'recKeep', removedTime: '2026-05-10T02:00:00.000Z' }), + ]); + // the tombstoned record re-archived AFTER the tombstone: its new sunk row + // is live data and must keep surfacing + await seedParts(storage, tableId, reason, { yyyymm: '202607', kind: 'day', dd: '01' }, [ + makeRow({ id: 'rmsT2', recordId: 'recTomb', removedTime: '2026-07-01T00:00:00.000Z' }), + ]); + const tombstones = new Map([['recTomb', '2026-06-01T00:00:00.000Z']]); + + const page = await collect({ + isTombstoned: (recordId, removedTime) => isTombstonedAt(tombstones, recordId, removedTime), + }); + expect(page.rows.map((r) => r.id)).toEqual(['rmsT2', 'rmsK1']); + expect(page.nextCursor).toBeNull(); + }); + + it('point lookup returns the latest row per record with bloom pruning and month early stop', async () => { + const june = await seedParts(storage, tableId, reason, { yyyymm: '202606', kind: 'month' }, [ + makeRow({ id: 'rmsZ1', recordId: 'recZ', removedTime: '2026-06-10T00:00:00.000Z' }), + ]); + const may = await seedParts(storage, tableId, reason, { yyyymm: '202605', kind: 'month' }, [ + makeRow({ id: 'rmsX2', recordId: 'recX', removedTime: '2026-05-10T00:00:00.000Z' }), + makeRow({ id: 'rmsY1', recordId: 'recY', removedTime: '2026-05-12T00:00:00.000Z' }), + ]); + const april = await seedParts(storage, tableId, reason, { yyyymm: '202604', kind: 'month' }, [ + makeRow({ id: 'rmsX1', recordId: 'recX', removedTime: '2026-04-05T00:00:00.000Z' }), + ]); + await writeStatsFor(june, may, april); + downloadedPartKeys.length = 0; + + const found = await readService.lookupArchivedRowsByRecordIds({ + tableId, + reason, + recordIds: ['recX', 'recY'], + }); + // the newest month containing each record wins — the April copy of recX + // is older by construction and never consulted + expect(found.get('recX')?.id).toBe('rmsX2'); + expect(found.get('recY')?.id).toBe('rmsY1'); + // bloom pruned the June part (both ids definitely absent)… + expect(downloadedPartKeys).not.toContain(june[0].key); + // …and the month walk stopped before April (all ids resolved in May) + expect(downloadedPartKeys).not.toContain(april[0].key); + expect(downloadedPartKeys).toContain(may[0].key); + + // an id that never existed prunes every part via the blooms + downloadedPartKeys.length = 0; + const none = await readService.lookupArchivedRowsByRecordIds({ + tableId, + reason, + recordIds: ['recNever'], + }); + expect(none.size).toBe(0); + expect(downloadedPartKeys).toEqual([]); + }); + + it('point lookup skips tombstoned rows and fails loudly past the deadline', async () => { + const may = await seedParts(storage, tableId, reason, { yyyymm: '202605', kind: 'month' }, [ + makeRow({ id: 'rmsX1', recordId: 'recX', removedTime: '2026-05-10T00:00:00.000Z' }), + ]); + await writeStatsFor(may); + + // every cold row of recX predates the tombstone → "not found" + const tombstones = new Map([['recX', '2026-06-01T00:00:00.000Z']]); + const found = await readService.lookupArchivedRowsByRecordIds({ + tableId, + reason, + recordIds: ['recX'], + isTombstoned: (recordId, removedTime) => isTombstonedAt(tombstones, recordId, removedTime), + }); + expect(found.size).toBe(0); + + // all-or-nothing under the budget: a stalled metadata read throws + // instead of returning a partial (possibly stale) result + const stalledStorage = new Proxy(storage, { + get(target, prop, receiver) { + if (prop === 'listMonths') { + return async (...args: [string, ColdRemovalReason]) => { + await new Promise((resolve) => setTimeout(resolve, 600)); + return target.listMonths(...args); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + const stalledService = new RecordRemovalColdReadService(stalledStorage as never); + await expect( + stalledService.lookupArchivedRowsByRecordIds({ + tableId, + reason, + recordIds: ['recX'], + deadlineMs: 400, + }) + ).rejects.toBeInstanceOf(ServiceUnavailableException); + }); + }); + + describe('tombstones', () => { + interface IFakeTombstoneRow { + id: string; + tableId: string; + recordId: string; + type: string; + createdTime: Date; + } + + // fake of the prisma recordRemovalTombstone delegate surface the service uses + class FakeTombstoneDb { + rows: IFakeTombstoneRow[] = []; + + client = { + recordRemovalTombstone: { + createMany: async ({ data }: { data: Omit[] }) => { + for (const row of data) { + this.rows.push({ createdTime: new Date(), ...row }); + } + return { count: data.length }; + }, + findMany: async ({ where }: { where: { tableId: string } }) => + this.rows + .filter((row) => row.tableId === where.tableId) + .map(({ recordId, createdTime }) => ({ recordId, createdTime })), + }, + }; + } + + it('marks write prefixed rows and the load keeps the newest time per record', async () => { + const db = new FakeTombstoneDb(); + const service = new RecordRemovalTombstoneService(); + + await service.markRestored(db.client as never, 'tblT', ['recA', 'recB']); + await service.markPurged(db.client as never, 'tblT', ['recB']); + await service.markPurged(db.client as never, 'tblT', []); // no-op, no empty createMany + expect(db.rows).toHaveLength(3); + expect(db.rows.every((row) => /^rmt[0-9a-zA-Z]{16}$/.test(row.id))).toBe(true); + expect(db.rows.map((row) => row.type)).toEqual(['restored', 'restored', 'purged']); + + // load keeps the LATEST tombstone per record and stays table-scoped + db.rows[0].createdTime = new Date('2026-07-01T00:00:00.000Z'); // recA restored + db.rows[1].createdTime = new Date('2026-07-01T00:00:00.000Z'); // recB restored + db.rows[2].createdTime = new Date('2026-07-05T00:00:00.000Z'); // recB purged later + db.rows.push({ + id: 'rmtOtherTable0000000', + tableId: 'tblOther', + recordId: 'recC', + type: 'purged', + createdTime: new Date(), + }); + const map = await service.loadTombstonedRecordIds(db.client as never, 'tblT'); + expect(map.get('recA')).toBe('2026-07-01T00:00:00.000Z'); + expect(map.get('recB')).toBe('2026-07-05T00:00:00.000Z'); + expect(map.has('recC')).toBe(false); + + // the time-qualified rule: only rows REMOVED BEFORE the tombstone are hidden + expect(isTombstonedAt(map, 'recA', '2026-06-30T00:00:00.000Z')).toBe(true); + expect(isTombstonedAt(map, 'recA', '2026-07-01T00:00:00.000Z')).toBe(true); // boundary inclusive + expect(isTombstonedAt(map, 'recA', '2026-07-02T00:00:00.000Z')).toBe(false); + expect(isTombstonedAt(map, 'recUnknown', '2026-01-01T00:00:00.000Z')).toBe(false); + }); + + it('compaction physically drops tombstoned rows and leaves the tombstones in place', async () => { + const tombstoneDb = new FakeTombstoneDb(); + const compactor = new RecordRemovalCompactorService( + storage, + { dataPrismaForTable: async () => tombstoneDb.client } as never, + new RecordRemovalTombstoneService() + ); + await seedParts(storage, 'tblC', 'archived', { yyyymm: '202605', kind: 'day', dd: '10' }, [ + makeRow({ id: 'rmsG1', recordId: 'recGone', removedTime: '2026-05-10T01:00:00.000Z' }), + makeRow({ id: 'rmsS1', recordId: 'recStay', removedTime: '2026-05-10T02:00:00.000Z' }), + ]); + await seedParts(storage, 'tblC', 'archived', { yyyymm: '202605', kind: 'day', dd: '20' }, [ + makeRow({ id: 'rmsG2', recordId: 'recGone', removedTime: '2026-05-20T01:00:00.000Z' }), + // re-archived AFTER its purge tombstone: the newer row is live data + makeRow({ id: 'rmsB1', recordId: 'recBack', removedTime: '2026-05-25T00:00:00.000Z' }), + ]); + tombstoneDb.rows.push( + { + id: 'rmtGone000000000000', + tableId: 'tblC', + recordId: 'recGone', + type: 'restored', + createdTime: new Date('2026-06-01T00:00:00.000Z'), + }, + { + id: 'rmtBack000000000000', + tableId: 'tblC', + recordId: 'recBack', + type: 'purged', + createdTime: new Date('2026-05-24T00:00:00.000Z'), + } + ); + + const result = await compactor.compactMonth('tblC', 'archived', '202605'); + expect(result.tombstonedRows).toBe(2); + expect(result.rows).toBe(2); + + // the rewritten month parts hold only the surviving rows; day parts healed away + const parts = await storage.listMonthParts('tblC', 'archived', '202605'); + expect(parts.every((part) => part.kind === 'month')).toBe(true); + const decoded = await decodeParts( + storage, + parts.map((part) => part.key) + ); + expect(decoded.map((row) => row.id)).toEqual(['rmsB1', 'rmsS1']); + + // stats (and the bloom) rebuilt without the dropped record + const stats = await storage.readStats('tblC', 'archived'); + expect(Object.keys(stats!.parts).sort()).toEqual(parts.map((part) => part.key).sort()); + const bloom = stats!.parts[parts[0].key].recordBloom!; + expect(bloomMightContain(bloom, 'recStay')).toBe(true); + expect(bloomMightContain(bloom, 'recGone')).toBe(false); + + // tombstones are NOT GC'd here: day parts of the current month or other + // months may still hold copies — GC needs an "all parts confirmed clean" + // check, deferred + expect(tombstoneDb.rows).toHaveLength(2); + }); + + it('an unreachable data db compacts without the drop (fail open)', async () => { + const compactor = new RecordRemovalCompactorService( + storage, + { + dataPrismaForTable: async () => { + throw new Error('tenant binding down'); + }, + } as never, + new RecordRemovalTombstoneService() + ); + await seedParts(storage, 'tblC2', 'archived', { yyyymm: '202605', kind: 'day', dd: '10' }, [ + makeRow({ id: 'rmsF1', recordId: 'recF', removedTime: '2026-05-10T01:00:00.000Z' }), + ]); + + const result = await compactor.compactMonth('tblC2', 'archived', '202605'); + expect(result.tombstonedRows).toBe(0); + expect(result.rows).toBe(1); + expect(result.outputParts).toBe(1); + }); + }); + + describe('flusher', () => { + interface IFakeTrashRow { + id: string; + tableId: string; + recordId: string; + snapshot: string; + reason: ColdRemovalReason; + createdTime: Date; + createdBy: string; + operationId?: string; + recordCreatedTime?: Date; + recordCreatedBy?: string; + recordLastModifiedTime?: Date; + recordLastModifiedBy?: string; + } + + const trashRow = ( + overrides: Partial & + Pick + ): IFakeTrashRow => ({ + recordId: 'recA', + snapshot: JSON.stringify({ id: 'recA', fields: { fldA: 'v' } }), + createdBy: 'usr1', + ...overrides, + }); + + class FakeTrashDb { + rows: IFakeTrashRow[] = []; + // one-shot hook before the reconcile count (straggler injection) + onReconcileCount?: () => void; + + insert(row: IFakeTrashRow) { + this.rows.push(row); + } + + countFor(tableId: string, reason: string, cutoff: Date): number { + return this.rows.filter( + (r) => r.tableId === tableId && r.reason === reason && r.createdTime < cutoff + ).length; + } + + deleteFor(tableId: string, reason: string, cutoff: Date): number { + const before = this.rows.length; + this.rows = this.rows.filter( + (r) => !(r.tableId === tableId && r.reason === reason && r.createdTime < cutoff) + ); + return before - this.rows.length; + } + } + + // mini interpreters for the flusher's raw queries against the fake buffer; + // JS Date/ordinal-string compares match the COLLATE "C" + UTC semantics + // the SQL pins + const makeFlusherHarness = ( + db: FakeTrashDb, + opts: { + liveTables?: { id: string; binding?: { mode: string; state: string } | null }[]; + } = {} + ) => { + const orphanDeletes: { sql: string; params: unknown[] }[] = []; + const prismaService = { + tableMeta: { + findMany: async ({ where }: any) => { + const ids: string[] = where.id.in; + return (opts.liveTables ?? []) + .filter((table) => ids.includes(table.id)) + .map((table) => ({ + id: table.id, + base: { space: { dataDbBinding: table.binding ?? null } }, + })); + }, + }, + spaceDataDbBinding: { + findMany: async () => [], + updateMany: async () => ({ count: 0 }), + }, + }; + const metaFallbackDataPrismaService = { + // the recursive-CTE distinct table listing + $queryRawUnsafe: async () => + [...new Set(db.rows.map((r) => r.tableId))].sort().map((tableId) => ({ tableId })), + // the orphan sweep delete + $executeRawUnsafe: async (sql: string, ...params: unknown[]) => { + orphanDeletes.push({ sql, params }); + const [tableIds, cutoff] = params as [string[], Date]; + const before = db.rows.length; + db.rows = db.rows.filter( + (r) => !(tableIds.includes(r.tableId) && r.createdTime < cutoff) + ); + return before - db.rows.length; + }, + }; + const dataDbClientManager = { + getDataDatabaseUrlForTable: async () => + 'postgresql://user:pass@localhost:5432/teable?schema=public', + dataPrismaForTable: async () => ({ + // snapshot-consistent delete: count latch + delete in one "transaction" + $transaction: async (fn: (tx: any) => Promise) => + fn({ + $queryRawUnsafe: async (_sql: string, ...params: unknown[]) => { + const [tableId, reason, cutoff] = params as [string, string, Date]; + return [{ count: db.countFor(tableId, reason, cutoff) }]; + }, + $executeRawUnsafe: async (_sql: string, ...params: unknown[]) => { + const [tableId, reason, cutoff] = params as [string, string, Date]; + return db.deleteFor(tableId, reason, cutoff); + }, + }), + }), + }; + const databaseRouter = { + queryDataPrismaForTable: async (_tableId: string, sql: string, ...params: unknown[]) => { + if (sql.includes('GROUP BY')) { + // planBucketCoverage: per-bucket count + created_time bounds + const [tableId, reason, cutoff, dayWindowStart] = params as [ + string, + string, + Date, + Date, + ]; + const groups = new Map< + string, + { yyyymm: string; dd: string | null; count: number; min: Date; max: Date } + >(); + for (const r of db.rows) { + if (r.tableId !== tableId || r.reason !== reason || !(r.createdTime < cutoff)) { + continue; + } + const yyyymm = `${r.createdTime.getUTCFullYear()}${String( + r.createdTime.getUTCMonth() + 1 + ).padStart(2, '0')}`; + const dd = + r.createdTime >= dayWindowStart + ? String(r.createdTime.getUTCDate()).padStart(2, '0') + : null; + const key = `${yyyymm}/${dd ?? 'm'}`; + const group = groups.get(key) ?? { + yyyymm, + dd, + count: 0, + min: r.createdTime, + max: r.createdTime, + }; + group.count += 1; + if (r.createdTime < group.min) group.min = r.createdTime; + if (r.createdTime > group.max) group.max = r.createdTime; + groups.set(key, group); + } + return [...groups.values()].map((g) => ({ ...g, count: String(g.count) })); + } + if (sql.includes('count(*)')) { + // the reconcile pre-check count + db.onReconcileCount?.(); + db.onReconcileCount = undefined; + const [tableId, reason, cutoff] = params as [string, string, Date]; + return [{ count: String(db.countFor(tableId, reason, cutoff)) }]; + } + throw new Error(`unhandled queryDataPrismaForTable sql: ${sql}`); + }, + dataKnexForTable: async () => ({ + // the keyset buffer read on the native pg client (? binds, UTC naive + // timestamp strings both ways) + raw: async (sql: string, bindings: unknown[]) => { + let i = 0; + const next = () => bindings[i++]; + const tableId = next() as string; + const reason = next() as string; + const cutoff = new Date(`${next()}Z`); + const rangeCount = (sql.match(/"created_time" >= \?/g) ?? []).length; + const ranges = Array.from({ length: rangeCount }, () => ({ + lo: new Date(`${next()}Z`), + hi: new Date(`${next()}Z`), + })); + let after: { t: number; id: string } | undefined; + if (sql.includes('COLLATE "C") > (')) { + after = { t: new Date(`${next()}Z`).getTime(), id: next() as string }; + } + const limit = Number(/LIMIT (\d+)/.exec(sql)![1]); + const selected = db.rows + .filter((r) => { + if (r.tableId !== tableId || r.reason !== reason) return false; + if (!(r.createdTime < cutoff)) return false; + if ( + ranges.length > 0 && + !ranges.some((range) => r.createdTime >= range.lo && r.createdTime < range.hi) + ) { + return false; + } + if (after) { + const t = r.createdTime.getTime(); + if (t < after.t || (t === after.t && r.id <= after.id)) return false; + } + return true; + }) + .sort((a, b) => { + const delta = a.createdTime.getTime() - b.createdTime.getTime(); + if (delta !== 0) return delta; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }) + .slice(0, limit); + return { + rows: selected.map((r) => ({ + id: r.id, + recordId: r.recordId, + snapshot: r.snapshot, + createdTime: r.createdTime.toISOString(), + createdBy: r.createdBy, + operationId: r.operationId ?? null, + recordCreatedTime: r.recordCreatedTime?.toISOString() ?? null, + recordCreatedBy: r.recordCreatedBy ?? null, + recordLastModifiedTime: r.recordLastModifiedTime?.toISOString() ?? null, + recordLastModifiedBy: r.recordLastModifiedBy ?? null, + })), + }; + }, + }), + }; + const flusher = new RecordRemovalFlusherService( + prismaService as any, + metaFallbackDataPrismaService as any, + dataDbClientManager as any, + databaseRouter as any, + storage + ); + return { flusher, orphanDeletes }; + }; + + it('flushes rows past each reason horizon while young rows stay buffered', async () => { + const now = Date.now(); + const db = new FakeTrashDb(); + db.insert( + trashRow({ + id: 'trsArchOld', + tableId: 'tblA', + reason: 'archived', + createdTime: new Date(now - 60 * DAY_MS), + }) + ); + db.insert( + trashRow({ + id: 'trsArchYoung', + tableId: 'tblA', + reason: 'archived', + createdTime: new Date(now - 1 * DAY_MS), + }) + ); + db.insert( + trashRow({ + id: 'trsDelYoung', + tableId: 'tblA', + reason: 'deleted', + createdTime: new Date(now - 1 * DAY_MS), + }) + ); + db.insert( + trashRow({ + id: 'trsDelOld', + tableId: 'tblA', + reason: 'deleted', + createdTime: new Date(now - 100 * DAY_MS), + }) + ); + const { flusher } = makeFlusherHarness(db, { liveTables: [{ id: 'tblA' }] }); + + const result = await flusher.runFlush({ mode: 'incremental' }); + + // per-reason horizons, both 30d by default (recycle-bin reads merge PG + S3 + // exactly like the archive UI) + const started = new Date(result.startedAt).getTime(); + expect(started - new Date(result.cutoffs.archived).getTime()).toBe(30 * DAY_MS); + expect(started - new Date(result.cutoffs.deleted).getTime()).toBe(30 * DAY_MS); + + const archived = result.tables.find((t) => t.reason === 'archived')!; + const deleted = result.tables.find((t) => t.reason === 'deleted')!; + expect(archived).toMatchObject({ tableId: 'tblA', rows: 1, deletedRows: 1 }); + expect(deleted).toMatchObject({ tableId: 'tblA', rows: 1, deletedRows: 1 }); + + // the young side of each horizon survives in the buffer + expect(db.rows.map((r) => r.id).sort()).toEqual(['trsArchYoung', 'trsDelYoung']); + + // each reason landed under its own prefix, with its own stats file + const parts = [...fake.objects.keys()] + .map((key) => parsePartKey(ROOT, key)) + .filter((part): part is IParsedPartKey => Boolean(part)); + const archivedKeys = parts.filter((p) => p.reason === 'archived').map((p) => p.key); + const deletedKeys = parts.filter((p) => p.reason === 'deleted').map((p) => p.key); + expect((await decodeParts(storage, archivedKeys)).map((r) => r.id)).toEqual(['trsArchOld']); + expect((await decodeParts(storage, deletedKeys)).map((r) => r.id)).toEqual(['trsDelOld']); + expect(fake.objects.has(statsKey(ROOT, 'tblA', 'archived'))).toBe(true); + expect(fake.objects.has(statsKey(ROOT, 'tblA', 'deleted'))).toBe(true); + }); + + it('expands each table into independent (table, reason) work items', async () => { + const now = Date.now(); + const db = new FakeTrashDb(); + // tblB has ONLY archived rows; the deleted-reason item still runs (and + // reports zero) instead of being silently dropped + db.insert( + trashRow({ + id: 'trsB1', + tableId: 'tblB', + reason: 'archived', + createdTime: new Date(now - 90 * DAY_MS), + }) + ); + const { flusher } = makeFlusherHarness(db, { liveTables: [{ id: 'tblB' }] }); + + const result = await flusher.runFlush({ mode: 'incremental' }); + + expect(result.tables.map((t) => `${t.tableId}/${t.reason}`).sort()).toEqual([ + 'tblB/archived', + 'tblB/deleted', + ]); + const idle = result.tables.find((t) => t.reason === 'deleted')!; + expect(idle).toMatchObject({ rows: 0, parts: 0, deletedRows: 0 }); + expect(idle.error).toBeUndefined(); + expect(result.tables.find((t) => t.reason === 'archived')!.rows).toBe(1); + }); + + it('a count-latch mismatch defers the delete instead of losing the straggler', async () => { + const now = Date.now(); + const cutoff = new Date(now - 30 * DAY_MS); + const db = new FakeTrashDb(); + db.insert( + trashRow({ + id: 'trs01', + tableId: 'tblL', + reason: 'archived', + createdTime: new Date(now - 60 * DAY_MS), + }) + ); + db.insert( + trashRow({ + id: 'trs02', + tableId: 'tblL', + reason: 'archived', + createdTime: new Date(now - 59 * DAY_MS), + }) + ); + const { flusher } = makeFlusherHarness(db); + // a straggler write lands BELOW the cutoff between the stream and the count + db.onReconcileCount = () => { + db.insert( + trashRow({ + id: 'trs00straggler', + tableId: 'tblL', + reason: 'archived', + createdTime: new Date(now - 45 * DAY_MS), + }) + ); + }; + + const result = await flusher.flushTable('tblL', 'archived', cutoff, 'incremental', true); + + expect(result.rows).toBe(2); + expect(result.deletedRows).toBe(0); + expect(result.deleteSkippedReason).toContain('count-mismatch'); + // nothing was deleted — the straggler is re-flushed by the next run + expect(db.rows).toHaveLength(3); + }); + + it('the coverage plan skips fully-persisted buckets and only reconciles + deletes', async () => { + const now = Date.now(); + const cutoff = new Date(now - 40 * DAY_MS); + const db = new FakeTrashDb(); + for (let i = 0; i < 3; i++) { + db.insert( + trashRow({ + id: `trsCov${i}`, + tableId: 'tblCov', + reason: 'archived', + createdTime: new Date(now - 100 * DAY_MS + i * 60 * 60 * 1000), + }) + ); + } + const { flusher } = makeFlusherHarness(db); + + // run 1: upload-only (delete gate off) — parts + stats land, buffer intact + const run1 = await flusher.flushTable('tblCov', 'archived', cutoff, 'incremental', false); + expect(run1.rows).toBe(3); + expect(run1.parts).toBeGreaterThan(0); + expect(run1.deletedRows).toBe(0); + expect(db.rows).toHaveLength(3); + const keysAfterRun1 = [...fake.objects.keys()].sort(); + + // run 2 (delete-enabled): the buckets are already fully persisted, so + // nothing streams or uploads — the run only reconciles and deletes + const run2 = await flusher.flushTable('tblCov', 'archived', cutoff, 'incremental', true); + expect(run2.rows).toBe(0); + expect(run2.parts).toBe(0); + expect(run2.reconciledRows).toBe(3); + expect(run2.deletedRows).toBe(3); + expect([...fake.objects.keys()].sort()).toEqual(keysAfterRun1); // no rewrite + expect(db.rows).toHaveLength(0); + }); + + it('the orphan sweep clears hard-deleted tables and spares live and byodb-routed ones', async () => { + const now = Date.now(); + const db = new FakeTrashDb(); + db.insert( + trashRow({ + id: 'trsLive', + tableId: 'tblLive', + reason: 'archived', + createdTime: new Date(now - 90 * DAY_MS), + }) + ); + db.insert( + trashRow({ + id: 'trsOrphan', + tableId: 'tblOrphan', + reason: 'deleted', + createdTime: new Date(now - 90 * DAY_MS), + }) + ); + db.insert( + trashRow({ + id: 'trsByodb', + tableId: 'tblByodb', + reason: 'archived', + createdTime: new Date(now - 90 * DAY_MS), + }) + ); + const { flusher, orphanDeletes } = makeFlusherHarness(db, { + liveTables: [ + { id: 'tblLive' }, + { id: 'tblByodb', binding: { mode: 'byodb', state: 'ready' } }, + ], + }); + const cutoffs = { + archived: new Date(now - 30 * DAY_MS), + deleted: new Date(now - 1200 * DAY_MS), + }; + const orphanCleanup = { enabled: true, deletedRows: 0 }; + + const groups = await (flusher as any).discoverGroups( + { mode: 'incremental' }, + cutoffs, + orphanCleanup + ); + + // only the live shared table is flushed; the byodb-routed one is served + // elsewhere and the orphan appears in no group + expect(groups).toEqual([{ kind: 'shared', tableIds: ['tblLive'] }]); + // exactly one delete, scoped to the orphan id, bounded by the ARCHIVED + // (newer) cutoff, both reasons at once + expect(orphanDeletes).toHaveLength(1); + expect(orphanDeletes[0].sql).toContain('DELETE FROM "record_trash"'); + expect(orphanDeletes[0].params[0]).toEqual(['tblOrphan']); + expect(orphanDeletes[0].params[1]).toBe(cutoffs.archived); + expect(orphanCleanup.deletedRows).toBe(1); + expect(db.rows.map((r) => r.tableId).sort()).toEqual(['tblByodb', 'tblLive']); + }); + + describe('deep-read assertions', () => { + // `count` archived buffer rows spanning ~4 months of removedTimes: the + // young side lands in day buckets, the old side in month buckets, + // groups of 3 share a removedTime (the id byte-order tiebreak lands on + // many page boundaries) and ids mix cases (byte order ≠ a ci collation) + const seedDeepArchivedRows = (db: FakeTrashDb, tableId: string, count: number) => { + const now = Date.now(); + for (let i = 0; i < count; i++) { + const suffix = String(i).padStart(5, '0'); + db.insert( + trashRow({ + id: `rms${i % 2 === 0 ? 'A' : 'a'}${suffix}`, + tableId, + reason: 'archived', + createdTime: new Date(now - 2 * DAY_MS - Math.floor(i / 3) * 3 * 60 * 60 * 1000), + recordId: `rec${suffix}`, + snapshot: JSON.stringify({ + id: `rec${suffix}`, + fields: { fldA: `值-ünïq-${suffix}` }, + }), + }) + ); + } + }; + + // the canonical serving order the parts are written in: removedTime + // DESC, id DESC in byte order + const expectedServingIds = (rows: { id: string; createdTime: Date }[]) => + rows + .map((row) => ({ id: row.id, removedTime: row.createdTime.toISOString() })) + .sort(compareRemovalRowDesc) + .map((row) => row.id); + + // page the cold archive top-to-bottom, decoding each rms1: cursor into + // the next page's boundary exactly like the EE seam does + const pageThroughArchived = async (tableId: string, limit: number) => { + const readService = new RecordRemovalColdReadService(storage); + const rows: IColdRemovalRow[] = []; + let pages = 0; + let boundary: { k: string; id: string } | undefined; + for (;;) { + const page = await readService.collectArchivedRows({ + tableId, + reason: 'archived', + limit, + orderBy: 'removedTime', + direction: 'desc', + boundary, + seenIds: new Set(), + }); + pages += 1; + rows.push(...page.rows); + if (!page.nextCursor) return { rows, pages }; + const decoded = decodeRemovalColdCursor(page.nextCursor)?.boundary; + if (!decoded) throw new Error(`page ${pages} handed back a boundary-less cursor`); + boundary = decoded; + if (pages > 1000) throw new Error('cursor traversal did not converge'); + } + }; + + it('100-page deep cursor traversal is duplicate-free and gap-free over 3000 rows', async () => { + const db = new FakeTrashDb(); + seedDeepArchivedRows(db, 'tblDeep', 3000); + const inserted = [...db.rows]; + const { flusher } = makeFlusherHarness(db, { liveTables: [{ id: 'tblDeep' }] }); + + const result = await flusher.runFlush({ + mode: 'incremental', + archiveHorizonMs: 60 * 60 * 1000, + }); + const archived = result.tables.find((t) => t.reason === 'archived')!; + expect(archived).toMatchObject({ tableId: 'tblDeep', rows: 3000, deletedRows: 3000 }); + expect(db.rows).toHaveLength(0); + // several months and many parts: pages cross bucket/part seams constantly + expect((await storage.listMonths('tblDeep', 'archived')).length).toBeGreaterThanOrEqual(4); + expect(result.totalParts).toBeGreaterThanOrEqual(10); + + const { rows, pages } = await pageThroughArchived('tblDeep', 30); + expect(pages).toBe(100); + const ids = rows.map((row) => row.id); + expect(new Set(ids).size).toBe(3000); // no duplicates + // exact total order end to end — no gaps, no reordering anywhere in + // the 100-page traversal + expect(ids).toEqual(expectedServingIds(inserted)); + }); + + it('point lookups return rows byte-identical to what deep paging surfaces', async () => { + const db = new FakeTrashDb(); + seedDeepArchivedRows(db, 'tblBytes', 900); + const { flusher } = makeFlusherHarness(db, { liveTables: [{ id: 'tblBytes' }] }); + await flusher.runFlush({ mode: 'incremental', archiveHorizonMs: 60 * 60 * 1000 }); + + const { rows } = await pageThroughArchived('tblBytes', 100); + expect(rows).toHaveLength(900); + const byRecordId = new Map(rows.map((row) => [row.recordId, row])); + + // sample across the whole range: both ends, the middle, tie-group mates + const sample = [0, 1, 2, 449, 450, 451, 897, 898, 899].map( + (i) => `rec${String(i).padStart(5, '0')}` + ); + const readService = new RecordRemovalColdReadService(storage); + const found = await readService.lookupArchivedRowsByRecordIds({ + tableId: 'tblBytes', + reason: 'archived', + recordIds: sample, + }); + expect(found.size).toBe(sample.length); + for (const recordId of sample) { + const paged = byRecordId.get(recordId)!; + const looked = found.get(recordId)!; + // byte-identical snapshot across the two entry points… + expect(looked.snapshot).toBe(paged.snapshot); + // …and the whole row agrees field for field + expect(looked).toEqual(paged); + } + }); + + it('a second flush into the same bucket folds A∪B losslessly and heals superseded parts', async () => { + const now = Date.now(); + const cutoff = new Date(now - DAY_MS); + const dayBase = new Date(now - 5 * DAY_MS); + dayBase.setUTCHours(2, 0, 0, 0); + const at = (minute: number) => new Date(dayBase.getTime() + minute * 60_000); + // small parts force each flush to cut several files in the ONE bucket + const smallParts = { ...recordRemovalColdConfig(), partUncompressedBytes: 2048 }; + const db = new FakeTrashDb(); + const { flusher } = makeFlusherHarness(db); + const insertBatch = (batch: 'A' | 'B') => { + for (let i = 0; i < 40; i++) { + const suffix = String(i).padStart(3, '0'); + db.insert( + trashRow({ + id: `rms${batch}${suffix}`, + tableId: 'tblTwice', + reason: 'archived', + // even B rows TIE an A row's removedTime exactly; odd ones + // interleave between A rows (and push the bucket max past A's) + createdTime: batch === 'A' || i % 2 === 0 ? at(i * 2) : at(i * 2 + 1), + recordId: `rec${batch}${suffix}`, + snapshot: JSON.stringify({ + id: `rec${batch}${suffix}`, + fields: { fldA: `${batch}-${suffix}-${'x'.repeat(120)}` }, + }), + }) + ); + } + }; + + insertBatch('A'); + const inserted = [...db.rows]; + const run1 = await flusher.flushTable( + 'tblTwice', + 'archived', + cutoff, + 'incremental', + true, + smallParts + ); + expect(run1).toMatchObject({ rows: 40, deletedRows: 40 }); + expect(run1.parts).toBeGreaterThan(1); + const partKeysA = [...fake.objects.keys()].filter((key) => parsePartKey(ROOT, key)); + expect(partKeysA).toHaveLength(run1.parts); + + insertBatch('B'); + inserted.push(...db.rows); + const run2 = await flusher.flushTable( + 'tblTwice', + 'archived', + cutoff, + 'incremental', + true, + smallParts + ); + // the bucket was NOT judged covered (B changed its aggregate): the + // whole bucket re-streamed, folding A's parts through the feeder + expect(run2).toMatchObject({ rows: 40, deletedRows: 40, reconciledRows: 0 }); + expect(db.rows).toHaveLength(0); + + // every superseded first-run key healed away; stats track exactly the + // live keys of the single (yyyymm, dd) bucket + for (const key of partKeysA) { + expect(fake.objects.has(key)).toBe(false); + } + const yyyymm = `${dayBase.getUTCFullYear()}${String(dayBase.getUTCMonth() + 1).padStart(2, '0')}`; + const dd = String(dayBase.getUTCDate()).padStart(2, '0'); + const liveParts = await storage.listMonthParts('tblTwice', 'archived', yyyymm); + expect(liveParts.every((part) => part.kind === 'day' && part.dd === dd)).toBe(true); + const stats = await storage.readStats('tblTwice', 'archived'); + expect(Object.keys(stats!.parts).sort()).toEqual(liveParts.map((p) => p.key).sort()); + + // A∪B exactly once each, in canonical order, via a full page-through + const { rows } = await pageThroughArchived('tblTwice', 7); + expect(new Set(rows.map((row) => row.id)).size).toBe(80); + expect(rows.map((row) => row.id)).toEqual(expectedServingIds(inserted)); + }); + }); + }); + + describe('cold maintenance processor', () => { + class FakeColdQueue { + jobs: { id?: string; name: string; data: unknown; state: string; opts?: unknown }[] = []; + schedulers: { key: string }[] = []; + + async upsertJobScheduler(key: string) { + if (!this.schedulers.some((scheduler) => scheduler.key === key)) { + this.schedulers.push({ key }); + } + } + + async getJobs(states: string[]) { + return this.jobs.filter((job) => states.includes(job.state)); + } + + async add(name: string, data: unknown, opts?: { jobId?: string }) { + // mirrors BullMQ's custom-id validation — the exact rule the first + // record-history catch-up chain tripped over in production + if (opts?.jobId?.includes(':')) { + throw new Error('Custom Id cannot contain :'); + } + // mirrors BullMQ's dedupe: a custom id matching ANY still-stored job + // returns the EXISTING job instead of adding + const existing = opts?.jobId && this.jobs.find((job) => job.id === opts.jobId); + if (existing) { + return existing; + } + const job = { id: opts?.jobId, name, data, state: 'delayed', opts }; + this.jobs.push(job); + return job; + } + } + + const makeProcessor = ( + queue: FakeColdQueue, + flushResult: Partial = {}, + runFlushCalls?: unknown[] + ) => { + const flusher = { + runFlush: async (options: unknown): Promise => { + runFlushCalls?.push(options); + return { + startedAt: '2026-07-17T00:00:00.000Z', + cutoffs: { + archived: '2026-06-17T00:00:00.000Z', + deleted: '2023-04-04T00:00:00.000Z', + }, + mode: 'incremental', + tables: [], + totalRows: 0, + totalParts: 0, + totalCompressedBytes: 0, + totalTruncatedRows: 0, + orphanRowsDeleted: 0, + durationMs: 1, + leftoverTables: 0, + budgetExhausted: false, + ...flushResult, + }; + }, + }; + return new RecordRemovalColdProcessor( + flusher as never, + {} as never, + {} as never, + queue as never + ); + }; + + beforeEach(() => { + delete process.env.BACKEND_STORAGE_COLD_ARCHIVE_DISABLED; + }); + + afterEach(() => { + delete process.env.BACKEND_STORAGE_COLD_ARCHIVE_DISABLED; + }); + + it('chains a catch-up job with a colon-free id when the budget is exhausted', async () => { + const queue = new FakeColdQueue(); + const processor = makeProcessor(queue, { budgetExhausted: true, leftoverTables: 3 }); + + await processor.process({ name: 'record-removal-cold:flush', data: {} } as any); + + const chained = queue.jobs.filter((job) => + job.id?.startsWith('record-removal-cold-flush-catchup') + ); + expect(chained).toHaveLength(1); + expect(chained[0].id).toBe('record-removal-cold-flush-catchup-1'); + expect(chained[0].data).toEqual({ catchupHop: 1 }); + }); + + it('increments the hop id along the chain', async () => { + const queue = new FakeColdQueue(); + const processor = makeProcessor(queue, { budgetExhausted: true }); + + await processor.process({ + id: 'record-removal-cold-flush-catchup-4', + name: 'record-removal-cold:flush', + data: { catchupHop: 4 }, + } as any); + + expect(queue.jobs.map((job) => job.id)).toEqual(['record-removal-cold-flush-catchup-5']); + }); + + it('registers both schedulers at bootstrap and queues nothing else', async () => { + const queue = new FakeColdQueue(); + await makeProcessor(queue).onApplicationBootstrap(); + expect(queue.schedulers.map((scheduler) => scheduler.key)).toEqual([ + 'record-removal-cold:flush', + 'record-removal-cold:compact', + ]); + // deliberately no boot-time kick (see the 2026-07-08 record-history stalls) + expect(queue.jobs).toHaveLength(0); + }); + + it('does not start a second chain while one is pending', async () => { + const queue = new FakeColdQueue(); + queue.jobs.push({ + id: 'record-removal-cold-flush-catchup-9', + name: 'record-removal-cold:flush', + data: { catchupHop: 9 }, + state: 'delayed', + }); + const processor = makeProcessor(queue, { budgetExhausted: true }); + + await processor.process({ name: 'record-removal-cold:flush', data: {} } as any); + + expect(queue.jobs).toHaveLength(1); + }); + + it('a kill-switched process skips cold jobs instead of consuming them', async () => { + process.env.BACKEND_STORAGE_COLD_ARCHIVE_DISABLED = 'true'; + const queue = new FakeColdQueue(); + const runFlushCalls: unknown[] = []; + const processor = makeProcessor(queue, { budgetExhausted: true }, runFlushCalls); + + const result = await processor.process({ + name: 'record-removal-cold:flush', + data: {}, + } as any); + + expect(result).toBeUndefined(); + expect(runFlushCalls).toHaveLength(0); + expect(queue.jobs).toHaveLength(0); // no catch-up chained either + }); + + it('a kill-switched process pauses its worker and registers no schedulers', async () => { + process.env.BACKEND_STORAGE_COLD_ARCHIVE_DISABLED = 'true'; + const queue = new FakeColdQueue(); + const processor = makeProcessor(queue); + const paused: boolean[] = []; + // WorkerHost's `worker` getter reads _worker (set by the Bull explorer in + // a real process); the pause(true) path is what a kill-switched pod takes + (processor as any)._worker = { + pause: async (force: boolean) => { + paused.push(force); + }, + }; + + await processor.onApplicationBootstrap(); + + expect(paused).toEqual([true]); + expect(queue.schedulers).toEqual([]); + }); + }); +}); diff --git a/apps/nestjs-backend/src/features/record-removal-cold/record-removal-compactor.service.ts b/apps/nestjs-backend/src/features/record-removal-cold/record-removal-compactor.service.ts new file mode 100644 index 0000000000..90dc91e777 --- /dev/null +++ b/apps/nestjs-backend/src/features/record-removal-cold/record-removal-compactor.service.ts @@ -0,0 +1,223 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataDbClientManager } from '../../global/data-db-client-manager.service'; +import { ExternalRowSorter, SortMemoryBudget } from './external-sort'; +import { COLD_REMOVAL_REASONS, truncateRemovalRow } from './part-codec'; +import type { ColdRemovalReason, IParsedPartKey, ITableColdStats } from './part-codec'; +import { PartWriter } from './part-writer'; +import { RecordRemovalColdStorageService } from './record-removal-cold-storage.service'; +import { recordRemovalColdConfig } from './record-removal-cold.config'; +import type { IRemovalTombstoneMap } from './record-removal-tombstone.service'; +import { isTombstonedAt, RecordRemovalTombstoneService } from './record-removal-tombstone.service'; + +export interface ICompactMonthResult { + tableId: string; + reason: ColdRemovalReason; + yyyymm: string; + inputParts: number; + outputParts: number; + rows: number; + // tombstoned rows physically dropped from the rewritten month parts + tombstonedRows: number; + skippedReason?: string; + durationMs: number; +} + +// Merges the day parts of one (table, reason, month) — plus any existing +// month parts, so late flushes after a previous compaction fold in — into +// fresh month parts, deduplicated by row id and canonically ordered via an +// external sort. Input parts are read sequentially to EOF and NO input +// ordering is assumed, which also makes compaction the repair tool for parts +// written under a mismatched order. Idempotent: healing removes every key of +// the month not written by the final run, and the read path dedups by id +// during any transition window. +// +// Tombstone filtering: the month rewrite is where restored/purged rows get +// physically dropped from the parts (readers already filter them; this +// reclaims the bytes and rebuilds stats/bloom without them). Tombstone rows +// are NOT deleted afterwards — day parts of the current month or other months +// may still hold copies of the same record, and only the tombstone keeps them +// invisible. GC needs an "every part of the table confirmed clean" check; +// deferred (the tombstone table stays tiny, see the tombstone service). +@Injectable() +export class RecordRemovalCompactorService { + private readonly logger = new Logger(RecordRemovalCompactorService.name); + + constructor( + private readonly coldStorage: RecordRemovalColdStorageService, + private readonly dataDbClientManager: DataDbClientManager, + private readonly tombstoneService: RecordRemovalTombstoneService + ) {} + + // compact every closed month of a table, both reason prefixes; the current + // (still-hot) month is skipped + async compactTable(tableId: string): Promise { + const now = new Date(); + const currentMonth = `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, '0')}`; + const results: ICompactMonthResult[] = []; + for (const reason of COLD_REMOVAL_REASONS) { + const months = await this.coldStorage.listMonths(tableId, reason); + for (const yyyymm of months) { + if (yyyymm >= currentMonth) continue; + results.push(await this.compactMonth(tableId, reason, yyyymm)); + } + } + return results; + } + + async compactMonth( + tableId: string, + reason: ColdRemovalReason, + yyyymm: string, + options?: { force?: boolean } + ): Promise { + const startedAt = Date.now(); + const config = recordRemovalColdConfig(); + const parts = await this.coldStorage.listMonthParts(tableId, reason, yyyymm); + const dayParts = parts.filter((part) => part.kind === 'day'); + const monthParts = parts.filter((part) => part.kind === 'month'); + + const base: Omit = { + tableId, + reason, + yyyymm, + inputParts: parts.length, + outputParts: 0, + rows: 0, + tombstonedRows: 0, + durationMs: 0, + }; + if (dayParts.length === 0 && !options?.force) { + return { ...base, durationMs: Date.now() - startedAt, skippedReason: 'no-day-parts' }; + } + if (parts.length === 0) { + return { ...base, durationMs: Date.now() - startedAt, skippedReason: 'empty-month' }; + } + + const tombstones = await this.loadTombstones(tableId); + const inputs: IParsedPartKey[] = [...dayParts, ...monthParts]; + // never write the keys we are still reading (S3 GET vs same-key overwrite + // is unspecified): new month parts start past the existing max seq and + // healing drops the superseded keys afterwards + const startSeq = monthParts.reduce((max, part) => Math.max(max, part.seq + 1), 0); + const writer = new PartWriter({ + store: this.coldStorage.partStore, + rootDir: this.coldStorage.rootDir, + tableId, + reason, + bucket: { yyyymm, kind: 'month' }, + partUncompressedBytes: config.partUncompressedBytes, + startSeq, + }); + + const { rows, tombstonedRows } = await this.mergeInputs( + inputs, + writer, + tombstones, + new SortMemoryBudget(config.sortMemoryBudgetBytes), + config.sortMergeFanIn, + config.truncateFieldUnits, + config.truncateRowUnits + ); + const entries = await writer.finish(); + const writtenKeys = new Set(entries.map((entry) => entry.key)); + + // stats: replace exactly the consumed inputs with the fresh outputs; an + // entry for a part that landed after our input snapshot belongs to a + // concurrent run and stays intact + const inputKeys = new Set(inputs.map((input) => input.key)); + const stats: ITableColdStats = (await this.coldStorage.readStats(tableId, reason)) ?? { + version: 1, + tableId, + reason, + parts: {}, + }; + for (const key of Object.keys(stats.parts)) { + if (inputKeys.has(key)) delete stats.parts[key]; + } + for (const entry of entries) { + stats.parts[entry.key] = entry; + } + await this.coldStorage.writeStats(tableId, reason, stats); + + // heal: delete exactly what this run consumed and superseded — never a + // key that appeared after the input snapshot. A concurrent backfill or + // flush may have written it, and it can be the only cold copy of rows + // whose buffer entries that other run then deletes. + const staleKeys = inputs + .filter((input) => !writtenKeys.has(input.key)) + .map((input) => input.key); + await this.coldStorage.deleteKeys(staleKeys); + + this.logger.log( + `compacted ${tableId}/${reason}/${yyyymm}: ${inputs.length} part(s) -> ${entries.length}, rows=${rows}` + + (tombstonedRows ? `, tombstoned=${tombstonedRows} dropped` : '') + ); + return { + ...base, + outputParts: entries.length, + rows, + tombstonedRows, + durationMs: Date.now() - startedAt, + }; + } + + // Tombstones live in the table's data db (next to record_trash). Loading + // fails open to an empty map: dropping tombstoned rows is a space + // optimization — readers filter them regardless — so an unreachable tenant + // db (or a table hard-deleted with cold data left behind) must not fail the + // month merge; the next compaction retries the drop. + private async loadTombstones(tableId: string): Promise { + try { + const dataPrisma = await this.dataDbClientManager.dataPrismaForTable(tableId); + return await this.tombstoneService.loadTombstonedRecordIds(dataPrisma, tableId); + } catch (error) { + this.logger.warn( + `tombstone load failed for ${tableId}; compacting without the drop: ${error instanceof Error ? error.message : error}` + ); + return new Map(); + } + } + + // external sort + id-dedup: inputs are read one at a time, order-agnostic + private async mergeInputs( + inputs: IParsedPartKey[], + writer: PartWriter, + tombstones: IRemovalTombstoneMap, + sortBudget: SortMemoryBudget, + mergeFanIn: number, + truncateFieldUnits: number, + truncateRowUnits: number + ): Promise<{ rows: number; tombstonedRows: number }> { + // one sorter per month here (months compact serially), but a fat-row + // month can still out-weigh the 50k row cap — the byte budget bounds it + const sorter = new ExternalRowSorter(undefined, sortBudget, mergeFanIn); + let tombstonedRows = 0; + try { + for (const input of inputs) { + for await (const item of this.coldStorage.iterateRows(input.key)) { + if (!item.row) continue; + // physical tombstone drop: restored/purged rows never reach the + // rewritten parts, so stats/bloom rebuild without them for free + if (isTombstonedAt(tombstones, item.row.recordId, item.row.removedTime)) { + tombstonedRows += 1; + continue; + } + // heal legacy oversized snapshots as month parts are rewritten + await sorter.add( + truncateFieldUnits || truncateRowUnits + ? truncateRemovalRow(item.row, truncateFieldUnits, truncateRowUnits) + : item.row + ); + } + } + let rows = 0; + await sorter.drainTo(async (row) => { + await writer.add(row); + rows += 1; + }); + return { rows, tombstonedRows }; + } finally { + await sorter.cleanup(); + } + } +} diff --git a/apps/nestjs-backend/src/features/record-removal-cold/record-removal-flusher.service.ts b/apps/nestjs-backend/src/features/record-removal-cold/record-removal-flusher.service.ts new file mode 100644 index 0000000000..5d765c1c90 --- /dev/null +++ b/apps/nestjs-backend/src/features/record-removal-cold/record-removal-flusher.service.ts @@ -0,0 +1,1111 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataPrismaService } from '@teable/db-data-prisma'; +import { Prisma, PrismaService } from '@teable/db-main-prisma'; +import { DataDbClientManager } from '../../global/data-db-client-manager.service'; +import { DatabaseRouter } from '../../global/database-router.service'; +import { mapWithConcurrency } from '../../utils/map-with-concurrency'; +import { bucketRange, groupStatsByBucket, isBucketCovered } from '../cold-archive/bucket-coverage'; +import { nextReadBatchLimit, READ_BATCH_PROBE_ROWS } from '../cold-archive/read-batch'; +import { BucketMergeFeeder } from './bucket-merge-feeder'; +import { approxRemovalRowBytes, SortMemoryBudget } from './external-sort'; +import type { + ColdRemovalReason, + IColdRemovalRow, + IPartBucket, + IPartStatsEntry, + ITableColdStats, +} from './part-codec'; +import { + bucketId, + bucketOfDate, + COLD_REMOVAL_REASONS, + parsePartKey, + truncateRemovalRow, +} from './part-codec'; +import { PartWriter } from './part-writer'; +import { RecordRemovalColdStorageService } from './record-removal-cold-storage.service'; +import { recordRemovalColdConfig } from './record-removal-cold.config'; + +export interface IColdFlushOptions { + mode: 'incremental' | 'backfill'; + // override config gate; backfill runs are upload-only unless explicitly enabled + deleteEnabled?: boolean; + // override the reason='archived' flush horizon (ms before now) + archiveHorizonMs?: number; + // override the reason='deleted' flush horizon (ms before now) + deletedHorizonMs?: number; + // flush exactly these tables, skipping discovery + tableIds?: string[]; + // restrict discovery to these spaces + spaceIds?: string[]; + tableConcurrency?: number; + // skip the lastModifiedTime bookmark pruning during discovery + ignoreBookmarks?: boolean; + // override the soft per-run row budget (0 = unlimited) + maxRows?: number; +} + +export interface ITableFlushResult { + tableId: string; + reason: ColdRemovalReason; + rows: number; + parts: number; + uncompressedBytes: number; + compressedBytes: number; + deletedRows: number; + deleteSkippedReason?: string; + // rows already fully covered by existing parts — rewrite skipped + reconciledRows: number; + // rows whose snapshot was capped by truncateRemovalRow before upload + truncatedRows: number; + durationMs: number; + error?: string; +} + +export interface IColdFlushRunResult { + startedAt: string; + // per-reason cutoffs (both ≈ 30d by default; each independently overridable) + cutoffs: Record; + mode: 'incremental' | 'backfill'; + tables: ITableFlushResult[]; + totalRows: number; + totalParts: number; + totalCompressedBytes: number; + totalTruncatedRows: number; + // buffer rows of hard-deleted tables swept from the buffer this run + orphanRowsDeleted: number; + durationMs: number; + // (table, reason) units discovered but deferred to the next run by the row budget + leftoverTables: number; + budgetExhausted: boolean; +} + +interface IDiscoveredGroup { + kind: 'shared' | 'byodb'; + spaceId?: string; + bindingId?: string; + tableIds: string[]; +} + +// the flush work unit: reason is part of the S3 key prefix and stats path, so +// each (table, reason) pair runs the whole coverage/stream/heal/stats/delete +// pipeline independently against its own cutoff +interface IFlushWorkItem { + tableId: string; + reason: ColdRemovalReason; + cutoff: Date; +} + +// mutable accumulator threaded through discovery to tally orphan deletions +interface IOrphanCleanup { + enabled: boolean; + deletedRows: number; +} + +interface ITouchedBucket { + bucket: IPartBucket; + writtenKeys: Set; + // pre-existing keys folded into the rewrite — the only healable keys + consumedKeys: Set; +} + +const quoteIdent = (name: string) => `"${name.replace(/"/g, '""')}"`; + +export { nextReadBatchLimit } from '../cold-archive/read-batch'; + +// Flushes record_trash buffer rows older than their reason's horizon into +// cold parts — per-reason horizons (both ~30d by default: archive and +// recycle-bin reads alike merge PG + S3, so the hot window only covers the +// interactive-read sweet spot). +// +// Discovery never wakes idle tenant dbs: BYODB targets are pruned purely on +// the main db via max(table_meta.last_modified_time) vs the binding bookmark +// (touchTableMeta keeps that signal fresh on every record write, and removal +// IS a record write). Per-table reads/deletes route through DatabaseRouter, +// so a table is always flushed from its authoritative db. +@Injectable() +export class RecordRemovalFlusherService { + private readonly logger = new Logger(RecordRemovalFlusherService.name); + + constructor( + private readonly prismaService: PrismaService, + private readonly metaFallbackDataPrismaService: DataPrismaService, + private readonly dataDbClientManager: DataDbClientManager, + private readonly databaseRouter: DatabaseRouter, + private readonly coldStorage: RecordRemovalColdStorageService + ) {} + + async runFlush(options: IColdFlushOptions): Promise { + const config = recordRemovalColdConfig(); + const startedAt = new Date(); + const cutoffs: Record = { + archived: new Date( + startedAt.getTime() - (options.archiveHorizonMs ?? config.archiveFlushHorizonMs) + ), + deleted: new Date( + startedAt.getTime() - (options.deletedHorizonMs ?? config.deletedFlushHorizonMs) + ), + }; + // a backfill is upload-only unless the caller explicitly asks for deletes; + // it must never inherit the global delete gate (a dry backfill run with + // the env flag on would otherwise silently drain the buffer). Merged reads + // are unconditional (every process can serve cold data), so deletion after + // verified upload is safe wherever it was requested. + const deleteEnabled = + options.mode === 'backfill' + ? options.deleteEnabled === true + : options.deleteEnabled ?? config.deleteEnabled; + const concurrency = options.tableConcurrency ?? config.tableConcurrency; + const maxRows = options.maxRows ?? config.maxRowsPerRun; + // ONE budget for the whole run: with tableConcurrency > 1 the concurrent + // work items' bucket sorters all coexist, so a per-item budget would just + // multiply by the concurrency again + const sortBudget = new SortMemoryBudget(config.sortMemoryBudgetBytes); + + // orphan buffer rows (trash of hard-deleted tables) are swept during + // discovery, on whichever db holds them, under the same delete gate as a + // normal flush. A manual tableIds run targets specific live tables and skips + // discovery, so it does not sweep. + const orphanCleanup: IOrphanCleanup = { enabled: deleteEnabled, deletedRows: 0 }; + const groups = options.tableIds?.length + ? [{ kind: 'shared' as const, tableIds: options.tableIds }] + : await this.discoverGroups(options, cutoffs, orphanCleanup); + + const results: ITableFlushResult[] = []; + const budget = { flushedRows: 0, maxRows }; + let leftoverTables = 0; + + for (const group of groups) { + const deferredInGroup = await this.flushGroup(group, results, budget, { + cutoffs, + mode: options.mode, + deleteEnabled, + concurrency, + config, + sortBudget, + }); + leftoverTables += deferredInGroup; + + const groupResults = results.filter((result) => group.tableIds.includes(result.tableId)); + const groupFailed = groupResults.some((result) => result.error); + const groupFullyDrained = groupResults.every( + (result) => !result.deleteSkippedReason && (result.rows === 0 || result.deletedRows > 0) + ); + // The single bookmark asserts "every ARCHIVED row at or before the + // bookmark left the buffer", so it advances to the ARCHIVED cutoff (the + // newest of the two) and only when this run actually deleted what it + // flushed for BOTH reasons: an upload-only run (delete gate off) or a + // deferred/failed/skipped item leaves rows behind, and advancing would + // let a then-idle space strand them forever. Rows age past their + // horizon while a space sits idle (no new activity signal), so bookmark + // pruning alone would defer them — the monthly ignoreBookmarks sweep + // bounds that deferral to a month. + if ( + group.kind === 'byodb' && + group.bindingId && + !groupFailed && + deferredInGroup === 0 && + deleteEnabled && + groupFullyDrained + ) { + await this.advanceBookmark(group.bindingId, cutoffs.archived).catch((error) => + this.logger.warn(`failed to advance flush bookmark for ${group.spaceId}: ${error}`) + ); + } + } + + if (leftoverTables > 0) { + this.logger.log( + `removal cold flush row budget reached (${budget.flushedRows} rows); ${leftoverTables} table-reason unit(s) deferred to the next run` + ); + } + + return { + startedAt: startedAt.toISOString(), + cutoffs: { + archived: cutoffs.archived.toISOString(), + deleted: cutoffs.deleted.toISOString(), + }, + mode: options.mode, + tables: results, + totalRows: results.reduce((sum, item) => sum + item.rows, 0), + totalParts: results.reduce((sum, item) => sum + item.parts, 0), + totalCompressedBytes: results.reduce((sum, item) => sum + item.compressedBytes, 0), + totalTruncatedRows: results.reduce((sum, item) => sum + item.truncatedRows, 0), + orphanRowsDeleted: orphanCleanup.deletedRows, + durationMs: Date.now() - startedAt.getTime(), + leftoverTables, + budgetExhausted: leftoverTables > 0, + }; + } + + // flush one discovered group as (table, reason) work items slice-by-slice + // under the shared row budget (soft, checked between slices: an oversized + // single item still completes atomically); returns how many items were + // deferred to the next run + private async flushGroup( + group: IDiscoveredGroup, + results: ITableFlushResult[], + budget: { flushedRows: number; maxRows: number }, + run: { + cutoffs: Record; + mode: 'incremental' | 'backfill'; + deleteEnabled: boolean; + concurrency: number; + config: ReturnType; + sortBudget: SortMemoryBudget; + } + ): Promise { + // both reasons of a table may run in the same slice: they touch disjoint + // buffer predicates, S3 prefixes and stats files + const items: IFlushWorkItem[] = group.tableIds.flatMap((tableId) => + COLD_REMOVAL_REASONS.map((reason) => ({ tableId, reason, cutoff: run.cutoffs[reason] })) + ); + let index = 0; + while (index < items.length) { + if (budget.maxRows > 0 && budget.flushedRows >= budget.maxRows) { + return items.length - index; + } + const slice = items.slice(index, index + run.concurrency); + index += slice.length; + const sliceResults = await mapWithConcurrency(slice, run.concurrency, (item) => + this.flushTable( + item.tableId, + item.reason, + item.cutoff, + run.mode, + run.deleteEnabled, + run.config, + run.sortBudget + ).catch((error): ITableFlushResult => { + this.logger.error( + `removal cold flush failed for table ${item.tableId} reason ${item.reason}: ${error instanceof Error ? error.stack : error}` + ); + return { + tableId: item.tableId, + reason: item.reason, + rows: 0, + parts: 0, + uncompressedBytes: 0, + compressedBytes: 0, + deletedRows: 0, + reconciledRows: 0, + truncatedRows: 0, + durationMs: 0, + error: error instanceof Error ? error.message : String(error), + } satisfies ITableFlushResult; + }) + ); + results.push(...sliceResults); + // reconciled rows count only when their delete actually happened: the + // deletes are the work the budget bounds. Rows retained by an + // upload-only run OR a deferred delete (skipped reason set) would be + // re-counted every run, burning the budget on the same rows forever + // and starving later tables. + budget.flushedRows += sliceResults.reduce( + (sum, item) => + sum + + item.rows + + (run.deleteEnabled && !item.deleteSkippedReason ? item.reconciledRows : 0), + 0 + ); + } + return 0; + } + + // bookmark writes are monotonic: a manual run with a wide horizon override + // computes an older cutoff and must not regress the high-water mark (a + // regressed bookmark only costs an extra reconnect, but staying monotonic + // keeps "everything at or before the bookmark is flushed" trivially true) + private async advanceBookmark(bindingId: string, cutoff: Date): Promise { + await this.prismaService.spaceDataDbBinding.updateMany({ + where: { + id: bindingId, + OR: [{ lastRemovalFlushedAt: null }, { lastRemovalFlushedAt: { lt: cutoff } }], + }, + data: { lastRemovalFlushedAt: cutoff }, + }); + } + + // discovery: the shared data db always participates (it is the always-on + // main data db; a space filter narrows its tables rather than skipping it — + // shared-storage spaces are valid targets too); BYODB dbs only when the + // meta-side activity signal moved past the bookmark + private async discoverGroups( + options: IColdFlushOptions, + cutoffs: Record, + orphanCleanup: IOrphanCleanup + ): Promise { + const groups: IDiscoveredGroup[] = []; + + const sharedTables = await this.listBufferedTables(this.metaFallbackDataPrismaService); + const shared = await this.filterKnownTables(sharedTables, { + excludeByodbBound: true, + ...(options.spaceIds?.length ? { spaceIds: options.spaceIds } : undefined), + }); + if (shared.keep.length) { + groups.push({ kind: 'shared', tableIds: shared.keep }); + } + if (orphanCleanup.enabled && shared.orphans.length) { + orphanCleanup.deletedRows += await this.deleteOrphanBufferRows( + this.metaFallbackDataPrismaService, + shared.orphans, + cutoffs.archived + ); + } + + const bindings = await this.prismaService.spaceDataDbBinding.findMany({ + where: { + mode: 'byodb', + state: 'ready', + ...(options.spaceIds?.length ? { spaceId: { in: options.spaceIds } } : {}), + }, + select: { id: true, spaceId: true, lastRemovalFlushedAt: true }, + }); + const activeBindings = options.ignoreBookmarks + ? bindings + : await this.filterActiveBindings(bindings); + + for (const binding of activeBindings) { + const group = await this.discoverBindingGroup(binding, cutoffs, orphanCleanup); + if (group) groups.push(group); + } + + return groups; + } + + // one grouped aggregate over table_meta replaces a per-binding max() query; + // bindings with no record activity since their last flush are pruned here so + // discoverBindingGroup never connects to them (keeps idle dbs asleep) + private async filterActiveBindings< + TBinding extends { spaceId: string; lastRemovalFlushedAt: Date | null }, + >(bindings: TBinding[]): Promise { + if (!bindings.length) return bindings; + const rows = await this.prismaService.$queryRaw< + { spaceId: string; maxModified: Date | null }[] + >`SELECT b.space_id AS "spaceId", max(tm.last_modified_time) AS "maxModified" + FROM table_meta tm JOIN base b ON b.id = tm.base_id + WHERE b.space_id IN (${Prisma.join(bindings.map((binding) => binding.spaceId))}) + GROUP BY b.space_id`; + const maxModifiedBySpace = new Map(rows.map((row) => [row.spaceId, row.maxModified])); + return bindings.filter((binding) => { + if (!binding.lastRemovalFlushedAt) return true; + const maxModified = maxModifiedBySpace.get(binding.spaceId); + return !!maxModified && maxModified > binding.lastRemovalFlushedAt; + }); + } + + private async discoverBindingGroup( + binding: { id: string; spaceId: string }, + cutoffs: Record, + orphanCleanup: IOrphanCleanup + ): Promise { + try { + const client = await this.dataDbClientManager.dataPrismaForSpace(binding.spaceId); + const tableIds = await this.listBufferedTables(client); + const filtered = await this.filterKnownTables(tableIds); + // the tenant db is already awake here, so cleaning its own orphans (rows + // of tables deleted inside this tenant) costs nothing extra and never + // wakes an idle db on its own + if (orphanCleanup.enabled && filtered.orphans.length) { + orphanCleanup.deletedRows += await this.deleteOrphanBufferRows( + client, + filtered.orphans, + cutoffs.archived + ); + } + if (filtered.keep.length) { + return { + kind: 'byodb', + spaceId: binding.spaceId, + bindingId: binding.id, + tableIds: filtered.keep, + }; + } + // nothing buffered: still advance the bookmark (to the archived cutoff, + // matching what a flush would have covered) so quiet dbs stay skipped + await this.advanceBookmark(binding.id, cutoffs.archived).catch(() => undefined); + } catch (error) { + this.logger.warn(`removal cold flush discovery skipped space ${binding.spaceId}: ${error}`); + } + return undefined; + } + + // loose index scan: distinct table_id from the buffer at O(#tables × log n) + private async listBufferedTables(client: unknown): Promise { + const prisma = this.unwrapClient(client); + const rows = (await prisma.$queryRawUnsafe( + `WITH RECURSIVE distinct_tables AS ( + SELECT min(table_id) AS table_id FROM record_trash + UNION ALL + SELECT (SELECT min(r.table_id) FROM record_trash r WHERE r.table_id > d.table_id) + FROM distinct_tables d WHERE d.table_id IS NOT NULL + ) + SELECT table_id AS "tableId" FROM distinct_tables WHERE table_id IS NOT NULL` + )) as { tableId: string }[]; + return rows.map((row) => row.tableId); + } + + // drop buffer rows of deleted/unknown tables from the work list (abandoned + // copies); for the shared group also drop every table whose space has a + // non-default binding, REGARDLESS of state — this must mirror the + // DatabaseRouter exactly, which never falls back to the shared db for + // mode='byodb' (ready/migrating/error route to the tenant connection, + // anything else throws). Flushing a shared-db copy the router would not + // serve corrupts an active migration's row-count checks (copy/validate), + // and for error/disabled it would operate on the wrong database entirely. + // Those rows simply wait untiered until the binding is repaired or reset. + private async filterKnownTables( + tableIds: string[], + options?: { excludeByodbBound?: boolean; spaceIds?: string[] } + ): Promise<{ keep: string[]; orphans: string[] }> { + if (!tableIds.length) return { keep: [], orphans: [] }; + const known = await this.prismaService.tableMeta.findMany({ + where: { + id: { in: tableIds }, + ...(options?.spaceIds?.length + ? { base: { spaceId: { in: options.spaceIds } } } + : undefined), + }, + select: { + id: true, + base: { + select: { space: { select: { dataDbBinding: { select: { mode: true, state: true } } } } }, + }, + }, + }); + const keepSet = new Set( + known + .filter((table) => { + if (!options?.excludeByodbBound) return true; + const binding = table.base.space.dataDbBinding; + return !binding || binding.mode === 'default'; + }) + .map((table) => table.id) + ); + // An orphan is a buffered table_id with NO table_meta row anywhere: the + // table was hard-deleted, so its trash is unreachable by every reader + // (trash/archive reads need a live table) AND by normal flushing + // (discovery is table_meta-driven), leaving it stranded in the buffer + // forever. This is DISTINCT from a byodb-routed table, which keeps its + // table_meta and is merely served from another db — those are never + // orphaned or deleted here. A space-scoped run filters `known`, so + // re-check existence unfiltered to avoid misclassifying an other-space + // table as an orphan. + const existingIds = options?.spaceIds?.length + ? new Set( + ( + await this.prismaService.tableMeta.findMany({ + where: { id: { in: tableIds } }, + select: { id: true }, + }) + ).map((table) => table.id) + ) + : new Set(known.map((table) => table.id)); + const orphans = tableIds.filter((id) => !existingIds.has(id)); + const servedElsewhere = tableIds.filter((id) => existingIds.has(id) && !keepSet.has(id)); + if (servedElsewhere.length) { + this.logger.warn( + `removal cold flush skipping ${servedElsewhere.length} buffered table(s) served elsewhere (byodb/out-of-scope): ${servedElsewhere.slice(0, 5).join(',')}` + ); + } + return { keep: tableIds.filter((id) => keepSet.has(id)), orphans }; + } + + // Map a buffer row to a cold row and cap its snapshot. Truncation is + // JS-side for v1: unlike record-history's two scalar columns the snapshot + // is one JSON document, and SQL-side JSON truncation is not worth the + // complexity — so an oversized snapshot DOES cross the wire and briefly + // lives on the heap before the cap replaces it. rawBytes is therefore the + // PRE-truncation size: the adaptive batch limit must bound what the wire + // delivers, not what survives the cap. + private buildColdRow( + reason: ColdRemovalReason, + row: { + id: string; + recordId: string; + snapshot: string; + createdTime: string; + createdBy: string; + operationId: string | null; + recordCreatedTime: string | null; + recordCreatedBy: string | null; + recordLastModifiedTime: string | null; + recordLastModifiedBy: string | null; + }, + config: ReturnType + ): { row: IColdRemovalRow; truncatedCount: number; rawBytes: number } { + const raw: IColdRemovalRow = { + id: row.id, + recordId: row.recordId, + snapshot: row.snapshot, + reason, + removedTime: row.createdTime, + removedBy: row.createdBy, + operationId: row.operationId ?? undefined, + recordCreatedTime: row.recordCreatedTime ?? undefined, + recordCreatedBy: row.recordCreatedBy ?? undefined, + recordLastModifiedTime: row.recordLastModifiedTime ?? undefined, + recordLastModifiedBy: row.recordLastModifiedBy ?? undefined, + }; + const rawBytes = approxRemovalRowBytes(raw); + const capped = truncateRemovalRow(raw, config.truncateFieldUnits, config.truncateRowUnits); + return { row: capped, truncatedCount: capped !== raw ? 1 : 0, rawBytes }; + } + + async flushTable( + tableId: string, + reason: ColdRemovalReason, + cutoff: Date, + mode: 'incremental' | 'backfill', + deleteEnabled: boolean, + config = recordRemovalColdConfig(), + sortBudget = new SortMemoryBudget(config.sortMemoryBudgetBytes) + ): Promise { + const startedAt = Date.now(); + const qualified = await this.qualifiedTrashTable(tableId); + const dayWindowStart = new Date(Date.now() - config.backfillDayWindowMs); + + // buckets whose rows are already fully persisted (stats corroborated by a + // live part listing) skip the merge-rewrite entirely — the "upload-only → + // delete-enabled" transition then reconciles and deletes without redoing + // any upload work + const coverage = await this.planBucketCoverage( + tableId, + reason, + qualified, + cutoff, + dayWindowStart + ); + + const feeders = new Map(); + // bucketing is date-based regardless of mode: a steady-state daily run + // only ever sees young-side rows (day files), while the very first run + // after an upgrade sees the whole historical backlog and lands it directly + // as month files — a zero-ops instance gets the backfill layout for free + + const monthParts = new Map< + string, + Awaited> + >(); + const feederFor = async (removedTime: string): Promise => { + const removed = new Date(removedTime); + const kind = removed >= dayWindowStart ? 'day' : 'month'; + const bucket: IPartBucket = bucketOfDate(removed, kind); + const id = bucketId(bucket); + let feeder = feeders.get(id); + if (!feeder) { + // a bucket may already hold parts from an earlier run whose buffer + // rows were deleted since — those must be merged back in, not clobbered + let parts = monthParts.get(bucket.yyyymm); + if (!parts) { + parts = await this.coldStorage.listMonthParts(tableId, reason, bucket.yyyymm); + monthParts.set(bucket.yyyymm, parts); + } + const existing = parts.filter( + (part) => part.kind === bucket.kind && (bucket.kind === 'month' || part.dd === bucket.dd) + ); + // new keys start past the existing ones: the feeder is still streaming + // the old parts while we upload, and S3 gives no guarantees for a GET + // racing an overwrite of the same key; healing removes the old keys + // once the rewrite has been verified + const startSeq = existing.reduce((max, part) => Math.max(max, part.seq + 1), 0); + const writer = new PartWriter({ + store: this.coldStorage.partStore, + rootDir: this.coldStorage.rootDir, + tableId, + reason, + bucket, + partUncompressedBytes: config.partUncompressedBytes, + startSeq, + }); + feeder = new BucketMergeFeeder( + writer, + existing, + this.coldStorage, + sortBudget, + config.sortMergeFanIn, + config.truncateFieldUnits, + config.truncateRowUnits + ); + feeders.set(id, feeder); + } + return feeder; + }; + + let flushedRows = 0; + let truncatedRows = 0; + let lastKey: { createdTime: Date; id: string } | undefined; + const streamNothing = coverage.streamRanges !== undefined && coverage.streamRanges.length === 0; + let batchLimit = Math.min(READ_BATCH_PROBE_ROWS, config.readBatchSize); + const allEntries: IPartStatsEntry[] = []; + const touched = new Map(); + try { + while (!streamNothing) { + const batch = await this.readBatch( + tableId, + reason, + qualified, + cutoff, + batchLimit, + lastKey, + coverage.streamRanges + ); + if (batch.length === 0) break; + const last = batch[batch.length - 1]; + lastKey = { createdTime: new Date(last.createdTime), id: last.id }; + let batchBytes = 0; + for (let i = 0; i < batch.length; i++) { + const built = this.buildColdRow(reason, batch[i], config); + batchBytes += built.rawBytes; + truncatedRows += built.truncatedCount; + // drop the source row's reference as we go: with multi-MB rows the + // whole batch array would otherwise stay live until the loop ends + (batch as unknown as (unknown | undefined)[])[i] = undefined; + await (await feederFor(built.row.removedTime)).push(built.row); + flushedRows += 1; + } + if (batch.length < batchLimit) break; + batchLimit = nextReadBatchLimit(batchBytes, batch.length, config.readBatchSize); + } + + for (const [id, feeder] of feeders) { + const entries = await feeder.finish(); + allEntries.push(...entries); + touched.set(id, { + bucket: feeder.bucket, + writtenKeys: new Set(entries.map((e) => e.key)), + consumedKeys: feeder.consumedKeys, + }); + } + } catch (error) { + // a mid-stream failure (a spill error surfaced by another table's + // eviction, an S3 hiccup, a feeder still unfinished) must not leave + // this table's feeders charged against the run-wide budget and + // evictable for the rest of the run. abort() frees each sorter's + // budget charge, temp files and registry slot; it is idempotent, so + // already-finished feeders are unaffected. + await Promise.allSettled([...feeders.values()].map((feeder) => feeder.abort())); + throw error; + } + + const metrics = [...feeders.values()].reduce( + (sum, feeder) => ({ + parts: sum.parts + feeder.metrics.parts, + uncompressedBytes: sum.uncompressedBytes + feeder.metrics.uncompressedBytes, + compressedBytes: sum.compressedBytes + feeder.metrics.compressedBytes, + }), + { parts: 0, uncompressedBytes: 0, compressedBytes: 0 } + ); + + if (touched.size > 0) { + await this.healStaleParts(tableId, touched); + await this.updateStats(tableId, reason, touched, allEntries); + } + + let deletedRows = 0; + let deleteSkippedReason: string | undefined; + if (deleteEnabled && flushedRows + coverage.coveredRows > 0) { + const outcome = await this.reconcileAndDelete( + tableId, + reason, + qualified, + cutoff, + flushedRows + coverage.coveredRows + ); + deletedRows = outcome.deletedRows; + deleteSkippedReason = outcome.skippedReason; + } + + return { + tableId, + reason, + rows: flushedRows, + parts: metrics.parts, + uncompressedBytes: metrics.uncompressedBytes, + compressedBytes: metrics.compressedBytes, + deletedRows, + deleteSkippedReason, + reconciledRows: coverage.coveredRows, + truncatedRows, + durationMs: Date.now() - startedAt, + }; + } + + // Coverage plan for the "upload-only → delete-enabled" transition (and for + // idempotent re-runs): a bucket whose buffer rows are ALREADY fully + // persisted skips the merge-rewrite. "Fully persisted" is judged by an + // exact triple match — row count and min/max created_time — between the + // buffer's per-bucket aggregate and the bucket's stats entries, AND a + // live listing that corroborates the stats keys one-to-one (stats alone + // are advisory; skipping an upload on stale stats would lose rows at the + // delete step). Buffer rows are insert-only with db-stamped timestamps and + // uploads came from this very buffer, so a triple match implies set + // equality for our write pattern. + // + // Returns the rows covered this way plus the canonical time ranges of the + // NON-covered buckets to stream (undefined = stream everything; [] = + // nothing left to stream). + private async planBucketCoverage( + tableId: string, + reason: ColdRemovalReason, + qualified: string, + cutoff: Date, + dayWindowStart: Date + ): Promise<{ coveredRows: number; streamRanges?: { lo: Date; hi: Date }[] }> { + const noCoverage = { coveredRows: 0, streamRanges: undefined }; + const buckets = (await this.databaseRouter.queryDataPrismaForTable( + tableId, + `SELECT to_char("created_time", 'YYYYMM') AS "yyyymm", + CASE WHEN "created_time" >= $4 THEN to_char("created_time", 'DD') END AS "dd", + count(*)::text AS "count", + min("created_time") AS "min", max("created_time") AS "max" + FROM ${qualified} + WHERE "table_id" = $1 AND "reason" = $2 AND "created_time" < $3 + GROUP BY 1, 2`, + tableId, + reason, + cutoff, + dayWindowStart + )) as { yyyymm: string; dd: string | null; count: string; min: Date; max: Date }[]; + if (buckets.length === 0) { + return { coveredRows: 0, streamRanges: [] }; + } + + const stats = await this.coldStorage.readStats(tableId, reason); + if (!stats) return noCoverage; + + const statsByBucket = this.groupStatsByBucket(stats); + const listedByBucket = await this.listPartsByBucket(tableId, reason, [ + ...new Set(buckets.map((bucket) => bucket.yyyymm)), + ]); + + let coveredRows = 0; + const streamRanges: { lo: Date; hi: Date }[] = []; + for (const bucket of buckets) { + const id = bucket.dd ? `${bucket.yyyymm}/${bucket.dd}` : `${bucket.yyyymm}/m`; + if (isBucketCovered(statsByBucket.get(id), listedByBucket.get(id), bucket)) { + coveredRows += Number(bucket.count); + } else { + streamRanges.push(bucketRange(bucket, cutoff, dayWindowStart)); + } + } + + if (coveredRows === 0) return noCoverage; + if (streamRanges.length > 64) { + this.logger.warn( + `removal cold flush coverage: ${streamRanges.length} uncovered bucket(s) exceed the predicate cap; falling back to a full rewrite for ${tableId}/${reason}` + ); + return noCoverage; + } + return { coveredRows, streamRanges }; + } + + private groupStatsByBucket(stats: ITableColdStats) { + return groupStatsByBucket( + stats.parts, + (key) => { + const parsed = parsePartKey(this.coldStorage.rootDir, key); + return parsed ? bucketId(parsed) : undefined; + }, + (entry) => ({ min: entry.minRemovedTime, max: entry.maxRemovedTime }) + ); + } + + private async listPartsByBucket(tableId: string, reason: ColdRemovalReason, months: string[]) { + const byBucket = new Map>(); + for (const yyyymm of months) { + for (const part of await this.coldStorage.listMonthParts(tableId, reason, yyyymm)) { + const id = bucketId(part); + const set = byBucket.get(id) ?? new Set(); + set.add(part.key); + byBucket.set(id, set); + } + } + return byBucket; + } + + private async qualifiedTrashTable(tableId: string): Promise { + const url = await this.dataDbClientManager.getDataDatabaseUrlForTable(tableId); + const schema = new URL(url).searchParams.get('schema') || 'public'; + return `${quoteIdent(schema)}."record_trash"`; + } + + private async readBatch( + tableId: string, + reason: ColdRemovalReason, + qualified: string, + cutoff: Date, + limit: number, + after?: { createdTime: Date; id: string }, + ranges?: { lo: Date; hi: Date }[] + ) { + // Read on the table's own pg connection via the NATIVE pg client (knex / + // node-postgres), routed per-table by dataKnexForTable exactly as the + // Prisma path would be — a BYODB table hits the tenant DB over its own + // connection string, a shared table the main DB. The native driver + // (rather than Prisma) mirrors record-history, whose rust engine + // deterministically failed on one shared-DB table with "Failed to convert + // rust String into napi string" for valid sub-cap UTF-8. + const bindings: unknown[] = []; + // positional binds are consumed left-to-right, so emit them in SQL order + const bind = (value: unknown) => { + bindings.push(value); + return '?'; + }; + // created_time is TIMESTAMP without time zone storing UTC. node-postgres + // binds a Date using the process timezone, so pass UTC naive strings (and + // read the columns back as UTC ISO strings below) to keep the predicate + // window identical on any deployment TZ. + const bindTs = (value: Date) => `${bind(value.toISOString().slice(0, -1))}::timestamp`; + const tableIdBind = bind(tableId); + const reasonBind = bind(reason); + const cutoffBind = bindTs(cutoff); + let rangeClause = ''; + if (ranges && ranges.length > 0) { + const parts = ranges.map( + (range) => + `("created_time" >= ${bindTs(range.lo)} AND "created_time" < ${bindTs(range.hi)})` + ); + rangeClause = ` AND (${parts.join(' OR ')})`; + } + let afterClause = ''; + if (after) { + afterClause = ` AND ("created_time", "id" COLLATE "C") > (${bindTs(after.createdTime)}, ${bind(after.id)})`; + } + // keyset order is the removal main order (removedTime-major); the id + // tiebreak pins COLLATE "C" so the paging comparison and the ORDER BY + // agree byte-for-byte with the JS comparator, never a db collation + const utcIso = `'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'`; + const sql = `SELECT "id", "record_id" AS "recordId", "snapshot", + to_char("created_time", ${utcIso}) AS "createdTime", + "created_by" AS "createdBy", + "operation_id" AS "operationId", + to_char("record_created_time", ${utcIso}) AS "recordCreatedTime", + "record_created_by" AS "recordCreatedBy", + to_char("record_last_modified_time", ${utcIso}) AS "recordLastModifiedTime", + "record_last_modified_by" AS "recordLastModifiedBy" + FROM ${qualified} + WHERE "table_id" = ${tableIdBind} AND "reason" = ${reasonBind} AND "created_time" < ${cutoffBind}${rangeClause}${afterClause} + ORDER BY "created_time" ASC, "id" COLLATE "C" ASC LIMIT ${Math.max(1, Math.floor(limit))}`; + const knex = await this.databaseRouter.dataKnexForTable(tableId); + const result = await knex.raw(sql, bindings); + return ((result as { rows?: unknown[] }).rows ?? (result as unknown[])) as Array<{ + id: string; + recordId: string; + snapshot: string; + createdTime: string; + createdBy: string; + operationId: string | null; + recordCreatedTime: string | null; + recordCreatedBy: string | null; + recordLastModifiedTime: string | null; + recordLastModifiedBy: string | null; + }>; + } + + // deterministic self-healing, scoped to what this run actually superseded: + // only the pre-existing keys the bucket feeder folded into its rewrite may + // be deleted. A same-bucket key that appeared after the feeder's listing + // belongs to a concurrent flush (manual/catch-up overlapping the daily job) + // and must survive — read-side id-dedup absorbs the temporary duplication. + private async healStaleParts( + tableId: string, + touched: Map + ): Promise { + const staleKeys: string[] = []; + for (const { writtenKeys, consumedKeys } of touched.values()) { + for (const key of consumedKeys) { + if (!writtenKeys.has(key)) staleKeys.push(key); + } + } + if (staleKeys.length) { + this.logger.warn( + `removal cold flush healing ${staleKeys.length} superseded part(s) for ${tableId}` + ); + await this.coldStorage.deleteKeys(staleKeys); + } + } + + private async updateStats( + tableId: string, + reason: ColdRemovalReason, + touched: Map, + entries: IPartStatsEntry[] + ): Promise { + const stats: ITableColdStats = (await this.coldStorage.readStats(tableId, reason)) ?? { + version: 1, + tableId, + reason, + parts: {}, + }; + // drop only entries for keys this run consumed (their parts are healed + // away above); a concurrent run's entries stay intact + for (const { consumedKeys } of touched.values()) { + for (const key of consumedKeys) { + delete stats.parts[key]; + } + } + for (const entry of entries) { + stats.parts[entry.key] = entry; + } + await this.coldStorage.writeStats(tableId, reason, stats); + } + + // range delete with a count reconciliation latch: the cutoff was pinned at + // run start and created_time is stamped by the db at insert, so the set + // "rows < cutoff" is stable — unless a straggler write slipped in after the + // read. The count check catches exactly that case and defers deletion to + // the next run instead of losing rows. + private async reconcileAndDelete( + tableId: string, + reason: ColdRemovalReason, + qualified: string, + cutoff: Date, + flushedRows: number + ): Promise<{ deletedRows: number; skippedReason?: string }> { + const countRows = (await this.databaseRouter.queryDataPrismaForTable( + tableId, + `SELECT count(*)::text AS "count" FROM ${qualified} WHERE "table_id" = $1 AND "reason" = $2 AND "created_time" < $3`, + tableId, + reason, + cutoff + )) as { count: string }[]; + const count = Number(countRows[0]?.count ?? '0'); + if (count !== flushedRows) { + return { + deletedRows: 0, + skippedReason: `count-mismatch buffered=${count} flushed=${flushedRows} (late writes below cutoff; next run re-flushes)`, + }; + } + try { + return { + deletedRows: await this.deleteFlushedRows(tableId, reason, qualified, cutoff, flushedRows), + }; + } catch (error) { + // serialization failure or timeout: rows stay buffered, next run retries + return { + deletedRows: 0, + skippedReason: `delete-deferred: ${error instanceof Error ? error.message : error}`, + }; + } + } + + // snapshot-consistent delete: count and delete run inside one REPEATABLE + // READ transaction, so a trash row whose transaction opened before the + // cutoff but commits between the two statements is invisible to the delete + // and survives for the next run — the range predicate alone would remove it + // without it ever having been uploaded. (This is why the delete is NOT split + // into separately-committed batches: a fresh snapshot per batch would see + // such a late row and delete it un-uploaded. The single-statement DELETE is + // also one O(n) index pass — record-history's earlier ctid-LIMIT batching + // loop re-scanned not-yet-vacuumable dead tuples every iteration, O(n^2), + // and timed out the 30-min transaction on 10M+ row tables, the 2026-07-09 + // cn stall.) A table beyond a few tens of millions of cold rows can still + // exceed the timeout; it then defers to the next run rather than crashing. + private async deleteFlushedRows( + tableId: string, + reason: ColdRemovalReason, + qualified: string, + cutoff: Date, + expectedRows: number + ): Promise { + const client = (await this.dataDbClientManager.dataPrismaForTable(tableId)) as unknown as { + $transaction: ( + fn: (tx: { + $queryRawUnsafe: (sql: string, ...params: unknown[]) => Promise; + $executeRawUnsafe: (sql: string, ...params: unknown[]) => Promise; + }) => Promise, + options?: { isolationLevel?: string; timeout?: number; maxWait?: number } + ) => Promise; + }; + return await client.$transaction( + async (tx) => { + const countRows = (await tx.$queryRawUnsafe( + `SELECT count(*)::int AS "count" FROM ${qualified} WHERE "table_id" = $1 AND "reason" = $2 AND "created_time" < $3`, + tableId, + reason, + cutoff + )) as { count: number }[]; + const count = Number(countRows[0]?.count ?? 0); + if (count !== expectedRows) { + throw new Error( + `snapshot count ${count} != flushed ${expectedRows}; rows changed since reconciliation` + ); + } + return await tx.$executeRawUnsafe( + `DELETE FROM ${qualified} WHERE "table_id" = $1 AND "reason" = $2 AND "created_time" < $3`, + tableId, + reason, + cutoff + ); + }, + { isolationLevel: 'RepeatableRead', timeout: 30 * 60_000, maxWait: 30_000 } + ); + } + + // Delete buffered trash of hard-deleted tables (no table_meta row) from the + // db that holds it. Unlike a live table these rows can never be tiered: + // normal flushing discovers work through table_meta, so it can neither + // upload nor delete them, and they pile up in the buffer forever. Dropping + // them loses nothing readable — trash/archive reads need a live table — so + // there is no cold part to write first. + // + // The delete runs on the SAME client that listed the rows (a deleted table + // has no metadata to route through dataPrismaForTable), addressing + // record_trash unqualified exactly like listBufferedTables so it lands on + // that client's search_path. No reason predicate: BOTH reasons of an + // unreachable table are garbage. Bounded by the ARCHIVED cutoff (the newer + // one) so a table only momentarily missing from table_meta + // (mid-create/restore) keeps its recent rows; callers gate this on + // deleteEnabled, so a read-only environment sharing the db never mutates it. + private async deleteOrphanBufferRows( + client: unknown, + orphanTableIds: string[], + cutoff: Date + ): Promise { + if (!orphanTableIds.length) return 0; + try { + const prisma = this.unwrapClient(client); + const deleted = Number( + await prisma.$executeRawUnsafe( + `DELETE FROM "record_trash" WHERE "table_id" = ANY($1::text[]) AND "created_time" < $2`, + orphanTableIds, + cutoff + ) + ); + if (deleted > 0) { + this.logger.log( + `removal cold flush deleted ${deleted} orphan buffer row(s) from ${orphanTableIds.length} deleted table(s): ${orphanTableIds.slice(0, 5).join(',')}` + ); + } + return deleted; + } catch (error) { + // orphan cleanup runs in discovery, before any live table is flushed, so + // an unbounded delete that hits a lock or the statement/transaction + // timeout on a large deleted-table backlog must NOT escape and abort the + // whole run — a per-table flush failure is merely deferred to a result, + // and one stuck orphan set must not stall otherwise-healthy tables. Log + // and move on; the orphans stay put and are retried next run. + this.logger.warn( + `removal cold flush orphan cleanup failed for ${orphanTableIds.length} table(s) (${orphanTableIds.slice(0, 5).join(',')}): ${error instanceof Error ? error.message : String(error)}` + ); + return 0; + } + } + + private unwrapClient(client: unknown): { + $queryRawUnsafe: (query: string, ...values: unknown[]) => Promise; + $executeRawUnsafe: (query: string, ...values: unknown[]) => Promise; + } { + const candidate = client as { + txClient?: () => unknown; + $queryRawUnsafe?: (query: string, ...values: unknown[]) => Promise; + $executeRawUnsafe?: (query: string, ...values: unknown[]) => Promise; + }; + if (typeof candidate.txClient === 'function') { + return candidate.txClient() as ReturnType; + } + return candidate as ReturnType; + } +} diff --git a/apps/nestjs-backend/src/features/record-removal-cold/record-removal-tombstone.service.ts b/apps/nestjs-backend/src/features/record-removal-cold/record-removal-tombstone.service.ts new file mode 100644 index 0000000000..91a406e689 --- /dev/null +++ b/apps/nestjs-backend/src/features/record-removal-cold/record-removal-tombstone.service.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@nestjs/common'; +import { getRandomString } from '@teable/core'; +import type { PrismaClient } from '@teable/db-data-prisma'; + +// True-deletion markers for record_trash rows already sunk to cold parts: S3 +// parts are immutable, so restoring or purging a sunk row cannot remove its +// cold copy in place — a tombstone suppresses it instead (cold reads and the +// restore fallback filter through the set; monthly compaction physically +// drops tombstoned rows when it rewrites month parts). Callers mark EVERY +// restore/purge of a removed row — archive AND trash restores alike — not just +// cold-fetched rows: a PG buffer row cannot tell whether it already sits in +// the flush overlap window (uploaded, not yet drained), and a marker for a +// never-sunk row is harmless. Markers are reason-agnostic by design: a record +// is in at most one removed state at a time, so a restore marker always +// predates the record's NEXT removal and the time-qualified check below never +// suppresses that newer row, whatever its reason. + +export const RECORD_REMOVAL_TOMBSTONE_TYPES = ['restored', 'purged'] as const; + +export type RecordRemovalTombstoneType = (typeof RECORD_REMOVAL_TOMBSTONE_TYPES)[number]; + +const TOMBSTONE_ID_PREFIX = 'rmt'; + +export const generateRecordRemovalTombstoneId = () => TOMBSTONE_ID_PREFIX + getRandomString(16); + +// recordId -> latest tombstone createdTime (canonical ISO string). The time +// qualifies the suppression: a tombstone only hides cold rows REMOVED BEFORE +// it was written. A record restored from cold and archived again later sinks a +// NEW row with removedTime after the tombstone — that row is live data and +// must neither be hidden from cold reads nor dropped by compaction, so a bare +// recordId set would be unsound. +export type IRemovalTombstoneMap = Map; + +export const isTombstonedAt = ( + tombstones: IRemovalTombstoneMap, + recordId: string, + removedTime: string +): boolean => { + const tombstonedAt = tombstones.get(recordId); + return tombstonedAt !== undefined && removedTime <= tombstonedAt; +}; + +// the tombstone table lives in each table's DATA db (same db as record_trash), +// so every method takes the table-scoped client the caller already routed — +// mirroring how the flusher/archive service obtain theirs via +// DataDbClientManager. The minimal Pick also accepts a transaction client. +type ITombstoneDbClient = Pick; + +@Injectable() +export class RecordRemovalTombstoneService { + async markRestored( + dataPrisma: ITombstoneDbClient, + tableId: string, + recordIds: string[] + ): Promise { + await this.mark(dataPrisma, tableId, recordIds, 'restored'); + } + + async markPurged( + dataPrisma: ITombstoneDbClient, + tableId: string, + recordIds: string[] + ): Promise { + await this.mark(dataPrisma, tableId, recordIds, 'purged'); + } + + private async mark( + dataPrisma: ITombstoneDbClient, + tableId: string, + recordIds: string[], + type: RecordRemovalTombstoneType + ): Promise { + if (recordIds.length === 0) return; + // App clock, not the column's db-side now() default: the suppression compares + // this against removedTime, which is stamped from the app clock at archive time + // (buildRecordTrashRows) — same clock source keeps the <= comparison from + // inverting on app-vs-db clock skew. + const createdTime = new Date(); + await dataPrisma.recordRemovalTombstone.createMany({ + data: recordIds.map((recordId) => ({ + id: generateRecordRemovalTombstoneId(), + tableId, + recordId, + type, + createdTime, + })), + }); + } + + // Whole-table load, no pagination: tombstones accumulate one row per + // restored/purged record (never from archive/delete creation traffic, and + // reset paths wipe the cold prefix instead of marking), so the per-table set + // stays bounded by user-driven restore/purge volume — one indexed query per + // cold fill is cheaper than plumbing per-row lookups through the reader. + // Bulk trash restores can mark tens of thousands of ids at once; if a table's + // set ever grows past what one load comfortably holds, compaction-side + // cleanup of markers older than every remaining part is the relief valve. + async loadTombstonedRecordIds( + dataPrisma: ITombstoneDbClient, + tableId: string + ): Promise { + const rows = await dataPrisma.recordRemovalTombstone.findMany({ + where: { tableId }, + select: { recordId: true, createdTime: true }, + }); + const tombstones: IRemovalTombstoneMap = new Map(); + for (const row of rows) { + const createdTime = row.createdTime.toISOString(); + const existing = tombstones.get(row.recordId); + if (existing === undefined || existing < createdTime) { + tombstones.set(row.recordId, createdTime); + } + } + return tombstones; + } +} diff --git a/apps/nestjs-backend/src/features/record/computed/services/computed-dependency-collector.service.ts b/apps/nestjs-backend/src/features/record/computed/services/computed-dependency-collector.service.ts index 172a0c4f3a..442124876e 100644 --- a/apps/nestjs-backend/src/features/record/computed/services/computed-dependency-collector.service.ts +++ b/apps/nestjs-backend/src/features/record/computed/services/computed-dependency-collector.service.ts @@ -861,6 +861,9 @@ export class ComputedDependencyCollectorService { selectionMap, fieldReferenceSelectionMap, fieldReferenceFieldMap, + // Affected-set derivation: an unsupported field-reference comparison + // must widen the set, never fail the triggering record write. + unsupportedFieldReferenceBehavior: 'match-all', }) .appendQueryBuilder(); @@ -1036,6 +1039,9 @@ export class ComputedDependencyCollectorService { selectionMap, fieldReferenceSelectionMap, fieldReferenceFieldMap, + // Affected-set derivation: an unsupported field-reference comparison + // must widen the set, never fail the triggering record write. + unsupportedFieldReferenceBehavior: 'match-all', }) .appendQueryBuilder(); diff --git a/apps/nestjs-backend/src/features/record/open-api/record-filter-v2.mapper.spec.ts b/apps/nestjs-backend/src/features/record/open-api/record-filter-v2.mapper.spec.ts new file mode 100644 index 0000000000..a0bd57c384 --- /dev/null +++ b/apps/nestjs-backend/src/features/record/open-api/record-filter-v2.mapper.spec.ts @@ -0,0 +1,185 @@ +import { CellValueType, FieldType, is } from '@teable/core'; +import { normalizeLegacyRecordFilterForV2 } from './record-filter-v2.mapper'; + +describe('normalizeLegacyRecordFilterForV2', () => { + const textFieldId = 'fldText'; + const checkboxFieldId = 'fldCheckbox'; + const userFieldId = 'fldUser'; + const dateFieldId = 'fldDate'; + const fields = new Map([ + [textFieldId, { type: FieldType.SingleLineText, cellValueType: CellValueType.String }], + [checkboxFieldId, { type: FieldType.Checkbox, cellValueType: CellValueType.Boolean }], + [userFieldId, { type: FieldType.User, cellValueType: CellValueType.String }], + [ + dateFieldId, + { + type: FieldType.Date, + cellValueType: CellValueType.DateTime, + options: { formatting: { timeZone: 'Asia/Singapore' } }, + }, + ], + ]); + + it('preserves v1 checkbox null semantics while dropping incomplete text filters', () => { + const result = normalizeLegacyRecordFilterForV2( + { + conjunction: 'and', + filterSet: [ + { fieldId: checkboxFieldId, operator: is.value, value: null }, + { fieldId: textFieldId, operator: is.value, value: null }, + ], + }, + fields + ); + + expect(result._unsafeUnwrap()).toEqual({ + conjunction: 'and', + items: [{ fieldId: checkboxFieldId, operator: 'is', value: false }], + }); + }); + + it('maps checkbox isNot+null (checked) to is+true', () => { + const result = normalizeLegacyRecordFilterForV2( + { + conjunction: 'and', + filterSet: [{ fieldId: checkboxFieldId, operator: 'isNot', value: null }], + }, + fields + ); + + expect(result._unsafeUnwrap()).toEqual({ + conjunction: 'and', + items: [{ fieldId: checkboxFieldId, operator: 'is', value: true }], + }); + }); + + it('maps symbol operators and normalizes scalar values for list operators', () => { + const result = normalizeLegacyRecordFilterForV2( + { + conjunction: 'and', + filterSet: [ + { fieldId: textFieldId, operator: '!=', value: 'Alpha', isSymbol: true }, + { fieldId: textFieldId, operator: 'isAnyOf', value: 'Beta' }, + ], + }, + fields + ); + + expect(result._unsafeUnwrap()).toEqual({ + conjunction: 'and', + items: [ + { fieldId: textFieldId, operator: 'isNot', value: 'Alpha' }, + { fieldId: textFieldId, operator: 'isAnyOf', value: ['Beta'] }, + ], + }); + }); + + it('replaces Me only for user-like Fields', () => { + const result = normalizeLegacyRecordFilterForV2( + { + conjunction: 'and', + filterSet: [ + { fieldId: userFieldId, operator: 'hasAnyOf', value: ['Me', 'usrOther'] }, + { fieldId: textFieldId, operator: 'is', value: 'Me' }, + ], + }, + fields, + 'usrCurrent' + ); + + expect(result._unsafeUnwrap()).toEqual({ + conjunction: 'and', + items: [ + { + fieldId: userFieldId, + operator: 'hasAnyOf', + value: ['usrCurrent', 'usrOther'], + }, + { fieldId: textFieldId, operator: 'is', value: 'Me' }, + ], + }); + }); + + it('converts exact date comparisons with the aggregate Field timezone', () => { + const result = normalizeLegacyRecordFilterForV2( + { + fieldId: dateFieldId, + operator: 'isOnOrAfter', + value: '2026-07-30T01:00:00.000Z', + }, + fields + ); + + expect(result._unsafeUnwrap()).toEqual({ + fieldId: dateFieldId, + operator: 'isOnOrAfter', + value: { + mode: 'exactDate', + exactDate: '2026-07-30T01:00:00.000Z', + timeZone: 'Asia/Singapore', + }, + }); + }); + + it('expands valid date ranges and passes reversed or unsupported ranges through for engine-side skipping', () => { + const valid = normalizeLegacyRecordFilterForV2( + { + fieldId: dateFieldId, + operator: 'is', + value: { + mode: 'dateRange', + exactDate: '2026-07-01T00:00:00.000Z', + exactDateEnd: '2026-07-31T00:00:00.000Z', + timeZone: 'utc', + }, + }, + fields + ); + const reversed = normalizeLegacyRecordFilterForV2( + { + fieldId: dateFieldId, + operator: 'is', + value: { + mode: 'dateRange', + exactDate: '2026-07-31T00:00:00.000Z', + exactDateEnd: '2026-07-01T00:00:00.000Z', + timeZone: 'utc', + }, + }, + fields + ); + const unsupported = normalizeLegacyRecordFilterForV2( + { + fieldId: dateFieldId, + operator: 'isNot', + value: { + mode: 'dateRange', + exactDate: '2026-07-01T00:00:00.000Z', + exactDateEnd: '2026-07-31T00:00:00.000Z', + timeZone: 'utc', + }, + }, + fields + ); + + expect(valid._unsafeUnwrap()).toMatchObject({ + conjunction: 'and', + items: [ + { fieldId: dateFieldId, operator: 'isOnOrAfter' }, + { fieldId: dateFieldId, operator: 'isOnOrBefore' }, + ], + }); + // v1 parity: invalid combinations are not errors — they pass through and + // the v2 condition visitor compiles them to no-op TRUE fragments. + expect(reversed._unsafeUnwrap()).toMatchObject({ + fieldId: dateFieldId, + operator: 'is', + value: { mode: 'dateRange' }, + }); + expect(unsupported._unsafeUnwrap()).toMatchObject({ + fieldId: dateFieldId, + operator: 'isNot', + value: { mode: 'dateRange' }, + }); + }); +}); diff --git a/apps/nestjs-backend/src/features/record/open-api/record-filter-v2.mapper.ts b/apps/nestjs-backend/src/features/record/open-api/record-filter-v2.mapper.ts new file mode 100644 index 0000000000..f2d34c633e --- /dev/null +++ b/apps/nestjs-backend/src/features/record/open-api/record-filter-v2.mapper.ts @@ -0,0 +1,404 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable sonarjs/cognitive-complexity */ +import { CellValueType, FieldType, isMeTag } from '@teable/core'; +import { + domainError, + type DomainError, + type RecordFilter, + type RecordFilterDateValue, + type RecordFilterGroup, + type RecordFilterNode, + type RecordFilterOperator, + type RecordFilterValue, +} from '@teable/v2-core'; +import { err, ok, type Result } from 'neverthrow'; + +export interface IRecordFilterFieldMeta { + readonly type: string; + readonly cellValueType?: string; + readonly options?: unknown; +} + +const v1SymbolOperatorMap: Readonly> = { + '=': 'is', + '!=': 'isNot', + '>': 'isGreater', + '>=': 'isGreaterEqual', + '<': 'isLess', + '<=': 'isLessEqual', + LIKE: 'contains', + 'NOT LIKE': 'doesNotContain', + IN: 'isAnyOf', + 'NOT IN': 'isNoneOf', + HAS: 'hasAllOf', + 'IS NULL': 'isEmpty', + 'IS NOT NULL': 'isNotEmpty', + 'IS WITH IN': 'isWithIn', +}; + +const dateComparisonOperators: ReadonlySet = new Set([ + 'is', + 'isNot', + 'isBefore', + 'isAfter', + 'isOnOrBefore', + 'isOnOrAfter', +]); + +const dateFilterFieldTypes: ReadonlySet = new Set([ + FieldType.Date, + FieldType.CreatedTime, + FieldType.LastModifiedTime, +]); + +const operatorsExpectingNull: ReadonlySet = new Set([ + 'isEmpty', + 'isNotEmpty', +]); + +const operatorsExpectingArray: ReadonlySet = new Set([ + 'isAnyOf', + 'isNoneOf', + 'hasAnyOf', + 'hasAllOf', + 'isNotExactly', + 'hasNoneOf', + 'isExactly', +]); + +type LegacyFilterGroup = { + readonly conjunction: 'and' | 'or'; + readonly filterSet: ReadonlyArray; +}; + +type LegacyFilterItem = { + readonly fieldId: string; + readonly operator: string; + readonly value?: unknown; + readonly isSymbol?: boolean; +}; + +const isRecordFilterFieldReferenceValue = ( + value: unknown +): value is { fieldId: string; type: 'field' } => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + return record.type === 'field' && typeof record.fieldId === 'string'; +}; + +const isV2FilterNode = (value: unknown): value is RecordFilterNode => { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + if (Array.isArray(record.items)) return true; + if (record.not && typeof record.not === 'object') return true; + return typeof record.fieldId === 'string' && typeof record.operator === 'string'; +}; + +const isV1FilterGroup = (value: unknown): value is LegacyFilterGroup => { + if (!value || typeof value !== 'object') return false; + return Array.isArray((value as Record).filterSet); +}; + +const isV1FilterItem = (value: unknown): value is LegacyFilterItem => { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + return typeof record.fieldId === 'string' && typeof record.operator === 'string'; +}; + +const normalizeV1Operator = (operator: string): RecordFilterOperator => + (v1SymbolOperatorMap[operator] ?? operator) as RecordFilterOperator; + +const mapLegacyDateRangeCondition = ( + fieldId: string, + operator: RecordFilterOperator, + value: unknown +): Result => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return ok(null); + + const record = value as Record; + if (record.mode !== 'dateRange') return ok(null); + + if (operator !== 'is' && operator !== 'isWithIn') { + // v1 parity: unsupported operator + dateRange is skipped by the engine, not + // an error — fall through to the plain mapping; the v2 condition visitor + // compiles it to a no-op TRUE fragment. + return ok(null); + } + + const exactDate = record.exactDate; + const exactDateEnd = record.exactDateEnd; + const timeZone = record.timeZone; + if ( + typeof exactDate !== 'string' || + typeof exactDateEnd !== 'string' || + typeof timeZone !== 'string' + ) { + return ok(null); + } + + const startTimestamp = Date.parse(exactDate); + const endTimestamp = Date.parse(exactDateEnd); + if (!Number.isFinite(startTimestamp) || !Number.isFinite(endTimestamp)) { + return ok(null); + } + if (startTimestamp > endTimestamp) { + // v1 parity: an inverted range is skipped by the engine, not an error — + // fall through to the plain mapping; the v2 condition visitor compiles it + // to a no-op TRUE fragment. + return ok(null); + } + + return ok({ + conjunction: 'and', + items: [ + { + fieldId, + operator: 'isOnOrAfter', + value: { + mode: 'exactDate', + exactDate, + timeZone, + } as RecordFilterDateValue, + }, + { + fieldId, + operator: 'isOnOrBefore', + value: { + mode: 'exactDate', + exactDate: exactDateEnd, + timeZone, + } as RecordFilterDateValue, + }, + ], + }); +}; + +const normalizeV2FilterNode = ( + filter: RecordFilterNode +): Result => { + if ('not' in filter) { + return normalizeV2FilterNode(filter.not).map((next) => (next ? { not: next } : null)); + } + + if ('items' in filter) { + const items: RecordFilterNode[] = []; + for (const item of filter.items) { + const normalized = normalizeV2FilterNode(item); + if (normalized.isErr()) return err(normalized.error); + if (normalized.value) items.push(normalized.value); + } + return ok(items.length ? { conjunction: filter.conjunction, items } : null); + } + + const operator = filter.operator as RecordFilterOperator; + const value = filter.value as RecordFilterValue; + const legacyDateRangeCondition = mapLegacyDateRangeCondition(filter.fieldId, operator, value); + if (legacyDateRangeCondition.isErr()) return err(legacyDateRangeCondition.error); + if (legacyDateRangeCondition.value) return ok(legacyDateRangeCondition.value); + + if (operatorsExpectingNull.has(operator)) { + return ok(value === null ? filter : null); + } + + if (operatorsExpectingArray.has(operator)) { + if (value == null || (Array.isArray(value) && value.length === 0)) return ok(null); + return ok(filter); + } + + if (value == null) { + return ok( + operator === 'is' || operator === 'isNot' + ? { fieldId: filter.fieldId, operator, value: null } + : null + ); + } + return ok(filter); +}; + +const mapV1FilterItem = ( + filter: LegacyFilterItem +): Result => { + const operator = normalizeV1Operator(filter.operator); + const rawValue = 'value' in filter ? filter.value : null; + const legacyDateRangeCondition = mapLegacyDateRangeCondition(filter.fieldId, operator, rawValue); + if (legacyDateRangeCondition.isErr()) return err(legacyDateRangeCondition.error); + if (legacyDateRangeCondition.value) return ok(legacyDateRangeCondition.value); + + if (operatorsExpectingNull.has(operator)) { + return ok({ fieldId: filter.fieldId, operator, value: null }); + } + + if (operatorsExpectingArray.has(operator)) { + let value = rawValue; + if (value == null) return ok(null); + if (!Array.isArray(value) && !isRecordFilterFieldReferenceValue(value)) { + value = [value]; + } + if (Array.isArray(value) && value.length === 0) return ok(null); + return ok({ + fieldId: filter.fieldId, + operator, + value: value as RecordFilterValue, + }); + } + + if (rawValue == null) { + return ok( + operator === 'is' || operator === 'isNot' + ? { fieldId: filter.fieldId, operator, value: null } + : null + ); + } + + return ok({ + fieldId: filter.fieldId, + operator, + value: rawValue as RecordFilterValue, + }); +}; + +const mapFilterEntry = (entry: unknown): Result => { + if (entry == null) return ok(null); + if (isV1FilterGroup(entry)) return mapV1FilterGroup(entry); + if (isV1FilterItem(entry)) return mapV1FilterItem(entry); + if (isV2FilterNode(entry)) return normalizeV2FilterNode(entry); + return ok(null); +}; + +const mapV1FilterGroup = ( + filter: LegacyFilterGroup +): Result => { + const items: RecordFilterNode[] = []; + for (const entry of filter.filterSet) { + const mapped = mapFilterEntry(entry); + if (mapped.isErr()) return err(mapped.error); + if (mapped.value) items.push(mapped.value); + } + return ok( + items.length + ? { + conjunction: filter.conjunction === 'or' ? 'or' : 'and', + items, + } + : null + ); +}; + +const mapFilter = (filter: unknown): Result => { + if (filter === undefined) return ok(undefined); + if (filter === null) return ok(null); + if (isV1FilterGroup(filter)) return mapV1FilterGroup(filter); + if (isV1FilterItem(filter)) return mapV1FilterItem(filter); + if (isV2FilterNode(filter)) return normalizeV2FilterNode(filter); + return ok(undefined); +}; + +const extractTimeZone = (options: unknown): string => { + if (!options || typeof options !== 'object' || !('formatting' in options)) return 'utc'; + const formatting = options.formatting; + if (!formatting || typeof formatting !== 'object' || !('timeZone' in formatting)) return 'utc'; + return typeof formatting.timeZone === 'string' ? formatting.timeZone : 'utc'; +}; + +const isDateFilterField = (fieldMeta: IRecordFilterFieldMeta): boolean => + dateFilterFieldTypes.has(fieldMeta.type) || fieldMeta.cellValueType === CellValueType.DateTime; + +const normalizeLegacyDateComparisonValue = ( + fieldMeta: IRecordFilterFieldMeta | undefined, + operator: RecordFilterOperator, + value: RecordFilterValue +): RecordFilterValue => { + if (!fieldMeta || !dateComparisonOperators.has(operator) || !isDateFilterField(fieldMeta)) { + return value; + } + if (isRecordFilterFieldReferenceValue(value) || Array.isArray(value)) { + return value; + } + if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) { + return value; + } + + return { + mode: 'exactDate', + exactDate: value, + timeZone: extractTimeZone(fieldMeta.options), + } as RecordFilterDateValue; +}; + +const normalizeMappedNode = ( + node: RecordFilterNode, + fieldMetaById: ReadonlyMap, + currentUserId?: string +): RecordFilterNode | null => { + if ('not' in node) { + const next = normalizeMappedNode(node.not, fieldMetaById, currentUserId); + return next ? { not: next } : null; + } + + if ('items' in node) { + const items = node.items + .map((item) => normalizeMappedNode(item, fieldMetaById, currentUserId)) + .filter((item): item is RecordFilterNode => Boolean(item)); + return items.length ? { conjunction: node.conjunction, items } : null; + } + + const operator = node.operator as RecordFilterOperator; + const fieldMeta = fieldMetaById.get(node.fieldId); + let value = node.value as RecordFilterValue; + + if (operatorsExpectingNull.has(operator)) { + return value === null ? { ...node, value: null } : null; + } + + if (value == null) { + const isCheckboxField = + fieldMeta?.type === FieldType.Checkbox || fieldMeta?.cellValueType === CellValueType.Boolean; + if (!isCheckboxField) return null; + // v1 stores unchecked as is+null and checked as isNot+null; boolean condition + // specs only accept `is`, so isNot+null must become is+true (checked). + if (operator === 'is') return { ...node, operator: 'is', value: false }; + if (operator === 'isNot') return { ...node, operator: 'is', value: true }; + return null; + } + + if ( + currentUserId && + fieldMeta && + [FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes( + fieldMeta.type as FieldType + ) + ) { + if (Array.isArray(value)) { + value = value.map((entry) => + typeof entry === 'string' && isMeTag(entry) ? currentUserId : entry + ) as RecordFilterValue; + } else if (typeof value === 'string' && isMeTag(value)) { + value = currentUserId; + } + } + + value = normalizeLegacyDateComparisonValue(fieldMeta, operator, value); + + if (operatorsExpectingArray.has(operator)) { + if ( + !Array.isArray(value) && + !isRecordFilterFieldReferenceValue(value) && + typeof value !== 'object' + ) { + value = [value]; + } + if (Array.isArray(value) && value.length === 0) return null; + } + + return { ...node, value }; +}; + +export const normalizeLegacyRecordFilterForV2 = ( + filter: unknown, + fieldMetaById: ReadonlyMap, + currentUserId?: string +): Result => + mapFilter(filter).map((mapped) => { + if (!mapped) return mapped; + return normalizeMappedNode(mapped, fieldMetaById, currentUserId) ?? undefined; + }); 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 c8eba3d8e2..4464e77c9f 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,36 +1,142 @@ import { + CellFormat, CellValueType, DbFieldType, FieldKeyType, FieldType, SortFunc, - TimeFormatting, } from '@teable/core'; import { + BaseId, + CellValueMultiplicity, + CellValueType as V2CellValueType, + ConditionalLookupOptions, CreateRecordResult, CreateRecordsResult, + createConditionalLookupField, + createDateField, + createNumberField, + createUserField, + DateTimeFormatting, DuplicateRecordResult, FieldId, + FieldName, + FormulaExpression, + LookupField, + LookupOptions, ListTableRecordsQuery, ListTableRecordsResult, + NumberFormatting, + Table, + TableId, + TableName, + TableRecord, + TimeFormatting as V2TimeFormatting, UpdateRecordResult, UpdateRecordsResult, - TableRecord, - TableId, + UserMultiplicity, v2CoreTokens, + type Table as V2Table, + type TableBuilder, } from '@teable/v2-core'; +import { fromZonedTime } from 'date-fns-tz'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { convertValueToStringify, string2Hash } from '../../../utils'; import { createFieldInstanceByVo } from '../../field/model/factory'; import { RecordOpenApiV2Service } from './record-open-api-v2.service'; +const tableIdText = `tbl${'c'.repeat(16)}`; +const primaryFieldId = `fld${'p'.repeat(16)}`; +const statusFieldId = `fld${'s'.repeat(16)}`; +const noteFieldId = `fld${'n'.repeat(16)}`; +const countFieldId = `fld${'c'.repeat(16)}`; +const createdTimeFieldId = `fld${'t'.repeat(16)}`; +const dateFieldIdText = `fld${'d'.repeat(16)}`; +const checkboxFieldId = `fld${'b'.repeat(16)}`; +const createdByFieldId = `fld${'u'.repeat(16)}`; +const formulaDateFieldId = `fld${'f'.repeat(16)}`; +const formulaBooleanFieldId = `fld${'o'.repeat(16)}`; +const formattedNumberFieldId = `fld${'m'.repeat(16)}`; +const conditionalNumberFieldId = `fld${'q'.repeat(16)}`; +const conditionalDateFieldId = `fld${'z'.repeat(16)}`; +const lookupUserFieldId = `fld${'l'.repeat(16)}`; +const conditionalUserFieldId = `fld${'v'.repeat(16)}`; + +/** + * Pure domain Table aggregate via builder — not a structural mock. + * Pass `extend` to add fields/views on the same builder before build. + */ +const createTestTable = (extend?: (builder: TableBuilder) => void): V2Table => { + const builder = Table.builder() + .withId(TableId.create(tableIdText)._unsafeUnwrap()) + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('OpenAPI V2 Test')._unsafeUnwrap()); + + builder + .field() + .singleLineText() + .withId(FieldId.create(primaryFieldId)._unsafeUnwrap()) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .primary() + .done(); + builder + .field() + .singleLineText() + .withId(FieldId.create(statusFieldId)._unsafeUnwrap()) + .withName(FieldName.create('Status')._unsafeUnwrap()) + .done(); + builder + .field() + .singleLineText() + .withId(FieldId.create(noteFieldId)._unsafeUnwrap()) + .withName(FieldName.create('Note')._unsafeUnwrap()) + .done(); + builder + .field() + .createdTime() + .withId(FieldId.create(createdTimeFieldId)._unsafeUnwrap()) + .withName(FieldName.create('Created')._unsafeUnwrap()) + .withFormatting( + DateTimeFormatting.create({ + date: 'YYYY-MM-DD', + time: V2TimeFormatting.None, + timeZone: 'UTC', + })._unsafeUnwrap() + ) + .done(); + + extend?.(builder); + + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +const createConditionalLookupOptions = (seed: string) => + ConditionalLookupOptions.create({ + foreignTableId: `tbl${seed.repeat(16)}`, + lookupFieldId: `fld${seed.repeat(16)}`, + condition: { + filter: { + conjunction: 'and', + filterSet: [{ fieldId: statusFieldId, operator: 'is', value: 'Open' }], + }, + }, + })._unsafeUnwrap(); + +const createLookupOptions = (seed: string) => + LookupOptions.create({ + linkFieldId: `fld${seed.repeat(16)}`, + lookupFieldId: `fld${seed.toUpperCase().repeat(16)}`, + foreignTableId: `tbl${seed.repeat(16)}`, + })._unsafeUnwrap(); + 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 getGroupRelatedData = vi.fn(); + const getDefaultViewId = vi.fn(); const createContext = vi.fn(); const getReadQuerySource = vi.fn(); const getFieldsByQuery = vi.fn(); @@ -39,6 +145,7 @@ describe('RecordOpenApiV2Service', () => { const execute = vi.fn(); const commandExecute = vi.fn(); const resolve = vi.fn(); + const isRegistered = vi.fn(); const getContainer = vi.fn(); const clsGet = vi.fn(); const clsSet = vi.fn(); @@ -49,7 +156,10 @@ describe('RecordOpenApiV2Service', () => { const dataPrismaForTable = vi.fn(); const resolveForRecordSearch = vi.fn(); const assertTableRecordWritable = vi.fn(); + const tableFindOne = vi.fn(); + const pluginPrepare = vi.fn(); + let testTable: V2Table; let service: RecordOpenApiV2Service; const createUpdateRecordResult = (params: { @@ -143,7 +253,27 @@ describe('RecordOpenApiV2Service', () => { beforeEach(() => { vi.clearAllMocks(); assertTableRecordWritable.mockResolvedValue(undefined); - + testTable = createTestTable(); + + isRegistered.mockImplementation((token) => { + return ( + token === v2CoreTokens.queryBus || + token === v2CoreTokens.commandBus || + token === v2CoreTokens.tableRepository || + token === v2CoreTokens.recordQueryPluginRunner + ); + }); + pluginPrepare.mockResolvedValue({ + isErr: () => false, + value: { + guard: async () => ({ isErr: () => false, value: undefined }), + getScope: () => ({ isErr: () => false, value: undefined }), + }, + }); + tableFindOne.mockResolvedValue({ + isErr: () => false, + value: testTable, + }); resolve.mockImplementation((token) => { if (token === v2CoreTokens.queryBus) { return { execute }; @@ -151,9 +281,15 @@ describe('RecordOpenApiV2Service', () => { if (token === v2CoreTokens.commandBus) { return { execute: commandExecute }; } + if (token === v2CoreTokens.tableRepository) { + return { findOne: tableFindOne }; + } + if (token === v2CoreTokens.recordQueryPluginRunner) { + return { prepare: pluginPrepare }; + } return undefined; }); - getContainer.mockResolvedValue({ resolve }); + getContainer.mockResolvedValue({ resolve, isRegistered }); createContext.mockResolvedValue({}); clsGet.mockImplementation((key: string) => { if (key == null) { @@ -169,7 +305,17 @@ describe('RecordOpenApiV2Service', () => { }); clsRunWith.mockImplementation((_store, fn: () => unknown) => fn()); getReadQuerySource.mockResolvedValue(undefined); - getFieldsByQuery.mockResolvedValue([]); + getDefaultViewId.mockResolvedValue({ id: `viw${'v'.repeat(16)}` }); + getGroupRelatedData.mockResolvedValue({ + filter: undefined, + groupPoints: undefined, + allGroupHeaderRefs: undefined, + }); + getFieldsByQuery.mockResolvedValue([ + { id: primaryFieldId, name: 'Title' }, + { id: statusFieldId, name: 'Status' }, + { id: noteFieldId, name: 'Note' }, + ]); getFieldInstances.mockResolvedValue([]); performRowCount.mockResolvedValue({ rowCount: 1 }); getDataDatabaseForTable.mockResolvedValue({ @@ -186,8 +332,20 @@ describe('RecordOpenApiV2Service', () => { isErr: () => false, value: ListTableRecordsResult.create( [ - { id: 'rec1111111111111111', fields: {}, version: 1 }, - { id: 'rec2222222222222222', fields: {}, version: 1 }, + { + id: 'rec1111111111111111', + fields: { [primaryFieldId]: 'A' }, + version: 1, + autoNumber: 1, + createdTime: createdTimeIso, + }, + { + id: 'rec2222222222222222', + fields: { [primaryFieldId]: 'B' }, + version: 1, + autoNumber: 2, + createdTime: createdTimeIso, + }, ], 2, 0, @@ -201,8 +359,8 @@ describe('RecordOpenApiV2Service', () => { service = new RecordOpenApiV2Service( { getContainerForTable: getContainer } as never, { createContext } as never, - { getDocIdsByQuery, getSnapshotBulkWithPermission } as never, - {} as never, + { getDocIdsByQuery, getSnapshotBulkWithPermission, getGroupRelatedData } as never, + { getDefaultViewId } as never, { get: clsGet, set: clsSet, runWith: clsRunWith } as never, { del: cacheDel, setDetail: cacheSetDetail } as never, { getFieldsByQuery, getFieldInstances } as never, @@ -345,49 +503,31 @@ describe('RecordOpenApiV2Service', () => { ]); }); - it('should ignore unreadable fields in orderBy and groupBy', () => { - const query = { - orderBy: [ - { fieldId: 'fldReadable', order: SortFunc.Asc }, - { fieldId: 'fldHidden', order: SortFunc.Desc }, - ], - groupBy: [ - { fieldId: 'fldHidden', order: SortFunc.Asc }, - { fieldId: 'fldReadable', order: SortFunc.Desc }, - ], - }; - - expect( - ( - service as unknown as { - sanitizeReadableSortAndGroup: ( - input: typeof query, - enabledFieldIds?: string[] - ) => typeof query; - } - ).sanitizeReadableSortAndGroup(query, ['fldReadable']) - ).toEqual({ - orderBy: [{ fieldId: 'fldReadable', order: SortFunc.Asc }], - groupBy: [{ fieldId: 'fldReadable', order: SortFunc.Desc }], + it('forwards explicit sort and group keys for V2 permission validation', async () => { + pluginPrepare.mockResolvedValueOnce({ + isErr: () => false, + value: { + guard: async () => ({ isErr: () => false, value: undefined }), + getScope: () => ({ + isErr: () => false, + value: { readableFieldIds: new Set([primaryFieldId]) }, + }), + }, }); - }); - it('should keep orderBy and groupBy unchanged when all fields are readable', () => { - const query = { - orderBy: [{ fieldId: 'fldReadable', order: SortFunc.Asc }], - groupBy: [{ fieldId: 'fldReadable', order: SortFunc.Desc }], - }; + await service.getRecords(tableIdText, { + fieldKeyType: FieldKeyType.Id, + orderBy: [{ fieldId: statusFieldId, order: SortFunc.Asc }], + groupBy: [{ fieldId: noteFieldId, order: SortFunc.Desc }], + includeQueryExtra: false, + }); - expect( - ( - service as unknown as { - sanitizeReadableSortAndGroup: ( - input: typeof query, - enabledFieldIds?: string[] - ) => typeof query; - } - ).sanitizeReadableSortAndGroup(query, ['fldReadable']) - ).toEqual(query); + const query = execute.mock.calls[0]?.[1] as ListTableRecordsQuery; + expect(query.sort).toEqual([ + { fieldId: noteFieldId, order: SortFunc.Desc }, + { fieldId: statusFieldId, order: SortFunc.Asc }, + ]); + expect(query.groupBy).toEqual([noteFieldId]); }); it('forwards advanced link filters into the v2 query handler instead of using docIds fallback', async () => { @@ -417,18 +557,39 @@ describe('RecordOpenApiV2Service', () => { filterLinkCellCandidate ); expect((query as ListTableRecordsQuery).selectedRecordIds).toEqual(selectedRecordIds); - expect((query as ListTableRecordsQuery).projection).toEqual([]); + expect((query as ListTableRecordsQuery).projection).toEqual([ + primaryFieldId, + statusFieldId, + noteFieldId, + createdTimeFieldId, + ]); expect((query as ListTableRecordsQuery).includeTotal).toBe(false); expect((query as ListTableRecordsQuery).viewId).toBe(viewId); expect((query as ListTableRecordsQuery).ignoreViewQuery).toBe(true); - expect(getReadQuerySource).toHaveBeenCalledWith(`tbl${'c'.repeat(16)}`, { - viewId, - keepPrimaryKey: false, - }); + expect(getReadQuerySource).not.toHaveBeenCalled(); + expect(getSnapshotBulkWithPermission).not.toHaveBeenCalled(); expect(result.records).toEqual([ - { id: 'rec1111111111111111', fields: {} }, - { id: 'rec2222222222222222', fields: {} }, + { + id: 'rec1111111111111111', + fields: { [primaryFieldId]: 'A' }, + name: 'A', + autoNumber: 1, + createdTime: createdTimeIso, + lastModifiedTime: undefined, + createdBy: undefined, + lastModifiedBy: undefined, + }, + { + id: 'rec2222222222222222', + fields: { [primaryFieldId]: 'B' }, + name: 'B', + autoNumber: 2, + createdTime: createdTimeIso, + lastModifiedTime: undefined, + createdBy: undefined, + lastModifiedBy: undefined, + }, ]); }); @@ -452,7 +613,7 @@ describe('RecordOpenApiV2Service', () => { }); expect(resolveForRecordSearch).toHaveBeenCalledWith({ - container: { resolve }, + container: { resolve, isRegistered }, tableId, search, }); @@ -462,30 +623,29 @@ describe('RecordOpenApiV2Service', () => { expect((query as ListTableRecordsQuery).recordSearchAccessPath).toBe(accessPath); }); - it('normalizes legacy ISO date filters for v2 date comparisons', async () => { - const tableId = `tbl${'c'.repeat(16)}`; - const dateFieldId = `fld${'d'.repeat(16)}`; + it('normalizes legacy ISO date filters for v2 date comparisons using table aggregate fields', async () => { const exactDate = '2026-06-02T00:00:00.000Z'; - - getFieldInstances.mockResolvedValueOnce([ - createFieldInstanceByVo({ - id: dateFieldId, - dbFieldName: 'created_date', - name: 'Created Date', - type: FieldType.Date, - cellValueType: CellValueType.DateTime, - dbFieldType: DbFieldType.DateTime, - options: { - formatting: { - date: 'YYYY-MM-DD', - time: TimeFormatting.None, - timeZone: 'Asia/Shanghai', - }, - }, + // Domain table with a date field (builder extend), not a structural field mock. + tableFindOne.mockResolvedValue({ + isErr: () => false, + value: createTestTable((builder) => { + builder + .field() + .date() + .withId(FieldId.create(dateFieldIdText)._unsafeUnwrap()) + .withName(FieldName.create('Created Date')._unsafeUnwrap()) + .withFormatting( + DateTimeFormatting.create({ + date: 'YYYY-MM-DD', + time: V2TimeFormatting.None, + timeZone: 'Asia/Shanghai', + })._unsafeUnwrap() + ) + .done(); }), - ]); + }); - await service.getRecords(tableId, { + await service.getRecords(tableIdText, { fieldKeyType: FieldKeyType.Id, skip: 0, take: 2, @@ -493,7 +653,7 @@ describe('RecordOpenApiV2Service', () => { conjunction: 'and', filterSet: [ { - fieldId: dateFieldId, + fieldId: dateFieldIdText, operator: 'isOnOrAfter', value: exactDate, }, @@ -503,11 +663,106 @@ describe('RecordOpenApiV2Service', () => { const query = execute.mock.calls[0]?.[1]; expect(query).toBeInstanceOf(ListTableRecordsQuery); + expect(getFieldInstances).not.toHaveBeenCalled(); expect((query as ListTableRecordsQuery).filter).toEqual({ conjunction: 'and', items: [ { - fieldId: dateFieldId, + fieldId: dateFieldIdText, + operator: 'isOnOrAfter', + value: { + mode: 'exactDate', + exactDate, + timeZone: 'Asia/Shanghai', + }, + }, + ], + }); + }); + + it('normalizes computed date and boolean filters from their effective result types', async () => { + const exactDate = '2026-06-02T00:00:00.000Z'; + const innerDate = createDateField({ + id: FieldId.create(`fld${'k'.repeat(16)}`)._unsafeUnwrap(), + name: FieldName.create('Inner date')._unsafeUnwrap(), + formatting: DateTimeFormatting.create({ + date: 'YYYY-MM-DD', + time: V2TimeFormatting.None, + timeZone: 'Asia/Tokyo', + })._unsafeUnwrap(), + })._unsafeUnwrap(); + testTable = createTestTable((builder) => { + builder + .field() + .formula() + .withId(FieldId.create(formulaDateFieldId)._unsafeUnwrap()) + .withName(FieldName.create('Calculated Date')._unsafeUnwrap()) + .withExpression(FormulaExpression.create('TODAY()')._unsafeUnwrap()) + .withResultType({ + cellValueType: V2CellValueType.dateTime(), + isMultipleCellValue: CellValueMultiplicity.single(), + }) + .withFormatting( + DateTimeFormatting.create({ + date: 'YYYY-MM-DD', + time: V2TimeFormatting.None, + timeZone: 'Asia/Shanghai', + })._unsafeUnwrap() + ) + .done(); + builder + .field() + .formula() + .withId(FieldId.create(formulaBooleanFieldId)._unsafeUnwrap()) + .withName(FieldName.create('Calculated Done')._unsafeUnwrap()) + .withExpression(FormulaExpression.create('TRUE')._unsafeUnwrap()) + .withResultType({ + cellValueType: V2CellValueType.boolean(), + isMultipleCellValue: CellValueMultiplicity.single(), + }) + .done(); + builder.addFieldFromResult( + createConditionalLookupField({ + id: FieldId.create(conditionalDateFieldId)._unsafeUnwrap(), + name: FieldName.create('Conditional date')._unsafeUnwrap(), + innerField: innerDate, + conditionalLookupOptions: createConditionalLookupOptions('d'), + isMultipleCellValue: false, + }) + ); + }); + tableFindOne.mockResolvedValue({ isErr: () => false, value: testTable }); + + await service.getRecords(tableIdText, { + fieldKeyType: FieldKeyType.Id, + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: formulaDateFieldId, + operator: 'isOnOrAfter', + value: exactDate, + }, + { + fieldId: formulaBooleanFieldId, + operator: 'is', + value: null, + }, + { + fieldId: conditionalDateFieldId, + operator: 'isOnOrAfter', + value: exactDate, + }, + ], + } as never, + }); + + const query = execute.mock.calls[0]?.[1] as ListTableRecordsQuery; + expect(query.filter).toEqual({ + conjunction: 'and', + items: [ + { + fieldId: formulaDateFieldId, operator: 'isOnOrAfter', value: { mode: 'exactDate', @@ -515,6 +770,20 @@ describe('RecordOpenApiV2Service', () => { timeZone: 'Asia/Shanghai', }, }, + { + fieldId: formulaBooleanFieldId, + operator: 'is', + value: false, + }, + { + fieldId: conditionalDateFieldId, + operator: 'isOnOrAfter', + value: { + mode: 'exactDate', + exactDate, + timeZone: 'Asia/Tokyo', + }, + }, ], }); }); @@ -522,11 +791,27 @@ describe('RecordOpenApiV2Service', () => { it('loads grouped query extra by default for grouped record reads', async () => { const tableId = `tbl${'c'.repeat(16)}`; const groupBy = [{ fieldId: statusFieldId, order: SortFunc.Asc }]; - const extra = { - groupPoints: [{ type: 1, count: 2 }], - allGroupHeaderRefs: [], - }; - getDocIdsByQuery.mockResolvedValueOnce({ extra }); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create( + [ + { + id: 'rec1111111111111111', + fields: { [primaryFieldId]: 'A' }, + version: 1, + }, + { + id: 'rec2222222222222222', + fields: { [primaryFieldId]: 'B' }, + version: 1, + }, + ], + 2, + 0, + 2, + [{ fields: { [statusFieldId]: 'Open' }, count: 2 }] + ), + }); const result = await service.getRecords(tableId, { fieldKeyType: FieldKeyType.Id, @@ -535,12 +820,268 @@ describe('RecordOpenApiV2Service', () => { groupBy, }); - expect(getDocIdsByQuery).toHaveBeenCalledWith( - tableId, - expect.objectContaining({ groupBy }), - true + expect(getDocIdsByQuery).not.toHaveBeenCalled(); + expect(result.extra).toEqual({ + searchHitIndex: null, + groupPoints: [ + expect.objectContaining({ type: 0, depth: 0, value: 'Open' }), + { type: 1, count: 2 }, + ], + allGroupHeaderRefs: [expect.objectContaining({ depth: 0 })], + }); + const query = execute.mock.calls[0]?.[1] as ListTableRecordsQuery; + expect(query.includeGroupMetadata).toBe(true); + }); + + it('omits the legacy searchHitIndex on grouped searches instead of paging without groupBy', async () => { + const tableId = `tbl${'c'.repeat(16)}`; + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create( + [{ id: 'rec1111111111111111', fields: { [primaryFieldId]: 'A' }, version: 1 }], + 1, + 0, + 2, + [{ fields: { [statusFieldId]: 'Open' }, count: 1 }] + ), + }); + + const result = await service.getRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + skip: 0, + take: 2, + groupBy: [{ fieldId: statusFieldId, order: SortFunc.Asc }], + search: ['A'], + }); + + // The V1 extra query pages by a groupBy-free sort; its hit index would + // reference the wrong rows for the grouped V2 page. + expect(getDocIdsByQuery).not.toHaveBeenCalled(); + expect(result.extra?.searchHitIndex).toBeNull(); + expect(result.extra?.groupPoints).toBeDefined(); + }); + + it('keeps projected group metadata on generated-index searches', async () => { + const tableId = `tbl${'c'.repeat(16)}`; + const groupBy = [{ fieldId: statusFieldId, order: SortFunc.Asc }]; + resolveForRecordSearch.mockResolvedValueOnce({ + kind: 'generated_tsvector', + generatedColumnName: '__tqops_search_vector', + languageConfig: 'simple', + searchScope: 'all_fields', + coveredFieldIds: [FieldId.create(statusFieldId)._unsafeUnwrap()], + }); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create([], 1, 0, 1, [ + { fields: { [statusFieldId]: 'Open' }, count: 1 }, + ]), + }); + + const result = await service.getRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + groupBy, + projection: [statusFieldId], + search: ['Open'], + }); + + const query = execute.mock.calls[0]?.[1] as ListTableRecordsQuery; + expect(query.includeGroupMetadata).toBe(true); + expect(getDocIdsByQuery).not.toHaveBeenCalled(); + expect(result.extra?.groupPoints).toEqual([ + expect.objectContaining({ type: 0, depth: 0, value: 'Open' }), + { type: 1, count: 1 }, + ]); + }); + + it('keeps authority-matrix row scope and client filter on the V2 grouped query', async () => { + const recordSpec = { isSatisfiedBy: () => true } as never; + pluginPrepare.mockResolvedValueOnce({ + isErr: () => false, + value: { + guard: async () => ({ isErr: () => false, value: undefined }), + getScope: () => ({ + isErr: () => false, + value: { + recordSpec, + readableFieldIds: new Set([primaryFieldId, statusFieldId]), + legacyPermissionQueryCompatible: true, + }, + }), + }, + }); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create([], 1, 0, 1, [ + { fields: { [statusFieldId]: 'Open' }, count: 1 }, + ]), + }); + + const result = await service.getRecords(tableIdText, { + fieldKeyType: FieldKeyType.Id, + skip: 0, + take: 2, + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: primaryFieldId, + operator: 'contains', + value: 'ticket', + }, + ], + }, + groupBy: [{ fieldId: statusFieldId, order: SortFunc.Asc }], + }); + + const query = execute.mock.calls[0]?.[1] as ListTableRecordsQuery; + expect(query.queryScope?.recordSpec).toBe(recordSpec); + expect(query.queryScope?.readableFieldIds).toEqual(new Set([primaryFieldId, statusFieldId])); + expect(query.filter).toEqual({ + conjunction: 'and', + items: [{ fieldId: primaryFieldId, operator: 'contains', value: 'ticket' }], + }); + expect(query.includeGroupMetadata).toBe(true); + expect(getDocIdsByQuery).not.toHaveBeenCalled(); + expect(result.extra?.groupPoints).toEqual([ + expect.objectContaining({ type: 0, depth: 0, value: 'Open' }), + { type: 1, count: 1 }, + ]); + }); + + it('preserves the V1 null checkbox group-header value', async () => { + testTable = createTestTable((builder) => { + builder + .field() + .checkbox() + .withId(FieldId.create(checkboxFieldId)._unsafeUnwrap()) + .withName(FieldName.create('Done')._unsafeUnwrap()) + .done(); + }); + tableFindOne.mockResolvedValue({ isErr: () => false, value: testTable }); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create([], 1, 0, 1, [ + { fields: { [checkboxFieldId]: null }, count: 1 }, + ]), + }); + + const result = await service.getRecords(tableIdText, { + fieldKeyType: FieldKeyType.Id, + skip: 0, + take: 2, + groupBy: [{ fieldId: checkboxFieldId, order: SortFunc.Asc }], + }); + + expect(result.extra?.groupPoints).toEqual([ + expect.objectContaining({ type: 0, depth: 0, value: null }), + { type: 1, count: 1 }, + ]); + }); + + it('normalizes generated user group header avatars to the public avatar URL', async () => { + const userId = `usr${'g'.repeat(16)}`; + testTable = createTestTable((builder) => { + builder + .field() + .createdBy() + .withId(FieldId.create(createdByFieldId)._unsafeUnwrap()) + .withName(FieldName.create('Created By')._unsafeUnwrap()) + .done(); + }); + tableFindOne.mockResolvedValue({ isErr: () => false, value: testTable }); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create([], 1, 0, 1, [ + { + fields: { + [createdByFieldId]: { + id: userId, + title: 'Grace', + avatarUrl: '/api/attachments/avatar/grace.png', + }, + }, + count: 1, + }, + ]), + }); + + const result = await service.getRecords(tableIdText, { + fieldKeyType: FieldKeyType.Id, + groupBy: [{ fieldId: createdByFieldId, order: SortFunc.Asc }], + }); + + expect(result.extra?.groupPoints).toEqual([ + expect.objectContaining({ + type: 0, + depth: 0, + value: { + id: userId, + title: 'Grace', + avatarUrl: expect.stringContaining(`/avatar/${userId}`), + }, + }), + { type: 1, count: 1 }, + ]); + }); + + it('hydrates legacy generated user ids in group headers', async () => { + const userId = `usr${'g'.repeat(16)}`; + const listUsersByIdentifiers = vi.fn().mockResolvedValue({ + isErr: () => false, + isOk: () => true, + value: [{ id: userId, name: 'Grace', email: 'grace@example.com' }], + }); + testTable = createTestTable((builder) => { + builder + .field() + .createdBy() + .withId(FieldId.create(createdByFieldId)._unsafeUnwrap()) + .withName(FieldName.create('Created By')._unsafeUnwrap()) + .done(); + }); + tableFindOne.mockResolvedValue({ isErr: () => false, value: testTable }); + isRegistered.mockImplementation( + (token) => + token === v2CoreTokens.queryBus || + token === v2CoreTokens.commandBus || + token === v2CoreTokens.tableRepository || + token === v2CoreTokens.recordQueryPluginRunner || + token === v2CoreTokens.userLookupService ); - expect(result.extra).toEqual(extra); + resolve.mockImplementation((token) => { + if (token === v2CoreTokens.queryBus) return { execute }; + if (token === v2CoreTokens.commandBus) return { execute: commandExecute }; + if (token === v2CoreTokens.tableRepository) return { findOne: tableFindOne }; + if (token === v2CoreTokens.recordQueryPluginRunner) return { prepare: pluginPrepare }; + if (token === v2CoreTokens.userLookupService) return { listUsersByIdentifiers }; + return undefined; + }); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create([], 1, 0, 1, [ + { fields: { [createdByFieldId]: userId }, count: 1 }, + ]), + }); + + const result = await service.getRecords(tableIdText, { + fieldKeyType: FieldKeyType.Id, + groupBy: [{ fieldId: createdByFieldId, order: SortFunc.Asc }], + }); + + expect(listUsersByIdentifiers).toHaveBeenCalledWith([userId]); + expect(result.extra?.groupPoints).toEqual([ + expect.objectContaining({ + type: 0, + depth: 0, + value: { + id: userId, + title: 'Grace', + avatarUrl: expect.stringContaining(`/avatar/${userId}`), + }, + }), + { type: 1, count: 1 }, + ]); }); it('skips grouped query extra when includeQueryExtra is false', async () => { @@ -564,9 +1105,15 @@ describe('RecordOpenApiV2Service', () => { expect((query as ListTableRecordsQuery).groupBy).toEqual([statusFieldId]); }); - it('skips grouped query extra by default for projected record reads', async () => { + it('loads grouped query extra by default for projected record reads', async () => { const tableId = `tbl${'c'.repeat(16)}`; const groupBy = [{ fieldId: statusFieldId, order: SortFunc.Asc }]; + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create([], 2, 0, 2, [ + { fields: { [statusFieldId]: 'Open' }, count: 2 }, + ]), + }); const result = await service.getRecords(tableId, { fieldKeyType: FieldKeyType.Id, @@ -577,7 +1124,10 @@ describe('RecordOpenApiV2Service', () => { }); expect(getDocIdsByQuery).not.toHaveBeenCalled(); - expect(result.extra).toBeUndefined(); + expect(result.extra?.groupPoints).toEqual([ + expect.objectContaining({ type: 0, depth: 0, value: 'Open' }), + { type: 1, count: 2 }, + ]); const query = execute.mock.calls[0]?.[1]; expect(query).toBeInstanceOf(ListTableRecordsQuery); @@ -588,11 +1138,12 @@ describe('RecordOpenApiV2Service', () => { it('loads grouped query extra for projected record reads when explicitly requested', async () => { const tableId = `tbl${'c'.repeat(16)}`; const groupBy = [{ fieldId: statusFieldId, order: SortFunc.Asc }]; - const extra = { - groupPoints: [{ type: 1, count: 2 }], - allGroupHeaderRefs: [], - }; - getDocIdsByQuery.mockResolvedValueOnce({ extra }); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create([], 2, 0, 2, [ + { fields: { [statusFieldId]: 'Open' }, count: 2 }, + ]), + }); const result = await service.getRecords(tableId, { fieldKeyType: FieldKeyType.Id, @@ -603,23 +1154,15 @@ describe('RecordOpenApiV2Service', () => { includeQueryExtra: true, }); - expect(getDocIdsByQuery).toHaveBeenCalledWith( - tableId, - expect.objectContaining({ groupBy, projection: [statusFieldId, noteFieldId] }), - true - ); - expect(result.extra).toEqual(extra); + expect(getDocIdsByQuery).not.toHaveBeenCalled(); + expect(result.extra?.groupPoints).toEqual([ + expect.objectContaining({ type: 0, depth: 0, value: 'Open' }), + { type: 1, count: 2 }, + ]); }); - it('runs legacy snapshot compatibility reads against the table data client for BYODB tables', async () => { + it('reads records through pure v2 list without legacy snapshot bulk', async () => { const tableId = `tbl${'c'.repeat(16)}`; - const dataPrisma = { $queryRawUnsafe: vi.fn() }; - getDataDatabaseForTable.mockResolvedValue({ - cacheKey: 'ddc-byodb', - url: 'postgresql://byodb', - isMetaFallback: false, - }); - dataPrismaForTable.mockResolvedValue(dataPrisma); const result = await service.getRecords(tableId, { fieldKeyType: FieldKeyType.Id, @@ -627,145 +1170,985 @@ describe('RecordOpenApiV2Service', () => { take: 2, }); - expect(result.records).toEqual([ - { id: 'rec1111111111111111', fields: {} }, - { id: 'rec2222222222222222', fields: {} }, + expect(result.records.map((record) => record.id)).toEqual([ + 'rec1111111111111111', + 'rec2222222222222222', ]); - expect(dataPrismaForTable).toHaveBeenCalledWith(tableId); - expect(clsRunWith).toHaveBeenCalled(); - expect(clsSet).toHaveBeenCalledWith('dataTx.client', dataPrisma); - expect(clsSet).toHaveBeenLastCalledWith('dataTx.client', undefined); - expect(getSnapshotBulkWithPermission).toHaveBeenCalledTimes(1); + expect(getSnapshotBulkWithPermission).not.toHaveBeenCalled(); + expect(getFieldsByQuery).not.toHaveBeenCalled(); + expect(pluginPrepare).toHaveBeenCalled(); + expect(tableFindOne).toHaveBeenCalled(); }); - it('formats sorted top-level system datetime fields in the final OpenAPI response', async () => { - execute.mockResolvedValue({ + it('hydrates legacy generated audit-user ids into public user cells', async () => { + const userId = `usr${'a'.repeat(16)}`; + const listUsersByIdentifiers = vi.fn().mockResolvedValue({ + isErr: () => false, + isOk: () => true, + value: [ + { + id: userId, + name: 'Alice', + email: 'alice@example.com', + avatarUrl: '/api/attachments/avatar/alice.png', + }, + ], + }); + testTable = createTestTable((builder) => { + builder + .field() + .createdBy() + .withId(FieldId.create(createdByFieldId)._unsafeUnwrap()) + .withName(FieldName.create('Created By')._unsafeUnwrap()) + .done(); + }); + tableFindOne.mockResolvedValue({ isErr: () => false, value: testTable }); + isRegistered.mockImplementation( + (token) => + token === v2CoreTokens.queryBus || + token === v2CoreTokens.commandBus || + token === v2CoreTokens.tableRepository || + token === v2CoreTokens.recordQueryPluginRunner || + token === v2CoreTokens.userLookupService + ); + resolve.mockImplementation((token) => { + if (token === v2CoreTokens.queryBus) return { execute }; + if (token === v2CoreTokens.commandBus) return { execute: commandExecute }; + if (token === v2CoreTokens.tableRepository) return { findOne: tableFindOne }; + if (token === v2CoreTokens.recordQueryPluginRunner) return { prepare: pluginPrepare }; + if (token === v2CoreTokens.userLookupService) return { listUsersByIdentifiers }; + return undefined; + }); + execute.mockResolvedValueOnce({ isErr: () => false, value: ListTableRecordsResult.create( - [{ id: 'rec1111111111111111', fields: {}, version: 1 }], + [ + { + id: 'rec1111111111111111', + fields: { + [primaryFieldId]: 'A', + [createdByFieldId]: userId, + }, + version: 1, + }, + ], 1, 0, 1 ), }); - getSnapshotBulkWithPermission.mockResolvedValue([ - { - data: { - id: 'rec1111111111111111', - createdTime: createdTimeIso, - fields: { - createdTime: createdTimeIso, + + const result = await service.getRecords(tableIdText, { + fieldKeyType: FieldKeyType.Id, + skip: 0, + take: 1, + }); + + expect(listUsersByIdentifiers).toHaveBeenCalledWith([userId]); + expect(result.records[0]?.fields[createdByFieldId]).toEqual({ + id: userId, + title: 'Alice', + email: 'alice@example.com', + avatarUrl: expect.stringContaining(`/avatar/${userId}`), + }); + }); + + it('preserves V1 last-modified-by cell identity while normalizing its public shape', async () => { + const lastModifiedByFieldId = `fld${'e'.repeat(16)}`; + const userId = `usr${'a'.repeat(16)}`; + testTable = createTestTable((builder) => { + builder + .field() + .lastModifiedBy() + .withId(FieldId.create(lastModifiedByFieldId)._unsafeUnwrap()) + .withName(FieldName.create('Last Modified By')._unsafeUnwrap()) + .done(); + }); + tableFindOne.mockResolvedValue({ isErr: () => false, value: testTable }); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create( + [ + { + id: 'rec1111111111111111', + fields: { + [primaryFieldId]: 'A', + [lastModifiedByFieldId]: userId, + }, + version: 1, }, - }, + ], + 1, + 0, + 1 + ), + }); + + const result = await service.getRecords(tableIdText, { + fieldKeyType: FieldKeyType.Id, + skip: 0, + take: 1, + }); + + expect(result.records[0]?.fields[lastModifiedByFieldId]).toEqual({ + id: userId, + title: userId, + avatarUrl: expect.stringContaining(`/avatar/${userId}`), + }); + }); + + it('hydrates lookup user cells and conditional-lookup user group headers', async () => { + const userId = `usr${'w'.repeat(16)}`; + const listUsersByIdentifiers = vi.fn().mockResolvedValue({ + isErr: () => false, + isOk: () => true, + value: [{ id: userId, name: 'Wendy', email: 'wendy@example.com' }], + }); + const innerUser = createUserField({ + id: FieldId.create(`fld${'i'.repeat(16)}`)._unsafeUnwrap(), + name: FieldName.create('Inner user')._unsafeUnwrap(), + isMultiple: UserMultiplicity.single(), + })._unsafeUnwrap(); + testTable = createTestTable((builder) => { + builder.addFieldFromResult( + LookupField.create({ + id: FieldId.create(lookupUserFieldId)._unsafeUnwrap(), + name: FieldName.create('Lookup user')._unsafeUnwrap(), + innerField: innerUser, + lookupOptions: createLookupOptions('r'), + isMultipleCellValue: true, + }) + ); + builder.addFieldFromResult( + createConditionalLookupField({ + id: FieldId.create(conditionalUserFieldId)._unsafeUnwrap(), + name: FieldName.create('Conditional user')._unsafeUnwrap(), + innerField: innerUser, + conditionalLookupOptions: createConditionalLookupOptions('u'), + isMultipleCellValue: true, + }) + ); + }); + tableFindOne.mockResolvedValue({ isErr: () => false, value: testTable }); + isRegistered.mockImplementation( + (token) => + token === v2CoreTokens.queryBus || + token === v2CoreTokens.commandBus || + token === v2CoreTokens.tableRepository || + token === v2CoreTokens.recordQueryPluginRunner || + token === v2CoreTokens.userLookupService + ); + resolve.mockImplementation((token) => { + if (token === v2CoreTokens.queryBus) return { execute }; + if (token === v2CoreTokens.commandBus) return { execute: commandExecute }; + if (token === v2CoreTokens.tableRepository) return { findOne: tableFindOne }; + if (token === v2CoreTokens.recordQueryPluginRunner) return { prepare: pluginPrepare }; + if (token === v2CoreTokens.userLookupService) return { listUsersByIdentifiers }; + return undefined; + }); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create( + [ + { + id: 'rec1111111111111111', + fields: { + [primaryFieldId]: 'A', + [lookupUserFieldId]: [ + { + id: userId, + title: userId, + avatarUrl: '/api/attachments/avatar/legacy.png', + }, + ], + [conditionalUserFieldId]: [ + { + id: userId, + title: userId, + avatarUrl: '/api/attachments/avatar/legacy.png', + }, + ], + }, + version: 1, + }, + ], + 1, + 0, + 1, + [ + { + fields: { + [conditionalUserFieldId]: [ + { + id: userId, + title: userId, + avatarUrl: '/api/attachments/avatar/legacy.png', + }, + ], + }, + count: 1, + }, + ] + ), + }); + + const result = await service.getRecords(tableIdText, { + fieldKeyType: FieldKeyType.Id, + groupBy: [{ fieldId: conditionalUserFieldId, order: SortFunc.Asc }], + }); + + expect(listUsersByIdentifiers).toHaveBeenCalledWith([userId]); + expect(result.records[0]?.fields[lookupUserFieldId]).toEqual([ + { + id: userId, + title: 'Wendy', + email: 'wendy@example.com', + avatarUrl: expect.stringContaining(`/avatar/${userId}`), }, ]); - getFieldsByQuery.mockResolvedValue([ + expect(result.records[0]?.fields[conditionalUserFieldId]).toEqual([ { - id: 'fldCreatedTime0001', - name: 'createdTime', - type: FieldType.CreatedTime, - cellValueType: CellValueType.DateTime, - isMultipleCellValue: false, - dbFieldType: 'timestamp', - options: { - formatting: { - date: 'YYYY-MM-DD', - time: 'None', - timeZone: 'UTC', - }, - }, + id: userId, + title: 'Wendy', + email: 'wendy@example.com', + avatarUrl: expect.stringContaining(`/avatar/${userId}`), }, ]); + expect(result.extra?.groupPoints).toEqual([ + expect.objectContaining({ + type: 0, + depth: 0, + value: [ + { + id: userId, + title: 'Wendy', + avatarUrl: expect.stringContaining(`/avatar/${userId}`), + }, + ], + }), + { type: 1, count: 1 }, + ]); + }); - const result = await service.getRecords(`tbl${'c'.repeat(16)}`, { - fieldKeyType: FieldKeyType.Name, + it('does not fill tracked-subset LastModifiedBy cells from the record system user', async () => { + const lastModifiedByFieldId = `fld${'x'.repeat(16)}`; + const userId = `usr${'a'.repeat(16)}`; + testTable = createTestTable((builder) => { + builder + .field() + .lastModifiedBy() + .withId(FieldId.create(lastModifiedByFieldId)._unsafeUnwrap()) + .withName(FieldName.create('Last Modified By')._unsafeUnwrap()) + .withTrackedFieldIds([FieldId.create(primaryFieldId)._unsafeUnwrap()]) + .done(); + }); + tableFindOne.mockResolvedValue({ isErr: () => false, value: testTable }); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create( + [ + { + id: 'rec1111111111111111', + fields: { [primaryFieldId]: 'A' }, + version: 1, + lastModifiedBy: userId, + }, + ], + 1, + 0, + 1 + ), + }); + + const result = await service.getRecords(tableIdText, { + fieldKeyType: FieldKeyType.Id, skip: 0, take: 1, - orderBy: [{ fieldId: 'fldCreatedTime0001', order: SortFunc.Asc }], }); - expect(result.records).toEqual([ - { - id: 'rec1111111111111111', - createdTime: '2026-03-19', - fields: { - createdTime: '2026-03-19T01:02:03.000Z', - }, - }, - ]); - expect(getSnapshotBulkWithPermission).toHaveBeenCalledTimes(1); - expect(getFieldsByQuery).toHaveBeenCalledWith(`tbl${'c'.repeat(16)}`, { - projection: ['fldCreatedTime0001'], + expect(result.records[0]?.lastModifiedBy).toBe(userId); + expect(result.records[0]?.fields).not.toHaveProperty(lastModifiedByFieldId); + }); + + it('uses each field formatter for cellFormat=text record values', async () => { + testTable = createTestTable((builder) => { + builder + .field() + .number() + .withId(FieldId.create(formattedNumberFieldId)._unsafeUnwrap()) + .withName(FieldName.create('Amount')._unsafeUnwrap()) + .withFormatting(NumberFormatting.create({ type: 'decimal', precision: 2 })._unsafeUnwrap()) + .done(); + }); + tableFindOne.mockResolvedValue({ isErr: () => false, value: testTable }); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create( + [ + { + id: 'rec1111111111111111', + fields: { + [primaryFieldId]: 'A', + [formattedNumberFieldId]: 1.234, + }, + version: 1, + }, + ], + 1, + 0, + 1 + ), }); + + const result = await service.getRecords(tableIdText, { + fieldKeyType: FieldKeyType.Id, + cellFormat: CellFormat.Text, + skip: 0, + take: 1, + }); + + expect(result.records[0]?.fields[formattedNumberFieldId]).toBe('1.23'); }); - it('does not normalize system datetime fields when they are not part of the active sort', async () => { - execute.mockResolvedValue({ + it('uses conditional lookup inner formatting for cellFormat=text values', async () => { + const innerNumber = createNumberField({ + id: FieldId.create(`fld${'j'.repeat(16)}`)._unsafeUnwrap(), + name: FieldName.create('Inner amount')._unsafeUnwrap(), + formatting: NumberFormatting.create({ type: 'decimal', precision: 2 })._unsafeUnwrap(), + })._unsafeUnwrap(); + testTable = createTestTable((builder) => { + builder.addFieldFromResult( + createConditionalLookupField({ + id: FieldId.create(conditionalNumberFieldId)._unsafeUnwrap(), + name: FieldName.create('Conditional amount')._unsafeUnwrap(), + innerField: innerNumber, + conditionalLookupOptions: createConditionalLookupOptions('n'), + isMultipleCellValue: false, + }) + ); + }); + tableFindOne.mockResolvedValue({ isErr: () => false, value: testTable }); + execute.mockResolvedValueOnce({ isErr: () => false, value: ListTableRecordsResult.create( - [{ id: 'rec1111111111111111', fields: {}, version: 1 }], + [ + { + id: 'rec1111111111111111', + fields: { + [primaryFieldId]: 'A', + [conditionalNumberFieldId]: 1.234, + }, + version: 1, + }, + ], 1, 0, 1 ), }); - getSnapshotBulkWithPermission.mockResolvedValue([ + + const result = await service.getRecords(tableIdText, { + fieldKeyType: FieldKeyType.Id, + cellFormat: CellFormat.Text, + skip: 0, + take: 1, + }); + + expect(result.records[0]?.fields[conditionalNumberFieldId]).toBe('1.23'); + }); + + it('uses the primary field formatter for JSON record names', async () => { + const builder = Table.builder() + .withId(TableId.create(tableIdText)._unsafeUnwrap()) + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Formatted primary')._unsafeUnwrap()); + builder + .field() + .number() + .withId(FieldId.create(formattedNumberFieldId)._unsafeUnwrap()) + .withName(FieldName.create('Amount')._unsafeUnwrap()) + .withFormatting(NumberFormatting.create({ type: 'decimal', precision: 2 })._unsafeUnwrap()) + .primary() + .done(); + builder.view().defaultGrid().done(); + testTable = builder.build()._unsafeUnwrap(); + tableFindOne.mockResolvedValue({ isErr: () => false, value: testTable }); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create( + [ + { + id: 'rec1111111111111111', + fields: { [formattedNumberFieldId]: 1.234 }, + version: 1, + }, + ], + 1, + 0, + 1 + ), + }); + + const result = await service.getRecords(tableIdText, { + fieldKeyType: FieldKeyType.Id, + cellFormat: CellFormat.Json, + skip: 0, + take: 1, + }); + + expect(result.records[0]?.name).toBe('1.23'); + expect(result.records[0]?.fields[formattedNumberFieldId]).toBe(1.234); + }); + + it('builds ShareDB snapshots from pure v2 records with persisted versions and getByIds scope', async () => { + const tableId = `tbl${'c'.repeat(16)}`; + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create( + [ + { + id: 'rec1111111111111111', + fields: { [primaryFieldId]: 'A' }, + version: 7, + autoNumber: 1, + createdTime: createdTimeIso, + }, + ], + 1, + 0, + 1 + ), + }); + + const result = await service.getSocketSnapshotBulk(tableId, ['rec1111111111111111'], { + [primaryFieldId]: true, + }); + + expect(result).toEqual([ { + id: 'rec1111111111111111', + v: 7, + type: 'json0', data: { id: 'rec1111111111111111', + fields: { [primaryFieldId]: 'A' }, + name: 'A', + autoNumber: 1, createdTime: createdTimeIso, - fields: { - createdTime: createdTimeIso, - }, }, }, ]); + expect(pluginPrepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'getByIds', + payload: expect.objectContaining({ + recordIds: ['rec1111111111111111'], + projectionFieldIds: [primaryFieldId], + ignoreViewQuery: true, + keepPrimaryKey: true, + }), + }) + ); + expect(getSnapshotBulkWithPermission).not.toHaveBeenCalled(); + }); - const result = await service.getRecords(`tbl${'c'.repeat(16)}`, { - fieldKeyType: FieldKeyType.Name, + it('chunks ShareDB snapshot reads above the public list limit', async () => { + const recordIds = Array.from( + { length: 1001 }, + (_, index) => `rec${String(index).padStart(16, '0')}` + ); + execute.mockImplementation(async (_context, query: ListTableRecordsQuery) => { + const selectedRecordIds = query.selectedRecordIds ?? []; + return { + isErr: () => false, + value: ListTableRecordsResult.create( + selectedRecordIds.map((recordId, index) => ({ + id: recordId, + fields: { [primaryFieldId]: recordId }, + version: index + 1, + })), + selectedRecordIds.length, + 0, + selectedRecordIds.length + ), + }; + }); + + const result = await service.getSocketSnapshotBulk(tableIdText, recordIds, { + [primaryFieldId]: true, + }); + + expect(result).toHaveLength(1001); + expect(result.map((snapshot) => snapshot.id)).toEqual(recordIds); + expect(execute).toHaveBeenCalledTimes(2); + expect( + execute.mock.calls.map((call) => (call[1] as ListTableRecordsQuery).selectedRecordIds?.length) + ).toEqual([1000, 1]); + }); + + it('resolves ShareDB query ids through the v2 list scope without legacy doc-id reads', async () => { + const tableId = `tbl${'c'.repeat(16)}`; + + const result = await service.getSocketDocIds(tableId, { + viewId: `viw${'v'.repeat(16)}`, skip: 0, - take: 1, + take: 2, }); - expect(result.records).toEqual([ + expect(result).toEqual({ + ids: ['rec1111111111111111', 'rec2222222222222222'], + }); + expect(pluginPrepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'list', + payload: expect.objectContaining({ + viewId: `viw${'v'.repeat(16)}`, + limit: 2, + offset: 0, + }), + }) + ); + const query = execute.mock.calls[0]?.[1] as ListTableRecordsQuery; + expect(query.projection).toEqual([]); + expect(getDocIdsByQuery).not.toHaveBeenCalled(); + }); + + it('uses legacy mask-aware ordering and revalidates ids through the v2 scope for ShareDB', async () => { + const tableId = `tbl${'c'.repeat(16)}`; + const extra = { + groupPoints: [{ type: 1, count: 2 }], + }; + const recordSpec = { isSatisfiedBy: () => true } as never; + pluginPrepare.mockResolvedValueOnce({ + isErr: () => false, + value: { + guard: async () => ({ isErr: () => false, value: undefined }), + getScope: () => ({ + isErr: () => false, + value: { + recordSpec, + fieldMasks: [{ fieldId: statusFieldId, visibleWhen: recordSpec }], + legacyPermissionQueryCompatible: true, + }, + }), + }, + }); + getDocIdsByQuery.mockResolvedValueOnce({ + ids: ['rec2222222222222222', 'rec1111111111111111'], + extra, + }); + + const result = await service.getSocketDocIds(tableId, { + groupBy: [{ fieldId: statusFieldId, order: SortFunc.Asc }], + skip: 0, + take: 2, + }); + + expect(result).toEqual({ + ids: ['rec2222222222222222', 'rec1111111111111111'], + extra, + }); + expect(getDocIdsByQuery).toHaveBeenCalledWith( + tableId, + expect.objectContaining({ + groupBy: [{ fieldId: statusFieldId, order: SortFunc.Asc }], + skip: 0, + take: 2, + }), + true + ); + const query = execute.mock.calls[0]?.[1] as ListTableRecordsQuery; + expect(query.selectedRecordIds).toEqual(['rec2222222222222222', 'rec1111111111111111']); + expect(query.groupBy).toBeUndefined(); + expect(query.search).toBeUndefined(); + expect(query.queryScope?.recordSpec).toBe(recordSpec); + }); + + describe('ShareDB authorization compatibility matrix', () => { + it('preserves legacy order and drops extra when V2 scope rejects any legacy id', async () => { + const deniedRecordId = 'rec3333333333333333'; + const extra = { + groupPoints: [{ type: 1, count: 3 }], + }; + const recordSpec = { isSatisfiedBy: () => true } as never; + pluginPrepare.mockResolvedValueOnce({ + isErr: () => false, + value: { + guard: async () => ({ isErr: () => false, value: undefined }), + getScope: () => ({ + isErr: () => false, + value: { + recordSpec, + fieldMasks: [{ fieldId: statusFieldId, visibleWhen: recordSpec }], + legacyPermissionQueryCompatible: true, + }, + }), + }, + }); + getDocIdsByQuery.mockResolvedValueOnce({ + ids: [deniedRecordId, 'rec2222222222222222', 'rec1111111111111111'], + extra, + }); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create( + [ + { + id: 'rec1111111111111111', + fields: { [primaryFieldId]: 'A' }, + version: 1, + autoNumber: 1, + createdTime: createdTimeIso, + }, + { + id: 'rec2222222222222222', + fields: { [primaryFieldId]: 'B' }, + version: 1, + autoNumber: 2, + createdTime: createdTimeIso, + }, + ], + 2, + 0, + 3 + ), + }); + + const result = await service.getSocketDocIds(tableIdText, { + groupBy: [{ fieldId: statusFieldId, order: SortFunc.Asc }], + skip: 0, + take: 3, + }); + + expect(result).toEqual({ + ids: ['rec2222222222222222', 'rec1111111111111111'], + }); + }); + + it.each([ { - id: 'rec1111111111111111', - createdTime: createdTimeIso, - fields: { - createdTime: createdTimeIso, + label: 'masked sort without query extra', + query: { + orderBy: [{ fieldId: statusFieldId, order: SortFunc.Asc }], + includeQueryExtra: false, }, }, - ]); - expect(getSnapshotBulkWithPermission).toHaveBeenCalledTimes(1); - expect(getFieldsByQuery).not.toHaveBeenCalled(); + { + label: 'masked group without query extra', + query: { + groupBy: [{ fieldId: statusFieldId, order: SortFunc.Asc }], + includeQueryExtra: false, + }, + }, + { + label: 'masked search without query extra', + query: { + search: ['secret', statusFieldId, true] as [string, string, boolean], + includeQueryExtra: false, + }, + }, + ])('routes $label through legacy mask-aware membership', async ({ query }) => { + const recordSpec = { isSatisfiedBy: () => true } as never; + pluginPrepare.mockResolvedValueOnce({ + isErr: () => false, + value: { + guard: async () => ({ isErr: () => false, value: undefined }), + getScope: () => ({ + isErr: () => false, + value: { + recordSpec, + fieldMasks: [{ fieldId: statusFieldId, visibleWhen: recordSpec }], + legacyPermissionQueryCompatible: true, + }, + }), + }, + }); + getDocIdsByQuery.mockResolvedValueOnce({ + ids: ['rec2222222222222222', 'rec1111111111111111'], + }); + + const result = await service.getSocketDocIds(tableIdText, { + ...query, + skip: 0, + take: 2, + }); + + expect(result.ids).toEqual(['rec2222222222222222', 'rec1111111111111111']); + expect(getDocIdsByQuery).toHaveBeenCalledTimes(1); + }); + + it('stays on strict V2 when any restricting plugin removes legacy compatibility', async () => { + const recordSpec = { isSatisfiedBy: () => true } as never; + pluginPrepare.mockResolvedValueOnce({ + isErr: () => false, + value: { + guard: async () => ({ isErr: () => false, value: undefined }), + getScope: () => ({ + isErr: () => false, + value: { + recordSpec, + fieldMasks: [{ fieldId: statusFieldId, visibleWhen: recordSpec }], + }, + }), + }, + }); + + const result = await service.getSocketDocIds(tableIdText, { + groupBy: [{ fieldId: statusFieldId, order: SortFunc.Asc }], + includeQueryExtra: false, + skip: 0, + take: 2, + }); + + expect(result.ids).toEqual(['rec1111111111111111', 'rec2222222222222222']); + expect(getDocIdsByQuery).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'], + it('intersects ShareDB snapshot projection with v2 readable fields', async () => { + pluginPrepare.mockResolvedValueOnce({ + isErr: () => false, + value: { + guard: async () => ({ isErr: () => false, value: undefined }), + getScope: () => ({ + isErr: () => false, + value: { readableFieldIds: new Set([primaryFieldId]) }, + }), + }, }); + + await service.getSocketSnapshotBulk(`tbl${'c'.repeat(16)}`, ['rec1111111111111111'], { + [primaryFieldId]: true, + [noteFieldId]: true, + }); + + const query = execute.mock.calls[0]?.[1] as ListTableRecordsQuery; + expect(query.projection).toEqual([primaryFieldId]); + expect(query.queryScope?.readableFieldIds).toEqual(new Set([primaryFieldId])); + }); + + it('applies collapsed group filters before resolving ShareDB query ids', async () => { + const collapsedGroupId = String(string2Hash(`${statusFieldId}_Open`)); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create([], 2, 0, 2, [ + { fields: { [statusFieldId]: 'Open' }, count: 2 }, + ]), + }); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create([], 0, 0, 2), + }); + + await service.getSocketDocIds(`tbl${'c'.repeat(16)}`, { + viewId: `viw${'v'.repeat(16)}`, + groupBy: [{ fieldId: statusFieldId, order: SortFunc.Asc }], + collapsedGroupIds: [collapsedGroupId], + skip: 0, + take: 2, + includeQueryExtra: false, + }); + + expect(getGroupRelatedData).not.toHaveBeenCalled(); + expect(execute).toHaveBeenCalledTimes(2); + const query = execute.mock.calls[1]?.[1] as ListTableRecordsQuery; + // V1 parity: null-inclusive isNot keeps empty-bucket rows visible. + expect(query.filter).toEqual({ + conjunction: 'and', + items: [ + { + conjunction: 'or', + items: [{ fieldId: statusFieldId, operator: 'isNot', value: 'Open' }], + }, + ], + }); + }); + + it('excludes a collapsed empty-value group with isNotEmpty', async () => { + // Impl joins path values with Array.join, which renders null as ''. + const collapsedGroupId = String( + string2Hash(`${statusFieldId}_${[convertValueToStringify(null)].join('_')}`) + ); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create([], 2, 0, 2, [ + { fields: { [statusFieldId]: null }, count: 2 }, + ]), + }); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create([], 0, 0, 2), + }); + + await service.getSocketDocIds(`tbl${'c'.repeat(16)}`, { + viewId: `viw${'v'.repeat(16)}`, + groupBy: [{ fieldId: statusFieldId, order: SortFunc.Asc }], + collapsedGroupIds: [collapsedGroupId], + skip: 0, + take: 2, + includeQueryExtra: false, + }); + + const query = execute.mock.calls[1]?.[1] as ListTableRecordsQuery; + expect(query.filter).toEqual({ + conjunction: 'and', + items: [ + { + conjunction: 'or', + items: [{ fieldId: statusFieldId, operator: 'isNotEmpty', value: null }], + }, + ], + }); + }); + + it('excludes a collapsed date group at formatting granularity (exactFormatDate)', async () => { + const groupValueIso = '2026-06-02T00:00:00.000Z'; + tableFindOne.mockResolvedValue({ + isErr: () => false, + value: createTestTable((builder) => { + builder + .field() + .date() + .withId(FieldId.create(dateFieldIdText)._unsafeUnwrap()) + .withName(FieldName.create('Created Date')._unsafeUnwrap()) + .withFormatting( + DateTimeFormatting.create({ + date: 'YYYY-MM-DD', + time: V2TimeFormatting.None, + timeZone: 'Asia/Shanghai', + })._unsafeUnwrap() + ) + .done(); + }), + }); + const collapsedGroupId = String(string2Hash(`${dateFieldIdText}_${groupValueIso}`)); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create([], 2, 0, 2, [ + { fields: { [dateFieldIdText]: groupValueIso }, count: 2 }, + ]), + }); + execute.mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create([], 0, 0, 2), + }); + + await service.getSocketDocIds(`tbl${'c'.repeat(16)}`, { + viewId: `viw${'v'.repeat(16)}`, + groupBy: [{ fieldId: dateFieldIdText, order: SortFunc.Asc }], + collapsedGroupIds: [collapsedGroupId], + skip: 0, + take: 2, + includeQueryExtra: false, + }); + + const query = execute.mock.calls[1]?.[1] as ListTableRecordsQuery; + expect(query.filter).toEqual({ + conjunction: 'and', + items: [ + { + conjunction: 'or', + items: [ + { + fieldId: dateFieldIdText, + operator: 'isNot', + value: { + exactDate: fromZonedTime(groupValueIso, 'Asia/Shanghai').toISOString(), + mode: 'exactFormatDate', + timeZone: 'Asia/Shanghai', + }, + }, + ], + }, + ], + }); + }); + + it('formats sorted top-level system datetime fields from table aggregate (no FieldService)', async () => { execute.mockResolvedValue({ isErr: () => false, value: ListTableRecordsResult.create( - [{ id: 'rec1111111111111111', fields: {}, version: 1 }], + [ + { + id: 'rec1111111111111111', + fields: { createdTime: createdTimeIso }, + version: 1, + createdTime: createdTimeIso, + }, + ], 1, 0, 1 ), }); - getSnapshotBulkWithPermission.mockResolvedValue([ - { - data: { - id: 'rec1111111111111111', - fields: { - Visible: 'alpha', + + const result = await service.getRecords(`tbl${'c'.repeat(16)}`, { + fieldKeyType: FieldKeyType.Name, + skip: 0, + take: 1, + orderBy: [{ fieldId: createdTimeFieldId, order: SortFunc.Asc }], + }); + + expect(result.records[0]?.createdTime).toBe('2026-03-19'); + expect(getSnapshotBulkWithPermission).not.toHaveBeenCalled(); + // Pure read path must not re-fetch fields via V1 FieldService. + expect(getFieldsByQuery).not.toHaveBeenCalled(); + }); + + it('does not normalize system datetime fields when they are not part of the active sort', async () => { + execute.mockResolvedValue({ + isErr: () => false, + value: ListTableRecordsResult.create( + [ + { + id: 'rec1111111111111111', + fields: { [primaryFieldId]: 'Title' }, + version: 1, + createdTime: createdTimeIso, }, - }, + ], + 1, + 0, + 1 + ), + }); + + const result = await service.getRecords(`tbl${'c'.repeat(16)}`, { + fieldKeyType: FieldKeyType.Id, + skip: 0, + take: 1, + }); + + expect(result.records[0]?.createdTime).toBe(createdTimeIso); + expect(getSnapshotBulkWithPermission).not.toHaveBeenCalled(); + }); + + it('applies readable field scope from query plugins to list projection', async () => { + pluginPrepare.mockResolvedValueOnce({ + isErr: () => false, + value: { + guard: async () => ({ isErr: () => false, value: undefined }), + getScope: () => ({ + isErr: () => false, + value: { readableFieldIds: new Set(['fldVisible0000000001']) }, + }), }, - ]); + }); + execute.mockResolvedValue({ + isErr: () => false, + value: ListTableRecordsResult.create( + [ + { + id: 'rec1111111111111111', + fields: { fldVisible0000000001: 'alpha' }, + version: 1, + }, + ], + 1, + 0, + 1 + ), + }); getFieldsByQuery.mockResolvedValue([ { id: 'fldVisible0000000001', @@ -778,54 +2161,102 @@ describe('RecordOpenApiV2Service', () => { ]); const result = await service.getRecords(`tbl${'c'.repeat(16)}`, { - fieldKeyType: FieldKeyType.Name, + fieldKeyType: FieldKeyType.Id, skip: 0, take: 1, viewId: `viw${'v'.repeat(16)}`, }); - expect(result.records).toEqual([ - { - id: 'rec1111111111111111', - fields: { - Visible: 'alpha', + expect(result.records[0]?.fields).toEqual({ + fldVisible0000000001: 'alpha', + }); + expect(getSnapshotBulkWithPermission).not.toHaveBeenCalled(); + const query = execute.mock.calls[0]?.[1]; + expect((query as ListTableRecordsQuery).projection).toEqual(['fldVisible0000000001']); + expect((query as ListTableRecordsQuery).queryScope?.readableFieldIds).toEqual( + new Set(['fldVisible0000000001']) + ); + }); + + it('returns 403 when getRecord finds the row only outside authority row scope', async () => { + const tableId = `tbl${'c'.repeat(16)}`; + const recordId = 'rec1111111111111111'; + const fakeSpec = { + isSatisfiedBy: () => false, + }; + pluginPrepare + .mockResolvedValueOnce({ + isErr: () => false, + value: { + guard: async () => ({ isErr: () => false, value: undefined }), + getScope: () => ({ + isErr: () => false, + value: { recordSpec: fakeSpec }, + }), }, - }, - ]); - expect(getFieldsByQuery).toHaveBeenCalledWith(`tbl${'c'.repeat(16)}`, { - projection: ['fldVisible0000000001'], - }); - expect(getSnapshotBulkWithPermission).toHaveBeenCalledWith( - `tbl${'c'.repeat(16)}`, - ['rec1111111111111111'], - { Visible: true }, - FieldKeyType.Name, - undefined, - true + }) + // first getRecords under scope: empty + // second prepare for exists check with full scope + .mockResolvedValueOnce({ + isErr: () => false, + value: { + guard: async () => ({ isErr: () => false, value: undefined }), + getScope: () => ({ + isErr: () => false, + value: { recordSpec: fakeSpec }, + }), + }, + }); + + execute + .mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create([], 0, 0, 1), + }) + .mockResolvedValueOnce({ + isErr: () => false, + value: ListTableRecordsResult.create([{ id: recordId, fields: {}, version: 1 }], 1, 0, 1), + }); + + await expect( + service.getRecord(tableId, recordId, { fieldKeyType: FieldKeyType.Id }) + ).rejects.toMatchObject({ + response: expect.stringContaining('Record permission not allowed'), + }); + }); + + it('passes keepPrimaryKey into the query plugin for filterLinkCellSelected', async () => { + const tableId = `tbl${'c'.repeat(16)}`; + await service.getRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + filterLinkCellSelected: [`fld${'d'.repeat(16)}`, `rec${'e'.repeat(16)}`], + skip: 0, + take: 2, + }); + + expect(pluginPrepare).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ keepPrimaryKey: true }), + }) ); }); - it('keeps snapshot fallback when an explicit projection is requested', async () => { + it('honors explicit projection on pure v2 list without snapshot bulk', async () => { execute.mockResolvedValue({ isErr: () => false, value: ListTableRecordsResult.create( - [{ id: 'rec1111111111111111', fields: { Title: 'Alpha' }, version: 1 }], + [ + { + id: 'rec1111111111111111', + fields: { Title: 'Alpha' }, + version: 1, + }, + ], 1, 0, 1 ), }); - getSnapshotBulkWithPermission.mockResolvedValue([ - { - data: { - id: 'rec1111111111111111', - name: 'Alpha', - fields: { - Title: 'Alpha', - }, - }, - }, - ]); const result = await service.getRecords(`tbl${'c'.repeat(16)}`, { fieldKeyType: FieldKeyType.Name, @@ -834,16 +2265,8 @@ describe('RecordOpenApiV2Service', () => { take: 1, }); - expect(result.records).toEqual([ - { - id: 'rec1111111111111111', - name: 'Alpha', - fields: { - Title: 'Alpha', - }, - }, - ]); - expect(getSnapshotBulkWithPermission).toHaveBeenCalledTimes(1); + expect(result.records[0]?.fields).toEqual({ Title: 'Alpha' }); + expect(getSnapshotBulkWithPermission).not.toHaveBeenCalled(); }); it('routes explicit batch field updates through native v2 updateRecords', async () => { @@ -1047,6 +2470,49 @@ describe('RecordOpenApiV2Service', () => { ]); }); + it('uses the last duplicate occurrence when native v2 updateRecords also reorders', async () => { + commandExecute.mockResolvedValueOnce({ + isErr: () => false, + value: createUpdateRecordsResult({ + tableId: `tbl${'c'.repeat(16)}`, + records: [ + { id: 'rec2222222222222222', fields: { [statusFieldId]: 'Open' } }, + { + id: 'rec1111111111111111', + fields: { [statusFieldId]: 'Done', [noteFieldId]: 'latest' }, + }, + ], + fieldKeyMapping: new Map([ + [statusFieldId, statusFieldId], + [noteFieldId, noteFieldId], + ]), + }), + }); + + await service.updateRecords(`tbl${'c'.repeat(16)}`, { + fieldKeyType: FieldKeyType.Id, + records: [ + { id: 'rec1111111111111111', fields: { [statusFieldId]: 'Open' } }, + { id: 'rec2222222222222222', fields: { [statusFieldId]: 'Open' } }, + { id: 'rec1111111111111111', fields: { [statusFieldId]: 'Done', [noteFieldId]: 'latest' } }, + ], + order: { + viewId: `viw${'c'.repeat(16)}`, + anchorId: 'rec3333333333333333', + position: 'after', + }, + }); + + const command = commandExecute.mock.calls[0]?.[1]; + expect( + command.records?.map((record: { recordId: { toString(): string } }) => + record.recordId.toString() + ) + ).toEqual(['rec2222222222222222', 'rec1111111111111111']); + expect(command.records?.[1]?.fieldValues.get(statusFieldId)).toBe('Done'); + expect(command.records?.[1]?.fieldValues.get(noteFieldId)).toBe('latest'); + }); + it('returns the v2 createRecords payload directly without reloading legacy snapshots', async () => { commandExecute.mockResolvedValueOnce({ isErr: () => false, 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 a6f7fcdf46..eac4ccafa9 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 @@ -10,6 +10,7 @@ import { HttpErrorCode, TimeFormatting, formatDateToString, + getDbFieldType, isMeTag, parseClipboardText, type IAttachmentItem, @@ -17,9 +18,11 @@ import { type IFieldVo, type IFilter, type IFilterSet, + type ISnapshotBase, } from '@teable/core'; import type { IClearSelectionStreamEvent, + IButtonClickVo, IDeleteSelectionStreamEvent, IDuplicateSelectionStreamEvent, IPasteSelectionStreamEvent, @@ -38,8 +41,10 @@ import type { ISelectionIdsRo, IRecordsVo, IRecordInsertOrderRo, + IGroupHeaderRef, + IGroupPoint, } from '@teable/openapi'; -import { RangeType } from '@teable/openapi'; +import { GroupPointType, RangeType } from '@teable/openapi'; import { mapDomainErrorToHttpError, mapDomainErrorToHttpStatus } from '@teable/v2-contract-http'; import { executeCreateRecordsEndpoint, @@ -55,57 +60,103 @@ import { } from '@teable/v2-contract-http-implementation/handlers'; import { ClearStreamCommand, + ClickButtonCommand, + buildUserAvatarUrl, DeleteByRangeStreamCommand, DuplicateRecordsStreamCommand, + FieldOptionsDtoVisitor, + FieldType as V2FieldType, + FieldValueTypeVisitor, + isForbiddenError, + ListTableRecordsQuery, PasteStreamCommand, + ResetButtonCommand, + presignAttachmentFieldMaps, + RecordQueryOperationKind, + TableByIdSpec, + TableId, v2CoreTokens, type ClearStreamResult, + type ClickButtonResult, type DeleteByRangeStreamResult, type DuplicateRecordsStreamResult, + type IAttachmentUrlSignerService, type ICommandBus, + type IDeleteRecordsCommandOptions, type IExecutionContext, type IListTableRecordsQueryInput, type IPasteCommandInput, type IQueryBus, type IRecordReadQuerySource, type IRecordSearchAccessPath, + type ITableRepository, + type ITableRecordGroup, + type IUserLookupService, + type ConditionalLookupField, + type Field as V2Field, + type LastModifiedByField, + type ListTableRecordsResult, + type LookupField, type PasteStreamResult, + type ResetButtonResult, type RecordFilter, type RecordFilterDateValue, type RecordFilterGroup, type RecordFilterNode, type RecordFilterOperator, type RecordFilterValue, + type RecordQueryPluginRunner, + type RecordQueryPluginScope, type RecordWritePluginRunnerOptions, + type Table, + type TableRecordReadModel, } from '@teable/v2-core'; import type { DependencyContainer } from '@teable/v2-di'; import { pick } from 'lodash'; import { ClsService } from 'nestjs-cls'; import { CacheService } from '../../../cache/cache.service'; import type { ICacheStore } from '../../../cache/types'; -import { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exception'; +import { CustomHttpException } from '../../../custom.exception'; import { DataDbClientManager } from '../../../global/data-db-client-manager.service'; import type { IClsStore } from '../../../types/cls'; +import { convertValueToStringify, string2Hash } from '../../../utils'; +import { generateFilterItem } from '../../../utils/filter'; import { AggregationService } from '../../aggregation/aggregation.service'; import { AttachmentsService } from '../../attachments/attachments.service'; import { AuditScope } from '../../audit/audit-scope'; import { FieldService } from '../../field/field.service'; import type { IFieldInstance } from '../../field/model/factory'; import { createFieldInstanceByVo } from '../../field/model/factory'; -import { TableService } from '../../table/table.service'; import { SpaceDataDbMigrationGuardService } from '../../space/space-data-db-migration-guard.service'; +import { TableService } from '../../table/table.service'; import { buildUndoRedoEnginePreferenceKey } from '../../undo-redo/open-api/undo-redo-engine-preference'; import { TableQuerySearchVectorRuntimeService } from '../../v2/table-query-search-vector-runtime.service'; import { V2ContainerService } from '../../v2/v2-container.service'; import { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory'; +import { throwV2Error } from '../../v2/v2-http-error'; import { convertLinkPasteCellValue } from '../paste-link-cell-value'; import { RecordPermissionService } from '../record-permission.service'; import { RecordService } from '../record.service'; const internalServerError = 'Internal server error'; const invalidFilterCode = 'validation.invalid_filter'; +const publicUserFieldTypes: ReadonlySet = new Set(['user', 'createdBy', 'lastModifiedBy']); + +interface IRecordsWithVersions { + result: IRecordsVo; + versionByRecordId: ReadonlyMap; +} const dataTxClientKey = 'dataTx.client'; const maxResolveSelectionRecordIdsPageSize = 1000; +const defaultMaxGroupPoints = 5_000; +const configuredMaxGroupPoints = Number.parseInt( + process.env.MAX_GROUP_POINTS ?? String(defaultMaxGroupPoints), + 10 +); +const maxGroupPoints = + Number.isSafeInteger(configuredMaxGroupPoints) && configuredMaxGroupPoints > 0 + ? configuredMaxGroupPoints + : defaultMaxGroupPoints; const describeTraceError = (error: unknown): string => error instanceof Error ? error.message : String(error); const v1SymbolOperatorMap: Record = { @@ -138,7 +189,10 @@ const dateFilterFieldTypes: ReadonlySet = new Set([ FieldType.LastModifiedTime, ]); -type FilterFieldMeta = Pick; +type FilterFieldMeta = Pick & { + /** Optional — pure-V2 table aggregate may not materialize full V1 options. */ + options?: IFieldInstance['options']; +}; @Injectable() export class RecordOpenApiV2Service { @@ -164,22 +218,6 @@ export class RecordOpenApiV2Service { await this.spaceDataDbMigrationGuard.assertTableRecordWritable(tableId); } - private throwV2Error( - error: { - code: string; - message: string; - tags?: ReadonlyArray; - details?: Readonly>; - }, - status: number - ): never { - throw new CustomHttpException(error.message, getDefaultCodeByStatus(status), { - domainCode: error.code, - domainTags: error.tags, - details: error.details, - }); - } - private getUndoRedoEnginePreferenceKey( tableId: string ): ReturnType | null { @@ -219,42 +257,36 @@ export class RecordOpenApiV2Service { }; } - private mergeDuplicateRecordUpdates( - records: NonNullable - ): NonNullable { - const mergedById = new Map[number]>(); - const order: string[] = []; - - for (const record of records) { - const existing = mergedById.get(record.id); - if (!existing) { - order.push(record.id); - mergedById.set(record.id, { - id: record.id, - fields: { ...record.fields }, - }); - continue; - } + async getRecords(tableId: string, query: IGetRecordsRo): Promise { + this.assertValidListQuery(query); - mergedById.set(record.id, { - id: record.id, - fields: { - ...existing.fields, - ...record.fields, - }, - }); - } + const container = await this.v2ContainerService.getContainerForTable(tableId); + const context = await this.v2ContextFactory.createContext(container); + const table = await this.loadV2Table(context, container, tableId); + const queryScope = await this.prepareRecordQueryScope(context, container, table, { + kind: RecordQueryOperationKind.list, + viewId: query.viewId, + ignoreViewQuery: query.ignoreViewQuery, + limit: query.take, + offset: query.skip, + // Match legacy CTE keepPrimaryKey: skip row filter for link-selected reads. + keepPrimaryKey: Boolean(query.filterLinkCellSelected), + }); - return order - .map((recordId) => mergedById.get(recordId)) - .filter((record): record is NonNullable[number] => - Boolean(record) - ); + const result = await this.getRecordsWithPreparedScope( + tableId, + query, + queryScope, + container, + context, + table + ); + return result.result; } - async getRecords(tableId: string, query: IGetRecordsRo): Promise { + private assertValidListQuery(query: IGetRecordsRo): void { if (query.filterLinkCellSelected && query.filterLinkCellCandidate) { - this.throwV2Error( + throwV2Error( { code: invalidFilterCode, message: @@ -264,37 +296,57 @@ export class RecordOpenApiV2Service { HttpStatus.BAD_REQUEST ); } + } - const container = await this.v2ContainerService.getContainerForTable(tableId); - const { context, recordReadQuerySource } = await this.createV2ReadContext( - tableId, - query, - container - ); - const enabledFieldIds = recordReadQuerySource?.enabledFieldIds; + /** + * List implementation that reuses a pre-built plugin scope (list / getOne / getByIds). + */ + private async getRecordsWithPreparedScope( + tableId: string, + query: IGetRecordsRo, + queryScope: RecordQueryPluginScope | undefined, + container: DependencyContainer, + context: IExecutionContext, + table: Table, + options?: { + projectionFieldIds?: ReadonlyArray; + } + ): Promise { + // undefined = unrestricted; empty array = no user fields (deny-all fields). + const enabledFieldIds = + queryScope?.readableFieldIds != null ? [...queryScope.readableFieldIds] : undefined; + // Clients often send groupBy/orderBy field *names*; list uses field ids. + // Resolve before dispatch; the V2 handler owns permission validation so + // explicit unreadable sort/group keys cannot be silently removed here. const effectiveQuery = { ...query, - ...this.sanitizeReadableSortAndGroup(query, enabledFieldIds), + orderBy: this.resolveSortGroupFieldKeysToIds(table, query.orderBy ?? undefined), + groupBy: this.resolveSortGroupFieldKeysToIds(table, query.groupBy ?? undefined), } satisfies IGetRecordsRo; const requestedFieldKeyType = query.fieldKeyType ?? FieldKeyType.Name; - const snapshotProjection = await this.withRecordReadSpan( - context, - 'teable.RecordOpenApiV2Service.resolveSnapshotProjection', - { - 'record.read.has_explicit_projection': Boolean(query.projection), - 'record.read.has_enabled_fields': Boolean(enabledFieldIds?.length), - 'record.read.field_key_type': requestedFieldKeyType, - }, - () => this.resolveSnapshotProjection(tableId, query, requestedFieldKeyType, enabledFieldIds) - ); - const normalizedFilter = await this.withRecordReadSpan( + // Field metadata comes only from the V2 table aggregate (DDD), never FieldService. + const projectionFieldIds = + options?.projectionFieldIds != null + ? [...options.projectionFieldIds] + : this.withRecordReadSyncSpan( + context, + 'teable.RecordOpenApiV2Service.resolveListProjection', + { + 'record.read.has_explicit_projection': Boolean(query.projection), + 'record.read.has_enabled_fields': enabledFieldIds != null, + 'record.read.field_key_type': requestedFieldKeyType, + }, + () => this.resolveListProjectionFieldIdsFromTable(table, query, enabledFieldIds) + ); + const filterWithCollapsedGroups = effectiveQuery.filter; + const normalizedFilter = this.withRecordReadSyncSpan( context, 'teable.RecordOpenApiV2Service.normalizeFilter', { - 'record.read.has_filter': Boolean(query.filter), + 'record.read.has_filter': Boolean(filterWithCollapsedGroups), }, - () => this.normalizeFilterForV2(tableId, query.filter) + () => this.normalizeFilterForV2FromTable(table, filterWithCollapsedGroups) ); const sortWithGroupFallback = this.mergeGroupByIntoSort( effectiveQuery.groupBy, @@ -311,305 +363,1297 @@ export class RecordOpenApiV2Service { container, effectiveQuery.search ); - const queryExtra = await this.loadQueryExtraWithTrace( - context, - tableId, - effectiveQuery, - recordSearchAccessPath - ); + const shouldExposeGroupMetadata = + this.shouldLoadQueryExtra(effectiveQuery, recordSearchAccessPath) && + Boolean(effectiveQuery.groupBy?.length); + const shouldComputeGroupMetadata = + Boolean(effectiveQuery.groupBy?.length) && + (shouldExposeGroupMetadata || Boolean(effectiveQuery.collapsedGroupIds?.length)); + // Grouped reads compute group metadata purely in V2. The residual V1 extra + // query pages by a groupBy-free sort, so its searchHitIndex would point at + // rows outside the grouped V2 page — omit it rather than return wrong hits. + const legacyQueryExtra = shouldComputeGroupMetadata + ? undefined + : await this.loadQueryExtraWithTrace( + context, + tableId, + effectiveQuery, + recordSearchAccessPath, + queryScope + ); const queryBus = container.resolve(v2CoreTokens.queryBus); - const pageResult = await this.withRecordReadSpan( + const listInput = { + tableId, + // List always uses field ids internally; response keys remapped below. + fieldKeyType: 'id' as const, + limit: query.take, + offset: query.skip, + projection: projectionFieldIds, + includeTotal: shouldComputeGroupMetadata, + ...(normalizedFilter ? { filter: normalizedFilter } : {}), + ...(normalizedSort?.length ? { sort: normalizedSort } : {}), + ...(normalizedGroupBy?.length ? { groupBy: normalizedGroupBy } : {}), + ...(effectiveQuery.search ? { search: effectiveQuery.search } : {}), + ...(effectiveQuery.filterLinkCellSelected + ? { filterLinkCellSelected: effectiveQuery.filterLinkCellSelected } + : {}), + ...(effectiveQuery.filterLinkCellCandidate + ? { filterLinkCellCandidate: effectiveQuery.filterLinkCellCandidate } + : {}), + ...(effectiveQuery.selectedRecordIds?.length + ? { selectedRecordIds: effectiveQuery.selectedRecordIds } + : {}), + ...(effectiveQuery.viewId ? { viewId: effectiveQuery.viewId } : {}), + ...(effectiveQuery.ignoreViewQuery !== undefined + ? { ignoreViewQuery: effectiveQuery.ignoreViewQuery } + : {}), + } satisfies IListTableRecordsQueryInput; + let listResult = await this.withRecordReadSpan( context, - 'teable.RecordOpenApiV2Service.listRecordIds', + 'teable.RecordOpenApiV2Service.listRecords', { 'record.read.limit': query.take ?? 0, 'record.read.offset': query.skip ?? 0, 'record.read.has_filter': Boolean(normalizedFilter), 'record.read.sort_count': normalizedSort?.length ?? 0, 'record.read.group_by_count': normalizedGroupBy?.length ?? 0, + 'record.read.projection_count': projectionFieldIds.length, + 'record.read.has_query_scope': Boolean(queryScope), }, () => - this.executeListRecordsEndpoint( - { - tableId, - // FieldKeyPipe has normalized request field keys to ids. - fieldKeyType: FieldKeyType.Id, - limit: query.take, - offset: query.skip, - projection: [], - includeTotal: false, - ...(normalizedFilter ? { filter: normalizedFilter } : {}), - ...(normalizedSort?.length ? { sort: normalizedSort } : {}), - ...(normalizedGroupBy?.length ? { groupBy: normalizedGroupBy } : {}), - ...(effectiveQuery.search ? { search: effectiveQuery.search } : {}), - ...(effectiveQuery.filterLinkCellSelected - ? { filterLinkCellSelected: effectiveQuery.filterLinkCellSelected } - : {}), - ...(effectiveQuery.filterLinkCellCandidate - ? { filterLinkCellCandidate: effectiveQuery.filterLinkCellCandidate } - : {}), - ...(effectiveQuery.selectedRecordIds?.length - ? { selectedRecordIds: effectiveQuery.selectedRecordIds } - : {}), - ...(effectiveQuery.viewId ? { viewId: effectiveQuery.viewId } : {}), - ...(effectiveQuery.ignoreViewQuery !== undefined - ? { ignoreViewQuery: effectiveQuery.ignoreViewQuery } - : {}), - }, - context, - queryBus, - recordReadQuerySource || recordSearchAccessPath - ? { recordReadQuerySource, recordSearchAccessPath } - : undefined + this.executeListTableRecordsQuery(listInput, context, queryBus, { + queryScope, + ...(recordSearchAccessPath ? { recordSearchAccessPath } : {}), + includeGroupMetadata: shouldComputeGroupMetadata, + ...(shouldComputeGroupMetadata ? { groupLimit: maxGroupPoints } : {}), + }) + ); + + let computedGroupExtra = shouldComputeGroupMetadata + ? this.buildGroupQueryExtra( + table, + effectiveQuery.groupBy, + listResult.groups, + listResult.total, + effectiveQuery.collapsedGroupIds ) + : undefined; + computedGroupExtra = await this.hydrateLegacyUserGroupExtra( + container, + table, + effectiveQuery.groupBy, + computedGroupExtra + ); + let queryExtra = this.mergeQueryExtra( + shouldExposeGroupMetadata ? computedGroupExtra : undefined, + legacyQueryExtra + ); + queryExtra = await this.presignAttachmentGroupExtra( + container, + table, + effectiveQuery.groupBy, + queryExtra ); - const orderedRecords = pageResult.records; + const collapsedFilter = this.buildCollapsedGroupFilter( + table, + effectiveQuery.groupBy, + computedGroupExtra?.groupPoints, + effectiveQuery.collapsedGroupIds + ); + if (collapsedFilter) { + const filteredResult = await this.executeListTableRecordsQuery( + { + ...listInput, + filter: normalizedFilter + ? { conjunction: 'and', items: [normalizedFilter, collapsedFilter] } + : collapsedFilter, + includeTotal: false, + }, + context, + queryBus, + { + queryScope, + ...(recordSearchAccessPath ? { recordSearchAccessPath } : {}), + includeGroupMetadata: false, + } + ); + listResult = { + ...filteredResult, + total: listResult.total, + groups: listResult.groups, + }; + } - if (orderedRecords.length === 0) { - return queryExtra ? { records: [], extra: queryExtra } : { records: [] }; + if (listResult.records.length === 0) { + return { + result: queryExtra ? { records: [], extra: queryExtra } : { records: [] }, + versionByRecordId: new Map(), + }; } + const versionByRecordId = new Map( + listResult.records.map((record) => [record.id, record.version] as const) + ); - const recordIds = orderedRecords.map((record) => record.id); - const snapshots = await this.withRecordReadSpan( + const primaryFieldId = table.primaryFieldId().toString(); + const primaryField = table.getField((field) => field.id().toString() === primaryFieldId); + const primaryFormatter = primaryField.isOk() + ? this.createDisplayFieldInstance(primaryField.value) + : undefined; + let records = this.withRecordReadSyncSpan( context, - 'teable.RecordOpenApiV2Service.snapshotBulk', + 'teable.RecordOpenApiV2Service.mapReadModels', { - 'record.read.record_count': recordIds.length, - 'record.read.has_snapshot_projection': Boolean(snapshotProjection), + 'record.read.record_count': listResult.records.length, + 'record.read.field_key_type': requestedFieldKeyType, }, () => - this.withTableDataClient(tableId, () => - this.recordService.getSnapshotBulkWithPermission( - tableId, - recordIds, - snapshotProjection, + listResult.records.map((record) => + this.mapTableRecordReadModelToIRecord( + table, + record, + primaryFieldId, requestedFieldKeyType, - query.cellFormat, - true + primaryFormatter ) ) ); + records = await this.hydrateLegacyUserCells(container, table, records, requestedFieldKeyType); - const records = this.withRecordReadSyncSpan( - context, - 'teable.RecordOpenApiV2Service.orderSnapshots', - { - 'record.read.record_count': recordIds.length, - }, - () => { - 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 as IRecord]) - ); - const records = recordIds - .map((recordId) => snapshotMap.get(recordId)) - .filter((record): record is IRecord => Boolean(record)); - - if (records.length !== recordIds.length) { - throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); - } - - return records; - } - ); - - const normalizedRecords = await this.withRecordReadSpan( + let normalizedRecords = this.withRecordReadSyncSpan( context, 'teable.RecordOpenApiV2Service.formatRecords', { 'record.read.record_count': records.length, 'record.read.sorted_field_count': sortWithGroupFallback?.length ?? 0, + 'record.read.cell_format': query.cellFormat ?? CellFormat.Json, }, () => - this.formatSystemDatetimeFields( - tableId, + this.formatSystemDatetimeFieldsFromTable( + table, records, query.cellFormat, sortWithGroupFallback?.map((item) => item.fieldId) ) ); - return queryExtra - ? { records: normalizedRecords, extra: queryExtra } - : { records: normalizedRecords }; - } - - async resolveRecordIdsBySelection( - tableId: string, - selectionRo: Pick< - ISelectionIdMutationBaseRo, - | 'selection' - | 'viewId' - | 'ignoreViewQuery' - | 'filter' - | 'orderBy' - | 'groupBy' - | 'search' - | 'collapsedGroupIds' - | 'projection' - > - ): Promise { - const { selection, ...queryRo } = selectionRo; - if (selection.recordIds) { - return selection.recordIds; + // Pure-V2 presentation: no FieldService / RecordService. Attachment URLs + // via IAttachmentUrlSignerService + free-function presign helpers. + if (query.cellFormat === CellFormat.Text) { + normalizedRecords = this.formatRecordFieldsAsDisplayText( + table, + normalizedRecords, + requestedFieldKeyType + ); + } else { + normalizedRecords = await this.presignAttachmentFieldsFromTable( + container, + table, + normalizedRecords, + requestedFieldKeyType + ); } - const rangeQuery = await this.normalizeRangeQuery(tableId, queryRo); - const records: IRecordsVo['records'] = []; - let skip = 0; - let hasMore = true; - while (hasMore) { - const result = await this.getRecords(tableId, { - viewId: rangeQuery.viewId, - ignoreViewQuery: rangeQuery.ignoreViewQuery, - filter: rangeQuery.filter, - orderBy: rangeQuery.orderBy, - groupBy: rangeQuery.groupBy, - search: rangeQuery.search, - projection: queryRo.projection, - skip, - take: maxResolveSelectionRecordIdsPageSize, - fieldKeyType: FieldKeyType.Id, - }); - records.push(...result.records); - hasMore = result.records.length === maxResolveSelectionRecordIdsPageSize; - skip += maxResolveSelectionRecordIdsPageSize; - } - const excludedIds = new Set(selection.excludeRecordIds ?? []); - return records.map((record) => record.id).filter((recordId) => !excludedIds.has(recordId)); + return { + result: queryExtra + ? { records: normalizedRecords, extra: queryExtra } + : { records: normalizedRecords }, + versionByRecordId, + }; } - private async withTableDataClient(tableId: string, fn: () => Promise): Promise { - const resolvedDataDb = await this.dataDbClientManager.getDataDatabaseForTable(tableId); - if (resolvedDataDb.isMetaFallback) { - return fn(); - } - - const dataPrisma = await this.dataDbClientManager.dataPrismaForTable(tableId); - const cls = this.cls as unknown as ClsService<{ dataTx: { client?: unknown } }>; - const store = cls.get(); - const previousClient = cls.get(dataTxClientKey); - - return cls.runWith(store, async () => { - cls.set(dataTxClientKey, dataPrisma); - try { - return await fn(); - } finally { - cls.set(dataTxClientKey, previousClient); - } - }); + private mergeQueryExtra( + groupExtra: IRecordsVo['extra'] | undefined, + otherExtra: IRecordsVo['extra'] | undefined + ): IRecordsVo['extra'] | undefined { + if (!groupExtra && !otherExtra) return undefined; + return { + ...(otherExtra?.searchHitIndex !== undefined + ? { searchHitIndex: otherExtra.searchHitIndex } + : groupExtra + ? { searchHitIndex: null } + : {}), + ...(groupExtra?.groupPoints !== undefined ? { groupPoints: groupExtra.groupPoints } : {}), + ...(groupExtra?.allGroupHeaderRefs !== undefined + ? { allGroupHeaderRefs: groupExtra.allGroupHeaderRefs } + : {}), + }; } - private async formatSystemDatetimeFields( - tableId: string, - records: IRecord[], - cellFormat?: CellFormat, - sortedFieldIds?: ReadonlyArray - ): Promise { - if (!records.length || cellFormat === CellFormat.Text || !sortedFieldIds?.length) { - return records; - } + private buildGroupQueryExtra( + table: Table, + groupBy: IGetRecordsRo['groupBy'], + groups: ReadonlyArray | undefined, + rowCount: number, + collapsedGroupIds?: ReadonlyArray + ): IRecordsVo['extra'] | undefined { + if (!groupBy?.length) return undefined; + + const collapsed = new Set(collapsedGroupIds ?? []); + const groupPoints: IGroupPoint[] = []; + const allGroupHeaderRefs: IGroupHeaderRef[] = []; + let previousValues: unknown[] = []; + let collapsedDepth = Number.MAX_SAFE_INTEGER; + let groupedRowCount = 0; + + for (const group of groups ?? []) { + for (let depth = 0; depth < groupBy.length; depth += 1) { + const fieldId = groupBy[depth]!.fieldId; + const value = group.fields[fieldId] ?? null; + const outputValue = this.normalizeGroupPointValue(table, fieldId, value); + const comparable = convertValueToStringify( + this.groupPointIdentityValue(table, fieldId, value, outputValue) + ); + if (previousValues[depth] === comparable) continue; - const sortedFieldIdSet = new Set(sortedFieldIds); - const fields = await this.fieldService.getFieldsByQuery(tableId, { - projection: Array.from(sortedFieldIdSet), - }); - const formatters = fields.flatMap((field) => { - if (!sortedFieldIdSet.has(field.id)) { - return []; - } - if (field.type !== FieldType.CreatedTime && field.type !== FieldType.LastModifiedTime) { - return []; + const groupId = String( + string2Hash(`${fieldId}_${[...previousValues.slice(0, depth), comparable].join('_')}`) + ); + allGroupHeaderRefs.push({ id: groupId, depth }); + if (depth > collapsedDepth) break; + + collapsedDepth = Number.MAX_SAFE_INTEGER; + previousValues[depth] = comparable; + previousValues = previousValues.slice(0, depth + 1); + const isCollapsed = collapsed.has(groupId); + groupPoints.push({ + id: groupId, + type: GroupPointType.Header, + depth, + value: outputValue, + isCollapsed, + }); + if (isCollapsed) collapsedDepth = depth; } - const formatting = this.extractDatetimeFormatting(field.options); - if (!formatting || formatting.time !== TimeFormatting.None) { - return []; + groupedRowCount += group.count; + if (collapsedDepth === Number.MAX_SAFE_INTEGER) { + groupPoints.push({ type: GroupPointType.Row, count: group.count }); } + } - return [ + if (groupedRowCount < rowCount) { + groupPoints.push( { - topLevelKey: - field.type === FieldType.CreatedTime - ? ('createdTime' as const) - : ('lastModifiedTime' as const), - formatting, + id: 'unknown', + type: GroupPointType.Header, + depth: 0, + value: 'Unknown', + isCollapsed: false, }, - ]; - }); - - if (!formatters.length) { - return records; + { type: GroupPointType.Row, count: rowCount - groupedRowCount } + ); } - return records.map((record) => { - let nextRecord: IRecord | undefined; - - for (const formatter of formatters) { - const topLevelValue = record[formatter.topLevelKey]; - if (typeof topLevelValue === 'string') { - const formattedTopLevel = formatDateToString(topLevelValue, formatter.formatting); - if (formattedTopLevel !== topLevelValue) { - nextRecord ??= { ...record }; - nextRecord[formatter.topLevelKey] = formattedTopLevel; - } - } - } - - return nextRecord ?? record; - }); + return { groupPoints, allGroupHeaderRefs }; } - private extractDatetimeFormatting(options: unknown): IDatetimeFormatting | undefined { - if (!options || typeof options !== 'object' || !('formatting' in options)) { - return undefined; + private normalizeGroupPointValue(table: Table, fieldId: string, value: unknown): unknown { + if (value instanceof Date) return value.toISOString(); + const field = table.getField((candidate) => candidate.id().toString() === fieldId); + if (field.isErr()) { + return value; } - - const formatting = options.formatting; - if (!formatting || typeof formatting !== 'object') { - return undefined; + if (this.presentationFieldType(field.value) === V2FieldType.checkbox().toString()) { + return value ?? null; } + if (this.isPublicUserValueField(field.value)) { + return this.normalizeGroupUserValue(value); + } + return value; + } - return formatting as IDatetimeFormatting; + private groupPointIdentityValue( + table: Table, + fieldId: string, + storedValue: unknown, + outputValue: unknown + ): unknown { + const field = table.getField((candidate) => candidate.id().toString() === fieldId); + if (field.isErr() || !this.isPublicUserValueField(field.value)) { + return storedValue; + } + return this.userGroupIdentityValue(outputValue); } - private toProjectionMap( - fieldKeys?: string | ReadonlyArray - ): Record | undefined { - if (!fieldKeys) { - return undefined; + private userGroupIdentityValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.userGroupIdentityValue(item)); } - const keys = (Array.isArray(fieldKeys) ? fieldKeys : [fieldKeys]).filter( - (key): key is string => typeof key === 'string' && key.length > 0 - ); - if (!keys.length) { - return undefined; + if (!value || typeof value !== 'object') { + return value; } - return keys.reduce>((acc, key) => { - acc[key] = true; - return acc; - }, {}); + const user = value as Record; + return { + id: user.id, + title: user.title, + }; } - private async resolveSnapshotProjection( - tableId: string, - query: IGetRecordsRo, - fieldKeyType: FieldKeyType, - enabledFieldIds?: ReadonlyArray - ): Promise | undefined> { - const explicitProjection = this.toProjectionMap( - query.projection as unknown as string | string[] - ); - if (explicitProjection) { - return explicitProjection; + private normalizeGroupUserValue( + value: unknown, + resolvedUsers: ReadonlyMap = new Map() + ): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.normalizeGroupUserValue(item, resolvedUsers)); + } + const normalized = this.normalizePublicUserValue(value, resolvedUsers); + if (!normalized || typeof normalized !== 'object' || Array.isArray(normalized)) { + return normalized; } + const groupValue = { ...(normalized as Record) }; + delete groupValue.email; + return groupValue; + } - if (enabledFieldIds?.length) { - if (fieldKeyType === FieldKeyType.Id) { + private async hydrateLegacyUserGroupExtra( + container: DependencyContainer, + table: Table, + groupBy: IGetRecordsRo['groupBy'], + extra: IRecordsVo['extra'] | undefined + ): Promise { + if (!extra?.groupPoints?.length || !groupBy?.length) { + return extra; + } + + const userGroupDepths = new Set( + groupBy.flatMap((item, depth) => { + const field = table.getField((candidate) => candidate.id().toString() === item.fieldId); + return field.isOk() && this.isPublicUserValueField(field.value) ? [depth] : []; + }) + ); + if (!userGroupDepths.size) { + return extra; + } + + const userIds = new Set(); + for (const point of extra.groupPoints) { + if ( + point.type === GroupPointType.Header && + point.id !== 'unknown' && + userGroupDepths.has(point.depth) + ) { + this.collectGroupUserIds(point.value, userIds); + } + } + if (!userIds.size) { + return extra; + } + + const resolvedUsers = await this.resolvePublicUsers(container, userIds); + return { + ...extra, + groupPoints: extra.groupPoints.map((point) => + point.type === GroupPointType.Header && + point.id !== 'unknown' && + userGroupDepths.has(point.depth) + ? { ...point, value: this.normalizeGroupUserValue(point.value, resolvedUsers) } + : point + ), + }; + } + + private async presignAttachmentGroupExtra( + container: DependencyContainer, + table: Table, + groupBy: IGetRecordsRo['groupBy'], + extra: IRecordsVo['extra'] | undefined + ): Promise { + if ( + !extra?.groupPoints?.length || + !groupBy?.length || + !container.isRegistered(v2CoreTokens.attachmentUrlSignerService) + ) { + return extra; + } + + const attachmentFieldIds = new Set( + table + .getFields() + .filter((field) => this.isAttachmentValueField(field)) + .map((field) => field.id().toString()) + ); + const headerInputs = extra.groupPoints.flatMap((point, pointIndex) => { + if (point.type !== GroupPointType.Header || point.id === 'unknown') return []; + const fieldId = groupBy[point.depth]?.fieldId; + return fieldId && attachmentFieldIds.has(fieldId) + ? [{ pointIndex, fieldId, fields: { [fieldId]: point.value } }] + : []; + }); + if (!headerInputs.length) return extra; + + const signer = container.resolve( + v2CoreTokens.attachmentUrlSignerService + ); + const signedResult = await presignAttachmentFieldMaps( + headerInputs.map((input) => input.fields), + attachmentFieldIds, + signer + ); + if (signedResult.isErr()) return extra; + + const signedValueByPointIndex = new Map( + headerInputs.map((input, index) => [ + input.pointIndex, + signedResult.value[index]?.[input.fieldId], + ]) + ); + return { + ...extra, + groupPoints: extra.groupPoints.map((point, pointIndex) => + signedValueByPointIndex.has(pointIndex) && point.type === GroupPointType.Header + ? { ...point, value: signedValueByPointIndex.get(pointIndex) } + : point + ), + }; + } + + private buildCollapsedGroupFilter( + table: Table, + groupBy: IGetRecordsRo['groupBy'], + groupPoints: ReadonlyArray | null | undefined, + collapsedGroupIds?: ReadonlyArray + ): RecordFilter | undefined { + if (!groupBy?.length || !groupPoints?.length || !collapsedGroupIds?.length) { + return undefined; + } + + const pathValues: unknown[] = []; + const pathByHeaderId = new Map(); + for (const point of groupPoints) { + if (point.type !== GroupPointType.Header || point.id === 'unknown') continue; + pathValues.length = point.depth; + pathValues[point.depth] = point.value; + pathByHeaderId.set(point.id, [...pathValues]); + } + + // V1 parity: each collapsed group is excluded with an OR of per-depth + // null-inclusive negations (isNot/isNotEmpty/isNotExactly, exactFormatDate + // for dates), so rows in the empty bucket stay visible and date buckets + // match the field's formatting granularity. Plain not+is would drop + // NULL-valued rows (three-valued NOT). + const filterFieldCache = new Map(); + const resolveFilterField = (fieldId: string): IFieldInstance | undefined => { + if (!filterFieldCache.has(fieldId)) { + const field = table.getField((candidate) => candidate.id().toString() === fieldId); + filterFieldCache.set( + fieldId, + field.isOk() ? this.createDisplayFieldInstance(field.value) : undefined + ); + } + return filterFieldCache.get(fieldId); + }; + + const exclusions: IFilterSet[] = []; + for (const collapsedId of collapsedGroupIds) { + const path = pathByHeaderId.get(collapsedId); + if (!path) continue; + const innerFilterSet: IFilterSet = { conjunction: 'or', filterSet: [] }; + for (let depth = 0; depth < path.length; depth += 1) { + const fieldId = groupBy[depth]?.fieldId; + if (!fieldId) continue; + const field = resolveFilterField(fieldId); + if (!field) continue; + innerFilterSet.filterSet.push(generateFilterItem(field, path[depth] ?? null)); + } + if (!innerFilterSet.filterSet.length) continue; + exclusions.push(innerFilterSet); + } + + if (!exclusions.length) return undefined; + const v1Filter: IFilterSet = { conjunction: 'and', filterSet: exclusions }; + return this.normalizeFilterForV2FromTable(table, v1Filter) ?? undefined; + } + + async getSocketDocIds( + tableId: string, + query: IGetRecordsRo + ): Promise<{ ids: string[]; extra?: IRecordsVo['extra'] }> { + this.assertValidListQuery(query); + + const container = await this.v2ContainerService.getContainerForTable(tableId); + const context = await this.v2ContextFactory.createContext(container); + const table = await this.loadV2Table(context, container, tableId); + const queryScope = await this.prepareRecordQueryScope(context, container, table, { + kind: RecordQueryOperationKind.list, + viewId: query.viewId, + ignoreViewQuery: query.ignoreViewQuery, + limit: query.take, + offset: query.skip, + keepPrimaryKey: Boolean(query.filterLinkCellSelected), + }); + if (this.shouldUseLegacyPermissionSocketQuery(table, query, queryScope)) { + const legacyResult = await this.withTableDataClient(tableId, () => + this.recordService.getDocIdsByQuery( + tableId, + { + ...query, + fieldKeyType: FieldKeyType.Id, + ignoreViewQuery: query.ignoreViewQuery ?? false, + }, + true + ) + ); + if (!legacyResult.ids.length) { + return legacyResult.extra ? { ids: [], extra: legacyResult.extra } : { ids: [] }; + } + + // The legacy query supplies mask-aware order/group/search semantics. V2 + // still revalidates membership through the merged plugin scope. If the + // compatibility contract is ever wrong, omit legacy aggregates rather + // than expose structure for rows rejected by V2. + const { result: scopedResult } = await this.getRecordsWithPreparedScope( + tableId, + { + selectedRecordIds: legacyResult.ids, + take: legacyResult.ids.length, + skip: 0, + projection: [], + fieldKeyType: FieldKeyType.Id, + cellFormat: CellFormat.Json, + ignoreViewQuery: true, + includeQueryExtra: false, + }, + queryScope, + container, + context, + table, + { projectionFieldIds: [] } + ); + const scopedIds = new Set(scopedResult.records.map((record) => record.id)); + const ids = legacyResult.ids.filter((recordId) => scopedIds.has(recordId)); + const allIdsRevalidated = ids.length === legacyResult.ids.length; + return legacyResult.extra && allIdsRevalidated ? { ids, extra: legacyResult.extra } : { ids }; + } + + const { result } = await this.getRecordsWithPreparedScope( + tableId, + { + ...query, + fieldKeyType: FieldKeyType.Id, + cellFormat: CellFormat.Json, + }, + queryScope, + container, + context, + table, + { projectionFieldIds: [] } + ); + return result.extra + ? { ids: result.records.map((record) => record.id), extra: result.extra } + : { ids: result.records.map((record) => record.id) }; + } + + private shouldUseLegacyPermissionSocketQuery( + table: Table, + query: IGetRecordsRo, + queryScope: RecordQueryPluginScope | undefined + ): boolean { + if (queryScope?.legacyPermissionQueryCompatible !== true) { + return false; + } + + const needsLegacyExtra = query.includeQueryExtra !== false && Boolean(query.search); + if (needsLegacyExtra) { + return true; + } + + const maskedFieldIds = new Set(queryScope.fieldMasks?.map((mask) => mask.fieldId) ?? []); + if (!maskedFieldIds.size) { + return false; + } + if (query.search) { + return true; + } + + const orderBy = this.resolveSortGroupFieldKeysToIds(table, query.orderBy ?? undefined); + const groupBy = this.resolveSortGroupFieldKeysToIds(table, query.groupBy ?? undefined); + return [...(orderBy ?? []), ...(groupBy ?? [])].some((item) => + maskedFieldIds.has(item.fieldId) + ); + } + + async getSocketSnapshotBulk( + tableId: string, + recordIds: string[], + projection?: { [fieldNameOrId: string]: boolean } + ): Promise[]> { + if (recordIds.length === 0) { + return []; + } + + const requestedProjectionFieldIds = projection + ? Object.entries(projection) + .filter(([, included]) => included) + .map(([fieldId]) => fieldId) + : []; + const projectionFieldIds = requestedProjectionFieldIds.length + ? requestedProjectionFieldIds + : undefined; + const container = await this.v2ContainerService.getContainerForTable(tableId); + const context = await this.v2ContextFactory.createContext(container); + const table = await this.loadV2Table(context, container, tableId); + const queryScope = await this.prepareRecordQueryScope(context, container, table, { + kind: RecordQueryOperationKind.getByIds, + recordIds, + projectionFieldIds, + ignoreViewQuery: true, + // ShareDB query membership already scopes which ids are subscribed. + // Match the legacy snapshot reader: retain known documents for version + // continuity while the plugin still enforces non-primary field scope. + keepPrimaryKey: true, + }); + const recordById = new Map(); + const versionByRecordId = new Map(); + for (let index = 0; index < recordIds.length; index += maxResolveSelectionRecordIdsPageSize) { + const chunk = recordIds.slice(index, index + maxResolveSelectionRecordIdsPageSize); + const query = { + selectedRecordIds: chunk, + take: chunk.length, + skip: 0, + projection: projectionFieldIds, + fieldKeyType: FieldKeyType.Id, + cellFormat: CellFormat.Json, + ignoreViewQuery: true, + includeQueryExtra: false, + } satisfies IGetRecordsRo; + const page = await this.getRecordsWithPreparedScope( + tableId, + query, + queryScope, + container, + context, + table + ); + for (const record of page.result.records) { + recordById.set(record.id, record); + } + for (const [recordId, version] of page.versionByRecordId) { + versionByRecordId.set(recordId, version); + } + } + + return recordIds.flatMap((recordId) => { + const record = recordById.get(recordId); + const version = versionByRecordId.get(recordId); + if (!record || version == null) { + return []; + } + return [ + { + id: recordId, + v: version, + type: 'json0', + data: record, + }, + ]; + }); + } + + async getRecord( + tableId: string, + recordId: string, + query: { + projection?: string[]; + cellFormat?: CellFormat; + fieldKeyType?: FieldKeyType; + } + ): Promise { + // Use getOne plugin kind so plugins that only support getOne (or apply a + // stricter getOne policy) are not skipped by hard-coding list. + const container = await this.v2ContainerService.getContainerForTable(tableId); + const context = await this.v2ContextFactory.createContext(container); + const table = await this.loadV2Table(context, container, tableId); + const queryScope = await this.prepareRecordQueryScope(context, container, table, { + kind: RecordQueryOperationKind.getOne, + recordId, + projectionFieldIds: query.projection, + ignoreViewQuery: true, + }); + + const listQuery = { + selectedRecordIds: [recordId], + take: 1, + skip: 0, + projection: query.projection, + cellFormat: query.cellFormat, + fieldKeyType: query.fieldKeyType ?? FieldKeyType.Name, + ignoreViewQuery: true, + } satisfies IGetRecordsRo; + + const result = await this.getRecordsWithPreparedScope( + tableId, + listQuery, + queryScope, + container, + context, + table + ); + if (result.result.records[0]) { + return result.result.records[0]; + } + + // Authority-matrix parity: if the row exists but is outside recordSpec, + // return 403 (not 404). EE AuthorityGuard often catches this first; this + // covers internal/delegated paths and defense in depth. + const existsOutsideScope = await this.probeRecordExistsOutsideDiscretionaryRowFilter( + tableId, + recordId, + queryScope, + container, + context, + table + ); + if (existsOutsideScope) { + throw new CustomHttpException( + `Record permission not allowed: record|read`, + HttpErrorCode.RESTRICTED_RESOURCE, + { + localization: { + i18nKey: 'httpErrors.permission.notAllowedOperationRecord', + }, + } + ); + } + + throw new CustomHttpException('Record not found', HttpErrorCode.NOT_FOUND, { + localization: { i18nKey: 'httpErrors.record.notFound' }, + }); + } + + async resolveRecordIdsBySelection( + tableId: string, + selectionRo: Pick< + ISelectionIdMutationBaseRo, + | 'selection' + | 'viewId' + | 'ignoreViewQuery' + | 'filter' + | 'orderBy' + | 'groupBy' + | 'search' + | 'collapsedGroupIds' + | 'projection' + > + ): Promise { + const { selection, ...queryRo } = selectionRo; + if (selection.recordIds) { + return selection.recordIds; + } + + const rangeQuery = await this.normalizeRangeQuery(tableId, queryRo); + const records: IRecordsVo['records'] = []; + let skip = 0; + let hasMore = true; + while (hasMore) { + const result = await this.getRecords(tableId, { + viewId: rangeQuery.viewId, + ignoreViewQuery: rangeQuery.ignoreViewQuery, + filter: rangeQuery.filter, + orderBy: rangeQuery.orderBy, + groupBy: rangeQuery.groupBy, + search: rangeQuery.search, + projection: queryRo.projection, + skip, + take: maxResolveSelectionRecordIdsPageSize, + fieldKeyType: FieldKeyType.Id, + }); + records.push(...result.records); + hasMore = result.records.length === maxResolveSelectionRecordIdsPageSize; + skip += maxResolveSelectionRecordIdsPageSize; + } + const excludedIds = new Set(selection.excludeRecordIds ?? []); + return records.map((record) => record.id).filter((recordId) => !excludedIds.has(recordId)); + } + + private async withTableDataClient(tableId: string, fn: () => Promise): Promise { + const resolvedDataDb = await this.dataDbClientManager.getDataDatabaseForTable(tableId); + if (resolvedDataDb.isMetaFallback) { + return fn(); + } + + const dataPrisma = await this.dataDbClientManager.dataPrismaForTable(tableId); + const cls = this.cls as unknown as ClsService<{ dataTx: { client?: unknown } }>; + const store = cls.get(); + const previousClient = cls.get(dataTxClientKey); + + return cls.runWith(store, async () => { + cls.set(dataTxClientKey, dataPrisma); + try { + return await fn(); + } finally { + cls.set(dataTxClientKey, previousClient); + } + }); + } + + /** + * Sign attachment download/preview URLs for pure-V2 JSON responses. + * + * Field discovery: V2 table aggregate. Signing: v2-core free function + * {@link presignAttachmentFieldMaps} + container {@link IAttachmentUrlSignerService} + * (Nest adapter looks up thumbnails and storage URLs). No RecordService. + */ + private async presignAttachmentFieldsFromTable( + container: DependencyContainer, + table: Table, + records: IRecord[], + fieldKeyType: FieldKeyType + ): Promise { + if (!records.length) { + return records; + } + if (!container.isRegistered(v2CoreTokens.attachmentUrlSignerService)) { + return records; + } + + const attachmentFieldKeys = new Set( + table + .getFields() + .filter((field) => this.isAttachmentValueField(field)) + .map((field) => this.resolveResponseFieldKey(table, field.id().toString(), fieldKeyType)) + ); + if (!attachmentFieldKeys.size) { + return records; + } + + const signer = container.resolve( + v2CoreTokens.attachmentUrlSignerService + ); + const signedFieldsResult = await presignAttachmentFieldMaps( + records.map((record) => record.fields), + attachmentFieldKeys, + signer + ); + if (signedFieldsResult.isErr()) { + // Fail closed on presentation: return unsigned cells rather than 500 the list. + return records; + } + + const signedFieldMaps = signedFieldsResult.value; + return records.map((record, index) => ({ + ...record, + fields: signedFieldMaps[index] ?? record.fields, + })); + } + + private isAttachmentValueField(field: V2Field): boolean { + return this.presentationFieldType(field) === 'attachment'; + } + + private presentationField(field: V2Field): V2Field { + const fieldType = field.type().toString(); + if (fieldType !== 'lookup' && fieldType !== 'conditionalLookup') { + return field; + } + const innerField = + fieldType === 'lookup' + ? (field as LookupField).innerField() + : (field as ConditionalLookupField).innerField(); + return innerField.isOk() ? this.presentationField(innerField.value) : field; + } + + private presentationFieldType(field: V2Field): string { + return this.presentationField(field).type().toString(); + } + + private isPublicUserValueField(field: V2Field): boolean { + return publicUserFieldTypes.has(this.presentationFieldType(field)); + } + + private async hydrateLegacyUserCells( + container: DependencyContainer, + table: Table, + records: IRecord[], + fieldKeyType: FieldKeyType + ): Promise { + const userFields = table + .getFields() + .filter((field) => this.isPublicUserValueField(field)) + .map((field) => ({ + key: this.resolveResponseFieldKey(table, field.id().toString(), fieldKeyType), + shouldResolveUser: this.presentationFieldType(field) !== 'lastModifiedBy', + })); + if (!userFields.length || !records.length) { + return records; + } + + const userFieldKeys = new Set( + userFields.filter((field) => field.shouldResolveUser).map((field) => field.key) + ); + + const userIds = new Set(); + for (const record of records) { + for (const key of userFieldKeys) { + this.collectLegacyUserIds(record.fields[key], userIds); + } + } + const resolvedUsers = userIds.size + ? await this.resolvePublicUsers(container, userIds) + : new Map(); + const unresolvedUsers = new Map(); + + return records.map((record) => { + const fields = { ...record.fields }; + for (const field of userFields) { + if (field.key in fields) { + fields[field.key] = this.normalizePublicUserValue( + fields[field.key], + field.shouldResolveUser ? resolvedUsers : unresolvedUsers + ); + } + } + return { ...record, fields }; + }); + } + + private collectLegacyUserIds(value: unknown, target: Set): void { + if (typeof value === 'string') { + if (value.startsWith('usr')) target.add(value); + return; + } + if (Array.isArray(value)) { + value.forEach((item) => this.collectLegacyUserIds(item, target)); + return; + } + if (value && typeof value === 'object') { + const id = (value as { id?: unknown }).id; + if (typeof id === 'string' && id.startsWith('usr')) target.add(id); + } + } + + private collectGroupUserIds(value: unknown, target: Set): void { + this.collectLegacyUserIds(value, target); + if (Array.isArray(value)) { + value.forEach((item) => this.collectGroupUserIds(item, target)); + return; + } + if (value && typeof value === 'object') { + const id = (value as { id?: unknown }).id; + if (typeof id === 'string' && id.startsWith('usr')) target.add(id); + } + } + + private async resolvePublicUsers( + container: DependencyContainer, + userIds: ReadonlySet + ): Promise> { + const resolvedUsers = new Map(); + if (!container.isRegistered(v2CoreTokens.userLookupService)) { + return resolvedUsers; + } + try { + const lookup = container.resolve(v2CoreTokens.userLookupService); + const result = await lookup.listUsersByIdentifiers([...userIds]); + if (result.isOk()) { + for (const user of result.value) { + resolvedUsers.set(user.id, { + id: user.id, + title: user.name, + ...(user.email ? { email: user.email } : {}), + }); + } + } + } catch { + // Keep the public user-cell shape even when optional enrichment fails. + } + return resolvedUsers; + } + + private normalizePublicUserValue( + value: unknown, + resolvedUsers: ReadonlyMap + ): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.normalizePublicUserValue(item, resolvedUsers)); + } + + const id = + typeof value === 'string' + ? value + : value && typeof value === 'object' + ? (value as { id?: unknown }).id + : undefined; + if (typeof id !== 'string' || !id.startsWith('usr')) { + return value; + } + + const resolved = resolvedUsers.get(id); + const existing = value && typeof value === 'object' ? value : {}; + return { + ...existing, + id, + title: + resolved?.title ?? + (typeof (existing as { title?: unknown }).title === 'string' + ? (existing as { title: string }).title + : id), + ...(resolved?.email ? { email: resolved.email } : {}), + avatarUrl: buildUserAvatarUrl(id), + }; + } + + /** + * Pure-V2 CellFormat.Text mapping from already-resolved cell values. + * Prefer structural title/name for link/user cells; never "[object Object]". + * Does not load V1 FieldService. + */ + private formatRecordFieldsAsDisplayText( + table: Table, + records: IRecord[], + fieldKeyType: FieldKeyType + ): IRecord[] { + if (!records.length) { + return records; + } + + const formatterByKey = new Map(); + for (const field of table.getFields()) { + const formatter = this.createDisplayFieldInstance(field); + if (formatter) { + formatterByKey.set( + this.resolveResponseFieldKey(table, field.id().toString(), fieldKeyType), + formatter + ); + } + } + const primaryKey = this.resolveResponseFieldKey( + table, + table.primaryFieldId().toString(), + fieldKeyType + ); + + return records.map((record) => { + const nextFields: IRecord['fields'] = {}; + for (const [key, value] of Object.entries(record.fields)) { + if (value == null) { + continue; + } + nextFields[key] = this.formatCellValueWithField(formatterByKey.get(key), value); + } + return { + ...record, + fields: nextFields, + name: + primaryKey in record.fields + ? this.formatCellValueWithField( + formatterByKey.get(primaryKey), + record.fields[primaryKey] + ) + : this.primaryValueToRecordName(record.name), + }; + }); + } + + private createDisplayFieldInstance( + field: ReturnType[number] + ): IFieldInstance | undefined { + const presentationField = this.presentationField(field); + const valueTypeResult = field.accept(new FieldValueTypeVisitor()); + const optionsResult = presentationField.accept(new FieldOptionsDtoVisitor()); + if (valueTypeResult.isErr() || optionsResult.isErr()) { + return undefined; + } + + const type = presentationField.type().toString() as FieldType; + const cellValueType = this.cellValueTypeFromV2ValueType( + valueTypeResult.value.cellValueType.toString() + ); + const isMultipleCellValue = valueTypeResult.value.isMultipleCellValue.toBoolean(); + try { + return createFieldInstanceByVo({ + id: field.id().toString(), + dbFieldName: field.id().toString(), + name: field.name().toString(), + type, + options: + optionsResult.value && typeof optionsResult.value === 'object' + ? (optionsResult.value as IFieldVo['options']) + : {}, + cellValueType, + isMultipleCellValue, + dbFieldType: getDbFieldType(type, cellValueType, isMultipleCellValue), + }); + } catch { + return undefined; + } + } + + private formatCellValueWithField(field: IFieldInstance | undefined, value: unknown): string { + if (field) { + try { + return field.cellValue2String(value) ?? ''; + } catch { + // Malformed legacy cells should not fail the entire records endpoint. + } + } + return this.cellValueToDisplayText(value); + } + + private primaryValueToRecordName(value: unknown): string { + if (value == null) { + return ''; + } + if (typeof value === 'string') { + return value; + } + return this.cellValueToDisplayText(value); + } + + private cellValueToDisplayText(value: unknown): string { + if (value == null) { + return ''; + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + if (value instanceof Date) { + return value.toISOString(); + } + if (Array.isArray(value)) { + return value + .map((entry) => this.cellValueToDisplayText(entry)) + .filter((entry) => entry.length > 0) + .join(', '); + } + if (typeof value === 'object') { + const obj = value as Record; + if (typeof obj.title === 'string') { + return obj.title; + } + if (typeof obj.name === 'string') { + return obj.name; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); + } + + /** + * Format top-level system datetime fields using V2 table aggregate field defs + * (CreatedTime / LastModifiedTime formatting), not FieldService. + */ + private formatSystemDatetimeFieldsFromTable( + table: Table, + records: IRecord[], + cellFormat?: CellFormat, + sortedFieldIds?: ReadonlyArray + ): IRecord[] { + if (!records.length || cellFormat === CellFormat.Text || !sortedFieldIds?.length) { + return records; + } + + const sortedFieldIdSet = new Set(sortedFieldIds); + const formatters = table.getFields().flatMap((field) => { + const fieldId = field.id().toString(); + if (!sortedFieldIdSet.has(fieldId)) { + return []; + } + const fieldType = field.type().toString(); + if (fieldType !== 'createdTime' && fieldType !== 'lastModifiedTime') { + return []; + } + const formattingDto = + 'formatting' in field && typeof field.formatting === 'function' + ? ( + field as { + formatting: () => { toDto: () => IDatetimeFormatting }; + } + ) + .formatting() + .toDto() + : undefined; + if (!formattingDto || formattingDto.time !== TimeFormatting.None) { + return []; + } + return [ + { + topLevelKey: + fieldType === 'createdTime' ? ('createdTime' as const) : ('lastModifiedTime' as const), + formatting: formattingDto, + }, + ]; + }); + + if (!formatters.length) { + return records; + } + + return records.map((record) => { + let nextRecord: IRecord | undefined; + + for (const formatter of formatters) { + const topLevelValue = record[formatter.topLevelKey]; + if (typeof topLevelValue === 'string') { + const formattedTopLevel = formatDateToString(topLevelValue, formatter.formatting); + if (formattedTopLevel !== topLevelValue) { + nextRecord ??= { ...record }; + nextRecord[formatter.topLevelKey] = formattedTopLevel; + } + } + } + + return nextRecord ?? record; + }); + } + + private extractDatetimeFormatting(options: unknown): IDatetimeFormatting | undefined { + if (!options || typeof options !== 'object' || !('formatting' in options)) { + return undefined; + } + + const formatting = options.formatting; + if (!formatting || typeof formatting !== 'object') { + return undefined; + } + + return formatting as IDatetimeFormatting; + } + + private toProjectionMap( + fieldKeys?: string | ReadonlyArray + ): Record | undefined { + if (!fieldKeys) { + return undefined; + } + const keys = (Array.isArray(fieldKeys) ? fieldKeys : [fieldKeys]).filter( + (key): key is string => typeof key === 'string' && key.length > 0 + ); + if (!keys.length) { + return undefined; + } + return keys.reduce>((acc, key) => { + acc[key] = true; + return acc; + }, {}); + } + + private async resolveSnapshotProjection( + tableId: string, + query: IGetRecordsRo, + fieldKeyType: FieldKeyType, + enabledFieldIds?: ReadonlyArray + ): Promise | undefined> { + const explicitProjection = this.toProjectionMap( + query.projection as unknown as string | string[] + ); + if (explicitProjection) { + return explicitProjection; + } + + // undefined = unrestricted; empty array = no user fields (deny-all). + if (enabledFieldIds != null) { + if (!enabledFieldIds.length) { + return {}; + } + if (fieldKeyType === FieldKeyType.Id) { return this.toProjectionMap(enabledFieldIds); } @@ -625,89 +1669,489 @@ export class RecordOpenApiV2Service { }) .filter((key): key is string => Boolean(key)); - return this.toProjectionMap(projectionKeys); + return this.toProjectionMap(projectionKeys); + } + + if (query.ignoreViewQuery || !query.viewId) { + return undefined; + } + + const visibleFields = await this.fieldService.getFieldsByQuery(tableId, { + viewId: query.viewId, + filterHidden: true, + }); + + const projectionKeys = visibleFields + .map((field) => { + if (fieldKeyType === FieldKeyType.Id) { + return field.id; + } + if (fieldKeyType === FieldKeyType.Name) { + return field.name; + } + return field.dbFieldName || field.name; + }) + .filter((key): key is string => Boolean(key)); + + return this.toProjectionMap(projectionKeys); + } + + private async executeListRecordsEndpoint( + input: IListTableRecordsQueryInput, + context: IExecutionContext, + queryBus: IQueryBus, + options?: { + queryScope?: RecordQueryPluginScope; + recordReadQuerySource?: IRecordReadQuerySource; + recordSearchAccessPath?: IRecordSearchAccessPath; + } + ): Promise<{ + records: Array<{ id: string; fields: Record }>; + pagination: { hasMore: boolean }; + }> { + const result = await executeListTableRecordsEndpoint(context, input, queryBus, options); + if (result.status === 200 && result.body.ok) { + return { + records: result.body.data.records as Array<{ id: string; fields: Record }>, + pagination: { + hasMore: result.body.data.pagination.hasMore, + }, + }; + } + + if (!result.body.ok) { + throwV2Error(result.body.error, result.status); + } + + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + + /** + * Pure-V2 list path: executes ListTableRecordsQuery and returns full read models + * (including system columns) without the HTTP DTO strip. + */ + private async executeListTableRecordsQuery( + input: IListTableRecordsQueryInput, + context: IExecutionContext, + queryBus: IQueryBus, + options?: { + queryScope?: RecordQueryPluginScope; + recordSearchAccessPath?: IRecordSearchAccessPath; + includeGroupMetadata?: boolean; + groupLimit?: number; + } + ): Promise<{ + records: ReadonlyArray; + total: number; + groups?: ReadonlyArray; + }> { + const queryResult = ListTableRecordsQuery.create(input, options); + if (queryResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(queryResult.error), + mapDomainErrorToHttpStatus(queryResult.error) + ); + } + + const result = await queryBus.execute( + context, + queryResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + + return { + records: result.value.records, + total: result.value.total, + ...(result.value.groups ? { groups: result.value.groups } : {}), + }; + } + + private async loadV2Table( + context: IExecutionContext, + container: DependencyContainer, + tableId: string + ): Promise { + const tableIdResult = TableId.create(tableId); + if (tableIdResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(tableIdResult.error), + mapDomainErrorToHttpStatus(tableIdResult.error) + ); + } + const tableRepository = container.resolve(v2CoreTokens.tableRepository); + const tableResult = await tableRepository.findOne( + context, + TableByIdSpec.create(tableIdResult.value) + ); + if (tableResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(tableResult.error), + mapDomainErrorToHttpStatus(tableResult.error) + ); + } + return tableResult.value; + } + + /** + * Host-controlled existence probe for 403 vs 404 after a scoped getOne miss. + * + * Re-prepares plugins as **getOne** with `existenceProbe: true` so only plugins + * that honor that intent (authority matrix) drop their discretionary row filter. + * Other plugins keep their recordSpec. Never sets global skipRecordSpec on a + * pre-merged scope. + */ + private async probeRecordExistsOutsideDiscretionaryRowFilter( + tableId: string, + recordId: string, + getOneScope: RecordQueryPluginScope | undefined, + container: DependencyContainer, + context: IExecutionContext, + table: Table + ): Promise { + // No row filter was applied on the miss — cannot distinguish 403 vs 404. + if (!getOneScope?.recordSpec) { + return false; + } + const probeScope = await this.prepareRecordQueryScope(context, container, table, { + kind: RecordQueryOperationKind.getOne, + recordId, + ignoreViewQuery: true, + existenceProbe: true, + }); + const queryBus = container.resolve(v2CoreTokens.queryBus); + const listResult = await this.executeListTableRecordsQuery( + { + tableId, + fieldKeyType: FieldKeyType.Id, + limit: 1, + offset: 0, + projection: [], + includeTotal: false, + selectedRecordIds: [recordId], + ignoreViewQuery: true, + }, + context, + queryBus, + { + ...(probeScope ? { queryScope: probeScope } : {}), + } + ); + return listResult.records.length > 0; + } + + private async prepareRecordQueryScope( + context: IExecutionContext, + container: DependencyContainer, + table: Table, + payload: + | { + kind: typeof RecordQueryOperationKind.list; + viewId?: string; + ignoreViewQuery?: boolean; + limit?: number; + offset?: number; + projectionFieldIds?: ReadonlyArray; + keepPrimaryKey?: boolean; + } + | { + kind: typeof RecordQueryOperationKind.getOne; + recordId: string; + viewId?: string; + ignoreViewQuery?: boolean; + projectionFieldIds?: ReadonlyArray; + /** See RecordQueryGetOnePayload.existenceProbe */ + existenceProbe?: boolean; + } + | { + kind: typeof RecordQueryOperationKind.getByIds; + recordIds: ReadonlyArray; + viewId?: string; + ignoreViewQuery?: boolean; + projectionFieldIds?: ReadonlyArray; + keepPrimaryKey?: boolean; + } + ): Promise { + if (!container.isRegistered(v2CoreTokens.recordQueryPluginRunner)) { + return undefined; + } + const runner = container.resolve(v2CoreTokens.recordQueryPluginRunner); + const prepared = + payload.kind === RecordQueryOperationKind.getOne + ? await runner.prepare({ + kind: RecordQueryOperationKind.getOne, + executionContext: context, + table, + payload: { + recordId: payload.recordId, + viewId: payload.viewId, + ignoreViewQuery: payload.ignoreViewQuery, + projectionFieldIds: payload.projectionFieldIds, + existenceProbe: payload.existenceProbe, + }, + }) + : payload.kind === RecordQueryOperationKind.getByIds + ? await runner.prepare({ + kind: RecordQueryOperationKind.getByIds, + executionContext: context, + table, + payload: { + recordIds: payload.recordIds, + viewId: payload.viewId, + ignoreViewQuery: payload.ignoreViewQuery, + projectionFieldIds: payload.projectionFieldIds, + keepPrimaryKey: payload.keepPrimaryKey, + }, + }) + : await runner.prepare({ + kind: RecordQueryOperationKind.list, + executionContext: context, + table, + payload: { + viewId: payload.viewId, + ignoreViewQuery: payload.ignoreViewQuery, + projectionFieldIds: payload.projectionFieldIds, + limit: payload.limit, + offset: payload.offset, + keepPrimaryKey: payload.keepPrimaryKey, + }, + }); + if (prepared.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(prepared.error), + mapDomainErrorToHttpStatus(prepared.error) + ); + } + const execution = prepared.value; + const guardResult = await execution.guard(); + if (guardResult.isErr()) { + const status = isForbiddenError(guardResult.error) + ? HttpStatus.FORBIDDEN + : mapDomainErrorToHttpStatus(guardResult.error); + throwV2Error(mapDomainErrorToHttpError(guardResult.error), status); + } + const scopeResult = execution.getScope(); + if (scopeResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(scopeResult.error), + mapDomainErrorToHttpStatus(scopeResult.error) + ); + } + return scopeResult.value; + } + + /** + * Resolve list projection to field **ids** from the V2 table aggregate only. + * + * Projection keys may be ids, names, or dbFieldNames depending on + * `fieldKeyType` (EE `getProjectionWithPermission` often returns names). + * Always normalize to field ids before ListTableRecords. + */ + private resolveListProjectionFieldIdsFromTable( + table: Table, + query: IGetRecordsRo, + enabledFieldIds?: ReadonlyArray + ): string[] { + // Empty allow-list means no user fields (not unrestricted). + if (enabledFieldIds != null && enabledFieldIds.length === 0) { + return []; } - if (query.ignoreViewQuery || !query.viewId) { - return undefined; + const allowSet = enabledFieldIds != null ? new Set(enabledFieldIds) : undefined; + const intersectAllow = (ids: ReadonlyArray) => + allowSet ? ids.filter((id) => allowSet.has(id)) : [...ids]; + + const fieldKeyType = query.fieldKeyType ?? FieldKeyType.Name; + const explicitProjection = Array.isArray(query.projection) + ? query.projection.filter((key): key is string => typeof key === 'string' && key.length > 0) + : undefined; + if (explicitProjection?.length) { + const resolvedIds = this.resolveProjectionKeysToFieldIds( + table, + explicitProjection, + fieldKeyType + ); + return intersectAllow(resolvedIds); } - const visibleFields = await this.fieldService.getFieldsByQuery(tableId, { - viewId: query.viewId, - filterHidden: true, - }); + // Restricted role without client projection: allow-list *is* the projection + // (matrix already scoped to this table's fields). + if (allowSet) { + return [...allowSet]; + } - const projectionKeys = visibleFields - .map((field) => { - if (fieldKeyType === FieldKeyType.Id) { - return field.id; - } - if (fieldKeyType === FieldKeyType.Name) { - return field.name; - } - return field.dbFieldName || field.name; - }) - .filter((key): key is string => Boolean(key)); + if (query.viewId && !query.ignoreViewQuery) { + const visibleResult = table.getOrderedVisibleFieldIds(query.viewId); + if (visibleResult.isOk()) { + return visibleResult.value.map((fieldId) => fieldId.toString()); + } + // View missing: fall through to all table fields. + } - return this.toProjectionMap(projectionKeys); + return table.fieldIds().map((fieldId) => fieldId.toString()); } - private async executeListRecordsEndpoint( - input: IListTableRecordsQueryInput, - context: IExecutionContext, - queryBus: IQueryBus, - options?: { - recordReadQuerySource?: IRecordReadQuerySource; - recordSearchAccessPath?: IRecordSearchAccessPath; - } - ): Promise<{ - records: Array<{ id: string; fields: Record }>; - pagination: { hasMore: boolean }; - }> { - const result = await executeListTableRecordsEndpoint(context, input, queryBus, options); - if (result.status === 200 && result.body.ok) { - return { - records: result.body.data.records as Array<{ id: string; fields: Record }>, - pagination: { - hasMore: result.body.data.pagination.hasMore, - }, - }; + /** + * Map projection keys (id / name / dbFieldName) to field ids via table aggregate. + */ + private resolveProjectionKeysToFieldIds( + table: Table, + keys: ReadonlyArray, + fieldKeyType: FieldKeyType + ): string[] { + if (fieldKeyType === FieldKeyType.Id || (fieldKeyType as string) === 'id') { + return [...keys]; } - if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + const byName = new Map(); + const byDbName = new Map(); + for (const field of table.getFields()) { + const id = field.id().toString(); + byName.set(field.name().toString(), id); + const dbResult = field.dbFieldName(); + if (dbResult.isOk()) { + const valueResult = dbResult.value.value(); + if (valueResult.isOk() && valueResult.value) { + byDbName.set(valueResult.value, id); + } + } } - throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + const resolved: string[] = []; + const seen = new Set(); + for (const key of keys) { + let fieldId: string | undefined; + if (fieldKeyType === FieldKeyType.Name || (fieldKeyType as string) === 'name') { + fieldId = byName.get(key) ?? (key.startsWith('fld') ? key : undefined); + } else { + fieldId = byDbName.get(key) ?? byName.get(key) ?? (key.startsWith('fld') ? key : undefined); + } + if (fieldId && !seen.has(fieldId)) { + seen.add(fieldId); + resolved.push(fieldId); + } + } + return resolved; } - private async createV2ReadContext( - tableId: string, - query: Pick, - container: DependencyContainer - ): Promise<{ - context: IExecutionContext; - recordReadQuerySource?: IRecordReadQuerySource; - }> { - const context = await this.v2ContextFactory.createContext(container); - const readSource = await this.recordPermissionService.getReadQuerySource(tableId, { - viewId: query.viewId, - keepPrimaryKey: Boolean(query.filterLinkCellSelected), - }); - if (!readSource) { - return { context }; + private mapTableRecordReadModelToIRecord( + table: Table, + record: TableRecordReadModel, + primaryFieldId: string, + fieldKeyType: FieldKeyType, + primaryFormatter: IFieldInstance | undefined + ): IRecord { + const rawFields = { ...record.fields }; + for (const field of table.getFields()) { + const fieldId = field.id().toString(); + if (rawFields[fieldId] != null) { + continue; + } + const fieldType = field.type().toString(); + if (fieldType === 'createdBy' && record.createdBy) { + rawFields[fieldId] = this.systemAuditUserFallback(record.createdBy); + } else if ( + fieldType === 'lastModifiedBy' && + (field as LastModifiedByField).isTrackAll() && + record.lastModifiedBy + ) { + rawFields[fieldId] = this.systemAuditUserFallback(record.lastModifiedBy); + } } + // List returns id-keyed fields; remap using table aggregate (not FieldService). + const fields = this.remapRecordFieldsFromTable(table, rawFields, fieldKeyType); + const primaryKey = this.resolveResponseFieldKey(table, primaryFieldId, fieldKeyType); + const primaryValue = fields[primaryKey] ?? record.fields[primaryFieldId]; return { - context, - recordReadQuerySource: { - tableName: readSource.tableName, - cteName: readSource.cteName, - cteSql: readSource.cteSql, - enabledFieldIds: readSource.enabledFieldIds, - }, + id: record.id, + fields, + name: this.formatCellValueWithField(primaryFormatter, primaryValue), + autoNumber: record.autoNumber, + createdTime: record.createdTime, + lastModifiedTime: record.lastModifiedTime, + createdBy: record.createdBy, + lastModifiedBy: record.lastModifiedBy, + }; + } + + private systemAuditUserFallback(userId: string): { + id: string; + title: string; + avatarUrl: string; + } { + return { + id: userId, + title: userId, + avatarUrl: buildUserAvatarUrl(userId), }; } + /** + * Remap id-keyed cell map to the requested OpenAPI fieldKeyType using the + * V2 table aggregate only (names / dbFieldNames live on domain fields). + * + * V1 parity: omit null/undefined cells (and unchecked checkbox `false`) so + * clients and e2e asserts see missing keys, not explicit nulls. + */ + private remapRecordFieldsFromTable( + table: Table, + fields: Record, + fieldKeyType: FieldKeyType + ): Record { + const byId = new Map(table.getFields().map((field) => [field.id().toString(), field])); + const remapped: Record = {}; + for (const [fieldId, value] of Object.entries(fields)) { + if (value == null) { + continue; + } + const field = byId.get(fieldId); + // Unchecked checkbox is null in V1 JSON responses. + if (value === false && field?.type().toString() === 'checkbox') { + continue; + } + if (fieldKeyType === FieldKeyType.Id || (fieldKeyType as string) === 'id') { + remapped[fieldId] = value; + continue; + } + if (!field) { + remapped[fieldId] = value; + continue; + } + remapped[this.resolveResponseFieldKey(table, fieldId, fieldKeyType)] = value; + } + return remapped; + } + + private resolveResponseFieldKey( + table: Table, + fieldId: string, + fieldKeyType: FieldKeyType + ): string { + if (fieldKeyType === FieldKeyType.Id || (fieldKeyType as string) === 'id') { + return fieldId; + } + const field = table.getFields().find((item) => item.id().toString() === fieldId); + if (!field) { + return fieldId; + } + if (fieldKeyType === FieldKeyType.Name || (fieldKeyType as string) === 'name') { + return field.name().toString(); + } + // dbFieldName — fall back to name when physical name is unset. + const dbFieldNameResult = field.dbFieldName(); + if (dbFieldNameResult.isOk()) { + const valueResult = dbFieldNameResult.value.value(); + if (valueResult.isOk() && valueResult.value) { + return valueResult.value; + } + } + return field.name().toString(); + } + private async resolveRecordSearchAccessPath( context: IExecutionContext, tableId: string, @@ -734,25 +2178,40 @@ export class RecordOpenApiV2Service { ); } - private sanitizeReadableSortAndGroup( - query: Pick, - enabledFieldIds?: ReadonlyArray - ): Pick { - if (!enabledFieldIds?.length) { - return { - orderBy: query.orderBy, - groupBy: query.groupBy, - }; + /** + * Resolve orderBy/groupBy field keys (name / dbFieldName / id) to field ids. + */ + private resolveSortGroupFieldKeysToIds< + T extends { fieldId: string; order?: string } | { fieldId: string; order: string }, + >(table: Table, items: ReadonlyArray | undefined): T[] | undefined { + if (!items?.length) { + return items as T[] | undefined; } - - const enabledFieldIdSet = new Set(enabledFieldIds); - const orderBy = query.orderBy?.filter((item) => enabledFieldIdSet.has(item.fieldId)); - const groupBy = query.groupBy?.filter((item) => enabledFieldIdSet.has(item.fieldId)); - - return { - orderBy: orderBy?.length ? orderBy : undefined, - groupBy: groupBy?.length ? groupBy : undefined, - }; + const byId = new Set(table.getFields().map((field) => field.id().toString())); + const byName = new Map( + table.getFields().map((field) => [field.name().toString(), field.id().toString()]) + ); + const byDbName = new Map(); + for (const field of table.getFields()) { + const dbResult = field.dbFieldName(); + if (dbResult.isOk()) { + const valueResult = dbResult.value.value(); + if (valueResult.isOk() && valueResult.value) { + byDbName.set(valueResult.value, field.id().toString()); + } + } + } + const resolved: T[] = []; + for (const item of items) { + const fieldId = byId.has(item.fieldId) + ? item.fieldId + : byName.get(item.fieldId) ?? byDbName.get(item.fieldId); + if (!fieldId) { + continue; + } + resolved.push({ ...item, fieldId }); + } + return resolved.length ? resolved : undefined; } private shouldLoadQueryExtra( @@ -762,6 +2221,9 @@ export class RecordOpenApiV2Service { if (query.includeQueryExtra === false) { return false; } + if (query.groupBy?.length || query.collapsedGroupIds?.length) { + return true; + } if ( (recordSearchAccessPath?.kind === 'generated_tsvector' || recordSearchAccessPath?.kind === 'generated_text') && @@ -770,36 +2232,29 @@ export class RecordOpenApiV2Service { ) { return false; } - const hasQueryExtraSource = Boolean( - query.search || query.groupBy?.length || query.collapsedGroupIds?.length - ); - if (query.includeQueryExtra === true) { - return hasQueryExtraSource; - } - - const hasExplicitProjection = Array.isArray(query.projection) - ? query.projection.length > 0 - : Boolean(query.projection); - if (hasExplicitProjection && !query.search && !query.collapsedGroupIds?.length) { - return false; - } - - return hasQueryExtraSource; + return Boolean(query.search); } private async loadQueryExtraWithTrace( context: IExecutionContext, tableId: string, query: IGetRecordsRo, - recordSearchAccessPath?: IRecordSearchAccessPath + recordSearchAccessPath?: IRecordSearchAccessPath, + queryScope?: RecordQueryPluginScope ): Promise { const shouldLoad = this.shouldLoadQueryExtra(query, recordSearchAccessPath); + // Fail-closed: residual V1 getDocIdsByQuery may not honor V2 plugin scope + // (missing CLS authority on internal/delegated paths). Prefer omitting + // searchHitIndex/group counts over leaking unauthorized ids/structure. + const scopeRestrictsAccess = this.queryScopeRestrictsAccess(queryScope); + const enabled = shouldLoad && !scopeRestrictsAccess; return await this.withRecordReadSpan( context, 'teable.RecordOpenApiV2Service.queryExtra', { - 'record.read.query_extra_enabled': shouldLoad, + 'record.read.query_extra_enabled': enabled, + 'record.read.query_extra_skipped_for_scope': shouldLoad && scopeRestrictsAccess, 'record.read.include_query_extra': query.includeQueryExtra !== false, 'record.read.has_search': Boolean(query.search), 'record.read.search_access_path': recordSearchAccessPath?.kind ?? 'default', @@ -808,12 +2263,27 @@ export class RecordOpenApiV2Service { 'record.read.has_explicit_projection': Boolean(query.projection), }, () => - shouldLoad + enabled ? this.withTableDataClient(tableId, () => this.getQueryExtra(tableId, query)) : Promise.resolve(undefined) ); } + /** + * True when V2 plugin scope constrains rows/fields — V1 queryExtra must not run. + */ + private queryScopeRestrictsAccess(scope: RecordQueryPluginScope | undefined): boolean { + if (!scope) { + return false; + } + return Boolean( + scope.recordSpec || + scope.fieldMasks?.length || + scope.readableFieldIds != null || + scope.skipRecordSpec + ); + } + private async getQueryExtra( tableId: string, query: IGetRecordsRo @@ -923,7 +2393,7 @@ export class RecordOpenApiV2Service { const result = await executeUpdateRecordEndpoint(context, v2Input, commandBus); if (!(result.status === 200 && result.body.ok)) { if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + throwV2Error(result.body.error, result.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); } @@ -935,6 +2405,93 @@ export class RecordOpenApiV2Service { throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); } + async buttonClick( + tableId: string, + recordId: string, + fieldId: string, + shareScope?: { + viewId: string; + includeHiddenFields: boolean; + includeRecords: boolean; + } + ): Promise { + await this.assertTableRecordWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const command = ClickButtonCommand.create({ + tableId, + recordId, + fieldId, + shareScope, + }); + if (command.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(command.error), + mapDomainErrorToHttpStatus(command.error) + ); + } + const result = await commandBus.execute( + context, + command.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + const record: IRecord = { + id: result.value.record.id().toString(), + fields: Object.fromEntries( + result.value.record + .fields() + .entries() + .map(({ fieldId: resultFieldId, value }) => [resultFieldId.toString(), value.toValue()]) + ), + }; + await this.clearUndoRedoEnginePreference(tableId); + return { + runId: result.value.runId, + tableId: result.value.tableId, + fieldId: result.value.fieldId, + record, + }; + } + + async buttonReset(tableId: string, recordId: string, fieldId: string): Promise { + await this.assertTableRecordWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const command = ResetButtonCommand.create({ tableId, recordId, fieldId }); + if (command.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(command.error), + mapDomainErrorToHttpStatus(command.error) + ); + } + const result = await commandBus.execute( + context, + command.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + const fields = Object.fromEntries( + result.value.record + .fields() + .entries() + .filter(({ value }) => value.toValue() != null) + .map(({ fieldId: resultFieldId, value }) => [resultFieldId.toString(), value.toValue()]) + ); + await this.clearUndoRedoEnginePreference(tableId); + return { id: result.value.record.id().toString(), fields }; + } + async updateRecords( tableId: string, updateRecordsRo: IUpdateRecordsRo, @@ -943,8 +2500,7 @@ export class RecordOpenApiV2Service { } ): Promise { await this.assertTableRecordWritable(tableId); - const rawRecords = updateRecordsRo.records ?? []; - const records = this.mergeDuplicateRecordUpdates(rawRecords); + const records = updateRecordsRo.records ?? []; const recordIds = records.map((record) => record.id); if (recordIds.length === 0) { return []; @@ -988,7 +2544,7 @@ export class RecordOpenApiV2Service { ); if (!(updateResult.status === 200 && updateResult.body.ok)) { if (!updateResult.body.ok) { - this.throwV2Error(updateResult.body.error, updateResult.status); + throwV2Error(updateResult.body.error, updateResult.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); } @@ -1135,7 +2691,7 @@ export class RecordOpenApiV2Service { } if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + throwV2Error(result.body.error, result.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); @@ -1164,7 +2720,7 @@ export class RecordOpenApiV2Service { } if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + throwV2Error(result.body.error, result.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); @@ -1234,7 +2790,7 @@ export class RecordOpenApiV2Service { } if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + throwV2Error(result.body.error, result.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); @@ -1258,7 +2814,7 @@ export class RecordOpenApiV2Service { const preparedPaste = await this.preparePasteCommandInput(tableId, pasteRo, options); const commandResult = PasteStreamCommand.create(preparedPaste.commandInput); if (commandResult.isErr()) { - this.throwV2Error( + throwV2Error( mapDomainErrorToHttpError(commandResult.error), mapDomainErrorToHttpStatus(commandResult.error) ); @@ -1269,7 +2825,7 @@ export class RecordOpenApiV2Service { commandResult.value ); if (result.isErr()) { - this.throwV2Error( + throwV2Error( mapDomainErrorToHttpError(result.error), mapDomainErrorToHttpStatus(result.error) ); @@ -1311,7 +2867,7 @@ export class RecordOpenApiV2Service { targetFieldIds: fieldIds, }); if (commandResult.isErr()) { - this.throwV2Error( + throwV2Error( mapDomainErrorToHttpError(commandResult.error), mapDomainErrorToHttpStatus(commandResult.error) ); @@ -1322,7 +2878,7 @@ export class RecordOpenApiV2Service { commandResult.value ); if (result.isErr()) { - this.throwV2Error( + throwV2Error( mapDomainErrorToHttpError(result.error), mapDomainErrorToHttpStatus(result.error) ); @@ -1736,7 +3292,7 @@ export class RecordOpenApiV2Service { } if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + throwV2Error(result.body.error, result.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); @@ -1771,7 +3327,7 @@ export class RecordOpenApiV2Service { ignoreViewQuery: rangeQuery.ignoreViewQuery, }); if (commandResult.isErr()) { - this.throwV2Error( + throwV2Error( mapDomainErrorToHttpError(commandResult.error), mapDomainErrorToHttpStatus(commandResult.error) ); @@ -1782,7 +3338,7 @@ export class RecordOpenApiV2Service { commandResult.value ); if (result.isErr()) { - this.throwV2Error( + throwV2Error( mapDomainErrorToHttpError(result.error), mapDomainErrorToHttpStatus(result.error) ); @@ -1826,7 +3382,7 @@ export class RecordOpenApiV2Service { targetFieldIds: this.resolveSelectedFieldIds(selectionRo.selection), }); if (commandResult.isErr()) { - this.throwV2Error( + throwV2Error( mapDomainErrorToHttpError(commandResult.error), mapDomainErrorToHttpStatus(commandResult.error) ); @@ -1837,7 +3393,7 @@ export class RecordOpenApiV2Service { commandResult.value ); if (result.isErr()) { - this.throwV2Error( + throwV2Error( mapDomainErrorToHttpError(result.error), mapDomainErrorToHttpStatus(result.error) ); @@ -1968,7 +3524,7 @@ export class RecordOpenApiV2Service { } if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + throwV2Error(result.body.error, result.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); @@ -2004,7 +3560,7 @@ export class RecordOpenApiV2Service { ignoreViewQuery: rangeQuery.ignoreViewQuery, }); if (commandResult.isErr()) { - this.throwV2Error( + throwV2Error( mapDomainErrorToHttpError(commandResult.error), mapDomainErrorToHttpStatus(commandResult.error) ); @@ -2015,7 +3571,7 @@ export class RecordOpenApiV2Service { commandResult.value ); if (result.isErr()) { - this.throwV2Error( + throwV2Error( mapDomainErrorToHttpError(result.error), mapDomainErrorToHttpStatus(result.error) ); @@ -2057,7 +3613,7 @@ export class RecordOpenApiV2Service { excludedTargetRecordIds: this.resolveExcludedRecordIds(selectionRo.selection), }); if (commandResult.isErr()) { - this.throwV2Error( + throwV2Error( mapDomainErrorToHttpError(commandResult.error), mapDomainErrorToHttpStatus(commandResult.error) ); @@ -2068,7 +3624,7 @@ export class RecordOpenApiV2Service { commandResult.value ); if (result.isErr()) { - this.throwV2Error( + throwV2Error( mapDomainErrorToHttpError(result.error), mapDomainErrorToHttpStatus(result.error) ); @@ -2107,7 +3663,7 @@ export class RecordOpenApiV2Service { ignoreViewQuery: rangeQuery.ignoreViewQuery, }); if (commandResult.isErr()) { - this.throwV2Error( + throwV2Error( mapDomainErrorToHttpError(commandResult.error), mapDomainErrorToHttpStatus(commandResult.error) ); @@ -2118,7 +3674,7 @@ export class RecordOpenApiV2Service { DuplicateRecordsStreamResult >(context, commandResult.value); if (result.isErr()) { - this.throwV2Error( + throwV2Error( mapDomainErrorToHttpError(result.error), mapDomainErrorToHttpStatus(result.error) ); @@ -2166,23 +3722,30 @@ export class RecordOpenApiV2Service { async deleteRecordsByIds( tableId: string, recordIds: string[], - _windowId?: string + _windowId?: string, + options?: IDeleteRecordsCommandOptions ): Promise { await this.assertTableRecordWritable(tableId); const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); const context = await this.v2ContextFactory.createContext(container); - await this.executeDeleteRecordsCommand(context, commandBus, tableId, recordIds); + await this.executeDeleteRecordsCommand(context, commandBus, tableId, recordIds, options); } private async executeDeleteRecordsCommand( context: IExecutionContext, commandBus: ICommandBus, tableId: string, - recordIds: string[] + recordIds: string[], + options?: IDeleteRecordsCommandOptions ): Promise { - const result = await executeDeleteRecordsEndpoint(context, { tableId, recordIds }, commandBus); + const result = await executeDeleteRecordsEndpoint( + context, + { tableId, recordIds }, + commandBus, + options + ); if (result.status === 200 && result.body.ok) { await this.clearUndoRedoEnginePreference(tableId); @@ -2190,7 +3753,7 @@ export class RecordOpenApiV2Service { } if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + throwV2Error(result.body.error, result.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); @@ -2318,6 +3881,95 @@ export class RecordOpenApiV2Service { return [searchValue, fieldId, hideNotMatch]; } + /** + * Pure-path filter normalize: field meta from table aggregate (no FieldService). + * Always rewrites field keys to field **ids** so ListTableRecords (fieldKeyType=id) + * can apply filters that clients send as names. + */ + private normalizeFilterForV2FromTable( + table: Table, + filter: unknown + ): RecordFilter | undefined | null { + const fieldMetaMap = this.buildFilterFieldMetaFromTable(table); + const mapped = this.mapV1FilterToV2(filter); + if (!mapped) { + return mapped; + } + const withIds = this.rewriteFilterFieldKeysToIds(table, mapped); + if (!withIds) { + return undefined; + } + return this.normalizeFilterForV2WithFieldMeta(filter, fieldMetaMap, withIds); + } + + /** + * Rewrite filter condition fieldId (and field-reference values) from name/dbName + * to field ids. List query always uses fieldKeyType=id. + */ + private rewriteFilterFieldKeysToIds(table: Table, filter: RecordFilter): RecordFilter | null { + const byId = new Map(table.getFields().map((field) => [field.id().toString(), field])); + const byName = new Map( + table.getFields().map((field) => [field.name().toString(), field.id().toString()]) + ); + const byDbName = new Map(); + for (const field of table.getFields()) { + const dbResult = field.dbFieldName(); + if (dbResult.isOk()) { + const valueResult = dbResult.value.value(); + if (valueResult.isOk() && valueResult.value) { + byDbName.set(valueResult.value, field.id().toString()); + } + } + } + + const resolveKey = (key: string): string | undefined => { + if (byId.has(key)) return key; + return byName.get(key) ?? byDbName.get(key); + }; + + const rewriteNode = (node: RecordFilterNode): RecordFilterNode | null => { + if ('not' in node) { + const next = rewriteNode(node.not); + return next ? { not: next } : null; + } + if ('items' in node) { + const items = node.items + .map((item) => rewriteNode(item)) + .filter((item): item is RecordFilterNode => Boolean(item)); + if (!items.length) return null; + return { conjunction: node.conjunction, items }; + } + const fieldId = resolveKey(node.fieldId); + if (!fieldId) { + return null; + } + let value = node.value; + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + (value as { type?: string }).type === 'field' && + typeof (value as { fieldId?: unknown }).fieldId === 'string' + ) { + const refId = resolveKey((value as { fieldId: string }).fieldId); + if (!refId) { + return null; + } + value = { ...(value as object), fieldId: refId } as typeof value; + } + return { ...node, fieldId, value }; + }; + + if (filter == null) { + return null; + } + return rewriteNode(filter); + } + + /** + * Hybrid/write paths may still resolve meta via FieldService until those paths + * are pure-V2. Prefer {@link normalizeFilterForV2FromTable} on the record-read path. + */ private async normalizeFilterForV2( tableId: string, filter: unknown @@ -2338,6 +3990,73 @@ export class RecordOpenApiV2Service { }, ]) ); + return this.normalizeFilterForV2WithFieldMeta(filter, fieldMetaMap, mapped); + } + + private buildFilterFieldMetaFromTable(table: Table): Map { + const fieldMetaMap = new Map(); + for (const field of table.getFields()) { + const presentationField = this.presentationField(field); + const type = presentationField.type().toString() as FieldType; + const valueTypeResult = field.accept(new FieldValueTypeVisitor()); + const optionsResult = presentationField.accept(new FieldOptionsDtoVisitor()); + const options = + optionsResult.isOk() && optionsResult.value && typeof optionsResult.value === 'object' + ? (optionsResult.value as FilterFieldMeta['options']) + : undefined; + const meta: FilterFieldMeta = { + type, + cellValueType: valueTypeResult.isOk() + ? this.cellValueTypeFromV2ValueType(valueTypeResult.value.cellValueType.toString()) + : this.cellValueTypeFromV2FieldType(type), + options, + }; + fieldMetaMap.set(field.id().toString(), meta); + fieldMetaMap.set(field.name().toString(), meta); + } + return fieldMetaMap; + } + + private cellValueTypeFromV2ValueType(type: string): CellValueType { + switch (type) { + case 'boolean': + return CellValueType.Boolean; + case 'number': + return CellValueType.Number; + case 'dateTime': + return CellValueType.DateTime; + default: + return CellValueType.String; + } + } + + private cellValueTypeFromV2FieldType(type: string): CellValueType { + switch (type) { + case 'checkbox': + return CellValueType.Boolean; + case 'number': + case 'rating': + case 'autoNumber': + return CellValueType.Number; + case 'date': + case 'createdTime': + case 'lastModifiedTime': + return CellValueType.DateTime; + default: + return CellValueType.String; + } + } + + private normalizeFilterForV2WithFieldMeta( + filter: unknown, + fieldMetaMap: Map, + preMapped?: RecordFilter | null + ): RecordFilter | undefined | null { + const mapped = preMapped !== undefined ? preMapped : this.mapV1FilterToV2(filter); + if (!mapped) { + return mapped; + } + const currentUserId = this.cls.get('user.id'); const normalizeNode = (node: RecordFilterNode): RecordFilterNode | null => { @@ -2607,7 +4326,7 @@ export class RecordOpenApiV2Service { if (record.mode !== 'dateRange') return null; if (operator !== 'is' && operator !== 'isWithIn') { - this.throwV2Error( + throwV2Error( { code: invalidFilterCode, message: 'dateRange mode only supports is/isWithIn operators', @@ -2634,7 +4353,7 @@ export class RecordOpenApiV2Service { return null; } if (startTimestamp > endTimestamp) { - this.throwV2Error( + throwV2Error( { code: invalidFilterCode, message: 'dateRange exactDate must be less than or equal to exactDateEnd', @@ -2763,7 +4482,7 @@ export class RecordOpenApiV2Service { } if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + throwV2Error(result.body.error, result.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts index 5d773e1b7c..f12bbea42d 100644 --- a/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts @@ -63,6 +63,7 @@ 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 { SpaceDataDbMigrationGuardService } from '../../space/space-data-db-migration-guard.service'; import { RecordService } from '../record.service'; import { ShareViewScopeService } from '../share-view-scope.service'; import { FieldKeyPipe } from './field-key.pipe'; @@ -85,7 +86,8 @@ export class RecordOpenApiController { // protected (not private) so the EE override controller can call // assertXxx from its own write methods — subclass methods bypass the // community implementations, so scope enforcement must be reachable. - protected readonly shareViewScopeService: ShareViewScopeService + protected readonly shareViewScopeService: ShareViewScopeService, + protected readonly spaceDataDbMigrationGuardService: SpaceDataDbMigrationGuardService ) {} @Permissions('record|update') @@ -123,6 +125,8 @@ export class RecordOpenApiController { @Param('tableId') tableId: string, @Query(new ZodValidationPipe(getRecordsRoSchema), TqlPipe, FieldKeyPipe) query: IGetRecordsRo ): Promise { + await this.spaceDataDbMigrationGuardService.assertTableRecordSearchReadable(tableId, query); + if (this.cls.get('useV2')) { return this.recordOpenApiV2Service.getRecords(tableId, query); } @@ -130,6 +134,7 @@ export class RecordOpenApiController { return await this.recordService.getRecords(tableId, query, true); } + @UseV2Feature('getRecords') @Permissions('record|read') @Get(':recordId') async getRecord( @@ -137,6 +142,9 @@ export class RecordOpenApiController { @Param('recordId') recordId: string, @Query(new ZodValidationPipe(getRecordQuerySchema)) query: IGetRecordQuery ): Promise { + if (this.cls.get('useV2')) { + return this.recordOpenApiV2Service.getRecord(tableId, recordId, query); + } return await this.recordService.getRecord(tableId, recordId, query, true, true); } @@ -363,13 +371,18 @@ export class RecordOpenApiController { return await this.recordOpenApiService.deleteRecords(tableId, query.recordIds, windowId); } + @UseV2Feature('getRecords') @Permissions('record|read') - @Get('/socket/snapshot-bulk') + @Post('/socket/snapshot-bulk') async getSnapshotBulk( @Param('tableId') tableId: string, - @Query('ids') ids: string[], - @Query('projection') projection?: { [fieldNameOrId: string]: boolean } + @Body('ids') ids: string[], + @Body('projection') projection?: { [fieldNameOrId: string]: boolean } ) { + if (this.cls.get('useV2')) { + return this.recordOpenApiV2Service.getSocketSnapshotBulk(tableId, ids, projection); + } + return this.recordService.getSnapshotBulkWithPermission( tableId, ids, @@ -380,16 +393,29 @@ export class RecordOpenApiController { ); } + @UseV2Feature('getRecords') @Permissions('record|read') @Post('/socket/doc-ids') async getDocIds( @Param('tableId') tableId: string, @Body(new ZodValidationPipe(getRecordsRoSchema), TqlPipe) query: IGetRecordsRo ) { + await this.spaceDataDbMigrationGuardService.assertTableRecordSearchReadable(tableId, query); + + if (this.cls.get('useV2')) { + return this.getDocIdsWithCache(tableId, query, () => + this.recordOpenApiV2Service.getSocketDocIds(tableId, query) + ); + } + return this.getDocIdsWithCache(tableId, query); } - private async getDocIdsWithCache(tableId: string, query: IGetRecordsRo) { + private async getDocIdsWithCache( + tableId: string, + query: IGetRecordsRo, + load?: () => ReturnType + ) { const table = await this.prismaService.tableMeta.findUniqueOrThrow({ where: { id: tableId, @@ -424,9 +450,7 @@ export class RecordOpenApiController { ); return this.performanceCacheService.wrap( cacheKey, - () => { - return this.recordService.getDocIdsByQuery(tableId, cacheQuery, true); - }, + load ?? (() => this.recordService.getDocIdsByQuery(tableId, cacheQuery, true)), { ttl: 60 * 60, // 1 hour } @@ -463,6 +487,7 @@ export class RecordOpenApiController { } @Permissions('record|read') + @UseV2Feature('buttonClick') @Post(':recordId/:fieldId/button-click') async buttonClick( @Req() req: Express.Request, @@ -470,11 +495,15 @@ export class RecordOpenApiController { @Param('recordId') recordId: string, @Param('fieldId') fieldId: string ): Promise { + if (this.cls.get('useV2')) { + return this.recordOpenApiV2Service.buttonClick(tableId, recordId, fieldId); + } const result = await this.recordOpenApiService.buttonClick(tableId, recordId, fieldId); return { ...result, runId: '' }; } @Permissions('record|update') + @UseV2Feature('buttonReset') @Post(':recordId/:fieldId/button-reset') async buttonReset( @Param('tableId') tableId: string, @@ -490,6 +519,9 @@ export class RecordOpenApiController { }, }); + if (this.cls.get('useV2')) { + return this.recordOpenApiV2Service.buttonReset(tableId, recordId, fieldId); + } return await this.recordOpenApiService.resetButton(tableId, recordId, fieldId); } } diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api.module.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api.module.ts index e79e9faf66..0f56459a9e 100644 --- a/apps/nestjs-backend/src/features/record/open-api/record-open-api.module.ts +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api.module.ts @@ -21,6 +21,7 @@ import { RecordModule } from '../record.module'; import { RecordOpenApiV2Service } from './record-open-api-v2.service'; import { RecordOpenApiController } from './record-open-api.controller'; import { RecordOpenApiService } from './record-open-api.service'; +import { RecordRestoreService } from './record-restore.service'; @Module({ imports: [ @@ -44,7 +45,12 @@ import { RecordOpenApiService } from './record-open-api.service'; forwardRef(() => SelectionModule), ], controllers: [RecordOpenApiController], - providers: [RecordOpenApiService, RecordOpenApiV2Service, TableQuerySearchVectorRuntimeService], - exports: [RecordOpenApiService, RecordOpenApiV2Service], + providers: [ + RecordOpenApiService, + RecordOpenApiV2Service, + RecordRestoreService, + TableQuerySearchVectorRuntimeService, + ], + exports: [RecordOpenApiService, RecordOpenApiV2Service, RecordRestoreService], }) export class RecordOpenApiModule {} diff --git a/apps/nestjs-backend/src/features/record/open-api/record-restore.service.spec.ts b/apps/nestjs-backend/src/features/record/open-api/record-restore.service.spec.ts new file mode 100644 index 0000000000..410fdf90e6 --- /dev/null +++ b/apps/nestjs-backend/src/features/record/open-api/record-restore.service.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { collectLinkTargetIds, filterLiveLinkEntries } from './record-restore.service'; + +describe('collectLinkTargetIds', () => { + it('collects ids from single and multi link cell values', () => { + expect(collectLinkTargetIds({ id: 'recA', title: 'A' })).toEqual(['recA']); + expect( + collectLinkTargetIds([ + { id: 'recA', title: 'A' }, + { id: 'recB', title: 'B' }, + ]) + ).toEqual(['recA', 'recB']); + }); + + it('contributes nothing for null and unrecognized shapes', () => { + expect(collectLinkTargetIds(null)).toEqual([]); + expect(collectLinkTargetIds('recA')).toEqual([]); + expect(collectLinkTargetIds([{ title: 'no id' }, 42])).toEqual([]); + }); +}); + +describe('filterLiveLinkEntries', () => { + const live = (ids: string[]) => (id: string) => ids.includes(id); + + it('returns the same reference when every entry is live', () => { + const single = { id: 'recA', title: 'A' }; + expect(filterLiveLinkEntries(single, live(['recA']))).toBe(single); + + const multi = [{ id: 'recA' }, { id: 'recB' }]; + expect(filterLiveLinkEntries(multi, live(['recA', 'recB']))).toBe(multi); + }); + + it('nulls a dead single value and filters dead entries from a multi value', () => { + expect(filterLiveLinkEntries({ id: 'recDead' }, live([]))).toBeNull(); + + expect(filterLiveLinkEntries([{ id: 'recA' }, { id: 'recDead' }], live(['recA']))).toEqual([ + { id: 'recA' }, + ]); + }); + + it('collapses a fully-dead multi value to null instead of an empty array', () => { + expect(filterLiveLinkEntries([{ id: 'recDead' }], live([]))).toBeNull(); + }); + + it('leaves unrecognized shapes untouched', () => { + expect(filterLiveLinkEntries('recA', live([]))).toBe('recA'); + const mixed = [{ title: 'no id' }, { id: 'recA' }]; + expect(filterLiveLinkEntries(mixed, live(['recA']))).toBe(mixed); + }); +}); diff --git a/apps/nestjs-backend/src/features/record/open-api/record-restore.service.ts b/apps/nestjs-backend/src/features/record/open-api/record-restore.service.ts new file mode 100644 index 0000000000..2c62aebe13 --- /dev/null +++ b/apps/nestjs-backend/src/features/record/open-api/record-restore.service.ts @@ -0,0 +1,257 @@ +import { Injectable } from '@nestjs/common'; +import type { IRecord } from '@teable/core'; +import { FieldKeyType, FieldType, HttpErrorCode } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import { RestoreRecordsCommand, v2CoreTokens } from '@teable/v2-core'; +import type { ICommandBus, RestoreRecordInput, RestoreRecordsResult } from '@teable/v2-core'; +import { CustomHttpException } from '../../../custom.exception'; +import { CanaryService } from '../../canary/canary.service'; +import { V2ContainerService } from '../../v2/v2-container.service'; +import { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory'; +import { RecordService } from '../record.service'; +import { RecordOpenApiService } from './record-open-api.service'; + +export type IRestorableRecordSnapshot = IRecord & { + version?: number; + order?: Record; +}; + +const isLinkEntry = (value: unknown): value is { id: string } => + typeof value === 'object' && value !== null && typeof (value as { id?: unknown }).id === 'string'; + +// exported for tests: link target ids of a snapshot link cell value; unrecognized +// shapes contribute nothing (they were tolerated before and stay untouched) +export const collectLinkTargetIds = (cellValue: unknown): string[] => { + if (Array.isArray(cellValue)) { + return cellValue.filter(isLinkEntry).map((entry) => entry.id); + } + return isLinkEntry(cellValue) ? [cellValue.id] : []; +}; + +// exported for tests: drops link entries whose target is not live; returns the SAME +// reference when nothing changes so callers can cheaply detect mutation. An emptied +// multi-value collapses to null, matching how the write pipeline stores "no links". +export const filterLiveLinkEntries = ( + cellValue: unknown, + isLive: (id: string) => boolean +): unknown => { + if (Array.isArray(cellValue)) { + const kept = cellValue.filter((entry) => !isLinkEntry(entry) || isLive(entry.id)); + if (kept.length === cellValue.length) { + return cellValue; + } + return kept.length ? kept : null; + } + if (isLinkEntry(cellValue)) { + return isLive(cellValue.id) ? cellValue : null; + } + return cellValue; +}; + +const parseLinkFieldOptions = (options: string | null): { foreignTableId?: string } => { + if (!options) { + return {}; + } + try { + return JSON.parse(options) as { foreignTableId?: string }; + } catch { + return {}; + } +}; + +// Rebuilds records from persisted snapshot rows (table trash, archive) through whichever +// engine the base's canary decision selects, so the routing and the snapshot→command +// mapping live in one place. +@Injectable() +export class RecordRestoreService { + constructor( + private readonly prismaService: PrismaService, + private readonly canaryService: CanaryService, + private readonly recordOpenApiService: RecordOpenApiService, + private readonly recordService: RecordService, + private readonly v2ContainerService: V2ContainerService, + private readonly v2ExecutionContextFactory: V2ExecutionContextFactory + ) {} + + async restoreRecordSnapshots( + tableId: string, + records: IRestorableRecordSnapshot[] + ): Promise { + records = await this.stripDanglingLinks(tableId, records); + + if (await this.shouldRestoreRecordsWithV2(tableId)) { + await this.restoreRecordsV2(tableId, records); + return; + } + + await this.recordOpenApiService.multipleCreateRecords( + tableId, + { + fieldKeyType: FieldKeyType.Id, + records, + typecast: true, + }, + true + ); + } + + // A snapshot can reference records deleted after it was taken; replaying such a + // link fails the v1 write path's consistency check and leaves v2 with orphan + // junction rows. Restore-succeeds-first: drop dead entries up front. Records + // restored in this same call count as live, so batch-restoring both sides of a + // link keeps it intact. + private async stripDanglingLinks( + tableId: string, + records: IRestorableRecordSnapshot[] + ): Promise { + const linkFieldRaws = await this.prismaService.txClient().field.findMany({ + where: { tableId, type: FieldType.Link, isLookup: null, deletedTime: null }, + select: { id: true, options: true }, + }); + const linkFields = linkFieldRaws.flatMap((raw) => { + const { foreignTableId } = parseLinkFieldOptions(raw.options); + return foreignTableId ? [{ id: raw.id, foreignTableId }] : []; + }); + if (linkFields.length === 0) { + return records; + } + + const targetIdsByTable = new Map>(); + for (const field of linkFields) { + for (const record of records) { + const targetIds = collectLinkTargetIds(record.fields?.[field.id]); + if (targetIds.length === 0) { + continue; + } + const set = targetIdsByTable.get(field.foreignTableId) ?? new Set(); + targetIds.forEach((id) => set.add(id)); + targetIdsByTable.set(field.foreignTableId, set); + } + } + if (targetIdsByTable.size === 0) { + return records; + } + + // a deleted foreign table means every link into it is dead — skip the record + // probe instead of erroring inside it + const liveForeignTables = new Set( + ( + await this.prismaService.txClient().tableMeta.findMany({ + where: { id: { in: [...targetIdsByTable.keys()] }, deletedTime: null }, + select: { id: true }, + }) + ).map((table) => table.id) + ); + + const batchIds = new Set(records.map((record) => record.id)); + const liveIdsByTable = new Map>(); + const PROBE_CHUNK_SIZE = 5000; + for (const [foreignTableId, targetIds] of targetIdsByTable) { + const live = new Set(); + if (liveForeignTables.has(foreignTableId)) { + const ids = [...targetIds]; + for (let i = 0; i < ids.length; i += PROBE_CHUNK_SIZE) { + const rows = await this.recordService.getRecordsHeadWithIds( + foreignTableId, + ids.slice(i, i + PROBE_CHUNK_SIZE) + ); + rows.forEach((row) => live.add(row.id)); + } + } + if (foreignTableId === tableId) { + batchIds.forEach((id) => live.add(id)); + } + liveIdsByTable.set(foreignTableId, live); + } + + return records.map((record) => { + let changed = false; + const fields = { ...record.fields }; + for (const field of linkFields) { + const value = fields[field.id]; + if (value == null) { + continue; + } + const live = liveIdsByTable.get(field.foreignTableId); + if (!live) { + continue; + } + const next = filterLiveLinkEntries(value, (id) => live.has(id)); + if (next !== value) { + fields[field.id] = next as IRecord['fields'][string]; + changed = true; + } + } + return changed ? { ...record, fields } : record; + }); + } + + toV2RestoreRecord(record: IRestorableRecordSnapshot): RestoreRecordInput { + return { + recordId: record.id, + fields: record.fields ?? {}, + ...(record.version !== undefined ? { version: record.version } : {}), + ...(record.order ? { orders: record.order } : {}), + ...(record.autoNumber !== undefined ? { autoNumber: record.autoNumber } : {}), + ...(record.createdTime ? { createdTime: record.createdTime } : {}), + ...(record.createdBy ? { createdBy: record.createdBy } : {}), + ...(record.lastModifiedTime ? { lastModifiedTime: record.lastModifiedTime } : {}), + ...(record.lastModifiedBy ? { lastModifiedBy: record.lastModifiedBy } : {}), + }; + } + + private async shouldRestoreRecordsWithV2(tableId: string): Promise { + const table = await this.prismaService.txClient().tableMeta.findFirst({ + where: { id: tableId, deletedTime: null }, + select: { + base: { + select: { + spaceId: true, + v2Enabled: true, + }, + }, + }, + }); + + if (!table?.base?.spaceId) { + return false; + } + + const decision = await this.canaryService.shouldUseV2ForBaseWithReason( + table.base, + 'createRecord' + ); + return decision.useV2; + } + + private async restoreRecordsV2( + tableId: string, + records: IRestorableRecordSnapshot[] + ): Promise { + if (records.length === 0) { + return; + } + + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ExecutionContextFactory.createContext(container); + + const commandResult = RestoreRecordsCommand.create({ + tableId, + records: records.map((record) => this.toV2RestoreRecord(record)), + }); + + if (commandResult.isErr()) { + throw new CustomHttpException(commandResult.error.message, HttpErrorCode.VALIDATION_ERROR); + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + + if (result.isErr()) { + throw new CustomHttpException(result.error.message, HttpErrorCode.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/apps/nestjs-backend/src/features/record/query-builder/record-query-builder.interface.ts b/apps/nestjs-backend/src/features/record/query-builder/record-query-builder.interface.ts index 7105fba7cf..2366921222 100644 --- a/apps/nestjs-backend/src/features/record/query-builder/record-query-builder.interface.ts +++ b/apps/nestjs-backend/src/features/record/query-builder/record-query-builder.interface.ts @@ -170,6 +170,17 @@ export interface IRecordQueryFilterContext { selectionMap: IReadonlyRecordSelectionMap; fieldReferenceSelectionMap?: Map; fieldReferenceFieldMap?: Map; + /** + * How to compile a filter item whose field-reference comparison the SQL layer + * does not support (e.g. 'contains' against another field): + * - 'throw' (default): reject the whole query — correct for user-issued + * queries, which must not silently change meaning; + * - 'match-all': compile the item as TRUE and log a warning — for machinery + * deriving AFFECTED sets (computed dependency collection), where the + * conservative direction is to include more rows, and where throwing would + * otherwise fail unrelated record WRITES on the host table. + */ + unsupportedFieldReferenceBehavior?: 'throw' | 'match-all'; } export interface IRecordQuerySortContext { diff --git a/apps/nestjs-backend/src/features/record/record-modify/record-delete.service.ts b/apps/nestjs-backend/src/features/record/record-modify/record-delete.service.ts index 613817458d..107b960831 100644 --- a/apps/nestjs-backend/src/features/record/record-modify/record-delete.service.ts +++ b/apps/nestjs-backend/src/features/record/record-modify/record-delete.service.ts @@ -82,6 +82,7 @@ export class RecordDeleteService { ...record, order: orders?.[index], })), + removalReason: this.cls.get('recordRemovalReason'), }); return recordsForEvent; diff --git a/apps/nestjs-backend/src/features/record/record.service.spec.ts b/apps/nestjs-backend/src/features/record/record.service.spec.ts index dd28a910db..2d48563f2a 100644 --- a/apps/nestjs-backend/src/features/record/record.service.spec.ts +++ b/apps/nestjs-backend/src/features/record/record.service.spec.ts @@ -288,7 +288,7 @@ describe('RecordService', () => { await dataKnex.destroy(); }); - it('writes SQL-only created record history into the routed data DB internal schema', async () => { + it('does not write record history for SQL-only imported records', async () => { const dataKnex = Knex({ client: 'pg' }); const executedSql: string[] = []; const service = Object.create(RecordService.prototype) as { @@ -344,10 +344,8 @@ describe('RecordService', () => { ); expect(executedSql[0]).toContain('"bse_data"."tbl_imported"'); - expect(executedSql.some((sql) => sql.includes('"teable_internal"."record_history"'))).toBe( - true - ); - expect(executedSql.some((sql) => sql.includes('insert into "record_history"'))).toBe(false); + expect(executedSql).toHaveLength(1); + expect(executedSql.some((sql) => sql.includes('record_history'))).toBe(false); await dataKnex.destroy(); }); diff --git a/apps/nestjs-backend/src/features/record/record.service.ts b/apps/nestjs-backend/src/features/record/record.service.ts index 824664a25a..4be69ed3d0 100644 --- a/apps/nestjs-backend/src/features/record/record.service.ts +++ b/apps/nestjs-backend/src/features/record/record.service.ts @@ -29,7 +29,6 @@ import { extractFieldIdsFromFilter, FieldKeyType, FieldType, - generateRecordHistoryId, generateRecordId, HttpErrorCode, identify, @@ -1437,15 +1436,9 @@ export class RecordService { {} as Record ); - const recordHistoryList: { - id: string; - table_id: string; - record_id: string; - field_id: string; - before: string; - after: string; - created_by: string; - }[] = []; + // Imported records intentionally write no record history: creation is already + // attributed by __created_by/__created_time, and per-cell null→value entries + // would add rows × non-empty-cells of history on large imports. const newRecords = records.map((record) => { const createdTime = writableCreatedTimeFieldNames.size > 0 ? new Date().toISOString() : undefined; @@ -1454,17 +1447,6 @@ export class RecordService { Object.entries(record.fields).forEach(([fieldId, value]) => { const fieldInstance = fieldInstanceMap[fieldId]; fieldsValues[fieldInstance.dbFieldName] = fieldInstance.convertCellValue2DBValue(value); - if (value !== '' && value != null) { - recordHistoryList.push({ - id: generateRecordHistoryId(), - table_id: table.id, - record_id: recordId, - field_id: fieldInstance.id, - before: JSON.stringify({ data: null }), - after: JSON.stringify({ data: value }), - created_by: userId, - }); - } }); if (auditUserValue && createdByFields.length) { createdByFields.forEach((field) => { @@ -1488,17 +1470,6 @@ export class RecordService { }); const sql = this.dbProvider.batchInsertSql(dbTableName, newRecords); await this.databaseRouter.executeDataPrismaForTable(table.id, sql); - if (recordHistoryList.length) { - const dataKnex = await this.databaseRouter.dataKnexForTable(table.id); - const dataDbUrl = await this.databaseRouter.getDataDatabaseUrlForTable(table.id); - const dataDbInternalSchema = new URL(dataDbUrl).searchParams.get('schema') || 'public'; - const historySql = dataKnex - .withSchema(dataDbInternalSchema) - .insert(recordHistoryList) - .into('record_history') - .toQuery(); - await this.databaseRouter.executeDataPrismaForTable(table.id, historySql); - } } async creditCheck(tableId: string) { diff --git a/apps/nestjs-backend/src/features/share/guard/auth.guard.ts b/apps/nestjs-backend/src/features/share/guard/auth.guard.ts index 2f36cf787a..8e388920f9 100644 --- a/apps/nestjs-backend/src/features/share/guard/auth.guard.ts +++ b/apps/nestjs-backend/src/features/share/guard/auth.guard.ts @@ -48,13 +48,14 @@ export class ShareAuthGuard extends PassportAuthGuard([SHARE_JWT_STRATEGY]) { shareId, templateHeader, shareViewHeader, - req.headers.cookie + req.headers.cookie, + req.useV2 === true ); req.shareInfo = shareInfo; return activate; } - const shareInfo = await this.shareAuthService.getShareViewInfo(shareId); + const shareInfo = await this.shareAuthService.getShareViewInfo(shareId, req.useV2 === true); try { req.shareInfo = shareInfo; diff --git a/apps/nestjs-backend/src/features/share/guard/share-auth-local.guard.ts b/apps/nestjs-backend/src/features/share/guard/share-auth-local.guard.ts index dfcb773cfc..52252cc6c5 100644 --- a/apps/nestjs-backend/src/features/share/guard/share-auth-local.guard.ts +++ b/apps/nestjs-backend/src/features/share/guard/share-auth-local.guard.ts @@ -12,7 +12,11 @@ export class ShareAuthLocalGuard implements CanActivate { const req = context.switchToHttp().getRequest(); const shareId = req.params.shareId; const password = req.body.password; - const authShareId = await this.shareAuthService.authShareView(shareId, password); + const authShareId = await this.shareAuthService.authShareView( + shareId, + password, + req.useV2 === true + ); req.shareId = authShareId; req.password = password; if (!authShareId) { diff --git a/apps/nestjs-backend/src/features/share/share-auth.module.ts b/apps/nestjs-backend/src/features/share/share-auth.module.ts index 09b0c44e72..831fdca18d 100644 --- a/apps/nestjs-backend/src/features/share/share-auth.module.ts +++ b/apps/nestjs-backend/src/features/share/share-auth.module.ts @@ -1,28 +1,32 @@ import { Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; -import { authConfig, type IAuthConfig } from '../../configs/auth.config'; import { DbProvider } from '../../db-provider/db.provider'; import { AuthModule } from '../auth/auth.module'; +import { V2Module } from '../v2/v2.module'; +import { ViewOpenApiV2Service } from '../view/open-api/view-open-api-v2.service'; import { ShareAuthGuard } from './guard/auth.guard'; import { ShareAuthService } from './share-auth.service'; +import { SharedViewAccessV2Service } from './shared-view-access-v2.service'; import { JwtStrategy } from './strategies/jwt.strategy'; @Module({ imports: [ AuthModule, + V2Module, + // ViewOpenApiV2Service is provided directly (below) instead of importing + // ViewOpenApiModule: this module sits early in the auth wiring, and pulling + // a controller-bearing module in here would register community controllers + // ahead of the EE override controllers, breaking route shadowing. PassportModule, - JwtModule.registerAsync({ - useFactory: (config: IAuthConfig) => ({ - secret: config.jwt.secret, - signOptions: { - expiresIn: config.jwt.expiresIn, - }, - }), - inject: [authConfig.KEY], - }), ], - providers: [JwtStrategy, ShareAuthService, DbProvider, ShareAuthGuard], + providers: [ + JwtStrategy, + ShareAuthService, + ViewOpenApiV2Service, + SharedViewAccessV2Service, + DbProvider, + ShareAuthGuard, + ], exports: [ShareAuthService, ShareAuthGuard], }) export class ShareAuthModule {} diff --git a/apps/nestjs-backend/src/features/share/share-auth.service.spec.ts b/apps/nestjs-backend/src/features/share/share-auth.service.spec.ts new file mode 100644 index 0000000000..ebbf9ecfd4 --- /dev/null +++ b/apps/nestjs-backend/src/features/share/share-auth.service.spec.ts @@ -0,0 +1,115 @@ +import { HttpErrorCode } from '@teable/core'; +import { describe, expect, it, vi } from 'vitest'; +import { ShareAuthService } from './share-auth.service'; + +const createFixture = (shareInfo?: { + shareId: string; + tableId: string; + shareMeta?: { password?: string }; +}) => { + const prismaService = { + view: { findFirst: vi.fn().mockResolvedValue(undefined) }, + }; + const sharedViewAccessV2Service = { + findByShareId: vi.fn().mockResolvedValue(shareInfo), + }; + const service = new ShareAuthService( + {} as never, + prismaService as never, + {} as never, + {} as never, + sharedViewAccessV2Service as never + ); + return { service, prismaService, sharedViewAccessV2Service }; +}; + +describe('ShareAuthService v2 View access', () => { + it('returns aggregate-backed share information without querying Prisma View', async () => { + const shareInfo = { + shareId: 'shrShared', + tableId: 'tblShared', + shareMeta: { password: 'secret' }, + }; + const fixture = createFixture(shareInfo); + + await expect(fixture.service.getShareViewInfo('shrShared', true)).resolves.toBe(shareInfo); + expect(fixture.sharedViewAccessV2Service.findByShareId).toHaveBeenCalledWith('shrShared'); + expect(fixture.prismaService.view.findFirst).not.toHaveBeenCalled(); + }); + + it('preserves missing-share behavior for metadata and password authentication', async () => { + const fixture = createFixture(); + + await expect(fixture.service.getShareViewInfo('shrMissing', true)).rejects.toMatchObject({ + code: HttpErrorCode.VALIDATION_ERROR, + }); + await expect(fixture.service.authShareView('shrMissing', 'secret', true)).resolves.toBeNull(); + expect(fixture.prismaService.view.findFirst).not.toHaveBeenCalled(); + }); + + it('accepts only the aggregate-backed password', async () => { + const fixture = createFixture({ + shareId: 'shrShared', + tableId: 'tblShared', + shareMeta: { password: 'secret' }, + }); + + await expect(fixture.service.authShareView('shrShared', 'secret', true)).resolves.toBe( + 'shrShared' + ); + await expect(fixture.service.authShareView('shrShared', 'wrong', true)).resolves.toBeNull(); + }); + + it('preserves the password-not-enabled validation branch', async () => { + const fixture = createFixture({ + shareId: 'shrShared', + tableId: 'tblShared', + }); + + await expect(fixture.service.authShareView('shrShared', 'secret', true)).rejects.toMatchObject({ + code: HttpErrorCode.VALIDATION_ERROR, + }); + }); + + it('uses the legacy Prisma lookup when v2 is not selected', async () => { + const fixture = createFixture({ + shareId: 'shrV2MustNotRun', + tableId: 'tblV2MustNotRun', + shareMeta: { password: 'wrong-source' }, + }); + fixture.prismaService.view.findFirst.mockResolvedValue({ + id: 'viwLegacy', + tableId: 'tblLegacy', + name: 'Legacy shared View', + type: 'grid', + description: null, + options: 'null', + filter: 'null', + sort: 'null', + group: 'null', + shareId: 'shrLegacy', + shareMeta: JSON.stringify({ password: 'legacy-secret' }), + enableShare: true, + createdBy: 'usrLegacy', + lastModifiedBy: null, + createdTime: new Date('2026-01-01T00:00:00.000Z'), + lastModifiedTime: null, + columnMeta: '{}', + isLocked: null, + }); + + await expect(fixture.service.getShareViewInfo('shrLegacy')).resolves.toMatchObject({ + shareId: 'shrLegacy', + tableId: 'tblLegacy', + shareMeta: { password: 'legacy-secret' }, + }); + await expect(fixture.service.authShareView('shrLegacy', 'legacy-secret')).resolves.toBe( + 'shrLegacy' + ); + await expect(fixture.service.authShareView('shrLegacy', 'wrong')).resolves.toBeNull(); + expect(fixture.prismaService.view.findFirst).toHaveBeenCalledWith({ + where: { shareId: 'shrLegacy', enableShare: true, deletedTime: null }, + }); + expect(fixture.sharedViewAccessV2Service.findByShareId).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/nestjs-backend/src/features/share/share-auth.service.ts b/apps/nestjs-backend/src/features/share/share-auth.service.ts index d5c65a90a2..9eb00ed4ef 100644 --- a/apps/nestjs-backend/src/features/share/share-auth.service.ts +++ b/apps/nestjs-backend/src/features/share/share-auth.service.ts @@ -1,5 +1,4 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; import { FieldType, HttpErrorCode } from '@teable/core'; import type { IViewVo, IShareViewMeta, ILinkFieldOptions } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; @@ -8,9 +7,11 @@ import { ClsService } from 'nestjs-cls'; import { CustomHttpException } from '../../custom.exception'; import type { IClsStore } from '../../types/cls'; import { isNotHiddenField } from '../../utils/is-not-hidden-field'; +import { TeableJwtService } from '../auth/jwt/teable-jwt.service'; import { PermissionService } from '../auth/permission.service'; import { createFieldInstanceByRaw } from '../field/model/factory'; import { createViewVoByRaw } from '../view/model/factory'; +import { SharedViewAccessV2Service } from './shared-view-access-v2.service'; export interface IShareViewInfo { shareId: string; @@ -30,8 +31,9 @@ export class ShareAuthService { constructor( private readonly permissionService: PermissionService, private readonly prismaService: PrismaService, - private readonly jwtService: JwtService, - private readonly cls: ClsService + private readonly jwtService: TeableJwtService, + private readonly cls: ClsService, + private readonly sharedViewAccessV2Service: SharedViewAccessV2Service ) {} async validateJwtToken(token: string) { @@ -42,16 +44,12 @@ export class ShareAuthService { } } - async authShareView(shareId: string, pass: string): Promise { - const view = await this.prismaService.view.findFirst({ - where: { shareId, enableShare: true, deletedTime: null }, - select: { shareId: true, shareMeta: true }, - }); - if (!view) { + async authShareView(shareId: string, pass: string, useV2 = false): Promise { + const shareInfo = await this.findShareViewInfo(shareId, useV2); + if (!shareInfo) { return null; } - const shareMeta = view.shareMeta ? (JSON.parse(view.shareMeta) as IShareViewMeta) : undefined; - const password = shareMeta?.password; + const password = shareInfo.shareMeta?.password; if (!password) { throw new CustomHttpException( 'Password restriction is not enabled', @@ -70,31 +68,24 @@ export class ShareAuthService { return await this.jwtService.signAsync(jwtShareInfo); } - async getShareViewInfo(shareId: string): Promise { - const view = await this.prismaService.view.findFirst({ - where: { shareId, enableShare: true, deletedTime: null }, - }); - if (!view) { + async getShareViewInfo(shareId: string, useV2 = false): Promise { + const shareInfo = await this.findShareViewInfo(shareId, useV2); + if (!shareInfo) { throw new CustomHttpException('Share view not found', HttpErrorCode.VALIDATION_ERROR, { localization: { i18nKey: 'httpErrors.shareAuth.shareViewNotFound', }, }); } - const viewVo = createViewVoByRaw(view); - return { - shareId, - tableId: view.tableId, - view: createViewVoByRaw(view), - shareMeta: viewVo.shareMeta, - }; + return shareInfo; } async getLinkViewInfo( linkFieldId: string, templateHeader?: string, shareViewHeader?: string, - cookieHeader?: string + cookieHeader?: string, + useV2 = false ): Promise { const fieldRaw = await this.prismaService.field .findFirstOrThrow({ @@ -159,7 +150,8 @@ export class ShareAuthService { fieldRaw.tableId, fieldRaw.id, shareViewHeader, - cookieHeader + cookieHeader, + useV2 ); if (!hasShareViewContext) { // Not a share context — fall back to checking the user's own role. @@ -188,7 +180,8 @@ export class ShareAuthService { tableId: string, fieldId: string, shareViewHeader?: string, - cookieHeader?: string + cookieHeader?: string, + useV2 = false ) { if (!shareViewHeader) { return false; @@ -199,14 +192,12 @@ export class ShareAuthService { return false; } - const viewRaw = await this.prismaService.view.findFirst({ - where: { shareId, enableShare: true, deletedTime: null }, - }); - if (!viewRaw || viewRaw.tableId !== tableId) { + const shareInfo = await this.findShareViewInfo(shareId, useV2); + if (!shareInfo || shareInfo.tableId !== tableId || !shareInfo.view) { return false; } - const view = createViewVoByRaw(viewRaw); + const view = shareInfo.view; if (view.shareMeta?.password) { const token = cookie.parse(cookieHeader ?? '')[shareId]; const valid = token @@ -231,4 +222,28 @@ export class ShareAuthService { return true; } + + private async findShareViewInfo( + shareId: string, + useV2: boolean + ): Promise { + if (useV2) { + return (await this.sharedViewAccessV2Service.findByShareId(shareId)) ?? undefined; + } + + const view = await this.prismaService.view.findFirst({ + where: { shareId, enableShare: true, deletedTime: null }, + }); + if (!view) { + return undefined; + } + + const viewVo = createViewVoByRaw(view); + return { + shareId, + tableId: view.tableId, + view: viewVo, + shareMeta: viewVo.shareMeta, + }; + } } diff --git a/apps/nestjs-backend/src/features/share/share-socket.service.spec.ts b/apps/nestjs-backend/src/features/share/share-socket.service.spec.ts index 1a1d08ed55..8fe20a8ef6 100644 --- a/apps/nestjs-backend/src/features/share/share-socket.service.spec.ts +++ b/apps/nestjs-backend/src/features/share/share-socket.service.spec.ts @@ -1,12 +1,100 @@ import { HttpErrorCode } from '@teable/core'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { ShareSocketService } from './share-socket.service'; -const createService = () => new ShareSocketService({} as never, {} as never, {} as never); +const createService = (useV2 = false) => { + const viewService = { + getDocIdsByQuery: vi.fn(), + getSnapshotBulk: vi.fn(), + }; + const viewOpenApiV2Service = { + getView: vi.fn(), + getSnapshotBulk: vi.fn(), + }; + const service = new ShareSocketService( + viewService as never, + viewOpenApiV2Service as never, + {} as never, + {} as never, + { get: vi.fn().mockReturnValue(useV2) } as never + ); + return { service, viewService, viewOpenApiV2Service }; +}; + +const shareInfo = { + shareId: 'shrTest', + tableId: 'tblShared', + view: { id: 'viwShared' }, +} as never; + +describe('ShareSocketService View reads', () => { + it('loads the shared View through the v2 Table aggregate without using ViewService', async () => { + const { service, viewService, viewOpenApiV2Service } = createService(true); + viewOpenApiV2Service.getView.mockResolvedValue({ id: 'viwShared' }); + viewOpenApiV2Service.getSnapshotBulk.mockResolvedValue([{ id: 'viwShared' }]); + + await expect(service.getViewDocIdsByQuery(shareInfo)).resolves.toEqual({ + ids: ['viwShared'], + }); + await expect(service.getViewSnapshotBulk(shareInfo, ['viwShared'])).resolves.toEqual([ + { id: 'viwShared' }, + ]); + + expect(viewOpenApiV2Service.getView).toHaveBeenCalledWith('tblShared', 'viwShared'); + expect(viewOpenApiV2Service.getSnapshotBulk).toHaveBeenCalledWith('tblShared', ['viwShared']); + expect(viewService.getDocIdsByQuery).not.toHaveBeenCalled(); + expect(viewService.getSnapshotBulk).not.toHaveBeenCalled(); + }); + + it('keeps the legacy path only when the v2 feature is disabled', async () => { + const { service, viewService, viewOpenApiV2Service } = createService(false); + viewService.getDocIdsByQuery.mockResolvedValue({ ids: ['viwShared'] }); + viewService.getSnapshotBulk.mockResolvedValue([{ id: 'viwShared' }]); + + await service.getViewDocIdsByQuery(shareInfo); + await service.getViewSnapshotBulk(shareInfo, ['viwShared']); + + expect(viewService.getDocIdsByQuery).toHaveBeenCalledWith('tblShared', { + includeIds: ['viwShared'], + }); + expect(viewService.getSnapshotBulk).toHaveBeenCalledWith('tblShared', ['viwShared']); + expect(viewOpenApiV2Service.getView).not.toHaveBeenCalled(); + expect(viewOpenApiV2Service.getSnapshotBulk).not.toHaveBeenCalled(); + }); + + it('rejects a missing shared View before either persistence path', async () => { + const { service, viewService, viewOpenApiV2Service } = createService(true); + const missingView = { shareId: 'shrTest', tableId: 'tblShared' }; + + await expect(service.getViewDocIdsByQuery(missingView)).rejects.toMatchObject({ + code: HttpErrorCode.NOT_FOUND, + }); + await expect(service.getViewSnapshotBulk(missingView, ['viwShared'])).rejects.toMatchObject({ + code: HttpErrorCode.NOT_FOUND, + }); + expect(viewService.getDocIdsByQuery).not.toHaveBeenCalled(); + expect(viewService.getSnapshotBulk).not.toHaveBeenCalled(); + expect(viewOpenApiV2Service.getView).not.toHaveBeenCalled(); + expect(viewOpenApiV2Service.getSnapshotBulk).not.toHaveBeenCalled(); + }); + + it.each([{ ids: [] }, { ids: ['viwOther'] }, { ids: ['viwShared', 'viwOther'] }])( + 'rejects snapshot IDs outside the single shared View scope: $ids', + async ({ ids }) => { + const { service, viewService, viewOpenApiV2Service } = createService(true); + + await expect(service.getViewSnapshotBulk(shareInfo, ids)).rejects.toMatchObject({ + code: HttpErrorCode.RESTRICTED_RESOURCE, + }); + expect(viewService.getSnapshotBulk).not.toHaveBeenCalled(); + expect(viewOpenApiV2Service.getSnapshotBulk).not.toHaveBeenCalled(); + } + ); +}); describe('ShareSocketService computed activity authorization', () => { it('allows activity for the shared table', () => { - const service = createService(); + const { service } = createService(); expect(() => service.authorizeComputedActivityRead( @@ -17,7 +105,7 @@ describe('ShareSocketService computed activity authorization', () => { }); it('rejects activity for a different table', () => { - const service = createService(); + const { service } = createService(); expect(() => service.authorizeComputedActivityRead( @@ -32,3 +120,45 @@ describe('ShareSocketService computed activity authorization', () => { ); }); }); + +describe('ShareSocketService record snapshot projection', () => { + it('intersects a requested projection with the server-owned shared-field allow-list', async () => { + const getFieldsByQuery = vi.fn().mockResolvedValue([{ id: 'fldVisible', isPrimary: true }]); + const getSnapshotBulk = vi.fn().mockResolvedValue([]); + const service = new ShareSocketService( + {} as never, + {} as never, + { getFieldsByQuery } as never, + { + getDiffIdsByIdAndFilter: vi.fn().mockResolvedValue([]), + getSnapshotBulk, + } as never, + { get: vi.fn() } as never + ); + + await service.getRecordSnapshotBulk( + { + shareId: 'shrTest', + tableId: 'tblShared', + shareMeta: { includeRecords: true }, + view: { + id: 'viwShared', + filter: null, + shareMeta: { includeHiddenField: false }, + }, + } as never, + ['recVisible'], + true, + { fldVisible: true, fldSecret: true } + ); + + expect(getSnapshotBulk).toHaveBeenCalledWith( + 'tblShared', + ['recVisible'], + { fldVisible: true }, + undefined, + undefined, + true + ); + }); +}); diff --git a/apps/nestjs-backend/src/features/share/share-socket.service.ts b/apps/nestjs-backend/src/features/share/share-socket.service.ts index efb78722bc..969c9a325a 100644 --- a/apps/nestjs-backend/src/features/share/share-socket.service.ts +++ b/apps/nestjs-backend/src/features/share/share-socket.service.ts @@ -2,9 +2,12 @@ import { Injectable } from '@nestjs/common'; import { HttpErrorCode, type IGetFieldsQuery } from '@teable/core'; import type { IGetRecordsRo } from '@teable/openapi'; import { difference } from 'lodash'; +import { ClsService } from 'nestjs-cls'; import { CustomHttpException } from '../../custom.exception'; +import type { IClsStore } from '../../types/cls'; import { FieldService } from '../field/field.service'; import { RecordService } from '../record/record.service'; +import { ViewOpenApiV2Service } from '../view/open-api/view-open-api-v2.service'; import { ViewService } from '../view/view.service'; import type { IShareViewInfo } from './share-auth.service'; import { isLinkRecordSelectionQuery } from './share-link-query.util'; @@ -13,11 +16,13 @@ import { isLinkRecordSelectionQuery } from './share-link-query.util'; export class ShareSocketService { constructor( private readonly viewService: ViewService, + private readonly viewOpenApiV2Service: ViewOpenApiV2Service, private readonly fieldService: FieldService, - private readonly recordService: RecordService + private readonly recordService: RecordService, + private readonly cls: ClsService ) {} - getViewDocIdsByQuery(shareInfo: IShareViewInfo) { + async getViewDocIdsByQuery(shareInfo: IShareViewInfo) { const { tableId, view } = shareInfo; if (!view) { throw new CustomHttpException('View not found', HttpErrorCode.NOT_FOUND, { @@ -26,12 +31,16 @@ export class ShareSocketService { }, }); } + if (this.cls.get('useV2')) { + await this.viewOpenApiV2Service.getView(tableId, view.id); + return { ids: [view.id] }; + } return this.viewService.getDocIdsByQuery(tableId, { includeIds: [view.id], }); } - getViewSnapshotBulk(shareInfo: IShareViewInfo, ids: string[]) { + async getViewSnapshotBulk(shareInfo: IShareViewInfo, ids: string[]) { const { tableId, view } = shareInfo; if (!view) { throw new CustomHttpException('View not found', HttpErrorCode.NOT_FOUND, { @@ -52,6 +61,9 @@ export class ShareSocketService { } ); } + if (this.cls.get('useV2')) { + return this.viewOpenApiV2Service.getSnapshotBulk(tableId, [view.id]); + } return this.viewService.getSnapshotBulk(tableId, [view.id]); } @@ -133,13 +145,29 @@ export class ShareSocketService { ); } - async getRecordSnapshotBulk(shareInfo: IShareViewInfo, ids: string[], useQueryModel: boolean) { + async getRecordSnapshotBulk( + shareInfo: IShareViewInfo, + ids: string[], + useQueryModel: boolean, + projection?: { [fieldNameOrId: string]: boolean } + ) { const { tableId } = shareInfo; await this.validRecordSnapshotPermission(shareInfo, ids); + const { ids: allowedFieldIds } = await this.getFieldDocIdsByQuery(shareInfo); + const allowedFieldIdSet = new Set(allowedFieldIds); + const requestedFieldIds = projection + ? Object.entries(projection) + .filter(([, included]) => included) + .map(([fieldId]) => fieldId) + : []; + const projectedFieldIds = requestedFieldIds.length + ? requestedFieldIds.filter((fieldId) => allowedFieldIdSet.has(fieldId)) + : allowedFieldIds; + const safeProjection = Object.fromEntries(projectedFieldIds.map((fieldId) => [fieldId, true])); return this.recordService.getSnapshotBulk( tableId, ids, - undefined, + safeProjection, undefined, undefined, useQueryModel diff --git a/apps/nestjs-backend/src/features/share/share.controller.ts b/apps/nestjs-backend/src/features/share/share.controller.ts index dc8ef0bbbd..409e1fa8e2 100644 --- a/apps/nestjs-backend/src/features/share/share.controller.ts +++ b/apps/nestjs-backend/src/features/share/share.controller.ts @@ -24,8 +24,8 @@ import { IShareViewGroupPointsRo, IShareViewAggregationsRo, IShareViewRecordsRo, - rangesQuerySchema, - IRangesRo, + shareViewCopyQuerySchema, + IShareViewCopyQuery, shareViewLinkRecordsRoSchema, IShareViewLinkRecordsRo, shareViewCollaboratorsRoSchema, @@ -62,6 +62,7 @@ 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 { TqlPipe } from '../record/open-api/tql.pipe'; +import { SpaceDataDbMigrationGuardService } from '../space/space-data-db-migration-guard.service'; import { ShareAuthGuard } from './guard/auth.guard'; import { ShareLinkView } from './guard/link-view.decorator'; import { ShareAuthLocalGuard } from './guard/share-auth-local.guard'; @@ -77,11 +78,14 @@ export class ShareController { constructor( private readonly shareService: ShareService, private readonly shareAuthService: ShareAuthService, - private readonly shareSocketService: ShareSocketService + private readonly shareSocketService: ShareSocketService, + protected readonly spaceDataDbMigrationGuardService: SpaceDataDbMigrationGuardService ) {} @HttpCode(200) - @UseGuards(ShareAuthLocalGuard) + @UseV2Feature('getSharedView') + @UseGuards(V2FeatureGuard, ShareAuthLocalGuard) + @UseInterceptors(V2IndicatorInterceptor) @Post('/:shareId/view/auth') async auth(@Request() req: any, @Res({ passthrough: true }) res: Response) { const shareId = req.shareId; @@ -95,15 +99,24 @@ export class ShareController { } @ShareLinkView() - @UseGuards(ShareAuthGuard) + @UseV2Feature('getSharedView') + @UseGuards(V2FeatureGuard, ShareAuthGuard) + @UseInterceptors(V2IndicatorInterceptor) @AllowAnonymous() @Get('/:shareId/view') async getShareView(@Request() req?: any): Promise { const shareInfo = req.shareInfo as IShareViewInfo; + if (req.useV2) { + return this.shareService.getShareViewV2(shareInfo); + } return this.shareService.getShareView(shareInfo); } - @UseGuards(ShareAuthGuard) + @ShareLinkView() + @UseV2Feature('getSharedViewAggregations') + @UseGuards(V2FeatureGuard, ShareAuthGuard) + @UseInterceptors(V2IndicatorInterceptor) + @AllowAnonymous() @Get('/:shareId/view/aggregations') async getViewAggregations( @Request() req: any, @@ -111,11 +124,16 @@ export class ShareController { query?: IShareViewAggregationsRo ): Promise { const shareInfo = req.shareInfo as IShareViewInfo; + if (req.useV2) { + return this.shareService.getViewAggregationsV2(shareInfo, query); + } return this.shareService.getViewAggregations(shareInfo, query); } @ShareLinkView() - @UseGuards(ShareAuthGuard) + @UseV2Feature('getSharedViewRowCount') + @UseGuards(V2FeatureGuard, ShareAuthGuard) + @UseInterceptors(V2IndicatorInterceptor) @AllowAnonymous() @Get('/:shareId/view/row-count') async getViewRowCount( @@ -124,11 +142,16 @@ export class ShareController { query?: IShareViewRowCountRo ): Promise { const shareInfo = req.shareInfo as IShareViewInfo; + if (req.useV2) { + return this.shareService.getViewRowCountV2(shareInfo, query); + } return this.shareService.getViewRowCount(shareInfo, query); } @ShareLinkView() - @UseGuards(ShareAuthGuard) + @UseV2Feature('getSharedViewRecords') + @UseGuards(V2FeatureGuard, ShareAuthGuard) + @UseInterceptors(V2IndicatorInterceptor) @AllowAnonymous() @Get('/:shareId/view/records') async getViewRecords( @@ -137,12 +160,15 @@ export class ShareController { query?: IShareViewRecordsRo ): Promise { const shareInfo = req.shareInfo as IShareViewInfo; + if (req.useV2) { + return this.shareService.getViewRecordsV2(shareInfo, query); + } return this.shareService.getViewRecords(shareInfo, query); } @ShareSubmit() @UseV2Feature('formSubmit') - @UseGuards(ShareAuthGuard, V2FeatureGuard) + @UseGuards(V2FeatureGuard, ShareAuthGuard) @UseInterceptors(V2IndicatorInterceptor) @Post('/:shareId/view/form-submit') async submitRecord( @@ -154,17 +180,28 @@ export class ShareController { return this.shareService.formSubmit(shareInfo, shareViewFormSubmitRo); } - @UseGuards(ShareAuthGuard) + @UseV2Feature('getSharedViewCopy') + @UseGuards(V2FeatureGuard, ShareAuthGuard) + @UseInterceptors(V2IndicatorInterceptor) + @AllowAnonymous() @Get('/:shareId/view/copy') async copy( @Request() req: any, - @Query(new ZodValidationPipe(rangesQuerySchema), TqlPipe) shareViewCopyRo: IRangesRo + @Query(new ZodValidationPipe(shareViewCopyQuerySchema), TqlPipe) + shareViewCopyRo: IShareViewCopyQuery ): Promise { const shareInfo = req.shareInfo as IShareViewInfo; + if (req.useV2) { + return this.shareService.copyV2(shareInfo, shareViewCopyRo); + } return this.shareService.copy(shareInfo, shareViewCopyRo); } - @UseGuards(ShareAuthGuard) + @ShareLinkView() + @UseV2Feature('getSharedViewGroupPoints') + @UseGuards(V2FeatureGuard, ShareAuthGuard) + @UseInterceptors(V2IndicatorInterceptor) + @AllowAnonymous() @Get('/:shareId/view/group-points') async getViewGroupPoints( @Request() req: any, @@ -172,10 +209,17 @@ export class ShareController { query?: IShareViewGroupPointsRo ): Promise { const shareInfo = req.shareInfo as IShareViewInfo; + if (req.useV2) { + return this.shareService.getViewGroupPointsV2(shareInfo, query); + } return this.shareService.getViewGroupPoints(shareInfo, query); } - @UseGuards(ShareAuthGuard) + @ShareLinkView() + @UseV2Feature('getSharedViewCalendarDailyCollection') + @UseGuards(V2FeatureGuard, ShareAuthGuard) + @UseInterceptors(V2IndicatorInterceptor) + @AllowAnonymous() @Get('/:shareId/view/calendar-daily-collection') async getViewCalendarDailyCollection( @Request() req: any, @@ -183,10 +227,16 @@ export class ShareController { query: IShareViewCalendarDailyCollectionRo ): Promise { const shareInfo = req.shareInfo as IShareViewInfo; + if (req.useV2) { + return this.shareService.getViewCalendarDailyCollectionV2(shareInfo, query); + } return this.shareService.getViewCalendarDailyCollection(shareInfo, query); } - @UseGuards(ShareAuthGuard) + @UseV2Feature('getSharedViewLinkRecords') + @UseGuards(V2FeatureGuard, ShareAuthGuard) + @UseInterceptors(V2IndicatorInterceptor) + @AllowAnonymous() @Get('/:shareId/view/link-records') async viewLinkRecords( @Request() req: any, @@ -194,42 +244,72 @@ export class ShareController { shareViewLinkRecordsRo: IShareViewLinkRecordsRo ): Promise { const shareInfo = req.shareInfo as IShareViewInfo; + if (req.useV2) { + return this.shareService.getViewLinkRecordsV2(shareInfo, shareViewLinkRecordsRo); + } return this.shareService.getViewLinkRecords(shareInfo, shareViewLinkRecordsRo); } - @UseGuards(ShareAuthGuard) + @UseV2Feature('getSharedViewCollaborators') + @UseGuards(V2FeatureGuard, ShareAuthGuard) + @UseInterceptors(V2IndicatorInterceptor) @Get('/:shareId/view/collaborators') async getViewCollaborators( @Request() req: any, @Query(new ZodValidationPipe(shareViewCollaboratorsRoSchema)) query: IShareViewCollaboratorsRo ): Promise { const shareInfo = req.shareInfo as IShareViewInfo; + if (req.useV2) { + return this.shareService.getViewCollaboratorsV2(shareInfo, query); + } return this.shareService.getViewCollaborators(shareInfo, query); } - @UseGuards(ShareAuthGuard) + @UseV2Feature('getSharedViewSearchCount') + @UseGuards(V2FeatureGuard, ShareAuthGuard) + @UseInterceptors(V2IndicatorInterceptor) @Get('/:shareId/view/search-count') async getSearchCount( @Request() req: any, @Query(new ZodValidationPipe(searchCountRoSchema)) queryRo: ISearchCountRo ): Promise { - const { tableId, view } = req.shareInfo as IShareViewInfo; + const shareInfo = req.shareInfo as IShareViewInfo; + await this.spaceDataDbMigrationGuardService.assertTableRecordSearchReadable( + shareInfo.tableId, + queryRo + ); + if (req.useV2) { + return this.shareService.getShareSearchCountV2(shareInfo, queryRo); + } + const { tableId, view } = shareInfo; return this.shareService.getShareSearchCount(tableId, { ...queryRo, viewId: view?.id }); } - @UseGuards(ShareAuthGuard) + @UseV2Feature('getSharedViewSearchIndex') + @UseGuards(V2FeatureGuard, ShareAuthGuard) + @UseInterceptors(V2IndicatorInterceptor) @Get('/:shareId/view/search-index') async getSearchIndex( @Request() req: any, @Query(new ZodValidationPipe(searchIndexByQueryRoSchema)) queryRo: ISearchIndexByQueryRo ): Promise { - const { tableId, view } = req.shareInfo as IShareViewInfo; + const shareInfo = req.shareInfo as IShareViewInfo; + await this.spaceDataDbMigrationGuardService.assertTableRecordSearchReadable( + shareInfo.tableId, + queryRo + ); + if (req.useV2) { + return this.shareService.getShareSearchIndexV2(shareInfo, queryRo); + } + const { tableId, view } = shareInfo; return this.shareService.getShareSearchIndex(tableId, { ...queryRo, viewId: view?.id }); } - @UseGuards(ShareAuthGuard) + @UseV2Feature('buttonClick') + @UseGuards(V2FeatureGuard, ShareAuthGuard) + @UseInterceptors(V2IndicatorInterceptor) @Post('/:shareId/view/record/:recordId/:fieldId/button-click') async buttonClick( @Request() req: any, @@ -242,7 +322,9 @@ export class ShareController { } @ShareLinkView() - @UseGuards(ShareAuthGuard) + @UseV2Feature('getSharedViewSocketSnapshotBulk') + @UseGuards(V2FeatureGuard, ShareAuthGuard) + @UseInterceptors(V2IndicatorInterceptor) @AllowAnonymous() @Get('/:shareId/socket/view/snapshot-bulk') async getViewSnapshotBulk(@Request() req: any, @Query('ids') ids: string[]) { @@ -251,7 +333,9 @@ export class ShareController { } @ShareLinkView() - @UseGuards(ShareAuthGuard) + @UseV2Feature('getSharedViewSocketDocIds') + @UseGuards(V2FeatureGuard, ShareAuthGuard) + @UseInterceptors(V2IndicatorInterceptor) @AllowAnonymous() @Get('/:shareId/socket/view/doc-ids') async getViewDocIds(@Request() req: any) { @@ -296,10 +380,14 @@ export class ShareController { @ShareLinkView() @UseGuards(ShareAuthGuard) @AllowAnonymous() - @Get('/:shareId/socket/record/snapshot-bulk') - async getRecordSnapshotBulk(@Request() req: any, @Query('ids') ids: string[]) { + @Post('/:shareId/socket/record/snapshot-bulk') + async getRecordSnapshotBulk( + @Request() req: any, + @Body('ids') ids: string[], + @Body('projection') projection?: { [fieldNameOrId: string]: boolean } + ) { const shareInfo = req.shareInfo as IShareViewInfo; - return this.shareSocketService.getRecordSnapshotBulk(shareInfo, ids, true); + return this.shareSocketService.getRecordSnapshotBulk(shareInfo, ids, true, projection); } @ShareLinkView() @@ -311,6 +399,10 @@ export class ShareController { @Body(new ZodValidationPipe(getRecordsRoSchema), TqlPipe) query: IGetRecordsRo ) { const shareInfo = req.shareInfo as IShareViewInfo; + await this.spaceDataDbMigrationGuardService.assertTableRecordSearchReadable( + shareInfo.tableId, + query + ); return this.shareSocketService.getRecordDocIdsByQuery(shareInfo, query, true); } } diff --git a/apps/nestjs-backend/src/features/share/share.module.ts b/apps/nestjs-backend/src/features/share/share.module.ts index 8e066a3296..14255cbf5b 100644 --- a/apps/nestjs-backend/src/features/share/share.module.ts +++ b/apps/nestjs-backend/src/features/share/share.module.ts @@ -5,19 +5,25 @@ import { AuthModule } from '../auth/auth.module'; import { CanaryModule } from '../canary/canary.module'; import { CollaboratorModule } from '../collaborator/collaborator.module'; import { FieldModule } from '../field/field.module'; +import { FieldOpenApiModule } from '../field/open-api/field-open-api.module'; import { RecordOpenApiModule } from '../record/open-api/record-open-api.module'; import { RecordModule } from '../record/record.module'; import { SelectionModule } from '../selection/selection.module'; +import { SpaceDataDbMigrationGuardModule } from '../space/space-data-db-migration-guard.module'; +import { V2Module } from '../v2/v2.module'; +import { ViewOpenApiModule } from '../view/open-api/view-open-api.module'; import { ViewModule } from '../view/view.module'; import { ShareAuthModule } from './share-auth.module'; import { ShareSocketService } from './share-socket.service'; import { ShareController } from './share.controller'; import { ShareService } from './share.service'; +import { SharedViewRecordQueryV2Service } from './shared-view-record-query-v2.service'; @Module({ imports: [ AuthModule, FieldModule, + FieldOpenApiModule, RecordModule, RecordOpenApiModule, SelectionModule, @@ -25,9 +31,12 @@ import { ShareService } from './share.service'; ShareAuthModule, CollaboratorModule, ViewModule, + ViewOpenApiModule, CanaryModule, + SpaceDataDbMigrationGuardModule, + V2Module, ], - providers: [ShareService, DbProvider, ShareSocketService], + providers: [ShareService, DbProvider, ShareSocketService, SharedViewRecordQueryV2Service], controllers: [ShareController], exports: [ShareService, ShareSocketService], }) diff --git a/apps/nestjs-backend/src/features/share/share.service.spec.ts b/apps/nestjs-backend/src/features/share/share.service.spec.ts index f7a67f03f3..3e90a5293d 100644 --- a/apps/nestjs-backend/src/features/share/share.service.spec.ts +++ b/apps/nestjs-backend/src/features/share/share.service.spec.ts @@ -1,6 +1,9 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; +import { ViewType } from '@teable/core'; +import { vi } from 'vitest'; import { GlobalModule } from '../../global/global.module'; +import type { IShareViewInfo } from './share-auth.service'; import { ShareModule } from './share.module'; import { ShareService } from './share.service'; @@ -19,3 +22,220 @@ describe('ShareService', () => { expect(service).toBeDefined(); }); }); + +describe('ShareService.getShareViewV2', () => { + const createFixture = () => { + const legacyFieldRead = vi.fn(); + const legacyRecordRead = vi.fn(); + const legacyPluginRead = vi.fn(); + const fieldRead = vi.fn().mockResolvedValue([ + { id: 'fldPrimary', isPrimary: true }, + { id: 'fldVisible', isPrimary: false }, + ]); + const recordRead = vi.fn().mockResolvedValue({ + records: [{ id: 'recOne', fields: { fldPrimary: 'One', fldVisible: 'Visible' } }], + extra: { groupPoints: [] }, + }); + const pluginRead = vi.fn().mockResolvedValue({ + pluginId: 'plgOne', + pluginInstallId: 'pliOne', + name: 'Plugin', + storage: { mode: 'sheet' }, + url: 'https://plugin.example', + }); + const service = new ShareService( + { pluginInstall: { findFirst: legacyPluginRead } } as never, + {} as never, + { getFieldsByQuery: legacyFieldRead } as never, + { getFields: fieldRead } as never, + { getRecords: legacyRecordRead } as never, + {} as never, + {} as never, + { getRecords: recordRead } as never, + {} as never, + {} as never, + {} as never, + { getPluginInstall: pluginRead } as never, + { getRowCount: vi.fn() } as never, + { get: vi.fn() } as never, + {} as never, + {} as never + ); + return { + service, + fieldRead, + recordRead, + pluginRead, + legacyFieldRead, + legacyRecordRead, + legacyPluginRead, + }; + }; + + const gridShareInfo = { + shareId: 'shrOne', + tableId: 'tblOne', + shareMeta: { includeRecords: true }, + view: { + id: 'viwOne', + name: 'Grid', + type: ViewType.Grid, + columnMeta: {}, + group: [{ fieldId: 'fldVisible', order: 'asc' }], + }, + } as IShareViewInfo; + + it('composes fields and first-page records only from v2 services', async () => { + const fixture = createFixture(); + + const result = await fixture.service.getShareViewV2(gridShareInfo); + + expect(fixture.fieldRead).toHaveBeenCalledWith('tblOne', { + viewId: 'viwOne', + filterHidden: true, + }); + expect(fixture.recordRead).toHaveBeenCalledWith( + 'tblOne', + expect.objectContaining({ + viewId: 'viwOne', + take: 50, + projection: ['fldPrimary', 'fldVisible'], + }) + ); + expect(result.records).toHaveLength(1); + expect(fixture.legacyFieldRead).not.toHaveBeenCalled(); + expect(fixture.legacyRecordRead).not.toHaveBeenCalled(); + expect(fixture.legacyPluginRead).not.toHaveBeenCalled(); + }); + + it('does not query records when aggregate share metadata disables them', async () => { + const fixture = createFixture(); + + const result = await fixture.service.getShareViewV2({ + ...gridShareInfo, + shareMeta: { includeRecords: false }, + }); + + expect(result.records).toEqual([]); + expect(fixture.recordRead).not.toHaveBeenCalled(); + }); + + it('keeps link-share visible fields bounded while retaining the primary Field', async () => { + const fixture = createFixture(); + + const result = await fixture.service.getShareViewV2({ + shareId: 'fldLink', + tableId: 'tblForeign', + linkOptions: { + filterByViewId: 'viwForeign', + visibleFieldIds: ['fldVisible'], + }, + shareMeta: { includeRecords: true }, + }); + + expect(result.fields.map((field) => field.id)).toEqual(['fldPrimary', 'fldVisible']); + expect(fixture.recordRead).toHaveBeenCalledWith( + 'tblForeign', + expect.objectContaining({ + viewId: 'viwForeign', + projection: ['fldPrimary', 'fldVisible'], + }) + ); + }); + + it('loads PluginInstallation through the v2 port and merges plugin extra', async () => { + const fixture = createFixture(); + + const result = await fixture.service.getShareViewV2({ + ...gridShareInfo, + view: { + ...gridShareInfo.view!, + type: ViewType.Plugin, + }, + }); + + expect(fixture.pluginRead).toHaveBeenCalledWith('tblOne', 'viwOne'); + expect(result.extra).toEqual({ + groupPoints: [], + plugin: { + pluginId: 'plgOne', + pluginInstallId: 'pliOne', + name: 'Plugin', + storage: { mode: 'sheet' }, + url: 'https://plugin.example', + }, + }); + expect(fixture.legacyPluginRead).not.toHaveBeenCalled(); + }); + + it('bounds requested record projections to v2-visible Fields', async () => { + const fixture = createFixture(); + + await fixture.service.getViewRecordsV2(gridShareInfo, { + skip: 5, + take: 20, + projection: ['fldHidden'], + orderBy: [{ fieldId: 'fldVisible', order: 'desc' }], + }); + + expect(fixture.recordRead).toHaveBeenCalledWith( + 'tblOne', + expect.objectContaining({ + viewId: 'viwOne', + skip: 5, + take: 20, + projection: ['fldPrimary', 'fldVisible'], + orderBy: [{ fieldId: 'fldVisible', order: 'desc' }], + }) + ); + expect(fixture.legacyFieldRead).not.toHaveBeenCalled(); + expect(fixture.legacyRecordRead).not.toHaveBeenCalled(); + }); + + it('keeps selected link records outside the configured candidate View/filter scope', async () => { + const fixture = createFixture(); + + await fixture.service.getViewRecordsV2( + { + shareId: 'fldLink', + tableId: 'tblForeign', + linkOptions: { + filterByViewId: 'viwCandidates', + filter: { + conjunction: 'and', + filterSet: [{ fieldId: 'fldVisible', operator: 'is', value: 'candidate' }], + }, + }, + shareMeta: { includeRecords: true }, + }, + { + filterLinkCellSelected: 'fldLink', + selectedRecordIds: ['recSelected'], + } + ); + + expect(fixture.recordRead).toHaveBeenCalledWith( + 'tblForeign', + expect.objectContaining({ + viewId: undefined, + ignoreViewQuery: true, + filter: undefined, + selectedRecordIds: ['recSelected'], + projection: ['fldPrimary', 'fldVisible'], + }) + ); + }); + + it('returns early without any Field or Record query when records are disabled', async () => { + const fixture = createFixture(); + + const result = await fixture.service.getViewRecordsV2({ + ...gridShareInfo, + shareMeta: { includeRecords: false }, + }); + + expect(result).toEqual({ records: [] }); + expect(fixture.fieldRead).not.toHaveBeenCalled(); + expect(fixture.recordRead).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/nestjs-backend/src/features/share/share.service.ts b/apps/nestjs-backend/src/features/share/share.service.ts index f9974a1e5c..3a1080dbcf 100644 --- a/apps/nestjs-backend/src/features/share/share.service.ts +++ b/apps/nestjs-backend/src/features/share/share.service.ts @@ -19,13 +19,16 @@ import type { IShareViewAggregationsRo, IShareViewRecordsRo, IRangesRo, + IShareViewCopyQuery, IShareViewGroupPointsRo, IAggregationVo, IGroupPointsVo, IRowCountVo, IShareViewLinkRecordsRo, + IShareViewLinkRecordsVo, IRecordsVo, IShareViewCollaboratorsRo, + IShareViewCollaboratorsVo, ISearchCountRo, ISearchIndexByQueryRo, } from '@teable/openapi'; @@ -47,13 +50,16 @@ import { CollaboratorService } from '../collaborator/collaborator.service'; import { FieldService } from '../field/field.service'; import type { IFieldInstance } from '../field/model/factory'; import { createFieldInstanceByVo } from '../field/model/factory'; +import { FieldOpenApiV2Service } from '../field/open-api/field-open-api-v2.service'; import { RecordOpenApiV2Service } from '../record/open-api/record-open-api-v2.service'; import { RecordOpenApiService } from '../record/open-api/record-open-api.service'; import { RecordService } from '../record/record.service'; import { SelectionService } from '../selection/selection.service'; +import { ViewOpenApiV2Service } from '../view/open-api/view-open-api-v2.service'; import type { IShareViewInfo } from './share-auth.service'; import { isLinkRecordSelectionQuery } from './share-link-query.util'; import { ShareSocketService } from './share-socket.service'; +import { SharedViewRecordQueryV2Service } from './shared-view-record-query-v2.service'; export interface IJwtShareInfo { shareId: string; @@ -87,6 +93,7 @@ export class ShareService { private readonly prismaService: PrismaService, private readonly databaseRouter: DatabaseRouter, private readonly fieldService: FieldService, + private readonly fieldOpenApiV2Service: FieldOpenApiV2Service, private readonly recordService: RecordService, @InjectAggregationService() private readonly aggregationService: IAggregationService, private readonly recordOpenApiService: RecordOpenApiService, @@ -94,6 +101,8 @@ export class ShareService { private readonly selectionService: SelectionService, private readonly collaboratorService: CollaboratorService, private readonly shareSocketService: ShareSocketService, + private readonly viewOpenApiV2Service: ViewOpenApiV2Service, + private readonly sharedViewRecordQueryV2Service: SharedViewRecordQueryV2Service, private readonly cls: ClsService, @InjectDbProvider() private readonly dbProvider: IDbProvider, @InjectModel(DATA_KNEX) private readonly knex: Knex @@ -184,6 +193,56 @@ export class ShareService { }; } + async getShareViewV2(shareInfo: IShareViewInfo): Promise { + const { shareId, tableId, view, linkOptions, shareMeta } = shareInfo; + const { filterByViewId, filter } = linkOptions ?? {}; + const viewId = filterByViewId ?? view?.id; + const filteredFields = await this.getShareVisibleFieldsV2(shareInfo); + + let records: IRecordsVo['records'] = []; + let extra: ShareViewGetVo['extra']; + if (shareMeta?.includeRecords) { + const recordsData = await this.recordOpenApiV2Service.getRecords(tableId, { + viewId, + skip: 0, + take: 50, + filter, + groupBy: view?.group, + fieldKeyType: FieldKeyType.Id, + projection: filteredFields.map((field) => field.id), + }); + records = recordsData.records; + extra = recordsData.extra; + } + + if (view?.type === ViewType.Plugin && viewId) { + const pluginInstall = await this.viewOpenApiV2Service.getPluginInstall(tableId, viewId); + const plugin = { + pluginId: pluginInstall.pluginId, + pluginInstallId: pluginInstall.pluginInstallId, + name: pluginInstall.name, + storage: pluginInstall.storage, + url: pluginInstall.url, + }; + if (extra) { + extra.plugin = plugin; + } else { + extra = { plugin }; + } + } + + return { + shareMeta, + shareId, + tableId, + viewId, + view: view ? convertViewVoAttachmentUrl(view) : undefined, + fields: filteredFields, + records, + extra, + }; + } + async getViewAggregations( shareInfo: IShareViewInfo, query: IShareViewAggregationsRo = {} @@ -222,6 +281,13 @@ export class ShareService { return { aggregations: result?.aggregations }; } + async getViewAggregationsV2( + shareInfo: IShareViewInfo, + query: IShareViewAggregationsRo = {} + ): Promise { + return this.sharedViewRecordQueryV2Service.getAggregations(shareInfo, query); + } + async getViewRowCount( shareInfo: IShareViewInfo, query?: IShareViewRowCountRo @@ -254,6 +320,13 @@ export class ShareService { }; } + async getViewRowCountV2( + shareInfo: IShareViewInfo, + query?: IShareViewRowCountRo + ): Promise { + return this.sharedViewRecordQueryV2Service.getRowCount(shareInfo, query); + } + async getViewRecords( shareInfo: IShareViewInfo, query?: IShareViewRecordsRo @@ -304,6 +377,45 @@ export class ShareService { ); } + async getViewRecordsV2( + shareInfo: IShareViewInfo, + query?: IShareViewRecordsRo + ): Promise { + const { tableId, view, linkOptions, shareMeta } = shareInfo; + + if (!shareMeta?.includeRecords) { + return { records: [] }; + } + + const { id, group } = view ?? {}; + const { filterByViewId, filter: linkFilter } = linkOptions ?? {}; + const viewId = filterByViewId ?? id; + const shareVisibleFields = await this.getShareVisibleFieldsV2(shareInfo); + const projection = resolveShareRecordProjection( + shareVisibleFields, + query?.projection, + Boolean(linkOptions) + ); + const isLinkSelectionQuery = Boolean(linkOptions) && isLinkRecordSelectionQuery(query); + const filter = isLinkSelectionQuery ? undefined : query?.filter ?? linkFilter; + + return this.recordOpenApiV2Service.getRecords(tableId, { + viewId: isLinkSelectionQuery ? id : viewId, + ignoreViewQuery: isLinkSelectionQuery || undefined, + skip: query?.skip ?? 0, + take: query?.take ?? 100, + filter, + orderBy: query?.orderBy, + groupBy: query?.groupBy ?? group, + fieldKeyType: FieldKeyType.Id, + projection, + search: query?.search, + filterLinkCellCandidate: query?.filterLinkCellCandidate, + filterLinkCellSelected: query?.filterLinkCellSelected, + selectedRecordIds: query?.selectedRecordIds, + }); + } + async formSubmit(shareInfo: IShareViewInfo, shareViewFormSubmitRo: ShareViewFormSubmitRo) { const { tableId, view } = shareInfo; const { fields, typecast } = shareViewFormSubmitRo; @@ -357,6 +469,14 @@ export class ShareService { }); } + async copyV2(shareInfo: IShareViewInfo, shareViewCopyRo: IShareViewCopyQuery) { + return this.sharedViewRecordQueryV2Service.getCopy( + shareInfo, + shareViewCopyRo, + this.isShareEditor(shareInfo) + ); + } + // The field ids a share visitor is allowed to read: the view's non-hidden // fields (or, for a link share, its configured visibleFieldIds plus primary). // Used to bound any client-supplied projection so hidden columns never leak — @@ -375,6 +495,19 @@ export class ShareService { : fields; } + private async getShareVisibleFieldsV2(shareInfo: IShareViewInfo): Promise { + const { tableId, view, linkOptions, shareMeta } = shareInfo; + const { filterByViewId, visibleFieldIds } = linkOptions ?? {}; + const viewId = filterByViewId ?? view?.id; + const fields = await this.fieldOpenApiV2Service.getFields(tableId, { + viewId, + filterHidden: Boolean(filterByViewId) || !shareMeta?.includeHiddenField, + }); + return visibleFieldIds?.length + ? fields.filter((field) => visibleFieldIds.includes(field.id) || field.isPrimary) + : fields; + } + private async getShareVisibleFieldIds(shareInfo: IShareViewInfo): Promise { return (await this.getShareVisibleFields(shareInfo)).map((field) => field.id); } @@ -439,6 +572,13 @@ export class ShareService { }); } + async getViewLinkRecordsV2( + shareInfo: IShareViewInfo, + query: IShareViewLinkRecordsRo + ): Promise { + return this.sharedViewRecordQueryV2Service.getLinkRecords(shareInfo, query); + } + async getFormLinkRecords(field: IFieldVo, query: IShareViewLinkRecordsRo) { const { lookupFieldId, foreignTableId, filter, filterByViewId } = field.options as ILinkFieldOptions; @@ -502,6 +642,13 @@ export class ShareService { return this.aggregationService.getGroupPoints(tableId, { ...query, viewId }); } + async getViewGroupPointsV2( + shareInfo: IShareViewInfo, + query: IShareViewGroupPointsRo = {} + ): Promise { + return this.sharedViewRecordQueryV2Service.getGroupPoints(shareInfo, query); + } + async getViewCollaborators(shareInfo: IShareViewInfo, query: IShareViewCollaboratorsRo) { const { view, tableId } = shareInfo; const { fieldId } = query; @@ -551,6 +698,23 @@ export class ShareService { return this.getViewFilterCollaborators(shareInfo, field, query); } + async getViewCollaboratorsV2( + shareInfo: IShareViewInfo, + query: IShareViewCollaboratorsRo + ): Promise { + const collaborators = await this.sharedViewRecordQueryV2Service.getCollaborators( + shareInfo, + query, + this.isShareEditor(shareInfo) + ); + return collaborators.map((collaborator) => ({ + ...collaborator, + avatar: collaborator.avatar + ? getPublicFullStorageUrl(collaborator.avatar) + : collaborator.avatar, + })); + } + private async getViewFilterUserIds( tableId: string, filter: IFilter | undefined, @@ -695,10 +859,18 @@ export class ShareService { return this.aggregationService.getSearchCount(tableId, query); } + async getShareSearchCountV2(shareInfo: IShareViewInfo, query: ISearchCountRo) { + return this.sharedViewRecordQueryV2Service.getSearchCount(shareInfo, query); + } + async getShareSearchIndex(tableId: string, query: ISearchIndexByQueryRo) { return this.aggregationService.getRecordIndexBySearchOrder(tableId, query); } + async getShareSearchIndexV2(shareInfo: IShareViewInfo, query: ISearchIndexByQueryRo) { + return this.sharedViewRecordQueryV2Service.getSearchIndex(shareInfo, query); + } + async getViewCalendarDailyCollection( shareInfo: IShareViewInfo, query: IShareViewCalendarDailyCollectionRo @@ -721,7 +893,25 @@ export class ShareService { }; } + async getViewCalendarDailyCollectionV2( + shareInfo: IShareViewInfo, + query: IShareViewCalendarDailyCollectionRo + ) { + return this.sharedViewRecordQueryV2Service.getCalendarDailyCollection(shareInfo, query); + } + async buttonClick(shareInfo: IShareViewInfo, recordId: string, fieldId: string) { + if (this.cls.get('useV2')) { + const viewId = shareInfo.view?.id; + if (!viewId) { + throw new CustomHttpException('Shared view not found', HttpErrorCode.NOT_FOUND); + } + return this.recordOpenApiV2Service.buttonClick(shareInfo.tableId, recordId, fieldId, { + viewId, + includeHiddenFields: Boolean(shareInfo.shareMeta?.includeHiddenField), + includeRecords: Boolean(shareInfo.shareMeta?.includeRecords), + }); + } await this.shareSocketService.validFieldSnapshotPermission(shareInfo, [fieldId]); await this.shareSocketService.validRecordSnapshotPermission(shareInfo, [recordId]); return this.recordOpenApiService.buttonClick(shareInfo.tableId, recordId, fieldId); diff --git a/apps/nestjs-backend/src/features/share/shared-view-access-v2.service.spec.ts b/apps/nestjs-backend/src/features/share/shared-view-access-v2.service.spec.ts new file mode 100644 index 0000000000..79a2f2f8a3 --- /dev/null +++ b/apps/nestjs-backend/src/features/share/shared-view-access-v2.service.spec.ts @@ -0,0 +1,79 @@ +import { v2MetaDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import { describe, expect, it, vi } from 'vitest'; +import { SharedViewAccessV2Service } from './shared-view-access-v2.service'; + +/* eslint-disable @typescript-eslint/naming-convention */ +const createFixture = (identity?: { id: string; table_id: string }) => { + const query = { + select: vi.fn(), + where: vi.fn(), + executeTakeFirst: vi.fn().mockResolvedValue(identity), + }; + query.select.mockReturnValue(query); + query.where.mockReturnValue(query); + const db = { + selectFrom: vi.fn().mockReturnValue(query), + }; + const resolve = vi.fn().mockReturnValue(db); + const v2ContainerService = { + getContainer: vi.fn().mockResolvedValue({ resolve }), + }; + const viewOpenApiV2Service = { + getView: vi.fn(), + }; + const service = new SharedViewAccessV2Service( + v2ContainerService as never, + viewOpenApiV2Service as never + ); + return { service, db, query, resolve, viewOpenApiV2Service }; +}; + +describe('SharedViewAccessV2Service', () => { + it('resolves aggregate identity with Kysely and loads the View through Table', async () => { + const fixture = createFixture({ id: 'viwShared', table_id: 'tblShared' }); + fixture.viewOpenApiV2Service.getView.mockResolvedValue({ + id: 'viwShared', + enableShare: true, + shareId: 'shrShared', + shareMeta: { includeRecords: true }, + }); + + await expect(fixture.service.findByShareId('shrShared')).resolves.toEqual({ + shareId: 'shrShared', + tableId: 'tblShared', + view: expect.objectContaining({ id: 'viwShared' }), + shareMeta: { includeRecords: true }, + }); + + expect(fixture.resolve).toHaveBeenCalledWith(v2MetaDbTokens.db); + expect(fixture.db.selectFrom).toHaveBeenCalledWith('view'); + expect(fixture.query.where).toHaveBeenNthCalledWith(1, 'share_id', '=', 'shrShared'); + expect(fixture.query.where).toHaveBeenNthCalledWith(2, 'enable_share', '=', true); + expect(fixture.query.where).toHaveBeenNthCalledWith(3, 'deleted_time', 'is', null); + expect(fixture.viewOpenApiV2Service.getView).toHaveBeenCalledWith( + 'tblShared', + 'viwShared', + expect.objectContaining({ actorId: expect.anything() }) + ); + }); + + it('returns undefined without loading a Table when the active share index misses', async () => { + const fixture = createFixture(); + + await expect(fixture.service.findByShareId('shrMissing')).resolves.toBeUndefined(); + expect(fixture.viewOpenApiV2Service.getView).not.toHaveBeenCalled(); + }); + + it.each([ + { enableShare: false, shareId: 'shrShared' }, + { enableShare: true, shareId: 'shrRotated' }, + ])('rejects stale aggregate share state: %j', async (view) => { + const fixture = createFixture({ id: 'viwShared', table_id: 'tblShared' }); + fixture.viewOpenApiV2Service.getView.mockResolvedValue({ + id: 'viwShared', + ...view, + }); + + await expect(fixture.service.findByShareId('shrShared')).resolves.toBeUndefined(); + }); +}); diff --git a/apps/nestjs-backend/src/features/share/shared-view-access-v2.service.ts b/apps/nestjs-backend/src/features/share/shared-view-access-v2.service.ts new file mode 100644 index 0000000000..c7c03d7e11 --- /dev/null +++ b/apps/nestjs-backend/src/features/share/shared-view-access-v2.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { ANONYMOUS_USER_ID } from '@teable/core'; +import { v2MetaDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import { ActorId } from '@teable/v2-core'; +import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; +import type { Kysely } from 'kysely'; +import { V2ContainerService } from '../v2/v2-container.service'; +import { ViewOpenApiV2Service } from '../view/open-api/view-open-api-v2.service'; +import type { IShareViewInfo } from './share-auth.service'; + +const publicShareActorId = ActorId.create(ANONYMOUS_USER_ID)._unsafeUnwrap(); + +/** + * Resolves the public share credential through a read-model index, then loads + * the View child through the Table aggregate query path. + * + * This is intentionally not a View repository: the Kysely lookup returns only + * aggregate identity, while View state comes from `ITableRepository`. + */ +@Injectable() +export class SharedViewAccessV2Service { + constructor( + private readonly v2ContainerService: V2ContainerService, + private readonly viewOpenApiV2Service: ViewOpenApiV2Service + ) {} + + async findByShareId(shareId: string): Promise { + const container = await this.v2ContainerService.getContainer(); + const db = container.resolve>(v2MetaDbTokens.db); + const identity = await db + .selectFrom('view') + .select(['id', 'table_id']) + .where('share_id', '=', shareId) + .where('enable_share', '=', true) + .where('deleted_time', 'is', null) + .executeTakeFirst(); + if (!identity) return undefined; + + const view = await this.viewOpenApiV2Service.getView(identity.table_id, identity.id, { + actorId: publicShareActorId, + }); + if (view.enableShare !== true || view.shareId !== shareId) return undefined; + + return { + shareId, + tableId: identity.table_id, + view, + shareMeta: view.shareMeta, + }; + } +} diff --git a/apps/nestjs-backend/src/features/share/shared-view-record-query-v2.service.spec.ts b/apps/nestjs-backend/src/features/share/shared-view-record-query-v2.service.spec.ts new file mode 100644 index 0000000000..d11dae6928 --- /dev/null +++ b/apps/nestjs-backend/src/features/share/shared-view-record-query-v2.service.spec.ts @@ -0,0 +1,948 @@ +import { HttpException } from '@nestjs/common'; +import { FieldType, SortFunc, ViewType } from '@teable/core'; +import { ShareViewLinkRecordsType } from '@teable/openapi'; +import { + AggregateTableRecordsQuery, + AggregateTableRecordsResult, + FieldId, + GetCalendarDailyCollectionQuery, + GetCalendarDailyCollectionResult, + GetViewLinkRecordsQuery, + GetViewLinkRecordsResult, + GetViewCollaboratorsQuery, + GetViewCollaboratorsResult, + GetViewSelectionCopyQuery, + GetViewSelectionCopyResult, + ListFieldsQuery, + ListFieldsResult, + ListTableRecordsQuery, + ListTableRecordsResult, + RecordId, + v2CoreTokens, +} from '@teable/v2-core'; +import { ok } from 'neverthrow'; +import { vi } from 'vitest'; +import { string2Hash } from '../../utils'; +import type { IShareViewInfo } from './share-auth.service'; +import { SharedViewRecordQueryV2Service } from './shared-view-record-query-v2.service'; + +describe('SharedViewRecordQueryV2Service', () => { + const tableId = `tbl${'t'.repeat(16)}`; + const viewId = `viw${'v'.repeat(16)}`; + const candidateViewId = `viw${'c'.repeat(16)}`; + const fieldId = `fld${'f'.repeat(16)}`; + const startFieldId = `fld${'s'.repeat(16)}`; + const endFieldId = `fld${'e'.repeat(16)}`; + const primaryFieldId = FieldId.create(fieldId)._unsafeUnwrap(); + + const createFixture = ( + total = 3, + searchMatches?: Parameters[5], + aggregateValues: Parameters[0] = [], + aggregateGroups: Parameters[1] = [], + calendarResult: GetCalendarDailyCollectionResult = GetCalendarDailyCollectionResult.create( + [], + [] + ), + linkResult: GetViewLinkRecordsResult = GetViewLinkRecordsResult.create([]), + collaboratorsResult: GetViewCollaboratorsResult = GetViewCollaboratorsResult.create([]), + copyResult?: GetViewSelectionCopyResult + ) => { + const queries: unknown[] = []; + const mappedField = { + accept: vi.fn().mockReturnValue( + ok({ + id: fieldId, + name: 'Name', + type: FieldType.SingleLineText, + isPrimary: true, + }) + ), + }; + const queryBus = { + execute: vi.fn(async (_context, query: unknown) => { + queries.push(query); + if (query instanceof ListFieldsQuery) { + return ok(ListFieldsResult.create([mappedField as never], primaryFieldId)); + } + if (query instanceof ListTableRecordsQuery) { + return ok(ListTableRecordsResult.create([], total, 0, 1, undefined, searchMatches)); + } + if (query instanceof AggregateTableRecordsQuery) { + return ok(AggregateTableRecordsResult.create(aggregateValues, aggregateGroups)); + } + if (query instanceof GetCalendarDailyCollectionQuery) { + return ok(calendarResult); + } + if (query instanceof GetViewLinkRecordsQuery) { + return ok(linkResult); + } + if (query instanceof GetViewCollaboratorsQuery) { + return ok(collaboratorsResult); + } + if (query instanceof GetViewSelectionCopyQuery) { + return ok( + copyResult ?? + GetViewSelectionCopyResult.create('Alpha', [mappedField as never], primaryFieldId) + ); + } + throw new Error('Unexpected query'); + }), + }; + const attachmentDecorator = { + decorateAttachmentValue: vi.fn(async (value: unknown) => ok(value)), + }; + const getContainerForTable = vi.fn().mockResolvedValue({ + resolve: vi.fn((token) => + token === v2CoreTokens.attachmentValueDecoratorService ? attachmentDecorator : queryBus + ), + }); + const createContext = vi.fn().mockResolvedValue({ + actorId: { toString: () => `usr${'u'.repeat(16)}` }, + }); + const cacheGet = vi.fn(async (): Promise | undefined> => undefined); + const service = new SharedViewRecordQueryV2Service( + { getContainerForTable } as never, + { createContext } as never, + { maxGroupPoints: 5_000, maxCopyCells: 50_000 } as never, + { get: cacheGet } as never + ); + + return { + service, + queries, + queryBus, + attachmentDecorator, + getContainerForTable, + createContext, + cacheGet, + }; + }; + + const shareInfo = { + shareId: `shr${'s'.repeat(16)}`, + tableId, + shareMeta: { includeRecords: true }, + view: { + id: viewId, + name: 'Grid', + type: ViewType.Grid, + columnMeta: {}, + }, + } as IShareViewInfo; + + it('returns empty aggregation before resolving v2 dependencies when records are disabled', async () => { + const fixture = createFixture(); + + const result = await fixture.service.getAggregations({ + ...shareInfo, + shareMeta: { includeRecords: false }, + }); + + expect(result).toEqual({ aggregations: [] }); + expect(fixture.getContainerForTable).not.toHaveBeenCalled(); + }); + + it('returns an empty calendar collection before resolving v2 dependencies when records are disabled', async () => { + const fixture = createFixture(); + + const result = await fixture.service.getCalendarDailyCollection( + { ...shareInfo, shareMeta: { includeRecords: false } }, + { + startDate: '2025-01-01', + endDate: '2025-01-03', + startDateFieldId: startFieldId, + endDateFieldId: endFieldId, + } + ); + + expect(result).toEqual({ countMap: {}, records: [] }); + expect(fixture.getContainerForTable).not.toHaveBeenCalled(); + }); + + it('binds calendar collection to the authorized View, normalizes filters, and maps records', async () => { + const recordId = RecordId.create(`rec${'r'.repeat(16)}`)._unsafeUnwrap(); + const fixture = createFixture( + 0, + undefined, + [], + [], + GetCalendarDailyCollectionResult.create( + [{ date: '2025-01-02', count: 1, recordIds: [recordId] }], + [{ id: recordId.toString(), fields: { [fieldId]: 'Alpha' }, version: 3 }] + ) + ); + + const result = await fixture.service.getCalendarDailyCollection( + { + ...shareInfo, + shareMeta: { includeRecords: true, includeHiddenField: true }, + }, + { + startDate: '2025-01-01', + endDate: '2025-01-03', + startDateFieldId: startFieldId, + endDateFieldId: endFieldId, + filter: { + conjunction: 'and', + filterSet: [{ fieldId, operator: 'is', value: 'Alpha' }], + }, + search: ['Alpha', fieldId, true], + } + ); + const calendarQuery = fixture.queries.find( + (query): query is GetCalendarDailyCollectionQuery => + query instanceof GetCalendarDailyCollectionQuery + ); + + expect(fixture.queries[0]).toBeInstanceOf(ListFieldsQuery); + expect(calendarQuery?.viewId.toString()).toBe(viewId); + expect(calendarQuery?.filter).toEqual({ + conjunction: 'and', + items: [{ fieldId, operator: 'is', value: 'Alpha' }], + }); + expect(calendarQuery?.search).toEqual(['Alpha', fieldId, true]); + expect(calendarQuery?.includeHiddenFields).toBe(true); + expect(result).toEqual({ + countMap: Object.fromEntries([['2025-01-02', 1]]), + records: [{ id: recordId.toString(), fields: { [fieldId]: 'Alpha' } }], + }); + }); + + it('rejects a missing authorized View before opening a v2 container', async () => { + const fixture = createFixture(); + + await expect( + fixture.service.getCalendarDailyCollection( + { ...shareInfo, view: undefined }, + { + startDate: '2025-01-01', + endDate: '2025-01-03', + startDateFieldId: startFieldId, + endDateFieldId: endFieldId, + } + ) + ).rejects.toMatchObject({ status: 404 }); + expect(fixture.getContainerForTable).not.toHaveBeenCalled(); + }); + + it('binds Link Records to the authorized aggregate and preserves pagination/search inputs', async () => { + const linkFieldId = `fld${'k'.repeat(16)}`; + const firstRecordId = `rec${'a'.repeat(16)}`; + const secondRecordId = `rec${'b'.repeat(16)}`; + const fixture = createFixture( + 2, + undefined, + [], + [], + GetCalendarDailyCollectionResult.create([], []), + GetViewLinkRecordsResult.create([ + { id: firstRecordId, title: 'Alpha' }, + { id: secondRecordId, title: '42' }, + ]) + ); + + const result = await fixture.service.getLinkRecords( + { + ...shareInfo, + shareMeta: { includeRecords: false, includeHiddenField: true }, + }, + { + fieldId: linkFieldId, + type: ShareViewLinkRecordsType.Candidate, + search: 'Al', + take: 20, + skip: 5, + } + ); + const planQuery = fixture.queries.find( + (item): item is GetViewLinkRecordsQuery => item instanceof GetViewLinkRecordsQuery + ); + + expect(planQuery).toMatchObject({ + requestType: 'candidate', + includeHiddenFields: true, + search: 'Al', + }); + expect(planQuery?.tableId.toString()).toBe(tableId); + expect(planQuery?.viewId.toString()).toBe(viewId); + expect(planQuery?.fieldId.toString()).toBe(linkFieldId); + expect(planQuery?.pagination.limit().toNumber()).toBe(20); + expect(planQuery?.pagination.offset().toNumber()).toBe(5); + expect(result).toEqual([ + { id: firstRecordId, title: 'Alpha' }, + { id: secondRecordId, title: '42' }, + ]); + }); + + it('defaults Link Records pagination without consulting includeRecords', async () => { + const recordId = `rec${'a'.repeat(16)}`; + const fixture = createFixture( + 1, + undefined, + [], + [], + GetCalendarDailyCollectionResult.create([], []), + GetViewLinkRecordsResult.create([{ id: recordId }]) + ); + + const result = await fixture.service.getLinkRecords( + { ...shareInfo, shareMeta: { includeRecords: false } }, + { + fieldId, + skip: 5, + } + ); + const planQuery = fixture.queries.find( + (item): item is GetViewLinkRecordsQuery => item instanceof GetViewLinkRecordsQuery + ); + + expect(planQuery?.pagination.limit().toNumber()).toBe(100); + expect(planQuery?.pagination.offset().toNumber()).toBe(5); + expect(result).toEqual([{ id: recordId }]); + }); + + it('rejects Link Records without an authorized View before opening a v2 container', async () => { + const fixture = createFixture(); + + await expect( + fixture.service.getLinkRecords( + { ...shareInfo, view: undefined }, + { fieldId, take: 10, skip: 0 } + ) + ).rejects.toMatchObject({ status: 404 }); + expect(fixture.getContainerForTable).not.toHaveBeenCalled(); + }); + + it('binds collaborators to the authorized aggregate and preserves privacy inputs', async () => { + const userFieldId = `fld${'u'.repeat(16)}`; + const fixture = createFixture( + 0, + undefined, + [], + [], + GetCalendarDailyCollectionResult.create([], []), + GetViewLinkRecordsResult.create([]), + GetViewCollaboratorsResult.create([ + { userId: 'usr-alice', userName: 'Alice', avatar: 'alice.png' }, + ]) + ); + + const result = await fixture.service.getCollaborators( + { + ...shareInfo, + shareMeta: { includeHiddenField: true }, + }, + { + fieldId: userFieldId, + search: 'Ali', + take: 20, + skip: 5, + }, + true + ); + const collaboratorsQuery = fixture.queries.find( + (item): item is GetViewCollaboratorsQuery => item instanceof GetViewCollaboratorsQuery + ); + + expect(collaboratorsQuery?.tableId.toString()).toBe(tableId); + expect(collaboratorsQuery?.viewId?.toString()).toBe(viewId); + expect(collaboratorsQuery?.fieldId?.toString()).toBe(userFieldId); + expect(collaboratorsQuery).toMatchObject({ + includeHiddenFields: true, + canReadAllCollaborators: true, + search: 'Ali', + }); + expect(collaboratorsQuery?.pagination.limit().toNumber()).toBe(20); + expect(collaboratorsQuery?.pagination.offset().toNumber()).toBe(5); + expect(result).toEqual([{ userId: 'usr-alice', userName: 'Alice', avatar: 'alice.png' }]); + expect(result[0]).not.toHaveProperty('email'); + }); + + it('supports the legacy no-View all-collaborator branch with default pagination', async () => { + const fixture = createFixture(); + + await fixture.service.getCollaborators({ ...shareInfo, view: undefined }, {}, false); + const collaboratorsQuery = fixture.queries.find( + (item): item is GetViewCollaboratorsQuery => item instanceof GetViewCollaboratorsQuery + ); + + expect(collaboratorsQuery?.viewId).toBeUndefined(); + expect(collaboratorsQuery?.pagination.limit().toNumber()).toBe(50); + expect(collaboratorsQuery?.pagination.offset().toNumber()).toBe(0); + }); + + it('binds copy to the authorized View and drops client authority-expanding inputs', async () => { + const fixture = createFixture(); + const otherViewId = `viw${'x'.repeat(16)}`; + + const result = await fixture.service.getCopy( + shareInfo, + { + viewId: otherViewId, + ignoreViewQuery: true, + filterLinkCellSelected: fieldId, + projection: [fieldId], + ranges: [ + [0, 0], + [0, 0], + ], + } as never, + true + ); + const copyQuery = fixture.queries.find( + (item): item is GetViewSelectionCopyQuery => item instanceof GetViewSelectionCopyQuery + ); + + expect(copyQuery?.tableId.toString()).toBe(tableId); + expect(copyQuery?.viewId.toString()).toBe(viewId); + expect(copyQuery?.canCopyAsEditor).toBe(true); + expect(copyQuery?.projection?.map((id) => id.toString())).toEqual([fieldId]); + expect(copyQuery).not.toHaveProperty('ignoreViewQuery'); + expect(copyQuery).not.toHaveProperty('filterLinkCellSelected'); + expect(result).toEqual({ + content: 'Alpha', + header: [expect.objectContaining({ id: fieldId, name: 'Name', isPrimary: true })], + }); + }); + + it('normalizes an allowed copy filter before dispatching the aggregate query', async () => { + const fixture = createFixture(); + + await fixture.service.getCopy( + shareInfo, + { + filter: { + conjunction: 'and', + filterSet: [{ fieldId, operator: 'is', value: 'Alpha' }], + }, + ranges: [ + [0, 0], + [0, 0], + ], + }, + false + ); + const copyQuery = fixture.queries.find( + (item): item is GetViewSelectionCopyQuery => item instanceof GetViewSelectionCopyQuery + ); + + expect(fixture.queries[0]).toBeInstanceOf(ListFieldsQuery); + expect(copyQuery?.filter).toEqual({ + conjunction: 'and', + items: [{ fieldId, operator: 'is', value: 'Alpha' }], + }); + }); + + it('restores cached collapsed groups for a large selection query id', async () => { + const fixture = createFixture(); + fixture.cacheGet.mockResolvedValue({ + collapsedGroupIds: ['cached-group'], + }); + + await fixture.service.getCopy( + shareInfo, + { + queryId: 'qry_cached', + collapsedGroupIds: ['request-group'], + ranges: [ + [0, 0], + [0, 0], + ], + }, + false + ); + const copyQuery = fixture.queries.find( + (item): item is GetViewSelectionCopyQuery => item instanceof GetViewSelectionCopyQuery + ); + + expect(fixture.cacheGet).toHaveBeenCalledWith('query-params:qry_cached'); + expect(copyQuery?.collapsedGroupIds).toEqual(['cached-group']); + }); + + it('rejects copy without an authorized View before opening a v2 container', async () => { + const fixture = createFixture(); + + await expect( + fixture.service.getCopy( + { ...shareInfo, view: undefined }, + { + ranges: [ + [0, 0], + [0, 0], + ], + }, + false + ) + ).rejects.toMatchObject({ status: 404 }); + expect(fixture.getContainerForTable).not.toHaveBeenCalled(); + }); + + it('binds aggregation to the authorized View and maps requested totals', async () => { + const fixture = createFixture(0, undefined, [ + { + fieldId: primaryFieldId, + statisticFunc: 'count', + value: 3, + }, + { + fieldId: primaryFieldId, + statisticFunc: 'unique', + value: 2, + }, + ]); + + const result = await fixture.service.getAggregations(shareInfo, { + field: { + count: [fieldId], + unique: [fieldId], + }, + }); + const aggregateQuery = fixture.queries.find( + (query): query is AggregateTableRecordsQuery => query instanceof AggregateTableRecordsQuery + ); + + expect(result).toEqual({ + aggregations: [ + { fieldId, total: { value: 3, aggFunc: 'count' } }, + { fieldId, total: { value: 2, aggFunc: 'unique' } }, + ], + }); + expect(aggregateQuery?.viewId.toString()).toBe(viewId); + expect(aggregateQuery?.fields).toEqual([ + { fieldId, statisticFunc: 'count' }, + { fieldId, statisticFunc: 'unique' }, + ]); + }); + + it('normalizes request filters and delegates default View statistics to the Table aggregate', async () => { + const fixture = createFixture(); + + await fixture.service.getAggregations(shareInfo, { + field: {}, + filter: { + conjunction: 'and', + filterSet: [{ fieldId, operator: 'is', value: 'Alpha' }], + }, + }); + const aggregateQuery = fixture.queries.find( + (query): query is AggregateTableRecordsQuery => query instanceof AggregateTableRecordsQuery + ); + + expect(fixture.queries[0]).toBeInstanceOf(ListFieldsQuery); + expect(aggregateQuery?.filter).toEqual({ + conjunction: 'and', + items: [{ fieldId, operator: 'is', value: 'Alpha' }], + }); + expect(aggregateQuery?.fields).toBeUndefined(); + }); + + it('maps every grouped prefix to the legacy public group id contract', async () => { + const secondGroupFieldId = `fld${'g'.repeat(16)}`; + const fixture = createFixture(0, undefined, [ + { + fieldId: primaryFieldId, + statisticFunc: 'count', + value: 3, + }, + { + fieldId: primaryFieldId, + statisticFunc: 'count', + value: 2, + groupValues: ['Open'], + }, + { + fieldId: primaryFieldId, + statisticFunc: 'count', + value: 1, + groupValues: ['Open', 'High'], + }, + ]); + + const result = await fixture.service.getAggregations(shareInfo, { + field: { count: [fieldId] }, + groupBy: [ + { fieldId, order: SortFunc.Asc }, + { fieldId: secondGroupFieldId, order: SortFunc.Desc }, + ], + }); + const aggregateQuery = fixture.queries.find( + (query): query is AggregateTableRecordsQuery => query instanceof AggregateTableRecordsQuery + ); + + expect(aggregateQuery?.groupBy).toEqual([ + { fieldId, order: 'asc' }, + { fieldId: secondGroupFieldId, order: 'desc' }, + ]); + expect(result.aggregations?.[0]?.total).toEqual({ value: 3, aggFunc: 'count' }); + expect(Object.values(result.aggregations?.[0]?.group ?? {})).toEqual([ + { value: 2, aggFunc: 'count' }, + { value: 1, aggFunc: 'count' }, + ]); + }); + + it('forwards visible-row search to aggregation while keeping the authorized View scope', async () => { + const fixture = createFixture(); + + await fixture.service.getAggregations(shareInfo, { + field: { count: [fieldId] }, + search: ['Alpha', fieldId, true], + }); + const aggregateQuery = fixture.queries.find( + (query): query is AggregateTableRecordsQuery => query instanceof AggregateTableRecordsQuery + ); + + expect(aggregateQuery?.viewId.toString()).toBe(viewId); + expect(aggregateQuery?.search).toEqual(['Alpha', fieldId, true]); + }); + + it('returns before persistence when records, View, or grouping are absent', async () => { + const disabled = createFixture(); + const missingView = createFixture(); + const ungrouped = createFixture(); + + await expect( + disabled.service.getGroupPoints( + { ...shareInfo, shareMeta: { includeRecords: false } }, + { groupBy: [{ fieldId, order: SortFunc.Asc }] } + ) + ).resolves.toEqual([]); + await expect( + missingView.service.getGroupPoints( + { ...shareInfo, view: undefined }, + { groupBy: [{ fieldId, order: SortFunc.Asc }] } + ) + ).resolves.toBeNull(); + await expect(ungrouped.service.getGroupPoints(shareInfo)).resolves.toEqual([]); + expect(disabled.getContainerForTable).not.toHaveBeenCalled(); + expect(missingView.getContainerForTable).not.toHaveBeenCalled(); + expect(ungrouped.getContainerForTable).not.toHaveBeenCalled(); + }); + + it('maps ordered group rows, collapsed headers, search, and overflow through the v2 aggregate', async () => { + const secondGroupFieldId = `fld${'g'.repeat(16)}`; + const secondFieldId = FieldId.create(secondGroupFieldId)._unsafeUnwrap(); + const firstGroupId = String(string2Hash(`${fieldId}_A`)); + const fixture = createFixture( + 0, + undefined, + [ + { fieldId: primaryFieldId, statisticFunc: 'count', value: 7 }, + { + fieldId: primaryFieldId, + statisticFunc: 'count', + value: 2, + groupValues: ['A', 'X'], + }, + { + fieldId: primaryFieldId, + statisticFunc: 'count', + value: 1, + groupValues: ['A', 'Y'], + }, + { + fieldId: primaryFieldId, + statisticFunc: 'count', + value: 2, + groupValues: ['B', 'Z'], + }, + ], + [ + { fieldId: primaryFieldId, fieldType: 'singleLineText', order: 'asc' }, + { fieldId: secondFieldId, fieldType: 'singleLineText', order: 'desc' }, + ] + ); + + const result = await fixture.service.getGroupPoints(shareInfo, { + filter: { + conjunction: 'and', + filterSet: [{ fieldId, operator: 'is', value: 'A' }], + }, + search: ['A', fieldId, true], + groupBy: [ + { fieldId, order: SortFunc.Asc }, + { fieldId: secondGroupFieldId, order: SortFunc.Desc }, + ], + collapsedGroupIds: [firstGroupId], + }); + const aggregateQuery = fixture.queries.find( + (query): query is AggregateTableRecordsQuery => query instanceof AggregateTableRecordsQuery + ); + + expect(aggregateQuery?.search).toEqual(['A', fieldId, true]); + expect(aggregateQuery?.groupBy).toEqual([ + { fieldId, order: 'asc' }, + { fieldId: secondGroupFieldId, order: 'desc' }, + ]); + expect(result?.filter((point) => point.type === 1)).toEqual([ + { type: 1, count: 2 }, + { type: 1, count: 2 }, + ]); + expect(result?.find((point) => point.type === 0 && point.value === 'A')).toMatchObject({ + id: firstGroupId, + isCollapsed: true, + }); + expect(result?.at(-2)).toMatchObject({ id: 'unknown', value: 'Unknown' }); + expect(result?.at(-1)).toEqual({ type: 1, count: 2 }); + }); + + it('decorates attachment group headers without changing their stable group identity', async () => { + const rawAttachment = [ + { token: 'tok-1', path: 'table/file.png', name: 'file.png', mimetype: 'image/png' }, + ]; + const signedAttachment = [{ ...rawAttachment[0], presignedUrl: 'https://cdn/file.png' }]; + const fixture = createFixture( + 0, + undefined, + [ + { fieldId: primaryFieldId, statisticFunc: 'count', value: 1 }, + { + fieldId: primaryFieldId, + statisticFunc: 'count', + value: 1, + groupValues: [rawAttachment], + }, + ], + [{ fieldId: primaryFieldId, fieldType: 'attachment', order: 'asc' }] + ); + fixture.attachmentDecorator.decorateAttachmentValue.mockResolvedValue(ok(signedAttachment)); + + const result = await fixture.service.getGroupPoints(shareInfo, { + groupBy: [{ fieldId, order: SortFunc.Asc }], + }); + + expect(fixture.attachmentDecorator.decorateAttachmentValue).toHaveBeenCalledWith(rawAttachment); + expect(result?.[0]).toMatchObject({ + type: 0, + value: signedAttachment, + id: String(string2Hash(`${fieldId}_${JSON.stringify(rawAttachment)}`)), + }); + }); + + it('rejects a malformed aggregation before executing the aggregate query', async () => { + const fixture = createFixture(); + + const error = await fixture.service + .getAggregations(shareInfo, { + field: { count: [''] }, + }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(HttpException); + expect((error as HttpException).getStatus()).toBe(400); + expect(fixture.queries.some((query) => query instanceof AggregateTableRecordsQuery)).toBe( + false + ); + }); + + it('returns before resolving any v2 dependency when records are disabled', async () => { + const fixture = createFixture(); + + const result = await fixture.service.getRowCount({ + ...shareInfo, + shareMeta: { includeRecords: false }, + }); + + expect(result).toEqual({ rowCount: 0 }); + expect(fixture.getContainerForTable).not.toHaveBeenCalled(); + }); + + it('requires a search tuple before resolving persistence', async () => { + const fixture = createFixture(); + + const error = await fixture.service + .getSearchCount(shareInfo, {}) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(HttpException); + expect((error as HttpException).getStatus()).toBe(400); + expect(fixture.getContainerForTable).not.toHaveBeenCalled(); + }); + + it('binds search count to the authorized View and ignores caller View overrides', async () => { + const fixture = createFixture(2); + + const result = await fixture.service.getSearchCount(shareInfo, { + viewId: candidateViewId, + ignoreViewQuery: true, + search: ['Alpha', fieldId, false], + }); + const countQuery = fixture.queries.find( + (query): query is ListTableRecordsQuery => query instanceof ListTableRecordsQuery + ); + + expect(result).toEqual({ count: 2 }); + expect(countQuery?.viewId).toBe(viewId); + expect(countQuery?.ignoreViewQuery).toBeUndefined(); + expect(countQuery?.search).toEqual(['Alpha', fieldId, true]); + }); + + it('returns null before resolving v2 dependencies when search-index records are disabled', async () => { + const fixture = createFixture(); + + const result = await fixture.service.getSearchIndex( + { ...shareInfo, shareMeta: { includeRecords: false } }, + { take: 10, search: ['Alpha', fieldId, false] } + ); + + expect(result).toBeNull(); + expect(fixture.getContainerForTable).not.toHaveBeenCalled(); + }); + + it('validates search-index input before resolving persistence', async () => { + const fixture = createFixture(); + + const missingSearch = await fixture.service + .getSearchIndex(shareInfo, { take: 10 }) + .catch((caught: unknown) => caught); + const excessiveTake = await fixture.service + .getSearchIndex(shareInfo, { take: 1001, search: ['Alpha', fieldId, false] }) + .catch((caught: unknown) => caught); + + expect(missingSearch).toBeInstanceOf(HttpException); + expect(excessiveTake).toBeInstanceOf(HttpException); + expect(fixture.getContainerForTable).not.toHaveBeenCalled(); + }); + + it('projects complete-View search indexes from the authorized aggregate scope', async () => { + const recordId = RecordId.create(`rec${'r'.repeat(16)}`)._unsafeUnwrap(); + const fixture = createFixture(1, [{ index: 3, fieldId: primaryFieldId, recordId }]); + + const result = await fixture.service.getSearchIndex(shareInfo, { + take: 10, + projection: [fieldId], + viewId: candidateViewId, + ignoreViewQuery: true, + groupBy: [{ fieldId, order: SortFunc.Asc }], + orderBy: [{ fieldId, order: SortFunc.Desc }], + search: ['Alpha', fieldId, false], + }); + const searchQuery = fixture.queries.find( + (query): query is ListTableRecordsQuery => query instanceof ListTableRecordsQuery + ); + + expect(result).toEqual([{ index: 3, fieldId, recordId: recordId.toString() }]); + expect(searchQuery?.viewId).toBe(viewId); + expect(searchQuery?.ignoreViewQuery).toBeUndefined(); + expect(searchQuery?.includeSearchFieldMatches).toBe(true); + expect(searchQuery?.searchIndexMode).toBe('view'); + expect(searchQuery?.search).toEqual(['Alpha', fieldId, true]); + expect(searchQuery?.sort).toEqual([ + { fieldId, order: 'asc' }, + { fieldId, order: 'desc' }, + ]); + }); + + it('uses matched-row numbering and returns null when no field matches remain', async () => { + const fixture = createFixture(0, []); + + const result = await fixture.service.getSearchIndex(shareInfo, { + skip: 2, + take: 5, + search: ['missing', '', true], + }); + const searchQuery = fixture.queries.find( + (query): query is ListTableRecordsQuery => query instanceof ListTableRecordsQuery + ); + + expect(result).toBeNull(); + expect(searchQuery?.searchIndexMode).toBe('matched'); + expect(searchQuery?.pagination.offset().toNumber()).toBe(2); + expect(searchQuery?.pagination.limit().toNumber()).toBe(5); + }); + + it('counts through ListTableRecordsQuery with the aggregate-owned View', async () => { + const fixture = createFixture(7); + + const result = await fixture.service.getRowCount(shareInfo); + const countQuery = fixture.queries.find( + (query): query is ListTableRecordsQuery => query instanceof ListTableRecordsQuery + ); + + expect(result).toEqual({ rowCount: 7 }); + expect(countQuery?.viewId).toBe(viewId); + expect(countQuery?.ignoreViewQuery).toBeUndefined(); + expect(countQuery?.includeTotal).toBe(true); + expect(countQuery?.pagination.limit().toNumber()).toBe(1); + }); + + it('gives the link candidate scope priority over a caller filter', async () => { + const fixture = createFixture(1); + + await fixture.service.getRowCount( + { + ...shareInfo, + linkOptions: { + filterByViewId: candidateViewId, + filter: { + conjunction: 'and', + filterSet: [{ fieldId, operator: 'is', value: 'candidate' }], + }, + }, + }, + { + filter: { + conjunction: 'and', + filterSet: [{ fieldId, operator: 'is', value: 'caller' }], + }, + } + ); + const countQuery = fixture.queries.find( + (query): query is ListTableRecordsQuery => query instanceof ListTableRecordsQuery + ); + + expect(fixture.queries[0]).toBeInstanceOf(ListFieldsQuery); + expect(countQuery?.viewId).toBe(candidateViewId); + expect(countQuery?.filter).toEqual({ + conjunction: 'and', + items: [{ fieldId, operator: 'is', value: 'candidate' }], + }); + }); + + it('ignores candidate View and filter defaults for already-selected link records', async () => { + const fixture = createFixture(1); + const hostRecordId = `rec${'r'.repeat(16)}`; + + await fixture.service.getRowCount( + { + ...shareInfo, + linkOptions: { + filterByViewId: candidateViewId, + filter: { + conjunction: 'and', + filterSet: [{ fieldId, operator: 'is', value: 'candidate' }], + }, + }, + }, + { + filterLinkCellSelected: [fieldId, hostRecordId], + selectedRecordIds: [`rec${'x'.repeat(16)}`], + } + ); + const countQuery = fixture.queries.find( + (query): query is ListTableRecordsQuery => query instanceof ListTableRecordsQuery + ); + + expect(fixture.queries).toHaveLength(1); + expect(countQuery?.viewId).toBe(viewId); + expect(countQuery?.ignoreViewQuery).toBe(true); + expect(countQuery?.filter).toBeUndefined(); + expect(countQuery?.filterLinkCellSelected).toEqual([fieldId, hostRecordId]); + expect(countQuery?.selectedRecordIds).toEqual([`rec${'x'.repeat(16)}`]); + }); + + it('rejects mutually exclusive link candidate and selected modes before persistence', async () => { + const fixture = createFixture(); + + const error = await fixture.service + .getRowCount(shareInfo, { + filterLinkCellCandidate: fieldId, + filterLinkCellSelected: fieldId, + }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(HttpException); + expect((error as HttpException).getStatus()).toBe(400); + expect(fixture.queries).toHaveLength(0); + }); +}); diff --git a/apps/nestjs-backend/src/features/share/shared-view-record-query-v2.service.ts b/apps/nestjs-backend/src/features/share/shared-view-record-query-v2.service.ts new file mode 100644 index 0000000000..f53a5692b3 --- /dev/null +++ b/apps/nestjs-backend/src/features/share/shared-view-record-query-v2.service.ts @@ -0,0 +1,732 @@ +import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { FieldKeyType, HttpErrorCode } from '@teable/core'; +import type { IFieldVo, StatisticsFunc } from '@teable/core'; +import type { + IAggregationVo, + IGroupPoint, + IGroupPointsVo, + IRowCountVo, + ISearchCountRo, + ISearchCountVo, + ISearchIndexByQueryRo, + ISearchIndexVo, + IShareViewRowCountRo, + IShareViewAggregationsRo, + IShareViewGroupPointsRo, + IShareViewCalendarDailyCollectionRo, + ICalendarDailyCollectionVo, + IShareViewLinkRecordsRo, + IShareViewLinkRecordsVo, + IShareViewCollaboratorsRo, + IShareViewCollaboratorsVo, + IShareViewCopyQuery, + ICopyVo, +} from '@teable/openapi'; +import { GroupPointType } from '@teable/openapi'; +import { + mapDomainErrorToHttpError, + mapDomainErrorToHttpStatus, + mapFieldToDto, + mapTableRecordToDto, +} from '@teable/v2-contract-http'; +import { executeListTableRecordsEndpoint } from '@teable/v2-contract-http-implementation/handlers'; +import { + AggregateTableRecordsQuery, + type AggregateTableRecordsResult, + GetCalendarDailyCollectionQuery, + type GetCalendarDailyCollectionResult, + GetViewLinkRecordsQuery, + type GetViewLinkRecordsResult, + GetViewCollaboratorsQuery, + type GetViewCollaboratorsResult, + GetViewSelectionCopyQuery, + type GetViewSelectionCopyResult, + type AttachmentValueDecoratorService, + ListFieldsQuery, + type ListFieldsResult, + ListTableRecordsQuery, + type ListTableRecordsResult, + type IQueryBus, + v2CoreTokens, +} from '@teable/v2-core'; +import { CacheService } from '../../cache/cache.service'; +import type { ICacheStore } from '../../cache/types'; +import { type IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config'; +import { CustomHttpException, getDefaultCodeByStatus } from '../../custom.exception'; +import { convertValueToStringify, string2Hash } from '../../utils'; +import { + normalizeLegacyRecordFilterForV2, + type IRecordFilterFieldMeta, +} from '../record/open-api/record-filter-v2.mapper'; +import { V2ContainerService } from '../v2/v2-container.service'; +import { V2ExecutionContextFactory } from '../v2/v2-execution-context.factory'; +import type { IShareViewInfo } from './share-auth.service'; +import { isLinkRecordSelectionQuery } from './share-link-query.util'; + +const internalServerError = 'Internal server error'; + +type GroupPointMappingState = { + previousValues: unknown[]; + collapsedDepth: number; +}; + +@Injectable() +export class SharedViewRecordQueryV2Service { + constructor( + private readonly v2ContainerService: V2ContainerService, + private readonly v2ContextFactory: V2ExecutionContextFactory, + @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig, + private readonly cacheService: CacheService + ) {} + + async getAggregations( + shareInfo: IShareViewInfo, + query: IShareViewAggregationsRo = {} + ): Promise { + if (!shareInfo.shareMeta?.includeRecords) return { aggregations: [] }; + const viewId = shareInfo.view?.id; + if (!viewId) { + throw new CustomHttpException('Shared view not found', HttpErrorCode.NOT_FOUND); + } + + const { tableId } = shareInfo; + const container = await this.v2ContainerService.getContainerForTable(tableId); + const context = await this.v2ContextFactory.createContext(container); + const queryBus = container.resolve(v2CoreTokens.queryBus); + const filter = await this.normalizeFilter( + tableId, + query.filter, + context.actorId.toString(), + queryBus, + context + ); + const requestedFields = query.field + ? Object.entries(query.field).flatMap(([statisticFunc, fieldIds]) => + fieldIds.map((fieldId) => ({ fieldId, statisticFunc })) + ) + : undefined; + const fields = requestedFields?.length ? requestedFields : undefined; + const aggregationQuery = AggregateTableRecordsQuery.create( + { + tableId, + viewId, + filter, + search: query.search, + fields, + groupBy: query.groupBy, + includeHiddenFields: Boolean(shareInfo.shareMeta.includeHiddenField), + }, + { maxGroupPoints: this.thresholdConfig.maxGroupPoints } + ); + if (aggregationQuery.isErr()) this.throwDomainError(aggregationQuery.error); + const result = await queryBus.execute( + context, + aggregationQuery.value + ); + if (result.isErr()) this.throwDomainError(result.error); + return this.mapAggregationResult(result.value, query); + } + + async getGroupPoints( + shareInfo: IShareViewInfo, + query: IShareViewGroupPointsRo = {} + ): Promise { + if (!shareInfo.shareMeta?.includeRecords) return []; + const viewId = shareInfo.view?.id; + if (!viewId) return null; + const groupBy = query.groupBy?.slice(0, 3); + if (!groupBy?.length) return []; + + const { tableId } = shareInfo; + const container = await this.v2ContainerService.getContainerForTable(tableId); + const context = await this.v2ContextFactory.createContext(container); + const queryBus = container.resolve(v2CoreTokens.queryBus); + const filter = await this.normalizeFilter( + tableId, + query.filter, + context.actorId.toString(), + queryBus, + context + ); + const aggregationQuery = AggregateTableRecordsQuery.create( + { + tableId, + viewId, + filter, + search: query.search, + fields: [{ fieldId: groupBy[0].fieldId, statisticFunc: 'count' }], + groupBy, + includeHiddenFields: Boolean(shareInfo.shareMeta.includeHiddenField), + }, + { maxGroupPoints: this.thresholdConfig.maxGroupPoints } + ); + if (aggregationQuery.isErr()) this.throwDomainError(aggregationQuery.error); + const result = await queryBus.execute( + context, + aggregationQuery.value + ); + if (result.isErr()) this.throwDomainError(result.error); + const attachmentDecorator = container.resolve( + v2CoreTokens.attachmentValueDecoratorService + ); + return this.mapGroupPointsResult( + result.value, + new Set(query.collapsedGroupIds), + attachmentDecorator + ); + } + + async getCalendarDailyCollection( + shareInfo: IShareViewInfo, + query: IShareViewCalendarDailyCollectionRo + ): Promise { + if (!shareInfo.shareMeta?.includeRecords) return { countMap: {}, records: [] }; + const viewId = shareInfo.view?.id; + if (!viewId) { + throw new CustomHttpException('Shared view not found', HttpErrorCode.NOT_FOUND); + } + + const { tableId } = shareInfo; + const container = await this.v2ContainerService.getContainerForTable(tableId); + const context = await this.v2ContextFactory.createContext(container); + const queryBus = container.resolve(v2CoreTokens.queryBus); + const filter = await this.normalizeFilter( + tableId, + query.filter, + context.actorId.toString(), + queryBus, + context + ); + const calendarQuery = GetCalendarDailyCollectionQuery.create({ + tableId, + viewId, + startDate: query.startDate, + endDate: query.endDate, + startDateFieldId: query.startDateFieldId, + endDateFieldId: query.endDateFieldId, + filter, + search: query.search, + includeHiddenFields: Boolean(shareInfo.shareMeta.includeHiddenField), + }); + if (calendarQuery.isErr()) this.throwDomainError(calendarQuery.error); + const result = await queryBus.execute< + GetCalendarDailyCollectionQuery, + GetCalendarDailyCollectionResult + >(context, calendarQuery.value); + if (result.isErr()) this.throwDomainError(result.error); + + const records = result.value.records.map((record) => { + const dto = mapTableRecordToDto(record); + if (dto.isErr()) this.throwDomainError(dto.error); + return dto.value; + }); + return { countMap: { ...result.value.countMap }, records }; + } + + async getLinkRecords( + shareInfo: IShareViewInfo, + query: IShareViewLinkRecordsRo + ): Promise { + const viewId = shareInfo.view?.id; + if (!viewId) { + throw new CustomHttpException('Shared view not found', HttpErrorCode.NOT_FOUND); + } + + const { tableId } = shareInfo; + const container = await this.v2ContainerService.getContainerForTable(tableId); + const context = await this.v2ContextFactory.createContext(container); + const queryBus = container.resolve(v2CoreTokens.queryBus); + const planQuery = GetViewLinkRecordsQuery.create({ + tableId, + viewId, + fieldId: query.fieldId, + requestType: query.type, + includeHiddenFields: Boolean(shareInfo.shareMeta?.includeHiddenField), + search: query.search, + take: query.take ?? 100, + skip: query.skip ?? 0, + }); + if (planQuery.isErr()) this.throwDomainError(planQuery.error); + + const planResult = await queryBus.execute( + context, + planQuery.value + ); + if (planResult.isErr()) this.throwDomainError(planResult.error); + return [...planResult.value.records]; + } + + async getCollaborators( + shareInfo: IShareViewInfo, + query: IShareViewCollaboratorsRo, + canReadAllCollaborators: boolean + ): Promise { + const { tableId } = shareInfo; + const container = await this.v2ContainerService.getContainerForTable(tableId); + const context = await this.v2ContextFactory.createContext(container); + const queryBus = container.resolve(v2CoreTokens.queryBus); + const collaboratorsQuery = GetViewCollaboratorsQuery.create({ + tableId, + viewId: shareInfo.view?.id, + fieldId: query.fieldId, + includeHiddenFields: Boolean(shareInfo.shareMeta?.includeHiddenField), + canReadAllCollaborators, + search: query.search, + take: query.take ?? 50, + skip: query.skip ?? 0, + }); + if (collaboratorsQuery.isErr()) this.throwDomainError(collaboratorsQuery.error); + const result = await queryBus.execute( + context, + collaboratorsQuery.value + ); + if (result.isErr()) this.throwDomainError(result.error); + return [...result.value.collaborators]; + } + + async getCopy( + shareInfo: IShareViewInfo, + query: IShareViewCopyQuery, + canCopyAsEditor: boolean + ): Promise { + const viewId = shareInfo.view?.id; + if (!viewId) { + throw new CustomHttpException('Shared view not found', HttpErrorCode.NOT_FOUND); + } + + const { tableId } = shareInfo; + const container = await this.v2ContainerService.getContainerForTable(tableId); + const context = await this.v2ContextFactory.createContext(container); + const queryBus = container.resolve(v2CoreTokens.queryBus); + const filter = await this.normalizeFilter( + tableId, + query.filter, + context.actorId.toString(), + queryBus, + context + ); + const collapsedGroupIds = await this.resolveCopyCollapsedGroupIds(query); + const copyQuery = GetViewSelectionCopyQuery.create( + { + tableId, + viewId, + canCopyAsEditor, + ranges: query.ranges, + type: query.type, + projection: query.projection, + filter, + orderBy: query.orderBy, + groupBy: query.groupBy, + search: query.search, + collapsedGroupIds, + }, + { + maxCopyCells: this.thresholdConfig.maxCopyCells, + maxGroupPoints: this.thresholdConfig.maxGroupPoints, + } + ); + if (copyQuery.isErr()) this.throwDomainError(copyQuery.error); + const result = await queryBus.execute( + context, + copyQuery.value + ); + if (result.isErr()) this.throwDomainError(result.error); + + const header: IFieldVo[] = result.value.fields.map((field) => { + const fieldDto = mapFieldToDto(field, result.value.primaryFieldId); + if (fieldDto.isErr()) this.throwDomainError(fieldDto.error); + return fieldDto.value as IFieldVo; + }); + return { content: result.value.content, header }; + } + + private async resolveCopyCollapsedGroupIds( + query: IShareViewCopyQuery + ): Promise | undefined> { + if (!query.queryId) return query.collapsedGroupIds; + + const cache = await this.cacheService.get(`query-params:${query.queryId}`); + if (!cache) return query.collapsedGroupIds; + const nestedQueryParams = + cache.queryParams != null && + typeof cache.queryParams === 'object' && + !Array.isArray(cache.queryParams) + ? (cache.queryParams as Record) + : undefined; + const collapsedGroupIds = (nestedQueryParams ?? cache).collapsedGroupIds; + return Array.isArray(collapsedGroupIds) && + collapsedGroupIds.every((groupId): groupId is string => typeof groupId === 'string') + ? collapsedGroupIds + : query.collapsedGroupIds; + } + + private mapAggregationResult( + result: AggregateTableRecordsResult, + query: IShareViewAggregationsRo + ): IAggregationVo { + const aggregations: NonNullable = result.values + .filter((value) => value.groupValues === undefined) + .map((value) => ({ + fieldId: value.fieldId.toString(), + total: { value: value.value, aggFunc: value.statisticFunc as StatisticsFunc }, + })); + const aggregationByKey = new Map( + aggregations.map((aggregation) => [ + `${aggregation.fieldId}:${aggregation.total!.aggFunc}`, + aggregation, + ]) + ); + + for (const value of result.values) { + if (!value.groupValues?.length) continue; + const currentGroup = query.groupBy?.[value.groupValues.length - 1]; + if (!currentGroup) continue; + const groupValue = value.groupValues.map(convertValueToStringify).join('_'); + const groupId = String(string2Hash(`${currentGroup.fieldId}_${groupValue}`)); + const aggregation = aggregationByKey.get( + `${value.fieldId.toString()}:${value.statisticFunc}` + ); + if (!aggregation) continue; + aggregation.group ??= {}; + aggregation.group[groupId] = { + value: value.value, + aggFunc: value.statisticFunc as StatisticsFunc, + }; + } + + return { aggregations }; + } + + private async mapGroupPointsResult( + result: AggregateTableRecordsResult, + collapsedGroupIds: ReadonlySet, + attachmentDecorator: AttachmentValueDecoratorService + ): Promise { + const depth = result.groupBy.length; + if (!depth) return []; + const total = + Number( + result.values.find( + (value) => value.statisticFunc === 'count' && value.groupValues === undefined + )?.value + ) || 0; + const rows = result.values.filter( + (value) => value.statisticFunc === 'count' && value.groupValues?.length === depth + ); + const groupPoints: IGroupPoint[] = []; + const state: GroupPointMappingState = { + previousValues: Array.from({ length: depth }, () => Symbol()), + collapsedDepth: Number.MAX_SAFE_INTEGER, + }; + let groupedRowCount = 0; + + for (const row of rows) { + await this.appendGroupHeaders( + result, + row.groupValues!, + collapsedGroupIds, + attachmentDecorator, + state, + groupPoints + ); + + const count = Number(row.value) || 0; + groupedRowCount += count; + if (state.collapsedDepth === Number.MAX_SAFE_INTEGER) { + groupPoints.push({ type: GroupPointType.Row, count }); + } + } + + if (groupedRowCount < total) { + groupPoints.push( + { + id: 'unknown', + type: GroupPointType.Header, + depth: 0, + value: 'Unknown', + isCollapsed: false, + }, + { type: GroupPointType.Row, count: total - groupedRowCount } + ); + } + return groupPoints; + } + + private async appendGroupHeaders( + result: AggregateTableRecordsResult, + rawGroupValues: ReadonlyArray, + collapsedGroupIds: ReadonlySet, + attachmentDecorator: AttachmentValueDecoratorService, + state: GroupPointMappingState, + groupPoints: IGroupPoint[] + ): Promise { + for (let index = 0; index < rawGroupValues.length; index++) { + const rawValue = rawGroupValues[index]; + const stringifiedValue = convertValueToStringify(rawValue); + if (state.previousValues[index] === stringifiedValue) continue; + + const group = result.groupBy[index]!; + const groupId = String( + string2Hash( + `${group.fieldId.toString()}_${[ + ...state.previousValues.slice(0, index), + stringifiedValue, + ].join('_')}` + ) + ); + if (index > state.collapsedDepth) break; + + state.collapsedDepth = Number.MAX_SAFE_INTEGER; + state.previousValues[index] = stringifiedValue; + state.previousValues = state.previousValues.map((value, valueIndex) => + valueIndex > index ? Symbol() : value + ); + const isCollapsed = collapsedGroupIds.has(groupId); + const value = + group.fieldType === 'attachment' + ? await this.decorateAttachmentGroupValue(rawValue, attachmentDecorator) + : rawValue; + groupPoints.push({ + id: groupId, + type: GroupPointType.Header, + depth: index, + value, + isCollapsed, + }); + if (isCollapsed) state.collapsedDepth = index; + } + } + + private async decorateAttachmentGroupValue( + value: unknown, + attachmentDecorator: AttachmentValueDecoratorService + ): Promise { + const result = await attachmentDecorator.decorateAttachmentValue(value); + if (result.isErr()) this.throwDomainError(result.error); + return result.value; + } + + async getSearchCount(shareInfo: IShareViewInfo, query: ISearchCountRo): Promise { + if (!query.search) { + throw new CustomHttpException('Search query is required', HttpErrorCode.VALIDATION_ERROR, { + localization: { + i18nKey: 'httpErrors.aggregation.searchQueryRequired', + }, + }); + } + + const [searchValue, searchFieldKeys] = query.search; + const result = await this.getRowCount(shareInfo, { + filter: query.filter, + search: [searchValue, searchFieldKeys ?? '', true], + }); + return { count: result.rowCount }; + } + + async getSearchIndex( + shareInfo: IShareViewInfo, + query: ISearchIndexByQueryRo + ): Promise { + const [searchValue, searchFieldKeys, hideNotMatchRow] = this.validateSearchIndexQuery(query); + if (!shareInfo.shareMeta?.includeRecords) return null; + + const { tableId, view, linkOptions } = shareInfo; + const container = await this.v2ContainerService.getContainerForTable(tableId); + const context = await this.v2ContextFactory.createContext(container); + const queryBus = container.resolve(v2CoreTokens.queryBus); + const isLinkSelectionQuery = Boolean(linkOptions) && isLinkRecordSelectionQuery(query); + const viewId = isLinkSelectionQuery ? view?.id : linkOptions?.filterByViewId ?? view?.id; + const rawFilter = isLinkSelectionQuery ? undefined : query.filter ?? linkOptions?.filter; + const filter = await this.normalizeFilter( + tableId, + rawFilter, + context.actorId.toString(), + queryBus, + context + ); + const sort = [...(query.groupBy ?? []), ...(query.orderBy ?? [])].map((item) => ({ + fieldId: item.fieldId, + order: item.order, + })); + const listQuery = ListTableRecordsQuery.create( + { + tableId, + fieldKeyType: FieldKeyType.Id, + limit: query.take, + offset: query.skip ?? 0, + includeTotal: false, + search: [searchValue, searchFieldKeys ?? '', true], + viewId, + ignoreViewQuery: isLinkSelectionQuery || undefined, + filter, + sort: sort.length ? sort : undefined, + groupBy: query.groupBy?.map((item) => item.fieldId), + projection: query.projection, + filterLinkCellSelected: query.filterLinkCellSelected, + filterLinkCellCandidate: query.filterLinkCellCandidate, + selectedRecordIds: query.selectedRecordIds, + }, + { + includeSearchFieldMatches: true, + searchIndexMode: hideNotMatchRow ? 'matched' : 'view', + } + ); + if (listQuery.isErr()) this.throwDomainError(listQuery.error); + const result = await queryBus.execute( + context, + listQuery.value + ); + if (result.isErr()) this.throwDomainError(result.error); + return this.mapSearchIndexResult(result.value.searchMatches); + } + + private validateSearchIndexQuery(query: ISearchIndexByQueryRo) { + if (query.take > 1000) { + throw new CustomHttpException( + 'The maximum search index result is 1000', + HttpErrorCode.VALIDATION_ERROR, + { + localization: { + i18nKey: 'httpErrors.aggregation.maxSearchIndexResult', + }, + } + ); + } + if (!query.search) { + throw new CustomHttpException('Search query is required', HttpErrorCode.VALIDATION_ERROR, { + localization: { + i18nKey: 'httpErrors.aggregation.searchQueryRequired', + }, + }); + } + return query.search; + } + + private mapSearchIndexResult(matches: ListTableRecordsResult['searchMatches']): ISearchIndexVo { + if (!matches?.length) return null; + return matches.map((match) => ({ + index: match.index, + fieldId: match.fieldId.toString(), + recordId: match.recordId.toString(), + })); + } + + // eslint-disable-next-line sonarjs/cognitive-complexity + async getRowCount( + shareInfo: IShareViewInfo, + query: IShareViewRowCountRo = {} + ): Promise { + const { tableId, view, linkOptions, shareMeta } = shareInfo; + if (!shareMeta?.includeRecords) { + return { rowCount: 0 }; + } + + const container = await this.v2ContainerService.getContainerForTable(tableId); + const context = await this.v2ContextFactory.createContext(container); + const queryBus = container.resolve(v2CoreTokens.queryBus); + + const isLinkSelectionQuery = Boolean(linkOptions) && isLinkRecordSelectionQuery(query); + const viewId = isLinkSelectionQuery ? view?.id : linkOptions?.filterByViewId ?? view?.id; + const rawFilter = isLinkSelectionQuery ? undefined : linkOptions?.filter ?? query.filter; + const filter = await this.normalizeFilter( + tableId, + rawFilter, + context.actorId.toString(), + queryBus, + context + ); + + const result = await executeListTableRecordsEndpoint( + context, + { + tableId, + fieldKeyType: FieldKeyType.Id, + limit: 1, + offset: 0, + projection: [], + includeTotal: true, + ...(viewId ? { viewId } : {}), + ...(isLinkSelectionQuery ? { ignoreViewQuery: true } : {}), + ...(filter ? { filter } : {}), + ...(query.search ? { search: query.search } : {}), + ...(query.filterLinkCellSelected + ? { filterLinkCellSelected: query.filterLinkCellSelected } + : {}), + ...(query.filterLinkCellCandidate + ? { filterLinkCellCandidate: query.filterLinkCellCandidate } + : {}), + ...(query.selectedRecordIds?.length ? { selectedRecordIds: query.selectedRecordIds } : {}), + }, + queryBus + ); + + if (result.status === 200 && result.body.ok) { + return { rowCount: result.body.data.pagination.total }; + } + if (!result.body.ok) { + this.throwV2Error(result.body.error, result.status); + } + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + + private async normalizeFilter( + tableId: string, + rawFilter: unknown, + actorId: string, + queryBus: IQueryBus, + context: Parameters[0] + ) { + if (rawFilter == null) return rawFilter; + + const queryResult = ListFieldsQuery.create({ tableId }); + if (queryResult.isErr()) { + this.throwDomainError(queryResult.error); + } + const fieldsResult = await queryBus.execute( + context, + queryResult.value + ); + if (fieldsResult.isErr()) { + this.throwDomainError(fieldsResult.error); + } + + const fieldMetaById = new Map(); + for (const field of fieldsResult.value.fields) { + const fieldDto = mapFieldToDto(field, fieldsResult.value.primaryFieldId); + if (fieldDto.isErr()) { + this.throwDomainError(fieldDto.error); + } + fieldMetaById.set(fieldDto.value.id, { + type: fieldDto.value.type, + cellValueType: 'cellValueType' in fieldDto.value ? fieldDto.value.cellValueType : undefined, + options: fieldDto.value.options, + }); + } + + const normalized = normalizeLegacyRecordFilterForV2(rawFilter, fieldMetaById, actorId); + if (normalized.isErr()) { + this.throwDomainError(normalized.error); + } + return normalized.value; + } + + private throwDomainError(error: Parameters[0]): never { + this.throwV2Error(mapDomainErrorToHttpError(error), mapDomainErrorToHttpStatus(error)); + } + + private throwV2Error( + error: { + code: string; + message: string; + tags?: ReadonlyArray; + details?: Readonly>; + }, + status: number + ): never { + throw new CustomHttpException(error.message, getDefaultCodeByStatus(status), { + domainCode: error.code, + domainTags: error.tags, + details: error.details, + }); + } +} diff --git a/apps/nestjs-backend/src/features/share/strategies/jwt.strategy.ts b/apps/nestjs-backend/src/features/share/strategies/jwt.strategy.ts index 273099ba23..760def54b8 100644 --- a/apps/nestjs-backend/src/features/share/strategies/jwt.strategy.ts +++ b/apps/nestjs-backend/src/features/share/strategies/jwt.strategy.ts @@ -1,11 +1,9 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { ConfigType } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import cookie from 'cookie'; import type { Request } from 'express'; import { ExtractJwt, Strategy } from 'passport-jwt'; -import type { authConfig } from '../../../configs/auth.config'; -import { AuthConfig } from '../../../configs/auth.config'; +import { TeableJwtService } from '../../auth/jwt/teable-jwt.service'; import { SHARE_JWT_STRATEGY } from '../guard/constant'; import { ShareAuthService } from '../share-auth.service'; import type { IJwtShareInfo } from '../share.service'; @@ -13,13 +11,14 @@ import type { IJwtShareInfo } from '../share.service'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, SHARE_JWT_STRATEGY) { constructor( - @AuthConfig() readonly config: ConfigType, + teableJwtService: TeableJwtService, private readonly shareAuthService: ShareAuthService ) { super({ jwtFromRequest: ExtractJwt.fromExtractors([JwtStrategy.fromAuthCookieAsToken]), ignoreExpiration: false, - secretOrKey: config.jwt.secret, + passReqToCallback: true, + secretOrKeyProvider: teableJwtService.passportSecretProvider(), }); } @@ -29,9 +28,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, SHARE_JWT_STRATEGY) return cookieObj?.[shareId] ?? null; } - async validate(payload: IJwtShareInfo) { + async validate(req: Request & { useV2?: boolean }, payload: IJwtShareInfo) { const { shareId, password } = payload; - const authShareId = await this.shareAuthService.authShareView(shareId, password); + const authShareId = await this.shareAuthService.authShareView( + shareId, + password, + req.useV2 === true + ); if (!authShareId) { throw new UnauthorizedException(); } diff --git a/apps/nestjs-backend/src/features/space/data-db-preflight.service.spec.ts b/apps/nestjs-backend/src/features/space/data-db-preflight.service.spec.ts index 9cb0b6eb65..c8d3b6fbab 100644 --- a/apps/nestjs-backend/src/features/space/data-db-preflight.service.spec.ts +++ b/apps/nestjs-backend/src/features/space/data-db-preflight.service.spec.ts @@ -111,12 +111,14 @@ const BASELINE_TABLES = [ 'computed_update_outbox_seed', 'computed_update_dead_letter', 'computed_update_pause_scope', + 'computed_update_stage_ledger', 'computed_field_activity', 'computed_table_activity', 'computed_task_field_ref', 'record_history', 'table_trash', 'record_trash', + 'record_removal_tombstone', '__undo_log', 'attachments', 'attachments_table', diff --git a/apps/nestjs-backend/src/features/space/data-db-preflight.service.ts b/apps/nestjs-backend/src/features/space/data-db-preflight.service.ts index 46d0550552..b6c162aea8 100644 --- a/apps/nestjs-backend/src/features/space/data-db-preflight.service.ts +++ b/apps/nestjs-backend/src/features/space/data-db-preflight.service.ts @@ -49,12 +49,14 @@ const DATA_PLANE_TABLES = [ 'computed_update_outbox_seed', 'computed_update_dead_letter', 'computed_update_pause_scope', + 'computed_update_stage_ledger', 'computed_field_activity', 'computed_table_activity', 'computed_task_field_ref', 'record_history', 'table_trash', 'record_trash', + 'record_removal_tombstone', '__undo_log', 'attachments', 'attachments_table', diff --git a/apps/nestjs-backend/src/features/space/data-db-url-secret.ts b/apps/nestjs-backend/src/features/space/data-db-url-secret.ts index 799478c0b7..0a81a2a970 100644 --- a/apps/nestjs-backend/src/features/space/data-db-url-secret.ts +++ b/apps/nestjs-backend/src/features/space/data-db-url-secret.ts @@ -5,23 +5,40 @@ type IDataDbUrlSecret = { url: string; }; +const requirePresent = (value: string | undefined, envKey: string): string => { + if (!value) { + throw new Error(`Missing secret configuration: set ${envKey} or SECRET_KEY`); + } + return value; +}; + +// Legacy SECRET_KEY derivation, kept verbatim for ciphertext compatibility: +// existing deployments without a dedicated key encrypted their BYODB URLs +// under sha256(SECRET_KEY) — note key and iv derive from the SAME input here, +// a historical quirk that must not change while such ciphertext exists. +const legacyDerived = () => + process.env.SECRET_KEY + ? createHash('sha256').update(process.env.SECRET_KEY).digest('hex').slice(0, 16) + : undefined; + +// Resolution order is unchanged from before (dedicated key, then the +// access-token key — a historical coupling, see T6475 — then SECRET_KEY +// derivation); only the public literal defaults are gone from source. const getDataDbUrlEncryptor = () => new Encryptor({ algorithm: process.env.BACKEND_DATA_DB_URL_ENCRYPTION_ALGORITHM ?? 'aes-128-cbc', - key: + key: requirePresent( process.env.BACKEND_DATA_DB_URL_ENCRYPTION_KEY ?? - process.env.BACKEND_ACCESS_TOKEN_ENCRYPTION_KEY ?? - createHash('sha256') - .update(process.env.SECRET_KEY ?? 'teable-data-db-url-secret') - .digest('hex') - .slice(0, 16), - iv: + process.env.BACKEND_ACCESS_TOKEN_ENCRYPTION_KEY ?? + legacyDerived(), + 'BACKEND_DATA_DB_URL_ENCRYPTION_KEY' + ), + iv: requirePresent( process.env.BACKEND_DATA_DB_URL_ENCRYPTION_IV ?? - process.env.BACKEND_ACCESS_TOKEN_ENCRYPTION_IV ?? - createHash('sha256') - .update(process.env.SECRET_KEY ?? 'teable-data-db-url-secret-iv') - .digest('hex') - .slice(0, 16), + process.env.BACKEND_ACCESS_TOKEN_ENCRYPTION_IV ?? + legacyDerived(), + 'BACKEND_DATA_DB_URL_ENCRYPTION_IV' + ), }); export const encryptDataDbUrl = (url: string) => getDataDbUrlEncryptor().encrypt({ url }); diff --git a/apps/nestjs-backend/src/features/space/space-data-db-admin-gate.test.ts b/apps/nestjs-backend/src/features/space/space-data-db-admin-gate.test.ts index 795092f093..9285c742e0 100644 --- a/apps/nestjs-backend/src/features/space/space-data-db-admin-gate.test.ts +++ b/apps/nestjs-backend/src/features/space/space-data-db-admin-gate.test.ts @@ -72,7 +72,8 @@ describe('SpaceController data DB admin gate', () => { dataDbPreflightService as never, dataDbBindingService as never, cls as never, - spaceDataDbMigrationService as never + spaceDataDbMigrationService as never, + {} as never ); }); diff --git a/apps/nestjs-backend/src/features/space/space-data-db-copy-plan.spec.ts b/apps/nestjs-backend/src/features/space/space-data-db-copy-plan.spec.ts index 1bdc014ec3..094846a005 100644 --- a/apps/nestjs-backend/src/features/space/space-data-db-copy-plan.spec.ts +++ b/apps/nestjs-backend/src/features/space/space-data-db-copy-plan.spec.ts @@ -451,21 +451,31 @@ describe('space data DB copy plan', () => { 'record_history', 'table_trash', 'record_trash', - 'computed_update_pause_scope', 'computed_update_outbox', 'computed_update_dead_letter', 'computed_update_outbox_seed', '__undo_log', + 'record_removal_tombstone', ]); expect(plans[0].sourceSql).toContain(`"table_id" = ANY(ARRAY['tblxxx', 'tblyyy']::text[])`); - expect(plans[3].sourceSql).toContain(`"scope_id" = ANY(ARRAY['spc''x']::text[])`); - expect(plans[4].sourceSql).toContain(`"base_id" = ANY(ARRAY['bsexxx', 'bseyyy']::text[])`); - expect(plans[6].sourceSql).toContain( + expect(plans[3].sourceSql).toContain(`"base_id" = ANY(ARRAY['bsexxx', 'bseyyy']::text[])`); + expect(plans[5].sourceSql).toContain( 'FROM "public"."computed_update_outbox" WHERE "base_id" = ANY' ); - expect(plans[7].sourceSql).toContain( + const outboxSeedTargetResetSql = String(plans[5].targetReset?.args.at(-2)); + expect(outboxSeedTargetResetSql).toContain( + 'FROM "teable_meta_target"."computed_update_outbox" WHERE "base_id" = ANY' + ); + expect(outboxSeedTargetResetSql).not.toContain('FROM "public"."computed_update_outbox"'); + expect(plans[6].sourceSql).toContain( `split_part("table_name", '.', 1) = ANY(ARRAY['bsexxx', 'bseyyy']::text[])` ); + expect(plans[7].sourceSql).toContain(`"table_id" = ANY(ARRAY['tblxxx', 'tblyyy']::text[])`); + expect( + plans.every((plan) => + plan.targetReset?.args.some((arg) => String(arg).includes('DELETE FROM')) + ) + ).toBe(true); expect(plans.every((plan) => plan.source.args.includes(sourceUrl))).toBe(true); expect(plans.every((plan) => plan.target.args.includes(targetUrl))).toBe(true); }); @@ -491,12 +501,35 @@ describe('space data DB copy plan', () => { expect(plans.find((plan) => plan.table === 'record_trash')?.sourceSql).toContain( `"table_id" = ANY(ARRAY['tblactive', 'tbldeleted']::text[])` ); + expect(plans.find((plan) => plan.table === 'record_removal_tombstone')?.sourceSql).toContain( + `"table_id" = ANY(ARRAY['tblactive', 'tbldeleted']::text[])` + ); expect(plans.find((plan) => plan.table === 'computed_update_outbox_seed')?.sourceSql).toContain( `"table_id" = ANY(ARRAY['tblactive']::text[])` ); + expect(plans.find((plan) => plan.table === 'computed_update_pause_scope')).toBeUndefined(); + }); + + it('can still include pause scopes for base-move style copies', () => { + const plans = buildMigrationSharedTablePsqlCopyPlans({ + sourceUrl, + targetUrl, + sourceSchema: 'public', + targetSchema: 'teable_meta_target', + spaceId: 'spcxxx', + baseIds: ['bsexxx'], + tableIds: ['tblactive'], + sharedTableIds: ['tblactive', 'tbldeleted'], + includePauseScopes: true, + includeSpacePauseScopes: false, + }); + expect(plans.find((plan) => plan.table === 'computed_update_pause_scope')?.sourceSql).toContain( `"scope_id" = ANY(ARRAY['tblactive', 'tbldeleted']::text[])` ); + expect( + plans.find((plan) => plan.table === 'computed_update_pause_scope')?.sourceSql + ).not.toContain(`"scope_type" = 'space'`); }); it('builds scoped postgres_fdw plans for all migration shared tables', () => { @@ -516,15 +549,21 @@ describe('space data DB copy plan', () => { 'record_history', 'table_trash', 'record_trash', - 'computed_update_pause_scope', 'computed_update_outbox', 'computed_update_dead_letter', 'computed_update_outbox_seed', '__undo_log', + 'record_removal_tombstone', ]); expect(plans[0].sql).toContain('FROM "sdmjxxx_fdw_0"."record_history"'); - expect(plans[3].sql).toContain(`"scope_id" = ANY(ARRAY['spc''x']::text[])`); - expect(plans[6].sql).toContain('FROM "sdmjxxx_fdw_6"."computed_update_outbox"'); + expect(plans[0].sql).toContain('DELETE FROM "teable_meta_target"."record_history"'); + expect(plans[3].sql).toContain('FROM "sdmjxxx_fdw_3"."computed_update_outbox"'); + expect(plans[5].sql).toContain( + 'DELETE FROM "teable_meta_target"."computed_update_outbox_seed" WHERE "table_id" = ANY' + ); + expect(plans[5].sql).toContain( + 'FROM "teable_meta_target"."computed_update_outbox" WHERE "base_id" = ANY' + ); expect(plans.every((plan) => plan.target.args.includes(targetUrl))).toBe(true); }); diff --git a/apps/nestjs-backend/src/features/space/space-data-db-copy-plan.ts b/apps/nestjs-backend/src/features/space/space-data-db-copy-plan.ts index d0541d2844..4d7cdeb3ed 100644 --- a/apps/nestjs-backend/src/features/space/space-data-db-copy-plan.ts +++ b/apps/nestjs-backend/src/features/space/space-data-db-copy-plan.ts @@ -40,6 +40,8 @@ export type ISharedTablePsqlCopyPlan = ISpaceDataDbProcessPipelinePlan & { table: string; sourceSql: string; targetSql: string; + /** Idempotent scoped delete before COPY so per-table retries restart cleanly. */ + targetReset?: ISpaceDataDbProcessPlan; }; export type ISharedTablePostgresFdwCopyPlan = { @@ -360,10 +362,14 @@ export const buildSharedTablePsqlCopyPlan = (input: { table: string; columns: string[]; whereSql: string; + targetWhereSql?: string; snapshotId?: string; }): ISharedTablePsqlCopyPlan => { const plan = buildSharedTableCopyPlan(input); const sourceSql = withExportedSnapshot(plan.sourceSql, input.snapshotId); + const targetResetSql = `DELETE FROM ${qualify(input.targetSchema, input.table)} WHERE ${ + input.targetWhereSql ?? input.whereSql + }`; return { table: input.table, label: `shared-table:${input.table}`, @@ -377,6 +383,10 @@ export const buildSharedTablePsqlCopyPlan = (input: { command: 'psql', args: psqlArgs(input.targetUrl, plan.targetSql), }, + targetReset: { + command: 'psql', + args: psqlArgs(input.targetUrl, targetResetSql), + }, }; }; @@ -388,6 +398,7 @@ export const buildSharedTablePostgresFdwCopyPlan = (input: { table: string; columns: string[]; whereSql: string; + targetWhereSql?: string; fdwSchema: string; serverName: string; }): ISharedTablePostgresFdwCopyPlan => { @@ -429,6 +440,8 @@ export const buildSharedTablePostgresFdwCopyPlan = (input: { `IMPORT FOREIGN SCHEMA ${quoteIdent(input.sourceSchema)} LIMIT TO (${importLimit}) FROM SERVER ${quoteIdent( input.serverName )} INTO ${quoteIdent(input.fdwSchema)}`, + // Scoped delete keeps FDW inserts restart-safe across per-table retries. + `DELETE FROM ${targetTable} WHERE ${input.targetWhereSql ?? input.whereSql}`, `INSERT INTO ${targetTable} (${columns}) SELECT ${columns} FROM ${foreignTable} WHERE ${input.whereSql}`, `DROP SERVER ${quoteIdent(input.serverName)} CASCADE`, `DROP SCHEMA ${quoteIdent(input.fdwSchema)} CASCADE`, @@ -490,11 +503,18 @@ const recordHistoryColumns = [ const buildMigrationSharedTableDefinitions = (input: { sourceSchema: string; + targetSchema: string; spaceId: string; spaceIds?: string[]; baseIds: string[]; tableIds: string[]; sharedTableIds?: string[]; + /** + * When true, include computed_update_pause_scope. + * Defaults to false: space migration must not copy source pause scopes or + * they freeze computed updates on the target after switch. Base moves opt in. + */ + includePauseScopes?: boolean; /** When false, only base/table pause scopes are copied (base move). Default true. */ includeSpacePauseScopes?: boolean; }) => { @@ -509,19 +529,21 @@ const buildMigrationSharedTableDefinitions = (input: { 'computed_update_outbox' )} WHERE ${basePredicate})`, ].join(' AND '); - const pauseScopeParts = [ - `("scope_type" = 'base' AND "scope_id" = ANY(${textArray(input.baseIds)}))`, - `("scope_type" = 'table' AND "scope_id" = ANY(${textArray(sharedTableIds)}))`, - ]; - if (input.includeSpacePauseScopes !== false) { - pauseScopeParts.unshift( - `("scope_type" = 'space' AND "scope_id" = ANY(${textArray(spaceIds)}))` - ); - } - const pauseScopePredicate = pauseScopeParts.join(' OR '); + const targetOutboxSeedPredicate = [ + textArrayPredicate('table_id', input.tableIds), + `"task_id" IN (SELECT "id" FROM ${qualify( + input.targetSchema, + 'computed_update_outbox' + )} WHERE ${basePredicate})`, + ].join(' AND '); const undoPredicate = `split_part("table_name", '.', 1) = ANY(${textArray(input.baseIds)})`; - return [ + const definitions: Array<{ + table: string; + columns: string[]; + whereSql: string; + targetWhereSql?: string; + }> = [ { table: 'record_history', columns: recordHistoryColumns, @@ -534,10 +556,35 @@ const buildMigrationSharedTableDefinitions = (input: { }, { table: 'record_trash', - columns: ['id', 'table_id', 'record_id', 'snapshot', 'created_time', 'created_by'], + columns: [ + 'id', + 'table_id', + 'record_id', + 'snapshot', + 'created_time', + 'created_by', + 'reason', + 'record_created_time', + 'record_created_by', + 'record_last_modified_time', + 'record_last_modified_by', + 'operation_id', + ], whereSql: tablePredicate, }, - { + ]; + + if (input.includePauseScopes === true) { + const pauseScopeParts = [ + `("scope_type" = 'base' AND "scope_id" = ANY(${textArray(input.baseIds)}))`, + `("scope_type" = 'table' AND "scope_id" = ANY(${textArray(sharedTableIds)}))`, + ]; + if (input.includeSpacePauseScopes !== false) { + pauseScopeParts.unshift( + `("scope_type" = 'space' AND "scope_id" = ANY(${textArray(spaceIds)}))` + ); + } + definitions.push({ table: 'computed_update_pause_scope', columns: [ 'id', @@ -550,8 +597,11 @@ const buildMigrationSharedTableDefinitions = (input: { 'updated_at', 'updated_by', ], - whereSql: pauseScopePredicate, - }, + whereSql: pauseScopeParts.join(' OR '), + }); + } + + definitions.push( { table: 'computed_update_outbox', columns: computedOutboxColumns, @@ -566,6 +616,7 @@ const buildMigrationSharedTableDefinitions = (input: { table: 'computed_update_outbox_seed', columns: ['id', 'task_id', 'table_id', 'record_id'], whereSql: outboxSeedPredicate, + targetWhereSql: targetOutboxSeedPredicate, }, { table: '__undo_log', @@ -581,7 +632,14 @@ const buildMigrationSharedTableDefinitions = (input: { ], whereSql: undoPredicate, }, - ]; + { + table: 'record_removal_tombstone', + columns: ['id', 'table_id', 'record_id', 'type', 'created_time'], + whereSql: tablePredicate, + } + ); + + return definitions; }; export const buildMigrationSharedTablePsqlCopyPlans = (input: { @@ -595,6 +653,7 @@ export const buildMigrationSharedTablePsqlCopyPlans = (input: { tableIds: string[]; sharedTableIds?: string[]; snapshotId?: string; + includePauseScopes?: boolean; includeSpacePauseScopes?: boolean; }): ISharedTablePsqlCopyPlan[] => { const shared = buildMigrationSharedTableDefinitions(input); @@ -608,6 +667,7 @@ export const buildMigrationSharedTablePsqlCopyPlans = (input: { table: item.table, columns: item.columns, whereSql: item.whereSql, + targetWhereSql: item.targetWhereSql, snapshotId: input.snapshotId, }) ); @@ -625,6 +685,7 @@ export const buildMigrationSharedTablePostgresFdwCopyPlans = (input: { sharedTableIds?: string[]; fdwSchemaPrefix: string; serverNamePrefix: string; + includePauseScopes?: boolean; includeSpacePauseScopes?: boolean; }): ISharedTablePostgresFdwCopyPlan[] => { const shared = buildMigrationSharedTableDefinitions(input); @@ -640,6 +701,7 @@ export const buildMigrationSharedTablePostgresFdwCopyPlans = (input: { table: item.table, columns: item.columns, whereSql: item.whereSql, + targetWhereSql: item.targetWhereSql, fdwSchema, serverName: `${input.serverNamePrefix}_${index}`, }); diff --git a/apps/nestjs-backend/src/features/space/space-data-db-copy.integration.spec.ts b/apps/nestjs-backend/src/features/space/space-data-db-copy.integration.spec.ts index 6b2800326a..06b982be42 100644 --- a/apps/nestjs-backend/src/features/space/space-data-db-copy.integration.spec.ts +++ b/apps/nestjs-backend/src/features/space/space-data-db-copy.integration.spec.ts @@ -172,7 +172,22 @@ const createSharedTables = async (client: Client, schema: string) => { "record_id" text, "snapshot" jsonb, "created_time" timestamp, - "created_by" text + "created_by" text, + "reason" text, + "record_created_time" timestamp, + "record_created_by" text, + "record_last_modified_time" timestamp, + "record_last_modified_by" text, + "operation_id" text + ) + `); + await client.query(` + CREATE TABLE "${schema}"."record_removal_tombstone" ( + "id" text PRIMARY KEY, + "table_id" text, + "record_id" text, + "type" text, + "created_time" timestamp ) `); await client.query(` @@ -360,9 +375,17 @@ const seedSourceData = async (client: Client) => { [tableId] ); await client.query( - `INSERT INTO "public"."record_trash" VALUES - ('rt1', $1, 'rec1', '{}'::jsonb, now(), 'usr'), - ('rt2', 'tblother', 'rec9', '{}'::jsonb, now(), 'usr')`, + `INSERT INTO "public"."record_trash" + ("id", "table_id", "record_id", "snapshot", "created_time", "created_by", "reason", "operation_id") + VALUES + ('rt1', $1, 'rec1', '{}'::jsonb, now(), 'usr', 'archived', 'opr1'), + ('rt2', 'tblother', 'rec9', '{}'::jsonb, now(), 'usr', 'deleted', 'opr2')`, + [tableId] + ); + await client.query( + `INSERT INTO "public"."record_removal_tombstone" VALUES + ('rmt1', $1, 'rec1', 'restored', now()), + ('rmt2', 'tblother', 'rec9', 'purged', now())`, [tableId] ); await client.query( @@ -671,10 +694,10 @@ describeWithPostgres('SpaceDataDbCopyService integration', () => { expect.objectContaining({ table: 'record_history', copiedRows: null }), expect.objectContaining({ table: 'table_trash', copiedRows: null }), expect.objectContaining({ table: 'record_trash', copiedRows: null }), + expect.objectContaining({ table: 'record_removal_tombstone', copiedRows: 1 }), expect.objectContaining({ table: 'computed_update_outbox', copiedRows: null }), expect.objectContaining({ table: 'computed_update_dead_letter', copiedRows: null }), expect.objectContaining({ table: 'computed_update_outbox_seed', copiedRows: null }), - expect.objectContaining({ table: 'computed_update_pause_scope', copiedRows: null }), expect.objectContaining({ table: '__undo_log', copiedRows: null }), ]) ); @@ -805,6 +828,26 @@ describeWithPostgres('SpaceDataDbCopyService integration', () => { `SELECT COUNT(*) AS count FROM "${targetSchema}"."record_trash" WHERE "table_id" = 'tblother'` ) ).resolves.toBe(0); + await expect( + queryCount( + target, + `SELECT COUNT(*) AS count FROM "${targetSchema}"."record_trash" WHERE "table_id" = $1 AND "reason" = 'archived' AND "operation_id" = 'opr1'`, + [tableId] + ) + ).resolves.toBe(1); + await expect( + queryCount( + target, + `SELECT COUNT(*) AS count FROM "${targetSchema}"."record_removal_tombstone" WHERE "table_id" = $1 AND "type" = 'restored'`, + [tableId] + ) + ).resolves.toBe(1); + await expect( + queryCount( + target, + `SELECT COUNT(*) AS count FROM "${targetSchema}"."record_removal_tombstone" WHERE "table_id" = 'tblother'` + ) + ).resolves.toBe(0); await expect( queryCount( target, @@ -845,12 +888,13 @@ describeWithPostgres('SpaceDataDbCopyService integration', () => { `SELECT COUNT(*) AS count FROM "${targetSchema}"."computed_update_outbox_seed" WHERE "task_id" = 'cuo2'` ) ).resolves.toBe(0); + // Space migration intentionally does not copy source pause scopes. await expect( queryCount( target, `SELECT COUNT(*) AS count FROM "${targetSchema}"."computed_update_pause_scope"` ) - ).resolves.toBe(3); + ).resolves.toBe(0); await expect( queryCount( target, diff --git a/apps/nestjs-backend/src/features/space/space-data-db-copy.service.spec.ts b/apps/nestjs-backend/src/features/space/space-data-db-copy.service.spec.ts index d0ae1147b0..d9914cb96e 100644 --- a/apps/nestjs-backend/src/features/space/space-data-db-copy.service.spec.ts +++ b/apps/nestjs-backend/src/features/space/space-data-db-copy.service.spec.ts @@ -596,4 +596,54 @@ describe('SpaceDataDbCopyService', () => { 2 ); }); + + it('retries a failed shared table COPY after resetting target rows', async () => { + vi.useFakeTimers(); + processRunner.run.mockResolvedValue({ + command: 'psql', + args: [], + exitCode: 0, + signal: null, + stderr: '', + stdout: 'DELETE 1', + startedAt: '2026-05-06T00:00:00.000Z', + completedAt: '2026-05-06T00:00:01.000Z', + durationMs: 1000, + }); + processRunner.runPipeline + .mockRejectedValueOnce(new Error('source stream broke')) + .mockResolvedValueOnce({ + source: { command: 'psql', args: [], exitCode: 0, signal: null, stderr: '', stdout: '' }, + target: { + command: 'psql', + args: [], + exitCode: 0, + signal: null, + stderr: '', + stdout: 'COPY 4\n', + }, + }); + const service = new SpaceDataDbCopyService(processRunner as never); + + const promise = service.copySharedTable( + { + table: 'record_trash', + sourceSql: 'COPY source trash TO STDOUT', + targetSql: 'COPY target trash FROM STDIN', + source: { command: psqlCommand, args: ['trash-source'] }, + target: { command: psqlCommand, args: trashTargetArgs }, + targetReset: { command: psqlCommand, args: ['trash-reset'] }, + }, + { timeoutMs: 10_000 } + ); + + await vi.advanceTimersByTimeAsync(2500); + await expect(promise).resolves.toMatchObject({ table: 'record_trash', copiedRows: 4 }); + expect(processRunner.runPipeline).toHaveBeenCalledTimes(2); + expect(processRunner.run).toHaveBeenCalledWith( + { command: psqlCommand, args: ['trash-reset'] }, + { timeoutMs: 10_000 } + ); + vi.useRealTimers(); + }); }); diff --git a/apps/nestjs-backend/src/features/space/space-data-db-copy.service.ts b/apps/nestjs-backend/src/features/space/space-data-db-copy.service.ts index ced9a9c3ea..64c4bc970a 100644 --- a/apps/nestjs-backend/src/features/space/space-data-db-copy.service.ts +++ b/apps/nestjs-backend/src/features/space/space-data-db-copy.service.ts @@ -1,5 +1,6 @@ import { mkdir, writeFile } from 'fs/promises'; import path from 'path'; +import { setTimeout as delay } from 'timers/promises'; import { Injectable } from '@nestjs/common'; import { buildBaseSchemaDumpRestorePlan, @@ -23,6 +24,7 @@ import { export const REQUIRED_POSTGRES_COPY_TOOLS = ['pg_dump', 'pg_restore', 'psql'] as const; export const REQUIRED_PGCOPYDB_COPY_TOOLS = [...REQUIRED_POSTGRES_COPY_TOOLS, 'pgcopydb'] as const; export const PG_RESTORE_LIST_STDOUT_LIMIT = 64 * 1024 * 1024; +const sharedTableCopyMaxAttempts = 3; export type ISpaceDataDbBaseSchemaCopyStrategy = | 'pg_dump_restore' @@ -174,6 +176,25 @@ export const filterPgRestoreListForForeignKeys = ( export class SpaceDataDbCopyService { constructor(private readonly processRunner: SpaceDataDbProcessRunnerService) {} + private async retrySharedTableCopy( + copy: (attempt: number) => Promise, + processOptions?: ISpaceDataDbProcessRunOptions + ): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= sharedTableCopyMaxAttempts; attempt++) { + try { + return await copy(attempt); + } catch (error) { + lastError = error; + if (attempt >= sharedTableCopyMaxAttempts || (await processOptions?.shouldCancel?.())) { + throw error; + } + await delay(2000 * attempt); + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); + } + async assertPostgresToolsAvailable( strategy: ISpaceDataDbBaseSchemaCopyStrategy = 'pg_dump_restore', processOptions?: ISpaceDataDbProcessRunOptions @@ -290,18 +311,23 @@ export class SpaceDataDbCopyService { restore, }; } - async copySharedTable( plan: ISharedTablePsqlCopyPlan, processOptions?: ISpaceDataDbProcessRunOptions ): Promise { - const result = await this.processRunner.runPipeline(plan, processOptions); - return { - strategy: 'psql_copy', - table: plan.table, - copiedRows: parsePsqlCopyRowCount(`${result.target.stdout}\n${result.target.stderr}`), - ...result, - }; + return this.retrySharedTableCopy(async (attempt) => { + if (attempt > 1 && plan.targetReset) { + // Clear any partial target rows from the previous interrupted COPY. + await this.processRunner.run(plan.targetReset, processOptions); + } + const result = await this.processRunner.runPipeline(plan, processOptions); + return { + strategy: 'psql_copy', + table: plan.table, + copiedRows: parsePsqlCopyRowCount(`${result.target.stdout}\n${result.target.stderr}`), + ...result, + }; + }, processOptions); } async copySharedTables( @@ -322,13 +348,16 @@ export class SpaceDataDbCopyService { plan: ISharedTablePostgresFdwCopyPlan, processOptions?: ISpaceDataDbProcessRunOptions ): Promise { - const target = await this.processRunner.run(plan.target, processOptions); - return { - strategy: 'postgres_fdw', - table: plan.table, - copiedRows: parsePsqlInsertRowCount(`${target.stdout}\n${target.stderr}`), - target, - }; + return this.retrySharedTableCopy(async () => { + // FDW plan deletes scoped target rows inside the transaction. + const target = await this.processRunner.run(plan.target, processOptions); + return { + strategy: 'postgres_fdw', + table: plan.table, + copiedRows: parsePsqlInsertRowCount(`${target.stdout}\n${target.stderr}`), + target, + }; + }, processOptions); } async copySharedTablesViaPostgresFdw( diff --git a/apps/nestjs-backend/src/features/space/space-data-db-migration-guard.service.spec.ts b/apps/nestjs-backend/src/features/space/space-data-db-migration-guard.service.spec.ts index f4ca179d1d..abcd62e851 100644 --- a/apps/nestjs-backend/src/features/space/space-data-db-migration-guard.service.spec.ts +++ b/apps/nestjs-backend/src/features/space/space-data-db-migration-guard.service.spec.ts @@ -1,5 +1,7 @@ import { HttpErrorCode } from '@teable/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AggregationOpenApiController } from '../aggregation/open-api/aggregation-open-api.controller'; +import { ShareController } from '../share/share.controller'; import { SpaceDataDbMigrationGuardService } from './space-data-db-migration-guard.service'; describe('SpaceDataDbMigrationGuardService', () => { @@ -86,6 +88,29 @@ describe('SpaceDataDbMigrationGuardService', () => { await expect(service.assertSpaceWritable('spcxxx')).resolves.toBeUndefined(); }); + it('degrades expensive search reads while a migration is active', async () => { + prismaService.tableMeta.findUnique.mockResolvedValue({ + baseId: 'bsexxx', + base: { spaceId: 'spcxxx' }, + }); + prismaService.spaceDataDbMigrationJob.findFirst.mockResolvedValue({ + id: 'sdmjxxx', + state: 'copying', + }); + const service = new SpaceDataDbMigrationGuardService(prismaService as never); + + await expect( + service.assertTableRecordSearchReadable('tblxxx', { search: ['needle'] }) + ).rejects.toMatchObject({ + code: HttpErrorCode.TOO_MANY_REQUESTS, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_MIGRATING', + migrationJobId: 'sdmjxxx', + }), + }); + await expect(service.assertTableRecordSearchReadable('tblxxx', {})).resolves.toBeUndefined(); + }); + it('allows record writes during the online copy phase while schema writes stay blocked', async () => { prismaService.spaceDataDbMigrationJob.findFirst.mockImplementation(async (args) => { const states = args?.where?.state?.in ?? []; @@ -296,4 +321,37 @@ describe('SpaceDataDbMigrationGuardService', () => { }), }); }); + + it('guards every expensive aggregation and shared-view search entrypoint', async () => { + const migrationError = new Error('search degraded'); + const searchGuard = { + assertTableRecordSearchReadable: vi.fn().mockRejectedValue(migrationError), + }; + const aggregationController = new AggregationOpenApiController( + {} as never, + {} as never, + {} as never, + {} as never, + searchGuard as never + ); + const shareController = new ShareController( + {} as never, + {} as never, + {} as never, + searchGuard as never + ); + const query = { search: ['needle'] } as never; + const shareRequest = { shareInfo: { tableId: 'tblxxx' } }; + + await expect(aggregationController.getSearchCount('tblxxx', query)).rejects.toBe( + migrationError + ); + await expect(aggregationController.getSearchIndex('tblxxx', query)).rejects.toBe( + migrationError + ); + await expect(shareController.getSearchCount(shareRequest, query)).rejects.toBe(migrationError); + await expect(shareController.getSearchIndex(shareRequest, query)).rejects.toBe(migrationError); + await expect(shareController.getRecordDocIds(shareRequest, query)).rejects.toBe(migrationError); + expect(searchGuard.assertTableRecordSearchReadable).toHaveBeenCalledTimes(5); + }); }); diff --git a/apps/nestjs-backend/src/features/space/space-data-db-migration-guard.service.ts b/apps/nestjs-backend/src/features/space/space-data-db-migration-guard.service.ts index aaa949a77e..b13db92fa6 100644 --- a/apps/nestjs-backend/src/features/space/space-data-db-migration-guard.service.ts +++ b/apps/nestjs-backend/src/features/space/space-data-db-migration-guard.service.ts @@ -279,6 +279,41 @@ export class SpaceDataDbMigrationGuardService { await this.assertSpaceRecordWritable(table.base.spaceId); } + async assertTableRecordSearchReadable( + tableId: string, + query?: { search?: unknown } + ): Promise { + if (!query?.search) { + return; + } + const table = await this.prismaClient.tableMeta.findUnique({ + where: { id: tableId }, + select: { baseId: true, base: { select: { spaceId: true } } }, + }); + if (!table) { + return; + } + const activeJob = await this.findActiveMigrationForSpace( + table.base.spaceId, + [...activeSpaceDataDbMigrationStates], + { switchOnCompletionOnly: false } + ); + if (!activeJob) { + return; + } + + throw new CustomHttpException( + 'Search is temporarily degraded during data database migration', + HttpErrorCode.TOO_MANY_REQUESTS, + { + errorCode: spaceDataDbMigratingErrorCode, + migrationJobId: activeJob.id, + migrationState: activeJob.state, + spaceId: table.base.spaceId, + } + ); + } + private get prismaClient(): IMigrationJobClient { const client = this.prismaService as unknown as IMigrationJobClient; return client.txClient?.() ?? client; diff --git a/apps/nestjs-backend/src/features/space/space-data-db-migration-worker.service.spec.ts b/apps/nestjs-backend/src/features/space/space-data-db-migration-worker.service.spec.ts index 4d1300c814..0b22d2cba4 100644 --- a/apps/nestjs-backend/src/features/space/space-data-db-migration-worker.service.spec.ts +++ b/apps/nestjs-backend/src/features/space/space-data-db-migration-worker.service.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SpaceDataDbMigrationWorkerService } from './space-data-db-migration-worker.service'; describe('SpaceDataDbMigrationWorkerService', () => { @@ -10,12 +10,19 @@ describe('SpaceDataDbMigrationWorkerService', () => { }; beforeEach(() => { + vi.unstubAllEnvs(); vi.stubEnv('BYODB_SPACE_DATA_DB_MIGRATION_WORKER_ID', workerId); + vi.stubEnv('NODE_ENV', 'test'); + vi.stubEnv('VITEST', 'true'); migrationService.recoverStaleActiveMigrationJobs.mockReset().mockResolvedValue([]); migrationService.claimNextPendingMigrationJob.mockReset().mockResolvedValue(null); migrationService.runMigrationJob.mockReset().mockResolvedValue({ state: 'succeeded' }); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('returns null when there is no pending job', async () => { const service = new SpaceDataDbMigrationWorkerService(migrationService as never); @@ -49,4 +56,26 @@ describe('SpaceDataDbMigrationWorkerService', () => { error: 'copy failed', }); }); + + it('does not auto-start the poll loop in test runtime', () => { + const service = new SpaceDataDbMigrationWorkerService(migrationService as never); + const runForever = vi.spyOn(service, 'runForever').mockResolvedValue(undefined); + + service.onApplicationBootstrap(); + + expect(runForever).not.toHaveBeenCalled(); + }); + + it('starts the poll loop when explicitly enabled', async () => { + vi.stubEnv('BYODB_SPACE_DATA_DB_MIGRATION_WORKER_ENABLED', 'true'); + const service = new SpaceDataDbMigrationWorkerService(migrationService as never); + const runForever = vi.spyOn(service, 'runForever').mockImplementation(async () => { + service.stop(); + }); + + service.onApplicationBootstrap(); + await service.waitForStop(); + + expect(runForever).toHaveBeenCalledOnce(); + }); }); diff --git a/apps/nestjs-backend/src/features/space/space-data-db-migration-worker.service.ts b/apps/nestjs-backend/src/features/space/space-data-db-migration-worker.service.ts index 43cb129111..3f86a352d5 100644 --- a/apps/nestjs-backend/src/features/space/space-data-db-migration-worker.service.ts +++ b/apps/nestjs-backend/src/features/space/space-data-db-migration-worker.service.ts @@ -1,4 +1,5 @@ import { hostname } from 'os'; +import type { OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common'; import { SpaceDataDbMigrationService } from './space-data-db-migration.service'; @@ -8,23 +9,59 @@ type ISpaceDataDbMigrationWorkerRunResult = { error?: string; }; +const enabledEnvKey = 'BYODB_SPACE_DATA_DB_MIGRATION_WORKER_ENABLED'; +const pollMsEnvKey = 'BYODB_SPACE_DATA_DB_MIGRATION_WORKER_POLL_MS'; +const errorBackoffMsEnvKey = 'BYODB_SPACE_DATA_DB_MIGRATION_WORKER_ERROR_BACKOFF_MS'; +const workerIdEnvKey = 'BYODB_SPACE_DATA_DB_MIGRATION_WORKER_ID'; + const defaultPollMs = 5000; const defaultErrorBackoffMs = 10000; +const parseBoolean = (value: unknown, defaultValue: boolean): boolean => { + if (value == null || value === '') return defaultValue; + if (typeof value === 'boolean') return value; + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return defaultValue; +}; + const readPositiveIntegerEnv = (key: string, fallback: number) => { const value = Number.parseInt(process.env[key] ?? '', 10); return Number.isFinite(value) && value > 0 ? value : fallback; }; -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @Injectable() -export class SpaceDataDbMigrationWorkerService { +export class SpaceDataDbMigrationWorkerService implements OnApplicationBootstrap, OnModuleDestroy { private readonly logger = new Logger(SpaceDataDbMigrationWorkerService.name); private stopped = false; + private loopPromise: Promise | undefined; constructor(private readonly migrationService: SpaceDataDbMigrationService) {} + onApplicationBootstrap() { + if (!this.isEnabled()) { + this.logger.log('BYODB space data DB migration worker disabled'); + return; + } + + this.stopped = false; + this.loopPromise = this.runForever().catch((error) => { + this.logger.error( + `BYODB space data DB migration worker exited unexpectedly: ${ + error instanceof Error ? error.message : String(error) + }`, + error instanceof Error ? error.stack : undefined + ); + }); + } + + onModuleDestroy() { + this.stop(); + } + stop() { this.stopped = true; } @@ -60,15 +97,9 @@ export class SpaceDataDbMigrationWorkerService { async runForever(options: { pollMs?: number; errorBackoffMs?: number } = {}) { this.stopped = false; - const pollMs = - options.pollMs ?? - readPositiveIntegerEnv('BYODB_SPACE_DATA_DB_MIGRATION_WORKER_POLL_MS', defaultPollMs); + const pollMs = options.pollMs ?? readPositiveIntegerEnv(pollMsEnvKey, defaultPollMs); const errorBackoffMs = - options.errorBackoffMs ?? - readPositiveIntegerEnv( - 'BYODB_SPACE_DATA_DB_MIGRATION_WORKER_ERROR_BACKOFF_MS', - defaultErrorBackoffMs - ); + options.errorBackoffMs ?? readPositiveIntegerEnv(errorBackoffMsEnvKey, defaultErrorBackoffMs); this.logger.log( `BYODB space data DB migration worker ${this.getWorkerId()} started; pollMs=${pollMs}` @@ -95,7 +126,28 @@ export class SpaceDataDbMigrationWorkerService { this.logger.log(`BYODB space data DB migration worker ${this.getWorkerId()} stopped`); } + /** + * Await the in-process loop after stop(). Useful for tests that start the + * bootstrap lifecycle explicitly. + */ + async waitForStop() { + await this.loopPromise; + } + + private isEnabled() { + // Tests drive jobs via runOnce(); keep the background loop off unless a + // suite opts in explicitly. + const isTestRuntime = + process.env.NODE_ENV === 'test' || + process.env.VITEST === 'true' || + Boolean(process.env.VITEST); + if (isTestRuntime) { + return parseBoolean(process.env[enabledEnvKey], false); + } + return parseBoolean(process.env[enabledEnvKey], true); + } + private getWorkerId() { - return process.env.BYODB_SPACE_DATA_DB_MIGRATION_WORKER_ID ?? `${hostname()}:${process.pid}`; + return process.env[workerIdEnvKey] ?? `${hostname()}:${process.pid}`; } } diff --git a/apps/nestjs-backend/src/features/space/space-data-db-migration.module.ts b/apps/nestjs-backend/src/features/space/space-data-db-migration.module.ts new file mode 100644 index 0000000000..dc9db72825 --- /dev/null +++ b/apps/nestjs-backend/src/features/space/space-data-db-migration.module.ts @@ -0,0 +1,43 @@ +import { Module } from '@nestjs/common'; +import { EventJobModule } from '../../event-emitter/event-job/event-job.module'; +import { BASE_IMPORT_CSV_QUEUE } from '../base/base-import-processor/base-import-csv.processor'; +import { BASE_IMPORT_JUNCTION_CSV_QUEUE } from '../base/base-import-processor/base-import-junction.processor'; +import { TABLE_IMPORT_CSV_CHUNK_QUEUE } from '../import/open-api/import-csv-chunk.processor'; +import { TABLE_IMPORT_CSV_QUEUE } from '../import/open-api/import-csv.processor'; +import { DataDbBaselineService } from './data-db-baseline.service'; +import { DataDbPreflightService } from './data-db-preflight.service'; +import { SpaceDataDbCopyModule } from './space-data-db-copy.module'; +import { SpaceDataDbMigrationWorkerService } from './space-data-db-migration-worker.service'; +import { SpaceDataDbMigrationService } from './space-data-db-migration.service'; + +/** + * Slim BYODB space data DB migration surface. + * + * Intentionally excludes Space/Base API modules and queue processors. Queue + * registrations below are producer/inspector-only so + * SpaceDataDbMigrationService can drain import jobs during cutover — they must + * never pull @Processor workers into auxiliary graphs. + */ +@Module({ + imports: [ + SpaceDataDbCopyModule, + EventJobModule.registerQueue(BASE_IMPORT_CSV_QUEUE), + EventJobModule.registerQueue(BASE_IMPORT_JUNCTION_CSV_QUEUE), + EventJobModule.registerQueue(TABLE_IMPORT_CSV_CHUNK_QUEUE), + EventJobModule.registerQueue(TABLE_IMPORT_CSV_QUEUE), + ], + providers: [ + DataDbPreflightService, + DataDbBaselineService, + SpaceDataDbMigrationService, + SpaceDataDbMigrationWorkerService, + ], + exports: [ + SpaceDataDbCopyModule, + DataDbPreflightService, + DataDbBaselineService, + SpaceDataDbMigrationService, + SpaceDataDbMigrationWorkerService, + ], +}) +export class SpaceDataDbMigrationModule {} diff --git a/apps/nestjs-backend/src/features/space/space-data-db-migration.service.spec.ts b/apps/nestjs-backend/src/features/space/space-data-db-migration.service.spec.ts index bbcc0b5066..b2e1918ff4 100644 --- a/apps/nestjs-backend/src/features/space/space-data-db-migration.service.spec.ts +++ b/apps/nestjs-backend/src/features/space/space-data-db-migration.service.spec.ts @@ -115,6 +115,7 @@ describe('SpaceDataDbMigrationService', () => { spaceDataDbBinding: { findUnique: vi.fn(), findMany: vi.fn(), + count: vi.fn(), }, spaceDataDbMigrationJob: { findFirst: vi.fn(), @@ -122,6 +123,7 @@ describe('SpaceDataDbMigrationService', () => { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn(), + count: vi.fn(), }, schemaOperation: { count: vi.fn(), @@ -255,6 +257,8 @@ describe('SpaceDataDbMigrationService', () => { prismaService.field.findMany.mockReset().mockResolvedValue([]); prismaService.$queryRawUnsafe.mockReset().mockResolvedValue([]); prismaService.spaceDataDbBinding.findMany.mockReset().mockResolvedValue([]); + prismaService.spaceDataDbBinding.count.mockReset().mockResolvedValue(0); + prismaService.spaceDataDbMigrationJob.count.mockReset().mockResolvedValue(0); preflightService.preflight.mockReset().mockResolvedValue({ ok: true, provider: 'postgres', @@ -2829,20 +2833,21 @@ describe('SpaceDataDbMigrationService', () => { service.copyBaseSchemasForJob('sdmjxxx', { workDir: '/tmp/sdmjxxx', }) - ).rejects.toThrow('pg_dump failed'); + ).rejects.toThrow(/pg_dump failed/); expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenLastCalledWith( expect.objectContaining({ where: { id: 'sdmjxxx' }, data: expect.objectContaining({ state: 'failed', - lastError: 'pg_dump failed: pg_dump - exit 1 - dump stderr', + lastError: expect.stringMatching(/pg_dump failed[\s\S]*dump stderr/), copyStats: expect.objectContaining({ phase: 'base_schemas_failed', baseSchemas: expect.objectContaining({ - error: 'pg_dump failed: pg_dump - exit 1 - dump stderr', + error: expect.stringMatching(/pg_dump failed[\s\S]*dump stderr/), failure: expect.objectContaining({ type: 'process', + message: expect.stringContaining('[stderr]: dump stderr'), result: expect.objectContaining({ command: 'pg_dump', exitCode: 1, @@ -3012,10 +3017,11 @@ describe('SpaceDataDbMigrationService', () => { }), }) ); - expect(pauseTargetComputed).not.toHaveBeenCalled(); + // Fallback pause after shared-row copy completes when switchOnCompletion is true. + expect(pauseTargetComputed).toHaveBeenCalledWith('sdmjxxx'); }); - it('pauses target computed claims after pause scopes are copied and before target outbox rows', async () => { + it('does not copy source pause scopes and pauses target after record_trash', async () => { prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ id: 'sdmjxxx', spaceId: 'spcxxx', @@ -3076,7 +3082,8 @@ describe('SpaceDataDbMigrationService', () => { const copiedTableNames = ( copyService.copySharedTables.mock.calls[0][0] as Array<{ table: string }> ).map((plan) => plan.table); - expect(copiedTableNames.indexOf('computed_update_pause_scope')).toBeLessThan( + expect(copiedTableNames).not.toContain('computed_update_pause_scope'); + expect(copiedTableNames.indexOf('record_trash')).toBeLessThan( copiedTableNames.indexOf('computed_update_outbox') ); expect(pauseTargetComputed).toHaveBeenCalledWith('sdmjxxx'); @@ -3089,6 +3096,7 @@ describe('SpaceDataDbMigrationService', () => { 'space-data-db-migration:sdmjxxx', 'usrxxx', 'space-data-db-migration:sdmjxxx', + 'space-data-db-migration:%', ] ); }); @@ -3159,10 +3167,6 @@ describe('SpaceDataDbMigrationService', () => { table: 'computed_update_outbox_seed', sourceSql: expect.stringContaining(`"table_id" = ANY(ARRAY['tblrelated']::text[])`), }), - expect.objectContaining({ - table: 'computed_update_pause_scope', - sourceSql: expect.stringContaining(`"scope_id" = ANY(ARRAY['spcrelated']::text[])`), - }), ]), expect.anything(), expect.anything() @@ -3284,14 +3288,14 @@ describe('SpaceDataDbMigrationService', () => { ); const service = createService(); - await expect(service.copySharedRowsForJob('sdmjxxx', {})).rejects.toThrow('psql copy failed'); + await expect(service.copySharedRowsForJob('sdmjxxx', {})).rejects.toThrow(/psql copy failed/); expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenLastCalledWith( expect.objectContaining({ where: { id: 'sdmjxxx' }, data: expect.objectContaining({ state: 'failed', - lastError: 'psql copy failed', + lastError: expect.stringContaining('psql copy failed'), copyStats: expect.objectContaining({ phase: 'shared_rows_failed', sharedTables: expect.objectContaining({ @@ -3303,9 +3307,10 @@ describe('SpaceDataDbMigrationService', () => { copiedRows: 5, }), ], - error: 'psql copy failed', + error: expect.stringContaining('psql copy failed'), failure: expect.objectContaining({ type: 'pipeline', + message: expect.stringContaining('[source stderr]: source failed'), result: expect.objectContaining({ label: 'shared-table:record_trash', source: expect.objectContaining({ @@ -3331,6 +3336,7 @@ describe('SpaceDataDbMigrationService', () => { id: 'sdmjxxx', spaceId: 'spcxxx', state: 'failed', + targetConnectionId: 'dcnxxx', targetInternalSchema: internalSchema, copyStats: { phase: 'shared_rows_failed' }, targetConnection: { @@ -3369,6 +3375,9 @@ describe('SpaceDataDbMigrationService', () => { }); expect(targetClient.raw).toHaveBeenCalledWith('DROP SCHEMA IF EXISTS "bsexxx" CASCADE'); + expect(targetClient.raw).toHaveBeenCalledWith( + `DROP SCHEMA IF EXISTS "${internalSchema}" CASCADE` + ); expect( targetClient.raw.mock.calls.some( ([sql, bindings]) => @@ -3393,11 +3402,61 @@ describe('SpaceDataDbMigrationService', () => { ); }); + it('keeps the target internal schema when another successful dry-run still uses it', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + state: 'failed', + targetConnectionId: 'dcnxxx', + targetInternalSchema: internalSchema, + copyStats: { phase: 'shared_rows_failed' }, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + sharedTableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [], + }, + }); + prismaService.spaceDataDbMigrationJob.count.mockImplementation(async (args) => { + expect(args).toMatchObject({ + where: { + targetConnectionId: 'dcnxxx', + OR: expect.arrayContaining([{ state: 'succeeded', switchOnCompletion: false }]), + }, + }); + return 1; + }); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('FROM information_schema.schemata')) { + return { rows: [] }; + } + if (sql.includes('to_regclass')) { + return { rows: [{ exists: true }] }; + } + return { rows: [] }; + }); + const service = createService(); + + await expect( + service.cleanupTargetArtifactsForJob('sdmjxxx', 'copy_failed') + ).resolves.toMatchObject({ + internalSchema: { schemaName: internalSchema, dropped: false }, + }); + expect(targetClient.raw).not.toHaveBeenCalledWith( + `DROP SCHEMA IF EXISTS "${internalSchema}" CASCADE` + ); + }); + it('truncates target shared tables when the target connection is unbound', async () => { prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ id: 'sdmjxxx', spaceId: 'spcxxx', state: 'failed', + targetConnectionId: 'dcnxxx', targetInternalSchema: internalSchema, copyStats: { phase: 'shared_rows_failed' }, targetConnection: { @@ -4437,7 +4496,7 @@ describe('SpaceDataDbMigrationService', () => { expect(txClient.spaceDataDbBinding.upsert).not.toHaveBeenCalled(); expect(sourceClient.raw).toHaveBeenCalledWith( expect.stringContaining(`DELETE FROM "public"."computed_update_pause_scope"`), - ['space', ['spcxxx'], 'space-data-db-migration:sdmjxxx'] + ['space', ['spcxxx'], 'space-data-db-migration:sdmjxxx', 'space-data-db-migration:%'] ); expect(txClient.dataDbConnection.update).toHaveBeenCalledWith({ where: { id: 'dcnxxx' }, @@ -4609,7 +4668,7 @@ describe('SpaceDataDbMigrationService', () => { }); expect(targetClient.raw).toHaveBeenCalledWith( expect.stringContaining(`DELETE FROM "${internalSchema}"."computed_update_pause_scope"`), - ['space', ['spcxxx'], 'space-data-db-migration:sdmjxxx'] + ['space', ['spcxxx'], 'space-data-db-migration:sdmjxxx', 'space-data-db-migration:%'] ); expect(dataDbClientManager.invalidateConnection).toHaveBeenCalledWith('dcnxxx'); expect(dataDbClientManager.invalidateConnection).toHaveBeenCalledWith('dcnsource'); @@ -4687,6 +4746,29 @@ describe('SpaceDataDbMigrationService', () => { ); }); + it('purges the source computed backlog and lifts the source pause after a successful switch', async () => { + mockValidationClient(sourceClient, 3); + mockValidationClient(targetClient, 3); + const service = createService(); + + await expect(service.validateAndSwitchJob('sdmjxxx')).resolves.toMatchObject({ + state: 'succeeded', + }); + + expect(sourceClient.raw).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM "public"."computed_update_outbox"'), + [['bsexxx']] + ); + expect(sourceClient.raw).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM "public"."computed_update_outbox_seed"'), + [['tblxxx']] + ); + expect(sourceClient.raw).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM "public"."computed_update_pause_scope"'), + ['space', ['spcxxx'], 'space-data-db-migration:sdmjxxx', 'space-data-db-migration:%'] + ); + }); + it('keeps validation fresh while row counts are running', async () => { vi.useFakeTimers(); prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ @@ -6342,7 +6424,7 @@ describe('SpaceDataDbMigrationService', () => { expect(sourceClient.raw).toHaveBeenCalledWith( expect.stringContaining('DELETE FROM "public"."computed_update_pause_scope"'), - ['space', ['spcxxx'], 'space-data-db-migration:sdmjxxx'] + ['space', ['spcxxx'], 'space-data-db-migration:sdmjxxx', 'space-data-db-migration:%'] ); expect(txClient.dataDbConnection.update).toHaveBeenCalledWith({ where: { id: 'dcnxxx' }, diff --git a/apps/nestjs-backend/src/features/space/space-data-db-migration.service.ts b/apps/nestjs-backend/src/features/space/space-data-db-migration.service.ts index 624e2ce334..0cf769b14a 100644 --- a/apps/nestjs-backend/src/features/space/space-data-db-migration.service.ts +++ b/apps/nestjs-backend/src/features/space/space-data-db-migration.service.ts @@ -402,6 +402,10 @@ type ITargetArtifactCleanupStats = { deletedRows: number | null; truncated?: boolean; }[]; + internalSchema?: { + schemaName: string; + dropped: boolean; + }; truncateSharedTables?: boolean; startedAt: string; completedAt?: string; @@ -651,6 +655,11 @@ type IMigrationJobClient = { create(args: unknown): Promise<{ id: string }>; update(args: unknown): Promise; updateMany(args: unknown): Promise<{ count: number }>; + count(args: unknown): Promise; + }; + spaceDataDbBinding: { + count(args: unknown): Promise; + findMany(args: unknown): Promise<{ spaceId: string }[]>; }; }; @@ -681,6 +690,7 @@ const sharedTables = { recordHistory: 'record_history', tableTrash: 'table_trash', recordTrash: 'record_trash', + recordRemovalTombstone: 'record_removal_tombstone', computedUpdateOutbox: 'computed_update_outbox', computedUpdateDeadLetter: 'computed_update_dead_letter', computedUpdateOutboxSeed: 'computed_update_outbox_seed', @@ -787,7 +797,8 @@ const migrationProgressCompletedSteps: Record = { canceled_before_copy: 1, }; -const migrationPauseReason = (jobId: string) => `space-data-db-migration:${jobId}`; +const migrationPauseReasonPrefix = 'space-data-db-migration:'; +const migrationPauseReason = (jobId: string) => `${migrationPauseReasonPrefix}${jobId}`; const readPositiveIntEnv = (key: string, fallback: number) => { const value = Number(process.env[key]); @@ -2565,7 +2576,8 @@ export class SpaceDataDbMigrationService { if ( row.tableName === sharedTables.recordHistory || row.tableName === sharedTables.tableTrash || - row.tableName === sharedTables.recordTrash + row.tableName === sharedTables.recordTrash || + row.tableName === sharedTables.recordRemovalTombstone ) { return typeof payload.table_id === 'string' && tableScopeIds.has(payload.table_id); } @@ -2579,13 +2591,9 @@ export class SpaceDataDbMigrationService { return typeof payload.table_id === 'string' && inventory.tableIds.includes(payload.table_id); } if (row.tableName === sharedTables.computedUpdatePauseScope) { - const scopeType = payload.scope_type; - const scopeId = payload.scope_id; - return ( - (scopeType === 'space' && typeof scopeId === 'string' && copySpaceIds.has(scopeId)) || - (scopeType === 'base' && typeof scopeId === 'string' && baseIds.has(scopeId)) || - (scopeType === 'table' && typeof scopeId === 'string' && tableScopeIds.has(scopeId)) - ); + // Pause scopes are intentionally not mirrored during space migration. + // Target pause rows are owned by pauseTargetComputedForJob only. + return false; } if (row.tableName === sharedTables.undoLog) { const tableName = typeof payload.table_name === 'string' ? payload.table_name : ''; @@ -3976,6 +3984,66 @@ export class SpaceDataDbMigrationService { } } + /** + * After a successful switch the source database still holds this space's + * computed outbox backlog (rows are copied to the target, never deleted) and + * the migration pause row. Leaving both behind creates a silent black hole: + * the permanent pause blocks claim/redrive/wakeup until someone deletes it + * manually, at which point the zombie backlog executes against the orphaned + * source schema copy. Delete the backlog first, then lift the pause, so the + * source can never replay stale work. + * + * Only default-mode sources are cleaned: a BYODB source would need the old + * connection, which post-switch resolution no longer returns (the space + * binding already points at the target). + */ + async cleanupSourceComputedAfterSwitchForJob( + jobId: string + ): Promise<{ skipped: boolean; outboxDeleted: number; pauseDeleted: number }> { + const job = await this.getMigrationJob(jobId); + const inventory = this.normalizeInventory(job.inventory, job.spaceId); + if (inventory.sourceDataDb.mode !== 'default' || inventory.sourceDataDb.connectionId) { + return { skipped: true, outboxDeleted: 0, pauseDeleted: 0 }; + } + const sourceDataDb = this.getSourceDataDbFromInventory(job); + const sourceSchema = sourceDataDb.internalSchema ?? 'public'; + const client = this.clientFactory(sourceDataDb.url); + try { + let outboxDeleted = 0; + if (inventory.baseIds.length) { + const outboxRows = normalizeRawRows<{ id: string }>( + await client.raw( + ` + DELETE FROM ${qualify(sourceSchema, sharedTables.computedUpdateOutbox)} + WHERE "base_id" = ANY(?::text[]) + RETURNING "id" + `, + [inventory.baseIds] + ) + ); + outboxDeleted = outboxRows.length; + } + if (inventory.tableIds.length) { + await client.raw( + ` + DELETE FROM ${qualify(sourceSchema, sharedTables.computedUpdateOutboxSeed)} + WHERE "table_id" = ANY(?::text[]) + `, + [inventory.tableIds] + ); + } + const pause = await this.deleteMigrationComputedPause( + client, + sourceSchema, + this.getInventoryCopySpaceIds(inventory), + job.id + ); + return { skipped: false, outboxDeleted, pauseDeleted: pause.deleted }; + } finally { + await client.destroy().catch(() => undefined); + } + } + private async insertMigrationComputedPause( client: IDataDbPreflightClient, schema: string, @@ -4011,13 +4079,14 @@ export class SpaceDataDbMigrationService { "updated_at" = EXCLUDED."updated_at", "updated_by" = EXCLUDED."updated_by" WHERE "pause_scope"."reason" = ? + OR "pause_scope"."reason" LIKE ? OR ( "pause_scope"."resume_at" IS NOT NULL AND "pause_scope"."resume_at" <= now() ) RETURNING "id" `, - [...bindings, pauseReason] + [...bindings, pauseReason, `${migrationPauseReasonPrefix}%`] ) ); return { created: rows.length > 0, createdCount: rows.length }; @@ -4625,6 +4694,7 @@ export class SpaceDataDbMigrationService { pushTableScoped(sharedTables.recordHistory); pushTableScoped(sharedTables.tableTrash); pushTableScoped(sharedTables.recordTrash); + pushTableScoped(sharedTables.recordRemovalTombstone); pushBaseScoped(sharedTables.computedUpdateOutbox); pushBaseScoped(sharedTables.computedUpdateDeadLetter); @@ -4779,6 +4849,9 @@ export class SpaceDataDbMigrationService { tableIds: inventory.tableIds, sharedTableIds: inventory.sharedTableIds, snapshotId: options.snapshotId, + // Never copy source pause scopes into the target: they would freeze + // computed updates after switch. Migration inserts its own pause row. + includePauseScopes: false, }; const fdwNamePrefix = this.buildPostgresFdwNamePrefix(jobId); const plans = @@ -4820,9 +4893,11 @@ export class SpaceDataDbMigrationService { copiedTables.push(this.buildSharedTableCopySummary(result)); if ( job.switchOnCompletion === true && - result.table === sharedTables.computedUpdatePauseScope && - !targetComputedPaused + !targetComputedPaused && + result.table === sharedTables.recordTrash ) { + // Pause target computed after trash/history and before outbox rows so + // the target never claims outbox work during/after the switch window. await this.pauseTargetComputedForJob(jobId); targetComputedPaused = true; } @@ -4878,6 +4953,10 @@ export class SpaceDataDbMigrationService { processOptionsWithHeartbeat, { onTableCopied } ); + if (job.switchOnCompletion === true && !targetComputedPaused) { + await this.pauseTargetComputedForJob(jobId); + targetComputedPaused = true; + } const copiedSharedTables = results.map((result) => this.buildSharedTableCopySummary(result)); const copyStats = { phase: 'shared_rows_completed', @@ -4906,7 +4985,7 @@ export class SpaceDataDbMigrationService { if (await this.isProcessCancelErrorForJob(error, jobId)) { throw error; } - const lastError = error instanceof Error ? error.message : String(error); + const lastError = this.buildProcessFailureMessage(error); await this.migrationJobClient.spaceDataDbMigrationJob.update({ where: { id: jobId }, data: { @@ -5018,6 +5097,15 @@ export class SpaceDataDbMigrationService { return readOnlyMessage; } const baseMessage = error instanceof Error ? error.message : String(error); + // Process runner embeds stderr into Error.message. Prefer that single + // source of truth so last_error stays scannable and non-duplicative. + if ( + baseMessage.includes('[stderr]:') || + baseMessage.includes('[source stderr]:') || + baseMessage.includes('[target stderr]:') + ) { + return baseMessage; + } const failureStats = this.buildProcessFailureStats(error); const detail = this.getProcessFailureDetail(failureStats); return detail ? `${baseMessage}: ${detail}` : baseMessage; @@ -5190,6 +5278,17 @@ export class SpaceDataDbMigrationService { ], }; } + try { + await this.cleanupSourceComputedAfterSwitchForJob(jobId); + } catch (error) { + completedValidationStats = { + ...completedValidationStats, + warnings: [ + ...(completedValidationStats.warnings ?? []), + `source_computed_cleanup_failed: ${error instanceof Error ? error.message : String(error)}`, + ], + }; + } await this.migrationJobClient.spaceDataDbMigrationJob.update({ where: { id: jobId }, data: { @@ -6611,6 +6710,14 @@ export class SpaceDataDbMigrationService { inventory.sharedTableIds.length ? `"table_id" = ANY(?::text[])` : '', [inventory.sharedTableIds] ); + await this.pushConflictCount( + client, + conflicts, + internalSchema, + sharedTables.recordRemovalTombstone, + inventory.sharedTableIds.length ? `"table_id" = ANY(?::text[])` : '', + [inventory.sharedTableIds] + ); await this.pushConflictCount( client, conflicts, @@ -6732,7 +6839,6 @@ export class SpaceDataDbMigrationService { truncateSharedTables: options.truncateSharedTables === true, startedAt: new Date().toISOString(), }; - try { stats.sharedTables = await this.cleanupTargetSharedRows( client, @@ -6742,6 +6848,44 @@ export class SpaceDataDbMigrationService { { truncate: options.truncateSharedTables === true } ); stats.baseSchemas = await this.cleanupTargetBaseSchemas(client, inventory.baseIds); + + const activeBindingsCount = await this.migrationJobClient.spaceDataDbBinding.count({ + where: { + dataDbConnectionId: job.targetConnectionId, + }, + }); + + const otherJobsCount = await this.migrationJobClient.spaceDataDbMigrationJob.count({ + where: { + id: { not: jobId }, + targetConnectionId: job.targetConnectionId, + OR: [ + { state: { in: [...activeSpaceDataDbMigrationStates] } }, + { state: 'succeeded', switchOnCompletion: false }, + ], + }, + }); + + // The internal schema is connection-wide, not job-owned. Drop it only + // when no binding, active migration, or successful dry-run still uses it. + if ( + activeBindingsCount === 0 && + otherJobsCount === 0 && + job.targetInternalSchema && + job.targetInternalSchema !== 'public' + ) { + await client.raw(`DROP SCHEMA IF EXISTS ${quoteIdent(job.targetInternalSchema)} CASCADE`); + stats.internalSchema = { + schemaName: job.targetInternalSchema, + dropped: true, + }; + } else { + stats.internalSchema = { + schemaName: job.targetInternalSchema, + dropped: false, + }; + } + stats.completedAt = new Date().toISOString(); await this.updateTargetCleanupStats(jobId, job.copyStats, stats); return stats; @@ -6877,6 +7021,7 @@ export class SpaceDataDbMigrationService { [sharedTables.computedUpdateOutbox, 5], [sharedTables.computedUpdatePauseScope, 6], [sharedTables.undoLog, 7], + [sharedTables.recordRemovalTombstone, 8], ]); return [...plans].sort((left, right) => { const leftPriority = priority.get(left.table) ?? Number.MAX_SAFE_INTEGER; @@ -7291,7 +7436,9 @@ export class SpaceDataDbMigrationService { targetCount, }; const mismatches: IValidationMismatch[] = []; - if (sourceCount !== targetCount) { + // Pause scopes are managed by the migration itself on the target and are + // not copied from source, so source/target counts are not expected to match. + if (plan.table !== sharedTables.computedUpdatePauseScope && sourceCount !== targetCount) { mismatches.push({ ...rowValidation, reason: 'row_count_mismatch', @@ -7396,6 +7543,7 @@ export class SpaceDataDbMigrationService { pushTableScoped(sharedTables.recordHistory); pushTableScoped(sharedTables.tableTrash); pushTableScoped(sharedTables.recordTrash); + pushTableScoped(sharedTables.recordRemovalTombstone); pushBaseScoped(sharedTables.computedUpdateOutbox); pushBaseScoped(sharedTables.computedUpdateDeadLetter); @@ -8539,10 +8687,10 @@ export class SpaceDataDbMigrationService { DELETE FROM ${qualify(schema, sharedTables.computedUpdatePauseScope)} WHERE "scope_type" = ? AND "scope_id" = ANY(?::text[]) - AND "reason" = ? + AND ("reason" = ? OR "reason" LIKE ?) RETURNING "id" `, - ['space', spaceIds, migrationPauseReason(jobId)] + ['space', spaceIds, migrationPauseReason(jobId), `${migrationPauseReasonPrefix}%`] ) ); return { deleted: rows.length }; diff --git a/apps/nestjs-backend/src/features/space/space-data-db-process-runner.service.spec.ts b/apps/nestjs-backend/src/features/space/space-data-db-process-runner.service.spec.ts index 24e43d0b97..b2ddf48fbb 100644 --- a/apps/nestjs-backend/src/features/space/space-data-db-process-runner.service.spec.ts +++ b/apps/nestjs-backend/src/features/space/space-data-db-process-runner.service.spec.ts @@ -820,4 +820,26 @@ describe('SpaceDataDbProcessRunnerService', () => { expect(sourceProcess.kill).toHaveBeenCalledWith('SIGTERM'); expect(targetProcess.kill).toHaveBeenCalledWith('SIGTERM'); }); + + it('includes source stderr in pipeline error messages for accurate last_error', async () => { + const sourceProcess = new FakeProcess(); + const targetProcess = new FakeProcess(); + spawnProcess = vi.fn().mockReturnValueOnce(sourceProcess).mockReturnValueOnce(targetProcess); + const service = new SpaceDataDbProcessRunnerService(spawnProcess); + + const promise = service.runPipeline({ + source: { command: 'psql', args: ['--command', 'COPY bad TO STDOUT', secretUrl] }, + target: { command: 'psql', args: ['--command', 'COPY good FROM STDIN', secretUrl] }, + label: sharedTableLabel, + }); + + sourceProcess.stderr.write('FATAL: Timed-out waiting to acquire database connection'); + sourceProcess.emit('close', 1, null); + + await expect(promise).rejects.toMatchObject({ + message: expect.stringContaining( + '[source stderr]: FATAL: Timed-out waiting to acquire database connection' + ), + }); + }); }); diff --git a/apps/nestjs-backend/src/features/space/space-data-db-process-runner.service.ts b/apps/nestjs-backend/src/features/space/space-data-db-process-runner.service.ts index 065cd8bb11..b987e5ed4f 100644 --- a/apps/nestjs-backend/src/features/space/space-data-db-process-runner.service.ts +++ b/apps/nestjs-backend/src/features/space/space-data-db-process-runner.service.ts @@ -183,7 +183,11 @@ export class SpaceDataDbProcessError extends Error { exitCode: number | null; } ) { - super(message); + const parts = [message]; + if (result.stderr?.trim()) { + parts.push(`[stderr]: ${result.stderr.trim()}`); + } + super(parts.join('\n')); } } @@ -196,7 +200,14 @@ export class SpaceDataDbProcessPipelineError extends Error { target: ISpaceDataDbProcessPartialResult; } ) { - super(message); + const parts = [message]; + if (result.source.stderr?.trim()) { + parts.push(`[source stderr]: ${result.source.stderr.trim()}`); + } + if (result.target.stderr?.trim()) { + parts.push(`[target stderr]: ${result.target.stderr.trim()}`); + } + super(parts.join('\n')); } } diff --git a/apps/nestjs-backend/src/features/space/space.controller.ts b/apps/nestjs-backend/src/features/space/space.controller.ts index 451670367f..079c107aef 100644 --- a/apps/nestjs-backend/src/features/space/space.controller.ts +++ b/apps/nestjs-backend/src/features/space/space.controller.ts @@ -13,25 +13,26 @@ import { } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { HttpErrorCode, Role } from '@teable/core'; -import type { - ICreateSpaceVo, - IUpdateSpaceVo, - IGetSpaceVo, - IDataDbConnectionSummaryVo, - IDataDbMigrationJobStatusVo, - IDataDbPreflightVo, - EmailInvitationVo, - ListSpaceInvitationLinkVo, - CreateSpaceInvitationLinkVo, - UpdateSpaceInvitationLinkVo, - ListSpaceCollaboratorVo, - IGetBaseAllVo, - ITestLLMVo, - ISpaceSearchVo, -} from '@teable/openapi'; import { + type IBaseEntryMapVo, + type ICreateSpaceVo, + type IUpdateSpaceVo, + type IGetSpaceVo, + type IDataDbConnectionSummaryVo, + type IDataDbMigrationJobStatusVo, + type IDataDbPreflightVo, + type EmailInvitationVo, + type ListSpaceInvitationLinkVo, + type CreateSpaceInvitationLinkVo, + type UpdateSpaceInvitationLinkVo, + type ListSpaceCollaboratorVo, + type IGetBaseAllVo, + type ITestLLMVo, + type ISpaceSearchVo, createSpaceRoSchema, ICreateSpaceRo, + getBaseEntryMapRoSchema, + IGetBaseEntryMapRo, dataDbPreflightRoSchema, IDataDbPreflightRo, type ISpaceDataDbSummaryQuery, @@ -71,6 +72,7 @@ import { ZodValidationPipe } from '../../zod.validation.pipe'; import { Permissions } from '../auth/decorators/permissions.decorator'; import { CollaboratorService } from '../collaborator/collaborator.service'; import { InvitationService } from '../invitation/invitation.service'; +import { LastVisitService } from '../user/last-visit/last-visit.service'; import { DataDbBindingService } from './data-db-binding.service'; import { DataDbPreflightService } from './data-db-preflight.service'; import { @@ -96,7 +98,8 @@ export class SpaceController { protected readonly dataDbPreflightService: DataDbPreflightService, protected readonly dataDbBindingService: DataDbBindingService, protected readonly cls: ClsService, - protected readonly spaceDataDbMigrationService: SpaceDataDbMigrationService + protected readonly spaceDataDbMigrationService: SpaceDataDbMigrationService, + protected readonly lastVisitService: LastVisitService ) {} @Post('data-db/preflight') @@ -277,6 +280,24 @@ export class SpaceController { return await this.spaceService.getBaseListBySpaceId(spaceId); } + @Permissions('base|read') + @Get(':spaceId/base-entry-map') + async getBaseEntryMap( + @Param('spaceId') spaceId: string, + @Query(new ZodValidationPipe(getBaseEntryMapRoSchema.pick({ take: true }))) + query: Pick + ): Promise { + // Reuse the permission-checked base list of this space, then resolve the + // entry URL of the first `take` bases from the user's own visit history + const baseList = await this.spaceService.getBaseListBySpaceId(spaceId); + const capped = query.take ? baseList.slice(0, query.take) : baseList; + const userId = this.cls.get('user.id'); + return this.lastVisitService.getBaseEntryMap( + userId, + capped.map((base) => base.id) + ); + } + @Permissions('space|read') @Get(':spaceId/search') async search( diff --git a/apps/nestjs-backend/src/features/space/space.module.ts b/apps/nestjs-backend/src/features/space/space.module.ts index 7522744407..bf86d29afe 100644 --- a/apps/nestjs-backend/src/features/space/space.module.ts +++ b/apps/nestjs-backend/src/features/space/space.module.ts @@ -1,50 +1,31 @@ import { Module } from '@nestjs/common'; -import { EventJobModule } from '../../event-emitter/event-job/event-job.module'; import { StorageModule } from '../attachments/plugins/storage.module'; import { PermissionModule } from '../auth/permission.module'; -import { BASE_IMPORT_CSV_QUEUE } from '../base/base-import-processor/base-import-csv.processor'; -import { BASE_IMPORT_JUNCTION_CSV_QUEUE } from '../base/base-import-processor/base-import-junction.processor'; import { BaseModule } from '../base/base.module'; import { CollaboratorModule } from '../collaborator/collaborator.module'; -import { TABLE_IMPORT_CSV_CHUNK_QUEUE } from '../import/open-api/import-csv-chunk.processor'; -import { TABLE_IMPORT_CSV_QUEUE } from '../import/open-api/import-csv.processor'; import { InvitationModule } from '../invitation/invitation.module'; import { SettingOpenApiModule } from '../setting/open-api/setting-open-api.module'; import { SettingModule } from '../setting/setting.module'; -import { DataDbBaselineService } from './data-db-baseline.service'; +import { LastVisitModule } from '../user/last-visit/last-visit.module'; import { DataDbBindingService } from './data-db-binding.service'; -import { DataDbPreflightService } from './data-db-preflight.service'; -import { SpaceDataDbCopyModule } from './space-data-db-copy.module'; import { SpaceDataDbMigrationGuardModule } from './space-data-db-migration-guard.module'; -import { SpaceDataDbMigrationWorkerService } from './space-data-db-migration-worker.service'; -import { SpaceDataDbMigrationService } from './space-data-db-migration.service'; +import { SpaceDataDbMigrationModule } from './space-data-db-migration.module'; import { SpaceController } from './space.controller'; import { SpaceService } from './space.service'; import { TemplateSpaceInitService } from './template-space-init/template-space.init.service'; @Module({ controllers: [SpaceController], - providers: [ - SpaceService, - TemplateSpaceInitService, - DataDbPreflightService, - DataDbBaselineService, - DataDbBindingService, - SpaceDataDbMigrationService, - SpaceDataDbMigrationWorkerService, - ], + providers: [SpaceService, TemplateSpaceInitService, DataDbBindingService], exports: [ SpaceService, TemplateSpaceInitService, - DataDbPreflightService, - DataDbBaselineService, DataDbBindingService, - SpaceDataDbCopyModule, - SpaceDataDbMigrationService, - SpaceDataDbMigrationWorkerService, + SpaceDataDbMigrationModule, SpaceDataDbMigrationGuardModule, ], imports: [ + LastVisitModule, StorageModule, SettingModule, SettingOpenApiModule, @@ -53,11 +34,7 @@ import { TemplateSpaceInitService } from './template-space-init/template-space.i BaseModule, PermissionModule, SpaceDataDbMigrationGuardModule, - SpaceDataDbCopyModule, - EventJobModule.registerQueue(BASE_IMPORT_CSV_QUEUE), - EventJobModule.registerQueue(BASE_IMPORT_JUNCTION_CSV_QUEUE), - EventJobModule.registerQueue(TABLE_IMPORT_CSV_CHUNK_QUEUE), - EventJobModule.registerQueue(TABLE_IMPORT_CSV_QUEUE), + SpaceDataDbMigrationModule, ], }) export class SpaceModule {} diff --git a/apps/nestjs-backend/src/features/table/open-api/table-open-api-v2.service.ts b/apps/nestjs-backend/src/features/table/open-api/table-open-api-v2.service.ts index fbb64d1d3f..684b7867c3 100644 --- a/apps/nestjs-backend/src/features/table/open-api/table-open-api-v2.service.ts +++ b/apps/nestjs-backend/src/features/table/open-api/table-open-api-v2.service.ts @@ -10,6 +10,7 @@ import { type ITableFullVo, type ITableVo, } from '@teable/openapi'; +import { mapDomainErrorToHttpError, mapDomainErrorToHttpStatus } from '@teable/v2-contract-http'; import { executeCreateTableEndpoint, executeDeleteTableEndpoint, @@ -17,10 +18,14 @@ import { executeListTableRecordsEndpoint, executeRestoreTableEndpoint, } from '@teable/v2-contract-http-implementation/handlers'; -import { v2CoreTokens } from '@teable/v2-core'; -import type { ICommandBus, IExecutionContext, IQueryBus } from '@teable/v2-core'; +import { GetDefaultViewIdQuery, v2CoreTokens } from '@teable/v2-core'; +import type { + GetDefaultViewIdResult, + ICommandBus, + IExecutionContext, + IQueryBus, +} from '@teable/v2-core'; import { ClsService } from 'nestjs-cls'; -import { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exception'; import { InjectDbProvider } from '../../../db-provider/db.provider'; import { IDbProvider } from '../../../db-provider/db.provider.interface'; import { DatabaseRouter } from '../../../global/database-router.service'; @@ -32,6 +37,7 @@ import { RecordHistoryColdStorageService } from '../../record-history-cold/recor import { SpaceDataDbMigrationGuardService } from '../../space/space-data-db-migration-guard.service'; import { V2ContainerService } from '../../v2/v2-container.service'; import { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory'; +import { throwV2Error } from '../../v2/v2-http-error'; import { ViewService } from '../../view/view.service'; import { TableDuplicateService } from '../table-duplicate.service'; import { TableService } from '../table.service'; @@ -95,6 +101,32 @@ export class TableOpenApiV2Service { await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); } + async getDefaultViewId(tableId: string): Promise<{ id: string }> { + const container = await this.v2ContainerService.getContainerForTable(tableId); + const queryBus = container.resolve(v2CoreTokens.queryBus); + const context = await this.v2ContextFactory.createContext(container); + const queryResult = GetDefaultViewIdQuery.create({ tableId }); + if (queryResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(queryResult.error), + mapDomainErrorToHttpStatus(queryResult.error) + ); + } + + const result = await queryBus.execute( + context, + queryResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + + return { id: result.value.viewId }; + } + private async collectCrossSpaceAffectedFields( tableId: string ): Promise> { @@ -105,22 +137,6 @@ export class TableOpenApiV2Service { return this.tableDuplicateLegacyService.previewCrossSpaceAffectedFields(tableId); } - private throwV2Error( - error: { - code: string; - message: string; - tags?: ReadonlyArray; - details?: Readonly>; - }, - status: number - ): never { - throw new CustomHttpException(error.message, getDefaultCodeByStatus(status), { - domainCode: error.code, - domainTags: error.tags, - details: error.details, - }); - } - @Audit({ // Only open the CreateDefaultRecords scope for the canonical 3-empty-row UI default. // Custom records sent via API skip the attribution and produce plain atomic record events. @@ -157,7 +173,7 @@ export class TableOpenApiV2Service { } if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + throwV2Error(result.body.error, result.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); @@ -191,7 +207,7 @@ export class TableOpenApiV2Service { } if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + throwV2Error(result.body.error, result.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); @@ -217,7 +233,7 @@ export class TableOpenApiV2Service { } if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + throwV2Error(result.body.error, result.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); @@ -279,7 +295,7 @@ export class TableOpenApiV2Service { } if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + throwV2Error(result.body.error, result.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); @@ -360,7 +376,7 @@ export class TableOpenApiV2Service { } if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + throwV2Error(result.body.error, result.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); diff --git a/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts b/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts index ba4474f3ff..8d7510b096 100644 --- a/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts @@ -74,8 +74,12 @@ export class TableController { ) {} @Permissions('table|read') + @UseV2Feature('getDefaultViewId') @Get(':tableId/default-view-id') async getDefaultViewId(@Param('tableId') tableId: string): Promise<{ id: string }> { + if (this.cls.get('useV2')) { + return await this.tableOpenApiV2Service.getDefaultViewId(tableId); + } return await this.tableService.getDefaultViewId(tableId); } 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 59e040790a..a17622829e 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 @@ -514,7 +514,18 @@ export class TableOpenApiService { target: `table ${table.id}`, }); } - await this.tableMutationCacheInvalidator.invalidateDroppedTable(table.dbTableName); + try { + await this.tableMutationCacheInvalidator.invalidateDroppedTable(table.dbTableName); + } catch (error) { + handleBestEffortDataDbDropError({ + error, + isMetaFallback: await this.databaseRouter.isMetaFallbackForBase(table.baseId, { + useTransaction: true, + }), + logger: this.logger, + target: `mutation cache for table ${table.id}`, + }); + } } } @@ -734,7 +745,7 @@ export class TableOpenApiService { }); } - async updateIcon(baseId: string, tableId: string, icon: string) { + async updateIcon(baseId: string, tableId: string, icon: string | null) { await this.prismaService.$tx(async () => { await this.tableService.updateTable(baseId, tableId, { icon }); }); diff --git a/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.spec.ts b/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.spec.ts index 47dd92e2d8..21e726c445 100644 --- a/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.spec.ts +++ b/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.spec.ts @@ -40,7 +40,14 @@ describe('TableTrashListener', () => { tableId: 'tblTrashListenerTable', userId: 'usrTrashListenerUser', records: [ - { id: 'recTrashListenerOne', fields: { fldText: 'A' } }, + { + id: 'recTrashListenerOne', + fields: { fldText: 'A' }, + createdTime: '2026-07-01T00:00:00.000Z', + createdBy: 'usrTrashListenerCreator', + lastModifiedTime: '2026-07-02T00:00:00.000Z', + lastModifiedBy: 'usrTrashListenerModifier', + }, { id: 'recTrashListenerTwo', fields: { fldText: 'B' } }, ], }; @@ -69,9 +76,21 @@ describe('TableTrashListener', () => { id: expect.any(String), tableId: 'tblTrashListenerTable', recordId: 'recTrashListenerOne', - snapshot: JSON.stringify({ id: 'recTrashListenerOne', fields: { fldText: 'A' } }), + snapshot: JSON.stringify({ + id: 'recTrashListenerOne', + fields: { fldText: 'A' }, + createdTime: '2026-07-01T00:00:00.000Z', + createdBy: 'usrTrashListenerCreator', + lastModifiedTime: '2026-07-02T00:00:00.000Z', + lastModifiedBy: 'usrTrashListenerModifier', + }), createdBy: 'usrTrashListenerUser', createdTime: expect.any(Date), + operationId: 'oprTrashListenerRecord', + recordCreatedTime: new Date('2026-07-01T00:00:00.000Z'), + recordCreatedBy: 'usrTrashListenerCreator', + recordLastModifiedTime: new Date('2026-07-02T00:00:00.000Z'), + recordLastModifiedBy: 'usrTrashListenerModifier', }, { id: expect.any(String), @@ -80,6 +99,11 @@ describe('TableTrashListener', () => { snapshot: JSON.stringify({ id: 'recTrashListenerTwo', fields: { fldText: 'B' } }), createdBy: 'usrTrashListenerUser', createdTime: expect.any(Date), + operationId: 'oprTrashListenerRecord', + recordCreatedTime: undefined, + recordCreatedBy: undefined, + recordLastModifiedTime: undefined, + recordLastModifiedBy: undefined, }, ], }); diff --git a/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.ts b/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.ts index 806d12dff3..d2de59e8f2 100644 --- a/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.ts +++ b/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.ts @@ -1,6 +1,5 @@ import { Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; -import { generateRecordTrashId } from '@teable/core'; import { ResourceType } from '@teable/openapi'; import { IThresholdConfig, ThresholdConfig } from '../../../configs/threshold.config'; import { Events } from '../../../event-emitter/events'; @@ -8,6 +7,7 @@ import { DataDbClientManager } from '../../../global/data-db-client-manager.serv import { IDeleteFieldsPayload } from '../../undo-redo/operations/delete-fields.operation'; import { IDeleteRecordsPayload } from '../../undo-redo/operations/delete-records.operation'; import { IDeleteViewPayload } from '../../undo-redo/operations/delete-view.operation'; +import { buildRecordTrashRows } from '../record-trash-row'; type ITableTrashDataPrisma = { tableTrash: { @@ -67,9 +67,11 @@ export class TableTrashListener { @OnEvent(Events.OPERATION_RECORDS_DELETE) async recordDeleteListener(payload: IDeleteRecordsPayload) { - const { operationId, userId, tableId, records } = payload; + const { operationId, userId, tableId, records, removalReason } = payload; if (!operationId) return; + // Archive removals persist their own snapshot (with reason='archived') before deleting. + if (removalReason === 'archived') return; const recordIds = records.map((record) => record.id); const createdTime = new Date(); @@ -92,14 +94,7 @@ export class TableTrashListener { for (let i = 0; i < records.length; i += batchSize) { const batch = records.slice(i, i + batchSize); await prisma.recordTrash.createMany({ - data: batch.map((record) => ({ - id: generateRecordTrashId(), - tableId, - recordId: record.id, - snapshot: JSON.stringify(record), - createdBy: userId, - createdTime, - })), + data: buildRecordTrashRows(batch, { tableId, userId, createdTime, operationId }), }); } }, diff --git a/apps/nestjs-backend/src/features/trash/record-trash-row.ts b/apps/nestjs-backend/src/features/trash/record-trash-row.ts new file mode 100644 index 0000000000..fd42f2be15 --- /dev/null +++ b/apps/nestjs-backend/src/features/trash/record-trash-row.ts @@ -0,0 +1,35 @@ +import { generateRecordTrashId } from '@teable/core'; +import type { IRecord } from '@teable/core'; +import type { IRecordRemovalReason } from '@teable/v2-core'; + +type ISnapshotRecord = IRecord & { version?: number; order?: Record }; + +// Projects record snapshots into record_trash rows: the JSON snapshot plus the extracted +// metadata columns the trash/archive UIs filter and sort by. Omitting `reason` leaves the +// column to its DB default ('deleted'). +export const buildRecordTrashRows = ( + records: ISnapshotRecord[], + options: { + tableId: string; + userId: string; + createdTime: Date; + operationId?: string; + reason?: IRecordRemovalReason; + } +) => { + const { tableId, userId, createdTime, operationId, reason } = options; + return records.map((record) => ({ + id: generateRecordTrashId(), + tableId, + recordId: record.id, + snapshot: JSON.stringify(record), + createdBy: userId, + createdTime, + operationId, + reason, + recordCreatedTime: record.createdTime ? new Date(record.createdTime) : undefined, + recordCreatedBy: record.createdBy, + recordLastModifiedTime: record.lastModifiedTime ? new Date(record.lastModifiedTime) : undefined, + recordLastModifiedBy: record.lastModifiedBy, + })); +}; diff --git a/apps/nestjs-backend/src/features/trash/trash-freeze.service.spec.ts b/apps/nestjs-backend/src/features/trash/trash-freeze.service.spec.ts index 82851be345..172fd5298a 100644 --- a/apps/nestjs-backend/src/features/trash/trash-freeze.service.spec.ts +++ b/apps/nestjs-backend/src/features/trash/trash-freeze.service.spec.ts @@ -63,9 +63,13 @@ describe('TrashService write freeze', () => { {} as never, {} as never, {} as never, + {} as never, dataDbClientManager as never, {} as never, {} as never, + {} as never, + {} as never, + {} as never, migrationGuard as never ); diff --git a/apps/nestjs-backend/src/features/trash/trash.controller.ts b/apps/nestjs-backend/src/features/trash/trash.controller.ts index 75d29f0d8e..caa0f9bf18 100644 --- a/apps/nestjs-backend/src/features/trash/trash.controller.ts +++ b/apps/nestjs-backend/src/features/trash/trash.controller.ts @@ -1,6 +1,11 @@ import { Controller, Delete, Get, Param, Post, Query, Res } from '@nestjs/common'; import { IdPrefix } from '@teable/core'; -import type { IRestoreFieldTrashStreamEvent, ITrashVo, V2Feature } from '@teable/openapi'; +import type { + IGetTrashItemRecordsVo, + IRestoreFieldTrashStreamEvent, + ITrashVo, + V2Feature, +} from '@teable/openapi'; import { ITrashRo, trashItemsRoSchema, @@ -8,6 +13,8 @@ import { ITrashItemsRo, resetTrashItemsRoSchema, IResetTrashItemsRo, + getTrashItemRecordsQuerySchema, + IGetTrashItemRecordsQuery, } from '@teable/openapi'; import type { Response } from 'express'; import { ClsService } from 'nestjs-cls'; @@ -43,6 +50,15 @@ export class TrashController { return await this.trashService.getTrashItems(query); } + @Get(':trashId/records') + @TokenAccess() + async getTrashItemRecords( + @Param('trashId') trashId: string, + @Query(new ZodValidationPipe(getTrashItemRecordsQuerySchema)) query: IGetTrashItemRecordsQuery + ): Promise { + return await this.trashService.getTableTrashItemRecords(trashId, query); + } + @Post('restore/:trashId') @TokenAccess() async restoreTrash( diff --git a/apps/nestjs-backend/src/features/trash/trash.module.ts b/apps/nestjs-backend/src/features/trash/trash.module.ts index 322a2d9222..5a5e9bd531 100644 --- a/apps/nestjs-backend/src/features/trash/trash.module.ts +++ b/apps/nestjs-backend/src/features/trash/trash.module.ts @@ -5,6 +5,7 @@ import { CanaryModule } from '../canary/canary.module'; import { FieldOpenApiModule } from '../field/open-api/field-open-api.module'; import { RecordOpenApiModule } from '../record/open-api/record-open-api.module'; import { RecordModule } from '../record/record.module'; +import { RecordRemovalColdCoreModule } from '../record-removal-cold/record-removal-cold.module'; import { SpaceModule } from '../space/space.module'; import { TableOpenApiModule } from '../table/open-api/table-open-api.module'; import { UserModule } from '../user/user.module'; @@ -25,6 +26,7 @@ import { V2TableTrashService } from './v2-table-trash.service'; CanaryModule, TableOpenApiModule, FieldOpenApiModule, + RecordRemovalColdCoreModule, RecordOpenApiModule, RecordModule, V2Module, diff --git a/apps/nestjs-backend/src/features/trash/trash.service.ts b/apps/nestjs-backend/src/features/trash/trash.service.ts index d4d0681ac5..ddc38f8565 100644 --- a/apps/nestjs-backend/src/features/trash/trash.service.ts +++ b/apps/nestjs-backend/src/features/trash/trash.service.ts @@ -1,12 +1,16 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { Injectable, Optional } from '@nestjs/common'; -import type { FieldType, IFieldVo } from '@teable/core'; -import { FieldKeyType, HttpErrorCode, IdPrefix, Role } from '@teable/core'; +import { Injectable, Optional, ServiceUnavailableException } from '@nestjs/common'; +import type { FieldType, IFieldVo, IRecord } from '@teable/core'; +import { HttpErrorCode, IdPrefix, Role } from '@teable/core'; +import type { DataPrismaService } from '@teable/db-data-prisma'; import { PrismaService, type Prisma } from '@teable/db-main-prisma'; import type { + IGetTrashItemRecordsQuery, + IGetTrashItemRecordsVo, IRestoreFieldTrashStreamEvent, IResetTrashItemsRo, IResourceMapVo, + ITrashItemRecordVo, ITrashItemsRo, ITrashItemVo, ITrashRo, @@ -15,8 +19,8 @@ import type { } from '@teable/openapi'; import { CollaboratorType, ResourceType, TableTrashType, TrashType } from '@teable/openapi'; import { + RECORD_REMOVAL_REASON, RestoreFieldStreamCommand, - RestoreRecordsCommand, RestoreRecordsStreamCommand, TableId, v2CoreTokens, @@ -25,7 +29,6 @@ import type { ICommandBus, RestoreFieldStreamResult, RestoreRecordInput, - RestoreRecordsResult, RestoreRecordsStreamResult, Table, TableQueryService, @@ -47,29 +50,93 @@ import { getPublicFullStorageUrl } from '../attachments/plugins/utils'; import { PermissionService } from '../auth/permission.service'; import { BaseService } from '../base/base.service'; import { CanaryService, type IV2Decision } from '../canary/canary.service'; +import type { IFieldInstance } from '../field/model/factory'; import { FieldOpenApiV2Service } from '../field/open-api/field-open-api-v2.service'; import { FieldOpenApiService } from '../field/open-api/field-open-api.service'; import { restoreFieldRecordValues } from '../field/restore-field-record-values'; import { RecordOpenApiV2Service } from '../record/open-api/record-open-api-v2.service'; import { RecordOpenApiService } from '../record/open-api/record-open-api.service'; +import { RecordRestoreService } from '../record/open-api/record-restore.service'; import { RecordService } from '../record/record.service'; +import type { IColdRemovalRow } from '../record-removal-cold/part-codec'; +import type { IRemovalColdBoundary } from '../record-removal-cold/record-removal-cold-read.service'; +import { + decodeRemovalColdCursor, + encodeRemovalColdCursor, + RecordRemovalColdReadService, +} from '../record-removal-cold/record-removal-cold-read.service'; +import { RecordRemovalColdStorageService } from '../record-removal-cold/record-removal-cold-storage.service'; +import { + isTombstonedAt, + RecordRemovalTombstoneService, +} from '../record-removal-cold/record-removal-tombstone.service'; import { SpaceDataDbMigrationGuardService } from '../space/space-data-db-migration-guard.service'; import { SpaceService } from '../space/space.service'; import { TableOpenApiV2Service } from '../table/open-api/table-open-api-v2.service'; import { TableOpenApiService } from '../table/open-api/table-open-api.service'; -import type { IDeleteRecordsPayload } from '../undo-redo/operations/delete-records.operation'; import { UserService } from '../user/user.service'; import { V2ContainerService } from '../v2/v2-container.service'; import { V2ExecutionContextFactory } from '../v2/v2-execution-context.factory'; import { ViewService } from '../view/view.service'; import { resolveV2TrashRecordDisplayName } from './v2-trash-record-name'; -type IRecordTrashSnapshot = IDeleteRecordsPayload['records'][number]; - // A single trash item can reference tens of thousands of resource ids (bulk record deletion), // while postgres prepared statements accept at most 32767 bind variables per query. const IN_CHUNK = 5000; +// The list only previews the first few resources of each trash item (name resolution +// included); the full set is paged through the item records endpoint. +const TABLE_TRASH_RESOURCE_PREVIEW_LIMIT = 20; + +const TRASH_RECORD_DEFAULT_TAKE = 50; + +// Hot-zone scan budget for LEGACY trash items (rows predating the operation_id column): +// the walk filters item membership app-side, so a busy table could make one page scan far +// more rows than it serves — cap the work and hand back a resume cursor instead. +const TRASH_HOT_SCAN_BATCH = 1000; +const TRASH_HOT_MAX_SCANNED = 5000; + +// rth1: hot-zone cursor of the trash-item records walk — exclusive (created_time, id) +// resume point in the PG zone. Once a page is served (even partially) from cold parts the +// cursor becomes the cold reader's self-describing `rms1:` form and skips PG entirely. +const TRASH_HOT_CURSOR_PREFIX = 'rth1:'; + +const encodeTrashHotCursor = (k: Date, id: string): string => + TRASH_HOT_CURSOR_PREFIX + + Buffer.from(JSON.stringify({ k: k.toISOString(), id })).toString('base64url'); + +const decodeTrashHotCursor = (cursor: string): { k: Date; id: string } | undefined => { + if (!cursor.startsWith(TRASH_HOT_CURSOR_PREFIX)) return undefined; + try { + const payload = JSON.parse( + Buffer.from(cursor.slice(TRASH_HOT_CURSOR_PREFIX.length), 'base64url').toString() + ) as { k: string; id: string }; + const k = new Date(payload.k); + if (Number.isNaN(k.getTime()) || typeof payload.id !== 'string') return undefined; + return { k, id: payload.id }; + } catch { + return undefined; + } +}; + +type ITrashRecordHotRow = { + id: string; + recordId: string; + snapshot: string; + createdTime: Date; + createdBy: string; + recordCreatedTime: Date | null; + recordCreatedBy: string | null; + recordLastModifiedTime: Date | null; + recordLastModifiedBy: string | null; +}; + +const maxDefinedDate = (a?: Date, b?: Date): Date | undefined => { + if (!a) return b; + if (!b) return a; + return a > b ? a : b; +}; + type IRestoreProgressInput = { phase: 'preparing' | 'restoring'; batchIndex: number; @@ -133,6 +200,12 @@ type ITableTrashDelegate = { createdTime: Date; }> >; + findFirst(args: TArgs): Promise<{ + id: string; + resourceType: string; + snapshot: string; + createdTime: Date; + } | null>; findUniqueOrThrow(args: TArgs): Promise<{ tableId: string; resourceType: string; @@ -150,6 +223,11 @@ type IRecordTrashDelegate = { recordId: string; snapshot: string; createdTime: Date; + createdBy: string; + recordCreatedTime: Date | null; + recordCreatedBy: string | null; + recordLastModifiedTime: Date | null; + recordLastModifiedBy: string | null; }> >; deleteMany(args: TArgs): Promise; @@ -160,6 +238,12 @@ type ITrashDataPrisma = { recordTrash: IRecordTrashDelegate; }; +export type IGetTrashItemsOptions = { + // Hide table-trash rows created before this instant (plan read window); rows are hidden, + // never deleted. + createdTimeAfter?: Date; +}; + type IScopedTrashDataPrisma = ITrashDataPrisma & { txClient?: () => ITrashDataPrisma; $tx?: ( @@ -188,12 +272,16 @@ export class TrashService { protected readonly fieldOpenApiV2Service: FieldOpenApiV2Service, protected readonly recordOpenApiService: RecordOpenApiService, protected readonly recordOpenApiV2Service: RecordOpenApiV2Service, + protected readonly recordRestoreService: RecordRestoreService, protected readonly recordService: RecordService, protected readonly viewService: ViewService, protected readonly v2ContainerService: V2ContainerService, protected readonly v2ExecutionContextFactory: V2ExecutionContextFactory, protected readonly canaryService: CanaryService, protected readonly dataDbClientManager: DataDbClientManager, + protected readonly recordRemovalTombstoneService: RecordRemovalTombstoneService, + protected readonly recordRemovalColdStorageService: RecordRemovalColdStorageService, + protected readonly recordRemovalColdReadService: RecordRemovalColdReadService, @ThresholdConfig() protected readonly thresholdConfig: IThresholdConfig, @InjectModel(META_KNEX) protected readonly knex: Knex, @Optional() @@ -239,6 +327,16 @@ export class TrashService { })) as IScopedTrashDataPrisma; } + // Full-typed executor for the tombstone service (the narrow ITrashDataPrisma + // view has no recordRemovalTombstone delegate); the tombstone table lives in + // the same data db as record_trash. + private async trashTombstoneClientForTable(tableId: string): Promise { + const prisma = (await this.dataDbClientManager.dataPrismaForTable(tableId, { + useTransaction: true, + })) as DataPrismaService; + return (prisma.txClient?.() ?? prisma) as DataPrismaService; + } + private async trashDataPrismaTransactionForTable( tableId: string, fn: (prisma: ITrashDataPrisma) => Promise @@ -432,14 +530,17 @@ export class TrashService { }; } - async getTrashItems(trashItemsRo: ITrashItemsRo): Promise { + async getTrashItems( + trashItemsRo: ITrashItemsRo, + options?: IGetTrashItemsOptions + ): Promise { const { resourceType } = trashItemsRo; switch (resourceType) { case TrashType.Base: return await this.getBaseTrashItems(trashItemsRo); case TrashType.Table: - return await this.getTableTrashItems(trashItemsRo); + return await this.getTableTrashItems(trashItemsRo, options); default: throw new CustomHttpException( `Invalid resource type ${resourceType}`, @@ -599,7 +700,7 @@ export class TrashService { await Promise.all( chunk(resourceIds, IN_CHUNK).map((ids) => dataPrisma.recordTrash.findMany({ - where: { tableId, recordId: { in: ids } }, + where: { tableId, recordId: { in: ids }, reason: RECORD_REMOVAL_REASON.Deleted }, select: { recordId: true, snapshot: true, @@ -624,8 +725,19 @@ export class TrashService { } } - async getTableTrashItems(trashItemsRo: ITrashItemsRo): Promise { - const { resourceId: tableId, cursor, pageSize = 20 } = trashItemsRo; + async getTableTrashItems( + trashItemsRo: ITrashItemsRo, + options?: IGetTrashItemsOptions + ): Promise { + const { + resourceId: tableId, + cursor, + pageSize = 20, + resourceTypes, + deletedBy, + deletedTimeStart, + deletedTimeEnd, + } = trashItemsRo; const accessTokenId = this.cls.get('accessTokenId'); let nextCursor: typeof cursor | undefined = undefined; @@ -636,10 +748,28 @@ export class TrashService { true ); + // Plan read window (EE) and the user's deleted-time filter combine to the later bound; + // rows outside the window stay stored but are hidden from the list. + const createdTimeGte = maxDefinedDate( + options?.createdTimeAfter, + deletedTimeStart ? new Date(deletedTimeStart) : undefined + ); + const createdTimeLte = deletedTimeEnd ? new Date(deletedTimeEnd) : undefined; + const dataPrisma = this.getTrashDataPrismaExecutor(await this.trashDataPrismaForTable(tableId)); const list = await dataPrisma.tableTrash.findMany({ where: { tableId, + ...(resourceTypes?.length ? { resourceType: { in: resourceTypes } } : {}), + ...(deletedBy?.length ? { createdBy: { in: deletedBy } } : {}), + ...(createdTimeGte || createdTimeLte + ? { + createdTime: { + ...(createdTimeGte ? { gte: createdTimeGte } : {}), + ...(createdTimeLte ? { lte: createdTimeLte } : {}), + }, + } + : {}), }, select: { id: true, @@ -674,11 +804,12 @@ export class TrashService { const parsedSnapshot = JSON.parse(snapshot); const resourceType = item.resourceType as TableTrashType; - const resourceIds = + const resourceIds: string[] = resourceType === TableTrashType.Field ? (parsedSnapshot.fields as IFieldVo[]).map(({ id }) => id) : parsedSnapshot; - deletedResourceMap[resourceType].push(...resourceIds); + const previewResourceIds = resourceIds.slice(0, TABLE_TRASH_RESOURCE_PREVIEW_LIMIT); + deletedResourceMap[resourceType].push(...previewResourceIds); deletedBySet.add(createdBy); return { @@ -686,7 +817,8 @@ export class TrashService { resourceType: resourceType, deletedTime: createdTime.toISOString(), deletedBy: createdBy, - resourceIds, + resourceIds: previewResourceIds, + totalResourceCount: resourceIds.length, }; }); @@ -709,6 +841,452 @@ export class TrashService { }; } + async getTableTrashItemRecords( + trashId: string, + query: IGetTrashItemRecordsQuery, + options?: IGetTrashItemsOptions + ): Promise { + const { tableId, cursor, take = TRASH_RECORD_DEFAULT_TAKE } = query; + const accessTokenId = this.cls.get('accessTokenId'); + + await this.permissionService.validPermissions( + tableId, + ['table|trash_read'], + accessTokenId, + true + ); + + const dataPrisma = this.getTrashDataPrismaExecutor(await this.trashDataPrismaForTable(tableId)); + const [trashItem, fieldInstances] = await Promise.all([ + this.loadRecordTrashItem(dataPrisma, trashId, tableId, options), + this.recordService.getFieldsByProjection(tableId), + ]); + + const recordIds = JSON.parse(trashItem.snapshot) as string[]; + const idSet = new Set(recordIds); + + // Dual-zone cursor, mirroring the archive list merge: while pages come from PG the + // cursor is the rth1: keyset form; once a page is served (even partially) from cold + // parts it becomes the cold reader's rms1: cursor, which skips PG entirely. + const coldCursor = cursor ? decodeRemovalColdCursor(cursor) : undefined; + const hotCursor = cursor && !coldCursor ? decodeTrashHotCursor(cursor) : undefined; + if (cursor && !coldCursor && !hotCursor) { + throw new CustomHttpException('Invalid trash records cursor', HttpErrorCode.VALIDATION_ERROR); + } + + let hotRows: ITrashRecordHotRow[] = []; + let nextCursor: string | null = null; + let boundary: IRemovalColdBoundary | undefined = coldCursor?.boundary; + let fillFromCold = Boolean(coldCursor); + if (!coldCursor) { + const hot = await this.collectHotTrashItemRecords({ + dataPrisma, + trashId, + tableId, + itemCreatedTime: trashItem.createdTime, + idSet, + query, + take, + hotCursor, + }); + hotRows = hot.rows; + if (hot.nextCursor) { + nextCursor = hot.nextCursor; + } else { + fillFromCold = true; + boundary = hot.boundary; + } + } + + let coldRows: IColdRemovalRow[] = []; + if (fillFromCold) { + ({ coldRows, nextCursor } = await this.fillTrashItemColdPage({ + tableId, + idSet, + itemCreatedTime: trashItem.createdTime, + query, + pageSize: take, + hotRows, + boundary, + })); + } + + const items = hotRows.map((row) => this.buildTrashItemRecordVo(row, fieldInstances)); + // cold rows are already predicate-filtered and ordered after the PG zone; their + // time dims are the flusher's canonical ISO strings + for (const row of coldRows) { + items.push({ + id: row.id, + recordId: row.recordId, + record: this.normalizeTrashRecordSnapshot( + fieldInstances, + JSON.parse(row.snapshot) as IRecord + ), + deletedTime: row.removedTime, + deletedBy: row.removedBy, + recordCreatedTime: row.recordCreatedTime ?? null, + recordCreatedBy: row.recordCreatedBy ?? null, + recordLastModifiedTime: row.recordLastModifiedTime ?? null, + recordLastModifiedBy: row.recordLastModifiedBy ?? null, + }); + } + + const userList = await this.userService.getUserInfoList( + Array.from(this.collectTrashRecordUserIds(items)) + ); + + return { + items, + userMap: keyBy(userList, 'id'), + nextCursor, + }; + } + + // Hot (PG) zone of one trash-item records page, keyset-ordered by + // (created_time DESC, id DESC). Items whose rows carry operation_id read straight off + // the operation-scoped partial index; LEGACY items (rows predating the column) walk the + // table's deleted timeline and filter item membership app-side under a scan budget. + // Latest-wins per record id holds within one request via `servedRecordIds`; a duplicate + // pair split across pages can only exist in the transient window between a restore and + // its row cleanup — the same accepted edge the pre-merge implementation carried. + private async collectHotTrashItemRecords(params: { + dataPrisma: ITrashDataPrisma; + trashId: string; + tableId: string; + itemCreatedTime: Date; + idSet: Set; + query: IGetTrashItemRecordsQuery; + take: number; + hotCursor?: { k: Date; id: string }; + }): Promise<{ + rows: ITrashRecordHotRow[]; + nextCursor: string | null; + boundary?: IRemovalColdBoundary; + }> { + const { dataPrisma, trashId, tableId, itemCreatedTime, idSet, query, take } = params; + const probe = await dataPrisma.recordTrash.findMany({ + where: { tableId, operationId: trashId, reason: RECORD_REMOVAL_REASON.Deleted }, + select: { id: true }, + take: 1, + }); + const usesOperationId = probe.length > 0; + const filters = this.buildTrashRecordSnapshotFilters(query); + + const rows: ITrashRecordHotRow[] = []; + const servedRecordIds = new Set(); + let position = params.hotCursor; + let scanned = 0; + let exhausted = false; + + while (rows.length <= take && !exhausted && scanned < TRASH_HOT_MAX_SCANNED) { + const batchTake = usesOperationId ? take + 1 - rows.length : TRASH_HOT_SCAN_BATCH; + const batch = (await dataPrisma.recordTrash.findMany({ + where: { + tableId, + reason: RECORD_REMOVAL_REASON.Deleted, + ...(usesOperationId ? { operationId: trashId } : {}), + ...filters, + ...this.buildHotTrashKeysetWhere(itemCreatedTime, position), + }, + select: { + id: true, + recordId: true, + snapshot: true, + createdTime: true, + createdBy: true, + recordCreatedTime: true, + recordCreatedBy: true, + recordLastModifiedTime: true, + recordLastModifiedBy: true, + }, + orderBy: [{ createdTime: 'desc' }, { id: 'desc' }], + take: batchTake, + })) as ITrashRecordHotRow[]; + + scanned += batch.length; + this.collectHotTrashBatch({ batch, take, idSet, servedRecordIds, rows }); + if (batch.length < batchTake) { + exhausted = true; + } else { + const last = batch[batch.length - 1]; + position = { k: last.createdTime, id: last.id }; + } + } + + return this.resolveHotTrashPageOutcome({ + rows, + take, + exhausted, + position, + hotCursor: params.hotCursor, + }); + } + + // Keyset predicate of the hot walk: an exclusive (created_time, id) resume point, or — + // from the top — only snapshots that belong to this trash item, not rows written by a + // later delete of the same record ids. + private buildHotTrashKeysetWhere(itemCreatedTime: Date, position?: { k: Date; id: string }) { + return position + ? { + OR: [ + { createdTime: { lt: position.k } }, + { createdTime: position.k, id: { lt: position.id } }, + ], + } + : { createdTime: { lte: itemCreatedTime } }; + } + + private collectHotTrashBatch(params: { + batch: ITrashRecordHotRow[]; + take: number; + idSet: Set; + servedRecordIds: Set; + rows: ITrashRecordHotRow[]; + }): void { + const { batch, take, idSet, servedRecordIds, rows } = params; + for (const row of batch) { + if (rows.length > take) return; + if (!idSet.has(row.recordId) || servedRecordIds.has(row.recordId)) continue; + servedRecordIds.add(row.recordId); + rows.push(row); + } + } + + private resolveHotTrashPageOutcome(params: { + rows: ITrashRecordHotRow[]; + take: number; + exhausted: boolean; + position?: { k: Date; id: string }; + hotCursor?: { k: Date; id: string }; + }): { rows: ITrashRecordHotRow[]; nextCursor: string | null; boundary?: IRemovalColdBoundary } { + const { rows, take, exhausted, position, hotCursor } = params; + if (rows.length > take) { + rows.pop(); + const last = rows[rows.length - 1]; + return { rows, nextCursor: encodeTrashHotCursor(last.createdTime, last.id) }; + } + if (!exhausted) { + // scan budget hit before the page filled: a partial page with a resume point at + // the last scanned row — every request makes progress + return { + rows, + nextCursor: position ? encodeTrashHotCursor(position.k, position.id) : null, + }; + } + // hot zone exhausted: cold continues strictly after the last served row (or the + // incoming resume point when this request served nothing) + const lastServed = rows[rows.length - 1]; + const boundary = lastServed + ? { k: lastServed.createdTime.toISOString(), id: lastServed.id } + : hotCursor + ? { k: hotCursor.k.toISOString(), id: hotCursor.id } + : undefined; + return { rows, nextCursor: null, boundary }; + } + + // Cold continuation of one trash-item records page: shortfall fill from the deleted/ + // parts (or the seam cursor when PG filled the page exactly) plus the S3 degradation + // rule, mirroring the archive list merge. + private async fillTrashItemColdPage(params: { + tableId: string; + idSet: Set; + itemCreatedTime: Date; + query: IGetTrashItemRecordsQuery; + pageSize: number; + hotRows: ITrashRecordHotRow[]; + boundary?: IRemovalColdBoundary; + }): Promise<{ coldRows: IColdRemovalRow[]; nextCursor: string | null }> { + const { tableId, idSet, itemCreatedTime, query, pageSize, hotRows, boundary } = params; + const shortfall = pageSize - hotRows.length; + // seeded with the hot page ids: rows already sunk to parts but not yet deleted from + // the buffer exist in both stores and must not be served twice + const seenIds = new Set(hotRows.map((row) => row.id)); + try { + if (shortfall <= 0) { + // hot rows filled the page exactly: hand out a seam cursor instead of probing S3 + // now — the next request serves the (possibly empty) cold tail + return { coldRows: [], nextCursor: encodeRemovalColdCursor(boundary) }; + } + const tombstoneClient = await this.trashTombstoneClientForTable(tableId); + const tombstones = await this.recordRemovalTombstoneService.loadTombstonedRecordIds( + tombstoneClient, + tableId + ); + const cold = await this.recordRemovalColdReadService.collectArchivedRows({ + tableId, + reason: RECORD_REMOVAL_REASON.Deleted, + limit: shortfall, + orderBy: 'removedTime', + direction: 'desc', + boundary, + filters: { + // rows of this item share the item's delete instant; later re-deletes of the + // same record ids carry newer removedTimes and stay out + removedTimeEnd: itemCreatedTime.toISOString(), + recordCreatedBys: query.recordCreatedBy, + recordCreatedTimeStart: query.recordCreatedTimeStart, + recordCreatedTimeEnd: query.recordCreatedTimeEnd, + }, + // item membership: rows of other delete operations in the same month do not + // count toward the page + rowPredicate: (row) => idSet.has(row.recordId), + isTombstoned: (recordId, removedTime) => isTombstonedAt(tombstones, recordId, removedTime), + seenIds, + }); + return { coldRows: cold.rows, nextCursor: cold.nextCursor }; + } catch (error) { + // an S3 outage/timeout must not take the hot rows down with it: degrade to the hot + // rows plus a retryable cold cursor pinned at the boundary. Only an entirely empty + // response propagates the failure, mirroring the archive merge. + if (!(error instanceof ServiceUnavailableException) || hotRows.length === 0) { + throw error; + } + return { coldRows: [], nextCursor: encodeRemovalColdCursor(boundary) }; + } + } + + // Cold fallback for a trash-item restore: ids with no PG snapshot row may have sunk + // past the flush horizon. Latest cold row per id, tombstone-filtered, and bounded to + // rows belonging to THIS item (removedTime <= the item's delete instant) — a record + // individually restored and re-deleted later owns a newer cold row that must stay + // untouched. The read service throws ServiceUnavailable past its S3 budget and that + // propagates deliberately: restore stays all-or-nothing per request (a partial scan + // could restore a stale snapshot); retries progress through the part byte cache. + private async lookupColdTrashRows( + tableId: string, + recordIds: string[], + itemCreatedTime: Date + ): Promise { + const client = await this.trashTombstoneClientForTable(tableId); + const tombstones = await this.recordRemovalTombstoneService.loadTombstonedRecordIds( + client, + tableId + ); + const found = await this.recordRemovalColdReadService.lookupArchivedRowsByRecordIds({ + tableId, + reason: RECORD_REMOVAL_REASON.Deleted, + recordIds, + isTombstoned: (recordId, removedTime) => isTombstonedAt(tombstones, recordId, removedTime), + }); + const itemTimeIso = itemCreatedTime.toISOString(); + return [...found.values()].filter((row) => row.removedTime <= itemTimeIso); + } + + private buildTrashRecordSnapshotFilters(query: IGetTrashItemRecordsQuery) { + const { recordCreatedBy, recordCreatedTimeStart, recordCreatedTimeEnd } = query; + return { + ...(recordCreatedBy?.length ? { recordCreatedBy: { in: recordCreatedBy } } : {}), + ...(recordCreatedTimeStart || recordCreatedTimeEnd + ? { + recordCreatedTime: { + ...(recordCreatedTimeStart ? { gte: new Date(recordCreatedTimeStart) } : {}), + ...(recordCreatedTimeEnd ? { lte: new Date(recordCreatedTimeEnd) } : {}), + }, + } + : {}), + }; + } + + private async loadRecordTrashItem( + dataPrisma: ITrashDataPrisma, + trashId: string, + tableId: string, + options?: IGetTrashItemsOptions + ) { + const trashItem = await dataPrisma.tableTrash.findFirst({ + where: { + id: trashId, + tableId, + // Plan read window (EE): items hidden from the list are hidden from the detail too. + ...(options?.createdTimeAfter ? { createdTime: { gte: options.createdTimeAfter } } : {}), + }, + select: { + id: true, + resourceType: true, + snapshot: true, + createdTime: true, + }, + }); + + if (!trashItem) { + throw new CustomHttpException( + `The table trash ${trashId} not found`, + HttpErrorCode.NOT_FOUND, + { + localization: { + i18nKey: 'httpErrors.trash.tableNotFound', + }, + } + ); + } + + if (trashItem.resourceType !== TableTrashType.Record) { + throw new CustomHttpException( + `Invalid resource type ${trashItem.resourceType}`, + HttpErrorCode.VALIDATION_ERROR, + { + localization: { + i18nKey: 'httpErrors.trash.invalidResourceType', + }, + } + ); + } + + return trashItem; + } + + private buildTrashItemRecordVo( + row: ITrashRecordHotRow, + fieldInstances: IFieldInstance[] + ): ITrashItemRecordVo { + return { + id: row.id, + recordId: row.recordId, + record: this.normalizeTrashRecordSnapshot( + fieldInstances, + JSON.parse(row.snapshot) as IRecord + ), + deletedTime: row.createdTime.toISOString(), + deletedBy: row.createdBy, + recordCreatedTime: row.recordCreatedTime?.toISOString() ?? null, + recordCreatedBy: row.recordCreatedBy ?? null, + recordLastModifiedTime: row.recordLastModifiedTime?.toISOString() ?? null, + recordLastModifiedBy: row.recordLastModifiedBy ?? null, + }; + } + + private collectTrashRecordUserIds(items: ITrashItemRecordVo[]): Set { + const userIds = new Set(); + for (const item of items) { + userIds.add(item.deletedBy); + if (item.recordCreatedBy) { + userIds.add(item.recordCreatedBy); + } + if (item.recordLastModifiedBy) { + userIds.add(item.recordLastModifiedBy); + } + } + return userIds; + } + + // Deletion snapshots differ by engine: v1 stores normalized cell values while v2 stores + // raw db column values. convertDBValue2CellValue is idempotent on normalized values, so + // it is applied unconditionally; a field that fails to convert keeps its snapshot value. + private normalizeTrashRecordSnapshot(fieldInstances: IFieldInstance[], record: IRecord): IRecord { + const fields: IRecord['fields'] = { ...record.fields }; + for (const field of fieldInstances) { + if (!(field.id in fields)) { + continue; + } + try { + fields[field.id] = field.convertDBValue2CellValue(fields[field.id] as never); + } catch { + // Keep the snapshot value; the client tolerates unknown shapes. + } + } + return { ...record, fields }; + } + protected async getBaseTrashResourceList(baseId: string) { return await this.prismaService.tableMeta.findMany({ where: { @@ -1193,7 +1771,7 @@ export class TrashService { await Promise.all( chunk(recordIds, IN_CHUNK).map((ids) => lookupDataPrisma.recordTrash.findMany({ - where: { tableId, recordId: { in: ids } }, + where: { tableId, recordId: { in: ids }, reason: RECORD_REMOVAL_REASON.Deleted }, select: { id: true, recordId: true, @@ -1216,8 +1794,13 @@ export class TrashService { const matchedRecordTrashRows = recordIds .map((recordId) => latestSnapshotsByRecordId.get(recordId)) .filter((row): row is (typeof recordTrashRows)[number] => row != null); - const records = matchedRecordTrashRows.map(({ snapshot }) => - this.toV2RestoreRecord(JSON.parse(snapshot)) + // Cold fallback: ids with no PG snapshot row may have sunk past the flush horizon. + const missingIds = recordIds.filter((recordId) => !latestSnapshotsByRecordId.has(recordId)); + const coldTrashRows = missingIds.length + ? await this.lookupColdTrashRows(tableId, missingIds, createdTime) + : []; + const records = [...matchedRecordTrashRows, ...coldTrashRows].map(({ snapshot }) => + this.recordRestoreService.toV2RestoreRecord(JSON.parse(snapshot)) ); yield this.createRestoreProgressEvent(ResourceType.Record, { @@ -1293,6 +1876,17 @@ export class TrashService { }); }); + // Cold-copy suppression: a trash row already uploaded to a cold part (flush + // overlap window) outlives the deleteMany above and would resurface in merged + // reads once the buffer drains; cold-fetched rows have no PG row at all and rely + // on the marker alone. Marked only after the restore succeeded, matching the + // archive restore ordering. + await this.recordRemovalTombstoneService.markRestored( + await this.trashTombstoneClientForTable(tableId), + tableId, + [...matchedRecordTrashRows, ...coldTrashRows].map(({ recordId }) => recordId) + ); + yield this.createRestoreDoneEvent(ResourceType.Record, { totalCount: records.length, restoredCount, @@ -1472,7 +2066,7 @@ export class TrashService { await Promise.all( chunk(recordIds, IN_CHUNK).map((ids) => dataPrisma.recordTrash.findMany({ - where: { tableId, recordId: { in: ids } }, + where: { tableId, recordId: { in: ids }, reason: RECORD_REMOVAL_REASON.Deleted }, select: { id: true, recordId: true, @@ -1500,30 +2094,16 @@ export class TrashService { const matchedRecordTrashRows = recordIds .map((recordId) => latestSnapshotsByRecordId.get(recordId)) .filter((row): row is IRecordTrashSnapshotRow => row != null); - const records = matchedRecordTrashRows.map(({ snapshot }) => JSON.parse(snapshot)); - - if (await this.shouldRestoreRecordsWithV2(tableId)) { - await this.restoreRecordsV2(tableId, records); - await this.trashDataPrismaTransactionForTable(tableId, async (prisma) => { - await prisma.recordTrash.deleteMany({ - where: { id: { in: matchedRecordTrashRows.map(({ id }) => id) } }, - }); - await prisma.tableTrash.delete({ - where: { id: trashId }, - }); - }); - return; - } - - await this.recordOpenApiService.multipleCreateRecords( - tableId, - { - fieldKeyType: FieldKeyType.Id, - records, - typecast: true, - }, - true + // Cold fallback: ids with no PG snapshot row may have sunk past the flush horizon. + const missingIds = recordIds.filter((recordId) => !latestSnapshotsByRecordId.has(recordId)); + const coldTrashRows = missingIds.length + ? await this.lookupColdTrashRows(tableId, missingIds, createdTime) + : []; + const records = [...matchedRecordTrashRows, ...coldTrashRows].map(({ snapshot }) => + JSON.parse(snapshot) ); + + await this.recordRestoreService.restoreRecordSnapshots(tableId, records); await this.trashDataPrismaTransactionForTable(tableId, async (prisma) => { await prisma.recordTrash.deleteMany({ where: { id: { in: matchedRecordTrashRows.map(({ id }) => id) } }, @@ -1532,6 +2112,12 @@ export class TrashService { where: { id: trashId }, }); }); + // Cold-copy suppression, same rule as the stream restore path above. + await this.recordRemovalTombstoneService.markRestored( + await this.trashTombstoneClientForTable(tableId), + tableId, + [...matchedRecordTrashRows, ...coldTrashRows].map(({ recordId }) => recordId) + ); return; } default: @@ -1551,70 +2137,15 @@ export class TrashService { }); } - private async shouldRestoreRecordsWithV2(tableId: string): Promise { - const table = await this.prismaService.txClient().tableMeta.findFirst({ - where: { id: tableId, deletedTime: null }, - select: { - base: { - select: { - spaceId: true, - v2Enabled: true, - }, - }, - }, - }); - - if (!table?.base?.spaceId) { - return false; - } - - const decision = await this.canaryService.shouldUseV2ForBaseWithReason( - table.base, - 'createRecord' - ); - return decision.useV2; - } - - private async restoreRecordsV2(tableId: string, records: IRecordTrashSnapshot[]): Promise { - if (records.length === 0) { - return; - } - - const container = await this.v2ContainerService.getContainerForTable(tableId); - const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ExecutionContextFactory.createContext(container); - - const commandResult = RestoreRecordsCommand.create({ - tableId, - records: records.map((record) => this.toV2RestoreRecord(record)), + // Lets EE guards inspect what a table-trash operation restores (e.g. row-quota checks + // only apply to record restores) without duplicating the data-db routing. + async getTableTrashResourceType(trashId: string, tableId: string): Promise { + const prisma = this.getTrashDataPrismaExecutor(await this.trashDataPrismaForTable(tableId)); + const rows = await prisma.tableTrash.findMany({ + where: { id: trashId }, + select: { resourceType: true }, }); - - if (commandResult.isErr()) { - throw new CustomHttpException(commandResult.error.message, HttpErrorCode.VALIDATION_ERROR); - } - - const result = await commandBus.execute( - context, - commandResult.value - ); - - if (result.isErr()) { - throw new CustomHttpException(result.error.message, HttpErrorCode.INTERNAL_SERVER_ERROR); - } - } - - private toV2RestoreRecord(record: IRecordTrashSnapshot): RestoreRecordInput { - return { - recordId: record.id, - fields: record.fields ?? {}, - ...(record.version !== undefined ? { version: record.version } : {}), - ...(record.order ? { orders: record.order } : {}), - ...(record.autoNumber !== undefined ? { autoNumber: record.autoNumber } : {}), - ...(record.createdTime ? { createdTime: record.createdTime } : {}), - ...(record.createdBy ? { createdBy: record.createdBy } : {}), - ...(record.lastModifiedTime ? { lastModifiedTime: record.lastModifiedTime } : {}), - ...(record.lastModifiedBy ? { lastModifiedBy: record.lastModifiedBy } : {}), - }; + return rows[0]?.resourceType ?? null; } async restoreTrash(trashId: string, tableId?: string) { @@ -1774,14 +2305,26 @@ export class TrashService { }); await this.trashDataPrismaTransactionForTable(tableId, async (prisma) => { + // Scope to trash rows: archive snapshots share record_trash (reason 'archived') and + // must survive a trash reset together with their kept attachment reference rows. await prisma.recordTrash.deleteMany({ - where: { tableId }, + where: { tableId, reason: RECORD_REMOVAL_REASON.Deleted }, }); await prisma.tableTrash.deleteMany({ where: { tableId }, }); }); + + // The deleted/ cold subtree mirrors the PG rows just removed — wipe it too so + // sunk copies cannot resurface in merged reads. Same rule as archive reset: a + // full prefix wipe needs no tombstones, and running after the PG deletes + // leaves a retryable state if the wipe fails. The archived/ subtree is + // untouched. + await this.recordRemovalColdStorageService.deleteReasonPrefix( + tableId, + RECORD_REMOVAL_REASON.Deleted + ); } async delete(trashId: string, ignorePermissionCheck = false): Promise { 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 index d3359bc387..2dc2d8b95a 100644 --- a/apps/nestjs-backend/src/features/trash/v2-record-trash.service.ts +++ b/apps/nestjs-backend/src/features/trash/v2-record-trash.service.ts @@ -12,7 +12,7 @@ interface ITableTrashInsert { resource_type: string; snapshot: string; created_by: string; - created_time: Date; + created_time: string; } interface IRecordTrashInsert { @@ -21,7 +21,12 @@ interface IRecordTrashInsert { record_id: string; snapshot: string; created_by: string; - created_time: Date; + created_time: string; + operation_id: string | null; + record_created_time: string | null; + record_created_by: string | null; + record_last_modified_time: string | null; + record_last_modified_by: string | null; } type TrashDbTransaction = { @@ -62,7 +67,7 @@ export class V2RecordTrashService { const container = await this.v2ContainerService.getContainerForTable(tableId); const db = container.resolve(v2DataDbTokens.db) as TrashDbClient; const recordIds = records.map((record) => record.id); - const createdTime = new Date(); + const createdTime = new Date().toISOString(); await this.runInSpan( context, @@ -97,6 +102,15 @@ export class V2RecordTrashService { snapshot: JSON.stringify(record), created_by: userId, created_time: createdTime, + operation_id: operationId ?? null, + record_created_time: record.createdTime + ? new Date(record.createdTime).toISOString() + : null, + record_created_by: record.createdBy ?? null, + record_last_modified_time: record.lastModifiedTime + ? new Date(record.lastModifiedTime).toISOString() + : null, + record_last_modified_by: record.lastModifiedBy ?? null, })) ) .execute(); 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 16bc2fa34a..1704115dd6 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 @@ -269,7 +269,7 @@ describe('V2RecordTrashService', () => { resource_type: 'record', snapshot: JSON.stringify(['recFirstRecordId01', 'recSecondRecordId2']), created_by: 'usrTestUserId', - created_time: expect.any(Date), + created_time: expect.any(String), }, }); expect(operations[1].table).toBe('record_trash'); 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 441ad117f1..6135c0ae86 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 @@ -148,6 +148,10 @@ export class V2RecordsDeletedTableTrashProjection implements IEventHandler>(v2MetaDbTokens.db); diff --git a/apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo-freeze.service.spec.ts b/apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo-freeze.service.spec.ts index 1b8aa5b32e..ee579e6b04 100644 --- a/apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo-freeze.service.spec.ts +++ b/apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo-freeze.service.spec.ts @@ -44,6 +44,8 @@ describe('UndoRedoService write freeze', () => { cacheService as never, undoRedoStackService as never, undoRedoOperationService as never, + { dataPrismaForTable: vi.fn() } as never, + { markRestored: vi.fn() } as never, migrationGuard as never ); diff --git a/apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo.module.ts b/apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo.module.ts index c9db826b32..d9da7a333c 100644 --- a/apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo.module.ts +++ b/apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; +import { RecordRemovalColdCoreModule } from '../../record-removal-cold/record-removal-cold.module'; import { V2Module } from '../../v2/v2.module'; import { UndoRedoStackModule } from '../stack/undo-redo-stack.module'; import { UndoRedoController } from './undo-redo.controller'; import { UndoRedoService } from './undo-redo.service'; @Module({ - imports: [UndoRedoStackModule, V2Module], + imports: [RecordRemovalColdCoreModule, UndoRedoStackModule, V2Module], controllers: [UndoRedoController], providers: [UndoRedoService], }) diff --git a/apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo.service.ts b/apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo.service.ts index adb6889e79..b96600065d 100644 --- a/apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo.service.ts +++ b/apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo.service.ts @@ -1,5 +1,6 @@ /* eslint-disable sonarjs/no-duplicate-string */ import { Injectable, Logger, Optional } from '@nestjs/common'; +import type { DataPrismaService } from '@teable/db-data-prisma'; import type { IRedoVo, IUndoRedoStreamEvent, IUndoVo } from '@teable/openapi'; import { RedoCommand, @@ -11,13 +12,16 @@ import { import type { ICommandBus, RedoResult, + UndoRedoCommandData, UndoRedoStackService as V2UndoRedoStackService, UndoResult, } from '@teable/v2-core'; import { ClsService } from 'nestjs-cls'; import { CacheService } from '../../../cache/cache.service'; import type { ICacheStore } from '../../../cache/types'; +import { DataDbClientManager } from '../../../global/data-db-client-manager.service'; import type { IClsStore } from '../../../types/cls'; +import { RecordRemovalTombstoneService } from '../../record-removal-cold/record-removal-tombstone.service'; import { SpaceDataDbMigrationGuardService } from '../../space/space-data-db-migration-guard.service'; import { V2ContainerService } from '../../v2/v2-container.service'; import { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory'; @@ -27,6 +31,21 @@ import { buildUndoRedoEnginePreferenceKey } from './undo-redo-engine-preference' export const X_TEABLE_UNDO_REDO_ENGINE_HEADER = 'x-teable-undo-redo-engine'; +// Record ids a v2 undo restores back to the table: replaying RestoreRecords +// (undo of a delete) or RestoreArchivedRecords (undo of an archive) deletes the +// matching record_trash rows inside the engine, so these are the ids whose cold +// copies need suppression. +const collectV2RestoredRecordIds = (command: UndoRedoCommandData): string[] => { + const leaves = command.type === 'Batch' ? command.payload : [command]; + const recordIds = new Set(); + for (const leaf of leaves) { + if (leaf.type === 'RestoreRecords' || leaf.type === 'RestoreArchivedRecords') { + leaf.payload.records.forEach((record) => recordIds.add(record.recordId)); + } + } + return [...recordIds]; +}; + export type IUndoRedoEngine = 'v1' | 'v2'; type IUndoRedoResponse = { @@ -95,10 +114,40 @@ export class UndoRedoService { private readonly cacheService: CacheService, private readonly undoRedoStackService: UndoRedoStackService, private readonly undoRedoOperationService: UndoRedoOperationService, + private readonly dataDbClientManager: DataDbClientManager, + private readonly recordRemovalTombstoneService: RecordRemovalTombstoneService, @Optional() private readonly spaceDataDbMigrationGuard?: SpaceDataDbMigrationGuardService ) {} + // Cold-copy suppression after a fulfilled v2 undo. The row deletion happens + // inside the v2 engine (package boundary — the tombstone service is out of + // reach there), so the marker is written here once the replay committed. + // Failure is logged, never rethrown: the undo itself succeeded, and failing + // the response would invite a retry that pops ANOTHER stack entry. + private async markV2RestoredTombstones(tableId: string, undoCommand: UndoRedoCommandData) { + try { + const recordIds = collectV2RestoredRecordIds(undoCommand); + if (recordIds.length === 0) { + return; + } + const dataPrisma = (await this.dataDbClientManager.dataPrismaForTable(tableId, { + useTransaction: true, + })) as DataPrismaService; + await this.recordRemovalTombstoneService.markRestored( + (dataPrisma.txClient?.() ?? dataPrisma) as DataPrismaService, + tableId, + recordIds + ); + } catch (error) { + this.logger.error( + `tombstone marking failed after v2 undo on ${tableId}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + async undo(tableId: string, windowId: string): Promise> { await this.assertTableWritable(tableId); @@ -359,6 +408,10 @@ export class UndoRedoService { return undefined; } + if (mode === 'undo') { + await this.markV2RestoredTombstones(tableId, executeResult.value.entry.undoCommand); + } + return { body: { status: 'fulfilled', @@ -447,6 +500,10 @@ export class UndoRedoService { return; } + if (mode === 'undo' && replayResult.value) { + await this.markV2RestoredTombstones(tableId, replayResult.value.undoCommand); + } + queue.push({ id: 'done', mode, diff --git a/apps/nestjs-backend/src/features/undo-redo/operations/archive-records.operation.ts b/apps/nestjs-backend/src/features/undo-redo/operations/archive-records.operation.ts new file mode 100644 index 0000000000..d708831b57 --- /dev/null +++ b/apps/nestjs-backend/src/features/undo-redo/operations/archive-records.operation.ts @@ -0,0 +1,72 @@ +import type { IArchiveRecordsOperation } from '../../../cache/types'; +import { OperationName } from '../../../cache/types'; + +// Record archive is an enterprise-only feature: the orchestrator (ArchiveService) lives in +// the enterprise edition and is injected into the community undo stack through this token +// (@Global provider on the EE side, @Optional() here). In a pure community boot the token +// resolves to undefined — archive endpoints do not exist there, so archive operations can +// only reach the stack on an enterprise deployment. +export const ARCHIVE_UNDO_SERVICE = 'ARCHIVE_UNDO_SERVICE'; + +export interface IArchiveUndoService { + archiveRecords( + tableId: string, + recordIds: string[], + windowId?: string + ): Promise<{ archivedRecordIds: string[]; operationId: string }>; + restoreArchiveRecordsByOperationId( + tableId: string, + operationId: string + ): Promise<{ restoredRecordIds: string[] }>; +} + +export interface IArchiveRecordsPayload { + operationId: string; + windowId?: string; + tableId: string; + userId: string; + recordIds: string[]; +} + +export class ArchiveRecordsOperation { + constructor(private readonly archiveService?: IArchiveUndoService) {} + + private requireService(): IArchiveUndoService { + if (!this.archiveService) { + throw new Error('Record archive requires the enterprise edition'); + } + return this.archiveService; + } + + async event2Operation(payload: IArchiveRecordsPayload): Promise { + return { + name: OperationName.ArchiveRecords, + params: { + tableId: payload.tableId, + }, + result: { + recordIds: payload.recordIds, + }, + operationId: payload.operationId, + }; + } + + // Restores exactly the rows this operation archived (matched by operationId); a no-op + // if they were purged from the archive meanwhile. + async undo(operation: IArchiveRecordsOperation) { + const { params, operationId } = operation; + await this.requireService().restoreArchiveRecordsByOperationId(params.tableId, operationId); + return operation; + } + + async redo(operation: IArchiveRecordsOperation) { + const { params, result } = operation; + const { archivedRecordIds, operationId } = await this.requireService().archiveRecords( + params.tableId, + result.recordIds + ); + // Re-archiving persists new snapshot rows under a new operationId — refresh the + // entry so a following undo matches them. + return { ...operation, operationId, result: { recordIds: archivedRecordIds } }; + } +} diff --git a/apps/nestjs-backend/src/features/undo-redo/operations/delete-records.operation.ts b/apps/nestjs-backend/src/features/undo-redo/operations/delete-records.operation.ts index 748fc9be8a..e5a1f4b405 100644 --- a/apps/nestjs-backend/src/features/undo-redo/operations/delete-records.operation.ts +++ b/apps/nestjs-backend/src/features/undo-redo/operations/delete-records.operation.ts @@ -1,11 +1,15 @@ import type { IRecord } from '@teable/core'; import { FieldKeyType } from '@teable/core'; import type { DataPrismaService } from '@teable/db-data-prisma'; +import type { IRecordRemovalReason } from '@teable/v2-core'; import type { IDeleteRecordsOperation } from '../../../cache/types'; import { OperationName } from '../../../cache/types'; import type { IThresholdConfig } from '../../../configs/threshold.config'; import type { DataDbClientManager } from '../../../global/data-db-client-manager.service'; import type { RecordOpenApiService } from '../../record/open-api/record-open-api.service'; +import type { RecordRemovalTombstoneService } from '../../record-removal-cold/record-removal-tombstone.service'; + +export type { IRecordRemovalReason }; export interface IDeleteRecordsPayload { operationId: string; @@ -13,13 +17,16 @@ export interface IDeleteRecordsPayload { tableId: string; userId: string; records: (IRecord & { version?: number; order?: Record })[]; + // 'archived' removals persist their own snapshot before deleting; trash sinks skip them. + removalReason?: IRecordRemovalReason; } export class DeleteRecordsOperation { constructor( private readonly recordOpenApiService: RecordOpenApiService, private readonly thresholdConfig: IThresholdConfig, - private readonly dataDbClientManager: DataDbClientManager + private readonly dataDbClientManager: DataDbClientManager, + private readonly recordRemovalTombstoneService: RecordRemovalTombstoneService ) {} private async dataPrismaForTable(tableId: string): Promise { @@ -93,9 +100,19 @@ export class DeleteRecordsOperation { where: { tableId: params.tableId, recordId: { in: recordIds }, + reason: 'deleted', }, }); }); + + // Cold-copy suppression: a trash row already uploaded to a cold part (flush + // overlap window) outlives the deleteMany above and would resurface in + // merged reads once the buffer drains. + await this.recordRemovalTombstoneService.markRestored( + await this.dataPrismaExecutorForTable(params.tableId), + params.tableId, + recordIds + ); } return operation; diff --git a/apps/nestjs-backend/src/features/undo-redo/operations/delete-trash-routing.spec.ts b/apps/nestjs-backend/src/features/undo-redo/operations/delete-trash-routing.spec.ts index 046995fa72..644a164ed5 100644 --- a/apps/nestjs-backend/src/features/undo-redo/operations/delete-trash-routing.spec.ts +++ b/apps/nestjs-backend/src/features/undo-redo/operations/delete-trash-routing.spec.ts @@ -167,10 +167,14 @@ describe('trash-backed undo operations', () => { const dataDbClientManager = { dataPrismaForTable: vi.fn().mockResolvedValue(dataPrismaService), }; + const recordRemovalTombstoneService = { + markRestored: vi.fn().mockResolvedValue(undefined), + }; const operation = new DeleteRecordsOperation( recordOpenApiService as never, { bigTransactionTimeout: 60_000 } as never, - dataDbClientManager as never + dataDbClientManager as never, + recordRemovalTombstoneService as never ); await operation.undo({ @@ -197,8 +201,14 @@ describe('trash-backed undo operations', () => { where: { tableId: 'tbl1', recordId: { in: ['rec1', 'rec2'] }, + reason: 'deleted', }, }); + expect(recordRemovalTombstoneService.markRestored).toHaveBeenCalledWith( + dataPrismaService, + 'tbl1', + ['rec1', 'rec2'] + ); }); it('DeleteViewOperation restores metadata and clears its trash marker from the data prisma service', async () => { diff --git a/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-operation.service.ts b/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-operation.service.ts index c6eaf601fb..128658d046 100644 --- a/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-operation.service.ts +++ b/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-operation.service.ts @@ -1,5 +1,5 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Optional } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { assertNever } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; @@ -12,9 +12,16 @@ import { FieldOpenApiV2Service } from '../../field/open-api/field-open-api-v2.se import { FieldOpenApiService } from '../../field/open-api/field-open-api.service'; import { RecordOpenApiService } from '../../record/open-api/record-open-api.service'; import { RecordService } from '../../record/record.service'; +import { RecordRemovalTombstoneService } from '../../record-removal-cold/record-removal-tombstone.service'; import { TableDomainQueryService } from '../../table-domain'; import { ViewOpenApiService } from '../../view/open-api/view-open-api.service'; import { ViewService } from '../../view/view.service'; +import type { IArchiveUndoService } from '../operations/archive-records.operation'; +import { + ARCHIVE_UNDO_SERVICE, + ArchiveRecordsOperation, + IArchiveRecordsPayload, +} from '../operations/archive-records.operation'; import { ConvertFieldV2Operation } from '../operations/convert-field-v2.operation'; import { ConvertFieldOperation, IConvertFieldPayload } from '../operations/convert-field.operation'; import { CreateFieldsOperation, ICreateFieldsPayload } from '../operations/create-fields.operation'; @@ -47,6 +54,7 @@ import { UndoRedoStackService } from './undo-redo-stack.service'; export class UndoRedoOperationService { createRecords: CreateRecordsOperation; deleteRecords: DeleteRecordsOperation; + archiveRecords: ArchiveRecordsOperation; updateRecords: UpdateRecordsOperation; updateRecordsOrder: UpdateRecordsOrderOperation; createFields: CreateFieldsOperation; @@ -69,6 +77,11 @@ export class UndoRedoOperationService { private readonly prismaService: PrismaService, private readonly dataDbClientManager: DataDbClientManager, private readonly tableDomainQueryService: TableDomainQueryService, + private readonly recordRemovalTombstoneService: RecordRemovalTombstoneService, + // Enterprise-only: provided by the EE ArchiveModule (@Global); undefined on community. + @Optional() + @Inject(ARCHIVE_UNDO_SERVICE) + private readonly archiveService: IArchiveUndoService | undefined, @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig ) { this.createRecords = new CreateRecordsOperation( @@ -79,8 +92,10 @@ export class UndoRedoOperationService { this.deleteRecords = new DeleteRecordsOperation( this.recordOpenApiService, this.thresholdConfig, - this.dataDbClientManager + this.dataDbClientManager, + this.recordRemovalTombstoneService ); + this.archiveRecords = new ArchiveRecordsOperation(this.archiveService); this.updateRecords = new UpdateRecordsOperation(this.recordOpenApiService, this.recordService); this.updateRecordsOrder = new UpdateRecordsOrderOperation(this.viewOpenApiService); this.createFields = new CreateFieldsOperation( @@ -117,6 +132,8 @@ export class UndoRedoOperationService { return this.createRecords.undo(operation); case OperationName.DeleteRecords: return this.deleteRecords.undo(operation); + case OperationName.ArchiveRecords: + return this.archiveRecords.undo(operation); case OperationName.UpdateRecords: return this.updateRecords.undo(operation); case OperationName.UpdateRecordsOrder: @@ -148,6 +165,8 @@ export class UndoRedoOperationService { return this.createRecords.redo(operation); case OperationName.DeleteRecords: return this.deleteRecords.redo(operation); + case OperationName.ArchiveRecords: + return this.archiveRecords.redo(operation); case OperationName.UpdateRecords: return this.updateRecords.redo(operation); case OperationName.UpdateRecordsOrder: @@ -184,13 +203,25 @@ export class UndoRedoOperationService { await this.undoRedoStackService.push(userId, operation.params.tableId, windowId, operation); } - @OnEvent(Events.OPERATION_RECORDS_DELETE) - private async onDeleteRecords(payload: IDeleteRecordsPayload) { + @OnEvent(Events.OPERATION_RECORDS_ARCHIVE) + private async onArchiveRecords(payload: IArchiveRecordsPayload) { const { windowId, userId, tableId } = payload; if (!windowId || !userId) { return; } + const operation = await this.archiveRecords.event2Operation(payload); + await this.undoRedoStackService.push(userId, tableId, windowId, operation); + } + + @OnEvent(Events.OPERATION_RECORDS_DELETE) + private async onDeleteRecords(payload: IDeleteRecordsPayload) { + const { windowId, userId, tableId, removalReason } = payload; + // Archived removals are not undoable: the archive keeps the only snapshot. + if (!windowId || !userId || removalReason === 'archived') { + return; + } + const operation = await this.deleteRecords.event2Operation(payload); await this.undoRedoStackService.push(userId, tableId, windowId, operation); } diff --git a/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-stack.module.ts b/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-stack.module.ts index 262433de77..ca9713417d 100644 --- a/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-stack.module.ts +++ b/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-stack.module.ts @@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { FieldOpenApiModule } from '../../field/open-api/field-open-api.module'; import { RecordOpenApiModule } from '../../record/open-api/record-open-api.module'; import { RecordModule } from '../../record/record.module'; +import { RecordRemovalColdCoreModule } from '../../record-removal-cold/record-removal-cold.module'; import { TableDomainQueryModule } from '../../table-domain'; import { ViewOpenApiModule } from '../../view/open-api/view-open-api.module'; import { ViewModule } from '../../view/view.module'; @@ -11,6 +12,7 @@ import { UndoRedoStackService } from './undo-redo-stack.service'; @Module({ imports: [ RecordModule, + RecordRemovalColdCoreModule, forwardRef(() => RecordOpenApiModule), ViewModule, ViewOpenApiModule, diff --git a/apps/nestjs-backend/src/features/user/last-visit/last-visit.service.ts b/apps/nestjs-backend/src/features/user/last-visit/last-visit.service.ts index 9c5749f50f..8a6e11209d 100644 --- a/apps/nestjs-backend/src/features/user/last-visit/last-visit.service.ts +++ b/apps/nestjs-backend/src/features/user/last-visit/last-visit.service.ts @@ -13,7 +13,7 @@ import type { IUserLastVisitVo, IUserLastVisitBaseNodeVo, } from '@teable/openapi'; -import { LastVisitResourceType } from '@teable/openapi'; +import { BaseNodeResourceType, LastVisitResourceType } from '@teable/openapi'; import { Knex } from 'knex'; import { keyBy } from 'lodash'; import { InjectModel } from 'nest-knexjs'; @@ -79,6 +79,183 @@ export class LastVisitService { }; } + /** + * The entry URL of each given base, resolved purely from the user's own + * visit history — so a base-list click can navigate straight to + * /base/{id}/table/{tableId}/{viewId} instead of paying the /base/{id} + * redirect chain. Pure resolution: callers own access control and pass ids + * already scoped to what the user may see (the space controller passes its + * permission-checked base list). A base maps to its latest visited + * still-alive table, or — when never visited — to its default first table, + * mirroring the redirect chain; bases whose target is a non-table node stay + * omitted so the chain handles them. + * + * URLs mirror the frontend getNodeUrl table rule + * (features/app/blocks/base/base-node/hooks/helper.ts) — keep in sync. + */ + async getBaseEntryMap(userId: string, baseIds: string[]): Promise> { + if (baseIds.length === 0) return {}; + + // Latest visited node per base (newest first, pick first occurrence); + // only table nodes proceed — matching what the redirect chain would pick. + // Visit rows are pruned to one per (base, type). The userId filter keeps + // this to the caller's own history only. + const nodeVisits = await this.prismaService.userLastVisit.findMany({ + where: { + userId, + parentResourceId: { in: baseIds }, + resourceType: { + in: [ + LastVisitResourceType.Table, + LastVisitResourceType.Dashboard, + LastVisitResourceType.Workflow, + LastVisitResourceType.App, + ], + }, + }, + orderBy: { lastVisitTime: 'desc' }, + select: { parentResourceId: true, resourceId: true, resourceType: true }, + }); + const latestNodeByBase = new Map(); + for (const visit of nodeVisits) { + if (!latestNodeByBase.has(visit.parentResourceId)) { + latestNodeByBase.set(visit.parentResourceId, visit); + } + } + const tableIdToBaseId = new Map(); + for (const [visitedBaseId, node] of latestNodeByBase) { + if (node.resourceType === LastVisitResourceType.Table) { + tableIdToBaseId.set(node.resourceId, visitedBaseId); + } + } + // Never-visited bases fall back to the same default the redirect chain + // would compute: the first non-folder node, when it is a table + const neverVisitedBaseIds = baseIds.filter((id) => !latestNodeByBase.has(id)); + await this.collectDefaultTableEntries(neverVisitedBaseIds, tableIdToBaseId); + + if (tableIdToBaseId.size === 0) return {}; + const urlByTableId = await this.resolveTableEntryUrls(userId, tableIdToBaseId); + const entryMap: Record = {}; + for (const [tableId, entryBaseId] of tableIdToBaseId) { + const url = urlByTableId[tableId]; + if (url) entryMap[entryBaseId] = url; + } + return entryMap; + } + + /** + * Entry URL per table (last visited view when alive, else the first by + * order) for known (tableId, baseId) pairs — e.g. pinned tables. Same + * contract as getBaseEntryMap: pure resolution over the user's own visit + * history, callers own access control. + */ + async getTableEntryUrls( + userId: string, + tables: { tableId: string; baseId: string }[] + ): Promise> { + if (tables.length === 0) return {}; + return this.resolveTableEntryUrls( + userId, + new Map(tables.map((table) => [table.tableId, table.baseId])) + ); + } + + /** + * The default table of each base — its first non-folder node when that node + * is a table — mirroring the redirect chain. Bases whose first node is a + * dashboard/automation/app are skipped on purpose: those URLs cannot + * self-heal when stale (no table-route-style fallback), so the redirect + * chain keeps handling them. (An EE authority-restricted first table can + * slip in here; clicking it self-heals through the table route's + * permission-filtered fallback.) + */ + private async collectDefaultTableEntries( + baseIds: string[], + tableIdToBaseId: Map + ): Promise { + if (baseIds.length === 0) return; + const nodes = await this.prismaService.baseNode.findMany({ + where: { baseId: { in: baseIds } }, + orderBy: [{ baseId: 'asc' }, { order: 'asc' }], + select: { baseId: true, resourceType: true, resourceId: true }, + }); + const firstNodeByBase = new Map(); + for (const node of nodes) { + if (node.resourceType === BaseNodeResourceType.Folder) continue; + if (!firstNodeByBase.has(node.baseId)) { + firstNodeByBase.set(node.baseId, node); + } + } + for (const [defaultBaseId, node] of firstNodeByBase) { + if (node.resourceType === BaseNodeResourceType.Table) { + tableIdToBaseId.set(node.resourceId, defaultBaseId); + } + } + } + + /** + * For each table: keep it only when still alive in its expected base, then + * emit its entry pathname keyed by tableId — with the user's own last + * visited view when alive, otherwise viewless (the table route resolves + * the view with permission filtering, one redirect) + */ + private async resolveTableEntryUrls( + userId: string, + tableIdToBaseId: Map + ): Promise> { + const entryMap: Record = {}; + const tableIds = [...tableIdToBaseId.keys()]; + const [tables, viewVisits, views] = await Promise.all([ + this.prismaService.tableMeta.findMany({ + where: { id: { in: tableIds }, deletedTime: null }, + select: { id: true, baseId: true }, + }), + this.prismaService.userLastVisit.findMany({ + where: { + userId, + resourceType: LastVisitResourceType.View, + parentResourceId: { in: tableIds }, + }, + orderBy: { lastVisitTime: 'desc' }, + select: { parentResourceId: true, resourceId: true }, + }), + this.prismaService.view.findMany({ + where: { tableId: { in: tableIds }, deletedTime: null }, + orderBy: { order: 'asc' }, + select: { id: true, tableId: true }, + }), + ]); + const latestViewByTable = new Map(); + for (const visit of viewVisits) { + if (!latestViewByTable.has(visit.parentResourceId)) { + latestViewByTable.set(visit.parentResourceId, visit.resourceId); + } + } + const viewIdsByTable = new Map(); + for (const view of views) { + const list = viewIdsByTable.get(view.tableId) ?? []; + list.push(view.id); + viewIdsByTable.set(view.tableId, list); + } + + for (const table of tables) { + const entryBaseId = tableIdToBaseId.get(table.id); + const tableViewIds = viewIdsByTable.get(table.id); + if (entryBaseId !== table.baseId || !entryBaseId || !tableViewIds?.length) continue; + // Only the user's own last visited view may appear in the URL — they + // could see it at visit time. Falling back to the first view by order + // would leak (and route to) views an EE authority-matrix role hides; + // a viewless URL instead lets the table route resolve the view through + // its permission-filtered list at the cost of one redirect. + const lastViewId = latestViewByTable.get(table.id); + const viewId = lastViewId && tableViewIds.includes(lastViewId) ? lastViewId : undefined; + entryMap[table.id] = viewId + ? `/base/${entryBaseId}/table/${table.id}/${viewId}` + : `/base/${entryBaseId}/table/${table.id}`; + } + return entryMap; + } + async spaceVisit(userId: string, parentResourceId: string) { const lastVisit = await this.prismaService.userLastVisit.findFirst({ where: { diff --git a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/bullmq-computed-outbox-wakeup.processor.ts b/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/bullmq-computed-outbox-wakeup.processor.ts index 62c8730c20..c245c655b4 100644 --- a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/bullmq-computed-outbox-wakeup.processor.ts +++ b/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/bullmq-computed-outbox-wakeup.processor.ts @@ -35,6 +35,7 @@ export class BullMqComputedOutboxWakeupProcessor extends WorkerHost { } const wakeup = parsed.data as ComputedOutboxWakeupWire; try { + // Join the originating write trace when the producer captured W3C context. await this.handler.handle(wakeup); } catch (error) { const maxAttempts = job.opts.attempts ?? 1; @@ -47,6 +48,8 @@ export class BullMqComputedOutboxWakeupProcessor extends WorkerHost { baseId: wakeup.baseId, availableAt: new Date(Date.now() + 30_000), cause: 'replay', + ...(wakeup.traceparent ? { traceparent: wakeup.traceparent } : {}), + ...(wakeup.tracestate ? { tracestate: wakeup.tracestate } : {}), }) ) ) diff --git a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/bullmq-computed-outbox-wakeup.publisher.spec.ts b/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/bullmq-computed-outbox-wakeup.publisher.spec.ts index 0fb8737041..075de8e006 100644 --- a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/bullmq-computed-outbox-wakeup.publisher.spec.ts +++ b/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/bullmq-computed-outbox-wakeup.publisher.spec.ts @@ -61,6 +61,29 @@ describe('BullMqComputedOutboxWakeupPublisher', () => { expect(metrics.recordPublish).toHaveBeenCalledWith('accepted', 'retry'); }); + it('forwards optional W3C trace carrier on the wake-up job', async () => { + const add = vi.fn().mockResolvedValue({ id: 'job-trace' }); + const publisher = new BullMqComputedOutboxWakeupPublisher( + queue(add) as never, + { recordPublish: vi.fn(), recordPublishDuration: vi.fn() } as never + ); + + await publisher.publish({ + ...createWakeup(), + traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + tracestate: 'vendor=1', + }); + + expect(add).toHaveBeenCalledWith( + 'computed-outbox-wakeup', + expect.objectContaining({ + traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + tracestate: 'vendor=1', + }), + expect.any(Object) + ); + }); + it('records and propagates queue publication failures', async () => { const queueError = new Error('redis unavailable'); const metrics = { diff --git a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/bullmq-computed-outbox-wakeup.publisher.ts b/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/bullmq-computed-outbox-wakeup.publisher.ts index 2dde917d8d..2c51c466d0 100644 --- a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/bullmq-computed-outbox-wakeup.publisher.ts +++ b/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/bullmq-computed-outbox-wakeup.publisher.ts @@ -89,6 +89,8 @@ export class BullMqComputedOutboxWakeupPublisher implements IComputedOutboxWakeu availableAt: wakeup.availableAt.toISOString(), emittedAt: wakeup.emittedAt.toISOString(), cause: wakeup.cause, + ...(wakeup.traceparent ? { traceparent: wakeup.traceparent } : {}), + ...(wakeup.tracestate ? { tracestate: wakeup.tracestate } : {}), }, { jobId: wakeup.wakeupId, diff --git a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-anomaly.service.ts b/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-anomaly.service.ts index d2782e4ba7..77383f4a85 100644 --- a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-anomaly.service.ts +++ b/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-anomaly.service.ts @@ -9,13 +9,13 @@ import type { IComputedOutboxMaintenanceTarget, } from '../../../global/data-db-client-manager.service'; import { DataDbClientManager } from '../../../global/data-db-client-manager.service'; +import { mapWithConcurrency } from '../../../utils/map-with-concurrency'; import { IComputedOutboxWakeupAppPublisher } from './computed-outbox-wakeup.publisher'; import { COMPUTED_OUTBOX_ANOMALY_FETCH_CAP, COMPUTED_OUTBOX_ANOMALY_GROUP_SAMPLE_LIMIT, COMPUTED_OUTBOX_WAKEUP_PUBLISHER, } from './constants'; -import { mapWithConcurrency } from './map-with-concurrency'; export type ComputedOutboxAnomaly = IComputedOutboxMaintenanceAnomaly & { targetId: string; diff --git a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-monitor.service.ts b/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-monitor.service.ts index 1e0bde1fb3..6add71307a 100644 --- a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-monitor.service.ts +++ b/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-monitor.service.ts @@ -13,6 +13,7 @@ import type { IComputedOutboxMaintenanceTarget, } from '../../../global/data-db-client-manager.service'; import { DataDbClientManager } from '../../../global/data-db-client-manager.service'; +import { mapWithConcurrency } from '../../../utils/map-with-concurrency'; import { ComputedOutboxTriggerMetrics } from './computed-outbox-trigger.metrics'; import { computedOutboxWakeupWireSchema, @@ -24,7 +25,6 @@ import { COMPUTED_OUTBOX_RECENT_FAILED_LIMIT, COMPUTED_OUTBOX_WAKEUP_QUEUE, } from './constants'; -import { mapWithConcurrency } from './map-with-concurrency'; type Storage = 'default' | 'byodb'; type HealthStatus = 'healthy' | 'degraded' | 'critical'; diff --git a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-redrive.service.spec.ts b/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-redrive.service.spec.ts index c87d7dd6f0..c0be644989 100644 --- a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-redrive.service.spec.ts +++ b/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-redrive.service.spec.ts @@ -13,6 +13,7 @@ const config = { publishTimeoutMs: 1000, monitorConcurrency: 2, monitorIntervalMs: 30_000, + redriveMaxPublishPerTarget: 1000, } as const; describe('ComputedOutboxRedriveService', () => { @@ -68,6 +69,46 @@ describe('ComputedOutboxRedriveService', () => { ); }); + it('stops publishing once the per-target redrive budget is reached', async () => { + const targets = [ + { + cacheKey: 'default', + url: 'postgres://hidden', + isMetaFallback: true, + storage: 'default', + }, + ] as const; + const availableAt = new Date('2026-07-14T09:00:00.000Z'); + const iterateComputedOutboxWakeupCandidates = vi.fn(async function* () { + yield [ + { taskId: 'cuo-1', baseId: 'bse-1', availableAt, revision: '1-0-1-0' }, + { taskId: 'cuo-2', baseId: 'bse-2', availableAt, revision: '2-0-2-0' }, + { taskId: 'cuo-3', baseId: 'bse-3', availableAt, revision: '3-0-3-0' }, + ]; + throw new Error('iterator should not be drained past the publish budget'); + }); + const publish = vi.fn().mockResolvedValue({ status: 'accepted' }); + const service = new ComputedOutboxRedriveService( + { ...config, redriveMaxPublishPerTarget: 2 }, + { + withComputedOutboxRedriveLease: vi.fn(async (run: () => Promise) => { + await run(); + return true; + }), + listComputedOutboxMaintenanceTargets: vi.fn().mockResolvedValue(targets), + iterateComputedOutboxWakeupCandidates, + } as never, + { + publish, + runAsConsumer: vi.fn(async (operation: () => Promise) => await operation()), + } as never + ); + + await service.runOnce(); + + expect(publish).toHaveBeenCalledTimes(2); + }); + it('starts recovery in the background for a consumer-only process', async () => { const withComputedOutboxRedriveLease = vi.fn().mockResolvedValue(true); const service = new ComputedOutboxRedriveService( diff --git a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-redrive.service.ts b/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-redrive.service.ts index a67d29a668..c4f2782407 100644 --- a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-redrive.service.ts +++ b/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-redrive.service.ts @@ -11,9 +11,9 @@ import { } from '../../../configs/computed-outbox-trigger.config'; import type { IComputedOutboxMaintenanceTarget } from '../../../global/data-db-client-manager.service'; import { DataDbClientManager } from '../../../global/data-db-client-manager.service'; +import { mapWithConcurrency } from '../../../utils/map-with-concurrency'; import { IComputedOutboxWakeupAppPublisher } from './computed-outbox-wakeup.publisher'; import { COMPUTED_OUTBOX_WAKEUP_PUBLISHER } from './constants'; -import { mapWithConcurrency } from './map-with-concurrency'; /** Re-arms durable tasks at startup and performs a low-frequency actionable-only reconciliation. */ @Injectable() @@ -182,10 +182,22 @@ export class ComputedOutboxRedriveService implements OnApplicationBootstrap, OnM target, defaultComputedUpdateOutboxConfig.processingLeaseMs ); + const maxPublish = this.config.redriveMaxPublishPerTarget; for await (const candidates of iterator) { if (this.stopped) return published; for (const candidate of candidates) { if (await this.publishCandidate(candidate)) published += 1; + if (published >= maxPublish) { + // Backlog exceeds one sweep's budget — stop here and let the next + // reconcile cycle continue instead of flooding the claim path. + this.logger.warn('computed:outbox:redrive_publish_capped', { + cacheKey: target.cacheKey, + storage: target.storage, + published, + maxPublish, + }); + return published; + } } } return published; diff --git a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-wakeup.handler.ts b/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-wakeup.handler.ts index c42bf918c7..bcde77716d 100644 --- a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-wakeup.handler.ts +++ b/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-wakeup.handler.ts @@ -9,6 +9,7 @@ import { import { v2CoreTokens, type ITracer } from '@teable/v2-core'; import { V2ContainerService } from '../v2-container.service'; +import { OpenTelemetryTracer } from '../v2-tracer.adapter'; import { ComputedOutboxBaseAdmissionService, type ComputedOutboxBaseAdmissionPermit, @@ -19,6 +20,9 @@ import { IComputedOutboxWakeupAppPublisher } from './computed-outbox-wakeup.publ import type { ComputedOutboxWakeupWire } from './computed-outbox-wakeup.wire'; import { COMPUTED_OUTBOX_WAKEUP_PUBLISHER } from './constants'; +/** Handler-local tracer for wake-up spans + W3C carrier restore (no container needed). */ +const wakeupTracer = new OpenTelemetryTracer('computed-outbox-wakeup'); + export type ComputedOutboxWakeupHandlerOutcome = { status: 'processed' | 'noop' | 'deferred' | 'parked'; }; @@ -130,64 +134,126 @@ export class ComputedOutboxWakeupHandler { private async handleAsConsumer( wakeup: ComputedOutboxWakeupWire + ): Promise { + const carrier = + wakeup.traceparent != null + ? { + traceparent: wakeup.traceparent, + ...(wakeup.tracestate ? { tracestate: wakeup.tracestate } : {}), + } + : undefined; + + const run = () => this.handleWithSpans(wakeup); + if (carrier) { + return wakeupTracer.runWithPropagationCarrier(carrier, run); + } + return run(); + } + + private async handleWithSpans( + wakeup: ComputedOutboxWakeupWire ): Promise { const startedAt = performance.now(); - let admittedOperationStarted = false; - this.metrics.recordDeliveryLag(Date.now() - new Date(wakeup.availableAt).getTime()); - let admission; - try { - admission = await this.baseAdmission.runWithPermit(wakeup.baseId, (permit) => { - admittedOperationStarted = true; - return this.handleAdmitted(wakeup, startedAt, permit); - }); - } catch (error) { - if (!admittedOperationStarted) { + const availableAtMs = new Date(wakeup.availableAt).getTime(); + const emittedAtMs = new Date(wakeup.emittedAt).getTime(); + const nowMs = Date.now(); + const deliveryLagMs = Math.max(0, nowMs - availableAtMs); + const enqueueToConsumeMs = Number.isFinite(emittedAtMs) + ? Math.max(0, nowMs - emittedAtMs) + : undefined; + + this.metrics.recordDeliveryLag(deliveryLagMs); + + const rootSpan = wakeupTracer.startSpan('teable.computed.outbox.wakeup.handle', { + 'outbox.taskId': wakeup.taskId, + 'outbox.baseId': wakeup.baseId, + 'outbox.wakeupId': wakeup.wakeupId, + 'outbox.wakeupCause': wakeup.cause, + 'outbox.hasTraceparent': Boolean(wakeup.traceparent), + 'outbox.deliveryLagMs': deliveryLagMs, + ...(enqueueToConsumeMs != null ? { 'outbox.enqueueToConsumeMs': enqueueToConsumeMs } : {}), + }); + + const execute = async (): Promise => { + let admittedOperationStarted = false; + let admission; + try { + const admissionStartedAt = performance.now(); + admission = await this.baseAdmission.runWithPermit(wakeup.baseId, (permit) => { + admittedOperationStarted = true; + rootSpan.setAttribute( + 'outbox.admissionWaitMs', + Math.round(performance.now() - admissionStartedAt) + ); + rootSpan.setAttribute('outbox.admission', 'admitted'); + return this.handleAdmitted(wakeup, startedAt, permit, rootSpan); + }); + } catch (error) { + if (!admittedOperationStarted) { + this.metrics.recordConsume('error'); + this.metrics.recordExecutionDuration(performance.now() - startedAt, 'error'); + } + rootSpan.recordError(error instanceof Error ? error.message : String(error)); + throw error; + } + if (admission.admitted) { + rootSpan.setAttribute('outbox.outcome', admission.value.status); + return admission.value; + } + + rootSpan.setAttribute('outbox.admission', 'deferred'); + const deferNowMs = Date.now(); + const deferDelayMs = stableAdmissionDeferDelayMs(wakeup.baseId, wakeup.taskId); + const availableAt = new Date(deferNowMs + deferDelayMs); + const baseWakeupId = `cuwd-admit-${wakeup.taskId}-${Math.floor( + availableAt.getTime() / ADMISSION_DEFER_SPREAD_MS + )}`; + const wakeupId = + wakeup.wakeupId === baseWakeupId || wakeup.wakeupId.startsWith(`${baseWakeupId}-r`) + ? `${baseWakeupId}-r${Math.floor(deferNowMs / ADMISSION_DEFER_MIN_MS)}` + : baseWakeupId; + try { + await this.wakeupPublisher.publish( + createComputedOutboxWakeup({ + wakeupId, + taskId: wakeup.taskId, + baseId: wakeup.baseId, + availableAt, + cause: 'replay', + ...(wakeup.traceparent ? { traceparent: wakeup.traceparent } : {}), + ...(wakeup.tracestate ? { tracestate: wakeup.tracestate } : {}), + }) + ); + } catch (error) { this.metrics.recordConsume('error'); this.metrics.recordExecutionDuration(performance.now() - startedAt, 'error'); + rootSpan.recordError(error instanceof Error ? error.message : String(error)); + throw error; } - throw error; - } - if (admission.admitted) return admission.value; + this.metrics.recordConsume('deferred'); + this.metrics.recordExecutionDuration(performance.now() - startedAt, 'deferred'); + rootSpan.setAttribute('outbox.outcome', 'deferred'); + rootSpan.setAttribute('outbox.deferReason', 'admission'); + this.logger.debug('computed:outbox:wakeup_admission_deferred', { + taskId: wakeup.taskId, + baseId: wakeup.baseId, + availableAt: availableAt.toISOString(), + }); + return { status: 'deferred' }; + }; - const nowMs = Date.now(); - const deferDelayMs = stableAdmissionDeferDelayMs(wakeup.baseId, wakeup.taskId); - const availableAt = new Date(nowMs + deferDelayMs); - const baseWakeupId = `cuwd-admit-${wakeup.taskId}-${Math.floor( - availableAt.getTime() / ADMISSION_DEFER_SPREAD_MS - )}`; - const wakeupId = - wakeup.wakeupId === baseWakeupId || wakeup.wakeupId.startsWith(`${baseWakeupId}-r`) - ? `${baseWakeupId}-r${Math.floor(nowMs / ADMISSION_DEFER_MIN_MS)}` - : baseWakeupId; try { - await this.wakeupPublisher.publish( - createComputedOutboxWakeup({ - wakeupId, - taskId: wakeup.taskId, - baseId: wakeup.baseId, - availableAt, - cause: 'replay', - }) - ); - } catch (error) { - this.metrics.recordConsume('error'); - this.metrics.recordExecutionDuration(performance.now() - startedAt, 'error'); - throw error; + return await wakeupTracer.withSpan(rootSpan, execute); + } finally { + rootSpan.end(); } - this.metrics.recordConsume('deferred'); - this.metrics.recordExecutionDuration(performance.now() - startedAt, 'deferred'); - this.logger.debug('computed:outbox:wakeup_admission_deferred', { - taskId: wakeup.taskId, - baseId: wakeup.baseId, - availableAt: availableAt.toISOString(), - }); - return { status: 'deferred' }; } private async handleAdmitted( wakeup: ComputedOutboxWakeupWire, startedAt: number, - permit: ComputedOutboxBaseAdmissionPermit + permit: ComputedOutboxBaseAdmissionPermit, + parentSpan: ReturnType ): Promise { try { permit.assertActive(); @@ -196,15 +262,21 @@ export class ComputedOutboxWakeupHandler { v2RecordRepositoryPostgresTokens.computedUpdateWorker ); const workerId = `computed-queue-${process.pid}`; - const tracer = container.resolve(v2CoreTokens.tracer); + const workerTracer = container.resolve(v2CoreTokens.tracer); permit.assertActive(); + + const runTaskStartedAt = performance.now(); const result = await worker.runTaskById({ taskId: wakeup.taskId, workerId, - tracer, + tracer: workerTracer, // Healthy leases must not be stolen; claimById still reclaims expired processing. allowProcessingTakeover: false, }); + parentSpan.setAttribute( + 'outbox.runTaskByIdMs', + Math.round(performance.now() - runTaskStartedAt) + ); if (result.isErr()) throw result.error; permit.assertActive(); @@ -214,12 +286,20 @@ export class ComputedOutboxWakeupHandler { // immediately instead of waiting for another BullMQ delivery or a multi-second // concurrency defer — this restores the T6191 "continue after any progress" // behavior after polling was replaced by BullMQ-only wake-ups. - await this.drainRemainingOutbox(worker, workerId, wakeup.baseId, permit); + const drained = await this.drainRemainingOutbox( + worker, + workerId, + wakeup.baseId, + permit, + workerTracer + ); + parentSpan.setAttribute('outbox.drainTaskCount', drained); this.metrics.recordConsume('processed'); this.metrics.recordExecutionDuration(performance.now() - startedAt, 'processed'); return { status: 'processed' }; } + parentSpan.setAttribute('outbox.taskClaimed', false); permit.assertActive(); const outbox = container.resolve( v2RecordRepositoryPostgresTokens.computedUpdateOutbox @@ -262,10 +342,16 @@ export class ComputedOutboxWakeupHandler { baseId: wakeup.baseId, availableAt, cause: 'replay', + ...(wakeup.traceparent ? { traceparent: wakeup.traceparent } : {}), + ...(wakeup.tracestate ? { tracestate: wakeup.tracestate } : {}), }) ); this.metrics.recordConsume('deferred'); this.metrics.recordExecutionDuration(performance.now() - startedAt, 'deferred'); + parentSpan.setAttribute( + 'outbox.deferReason', + eligibility.status === 'deferred' ? eligibility.reason : eligibility.status + ); this.logger.debug('computed:outbox:wakeup_deferred', { taskId: wakeup.taskId, baseId: wakeup.baseId, @@ -277,6 +363,7 @@ export class ComputedOutboxWakeupHandler { } catch (error) { this.metrics.recordConsume('error'); this.metrics.recordExecutionDuration(performance.now() - startedAt, 'error'); + parentSpan.recordError(error instanceof Error ? error.message : String(error)); throw error; } } @@ -289,48 +376,69 @@ export class ComputedOutboxWakeupHandler { worker: ComputedUpdateWorker, workerId: string, baseId: string, - permit: ComputedOutboxBaseAdmissionPermit - ): Promise { - let drained = 0; - while (drained < POST_PROCESS_DRAIN_MAX_TASKS) { - permit.assertActive(); - const more = await worker.runOnce({ - workerId, - limit: POST_PROCESS_DRAIN_BATCH_SIZE, - }); - permit.assertActive(); - if (more.isErr()) { - this.logger.warn('computed:outbox:post_process_drain_failed', { - baseId, + permit: ComputedOutboxBaseAdmissionPermit, + workerTracer?: ITracer + ): Promise { + const span = wakeupTracer.startSpan('teable.computed.outbox.wakeup.drain', { + 'outbox.baseId': baseId, + 'worker.id': workerId, + }); + + const run = async (): Promise => { + let drained = 0; + while (drained < POST_PROCESS_DRAIN_MAX_TASKS) { + permit.assertActive(); + const more = await worker.runOnce({ workerId, - drained, - error: more.error.message, + limit: POST_PROCESS_DRAIN_BATCH_SIZE, + tracer: workerTracer, }); - return; - } - if (more.value <= 0) { - if (drained > 0) { - this.logger.debug('computed:outbox:post_process_drain_idle', { + permit.assertActive(); + if (more.isErr()) { + span.recordError(more.error.message); + this.logger.warn('computed:outbox:post_process_drain_failed', { baseId, workerId, drained, + error: more.error.message, }); + return drained; + } + if (more.value <= 0) { + if (drained > 0) { + this.logger.debug('computed:outbox:post_process_drain_idle', { + baseId, + workerId, + drained, + }); + } + span.setAttribute('outbox.drainTaskCount', drained); + span.setAttribute('outbox.drainCapped', false); + return drained; } - return; + drained += more.value; + this.logger.debug('computed:outbox:post_process_drain_continue', { + baseId, + workerId, + processed: more.value, + drained, + }); } - drained += more.value; - this.logger.debug('computed:outbox:post_process_drain_continue', { + span.setAttribute('outbox.drainTaskCount', drained); + span.setAttribute('outbox.drainCapped', true); + this.logger.warn('computed:outbox:post_process_drain_capped', { baseId, workerId, - processed: more.value, drained, + maxTasks: POST_PROCESS_DRAIN_MAX_TASKS, }); + return drained; + }; + + try { + return await wakeupTracer.withSpan(span, run); + } finally { + span.end(); } - this.logger.warn('computed:outbox:post_process_drain_capped', { - baseId, - workerId, - drained, - maxTasks: POST_PROCESS_DRAIN_MAX_TASKS, - }); } } diff --git a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-wakeup.wire.ts b/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-wakeup.wire.ts index 5aa355be9f..3c3f20a8ef 100644 --- a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-wakeup.wire.ts +++ b/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/computed-outbox-wakeup.wire.ts @@ -8,6 +8,9 @@ export const computedOutboxWakeupWireSchema = z.object({ availableAt: z.iso.datetime(), emittedAt: z.iso.datetime(), cause: z.enum(['created', 'merged', 'retry', 'replay']), + // Optional W3C carrier so worker spans join the originating write trace. + traceparent: z.string().min(1).optional(), + tracestate: z.string().min(1).optional(), }); export type ComputedOutboxWakeupWire = z.infer; diff --git a/apps/nestjs-backend/src/features/v2/table-query-search-observability.ts b/apps/nestjs-backend/src/features/v2/table-query-search-observability.ts index a2fb0157a0..3113200b1b 100644 --- a/apps/nestjs-backend/src/features/v2/table-query-search-observability.ts +++ b/apps/nestjs-backend/src/features/v2/table-query-search-observability.ts @@ -71,10 +71,13 @@ const requestDuration = tableQueryMeter.createHistogram('teable.table_query.dura unit: 'ms', }); -const dbParentDuration = tableQueryMeter.createHistogram('teable.table_query.db_parent.duration.ms', { - description: 'Parent application span duration for table-query database work', - unit: 'ms', -}); +const dbParentDuration = tableQueryMeter.createHistogram( + 'teable.table_query.db_parent.duration.ms', + { + description: 'Parent application span duration for table-query database work', + unit: 'ms', + } +); const validationDuration = tableQueryMeter.createHistogram( 'teable.table_query.search.validation.duration.ms', diff --git a/apps/nestjs-backend/src/features/v2/table-query-search-vector-runtime.service.spec.ts b/apps/nestjs-backend/src/features/v2/table-query-search-vector-runtime.service.spec.ts index 35bb913608..4220873c5e 100644 --- a/apps/nestjs-backend/src/features/v2/table-query-search-vector-runtime.service.spec.ts +++ b/apps/nestjs-backend/src/features/v2/table-query-search-vector-runtime.service.spec.ts @@ -1,10 +1,11 @@ +import { v2TableOpsTokens } from '@teable/v2-table-query-ops'; +import { ok } from 'neverthrow'; import { describe, expect, it, vi } from 'vitest'; import { TableQuerySearchVectorRuntimeService, hasSearchValueForSearchVectorRuntime, resolveTableQuerySearchVectorRuntimeMode, - toRecordSearchAccessPathFromConfig, } from './table-query-search-vector-runtime.service'; describe('TableQuerySearchVectorRuntimeService', () => { @@ -21,71 +22,6 @@ describe('TableQuerySearchVectorRuntimeService', () => { expect(resolveTableQuerySearchVectorRuntimeMode(input)).toBe(expected); }); - it('converts a ready config row into a generated tsvector access path', () => { - const fieldId = `fld${'a'.repeat(16)}`; - - const accessPath = toRecordSearchAccessPathFromConfig({ - generatedColumnName: '__tqops_search_vector', - languageConfig: 'simple', - fieldIds: JSON.stringify([fieldId]), - searchScope: 'all_fields', - status: 'ready', - }); - - expect(accessPath).toMatchObject({ - kind: 'generated_tsvector', - generatedColumnName: '__tqops_search_vector', - languageConfig: 'simple', - searchScope: 'all_fields', - }); - expect(accessPath?.coveredFieldIds.map((id) => id.toString())).toEqual([fieldId]); - }); - - it('converts a ready substring config into a generated text access path', () => { - const fieldId = `fld${'b'.repeat(16)}`; - const accessPath = toRecordSearchAccessPathFromConfig({ - generatedColumnName: '__tqops_search_document', - semantics: 'substring', - accessPath: 'generated_text', - provider: 'pg_bigm', - fieldIds: [fieldId], - searchScope: 'all_fields', - status: 'ready', - }); - - expect(accessPath).toMatchObject({ - kind: 'generated_text', - generatedColumnName: '__tqops_search_document', - provider: 'pg_bigm', - searchScope: 'all_fields', - }); - expect(accessPath?.coveredFieldIds.map((id) => id.toString())).toEqual([fieldId]); - }); - - it('does not create an access path when covered fields are missing or invalid', () => { - expect( - toRecordSearchAccessPathFromConfig({ - generatedColumnName: '__tqops_search_vector', - languageConfig: 'simple', - fieldIds: JSON.stringify(['not-a-field']), - searchScope: 'all_fields', - status: 'ready', - }) - ).toBeUndefined(); - }); - - it('does not reactivate an older ready path when the latest config is pending', () => { - expect( - toRecordSearchAccessPathFromConfig({ - generatedColumnName: '__tqops_search_vector', - languageConfig: 'simple', - fieldIds: JSON.stringify([`fld${'a'.repeat(16)}`]), - searchScope: 'all_fields', - status: 'rebuild_pending', - }) - ).toBeUndefined(); - }); - it.each([ [undefined, false], [[], false], @@ -96,7 +32,7 @@ describe('TableQuerySearchVectorRuntimeService', () => { expect(hasSearchValueForSearchVectorRuntime(search)).toBe(expected); }); - it('does not read meta config when the global runtime gate is off', async () => { + it('does not consult the resolver when the global runtime gate is off', async () => { const service = new TableQuerySearchVectorRuntimeService({ get: vi.fn().mockReturnValue('off'), } as never); @@ -113,4 +49,51 @@ describe('TableQuerySearchVectorRuntimeService', () => { ).resolves.toBeUndefined(); expect(container.isRegistered).not.toHaveBeenCalled(); }); + + it('delegates to the registered search access path resolver port', async () => { + const accessPath = { + kind: 'generated_text' as const, + generatedColumnName: '__tqops_search_document', + provider: 'pg_trgm' as const, + searchScope: 'all_fields' as const, + coveredFieldIds: [], + }; + const resolve = vi.fn().mockResolvedValue(ok(accessPath)); + const container = { + isRegistered: vi.fn().mockReturnValue(true), + resolve: vi.fn().mockReturnValue({ resolve }), + }; + const service = new TableQuerySearchVectorRuntimeService({ + get: vi.fn().mockReturnValue('auto'), + } as never); + + await expect( + service.resolveForRecordSearch({ + container: container as never, + tableId: `tbl${'a'.repeat(16)}`, + search: ['order 123'], + }) + ).resolves.toBe(accessPath); + expect(container.isRegistered).toHaveBeenCalledWith(v2TableOpsTokens.searchAccessPathResolver); + expect(resolve).toHaveBeenCalledWith(expect.anything(), `tbl${'a'.repeat(16)}`); + }); + + it('returns undefined when the resolver port is not registered', async () => { + const container = { + isRegistered: vi.fn().mockReturnValue(false), + resolve: vi.fn(), + }; + const service = new TableQuerySearchVectorRuntimeService({ + get: vi.fn().mockReturnValue('auto'), + } as never); + + await expect( + service.resolveForRecordSearch({ + container: container as never, + tableId: `tbl${'a'.repeat(16)}`, + search: ['order 123'], + }) + ).resolves.toBeUndefined(); + expect(container.resolve).not.toHaveBeenCalled(); + }); }); diff --git a/apps/nestjs-backend/src/features/v2/table-query-search-vector-runtime.service.ts b/apps/nestjs-backend/src/features/v2/table-query-search-vector-runtime.service.ts index 352568a61a..4cfaedb1ca 100644 --- a/apps/nestjs-backend/src/features/v2/table-query-search-vector-runtime.service.ts +++ b/apps/nestjs-backend/src/features/v2/table-query-search-vector-runtime.service.ts @@ -1,23 +1,8 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { v2MetaDbTokens } from '@teable/v2-adapter-db-postgres-pg'; -import { FieldId, type IRecordSearchAccessPath } from '@teable/v2-core'; +import { ActorId, type IExecutionContext, type IRecordSearchAccessPath } from '@teable/v2-core'; import type { DependencyContainer } from '@teable/v2-di'; -import type { Kysely } from 'kysely'; -import { sql } from 'kysely'; - -type UnknownRow = Record; - -export type SearchVectorConfigRow = { - readonly generatedColumnName: string; - readonly semantics?: string; - readonly accessPath?: string; - readonly provider?: string; - readonly languageConfig?: string | null; - readonly fieldIds: unknown; - readonly searchScope: string; - readonly status: string; -}; +import { v2TableOpsTokens, type TableSearchAccessPathResolver } from '@teable/v2-table-query-ops'; export type TableQuerySearchVectorRuntimeMode = 'off' | 'auto'; @@ -43,73 +28,6 @@ export const resolveTableQuerySearchVectorRuntimeMode = ( return 'off'; }; -const parseFieldIds = (raw: unknown): readonly FieldId[] => { - const parsed = - typeof raw === 'string' - ? (() => { - try { - return JSON.parse(raw) as unknown; - } catch { - return undefined; - } - })() - : raw; - - if (!Array.isArray(parsed)) { - return []; - } - - return parsed.flatMap((value) => { - const fieldIdResult = FieldId.create(value); - return fieldIdResult.isOk() ? [fieldIdResult.value] : []; - }); -}; - -export const toRecordSearchAccessPathFromConfig = ( - row: SearchVectorConfigRow | undefined -): IRecordSearchAccessPath | undefined => { - if (!row) { - return undefined; - } - - if (row.status !== 'ready') { - return undefined; - } - - const searchScope = - row.searchScope === 'all_fields' || row.searchScope === 'selected_fields' - ? row.searchScope - : undefined; - const coveredFieldIds = parseFieldIds(row.fieldIds); - if (!row.generatedColumnName || !searchScope || coveredFieldIds.length === 0) { - return undefined; - } - - if ( - row.semantics === 'substring' && - row.accessPath === 'generated_text' && - (row.provider === 'pg_trgm' || row.provider === 'pg_bigm') - ) { - return { - kind: 'generated_text', - generatedColumnName: row.generatedColumnName, - provider: row.provider, - searchScope, - coveredFieldIds, - }; - } - - if (!row.languageConfig) return undefined; - - return { - kind: 'generated_tsvector', - generatedColumnName: row.generatedColumnName, - languageConfig: row.languageConfig, - searchScope, - coveredFieldIds, - }; -}; - export const hasSearchValueForSearchVectorRuntime = (search: unknown): boolean => { if (!Array.isArray(search)) { return false; @@ -133,8 +51,16 @@ export class TableQuerySearchVectorRuntimeService { } try { - const row = await this.readReadyConfig(input.container, input.tableId); - return toRecordSearchAccessPathFromConfig(row); + // The config storage is owned by the table-query-ops adapter; read it + // through its resolver port instead of issuing SQL from the app layer. + if (!input.container.isRegistered(v2TableOpsTokens.searchAccessPathResolver)) { + return undefined; + } + const resolver = input.container.resolve( + v2TableOpsTokens.searchAccessPathResolver + ); + const resolved = await resolver.resolve(this.systemContext(), input.tableId); + return resolved.isOk() ? resolved.value : undefined; } catch { return undefined; } @@ -147,32 +73,10 @@ export class TableQuerySearchVectorRuntimeService { ); } - private async readReadyConfig( - container: DependencyContainer, - tableId: string - ): Promise { - if (!container.isRegistered(v2MetaDbTokens.db)) { - return undefined; - } - - const metaDb = container.resolve>(v2MetaDbTokens.db); - const result = await sql` - SELECT - generated_column_name AS "generatedColumnName", - semantics, - access_path AS "accessPath", - provider, - language_config AS "languageConfig", - field_ids AS "fieldIds", - search_scope AS "searchScope", - status - FROM table_query_search_vector_config - WHERE table_id = ${tableId} - AND status IN ('ready', 'rebuild_pending', 'stale') - ORDER BY last_modified_time DESC NULLS LAST, created_time DESC NULLS LAST - LIMIT 1 - `.execute(metaDb); - - return result.rows[0]; + private systemContext(): IExecutionContext { + return { + actorId: ActorId.create('system')._unsafeUnwrap(), + requestId: 'table-query-search-vector-runtime', + }; } } 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 beb18cd5cb..f2f4ba1371 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 @@ -11,6 +11,9 @@ import { RecordsDeleted, TableActionTriggerRequested, TableId, + ViewColumnMetaUpdated, + ViewFilterUpdated, + ViewGroupUpdated, ViewId, type IExecutionContext, type IEventHandler, @@ -60,6 +63,7 @@ const createIds = () => { baseId: BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap(), tableId: TableId.create(`tbl${'b'.repeat(16)}`)._unsafeUnwrap(), fieldId: FieldId.create(`fld${'c'.repeat(16)}`)._unsafeUnwrap(), + viewId: ViewId.create(`viw${'d'.repeat(16)}`)._unsafeUnwrap(), }; }; @@ -922,4 +926,207 @@ describe('V2ActionTriggerService', () => { ], ]); }); + + it('emits applyViewFilter through the v2 action-trigger sink', async () => { + let channelSubmitted: string | undefined; + let submitted: IPresencePayload | undefined; + const shareDbService = { + connect: () => ({ + getPresence: (channel: string) => { + channelSubmitted = channel; + return { + 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; + new V2ActionTriggerService(shareDbService).registerProjections(container); + const projection = registered.find( + (item) => + (item.instance as { constructor?: { name?: string } }).constructor?.name === + 'V2ViewFilterUpdatedActionTriggerProjection' + )?.instance as IEventHandler | undefined; + const { baseId, tableId, fieldId, viewId } = createIds(); + + const result = await projection?.handle( + {} as IExecutionContext, + ViewFilterUpdated.create({ + baseId, + tableId, + viewId, + previousFilter: null, + nextFilter: { + conjunction: 'and', + filterSet: [{ fieldId: fieldId.toString(), operator: 'is', value: 'active' }], + }, + }) + ); + await waitForPresenceFlush(); + + expect(result?.isOk()).toBe(true); + expect(channelSubmitted).toBe(getActionTriggerChannel(viewId.toString())); + expect(submitted).toEqual([{ actionKey: 'applyViewFilter' }]); + }); + + it('emits applyViewGroup through the v2 action-trigger sink', async () => { + let channelSubmitted: string | undefined; + let submitted: IPresencePayload | undefined; + const shareDbService = { + connect: () => ({ + getPresence: (channel: string) => { + channelSubmitted = channel; + return { + 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; + new V2ActionTriggerService(shareDbService).registerProjections(container); + const projection = registered.find( + (item) => + (item.instance as { constructor?: { name?: string } }).constructor?.name === + 'V2ViewGroupUpdatedActionTriggerProjection' + )?.instance as IEventHandler | undefined; + const { baseId, tableId, fieldId, viewId } = createIds(); + + const result = await projection?.handle( + {} as IExecutionContext, + ViewGroupUpdated.create({ + baseId, + tableId, + viewId, + previousGroup: null, + nextGroup: [{ fieldId: fieldId.toString(), order: 'asc' }], + }) + ); + await waitForPresenceFlush(); + + expect(result?.isOk()).toBe(true); + expect(channelSubmitted).toBe(getActionTriggerChannel(viewId.toString())); + expect(submitted).toEqual([{ actionKey: 'applyViewGroup' }]); + }); + + it('derives View column actions from v2 column metadata changes', 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; + new V2ActionTriggerService(shareDbService).registerProjections(container); + const projection = registered.find( + (item) => + (item.instance as { constructor?: { name?: string } }).constructor?.name === + 'V2ViewColumnMetaUpdatedActionTriggerProjection' + )?.instance as IEventHandler | undefined; + const { baseId, tableId, fieldId, viewId } = createIds(); + + const result = await projection?.handle( + {} as IExecutionContext, + ViewColumnMetaUpdated.create({ + baseId, + tableId, + viewId, + fieldId, + changes: [ + { + fieldId, + previousColumnMeta: { hidden: true, statisticFunc: 'sum' }, + nextColumnMeta: { hidden: false, statisticFunc: 'average' }, + }, + ], + }) + ); + await waitForPresenceFlush(); + + expect(result?.isOk()).toBe(true); + expect(submitted).toEqual([ + { actionKey: 'showViewField' }, + { actionKey: 'applyViewStatisticFunc' }, + ]); + }); + + it('skips View column actions when visibility and statistic behavior do not change', async () => { + const submit = vi.fn(); + const shareDbService = { + connect: () => ({ + getPresence: () => ({ + create: () => ({ submit }), + }), + }), + } 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; + new V2ActionTriggerService(shareDbService).registerProjections(container); + const projection = registered.find( + (item) => + (item.instance as { constructor?: { name?: string } }).constructor?.name === + 'V2ViewColumnMetaUpdatedActionTriggerProjection' + )?.instance as IEventHandler | undefined; + const { baseId, tableId, fieldId, viewId } = createIds(); + + const result = await projection?.handle( + {} as IExecutionContext, + ViewColumnMetaUpdated.create({ + baseId, + tableId, + viewId, + fieldId, + changes: [ + { + fieldId, + previousColumnMeta: { hidden: true, statisticFunc: 'sum', width: 120 }, + nextColumnMeta: { hidden: true, statisticFunc: 'sum', width: 240 }, + }, + ], + }) + ); + await waitForPresenceFlush(); + + expect(result?.isOk()).toBe(true); + expect(submit).not.toHaveBeenCalled(); + }); }); 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 6db223e8b9..4b3b3fab61 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 @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { getActionTriggerChannel } from '@teable/core'; -import type { ITableActionKey } from '@teable/core'; +import type { ITableActionKey, IViewActionKey } from '@teable/core'; import { FieldCreated, FieldDeleted, @@ -12,6 +12,9 @@ import { RecordsBatchUpdated, RecordsDeleted, TableActionTriggerRequested, + ViewColumnMetaUpdated, + ViewFilterUpdated, + ViewGroupUpdated, ProjectionHandler, ok, serializeFieldUpdatedValue, @@ -23,16 +26,33 @@ import { ShareDbService } from '../../share-db/share-db.service'; import { V2ProjectionRegistrar, type IV2ProjectionRegistrar } from './v2-projection-registrar'; export interface IActionTriggerData { - actionKey: ITableActionKey; + actionKey: ITableActionKey | IViewActionKey; payload?: Record; } +interface IActionTriggerSink { + submit(targetId: string, data: IActionTriggerData[]): void; +} + type IPendingActionTriggerBatch = { - shareDbService: ShareDbService; - tableId: string; + sink: IActionTriggerSink; + targetId: string; data: IActionTriggerData[]; }; +class ShareDbActionTriggerSink implements IActionTriggerSink { + constructor(private readonly shareDbService: ShareDbService) {} + + submit(targetId: string, data: IActionTriggerData[]): void { + const channel = getActionTriggerChannel(targetId); + const presence = this.shareDbService.connect().getPresence(channel); + const localPresence = presence.create(targetId); + localPresence.submit(data, (error) => { + if (error) console.error('Action trigger error:', error); + }); + } +} + const isRecord = (value: unknown): value is Record => value instanceof Object && !Array.isArray(value); @@ -113,27 +133,22 @@ const flushPendingActionTriggers = () => { pendingActionTriggerBatches.clear(); for (const batch of batches) { - const channel = getActionTriggerChannel(batch.tableId); - const presence = batch.shareDbService.connect().getPresence(channel); - const localPresence = presence.create(batch.tableId); - localPresence.submit(batch.data, (error) => { - if (error) console.error('Action trigger error:', error); - }); + batch.sink.submit(batch.targetId, batch.data); } }; const emitActionTrigger = ( - shareDbService: ShareDbService, - tableId: string, + sink: IActionTriggerSink, + targetId: string, data: IActionTriggerData[] ) => { - const pending = pendingActionTriggerBatches.get(tableId) ?? { - shareDbService, - tableId, + const pending = pendingActionTriggerBatches.get(targetId) ?? { + sink, + targetId, data: [], }; pending.data.push(...data); - pendingActionTriggerBatches.set(tableId, pending); + pendingActionTriggerBatches.set(targetId, pending); if (!flushScheduled) { flushScheduled = true; @@ -143,17 +158,19 @@ const emitActionTrigger = ( /** * V2 projection handler that emits action triggers for record create events. - * This enables V1 frontend features like row count refresh. + * This keeps realtime clients informed about record changes such as row-count refreshes. */ @ProjectionHandler(RecordCreated) class V2RecordCreatedActionTriggerProjection implements IEventHandler { - constructor(private readonly shareDbService: ShareDbService) {} + constructor(private readonly actionTriggerSink: IActionTriggerSink) {} async handle( _context: IExecutionContext, event: RecordCreated ): Promise> { - emitActionTrigger(this.shareDbService, event.tableId.toString(), [{ actionKey: 'addRecord' }]); + emitActionTrigger(this.actionTriggerSink, event.tableId.toString(), [ + { actionKey: 'addRecord' }, + ]); return ok(undefined); } } @@ -163,7 +180,7 @@ class V2RecordCreatedActionTriggerProjection implements IEventHandler { - constructor(private readonly shareDbService: ShareDbService) {} + constructor(private readonly actionTriggerSink: IActionTriggerSink) {} async handle( _context: IExecutionContext, @@ -173,7 +190,7 @@ class V2RecordsBatchCreatedActionTriggerProjection implements IEventHandler { - constructor(private readonly shareDbService: ShareDbService) {} + constructor(private readonly actionTriggerSink: IActionTriggerSink) {} async handle( _context: IExecutionContext, event: RecordUpdated ): Promise> { const fieldIds = event.changes.map((c) => c.fieldId); - emitActionTrigger(this.shareDbService, event.tableId.toString(), [ + emitActionTrigger(this.actionTriggerSink, event.tableId.toString(), [ { actionKey: 'setRecord', payload: { fieldIds } }, ]); return ok(undefined); @@ -219,7 +236,7 @@ class V2RecordUpdatedActionTriggerProjection implements IEventHandler { - constructor(private readonly shareDbService: ShareDbService) {} + constructor(private readonly actionTriggerSink: IActionTriggerSink) {} async handle( _context: IExecutionContext, @@ -230,7 +247,7 @@ class V2RecordsBatchUpdatedActionTriggerProjection implements IEventHandler { - constructor(private readonly shareDbService: ShareDbService) {} + constructor(private readonly actionTriggerSink: IActionTriggerSink) {} async handle( _context: IExecutionContext, @@ -271,7 +288,7 @@ class V2RecordReorderedActionTriggerProjection implements IEventHandler { - constructor(private readonly shareDbService: ShareDbService) {} + constructor(private readonly actionTriggerSink: IActionTriggerSink) {} async handle( _context: IExecutionContext, @@ -292,7 +309,7 @@ class V2RecordsDeletedActionTriggerProjection implements IEventHandler { - constructor(private readonly shareDbService: ShareDbService) {} + constructor(private readonly actionTriggerSink: IActionTriggerSink) {} async handle( _context: IExecutionContext, event: FieldCreated ): Promise> { - emitActionTrigger(this.shareDbService, event.tableId.toString(), [ + emitActionTrigger(this.actionTriggerSink, event.tableId.toString(), [ { actionKey: 'addField', payload: { @@ -353,13 +370,13 @@ class V2FieldCreatedActionTriggerProjection implements IEventHandler { - constructor(private readonly shareDbService: ShareDbService) {} + constructor(private readonly actionTriggerSink: IActionTriggerSink) {} async handle( _context: IExecutionContext, event: FieldDeleted ): Promise> { - emitActionTrigger(this.shareDbService, event.tableId.toString(), [ + emitActionTrigger(this.actionTriggerSink, event.tableId.toString(), [ { actionKey: 'deleteField', payload: { @@ -377,7 +394,7 @@ class V2FieldDeletedActionTriggerProjection implements IEventHandler { - constructor(private readonly shareDbService: ShareDbService) {} + constructor(private readonly actionTriggerSink: IActionTriggerSink) {} async handle( _context: IExecutionContext, @@ -387,7 +404,7 @@ class V2FieldUpdatedActionTriggerProjection implements IEventHandler { + constructor(private readonly actionTriggerSink: IActionTriggerSink) {} + + async handle( + _context: IExecutionContext, + event: ViewFilterUpdated + ): Promise> { + emitActionTrigger(this.actionTriggerSink, event.viewId.toString(), [ + { actionKey: 'applyViewFilter' }, + ]); + return ok(undefined); + } +} + +@ProjectionHandler(ViewGroupUpdated) +class V2ViewGroupUpdatedActionTriggerProjection implements IEventHandler { + constructor(private readonly actionTriggerSink: IActionTriggerSink) {} + + async handle( + _context: IExecutionContext, + event: ViewGroupUpdated + ): Promise> { + emitActionTrigger(this.actionTriggerSink, event.viewId.toString(), [ + { actionKey: 'applyViewGroup' }, + ]); + return ok(undefined); + } +} + +@ProjectionHandler(ViewColumnMetaUpdated) +class V2ViewColumnMetaUpdatedActionTriggerProjection + implements IEventHandler +{ + constructor(private readonly actionTriggerSink: IActionTriggerSink) {} + + async handle( + _context: IExecutionContext, + event: ViewColumnMetaUpdated + ): Promise> { + const actions: IActionTriggerData[] = []; + for (const change of event.changes ?? []) { + const previous = change.previousColumnMeta; + const next = change.nextColumnMeta; + if (!next.hidden && previous?.hidden !== next.hidden) { + actions.push({ actionKey: 'showViewField' }); + } + if (previous?.statisticFunc !== next.statisticFunc) { + actions.push({ actionKey: 'applyViewStatisticFunc' }); + } + } + if (actions.length > 0) { + emitActionTrigger(this.actionTriggerSink, event.viewId.toString(), actions); + } + return ok(undefined); + } +} + @ProjectionHandler(TableActionTriggerRequested) class V2TableActionTriggerRequestedProjection implements IEventHandler { - constructor(private readonly shareDbService: ShareDbService) {} + constructor(private readonly actionTriggerSink: IActionTriggerSink) {} async handle( _context: IExecutionContext, event: TableActionTriggerRequested ): Promise> { - emitActionTrigger(this.shareDbService, event.tableId.toString(), [ + emitActionTrigger(this.actionTriggerSink, event.tableId.toString(), [ { actionKey: event.actionKey, ...(event.payload ? { payload: event.payload } : {}), @@ -422,7 +497,7 @@ class V2TableActionTriggerRequestedProjection /** * Service that registers V2 action trigger projections with the V2 container. - * These projections emit ShareDB presence events for V1 frontend compatibility. + * The projections target a narrow sink port; the Nest adapter owns ShareDB integration. */ @V2ProjectionRegistrar() @Injectable() @@ -438,57 +513,72 @@ export class V2ActionTriggerService implements IV2ProjectionRegistrar { registerProjections(container: DependencyContainer): void { this.logger.log('Registering V2 action trigger projections'); - const shareDbService = this.shareDbService; + const actionTriggerSink = new ShareDbActionTriggerSink(this.shareDbService); // Register projection instances directly since they depend on NestJS ShareDbService container.registerInstance( V2RecordCreatedActionTriggerProjection, - new V2RecordCreatedActionTriggerProjection(shareDbService) + new V2RecordCreatedActionTriggerProjection(actionTriggerSink) ); container.registerInstance( V2RecordsBatchCreatedActionTriggerProjection, - new V2RecordsBatchCreatedActionTriggerProjection(shareDbService) + new V2RecordsBatchCreatedActionTriggerProjection(actionTriggerSink) ); container.registerInstance( V2RecordUpdatedActionTriggerProjection, - new V2RecordUpdatedActionTriggerProjection(shareDbService) + new V2RecordUpdatedActionTriggerProjection(actionTriggerSink) ); container.registerInstance( V2RecordsBatchUpdatedActionTriggerProjection, - new V2RecordsBatchUpdatedActionTriggerProjection(shareDbService) + new V2RecordsBatchUpdatedActionTriggerProjection(actionTriggerSink) ); container.registerInstance( V2RecordReorderedActionTriggerProjection, - new V2RecordReorderedActionTriggerProjection(shareDbService) + new V2RecordReorderedActionTriggerProjection(actionTriggerSink) ); container.registerInstance( V2RecordsDeletedActionTriggerProjection, - new V2RecordsDeletedActionTriggerProjection(shareDbService) + new V2RecordsDeletedActionTriggerProjection(actionTriggerSink) ); container.registerInstance( V2FieldCreatedActionTriggerProjection, - new V2FieldCreatedActionTriggerProjection(shareDbService) + new V2FieldCreatedActionTriggerProjection(actionTriggerSink) ); container.registerInstance( V2FieldDeletedActionTriggerProjection, - new V2FieldDeletedActionTriggerProjection(shareDbService) + new V2FieldDeletedActionTriggerProjection(actionTriggerSink) ); container.registerInstance( V2FieldUpdatedActionTriggerProjection, - new V2FieldUpdatedActionTriggerProjection(shareDbService) + new V2FieldUpdatedActionTriggerProjection(actionTriggerSink) + ); + + container.registerInstance( + V2ViewFilterUpdatedActionTriggerProjection, + new V2ViewFilterUpdatedActionTriggerProjection(actionTriggerSink) + ); + + container.registerInstance( + V2ViewGroupUpdatedActionTriggerProjection, + new V2ViewGroupUpdatedActionTriggerProjection(actionTriggerSink) + ); + + container.registerInstance( + V2ViewColumnMetaUpdatedActionTriggerProjection, + new V2ViewColumnMetaUpdatedActionTriggerProjection(actionTriggerSink) ); container.registerInstance( V2TableActionTriggerRequestedProjection, - new V2TableActionTriggerRequestedProjection(shareDbService) + new V2TableActionTriggerRequestedProjection(actionTriggerSink) ); } } diff --git a/apps/nestjs-backend/src/features/v2/v2-collaborator-notification.service.spec.ts b/apps/nestjs-backend/src/features/v2/v2-collaborator-notification.service.spec.ts index 347943292a..5f04e0352c 100644 --- a/apps/nestjs-backend/src/features/v2/v2-collaborator-notification.service.spec.ts +++ b/apps/nestjs-backend/src/features/v2/v2-collaborator-notification.service.spec.ts @@ -15,6 +15,7 @@ vi.mock('./v2-container.service', () => ({ import { V2CollaboratorNotificationDispatcher, + V2RecordsBatchUpdatedCollaboratorNotificationProjection, V2RecordCreatedCollaboratorNotificationProjection, V2RecordUpdatedCollaboratorNotificationProjection, } from './v2-collaborator-notification.service'; @@ -202,6 +203,108 @@ describe('V2CollaboratorNotificationDispatcher', () => { }); }); +describe('V2RecordsBatchUpdatedCollaboratorNotificationProjection', () => { + it('does not schedule or query for an all-clear batch', async () => { + const { db, dispatcher, notificationService } = createDispatcher(); + const projection = new V2RecordsBatchUpdatedCollaboratorNotificationProjection(dispatcher); + const { context, scheduled } = createScheduledContext(); + + const result = await projection.handle( + context as never, + { + source: 'user', + tableId: { toString: () => 'tblNotify00000001' }, + updates: [ + { + recordId: 'recNotify00000001', + changes: [ + { + fieldId: 'fldAssignee0000001', + oldValue: { id: 'usrTarget00000001', title: 'Target' }, + newValue: null, + }, + ], + }, + ], + } as never + ); + + expect(result._unsafeUnwrap()).toBeUndefined(); + expect(scheduled).toHaveLength(0); + expect(db.selectFrom).not.toHaveBeenCalled(); + expect(notificationService.sendCollaboratorNotify).not.toHaveBeenCalled(); + }); + + it.each([ + ['single object', { id: 'usrTarget00000001', title: 'Target' }], + ['array', [{ id: 'usrTarget00000001', title: 'Target' }]], + ])('schedules once for a valid %s user candidate', async (_label, newValue) => { + const { db, dispatcher, notificationService } = createDispatcher(); + const projection = new V2RecordsBatchUpdatedCollaboratorNotificationProjection(dispatcher); + const { context, scheduled } = createScheduledContext(); + + const result = await projection.handle( + context as never, + { + source: 'user', + tableId: { toString: () => 'tblNotify00000001' }, + updates: [ + { + recordId: 'recNotify00000001', + changes: [ + { + fieldId: 'fldAssignee0000001', + oldValue: null, + newValue, + }, + ], + }, + ], + } as never + ); + + expect(result._unsafeUnwrap()).toBeUndefined(); + expect(scheduled).toHaveLength(1); + expect(db.selectFrom).not.toHaveBeenCalled(); + + await flushScheduled(scheduled); + + expect(db.selectFrom).toHaveBeenCalledTimes(1); + expect(notificationService.sendCollaboratorNotify).toHaveBeenCalledTimes(1); + }); + + it('does not schedule computed batches even when they contain a user candidate', async () => { + const { db, dispatcher, notificationService } = createDispatcher(); + const projection = new V2RecordsBatchUpdatedCollaboratorNotificationProjection(dispatcher); + const { context, scheduled } = createScheduledContext(); + + const result = await projection.handle( + context as never, + { + source: 'computed', + tableId: { toString: () => 'tblNotify00000001' }, + updates: [ + { + recordId: 'recNotify00000001', + changes: [ + { + fieldId: 'fldAssignee0000001', + oldValue: null, + newValue: { id: 'usrTarget00000001', title: 'Target' }, + }, + ], + }, + ], + } as never + ); + + expect(result._unsafeUnwrap()).toBeUndefined(); + expect(scheduled).toHaveLength(0); + expect(db.selectFrom).not.toHaveBeenCalled(); + expect(notificationService.sendCollaboratorNotify).not.toHaveBeenCalled(); + }); +}); + describe('v2 collaborator notification field filtering', () => { it('keeps v1-compatible shouldNotify semantics', () => { expect(FieldType.User).toBe('user'); diff --git a/apps/nestjs-backend/src/features/v2/v2-collaborator-notification.service.ts b/apps/nestjs-backend/src/features/v2/v2-collaborator-notification.service.ts index 521c3b6352..930587a3dd 100644 --- a/apps/nestjs-backend/src/features/v2/v2-collaborator-notification.service.ts +++ b/apps/nestjs-backend/src/features/v2/v2-collaborator-notification.service.ts @@ -123,6 +123,11 @@ const getUserId = (value: unknown): string | null => { return typeof userId === 'string' && userId ? userId : null; }; +const hasUserCandidate = (value: unknown): boolean => { + const candidates = Array.isArray(value) ? value : [value]; + return candidates.some((candidate) => getUserId(candidate) !== null); +}; + @Injectable() export class V2CollaboratorNotificationDispatcher { private readonly logger = new Logger(V2CollaboratorNotificationDispatcher.name); @@ -354,6 +359,13 @@ export class V2RecordsBatchUpdatedCollaboratorNotificationProjection return ok(undefined); } + const hasCandidate = event.updates.some((update) => + update.changes.some((change) => hasUserCandidate(change.newValue)) + ); + if (!hasCandidate) { + return ok(undefined); + } + scheduleCollaboratorNotificationRun( context, () => diff --git a/apps/nestjs-backend/src/features/v2/v2-container.service.spec.ts b/apps/nestjs-backend/src/features/v2/v2-container.service.spec.ts index b5b056a446..01680e5687 100644 --- a/apps/nestjs-backend/src/features/v2/v2-container.service.spec.ts +++ b/apps/nestjs-backend/src/features/v2/v2-container.service.spec.ts @@ -6,9 +6,9 @@ import type { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper'; import { Test, type TestingModule } from '@nestjs/testing'; import { PgPoolRegistry } from '@teable/db-main-prisma'; import { v2DataDbTokens, v2MetaDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import type { IV2NodePgContainerOptions } from '@teable/v2-container-node'; import { v2CoreTokens } from '@teable/v2-core'; import type { DependencyContainer } from '@teable/v2-di'; -import type { IV2NodePgContainerOptions } from '@teable/v2-container-node'; import { PinoLogger } from 'nestjs-pino'; import type { Pool } from 'pg'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -447,7 +447,14 @@ describe('V2ContainerService', () => { taskWorkerConfig: expect.objectContaining({ enabled: true, allowManualIndexExecution: false, - allowedKinds: ['rebuild_search_vector', 'manual_investigation'], + // rebuild_search_access_path is what schema-change maintenance + // enqueues; the worker must claim it or search silently degrades + // to ILIKE after any field change. + allowedKinds: [ + 'rebuild_search_access_path', + 'rebuild_search_vector', + 'manual_investigation', + ], }), }), }) diff --git a/apps/nestjs-backend/src/features/v2/v2-container.service.ts b/apps/nestjs-backend/src/features/v2/v2-container.service.ts index 874b2d2f4c..baaedc2bbf 100644 --- a/apps/nestjs-backend/src/features/v2/v2-container.service.ts +++ b/apps/nestjs-backend/src/features/v2/v2-container.service.ts @@ -78,6 +78,23 @@ const resolveBoolean = (value: unknown, defaultValue = false): boolean => { return defaultValue; }; +const buildComputedUpdateOptions = ( + computedUpdateMode: string | undefined, + wakeupPublisher: NonNullable['wakeupPublisher'] +): IV2NodePgContainerOptions['computedUpdate'] => { + const shared = { + wakeupPublisher, + }; + if (computedUpdateMode === 'sync') { + return { + mode: 'sync', + fieldBackfillConfig: { mode: 'sync' }, + ...shared, + }; + } + return shared; +}; + const executablePhase1RemediationKinds = [ 'create_search_index', 'create_search_vector', @@ -256,16 +273,10 @@ export class V2ContainerService implements OnApplicationBootstrap, OnModuleDestr const legacyMaxFreeRowLimit = resolvePositiveInteger( this.configService.get('MAX_FREE_ROW_LIMIT') ); - const computedUpdate: IV2NodePgContainerOptions['computedUpdate'] = - computedUpdateMode === 'sync' - ? { - mode: 'sync', - fieldBackfillConfig: { mode: 'sync' }, - wakeupPublisher: this.computedOutboxWakeupPublisher, - } - : { - wakeupPublisher: this.computedOutboxWakeupPublisher, - }; + const computedUpdate = buildComputedUpdateOptions( + computedUpdateMode, + this.computedOutboxWakeupPublisher + ); this.logger.log('Initializing V2 container'); @@ -282,6 +293,9 @@ export class V2ContainerService implements OnApplicationBootstrap, OnModuleDestr queryBusMiddlewares, computedUpdate, tableQueryOps, + // This app writes record_trash rows for v2 deletes + // (V2RecordTrashService), so the delete-undo purge guard is sound here. + undoRedoRestorePurgeGuard: true, ...(tableMaxRowLimit ? { tableMaxRowLimit } : legacyMaxFreeRowLimit @@ -363,7 +377,11 @@ export class V2ContainerService implements OnApplicationBootstrap, OnModuleDestr (allowManualIndexExecution ? executablePhase1RemediationKinds : searchVectorRuntimeEnabled - ? ([ + ? // Schema-change maintenance enqueues rebuild_search_access_path; + // without it here those tasks stay queued forever and search + // silently degrades to ILIKE after any field change. + ([ + 'rebuild_search_access_path', 'rebuild_search_vector', 'manual_investigation', ] satisfies ReadonlyArray) diff --git a/apps/nestjs-backend/src/features/v2/v2-http-error.spec.ts b/apps/nestjs-backend/src/features/v2/v2-http-error.spec.ts new file mode 100644 index 0000000000..b526189976 --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-http-error.spec.ts @@ -0,0 +1,84 @@ +import { HttpStatus } from '@nestjs/common'; +import { HttpErrorCode } from '@teable/core'; +import { mapDomainErrorToHttpError } from '@teable/v2-contract-http'; +import { domainError } from '@teable/v2-core'; +import { describe, expect, it } from 'vitest'; +import { CustomHttpException } from '../../custom.exception'; +import { throwV2Error } from './v2-http-error'; + +describe('throwV2Error', () => { + it('passes the throw-site localization through to the HTTP exception', () => { + let caught: CustomHttpException | undefined; + try { + throwV2Error( + { + code: 'validation.field.not_null', + message: 'Cannot set null: field "Number" violates not-null constraint', + tags: ['validation'], + details: { fieldId: 'fldabc', fieldName: 'Number' }, + localization: { + i18nKey: 'httpErrors.custom.recordFieldValueNotNull', + context: { fieldName: 'Number' }, + }, + }, + HttpStatus.BAD_REQUEST + ); + } catch (error) { + caught = error as CustomHttpException; + } + + expect(caught).toBeInstanceOf(CustomHttpException); + expect(caught?.code).toBe(HttpErrorCode.VALIDATION_ERROR); + expect(caught?.data).toEqual({ + domainCode: 'validation.field.not_null', + domainTags: ['validation'], + details: { fieldId: 'fldabc', fieldName: 'Number' }, + localization: { + i18nKey: 'httpErrors.custom.recordFieldValueNotNull', + context: { fieldName: 'Number' }, + }, + }); + }); + + it('leaves localization undefined for errors that carry none', () => { + let caught: CustomHttpException | undefined; + try { + throwV2Error( + { code: 'validation.field.invalid_value', message: 'Invalid value' }, + HttpStatus.BAD_REQUEST + ); + } catch (error) { + caught = error as CustomHttpException; + } + + expect(caught?.data?.localization).toBeUndefined(); + }); + + it('reattaches DomainError creation stack onto the thrown HTTP exception', () => { + const domain = domainError.infrastructure({ + message: 'Failed to load compute activity', + details: { tableId: 'tbl1', error: 'connection refused' }, + }); + const mapped = mapDomainErrorToHttpError(domain); + + // HTTP body stays clean — stack is non-enumerable. + expect(JSON.parse(JSON.stringify(mapped))).toEqual({ + code: 'infrastructure', + message: 'Failed to load compute activity', + tags: ['infrastructure'], + details: { tableId: 'tbl1', error: 'connection refused' }, + }); + expect(mapped.stack).toBe(domain.stack); + + let caught: CustomHttpException | undefined; + try { + throwV2Error(mapped, HttpStatus.INTERNAL_SERVER_ERROR); + } catch (error) { + caught = error as CustomHttpException; + } + + expect(caught).toBeInstanceOf(CustomHttpException); + expect(caught?.stack).toBe(domain.stack); + expect(caught?.stack).toEqual(expect.stringContaining('v2-http-error.spec.ts')); + }); +}); diff --git a/apps/nestjs-backend/src/features/v2/v2-http-error.ts b/apps/nestjs-backend/src/features/v2/v2-http-error.ts new file mode 100644 index 0000000000..b18be36afd --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-http-error.ts @@ -0,0 +1,46 @@ +import type { IDomainErrorLocalization } from '@teable/v2-core'; +import { CustomHttpException, getDefaultCodeByStatus } from '../../custom.exception'; + +export interface IV2DomainErrorLike { + code: string; + message: string; + tags?: ReadonlyArray; + details?: Readonly>; + localization?: IDomainErrorLocalization; + /** Non-enumerable creation-site stack from DomainError; optional on plain DTOs. */ + stack?: string; + cause?: unknown; +} + +/** + * The single bridge from a v2 domain error to an HTTP error. `localization` is + * attached where the error is created and passed through untouched here; + * `message` stays English and is only the fallback for errors that carry none. + * + * Declared as a function statement so TypeScript's control-flow analysis + * treats calls as unreachable-after (`never` on a const arrow is not enough). + * + * When the source DomainError carries a creation-site stack, reattach it on the + * thrown HttpException so Sentry/global filters group by the real failure site + * instead of this adapter frame. + */ +export function throwV2Error(error: IV2DomainErrorLike, status: number): never { + const exception = new CustomHttpException(error.message, getDefaultCodeByStatus(status), { + domainCode: error.code, + domainTags: error.tags, + details: error.details, + localization: error.localization, + }); + if (error.stack) { + exception.stack = error.stack; + } + if (error.cause !== undefined) { + Object.defineProperty(exception, 'cause', { + value: error.cause, + enumerable: false, + configurable: true, + writable: true, + }); + } + throw exception; +} 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 index 9bfa669444..f8e42c57e0 100644 --- 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 @@ -184,6 +184,7 @@ describe('V2RecordsBatchCreatedHistoryProjection', () => { context as never, { tableId: { toString: () => 'tblHistTable0000001' }, + source: { type: 'user' }, records: [ { recordId: 'recHistRecord000001', @@ -234,6 +235,51 @@ describe('V2RecordsBatchCreatedHistoryProjection', () => { recordIds: ['recHistRecord000001', 'recHistRecord000002'], }); }); + + it.each([{ type: 'import' }, { type: 'tableDuplicate' }])( + 'skips record history for $type-sourced batch creation', + async (source) => { + const { db, service: v2ContainerService } = createV2ContainerService(); + const tableQueryService = { + getById: vi + .fn() + .mockResolvedValue( + okResult(createTable([createTextField('fldHistField0000001', 'Name')])) + ), + }; + const eventEmitterService = { + emit: vi.fn(), + }; + const projection = new V2RecordsBatchCreatedHistoryProjection( + v2ContainerService as never, + { recordHistoryDisabled: false } as never, + tableQueryService as never, + eventEmitterService as never + ); + const { context, scheduled } = createScheduledContext('usrBatchCreator00001'); + + const result = await projection.handle( + context as never, + { + tableId: { toString: () => 'tblHistTable0000001' }, + source, + records: [ + { + recordId: 'recHistRecord000001', + fields: [{ fieldId: 'fldHistField0000001', value: 'created-1' }], + }, + ], + } as never + ); + + expect(result._unsafeUnwrap()).toBeUndefined(); + + await flushScheduled(scheduled); + + expect(db.insertInto).not.toHaveBeenCalled(); + expect(eventEmitterService.emit).not.toHaveBeenCalled(); + } + ); }); describe('V2RecordsBatchUpdatedHistoryProjection', () => { 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 6646ee6bb9..1999acf915 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 @@ -362,7 +362,12 @@ export class V2RecordUpdatedHistoryProjection implements IEventHandler { @@ -381,6 +386,10 @@ export class V2RecordsBatchCreatedHistoryProjection implements IEventHandler(); for (const record of event.records) { for (const field of record.fields) { diff --git a/apps/nestjs-backend/src/features/v2/v2-schema-operation-runner.service.spec.ts b/apps/nestjs-backend/src/features/v2/v2-schema-operation-runner.service.spec.ts index 02756c9da6..e1ac9e9658 100644 --- a/apps/nestjs-backend/src/features/v2/v2-schema-operation-runner.service.spec.ts +++ b/apps/nestjs-backend/src/features/v2/v2-schema-operation-runner.service.spec.ts @@ -16,6 +16,7 @@ import { V2SchemaOperationRunnerService } from './v2-schema-operation-runner.ser const sentryScope = { setContext: vi.fn(), + setFingerprint: vi.fn(), setLevel: vi.fn(), setTag: vi.fn(), }; @@ -100,6 +101,7 @@ describe('V2SchemaOperationRunnerService', () => { vi.mocked(Sentry.captureException).mockClear(); vi.mocked(Sentry.withScope).mockClear(); sentryScope.setContext.mockClear(); + sentryScope.setFingerprint.mockClear(); sentryScope.setLevel.mockClear(); sentryScope.setTag.mockClear(); }); @@ -173,13 +175,27 @@ describe('V2SchemaOperationRunnerService', () => { await vi.advanceTimersByTimeAsync(0); expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(Sentry.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'V2SchemaOperationFailure', + message: + 'Only missing-column table updates can be repaired automatically | original: Unexpected unit of work error: error: too many range table entries', + }) + ); expect(sentryScope.setTag).toHaveBeenCalledWith('feature', 'v2-schema-operation-runner'); expect(sentryScope.setTag).toHaveBeenCalledWith('table.id', 'tblSchemaOpRunner'); expect(sentryScope.setTag).toHaveBeenCalledWith('schema_operation.id', 'sgoTerminal'); + expect(sentryScope.setTag).toHaveBeenCalledWith('schema_operation.created_by', 'system'); + expect(sentryScope.setFingerprint).toHaveBeenCalledWith([ + 'v2-schema-operation-runner', + 'table.create', + 'Unexpected unit of work error: error: too many range table entries', + ]); expect(sentryScope.setContext).toHaveBeenCalledWith( 'schema_operation', expect.objectContaining({ id: 'sgoTerminal', + createdBy: 'system', originalLastError: 'Unexpected unit of work error: error: too many range table entries', runnerError: 'Only missing-column table updates can be repaired automatically', }) diff --git a/apps/nestjs-backend/src/features/v2/v2-schema-operation-runner.service.ts b/apps/nestjs-backend/src/features/v2/v2-schema-operation-runner.service.ts index 0edc410956..706d03e7c4 100644 --- a/apps/nestjs-backend/src/features/v2/v2-schema-operation-runner.service.ts +++ b/apps/nestjs-backend/src/features/v2/v2-schema-operation-runner.service.ts @@ -200,6 +200,16 @@ export class V2SchemaOperationRunnerService implements OnApplicationBootstrap, O const operation = result.operation; const target = operation.target; + const originalLastError = result.originalLastError ?? null; + const runnerError = result.error.message; + // Prefer the original failure for Sentry titles/grouping. Repair handlers often + // overwrite last_error with a generic "cannot repair" reason that hides the + // real root cause (e.g. double precision = text during computed backfill). + const diagnosticMessage = + originalLastError && originalLastError !== runnerError + ? `${runnerError} | original: ${originalLastError}` + : runnerError; + Sentry.withScope((scope) => { scope.setLevel('error'); scope.setTag('feature', 'v2-schema-operation-runner'); @@ -210,12 +220,18 @@ export class V2SchemaOperationRunnerService implements OnApplicationBootstrap, O scope.setTag('schema_operation.phase', operation.phase); scope.setTag('schema_operation.terminal', String(result.terminal)); scope.setTag('schema_operation.retryable', String(result.retryable)); + scope.setTag('schema_operation.created_by', operation.createdBy); if (target.baseId) { scope.setTag('base.id', target.baseId); } if (target.tableId) { scope.setTag('table.id', target.tableId); } + scope.setFingerprint([ + 'v2-schema-operation-runner', + operation.type, + originalLastError ?? runnerError, + ]); scope.setContext('schema_operation', { id: operation.id, type: operation.type, @@ -225,12 +241,13 @@ export class V2SchemaOperationRunnerService implements OnApplicationBootstrap, O maxAttempts: operation.maxAttempts, idempotencyKey: operation.idempotencyKey, target, + createdBy: operation.createdBy, lastError: operation.lastError, - originalLastError: result.originalLastError ?? null, - runnerError: result.error.message, + originalLastError, + runnerError, }); - const error = new Error(result.error.message); + const error = new Error(diagnosticMessage); error.name = 'V2SchemaOperationFailure'; Sentry.captureException(error); }); diff --git a/apps/nestjs-backend/src/features/v2/v2-tracer.adapter.spec.ts b/apps/nestjs-backend/src/features/v2/v2-tracer.adapter.spec.ts index 9d463a83c5..e0cb3c4445 100644 --- a/apps/nestjs-backend/src/features/v2/v2-tracer.adapter.spec.ts +++ b/apps/nestjs-backend/src/features/v2/v2-tracer.adapter.spec.ts @@ -46,4 +46,21 @@ describe('OpenTelemetryTracer', () => { otelContext.active() ); }); + + it('captures and restores W3C carriers around async handoff', async () => { + const startSpan = vi.fn(() => ({ end: vi.fn() })); + vi.mocked(trace.getTracer).mockReturnValue({ startSpan } as never); + vi.mocked(trace.getActiveSpan).mockReturnValue({ spanContext: () => ({}) } as never); + + const tracer = new OpenTelemetryTracer(); + // Without a real OTEL SDK propagator this may be undefined; ensure no throw. + const carrier = tracer.capturePropagationCarrier(); + await expect(tracer.runWithPropagationCarrier(carrier, async () => 'ok')).resolves.toBe('ok'); + await expect( + tracer.runWithPropagationCarrier( + { traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' }, + async () => 'ok' + ) + ).resolves.toBe('ok'); + }); }); diff --git a/apps/nestjs-backend/src/features/v2/v2-tracer.adapter.ts b/apps/nestjs-backend/src/features/v2/v2-tracer.adapter.ts index dc67e652ed..48100d64a5 100644 --- a/apps/nestjs-backend/src/features/v2/v2-tracer.adapter.ts +++ b/apps/nestjs-backend/src/features/v2/v2-tracer.adapter.ts @@ -1,6 +1,12 @@ -import type { Span as ApiSpan } from '@opentelemetry/api'; -import { SpanStatusCode, context as otelContext, trace } from '@opentelemetry/api'; -import type { ISpan, ITracer, SpanAttributeValue, SpanAttributes } from '@teable/v2-core'; +import type { Span as ApiSpan, TextMapGetter, TextMapSetter } from '@opentelemetry/api'; +import { SpanStatusCode, context as otelContext, propagation, trace } from '@opentelemetry/api'; +import type { + ISpan, + ITracer, + SpanAttributeValue, + SpanAttributes, + TracePropagationCarrier, +} from '@teable/v2-core'; export const V2_CODE_OWNERSHIP_ATTRIBUTE = 'teable.code.ownership'; export const V2_CODE_PATH_ATTRIBUTE = 'teable.code.path'; @@ -33,6 +39,24 @@ class OpenTelemetrySpan implements ISpan { } } +const carrierSetter: TextMapSetter> = { + set(carrier, key, value) { + carrier[key] = value; + }, +}; + +const carrierGetter: TextMapGetter = { + keys(carrier) { + return Object.keys(carrier).filter((key) => carrier[key as keyof TracePropagationCarrier]); + }, + get(carrier, key) { + const normalized = key.toLowerCase(); + if (normalized === 'traceparent') return carrier.traceparent; + if (normalized === 'tracestate') return carrier.tracestate; + return undefined; + }, +}; + export class OpenTelemetryTracer implements ITracer { constructor(private readonly name = 'v2-core') {} @@ -58,4 +82,24 @@ export class OpenTelemetryTracer implements ITracer { if (!span) return undefined; return new OpenTelemetrySpan(span); } + + capturePropagationCarrier(): TracePropagationCarrier | undefined { + if (!trace.getActiveSpan()) return undefined; + const carrier: Record = {}; + propagation.inject(otelContext.active(), carrier, carrierSetter); + if (!carrier.traceparent) return undefined; + return { + traceparent: carrier.traceparent, + ...(carrier.tracestate ? { tracestate: carrier.tracestate } : {}), + }; + } + + async runWithPropagationCarrier( + carrier: TracePropagationCarrier | undefined, + callback: () => Promise + ): Promise { + if (!carrier?.traceparent) return callback(); + const extracted = propagation.extract(otelContext.active(), carrier, carrierGetter); + return otelContext.with(extracted, callback); + } } 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 index b1707cb66a..b9366259b0 100644 --- a/apps/nestjs-backend/src/features/v2/v2-view-compat.service.ts +++ b/apps/nestjs-backend/src/features/v2/v2-view-compat.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { HttpErrorCode, IdPrefix, @@ -12,7 +12,6 @@ import { v2MetaDbTokens } from '@teable/v2-adapter-db-postgres-pg'; import { v2CoreTokens, ViewOperationKind, - type DomainError, type IExecutionContext, type ViewOperationPayloadViewConfig, type ViewOperationPluginContext, @@ -29,6 +28,7 @@ import type { IClsStore } from '../../types/cls'; import { BatchService } from '../calculation/batch.service'; import { V2ContainerService } from './v2-container.service'; import { V2ExecutionContextFactory } from './v2-execution-context.factory'; +import { throwV2Error } from './v2-http-error'; /* eslint-disable @typescript-eslint/naming-convention */ type IV2ViewCompatDb = V1TeableDatabase & { @@ -59,14 +59,6 @@ export class V2ViewCompatService { private readonly v2ContextFactory: V2ExecutionContextFactory ) {} - private throwDomainError(error: DomainError): never { - throw new CustomHttpException(error.message, HttpErrorCode.VALIDATION_ERROR, { - domainCode: error.code, - domainTags: error.tags, - details: error.details, - }); - } - private mergeSetViewPropertyByOpContexts(opContexts: ISetViewPropertyOpContext[]) { const result: Record = {}; for (const opContext of opContexts) { @@ -126,12 +118,12 @@ export class V2ViewCompatService { ): Promise { const preparedResult = await runner.prepare(context); if (preparedResult.isErr()) { - this.throwDomainError(preparedResult.error); + throwV2Error(preparedResult.error, HttpStatus.BAD_REQUEST); } const guardResult = await preparedResult.value.guard(executionContext); if (guardResult.isErr()) { - this.throwDomainError(guardResult.error); + throwV2Error(guardResult.error, HttpStatus.BAD_REQUEST); } } diff --git a/apps/nestjs-backend/src/features/v2/v2-view-delete-side-effect.service.spec.ts b/apps/nestjs-backend/src/features/v2/v2-view-delete-side-effect.service.spec.ts new file mode 100644 index 0000000000..e4bb2822dd --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-view-delete-side-effect.service.spec.ts @@ -0,0 +1,107 @@ +import { LastVisitResourceType, PinType } from '@teable/openapi'; +import { v2MetaDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import { ActorId, BaseId, TableId, ViewDeleted, ViewId } from '@teable/v2-core'; +import { vi } from 'vitest'; + +import { + V2ViewDeletedResourceCleanupProjection, + V2ViewDeleteSideEffectService, +} from './v2-view-delete-side-effect.service'; + +const createDeleteDb = () => { + const deletes: Array<{ + table: string; + where: Array<[string, string, string]>; + execute: ReturnType; + }> = []; + const db = { + deleteFrom: vi.fn((table: string) => { + const query = { + table, + where: [] as Array<[string, string, string]>, + execute: vi.fn().mockResolvedValue(undefined), + }; + deletes.push(query); + return { + where: vi.fn((column: string, operator: string, value: string) => { + query.where.push([column, operator, value]); + return { + where: vi.fn((nextColumn: string, nextOperator: string, nextValue: string) => { + query.where.push([nextColumn, nextOperator, nextValue]); + return { execute: query.execute }; + }), + }; + }), + }; + }), + }; + return { db, deletes }; +}; + +const event = ViewDeleted.create({ + baseId: BaseId.create('bse0000000000000001')._unsafeUnwrap(), + tableId: TableId.create('tbl0000000000000001')._unsafeUnwrap(), + viewId: ViewId.create('viw0000000000000001')._unsafeUnwrap(), +}); + +describe('V2ViewDeleteSideEffectService', () => { + it('registers the cleanup projection with the v2 Kysely connection', () => { + const { db } = createDeleteDb(); + const container = { + resolve: vi.fn().mockReturnValue(db), + registerInstance: vi.fn(), + }; + + new V2ViewDeleteSideEffectService().registerProjections(container as never); + + expect(container.resolve).toHaveBeenCalledWith(v2MetaDbTokens.db); + expect(container.registerInstance).toHaveBeenCalledWith( + V2ViewDeletedResourceCleanupProjection, + expect.any(V2ViewDeletedResourceCleanupProjection) + ); + }); + + it('deletes View last-visit and pin rows without v1 services or EventEmitter', async () => { + const { db, deletes } = createDeleteDb(); + const projection = new V2ViewDeletedResourceCleanupProjection(db as never); + + const result = await projection.handle( + { actorId: ActorId.create('system')._unsafeUnwrap() }, + event + ); + + expect(result.isOk()).toBe(true); + expect(deletes).toEqual([ + expect.objectContaining({ + table: 'user_last_visit', + where: [ + ['resource_id', '=', 'viw0000000000000001'], + ['resource_type', '=', LastVisitResourceType.View], + ], + }), + expect.objectContaining({ + table: 'pin_resource', + where: [ + ['resource_id', '=', 'viw0000000000000001'], + ['type', '=', PinType.View], + ], + }), + ]); + }); + + it('returns a domain error when Kysely cleanup fails', async () => { + const { db } = createDeleteDb(); + db.deleteFrom.mockImplementationOnce((_table: string) => ({ + where: (_column: string, _operator: string, _value: string) => ({ + where: (_nextColumn: string, _nextOperator: string, _nextValue: string) => ({ + execute: vi.fn().mockRejectedValue(new Error('cleanup failed')), + }), + }), + })); + const projection = new V2ViewDeletedResourceCleanupProjection(db as never); + + const result = await projection.handle({} as never, event); + + expect(result._unsafeUnwrapErr().message).toContain('cleanup failed'); + }); +}); diff --git a/apps/nestjs-backend/src/features/v2/v2-view-delete-side-effect.service.ts b/apps/nestjs-backend/src/features/v2/v2-view-delete-side-effect.service.ts new file mode 100644 index 0000000000..293c5e6d15 --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-view-delete-side-effect.service.ts @@ -0,0 +1,77 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { LastVisitResourceType, PinType } from '@teable/openapi'; +import { v2MetaDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import { + domainError, + type DomainError, + type IEventHandler, + type IExecutionContext, + ProjectionHandler, + type Result, + ViewDeleted, +} from '@teable/v2-core'; +import type { DependencyContainer } from '@teable/v2-di'; +import { Kysely } from 'kysely'; +import { err, ok } from 'neverthrow'; + +import { V2ProjectionRegistrar, type IV2ProjectionRegistrar } from './v2-projection-registrar'; + +/* eslint-disable @typescript-eslint/naming-convention */ +type IV2ViewDeleteSideEffectDb = { + pin_resource: { + resource_id: string; + type: string; + }; + user_last_visit: { + resource_id: string; + resource_type: string; + }; +}; +/* eslint-enable @typescript-eslint/naming-convention */ + +@ProjectionHandler(ViewDeleted) +export class V2ViewDeletedResourceCleanupProjection implements IEventHandler { + constructor(private readonly db: Kysely) {} + + async handle( + _context: IExecutionContext, + event: ViewDeleted + ): Promise> { + try { + const viewId = event.viewId.toString(); + // View-share short-link rows are intentionally retained as advisory + // aliases. ShortLinkService revalidates enable_share and deleted_time on + // every uncached redirect, so a deleted View cannot authorize access. + await Promise.all([ + this.db + .deleteFrom('user_last_visit') + .where('resource_id', '=', viewId) + .where('resource_type', '=', LastVisitResourceType.View) + .execute(), + this.db + .deleteFrom('pin_resource') + .where('resource_id', '=', viewId) + .where('type', '=', PinType.View) + .execute(), + ]); + return ok(undefined); + } catch (error) { + return err(domainError.fromUnknown(error)); + } + } +} + +@V2ProjectionRegistrar() +@Injectable() +export class V2ViewDeleteSideEffectService implements IV2ProjectionRegistrar { + private readonly logger = new Logger(V2ViewDeleteSideEffectService.name); + + registerProjections(container: DependencyContainer): void { + this.logger.debug('Registering V2 View delete resource cleanup projection'); + const db = container.resolve>(v2MetaDbTokens.db); + container.registerInstance( + V2ViewDeletedResourceCleanupProjection, + new V2ViewDeletedResourceCleanupProjection(db) + ); + } +} diff --git a/apps/nestjs-backend/src/features/v2/v2-view-share-side-effect.service.spec.ts b/apps/nestjs-backend/src/features/v2/v2-view-share-side-effect.service.spec.ts new file mode 100644 index 0000000000..1a642c791e --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-view-share-side-effect.service.spec.ts @@ -0,0 +1,142 @@ +import { ShortLinkType } from '@teable/openapi'; +import { v2MetaDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import { + ActorId, + BaseId, + TableId, + ViewId, + ViewShareDisabled, + ViewShareIdRefreshed, +} from '@teable/v2-core'; +import { vi } from 'vitest'; + +import { generateShortLinkCacheKey } from '../../performance-cache/generate-keys'; +import { + V2ViewShareIdRefreshedShortLinkProjection, + V2ViewShareSideEffectService, +} from './v2-view-share-side-effect.service'; + +const createQuery = (result: T) => { + const where: Array<[string, string, unknown]> = []; + const query = { + where, + select: vi.fn(), + set: vi.fn(), + execute: vi.fn().mockResolvedValue(result), + }; + const chain = { + select: (column: string) => { + query.select(column); + return chain; + }, + set: (value: unknown) => { + query.set(value); + return chain; + }, + where: (column: string, operator: string, value: unknown) => { + where.push([column, operator, value]); + return chain; + }, + execute: query.execute, + }; + return { query, chain }; +}; + +const buildEvent = (...args: [] | [string | undefined]) => { + const previousShareId = args.length === 0 ? `shr${'a'.repeat(16)}` : args[0]; + return ViewShareIdRefreshed.create({ + baseId: BaseId.create('bse0000000000000001')._unsafeUnwrap(), + tableId: TableId.create('tbl0000000000000001')._unsafeUnwrap(), + viewId: ViewId.create('viw0000000000000001')._unsafeUnwrap(), + previousShareId, + nextShareId: `shr${'b'.repeat(16)}`, + }); +}; + +const buildDisabledEvent = () => + ViewShareDisabled.create({ + baseId: BaseId.create('bse0000000000000001')._unsafeUnwrap(), + tableId: TableId.create('tbl0000000000000001')._unsafeUnwrap(), + viewId: ViewId.create('viw0000000000000001')._unsafeUnwrap(), + previousShareId: `shr${'a'.repeat(16)}`, + shareMeta: { includeRecords: true }, + }); + +describe('V2ViewShareSideEffectService', () => { + it('registers the short-link projection with v2 Kysely', () => { + const db = {}; + const cache = { del: vi.fn() }; + const container = { + resolve: vi.fn().mockReturnValue(db), + registerInstance: vi.fn(), + }; + + new V2ViewShareSideEffectService(cache as never).registerProjections(container as never); + + expect(container.resolve).toHaveBeenCalledWith(v2MetaDbTokens.db); + expect(container.registerInstance).toHaveBeenCalledWith( + V2ViewShareIdRefreshedShortLinkProjection, + expect.any(V2ViewShareIdRefreshedShortLinkProjection) + ); + }); + + it('marks the old share short link deleted and invalidates its performance cache', async () => { + const select = createQuery([{ code: 'short-code' }]); + const update = createQuery(undefined); + const db = { + selectFrom: vi.fn(() => select.chain), + updateTable: vi.fn(() => update.chain), + }; + const cache = { del: vi.fn().mockResolvedValue(undefined) }; + const projection = new V2ViewShareIdRefreshedShortLinkProjection(db as never, cache as never); + + const result = await projection.handle( + { actorId: ActorId.create('system')._unsafeUnwrap() }, + buildEvent() + ); + + expect(result.isOk()).toBe(true); + expect(select.query.where).toEqual([ + ['type', '=', ShortLinkType.ViewShare], + ['resource_id', '=', `shr${'a'.repeat(16)}`], + ['deleted_time', 'is', null], + ]); + expect(update.query.set).toHaveBeenCalledWith({ deleted_time: expect.any(Date) }); + expect(update.query.where).toEqual(select.query.where); + expect(cache.del).toHaveBeenCalledWith(generateShortLinkCacheKey('short-code')); + }); + + it('invalidates the current share short link when sharing is disabled', async () => { + const select = createQuery([{ code: 'disabled-code' }]); + const update = createQuery(undefined); + const db = { + selectFrom: vi.fn(() => select.chain), + updateTable: vi.fn(() => update.chain), + }; + const cache = { del: vi.fn().mockResolvedValue(undefined) }; + const projection = new V2ViewShareIdRefreshedShortLinkProjection(db as never, cache as never); + + const result = await projection.handle( + { actorId: ActorId.create('system')._unsafeUnwrap() }, + buildDisabledEvent() + ); + + expect(result.isOk()).toBe(true); + expect(update.query.where).toContainEqual(['resource_id', '=', `shr${'a'.repeat(16)}`]); + expect(cache.del).toHaveBeenCalledWith(generateShortLinkCacheKey('disabled-code')); + }); + + it('skips storage when there was no previous share ID and keeps cleanup advisory', async () => { + const db = { + selectFrom: vi.fn(() => { + throw new Error('cleanup failed'); + }), + }; + const cache = { del: vi.fn() }; + const projection = new V2ViewShareIdRefreshedShortLinkProjection(db as never, cache as never); + + expect((await projection.handle({} as never, buildEvent(undefined))).isOk()).toBe(true); + expect(db.selectFrom).not.toHaveBeenCalled(); + expect((await projection.handle({} as never, buildEvent())).isOk()).toBe(true); + }); +}); diff --git a/apps/nestjs-backend/src/features/v2/v2-view-share-side-effect.service.ts b/apps/nestjs-backend/src/features/v2/v2-view-share-side-effect.service.ts new file mode 100644 index 0000000000..f5984522a2 --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-view-share-side-effect.service.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ShortLinkType } from '@teable/openapi'; +import { v2MetaDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import { + ok, + ProjectionHandler, + type DomainError, + type IEventHandler, + type IExecutionContext, + type Result, + ViewShareDisabled, + ViewShareIdRefreshed, +} from '@teable/v2-core'; +import type { DependencyContainer } from '@teable/v2-di'; +import { Kysely } from 'kysely'; + +import { PerformanceCacheService } from '../../performance-cache'; +import { generateShortLinkCacheKey } from '../../performance-cache/generate-keys'; +import { V2ProjectionRegistrar, type IV2ProjectionRegistrar } from './v2-projection-registrar'; + +/* eslint-disable @typescript-eslint/naming-convention */ +type IV2ViewShareSideEffectDb = { + short_link: { + code: string; + type: string; + resource_id: string; + deleted_time: Date | null; + }; +}; +/* eslint-enable @typescript-eslint/naming-convention */ + +@ProjectionHandler(ViewShareIdRefreshed) +@ProjectionHandler(ViewShareDisabled) +export class V2ViewShareIdRefreshedShortLinkProjection + implements IEventHandler +{ + private readonly logger = new Logger(V2ViewShareIdRefreshedShortLinkProjection.name); + + constructor( + private readonly db: Kysely, + private readonly performanceCacheService: PerformanceCacheService + ) {} + + async handle( + _context: IExecutionContext, + event: ViewShareIdRefreshed | ViewShareDisabled + ): Promise> { + const previousShareId = event.previousShareId; + if (previousShareId === undefined) return ok(undefined); + + try { + const links = await this.db + .selectFrom('short_link') + .select('code') + .where('type', '=', ShortLinkType.ViewShare) + .where('resource_id', '=', previousShareId) + .where('deleted_time', 'is', null) + .execute(); + if (links.length === 0) return ok(undefined); + + await this.db + .updateTable('short_link') + .set({ deleted_time: new Date() }) + .where('type', '=', ShortLinkType.ViewShare) + .where('resource_id', '=', previousShareId) + .where('deleted_time', 'is', null) + .execute(); + await Promise.all( + links.map(({ code }) => this.performanceCacheService.del(generateShortLinkCacheKey(code))) + ); + } catch (error) { + this.logger.warn( + `Failed to invalidate short links for revoked View share ${previousShareId}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + return ok(undefined); + } +} + +@V2ProjectionRegistrar() +@Injectable() +export class V2ViewShareSideEffectService implements IV2ProjectionRegistrar { + private readonly logger = new Logger(V2ViewShareSideEffectService.name); + + constructor(private readonly performanceCacheService: PerformanceCacheService) {} + + registerProjections(container: DependencyContainer): void { + this.logger.debug('Registering V2 View share side-effect projections'); + const db = container.resolve>(v2MetaDbTokens.db); + container.registerInstance( + V2ViewShareIdRefreshedShortLinkProjection, + new V2ViewShareIdRefreshedShortLinkProjection(db, this.performanceCacheService) + ); + } +} diff --git a/apps/nestjs-backend/src/features/v2/v2.module.ts b/apps/nestjs-backend/src/features/v2/v2.module.ts index 152236be01..db890e9ab5 100644 --- a/apps/nestjs-backend/src/features/v2/v2.module.ts +++ b/apps/nestjs-backend/src/features/v2/v2.module.ts @@ -28,6 +28,8 @@ import { V2RecordHistoryService } from './v2-record-history.service'; import { V2SchemaOperationRunnerService } from './v2-schema-operation-runner.service'; import { V2UserRenamePropagationService } from './v2-user-rename-propagation.service'; import { V2ViewCompatService } from './v2-view-compat.service'; +import { V2ViewDeleteSideEffectService } from './v2-view-delete-side-effect.service'; +import { V2ViewShareSideEffectService } from './v2-view-share-side-effect.service'; import { V2Controller } from './v2.controller'; const isRecord = (value: unknown): value is Record => @@ -128,6 +130,8 @@ const toErrorMessage = (body: unknown): string => { V2RecordHistoryService, V2SchemaOperationRunnerService, V2ViewCompatService, + V2ViewDeleteSideEffectService, + V2ViewShareSideEffectService, UndoRedoStackService, ComputedOutboxRedriveService, ComputedOutboxMonitorService, diff --git a/apps/nestjs-backend/src/features/view/open-api/view-open-api-v2.service.ts b/apps/nestjs-backend/src/features/view/open-api/view-open-api-v2.service.ts index 7a9cd2e244..e8234fe3c4 100644 --- a/apps/nestjs-backend/src/features/view/open-api/view-open-api-v2.service.ts +++ b/apps/nestjs-backend/src/features/view/open-api/view-open-api-v2.service.ts @@ -1,18 +1,94 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; -import type { IViewRo, IViewVo } from '@teable/core'; -import { generateShareId, ViewType } from '@teable/core'; -import { PrismaService } from '@teable/db-main-prisma'; -import type { IUpdateRecordOrdersRo } from '@teable/openapi'; +import { HttpException, HttpStatus, Injectable, Optional } from '@nestjs/common'; +import type { + IColumnMetaRo, + IFilterRo, + IManualSortRo, + IPluginViewOptions, + ISnapshotBase, + IViewGroupRo, + IViewOptions, + IViewRo, + IViewVo, +} from '@teable/core'; +import { viewVoSchema } from '@teable/core'; +import { + getViewFilterLinkRecordsVoSchema, + type IGetViewFilterLinkRecordsVo, + type IRefreshShareViewVo, + type IEnableShareViewVo, + type IGetViewInstallPluginVo, + type IUpdateRecordOrdersRo, + type IUpdateOrderRo, + type IViewInstallPluginRo, + type IViewInstallPluginVo, + type IViewPluginUpdateStorageRo, + type IViewPluginUpdateStorageVo, + type IViewShareMetaRo, + type IViewSortRo, +} from '@teable/openapi'; +import { mapDomainErrorToHttpError, mapDomainErrorToHttpStatus } from '@teable/v2-contract-http'; import { executeReorderRecordsEndpoint } from '@teable/v2-contract-http-implementation/handlers'; -import type { ICommandBus } from '@teable/v2-core'; -import { v2CoreTokens } from '@teable/v2-core'; -import { pick } from 'lodash'; +import type { + CreateViewResult, + DisableViewShareResult, + EnableViewShareResult, + ApplyViewManualSortResult, + DeleteViewResult, + DuplicateViewResult, + GetViewFilterLinkRecordsResult, + GetViewPluginInstallResult, + GetViewResult, + GetViewSnapshotsResult, + ICommandBus, + IExecutionContext, + IQueryBus, + ListViewsResult, + RefreshViewShareIdResult, + RenameViewResult, + UpdateViewDescriptionResult, + UpdateViewFilterResult, + UpdateViewGroupResult, + UpdateViewOptionsResult, + UpdateViewPluginStorageResult, + UpdateViewShareMetaResult, + UpdateViewLockedResult, + UpdateViewColumnMetaResult, + UpdateViewOrderResult, + UpdateViewSortResult, + ViewQueryResultView, +} from '@teable/v2-core'; +import { + CreateViewCommand, + DisableViewShareCommand, + EnableViewShareCommand, + ApplyViewManualSortCommand, + DeleteViewCommand, + DuplicateViewCommand, + GetViewFilterLinkRecordsQuery, + GetViewPluginInstallQuery, + GetViewQuery, + GetViewSnapshotsQuery, + ListViewsQuery, + RefreshViewShareIdCommand, + RenameViewCommand, + UpdateViewDescriptionCommand, + UpdateViewFilterCommand, + UpdateViewGroupCommand, + UpdateViewOptionsCommand, + UpdateViewPluginStorageCommand, + UpdateViewShareMetaCommand, + UpdateViewLockedCommand, + UpdateViewColumnMetaCommand, + UpdateViewOrderCommand, + UpdateViewSortCommand, + v2CoreTokens, +} from '@teable/v2-core'; -import { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exception'; +import { convertViewVoAttachmentUrl } from '../../../utils/convert-view-vo-attachment-url'; +import { SpaceDataDbMigrationGuardService } from '../../space/space-data-db-migration-guard.service'; import { V2ContainerService } from '../../v2/v2-container.service'; import { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory'; -import { ViewService } from '../view.service'; -import { ViewOpenApiService } from './view-open-api.service'; +import { throwV2Error } from '../../v2/v2-http-error'; const internalServerError = 'Internal server error'; @@ -21,25 +97,784 @@ export class ViewOpenApiV2Service { constructor( private readonly v2ContainerService: V2ContainerService, private readonly v2ContextFactory: V2ExecutionContextFactory, - private readonly prismaService: PrismaService, - private readonly viewService: ViewService, - private readonly viewOpenApiService: ViewOpenApiService + @Optional() + private readonly spaceDataDbMigrationGuard?: SpaceDataDbMigrationGuardService ) {} - private throwV2Error( - error: { - code: string; - message: string; - tags?: ReadonlyArray; - details?: Readonly>; - }, - status: number - ): never { - throw new CustomHttpException(error.message, getDefaultCodeByStatus(status), { - domainCode: error.code, - domainTags: error.tags, - details: error.details, + async createView(tableId: string, viewRo: IViewRo): Promise { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + + const commandResult = CreateViewCommand.create({ + tableId, + view: { + name: viewRo.name, + type: viewRo.type, + description: viewRo.description, + columnMeta: viewRo.columnMeta, + options: viewRo.options, + sourceFilter: viewRo.filter, + sort: viewRo.sort?.sortObjs, + manualSort: viewRo.sort?.manualSort, + group: viewRo.group ?? undefined, + isLocked: viewRo.isLocked, + order: viewRo.order, + enableShare: viewRo.enableShare, + shareId: viewRo.shareId, + shareMeta: viewRo.shareMeta, + }, }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + + return this.getView(tableId, result.value.viewId.toString()); + } + + async installPlugin(tableId: string, ro: IViewInstallPluginRo): Promise { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const commandResult = CreateViewCommand.create({ + tableId, + view: { + name: ro.name, + type: 'plugin', + options: { pluginId: ro.pluginId }, + }, + }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + + const viewResult = result.value.table.getView(result.value.viewId); + if (viewResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(viewResult.error), + mapDomainErrorToHttpStatus(viewResult.error) + ); + } + const view = viewResult.value; + const options = view.options() as IPluginViewOptions; + return { + pluginId: options.pluginId, + pluginInstallId: options.pluginInstallId, + name: view.name().toString(), + viewId: view.id().toString(), + }; + } + + async getPluginInstall(tableId: string, viewId: string): Promise { + const container = await this.v2ContainerService.getContainerForTable(tableId); + const queryBus = container.resolve(v2CoreTokens.queryBus); + const context = await this.v2ContextFactory.createContext(container); + const queryResult = GetViewPluginInstallQuery.create({ tableId, viewId }); + if (queryResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(queryResult.error), + mapDomainErrorToHttpStatus(queryResult.error) + ); + } + + const result = await queryBus.execute( + context, + queryResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + const installation = result.value.installation; + return { + pluginId: installation.pluginId, + pluginInstallId: installation.id, + baseId: installation.baseId, + name: installation.name, + ...(installation.url !== undefined ? { url: installation.url } : {}), + ...(installation.storage !== undefined ? { storage: { ...installation.storage } } : {}), + }; + } + + async updatePluginStorage( + tableId: string, + viewId: string, + pluginInstallId: string, + storage: IViewPluginUpdateStorageRo['storage'] + ): Promise { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const commandResult = UpdateViewPluginStorageCommand.create({ + tableId, + viewId, + pluginInstallId, + storage, + }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + + const result = await commandBus.execute< + UpdateViewPluginStorageCommand, + UpdateViewPluginStorageResult + >(context, commandResult.value); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + return { + tableId: result.value.tableId, + viewId: result.value.viewId, + pluginInstallId: result.value.pluginInstallId, + ...(result.value.storage !== undefined ? { storage: { ...result.value.storage } } : {}), + }; + } + + async manualSort(tableId: string, viewId: string, sortRo: IManualSortRo): Promise { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const commandResult = ApplyViewManualSortCommand.create({ + tableId, + viewId, + sort: sortRo.sortObjs, + }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + } + + async getView( + tableId: string, + viewId: string, + contextOverride?: IExecutionContext + ): Promise { + const container = await this.v2ContainerService.getContainerForTable(tableId); + const queryBus = container.resolve(v2CoreTokens.queryBus); + const context = contextOverride ?? (await this.v2ContextFactory.createContext(container)); + const queryResult = GetViewQuery.create({ tableId, viewId }); + if (queryResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(queryResult.error), + mapDomainErrorToHttpStatus(queryResult.error) + ); + } + + const result = await queryBus.execute(context, queryResult.value); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + + return this.toViewVo(result.value.view); + } + + async deleteView(tableId: string, viewId: string, _windowId?: string): Promise { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const commandResult = DeleteViewCommand.create({ tableId, viewId }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + } + + async updateName( + tableId: string, + viewId: string, + name: string, + _windowId?: string + ): Promise { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const commandResult = RenameViewCommand.create({ tableId, viewId, name }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + } + + async updateDescription( + tableId: string, + viewId: string, + description: string, + _windowId?: string + ): Promise { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const commandResult = UpdateViewDescriptionCommand.create({ + tableId, + viewId, + description, + }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + + const result = await commandBus.execute< + UpdateViewDescriptionCommand, + UpdateViewDescriptionResult + >(context, commandResult.value); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + } + + async updateLocked( + tableId: string, + viewId: string, + isLocked: boolean | undefined, + _windowId?: string + ): Promise { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const commandResult = UpdateViewLockedCommand.create({ + tableId, + viewId, + isLocked, + }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + } + + async updateOrder( + tableId: string, + viewId: string, + orderRo: IUpdateOrderRo, + _windowId?: string + ): Promise { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const commandResult = UpdateViewOrderCommand.create({ + tableId, + viewId, + anchorId: orderRo.anchorId, + position: orderRo.position, + }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + } + + async updateColumnMeta( + tableId: string, + viewId: string, + columnMetaRo: IColumnMetaRo, + _windowId?: string + ): Promise { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const commandResult = UpdateViewColumnMetaCommand.create({ + tableId, + viewId, + columnMeta: columnMetaRo, + }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + + const result = await commandBus.execute< + UpdateViewColumnMetaCommand, + UpdateViewColumnMetaResult + >(context, commandResult.value); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + } + + async updateFilter( + tableId: string, + viewId: string, + filterRo: IFilterRo, + _windowId?: string + ): Promise { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const commandResult = UpdateViewFilterCommand.create({ + tableId, + viewId, + filter: filterRo.filter, + }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + } + + async updateSort( + tableId: string, + viewId: string, + sortRo: IViewSortRo, + _windowId?: string + ): Promise { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const commandResult = UpdateViewSortCommand.create({ + tableId, + viewId, + sort: sortRo.sort, + }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + } + + async updateGroup( + tableId: string, + viewId: string, + groupRo: IViewGroupRo, + _windowId?: string + ): Promise { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const commandResult = UpdateViewGroupCommand.create({ + tableId, + viewId, + group: groupRo.group, + }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + } + + async updateOptions( + tableId: string, + viewId: string, + options: IViewOptions, + _windowId?: string + ): Promise { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const commandResult = UpdateViewOptionsCommand.create({ + tableId, + viewId, + options, + }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + } + + async updateShareMeta( + tableId: string, + viewId: string, + shareMeta: IViewShareMetaRo + ): Promise { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const commandResult = UpdateViewShareMetaCommand.create({ tableId, viewId, shareMeta }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + } + + async refreshShareId(tableId: string, viewId: string): Promise { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const commandResult = RefreshViewShareIdCommand.create({ tableId, viewId }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + return { shareId: result.value.shareId }; + } + + async enableShare(tableId: string, viewId: string): Promise { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const commandResult = EnableViewShareCommand.create({ tableId, viewId }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + return { shareId: result.value.shareId }; + } + + async disableShare(tableId: string, viewId: string): Promise { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const commandResult = DisableViewShareCommand.create({ tableId, viewId }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + } + + async getViews(tableId: string, viewIds?: ReadonlyArray): Promise { + const container = await this.v2ContainerService.getContainerForTable(tableId); + const queryBus = container.resolve(v2CoreTokens.queryBus); + const context = await this.v2ContextFactory.createContext(container); + const queryResult = ListViewsQuery.create({ tableId, viewIds }); + if (queryResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(queryResult.error), + mapDomainErrorToHttpStatus(queryResult.error) + ); + } + + const result = await queryBus.execute( + context, + queryResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + + return result.value.views.map((view) => this.toViewVo(view)); + } + + async getSnapshotBulk( + tableId: string, + ids: ReadonlyArray | string | undefined + ): Promise[]> { + const viewIds = Array.isArray(ids) ? [...ids] : typeof ids === 'string' ? [ids] : []; + const container = await this.v2ContainerService.getContainerForTable(tableId); + const queryBus = container.resolve(v2CoreTokens.queryBus); + const context = await this.v2ContextFactory.createContext(container); + const queryResult = GetViewSnapshotsQuery.create({ tableId, viewIds }); + if (queryResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(queryResult.error), + mapDomainErrorToHttpStatus(queryResult.error) + ); + } + + const result = await queryBus.execute( + context, + queryResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + + return result.value.snapshots.map((snapshot) => ({ + id: snapshot.id, + v: snapshot.version, + type: 'json0', + data: this.toViewVo(snapshot.view), + })); + } + + async getDocIds(tableId: string, viewIds?: ReadonlyArray): Promise<{ ids: string[] }> { + const container = await this.v2ContainerService.getContainerForTable(tableId); + const queryBus = container.resolve(v2CoreTokens.queryBus); + const context = await this.v2ContextFactory.createContext(container); + const queryResult = ListViewsQuery.create({ tableId, viewIds }); + if (queryResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(queryResult.error), + mapDomainErrorToHttpStatus(queryResult.error) + ); + } + + const result = await queryBus.execute( + context, + queryResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + return { ids: result.value.views.map((view) => view.id) }; + } + + async getViewFilterLinkRecords( + tableId: string, + viewId: string + ): Promise { + const container = await this.v2ContainerService.getContainerForTable(tableId); + const queryBus = container.resolve(v2CoreTokens.queryBus); + const context = await this.v2ContextFactory.createContext(container); + const queryResult = GetViewFilterLinkRecordsQuery.create({ tableId, viewId }); + if (queryResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(queryResult.error), + mapDomainErrorToHttpStatus(queryResult.error) + ); + } + + const result = await queryBus.execute< + GetViewFilterLinkRecordsQuery, + GetViewFilterLinkRecordsResult + >(context, queryResult.value); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + + const parsed = getViewFilterLinkRecordsVoSchema.safeParse(result.value.groups); + if (!parsed.success) { + throwV2Error( + { + code: 'view.filter_link_records.invalid_projection', + message: 'Invalid View filter link records projection', + details: { issues: parsed.error.issues }, + }, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + return parsed.data; } async updateRecordOrders( @@ -67,43 +902,51 @@ export class ViewOpenApiV2Service { } if (!result.body.ok) { - this.throwV2Error(result.body.error, result.status); + throwV2Error(result.body.error, result.status); } throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); } async duplicateView(tableId: string, viewId: string): Promise { - const view = await this.viewService.getViewById(tableId, viewId); + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const commandResult = DuplicateViewCommand.create({ tableId, viewId }); + if (commandResult.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } - if (view.type === ViewType.Plugin) { - return this.viewOpenApiService.duplicateView(tableId, viewId); + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); } - const { options: optionsRaw } = await this.prismaService.txClient().view.findFirstOrThrow({ - where: { id: viewId, tableId, deletedTime: null }, - select: { options: true }, - }); - const options = optionsRaw ? JSON.parse(optionsRaw) : undefined; - - return this.prismaService.$tx(async () => { - return this.viewService.createView(tableId, { - ...pick(view, [ - 'name', - 'type', - 'description', - 'filter', - 'group', - 'columnMeta', - 'sort', - 'enableShare', - 'shareMeta', - 'shareId', - 'isLocked', - ]), - options, - shareId: view.shareId ? generateShareId() : undefined, - } as IViewRo); - }); + return this.getView(tableId, result.value.viewId.toString()); + } + + private toViewVo(view: ViewQueryResultView): IViewVo { + const parsed = viewVoSchema.safeParse(view); + if (!parsed.success) { + throwV2Error( + { + code: 'view.invalid_projection', + message: 'Invalid View projection', + details: { issues: parsed.error.issues }, + }, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + return convertViewVoAttachmentUrl(parsed.data); } } diff --git a/apps/nestjs-backend/src/features/view/open-api/view-open-api.controller.ts b/apps/nestjs-backend/src/features/view/open-api/view-open-api.controller.ts index 1650acca75..a8b382a6fa 100644 --- a/apps/nestjs-backend/src/features/view/open-api/view-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/view/open-api/view-open-api.controller.ts @@ -13,43 +13,48 @@ import { UseGuards, UseInterceptors, } from '@nestjs/common'; -import type { IViewVo } from '@teable/core'; -import { - viewRoSchema, - manualSortRoSchema, +import type { + IViewVo, IManualSortRo, IViewRo, IColumnMetaRo, - columnMetaRoSchema, IFilterRo, IViewGroupRo, +} from '@teable/core'; +import { + viewRoSchema, + manualSortRoSchema, + columnMetaRoSchema, filterRoSchema, viewGroupRoSchema, } from '@teable/core'; +import type { + IViewNameRo, + IViewDescriptionRo, + IViewShareMetaRo, + IViewSortRo, + IViewOptionsRo, + IUpdateOrderRo, + IUpdateRecordOrdersRo, + IViewInstallPluginRo, + IViewPluginUpdateStorageRo, + IViewLockedRo, +} from '@teable/openapi'; import { viewNameRoSchema, - IViewNameRo, viewDescriptionRoSchema, - IViewDescriptionRo, viewShareMetaRoSchema, - IViewShareMetaRo, viewSortRoSchema, - IViewSortRo, viewOptionsRoSchema, - IViewOptionsRo, updateOrderRoSchema, - IUpdateOrderRo, updateRecordOrdersRoSchema, - IUpdateRecordOrdersRo, viewInstallPluginRoSchema, - IViewInstallPluginRo, viewPluginUpdateStorageRoSchema, - IViewPluginUpdateStorageRo, viewLockedRoSchema, - IViewLockedRo, } from '@teable/openapi'; import type { IEnableShareViewVo, + IRefreshShareViewVo, IGetViewFilterLinkRecordsVo, IGetViewInstallPluginVo, IViewInstallPluginVo, @@ -82,47 +87,77 @@ export class ViewOpenApiController { @Permissions('view|read') @Get(':viewId') + @UseV2Feature('getView') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async getView( @Param('tableId') tableId: string, @Param('viewId') viewId: string ): Promise { + if (this.cls.get('useV2')) { + return await this.viewOpenApiV2Service.getView(tableId, viewId); + } return await this.viewService.getViewById(tableId, viewId); } @Permissions('view|read') @Get() + @UseV2Feature('getViews') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async getViews(@Param('tableId') tableId: string): Promise { + if (this.cls.get('useV2')) { + return await this.viewOpenApiV2Service.getViews(tableId); + } return await this.viewService.getViews(tableId); } @Permissions('view|create') @Post() - @EmitControllerEvent(Events.OPERATION_VIEW_CREATE) + @UseV2Feature('createView') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) + @EmitControllerEvent(Events.OPERATION_VIEW_CREATE, { skipWhenV2: true }) async createView( @Param('tableId') tableId: string, @Body(new ZodValidationPipe(viewRoSchema)) viewRo: IViewRo ): Promise { + if (this.cls.get('useV2')) { + return await this.viewOpenApiV2Service.createView(tableId, viewRo); + } return await this.viewOpenApiService.createView(tableId, viewRo); } @Permissions('view|delete') @Delete('/:viewId') + @UseV2Feature('deleteView') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async deleteView( @Param('tableId') tableId: string, @Param('viewId') viewId: string, @Headers('x-window-id') windowId?: string ) { + if (this.cls.get('useV2')) { + return await this.viewOpenApiV2Service.deleteView(tableId, viewId, windowId); + } return await this.viewOpenApiService.deleteView(tableId, viewId, windowId); } @Permissions('view|update') @Put('/:viewId/name') + @UseV2Feature('updateViewName') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async updateName( @Param('tableId') tableId: string, @Param('viewId') viewId: string, @Body(new ZodValidationPipe(viewNameRoSchema)) viewNameRo: IViewNameRo, @Headers('x-window-id') windowId?: string ): Promise { + if (this.cls.get('useV2')) { + return await this.viewOpenApiV2Service.updateName(tableId, viewId, viewNameRo.name, windowId); + } return await this.viewOpenApiService.setViewProperty( tableId, viewId, @@ -134,12 +169,23 @@ export class ViewOpenApiController { @Permissions('view|update') @Put('/:viewId/description') + @UseV2Feature('updateViewDescription') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async updateDescription( @Param('tableId') tableId: string, @Param('viewId') viewId: string, @Body(new ZodValidationPipe(viewDescriptionRoSchema)) viewDescriptionRo: IViewDescriptionRo, @Headers('x-window-id') windowId?: string ): Promise { + if (this.cls.get('useV2')) { + return await this.viewOpenApiV2Service.updateDescription( + tableId, + viewId, + viewDescriptionRo.description, + windowId + ); + } return await this.viewOpenApiService.setViewProperty( tableId, viewId, @@ -151,12 +197,23 @@ export class ViewOpenApiController { @Permissions('view|update') @Put('/:viewId/locked') + @UseV2Feature('updateViewLocked') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async updateLocked( @Param('tableId') tableId: string, @Param('viewId') viewId: string, @Body(new ZodValidationPipe(viewLockedRoSchema)) viewLockedRo: IViewLockedRo, @Headers('x-window-id') windowId?: string ): Promise { + if (this.cls.get('useV2')) { + return await this.viewOpenApiV2Service.updateLocked( + tableId, + viewId, + viewLockedRo.isLocked, + windowId + ); + } return await this.viewOpenApiService.setViewProperty( tableId, viewId, @@ -166,34 +223,57 @@ export class ViewOpenApiController { ); } - @Permissions('view|update') + @Permissions('view|share') @Put('/:viewId/share-meta') + @UseV2Feature('updateViewShareMeta') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async updateShareMeta( @Param('tableId') tableId: string, @Param('viewId') viewId: string, @Body(new ZodValidationPipe(viewShareMetaRoSchema)) viewShareMetaRo: IViewShareMetaRo ): Promise { + if (this.cls.get('useV2')) { + return await this.viewOpenApiV2Service.updateShareMeta(tableId, viewId, viewShareMetaRo); + } return await this.viewOpenApiService.updateShareMeta(tableId, viewId, viewShareMetaRo); } @Permissions('view|update') @Put('/:viewId/manual-sort') + @UseV2Feature('manualSortView') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async manualSort( @Param('tableId') tableId: string, @Param('viewId') viewId: string, @Body(new ZodValidationPipe(manualSortRoSchema)) updateViewOrderRo: IManualSortRo ): Promise { + if (this.cls.get('useV2')) { + return await this.viewOpenApiV2Service.manualSort(tableId, viewId, updateViewOrderRo); + } return await this.viewOpenApiService.manualSort(tableId, viewId, updateViewOrderRo); } @Permissions('view|update') @Put('/:viewId/column-meta') + @UseV2Feature('updateViewColumnMeta') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async updateColumnMeta( @Param('tableId') tableId: string, @Param('viewId') viewId: string, @Body(new ZodValidationPipe(columnMetaRoSchema)) updateViewColumnMetaRo: IColumnMetaRo, @Headers('x-window-id') windowId?: string ): Promise { + if (this.cls.get('useV2')) { + return await this.viewOpenApiV2Service.updateColumnMeta( + tableId, + viewId, + updateViewColumnMetaRo, + windowId + ); + } return await this.viewOpenApiService.updateViewColumnMeta( tableId, viewId, @@ -204,12 +284,23 @@ export class ViewOpenApiController { @Permissions('view|update') @Put('/:viewId/filter') + @UseV2Feature('updateViewFilter') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async updateViewFilter( @Param('tableId') tableId: string, @Param('viewId') viewId: string, @Body(new ZodValidationPipe(filterRoSchema)) updateViewFilterRo: IFilterRo, @Headers('x-window-id') windowId?: string ): Promise { + if (this.cls.get('useV2')) { + return await this.viewOpenApiV2Service.updateFilter( + tableId, + viewId, + updateViewFilterRo, + windowId + ); + } return await this.viewOpenApiService.setViewProperty( tableId, viewId, @@ -221,12 +312,23 @@ export class ViewOpenApiController { @Permissions('view|update') @Put('/:viewId/sort') + @UseV2Feature('updateViewSort') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async updateViewSort( @Param('tableId') tableId: string, @Param('viewId') viewId: string, @Body(new ZodValidationPipe(viewSortRoSchema)) updateViewSortRo: IViewSortRo, @Headers('x-window-id') windowId?: string ): Promise { + if (this.cls.get('useV2')) { + return await this.viewOpenApiV2Service.updateSort( + tableId, + viewId, + updateViewSortRo, + windowId + ); + } return await this.viewOpenApiService.setViewProperty( tableId, viewId, @@ -238,12 +340,23 @@ export class ViewOpenApiController { @Permissions('view|update') @Put('/:viewId/group') + @UseV2Feature('updateViewGroup') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async updateViewGroup( @Param('tableId') tableId: string, @Param('viewId') viewId: string, @Body(new ZodValidationPipe(viewGroupRoSchema)) updateViewGroupRo: IViewGroupRo, @Headers('x-window-id') windowId?: string ): Promise { + if (this.cls.get('useV2')) { + return await this.viewOpenApiV2Service.updateGroup( + tableId, + viewId, + updateViewGroupRo, + windowId + ); + } return await this.viewOpenApiService.setViewProperty( tableId, viewId, @@ -255,12 +368,23 @@ export class ViewOpenApiController { @Permissions('view|update') @Patch('/:viewId/options') + @UseV2Feature('updateViewOptions') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async updateViewOptions( @Param('tableId') tableId: string, @Param('viewId') viewId: string, @Body(new ZodValidationPipe(viewOptionsRoSchema)) updateViewOptionRo: IViewOptionsRo, @Headers('x-window-id') windowId?: string ): Promise { + if (this.cls.get('useV2')) { + return await this.viewOpenApiV2Service.updateOptions( + tableId, + viewId, + updateViewOptionRo.options, + windowId + ); + } return await this.viewOpenApiService.patchViewOptions( tableId, viewId, @@ -271,12 +395,18 @@ export class ViewOpenApiController { @Permissions('view|update') @Put('/:viewId/order') + @UseV2Feature('updateViewOrder') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async updateViewOrder( @Param('tableId') tableId: string, @Param('viewId') viewId: string, @Body(new ZodValidationPipe(updateOrderRoSchema)) updateOrderRo: IUpdateOrderRo, @Headers('x-window-id') windowId?: string ): Promise { + if (this.cls.get('useV2')) { + return await this.viewOpenApiV2Service.updateOrder(tableId, viewId, updateOrderRo, windowId); + } return await this.viewOpenApiService.updateViewOrder(tableId, viewId, updateOrderRo, windowId); } @@ -306,74 +436,125 @@ export class ViewOpenApiController { ); } - @Permissions('view|update') + @Permissions('view|share') @Post('/:viewId/refresh-share-id') + @UseV2Feature('refreshViewShareId') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async refreshShareId( @Param('tableId') tableId: string, @Param('viewId') viewId: string - ): Promise { + ): Promise { + if (this.cls.get('useV2')) { + return await this.viewOpenApiV2Service.refreshShareId(tableId, viewId); + } return await this.viewOpenApiService.refreshShareId(tableId, viewId); } @Permissions('view|share') @Post('/:viewId/enable-share') + @UseV2Feature('enableViewShare') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async enableShare( @Param('tableId') tableId: string, @Param('viewId') viewId: string ): Promise { + if (this.cls.get('useV2')) { + return await this.viewOpenApiV2Service.enableShare(tableId, viewId); + } return await this.viewOpenApiService.enableShare(tableId, viewId); } @Permissions('view|update') @Post('/:viewId/disable-share') + @UseV2Feature('disableViewShare') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async disableShare( @Param('tableId') tableId: string, @Param('viewId') viewId: string ): Promise { + if (this.cls.get('useV2')) { + return await this.viewOpenApiV2Service.disableShare(tableId, viewId); + } return await this.viewOpenApiService.disableShare(tableId, viewId); } @Permissions('view|read') @Get('/:viewId/filter-link-records') + @UseV2Feature('getViewFilterLinkRecords') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async getFilterLinkRecords( @Param('tableId') tableId: string, @Param('viewId') viewId: string ): Promise { + if (this.cls.get('useV2')) { + return this.viewOpenApiV2Service.getViewFilterLinkRecords(tableId, viewId); + } return this.viewOpenApiService.getFilterLinkRecords(tableId, viewId); } @Permissions('view|read') @Get('/socket/snapshot-bulk') + @UseV2Feature('getViewSocketSnapshotBulk') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async getSnapshotBulk(@Param('tableId') tableId: string, @Query('ids') ids: string[]) { + if (this.cls.get('useV2')) { + return this.viewOpenApiV2Service.getSnapshotBulk(tableId, ids); + } return this.viewService.getSnapshotBulk(tableId, ids); } @Permissions('view|read') @Get('/socket/doc-ids') + @UseV2Feature('getViewSocketDocIds') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async getDocIds(@Param('tableId') tableId: string) { + if (this.cls.get('useV2')) { + return this.viewOpenApiV2Service.getDocIds(tableId); + } return this.viewService.getDocIdsByQuery(tableId, undefined); } @Permissions('view|create') @Post('/plugin') + @UseV2Feature('installViewPlugin') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async pluginInstall( @Param('tableId') tableId: string, @Body(new ZodValidationPipe(viewInstallPluginRoSchema)) ro: IViewInstallPluginRo ): Promise { + if (this.cls.get('useV2')) { + return this.viewOpenApiV2Service.installPlugin(tableId, ro); + } return this.viewOpenApiService.pluginInstall(tableId, ro); } @Get(':viewId/plugin') @Permissions('view|read') - getPluginInstall( + @UseV2Feature('getViewPluginInstall') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) + async getPluginInstall( @Param('tableId') tableId: string, @Param('viewId') viewId: string ): Promise { + if (this.cls.get('useV2')) { + return this.viewOpenApiV2Service.getPluginInstall(tableId, viewId); + } return this.viewOpenApiService.getPluginInstall(tableId, viewId); } @Permissions('view|update') @Patch(':viewId/plugin/:pluginInstallId') + @UseV2Feature('updateViewPluginStorage') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async pluginUpdateStorage( @Param('tableId') tableId: string, @Param('viewId') viewId: string, @@ -381,6 +562,14 @@ export class ViewOpenApiController { @Body(new ZodValidationPipe(viewPluginUpdateStorageRoSchema)) ro: IViewPluginUpdateStorageRo ) { + if (this.cls.get('useV2')) { + return this.viewOpenApiV2Service.updatePluginStorage( + tableId, + viewId, + pluginInstallId, + ro.storage + ); + } return this.viewOpenApiService.updatePluginStorage( tableId, viewId, diff --git a/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts b/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts index 4b0af25cd9..ef58f322ba 100644 --- a/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts +++ b/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts @@ -15,11 +15,11 @@ import type { CellValueType, ISort, IGroup, + IManualSortRo, TableDomain, } from '@teable/core'; import { ViewType, - IManualSortRo, RecordOpBuilder, ViewOpBuilder, generateShareId, diff --git a/apps/nestjs-backend/src/features/view/view-data-safety-limit.service.ts b/apps/nestjs-backend/src/features/view/view-data-safety-limit.service.ts index 6a294f2044..7e5f4eec11 100644 --- a/apps/nestjs-backend/src/features/view/view-data-safety-limit.service.ts +++ b/apps/nestjs-backend/src/features/view/view-data-safety-limit.service.ts @@ -1,12 +1,6 @@ -import { Injectable } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { - HttpErrorCode, - type IFilter, - type IGroup, - type ISort, - type IViewOptions, -} from '@teable/core'; +import { type IFilter, type IGroup, type ISort, type IViewOptions } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import { ensureTableDataSafetyViewOperationLimits, @@ -17,7 +11,7 @@ import { type ViewOperationPayloadViewConfig, type ViewOperationPluginContext, } from '@teable/v2-core'; -import { CustomHttpException } from '../../custom.exception'; +import { throwV2Error } from '../v2/v2-http-error'; type SerializedViewProperties = { name?: string | null; @@ -102,12 +96,7 @@ export class ViewDataSafetyLimitService { const result = ensureTableDataSafetyViewOperationLimits(context, this.getLimits()); if (result.isOk()) return; - const error = result.error; - throw new CustomHttpException(error.message, HttpErrorCode.VALIDATION_ERROR, { - domainCode: error.code, - domainTags: error.tags, - details: error.details, - }); + throwV2Error(result.error, HttpStatus.BAD_REQUEST); } async ensureCanCreateView(tableId: string): Promise { diff --git a/apps/nestjs-backend/src/filter/global-exception.filter.spec.ts b/apps/nestjs-backend/src/filter/global-exception.filter.spec.ts index c32dc30e23..b2f3ca511e 100644 --- a/apps/nestjs-backend/src/filter/global-exception.filter.spec.ts +++ b/apps/nestjs-backend/src/filter/global-exception.filter.spec.ts @@ -7,6 +7,7 @@ const { activeSpan, runtimeErrorCounter, sentryScope, captureException, withScop const activeSpan = { setAttributes: vi.fn(), setStatus: vi.fn(), + recordException: vi.fn(), }; const runtimeErrorCounter = { add: vi.fn(), @@ -298,4 +299,26 @@ describe('GlobalExceptionFilter', () => { [dataDbOtelAttribute.userActionable]: true, }); }); + + // NestInstrumentation used to do this on its handler span; it is disabled now, so the + // filter is the only thing left that sees a thrown exception with the span active. + it('records the exception on the span but leaves 4xx unmarked', () => { + const filter = new GlobalExceptionFilter(configService as never); + const exception = new BadRequestException('bad input'); + + filter.catch(exception, host as never); + + expect(activeSpan.recordException).toHaveBeenCalledWith(exception); + expect(activeSpan.setStatus).not.toHaveBeenCalled(); + }); + + it('marks the span as errored for a 5xx', () => { + const filter = new GlobalExceptionFilter(configService as never); + const exception = new Error('boom'); + + filter.catch(exception, host as never); + + expect(activeSpan.recordException).toHaveBeenCalledWith(exception); + expect(activeSpan.setStatus).toHaveBeenCalledWith({ code: 2, message: 'boom' }); + }); }); diff --git a/apps/nestjs-backend/src/filter/global-exception.filter.ts b/apps/nestjs-backend/src/filter/global-exception.filter.ts index bad7ab1390..3b7e290205 100644 --- a/apps/nestjs-backend/src/filter/global-exception.filter.ts +++ b/apps/nestjs-backend/src/filter/global-exception.filter.ts @@ -73,7 +73,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { if (responseWritable) { setV2AttributionHeaders(response, getV2Attribution(this.cls)); } - this.annotateActiveSpan(dataDbError); + this.annotateActiveSpan(exception, dataDbError); this.recordDataDbMetric(dataDbError); this.captureException(exception, dataDbError); @@ -185,10 +185,27 @@ export class GlobalExceptionFilter implements ExceptionFilter { }); } - private annotateActiveSpan(dataDbError?: IDataDbRuntimeErrorClassification | null) { + private annotateActiveSpan( + exception: Error | HttpException, + dataDbError?: IDataDbRuntimeErrorClassification | null + ) { const span = trace.getActiveSpan(); if (!span) return; + // NestInstrumentation is disabled (see tracing.ts) and RouteTracingInterceptor never + // runs for what a guard or pipe rejects, so this filter is the only place left that + // still sees a thrown exception with the request span active. + span.recordException(exception); + // Only 5xx is the server failing. Marking 4xx would multiply the APM error rate and + // promote every routine 404 into a full-detail trace export. + const status = dataDbError ? 503 : exceptionParse(exception).getStatus(); + if (status >= 500) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: dataDbError?.code ?? exception.message, + }); + } + const v2Attributes = getV2AttributionSpanAttributes(getV2Attribution(this.cls)); if (Object.keys(v2Attributes).length) { span.setAttributes(v2Attributes); @@ -208,7 +225,6 @@ export class GlobalExceptionFilter implements ExceptionFilter { [dataDbOtelAttribute.retryable]: dataDbError.retryable, [dataDbOtelAttribute.userActionable]: dataDbError.userActionable, }); - span.setStatus({ code: SpanStatusCode.ERROR, message: dataDbError.code }); } private recordDataDbMetric(dataDbError?: IDataDbRuntimeErrorClassification | null) { diff --git a/apps/nestjs-backend/src/global/computed-outbox-maintenance-query.spec.ts b/apps/nestjs-backend/src/global/computed-outbox-maintenance-query.spec.ts index 8f983187ef..a673c1d7d1 100644 --- a/apps/nestjs-backend/src/global/computed-outbox-maintenance-query.spec.ts +++ b/apps/nestjs-backend/src/global/computed-outbox-maintenance-query.spec.ts @@ -23,6 +23,27 @@ describe('buildComputedOutboxWakeupCandidatesQuery', () => { expect(query.bindings).toEqual([120_000, 500]); }); + it('excludes tasks of externally bound spaces on the default storage', () => { + const query = buildComputedOutboxWakeupCandidatesQuery({ storage: 'default' }, 120_000, 500); + + expect(query.sql).toContain('join "space_data_db_binding" as sdb'); + expect(query.sql).toContain(`sdb."mode" <> 'default'`); + }); + + it('does not add the foreign-binding exclusion on BYODB storages', () => { + const query = buildComputedOutboxWakeupCandidatesQuery( + { + storage: 'byodb', + internalSchema: 'teable_data', + baseSpaceMapping: [], + }, + 120_000, + 500 + ); + + expect(query.sql).not.toContain('space_data_db_binding'); + }); + it('uses the supplied base-to-space mapping for BYODB pause scopes', () => { const query = buildComputedOutboxWakeupCandidatesQuery( { diff --git a/apps/nestjs-backend/src/global/computed-outbox-maintenance-query.ts b/apps/nestjs-backend/src/global/computed-outbox-maintenance-query.ts index f62d28dac8..95767ad4af 100644 --- a/apps/nestjs-backend/src/global/computed-outbox-maintenance-query.ts +++ b/apps/nestjs-backend/src/global/computed-outbox-maintenance-query.ts @@ -62,6 +62,25 @@ export const buildComputedOutboxActivePauseExclusion = ( }; }; +/** + * The default storage claims tasks with the shared (meta) database as its data + * plane. A space bound to an external data database only has an orphaned + * pre-switch copy there, so its tasks must never be redriven on this storage — + * the claim side fences them out and publishing wakeups for them only churns. + */ +export const buildComputedOutboxForeignBindingExclusion = ( + target: ComputedOutboxWakeupCandidateQueryTarget +): string => + target.storage === 'default' + ? `and not exists ( + select 1 + from "base" as fbb + join "space_data_db_binding" as sdb on sdb."space_id" = fbb."space_id" + where fbb."id" = o.base_id + and sdb."mode" <> 'default' + )` + : ''; + export const buildComputedOutboxWakeupCandidatesQuery = ( target: ComputedOutboxWakeupCandidateQueryTarget, processingLeaseMs: number, @@ -70,6 +89,7 @@ export const buildComputedOutboxWakeupCandidatesQuery = ( options: ComputedOutboxWakeupCandidateQueryOptions = {} ): { sql: string; bindings: unknown[] } => { const pauseExclusion = buildComputedOutboxActivePauseExclusion(target); + const foreignBindingExclusion = buildComputedOutboxForeignBindingExclusion(target); const outboxTable = qualifyComputedOutboxTable(target, 'computed_update_outbox'); const bindings: unknown[] = [...pauseExclusion.bindings]; const actionableClause = options.actionableOnly @@ -98,6 +118,7 @@ export const buildComputedOutboxWakeupCandidatesQuery = ( from ${outboxTable} as o where o.status in ('pending', 'processing') and ${pauseExclusion.sql} + ${foreignBindingExclusion} ${actionableClause} ${afterClause} order by o.id asc diff --git a/apps/nestjs-backend/src/global/global.module.ts b/apps/nestjs-backend/src/global/global.module.ts index 9015cf588b..1bf13cd695 100644 --- a/apps/nestjs-backend/src/global/global.module.ts +++ b/apps/nestjs-backend/src/global/global.module.ts @@ -22,6 +22,7 @@ import { EventEmitterModule } from '../event-emitter/event-emitter.module'; import { AuditSourceModule } from '../features/audit/audit.module'; import { AuthGuard } from '../features/auth/guard/auth.guard'; import { PermissionGuard } from '../features/auth/guard/permission.guard'; +import { TeableJwtModule } from '../features/auth/jwt/teable-jwt.module'; import { PermissionModule } from '../features/auth/permission.module'; import { DataLoaderModule } from '../features/data-loader/data-loader.module'; import { ModelModule } from '../features/model/model.module'; @@ -68,6 +69,7 @@ const globalModules = { PermissionModule, DataLoaderModule, PerformanceCacheModule, + TeableJwtModule, I18nModule.forRootAsync({ useFactory: () => { const i18nPath = getI18nPath(); diff --git a/apps/nestjs-backend/src/instrument.ts b/apps/nestjs-backend/src/instrument.ts index 0d3fea43c3..baa2d4e559 100644 --- a/apps/nestjs-backend/src/instrument.ts +++ b/apps/nestjs-backend/src/instrument.ts @@ -1,5 +1,6 @@ import { Logger } from '@nestjs/common'; import * as Sentry from '@sentry/nestjs'; +import { enrichSentryEventWithDomainError } from './sentry-domain-error'; import { resolveBuildVersion } from './utils/build-version'; if (process.env.BACKEND_SENTRY_DSN) { @@ -28,6 +29,9 @@ if (process.env.BACKEND_SENTRY_DSN) { Sentry.linkedErrorsIntegration(), Sentry.dataloaderIntegration(), ], + beforeSend(event, hint) { + return enrichSentryEventWithDomainError(event, hint); + }, }); Logger.log(`Sentry initialized, tracesSampleRate: ${traceRate}`); } diff --git a/apps/nestjs-backend/src/middleware/request-info.middleware.spec.ts b/apps/nestjs-backend/src/middleware/request-info.middleware.spec.ts index 37ff5fea26..31185b8c77 100644 --- a/apps/nestjs-backend/src/middleware/request-info.middleware.spec.ts +++ b/apps/nestjs-backend/src/middleware/request-info.middleware.spec.ts @@ -1,3 +1,4 @@ +import { Logger } from '@nestjs/common'; import type { Request, Response } from 'express'; import type { ClsService } from 'nestjs-cls'; import { describe, expect, it, vi } from 'vitest'; @@ -120,17 +121,8 @@ describe('RequestInfoMiddleware', () => { expect(clsValues.get('affiliateVia')).toBe('k ol'); }); - it('runs v2 background tasks only after the HTTP response finishes', () => { - const globalWithTimeout = globalThis as { - setTimeout: typeof setTimeout; - }; - const originalSetTimeout = globalWithTimeout.setTimeout; - const timers: Array<() => void> = []; - globalWithTimeout.setTimeout = ((callback: () => void) => { - timers.push(callback); - return { unref: vi.fn() }; - }) as unknown as typeof setTimeout; - + it('runs v2 background tasks only after the HTTP response finishes', async () => { + vi.useFakeTimers(); try { const clsValues = new Map(); const cls = { @@ -153,42 +145,133 @@ describe('RequestInfoMiddleware', () => { const middleware = new RequestInfoMiddleware(cls); middleware.use(createRequest(), res, next); - const schedule = clsValues.get('scheduleV2BackgroundTask') as NonNullable< IClsStore['scheduleV2BackgroundTask'] >; const task = vi.fn(); schedule(task); - expect(next).toHaveBeenCalledWith(); expect(task).not.toHaveBeenCalled(); - expect(timers).toHaveLength(0); listeners.get('finish')?.(); - expect(task).not.toHaveBeenCalled(); - expect(timers).toHaveLength(1); - - timers.shift()?.(); + await vi.runAllTimersAsync(); expect(task).toHaveBeenCalledTimes(1); } finally { - globalWithTimeout.setTimeout = originalSetTimeout; + vi.useRealTimers(); + } + }); + + it('runs v2 background tasks in FIFO order with bounded concurrency', async () => { + vi.useFakeTimers(); + try { + const clsValues = new Map(); + const cls = { + get: vi.fn(() => undefined), + runWith: vi.fn((_store: IClsStore, callback: () => void) => callback()), + set: vi.fn((key: string, value: unknown) => { + clsValues.set(key, value); + }), + } as unknown as ClsService; + const listeners = new Map void>(); + const res = { + once: vi.fn((event: string, listener: () => void) => { + listeners.set(event, listener); + return res; + }), + writableEnded: false, + destroyed: false, + } as unknown as Response; + const middleware = new RequestInfoMiddleware(cls); + const releases: Array<() => void> = []; + const started: number[] = []; + let activeTasks = 0; + let peakActiveTasks = 0; + + middleware.use(createRequest(), res, vi.fn()); + const schedule = clsValues.get('scheduleV2BackgroundTask') as NonNullable< + IClsStore['scheduleV2BackgroundTask'] + >; + for (let index = 0; index < 10; index += 1) { + schedule( + () => + new Promise((resolve) => { + started.push(index); + activeTasks += 1; + peakActiveTasks = Math.max(peakActiveTasks, activeTasks); + releases.push(() => { + activeTasks -= 1; + resolve(); + }); + }) + ); + } + + listeners.get('finish')?.(); + listeners.get('close')?.(); + await vi.advanceTimersByTimeAsync(0); + expect(started).toEqual([0, 1, 2, 3]); + expect(peakActiveTasks).toBe(4); + + for (let completed = 0; completed < 10; completed += 1) { + releases.shift()?.(); + await vi.advanceTimersByTimeAsync(0); + } + + expect(started).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + expect(activeTasks).toBe(0); + expect(peakActiveTasks).toBe(4); + } finally { + vi.useRealTimers(); } }); - it('runs v2 background tasks with the CLS store captured when scheduled', () => { - const globalWithTimeout = globalThis as { - setTimeout: typeof setTimeout; - }; - const originalSetTimeout = globalWithTimeout.setTimeout; - const timers: Array<() => void> = []; - globalWithTimeout.setTimeout = ((callback: () => void) => { - timers.push(callback); - return { unref: vi.fn() }; - }) as unknown as typeof setTimeout; + it('continues draining when a v2 background task rejects', async () => { + vi.useFakeTimers(); + const loggerError = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + try { + const clsValues = new Map(); + const cls = { + get: vi.fn(() => undefined), + runWith: vi.fn((_store: IClsStore, callback: () => void) => callback()), + set: vi.fn((key: string, value: unknown) => { + clsValues.set(key, value); + }), + } as unknown as ClsService; + const listeners = new Map void>(); + const res = { + once: vi.fn((event: string, listener: () => void) => { + listeners.set(event, listener); + return res; + }), + writableEnded: false, + destroyed: false, + } as unknown as Response; + const middleware = new RequestInfoMiddleware(cls); + const completed = vi.fn(); + + middleware.use(createRequest(), res, vi.fn()); + const schedule = clsValues.get('scheduleV2BackgroundTask') as NonNullable< + IClsStore['scheduleV2BackgroundTask'] + >; + schedule(() => Promise.reject(new Error('expected background failure'))); + schedule(completed); + + listeners.get('finish')?.(); + await vi.runAllTimersAsync(); + + expect(completed).toHaveBeenCalledTimes(1); + expect(loggerError).toHaveBeenCalledOnce(); + } finally { + loggerError.mockRestore(); + vi.useRealTimers(); + } + }); + it('runs v2 background tasks with the CLS store captured when scheduled', async () => { + vi.useFakeTimers(); try { const clsValues = new Map(); const scheduledStore = { @@ -216,7 +299,6 @@ describe('RequestInfoMiddleware', () => { const middleware = new RequestInfoMiddleware(cls); middleware.use(createRequest(), res, vi.fn()); - const schedule = clsValues.get('scheduleV2BackgroundTask') as NonNullable< IClsStore['scheduleV2BackgroundTask'] >; @@ -224,12 +306,12 @@ describe('RequestInfoMiddleware', () => { schedule(task); listeners.get('finish')?.(); - timers.shift()?.(); + await vi.runAllTimersAsync(); expect(cls.runWith).toHaveBeenCalledWith(scheduledStore, expect.any(Function)); expect(task).toHaveBeenCalledTimes(1); } finally { - globalWithTimeout.setTimeout = originalSetTimeout; + vi.useRealTimers(); } }); }); diff --git a/apps/nestjs-backend/src/middleware/request-info.middleware.ts b/apps/nestjs-backend/src/middleware/request-info.middleware.ts index 6ef69aefd6..19600fc346 100644 --- a/apps/nestjs-backend/src/middleware/request-info.middleware.ts +++ b/apps/nestjs-backend/src/middleware/request-info.middleware.ts @@ -35,6 +35,9 @@ const fallbackScheduleV2BackgroundTask: NonNullable, res: Response @@ -42,23 +45,51 @@ const createAfterResponseScheduler = ( const pendingTasks: Array<() => Promise | void> = []; let responseFinished = res.writableEnded || res.destroyed; let flushScheduled = false; + let activeTasks = 0; + + async function runTask(task: () => Promise | void) { + activeTasks += 1; + try { + await task(); + } catch (error) { + backgroundTaskLogger.error( + `V2 background task failed: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined + ); + } finally { + activeTasks -= 1; + scheduleFlush(); + } + } - const scheduleFlush = () => { - if (flushScheduled) { + function scheduleFlush() { + if ( + flushScheduled || + !responseFinished || + pendingTasks.length === 0 || + activeTasks >= maxConcurrentBackgroundTasks + ) { return; } + flushScheduled = true; const handle = setTimeout(() => { flushScheduled = false; - const tasks = pendingTasks.splice(0); - for (const task of tasks) { - void task(); + while (activeTasks < maxConcurrentBackgroundTasks) { + const task = pendingTasks.shift(); + if (!task) { + break; + } + void runTask(task); } }, 0); handle.unref?.(); - }; + } const markResponseFinished = () => { + if (responseFinished) { + return; + } responseFinished = true; scheduleFlush(); }; @@ -74,9 +105,7 @@ const createAfterResponseScheduler = ( } return task(); }); - if (responseFinished) { - scheduleFlush(); - } + scheduleFlush(); }; }; diff --git a/apps/nestjs-backend/src/sentry-domain-error.spec.ts b/apps/nestjs-backend/src/sentry-domain-error.spec.ts new file mode 100644 index 0000000000..3f04a1e7b6 --- /dev/null +++ b/apps/nestjs-backend/src/sentry-domain-error.spec.ts @@ -0,0 +1,121 @@ +import type { ErrorEvent, EventHint } from '@sentry/nestjs'; +import { HttpErrorCode } from '@teable/core'; +import { domainError, toError } from '@teable/v2-core'; +import { describe, expect, it } from 'vitest'; +import { CustomHttpException } from './custom.exception'; +import { enrichSentryEventWithDomainError, getDomainErrorContext } from './sentry-domain-error'; + +const makeEvent = (): ErrorEvent => + ({ + type: undefined, + exception: { values: [{ type: 'Error', value: 'original' }] }, + }) as ErrorEvent; + +const hintFor = (exception: unknown): EventHint => ({ originalException: exception }); + +describe('getDomainErrorContext', () => { + it('extracts from toError() output via the attached domainError', () => { + const domain = domainError.infrastructure({ + message: 'Failed to load compute activity', + details: { tableId: 'tbl1', error: 'connection refused' }, + }); + + const context = getDomainErrorContext(toError(domain)); + + expect(context).toEqual({ + code: 'infrastructure', + message: 'Failed to load compute activity', + detail: 'connection refused', + tags: ['infrastructure'], + details: { tableId: 'tbl1', error: 'connection refused' }, + }); + }); + + it('extracts from a CustomHttpException via data.domainCode', () => { + const exception = new CustomHttpException('bad field', HttpErrorCode.VALIDATION_ERROR, { + domainCode: 'validation.field.invalid', + domainTags: ['validation'], + details: { field: 'name', error: { message: 'must not be empty' } }, + }); + + const context = getDomainErrorContext(exception); + + expect(context).toEqual({ + code: 'validation.field.invalid', + message: 'bad field', + detail: 'must not be empty', + tags: ['validation'], + details: { field: 'name', error: { message: 'must not be empty' } }, + }); + }); + + it('returns undefined for non-domain exceptions', () => { + expect(getDomainErrorContext(new Error('plain'))).toBeUndefined(); + expect(getDomainErrorContext('string reason')).toBeUndefined(); + expect(getDomainErrorContext(undefined)).toBeUndefined(); + }); +}); + +describe('enrichSentryEventWithDomainError', () => { + it('fingerprints by code + message only, keeping dynamic detail out', () => { + const domain = domainError.infrastructure({ + message: 'Failed to load compute activity', + details: { tableId: 'tbl1', error: 'relation "tbl_x9f2" does not exist' }, + }); + + const event = enrichSentryEventWithDomainError(makeEvent(), hintFor(toError(domain))); + + expect(event.fingerprint).toEqual([ + 'domain-error', + 'infrastructure', + 'Failed to load compute activity', + ]); + expect(event.transaction).toBe('infrastructure'); + expect(event.exception?.values?.[0]).toEqual({ + type: 'DomainError:infrastructure', + value: 'Failed to load compute activity | relation "tbl_x9f2" does not exist', + }); + // eslint-disable-next-line @typescript-eslint/naming-convention -- dot-separated Sentry tag key + expect(event.tags).toEqual({ 'domain.error_code': 'infrastructure' }); + expect(event.extra).toEqual({ + domainTags: ['infrastructure'], + domainDetails: { tableId: 'tbl1', error: 'relation "tbl_x9f2" does not exist' }, + }); + }); + + it('captures domainDetails from CustomHttpException data', () => { + const exception = new CustomHttpException('boom', HttpErrorCode.INTERNAL_SERVER_ERROR, { + domainCode: 'infrastructure', + domainTags: ['infrastructure'], + details: { tableId: 'tbl1' }, + }); + + const event = enrichSentryEventWithDomainError(makeEvent(), hintFor(exception)); + + expect(event.extra).toEqual({ + domainTags: ['infrastructure'], + domainDetails: { tableId: 'tbl1' }, + }); + }); + + it('keeps an existing transaction name', () => { + const domain = domainError.validation({ message: 'bad field' }); + const event = makeEvent(); + event.transaction = 'POST /api/table'; + + const enriched = enrichSentryEventWithDomainError(event, hintFor(toError(domain))); + + expect(enriched.transaction).toBe('POST /api/table'); + expect(enriched.fingerprint).toEqual(['domain-error', 'validation.invalid', 'bad field']); + }); + + it('leaves non-domain events untouched', () => { + const event = makeEvent(); + + const enriched = enrichSentryEventWithDomainError(event, hintFor(new Error('plain'))); + + expect(enriched).toBe(event); + expect(enriched.fingerprint).toBeUndefined(); + expect(enriched.exception?.values?.[0]).toEqual({ type: 'Error', value: 'original' }); + }); +}); diff --git a/apps/nestjs-backend/src/sentry-domain-error.ts b/apps/nestjs-backend/src/sentry-domain-error.ts new file mode 100644 index 0000000000..eb04ecc90b --- /dev/null +++ b/apps/nestjs-backend/src/sentry-domain-error.ts @@ -0,0 +1,140 @@ +import type { ErrorEvent, EventHint } from '@sentry/nestjs'; + +export interface IDomainErrorEventContext { + code?: string; + message?: string; + detail?: string; + tags?: unknown; + details?: unknown; +} + +const asString = (value: unknown): string | undefined => + typeof value === 'string' ? value : undefined; + +const describeDomainErrorDetail = (details: unknown): string | undefined => { + if (!details || typeof details !== 'object') return undefined; + const nested = (details as Record).error; + if (typeof nested === 'string' && nested.trim()) return nested.trim(); + if (nested && typeof nested === 'object') { + const nestedMessage = asString((nested as { message?: unknown }).message); + if (nestedMessage?.trim()) return nestedMessage.trim(); + } + return undefined; +}; + +type ICandidate = { + name?: unknown; + message?: unknown; + code?: unknown; + tags?: unknown; + details?: unknown; + data?: { domainCode?: unknown; domainTags?: unknown; details?: unknown }; + domainError?: { code?: unknown; message?: unknown; tags?: unknown; details?: unknown }; +}; + +/** toError() output carries the original DomainError POJO. */ +const contextFromAttachedDomainError = ( + candidate: ICandidate +): IDomainErrorEventContext | undefined => { + const domain = candidate.domainError; + if (!domain || typeof domain !== 'object') return undefined; + return { + code: asString(domain.code), + message: asString(domain.message), + detail: describeDomainErrorDetail(domain.details), + tags: domain.tags, + details: domain.details, + }; +}; + +/** A real Error named by toError() but without the attached POJO. */ +const contextFromNamedError = (candidate: ICandidate): IDomainErrorEventContext | undefined => { + const name = asString(candidate.name); + if (!name?.startsWith('DomainError:')) return undefined; + return { + code: asString(candidate.code) ?? name.slice('DomainError:'.length), + message: asString(candidate.message), + detail: describeDomainErrorDetail(candidate.details), + tags: candidate.tags, + details: candidate.details, + }; +}; + +/** CustomHttpException thrown by throwV2Error carries `data.domainCode`. */ +const contextFromHttpExceptionData = ( + candidate: ICandidate +): IDomainErrorEventContext | undefined => { + const data = candidate.data; + if (!data || typeof data.domainCode !== 'string') return undefined; + return { + code: data.domainCode, + message: asString(candidate.message), + detail: describeDomainErrorDetail(data.details), + tags: data.domainTags, + details: data.details, + }; +}; + +/** + * Extract DomainError attribution from the three shapes that reach Sentry: + * toError() output (carries `domainError`), a DomainError-named Error, and + * CustomHttpException (carries `data.domainCode`). + */ +export const getDomainErrorContext = (exception: unknown): IDomainErrorEventContext | undefined => { + if (!exception || typeof exception !== 'object') return undefined; + const candidate = exception as ICandidate; + return ( + contextFromAttachedDomainError(candidate) ?? + contextFromNamedError(candidate) ?? + contextFromHttpExceptionData(candidate) + ); +}; + +const retitleException = (event: ErrorEvent, domain: IDomainErrorEventContext): void => { + const value = event.exception?.values?.[0]; + if (!value) return; + value.type = domain.code ? `DomainError:${domain.code}` : value.type; + value.value = [domain.message, domain.detail].filter(Boolean).join(' | ') || value.value; +}; + +/** + * Sentry beforeSend hook: fingerprint and retitle DomainError events so they + * group by failure kind instead of Sentry's activeSpanWrapper fallback. + * + * The fingerprint uses `code` + `message` only. `details.error` frequently + * carries dynamic identifiers (record ids, relation names, driver text), which + * would split one failure kind into unbounded Sentry issues — it is kept on the + * displayed value and `extra.domainDetails` instead. + */ +export const enrichSentryEventWithDomainError = ( + event: ErrorEvent, + hint: EventHint +): ErrorEvent => { + const domain = getDomainErrorContext(hint.originalException ?? hint.syntheticException); + if (!domain?.code && !domain?.message) { + return event; + } + + const fingerprintParts = [domain.code, domain.message].filter( + (part): part is string => typeof part === 'string' && part.length > 0 + ); + if (fingerprintParts.length > 0) { + event.fingerprint = ['domain-error', ...fingerprintParts]; + // Prefer a stable, informative title over Sentry's activeSpanWrapper fallback. + event.transaction = event.transaction ?? fingerprintParts[0]; + retitleException(event, domain); + } + + if (domain.code) { + // eslint-disable-next-line @typescript-eslint/naming-convention -- dot-separated Sentry tag key + event.tags = { ...event.tags, 'domain.error_code': domain.code }; + } + if (domain.tags !== undefined || domain.details !== undefined) { + event.extra = { + ...event.extra, + ...(domain.tags !== undefined ? { domainTags: domain.tags } : {}), + ...(domain.details !== undefined ? { domainDetails: domain.details } : {}), + }; + } + return event; +}; diff --git a/apps/nestjs-backend/src/sentry-handled-error.ts b/apps/nestjs-backend/src/sentry-handled-error.ts new file mode 100644 index 0000000000..e0e3c8362c --- /dev/null +++ b/apps/nestjs-backend/src/sentry-handled-error.ts @@ -0,0 +1,29 @@ +import * as Sentry from '@sentry/nestjs'; + +/** + * Single convention for reporting a deliberately-absorbed failure: the caller + * keeps its fallback behavior (log, degrade, retry) while this makes the + * failure visible as a Sentry issue. `type` names the seam (e.g. + * 'ai_proxy.billing_charge_failed') and becomes the event's mechanism. Tags are + * entry pairs because Sentry tag keys are dot-separated. + */ +export const captureHandledError = ( + error: unknown, + options: { + type: string; + tags?: ReadonlyArray; + context?: { name: string; data: Record }; + } +): void => { + Sentry.withScope((scope) => { + for (const [key, value] of options.tags ?? []) { + scope.setTag(key, value); + } + if (options.context) { + scope.setContext(options.context.name, options.context.data); + } + Sentry.captureException(error, { + mechanism: { handled: true, type: options.type }, + }); + }); +}; diff --git a/apps/nestjs-backend/src/share-db/metrics/compression-metrics.spec.ts b/apps/nestjs-backend/src/share-db/metrics/compression-metrics.spec.ts new file mode 100644 index 0000000000..d9e3d8713d --- /dev/null +++ b/apps/nestjs-backend/src/share-db/metrics/compression-metrics.spec.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { recordCompressionNegotiation } from './compression-metrics'; + +describe('recordCompressionNegotiation', () => { + it('reports a websocket connection whose offer survived the proxy', () => { + expect( + recordCompressionNegotiation('websocket', 'permessage-deflate; client_max_window_bits') + ).toBe('negotiated'); + }); + + it('flags a websocket connection whose offer never reached the pod', () => { + // Every current browser offers permessage-deflate, so its absence on a + // websocket upgrade means something in front of us removed the header. + expect(recordCompressionNegotiation('websocket', undefined)).toBe('offer_missing'); + expect(recordCompressionNegotiation('websocket', 'x-webkit-deflate-frame')).toBe( + 'offer_missing' + ); + }); + + it('does not count the xhr-streaming fallback as a stripped offer', () => { + expect(recordCompressionNegotiation('xhr-streaming', undefined)).toBe('not_applicable'); + }); +}); diff --git a/apps/nestjs-backend/src/share-db/metrics/compression-metrics.ts b/apps/nestjs-backend/src/share-db/metrics/compression-metrics.ts new file mode 100644 index 0000000000..be37688a61 --- /dev/null +++ b/apps/nestjs-backend/src/share-db/metrics/compression-metrics.ts @@ -0,0 +1,155 @@ +import { metrics } from '@opentelemetry/api'; + +// Lives alongside RealtimeMetricsService so every realtime.* metric definition +// is discoverable in one folder. Kept standalone (not a method on the +// @Injectable service) on purpose, same as query-poll-skip-metrics: the +// permessage-deflate extension is built once at module load and handed to +// sockjs, so it can never inject a Nest provider. +// +// recordCompressionFrame fires on every websocket frame — the same order of +// magnitude as skipPoll — so frames are observed through aggregated counters +// only; per-frame logging at this rate is not acceptable. +// +// Cardinality budget, using the accounting in tracing.ts (SigNoz bills per +// sample; a histogram with N boundaries costs N+4 series per label set): +// +// negotiation.total 3 results -> 3 +// sessions.active no labels -> 1 +// bytes.uncompressed 2 directions -> 2 +// bytes.compressed 2 directions -> 2 +// duration 4 boundaries, no labels -> 9 +// ── 17 samples per pod +// +// Every label here is closed-set. Nothing derived from a user, table, space or +// connection is ever attached — that is what turns a metric into a bill. +const meter = metrics.getMeter('teable-observability'); + +const negotiationTotal = meter.createCounter('realtime.compression.negotiation.total', { + description: + 'WebSocket connections by permessage-deflate outcome. `offer_missing` means the ' + + 'Sec-WebSocket-Extensions header did not reach this pod — normally a proxy, load ' + + 'balancer or CDN in front stripped it. `not_applicable` is the xhr-streaming ' + + 'fallback, which has no websocket extensions to negotiate.', +}); + +const uncompressedBytes = meter.createCounter('realtime.compression.bytes.uncompressed', { + description: 'Frame bytes before deflate (outbound) or after inflate (inbound)', + unit: 'By', +}); + +const compressedBytes = meter.createCounter('realtime.compression.bytes.compressed', { + description: 'Frame bytes on the wire. Divide uncompressed by this for the live ratio.', + unit: 'By', +}); + +// Outbound only, and deliberately unlabeled. Inbound is inflate over small +// ShareDB ops and would double the series count to restate what outbound +// already shows. Four boundaries are enough for the one question this answers: +// normal (sub-millisecond) versus libuv threadpool contention (tens of ms). +// Its `count` also stands in for a frames-sent counter, so there isn't one. +const outboundDuration = meter.createHistogram('realtime.compression.duration', { + description: + 'Wall time per outbound frame through zlib. Node runs deflate on the libuv ' + + 'threadpool, so sustained growth here means threadpool contention rather than ' + + 'slow compression.', + unit: 'ms', + advice: { explicitBucketBoundaries: [0.5, 5, 25, 100] }, +}); + +// Deliberately a gauge rather than a cumulative count of sessions created: this +// is the multiplier for the memory budget. Each live session pins a deflate and +// an inflate context, measured at ~250 KiB per connection with the settings in +// ws/sockjs-options.ts, so `sessions.active * 250 KiB` is the RAM compression is +// costing right now. realtime.connections.active cannot stand in for it — that +// counts xhr-streaming connections too, and those hold no zlib contexts. +const sessionsActive = meter.createUpDownCounter('realtime.compression.sessions.active', { + description: + 'Live permessage-deflate sessions, each holding a deflate + inflate context. ' + + 'Multiply by the per-connection cost to get compression memory.', +}); + +export type ICompressionNegotiation = 'negotiated' | 'offer_missing' | 'not_applicable'; +export type ICompressionDirection = 'outbound' | 'inbound'; + +export interface ICompressionSnapshot { + /** Sessions opened since boot. Process-local only; not exported. */ + sessionsCreated: number; + /** Sessions currently holding zlib contexts — the memory multiplier. */ + sessionsActive: number; + outbound: { frames: number; uncompressedBytes: number; compressedBytes: number }; + inbound: { frames: number; uncompressedBytes: number; compressedBytes: number }; +} + +// Process-local mirror of the counters above. OTEL counters are write-only, and +// a running total is worth having on hand for a one-off check without a +// dashboard query. +const local: ICompressionSnapshot = { + sessionsCreated: 0, + sessionsActive: 0, + outbound: { frames: 0, uncompressedBytes: 0, compressedBytes: 0 }, + inbound: { frames: 0, uncompressedBytes: 0, compressedBytes: 0 }, +}; + +export const getCompressionSnapshot = (): ICompressionSnapshot => ({ + sessionsCreated: local.sessionsCreated, + sessionsActive: local.sessionsActive, + outbound: { ...local.outbound }, + inbound: { ...local.inbound }, +}); + +/** + * Classifies whether compression is actually reaching this pod. + * + * Every browser in current use offers permessage-deflate on a websocket + * upgrade, so `offer_missing` on the websocket transport is the signal that + * something in front of the pod removed `Sec-WebSocket-Extensions`. + */ +export const recordCompressionNegotiation = ( + transport: string, + extensionsHeader?: string +): ICompressionNegotiation => { + const result: ICompressionNegotiation = + transport !== 'websocket' + ? 'not_applicable' + : extensionsHeader?.includes('permessage-deflate') + ? 'negotiated' + : 'offer_missing'; + + // `transport` is intentionally not a label: `result` already separates the + // websocket cases from the fallback, so adding it would only widen the label + // set the day another transport is enabled. + negotiationTotal.add(1, { result }); + return result; +}; + +/** + * @param plain bytes before deflate (outbound) or after inflate (inbound) + * @param wire bytes as they cross the socket + * @param durationMs omitted for inbound, which is not timed + */ +export const recordCompressionFrame = ( + direction: ICompressionDirection, + plain: number, + wire: number, + durationMs?: number +): void => { + const bucket = direction === 'outbound' ? local.outbound : local.inbound; + bucket.frames += 1; + bucket.uncompressedBytes += plain; + bucket.compressedBytes += wire; + + uncompressedBytes.add(plain, { direction }); + compressedBytes.add(wire, { direction }); + if (durationMs !== undefined) outboundDuration.record(durationMs); +}; + +export const recordCompressionSessionOpen = (): void => { + local.sessionsCreated += 1; + local.sessionsActive += 1; + sessionsActive.add(1); +}; + +export const recordCompressionSessionClose = (): void => { + local.sessionsActive -= 1; + sessionsActive.add(-1); +}; diff --git a/apps/nestjs-backend/src/share-db/readonly/record-readonly.service.ts b/apps/nestjs-backend/src/share-db/readonly/record-readonly.service.ts index 9286f2ddb2..d3b253eed6 100644 --- a/apps/nestjs-backend/src/share-db/readonly/record-readonly.service.ts +++ b/apps/nestjs-backend/src/share-db/readonly/record-readonly.service.ts @@ -65,18 +65,23 @@ export class RecordReadonlyServiceAdapter const url = useShareViewEndpoint ? `/share/${shareId}/socket/record/snapshot-bulk` : `/table/${tableId}/record/socket/snapshot-bulk`; + // Use POST body: hundreds of record ids plus a wide projection in GET + // query params can exceed the HTTP header size limit (431) return this.axios - .get(url, { - headers: { - cookie: this.cls.get('cookie'), - [IS_TEMPLATE_HEADER]: templateHeader, - [BASE_SHARE_ID_HEADER]: baseShareId, - }, - params: { + .post( + url, + { ids: recordIds, projection, }, - }) + { + headers: { + cookie: this.cls.get('cookie'), + [IS_TEMPLATE_HEADER]: templateHeader, + [BASE_SHARE_ID_HEADER]: baseShareId, + }, + } + ) .then((res) => res.data); } diff --git a/apps/nestjs-backend/src/share-db/share-db.service.ts b/apps/nestjs-backend/src/share-db/share-db.service.ts index a6b959cc3b..b6cc5a1d53 100644 --- a/apps/nestjs-backend/src/share-db/share-db.service.ts +++ b/apps/nestjs-backend/src/share-db/share-db.service.ts @@ -1,5 +1,4 @@ import { Injectable, Logger, Optional } from '@nestjs/common'; -import { context as otelContext, trace as otelTrace } from '@opentelemetry/api'; import { FieldOpBuilder, IdPrefix } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import { noop } from 'lodash'; @@ -196,36 +195,31 @@ export class ShareDbService extends ShareDBClass { context: ShareDBClass.middleware.SubmitContext, next: (err?: unknown) => void ) => { - const tracer = otelTrace.getTracer('default'); - const currentSpan = tracer.startSpan('submitOp'); - - otelContext.with(otelTrace.setSpan(otelContext.active(), currentSpan), () => { - const submitSource = - ((context as ShareDBClass.middleware.SubmitContext & { options?: { source?: unknown } }) - .options?.source as unknown) ?? - ((context as ShareDBClass.middleware.SubmitContext & { extra?: { source?: unknown } }).extra - ?.source as unknown); - if (submitSource === v2ProjectionSubmitSource) { - return next(); - } + const submitSource = + ((context as ShareDBClass.middleware.SubmitContext & { options?: { source?: unknown } }) + .options?.source as unknown) ?? + ((context as ShareDBClass.middleware.SubmitContext & { extra?: { source?: unknown } }).extra + ?.source as unknown); + if (submitSource === v2ProjectionSubmitSource) { + return next(); + } - const opSource = typeof context.op.src === 'string' ? context.op.src : ''; - if (opSource.startsWith(v2ProjectionOpSourcePrefix)) { - return next(); - } + const opSource = typeof context.op.src === 'string' ? context.op.src : ''; + if (opSource.startsWith(v2ProjectionOpSourcePrefix)) { + return next(); + } - if (!hasClientStream(context.agent)) { - return next(); - } + if (!hasClientStream(context.agent)) { + return next(); + } - const [docType] = context.collection.split('_'); + const [docType] = context.collection.split('_'); - if (docType !== IdPrefix.Record || !context.op.op) { - this.realtimeMetrics?.recordOperationError('invalid_doc_type'); - return next(new Error('only record op can be committed')); - } - this.realtimeMetrics?.recordOperationSubmit(); - next(); - }); + if (docType !== IdPrefix.Record || !context.op.op) { + this.realtimeMetrics?.recordOperationError('invalid_doc_type'); + return next(new Error('only record op can be committed')); + } + this.realtimeMetrics?.recordOperationSubmit(); + next(); }; } diff --git a/apps/nestjs-backend/src/tracing-span-export.spec.ts b/apps/nestjs-backend/src/tracing-span-export.spec.ts index fc53a456d1..03b025217f 100644 --- a/apps/nestjs-backend/src/tracing-span-export.spec.ts +++ b/apps/nestjs-backend/src/tracing-span-export.spec.ts @@ -19,6 +19,7 @@ import { PER_TRACE_CAP, SETTLED_LINGER_MS, TOMBSTONE_CAP, + TRACE_EXPORT_SPAN_CAP, } from './tracing-span-export'; type ExportCallback = Parameters[1]; @@ -114,6 +115,7 @@ const DEFAULT_OPTIONS = { scheduledDelayMillis: 5000, priorityScheduledDelayMillis: 1000, exportTimeoutMillis: 30_000, + maxExportedSpansPerTrace: TRACE_EXPORT_SPAN_CAP, }; const createProcessor = (overrides: Partial = {}) => { @@ -175,12 +177,20 @@ describe('span predicates', () => { it('recognizes priority spans', () => { expect(isPriorityTraceSpan(makeSpan({ kind: SpanKind.SERVER }))).toBe(true); - expect(isPriorityTraceSpan(makeSpan({ attributes: { 'http.route': '/api/x' } }))).toBe(true); expect( - isPriorityTraceSpan(makeSpan({ attributes: { 'nest.controller': 'A', 'nest.handler': 'b' } })) + isPriorityTraceSpan(makeSpan({ attributes: { 'teable.route.full': 'GET /api/x' } })) ).toBe(true); expect(isPriorityTraceSpan(makeSpan({}))).toBe(false); }); + + it('does not promote the nest handler span that mirrors the SERVER span', () => { + // NestInstrumentation sets http.route + the interceptor used to add nest.*; both + // made a second always-exported copy of every request. + expect(isPriorityTraceSpan(makeSpan({ attributes: { 'http.route': '/api/x' } }))).toBe(false); + expect( + isPriorityTraceSpan(makeSpan({ attributes: { 'nest.controller': 'A', 'nest.handler': 'b' } })) + ).toBe(false); + }); }); describe('createSmartSpanProcessor', () => { @@ -196,7 +206,11 @@ describe('createSmartSpanProcessor', () => { runSpan(processor, makeSpan({ traceId: SAMPLED_TRACE_ID, name: 'db-call' })); runSpan( processor, - makeSpan({ traceId: SAMPLED_TRACE_ID, name: 'route', attributes: { 'http.route': '/x' } }) + makeSpan({ + traceId: SAMPLED_TRACE_ID, + name: 'route', + attributes: { 'teable.route.full': 'GET /x' }, + }) ); processor.onEnd(root); await processor.forceFlush(); @@ -308,7 +322,10 @@ describe('createSmartSpanProcessor', () => { const errorChild = makeSpan({ name: 'error-child', statusCode: SpanStatusCode.ERROR }); startSpan(processor, errorChild); runSpan(processor, makeSpan({ name: 'buffered' })); - runSpan(processor, makeSpan({ name: 'handler', attributes: { 'http.route': '/api/chat' } })); + runSpan( + processor, + makeSpan({ name: 'handler', attributes: { 'teable.route.full': 'GET /api/chat' } }) + ); processor.onEnd(errorChild); await processor.forceFlush(); expect(names(priorityExporter)).toEqual(['handler']); @@ -567,6 +584,59 @@ describe('createSmartSpanProcessor', () => { expect(names(batchExporter)).toContain('follow-recent'); }); + it('truncates a runaway trace past the per-trace export cap', async () => { + const { processor, batchExporter, priorityExporter } = createProcessor({ + maxExportedSpansPerTrace: 3, + }); + for (let i = 0; i < 6; i++) { + runSpan(processor, makeSpan({ traceId: SAMPLED_TRACE_ID, name: `detail-${i}` })); + } + // priority and error spans stay exempt so APM stats and failures survive + runSpan( + processor, + makeSpan({ + traceId: SAMPLED_TRACE_ID, + name: 'route', + attributes: { 'teable.route.full': 'GET /x' }, + }) + ); + runSpan( + processor, + makeSpan({ traceId: SAMPLED_TRACE_ID, name: 'boom', statusCode: SpanStatusCode.ERROR }) + ); + await processor.forceFlush(); + expect(names(batchExporter)).toEqual(['detail-0', 'detail-1', 'detail-2', 'boom']); + expect(names(priorityExporter)).toEqual(['route']); + }); + + it('keeps counting across settled gaps, so a leaked trace cannot reset the cap', async () => { + const { processor, batchExporter } = createProcessor({ maxExportedSpansPerTrace: 2 }); + // each span opens and closes alone: the trace settles between every one + runSpan(processor, makeSpan({ traceId: SAMPLED_TRACE_ID, name: 'a' })); + runSpan(processor, makeSpan({ traceId: SAMPLED_TRACE_ID, name: 'b' })); + runSpan(processor, makeSpan({ traceId: SAMPLED_TRACE_ID, name: 'dropped' })); + await processor.forceFlush(); + expect(names(batchExporter)).toEqual(['a', 'b']); + }); + + it('reclaims the export tally once a quiet trace is swept', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const start = Date.now(); + const [runaway, other] = findTraceIds(2, true); + const { processor, batchExporter } = createProcessor({ maxExportedSpansPerTrace: 2 }); + runSpan(processor, makeSpan({ traceId: runaway, name: 'a' })); + runSpan(processor, makeSpan({ traceId: runaway, name: 'b' })); + runSpan(processor, makeSpan({ traceId: runaway, name: 'dropped' })); + + // silence past the exported TTL, then unrelated traffic drives the sweep + vi.setSystemTime(start + EXPORTED_TTL_MS + 60_000); + runSpan(processor, makeSpan({ traceId: other, name: 'other' })); + + runSpan(processor, makeSpan({ traceId: runaway, name: 'after-sweep' })); + await processor.forceFlush(); + expect(names(batchExporter)).toEqual(['a', 'b', 'other', 'after-sweep']); + }); + it('drains the pending buffer on shutdown', async () => { const { processor, batchExporter } = createProcessor(); const root = makeSpan({ name: 'root', kind: SpanKind.SERVER, parent: 'none' }); diff --git a/apps/nestjs-backend/src/tracing-span-export.ts b/apps/nestjs-backend/src/tracing-span-export.ts index ce615517ae..feb53fa5b1 100644 --- a/apps/nestjs-backend/src/tracing-span-export.ts +++ b/apps/nestjs-backend/src/tracing-span-export.ts @@ -10,6 +10,12 @@ * - other traces export only priority spans (SERVER/route/handler, keeps APM * stats accurate); the rest are buffered and discarded once the trace's * live-span refcount drops to zero without a promotion + * - a trace that has already shipped maxExportedSpansPerTrace detail spans is + * truncated: further detail spans are dropped, priority and error spans + * still go out. A context leak (a long-lived listener that keeps the + * bootstrap context, or a span that is never ended) otherwise funnels a + * pod's whole lifetime into one trace, which no sampling ratio can bound. + * Full-export mode (ratio >= 1.0) keeps no per-trace state and is uncapped. * * Refcounting (onStart/onEnd) instead of watching for a parentless root span * handles remote-parent entry spans and post-response async work uniformly. @@ -17,6 +23,7 @@ * shapes and memory bounds. Priority spans batch on a short delay; shutdown * drains undecided buffers (they belong to interrupted requests). */ +import { Logger } from '@nestjs/common'; import { SpanKind, SpanStatusCode } from '@opentelemetry/api'; import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; import type { ReadableSpan, SpanExporter, SpanProcessor } from '@opentelemetry/sdk-trace-base'; @@ -29,8 +36,11 @@ export const LIVE_TTL_MS = 30 * 60 * 1000; export const EXPORTED_TTL_MS = 10 * 60 * 1000; export const SETTLED_LINGER_MS = 10_000; export const TOMBSTONE_CAP = 10_000; +export const TRACE_EXPORT_SPAN_CAP = 10_000; const CLEANUP_INTERVAL_MS = 30_000; +const truncationLogger = new Logger('SmartSpanExport'); + export const hashTraceId = (traceId: string): number => { // FNV-1a hash for better distribution let hash = 2166136261; @@ -56,16 +66,13 @@ const PRISMA_SPINE_SPANS = new Set([ export const isDroppedPrismaSpan = (span: ReadableSpan): boolean => span.name.startsWith('prisma:') && !PRISMA_SPINE_SPANS.has(span.name); -export const isPriorityTraceSpan = (span: ReadableSpan): boolean => { - const attributes = span.attributes; - return ( - span.kind === SpanKind.SERVER || - typeof attributes['teable.route.full'] === 'string' || - typeof attributes['http.route'] === 'string' || - (typeof attributes['nest.controller'] === 'string' && - typeof attributes['nest.handler'] === 'string') - ); -}; +// `http.route` and `nest.*` are deliberately not triggers. NestInstrumentation set +// `http.route` on its controller-handler span, which mirrored the request's SERVER span, +// so honouring it exported both copies of every request. That instrumentation is disabled +// now (see tracing.ts); keeping the predicate narrow means re-enabling it cannot quietly +// double the export volume again. +export const isPriorityTraceSpan = (span: ReadableSpan): boolean => + span.kind === SpanKind.SERVER || typeof span.attributes['teable.route.full'] === 'string'; export const isErrorSpan = (span: ReadableSpan): boolean => { if (span.status.code === SpanStatusCode.ERROR) return true; @@ -82,6 +89,8 @@ export interface ISmartSpanProcessorOptions { /** Short delay for priority spans; APM stats lag by at most this much. */ priorityScheduledDelayMillis: number; exportTimeoutMillis: number; + /** Detail spans one trace may export before it is truncated as runaway. */ + maxExportedSpansPerTrace?: number; } interface ITraceState { @@ -90,6 +99,8 @@ interface ITraceState { promotedUntilMs: number; settledAtMs: number; lastTouchedMs: number; + exportedSpans: number; + truncationLogged: boolean; } /** @@ -104,6 +115,9 @@ interface ITraceState { * PENDING_TTL_MS * - tombstone (promoted and settled): kept until promotedUntilMs so late * spans keep exporting; capped at TOMBSTONE_CAP + * - counting (has exported detail spans): kept for EXPORTED_TTL_MS past its + * last export so the per-trace export cap survives the gaps between spans; + * also capped at TOMBSTONE_CAP * * Buffers are capped per trace (PER_TRACE_CAP) and globally (GLOBAL_CAP). * `bufferHolders`/`settledHolders` are insertion-ordered views used only to @@ -119,12 +133,30 @@ class TraceStore { getOrCreate(traceId: string, nowMs: number): ITraceState { let trace = this.traces.get(traceId); if (!trace) { - trace = { liveSpans: 0, spans: [], promotedUntilMs: 0, settledAtMs: 0, lastTouchedMs: nowMs }; + trace = { + liveSpans: 0, + spans: [], + promotedUntilMs: 0, + settledAtMs: 0, + lastTouchedMs: nowMs, + exportedSpans: 0, + truncationLogged: false, + }; this.traces.set(traceId, trace); } return trace; } + /** A trace keeps its export tally alive between spans, so the cap is not reset. */ + isCounting(trace: ITraceState, nowMs: number): boolean { + return trace.exportedSpans > 0 && nowMs - trace.lastTouchedMs <= EXPORTED_TTL_MS; + } + + countExport(trace: ITraceState, nowMs: number): void { + trace.exportedSpans++; + trace.lastTouchedMs = nowMs; + } + /** Empties a trace's buffer and returns the spans. The only index-removal point. */ drainBuffer(traceId: string, trace: ITraceState): ReadableSpan[] { const spans = trace.spans; @@ -136,7 +168,12 @@ class TraceStore { } removeIfInert(traceId: string, trace: ITraceState, nowMs: number): void { - if (trace.liveSpans === 0 && trace.spans.length === 0 && trace.promotedUntilMs <= nowMs) { + if ( + trace.liveSpans === 0 && + trace.spans.length === 0 && + trace.promotedUntilMs <= nowMs && + !this.isCounting(trace, nowMs) + ) { this.traces.delete(traceId); } } @@ -235,19 +272,22 @@ class TraceStore { } return false; } - if (trace.promotedUntilMs <= nowMs) { - this.traces.delete(traceId); - return false; - } - return true; + if (trace.promotedUntilMs > nowMs || this.isCounting(trace, nowMs)) return true; + this.traces.delete(traceId); + return false; } // Evict oldest surplus tombstones; their late spans just fall back to - // the hash decision instead of following the promotion. + // the hash decision instead of following the promotion, and a truncated + // trace gets a fresh export tally. private evictTombstones(excess: number, nowMs: number): void { for (const [traceId, trace] of this.traces) { if (excess === 0) break; - if (trace.liveSpans === 0 && trace.spans.length === 0 && trace.promotedUntilMs > nowMs) { + if ( + trace.liveSpans === 0 && + trace.spans.length === 0 && + (trace.promotedUntilMs > nowMs || this.isCounting(trace, nowMs)) + ) { this.traces.delete(traceId); excess--; } @@ -274,6 +314,10 @@ export const createSmartSpanProcessor = ( options: ISmartSpanProcessorOptions ): SpanProcessor => { const { exportRatio } = options; + const maxExportedSpansPerTrace = Math.max( + 1, + options.maxExportedSpansPerTrace ?? TRACE_EXPORT_SPAN_CAP + ); const batchProcessor = new BatchSpanProcessor(batchExporter, { maxQueueSize: options.maxQueueSize, maxExportBatchSize: options.maxExportBatchSize, @@ -338,30 +382,58 @@ export const createSmartSpanProcessor = ( const cleanupTimer = setInterval(() => cleanup(Date.now()), CLEANUP_INTERVAL_MS); cleanupTimer.unref?.(); + // Priority spans are the APM baseline and error spans are the scarcest + // detail, so neither is truncated; only ordinary detail spans are counted + // against the cap that bounds a runaway trace. + const routeCounted = ( + span: ReadableSpan, + trace: ITraceState, + traceId: string, + nowMs: number + ): void => { + if (isPriorityTraceSpan(span)) { + priorityProcessor.onEnd(span); + return; + } + if (!isErrorSpan(span) && trace.exportedSpans >= maxExportedSpansPerTrace) { + if (!trace.truncationLogged) { + trace.truncationLogged = true; + truncationLogger.warn( + `Truncating trace ${traceId}: over ${maxExportedSpansPerTrace} exported spans ` + + `(latest "${span.name}"). A long-lived listener is holding the bootstrap ` + + `context, or a span parenting this work was never ended.` + ); + } + return; + } + store.countExport(trace, nowMs); + batchProcessor.onEnd(span); + }; + const promote = (traceId: string, trace: ITraceState, nowMs: number): void => { trace.promotedUntilMs = nowMs + EXPORTED_TTL_MS; for (const buffered of store.drainBuffer(traceId, trace)) { - batchProcessor.onEnd(buffered); + routeCounted(buffered, trace, traceId, nowMs); } }; const decide = (span: ReadableSpan, trace: ITraceState, traceId: string, nowMs: number): void => { if (isErrorSpan(span)) { promote(traceId, trace, nowMs); - route(span); + routeCounted(span, trace, traceId, nowMs); return; } if (isDroppedPrismaSpan(span)) return; if (trace.promotedUntilMs > nowMs) { - route(span); + routeCounted(span, trace, traceId, nowMs); return; } // Deterministic per traceId, so picked traces need no stored state. if (getTraceDecision(traceId, exportRatio)) { - route(span); + routeCounted(span, trace, traceId, nowMs); return; } diff --git a/apps/nestjs-backend/src/tracing.ts b/apps/nestjs-backend/src/tracing.ts index fee52ef090..c8f4594d2f 100644 --- a/apps/nestjs-backend/src/tracing.ts +++ b/apps/nestjs-backend/src/tracing.ts @@ -33,6 +33,8 @@ * - Smart export always sends errors and HTTP 5xx responses (regardless of ratio) and promotes * their whole trace so it arrives complete; everything else follows the trace-level * OTEL_EXPORT_RATIO (see tracing-span-export.ts) + * - Any single trace is truncated past TRACE_EXPORT_SPAN_CAP detail spans, so a leaked + * context cannot funnel a pod's whole lifetime into one unbounded trace */ import { Logger } from '@nestjs/common'; import { metrics, SpanKind } from '@opentelemetry/api'; @@ -43,7 +45,6 @@ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { ExpressInstrumentation, ExpressLayerType } from '@opentelemetry/instrumentation-express'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis'; -import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core'; import { PgInstrumentation } from '@opentelemetry/instrumentation-pg'; import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino'; import { RuntimeNodeInstrumentation } from '@opentelemetry/instrumentation-runtime-node'; @@ -246,7 +247,9 @@ const httpClientActiveRequestsProcessor: SpanProcessor = { const teableDbSpanAttributeProcessor: SpanProcessor = { onStart(span): void { const attributes = (span as unknown as { attributes?: Record }).attributes; - const dbSystem = attributes?.['db.system']; + // instrumentation-pg >=0.73 emits stable semconv (db.system.name); older + // pods in a rolling deploy still emit db.system, so accept both. + const dbSystem = attributes?.['db.system.name'] ?? attributes?.['db.system']; if (dbSystem !== 'postgresql' && dbSystem !== 'postgres') { return; } @@ -333,16 +336,20 @@ const metricViews: opentelemetry.metrics.ViewOptions[] = [ // Reduce high-cardinality auto-instrumented histograms from 16 → 6 series per label set. // Boundaries are in seconds: 1ms=cached, 5ms=indexed, 25ms=scan, 100ms=slow, 1s=very-slow. // Keep only operation name + system; drop db.namespace, server.address/port, error.type. + // db.system (old semconv) kept alongside db.system.name so mixed fleets during + // a rolling deploy don't lose the dimension. { instrumentName: 'db.client.operation.duration', aggregation: buckets([0.001, 0.005, 0.025, 0.1, 1]), - attributesProcessors: [createAllowListAttributesProcessor(['db.operation.name', 'db.system'])], + attributesProcessors: [ + createAllowListAttributesProcessor(['db.operation.name', 'db.system', 'db.system.name']), + ], }, ]; const otelSDK = new opentelemetry.NodeSDK({ spanProcessors, - logRecordProcessors: logExporter ? [new BatchLogRecordProcessor(logExporter)] : [], + logRecordProcessors: logExporter ? [new BatchLogRecordProcessor({ exporter: logExporter })] : [], sampler: new AlwaysOnSampler(), contextManager: SentryContextManager ? new SentryContextManager() : undefined, textMapPropagator: undefined, @@ -360,7 +367,14 @@ const otelSDK = new opentelemetry.NodeSDK({ new ExpressInstrumentation({ ignoreLayersType: [ExpressLayerType.MIDDLEWARE, ExpressLayerType.REQUEST_HANDLER], }), - new NestInstrumentation(), + // NestInstrumentation is deliberately absent. Its controller-handler span wrapped + // the same work as the HTTP SERVER span (2ms apart) and RouteTracingInterceptor + // renamed it to the route, so every request shipped two near-identical spans that + // both bypassed sampling — a quarter of all exported spans. Its `Create Nest App` + // span was also the bootstrap context that long-lived listeners leaked into. + // What it uniquely provided is replaced: the route/controller/handler attributes by + // RouteTracingInterceptor, the exception recording by GlobalExceptionFilter. + // new NestInstrumentation(), new PrismaInstrumentation(), new PgInstrumentation({ enhancedDatabaseReporting: true, // Records SQL; ensure sensitive data is scrubbed. diff --git a/apps/nestjs-backend/src/tracing/route-tracing.interceptor.ts b/apps/nestjs-backend/src/tracing/route-tracing.interceptor.ts index 84dc625a9e..6a957eb78b 100644 --- a/apps/nestjs-backend/src/tracing/route-tracing.interceptor.ts +++ b/apps/nestjs-backend/src/tracing/route-tracing.interceptor.ts @@ -29,6 +29,9 @@ export class RouteTracingInterceptor implements NestInterceptor { const request = context.switchToHttp().getRequest(); const response = context.switchToHttp().getResponse(); + // NestInstrumentation is disabled (see tracing.ts), so this is the request's SERVER + // span. While it was enabled the active span was Nest's controller-handler span, and + // stamping that one gave every request a second, nearly identical always-exported span. const span = trace.getActiveSpan(); if (span) { diff --git a/apps/nestjs-backend/src/types/cls.ts b/apps/nestjs-backend/src/types/cls.ts index edd2fd5394..20a966911a 100644 --- a/apps/nestjs-backend/src/types/cls.ts +++ b/apps/nestjs-backend/src/types/cls.ts @@ -1,7 +1,10 @@ import type { Action, IFieldVo } from '@teable/core'; import type { Prisma } from '@teable/db-main-prisma'; import type { V2Feature } from '@teable/openapi'; -import type { ExecutionContextBackgroundTaskScheduler } from '@teable/v2-core'; +import type { + ExecutionContextBackgroundTaskScheduler, + IRecordRemovalReason, +} from '@teable/v2-core'; import type { ClsStore } from 'nestjs-cls'; import type { IAuditOperation } from '../features/audit/audit-scope'; import type { IWorkflowContext } from '../features/auth/strategies/types'; @@ -115,6 +118,7 @@ export interface IClsStore extends ClsStore { v2Reason?: IV2Reason; // Reason why V2 was enabled or disabled v2Feature?: V2Feature; // The feature name that triggered V2 check windowId?: string; // Window ID from x-window-id header for undo/redo tracking + recordRemovalReason?: IRecordRemovalReason; // set by the archive flow; flows into op events // cache for base share node tree (to avoid repeated queries within same request) baseShareNodeCache?: Map< string, diff --git a/apps/nestjs-backend/src/types/i18n.generated.ts b/apps/nestjs-backend/src/types/i18n.generated.ts index 860aca0cb2..65f5e9cad0 100644 --- a/apps/nestjs-backend/src/types/i18n.generated.ts +++ b/apps/nestjs-backend/src/types/i18n.generated.ts @@ -247,9 +247,6 @@ export type I18nTranslations = { "refresh": string; "login": string; "useTemplate": string; - "copyToMySpace": string; - "saveToMySpace": string; - "supportSaveCopy": string; "backToSpace": string; "switchBase": string; "getMore": string; @@ -310,6 +307,7 @@ export type I18nTranslations = { "baseShare": { "shareTitle": string; "shareToWeb": string; + "noPermissionTip": string; "linkHolderLabel": string; "linkHolderCanView": string; "linkHolderCanViewDesc": string; @@ -708,8 +706,6 @@ export type I18nTranslations = { "linkCreatedTime": string; "linkCopySuccess": string; "linkRemove": string; - "desc_billable_one": string; - "desc_billable_other": string; "spaceTitleWithCount": string; "baseTitle": string; "allCollaboratorsTitle": string; @@ -769,6 +765,20 @@ export type I18nTranslations = { "viewPricing": string; "billable": string; "billableByAuthorityMatrix": string; + "seatConfirm": { + "title": string; + "roleChangeTitle": string; + "matrixTitle": string; + "inviteDesc_one": string; + "inviteDesc_other": string; + "linkDesc": string; + "roleChangeDesc": string; + "matrixDesc": string; + "seatLimitTitle": string; + "seatLimitDesc": string; + "seatLimitConfirm": string; + "confirmInvite": string; + }; "licenseExpiredGracePeriod": string; "licenseAutoFetchFailed": string; "licenseAutoFetchRetryFailed": string; @@ -1841,12 +1851,6 @@ export type I18nTranslations = { "copyError": string; }; }; - "changelog": { - "newUpdate": string; - "title": string; - "url": string; - "id": string; - }; "resourceDescription": { "addDescription": string; "nodeDescription": string; @@ -1854,6 +1858,16 @@ export type I18nTranslations = { "descriptionSaveFailed": string; "descriptionPlaceholder": string; }; + "announcement": { + "viewDetail": string; + "close": string; + "acknowledge": string; + "collapse": string; + "more_one": string; + "more_other": string; + "more_few": string; + "more_many": string; + }; "noPermissionToCreateBase": string; "chat": { "responseInterrupted": string; @@ -2158,6 +2172,8 @@ export type I18nTranslations = { "preview": { "previewFileLimit": string; "loadFileError": string; + "previousAttachment": string; + "nextAttachment": string; }; "undoRedo": { "undo": string; @@ -2386,6 +2402,8 @@ export type I18nTranslations = { }; "expandRecord": { "copy": string; + "previousRecord": string; + "nextRecord": string; "duplicateRecord": string; "copyRecordUrl": string; "deleteRecord": string; @@ -2515,6 +2533,8 @@ export type I18nTranslations = { "tableTrashRead": string; "tableTrashUpdate": string; "tableTrashReset": string; + "tableArchiveRead": string; + "tableArchiveManage": string; "viewCreate": string; "viewDelete": string; "viewRead": string; @@ -2530,6 +2550,7 @@ export type I18nTranslations = { "recordRead": string; "recordUpdate": string; "recordCopy": string; + "recordArchive": string; "automationCreate": string; "automationDelete": string; "automationRead": string; @@ -3063,15 +3084,18 @@ export type I18nTranslations = { "nameMaxLength": string; "descriptionMaxLength": string; }; - "validation": { - "field": { - "unique": string; - }; - }; "custom": { "fieldValueNotNull": string; "fieldValueDuplicate": string; + "recordFieldValueNotNull": string; + "recordFieldValueDuplicate": string; "linkFieldValueDuplicate": string; + "linkBatchDuplicate": string; + "linkOneManyDuplicate": string; + "linkOneOneDuplicate": string; + "fieldMaxColumnLimit": string; + "fieldRequiredExistingValues": string; + "fieldUniqueExistingValues": string; "requestTimeout": string; "searchTimeOut": string; "dependencyNodeRequire": string; @@ -4190,17 +4214,6 @@ export type I18nTranslations = { "help": string; "helpCenter": string; }; - "validation": { - "link": { - "batch_duplicate": string; - "one_many_duplicate": string; - "one_one_duplicate": string; - }; - "field": { - "maxColumnLimit": string; - "requiredExistingValues": string; - }; - }; "field": { "advancedProps": string; "hide": string; @@ -4603,6 +4616,11 @@ export type I18nTranslations = { "fillFailed": string; "clearing": string; "clearSuccessful": string; + "archiveRecordConfirmTitle": string; + "archiveRecordConfirmDescription": string; + "archiveRecord": string; + "archiving": string; + "archiveSuccessful": string; "deleting": string; "deleteSuccessful": string; "deleteStream": { @@ -5168,6 +5186,8 @@ export type I18nTranslations = { "insertRecordBelow": string; "deleteRecord": string; "deleteAllSelectedRecords": string; + "archiveRecord": string; + "archiveAllSelectedRecords": string; "editField": string; "insertFieldLeft": string; "insertFieldRight": string; @@ -5238,11 +5258,49 @@ export type I18nTranslations = { "title": string; "description": string; }; + "tableArchive": { + "title": string; + "menuTitle": string; + "archivedTime": string; + "archivedBy": string; + "recordDetail": string; + "empty": string; + "allCreators": string; + "filterArchivedTime": string; + "searchPlaceholder": string; + "clearFilter": string; + "export": string; + "exporting": string; + "exportSucceed": string; + "restoreSelected": string; + "permanentDeleteSelected": string; + "permanentDeleteConfirm": string; + "permanentDeleteSucceed": string; + "resetArchive": string; + "resetArchiveConfirm": string; + "resetSucceed": string; + "orderBy": { + "archivedTime": string; + "recordCreatedTime": string; + "recordLastModifiedTime": string; + }; + }; "tableTrash": { "title": string; "resourceType": string; "deletedResource": string; "moreResources": string; + "deletedTime": string; + "deletedBy": string; + "filterAllTypes": string; + "filterAllUsers": string; + "filterDeletedTime": string; + "clearFilter": string; + "recordsDialogTitle": string; + "recordDetail": string; + "filterAllCreators": string; + "filterCreatedTime": string; + "searchPlaceholder": string; }; "baseShare": { "shareTitle": string; @@ -5272,6 +5330,9 @@ export type I18nTranslations = { "linkScopeDialogTitle": string; "linkHolderCanCopyAndSave": string; "linkHolderCanCopyAndSaveDesc": string; + "copyToMySpace": string; + "saveToMySpace": string; + "supportSaveCopy": string; "editRequiresLogin": string; "enterPassword": string; "allowCopyData": string; diff --git a/apps/nestjs-backend/src/types/permessage-deflate.d.ts b/apps/nestjs-backend/src/types/permessage-deflate.d.ts new file mode 100644 index 0000000000..666a6e565d --- /dev/null +++ b/apps/nestjs-backend/src/types/permessage-deflate.d.ts @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +declare module 'permessage-deflate' { + /** + * RFC 7692 permessage-deflate extension for websocket-driver, as consumed by + * faye-websocket (and therefore sockjs `faye_server_options.extensions`). + * + * Option names mirror the RFC's server_/client_ parameter split: the bare + * options constrain this endpoint's own deflater, the `request*` options ask + * the peer to constrain theirs. + */ + export interface IPermessageDeflateOptions { + /** zlib compression level, 0-9. */ + level?: number; + /** zlib memLevel, 1-9. */ + memLevel?: number; + /** zlib strategy. */ + strategy?: number; + /** Reset our deflate context between messages (`server_no_context_takeover`). */ + noContextTakeover?: boolean; + /** Cap our own deflate window, 8-15 (`server_max_window_bits`). */ + maxWindowBits?: number; + /** Ask the peer to reset its context (`client_no_context_takeover`). */ + requestNoContextTakeover?: boolean; + /** + * Ask the peer to cap its deflate window, 8-15 (`client_max_window_bits`). + * This is what sizes our inflater. + */ + requestMaxWindowBits?: number; + /** zlib implementation override; used by the package's own tests. */ + zlib?: unknown; + } + + /** A websocket frame as websocket-extensions hands it down the pipeline. */ + export interface IPermessageDeflateMessage { + data: Buffer; + rsv1: boolean; + } + + export interface IPermessageDeflateSession { + /** Negotiated response params, serialized into Sec-WebSocket-Extensions. */ + generateResponse(): Record; + processOutgoingMessage( + message: IPermessageDeflateMessage, + callback: (error: Error | null, message: IPermessageDeflateMessage) => void + ): void; + processIncomingMessage( + message: IPermessageDeflateMessage, + callback: (error: Error | null, message: IPermessageDeflateMessage) => void + ): void; + close(): void; + } + + export interface IPermessageDeflateExtension { + readonly name: 'permessage-deflate'; + readonly type: 'permessage'; + readonly rsv1: boolean; + readonly rsv2: boolean; + readonly rsv3: boolean; + configure(options: IPermessageDeflateOptions): IPermessageDeflateExtension; + /** Returns null when none of the peer's offers are usable. */ + createServerSession(offers: Array>): IPermessageDeflateSession | null; + } + + const deflate: IPermessageDeflateExtension; + export default deflate; +} diff --git a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/map-with-concurrency.spec.ts b/apps/nestjs-backend/src/utils/map-with-concurrency.spec.ts similarity index 100% rename from apps/nestjs-backend/src/features/v2/computed-outbox-trigger/map-with-concurrency.spec.ts rename to apps/nestjs-backend/src/utils/map-with-concurrency.spec.ts diff --git a/apps/nestjs-backend/src/features/v2/computed-outbox-trigger/map-with-concurrency.ts b/apps/nestjs-backend/src/utils/map-with-concurrency.ts similarity index 100% rename from apps/nestjs-backend/src/features/v2/computed-outbox-trigger/map-with-concurrency.ts rename to apps/nestjs-backend/src/utils/map-with-concurrency.ts diff --git a/apps/nestjs-backend/src/utils/sse-stream.ts b/apps/nestjs-backend/src/utils/sse-stream.ts new file mode 100644 index 0000000000..d33e80a2e0 --- /dev/null +++ b/apps/nestjs-backend/src/utils/sse-stream.ts @@ -0,0 +1,56 @@ +import type { Response } from 'express'; + +type IFlushableResponse = Response & { flush?: () => void }; + +const HEARTBEAT_INTERVAL_MS = 15_000; + +export const isSseStreamClosed = (response: Response) => + response.writableEnded || response.destroyed; + +export const sendSseEvent = (response: Response, data: unknown) => { + if (isSseStreamClosed(response)) { + return; + } + + response.write(`data: ${JSON.stringify(data)}\n\n`); + (response as IFlushableResponse).flush?.(); +}; + +// Writes an AsyncIterable of events to the response as an SSE stream: sets the SSE +// headers, keeps proxies from timing out the connection with comment heartbeats, and +// maps a thrown error to one final event produced by buildErrorEvent. +export const streamSseResponse = async ( + response: Response, + stream: AsyncIterable, + buildErrorEvent: (error: unknown) => T +): Promise => { + 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'); + response.flushHeaders(); + + const heartbeat = setInterval(() => { + if (isSseStreamClosed(response)) { + return; + } + + response.write(': ping\n\n'); + (response as IFlushableResponse).flush?.(); + }, HEARTBEAT_INTERVAL_MS); + response.on('close', () => clearInterval(heartbeat)); + + try { + for await (const event of stream) { + if (isSseStreamClosed(response)) { + break; + } + sendSseEvent(response, event); + } + } catch (error) { + sendSseEvent(response, buildErrorEvent(error)); + } finally { + clearInterval(heartbeat); + response.end(); + } +}; diff --git a/apps/nestjs-backend/src/ws/instrumented-deflate.spec.ts b/apps/nestjs-backend/src/ws/instrumented-deflate.spec.ts new file mode 100644 index 0000000000..533e917729 --- /dev/null +++ b/apps/nestjs-backend/src/ws/instrumented-deflate.spec.ts @@ -0,0 +1,90 @@ +import deflate from 'permessage-deflate'; +import type { IPermessageDeflateMessage, IPermessageDeflateSession } from 'permessage-deflate'; +import { describe, it, expect } from 'vitest'; +import { getCompressionSnapshot } from '../share-db/metrics/compression-metrics'; +import { instrumentDeflate } from './instrumented-deflate'; + +/** + * Counters are module-level (same reason as query-poll-skip-metrics: the deflate + * extension is not a Nest provider), so every assertion here is on a delta + * rather than an absolute — no test-only reset hook on the production module. + */ +const delta = (before: ReturnType) => { + const after = getCompressionSnapshot(); + return { + frames: after.outbound.frames - before.outbound.frames, + uncompressed: after.outbound.uncompressedBytes - before.outbound.uncompressedBytes, + compressed: after.outbound.compressedBytes - before.outbound.compressedBytes, + sessions: after.sessionsCreated - before.sessionsCreated, + active: after.sessionsActive - before.sessionsActive, + }; +}; + +const sendThrough = (session: IPermessageDeflateSession, message: IPermessageDeflateMessage) => + new Promise((resolve, reject) => + session.processOutgoingMessage(message, (error) => (error ? reject(error) : resolve())) + ); + +const openSession = () => { + const extension = instrumentDeflate( + deflate.configure({ level: 3, maxWindowBits: 13, memLevel: 6, requestMaxWindowBits: 13 }) + ); + const session = extension.createServerSession([{ client_max_window_bits: true }]); + if (!session) throw new Error('expected the browser-shaped offer to be accepted'); + session.generateResponse(); + return session; +}; + +describe('instrumentDeflate', () => { + it('accounts for the bytes a frame saved, so the ratio is observable in production', async () => { + const payload = JSON.stringify( + Array.from({ length: 400 }, (_, i) => ({ fldTitleAaBbCcDd: `Customer account ${i}` })) + ); + const before = getCompressionSnapshot(); + + const session = openSession(); + await sendThrough(session, { data: Buffer.from(payload, 'utf8'), rsv1: false }); + session.close(); + + const d = delta(before); + expect(d.sessions).toBe(1); + expect(d.frames).toBe(1); + expect(d.active).toBe(0); // opened and closed within the test + expect(d.uncompressed).toBe(Buffer.byteLength(payload)); + expect(d.compressed).toBeGreaterThan(0); + expect(d.compressed).toBeLessThan(Buffer.byteLength(payload) / 5); + }); + + it('leaves the compressed payload intact for the driver to frame', async () => { + // The wrapper must hand websocket-driver the same message object the real + // session produced, rsv1 flag and all, or every frame goes out malformed. + const session = openSession(); + const message: IPermessageDeflateMessage = { + data: Buffer.from('x'.repeat(2000), 'utf8'), + rsv1: false, + }; + + await sendThrough(session, message); + session.close(); + + expect(message.rsv1).toBe(true); + expect(message.data.length).toBeLessThan(2000); + }); + + it('tracks how many sessions are holding zlib contexts right now', async () => { + // This is the number the memory budget is built on: live deflate+inflate + // pairs, not connections (xhr-streaming holds none) and not sessions ever + // created. Without the decrement it would climb forever and read as a leak. + const before = getCompressionSnapshot(); + + const a = openSession(); + const b = openSession(); + expect(getCompressionSnapshot().sessionsActive).toBe(before.sessionsActive + 2); + + a.close(); + expect(getCompressionSnapshot().sessionsActive).toBe(before.sessionsActive + 1); + + b.close(); + expect(getCompressionSnapshot().sessionsActive).toBe(before.sessionsActive); + }); +}); diff --git a/apps/nestjs-backend/src/ws/instrumented-deflate.ts b/apps/nestjs-backend/src/ws/instrumented-deflate.ts new file mode 100644 index 0000000000..4e32e89ba8 --- /dev/null +++ b/apps/nestjs-backend/src/ws/instrumented-deflate.ts @@ -0,0 +1,80 @@ +import { performance } from 'perf_hooks'; +import type { IPermessageDeflateExtension, IPermessageDeflateSession } from 'permessage-deflate'; +import { + recordCompressionFrame, + recordCompressionSessionClose, + recordCompressionSessionOpen, +} from '../share-db/metrics/compression-metrics'; + +const instrumentSession = (session: IPermessageDeflateSession): IPermessageDeflateSession => { + recordCompressionSessionOpen(); + + // websocket-extensions closes the session exactly once when the pipeline + // drains (pipeline/cell.js), but guard anyway — a double decrement would + // quietly turn the memory multiplier negative. + let closed = false; + + return { + generateResponse: () => session.generateResponse(), + + close() { + if (!closed) { + closed = true; + recordCompressionSessionClose(); + } + session.close(); + }, + + processOutgoingMessage(message, callback) { + const plain = message.data.length; + const started = performance.now(); + session.processOutgoingMessage(message, (error, result) => { + if (!error && result) { + recordCompressionFrame( + 'outbound', + plain, + result.data.length, + performance.now() - started + ); + } + callback(error, result); + }); + }, + + processIncomingMessage(message, callback) { + // Inbound arrives compressed and leaves inflated, so the sizes swap + // sides. Not timed: inflate over small ShareDB ops tells us nothing + // outbound has not already shown, and a second histogram would cost + // another 9 samples per export. + const wire = message.data.length; + session.processIncomingMessage(message, (error, result) => { + if (!error && result) recordCompressionFrame('inbound', result.data.length, wire); + callback(error, result); + }); + }, + }; +}; + +/** + * Wraps a configured permessage-deflate extension so every session and frame + * lands in the realtime.compression.* metrics. + * + * `deflate.configure()` returns an object whose `name`/`type`/`rsv*` live on the + * prototype, so they are copied across explicitly — spreading would drop them + * and websocket-extensions would silently refuse to register the extension. + */ +export const instrumentDeflate = ( + extension: IPermessageDeflateExtension +): IPermessageDeflateExtension => ({ + name: extension.name, + type: extension.type, + rsv1: extension.rsv1, + rsv2: extension.rsv2, + rsv3: extension.rsv3, + configure: (options) => extension.configure(options), + + createServerSession(offers) { + const session = extension.createServerSession(offers); + return session ? instrumentSession(session) : null; + }, +}); diff --git a/apps/nestjs-backend/src/ws/sockjs-options.spec.ts b/apps/nestjs-backend/src/ws/sockjs-options.spec.ts new file mode 100644 index 0000000000..e3fd1029a4 --- /dev/null +++ b/apps/nestjs-backend/src/ws/sockjs-options.spec.ts @@ -0,0 +1,197 @@ +import http from 'http'; +import type { AddressInfo, Socket } from 'net'; +import sockjs from 'sockjs'; +import { describe, it, expect, afterEach } from 'vitest'; +import WebSocket from 'ws'; +import { getCompressionSnapshot } from '../share-db/metrics/compression-metrics'; +import { createSockjsServerOptions } from './sockjs-options'; + +/** + * These tests drive a real SockJS server over a real WebSocket client, because + * permessage-deflate only exists as a handshake negotiation plus RSV1 framing — + * asserting on the options object would prove nothing about either. + */ + +/** ~90 KiB of record snapshots, matching what ShareDB pushes on a grid load. */ +const buildSnapshotPayload = () => + JSON.stringify({ + a: 'q', + id: 1, + data: Array.from({ length: 200 }, (_, i) => ({ + d: `rec${String(i).padStart(13, 'A')}`, + v: 3, + type: 'http://sharedb.org/types/json0', + data: { + id: `rec${String(i).padStart(13, 'A')}`, + fields: { + fldTitleAaBbCcDd: `Customer account ${i} — western region`, + fldStatusEeFfGg1: ['Active', 'Pending review', 'Churned'][i % 3], + fldOwnerHhIiJjKk: { id: `usr${i % 7}`, title: `Team Member ${i % 7}` }, + fldNotesSsTtUuVv: `Follow-up scheduled. Renewal pending for cycle ${i}.`, + }, + createdTime: '2026-05-10T04:12:33.000Z', + lastModifiedTime: '2026-07-20T11:05:12.000Z', + }, + })), + }); + +interface IHarness { + /** Negotiated `Sec-WebSocket-Extensions` response header, or undefined. */ + negotiated?: string; + /** + * Total bytes the server wrote to the TCP socket for this connection. The + * server pushes the payload immediately on connect, so a delta measured from + * the client-observed `o` frame races the write — the total (payload plus a + * ~250 byte upgrade response) is the only stable reading. + */ + bytesOnWire: number; + /** Payloads as the client decoded them, after SockJS unframing. */ + received: string[]; +} + +const teardown: Array<() => Promise> = []; + +afterEach(async () => { + while (teardown.length) await teardown.pop()!(); +}); + +/** + * Boots a real SockJS server that pushes `payloads` on connect, connects with a + * browser-shaped offer (`permessage-deflate; client_max_window_bits`) and + * reports what crossed the wire. SockJS flushes its send buffer on every + * `write` (`transport.js` `Session.send`), so each payload leaves as its own + * WebSocket frame and gets its own deflate pass. + */ +async function connectAndReceive( + payloads: string[], + // ws sends a bare `client_max_window_bits` for `true`, which is exactly the + // offer Chrome and Firefox make. + offer: WebSocket.ClientOptions['perMessageDeflate'] = true +): Promise { + const httpServer = http.createServer(); + const sockjsServer = sockjs.createServer(createSockjsServerOptions(() => undefined)); + sockjsServer.on('connection', (conn) => payloads.forEach((p) => conn.write(p))); + sockjsServer.installHandlers(httpServer); + + let serverSocket: Socket | undefined; + httpServer.on('connection', (socket) => (serverSocket = socket)); + + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)); + const { port } = httpServer.address() as AddressInfo; + + const client = new WebSocket(`ws://127.0.0.1:${port}/socket/000/vitest/websocket`, { + perMessageDeflate: offer, + }); + + teardown.push(async () => { + client.close(); + await new Promise((resolve) => httpServer.close(() => resolve())); + }); + + let negotiated: string | undefined; + client.on('upgrade', (res) => (negotiated = res.headers['sec-websocket-extensions'])); + + const result = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('timed out waiting for SockJS payloads')), + 5000 + ); + const received: string[] = []; + + client.on('error', reject); + client.on('message', (raw) => { + const frame = raw.toString(); + // SockJS opens with `o`, then delivers messages as `a["",…]`. + if (!frame.startsWith('a')) return; + received.push(...(JSON.parse(frame.slice(1)) as string[])); + if (received.length < payloads.length) return; + clearTimeout(timer); + resolve({ negotiated, bytesOnWire: serverSocket?.bytesWritten ?? 0, received }); + }); + }); + + return result; +} + +describe('createSockjsServerOptions', () => { + it('negotiates permessage-deflate against a browser-shaped offer', async () => { + const { negotiated } = await connectAndReceive(['hello']); + + expect(negotiated).toMatch(/(^|[,;\s])permessage-deflate\b/); + }); + + it('caps the client deflate window at 13 bits to bound the server inflater', async () => { + // The inflate side is allocated from the *peer's* window (session.js + // `_getInflate`), so only client_max_window_bits keeps it off 32 KiB. + const { negotiated } = await connectAndReceive(['hello']); + + expect(negotiated).toContain('client_max_window_bits=13'); + }); + + it('caps its own deflate window at 13 bits, the dominant per-connection cost', async () => { + // zlib sizes the deflater at 1<<(windowBits+2) plus 1<<(memLevel+9): 256 KiB + // at defaults against 64 KiB here. Measured across 800 live connections that + // is ~398 KiB/conn versus ~250 KiB/conn — the difference between fitting and + // not fitting on a small box. + // + // permessage-deflate only echoes server_max_window_bits when the peer named + // it (server_session.js, a Firefox workaround), so the offer has to ask. + const { negotiated } = await connectAndReceive(['hello'], { serverMaxWindowBits: 15 }); + + expect(negotiated).toContain('server_max_window_bits=13'); + }); + + it('delivers a grid-load payload compressed and byte-identical', async () => { + const payload = buildSnapshotPayload(); + + const { bytesOnWire, received } = await connectAndReceive([payload]); + + expect(received).toEqual([payload]); + expect(payload.length).toBeGreaterThan(80 * 1024); + // Measured ~10-19x on this shape; 5x is a floor that only an uncompressed + // connection can miss. + expect(bytesOnWire).toBeLessThan(payload.length / 5); + }); + + it('counts what it compressed, so the live ratio is observable', async () => { + // Guards the wiring, not the counters: without instrumentDeflate() in the + // production path every compression test above still passes while the + // metrics stay flat at zero. + const payload = buildSnapshotPayload(); + const before = getCompressionSnapshot(); + + await connectAndReceive([payload]); + + const after = getCompressionSnapshot(); + expect(after.sessionsCreated).toBe(before.sessionsCreated + 1); + expect( + after.outbound.uncompressedBytes - before.outbound.uncompressedBytes + ).toBeGreaterThanOrEqual(payload.length); + expect(after.outbound.compressedBytes - before.outbound.compressedBytes).toBeLessThan( + payload.length / 5 + ); + }); + + it('keeps the deflate dictionary across ops so steady-state traffic stays small', async () => { + // Each op is its own frame, so this is the case context takeover decides: + // with it, later ops cost a handful of bytes; with `noContextTakeover` + // every op restarts from an empty dictionary and the ratio falls to ~1.4x. + const ops = Array.from({ length: 300 }, (_, i) => + JSON.stringify({ + a: 'op', + c: 'record_tblXyZ123456789Ab', + d: `rec${String(i).padStart(13, 'A')}`, + v: 4 + (i % 20), + op: [{ p: ['record', 'fields', 'fldStatusEeFfGg1'], oi: 'Active', od: 'Pending review' }], + src: 'a1b2c3d4e5f6a7b8c9d0e1f2', + seq: i, + }) + ); + const rawBytes = ops.reduce((sum, op) => sum + op.length, 0); + + const { bytesOnWire, received } = await connectAndReceive(ops); + + expect(received).toEqual(ops); + expect(bytesOnWire).toBeLessThan(rawBytes / 5); + }); +}); diff --git a/apps/nestjs-backend/src/ws/sockjs-options.ts b/apps/nestjs-backend/src/ws/sockjs-options.ts new file mode 100644 index 0000000000..0cc45c2f4a --- /dev/null +++ b/apps/nestjs-backend/src/ws/sockjs-options.ts @@ -0,0 +1,73 @@ +import deflate from 'permessage-deflate'; +import type sockjs from 'sockjs'; +import { instrumentDeflate } from './instrumented-deflate'; + +export type ISockjsLog = (severity: string, message: string) => void; + +/** + * permessage-deflate (RFC 7692) for the WebSocket transport. + * + * ShareDB pushes whole record snapshots down this socket (`share-db.adapter.ts` + * hydrates query results via `getSnapshotBulk`), and SockJS then wraps each one + * in `a[""]`, escaping the payload a second time. Repeated field + * ids plus that escaping make the stream unusually compressible — measured + * end to end in `sockjs-options.spec.ts` at 20.7x for a grid load (84 KB -> 4.1 KB + * on the wire) and 12.9x for a burst of 300 record ops. + * + * Memory, not CPU, is the binding constraint here: every connection holds a + * deflate and an inflate context for as long as it lives. Measured against 800 + * real connections, an uncompressed connection costs ~39 KiB and the settings + * below bring a compressed one to ~250 KiB, down from ~398 KiB at zlib defaults + * — with identical compression. Budget ~210 KiB per concurrent connection and + * check `realtime.connections.active` for the peak before rolling out. + * + * - `level: 3` — measured no slower than level 1 on this data while compressing + * better (ops 19.9x vs 17.3x). Level 6 doubles grid CPU for +13%, level 9 + * quadruples it. CPU is cheap either way: ~15 us per op, ~180 us per grid + * frame, so even 5k ops/sec is a few percent of one core. + * - `maxWindowBits: 13` + `memLevel: 6` — bounds the deflate context, which is + * the dominant allocation (zlib needs `1<<(windowBits+2)` plus + * `1<<(memLevel+9)` bytes: 256 KiB at defaults, 64 KiB here). This is the + * memory/ratio trade and it is deliberately biased towards memory, because + * the deployment target is a 2 core / 4 GiB box. It is not free: on wide + * records the 8 KiB window gives up roughly a fifth of the ratio (a 2.3 MiB + * payload compresses 15.1x here against 19.1x at the 32 KiB default), while + * saving ~148 KiB per live connection. Revisit if peak + * `realtime.connections.active` per pod stays well under ~1500, where the + * wider window costs little memory and compresses better for the same CPU. + * Going narrower is a bad trade in both directions: wb12/mem5 drops ops to + * 15.4x, wb11/mem4 drops grid loads to 19.1x. + * - `requestMaxWindowBits: 13` — bounds the inflater, which is sized from the + * peer's window (`permessage-deflate/lib/session.js` `_getInflate`). Worth + * far less than the deflate side but free: inbound traffic is small ShareDB + * ops that lose nothing to an 8 KiB window. + * - Context takeover stays enabled. Disabling it would bound memory further but + * collapses op compression from ~19.9x to ~1.4x, since each small message + * would restart from an empty dictionary. + */ +const permessageDeflate = instrumentDeflate( + deflate.configure({ + level: 3, + maxWindowBits: 13, + memLevel: 6, + requestMaxWindowBits: 13, + }) +); + +/** + * SockJS server configuration for collaborative data sync (similar to Airtable) + * - transports: Only websocket and xhr-streaming (xhr-polling excluded for performance) + * - response_limit: 2MB to handle large batch operations (table sync, bulk row updates) + * + * Note: compression applies to the websocket transport only. The xhr-streaming + * fallback is unaffected — it would need HTTP-level compression instead. + */ +export const createSockjsServerOptions = (log: ISockjsLog) => + ({ + prefix: '/socket', + transports: ['websocket', 'xhr-streaming'], + response_limit: 2 * 1024 * 1024, // 2MB for large collaborative payloads + log, + faye_server_options: { extensions: [permessageDeflate] }, + // eslint-disable-next-line @typescript-eslint/naming-convention + }) as sockjs.ServerOptions & { transports: string[]; response_limit: number }; diff --git a/apps/nestjs-backend/src/ws/ws.gateway.dev.spec.ts b/apps/nestjs-backend/src/ws/ws.gateway.dev.spec.ts index c3c1e3c2dc..a047a7b894 100644 --- a/apps/nestjs-backend/src/ws/ws.gateway.dev.spec.ts +++ b/apps/nestjs-backend/src/ws/ws.gateway.dev.spec.ts @@ -139,6 +139,10 @@ describe('DevWsGateway', () => { transports: ['websocket', 'xhr-streaming'], response_limit: 2 * 1024 * 1024, log: expect.any(Function), + // negotiation and compression itself are covered by sockjs-options.spec.ts + faye_server_options: { + extensions: [expect.objectContaining({ name: 'permessage-deflate' })], + }, }); expect(mockSockjsServer.on).toHaveBeenCalledWith('connection', expect.any(Function)); expect(mockSockjsServer.installHandlers).toHaveBeenCalledWith(mockHttpServer); diff --git a/apps/nestjs-backend/src/ws/ws.gateway.dev.ts b/apps/nestjs-backend/src/ws/ws.gateway.dev.ts index 94d6f9af44..1253355b51 100644 --- a/apps/nestjs-backend/src/ws/ws.gateway.dev.ts +++ b/apps/nestjs-backend/src/ws/ws.gateway.dev.ts @@ -8,6 +8,7 @@ import type { Request } from 'express'; import sockjs from 'sockjs'; import { RealtimeMetricsService } from '../share-db/metrics/realtime-metrics.service'; import { ShareDbService } from '../share-db/share-db.service'; +import { createSockjsServerOptions } from './sockjs-options'; @Injectable() export class DevWsGateway implements OnModuleInit, OnModuleDestroy { @@ -25,14 +26,8 @@ export class DevWsGateway implements OnModuleInit, OnModuleDestroy { onModuleInit() { const port = this.configService.get('SOCKET_PORT'); - // SockJS server configuration for collaborative data sync (similar to Airtable) - // - transports: Only websocket and xhr-streaming (xhr-polling excluded for performance) - // - response_limit: 1MB to handle large batch operations (table sync, bulk row updates) - this.sockjsServer = sockjs.createServer({ - prefix: '/socket', - transports: ['websocket', 'xhr-streaming'], - response_limit: 2 * 1024 * 1024, // 2MB for large collaborative payloads - log: (severity: string, message: string) => { + this.sockjsServer = sockjs.createServer( + createSockjsServerOptions((severity: string, message: string) => { if (severity === 'error') { this.logger.error(message); } else if (severity === 'info') { @@ -40,9 +35,8 @@ export class DevWsGateway implements OnModuleInit, OnModuleDestroy { } else { this.logger.debug(message); } - }, - // eslint-disable-next-line @typescript-eslint/naming-convention - } as sockjs.ServerOptions & { transports: string[]; response_limit: number }); + }) + ); this.sockjsServer.on('connection', this.handleConnection); diff --git a/apps/nestjs-backend/src/ws/ws.gateway.spec.ts b/apps/nestjs-backend/src/ws/ws.gateway.spec.ts index 31438eee42..e27f424b50 100644 --- a/apps/nestjs-backend/src/ws/ws.gateway.spec.ts +++ b/apps/nestjs-backend/src/ws/ws.gateway.spec.ts @@ -106,6 +106,10 @@ describe('WsGateway', () => { transports: ['websocket', 'xhr-streaming'], response_limit: 2 * 1024 * 1024, log: expect.any(Function), + // negotiation and compression itself are covered by sockjs-options.spec.ts + faye_server_options: { + extensions: [expect.objectContaining({ name: 'permessage-deflate' })], + }, }); expect(mockSockjsServer.on).toHaveBeenCalledWith('connection', expect.any(Function)); expect(mockSockjsServer.installHandlers).toHaveBeenCalledWith(mockHttpServer); diff --git a/apps/nestjs-backend/src/ws/ws.gateway.ts b/apps/nestjs-backend/src/ws/ws.gateway.ts index 4f2f4c1e79..46ebdb71b3 100644 --- a/apps/nestjs-backend/src/ws/ws.gateway.ts +++ b/apps/nestjs-backend/src/ws/ws.gateway.ts @@ -6,8 +6,10 @@ import { Injectable, Logger, Optional } from '@nestjs/common'; import { HttpAdapterHost } from '@nestjs/core'; import type { Request } from 'express'; import sockjs from 'sockjs'; +import { recordCompressionNegotiation } from '../share-db/metrics/compression-metrics'; import { RealtimeMetricsService } from '../share-db/metrics/realtime-metrics.service'; import { ShareDbService } from '../share-db/share-db.service'; +import { createSockjsServerOptions } from './sockjs-options'; @Injectable() export class WsGateway implements OnModuleInit, OnModuleDestroy { @@ -32,14 +34,8 @@ export class WsGateway implements OnModuleInit, OnModuleDestroy { onModuleInit() { const httpServer = this.httpAdapterHost.httpAdapter.getHttpServer() as http.Server; - // SockJS server configuration for collaborative data sync (similar to Airtable) - // - transports: Only websocket and xhr-streaming (xhr-polling excluded for performance) - // - response_limit: 1MB to handle large batch operations (table sync, bulk row updates) - this.sockjsServer = sockjs.createServer({ - prefix: '/socket', - transports: ['websocket', 'xhr-streaming'], - response_limit: 2 * 1024 * 1024, // 2MB for large collaborative payloads - log: (severity: string, message: string) => { + this.sockjsServer = sockjs.createServer( + createSockjsServerOptions((severity: string, message: string) => { if (severity === 'error') { this.logger.error(message); } else if (severity === 'info') { @@ -47,9 +43,8 @@ export class WsGateway implements OnModuleInit, OnModuleDestroy { } else { this.logger.debug(message); } - }, - // eslint-disable-next-line @typescript-eslint/naming-convention - } as sockjs.ServerOptions & { transports: string[]; response_limit: number }); + }) + ); this.sockjsServer.on('connection', this.handleConnection); this.sockjsServer.installHandlers(httpServer); @@ -82,6 +77,20 @@ export class WsGateway implements OnModuleInit, OnModuleDestroy { // Extract request with headers (including cookies for auth) const request = this.getRequestFromConnection(conn); + // Records whether Sec-WebSocket-Extensions survived whatever sits in front + // of this pod; `offer_missing` on a websocket connection means compression + // is silently off for that client. + const negotiation = recordCompressionNegotiation( + conn.protocol, + request.headers?.['sec-websocket-extensions'] as string | undefined + ); + if (negotiation === 'offer_missing') { + this.logger.debug( + `sockjs:on:connection no permessage-deflate offer reached the pod ` + + `(transport: ${conn.protocol}) — check for a proxy stripping Sec-WebSocket-Extensions` + ); + } + this.shareDb.listen(stream, request); // After listen, the ShareDB agent will have custom.userId set by auth middleware diff --git a/apps/nestjs-backend/test/auth.e2e-spec.ts b/apps/nestjs-backend/test/auth.e2e-spec.ts index 0dd0d70fa1..038f425659 100644 --- a/apps/nestjs-backend/test/auth.e2e-spec.ts +++ b/apps/nestjs-backend/test/auth.e2e-spec.ts @@ -1,6 +1,5 @@ /* eslint-disable sonarjs/no-duplicate-string */ import type { INestApplication } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; import { DriverClient, generateAccountId, HttpErrorCode } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import type { @@ -47,6 +46,7 @@ import type { AxiosInstance } from 'axios'; import axios from 'axios'; import { vi } from 'vitest'; import { AUTH_SESSION_COOKIE_NAME } from '../src/const'; +import { TeableJwtService } from '../src/features/auth/jwt/teable-jwt.service'; import { SettingService } from '../src/features/setting/setting.service'; import { createNewUserAxios } from './utils/axios-instance/new-user'; import { getError } from './utils/get-error'; @@ -239,7 +239,7 @@ describe('Auth Controller (e2e)', () => { const data = error?.data as { token: string; expiresTime: number }; expect(data.token).not.toBeUndefined(); expect(data.expiresTime).not.toBeUndefined(); - const jwtService = app.get(JwtService); + const jwtService = app.get(TeableJwtService); const decoded = await jwtService.verifyAsync<{ email: string; code: string }>(data.token); const res = await signup({ email: authTestEmail, @@ -337,7 +337,7 @@ describe('Auth Controller (e2e)', () => { password: '12345678a', }); expect(codeRes.data.token).not.toBeUndefined(); - const jwtService = app.get(JwtService); + const jwtService = app.get(TeableJwtService); const decoded = await jwtService.verifyAsync<{ email: string; code: string }>( codeRes.data.token ); diff --git a/apps/nestjs-backend/test/base-duplicate.e2e-spec.ts b/apps/nestjs-backend/test/base-duplicate.e2e-spec.ts index fdb94abc73..9a70397547 100644 --- a/apps/nestjs-backend/test/base-duplicate.e2e-spec.ts +++ b/apps/nestjs-backend/test/base-duplicate.e2e-spec.ts @@ -937,6 +937,21 @@ describe('OpenAPI Base Duplicate (e2e)', () => { ); }); + it('seeds last-visit through v2 so the duplicated base tops the recent list', async () => { + const dupResult = await duplicateBase({ + fromBaseId: base.id, + spaceId, + name: 'v2 last-visit seed copy', + }); + expect(dupResult.status).toBe(201); + duplicateBaseId = dupResult.data.id; + + const listRes = await getUserLastVisitListBase(); + const listedIds = listRes.data.list.map((item) => item.resource.id); + expect(listedIds).toContain(duplicateBaseId); + expect(listRes.data.list[0].resource.id).toBe(duplicateBaseId); + }); + it('duplicates bidirectional link records through v2 stream copy', async () => { const sourceTable = await createTable(base.id, { name: 'V2 Source', records: [] }); const linkedTable = await createTable(base.id, { name: 'V2 Linked', records: [] }); diff --git a/apps/nestjs-backend/test/byodb-space-storage-placement.e2e-spec.ts b/apps/nestjs-backend/test/byodb-space-storage-placement.e2e-spec.ts index 140031215c..2d8494fff5 100644 --- a/apps/nestjs-backend/test/byodb-space-storage-placement.e2e-spec.ts +++ b/apps/nestjs-backend/test/byodb-space-storage-placement.e2e-spec.ts @@ -115,6 +115,7 @@ const dataPlaneSystemTables = [ 'computed_update_outbox_seed', 'computed_update_dead_letter', 'computed_update_pause_scope', + 'computed_update_stage_ledger', 'computed_field_activity', 'computed_table_activity', 'computed_task_field_ref', @@ -2007,6 +2008,22 @@ describeByodbStorage('BYODB space storage placement (e2e)', () => { ]) ).resolves.toBe(0); + // Imports intentionally write no record history; update one imported record to + // verify record history for the imported table is routed to the data DB. + await expect( + countRows(dataDb, internalSchema, 'record_history', `${quoteIdent('table_id')} = ?`, [ + importedTable.id, + ]) + ).resolves.toBe(0); + const importedRecordId = importedRecords.records[0].id; + await updateRecord(importedTable.id, importedRecordId, { + fieldKeyType: FieldKeyType.Name, + record: { + fields: { + ['Ming_Zi']: 'Ada Updated', + }, + }, + }); await expect( waitForAtLeast( () => diff --git a/apps/nestjs-backend/test/default-view-id.e2e-spec.ts b/apps/nestjs-backend/test/default-view-id.e2e-spec.ts new file mode 100644 index 0000000000..4331ea0217 --- /dev/null +++ b/apps/nestjs-backend/test/default-view-id.e2e-spec.ts @@ -0,0 +1,111 @@ +import type { INestApplication } from '@nestjs/common'; +import { ViewType } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import { getDefaultViewId, updateViewOrder } from '@teable/openapi'; +import { vi } from 'vitest'; + +import { + X_TEABLE_V2_FEATURE_HEADER, + X_TEABLE_V2_HEADER, + X_TEABLE_V2_REASON_HEADER, +} from '../src/features/canary/interceptors/v2-indicator.interceptor'; +import { TableService } from '../src/features/table/table.service'; +import { getError } from './utils/get-error'; +import { createTable, createView, initApp, permanentDeleteTable } from './utils/init-app'; + +describe('GET /api/base/:baseId/table/:tableId/default-view-id v2 (T6420)', () => { + let app: INestApplication; + let prismaService: PrismaService; + let tableService: TableService; + let tableId: string; + let defaultViewId: string; + const baseId = globalThis.testConfig.baseId; + let previousForceV2All: string | undefined; + + beforeAll(async () => { + const appContext = await initApp(); + app = appContext.app; + prismaService = app.get(PrismaService); + tableService = app.get(TableService); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + const table = await createTable(baseId, { name: 'default_view_id_v2' }); + tableId = table.id; + defaultViewId = table.defaultViewId!; + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await permanentDeleteTable(baseId, tableId); + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + }); + + it('returns the Table aggregate default View without invoking the legacy Prisma service', async () => { + const legacySpy = vi + .spyOn(tableService, 'getDefaultViewId') + .mockRejectedValue(new Error('legacy TableService must not be used')); + + const response = await getDefaultViewId(baseId, tableId); + + expect(response.data).toEqual({ id: defaultViewId }); + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getDefaultViewId'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(legacySpy).not.toHaveBeenCalled(); + }); + + it('tracks View order changes through the Table aggregate', async () => { + const second = await createView(tableId, { + name: 'New default', + type: ViewType.Grid, + }); + await updateViewOrder(tableId, second.id, { + anchorId: defaultViewId, + position: 'before', + }); + + const response = await getDefaultViewId(baseId, tableId); + + expect(response.data).toEqual({ id: second.id }); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getDefaultViewId'); + }); + + it('returns view.not_found when the Table has no active View child', async () => { + const deletedTime = new Date(); + await prismaService.view.updateMany({ + where: { tableId }, + data: { deletedTime }, + }); + + try { + const error = await getError(() => getDefaultViewId(baseId, tableId)); + + expect(error).toMatchObject({ + status: 404, + code: 'not_found', + }); + } finally { + await prismaService.view.updateMany({ + where: { tableId, deletedTime }, + data: { deletedTime: null }, + }); + } + }); + + it('rejects a Table outside the route Base scope before returning a sibling View', async () => { + const error = await getError(() => getDefaultViewId(`bse${'z'.repeat(16)}`, tableId)); + + expect(error).toMatchObject({ status: 404, code: 'not_found' }); + }); +}); diff --git a/apps/nestjs-backend/test/field-converting.e2e-spec.ts b/apps/nestjs-backend/test/field-converting.e2e-spec.ts index ce8fd73b28..29b14d6b61 100644 --- a/apps/nestjs-backend/test/field-converting.e2e-spec.ts +++ b/apps/nestjs-backend/test/field-converting.e2e-spec.ts @@ -1679,6 +1679,47 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => { expect(values[1]).toEqual(5); }); + it.skipIf(!canRunCanaryV2)( + 'should normalize converted rating values through the public v2 API T6518', + async () => { + const sourceField = await createField(table1.id, { + type: FieldType.Number, + name: 'Source Rating Value', + }); + const sourceValues = [2.7, 4.6, 0, -3, 9, 3, 0.4]; + const expectedValues = [3, 5, null, null, 5, 3, null]; + const { records } = await createRecords(table1.id, { + records: sourceValues.map((value) => ({ fields: { [sourceField.id]: value } })), + }); + + const convertedField = await convertFieldByCanaryV2(table1.id, sourceField.id, { + type: FieldType.Rating, + options: { + icon: RatingIcon.Star, + color: Colors.YellowBright, + max: 5, + }, + }); + expect(convertedField.type).toEqual(FieldType.Rating); + + for (const [index, record] of records.entries()) { + const convertedRecord = await getRecord(table1.id, record.id); + expect(convertedRecord.fields[sourceField.id] ?? null).toEqual(expectedValues[index]); + + const expectedValue = expectedValues[index]; + if (expectedValue !== null) { + const rewrittenRecord = await updateRecordByApi( + table1.id, + record.id, + sourceField.id, + expectedValue + ); + expect(rewrittenRecord.fields[sourceField.id]).toEqual(expectedValue); + } + } + } + ); + it('should correctly update and maintain values when transitioning from a Rating field to a Number field', async () => { const sourceFieldRo: IFieldRo = { type: FieldType.Rating, diff --git a/apps/nestjs-backend/test/import-base.e2e-spec.ts b/apps/nestjs-backend/test/import-base.e2e-spec.ts index e5e5470d1f..9c14ac81f1 100644 --- a/apps/nestjs-backend/test/import-base.e2e-spec.ts +++ b/apps/nestjs-backend/test/import-base.e2e-spec.ts @@ -1041,14 +1041,16 @@ describe('OpenAPI BaseController for base import (e2e)', () => { where: { baseId }, select: { status: true, attempts: true }, }); + // Allow any pending row (including lock-miss / one-shot retries that + // bump attempts) and in-flight processing. Delete all pending so a + // requeued task cannot block permanent delete after the run settled. const unexpectedTasks = deferredTasks.filter( - ({ status, attempts }) => - status !== 'processing' && !(status === 'pending' && attempts === 0) + ({ status }) => status !== 'processing' && status !== 'pending' ); expect(unexpectedTasks).toEqual([]); await prisma.computedUpdateOutbox.deleteMany({ - where: { baseId, status: 'pending', attempts: 0 }, + where: { baseId, status: 'pending' }, }); const remainingTaskCount = await prisma.computedUpdateOutbox.count({ diff --git a/apps/nestjs-backend/test/legacy-created-by-create.e2e-spec.ts b/apps/nestjs-backend/test/legacy-created-by-create.e2e-spec.ts index 443c812195..7f18fb20b1 100644 --- a/apps/nestjs-backend/test/legacy-created-by-create.e2e-spec.ts +++ b/apps/nestjs-backend/test/legacy-created-by-create.e2e-spec.ts @@ -105,8 +105,13 @@ describe('Legacy createdBy create compatibility (e2e) T6146', () => { const list = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id }); const target = list.records.find((r) => r.id === recordId); expect(target?.fields[nameField!.id]).toBe('legacy-created-by-row'); - // Display may resolve via system column fallback - expect(target?.fields[createdByField.id]).toBeTruthy(); + expect(target?.fields[createdByField.id]).toEqual( + expect.objectContaining({ + id: rows[0]?.created_by, + title: expect.any(String), + avatarUrl: expect.stringContaining(`/avatar/${rows[0]?.created_by}`), + }) + ); } finally { await permanentDeleteTable(baseId, table.id); } diff --git a/apps/nestjs-backend/test/record-read/presentation-contract.e2e-spec.ts b/apps/nestjs-backend/test/record-read/presentation-contract.e2e-spec.ts new file mode 100644 index 0000000000..c063ace8de --- /dev/null +++ b/apps/nestjs-backend/test/record-read/presentation-contract.e2e-spec.ts @@ -0,0 +1,734 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import type { INestApplication } from '@nestjs/common'; +import { + CellFormat, + FieldKeyType, + FieldType, + NumberFormattingType, + RatingIcon, + Relationship, + SortFunc, +} from '@teable/core'; +import type { ICreateTableRo, IGetRecordsRo, IRecordsVo, ITableFullVo } from '@teable/openapi'; +import { + GET_RECORDS_URL, + X_CANARY_HEADER, + axios, + uploadAttachment, + urlBuilder, +} from '@teable/openapi'; +import { + createField, + createRecords, + createTable, + initApp, + permanentDeleteTable, + updateRecordByApi, +} from '../utils/init-app'; + +// This suite owns response presentation compatibility. Filter/sort/group row-selection +// semantics are covered separately by their query and authority matrices. +describe('Record read V1/V2 presentation contract (e2e)', () => { + let app: INestApplication; + let previousForceV2All: string | undefined; + let previousEnableCanaryFeature: string | undefined; + let attachmentFixturePath: string; + + const baseId = globalThis.testConfig.baseId; + const primaryFieldId = `fld${'p'.repeat(16)}`; + const longTextFieldId = `fld${'l'.repeat(16)}`; + const numberFieldId = `fld${'n'.repeat(16)}`; + const ratingFieldId = `fld${'r'.repeat(16)}`; + const singleSelectFieldId = `fld${'s'.repeat(16)}`; + const multipleSelectFieldId = `fld${'m'.repeat(16)}`; + const checkboxFieldId = `fld${'c'.repeat(16)}`; + const dateFieldId = `fld${'d'.repeat(16)}`; + const formulaFieldId = `fld${'f'.repeat(16)}`; + const autoNumberFieldId = `fld${'a'.repeat(16)}`; + const createdTimeFieldId = `fld${'t'.repeat(16)}`; + const lastModifiedTimeFieldId = `fld${'i'.repeat(16)}`; + const createdByFieldId = `fld${'u'.repeat(16)}`; + const lastModifiedByFieldId = `fld${'v'.repeat(16)}`; + const userFieldId = `fld${'w'.repeat(16)}`; + const multipleUserFieldId = `fld${'z'.repeat(16)}`; + const formulaDateFieldId = `fld${'j'.repeat(16)}`; + const formulaBooleanFieldId = `fld${'h'.repeat(16)}`; + const foreignNameFieldId = `fld${'q'.repeat(16)}`; + const foreignRevenueFieldId = `fld${'e'.repeat(16)}`; + const attachmentFieldId = `fld${'x'.repeat(16)}`; + const foreignAttachmentFieldId = `fld${'2'.repeat(16)}`; + const attachmentLookupFieldId = `fld${'3'.repeat(16)}`; + const conditionalAttachmentLookupFieldId = `fld${'4'.repeat(16)}`; + const buttonFieldId = `fld${'b'.repeat(16)}`; + const linkFieldId = `fld${'k'.repeat(16)}`; + const multipleLinkFieldId = `fld${'g'.repeat(16)}`; + const lookupFieldId = `fld${'o'.repeat(16)}`; + const rollupFieldId = `fld${'y'.repeat(16)}`; + const conditionalLookupFieldId = `fld${'0'.repeat(16)}`; + const conditionalRollupFieldId = `fld${'1'.repeat(16)}`; + + beforeAll(async () => { + previousForceV2All = process.env.FORCE_V2_ALL; + previousEnableCanaryFeature = process.env.ENABLE_CANARY_FEATURE; + process.env.FORCE_V2_ALL = 'false'; + process.env.ENABLE_CANARY_FEATURE = 'true'; + + const appCtx = await initApp(); + app = appCtx.app; + attachmentFixturePath = path.join(os.tmpdir(), `teable-record-presentation-${Date.now()}.txt`); + fs.writeFileSync(attachmentFixturePath, 'presentation contract attachment'); + }); + + afterAll(async () => { + if (fs.existsSync(attachmentFixturePath)) { + fs.unlinkSync(attachmentFixturePath); + } + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + if (previousEnableCanaryFeature == null) { + delete process.env.ENABLE_CANARY_FEATURE; + } else { + process.env.ENABLE_CANARY_FEATURE = previousEnableCanaryFeature; + } + await app.close(); + }); + + const getRecordsFromVersion = async (tableId: string, useV2: boolean, query: IGetRecordsRo) => { + const response = await axios.get(urlBuilder(GET_RECORDS_URL, { tableId }), { + params: query, + headers: { + [X_CANARY_HEADER]: useV2 ? 'true' : 'false', + }, + }); + + expect(response.headers['x-teable-v2']).toBe(useV2 ? 'true' : 'false'); + expect(response.headers['x-teable-v2-feature']).toBe('getRecords'); + return response.data; + }; + + const createPresentationTable = async (): Promise => { + const table = await createTable(baseId, { + name: `record-presentation-${Date.now()}`, + fields: [ + { + id: primaryFieldId, + name: 'Name', + type: FieldType.SingleLineText, + isPrimary: true, + }, + { + id: longTextFieldId, + name: 'Description', + type: FieldType.LongText, + }, + { + id: numberFieldId, + name: 'Amount', + type: FieldType.Number, + options: { + formatting: { + type: NumberFormattingType.Decimal, + precision: 2, + }, + }, + }, + { + id: ratingFieldId, + name: 'Rating', + type: FieldType.Rating, + options: { + max: 5, + icon: RatingIcon.Star, + color: 'yellowBright', + }, + }, + { + id: singleSelectFieldId, + name: 'Status', + type: FieldType.SingleSelect, + options: { + choices: [ + { name: 'Todo', color: 'blue' }, + { name: 'Done', color: 'green' }, + ], + }, + }, + { + id: multipleSelectFieldId, + name: 'Tags', + type: FieldType.MultipleSelect, + options: { + choices: [ + { name: 'Frontend', color: 'purple' }, + { name: 'Backend', color: 'orange' }, + ], + }, + }, + { + id: checkboxFieldId, + name: 'Done', + type: FieldType.Checkbox, + }, + { + id: dateFieldId, + name: 'Due Date', + type: FieldType.Date, + options: { + formatting: { + date: 'YYYY-MM-DD', + time: 'HH:mm', + timeZone: 'UTC', + }, + }, + }, + { + id: formulaFieldId, + name: 'Double Amount', + type: FieldType.Formula, + options: { + expression: `{${numberFieldId}} * 2`, + formatting: { + type: NumberFormattingType.Decimal, + precision: 1, + }, + }, + }, + { + id: formulaDateFieldId, + name: 'Formula Due Date', + type: FieldType.Formula, + options: { + expression: `{${dateFieldId}}`, + formatting: { + date: 'YYYY-MM-DD', + time: 'HH:mm', + timeZone: 'UTC', + }, + }, + }, + { + id: formulaBooleanFieldId, + name: 'Formula Done', + type: FieldType.Formula, + options: { + expression: `{${checkboxFieldId}}`, + }, + }, + { + id: autoNumberFieldId, + name: 'Auto Number', + type: FieldType.AutoNumber, + }, + { + id: createdTimeFieldId, + name: 'Created Time', + type: FieldType.CreatedTime, + }, + { + id: lastModifiedTimeFieldId, + name: 'Last Modified Time', + type: FieldType.LastModifiedTime, + }, + { + id: createdByFieldId, + name: 'Created By', + type: FieldType.CreatedBy, + }, + { + id: lastModifiedByFieldId, + name: 'Last Modified By', + type: FieldType.LastModifiedBy, + }, + { + id: userFieldId, + name: 'Owner', + type: FieldType.User, + options: { + isMultiple: false, + shouldNotify: false, + }, + }, + { + id: multipleUserFieldId, + name: 'Reviewers', + type: FieldType.User, + options: { + isMultiple: true, + shouldNotify: false, + }, + }, + ], + records: [], + } as unknown as ICreateTableRo); + + const created = await createRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + typecast: true, + records: [ + { + fields: { + [primaryFieldId]: 'Presentation row', + [longTextFieldId]: 'Line one\nLine two', + [numberFieldId]: 1.234, + [ratingFieldId]: 4, + [singleSelectFieldId]: 'Todo', + [multipleSelectFieldId]: ['Frontend', 'Backend'], + [checkboxFieldId]: true, + [dateFieldId]: '2026-07-28T12:34:00.000Z', + [userFieldId]: globalThis.testConfig.userId, + [multipleUserFieldId]: [globalThis.testConfig.userId], + }, + }, + { + fields: { + [primaryFieldId]: 'Empty row', + [checkboxFieldId]: false, + }, + }, + ], + }); + + await updateRecordByApi(table.id, created.records[0]!.id, primaryFieldId, 'Presentation row'); + return table; + }; + + it('matches scalar, selection, temporal, system, computed, and user JSON shapes', async () => { + const table = await createPresentationTable(); + try { + const query = { + fieldKeyType: FieldKeyType.Id, + cellFormat: CellFormat.Json, + }; + const v1 = await getRecordsFromVersion(table.id, false, query); + const v2 = await getRecordsFromVersion(table.id, true, query); + + expect(v2.records).toEqual(v1.records); + expect(v1.records[0]?.fields[userFieldId]).toMatchObject({ + id: globalThis.testConfig.userId, + title: globalThis.testConfig.userName, + }); + expect(v1.records[0]?.fields[createdByFieldId]).toMatchObject({ + id: globalThis.testConfig.userId, + title: globalThis.testConfig.userName, + }); + expect(v1.records[0]?.fields[lastModifiedByFieldId]).toMatchObject({ + id: globalThis.testConfig.userId, + title: globalThis.testConfig.userId, + }); + expect(v1.records[0]?.fields[multipleUserFieldId]).toEqual([ + expect.objectContaining({ + id: globalThis.testConfig.userId, + title: globalThis.testConfig.userName, + }), + ]); + expect(v1.records[0]?.fields[formulaDateFieldId]).toBe('2026-07-28T12:34:00.000Z'); + expect(v1.records[0]?.fields[formulaBooleanFieldId]).toBe(true); + const emptyRecord = v1.records.find( + (record) => record.fields[primaryFieldId] === 'Empty row' + ); + expect(emptyRecord?.fields).not.toHaveProperty(checkboxFieldId); + expect(emptyRecord?.fields).not.toHaveProperty(numberFieldId); + expect(emptyRecord?.fields).not.toHaveProperty(dateFieldId); + expect(emptyRecord?.fields).not.toHaveProperty(userFieldId); + } finally { + await permanentDeleteTable(baseId, table.id); + } + }); + + it('matches configured display text for the same fields', async () => { + const table = await createPresentationTable(); + try { + const query = { + fieldKeyType: FieldKeyType.Id, + cellFormat: CellFormat.Text, + }; + const v1 = await getRecordsFromVersion(table.id, false, query); + const v2 = await getRecordsFromVersion(table.id, true, query); + + expect(v2.records).toEqual(v1.records); + expect(v1.records[0]?.fields[numberFieldId]).toBe('1.23'); + expect(v1.records[0]?.fields[formulaFieldId]).toBe('2.5'); + expect(v1.records[0]?.fields[dateFieldId]).toBe('2026-07-28 12:34'); + expect(v1.records[0]?.fields[formulaDateFieldId]).toBe('2026-07-28 12:34'); + } finally { + await permanentDeleteTable(baseId, table.id); + } + }); + + it('matches checkbox, selection, and user group-header shapes', async () => { + const table = await createPresentationTable(); + try { + for (const fieldId of [checkboxFieldId, singleSelectFieldId, userFieldId, createdByFieldId]) { + const query = { + fieldKeyType: FieldKeyType.Id, + cellFormat: CellFormat.Json, + groupBy: [{ fieldId, order: SortFunc.Asc }], + projection: [fieldId], + }; + const v1 = await getRecordsFromVersion(table.id, false, query); + const v2 = await getRecordsFromVersion(table.id, true, query); + + expect(v2.extra).toEqual(v1.extra); + } + + const lastModifiedByGroups = await getRecordsFromVersion(table.id, true, { + fieldKeyType: FieldKeyType.Id, + cellFormat: CellFormat.Json, + groupBy: [{ fieldId: lastModifiedByFieldId, order: SortFunc.Asc }], + includeQueryExtra: true, + }); + expect(lastModifiedByGroups.extra?.groupPoints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + id: globalThis.testConfig.userId, + title: globalThis.testConfig.userName, + }), + }), + ]) + ); + + const userGroups = await getRecordsFromVersion(table.id, false, { + fieldKeyType: FieldKeyType.Id, + groupBy: [{ fieldId: userFieldId, order: SortFunc.Asc }], + includeQueryExtra: true, + }); + expect(userGroups.extra?.groupPoints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + id: globalThis.testConfig.userId, + title: globalThis.testConfig.userName, + avatarUrl: expect.any(String), + }), + }), + ]) + ); + } finally { + await permanentDeleteTable(baseId, table.id); + } + }); + + it('matches attachment, link, lookup, rollup, and button presentation', async () => { + let foreignTable: ITableFullVo | undefined; + let table: ITableFullVo | undefined; + try { + foreignTable = await createTable(baseId, { + name: `record-presentation-foreign-${Date.now()}`, + fields: [ + { + id: foreignNameFieldId, + name: 'Company', + type: FieldType.SingleLineText, + isPrimary: true, + }, + { + id: foreignRevenueFieldId, + name: 'Revenue', + type: FieldType.Number, + options: { + formatting: { + type: NumberFormattingType.Decimal, + precision: 2, + }, + }, + }, + { + id: foreignAttachmentFieldId, + name: 'Documents', + type: FieldType.Attachment, + }, + ], + records: [ + { + fields: { + Company: 'Acme', + Revenue: 123.45, + }, + }, + ], + } as unknown as ICreateTableRo); + + table = await createTable(baseId, { + name: `record-presentation-structured-${Date.now()}`, + fields: [ + { + id: primaryFieldId, + name: 'Name', + type: FieldType.SingleLineText, + isPrimary: true, + }, + { + id: attachmentFieldId, + name: 'Files', + type: FieldType.Attachment, + }, + { + id: buttonFieldId, + name: 'Action', + type: FieldType.Button, + options: { + label: 'Run', + color: 'teal', + maxCount: 3, + resetCount: true, + }, + }, + ], + records: [], + } as unknown as ICreateTableRo); + + await createField(table.id, { + id: linkFieldId, + name: 'Company', + type: FieldType.Link, + options: { + relationship: Relationship.ManyOne, + foreignTableId: foreignTable.id, + lookupFieldId: foreignNameFieldId, + }, + }); + await createField(table.id, { + id: lookupFieldId, + name: 'Company Name', + type: FieldType.SingleLineText, + isLookup: true, + lookupOptions: { + linkFieldId, + foreignTableId: foreignTable.id, + lookupFieldId: foreignNameFieldId, + }, + }); + await createField(table.id, { + id: attachmentLookupFieldId, + name: 'Company Documents', + type: FieldType.Attachment, + isLookup: true, + lookupOptions: { + linkFieldId, + foreignTableId: foreignTable.id, + lookupFieldId: foreignAttachmentFieldId, + }, + }); + await createField(table.id, { + id: rollupFieldId, + name: 'Company Revenue', + type: FieldType.Rollup, + options: { + expression: 'sum({values})', + formatting: { + type: NumberFormattingType.Decimal, + precision: 2, + }, + timeZone: 'UTC', + }, + lookupOptions: { + linkFieldId, + foreignTableId: foreignTable.id, + lookupFieldId: foreignRevenueFieldId, + }, + }); + await createField(table.id, { + id: multipleLinkFieldId, + name: 'Related Companies', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: foreignTable.id, + lookupFieldId: foreignNameFieldId, + }, + }); + await createField(table.id, { + id: conditionalLookupFieldId, + name: 'High Revenue Companies', + type: FieldType.SingleLineText, + isLookup: true, + isConditionalLookup: true, + lookupOptions: { + foreignTableId: foreignTable.id, + lookupFieldId: foreignNameFieldId, + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: foreignRevenueFieldId, + operator: 'isGreater', + value: 100, + }, + ], + }, + }, + }); + await createField(table.id, { + id: conditionalAttachmentLookupFieldId, + name: 'High Revenue Documents', + type: FieldType.Attachment, + isLookup: true, + isConditionalLookup: true, + lookupOptions: { + foreignTableId: foreignTable.id, + lookupFieldId: foreignAttachmentFieldId, + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: foreignRevenueFieldId, + operator: 'isGreater', + value: 100, + }, + ], + }, + }, + }); + await createField(table.id, { + id: conditionalRollupFieldId, + name: 'High Revenue Total', + type: FieldType.ConditionalRollup, + options: { + foreignTableId: foreignTable.id, + lookupFieldId: foreignRevenueFieldId, + expression: 'sum({values})', + timeZone: 'UTC', + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: foreignRevenueFieldId, + operator: 'isGreater', + value: 100, + }, + ], + }, + }, + }); + + const created = await createRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + records: [ + { + fields: { + [primaryFieldId]: 'Structured row', + [linkFieldId]: { + id: foreignTable.records[0]!.id, + title: 'Acme', + }, + [multipleLinkFieldId]: [ + { + id: foreignTable.records[0]!.id, + title: 'Acme', + }, + ], + }, + }, + ], + }); + await uploadAttachment( + table.id, + created.records[0]!.id, + attachmentFieldId, + fs.createReadStream(attachmentFixturePath), + { filename: 'presentation.txt' } + ); + await uploadAttachment( + foreignTable.id, + foreignTable.records[0]!.id, + foreignAttachmentFieldId, + fs.createReadStream(attachmentFixturePath), + { filename: 'foreign-presentation.txt' } + ); + await updateRecordByApi( + foreignTable.id, + foreignTable.records[0]!.id, + foreignRevenueFieldId, + 124.5 + ); + + const jsonQuery = { + fieldKeyType: FieldKeyType.Id, + cellFormat: CellFormat.Json, + }; + const v1Json = await getRecordsFromVersion(table.id, false, jsonQuery); + const v2Json = await getRecordsFromVersion(table.id, true, jsonQuery); + expect(v2Json.records).toEqual(v1Json.records); + expect(v1Json.records[0]?.fields[attachmentFieldId]).toEqual([ + expect.objectContaining({ + name: 'presentation.txt', + token: expect.any(String), + presignedUrl: expect.any(String), + }), + ]); + expect(v1Json.records[0]?.fields[linkFieldId]).toEqual({ + id: foreignTable.records[0]!.id, + title: 'Acme', + }); + expect(v1Json.records[0]?.fields[multipleLinkFieldId]).toEqual([ + { + id: foreignTable.records[0]!.id, + title: 'Acme', + }, + ]); + expect(v1Json.records[0]?.fields[lookupFieldId]).toBe('Acme'); + expect(v1Json.records[0]?.fields[attachmentLookupFieldId]).toEqual([ + expect.objectContaining({ + name: 'foreign-presentation.txt', + presignedUrl: expect.any(String), + }), + ]); + expect(v1Json.records[0]?.fields[rollupFieldId]).toBe(124.5); + expect(v1Json.records[0]?.fields[conditionalLookupFieldId]).toEqual(['Acme']); + expect(v1Json.records[0]?.fields[conditionalAttachmentLookupFieldId]).toEqual([ + expect.objectContaining({ + name: 'foreign-presentation.txt', + presignedUrl: expect.any(String), + }), + ]); + expect(v1Json.records[0]?.fields[conditionalRollupFieldId]).toBe(124.5); + expect(v1Json.records[0]?.fields).not.toHaveProperty(buttonFieldId); + + for (const fieldId of [ + attachmentFieldId, + attachmentLookupFieldId, + conditionalAttachmentLookupFieldId, + linkFieldId, + ]) { + const groupQuery = { + fieldKeyType: FieldKeyType.Id, + cellFormat: CellFormat.Json, + groupBy: [{ fieldId, order: SortFunc.Asc }], + includeQueryExtra: true, + }; + const v1Group = await getRecordsFromVersion(table.id, false, groupQuery); + const v2Group = await getRecordsFromVersion(table.id, true, groupQuery); + expect(v2Group.extra).toEqual(v1Group.extra); + } + + const textQuery = { + fieldKeyType: FieldKeyType.Id, + cellFormat: CellFormat.Text, + }; + const v1Text = await getRecordsFromVersion(table.id, false, textQuery); + const v2Text = await getRecordsFromVersion(table.id, true, textQuery); + expect(v2Text.records).toEqual(v1Text.records); + expect(v1Text.records[0]?.fields[attachmentFieldId]).toMatch(/^presentation\.txt \([^)]+\)$/); + expect(v1Text.records[0]?.fields[linkFieldId]).toBe('Acme'); + expect(v1Text.records[0]?.fields[multipleLinkFieldId]).toBe('Acme'); + expect(v1Text.records[0]?.fields[lookupFieldId]).toBe('Acme'); + expect(v1Text.records[0]?.fields[rollupFieldId]).toBe('124.50'); + expect(v1Text.records[0]?.fields[conditionalLookupFieldId]).toBe('Acme'); + expect(v1Text.records[0]?.fields[conditionalRollupFieldId]).toBe('124.50'); + expect(v1Text.records[0]?.fields).not.toHaveProperty(buttonFieldId); + } finally { + if (table) { + await permanentDeleteTable(baseId, table.id); + } + if (foreignTable) { + await permanentDeleteTable(baseId, foreignTable.id); + } + } + }, 30_000); +}); diff --git a/apps/nestjs-backend/test/record-socket-snapshot-bulk.e2e-spec.ts b/apps/nestjs-backend/test/record-socket-snapshot-bulk.e2e-spec.ts new file mode 100644 index 0000000000..65b8fd0f60 --- /dev/null +++ b/apps/nestjs-backend/test/record-socket-snapshot-bulk.e2e-spec.ts @@ -0,0 +1,84 @@ +import type { INestApplication } from '@nestjs/common'; +import { FieldType } from '@teable/core'; +import type { ITableFullVo } from '@teable/openapi'; +import { ClsService } from 'nestjs-cls'; +import { RecordReadonlyServiceAdapter } from '../src/share-db/readonly/record-readonly.service'; +import type { IClsStore } from '../src/types/cls'; +import { createRecords, createTable, initApp, permanentDeleteTable } from './utils/init-app'; + +// A grid scroll fetches up to 300 records at once and a wide view projects +// every visible field. The ShareDB readonly adapter forwards that request to +// its own HTTP API, so it must not be sensitive to ids/projection size: as GET +// query params this payload exceeds Node's 16KB header limit and the server +// rejects it with 431 before routing. +const FIELD_COUNT = 300; +const RECORD_COUNT = 300; + +describe('Record socket snapshot-bulk (e2e)', () => { + let app: INestApplication; + let cookie: string; + const baseId = globalThis.testConfig.baseId; + let table: ITableFullVo; + let recordIds: string[]; + + beforeAll(async () => { + const bundle = await initApp(); + app = bundle.app; + cookie = bundle.cookie; + + table = await createTable(baseId, { + name: 'snapshot-bulk wide', + fields: Array.from({ length: FIELD_COUNT }, (_, i) => ({ + name: `text ${i}`, + type: FieldType.SingleLineText, + })), + }); + + const created = await createRecords(table.id, { + records: Array.from({ length: RECORD_COUNT }, (_, i) => ({ + fields: { [table.fields[0].id]: `record ${i}` }, + })), + }); + recordIds = created.records.map((record) => record.id); + }); + + afterAll(async () => { + await permanentDeleteTable(baseId, table.id); + }); + + it('loads a 300-record window with a full wide projection', async () => { + const adapter = app.get(RecordReadonlyServiceAdapter); + const clsService = app.get>(ClsService); + const projection = Object.fromEntries(table.fields.map((field) => [field.id, true])); + + // Guard the regression premise: keep the payload large enough that the old + // GET-with-query transport could not have carried it (16KB header limit). + // Sized with axios' serialization, which keeps [] brackets unescaped. + const asGetQueryLength = + recordIds.reduce((sum, id) => sum + `ids[]=${id}&`.length, 0) + + table.fields.reduce((sum, field) => sum + `projection[${field.id}]=true&`.length, 0); + expect(asGetQueryLength).toBeGreaterThan(16 * 1024); + + const snapshots = await clsService.runWith( + { + user: { + id: globalThis.testConfig.userId, + name: globalThis.testConfig.userName, + email: globalThis.testConfig.email, + isAdmin: false, + }, + origin: { ip: '127.0.0.1', byApi: false, userAgent: 'test-agent', referer: '' }, + tx: {}, + permissions: [], + cookie, + } as IClsStore, + () => adapter.getSnapshotBulk(table.id, recordIds, projection) + ); + + expect(snapshots).toHaveLength(RECORD_COUNT); + const byId = new Map(snapshots.map((snapshot) => [snapshot.data.id, snapshot])); + recordIds.forEach((recordId, i) => { + expect(byId.get(recordId)?.data.fields[table.fields[0].id]).toEqual(`record ${i}`); + }); + }); +}); diff --git a/apps/nestjs-backend/test/record.e2e-spec.ts b/apps/nestjs-backend/test/record.e2e-spec.ts index 6ea79efcf1..3533440e33 100644 --- a/apps/nestjs-backend/test/record.e2e-spec.ts +++ b/apps/nestjs-backend/test/record.e2e-spec.ts @@ -10,6 +10,12 @@ import { Relationship, } from '@teable/core'; import { axios, buttonClick, buttonReset, updateRecords, type ITableFullVo } from '@teable/openapi'; +import { vi } from 'vitest'; +import { + X_TEABLE_V2_FEATURE_HEADER, + X_TEABLE_V2_HEADER, +} from '../src/features/canary/interceptors/v2-indicator.interceptor'; +import { RecordOpenApiService } from '../src/features/record/open-api/record-open-api.service'; import { convertField, createField, @@ -27,7 +33,6 @@ import { updateRecord, updateRecordByApi, } from './utils/init-app'; -import { X_TEABLE_V2_HEADER } from '../src/features/canary/interceptors/v2-indicator.interceptor'; describe('OpenAPI RecordController (e2e)', () => { let app: INestApplication; @@ -1064,13 +1069,20 @@ describe('OpenAPI RecordController (e2e)', () => { describe('button field click and reset', () => { let table: ITableFullVo; + let previousForceV2All: string | undefined; beforeAll(async () => { + // These cases assert the v2 button-click chain (attribution headers and + // legacy-service isolation); pin the env regardless of the CI lane. + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; table = await createTable(baseId, { name: 'table1', }); }); afterAll(async () => { + if (previousForceV2All == null) delete process.env.FORCE_V2_ALL; + else process.env.FORCE_V2_ALL = previousForceV2All; await permanentDeleteTable(baseId, table.id); }); @@ -1088,9 +1100,20 @@ describe('OpenAPI RecordController (e2e)', () => { }, }); - const res = await buttonClick(table.id, table.records[0].id, field.id); - const value = res.data.record.fields[field.id] as IButtonFieldCellValue; - expect(value.count).toEqual(1); + const legacyService = app.get(RecordOpenApiService); + const legacySpy = vi + .spyOn(legacyService, 'buttonClick') + .mockRejectedValue(new Error('legacy buttonClick must not be used')); + try { + const res = await buttonClick(table.id, table.records[0].id, field.id); + const value = res.data.record.fields[field.id] as IButtonFieldCellValue; + expect(value.count).toEqual(1); + expect(res.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(res.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('buttonClick'); + expect(legacySpy).not.toHaveBeenCalled(); + } finally { + legacySpy.mockRestore(); + } }); it('should not click a button field without workflow', async () => { @@ -1102,7 +1125,7 @@ describe('OpenAPI RecordController (e2e)', () => { }, }); - expect(buttonClick(table.id, table.records[0].id, field.id)).rejects.toThrow(); + await expect(buttonClick(table.id, table.records[0].id, field.id)).rejects.toThrow(); }); it('should not click a button field with exceed max count', async () => { @@ -1124,7 +1147,7 @@ describe('OpenAPI RecordController (e2e)', () => { const value = res.data.record.fields[field.id] as IButtonFieldCellValue; expect(value.count).toEqual(1); - expect(buttonClick(table.id, table.records[0].id, field.id)).rejects.toThrow(); + await expect(buttonClick(table.id, table.records[0].id, field.id)).rejects.toThrow(); }); it('should reset a button field', async () => { @@ -1146,9 +1169,25 @@ describe('OpenAPI RecordController (e2e)', () => { const clickValue = clickRes.data.record.fields[field.id] as IButtonFieldCellValue; expect(clickValue.count).toEqual(1); - const resetRes = await buttonReset(table.id, table.records[0].id, field.id); - const resetValue = resetRes.data.fields[field.id] as IButtonFieldCellValue; - expect(resetValue).toBeUndefined(); + const legacyService = app.get(RecordOpenApiService); + const legacySpy = vi + .spyOn(legacyService, 'resetButton') + .mockRejectedValue(new Error('legacy resetButton must not be used')); + try { + const resetRes = await buttonReset(table.id, table.records[0].id, field.id); + const resetValue = resetRes.data.fields[field.id] as IButtonFieldCellValue; + expect(resetValue).toBeUndefined(); + expect(resetRes.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(resetRes.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('buttonReset'); + await expect(buttonReset(table.id, table.records[0].id, field.id)).resolves.toMatchObject({ + headers: { + [X_TEABLE_V2_FEATURE_HEADER]: 'buttonReset', + }, + }); + expect(legacySpy).not.toHaveBeenCalled(); + } finally { + legacySpy.mockRestore(); + } }); it('should not reset a button field without resetCount', async () => { @@ -1165,7 +1204,7 @@ describe('OpenAPI RecordController (e2e)', () => { }, }); - expect(buttonReset(table.id, table.records[0].id, field.id)).rejects.toThrow(); + await expect(buttonReset(table.id, table.records[0].id, field.id)).rejects.toThrow(); }); }); diff --git a/apps/nestjs-backend/test/selection.e2e-spec.ts b/apps/nestjs-backend/test/selection.e2e-spec.ts index d9a167434a..3c325d4014 100644 --- a/apps/nestjs-backend/test/selection.e2e-spec.ts +++ b/apps/nestjs-backend/test/selection.e2e-spec.ts @@ -4323,8 +4323,10 @@ describe('OpenAPI SelectionController (e2e)', () => { const recordsAfter = await getRecords(streamTable.id, { fieldKeyType: FieldKeyType.Id, }); + // Cleared single-line text is stored as null and omitted from the record + // payload, so the first row reads as undefined rather than ''. expect(recordsAfter.data.records.map((record) => record.fields[nameFieldId])).toEqual([ - '', + undefined, 'new-2', ]); } finally { diff --git a/apps/nestjs-backend/test/share-socket.e2e-spec.ts b/apps/nestjs-backend/test/share-socket.e2e-spec.ts index e4b02432de..03d834f14b 100644 --- a/apps/nestjs-backend/test/share-socket.e2e-spec.ts +++ b/apps/nestjs-backend/test/share-socket.e2e-spec.ts @@ -7,6 +7,8 @@ import { } from '@teable/openapi'; import { map } from 'lodash'; import type { Connection, Doc } from 'sharedb/lib/client'; +import { vi } from 'vitest'; +import { ViewService } from '../src/features/view/view.service'; import { ShareDbService } from '../src/share-db/share-db.service'; import { getError } from './utils/get-error'; import { initApp, updateViewColumnMeta, createTable, permanentDeleteTable } from './utils/init-app'; @@ -22,8 +24,11 @@ describe('Share (socket-e2e) (e2e)', () => { const timeoutErrorMessage = 'connection timeout'; let fieldIds: string[] = []; let shareDbService!: ShareDbService; + let previousForceV2All: string | undefined; beforeAll(async () => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; const appCtx = await initApp(); app = appCtx.app; port = process.env.PORT!; @@ -58,6 +63,8 @@ describe('Share (socket-e2e) (e2e)', () => { await permanentDeleteTable(baseId, tableId); await app.close(); + if (previousForceV2All == null) delete process.env.FORCE_V2_ALL; + else process.env.FORCE_V2_ALL = previousForceV2All; }); const createConnection = (shareId: string): Connection => { @@ -153,12 +160,19 @@ describe('Share (socket-e2e) (e2e)', () => { }); describe('View queries', () => { - it('should only get the shared view', async () => { + it('should only get the shared view through v2 without using ViewService', async () => { + const viewService = app.get(ViewService); + const legacyDocIdsSpy = vi.spyOn(viewService, 'getDocIdsByQuery'); + const legacySnapshotsSpy = vi.spyOn(viewService, 'getSnapshotBulk'); const collection = `${IdPrefix.View}_${tableId}`; const views = await getQuery(collection, shareId); expect(views.length).toEqual(1); expect(views[0].id).toEqual(viewId); + expect(legacyDocIdsSpy).not.toHaveBeenCalled(); + expect(legacySnapshotsSpy).not.toHaveBeenCalled(); + legacyDocIdsSpy.mockRestore(); + legacySnapshotsSpy.mockRestore(); }); it('should get view document by id', async () => { diff --git a/apps/nestjs-backend/test/share.e2e-spec.ts b/apps/nestjs-backend/test/share.e2e-spec.ts index 5356cfc1f0..660cb04706 100644 --- a/apps/nestjs-backend/test/share.e2e-spec.ts +++ b/apps/nestjs-backend/test/share.e2e-spec.ts @@ -1,5 +1,6 @@ import { type INestApplication } from '@nestjs/common'; import type { + IButtonFieldCellValue, IFieldRo, IFilterRo, ILinkFieldOptions, @@ -9,20 +10,26 @@ import type { } from '@teable/core'; import { ANONYMOUS_USER_ID, + Colors, DateFormattingPreset, FieldKeyType, FieldType, + generateWorkflowId, is, Relationship, SortFunc, + StatisticsFunc, TimeFormatting, ViewType, } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; import { urlBuilder, SHARE_VIEW_GET, SHARE_VIEW_FORM_SUBMIT, SHARE_VIEW_RECORDS, + SHARE_VIEW_CALENDAR_DAILY_COLLECTION, + SHARE_VIEW_ROW_COUNT, createRecords as apiCreateRecords, deleteRecords as apiDeleteRecords, enableShareView as apiEnableShareView, @@ -34,6 +41,7 @@ import { updateViewColumnMeta as apiUpdateViewColumnMeta, updateViewShareMeta as apiUpdateViewShareMeta, SHARE_VIEW_COPY, + SHARE_VIEW_BUTTON_CLICK, SHARE_VIEW_AUTH, getShareView, createField, @@ -47,14 +55,43 @@ import { CREATE_RECORD, DELETE_RECORD_URL, GET_RECORDS_URL, + GET_SHARE_VIEW_SEARCH_COUNT, + GET_SHARE_VIEW_SEARCH_INDEX, OPERATION_UNDO, PASTE_URL, SHARE_VIEW_COLLABORATORS, SHARE_VIEW_ID_HEADER, UPDATE_RECORD, + getShareViewSearchCount, + getShareViewSearchIndex, + getShareViewAggregations, + getShareViewGroupPoints, + GroupPointType, + ShareViewLinkRecordsType, +} from '@teable/openapi'; +import type { + ICopyVo, + IButtonClickVo, + IGroupPoint, + ITableFullVo, + ShareViewAuthVo, + ShareViewGetVo, } from '@teable/openapi'; -import type { ITableFullVo, ShareViewAuthVo, ShareViewGetVo } from '@teable/openapi'; import { map } from 'lodash'; +import { vi } from 'vitest'; +import { CacheService } from '../src/cache/cache.service'; +import type { ICacheStore } from '../src/cache/types'; +import { + X_TEABLE_V2_FEATURE_HEADER, + X_TEABLE_V2_HEADER, + X_TEABLE_V2_REASON_HEADER, +} from '../src/features/canary/interceptors/v2-indicator.interceptor'; +import { CollaboratorService } from '../src/features/collaborator/collaborator.service'; +import { FieldService } from '../src/features/field/field.service'; +import { RecordOpenApiService } from '../src/features/record/open-api/record-open-api.service'; +import { RecordService } from '../src/features/record/record.service'; +import { SelectionService } from '../src/features/selection/selection.service'; +import { ShareService } from '../src/features/share/share.service'; import { x_20 } from './data-helpers/20x'; import { createAnonymousUserAxios } from './utils/axios-instance/anonymous-user'; import { createNewUserAxios } from './utils/axios-instance/new-user'; @@ -85,6 +122,15 @@ const gridViewRo: IViewRo = { type: ViewType.Grid, }; +const isGroupHeaderPoint = ( + point: IGroupPoint +): point is Extract => + point.type === GroupPointType.Header; + +const isGroupRowPoint = ( + point: IGroupPoint +): point is Extract => point.type === GroupPointType.Row; + describe('OpenAPI ShareController (e2e)', () => { let app: INestApplication; let tableId: string; @@ -96,10 +142,31 @@ describe('OpenAPI ShareController (e2e)', () => { const userName = globalThis.testConfig.userName; let fieldIds: string[] = []; let anonymousUser: ReturnType; + let cacheService: CacheService; + let fieldService: FieldService; + let recordService: RecordService; + let recordOpenApiService: RecordOpenApiService; + let selectionService: SelectionService; + let shareService: ShareService; + let collaboratorService: CollaboratorService; + let prismaService: PrismaService; + let previousForceV2All: string | undefined; beforeAll(async () => { + // Every v2 attribution assertion in this file expects the env_force_v2_all + // reason; pin the env for the suite regardless of the CI lane default. + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; const appCtx = await initApp(); app = appCtx.app; + cacheService = app.get(CacheService); + fieldService = app.get(FieldService); + recordService = app.get(RecordService); + recordOpenApiService = app.get(RecordOpenApiService); + selectionService = app.get(SelectionService); + shareService = app.get(ShareService); + collaboratorService = app.get(CollaboratorService); + prismaService = app.get(PrismaService); anonymousUser = createAnonymousUserAxios(appCtx.appUrl); baseId = await createBase({ name: 'share-e2e', @@ -121,12 +188,62 @@ describe('OpenAPI ShareController (e2e)', () => { }); afterAll(async () => { + if (previousForceV2All == null) delete process.env.FORCE_V2_ALL; + else process.env.FORCE_V2_ALL = previousForceV2All; await permanentDeleteBase(baseId); await permanentDeleteTable(baseId, tableId); await app.close(); }); describe('api/:shareId/view (GET)', async () => { + it('uses only v2 Table/Field/Record reads once the feature is selected', async () => { + const legacyShareSpy = vi + .spyOn(shareService, 'getShareView') + .mockRejectedValue(new Error('legacy ShareService metadata path must not be used')); + const legacyFieldSpy = vi + .spyOn(fieldService, 'getFieldsByQuery') + .mockRejectedValue(new Error('legacy FieldService must not be used')); + const legacyRecordSpy = vi + .spyOn(recordService, 'getRecords') + .mockRejectedValue(new Error('legacy RecordService must not be used')); + + try { + const result = await anonymousUser.get( + urlBuilder(SHARE_VIEW_GET, { shareId }) + ); + + expect(result.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedView'); + expect(result.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(result.data.fields).toHaveLength(fieldIds.length - 1); + expect(result.data.records.length).toBeGreaterThan(0); + expect(legacyShareSpy).not.toHaveBeenCalled(); + expect(legacyFieldSpy).not.toHaveBeenCalled(); + expect(legacyRecordSpy).not.toHaveBeenCalled(); + } finally { + legacyShareSpy.mockRestore(); + legacyFieldSpy.mockRestore(); + legacyRecordSpy.mockRestore(); + } + }); + + it('includes hidden fields only when the aggregate share metadata allows it', async () => { + await apiUpdateViewShareMeta(tableId, viewId, { includeHiddenField: true }); + try { + const result = await anonymousUser.get( + urlBuilder(SHARE_VIEW_GET, { shareId }) + ); + + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedView'); + expect(result.data.fields).toHaveLength(fieldIds.length); + for (const record of result.data.records) { + expect(Object.keys(record.fields)).toHaveLength(fieldIds.length); + } + } finally { + await apiUpdateViewShareMeta(tableId, viewId, { includeHiddenField: false }); + } + }); + it('should return view', async () => { const result = await anonymousUser.get( urlBuilder(SHARE_VIEW_GET, { shareId }) @@ -173,6 +290,9 @@ describe('OpenAPI ShareController (e2e)', () => { password: '123123123', } ); + expect(res.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(res.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedView'); + expect(res.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); const resultData = await anonymousUser.get( urlBuilder(SHARE_VIEW_GET, { shareId: gridViewShareId }), { @@ -183,6 +303,54 @@ describe('OpenAPI ShareController (e2e)', () => { ); expect(resultData.data.viewId).toEqual(gridViewId); }); + + it('keeps password authentication and shared reads on v1 when canary is disabled', async () => { + const previousForceV2All = process.env.FORCE_V2_ALL; + const previousCanary = process.env.ENABLE_CANARY_FEATURE; + const previousBase = await prismaService.base.findUniqueOrThrow({ + where: { id: baseId }, + select: { v2Enabled: true }, + }); + process.env.FORCE_V2_ALL = 'false'; + process.env.ENABLE_CANARY_FEATURE = 'false'; + await prismaService.base.update({ + where: { id: baseId }, + data: { v2Enabled: false }, + }); + + try { + const result = await createView(tableId, gridViewRo); + const legacyViewId = result.id; + const shareResult = await apiEnableShareView({ tableId, viewId: legacyViewId }); + const legacyShareId = shareResult.data.shareId; + await apiUpdateViewShareMeta(tableId, legacyViewId, { password: 'legacy-password' }); + + const authResponse = await anonymousUser.post( + urlBuilder(SHARE_VIEW_AUTH, { shareId: legacyShareId }), + { password: 'legacy-password' } + ); + + expect(authResponse.headers[X_TEABLE_V2_HEADER]).toBe('false'); + expect(authResponse.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedView'); + expect(authResponse.headers[X_TEABLE_V2_REASON_HEADER]).toBe('disabled'); + + const viewResponse = await anonymousUser.get( + urlBuilder(SHARE_VIEW_GET, { shareId: legacyShareId }), + { headers: { cookie: authResponse.headers['set-cookie'] } } + ); + expect(viewResponse.headers[X_TEABLE_V2_HEADER]).toBe('false'); + expect(viewResponse.data.viewId).toBe(legacyViewId); + } finally { + if (previousForceV2All == null) delete process.env.FORCE_V2_ALL; + else process.env.FORCE_V2_ALL = previousForceV2All; + if (previousCanary == null) delete process.env.ENABLE_CANARY_FEATURE; + else process.env.ENABLE_CANARY_FEATURE = previousCanary; + await prismaService.base.update({ + where: { id: baseId }, + data: { v2Enabled: previousBase.v2Enabled }, + }); + } + }); }); describe('api/:shareId/view/form-submit (POST)', () => { @@ -298,6 +466,32 @@ describe('OpenAPI ShareController (e2e)', () => { await permanentDeleteTable(baseId, recordsTableId); }); + it('uses the v2 Field scope and Record query without legacy reads', async () => { + const legacyFieldSpy = vi + .spyOn(fieldService, 'getFieldsByQuery') + .mockRejectedValue(new Error('legacy FieldService must not be used')); + const legacyRecordSpy = vi + .spyOn(recordService, 'getRecords') + .mockRejectedValue(new Error('legacy RecordService must not be used')); + + try { + const result = await apiGetShareViewRecords(recordsShareId, { + take: 2, + skip: 0, + }); + + expect(result.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedViewRecords'); + expect(result.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(result.data.records).toHaveLength(2); + expect(legacyFieldSpy).not.toHaveBeenCalled(); + expect(legacyRecordSpy).not.toHaveBeenCalled(); + } finally { + legacyFieldSpy.mockRestore(); + legacyRecordSpy.mockRestore(); + } + }); + it('should return records with pagination', async () => { const result = await apiGetShareViewRecords(recordsShareId, { take: 2, @@ -398,6 +592,859 @@ describe('OpenAPI ShareController (e2e)', () => { }); }); + describe('api/:shareId/view/row-count (GET)', () => { + let rowCountTableId: string; + let rowCountViewId: string; + let rowCountShareId: string; + let nameFieldId: string; + let checkboxFieldId: string; + + beforeAll(async () => { + const table = await createTable(baseId, { + name: 'row-count-test-table', + fields: [ + { name: 'Name', type: FieldType.SingleLineText }, + { name: 'Done', type: FieldType.Checkbox }, + ], + records: [ + { fields: { Name: 'Alpha', Done: true } }, + { fields: { Name: 'Beta', Done: false } }, + { fields: { Name: 'Gamma', Done: false } }, + ], + }); + rowCountTableId = table.id; + rowCountViewId = table.defaultViewId!; + nameFieldId = table.fields[0].id; + checkboxFieldId = table.fields[1].id; + const shareResult = await apiEnableShareView({ + tableId: rowCountTableId, + viewId: rowCountViewId, + }); + rowCountShareId = shareResult.data.shareId; + }); + + afterAll(async () => { + await permanentDeleteTable(baseId, rowCountTableId); + }); + + it('uses the v2 Table/Record query without legacy aggregation or Field reads', async () => { + const legacyRowCountSpy = vi + .spyOn(shareService, 'getViewRowCount') + .mockRejectedValue(new Error('legacy AggregationService path must not be used')); + const legacyFieldSpy = vi + .spyOn(fieldService, 'getFieldInstances') + .mockRejectedValue(new Error('legacy FieldService filter metadata must not be used')); + const legacyRecordSpy = vi + .spyOn(recordService, 'getRecords') + .mockRejectedValue(new Error('legacy RecordService must not be used')); + + try { + const result = await anonymousUser.get( + urlBuilder(SHARE_VIEW_ROW_COUNT, { shareId: rowCountShareId }) + ); + + expect(result.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedViewRowCount'); + expect(result.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(result.data).toEqual({ rowCount: 3 }); + expect(legacyRowCountSpy).not.toHaveBeenCalled(); + expect(legacyFieldSpy).not.toHaveBeenCalled(); + expect(legacyRecordSpy).not.toHaveBeenCalled(); + } finally { + legacyRowCountSpy.mockRestore(); + legacyFieldSpy.mockRestore(); + legacyRecordSpy.mockRestore(); + } + }); + + it('combines the aggregate View filter with a request filter', async () => { + await updateViewFilter(rowCountTableId, rowCountViewId, { + filter: { + conjunction: 'and', + filterSet: [{ fieldId: nameFieldId, operator: 'contains', value: 'a' }], + }, + }); + try { + const result = await getShareViewRowCount(rowCountShareId, { + filter: { + conjunction: 'and', + filterSet: [{ fieldId: nameFieldId, operator: is.value, value: 'Beta' }], + }, + }); + + expect(result.data.rowCount).toBe(1); + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedViewRowCount'); + } finally { + await updateViewFilter(rowCountTableId, rowCountViewId, { filter: null }); + } + }); + + it('normalizes the legacy unchecked-checkbox null filter through v2 Field metadata', async () => { + const result = await getShareViewRowCount(rowCountShareId, { + filter: { + conjunction: 'and', + filterSet: [{ fieldId: checkboxFieldId, operator: is.value, value: null }], + }, + }); + + expect(result.data.rowCount).toBe(2); + }); + + it('counts only records matching visible-row search', async () => { + const result = await getShareViewRowCount(rowCountShareId, { + search: ['Alpha', nameFieldId, true], + }); + + expect(result.data.rowCount).toBe(1); + }); + + it('returns zero before querying records when sharing disables records', async () => { + await apiUpdateViewShareMeta(rowCountTableId, rowCountViewId, { + includeRecords: false, + }); + try { + const result = await getShareViewRowCount(rowCountShareId, {}); + + expect(result.data).toEqual({ rowCount: 0 }); + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedViewRowCount'); + } finally { + await apiUpdateViewShareMeta(rowCountTableId, rowCountViewId, { + includeRecords: true, + }); + } + }); + + it('rejects simultaneous link candidate and selected query modes', async () => { + const error = await getError(() => + getShareViewRowCount(rowCountShareId, { + filterLinkCellCandidate: nameFieldId, + filterLinkCellSelected: nameFieldId, + }) + ); + + expect(error?.status).toBe(400); + }); + + it('preserves password protection before executing the v2 query', async () => { + await apiUpdateViewShareMeta(rowCountTableId, rowCountViewId, { + password: 'row-count-password', + }); + try { + const error = await getError(() => + anonymousUser.get(urlBuilder(SHARE_VIEW_ROW_COUNT, { shareId: rowCountShareId })) + ); + + expect(error?.status).toBe(401); + } finally { + await apiUpdateViewShareMeta(rowCountTableId, rowCountViewId, { + password: undefined, + }); + } + }); + }); + + describe('api/:shareId/view/aggregations (GET)', () => { + const defaultAggregationShareMeta = { includeRecords: true }; + let aggregationTableId: string; + let aggregationViewId: string; + let aggregationShareId: string; + let nameFieldId: string; + let amountFieldId: string; + let doneFieldId: string; + let secretFieldId: string; + let dueFieldId: string; + + beforeAll(async () => { + const table = await createTable(baseId, { + name: 'shared-aggregation-v2', + fields: [ + { name: 'Name', type: FieldType.SingleLineText }, + { name: 'Amount', type: FieldType.Number }, + { name: 'Done', type: FieldType.Checkbox }, + { name: 'Secret', type: FieldType.Number }, + { name: 'Due', type: FieldType.Date }, + ], + records: [ + { + fields: { + Name: 'A', + Amount: 10, + Done: true, + Secret: 100, + Due: '2025-01-01T00:00:00.000Z', + }, + }, + { + fields: { + Name: 'A', + Amount: 20, + Done: false, + Secret: 200, + Due: '2025-02-15T00:00:00.000Z', + }, + }, + { + fields: { + Name: 'B', + Amount: 30, + Done: false, + Secret: 300, + Due: '2025-03-01T00:00:00.000Z', + }, + }, + ], + }); + aggregationTableId = table.id; + aggregationViewId = table.defaultViewId!; + [nameFieldId, amountFieldId, doneFieldId, secretFieldId, dueFieldId] = table.fields.map( + (field) => field.id + ); + await updateViewColumnMeta(aggregationTableId, aggregationViewId, [ + { fieldId: amountFieldId, columnMeta: { statisticFunc: StatisticsFunc.Sum } }, + { + fieldId: secretFieldId, + columnMeta: { hidden: true, statisticFunc: StatisticsFunc.Sum }, + }, + ]); + const shareResult = await apiEnableShareView({ + tableId: aggregationTableId, + viewId: aggregationViewId, + }); + aggregationShareId = shareResult.data.shareId; + }); + + afterAll(async () => { + await permanentDeleteTable(baseId, aggregationTableId); + }); + + it('uses the pure v2 Table/Record chain and returns totals plus grouped prefixes', async () => { + const legacyAggregationSpy = vi + .spyOn(shareService, 'getViewAggregations') + .mockRejectedValue(new Error('legacy AggregationService path must not be used')); + const legacyFieldSpy = vi + .spyOn(fieldService, 'getFieldInstances') + .mockRejectedValue(new Error('legacy FieldService must not be used')); + const legacyRecordSpy = vi + .spyOn(recordService, 'getRecords') + .mockRejectedValue(new Error('legacy RecordService must not be used')); + + try { + const result = await getShareViewAggregations(aggregationShareId, { + field: { + [StatisticsFunc.Count]: [nameFieldId], + [StatisticsFunc.Sum]: [amountFieldId], + [StatisticsFunc.Checked]: [doneFieldId], + }, + groupBy: [{ fieldId: nameFieldId, order: SortFunc.Asc }], + }); + + expect(result.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedViewAggregations'); + expect(result.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(result.data.aggregations?.map(({ fieldId, total }) => ({ fieldId, total }))).toEqual( + [ + { fieldId: nameFieldId, total: { value: 3, aggFunc: StatisticsFunc.Count } }, + { fieldId: amountFieldId, total: { value: 60, aggFunc: StatisticsFunc.Sum } }, + { fieldId: doneFieldId, total: { value: 1, aggFunc: StatisticsFunc.Checked } }, + ] + ); + expect( + result.data.aggregations?.map(({ group }) => + Object.values(group ?? {}) + .map(({ value }) => value) + .sort((left, right) => Number(left) - Number(right)) + ) + ).toEqual([ + [1, 2], + [30, 30], + [0, 1], + ]); + expect(legacyAggregationSpy).not.toHaveBeenCalled(); + expect(legacyFieldSpy).not.toHaveBeenCalled(); + expect(legacyRecordSpy).not.toHaveBeenCalled(); + } finally { + legacyAggregationSpy.mockRestore(); + legacyFieldSpy.mockRestore(); + legacyRecordSpy.mockRestore(); + } + }); + + it('merges the aggregate View filter with the request filter', async () => { + await updateViewFilter(aggregationTableId, aggregationViewId, { + filter: { + conjunction: 'and', + filterSet: [{ fieldId: nameFieldId, operator: is.value, value: 'A' }], + }, + }); + try { + const result = await getShareViewAggregations(aggregationShareId, { + field: { [StatisticsFunc.Sum]: [amountFieldId] }, + filter: { + conjunction: 'and', + filterSet: [{ fieldId: amountFieldId, operator: is.value, value: 20 }], + }, + }); + + expect(result.data.aggregations).toEqual([ + { + fieldId: amountFieldId, + total: { value: 20, aggFunc: StatisticsFunc.Sum }, + }, + ]); + } finally { + await updateViewFilter(aggregationTableId, aggregationViewId, { filter: null }); + } + }); + + it('applies visible-row search before aggregation', async () => { + const result = await getShareViewAggregations(aggregationShareId, { + field: { [StatisticsFunc.Count]: [nameFieldId] }, + search: ['A', nameFieldId, true], + }); + + expect(result.data.aggregations).toEqual([ + { + fieldId: nameFieldId, + total: { value: 2, aggFunc: StatisticsFunc.Count }, + }, + ]); + }); + + it('covers empty, filled, unique, average, percentage, and date-range functions', async () => { + const result = await getShareViewAggregations(aggregationShareId, { + field: { + [StatisticsFunc.Empty]: [amountFieldId], + [StatisticsFunc.Filled]: [amountFieldId], + [StatisticsFunc.Unique]: [nameFieldId], + [StatisticsFunc.Average]: [amountFieldId], + [StatisticsFunc.PercentFilled]: [amountFieldId], + [StatisticsFunc.EarliestDate]: [dueFieldId], + [StatisticsFunc.LatestDate]: [dueFieldId], + [StatisticsFunc.DateRangeOfDays]: [dueFieldId], + [StatisticsFunc.DateRangeOfMonths]: [dueFieldId], + }, + }); + + expect(result.data.aggregations).toEqual([ + { fieldId: amountFieldId, total: { value: 0, aggFunc: StatisticsFunc.Empty } }, + { fieldId: amountFieldId, total: { value: 3, aggFunc: StatisticsFunc.Filled } }, + { fieldId: nameFieldId, total: { value: 2, aggFunc: StatisticsFunc.Unique } }, + { fieldId: amountFieldId, total: { value: 20, aggFunc: StatisticsFunc.Average } }, + { + fieldId: amountFieldId, + total: { value: 100, aggFunc: StatisticsFunc.PercentFilled }, + }, + { + fieldId: dueFieldId, + total: { value: '2025-01-01T00:00:00.000Z', aggFunc: StatisticsFunc.EarliestDate }, + }, + { + fieldId: dueFieldId, + total: { value: '2025-03-01T00:00:00.000Z', aggFunc: StatisticsFunc.LatestDate }, + }, + { + fieldId: dueFieldId, + total: { value: 59, aggFunc: StatisticsFunc.DateRangeOfDays }, + }, + { + fieldId: dueFieldId, + total: { value: 2, aggFunc: StatisticsFunc.DateRangeOfMonths }, + }, + ]); + }); + + it('uses visible View column statistics by default and skips hidden statistics', async () => { + const result = await getShareViewAggregations(aggregationShareId); + + expect(result.data.aggregations).toEqual([ + { + fieldId: amountFieldId, + total: { value: 60, aggFunc: StatisticsFunc.Sum }, + }, + ]); + }); + + it('allows hidden statistics only when share metadata explicitly includes hidden fields', async () => { + const hiddenError = await getError(() => + getShareViewAggregations(aggregationShareId, { + field: { [StatisticsFunc.Sum]: [secretFieldId] }, + }) + ); + expect(hiddenError?.status).toBe(403); + + await apiUpdateViewShareMeta(aggregationTableId, aggregationViewId, { + ...defaultAggregationShareMeta, + includeHiddenField: true, + }); + try { + const result = await getShareViewAggregations(aggregationShareId, { + field: { [StatisticsFunc.Sum]: [secretFieldId] }, + }); + expect(result.data.aggregations).toEqual([ + { + fieldId: secretFieldId, + total: { value: 600, aggFunc: StatisticsFunc.Sum }, + }, + ]); + } finally { + await apiUpdateViewShareMeta(aggregationTableId, aggregationViewId, { + ...defaultAggregationShareMeta, + }); + } + }); + + it('returns no aggregations when shared records are disabled', async () => { + await apiUpdateViewShareMeta(aggregationTableId, aggregationViewId, { + ...defaultAggregationShareMeta, + includeRecords: false, + }); + try { + const result = await getShareViewAggregations(aggregationShareId, { + field: { [StatisticsFunc.Sum]: [amountFieldId] }, + }); + expect(result.data).toEqual({ aggregations: [] }); + } finally { + await apiUpdateViewShareMeta(aggregationTableId, aggregationViewId, { + ...defaultAggregationShareMeta, + }); + } + }); + + it('rejects a statistic function that is invalid for the Field child', async () => { + const error = await getError(() => + getShareViewAggregations(aggregationShareId, { + field: { [StatisticsFunc.Sum]: [nameFieldId] }, + }) + ); + + expect(error?.status).toBe(400); + }); + + it('preserves password authorization before the v2 aggregate query', async () => { + await apiUpdateViewShareMeta(aggregationTableId, aggregationViewId, { + ...defaultAggregationShareMeta, + password: 'aggregation-password', + }); + try { + const error = await getError(() => + getShareViewAggregations(aggregationShareId, { + field: { [StatisticsFunc.Count]: [nameFieldId] }, + }) + ); + expect(error?.status).toBe(401); + } finally { + await apiUpdateViewShareMeta(aggregationTableId, aggregationViewId, { + ...defaultAggregationShareMeta, + }); + } + }); + + describe('api/:shareId/view/group-points (GET)', () => { + it('uses only the v2 Table/Record chain and preserves multi-level group order', async () => { + const legacyGroupSpy = vi + .spyOn(shareService, 'getViewGroupPoints') + .mockRejectedValue(new Error('legacy group-points path must not be used')); + const legacyFieldSpy = vi + .spyOn(fieldService, 'getFieldInstances') + .mockRejectedValue(new Error('legacy FieldService must not be used')); + const legacyRecordSpy = vi + .spyOn(recordService, 'getGroupRelatedData') + .mockRejectedValue(new Error('legacy RecordService must not be used')); + + try { + const result = await getShareViewGroupPoints(aggregationShareId, { + groupBy: [ + { fieldId: nameFieldId, order: SortFunc.Asc }, + { fieldId: amountFieldId, order: SortFunc.Desc }, + ], + }); + + expect(result.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedViewGroupPoints'); + expect(result.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect( + result.data?.filter(isGroupHeaderPoint).map(({ depth, value }) => ({ depth, value })) + ).toEqual([ + { depth: 0, value: 'A' }, + { depth: 1, value: 20 }, + { depth: 1, value: 10 }, + { depth: 0, value: 'B' }, + { depth: 1, value: 30 }, + ]); + expect(result.data?.filter(isGroupRowPoint).map(({ count }) => count)).toEqual([1, 1, 1]); + expect(legacyGroupSpy).not.toHaveBeenCalled(); + expect(legacyFieldSpy).not.toHaveBeenCalled(); + expect(legacyRecordSpy).not.toHaveBeenCalled(); + } finally { + legacyGroupSpy.mockRestore(); + legacyFieldSpy.mockRestore(); + legacyRecordSpy.mockRestore(); + } + }); + + it('merges View/request filters, applies search, and honors collapsed group ids', async () => { + await updateViewFilter(aggregationTableId, aggregationViewId, { + filter: { + conjunction: 'and', + filterSet: [{ fieldId: amountFieldId, operator: is.value, value: 20 }], + }, + }); + try { + const initial = await getShareViewGroupPoints(aggregationShareId, { + filter: { + conjunction: 'and', + filterSet: [{ fieldId: nameFieldId, operator: is.value, value: 'A' }], + }, + search: ['A', nameFieldId, true], + groupBy: [{ fieldId: nameFieldId, order: SortFunc.Asc }], + }); + const header = initial.data?.find( + (point) => point.type === GroupPointType.Header && point.value === 'A' + ); + expect(header).toBeDefined(); + if (!header || header.type !== GroupPointType.Header) { + throw new Error('Expected group header'); + } + expect(initial.data?.filter((point) => point.type === GroupPointType.Row)).toEqual([ + { type: GroupPointType.Row, count: 1 }, + ]); + + const collapsed = await getShareViewGroupPoints(aggregationShareId, { + filter: { + conjunction: 'and', + filterSet: [{ fieldId: nameFieldId, operator: is.value, value: 'A' }], + }, + search: ['A', nameFieldId, true], + groupBy: [{ fieldId: nameFieldId, order: SortFunc.Asc }], + collapsedGroupIds: [header.id], + }); + expect(collapsed.data).toEqual([{ ...header, isCollapsed: true }]); + } finally { + await updateViewFilter(aggregationTableId, aggregationViewId, { filter: null }); + } + }); + + it('protects hidden group Fields unless share metadata exposes them', async () => { + const hiddenError = await getError(() => + getShareViewGroupPoints(aggregationShareId, { + groupBy: [{ fieldId: secretFieldId, order: SortFunc.Asc }], + }) + ); + expect(hiddenError?.status).toBe(403); + + await apiUpdateViewShareMeta(aggregationTableId, aggregationViewId, { + ...defaultAggregationShareMeta, + includeHiddenField: true, + }); + try { + const result = await getShareViewGroupPoints(aggregationShareId, { + groupBy: [{ fieldId: secretFieldId, order: SortFunc.Desc }], + }); + expect(result.data?.filter(isGroupHeaderPoint).map(({ value }) => value)).toEqual([ + 300, 200, 100, + ]); + } finally { + await apiUpdateViewShareMeta(aggregationTableId, aggregationViewId, { + ...defaultAggregationShareMeta, + }); + } + }); + + it('returns early for disabled records and absent grouping', async () => { + const ungrouped = await getShareViewGroupPoints(aggregationShareId); + expect(ungrouped.data).toEqual([]); + + await apiUpdateViewShareMeta(aggregationTableId, aggregationViewId, { + ...defaultAggregationShareMeta, + includeRecords: false, + }); + try { + const disabled = await getShareViewGroupPoints(aggregationShareId, { + groupBy: [{ fieldId: nameFieldId, order: SortFunc.Asc }], + }); + expect(disabled.data).toEqual([]); + } finally { + await apiUpdateViewShareMeta(aggregationTableId, aggregationViewId, { + ...defaultAggregationShareMeta, + }); + } + }); + + it('preserves password authorization before the v2 group query', async () => { + await apiUpdateViewShareMeta(aggregationTableId, aggregationViewId, { + ...defaultAggregationShareMeta, + password: 'group-password', + }); + try { + const error = await getError(() => + getShareViewGroupPoints(aggregationShareId, { + groupBy: [{ fieldId: nameFieldId, order: SortFunc.Asc }], + }) + ); + expect(error?.status).toBe(401); + } finally { + await apiUpdateViewShareMeta(aggregationTableId, aggregationViewId, { + ...defaultAggregationShareMeta, + }); + } + }); + }); + }); + + describe('api/:shareId/view/search-count (GET)', () => { + let searchTableId: string; + let searchViewId: string; + let searchShareId: string; + let searchFieldId: string; + + beforeAll(async () => { + const table = await createTable(baseId, { + name: 'search-count-test-table', + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + records: [ + { fields: { Name: 'Alpha' } }, + { fields: { Name: 'Alpine' } }, + { fields: { Name: 'Beta' } }, + ], + }); + searchTableId = table.id; + searchViewId = table.defaultViewId!; + searchFieldId = table.fields[0].id; + await updateViewFilter(searchTableId, searchViewId, { + filter: { + conjunction: 'and', + filterSet: [{ fieldId: searchFieldId, operator: is.value, value: 'Alpha' }], + }, + }); + const shareResult = await apiEnableShareView({ + tableId: searchTableId, + viewId: searchViewId, + }); + searchShareId = shareResult.data.shareId; + }); + + afterAll(async () => { + await permanentDeleteTable(baseId, searchTableId); + }); + + it('uses only the v2 aggregate and Record query for filtered search counts', async () => { + const legacySearchSpy = vi + .spyOn(shareService, 'getShareSearchCount') + .mockRejectedValue(new Error('legacy search-count path must not be used')); + const legacyFieldSpy = vi + .spyOn(fieldService, 'getFieldInstances') + .mockRejectedValue(new Error('legacy FieldService must not be used')); + const legacyRecordSpy = vi + .spyOn(recordService, 'getRecords') + .mockRejectedValue(new Error('legacy RecordService must not be used')); + + try { + const result = await anonymousUser.get( + urlBuilder(GET_SHARE_VIEW_SEARCH_COUNT, { shareId: searchShareId }), + { + params: { + search: ['Alpha', searchFieldId, false], + filter: JSON.stringify({ + conjunction: 'and', + filterSet: [{ fieldId: searchFieldId, operator: 'contains', value: 'Al' }], + }), + }, + } + ); + + expect(result.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedViewSearchCount'); + expect(result.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(result.data).toEqual({ count: 1 }); + expect(legacySearchSpy).not.toHaveBeenCalled(); + expect(legacyFieldSpy).not.toHaveBeenCalled(); + expect(legacyRecordSpy).not.toHaveBeenCalled(); + } finally { + legacySearchSpy.mockRestore(); + legacyFieldSpy.mockRestore(); + legacyRecordSpy.mockRestore(); + } + }); + + it('cannot use caller viewId or ignoreViewQuery to escape the shared View', async () => { + const result = await getShareViewSearchCount(searchShareId, { + viewId: `viw${'x'.repeat(16)}`, + ignoreViewQuery: true, + search: ['Al', searchFieldId, false], + }); + + expect(result.data.count).toBe(1); + }); + + it('returns zero when no visible record matches the search', async () => { + const result = await getShareViewSearchCount(searchShareId, { + search: ['No match', searchFieldId, false], + }); + + expect(result.data).toEqual({ count: 0 }); + }); + + it('returns zero before querying when sharing disables records', async () => { + await apiUpdateViewShareMeta(searchTableId, searchViewId, { + includeRecords: false, + }); + try { + const result = await getShareViewSearchCount(searchShareId, { + search: ['Alpha', searchFieldId, false], + }); + + expect(result.data).toEqual({ count: 0 }); + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedViewSearchCount'); + } finally { + await apiUpdateViewShareMeta(searchTableId, searchViewId, { + includeRecords: true, + }); + } + }); + + it('rejects a missing search tuple before persistence', async () => { + const error = await getError(() => + anonymousUser.get(urlBuilder(GET_SHARE_VIEW_SEARCH_COUNT, { shareId: searchShareId })) + ); + + expect(error?.status).toBe(400); + }); + }); + + describe('api/:shareId/view/search-index (GET)', () => { + let searchTableId: string; + let searchViewId: string; + let searchShareId: string; + let nameFieldId: string; + let notesFieldId: string; + + beforeAll(async () => { + const table = await createTable(baseId, { + name: 'search-index-test-table', + fields: [ + { name: 'Name', type: FieldType.SingleLineText }, + { name: 'Notes', type: FieldType.SingleLineText }, + { name: 'Active', type: FieldType.Checkbox }, + ], + records: [ + { fields: { Name: 'Alpha', Notes: 'first', Active: true } }, + { fields: { Name: 'Beta', Notes: 'second', Active: true } }, + { fields: { Name: 'Gamma', Notes: 'Alpha note', Active: true } }, + { fields: { Name: 'Hidden Alpha', Notes: 'excluded', Active: false } }, + ], + }); + searchTableId = table.id; + searchViewId = table.defaultViewId!; + nameFieldId = table.fields.find((field) => field.name === 'Name')!.id; + notesFieldId = table.fields.find((field) => field.name === 'Notes')!.id; + const activeFieldId = table.fields.find((field) => field.name === 'Active')!.id; + await updateViewFilter(searchTableId, searchViewId, { + filter: { + conjunction: 'and', + filterSet: [{ fieldId: activeFieldId, operator: is.value, value: true }], + }, + }); + const shareResult = await apiEnableShareView({ + tableId: searchTableId, + viewId: searchViewId, + }); + searchShareId = shareResult.data.shareId; + }); + + afterAll(async () => { + await permanentDeleteTable(baseId, searchTableId); + }); + + it('uses the v2 aggregate and Record repository without the legacy aggregation path', async () => { + const legacySearchSpy = vi + .spyOn(shareService, 'getShareSearchIndex') + .mockRejectedValue(new Error('legacy search-index path must not be used')); + try { + const result = await getShareViewSearchIndex(searchShareId, { + take: 10, + search: ['Alpha', '', false], + }); + + expect(result.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedViewSearchIndex'); + expect(result.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ index: 1, fieldId: nameFieldId }), + expect.objectContaining({ index: 3, fieldId: notesFieldId }), + ]) + ); + expect(legacySearchSpy).not.toHaveBeenCalled(); + } finally { + legacySearchSpy.mockRestore(); + } + }); + + it('numbers hidden-non-match results inside the matching result set', async () => { + const result = await getShareViewSearchIndex(searchShareId, { + skip: 1, + take: 1, + search: ['Alpha', '', true], + }); + + expect(result.data).toEqual([expect.objectContaining({ index: 2, fieldId: notesFieldId })]); + }); + + it('keeps the complete View row number when non-matching rows remain visible', async () => { + const result = await getShareViewSearchIndex(searchShareId, { + skip: 1, + take: 1, + search: ['Alpha', '', false], + }); + + expect(result.data).toEqual([expect.objectContaining({ index: 3, fieldId: notesFieldId })]); + }); + + it('honors projection and cannot escape the authorized shared View', async () => { + const result = await getShareViewSearchIndex(searchShareId, { + take: 10, + projection: [nameFieldId], + viewId: `viw${'x'.repeat(16)}`, + ignoreViewQuery: true, + search: ['Alpha', '', false], + }); + + expect(result.data).toHaveLength(1); + expect(result.data?.[0]).toEqual(expect.objectContaining({ index: 1, fieldId: nameFieldId })); + }); + + it('returns null before querying when sharing disables records', async () => { + await apiUpdateViewShareMeta(searchTableId, searchViewId, { includeRecords: false }); + try { + const result = await getShareViewSearchIndex(searchShareId, { + take: 10, + search: ['Alpha', '', false], + }); + + // Nest serializes a controller-level null response as an empty HTTP body. + expect(result.data).toBe(''); + } finally { + await apiUpdateViewShareMeta(searchTableId, searchViewId, { includeRecords: true }); + } + }); + + it('rejects missing search and result windows above 1000', async () => { + const missingSearch = await getError(() => + anonymousUser.get(urlBuilder(GET_SHARE_VIEW_SEARCH_INDEX, { shareId: searchShareId }), { + params: { take: 10 }, + }) + ); + const excessiveTake = await getError(() => + getShareViewSearchIndex(searchShareId, { + take: 1001, + search: ['Alpha', '', false], + }) + ); + + expect(missingSearch?.status).toBe(400); + expect(excessiveTake?.status).toBe(400); + }); + }); + // A share view's hidden columns must never reach a visitor, regardless of what // field references the client puts in the query. The per-endpoint default // projection only protects the default case; a crafted projection (records) or @@ -407,7 +1454,9 @@ describe('OpenAPI ShareController (e2e)', () => { let leakTableId: string; let leakViewId: string; let leakShareId: string; + let nameFieldId: string; let dueFieldId: string; + let hiddenDueFieldId: string; let secretFieldId: string; const secretValue = 'top-secret-value'; @@ -427,22 +1476,51 @@ describe('OpenAPI ShareController (e2e)', () => { }, }, }, + { + name: 'Hidden Due', + type: FieldType.Date, + options: { + formatting: { + date: DateFormattingPreset.ISO, + time: TimeFormatting.None, + timeZone: 'Asia/Singapore', + }, + }, + }, { name: 'Secret', type: FieldType.SingleLineText }, ], records: [ - { fields: { Name: 'Visible', Due: '2022-03-01T10:00:00.000Z', Secret: secretValue } }, + { + fields: { + Name: 'Visible', + Due: '2022-03-01T10:00:00.000Z', + ['Hidden Due']: '2022-03-01T10:00:00.000Z', + Secret: secretValue, + }, + }, + { + fields: { + Name: 'Other', + Due: '2022-03-01T11:00:00.000Z', + ['Hidden Due']: '2022-03-01T11:00:00.000Z', + Secret: 'another-secret', + }, + }, ], }); leakTableId = table.id; leakViewId = table.defaultViewId!; + nameFieldId = table.fields[0].id; dueFieldId = table.fields[1].id; - secretFieldId = table.fields[2].id; + hiddenDueFieldId = table.fields[2].id; + secretFieldId = table.fields[3].id; const shareResult = await apiEnableShareView({ tableId: leakTableId, viewId: leakViewId }); leakShareId = shareResult.data.shareId; // hide the Secret column from the shared view await updateViewColumnMeta(leakTableId, leakViewId, [ + { fieldId: hiddenDueFieldId, columnMeta: { hidden: true } }, { fieldId: secretFieldId, columnMeta: { hidden: true } }, ]); }); @@ -454,7 +1532,7 @@ describe('OpenAPI ShareController (e2e)', () => { it('omits the hidden column from the records payload by default', async () => { const result = await apiGetShareViewRecords(leakShareId, { take: 10 }); - expect(result.data.records).toHaveLength(1); + expect(result.data.records).toHaveLength(2); expect(result.data.records[0].fields).not.toHaveProperty(secretFieldId); }); @@ -471,18 +1549,165 @@ describe('OpenAPI ShareController (e2e)', () => { }); it('must not return hidden columns in the calendar daily collection records', async () => { - const result = await apiGetShareViewCalendarDailyCollection(leakShareId, { + const legacyCalendarSpy = vi.spyOn(shareService, 'getViewCalendarDailyCollection'); + const legacyFieldSpy = vi.spyOn(recordService, 'getFieldsByProjection'); + const legacyRecordSpy = vi.spyOn(recordService, 'getRecordsById'); + try { + const result = await apiGetShareViewCalendarDailyCollection(leakShareId, { + startDateFieldId: dueFieldId, + endDateFieldId: dueFieldId, + startDate: '2022-02-27T16:00:00.000Z', + endDate: '2022-03-12T16:00:00.000Z', + }); + + expect(result.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe( + 'getSharedViewCalendarDailyCollection' + ); + expect(result.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(result.data.countMap).toEqual(Object.fromEntries([['2022-03-01', 2]])); + expect(result.data.records).toHaveLength(2); + for (const record of result.data.records) { + expect(record.fields).not.toHaveProperty(secretFieldId); + expect(record.fields).not.toHaveProperty(hiddenDueFieldId); + } + expect(legacyCalendarSpy).not.toHaveBeenCalled(); + expect(legacyFieldSpy).not.toHaveBeenCalled(); + expect(legacyRecordSpy).not.toHaveBeenCalled(); + } finally { + legacyCalendarSpy.mockRestore(); + legacyFieldSpy.mockRestore(); + legacyRecordSpy.mockRestore(); + } + }); + + it('ANDs the request filter and applies only visible-row search', async () => { + const filtered = await apiGetShareViewCalendarDailyCollection(leakShareId, { startDateFieldId: dueFieldId, endDateFieldId: dueFieldId, startDate: '2022-02-27T16:00:00.000Z', endDate: '2022-03-12T16:00:00.000Z', + filter: { + conjunction: 'and', + filterSet: [{ fieldId: nameFieldId, operator: is.value, value: 'Visible' }], + }, }); + expect(filtered.data.countMap).toEqual(Object.fromEntries([['2022-03-01', 1]])); + expect(filtered.data.records).toHaveLength(1); + expect(filtered.data.records[0].fields[nameFieldId]).toBe('Visible'); - expect(result.data.records.length).toBeGreaterThan(0); - const leaked = result.data.records.some((record) => - Object.prototype.hasOwnProperty.call(record.fields, secretFieldId) + const highlightOnly = await apiGetShareViewCalendarDailyCollection(leakShareId, { + startDateFieldId: dueFieldId, + endDateFieldId: dueFieldId, + startDate: '2022-02-27T16:00:00.000Z', + endDate: '2022-03-12T16:00:00.000Z', + search: ['Visible', nameFieldId, false], + }); + expect(highlightOnly.data.countMap).toEqual(Object.fromEntries([['2022-03-01', 2]])); + + const visibleRows = await apiGetShareViewCalendarDailyCollection(leakShareId, { + startDateFieldId: dueFieldId, + endDateFieldId: dueFieldId, + startDate: '2022-02-27T16:00:00.000Z', + endDate: '2022-03-12T16:00:00.000Z', + search: ['Visible', nameFieldId, true], + }); + expect(visibleRows.data.countMap).toEqual(Object.fromEntries([['2022-03-01', 1]])); + expect(visibleRows.data.records).toHaveLength(1); + }); + + it('rejects hidden or invalid date fields and allows hidden dates only through share metadata', async () => { + const hiddenError = await getError(() => + apiGetShareViewCalendarDailyCollection(leakShareId, { + startDateFieldId: hiddenDueFieldId, + endDateFieldId: hiddenDueFieldId, + startDate: '2022-02-27T16:00:00.000Z', + endDate: '2022-03-12T16:00:00.000Z', + }) ); - expect(leaked).toBe(false); + expect(hiddenError?.status).toBe(403); + + const invalidError = await getError(() => + apiGetShareViewCalendarDailyCollection(leakShareId, { + startDateFieldId: nameFieldId, + endDateFieldId: nameFieldId, + startDate: '2022-02-27T16:00:00.000Z', + endDate: '2022-03-12T16:00:00.000Z', + }) + ); + expect(invalidError?.status).toBe(400); + + await apiUpdateViewShareMeta(leakTableId, leakViewId, { + includeRecords: true, + includeHiddenField: true, + }); + try { + const included = await apiGetShareViewCalendarDailyCollection(leakShareId, { + startDateFieldId: hiddenDueFieldId, + endDateFieldId: hiddenDueFieldId, + startDate: '2022-02-27T16:00:00.000Z', + endDate: '2022-03-12T16:00:00.000Z', + }); + expect(included.data.countMap).toEqual(Object.fromEntries([['2022-03-01', 2]])); + expect(included.data.records[0].fields).toHaveProperty(hiddenDueFieldId); + } finally { + await apiUpdateViewShareMeta(leakTableId, leakViewId, { + includeRecords: true, + includeHiddenField: false, + }); + } + }); + + it('returns an empty collection before querying records when share metadata disables them', async () => { + await apiUpdateViewShareMeta(leakTableId, leakViewId, { + includeRecords: false, + includeHiddenField: false, + }); + try { + const result = await apiGetShareViewCalendarDailyCollection(leakShareId, { + startDateFieldId: dueFieldId, + endDateFieldId: dueFieldId, + startDate: '2022-02-27T16:00:00.000Z', + endDate: '2022-03-12T16:00:00.000Z', + }); + expect(result.data).toEqual({ countMap: {}, records: [] }); + } finally { + await apiUpdateViewShareMeta(leakTableId, leakViewId, { + includeRecords: true, + includeHiddenField: false, + }); + } + }); + + it('preserves password authorization before the v2 calendar query', async () => { + await apiUpdateViewShareMeta(leakTableId, leakViewId, { + includeRecords: true, + includeHiddenField: false, + password: 'calendar-secret', + }); + try { + const error = await getError(() => + anonymousUser.get( + urlBuilder(SHARE_VIEW_CALENDAR_DAILY_COLLECTION, { + shareId: leakShareId, + }), + { + params: { + startDateFieldId: dueFieldId, + endDateFieldId: dueFieldId, + startDate: '2022-02-27T16:00:00.000Z', + endDate: '2022-03-12T16:00:00.000Z', + }, + } + ) + ); + expect(error?.status).toBe(401); + } finally { + await apiUpdateViewShareMeta(leakTableId, leakViewId, { + includeRecords: true, + includeHiddenField: false, + }); + } }); }); @@ -492,6 +1717,7 @@ describe('OpenAPI ShareController (e2e)', () => { let editViewId: string; let nameFieldId: string; let secretFieldId: string; + let assigneeFieldId: string; let visibleRecordId: string; let filteredOutRecordId: string; @@ -501,6 +1727,11 @@ describe('OpenAPI ShareController (e2e)', () => { fields: [ { name: 'Name', type: FieldType.SingleLineText }, { name: 'Secret', type: FieldType.SingleLineText }, + { + name: 'Assignee', + type: FieldType.User, + options: { isMultiple: false, shouldNotify: false }, + }, ], records: [ { fields: { Name: 'Visible', Secret: 'visible-secret' } }, @@ -510,6 +1741,7 @@ describe('OpenAPI ShareController (e2e)', () => { editViewId = editTable.defaultViewId!; nameFieldId = editTable.fields[0].id; secretFieldId = editTable.fields[1].id; + assigneeFieldId = editTable.fields[2].id; visibleRecordId = editTable.records[0].id; filteredOutRecordId = editTable.records[1].id; @@ -636,6 +1868,16 @@ describe('OpenAPI ShareController (e2e)', () => { expect(error?.status).toEqual(400); }); + it('should give logged-in share editors the full collaborator directory', async () => { + const result = await apiGetShareViewCollaborators(editShareId, { + fieldId: assigneeFieldId, + }); + + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedViewCollaborators'); + expect(result.data.map((item) => item.userId)).toContain(userId); + expect(result.data.every((item) => !('email' in item))).toBe(true); + }); + it('should allow share editors to delete a visible record', async () => { // Use a fresh record so we don't disturb the rest of the suite. const created = await apiCreateRecords(editTable.id, { @@ -882,13 +2124,52 @@ describe('OpenAPI ShareController (e2e)', () => { fromViewShareId = shareResult.data.shareId; }); it('should return link records', async () => { - const result = await apiGetShareViewLinkRecords(fromViewShareId, { + const legacyShareSpy = vi + .spyOn(shareService, 'getViewLinkRecords') + .mockRejectedValue(new Error('legacy shared Link Records path must not be used')); + const legacyFieldSpy = vi + .spyOn(fieldService, 'getField') + .mockRejectedValue(new Error('legacy FieldService must not be used')); + const legacyRecordSpy = vi + .spyOn(recordService, 'getRecords') + .mockRejectedValue(new Error('legacy RecordService must not be used')); + + try { + const result = await apiGetShareViewLinkRecords(fromViewShareId, { + fieldId: linkFieldId, + }); + const linkRecords = result.data; + expect(result.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedViewLinkRecords'); + expect(result.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(linkRecords.map((record) => record.title)).toEqual( + tableRecords.map((record) => record.fields[primaryFieldName]) + ); + expect(legacyShareSpy).not.toHaveBeenCalled(); + expect(legacyFieldSpy).not.toHaveBeenCalled(); + expect(legacyRecordSpy).not.toHaveBeenCalled(); + } finally { + legacyShareSpy.mockRestore(); + legacyFieldSpy.mockRestore(); + legacyRecordSpy.mockRestore(); + } + }); + + it('applies lookup-only search and page windows while includeRecords is absent', async () => { + const searched = await apiGetShareViewLinkRecords(fromViewShareId, { fieldId: linkFieldId, + search: '2', + take: 1, + skip: 0, }); - const linkRecords = result.data; - expect(linkRecords.map((record) => record.title)).toEqual( - tableRecords.map((record) => record.fields[primaryFieldName]) - ); + const paged = await apiGetShareViewLinkRecords(fromViewShareId, { + fieldId: linkFieldId, + take: 1, + skip: 1, + }); + + expect(searched.data.map((record) => record.title)).toEqual(['2']); + expect(paged.data.map((record) => record.title)).toEqual(['2']); }); }); @@ -910,10 +2191,79 @@ describe('OpenAPI ShareController (e2e)', () => { fieldId: linkFieldId, }); const linkRecords = result.data; + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedViewLinkRecords'); expect(linkRecords.map((record) => record.title)).toEqual( tableRecords.slice(0, 2).map((record) => record.fields[primaryFieldName]) ); }); + + it('rejects hidden and non-Link Fields at the Table aggregate boundary', async () => { + await apiUpdateViewColumnMeta(linkTableRes.id, gridViewId, [ + { fieldId: linkFieldId, columnMeta: { hidden: true } }, + ]); + try { + const hiddenError = await getError(() => + apiGetShareViewLinkRecords(gridViewShareId, { fieldId: linkFieldId }) + ); + const nonLinkError = await getError(() => + apiGetShareViewLinkRecords(gridViewShareId, { + fieldId: linkTableRes.fields[0].id, + }) + ); + + expect(hiddenError?.status).toBe(403); + expect(nonLinkError?.status).toBe(403); + + await apiUpdateViewShareMeta(linkTableRes.id, gridViewId, { + includeHiddenField: true, + }); + const allowed = await apiGetShareViewLinkRecords(gridViewShareId, { + fieldId: linkFieldId, + }); + expect(allowed.data.map((record) => record.title)).toEqual(['1', '2']); + } finally { + await apiUpdateViewShareMeta(linkTableRes.id, gridViewId, { + includeHiddenField: false, + }); + await apiUpdateViewColumnMeta(linkTableRes.id, gridViewId, [ + { fieldId: linkFieldId, columnMeta: { hidden: false } }, + ]); + } + }); + }); + + describe('plugin view', () => { + let pluginViewShareId: string; + + beforeAll(async () => { + const pluginView = await createView(linkTableRes.id, { + type: ViewType.Plugin, + options: { + pluginId: 'plgsheetform', + pluginInstallId: 'ignored-by-create', + pluginLogo: 'ignored-by-create', + }, + }); + const shareResult = await apiEnableShareView({ + tableId: linkTableRes.id, + viewId: pluginView.id, + }); + pluginViewShareId = shareResult.data.shareId; + }); + + it('switches only Plugin Views between selected and candidate scopes', async () => { + const selected = await apiGetShareViewLinkRecords(pluginViewShareId, { + fieldId: linkFieldId, + type: ShareViewLinkRecordsType.Selected, + }); + const candidate = await apiGetShareViewLinkRecords(pluginViewShareId, { + fieldId: linkFieldId, + type: ShareViewLinkRecordsType.Candidate, + }); + + expect(selected.data.map((record) => record.title)).toEqual(['1', '2']); + expect(candidate.data.map((record) => record.title)).toEqual(['1', '2', '3']); + }); }); }); @@ -923,6 +2273,7 @@ describe('OpenAPI ShareController (e2e)', () => { const multipleUserFieldName = 'multiple user'; let userFieldId: string; let multipleUserFieldId: string; + let primaryFieldId: string; const userFieldRo: IFieldRo = { name: userFieldName, type: FieldType.User, @@ -955,6 +2306,7 @@ describe('OpenAPI ShareController (e2e)', () => { }); userFieldId = userTableRes.fields[1].id; multipleUserFieldId = userTableRes.fields[2].id; + primaryFieldId = userTableRes.fields[0].id; }); afterAll(async () => { @@ -976,20 +2328,37 @@ describe('OpenAPI ShareController (e2e)', () => { const result = await apiGetShareViewCollaborators(gridViewShareId, { fieldId: userFieldId, }); + expect(result.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedViewCollaborators'); + expect(result.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); expect(result.data).toEqual([]); }); it('should return the value that exists and there will be no duplicates of the', async () => { + const legacyShareSpy = vi + .spyOn(shareService, 'getViewCollaborators') + .mockRejectedValue(new Error('legacy collaborator path must not be used')); + const legacyFieldSpy = vi + .spyOn(fieldService, 'getField') + .mockRejectedValue(new Error('legacy FieldService must not be used')); + const legacyRecordSpy = vi + .spyOn(recordService, 'getDbTableName') + .mockRejectedValue(new Error('legacy RecordService must not be used')); + const legacyDirectorySpy = vi + .spyOn(collaboratorService, 'getUserCollaborators') + .mockRejectedValue(new Error('legacy CollaboratorService must not be used')); const { data: createRes } = await apiCreateRecords(userTableRes.id, { records: [ { fields: { + [primaryFieldId]: 'Visible', [multipleUserFieldId]: [{ id: userId, title: userName }], [userFieldId]: { id: userId, title: userName }, }, }, { fields: { + [primaryFieldId]: 'Hidden', [multipleUserFieldId]: [{ id: userId, title: userName }], [userFieldId]: { id: userId, title: userName }, }, @@ -997,21 +2366,87 @@ describe('OpenAPI ShareController (e2e)', () => { ], fieldKeyType: FieldKeyType.Id, }); - const result = await apiGetShareViewCollaborators(gridViewShareId, { - fieldId: userFieldId, + try { + const result = await apiGetShareViewCollaborators(gridViewShareId, { + fieldId: userFieldId, + }); + const mulResult = await apiGetShareViewCollaborators(gridViewShareId, { + fieldId: multipleUserFieldId, + }); + // Email is intentionally omitted from share responses to avoid leaking + // the member directory to anonymous viewers. + expect(result.data).toEqual([{ userId, userName, avatar: expect.any(String) }]); + expect(mulResult.data).toEqual([{ userId, userName, avatar: expect.any(String) }]); + expect(result.data[0]).not.toHaveProperty('email'); + expect(legacyShareSpy).not.toHaveBeenCalled(); + expect(legacyFieldSpy).not.toHaveBeenCalled(); + expect(legacyRecordSpy).not.toHaveBeenCalled(); + expect(legacyDirectorySpy).not.toHaveBeenCalled(); + } finally { + legacyShareSpy.mockRestore(); + legacyFieldSpy.mockRestore(); + legacyRecordSpy.mockRestore(); + legacyDirectorySpy.mockRestore(); + await apiDeleteRecords( + userTableRes.id, + createRes.records.map((record) => record.id) + ); + } + }); + + it('applies the View filter before resolving referenced collaborators', async () => { + const { data: created } = await apiCreateRecords(userTableRes.id, { + records: [ + { fields: { [primaryFieldId]: 'Visible' } }, + { + fields: { + [primaryFieldId]: 'Hidden', + [userFieldId]: { id: userId, title: userName }, + }, + }, + ], + fieldKeyType: FieldKeyType.Id, }); - const mulResult = await apiGetShareViewCollaborators(gridViewShareId, { - fieldId: multipleUserFieldId, + await updateViewFilter(userTableRes.id, gridViewId, { + filter: { + conjunction: 'and', + filterSet: [{ fieldId: primaryFieldId, operator: is.value, value: 'Visible' }], + }, }); - // Email is intentionally omitted from share responses to avoid leaking - // the member directory to anonymous viewers. - expect(result.data).toEqual([{ userId, userName, avatar: expect.any(String) }]); - expect(mulResult.data).toEqual([{ userId, userName, avatar: expect.any(String) }]); + try { + const result = await apiGetShareViewCollaborators(gridViewShareId, { + fieldId: userFieldId, + }); + expect(result.data).toEqual([]); + } finally { + await updateViewFilter(userTableRes.id, gridViewId, { filter: null }); + await apiDeleteRecords( + userTableRes.id, + created.records.map((record) => record.id) + ); + } + }); - await apiDeleteRecords( - userTableRes.id, - createRes.records.map((record) => record.id) + it('rejects missing, hidden, and non-user Fields at the Table boundary', async () => { + const missing = await getError(() => apiGetShareViewCollaborators(gridViewShareId, {})); + const nonUser = await getError(() => + apiGetShareViewCollaborators(gridViewShareId, { fieldId: primaryFieldId }) ); + await apiUpdateViewColumnMeta(userTableRes.id, gridViewId, [ + { fieldId: userFieldId, columnMeta: { hidden: true } }, + ]); + try { + const hidden = await getError(() => + apiGetShareViewCollaborators(gridViewShareId, { fieldId: userFieldId }) + ); + expect(missing?.status).toBe(400); + expect(nonUser?.status).toBe(403); + expect(hidden?.status).toBe(403); + } finally { + await apiUpdateViewColumnMeta(userTableRes.id, gridViewId, [ + { fieldId: userFieldId, columnMeta: { hidden: false } }, + ]); + } }); }); @@ -1053,6 +2488,8 @@ describe('OpenAPI ShareController (e2e)', () => { expect(result.data.map((user) => user.userId)).toEqual( baseCollaborators.data.collaborators.map((item) => item.userId) ); + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedViewCollaborators'); + expect(result.data.every((item) => !('email' in item))).toBe(true); await apiUpdateViewColumnMeta(userTableRes.id, formViewId, [ { fieldId: userFieldId, @@ -1078,54 +2515,500 @@ describe('OpenAPI ShareController (e2e)', () => { { fieldId: userFieldId, columnMeta: { visible: false } }, ]); }); + + it('applies directory pagination and preserves password authorization', async () => { + await apiUpdateViewColumnMeta(userTableRes.id, formViewId, [ + { fieldId: userFieldId, columnMeta: { visible: true } }, + ]); + const first = await apiGetShareViewCollaborators(fromViewShareId, { + take: 1, + skip: 0, + }); + const afterFirst = await apiGetShareViewCollaborators(fromViewShareId, { + take: 1, + skip: 100, + }); + expect(first.data).toHaveLength(1); + expect(afterFirst.data).toEqual([]); + + await apiUpdateViewShareMeta(userTableRes.id, formViewId, { + password: 'collaborator-secret', + }); + try { + const error = await getError(() => + anonymousUser.get(urlBuilder(SHARE_VIEW_COLLABORATORS, { shareId: fromViewShareId })) + ); + expect(error?.status).toBe(401); + } finally { + await apiUpdateViewShareMeta(userTableRes.id, formViewId, {}); + await apiUpdateViewColumnMeta(userTableRes.id, formViewId, [ + { fieldId: userFieldId, columnMeta: { visible: false } }, + ]); + } + }); + }); + + describe('Plugin view', () => { + let pluginShareId: string; + + beforeAll(async () => { + const pluginView = await createView(userTableRes.id, { + type: ViewType.Plugin, + options: { + pluginId: 'plgsheetform', + pluginInstallId: 'ignored-by-create', + pluginLogo: 'ignored-by-create', + }, + }); + const shareResult = await apiEnableShareView({ + tableId: userTableRes.id, + viewId: pluginView.id, + }); + pluginShareId = shareResult.data.shareId; + }); + + it('uses the full member directory without a subtype fallback', async () => { + const result = await apiGetShareViewCollaborators(pluginShareId, { + fieldId: userFieldId, + }); + + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedViewCollaborators'); + expect(result.data.map((item) => item.userId)).toContain(userId); + }); }); }); - describe('api/:shareId/view/copy (PATCH)', () => { - let gridViewId: string; - let gridViewShareId: string; + describe('api/:shareId/view/record/:recordId/:fieldId/button-click (POST)', () => { + let buttonTable: ITableFullVo; + let buttonViewId: string; + let buttonShareId: string; + let buttonFieldId: string; + let textFieldId: string; + let recordId: string; + + const click = (fieldId = buttonFieldId) => + anonymousUser.post( + urlBuilder(SHARE_VIEW_BUTTON_CLICK, { + shareId: buttonShareId, + recordId, + fieldId, + }) + ); - beforeEach(async () => { - const result = await createView(tableId, gridViewRo); - gridViewId = result.id; + beforeAll(async () => { + buttonTable = await createTable(baseId, { + name: 'shared-button-click-v2', + fields: x_20.fields, + records: x_20.records.slice(0, 2), + }); + buttonViewId = buttonTable.defaultViewId!; + textFieldId = buttonTable.fields[0].id; + recordId = buttonTable.records[0].id; + const field = await createField(buttonTable.id, { + type: FieldType.Button, + options: { + label: 'Run', + color: Colors.Teal, + workflow: { + id: generateWorkflowId(), + name: 'Run', + isActive: true, + }, + }, + }); + buttonFieldId = field.data.id; + const shareResult = await apiEnableShareView({ + tableId: buttonTable.id, + viewId: buttonViewId, + }); + buttonShareId = shareResult.data.shareId; + await apiUpdateViewShareMeta(buttonTable.id, buttonViewId, { + includeRecords: true, + }); + }); - const shareResult = await apiEnableShareView({ tableId, viewId: gridViewId }); - await apiUpdateViewShareMeta(tableId, gridViewId, { allowCopy: true }); - gridViewShareId = shareResult.data.shareId; + afterAll(async () => { + await permanentDeleteTable(baseId, buttonTable.id); }); - it('should return 200', async () => { - const result = await anonymousUser.get( - urlBuilder(SHARE_VIEW_COPY, { shareId: gridViewShareId }), - { - params: { + it('increments through the isolated v2 chain and reports the feature', async () => { + const legacySpy = vi + .spyOn(recordOpenApiService, 'buttonClick') + .mockRejectedValue(new Error('legacy RecordOpenApiService.buttonClick must not be used')); + try { + const first = await click(); + const second = await click(); + + expect(first.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(first.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('buttonClick'); + expect((first.data.record.fields[buttonFieldId] as IButtonFieldCellValue).count).toBe(1); + expect((second.data.record.fields[buttonFieldId] as IButtonFieldCellValue).count).toBe(2); + expect(legacySpy).not.toHaveBeenCalled(); + } finally { + legacySpy.mockRestore(); + } + }); + + it('rejects a non-Button Field at the Table aggregate boundary', async () => { + const error = await getError(() => click(textFieldId)); + expect(error?.status).toBe(400); + }); + + it('rejects an inactive workflow', async () => { + const inactive = await createField(buttonTable.id, { + type: FieldType.Button, + options: { + label: 'Inactive', + color: Colors.Teal, + workflow: { + id: generateWorkflowId(), + name: 'Inactive', + isActive: false, + }, + }, + }); + + const error = await getError(() => click(inactive.data.id)); + expect(error?.status).toBe(400); + }); + + it('enforces maxCount', async () => { + const limited = await createField(buttonTable.id, { + type: FieldType.Button, + options: { + label: 'Once', + color: Colors.Teal, + maxCount: 1, + workflow: { + id: generateWorkflowId(), + name: 'Once', + isActive: true, + }, + }, + }); + + await click(limited.data.id); + const error = await getError(() => click(limited.data.id)); + expect(error?.status).toBe(400); + }); + + it('rejects a hidden Field unless share metadata includes hidden Fields', async () => { + await apiUpdateViewColumnMeta(buttonTable.id, buttonViewId, [ + { fieldId: buttonFieldId, columnMeta: { hidden: true } }, + ]); + try { + const hiddenError = await getError(() => click()); + expect(hiddenError?.status).toBe(403); + + await apiUpdateViewShareMeta(buttonTable.id, buttonViewId, { + includeRecords: true, + includeHiddenField: true, + }); + await expect(click()).resolves.toMatchObject({ status: 201 }); + } finally { + await apiUpdateViewShareMeta(buttonTable.id, buttonViewId, { + includeRecords: true, + includeHiddenField: false, + }); + await apiUpdateViewColumnMeta(buttonTable.id, buttonViewId, [ + { fieldId: buttonFieldId, columnMeta: { hidden: false } }, + ]); + } + }); + + it('rejects a Record outside the shared View filter', async () => { + await updateViewFilter(buttonTable.id, buttonViewId, { + filter: { + conjunction: 'and', + filterSet: [{ fieldId: textFieldId, operator: is.value, value: 'not-present' }], + }, + }); + try { + const error = await getError(() => click()); + expect(error?.status).toBe(403); + } finally { + await updateViewFilter(buttonTable.id, buttonViewId, { filter: null }); + } + }); + + it('rejects clicks when shared records are disabled', async () => { + await apiUpdateViewShareMeta(buttonTable.id, buttonViewId, { + includeRecords: false, + }); + try { + const error = await getError(() => click()); + expect(error?.status).toBe(403); + } finally { + await apiUpdateViewShareMeta(buttonTable.id, buttonViewId, { + includeRecords: true, + }); + } + }); + }); + + describe('api/:shareId/view/copy (GET)', () => { + let copyTable: ITableFullVo; + let copyViewId: string; + let copyShareId: string; + let textFieldId: string; + let numberFieldId: string; + + const getCopy = (params: Record) => + anonymousUser.get(urlBuilder(SHARE_VIEW_COPY, { shareId: copyShareId }), { + params, + }); + + beforeAll(async () => { + copyTable = await createTable(baseId, { + name: 'shared-copy-v2', + fields: x_20.fields, + records: x_20.records, + }); + copyViewId = copyTable.defaultViewId!; + textFieldId = copyTable.fields[0].id; + numberFieldId = copyTable.fields[1].id; + const shareResult = await apiEnableShareView({ + tableId: copyTable.id, + viewId: copyViewId, + }); + copyShareId = shareResult.data.shareId; + await apiUpdateViewShareMeta(copyTable.id, copyViewId, { + allowCopy: true, + includeRecords: true, + }); + }); + + afterAll(async () => { + await permanentDeleteTable(baseId, copyTable.id); + }); + + it('returns exact clipboard content/header through the isolated v2 chain', async () => { + const legacyCopySpy = vi + .spyOn(shareService, 'copy') + .mockRejectedValue(new Error('legacy ShareService.copy must not be used')); + const legacySelectionSpy = vi + .spyOn(selectionService, 'copy') + .mockRejectedValue(new Error('legacy SelectionService.copy must not be used')); + try { + const result = await getCopy({ + ranges: JSON.stringify([ + [0, 1], + [1, 2], + ]), + }); + + expect(result.status).toBe(200); + expect(result.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(result.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getSharedViewCopy'); + expect(result.data.content).toBe('Text Field 0\t0.0\nText Field 1\t1.0'); + expect(result.data.header.map((field) => field.id)).toEqual([textFieldId, numberFieldId]); + expect(legacyCopySpy).not.toHaveBeenCalled(); + expect(legacySelectionSpy).not.toHaveBeenCalled(); + } finally { + legacyCopySpy.mockRestore(); + legacySelectionSpy.mockRestore(); + } + }); + + it('preserves disjoint row ranges and their request order', async () => { + const result = await getCopy({ + type: 'rows', + projection: [textFieldId], + ranges: JSON.stringify([ + [2, 3], + [1, 1], + ]), + }); + + expect(result.data.content).toBe('Text Field 1\nText Field 2\nText Field 0'); + expect(result.data.header.map((field) => field.id)).toEqual([textFieldId]); + }); + + it('copies all matched rows for a column selection', async () => { + const result = await getCopy({ + type: 'columns', + ranges: JSON.stringify([[0, 0]]), + }); + + expect(result.data.header.map((field) => field.id)).toEqual([textFieldId]); + expect(result.data.content.split('\n')).toHaveLength(x_20.records.length); + expect(result.data.content).toContain('Text Field 0'); + expect(result.data.content).toContain('Text Field 20'); + }); + + it('bounds projection to View visibility and honors includeHiddenField explicitly', async () => { + await apiUpdateViewColumnMeta(copyTable.id, copyViewId, [ + { fieldId: numberFieldId, columnMeta: { hidden: true } }, + ]); + try { + const hidden = await getCopy({ + projection: [numberFieldId, textFieldId], + ranges: JSON.stringify([ + [0, 1], + [1, 1], + ]), + }); + expect(hidden.data.header.map((field) => field.id)).toEqual([textFieldId]); + expect(hidden.data.content).toBe('Text Field 0'); + + const hiddenFilterError = await getError(() => + getCopy({ + filter: JSON.stringify({ + conjunction: 'and', + filterSet: [{ fieldId: numberFieldId, operator: is.value, value: 0 }], + }), ranges: JSON.stringify([ [0, 0], - [1, 1], + [0, 0], ]), - }, - } + }) + ); + expect(hiddenFilterError?.status).toBe(403); + + await apiUpdateViewShareMeta(copyTable.id, copyViewId, { + allowCopy: true, + includeRecords: true, + includeHiddenField: true, + }); + const included = await getCopy({ + projection: [numberFieldId, textFieldId], + ranges: JSON.stringify([ + [0, 1], + [1, 1], + ]), + }); + expect(included.data.header.map((field) => field.id)).toEqual([numberFieldId, textFieldId]); + expect(included.data.content).toBe('0.0\tText Field 0'); + } finally { + await apiUpdateViewShareMeta(copyTable.id, copyViewId, { + allowCopy: true, + includeRecords: true, + includeHiddenField: false, + }); + await apiUpdateViewColumnMeta(copyTable.id, copyViewId, [ + { fieldId: numberFieldId, columnMeta: { hidden: false } }, + ]); + } + }); + + it('cannot replace the authorized View or bypass its filter', async () => { + const otherView = await createView(copyTable.id, gridViewRo); + await updateViewFilter(copyTable.id, copyViewId, { + filter: { + conjunction: 'and', + filterSet: [{ fieldId: textFieldId, operator: is.value, value: 'Text Field 3' }], + }, + }); + try { + const result = await getCopy({ + viewId: otherView.id, + ignoreViewQuery: true, + type: 'columns', + ranges: JSON.stringify([[0, 0]]), + }); + + expect(result.data.content).toBe('Text Field 3'); + } finally { + await updateViewFilter(copyTable.id, copyViewId, { filter: null }); + await deleteView(copyTable.id, otherView.id); + } + }); + + it('excludes records inside collapsed groups through the Table Record repository', async () => { + const groupBy = [{ fieldId: textFieldId, order: SortFunc.Asc }]; + const points = await getShareViewGroupPoints(copyShareId, { groupBy }); + const collapsed = points.data?.find( + (point): point is Extract => + isGroupHeaderPoint(point) && point.depth === 0 && point.value === 'Text Field 3' ); - expect(result.status).toEqual(200); + expect(collapsed?.id).toBeDefined(); + + const result = await getCopy({ + type: 'columns', + ranges: JSON.stringify([[0, 0]]), + groupBy: JSON.stringify(groupBy), + collapsedGroupIds: JSON.stringify([collapsed!.id]), + }); + const rows = result.data.content.split('\n'); + + expect(rows).not.toContain('Text Field 3'); + expect(rows).toContain('Text Field 2'); + expect(rows).toContain('Text Field 4'); + + const queryId = `qry_copy_${copyShareId}`; + const cacheKey = `query-params:${queryId}` as const; + await cacheService.setDetail(cacheKey, { collapsedGroupIds: [collapsed!.id] }, 60); + try { + const cachedResult = await getCopy({ + type: 'columns', + ranges: JSON.stringify([[0, 0]]), + groupBy: JSON.stringify(groupBy), + queryId, + }); + expect(cachedResult.data.content.split('\n')).not.toContain('Text Field 3'); + } finally { + await cacheService.del(cacheKey); + } }); - it('share not allow copy', async () => { - const result = await createView(tableId, gridViewRo); - const gridViewId = result.id; + it('does not read records when share metadata excludes them', async () => { + await apiUpdateViewShareMeta(copyTable.id, copyViewId, { + allowCopy: true, + includeRecords: false, + }); + try { + const result = await getCopy({ + ranges: JSON.stringify([ + [0, 0], + [0, 1], + ]), + }); + expect(result.data.content).toBe(''); + expect(result.data.header.map((field) => field.id)).toEqual([textFieldId]); + } finally { + await apiUpdateViewShareMeta(copyTable.id, copyViewId, { + allowCopy: true, + includeRecords: true, + }); + } + }); - const shareResult = await apiEnableShareView({ tableId, viewId: gridViewId }); - const gridViewShareId = shareResult.data.shareId; - const error = await getError(() => - anonymousUser.get(urlBuilder(SHARE_VIEW_COPY, { shareId: gridViewShareId }), { - params: { + it.each([ + { ranges: 'not-json' }, + { ranges: JSON.stringify([[0, 0]]) }, + { + ranges: JSON.stringify([ + [1, 1], + [0, 0], + ]), + }, + { type: 'rows', ranges: JSON.stringify([[2, 1]]) }, + ])('rejects malformed ranges: $ranges', async (params) => { + const error = await getError(() => getCopy(params)); + expect(error?.status).toBe(400); + }); + + it('rejects a share without allowCopy', async () => { + await apiUpdateViewShareMeta(copyTable.id, copyViewId, { + allowCopy: false, + includeRecords: true, + }); + try { + const error = await getError(() => + getCopy({ ranges: JSON.stringify([ [0, 0], - [1, 1], + [0, 0], ]), - }, - }) - ); - expect(error?.status).toEqual(403); + }) + ); + expect(error?.status).toBe(403); + } finally { + await apiUpdateViewShareMeta(copyTable.id, copyViewId, { + allowCopy: true, + includeRecords: true, + }); + } }); }); diff --git a/apps/nestjs-backend/test/short-link.e2e-spec.ts b/apps/nestjs-backend/test/short-link.e2e-spec.ts index c94ece005d..f58c6f4709 100644 --- a/apps/nestjs-backend/test/short-link.e2e-spec.ts +++ b/apps/nestjs-backend/test/short-link.e2e-spec.ts @@ -1,6 +1,10 @@ import type { INestApplication } from '@nestjs/common'; +import { ViewType } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; import { + createView, createShortLink, + deleteView, disableShareView, enableShareView, getShortLink, @@ -17,12 +21,14 @@ describe('OpenAPI ShortLinkController (e2e)', () => { let app: INestApplication; let table: ITableFullVo; let shareId: string; + let prisma: PrismaService; let anonymousUser: ReturnType; const baseId = globalThis.testConfig.baseId; beforeAll(async () => { const appCtx = await initApp(); app = appCtx.app; + prisma = app.get(PrismaService); anonymousUser = createAnonymousUserAxios(appCtx.appUrl); table = await createTable(baseId, { name: 'short-link-table' }); @@ -117,4 +123,35 @@ describe('OpenAPI ShortLinkController (e2e)', () => { await permanentDeleteTable(baseId, table2.id); }); + + it('should stop resolving a retained short link after its shared View is deleted', async () => { + const view = ( + await createView(table.id, { + type: ViewType.Grid, + name: 'deleted-share-view', + }) + ).data; + const enabled = await enableShareView({ tableId: table.id, viewId: view.id }); + const { data: created } = await createShortLink({ + type: ShortLinkType.ViewShare, + resourceId: enabled.data.shareId, + }); + + // Do not resolve before deletion: the short-link cache is intentionally + // short-lived and this case verifies the authoritative database lookup. + await deleteView(table.id, view.id); + + expect( + await prisma.shortLink.findUnique({ + where: { code: created.code }, + select: { type: true, resourceId: true, deletedTime: true }, + }) + ).toEqual({ + type: ShortLinkType.ViewShare, + resourceId: enabled.data.shareId, + deletedTime: null, + }); + const error = await getError(() => getShortLink(created.code)); + expect(error?.status).toBe(404); + }); }); diff --git a/apps/nestjs-backend/test/table-lifecycle-full.e2e-spec.ts b/apps/nestjs-backend/test/table-lifecycle-full.e2e-spec.ts index e97769ad2a..01b0577274 100644 --- a/apps/nestjs-backend/test/table-lifecycle-full.e2e-spec.ts +++ b/apps/nestjs-backend/test/table-lifecycle-full.e2e-spec.ts @@ -372,5 +372,7 @@ describe('Table Lifecycle Comprehensive (e2e)', () => { // 14) Clean up: permanently delete tables await permanentDeleteTable(baseId, tableA.id); await permanentDeleteTable(baseId, tableB.id); - }); + // The full lifecycle regularly takes 8-10s on a loaded CI shard; the + // default 10s timeout leaves no margin. + }, 30_000); }); diff --git a/apps/nestjs-backend/test/table-trash.e2e-spec.ts b/apps/nestjs-backend/test/table-trash.e2e-spec.ts index caf5b920af..57c830979d 100644 --- a/apps/nestjs-backend/test/table-trash.e2e-spec.ts +++ b/apps/nestjs-backend/test/table-trash.e2e-spec.ts @@ -10,7 +10,11 @@ import { generateRecordTrashId, } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import type { ITableTrashItemVo } from '@teable/openapi'; +import type { + IGetTrashItemRecordsQuery, + ITableTrashItemVo, + ITrashItemRecordVo, +} from '@teable/openapi'; import { axios, RangeType, @@ -20,10 +24,13 @@ import { deleteRecords, deleteSelection, deleteView, + getTrashItemRecords, getTrashItems, resetTrashItems, ResourceType, restoreTrash, + TableTrashType, + TrashType, updateRecords, updateSetting, urlBuilder, @@ -33,6 +40,7 @@ import { EventEmitterService } from '../src/event-emitter/event-emitter.service' import { Events } from '../src/event-emitter/events'; import { RecordOpenApiService } from '../src/features/record/open-api/record-open-api.service'; import { createAwaitWithEvent } from './utils/event-promise'; +import { getError } from './utils/get-error'; import { initApp, createTable, @@ -133,6 +141,22 @@ const readRestoreTrashStream = async (response: Response) => { return events; }; +const collectAllTrashItemRecords = async ( + trashId: string, + query: Omit +) => { + const collected: ITrashItemRecordVo[] = []; + let cursor: string | undefined; + // generous guard against a cursor that never terminates + for (let i = 0; i < 100; i++) { + const page = await getTrashItemRecords(trashId, { ...query, cursor }); + collected.push(...page.data.items); + cursor = page.data.nextCursor ?? undefined; + if (!cursor) return collected; + } + throw new Error('trash item records cursor did not terminate'); +}; + const waitForTableTrashItems = async (tableId: string, expectedCount = 1, maxRetries = 100) => { for (let i = 0; i < maxRetries; i++) { const result = await getTrashItems({ resourceId: tableId, resourceType: ResourceType.Table }); @@ -415,6 +439,294 @@ describe('Trash (e2e)', () => { }); }); + describe('Table trash filters and record snapshots', () => { + let tableId: string; + + beforeEach(async () => { + tableId = (await createTable(baseId, tableVo)).id; + }); + + afterEach(async () => { + await permanentDeleteTable(baseId, tableId); + }); + + const deleteOneOfEachResource = async () => { + const views = await getViews(tableId); + await awaitWithViewEvent(() => deleteView(tableId, views[1].id)); + + const fields = await getFields(tableId); + const deletedFieldIds = fields.filter((f) => !f.isPrimary).map((f) => f.id); + await awaitWithFieldDeleteSync(async () => deleteFields(tableId, deletedFieldIds)); + + const recordsData = await getRecords(tableId); + await deleteRecords( + tableId, + recordsData.records.map((r) => r.id) + ); + + return await waitForTableTrashItems(tableId, 3); + }; + + it('should filter table trash items by resource type, operator and deleted time', async () => { + await deleteOneOfEachResource(); + + const recordOnly = await getTrashItems({ + resourceId: tableId, + resourceType: TrashType.Table, + resourceTypes: [TableTrashType.Record], + }); + expect(recordOnly.data.trashItems.length).toBe(1); + expect((recordOnly.data.trashItems[0] as ITableTrashItemVo).resourceType).toBe( + TableTrashType.Record + ); + + const viewAndField = await getTrashItems({ + resourceId: tableId, + resourceType: TrashType.Table, + resourceTypes: [TableTrashType.View, TableTrashType.Field], + }); + expect(viewAndField.data.trashItems.length).toBe(2); + + const byUser = await getTrashItems({ + resourceId: tableId, + resourceType: TrashType.Table, + deletedBy: [globalThis.testConfig.userId], + }); + expect(byUser.data.trashItems.length).toBe(3); + + const byUnknownUser = await getTrashItems({ + resourceId: tableId, + resourceType: TrashType.Table, + deletedBy: ['usrunknownfilter001'], + }); + expect(byUnknownUser.data.trashItems.length).toBe(0); + + const oneDayMs = 24 * 60 * 60 * 1000; + const futureStart = await getTrashItems({ + resourceId: tableId, + resourceType: TrashType.Table, + deletedTimeStart: new Date(Date.now() + oneDayMs).toISOString(), + }); + expect(futureStart.data.trashItems.length).toBe(0); + + const aroundNow = await getTrashItems({ + resourceId: tableId, + resourceType: TrashType.Table, + deletedTimeStart: new Date(Date.now() - oneDayMs).toISOString(), + deletedTimeEnd: new Date(Date.now() + oneDayMs).toISOString(), + }); + expect(aroundNow.data.trashItems.length).toBe(3); + }); + + it('should truncate the resource preview in the list while keeping the total count', async () => { + await createRecords(tableId, { + records: Array.from({ length: 15 }).map((_, i) => ({ + fields: { SingleLineText: `bulk-${i}` }, + })), + }); + const recordsData = await getRecords(tableId); + const deletedRecordIds = recordsData.records.map((r) => r.id); + expect(deletedRecordIds.length).toBe(25); + await deleteRecords(tableId, deletedRecordIds); + + const trashRes = await waitForTableTrashItems(tableId, 1); + const item = trashRes.data.trashItems[0] as ITableTrashItemVo; + expect(item.totalResourceCount).toBe(25); + expect(item.resourceIds).toEqual(deletedRecordIds.slice(0, 20)); + // Name resolution only covers the preview ids. + expect(Object.keys(trashRes.data.resourceMap).length).toBe(20); + + // The detail endpoint still pages through the full set (cursor walk). + const collected = (await collectAllTrashItemRecords(item.id, { tableId, take: 10 })).map( + (record) => record.recordId + ); + expect(collected.length).toBe(25); + expect(new Set(collected)).toEqual(new Set(deletedRecordIds)); + }); + + it('should list record snapshots of a record trash item with pagination', async () => { + const recordsData = await getRecords(tableId); + const deletedRecordIds = recordsData.records.map((r) => r.id); + await deleteRecords(tableId, deletedRecordIds); + + const trashRes = await waitForTableTrashItems(tableId, 1); + const trashId = trashRes.data.trashItems[0].id; + + const all = await getTrashItemRecords(trashId, { tableId }); + expect(new Set(all.data.items.map((item) => item.recordId))).toEqual( + new Set(deletedRecordIds) + ); + + const first = all.data.items[0]; + expect(first.deletedBy).toBe(globalThis.testConfig.userId); + expect(first.deletedTime).toBeTruthy(); + expect(all.data.userMap[first.deletedBy]).toBeTruthy(); + // Snapshot fields are keyed by field id while getRecords defaults to name keys. + const fields = await getFields(tableId); + const textField = fields.find((f) => f.name === 'SingleLineText')!; + const firstSource = recordsData.records.find((record) => record.id === first.recordId)!; + expect(first.record.fields[textField.id]).toBe(firstSource.fields.SingleLineText); + + const pageSizes: number[] = []; + let cursor: string | undefined; + do { + const page = await getTrashItemRecords(trashId, { tableId, take: 3, cursor }); + pageSizes.push(page.data.items.length); + cursor = page.data.nextCursor ?? undefined; + } while (cursor); + expect(pageSizes).toEqual([3, 3, 3, 1]); + }); + + it('should skip missing snapshots and keep pagination advancing', async () => { + const recordsData = await getRecords(tableId); + const deletedRecordIds = recordsData.records.map((r) => r.id); + await deleteRecords(tableId, deletedRecordIds); + + const trashRes = await waitForTableTrashItems(tableId, 1); + const trashId = trashRes.data.trashItems[0].id; + + // Simulate restored/purged records: their snapshot rows are gone while the + // trash item still lists their ids. + const removedIds = [ + deletedRecordIds[0], + deletedRecordIds[3], + deletedRecordIds[4], + deletedRecordIds[9], + ]; + await prisma.recordTrash.deleteMany({ + where: { tableId, recordId: { in: removedIds } }, + }); + + const remaining = deletedRecordIds.filter((id) => !removedIds.includes(id)); + // The cursor walk only serves surviving snapshots; restored/purged ids simply + // never appear and pagination keeps advancing past them. + const collected = (await collectAllTrashItemRecords(trashId, { tableId, take: 2 })).map( + (item) => item.recordId + ); + expect(new Set(collected)).toEqual(new Set(remaining)); + expect(collected.length).toBe(remaining.length); + }); + + it('should filter record snapshots with record-level filters', async () => { + const recordsData = await getRecords(tableId); + const deletedRecordIds = recordsData.records.map((r) => r.id); + await deleteRecords(tableId, deletedRecordIds); + + const trashRes = await waitForTableTrashItems(tableId, 1); + const trashId = trashRes.data.trashItems[0].id; + + // Creator: every record was created by the test user; an unknown user matches none. + const byCreator = await collectAllTrashItemRecords(trashId, { + tableId, + recordCreatedBy: [globalThis.testConfig.userId], + }); + expect(new Set(byCreator.map((item) => item.recordId))).toEqual(new Set(deletedRecordIds)); + const byUnknownCreator = await collectAllTrashItemRecords(trashId, { + tableId, + recordCreatedBy: ['usrunknowncreator01'], + }); + expect(byUnknownCreator.length).toBe(0); + + // Created-time range: a future-only window matches none. + const oneDayMs = 24 * 60 * 60 * 1000; + const futureOnly = await collectAllTrashItemRecords(trashId, { + tableId, + recordCreatedTimeStart: new Date(Date.now() + oneDayMs).toISOString(), + }); + expect(futureOnly.length).toBe(0); + }); + + it('should reject non-record trash items and unknown trash ids', async () => { + const views = await getViews(tableId); + await awaitWithViewEvent(() => deleteView(tableId, views[1].id)); + + const trashRes = await waitForTableTrashItems(tableId, 1); + const viewTrashId = trashRes.data.trashItems[0].id; + + const invalidTypeError = await getError(() => getTrashItemRecords(viewTrashId, { tableId })); + expect(invalidTypeError?.status).toBe(400); + + const notFoundError = await getError(() => + getTrashItemRecords(generateRecordTrashId(), { tableId }) + ); + expect(notFoundError?.status).toBe(404); + }); + + it('should return 404 for the detail endpoint after the trash item is restored', async () => { + const recordsData = await getRecords(tableId); + const deletedRecordIds = recordsData.records.slice(0, 2).map((r) => r.id); + await deleteRecords(tableId, deletedRecordIds); + + const trashRes = await waitForTableTrashItems(tableId, 1); + const trashId = trashRes.data.trashItems[0].id; + + await restoreTrash(trashId, tableId); + + const error = await getError(() => getTrashItemRecords(trashId, { tableId })); + expect(error?.status).toBe(404); + }); + + it('should normalize V2 raw snapshots to cell values in the detail endpoint', async () => { + await updateSetting({ + [SettingKey.CANARY_CONFIG]: { + enabled: true, + spaceIds: [globalThis.testConfig.spaceId], + }, + }); + + try { + const selectField = await createField(tableId, { + name: 'Tags', + type: FieldType.MultipleSelect, + options: { choices: [{ name: 'A' }, { name: 'B' }] }, + }); + + const createRes = await createRecords(tableId, { + records: [{ fields: { SingleLineText: 'v2-normalize', Tags: ['A', 'B'] } }], + }); + expect(createRes.headers['x-teable-v2']).toBe('true'); + const recordId = createRes.data.records[0].id; + + const deleteRes = await deleteRecords(tableId, [recordId]); + expect(deleteRes.headers['x-teable-v2']).toBe('true'); + + const trashRes = await waitForTableTrashItems(tableId, 1); + const trashId = trashRes.data.trashItems[0].id; + + const detail = await getTrashItemRecords(trashId, { tableId }); + const item = detail.data.items.find((i) => i.recordId === recordId); + expect(item).toBeTruthy(); + expect(item!.record.fields[selectField.id]).toEqual(['A', 'B']); + + // Force the JSON-string form a raw TEXT column produces (legacy/sqlite storage), + // then assert the endpoint still returns the parsed cell value. + const rawTrash = await prisma.recordTrash.findFirst({ + where: { tableId, recordId }, + select: { id: true, snapshot: true }, + }); + const rawSnapshot = JSON.parse(rawTrash!.snapshot) as { + fields: Record; + }; + rawSnapshot.fields[selectField.id] = JSON.stringify(['A', 'B']); + await prisma.recordTrash.update({ + where: { id: rawTrash!.id }, + data: { snapshot: JSON.stringify(rawSnapshot) }, + }); + + const normalized = await getTrashItemRecords(trashId, { tableId }); + const normalizedItem = normalized.data.items.find((i) => i.recordId === recordId); + expect(normalizedItem!.record.fields[selectField.id]).toEqual(['A', 'B']); + } finally { + await updateSetting({ + [SettingKey.CANARY_CONFIG]: { + enabled: false, + spaceIds: [], + }, + }); + } + }); + }); + describe('Restoring table trash items', () => { let tableId: string; @@ -531,6 +843,95 @@ describe('Trash (e2e)', () => { ).toBe(true); }); + it('should clear dangling link entries instead of failing when a link target was deleted after the snapshot', async () => { + const foreignTable = await createTable(baseId, { + name: `restore-dangling-target-${Date.now()}`, + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + records: [{ fields: { Name: 'target' } }], + }); + + try { + const linkField = await createField(tableId, { + name: 'restore dangling link', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: foreignTable.id, + }, + }); + + const targetRecord = (await getRecords(foreignTable.id, { fieldKeyType: FieldKeyType.Id })) + .records[0]; + const createRes = await createRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + records: [{ fields: { [linkField.id]: [{ id: targetRecord.id }] } }], + }); + const mainRecordId = createRes.data.records[0].id; + + await deleteRecords(tableId, [mainRecordId]); + const trashItemsRes = await waitForTableTrashItems(tableId, 1); + const recordTrashItem = trashItemsRes.data.trashItems.find( + (item) => (item as ITableTrashItemVo).resourceType === TableTrashType.Record + ) as ITableTrashItemVo | undefined; + expect(recordTrashItem).toBeTruthy(); + + // the snapshot now references a record that no longer exists + await deleteRecords(foreignTable.id, [targetRecord.id]); + + const restored = await restoreTrash(recordTrashItem!.id, tableId); + expect(restored.status).toEqual(201); + + const recordsAfterRestore = await getRecords(tableId, { fieldKeyType: FieldKeyType.Id }); + const restoredRecord = recordsAfterRestore.records.find( + (record) => record.id === mainRecordId + ); + expect(restoredRecord).toBeTruthy(); + expect(restoredRecord!.fields[linkField.id]).toBeFalsy(); + } finally { + await permanentDeleteTable(baseId, foreignTable.id); + } + }); + + it('should keep links whose targets are restored in the same batch', async () => { + const linkField = await createField(tableId, { + name: 'restore self link', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: tableId, + }, + }); + + const existing = (await getRecords(tableId, { fieldKeyType: FieldKeyType.Id })).records; + const [recordA, recordB] = existing.map((record) => record.id); + await updateRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + records: [{ id: recordA, fields: { [linkField.id]: [{ id: recordB }] } }], + }); + + await deleteRecords(tableId, [recordA, recordB]); + const trashItemsRes = await waitForTableTrashItems(tableId, 1); + const recordTrashItem = trashItemsRes.data.trashItems.find( + (item) => (item as ITableTrashItemVo).resourceType === TableTrashType.Record + ) as ITableTrashItemVo | undefined; + expect(recordTrashItem).toBeTruthy(); + + const restored = await restoreTrash(recordTrashItem!.id, tableId); + expect(restored.status).toEqual(201); + + const recordsAfterRestore = await getRecords(tableId, { fieldKeyType: FieldKeyType.Id }); + const restoredA = recordsAfterRestore.records.find((record) => record.id === recordA); + expect(restoredA).toBeTruthy(); + // the dangling-link filter must count in-batch records as live and keep this + // entry. Not asserted as an exact match: replaying both sides of a two-way + // link in one batch duplicates the entry — a pre-existing write-pipeline + // quirk unrelated to the filter. + const linkIds = (restoredA!.fields[linkField.id] as { id: string }[]).map( + (entry) => entry.id + ); + expect(linkIds).toContain(recordB); + }); + it('should restore V2 record trash through the V2 restore command in canary bases', async () => { await updateSetting({ [SettingKey.CANARY_CONFIG]: { diff --git a/apps/nestjs-backend/test/table.e2e-spec.ts b/apps/nestjs-backend/test/table.e2e-spec.ts index 686c536505..86d9f016ac 100644 --- a/apps/nestjs-backend/test/table.e2e-spec.ts +++ b/apps/nestjs-backend/test/table.e2e-spec.ts @@ -405,6 +405,11 @@ describe('OpenAPI TableController (e2e)', () => { expect(table.name).toEqual('newTableName'); expect(table.description).toEqual('newDescription'); expect(table.icon).toEqual('😀'); + + await updateTableIcon(baseId, tableId, { icon: null }); + + const tableAfterIconRemoved = await getTable(baseId, tableId); + expect(tableAfterIconRemoved.icon).toBeFalsy(); }); it('should delete table and clean up link and lookup fields', async () => { diff --git a/apps/nestjs-backend/test/trash.e2e-spec.ts b/apps/nestjs-backend/test/trash.e2e-spec.ts index f2893af616..36dc1827ae 100644 --- a/apps/nestjs-backend/test/trash.e2e-spec.ts +++ b/apps/nestjs-backend/test/trash.e2e-spec.ts @@ -1,4 +1,5 @@ /* eslint-disable sonarjs/no-duplicate-string */ +import net from 'node:net'; import type { INestApplication } from '@nestjs/common'; import { FieldType, Relationship } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; @@ -7,12 +8,14 @@ import { getTrash, getTrashItems, resetTrashItems, - ResourceType, restoreTrash, + TrashType, trashVoSchema, } from '@teable/openapi'; import { EventEmitterService } from '../src/event-emitter/event-emitter.service'; import { Events } from '../src/event-emitter/events'; +import { encryptDataDbUrl } from '../src/features/space/data-db-url-secret'; +import { TrashService } from '../src/features/trash/trash.service'; import { createAwaitWithEvent } from './utils/event-promise'; import { initApp, @@ -31,14 +34,68 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const waitForBaseTrashItems = async (baseId: string, expectedCount = 1, maxRetries = 100) => { for (let i = 0; i < maxRetries; i++) { - const result = await getTrashItems({ resourceId: baseId, resourceType: ResourceType.Base }); + const result = await getTrashItems({ resourceId: baseId, resourceType: TrashType.Base }); if (result.data.trashItems.length >= expectedCount) { return result; } await sleep(100); } - return await getTrashItems({ resourceId: baseId, resourceType: ResourceType.Base }); + return await getTrashItems({ resourceId: baseId, resourceType: TrashType.Base }); +}; + +const buildPostgresErrorResponse = (message: string) => { + const fields = [ + Buffer.from('SFATAL\0'), + Buffer.from('CXX000\0'), + Buffer.from(`M${message}\0`), + Buffer.from('\0'), + ]; + const payload = Buffer.concat(fields); + const response = Buffer.alloc(5 + payload.length); + response[0] = 'E'.charCodeAt(0); + response.writeInt32BE(4 + payload.length, 1); + payload.copy(response, 5); + return response; +}; + +const SSL_REQUEST_CODE = 80877103; + +/** + * A Supavisor pooler whose Supabase project has been deleted: every login is + * rejected with "(ENOTFOUND) tenant/user postgres. not found". + */ +const createDeadSupavisor = async (tenantRef: string) => { + const sockets = new Set(); + let rejectedLogins = 0; + + const server = net.createServer((socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + socket.on('error', () => socket.destroy()); + socket.on('data', (chunk) => { + if (chunk.length === 8 && chunk.readInt32BE(4) === SSL_REQUEST_CODE) { + socket.write('N'); + return; + } + rejectedLogins += 1; + socket.end( + buildPostgresErrorResponse(`(ENOTFOUND) tenant/user postgres.${tenantRef} not found`) + ); + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as net.AddressInfo).port; + + return { + url: `postgresql://postgres.${tenantRef}:secret@127.0.0.1:${port}/postgres`, + rejectedLogins: () => rejectedLogins, + close: async () => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(resolve)); + }, + }; }; describe('Trash (e2e)', () => { @@ -100,7 +157,7 @@ describe('Trash (e2e)', () => { it('should get trash for space', async () => { await awaitWithSpaceEvent(() => deleteSpace(spaceId)); - const res = await getTrash({ resourceType: ResourceType.Space }); + const res = await getTrash({ resourceType: TrashType.Space }); expect(trashVoSchema.safeParse(res.data).success).toEqual(true); }); @@ -108,7 +165,7 @@ describe('Trash (e2e)', () => { it('should get trash for base', async () => { await awaitWithBaseEvent(() => deleteBase(baseId)); - const res = await getTrash({ resourceType: ResourceType.Base }); + const res = await getTrash({ resourceType: TrashType.Base }); expect(trashVoSchema.safeParse(res.data).success).toEqual(true); }); @@ -166,7 +223,7 @@ describe('Trash (e2e)', () => { it('should restore space successfully', async () => { await awaitWithSpaceEvent(() => deleteSpace(spaceId)); - const trash = (await getTrash({ resourceType: ResourceType.Space })).data; + const trash = (await getTrash({ resourceType: TrashType.Space })).data; const restored = await restoreTrash(trash.trashItems[0].id); expect(restored.status).toEqual(201); @@ -175,7 +232,7 @@ describe('Trash (e2e)', () => { it('should restore base successfully', async () => { await awaitWithBaseEvent(() => deleteBase(baseId)); - const trash = (await getTrash({ resourceType: ResourceType.Base })).data; + const trash = (await getTrash({ resourceType: TrashType.Base })).data; const restored = await restoreTrash(trash.trashItems[0].id); expect(restored.status).toEqual(201); @@ -245,13 +302,71 @@ describe('Trash (e2e)', () => { expect(trash.trashItems.length).toEqual(3); - await resetTrashItems({ resourceType: ResourceType.Base, resourceId: baseId }); + await resetTrashItems({ resourceType: TrashType.Base, resourceId: baseId }); - const resetTrash = ( - await getTrashItems({ resourceId: baseId, resourceType: ResourceType.Base }) - ).data; + const resetTrash = (await getTrashItems({ resourceId: baseId, resourceType: TrashType.Base })) + .data; expect(resetTrash.trashItems.length).toEqual(0); }); }); + + describe('Cleanup on a dead BYODB', () => { + let deadDb: Awaited>; + + beforeAll(async () => { + deadDb = await createDeadSupavisor('sztvxe2efake'); + }); + + afterAll(async () => { + await deadDb.close(); + }); + + it('purges a table trash row even though every login to the bound DB fails', async () => { + const space = await createSpace({ name: 'dead byodb space' }); + const base = await createBase({ spaceId: space.id, name: 'dead byodb base' }); + const table = await createTable(base.id, { name: 'victim table' }); + await deleteTable(base.id, table.id); + + // The TableTrashed listener writes the trash row asynchronously + // (delete+insert replace), so poll until it lands. + let trash: { id: string; parentId: string | null } | null = null; + for (let i = 0; i < 100 && !trash; i++) { + trash = await prisma.trash.findFirst({ where: { resourceId: table.id } }); + if (!trash) await sleep(100); + } + if (!trash) throw new Error('trash row for the deleted table never appeared'); + expect(trash.parentId).toBe(base.id); + + // Bind the space to the dead database only after the table exists on the + // meta-fallback DB — mirrors production, where the customer's project + // died after the tables were created. + const connection = await prisma.dataDbConnection.create({ + data: { + encryptedUrl: encryptDataDbUrl(deadDb.url), + urlFingerprint: `dead-e2e-${Date.now()}`, + internalSchema: '__teable_internal', + status: 'ready', + createdBy: 'e2e', + }, + }); + await prisma.spaceDataDbBinding.create({ + data: { + spaceId: space.id, + dataDbConnectionId: connection.id, + mode: 'byodb', + state: 'ready', + createdBy: 'e2e', + }, + }); + + // Same call the TrashCleanupProcessor makes. + const trashService = app.get(TrashService); + await trashService.delete(trash.id, true); + + expect(deadDb.rejectedLogins()).toBeGreaterThan(0); + await expect(prisma.trash.findUnique({ where: { id: trash.id } })).resolves.toBeNull(); + await expect(prisma.tableMeta.findUnique({ where: { id: table.id } })).resolves.toBeNull(); + }); + }); }); diff --git a/apps/nestjs-backend/test/undo-redo.e2e-spec.ts b/apps/nestjs-backend/test/undo-redo.e2e-spec.ts index c34cd07ab0..86382aa7db 100644 --- a/apps/nestjs-backend/test/undo-redo.e2e-spec.ts +++ b/apps/nestjs-backend/test/undo-redo.e2e-spec.ts @@ -1,17 +1,27 @@ /* eslint-disable sonarjs/no-duplicate-string */ import type { INestApplication } from '@nestjs/common'; -import type { IFieldRo, IFieldVo, ILinkFieldOptions, IRollupFieldOptions } from '@teable/core'; +import type { + IButtonFieldCellValue, + IFieldRo, + IFieldVo, + ILinkFieldOptions, + IRollupFieldOptions, +} from '@teable/core'; import { CellValueType, + Colors, DbFieldType, FieldKeyType, FieldType, getRandomString, Relationship, + SortFunc, ViewType, } from '@teable/core'; import { axios, + buttonClick, + buttonReset, clear, convertField, copy, @@ -25,18 +35,24 @@ import { deleteSelection, deleteSelectionStream, deleteView, + disableShareView, + duplicateView, duplicateSelectionStream, getField, getFields, getRecord, getRecords, getTrashItems, + getViewInstallPlugin, ResourceType, getView, getViewList, + getShareView, + installViewPlugin, paste, RangeType, redo, + enableShareView, undo, updateRecord, updateRecordOrders, @@ -44,17 +60,24 @@ import { updateViewColumnMeta, updateViewDescription, updateViewFilter, + updateViewGroup, + updateViewOptions, updateViewName, updateViewOrder, + updateViewSort, + updateViewShareMeta, + manualSortView, + refreshViewShareId, X_CANARY_HEADER, ensureUndoRedoWindowIdHeader, } from '@teable/openapi'; import type { ITableFullVo } from '@teable/openapi'; +import { onTestFinished } from 'vitest'; import { EventEmitterService } from '../src/event-emitter/event-emitter.service'; import { Events } from '../src/event-emitter/events'; import { X_TEABLE_V2_HEADER } from '../src/features/canary/interceptors/v2-indicator.interceptor'; import { X_TEABLE_UNDO_REDO_ENGINE_HEADER } from '../src/features/undo-redo/open-api/undo-redo.service'; -import { createAwaitWithEvent } from './utils/event-promise'; +import { createEventPromise } from './utils/event-promise'; import { initApp, permanentDeleteTable, createTable, updateRecordByApi } from './utils/init-app'; const isForceV2 = process.env.FORCE_V2_ALL === 'true'; @@ -107,9 +130,20 @@ describe('Undo Redo (e2e)', () => { eventEmitterService = app.get(EventEmitterService); windowId = 'win' + getRandomString(8); ensureUndoRedoWindowIdHeader(windowId); + // Per-request routing can select v2 even without FORCE_V2_ALL (e.g. the + // seeded base is v2Enabled); v2 paths append undo entries without emitting + // the v1 OPERATION_PUSH event, so only wait for it on v1-routed responses. awaitWithEvent = isForceV2 ? async (action: () => Promise) => await action() - : createAwaitWithEvent(eventEmitterService, Events.OPERATION_PUSH); + : async (action: () => Promise) => { + const eventPromise = createEventPromise(eventEmitterService, Events.OPERATION_PUSH); + const response = await action(); + const headers = (response as { headers?: Record } | undefined)?.headers; + if (headers?.[X_TEABLE_V2_HEADER] !== 'true') { + await eventPromise; + } + return response; + }; }); afterAll(async () => { @@ -175,6 +209,85 @@ describe('Undo Redo (e2e)', () => { }); }); + it.skipIf(!isForceV2)('should undo / redo a v2 Button click', async () => { + const button = ( + await createField(table.id, { + type: FieldType.Button, + options: { + label: 'Run', + color: Colors.Teal, + workflow: { + id: `wfl${'b'.repeat(16)}`, + name: 'Run', + isActive: true, + }, + }, + }) + ).data; + const recordId = table.records[0].id; + + const clickResponse = await buttonClick(table.id, recordId, button.id); + expect(clickResponse.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect((clickResponse.data.record.fields[button.id] as IButtonFieldCellValue).count).toBe(1); + + const undoResponse = await undo(table.id); + expect(undoResponse.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + expect(undoResponse.data).toMatchObject({ status: 'fulfilled' }); + + const redoResponse = await redo(table.id); + expect(redoResponse.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + expect(redoResponse.data).toMatchObject({ status: 'fulfilled' }); + + const clickAfterRedo = await buttonClick(table.id, recordId, button.id); + expect((clickAfterRedo.data.record.fields[button.id] as IButtonFieldCellValue).count).toBe(2); + + await undo(table.id); + await undo(table.id); + + const clickAfterUndo = await buttonClick(table.id, recordId, button.id); + expect((clickAfterUndo.data.record.fields[button.id] as IButtonFieldCellValue).count).toBe(1); + }); + + it.skipIf(!isForceV2)('should undo / redo a v2 Button reset', async () => { + const button = ( + await createField(table.id, { + type: FieldType.Button, + options: { + label: 'Run', + color: Colors.Teal, + resetCount: true, + workflow: { + id: `wfl${'c'.repeat(16)}`, + name: 'Run', + isActive: true, + }, + }, + }) + ).data; + const recordId = table.records[0].id; + + await buttonClick(table.id, recordId, button.id); + const resetResponse = await buttonReset(table.id, recordId, button.id); + expect(resetResponse.headers[X_TEABLE_V2_HEADER]).toBe('true'); + + const undoReset = await undo(table.id); + expect(undoReset.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + expect(undoReset.data).toMatchObject({ status: 'fulfilled' }); + + const redoReset = await redo(table.id); + expect(redoReset.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + expect(redoReset.data).toMatchObject({ status: 'fulfilled' }); + + const clickAfterRedo = await buttonClick(table.id, recordId, button.id); + expect((clickAfterRedo.data.record.fields[button.id] as IButtonFieldCellValue).count).toBe(1); + + await undo(table.id); + await undo(table.id); + + const clickAfterUndo = await buttonClick(table.id, recordId, button.id); + expect((clickAfterUndo.data.record.fields[button.id] as IButtonFieldCellValue).count).toBe(2); + }); + it('should undo / redo delete record', async () => { await awaitWithEvent(() => createField(table.id, { type: FieldType.CreatedTime })); await awaitWithEvent(() => createField(table.id, { type: FieldType.LastModifiedTime })); @@ -1231,23 +1344,23 @@ describe('Undo Redo (e2e)', () => { }); it('should undo / redo create view', async () => { - const view = ( - await awaitWithEvent(() => - createView(table.id, { - type: ViewType.Grid, - name: 'view1', - }) - ) - ).data; + const createResponse = await awaitWithEvent(() => + createView(table.id, { + type: ViewType.Grid, + name: 'view1', + }) + ); + const view = createResponse.data; + const expectedEngine = createResponse.headers[X_TEABLE_V2_HEADER] === 'true' ? 'v2' : 'v1'; const undoRes = await undo(table.id); - expect(undoRes.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v1'); + expect(undoRes.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(expectedEngine); const viewsAfterUndo = (await getViewList(table.id)).data; expect(viewsAfterUndo.find((v) => v.id === view.id)).toBeUndefined(); const redoRes = await redo(table.id); - expect(redoRes.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v1'); + expect(redoRes.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(expectedEngine); const viewsAfterRedo = (await getViewList(table.id)).data; expect(viewsAfterRedo.find((v) => v.id === view.id)).toMatchObject({ @@ -1257,6 +1370,69 @@ describe('Undo Redo (e2e)', () => { }); }); + it.skipIf(!isForceV2)( + 'should undo / redo Plugin View install with the same installation identity', + async () => { + const installResponse = await installViewPlugin(table.id, { + name: 'Undo plugin', + pluginId: 'plgsheetform', + }); + const installed = installResponse.data; + + expect(installResponse.headers[X_TEABLE_V2_HEADER]).toBe('true'); + await expect(getViewInstallPlugin(table.id, installed.viewId)).resolves.toMatchObject({ + data: { + pluginId: 'plgsheetform', + pluginInstallId: installed.pluginInstallId, + name: 'Undo plugin', + }, + }); + + const undoResponse = await undo(table.id); + expect(undoResponse.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + expect(await waitForViewVisibility(table.id, installed.viewId, false, 300)).toBeUndefined(); + await expect(getViewInstallPlugin(table.id, installed.viewId)).rejects.toThrow(); + + const redoResponse = await redo(table.id); + expect(redoResponse.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + expect(await waitForViewVisibility(table.id, installed.viewId, true, 300)).toMatchObject({ + id: installed.viewId, + name: 'Undo plugin', + type: ViewType.Plugin, + }); + await expect(getViewInstallPlugin(table.id, installed.viewId)).resolves.toMatchObject({ + data: { + pluginId: 'plgsheetform', + pluginInstallId: installed.pluginInstallId, + name: 'Undo plugin', + }, + }); + } + ); + + it.skipIf(!isForceV2)( + 'should undo / redo duplicate view with the same View identity', + async () => { + const source = table.views[0]; + const duplicateResponse = await duplicateView(table.id, source.id); + const duplicated = duplicateResponse.data; + const expectedEngine = duplicateResponse.headers[X_TEABLE_V2_HEADER] === 'true' ? 'v2' : 'v1'; + + const undoResponse = await undo(table.id); + expect(undoResponse.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(expectedEngine); + expect(await waitForViewVisibility(table.id, duplicated.id, false, 300)).toBeUndefined(); + + const redoResponse = await redo(table.id); + expect(redoResponse.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(expectedEngine); + expect(await waitForViewVisibility(table.id, duplicated.id, true, 300)).toMatchObject({ + id: duplicated.id, + name: duplicated.name, + type: duplicated.type, + columnMeta: duplicated.columnMeta, + }); + } + ); + it('should undo / redo delete view', async () => { const view = ( await awaitWithEvent(() => @@ -1267,9 +1443,11 @@ describe('Undo Redo (e2e)', () => { ) ).data; - await awaitWithEvent(() => deleteView(table.id, view.id)); + const deleteResponse = await awaitWithEvent(() => deleteView(table.id, view.id)); + const expectedEngine = deleteResponse.headers[X_TEABLE_V2_HEADER] === 'true' ? 'v2' : 'v1'; - await undo(table.id); + const undoResponse = await undo(table.id); + expect(undoResponse.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(expectedEngine); expect(await waitForViewVisibility(table.id, view.id, true, 300)).toMatchObject({ id: view.id, @@ -1277,63 +1455,114 @@ describe('Undo Redo (e2e)', () => { type: view.type, }); - await redo(table.id); + const redoResponse = await redo(table.id); + expect(redoResponse.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(expectedEngine); expect(await waitForViewVisibility(table.id, view.id, false, 300)).toBeUndefined(); }); + it.skipIf(!isForceV2)( + 'should never revive a revoked share credential through delete snapshot replay', + async () => { + const view = ( + await createView(table.id, { + type: ViewType.Grid, + name: 'Shared delete replay', + }) + ).data; + const enabled = await enableShareView({ tableId: table.id, viewId: view.id }); + const revokedShareId = enabled.data.shareId; + + expect(enabled.headers[X_TEABLE_V2_HEADER]).toBe('true'); + await expect(getShareView(revokedShareId)).resolves.toBeDefined(); + + const deleted = await deleteView(table.id, view.id); + expect(deleted.headers[X_TEABLE_V2_HEADER]).toBe('true'); + await expect(getShareView(revokedShareId)).rejects.toThrow(); + + const firstUndo = await undo(table.id); + expect(firstUndo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + const firstRestore = (await getView(table.id, view.id)).data; + expect(firstRestore.enableShare).not.toBe(true); + expect(firstRestore.shareId).toBeUndefined(); + await expect(getShareView(revokedShareId)).rejects.toThrow(); + + // Restored snapshots are deliberately unshared, so refresh cannot rotate + // the revoked credential and must leave the delete redo entry intact. + await expect(refreshViewShareId(table.id, view.id)).rejects.toThrow(); + + const redoDelete = await redo(table.id); + expect(redoDelete.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + expect(await waitForViewVisibility(table.id, view.id, false, 300)).toBeUndefined(); + + const secondUndo = await undo(table.id); + expect(secondUndo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + const secondRestore = (await getView(table.id, view.id)).data; + expect(secondRestore.enableShare).not.toBe(true); + expect(secondRestore.shareId).toBeUndefined(); + await expect(getShareView(revokedShareId)).rejects.toThrow(); + } + ); + it('should undo / redo update view property', async () => { // name const view = table.views[0]; - (await awaitWithEvent(() => updateViewName(table.id, view.id, { name: 'newName' }))).data; + const renameResponse = await awaitWithEvent(() => + updateViewName(table.id, view.id, { name: 'newName' }) + ); + const renameEngine = renameResponse.headers[X_TEABLE_V2_HEADER] === 'true' ? 'v2' : 'v1'; - await undo(table.id); + const renameUndo = await undo(table.id); + expect(renameUndo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(renameEngine); expect((await getView(table.id, view.id)).data.name).toEqual(view.name); - await redo(table.id); + const renameRedo = await redo(table.id); + expect(renameRedo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(renameEngine); expect((await getView(table.id, view.id)).data.name).toEqual('newName'); // description - ( - await awaitWithEvent(() => - updateViewDescription(table.id, view.id, { description: 'newName' }) - ) - ).data; + const descriptionResponse = await awaitWithEvent(() => + updateViewDescription(table.id, view.id, { description: 'newName' }) + ); + const descriptionEngine = + descriptionResponse.headers[X_TEABLE_V2_HEADER] === 'true' ? 'v2' : 'v1'; - await undo(table.id); + const descriptionUndo = await undo(table.id); + expect(descriptionUndo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(descriptionEngine); expect((await getView(table.id, view.id)).data.description).toEqual(view.description); - await redo(table.id); + const descriptionRedo = await redo(table.id); + expect(descriptionRedo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(descriptionEngine); expect((await getView(table.id, view.id)).data.description).toEqual('newName'); // filter + const filterResponse = await awaitWithEvent(() => + updateViewFilter(table.id, view.id, { + filter: { + filterSet: [ + { + fieldId: table.fields![0].id, + value: 'text', + operator: 'is', + }, + ], + conjunction: 'and', + }, + }) + ); + const filterEngine = filterResponse.headers[X_TEABLE_V2_HEADER] === 'true' ? 'v2' : 'v1'; - ( - await awaitWithEvent(() => - updateViewFilter(table.id, view.id, { - filter: { - filterSet: [ - { - fieldId: table.fields![0].id, - value: 'text', - operator: 'is', - }, - ], - conjunction: 'and', - }, - }) - ) - ).data; - - await undo(table.id); + const filterUndo = await undo(table.id); + expect(filterUndo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(filterEngine); expect((await getView(table.id, view.id)).data.filter).toEqual(view.filter); - await redo(table.id); + const filterRedo = await redo(table.id); + expect(filterRedo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(filterEngine); expect((await getView(table.id, view.id)).data.filter).toEqual({ filterSet: [ @@ -1345,34 +1574,170 @@ describe('Undo Redo (e2e)', () => { ], conjunction: 'and', }); + + // sort + const sort = { + sortObjs: [{ fieldId: table.fields![0].id, order: SortFunc.Desc }], + manualSort: false, + }; + const sortResponse = await awaitWithEvent(() => updateViewSort(table.id, view.id, { sort })); + const sortEngine = sortResponse.headers[X_TEABLE_V2_HEADER] === 'true' ? 'v2' : 'v1'; + + const sortUndo = await undo(table.id); + expect(sortUndo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(sortEngine); + expect((await getView(table.id, view.id)).data.sort).toEqual(view.sort); + + const sortRedo = await redo(table.id); + expect(sortRedo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(sortEngine); + expect((await getView(table.id, view.id)).data.sort).toEqual(sort); + + // group + const group = [{ fieldId: table.fields![0].id, order: SortFunc.Asc }]; + const groupResponse = await awaitWithEvent(() => updateViewGroup(table.id, view.id, { group })); + const groupEngine = groupResponse.headers[X_TEABLE_V2_HEADER] === 'true' ? 'v2' : 'v1'; + + const groupUndo = await undo(table.id); + expect(groupUndo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(groupEngine); + expect((await getView(table.id, view.id)).data.group).toEqual(view.group); + + const groupRedo = await redo(table.id); + expect(groupRedo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(groupEngine); + expect((await getView(table.id, view.id)).data.group).toEqual(group); + + // options + const options = { rowHeight: 'tall' as const, fieldNameDisplayLines: 2 }; + const optionsResponse = await awaitWithEvent(() => + updateViewOptions(table.id, view.id, { options }) + ); + const optionsEngine = optionsResponse.headers[X_TEABLE_V2_HEADER] === 'true' ? 'v2' : 'v1'; + + const optionsUndo = await undo(table.id); + expect(optionsUndo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(optionsEngine); + expect((await getView(table.id, view.id)).data.options).toEqual(view.options); + + const optionsRedo = await redo(table.id); + expect(optionsRedo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(optionsEngine); + expect((await getView(table.id, view.id)).data.options).toEqual(options); + }); + + // v1 share-meta updates never registered an undo operation (no window id on + // that path), so this half of the contract only exists on the v2 engine. + it.skipIf(!isForceV2)( + 'should undo / redo view share metadata through the v2 engine', + async () => { + const view = table.views[0]; + const shareMeta = { allowCopy: true, submit: { requireLogin: true } }; + const shareMetaResponse = await updateViewShareMeta(table.id, view.id, shareMeta); + expect(shareMetaResponse.headers[X_TEABLE_V2_HEADER]).toBe('true'); + + const shareMetaUndo = await undo(table.id); + expect(shareMetaUndo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + expect((await getView(table.id, view.id)).data.shareMeta).toEqual(view.shareMeta); + + const shareMetaRedo = await redo(table.id); + expect(shareMetaRedo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + expect((await getView(table.id, view.id)).data.shareMeta).toEqual(shareMeta); + } + ); + + // v1 manual sort never registered an undo operation, so this half of the + // contract only exists on the v2 engine. + it.skipIf(!isForceV2)('should undo / redo view manual sort through the v2 engine', async () => { + const view = table.views[0]; + const sort = { + sortObjs: [{ fieldId: table.fields![0].id, order: SortFunc.Desc }], + manualSort: false, + }; + await updateViewSort(table.id, view.id, { sort }); + + const manualSortResponse = await manualSortView(table.id, view.id, { + sortObjs: [{ fieldId: table.fields![0].id, order: SortFunc.Asc }], + }); + expect(manualSortResponse.headers[X_TEABLE_V2_HEADER]).toBe('true'); + + const manualSortUndo = await undo(table.id); + expect(manualSortUndo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + expect((await getView(table.id, view.id)).data.sort).toEqual(sort); + + const manualSortRedo = await redo(table.id); + expect(manualSortRedo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + expect((await getView(table.id, view.id)).data.sort).toEqual({ + sortObjs: [{ fieldId: table.fields![0].id, order: SortFunc.Asc }], + manualSort: true, + }); + }); + + it('should undo / redo v2 View share lifecycle without restoring revoked credentials', async () => { + // This case asserts the v2 share lifecycle chain end to end; pin the env so + // the default CI lane cannot route it to v1. + const previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + onTestFinished(() => { + if (previousForceV2All == null) delete process.env.FORCE_V2_ALL; + else process.env.FORCE_V2_ALL = previousForceV2All; + }); + const view = table.views[0]; + const enableResponse = await enableShareView({ tableId: table.id, viewId: view.id }); + const firstShareId = enableResponse.data.shareId; + expect(enableResponse.headers[X_TEABLE_V2_HEADER]).toBe('true'); + + const enableUndo = await undo(table.id); + expect(enableUndo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + const afterEnableUndo = (await getView(table.id, view.id)).data; + expect(afterEnableUndo.enableShare).not.toBe(true); + expect(afterEnableUndo.shareId).toBe(firstShareId); + await expect(getShareView(firstShareId)).rejects.toThrow(); + + const enableRedo = await redo(table.id); + expect(enableRedo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + const afterEnableRedo = (await getView(table.id, view.id)).data; + expect(afterEnableRedo.enableShare).toBe(true); + expect(afterEnableRedo.shareId).not.toBe(firstShareId); + await expect(getShareView(firstShareId)).rejects.toThrow(); + + const disabledShareId = afterEnableRedo.shareId!; + await disableShareView({ tableId: table.id, viewId: view.id }); + await expect(getShareView(disabledShareId)).rejects.toThrow(); + + const disableUndo = await undo(table.id); + expect(disableUndo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + const afterDisableUndo = (await getView(table.id, view.id)).data; + expect(afterDisableUndo.enableShare).toBe(true); + expect(afterDisableUndo.shareId).not.toBe(disabledShareId); + await expect(getShareView(disabledShareId)).rejects.toThrow(); + + const disableRedo = await redo(table.id); + expect(disableRedo.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2'); + expect((await getView(table.id, view.id)).data.enableShare).not.toBe(true); }); it('should undo / redo update view column meta', async () => { const view = table.views[0]; - ( - await awaitWithEvent(() => - updateViewColumnMeta(table.id, view.id, [ - { - fieldId: table.fields[1].id, - columnMeta: { - order: 10, - }, + const updateResponse = await awaitWithEvent(() => + updateViewColumnMeta(table.id, view.id, [ + { + fieldId: table.fields[1].id, + columnMeta: { + order: 10, }, - ]) - ) - ).data; + }, + ]) + ); + const expectedEngine = updateResponse.headers[X_TEABLE_V2_HEADER] === 'true' ? 'v2' : 'v1'; const fields = (await getFields(table.id, { viewId: view.id })).data; expect(fields[2].id).toEqual(table.fields[1].id); - await undo(table.id); + const undoResponse = await undo(table.id); + expect(undoResponse.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(expectedEngine); const fieldsAfterUndo = (await getFields(table.id, { viewId: view.id })).data; expect(fieldsAfterUndo[1].id).toEqual(table.fields[1].id); - await redo(table.id); + const redoResponse = await redo(table.id); + expect(redoResponse.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(expectedEngine); const fieldsAfterRedo = (await getFields(table.id, { viewId: view.id })).data; @@ -1390,18 +1755,19 @@ describe('Undo Redo (e2e)', () => { ) ).data; - ( - await awaitWithEvent(() => - updateViewOrder(table.id, view.id, { anchorId: view1.id, position: 'after' }) - ) - ).data; + const updateResponse = await awaitWithEvent(() => + updateViewOrder(table.id, view.id, { anchorId: view1.id, position: 'after' }) + ); + const expectedEngine = updateResponse.headers[X_TEABLE_V2_HEADER] === 'true' ? 'v2' : 'v1'; - await undo(table.id); + const undoResponse = await undo(table.id); + expect(undoResponse.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(expectedEngine); const viewsAfterUndo = (await getViewList(table.id)).data; expect(viewsAfterUndo[0].id).equal(view.id); - await redo(table.id); + const redoResponse = await redo(table.id); + expect(redoResponse.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(expectedEngine); const viewsAfterRedo = (await getViewList(table.id)).data; expect(viewsAfterRedo[1].id).equal(view.id); diff --git a/apps/nestjs-backend/test/utils/e2e-shared.ts b/apps/nestjs-backend/test/utils/e2e-shared.ts index cbb4dcb28b..5f31a1c9ae 100644 --- a/apps/nestjs-backend/test/utils/e2e-shared.ts +++ b/apps/nestjs-backend/test/utils/e2e-shared.ts @@ -33,6 +33,7 @@ export interface ISharedBundle { interface ISharedEntry { bundle: ISharedBundle; proxied: ISharedBundle; + refreshSession?: () => Promise>; } interface ISharedState { @@ -146,6 +147,7 @@ function envDiffFromBaseline(): string[] { export interface IBootResult { bundle: ISharedBundle; cookieInterceptorId: number; + refreshSession?: () => Promise>; } /** @@ -203,13 +205,14 @@ export async function acquireApp( const st = state(); let entryPromise = st.registry.get(cacheKey); + const reusing = Boolean(entryPromise); if (!entryPromise) { // Files run sequentially inside a worker, so nothing else executes test code // while this boot is in flight: any env delta across the boot is a boot // artifact (e.g. SSL_CERT_FILE) — absorb it into the baseline so later files // aren't misclassified as env-customized. const preBootEnv = { ...process.env }; - entryPromise = boot().then(({ bundle }) => { + entryPromise = boot().then(({ bundle, refreshSession }) => { const baseline = st.baselineEnv; if (baseline) { const keys = new Set([...Object.keys(preBootEnv), ...Object.keys(process.env)]); @@ -222,6 +225,7 @@ export async function acquireApp( const entry: ISharedEntry = { bundle, proxied: { ...bundle, app: closelessApp(bundle.app) }, + refreshSession, }; st.resolved.set(cacheKey, entry); if (!st.primaryKey && axios) { @@ -236,13 +240,61 @@ export async function acquireApp( st.registry.set(cacheKey, entryPromise); } const entry = await entryPromise; + if (reusing && entry.refreshSession) { + const session = await entry.refreshSession(); + Object.assign(entry.bundle, session); + Object.assign(entry.proxied, session); + } + // Self-heal on reuse: probe auth and reboot the shared app when it can no + // longer authenticate (see sharedAppAuthBroken for the mechanism). + if (reusing && axios && (await sharedAppAuthBroken(cacheKey, entry, axios))) { + st.registry.delete(cacheKey); + st.resolved.delete(cacheKey); + if (st.primaryKey === cacheKey) { + st.primaryKey = undefined; + st.axiosSnapshot = undefined; + } + await entry.bundle.app.close().catch(() => undefined); + return acquireApp(cacheKey, boot, restoreAxios, axios); + } // Reusing a secondary shared app (e.g. the EE-edition app while CLOUD is the // worker primary): point the axios singleton at it — booting did this, reuse // must too. The runner resets back to the primary after the file. if (axios) { axios.defaults.baseURL = entry.bundle.appUrl + '/api'; } - return entry.proxied; + return { ...entry.proxied }; +} + +/** + * Whether the shared app persistently rejects its own canonical session over + * HTTP. Process-global singletons leak across app instances — passport + * strategies, for example, self-register on the process-global passport at + * construction (last boot wins) and capture their own app's services; after a + * private app closes, HTTP auth on the surviving shared app can 401 every + * request even though the session store itself is intact (verified in CI: + * a middleware replay resolves the session while a protected HTTP request + * 401s). The caller reboots the shared app instead of letting every remaining + * file in the worker fail. + */ +async function sharedAppAuthBroken( + cacheKey: string, + entry: ISharedEntry, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + axios: any +): Promise { + const probeStatus = await axios + .get(`${entry.bundle.appUrl}/api/space`, { + headers: { Cookie: entry.bundle.cookie }, + validateStatus: () => true, + }) + .then((res: { status: number }) => res.status) + .catch(() => undefined); + if (probeStatus !== 401) return false; + process.stderr.write( + `[e2e-shared] auth probe on the shared app "${cacheKey}" returned 401; rebooting it\n` + ); + return true; } /* --------------------------- axios singleton hygiene --------------------------- */ diff --git a/apps/nestjs-backend/test/utils/init-app.ts b/apps/nestjs-backend/test/utils/init-app.ts index 0ef5cabbf7..bc28efe84c 100644 --- a/apps/nestjs-backend/test/utils/init-app.ts +++ b/apps/nestjs-backend/test/utils/init-app.ts @@ -158,16 +158,26 @@ async function bootApp() { axios.defaults.baseURL = url + '/api'; - const cookie = ( - await getCookie(globalThis.testConfig.email, globalThis.testConfig.password) - ).cookie.join(';'); + const sessionHandleService = app.get(SessionHandleService); + const createSession = async () => { + const cookie = ( + await getCookie(globalThis.testConfig.email, globalThis.testConfig.password) + ).cookie.join(';'); + const sessionID = await sessionHandleService.getSessionIdFromRequest({ + headers: { cookie }, + url: `${url}/socket`, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + return { cookie, sessionID }; + }; + const session = await createSession(); const cookieInterceptorId = axios.interceptors.request.use((config) => { // Never attach the shared session to signin/signup: passport regenerates the // session attached to a login request, which would destroy this cookie's sid // and break every later spec file sharing the app. if (!/\/auth\/(?:signin|signup)\b/.test(config.url ?? '')) { - config.headers.Cookie = cookie; + config.headers.Cookie = session.cookie; } return config; }); @@ -181,18 +191,19 @@ async function bootApp() { console.log('> Test System Time Zone:', timeZone); console.log('> Test Current System Time:', now.toString()); - const sessionHandleService = app.get(SessionHandleService); const bundle = { app, appUrl: url, - cookie, - sessionID: await sessionHandleService.getSessionIdFromRequest({ - headers: { cookie }, - url: `${url}/socket`, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any), + ...session, + }; + const refreshSession = async () => { + const userId = await sessionHandleService.getUserId(session.sessionID); + if (userId !== globalThis.testConfig.userId) { + Object.assign(session, await createSession()); + } + return session; }; - return { bundle, cookieInterceptorId }; + return { bundle, cookieInterceptorId, refreshSession }; } /** diff --git a/apps/nestjs-backend/test/view-manual-sort-realtime.e2e-spec.ts b/apps/nestjs-backend/test/view-manual-sort-realtime.e2e-spec.ts index f45410ce15..bd6e6058c3 100644 --- a/apps/nestjs-backend/test/view-manual-sort-realtime.e2e-spec.ts +++ b/apps/nestjs-backend/test/view-manual-sort-realtime.e2e-spec.ts @@ -22,11 +22,9 @@ const waitForQueryReady = (query: Query, timeout = 5000): Promise }); }; -// The manual-sort endpoint rewrites __row_ with raw SQL, so no record -// op exists to wake subscriptions and no record write bumps the table's -// lastModifiedTime (the socket doc-ids cache key). Both must happen via -// publishRowOrderChange, otherwise open pages keep the old order and a -// refresh serves the stale cached order over the socket. +// Manual sort materializes __row_ in one bulk v2 record write. The +// ViewManualSortApplied projection must invalidate collection queries after +// commit, while the native record repository rotates table lastModifiedTime. describe('OpenAPI ViewController manual-sort realtime (e2e)', () => { let app: INestApplication; let cookie: string; diff --git a/apps/nestjs-backend/test/view.e2e-spec.ts b/apps/nestjs-backend/test/view.e2e-spec.ts index 4eaa5da764..75b63f5c55 100644 --- a/apps/nestjs-backend/test/view.e2e-spec.ts +++ b/apps/nestjs-backend/test/view.e2e-spec.ts @@ -7,6 +7,7 @@ import type { IFieldVo, IFormColumn, IFormColumnMeta, + ILinkFieldOptions, IPluginViewOptions, IViewRo, } from '@teable/core'; @@ -16,15 +17,26 @@ import { FieldKeyType, FieldType, generatePluginInstallId, + generateRecordId, generateViewId, Relationship, RowHeightLevel, SortFunc, + StatisticsFunc, ViewType, } from '@teable/core'; import { PrismaService, type Prisma } from '@teable/db-main-prisma'; -import type { ICreateTableRo, ITableFullVo } from '@teable/openapi'; +import type { ICreateTableRo, IRefreshShareViewVo, ITableFullVo } from '@teable/openapi'; import { + axios, + createShortLink, + createPlugin, + deletePlugin, + disableShareView, + updateViewFilter, + updateViewGroup, + updateViewOptions, + updateViewSort, updateViewDescription, updateViewName, getViewFilterLinkRecords, @@ -33,21 +45,47 @@ import { updateViewColumnMeta, updateRecord, getRecords, + getShortLink, updateViewLocked, + updateViewOrder, + updateRecordOrders, duplicateView, installViewPlugin, + manualSortView, getViewInstallPlugin, updateViewPluginStorage, deleteView, + createView as createViewApi, + getView as getViewApi, + getViewList as getViewListApi, + getShareView, + LastVisitResourceType, + PinType, + PluginPosition, + publishPlugin, + refreshViewShareId, + ShortLinkType, + submitPlugin, } from '@teable/openapi'; import { sample } from 'lodash'; -import { X_TEABLE_V2_HEADER } from '../src/features/canary/interceptors/v2-indicator.interceptor'; +import { vi } from 'vitest'; +import { EventEmitterService } from '../src/event-emitter/event-emitter.service'; +import { Events } from '../src/event-emitter/events'; +import { + X_TEABLE_V2_FEATURE_HEADER, + X_TEABLE_V2_HEADER, + X_TEABLE_V2_REASON_HEADER, +} from '../src/features/canary/interceptors/v2-indicator.interceptor'; +import { ViewOpenApiService } from '../src/features/view/open-api/view-open-api.service'; import { ViewService } from '../src/features/view/view.service'; import { x_20 } from './data-helpers/20x'; import { VIEW_DEFAULT_SHARE_META } from './data-helpers/caces/view-default-share-meta'; +import { getError } from './utils/get-error'; import { createField, + createRecords, getFields, + getField, initApp, createView, permanentDeleteTable, @@ -64,7 +102,18 @@ const defaultViews = [ type: ViewType.Grid, }, ]; -const isForceV2 = process.env.FORCE_V2_ALL === 'true'; + +const expectNoLegacyViewEvent = (eventSpy: { + mock: { calls: ReadonlyArray> }; +}) => { + const emittedEvents = eventSpy.mock.calls.map(([event]) => event); + expect(emittedEvents).not.toContain(Events.TABLE_VIEW_CREATE); + expect(emittedEvents).not.toContain(Events.TABLE_VIEW_UPDATE); + expect(emittedEvents).not.toContain(Events.TABLE_VIEW_DELETE); + expect(emittedEvents).not.toContain(Events.OPERATION_VIEW_CREATE); + expect(emittedEvents).not.toContain(Events.OPERATION_VIEW_UPDATE); + expect(emittedEvents).not.toContain(Events.OPERATION_VIEW_DELETE); +}; describe('OpenAPI ViewController (e2e)', () => { let app: INestApplication; @@ -72,11 +121,15 @@ describe('OpenAPI ViewController (e2e)', () => { const baseId = globalThis.testConfig.baseId; let prismaService: PrismaService; let viewService: ViewService; + let viewOpenApiService: ViewOpenApiService; + let eventEmitterService: EventEmitterService; beforeAll(async () => { const appCtx = await initApp(); app = appCtx.app; prismaService = app.get(PrismaService); viewService = app.get(ViewService); + viewOpenApiService = app.get(ViewOpenApiService); + eventEmitterService = app.get(EventEmitterService); }); afterAll(async () => { @@ -109,385 +162,3990 @@ describe('OpenAPI ViewController (e2e)', () => { } }); - it('/api/table/{tableId}/view (POST)', async () => { - const viewRo: IViewRo = { - name: 'New view', - description: 'the new view', - type: ViewType.Grid, - }; + describe('Delete View v2 canary (T6420)', () => { + let previousForceV2All: string | undefined; - const createdView = await createView(table.id, viewRo); + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + }); - const { dbTableName } = await prismaService.tableMeta.findUniqueOrThrow({ - where: { id: table.id }, - select: { dbTableName: true }, + afterEach(() => { + vi.restoreAllMocks(); + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } }); - const rowOrderColumn = await viewService.existIndex( - dbTableName, - createdView.id, - prismaService.txClient() + + it.each([ + [ViewType.Grid, undefined], + [ViewType.Kanban, undefined], + [ViewType.Gallery, undefined], + [ViewType.Calendar, undefined], + [ViewType.Form, undefined], + [ + ViewType.Plugin, + { + pluginId: 'plgsheetform', + pluginInstallId: 'ignored-by-create', + pluginLogo: 'ignored-by-create', + }, + ], + ] as const)( + 'deletes a %s View through v2 without calling the legacy service', + async (type, options) => { + const created = await createViewApi(table.id, { + name: `Delete ${type}`, + type, + ...(options ? { options } : {}), + }); + const legacyDeleteSpy = vi + .spyOn(viewOpenApiService, 'deleteView') + .mockRejectedValue(new Error('legacy ViewOpenApiService must not be used')); + const operationSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + + const response = await deleteView(table.id, created.data.id); + + expect(response.status).toBe(200); + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('deleteView'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect((await getViews(table.id)).some((view) => view.id === created.data.id)).toBe(false); + expect(legacyDeleteSpy).not.toHaveBeenCalled(); + expectNoLegacyViewEvent(operationSpy); + } ); - expect(rowOrderColumn).toBe(`__row_${createdView.id}`); - const result = await getViews(table.id); - expect(result).toMatchObject([ - ...defaultViews, - { - name: 'New view', - description: 'the new view', + it('cleans View last-visit and pin resources through v2 Kysely projections', async () => { + const created = await createViewApi(table.id, { + name: 'Delete resource cleanup', type: ViewType.Grid, - }, - ]); - }); + }); + const viewId = created.data.id; + await prismaService.userLastVisit.create({ + data: { + userId: globalThis.testConfig.userId, + resourceType: LastVisitResourceType.View, + resourceId: viewId, + parentResourceId: table.id, + }, + }); + await prismaService.pinResource.create({ + data: { + type: PinType.View, + resourceId: viewId, + createdBy: globalThis.testConfig.userId, + order: 1, + }, + }); + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); - it('/api/table/{tableId}/view (POST) with gallery view', async () => { - const viewRo: IViewRo = { - name: 'New gallery view', - description: 'the new gallery view', - type: ViewType.Gallery, - }; + const response = await deleteView(table.id, viewId); - const fieldVo = await createField(table.id, { - name: 'Attachment', - type: FieldType.Attachment, + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('deleteView'); + await vi.waitFor(async () => { + const [lastVisitCount, pinCount] = await Promise.all([ + prismaService.userLastVisit.count({ + where: { + resourceId: viewId, + resourceType: LastVisitResourceType.View, + }, + }), + prismaService.pinResource.count({ + where: { + resourceId: viewId, + type: PinType.View, + }, + }), + ]); + expect({ lastVisitCount, pinCount }).toEqual({ lastVisitCount: 0, pinCount: 0 }); + }); + expectNoLegacyViewEvent(eventSpy); }); - await createView(table.id, viewRo); - - const result = await getViews(table.id); - expect(result).toMatchObject([ - ...defaultViews, - { - name: 'New gallery view', - description: 'the new gallery view', - type: ViewType.Gallery, - options: { - coverFieldId: fieldVo.id, - }, - }, - ]); - }); - it('should update view simple properties', async () => { - const viewRo: IViewRo = { - name: 'New view', - description: 'the new view', - type: ViewType.Grid, - }; + it('rejects a View owned by another Table without deleting either aggregate child', async () => { + const anotherTable = await createTable(baseId, { name: 'delete_view_other_table' }); + try { + const sourceView = await createView(table.id, { + name: 'Keep Source Valid', + type: ViewType.Grid, + }); + const anotherView = await createView(anotherTable.id, { + name: 'Other Table View', + type: ViewType.Grid, + }); + const legacyDeleteSpy = vi.spyOn(viewOpenApiService, 'deleteView'); - const view = await createView(table.id, viewRo); + const error = await getError(() => deleteView(table.id, anotherView.id)); - await updateViewName(table.id, view.id, { name: 'New view 2' }); - await updateViewDescription(table.id, view.id, { description: 'description2' }); - await updateViewLocked(table.id, view.id, { isLocked: true }); - const viewNew = await getView(table.id, view.id); + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'view.not_found' }); + expect((await getViews(anotherTable.id)).some((view) => view.id === anotherView.id)).toBe( + true + ); + expect((await getViews(table.id)).some((view) => view.id === sourceView.id)).toBe(true); + expect(legacyDeleteSpy).not.toHaveBeenCalled(); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } + }); - expect(viewNew.name).toEqual('New view 2'); - expect(viewNew.description).toEqual('description2'); - expect(viewNew.isLocked).toBeTruthy(); - }); + it('rejects the last View with the aggregate invariant and leaves it active', async () => { + const [lastView] = await getViews(table.id); + const legacyDeleteSpy = vi.spyOn(viewOpenApiService, 'deleteView'); - it('should create view with field order', async () => { - // get fields - const fields = await getFields(table.id); - const testFieldId = fields?.[0].id; - const assertOrder = 10; - const columnMeta = fields.reduce>( - (pre, cur, index) => { - pre[cur.id] = {} as IColumn; - pre[cur.id].order = index === 0 ? assertOrder : index; - return pre; - }, - {} as Record - ); + const error = await getError(() => deleteView(table.id, lastView.id)); - const viewResponse = await createView(table.id, { - name: 'view', - columnMeta, - type: ViewType.Grid, + expect(error?.status).toBe(400); + expect(error?.data).toMatchObject({ + domainCode: 'view.cannot_delete_last', + }); + expect((await getViews(table.id)).map((view) => view.id)).toEqual([lastView.id]); + expect(legacyDeleteSpy).not.toHaveBeenCalled(); }); - const { columnMeta: columnMetaResponse } = viewResponse; - const order = columnMetaResponse?.[testFieldId]?.order; - expect(order).toEqual(assertOrder); - expect(fields.length).toEqual(Object.keys(columnMetaResponse).length); - }); + it('clears an incoming symmetric Link filterByViewId in the same transaction', async () => { + const foreignTable = await createTable(baseId, { name: 'delete_view_link_cleanup' }); + try { + const targetView = await createView(table.id, { + name: 'Link Filter View', + type: ViewType.Grid, + }); + const linkField = await createField(foreignTable.id, { + name: 'Filtered Link', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: table.id, + filterByViewId: targetView.id, + }, + }); + expect((linkField.options as ILinkFieldOptions).filterByViewId).toBe(targetView.id); - it('should set all eligible fields visible when creating form view', async () => { - const formView = await createView(table.id, { - name: 'Form view', - type: ViewType.Form, + await deleteView(table.id, targetView.id); + + const currentLinkField = await getField(foreignTable.id, linkField.id); + expect((currentLinkField.options as ILinkFieldOptions).filterByViewId).toBeNull(); + } finally { + await permanentDeleteTable(baseId, foreignTable.id); + } }); + }); - const views = await getViews(table.id); - const createdForm = views.find(({ id }) => id === formView.id)!; - const formColumnMeta = createdForm.columnMeta as unknown as Record; + describe('Rename View v2 canary (T6420)', () => { + const windowIdHeader = 'x-window-id'; + const windowId = 'rename-view-v2-window'; + let previousForceV2All: string | undefined; + let previousWindowId = axios.defaults.headers.common[windowIdHeader]; - const eligibleFieldIds = table.fields - .filter((f) => !f.isComputed && !f.isLookup && f.type !== FieldType.Button) - .map((f) => f.id); + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + previousWindowId = axios.defaults.headers.common[windowIdHeader]; + axios.defaults.headers.common[windowIdHeader] = windowId; + }); - eligibleFieldIds.forEach((fieldId) => { - expect(formColumnMeta[fieldId]?.visible ?? false).toBe(true); + afterEach(() => { + vi.restoreAllMocks(); + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + if (previousWindowId == null) { + delete axios.defaults.headers.common[windowIdHeader]; + } else { + axios.defaults.headers.common[windowIdHeader] = previousWindowId; + } }); - }); - it('should batch update view when create field', async () => { - const initialColumnMeta = await viewService.generateViewOrderColumnMeta(table.id); - const createData: Prisma.ViewCreateManyInput[] = []; - const num = 100; - for (let i = 0; i < num; i++) { - const data: Prisma.ViewCreateManyInput = { - id: generateViewId(), - tableId: table.id, - name: `New view ${i}`, - type: ViewType.Grid, - version: 1, - order: i + 1, - createdBy: globalThis.testConfig.userId, - columnMeta: JSON.stringify(initialColumnMeta ?? {}), - }; + it.each([ + [ViewType.Grid, undefined], + [ViewType.Kanban, undefined], + [ViewType.Gallery, undefined], + [ViewType.Calendar, undefined], + [ViewType.Form, undefined], + [ + ViewType.Plugin, + { + pluginId: 'plgsheetform', + pluginInstallId: 'ignored-by-create', + pluginLogo: 'ignored-by-create', + }, + ], + ] as const)( + 'renames a %s View through the Table aggregate without calling the legacy write path', + async (type, options) => { + const created = await createView(table.id, { + name: `Rename ${type}`, + type, + ...(options ? { options } : {}), + }); + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: created.id }, + select: { version: true, lastModifiedBy: true, lastModifiedTime: true }, + }); + const legacyPropertySpy = vi + .spyOn(viewOpenApiService, 'setViewProperty') + .mockRejectedValue(new Error('legacy ViewOpenApiService must not be used')); + const legacyOpsSpy = vi + .spyOn(viewService, 'updateViewByOps') + .mockRejectedValue(new Error('legacy ViewService must not be used')); + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + const nextName = `Renamed ${type}`; - createData.push(data); - } - const result = await prismaService.txClient().view.createMany({ data: createData }); - expect(result.count).toEqual(num); + const response = await updateViewName(table.id, created.id, { name: nextName }); - await createField(table.id, { type: FieldType.SingleLineText }); - const fields = await getFields(table.id); - const assertFieldIds = fields.map((field) => field.id).sort(); - const randomViewId = sample(createData.map((data) => data.id)); - const view = await getView(table.id, randomViewId!); - const columnMetaFieldIds = Object.keys(view.columnMeta).sort(); - expect(columnMetaFieldIds).toEqual(assertFieldIds); - }); + expect(response.status).toBe(200); + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewName'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect((await getView(table.id, created.id)).name).toBe(nextName); + const rowAfter = await prismaService.view.findUniqueOrThrow({ + where: { id: created.id }, + select: { version: true, lastModifiedBy: true, lastModifiedTime: true }, + }); + expect(rowAfter.version).toBe(rowBefore.version + 1); + expect(rowAfter.lastModifiedBy).toBe(globalThis.testConfig.userId); + expect(rowAfter.lastModifiedTime?.getTime()).toBeGreaterThanOrEqual( + rowBefore.lastModifiedTime?.getTime() ?? 0 + ); + expect(legacyPropertySpy).not.toHaveBeenCalled(); + expect(legacyOpsSpy).not.toHaveBeenCalled(); + expectNoLegacyViewEvent(eventSpy); + } + ); - it('should ignore stale column meta for deleted fields when reading views', async () => { - const staleField = await createField(table.id, { - name: 'deleted column meta field', - type: FieldType.SingleLineText, + it('rejects a View owned by another Table and leaves both aggregates unchanged', async () => { + const anotherTable = await createTable(baseId, { name: 'rename_view_other_table' }); + try { + const sourceView = (await getViews(table.id))[0]!; + const anotherView = (await getViews(anotherTable.id))[0]!; + const legacyPropertySpy = vi.spyOn(viewOpenApiService, 'setViewProperty'); + + const error = await getError(() => + updateViewName(table.id, anotherView.id, { name: 'Cross aggregate rename' }) + ); + + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'view.not_found' }); + expect((await getView(table.id, sourceView.id)).name).toBe(sourceView.name); + expect((await getView(anotherTable.id, anotherView.id)).name).toBe(anotherView.name); + expect(legacyPropertySpy).not.toHaveBeenCalled(); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } }); - const view = await createView(table.id, { - name: 'view with stale column meta', - type: ViewType.Grid, + + it('rejects a duplicate active name through the Table uniqueness invariant', async () => { + const firstView = (await getViews(table.id))[0]!; + const secondView = await createView(table.id, { + name: 'Existing view name', + type: ViewType.Grid, + }); + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: firstView.id }, + select: { name: true, version: true }, + }); + + const error = await getError(() => + updateViewName(table.id, firstView.id, { name: secondView.name }) + ); + + expect(error?.status).toBe(400); + expect(error?.data).toMatchObject({ domainCode: 'conflict' }); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: firstView.id }, + select: { name: true, version: true }, + }) + ).resolves.toEqual(rowBefore); }); - await deleteField(table.id, staleField.id); - const activeFields = await getFields(table.id); - const activeColumnMeta = activeFields.reduce>((acc, field, index) => { - acc[field.id] = { order: index }; - return acc; - }, {}); + it('preserves the accepted empty-name and unchanged-name branches', async () => { + const view = (await getViews(table.id))[0]!; - await prismaService.txClient().view.update({ - where: { id: view.id }, - data: { - columnMeta: JSON.stringify({ - ...activeColumnMeta, - [staleField.id]: { order: activeFields.length + 1, visible: true }, - }), - }, + const emptyResponse = await updateViewName(table.id, view.id, { name: '' }); + const unchangedResponse = await updateViewName(table.id, view.id, { name: '' }); + + expect(emptyResponse.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewName'); + expect(unchangedResponse.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewName'); + expect((await getView(table.id, view.id)).name).toBe(''); }); - const activeFieldIds = activeFields.map((field) => field.id).sort(); - const viewAfter = await getView(table.id, view.id); - const viewsAfter = await getViews(table.id); - const viewFromList = viewsAfter.find(({ id }) => id === view.id); - const [viewSnapshot] = await viewService.getSnapshotBulk(table.id, [view.id]); + it('rejects an oversized name through the v2 View operation guard without persistence', async () => { + const view = (await getViews(table.id))[0]!; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { name: true, version: true }, + }); - expect(viewAfter.columnMeta?.[staleField.id]).toBeUndefined(); - expect(Object.keys(viewAfter.columnMeta ?? {}).sort()).toEqual(activeFieldIds); - expect(viewFromList?.columnMeta?.[staleField.id]).toBeUndefined(); - expect(Object.keys(viewFromList?.columnMeta ?? {}).sort()).toEqual(activeFieldIds); - expect(viewSnapshot.data.columnMeta?.[staleField.id]).toBeUndefined(); - expect(Object.keys(viewSnapshot.data.columnMeta ?? {}).sort()).toEqual(activeFieldIds); - }); + const error = await getError(() => + updateViewName(table.id, view.id, { name: 'x'.repeat(101) }) + ); - it('fields in new view should sort by created time and primary field is always first', async () => { - const viewRo: IViewRo = { - name: 'New view', - description: 'the new view', - type: ViewType.Grid, - }; + expect(error?.status).toBe(400); + expect(error?.data).toMatchObject({ + domainCode: 'validation.limit.name_max_length', + }); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { name: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + }); - const oldFields: IFieldVo[] = []; - oldFields.push(await createField(table.id, { type: FieldType.SingleLineText })); - oldFields.push(await createField(table.id, { type: FieldType.SingleLineText })); - oldFields.push(await createField(table.id, { type: FieldType.SingleLineText })); + it('allows only one concurrent rename from the same Table aggregate version', async () => { + const view = (await getViews(table.id))[0]!; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { name: true, version: true }, + }); + const legacyPropertySpy = vi.spyOn(viewOpenApiService, 'setViewProperty'); + const results = await Promise.allSettled([ + updateViewName(table.id, view.id, { name: 'Concurrent writer A' }), + updateViewName(table.id, view.id, { name: 'Concurrent writer B' }), + ]); - const newView = await createView(table.id, viewRo); - const newFields = await getFields(table.id, newView.id); + const fulfilled = results.filter( + (result): result is PromiseFulfilledResult>> => + result.status === 'fulfilled' + ); + const rejected = results.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ); + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(rejected[0]?.reason).toMatchObject({ + status: 400, + data: { domainCode: 'view.version_conflict' }, + }); - expect(newFields.slice(3)).toMatchObject(oldFields); + const persisted = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { name: true, version: true }, + }); + expect(['Concurrent writer A', 'Concurrent writer B']).toContain(persisted.name); + expect(persisted.version).toBe(rowBefore.version + 1); + expect(legacyPropertySpy).not.toHaveBeenCalled(); + }); }); - describe('/api/table/{tableId}/view/:viewId/filter-link-records (GET)', () => { - let table: ITableFullVo; - let linkTable1: ITableFullVo; - let linkTable2: ITableFullVo; + describe('Update View Description v2 canary (T6420)', () => { + const windowIdHeader = 'x-window-id'; + const windowId = 'update-view-description-v2-window'; + let previousForceV2All: string | undefined; + let previousWindowId = axios.defaults.headers.common[windowIdHeader]; - const linkTable1FieldRo: IFieldRo[] = [ - { - name: 'single_line_text_field', - type: FieldType.SingleLineText, - }, - ]; + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + previousWindowId = axios.defaults.headers.common[windowIdHeader]; + axios.defaults.headers.common[windowIdHeader] = windowId; + }); - const linkTable2FieldRo: IFieldRo[] = [ - { - name: 'single_line_text_field', - type: FieldType.SingleLineText, - }, - ]; + afterEach(() => { + vi.restoreAllMocks(); + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + if (previousWindowId == null) { + delete axios.defaults.headers.common[windowIdHeader]; + } else { + axios.defaults.headers.common[windowIdHeader] = previousWindowId; + } + }); - const linkTable1RecordRo: ICreateTableRo['records'] = [ - { - fields: { - single_line_text_field: 'link_table1_record1', - }, - }, - { - fields: { - single_line_text_field: 'link_table1_record2', - }, - }, - { - fields: { - single_line_text_field: 'link_table1_record3', - }, - }, - ]; - const linkTable2RecordRo: ICreateTableRo['records'] = [ - { - fields: { - single_line_text_field: 'link_table2_record1', - }, - }, - { - fields: { - single_line_text_field: 'link_table2_record2', - }, - }, - { - fields: { - single_line_text_field: 'link_table2_record3', + it.each([ + [ViewType.Grid, undefined], + [ViewType.Kanban, undefined], + [ViewType.Gallery, undefined], + [ViewType.Calendar, undefined], + [ViewType.Form, undefined], + [ + ViewType.Plugin, + { + pluginId: 'plgsheetform', + pluginInstallId: 'ignored-by-create', + pluginLogo: 'ignored-by-create', }, - }, - ]; + ], + ] as const)( + 'updates a %s View description through the Table aggregate without calling the legacy write path', + async (type, options) => { + const previousDescription = `Before ${type}`; + const created = await createView(table.id, { + name: `Describe ${type}`, + description: previousDescription, + type, + ...(options ? { options } : {}), + }); + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: created.id }, + select: { + description: true, + version: true, + lastModifiedBy: true, + lastModifiedTime: true, + }, + }); + const legacyPropertySpy = vi + .spyOn(viewOpenApiService, 'setViewProperty') + .mockRejectedValue(new Error('legacy ViewOpenApiService must not be used')); + const legacyOpsSpy = vi + .spyOn(viewService, 'updateViewByOps') + .mockRejectedValue(new Error('legacy ViewService must not be used')); + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + const nextDescription = `After ${type}`; - beforeAll(async () => { - const fullTable = await createTable(baseId, { - name: 'filter_link_records', - fields: [ - { - name: 'link_field1', - type: FieldType.SingleLineText, + const response = await updateViewDescription(table.id, created.id, { + description: nextDescription, + }); + + expect(response.status).toBe(200); + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewDescription'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect((await getView(table.id, created.id)).description).toBe(nextDescription); + const rowAfter = await prismaService.view.findUniqueOrThrow({ + where: { id: created.id }, + select: { + description: true, + version: true, + lastModifiedBy: true, + lastModifiedTime: true, }, - ], - records: [], + }); + expect(rowAfter.description).toBe(nextDescription); + expect(rowAfter.version).toBe(rowBefore.version + 1); + expect(rowAfter.lastModifiedBy).toBe(globalThis.testConfig.userId); + expect(rowAfter.lastModifiedTime?.getTime()).toBeGreaterThanOrEqual( + rowBefore.lastModifiedTime?.getTime() ?? 0 + ); + expect(legacyPropertySpy).not.toHaveBeenCalled(); + expect(legacyOpsSpy).not.toHaveBeenCalled(); + expectNoLegacyViewEvent(eventSpy); + } + ); + + it('rejects a View owned by another Table and leaves both aggregates unchanged', async () => { + const anotherTable = await createTable(baseId, { + name: 'update_view_description_other_table', }); + try { + const sourceView = (await getViews(table.id))[0]!; + const anotherView = (await getViews(anotherTable.id))[0]!; + await updateViewDescription(table.id, sourceView.id, { + description: 'Source description', + }); + await updateViewDescription(anotherTable.id, anotherView.id, { + description: 'Other description', + }); + const legacyPropertySpy = vi.spyOn(viewOpenApiService, 'setViewProperty'); - linkTable1 = await createTable(baseId, { - name: 'link_table1', - fields: [ - ...linkTable1FieldRo, - { - type: FieldType.Link, - options: { - foreignTableId: fullTable.id, - relationship: Relationship.OneMany, - }, - }, - ], - records: linkTable1RecordRo, + const error = await getError(() => + updateViewDescription(table.id, anotherView.id, { + description: 'Cross aggregate description', + }) + ); + + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'view.not_found' }); + expect((await getView(table.id, sourceView.id)).description).toBe('Source description'); + expect((await getView(anotherTable.id, anotherView.id)).description).toBe( + 'Other description' + ); + expect(legacyPropertySpy).not.toHaveBeenCalled(); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } + }); + + it('preserves empty and unchanged descriptions while omitting empty values from legacy reads', async () => { + const view = (await getViews(table.id))[0]!; + await updateViewDescription(table.id, view.id, { + description: 'Before empty', }); - linkTable2 = await createTable(baseId, { - name: 'link_table2', - fields: [ - ...linkTable2FieldRo, - { - type: FieldType.Link, - options: { - foreignTableId: fullTable.id, - relationship: Relationship.OneMany, - }, + const emptyResponse = await updateViewDescription(table.id, view.id, { + description: '', + }); + const unchangedResponse = await updateViewDescription(table.id, view.id, { + description: '', + }); + + expect(emptyResponse.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewDescription'); + expect(unchangedResponse.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewDescription'); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { description: true }, + }) + ).resolves.toEqual({ description: '' }); + expect((await getView(table.id, view.id)).description).toBeUndefined(); + }); + + it('updates a previously missing description without emitting v1 View events', async () => { + const view = (await getViews(table.id))[0]!; + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + + await updateViewDescription(table.id, view.id, { description: 'First description' }); + + expectNoLegacyViewEvent(eventSpy); + expect((await getView(table.id, view.id)).description).toBe('First description'); + }); + + it('rejects an oversized description through the v2 View operation guard without persistence', async () => { + const view = (await getViews(table.id))[0]!; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { description: true, version: true }, + }); + + const error = await getError(() => + updateViewDescription(table.id, view.id, { + description: 'x'.repeat(2_001), + }) + ); + + expect(error?.status).toBe(400); + expect(error?.data).toMatchObject({ + domainCode: 'validation.limit.description_max_length', + }); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { description: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + }); + }); + + describe('Update View Locked v2 canary (T6420)', () => { + const windowIdHeader = 'x-window-id'; + const windowId = 'update-view-locked-v2-window'; + let previousForceV2All: string | undefined; + let previousWindowId = axios.defaults.headers.common[windowIdHeader]; + + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + previousWindowId = axios.defaults.headers.common[windowIdHeader]; + axios.defaults.headers.common[windowIdHeader] = windowId; + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + if (previousWindowId == null) { + delete axios.defaults.headers.common[windowIdHeader]; + } else { + axios.defaults.headers.common[windowIdHeader] = previousWindowId; + } + }); + + it.each([ + [ViewType.Grid, undefined], + [ViewType.Kanban, undefined], + [ViewType.Gallery, undefined], + [ViewType.Calendar, undefined], + [ViewType.Form, undefined], + [ + ViewType.Plugin, + { + pluginId: 'plgsheetform', + pluginInstallId: 'ignored-by-create', + pluginLogo: 'ignored-by-create', + }, + ], + ] as const)( + 'updates a %s View locked state through the Table aggregate without calling the legacy write path', + async (type, options) => { + const created = await createView(table.id, { + name: `Lock ${type}`, + type, + isLocked: false, + ...(options ? { options } : {}), + }); + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: created.id }, + select: { + isLocked: true, + version: true, + lastModifiedBy: true, + lastModifiedTime: true, }, - ], - records: linkTable2RecordRo, + }); + const legacyPropertySpy = vi + .spyOn(viewOpenApiService, 'setViewProperty') + .mockRejectedValue(new Error('legacy ViewOpenApiService must not be used')); + const legacyOpsSpy = vi + .spyOn(viewService, 'updateViewByOps') + .mockRejectedValue(new Error('legacy ViewService must not be used')); + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + + const response = await updateViewLocked(table.id, created.id, { isLocked: true }); + + expect(response.status).toBe(200); + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewLocked'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect((await getView(table.id, created.id)).isLocked).toBe(true); + const rowAfter = await prismaService.view.findUniqueOrThrow({ + where: { id: created.id }, + select: { + isLocked: true, + version: true, + lastModifiedBy: true, + lastModifiedTime: true, + }, + }); + expect(rowAfter.isLocked).toBe(true); + expect(rowAfter.version).toBe(rowBefore.version + 1); + expect(rowAfter.lastModifiedBy).toBe(globalThis.testConfig.userId); + expect(rowAfter.lastModifiedTime?.getTime()).toBeGreaterThanOrEqual( + rowBefore.lastModifiedTime?.getTime() ?? 0 + ); + expect(legacyPropertySpy).not.toHaveBeenCalled(); + expect(legacyOpsSpy).not.toHaveBeenCalled(); + expectNoLegacyViewEvent(eventSpy); + } + ); + + it('rejects a View owned by another Table and leaves both aggregates unchanged', async () => { + const anotherTable = await createTable(baseId, { + name: 'update_view_locked_other_table', + }); + try { + const sourceView = (await getViews(table.id))[0]!; + const anotherView = (await getViews(anotherTable.id))[0]!; + await updateViewLocked(table.id, sourceView.id, { isLocked: true }); + await updateViewLocked(anotherTable.id, anotherView.id, { isLocked: false }); + const legacyPropertySpy = vi.spyOn(viewOpenApiService, 'setViewProperty'); + + const error = await getError(() => + updateViewLocked(table.id, anotherView.id, { isLocked: true }) + ); + + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'view.not_found' }); + expect((await getView(table.id, sourceView.id)).isLocked).toBe(true); + expect((await getView(anotherTable.id, anotherView.id)).isLocked).toBeUndefined(); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: anotherView.id }, + select: { isLocked: true }, + }) + ).resolves.toEqual({ isLocked: false }); + expect(legacyPropertySpy).not.toHaveBeenCalled(); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } + }); + + it('preserves true, false, omitted, and unchanged states without v1 View events', async () => { + const view = (await getViews(table.id))[0]!; + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + + await updateViewLocked(table.id, view.id, { isLocked: true }); + await updateViewLocked(table.id, view.id, { isLocked: false }); + const omittedResponse = await updateViewLocked(table.id, view.id, {}); + const unchangedResponse = await updateViewLocked(table.id, view.id, {}); + + expect(omittedResponse.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewLocked'); + expect(unchangedResponse.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewLocked'); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { isLocked: true }, + }) + ).resolves.toEqual({ isLocked: null }); + expect((await getView(table.id, view.id)).isLocked).toBeUndefined(); + expectNoLegacyViewEvent(eventSpy); + }); + + it('rejects a non-boolean locked state before persistence without falling back to v1', async () => { + const view = (await getViews(table.id))[0]!; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { isLocked: true, version: true }, + }); + const legacyPropertySpy = vi.spyOn(viewOpenApiService, 'setViewProperty'); + + const error = await getError(() => + axios.put(`/table/${table.id}/view/${view.id}/locked`, { + isLocked: 'true', + }) + ); + + expect(error?.status).toBe(400); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { isLocked: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + expect(legacyPropertySpy).not.toHaveBeenCalled(); + }); + }); + + describe('Update View Order v2 canary (T6420)', () => { + const windowIdHeader = 'x-window-id'; + const windowId = 'update-view-order-v2-window'; + let previousForceV2All: string | undefined; + let previousWindowId = axios.defaults.headers.common[windowIdHeader]; + + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + previousWindowId = axios.defaults.headers.common[windowIdHeader]; + axios.defaults.headers.common[windowIdHeader] = windowId; + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + if (previousWindowId == null) { + delete axios.defaults.headers.common[windowIdHeader]; + } else { + axios.defaults.headers.common[windowIdHeader] = previousWindowId; + } + }); + + const createThreeViews = async () => { + const first = (await getViews(table.id))[0]!; + const second = await createView(table.id, { name: 'Order second', type: ViewType.Grid }); + const third = await createView(table.id, { name: 'Order third', type: ViewType.Grid }); + return { first, second, third }; + }; + + it('routes all before/after and boundary branches through v2 without legacy writes', async () => { + const { first, second, third } = await createThreeViews(); + const legacyOrderSpy = vi + .spyOn(viewOpenApiService, 'updateViewOrder') + .mockRejectedValue(new Error('legacy ViewOpenApiService must not be used')); + const legacyOpsSpy = vi + .spyOn(viewService, 'updateViewByOps') + .mockRejectedValue(new Error('legacy ViewService must not be used')); + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + const thirdBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: third.id }, + select: { order: true, version: true }, + }); + + const beforeMiddle = await updateViewOrder(table.id, third.id, { + anchorId: second.id, + position: 'before', + }); + expect(beforeMiddle.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(beforeMiddle.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewOrder'); + expect(beforeMiddle.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect((await getViews(table.id)).map(({ id }) => id)).toEqual([ + first.id, + third.id, + second.id, + ]); + + await updateViewOrder(table.id, third.id, { + anchorId: first.id, + position: 'before', + }); + expect((await getViews(table.id)).map(({ id }) => id)).toEqual([ + third.id, + first.id, + second.id, + ]); + + await updateViewOrder(table.id, third.id, { + anchorId: first.id, + position: 'after', + }); + expect((await getViews(table.id)).map(({ id }) => id)).toEqual([ + first.id, + third.id, + second.id, + ]); + + await updateViewOrder(table.id, third.id, { + anchorId: second.id, + position: 'after', + }); + expect((await getViews(table.id)).map(({ id }) => id)).toEqual([ + first.id, + second.id, + third.id, + ]); + + const thirdAfter = await prismaService.view.findUniqueOrThrow({ + where: { id: third.id }, + select: { order: true, version: true, lastModifiedBy: true }, + }); + expect(thirdAfter.version).toBe(thirdBefore.version + 4); + expect(thirdAfter.lastModifiedBy).toBe(globalThis.testConfig.userId); + expect(legacyOrderSpy).not.toHaveBeenCalled(); + expect(legacyOpsSpy).not.toHaveBeenCalled(); + expectNoLegacyViewEvent(eventSpy); + }); + + it('keeps legacy adjacent and same-anchor behavior as real versioned updates', async () => { + const { first, second } = await createThreeViews(); + const before = await prismaService.view.findUniqueOrThrow({ + where: { id: first.id }, + select: { order: true, version: true }, + }); + + await updateViewOrder(table.id, first.id, { + anchorId: second.id, + position: 'before', + }); + const adjacent = await prismaService.view.findUniqueOrThrow({ + where: { id: first.id }, + select: { order: true, version: true }, + }); + expect(adjacent.order).not.toBe(before.order); + expect(adjacent.version).toBe(before.version + 1); + + await updateViewOrder(table.id, first.id, { + anchorId: first.id, + position: 'after', + }); + const sameAnchor = await prismaService.view.findUniqueOrThrow({ + where: { id: first.id }, + select: { order: true, version: true }, + }); + expect(sameAnchor.version).toBe(adjacent.version + 1); + expect((await getViews(table.id)).map(({ id }) => id)).toContain(first.id); + }); + + it('rejects source and anchor Views outside the Table aggregate without partial writes', async () => { + const { first } = await createThreeViews(); + const anotherTable = await createTable(baseId, { name: 'view_order_other_table' }); + try { + const foreignView = (await getViews(anotherTable.id))[0]!; + const before = await prismaService.view.findMany({ + where: { tableId: table.id, deletedTime: null }, + select: { id: true, order: true, version: true }, + orderBy: { id: 'asc' }, + }); + const legacyOrderSpy = vi.spyOn(viewOpenApiService, 'updateViewOrder'); + + const sourceError = await getError(() => + updateViewOrder(table.id, foreignView.id, { + anchorId: first.id, + position: 'before', + }) + ); + const anchorError = await getError(() => + updateViewOrder(table.id, first.id, { + anchorId: foreignView.id, + position: 'after', + }) + ); + + expect(sourceError?.status).toBe(404); + expect(sourceError?.data).toMatchObject({ domainCode: 'view.not_found' }); + expect(anchorError?.status).toBe(404); + expect(anchorError?.data).toMatchObject({ domainCode: 'view.anchor_not_found' }); + await expect( + prismaService.view.findMany({ + where: { tableId: table.id, deletedTime: null }, + select: { id: true, order: true, version: true }, + orderBy: { id: 'asc' }, + }) + ).resolves.toEqual(before); + expect(legacyOrderSpy).not.toHaveBeenCalled(); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } + }); + + it('normalizes exhausted gaps inside one Table update flow and versions every affected View', async () => { + const { first, second, third } = await createThreeViews(); + await prismaService.view.update({ + where: { id: first.id }, + data: { order: 0 }, + }); + await prismaService.view.update({ + where: { id: second.id }, + data: { order: 1 - Number.EPSILON }, + }); + await prismaService.view.update({ + where: { id: third.id }, + data: { order: 1 }, + }); + const before = await prismaService.view.findMany({ + where: { id: { in: [first.id, second.id, third.id] } }, + select: { id: true, version: true }, + }); + const versionById = new Map(before.map((row) => [row.id, row.version])); + + await updateViewOrder(table.id, first.id, { + anchorId: third.id, + position: 'before', + }); + + const after = await prismaService.view.findMany({ + where: { id: { in: [first.id, second.id, third.id] } }, + select: { id: true, order: true, version: true }, + orderBy: { order: 'asc' }, + }); + expect(after.map(({ id }) => id)).toEqual([second.id, first.id, third.id]); + expect(after.find(({ id }) => id === first.id)?.version).toBe(versionById.get(first.id)! + 2); + expect(after.find(({ id }) => id === second.id)?.version).toBe( + versionById.get(second.id)! + 1 + ); + expect(after.find(({ id }) => id === third.id)?.version).toBe(versionById.get(third.id)! + 1); + }); + }); + + describe('Update View record order v2 canary (T6420)', () => { + let previousForceV2All: string | undefined; + + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + }); + + it('routes before and after moves through the generic v2 Table contract', async () => { + const view = (await getViews(table.id))[0]!; + const [first, second, third] = table.records; + const legacyOrderSpy = vi + .spyOn(viewOpenApiService, 'updateRecordOrders') + .mockRejectedValue(new Error('legacy ViewOpenApiService must not be used')); + + const beforeResponse = await updateRecordOrders(table.id, view.id, { + anchorId: second!.id, + position: 'before', + recordIds: [third!.id], + }); + + expect(beforeResponse.status).toBe(200); + expect(beforeResponse.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(beforeResponse.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('reorderRecords'); + expect(beforeResponse.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect( + ( + await getRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + viewId: view.id, + }) + ).data.records.map(({ id }) => id) + ).toEqual([first!.id, third!.id, second!.id]); + + const afterResponse = await updateRecordOrders(table.id, view.id, { + anchorId: first!.id, + position: 'after', + recordIds: [third!.id, second!.id], + }); + + expect(afterResponse.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('reorderRecords'); + expect( + ( + await getRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + viewId: view.id, + }) + ).data.records.map(({ id }) => id) + ).toEqual([first!.id, third!.id, second!.id]); + expect(legacyOrderSpy).not.toHaveBeenCalled(); + }); + + it('rejects foreign Views and missing anchors without partial reordering or v1 fallback', async () => { + const view = (await getViews(table.id))[0]!; + const [first, second, third] = table.records; + const anotherTable = await createTable(baseId, { name: 'record_order_other_table' }); + const legacyOrderSpy = vi.spyOn(viewOpenApiService, 'updateRecordOrders'); + + try { + const anotherView = (await getViews(anotherTable.id))[0]!; + const cases = [ + () => + updateRecordOrders(table.id, anotherView.id, { + anchorId: second!.id, + position: 'before', + recordIds: [third!.id], + }), + () => + updateRecordOrders(table.id, view.id, { + anchorId: generateRecordId(), + position: 'after', + recordIds: [third!.id], + }), + ]; + + for (const run of cases) { + const error = await getError(run); + expect(error?.status).toBe(404); + expect( + ( + await getRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + viewId: view.id, + }) + ).data.records.map(({ id }) => id) + ).toEqual([first!.id, second!.id, third!.id]); + } + expect(legacyOrderSpy).not.toHaveBeenCalled(); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } + }); + }); + + describe('List Views v2 canary (T6420)', () => { + let previousForceV2All: string | undefined; + + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + }); + + it('lists the complete subtype matrix in persisted order without using ViewService', async () => { + const [defaultView] = await getViews(table.id); + const createdViews = []; + for (const type of [ + ViewType.Grid, + ViewType.Kanban, + ViewType.Gallery, + ViewType.Calendar, + ViewType.Form, + ViewType.Plugin, + ]) { + createdViews.push( + await createViewApi(table.id, { + name: `List ${type}`, + type, + ...(type === ViewType.Plugin + ? { + options: { + pluginId: 'plgsheetform', + pluginInstallId: 'ignored-by-create', + pluginLogo: 'ignored-by-create', + }, + } + : {}), + }) + ); + } + const legacyReadSpy = vi + .spyOn(viewService, 'getViews') + .mockRejectedValue(new Error('legacy ViewService must not be used')); + + const response = await getViewListApi(table.id); + + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getViews'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(response.data.map((view) => view.id)).toEqual([ + defaultView.id, + ...createdViews.map((view) => view.data.id), + ]); + expect(response.data.map((view) => view.type)).toEqual([ + ViewType.Grid, + ViewType.Grid, + ViewType.Kanban, + ViewType.Gallery, + ViewType.Calendar, + ViewType.Form, + ViewType.Plugin, + ]); + expect(response.data.every((view) => Boolean(view.createdBy && view.createdTime))).toBe(true); + expect(legacyReadSpy).not.toHaveBeenCalled(); + }); + + it('preserves rich properties while omitting false and empty legacy properties', async () => { + const primaryFieldId = table.fields[0].id; + const rich = await createViewApi(table.id, { + name: 'Rich list view', + description: 'list every branch', + type: ViewType.Grid, + options: { rowHeight: RowHeightLevel.Tall }, + filter: { + conjunction: 'and', + filterSet: [{ fieldId: primaryFieldId, operator: 'is', value: 'alpha' }], + }, + sort: { + sortObjs: [{ fieldId: primaryFieldId, order: SortFunc.Desc }], + manualSort: false, + }, + group: [{ fieldId: primaryFieldId, order: SortFunc.Asc }], + isLocked: true, + enableShare: true, + shareId: 'shr-list-views-v2', + shareMeta: { + allowCopy: false, + includeHiddenField: true, + submit: { requireLogin: true }, + }, + }); + const sparse = await createViewApi(table.id, { + name: 'Sparse list view', + description: '', + type: ViewType.Kanban, + isLocked: false, + enableShare: false, + shareId: '', + }); + + const response = await getViewListApi(table.id); + const richResult = response.data.find((view) => view.id === rich.data.id); + const sparseResult = response.data.find((view) => view.id === sparse.data.id); + + expect(richResult).toMatchObject({ + name: 'Rich list view', + description: 'list every branch', + type: ViewType.Grid, + options: { rowHeight: RowHeightLevel.Tall }, + filter: { + conjunction: 'and', + filterSet: [{ fieldId: primaryFieldId, operator: 'is', value: 'alpha' }], + }, + sort: { + sortObjs: [{ fieldId: primaryFieldId, order: SortFunc.Desc }], + manualSort: false, + }, + group: [{ fieldId: primaryFieldId, order: SortFunc.Asc }], + isLocked: true, + enableShare: true, + shareId: 'shr-list-views-v2', + shareMeta: { + allowCopy: false, + includeHiddenField: true, + submit: { requireLogin: true }, + }, + }); + expect(sparseResult?.description).toBeUndefined(); + expect(sparseResult?.isLocked).toBeUndefined(); + expect(sparseResult?.enableShare).toBeUndefined(); + expect(sparseResult?.shareId).toBeUndefined(); + }); + + it('returns updated properties and optional audit metadata', async () => { + const created = await createViewApi(table.id, { + name: 'Updated list view', + type: ViewType.Grid, + }); + await updateViewDescription(table.id, created.data.id, { + description: 'updated through legacy mutation', + }); + + const response = await getViewListApi(table.id); + const updated = response.data.find((view) => view.id === created.data.id); + + expect(updated).toMatchObject({ + description: 'updated through legacy mutation', + }); + expect(updated?.lastModifiedBy).toBeTruthy(); + expect(updated?.lastModifiedTime).toBeTruthy(); + }); + + it('omits soft-deleted View children from the aggregate', async () => { + const created = await createViewApi(table.id, { + name: 'Deleted list view', + type: ViewType.Grid, + }); + await deleteView(table.id, created.data.id); + + const response = await getViewListApi(table.id); + + expect(response.data).not.toContainEqual(expect.objectContaining({ id: created.data.id })); + }); + }); + + describe('Get View v2 canary (T6420)', () => { + let previousForceV2All: string | undefined; + + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + }); + + it('routes the complete subtype matrix through v2 without using the legacy ViewService', async () => { + const createdViews = []; + for (const type of [ + ViewType.Grid, + ViewType.Kanban, + ViewType.Gallery, + ViewType.Calendar, + ViewType.Form, + ViewType.Plugin, + ]) { + createdViews.push( + await createViewApi(table.id, { + name: `Read ${type}`, + type, + ...(type === ViewType.Plugin + ? { + options: { + pluginId: 'plgsheetform', + pluginInstallId: 'ignored-by-create', + pluginLogo: 'ignored-by-create', + }, + } + : {}), + }) + ); + } + const legacyReadSpy = vi + .spyOn(viewService, 'getViewById') + .mockRejectedValue(new Error('legacy ViewService must not be used')); + + for (const created of createdViews) { + const response = await getViewApi(table.id, created.data.id); + + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getView'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(response.data).toMatchObject({ + id: created.data.id, + name: created.data.name, + type: created.data.type, + columnMeta: created.data.columnMeta, + }); + expect(response.data.createdBy).toBeTruthy(); + expect(response.data.createdTime).toBeTruthy(); + } + + expect(legacyReadSpy).not.toHaveBeenCalled(); + }); + + it('returns a v2 domain error when the View does not belong to the Table', async () => { + const anotherTable = await createTable(baseId, { name: 'another_get_view_table' }); + + try { + const [anotherView] = await getViews(anotherTable.id); + const error = await getError(() => getViewApi(table.id, anotherView.id)); + + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'view.not_found' }); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } + }); + + it('creates the response through the v2 query without using the legacy ViewService', async () => { + const legacyReadSpy = vi + .spyOn(viewService, 'getViewById') + .mockRejectedValue(new Error('legacy ViewService must not be used')); + + const response = await createViewApi(table.id, { + name: 'Create response from v2 query', + type: ViewType.Grid, + }); + + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.data).toMatchObject({ + name: 'Create response from v2 query', + type: ViewType.Grid, + }); + expect(legacyReadSpy).not.toHaveBeenCalled(); + }); + + it('returns all persisted optional properties through the direct GET endpoint', async () => { + const primaryFieldId = table.fields[0].id; + const created = await createViewApi(table.id, { + name: 'Rich GET view', + description: 'read every branch', + type: ViewType.Grid, + options: { rowHeight: RowHeightLevel.Tall }, + filter: { + conjunction: 'and', + filterSet: [{ fieldId: primaryFieldId, operator: 'is', value: 'alpha' }], + }, + sort: { + sortObjs: [{ fieldId: primaryFieldId, order: SortFunc.Desc }], + manualSort: false, + }, + group: [{ fieldId: primaryFieldId, order: SortFunc.Asc }], + isLocked: true, + enableShare: true, + shareId: 'shr-get-view-v2', + shareMeta: { + allowCopy: false, + includeHiddenField: true, + submit: { requireLogin: true }, + }, + }); + + const response = await getViewApi(table.id, created.data.id); + + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getView'); + expect(response.data).toMatchObject({ + name: 'Rich GET view', + description: 'read every branch', + type: ViewType.Grid, + options: { rowHeight: RowHeightLevel.Tall }, + filter: { + conjunction: 'and', + filterSet: [{ fieldId: primaryFieldId, operator: 'is', value: 'alpha' }], + }, + sort: { + sortObjs: [{ fieldId: primaryFieldId, order: SortFunc.Desc }], + manualSort: false, + }, + group: [{ fieldId: primaryFieldId, order: SortFunc.Asc }], + isLocked: true, + enableShare: true, + shareId: 'shr-get-view-v2', + shareMeta: { + allowCopy: false, + includeHiddenField: true, + submit: { requireLogin: true }, + }, + }); + }); + + it('omits false and empty legacy properties and removes stale column metadata', async () => { + const primaryFieldId = table.fields[0].id; + const staleFieldId = `fld${'z'.repeat(16)}`; + const created = await createViewApi(table.id, { + name: 'Sparse GET view', + description: '', + type: ViewType.Grid, + isLocked: false, + enableShare: false, + shareId: '', + columnMeta: { + [primaryFieldId]: { order: 0, width: 180 }, + [staleFieldId]: { order: 1, width: 320 }, + }, + }); + + const response = await getViewApi(table.id, created.data.id); + + expect(response.data.description).toBeUndefined(); + expect(response.data.isLocked).toBeUndefined(); + expect(response.data.enableShare).toBeUndefined(); + expect(response.data.shareId).toBeUndefined(); + expect(response.data.columnMeta[primaryFieldId]).toEqual({ order: 0, width: 180 }); + expect(response.data.columnMeta).not.toHaveProperty(staleFieldId); + }); + + it('returns v2 validation details for malformed identifiers', async () => { + const error = await getError(() => getViewApi(table.id, 'invalid-view-id')); + + expect(error?.status).toBe(400); + expect(error?.data).toMatchObject({ domainCode: 'validation.invalid' }); + }); + + it('does not hydrate a soft-deleted View into the Table aggregate', async () => { + const created = await createViewApi(table.id, { + name: 'Deleted GET view', + type: ViewType.Grid, + }); + await deleteView(table.id, created.data.id); + + const error = await getError(() => getViewApi(table.id, created.data.id)); + + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'view.not_found' }); + }); + }); + + it('/api/table/{tableId}/view (POST)', async () => { + const viewRo: IViewRo = { + name: 'New view', + description: 'the new view', + type: ViewType.Grid, + }; + + const createdView = await createView(table.id, viewRo); + + const { dbTableName } = await prismaService.tableMeta.findUniqueOrThrow({ + where: { id: table.id }, + select: { dbTableName: true }, + }); + const rowOrderColumn = await viewService.existIndex( + dbTableName, + createdView.id, + prismaService.txClient() + ); + expect(rowOrderColumn).toBe(`__row_${createdView.id}`); + + const result = await getViews(table.id); + expect(result).toMatchObject([ + ...defaultViews, + { + name: 'New view', + description: 'the new view', + type: ViewType.Grid, + }, + ]); + }); + + describe('Create View v2 canary (T6420)', () => { + let previousForceV2All: string | undefined; + + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + }); + + afterEach(() => { + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + }); + + it('routes a supported Grid payload through v2 and creates its row-order column', async () => { + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + const response = await createViewApi(table.id, { + name: 'V2 grid view', + type: ViewType.Grid, + }); + + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('createView'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(response.data).toMatchObject({ + name: 'V2 grid view', + type: ViewType.Grid, + }); + + const fields = await getFields(table.id); + expect(Object.keys(response.data.columnMeta)).toEqual(fields.map(({ id }) => id)); + + const { dbTableName } = await prismaService.tableMeta.findUniqueOrThrow({ + where: { id: table.id }, + select: { dbTableName: true }, + }); + await expect( + viewService.existIndex(dbTableName, response.data.id, prismaService.txClient()) + ).resolves.toBe(`__row_${response.data.id}`); + expectNoLegacyViewEvent(eventSpy); + }); + + it('applies aggregate-owned default and unique names through the HTTP API', async () => { + const firstResponse = await createViewApi(table.id, { + type: ViewType.Grid, + }); + const secondResponse = await createViewApi(table.id, { + type: ViewType.Grid, + }); + + expect(firstResponse.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(secondResponse.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(firstResponse.data.name).toBe('New view'); + expect(secondResponse.data.name).toBe('New view 2'); + + const views = await getViews(table.id); + expect(views.map(({ name }) => name)).toEqual(['Grid view', 'New view', 'New view 2']); + }); + + it.each(['', ' Spaced view '])( + 'preserves the legal public name payload %j through v2', + async (name) => { + const response = await createViewApi(table.id, { + name, + type: ViewType.Grid, + }); + + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('createView'); + expect(response.data.name).toBe(name); + } + ); + + it.each([ + { + requestedName: 'Sprint 2', + expectedDuplicateName: 'Sprint 3', + }, + { + requestedName: '123', + expectedDuplicateName: '123 2', + }, + ])( + 'increments duplicate name "$requestedName" as "$expectedDuplicateName"', + async ({ requestedName, expectedDuplicateName }) => { + const firstResponse = await createViewApi(table.id, { + name: requestedName, + type: ViewType.Grid, + }); + const duplicateResponse = await createViewApi(table.id, { + name: requestedName, + type: ViewType.Grid, + }); + + expect(firstResponse.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(duplicateResponse.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(firstResponse.data.name).toBe(requestedName); + expect(duplicateResponse.data.name).toBe(expectedDuplicateName); + } + ); + + it('merges supported columnMeta and options while ignoring unknown fields', async () => { + const primaryField = (await getFields(table.id))[0]!; + const ignoredFieldId = `fld${'z'.repeat(16)}`; + + const response = await createViewApi(table.id, { + name: 'Configured grid view', + type: ViewType.Grid, + columnMeta: { + [primaryField.id]: { + order: 12, + width: 240, + hidden: true, + }, + [ignoredFieldId]: { + order: 99, + width: 320, + }, + }, + options: { + rowHeight: RowHeightLevel.Tall, + fieldNameDisplayLines: 2, + }, + }); + + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('createView'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(response.data.options).toEqual({ + rowHeight: RowHeightLevel.Tall, + fieldNameDisplayLines: 2, + }); + expect(response.data.columnMeta[primaryField.id]).toEqual({ + order: 12, + width: 240, + hidden: true, + }); + expect(response.data.columnMeta).not.toHaveProperty(ignoredFieldId); + }); + + it.each([ViewType.Kanban, ViewType.Gallery, ViewType.Calendar, ViewType.Form])( + 'routes %s creation through v2 without a Grid row-order column', + async (type) => { + const response = await createViewApi(table.id, { + name: `V2 ${type} view`, + type, + }); + + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('createView'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(response.data).toMatchObject({ + name: `V2 ${type} view`, + type, + }); + + const { dbTableName } = await prismaService.tableMeta.findUniqueOrThrow({ + where: { id: table.id }, + select: { dbTableName: true }, + }); + await expect( + viewService.existIndex(dbTableName, response.data.id, prismaService.txClient()) + ).resolves.toBeUndefined(); + } + ); + + it('preserves all legacy creation properties through v2', async () => { + const primaryFieldId = table.fields[0].id; + const response = await createViewApi(table.id, { + name: 'Legacy metadata grid view', + description: 'keep this description', + type: ViewType.Grid, + filter: { + conjunction: 'and', + filterSet: [{ fieldId: primaryFieldId, operator: 'is', value: 'alpha' }], + }, + sort: { + sortObjs: [{ fieldId: primaryFieldId, order: SortFunc.Desc }], + manualSort: false, + }, + group: [{ fieldId: primaryFieldId, order: SortFunc.Asc }], + isLocked: true, + enableShare: true, + shareId: 'shr-create-view-v2', + shareMeta: { + allowCopy: false, + includeHiddenField: true, + password: 'secret', + includeRecords: true, + allowEdit: false, + submit: { requireLogin: true }, + }, + }); + + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('createView'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(response.data).toMatchObject({ + name: 'Legacy metadata grid view', + description: 'keep this description', + type: ViewType.Grid, + filter: { + conjunction: 'and', + filterSet: [{ fieldId: primaryFieldId, operator: 'is', value: 'alpha' }], + }, + sort: { + sortObjs: [{ fieldId: primaryFieldId, order: SortFunc.Desc }], + manualSort: false, + }, + group: [{ fieldId: primaryFieldId, order: SortFunc.Asc }], + isLocked: true, + enableShare: true, + shareId: 'shr-create-view-v2', + shareMeta: { + allowCopy: false, + includeHiddenField: true, + password: 'secret', + includeRecords: true, + allowEdit: false, + submit: { requireLogin: true }, + }, + }); + }); + + it('preserves an empty legacy filter group through v2', async () => { + const response = await createViewApi(table.id, { + type: ViewType.Grid, + filter: { + conjunction: 'and', + filterSet: [], + }, + }); + + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('createView'); + expect(response.data.filter).toEqual({ + conjunction: 'and', + filterSet: [], + }); + }); + + it('accepts legacy date filters without millisecond precision through v2', async () => { + const dateField = await createField(table.id, { + name: 'Due', + type: FieldType.Date, + }); + const response = await createViewApi(table.id, { + type: ViewType.Calendar, + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: dateField.id, + operator: 'is', + value: { + mode: 'exactDate', + exactDate: '2026-07-01T00:00:00Z', + timeZone: 'UTC', + }, + }, + ], + }, + }); + + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('createView'); + expect(response.data.filter).toEqual({ + conjunction: 'and', + filterSet: [ + { + fieldId: dateField.id, + operator: 'is', + value: { + mode: 'exactDate', + exactDate: '2026-07-01T00:00:00Z', + timeZone: 'UTC', + }, + }, + ], + }); + }); + + it('round-trips symbol, scalar-array, and date-range filters without normalization', async () => { + const dateField = await createField(table.id, { + name: 'Range date', + type: FieldType.Date, + }); + const sourceFilter = { + conjunction: 'and' as const, + filterSet: [ + { + fieldId: table.fields[0].id, + operator: '=', + isSymbol: true, + value: 'alpha', + }, + { + fieldId: table.fields[0].id, + operator: 'IN', + isSymbol: true, + value: 'alpha', + }, + { + fieldId: dateField.id, + operator: 'is', + value: { + mode: 'dateRange' as const, + exactDate: '2026-07-01T00:00:00.000Z', + exactDateEnd: '2026-07-31T23:59:59.000Z', + timeZone: 'UTC', + }, + }, + ], + }; + + const response = await createViewApi(table.id, { + type: ViewType.Grid, + filter: sourceFilter, + }); + + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('createView'); + expect(response.data.filter).toEqual(sourceFilter); + }); + + it('applies Gallery, Calendar, and Form defaults inside the aggregate', async () => { + const attachmentField = await createField(table.id, { + name: 'Cover', + type: FieldType.Attachment, + }); + const startDateField = await createField(table.id, { + name: 'Start', + type: FieldType.Date, + }); + const endDateField = await createField(table.id, { + name: 'End', + type: FieldType.Date, + }); + const buttonField = await createField(table.id, { + name: 'Action', + type: FieldType.Button, + }); + + const gallery = await createViewApi(table.id, { + type: ViewType.Gallery, + }); + const calendar = await createViewApi(table.id, { + type: ViewType.Calendar, + }); + const form = await createViewApi(table.id, { + type: ViewType.Form, + }); + + expect(gallery.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(calendar.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(form.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(gallery.data.options).toEqual({ coverFieldId: attachmentField.id }); + expect(calendar.data.options).toMatchObject({ + startDateFieldId: startDateField.id, + endDateFieldId: endDateField.id, + }); + expect(form.data.columnMeta[table.fields[0].id]).toMatchObject({ visible: true }); + expect(form.data.columnMeta[attachmentField.id]).toMatchObject({ visible: true }); + expect(form.data.columnMeta[startDateField.id]).toMatchObject({ visible: true }); + expect(form.data.columnMeta[endDateField.id]).toMatchObject({ visible: true }); + expect(form.data.columnMeta[buttonField.id]).not.toHaveProperty('visible'); + }); + + it('keeps type-required columns visible when the request tries to hide them', async () => { + const attachmentField = await createField(table.id, { + name: 'Visible in form', + type: FieldType.Attachment, + }); + const primaryFieldId = table.fields[0].id; + + for (const type of [ViewType.Kanban, ViewType.Gallery, ViewType.Calendar]) { + const response = await createViewApi(table.id, { + type, + columnMeta: { + [primaryFieldId]: { order: 0, visible: false }, + }, + }); + + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.data.columnMeta[primaryFieldId]).toMatchObject({ visible: true }); + } + + const form = await createViewApi(table.id, { + type: ViewType.Form, + columnMeta: { + [primaryFieldId]: { order: 0, visible: false }, + [attachmentField.id]: { order: 1, visible: false }, + }, + }); + expect(form.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(form.data.columnMeta[primaryFieldId]).toMatchObject({ visible: true }); + expect(form.data.columnMeta[attachmentField.id]).toMatchObject({ visible: true }); + }); + + it('creates Plugin views and their installation through the v2 transaction', async () => { + const response = await createViewApi(table.id, { + type: ViewType.Plugin, + options: { + pluginId: 'plgsheetform', + pluginInstallId: 'ignored-by-create', + pluginLogo: 'ignored-by-create', + }, + }); + + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('createView'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(response.data.type).toBe(ViewType.Plugin); + expect(response.data.options).toMatchObject({ + pluginId: 'plgsheetform', + }); + expect((response.data.options as IPluginViewOptions).pluginInstallId).not.toBe( + 'ignored-by-create' + ); + expect((response.data.options as IPluginViewOptions).pluginLogo).not.toBe( + 'ignored-by-create' + ); + + const installation = await getViewInstallPlugin(table.id, response.data.id); + expect(installation.data.pluginInstallId).toBe( + (response.data.options as IPluginViewOptions).pluginInstallId + ); + }); + + it('rejects a missing Plugin through v2 without creating a View', async () => { + const viewsBefore = await getViews(table.id); + + const error = await getError(() => + createViewApi(table.id, { + type: ViewType.Plugin, + options: { + pluginId: 'plg-missing-view-plugin', + pluginInstallId: 'ignored-by-create', + pluginLogo: 'ignored-by-create', + }, + }) + ); + + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'not_found' }); + await expect(getViews(table.id)).resolves.toHaveLength(viewsBefore.length); + }); + + it('rejects a Plugin that does not support the View position', async () => { + const plugin = await createPlugin({ + name: 'Panel-only plugin', + logo: 'https://example.test/panel-only.png', + positions: [PluginPosition.Panel], + }); + const viewsBefore = await getViews(table.id); + + try { + await submitPlugin(plugin.data.id); + await publishPlugin(plugin.data.id); + const error = await getError(() => + createViewApi(table.id, { + type: ViewType.Plugin, + options: { + pluginId: plugin.data.id, + pluginInstallId: 'ignored-by-create', + pluginLogo: 'ignored-by-create', + }, + }) + ); + + expect(error?.status).toBe(400); + expect(error?.data).toMatchObject({ domainCode: 'validation.invalid' }); + await expect(getViews(table.id)).resolves.toHaveLength(viewsBefore.length); + } finally { + await deletePlugin(plugin.data.id); + } + }); + }); + + it('/api/table/{tableId}/view (POST) with gallery view', async () => { + const viewRo: IViewRo = { + name: 'New gallery view', + description: 'the new gallery view', + type: ViewType.Gallery, + }; + + const fieldVo = await createField(table.id, { + name: 'Attachment', + type: FieldType.Attachment, + }); + await createView(table.id, viewRo); + + const result = await getViews(table.id); + expect(result).toMatchObject([ + ...defaultViews, + { + name: 'New gallery view', + description: 'the new gallery view', + type: ViewType.Gallery, + options: { + coverFieldId: fieldVo.id, + }, + }, + ]); + }); + + it('should update view simple properties', async () => { + const viewRo: IViewRo = { + name: 'New view', + description: 'the new view', + type: ViewType.Grid, + }; + + const view = await createView(table.id, viewRo); + + await updateViewName(table.id, view.id, { name: 'New view 2' }); + await updateViewDescription(table.id, view.id, { description: 'description2' }); + await updateViewLocked(table.id, view.id, { isLocked: true }); + const viewNew = await getView(table.id, view.id); + + expect(viewNew.name).toEqual('New view 2'); + expect(viewNew.description).toEqual('description2'); + expect(viewNew.isLocked).toBeTruthy(); + }); + + it('should create view with field order', async () => { + // get fields + const fields = await getFields(table.id); + const testFieldId = fields?.[0].id; + const assertOrder = 10; + const columnMeta = fields.reduce>( + (pre, cur, index) => { + pre[cur.id] = {} as IColumn; + pre[cur.id].order = index === 0 ? assertOrder : index; + return pre; + }, + {} as Record + ); + + const viewResponse = await createView(table.id, { + name: 'view', + columnMeta, + type: ViewType.Grid, + }); + + const { columnMeta: columnMetaResponse } = viewResponse; + const order = columnMetaResponse?.[testFieldId]?.order; + expect(order).toEqual(assertOrder); + expect(fields.length).toEqual(Object.keys(columnMetaResponse).length); + }); + + it('should set all eligible fields visible when creating form view', async () => { + const formView = await createView(table.id, { + name: 'Form view', + type: ViewType.Form, + }); + + const views = await getViews(table.id); + const createdForm = views.find(({ id }) => id === formView.id)!; + const formColumnMeta = createdForm.columnMeta as unknown as Record; + + const eligibleFieldIds = table.fields + .filter((f) => !f.isComputed && !f.isLookup && f.type !== FieldType.Button) + .map((f) => f.id); + + eligibleFieldIds.forEach((fieldId) => { + expect(formColumnMeta[fieldId]?.visible ?? false).toBe(true); + }); + }); + + it('should batch update view when create field', async () => { + const initialColumnMeta = await viewService.generateViewOrderColumnMeta(table.id); + const createData: Prisma.ViewCreateManyInput[] = []; + const num = 100; + for (let i = 0; i < num; i++) { + const data: Prisma.ViewCreateManyInput = { + id: generateViewId(), + tableId: table.id, + name: `New view ${i}`, + type: ViewType.Grid, + version: 1, + order: i + 1, + createdBy: globalThis.testConfig.userId, + columnMeta: JSON.stringify(initialColumnMeta ?? {}), + }; + + createData.push(data); + } + const result = await prismaService.txClient().view.createMany({ data: createData }); + expect(result.count).toEqual(num); + + await createField(table.id, { type: FieldType.SingleLineText }); + const fields = await getFields(table.id); + const assertFieldIds = fields.map((field) => field.id).sort(); + const randomViewId = sample(createData.map((data) => data.id)); + const view = await getView(table.id, randomViewId!); + const columnMetaFieldIds = Object.keys(view.columnMeta).sort(); + expect(columnMetaFieldIds).toEqual(assertFieldIds); + }); + + it('should ignore stale column meta for deleted fields when reading views', async () => { + const staleField = await createField(table.id, { + name: 'deleted column meta field', + type: FieldType.SingleLineText, + }); + const view = await createView(table.id, { + name: 'view with stale column meta', + type: ViewType.Grid, + }); + + await deleteField(table.id, staleField.id); + const activeFields = await getFields(table.id); + const activeColumnMeta = activeFields.reduce>((acc, field, index) => { + acc[field.id] = { order: index }; + return acc; + }, {}); + + await prismaService.txClient().view.update({ + where: { id: view.id }, + data: { + columnMeta: JSON.stringify({ + ...activeColumnMeta, + [staleField.id]: { order: activeFields.length + 1, visible: true }, + }), + }, + }); + + const activeFieldIds = activeFields.map((field) => field.id).sort(); + const viewAfter = await getView(table.id, view.id); + const viewsAfter = await getViews(table.id); + const viewFromList = viewsAfter.find(({ id }) => id === view.id); + const [viewSnapshot] = await viewService.getSnapshotBulk(table.id, [view.id]); + + expect(viewAfter.columnMeta?.[staleField.id]).toBeUndefined(); + expect(Object.keys(viewAfter.columnMeta ?? {}).sort()).toEqual(activeFieldIds); + expect(viewFromList?.columnMeta?.[staleField.id]).toBeUndefined(); + expect(Object.keys(viewFromList?.columnMeta ?? {}).sort()).toEqual(activeFieldIds); + expect(viewSnapshot.data.columnMeta?.[staleField.id]).toBeUndefined(); + expect(Object.keys(viewSnapshot.data.columnMeta ?? {}).sort()).toEqual(activeFieldIds); + }); + + it('fields in new view should sort by created time and primary field is always first', async () => { + const viewRo: IViewRo = { + name: 'New view', + description: 'the new view', + type: ViewType.Grid, + }; + + const oldFields: IFieldVo[] = []; + oldFields.push(await createField(table.id, { type: FieldType.SingleLineText })); + oldFields.push(await createField(table.id, { type: FieldType.SingleLineText })); + oldFields.push(await createField(table.id, { type: FieldType.SingleLineText })); + + const newView = await createView(table.id, viewRo); + const newFields = await getFields(table.id, newView.id); + + expect(newFields.slice(3)).toMatchObject(oldFields); + }); + + describe('/api/table/{tableId}/view/:viewId/filter-link-records (GET)', () => { + let table: ITableFullVo; + let linkTable1: ITableFullVo; + let linkTable2: ITableFullVo; + let previousForceV2All: string | undefined; + + const linkTable1FieldRo: IFieldRo[] = [ + { + name: 'single_line_text_field', + type: FieldType.SingleLineText, + }, + ]; + + const linkTable2FieldRo: IFieldRo[] = [ + { + name: 'single_line_text_field', + type: FieldType.SingleLineText, + }, + ]; + + const linkTable1RecordRo: ICreateTableRo['records'] = [ + { + fields: { + single_line_text_field: 'link_table1_record1', + }, + }, + { + fields: { + single_line_text_field: 'link_table1_record2', + }, + }, + { + fields: { + single_line_text_field: 'link_table1_record3', + }, + }, + ]; + const linkTable2RecordRo: ICreateTableRo['records'] = [ + { + fields: { + single_line_text_field: 'link_table2_record1', + }, + }, + { + fields: { + single_line_text_field: 'link_table2_record2', + }, + }, + { + fields: { + single_line_text_field: 'link_table2_record3', + }, + }, + ]; + + beforeAll(async () => { + const fullTable = await createTable(baseId, { + name: 'filter_link_records', + fields: [ + { + name: 'link_field1', + type: FieldType.SingleLineText, + }, + ], + records: [], + }); + + linkTable1 = await createTable(baseId, { + name: 'link_table1', + fields: [ + ...linkTable1FieldRo, + { + type: FieldType.Link, + options: { + foreignTableId: fullTable.id, + relationship: Relationship.OneMany, + }, + }, + ], + records: linkTable1RecordRo, + }); + + linkTable2 = await createTable(baseId, { + name: 'link_table2', + fields: [ + ...linkTable2FieldRo, + { + type: FieldType.Link, + options: { + foreignTableId: fullTable.id, + relationship: Relationship.OneMany, + }, + }, + ], + records: linkTable2RecordRo, + }); + + table = (await getTable(baseId, fullTable.id, { includeContent: true })) as ITableFullVo; + }); + + afterAll(async () => { + await permanentDeleteTable(baseId, table.id); + await permanentDeleteTable(baseId, linkTable1.id); + await permanentDeleteTable(baseId, linkTable2.id); + }); + + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + }); + + it('returns nested, deduplicated Link records through v2 without using ViewService', async () => { + const missingRecordId = generateRecordId(); + const viewRo: IViewRo = { + name: 'New view', + description: 'the new view', + type: ViewType.Grid, + filter: { + filterSet: [ + { + fieldId: table.fields![1].id, + value: linkTable1.records[0].id, + operator: 'is', + }, + { + filterSet: [ + { + fieldId: table.fields![1].id, + value: [ + linkTable1.records[0].id, + linkTable1.records[1].id, + linkTable1.records[2].id, + missingRecordId, + ], + operator: 'isAnyOf', + }, + ], + conjunction: 'and', + }, + { + fieldId: table.fields![2].id, + value: linkTable2.records[0].id, + operator: 'is', + }, + { + filterSet: [ + { + fieldId: table.fields![2].id, + value: [linkTable2.records[2].id], + operator: 'isAnyOf', + }, + ], + conjunction: 'and', + }, + ], + conjunction: 'and', + }, + }; + + const viewResponse = await createViewApi(table.id, viewRo); + expect(viewResponse.headers[X_TEABLE_V2_HEADER]).toBe('true'); + const legacyReadSpy = vi + .spyOn(viewService, 'getViewById') + .mockRejectedValue(new Error('legacy ViewService must not be used')); + + const response = await getViewFilterLinkRecords(table.id, viewResponse.data.id); + const records = response.data; + + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getViewFilterLinkRecords'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(legacyReadSpy).not.toHaveBeenCalled(); + expect(records).toMatchObject([ + { + tableId: linkTable1.id, + records: [ + { id: linkTable1.records[0].id, title: 'link_table1_record1' }, + { id: linkTable1.records[1].id, title: 'link_table1_record2' }, + { id: linkTable1.records[2].id, title: 'link_table1_record3' }, + ], + }, + { + tableId: linkTable2.id, + records: [ + { id: linkTable2.records[0].id, title: 'link_table2_record1' }, + { + id: linkTable2.records[2].id, + title: 'link_table2_record3', + }, + ], + }, + ]); + }); + + it('returns an empty list when filters do not reference a Link Field', async () => { + const viewResponse = await createViewApi(table.id, { + name: 'No Link references', + type: ViewType.Grid, + filter: { + filterSet: [ + { + fieldId: table.fields![0].id, + value: generateRecordId(), + operator: 'is', + }, + ], + conjunction: 'and', + }, + }); + + const response = await getViewFilterLinkRecords(table.id, viewResponse.data.id); + + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.data).toEqual([]); + }); + + it('returns view.not_found when the View belongs to another Table', async () => { + const anotherTable = await createTable(baseId, { name: 'another_filter_link_table' }); + + try { + const [anotherView] = await getViews(anotherTable.id); + const error = await getError(() => getViewFilterLinkRecords(table.id, anotherView.id)); + + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'view.not_found' }); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } + }); + }); + + describe('Update View column metadata v2 canary (T6420)', () => { + let previousForceV2All: string | undefined; + const windowIdHeader = 'X-Window-Id'; + + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + axios.defaults.headers.common[windowIdHeader] = 'update-view-column-meta-v2-window'; + }); + + afterEach(() => { + delete axios.defaults.headers.common[windowIdHeader]; + vi.restoreAllMocks(); + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + }); + + it('updates supported metadata and emits only v2 domain-event projections', async () => { + const view = (await getViews(table.id))[0]!; + const field = table.fields[1]; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }); + const legacyColumnMetaSpy = vi + .spyOn(viewOpenApiService, 'updateViewColumnMeta') + .mockRejectedValue(new Error('legacy ViewOpenApiService must not be used')); + const legacyOpsSpy = vi + .spyOn(viewService, 'updateViewByOps') + .mockRejectedValue(new Error('legacy ViewService must not be used')); + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + + const response = await updateViewColumnMeta(table.id, view.id, [ + { + fieldId: field.id, + columnMeta: { + order: 9, + width: 320, + hidden: true, + statisticFunc: StatisticsFunc.Sum, + }, + }, + ]); + + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewColumnMeta'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect((await getView(table.id, view.id)).columnMeta[field.id]).toMatchObject({ + order: 9, + width: 320, + hidden: true, + statisticFunc: StatisticsFunc.Sum, + }); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }) + ).resolves.toEqual({ version: rowBefore.version + 1 }); + expect(legacyColumnMetaSpy).not.toHaveBeenCalled(); + expect(legacyOpsSpy).not.toHaveBeenCalled(); + expectNoLegacyViewEvent(eventSpy); + }); + + it('adds the default order when patching metadata for a missing column entry', async () => { + const view = (await getViews(table.id))[0]!; + const primaryField = table.fields[0]; + const field = table.fields.at(-1)!; + await prismaService.view.update({ + where: { id: view.id }, + data: { + columnMeta: JSON.stringify({ + [primaryField.id]: { order: 0 }, + }), + }, + }); + + await updateViewColumnMeta(table.id, view.id, [ + { + fieldId: field.id, + columnMeta: { width: 241 }, + }, + ]); + + expect((await getView(table.id, view.id)).columnMeta[field.id]).toEqual({ + order: table.fields.length - 1, + width: 241, + }); + }); + + it('updates the aggregate-owned frozen-field boundary when the frozen field moves', async () => { + const [primaryField, frozenField] = table.fields; + const viewResponse = await createViewApi(table.id, { + name: 'Frozen columns', + type: ViewType.Grid, + options: { frozenFieldId: frozenField.id }, + }); + + const response = await updateViewColumnMeta(table.id, viewResponse.data.id, [ + { + fieldId: frozenField.id, + columnMeta: { order: 9 }, + }, + ]); + + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewColumnMeta'); + expect((await getView(table.id, viewResponse.data.id)).options).toMatchObject({ + frozenFieldId: primaryField.id, + }); + }); + + it('rejects hiding the primary field and a View from another Table without persistence', async () => { + const view = (await getViews(table.id))[0]!; + const before = await getView(table.id, view.id); + const hidePrimaryError = await getError(() => + updateViewColumnMeta(table.id, view.id, [ + { + fieldId: table.fields[0].id, + columnMeta: { hidden: true }, + }, + ]) + ); + expect(hidePrimaryError?.status).toBe(400); + expect(hidePrimaryError?.data).toMatchObject({ + domainCode: 'view.primary_field_cannot_be_hidden', + }); + expect((await getView(table.id, view.id)).columnMeta).toEqual(before.columnMeta); + + const anotherTable = await createTable(baseId, { name: 'column_meta_other_table' }); + try { + const anotherView = (await getViews(anotherTable.id))[0]!; + const error = await getError(() => + updateViewColumnMeta(table.id, anotherView.id, [ + { + fieldId: table.fields[1].id, + columnMeta: { width: 200 }, + }, + ]) + ); + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'view.not_found' }); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } + }); + + it('treats empty and identical patches as no-op writes', async () => { + const view = (await getViews(table.id))[0]!; + const field = table.fields[1]; + const existing = view.columnMeta[field.id]!; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }); + + const emptyResponse = await updateViewColumnMeta(table.id, view.id, []); + const identicalResponse = await updateViewColumnMeta(table.id, view.id, [ + { + fieldId: field.id, + columnMeta: { order: existing.order }, + }, + ]); + + expect(emptyResponse.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewColumnMeta'); + expect(identicalResponse.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewColumnMeta'); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }) + ).resolves.toEqual(rowBefore); + }); + }); + + describe('Update View filter v2 canary (T6420)', () => { + let previousForceV2All: string | undefined; + const windowIdHeader = 'X-Window-Id'; + + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + axios.defaults.headers.common[windowIdHeader] = 'update-view-filter-v2-window'; + }); + + afterEach(() => { + delete axios.defaults.headers.common[windowIdHeader]; + vi.restoreAllMocks(); + if (previousForceV2All == null) delete process.env.FORCE_V2_ALL; + else process.env.FORCE_V2_ALL = previousForceV2All; + }); + + it('updates a nested source filter through the Table aggregate and v2 projections', async () => { + const view = (await getViews(table.id))[0]!; + const [textField, numberField] = table.fields; + const filter = { + conjunction: 'and' as const, + filterSet: [ + { + fieldId: textField.id, + operator: '=' as const, + value: 'alpha', + isSymbol: true as const, + }, + { + conjunction: 'or' as const, + filterSet: [ + { fieldId: numberField.id, operator: 'isGreater' as const, value: 3 }, + { + fieldId: textField.id, + operator: 'is' as const, + value: { + type: 'field' as const, + fieldId: textField.id, + tableId: table.id, + }, + }, + ], + }, + ], + }; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }); + const legacyPropertySpy = vi + .spyOn(viewOpenApiService, 'setViewProperty') + .mockRejectedValue(new Error('legacy ViewOpenApiService must not be used')); + const legacyOpsSpy = vi + .spyOn(viewService, 'updateViewByOps') + .mockRejectedValue(new Error('legacy ViewService must not be used')); + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + + const response = await updateViewFilter(table.id, view.id, { filter }); + + expect(response.status).toBe(200); + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewFilter'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect((await getView(table.id, view.id)).filter).toEqual(filter); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }) + ).resolves.toEqual({ version: rowBefore.version + 1 }); + expect(legacyPropertySpy).not.toHaveBeenCalled(); + expect(legacyOpsSpy).not.toHaveBeenCalled(); + expectNoLegacyViewEvent(eventSpy); + }); + + it('preserves empty and incomplete filters, skips identical writes, and clears null', async () => { + const view = (await getViews(table.id))[0]!; + const emptyFilter = { conjunction: 'and' as const, filterSet: [] }; + await updateViewFilter(table.id, view.id, { filter: emptyFilter }); + const afterEmpty = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }); + const identical = await updateViewFilter(table.id, view.id, { filter: emptyFilter }); + expect(identical.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewFilter'); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }) + ).resolves.toEqual(afterEmpty); + + const incompleteFilter = { + conjunction: 'and' as const, + filterSet: [ + { + fieldId: table.fields[0].id, + operator: 'isNot' as const, + value: null, + }, + ], + }; + await updateViewFilter(table.id, view.id, { filter: incompleteFilter }); + expect((await getView(table.id, view.id)).filter).toEqual(incompleteFilter); + await updateViewFilter(table.id, view.id, { filter: null }); + expect((await getView(table.id, view.id)).filter).toBeUndefined(); + }); + + it('rejects missing fields, Button fields, and incompatible operators without persistence', async () => { + const view = (await getViews(table.id))[0]!; + const buttonField = await createField(table.id, { + name: 'Filter action', + type: FieldType.Button, + }); + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { filter: true, version: true }, + }); + const cases = [ + { + filter: { + conjunction: 'and' as const, + filterSet: [ + { + fieldId: `fld${'z'.repeat(16)}`, + operator: 'is' as const, + value: 'missing', + }, + ], + }, + domainCode: 'field.not_found', + status: 404, + }, + { + filter: { + conjunction: 'and' as const, + filterSet: [{ fieldId: buttonField.id, operator: 'isEmpty' as const, value: null }], + }, + domainCode: 'view.filter_unsupported_field_type', + status: 400, + }, + { + filter: { + conjunction: 'and' as const, + filterSet: [ + { + fieldId: table.fields[1].id, + operator: 'contains' as const, + value: 'three', + }, + ], + }, + status: 400, + }, + ]; + for (const testCase of cases) { + const error = await getError(() => + updateViewFilter(table.id, view.id, { filter: testCase.filter }) + ); + expect(error?.status).toBe(testCase.status); + if (testCase.domainCode) { + expect(error?.data).toMatchObject({ domainCode: testCase.domainCode }); + } + } + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { filter: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + }); + + it('rejects a View owned by another Table without crossing the aggregate boundary', async () => { + const anotherTable = await createTable(baseId, { name: 'filter_other_table' }); + try { + const sourceView = (await getViews(table.id))[0]!; + const anotherView = (await getViews(anotherTable.id))[0]!; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: sourceView.id }, + select: { filter: true, version: true }, + }); + const legacyPropertySpy = vi.spyOn(viewOpenApiService, 'setViewProperty'); + + const error = await getError(() => + updateViewFilter(table.id, anotherView.id, { filter: null }) + ); + + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'view.not_found' }); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: sourceView.id }, + select: { filter: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + expect(legacyPropertySpy).not.toHaveBeenCalled(); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } + }); + }); + + describe('Update View sort v2 canary (T6420)', () => { + let previousForceV2All: string | undefined; + const windowIdHeader = 'X-Window-Id'; + + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + axios.defaults.headers.common[windowIdHeader] = 'update-view-sort-v2-window'; + }); + + afterEach(() => { + delete axios.defaults.headers.common[windowIdHeader]; + vi.restoreAllMocks(); + if (previousForceV2All == null) delete process.env.FORCE_V2_ALL; + else process.env.FORCE_V2_ALL = previousForceV2All; + }); + + it('updates multiple sort items through the Table aggregate and v2 projections', async () => { + const view = (await getViews(table.id))[0]!; + const sort = { + sortObjs: [ + { fieldId: table.fields[0].id, order: SortFunc.Asc }, + { fieldId: table.fields[1].id, order: SortFunc.Desc }, + ], + manualSort: false, + }; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }); + const legacyPropertySpy = vi + .spyOn(viewOpenApiService, 'setViewProperty') + .mockRejectedValue(new Error('legacy ViewOpenApiService must not be used')); + const legacyOpsSpy = vi + .spyOn(viewService, 'updateViewByOps') + .mockRejectedValue(new Error('legacy ViewService must not be used')); + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + + const response = await updateViewSort(table.id, view.id, { sort }); + + expect(response.status).toBe(200); + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewSort'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect((await getView(table.id, view.id)).sort).toEqual(sort); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { sort: true, version: true }, + }) + ).resolves.toEqual({ + sort: JSON.stringify(sort), + version: rowBefore.version + 1, + }); + expect(legacyPropertySpy).not.toHaveBeenCalled(); + expect(legacyOpsSpy).not.toHaveBeenCalled(); + expectNoLegacyViewEvent(eventSpy); + }); + + it('preserves empty and manual sorts, skips identical writes, and clears null', async () => { + const view = (await getViews(table.id))[0]!; + await updateViewSort(table.id, view.id, { sort: { sortObjs: [] } }); + expect((await getView(table.id, view.id)).sort).toEqual({ sortObjs: [] }); + + await updateViewSort(table.id, view.id, { + sort: { sortObjs: [], manualSort: true }, + }); + const afterManual = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }); + const identical = await updateViewSort(table.id, view.id, { + sort: { sortObjs: [], manualSort: true }, + }); + expect(identical.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewSort'); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }) + ).resolves.toEqual(afterManual); + expect((await getView(table.id, view.id)).sort).toEqual({ + sortObjs: [], + manualSort: true, + }); + + await updateViewSort(table.id, view.id, { sort: null }); + expect((await getView(table.id, view.id)).sort).toBeUndefined(); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { sort: true }, + }) + ).resolves.toEqual({ sort: null }); + }); + + it('rejects missing fields and Button fields without persistence or v1 fallback', async () => { + const view = (await getViews(table.id))[0]!; + const buttonField = await createField(table.id, { + name: 'Sort action', + type: FieldType.Button, + }); + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { sort: true, version: true }, + }); + const legacyPropertySpy = vi.spyOn(viewOpenApiService, 'setViewProperty'); + const cases = [ + { + fieldId: `fld${'z'.repeat(16)}`, + domainCode: 'field.not_found', + status: 404, + }, + { + fieldId: buttonField.id, + domainCode: 'view.sort_unsupported_field_type', + status: 400, + }, + ]; + + for (const testCase of cases) { + const error = await getError(() => + updateViewSort(table.id, view.id, { + sort: { + sortObjs: [{ fieldId: testCase.fieldId, order: SortFunc.Asc }], + }, + }) + ); + expect(error?.status).toBe(testCase.status); + expect(error?.data).toMatchObject({ domainCode: testCase.domainCode }); + } + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { sort: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + expect(legacyPropertySpy).not.toHaveBeenCalled(); + }); + + it('rejects a View owned by another Table without crossing the aggregate boundary', async () => { + const anotherTable = await createTable(baseId, { name: 'sort_other_table' }); + try { + const sourceView = (await getViews(table.id))[0]!; + const anotherView = (await getViews(anotherTable.id))[0]!; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: sourceView.id }, + select: { sort: true, version: true }, + }); + const legacyPropertySpy = vi.spyOn(viewOpenApiService, 'setViewProperty'); + + const error = await getError(() => + updateViewSort(table.id, anotherView.id, { sort: null }) + ); + + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'view.not_found' }); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: sourceView.id }, + select: { sort: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + expect(legacyPropertySpy).not.toHaveBeenCalled(); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } + }); + + it('rejects invalid sort directions at the HTTP boundary', async () => { + const view = (await getViews(table.id))[0]!; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { sort: true, version: true }, + }); + + const error = await getError(() => + axios.put(`/table/${table.id}/view/${view.id}/sort`, { + sort: { + sortObjs: [{ fieldId: table.fields[0].id, order: 'up' }], + }, + }) + ); + + expect(error?.status).toBe(400); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { sort: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + }); + }); + + describe('View manual sort v2 canary (T6420)', () => { + let previousForceV2All: string | undefined; + const windowIdHeader = 'X-Window-Id'; + + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + axios.defaults.headers.common[windowIdHeader] = 'manual-sort-view-v2-window'; + }); + + afterEach(() => { + delete axios.defaults.headers.common[windowIdHeader]; + vi.restoreAllMocks(); + if (previousForceV2All == null) delete process.env.FORCE_V2_ALL; + else process.env.FORCE_V2_ALL = previousForceV2All; + }); + + it('materializes multi-row field sort with stable ties through native v2', async () => { + const view = (await getViews(table.id))[0]!; + const primaryFieldId = table.fields[0].id; + const { records } = await createRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + records: [ + { fields: { [primaryFieldId]: 'Beta' } }, + { fields: { [primaryFieldId]: 'Alpha' } }, + { fields: { [primaryFieldId]: 'Beta' } }, + ], + }); + const legacyManualSortSpy = vi + .spyOn(viewOpenApiService, 'manualSort') + .mockRejectedValue(new Error('legacy ViewOpenApiService must not be used')); + const legacyViewSortSpy = vi.spyOn(viewService, 'updateViewSort'); + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + + const response = await manualSortView(table.id, view.id, { + sortObjs: [{ fieldId: primaryFieldId, order: SortFunc.Desc }], + }); + + expect(response.status).toBe(200); + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('manualSortView'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect((await getView(table.id, view.id)).sort).toEqual({ + sortObjs: [{ fieldId: primaryFieldId, order: SortFunc.Desc }], + manualSort: true, + }); + const ordered = await getRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + viewId: view.id, + }); + expect(ordered.data.records.slice(0, 3).map((record) => record.id)).toEqual([ + records[0]!.id, + records[2]!.id, + records[1]!.id, + ]); + expect(legacyManualSortSpy).not.toHaveBeenCalled(); + expect(legacyViewSortSpy).not.toHaveBeenCalled(); + expectNoLegacyViewEvent(eventSpy); + }); + + it('preserves empty sort and skips an identical View metadata write', async () => { + const view = (await getViews(table.id))[0]!; + const legacyManualSortSpy = vi.spyOn(viewOpenApiService, 'manualSort'); + + const first = await manualSortView(table.id, view.id, { sortObjs: [] }); + expect(first.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('manualSortView'); + expect((await getView(table.id, view.id)).sort).toEqual({ + sortObjs: [], + manualSort: true, + }); + const afterFirst = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }); + + const identical = await manualSortView(table.id, view.id, { sortObjs: [] }); + + expect(identical.headers[X_TEABLE_V2_HEADER]).toBe('true'); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }) + ).resolves.toEqual(afterFirst); + expect(legacyManualSortSpy).not.toHaveBeenCalled(); + }); + + it('rejects invalid fields, types, directions, and aggregate ownership without v1 fallback', async () => { + const view = (await getViews(table.id))[0]!; + const buttonField = await createField(table.id, { + name: 'Manual sort action', + type: FieldType.Button, + }); + const galleryView = await createView(table.id, { + name: 'Manual sort gallery', + type: ViewType.Gallery, + }); + const anotherTable = await createTable(baseId, { name: 'manual_sort_other_table' }); + const legacyManualSortSpy = vi.spyOn(viewOpenApiService, 'manualSort'); + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { sort: true, version: true }, + }); + + try { + const anotherView = (await getViews(anotherTable.id))[0]!; + const cases = [ + { + run: () => + manualSortView(table.id, view.id, { + sortObjs: [{ fieldId: `fld${'z'.repeat(16)}`, order: SortFunc.Asc }], + }), + status: 404, + domainCode: 'field.not_found', + }, + { + run: () => + manualSortView(table.id, view.id, { + sortObjs: [{ fieldId: buttonField.id, order: SortFunc.Asc }], + }), + status: 400, + domainCode: 'view.sort_unsupported_field_type', + }, + { + run: () => manualSortView(table.id, galleryView.id, { sortObjs: [] }), + status: 400, + domainCode: 'view.manual_sort_unsupported_type', + }, + { + run: () => manualSortView(table.id, anotherView.id, { sortObjs: [] }), + status: 404, + domainCode: 'view.not_found', + }, + ]; + + for (const testCase of cases) { + const error = await getError(testCase.run); + expect(error?.status).toBe(testCase.status); + expect(error?.data).toMatchObject({ domainCode: testCase.domainCode }); + } + + const malformed = await getError(() => + axios.put(`/table/${table.id}/view/${view.id}/manual-sort`, { + sortObjs: [{ fieldId: table.fields[0].id, order: 'up' }], + }) + ); + expect(malformed?.status).toBe(400); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { sort: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + expect(legacyManualSortSpy).not.toHaveBeenCalled(); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } + }); + }); + + describe('Update View group v2 canary (T6420)', () => { + let previousForceV2All: string | undefined; + const windowIdHeader = 'X-Window-Id'; + + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + axios.defaults.headers.common[windowIdHeader] = 'update-view-group-v2-window'; + }); + + afterEach(() => { + delete axios.defaults.headers.common[windowIdHeader]; + vi.restoreAllMocks(); + if (previousForceV2All == null) delete process.env.FORCE_V2_ALL; + else process.env.FORCE_V2_ALL = previousForceV2All; + }); + + it('updates multiple group items through the Table aggregate and v2 projections', async () => { + const view = (await getViews(table.id))[0]!; + const group = [ + { fieldId: table.fields[0].id, order: SortFunc.Asc }, + { fieldId: table.fields[1].id, order: SortFunc.Desc }, + ]; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }); + const legacyPropertySpy = vi + .spyOn(viewOpenApiService, 'setViewProperty') + .mockRejectedValue(new Error('legacy ViewOpenApiService must not be used')); + const legacyOpsSpy = vi + .spyOn(viewService, 'updateViewByOps') + .mockRejectedValue(new Error('legacy ViewService must not be used')); + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + + const response = await updateViewGroup(table.id, view.id, { group }); + + expect(response.status).toBe(200); + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewGroup'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect((await getView(table.id, view.id)).group).toEqual(group); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { group: true, version: true }, + }) + ).resolves.toEqual({ + group: JSON.stringify(group), + version: rowBefore.version + 1, + }); + expect(legacyPropertySpy).not.toHaveBeenCalled(); + expect(legacyOpsSpy).not.toHaveBeenCalled(); + expectNoLegacyViewEvent(eventSpy); + }); + + it('preserves empty groups, skips identical writes, and clears null', async () => { + const view = (await getViews(table.id))[0]!; + await updateViewGroup(table.id, view.id, { group: [] }); + // Legacy View responses omit empty group arrays, while v2 keeps the persisted + // distinction so an identical request remains a true no-op. + expect((await getView(table.id, view.id)).group).toBeUndefined(); + const afterEmpty = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { group: true, version: true }, + }); + expect(afterEmpty.group).toBe('[]'); + + const identical = await updateViewGroup(table.id, view.id, { group: [] }); + expect(identical.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewGroup'); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { group: true, version: true }, + }) + ).resolves.toEqual(afterEmpty); + + await updateViewGroup(table.id, view.id, { group: null }); + expect((await getView(table.id, view.id)).group).toBeUndefined(); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { group: true }, + }) + ).resolves.toEqual({ group: null }); + }); + + it('rejects missing fields and Button fields without persistence or v1 fallback', async () => { + const view = (await getViews(table.id))[0]!; + const buttonField = await createField(table.id, { + name: 'Group action', + type: FieldType.Button, + }); + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { group: true, version: true }, + }); + const legacyPropertySpy = vi.spyOn(viewOpenApiService, 'setViewProperty'); + const cases = [ + { + fieldId: `fld${'z'.repeat(16)}`, + domainCode: 'field.not_found', + status: 404, + }, + { + fieldId: buttonField.id, + domainCode: 'view.group_unsupported_field_type', + status: 400, + }, + ]; + + for (const testCase of cases) { + const error = await getError(() => + updateViewGroup(table.id, view.id, { + group: [{ fieldId: testCase.fieldId, order: SortFunc.Asc }], + }) + ); + expect(error?.status).toBe(testCase.status); + expect(error?.data).toMatchObject({ domainCode: testCase.domainCode }); + } + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { group: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + expect(legacyPropertySpy).not.toHaveBeenCalled(); + }); + + it('rejects a View owned by another Table without crossing the aggregate boundary', async () => { + const anotherTable = await createTable(baseId, { name: 'group_other_table' }); + try { + const sourceView = (await getViews(table.id))[0]!; + const anotherView = (await getViews(anotherTable.id))[0]!; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: sourceView.id }, + select: { group: true, version: true }, + }); + const legacyPropertySpy = vi.spyOn(viewOpenApiService, 'setViewProperty'); + + const error = await getError(() => + updateViewGroup(table.id, anotherView.id, { group: null }) + ); + + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'view.not_found' }); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: sourceView.id }, + select: { group: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + expect(legacyPropertySpy).not.toHaveBeenCalled(); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } + }); + + it('rejects invalid group directions at the HTTP boundary', async () => { + const view = (await getViews(table.id))[0]!; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { group: true, version: true }, + }); + + const error = await getError(() => + axios.put(`/table/${table.id}/view/${view.id}/group`, { + group: [{ fieldId: table.fields[0].id, order: 'up' }], + }) + ); + + expect(error?.status).toBe(400); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { group: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + }); + }); + + describe('Update View options v2 canary (T6420)', () => { + let previousForceV2All: string | undefined; + const windowIdHeader = 'X-Window-Id'; + + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + axios.defaults.headers.common[windowIdHeader] = 'update-view-options-v2-window'; + }); + + afterEach(() => { + delete axios.defaults.headers.common[windowIdHeader]; + vi.restoreAllMocks(); + if (previousForceV2All == null) delete process.env.FORCE_V2_ALL; + else process.env.FORCE_V2_ALL = previousForceV2All; + }); + + it.each([ + [ViewType.Grid, { rowHeight: RowHeightLevel.Tall, fieldNameDisplayLines: 2 }], + [ViewType.Kanban, { coverFieldId: null, isEmptyStackHidden: true }], + [ViewType.Gallery, { coverFieldId: null, isCoverFit: true }], + [ + ViewType.Calendar, + { + startDateFieldId: null, + colorConfig: { type: ColorConfigType.Custom, color: Colors.Blue }, + }, + ], + [ViewType.Form, { submitLabel: 'Send' }], + ] as const)('updates %s options through the Table aggregate', async (type, options) => { + const created = await createViewApi(table.id, { + name: `Options ${type}`, + type, + }); + const viewId = created.data.id; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: viewId }, + select: { version: true }, + }); + const legacyOptionsSpy = vi + .spyOn(viewOpenApiService, 'patchViewOptions') + .mockRejectedValue(new Error('legacy ViewOpenApiService must not be used')); + const legacyOpsSpy = vi + .spyOn(viewService, 'updateViewByOps') + .mockRejectedValue(new Error('legacy ViewService must not be used')); + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + + const response = await updateViewOptions(table.id, viewId, { options }); + + expect(response.status).toBe(200); + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewOptions'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect((await getView(table.id, viewId)).options).toMatchObject(options); + const persisted = await prismaService.view.findUniqueOrThrow({ + where: { id: viewId }, + select: { options: true, version: true }, + }); + expect(JSON.parse(persisted.options!)).toMatchObject(options); + expect(persisted.version).toBe(rowBefore.version + 1); + expect(legacyOptionsSpy).not.toHaveBeenCalled(); + expect(legacyOpsSpy).not.toHaveBeenCalled(); + expectNoLegacyViewEvent(eventSpy); + }); + + it('updates complete Plugin options and enforces the subtype contract', async () => { + const created = await createViewApi(table.id, { + name: 'Options plugin', + type: ViewType.Plugin, + options: { + pluginId: 'plgsheetform', + pluginInstallId: 'ignored-by-create', + pluginLogo: 'ignored-by-create', + }, + }); + const current = (await getView(table.id, created.data.id)).options as IPluginViewOptions; + const next = { ...current, pluginLogo: 'https://example.test/next-logo.png' }; + const legacyOptionsSpy = vi.spyOn(viewOpenApiService, 'patchViewOptions'); + + const response = await updateViewOptions(table.id, created.data.id, { options: next }); + + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewOptions'); + expect((await getView(table.id, created.data.id)).options).toMatchObject({ + pluginId: next.pluginId, + pluginInstallId: next.pluginInstallId, + }); + expect(legacyOptionsSpy).not.toHaveBeenCalled(); + + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: created.data.id }, + select: { options: true, version: true }, + }); + expect(JSON.parse(rowBefore.options!)).toEqual(next); + const error = await getError(() => + axios.patch(`/table/${table.id}/view/${created.data.id}/options`, { + options: { pluginLogo: 'incomplete.png' }, + }) + ); + expect(error?.status).toBe(400); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: created.data.id }, + select: { options: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + }); + + it('shallow-merges, preserves null, and skips an identical write', async () => { + const created = await createViewApi(table.id, { + name: 'Options merge', + type: ViewType.Gallery, + options: { coverFieldId: table.fields[0].id, isCoverFit: true }, + }); + await updateViewOptions(table.id, created.data.id, { + options: { coverFieldId: null }, + }); + expect((await getView(table.id, created.data.id)).options).toEqual({ + coverFieldId: null, + isCoverFit: true, + }); + const afterClear = await prismaService.view.findUniqueOrThrow({ + where: { id: created.data.id }, + select: { options: true, version: true }, + }); + + const identical = await updateViewOptions(table.id, created.data.id, { + options: { coverFieldId: null }, + }); + expect(identical.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewOptions'); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: created.data.id }, + select: { options: true, version: true }, + }) + ).resolves.toEqual(afterClear); + }); + + it('rejects subtype mismatches without persistence or v1 fallback', async () => { + const view = (await getViews(table.id))[0]!; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { options: true, version: true }, + }); + const legacyOptionsSpy = vi.spyOn(viewOpenApiService, 'patchViewOptions'); + + const error = await getError(() => + updateViewOptions(table.id, view.id, { options: { submitLabel: 'Wrong subtype' } }) + ); + + expect(error?.status).toBe(400); + expect(error?.data).toMatchObject({ domainCode: 'view.options_invalid' }); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { options: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + expect(legacyOptionsSpy).not.toHaveBeenCalled(); + }); + + it('rejects a View owned by another Table without crossing the aggregate boundary', async () => { + const anotherTable = await createTable(baseId, { name: 'options_other_table' }); + try { + const sourceView = (await getViews(table.id))[0]!; + const anotherView = (await getViews(anotherTable.id))[0]!; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: sourceView.id }, + select: { options: true, version: true }, + }); + const legacyOptionsSpy = vi.spyOn(viewOpenApiService, 'patchViewOptions'); + + const error = await getError(() => + updateViewOptions(table.id, anotherView.id, { options: {} }) + ); + + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'view.not_found' }); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: sourceView.id }, + select: { options: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + expect(legacyOptionsSpy).not.toHaveBeenCalled(); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } + }); + }); + + describe('Update View share metadata v2 canary (T6420)', () => { + let previousForceV2All: string | undefined; + + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (previousForceV2All == null) delete process.env.FORCE_V2_ALL; + else process.env.FORCE_V2_ALL = previousForceV2All; + }); + + it('replaces the complete share metadata through the Table aggregate', async () => { + const view = (await getViews(table.id))[0]!; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }); + const legacyShareMetaSpy = vi + .spyOn(viewOpenApiService, 'updateShareMeta') + .mockRejectedValue(new Error('legacy ViewOpenApiService must not be used')); + const legacyOpsSpy = vi + .spyOn(viewService, 'updateViewByOps') + .mockRejectedValue(new Error('legacy ViewService must not be used')); + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + const shareMeta = { + allowCopy: true, + includeHiddenField: true, + password: 'secret-123', + includeRecords: true, + submit: { requireLogin: true }, + allowEdit: true, + }; + + const response = await updateViewShareMeta(table.id, view.id, shareMeta); + + expect(response.status).toBe(200); + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewShareMeta'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect((await getView(table.id, view.id)).shareMeta).toEqual(shareMeta); + const persisted = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { shareMeta: true, version: true }, + }); + expect(JSON.parse(persisted.shareMeta!)).toEqual(shareMeta); + expect(persisted.version).toBe(rowBefore.version + 1); + expect(legacyShareMetaSpy).not.toHaveBeenCalled(); + expect(legacyOpsSpy).not.toHaveBeenCalled(); + expectNoLegacyViewEvent(eventSpy); + }); + + it('persists empty metadata and treats an identical replacement as a no-op', async () => { + const view = (await getViews(table.id))[0]!; + const first = await updateViewShareMeta(table.id, view.id, {}); + expect(first.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewShareMeta'); + const rowAfterFirst = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { shareMeta: true, version: true }, + }); + expect(JSON.parse(rowAfterFirst.shareMeta!)).toEqual({}); + + const identical = await updateViewShareMeta(table.id, view.id, {}); + expect(identical.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewShareMeta'); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { shareMeta: true, version: true }, + }) + ).resolves.toEqual(rowAfterFirst); + }); + + it('rejects a View owned by another Table without crossing the aggregate boundary', async () => { + const anotherTable = await createTable(baseId, { name: 'share_meta_other_table' }); + try { + const sourceView = (await getViews(table.id))[0]!; + const anotherView = (await getViews(anotherTable.id))[0]!; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: sourceView.id }, + select: { shareMeta: true, version: true }, + }); + const legacyShareMetaSpy = vi.spyOn(viewOpenApiService, 'updateShareMeta'); + + const error = await getError(() => + updateViewShareMeta(table.id, anotherView.id, { allowCopy: true }) + ); + + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'view.not_found' }); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: sourceView.id }, + select: { shareMeta: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + expect(legacyShareMetaSpy).not.toHaveBeenCalled(); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } + }); + + it.each([ + [{ password: 'ab' }, 'short password'], + [{ allowCopy: 'yes' }, 'non-boolean flag'], + [{ submit: { requireLogin: 'yes' } }, 'invalid nested submit flag'], + ])('rejects invalid metadata at the HTTP boundary: %s (%s)', async (shareMeta) => { + const view = (await getViews(table.id))[0]!; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { shareMeta: true, version: true }, + }); + + const error = await getError(() => + axios.put(`/table/${table.id}/view/${view.id}/share-meta`, shareMeta) + ); + + expect(error?.status).toBe(400); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { shareMeta: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + }); + }); + + describe('Refresh View share ID v2 canary (T6420)', () => { + let previousForceV2All: string | undefined; + + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (previousForceV2All == null) delete process.env.FORCE_V2_ALL; + else process.env.FORCE_V2_ALL = previousForceV2All; + }); + + it('rotates the share ID through the Table aggregate and revokes the old ID', async () => { + const view = (await getViews(table.id))[0]!; + const enabled = await enableShareView({ tableId: table.id, viewId: view.id }); + const oldShareId = enabled.data.shareId; + const oldShortLink = await createShortLink({ + type: ShortLinkType.ViewShare, + resourceId: oldShareId, + }); + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }); + const legacyRefreshSpy = vi + .spyOn(viewOpenApiService, 'refreshShareId') + .mockRejectedValue(new Error('legacy ViewOpenApiService must not be used')); + const legacyOpsSpy = vi + .spyOn(viewService, 'updateViewByOps') + .mockRejectedValue(new Error('legacy ViewService must not be used')); + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + + const response = await axios.post( + `/table/${table.id}/view/${view.id}/refresh-share-id` + ); + + expect(response.status).toBe(201); + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('refreshViewShareId'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(response.data.shareId).toMatch(/^shr[0-9a-zA-Z]{16}$/); + expect(response.data.shareId).not.toBe(oldShareId); + await expect(getShareView(oldShareId)).rejects.toThrow(); + expect((await getError(() => getShortLink(oldShortLink.data.code)))?.status).toBe(404); + await expect(getShareView(response.data.shareId)).resolves.toMatchObject({ + data: { viewId: view.id, shareId: response.data.shareId }, + }); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { shareId: true, version: true }, + }) + ).resolves.toEqual({ + shareId: response.data.shareId, + version: rowBefore.version + 1, + }); + expect(legacyRefreshSpy).not.toHaveBeenCalled(); + expect(legacyOpsSpy).not.toHaveBeenCalled(); + expectNoLegacyViewEvent(eventSpy); + }); + + it('rejects refreshing a View whose sharing is disabled', async () => { + const view = (await getViews(table.id))[0]!; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { enableShare: true, shareId: true, version: true }, + }); + const legacyRefreshSpy = vi.spyOn(viewOpenApiService, 'refreshShareId'); + + const error = await getError(() => refreshViewShareId(table.id, view.id)); + + expect(error?.status).toBe(400); + expect(error?.data).toMatchObject({ domainCode: 'validation.invalid' }); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { enableShare: true, shareId: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + expect(legacyRefreshSpy).not.toHaveBeenCalled(); + }); + + it('rejects a View owned by another Table without crossing the aggregate boundary', async () => { + const anotherTable = await createTable(baseId, { name: 'refresh_share_other_table' }); + try { + const sourceView = (await getViews(table.id))[0]!; + const anotherView = (await getViews(anotherTable.id))[0]!; + await enableShareView({ tableId: anotherTable.id, viewId: anotherView.id }); + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: sourceView.id }, + select: { shareId: true, version: true }, + }); + const legacyRefreshSpy = vi.spyOn(viewOpenApiService, 'refreshShareId'); + + const error = await getError(() => refreshViewShareId(table.id, anotherView.id)); + + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'view.not_found' }); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: sourceView.id }, + select: { shareId: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + expect(legacyRefreshSpy).not.toHaveBeenCalled(); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } + }); + }); + + describe('Enable and disable View share v2 canary (T6420)', () => { + let previousForceV2All: string | undefined; + + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (previousForceV2All == null) delete process.env.FORCE_V2_ALL; + else process.env.FORCE_V2_ALL = previousForceV2All; + }); + + it.each([ + [ViewType.Grid, { includeRecords: true }], + [ViewType.Kanban, { includeRecords: true }], + [ViewType.Gallery, { includeRecords: true }], + [ViewType.Calendar, { includeRecords: true }], + [ViewType.Form, {}], + [ViewType.Plugin, { includeRecords: true }], + ])('enables %s sharing with its aggregate-owned default metadata', async (type, shareMeta) => { + const created = await createViewApi(table.id, { + name: `Enable ${type}`, + type, + ...(type === ViewType.Plugin + ? { + options: { + pluginId: 'plgsheetform', + pluginInstallId: 'ignored-by-create', + pluginLogo: 'ignored-by-create', + }, + } + : {}), + }); + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: created.data.id }, + select: { version: true }, + }); + const legacyEnableSpy = vi + .spyOn(viewOpenApiService, 'enableShare') + .mockRejectedValue(new Error('legacy ViewOpenApiService must not be used')); + const legacyOpsSpy = vi + .spyOn(viewService, 'updateViewByOps') + .mockRejectedValue(new Error('legacy ViewService must not be used')); + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + + const response = await enableShareView({ tableId: table.id, viewId: created.data.id }); + + expect(response.status).toBe(201); + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('enableViewShare'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(response.data.shareId).toMatch(/^shr[0-9a-zA-Z]{16}$/); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: created.data.id }, + select: { enableShare: true, shareId: true, shareMeta: true, version: true }, + }) + ).resolves.toEqual({ + enableShare: true, + shareId: response.data.shareId, + shareMeta: JSON.stringify(shareMeta), + version: rowBefore.version + 1, + }); + await expect(getShareView(response.data.shareId)).resolves.toMatchObject({ + data: { viewId: created.data.id, shareId: response.data.shareId }, + }); + expect(legacyEnableSpy).not.toHaveBeenCalled(); + expect(legacyOpsSpy).not.toHaveBeenCalled(); + expectNoLegacyViewEvent(eventSpy); + }); + + it('preserves existing share metadata and rejects a repeated enable without writing', async () => { + const created = await createViewApi(table.id, { + name: 'Enable existing metadata', + type: ViewType.Grid, + shareMeta: { allowCopy: false, includeHiddenField: true }, + }); + const legacyEnableSpy = vi.spyOn(viewOpenApiService, 'enableShare'); + + await enableShareView({ tableId: table.id, viewId: created.data.id }); + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: created.data.id }, + select: { enableShare: true, shareId: true, shareMeta: true, version: true }, + }); + const error = await getError(() => + enableShareView({ tableId: table.id, viewId: created.data.id }) + ); + + expect(error?.status).toBe(400); + expect(error?.data).toMatchObject({ domainCode: 'validation.invalid' }); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: created.data.id }, + select: { enableShare: true, shareId: true, shareMeta: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + expect(JSON.parse(rowBefore.shareMeta!)).toEqual({ + allowCopy: false, + includeHiddenField: true, + }); + expect(legacyEnableSpy).not.toHaveBeenCalled(); + }); + + it('serializes concurrent enable and refresh mutations by View version', async () => { + const view = (await getViews(table.id))[0]!; + const rowBeforeEnable = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }); + + const enableResults = await Promise.allSettled([ + enableShareView({ tableId: table.id, viewId: view.id }), + enableShareView({ tableId: table.id, viewId: view.id }), + ]); + const enabled = enableResults.filter( + (result): result is PromiseFulfilledResult>> => + result.status === 'fulfilled' + ); + const enableRejected = enableResults.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ); + + expect(enabled).toHaveLength(1); + expect(enableRejected).toHaveLength(1); + expect(enableRejected[0]?.reason).toMatchObject({ + status: 400, + data: { domainCode: 'view.version_conflict' }, + }); + const enabledShareId = enabled[0]!.value.data.shareId; + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { enableShare: true, shareId: true, version: true }, + }) + ).resolves.toEqual({ + enableShare: true, + shareId: enabledShareId, + version: rowBeforeEnable.version + 1, + }); + await expect(getShareView(enabledShareId)).resolves.toMatchObject({ + data: { viewId: view.id, shareId: enabledShareId }, + }); + + const rowBeforeRefresh = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }); + const refreshResults = await Promise.allSettled([ + refreshViewShareId(table.id, view.id), + refreshViewShareId(table.id, view.id), + ]); + const refreshed = refreshResults.filter( + ( + result + ): result is PromiseFulfilledResult>> => + result.status === 'fulfilled' + ); + const refreshRejected = refreshResults.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ); + + expect(refreshed).toHaveLength(1); + expect(refreshRejected).toHaveLength(1); + expect(refreshRejected[0]?.reason).toMatchObject({ + status: 400, + data: { domainCode: 'view.version_conflict' }, + }); + const refreshedShareId = refreshed[0]!.value.data.shareId; + expect(refreshedShareId).not.toBe(enabledShareId); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { shareId: true, version: true }, + }) + ).resolves.toEqual({ + shareId: refreshedShareId, + version: rowBeforeRefresh.version + 1, + }); + await expect(getShareView(enabledShareId)).rejects.toThrow(); + await expect(getShareView(refreshedShareId)).resolves.toMatchObject({ + data: { viewId: view.id, shareId: refreshedShareId }, + }); + }); + + it('disables sharing, permanently revokes the credential, and re-enables with a new ID', async () => { + const view = (await getViews(table.id))[0]!; + const enabled = await enableShareView({ tableId: table.id, viewId: view.id }); + const oldShareId = enabled.data.shareId; + const oldShortLink = await createShortLink({ + type: ShortLinkType.ViewShare, + resourceId: oldShareId, + }); + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { version: true }, + }); + const legacyDisableSpy = vi + .spyOn(viewOpenApiService, 'disableShare') + .mockRejectedValue(new Error('legacy ViewOpenApiService must not be used')); + const legacyOpsSpy = vi + .spyOn(viewService, 'updateViewByOps') + .mockRejectedValue(new Error('legacy ViewService must not be used')); + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + + const response = await disableShareView({ tableId: table.id, viewId: view.id }); + + expect(response.status).toBe(201); + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('disableViewShare'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { enableShare: true, shareId: true, version: true }, + }) + ).resolves.toEqual({ + enableShare: false, + shareId: oldShareId, + version: rowBefore.version + 1, + }); + await expect(getShareView(oldShareId)).rejects.toThrow(); + expect((await getError(() => getShortLink(oldShortLink.data.code)))?.status).toBe(404); + + const reEnabled = await enableShareView({ tableId: table.id, viewId: view.id }); + expect(reEnabled.data.shareId).not.toBe(oldShareId); + await expect(getShareView(oldShareId)).rejects.toThrow(); + expect(legacyDisableSpy).not.toHaveBeenCalled(); + expect(legacyOpsSpy).not.toHaveBeenCalled(); + expectNoLegacyViewEvent(eventSpy); + }); + + it('rejects repeated disable without changing the persisted View', async () => { + const view = (await getViews(table.id))[0]!; + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { enableShare: true, shareId: true, shareMeta: true, version: true }, }); + const legacyDisableSpy = vi.spyOn(viewOpenApiService, 'disableShare'); - table = (await getTable(baseId, fullTable.id, { includeContent: true })) as ITableFullVo; - }); + const error = await getError(() => disableShareView({ tableId: table.id, viewId: view.id })); - afterAll(async () => { - await permanentDeleteTable(baseId, table.id); - await permanentDeleteTable(baseId, linkTable1.id); - await permanentDeleteTable(baseId, linkTable2.id); + expect(error?.status).toBe(400); + expect(error?.data).toMatchObject({ domainCode: 'validation.invalid' }); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: view.id }, + select: { enableShare: true, shareId: true, shareMeta: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + expect(legacyDisableSpy).not.toHaveBeenCalled(); }); - it('should return filter link records', async () => { - const viewRo: IViewRo = { - name: 'New view', - description: 'the new view', - type: ViewType.Grid, - filter: { - filterSet: [ - { - fieldId: table.fields![1].id, - value: linkTable1.records[0].id, - operator: 'is', - }, - { - filterSet: [ - { - fieldId: table.fields![1].id, - value: [linkTable1.records[1].id, linkTable1.records[2].id], - operator: 'isAnyOf', - }, - ], - conjunction: 'and', - }, - { - fieldId: table.fields![2].id, - value: linkTable2.records[0].id, - operator: 'is', - }, - { - filterSet: [ - { - fieldId: table.fields![2].id, - value: [linkTable2.records[2].id], - operator: 'isAnyOf', - }, - ], - conjunction: 'and', - }, - ], - conjunction: 'and', - }, - }; - - const view = await createView(table.id, viewRo); + it.each([ + ['enable', (tableId: string, viewId: string) => enableShareView({ tableId, viewId })], + ['disable', (tableId: string, viewId: string) => disableShareView({ tableId, viewId })], + ])( + 'rejects cross-Table %s without crossing the aggregate boundary', + async (operation, call) => { + const anotherTable = await createTable(baseId, { name: `${operation}_share_other_table` }); + try { + const anotherView = (await getViews(anotherTable.id))[0]!; + if (operation === 'disable') { + await enableShareView({ tableId: anotherTable.id, viewId: anotherView.id }); + } + const rowBefore = await prismaService.view.findUniqueOrThrow({ + where: { id: anotherView.id }, + select: { enableShare: true, shareId: true, version: true }, + }); - const { data: records } = await getViewFilterLinkRecords(table.id, view.id); + const error = await getError(() => call(table.id, anotherView.id)); - expect(records).toMatchObject([ - { - tableId: linkTable1.id, - records: linkTable1.records.map(({ id, name }) => ({ id, title: name })), - }, - { - tableId: linkTable2.id, - records: [ - { id: linkTable2.records[0].id, title: linkTable2.records[0].name }, - { - id: linkTable2.records[2].id, - title: linkTable2.records[2].name, - }, - ], - }, - ]); - }); + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'view.not_found' }); + await expect( + prismaService.view.findUniqueOrThrow({ + where: { id: anotherView.id }, + select: { enableShare: true, shareId: true, version: true }, + }) + ).resolves.toEqual(rowBefore); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } + } + ); }); describe('/api/table/{tableId}/view/:viewId/column-meta (PUT)', () => { @@ -602,6 +4260,455 @@ describe('OpenAPI ViewController (e2e)', () => { } }); + describe('View socket read endpoints v2 canary (T6420)', () => { + let previousForceV2All: string | undefined; + + const getSocketDocIds = () => + axios.get<{ ids: string[] }>(`/table/${table.id}/view/socket/doc-ids`); + const getSocketSnapshots = (ids?: string[]) => + axios.get< + Array<{ + id: string; + v: number; + type: string; + data: { id: string; name: string; columnMeta: Record }; + }> + >(`/table/${table.id}/view/socket/snapshot-bulk`, { + ...(ids !== undefined ? { params: { ids } } : {}), + }); + + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (previousForceV2All == null) delete process.env.FORCE_V2_ALL; + else process.env.FORCE_V2_ALL = previousForceV2All; + }); + + it('returns ordered doc IDs and requested snapshots from the Table aggregate', async () => { + const first = ( + await createViewApi(table.id, { + name: 'Socket first', + type: ViewType.Grid, + }) + ).data; + const second = ( + await createViewApi(table.id, { + name: 'Socket second', + type: ViewType.Kanban, + }) + ).data; + const activeFieldId = table.fields[0].id; + await prismaService.view.update({ + where: { id: first.id }, + data: { + columnMeta: JSON.stringify({ + [activeFieldId]: { order: 0, width: 220 }, + [`fld${'z'.repeat(16)}`]: { order: 1, width: 300 }, + }), + }, + }); + const legacyDocIdsSpy = vi + .spyOn(viewService, 'getDocIdsByQuery') + .mockRejectedValue(new Error('legacy View doc IDs must not be used')); + const legacySnapshotsSpy = vi + .spyOn(viewService, 'getSnapshotBulk') + .mockRejectedValue(new Error('legacy View snapshots must not be used')); + + const docIdsResponse = await getSocketDocIds(); + const snapshotsResponse = await getSocketSnapshots([second.id, first.id]); + + expect(docIdsResponse.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(docIdsResponse.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getViewSocketDocIds'); + expect(docIdsResponse.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(docIdsResponse.data.ids).toEqual((await getViews(table.id)).map((view) => view.id)); + expect(snapshotsResponse.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(snapshotsResponse.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe( + 'getViewSocketSnapshotBulk' + ); + expect(snapshotsResponse.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(snapshotsResponse.data.map((snapshot) => snapshot.id)).toEqual([second.id, first.id]); + expect(snapshotsResponse.data.map((snapshot) => snapshot.type)).toEqual(['json0', 'json0']); + expect(snapshotsResponse.data[1].data).toMatchObject({ + id: first.id, + name: 'Socket first', + columnMeta: { + [activeFieldId]: { order: 0, width: 220 }, + }, + }); + expect(snapshotsResponse.data[1].data.columnMeta).not.toHaveProperty(`fld${'z'.repeat(16)}`); + expect(legacyDocIdsSpy).not.toHaveBeenCalled(); + expect(legacySnapshotsSpy).not.toHaveBeenCalled(); + }); + + it('projects legacy column metadata entries that are missing order', async () => { + const created = ( + await createViewApi(table.id, { + name: 'Socket legacy column metadata', + type: ViewType.Grid, + }) + ).data; + const field = table.fields.at(-1)!; + + await prismaService.view.update({ + where: { id: created.id }, + data: { + columnMeta: JSON.stringify({ + [field.id]: { width: 241 }, + }), + }, + }); + + const viewFromList = (await getViews(table.id)).find((view) => view.id === created.id); + const snapshot = (await getSocketSnapshots([created.id])).data[0]; + + expect(viewFromList?.columnMeta[field.id]).toEqual({ + order: table.fields.length - 1, + width: 241, + }); + expect(snapshot.data.columnMeta[field.id]).toEqual({ + order: table.fields.length - 1, + width: 241, + }); + }); + + it('returns the persisted View version and advances it after a v2 mutation', async () => { + const created = ( + await createViewApi(table.id, { + name: 'Versioned socket', + type: ViewType.Gallery, + }) + ).data; + + const before = (await getSocketSnapshots([created.id])).data[0]; + await updateViewName(table.id, created.id, { name: 'Versioned socket updated' }); + const after = (await getSocketSnapshots([created.id])).data[0]; + + expect(before.v).toBeGreaterThanOrEqual(1); + expect(after.v).toBe(before.v + 1); + expect(after.data).toMatchObject({ + id: created.id, + name: 'Versioned socket updated', + }); + expect(after.data).not.toHaveProperty('version'); + }); + + it('rejects missing, foreign, deleted, and duplicate View children without using v1', async () => { + const anotherTable = await createTable(baseId, { name: 'socket_other_table' }); + const deleted = ( + await createViewApi(table.id, { + name: 'Deleted socket', + type: ViewType.Grid, + }) + ).data; + await deleteView(table.id, deleted.id); + const foreignViewId = anotherTable.views[0].id; + const existingViewId = table.views[0].id; + const legacySnapshotsSpy = vi.spyOn(viewService, 'getSnapshotBulk'); + + try { + for (const ids of [ + [`viw${'z'.repeat(16)}`], + [foreignViewId], + [deleted.id], + [existingViewId, existingViewId], + ]) { + const error = await getError(() => getSocketSnapshots(ids)); + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'view.not_found' }); + } + expect(legacySnapshotsSpy).not.toHaveBeenCalled(); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } + }); + + it('validates malformed IDs and supports an empty snapshot request entirely in v2', async () => { + const invalidError = await getError(() => getSocketSnapshots(['invalid'])); + const emptyResponse = await getSocketSnapshots(); + + expect(invalidError?.status).toBe(400); + expect(invalidError?.data).toMatchObject({ domainCode: 'validation.invalid' }); + expect(emptyResponse.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getViewSocketSnapshotBulk'); + expect(emptyResponse.data).toEqual([]); + }); + }); + + describe('Plugin View endpoints v2 canary (T6420)', () => { + let previousForceV2All: string | undefined; + + beforeEach(() => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (previousForceV2All == null) delete process.env.FORCE_V2_ALL; + else process.env.FORCE_V2_ALL = previousForceV2All; + }); + + it('installs and reads a Plugin View through v2 with the plugin default name', async () => { + const plugin = await prismaService.plugin.findUniqueOrThrow({ + where: { id: 'plgsheetform' }, + select: { name: true, logo: true }, + }); + const legacyInstallSpy = vi + .spyOn(viewOpenApiService, 'pluginInstall') + .mockRejectedValue(new Error('legacy Plugin View install must not be used')); + const legacyReadSpy = vi + .spyOn(viewOpenApiService, 'getPluginInstall') + .mockRejectedValue(new Error('legacy Plugin View read must not be used')); + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + + const installResponse = await installViewPlugin(table.id, { + pluginId: 'plgsheetform', + }); + const installed = installResponse.data; + + expect(installResponse.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(installResponse.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('installViewPlugin'); + expect(installResponse.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(installed).toMatchObject({ + pluginId: 'plgsheetform', + name: plugin.name, + }); + expect(installed.pluginInstallId).toMatch(/^pli[0-9a-zA-Z]{16}$/); + expect(installed.viewId).toMatch(/^viw[0-9a-zA-Z]{16}$/); + + const view = await getView(table.id, installed.viewId); + expect(view).toMatchObject({ + id: installed.viewId, + name: plugin.name, + type: ViewType.Plugin, + options: { + pluginId: 'plgsheetform', + pluginInstallId: installed.pluginInstallId, + pluginLogo: expect.stringContaining(plugin.logo), + }, + }); + + const readResponse = await getViewInstallPlugin(table.id, installed.viewId); + expect(readResponse.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(readResponse.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('getViewPluginInstall'); + expect(readResponse.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(readResponse.data).toMatchObject({ + baseId, + pluginId: 'plgsheetform', + pluginInstallId: installed.pluginInstallId, + name: plugin.name, + }); + expect(readResponse.data.storage).toBeUndefined(); + expect(legacyInstallSpy).not.toHaveBeenCalled(); + expect(legacyReadSpy).not.toHaveBeenCalled(); + expectNoLegacyViewEvent(eventSpy); + }); + + it('preserves an explicit install name', async () => { + const response = await installViewPlugin(table.id, { + name: 'My sheet', + pluginId: 'plgsheetform', + }); + + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('installViewPlugin'); + expect(response.data.name).toBe('My sheet'); + await expect(getView(table.id, response.data.viewId)).resolves.toMatchObject({ + name: 'My sheet', + }); + }); + + it('rejects a missing plugin without creating a View or installation', async () => { + const viewsBefore = await getViews(table.id); + const legacyInstallSpy = vi.spyOn(viewOpenApiService, 'pluginInstall'); + + const error = await getError(() => + installViewPlugin(table.id, { + pluginId: 'plg-missing-direct-install', + }) + ); + + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'not_found' }); + await expect(getViews(table.id)).resolves.toHaveLength(viewsBefore.length); + expect(legacyInstallSpy).not.toHaveBeenCalled(); + }); + + it('rejects a plugin that does not support the View position', async () => { + const plugin = await createPlugin({ + name: 'Dash-only install', + logo: 'https://example.test/dashboard-only.png', + positions: [PluginPosition.Dashboard], + }); + const viewsBefore = await getViews(table.id); + try { + await submitPlugin(plugin.data.id); + await publishPlugin(plugin.data.id); + const error = await getError(() => + installViewPlugin(table.id, { + pluginId: plugin.data.id, + }) + ); + + expect(error?.status).toBe(400); + expect(error?.data).toMatchObject({ domainCode: 'validation.invalid' }); + await expect(getViews(table.id)).resolves.toHaveLength(viewsBefore.length); + } finally { + await deletePlugin(plugin.data.id); + } + }); + + it('rejects reading a non-Plugin View without bypassing the Table aggregate', async () => { + const view = (await getViews(table.id))[0]!; + const legacyReadSpy = vi.spyOn(viewOpenApiService, 'getPluginInstall'); + + const error = await getError(() => getViewInstallPlugin(table.id, view.id)); + + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'not_found' }); + expect(legacyReadSpy).not.toHaveBeenCalled(); + }); + + it('updates and reads nested plugin storage through v2 Kysely', async () => { + const installed = ( + await installViewPlugin(table.id, { + name: 'Storage sheet', + pluginId: 'plgsheetform', + }) + ).data; + const legacyUpdateSpy = vi + .spyOn(viewOpenApiService, 'updatePluginStorage') + .mockRejectedValue(new Error('legacy Plugin View storage update must not be used')); + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); + const storage = { + version: 2, + sheets: { + sheet1: { + rows: [{ id: 'row-1', values: [true, 42, 'text'] }], + }, + }, + }; + + const response = await updateViewPluginStorage( + table.id, + installed.viewId, + installed.pluginInstallId, + storage + ); + + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewPluginStorage'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + expect(response.data).toEqual({ + tableId: table.id, + viewId: installed.viewId, + pluginInstallId: installed.pluginInstallId, + storage, + }); + await expect(getViewInstallPlugin(table.id, installed.viewId)).resolves.toMatchObject({ + data: { storage }, + }); + await expect( + prismaService.pluginInstall.findUniqueOrThrow({ + where: { id: installed.pluginInstallId }, + select: { storage: true, lastModifiedBy: true }, + }) + ).resolves.toEqual({ + storage: JSON.stringify(storage), + lastModifiedBy: globalThis.testConfig.userId, + }); + expect(legacyUpdateSpy).not.toHaveBeenCalled(); + expectNoLegacyViewEvent(eventSpy); + }); + + it('treats omitted storage as a validated no-op and preserves the existing payload', async () => { + const installed = ( + await installViewPlugin(table.id, { + name: 'No-op storage sheet', + pluginId: 'plgsheetform', + }) + ).data; + const storage = { keep: { nested: true } }; + await updateViewPluginStorage(table.id, installed.viewId, installed.pluginInstallId, storage); + const rowBefore = await prismaService.pluginInstall.findUniqueOrThrow({ + where: { id: installed.pluginInstallId }, + select: { storage: true, lastModifiedTime: true, lastModifiedBy: true }, + }); + + const response = await updateViewPluginStorage( + table.id, + installed.viewId, + installed.pluginInstallId + ); + + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('updateViewPluginStorage'); + expect(response.data).toEqual({ + tableId: table.id, + viewId: installed.viewId, + pluginInstallId: installed.pluginInstallId, + }); + await expect(getViewInstallPlugin(table.id, installed.viewId)).resolves.toMatchObject({ + data: { storage }, + }); + await expect( + prismaService.pluginInstall.findUniqueOrThrow({ + where: { id: installed.pluginInstallId }, + select: { storage: true, lastModifiedTime: true, lastModifiedBy: true }, + }) + ).resolves.toEqual(rowBefore); + }); + + it('rejects cross-Table reads and mismatched installations without changing storage', async () => { + const anotherTable = await createTable(baseId, { name: 'plugin_v2_other_table' }); + try { + const ownPlugin = ( + await installViewPlugin(table.id, { + name: 'Own plugin', + pluginId: 'plgsheetform', + }) + ).data; + const anotherPlugin = ( + await installViewPlugin(anotherTable.id, { + name: 'Other plugin', + pluginId: 'plgsheetform', + }) + ).data; + const legacyReadSpy = vi.spyOn(viewOpenApiService, 'getPluginInstall'); + const legacyUpdateSpy = vi.spyOn(viewOpenApiService, 'updatePluginStorage'); + + const readError = await getError(() => + getViewInstallPlugin(table.id, anotherPlugin.viewId) + ); + const mismatchedError = await getError(() => + updateViewPluginStorage(table.id, ownPlugin.viewId, anotherPlugin.pluginInstallId, { + unauthorized: true, + }) + ); + const crossTableError = await getError(() => + updateViewPluginStorage(table.id, anotherPlugin.viewId, anotherPlugin.pluginInstallId, { + unauthorized: true, + }) + ); + + expect(readError?.status).toBe(404); + expect(mismatchedError?.status).toBe(404); + expect(crossTableError?.status).toBe(404); + expect( + (await getViewInstallPlugin(table.id, ownPlugin.viewId)).data.storage + ).toBeUndefined(); + expect( + (await getViewInstallPlugin(anotherTable.id, anotherPlugin.viewId)).data.storage + ).toBeUndefined(); + expect(legacyReadSpy).not.toHaveBeenCalled(); + expect(legacyUpdateSpy).not.toHaveBeenCalled(); + } finally { + await permanentDeleteTable(baseId, anotherTable.id); + } + }); + }); + describe('view plugin parent binding', () => { let anotherTable: ITableFullVo; @@ -658,7 +4765,17 @@ describe('OpenAPI ViewController (e2e)', () => { describe('/api/table/{tableId}/view/:viewId/duplicate (POST)', () => { let table: ITableFullVo; + let previousForceV2All: string | undefined; + + const expectDuplicateV2 = (response: { headers: Record }) => { + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.headers[X_TEABLE_V2_FEATURE_HEADER]).toBe('duplicateView'); + expect(response.headers[X_TEABLE_V2_REASON_HEADER]).toBe('env_force_v2_all'); + }; + beforeEach(async () => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; table = await createTable(baseId, { name: 'record_query_x_20', fields: x_20.fields, @@ -668,6 +4785,12 @@ describe('OpenAPI ViewController (e2e)', () => { afterEach(async () => { await permanentDeleteTable(baseId, table.id); + vi.restoreAllMocks(); + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } }); it('should reject duplicating a view through another table', async () => { @@ -676,7 +4799,10 @@ describe('OpenAPI ViewController (e2e)', () => { try { const [anotherView] = await getViews(anotherTable.id); - await expect(duplicateView(table.id, anotherView.id)).rejects.toThrow(); + const error = await getError(() => duplicateView(table.id, anotherView.id)); + + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'view.not_found' }); } finally { await permanentDeleteTable(baseId, anotherTable.id); } @@ -686,6 +4812,7 @@ describe('OpenAPI ViewController (e2e)', () => { const view = await createView(table.id, { name: 'grid_view', type: ViewType.Grid, + description: 'duplicate every Grid property', filter: { filterSet: [ { @@ -697,6 +4824,13 @@ describe('OpenAPI ViewController (e2e)', () => { conjunction: 'and', }, isLocked: true, + enableShare: true, + shareId: `shr${'g'.repeat(16)}`, + shareMeta: { + allowCopy: false, + includeHiddenField: true, + submit: { requireLogin: true }, + }, sort: { sortObjs: [ { @@ -704,6 +4838,7 @@ describe('OpenAPI ViewController (e2e)', () => { order: SortFunc.Asc, }, ], + manualSort: false, }, group: [ { @@ -722,6 +4857,16 @@ describe('OpenAPI ViewController (e2e)', () => { }, }); + const legacyDuplicateSpy = vi + .spyOn(viewOpenApiService, 'duplicateView') + .mockRejectedValue(new Error('legacy ViewOpenApiService must not be used')); + const legacyCreateSpy = vi + .spyOn(viewService, 'createView') + .mockRejectedValue(new Error('legacy ViewService.createView must not be used')); + const legacyReadSpy = vi + .spyOn(viewService, 'getViewById') + .mockRejectedValue(new Error('legacy ViewService.getViewById must not be used')); + const eventSpy = vi.spyOn(eventEmitterService, 'emitAsync'); const duplicatedViewResponse = await duplicateView(table.id, view.id); const duplicatedView = duplicatedViewResponse.data; const { dbTableName } = await prismaService.tableMeta.findUniqueOrThrow({ @@ -736,19 +4881,25 @@ describe('OpenAPI ViewController (e2e)', () => { expect(duplicatedView.name).toEqual('grid_view 2'); expect(duplicatedView.type).toEqual(ViewType.Grid); + expect(duplicatedView.description).toEqual(view.description); expect(duplicatedView.filter).toEqual(view.filter); expect(duplicatedView.sort).toEqual(view.sort); expect(duplicatedView.group).toEqual(view.group); expect(duplicatedView.options).toEqual(view.options); expect(duplicatedView.columnMeta).toEqual(view.columnMeta); expect(duplicatedView.isLocked).toBeTruthy(); - const duplicatedViaV2 = duplicatedViewResponse.headers[X_TEABLE_V2_HEADER] === 'true'; - if (isForceV2) { - expect(duplicatedViewResponse.headers[X_TEABLE_V2_HEADER]).toBe('true'); - } - if (duplicatedViaV2) { - expect(duplicatedRowOrderColumn).toBeUndefined(); - } + expect(duplicatedView.enableShare).toBe(true); + expect(duplicatedView.shareMeta).toEqual(view.shareMeta); + expect(duplicatedView.shareId).toMatch(/^shr[0-9a-zA-Z]{16}$/); + expect(duplicatedView.shareId).not.toBe(view.shareId); + expect(duplicatedView.createdBy).toBeTruthy(); + expect(duplicatedView.createdTime).toBeTruthy(); + expect(duplicatedRowOrderColumn).toBeDefined(); + expectDuplicateV2(duplicatedViewResponse); + expect(legacyDuplicateSpy).not.toHaveBeenCalled(); + expect(legacyCreateSpy).not.toHaveBeenCalled(); + expect(legacyReadSpy).not.toHaveBeenCalled(); + expectNoLegacyViewEvent(eventSpy); }); it('should duplicate form view', async () => { @@ -775,12 +4926,15 @@ describe('OpenAPI ViewController (e2e)', () => { }, }); - const duplicatedView = (await duplicateView(table.id, formView.id)).data; + const duplicatedResponse = await duplicateView(table.id, formView.id); + const duplicatedView = duplicatedResponse.data; + expectDuplicateV2(duplicatedResponse); expect(duplicatedView.name).toEqual('form_view 2'); expect(duplicatedView.type).toEqual(ViewType.Form); expect(duplicatedView.options).toEqual(formView.options); expect(duplicatedView.columnMeta).toEqual(initialColumnMeta); + expect(duplicatedView.shareId).toBeUndefined(); }); it('should duplicate gallery view', async () => { @@ -814,7 +4968,9 @@ describe('OpenAPI ViewController (e2e)', () => { }, }); - const duplicatedView = (await duplicateView(table.id, galleryView.id)).data; + const duplicatedResponse = await duplicateView(table.id, galleryView.id); + const duplicatedView = duplicatedResponse.data; + expectDuplicateV2(duplicatedResponse); expect(duplicatedView.name).toEqual('gallery_view 2'); expect(duplicatedView.type).toEqual(ViewType.Gallery); expect(duplicatedView.filter).toEqual(galleryView.filter); @@ -824,6 +4980,39 @@ describe('OpenAPI ViewController (e2e)', () => { }); }); + it('preserves explicit null and false Gallery options without replaying Create defaults', async () => { + const attachmentField = await createField(table.id, { + name: 'Optional cover', + type: FieldType.Attachment, + }); + const galleryView = await createView(table.id, { + name: 'gallery_without_cover', + type: ViewType.Gallery, + options: { + coverFieldId: attachmentField.id, + }, + }); + await prismaService.view.update({ + where: { id: galleryView.id }, + data: { + options: JSON.stringify({ + coverFieldId: null, + isCoverFit: false, + isFieldNameHidden: false, + }), + }, + }); + + const duplicatedResponse = await duplicateView(table.id, galleryView.id); + + expectDuplicateV2(duplicatedResponse); + expect(duplicatedResponse.data.options).toEqual({ + coverFieldId: null, + isCoverFit: false, + isFieldNameHidden: false, + }); + }); + it('should duplicate kanban view', async () => { const kanbanView = await createView(table.id, { name: 'kanban_view', @@ -851,7 +5040,9 @@ describe('OpenAPI ViewController (e2e)', () => { }, }); - const duplicatedView = (await duplicateView(table.id, kanbanView.id)).data; + const duplicatedResponse = await duplicateView(table.id, kanbanView.id); + const duplicatedView = duplicatedResponse.data; + expectDuplicateV2(duplicatedResponse); expect(duplicatedView.name).toEqual('kanban_view 2'); expect(duplicatedView.type).toEqual(ViewType.Kanban); expect(duplicatedView.filter).toEqual(kanbanView.filter); @@ -895,7 +5086,9 @@ describe('OpenAPI ViewController (e2e)', () => { }, }); - const duplicatedView = (await duplicateView(table.id, calendarView.id)).data; + const duplicatedResponse = await duplicateView(table.id, calendarView.id); + const duplicatedView = duplicatedResponse.data; + expectDuplicateV2(duplicatedResponse); expect(duplicatedView.name).toEqual('calendar_view 2'); expect(duplicatedView.type).toEqual(ViewType.Calendar); expect(duplicatedView.filter).toEqual(calendarView.filter); @@ -941,8 +5134,13 @@ describe('OpenAPI ViewController (e2e)', () => { const resolvedInstall = (await getViewInstallPlugin(table.id, sheetView.id)).data; expect(resolvedInstall.pluginInstallId).toBe(sheetPlugin.pluginInstallId); - const duplicatedView = (await duplicateView(table.id, sheetView.id)).data; + const legacyDuplicateSpy = vi + .spyOn(viewOpenApiService, 'duplicateView') + .mockRejectedValue(new Error('Plugin duplication must not fall back to v1')); + const duplicatedResponse = await duplicateView(table.id, sheetView.id); + const duplicatedView = duplicatedResponse.data; const duplicatedInstall = (await getViewInstallPlugin(table.id, duplicatedView.id)).data; + expectDuplicateV2(duplicatedResponse); expect(duplicatedView.name).toEqual('sheet_view 2'); expect(duplicatedView.type).toEqual(ViewType.Plugin); expect(duplicatedView.options).contain({ @@ -953,6 +5151,43 @@ describe('OpenAPI ViewController (e2e)', () => { ); expect(duplicatedInstall.pluginInstallId).not.toBe(sheetPlugin.pluginInstallId); expect(duplicatedInstall.storage).toEqual(storage); + expect(legacyDuplicateSpy).not.toHaveBeenCalled(); + }); + + it('owns numeric suffix collision resolution inside the Table aggregate', async () => { + const source = await createView(table.id, { + name: 'Sprint 2', + type: ViewType.Grid, + }); + await createView(table.id, { + name: 'Sprint 3', + type: ViewType.Grid, + }); + + const response = await duplicateView(table.id, source.id); + + expectDuplicateV2(response); + expect(response.data.name).toBe('Sprint 4'); + }); + + it('fails atomically when the source Plugin installation is missing', async () => { + const plugin = ( + await installViewPlugin(table.id, { + name: 'missing_install_source', + pluginId: 'plgsheetform', + }) + ).data; + const beforeViews = await getViews(table.id); + await prismaService.pluginInstall.delete({ + where: { id: plugin.pluginInstallId }, + }); + + const error = await getError(() => duplicateView(table.id, plugin.viewId)); + const afterViews = await getViews(table.id); + + expect(error?.status).toBe(404); + expect(error?.data).toMatchObject({ domainCode: 'not_found' }); + expect(afterViews.map((view) => view.id)).toEqual(beforeViews.map((view) => view.id)); }); }); @@ -960,8 +5195,11 @@ describe('OpenAPI ViewController (e2e)', () => { let table: ITableFullVo; let view1Id: string; let view2Id: string; + let previousForceV2All: string | undefined; beforeEach(async () => { + previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; table = await createTable(baseId, { name: 'concurrent_test_table' }); const view1 = await createView(table.id, { name: 'View 1', @@ -977,6 +5215,11 @@ describe('OpenAPI ViewController (e2e)', () => { afterEach(async () => { await permanentDeleteTable(baseId, table.id); + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } }); it('should prevent concurrent deletion of the last view using SELECT FOR UPDATE', async () => { diff --git a/apps/nestjs-backend/vitest.setup.ts b/apps/nestjs-backend/vitest.setup.ts index c6f2773240..ad5b844e95 100644 --- a/apps/nestjs-backend/vitest.setup.ts +++ b/apps/nestjs-backend/vitest.setup.ts @@ -4,3 +4,9 @@ import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const bufferModule = require('buffer') as { Buffer: typeof NodeBuffer; SlowBuffer?: unknown }; bufferModule.SlowBuffer ??= bufferModule.Buffer ?? NodeBuffer; + +// Secrets ship no source-code defaults; unit tests seed the ONE root secret — +// every other secret resolves from it (env fallback or per-purpose derivation). +// Deliberately NOT the real dev value: a spec must not depend on any +// particular key, and passes one explicitly when it does. +process.env.SECRET_KEY ??= 'unit-test-secret-key'; diff --git a/apps/nextjs-app/.env.development b/apps/nextjs-app/.env.development index a49ae1cdb0..af19660370 100644 --- a/apps/nextjs-app/.env.development +++ b/apps/nextjs-app/.env.development @@ -33,3 +33,25 @@ BACKEND_CACHE_REDIS_URI=redis://:teable@127.0.0.1:6379/0 API_DOC_DISENABLED=false API_DOC_ENABLED_SNIPPET=false CALC_CHUNK_SIZE=400 +# ── Development secrets (former source-code defaults) ──────────────────────── +# These are the publicly known values that used to be hardcoded fallbacks in +# the source. They moved here so source carries no secret literals; keeping +# the same values preserves existing local dev data (sessions, tokens, +# ciphertext). Production refuses to boot without its own explicit values. +SECRET_KEY=defaultSecretKey +# The \$ escape survives BOTH dotenv parsing and dotenv-expand (v5 strips a +# bare trailing $, and quoting breaks the escape) — verified against the exact +# chain @nestjs/config runs with expandVariables: true. +BACKEND_JWT_SECRET=533Cr3tK3yF0rH4sh1nGJ4W773k3n\$ +BACKEND_SESSION_SECRET=dafea6be69af1c1c3b8caf2b609342f6eb4540b554e19539f7643b75b480c932 +BACKEND_MAIL_ENCRYPTION_KEY=ie21hOKjlXUiGDx1 +BACKEND_MAIL_ENCRYPTION_IV=i0vKGXBWkzyAoGf1 +BACKEND_STORAGE_ENCRYPTION_KEY=73b00476e456323e +BACKEND_STORAGE_ENCRYPTION_IV=8c9183e4c175f63c +BACKEND_ACCESS_TOKEN_ENCRYPTION_KEY=ie21hOKjlXUiGDx9 +BACKEND_ACCESS_TOKEN_ENCRYPTION_IV=i0vKGXBWkzyAoGf4 +# sha256('teable-data-db-url-secret'/-iv) — the exact values the old derivation +# chain produced in dev, pinned so existing local BYODB ciphertext still opens. +BACKEND_DATA_DB_URL_ENCRYPTION_KEY=ed333d03ac334ea2 +BACKEND_DATA_DB_URL_ENCRYPTION_IV=3c50b81e61cb7f52 +SANDBOX_JWT_SECRET=p3CiEjfiAaa8B6pfVww33hMhm4Vv7aP0fqf3XWTL5X8= diff --git a/apps/nextjs-app/.env.example b/apps/nextjs-app/.env.example index 5ba0ef1705..c71e88b608 100644 --- a/apps/nextjs-app/.env.example +++ b/apps/nextjs-app/.env.example @@ -136,6 +136,10 @@ BACKEND_MAIL_AUTH_PASS=usertoken BACKEND_SESSION_EXPIRES_IN=7d # session secret, default is SECRET_KEY BACKEND_SESSION_SECRET=your_session_secret +# verify-only fallback while rotating BACKEND_SESSION_SECRET. express-session +# accepts both (new one signs, both validate), so live sessions survive the +# rotation. Remove once existing session cookies have aged out (7d default). +# BACKEND_SESSION_SECRET_OLD=your_previous_session_secret # enable Origin and Fetch Metadata checks for unsafe browser session-cookie API requests, default is false # enable only when your reverse proxy or CDN preserves these request headers BACKEND_SESSION_ORIGIN_CHECK_ENABLED=false @@ -144,7 +148,37 @@ BACKEND_SESSION_ORIGIN_CHECK_ENABLED=false BACKEND_JWT_EXPIRES_IN=20d # jwt secret, default is SECRET_KEY BACKEND_JWT_SECRET=your_jwt_secret - +# verify-only fallback while rotating BACKEND_JWT_SECRET: new tokens are signed +# with the new secret, existing tokens verify against either (session-style +# secret array), so a PLANNED rotation is seamless everywhere. Remove once +# tokens signed with the previous secret have aged out (up to 30d for OAuth +# refresh tokens). If the old secret LEAKED, do NOT set this — hard-cut instead +# (users re-login / verification codes are re-sent). +# BACKEND_JWT_SECRET_OLD=your_previous_jwt_secret + +# ── Secrets ────────────────────────────────────────────────────────────────── +# Every secret needs its own dedicated env var — source code carries NO +# built-in defaults anymore, and the app refuses to start without SECRET_KEY +# and the encryption vars below (SECRET_KEY does NOT substitute for them; +# the storage pair is only required with the local storage provider); the +# startup error explains how an existing deployment pins its previous +# values. KEY / IV values must be exactly 16 characters (aes-128-cbc), e.g. +# `openssl rand -hex 8`; other secrets e.g. `openssl rand -base64 32`. +# The root secret (required): +SECRET_KEY=your_secret_key +# encrypts email unsubscribe-link tokens +BACKEND_MAIL_ENCRYPTION_KEY=your_16_char_key +BACKEND_MAIL_ENCRYPTION_IV=your_16_char_iv0 +# encrypts attachment access tokens — only read (and only required) when +# BACKEND_STORAGE_PROVIDER is 'local'; cloud storage uses presigned URLs +BACKEND_STORAGE_ENCRYPTION_KEY=your_16_char_key +BACKEND_STORAGE_ENCRYPTION_IV=your_16_char_iv0 +# encrypts personal access tokens +BACKEND_ACCESS_TOKEN_ENCRYPTION_KEY=your_16_char_key +BACKEND_ACCESS_TOKEN_ENCRYPTION_IV=your_16_char_iv0 +# encrypts BYODB external database URLs +BACKEND_DATA_DB_URL_ENCRYPTION_KEY=your_16_char_key +BACKEND_DATA_DB_URL_ENCRYPTION_IV=your_16_char_iv0 # reset password email expires in, default is 30m BACKEND_RESET_PASSWORD_EMAIL_EXPIRES_IN=30m diff --git a/apps/nextjs-app/.env.test b/apps/nextjs-app/.env.test index adbc124534..9ad945ba44 100644 --- a/apps/nextjs-app/.env.test +++ b/apps/nextjs-app/.env.test @@ -25,3 +25,25 @@ ENABLE_GLOBAL_ERROR_LOGGING=true API_DOC_DISENABLED=false CALC_CHUNK_SIZE=400 ENABLE_CANARY_FEATURE=true +# ── Development secrets (former source-code defaults) ──────────────────────── +# These are the publicly known values that used to be hardcoded fallbacks in +# the source. They moved here so source carries no secret literals; keeping +# the same values preserves existing local dev data (sessions, tokens, +# ciphertext). Production refuses to boot without its own explicit values. +SECRET_KEY=defaultSecretKey +# The \$ escape survives BOTH dotenv parsing and dotenv-expand (v5 strips a +# bare trailing $, and quoting breaks the escape) — verified against the exact +# chain @nestjs/config runs with expandVariables: true. +BACKEND_JWT_SECRET=533Cr3tK3yF0rH4sh1nGJ4W773k3n\$ +BACKEND_SESSION_SECRET=dafea6be69af1c1c3b8caf2b609342f6eb4540b554e19539f7643b75b480c932 +BACKEND_MAIL_ENCRYPTION_KEY=ie21hOKjlXUiGDx1 +BACKEND_MAIL_ENCRYPTION_IV=i0vKGXBWkzyAoGf1 +BACKEND_STORAGE_ENCRYPTION_KEY=73b00476e456323e +BACKEND_STORAGE_ENCRYPTION_IV=8c9183e4c175f63c +BACKEND_ACCESS_TOKEN_ENCRYPTION_KEY=ie21hOKjlXUiGDx9 +BACKEND_ACCESS_TOKEN_ENCRYPTION_IV=i0vKGXBWkzyAoGf4 +# sha256('teable-data-db-url-secret'/-iv) — the exact values the old derivation +# chain produced in dev, pinned so existing local BYODB ciphertext still opens. +BACKEND_DATA_DB_URL_ENCRYPTION_KEY=ed333d03ac334ea2 +BACKEND_DATA_DB_URL_ENCRYPTION_IV=3c50b81e61cb7f52 +SANDBOX_JWT_SECRET=p3CiEjfiAaa8B6pfVww33hMhm4Vv7aP0fqf3XWTL5X8= diff --git a/apps/nextjs-app/src/backend/api/rest/ssr-api.ts b/apps/nextjs-app/src/backend/api/rest/ssr-api.ts index c735621f19..83af7895d2 100644 --- a/apps/nextjs-app/src/backend/api/rest/ssr-api.ts +++ b/apps/nextjs-app/src/backend/api/rest/ssr-api.ts @@ -83,6 +83,7 @@ import { GET_TEMPLATE_PERMALINK, GET_SHORT_LINK, } from '@teable/openapi'; +import { INITIAL_LOAD_PAGE_SIZE } from '@teable/sdk/utils/record-window'; import type { AxiosInstance } from 'axios'; import { getAxios } from './axios'; @@ -148,6 +149,9 @@ export class SsrApi { viewId, fieldKeyType: FieldKeyType.Id, groupBy: currentView?.group ? JSON.stringify(currentView.group) : undefined, + // must equal the grid's first window size — the seeded rows back + // that query verbatim, and any gap renders as blank rows + take: INITIAL_LOAD_PAGE_SIZE, }, }); }) diff --git a/apps/nextjs-app/src/components/changelog/ChangelogNotification.tsx b/apps/nextjs-app/src/components/changelog/ChangelogNotification.tsx deleted file mode 100644 index 6d32e10e7f..0000000000 --- a/apps/nextjs-app/src/components/changelog/ChangelogNotification.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { ArrowUpRight, X } from '@teable/icons'; -import { LocalStorageKeys } from '@teable/sdk/config'; -import { useIsHydrated, useIsReadOnlyPreview } from '@teable/sdk/hooks'; -import { Button } from '@teable/ui-lib/shadcn'; -import { Rocket } from 'lucide-react'; -import { useTranslation } from 'next-i18next'; -import { useCallback, useEffect, useState } from 'react'; -import { useIsCloud } from '@/features/app/hooks/useIsCloud'; - -export const ChangelogNotification = () => { - const { t } = useTranslation('common'); - const isHydrated = useIsHydrated(); - const isCloud = useIsCloud(); - const isReadOnlyPreview = useIsReadOnlyPreview(); - const [visible, setVisible] = useState(false); - - const changelogId = t('changelog.id'); - const title = t('changelog.title'); - const url = t('changelog.url'); - - useEffect(() => { - if (!changelogId) return; - try { - const dismissedId = localStorage.getItem(LocalStorageKeys.DismissedChangelog); - if (dismissedId !== changelogId) { - setVisible(true); - } - } catch { - // ignore - } - }, [changelogId, title]); - - const handleDismiss = useCallback(() => { - setVisible(false); - try { - localStorage.setItem(LocalStorageKeys.DismissedChangelog, changelogId); - } catch { - // ignore - } - }, [changelogId]); - - if (!isCloud || !isHydrated || !visible || isReadOnlyPreview) { - return null; - } - - return ( - - ); -}; diff --git a/apps/nextjs-app/src/components/changelog/index.ts b/apps/nextjs-app/src/components/changelog/index.ts deleted file mode 100644 index 77b5b8b6a1..0000000000 --- a/apps/nextjs-app/src/components/changelog/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ChangelogNotification } from './ChangelogNotification'; diff --git a/apps/nextjs-app/src/features/app/base-node/DashBoardPage.tsx b/apps/nextjs-app/src/features/app/base-node/DashBoardPage.tsx index 4c207bc46a..5d14a62fa0 100644 --- a/apps/nextjs-app/src/features/app/base-node/DashBoardPage.tsx +++ b/apps/nextjs-app/src/features/app/base-node/DashBoardPage.tsx @@ -2,6 +2,7 @@ import { dehydrate } from '@tanstack/react-query'; import { BaseNodeResourceType, LastVisitResourceType } from '@teable/openapi'; import { ReactQueryKeys } from '@teable/sdk/config'; import dynamic from 'next/dynamic'; +import { PlainPageSkeleton } from '@/features/app/components/PlainPageSkeleton'; import type { IBaseResourceParsed } from '@/features/app/hooks/useBaseResource'; import { redirect } from './helper'; import type { ISSRContext, SSRResult } from './types'; @@ -73,6 +74,9 @@ const DynamicDashboard = dynamic( () => import('@/features/app/dashboard/Pages').then((mod) => mod.DashboardPage), { ssr: false, + // Rendered into the SSR HTML and kept until the dashboard chunk hydrates, + // matching the base-entry transition overlay's plain variant + loading: () => , } ); diff --git a/apps/nextjs-app/src/features/app/base-node/TablePage.tsx b/apps/nextjs-app/src/features/app/base-node/TablePage.tsx index bad5ebe48c..a621e9d8b3 100644 --- a/apps/nextjs-app/src/features/app/base-node/TablePage.tsx +++ b/apps/nextjs-app/src/features/app/base-node/TablePage.tsx @@ -4,9 +4,10 @@ import type { IViewVo } from '@teable/core'; import { BaseNodeResourceType, LastVisitResourceType } from '@teable/openapi'; import { ReactQueryKeys } from '@teable/sdk/config'; import dynamic from 'next/dynamic'; +import { TableSkeleton } from '@/features/app/blocks/table/TableSkeleton'; import type { IBaseResourceParsed } from '@/features/app/hooks/useBaseResource'; import { getViewPageServerData } from '@/lib/view-pages-data'; -import { getDefaultViewId, redirect, validateResourceExists } from './helper'; +import { getDefaultNodeUrl, getDefaultViewId, redirect, validateResourceExists } from './helper'; import type { ISSRContext, SSRResult, ITablePageProps } from './types'; interface IQueryParams { @@ -66,7 +67,17 @@ export const getTableServerSideProps = async ( : null, ]); - if (tableList.length === 0) return { notFound: true }; + // The base has no tables left (e.g. a prefetched entry URL outlived the + // deletion of the base's only table) — degrade instead of 404ing. Resolve + // the default node ourselves with every table node filtered out: a stale + // node-list cache can otherwise still name a deleted table, and bouncing + // through /base/{baseId} would loop back here until the cache expires. + if (tableList.length === 0) { + const nonTableUrl = await getDefaultNodeUrl(ctx, { + filterNode: (node) => node.resourceType !== BaseNodeResourceType.Table, + }); + return redirect(nonTableUrl ?? `/base/${baseId}`); + } // If table doesn't exist, redirect to default node const validationResult = await validateResourceExists(ctx, { @@ -147,6 +158,9 @@ const DynamicTable = dynamic( () => import('@/features/app/blocks/table/Table').then((mod) => mod.Table), { ssr: false, + // Rendered into the SSR HTML and kept until the table chunk hydrates, so a + // hard refresh shows the same shell as the space→base transition overlay + loading: () => , } ); diff --git a/apps/nextjs-app/src/features/app/blocks/archive/TableArchiveDialog.tsx b/apps/nextjs-app/src/features/app/blocks/archive/TableArchiveDialog.tsx new file mode 100644 index 0000000000..d3369aa580 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/archive/TableArchiveDialog.tsx @@ -0,0 +1,7 @@ +export interface ITableArchiveDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + tableId: string; +} + +export const TableArchiveDialog = (_props: ITableArchiveDialogProps) => null; 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 dada38dedb..7ac22aba7b 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 @@ -43,6 +43,7 @@ import { import { toast } from '@teable/ui-lib/shadcn/ui/sonner'; import { AppWindowMacIcon, + Archive, CopyPlus, FileInputIcon, Info, @@ -54,9 +55,12 @@ import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import type { ReactNode } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react'; +import { useArchiveUpsell } from '@/features/app/hooks/useArchiveUpsell'; import { useBaseResource } from '@/features/app/hooks/useBaseResource'; +import { useBaseUsage } from '@/features/app/hooks/useBaseUsage'; import { useSetting } from '@/features/app/hooks/useSetting'; import { tableConfig } from '@/features/i18n/table.config'; +import { TableArchiveDialog } from '@overridable/TableArchiveDialog'; import { LoginAppWarning } from '../../../components/LoginAppWarning'; import { useDownload } from '../../../hooks/useDownLoad'; import { TableImport } from '../../import-table'; @@ -475,6 +479,14 @@ export const TableOperation = (props: ITableOperationProps) => { const basePermission = useBasePermission(); const canTableRecordHistoryRead = basePermission?.['table_record_history|read']; const canTableTrashRead = basePermission?.['table|trash_read']; + const baseUsage = useBaseUsage(); + const { + archiveUnlocked, + badge: archiveUpgradeBadge, + needsUpgrade: archiveNeedsUpgrade, + handleUpgradeClick: onArchiveUpgradeClick, + } = useArchiveUpsell(baseUsage); + const canTableArchiveRead = Boolean(basePermission?.['table|archive_read'] && archiveUnlocked); const node = useNode(resourceId); const nodeId = node?.id ?? ''; const loginApps = useMemo(() => { @@ -487,6 +499,7 @@ export const TableOperation = (props: ITableOperationProps) => { const [apiDialogOpen, setApiDialogOpen] = useState(false); const [tableHistoryDialogOpen, setTableHistoryDialogOpen] = useState(false); const [tableTrashDialogOpen, setTableTrashDialogOpen] = useState(false); + const [tableArchiveDialogOpen, setTableArchiveDialogOpen] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(false); const [importVisible, setImportVisible] = useState(false); const [duplicateSetting, setDuplicateSetting] = useState(false); @@ -519,8 +532,9 @@ export const TableOperation = (props: ITableOperationProps) => { basePermission, canTableRecordHistoryRead, canTableTrashRead, + canTableArchiveRead, }), - [basePermission, canTableRecordHistoryRead, canTableTrashRead, node, table] + [basePermission, canTableRecordHistoryRead, canTableTrashRead, canTableArchiveRead, node, table] ); const shareNodeId = menuPermission.shareTable && node ? node.id : undefined; @@ -719,6 +733,14 @@ export const TableOperation = (props: ITableOperationProps) => { /> )} + {menuPermission.tableArchive && !archiveNeedsUpgrade && ( + + )} + {apiDialogOpen && ( API @@ -797,7 +819,7 @@ export const TableOperation = (props: ITableOperationProps) => { className="h-auto w-full justify-start gap-3 rounded-none border-b p-3" > - {t('table:table.tableRecordHistory')} + {t('sdk:noun.recordHistory')} { className="h-auto w-full justify-start gap-3 rounded-none border-b p-3" > - {t('table:tableTrash.title')} + {t('common:noun.trash')} { )} - {(menuPermission.tableRecordHistory || menuPermission.tableTrash) && ( + {(menuPermission.tableRecordHistory || + menuPermission.tableTrash || + menuPermission.tableArchive) && ( @@ -953,7 +977,7 @@ export const TableOperation = (props: ITableOperationProps) => { }} > - {t('table:table.tableRecordHistory')} + {t('sdk:noun.recordHistory')} )} {menuPermission.tableTrash && ( @@ -963,7 +987,26 @@ export const TableOperation = (props: ITableOperationProps) => { }} > - {t('table:tableTrash.title')} + {t('common:noun.trash')} + + )} + {menuPermission.tableArchive && ( + { + if (archiveNeedsUpgrade) { + onArchiveUpgradeClick(); + return; + } + setTableArchiveDialogOpen(true); + }} + > + + {t('table:tableArchive.menuTitle')} + {archiveUpgradeBadge && ( + + {archiveUpgradeBadge} + + )} )} diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeMore.utils.ts b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeMore.utils.ts index b0247ea449..38a751e897 100644 --- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeMore.utils.ts +++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeMore.utils.ts @@ -46,6 +46,7 @@ interface ITableOperationPermissionInput { basePermission?: PermissionMap; canTableRecordHistoryRead?: boolean; canTableTrashRead?: boolean; + canTableArchiveRead?: boolean; } export const getTableOperationMenuPermission = ({ @@ -54,6 +55,7 @@ export const getTableOperationMenuPermission = ({ basePermission, canTableRecordHistoryRead, canTableTrashRead, + canTableArchiveRead, }: ITableOperationPermissionInput) => { const hasReadyTable = Boolean(table); @@ -69,6 +71,7 @@ export const getTableOperationMenuPermission = ({ importTable: Boolean(table?.permission?.['table|import']), tableRecordHistory: Boolean(hasReadyTable && canTableRecordHistoryRead), tableTrash: Boolean(hasReadyTable && canTableTrashRead), + tableArchive: Boolean(hasReadyTable && canTableArchiveRead), shareTable: Boolean(basePermission?.['base|update']), apiTable: hasReadyTable, }; diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx index d54086f745..f11b41d341 100644 --- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx +++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx @@ -40,6 +40,7 @@ import { createPortal } from 'react-dom'; import { useClickAway, useLocalStorage } from 'react-use'; import { Emoji } from '@/features/app/components/emoji/Emoji'; import { EmojiPicker } from '@/features/app/components/emoji/EmojiPicker'; +import { collapseChatPanelIfExpanded } from '@/features/app/components/sidebar/useChatPanelStore'; import { useShareUrlPrefix } from '@/features/app/context/ShareContext'; import { useBaseResource } from '@/features/app/hooks/useBaseResource'; import { useDisableAIAction } from '@/features/app/hooks/useDisableAIAction'; @@ -253,6 +254,7 @@ export const BaseNodeTree = (props: IBaseNodeTreeProps) => { const viewId = tableViewIdsMap[resourceId]; const url = tableHrefMap[resourceId]; if (url) { + collapseChatPanelIfExpanded(); router.push({ pathname: url }, undefined, { shallow: !isSharePage && Boolean(viewId), }); @@ -267,6 +269,9 @@ export const BaseNodeTree = (props: IBaseNodeTreeProps) => { urlPrefix: shareUrlPrefix, }); if (!url) return; + // A folder never reaches here (getNodeUrl returns null for it), so this + // only fires for node clicks that actually navigate to content + collapseChatPanelIfExpanded(); // Table URLs built here have no view id (hrefMap not ready yet); only a // non-shallow navigation runs getServerSideProps, which redirects to the // last-visited/default view. A shallow push would strand the page on a @@ -591,7 +596,9 @@ export const BaseNodeTree = (props: IBaseNodeTreeProps) => { {resourceType === BaseNodeResourceType.Table && ( curdHooks.updateNode(nodeId, { icon })} + onRemove={() => curdHooks.updateNode(nodeId, { icon: null })} disabled={!canUpdateTable} > {icon ? : } diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseShareContent.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseShareContent.tsx index faf01cd1af..e7fd0cf9fa 100644 --- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseShareContent.tsx +++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseShareContent.tsx @@ -64,6 +64,7 @@ export interface IBaseShareContentProps { isCreateLoading?: boolean; isDeleteLoading?: boolean; isRefreshLoading?: boolean; + disabled?: boolean; permissionOptions: IPermissionOption[]; onToggleShare: (enabled: boolean) => void; onUpdateSetting: (data: Record) => void; @@ -80,6 +81,7 @@ export const BaseShareContent = ({ isCreateLoading, isDeleteLoading, isRefreshLoading, + disabled, permissionOptions, onToggleShare, onUpdateSetting, @@ -130,6 +132,21 @@ export const BaseShareContent = ({
{isCreateLoading ? ( + ) : disabled ? ( + + + + {/* Focusable so keyboard users can reach the tooltip; the disabled switch itself can't take focus */} + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */} + + + + + +

{t('baseShare.noPermissionTip')}

+
+
+
) : ( )} @@ -144,8 +161,11 @@ export const BaseShareContent = ({
{t('baseShare.linkHolderLabel')} - - @@ -196,7 +216,7 @@ export const BaseShareContent = ({ size="icon-sm" className="shrink-0" onClick={onRefreshShare} - disabled={isRefreshLoading} + disabled={isRefreshLoading || disabled} > {isRefreshLoading ? ( @@ -222,6 +242,7 @@ export const BaseShareContent = ({ onUpdateSetting({ allowCopy: checked })} />
{renderWinFreeCredit && renderWinFreeCredit(base.spaceId)} - + {renderAnnouncementCard && renderAnnouncementCard()} ); }; 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 d37d110548..1c40ea904a 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 @@ -273,7 +273,7 @@ export const BaseSidebarHeaderLeft = ({ creditUsage }: { creditUsage?: React.Rea setTimeout(() => inputRef.current?.focus(), 200); }; - const hasUpdatePermission = hasPermission(base.role, 'base|update'); + const hasUpdatePermission = !base.restrictedAuthority && hasPermission(base.role, 'base|update'); const backSpace = () => { if (isReadOnlyPreview) { 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 054ff3e047..c90a7cd7e4 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 @@ -15,6 +15,7 @@ import { updateBaseShare, } from '@teable/openapi'; import { ReactQueryKeys } from '@teable/sdk/config'; +import { useBasePermission } from '@teable/sdk/hooks'; import { Spin } from '@teable/ui-lib'; import { Button, @@ -174,6 +175,8 @@ export const NodeShareContent = ({ }) => { const { t } = useTranslation(['common', 'table']); const queryClient = useQueryClient(); + const basePermission = useBasePermission(); + const canManageShare = Boolean(basePermission?.['base|update']); const { sharedNodeIds } = useSharedNodeIds(); const isNodeShared = sharedNodeIds.has(nodeId); @@ -299,6 +302,7 @@ export const NodeShareContent = ({ isCreateLoading={isCreateLoading} isDeleteLoading={isDeleteLoading} isRefreshLoading={isRefreshLoading} + disabled={!canManageShare} permissionOptions={permissionOptions} onToggleShare={() => createShare({ nodeId })} onUpdateSetting={handleUpdateSetting} diff --git a/apps/nextjs-app/src/features/app/blocks/share/base/share-base-ssr.ts b/apps/nextjs-app/src/features/app/blocks/share/base/share-base-ssr.ts index 252c188224..f8f38a3c9b 100644 --- a/apps/nextjs-app/src/features/app/blocks/share/base/share-base-ssr.ts +++ b/apps/nextjs-app/src/features/app/blocks/share/base/share-base-ssr.ts @@ -116,7 +116,9 @@ export const createShareBaseSSR = async ( baseId: baseIdStr, ssrApi, getTranslationsProps: () => - getTranslationsProps(context, i18nNamespaces ?? baseAllConfig.i18nNamespaces), + getTranslationsProps(context, [ + ...new Set([...(i18nNamespaces ?? baseAllConfig.i18nNamespaces), 'auth' as const]), + ]), base, }; diff --git a/apps/nextjs-app/src/features/app/blocks/share/view/ShareSignInButton.tsx b/apps/nextjs-app/src/features/app/blocks/share/view/ShareSignInButton.tsx index 1a3aa529bd..c8da3461e3 100644 --- a/apps/nextjs-app/src/features/app/blocks/share/view/ShareSignInButton.tsx +++ b/apps/nextjs-app/src/features/app/blocks/share/view/ShareSignInButton.tsx @@ -1,41 +1,69 @@ import { isAnonymous } from '@teable/core'; import { ShareViewContext } from '@teable/sdk/context'; import { useSession } from '@teable/sdk/hooks'; +import { Badge, Button } from '@teable/ui-lib/shadcn'; +import Link from 'next/link'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import { useContext } from 'react'; +import { TeableLogo } from '@/components/TeableLogo'; +import { MobileShareOperationBar } from '@/features/app/components/share-operation/MobileShareOperationBar'; +import { useBrand } from '@/features/app/hooks/useBrand'; import { shareConfig } from '@/features/i18n/share.config'; -/** - * Inline "sign in to edit" hint shown next to the view name in each share - * view's title bar. Only renders for anonymous viewers on an allowEdit share. - * The whole amber pill is clickable; its color is the affordance. - */ -export const ShareSignInButton = () => { - const { shareMeta } = useContext(ShareViewContext); +const useShareViewSignIn = () => { + const { shareId, shareMeta } = useContext(ShareViewContext); const { user } = useSession(); - const { t } = useTranslation(shareConfig.i18nNamespaces); const router = useRouter(); - const visible = Boolean(shareMeta?.allowEdit) && isAnonymous(user?.id); - if (!visible) return null; - const handleSignIn = () => { - const loginUrl = `/auth/login?redirect=${encodeURIComponent(router.asPath)}`; - router.push(loginUrl); + router.push(`/auth/login?redirect=${encodeURIComponent(window.location.href)}`); }; - // The i18n value still carries `` markers for the "sign in" verb (kept in - // case we later want to re-emphasize it); strip them here for plain rendering. - const label = t('share:view.signInToEdit').replace(/<\/?a>/g, ''); + return { shareId, visible, handleSignIn }; +}; + +export const ShareViewHeader = ({ viewName }: { viewName?: string }) => { + const { t } = useTranslation(shareConfig.i18nNamespaces); + const { visible, handleSignIn } = useShareViewSignIn(); + const { brandName } = useBrand(); + const editLabel = t('share:view.signInToEdit').replace(/<\/?a>/g, ''); + const authLabel = `${t('auth:button.signin')}/${t('auth:button.signup')}`; return ( - +
+
+

{viewName}

+ {visible && ( + + {editLabel} + + )} +
+
+ {visible && ( + + )} + + +

{brandName}

+ +
+
); }; + +export const ShareViewMobileSignIn = () => { + const { shareId, visible, handleSignIn } = useShareViewSignIn(); + + if (!visible) return null; + + return ; +}; diff --git a/apps/nextjs-app/src/features/app/blocks/share/view/ShareView.tsx b/apps/nextjs-app/src/features/app/blocks/share/view/ShareView.tsx index 3d928dee1c..50fd457bc0 100644 --- a/apps/nextjs-app/src/features/app/blocks/share/view/ShareView.tsx +++ b/apps/nextjs-app/src/features/app/blocks/share/view/ShareView.tsx @@ -8,6 +8,7 @@ import { GalleryView } from './component/gallery/GalleryView'; import { GridView } from './component/grid/GridView'; import { KanbanView } from './component/kanban/KanbanView'; import { PluginView } from './component/plugin/SharePluginView'; +import { ShareViewMobileSignIn } from './ShareSignInButton'; export const ShareView = () => { const { view, shareId, extra } = useContext(ShareViewContext); @@ -36,6 +37,7 @@ export const ShareView = () => { return (
{getViewComponent()}
+
); diff --git a/apps/nextjs-app/src/features/app/blocks/share/view/component/calendar/CalendarView.tsx b/apps/nextjs-app/src/features/app/blocks/share/view/component/calendar/CalendarView.tsx index 80c44966a9..2449662598 100644 --- a/apps/nextjs-app/src/features/app/blocks/share/view/component/calendar/CalendarView.tsx +++ b/apps/nextjs-app/src/features/app/blocks/share/view/component/calendar/CalendarView.tsx @@ -1,39 +1,24 @@ -/* eslint-disable @next/next/no-html-link-for-pages */ import { RecordProvider, ShareViewContext } from '@teable/sdk/context'; import { SearchProvider } from '@teable/sdk/context/query'; import { useIsHydrated } from '@teable/sdk/hooks'; import { cn } from '@teable/ui-lib/shadcn'; import { useRouter } from 'next/router'; import { useContext } from 'react'; -import { TeableLogo } from '@/components/TeableLogo'; import { CalendarViewBase } from '@/features/app/blocks/view/calendar/CalendarViewBase'; import { CalendarProvider } from '@/features/app/blocks/view/calendar/context'; -import { useBrand } from '@/features/app/hooks/useBrand'; -import { ShareSignInButton } from '../../ShareSignInButton'; +import { ShareViewHeader } from '../../ShareSignInButton'; import { CalendarToolbar } from './toolbar'; export const CalendarView = () => { const { view } = useContext(ShareViewContext); const isHydrated = useIsHydrated(); - const { brandName } = useBrand(); const { query: { hideToolBar, embed }, } = useRouter(); return (
- {!embed && ( - - )} + {!embed && }
diff --git a/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/GalleryView.tsx b/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/GalleryView.tsx index 1917460cd1..9a1112aadd 100644 --- a/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/GalleryView.tsx +++ b/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/GalleryView.tsx @@ -2,14 +2,11 @@ import { RecordProvider, RowCountProvider, ShareViewContext } from '@teable/sdk/ import { SearchProvider } from '@teable/sdk/context/query'; import { useIsHydrated } from '@teable/sdk/hooks'; import { cn } from '@teable/ui-lib/shadcn'; -import Link from 'next/link'; import { useRouter } from 'next/router'; import { useContext } from 'react'; -import { TeableLogo } from '@/components/TeableLogo'; import { GalleryProvider } from '@/features/app/blocks/view/gallery/context'; import { GalleryViewBase } from '@/features/app/blocks/view/gallery/GalleryViewBase'; -import { useBrand } from '@/features/app/hooks/useBrand'; -import { ShareSignInButton } from '../../ShareSignInButton'; +import { ShareViewHeader } from '../../ShareSignInButton'; import { GalleryToolbar } from './toolbar'; export const GalleryView = () => { @@ -18,21 +15,9 @@ export const GalleryView = () => { const { query: { hideToolBar, embed }, } = useRouter(); - const { brandName } = useBrand(); return (
- {!embed && ( -
-
-

{view?.name}

- -
- - -

{brandName}

- -
- )} + {!embed && }
diff --git a/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridView.tsx b/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridView.tsx index 8b0585ec88..900f91f99b 100644 --- a/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridView.tsx +++ b/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridView.tsx @@ -7,38 +7,23 @@ import { import { SearchProvider } from '@teable/sdk/context/query'; import { useIsHydrated } from '@teable/sdk/hooks'; import { cn } from '@teable/ui-lib/shadcn'; -import Link from 'next/link'; import { useRouter } from 'next/router'; import { useContext } from 'react'; -import { TeableLogo } from '@/components/TeableLogo'; -import { useBrand } from '@/features/app/hooks/useBrand'; import { EmbedFooter } from '../../EmbedFooter'; -import { ShareSignInButton } from '../../ShareSignInButton'; +import { ShareViewHeader } from '../../ShareSignInButton'; import { GridViewBase } from './GridViewBase'; import { Toolbar } from './toolbar'; export const GridView = () => { const { records, view, extra } = useContext(ShareViewContext); const isHydrated = useIsHydrated(); - const { brandName } = useBrand(); const { query: { hideToolBar, embed }, } = useRouter(); return (
- {!embed && ( -
-
-

{view?.name}

- -
- - -

{brandName}

- -
- )} + {!embed && }
diff --git a/apps/nextjs-app/src/features/app/blocks/share/view/component/kanban/KanbanView.tsx b/apps/nextjs-app/src/features/app/blocks/share/view/component/kanban/KanbanView.tsx index 90a5942062..1b9f0fe4d2 100644 --- a/apps/nextjs-app/src/features/app/blocks/share/view/component/kanban/KanbanView.tsx +++ b/apps/nextjs-app/src/features/app/blocks/share/view/component/kanban/KanbanView.tsx @@ -2,39 +2,24 @@ import { GroupPointProvider, RecordProvider, ShareViewContext } from '@teable/sd import { SearchProvider } from '@teable/sdk/context/query'; import { useIsHydrated } from '@teable/sdk/hooks'; import { cn } from '@teable/ui-lib/shadcn'; -import Link from 'next/link'; import { useRouter } from 'next/router'; import { useContext } from 'react'; -import { TeableLogo } from '@/components/TeableLogo'; import { KanbanProvider } from '@/features/app/blocks/view/kanban/context'; import { KanbanViewBase } from '@/features/app/blocks/view/kanban/KanbanViewBase'; -import { useBrand } from '@/features/app/hooks/useBrand'; import { EmbedFooter } from '../../EmbedFooter'; -import { ShareSignInButton } from '../../ShareSignInButton'; +import { ShareViewHeader } from '../../ShareSignInButton'; import { KanbanToolbar } from './toolbar'; export const KanbanView = () => { const { view } = useContext(ShareViewContext); const isHydrated = useIsHydrated(); - const { brandName } = useBrand(); const { query: { hideToolBar, embed }, } = useRouter(); return (
- {!embed && ( -
-
-

{view?.name}

- -
- - -

{brandName}

- -
- )} + {!embed && }
diff --git a/apps/nextjs-app/src/features/app/blocks/space/BaseCard.tsx b/apps/nextjs-app/src/features/app/blocks/space/BaseCard.tsx index 011fe166a7..966d64095b 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/BaseCard.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/BaseCard.tsx @@ -73,7 +73,7 @@ export const BaseCard: FC = (props) => { e.stopPropagation(); }; - const iconChange = (icon: string) => { + const iconChange = (icon: string | null) => { updateBaseMutator({ baseId: base.id, updateBaseRo: { icon }, @@ -109,7 +109,12 @@ export const BaseCard: FC = (props) => {
hasUpdatePermission && clickStopPropagation(e)}> - + iconChange(null)} + >
{base.icon ? : }
diff --git a/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx b/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx index 50285a264a..b4c0b59d13 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx @@ -34,7 +34,7 @@ export interface IBaseItemProps { dragHandleListeners?: Record; onToggleExpand?: () => void; onEnterBase?: () => void; - onUpdate?: (data: { name?: string; icon?: string }) => void; + onUpdate?: (data: { name?: string; icon?: string | null }) => void; onDelete?: (permanent?: boolean) => void; } @@ -131,7 +131,9 @@ export const BaseItem: FC = (props) => { onUpdate?.({ icon })} + onRemove={() => onUpdate?.({ icon: null })} > {base.icon ? : } diff --git a/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx b/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx index 2caceaea26..085decab99 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx @@ -32,6 +32,7 @@ import { useTranslation } from 'next-i18next'; import { useState, useMemo, useCallback } from 'react'; import { useLocalStorage } from 'react-use'; import { spaceConfig } from '@/features/i18n/space.config'; +import { useBaseEntryMap } from '../../hooks/useBaseEntryMap'; import { BaseNodeProvider } from '../base/base-node/BaseNodeProvider'; import { getNodeUrl } from '../base/base-node/hooks'; import { BaseNodeTree } from '../base/base-side-bar/BaseNodeTree'; @@ -79,6 +80,9 @@ export const BaseList = (props: IBaseListProps) => { ); const allBaseList = useBaseList(); + // warm the entry-URL map for this space's bases so clicking a card + // navigates straight to the final table/view URL (consumed in useEnterBase) + useBaseEntryMap(spaceId); const { map: lastVisitBaseMap = {} } = useLastVisitBase(); const { data: space } = useQuery({ diff --git a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/PinItem.tsx b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/PinItem.tsx index 033af13598..750a2bc7be 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/PinItem.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/PinItem.tsx @@ -16,10 +16,12 @@ interface IPinItemProps { className?: string; right?: React.ReactNode; pin: IGetPinListVo[number]; + /** full entry pathname from usePinEntryMap — navigate straight there when present */ + entryUrl?: string; } export const PinItem = (props: IPinItemProps) => { - const { className, pin, right } = props; + const { className, pin, right, entryUrl } = props; const router = useRouter(); const { enterBase, enterBaseOverlay } = useEnterBase(); @@ -64,18 +66,20 @@ export const PinItem = (props: IPinItemProps) => { interceptEnter( e, { id: pin.id, name: pin.name, icon: pin.icon }, - { pathname: '/base/[baseId]', query: { baseId: pin.id } } + entryUrl ?? { pathname: '/base/[baseId]', query: { baseId: pin.id } } ) } > @@ -99,13 +103,13 @@ export const PinItem = (props: IPinItemProps) => { {enterBaseOverlay} interceptEnter( e, { id: pin.parentBaseId! }, - `/base/${pin.parentBaseId}/table/${pin.id}` + entryUrl ?? `/base/${pin.parentBaseId}/table/${pin.id}` ) } > diff --git a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/PinList.tsx b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/PinList.tsx index 271cd1a54d..bf998a2c76 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/PinList.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/PinList.tsx @@ -9,6 +9,7 @@ import { DndKitContext, Draggable, Droppable } from '@teable/ui-lib/base'; import { cn, ScrollArea } from '@teable/ui-lib/shadcn'; import { useTranslation } from 'next-i18next'; import { spaceConfig } from '@/features/i18n/space.config'; +import { usePinEntryMap } from '../../../hooks/usePinEntryMap'; import { PinItem } from './PinItem'; import { StarButton } from './StarButton'; @@ -21,6 +22,9 @@ export const PinList = (props: { className?: string }) => { queryKey: ReactQueryKeys.pinList(), queryFn: () => getPinList().then((data) => data.data), }); + // warm the entry-URL map so pin clicks navigate straight to the final + // table/view URL (independent request — the pin list never waits on it) + const { data: pinEntryMap } = usePinEntryMap(); const { mutate: updateOrder } = useMutation({ mutationFn: updatePinOrder, @@ -92,6 +96,7 @@ export const PinList = (props: { className?: string }) => { { React.ReactNode; renderWinFreeCredit?: (spaceId: string) => React.ReactNode; + renderAnnouncementCard?: () => React.ReactNode; }) => { - const { renderSettingModal, renderWinFreeCredit } = props; + const { renderSettingModal, renderWinFreeCredit, renderAnnouncementCard } = props; const router = useRouter(); const { t } = useTranslation(spaceConfig.i18nNamespaces); const { spaceId } = useParams<{ spaceId: string }>(); @@ -146,7 +146,7 @@ export const SpaceInnerSideBar = (props: {
{renderWinFreeCredit && renderWinFreeCredit(spaceId)} - + {renderAnnouncementCard && renderAnnouncementCard()} {spaceId && ( = ({ space, isActive }) => { placeholder="name" maxLength={SPACE_NAME_MAX_LENGTH} defaultValue={space.name} - className="rounded-none absolute left-0 top-0 size-full cursor-text px-4" + className="absolute left-0 top-0 size-full cursor-text rounded-none px-4" onKeyDown={async (e) => { if (e.key === 'Enter') { if (e.currentTarget.value && e.currentTarget.value !== space.name) diff --git a/apps/nextjs-app/src/features/app/blocks/space/useEnterBase.tsx b/apps/nextjs-app/src/features/app/blocks/space/useEnterBase.tsx index 17429c1932..f7652cfdd3 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/useEnterBase.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/useEnterBase.tsx @@ -1,6 +1,7 @@ import type { UrlObject } from 'url'; +import { useQueryClient } from '@tanstack/react-query'; import { ChevronsLeft } from '@teable/icons'; -import { Spin } from '@teable/ui-lib/base'; +import type { IBaseEntryMapVo } from '@teable/openapi'; import { Skeleton } from '@teable/ui-lib/shadcn'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; @@ -8,6 +9,8 @@ import { useCallback, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { TeableLogo } from '@/components/TeableLogo'; import { Emoji } from '../../components/emoji/Emoji'; +import { PlainPageSkeleton } from '../../components/PlainPageSkeleton'; +import { TableSkeleton } from '../table/TableSkeleton'; /** * The subset of a base the transition shell can render; name/icon may be @@ -97,30 +100,7 @@ const EnterBaseOverlay = ({
{/* Main area — table variant mocks the table page; plain shows a neutral frame */} -
- {variant === 'table' ? ( - <> -
- -
-
- -
-
- - - -
- - ) : ( - <> -
-
- -
- - )} -
+ {variant === 'table' ? : }
, document.body ); @@ -132,12 +112,37 @@ const EnterBaseOverlay = ({ */ export const useEnterBase = () => { const router = useRouter(); + const queryClient = useQueryClient(); const [entering, setEntering] = useState<{ base: IEnterBaseTarget; variant: IEnterBaseVariant; } | null>(null); const enteringRef = useRef(false); + // Upgrade a bare /base/{id} destination to the prefetched final URL (last + // visited table/view) — the click then pays a single SSR round instead of + // the redirect chain. Pull only: read at click time, nothing navigates on + // data arrival; no entry → undefined → the bare URL and its redirect chain + // remain the fallback. + const resolveEntryUrl = useCallback( + (baseId: string): string | undefined => { + // prefix-matches ReactQueryKeys.baseEntryMap(spaceId); freshest map + // wins. The map refetches on every list mount, so at worst a click that + // races the refetch lands on the previous entry — the same staleness + // class as the redirect chain itself, and it self-heals next time. + const queries = queryClient + .getQueryCache() + .findAll({ queryKey: ['base-entry-map'] }) + .sort((a, b) => b.state.dataUpdatedAt - a.state.dataUpdatedAt); + for (const query of queries) { + const url = (query.state.data as IBaseEntryMapVo | undefined)?.[baseId]; + if (url) return url; + } + return undefined; + }, + [queryClient] + ); + const enterBase = useCallback( async ( base: IEnterBaseTarget, @@ -147,10 +152,16 @@ export const useEnterBase = () => { // Ignore re-entry (double clicks, clicks landing on the overlay) so an // in-flight navigation is never aborted and restarted if (enteringRef.current) return; + const isBareBaseUrl = + !url || + (typeof url === 'string' && url === `/base/${base.id}`) || + (typeof url === 'object' && url.pathname === '/base/[baseId]'); + const destination = + (isBareBaseUrl ? resolveEntryUrl(base.id) : undefined) ?? url ?? `/base/${base.id}`; enteringRef.current = true; setEntering({ base, variant }); try { - await router.push(url ?? `/base/${base.id}`); + await router.push(destination); } catch { // Navigation cancelled or failed — restore the page } finally { @@ -158,7 +169,7 @@ export const useEnterBase = () => { setEntering(null); } }, - [router] + [router, resolveEntryUrl] ); // Clicking the overlay's logo returns to the base list underneath: starting a diff --git a/apps/nextjs-app/src/features/app/blocks/table-list/TableListItem.tsx b/apps/nextjs-app/src/features/app/blocks/table-list/TableListItem.tsx index 7783d28331..b3ee08a0f5 100644 --- a/apps/nextjs-app/src/features/app/blocks/table-list/TableListItem.tsx +++ b/apps/nextjs-app/src/features/app/blocks/table-list/TableListItem.tsx @@ -74,7 +74,9 @@ export const TableListItem: React.FC = ({
e.stopPropagation()}> table.updateIcon(icon)} + onRemove={() => table.updateIcon(null)} disabled={!table.permission?.['table|update']} > {table.icon ? ( diff --git a/apps/nextjs-app/src/features/app/blocks/table/TableSkeleton.tsx b/apps/nextjs-app/src/features/app/blocks/table/TableSkeleton.tsx new file mode 100644 index 0000000000..7c95c06035 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/table/TableSkeleton.tsx @@ -0,0 +1,23 @@ +import { Skeleton } from '@teable/ui-lib/shadcn'; + +/** + * Mirrors the table page main area (header / toolbar / rows). Shared by the + * base-entry transition overlay and DynamicTable's loading fallback so the + * space→base transition and a hard refresh read identically. Must stay free + * of table-chunk imports — it renders while that chunk is still downloading. + */ +export const TableSkeleton = () => ( +
+
+ +
+
+ +
+
+ + + +
+
+); diff --git a/apps/nextjs-app/src/features/app/blocks/table/hooks/use-table-seed.ts b/apps/nextjs-app/src/features/app/blocks/table/hooks/use-table-seed.ts index 281f0c4a27..9abf191aa0 100644 --- a/apps/nextjs-app/src/features/app/blocks/table/hooks/use-table-seed.ts +++ b/apps/nextjs-app/src/features/app/blocks/table/hooks/use-table-seed.ts @@ -3,6 +3,7 @@ import { FieldKeyType, ViewType } from '@teable/core'; import type { IRecordsVo } from '@teable/openapi'; import { getFields, getRecords, getViewList } from '@teable/openapi'; import { ReactQueryKeys } from '@teable/sdk'; +import { INITIAL_LOAD_PAGE_SIZE } from '@teable/sdk/utils/record-window'; /** * Bootstrap data for a client-side table switch. @@ -27,7 +28,13 @@ export const useTableSeed = (tableId: string, viewId: string, enabled: boolean) refetchOnWindowFocus: false, refetchOnReconnect: false, queryFn: async () => { - const recordsQuery = { viewId, fieldKeyType: FieldKeyType.Id } as const; + // take must equal the grid's first window size — the seeded rows back + // that query verbatim, and any gap renders as blank rows + const recordsQuery = { + viewId, + fieldKeyType: FieldKeyType.Id, + take: INITIAL_LOAD_PAGE_SIZE, + } as const; const [fields, views, plainRecords] = await Promise.all([ getFields(tableId, { viewId }).then((res) => res.data), getViewList(tableId).then((res) => res.data), diff --git a/apps/nextjs-app/src/features/app/blocks/table/table-header/TableInfo.tsx b/apps/nextjs-app/src/features/app/blocks/table/table-header/TableInfo.tsx index c40f149396..8460603f4e 100644 --- a/apps/nextjs-app/src/features/app/blocks/table/table-header/TableInfo.tsx +++ b/apps/nextjs-app/src/features/app/blocks/table/table-header/TableInfo.tsx @@ -83,7 +83,9 @@ export const TableInfo: React.FC = (props: ITableInfoProps) => {connected && !isImporting ? ( table?.updateIcon(icon)} + onRemove={() => table?.updateIcon(null)} disabled={!canUpdateTable} > {icon} @@ -93,7 +95,7 @@ export const TableInfo: React.FC = (props: ITableInfoProps) => )}
diff --git a/apps/nextjs-app/src/features/app/blocks/trash/components/TableTrash.tsx b/apps/nextjs-app/src/features/app/blocks/trash/components/TableTrash.tsx index 83bed839b3..ff92402a05 100644 --- a/apps/nextjs-app/src/features/app/blocks/trash/components/TableTrash.tsx +++ b/apps/nextjs-app/src/features/app/blocks/trash/components/TableTrash.tsx @@ -1,10 +1,10 @@ -import type { QueryFunctionContext } from '@tanstack/react-query'; import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import type { ColumnDef } from '@tanstack/react-table'; import type { IRestoreFieldTrashStreamDoneEvent, IRestoreFieldTrashStreamErrorEvent, IRestoreFieldTrashStreamProgressEvent, + ITableTrashItemsFilter, ITrashVo, ITableTrashItemVo, IViewSnapshotItemVo, @@ -18,9 +18,16 @@ import { TableTrashType, } from '@teable/openapi'; import { CollaboratorWithHoverCard, InfiniteTable } from '@teable/sdk/components'; +import type { IDateRangeValue } from '@teable/sdk/components/filter/view-filter/component/filterDatePicker/DateRangePicker'; import { VIEW_ICON_MAP } from '@teable/sdk/components/view/constant'; import { ReactQueryKeys } from '@teable/sdk/config'; -import { useBase, useBasePermission, useFieldStaticGetter, useIsHydrated } from '@teable/sdk/hooks'; +import { + useBase, + useBasePermission, + useCollaboratorFilterUsers, + useFieldStaticGetter, + useIsHydrated, +} from '@teable/sdk/hooks'; import { Button } from '@teable/ui-lib/shadcn'; import { toast } from '@teable/ui-lib/shadcn/ui/sonner'; import dayjs from 'dayjs'; @@ -29,15 +36,13 @@ import { Fragment, useCallback, useMemo, useRef, useState } from 'react'; import { tableConfig } from '@/features/i18n/table.config'; import type { SelectionActionDialogStatus } from '../../view/grid/components/SelectionActionProgressDialog'; import { RestoreFieldTrashProgressDialog } from './RestoreFieldTrashProgressDialog'; +import { TableTrashFilterBar } from './TableTrashFilterBar'; +import { TrashRecordsDialog } from './TrashRecordsDialog'; interface ITableTrashProps { tableId: string; } -// A bulk deletion can put tens of thousands of resources into one trash item; -// rendering them all would create as many DOM nodes in a single cell. -const MAX_DISPLAY_RESOURCE_COUNT = 100; - export const TableTrash = (props: ITableTrashProps) => { const { tableId } = props; const { t } = useTranslation(tableConfig.i18nNamespaces); @@ -50,9 +55,13 @@ export const TableTrash = (props: ITableTrashProps) => { const hasRestorePermission = permission?.['table|trash_update']; const useV2RestoreField = base?.v2Status?.useV2 ?? Boolean(base?.isCanary); - const [nextCursor, setNextCursor] = useState(); - const [userMap, setUserMap] = useState({}); - const [resourceMap, setResourceMap] = useState({}); + const [resourceTypes, setResourceTypes] = useState([]); + const [deletedByIds, setDeletedByIds] = useState([]); + const [deletedDateRange, setDeletedDateRange] = useState(null); + // The item stays set while the close animation plays; only `recordsDialogOpen` drives + // the dialog, otherwise the closing dialog flashes empty. + const [viewingItem, setViewingItem] = useState(null); + const [recordsDialogOpen, setRecordsDialogOpen] = useState(false); const [restoreDialogOpen, setRestoreDialogOpen] = useState(false); const [restoreProgress, setRestoreProgress] = useState(null); @@ -65,31 +74,60 @@ export const TableTrash = (props: ITableTrashProps) => { const restoreErrorsRef = useRef([]); const restoreProgressRef = useRef(null); - const queryFn = async ({ - queryKey, - pageParam, - }: QueryFunctionContext) => { - const res = await getTrashItems({ - resourceType: TrashType.Table, - resourceId: queryKey[1] as string, - cursor: pageParam, - }); - const { trashItems, nextCursor } = res.data; - setNextCursor(() => nextCursor); - setUserMap({ ...userMap, ...res.data.userMap }); - setResourceMap({ ...resourceMap, ...res.data.resourceMap }); - return trashItems; - }; + const trashQuery = useMemo( + () => ({ + ...(resourceTypes.length ? { resourceTypes } : {}), + ...(deletedByIds.length ? { deletedBy: deletedByIds } : {}), + ...(deletedDateRange?.exactDate ? { deletedTimeStart: deletedDateRange.exactDate } : {}), + ...(deletedDateRange?.exactDateEnd ? { deletedTimeEnd: deletedDateRange.exactDateEnd } : {}), + }), + [resourceTypes, deletedByIds, deletedDateRange] + ); - const { data, isFetching, isLoading, fetchNextPage } = useInfiniteQuery({ - queryKey: ReactQueryKeys.getTrashItems(tableId), - queryFn, + const { data, isFetching, isLoading, hasNextPage, fetchNextPage } = useInfiniteQuery({ + queryKey: ReactQueryKeys.getTrashItems(tableId, trashQuery), + queryFn: ({ pageParam }) => + getTrashItems({ + resourceType: TrashType.Table, + resourceId: tableId, + cursor: pageParam, + ...trashQuery, + }).then((res) => res.data), refetchOnMount: 'always', refetchOnWindowFocus: false, initialPageParam: undefined as string | undefined, - getNextPageParam: () => nextCursor, + getNextPageParam: (lastPage: ITrashVo) => lastPage.nextCursor ?? undefined, + }); + + const allRows = useMemo( + () => (data ? data.pages.flatMap((page) => page.trashItems) : []) as ITableTrashItemVo[], + [data] + ); + + const userMap = useMemo(() => { + const map: ITrashVo['userMap'] = {}; + data?.pages.forEach((page) => Object.assign(map, page.userMap)); + return map; + }, [data]); + + const resourceMap = useMemo(() => { + const map: ITrashVo['resourceMap'] = {}; + data?.pages.forEach((page) => Object.assign(map, page.resourceMap)); + return map; + }, [data]); + + const { users: filterUsers, setUserSearch } = useCollaboratorFilterUsers({ + selectedIds: deletedByIds, + userMap, }); + const onFilterReset = useCallback(() => { + setResourceTypes([]); + setDeletedByIds([]); + setDeletedDateRange(null); + setUserSearch(''); + }, [setUserSearch]); + const { mutateAsync: mutateRestore } = useMutation({ mutationFn: (props: { trashId: string }) => restoreTrash(props.trashId, tableId), onSuccess: () => { @@ -163,10 +201,10 @@ export const TableTrash = (props: ITableTrashProps) => { [mutateRestore, restoreFieldTrash, useV2RestoreField] ); - const allRows = useMemo( - () => (data ? data.pages.flatMap((d) => d) : []) as ITableTrashItemVo[], - [data] - ); + const handleViewRecords = useCallback((item: ITableTrashItemVo) => { + setViewingItem(item); + setRecordsDialogOpen(true); + }, []); const columns: ColumnDef[] = useMemo(() => { const result: ColumnDef[] = [ @@ -178,52 +216,63 @@ export const TableTrash = (props: ITableTrashProps) => { cell: ({ row }) => { const resourceType = row.getValue('resourceType'); const resourceIds = row.getValue('resourceIds'); - const resourceList = resourceIds + const isRecord = resourceType === TableTrashType.Record; + // The server only returns a preview of each item's resources; the rest are + // represented by the total count. + const displayList = resourceIds .map((resourceId) => { return resourceMap[resourceId]; }) .filter(Boolean); - const displayList = resourceList.slice(0, MAX_DISPLAY_RESOURCE_COUNT); - const hiddenCount = resourceList.length - displayList.length; - return ( + const hiddenCount = row.original.totalResourceCount - displayList.length; + const chips = ( - {resourceList.length ? ( -
- {displayList.map((resource) => { - const { id, name } = resource; - const Icon = - resourceType === TableTrashType.Field - ? getFieldStatic((resource as IFieldSnapshotItemVo).type, { - isLookup: Boolean((resource as IFieldSnapshotItemVo).isLookup), - isConditionalLookup: Boolean( - (resource as IFieldSnapshotItemVo).isConditionalLookup - ), - hasAiConfig: false, - }).Icon - : resourceType === TableTrashType.View - ? VIEW_ICON_MAP[(resource as IViewSnapshotItemVo).type] - : null; - return ( -
- {Icon && } - {name || t('sdk:common.unnamedRecord')} -
- ); - })} - {hiddenCount > 0 && ( - - {t('table:tableTrash.moreResources', { count: hiddenCount })} - - )} -
- ) : ( - {t('sdk:common.empty')} + {displayList.map((resource) => { + const { id, name } = resource; + const Icon = + resourceType === TableTrashType.Field + ? getFieldStatic((resource as IFieldSnapshotItemVo).type, { + isLookup: Boolean((resource as IFieldSnapshotItemVo).isLookup), + isConditionalLookup: Boolean( + (resource as IFieldSnapshotItemVo).isConditionalLookup + ), + hasAiConfig: false, + }).Icon + : resourceType === TableTrashType.View + ? VIEW_ICON_MAP[(resource as IViewSnapshotItemVo).type] + : null; + return ( + + {Icon && } + {name || t('sdk:common.unnamedRecord')} + + ); + })} + {hiddenCount > 0 && ( + + {t('table:tableTrash.moreResources', { count: hiddenCount })} + )}
); + if (!displayList.length && hiddenCount <= 0) { + return {t('sdk:common.empty')}; + } + // Record rows: the whole chip block is one click target opening the records grid. + return isRecord ? ( + + ) : ( +
{chips}
+ ); }, }, { @@ -308,23 +357,43 @@ export const TableTrash = (props: ITableTrashProps) => { getFieldStatic, restoringTrashId, handleRestore, + handleViewRecords, ]); const fetchNextPageInner = useCallback(() => { - if (!isFetching && nextCursor) { + if (!isFetching && hasNextPage) { fetchNextPage(); } - }, [fetchNextPage, isFetching, nextCursor]); + }, [fetchNextPage, isFetching, hasNextPage]); if (!isHydrated || isLoading) return null; return ( - <> - + +
+ +
+ { status={restoreStatus} onOpenChange={setRestoreDialogOpen} /> - +
); }; diff --git a/apps/nextjs-app/src/features/app/blocks/trash/components/TableTrashFilterBar.tsx b/apps/nextjs-app/src/features/app/blocks/trash/components/TableTrashFilterBar.tsx new file mode 100644 index 0000000000..48cfb57f2d --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/trash/components/TableTrashFilterBar.tsx @@ -0,0 +1,135 @@ +import type { IItemBaseCollaboratorUser } from '@teable/openapi'; +import { TableTrashType } from '@teable/openapi'; +import { UserAvatar, UserOption } from '@teable/sdk/components'; +import { BaseMultipleSelect } from '@teable/sdk/components/filter/view-filter/component/base'; +import type { IDateRangeValue } from '@teable/sdk/components/filter/view-filter/component/filterDatePicker/DateRangePicker'; +import { DateRangePicker } from '@teable/sdk/components/filter/view-filter/component/filterDatePicker/DateRangePicker'; +import { Button } from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import { useCallback, useMemo } from 'react'; +import { tableConfig } from '@/features/i18n/table.config'; + +interface ITrashTypeOption { + value: TableTrashType; + label: string; +} + +interface ITrashUserOption { + value: string; + label: string; + email?: string; + avatar?: string | null; +} + +interface ITableTrashFilterBarProps { + users: IItemBaseCollaboratorUser[]; + resourceTypes: TableTrashType[]; + deletedByIds: string[]; + dateRange: IDateRangeValue | null; + onResourceTypesChange: (value: TableTrashType[]) => void; + onDeletedByIdsChange: (value: string[]) => void; + onDateRangeChange: (value: IDateRangeValue | null) => void; + onUserSearch: (value: string) => void; + onReset: () => void; +} + +export const TableTrashFilterBar = (props: ITableTrashFilterBarProps) => { + const { + users, + resourceTypes, + deletedByIds, + dateRange, + onResourceTypesChange, + onDeletedByIdsChange, + onDateRangeChange, + onUserSearch, + onReset, + } = props; + const { t } = useTranslation(tableConfig.i18nNamespaces); + + const typeOptions = useMemo( + () => [ + { value: TableTrashType.View, label: t('noun.view') }, + { value: TableTrashType.Field, label: t('noun.field') }, + { value: TableTrashType.Record, label: t('noun.record') }, + ], + [t] + ); + + const userOptions = useMemo( + () => + users.map((user) => ({ + value: user.id, + label: user.name, + email: user.email, + avatar: user.avatar, + })), + [users] + ); + + const renderUserOption = useCallback((option: ITrashUserOption) => { + return ( + + ); + }, []); + + const renderUserDisplay = useCallback((option: ITrashUserOption) => { + return ( +
+ + {option.label} +
+ ); + }, []); + + const hasFilter = resourceTypes.length > 0 || deletedByIds.length > 0 || dateRange != null; + + return ( +
+
+ + + +
+ {hasFilter && ( + + )} +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/trash/components/TrashRecordsDialog.tsx b/apps/nextjs-app/src/features/app/blocks/trash/components/TrashRecordsDialog.tsx new file mode 100644 index 0000000000..12c377a798 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/trash/components/TrashRecordsDialog.tsx @@ -0,0 +1,222 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import type { + IGetTrashItemRecordsVo, + ITableTrashItemVo, + ITrashItemRecordVo, + IUserMapVo, +} from '@teable/openapi'; +import { getTrashItemRecords } from '@teable/openapi'; +import { + RecordSnapshotExpandDialog, + RecordSnapshotGrid, + useRecordSnapshotFields, +} from '@teable/sdk/components'; +import type { IGridRef, IRecordSnapshotSystemColumn } from '@teable/sdk/components'; +import { BaseMultipleSelect } from '@teable/sdk/components/filter/view-filter/component/base'; +import type { IDateRangeValue } from '@teable/sdk/components/filter/view-filter/component/filterDatePicker/DateRangePicker'; +import { DateRangePicker } from '@teable/sdk/components/filter/view-filter/component/filterDatePicker/DateRangePicker'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import { useCollaboratorFilterUsers } from '@teable/sdk/hooks'; +import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from '@teable/ui-lib/shadcn'; +import dayjs from 'dayjs'; +import { useTranslation } from 'next-i18next'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { tableConfig } from '@/features/i18n/table.config'; + +const TRASH_TIME_FORMAT = 'YYYY/MM/DD HH:mm'; + +interface ITrashRecordsFilter { + recordCreatedBy?: string[]; + recordCreatedTimeStart?: string; + recordCreatedTimeEnd?: string; +} + +interface ITrashRecordsDialogProps { + tableId: string; + trashItem: ITableTrashItemVo | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const TrashRecordsDialog = (props: ITrashRecordsDialogProps) => { + const { tableId, trashItem, open, onOpenChange } = props; + const { t } = useTranslation(tableConfig.i18nNamespaces); + const trashId = trashItem?.id; + + // The item stays set while the close animation plays; only `expandOpen` drives the dialog, + // otherwise the closing dialog flashes empty. + const [expandedItem, setExpandedItem] = useState(null); + const [expandOpen, setExpandOpen] = useState(false); + + const [createdByIds, setCreatedByIds] = useState([]); + const [createdDateRange, setCreatedDateRange] = useState(null); + + const filter = useMemo( + () => ({ + ...(createdByIds.length ? { recordCreatedBy: createdByIds } : {}), + ...(createdDateRange?.exactDate + ? { recordCreatedTimeStart: createdDateRange.exactDate } + : {}), + ...(createdDateRange?.exactDateEnd + ? { recordCreatedTimeEnd: createdDateRange.exactDateEnd } + : {}), + }), + [createdByIds, createdDateRange] + ); + + const fields = useRecordSnapshotFields(tableId, open); + + // Cursor-accumulate loading, same model as the archive grid: snapshots may come from + // hot or cold storage, so pages walk a merged stream instead of windowing positions. + const { data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } = useInfiniteQuery({ + queryKey: ReactQueryKeys.getTrashItemRecords(trashId as string, { tableId, ...filter }), + queryFn: ({ pageParam }) => + getTrashItemRecords(trashId as string, { tableId, ...filter, cursor: pageParam }).then( + (res) => res.data + ), + enabled: Boolean(trashId) && open, + refetchOnMount: 'always', + refetchOnWindowFocus: false, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: IGetTrashItemRecordsVo) => lastPage.nextCursor ?? undefined, + }); + + const items = useMemo(() => (data ? data.pages.flatMap((page) => page.items) : []), [data]); + + const userMap = useMemo(() => { + const map: IUserMapVo = {}; + data?.pages.forEach((page) => Object.assign(map, page.userMap)); + return map; + }, [data]); + + const gridRef = useRef(null); + + // A filter change resets the accumulated pages; the scroll position must follow, + // otherwise the viewport sits past the shrunken row count. + useEffect(() => { + gridRef.current?.scrollTo(0, 0); + }, [filter]); + + const { users: filterUsers, setUserSearch } = useCollaboratorFilterUsers({ + selectedIds: createdByIds, + userMap, + }); + + const userOptions = useMemo( + () => filterUsers.map((user) => ({ value: user.id, label: user.name })), + [filterUsers] + ); + + const hasFilter = createdByIds.length > 0 || createdDateRange != null; + + const onFilterReset = useCallback(() => { + setCreatedByIds([]); + setCreatedDateRange(null); + setUserSearch(''); + }, [setUserSearch]); + + const getTrashRecord = useCallback((item: ITrashItemRecordVo) => item.record, []); + + const getItem = useCallback((rowIndex: number) => items[rowIndex], [items]); + + const onLoadMore = useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const systemColumns = useMemo[]>( + () => [ + { + id: '__deletedTime', + name: t('table:tableTrash.deletedTime'), + width: 150, + getCellText: (item) => dayjs(item.deletedTime).format(TRASH_TIME_FORMAT), + }, + { + id: '__deletedBy', + name: t('table:tableTrash.deletedBy'), + width: 130, + getCellText: (item) => userMap[item.deletedBy]?.name ?? '', + }, + ], + [t, userMap] + ); + + const onRowExpand = useCallback((item: ITrashItemRecordVo) => { + setExpandedItem(item); + setExpandOpen(true); + }, []); + + return ( + <> + + + + + {t('table:tableTrash.recordsDialogTitle', { + count: trashItem?.totalResourceCount ?? 0, + })} + + +
+ + + {hasFilter && ( + + )} +
+ + fields={fields} + rowCount={items.length} + getItem={getItem} + gridRef={gridRef} + getRecord={getTrashRecord} + systemColumns={systemColumns} + isLoading={isLoading} + onLoadMore={onLoadMore} + emptyText={t('sdk:common.noRecords')} + copySuccessText={t('table:table.actionTips.copySuccessful')} + onRowExpand={onRowExpand} + /> +
+
+ + {t('table:tableTrash.deletedTime')}:{' '} + {dayjs(expandedItem.deletedTime).format(TRASH_TIME_FORMAT)} + {' · '} + {t('table:tableTrash.deletedBy')}: {userMap[expandedItem.deletedBy]?.name ?? ''} +
+ ) + } + fields={fields} + record={expandedItem?.record} + /> + + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/calendar/components/AddDateFieldDialog.tsx b/apps/nextjs-app/src/features/app/blocks/view/calendar/components/AddDateFieldDialog.tsx index 68ae8fa9cb..75f7ec2a17 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/calendar/components/AddDateFieldDialog.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/calendar/components/AddDateFieldDialog.tsx @@ -14,8 +14,8 @@ import { useEffect, useState } from 'react'; import { getFormatStringForLanguage, localFormatStrings, - systemTimeZone, -} from '@/features/app/components/field-setting/formatting/DatetimeFormatting'; +} from '@/features/app/components/field-setting/formatting/date-format-strings'; +import { systemTimeZone } from '@/features/app/components/field-setting/formatting/DatetimeFormatting'; import { tableConfig } from '@/features/i18n/table.config'; import { useCalendar } from '../hooks'; diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx b/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx index 2739ff3b23..ef6d0d5cad 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx @@ -321,6 +321,7 @@ export const GridViewBaseInner: React.FC = ( paste, clear, deleteRecords, + archiveRecords, duplicateRecords, clearProgress, clearSummary, @@ -657,6 +658,21 @@ export const GridViewBaseInner: React.FC = ( .filter(Boolean); } + const confirmAndArchiveRecords = async (recordCount: number) => { + const confirmed = await confirm({ + title: t('table:table.actionTips.archiveRecordConfirmTitle'), + description: t('table:table.actionTips.archiveRecordConfirmDescription', { + recordCount, + }), + confirmText: t('table:table.actionTips.archiveRecord'), + cancelText: t('common:actions.cancel'), + }); + if (!confirmed) return; + + await archiveRecords(selection, recordMap); + gridRef.current?.setSelection(emptySelection); + }; + if (isCellSelection || isRowSelection) { const rowStart = isCellSelection ? ranges[0][1] : ranges[0][0]; const rowEnd = isCellSelection ? ranges[1][1] : ranges[0][1]; @@ -694,6 +710,7 @@ export const GridViewBaseInner: React.FC = ( deleteRecords(selection, recordMap); gridRef.current?.setSelection(emptySelection); }, + archiveRecords: () => confirmAndArchiveRecords(getEffectRows(selection, realRowCount)), duplicateRecord: async () => { await duplicateRecords(selection); }, @@ -736,6 +753,7 @@ export const GridViewBaseInner: React.FC = ( deleteRecords(selection, recordMap); gridRef.current?.setSelection(emptySelection); }, + archiveRecords: () => confirmAndArchiveRecords(1), copyRecordUrl: async () => { await copyRecordUrl(record?.id); }, diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/components/ClearSelectionProgressDialog.tsx b/apps/nextjs-app/src/features/app/blocks/view/grid/components/ClearSelectionProgressDialog.tsx index 78670feca1..e7bf4d3bac 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/components/ClearSelectionProgressDialog.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/components/ClearSelectionProgressDialog.tsx @@ -3,6 +3,10 @@ import type { IClearSelectionStreamErrorEvent, IClearSelectionStreamProgressEvent, } from '@teable/openapi'; +import { getLocalizationMessage } from '@teable/sdk'; +import type { ILocaleFunction } from '@teable/sdk/context/app/i18n'; +import { useTranslation } from 'next-i18next'; +import { tableConfig } from '@/features/i18n/table.config'; import { SelectionActionProgressDialog, type ISelectionActionDialogError, @@ -49,14 +53,19 @@ const toSummary = ( } : null; -const toErrors = (errors: IClearSelectionStreamErrorEvent[]): ISelectionActionDialogError[] => +const toErrors = ( + errors: IClearSelectionStreamErrorEvent[], + t: ILocaleFunction +): ISelectionActionDialogError[] => errors.map((error) => ({ phase: toErrorPhase(error.phase), batchIndex: error.batchIndex, totalCount: error.totalCount, completedCount: error.processedCount, recordIds: error.recordIds, - message: error.message, + message: error.localization + ? getLocalizationMessage(error.localization, t, 'sdk') + : error.message, })); export const ClearSelectionProgressDialog = ({ @@ -80,13 +89,14 @@ export const ClearSelectionProgressDialog = ({ onConfirm?: () => void; onOpenChange?: (open: boolean) => void; }) => { + const { t } = useTranslation(tableConfig.i18nNamespaces); return ( { setActivity({}); }); - it('shows current fields with their icons and batch progress', async () => { + it('shows compact progress only for running fields', async () => { mockedUseFields.mockReturnValue([ { id: 'fldFormula', @@ -87,9 +87,16 @@ describe('ComputeActivityPanel', () => { status: 'running', activeTaskCount: 3, processingTaskCount: 1, + estimatedDirtyRecords: 6000, batchProgress: { total: 5, completed: 2 }, }, - fldLookup: { status: 'queued', activeTaskCount: 1, processingTaskCount: 0 }, + fldLookup: { + status: 'queued', + activeTaskCount: 4, + processingTaskCount: 0, + estimatedDirtyRecords: 12000, + batchProgress: { total: 4, completed: 2 }, + }, }); render(); @@ -98,7 +105,24 @@ describe('ComputeActivityPanel', () => { expect(screen.getByText('Revenue formula')).toBeInTheDocument(); expect(screen.getByText('Customer lookup')).toBeInTheDocument(); expect(screen.getAllByTestId('field-type-icon')).toHaveLength(2); - expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '2'); + const progressText = screen.getByText('40%'); + expect(progressText).toHaveClass('ml-auto', 'text-muted-foreground'); + expect(progressText.parentElement).toHaveClass('justify-between'); + expect(screen.getByText('computeActivity.calculating')).not.toHaveTextContent('%'); + expect(screen.queryByText('50%')).not.toBeInTheDocument(); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + const scrollArea = screen.getByText('Revenue formula').closest('.overflow-y-auto'); + expect(scrollArea).toBeInTheDocument(); + expect(scrollArea).not.toContainElement( + screen.getByText('computeActivity.currentCalculations') + ); + expect(mockedT).toHaveBeenCalledWith('computeActivity.records', { + count: 6000, + formattedCount: '6,000', + }); + expect(mockedT).not.toHaveBeenCalledWith('computeActivity.batchesRunning', expect.anything()); + expect(mockedT).not.toHaveBeenCalledWith('computeActivity.batchesQueued', expect.anything()); + expect(mockedT).not.toHaveBeenCalledWith('computeActivity.batchesComplete', expect.anything()); }); it('does not reveal activity for fields without record-read permission', async () => { diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/components/ComputeActivityPanel.tsx b/apps/nextjs-app/src/features/app/blocks/view/grid/components/ComputeActivityPanel.tsx index 28688bb381..677f48efeb 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/components/ComputeActivityPanel.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/components/ComputeActivityPanel.tsx @@ -16,7 +16,8 @@ const FieldActivityStatus = ({ status }: { status: ComputeActivityFieldClient['s if (status === 'running') { return ( - {t('computeActivity.calculating')} + + {t('computeActivity.calculating')} ); } @@ -46,19 +47,14 @@ const FieldActivityRow = ({ }) => { const { t, i18n } = useTranslation('table'); const { field, meta } = item; - const processingCount = meta.processingTaskCount ?? (meta.status === 'running' ? 1 : 0); - const queuedCount = Math.max(0, (meta.activeTaskCount ?? 0) - processingCount); - const progress = meta.batchProgress; - const progressPercent = progress?.total - ? Math.round((progress.completed / progress.total) * 100) - : 0; - const batchStates = [ - processingCount > 0 ? t('computeActivity.batchesRunning', { count: processingCount }) : null, - queuedCount > 0 ? t('computeActivity.batchesQueued', { count: queuedCount }) : null, - ].filter(Boolean); + const progress = meta.status === 'running' ? meta.batchProgress : undefined; + const progressPercent = + progress && progress.total > 1 + ? Math.round((progress.completed / progress.total) * 100) + : undefined; return ( -
+
@@ -72,52 +68,22 @@ const FieldActivityRow = ({

{meta.lastError?.message ?? t('computeActivity.calculationFailed')}

- ) : ( - <> -
- {meta.estimatedDirtyRecords ? ( - - {t('computeActivity.records', { - count: meta.estimatedDirtyRecords, - formattedCount: formatCount( - meta.estimatedDirtyRecords, - i18n.resolvedLanguage ?? i18n.language - ), - })} - - ) : null} - {batchStates.length ? {batchStates.join(' · ')} : null} -
- {progress && progress.total > 1 ? ( -
-
- - {t('computeActivity.batchesComplete', { - completed: progress.completed, - total: progress.total, - })} - - {progressPercent}% -
-
-
-
-
+ ) : meta.estimatedDirtyRecords || progressPercent != null ? ( +
+ {meta.estimatedDirtyRecords + ? t('computeActivity.records', { + count: meta.estimatedDirtyRecords, + formattedCount: formatCount( + meta.estimatedDirtyRecords, + i18n.resolvedLanguage ?? i18n.language + ), + }) + : null} + {progressPercent != null ? ( + {progressPercent}% ) : null} - - )} +
+ ) : null}
); @@ -194,26 +160,37 @@ export const ComputeActivityPanel = () => { {summary} - -
-
- {t('computeActivity.currentCalculations')} + +
+
+
+ {t('computeActivity.currentCalculations')} +
+ + {t('computeActivity.thisTableOnly')} +
- - {t('computeActivity.thisTableOnly')} -
-
- - - +
+
+ + + +
diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/components/DeleteSelectionProgressDialog.tsx b/apps/nextjs-app/src/features/app/blocks/view/grid/components/DeleteSelectionProgressDialog.tsx index 72c221579c..80195b42c0 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/components/DeleteSelectionProgressDialog.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/components/DeleteSelectionProgressDialog.tsx @@ -3,6 +3,10 @@ import type { IDeleteSelectionStreamErrorEvent, IDeleteSelectionStreamProgressEvent, } from '@teable/openapi'; +import { getLocalizationMessage } from '@teable/sdk'; +import type { ILocaleFunction } from '@teable/sdk/context/app/i18n'; +import { useTranslation } from 'next-i18next'; +import { tableConfig } from '@/features/i18n/table.config'; import { SelectionActionProgressDialog, type ISelectionActionDialogError, @@ -50,14 +54,19 @@ const toSummary = ( } : null; -const toErrors = (errors: IDeleteSelectionStreamErrorEvent[]): ISelectionActionDialogError[] => +const toErrors = ( + errors: IDeleteSelectionStreamErrorEvent[], + t: ILocaleFunction +): ISelectionActionDialogError[] => errors.map((error) => ({ phase: toErrorPhase(error.phase), batchIndex: error.batchIndex, totalCount: error.totalCount, completedCount: error.deletedCount, recordIds: error.recordIds, - message: error.message, + message: error.localization + ? getLocalizationMessage(error.localization, t, 'sdk') + : error.message, })); export const DeleteSelectionProgressDialog = ({ @@ -81,13 +90,14 @@ export const DeleteSelectionProgressDialog = ({ onConfirm?: () => void; onOpenChange?: (open: boolean) => void; }) => { + const { t } = useTranslation(tableConfig.i18nNamespaces); return ( +const toErrors = ( + errors: IDuplicateSelectionStreamErrorEvent[], + t: ILocaleFunction +): ISelectionActionDialogError[] => errors.map((error) => ({ phase: toErrorPhase(error.phase), batchIndex: error.batchIndex, totalCount: error.totalCount, completedCount: error.duplicatedCount, recordIds: error.recordIds, - message: error.message, + message: error.localization + ? getLocalizationMessage(error.localization, t, 'sdk') + : error.message, })); export const DuplicateSelectionProgressDialog = ({ @@ -81,13 +90,14 @@ export const DuplicateSelectionProgressDialog = ({ onConfirm?: () => void; onOpenChange?: (open: boolean) => void; }) => { + const { t } = useTranslation(tableConfig.i18nNamespaces); return ( +const toErrors = ( + errors: IPasteSelectionStreamErrorEvent[], + t: ILocaleFunction +): ISelectionActionDialogError[] => errors.map((error) => ({ phase: toErrorPhase(error.phase), batchIndex: error.batchIndex, totalCount: error.totalCount, completedCount: error.processedCount, recordIds: error.recordIds, - message: error.message, + message: error.localization + ? getLocalizationMessage(error.localization, t, 'sdk') + : error.message, })); export const PasteSelectionProgressDialog = ({ @@ -80,13 +89,14 @@ export const PasteSelectionProgressDialog = ({ onConfirm?: () => void; onOpenChange?: (open: boolean) => void; }) => { + const { t } = useTranslation(tableConfig.i18nNamespaces); return ( void; +}): IMenuItemProps[] => { + const name = isMultipleSelected + ? t('table:menu.archiveAllSelectedRecords') + : t('table:menu.archiveRecord'); + return [ + { + type: MenuItemType.Archive, + name, + icon: , + hidden: !canArchive || isUndeletable || !recordMenu?.archiveRecords, + render: needsUpgrade ? ( +
+ + {name} + {upgradeBadge} +
+ ) : undefined, + onClick: () => { + if (needsUpgrade) { + onUpgradeClick(); + return; + } + if (recordMenu && tableId && recordMenu.archiveRecords) { + void recordMenu.archiveRecords(); + } + }, + }, + ]; +}; + const buildDeleteMenuItems = ({ t, canDelete, @@ -300,6 +352,12 @@ export const RecordMenu = () => { const { enable: aiEnable } = useAI(); const usage = useBaseUsage({ disabled: !baseId }); const chatEnabled = Boolean(aiEnable && usage?.limit?.chatAIEnable); + const { + archiveUnlocked, + badge: archiveUpgradeBadge, + needsUpgrade: archiveNeedsUpgrade, + handleUpgradeClick: onArchiveUpgradeClick, + } = useArchiveUpsell(usage); useClickAway(recordMenuRef, () => { closeRecordMenu(); @@ -331,6 +389,7 @@ export const RecordMenu = () => { const canUpdate = Boolean(permission['record|update']); const canComment = Boolean(permission['record|comment']); const canDelete = Boolean(permission['record|delete']); + const canArchive = Boolean(permission['record|archive'] && archiveUnlocked); const style = position ? { left: position.x, @@ -363,7 +422,17 @@ export const RecordMenu = () => { chatEnabled, recordMenu, }), - [], + buildArchiveMenuItems({ + t: t as unknown as MenuTranslate, + canArchive, + isMultipleSelected: Boolean(isMultipleSelected), + isUndeletable: Boolean(record?.undeletable), + tableId, + recordMenu, + needsUpgrade: archiveNeedsUpgrade, + upgradeBadge: archiveUpgradeBadge, + onUpgradeClick: onArchiveUpgradeClick, + }), buildDeleteMenuItems({ t: t as unknown as MenuTranslate, canDelete, diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useSelectionOperation.ts b/apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useSelectionOperation.ts index af9488ef0b..cb434cb0e9 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useSelectionOperation.ts +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useSelectionOperation.ts @@ -28,6 +28,9 @@ import type { ITemporaryPasteVo, } from '@teable/openapi'; import { + archiveRecords as archiveRecordsApi, + archiveRecordsStream, + MAX_ARCHIVE_RECORDS_PER_REQUEST, clearById, clearSelectionByIdStream, copy, @@ -58,6 +61,7 @@ import { SelectionRegionType, useRowCount, } from '@teable/sdk'; +import { UsageLimitModalType, useUsageLimitModalStore } from '@teable/sdk/components/billing/store'; import { useConfirm } from '@teable/ui-lib/base'; import { toast } from '@teable/ui-lib/shadcn/ui/sonner'; import type { AxiosResponse } from 'axios'; @@ -90,6 +94,7 @@ import { useSyncSelectionStore } from './useSelectionStore'; const clearToastId = 'clearToastId'; const deleteToastId = 'deleteToastId'; +const archiveToastId = 'archiveToastId'; const getPasteContentColumnCount = (content: IPasteByIdRo['content']) => { if (Array.isArray(content)) { return content.reduce((max, row) => Math.max(max, row.length), 0); @@ -1585,6 +1590,61 @@ export const useSelectionOperation = (props?: { [buildSelectionIdRequest, deleteReq, openDeleteConfirmationDialog, rowCount, tableId, t, viewId] ); + const doArchive = useCallback( + async (selection: CombinedSelection, recordMap?: IRecordIndexMap) => { + if (!viewId || !tableId) return; + try { + // The archive API takes explicit record ids, so resolve the selection without + // query-scope shortcuts (allRecords / exclusions). + const archiveRo = (await buildSelectionIdRequest(selection, recordMap ?? {}, { + includeFieldSelection: false, + allowQueryScope: false, + })) as { selection?: { recordIds?: string[] } }; + const recordIds = archiveRo.selection?.recordIds; + if (!recordIds?.length) return; + + const toastId = toast.loading(t('table:table.actionTips.archiving'), { + id: archiveToastId, + }); + if (recordIds.length <= MAX_ARCHIVE_RECORDS_PER_REQUEST) { + await archiveRecordsApi(tableId, { recordIds }); + } else { + await archiveRecordsStream( + tableId, + { recordIds }, + { + headers: { + 'X-Window-Id': ensureUndoRedoWindowIdHeader(), + }, + onProgress: (event) => { + toast.loading( + `${t('table:table.actionTips.archiving')} ${event.archivedCount}/${event.totalCount}`, + { id: toastId } + ); + }, + } + ); + } + toast.success(t('table:table.actionTips.archiveSuccessful'), { id: toastId }); + } catch (error) { + if ((error as HttpError).status === 402) { + toast.dismiss(archiveToastId); + useUsageLimitModalStore.setState({ + modalType: UsageLimitModalType.Upgrade, + modalOpen: true, + }); + return; + } + const description = + getHttpErrorMessage(error as HttpError, t, 'sdk') || + (error instanceof Error ? error.message : 'Unknown error'); + toast.error(description, { id: archiveToastId }); + console.error('Archive error: ', error); + } + }, + [buildSelectionIdRequest, tableId, t, viewId] + ); + const doDuplicate = useCallback( async (selection: CombinedSelection) => { if (!viewId || !tableId) return; @@ -1677,6 +1737,7 @@ export const useSelectionOperation = (props?: { paste: doPaste, clear: doClear, deleteRecords: doDelete, + archiveRecords: doArchive, duplicateRecords: doDuplicate, clearProgress, clearSummary, diff --git a/apps/nextjs-app/src/features/app/blocks/view/tool-bar/ShareViewContent.tsx b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/ShareViewContent.tsx index 93acd4d8e0..2e536fa06f 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/tool-bar/ShareViewContent.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/ShareViewContent.tsx @@ -319,6 +319,8 @@ export const ShareViewContent: React.FC = () => { const needConfigRequireLogin = [ViewType.Form].includes(view.type); const needEmbedHiddenToolbar = ![ViewType.Form].includes(view.type); + const canManageShare = Boolean(permission['view|share']); + const permissionOptions = needConfigAllowEdit ? [ { @@ -340,12 +342,29 @@ export const ShareViewContent: React.FC = () => { return (
- + {canManageShare ? ( + + ) : ( + + + + {/* Focusable so keyboard users can reach the tooltip; the disabled switch itself can't take focus */} + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */} + + + + + +

{t('common:baseShare.noPermissionTip')}

+
+
+
+ )} @@ -359,8 +378,11 @@ export const ShareViewContent: React.FC = () => { {t('table:baseShare.linkHolderLabel')} - - @@ -417,6 +439,7 @@ export const ShareViewContent: React.FC = () => { variant="outline" size="icon-sm" className="shrink-0" + disabled={!canManageShare} onClick={() => view.setRefreshLink()} > @@ -439,6 +462,7 @@ export const ShareViewContent: React.FC = () => { setShareMeta({ allowCopy: checked })} />
, document.body ); diff --git a/apps/nextjs-app/src/features/app/components/PlainPageSkeleton.tsx b/apps/nextjs-app/src/features/app/components/PlainPageSkeleton.tsx new file mode 100644 index 0000000000..9b143ede94 --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/PlainPageSkeleton.tsx @@ -0,0 +1,16 @@ +import { Spin } from '@teable/ui-lib/base'; + +/** + * Neutral main-area placeholder (top bar + centered spinner) for base pages + * whose layout can't be mocked while their client-only chunk loads (automation, + * app, dashboard). Shared with the base-entry transition overlay's plain + * variant so the space→base transition and a hard refresh read identically. + */ +export const PlainPageSkeleton = () => ( +
+
+
+ +
+
+); diff --git a/apps/nextjs-app/src/features/app/components/PublicOperateButton.tsx b/apps/nextjs-app/src/features/app/components/PublicOperateButton.tsx index b36893655a..7667d7d09d 100644 --- a/apps/nextjs-app/src/features/app/components/PublicOperateButton.tsx +++ b/apps/nextjs-app/src/features/app/components/PublicOperateButton.tsx @@ -7,39 +7,20 @@ import { useTranslation } from 'next-i18next'; import React, { useRef } from 'react'; import { useShareAllowEdit, useShareAllowSave } from '../context/ShareContext'; import { useIsInIframe } from '../hooks/useIsInIframe'; -import type { IShareSelectSpaceDialogRef } from './ShareSelectSpaceDialog'; -import { ShareSelectSpaceDialog } from './ShareSelectSpaceDialog'; +import { useShareBaseOperations } from './share-operation/ShareBaseOperationProvider'; import type { ITemplateSelectSpaceDialogRef } from './TemplateSelectSpaceDialog'; import { TemplateSelectSpaceDialog } from './TemplateSelectSpaceDialog'; -export const PublicOperateButton = () => { +const ShareOperateButton = () => { const isAnonymous = useIsAnonymous(); - const template = useTemplate(); - const shareId = useShareId(); - const isTemplate = !!template; - const isShare = !!shareId; const allowSave = useShareAllowSave(); const allowEdit = useShareAllowEdit(); - const { t } = useTranslation(['common', 'table']); - const router = useRouter(); - const isInIframe = useIsInIframe(); - const templateRef = useRef(null); - const shareRef = useRef(null); - const isHydrated = useIsHydrated(); - + const shareOperations = useShareBaseOperations(); + const { t } = useTranslation(['common', 'table', 'auth']); const { resolvedTheme } = useTheme(); const isDark = resolvedTheme === 'dark'; - if (isInIframe || !isHydrated) { - return <>; - } - - // For share mode with allowEdit, show login card for anonymous users - if (isShare && allowEdit && isAnonymous) { - const handleLoginClick = () => { - router.push(`/auth/login?redirect=${encodeURIComponent(window.location.href)}`); - }; - + if (allowEdit && isAnonymous) { return (
{

{t('table:baseShare.editRequiresLogin')}

-
); } - // For share mode, show "Copy to my space" button if allowSave is enabled - if (isShare) { - // Don't show the button if allowSave is disabled - if (!allowSave) { - return null; - } + if (!allowSave) { + return null; + } - const handleClick = () => { - if (isAnonymous) { - // Redirect to login first, then come back with isCopyToSpace flag - const url = new URL(window.location.href); - url.searchParams.set('isCopyToSpace', '1'); - router.push(`/auth/login?redirect=${encodeURIComponent(url.toString())}`); - return; - } - shareRef.current?.setOpen(true); - }; + return ( +
+ + +

{t('table:baseShare.supportSaveCopy')}

+ +
+ ); +}; - return ( -
- - -

{t('common:actions.supportSaveCopy')}

- - -
- ); +export const PublicOperateButton = () => { + const isAnonymous = useIsAnonymous(); + const template = useTemplate(); + const shareId = useShareId(); + const isTemplate = !!template; + const isShare = !!shareId; + const { t } = useTranslation(['common']); + const router = useRouter(); + const isInIframe = useIsInIframe(); + const templateRef = useRef(null); + const isHydrated = useIsHydrated(); + + if (isInIframe || !isHydrated) { + return null; + } + + if (isShare) { + return ; } if (!isAnonymous && !isTemplate) { diff --git a/apps/nextjs-app/src/features/app/components/ResourceDescription.tsx b/apps/nextjs-app/src/features/app/components/ResourceDescription.tsx index 0c635dd5bf..4a5bee2987 100644 --- a/apps/nextjs-app/src/features/app/components/ResourceDescription.tsx +++ b/apps/nextjs-app/src/features/app/components/ResourceDescription.tsx @@ -287,7 +287,7 @@ export const ResourceDescription = ({ if (!showDescription) { return fallback ? ( -
+
{fallback}
) : null; @@ -299,7 +299,7 @@ export const ResourceDescription = ({ + )} +
); diff --git a/apps/nextjs-app/src/features/app/components/field-setting/formatting/DatetimeFormatting.tsx b/apps/nextjs-app/src/features/app/components/field-setting/formatting/DatetimeFormatting.tsx index 3ba3721a7f..254ad10a14 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/formatting/DatetimeFormatting.tsx +++ b/apps/nextjs-app/src/features/app/components/field-setting/formatting/DatetimeFormatting.tsx @@ -7,56 +7,18 @@ import timezone from 'dayjs/plugin/timezone'; import utc from 'dayjs/plugin/utc'; import { useTranslation } from 'next-i18next'; import { Selector } from '@/components/Selector'; +import { + friendlyFormatStrings, + getFormatStringForLanguage, + localFormatStrings, +} from './date-format-strings'; import { TimeZoneFormatting } from './TimeZoneFormatting'; + dayjs.extend(utc); dayjs.extend(timezone); export const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; -// | Locale | Date Format | Notes | -// |--------|-------------|-------| -// | en-US | M/D/YYYY | U.S. English (United States), e.g., 12/31/2023 | -// | en-GB | D/M/YYYY | British English (United Kingdom, European), e.g., 31/12/2023 | -// | fr-FR | DD/MM/YYYY | French (France), e.g., 31/12/2023 | -// | de-DE | DD.MM.YYYY | German (Germany), e.g., 31.12.2023 | -// | ja-JP | YYYY/MM/DD | Japanese (Japan), e.g., 2023/12/31 | -// | zh-CN | YYYY-MM-DD | Simplified Chinese (China), e.g., 2023-12-31 | -// | ko-KR | YYYY.MM.DD | Korean (South Korea), e.g., 2023.12.31 | -export const localFormatStrings: { [key: string]: string } = { - en: 'M/D/YYYY', - 'en-GB': 'D/M/YYYY', - fr: 'DD/MM/YYYY', - de: 'DD.MM.YYYY', - ja: 'YYYY/MM/DD', - zh: 'YYYY-MM-DD', - ko: 'YYYY.MM.DD', -}; - -export const friendlyFormatStrings: { [key: string]: string } = { - en: 'MMMM D, YYYY', // English - 'en-GB': 'D MMMM YYYY', // English GB - zh: 'YYYY 年 M 月 D 日', // Chinese - fr: 'D MMM YYYY', // French - de: 'D. MMM YYYY', // German - es: 'D de MMM de YYYY', // Spanish - ru: 'D MMM YYYY г.', // Russian - ja: 'YYYY 年 M 月 D 日', // Japanese - ar: 'D MMMM, YYYY', // Arabic - pt: 'D de MMMM de YYYY', // Portuguese - hi: 'D MMMM, YYYY', // Hindi - bn: 'D MMMM, YYYY', // Bengali - jv: 'D MMMM YYYY', // Javanese - pa: 'D MMMM YYYY', // Punjabi - mr: 'D MMMM, YYYY', // Marathi - ta: 'D MMMM, YYYY', // Tamil -}; - -export function getFormatStringForLanguage(language: string, preset: { [key: string]: string }) { - // If the full language tag is not found, fallback to the base language - const baseLanguage = language.split('-')[0]; - return preset[language] || preset[baseLanguage] || preset['en']; // Default to 'en' -} - const useSelectInfoMap = (currentDateFormatting: string) => { const { t, i18n } = useTranslation(['common', 'table']); const friendlyDateFormatting = getFormatStringForLanguage(i18n.language, friendlyFormatStrings); diff --git a/apps/nextjs-app/src/features/app/components/field-setting/formatting/date-format-strings.ts b/apps/nextjs-app/src/features/app/components/field-setting/formatting/date-format-strings.ts new file mode 100644 index 0000000000..14d8ff7d4d --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/field-setting/formatting/date-format-strings.ts @@ -0,0 +1,46 @@ +// Plain lookup tables, kept out of DatetimeFormatting so a caller that only +// needs a pattern doesn't pull the field-setting UI and its dayjs plugins in. + +// | Locale | Date Format | Notes | +// |--------|-------------|-------| +// | en-US | M/D/YYYY | U.S. English (United States), e.g., 12/31/2023 | +// | en-GB | D/M/YYYY | British English (United Kingdom, European), e.g., 31/12/2023 | +// | fr-FR | DD/MM/YYYY | French (France), e.g., 31/12/2023 | +// | de-DE | DD.MM.YYYY | German (Germany), e.g., 31.12.2023 | +// | ja-JP | YYYY/MM/DD | Japanese (Japan), e.g., 2023/12/31 | +// | zh-CN | YYYY-MM-DD | Simplified Chinese (China), e.g., 2023-12-31 | +// | ko-KR | YYYY.MM.DD | Korean (South Korea), e.g., 2023.12.31 | +export const localFormatStrings: { [key: string]: string } = { + en: 'M/D/YYYY', + 'en-GB': 'D/M/YYYY', + fr: 'DD/MM/YYYY', + de: 'DD.MM.YYYY', + ja: 'YYYY/MM/DD', + zh: 'YYYY-MM-DD', + ko: 'YYYY.MM.DD', +}; + +export const friendlyFormatStrings: { [key: string]: string } = { + en: 'MMMM D, YYYY', // English + 'en-GB': 'D MMMM YYYY', // English GB + zh: 'YYYY 年 M 月 D 日', // Chinese + fr: 'D MMM YYYY', // French + de: 'D. MMM YYYY', // German + es: 'D de MMM de YYYY', // Spanish + ru: 'D MMM YYYY г.', // Russian + ja: 'YYYY 年 M 月 D 日', // Japanese + ar: 'D MMMM, YYYY', // Arabic + pt: 'D de MMMM de YYYY', // Portuguese + hi: 'D MMMM, YYYY', // Hindi + bn: 'D MMMM, YYYY', // Bengali + jv: 'D MMMM YYYY', // Javanese + pa: 'D MMMM YYYY', // Punjabi + mr: 'D MMMM, YYYY', // Marathi + ta: 'D MMMM, YYYY', // Tamil +}; + +export function getFormatStringForLanguage(language: string, preset: { [key: string]: string }) { + // If the full language tag is not found, fallback to the base language + const baseLanguage = language.split('-')[0]; + return preset[language] || preset[baseLanguage] || preset['en']; // Default to 'en' +} diff --git a/apps/nextjs-app/src/features/app/components/share-operation/MobileShareOperationBar.tsx b/apps/nextjs-app/src/features/app/components/share-operation/MobileShareOperationBar.tsx new file mode 100644 index 0000000000..0e62739597 --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/share-operation/MobileShareOperationBar.tsx @@ -0,0 +1,60 @@ +import { X } from '@teable/icons'; +import { useIsHydrated, useIsMobile } from '@teable/sdk/hooks'; +import { Button } from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import { useState } from 'react'; +import { useIsInIframe } from '../../hooks/useIsInIframe'; + +type MobileShareOperation = 'edit' | 'save'; + +interface IMobileShareOperationBarProps { + operation: MobileShareOperation; + onAction: () => void; +} + +export const MobileShareOperationBar = ({ operation, onAction }: IMobileShareOperationBarProps) => { + const { t } = useTranslation(['common', 'table', 'auth']); + const isHydrated = useIsHydrated(); + const isMobile = useIsMobile(); + const isInIframe = useIsInIframe(); + const [dismissed, setDismissed] = useState(false); + + if (!isHydrated || !isMobile || isInIframe || dismissed) { + return null; + } + + const isEdit = operation === 'edit'; + const message = isEdit + ? t('table:baseShare.editRequiresLogin') + : t('table:baseShare.supportSaveCopy'); + const actionLabel = isEdit + ? `${t('auth:button.signin')}/${t('auth:button.signup')}` + : t('common:actions.save'); + + return ( +
+

{message}

+ + +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/components/share-operation/ShareBaseOperationProvider.tsx b/apps/nextjs-app/src/features/app/components/share-operation/ShareBaseOperationProvider.tsx new file mode 100644 index 0000000000..73479417d0 --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/share-operation/ShareBaseOperationProvider.tsx @@ -0,0 +1,65 @@ +import { useIsAnonymous } from '@teable/sdk/hooks'; +import { useRouter } from 'next/router'; +import type { PropsWithChildren } from 'react'; +import { createContext, useCallback, useContext, useMemo, useRef } from 'react'; +import { useShareAllowEdit, useShareAllowSave, useShareContext } from '../../context/ShareContext'; +import { useIsInIframe } from '../../hooks/useIsInIframe'; +import type { IShareSelectSpaceDialogRef } from '../ShareSelectSpaceDialog'; +import { ShareSelectSpaceDialog } from '../ShareSelectSpaceDialog'; +import { MobileShareOperationBar } from './MobileShareOperationBar'; + +interface IShareBaseOperations { + loginToEdit: () => void; + saveCopy: () => void; +} + +const ShareBaseOperationContext = createContext(null); + +export const useShareBaseOperations = () => { + const operations = useContext(ShareBaseOperationContext); + if (!operations) { + throw new Error('ShareBaseOperationProvider is required in base share pages'); + } + return operations; +}; + +export const ShareBaseOperationProvider = ({ children }: PropsWithChildren) => { + const router = useRouter(); + const isAnonymous = useIsAnonymous(); + const isInIframe = useIsInIframe(); + const { shareId } = useShareContext(); + const allowEdit = useShareAllowEdit(); + const allowSave = useShareAllowSave(); + const dialogRef = useRef(null); + + const loginToEdit = useCallback(() => { + router.push(`/auth/login?redirect=${encodeURIComponent(window.location.href)}`); + }, [router]); + + const saveCopy = useCallback(() => { + if (isAnonymous) { + const url = new URL(window.location.href); + url.searchParams.set('isCopyToSpace', '1'); + router.push(`/auth/login?redirect=${encodeURIComponent(url.toString())}`); + return; + } + dialogRef.current?.setOpen(true); + }, [isAnonymous, router]); + + const value = useMemo(() => ({ loginToEdit, saveCopy }), [loginToEdit, saveCopy]); + const mobileOperation = allowEdit && isAnonymous ? 'edit' : allowSave ? 'save' : null; + + return ( + + {children} + {mobileOperation && ( + + )} + {allowSave && !isAnonymous && !isInIframe && } + + ); +}; diff --git a/apps/nextjs-app/src/features/app/components/sidebar/useChatPanelStore.ts b/apps/nextjs-app/src/features/app/components/sidebar/useChatPanelStore.ts index f403b7dc54..54c3323000 100644 --- a/apps/nextjs-app/src/features/app/components/sidebar/useChatPanelStore.ts +++ b/apps/nextjs-app/src/features/app/components/sidebar/useChatPanelStore.ts @@ -31,6 +31,18 @@ interface IChatPanelState { toggleExpanded: () => void; } +/** + * Shrink an 'expanded' (page-covering) panel back to the normal side panel; + * 'open' and 'close' are left untouched. For click handlers that navigate to + * content the expanded panel would otherwise keep hidden. + */ +export const collapseChatPanelIfExpanded = () => { + const { status, open } = useChatPanelStore.getState(); + if (status === 'expanded') { + open(); + } +}; + export const useChatPanelStore = create()( persist( (set) => ({ diff --git a/apps/nextjs-app/src/features/app/hooks/useArchiveUpsell.ts b/apps/nextjs-app/src/features/app/hooks/useArchiveUpsell.ts new file mode 100644 index 0000000000..1db3f77042 --- /dev/null +++ b/apps/nextjs-app/src/features/app/hooks/useArchiveUpsell.ts @@ -0,0 +1,19 @@ +import { BillingProductLevel } from '@teable/openapi'; +import { useUpgradeAction } from '@/features/app/components/billing/UpgradeWrapper'; +import type { useBaseUsage } from './useBaseUsage'; + +// Archive is a paid feature: paid tiers get the working entry, lower EE/cloud tiers see +// it with an upgrade badge as an upsell, and community (where needsUpgrade is always +// false and usage is never fetched) stays hidden. Callers AND `archiveUnlocked` with +// their surface-specific permission check. +export const useArchiveUpsell = (usage: ReturnType) => { + const { badge, needsUpgrade, handleUpgradeClick } = useUpgradeAction({ + targetBillingLevel: BillingProductLevel.Business, + }); + return { + archiveUnlocked: Boolean(usage?.limit?.archiveEnable || needsUpgrade), + badge, + needsUpgrade, + handleUpgradeClick, + }; +}; diff --git a/apps/nextjs-app/src/features/app/hooks/useBaseEntryMap.ts b/apps/nextjs-app/src/features/app/hooks/useBaseEntryMap.ts new file mode 100644 index 0000000000..6b03b8eb5d --- /dev/null +++ b/apps/nextjs-app/src/features/app/hooks/useBaseEntryMap.ts @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; +import { baseEntryMapDefaultTake, getBaseEntryMap } from '@teable/openapi'; +import { ReactQueryKeys } from '@teable/sdk/config'; + +/** + * Prefetch, while the user is still browsing a space's base list, the entry + * URL each base resolves to — so a card click can navigate straight to the + * final /base/{id}/table/{tableId}/{viewId} (a single SSR round) instead of + * paying the /base/{id} redirect chain. + * + * Pull-only: useEnterBase reads the cached map at click time; the map's + * arrival never triggers anything by itself. Bases missing from the map fall + * back to the redirect chain, and a stale entry self-heals through the table + * route's existing fallbacks — worst case is one extra redirect, same as + * today. Never on any critical path. + */ +export const useBaseEntryMap = (spaceId?: string) => { + useQuery({ + queryKey: ReactQueryKeys.baseEntryMap(spaceId as string), + queryFn: () => + getBaseEntryMap({ spaceId: spaceId as string, take: baseEntryMapDefaultTake }).then( + (res) => res.data + ), + enabled: Boolean(spaceId), + // refetch whenever the list is shown again so the map reflects visits + // made on other devices/tabs since the last look + staleTime: 0, + refetchOnWindowFocus: true, + // purely additive optimization: any failure must stay invisible — no + // global error toast, no retries, clicks just keep the redirect chain + retry: false, + meta: { preventGlobalError: true }, + }); +}; diff --git a/apps/nextjs-app/src/features/app/hooks/usePinEntryMap.ts b/apps/nextjs-app/src/features/app/hooks/usePinEntryMap.ts new file mode 100644 index 0000000000..c16b8fda4b --- /dev/null +++ b/apps/nextjs-app/src/features/app/hooks/usePinEntryMap.ts @@ -0,0 +1,26 @@ +import { useQuery } from '@tanstack/react-query'; +import { getPinEntryMap } from '@teable/openapi'; +import { ReactQueryKeys } from '@teable/sdk/config'; + +/** + * Prefetch, while the pin list is on screen, the entry URL each pinned + * base/table resolves to — so a pin click can navigate straight to the final + * /base/{id}/table/{tableId}/{viewId} instead of paying the redirect chain. + * + * Pull-only and fully independent of the pin list request: pins missing from + * the map keep today's navigation, a stale entry self-heals through the table + * route's existing fallbacks, and any failure stays invisible. + */ +export const usePinEntryMap = () => { + return useQuery({ + queryKey: ReactQueryKeys.pinEntryMap(), + queryFn: () => getPinEntryMap().then((res) => res.data), + // refetch whenever the list is shown again so the map reflects the + // latest visits + staleTime: 0, + refetchOnWindowFocus: true, + // purely additive optimization: no global error toast, no retries + retry: false, + meta: { preventGlobalError: true }, + }); +}; diff --git a/apps/nextjs-app/src/features/app/hooks/usePrefetchBaseEntry.ts b/apps/nextjs-app/src/features/app/hooks/usePrefetchBaseEntry.ts new file mode 100644 index 0000000000..a4f19fe86f --- /dev/null +++ b/apps/nextjs-app/src/features/app/hooks/usePrefetchBaseEntry.ts @@ -0,0 +1,55 @@ +import { useRouter } from 'next/router'; +import { useEffect } from 'react'; + +let warmedUp = false; + +/** + * Warm the heavy client-only Table chunk (all view types, calendar, dnd-kit...) + * and the base page bundle while the user is still browsing the space page, so + * entering a base no longer waits for them on the critical path. + * + * Runs once per app lifetime, only when the browser is idle, and skips + * data-saver / 2G connections. + */ +export const usePrefetchBaseEntry = () => { + const router = useRouter(); + + useEffect(() => { + if (warmedUp) { + return; + } + + const connection = ( + navigator as Navigator & { connection?: { saveData?: boolean; effectiveType?: string } } + ).connection; + if (connection?.saveData || connection?.effectiveType === '2g') { + return; + } + + const warmUp = () => { + warmedUp = true; + // Warming up is best-effort: swallow every failure (offline, stale chunk + // hash after a deploy...) so it can never surface on the space page. + try { + // Same module request as DynamicTable in base-node/TablePage.tsx, so it + // resolves to the same chunk. + import('@/features/app/blocks/table/Table').catch(() => { + warmedUp = false; + }); + router.prefetch('/base/[baseId]/[[...slug]]').catch(() => undefined); + } catch { + // ignore + } + }; + + if ( + typeof window.requestIdleCallback === 'function' && + typeof window.cancelIdleCallback === 'function' + ) { + const handle = window.requestIdleCallback(warmUp, { timeout: 5000 }); + return () => window.cancelIdleCallback(handle); + } + const timer = window.setTimeout(warmUp, 2000); + return () => window.clearTimeout(timer); + }, [router]); +}; diff --git a/apps/nextjs-app/src/features/app/hooks/useSeatConfirm.ts b/apps/nextjs-app/src/features/app/hooks/useSeatConfirm.ts new file mode 100644 index 0000000000..15b54fc0c3 --- /dev/null +++ b/apps/nextjs-app/src/features/app/hooks/useSeatConfirm.ts @@ -0,0 +1,92 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { isBillableRole, type IRole } from '@teable/core'; +import { BillingProductLevel, getInstanceUsage } from '@teable/openapi'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import { useConfirm } from '@teable/ui-lib/base/dialog/confirm-modal'; +import { useTranslation } from 'next-i18next'; +import { useCallback } from 'react'; +import { useRoleStatic } from '../components/collaborator-manage/useRoleStatic'; +import { useBillingLevel } from './useBillingLevel'; +import { useIsCloud } from './useIsCloud'; +import { useIsEE } from './useIsEE'; + +type ISeatConfirmAction = 'invite' | 'link' | 'roleChange' | 'matrix'; + +interface ISeatConfirmOptions { + count: number; + action: ISeatConfirmAction; + // omitted role means the seat is billable regardless of role (authority matrix) + role?: IRole; +} + +const SEAT_CONFIRM_COPY = { + invite: { titleKey: 'billing.seatConfirm.title', descKey: 'billing.seatConfirm.inviteDesc' }, + link: { titleKey: 'billing.seatConfirm.title', descKey: 'billing.seatConfirm.linkDesc' }, + roleChange: { + titleKey: 'billing.seatConfirm.roleChangeTitle', + descKey: 'billing.seatConfirm.roleChangeDesc', + }, + matrix: { + titleKey: 'billing.seatConfirm.matrixTitle', + descKey: 'billing.seatConfirm.matrixDesc', + }, +} as const; + +// Resolves true when the action does not need a billing confirmation, +// otherwise reflects the user's choice in the confirm dialog. +// Self-host over-limit is a hard stop: the dialog informs and always resolves false. +export const useSeatConfirm = ({ spaceId, baseId }: { spaceId?: string; baseId?: string }) => { + const { t } = useTranslation('common'); + const isCloud = useIsCloud(); + const isEE = useIsEE(); + const level = useBillingLevel({ spaceId, baseId }); + const roleStatic = useRoleStatic(); + const { confirm, alert } = useConfirm(); + const queryClient = useQueryClient(); + + const isPaidSpace = isCloud && level != null && level !== BillingProductLevel.Free; + + return useCallback( + async ({ role, count, action }: ISeatConfirmOptions) => { + if (count <= 0 || (role != null && !isBillableRole(role))) { + return true; + } + + if (isPaidSpace) { + const roleName = roleStatic.find((item) => item.role === role)?.name; + const copy = SEAT_CONFIRM_COPY[action]; + return confirm({ + title: t(copy.titleKey), + description: t(copy.descKey, { count, role: roleName }), + confirmText: + action === 'invite' ? t('billing.seatConfirm.confirmInvite') : t('actions.confirm'), + cancelText: t('actions.cancel'), + }); + } + + if (isEE) { + // fetched at decision time: the hard seat-limit gate must not act on stale counts + const instanceUsage = await queryClient + .fetchQuery({ + queryKey: ReactQueryKeys.instanceUsage(), + queryFn: () => getInstanceUsage().then((res) => res.data), + staleTime: 0, + }) + .catch(() => undefined); + const seats = instanceUsage?.seats ?? 0; + const seatLimit = instanceUsage?.seatLimit; + if (seatLimit != null && seats + count > seatLimit) { + await alert({ + title: t('billing.seatConfirm.seatLimitTitle'), + description: t('billing.seatConfirm.seatLimitDesc', { seats, seatLimit }), + confirmText: t('billing.seatConfirm.seatLimitConfirm'), + }); + return false; + } + } + + return true; + }, + [isPaidSpace, isEE, confirm, alert, queryClient, roleStatic, t] + ); +}; diff --git a/apps/nextjs-app/src/features/app/hooks/useTopBannerSlot.ts b/apps/nextjs-app/src/features/app/hooks/useTopBannerSlot.ts new file mode 100644 index 0000000000..a47f98c056 --- /dev/null +++ b/apps/nextjs-app/src/features/app/hooks/useTopBannerSlot.ts @@ -0,0 +1,95 @@ +import { useEffect } from 'react'; +import { create } from 'zustand'; + +/** + * Every owner of the top-banner stack. Registration overwrites by id and + * unregistration deletes by id, so two owners sharing one would evict each other. + */ +export enum TopBannerId { + LicenseExpiry = 'license-expiry', + Announcement = 'announcement', + /** Claims no slot; exists so the preview cannot unregister the real banner. */ + AnnouncementPreview = 'announcement-preview', +} + +interface ITopBannerEntry { + id: TopBannerId; + height: number; + /** Higher wins the upper slot. */ + priority: number; +} + +interface ITopBannerState { + entries: ITopBannerEntry[]; + register: (entry: ITopBannerEntry) => void; + unregister: (id: TopBannerId) => void; +} + +const sortEntries = (entries: ITopBannerEntry[]) => + [...entries].sort((a, b) => b.priority - a.priority || a.id.localeCompare(b.id)); + +const useTopBannerStore = create((set) => ({ + entries: [], + register: (entry) => + set(({ entries }) => ({ + entries: sortEntries([...entries.filter((item) => item.id !== entry.id), entry]), + })), + unregister: (id) => set(({ entries }) => ({ entries: entries.filter((e) => e.id !== id) })), +})); + +/** + * Shared stack for the fixed banners above the app shell. Several can be on + * screen at once, so the total height driving `--teable-top-banner-height` is + * owned here; each caller gets back the offset it should render at. + */ +export const useTopBannerSlot = ({ + id, + height, + priority = 0, + visible, +}: { + id: TopBannerId; + height: number; + priority?: number; + visible: boolean; +}) => { + const entries = useTopBannerStore((state) => state.entries); + const register = useTopBannerStore((state) => state.register); + const unregister = useTopBannerStore((state) => state.unregister); + + useEffect(() => { + if (!visible) { + unregister(id); + return; + } + register({ id, height, priority }); + return () => unregister(id); + }, [id, height, priority, visible, register, unregister]); + + const totalHeight = entries.reduce((sum, entry) => sum + entry.height, 0); + + useEffect(() => { + if (!totalHeight) { + document.documentElement.style.removeProperty('--teable-top-banner-height'); + delete document.body.dataset.teableTopBanner; + return; + } + document.documentElement.style.setProperty('--teable-top-banner-height', `${totalHeight}px`); + document.body.dataset.teableTopBanner = 'visible'; + }, [totalHeight]); + + useEffect(() => { + return () => { + if (!useTopBannerStore.getState().entries.length) { + document.documentElement.style.removeProperty('--teable-top-banner-height'); + delete document.body.dataset.teableTopBanner; + } + }; + }, []); + + const index = entries.findIndex((entry) => entry.id === id); + const offset = + index < 0 ? 0 : entries.slice(0, index).reduce((sum, entry) => sum + entry.height, 0); + + return { offset }; +}; diff --git a/apps/nextjs-app/src/features/app/layouts/ShareBaseLayout.tsx b/apps/nextjs-app/src/features/app/layouts/ShareBaseLayout.tsx index d1f34824df..893ca39b04 100644 --- a/apps/nextjs-app/src/features/app/layouts/ShareBaseLayout.tsx +++ b/apps/nextjs-app/src/features/app/layouts/ShareBaseLayout.tsx @@ -11,6 +11,7 @@ import { BaseNodeProvider } from '../blocks/base/base-node/BaseNodeProvider'; import { BaseSideBar } from '../blocks/base/base-side-bar/BaseSideBar'; import { BaseSidebarHeaderLeft } from '../blocks/base/base-side-bar/BaseSidebarHeaderLeft'; import { BasePermissionListener } from '../blocks/base/BasePermissionListener'; +import { ShareBaseOperationProvider } from '../components/share-operation/ShareBaseOperationProvider'; import { Sidebar } from '../components/sidebar/Sidebar'; import { SideBarFooter } from '../components/SideBarFooter'; import { ShareContext } from '../context/ShareContext'; @@ -105,24 +106,26 @@ export const ShareBaseLayout: React.FC = ({ -
-
- }> - -
- -
-
- - - -
{children}
+ +
+
+ }> + +
+ +
+
+ + + +
{children}
+
-
+
diff --git a/apps/nextjs-app/src/features/app/layouts/SpaceInnerLayout.tsx b/apps/nextjs-app/src/features/app/layouts/SpaceInnerLayout.tsx index 5d7b37e259..cc44762073 100644 --- a/apps/nextjs-app/src/features/app/layouts/SpaceInnerLayout.tsx +++ b/apps/nextjs-app/src/features/app/layouts/SpaceInnerLayout.tsx @@ -15,6 +15,7 @@ import { SpaceQuickSearch } from '../blocks/space/space-side-bar/SpaceQuickSearc import { SpaceSwitcher } from '../blocks/space/space-side-bar/SpaceSwitcher'; import { Sidebar } from '../components/sidebar/Sidebar'; import { SideBarFooter } from '../components/SideBarFooter'; +import { usePrefetchBaseEntry } from '../hooks/usePrefetchBaseEntry'; import { useSdkLocale } from '../hooks/useSdkLocale'; import { SpacePageTitle } from './SpacePageTitle'; @@ -27,6 +28,8 @@ export const SpaceInnerLayout: React.FC<{ const { i18n } = useTranslation(); const { spaceId } = useParams<{ spaceId: string }>(); + usePrefetchBaseEntry(); + useEffect(() => { if (!spaceId || !isString(spaceId)) { return; diff --git a/apps/nextjs-app/src/features/i18n/share.config.ts b/apps/nextjs-app/src/features/i18n/share.config.ts index 5c84268210..99f28d211e 100644 --- a/apps/nextjs-app/src/features/i18n/share.config.ts +++ b/apps/nextjs-app/src/features/i18n/share.config.ts @@ -2,9 +2,9 @@ import type { I18nActiveNamespaces } from '@/lib/i18n'; export interface IShareConfig { // Define namespaces in use in both the type and the config. - i18nNamespaces: I18nActiveNamespaces<'share' | 'common' | 'table' | 'sdk' | 'share'>; + i18nNamespaces: I18nActiveNamespaces<'share' | 'common' | 'table' | 'sdk' | 'auth'>; } export const shareConfig: IShareConfig = { - i18nNamespaces: ['share', 'common', 'table', 'sdk', 'share'], + i18nNamespaces: ['share', 'common', 'table', 'sdk', 'auth'], }; diff --git a/apps/nextjs-app/src/lib/ensureLogin.spec.ts b/apps/nextjs-app/src/lib/ensureLogin.spec.ts new file mode 100644 index 0000000000..771e1625dd --- /dev/null +++ b/apps/nextjs-app/src/lib/ensureLogin.spec.ts @@ -0,0 +1,130 @@ +import { HttpError } from '@teable/core'; +import type { GetServerSidePropsContext } from 'next'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getUserMe } from '@/backend/api/rest/get-user'; +import ensureLogin from './ensureLogin'; + +vi.mock('@/backend/api/rest/get-user', () => ({ + getUserMe: vi.fn(), +})); + +vi.mock('@/features/auth/components/SocialAuth', () => ({ + providersAll: [], +})); + +const mockedGetUserMe = vi.mocked(getUserMe); + +const createContext = (url = '/base/bse123/table/tbl123') => + ({ + req: { headers: { cookie: 'session=1' }, url }, + res: {}, + query: {}, + }) as unknown as GetServerSidePropsContext; + +const user = { id: 'usr123', name: 'Test' }; + +describe('ensureLogin parallel handler mode', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + it('merges user into handler props when both succeed', async () => { + mockedGetUserMe.mockResolvedValue(user as never); + const handler = vi.fn().mockResolvedValue({ props: { foo: 1 } }); + + const result = await ensureLogin(handler, false, { parallelHandler: true })(createContext()); + + expect(handler).toHaveBeenCalledTimes(1); + expect(result).toEqual({ props: { foo: 1, user } }); + }); + + it('redirects to login when user lookup fails with 4xx, even if handler succeeds', async () => { + mockedGetUserMe.mockRejectedValue(new HttpError('Unauthorized', 401)); + const handler = vi.fn().mockResolvedValue({ props: { foo: 1 } }); + const url = '/base/bse123/table/tbl123'; + + const result = await ensureLogin(handler, false, { parallelHandler: true })(createContext(url)); + + expect(result).toEqual({ + redirect: { + destination: `/auth/login?redirect=${encodeURIComponent(url)}`, + permanent: false, + }, + }); + }); + + it('keeps handler result and sets err prop when user lookup fails with 5xx', async () => { + mockedGetUserMe.mockRejectedValue(new HttpError('boom', 500)); + const handler = vi.fn().mockResolvedValue({ props: { foo: 1 } }); + + const result = await ensureLogin(handler, false, { parallelHandler: true })(createContext()); + + expect(result).toEqual({ props: { foo: 1, err: 'boom' } }); + }); + + it('keeps handler result and sets err prop on non-http user lookup errors', async () => { + mockedGetUserMe.mockRejectedValue(new Error('socket hang up')); + const handler = vi.fn().mockResolvedValue({ props: { foo: 1 } }); + + const result = await ensureLogin(handler, false, { parallelHandler: true })(createContext()); + + expect(result).toEqual({ props: { foo: 1, err: 'socket hang up' } }); + }); + + it('propagates handler rejection when the user lookup succeeds', async () => { + mockedGetUserMe.mockResolvedValue(user as never); + const handlerError = new Error('handler exploded'); + const handler = vi.fn().mockRejectedValue(handlerError); + + await expect( + ensureLogin(handler, false, { parallelHandler: true })(createContext()) + ).rejects.toBe(handlerError); + }); + + it('attaches user props to handler redirects, mirroring the serial mode', async () => { + mockedGetUserMe.mockResolvedValue(user as never); + const handler = vi + .fn() + .mockResolvedValue({ redirect: { destination: '/base/bse123', permanent: false } }); + + const result = await ensureLogin(handler, false, { parallelHandler: true })(createContext()); + + expect(result).toEqual({ + redirect: { destination: '/base/bse123', permanent: false }, + props: { user }, + }); + }); +}); + +describe('ensureLogin serial mode (default)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not invoke the handler when the user lookup fails with 4xx', async () => { + mockedGetUserMe.mockRejectedValue(new HttpError('Unauthorized', 401)); + const handler = vi.fn().mockResolvedValue({ props: { foo: 1 } }); + const url = '/base/bse123/table/tbl123'; + + const result = await ensureLogin(handler)(createContext(url)); + + expect(handler).not.toHaveBeenCalled(); + expect(result).toEqual({ + redirect: { + destination: `/auth/login?redirect=${encodeURIComponent(url)}`, + permanent: false, + }, + }); + }); + + it('merges user into handler props when both succeed', async () => { + mockedGetUserMe.mockResolvedValue(user as never); + const handler = vi.fn().mockResolvedValue({ props: { foo: 1 } }); + + const result = await ensureLogin(handler)(createContext()); + + expect(handler).toHaveBeenCalledTimes(1); + expect(result).toEqual({ props: { foo: 1, user } }); + }); +}); diff --git a/apps/nextjs-app/src/lib/ensureLogin.ts b/apps/nextjs-app/src/lib/ensureLogin.ts index 821a5c7e71..72e54dec6c 100644 --- a/apps/nextjs-app/src/lib/ensureLogin.ts +++ b/apps/nextjs-app/src/lib/ensureLogin.ts @@ -19,10 +19,14 @@ type GetServerSideProps< export default function ensureLogin

( handler: GetServerSideProps, - isLoginPage?: boolean + isLoginPage?: boolean, + options?: { parallelHandler?: boolean } ): NextGetServerSideProps

{ // eslint-disable-next-line sonarjs/cognitive-complexity return async (context: GetServerSidePropsContext) => { + if (options?.parallelHandler && !isLoginPage) { + return ensureLoginParallel(handler, context); + } const req = context.req; let props: { [key: string]: any } = {}; try { @@ -90,6 +94,54 @@ export default function ensureLogin

( }; }; } +// The user lookup only gates the login redirect, so it doesn't have to block +// the handler's own requests — those fail with 401 on their own (and withAuthSSR +// already redirects to login) when the session is invalid. +async function ensureLoginParallel

( + handler: GetServerSideProps, + context: GetServerSidePropsContext +): Promise> { + const req = context.req; + let props: { [key: string]: any } = {}; + const [userResult, handlerResult] = await Promise.allSettled([ + getUserMe(req?.headers.cookie), + handler(context), + ]); + + if (userResult.status === 'fulfilled') { + props['user'] = userResult.value; + } else { + const error = userResult.reason; + if (error instanceof HttpError && error.status < 500 && error.status >= 400) { + const redirect = encodeURIComponent(req?.url || ''); + const query = redirect ? `redirect=${redirect}` : ''; + return { + redirect: { + destination: `/auth/login?${query}`, + permanent: false, + }, + }; + } + console.error('ensureLogin: ', error); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + props['err'] = (error as any)?.message; + } + + if (handlerResult.status === 'rejected') { + throw handlerResult.reason; + } + const res = handlerResult.value; + if ('props' in res) { + props = { + ...(await res.props), + ...props, + }; + } + return { + ...res, + props: props as P, + }; +} /* eslint-enable @typescript-eslint/no-explicit-any */ // Redirect to social auth if password login is disabled and only one provider is available diff --git a/apps/nextjs-app/src/pages/base/[baseId]/[[...slug]].tsx b/apps/nextjs-app/src/pages/base/[baseId]/[[...slug]].tsx index 0265d9f478..91836a6513 100644 --- a/apps/nextjs-app/src/pages/base/[baseId]/[[...slug]].tsx +++ b/apps/nextjs-app/src/pages/base/[baseId]/[[...slug]].tsx @@ -49,8 +49,6 @@ export const getServerSideProps: GetServerSideProps = withEn withAuthSSR(async (context, ssrApi) => { const { baseId, slug, ...queryParams } = context.query; context.res.setHeader('Content-Security-Policy', 'frame-ancestors *;'); - const queryClient = new QueryClient(); - const base = await handleBase(baseId as string, ssrApi, queryClient); // Redirect legacy table URLs: /base/xxx/tbl1/viw1 → /base/xxx/table/tbl1/viw1 if (Array.isArray(slug) && slug.length > 0 && slug[0].startsWith(IdPrefix.Table)) { const queryString = new URLSearchParams(queryParams as Record).toString(); @@ -59,19 +57,36 @@ export const getServerSideProps: GetServerSideProps = withEn return redirect(`/base/${baseId}/table/${tablePath}${query}`); } - const parsed = parseBaseSlug(slug as string[]); + // This QueryClient lives for a single SSR pass — everything it fetches + // stays fresh, so repeated fetchQuery calls on the same key (e.g. the + // table list validation) reuse the result instead of refetching. + const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: Infinity } }, + }); const baseIdStr = baseId as string; - await Promise.all([ - queryClient.fetchQuery({ - queryKey: ReactQueryKeys.base(baseIdStr), - queryFn: () => base, - }), - queryClient.fetchQuery({ - queryKey: ReactQueryKeys.getBasePermission(baseIdStr), - queryFn: () => ssrApi.getBasePermission(baseIdStr), - }), + // The permission call only needs baseId, so it runs alongside the base + // fetch. Its endpoint is PUBLIC-annotated with the same headerless + // template fallback as the base endpoint, so it succeeds for template + // previews too; the replay below (with the template header handleBase + // installed) is only a backstop when the parallel call failed anyway. + const [base, permissionResult] = await Promise.all([ + handleBase(baseIdStr, ssrApi, queryClient), + ssrApi.getBasePermission(baseIdStr).then( + (data) => ({ data }), + (error) => ({ error }) + ), ]); + let basePermission; + if ('data' in permissionResult) { + basePermission = permissionResult.data; + } else if (base?.template?.headers) { + basePermission = await ssrApi.getBasePermission(baseIdStr); + } else { + throw permissionResult.error; + } + queryClient.setQueryData(ReactQueryKeys.getBasePermission(baseIdStr), basePermission); + const parsed = parseBaseSlug(slug as string[]); ssrApi.configureBaseHeaders(base); const i18nNamespaces = baseAllConfig.i18nNamespaces; @@ -99,7 +114,10 @@ export const getServerSideProps: GetServerSideProps = withEn default: return { notFound: true }; } - }) + }), + false, + // user/me only gates the login redirect — run it alongside the handler + { parallelHandler: true } ) ); diff --git a/apps/nextjs-app/tsconfig.json b/apps/nextjs-app/tsconfig.json index 4351402f9b..f1ecd51121 100644 --- a/apps/nextjs-app/tsconfig.json +++ b/apps/nextjs-app/tsconfig.json @@ -26,6 +26,7 @@ "./features/app/blocks/space-setting/SpaceInnerSettingModal" ], "@overridable/WorkFlowPanel": ["./features/app/automation/workflow-panel/WorkFlowPanel"], + "@overridable/TableArchiveDialog": ["./features/app/blocks/archive/TableArchiveDialog"], "@overridable/SettingDialog": ["./features/app/components/setting/SettingDialog"], "@teable/common-i18n": ["../../../packages/common-i18n/src/index"], "@teable/common-i18n/locales/*": ["../../../packages/common-i18n/src/locales/*"], diff --git a/packages/common-i18n/src/locales/de/common.json b/packages/common-i18n/src/locales/de/common.json index c53ba2c607..7e97c7b128 100644 --- a/packages/common-i18n/src/locales/de/common.json +++ b/packages/common-i18n/src/locales/de/common.json @@ -62,9 +62,6 @@ "refresh": "Aktualisieren", "login": "Anmelden", "useTemplate": "Vorlage verwenden", - "copyToMySpace": "Copy to my space", - "saveToMySpace": "Save to my space", - "supportSaveCopy": "Support saving a copy", "backToSpace": "Zurück zum Space", "switchBase": "Base wechseln", "getMore": "Mehr erhalten", @@ -120,6 +117,7 @@ "baseShare": { "shareTitle": "Teilen", "shareToWeb": "Im Web teilen", + "noPermissionTip": "Sie haben keine Berechtigung, die Freigabeeinstellungen zu verwalten", "linkHolderLabel": "Person mit dem Link", "linkHolderCanView": "Kann anzeigen", "linkHolderCanViewDesc": "Jeder mit dem Link kann die Daten anzeigen", @@ -545,6 +543,20 @@ "viewPricing": "Preise anzeigen", "billable": "Abrechenbar", "billableByAuthorityMatrix": "Abrechnung durch Berechtigungsmatrix generiert", + "seatConfirm": { + "title": "Diese Einladung erhöht Ihre Abonnementkosten", + "roleChangeTitle": "Diese Rollenänderung erhöht Ihre Abonnementkosten", + "matrixTitle": "Das Hinzufügen von Mitgliedern erhöht Ihre Abonnementkosten", + "inviteDesc_one": "Das eingeladene Mitglied tritt mit der Rolle {{role}} bei und belegt 1 Abonnement-Platz; Ihre Kosten steigen entsprechend. Wenn nur Ansehen oder Kommentieren nötig ist, wählen Sie einfach die kostenlose Rolle „Betrachter“ oder „Kommentator“.", + "inviteDesc_other": "Die {{count}} eingeladenen Mitglieder treten mit der Rolle {{role}} bei und belegen {{count}} Abonnement-Plätze; Ihre Kosten steigen entsprechend. Wenn nur Ansehen oder Kommentieren nötig ist, wählen Sie einfach die kostenlose Rolle „Betrachter“ oder „Kommentator“.", + "linkDesc": "Mitglieder, die über diesen Link beitreten, erhalten die Rolle {{role}} und belegen je 1 Abonnement-Platz; Ihre Kosten steigen mit jeder beitretenden Person. Für reines Ansehen oder Kommentieren wählen Sie einfach die kostenlose Rolle „Betrachter“ oder „Kommentator“.", + "roleChangeDesc": "Nach der Änderung auf {{role}} belegt dieses Mitglied 1 Abonnement-Platz; Ihre Kosten steigen entsprechend.", + "matrixDesc": "Dieses Mitglied hat derzeit eine kostenlose Rolle (Betrachter/Kommentator). Nach dem Hinzufügen zur Berechtigungsmatrix belegt es 1 Abonnement-Platz; Ihre Kosten steigen entsprechend.", + "seatLimitTitle": "Nicht genügend Lizenzplätze", + "seatLimitDesc": "Diese Instanz nutzt {{seats}} von {{seatLimit}} lizenzierten Plätzen; für diese Aktion reichen die verbleibenden Plätze nicht aus. Bitte wenden Sie sich an Ihren Administrator, um weitere Plätze zu erwerben. Wenn nur Ansehen oder Kommentieren nötig ist, können Sie stattdessen die kostenlose Rolle „Betrachter“ oder „Kommentator“ verwenden.", + "seatLimitConfirm": "Verstanden", + "confirmInvite": "Bestätigen und einladen" + }, "licenseExpiredGracePeriod": "Ihre Self-Hosted-Lizenz ist abgelaufen und wird am {{expiredTime}} auf den kostenlosen Plan herabgestuft. Bitte aktualisieren Sie Ihre Lizenz umgehend, um Zugriff auf Premium-Funktionen zu behalten.", "licenseAutoFetchFailed": "Automatische Lizenzverlängerung fehlgeschlagen. Noch {{days}} Tag(e) Kulanzzeitraum bis zur Herabstufung.", "licenseAutoFetchRetryFailed": "Die automatische Verlängerung ist weiterhin fehlgeschlagen. Bitte überprüfen Sie die Verbindung zum Teable-Server oder aktualisieren Sie die Lizenz manuell.", @@ -1290,17 +1302,19 @@ "updateSuccess": "Fähigkeit erfolgreich aktualisiert" } }, - "changelog": { - "newUpdate": "UPDATE VOM 28. JULI", - "title": "Teable Skill für KI-Agenten", - "url": "https://help.teable.ai/en/changelog#jul-28-2026", - "id": "changelog-2026-07-28-teable-skill-for-ai-agents" - }, "resourceDescription": { "addDescription": "Beschreibung hinzufügen", "nodeDescription": "Knotenbeschreibung", "descriptionSaving": "Speichern...", "descriptionSaveFailed": "Beschreibung konnte nicht gespeichert werden, bitte erneut versuchen", "descriptionPlaceholder": "Fügen Sie diesem Knoten eine Beschreibung hinzu" + }, + "announcement": { + "viewDetail": "Details anzeigen", + "close": "Ankündigung schließen", + "acknowledge": "Verstanden", + "collapse": "Weniger anzeigen", + "more_one": "{{count}} weitere Ankündigung", + "more_other": "{{count}} weitere Ankündigungen" } } diff --git a/packages/common-i18n/src/locales/de/sdk.json b/packages/common-i18n/src/locales/de/sdk.json index 98bb4a58c2..511cd047c4 100644 --- a/packages/common-i18n/src/locales/de/sdk.json +++ b/packages/common-i18n/src/locales/de/sdk.json @@ -39,7 +39,9 @@ }, "preview": { "previewFileLimit": "Größenbeschränkung der Vorschaudatei: {{size}}MB, bitte laden Sie die Datei herunter, um sie anzusehen.", - "loadFileError": "Datei konnte nicht geladen werden" + "loadFileError": "Datei konnte nicht geladen werden", + "previousAttachment": "Vorherige", + "nextAttachment": "Nächste" }, "undoRedo": { "undo": "Rückgängig machen", @@ -257,6 +259,8 @@ }, "expandRecord": { "copy": "In die Zwischenablage kopieren", + "previousRecord": "Zurück", + "nextRecord": "Weiter", "duplicateRecord": "Datensatz duplizieren", "copyRecordUrl": "Datensatz URL kopieren", "deleteRecord": "Datensatz löschen", @@ -386,6 +390,8 @@ "tableTrashRead": "Tabelle Papierkorb lesen", "tableTrashUpdate": "Tabelle Papierkorb akualisieren", "tableTrashReset": "Tabelle Papierkorb zurücksetzen", + "tableArchiveRead": "Tabellenarchiv lesen", + "tableArchiveManage": "Tabellenarchiv verwalten", "viewCreate": "Ansicht erstellen", "viewDelete": "Ansicht löschen", "viewRead": "Ansicht lesen", @@ -401,6 +407,7 @@ "recordRead": "Datensatz lesen", "recordUpdate": "Datensatz aktualisieren", "recordCopy": "Copy record", + "recordArchive": "Datensatz archivieren", "automationCreate": "Automatisierung erstellen", "automationDelete": "Automatisierung löschen", "automationRead": "Automatisierung lesen", @@ -926,15 +933,18 @@ "nameMaxLength": "Der Name ist zu lang. Maximal zulässig sind {{max}} Zeichen.", "descriptionMaxLength": "Die Beschreibung ist zu lang. Maximal zulässig sind {{max}} Zeichen." }, - "validation": { - "field": { - "unique": "Feld muss einen eindeutigen Wert haben" - } - }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" Feld \"{{fieldName}}\" darf keine leeren Werte enthalten, bitte vollständig ausfüllen bevor Sie absenden.", "fieldValueDuplicate": "\"{{tableName}}\" Feld \"{{fieldName}}\" darf keine doppelten Werte enthalten, bitte einen eindeutigen Wert vor dem Absenden ausfüllen.", + "recordFieldValueNotNull": "Feld \"{{fieldName}}\" darf keine leeren Werte enthalten, bitte vollständig ausfüllen bevor Sie absenden.", + "recordFieldValueDuplicate": "Feld \"{{fieldName}}\" darf keine doppelten Werte enthalten, bitte einen eindeutigen Wert vor dem Absenden ausfüllen.", "linkFieldValueDuplicate": "\"{{fieldName}}\" Feld darf keine doppelten Werte enthalten, bitte einen eindeutigen Wert vor dem Absenden ausfüllen.", + "linkBatchDuplicate": "Verknüpfung nicht möglich: Im selben Batch wurde der Datensatz bereits von einem anderen Datensatz verknüpft. In Eins-zu-viele-Beziehungen kann jeder untergeordnete Datensatz nur zu einem übergeordneten Datensatz gehören.", + "linkOneManyDuplicate": "Verknüpfung nicht möglich: Der Datensatz ist bereits mit einem anderen Datensatz verknüpft. In Eins-zu-viele-Beziehungen kann jeder untergeordnete Datensatz nur zu einem übergeordneten Datensatz gehören.", + "linkOneOneDuplicate": "Verknüpfung nicht möglich: Der Zieldatensatz ist in einer Eins-zu-eins-Beziehung bereits mit einem anderen Datensatz verknüpft.", + "fieldMaxColumnLimit": "Die Tabelle \"{{tableName}}\" darf höchstens {{maxFieldCount}} Felder enthalten.", + "fieldRequiredExistingValues": "Feld \"{{fieldName}}\" kann nicht als erforderlich markiert werden, da vorhandene Datensätze leere Werte enthalten.", + "fieldUniqueExistingValues": "Feld \"{{fieldName}}\" kann nicht als eindeutig markiert werden, da vorhandene Datensätze doppelte Werte enthalten.", "requestTimeout": "Der aktuelle Vorgangsbereich ist zu groß, bitte versuchen Sie es mit einem kleineren Bereich erneut.", "searchTimeOut": "Suche ist abgelaufen, bitte die Suchbegriffe reduzieren und erneut versuchen.", "dependencyNodeRequire": "Abhängiger Knoten nicht getestet, bitte überprüfen Sie, ob alle vorherigen Knoten getestet wurden", diff --git a/packages/common-i18n/src/locales/de/table.json b/packages/common-i18n/src/locales/de/table.json index 5453074de4..0706526ac9 100644 --- a/packages/common-i18n/src/locales/de/table.json +++ b/packages/common-i18n/src/locales/de/table.json @@ -102,17 +102,6 @@ "help": "Weitere Informationen finden Sie im .", "helpCenter": "Hilfe-Center" }, - "validation": { - "link": { - "batch_duplicate": "Verknüpfung nicht möglich: Im selben Batch wurde der Datensatz bereits von einem anderen Datensatz verknüpft. In Eins-zu-viele-Beziehungen kann jeder untergeordnete Datensatz nur zu einem übergeordneten Datensatz gehören.", - "one_many_duplicate": "Verknüpfung nicht möglich: Der Datensatz ist bereits mit einem anderen Datensatz verknüpft. In Eins-zu-viele-Beziehungen kann jeder untergeordnete Datensatz nur zu einem übergeordneten Datensatz gehören.", - "one_one_duplicate": "Verknüpfung nicht möglich: Der Zieldatensatz ist in einer Eins-zu-eins-Beziehung bereits mit einem anderen Datensatz verknüpft." - }, - "field": { - "maxColumnLimit": "Die Tabelle \"{{tableName}}\" darf höchstens {{maxFieldCount}} Felder enthalten.", - "requiredExistingValues": "Feld \"{{fieldName}}\" kann nicht als erforderlich markiert werden, da vorhandene Datensätze leere Werte enthalten." - } - }, "field": { "advancedProps": "Erweiterte Eigenschaften", "hide": "verstecken", @@ -478,6 +467,11 @@ "fillFailed": "Füllen fehlgeschlagen", "clearing": "Leeren...", "clearSuccessful": "Leeren erfolgreich", + "archiveRecordConfirmTitle": "Datensätze archivieren", + "archiveRecordConfirmDescription": "Diese Aktion archiviert {{recordCount}} Datensätze. Archivierte Datensätze können im Tabellenarchiv eingesehen und wiederhergestellt werden.", + "archiveRecord": "Archivieren", + "archiving": "Archiviere...", + "archiveSuccessful": "Archivierung erfolgreich", "deleting": "Lösche...", "deleteSuccessful": "Löschen erfolgreich", "deleteStream": { @@ -834,6 +828,8 @@ "insertRecordBelow": "Datensatz unterhalb einfügen", "deleteRecord": "Datensätze löschen", "deleteAllSelectedRecords": "Alle ausgewählten Datensätze löschen", + "archiveRecord": "Datensatz archivieren", + "archiveAllSelectedRecords": "Alle ausgewählten Datensätze archivieren", "editField": "Feld bearbeiten", "insertFieldLeft": "Links einfügen", "insertFieldRight": "Rechts einfügen", @@ -901,11 +897,49 @@ "title": "Möchten Sie mehrere Datensätze hinzufügen?", "description": "Die {{count}} Datensätze werden der Tabelle hinzugefügt." }, + "tableArchive": { + "title": "Tabellenarchiv", + "menuTitle": "Archiv", + "archivedTime": "Archivierungszeit", + "archivedBy": "Archiviert von", + "recordDetail": "Datensatzdetails", + "empty": "Keine archivierten Datensätze", + "allCreators": "Alle Ersteller", + "filterArchivedTime": "Archivierungszeit", + "searchPlaceholder": "Archivierte Datensätze durchsuchen", + "clearFilter": "Filter zurücksetzen", + "export": "CSV exportieren", + "exporting": "Exportiere… {{count}} Zeilen", + "exportSucceed": "Export abgeschlossen, Download startet", + "restoreSelected": "Wiederherstellen ({{count}})", + "permanentDeleteSelected": "Endgültig löschen ({{count}})", + "permanentDeleteConfirm": "{{count}} archivierte Datensätze endgültig löschen? Dies kann nicht rückgängig gemacht werden.", + "permanentDeleteSucceed": "Endgültig gelöscht", + "resetArchive": "Archiv leeren", + "resetArchiveConfirm": "Alle archivierten Datensätze dieser Tabelle löschen? Dies kann nicht rückgängig gemacht werden.", + "resetSucceed": "Archiv geleert", + "orderBy": { + "archivedTime": "Nach Archivierungszeit sortieren", + "recordCreatedTime": "Nach Erstellungszeit sortieren", + "recordLastModifiedTime": "Nach letzter Änderung sortieren" + } + }, "tableTrash": { "title": "Papierkorb", "resourceType": "Typ", "deletedResource": "Ressource", - "moreResources": "und {{count}} weitere" + "moreResources": "und {{count}} weitere", + "deletedTime": "Löschzeitpunkt", + "deletedBy": "Gelöscht von", + "filterAllTypes": "Alle Typen", + "filterAllUsers": "Alle Benutzer", + "filterDeletedTime": "Löschzeitpunkt", + "clearFilter": "Filter zurücksetzen", + "recordsDialogTitle": "Gelöschte Datensätze ({{count}})", + "recordDetail": "Datensatzdetails", + "filterAllCreators": "Alle Ersteller", + "filterCreatedTime": "Erstellungszeit", + "searchPlaceholder": "Datensätze durchsuchen" }, "baseShare": { "shareTitle": "Teilen", @@ -935,7 +969,10 @@ "linkScopeDialogTitle": "Link field scope", "linkHolderCanCopyAndSave": "Kann als Kopie speichern", "linkHolderCanCopyAndSaveDesc": "Anyone with the link can save a copy to their space", - "editRequiresLogin": "Users must log in to edit records", + "copyToMySpace": "Copy to my space", + "saveToMySpace": "Save to my space", + "supportSaveCopy": "Kopie in Ihrem Bereich speichern", + "editRequiresLogin": "Nach der Anmeldung können Sie Datensätze bearbeiten", "enterPassword": "Passwort eingeben", "allowCopyData": "Betrachtern erlauben, Daten zu kopieren", "sharedNode": "Geteilter Knoten", diff --git a/packages/common-i18n/src/locales/en/common.json b/packages/common-i18n/src/locales/en/common.json index 9ccf919ae2..a0442f38f1 100644 --- a/packages/common-i18n/src/locales/en/common.json +++ b/packages/common-i18n/src/locales/en/common.json @@ -64,9 +64,6 @@ "refresh": "Refresh", "login": "Login", "useTemplate": "Use template", - "copyToMySpace": "Copy to my space", - "saveToMySpace": "Save to my space", - "supportSaveCopy": "Support saving a copy", "copyLink": "Copy link", "openLink": "Open link", "backToSpace": "Back to space", @@ -125,6 +122,7 @@ "baseShare": { "shareTitle": "Share", "shareToWeb": "Share to web", + "noPermissionTip": "You don't have permission to manage share settings", "linkHolderLabel": "The person who obtained the link", "linkHolderCanView": "Can view", "linkHolderCanViewDesc": "Anyone with the link can view the data", @@ -498,8 +496,6 @@ "title": "{{spaceName}} space sharing", "desc_one": "This space has {{count}} collaborator. Adding a space collaborator will give them access to all bases within this space.", "desc_other": "This space has {{count}} collaborators. Adding a space collaborator will give them access to all bases within this space.", - "desc_billable_one": "This space has {{count}} collaborator and {{billableCount}} billable user. Adding a space collaborator will give them access to all bases within this space.", - "desc_billable_other": "This space has {{count}} collaborators and {{billableCount}} billable users. Adding a space collaborator will give them access to all bases within this space.", "tabEmail": "Invite by email", "emailPlaceholder": "Enter email addresses, separated by 'Enter' key", "tabLink": "Invite by link", @@ -573,6 +569,20 @@ "viewPricing": "View pricing", "billable": "Billable", "billableByAuthorityMatrix": "Billing generated by authority matrix", + "seatConfirm": { + "title": "This invitation will increase your bill", + "roleChangeTitle": "This role change will increase your bill", + "matrixTitle": "Adding members will increase your bill", + "inviteDesc_one": "The member you're inviting will join as {{role}}, taking up 1 subscription seat, and your cost will increase accordingly. If they only need to view or comment, just choose the free Viewer or Commenter role.", + "inviteDesc_other": "The {{count}} members you're inviting will join as {{role}}, taking up {{count}} subscription seats, and your cost will increase accordingly. If they only need to view or comment, just choose the free Viewer or Commenter role.", + "linkDesc": "Members joining via this link will get the {{role}} role, each taking up a subscription seat, and your cost will grow as people join. If they only need to view or comment, just choose the free Viewer or Commenter role.", + "roleChangeDesc": "After changing to {{role}}, this member will take up a subscription seat and your cost will increase accordingly.", + "matrixDesc": "This member is currently on a free role (Viewer/Commenter). Adding them to the authority matrix will take up 1 subscription seat, and your cost will increase accordingly.", + "seatLimitTitle": "Insufficient license seats", + "seatLimitDesc": "This instance is using {{seats}} of {{seatLimit}} licensed seats, and there aren't enough left for this action. Please contact your admin to purchase more seats. If they only need to view or comment, you can use the free Viewer or Commenter role instead.", + "seatLimitConfirm": "Got it", + "confirmInvite": "Confirm and invite" + }, "licenseExpiredGracePeriod": "Your self-hosted license has expired and will downgrade to the free plan on {{expiredTime}}. Please update your license promptly to retain access to premium features.", "licenseAutoFetchFailed": "License auto-renewal failed. {{days}} day(s) remaining in grace period before downgrade.", "licenseAutoFetchRetryFailed": "Auto-renewal still failed. Please check the connection to the Teable server, or update the license manually.", @@ -1650,17 +1660,19 @@ "copyError": "Copy failed" } }, - "changelog": { - "newUpdate": "JUL 28 UPDATE", - "title": "Teable Skill for AI Agents", - "url": "https://help.teable.ai/en/changelog#jul-28-2026", - "id": "changelog-2026-07-28-teable-skill-for-ai-agents" - }, "resourceDescription": { "addDescription": "Add description", "nodeDescription": "Node description", "descriptionSaving": "Saving...", "descriptionSaveFailed": "Failed to save description, please try again", "descriptionPlaceholder": "Add a description for this node" + }, + "announcement": { + "viewDetail": "View details", + "close": "Dismiss announcement", + "acknowledge": "Got it", + "collapse": "Show fewer", + "more_one": "{{count}} more announcement", + "more_other": "{{count}} more announcements" } } diff --git a/packages/common-i18n/src/locales/en/sdk.json b/packages/common-i18n/src/locales/en/sdk.json index 8c91c292cb..c21273ab94 100644 --- a/packages/common-i18n/src/locales/en/sdk.json +++ b/packages/common-i18n/src/locales/en/sdk.json @@ -44,7 +44,9 @@ }, "preview": { "previewFileLimit": "Preview file size limit: {{size}}MB, please download to view instead.", - "loadFileError": "Failed to load file" + "loadFileError": "Failed to load file", + "previousAttachment": "Previous", + "nextAttachment": "Next" }, "undoRedo": { "undo": "Undo", @@ -273,6 +275,8 @@ }, "expandRecord": { "copy": "Copy to clipboard", + "previousRecord": "Previous", + "nextRecord": "Next", "duplicateRecord": "Duplicate record", "copyRecordUrl": "Copy record URL", "deleteRecord": "Delete record", @@ -402,6 +406,8 @@ "tableTrashRead": "Read table trash", "tableTrashUpdate": "Update table trash", "tableTrashReset": "Reset table trash", + "tableArchiveRead": "Read table archive", + "tableArchiveManage": "Manage table archive", "viewCreate": "Create view", "viewDelete": "Delete view", "viewRead": "Read view", @@ -417,6 +423,7 @@ "recordRead": "Read record", "recordUpdate": "Update record", "recordCopy": "Copy record", + "recordArchive": "Archive record", "automationCreate": "Create automation", "automationDelete": "Delete automation", "automationRead": "Read automation", @@ -950,15 +957,18 @@ "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." }, - "validation": { - "field": { - "unique": "Field must have a unique value" - } - }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" field \"{{fieldName}}\" does not allow empty values, please fill in completely before submitting.", "fieldValueDuplicate": "\"{{tableName}}\" field \"{{fieldName}}\" does not allow duplicate values, please fill in a unique value before submitting.", + "recordFieldValueNotNull": "Field \"{{fieldName}}\" does not allow empty values, please fill it in before submitting.", + "recordFieldValueDuplicate": "Field \"{{fieldName}}\" does not allow duplicate values, please fill in a unique value before submitting.", "linkFieldValueDuplicate": "\"{{fieldName}}\" field does not allow duplicate associations with the same record", + "linkBatchDuplicate": "Cannot link record(s): already linked by another record in the same batch. In one-to-many relationships, each record can only belong to one parent.", + "linkOneManyDuplicate": "Cannot link record(s): already linked to another record. In one-to-many relationships, each record can only belong to one parent.", + "linkOneOneDuplicate": "Cannot link record(s): the target record is already linked by another record in a one-to-one relationship.", + "fieldMaxColumnLimit": "Table \"{{tableName}}\" can have at most {{maxFieldCount}} fields.", + "fieldRequiredExistingValues": "Cannot mark field \"{{fieldName}}\" as required because existing records contain empty values.", + "fieldUniqueExistingValues": "Cannot mark field \"{{fieldName}}\" as unique because existing records contain duplicate values.", "requestTimeout": "This action is too large, please try with a smaller scope.", "searchTimeOut": "Search timeout, please decrease the search scope and try again", "dependencyNodeRequire": "Dependency node not tested, please check if all previous nodes have been tested", diff --git a/packages/common-i18n/src/locales/en/table.json b/packages/common-i18n/src/locales/en/table.json index 1ab2b954e3..70e9a78713 100644 --- a/packages/common-i18n/src/locales/en/table.json +++ b/packages/common-i18n/src/locales/en/table.json @@ -103,17 +103,6 @@ "help": "Visit the for more information", "helpCenter": "Help center" }, - "validation": { - "link": { - "batch_duplicate": "Cannot link record(s): already linked by another record in the same batch. In one-to-many relationships, each record can only belong to one parent.", - "one_many_duplicate": "Cannot link record(s): already linked to another record. In one-to-many relationships, each record can only belong to one parent.", - "one_one_duplicate": "Cannot link record(s): the target record is already linked by another record in a one-to-one relationship." - }, - "field": { - "maxColumnLimit": "Table \"{{tableName}}\" can have at most {{maxFieldCount}} fields.", - "requiredExistingValues": "Cannot mark field \"{{fieldName}}\" as required because existing records contain empty values." - } - }, "field": { "advancedProps": "Advanced properties", "hide": "hide", @@ -517,6 +506,11 @@ "clearFailed": "Clear failed", "clearConfirmTitle": "Clear data", "clearConfirmDescription": "This action will clear {{cellCount}} cells in {{rowCount}} records. Are you sure you want to continue?", + "archiveRecordConfirmTitle": "Archive records", + "archiveRecordConfirmDescription": "This action will archive {{recordCount}} records. Archived records can be viewed and restored from the table archive.", + "archiveRecord": "Archive", + "archiving": "Archiving...", + "archiveSuccessful": "Archive successful", "deleteRecordConfirmTitle": "Delete records", "deleteRecordConfirmDescription": "This action will delete {{recordCount}} records. Are you sure you want to continue?", "duplicateRecordsConfirmTitle": "Duplicate records", @@ -1077,6 +1071,8 @@ "insertRecordBelow": "Insert record below", "deleteRecord": "Delete record", "deleteAllSelectedRecords": "Delete all selected records", + "archiveRecord": "Archive record", + "archiveAllSelectedRecords": "Archive all selected records", "duplicateRecords": "Duplicate selected records", "editField": "Edit field", "duplicateField": "Duplicate field", @@ -1147,11 +1143,49 @@ "title": "Do you want to add multiple records?", "description": "The {{count}} records will be added to the table." }, + "tableArchive": { + "title": "Table archive", + "menuTitle": "Archive", + "archivedTime": "Archived time", + "archivedBy": "Archived by", + "recordDetail": "Record detail", + "empty": "No archived records", + "allCreators": "All creators", + "filterArchivedTime": "Archived time", + "searchPlaceholder": "Search archived records", + "clearFilter": "Clear filter", + "export": "Export CSV", + "exporting": "Exporting... {{count}} rows", + "exportSucceed": "Export complete, downloading", + "restoreSelected": "Restore ({{count}})", + "permanentDeleteSelected": "Permanently delete ({{count}})", + "permanentDeleteConfirm": "Permanently delete {{count}} archived records? This cannot be undone.", + "permanentDeleteSucceed": "Permanently deleted", + "resetArchive": "Clear archive", + "resetArchiveConfirm": "Clear all archived records of this table? This cannot be undone.", + "resetSucceed": "Archive cleared", + "orderBy": { + "archivedTime": "Sort by archived time", + "recordCreatedTime": "Sort by created time", + "recordLastModifiedTime": "Sort by last modified time" + } + }, "tableTrash": { "title": "Trash", "resourceType": "Type", "deletedResource": "Resource", - "moreResources": "and {{count}} more" + "moreResources": "and {{count}} more", + "deletedTime": "Deleted time", + "deletedBy": "Deleted by", + "filterAllTypes": "All types", + "filterAllUsers": "All users", + "filterDeletedTime": "Deleted time", + "clearFilter": "Clear filter", + "recordsDialogTitle": "Deleted records ({{count}})", + "recordDetail": "Record details", + "filterAllCreators": "All creators", + "filterCreatedTime": "Created time", + "searchPlaceholder": "Search records" }, "pluginPanel": { "empty": { @@ -1205,7 +1239,10 @@ "linkScopeDialogTitle": "Link field scope", "linkHolderCanCopyAndSave": "Can save as copy", "linkHolderCanCopyAndSaveDesc": "Anyone with the link can save a copy to their space", - "editRequiresLogin": "Users must log in to edit records", + "copyToMySpace": "Copy to my space", + "saveToMySpace": "Save to my space", + "supportSaveCopy": "Save a copy to your space", + "editRequiresLogin": "Sign in to edit records", "enterPassword": "Enter password", "allowCopyData": "Allow viewers to copy data", "sharedNode": "Shared node", diff --git a/packages/common-i18n/src/locales/es/common.json b/packages/common-i18n/src/locales/es/common.json index 20025bddda..7be4f540b6 100644 --- a/packages/common-i18n/src/locales/es/common.json +++ b/packages/common-i18n/src/locales/es/common.json @@ -58,9 +58,6 @@ "refresh": "Actualizar", "login": "Iniciar sesión", "useTemplate": "Usar plantilla", - "copyToMySpace": "Copy to my space", - "saveToMySpace": "Save to my space", - "supportSaveCopy": "Support saving a copy", "backToSpace": "Volver al Espacio", "switchBase": "Cambiar Base", "continue": "Continuar", @@ -120,6 +117,7 @@ "baseShare": { "shareTitle": "Compartir", "shareToWeb": "Compartir en la web", + "noPermissionTip": "No tienes permiso para administrar la configuración de compartir", "linkHolderLabel": "Persona con el enlace", "linkHolderCanView": "Puede ver", "linkHolderCanViewDesc": "Cualquiera con el enlace puede ver los datos", @@ -545,6 +543,20 @@ "viewPricing": "Ver precios", "billable": "Facturable", "billableByAuthorityMatrix": "Facturación generada por la matriz de permisos", + "seatConfirm": { + "title": "Esta invitación aumentará el costo de tu suscripción", + "roleChangeTitle": "Este cambio de rol aumentará el costo de tu suscripción", + "matrixTitle": "Añadir miembros aumentará el costo de tu suscripción", + "inviteDesc_one": "El miembro que estás invitando se unirá con el rol {{role}} y ocupará 1 asiento de la suscripción; el costo aumentará en consecuencia. Si solo necesita ver o comentar, simplemente elige el rol gratuito Espectador o Comentador.", + "inviteDesc_other": "Los {{count}} miembros que estás invitando se unirán con el rol {{role}} y ocuparán {{count}} asientos de la suscripción; el costo aumentará en consecuencia. Si solo necesitan ver o comentar, simplemente elige el rol gratuito Espectador o Comentador.", + "linkDesc": "Los miembros que se unan mediante este enlace recibirán el rol {{role}} y cada uno ocupará 1 asiento de la suscripción; el costo crecerá a medida que se unan. Si solo necesitan ver o comentar, simplemente elige el rol gratuito Espectador o Comentador.", + "roleChangeDesc": "Tras cambiar a {{role}}, este miembro ocupará 1 asiento de la suscripción y el costo aumentará en consecuencia.", + "matrixDesc": "Este miembro tiene actualmente un rol gratuito (Espectador/Comentador). Al añadirlo a la matriz de permisos ocupará 1 asiento de la suscripción y el costo aumentará en consecuencia.", + "seatLimitTitle": "Asientos de licencia insuficientes", + "seatLimitDesc": "Esta instancia está usando {{seats}} de {{seatLimit}} asientos con licencia y no quedan suficientes para esta acción. Contacta con tu administrador para comprar más asientos. Si solo necesita ver o comentar, puedes usar el rol gratuito Espectador o Comentador.", + "seatLimitConfirm": "Entendido", + "confirmInvite": "Confirmar e invitar" + }, "licenseExpiredGracePeriod": "Su licencia autohospedada ha caducado y se degradará al plan gratuito el {{expiredTime}}. Actualice su licencia de inmediato para mantener el acceso a las funciones premium.", "licenseAutoFetchFailed": "Error en la renovación automática de la licencia. Quedan {{days}} día(s) del período de gracia antes de la degradación.", "licenseAutoFetchRetryFailed": "La renovación automática sigue fallando. Comprueba la conexión con el servidor de Teable o actualiza la licencia manualmente.", @@ -1293,17 +1305,19 @@ "updateSuccess": "Habilidad actualizada correctamente" } }, - "changelog": { - "newUpdate": "ACTUALIZACIÓN DEL 28 DE JULIO", - "title": "Teable Skill para agentes de IA", - "url": "https://help.teable.ai/en/changelog#jul-28-2026", - "id": "changelog-2026-07-28-teable-skill-for-ai-agents" - }, "resourceDescription": { "addDescription": "Agregar descripción", "nodeDescription": "Descripción del nodo", "descriptionSaving": "Guardando...", "descriptionSaveFailed": "No se pudo guardar la descripción, inténtalo de nuevo", "descriptionPlaceholder": "Agrega una descripción para este nodo" + }, + "announcement": { + "viewDetail": "Ver detalles", + "close": "Cerrar anuncio", + "acknowledge": "Entendido", + "collapse": "Mostrar menos", + "more_one": "{{count}} anuncio más", + "more_other": "{{count}} anuncios más" } } diff --git a/packages/common-i18n/src/locales/es/sdk.json b/packages/common-i18n/src/locales/es/sdk.json index edddd9fd47..222a84e08b 100644 --- a/packages/common-i18n/src/locales/es/sdk.json +++ b/packages/common-i18n/src/locales/es/sdk.json @@ -39,7 +39,9 @@ }, "preview": { "previewFileLimit": "Límite de tamaño para vista previa: {{size}}MB, por favor descarga para ver.", - "loadFileError": "Error al cargar el archivo" + "loadFileError": "Error al cargar el archivo", + "previousAttachment": "Anterior", + "nextAttachment": "Siguiente" }, "undoRedo": { "undo": "Deshacer", @@ -257,6 +259,8 @@ }, "expandRecord": { "copy": "Copiar al portapapeles", + "previousRecord": "Anterior", + "nextRecord": "Siguiente", "duplicateRecord": "Récord duplicado", "copyRecordUrl": "Copiar URL de registro", "deleteRecord": "Eliminar registro", @@ -386,6 +390,8 @@ "tableTrashRead": "Lea la basura de la mesa", "tableTrashUpdate": "Actualizar la basura de la tabla", "tableTrashReset": "Restablecer la basura de la mesa", + "tableArchiveRead": "Leer el archivo de la tabla", + "tableArchiveManage": "Gestionar el archivo de la tabla", "viewCreate": "Crear vista", "viewDelete": "Eliminar vista", "viewRead": "Leer vista", @@ -401,6 +407,7 @@ "recordRead": "Récord de lectura", "recordUpdate": "Registro de actualización", "recordCopy": "Copy record", + "recordArchive": "Archivar registro", "automationCreate": "Crear automatización", "automationDelete": "Eliminar la automatización", "automationRead": "Leer automatización", @@ -926,15 +933,18 @@ "nameMaxLength": "El nombre es demasiado largo. El máximo es {{max}} caracteres.", "descriptionMaxLength": "La descripción es demasiado larga. El máximo es {{max}} caracteres." }, - "validation": { - "field": { - "unique": "El campo debe tener un valor único" - } - }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" campo \"{{fieldName}}\" no permite valores vacíos, por favor complete antes de enviar.", "fieldValueDuplicate": "\"{{tableName}}\" campo \"{{fieldName}}\" no permite valores duplicados, por favor complete un valor único antes de enviar.", + "recordFieldValueNotNull": "El campo \"{{fieldName}}\" no permite valores vacíos, por favor complete antes de enviar.", + "recordFieldValueDuplicate": "El campo \"{{fieldName}}\" no permite valores duplicados, por favor complete un valor único antes de enviar.", "linkFieldValueDuplicate": "\"{{fieldName}}\" campo no permite asociaciones duplicadas con el mismo registro", + "linkBatchDuplicate": "No se puede vincular el registro: en el mismo lote ya está vinculado por otro registro. En las relaciones de uno a muchos, cada registro secundario solo puede pertenecer a un registro principal.", + "linkOneManyDuplicate": "No se puede vincular el registro: ya está vinculado a otro registro. En las relaciones de uno a muchos, cada registro secundario solo puede pertenecer a un registro principal.", + "linkOneOneDuplicate": "No se puede vincular el registro: el registro de destino ya está vinculado por otro registro en una relación de uno a uno.", + "fieldMaxColumnLimit": "La tabla \"{{tableName}}\" puede tener como máximo {{maxFieldCount}} campos.", + "fieldRequiredExistingValues": "No se puede marcar el campo \"{{fieldName}}\" como obligatorio porque hay registros existentes con valores vacíos.", + "fieldUniqueExistingValues": "No se puede marcar el campo \"{{fieldName}}\" como único porque hay registros existentes con valores duplicados.", "requestTimeout": "El ámbito de la operación actual es demasiado grande, por favor intente con un ámbito más pequeño.", "searchTimeOut": "La búsqueda ha expirado, por favor reduce el ámbito de búsqueda y vuelve a intentarlo.", "dependencyNodeRequire": "Nodo de dependencia no probado, por favor verifica si todos los nodos anteriores han sido probados", diff --git a/packages/common-i18n/src/locales/es/table.json b/packages/common-i18n/src/locales/es/table.json index 0095482b23..5a870ce93c 100644 --- a/packages/common-i18n/src/locales/es/table.json +++ b/packages/common-i18n/src/locales/es/table.json @@ -107,17 +107,6 @@ "help": "Visite el para obtener más información.", "helpCenter": "Centro de ayuda" }, - "validation": { - "link": { - "batch_duplicate": "No se puede vincular el registro: en el mismo lote ya está vinculado por otro registro. En las relaciones de uno a muchos, cada registro secundario solo puede pertenecer a un registro principal.", - "one_many_duplicate": "No se puede vincular el registro: ya está vinculado a otro registro. En las relaciones de uno a muchos, cada registro secundario solo puede pertenecer a un registro principal.", - "one_one_duplicate": "No se puede vincular el registro: el registro de destino ya está vinculado por otro registro en una relación de uno a uno." - }, - "field": { - "maxColumnLimit": "La tabla \"{{tableName}}\" puede tener como máximo {{maxFieldCount}} campos.", - "requiredExistingValues": "No se puede marcar el campo \"{{fieldName}}\" como obligatorio porque hay registros existentes con valores vacíos." - } - }, "field": { "advancedProps": "Propiedades avanzadas", "hide": "ocultar", @@ -480,6 +469,11 @@ "fillFailed": "Llenado falló", "clearing": "Claro...", "clearSuccessful": "Claro exitoso", + "archiveRecordConfirmTitle": "Archivar registros", + "archiveRecordConfirmDescription": "Esta acción archivará {{recordCount}} registros. Los registros archivados se pueden ver y restaurar desde el archivo de la tabla.", + "archiveRecord": "Archivar", + "archiving": "Archivando...", + "archiveSuccessful": "Archivado con éxito", "deleting": "Eliminar ...", "deleteSuccessful": "Eliminar exitoso", "deleteStream": { @@ -835,6 +829,8 @@ "insertRecordBelow": "Insertar registro a continuación", "deleteRecord": "Eliminar registro", "deleteAllSelectedRecords": "Eliminar todos los registros seleccionados", + "archiveRecord": "Archivar registro", + "archiveAllSelectedRecords": "Archivar todos los registros seleccionados", "editField": "Campo de edición", "insertFieldLeft": "Insertar a la izquierda", "insertFieldRight": "Insertar a la derecha", @@ -897,11 +893,49 @@ "pasteNewRecords": { "title": "¿Quieres agregar múltiples registros?" }, + "tableArchive": { + "title": "Archivo de la tabla", + "menuTitle": "Archivo", + "archivedTime": "Fecha de archivado", + "archivedBy": "Archivado por", + "recordDetail": "Detalle del registro", + "empty": "No hay registros archivados", + "allCreators": "Todos los creadores", + "filterArchivedTime": "Fecha de archivado", + "searchPlaceholder": "Buscar registros archivados", + "clearFilter": "Borrar filtros", + "export": "Exportar CSV", + "exporting": "Exportando… {{count}} filas", + "exportSucceed": "Exportación completada, iniciando descarga", + "restoreSelected": "Restaurar ({{count}})", + "permanentDeleteSelected": "Eliminar permanentemente ({{count}})", + "permanentDeleteConfirm": "¿Eliminar permanentemente {{count}} registros archivados? Esta acción no se puede deshacer.", + "permanentDeleteSucceed": "Eliminado permanentemente", + "resetArchive": "Vaciar archivo", + "resetArchiveConfirm": "¿Vaciar todos los registros archivados de esta tabla? Esta acción no se puede deshacer.", + "resetSucceed": "Archivo vaciado", + "orderBy": { + "archivedTime": "Ordenar por fecha de archivado", + "recordCreatedTime": "Ordenar por fecha de creación", + "recordLastModifiedTime": "Ordenar por última modificación" + } + }, "tableTrash": { "title": "Papelera", "resourceType": "Tipo", "deletedResource": "Recurso", - "moreResources": "y {{count}} más" + "moreResources": "y {{count}} más", + "deletedTime": "Fecha de eliminación", + "deletedBy": "Eliminado por", + "filterAllTypes": "Todos los tipos", + "filterAllUsers": "Todos los usuarios", + "filterDeletedTime": "Fecha de eliminación", + "clearFilter": "Borrar filtros", + "recordsDialogTitle": "Registros eliminados ({{count}})", + "recordDetail": "Detalles del registro", + "filterAllCreators": "Todos los creadores", + "filterCreatedTime": "Fecha de creación", + "searchPlaceholder": "Buscar registros" }, "baseShare": { "shareTitle": "Compartir", @@ -931,7 +965,10 @@ "linkScopeDialogTitle": "Link field scope", "linkHolderCanCopyAndSave": "Puede guardar como copia", "linkHolderCanCopyAndSaveDesc": "Anyone with the link can save a copy to their space", - "editRequiresLogin": "Users must log in to edit records", + "copyToMySpace": "Copy to my space", + "saveToMySpace": "Save to my space", + "supportSaveCopy": "Guarda una copia en tu espacio", + "editRequiresLogin": "Inicia sesión para editar registros", "enterPassword": "Introducir contraseña", "allowCopyData": "Permitir a los espectadores copiar datos", "sharedNode": "Nodo compartido", diff --git a/packages/common-i18n/src/locales/fr/common.json b/packages/common-i18n/src/locales/fr/common.json index 464b706069..79c01fc505 100644 --- a/packages/common-i18n/src/locales/fr/common.json +++ b/packages/common-i18n/src/locales/fr/common.json @@ -59,9 +59,6 @@ "refresh": "Actualiser", "login": "Se connecter", "useTemplate": "Utiliser le modèle", - "copyToMySpace": "Copy to my space", - "saveToMySpace": "Save to my space", - "supportSaveCopy": "Support saving a copy", "backToSpace": "Retour à l'espace", "switchBase": "Changer de base", "continue": "Continuer", @@ -120,6 +117,7 @@ "baseShare": { "shareTitle": "Partager", "shareToWeb": "Partager sur le web", + "noPermissionTip": "Vous n'avez pas l'autorisation de gérer les paramètres de partage", "linkHolderLabel": "Personne disposant du lien", "linkHolderCanView": "Peut consulter", "linkHolderCanViewDesc": "Toute personne disposant du lien peut consulter les données", @@ -547,6 +545,20 @@ "viewPricing": "Voir les tarifs", "billable": "Facturable", "billableByAuthorityMatrix": "Facturation générée par la matrice d'autorités", + "seatConfirm": { + "title": "Cette invitation augmentera le coût de votre abonnement", + "roleChangeTitle": "Ce changement de rôle augmentera le coût de votre abonnement", + "matrixTitle": "L'ajout de membres augmentera le coût de votre abonnement", + "inviteDesc_one": "Le membre que vous invitez rejoindra avec le rôle {{role}} et occupera 1 siège de l'abonnement ; le coût augmentera en conséquence. S'il doit seulement consulter ou commenter, choisissez simplement le rôle gratuit Lecteur ou Commentateur.", + "inviteDesc_other": "Les {{count}} membres que vous invitez rejoindront avec le rôle {{role}} et occuperont {{count}} sièges de l'abonnement ; le coût augmentera en conséquence. S'ils doivent seulement consulter ou commenter, choisissez simplement le rôle gratuit Lecteur ou Commentateur.", + "linkDesc": "Les membres qui rejoignent via ce lien recevront le rôle {{role}} et occuperont chacun 1 siège de l'abonnement ; le coût augmentera au fil des arrivées. Pour une simple consultation ou des commentaires, choisissez simplement le rôle gratuit Lecteur ou Commentateur.", + "roleChangeDesc": "Après le passage au rôle {{role}}, ce membre occupera 1 siège de l'abonnement et le coût augmentera en conséquence.", + "matrixDesc": "Ce membre a actuellement un rôle gratuit (Lecteur/Commentateur). Une fois ajouté à la matrice d'autorité, il occupera 1 siège de l'abonnement et le coût augmentera en conséquence.", + "seatLimitTitle": "Sièges de licence insuffisants", + "seatLimitDesc": "Cette instance utilise {{seats}} sièges sur {{seatLimit}} sous licence et il n'en reste pas assez pour cette action. Contactez votre administrateur pour acheter des sièges supplémentaires. Si la personne doit seulement consulter ou commenter, utilisez le rôle gratuit Lecteur ou Commentateur.", + "seatLimitConfirm": "Compris", + "confirmInvite": "Confirmer et inviter" + }, "licenseExpiredGracePeriod": "Votre licence auto-hébergée a expiré et sera rétrogradée au forfait gratuit le {{expiredTime}}. Veuillez mettre à jour votre licence rapidement pour conserver l'accès aux fonctionnalités premium.", "licenseAutoFetchFailed": "Échec du renouvellement automatique de la licence. Il reste {{days}} jour(s) de période de grâce avant la rétrogradation.", "licenseAutoFetchRetryFailed": "Le renouvellement automatique a de nouveau échoué. Veuillez vérifier la connexion au serveur Teable ou mettre à jour la licence manuellement.", @@ -1295,17 +1307,19 @@ "updateSuccess": "Compétence mise à jour avec succès" } }, - "changelog": { - "newUpdate": "MISE À JOUR DU 28 JUILLET", - "title": "Teable Skill pour les agents IA", - "url": "https://help.teable.ai/en/changelog#jul-28-2026", - "id": "changelog-2026-07-28-teable-skill-for-ai-agents" - }, "resourceDescription": { "addDescription": "Ajouter une description", "nodeDescription": "Description du nœud", "descriptionSaving": "Enregistrement...", "descriptionSaveFailed": "Échec de l’enregistrement de la description, veuillez réessayer", "descriptionPlaceholder": "Ajoutez une description à ce nœud" + }, + "announcement": { + "viewDetail": "Voir les détails", + "close": "Fermer l'annonce", + "acknowledge": "J'ai compris", + "collapse": "Réduire", + "more_one": "{{count}} autre annonce", + "more_other": "{{count}} autres annonces" } } diff --git a/packages/common-i18n/src/locales/fr/sdk.json b/packages/common-i18n/src/locales/fr/sdk.json index 9aa5735f19..841ba4df66 100644 --- a/packages/common-i18n/src/locales/fr/sdk.json +++ b/packages/common-i18n/src/locales/fr/sdk.json @@ -39,7 +39,9 @@ }, "preview": { "previewFileLimit": "Limite de taille du fichier en aperçu : {{size}} Mo, veuillez télécharger pour le visualiser.", - "loadFileError": "Échec du chargement du fichier" + "loadFileError": "Échec du chargement du fichier", + "previousAttachment": "Précédent", + "nextAttachment": "Suivant" }, "undoRedo": { "undo": "Undo", @@ -257,6 +259,8 @@ }, "expandRecord": { "copy": "Copier dans le presse-papiers", + "previousRecord": "Précédent", + "nextRecord": "Suivant", "duplicateRecord": "Duplicate record", "copyRecordUrl": "Copier l'URL de l'enregistrement", "deleteRecord": "Supprimer l'enregistrement", @@ -386,6 +390,8 @@ "tableTrashRead": "Lire la corbeille de la table", "tableTrashUpdate": "Mettre à jour la corbeille de la table", "tableTrashReset": "Réinitialiser la corbeille de la table", + "tableArchiveRead": "Lire l'archive de la table", + "tableArchiveManage": "Gérer l'archive de la table", "viewCreate": "Créer une vue", "viewDelete": "Supprimer une vue", "viewRead": "Lire une vue", @@ -401,6 +407,7 @@ "recordRead": "Lire un enregistrement", "recordUpdate": "Mettre à jour un enregistrement", "recordCopy": "Copy record", + "recordArchive": "Archiver l'enregistrement", "automationCreate": "Créer une automatisation", "automationDelete": "Supprimer une automatisation", "automationRead": "Lire une automatisation", @@ -926,15 +933,18 @@ "nameMaxLength": "Le nom est trop long. Le maximum est de {{max}} caractères.", "descriptionMaxLength": "La description est trop longue. Le maximum est de {{max}} caractères." }, - "validation": { - "field": { - "unique": "Le champ doit avoir une valeur unique" - } - }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" champ \"{{fieldName}}\" ne permet pas les valeurs vides, veuillez les remplir complètement avant de soumettre.", "fieldValueDuplicate": "\"{{tableName}}\" champ \"{{fieldName}}\" ne permet pas les valeurs dupliquées, veuillez remplir une valeur unique avant de soumettre.", + "recordFieldValueNotNull": "Le champ \"{{fieldName}}\" ne permet pas les valeurs vides, veuillez le remplir complètement avant de soumettre.", + "recordFieldValueDuplicate": "Le champ \"{{fieldName}}\" ne permet pas les valeurs dupliquées, veuillez remplir une valeur unique avant de soumettre.", "linkFieldValueDuplicate": "\"{{fieldName}}\" champ ne permet pas les associations dupliquées avec le même enregistrement", + "linkBatchDuplicate": "Impossible de lier l'enregistrement : dans ce même lot, il est déjà lié par un autre enregistrement. Dans une relation un-à-plusieurs, chaque enregistrement enfant ne peut appartenir qu'à un seul enregistrement parent.", + "linkOneManyDuplicate": "Impossible de lier l'enregistrement : il est déjà lié à un autre enregistrement. Dans une relation un-à-plusieurs, chaque enregistrement enfant ne peut appartenir qu'à un seul enregistrement parent.", + "linkOneOneDuplicate": "Impossible de lier l'enregistrement : l'enregistrement cible est déjà lié par un autre enregistrement dans une relation un-à-un.", + "fieldMaxColumnLimit": "La table \"{{tableName}}\" peut contenir au maximum {{maxFieldCount}} champs.", + "fieldRequiredExistingValues": "Impossible de rendre le champ \"{{fieldName}}\" obligatoire, car des enregistrements existants contiennent des valeurs vides.", + "fieldUniqueExistingValues": "Impossible de rendre le champ \"{{fieldName}}\" unique, car des enregistrements existants contiennent des valeurs dupliquées.", "requestTimeout": "L'action actuelle est trop grande, veuillez réessayer avec un étendue plus petite.", "searchTimeOut": "La recherche a expiré, veuillez réduire l'ambito de la recherche et réessayer.", "dependencyNodeRequire": "Nœud de dépendance non testé, veuillez vérifier si tous les nœuds précédents ont été testés", diff --git a/packages/common-i18n/src/locales/fr/table.json b/packages/common-i18n/src/locales/fr/table.json index 65f38dcc7b..1a72b4fe7c 100644 --- a/packages/common-i18n/src/locales/fr/table.json +++ b/packages/common-i18n/src/locales/fr/table.json @@ -105,17 +105,6 @@ "help": "Visitez le pour plus d'informations", "helpCenter": "Centre d'aide" }, - "validation": { - "link": { - "batch_duplicate": "Impossible de lier l'enregistrement : dans ce même lot, il est déjà lié par un autre enregistrement. Dans une relation un-à-plusieurs, chaque enregistrement enfant ne peut appartenir qu'à un seul enregistrement parent.", - "one_many_duplicate": "Impossible de lier l'enregistrement : il est déjà lié à un autre enregistrement. Dans une relation un-à-plusieurs, chaque enregistrement enfant ne peut appartenir qu'à un seul enregistrement parent.", - "one_one_duplicate": "Impossible de lier l'enregistrement : l'enregistrement cible est déjà lié par un autre enregistrement dans une relation un-à-un." - }, - "field": { - "maxColumnLimit": "La table \"{{tableName}}\" peut contenir au maximum {{maxFieldCount}} champs.", - "requiredExistingValues": "Impossible de rendre le champ \"{{fieldName}}\" obligatoire, car des enregistrements existants contiennent des valeurs vides." - } - }, "field": { "advancedProps": "Propriétés avancées", "hide": "cacher", @@ -474,6 +463,11 @@ "fillFailed": "Échec du remplissage", "clearing": "Effacement en cours...", "clearSuccessful": "Effacement réussi", + "archiveRecordConfirmTitle": "Archiver les enregistrements", + "archiveRecordConfirmDescription": "Cette action archivera {{recordCount}} enregistrements. Les enregistrements archivés peuvent être consultés et restaurés depuis l'archive de la table.", + "archiveRecord": "Archiver", + "archiving": "Archivage...", + "archiveSuccessful": "Archivage réussi", "deleting": "Suppression en cours...", "deleteSuccessful": "Suppression réussie", "deleteStream": { @@ -830,6 +824,8 @@ "insertRecordBelow": "Insérer un enregistrement en dessous", "deleteRecord": "Supprimer l'enregistrement", "deleteAllSelectedRecords": "Supprimer tous les enregistrements sélectionnés", + "archiveRecord": "Archiver l'enregistrement", + "archiveAllSelectedRecords": "Archiver tous les enregistrements sélectionnés", "editField": "Modifier le champ", "insertFieldLeft": "Insérer à gauche", "insertFieldRight": "Insérer à droite", @@ -885,11 +881,49 @@ }, "lastModifiedTime": "Dernière modification", "lastModify": "Dernière modification : ", + "tableArchive": { + "title": "Archive de la table", + "menuTitle": "Archives", + "archivedTime": "Date d'archivage", + "archivedBy": "Archivé par", + "recordDetail": "Détail de l'enregistrement", + "empty": "Aucun enregistrement archivé", + "allCreators": "Tous les créateurs", + "filterArchivedTime": "Date d'archivage", + "searchPlaceholder": "Rechercher des enregistrements archivés", + "clearFilter": "Effacer les filtres", + "export": "Exporter en CSV", + "exporting": "Exportation… {{count}} lignes", + "exportSucceed": "Exportation terminée, téléchargement en cours", + "restoreSelected": "Restaurer ({{count}})", + "permanentDeleteSelected": "Supprimer définitivement ({{count}})", + "permanentDeleteConfirm": "Supprimer définitivement {{count}} enregistrements archivés ? Cette action est irréversible.", + "permanentDeleteSucceed": "Supprimé définitivement", + "resetArchive": "Vider l'archive", + "resetArchiveConfirm": "Vider tous les enregistrements archivés de cette table ? Cette action est irréversible.", + "resetSucceed": "Archive vidée", + "orderBy": { + "archivedTime": "Trier par date d'archivage", + "recordCreatedTime": "Trier par date de création", + "recordLastModifiedTime": "Trier par dernière modification" + } + }, "tableTrash": { "title": "Corbeille", "resourceType": "Type", "deletedResource": "Ressource", - "moreResources": "et {{count}} de plus" + "moreResources": "et {{count}} de plus", + "deletedTime": "Date de suppression", + "deletedBy": "Supprimé par", + "filterAllTypes": "Tous les types", + "filterAllUsers": "Tous les utilisateurs", + "filterDeletedTime": "Date de suppression", + "clearFilter": "Effacer les filtres", + "recordsDialogTitle": "Enregistrements supprimés ({{count}})", + "recordDetail": "Détails de l'enregistrement", + "filterAllCreators": "Tous les créateurs", + "filterCreatedTime": "Date de création", + "searchPlaceholder": "Rechercher des enregistrements" }, "baseShare": { "shareTitle": "Partager", @@ -919,7 +953,10 @@ "linkScopeDialogTitle": "Link field scope", "linkHolderCanCopyAndSave": "Peut enregistrer comme copie", "linkHolderCanCopyAndSaveDesc": "Anyone with the link can save a copy to their space", - "editRequiresLogin": "Users must log in to edit records", + "copyToMySpace": "Copy to my space", + "saveToMySpace": "Save to my space", + "supportSaveCopy": "Enregistrez une copie dans votre espace", + "editRequiresLogin": "Connectez-vous pour modifier les enregistrements", "enterPassword": "Entrer le mot de passe", "allowCopyData": "Permettre aux spectateurs de copier les données", "sharedNode": "Nœud partagé", diff --git a/packages/common-i18n/src/locales/it/common.json b/packages/common-i18n/src/locales/it/common.json index dda6373244..cbde7715f7 100644 --- a/packages/common-i18n/src/locales/it/common.json +++ b/packages/common-i18n/src/locales/it/common.json @@ -58,9 +58,6 @@ "refresh": "Aggiorna", "login": "Accedi", "useTemplate": "Usa modello", - "copyToMySpace": "Copy to my space", - "saveToMySpace": "Save to my space", - "supportSaveCopy": "Support saving a copy", "backToSpace": "Torna allo Spazio", "switchBase": "Cambia Base", "continue": "Continua", @@ -120,6 +117,7 @@ "baseShare": { "shareTitle": "Condividi", "shareToWeb": "Condividi sul web", + "noPermissionTip": "Non hai l'autorizzazione per gestire le impostazioni di condivisione", "linkHolderLabel": "Persona con il link", "linkHolderCanView": "Può visualizzare", "linkHolderCanViewDesc": "Chiunque abbia il link può visualizzare i dati", @@ -547,6 +545,20 @@ "viewPricing": "Visualizza prezzi", "billable": "Fatturabile", "billableByAuthorityMatrix": "Fatturazione generata dalla matrice dei permessi", + "seatConfirm": { + "title": "Questo invito aumenterà il costo del tuo abbonamento", + "roleChangeTitle": "Questa modifica di ruolo aumenterà il costo del tuo abbonamento", + "matrixTitle": "L'aggiunta di membri aumenterà il costo del tuo abbonamento", + "inviteDesc_one": "Il membro che stai invitando entrerà con il ruolo {{role}} e occuperà 1 posto dell'abbonamento; il costo aumenterà di conseguenza. Se deve solo visualizzare o commentare, scegli semplicemente il ruolo gratuito Visualizzatore o Commentatore.", + "inviteDesc_other": "I {{count}} membri che stai invitando entreranno con il ruolo {{role}} e occuperanno {{count}} posti dell'abbonamento; il costo aumenterà di conseguenza. Se devono solo visualizzare o commentare, scegli semplicemente il ruolo gratuito Visualizzatore o Commentatore.", + "linkDesc": "I membri che si uniscono tramite questo link riceveranno il ruolo {{role}} e ognuno occuperà 1 posto dell'abbonamento; il costo crescerà man mano che le persone si uniscono. Per sola visualizzazione o commenti, scegli semplicemente il ruolo gratuito Visualizzatore o Commentatore.", + "roleChangeDesc": "Dopo il passaggio a {{role}}, questo membro occuperà 1 posto dell'abbonamento e il costo aumenterà di conseguenza.", + "matrixDesc": "Questo membro ha attualmente un ruolo gratuito (Visualizzatore/Commentatore). Aggiungendolo alla matrice delle autorizzazioni occuperà 1 posto dell'abbonamento e il costo aumenterà di conseguenza.", + "seatLimitTitle": "Posti della licenza insufficienti", + "seatLimitDesc": "Questa istanza sta usando {{seats}} di {{seatLimit}} posti con licenza e quelli rimanenti non bastano per questa azione. Contatta il tuo amministratore per acquistare più posti. Se deve solo visualizzare o commentare, puoi usare il ruolo gratuito Visualizzatore o Commentatore.", + "seatLimitConfirm": "Ho capito", + "confirmInvite": "Conferma e invita" + }, "licenseExpiredGracePeriod": "La tua licenza self-hosted è scaduta e verrà declassata al piano gratuito il {{expiredTime}}. Aggiorna la tua licenza tempestivamente per mantenere l'accesso alle funzionalità premium.", "licenseAutoFetchFailed": "Rinnovo automatico della licenza non riuscito. Restano {{days}} giorno/i del periodo di tolleranza prima del downgrade.", "licenseAutoFetchRetryFailed": "Il rinnovo automatico non è ancora riuscito. Verifica la connessione al server Teable o aggiorna la licenza manualmente.", @@ -1295,17 +1307,19 @@ "updateSuccess": "Competenza aggiornata con successo" } }, - "changelog": { - "newUpdate": "AGGIORNAMENTO DEL 28 LUGLIO", - "title": "Teable Skill per agenti IA", - "url": "https://help.teable.ai/en/changelog#jul-28-2026", - "id": "changelog-2026-07-28-teable-skill-for-ai-agents" - }, "resourceDescription": { "addDescription": "Aggiungi descrizione", "nodeDescription": "Descrizione nodo", "descriptionSaving": "Salvataggio...", "descriptionSaveFailed": "Impossibile salvare la descrizione, riprova", "descriptionPlaceholder": "Aggiungi una descrizione per questo nodo" + }, + "announcement": { + "viewDetail": "Vedi dettagli", + "close": "Chiudi annuncio", + "acknowledge": "Ho capito", + "collapse": "Mostra meno", + "more_one": "{{count}} altro annuncio", + "more_other": "{{count}} altri annunci" } } diff --git a/packages/common-i18n/src/locales/it/sdk.json b/packages/common-i18n/src/locales/it/sdk.json index 3307cee80b..ecb82206d3 100644 --- a/packages/common-i18n/src/locales/it/sdk.json +++ b/packages/common-i18n/src/locales/it/sdk.json @@ -39,7 +39,9 @@ }, "preview": { "previewFileLimit": "Limite dimensione file di anteprima: {{size}}MB, per favore scarica per visualizzare.", - "loadFileError": "Caricamento file fallito" + "loadFileError": "Caricamento file fallito", + "previousAttachment": "Precedente", + "nextAttachment": "Successivo" }, "undoRedo": { "undo": "Annulla", @@ -257,6 +259,8 @@ }, "expandRecord": { "copy": "Copia negli appunti", + "previousRecord": "Precedente", + "nextRecord": "Successivo", "duplicateRecord": "Duplica record", "copyRecordUrl": "Copia URL del record", "deleteRecord": "Elimina record", @@ -386,6 +390,8 @@ "tableTrashRead": "Leggi cestino della tabella", "tableTrashUpdate": "Aggiorna cestino della tabella", "tableTrashReset": "Reimposta cestino della tabella", + "tableArchiveRead": "Leggi archivio della tabella", + "tableArchiveManage": "Gestisci archivio della tabella", "viewCreate": "Crea vista", "viewDelete": "Elimina vista", "viewRead": "Leggi vista", @@ -401,6 +407,7 @@ "recordRead": "Leggi record", "recordUpdate": "Aggiorna record", "recordCopy": "Copy record", + "recordArchive": "Archivia record", "automationCreate": "Crea automazione", "automationDelete": "Elimina automazione", "automationRead": "Leggi automazione", @@ -926,15 +933,18 @@ "nameMaxLength": "Il nome è troppo lungo. Il massimo è {{max}} caratteri.", "descriptionMaxLength": "La descrizione è troppo lunga. Il massimo è {{max}} caratteri." }, - "validation": { - "field": { - "unique": "Il campo deve avere un valore unico" - } - }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" campo \"{{fieldName}}\" non consente valori vuoti, per favore riempilo completamente prima di inviare.", "fieldValueDuplicate": "\"{{tableName}}\" campo \"{{fieldName}}\" non consente valori duplicati, per favore riempilo con un valore unico prima di inviare.", + "recordFieldValueNotNull": "Il campo \"{{fieldName}}\" non consente valori vuoti, per favore riempilo completamente prima di inviare.", + "recordFieldValueDuplicate": "Il campo \"{{fieldName}}\" non consente valori duplicati, per favore riempilo con un valore unico prima di inviare.", "linkFieldValueDuplicate": "\"{{fieldName}}\" campo non consente associazioni duplicate con lo stesso record", + "linkBatchDuplicate": "Impossibile collegare il record: nello stesso batch è già collegato da un altro record. Nelle relazioni uno-a-molti, ogni record figlio può appartenere a un solo record padre.", + "linkOneManyDuplicate": "Impossibile collegare il record: è già collegato a un altro record. Nelle relazioni uno-a-molti, ogni record figlio può appartenere a un solo record padre.", + "linkOneOneDuplicate": "Impossibile collegare il record: il record di destinazione è già collegato da un altro record in una relazione uno-a-uno.", + "fieldMaxColumnLimit": "La tabella \"{{tableName}}\" può avere al massimo {{maxFieldCount}} campi.", + "fieldRequiredExistingValues": "Impossibile contrassegnare il campo \"{{fieldName}}\" come obbligatorio perché i record esistenti contengono valori vuoti.", + "fieldUniqueExistingValues": "Impossibile contrassegnare il campo \"{{fieldName}}\" come univoco perché i record esistenti contengono valori duplicati.", "requestTimeout": "L'azione corrente è troppo grande, per favore riprova con un ambito più piccolo.", "searchTimeOut": "La ricerca ha scaduto, per favore ridurre l'ambito di ricerca e riprova.", "dependencyNodeRequire": "Il nodo dipendente non è stato testato, per favore controlla se tutti i nodi precedenti sono stati testati", diff --git a/packages/common-i18n/src/locales/it/table.json b/packages/common-i18n/src/locales/it/table.json index 4e0f3b9470..c050361356 100644 --- a/packages/common-i18n/src/locales/it/table.json +++ b/packages/common-i18n/src/locales/it/table.json @@ -107,17 +107,6 @@ "help": "Visita il per maggiori informazioni.", "helpCenter": "Centro Assistenza" }, - "validation": { - "link": { - "batch_duplicate": "Impossibile collegare il record: nello stesso batch è già collegato da un altro record. Nelle relazioni uno-a-molti, ogni record figlio può appartenere a un solo record padre.", - "one_many_duplicate": "Impossibile collegare il record: è già collegato a un altro record. Nelle relazioni uno-a-molti, ogni record figlio può appartenere a un solo record padre.", - "one_one_duplicate": "Impossibile collegare il record: il record di destinazione è già collegato da un altro record in una relazione uno-a-uno." - }, - "field": { - "maxColumnLimit": "La tabella \"{{tableName}}\" può avere al massimo {{maxFieldCount}} campi.", - "requiredExistingValues": "Impossibile contrassegnare il campo \"{{fieldName}}\" come obbligatorio perché i record esistenti contengono valori vuoti." - } - }, "field": { "advancedProps": "Proprietà avanzate", "hide": "nascondi", @@ -483,6 +472,11 @@ "fillFailed": "Riempimento fallito", "clearing": "Cancellazione in corso...", "clearSuccessful": "Cancellazione riuscita", + "archiveRecordConfirmTitle": "Archivia record", + "archiveRecordConfirmDescription": "Questa azione archivierà {{recordCount}} record. I record archiviati possono essere visualizzati e ripristinati dall'archivio della tabella.", + "archiveRecord": "Archivia", + "archiving": "Archiviazione...", + "archiveSuccessful": "Archiviazione riuscita", "deleting": "Eliminazione in corso...", "deleteSuccessful": "Eliminazione riuscita", "deleteStream": { @@ -839,6 +833,8 @@ "insertRecordBelow": "Inserisci record sotto", "deleteRecord": "Elimina record", "deleteAllSelectedRecords": "Elimina tutti i record selezionati", + "archiveRecord": "Archivia record", + "archiveAllSelectedRecords": "Archivia tutti i record selezionati", "editField": "Modifica campo", "insertFieldLeft": "Inserisci a sinistra", "insertFieldRight": "Inserisci a destra", @@ -906,11 +902,49 @@ "title": "Vuoi aggiungere più record?", "description": "I {{count}} record saranno aggiunti alla tabella." }, + "tableArchive": { + "title": "Archivio della tabella", + "menuTitle": "Archivio", + "archivedTime": "Data di archiviazione", + "archivedBy": "Archiviato da", + "recordDetail": "Dettaglio del record", + "empty": "Nessun record archiviato", + "allCreators": "Tutti i creatori", + "filterArchivedTime": "Data di archiviazione", + "searchPlaceholder": "Cerca record archiviati", + "clearFilter": "Cancella filtri", + "export": "Esporta CSV", + "exporting": "Esportazione… {{count}} righe", + "exportSucceed": "Esportazione completata, download in corso", + "restoreSelected": "Ripristina ({{count}})", + "permanentDeleteSelected": "Elimina definitivamente ({{count}})", + "permanentDeleteConfirm": "Eliminare definitivamente {{count}} record archiviati? Questa azione non può essere annullata.", + "permanentDeleteSucceed": "Eliminato definitivamente", + "resetArchive": "Svuota archivio", + "resetArchiveConfirm": "Svuotare tutti i record archiviati di questa tabella? Questa azione non può essere annullata.", + "resetSucceed": "Archivio svuotato", + "orderBy": { + "archivedTime": "Ordina per data di archiviazione", + "recordCreatedTime": "Ordina per data di creazione", + "recordLastModifiedTime": "Ordina per ultima modifica" + } + }, "tableTrash": { "title": "Cestino", "resourceType": "Tipo", "deletedResource": "Risorsa", - "moreResources": "e altri {{count}}" + "moreResources": "e altri {{count}}", + "deletedTime": "Data di eliminazione", + "deletedBy": "Eliminato da", + "filterAllTypes": "Tutti i tipi", + "filterAllUsers": "Tutti gli utenti", + "filterDeletedTime": "Data di eliminazione", + "clearFilter": "Cancella filtri", + "recordsDialogTitle": "Record eliminati ({{count}})", + "recordDetail": "Dettagli del record", + "filterAllCreators": "Tutti i creatori", + "filterCreatedTime": "Data di creazione", + "searchPlaceholder": "Cerca record" }, "baseShare": { "shareTitle": "Condividi", @@ -940,7 +974,10 @@ "linkScopeDialogTitle": "Link field scope", "linkHolderCanCopyAndSave": "Può salvare come copia", "linkHolderCanCopyAndSaveDesc": "Anyone with the link can save a copy to their space", - "editRequiresLogin": "Users must log in to edit records", + "copyToMySpace": "Copy to my space", + "saveToMySpace": "Save to my space", + "supportSaveCopy": "Salva una copia nel tuo spazio", + "editRequiresLogin": "Accedi per modificare i record", "enterPassword": "Inserisci password", "allowCopyData": "Consenti agli spettatori di copiare i dati", "sharedNode": "Nodo condiviso", diff --git a/packages/common-i18n/src/locales/ja/common.json b/packages/common-i18n/src/locales/ja/common.json index 153f2846a4..8c372e7bea 100644 --- a/packages/common-i18n/src/locales/ja/common.json +++ b/packages/common-i18n/src/locales/ja/common.json @@ -59,9 +59,6 @@ "refresh": "更新", "login": "ログイン", "useTemplate": "テンプレートを使用", - "copyToMySpace": "Copy to my space", - "saveToMySpace": "Save to my space", - "supportSaveCopy": "Support saving a copy", "backToSpace": "スペースに戻る", "switchBase": "ベースを切り替え", "continue": "続行", @@ -120,6 +117,7 @@ "baseShare": { "shareTitle": "共有", "shareToWeb": "Webに公開", + "noPermissionTip": "共有設定を管理する権限がありません", "linkHolderLabel": "リンクを取得した人", "linkHolderCanView": "閲覧可能", "linkHolderCanViewDesc": "リンクを持つ人はデータを閲覧できます", @@ -549,6 +547,20 @@ "viewPricing": "料金を見る", "billable": "課金対象", "billableByAuthorityMatrix": "権限マトリックスによって生成された課金", + "seatConfirm": { + "title": "この招待によりサブスクリプション料金が増加します", + "roleChangeTitle": "このロール変更によりサブスクリプション料金が増加します", + "matrixTitle": "メンバーの追加によりサブスクリプション料金が増加します", + "inviteDesc_one": "招待した {{count}} 人のメンバーは {{role}} ロールで参加し、サブスクリプションの {{count}} 席を使用するため、料金がその分増加します。閲覧やコメントのみでよい場合は、無料の「閲覧者」または「解説者」ロールをお選びください。", + "inviteDesc_other": "招待した {{count}} 人のメンバーは {{role}} ロールで参加し、サブスクリプションの {{count}} 席を使用するため、料金がその分増加します。閲覧やコメントのみでよい場合は、無料の「閲覧者」または「解説者」ロールをお選びください。", + "linkDesc": "このリンクから参加したメンバーには {{role}} ロールが付与され、1 人につき 1 席を使用するため、参加人数に応じて料金が増加します。閲覧やコメントのみでよい場合は、無料の「閲覧者」または「解説者」ロールをお選びください。", + "roleChangeDesc": "{{role}} に変更すると、このメンバーはサブスクリプションの 1 席を使用し、料金がその分増加します。", + "matrixDesc": "このメンバーは現在無料ロール(閲覧者/解説者)です。権限マトリックスに追加するとサブスクリプションの 1 席を使用し、料金がその分増加します。", + "seatLimitTitle": "ライセンスシートが不足しています", + "seatLimitDesc": "このインスタンスはライセンスシート {{seatLimit}} 席中 {{seats}} 席を使用しており、この操作に必要な残りシートがありません。管理者に連絡して追加シートを購入してください。閲覧やコメントのみでよい場合は、無料の「閲覧者」または「解説者」ロールをご利用ください。", + "seatLimitConfirm": "わかりました", + "confirmInvite": "確認して招待" + }, "licenseExpiredGracePeriod": "セルフホスト版ライセンスの有効期限が切れました。{{expiredTime}}に無料プランへダウングレードされ、プレミアム機能が利用できなくなります。完全な機能を維持するため、速やかにライセンスを更新してください。", "licenseAutoFetchFailed": "ライセンスの自動更新に失敗しました。ダウングレードまでの猶予期間は残り {{days}} 日です。", "licenseAutoFetchRetryFailed": "自動更新が再び失敗しました。Teable サーバーへの接続を確認するか、ライセンスを手動で更新してください。", @@ -1297,17 +1309,18 @@ "updateSuccess": "スキルを更新しました" } }, - "changelog": { - "newUpdate": "7月28日アップデート", - "title": "AIエージェント向けTeable Skill", - "url": "https://help.teable.ai/en/changelog#jul-28-2026", - "id": "changelog-2026-07-28-teable-skill-for-ai-agents" - }, "resourceDescription": { "addDescription": "説明を追加", "nodeDescription": "ノードの説明", "descriptionSaving": "保存中...", "descriptionSaveFailed": "説明の保存に失敗しました。もう一度お試しください", "descriptionPlaceholder": "このノードの説明を追加" + }, + "announcement": { + "viewDetail": "詳細を見る", + "close": "お知らせを閉じる", + "acknowledge": "了解しました", + "collapse": "折りたたむ", + "more_other": "他 {{count}} 件のお知らせ" } } diff --git a/packages/common-i18n/src/locales/ja/sdk.json b/packages/common-i18n/src/locales/ja/sdk.json index e3edccea98..1e0ef39ef0 100644 --- a/packages/common-i18n/src/locales/ja/sdk.json +++ b/packages/common-i18n/src/locales/ja/sdk.json @@ -39,7 +39,9 @@ }, "preview": { "previewFileLimit": "プレビュー可能なファイルサイズの上限: {{size}}MB、ダウンロードしてご覧ください。", - "loadFileError": "ファイルの読み込みに失敗しました" + "loadFileError": "ファイルの読み込みに失敗しました", + "previousAttachment": "前へ", + "nextAttachment": "次へ" }, "undoRedo": { "undo": "Undo", @@ -257,6 +259,8 @@ }, "expandRecord": { "copy": "クリップボードにコピー", + "previousRecord": "前のレコード", + "nextRecord": "次のレコード", "duplicateRecord": "Duplicate record", "copyRecordUrl": "レコードのURLをコピー", "deleteRecord": "レコードを削除", @@ -386,6 +390,8 @@ "tableTrashRead": "テーブルのごみ箱を読む", "tableTrashUpdate": "テーブルのごみ箱を更新", "tableTrashReset": "テーブルのごみ箱をリセット", + "tableArchiveRead": "テーブルのアーカイブを読む", + "tableArchiveManage": "テーブルのアーカイブを管理", "viewCreate": "ビューを作成", "viewDelete": "ビューを削除", "viewRead": "ビューを読み取り", @@ -401,6 +407,7 @@ "recordRead": "レコードの読み取り", "recordUpdate": "レコードの更新", "recordCopy": "Copy record", + "recordArchive": "レコードをアーカイブ", "automationCreate": "オートメーションの作成", "automationDelete": "オートメーションの削除", "automationRead": "オートメーションの読み取り", @@ -926,15 +933,18 @@ "nameMaxLength": "名前が長すぎます。最大 {{max}} 文字です。", "descriptionMaxLength": "説明が長すぎます。最大 {{max}} 文字です。" }, - "validation": { - "field": { - "unique": "フィールドには一意の値が必要です" - } - }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" フィールド \"{{fieldName}}\" は空の値を許可しません。送信する前に完全に入力してください。", "fieldValueDuplicate": "\"{{tableName}}\" フィールド \"{{fieldName}}\" は重複する値を許可しません。送信する前に一意の値を入力してください。", + "recordFieldValueNotNull": "フィールド \"{{fieldName}}\" は空の値を許可しません。送信する前に完全に入力してください。", + "recordFieldValueDuplicate": "フィールド \"{{fieldName}}\" は重複する値を許可しません。送信する前に一意の値を入力してください。", "linkFieldValueDuplicate": "\"{{fieldName}}\" フィールドは同じレコードに複数の関連付けを許可しません。", + "linkBatchDuplicate": "関連付けできません:同じバッチ内で、このレコードはすでに別のレコードに関連付けられています。一対多の関係では、各子レコードは1つの親レコードにのみ属できます。", + "linkOneManyDuplicate": "関連付けできません:このレコードはすでに別のレコードに関連付けられています。一対多の関係では、各子レコードは1つの親レコードにのみ属できます。", + "linkOneOneDuplicate": "関連付けできません:対象レコードは一対一の関係ですでに別のレコードに関連付けられています。", + "fieldMaxColumnLimit": "テーブル「{{tableName}}」には最大 {{maxFieldCount}} 個のフィールドしか作成できません。", + "fieldRequiredExistingValues": "既存のレコードに空の値があるため、フィールド「{{fieldName}}」を必須にできません。", + "fieldUniqueExistingValues": "既存のレコードに重複する値があるため、フィールド「{{fieldName}}」を一意にできません。", "requestTimeout": "操作範囲が大きすぎます。範囲を縮小して再試行してください。", "searchTimeOut": "検索が期限切れ、検索範囲を狭めて再試行してください。", "dependencyNodeRequire": "依存ノードがテストされていません。前のノードをテストしてください。", diff --git a/packages/common-i18n/src/locales/ja/table.json b/packages/common-i18n/src/locales/ja/table.json index d880cc2f81..9ff03d65a2 100644 --- a/packages/common-i18n/src/locales/ja/table.json +++ b/packages/common-i18n/src/locales/ja/table.json @@ -95,17 +95,6 @@ "help": "詳細については、 をご覧ください", "helpCenter": "ヘルプセンター" }, - "validation": { - "link": { - "batch_duplicate": "関連付けできません:同じバッチ内で、このレコードはすでに別のレコードに関連付けられています。一対多の関係では、各子レコードは1つの親レコードにのみ属できます。", - "one_many_duplicate": "関連付けできません:このレコードはすでに別のレコードに関連付けられています。一対多の関係では、各子レコードは1つの親レコードにのみ属できます。", - "one_one_duplicate": "関連付けできません:対象レコードは一対一の関係ですでに別のレコードに関連付けられています。" - }, - "field": { - "maxColumnLimit": "テーブル「{{tableName}}」には最大 {{maxFieldCount}} 個のフィールドしか作成できません。", - "requiredExistingValues": "既存のレコードに空の値があるため、フィールド「{{fieldName}}」を必須にできません。" - } - }, "field": { "advancedProps": "高度なプロパティ", "hide": "非表示", @@ -464,6 +453,11 @@ "fillFailed": "入力失敗", "clearing": "クリア...", "clearSuccessful": "クリア成功", + "archiveRecordConfirmTitle": "レコードをアーカイブ", + "archiveRecordConfirmDescription": "この操作により {{recordCount}} 件のレコードがアーカイブされます。アーカイブされたレコードはテーブルのアーカイブから閲覧・復元できます。", + "archiveRecord": "アーカイブ", + "archiving": "アーカイブ中...", + "archiveSuccessful": "アーカイブに成功しました", "deleting": "削除中...", "deleteSuccessful": "削除成功", "deleteStream": { @@ -819,6 +813,8 @@ "insertRecordBelow": "レコードを下に挿入", "deleteRecord": "レコードを削除", "deleteAllSelectedRecords": "選択したすべてのレコードを削除", + "archiveRecord": "レコードをアーカイブ", + "archiveAllSelectedRecords": "選択したすべてのレコードをアーカイブ", "editField": "フィールドを編集", "insertFieldLeft": "左に挿入", "insertFieldRight": "右に挿入", @@ -874,11 +870,49 @@ }, "lastModifiedTime": "最終更新日時", "lastModify": "最終更新: ", + "tableArchive": { + "title": "テーブルのアーカイブ", + "menuTitle": "アーカイブ", + "archivedTime": "アーカイブ日時", + "archivedBy": "アーカイブしたユーザー", + "recordDetail": "レコードの詳細", + "empty": "アーカイブされたレコードはありません", + "allCreators": "すべての作成者", + "filterArchivedTime": "アーカイブ日時", + "searchPlaceholder": "アーカイブされたレコードを検索", + "clearFilter": "フィルターをクリア", + "export": "CSV をエクスポート", + "exporting": "エクスポート中… {{count}} 行", + "exportSucceed": "エクスポート完了、ダウンロードを開始します", + "restoreSelected": "復元({{count}})", + "permanentDeleteSelected": "完全に削除({{count}})", + "permanentDeleteConfirm": "{{count}} 件のアーカイブ済みレコードを完全に削除しますか?この操作は元に戻せません。", + "permanentDeleteSucceed": "完全に削除しました", + "resetArchive": "アーカイブを空にする", + "resetArchiveConfirm": "このテーブルのすべてのアーカイブ済みレコードを削除しますか?この操作は元に戻せません。", + "resetSucceed": "アーカイブを空にしました", + "orderBy": { + "archivedTime": "アーカイブ日時で並べ替え", + "recordCreatedTime": "作成日時で並べ替え", + "recordLastModifiedTime": "最終更新日時で並べ替え" + } + }, "tableTrash": { "title": "ゴミ箱", "resourceType": "タイプ", "deletedResource": "リソース", - "moreResources": "他 {{count}} 件" + "moreResources": "他 {{count}} 件", + "deletedTime": "削除日時", + "deletedBy": "削除したユーザー", + "filterAllTypes": "すべてのタイプ", + "filterAllUsers": "すべてのユーザー", + "filterDeletedTime": "削除日時", + "clearFilter": "フィルターをクリア", + "recordsDialogTitle": "削除されたレコード({{count}})", + "recordDetail": "レコードの詳細", + "filterAllCreators": "すべての作成者", + "filterCreatedTime": "作成日時", + "searchPlaceholder": "レコードを検索" }, "baseShare": { "shareTitle": "共有", @@ -908,7 +942,10 @@ "linkScopeDialogTitle": "Link field scope", "linkHolderCanCopyAndSave": "コピーとして保存可能", "linkHolderCanCopyAndSaveDesc": "Anyone with the link can save a copy to their space", - "editRequiresLogin": "Users must log in to edit records", + "copyToMySpace": "Copy to my space", + "saveToMySpace": "Save to my space", + "supportSaveCopy": "自分のスペースにコピーを保存できます", + "editRequiresLogin": "ログインするとレコードを編集できます", "enterPassword": "パスワードを入力", "allowCopyData": "閲覧者がデータをコピーすることを許可", "sharedNode": "共有ノード", diff --git a/packages/common-i18n/src/locales/ru/common.json b/packages/common-i18n/src/locales/ru/common.json index d59bef98e9..17221b8113 100644 --- a/packages/common-i18n/src/locales/ru/common.json +++ b/packages/common-i18n/src/locales/ru/common.json @@ -59,9 +59,6 @@ "refresh": "Обновить", "login": "Войти", "useTemplate": "Использовать шаблон", - "copyToMySpace": "Copy to my space", - "saveToMySpace": "Save to my space", - "supportSaveCopy": "Support saving a copy", "backToSpace": "Вернуться в пространство", "switchBase": "Переключить базу", "continue": "Продолжить", @@ -120,6 +117,7 @@ "baseShare": { "shareTitle": "Поделиться", "shareToWeb": "Опубликовать в интернете", + "noPermissionTip": "У вас нет прав на управление настройками общего доступа", "linkHolderLabel": "Владелец ссылки", "linkHolderCanView": "Может просматривать", "linkHolderCanViewDesc": "Любой, у кого есть ссылка, может просматривать данные", @@ -514,6 +512,20 @@ "viewPricing": "Посмотреть цены", "billable": "Платный", "billableByAuthorityMatrix": "Биллинг, созданный матрицей полномочий", + "seatConfirm": { + "title": "Это приглашение увеличит стоимость подписки", + "roleChangeTitle": "Это изменение роли увеличит стоимость подписки", + "matrixTitle": "Добавление участников увеличит стоимость подписки", + "inviteDesc_one": "Приглашённый участник присоединится с ролью {{role}} и займёт 1 место подписки; стоимость соответственно увеличится. Если нужен только просмотр или комментарии, просто выберите бесплатную роль «Просмотр» или «Комментатор».", + "inviteDesc_other": "Приглашённые участники ({{count}}) присоединятся с ролью {{role}} и займут {{count}} мест подписки; стоимость соответственно увеличится. Если нужен только просмотр или комментарии, просто выберите бесплатную роль «Просмотр» или «Комментатор».", + "linkDesc": "Участники, присоединившиеся по этой ссылке, получат роль {{role}}, каждый займёт 1 место подписки, и стоимость будет расти по мере присоединения. Для просмотра или комментариев просто выберите бесплатную роль «Просмотр» или «Комментатор».", + "roleChangeDesc": "После смены роли на {{role}} этот участник займёт 1 место подписки, и стоимость соответственно увеличится.", + "matrixDesc": "У этого участника сейчас бесплатная роль («Просмотр»/«Комментатор»). После добавления в матрицу полномочий он займёт 1 место подписки, и стоимость соответственно увеличится.", + "seatLimitTitle": "Недостаточно мест лицензии", + "seatLimitDesc": "Этот экземпляр использует {{seats}} из {{seatLimit}} лицензированных мест, и оставшихся мест недостаточно для этого действия. Обратитесь к администратору, чтобы приобрести дополнительные места. Если нужен только просмотр или комментарии, используйте бесплатную роль «Просмотр» или «Комментатор».", + "seatLimitConfirm": "Понятно", + "confirmInvite": "Подтвердить и пригласить" + }, "licenseExpiredGracePeriod": "Срок действия вашей лицензии для самостоятельного размещения истек и будет понижена до бесплатного плана {{expiredTime}}. Пожалуйста, обновите лицензию, чтобы сохранить доступ к премиум-функциям.", "licenseAutoFetchFailed": "Не удалось автоматически продлить лицензию. До понижения осталось {{days}} дн. льготного периода.", "licenseAutoFetchRetryFailed": "Автопродление снова не удалось. Проверьте подключение к серверу Teable или обновите лицензию вручную.", @@ -1252,17 +1264,21 @@ "updateSuccess": "Навык успешно обновлён" } }, - "changelog": { - "newUpdate": "ОБНОВЛЕНИЕ ОТ 28 ИЮЛЯ", - "title": "Teable Skill для ИИ-агентов", - "url": "https://help.teable.ai/en/changelog#jul-28-2026", - "id": "changelog-2026-07-28-teable-skill-for-ai-agents" - }, "resourceDescription": { "addDescription": "Добавить описание", "nodeDescription": "Описание узла", "descriptionSaving": "Сохранение...", "descriptionSaveFailed": "Не удалось сохранить описание, повторите попытку", "descriptionPlaceholder": "Добавьте описание для этого узла" + }, + "announcement": { + "viewDetail": "Подробнее", + "close": "Закрыть объявление", + "acknowledge": "Понятно", + "collapse": "Свернуть", + "more_one": "ещё {{count}} объявление", + "more_few": "ещё {{count}} объявления", + "more_many": "ещё {{count}} объявлений", + "more_other": "ещё {{count}} объявлений" } } diff --git a/packages/common-i18n/src/locales/ru/sdk.json b/packages/common-i18n/src/locales/ru/sdk.json index 80ea89c25f..447940cacc 100644 --- a/packages/common-i18n/src/locales/ru/sdk.json +++ b/packages/common-i18n/src/locales/ru/sdk.json @@ -39,7 +39,9 @@ }, "preview": { "previewFileLimit": "Лимит размера файла для предварительного просмотра: {{size}} МБ, пожалуйста, скачайте файл для просмотра.", - "loadFileError": "Не удалось загрузить файл" + "loadFileError": "Не удалось загрузить файл", + "previousAttachment": "Предыдущая", + "nextAttachment": "Следующая" }, "undoRedo": { "undo": "Отменить", @@ -257,6 +259,8 @@ }, "expandRecord": { "copy": "Копировать в буфер обмена", + "previousRecord": "Предыдущая", + "nextRecord": "Следующая", "duplicateRecord": "Duplicate record", "copyRecordUrl": "Копировать URL записи", "deleteRecord": "Удалить запись", @@ -386,6 +390,8 @@ "tableTrashRead": "Просмотр корзины таблицы", "tableTrashUpdate": "Обновление корзины таблицы", "tableTrashReset": "Сброс корзины таблицы", + "tableArchiveRead": "Просмотр архива таблицы", + "tableArchiveManage": "Управление архивом таблицы", "viewCreate": "Создать вид", "viewDelete": "Удалить вид", "viewRead": "Читать вид", @@ -401,6 +407,7 @@ "recordRead": "Читать запись", "recordUpdate": "Обновить запись", "recordCopy": "Copy record", + "recordArchive": "Архивировать запись", "automationCreate": "Создать автоматизацию", "automationDelete": "Удалить автоматизацию", "automationRead": "Читать автоматизацию", @@ -926,15 +933,18 @@ "nameMaxLength": "Название слишком длинное. Максимум — {{max}} символов.", "descriptionMaxLength": "Описание слишком длинное. Максимум — {{max}} символов." }, - "validation": { - "field": { - "unique": "Поле должно иметь уникальное значение" - } - }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" поле \"{{fieldName}}\" не допускает пустые значения, пожалуйста, заполните его полностью перед отправкой.", "fieldValueDuplicate": "\"{{tableName}}\" поле \"{{fieldName}}\" не допускает дубликаты значений, пожалуйста, заполните уникальное значение перед отправкой.", + "recordFieldValueNotNull": "Поле \"{{fieldName}}\" не допускает пустые значения, пожалуйста, заполните его полностью перед отправкой.", + "recordFieldValueDuplicate": "Поле \"{{fieldName}}\" не допускает дубликаты значений, пожалуйста, заполните уникальное значение перед отправкой.", "linkFieldValueDuplicate": "\"{{fieldName}}\" поле не допускает дубликаты связей с одним и тем же записью", + "linkBatchDuplicate": "Невозможно связать запись: в этом же пакете она уже связана другой записью. В отношениях один-ко-многим каждая дочерняя запись может принадлежать только одной родительской записи.", + "linkOneManyDuplicate": "Невозможно связать запись: она уже связана с другой записью. В отношениях один-ко-многим каждая дочерняя запись может принадлежать только одной родительской записи.", + "linkOneOneDuplicate": "Невозможно связать запись: целевая запись уже связана другой записью в отношении один-к-одному.", + "fieldMaxColumnLimit": "Таблица \"{{tableName}}\" может содержать не более {{maxFieldCount}} полей.", + "fieldRequiredExistingValues": "Нельзя сделать поле \"{{fieldName}}\" обязательным, поскольку существующие записи содержат пустые значения.", + "fieldUniqueExistingValues": "Нельзя сделать поле \"{{fieldName}}\" уникальным, поскольку существующие записи содержат повторяющиеся значения.", "requestTimeout": "Текущая операция слишком велика, пожалуйста, попробуйте снова с меньшим диапазоном.", "searchTimeOut": "Поиск завершился, уменьшите область поиска и повторите.", "dependencyNodeRequire": "Зависимый узел не протестирован, пожалуйста, проверьте, протестированы ли все предыдущие узлы", diff --git a/packages/common-i18n/src/locales/ru/table.json b/packages/common-i18n/src/locales/ru/table.json index 35837419cd..7247207838 100644 --- a/packages/common-i18n/src/locales/ru/table.json +++ b/packages/common-i18n/src/locales/ru/table.json @@ -110,17 +110,6 @@ "help": "Посетите для получения дополнительной информации.", "helpCenter": "Центр помощи" }, - "validation": { - "link": { - "batch_duplicate": "Невозможно связать запись: в этом же пакете она уже связана другой записью. В отношениях один-ко-многим каждая дочерняя запись может принадлежать только одной родительской записи.", - "one_many_duplicate": "Невозможно связать запись: она уже связана с другой записью. В отношениях один-ко-многим каждая дочерняя запись может принадлежать только одной родительской записи.", - "one_one_duplicate": "Невозможно связать запись: целевая запись уже связана другой записью в отношении один-к-одному." - }, - "field": { - "maxColumnLimit": "Таблица \"{{tableName}}\" может содержать не более {{maxFieldCount}} полей.", - "requiredExistingValues": "Нельзя сделать поле \"{{fieldName}}\" обязательным, поскольку существующие записи содержат пустые значения." - } - }, "field": { "advancedProps": "Расширенные свойства", "hide": "скрыть", @@ -479,6 +468,11 @@ "fillFailed": "Заполнение не удалось", "clearing": "Очистка...", "clearSuccessful": "Очистка успешна", + "archiveRecordConfirmTitle": "Архивировать записи", + "archiveRecordConfirmDescription": "Это действие архивирует {{recordCount}} записей. Архивированные записи можно просматривать и восстанавливать из архива таблицы.", + "archiveRecord": "Архивировать", + "archiving": "Архивирование...", + "archiveSuccessful": "Архивирование выполнено", "deleting": "Удаление...", "deleteSuccessful": "Удаление успешно", "deleteStream": { @@ -835,6 +829,8 @@ "insertRecordBelow": "Вставить запись ниже", "deleteRecord": "Удалить запись", "deleteAllSelectedRecords": "Удалить все выбранные записи", + "archiveRecord": "Архивировать запись", + "archiveAllSelectedRecords": "Архивировать все выбранные записи", "editField": "Редактировать поле", "insertFieldLeft": "Вставить слева", "insertFieldRight": "Вставить справа", @@ -890,11 +886,49 @@ }, "lastModifiedTime": "Время последнего изменения", "lastModify": "Последнее изменение: ", + "tableArchive": { + "title": "Архив таблицы", + "menuTitle": "Архив", + "archivedTime": "Время архивирования", + "archivedBy": "Кем архивировано", + "recordDetail": "Детали записи", + "empty": "Нет архивированных записей", + "allCreators": "Все создатели", + "filterArchivedTime": "Время архивирования", + "searchPlaceholder": "Поиск архивированных записей", + "clearFilter": "Сбросить фильтры", + "export": "Экспорт CSV", + "exporting": "Экспорт… {{count}} строк", + "exportSucceed": "Экспорт завершён, начинается загрузка", + "restoreSelected": "Восстановить ({{count}})", + "permanentDeleteSelected": "Удалить навсегда ({{count}})", + "permanentDeleteConfirm": "Удалить навсегда {{count}} архивированных записей? Это действие нельзя отменить.", + "permanentDeleteSucceed": "Удалено навсегда", + "resetArchive": "Очистить архив", + "resetArchiveConfirm": "Очистить все архивированные записи этой таблицы? Это действие нельзя отменить.", + "resetSucceed": "Архив очищен", + "orderBy": { + "archivedTime": "Сортировать по времени архивирования", + "recordCreatedTime": "Сортировать по времени создания", + "recordLastModifiedTime": "Сортировать по последнему изменению" + } + }, "tableTrash": { "title": "Корзина", "resourceType": "Тип", "deletedResource": "Ресурс", - "moreResources": "и ещё {{count}}" + "moreResources": "и ещё {{count}}", + "deletedTime": "Время удаления", + "deletedBy": "Удалил", + "filterAllTypes": "Все типы", + "filterAllUsers": "Все пользователи", + "filterDeletedTime": "Время удаления", + "clearFilter": "Сбросить фильтры", + "recordsDialogTitle": "Удалённые записи ({{count}})", + "recordDetail": "Детали записи", + "filterAllCreators": "Все создатели", + "filterCreatedTime": "Время создания", + "searchPlaceholder": "Поиск записей" }, "baseShare": { "shareTitle": "Поделиться", @@ -924,7 +958,10 @@ "linkScopeDialogTitle": "Link field scope", "linkHolderCanCopyAndSave": "Может сохранить как копию", "linkHolderCanCopyAndSaveDesc": "Anyone with the link can save a copy to their space", - "editRequiresLogin": "Users must log in to edit records", + "copyToMySpace": "Copy to my space", + "saveToMySpace": "Save to my space", + "supportSaveCopy": "Сохраните копию в своем пространстве", + "editRequiresLogin": "Войдите, чтобы редактировать записи", "enterPassword": "Введите пароль", "allowCopyData": "Разрешить зрителям копировать данные", "sharedNode": "Общий узел", diff --git a/packages/common-i18n/src/locales/tr/common.json b/packages/common-i18n/src/locales/tr/common.json index 3833369d0c..6d876985cf 100644 --- a/packages/common-i18n/src/locales/tr/common.json +++ b/packages/common-i18n/src/locales/tr/common.json @@ -59,9 +59,6 @@ "refresh": "Yenile", "login": "Giriş Yap", "useTemplate": "Şablon Kullan", - "copyToMySpace": "Copy to my space", - "saveToMySpace": "Save to my space", - "supportSaveCopy": "Support saving a copy", "backToSpace": "Alana Dön", "switchBase": "Veritabanı Değiştir", "continue": "Devam Et", @@ -120,6 +117,7 @@ "baseShare": { "shareTitle": "Paylaş", "shareToWeb": "Web'de paylaş", + "noPermissionTip": "Paylaşım ayarlarını yönetme izniniz yok", "linkHolderLabel": "Bağlantıya sahip kişi", "linkHolderCanView": "Görüntüleyebilir", "linkHolderCanViewDesc": "Bağlantıya sahip herkes verileri görüntüleyebilir", @@ -510,6 +508,20 @@ "levelTips": "Bu alan şu anda {{level}} planında", "billable": "Faturalandırılabilir", "billableByAuthorityMatrix": "Yetki matrisi tarafından oluşturulan faturalandırma", + "seatConfirm": { + "title": "Bu davet abonelik ücretinizi artıracak", + "roleChangeTitle": "Bu rol değişikliği abonelik ücretinizi artıracak", + "matrixTitle": "Üye eklemek abonelik ücretinizi artıracak", + "inviteDesc_one": "Davet ettiğiniz üye {{role}} rolüyle katılacak ve 1 abonelik koltuğu kullanacak; ücretiniz buna göre artacak. Yalnızca görüntüleme veya yorum gerekiyorsa ücretsiz İzleyici veya Yorumcu rolünü seçmeniz yeterli.", + "inviteDesc_other": "Davet ettiğiniz {{count}} üye {{role}} rolüyle katılacak ve {{count}} abonelik koltuğu kullanacak; ücretiniz buna göre artacak. Yalnızca görüntüleme veya yorum gerekiyorsa ücretsiz İzleyici veya Yorumcu rolünü seçmeniz yeterli.", + "linkDesc": "Bu bağlantıyla katılan üyeler {{role}} rolünü alır ve her biri 1 abonelik koltuğu kullanır; katılan kişi sayısıyla ücretiniz artar. Yalnızca görüntüleme veya yorum için ücretsiz İzleyici veya Yorumcu rolünü seçmeniz yeterli.", + "roleChangeDesc": "{{role}} rolüne geçtikten sonra bu üye 1 abonelik koltuğu kullanacak ve ücretiniz buna göre artacak.", + "matrixDesc": "Bu üye şu anda ücretsiz bir role sahip (İzleyici/Yorumcu). Yetki matrisine eklendiğinde 1 abonelik koltuğu kullanacak ve ücretiniz buna göre artacak.", + "seatLimitTitle": "Lisans koltuğu yetersiz", + "seatLimitDesc": "Bu örnek, lisanslı {{seatLimit}} koltuğun {{seats}} tanesini kullanıyor ve kalan koltuklar bu işlem için yeterli değil. Ek koltuk satın almak için lütfen yöneticinizle iletişime geçin. Yalnızca görüntüleme veya yorum gerekiyorsa ücretsiz İzleyici veya Yorumcu rolünü kullanabilirsiniz.", + "seatLimitConfirm": "Anladım", + "confirmInvite": "Onayla ve davet et" + }, "licenseExpiredGracePeriod": "Kendi sunucunuzda barındırma lisansınızın süresi doldu ve {{expiredTime}} tarihinde ücretsiz plana düşürülecek. Premium özelliklere erişimi korumak için lütfen lisansınızı hemen güncelleyin.", "licenseAutoFetchFailed": "Lisansın otomatik yenilenmesi başarısız oldu. Sürüm düşürülmeden önce {{days}} gün ek süre kaldı.", "licenseAutoFetchRetryFailed": "Otomatik yenileme yine başarısız oldu. Lütfen Teable sunucusuyla bağlantıyı kontrol edin veya lisansı manuel olarak güncelleyin.", @@ -1284,17 +1296,19 @@ "updateSuccess": "Beceri başarıyla güncellendi" } }, - "changelog": { - "newUpdate": "28 TEMMUZ GÜNCELLEMESİ", - "title": "AI Aracıları için Teable Skill", - "url": "https://help.teable.ai/en/changelog#jul-28-2026", - "id": "changelog-2026-07-28-teable-skill-for-ai-agents" - }, "resourceDescription": { "addDescription": "Açıklama ekle", "nodeDescription": "Düğüm açıklaması", "descriptionSaving": "Kaydediliyor...", "descriptionSaveFailed": "Açıklama kaydedilemedi, lütfen tekrar deneyin", "descriptionPlaceholder": "Bu düğüm için bir açıklama ekleyin" + }, + "announcement": { + "viewDetail": "Ayrıntıları gör", + "close": "Duyuruyu kapat", + "acknowledge": "Anladım", + "collapse": "Daha az göster", + "more_one": "{{count}} duyuru daha", + "more_other": "{{count}} duyuru daha" } } diff --git a/packages/common-i18n/src/locales/tr/sdk.json b/packages/common-i18n/src/locales/tr/sdk.json index aa48c43b75..4bea520e9a 100644 --- a/packages/common-i18n/src/locales/tr/sdk.json +++ b/packages/common-i18n/src/locales/tr/sdk.json @@ -39,7 +39,9 @@ }, "preview": { "previewFileLimit": "Önizleme dosya boyutu sınırı: {{size}}MB, lütfen görüntülemek için indirin.", - "loadFileError": "Dosya yüklenemedi" + "loadFileError": "Dosya yüklenemedi", + "previousAttachment": "Önceki", + "nextAttachment": "Sonraki" }, "undoRedo": { "undo": "Geri Al", @@ -257,6 +259,8 @@ }, "expandRecord": { "copy": "Panoya kopyala", + "previousRecord": "Önceki", + "nextRecord": "Sonraki", "duplicateRecord": "Kaydı çoğalt", "copyRecordUrl": "Kayıt URL'sini kopyala", "deleteRecord": "Kaydı sil", @@ -386,6 +390,8 @@ "tableTrashRead": "Read table trash", "tableTrashUpdate": "Update table trash", "tableTrashReset": "Reset table trash", + "tableArchiveRead": "Tablo arşivini görüntüle", + "tableArchiveManage": "Tablo arşivini yönet", "viewCreate": "Görünüm oluştur", "viewDelete": "Görünüm sil", "viewRead": "Görünüm oku", @@ -401,6 +407,7 @@ "recordRead": "Kayıt oku", "recordUpdate": "Kayıt güncelle", "recordCopy": "Copy record", + "recordArchive": "Kaydı arşivle", "automationCreate": "Otomasyon oluştur", "automationDelete": "Otomasyon sil", "automationRead": "Otomasyon oku", @@ -926,15 +933,18 @@ "nameMaxLength": "Ad çok uzun. En fazla {{max}} karakter olabilir.", "descriptionMaxLength": "Açıklama çok uzun. En fazla {{max}} karakter olabilir." }, - "validation": { - "field": { - "unique": "Alan benzersiz bir değere sahip olmalıdır" - } - }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" alanı \"{{fieldName}}\" boş değerlere izin vermiyor, lütfen tamamlayınız ve gönderimden önce doldurun.", "fieldValueDuplicate": "\"{{tableName}}\" alanı \"{{fieldName}}\" tekrarlayan değerlere izin vermiyor, lütfen benzersiz bir değer doldurun ve gönderimden önce doldurun.", + "recordFieldValueNotNull": "\"{{fieldName}}\" alanı boş değerlere izin vermiyor, lütfen göndermeden önce doldurun.", + "recordFieldValueDuplicate": "\"{{fieldName}}\" alanı tekrarlayan değerlere izin vermiyor, lütfen göndermeden önce benzersiz bir değer girin.", "linkFieldValueDuplicate": "\"{{fieldName}}\" alanı aynı kayıtla tekrarlayan değerlere izin vermiyor.", + "linkBatchDuplicate": "Kayıt bağlanamıyor: aynı grupta bu kayıt zaten başka bir kayıt tarafından bağlanmış. Bire-çok ilişkilerde her alt kayıt yalnızca bir üst kayda ait olabilir.", + "linkOneManyDuplicate": "Kayıt bağlanamıyor: bu kayıt zaten başka bir kayda bağlı. Bire-çok ilişkilerde her alt kayıt yalnızca bir üst kayda ait olabilir.", + "linkOneOneDuplicate": "Kayıt bağlanamıyor: hedef kayıt bire-bir ilişkide zaten başka bir kayıt tarafından bağlanmış.", + "fieldMaxColumnLimit": "\"{{tableName}}\" tablosunda en fazla {{maxFieldCount}} alan olabilir.", + "fieldRequiredExistingValues": "\"{{fieldName}}\" alanı zorunlu olarak işaretlenemez çünkü mevcut kayıtlarda boş değerler var.", + "fieldUniqueExistingValues": "\"{{fieldName}}\" alanı benzersiz olarak işaretlenemez çünkü mevcut kayıtlarda tekrarlayan değerler var.", "requestTimeout": "İşlem kapsamı çok büyük, lütfen kapsamı daraltıp tekrar deneyin.", "searchTimeOut": "Arama tamamlandı, arama kapsamını azaltarak tekrar deneyin.", "dependencyNodeRequire": "Bağımlı düğüm test edilmedi, lütfen önceki düğümleri test edin", diff --git a/packages/common-i18n/src/locales/tr/table.json b/packages/common-i18n/src/locales/tr/table.json index 204d6ff13b..b440844a79 100644 --- a/packages/common-i18n/src/locales/tr/table.json +++ b/packages/common-i18n/src/locales/tr/table.json @@ -102,17 +102,6 @@ "help": "Daha fazla bilgi için adresini ziyaret edin", "helpCenter": "Yardım Merkezi" }, - "validation": { - "link": { - "batch_duplicate": "Kayıt bağlanamıyor: aynı grupta bu kayıt zaten başka bir kayıt tarafından bağlanmış. Bire-çok ilişkilerde her alt kayıt yalnızca bir üst kayda ait olabilir.", - "one_many_duplicate": "Kayıt bağlanamıyor: bu kayıt zaten başka bir kayda bağlı. Bire-çok ilişkilerde her alt kayıt yalnızca bir üst kayda ait olabilir.", - "one_one_duplicate": "Kayıt bağlanamıyor: hedef kayıt bire-bir ilişkide zaten başka bir kayıt tarafından bağlanmış." - }, - "field": { - "maxColumnLimit": "\"{{tableName}}\" tablosunda en fazla {{maxFieldCount}} alan olabilir.", - "requiredExistingValues": "\"{{fieldName}}\" alanı zorunlu olarak işaretlenemez çünkü mevcut kayıtlarda boş değerler var." - } - }, "field": { "advancedProps": "Gelişmiş özellikler", "hide": "gizle", @@ -477,6 +466,11 @@ "fillFailed": "Doldurma başarısız", "clearing": "Temizleniyor...", "clearSuccessful": "Temizleme başarılı", + "archiveRecordConfirmTitle": "Kayıtları arşivle", + "archiveRecordConfirmDescription": "Bu işlem {{recordCount}} kaydı arşivleyecek. Arşivlenen kayıtlar tablo arşivinden görüntülenebilir ve geri yüklenebilir.", + "archiveRecord": "Arşivle", + "archiving": "Arşivleniyor...", + "archiveSuccessful": "Arşivleme başarılı", "deleting": "Siliniyor...", "deleteSuccessful": "Silme başarılı", "deleteStream": { @@ -819,6 +813,8 @@ "insertRecordBelow": "Alta kayıt ekle", "deleteRecord": "Kaydı sil", "deleteAllSelectedRecords": "Seçili tüm kayıtları sil", + "archiveRecord": "Kaydı arşivle", + "archiveAllSelectedRecords": "Seçili tüm kayıtları arşivle", "editField": "Alanı düzenle", "insertFieldLeft": "Sola ekle", "insertFieldRight": "Sağa ekle", @@ -934,7 +930,10 @@ "linkScopeDialogTitle": "Link field scope", "linkHolderCanCopyAndSave": "Kopya olarak kaydedebilir", "linkHolderCanCopyAndSaveDesc": "Anyone with the link can save a copy to their space", - "editRequiresLogin": "Users must log in to edit records", + "copyToMySpace": "Copy to my space", + "saveToMySpace": "Save to my space", + "supportSaveCopy": "Bir kopyayı alanınıza kaydedin", + "editRequiresLogin": "Kayıtları düzenlemek için giriş yapın", "enterPassword": "Şifre girin", "allowCopyData": "İzleyicilerin veri kopyalamasına izin ver", "sharedNode": "Paylaşılan düğüm", @@ -999,6 +998,50 @@ "storageFull": "Sandbox depolama alanı dolu. Bazı dosyaları silip tekrar deneyin." } }, + "tableTrash": { + "title": "Tablo çöp kutusu", + "resourceType": "Tür", + "deletedResource": "Kaynak", + "moreResources": "ve {{count}} tane daha", + "deletedTime": "Silinme zamanı", + "deletedBy": "Silen kullanıcı", + "filterAllTypes": "Tüm türler", + "filterAllUsers": "Tüm kullanıcılar", + "filterDeletedTime": "Silinme zamanı", + "clearFilter": "Filtreleri temizle", + "recordsDialogTitle": "Silinen kayıtlar ({{count}})", + "recordDetail": "Kayıt ayrıntıları", + "filterAllCreators": "Tüm oluşturanlar", + "filterCreatedTime": "Oluşturulma zamanı", + "searchPlaceholder": "Kayıtları ara" + }, + "tableArchive": { + "title": "Tablo arşivi", + "menuTitle": "Arşiv", + "archivedTime": "Arşivlenme zamanı", + "archivedBy": "Arşivleyen", + "recordDetail": "Kayıt detayı", + "empty": "Arşivlenmiş kayıt yok", + "allCreators": "Tüm oluşturanlar", + "filterArchivedTime": "Arşivlenme zamanı", + "searchPlaceholder": "Arşivlenmiş kayıtları ara", + "clearFilter": "Filtreyi temizle", + "export": "CSV olarak dışa aktar", + "exporting": "Dışa aktarılıyor… {{count}} satır", + "exportSucceed": "Dışa aktarma tamamlandı, indirme başlıyor", + "restoreSelected": "Geri yükle ({{count}})", + "permanentDeleteSelected": "Kalıcı olarak sil ({{count}})", + "permanentDeleteConfirm": "{{count}} arşivlenmiş kayıt kalıcı olarak silinsin mi? Bu işlem geri alınamaz.", + "permanentDeleteSucceed": "Kalıcı olarak silindi", + "resetArchive": "Arşivi temizle", + "resetArchiveConfirm": "Bu tablonun tüm arşivlenmiş kayıtları temizlensin mi? Bu işlem geri alınamaz.", + "resetSucceed": "Arşiv temizlendi", + "orderBy": { + "archivedTime": "Arşivlenme zamanına göre sırala", + "recordCreatedTime": "Oluşturulma zamanına göre sırala", + "recordLastModifiedTime": "Son değiştirilme zamanına göre sırala" + } + }, "baseNode": { "info": { "menu": "Düğüm bilgisi", diff --git a/packages/common-i18n/src/locales/uk/common.json b/packages/common-i18n/src/locales/uk/common.json index 692211a140..c3c80d8123 100644 --- a/packages/common-i18n/src/locales/uk/common.json +++ b/packages/common-i18n/src/locales/uk/common.json @@ -48,9 +48,6 @@ "preview": "Попередній перегляд", "viewAndEdit": "Переглянути та редагувати", "getMore": "Отримати більше", - "copyToMySpace": "Copy to my space", - "saveToMySpace": "Save to my space", - "supportSaveCopy": "Support saving a copy", "copySuccess": "Копіювання успішне", "openLink": "Відкрити посилання", "share": "Поділитися" @@ -107,6 +104,7 @@ "baseShare": { "shareTitle": "Поділитися", "shareToWeb": "Опублікувати в інтернеті", + "noPermissionTip": "У вас немає прав керувати налаштуваннями спільного доступу", "linkHolderLabel": "Власник посилання", "linkHolderCanView": "Може переглядати", "linkHolderCanViewDesc": "Будь-хто з посиланням може переглядати дані", @@ -500,6 +498,20 @@ "levelTips": "Це простір зараз на тарифі {{level}}", "billable": "Платний", "billableByAuthorityMatrix": "Білінг, створений матрицею повноважень", + "seatConfirm": { + "title": "Це запрошення збільшить вартість підписки", + "roleChangeTitle": "Ця зміна ролі збільшить вартість підписки", + "matrixTitle": "Додавання учасників збільшить вартість підписки", + "inviteDesc_one": "Запрошений учасник приєднається з роллю {{role}} і займе 1 місце підписки; вартість відповідно зросте. Якщо потрібен лише перегляд або коментарі, просто виберіть безкоштовну роль «Перегляд» або «Коментатор».", + "inviteDesc_other": "Запрошені учасники ({{count}}) приєднаються з роллю {{role}} і займуть {{count}} місць підписки; вартість відповідно зросте. Якщо потрібен лише перегляд або коментарі, просто виберіть безкоштовну роль «Перегляд» або «Коментатор».", + "linkDesc": "Учасники, які приєднаються за цим посиланням, отримають роль {{role}}, кожен займе 1 місце підписки, і вартість зростатиме з кожною новою людиною. Для перегляду чи коментарів просто виберіть безкоштовну роль «Перегляд» або «Коментатор».", + "roleChangeDesc": "Після зміни ролі на {{role}} цей учасник займе 1 місце підписки, і вартість відповідно зросте.", + "matrixDesc": "Цей учасник наразі має безкоштовну роль («Перегляд»/«Коментатор»). Після додавання до матриці повноважень він займе 1 місце підписки, і вартість відповідно зросте.", + "seatLimitTitle": "Недостатньо місць ліцензії", + "seatLimitDesc": "Цей екземпляр використовує {{seats}} із {{seatLimit}} ліцензованих місць, і решти місць недостатньо для цієї дії. Зверніться до адміністратора, щоб придбати додаткові місця. Якщо потрібен лише перегляд або коментарі, використайте безкоштовну роль «Перегляд» або «Коментатор».", + "seatLimitConfirm": "Зрозуміло", + "confirmInvite": "Підтвердити та запросити" + }, "licenseExpiredGracePeriod": "Термін дії вашої ліцензії для самостійного розміщення закінчився і буде понижена до безкоштовного плану {{expiredTime}}. Будь ласка, оновіть ліцензію, щоб зберегти доступ до преміум-функцій.", "licenseAutoFetchFailed": "Не вдалося автоматично продовжити ліцензію. До зниження залишилося {{days}} дн. пільгового періоду.", "licenseAutoFetchRetryFailed": "Автопродовження знову не вдалося. Перевірте з'єднання з сервером Teable або оновіть ліцензію вручну.", @@ -1274,17 +1286,21 @@ "updateSuccess": "Навичку успішно оновлено" } }, - "changelog": { - "newUpdate": "ОНОВЛЕННЯ ВІД 28 ЛИПНЯ", - "title": "Teable Skill для ШІ-агентів", - "url": "https://help.teable.ai/en/changelog#jul-28-2026", - "id": "changelog-2026-07-28-teable-skill-for-ai-agents" - }, "resourceDescription": { "addDescription": "Додати опис", "nodeDescription": "Опис вузла", "descriptionSaving": "Збереження...", "descriptionSaveFailed": "Не вдалося зберегти опис, спробуйте ще раз", "descriptionPlaceholder": "Додайте опис для цього вузла" + }, + "announcement": { + "viewDetail": "Детальніше", + "close": "Закрити оголошення", + "acknowledge": "Зрозуміло", + "collapse": "Згорнути", + "more_one": "ще {{count}} оголошення", + "more_few": "ще {{count}} оголошення", + "more_many": "ще {{count}} оголошень", + "more_other": "ще {{count}} оголошень" } } diff --git a/packages/common-i18n/src/locales/uk/sdk.json b/packages/common-i18n/src/locales/uk/sdk.json index cb76aee5ae..088f02818a 100644 --- a/packages/common-i18n/src/locales/uk/sdk.json +++ b/packages/common-i18n/src/locales/uk/sdk.json @@ -39,7 +39,9 @@ }, "preview": { "previewFileLimit": "Обмеження розміру файлу попереднього перегляду: {{size}} МБ, будь ласка, завантажте для перегляду.", - "loadFileError": "Не вдалося завантажити файл" + "loadFileError": "Не вдалося завантажити файл", + "previousAttachment": "Попередня", + "nextAttachment": "Наступна" }, "undoRedo": { "undo": "Скасувати", @@ -257,6 +259,8 @@ }, "expandRecord": { "copy": "Копіювати в буфер обміну", + "previousRecord": "Попередній", + "nextRecord": "Наступний", "duplicateRecord": "Дублювати запис", "copyRecordUrl": "Копіювати URL записи", "deleteRecord": "Видалити запис", @@ -386,6 +390,8 @@ "tableTrashRead": "Читати кошик таблиці", "tableTrashUpdate": "Оновити кошик таблиці", "tableTrashReset": "Скинути кошик таблиці", + "tableArchiveRead": "Читати архів таблиці", + "tableArchiveManage": "Керувати архівом таблиці", "viewCreate": "Створити перегляд", "viewDelete": "Видалити перегляд", "viewRead": "Перегляд читання", @@ -401,6 +407,7 @@ "recordRead": "Прочитати запис", "recordUpdate": "Оновити запис", "recordCopy": "Copy record", + "recordArchive": "Архівувати запис", "automationCreate": "Створити автоматизацію", "automationDelete": "Видалити автоматизацію", "automationRead": "Автоматизація читання", @@ -926,15 +933,18 @@ "nameMaxLength": "Назва задовга. Максимум — {{max}} символів.", "descriptionMaxLength": "Опис задовгий. Максимум — {{max}} символів." }, - "validation": { - "field": { - "unique": "Поле повинно мати унікальне значення" - } - }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" поле \"{{fieldName}}\" не допускає пусті значення, будь ласка, заповніть його повністю перед відправкою.", "fieldValueDuplicate": "\"{{tableName}}\" поле \"{{fieldName}}\" не допускає дублікатів значень, будь ласка, заповніть унікальне значення перед відправкою.", + "recordFieldValueNotNull": "Поле \"{{fieldName}}\" не допускає пусті значення, будь ласка, заповніть його повністю перед відправкою.", + "recordFieldValueDuplicate": "Поле \"{{fieldName}}\" не допускає дублікатів значень, будь ласка, заповніть унікальне значення перед відправкою.", "linkFieldValueDuplicate": "\"{{fieldName}}\" поле не допускає дублікатів зв'язків з одним і тим самим запису", + "linkBatchDuplicate": "Неможливо пов'язати запис: у цьому ж пакеті його вже пов'язано іншим записом. У зв'язках один-до-багатьох кожен дочірній запис може належати лише одному батьківському запису.", + "linkOneManyDuplicate": "Неможливо пов'язати запис: його вже пов'язано з іншим записом. У зв'язках один-до-багатьох кожен дочірній запис може належати лише одному батьківському запису.", + "linkOneOneDuplicate": "Неможливо пов'язати запис: цільовий запис уже пов'язано іншим записом у зв'язку один-до-одного.", + "fieldMaxColumnLimit": "Таблиця \"{{tableName}}\" може містити не більше ніж {{maxFieldCount}} полів.", + "fieldRequiredExistingValues": "Не можна зробити поле \"{{fieldName}}\" обов'язковим, оскільки наявні записи містять порожні значення.", + "fieldUniqueExistingValues": "Не можна зробити поле \"{{fieldName}}\" унікальним, оскільки наявні записи містять повторювані значення.", "requestTimeout": "Текущая операция слишком велика, пожалуйста, попробуйте снова с меньшим диапазоном.", "searchTimeOut": "Пошук завершений, зменшіть область пошуку та повторіть.", "dependencyNodeRequire": "Залежний вузол не протестований, перевірте, чи всі попередні вузли протестовані", diff --git a/packages/common-i18n/src/locales/uk/table.json b/packages/common-i18n/src/locales/uk/table.json index bb920fbc3e..485e0b9581 100644 --- a/packages/common-i18n/src/locales/uk/table.json +++ b/packages/common-i18n/src/locales/uk/table.json @@ -112,17 +112,6 @@ "help": "Відвідайте для отримання додаткової інформації", "helpCenter": "Центр допомоги" }, - "validation": { - "link": { - "batch_duplicate": "Неможливо пов'язати запис: у цьому ж пакеті його вже пов'язано іншим записом. У зв'язках один-до-багатьох кожен дочірній запис може належати лише одному батьківському запису.", - "one_many_duplicate": "Неможливо пов'язати запис: його вже пов'язано з іншим записом. У зв'язках один-до-багатьох кожен дочірній запис може належати лише одному батьківському запису.", - "one_one_duplicate": "Неможливо пов'язати запис: цільовий запис уже пов'язано іншим записом у зв'язку один-до-одного." - }, - "field": { - "maxColumnLimit": "Таблиця \"{{tableName}}\" може містити не більше ніж {{maxFieldCount}} полів.", - "requiredExistingValues": "Не можна зробити поле \"{{fieldName}}\" обов'язковим, оскільки наявні записи містять порожні значення." - } - }, "field": { "advancedProps": "Розширені властивості", "hide": "приховати", @@ -488,6 +477,11 @@ "fillFailed": "Не вдалося заповнити", "clearing": "Очищення...", "clearSuccessful": "Очистити успішно", + "archiveRecordConfirmTitle": "Архівувати записи", + "archiveRecordConfirmDescription": "Ця дія архівує {{recordCount}} записів. Архівовані записи можна переглядати та відновлювати з архіву таблиці.", + "archiveRecord": "Архівувати", + "archiving": "Архівування...", + "archiveSuccessful": "Архівування виконано", "deleting": "Видалення...", "deleteSuccessful": "Успішне видалення", "deleteStream": { @@ -845,6 +839,8 @@ "insertRecordBelow": "Вставте запис нижче", "deleteRecord": "Видалити запис", "deleteAllSelectedRecords": "Видалити всі вибрані записи", + "archiveRecord": "Архівувати запис", + "archiveAllSelectedRecords": "Архівувати всі вибрані записи", "editField": "Поле редагування", "insertFieldLeft": "Вставити ліворуч", "insertFieldRight": "Вставити праворуч", @@ -909,11 +905,49 @@ "title": "Ви хочете додати кілька записів?", "description": "{{count}} записів буде додано до таблиці." }, + "tableArchive": { + "title": "Архів таблиці", + "menuTitle": "Архів", + "archivedTime": "Час архівування", + "archivedBy": "Ким архівовано", + "recordDetail": "Деталі запису", + "empty": "Немає архівованих записів", + "allCreators": "Усі створювачі", + "filterArchivedTime": "Час архівування", + "searchPlaceholder": "Пошук архівованих записів", + "clearFilter": "Скинути фільтри", + "export": "Експорт CSV", + "exporting": "Експорт… {{count}} рядків", + "exportSucceed": "Експорт завершено, починається завантаження", + "restoreSelected": "Відновити ({{count}})", + "permanentDeleteSelected": "Видалити назавжди ({{count}})", + "permanentDeleteConfirm": "Видалити назавжди {{count}} архівованих записів? Цю дію неможливо скасувати.", + "permanentDeleteSucceed": "Видалено назавжди", + "resetArchive": "Очистити архів", + "resetArchiveConfirm": "Очистити всі архівовані записи цієї таблиці? Цю дію неможливо скасувати.", + "resetSucceed": "Архів очищено", + "orderBy": { + "archivedTime": "Сортувати за часом архівування", + "recordCreatedTime": "Сортувати за часом створення", + "recordLastModifiedTime": "Сортувати за останньою зміною" + } + }, "tableTrash": { "title": "Кошик", "resourceType": "Тип", "deletedResource": "Ресурс", - "moreResources": "і ще {{count}}" + "moreResources": "і ще {{count}}", + "deletedTime": "Час видалення", + "deletedBy": "Видалив", + "filterAllTypes": "Усі типи", + "filterAllUsers": "Усі користувачі", + "filterDeletedTime": "Час видалення", + "clearFilter": "Скинути фільтри", + "recordsDialogTitle": "Видалені записи ({{count}})", + "recordDetail": "Деталі запису", + "filterAllCreators": "Усі створювачі", + "filterCreatedTime": "Час створення", + "searchPlaceholder": "Пошук записів" }, "baseShare": { "shareTitle": "Поділитися", @@ -943,7 +977,10 @@ "linkScopeDialogTitle": "Link field scope", "linkHolderCanCopyAndSave": "Може зберегти як копію", "linkHolderCanCopyAndSaveDesc": "Anyone with the link can save a copy to their space", - "editRequiresLogin": "Users must log in to edit records", + "copyToMySpace": "Copy to my space", + "saveToMySpace": "Save to my space", + "supportSaveCopy": "Збережіть копію у своєму просторі", + "editRequiresLogin": "Увійдіть, щоб редагувати записи", "enterPassword": "Введіть пароль", "allowCopyData": "Дозволити глядачам копіювати дані", "sharedNode": "Спільний вузол", diff --git a/packages/common-i18n/src/locales/zh/common.json b/packages/common-i18n/src/locales/zh/common.json index b17fa57fd1..947a05c069 100644 --- a/packages/common-i18n/src/locales/zh/common.json +++ b/packages/common-i18n/src/locales/zh/common.json @@ -64,9 +64,6 @@ "refresh": "刷新", "login": "登录", "useTemplate": "使用模版", - "copyToMySpace": "复制到我的空间", - "saveToMySpace": "保存到我的空间", - "supportSaveCopy": "支持保存副本", "copyLink": "复制链接", "openLink": "打开链接", "backToSpace": "返回空间", @@ -129,6 +126,7 @@ "baseShare": { "shareTitle": "分享", "shareToWeb": "公开分享到网络", + "noPermissionTip": "你没有权限管理分享设置", "linkHolderLabel": "获得链接的人", "linkHolderCanView": "可查看", "linkHolderCanViewDesc": "获得链接的人可以查看数据", @@ -570,6 +568,20 @@ "viewPricing": "查看定价", "billable": "计费", "billableByAuthorityMatrix": "由权限矩阵产生的计费", + "seatConfirm": { + "title": "邀请将增加订阅费用", + "roleChangeTitle": "调整角色将增加订阅费用", + "matrixTitle": "添加成员将增加订阅费用", + "inviteDesc_one": "本次邀请的 {{count}} 位成员将以「{{role}}」角色加入,将占用 {{count}} 个订阅席位,费用会随之增加。如果只需查看或评论,改选免费的「可查看」或「可查看与评论」即可。", + "inviteDesc_other": "本次邀请的 {{count}} 位成员将以「{{role}}」角色加入,将占用 {{count}} 个订阅席位,费用会随之增加。如果只需查看或评论,改选免费的「可查看」或「可查看与评论」即可。", + "linkDesc": "通过此链接加入的成员将获得「{{role}}」角色,每加入 1 人将占用 1 个订阅席位,费用会随之增加。如果只需查看或评论,改选免费的「可查看」或「可查看与评论」即可。", + "roleChangeDesc": "调整为「{{role}}」后,该成员将占用 1 个订阅席位,费用会随之增加。", + "matrixDesc": "该成员目前是免费角色(可查看/可查看与评论),加入权限矩阵后将占用 1 个订阅席位,费用会随之增加。", + "seatLimitTitle": "许可证席位不足", + "seatLimitDesc": "当前实例已使用 {{seats}}/{{seatLimit}} 个许可证席位,剩余席位不足以完成本次操作,请联系管理员增购席位。如果对方只需查看或评论,可以改用免费的「可查看」或「可查看与评论」角色。", + "seatLimitConfirm": "知道了", + "confirmInvite": "确认并邀请" + }, "licenseExpiredGracePeriod": "您的私有化部署许可证已过期,系统将在 {{expiredTime}} 降级为免费版并停用高级功能,请尽快更新许可证以保留完整功能。", "licenseAutoFetchFailed": "许可证自动续期失败,宽限期剩余 {{days}} 天,届时将降级为免费版。", "licenseAutoFetchRetryFailed": "自动续期仍然失败,请检查与 Teable 服务器的连接,或手动更新许可证。", @@ -1647,17 +1659,18 @@ "copyError": "复制失败" } }, - "changelog": { - "newUpdate": "7 月 28 日更新", - "title": "面向 AI Agent 的 Teable Skill", - "url": "https://help.teable.ai/en/changelog#jul-28-2026", - "id": "changelog-2026-07-28-teable-skill-for-ai-agents" - }, "resourceDescription": { "addDescription": "添加描述", "nodeDescription": "节点描述", "descriptionSaving": "保存中...", "descriptionSaveFailed": "保存描述失败,请重试", "descriptionPlaceholder": "为此节点添加一段描述" + }, + "announcement": { + "viewDetail": "查看详情", + "close": "关闭公告", + "acknowledge": "我知道了", + "collapse": "收起", + "more_other": "还有 {{count}} 条公告" } } diff --git a/packages/common-i18n/src/locales/zh/sdk.json b/packages/common-i18n/src/locales/zh/sdk.json index 0dae4730d1..76c06dc6c2 100644 --- a/packages/common-i18n/src/locales/zh/sdk.json +++ b/packages/common-i18n/src/locales/zh/sdk.json @@ -61,7 +61,9 @@ }, "preview": { "previewFileLimit": "预览暂不支持{{size}}MB以上的附件, 请下载后预览", - "loadFileError": "加载文件失败" + "loadFileError": "加载文件失败", + "previousAttachment": "上一项", + "nextAttachment": "下一项" }, "undoRedo": { "undo": "撤销", @@ -290,6 +292,8 @@ }, "expandRecord": { "copy": "复制到剪贴板", + "previousRecord": "上一条", + "nextRecord": "下一条", "duplicateRecord": "复制记录", "copyRecordUrl": "复制记录链接", "deleteRecord": "删除记录", @@ -419,6 +423,8 @@ "tableTrashRead": "查看表格回收站", "tableTrashUpdate": "更新表格回收站", "tableTrashReset": "清空表格回收站", + "tableArchiveRead": "查看表格归档区", + "tableArchiveManage": "管理表格归档区", "viewCreate": "创建视图", "viewDelete": "删除视图", "viewRead": "查看视图", @@ -434,6 +440,7 @@ "recordRead": "查看记录", "recordUpdate": "更新记录", "recordCopy": "复制记录", + "recordArchive": "归档记录", "automationCreate": "创建自动化", "automationDelete": "删除自动化", "automationRead": "读取自动化", @@ -942,15 +949,18 @@ "nameMaxLength": "名称过长,最多允许 {{max}} 个字符。", "descriptionMaxLength": "描述过长,最多允许 {{max}} 个字符。" }, - "validation": { - "field": { - "unique": "字段必须为唯一值" - } - }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" 中的 \"{{fieldName}}\" 字段不允许空值,请填写完整再提交", "fieldValueDuplicate": "\"{{tableName}}\" 中的 \"{{fieldName}}\" 字段不允许重复值,请填写唯一值再提交", + "recordFieldValueNotNull": "\"{{fieldName}}\" 字段不允许空值,请填写完整再提交", + "recordFieldValueDuplicate": "\"{{fieldName}}\" 字段不允许重复值,请填写唯一值再提交", "linkFieldValueDuplicate": "\"{{fieldName}}\" 字段不允许重复关联同一条记录", + "linkBatchDuplicate": "无法建立关联:同一批次中已有其他记录关联了该记录。在一对多关系中,每条子记录只能属于一个父记录。", + "linkOneManyDuplicate": "无法建立关联:该记录已关联到其他记录。在一对多关系中,每条子记录只能属于一个父记录。", + "linkOneOneDuplicate": "无法建立关联:目标记录已在一对一关系中被其他记录关联。", + "fieldMaxColumnLimit": "表“{{tableName}}”最多只能有 {{maxFieldCount}} 个字段。", + "fieldRequiredExistingValues": "字段“{{fieldName}}”中已有记录为空,无法设置为必填。", + "fieldUniqueExistingValues": "字段“{{fieldName}}”中已有重复值,无法设置为唯一。", "requestTimeout": "当前操作范围过大,请缩小范围重新尝试", "searchTimeOut": "搜索超时,请尝试减少搜索范围重试", "dependencyNodeRequire": "依赖节点未测试,请检查前置节点是否通过测试", diff --git a/packages/common-i18n/src/locales/zh/table.json b/packages/common-i18n/src/locales/zh/table.json index e314574057..7e55dc1798 100644 --- a/packages/common-i18n/src/locales/zh/table.json +++ b/packages/common-i18n/src/locales/zh/table.json @@ -98,17 +98,6 @@ "help": "访问 获取更多信息", "helpCenter": "帮助中心" }, - "validation": { - "link": { - "batch_duplicate": "无法建立关联:同一批次中已有其他记录关联了该记录。在一对多关系中,每条子记录只能属于一个父记录。", - "one_many_duplicate": "无法建立关联:该记录已关联到其他记录。在一对多关系中,每条子记录只能属于一个父记录。", - "one_one_duplicate": "无法建立关联:目标记录已在一对一关系中被其他记录关联。" - }, - "field": { - "maxColumnLimit": "表“{{tableName}}”最多只能有 {{maxFieldCount}} 个字段。", - "requiredExistingValues": "字段“{{fieldName}}”中已有记录为空,无法设置为必填。" - } - }, "field": { "advancedProps": "高级属性", "hide": "隐藏", @@ -513,6 +502,11 @@ "clearFailed": "清除失败", "clearConfirmTitle": "清除数据", "clearConfirmDescription": "此操作将清除 {{cellCount}} 个单元格,{{rowCount}} 条记录。确定要继续吗?", + "archiveRecordConfirmTitle": "归档记录", + "archiveRecordConfirmDescription": "此操作将归档 {{recordCount}} 条记录。归档后可在表格归档区查看和恢复。", + "archiveRecord": "归档", + "archiving": "正在归档...", + "archiveSuccessful": "归档成功", "deleteRecordConfirmTitle": "删除记录", "deleteRecordConfirmDescription": "此操作将删除 {{recordCount}} 条记录。确定要继续吗?", "duplicateRecordsConfirmTitle": "复制记录", @@ -1074,6 +1068,8 @@ "insertRecordBelow": "在下方插入 记录", "deleteRecord": "删除记录", "deleteAllSelectedRecords": "删除所有选定的记录", + "archiveRecord": "归档记录", + "archiveAllSelectedRecords": "归档所有选定的记录", "duplicateRecords": "复制所选记录", "editField": "编辑字段", "duplicateField": "复制字段", @@ -1144,11 +1140,49 @@ "title": "确认新增记录?", "description": "将会新增 {{count}} 条记录" }, + "tableArchive": { + "title": "表格归档区", + "menuTitle": "归档区", + "archivedTime": "归档时间", + "archivedBy": "归档人", + "recordDetail": "记录详情", + "empty": "暂无归档记录", + "allCreators": "全部创建人", + "filterArchivedTime": "归档时间", + "searchPlaceholder": "搜索归档记录", + "clearFilter": "清除筛选", + "export": "导出 CSV", + "exporting": "导出中… {{count}} 行", + "exportSucceed": "导出完成,开始下载", + "restoreSelected": "恢复({{count}})", + "permanentDeleteSelected": "永久删除({{count}})", + "permanentDeleteConfirm": "永久删除 {{count}} 条归档记录?此操作不可撤销。", + "permanentDeleteSucceed": "已永久删除", + "resetArchive": "清空归档区", + "resetArchiveConfirm": "清空该表格的全部归档记录?此操作不可撤销。", + "resetSucceed": "归档区已清空", + "orderBy": { + "archivedTime": "按归档时间排序", + "recordCreatedTime": "按创建时间排序", + "recordLastModifiedTime": "按最后修改时间排序" + } + }, "tableTrash": { "title": "回收站", "resourceType": "类型", "deletedResource": "资源", - "moreResources": "还有 {{count}} 条" + "moreResources": "还有 {{count}} 条", + "deletedTime": "删除时间", + "deletedBy": "删除人", + "filterAllTypes": "全部类型", + "filterAllUsers": "全部操作人", + "filterDeletedTime": "删除时间", + "clearFilter": "清除筛选", + "recordsDialogTitle": "已删除的记录({{count}})", + "recordDetail": "记录详情", + "filterAllCreators": "全部创建人", + "filterCreatedTime": "创建时间", + "searchPlaceholder": "搜索记录" }, "pluginPanel": { "empty": { @@ -1202,7 +1236,10 @@ "linkScopeDialogTitle": "配置关联字段范围", "linkHolderCanCopyAndSave": "可另存为副本", "linkHolderCanCopyAndSaveDesc": "获得链接的人可以将数据保存到自己的空间", - "editRequiresLogin": "用户必须登录才能编辑记录", + "copyToMySpace": "复制到我的空间", + "saveToMySpace": "保存到我的空间", + "supportSaveCopy": "可保存副本到我的空间", + "editRequiresLogin": "登录后即可编辑记录", "enterPassword": "输入密码", "allowCopyData": "允许查看者复制数据", "sharedNode": "已分享节点", diff --git a/packages/core/src/auth/actions.ts b/packages/core/src/auth/actions.ts index b5b8b406ad..5989169bcd 100644 --- a/packages/core/src/auth/actions.ts +++ b/packages/core/src/auth/actions.ts @@ -55,6 +55,8 @@ export const tableActions = [ 'table|trash_read', 'table|trash_update', 'table|trash_reset', + 'table|archive_read', + 'table|archive_manage', ] as const; export const tableActionSchema = z.enum(tableActions); export type TableAction = z.infer; @@ -80,6 +82,7 @@ export const recordActions = [ 'record|update', 'record|comment', 'record|copy', + 'record|archive', ] as const; export const recordActionSchema = z.enum(recordActions); export type RecordAction = z.infer; diff --git a/packages/core/src/auth/oauth.ts b/packages/core/src/auth/oauth.ts index 4a93f2886c..c57b26d39e 100644 --- a/packages/core/src/auth/oauth.ts +++ b/packages/core/src/auth/oauth.ts @@ -41,6 +41,8 @@ export const OAUTH_ACTIONS: ( 'table|trash_read', 'table|trash_update', 'table|trash_reset', + 'table|archive_read', + 'table|archive_manage', 'view|create', 'view|delete', 'view|read', @@ -54,6 +56,7 @@ export const OAUTH_ACTIONS: ( 'record|delete', 'record|read', 'record|update', + 'record|archive', 'automation|create', 'automation|delete', 'automation|read', diff --git a/packages/core/src/auth/role/constant.ts b/packages/core/src/auth/role/constant.ts index dacfce7def..e45ef4915e 100644 --- a/packages/core/src/auth/role/constant.ts +++ b/packages/core/src/auth/role/constant.ts @@ -32,6 +32,8 @@ export const RolePermission: Record> = { 'table|trash_read': true, 'table|trash_update': true, 'table|trash_reset': true, + 'table|archive_read': true, + 'table|archive_manage': true, 'table_record_history|read': true, 'view|create': true, 'view|delete': true, @@ -45,6 +47,7 @@ export const RolePermission: Record> = { 'record|create': true, 'record|comment': true, 'record|delete': true, + 'record|archive': true, 'record|read': true, 'record|update': true, 'record|copy': true, @@ -92,6 +95,8 @@ export const RolePermission: Record> = { 'table|trash_read': true, 'table|trash_update': true, 'table|trash_reset': true, + 'table|archive_read': true, + 'table|archive_manage': true, 'table_record_history|read': true, 'view|create': true, 'view|delete': true, @@ -105,6 +110,7 @@ export const RolePermission: Record> = { 'record|create': true, 'record|comment': true, 'record|delete': true, + 'record|archive': true, 'record|read': true, 'record|update': true, 'record|copy': true, @@ -152,6 +158,8 @@ export const RolePermission: Record> = { 'table|trash_read': true, 'table|trash_update': true, 'table|trash_reset': false, + 'table|archive_read': true, + 'table|archive_manage': false, 'table_record_history|read': true, 'view|create': true, 'view|delete': true, @@ -165,6 +173,7 @@ export const RolePermission: Record> = { 'record|create': true, 'record|comment': true, 'record|delete': true, + 'record|archive': true, 'record|read': true, 'record|update': true, 'record|copy': true, @@ -212,6 +221,8 @@ export const RolePermission: Record> = { 'table|trash_read': false, 'table|trash_update': false, 'table|trash_reset': false, + 'table|archive_read': false, + 'table|archive_manage': false, 'table_record_history|read': false, 'view|create': false, 'view|delete': false, @@ -225,6 +236,7 @@ export const RolePermission: Record> = { 'record|create': false, 'record|comment': true, 'record|delete': false, + 'record|archive': false, 'record|read': true, 'record|update': false, 'record|copy': true, @@ -272,6 +284,8 @@ export const RolePermission: Record> = { 'table|trash_read': false, 'table|trash_update': false, 'table|trash_reset': false, + 'table|archive_read': false, + 'table|archive_manage': false, 'table_record_history|read': false, 'view|create': false, 'view|delete': false, @@ -285,6 +299,7 @@ export const RolePermission: Record> = { 'record|create': false, 'record|comment': false, 'record|delete': false, + 'record|archive': false, 'record|read': true, 'record|update': false, 'record|copy': true, diff --git a/packages/core/src/auth/role/template.ts b/packages/core/src/auth/role/template.ts index 666ca146b4..0b27c702f9 100644 --- a/packages/core/src/auth/role/template.ts +++ b/packages/core/src/auth/role/template.ts @@ -35,6 +35,8 @@ export const TemplateRolePermission: Record = { 'table|trash_read': false, 'table|trash_update': false, 'table|trash_reset': false, + 'table|archive_read': false, + 'table|archive_manage': false, 'table_record_history|read': false, 'view|create': false, 'view|delete': false, @@ -48,6 +50,7 @@ export const TemplateRolePermission: Record = { 'record|delete': false, 'record|update': false, 'record|copy': false, + 'record|archive': false, 'automation|create': false, 'automation|delete': false, 'automation|update': false, diff --git a/packages/core/src/auth/role/utils.ts b/packages/core/src/auth/role/utils.ts index 1b6713eb0b..082373050f 100644 --- a/packages/core/src/auth/role/utils.ts +++ b/packages/core/src/auth/role/utils.ts @@ -1,5 +1,9 @@ -import { RoleLevel } from './types'; +import { BillableRoles, RoleLevel } from './types'; export const canManageRole = (managerRole: string, targetRole: string) => { return RoleLevel.indexOf(managerRole) < RoleLevel.indexOf(targetRole); }; + +export const isBillableRole = (role: string) => { + return (BillableRoles as readonly string[]).includes(role); +}; diff --git a/packages/core/src/models/field/derivate/link.field.spec.ts b/packages/core/src/models/field/derivate/link.field.spec.ts index 42a05300bb..7dc654da4f 100644 --- a/packages/core/src/models/field/derivate/link.field.spec.ts +++ b/packages/core/src/models/field/derivate/link.field.spec.ts @@ -107,6 +107,9 @@ describe('LinkFieldCore', () => { expect(multipleFieldFromSingle.success && multipleFieldFromSingle.data).toEqual([cellValue]); expect(fieldMultiple.validateCellValue([cellValue, cellValue]).success).toBe(true); expect(fieldMultiple.validateCellValue([]).success).toBe(false); + const nullTitle = field.validateCellValue({ id: 'recxxxxxxxx', title: null }); + expect(nullTitle.success).toBe(true); + expect(nullTitle.success && nullTitle.data).toEqual({ id: 'recxxxxxxxx' }); }); it('should convert string to cellValue', () => { diff --git a/packages/core/src/models/field/derivate/link.field.ts b/packages/core/src/models/field/derivate/link.field.ts index d07ac0695c..9f38d2510f 100644 --- a/packages/core/src/models/field/derivate/link.field.ts +++ b/packages/core/src/models/field/derivate/link.field.ts @@ -11,10 +11,13 @@ import { type ILinkFieldMeta, } from './link-option.schema'; -export const linkCellValueSchema = z.object({ - id: z.string().startsWith(IdPrefix.Record), - title: z.string().optional(), -}); +export const linkCellValueSchema = z + .object({ + id: z.string().startsWith(IdPrefix.Record), + // Persisted empty-primary links may carry title:null; accept and strip. + title: z.string().nullish(), + }) + .transform(({ id, title }) => (title == null ? { id } : { id, title })); export type ILinkCellValue = z.infer; diff --git a/packages/db-data-prisma/prisma/migrations/20260730000000_add_record_archive_and_removal_cold/migration.sql b/packages/db-data-prisma/prisma/migrations/20260730000000_add_record_archive_and_removal_cold/migration.sql new file mode 100644 index 0000000000..d0e7a9fa2b --- /dev/null +++ b/packages/db-data-prisma/prisma/migrations/20260730000000_add_record_archive_and_removal_cold/migration.sql @@ -0,0 +1,54 @@ +-- Record archive and the record-removal cold layer. +-- +-- 1) record_trash gains the archive dimension columns and partial indexes: archive reuses the +-- delete orchestration and stores snapshots with reason = 'archived', filtered and sorted +-- on fixed dimensions extracted from the snapshot at write time. ADD COLUMN with a +-- constant default is metadata-only on PG11+, so existing rows are not rewritten, and the +-- partial indexes start empty because every existing row has reason = 'deleted'. +-- 2) The deleted-reason partial indexes serve the recycle bin's merged (PG + S3) record +-- reads, which page keyset-ordered by (created_time DESC, id DESC): operation-scoped for +-- items whose rows carry operation_id (every write since this migration stamps it), and +-- table-scoped for LEGACY items, whose reader walks the deleted timeline and filters item +-- membership app-side. +-- 3) record_removal_tombstone marks rows already sunk to cold storage that were later +-- restored or purged (S3 parts are immutable, so such a row cannot be deleted in place). +-- Cold reads and the restore fallback filter through this table; the monthly compaction +-- physically drops tombstoned rows when it rewrites month parts. + +ALTER TABLE "record_trash" ADD COLUMN IF NOT EXISTS "reason" TEXT NOT NULL DEFAULT 'deleted'; +ALTER TABLE "record_trash" ADD COLUMN IF NOT EXISTS "record_created_time" TIMESTAMP(3); +ALTER TABLE "record_trash" ADD COLUMN IF NOT EXISTS "record_created_by" TEXT; +ALTER TABLE "record_trash" ADD COLUMN IF NOT EXISTS "record_last_modified_time" TIMESTAMP(3); +ALTER TABLE "record_trash" ADD COLUMN IF NOT EXISTS "record_last_modified_by" TEXT; +ALTER TABLE "record_trash" ADD COLUMN IF NOT EXISTS "operation_id" TEXT; + +CREATE INDEX IF NOT EXISTS "record_trash_archived_removed_idx" + ON "record_trash"("table_id", "created_time" DESC, "id" DESC) WHERE "reason" = 'archived'; +CREATE INDEX IF NOT EXISTS "record_trash_archived_created_idx" + ON "record_trash"("table_id", "record_created_time" DESC, "id" DESC) WHERE "reason" = 'archived'; +CREATE INDEX IF NOT EXISTS "record_trash_archived_creator_idx" + ON "record_trash"("table_id", "record_created_by") WHERE "reason" = 'archived'; +CREATE INDEX IF NOT EXISTS "record_trash_archived_modified_idx" + ON "record_trash"("table_id", "record_last_modified_time" DESC, "id" DESC) WHERE "reason" = 'archived'; +CREATE INDEX IF NOT EXISTS "record_trash_archived_modifier_idx" + ON "record_trash"("table_id", "record_last_modified_by") WHERE "reason" = 'archived'; + +CREATE INDEX IF NOT EXISTS "record_trash_deleted_operation_idx" + ON "record_trash"("operation_id", "created_time" DESC, "id" DESC) + WHERE "reason" = 'deleted' AND "operation_id" IS NOT NULL; +CREATE INDEX IF NOT EXISTS "record_trash_deleted_removed_idx" + ON "record_trash"("table_id", "created_time" DESC, "id" DESC) + WHERE "reason" = 'deleted'; + +CREATE TABLE IF NOT EXISTS "record_removal_tombstone" ( + "id" TEXT NOT NULL, + "table_id" TEXT NOT NULL, + "record_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "created_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "record_removal_tombstone_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "record_removal_tombstone_table_id_record_id_idx" + ON "record_removal_tombstone"("table_id", "record_id"); diff --git a/packages/db-data-prisma/prisma/migrations/20260804000000_add_computed_update_stage_ledger/migration.sql b/packages/db-data-prisma/prisma/migrations/20260804000000_add_computed_update_stage_ledger/migration.sql new file mode 100644 index 0000000000..eb1f6696ff --- /dev/null +++ b/packages/db-data-prisma/prisma/migrations/20260804000000_add_computed_update_stage_ledger/migration.sql @@ -0,0 +1,17 @@ +-- Durable per-stage state for budget-staged computed updates (exclusion ledger +-- + frontier queue), keyed by the continuation chain's root task id. Purely +-- additive: no existing table or index changes, so it is safe under rolling +-- deploys. +CREATE TABLE "computed_update_stage_ledger" ( + "scope_id" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "table_id" TEXT NOT NULL, + "record_id" TEXT NOT NULL, + "seq" BIGINT NOT NULL DEFAULT 0, + + CONSTRAINT "computed_update_stage_ledger_pkey" PRIMARY KEY ("scope_id","kind","table_id","record_id"), + CONSTRAINT "computed_update_stage_ledger_kind_check" CHECK ("kind" IN ('excluded','frontier','consumed')) +); + +-- CreateIndex +CREATE INDEX "computed_update_stage_ledger_scope_id_kind_seq_idx" ON "computed_update_stage_ledger"("scope_id", "kind", "seq"); diff --git a/packages/db-data-prisma/prisma/schema.prisma b/packages/db-data-prisma/prisma/schema.prisma index 623d3c3be8..2ac99534a6 100644 --- a/packages/db-data-prisma/prisma/schema.prisma +++ b/packages/db-data-prisma/prisma/schema.prisma @@ -58,6 +58,27 @@ model ComputedUpdateOutboxSeed { @@map("computed_update_outbox_seed") } +/// Durable per-stage state for budget-staged computed updates, keyed by the +/// continuation chain's root task id (its scope). Rows are written once per +/// record and never copied between continuation tasks: +/// - kind 'excluded': processed-target exclusion ledger for partial batches; +/// - kind 'frontier': seq-ordered queue of sources whose outgoing propagation +/// is not finished (self-referential generations + migrated explicit seeds); +/// - kind 'consumed': retired frontier sources preserved for deferred edge +/// chunks, handed to the continuation as seeds at stage completion. +/// Cleared when the chain's stage completes or the chain dead-letters. +model ComputedUpdateStageLedger { + scopeId String @map("scope_id") + kind String + tableId String @map("table_id") + recordId String @map("record_id") + seq BigInt @default(0) + + @@id([scopeId, kind, tableId, recordId]) + @@index([scopeId, kind, seq]) + @@map("computed_update_stage_ledger") +} + model ComputedUpdateDeadLetter { id String @id baseId String @map("base_id") @@ -193,15 +214,32 @@ model TableTrash { } model RecordTrash { - id String @id @default(cuid()) + id String @id @default(cuid()) + tableId String @map("table_id") + recordId String @map("record_id") + snapshot String @map("snapshot") + createdTime DateTime @default(now()) @map("created_time") + createdBy String @map("created_by") + reason String @default("deleted") @map("reason") + recordCreatedTime DateTime? @map("record_created_time") + recordCreatedBy String? @map("record_created_by") + recordLastModifiedTime DateTime? @map("record_last_modified_time") + recordLastModifiedBy String? @map("record_last_modified_by") + operationId String? @map("operation_id") + + @@index([tableId, recordId]) + @@map("record_trash") +} + +model RecordRemovalTombstone { + id String @id tableId String @map("table_id") recordId String @map("record_id") - snapshot String @map("snapshot") + type String createdTime DateTime @default(now()) @map("created_time") - createdBy String @map("created_by") @@index([tableId, recordId]) - @@map("record_trash") + @@map("record_removal_tombstone") } model Attachments { diff --git a/packages/db-main-prisma/prisma/postgres/migrations/20260729000000_drop_unattributed_task_runs/migration.sql b/packages/db-main-prisma/prisma/postgres/migrations/20260729000000_drop_unattributed_task_runs/migration.sql new file mode 100644 index 0000000000..6d5122803a --- /dev/null +++ b/packages/db-main-prisma/prisma/postgres/migrations/20260729000000_drop_unattributed_task_runs/migration.sql @@ -0,0 +1,42 @@ +-- Drop pre-cutover task_run rows (T5401): rows created before the +-- per-generation scheduling pipeline never carry base_id, and with the cutover +-- converter removed they are invisible to every runtime path; terminal rows +-- have no readers anywhere in the product. +-- +-- Tasks still holding active unattributed runs are cancelled together with ALL +-- of their remaining active runs: the cutover-era watchdog backfilled base_id +-- in bounded batches, so one task can hold unattributed rows AND attributed +-- siblings whose queue jobs are still live — cancelling the runs is what makes +-- the worker's entry guard reject those jobs instead of executing (and +-- billing) a cancelled task. Both cancels run as ONE statement so they are +-- atomic and the affected task set is materialized from a single snapshot. +-- +-- The NOT NULL constraint lands across the two follow-up migrations (VALIDATE +-- under a lock that admits concurrent reads/writes, then a scan-free SET NOT +-- NULL) so no statement scans the table under ACCESS EXCLUSIVE while old pods +-- are still serving. Every statement is idempotent — a partially applied +-- deploy converges on re-run. +WITH "affected" AS ( + SELECT DISTINCT "task_id" FROM "task_run" + WHERE "base_id" IS NULL + AND "status" IN ('pending', 'queued', 'processing') +), +"cancelled_tasks" AS ( + UPDATE "task" + SET "status" = 'cancelled', + "last_modified_time" = CURRENT_TIMESTAMP + WHERE "status" IN ('pending', 'processing') + AND "id" IN (SELECT "task_id" FROM "affected") + RETURNING "id" +) +UPDATE "task_run" +SET "status" = 'cancelled', + "error_msg" = 'Cancelled: this run predates the current release and was cancelled during upgrade — please re-trigger', + "last_modified_time" = CURRENT_TIMESTAMP +WHERE "status" IN ('pending', 'queued', 'processing') + AND "task_id" IN (SELECT "task_id" FROM "affected"); + +DELETE FROM "task_run" WHERE "base_id" IS NULL; + +ALTER TABLE "task_run" DROP CONSTRAINT IF EXISTS "task_run_base_id_not_null"; +ALTER TABLE "task_run" ADD CONSTRAINT "task_run_base_id_not_null" CHECK ("base_id" IS NOT NULL) NOT VALID; diff --git a/packages/db-main-prisma/prisma/postgres/migrations/20260729000001_validate_task_run_base_id_check/migration.sql b/packages/db-main-prisma/prisma/postgres/migrations/20260729000001_validate_task_run_base_id_check/migration.sql new file mode 100644 index 0000000000..f0b37d6ad5 --- /dev/null +++ b/packages/db-main-prisma/prisma/postgres/migrations/20260729000001_validate_task_run_base_id_check/migration.sql @@ -0,0 +1,6 @@ +-- Kept as its own migration: VALIDATE takes only SHARE UPDATE EXCLUSIVE, so +-- concurrent task_run reads/writes keep flowing while the heap is scanned — +-- it must never share a transaction with the previous migration's ADD +-- CONSTRAINT, whose ACCESS EXCLUSIVE lock would otherwise pin across this +-- scan. Idempotent. +ALTER TABLE "task_run" VALIDATE CONSTRAINT "task_run_base_id_not_null"; diff --git a/packages/db-main-prisma/prisma/postgres/migrations/20260729000002_set_task_run_base_id_not_null/migration.sql b/packages/db-main-prisma/prisma/postgres/migrations/20260729000002_set_task_run_base_id_not_null/migration.sql new file mode 100644 index 0000000000..66c90f6a3c --- /dev/null +++ b/packages/db-main-prisma/prisma/postgres/migrations/20260729000002_set_task_run_base_id_not_null/migration.sql @@ -0,0 +1,6 @@ +-- The validated CHECK lets PostgreSQL (12+) prove the column has no NULLs, so +-- SET NOT NULL skips the table scan and its ACCESS EXCLUSIVE lock is +-- metadata-only and momentary. The CHECK is a stepping stone, dropped here so +-- the schema carries a single mechanism (the column constraint). Idempotent. +ALTER TABLE "task_run" ALTER COLUMN "base_id" SET NOT NULL; +ALTER TABLE "task_run" DROP CONSTRAINT IF EXISTS "task_run_base_id_not_null"; diff --git a/packages/db-main-prisma/prisma/postgres/migrations/20260730000000_add_record_archive_and_removal_cold/migration.sql b/packages/db-main-prisma/prisma/postgres/migrations/20260730000000_add_record_archive_and_removal_cold/migration.sql new file mode 100644 index 0000000000..8e71b4bb56 --- /dev/null +++ b/packages/db-main-prisma/prisma/postgres/migrations/20260730000000_add_record_archive_and_removal_cold/migration.sql @@ -0,0 +1,62 @@ +-- Record archive and the record-removal cold layer. +-- +-- 1) record_trash gains the archive dimension columns and partial indexes: archive reuses the +-- delete orchestration and stores snapshots with reason = 'archived', filtered and sorted +-- on fixed dimensions extracted from the snapshot at write time. ADD COLUMN with a +-- constant default is metadata-only on PG11+, so existing rows are not rewritten, and the +-- partial indexes start empty because every existing row has reason = 'deleted'. +-- 2) The deleted-reason partial indexes serve the recycle bin's merged (PG + S3) record +-- reads, which page keyset-ordered by (created_time DESC, id DESC): operation-scoped for +-- items whose rows carry operation_id (every write since this migration stamps it), and +-- table-scoped for LEGACY items, whose reader walks the deleted timeline and filters item +-- membership app-side. +-- 3) space_data_db_binding gains a per-binding flush bookmark so the daily removal flusher +-- can skip idle BYODB tenant dbs without connecting to them. +-- 4) record_removal_tombstone marks rows already sunk to cold storage that were later +-- restored or purged (S3 parts are immutable, so such a row cannot be deleted in place). +-- Cold reads and the restore fallback filter through this table; the monthly compaction +-- physically drops tombstoned rows when it rewrites month parts. +-- +-- The record_trash and record_removal_tombstone statements mirror the db-data-prisma +-- migration of the same name: shared (non-BYODB) deployments host the data-plane tables in +-- the main db, so both migration sets can target the same database — hence IF NOT EXISTS. + +ALTER TABLE "record_trash" ADD COLUMN IF NOT EXISTS "reason" TEXT NOT NULL DEFAULT 'deleted'; +ALTER TABLE "record_trash" ADD COLUMN IF NOT EXISTS "record_created_time" TIMESTAMP(3); +ALTER TABLE "record_trash" ADD COLUMN IF NOT EXISTS "record_created_by" TEXT; +ALTER TABLE "record_trash" ADD COLUMN IF NOT EXISTS "record_last_modified_time" TIMESTAMP(3); +ALTER TABLE "record_trash" ADD COLUMN IF NOT EXISTS "record_last_modified_by" TEXT; +ALTER TABLE "record_trash" ADD COLUMN IF NOT EXISTS "operation_id" TEXT; + +CREATE INDEX IF NOT EXISTS "record_trash_archived_removed_idx" + ON "record_trash"("table_id", "created_time" DESC, "id" DESC) WHERE "reason" = 'archived'; +CREATE INDEX IF NOT EXISTS "record_trash_archived_created_idx" + ON "record_trash"("table_id", "record_created_time" DESC, "id" DESC) WHERE "reason" = 'archived'; +CREATE INDEX IF NOT EXISTS "record_trash_archived_creator_idx" + ON "record_trash"("table_id", "record_created_by") WHERE "reason" = 'archived'; +CREATE INDEX IF NOT EXISTS "record_trash_archived_modified_idx" + ON "record_trash"("table_id", "record_last_modified_time" DESC, "id" DESC) WHERE "reason" = 'archived'; +CREATE INDEX IF NOT EXISTS "record_trash_archived_modifier_idx" + ON "record_trash"("table_id", "record_last_modified_by") WHERE "reason" = 'archived'; + +CREATE INDEX IF NOT EXISTS "record_trash_deleted_operation_idx" + ON "record_trash"("operation_id", "created_time" DESC, "id" DESC) + WHERE "reason" = 'deleted' AND "operation_id" IS NOT NULL; +CREATE INDEX IF NOT EXISTS "record_trash_deleted_removed_idx" + ON "record_trash"("table_id", "created_time" DESC, "id" DESC) + WHERE "reason" = 'deleted'; + +ALTER TABLE "space_data_db_binding" ADD COLUMN IF NOT EXISTS "last_removal_flushed_at" TIMESTAMP(3); + +CREATE TABLE IF NOT EXISTS "record_removal_tombstone" ( + "id" TEXT NOT NULL, + "table_id" TEXT NOT NULL, + "record_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "created_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "record_removal_tombstone_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "record_removal_tombstone_table_id_record_id_idx" + ON "record_removal_tombstone"("table_id", "record_id"); diff --git a/packages/db-main-prisma/prisma/postgres/migrations/20260803000000_backfill_user_default_avatar/migration.sql b/packages/db-main-prisma/prisma/postgres/migrations/20260803000000_backfill_user_default_avatar/migration.sql new file mode 100644 index 0000000000..4953d2c1e5 --- /dev/null +++ b/packages/db-main-prisma/prisma/postgres/migrations/20260803000000_backfill_user_default_avatar/migration.sql @@ -0,0 +1,12 @@ +-- Backfill default avatar paths for legacy users (created before 2024-03, +-- when default avatar generation was introduced). A NULL avatar fails V2 +-- user field updates with a 400 validation error. +-- The written path matches UserService.generateDefaultAvatar; the avatar +-- image itself may not exist in storage, in which case the UI falls back +-- to the user's initial (same rendering as before this migration). +-- System robots are intentionally left untouched (is_system is only ever +-- NULL or TRUE). +UPDATE "users" +SET "avatar" = 'avatar/' || "id" +WHERE "avatar" IS NULL + AND "is_system" IS NULL; diff --git a/packages/db-main-prisma/prisma/postgres/migrations/20260804000000_add_computed_update_stage_ledger/migration.sql b/packages/db-main-prisma/prisma/postgres/migrations/20260804000000_add_computed_update_stage_ledger/migration.sql new file mode 100644 index 0000000000..eb1f6696ff --- /dev/null +++ b/packages/db-main-prisma/prisma/postgres/migrations/20260804000000_add_computed_update_stage_ledger/migration.sql @@ -0,0 +1,17 @@ +-- Durable per-stage state for budget-staged computed updates (exclusion ledger +-- + frontier queue), keyed by the continuation chain's root task id. Purely +-- additive: no existing table or index changes, so it is safe under rolling +-- deploys. +CREATE TABLE "computed_update_stage_ledger" ( + "scope_id" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "table_id" TEXT NOT NULL, + "record_id" TEXT NOT NULL, + "seq" BIGINT NOT NULL DEFAULT 0, + + CONSTRAINT "computed_update_stage_ledger_pkey" PRIMARY KEY ("scope_id","kind","table_id","record_id"), + CONSTRAINT "computed_update_stage_ledger_kind_check" CHECK ("kind" IN ('excluded','frontier','consumed')) +); + +-- CreateIndex +CREATE INDEX "computed_update_stage_ledger_scope_id_kind_seq_idx" ON "computed_update_stage_ledger"("scope_id", "kind", "seq"); diff --git a/packages/db-main-prisma/prisma/postgres/migrations/20260804050000_backfill_view_column_meta_order/migration.sql b/packages/db-main-prisma/prisma/postgres/migrations/20260804050000_backfill_view_column_meta_order/migration.sql new file mode 100644 index 0000000000..ef8dd95fe4 --- /dev/null +++ b/packages/db-main-prisma/prisma/postgres/migrations/20260804050000_backfill_view_column_meta_order/migration.sql @@ -0,0 +1,39 @@ +-- Legacy column metadata can contain width/visibility patches without an order. +-- The public View contract requires every retained column entry to have a numeric order; +-- use the Table field order as the deterministic fallback for active fields. +WITH repaired_view_column_meta AS ( + SELECT + v."id", + jsonb_object_agg( + entry."key", + CASE + WHEN jsonb_typeof(entry."value") = 'object' + AND jsonb_typeof(entry."value" -> 'order') IS DISTINCT FROM 'number' + AND f."id" IS NOT NULL + THEN jsonb_set(entry."value", '{order}', to_jsonb(f."order"), true) + ELSE entry."value" + END + ) AS "column_meta" + FROM "view" AS v + CROSS JOIN LATERAL jsonb_each( + CASE + WHEN jsonb_typeof(v."column_meta"::jsonb) = 'object' THEN v."column_meta"::jsonb + ELSE '{}'::jsonb + END + ) AS entry + LEFT JOIN "field" AS f + ON f."id" = entry."key" + AND f."table_id" = v."table_id" + AND f."deleted_time" IS NULL + WHERE v."deleted_time" IS NULL + GROUP BY v."id" + HAVING bool_or( + jsonb_typeof(entry."value") = 'object' + AND jsonb_typeof(entry."value" -> 'order') IS DISTINCT FROM 'number' + AND f."id" IS NOT NULL + ) +) +UPDATE "view" AS v +SET "column_meta" = repaired."column_meta"::text +FROM repaired_view_column_meta AS repaired +WHERE v."id" = repaired."id"; diff --git a/packages/db-main-prisma/prisma/postgres/schema.prisma b/packages/db-main-prisma/prisma/postgres/schema.prisma index ac34289093..90fd18eb5c 100644 --- a/packages/db-main-prisma/prisma/postgres/schema.prisma +++ b/packages/db-main-prisma/prisma/postgres/schema.prisma @@ -112,6 +112,7 @@ model SpaceDataDbBinding { createdTime DateTime @default(now()) @map("created_time") lastModifiedTime DateTime? @updatedAt @map("last_modified_time") lastHistoryFlushedAt DateTime? @map("last_history_flushed_at") + lastRemovalFlushedAt DateTime? @map("last_removal_flushed_at") space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) dataDbConnection DataDbConnection? @relation(fields: [dataDbConnectionId], references: [id]) @@ -371,6 +372,27 @@ model ComputedUpdateOutboxSeed { @@map("computed_update_outbox_seed") } +/// Durable per-stage state for budget-staged computed updates, keyed by the +/// continuation chain's root task id (its scope). Rows are written once per +/// record and never copied between continuation tasks: +/// - kind 'excluded': processed-target exclusion ledger for partial batches; +/// - kind 'frontier': seq-ordered queue of sources whose outgoing propagation +/// is not finished (self-referential generations + migrated explicit seeds); +/// - kind 'consumed': retired frontier sources preserved for deferred edge +/// chunks, handed to the continuation as seeds at stage completion. +/// Cleared when the chain's stage completes or the chain dead-letters. +model ComputedUpdateStageLedger { + scopeId String @map("scope_id") + kind String + tableId String @map("table_id") + recordId String @map("record_id") + seq BigInt @default(0) + + @@id([scopeId, kind, tableId, recordId]) + @@index([scopeId, kind, seq]) + @@map("computed_update_stage_ledger") +} + model ComputedUpdateDeadLetter { id String @id baseId String @map("base_id") @@ -817,15 +839,35 @@ model TableTrash { } model RecordTrash { - id String @id @default(cuid()) + id String @id @default(cuid()) + tableId String @map("table_id") + recordId String @map("record_id") + snapshot String @map("snapshot") + createdTime DateTime @default(now()) @map("created_time") + createdBy String @map("created_by") + reason String @default("deleted") @map("reason") + recordCreatedTime DateTime? @map("record_created_time") + recordCreatedBy String? @map("record_created_by") + recordLastModifiedTime DateTime? @map("record_last_modified_time") + recordLastModifiedBy String? @map("record_last_modified_by") + operationId String? @map("operation_id") + + @@index([tableId, recordId]) + @@map("record_trash") +} + +// Mirror of the db-data-prisma model: shared (non-BYODB) deployments host the +// data-plane tables in the main db, so the table must exist here too. BYODB +// tenant dbs get it from the db-data migration instead. +model RecordRemovalTombstone { + id String @id tableId String @map("table_id") recordId String @map("record_id") - snapshot String @map("snapshot") + type String createdTime DateTime @default(now()) @map("created_time") - createdBy String @map("created_by") @@index([tableId, recordId]) - @@map("record_trash") + @@map("record_removal_tombstone") } model Plugin { @@ -1028,7 +1070,7 @@ model Task { model TaskRun { id String @id @default(cuid()) taskId String @map("task_id") - baseId String? @map("base_id") + baseId String @map("base_id") status String @map("status") snapshot String @map("snapshot") dependsOnRunIds String[] @default([]) @map("depends_on_run_ids") diff --git a/packages/db-main-prisma/prisma/template.prisma b/packages/db-main-prisma/prisma/template.prisma index 3ebc954a6b..3aeae482da 100644 --- a/packages/db-main-prisma/prisma/template.prisma +++ b/packages/db-main-prisma/prisma/template.prisma @@ -112,6 +112,7 @@ model SpaceDataDbBinding { createdTime DateTime @default(now()) @map("created_time") lastModifiedTime DateTime? @updatedAt @map("last_modified_time") lastHistoryFlushedAt DateTime? @map("last_history_flushed_at") + lastRemovalFlushedAt DateTime? @map("last_removal_flushed_at") space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) dataDbConnection DataDbConnection? @relation(fields: [dataDbConnectionId], references: [id]) @@ -371,6 +372,27 @@ model ComputedUpdateOutboxSeed { @@map("computed_update_outbox_seed") } +/// Durable per-stage state for budget-staged computed updates, keyed by the +/// continuation chain's root task id (its scope). Rows are written once per +/// record and never copied between continuation tasks: +/// - kind 'excluded': processed-target exclusion ledger for partial batches; +/// - kind 'frontier': seq-ordered queue of sources whose outgoing propagation +/// is not finished (self-referential generations + migrated explicit seeds); +/// - kind 'consumed': retired frontier sources preserved for deferred edge +/// chunks, handed to the continuation as seeds at stage completion. +/// Cleared when the chain's stage completes or the chain dead-letters. +model ComputedUpdateStageLedger { + scopeId String @map("scope_id") + kind String + tableId String @map("table_id") + recordId String @map("record_id") + seq BigInt @default(0) + + @@id([scopeId, kind, tableId, recordId]) + @@index([scopeId, kind, seq]) + @@map("computed_update_stage_ledger") +} + model ComputedUpdateDeadLetter { id String @id baseId String @map("base_id") @@ -817,15 +839,35 @@ model TableTrash { } model RecordTrash { - id String @id @default(cuid()) + id String @id @default(cuid()) + tableId String @map("table_id") + recordId String @map("record_id") + snapshot String @map("snapshot") + createdTime DateTime @default(now()) @map("created_time") + createdBy String @map("created_by") + reason String @default("deleted") @map("reason") + recordCreatedTime DateTime? @map("record_created_time") + recordCreatedBy String? @map("record_created_by") + recordLastModifiedTime DateTime? @map("record_last_modified_time") + recordLastModifiedBy String? @map("record_last_modified_by") + operationId String? @map("operation_id") + + @@index([tableId, recordId]) + @@map("record_trash") +} + +// Mirror of the db-data-prisma model: shared (non-BYODB) deployments host the +// data-plane tables in the main db, so the table must exist here too. BYODB +// tenant dbs get it from the db-data migration instead. +model RecordRemovalTombstone { + id String @id tableId String @map("table_id") recordId String @map("record_id") - snapshot String @map("snapshot") + type String createdTime DateTime @default(now()) @map("created_time") - createdBy String @map("created_by") @@index([tableId, recordId]) - @@map("record_trash") + @@map("record_removal_tombstone") } model Plugin { @@ -1028,7 +1070,7 @@ model Task { model TaskRun { id String @id @default(cuid()) taskId String @map("task_id") - baseId String? @map("base_id") + baseId String @map("base_id") status String @map("status") snapshot String @map("snapshot") dependsOnRunIds String[] @default([]) @map("depends_on_run_ids") diff --git a/packages/i18n-keys/src/index.ts b/packages/i18n-keys/src/index.ts index e6ecd04771..0fbc350321 100644 --- a/packages/i18n-keys/src/index.ts +++ b/packages/i18n-keys/src/index.ts @@ -1,15 +1,81 @@ -export const tableI18nKeys = { - validation: { - link: { - batch_duplicate: 'validation.link.batch_duplicate', - one_many_duplicate: 'validation.link.one_many_duplicate', - one_one_duplicate: 'validation.link.one_one_duplicate', - }, - field: { - maxColumnLimit: 'validation.field.maxColumnLimit', - requiredExistingValues: 'validation.field.requiredExistingValues', - }, +/** + * User-facing error message keys in the frontend `sdk` locale namespace + * (common-i18n `sdk.json`). A v2 error attaches one of these (plus its + * interpolation context) as `localization` at the site where the error is + * created; every layer up to HTTP passes it through untouched. + */ +export const sdkErrorI18nKeys = { + custom: { + recordFieldValueNotNull: 'httpErrors.custom.recordFieldValueNotNull', + recordFieldValueDuplicate: 'httpErrors.custom.recordFieldValueDuplicate', + linkBatchDuplicate: 'httpErrors.custom.linkBatchDuplicate', + linkOneManyDuplicate: 'httpErrors.custom.linkOneManyDuplicate', + linkOneOneDuplicate: 'httpErrors.custom.linkOneOneDuplicate', + fieldMaxColumnLimit: 'httpErrors.custom.fieldMaxColumnLimit', + fieldRequiredExistingValues: 'httpErrors.custom.fieldRequiredExistingValues', + fieldUniqueExistingValues: 'httpErrors.custom.fieldUniqueExistingValues', + }, + limit: { + fieldOptionsMaxBytes: 'httpErrors.limit.fieldOptionsMaxBytes', + selectChoicesMax: 'httpErrors.limit.selectChoicesMax', + selectChoiceNameMaxLength: 'httpErrors.limit.selectChoiceNameMaxLength', + selectDefaultValuesMax: 'httpErrors.limit.selectDefaultValuesMax', + cellValueMaxBytes: 'httpErrors.limit.cellValueMaxBytes', + recordFieldsMaxBytes: 'httpErrors.limit.recordFieldsMaxBytes', + recordsPerMutationMax: 'httpErrors.limit.recordsPerMutationMax', + computedCellValueMaxBytes: 'httpErrors.limit.computedCellValueMaxBytes', + formulaMaxLength: 'httpErrors.limit.formulaMaxLength', + tablesPerBaseMax: 'httpErrors.limit.tablesPerBaseMax', + fieldsPerTableMax: 'httpErrors.limit.fieldsPerTableMax', + rowsPerTableMax: 'httpErrors.limit.rowsPerTableMax', + viewsPerTableMax: 'httpErrors.limit.viewsPerTableMax', + createTableFieldsMax: 'httpErrors.limit.createTableFieldsMax', + createTableViewsMax: 'httpErrors.limit.createTableViewsMax', + createTableRecordsMax: 'httpErrors.limit.createTableRecordsMax', + viewFilterItemsMax: 'httpErrors.limit.viewFilterItemsMax', + viewFilterDepthMax: 'httpErrors.limit.viewFilterDepthMax', + viewSortItemsMax: 'httpErrors.limit.viewSortItemsMax', + viewGroupItemsMax: 'httpErrors.limit.viewGroupItemsMax', + viewOptionsMaxBytes: 'httpErrors.limit.viewOptionsMaxBytes', + nameMaxLength: 'httpErrors.limit.nameMaxLength', + descriptionMaxLength: 'httpErrors.limit.descriptionMaxLength', }, +} as const; + +export type SdkErrorI18nKey = + | 'httpErrors.custom.recordFieldValueNotNull' + | 'httpErrors.custom.recordFieldValueDuplicate' + | 'httpErrors.custom.linkBatchDuplicate' + | 'httpErrors.custom.linkOneManyDuplicate' + | 'httpErrors.custom.linkOneOneDuplicate' + | 'httpErrors.custom.fieldMaxColumnLimit' + | 'httpErrors.custom.fieldRequiredExistingValues' + | 'httpErrors.custom.fieldUniqueExistingValues' + | 'httpErrors.limit.fieldOptionsMaxBytes' + | 'httpErrors.limit.selectChoicesMax' + | 'httpErrors.limit.selectChoiceNameMaxLength' + | 'httpErrors.limit.selectDefaultValuesMax' + | 'httpErrors.limit.cellValueMaxBytes' + | 'httpErrors.limit.recordFieldsMaxBytes' + | 'httpErrors.limit.recordsPerMutationMax' + | 'httpErrors.limit.computedCellValueMaxBytes' + | 'httpErrors.limit.formulaMaxLength' + | 'httpErrors.limit.tablesPerBaseMax' + | 'httpErrors.limit.fieldsPerTableMax' + | 'httpErrors.limit.rowsPerTableMax' + | 'httpErrors.limit.viewsPerTableMax' + | 'httpErrors.limit.createTableFieldsMax' + | 'httpErrors.limit.createTableViewsMax' + | 'httpErrors.limit.createTableRecordsMax' + | 'httpErrors.limit.viewFilterItemsMax' + | 'httpErrors.limit.viewFilterDepthMax' + | 'httpErrors.limit.viewSortItemsMax' + | 'httpErrors.limit.viewGroupItemsMax' + | 'httpErrors.limit.viewOptionsMaxBytes' + | 'httpErrors.limit.nameMaxLength' + | 'httpErrors.limit.descriptionMaxLength'; + +export const tableI18nKeys = { field: { default: { singleLineText: { @@ -81,11 +147,6 @@ export const tableI18nKeys = { } as const; export type TableI18nKey = - | 'validation.link.batch_duplicate' - | 'validation.link.one_many_duplicate' - | 'validation.link.one_one_duplicate' - | 'validation.field.maxColumnLimit' - | 'validation.field.requiredExistingValues' | 'field.default.singleLineText.title' | 'field.default.longText.title' | 'field.default.number.title' diff --git a/packages/icons/src/components/SquareArrowUpRight.tsx b/packages/icons/src/components/SquareArrowUpRight.tsx new file mode 100644 index 0000000000..8ee59b1947 --- /dev/null +++ b/packages/icons/src/components/SquareArrowUpRight.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +const SquareArrowUpRight = (props: SVGProps) => ( + + + +); +export default SquareArrowUpRight; diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index cdb05f3912..a981efe633 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -20,6 +20,7 @@ export { default as ArrowRight } from './components/ArrowRight'; export { default as ArrowUp } from './components/ArrowUp'; export { default as ArrowUpDown } from './components/ArrowUpDown'; export { default as ArrowUpRight } from './components/ArrowUpRight'; +export { default as SquareArrowUpRight } from './components/SquareArrowUpRight'; export { default as Audio } from './components/Audio'; export { default as AppV0 } from './components/AppV0'; export { default as Azure } from './components/Azure'; diff --git a/packages/openapi/package.json b/packages/openapi/package.json index b46375a07b..4d48985efb 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -13,7 +13,10 @@ }, "main": "./dist/index.js", "types": "./dist/index.d.ts", - "sideEffects": false, + "sideEffects": [ + "./dist/zod.js", + "./src/zod.ts" + ], "exports": { ".": { "@teable/source": "./src/index.ts", diff --git a/packages/openapi/src/admin/setting/update.ts b/packages/openapi/src/admin/setting/update.ts index 180196629d..f172755518 100644 --- a/packages/openapi/src/admin/setting/update.ts +++ b/packages/openapi/src/admin/setting/update.ts @@ -317,6 +317,8 @@ export const v2FeatureSchema = z.enum([ 'schemaIntegrity', 'createRecord', 'formSubmit', + 'buttonClick', + 'buttonReset', 'updateRecord', 'updateRecords', 'deleteRecord', @@ -332,6 +334,44 @@ export const v2FeatureSchema = z.enum([ 'importRecords', 'importBase', 'createField', + 'createView', + 'deleteView', + 'updateViewName', + 'updateViewDescription', + 'updateViewLocked', + 'updateViewOrder', + 'updateViewColumnMeta', + 'updateViewFilter', + 'updateViewSort', + 'updateViewGroup', + 'updateViewOptions', + 'updateViewShareMeta', + 'refreshViewShareId', + 'enableViewShare', + 'disableViewShare', + 'installViewPlugin', + 'getViewPluginInstall', + 'updateViewPluginStorage', + 'getViewSocketSnapshotBulk', + 'getViewSocketDocIds', + 'getSharedViewSocketSnapshotBulk', + 'getSharedViewSocketDocIds', + 'getSharedView', + 'getSharedViewRecords', + 'getSharedViewRowCount', + 'getSharedViewSearchCount', + 'getSharedViewSearchIndex', + 'getSharedViewAggregations', + 'getSharedViewGroupPoints', + 'getSharedViewCalendarDailyCollection', + 'getSharedViewLinkRecords', + 'getSharedViewCollaborators', + 'getSharedViewCopy', + 'getDefaultViewId', + 'manualSortView', + 'getView', + 'getViews', + 'getViewFilterLinkRecords', 'deleteField', 'deleteTable', 'duplicateField', diff --git a/packages/openapi/src/archive/archive-stream.ts b/packages/openapi/src/archive/archive-stream.ts new file mode 100644 index 0000000000..49cb9cc3b5 --- /dev/null +++ b/packages/openapi/src/archive/archive-stream.ts @@ -0,0 +1,124 @@ +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { streamSSE } from '../utils/sse'; +import { z } from '../zod'; +import { archiveRecordIdSchema } from './archive'; + +export const ARCHIVE_RECORDS_STREAM = '/table/{tableId}/record/archive-stream'; + +// Same body as the plain endpoint but uncapped — the stream endpoint exists for the +// requests the plain endpoint's max would reject. +export const archiveStreamRoSchema = z.object({ + recordIds: z.array(archiveRecordIdSchema).min(1), +}); + +export type IArchiveStreamRo = z.infer; + +export const archiveStreamProgressEventSchema = z.object({ + id: z.literal('progress'), + phase: z.enum(['preparing', 'archiving']), + batchIndex: z.number(), + totalCount: z.number(), + archivedCount: z.number(), + batchArchivedCount: z.number(), +}); + +export const archiveStreamDoneEventSchema = z.object({ + id: z.literal('done'), + totalCount: z.number(), + archivedCount: z.number(), + archivedRecordIds: z.array(z.string()), +}); + +export const archiveStreamErrorEventSchema = z.object({ + id: z.literal('error'), + message: z.string(), + code: z.string().optional(), +}); + +export const archiveStreamEventSchema = z.union([ + archiveStreamProgressEventSchema, + archiveStreamDoneEventSchema, + archiveStreamErrorEventSchema, +]); + +export type IArchiveStreamProgressEvent = z.infer; +export type IArchiveStreamDoneEvent = z.infer; +export type IArchiveStreamErrorEvent = z.infer; +export type IArchiveStreamEvent = z.infer; + +export const ArchiveRecordsStreamRoute = registerRoute({ + method: 'post', + path: ARCHIVE_RECORDS_STREAM, + summary: 'Archive records with SSE progress', + request: { + params: z.object({ tableId: z.string() }), + body: { + content: { + 'application/json': { + schema: archiveStreamRoSchema, + }, + }, + }, + }, + responses: { + 200: { description: 'SSE stream with archive progress events and final result' }, + }, + tags: ['archive'], +}); + +export const archiveRecordsStream = async ( + tableId: string, + archiveRo: IArchiveStreamRo, + options?: { + onProgress?: (event: IArchiveStreamProgressEvent) => void; + onError?: (event: IArchiveStreamErrorEvent) => void; + signal?: AbortSignal; + headers?: RequestInit['headers']; + } +): Promise<{ + done: IArchiveStreamDoneEvent; + errors: IArchiveStreamErrorEvent[]; +}> => { + const url = axios.getUri({ + baseURL: axios.defaults.baseURL || '/api', + url: urlBuilder(ARCHIVE_RECORDS_STREAM, { tableId }), + }); + + let doneEvent: IArchiveStreamDoneEvent | null = null; + const errors: IArchiveStreamErrorEvent[] = []; + + await streamSSE( + url, + { + method: 'POST', + signal: options?.signal, + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(archiveRo), + }, + { + errorPrefix: 'Archive records stream failed', + onResult: (result) => { + switch (result.id) { + case 'progress': + options?.onProgress?.(result); + return; + case 'done': + doneEvent = result; + return; + case 'error': + errors.push(result); + options?.onError?.(result); + } + }, + } + ); + + if (!doneEvent) { + const lastError = errors.at(-1); + if (lastError) throw new Error(lastError.message); + throw new Error('Archive records stream ended without result'); + } + + return { done: doneEvent, errors }; +}; diff --git a/packages/openapi/src/archive/archive.ts b/packages/openapi/src/archive/archive.ts new file mode 100644 index 0000000000..a99488d678 --- /dev/null +++ b/packages/openapi/src/archive/archive.ts @@ -0,0 +1,63 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { IdPrefix } from '@teable/core'; +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; + +export const ARCHIVE_RECORDS = '/table/{tableId}/record/archive'; + +// Cap for the plain (non-stream) archive endpoints; clients switch to the SSE stream +// endpoint above it. +export const MAX_ARCHIVE_RECORDS_PER_REQUEST = 1000; + +export const archiveRecordIdSchema = z.string().startsWith(IdPrefix.Record); + +// Shared body of the plain archive / restore / delete endpoints. +export const archiveRecordIdsRoSchema = z.object({ + recordIds: z.array(archiveRecordIdSchema).min(1).max(MAX_ARCHIVE_RECORDS_PER_REQUEST), +}); + +export const archiveRecordsRoSchema = archiveRecordIdsRoSchema; + +export type IArchiveRecordsRo = z.infer; + +export const archiveRecordsVoSchema = z.object({ + archivedRecordIds: z.array(z.string()), +}); + +export type IArchiveRecordsVo = z.infer; + +export const ArchiveRecordsRoute: RouteConfig = registerRoute({ + method: 'post', + path: ARCHIVE_RECORDS, + summary: 'Archive records', + description: + 'Move records out of the table into the archive. Archived records are read-only and can be restored from the archive.', + request: { + params: z.object({ + tableId: z.string(), + }), + body: { + content: { + 'application/json': { + schema: archiveRecordsRoSchema, + }, + }, + }, + }, + responses: { + 201: { + description: 'Archived successfully', + content: { + 'application/json': { + schema: archiveRecordsVoSchema, + }, + }, + }, + }, + tags: ['archive'], +}); + +export const archiveRecords = async (tableId: string, archiveRo: IArchiveRecordsRo) => { + return axios.post(urlBuilder(ARCHIVE_RECORDS, { tableId }), archiveRo); +}; diff --git a/packages/openapi/src/archive/delete.ts b/packages/openapi/src/archive/delete.ts new file mode 100644 index 0000000000..a0fc2a00ff --- /dev/null +++ b/packages/openapi/src/archive/delete.ts @@ -0,0 +1,40 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; +import { archiveRecordIdsRoSchema } from './archive'; + +export const DELETE_ARCHIVE_ITEMS = '/table/{tableId}/archive/items'; + +export const deleteArchiveItemsRoSchema = archiveRecordIdsRoSchema; + +export type IDeleteArchiveItemsRo = z.infer; + +export const DeleteArchiveItemsRoute: RouteConfig = registerRoute({ + method: 'delete', + path: DELETE_ARCHIVE_ITEMS, + summary: 'Permanently delete archived records', + description: 'Permanently delete archive snapshots. This cannot be undone.', + request: { + params: z.object({ + tableId: z.string(), + }), + body: { + content: { + 'application/json': { + schema: deleteArchiveItemsRoSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Permanently deleted successfully', + }, + }, + tags: ['archive'], +}); + +export const deleteArchiveItems = async (tableId: string, deleteRo: IDeleteArchiveItemsRo) => { + return axios.delete(urlBuilder(DELETE_ARCHIVE_ITEMS, { tableId }), { data: deleteRo }); +}; diff --git a/packages/openapi/src/archive/export-stream.ts b/packages/openapi/src/archive/export-stream.ts new file mode 100644 index 0000000000..74bac7e08a --- /dev/null +++ b/packages/openapi/src/archive/export-stream.ts @@ -0,0 +1,119 @@ +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { streamSSE } from '../utils/sse'; +import { z } from '../zod'; + +export const ARCHIVE_EXPORT_STREAM = '/table/{tableId}/archive/export-stream'; + +// Same filter dimensions as the archive list (order is fixed archivedTime desc); +// POST body, so arrays are plain — no query-string coercion needed. +export const archiveExportStreamRoSchema = z.object({ + recordCreatedBy: z.array(z.string()).optional(), + recordLastModifiedBy: z.array(z.string()).optional(), + archivedTimeStart: z.string().optional(), + archivedTimeEnd: z.string().optional(), + recordCreatedTimeStart: z.string().optional(), + recordCreatedTimeEnd: z.string().optional(), +}); + +export type IArchiveExportStreamRo = z.infer; + +export const archiveExportProgressEventSchema = z.object({ + id: z.literal('progress'), + processedCount: z.number(), +}); + +export const archiveExportDoneEventSchema = z.object({ + id: z.literal('done'), + rowCount: z.number(), + fileName: z.string(), + downloadUrl: z.string(), +}); + +export const archiveExportErrorEventSchema = z.object({ + id: z.literal('error'), + message: z.string(), + code: z.string().optional(), +}); + +export const archiveExportStreamEventSchema = z.union([ + archiveExportProgressEventSchema, + archiveExportDoneEventSchema, + archiveExportErrorEventSchema, +]); + +export type IArchiveExportProgressEvent = z.infer; +export type IArchiveExportDoneEvent = z.infer; +export type IArchiveExportErrorEvent = z.infer; +export type IArchiveExportStreamEvent = z.infer; + +export const ArchiveExportStreamRoute = registerRoute({ + method: 'post', + path: ARCHIVE_EXPORT_STREAM, + summary: 'Export archived records as CSV with SSE progress', + request: { + params: z.object({ tableId: z.string() }), + body: { + content: { + 'application/json': { + schema: archiveExportStreamRoSchema, + }, + }, + }, + }, + responses: { + 200: { description: 'SSE stream with export progress events and a final download url' }, + }, + tags: ['archive'], +}); + +export const exportArchiveRecordsStream = async ( + tableId: string, + exportRo: IArchiveExportStreamRo, + options?: { + onProgress?: (event: IArchiveExportProgressEvent) => void; + signal?: AbortSignal; + headers?: RequestInit['headers']; + } +): Promise => { + const url = axios.getUri({ + baseURL: axios.defaults.baseURL || '/api', + url: urlBuilder(ARCHIVE_EXPORT_STREAM, { tableId }), + }); + + let doneEvent: IArchiveExportDoneEvent | null = null; + const errors: IArchiveExportErrorEvent[] = []; + + await streamSSE( + url, + { + method: 'POST', + signal: options?.signal, + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(exportRo), + }, + { + errorPrefix: 'Archive export stream failed', + onResult: (result) => { + switch (result.id) { + case 'progress': + options?.onProgress?.(result); + return; + case 'done': + doneEvent = result; + return; + case 'error': + errors.push(result); + } + }, + } + ); + + if (!doneEvent) { + const lastError = errors.at(-1); + if (lastError) throw new Error(lastError.message); + throw new Error('Archive export stream ended without result'); + } + + return doneEvent; +}; diff --git a/packages/openapi/src/archive/get-items.ts b/packages/openapi/src/archive/get-items.ts new file mode 100644 index 0000000000..b6bb4aad5c --- /dev/null +++ b/packages/openapi/src/archive/get-items.ts @@ -0,0 +1,94 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { recordSchema } from '@teable/core'; +import { axios } from '../axios'; +import { userMapVoSchema } from '../trash'; +import { + registerRoute, + serializeArrayAwareQuery, + stringOrArrayQuerySchema, + urlBuilder, +} from '../utils'; +import { z } from '../zod'; + +export const GET_ARCHIVE_ITEMS = '/table/{tableId}/archive/items'; + +export const archiveOrderBySchema = z.enum([ + 'archivedTime', + 'recordCreatedTime', + 'recordLastModifiedTime', +]); + +export type IArchiveOrderBy = z.infer; + +export const getArchiveItemsQuerySchema = z.object({ + cursor: z.string().nullish(), + pageSize: z.coerce.number().int().min(1).max(50).optional(), + orderBy: archiveOrderBySchema.optional(), + // desc only: the dual-zone list serves PG first, and under asc every + // over-horizon cold row would belong before it + direction: z.literal('desc').optional(), + // Keyword search is deliberately absent: archived snapshots live mostly in cold + // storage, which can only be scanned, not queried — same rule as record history. + recordCreatedBy: stringOrArrayQuerySchema, + recordLastModifiedBy: stringOrArrayQuerySchema, + archivedTimeStart: z.string().optional(), + archivedTimeEnd: z.string().optional(), + recordCreatedTimeStart: z.string().optional(), + recordCreatedTimeEnd: z.string().optional(), +}); + +export type IGetArchiveItemsQuery = z.infer; + +export const archiveItemVoSchema = z.object({ + id: z.string(), + recordId: z.string(), + record: recordSchema, + archivedTime: z.string(), + archivedBy: z.string(), + recordCreatedTime: z.string().nullish(), + recordCreatedBy: z.string().nullish(), + recordLastModifiedTime: z.string().nullish(), + recordLastModifiedBy: z.string().nullish(), +}); + +export type IArchiveItemVo = z.infer; + +export const getArchiveItemsVoSchema = z.object({ + items: z.array(archiveItemVoSchema), + userMap: userMapVoSchema, + nextCursor: z.string().nullish(), +}); + +export type IGetArchiveItemsVo = z.infer; + +export const GetArchiveItemsRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_ARCHIVE_ITEMS, + summary: 'Get archived records', + description: + 'List archived records of a table with fixed-dimension filters (archived time, record created time/by, record last modified by) and cursor pagination.', + request: { + params: z.object({ + tableId: z.string(), + }), + query: getArchiveItemsQuerySchema, + }, + responses: { + 200: { + description: 'Get archived records successfully', + content: { + 'application/json': { + schema: getArchiveItemsVoSchema, + }, + }, + }, + }, + tags: ['archive'], +}); + +export const getArchiveItems = async (tableId: string, query: IGetArchiveItemsQuery) => { + return axios.get(urlBuilder(GET_ARCHIVE_ITEMS, { tableId }), { + params: query, + paramsSerializer: serializeArrayAwareQuery, + }); +}; diff --git a/packages/openapi/src/archive/index.ts b/packages/openapi/src/archive/index.ts new file mode 100644 index 0000000000..7ce33722bf --- /dev/null +++ b/packages/openapi/src/archive/index.ts @@ -0,0 +1,7 @@ +export * from './archive'; +export * from './archive-stream'; +export * from './export-stream'; +export * from './get-items'; +export * from './restore'; +export * from './delete'; +export * from './reset'; diff --git a/packages/openapi/src/archive/reset.ts b/packages/openapi/src/archive/reset.ts new file mode 100644 index 0000000000..fa6a0ef19c --- /dev/null +++ b/packages/openapi/src/archive/reset.ts @@ -0,0 +1,28 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; + +export const RESET_ARCHIVE = '/table/{tableId}/archive/reset'; + +export const ResetArchiveRoute: RouteConfig = registerRoute({ + method: 'delete', + path: RESET_ARCHIVE, + summary: 'Clear table archive', + description: 'Permanently delete all archive snapshots of the table. This cannot be undone.', + request: { + params: z.object({ + tableId: z.string(), + }), + }, + responses: { + 200: { + description: 'Archive cleared successfully', + }, + }, + tags: ['archive'], +}); + +export const resetArchive = async (tableId: string) => { + return axios.delete(urlBuilder(RESET_ARCHIVE, { tableId })); +}; diff --git a/packages/openapi/src/archive/restore.ts b/packages/openapi/src/archive/restore.ts new file mode 100644 index 0000000000..ad67e776f5 --- /dev/null +++ b/packages/openapi/src/archive/restore.ts @@ -0,0 +1,57 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; +import { archiveRecordIdsRoSchema } from './archive'; + +export const RESTORE_ARCHIVE_RECORDS = '/table/{tableId}/archive/restore'; + +export const restoreArchiveRecordsRoSchema = archiveRecordIdsRoSchema; + +export type IRestoreArchiveRecordsRo = z.infer; + +export const restoreArchiveRecordsVoSchema = z.object({ + restoredRecordIds: z.array(z.string()), +}); + +export type IRestoreArchiveRecordsVo = z.infer; + +export const RestoreArchiveRecordsRoute: RouteConfig = registerRoute({ + method: 'post', + path: RESTORE_ARCHIVE_RECORDS, + summary: 'Restore archived records', + description: 'Rebuild archived records back into the table from their archive snapshots.', + request: { + params: z.object({ + tableId: z.string(), + }), + body: { + content: { + 'application/json': { + schema: restoreArchiveRecordsRoSchema, + }, + }, + }, + }, + responses: { + 201: { + description: 'Restored successfully', + content: { + 'application/json': { + schema: restoreArchiveRecordsVoSchema, + }, + }, + }, + }, + tags: ['archive'], +}); + +export const restoreArchiveRecords = async ( + tableId: string, + restoreRo: IRestoreArchiveRecordsRo +) => { + return axios.post( + urlBuilder(RESTORE_ARCHIVE_RECORDS, { tableId }), + restoreRo + ); +}; diff --git a/packages/openapi/src/attachment/signature.ts b/packages/openapi/src/attachment/signature.ts index 77d8dba3a1..2286e1276b 100644 --- a/packages/openapi/src/attachment/signature.ts +++ b/packages/openapi/src/attachment/signature.ts @@ -20,6 +20,7 @@ export enum UploadType { Automation = 14, RecordHistory = 15, SpaceAvatar = 16, + RecordRemoval = 17, } export const signatureRoSchema = z.object({ diff --git a/packages/openapi/src/base-node/update.ts b/packages/openapi/src/base-node/update.ts index b0160a7068..38a7442799 100644 --- a/packages/openapi/src/base-node/update.ts +++ b/packages/openapi/src/base-node/update.ts @@ -9,7 +9,7 @@ export const UPDATE_BASE_NODE = '/base/{baseId}/node/{nodeId}'; export const updateBaseNodeRoSchema = z.object({ name: z.string().trim().min(1).optional(), - icon: z.string().trim().optional(), + icon: z.string().trim().optional().nullable(), }); export type IUpdateBaseNodeRo = z.infer; diff --git a/packages/openapi/src/base/update.ts b/packages/openapi/src/base/update.ts index 186f781c45..6c8bc3f9a7 100644 --- a/packages/openapi/src/base/update.ts +++ b/packages/openapi/src/base/update.ts @@ -6,7 +6,9 @@ import { createBaseRoSchema } from './create'; export const UPDATE_BASE = '/base/{baseId}'; -export const updateBaseRoSchema = createBaseRoSchema.omit({ spaceId: true }); +export const updateBaseRoSchema = createBaseRoSchema.omit({ spaceId: true }).extend({ + icon: createBaseRoSchema.shape.icon.nullable(), +}); export type IUpdateBaseRo = z.infer; diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index f012cf9d13..e933692a63 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -1,3 +1,6 @@ +// Route modules register schemas during import, so install the Zod OpenAPI extension first. +import './zod'; + export * from './zod'; export * from './axios'; export * from './generate.schema'; @@ -28,6 +31,7 @@ export * from './admin'; export * from './usage'; export * from './oauth'; export * from './trash'; +export * from './archive'; export * from './undo-redo'; export * from './plugin'; export * from './dashboard'; diff --git a/packages/openapi/src/pin/entry-map.ts b/packages/openapi/src/pin/entry-map.ts new file mode 100644 index 0000000000..f82b7b0889 --- /dev/null +++ b/packages/openapi/src/pin/entry-map.ts @@ -0,0 +1,39 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../axios'; +import { registerRoute } from '../utils'; +import { z } from '../zod'; + +export const GET_PIN_ENTRY_MAP = '/pin/entry-map'; + +/** + * Entry pathname per pinned resource, keyed by baseId for base pins and by + * tableId for table pins: /base/{baseId}/table/{tableId}/{viewId} when the + * user's last visited view is known, otherwise viewless (the table route + * resolves the view with permission filtering, one redirect). Unresolvable + * pins are omitted — clicking falls back to the redirect chain. + */ +export const pinEntryMapVoSchema = z.record(z.string(), z.string()); + +export type IPinEntryMapVo = z.infer; + +export const GetPinEntryMapRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_PIN_ENTRY_MAP, + description: + "Resolve the entry URL of the current user's pinned bases and tables, so pin clicks can navigate straight to the final URL", + responses: { + 200: { + description: 'Returns a map of pinned resource id to entry URL pathname.', + content: { + 'application/json': { + schema: pinEntryMapVoSchema, + }, + }, + }, + }, + tags: ['pin'], +}); + +export const getPinEntryMap = async () => { + return axios.get(GET_PIN_ENTRY_MAP); +}; diff --git a/packages/openapi/src/pin/index.ts b/packages/openapi/src/pin/index.ts index eef4af4cfa..f5542b04d3 100644 --- a/packages/openapi/src/pin/index.ts +++ b/packages/openapi/src/pin/index.ts @@ -1,3 +1,4 @@ +export * from './entry-map'; export * from './delete'; export * from './add'; export * from './get-list'; diff --git a/packages/openapi/src/record/get-list.ts b/packages/openapi/src/record/get-list.ts index 9784d1a1ad..2631599f75 100644 --- a/packages/openapi/src/record/get-list.ts +++ b/packages/openapi/src/record/get-list.ts @@ -192,7 +192,7 @@ export const contentQueryBaseSchema = queryBaseSchema.extend({ .optional() .meta({ description: - 'Whether to include grouped query extra metadata such as group points. Projected record reads may skip this metadata unless explicitly enabled.', + 'Whether to include query extra metadata such as group points and search hit indexes. Group metadata defaults to enabled; optimized generated-index searches require explicit opt-in for search hit indexes.', }), }); diff --git a/packages/openapi/src/selection/clear-by-id-stream.ts b/packages/openapi/src/selection/clear-by-id-stream.ts index bbe2e9381f..cd68a8c6ef 100644 --- a/packages/openapi/src/selection/clear-by-id-stream.ts +++ b/packages/openapi/src/selection/clear-by-id-stream.ts @@ -9,6 +9,7 @@ import { type IClearSelectionStreamProgressEvent, } from './clear-stream'; import { selectionIdsRoSchema, type ISelectionIdsRo } from './id'; +import { createSelectionStreamError } from './stream-error'; export const CLEAR_BY_ID_STREAM_URL = '/table/{tableId}/selection/clear-by-id-stream'; @@ -86,7 +87,7 @@ export const clearSelectionByIdStream = async ( if (!doneEvent) { const lastError = errors.at(-1); - if (lastError) throw new Error(lastError.message); + if (lastError) throw createSelectionStreamError(lastError); throw new Error('Clear selection by id stream ended without result'); } diff --git a/packages/openapi/src/selection/clear-stream.spec.ts b/packages/openapi/src/selection/clear-stream.spec.ts index f8e13238e6..181431c2bf 100644 --- a/packages/openapi/src/selection/clear-stream.spec.ts +++ b/packages/openapi/src/selection/clear-stream.spec.ts @@ -106,6 +106,39 @@ describe('clearSelectionStream', () => { expect(result.errors).toHaveLength(1); }); + it('throws an HttpError carrying localization when the stream ends without done', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + createSSEStreamResponse([ + 'data: {"id":"error","phase":"clearing","batchIndex":0,"totalCount":1,"processedCount":0,"clearedCount":0,"recordIds":[],"message":"Cannot complete update: field fldabc cannot be empty","code":"validation.field.not_null","localization":{"i18nKey":"httpErrors.custom.recordFieldValueNotNull","context":{"fieldName":"Required"}}}', + ]) + ) + ); + + const promise = clearSelectionStream('tbl0000000000000000', { + ranges: [ + [0, 0], + [0, 0], + ], + }); + + await expect(promise).rejects.toMatchObject({ + message: 'Cannot complete update: field fldabc cannot be empty', + status: 400, + code: 'validation_error', + data: { + domainCode: 'validation.field.not_null', + localization: { + i18nKey: 'httpErrors.custom.recordFieldValueNotNull', + context: { fieldName: 'Required' }, + }, + }, + }); + }); + it('uses patch defaults and keeps the current undo/redo window id header', async () => { const common = new AxiosHeaders(); common.set('X-Window-Id', 'win_stream_clear'); diff --git a/packages/openapi/src/selection/clear-stream.ts b/packages/openapi/src/selection/clear-stream.ts index 1a896689a4..7ce0c2fdfc 100644 --- a/packages/openapi/src/selection/clear-stream.ts +++ b/packages/openapi/src/selection/clear-stream.ts @@ -1,8 +1,10 @@ +import { localizationSchema } from '@teable/core'; import { axios, ensureUndoRedoWindowIdHeader } from '../axios'; import { registerRoute, urlBuilder } from '../utils'; import { streamSSE } from '../utils/sse'; import { z } from '../zod'; import { clearRoSchema, CLEAR_URL } from './clear'; +import { createSelectionStreamError } from './stream-error'; export const CLEAR_STREAM_URL = `${CLEAR_URL}-stream`; @@ -38,6 +40,7 @@ export const clearSelectionStreamErrorEventSchema = z.object({ recordIds: z.array(z.string()), message: z.string(), code: z.string().optional(), + localization: localizationSchema.optional(), }); export const clearSelectionStreamEventSchema = z.union([ @@ -135,7 +138,7 @@ export const clearSelectionStream = async ( if (!doneEvent) { const lastError = errors.at(-1); if (lastError) { - throw new Error(lastError.message); + throw createSelectionStreamError(lastError); } throw new Error('Clear selection stream ended without result'); } diff --git a/packages/openapi/src/selection/delete-by-id-stream.ts b/packages/openapi/src/selection/delete-by-id-stream.ts index 8b62fe4962..a3490b3309 100644 --- a/packages/openapi/src/selection/delete-by-id-stream.ts +++ b/packages/openapi/src/selection/delete-by-id-stream.ts @@ -10,6 +10,7 @@ import type { IDeleteSelectionStreamProgressEvent, } from './delete-stream'; import { selectionIdsRoSchema, type ISelectionIdsRo } from './id'; +import { createSelectionStreamError } from './stream-error'; export const DELETE_BY_ID_STREAM_URL = '/table/{tableId}/selection/delete-by-id-stream'; @@ -87,7 +88,7 @@ export const deleteSelectionByIdStream = async ( if (!finalResult || !doneEvent) { const lastError = errors.at(-1); - if (lastError) throw new Error(lastError.message); + if (lastError) throw createSelectionStreamError(lastError); throw new Error('Delete selection by id stream ended without result'); } diff --git a/packages/openapi/src/selection/delete-stream.ts b/packages/openapi/src/selection/delete-stream.ts index 432b340c41..71faaa94c8 100644 --- a/packages/openapi/src/selection/delete-stream.ts +++ b/packages/openapi/src/selection/delete-stream.ts @@ -1,3 +1,4 @@ +import { localizationSchema } from '@teable/core'; import { axios, ensureUndoRedoWindowIdHeader } from '../axios'; import { registerRoute, urlBuilder } from '../utils'; import { streamSSE } from '../utils/sse'; @@ -5,6 +6,7 @@ import { z } from '../zod'; import { deleteVoSchema, type IDeleteVo } from './delete'; import type { IRangesRo } from './range'; import { rangesQuerySchema } from './range'; +import { createSelectionStreamError } from './stream-error'; export const DELETE_STREAM_URL = '/table/{tableId}/selection/delete-stream'; @@ -36,6 +38,7 @@ export const deleteSelectionStreamErrorEventSchema = z.object({ recordIds: z.array(z.string()), message: z.string(), code: z.string().optional(), + localization: localizationSchema.optional(), }); export const deleteSelectionStreamEventSchema = z.union([ @@ -140,7 +143,7 @@ export const deleteSelectionStream = async ( if (!finalResult || !doneEvent) { const lastError = errors.at(-1); if (lastError) { - throw new Error(lastError.message); + throw createSelectionStreamError(lastError); } throw new Error('Delete selection stream ended without result'); } diff --git a/packages/openapi/src/selection/duplicate-stream.ts b/packages/openapi/src/selection/duplicate-stream.ts index 02657f45a2..7f50d5b0eb 100644 --- a/packages/openapi/src/selection/duplicate-stream.ts +++ b/packages/openapi/src/selection/duplicate-stream.ts @@ -1,9 +1,11 @@ +import { localizationSchema } from '@teable/core'; import { axios, ensureUndoRedoWindowIdHeader } from '../axios'; import { registerRoute, urlBuilder } from '../utils'; import { streamSSE } from '../utils/sse'; import { z } from '../zod'; import type { IRangesRo } from './range'; import { rangesQuerySchema } from './range'; +import { createSelectionStreamError } from './stream-error'; export const DUPLICATE_STREAM_URL = '/table/{tableId}/selection/duplicate-stream'; @@ -35,6 +37,7 @@ export const duplicateSelectionStreamErrorEventSchema = z.object({ recordIds: z.array(z.string()), message: z.string(), code: z.string().optional(), + localization: localizationSchema.optional(), }); export const duplicateSelectionStreamEventSchema = z.union([ @@ -136,7 +139,7 @@ export const duplicateSelectionStream = async ( if (!doneEvent) { const lastError = errors.at(-1); if (lastError) { - throw new Error(lastError.message); + throw createSelectionStreamError(lastError); } throw new Error('Duplicate selection stream ended without result'); } diff --git a/packages/openapi/src/selection/id-mutation.ts b/packages/openapi/src/selection/id-mutation.ts index f8fd18172c..b82a9673e2 100644 --- a/packages/openapi/src/selection/id-mutation.ts +++ b/packages/openapi/src/selection/id-mutation.ts @@ -24,6 +24,7 @@ import { type IPasteSelectionStreamEvent, type IPasteSelectionStreamProgressEvent, } from './paste-stream'; +import { createSelectionStreamError } from './stream-error'; export const CLEAR_BY_ID_URL = '/table/{tableId}/selection/clear-by-id'; export const CLEAR_BY_ID_STREAM_URL = `${CLEAR_BY_ID_URL}-stream`; @@ -335,7 +336,10 @@ export const clearByIdSelectionStream = async ( if (!doneEvent) { const lastError = errors.at(-1); - throw new Error(lastError?.message ?? 'Clear selection by id stream ended without result'); + if (lastError) { + throw createSelectionStreamError(lastError); + } + throw new Error('Clear selection by id stream ended without result'); } return { data: null, done: doneEvent, errors }; @@ -396,7 +400,10 @@ export const pasteByIdSelectionStream = async ( if (!doneEvent) { const lastError = errors.at(-1); - throw new Error(lastError?.message ?? 'Paste selection by id stream ended without result'); + if (lastError) { + throw createSelectionStreamError(lastError); + } + throw new Error('Paste selection by id stream ended without result'); } const finalDoneEvent = doneEvent as IPasteSelectionStreamDoneEvent; @@ -482,7 +489,10 @@ export const deleteByIdSelectionStream = async ( if (!finalResult || !doneEvent) { const lastError = errors.at(-1); - throw new Error(lastError?.message ?? 'Delete selection by id stream ended without result'); + if (lastError) { + throw createSelectionStreamError(lastError); + } + throw new Error('Delete selection by id stream ended without result'); } return { data: finalResult, done: doneEvent, errors }; diff --git a/packages/openapi/src/selection/paste-by-id-stream.ts b/packages/openapi/src/selection/paste-by-id-stream.ts index 1762dd9168..725e54b55f 100644 --- a/packages/openapi/src/selection/paste-by-id-stream.ts +++ b/packages/openapi/src/selection/paste-by-id-stream.ts @@ -11,6 +11,7 @@ import type { IPasteSelectionStreamEvent, IPasteSelectionStreamProgressEvent, } from './paste-stream'; +import { createSelectionStreamError } from './stream-error'; export const PASTE_BY_ID_STREAM_URL = '/table/{tableId}/selection/paste-by-id-stream'; @@ -93,7 +94,7 @@ export const pasteSelectionByIdStream = async ( if (!doneEvent) { const lastError = errors.at(-1); - if (lastError) throw new Error(lastError.message); + if (lastError) throw createSelectionStreamError(lastError); throw new Error('Paste selection by id stream ended without result'); } diff --git a/packages/openapi/src/selection/paste-stream.ts b/packages/openapi/src/selection/paste-stream.ts index 13a27a207f..eb747f0d63 100644 --- a/packages/openapi/src/selection/paste-stream.ts +++ b/packages/openapi/src/selection/paste-stream.ts @@ -1,9 +1,11 @@ +import { localizationSchema } from '@teable/core'; import { axios, ensureUndoRedoWindowIdHeader } from '../axios'; import { registerRoute, urlBuilder } from '../utils'; import { streamSSE } from '../utils/sse'; import { z } from '../zod'; import type { IPasteRo, IPasteVo } from './paste'; import { pasteRoSchema, pasteVoSchema, PASTE_URL } from './paste'; +import { createSelectionStreamError } from './stream-error'; export const PASTE_STREAM_URL = `${PASTE_URL}-stream`; @@ -57,6 +59,7 @@ export const pasteSelectionStreamErrorEventSchema = z.object({ recordIds: z.array(z.string()), message: z.string(), code: z.string().optional(), + localization: localizationSchema.optional(), }); export const pasteSelectionStreamEventSchema = z.union([ @@ -155,7 +158,7 @@ export const pasteSelectionStream = async ( if (!doneEvent) { const lastError = errors.at(-1); if (lastError) { - throw new Error(lastError.message); + throw createSelectionStreamError(lastError); } throw new Error('Paste selection stream ended without result'); } diff --git a/packages/openapi/src/selection/stream-error.ts b/packages/openapi/src/selection/stream-error.ts new file mode 100644 index 0000000000..aac0bd96d2 --- /dev/null +++ b/packages/openapi/src/selection/stream-error.ts @@ -0,0 +1,32 @@ +import type { ILocalization } from '@teable/core'; +import { HttpError, HttpErrorCode } from '@teable/core'; + +/** + * Convert the last SSE error event of a selection stream into the error the + * client function throws. An `HttpError` carries the event's `localization` + * (and domain code) in `data`, so callers translating with + * `getHttpErrorMessage` show the localized text instead of the English + * fallback message. + * + * The status is synthetic — the SSE response itself was 200 — so it is + * classified from the domain code: validation errors are the user's to fix + * (400), anything else (e.g. infrastructure failures) is a server error (500). + */ +export const createSelectionStreamError = (event: { + message: string; + code?: string; + localization?: ILocalization; +}): HttpError => { + const isValidation = event.code?.startsWith('validation.') ?? false; + return new HttpError( + { + message: event.message, + code: isValidation ? HttpErrorCode.VALIDATION_ERROR : HttpErrorCode.INTERNAL_SERVER_ERROR, + data: { + domainCode: event.code, + ...(event.localization && { localization: event.localization }), + }, + }, + isValidation ? 400 : 500 + ); +}; diff --git a/packages/openapi/src/share/view-aggregations.ts b/packages/openapi/src/share/view-aggregations.ts index 82277914b7..e1d9fda7de 100644 --- a/packages/openapi/src/share/view-aggregations.ts +++ b/packages/openapi/src/share/view-aggregations.ts @@ -1,7 +1,6 @@ import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; -import { viewVoSchema } from '@teable/core'; import type { IAggregationVo } from '../aggregation'; -import { aggregationRoSchema } from '../aggregation'; +import { aggregationRoSchema, aggregationVoSchema } from '../aggregation'; import { axios } from '../axios'; import { registerRoute, urlBuilder } from '../utils'; import { z } from '../zod'; @@ -32,7 +31,7 @@ export const ShareViewAggregationsRoute: RouteConfig = registerRoute({ description: 'Returns aggregations list of share view.', content: { 'application/json': { - schema: z.array(viewVoSchema), + schema: aggregationVoSchema, }, }, }, diff --git a/packages/openapi/src/share/view-copy.ts b/packages/openapi/src/share/view-copy.ts index 3f1229e4b8..adb2433a3f 100644 --- a/packages/openapi/src/share/view-copy.ts +++ b/packages/openapi/src/share/view-copy.ts @@ -7,6 +7,83 @@ import { z } from '../zod'; export const SHARE_VIEW_COPY = '/share/{shareId}/view/copy'; +const shareCopyRangesSchema = z + .string() + .transform((value, ctx) => { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + ctx.addIssue({ code: 'custom', message: 'ranges must be valid JSON' }); + return z.NEVER; + } + const result = z + .array(z.tuple([z.number().int().nonnegative(), z.number().int().nonnegative()])) + .min(1) + .safeParse(parsed); + if (!result.success) { + for (const issue of result.error.issues) { + ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path }); + } + return z.NEVER; + } + return result.data; + }) + .meta({ + type: 'string', + description: 'Selection coordinates encoded as JSON', + example: '[[0, 0], [1, 1]]', + }); + +export const shareViewCopyQuerySchema = rangesQuerySchema + .pick({ + filterByTql: true, + filter: true, + search: true, + orderBy: true, + groupBy: true, + collapsedGroupIds: true, + queryId: true, + projection: true, + ranges: true, + type: true, + }) + .extend({ ranges: shareCopyRangesSchema }) + .superRefine((value, ctx) => { + if (value.type == null && value.ranges.length !== 2) { + ctx.addIssue({ + code: 'custom', + path: ['ranges'], + message: 'Cell selections require exactly two coordinates', + }); + return; + } + if ( + value.type == null && + (value.ranges[0]![0] > value.ranges[1]![0] || value.ranges[0]![1] > value.ranges[1]![1]) + ) { + ctx.addIssue({ + code: 'custom', + path: ['ranges'], + message: 'Cell selection coordinates must be ordered from top-left to bottom-right', + }); + return; + } + if (value.type != null) { + value.ranges.forEach(([start, end], index) => { + if (start > end) { + ctx.addIssue({ + code: 'custom', + path: ['ranges', index], + message: 'Selection ranges must be ascending', + }); + } + }); + } + }); + +export type IShareViewCopyQuery = z.infer; + export const ShareViewCopyRoute: RouteConfig = registerRoute({ method: 'get', path: SHARE_VIEW_COPY, @@ -15,7 +92,7 @@ export const ShareViewCopyRoute: RouteConfig = registerRoute({ params: z.object({ shareId: z.string(), }), - query: rangesQuerySchema, + query: shareViewCopyQuerySchema, }, responses: { 200: { diff --git a/packages/openapi/src/space/base-entry-map.ts b/packages/openapi/src/space/base-entry-map.ts new file mode 100644 index 0000000000..839e16c105 --- /dev/null +++ b/packages/openapi/src/space/base-entry-map.ts @@ -0,0 +1,65 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; + +export const GET_SPACE_BASE_ENTRY_MAP = '/space/{spaceId}/base-entry-map'; + +/** What the client passes today; the endpoint itself has no implicit limit */ +export const baseEntryMapDefaultTake = 100; + +export const getBaseEntryMapRoSchema = z.object({ + spaceId: z.string(), + take: z + .union([z.string(), z.number()]) + .transform(Number) + .pipe(z.number().int().min(1)) + .optional() + .meta({ + description: + 'Resolve at most this many bases, in base-list order; omitted means the whole list', + }), +}); + +export type IGetBaseEntryMapRo = z.infer; + +/** + * baseId → entry pathname for the accessible bases of the space: + * /base/{baseId}/table/{tableId}/{viewId} when the user's last visited view + * is known, otherwise viewless (the table route resolves the view with + * permission filtering, one redirect). Resolved from the user's own visit + * history, falling back to the base's default first table; bases whose + * target is a non-table node are omitted and keep the redirect chain. + */ +export const baseEntryMapVoSchema = z.record(z.string(), z.string()); + +export type IBaseEntryMapVo = z.infer; + +export const GetBaseEntryMapRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_SPACE_BASE_ENTRY_MAP, + description: + 'Resolve the entry URL (last visited table and view) of the accessible bases in a space, so base-list clicks can navigate straight to the final URL', + request: { + params: z.object({ spaceId: z.string() }), + query: getBaseEntryMapRoSchema.pick({ take: true }), + }, + responses: { + 200: { + description: 'Returns a map of baseId to entry URL pathname.', + content: { + 'application/json': { + schema: baseEntryMapVoSchema, + }, + }, + }, + }, + tags: ['space'], +}); + +export const getBaseEntryMap = async (params: IGetBaseEntryMapRo) => { + const { spaceId, take } = params; + return axios.get(urlBuilder(GET_SPACE_BASE_ENTRY_MAP, { spaceId }), { + params: { take }, + }); +}; diff --git a/packages/openapi/src/space/index.ts b/packages/openapi/src/space/index.ts index bc462b0254..bf8613bed4 100644 --- a/packages/openapi/src/space/index.ts +++ b/packages/openapi/src/space/index.ts @@ -1,3 +1,4 @@ +export * from './base-entry-map'; export * from './create'; export * from './delete'; export * from './get-list'; diff --git a/packages/openapi/src/table/update-icon.ts b/packages/openapi/src/table/update-icon.ts index dbc026c2f5..e34a545185 100644 --- a/packages/openapi/src/table/update-icon.ts +++ b/packages/openapi/src/table/update-icon.ts @@ -6,7 +6,7 @@ import { z } from '../zod'; export const TABLE_ICON = '/base/{baseId}/table/{tableId}/icon'; export const tableIconRoSchema = z.object({ - icon: z.string().emoji(), + icon: z.string().emoji().nullable(), }); export type ITableIconRo = z.infer; @@ -15,7 +15,8 @@ export const updateTableIconRoute: RouteConfig = registerRoute({ method: 'put', path: TABLE_ICON, summary: 'Update table tcon', - description: 'Update the emoji icon of a table. The icon must be a valid emoji character.', + description: + 'Update or remove the emoji icon of a table. The icon must be a valid emoji character. Set to null to remove the icon.', request: { params: z.object({ baseId: z.string(), diff --git a/packages/openapi/src/trash/get-item-records.ts b/packages/openapi/src/trash/get-item-records.ts new file mode 100644 index 0000000000..631d23f54d --- /dev/null +++ b/packages/openapi/src/trash/get-item-records.ts @@ -0,0 +1,88 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { recordSchema } from '@teable/core'; +import { axios } from '../axios'; +import { + registerRoute, + serializeArrayAwareQuery, + stringOrArrayQuerySchema, + urlBuilder, +} from '../utils'; +import { z } from '../zod'; +import { userMapVoSchema } from './get'; + +export const GET_TRASH_ITEM_RECORDS = '/trash/{trashId}/records'; + +export const MAX_TRASH_ITEM_RECORDS_TAKE = 200; + +export const getTrashItemRecordsQuerySchema = z.object({ + tableId: z.string(), + // Opaque continuation cursor from the previous page's nextCursor. Pages walk the item's + // snapshots in deletion order (newest first); snapshots may live in hot or cold storage, + // so random access by position is not supported. + cursor: z.string().optional(), + take: z.coerce.number().int().min(1).max(MAX_TRASH_ITEM_RECORDS_TAKE).optional(), + // Record-level filters narrow the walked stream; a page may return fewer items than + // `take` while more matches remain (nextCursor keeps paging). Keyword search is + // deliberately absent: snapshots live in hot or cold storage, and cold storage can + // only be scanned, not queried — same rule as record history. + recordCreatedBy: stringOrArrayQuerySchema, + recordCreatedTimeStart: z.string().optional(), + recordCreatedTimeEnd: z.string().optional(), +}); + +export type IGetTrashItemRecordsQuery = z.infer; + +export const trashItemRecordVoSchema = z.object({ + id: z.string(), + recordId: z.string(), + record: recordSchema, + deletedTime: z.string(), + deletedBy: z.string(), + recordCreatedTime: z.string().nullish(), + recordCreatedBy: z.string().nullish(), + recordLastModifiedTime: z.string().nullish(), + recordLastModifiedBy: z.string().nullish(), +}); + +export type ITrashItemRecordVo = z.infer; + +export const getTrashItemRecordsVoSchema = z.object({ + items: z.array(trashItemRecordVoSchema), + userMap: userMapVoSchema, + // null = the item's snapshot stream is exhausted; otherwise pass back as `cursor`. + nextCursor: z.string().nullish(), +}); + +export type IGetTrashItemRecordsVo = z.infer; + +export const GetTrashItemRecordsRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_TRASH_ITEM_RECORDS, + summary: 'Get deleted record snapshots of a trash item', + description: + 'List the record snapshots contained in a record-type table trash item in deletion order (newest first), cursor-paginated across hot and cold storage. Record-level filters narrow the stream. Records that were restored or permanently deleted are omitted.', + request: { + params: z.object({ + trashId: z.string(), + }), + query: getTrashItemRecordsQuerySchema, + }, + responses: { + 200: { + description: 'Get trash item records successfully', + content: { + 'application/json': { + schema: getTrashItemRecordsVoSchema, + }, + }, + }, + }, + tags: ['trash'], +}); + +export const getTrashItemRecords = (trashId: string, query: IGetTrashItemRecordsQuery) => { + return axios.get(urlBuilder(GET_TRASH_ITEM_RECORDS, { trashId }), { + params: query, + paramsSerializer: serializeArrayAwareQuery, + }); +}; diff --git a/packages/openapi/src/trash/get-items.ts b/packages/openapi/src/trash/get-items.ts index a81e83c692..ef651818a2 100644 --- a/packages/openapi/src/trash/get-items.ts +++ b/packages/openapi/src/trash/get-items.ts @@ -1,22 +1,41 @@ import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; import { axios } from '../axios'; -import { registerRoute } from '../utils'; +import { + createStringOrArrayQuerySchema, + registerRoute, + serializeArrayAwareQuery, + stringOrArrayQuerySchema, +} from '../utils'; import { z } from '../zod'; import type { ITrashVo } from './get'; import { trashVoSchema } from './get'; -import { TrashType } from './types'; +import { TableTrashType, TrashType } from './types'; export const GET_TRASH_ITEMS = '/trash/items'; +export const tableTrashResourceTypesQuerySchema = createStringOrArrayQuerySchema( + z.nativeEnum(TableTrashType) +); + export const trashItemsRoSchema = z.object({ resourceId: z.string(), resourceType: z.enum([TrashType.Base, TrashType.Table]), cursor: z.string().nullish(), pageSize: z.coerce.number().int().min(1).max(20).default(20).optional(), + // Filters below only apply to resourceType=Table; the Base branch ignores them. + resourceTypes: tableTrashResourceTypesQuerySchema, + deletedBy: stringOrArrayQuerySchema, + deletedTimeStart: z.string().optional(), + deletedTimeEnd: z.string().optional(), }); export type ITrashItemsRo = z.infer; +export type ITableTrashItemsFilter = Pick< + ITrashItemsRo, + 'resourceTypes' | 'deletedBy' | 'deletedTimeStart' | 'deletedTimeEnd' +>; + export const GetTrashItemsRoute: RouteConfig = registerRoute({ method: 'get', path: GET_TRASH_ITEMS, @@ -38,5 +57,8 @@ export const GetTrashItemsRoute: RouteConfig = registerRoute({ }); export const getTrashItems = (trashItemsRo: ITrashItemsRo) => { - return axios.get(GET_TRASH_ITEMS, { params: trashItemsRo }); + return axios.get(GET_TRASH_ITEMS, { + params: trashItemsRo, + paramsSerializer: serializeArrayAwareQuery, + }); }; diff --git a/packages/openapi/src/trash/get.ts b/packages/openapi/src/trash/get.ts index 98457a572b..5262aaeea9 100644 --- a/packages/openapi/src/trash/get.ts +++ b/packages/openapi/src/trash/get.ts @@ -98,7 +98,10 @@ export const trashItemVoSchema = z.object({ export const tableTrashItemVoSchema = z.object({ id: z.string(), + // Preview only: a bulk deletion can reference tens of thousands of resources, so the + // list returns the first few ids; the full set is paged through the item records endpoint. resourceIds: z.array(z.string()), + totalResourceCount: z.number(), resourceType: z.enum(TableTrashType), deletedTime: z.string(), deletedBy: z.string(), diff --git a/packages/openapi/src/trash/index.ts b/packages/openapi/src/trash/index.ts index a40a6f7ed2..321487ca0a 100644 --- a/packages/openapi/src/trash/index.ts +++ b/packages/openapi/src/trash/index.ts @@ -1,6 +1,7 @@ export * from './types'; export * from './delete'; export * from './get'; +export * from './get-item-records'; export * from './get-items'; export * from './reset-items'; export * from './restore'; diff --git a/packages/openapi/src/usage/get-instance-usage.ts b/packages/openapi/src/usage/get-instance-usage.ts index 47e8205651..d70279913f 100644 --- a/packages/openapi/src/usage/get-instance-usage.ts +++ b/packages/openapi/src/usage/get-instance-usage.ts @@ -1,11 +1,18 @@ import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; import { axios } from '../axios'; import { registerRoute } from '../utils'; -import type { IUsageVo } from './get-space-usage'; import { usageVoSchema } from './get-space-usage'; export const GET_INSTANCE_USAGE = '/instance/usage'; +export const instanceUsageVoSchema = usageVoSchema.extend({ + seats: z.number().optional(), + seatLimit: z.number().optional(), +}); + +export type IInstanceUsageVo = z.infer; + export const GetInstanceUsageRoute: RouteConfig = registerRoute({ method: 'get', path: GET_INSTANCE_USAGE, @@ -16,7 +23,7 @@ export const GetInstanceUsageRoute: RouteConfig = registerRoute({ description: 'Returns usage information for the instance.', content: { 'application/json': { - schema: usageVoSchema, + schema: instanceUsageVoSchema, }, }, }, @@ -25,5 +32,5 @@ export const GetInstanceUsageRoute: RouteConfig = registerRoute({ }); export const getInstanceUsage = async () => { - return axios.get(GET_INSTANCE_USAGE); + return axios.get(GET_INSTANCE_USAGE); }; diff --git a/packages/openapi/src/usage/get-space-usage.ts b/packages/openapi/src/usage/get-space-usage.ts index ff465bea0d..06dbb674b2 100644 --- a/packages/openapi/src/usage/get-space-usage.ts +++ b/packages/openapi/src/usage/get-space-usage.ts @@ -28,6 +28,7 @@ export enum UsageFeatureLimit { MaxNumAutomationRuns = 'maxNumAutomationRuns', MaxNumDatabaseConnections = 'maxNumDatabaseConnections', MaxRevisionHistoryDays = 'maxRevisionHistoryDays', + MaxTrashReadDays = 'maxTrashReadDays', MaxAutomationHistoryDays = 'maxAutomationHistoryDays', AutomationEnable = 'automationEnable', AuditLogEnable = 'auditLogEnable', @@ -44,6 +45,7 @@ export enum UsageFeatureLimit { OrganizationEnable = 'organizationEnable', APIRateLimit = 'apiRateLimit', ChatAIEnable = 'chatAIEnable', + ArchiveEnable = 'archiveEnable', AppEnable = 'appEnable', AppHideBadgeEnable = 'appHideBadgeEnable', CustomDomainEnable = 'customDomainEnable', @@ -56,6 +58,7 @@ export const usageFeatureLimitSchema = z.object({ [UsageFeatureLimit.MaxNumAutomationRuns]: z.number(), [UsageFeatureLimit.MaxNumDatabaseConnections]: z.number(), [UsageFeatureLimit.MaxRevisionHistoryDays]: z.number(), + [UsageFeatureLimit.MaxTrashReadDays]: z.number(), [UsageFeatureLimit.MaxAutomationHistoryDays]: z.number(), [UsageFeatureLimit.AutomationEnable]: z.boolean(), [UsageFeatureLimit.AuditLogEnable]: z.boolean(), @@ -72,6 +75,7 @@ export const usageFeatureLimitSchema = z.object({ [UsageFeatureLimit.OrganizationEnable]: z.boolean(), [UsageFeatureLimit.APIRateLimit]: z.number(), [UsageFeatureLimit.ChatAIEnable]: z.boolean(), + [UsageFeatureLimit.ArchiveEnable]: z.boolean(), [UsageFeatureLimit.AppEnable]: z.boolean(), [UsageFeatureLimit.AppHideBadgeEnable]: z.boolean(), [UsageFeatureLimit.CustomDomainEnable]: z.boolean(), diff --git a/packages/openapi/src/utils.ts b/packages/openapi/src/utils.ts index deb980aaff..06f2420e43 100644 --- a/packages/openapi/src/utils.ts +++ b/packages/openapi/src/utils.ts @@ -1,4 +1,39 @@ import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { z } from './zod'; + +// Accepts `?key=a` and `?key=a&key=b` alike, normalizing to an array. +export const createStringOrArrayQuerySchema = >(itemSchema: T) => + z + .union([itemSchema, itemSchema.array()]) + .transform((val) => (typeof val === 'string' ? [val] : val)) + .optional() + .meta({ + type: 'array', + items: { type: 'string' }, + }); + +export const stringOrArrayQuerySchema = createStringOrArrayQuerySchema(z.string()); + +// Serializes query params where array values become repeated `key=value` pairs +// (the shape stringOrArrayQuerySchema expects on the server). +export const serializeArrayAwareQuery = (params?: Record) => { + const searchParams = new URLSearchParams(); + + Object.entries(params ?? {}).forEach(([key, value]) => { + if (value == null) { + return; + } + + if (Array.isArray(value)) { + value.forEach((item) => searchParams.append(key, String(item))); + return; + } + + searchParams.append(key, String(value)); + }); + + return searchParams.toString(); +}; export const urlBuilder = (url: string, pathParams?: Record) => { if (!pathParams) { diff --git a/packages/openapi/src/view/refresh-share-id.ts b/packages/openapi/src/view/refresh-share-id.ts index 9ed1ddfeeb..6d3ac96570 100644 --- a/packages/openapi/src/view/refresh-share-id.ts +++ b/packages/openapi/src/view/refresh-share-id.ts @@ -8,6 +8,7 @@ export const REFRESH_SHARE_ID = '/table/{tableId}/view/{viewId}/refresh-share-id export const refreshShareViewVoSchema = z.object({ shareId: z.string(), }); +export type IRefreshShareViewVo = z.infer; export const refreshViewShareIdRoute: RouteConfig = registerRoute({ method: 'post', @@ -33,7 +34,7 @@ export const refreshViewShareIdRoute: RouteConfig = registerRoute({ }); export const refreshViewShareId = async (tableId: string, viewId: string) => { - return axios.post( + return axios.post( urlBuilder(REFRESH_SHARE_ID, { tableId, viewId, diff --git a/packages/sdk/src/components/editor/attachment/Editor.tsx b/packages/sdk/src/components/editor/attachment/Editor.tsx index 81b485016e..be5d9a3560 100644 --- a/packages/sdk/src/components/editor/attachment/Editor.tsx +++ b/packages/sdk/src/components/editor/attachment/Editor.tsx @@ -52,7 +52,7 @@ export const AttachmentEditor = (props: IAttachmentEditor) => { }, [tableId, recordId, fieldId]); return (

-
+
{isTouchDevice ? ( uploadAttachmentRef.current?.uploadAttachment(files)} diff --git a/packages/sdk/src/components/editor/attachment/upload-attachment/AttachmentItem.tsx b/packages/sdk/src/components/editor/attachment/upload-attachment/AttachmentItem.tsx index fbf731baf4..7a610b2bf2 100644 --- a/packages/sdk/src/components/editor/attachment/upload-attachment/AttachmentItem.tsx +++ b/packages/sdk/src/components/editor/attachment/upload-attachment/AttachmentItem.tsx @@ -3,6 +3,7 @@ import { CSS } from '@dnd-kit/utilities'; import type { IAttachmentItem } from '@teable/core'; import { Download, X } from '@teable/icons'; import { Button, cn, FilePreviewItem, isImage } from '@teable/ui-lib'; +import type { CSSProperties } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { EllipsisFileName } from '../../../upload/EllipsisFileName'; import { FileCover } from '../../../upload/FileCover'; @@ -27,9 +28,16 @@ function AttachmentItem(props: IUploadAttachment) { disabled: readonly || isEditing, }); - const style = { + const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition, + ...(!readonly && + !isEditing && { + WebkitTouchCallout: 'none', + WebkitUserSelect: 'none', + userSelect: 'none', + touchAction: 'manipulation', + }), }; const handleStartEdit = useCallback(() => { @@ -89,7 +97,12 @@ function AttachmentItem(props: IUploadAttachment) { size={attachment.size} > {shouldRenderPreviewImage ? ( - {attachment.name} + {attachment.name} ) : ( void; disabled? return (
) : (
- {fields.length > 0 ? ( -
+
+ {record && fields.length > 0 ? ( { buttonClickStatusHook={buttonClickStatusHook} onAttachmentDownload={onAttachmentDownload} /> -
- ) : ( - - )} + ) : ( +
+
+ {skeletonRows.map((labelWidth) => ( +
+
+
+ +
+
+ +
+
+
+ +
+
+ ))} +
+
+ )} +
{commentVisible && baseId && tableId && recordId && (
@@ -214,6 +243,28 @@ export const ExpandRecord = (props: IExpandRecordProps) => {
)}
+ {isMobile && (!disabledPrev || !disabledNext) && ( +
+ + +
+ )}
); diff --git a/packages/sdk/src/components/expand-record/ExpandRecordHeader.tsx b/packages/sdk/src/components/expand-record/ExpandRecordHeader.tsx index 91ef6283df..159c3c8143 100644 --- a/packages/sdk/src/components/expand-record/ExpandRecordHeader.tsx +++ b/packages/sdk/src/components/expand-record/ExpandRecordHeader.tsx @@ -19,7 +19,7 @@ import { import { CopyPlus, Trash } from 'lucide-react'; import { useMeasure } from 'react-use'; import { useTranslation } from '../../context/app/i18n'; -import { useTablePermission } from '../../hooks'; +import { useIsMobile, useTablePermission } from '../../hooks'; import { useRecordCommentCount } from '../comment/hooks'; import { TooltipWrap } from './TooltipWrap'; @@ -77,6 +77,7 @@ export const ExpandRecordHeader = (props: IExpandRecordHeader) => { const canDuplicate = Boolean(permission['record|create']); const [ref, { width }] = useMeasure(); const { t } = useTranslation(); + const isMobile = useIsMobile(); const showTitle = width > MIN_TITLE_WIDTH; const showOperator = width > MIN_OPERATOR_WIDTH; const recordCommentCount = useRecordCommentCount(tableId, recordId, canRead); @@ -90,36 +91,44 @@ export const ExpandRecordHeader = (props: IExpandRecordHeader) => { { 'justify-between': !showTitle } )} > -
- - - - - - -
+ {!isMobile && ( +
+ + + + + + +
+ )} {showTitle && (
-

+

{title || t('common.unnamedRecord')}

{foreignTableName && ( @@ -146,7 +155,7 @@ export const ExpandRecordHeader = (props: IExpandRecordHeader) => { - {editable && onRecordHistoryToggle && ( + {!isMobile && editable && onRecordHistoryToggle && ( { )} - {(canDelete || (canDuplicate && !!onDuplicate)) && ( + {((isMobile && editable && !!onRecordHistoryToggle) || + canDelete || + (canDuplicate && !!onDuplicate)) && ( - + + {isMobile && editable && onRecordHistoryToggle && ( + + + {recordHistoryVisible + ? t('expandRecord.recordHistory.hiddenRecordHistory') + : t('expandRecord.recordHistory.showRecordHistory')} + + )} {canDuplicate && !!onDuplicate && ( { )}
)} - + {!isMobile && } diff --git a/packages/sdk/src/components/expand-record/ExpandRecordWrap.tsx b/packages/sdk/src/components/expand-record/ExpandRecordWrap.tsx index 977dc6d43e..9d72b8aa7c 100644 --- a/packages/sdk/src/components/expand-record/ExpandRecordWrap.tsx +++ b/packages/sdk/src/components/expand-record/ExpandRecordWrap.tsx @@ -26,7 +26,7 @@ export const ExpandRecordWrap: FC< return ( c.recordId === record?.id && c.fieldId === field.id) ?? false; const cellValue = record?.getCellValue(field.id); + const compact = !vertical; + const showAiGenerateButton = hasAiConfig && Boolean(field.tableId && record && !readonly); + const aiGenerateButton = showAiGenerateButton && field.tableId && record && ( + + ); const onChangeInner = (value: unknown) => { if (cellValue === value) return; onChange?.(value, field.id); @@ -48,26 +58,47 @@ export const RecordEditorItem = (props: { return (
-
-
+
+
-
- - {field.name} +
+ + + {field.name} + + {field.notNull && ( + + * + + )} {field.description && ( - + )}
- {field.notNull && ( - - * - + {compact && aiGenerateButton && ( +
{aiGenerateButton}
)}
-
- {hasAiConfig && field.tableId && record && !readonly && ( - - )} -
+ {!compact && aiGenerateButton && ( +
+ {aiGenerateButton} +
+ )}
); }; diff --git a/packages/sdk/src/components/expand-record/RecordHistory.tsx b/packages/sdk/src/components/expand-record/RecordHistory.tsx index f6a3c3b006..95ee91d3b9 100644 --- a/packages/sdk/src/components/expand-record/RecordHistory.tsx +++ b/packages/sdk/src/components/expand-record/RecordHistory.tsx @@ -10,12 +10,7 @@ import type { IRecordHistoryItemVo, IRecordHistoryVo, } from '@teable/openapi'; -import { - getFields, - getRecordHistory, - getRecordListHistory, - getUserCollaborators, -} from '@teable/openapi'; +import { getFields, getRecordHistory, getRecordListHistory } from '@teable/openapi'; import { Button, Popover, @@ -31,7 +26,13 @@ import type { ReactNode } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ReactQueryKeys } from '../../config'; import { useTranslation } from '../../context/app/i18n'; -import { useBaseId, useFieldStaticGetter, useFields, useIsHydrated, useTableId } from '../../hooks'; +import { + useCollaboratorFilterUsers, + useFieldStaticGetter, + useFields, + useIsHydrated, + useTableId, +} from '../../hooks'; import { createFieldInstance, type IFieldInstance } from '../../model'; import { CellValue, UserAvatar } from '../cell-value'; import { CollaboratorWithHoverCard } from '../collaborator'; @@ -356,7 +357,6 @@ const RecordHistoryFilterBar = (props: IRecordHistoryFilterBarProps) => { const RecordHistoryContent = (props: IRecordHistoryContentProps) => { const { recordId, onRecordClick, tableId, contextFields } = props; - const baseId = useBaseId(); const { t } = useTranslation(); const isHydrated = useIsHydrated(); const getFieldStatic = useFieldStaticGetter(); @@ -365,10 +365,13 @@ const RecordHistoryContent = (props: IRecordHistoryContentProps) => { const [fieldIds, setFieldIds] = useState([]); const [createdByIds, setCreatedByIds] = useState([]); const [dateRange, setDateRange] = useState(null); - const [userSearch, setUserSearch] = useState(''); const [selectedUserMap, setSelectedUserMap] = useState>( {} ); + const { users, setUserSearch } = useCollaboratorFilterUsers({ + selectedIds: createdByIds, + userMap: selectedUserMap, + }); const shouldFetchFields = contextFields == null; @@ -397,35 +400,6 @@ const RecordHistoryContent = (props: IRecordHistoryContentProps) => { [createdByIds, dateRange?.exactDate, dateRange?.exactDateEnd, fieldIds] ); - const { data: collaboratorsData } = useQuery({ - queryKey: ReactQueryKeys.baseCollaboratorListUser(baseId as string, { - includeSystem: true, - skip: 0, - take: 100, - search: userSearch, - }), - queryFn: ({ queryKey }) => - getUserCollaborators(queryKey[1], queryKey[2]).then((res) => res.data), - enabled: Boolean(baseId), - }); - - const users = useMemo(() => { - const userMap = new Map(); - - createdByIds.forEach((id) => { - const user = selectedUserMap[id]; - if (user) { - userMap.set(id, user); - } - }); - - collaboratorsData?.users.forEach((user) => { - userMap.set(user.id, user); - }); - - return Array.from(userMap.values()); - }, [collaboratorsData?.users, createdByIds, selectedUserMap]); - const queryFn = async ({ queryKey, pageParam, @@ -678,7 +652,7 @@ const RecordHistoryContent = (props: IRecordHistoryContentProps) => { setCreatedByIds([]); setDateRange(null); setUserSearch(''); - }, []); + }, [setUserSearch]); if (!isHydrated) return null; diff --git a/packages/sdk/src/components/filter/view-filter/ViewFilter.spec.tsx b/packages/sdk/src/components/filter/view-filter/ViewFilter.spec.tsx new file mode 100644 index 0000000000..d67c105835 --- /dev/null +++ b/packages/sdk/src/components/filter/view-filter/ViewFilter.spec.tsx @@ -0,0 +1,125 @@ +import type { IFilter } from '@teable/core'; +import { CellValueType, FieldType } from '@teable/core'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAppContext } from '../../../context/__tests__/createAppContext'; +import type { IFieldInstance } from '../../../model'; +import type * as ViewFilterHooks from './hooks'; +import { ViewFilter } from './ViewFilter'; + +const field = { + id: 'fldText00000000001', + name: 'Single line text', + type: FieldType.SingleLineText, + cellValueType: CellValueType.String, + isMultipleCellValue: false, +} as IFieldInstance; + +const textFilter: IFilter = { + conjunction: 'and', + filterSet: [{ fieldId: field.id, operator: 'is', value: '111' }], +}; + +const remoteFilter: IFilter = { + conjunction: 'and', + filterSet: [{ fieldId: field.id, operator: 'is', value: 'remote' }], +}; + +vi.mock('../../../hooks', () => ({ + useFields: () => [field], + useTableId: () => 'tblTest00000000001', + useViewId: () => 'viwTest00000000001', +})); + +vi.mock('@teable/ui-lib', () => ({ + cn: (...values: unknown[]) => values.filter(Boolean).join(' '), + Popover: ({ children }: { children: ReactNode }) => <>{children}, + PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock('../../ReadOnlyTip', () => ({ ReadOnlyTip: () => null })); + +vi.mock('./BaseViewFilter', () => ({ + BaseViewFilter: ({ onChange }: { onChange: (filter: IFilter) => void }) => ( + <> + + + + ), +})); + +vi.mock('./hooks', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useViewFilterLinkContext: () => ({}), + }; +}); + +const wrapper = createAppContext(); + +const renderViewFilter = (filters: IFilter, onChange: (filter: IFilter) => void | Promise) => + render( + + {(text) => {text}} + , + { wrapper } + ); + +describe('ViewFilter', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('updates the toolbar summary before the parent filter prop changes', () => { + renderViewFilter(null, vi.fn()); + + fireEvent.click(screen.getByRole('button', { name: 'add filter' })); + + expect(screen.getByTestId('filter-label')).toHaveTextContent('Filter by Single line text'); + }); + + it('accepts a remote filter after a local edit returns to the parent value', async () => { + const onChange = vi.fn(); + const view = renderViewFilter(null, onChange); + + fireEvent.click(screen.getByRole('button', { name: 'add filter' })); + fireEvent.click(screen.getByRole('button', { name: 'clear filter' })); + await act(async () => { + vi.advanceTimersByTime(300); + }); + + view.rerender( + + {(text) => {text}} + + ); + + expect(screen.getByTestId('filter-label')).toHaveTextContent('Filter by Single line text'); + }); + + it('restores the synchronized toolbar summary when saving fails', async () => { + const onChange = vi.fn().mockRejectedValue(new Error('save failed')); + renderViewFilter(textFilter, onChange); + + fireEvent.click(screen.getByRole('button', { name: 'clear filter' })); + expect(screen.getByTestId('filter-label')).toHaveTextContent('Filter'); + + await act(async () => { + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + + expect(screen.getByTestId('filter-label')).toHaveTextContent('Filter by Single line text'); + }); +}); diff --git a/packages/sdk/src/components/filter/view-filter/ViewFilter.tsx b/packages/sdk/src/components/filter/view-filter/ViewFilter.tsx index be2faf9399..1e4bca4803 100644 --- a/packages/sdk/src/components/filter/view-filter/ViewFilter.tsx +++ b/packages/sdk/src/components/filter/view-filter/ViewFilter.tsx @@ -18,7 +18,7 @@ import type { IViewFilterConditionItem, IViewFilterLinkContext } from './types'; export interface IViewFilterProps { filters: IFilter; contentHeader?: React.ReactNode; - onChange: (value: IFilter) => void; + onChange: (value: IFilter) => void | Promise; viewFilterLinkContext?: IViewFilterLinkContext; children?: (text: string, isActive?: boolean, hasWarning?: boolean) => React.ReactNode; customValueComponent?: IFilterBaseComponent; @@ -28,8 +28,11 @@ export const ViewFilter = (props: IViewFilterProps) => { const { contentHeader, filters, children, onChange } = props; const defaultFields = useFields({ withHidden: true, withDenied: true }); const fields = defaultFields.filter((f) => f.type !== FieldType.Button); - const { text, isActive, hasWarning } = useFilterNode(filters, fields); + // Toolbar label must track the local editing filter immediately. Waiting on the + // server/collab `filters` prop leaves the button stuck on bare "Filter" until + // reopen/refresh when realtime lag or personal-view sync is delayed. const [filter, setFilter] = useState(filters); + const { text, isActive, hasWarning } = useFilterNode(filter, fields); // Validation errors against the local (editing) filter — lets the popover highlight // invalid rows in real time as the user fixes them. @@ -50,6 +53,8 @@ export const ViewFilter = (props: IViewFilterProps) => { // This solves the race condition where: user adds item A -> user adds item B -> server responds with A only -> UI flickers const localEditVersionRef = useRef(0); const lastSyncedVersionRef = useRef(0); + const filtersRef = useRef(filters); + filtersRef.current = filters; useUpdateEffect(() => { // Only accept server updates if no local edits are pending @@ -74,13 +79,21 @@ export const ViewFilter = (props: IViewFilterProps) => { useDebounce( () => { - if (!isEqual(filter, filters)) { - // Capture current version before sending to server - const currentVersion = localEditVersionRef.current; - onChange(filter); - // Mark this version as synced after onChange is called - // This allows subsequent server responses to be accepted - lastSyncedVersionRef.current = currentVersion; + const currentVersion = localEditVersionRef.current; + // A local edit that returns to the current prop still completes this version. + // Without this acknowledgement, every later collaborator update is rejected. + lastSyncedVersionRef.current = currentVersion; + if (isEqual(filter, filters)) return; + + const rollback = () => { + if (localEditVersionRef.current !== currentVersion) return; + setFilter(filtersRef.current); + }; + + try { + void Promise.resolve(onChange(filter)).catch(rollback); + } catch { + rollback(); } }, 300, diff --git a/packages/sdk/src/components/filter/view-filter/component/filterDatePicker/FilterDatePicker.spec.tsx b/packages/sdk/src/components/filter/view-filter/component/filterDatePicker/FilterDatePicker.spec.tsx new file mode 100644 index 0000000000..917373a08f --- /dev/null +++ b/packages/sdk/src/components/filter/view-filter/component/filterDatePicker/FilterDatePicker.spec.tsx @@ -0,0 +1,50 @@ +import type { IFieldVo } from '@teable/core'; +import { CellValueType, DbFieldType, FieldType, isBefore } from '@teable/core'; +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { createFieldInstance, type DateField } from '../../../../../model'; +import { FilterDatePicker } from './FilterDatePicker'; + +vi.mock('../../../../editor', () => ({ DateEditor: () => null })); +vi.mock('../../hooks', () => ({ + useDateI18nMap: () => new Proxy({}, { get: (_, key) => String(key) }), +})); +vi.mock('../base', () => ({ BaseSingleSelect: () => null })); +vi.mock('./DateRangePicker', () => ({ DateRangePicker: () => null })); + +const malformedLookupDateField = createFieldInstance({ + id: 'fldLookupDate00001', + name: 'Scheduled date', + dbFieldName: 'scheduled_date', + type: FieldType.Date, + options: {}, + unique: false, + cellValueType: CellValueType.String, + dbFieldType: DbFieldType.Text, + isLookup: true, + lookupOptions: { + relationship: 'manyOne', + foreignTableId: 'tblSource000000001', + lookupFieldId: 'fldSourceDate00001', + linkFieldId: 'fldSourceLink00001', + }, +} as unknown as IFieldVo) as DateField; + +describe('FilterDatePicker', () => { + it('renders a legacy date lookup whose formatting metadata is missing', () => { + expect(() => + render( + + ) + ).not.toThrow(); + }); +}); diff --git a/packages/sdk/src/components/filter/view-filter/component/filterDatePicker/FilterDatePicker.tsx b/packages/sdk/src/components/filter/view-filter/component/filterDatePicker/FilterDatePicker.tsx index 2698588bdd..444abf9a3f 100644 --- a/packages/sdk/src/components/filter/view-filter/component/filterDatePicker/FilterDatePicker.tsx +++ b/packages/sdk/src/components/filter/view-filter/component/filterDatePicker/FilterDatePicker.tsx @@ -161,9 +161,7 @@ function FilterDatePicker(props: IFilerDatePickerProps) { () => initValue ?? defaultConfig ); const dateMap = useDateI18nMap(); - const fieldTimeZone = - field.options.formatting.timeZone ?? - (Intl.DateTimeFormat().resolvedOptions().timeZone as ITimeZoneString); + const fieldTimeZone = field.getDatetimeFormatting().timeZone; const previousInitRef = useRef(initValue ?? null); const previousOperatorRef = useRef(operator); diff --git a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-async-records.spec.tsx b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-async-records.spec.tsx index 0902bfc5d2..9727b4346d 100644 --- a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-async-records.spec.tsx +++ b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-async-records.spec.tsx @@ -381,6 +381,115 @@ describe('useGridAsyncRecords', () => { }); }); + // shared two-group fixture for the collapse/expand tests below + type IOuterQuery = Pick; + const GROUP_BY = [{ fieldId: 'fldGroup', order: SortFunc.Asc }]; + const TWO_GROUP_POINTS = [ + { id: 'grpA', type: 0, depth: 0, value: 'A', isCollapsed: false }, + { type: 1, count: 2 }, + { id: 'grpB', type: 0, depth: 0, value: 'B', isCollapsed: false }, + { type: 1, count: 2 }, + ] as IGroupPointsVo; + const renderTwoGroupGrid = () => { + mockedUseRecords.mockReturnValue( + mockUseRecordsResult( + [ + createRecord('recA1'), + createRecord('recA2'), + createRecord('recB1'), + createRecord('recB2'), + ], + { groupPoints: TWO_GROUP_POINTS } + ) + ); + mockedUseView.mockReturnValue({ id: 'viwTest', filter: null } as unknown as ReturnType< + typeof useView + >); + return renderHook(({ outerQuery }) => useGridAsyncRecords(undefined, undefined, outerQuery), { + initialProps: { outerQuery: { groupBy: GROUP_BY } as IOuterQuery }, + }); + }; + + it('patches the layout in place on a collapse toggle instead of wiping to placeholders', async () => { + const { result, rerender } = renderTwoGroupGrid(); + expect(result.current.recordMap[3]?.id).toBe('recB2'); + + // collapse group A: the layout and loaded rows repaint in place — group B + // shifts up with its rows, nothing drops to loading placeholders + rerender({ outerQuery: { groupBy: GROUP_BY, collapsedGroupIds: ['grpA'] } }); + + expect(result.current.groupPoints).toEqual([ + { id: 'grpA', type: 0, depth: 0, value: 'A', isCollapsed: true }, + { id: 'grpB', type: 0, depth: 0, value: 'B', isCollapsed: false }, + { type: 1, count: 2 }, + ]); + expect(result.current.recordMap[0]?.id).toBe('recB1'); + expect(result.current.recordMap[1]?.id).toBe('recB2'); + expect(result.current.recordMap[2]).toBeUndefined(); + + // the re-created subscription delivers server truth the patch could not + // know (a row was added to group B meanwhile): it replaces the patched + // state entirely + const freshGroupPoints = [ + { id: 'grpA', type: 0, depth: 0, value: 'A', isCollapsed: true }, + { id: 'grpB', type: 0, depth: 0, value: 'B', isCollapsed: false }, + { type: 1, count: 3 }, + ] as IGroupPointsVo; + mockedUseRecords.mockReturnValue( + mockUseRecordsResult([createRecord('recB1'), createRecord('recB2'), createRecord('recB3')], { + groupPoints: freshGroupPoints, + }) + ); + rerender({ outerQuery: { groupBy: GROUP_BY, collapsedGroupIds: ['grpA'] } }); + + await waitFor(() => { + expect(result.current.groupPoints).toEqual(freshGroupPoints); + expect(result.current.recordMap[2]?.id).toBe('recB3'); + }); + }); + + it('re-expands a group in place using its cached row count, keeping rows behind it', () => { + const { result, rerender } = renderTwoGroupGrid(); + + // collapse A (its row count is cached from the delivered points), then + // expand it back before any fresh delivery arrives + rerender({ outerQuery: { groupBy: GROUP_BY, collapsedGroupIds: ['grpA'] } }); + rerender({ outerQuery: { groupBy: GROUP_BY } }); + + // the layout is restored in place: A shows a loading block of its known + // size while B's rows keep their content at their exact positions + expect(result.current.groupPoints).toEqual(TWO_GROUP_POINTS); + expect(result.current.recordMap[0]).toBeUndefined(); + expect(result.current.recordMap[1]).toBeUndefined(); + expect(result.current.recordMap[2]?.id).toBe('recB1'); + expect(result.current.recordMap[3]?.id).toBe('recB2'); + }); + + it('does not reuse cached row counts across a view filter change', () => { + const { result, rerender } = renderTwoGroupGrid(); + + rerender({ outerQuery: { groupBy: GROUP_BY, collapsedGroupIds: ['grpA'] } }); + + // the view filter changes while A is collapsed: its cached count now + // describes a different result set and must be dropped + mockedUseView.mockReturnValue({ + id: 'viwTest', + filter: { conjunction: 'and', filterSet: [] }, + } as unknown as ReturnType); + rerender({ outerQuery: { groupBy: GROUP_BY, collapsedGroupIds: ['grpA'] } }); + + rerender({ outerQuery: { groupBy: GROUP_BY } }); + + // expanding falls back to loading placeholders instead of placing rows + // with a count from the pre-filter result set + expect(result.current.groupPoints).toEqual([ + { id: 'grpA', type: 0, depth: 0, value: 'A', isCollapsed: false }, + { id: 'grpB', type: 0, depth: 0, value: 'B', isCollapsed: false }, + { type: 1, count: 2 }, + ]); + expect(result.current.recordMap).toEqual({}); + }); + it('does not keep empty cache slots in the loaded record map when loading a later window', async () => { mockedUseRecords.mockReturnValue( mockUseRecordsResult([createRecord('rec1'), createRecord('rec2')]) diff --git a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-async-records.ts b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-async-records.ts index 202df68d98..d1bbad2aca 100644 --- a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-async-records.ts +++ b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-async-records.ts @@ -1,19 +1,21 @@ import type { IRecord, ISearchHitIndex } from '@teable/core'; import { computeSearchHitIndex } from '@teable/core'; import type { IGetRecordsRo, IGroupHeaderRef, IGroupPointsVo } from '@teable/openapi'; -import { debounce, keyBy } from 'lodash'; +import { debounce, isEqual, keyBy } from 'lodash'; import { useCallback, useEffect, useLayoutEffect, useRef, useState, useMemo } from 'react'; import type { IGridProps, IRectangle } from '../..'; import { useFields, usePersonalView, useSearch, useTableId, useView } from '../../../hooks'; import { useRecords } from '../../../hooks/use-records'; import type { IFieldInstance, Record as IRecordInstance } from '../../../model'; import { createRecordInstance, recordInstanceFieldMap } from '../../../model'; +import { applyCollapsedGroupChange, collectGroupRowCounts } from '../../../utils/collapsed-group'; import { computeNextWindowQuery, INITIAL_LOAD_PAGE_SIZE, LOAD_PAGE_SIZE, } from '../../../utils/record-window'; import { + MAX_POINTS_PER_ENTRY, MAX_SNAPSHOT_BYTES, MAX_SNAPSHOT_ROWS, useGridViewCacheStore, @@ -184,24 +186,37 @@ export const useGridAsyncRecords = ( // view's slot, so skip until state and key agree again if (keyChanged) return; if (view?.id && groupPoints != null) { - useGridViewCacheStore - .getState() - .setGroupPoints( - groupPointsCacheKey, - groupPoints, - (extraRef.current as { allGroupHeaderRefs?: IGroupHeaderRef[] } | undefined) - ?.allGroupHeaderRefs - ); + const cache = useGridViewCacheStore.getState(); + cache.setGroupPoints( + groupPointsCacheKey, + groupPoints, + (extraRef.current as { allGroupHeaderRefs?: IGroupHeaderRef[] } | undefined) + ?.allGroupHeaderRefs + ); + // remember each expanded group's visible row count; a group keeps its + // last known value while collapsed, so expanding it later can restore + // its row block in place instead of dropping everything behind it. + // Same size discipline as the structure facet: past the cap the walk + // and the retained map are all cost and no seed value + if (groupPoints.length <= MAX_POINTS_PER_ENTRY) { + cache.mergeGroupRowCounts(groupPointsCacheKey, collectGroupRowCounts(groupPoints)); + } } }, [groupPoints, groupPointsCacheKey, view?.id, cacheEnabled]); const recordsScopeKey = useMemo( () => JSON.stringify({ initQuery, - outerQuery, + // collapse/expand toggles are excluded: they get a soft path in the + // combined effect below (patch the layout in place) instead of the wipe + outerQuery: { ...outerQuery, collapsedGroupIds: undefined }, }), [initQuery, outerQuery] ); + const collapsedGroupIdsKey = useMemo( + () => JSON.stringify(outerQuery?.collapsedGroupIds ?? null), + [outerQuery] + ); // on a shared (non-personal) view the server resolves filter/sort (and // row-hiding search) through viewId, so they redefine the result set without // appearing in initQuery/outerQuery: the subscription stays alive and the @@ -209,7 +224,8 @@ export const useGridAsyncRecords = ( // changes the cache must keep the current page (still correct in that case) // and only drop the entries retained from the previous result set. Group and // personal-view changes also flow through outerQuery — the scope wipe handles - // them and takes precedence in the combined effect below. + // them and takes precedence in the combined effect below; collapse toggles + // are carved out of that key and patched in place instead. const viewQueryScopeKey = useMemo( () => JSON.stringify({ @@ -226,9 +242,12 @@ export const useGridAsyncRecords = ( visiblePagesRef.current = visiblePages; const previousRecordsScopeKeyRef = useRef(recordsScopeKey); const previousViewQueryScopeKeyRef = useRef(viewQueryScopeKey); + const previousCollapsedGroupIdsKeyRef = useRef(collapsedGroupIdsKey); const lastMergedSkipRef = useRef(0); const loadedRecordMapRef = useRef(loadedRecordMap); loadedRecordMapRef.current = loadedRecordMap; + const groupPointsRef = useRef(groupPoints); + groupPointsRef.current = groupPoints; const fieldsRef = useRef(fields); fieldsRef.current = fields; const cacheKeyRef = useRef(groupPointsCacheKey); @@ -305,7 +324,12 @@ export const useGridAsyncRecords = ( }); if (extra != null) { - setGroupPoints((extra as { groupPoints: IGroupPointsVo } | undefined)?.groupPoints ?? null); + const freshGroupPoints = + (extra as { groupPoints: IGroupPointsVo } | undefined)?.groupPoints ?? null; + // deliveries re-send a structurally identical list on every page (and + // after an exact local collapse patch): keep the previous reference so + // the grid does not rebuild its O(total rows) linear layout for nothing + setGroupPoints((prev) => (isEqual(prev, freshGroupPoints) ? prev : freshGroupPoints)); } }, [records, extra]); @@ -321,10 +345,13 @@ export const useGridAsyncRecords = ( useLayoutEffect(() => { const recordsScopeChanged = previousRecordsScopeKeyRef.current !== recordsScopeKey; const viewQueryScopeChanged = previousViewQueryScopeKeyRef.current !== viewQueryScopeKey; + const collapsedGroupIdsChanged = + previousCollapsedGroupIdsKeyRef.current !== collapsedGroupIdsKey; const previousCacheKey = previousScopeCacheKeyRef.current; const cacheKeyChanged = previousCacheKey !== groupPointsCacheKey; previousRecordsScopeKeyRef.current = recordsScopeKey; previousViewQueryScopeKeyRef.current = viewQueryScopeKey; + previousCollapsedGroupIdsKeyRef.current = collapsedGroupIdsKey; previousScopeCacheKeyRef.current = groupPointsCacheKey; const keySwitched = cacheEnabled && cacheKeyChanged; @@ -343,6 +370,33 @@ export const useGridAsyncRecords = ( setGroupPoints(entry?.groupPoints ?? null); }; + // row counts collected under the previous result set must not place rows + // under a redefined one — cleared on every same-view scope change + const clearCachedGroupRowCounts = () => { + if (!cacheEnabled) return; + useGridViewCacheStore.getState().clearGroupRowCounts(groupPointsCacheKey); + }; + + const patchCollapsedGroupState = () => { + // a simultaneous view-query change (e.g. search toggling row hiding + // while it expands all groups) redefines the result set — drop the + // cached counts so neither this patch nor a later expand uses them + if (viewQueryScopeChanged) { + clearCachedGroupRowCounts(); + } + const knownRowCounts = cacheEnabled + ? useGridViewCacheStore.getState().cacheMap[groupPointsCacheKey]?.groupRowCounts + : undefined; + const patched = applyCollapsedGroupChange( + groupPointsRef.current, + loadedRecordMapRef.current, + new Set(outerQuery?.collapsedGroupIds), + knownRowCounts + ); + setGroupPoints(patched.groupPoints); + setLoadedRecordMap(patched.recordMap); + }; + // a scope change re-creates the subscription, which always delivers a fresh // ready event. On a view switch, seed the target view's last known rows and // group structure (session cache) — the fresh data overwrites them on ready; @@ -356,11 +410,25 @@ export const useGridAsyncRecords = ( } else { setLoadedRecordMap({}); setGroupPoints(null); + clearCachedGroupRowCounts(); } setVisiblePages(defaultVisiblePages); return; } + // collapse/expand toggle on the same view: the subscription re-creates + // (the ids ride in the query), but the new result is the same rows minus + // the collapsed ones — patch the group layout and loaded rows in place + // instead of dropping the whole grid to loading placeholders, keeping the + // scroll position and the other groups on screen. The first fresh + // delivery then replaces everything (pendingFresh) with server truth + if (collapsedGroupIdsChanged && !cacheKeyChanged) { + settledRef.current = false; + pendingFreshRef.current = true; + patchCollapsedGroupState(); + return; + } + if (!viewQueryScopeChanged) return; // a view switch can land here too (same records scope, new viewId): the @@ -383,6 +451,7 @@ export const useGridAsyncRecords = ( // and the server pushes nothing when the new result set equals the old one // — keep the current page (still correct in that case, diff events // overwrite it otherwise) and only drop the retained entries + clearCachedGroupRowCounts(); const startIndex = lastMergedSkipRef.current; setLoadedRecordMap(() => records.reduce((acc, record, i) => { @@ -393,6 +462,8 @@ export const useGridAsyncRecords = ( }, [ recordsScopeKey, viewQueryScopeKey, + collapsedGroupIdsKey, + outerQuery, records, extra, groupPointsCacheKey, diff --git a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-columns.tsx b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-columns.tsx index ef6984f1d1..782a2cc26d 100644 --- a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-columns.tsx +++ b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-columns.tsx @@ -30,6 +30,7 @@ import { } from '../../../hooks'; import type { IFieldInstance, NumberField, Record as IRecordModel } from '../../../model'; import type { GridView } from '../../../model/view'; +import { normalizeCellValueForDisplay } from '../../../utils/normalize-cell-value'; import { getDisplayChoiceMap } from '../../../utils/select-color'; import { isMarkdownShowAs, stripMarkdown } from '../../editor/long-text/utils'; import { getFilterFieldIds } from '../../filter/view-filter/utils'; @@ -264,9 +265,11 @@ export const useCreateCellValue2GridDisplay = ( cellValueType, } = field; - let cellValue = record.getCellValue(fieldId); - const validateCellValue = field.validateCellValue(cellValue); - cellValue = validateCellValue.success ? validateCellValue.data : undefined; + // Normalize against the display field instance (not record.fieldMap): after + // singleSelect ↔ multipleSelect converts, docs may still hold the previous + // shape while columns already use the new type. Strict validate-only would + // blank the cell even though copy/paste still works (T6459). + const cellValue = normalizeCellValueForDisplay(field, record.fields[fieldId]); const recordReadOnly = !recordEditable && !isPrefilling; const fieldLocked = record.isLocked(fieldId) && !isPrefilling; const readonly = isComputed || recordReadOnly || fieldLocked; diff --git a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-group-collection.ts b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-group-collection.ts index 958c8e6778..c0eba99ed1 100644 --- a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-group-collection.ts +++ b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-group-collection.ts @@ -6,6 +6,7 @@ import { useCallback, useMemo } from 'react'; import { useTranslation } from '../../../context/app/i18n/useTranslation'; import { useFields, useView } from '../../../hooks'; import type { IFieldInstance } from '../../../model'; +import { normalizeCellValueForDisplay } from '../../../utils/normalize-cell-value'; import { getDisplayChoiceMap } from '../../../utils/select-color'; import { getFileCover, isSystemFileIcon } from '../../editor'; import { GRID_DEFAULT } from '../../grid/configs'; @@ -61,13 +62,15 @@ const useGenerateGroupCellFn = () => { const { id: fieldId, type, isMultipleCellValue: isMultiple, cellValueType } = field; const emptyStr = '(Empty)'; - const validateCellValue = - field.cellValueType === CellValueType.DateTime - ? validateDateFieldValueLoose(_cellValue, field.isMultipleCellValue) - : field.validateCellValue(_cellValue); - const cellValue = ( - validateCellValue.success ? validateCellValue.data : undefined - ) as unknown; + // Same transitional-shape handling as the grid cell path (T6459). + // Date fields keep the loose validator used for group headers. + let cellValue: unknown; + if (field.cellValueType === CellValueType.DateTime) { + const validated = validateDateFieldValueLoose(_cellValue, field.isMultipleCellValue); + cellValue = validated.success ? validated.data : undefined; + } else { + cellValue = normalizeCellValueForDisplay(field, _cellValue); + } if (cellValue == null) { return { diff --git a/packages/sdk/src/components/grid-enhancements/store/type.ts b/packages/sdk/src/components/grid-enhancements/store/type.ts index 4d49566532..5dc280a97b 100644 --- a/packages/sdk/src/components/grid-enhancements/store/type.ts +++ b/packages/sdk/src/components/grid-enhancements/store/type.ts @@ -20,6 +20,7 @@ export interface IRecordMenu { isMultipleSelected?: boolean; position: IPosition; deleteRecords?: () => Promise; + archiveRecords?: () => Promise; insertRecord?: (anchorId: string, position: 'before' | 'after', num: number) => void; duplicateRecord?: () => Promise; copyRecordUrl?: () => Promise; diff --git a/packages/sdk/src/components/grid-enhancements/store/useGridViewCacheStore.ts b/packages/sdk/src/components/grid-enhancements/store/useGridViewCacheStore.ts index 2ec71d0f6d..e5964af566 100644 --- a/packages/sdk/src/components/grid-enhancements/store/useGridViewCacheStore.ts +++ b/packages/sdk/src/components/grid-enhancements/store/useGridViewCacheStore.ts @@ -1,6 +1,7 @@ import type { IRecord } from '@teable/core'; import type { IGroupHeaderRef, IGroupPointsVo } from '@teable/openapi'; import { create } from 'zustand'; +import type { IGroupRowCountMap } from '../../../utils/collapsed-group'; const MAX_CACHE_ENTRIES = 10; export const MAX_SNAPSHOT_ROWS = 100; @@ -10,12 +11,13 @@ export const MAX_SNAPSHOT_BYTES = 512 * 1024; // grouping by a high-cardinality field can produce one point per record; // caching such a structure buys little (the flat->grouped reflow it prevents // is proportionally tiny) and costs the most memory, so treat it as uncacheable -const MAX_POINTS_PER_ENTRY = 5000; +export const MAX_POINTS_PER_ENTRY = 5000; interface IGridViewCacheEntry { groupPoints?: IGroupPointsVo; groupHeaderRefs?: IGroupHeaderRef[]; rows?: IRecord[]; + groupRowCounts?: IGroupRowCountMap; } interface IGridViewCacheState { @@ -40,6 +42,14 @@ interface IGridViewCacheState { // empty rows CLEAR the facet: the caller only passes [] when the view has // settled empty, meaning the previous snapshot is known to be obsolete setRows: (key: string, rows: IRecord[]) => void; + // fresh counts MERGE over the previous map: currently collapsed groups are + // absent from fresh group points and keep their last known value — the + // value the expand patch needs to restore the group's row block in place + mergeGroupRowCounts: (key: string, counts: IGroupRowCountMap) => void; + // filter/sort/search changes redefine what "visible rows" means; counts + // collected under the previous result set must not place rows under the new + // one, so the caller clears the facet on same-view scope changes + clearGroupRowCounts: (key: string) => void; } type ICacheMap = Record; @@ -101,4 +111,18 @@ export const useGridViewCacheStore = create()((set) => ({ } return { cacheMap: upsert(state.cacheMap, key, { rows: rows.slice(0, MAX_SNAPSHOT_ROWS) }) }; }), + mergeGroupRowCounts: (key, counts) => + set((state) => { + if (!Object.keys(counts).length) return state; + const merged = { ...state.cacheMap[key]?.groupRowCounts, ...counts }; + // degenerate cardinality: keep only the fresh counts instead of + // accumulating an unbounded map of long-gone group ids + const groupRowCounts = Object.keys(merged).length > MAX_POINTS_PER_ENTRY ? counts : merged; + return { cacheMap: upsert(state.cacheMap, key, { groupRowCounts }) }; + }), + clearGroupRowCounts: (key) => + set((state) => { + const next = dropFacets(state.cacheMap, key, ['groupRowCounts']); + return next ? { cacheMap: next } : state; + }), })); diff --git a/packages/sdk/src/components/grid/components/editor/EditorContainer.tsx b/packages/sdk/src/components/grid/components/editor/EditorContainer.tsx index 5b8697a134..f2951955b4 100644 --- a/packages/sdk/src/components/grid/components/editor/EditorContainer.tsx +++ b/packages/sdk/src/components/grid/components/editor/EditorContainer.tsx @@ -146,8 +146,9 @@ export const EditorContainerBase: ForwardRefRenderFunction< initialSearchRef.current = ''; requestAnimationFrame(() => { - // Don't steal focus from dialogs/modals/sheets - if (document.activeElement?.closest('[role="dialog"]')) return; + // Don't steal focus from dialogs/modals/sheets — unless this grid itself lives inside it + const dialog = document.activeElement?.closest('[role="dialog"]'); + if (dialog && !dialog.contains(defaultFocusRef.current)) return; (editorRef.current || defaultFocusRef.current)?.focus?.(); }); }, [cellType, activeCell, selection, isEditing]); diff --git a/packages/sdk/src/components/hooks/useAttachmentPreviewI18Map.ts b/packages/sdk/src/components/hooks/useAttachmentPreviewI18Map.ts index 239561c3da..11102f6085 100644 --- a/packages/sdk/src/components/hooks/useAttachmentPreviewI18Map.ts +++ b/packages/sdk/src/components/hooks/useAttachmentPreviewI18Map.ts @@ -7,6 +7,8 @@ export const useAttachmentPreviewI18Map = () => { () => ({ previewFileLimit: t('preview.previewFileLimit', { size: 10 }), loadFileError: t('preview.loadFileError'), + previousAttachment: t('preview.previousAttachment'), + nextAttachment: t('preview.nextAttachment'), // Text preview caps at 1MB (~500k chars) — anything larger strains
       // rendering and is better served by a download. Reuses the same
       // translation template so we don't churn 10 locale files.
diff --git a/packages/sdk/src/components/index.ts b/packages/sdk/src/components/index.ts
index 89a72b80b0..f446ef250f 100644
--- a/packages/sdk/src/components/index.ts
+++ b/packages/sdk/src/components/index.ts
@@ -12,6 +12,7 @@ export * from './grid-enhancements';
 export * from './select-field-dialog';
 export * from './search';
 export * from './record-list';
+export * from './record-snapshot-grid';
 export * from './create-record';
 export * from './ReadOnlyTip';
 export * from './collaborator';
diff --git a/packages/sdk/src/components/record-snapshot-grid/RecordSnapshotExpandDialog.tsx b/packages/sdk/src/components/record-snapshot-grid/RecordSnapshotExpandDialog.tsx
new file mode 100644
index 0000000000..04a3c9db27
--- /dev/null
+++ b/packages/sdk/src/components/record-snapshot-grid/RecordSnapshotExpandDialog.tsx
@@ -0,0 +1,55 @@
+import type { IRecord } from '@teable/core';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@teable/ui-lib';
+import type { ReactNode } from 'react';
+import { useMemo } from 'react';
+import { useTranslation } from '../../context/app/i18n';
+import type { IFieldInstance } from '../../model';
+import { CellValue } from '../cell-value';
+
+export interface IRecordSnapshotExpandDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  title: string;
+  // Contextual line above the field list, e.g. "deleted time · deleted by".
+  meta?: ReactNode;
+  fields: IFieldInstance[];
+  // The caller keeps the record set while the close animation plays; only `open`
+  // drives the dialog, otherwise the closing dialog flashes empty.
+  record?: IRecord;
+}
+
+export const RecordSnapshotExpandDialog = (props: IRecordSnapshotExpandDialogProps) => {
+  const { open, onOpenChange, title, meta, fields, record } = props;
+  const { t } = useTranslation();
+
+  const fieldValues = useMemo(() => {
+    if (!record) return [];
+    return fields.map((field) => {
+      const validated = field.validateCellValue(record.fields[field.id]);
+      return { field, cellValue: validated.success ? validated.data : undefined };
+    });
+  }, [record, fields]);
+
+  return (
+    
+      
+        
+          {title}
+        
+        
+ {meta} + {fieldValues.map(({ field, cellValue }) => ( +
+
{field.name}
+ {cellValue != null ? ( + + ) : ( + {t('common.empty')} + )} +
+ ))} +
+
+
+ ); +}; diff --git a/packages/sdk/src/components/record-snapshot-grid/RecordSnapshotGrid.tsx b/packages/sdk/src/components/record-snapshot-grid/RecordSnapshotGrid.tsx new file mode 100644 index 0000000000..d0fbdbf553 --- /dev/null +++ b/packages/sdk/src/components/record-snapshot-grid/RecordSnapshotGrid.tsx @@ -0,0 +1,298 @@ +import type { IRecord } from '@teable/core'; +import { stringifyClipboardText } from '@teable/core'; +import { Skeleton, sonner } from '@teable/ui-lib'; +import type { MutableRefObject } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useIsHydrated } from '../../hooks/use-is-hydrated'; +import type { IFieldInstance } from '../../model'; +import { createRecordInstance } from '../../model'; +import type { + CombinedSelection, + ICell, + ICellItem, + IGridColumn, + IGridRef, + IRowControlItem, +} from '../grid'; +import { + CellType, + DraggableType, + Grid, + RowControlType, + SelectableType, + SelectionRegionType, +} from '../grid'; +import { GRID_DEFAULT } from '../grid/configs'; +import { useCreateCellValue2GridDisplay } from '../grid-enhancements/hooks/use-grid-columns'; +import { useGridIcons } from '../grid-enhancements/hooks/use-grid-icons'; +import { useGridTheme } from '../grid-enhancements/hooks/use-grid-theme'; + +const { toast } = sonner; + +const CLIPBOARD_TEXT_TYPE = 'text/plain'; +const DEFAULT_COLUMN_WIDTH = 150; +const LOAD_MORE_THRESHOLD = 30; +const DEFAULT_ROW_CONTROLS: IRowControlItem[] = [{ type: RowControlType.Expand }]; + +export interface IRecordSnapshotSystemColumn { + id: string; + name: string; + width?: number; + getCellText: (item: TItem) => string; +} + +export interface IRecordSnapshotGridProps { + // Already filtered by the caller (e.g. canReadFieldRecord). + fields: IFieldInstance[]; + rowCount: number; + // undefined → not loaded yet (loading cell); null → the row is permanently absent + // (e.g. a restored record), rendered as a blank row. + getItem: (rowIndex: number) => TItem | null | undefined; + getRecord: (item: TItem) => IRecord; + // Frozen leading columns; freeze count equals its length. + systemColumns: IRecordSnapshotSystemColumn[]; + isLoading?: boolean; + // Accumulate-mode driver: called when the loaded tail approaches the viewport. + // The caller guards hasNextPage/isFetching before actually fetching. + onLoadMore?: () => void; + // Window-mode driver: reports the visible row range so the caller can fetch/evict + // pages around it. + onVisibleRangeChanged?: (range: { y: number; height: number }) => void; + emptyText: string; + copySuccessText?: string; + selectable?: SelectableType; + rowControls?: IRowControlItem[]; + onSelectionChanged?: (selection: CombinedSelection) => void; + onRowExpand?: (item: TItem) => void; + gridRef?: MutableRefObject; +} + +export function RecordSnapshotGrid(props: IRecordSnapshotGridProps) { + const { + fields, + rowCount, + getItem, + getRecord, + systemColumns, + isLoading, + onLoadMore, + onVisibleRangeChanged, + emptyText, + copySuccessText, + selectable = SelectableType.Cell, + rowControls = DEFAULT_ROW_CONTROLS, + onSelectionChanged, + onRowExpand, + gridRef, + } = props; + const isHydrated = useIsHydrated(); + const theme = useGridTheme(); + const customIcons = useGridIcons(); + const systemColumnCount = systemColumns.length; + + // Fetched items keep their identity across window updates, so cached record instances + // survive scrolling instead of re-instantiating every visible row. + const recordCacheRef = useRef(new WeakMap>()); + const getRecordInstance = useCallback( + (item: TItem) => { + const cached = recordCacheRef.current.get(item); + if (cached) { + return cached; + } + const record = createRecordInstance(getRecord(item)); + recordCacheRef.current.set(item, record); + return record; + }, + [getRecord] + ); + + const columns = useMemo( + () => [ + ...systemColumns.map(({ id, name, width }) => ({ + id, + name, + width: width ?? DEFAULT_COLUMN_WIDTH, + })), + ...fields.map((field) => ({ + id: field.id, + name: field.name, + width: DEFAULT_COLUMN_WIDTH, + })), + ], + [systemColumns, fields] + ); + + const createCellValue2GridDisplay = useCreateCellValue2GridDisplay(); + const cellValue2GridDisplay = useMemo( + () => createCellValue2GridDisplay(fields), + [createCellValue2GridDisplay, fields] + ); + + const getCellContent = useCallback<(cell: ICellItem) => ICell>( + (cell) => { + const [colIndex, rowIndex] = cell; + const item = getItem(rowIndex); + if (item === undefined) return { type: CellType.Loading }; + + const systemColumn = systemColumns[colIndex]; + const cellId = `${rowIndex}-${columns[colIndex]?.id}`; + if (item === null) { + return { id: cellId, type: CellType.Text, data: '', displayData: '', readonly: true }; + } + if (systemColumn) { + const text = systemColumn.getCellText(item); + return { id: cellId, type: CellType.Text, data: text, displayData: text, readonly: true }; + } + return cellValue2GridDisplay(getRecordInstance(item), colIndex - systemColumnCount); + }, + [getItem, getRecordInstance, systemColumns, systemColumnCount, columns, cellValue2GridDisplay] + ); + + // The grid only reports its visible region on scroll, never on initial render, so an + // under-filled first page would stall accumulate-mode loading (no scrollbar → no scroll + // events). Fall back to estimating the viewport from the container height until a real + // region arrives, and re-check after every page append. + const containerRef = useRef(null); + const lastVisibleRegionRef = useRef<{ y: number; height: number } | null>(null); + + const checkLoadMore = useCallback(() => { + if (!onLoadMore) return; + const rect = lastVisibleRegionRef.current ?? { + y: 0, + height: Math.ceil((containerRef.current?.clientHeight ?? 0) / GRID_DEFAULT.rowHeight), + }; + if (rect.y + rect.height >= rowCount - LOAD_MORE_THRESHOLD) { + onLoadMore(); + } + }, [onLoadMore, rowCount]); + + const onVisibleRegionChanged = useCallback( + (rect: { y: number; height: number }) => { + lastVisibleRegionRef.current = { y: rect.y, height: rect.height }; + checkLoadMore(); + onVisibleRangeChanged?.({ y: rect.y, height: rect.height }); + }, + [checkLoadMore, onVisibleRangeChanged] + ); + + useEffect(() => { + checkLoadMore(); + }, [checkLoadMore]); + + // Read-only grid: copy resolves locally from the loaded snapshots, in the visible + // column order (system columns first, then fields). + const copyCellText = useCallback( + (colIndex: number, rowIndex: number): string => { + const item = getItem(rowIndex); + if (item == null) return ''; + const systemColumn = systemColumns[colIndex]; + if (systemColumn) return systemColumn.getCellText(item); + const field = fields[colIndex - systemColumnCount]; + if (!field) return ''; + const record = getRecordInstance(item); + return field.cellValue2String(record.fields[field.id] as never); + }, + [getItem, getRecordInstance, systemColumns, systemColumnCount, fields] + ); + + const onCopy = useCallback( + // eslint-disable-next-line sonarjs/cognitive-complexity + (selection: CombinedSelection, e: React.ClipboardEvent) => { + const columnCount = systemColumnCount + fields.length; + const content: string[][] = []; + if (selection.type === SelectionRegionType.Cells) { + const [[startCol, startRow], [endCol, endRow]] = selection.serialize(); + for (let rowIndex = startRow; rowIndex <= endRow; rowIndex++) { + const row: string[] = []; + for (let colIndex = startCol; colIndex <= endCol; colIndex++) { + row.push(copyCellText(colIndex, rowIndex)); + } + content.push(row); + } + } else if (selection.type === SelectionRegionType.Rows) { + for (const [start, end] of selection.serialize()) { + for (let rowIndex = start; rowIndex <= end; rowIndex++) { + const row: string[] = []; + for (let colIndex = 0; colIndex < columnCount; colIndex++) { + row.push(copyCellText(colIndex, rowIndex)); + } + content.push(row); + } + } + } else if (selection.type === SelectionRegionType.Columns) { + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + const row: string[] = []; + for (const [start, end] of selection.serialize()) { + for (let colIndex = start; colIndex <= end; colIndex++) { + row.push(copyCellText(colIndex, rowIndex)); + } + } + content.push(row); + } + } else { + return; + } + if (content.length === 0) return; + e.clipboardData.setData(CLIPBOARD_TEXT_TYPE, stringifyClipboardText(content)); + e.preventDefault(); + if (copySuccessText) { + toast.success(copySuccessText); + } + }, + [systemColumnCount, fields.length, rowCount, copyCellText, copySuccessText] + ); + + const handleRowExpand = useCallback( + (rowIndex: number) => { + const item = getItem(rowIndex); + if (item != null && onRowExpand) { + onRowExpand(item); + } + }, + [getItem, onRowExpand] + ); + + return ( +
+ {isHydrated && ( + + )} + {isLoading && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + + + +
+ ))} +
+ )} + {!isLoading && rowCount === 0 && ( +
+ {emptyText} +
+ )} +
+ ); +} diff --git a/packages/sdk/src/components/record-snapshot-grid/index.ts b/packages/sdk/src/components/record-snapshot-grid/index.ts new file mode 100644 index 0000000000..bdb63efc4b --- /dev/null +++ b/packages/sdk/src/components/record-snapshot-grid/index.ts @@ -0,0 +1,3 @@ +export * from './RecordSnapshotGrid'; +export * from './RecordSnapshotExpandDialog'; +export * from './use-record-snapshot-fields'; diff --git a/packages/sdk/src/components/record-snapshot-grid/use-record-snapshot-fields.ts b/packages/sdk/src/components/record-snapshot-grid/use-record-snapshot-fields.ts new file mode 100644 index 0000000000..9923a6c8df --- /dev/null +++ b/packages/sdk/src/components/record-snapshot-grid/use-record-snapshot-fields.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query'; +import { getFields } from '@teable/openapi'; +import { useMemo } from 'react'; +import { ReactQueryKeys } from '../../config'; +import { createFieldInstance } from '../../model'; + +// Field instances a snapshot viewer (trash records / archive) may render: every +// field the caller can read, in field-list order. +export const useRecordSnapshotFields = (tableId: string, enabled = true) => { + const { data: fieldsData } = useQuery({ + queryKey: ReactQueryKeys.fieldList(tableId), + queryFn: ({ queryKey }) => getFields(queryKey[1]).then((res) => res.data), + enabled: Boolean(tableId) && enabled, + }); + + return useMemo( + () => + (fieldsData ?? []) + .map((field) => createFieldInstance(field)) + .filter((field) => field.canReadFieldRecord), + [fieldsData] + ); +}; diff --git a/packages/sdk/src/config/local-storage-keys.ts b/packages/sdk/src/config/local-storage-keys.ts index 46a5289998..7fe172934c 100644 --- a/packages/sdk/src/config/local-storage-keys.ts +++ b/packages/sdk/src/config/local-storage-keys.ts @@ -27,5 +27,4 @@ export enum LocalStorageKeys { WinCreditTriggerVisible = 'ls_win_credit_trigger_visible', Sidebar = 'ls_sidebar', SpaceBaseListViewMode = 'ls_space_base_list_view_mode', - DismissedChangelog = 'ls_dismissed_changelog', } diff --git a/packages/sdk/src/config/react-query-keys.ts b/packages/sdk/src/config/react-query-keys.ts index 7c8cb2a01e..f3597cad38 100644 --- a/packages/sdk/src/config/react-query-keys.ts +++ b/packages/sdk/src/config/react-query-keys.ts @@ -26,7 +26,10 @@ import type { IRecordInsertOrderRo, IUpdateRecordOrdersRo, IRecordGetCollaboratorsRo, + IGetArchiveItemsQuery, IGetRecordHistoryQuery, + IGetTrashItemRecordsQuery, + ITableTrashItemsFilter, TrashType, } from '@teable/openapi'; @@ -204,7 +207,22 @@ export const ReactQueryKeys = { getSpaceTrash: (resourceType: TrashType, spaceId?: string) => ['space-trash', resourceType, spaceId] as const, - getTrashItems: (resourceId: string) => ['trash-items', resourceId] as const, + // Without `query` the key is a prefix that matches every filter variant — use it for + // invalidation. + getTrashItemRecords: (trashId: string, query?: Omit) => + query + ? (['trash-item-records', trashId, query] as const) + : (['trash-item-records', trashId] as const), + + // Without `query` the key is a prefix that matches every query variant — use it for + // invalidation. + getTrashItems: (resourceId: string, query?: ITableTrashItemsFilter) => + query ? (['trash-items', resourceId, query] as const) : (['trash-items', resourceId] as const), + + // Without `query` the key is a prefix that matches every query variant — use it for + // invalidation. + getArchiveItems: (tableId: string, query?: IGetArchiveItemsQuery) => + query ? (['archive-items', tableId, query] as const) : (['archive-items', tableId] as const), getDashboardList: (baseId: string) => ['dashboard-list', baseId] as const, @@ -246,6 +264,12 @@ export const ReactQueryKeys = { userLastVisitMap: (baseId: string) => ['user-last-visit-map', baseId] as const, + // prefix-matched by ['base-entry-map'] in useEnterBase — keep the first + // segment stable + baseEntryMap: (spaceId: string) => ['base-entry-map', spaceId] as const, + + pinEntryMap: () => ['pin-entry-map'] as const, + getTaskStatusCollection: (tableId: string) => ['task-status-collection', tableId] as const, chatHistory: (baseId: string) => ['chat-history', baseId] as const, diff --git a/packages/sdk/src/context/app/queryClient.spec.ts b/packages/sdk/src/context/app/queryClient.spec.ts index 63a5a79353..7cddc43dfe 100644 --- a/packages/sdk/src/context/app/queryClient.spec.ts +++ b/packages/sdk/src/context/app/queryClient.spec.ts @@ -19,7 +19,7 @@ import ukTable from '@teable/common-i18n/src/locales/uk/table.json'; import zhSdk from '@teable/common-i18n/src/locales/zh/sdk.json'; import zhTable from '@teable/common-i18n/src/locales/zh/table.json'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { tableI18nKeys } from '../../../../i18n-keys/src'; +import { sdkErrorI18nKeys, tableI18nKeys } from '../../../../i18n-keys/src'; import type { ILocaleFunction } from './i18n'; import { errorRequestHandler, getHttpErrorMessage } from './queryClient'; @@ -110,7 +110,8 @@ describe('sdk table data safety limit locale coverage', () => { ); }); -describe('sdk validation error locale coverage', () => { +describe('sdk v2 error message locale coverage', () => { + const expectedKeys = collectLeafValues(sdkErrorI18nKeys); const locales = { de: deSdk, en: enSdk, @@ -124,98 +125,105 @@ describe('sdk validation error locale coverage', () => { zh: zhSdk, }; - it.each(Object.entries(locales))( - 'covers unique field validation message in %s', + const readMessage = (sdk: unknown, i18nKey: string): string | undefined => { + const message = i18nKey + .split('.') + .reduce((node, part) => (node as Record | undefined)?.[part], sdk); + return typeof message === 'string' ? message : undefined; + }; + + const placeholdersOf = (message: string): string[] => + [...message.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)].map((match) => match[1]).sort(); + + it.each(Object.entries(locales))('covers all v2 error messages in %s', (_locale, sdk) => { + expect(expectedKeys.filter((key) => !readMessage(sdk, key))).toEqual([]); + }); + + // Throw sites build the localization context from the English message's + // placeholders; every translation must interpolate the same set or the + // client-side lodash template throws and the toast is silently dropped. + it.each(Object.entries(locales).filter(([locale]) => locale !== 'en'))( + 'uses the same interpolation placeholders as en in %s', (_locale, sdk) => { - expect(sdk.httpErrors.validation.field.unique).toBeTruthy(); + for (const key of expectedKeys) { + expect(placeholdersOf(readMessage(sdk, key) ?? ''), key).toEqual( + placeholdersOf(readMessage(enSdk, key) ?? '') + ); + } } ); }); const t: ILocaleFunction = ((key: string, options?: Record) => { - if (key === 'httpErrors.validation.field.unique') { + if (key.endsWith('httpErrors.custom.recordFieldValueDuplicate')) { return `${key}:${options?.fieldName ?? ''}`; } - if (key === 'sdk:httpErrors.limit.nameMaxLength') { + if (key.endsWith('httpErrors.limit.nameMaxLength')) { return `${key}:${options?.max}`; } - if (key === 'sdk:httpErrors.validation.field.unique') { - return `${key}:${options?.fieldName ?? ''}`; - } return key; }) as ILocaleFunction; describe('getHttpErrorMessage', () => { - it('localizes v2 table data safety validation limit errors by domain code', () => { + it('translates the localization the server attached', () => { const message = getHttpErrorMessage( { - message: 'Table data safety limit exceeded: validation.limit.name_max_length', + message: 'Cannot complete update: field fldEmail must have a unique value', data: { - domainCode: 'validation.limit.name_max_length', - details: { max: 100 }, + domainCode: 'validation.field.unique', + details: { fieldId: 'fldEmail', fieldName: 'Email' }, + localization: { + i18nKey: 'httpErrors.custom.recordFieldValueDuplicate', + context: { fieldName: 'Email' }, + }, }, }, t, 'sdk' ); - expect(message).toBe('sdk:httpErrors.limit.nameMaxLength:100'); + expect(message).toBe('sdk:httpErrors.custom.recordFieldValueDuplicate:Email'); }); - it('falls back to the server message for unknown validation limit keys', () => { + it('translates the localization without a namespace prefix', () => { const message = getHttpErrorMessage( { - message: 'fallback', + message: 'Cannot complete update: field fldEmail must have a unique value', data: { - domainCode: 'validation.limit.unknown_limit', - details: { max: 1 }, + localization: { + i18nKey: 'httpErrors.custom.recordFieldValueDuplicate', + context: { fieldName: 'Email' }, + }, }, }, - t, - 'sdk' + t ); - expect(message).toBe('fallback'); + expect(message).toBe('httpErrors.custom.recordFieldValueDuplicate:Email'); }); - it('localizes v2 validation errors by domain code', () => { + it('translates table data safety limit localizations', () => { const message = getHttpErrorMessage( { - message: 'Cannot complete update: field fldEmail must have a unique value', + message: 'Table data safety limit exceeded: validation.limit.name_max_length', data: { - domainCode: 'validation.field.unique', - details: { fieldName: 'Email' }, + domainCode: 'validation.limit.name_max_length', + details: { attempted: 120, max: 100 }, + localization: { i18nKey: 'httpErrors.limit.nameMaxLength', context: { max: 100 } }, }, }, t, 'sdk' ); - expect(message).toBe('sdk:httpErrors.validation.field.unique:Email'); - }); - - it('localizes v2 validation errors by domain code without namespace prefix', () => { - const message = getHttpErrorMessage( - { - message: 'Cannot complete update: field fldEmail must have a unique value', - data: { - domainCode: 'validation.field.unique', - details: { fieldName: 'Email' }, - }, - }, - t - ); - - expect(message).toBe('httpErrors.validation.field.unique:Email'); + expect(message).toBe('sdk:httpErrors.limit.nameMaxLength:100'); }); - it('falls back to the server message for unknown domain code keys', () => { + it('falls back to the server message when no localization is attached', () => { const message = getHttpErrorMessage( { message: 'fallback', - data: { - domainCode: 'validation.field.unknown', - }, + data: { domainCode: 'validation.field.invalid_value', details: { fieldId: 'fldabc' } }, }, t, 'sdk' diff --git a/packages/sdk/src/context/app/queryClient.tsx b/packages/sdk/src/context/app/queryClient.tsx index 9c745f9be8..b9a208e17e 100644 --- a/packages/sdk/src/context/app/queryClient.tsx +++ b/packages/sdk/src/context/app/queryClient.tsx @@ -35,48 +35,6 @@ export function toCamelCaseErrorCode(errorCode: string): string { .join(''); } -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null; - -const getValidationLimitMessage = ( - data: ICustomHttpExceptionData | undefined, - t: ILocaleFunction, - prefix?: string -) => { - const domainCode = data?.domainCode; - if (typeof domainCode !== 'string' || !domainCode.startsWith('validation.limit.')) { - return; - } - - const limitKey = toCamelCaseErrorCode(domainCode.slice('validation.limit.'.length)); - const key = `httpErrors.limit.${limitKey}`; - const prefixedKey = prefix ? `${prefix}:${key}` : key; - const details = isRecord(data?.details) ? data.details : {}; - const message = t(prefixedKey as TKey, details); - return typeof message === 'string' && message !== prefixedKey && message !== key - ? message - : undefined; -}; - -const getDomainCodeMessage = ( - data: ICustomHttpExceptionData | undefined, - t: ILocaleFunction, - prefix?: string -) => { - const domainCode = data?.domainCode; - if (typeof domainCode !== 'string') { - return; - } - - const key = `httpErrors.${domainCode}`; - const prefixedKey = prefix ? `${prefix}:${key}` : key; - const details = isRecord(data?.details) ? data.details : {}; - const message = t(prefixedKey as TKey, details); - return typeof message === 'string' && message !== prefixedKey && message !== key - ? message - : undefined; -}; - export const getLocalizationMessage = ( localization: ILocalization, t: ILocaleFunction, @@ -89,17 +47,8 @@ export const getLocalizationMessage = ( export const getHttpErrorMessage = (error: unknown, t: ILocaleFunction, prefix?: string) => { const { message, data } = error as IHttpError; - const customData = (data as ICustomHttpExceptionData) || {}; - const limitMessage = getValidationLimitMessage(customData, t, prefix); - if (limitMessage) return limitMessage; - - const { localization } = customData; - if (localization) return getLocalizationMessage(localization, t, prefix); - - const domainCodeMessage = getDomainCodeMessage(customData, t, prefix); - if (domainCodeMessage) return domainCodeMessage; - - return message; + const { localization } = (data as ICustomHttpExceptionData) || {}; + return localization ? getLocalizationMessage(localization, t, prefix) : message; }; const handleNetworkError = (t?: ILocaleFunction): boolean => { @@ -135,13 +84,19 @@ const dedupeValidationError = (message: string): boolean => { return false; }; -const handleStatusRedirect = (status: number): boolean => { +const handleStatusRedirect = (status: number, code?: string): boolean => { if (status === 401) { window.location.href = `/auth/login?redirect=${encodeURIComponent(window.location.href)}`; return true; } if (status === 402) { - useUsageLimitModalStore.setState({ modalType: UsageLimitModalType.Upgrade, modalOpen: true }); + // Credit exhaustion opens the purchase-credits modal; other 402s (plan + // limits, PAYMENT_REQUIRED) keep the plan-upgrade modal. + const modalType = + code === HttpErrorCode.CREDIT_LIMIT_EXCEEDED + ? UsageLimitModalType.CreditInsufficient + : UsageLimitModalType.Upgrade; + useUsageLimitModalStore.setState({ modalType, modalOpen: true }); return true; } if (status === 460) { @@ -167,7 +122,7 @@ export const errorRequestHandler = ( return; } - if (handleStatusRedirect(status)) { + if (handleStatusRedirect(status, code)) { return; } diff --git a/packages/sdk/src/context/app/useConnection.tsx b/packages/sdk/src/context/app/useConnection.tsx index 75d6307b15..2d809422fe 100644 --- a/packages/sdk/src/context/app/useConnection.tsx +++ b/packages/sdk/src/context/app/useConnection.tsx @@ -23,7 +23,7 @@ const shareDbErrorHandler = (error: unknown) => { window.location.reload(); return; } - if (ignoreErrorCodes) { + if (ignoreErrorCodes.includes(code)) { return; } toast({ title: 'Socket Error', variant: 'destructive', description: `${code}: ${message}` }); diff --git a/packages/sdk/src/hooks/index.ts b/packages/sdk/src/hooks/index.ts index f2f1355f65..b16f044a31 100644 --- a/packages/sdk/src/hooks/index.ts +++ b/packages/sdk/src/hooks/index.ts @@ -48,6 +48,7 @@ export { type IComputeActivityState, } from './use-compute-activity'; export * from './use-undo-redo'; +export * from './use-collaborator-filter-users'; export * from './use-comment-count-map'; export * from './use-organization'; export * from './use-personal-view'; diff --git a/packages/sdk/src/hooks/use-collaborator-filter-users.ts b/packages/sdk/src/hooks/use-collaborator-filter-users.ts new file mode 100644 index 0000000000..8cd4f134f5 --- /dev/null +++ b/packages/sdk/src/hooks/use-collaborator-filter-users.ts @@ -0,0 +1,55 @@ +import { useQuery } from '@tanstack/react-query'; +import type { IItemBaseCollaboratorUser } from '@teable/openapi'; +import { getUserCollaborators } from '@teable/openapi'; +import { useMemo, useState } from 'react'; +import { ReactQueryKeys } from '../config'; +import { useBaseId } from './use-base-id'; + +const COLLABORATOR_SEARCH_TAKE = 100; + +// Minimal user shape selected ids resolve from — IUserMapVo entries and collaborator +// items both satisfy it. +interface ICollaboratorFilterUser { + id: string; + name: string; + email?: string | null; + avatar?: string | null; +} + +// Base collaborator candidates for a filter dropdown with server-side search; +// already-selected users stay resolvable from `userMap` after the search narrows +// the candidate list. +export const useCollaboratorFilterUsers = (props: { + selectedIds: string[]; + userMap: Record; +}) => { + const { selectedIds, userMap } = props; + const baseId = useBaseId(); + const [userSearch, setUserSearch] = useState(''); + + const { data: collaboratorsData } = useQuery({ + queryKey: ReactQueryKeys.baseCollaboratorListUser(baseId as string, { + includeSystem: true, + skip: 0, + take: COLLABORATOR_SEARCH_TAKE, + search: userSearch, + }), + queryFn: ({ queryKey }) => + getUserCollaborators(queryKey[1], queryKey[2]).then((res) => res.data), + enabled: Boolean(baseId), + }); + + const users = useMemo(() => { + const map = new Map(); + selectedIds.forEach((id) => { + const user = userMap[id]; + if (user) { + map.set(id, { ...user, role: '', email: user.email ?? '' } as IItemBaseCollaboratorUser); + } + }); + collaboratorsData?.users.forEach((user) => map.set(user.id, user)); + return Array.from(map.values()); + }, [selectedIds, userMap, collaboratorsData?.users]); + + return { users, setUserSearch }; +}; diff --git a/packages/sdk/src/hooks/use-permission-actions-static.ts b/packages/sdk/src/hooks/use-permission-actions-static.ts index cfc9df84f2..0da78fc69d 100644 --- a/packages/sdk/src/hooks/use-permission-actions-static.ts +++ b/packages/sdk/src/hooks/use-permission-actions-static.ts @@ -92,6 +92,12 @@ const actionsI18nMap: Record< 'table|trash_reset': { description: 'permission.actionDescription.tableTrashReset', }, + 'table|archive_read': { + description: 'permission.actionDescription.tableArchiveRead', + }, + 'table|archive_manage': { + description: 'permission.actionDescription.tableArchiveManage', + }, 'table_record_history|read': { description: 'permission.actionDescription.recordHistoryRead', }, @@ -140,6 +146,9 @@ const actionsI18nMap: Record< 'record|copy': { description: 'permission.actionDescription.recordCopy', }, + 'record|archive': { + description: 'permission.actionDescription.recordArchive', + }, 'automation|create': { description: 'permission.actionDescription.automationCreate', }, diff --git a/packages/sdk/src/model/record/record.ts b/packages/sdk/src/model/record/record.ts index 71e2ddbd43..f3f93e62f4 100644 --- a/packages/sdk/src/model/record/record.ts +++ b/packages/sdk/src/model/record/record.ts @@ -7,6 +7,7 @@ import { isEqual, isEmpty } from 'lodash'; import type { Doc } from 'sharedb/lib/client'; import { getHttpErrorMessage } from '../../context'; import type { ILocaleFunction } from '../../context/app/i18n'; +import { normalizeCellValueForDisplay } from '../../utils/normalize-cell-value'; import type { IFieldInstance } from '../field/factory'; const { toast } = sonner; @@ -27,22 +28,7 @@ export class Record extends RecordCore { return cellValue; } - if (cellValue == null) { - return cellValue; - } - - const validated = field.validateCellValue(cellValue); - if (validated?.success) { - return validated.data; - } - - try { - const repaired = field.repair(cellValue); - const repairedValidated = field.validateCellValue(repaired); - return repairedValidated?.success ? repairedValidated.data : repaired; - } catch { - return cellValue; - } + return normalizeCellValueForDisplay(field, cellValue); } constructor( diff --git a/packages/sdk/src/model/table/table.ts b/packages/sdk/src/model/table/table.ts index 22b2942a44..08b5726fdf 100644 --- a/packages/sdk/src/model/table/table.ts +++ b/packages/sdk/src/model/table/table.ts @@ -49,7 +49,7 @@ export class Table extends TableCore { return requestWrap(updateTableDescription)(this.baseId, this.id, { description }); } - async updateIcon(icon: string) { + async updateIcon(icon: string | null) { return requestWrap(updateTableIcon)(this.baseId, this.id, { icon }); } diff --git a/packages/sdk/src/utils/collapsed-group.spec.ts b/packages/sdk/src/utils/collapsed-group.spec.ts new file mode 100644 index 0000000000..7b0284ca7e --- /dev/null +++ b/packages/sdk/src/utils/collapsed-group.spec.ts @@ -0,0 +1,217 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { IGroupPoint } from '@teable/openapi'; +import { GroupPointType } from '@teable/openapi'; +import { describe, expect, it } from 'vitest'; +import { applyCollapsedGroupChange, collectGroupRowCounts } from './collapsed-group'; + +const header = (id: string, depth = 0, isCollapsed = false): IGroupPoint => ({ + id, + type: GroupPointType.Header, + depth, + value: id, + isCollapsed, +}); + +const rows = (count: number): IGroupPoint => ({ type: GroupPointType.Row, count }); + +const mapOf = (...indexes: number[]) => + Object.fromEntries(indexes.map((index) => [index, `r${index}`])) as { + [index: number]: string; + }; + +describe('applyCollapsedGroupChange', () => { + it('collapses a group in place and shifts the rows behind it up', () => { + const points = [header('a'), rows(3), header('b'), rows(2), header('c'), rows(1)]; + const { groupPoints, recordMap } = applyCollapsedGroupChange( + points, + mapOf(0, 1, 2, 3, 4, 5), + new Set(['b']) + ); + + expect(groupPoints).toEqual([header('a'), rows(3), header('b', 0, true), header('c'), rows(1)]); + expect(recordMap).toEqual({ 0: 'r0', 1: 'r1', 2: 'r2', 3: 'r5' }); + }); + + it('expands a group, keeping rows ahead and dropping rows behind it', () => { + const points = [header('a'), rows(3), header('b', 0, true), header('c'), rows(1)]; + const { groupPoints, recordMap } = applyCollapsedGroupChange( + points, + mapOf(0, 1, 2, 3), + new Set() + ); + + expect(groupPoints).toEqual([header('a'), rows(3), header('b'), header('c'), rows(1)]); + expect(recordMap).toEqual({ 0: 'r0', 1: 'r1', 2: 'r2' }); + }); + + it('collapsing a parent drops its sub-headers and all covered rows', () => { + const points = [ + header('a', 0), + header('a1', 1), + rows(2), + header('a2', 1), + rows(1), + header('b', 0), + header('b1', 1), + rows(2), + ]; + const { groupPoints, recordMap } = applyCollapsedGroupChange( + points, + mapOf(0, 1, 2, 3, 4), + new Set(['a']) + ); + + expect(groupPoints).toEqual([header('a', 0, true), header('b', 0), header('b1', 1), rows(2)]); + expect(recordMap).toEqual({ 0: 'r3', 1: 'r4' }); + }); + + it('collapsing a sub-group keeps its peers and parent intact', () => { + const points = [header('a', 0), header('a1', 1), rows(2), header('a2', 1), rows(1)]; + const { groupPoints, recordMap } = applyCollapsedGroupChange( + points, + mapOf(0, 1, 2), + new Set(['a1']) + ); + + expect(groupPoints).toEqual([header('a', 0), header('a1', 1, true), header('a2', 1), rows(1)]); + expect(recordMap).toEqual({ 0: 'r2' }); + }); + + it('handles collapsing several groups at once', () => { + const points = [header('a'), rows(2), header('b'), rows(1)]; + const { groupPoints, recordMap } = applyCollapsedGroupChange( + points, + mapOf(0, 1, 2), + new Set(['a', 'b']) + ); + + expect(groupPoints).toEqual([header('a', 0, true), header('b', 0, true)]); + expect(recordMap).toEqual({}); + }); + + it('handles a mixed expand and collapse in one change', () => { + const points = [header('a', 0, true), header('b'), rows(2), header('c'), rows(3)]; + const { groupPoints, recordMap } = applyCollapsedGroupChange( + points, + mapOf(0, 1, 2, 3, 4), + new Set(['b']) + ); + + expect(groupPoints).toEqual([header('a'), header('b', 0, true), header('c'), rows(3)]); + // everything sits behind the expanded group "a", whose row count is unknown + expect(recordMap).toEqual({}); + }); + + it('keeps groups already collapsed on both sides untouched', () => { + const points = [header('a', 0, true), header('b'), rows(2)]; + const { groupPoints, recordMap } = applyCollapsedGroupChange( + points, + mapOf(0, 1), + new Set(['a']) + ); + + expect(groupPoints).toEqual(points); + expect(recordMap).toEqual({ 0: 'r0', 1: 'r1' }); + }); + + it('drops loaded rows outside any kept segment', () => { + const points = [header('a'), rows(2)]; + const { recordMap } = applyCollapsedGroupChange( + points, + // index 7 is beyond the grouped rows (stale tail) + mapOf(0, 1, 7), + new Set() + ); + + expect(recordMap).toEqual({ 0: 'r0', 1: 'r1' }); + }); + + it('places nothing when no layout is known yet', () => { + const { groupPoints, recordMap } = applyCollapsedGroupChange(null, mapOf(0, 1), new Set(['a'])); + + expect(groupPoints).toBeNull(); + expect(recordMap).toEqual({}); + }); + + it('restores an expanded group row block in place when its size is known', () => { + const points = [header('a', 0, true), header('b'), rows(2)]; + const { groupPoints, recordMap } = applyCollapsedGroupChange(points, mapOf(0, 1), new Set(), { + a: 3, + }); + + expect(groupPoints).toEqual([header('a'), rows(3), header('b'), rows(2)]); + // group B's rows keep their content, shifted by the restored block + expect(recordMap).toEqual({ 3: 'r0', 4: 'r1' }); + }); + + it('keeps rows behind an expanded group with a known count of zero', () => { + const points = [header('a', 0, true), header('b'), rows(2)]; + const { groupPoints, recordMap } = applyCollapsedGroupChange(points, mapOf(0, 1), new Set(), { + a: 0, + }); + + // no row block to restore — everything behind stays exactly in place + expect(groupPoints).toEqual([header('a'), header('b'), rows(2)]); + expect(recordMap).toEqual({ 0: 'r0', 1: 'r1' }); + }); + + it('keeps rows placeable up to the first expanded group of unknown size', () => { + const points = [ + header('a', 0, true), + header('b'), + rows(1), + header('c', 0, true), + header('d'), + rows(2), + ]; + const { groupPoints, recordMap } = applyCollapsedGroupChange( + points, + mapOf(0, 1, 2), + new Set(), + { a: 2 } + ); + + expect(groupPoints).toEqual([ + header('a'), + rows(2), + header('b'), + rows(1), + header('c'), + header('d'), + rows(2), + ]); + // b's row rides the known +2 shift; rows behind the unknown-size c drop + expect(recordMap).toEqual({ 2: 'r0' }); + }); +}); + +describe('collectGroupRowCounts', () => { + it('sums visible rows per group across depths', () => { + const points = [ + header('a', 0), + header('a1', 1), + rows(2), + header('a2', 1), + rows(1), + header('b', 0), + rows(4), + ]; + + expect(collectGroupRowCounts(points)).toEqual({ a: 3, a1: 2, a2: 1, b: 4 }); + }); + + it('records nothing for collapsed groups so merged caches keep their last value', () => { + const points = [header('a', 0, true), header('b'), rows(2)]; + + expect(collectGroupRowCounts(points)).toEqual({ b: 2 }); + }); + + it('records a true zero for an expanded group whose subtree is collapsed', () => { + // "a" is expanded but shows no rows: its visible count really is zero, + // and recording it overwrites a count that went stale when the children + // were collapsed + const points = [header('a', 0), header('a1', 1, true), header('b', 0), rows(2)]; + + expect(collectGroupRowCounts(points)).toEqual({ a: 0, b: 2 }); + }); +}); diff --git a/packages/sdk/src/utils/collapsed-group.ts b/packages/sdk/src/utils/collapsed-group.ts new file mode 100644 index 0000000000..bf757a1d83 --- /dev/null +++ b/packages/sdk/src/utils/collapsed-group.ts @@ -0,0 +1,155 @@ +import type { IGroupHeaderPoint, IGroupPoint } from '@teable/openapi'; +import { GroupPointType } from '@teable/openapi'; + +interface ICollapsedGroupChangeResult { + groupPoints: IGroupPoint[] | null; + recordMap: { [index: number]: T }; +} + +export type IGroupRowCountMap = { [groupId: string]: number }; + +// visible-row segment kept across the change: rows [start, end) shift by delta +interface IKeptRowSegment { + start: number; + end: number; + delta: number; +} + +interface ICollapseWalkContext { + nextCollapsedIds: ReadonlySet; + knownRowCounts?: IGroupRowCountMap; + nextGroupPoints: IGroupPoint[]; + // dropping points nested deeper than this (inside a newly collapsed group) + removeDepth: number; + // row indexes behind an expanded group of unknown size cannot be computed + unknownShift: boolean; + oldIndex: number; + newIndex: number; +} + +// point.isCollapsed is the on-screen state the row layout was built from +// (rows of flagged groups are already absent), so it is the diff baseline +const visitHeaderPoint = (point: IGroupHeaderPoint, ctx: ICollapseWalkContext): void => { + if (point.depth > ctx.removeDepth) return; + const isCollapsed = ctx.nextCollapsedIds.has(point.id); + ctx.nextGroupPoints.push(point.isCollapsed === isCollapsed ? point : { ...point, isCollapsed }); + ctx.removeDepth = isCollapsed && !point.isCollapsed ? point.depth : Number.MAX_SAFE_INTEGER; + if (isCollapsed || !point.isCollapsed) return; + + // newly expanded: restore a row block of the last known size, so everything + // behind keeps its exact position while the block itself loads; the size can + // be stale (edits while collapsed), which the authoritative delivery corrects + const knownCount = ctx.knownRowCounts?.[point.id]; + if (knownCount == null) { + ctx.unknownShift = true; + return; + } + if (knownCount > 0) { + ctx.nextGroupPoints.push({ type: GroupPointType.Row, count: knownCount }); + ctx.newIndex += knownCount; + } +}; + +const remapRecordMap = ( + recordMap: { [index: number]: T }, + segments: IKeptRowSegment[] +): { [index: number]: T } => { + const nextRecordMap: { [index: number]: T } = {}; + const indexes = Object.keys(recordMap) + .map(Number) + .sort((a, b) => a - b); + let segmentCursor = 0; + for (const index of indexes) { + while (segmentCursor < segments.length && segments[segmentCursor].end <= index) { + segmentCursor++; + } + const segment = segments[segmentCursor]; + if (segment == null || index < segment.start) continue; + nextRecordMap[index + segment.delta] = recordMap[index]; + } + return nextRecordMap; +}; + +// Locally derive the group layout and loaded-row placement after a +// collapse/expand toggle, mirroring what the server would return for the new +// collapsedGroupIds. Collapsing is fully computable (the covered points and +// row counts are on hand). Expanding restores the group's last known row +// count from knownRowCounts when available; without one, its hidden size is +// unknown, so loaded rows behind the first such group are dropped rather +// than misplaced. Rows outside any kept segment (including a stale tail +// beyond the grouped rows) are dropped for the same reason: data whose +// position cannot be computed must not be placed. +export const applyCollapsedGroupChange = ( + groupPoints: IGroupPoint[] | null, + recordMap: { [index: number]: T }, + nextCollapsedIds: ReadonlySet, + knownRowCounts?: IGroupRowCountMap +): ICollapsedGroupChangeResult => { + // no known layout — nothing is placeable + if (groupPoints == null) return { groupPoints: null, recordMap: {} }; + + const keptSegments: IKeptRowSegment[] = []; + const ctx: ICollapseWalkContext = { + nextCollapsedIds, + knownRowCounts, + nextGroupPoints: [], + removeDepth: Number.MAX_SAFE_INTEGER, + unknownShift: false, + oldIndex: 0, + newIndex: 0, + }; + + for (const point of groupPoints) { + if (point.type === GroupPointType.Header) { + visitHeaderPoint(point, ctx); + continue; + } + + const { count } = point; + if (ctx.removeDepth !== Number.MAX_SAFE_INTEGER) { + ctx.oldIndex += count; + continue; + } + ctx.nextGroupPoints.push(point); + if (!ctx.unknownShift) { + keptSegments.push({ + start: ctx.oldIndex, + end: ctx.oldIndex + count, + delta: ctx.newIndex - ctx.oldIndex, + }); + } + ctx.oldIndex += count; + ctx.newIndex += count; + } + + return { + groupPoints: ctx.nextGroupPoints, + recordMap: remapRecordMap(recordMap, keptSegments), + }; +}; + +// Last known visible row count per group id, extracted from a group-point +// list. Every expanded header is recorded — including a true zero when its +// whole subtree is collapsed, which overwrites a count that went stale when +// the children were collapsed. Collapsed headers yield nothing, so the +// caller's merge keeps their previously known value — which is exactly what +// the expand patch needs later. +export const collectGroupRowCounts = (groupPoints: IGroupPoint[]): IGroupRowCountMap => { + const counts: IGroupRowCountMap = {}; + // active expanded header ids, outermost first; dense by construction + const stack: string[] = []; + for (const point of groupPoints) { + if (point.type === GroupPointType.Header) { + stack.length = Math.min(stack.length, point.depth); + if (!point.isCollapsed) { + stack.push(point.id); + counts[point.id] ??= 0; + } + continue; + } + for (const id of stack) { + counts[id] += point.count; + } + } + return counts; +}; diff --git a/packages/sdk/src/utils/index.ts b/packages/sdk/src/utils/index.ts index 0da0ca05dd..dfb055f38b 100644 --- a/packages/sdk/src/utils/index.ts +++ b/packages/sdk/src/utils/index.ts @@ -7,3 +7,4 @@ export * from './copy'; export * from './filterWithDefaultValue'; export * from './select-color'; export * from './select-option'; +export * from './normalize-cell-value'; diff --git a/packages/sdk/src/utils/normalize-cell-value.spec.ts b/packages/sdk/src/utils/normalize-cell-value.spec.ts new file mode 100644 index 0000000000..914a155715 --- /dev/null +++ b/packages/sdk/src/utils/normalize-cell-value.spec.ts @@ -0,0 +1,67 @@ +import type { IFieldVo } from '@teable/core'; +import { CellValueType, DbFieldType, FieldType } from '@teable/core'; +import { describe, expect, it } from 'vitest'; +import { createFieldInstance } from '../model/field/factory'; +import { normalizeCellValueForDisplay } from './normalize-cell-value'; + +const createSelectField = (type: FieldType.SingleSelect | FieldType.MultipleSelect): IFieldVo => ({ + id: 'fldSpecies00000001', + name: 'Species', + dbFieldName: 'Species', + type, + options: { + choices: [ + { id: 'choCat0000000001', name: '猫', color: 'orangeBright' }, + { id: 'choDog0000000001', name: '狗', color: 'yellowBright' }, + { id: 'choHorse00000001', name: '马', color: 'redBright' }, + ], + }, + unique: false, + cellValueType: CellValueType.String, + isMultipleCellValue: type === FieldType.MultipleSelect, + dbFieldType: type === FieldType.MultipleSelect ? DbFieldType.Json : DbFieldType.Text, +}); + +describe('normalizeCellValueForDisplay T6459', () => { + it('proves the old grid validate-only path blanks transitional select values', () => { + const field = createFieldInstance(createSelectField(FieldType.MultipleSelect)); + const staleCellValue = '马'; + + // Previous use-grid-columns path: validate only → undefined → blank cell. + const validateOnly = field.validateCellValue(staleCellValue); + const blanked = validateOnly.success ? validateOnly.data : undefined; + expect(blanked).toBeUndefined(); + + // Fixed path keeps the value visible. + expect(normalizeCellValueForDisplay(field, staleCellValue)).toEqual(['马']); + }); + + it('keeps singleSelect string values visible after convert to multipleSelect', () => { + const field = createFieldInstance(createSelectField(FieldType.MultipleSelect)); + + expect(normalizeCellValueForDisplay(field, '马')).toEqual(['马']); + expect(normalizeCellValueForDisplay(field, '猫')).toEqual(['猫']); + }); + + it('keeps multipleSelect array values visible after convert to singleSelect', () => { + const field = createFieldInstance(createSelectField(FieldType.SingleSelect)); + + expect(normalizeCellValueForDisplay(field, ['狗'])).toBe('狗'); + expect(normalizeCellValueForDisplay(field, ['马', '猫'])).toBe('马'); + }); + + it('does not blank valid values of either shape', () => { + const single = createFieldInstance(createSelectField(FieldType.SingleSelect)); + const multiple = createFieldInstance(createSelectField(FieldType.MultipleSelect)); + + expect(normalizeCellValueForDisplay(single, '猫')).toBe('猫'); + expect(normalizeCellValueForDisplay(multiple, ['猫', '狗'])).toEqual(['猫', '狗']); + }); + + it('returns nullish values unchanged', () => { + const field = createFieldInstance(createSelectField(FieldType.MultipleSelect)); + + expect(normalizeCellValueForDisplay(field, null)).toBeNull(); + expect(normalizeCellValueForDisplay(field, undefined)).toBeUndefined(); + }); +}); diff --git a/packages/sdk/src/utils/normalize-cell-value.ts b/packages/sdk/src/utils/normalize-cell-value.ts new file mode 100644 index 0000000000..df812b65f9 --- /dev/null +++ b/packages/sdk/src/utils/normalize-cell-value.ts @@ -0,0 +1,30 @@ +import type { FieldCore } from '@teable/core'; + +/** + * Normalize a raw cell value against the field used for display. + * + * After singleSelect ↔ multipleSelect (and similar) converts, record docs may + * still hold the previous shape while the display field instance already has + * the new type. Strict validate alone would blank the cell even though the + * value is still present (copy/paste works; refresh reloads the matching shape). + * + * Prefer validate → repair → keep repaired/raw over returning undefined. + */ +export function normalizeCellValueForDisplay(field: FieldCore, cellValue: unknown): unknown { + if (cellValue == null) { + return cellValue; + } + + const validated = field.validateCellValue(cellValue); + if (validated?.success) { + return validated.data; + } + + try { + const repaired = field.repair(cellValue); + const repairedValidated = field.validateCellValue(repaired); + return repairedValidated?.success ? repairedValidated.data : repaired; + } catch { + return cellValue; + } +} diff --git a/packages/sdk/src/utils/record-window.spec.ts b/packages/sdk/src/utils/record-window.spec.ts index 3537936d54..1d8b4d9775 100644 --- a/packages/sdk/src/utils/record-window.spec.ts +++ b/packages/sdk/src/utils/record-window.spec.ts @@ -3,8 +3,9 @@ import { computeNextWindowQuery, INITIAL_LOAD_PAGE_SIZE, LOAD_PAGE_SIZE } from ' describe('computeNextWindowQuery', () => { it('keeps the initial window while a normal viewport rests at the top', () => { - // 1080p/1440p viewports (~28-40 rows) fit inside the initial 100-row window - for (const height of [20, 28, 40]) { + // 1080p/1440p viewports (~28-40 rows) fit inside the initial 64-row + // window's covered range (take minus the 1/3 prefetch margin ≈ 42 rows) + for (const height of [20, 28, 40, 42]) { expect( computeNextWindowQuery({ skip: 0, take: INITIAL_LOAD_PAGE_SIZE }, 0, height) ).toBeNull(); @@ -12,8 +13,9 @@ describe('computeNextWindowQuery', () => { }); it('upgrades to a full window when the viewport is taller than the initial one', () => { - // 4K / zoomed-out viewports exceed the initial window without any scroll - for (const height of [70, 95, 99]) { + // 4K / zoomed-out viewports exceed the initial window without any + // scroll; the upgrade fetch is async and never blocks the seeded paint + for (const height of [43, 70, 95]) { expect(computeNextWindowQuery({ skip: 0, take: INITIAL_LOAD_PAGE_SIZE }, 0, height)).toEqual({ skip: 0, take: LOAD_PAGE_SIZE, diff --git a/packages/sdk/src/utils/record-window.ts b/packages/sdk/src/utils/record-window.ts index d44c8e3631..ba2d611cdc 100644 --- a/packages/sdk/src/utils/record-window.ts +++ b/packages/sdk/src/utils/record-window.ts @@ -1,10 +1,14 @@ import { inRange } from 'lodash'; export const LOAD_PAGE_SIZE = 300; -// only the very first query: keeps the initial payload small (and matches the -// server-side default take used for SSR seeding). The first failed range check -// — a scroll, or a viewport taller than this — reissues a full-size window -export const INITIAL_LOAD_PAGE_SIZE = 100; +// only the very first query: keeps the initial payload small. The SSR record +// fetch and the table-switch seed pass this same constant as take, so the +// seeded rows exactly back the grid's first window — change them together. +// Effective still-viewport coverage is 2/3 of this (the rest is prefetch +// margin): 64 keeps a 1440p viewport (~40 rows) at rest, only 4K upgrades +// immediately. The first failed range check — a scroll, or a viewport taller +// than the covered range — reissues a full-size window +export const INITIAL_LOAD_PAGE_SIZE = 64; /** * Sliding-window pagination shared by the async-record hooks: given the diff --git a/packages/ui-lib/src/base/dialog/confirm-modal/ConfirmModal.tsx b/packages/ui-lib/src/base/dialog/confirm-modal/ConfirmModal.tsx index 75aacf4991..d3a6bbe217 100644 --- a/packages/ui-lib/src/base/dialog/confirm-modal/ConfirmModal.tsx +++ b/packages/ui-lib/src/base/dialog/confirm-modal/ConfirmModal.tsx @@ -70,9 +70,11 @@ export const ConfirmModalProvider: React.FC<{ children: React.ReactNode }> = ({ )} - + {options.cancelText && ( + + )} +

+ {name} +

-
+
-
-
- {files.map(({ fileId, ...item }) => ( - - ))} +
+
+
+
+ {files.map(({ fileId, ...item }) => { + const isActive = fileId === currentFile?.fileId; + return ( + + ); + })} +
+
diff --git a/packages/ui-lib/src/base/file/preview/image/ImagePreview.tsx b/packages/ui-lib/src/base/file/preview/image/ImagePreview.tsx index 0808fcceca..fb4adea96b 100644 --- a/packages/ui-lib/src/base/file/preview/image/ImagePreview.tsx +++ b/packages/ui-lib/src/base/file/preview/image/ImagePreview.tsx @@ -1,14 +1,76 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/click-events-have-key-events */ import { ZoomIn, ZoomOut, RotateCw, RefreshCcw } from '@teable/icons'; -import { useState, useRef, useEffect } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useIsMobile } from '../../../../hooks/use-is-mobile'; import { cn } from '../../../../shadcn'; import type { IFileItemInner } from '../FilePreviewContext'; interface IImagePreviewProps extends IFileItemInner {} +interface IDimensions { + width: number; + height: number; +} + +const MAX_SCALE = 5; +const DESKTOP_MIN_SCALE = 0.25; +const MOBILE_MIN_SCALE = 1; + +const getMobileMinScale = ( + rotation: number, + containerDimensions: IDimensions, + imageDimensions: IDimensions +) => { + if ( + rotation % 180 === 0 || + !containerDimensions.width || + !containerDimensions.height || + !imageDimensions.width || + !imageDimensions.height + ) { + return MOBILE_MIN_SCALE; + } + + return Math.min( + MOBILE_MIN_SCALE, + containerDimensions.width / imageDimensions.height, + containerDimensions.height / imageDimensions.width + ); +}; + +const constrainPosition = ( + position: { x: number; y: number }, + scale: number, + rotation: number, + containerDimensions: IDimensions, + imageDimensions: IDimensions +) => { + if ( + scale <= 1 || + !containerDimensions.width || + !containerDimensions.height || + !imageDimensions.width || + !imageDimensions.height + ) { + return { x: 0, y: 0 }; + } + + const swapsAxes = rotation % 180 !== 0; + const rotatedWidth = swapsAxes ? imageDimensions.height : imageDimensions.width; + const rotatedHeight = swapsAxes ? imageDimensions.width : imageDimensions.height; + const maxX = Math.max(0, (rotatedWidth * scale - containerDimensions.width) / 2); + const maxY = Math.max(0, (rotatedHeight * scale - containerDimensions.height) / 2); + + return { + x: Math.max(-maxX, Math.min(maxX, position.x)), + y: Math.max(-maxY, Math.min(maxY, position.y)), + }; +}; + export const ImagePreview = (props: IImagePreviewProps) => { const { src, name, onClose } = props; + const isMobile = useIsMobile(640); const [scale, setScale] = useState(1); const [rotation, setRotation] = useState(0); const [position, setPosition] = useState({ x: 0, y: 0 }); @@ -16,286 +78,299 @@ export const ImagePreview = (props: IImagePreviewProps) => { const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [initialPinchDistance, setInitialPinchDistance] = useState(null); const [initialPinchScale, setInitialPinchScale] = useState(1); - const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 }); + const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 }); + const [containerDimensions, setContainerDimensions] = useState({ + width: 0, + height: 0, + }); + const minScale = isMobile + ? getMobileMinScale(rotation, containerDimensions, imageDimensions) + : DESKTOP_MIN_SCALE; const imageRef = useRef(null); const containerRef = useRef(null); + const positionFrameRef = useRef(null); + const scaleFrameRef = useRef(null); + const pendingPositionRef = useRef<{ x: number; y: number }>(); + const pendingScaleRef = useRef(); + + const cancelScheduledUpdates = useCallback(() => { + if (positionFrameRef.current !== null) { + cancelAnimationFrame(positionFrameRef.current); + positionFrameRef.current = null; + } + if (scaleFrameRef.current !== null) { + cancelAnimationFrame(scaleFrameRef.current); + scaleFrameRef.current = null; + } + pendingPositionRef.current = undefined; + pendingScaleRef.current = undefined; + }, []); + + const schedulePosition = useCallback( + (nextPosition: { x: number; y: number }) => { + pendingPositionRef.current = constrainPosition( + nextPosition, + scale, + rotation, + containerDimensions, + imageDimensions + ); + if (positionFrameRef.current !== null) return; + + positionFrameRef.current = requestAnimationFrame(() => { + if (pendingPositionRef.current) { + setPosition(pendingPositionRef.current); + } + pendingPositionRef.current = undefined; + positionFrameRef.current = null; + }); + }, + [containerDimensions, imageDimensions, rotation, scale] + ); + + const scheduleScale = useCallback( + (nextScale: number) => { + pendingScaleRef.current = Math.min(Math.max(nextScale, minScale), MAX_SCALE); + if (scaleFrameRef.current !== null) return; + + scaleFrameRef.current = requestAnimationFrame(() => { + if (pendingScaleRef.current !== undefined) { + setScale(pendingScaleRef.current); + } + pendingScaleRef.current = undefined; + scaleFrameRef.current = null; + }); + }, + [minScale] + ); + + useEffect(() => cancelScheduledUpdates, [cancelScheduledUpdates]); - // Reset state when image changes useEffect(() => { + cancelScheduledUpdates(); setScale(1); setRotation(0); setPosition({ x: 0, y: 0 }); - }, [src]); + setIsDragging(false); + setInitialPinchDistance(null); + }, [cancelScheduledUpdates, src]); - // Adjust position when scale changes to keep within bounds useEffect(() => { - if (scale > 1) { - setPosition((prev) => constrainPosition(prev)); - } else { - setPosition({ x: 0, y: 0 }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [scale]); + setScale((previousScale) => Math.max(previousScale, minScale)); + }, [minScale]); - // Use ResizeObserver to efficiently monitor image dimension changes useEffect(() => { - const img = imageRef.current; - if (!img) return; - - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const { width, height } = entry.contentRect; - setImageDimensions({ width, height }); - } - }); + setPosition((previousPosition) => + constrainPosition(previousPosition, scale, rotation, containerDimensions, imageDimensions) + ); + }, [containerDimensions, imageDimensions, rotation, scale]); - resizeObserver.observe(img); + useEffect(() => { + const container = containerRef.current; + const image = imageRef.current; + if (!container || !image) return; - return () => { - resizeObserver.disconnect(); + const updateDimensions = () => { + setContainerDimensions({ width: container.clientWidth, height: container.clientHeight }); + setImageDimensions({ width: image.clientWidth, height: image.clientHeight }); }; - }, [src]); + const resizeObserver = new ResizeObserver(updateDimensions); - // Constrain position to prevent image from being dragged completely out of view - const constrainPosition = (newPosition: { x: number; y: number }) => { - if (scale <= 1 || !imageDimensions.width || !imageDimensions.height) { - return newPosition; - } - - // Use cached dimensions instead of getBoundingClientRect() for better performance - const baseImageWidth = imageDimensions.width / scale; // Original displayed size before scaling - const baseImageHeight = imageDimensions.height / scale; + updateDimensions(); + resizeObserver.observe(container); + resizeObserver.observe(image); - // Calculate scaled image dimensions - const scaledWidth = baseImageWidth * scale; - const scaledHeight = baseImageHeight * scale; - - // Calculate how much the image extends beyond the container when scaled - const excessWidth = (scaledWidth - baseImageWidth) / 2; - const excessHeight = (scaledHeight - baseImageHeight) / 2; + return () => resizeObserver.disconnect(); + }, [src]); - // Allow dragging within the container bounds - // The image can be dragged to show any part that extends beyond its original position - const maxX = excessWidth; - const minX = -excessWidth; - const maxY = excessHeight; - const minY = -excessHeight; + useEffect(() => { + const container = containerRef.current; + if (!container) return; - return { - x: Math.max(minX, Math.min(maxX, newPosition.x)), - y: Math.max(minY, Math.min(maxY, newPosition.y)), + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + const delta = event.deltaY < 0 ? 0.25 : -0.25; + setScale((previousScale) => Math.min(Math.max(previousScale + delta, minScale), MAX_SCALE)); }; - }; - // Zoom in + container.addEventListener('wheel', handleWheel, { passive: false }); + return () => container.removeEventListener('wheel', handleWheel); + }, [minScale]); + const handleZoomIn = () => { - setScale((prev) => Math.min(prev + 0.25, 5)); + setScale((previousScale) => Math.min(previousScale + 0.25, MAX_SCALE)); }; - // Zoom out const handleZoomOut = () => { - setScale((prev) => Math.max(prev - 0.25, 0.25)); + setScale((previousScale) => Math.max(previousScale - 0.25, minScale)); }; - // Rotate clockwise const handleRotateClockwise = () => { - setRotation((prev) => (prev + 90) % 360); + setRotation((previousRotation) => (previousRotation + 90) % 360); }; - // Rotate counter-clockwise const handleRotateCounterClockwise = () => { - setRotation((prev) => (prev - 90 + 360) % 360); + setRotation((previousRotation) => (previousRotation - 90 + 360) % 360); }; - // Reset all transformations const handleReset = () => { + cancelScheduledUpdates(); setScale(1); setRotation(0); setPosition({ x: 0, y: 0 }); + setIsDragging(false); + setInitialPinchDistance(null); }; - // Handle mouse down for dragging - const handleMouseDown = (e: React.MouseEvent) => { - if (scale > 1) { - setIsDragging(true); - setDragStart({ - x: e.clientX - position.x, - y: e.clientY - position.y, - }); - } - }; - - // Handle mouse move for dragging - const handleMouseMove = (e: React.MouseEvent) => { - if (isDragging && scale > 1) { - const newPosition = { - x: e.clientX - dragStart.x, - y: e.clientY - dragStart.y, - }; - setPosition(constrainPosition(newPosition)); - } + const handleMouseDown = (event: React.MouseEvent) => { + if (scale <= 1) return; + setIsDragging(true); + setDragStart({ + x: event.clientX - position.x, + y: event.clientY - position.y, + }); }; - // Handle mouse up to stop dragging - const handleMouseUp = () => { - setIsDragging(false); + const handleMouseMove = (event: React.MouseEvent) => { + if (!isDragging || scale <= 1) return; + schedulePosition({ + x: event.clientX - dragStart.x, + y: event.clientY - dragStart.y, + }); }; - // Handle mouse leave to stop dragging - const handleMouseLeave = () => { + const stopDragging = () => { setIsDragging(false); }; - // Add native wheel event listener with passive: false to allow preventDefault - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - const handleWheel = (e: WheelEvent) => { - e.preventDefault(); - if (e.deltaY < 0) { - setScale((prev) => Math.min(prev + 0.25, 5)); - } else { - setScale((prev) => Math.max(prev - 0.25, 0.25)); - } - }; - - // Add event listener with passive: false to allow preventDefault - container.addEventListener('wheel', handleWheel, { passive: false }); - - return () => { - container.removeEventListener('wheel', handleWheel); - }; - }, []); - - // Get distance between two touch points const getTouchDistance = (touches: React.TouchList) => { - const touch1 = touches[0]; - const touch2 = touches[1]; - const dx = touch2.clientX - touch1.clientX; - const dy = touch2.clientY - touch1.clientY; - return Math.sqrt(dx * dx + dy * dy); + const firstTouch = touches[0]; + const secondTouch = touches[1]; + return Math.hypot( + secondTouch.clientX - firstTouch.clientX, + secondTouch.clientY - firstTouch.clientY + ); }; - // Handle touch start - const handleTouchStart = (e: React.TouchEvent) => { - if (e.touches.length === 1 && scale > 1) { - // Single touch - start dragging + const handleTouchStart = (event: React.TouchEvent) => { + if (event.touches.length === 1 && scale > 1) { setIsDragging(true); setDragStart({ - x: e.touches[0].clientX - position.x, - y: e.touches[0].clientY - position.y, + x: event.touches[0].clientX - position.x, + y: event.touches[0].clientY - position.y, }); - } else if (e.touches.length === 2) { - // Two fingers - start pinch zoom - const distance = getTouchDistance(e.touches); - setInitialPinchDistance(distance); + return; + } + + if (event.touches.length === 2) { + setInitialPinchDistance(getTouchDistance(event.touches)); setInitialPinchScale(scale); setIsDragging(false); } }; - // Handle touch move - const handleTouchMove = (e: React.TouchEvent) => { - if (e.touches.length === 1 && isDragging && scale > 1) { - // Single touch - drag - const newPosition = { - x: e.touches[0].clientX - dragStart.x, - y: e.touches[0].clientY - dragStart.y, - }; - setPosition(constrainPosition(newPosition)); - } else if (e.touches.length === 2 && initialPinchDistance !== null) { - // Two fingers - pinch zoom - const distance = getTouchDistance(e.touches); - const scaleChange = distance / initialPinchDistance; - const newScale = Math.min(Math.max(initialPinchScale * scaleChange, 0.25), 5); - setScale(newScale); + const handleTouchMove = (event: React.TouchEvent) => { + if (event.touches.length === 1 && isDragging && scale > 1) { + schedulePosition({ + x: event.touches[0].clientX - dragStart.x, + y: event.touches[0].clientY - dragStart.y, + }); + return; + } + + if (event.touches.length === 2 && initialPinchDistance !== null) { + const scaleChange = getTouchDistance(event.touches) / initialPinchDistance; + scheduleScale(initialPinchScale * scaleChange); } }; - // Handle touch end const handleTouchEnd = () => { setIsDragging(false); setInitialPinchDistance(null); }; + const isInteracting = isDragging || initialPinchDistance !== null; + return ( -
- {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
1 ? 'cursor-grab' : 'cursor-default', isDragging && 'cursor-grabbing' )} style={{ touchAction: 'none' }} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} - onMouseUp={handleMouseUp} - onMouseLeave={handleMouseLeave} + onMouseUp={stopDragging} + onMouseLeave={stopDragging} onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} - onClick={(e) => { - if (e.target === e.currentTarget) { + onClick={(event) => { + if (event.target === event.currentTarget) { onClose?.(); } }} > {name}
- {/* Control buttons */} -
+
- - {Math.round(scale * 100)}% - + {Math.round(scale * 100)}% -
+
-
+
diff --git a/packages/ui-lib/src/shadcn/ui/sonner.tsx b/packages/ui-lib/src/shadcn/ui/sonner.tsx index eadfcb4811..1c179ca407 100644 --- a/packages/ui-lib/src/shadcn/ui/sonner.tsx +++ b/packages/ui-lib/src/shadcn/ui/sonner.tsx @@ -38,6 +38,8 @@ const Toaster = ({ ...props }: ToasterProps) => { }, }} position={props.position ?? 'top-center'} + // Clears the fixed top-banner stack; the CSS var is 0 when no banner shows. + offset={props.offset ?? { top: 'calc(var(--teable-top-banner-height, 0px) + 32px)' }} {...props} /> ); diff --git a/packages/v2/adapter-db-postgres-shared/src/index.ts b/packages/v2/adapter-db-postgres-shared/src/index.ts index 525fac5929..08c3d3af86 100644 --- a/packages/v2/adapter-db-postgres-shared/src/index.ts +++ b/packages/v2/adapter-db-postgres-shared/src/index.ts @@ -1,4 +1,5 @@ export * from './config'; export * from './di/tokens'; +export * from './managedSearchObjects'; export * from './PostgresSqlExecutionError'; export * from './unitOfWork'; diff --git a/packages/v2/adapter-db-postgres-shared/src/managedSearchObjects.ts b/packages/v2/adapter-db-postgres-shared/src/managedSearchObjects.ts new file mode 100644 index 0000000000..93e3bd41b2 --- /dev/null +++ b/packages/v2/adapter-db-postgres-shared/src/managedSearchObjects.ts @@ -0,0 +1,34 @@ +/** + * Naming contract for the managed search-document objects the query-ops + * adapters create on user tables. This is the single source for these + * prefixes: the executor refuses to ADD/DROP anything outside them, the + * record-repository schema visitor drops matching columns before column DDL, + * and devtools recognizes them during validation. Keep every consumer on + * these exports instead of re-declaring the literals. + * + * Lives in the side-effect-free shared postgres package on purpose: importing + * @teable/v2-table-query-ops registers its schema-maintenance projection into + * the global event registry, which breaks containers that never call + * registerV2TableOps. + */ +export const MANAGED_SEARCH_DOCUMENT_COLUMN_PREFIX = '__tqops_search_'; +export const MANAGED_SEARCH_INDEX_PREFIX = 'idx_tqops_search_'; +export const MANAGED_SCOPED_SEARCH_INDEX_PREFIX = 'idx_tqops_search_scope_'; +export const LEGACY_MANAGED_SEARCH_DOCUMENT_COLUMN_PREFIX = '__tqops_tsv_'; +export const LEGACY_MANAGED_SEARCH_INDEX_PREFIX = 'idx_tqops_tsv_'; + +export const managedSearchDocumentColumnPrefixes = [ + MANAGED_SEARCH_DOCUMENT_COLUMN_PREFIX, + LEGACY_MANAGED_SEARCH_DOCUMENT_COLUMN_PREFIX, +] as const; + +export const isManagedSearchDocumentColumnName = (columnName: string): boolean => + managedSearchDocumentColumnPrefixes.some((prefix) => columnName.startsWith(prefix)); + +export const isManagedSearchIndexName = (indexName: string): boolean => + indexName.startsWith(MANAGED_SEARCH_INDEX_PREFIX) || + indexName.startsWith(LEGACY_MANAGED_SEARCH_INDEX_PREFIX); + +/** SQL LIKE pattern (backslash escape) matching a managed prefix. */ +export const managedSearchPrefixLikePattern = (prefix: string): string => + `${prefix.replace(/_/g, '\\_')}%`; diff --git a/packages/v2/adapter-db-postgres-shared/src/unitOfWork.ts b/packages/v2/adapter-db-postgres-shared/src/unitOfWork.ts index 0ad8231ff4..acd7055caf 100644 --- a/packages/v2/adapter-db-postgres-shared/src/unitOfWork.ts +++ b/packages/v2/adapter-db-postgres-shared/src/unitOfWork.ts @@ -28,6 +28,9 @@ class UnitOfWorkAbort extends Error { constructor(readonly error: DomainError) { super(error.message); this.name = 'UnitOfWorkAbort'; + if (error.stack) { + this.stack = error.stack; + } } } diff --git a/packages/v2/adapter-realtime-broadcastchannel/src/BroadcastChannelRealtimeEngine.ts b/packages/v2/adapter-realtime-broadcastchannel/src/BroadcastChannelRealtimeEngine.ts index 16b54e3bd0..c7515797fb 100644 --- a/packages/v2/adapter-realtime-broadcastchannel/src/BroadcastChannelRealtimeEngine.ts +++ b/packages/v2/adapter-realtime-broadcastchannel/src/BroadcastChannelRealtimeEngine.ts @@ -42,4 +42,12 @@ export class BroadcastChannelRealtimeEngine implements IRealtimeEngine { ): Promise> { return this.hub.remove(docId); } + + async invalidateCollection( + _context: IExecutionContext, + collection: string, + _change: RealtimeChange + ): Promise> { + return this.hub.invalidateCollection(collection); + } } diff --git a/packages/v2/adapter-realtime-broadcastchannel/src/BroadcastChannelRealtimeHub.ts b/packages/v2/adapter-realtime-broadcastchannel/src/BroadcastChannelRealtimeHub.ts index 6ae1ce3ed1..1aa0d01031 100644 --- a/packages/v2/adapter-realtime-broadcastchannel/src/BroadcastChannelRealtimeHub.ts +++ b/packages/v2/adapter-realtime-broadcastchannel/src/BroadcastChannelRealtimeHub.ts @@ -13,7 +13,12 @@ type SnapshotMessage = { snapshot: unknown | null; }; -type BroadcastMessage = SnapshotMessage; +type CollectionInvalidatedMessage = { + type: 'collectionInvalidated'; + collection: string; +}; + +type BroadcastMessage = SnapshotMessage | CollectionInvalidatedMessage; type DocListener = (snapshot: unknown | null) => void; type CollectionListener = ( @@ -189,6 +194,12 @@ export class BroadcastChannelRealtimeHub { return ok(undefined); } + invalidateCollection(collection: string): Result { + this.broadcast({ type: 'collectionInvalidated', collection }); + this.notifyCollection(collection); + return ok(undefined); + } + remove(docId: RealtimeDocId): Result { const parsed = RealtimeDocIdValue.parse(docId); if (parsed.isErr()) { @@ -267,6 +278,7 @@ export class BroadcastChannelRealtimeHub { this.logger.debug('BroadcastChannel realtime broadcast', { type: message.type, docKey: message.type === 'snapshot' ? message.docKey : undefined, + collection: message.collection, }); } catch (error) { this.logger.warn('BroadcastChannel realtime broadcast failed', { error }); @@ -274,7 +286,13 @@ export class BroadcastChannelRealtimeHub { } private handleMessage(message: BroadcastMessage): void { - if (message.type !== 'snapshot') return; + if (message.type === 'collectionInvalidated') { + this.logger.debug('BroadcastChannel realtime collection invalidated', { + collection: message.collection, + }); + this.notifyCollection(message.collection); + return; + } this.logger.debug('BroadcastChannel realtime message received', { docKey: message.docKey, collection: message.collection, diff --git a/packages/v2/adapter-realtime-broadcastchannel/src/di/register.ts b/packages/v2/adapter-realtime-broadcastchannel/src/di/register.ts index afa9546b1a..bf4a6a97fc 100644 --- a/packages/v2/adapter-realtime-broadcastchannel/src/di/register.ts +++ b/packages/v2/adapter-realtime-broadcastchannel/src/di/register.ts @@ -3,6 +3,11 @@ import { FieldDeletedRealtimeProjection, TableCreatedRealtimeProjection, ViewColumnMetaUpdatedRealtimeProjection, + ViewDeletedRealtimeProjection, + ViewRenamedRealtimeProjection, + ViewDescriptionUpdatedRealtimeProjection, + ViewLockedUpdatedRealtimeProjection, + ViewOrderUpdatedRealtimeProjection, RecordCreatedRealtimeProjection, RecordUpdatedRealtimeProjection, RecordReorderedRealtimeProjection, @@ -61,6 +66,21 @@ export const registerV2BroadcastChannelRealtime = ( c.register(ViewColumnMetaUpdatedRealtimeProjection, ViewColumnMetaUpdatedRealtimeProjection, { lifecycle: Lifecycle.Singleton, }); + c.register(ViewDeletedRealtimeProjection, ViewDeletedRealtimeProjection, { + lifecycle: Lifecycle.Singleton, + }); + c.register(ViewRenamedRealtimeProjection, ViewRenamedRealtimeProjection, { + lifecycle: Lifecycle.Singleton, + }); + c.register(ViewDescriptionUpdatedRealtimeProjection, ViewDescriptionUpdatedRealtimeProjection, { + lifecycle: Lifecycle.Singleton, + }); + c.register(ViewLockedUpdatedRealtimeProjection, ViewLockedUpdatedRealtimeProjection, { + lifecycle: Lifecycle.Singleton, + }); + c.register(ViewOrderUpdatedRealtimeProjection, ViewOrderUpdatedRealtimeProjection, { + lifecycle: Lifecycle.Singleton, + }); // Record realtime projections c.register(RecordCreatedRealtimeProjection, RecordCreatedRealtimeProjection, { diff --git a/packages/v2/adapter-realtime-sharedb/src/ShareDbPublisher.ts b/packages/v2/adapter-realtime-sharedb/src/ShareDbPublisher.ts index d0e7db633b..e5922eb338 100644 --- a/packages/v2/adapter-realtime-sharedb/src/ShareDbPublisher.ts +++ b/packages/v2/adapter-realtime-sharedb/src/ShareDbPublisher.ts @@ -2,7 +2,11 @@ import { type DomainError } from '@teable/v2-core'; import type { Result } from 'neverthrow'; import type { CreateOp, DeleteOp, EditOp } from 'sharedb'; -export type ShareDbOp = CreateOp | DeleteOp | EditOp; +export type ShareDbCollectionInvalidationOp = Omit & { + d?: undefined; +}; + +export type ShareDbOp = CreateOp | DeleteOp | EditOp | ShareDbCollectionInvalidationOp; export interface IShareDbOpPublisher { publish(channels: ReadonlyArray, op: ShareDbOp): Promise>; diff --git a/packages/v2/adapter-realtime-sharedb/src/ShareDbRealtimeEngine.spec.ts b/packages/v2/adapter-realtime-sharedb/src/ShareDbRealtimeEngine.spec.ts index 5675d30334..5479a3d44c 100644 --- a/packages/v2/adapter-realtime-sharedb/src/ShareDbRealtimeEngine.spec.ts +++ b/packages/v2/adapter-realtime-sharedb/src/ShareDbRealtimeEngine.spec.ts @@ -527,6 +527,37 @@ describe('ShareDbRealtimeEngine', () => { ]); }); + it('publishes collection-only invalidation without a synthetic document', async () => { + const actorId = ActorId.create('test-actor')._unsafeUnwrap(); + const context = { actorId, requestId: 'manual-sort-request' }; + let publishedChannels: ReadonlyArray = []; + let publishedOp: unknown; + const publisher = { + publish: async (channels: ReadonlyArray, op: unknown) => { + publishedChannels = channels; + publishedOp = op; + return ok(undefined); + }, + }; + const engine = new ShareDbRealtimeEngine(publisher as unknown as ShareDbBackendPublisher); + + const result = await engine.invalidateCollection(context, 'rec_tbl_test', { + type: 'set', + path: ['fields', '__row_viw_test'], + value: null, + oldValue: null, + }); + + expect(result.isOk()).toBe(true); + expect(publishedChannels).toEqual(['rec_tbl_test']); + expect(publishedOp).toMatchObject({ + c: 'rec_tbl_test', + src: '@@v2-projection:manual-sort-request', + op: [{ p: ['fields', '__row_viw_test'], oi: null, od: null }], + }); + expect(publishedOp).not.toHaveProperty('d'); + }); + it('delivers delete ops to subscribed clients', async () => { if (!runtime) throw new Error('Missing ShareDB runtime'); diff --git a/packages/v2/adapter-realtime-sharedb/src/ShareDbRealtimeEngine.ts b/packages/v2/adapter-realtime-sharedb/src/ShareDbRealtimeEngine.ts index 5b67b36db1..367416afeb 100644 --- a/packages/v2/adapter-realtime-sharedb/src/ShareDbRealtimeEngine.ts +++ b/packages/v2/adapter-realtime-sharedb/src/ShareDbRealtimeEngine.ts @@ -143,6 +143,26 @@ export class ShareDbRealtimeEngine implements IRealtimeEngine { return this.publisher.publish(channels, op); } + async invalidateCollection( + context: IExecutionContext, + collection: string, + change: RealtimeChange + ): Promise> { + const op: ShareDbOp = { + create: undefined, + del: undefined, + op: this.toJson0Op(change), + src: this.toProjectionSource(context.requestId), + seq: 1, + v: 0, + m: { + ts: Date.now(), + }, + c: collection, + }; + return this.publisher.publish([collection], op); + } + private toProjectionSource(requestId: string | undefined): string { return `${v2ProjectionOpSourcePrefix}${requestId ?? 'unknown'}`; } diff --git a/packages/v2/adapter-realtime-sharedb/src/di/register.ts b/packages/v2/adapter-realtime-sharedb/src/di/register.ts index b79ea118d9..32d4933908 100644 --- a/packages/v2/adapter-realtime-sharedb/src/di/register.ts +++ b/packages/v2/adapter-realtime-sharedb/src/di/register.ts @@ -6,6 +6,11 @@ import { ComputedActivityRealtimeProjection, TableCreatedRealtimeProjection, ViewColumnMetaUpdatedRealtimeProjection, + ViewDeletedRealtimeProjection, + ViewRenamedRealtimeProjection, + ViewDescriptionUpdatedRealtimeProjection, + ViewLockedUpdatedRealtimeProjection, + ViewOrderUpdatedRealtimeProjection, RecordCreatedRealtimeProjection, RecordUpdatedRealtimeProjection, RecordReorderedRealtimeProjection, @@ -60,6 +65,21 @@ export const registerV2ShareDbRealtime = ( c.register(ViewColumnMetaUpdatedRealtimeProjection, ViewColumnMetaUpdatedRealtimeProjection, { lifecycle: Lifecycle.Singleton, }); + c.register(ViewDeletedRealtimeProjection, ViewDeletedRealtimeProjection, { + lifecycle: Lifecycle.Singleton, + }); + c.register(ViewRenamedRealtimeProjection, ViewRenamedRealtimeProjection, { + lifecycle: Lifecycle.Singleton, + }); + c.register(ViewDescriptionUpdatedRealtimeProjection, ViewDescriptionUpdatedRealtimeProjection, { + lifecycle: Lifecycle.Singleton, + }); + c.register(ViewLockedUpdatedRealtimeProjection, ViewLockedUpdatedRealtimeProjection, { + lifecycle: Lifecycle.Singleton, + }); + c.register(ViewOrderUpdatedRealtimeProjection, ViewOrderUpdatedRealtimeProjection, { + lifecycle: Lifecycle.Singleton, + }); c.register(FieldOptionsAddedRealtimeProjection, FieldOptionsAddedRealtimeProjection, { lifecycle: Lifecycle.Singleton, }); diff --git a/packages/v2/adapter-repository-postgres/src/db/schema.ts b/packages/v2/adapter-repository-postgres/src/db/schema.ts index 9555b031fe..e2ead25d72 100644 --- a/packages/v2/adapter-repository-postgres/src/db/schema.ts +++ b/packages/v2/adapter-repository-postgres/src/db/schema.ts @@ -34,6 +34,19 @@ export const ensureV1MetaSchema = async (db: Kysely): Promise< .addColumn('last_modified_time', 'timestamptz') .execute(); + await db.schema + .createTable('space_data_db_binding') + .ifNotExists() + .addColumn('id', 'text', (col) => col.primaryKey()) + .addColumn('space_id', 'text', (col) => col.notNull().unique()) + .addColumn('data_db_connection_id', 'text') + .addColumn('mode', 'text', (col) => col.notNull().defaultTo('default')) + .addColumn('state', 'text', (col) => col.notNull().defaultTo('ready')) + .addColumn('created_by', 'text', (col) => col.notNull().defaultTo('system')) + .addColumn('created_time', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`)) + .addColumn('last_modified_time', 'timestamptz') + .execute(); + await db.schema .createTable('table_meta') .ifNotExists() @@ -246,6 +259,12 @@ export const ensureV1MetaSchema = async (db: Kysely): Promise< .addColumn('snapshot', 'text', (col) => col.notNull()) .addColumn('created_time', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`)) .addColumn('created_by', 'text', (col) => col.notNull()) + .addColumn('reason', 'text', (col) => col.notNull().defaultTo('deleted')) + .addColumn('record_created_time', 'timestamptz') + .addColumn('record_created_by', 'text') + .addColumn('record_last_modified_time', 'timestamptz') + .addColumn('record_last_modified_by', 'text') + .addColumn('operation_id', 'text') .execute(); await db.schema @@ -255,6 +274,27 @@ export const ensureV1MetaSchema = async (db: Kysely): Promise< .columns(['table_id', 'record_id']) .execute(); + await sql` + CREATE INDEX IF NOT EXISTS "record_trash_archived_removed_idx" + ON "record_trash"("table_id", "created_time" DESC, "id" DESC) WHERE "reason" = 'archived' + `.execute(db); + await sql` + CREATE INDEX IF NOT EXISTS "record_trash_archived_created_idx" + ON "record_trash"("table_id", "record_created_time") WHERE "reason" = 'archived' + `.execute(db); + await sql` + CREATE INDEX IF NOT EXISTS "record_trash_archived_creator_idx" + ON "record_trash"("table_id", "record_created_by") WHERE "reason" = 'archived' + `.execute(db); + await sql` + CREATE INDEX IF NOT EXISTS "record_trash_archived_modified_idx" + ON "record_trash"("table_id", "record_last_modified_time") WHERE "reason" = 'archived' + `.execute(db); + await sql` + CREATE INDEX IF NOT EXISTS "record_trash_archived_modifier_idx" + ON "record_trash"("table_id", "record_last_modified_by") WHERE "reason" = 'archived' + `.execute(db); + await db.schema .createTable('computed_update_outbox') .ifNotExists() @@ -301,6 +341,22 @@ export const ensureV1MetaSchema = async (db: Kysely): Promise< .addColumn('record_id', 'text', (col) => col.notNull()) .execute(); + await db.schema + .createTable('computed_update_stage_ledger') + .ifNotExists() + .addColumn('scope_id', 'text', (col) => col.notNull()) + .addColumn('kind', 'text', (col) => col.notNull()) + .addColumn('table_id', 'text', (col) => col.notNull()) + .addColumn('record_id', 'text', (col) => col.notNull()) + .addColumn('seq', 'bigint', (col) => col.notNull().defaultTo(0)) + .addPrimaryKeyConstraint('computed_update_stage_ledger_pkey', [ + 'scope_id', + 'kind', + 'table_id', + 'record_id', + ]) + .execute(); + await db.schema .createTable('computed_update_pause_scope') .ifNotExists() @@ -397,6 +453,13 @@ export const ensureV1MetaSchema = async (db: Kysely): Promise< .unique() .execute(); + await db.schema + .createIndex('computed_update_stage_ledger_scope_id_kind_seq_idx') + .ifNotExists() + .on('computed_update_stage_ledger') + .columns(['scope_id', 'kind', 'seq']) + .execute(); + await db.schema .createIndex('computed_update_pause_scope_scope_type_scope_id_key') .ifNotExists() diff --git a/packages/v2/adapter-repository-postgres/src/di/register.spec.ts b/packages/v2/adapter-repository-postgres/src/di/register.spec.ts index 7965788561..0a6c8686c1 100644 --- a/packages/v2/adapter-repository-postgres/src/di/register.spec.ts +++ b/packages/v2/adapter-repository-postgres/src/di/register.spec.ts @@ -99,6 +99,9 @@ describe('registerV2PostgresStateAdapter', () => { '../repositories/PostgresSchemaOperationRepository' ); const { PostgresBaseRepository } = await import('../repositories/PostgresBaseRepository'); + const { PostgresViewPluginRepository } = await import( + '../repositories/PostgresViewPluginRepository' + ); const container = createContainer(); const db = { @@ -141,6 +144,11 @@ describe('registerV2PostgresStateAdapter', () => { implementation: PostgresTableRepository, options: { lifecycle: Lifecycle.Singleton }, }, + { + token: v2CoreTokens.viewPluginRepository, + implementation: PostgresViewPluginRepository, + options: { lifecycle: Lifecycle.Singleton }, + }, { token: v2CoreTokens.schemaOperationRepository, implementation: PostgresSchemaOperationRepository, diff --git a/packages/v2/adapter-repository-postgres/src/di/register.ts b/packages/v2/adapter-repository-postgres/src/di/register.ts index f96545462c..697ca5a5ef 100644 --- a/packages/v2/adapter-repository-postgres/src/di/register.ts +++ b/packages/v2/adapter-repository-postgres/src/di/register.ts @@ -14,6 +14,7 @@ import { PostgresTableRowLimitPlugin, StaticTableRowLimitPolicy, } from '../repositories/PostgresTableRowLimitPlugin'; +import { PostgresViewPluginRepository } from '../repositories/PostgresViewPluginRepository'; import { v2PostgresStateTokens } from './tokens'; export const registerV2PostgresStateAdapter = async ( @@ -45,6 +46,9 @@ export const registerV2PostgresStateAdapter = async ( c.register(v2CoreTokens.tableRepository, PostgresTableRepository, { lifecycle: Lifecycle.Singleton, }); + c.register(v2CoreTokens.viewPluginRepository, PostgresViewPluginRepository, { + lifecycle: Lifecycle.Singleton, + }); c.register(v2CoreTokens.schemaOperationRepository, PostgresSchemaOperationRepository, { lifecycle: Lifecycle.Singleton, }); diff --git a/packages/v2/adapter-repository-postgres/src/index.ts b/packages/v2/adapter-repository-postgres/src/index.ts index 8ec5df9275..254bf52860 100644 --- a/packages/v2/adapter-repository-postgres/src/index.ts +++ b/packages/v2/adapter-repository-postgres/src/index.ts @@ -13,3 +13,4 @@ export * from './di/tokens'; export * from './repositories/PostgresTableRowLimitPlugin'; export * from './repositories/PostgresSchemaOperationRepository'; export * from './repositories/PostgresTableRepository'; +export * from './repositories/PostgresViewPluginRepository'; diff --git a/packages/v2/adapter-repository-postgres/src/repositories/PostgresBaseRepository.spec.ts b/packages/v2/adapter-repository-postgres/src/repositories/PostgresBaseRepository.spec.ts index 72f743bc09..5d2c4bbec4 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/PostgresBaseRepository.spec.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/PostgresBaseRepository.spec.ts @@ -116,6 +116,37 @@ describe('PostgresBaseRepository', () => { }); }); + it('deletes a base and wraps delete failures as infrastructure errors', async () => { + const baseId = BaseId.create(`bse${'j'.repeat(16)}`)._unsafeUnwrap(); + const db = createTestDb(); + const repo = new PostgresBaseRepository(db); + + expect( + ( + await repo.delete({ actorId: ActorId.create('system')._unsafeUnwrap() } as never, baseId) + ).isOk() + ).toBe(true); + + const failedDb = { + deleteFrom: () => ({ + where: () => ({ + execute: () => { + throw new Error('delete failed'); + }, + }), + }), + } as unknown as Kysely; + const failedResult = await new PostgresBaseRepository(failedDb).delete( + { actorId: ActorId.create('system')._unsafeUnwrap() } as never, + baseId + ); + + expect(failedResult._unsafeUnwrapErr()).toMatchObject({ + code: 'infrastructure', + message: 'Failed to delete base: Error: delete failed', + }); + }); + it('findOne returns null, a mapped base, or an unexpected error', async () => { const nullDb = { selectFrom: vi.fn(() => ({ diff --git a/packages/v2/adapter-repository-postgres/src/repositories/PostgresBaseRepository.ts b/packages/v2/adapter-repository-postgres/src/repositories/PostgresBaseRepository.ts index 3118d959aa..10074ebe4f 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/PostgresBaseRepository.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/PostgresBaseRepository.ts @@ -73,6 +73,22 @@ export class PostgresBaseRepository implements core.IBaseRepository { return ok(base); } + @core.TraceSpan() + async delete( + context: core.IExecutionContext, + baseId: core.BaseId + ): Promise> { + try { + const db = resolvePostgresDbOrTx(this.db, context, 'meta'); + await db.deleteFrom('base').where('id', '=', baseId.toString()).execute(); + return ok(undefined); + } catch (error) { + return err( + domainError.infrastructure({ message: `Failed to delete base: ${describeError(error)}` }) + ); + } + } + @core.TraceSpan() async findOne( context: core.IExecutionContext, diff --git a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.helpers.spec.ts b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.helpers.spec.ts index ea5a8ca2f7..2d80c85b8d 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.helpers.spec.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.helpers.spec.ts @@ -75,11 +75,18 @@ describe('PostgresTableRepository helpers', () => { sort: [{ fieldId: 'fld1', order: 'asc' }], manualSort: true, }); + expect(repo.parseViewSort(JSON.stringify({ sortObjs: [] }))).toEqual({ + sort: [], + manualSort: undefined, + }); expect(repo.parseViewSort('invalid json')).toEqual({}); expect(repo.parseViewGroup(JSON.stringify([{ fieldId: 'fld1', order: 'desc' }]))).toEqual([ { fieldId: 'fld1', order: 'desc' }, ]); + expect(repo.parseViewGroup('[]')).toEqual([]); expect(repo.parseViewGroup('"oops"')).toBeUndefined(); + expect(repo.serializeViewQuery({ query: { group: [] } }).group).toBe('[]'); + expect(repo.serializeViewQuery({ query: {} }).group).toBeNull(); expect(repo.parseJsonValue('{"a":1}')).toEqual({ a: 1 }); expect(repo.parseJsonValue('oops')).toBeUndefined(); }); diff --git a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.spec.ts b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.spec.ts index 9d0da8708f..091425b722 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.spec.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.spec.ts @@ -57,6 +57,8 @@ import { TableUpdateViewColumnMetaSpec, TableSortKey, ViewColumnMeta, + ViewId, + ViewName, createSingleSelectField, v2CoreTokens, domainError, @@ -453,6 +455,39 @@ describe('PostgresTableRepository (pg)', () => { { name: 'Kanban', type: 'kanban' }, ]); + const targetView = table.views()[1]; + const selectiveSpec = table + .specs() + .byId(table.id()) + .withViewId(targetView.id()) + .build() + ._unsafeUnwrap(); + const selectivelyLoaded = (await repo.findOne(context, selectiveSpec))._unsafeUnwrap(); + + expect(selectivelyLoaded.id().equals(table.id())).toBe(true); + expect(selectivelyLoaded.getFields()).toHaveLength(table.getFields().length); + expect(selectivelyLoaded.views()).toHaveLength(1); + expect(selectivelyLoaded.views()[0].id().equals(targetView.id())).toBe(true); + expect(selectivelyLoaded.views()[0].type().toString()).toBe('kanban'); + expect(selectivelyLoaded.views()[0].version()._unsafeUnwrap().toNumber()).toBe(1); + expect(selectivelyLoaded.views()[0].auditMetadata()._unsafeUnwrap().toDto()).toMatchObject({ + createdBy: actorId.toString(), + createdTime: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + }); + + const projectedViewIdsSpec = table + .specs() + .byId(table.id()) + .withViewIds([targetView.id(), ViewId.create(`viw${'z'.repeat(16)}`)._unsafeUnwrap()]) + .build() + ._unsafeUnwrap(); + const projectedViews = (await repo.findOne(context, projectedViewIdsSpec))._unsafeUnwrap(); + + expect(projectedViews.id().equals(table.id())).toBe(true); + expect(projectedViews.views().map((view) => view.id().toString())).toEqual([ + targetView.id().toString(), + ]); + const snapshotVisitor: IFieldVisitor = new FieldToSnapshotVisitor(); const fieldSnapshots = loaded.getFields().map((f) => f.accept(snapshotVisitor)); fieldSnapshots.forEach((r) => r._unsafeUnwrap()); @@ -2334,4 +2369,105 @@ describe('PostgresTableRepository (pg)', () => { await db.destroy(); } }); + + it('rejects an update made from a stale Table aggregate View version', async () => { + const c = container.createChildContainer(); + const db = await createPgDb(pgContainer.getConnectionUri()); + await registerV2PostgresStateAdapter(c, { + db, + ensureSchema: true, + }); + const repo = c.resolve(v2CoreTokens.tableRepository); + + try { + const baseId = BaseId.generate()._unsafeUnwrap(); + const actorId = ActorId.create('system')._unsafeUnwrap(); + const context = { actorId }; + const spaceId = `spc${getRandomString(16)}`; + + await db + .insertInto('space') + .values({ id: spaceId, name: 'Stale View Space', created_by: actorId.toString() }) + .execute(); + + await db + .insertInto('base') + .values({ + id: baseId.toString(), + space_id: spaceId, + name: 'Stale View Base', + order: 1, + created_by: actorId.toString(), + }) + .execute(); + + const builder = Table.builder() + .withBaseId(baseId) + .withName(TableName.create('Stale View Table')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withName(FieldName.create('Title')._unsafeUnwrap()) + .primary() + .done(); + builder.view().defaultGrid().done(); + const inserted = ( + await repo.insert(context, builder.build()._unsafeUnwrap()) + )._unsafeUnwrap(); + const querySpec = inserted.specs().byId(inserted.id()).build()._unsafeUnwrap(); + const firstAggregate = (await repo.findOne(context, querySpec))._unsafeUnwrap(); + const staleAggregate = (await repo.findOne(context, querySpec))._unsafeUnwrap(); + const viewId = firstAggregate.views()[0]!.id(); + + const firstRename = firstAggregate + .renameView(viewId, ViewName.create('First writer')._unsafeUnwrap()) + ._unsafeUnwrap(); + const firstPersist = ( + await repo.updateOne( + context, + firstRename.updateResult.table, + firstRename.updateResult.mutateSpec + ) + )._unsafeUnwrap(); + + expect(firstPersist).toEqual({ + viewVersionChanges: [ + { + viewId: viewId.toString(), + oldVersion: 1, + newVersion: 2, + }, + ], + }); + + const staleRename = staleAggregate + .renameView(viewId, ViewName.create('Stale writer')._unsafeUnwrap()) + ._unsafeUnwrap(); + const stalePersist = await repo.updateOne( + context, + staleRename.updateResult.table, + staleRename.updateResult.mutateSpec + ); + + expect(stalePersist._unsafeUnwrapErr()).toMatchObject({ + code: 'view.version_conflict', + tags: ['conflict'], + details: { + tableId: inserted.id().toString(), + viewId: viewId.toString(), + expectedVersion: 1, + actualVersion: 2, + }, + }); + + const row = await db + .selectFrom('view') + .select(['name', 'version']) + .where('id', '=', viewId.toString()) + .executeTakeFirstOrThrow(); + expect(row).toMatchObject({ name: 'First writer', version: 2 }); + } finally { + await db.destroy(); + } + }); }); diff --git a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.ts b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.ts index 30b4115606..df29441c1e 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.ts @@ -22,9 +22,17 @@ import { TableWhereVisitor, } from './visitors/TableWhereVisitor'; +class TableUpdateRollback extends Error { + constructor(readonly domainError: DomainError) { + super('table update rolled back'); + } +} + const formatSpecDetails = (specInfo: TableWhereSpecInfo): string => { const parts: string[] = []; if (specInfo.tableId) parts.push(`tableId=${specInfo.tableId}`); + if (specInfo.viewId) parts.push(`viewId=${specInfo.viewId}`); + if (specInfo.viewIds) parts.push(`viewIds=${specInfo.viewIds.join(',')}`); if (specInfo.incomingReferenceToTableId) { parts.push(`incomingReferenceToTableId=${specInfo.incomingReferenceToTableId}`); } @@ -90,6 +98,9 @@ const tableProvisionStateToOperationStatus = ( const shouldFilterDeletedChildren = (state: core.TableQueryState): boolean => state === 'active' || state === 'activeWithPending' || state === 'activeAnyProvision'; +const toIsoTimestamp = (value: Date | string): string => + value instanceof Date ? value.toISOString() : new Date(value).toISOString(); + const jsonbValue = (value: unknown): ReturnType => { if (value === undefined) { return sql`NULL`; @@ -187,17 +198,19 @@ export class PostgresTableRepository implements core.ITableRepository { }), id: v.id, name: v.name, - description: null, + description: v.description ?? null, table_id: dto.id, type: v.type, options: v.options === undefined ? null : JSON.stringify(v.options), - order: i + 1, + // v1 assigns 0-based view orders; keep parity so export order + // normalization round-trips (see BaseExportService.generateViewConfig). + order: v.order ?? i, version: 1, column_meta: JSON.stringify(v.columnMeta), - is_locked: null, - enable_share: null, - share_id: null, - share_meta: null, + is_locked: v.isLocked ?? null, + enable_share: v.enableShare ?? null, + share_id: v.shareId ?? null, + share_meta: v.shareMeta === undefined ? null : JSON.stringify(v.shareMeta), created_time: now, last_modified_time: now, deleted_time: null, @@ -225,8 +238,8 @@ export class PostgresTableRepository implements core.ITableRepository { dto.id, baseId, dto.name, - null, - null, + dto.description ?? null, + dto.icon ?? null, tableDbMetaValue.dbTableName, null, 1, @@ -493,8 +506,8 @@ export class PostgresTableRepository implements core.ITableRepository { id: dto.id, base_id: baseId, name: dto.name, - description: null, - icon: null, + description: dto.description ?? null, + icon: dto.icon ?? null, db_table_name: tableDbMeta.dbTableName, db_view_name: null, version: 1, @@ -516,17 +529,17 @@ export class PostgresTableRepository implements core.ITableRepository { }), id: view.id, name: view.name, - description: null, + description: view.description ?? null, table_id: dto.id, type: view.type, options: view.options === undefined ? null : JSON.stringify(view.options), - order: index + 1, + order: view.order ?? index, version: 1, column_meta: JSON.stringify(view.columnMeta), - is_locked: null, - enable_share: null, - share_id: null, - share_meta: null, + is_locked: view.isLocked ?? null, + enable_share: view.enableShare ?? null, + share_id: view.shareId ?? null, + share_meta: view.shareMeta === undefined ? null : JSON.stringify(view.shareMeta), created_time: now, last_modified_time: now, deleted_time: null, @@ -583,7 +596,7 @@ export class PostgresTableRepository implements core.ITableRepository { async findOne( context: core.IExecutionContext, spec: core.ISpecification, - options?: Pick + options?: core.TableFindOneOptions ): Promise> { const visitor = new TableWhereVisitor(options?.state); const acceptResult = spec.accept(visitor); @@ -602,6 +615,12 @@ export class PostgresTableRepository implements core.ITableRepository { if (specInfo.tableId) { attributes[core.TeableSpanAttributes.TABLE_ID] = specInfo.tableId; } + if (specInfo.viewId) { + attributes['teable.view_id'] = specInfo.viewId; + } + if (specInfo.viewIds) { + attributes['teable.view_ids'] = specInfo.viewIds.join(','); + } if (specInfo.incomingReferenceToTableId) { attributes['teable.incoming_reference_to_table_id'] = specInfo.incomingReferenceToTableId; } @@ -622,6 +641,14 @@ export class PostgresTableRepository implements core.ITableRepository { try { const db = resolvePostgresDbOrTx(this.db, context, 'meta'); + if (options?.lock === 'forUpdate') { + await db + .selectFrom('table_meta') + .select('id') + .where((eb) => whereFactory(eb)) + .forUpdate() + .executeTakeFirst(); + } const effectiveState = options?.state ?? 'active'; const fieldsLateral = db .selectNoFrom((eb) => [ @@ -677,9 +704,37 @@ export class PostgresTableRepository implements core.ITableRepository { (() => { let query = eb .selectFrom('view') - .select(['id', 'name', 'type', 'options', 'column_meta', 'sort', 'filter', 'group']) + .select([ + 'id', + 'name', + 'description', + 'type', + 'options', + 'order', + 'version', + 'column_meta', + 'sort', + 'filter', + 'group', + 'is_locked', + 'enable_share', + 'share_id', + 'share_meta', + 'created_time', + 'last_modified_time', + 'created_by', + 'last_modified_by', + ]) .where(sql`${sql.ref('view.table_id')} = ${sql.ref('table_meta.id')}`) .orderBy('order'); + if (specInfo.viewId) { + query = query.where('id', '=', specInfo.viewId); + } else if (specInfo.viewIds) { + query = + specInfo.viewIds.length > 0 + ? query.where('id', 'in', specInfo.viewIds) + : query.where(sql`false`); + } if (shouldFilterDeletedChildren(effectiveState)) { query = query.where('deleted_time', 'is', null); } else if (effectiveState === 'deleted') { @@ -699,6 +754,8 @@ export class PostgresTableRepository implements core.ITableRepository { .select([ 'table_meta.id', 'table_meta.name', + 'table_meta.description', + 'table_meta.icon', 'table_meta.base_id', 'table_meta.db_table_name', 'fields.fields', @@ -740,6 +797,7 @@ export class PostgresTableRepository implements core.ITableRepository { const whereResult = visitor.where(); if (whereResult.isErr()) return err(whereResult.error); const whereFactory = whereResult.value; + const specInfo = visitor.describe(); try { const db = resolvePostgresDbOrTx(this.db, context, 'meta'); @@ -795,9 +853,37 @@ export class PostgresTableRepository implements core.ITableRepository { (() => { let query = eb .selectFrom('view') - .select(['id', 'name', 'type', 'options', 'column_meta', 'sort', 'filter', 'group']) + .select([ + 'id', + 'name', + 'description', + 'type', + 'options', + 'order', + 'version', + 'column_meta', + 'sort', + 'filter', + 'group', + 'is_locked', + 'enable_share', + 'share_id', + 'share_meta', + 'created_time', + 'last_modified_time', + 'created_by', + 'last_modified_by', + ]) .where(sql`${sql.ref('view.table_id')} = ${sql.ref('table_meta.id')}`) .orderBy('order'); + if (specInfo.viewId) { + query = query.where('id', '=', specInfo.viewId); + } else if (specInfo.viewIds) { + query = + specInfo.viewIds.length > 0 + ? query.where('id', 'in', specInfo.viewIds) + : query.where(sql`false`); + } if (shouldFilterDeletedChildren(effectiveState)) { query = query.where('deleted_time', 'is', null); } else if (effectiveState === 'deleted') { @@ -817,6 +903,8 @@ export class PostgresTableRepository implements core.ITableRepository { .select([ 'table_meta.id', 'table_meta.name', + 'table_meta.description', + 'table_meta.icon', 'table_meta.base_id', 'table_meta.db_table_name', 'fields.fields', @@ -914,6 +1002,33 @@ export class PostgresTableRepository implements core.ITableRepository { context: core.IExecutionContext, table: core.Table, mutateSpec: core.ISpecification + ): Promise> { + // The FOR UPDATE view-version guard in executeUpdateOne only holds until the + // statement ends unless a transaction is open; without an ambient meta + // transaction, open one so validate + update + version reload are atomic. + const ambientTx = getPostgresTransaction(context, 'meta'); + if (ambientTx) { + return this.executeUpdateOne(ambientTx, context, table, mutateSpec); + } + try { + return await this.db.transaction().execute(async (trx) => { + const result = await this.executeUpdateOne(trx, context, table, mutateSpec); + if (result.isErr()) throw new TableUpdateRollback(result.error); + return result; + }); + } catch (error) { + if (error instanceof TableUpdateRollback) return err(error.domainError); + return err( + domainError.infrastructure({ message: `Failed to update table: ${describeError(error)}` }) + ); + } + } + + private async executeUpdateOne( + db: Kysely | Transaction, + context: core.IExecutionContext, + table: core.Table, + mutateSpec: core.ISpecification ): Promise> { const now = new Date(); const actorId = context.actorId.toString(); @@ -926,7 +1041,6 @@ export class PostgresTableRepository implements core.ITableRepository { eb.eb('deleted_time', 'is', null), ]); - const db = resolvePostgresDbOrTx(this.db, context, 'meta'); try { const updateVisitor = new TableMetaUpdateVisitor({ db, @@ -942,13 +1056,23 @@ export class PostgresTableRepository implements core.ITableRepository { if (statementsResult.isErr()) return err(statementsResult.error); if (statementsResult.value.length === 0) return ok(undefined); + const fieldVersionTouchOrder = updateVisitor.fieldVersionTouchOrder(); + const viewVersionTouchOrder = updateVisitor.viewVersionTouchOrder(); + const viewVersionValidationResult = await this.lockAndValidateViewVersions( + db, + table, + tableId, + viewVersionTouchOrder + ); + if (viewVersionValidationResult.isErr()) { + return err(viewVersionValidationResult.error); + } + await executeCompiledQueries( db, statementsResult.value.map((statement) => statement.compile()) ); - const fieldVersionTouchOrder = updateVisitor.fieldVersionTouchOrder(); - const viewVersionTouchOrder = updateVisitor.viewVersionTouchOrder(); if (fieldVersionTouchOrder.length === 0 && viewVersionTouchOrder.length === 0) { return ok(undefined); } @@ -996,6 +1120,74 @@ export class PostgresTableRepository implements core.ITableRepository { } } + private async lockAndValidateViewVersions( + db: Kysely | Transaction, + table: core.Table, + tableId: string, + viewIds: ReadonlyArray + ): Promise> { + const expectedVersions = new Map(); + for (const viewId of [...new Set(viewIds)].sort()) { + const viewResult = table.getViewById(viewId); + if (viewResult.isErr()) { + // A deleted child is absent from the mutated aggregate, so it cannot + // participate in validation through a rehydrated ViewVersion. + continue; + } + const versionResult = viewResult.value.version(); + // A newly added child has no persisted version yet. + if (versionResult.isOk()) { + expectedVersions.set(viewId, versionResult.value.toNumber()); + } + } + + const versionedViewIds = [...expectedVersions.keys()]; + if (versionedViewIds.length === 0) { + return ok(undefined); + } + + const rows = await db + .selectFrom('view') + .select(['id', 'version']) + .where('table_id', '=', tableId) + .where('deleted_time', 'is', null) + .where('id', 'in', versionedViewIds) + .orderBy('id') + .forUpdate() + .execute(); + const actualVersions = new Map(rows.map((row) => [row.id, Number(row.version ?? 0)])); + + for (const viewId of versionedViewIds) { + const expectedVersion = expectedVersions.get(viewId); + const actualVersion = actualVersions.get(viewId); + if (actualVersion === undefined) { + return err( + domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${viewId}`, + details: { tableId, viewId }, + }) + ); + } + if (actualVersion !== expectedVersion) { + return err( + domainError.conflict({ + code: 'view.version_conflict', + message: `View version conflict: ${viewId}`, + details: { + tableId, + viewId, + expectedVersion, + actualVersion, + }, + }) + ); + } + } + + return ok(undefined); + } + private async loadFieldVersionsByIds( db: Kysely | Transaction, tableId: string, @@ -1416,6 +1608,8 @@ export class PostgresTableRepository implements core.ITableRepository { private mapTableRow(row: { id: string; name: string; + description: string | null; + icon: string | null; base_id: string; db_table_name: string | null; fields: unknown; @@ -1450,12 +1644,23 @@ export class PostgresTableRepository implements core.ITableRepository { ? (row.views as Array<{ id: string; name: string; + description: string | null; type: string; options: string | null; + order: number; + version: number; column_meta: string | null; sort: string | null; filter: string | null; group: string | null; + is_locked: boolean | null; + enable_share: boolean | null; + share_id: string | null; + share_meta: string | null; + created_time: Date | string; + last_modified_time: Date | string | null; + created_by: string; + last_modified_by: string | null; }>) : []; @@ -1469,6 +1674,8 @@ export class PostgresTableRepository implements core.ITableRepository { id: row.id, baseId: row.base_id, name: row.name, + ...(row.description !== null ? { description: row.description } : {}), + ...(row.icon !== null ? { icon: row.icon } : {}), dbTableName: row.db_table_name ?? undefined, primaryFieldId, fields: fieldRows.map((f) => this.deserializeFieldDto(f)), @@ -1883,15 +2090,20 @@ export class PostgresTableRepository implements core.ITableRepository { group: string | null; } { const query = view.query; - const filter = query?.filter == null ? null : JSON.stringify(query.filter); + const filter = + view.sourceFilter !== undefined + ? JSON.stringify(view.sourceFilter) + : query?.filter == null + ? null + : JSON.stringify(query.filter); const sort = - !query?.sort?.length && query?.manualSort === undefined + query?.sort === undefined && query?.manualSort === undefined ? null : JSON.stringify({ ...(query?.sort ? { sortObjs: query.sort } : { sortObjs: [] }), ...(query?.manualSort !== undefined ? { manualSort: query.manualSort } : {}), }); - const group = query?.group?.length ? JSON.stringify(query.group) : null; + const group = query?.group === undefined ? null : JSON.stringify(query.group); return { filter, sort, group }; } @@ -1913,7 +2125,10 @@ export class PostgresTableRepository implements core.ITableRepository { ) .map((item) => ({ fieldId: item.fieldId, order: item.order })); const manualSort = typeof record.manualSort === 'boolean' ? record.manualSort : undefined; - return { sort: sort.length ? sort : undefined, manualSort }; + return { + sort: Array.isArray(record.sortObjs) ? sort : undefined, + manualSort, + }; } private parseViewGroup( @@ -1929,7 +2144,7 @@ export class PostgresTableRepository implements core.ITableRepository { typeof item.fieldId === 'string' && (item.order === 'asc' || item.order === 'desc') ) .map((item) => ({ fieldId: item.fieldId, order: item.order })); - return group.length ? group : undefined; + return group; } private mapV1FilterToV2(filter: unknown): core.RecordFilter | null | undefined { @@ -1969,11 +2184,10 @@ export class PostgresTableRepository implements core.ITableRepository { private mapV1FilterGroup(filter: { conjunction: 'and' | 'or'; filterSet: unknown[]; - }): core.RecordFilterGroup | null { + }): core.RecordFilterGroup { const items = filter.filterSet .map((entry) => this.mapV1FilterEntry(entry)) .filter((entry): entry is core.RecordFilterNode => Boolean(entry)); - if (items.length === 0) return null; return { conjunction: filter.conjunction === 'or' ? 'or' : 'and', items, @@ -2232,40 +2446,77 @@ export class PostgresTableRepository implements core.ITableRepository { private deserializeViewDto(row: { id: string; name: string; + description: string | null; type: string; options: string | null; + order: number; + version: number; column_meta: string | null; sort: string | null; filter: string | null; group: string | null; + is_locked: boolean | null; + enable_share: boolean | null; + share_id: string | null; + share_meta: string | null; + created_time: Date | string; + last_modified_time: Date | string | null; + created_by: string; + last_modified_by: string | null; }): Result { const columnMeta = this.parseOptions( row.column_meta ) as core.ITableViewPersistenceDTO['columnMeta']; const filter = this.parseViewFilter(row.filter); + // Only a legacy-shaped filter (filterSet) needs source preservation; a v2 + // canonical filter round-trips through query.filter and must not be fed to + // the legacy source-filter schema. + const rawFilter = row.filter == null ? undefined : this.parseJsonValue(row.filter); + const sourceFilter = + rawFilter != null && typeof rawFilter === 'object' && 'filterSet' in rawFilter + ? rawFilter + : undefined; const sortResult = this.parseViewSort(row.sort); const group = this.parseViewGroup(row.group); const query: core.ViewQueryDefaultsDTO = { ...(filter !== undefined ? { filter } : {}), ...(sortResult.sort ? { sort: sortResult.sort } : {}), - ...(group ? { group } : {}), + ...(group !== undefined ? { group } : {}), ...(sortResult.manualSort !== undefined ? { manualSort: sortResult.manualSort } : {}), }; const options = row.options === null ? undefined : this.parseJsonValue(row.options); + const shareMeta = row.share_meta === null ? undefined : this.parseJsonValue(row.share_meta); + const base = { + id: row.id, + name: row.name, + version: Number(row.version), + order: Number(row.order), + ...(row.description !== null ? { description: row.description } : {}), + ...(row.is_locked !== null ? { isLocked: row.is_locked } : {}), + ...(row.enable_share !== null ? { enableShare: row.enable_share } : {}), + ...(row.share_id !== null ? { shareId: row.share_id } : {}), + ...(shareMeta !== undefined + ? { shareMeta: shareMeta as core.ITableViewPersistenceDTO['shareMeta'] } + : {}), + ...(row.created_by != null ? { createdBy: row.created_by } : {}), + ...(row.created_time != null ? { createdTime: toIsoTimestamp(row.created_time) } : {}), + ...(row.last_modified_by != null ? { lastModifiedBy: row.last_modified_by } : {}), + ...(row.last_modified_time != null + ? { lastModifiedTime: toIsoTimestamp(row.last_modified_time) } + : {}), + columnMeta, + query, + ...(sourceFilter !== undefined ? { sourceFilter } : {}), + options, + }; - if (row.type === 'grid') - return ok({ id: row.id, name: row.name, type: 'grid', columnMeta, query, options }); - if (row.type === 'kanban') - return ok({ id: row.id, name: row.name, type: 'kanban', columnMeta, query, options }); - if (row.type === 'gallery') - return ok({ id: row.id, name: row.name, type: 'gallery', columnMeta, query, options }); - if (row.type === 'calendar') - return ok({ id: row.id, name: row.name, type: 'calendar', columnMeta, query, options }); - if (row.type === 'form') - return ok({ id: row.id, name: row.name, type: 'form', columnMeta, query, options }); - if (row.type === 'plugin') - return ok({ id: row.id, name: row.name, type: 'plugin', columnMeta, query, options }); + if (row.type === 'grid') return ok({ ...base, type: 'grid' }); + if (row.type === 'kanban') return ok({ ...base, type: 'kanban' }); + if (row.type === 'gallery') return ok({ ...base, type: 'gallery' }); + if (row.type === 'calendar') return ok({ ...base, type: 'calendar' }); + if (row.type === 'form') return ok({ ...base, type: 'form' }); + if (row.type === 'plugin') return ok({ ...base, type: 'plugin' }); return err(domainError.validation({ message: 'Unsupported view type' })); } diff --git a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRowLimitPlugin.spec.ts b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRowLimitPlugin.spec.ts index 2055c111b6..39f269ec03 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRowLimitPlugin.spec.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRowLimitPlugin.spec.ts @@ -257,6 +257,29 @@ describe('PostgresTableRowLimitPlugin', () => { }); }); + it('rejects creation with a localized rows-per-table error when the cap is exceeded', async () => { + const executor = { + transformQuery: (node: unknown) => node, + compileQuery: () => ({ sql: '', parameters: [], query: { kind: 'RawNode' } }), + executeQuery: vi.fn().mockResolvedValue({ rows: [{ count: '10' }] }), + }; + const db = { getExecutor: () => executor } as unknown as Kysely; + + const plugin = new PostgresTableRowLimitPlugin(db, new StaticTableRowLimitPolicy(10)); + const context = createContext(); + const prepared = (await plugin.prepare(context))._unsafeUnwrap(); + const result = await plugin.guard(context, prepared); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toMatchObject({ + code: 'validation.limit.rows_per_table_max', + localization: { + i18nKey: 'httpErrors.limit.rowsPerTableMax', + context: { max: 10 }, + }, + }); + }); + it('short-circuits guard and beforePersist when there is nothing to enforce', async () => { const plugin = new PostgresTableRowLimitPlugin( createDb().db, diff --git a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRowLimitPlugin.ts b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRowLimitPlugin.ts index c80cc458fa..d4d5c7a475 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRowLimitPlugin.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRowLimitPlugin.ts @@ -137,7 +137,7 @@ export class PostgresTableRowLimitPlugin if (rowCount + recordCount > preparedState.maxRowCount) { return err( core.domainError.validation({ - code: 'validation.limit.rows_per_table_max', + code: core.tableDataSafetyLimitErrors.rowsPerTableMax.code, message: `Exceed max row limit: ${preparedState.maxRowCount}, please contact us to increase the limit`, details: { max: preparedState.maxRowCount, @@ -145,6 +145,10 @@ export class PostgresTableRowLimitPlugin rowCount, recordCount, }, + localization: { + i18nKey: core.tableDataSafetyLimitErrors.rowsPerTableMax.i18nKey, + context: { max: preparedState.maxRowCount }, + }, }) ); } diff --git a/packages/v2/adapter-repository-postgres/src/repositories/PostgresViewPluginRepository.ts b/packages/v2/adapter-repository-postgres/src/repositories/PostgresViewPluginRepository.ts new file mode 100644 index 0000000000..0afc47fdbf --- /dev/null +++ b/packages/v2/adapter-repository-postgres/src/repositories/PostgresViewPluginRepository.ts @@ -0,0 +1,247 @@ +import { getPostgresTransaction } from '@teable/v2-adapter-db-postgres-shared'; +import { + domainError, + type DomainError, + type IExecutionContext, + type IViewPluginRepository, + type UpdateViewPluginStorageInput, + type ViewPluginDefinition, + type ViewPluginInstallation, + type ViewPluginInstallationInfo, + type ViewPluginInstallationSource, +} from '@teable/v2-core'; +import { inject, injectable } from '@teable/v2-di'; +import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; +import type { Kysely } from 'kysely'; +import { err, ok, type Result } from 'neverthrow'; + +import { v2PostgresStateTokens } from '../di/tokens'; + +const viewPosition = 'view'; +const publishedStatus = 'published'; + +@injectable() +export class PostgresViewPluginRepository implements IViewPluginRepository { + constructor( + @inject(v2PostgresStateTokens.db) + private readonly db: Kysely + ) {} + + async findViewPlugin( + context: IExecutionContext, + pluginId: string + ): Promise> { + const db = getPostgresTransaction(context, 'meta') ?? this.db; + try { + const row = await db + .selectFrom('plugin') + .select(['id', 'name', 'logo', 'positions']) + .where('id', '=', pluginId) + .where((eb) => + eb.or([ + eb('status', '=', publishedStatus), + eb('created_by', '=', context.actorId.toString()), + ]) + ) + .executeTakeFirst(); + if (!row) { + return err(domainError.notFound({ message: `Plugin not found with id: ${pluginId}` })); + } + + const positions = JSON.parse(row.positions) as unknown; + if (!Array.isArray(positions) || !positions.includes(viewPosition)) { + return err( + domainError.validation({ + message: `Plugin ${pluginId} does not support install in view`, + }) + ); + } + return ok({ id: row.id, name: row.name, logo: row.logo }); + } catch (error) { + return err( + domainError.infrastructure({ + message: `Failed to resolve View plugin: ${error instanceof Error ? error.message : String(error)}`, + }) + ); + } + } + + async insertViewPluginInstallation( + context: IExecutionContext, + installation: ViewPluginInstallation + ): Promise> { + const db = getPostgresTransaction(context, 'meta') ?? this.db; + try { + await db + .insertInto('plugin_install') + .values({ + id: installation.id, + plugin_id: installation.pluginId, + base_id: installation.baseId, + name: installation.name, + position_id: installation.viewId, + position: viewPosition, + storage: installation.storage ?? null, + created_by: context.actorId.toString(), + last_modified_by: null, + }) + .execute(); + return ok(undefined); + } catch (error) { + return err( + domainError.infrastructure({ + message: `Failed to install View plugin: ${error instanceof Error ? error.message : String(error)}`, + }) + ); + } + } + + async findViewPluginInstallationByViewId( + context: IExecutionContext, + viewId: string + ): Promise> { + const db = getPostgresTransaction(context, 'meta') ?? this.db; + try { + const row = await db + .selectFrom('plugin_install') + .select('storage') + .where('position_id', '=', viewId) + .where('position', '=', viewPosition) + .executeTakeFirst(); + if (!row) { + return err( + domainError.notFound({ + message: `Plugin installation not found for View: ${viewId}`, + }) + ); + } + return ok({ storage: row.storage }); + } catch (error) { + return err( + domainError.infrastructure({ + message: `Failed to resolve View plugin installation: ${error instanceof Error ? error.message : String(error)}`, + }) + ); + } + } + + async getViewPluginInstallation( + context: IExecutionContext, + baseId: string, + viewId: string + ): Promise> { + const db = getPostgresTransaction(context, 'meta') ?? this.db; + try { + const row = await db + .selectFrom('plugin_install') + .innerJoin('plugin', 'plugin.id', 'plugin_install.plugin_id') + .select([ + 'plugin_install.id as id', + 'plugin_install.plugin_id as pluginId', + 'plugin_install.base_id as baseId', + 'plugin_install.position_id as viewId', + 'plugin_install.name as name', + 'plugin_install.storage as storage', + 'plugin.url as url', + ]) + .where('plugin_install.base_id', '=', baseId) + .where('plugin_install.position_id', '=', viewId) + .where('plugin_install.position', '=', viewPosition) + .executeTakeFirst(); + if (!row) { + return err( + domainError.notFound({ + message: `Plugin installation not found for View: ${viewId}`, + }) + ); + } + + let storage: Readonly> | undefined; + if (row.storage != null) { + const parsed = JSON.parse(row.storage) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return err( + domainError.infrastructure({ + message: `Invalid storage for View plugin installation: ${row.id}`, + }) + ); + } + storage = parsed as Readonly>; + } + return ok({ + id: row.id, + pluginId: row.pluginId, + baseId: row.baseId, + viewId: row.viewId, + name: row.name, + ...(row.url != null ? { url: row.url } : {}), + ...(storage !== undefined ? { storage } : {}), + }); + } catch (error) { + return err( + domainError.infrastructure({ + message: `Failed to resolve View plugin installation: ${ + error instanceof Error ? error.message : String(error) + }`, + }) + ); + } + } + + async updateViewPluginStorage( + context: IExecutionContext, + input: UpdateViewPluginStorageInput + ): Promise> { + const db = getPostgresTransaction(context, 'meta') ?? this.db; + try { + if (input.storage === undefined) { + const installation = await db + .selectFrom('plugin_install') + .select('id') + .where('id', '=', input.pluginInstallId) + .where('base_id', '=', input.baseId) + .where('position_id', '=', input.viewId) + .where('position', '=', viewPosition) + .executeTakeFirst(); + if (!installation) { + return err( + domainError.notFound({ + message: `Plugin installation not found: ${input.pluginInstallId}`, + }) + ); + } + return ok(undefined); + } + + const updated = await db + .updateTable('plugin_install') + .set({ + storage: JSON.stringify(input.storage), + last_modified_time: new Date(), + last_modified_by: context.actorId.toString(), + }) + .where('id', '=', input.pluginInstallId) + .where('base_id', '=', input.baseId) + .where('position_id', '=', input.viewId) + .where('position', '=', viewPosition) + .returning('id') + .executeTakeFirst(); + if (!updated) { + return err( + domainError.notFound({ + message: `Plugin installation not found: ${input.pluginInstallId}`, + }) + ); + } + return ok(undefined); + } catch (error) { + return err( + domainError.infrastructure({ + message: `Failed to update View plugin storage: ${ + error instanceof Error ? error.message : String(error) + }`, + }) + ); + } + } +} diff --git a/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableMetaUpdateVisitor.spec.ts b/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableMetaUpdateVisitor.spec.ts index b592defc0c..7f681ad701 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableMetaUpdateVisitor.spec.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableMetaUpdateVisitor.spec.ts @@ -16,17 +16,25 @@ import { RollupExpression, RollupFieldConfig, Table, + TableAddViewSpec, TableByNameSpec, TableId, + TableRemoveViewSpec, + TableRenameViewSpec, + TableUpdateViewDescriptionSpec, + TableUpdateViewLockedSpec, type ITableMapper, TableName, + TableProperties, TableRenameSpec, + TableUpdatePropertiesSpec, TableUpdateFieldDbFieldNameSpec, TableUpdateFieldHasErrorSpec, TableUpdateFieldNameSpec, UpdateUserMultiplicitySpec, UserMultiplicity, ViewColumnMeta, + ViewName, } from '@teable/v2-core'; import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; import { @@ -176,6 +184,173 @@ const compileStatements = ( ): CompiledQuery[] => statements.map((statement) => statement.compile(db)); describe('TableMetaUpdateVisitor', () => { + it('inserts a view with max-order allocation and version tracking', () => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'v'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'v'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Title')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + const originalTable = builder.build()._unsafeUnwrap(); + const sourceFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: originalTable.primaryFieldId().toString(), + operator: 'IN', + isSymbol: true, + value: 'alpha', + }, + ], + }; + const createResult = originalTable + .createView({ + name: 'Planning', + type: 'grid', + filter: { + fieldId: originalTable.primaryFieldId().toString(), + operator: 'isAnyOf', + value: ['alpha'], + }, + sourceFilter, + }) + ._unsafeUnwrap(); + const { view } = createResult; + const table = createResult.updateResult.table; + const { db, visitor } = createVisitor(table); + + const result = visitor.visitTableAddView(TableAddViewSpec.create(view)); + + expect(result.isOk()).toBe(true); + const compiled = compileStatements(db, result._unsafeUnwrap())[0]!; + expect(compiled.sql).toContain('insert into "view"'); + expect(compiled.sql).toContain('select max("order") from "view"'); + expect(compiled.sql).toContain('on conflict ("id") do update'); + expect(compiled.sql).toContain('"deleted_time" = $'); + expect(compiled.sql).toContain('"version" = coalesce(view.version, 0) + 1'); + expect(compiled.sql).toContain('"version"'); + expect(compiled.parameters).toContain( + JSON.stringify(view.queryDefaults()._unsafeUnwrap().sourceFilter()) + ); + expect(visitor.viewVersionTouchOrder()).toEqual([view.id().toString()]); + }); + + it('soft-deletes only the View owned by the Table aggregate', () => { + const fixture = createTableFixture(); + const { db, visitor } = createVisitor(fixture.table); + + const result = visitor.visitTableRemoveView(TableRemoveViewSpec.create(fixture.view)); + + expect(result.isOk()).toBe(true); + const compiled = compileStatements(db, result._unsafeUnwrap())[0]!; + expect(compiled.sql).toContain('update "view"'); + expect(compiled.sql).toContain('"deleted_time" = $1'); + expect(compiled.sql).toContain('"id" ='); + expect(compiled.sql).toContain('"table_id" ='); + expect(compiled.sql).toContain('"deleted_time" is null'); + expect(compiled.parameters).toContain(fixture.view.id().toString()); + expect(compiled.parameters).toContain(fixture.table.id().toString()); + }); + + it('renames only the active View owned by the Table and tracks its version', () => { + const fixture = createTableFixture(); + const { db, visitor } = createVisitor(fixture.table); + const spec = TableRenameViewSpec.create( + fixture.view.id(), + fixture.view.name(), + ViewName.create('Renamed view')._unsafeUnwrap() + ); + + const result = visitor.visitTableRenameView(spec); + + expect(result.isOk()).toBe(true); + const compiled = compileStatements(db, result._unsafeUnwrap())[0]!; + expect(compiled.sql).toContain('update "view"'); + expect(compiled.sql).toContain('"name" = $1'); + expect(compiled.sql).toContain('"version" = coalesce(version, 0) + 1'); + expect(compiled.sql).toContain('"last_modified_time"'); + expect(compiled.sql).toContain('"last_modified_by"'); + expect(compiled.sql).toContain('"table_id" ='); + expect(compiled.sql).toContain('"deleted_time" is null'); + expect(compiled.parameters).toContain('Renamed view'); + expect(compiled.parameters).toContain(fixture.view.id().toString()); + expect(compiled.parameters).toContain(fixture.table.id().toString()); + expect(visitor.viewVersionTouchOrder()).toEqual([fixture.view.id().toString()]); + }); + + it('updates description only for the active owned View and tracks its version', () => { + const fixture = createTableFixture(); + const { db, visitor } = createVisitor(fixture.table); + const spec = TableUpdateViewDescriptionSpec.create( + fixture.view.id(), + undefined, + 'Updated description' + ); + + const result = visitor.visitTableUpdateViewDescription(spec); + + expect(result.isOk()).toBe(true); + const compiled = compileStatements(db, result._unsafeUnwrap())[0]!; + expect(compiled.sql).toContain('update "view"'); + expect(compiled.sql).toContain('"description" = $1'); + expect(compiled.sql).toContain('"version" = coalesce(version, 0) + 1'); + expect(compiled.sql).toContain('"last_modified_time"'); + expect(compiled.sql).toContain('"last_modified_by"'); + expect(compiled.sql).toContain('"table_id" ='); + expect(compiled.sql).toContain('"deleted_time" is null'); + expect(compiled.parameters).toContain('Updated description'); + expect(compiled.parameters).toContain(fixture.view.id().toString()); + expect(compiled.parameters).toContain(fixture.table.id().toString()); + expect(visitor.viewVersionTouchOrder()).toEqual([fixture.view.id().toString()]); + }); + + it('persists an omitted View description as null for snapshot replay', () => { + const fixture = createTableFixture(); + const { db, visitor } = createVisitor(fixture.table); + const spec = TableUpdateViewDescriptionSpec.create( + fixture.view.id(), + 'Temporary description', + undefined + ); + + const result = visitor.visitTableUpdateViewDescription(spec); + + expect(result.isOk()).toBe(true); + const compiled = compileStatements(db, result._unsafeUnwrap())[0]!; + expect(compiled.sql).toContain('"description" = $1'); + expect(compiled.parameters[0]).toBeNull(); + expect(visitor.viewVersionTouchOrder()).toEqual([fixture.view.id().toString()]); + }); + + it.each([ + ['locked', true, true], + ['unlocked', false, false], + ['omitted', undefined, null], + ] as const)( + 'updates the %s state only for the active owned View and tracks its version', + (_label, nextIsLocked, expectedValue) => { + const fixture = createTableFixture(); + const { db, visitor } = createVisitor(fixture.table); + const spec = TableUpdateViewLockedSpec.create(fixture.view.id(), undefined, nextIsLocked); + + const result = visitor.visitTableUpdateViewLocked(spec); + + expect(result.isOk()).toBe(true); + const compiled = compileStatements(db, result._unsafeUnwrap())[0]!; + expect(compiled.sql).toContain('update "view"'); + expect(compiled.sql).toContain('"is_locked" = $1'); + expect(compiled.sql).toContain('"version" = coalesce(version, 0) + 1'); + expect(compiled.sql).toContain('"last_modified_time"'); + expect(compiled.sql).toContain('"last_modified_by"'); + expect(compiled.sql).toContain('"table_id" ='); + expect(compiled.sql).toContain('"deleted_time" is null'); + expect(compiled.parameters).toContain(expectedValue); + expect(compiled.parameters).toContain(fixture.view.id().toString()); + expect(compiled.parameters).toContain(fixture.table.id().toString()); + expect(visitor.viewVersionTouchOrder()).toEqual([fixture.view.id().toString()]); + } + ); + it('builds table metadata updates and merges collected statements', () => { const fixture = createTableFixture(); const { db, visitor } = createVisitor(fixture.table); @@ -199,6 +374,30 @@ describe('TableMetaUpdateVisitor', () => { expect(sqls[1]).toContain('update "table_meta"'); }); + it('builds a database update for table description and icon', () => { + const fixture = createTableFixture(); + const { db, visitor } = createVisitor(fixture.table); + const previousProperties = TableProperties.empty(); + const nextProperties = TableProperties.create({ + description: 'Projects tracked by the team', + icon: '📊', + })._unsafeUnwrap(); + const spec = TableUpdatePropertiesSpec.create(previousProperties, nextProperties, { + description: 'Projects tracked by the team', + icon: '📊', + }); + + const result = visitor.visitTableUpdateProperties(spec); + + expect(result.isOk()).toBe(true); + const compiled = compileStatements(db, result._unsafeUnwrap())[0]!; + expect(compiled.sql).toContain('update "table_meta"'); + expect(compiled.sql).toContain('"description" = $1'); + expect(compiled.sql).toContain('"icon" = $2'); + expect(compiled.parameters).toContain('Projects tracked by the team'); + expect(compiled.parameters).toContain('📊'); + }); + it('builds add, addMany, duplicate and remove field statements', () => { const augmented = Table.builder() .withBaseId(BaseId.create(`bse${'k'.repeat(16)}`)._unsafeUnwrap()) @@ -424,6 +623,7 @@ describe('TableMetaUpdateVisitor', () => { { viewId: view.id(), queryDefaults: { + sourceFilter: () => undefined, toDto: () => ({ filter: { conjunction: 'and', @@ -488,6 +688,105 @@ describe('TableMetaUpdateVisitor', () => { expect(visitor.viewVersionTouchOrder()).toEqual([view.id().toString(), view.id().toString()]); }); + it('clears View options when a column-meta snapshot explicitly removes them', () => { + const fixture = createTableFixture(); + const { db, visitor } = createVisitor(fixture.table); + const { view, titleField } = fixture; + const columnMeta = ViewColumnMeta.create({ + [titleField.id().toString()]: { order: 0 }, + })._unsafeUnwrap(); + + const result = visitor.visitTableUpdateViewColumnMeta({ + updates: () => [ + { + viewId: view.id(), + fieldId: titleField.id(), + columnMeta, + previousOptions: { rowHeight: 2 }, + nextOptions: undefined, + optionsChanged: true, + }, + ], + } as never); + + expect(result.isOk()).toBe(true); + const compiled = compileStatements(db, result._unsafeUnwrap())[0]!; + expect(compiled.sql).toContain('"column_meta" = $1'); + expect(compiled.sql).toContain('"options" = $2'); + expect(compiled.parameters[1]).toBeNull(); + expect(visitor.viewVersionTouchOrder()).toEqual([view.id().toString()]); + }); + + it('serializes a focused View options update and scopes it to the Table aggregate', () => { + const fixture = createTableFixture(); + const { db, visitor } = createVisitor(fixture.table); + const { view } = fixture; + const nextOptions = { rowHeight: 'tall', fieldNameDisplayLines: 2 }; + + const result = visitor.visitTableUpdateViewOptions({ + update: () => ({ + viewId: view.id(), + previousOptions: { rowHeight: 'short' }, + nextOptions, + }), + } as never); + + expect(result.isOk()).toBe(true); + const compiled = compileStatements(db, result._unsafeUnwrap())[0]!; + expect(compiled.sql).toContain('update "view"'); + expect(compiled.sql).toContain('"table_id" ='); + expect(compiled.sql).toContain('"deleted_time" is null'); + expect(compiled.parameters).toContain(JSON.stringify(nextOptions)); + expect(compiled.parameters).toContain(fixture.table.id().toString()); + expect(visitor.viewVersionTouchOrder()).toEqual([view.id().toString()]); + }); + + it('serializes focused View share metadata and scopes it to the Table aggregate', () => { + const fixture = createTableFixture(); + const { db, visitor } = createVisitor(fixture.table); + const { view } = fixture; + const nextShareMeta = { allowCopy: true, password: 'secret' }; + + const result = visitor.visitTableUpdateViewShareMeta({ + viewId: () => view.id(), + previousShareMeta: () => undefined, + nextShareMeta: () => nextShareMeta, + } as never); + + expect(result.isOk()).toBe(true); + const compiled = compileStatements(db, result._unsafeUnwrap())[0]!; + expect(compiled.sql).toContain('update "view"'); + expect(compiled.sql).toContain('"share_meta" ='); + expect(compiled.sql).toContain('"table_id" ='); + expect(compiled.sql).toContain('"deleted_time" is null'); + expect(compiled.parameters).toContain(JSON.stringify(nextShareMeta)); + expect(compiled.parameters).toContain(fixture.table.id().toString()); + expect(visitor.viewVersionTouchOrder()).toEqual([view.id().toString()]); + }); + + it('serializes a focused View share ID rotation and scopes it to the Table aggregate', () => { + const fixture = createTableFixture(); + const { db, visitor } = createVisitor(fixture.table); + const { view } = fixture; + const nextShareId = `shr${'b'.repeat(16)}`; + + const result = visitor.visitTableUpdateViewShareId({ + viewId: () => view.id(), + previousShareId: () => `shr${'a'.repeat(16)}`, + nextShareId: () => nextShareId, + } as never); + + expect(result.isOk()).toBe(true); + const compiled = compileStatements(db, result._unsafeUnwrap())[0]!; + expect(compiled.sql).toContain('update "view"'); + expect(compiled.sql).toContain('"share_id" ='); + expect(compiled.sql).toContain('"table_id" ='); + expect(compiled.sql).toContain('"deleted_time" is null'); + expect(compiled.parameters).toContain(nextShareId); + expect(compiled.parameters).toContain(fixture.table.id().toString()); + expect(visitor.viewVersionTouchOrder()).toEqual([view.id().toString()]); + }); + it('covers option-based and storage-based wrapper updates', () => { const fixture = createTableFixture(); const { db, visitor } = createVisitor(fixture.table); @@ -627,6 +926,8 @@ describe('TableMetaUpdateVisitor', () => { const unsupported = [ ['visitTableByBaseId', 'TableByBaseIdSpec is not supported for table updates'], ['visitTableById', 'TableByIdSpec is not supported for table updates'], + ['visitTableByViewId', 'TableByViewIdSpec is not supported for table updates'], + ['visitTableWithViewIds', 'TableWithViewIdsSpec is not supported for table updates'], [ 'visitTableByIncomingReferenceToTable', 'TableByIncomingReferenceToTableSpec is not supported for table updates', diff --git a/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableMetaUpdateVisitor.ts b/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableMetaUpdateVisitor.ts index ab41e740dd..e1c04af5d8 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableMetaUpdateVisitor.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableMetaUpdateVisitor.ts @@ -2,16 +2,30 @@ import { AbstractSpecFilterVisitor, TableAddFieldSpec, TableAddFieldsSpec, + TableAddViewSpec, + TableEnsureViewRowOrderSpec, + TableRemoveViewSpec, TableAddSelectOptionsSpec, TableDuplicateFieldSpec, TableRemoveFieldSpec, TableByBaseIdSpec, TableByIdSpec, + TableByViewIdSpec, + TableWithViewIdsSpec, TableByIncomingReferenceToTableSpec, TableByIdsSpec, TableByNameLikeSpec, TableByNameSpec, TableRenameSpec, + TableUpdatePropertiesSpec, + TableRenameViewSpec, + TableUpdateViewDescriptionSpec, + TableUpdateViewLockedSpec, + TableUpdateViewOrderSpec, + TableUpdateViewOptionsSpec, + TableUpdateViewShareIdSpec, + TableUpdateViewShareMetaSpec, + TableUpdateViewShareStateSpec, TableUpdateViewColumnMetaSpec, TableUpdateViewQueryDefaultsSpec, type TableViewQueryDefaultsUpdate, @@ -92,6 +106,7 @@ export type TableUpdateBuilder = | UpdateQueryBuilder | UpdateQueryBuilder | InsertQueryBuilder + | InsertQueryBuilder | DeleteQueryBuilder; type TableMetaUpdateVisitorParams = { @@ -105,6 +120,8 @@ type TableMetaUpdateVisitorParams = { type TableMetaUpdate = { name?: string; + description?: string | null; + icon?: string | null; }; export class TableMetaUpdateVisitor @@ -168,6 +185,206 @@ export class TableMetaUpdateVisitor return this.addCond(statements).map(() => statements); } + visitTableAddView( + spec: TableAddViewSpec + ): Result, DomainError> { + const dtoResult = this.params.tableMapper.toDTO(this.params.table); + if (dtoResult.isErr()) return err(dtoResult.error); + const view = dtoResult.value.views.find( + (candidate) => candidate.id === spec.view().id().toString() + ); + if (!view) { + return err(domainError.invariant({ message: 'Created view missing from table snapshot' })); + } + + const query = view.query ?? {}; + const sortPayload = + query.sort || query.manualSort !== undefined + ? { + ...(query.sort ? { sortObjs: query.sort } : {}), + ...(query.manualSort !== undefined ? { manualSort: query.manualSort } : {}), + } + : null; + const tableId = this.params.table.id().toString(); + this.trackViewVersionTouch(view.id); + + const row = { + id: view.id, + name: view.name, + description: view.description ?? null, + table_id: tableId, + type: view.type, + options: view.options === undefined ? null : JSON.stringify(view.options), + order: + view.order ?? + sql`coalesce(( + select max("order") from "view" + where "table_id" = ${tableId} and "deleted_time" is null + ), -1) + 1`, + version: 1, + column_meta: JSON.stringify(view.columnMeta), + filter: + view.sourceFilter !== undefined + ? this.stringifyLegacyFilter(view.sourceFilter) + : query.filter === undefined + ? null + : this.stringifyLegacyFilter(this.mapRecordFilterToLegacy(query.filter)), + sort: sortPayload ? JSON.stringify(sortPayload) : null, + group: query.group ? JSON.stringify(query.group) : null, + is_locked: view.isLocked ?? null, + enable_share: view.enableShare ?? null, + share_id: view.shareId ?? null, + share_meta: view.shareMeta === undefined ? null : JSON.stringify(view.shareMeta), + created_time: this.params.now, + last_modified_time: this.params.now, + deleted_time: null, + created_by: this.params.actorId, + last_modified_by: this.params.actorId, + }; + const statements: ReadonlyArray = [ + this.params.db + .insertInto('view') + .values(row) + .onConflict((oc) => + oc.column('id').doUpdateSet({ + name: row.name, + description: row.description, + table_id: sql`excluded."table_id"`, + order: sql`excluded."order"`, + type: row.type, + options: row.options, + column_meta: row.column_meta, + filter: row.filter, + sort: row.sort, + group: row.group, + is_locked: row.is_locked, + enable_share: row.enable_share, + share_id: row.share_id, + share_meta: row.share_meta, + version: sql`coalesce(view.version, 0) + 1`, + deleted_time: null, + last_modified_time: row.last_modified_time, + last_modified_by: row.last_modified_by, + }) + ), + ]; + + return this.addCond(statements).map(() => statements); + } + + visitTableEnsureViewRowOrder( + _spec: TableEnsureViewRowOrderSpec + ): Result, DomainError> { + const statements: ReadonlyArray = []; + return this.addCond(statements).map(() => statements); + } + + visitTableRemoveView( + spec: TableRemoveViewSpec + ): Result, DomainError> { + const statements: ReadonlyArray = [ + this.params.db + .updateTable('view') + .set({ + deleted_time: this.params.now, + last_modified_time: this.params.now, + last_modified_by: this.params.actorId, + }) + .where('id', '=', spec.view().id().toString()) + .where('table_id', '=', this.params.table.id().toString()) + .where('deleted_time', 'is', null), + ]; + + return this.addCond(statements).map(() => statements); + } + + visitTableRenameView( + spec: TableRenameViewSpec + ): Result, DomainError> { + const viewId = spec.viewId().toString(); + this.trackViewVersionTouch(viewId); + const statements: ReadonlyArray = [ + this.params.db + .updateTable('view') + .set({ + name: spec.nextName().toString(), + version: this.viewVersionIncrement, + last_modified_time: this.params.now, + last_modified_by: this.params.actorId, + }) + .where('id', '=', viewId) + .where('table_id', '=', this.params.table.id().toString()) + .where('deleted_time', 'is', null), + ]; + return this.addCond(statements).map(() => statements); + } + + visitTableUpdateViewDescription( + spec: TableUpdateViewDescriptionSpec + ): Result, DomainError> { + const viewId = spec.viewId().toString(); + this.trackViewVersionTouch(viewId); + const statements: ReadonlyArray = [ + this.params.db + .updateTable('view') + .set({ + description: spec.nextDescription() ?? null, + version: this.viewVersionIncrement, + last_modified_time: this.params.now, + last_modified_by: this.params.actorId, + }) + .where('id', '=', viewId) + .where('table_id', '=', this.params.table.id().toString()) + .where('deleted_time', 'is', null), + ]; + return this.addCond(statements).map(() => statements); + } + + visitTableUpdateViewLocked( + spec: TableUpdateViewLockedSpec + ): Result, DomainError> { + const viewId = spec.viewId().toString(); + this.trackViewVersionTouch(viewId); + const statements: ReadonlyArray = [ + this.params.db + .updateTable('view') + .set({ + is_locked: spec.nextIsLocked() ?? null, + version: this.viewVersionIncrement, + last_modified_time: this.params.now, + last_modified_by: this.params.actorId, + }) + .where('id', '=', viewId) + .where('table_id', '=', this.params.table.id().toString()) + .where('deleted_time', 'is', null), + ]; + return this.addCond(statements).map(() => statements); + } + + visitTableUpdateViewOrder( + spec: TableUpdateViewOrderSpec + ): Result, DomainError> { + const statements: TableUpdateBuilder[] = []; + for (const change of spec.changes()) { + const viewId = change.viewId.toString(); + this.trackViewVersionTouch(viewId); + statements.push( + this.params.db + .updateTable('view') + .set({ + order: change.nextOrder.toNumber(), + version: this.viewVersionIncrement, + last_modified_time: this.params.now, + last_modified_by: this.params.actorId, + }) + .where('id', '=', viewId) + .where('table_id', '=', this.params.table.id().toString()) + .where('deleted_time', 'is', null) + ); + } + return this.addCond(statements).map(() => statements); + } + visitTableAddSelectOptions( spec: TableAddSelectOptionsSpec ): Result, DomainError> { @@ -247,17 +464,101 @@ export class TableMetaUpdateVisitor .updateTable('view') .set({ column_meta: JSON.stringify(update.columnMeta.toDto()), + ...(update.optionsChanged + ? { + options: + update.nextOptions === undefined ? null : JSON.stringify(update.nextOptions), + } + : {}), version: this.viewVersionIncrement, last_modified_time: this.params.now, last_modified_by: this.params.actorId, }) .where('id', '=', update.viewId.toString()) + .where('table_id', '=', this.params.table.id().toString()) .where('deleted_time', 'is', null) ); return this.addCond(statements).map(() => statements); } + visitTableUpdateViewOptions( + spec: TableUpdateViewOptionsSpec + ): Result, DomainError> { + const update = spec.update(); + this.trackViewVersionTouch(update.viewId.toString()); + const statement = this.params.db + .updateTable('view') + .set({ + options: JSON.stringify(update.nextOptions), + version: this.viewVersionIncrement, + last_modified_time: this.params.now, + last_modified_by: this.params.actorId, + }) + .where('id', '=', update.viewId.toString()) + .where('table_id', '=', this.params.table.id().toString()) + .where('deleted_time', 'is', null); + return this.addCond([statement]).map(() => [statement]); + } + + visitTableUpdateViewShareMeta( + spec: TableUpdateViewShareMetaSpec + ): Result, DomainError> { + this.trackViewVersionTouch(spec.viewId().toString()); + const statement = this.params.db + .updateTable('view') + .set({ + share_meta: + spec.nextShareMeta() === undefined ? null : JSON.stringify(spec.nextShareMeta()), + version: this.viewVersionIncrement, + last_modified_time: this.params.now, + last_modified_by: this.params.actorId, + }) + .where('id', '=', spec.viewId().toString()) + .where('table_id', '=', this.params.table.id().toString()) + .where('deleted_time', 'is', null); + return this.addCond([statement]).map(() => [statement]); + } + + visitTableUpdateViewShareId( + spec: TableUpdateViewShareIdSpec + ): Result, DomainError> { + this.trackViewVersionTouch(spec.viewId().toString()); + const statement = this.params.db + .updateTable('view') + .set({ + share_id: spec.nextShareId(), + version: this.viewVersionIncrement, + last_modified_time: this.params.now, + last_modified_by: this.params.actorId, + }) + .where('id', '=', spec.viewId().toString()) + .where('table_id', '=', this.params.table.id().toString()) + .where('deleted_time', 'is', null); + return this.addCond([statement]).map(() => [statement]); + } + + visitTableUpdateViewShareState( + spec: TableUpdateViewShareStateSpec + ): Result, DomainError> { + this.trackViewVersionTouch(spec.viewId().toString()); + const nextState = spec.nextState(); + const statement = this.params.db + .updateTable('view') + .set({ + enable_share: nextState.enableShare, + share_id: nextState.shareId ?? null, + share_meta: nextState.shareMeta === undefined ? null : JSON.stringify(nextState.shareMeta), + version: this.viewVersionIncrement, + last_modified_time: this.params.now, + last_modified_by: this.params.actorId, + }) + .where('id', '=', spec.viewId().toString()) + .where('table_id', '=', this.params.table.id().toString()) + .where('deleted_time', 'is', null); + return this.addCond([statement]).map(() => [statement]); + } + visitTableUpdateViewQueryDefaults( spec: TableUpdateViewQueryDefaultsSpec ): Result, DomainError> { @@ -269,6 +570,7 @@ export class TableMetaUpdateVisitor .updates() .map((update: TableViewQueryDefaultsUpdate) => { const query = update.queryDefaults.toDto(); + const sourceFilter = update.queryDefaults.sourceFilter(); const sortPayload = query.sort || query.manualSort !== undefined ? { @@ -281,9 +583,11 @@ export class TableMetaUpdateVisitor .updateTable('view') .set({ filter: - query.filter === undefined - ? null - : this.stringifyLegacyFilter(this.mapRecordFilterToLegacy(query.filter)), + sourceFilter !== undefined + ? this.stringifyLegacyFilter(sourceFilter) + : query.filter === undefined + ? null + : this.stringifyLegacyFilter(this.mapRecordFilterToLegacy(query.filter)), sort: sortPayload ? JSON.stringify(sortPayload) : null, group: query.group ? JSON.stringify(query.group) : null, version: this.viewVersionIncrement, @@ -291,6 +595,7 @@ export class TableMetaUpdateVisitor last_modified_by: this.params.actorId, }) .where('id', '=', update.viewId.toString()) + .where('table_id', '=', this.params.table.id().toString()) .where('deleted_time', 'is', null); }); @@ -304,12 +609,42 @@ export class TableMetaUpdateVisitor return this.addCond(statements).map(() => statements); } + visitTableUpdateProperties( + spec: TableUpdatePropertiesSpec + ): Result, DomainError> { + const patch = spec.patch(); + const updates: TableMetaUpdate = { + ...('description' in patch ? { description: patch.description ?? null } : {}), + ...('icon' in patch ? { icon: patch.icon ?? null } : {}), + }; + const statements: ReadonlyArray = [this.buildTableMetaUpdate(updates)]; + return this.addCond(statements).map(() => statements); + } + visitTableById(_: TableByIdSpec): Result, DomainError> { return err( domainError.validation({ message: 'TableByIdSpec is not supported for table updates' }) ); } + visitTableByViewId(_: TableByViewIdSpec): Result, DomainError> { + return err( + domainError.validation({ + message: 'TableByViewIdSpec is not supported for table updates', + }) + ); + } + + visitTableWithViewIds( + _: TableWithViewIdsSpec + ): Result, DomainError> { + return err( + domainError.validation({ + message: 'TableWithViewIdsSpec is not supported for table updates', + }) + ); + } + visitTableByIncomingReferenceToTable( _: TableByIncomingReferenceToTableSpec ): Result, DomainError> { diff --git a/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableRecordConditionWhereVisitor.spec.ts b/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableRecordConditionWhereVisitor.spec.ts index 6b2095bbe0..e060f2438e 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableRecordConditionWhereVisitor.spec.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableRecordConditionWhereVisitor.spec.ts @@ -1,5 +1,6 @@ import { BaseId, + DateTimeFormatting, DbFieldName, FieldId, FieldName, @@ -33,7 +34,7 @@ import { Pool } from 'pg'; import { afterAll, describe, expect, test } from 'vitest'; import type { RecordConditionWhere } from './TableRecordConditionWhereVisitor'; -import { TableRecordConditionWhereVisitor } from './TableRecordConditionWhereVisitor'; +import { DateUtil, TableRecordConditionWhereVisitor } from './TableRecordConditionWhereVisitor'; type FieldKey = | 'singleLineText' @@ -43,6 +44,7 @@ type FieldKey = | 'rating' | 'checkbox' | 'date' + | 'dateTime' | 'singleSelect' | 'multipleSelect' | 'attachment' @@ -388,6 +390,18 @@ const buildFixture = (): { fields: Record } => { builder.field().rating().withName(FieldName.create('Rating')._unsafeUnwrap()).done(); builder.field().checkbox().withName(FieldName.create('Done')._unsafeUnwrap()).done(); builder.field().date().withName(FieldName.create('Due Date')._unsafeUnwrap()).done(); + builder + .field() + .date() + .withName(FieldName.create('Due At')._unsafeUnwrap()) + .withFormatting( + DateTimeFormatting.create({ + date: 'YYYY-MM-DD', + time: 'HH:mm', + timeZone: 'utc', + })._unsafeUnwrap() + ) + .done(); builder .field() .singleSelect() @@ -448,6 +462,7 @@ const buildFixture = (): { fields: Record } => { rating: table.getField((field) => field.name().toString() === 'Rating')._unsafeUnwrap(), checkbox: table.getField((field) => field.name().toString() === 'Done')._unsafeUnwrap(), date: table.getField((field) => field.name().toString() === 'Due Date')._unsafeUnwrap(), + dateTime: table.getField((field) => field.name().toString() === 'Due At')._unsafeUnwrap(), singleSelect: table.getField((field) => field.name().toString() === 'Status')._unsafeUnwrap(), multipleSelect: table.getField((field) => field.name().toString() === 'Tags')._unsafeUnwrap(), attachment: table.getField((field) => field.name().toString() === 'Files')._unsafeUnwrap(), @@ -471,6 +486,7 @@ const buildFixture = (): { fields: Record } => { rating: 'col_rating', checkbox: 'col_done', date: 'col_due_date', + dateTime: 'col_due_at', singleSelect: 'col_status', multipleSelect: 'col_tags', attachment: 'col_files', @@ -560,4 +576,33 @@ describe('TableRecordConditionWhereVisitor', () => { expect(compiled.sql).toContain('"t"."col_due_date" < "t"."col_due_date"'); expect(compiled.parameters).toEqual([]); }); + + test('preserves dateRange time bounds for fields with time formatting', () => { + const field = fixture.fields.dateTime; + const value = RecordConditionDateValue.create({ + mode: 'dateRange', + exactDate: '2025-12-15T09:00:00.000Z', + exactDateEnd: '2025-12-15T17:00:00.000Z', + timeZone: 'utc', + })._unsafeUnwrap(); + const spec = field.spec().create({ operator: 'is', value })._unsafeUnwrap(); + const visitor = new TableRecordConditionWhereVisitor(); + + expect(spec.accept(visitor).isOk()).toBe(true); + const compiled = compileCondition(db, visitor.where()._unsafeUnwrap()); + expect(compiled.parameters).toEqual(['2025-12-15T09:00:00.000Z', '2025-12-15T17:00:00.000Z']); + }); +}); + +describe('DateUtil', () => { + test('restores the IANA zone when an offset crosses into daylight saving time', () => { + const dateUtil = new DateUtil('Europe/London'); + const winter = dateUtil.date('2026-03-15T12:00:00.000Z'); + + const summer = dateUtil.offset('month', 1, winter); + expect(summer.utcOffset()).toBe(60); + expect(summer.format('HH:mm')).toBe('12:00'); + expect(summer.startOf('month').toISOString()).toBe('2026-03-31T23:00:00.000Z'); + expect(summer.endOf('month').toISOString()).toBe('2026-04-30T22:59:59.999Z'); + }); }); diff --git a/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableRecordConditionWhereVisitor.ts b/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableRecordConditionWhereVisitor.ts index 63213dc0c8..fe21c253be 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableRecordConditionWhereVisitor.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableRecordConditionWhereVisitor.ts @@ -40,16 +40,28 @@ export interface TableRecordConditionWhereVisitorOptions { tableAlias?: string; } -class DateUtil { +export class DateUtil { constructor(private readonly timeZone: string) {} date(value?: dayjs.ConfigType): Dayjs { - return dayjs(value).utc().tz(this.timeZone); + const zoned = dayjs(value).utc().tz(this.timeZone); + // dayjs's timezone plugin stores the zone offset in a field that later code + // checks for truthiness, so instances whose current offset is exactly 0 + // (UTC, Etc/GMT, London in winter) fall back to the host-local calendar in + // startOf/endOf and produce host-timezone-dependent ranges. Pure utc-mode + // instances share the same day boundaries and are immune. + if (zoned.utcOffset() === 0) { + return dayjs(value).utc(); + } + return zoned; } offset(dateField: ManipulateType, offset: number, value = this.date()): Dayjs { if (offset === 0) return value; - return value[offset > 0 ? 'add' : 'subtract'](Math.abs(offset), dateField); + const shifted = value[offset > 0 ? 'add' : 'subtract'](Math.abs(offset), dateField); + // Keep the source wall-clock time while recalculating the target date's + // IANA offset. The outer date() retains the zero-offset dayjs workaround. + return this.date(shifted.tz(this.timeZone, true)); } offsetDay(offset: number, value = this.date()): Dayjs { @@ -184,6 +196,7 @@ const resolveDateRange = ( const mode = value.mode(); const numberOfDays = value.numberOfDays(); const exactDate = value.exactDate(); + const exactDateEnd = value.exactDateEnd(); const dateUtil = new DateUtil(value.timeZone().toString()); const requireExactDate = (): Result => { @@ -226,6 +239,23 @@ const resolveDateRange = ( }); }; + const determineDateRangeSpan = (): Result<[Dayjs, Dayjs], DomainError> => { + return requireExactDate().andThen((rawStart) => { + if (!exactDateEnd) { + return err( + core.domainError.unexpected({ message: 'Date condition requires exactDateEnd' }) + ); + } + const hasTimeFormat = formatting != null && formatting.time() !== core.TimeFormatting.None; + const start = dateUtil.date(rawStart); + const end = dateUtil.date(exactDateEnd); + return ok<[Dayjs, Dayjs]>([ + hasTimeFormat ? start : start.startOf('day'), + hasTimeFormat ? end : end.endOf('day'), + ]); + }); + }; + const determineExactDateTimeRange = (): Result<[Dayjs, Dayjs], DomainError> => { return requireExactDate().map((raw) => { const parsed = dateUtil.date(raw); @@ -276,8 +306,8 @@ const resolveDateRange = ( weekStart: 1, }); const cursorDate = match(relativeMode) - .with('next', () => dateUtil.date().add(1, unit)) - .with('last', () => dateUtil.date().subtract(1, unit)) + .with('next', () => dateUtil.offset(unit, 1)) + .with('last', () => dateUtil.offset(unit, -1)) .with('current', () => dateUtil.date()) .exhaustive(); return [cursorDate.startOf(unit).startOf('day'), cursorDate.endOf(unit).endOf('day')]; @@ -304,6 +334,7 @@ const resolveDateRange = ( .with('daysAgo', () => calculateDateRangeForOffsetDays(true)) .with('daysFromNow', () => calculateDateRangeForOffsetDays(false)) .with('exactDate', () => determineExactDateRange()) + .with('dateRange', () => determineDateRangeSpan()) .with('exactDateTime', () => determineExactDateTimeRange()) .with('exactFormatDate', () => determineExactFormatDateRange()) .with('currentWeek', () => ok(generateRelativeDateFromCurrentDateRange('current', 'week'))) @@ -413,6 +444,10 @@ const buildIsCondition = ( const columnRef = sql.ref(column); if (core.isRecordConditionDateValue(value)) { const range = yield* resolveDateRange(value, resolveDateFormatting(field)); + // v1 parity: an inverted dateRange (start > end) is skipped, not an error + if (value.mode() === 'dateRange' && Date.parse(range.start) > Date.parse(range.end)) { + return ok(sql`true`); + } return ok(sql`${columnRef} between ${range.start} and ${range.end}`); } const operand = yield* resolvePrimitiveOperand(value, tableAlias); @@ -432,6 +467,10 @@ const buildIsNotCondition = ( const column = yield* resolveColumn(field, tableAlias); const columnRef = sql.ref(column); if (core.isRecordConditionDateValue(value)) { + // v1 parity: dateRange only supports is/isWithIn — with isNot the condition is skipped + if (value.mode() === 'dateRange') { + return ok(sql`true`); + } const range = yield* resolveDateRange(value, resolveDateFormatting(field)); return ok( sql`(${columnRef} not between ${range.start} and ${range.end} or ${columnRef} is null)` @@ -539,6 +578,10 @@ const buildIsWithinCondition = ( const column = yield* resolveColumn(field, tableAlias); const dateValue = yield* resolveDateValue(value); const range = yield* resolveDateRange(dateValue, resolveDateFormatting(field)); + // v1 parity: an inverted dateRange (start > end) is skipped, not an error + if (dateValue.mode() === 'dateRange' && Date.parse(range.start) > Date.parse(range.end)) { + return ok(sql`true`); + } const columnRef = sql.ref(column); return ok(sql`${columnRef} between ${range.start} and ${range.end}`); }); diff --git a/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableWhereVisitor.spec.ts b/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableWhereVisitor.spec.ts index 577c1480e4..c5c22e00f8 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableWhereVisitor.spec.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableWhereVisitor.spec.ts @@ -3,11 +3,14 @@ import { TableByBaseIdSpec, TableByIdSpec, TableByIdsSpec, + TableByViewIdSpec, + TableWithViewIdsSpec, TableByIncomingReferenceToTableSpec, TableByNameLikeSpec, TableByNameSpec, TableId, TableName, + ViewId, } from '@teable/v2-core'; import { describe, expect, it } from 'vitest'; @@ -138,6 +141,44 @@ describe('TableWhereVisitor', () => { }); }); + it('records a child View selector for selective aggregate hydration', () => { + const viewId = ViewId.create(`viw${'a'.repeat(16)}`)._unsafeUnwrap(); + + for (const state of [ + 'active', + 'activeWithPending', + 'activeAnyProvision', + 'deleted', + 'all', + ] as const) { + const visitor = new TableWhereVisitor(state); + const result = visitor.visitTableByViewId(TableByViewIdSpec.create(viewId)); + + expect(result.isOk()).toBe(true); + expect(visitor.describe()).toEqual({ + specName: 'TableByViewIdSpec', + viewId: viewId.toString(), + }); + expect(typeof result._unsafeUnwrap()).toBe('function'); + } + }); + + it('records a View child hydration projection without filtering the Table root', () => { + const viewIds = [ + ViewId.create(`viw${'a'.repeat(16)}`)._unsafeUnwrap(), + ViewId.create(`viw${'b'.repeat(16)}`)._unsafeUnwrap(), + ]; + const visitor = new TableWhereVisitor('active'); + const result = visitor.visitTableWithViewIds(TableWithViewIdsSpec.create(viewIds)); + + expect(result.isOk()).toBe(true); + expect(visitor.describe()).toEqual({ + specName: 'TableWithViewIdsSpec', + viewIds: viewIds.map((viewId) => viewId.toString()), + }); + expect(typeof result._unsafeUnwrap()).toBe('function'); + }); + it('records incoming-reference filters for both active and deleted states', () => { const activeVisitor = new TableWhereVisitor('active'); const deletedVisitor = new TableWhereVisitor('deleted'); @@ -168,6 +209,7 @@ describe('TableWhereVisitor', () => { const unsupportedMethods = [ ['visitTableAddField', 'TableAddFieldSpec is not supported for table filters'], ['visitTableAddFields', 'TableAddFieldsSpec is not supported for table filters'], + ['visitTableAddView', 'TableAddViewSpec is not supported for table filters'], [ 'visitTableAddSelectOptions', 'TableAddSelectOptionsSpec is not supported for table filters', diff --git a/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableWhereVisitor.ts b/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableWhereVisitor.ts index d6c72962f3..20c580828c 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableWhereVisitor.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableWhereVisitor.ts @@ -3,14 +3,28 @@ import { type ITableSpecVisitor, TableAddFieldSpec, TableAddFieldsSpec, + TableAddViewSpec, + TableEnsureViewRowOrderSpec, + TableRemoveViewSpec, + TableRenameViewSpec, + TableUpdateViewDescriptionSpec, + TableUpdateViewLockedSpec, + TableUpdateViewOrderSpec, TableAddSelectOptionsSpec, TableDuplicateFieldSpec, TableRemoveFieldSpec, TableUpdateViewColumnMetaSpec, + TableUpdateViewOptionsSpec, + TableUpdateViewShareIdSpec, + TableUpdateViewShareMetaSpec, + TableUpdateViewShareStateSpec, TableUpdateViewQueryDefaultsSpec, TableRenameSpec, + TableUpdatePropertiesSpec, TableByBaseIdSpec, TableByIdSpec, + TableByViewIdSpec, + TableWithViewIdsSpec, TableByIncomingReferenceToTableSpec, TableByIdsSpec, TableByNameLikeSpec, @@ -80,6 +94,8 @@ export type ITableMetaWhere = ( export type TableWhereSpecInfo = { readonly specName?: string; readonly tableId?: string; + readonly viewId?: string; + readonly viewIds?: ReadonlyArray; readonly incomingReferenceToTableId?: string; readonly baseId?: string; readonly tableIds?: ReadonlyArray; @@ -131,6 +147,60 @@ export class TableWhereVisitor ); } + visitTableAddView(_: TableAddViewSpec): Result { + return err( + domainError.validation({ message: 'TableAddViewSpec is not supported for table filters' }) + ); + } + + visitTableEnsureViewRowOrder( + _: TableEnsureViewRowOrderSpec + ): Result { + return err( + domainError.validation({ + message: 'TableEnsureViewRowOrderSpec is not supported for table filters', + }) + ); + } + + visitTableRemoveView(_: TableRemoveViewSpec): Result { + return err( + domainError.validation({ message: 'TableRemoveViewSpec is not supported for table filters' }) + ); + } + + visitTableRenameView(_: TableRenameViewSpec): Result { + return err( + domainError.validation({ message: 'TableRenameViewSpec is not supported for table filters' }) + ); + } + + visitTableUpdateViewDescription( + _: TableUpdateViewDescriptionSpec + ): Result { + return err( + domainError.validation({ + message: 'TableUpdateViewDescriptionSpec is not supported for table filters', + }) + ); + } + + visitTableUpdateViewLocked(_: TableUpdateViewLockedSpec): Result { + return err( + domainError.validation({ + message: 'TableUpdateViewLockedSpec is not supported for table filters', + }) + ); + } + + visitTableUpdateViewOrder(_: TableUpdateViewOrderSpec): Result { + return err( + domainError.validation({ + message: 'TableUpdateViewOrderSpec is not supported for table filters', + }) + ); + } + visitTableAddSelectOptions(_: TableAddSelectOptionsSpec): Result { return err( domainError.validation({ @@ -163,6 +233,42 @@ export class TableWhereVisitor ); } + visitTableUpdateViewOptions(_: TableUpdateViewOptionsSpec): Result { + return err( + domainError.validation({ + message: 'TableUpdateViewOptionsSpec is not supported for table filters', + }) + ); + } + + visitTableUpdateViewShareMeta( + _: TableUpdateViewShareMetaSpec + ): Result { + return err( + domainError.validation({ + message: 'TableUpdateViewShareMetaSpec is not supported for table filters', + }) + ); + } + + visitTableUpdateViewShareId(_: TableUpdateViewShareIdSpec): Result { + return err( + domainError.validation({ + message: 'TableUpdateViewShareIdSpec is not supported for table filters', + }) + ); + } + + visitTableUpdateViewShareState( + _: TableUpdateViewShareStateSpec + ): Result { + return err( + domainError.validation({ + message: 'TableUpdateViewShareStateSpec is not supported for table filters', + }) + ); + } + visitTableUpdateViewQueryDefaults( _: TableUpdateViewQueryDefaultsSpec ): Result { @@ -179,6 +285,14 @@ export class TableWhereVisitor ); } + visitTableUpdateProperties(_: TableUpdatePropertiesSpec): Result { + return err( + domainError.validation({ + message: 'TableUpdatePropertiesSpec is not supported for table filters', + }) + ); + } + visitTableByBaseId(spec: TableByBaseIdSpec): Result { const cond: ITableMetaWhere = (eb) => eb.eb('base_id', '=', spec.baseId().toString()); this.mergeSpecInfo({ specName: 'TableByBaseIdSpec', baseId: spec.baseId().toString() }); @@ -191,6 +305,34 @@ export class TableWhereVisitor return this.addCond(cond).map(() => cond); } + visitTableByViewId(spec: TableByViewIdSpec): Result { + const viewId = spec.viewId().toString(); + const childStatePredicate = + this.state === 'deleted' + ? sql`"child_view"."deleted_time" = "table_meta"."deleted_time"` + : this.state === 'all' + ? sql`true` + : sql`"child_view"."deleted_time" is null`; + const cond: ITableMetaWhere = () => sql` + exists ( + select 1 + from "view" as "child_view" + where "child_view"."table_id" = ${sql.ref('table_meta.id')} + and "child_view"."id" = ${viewId} + and ${childStatePredicate} + ) + `; + this.mergeSpecInfo({ specName: 'TableByViewIdSpec', viewId }); + return this.addCond(cond).map(() => cond); + } + + visitTableWithViewIds(spec: TableWithViewIdsSpec): Result { + const viewIds = spec.viewIds().map((viewId) => viewId.toString()); + const cond: ITableMetaWhere = () => sql`true`; + this.mergeSpecInfo({ specName: 'TableWithViewIdsSpec', viewIds }); + return this.addCond(cond).map(() => cond); + } + visitTableByIncomingReferenceToTable( spec: TableByIncomingReferenceToTableSpec ): Result { diff --git a/packages/v2/adapter-table-query-ops-postgres/src/index.ts b/packages/v2/adapter-table-query-ops-postgres/src/index.ts index aa472ed2dc..189cdc7e39 100644 --- a/packages/v2/adapter-table-query-ops-postgres/src/index.ts +++ b/packages/v2/adapter-table-query-ops-postgres/src/index.ts @@ -5,6 +5,7 @@ export * from './planValidation'; export * from './register'; export * from './repositories'; export * from './schema'; +export * from './searchDocumentProjection'; export * from './searchVector'; export * from './searchVectorStatus'; export * from './searchAccessPathCapability'; diff --git a/packages/v2/adapter-table-query-ops-postgres/src/register.ts b/packages/v2/adapter-table-query-ops-postgres/src/register.ts index b922297307..d68970b42c 100644 --- a/packages/v2/adapter-table-query-ops-postgres/src/register.ts +++ b/packages/v2/adapter-table-query-ops-postgres/src/register.ts @@ -12,6 +12,7 @@ import { type TableQueryRecommendationRepository, type TableQueryRemediationExecutor, type TableQueryRemediationTaskRepository, + type TableSearchAccessPathResolver, type TableSearchVectorReconciler, type TableSearchVectorSchemaMaintenanceScheduler, type TableSearchVectorStatusReader, @@ -32,10 +33,13 @@ import { PostgresTableQueryRemediationTaskRepository, } from './repositories'; import { ensureTableQueryOpsSchema, type TableQueryOpsDatabase } from './schema'; +import { PostgresTableSearchAccessPathCapabilityReader } from './searchAccessPathCapability'; import { PostgresTableSearchVectorReconciler } from './searchVector'; import { PostgresTableSearchVectorSchemaMaintenanceScheduler } from './searchVectorMaintenance'; -import { PostgresTableSearchVectorStatusReader } from './searchVectorStatus'; -import { PostgresTableSearchAccessPathCapabilityReader } from './searchAccessPathCapability'; +import { + PostgresTableSearchAccessPathResolver, + PostgresTableSearchVectorStatusReader, +} from './searchVectorStatus'; import { v2TableOpsPostgresTokens } from './tokens'; import type { UnknownPostgresDatabase } from './types'; @@ -139,6 +143,10 @@ export const registerV2TableOpsPostgresAdapter = async < v2TableOpsTokens.searchVectorStatusReader, new PostgresTableSearchVectorStatusReader(unknownMetaDb) ); + container.registerInstance( + v2TableOpsTokens.searchAccessPathResolver, + new PostgresTableSearchAccessPathResolver(unknownMetaDb) + ); container.registerInstance( v2TableOpsTokens.searchAccessPathCapabilityReader, new PostgresTableSearchAccessPathCapabilityReader(unknownDataDb) diff --git a/packages/v2/adapter-table-query-ops-postgres/src/searchDocumentProjection.ts b/packages/v2/adapter-table-query-ops-postgres/src/searchDocumentProjection.ts new file mode 100644 index 0000000000..a8d6b6c654 --- /dev/null +++ b/packages/v2/adapter-table-query-ops-postgres/src/searchDocumentProjection.ts @@ -0,0 +1,87 @@ +import type { SearchFieldTextProjection } from '@teable/v2-core'; + +/** + * Raw-SQL renderers for the canonical search text projections defined in + * v2-core (`SearchFieldTextProjection`). The record query repository renders + * the same projections through kysely at query time; the two renderers must + * stay value-equivalent — the generated document column built here is the + * indexed prefilter for the exact predicate built there, and the prefilter is + * only sound while both sides project a cell to the same text. + */ + +const MAX_NUMERIC_PRECISION = 20; + +// Multi-value cells are physically jsonb; a direct cast plus text-level array +// wrapping keeps the expression immutable — to_jsonb() and jsonb_build_array() +// are only STABLE and are rejected by generated columns. +const normalizeToJsonArraySql = (columnSql: string): string => + `CASE WHEN jsonb_typeof((${columnSql})::jsonb) = 'array' THEN (${columnSql})::jsonb ` + + `WHEN (${columnSql})::jsonb IS NULL THEN '[]'::jsonb ` + + `ELSE ('[' || ((${columnSql})::jsonb)::text || ']')::jsonb END`; + +// `["a", "b"]`::text -> `a, b`, matching the cell text users search for. See +// the kysely twin in RecordSearchWhereBuilder for the escaping caveats. +const joinJsonArrayTextSql = (arraySql: string): string => + `btrim(replace(btrim((${arraySql})::text, '[]'), '", "', ', '), '"')`; + +export const renderSearchTextProjectionSql = ( + columnSql: string, + projection?: SearchFieldTextProjection +): string => { + switch (projection?.kind) { + case 'multiline': + return `replace(replace(replace((${columnSql})::text, chr(13), ' '), chr(10), ' '), chr(9), ' ')`; + case 'structured_title': + return `((${columnSql})::jsonb #>> '{title}')`; + case 'structured_title_list': + return joinJsonArrayTextSql( + `jsonb_path_query_array(${normalizeToJsonArraySql(columnSql)}, '$[*].**."title"')` + ); + case 'plain_list': + return joinJsonArrayTextSql(normalizeToJsonArraySql(columnSql)); + case 'rounded_number': + return `round((${columnSql})::numeric, ${sanitizePrecision(projection.precision)})::text`; + case 'plain': + default: + return `(${columnSql})::text`; + } +}; + +const projectionKinds: ReadonlySet = new Set([ + 'plain', + 'multiline', + 'plain_list', + 'structured_title', + 'structured_title_list', + 'rounded_number', +]); + +const sanitizePrecision = (value: unknown): number => { + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isInteger(parsed) || parsed < 0) return 0; + return Math.min(parsed, MAX_NUMERIC_PRECISION); +}; + +/** + * Task payloads arrive as JSON; never interpolate an unvalidated projection + * into DDL. Unknown kinds degrade to the plain text cast. + */ +export const sanitizeSearchTextProjection = (value: unknown): SearchFieldTextProjection => { + if (typeof value !== 'object' || value === null) return { kind: 'plain' }; + const kind = (value as { kind?: unknown }).kind; + if (typeof kind !== 'string' || !projectionKinds.has(kind)) return { kind: 'plain' }; + if (kind === 'rounded_number') { + return { + kind, + precision: sanitizePrecision((value as { precision?: unknown }).precision), + }; + } + return { kind } as SearchFieldTextProjection; +}; + +export const searchTextProjectionKey = (projection?: SearchFieldTextProjection): string => { + if (!projection) return 'plain'; + return projection.kind === 'rounded_number' + ? `rounded_number(${sanitizePrecision(projection.precision)})` + : projection.kind; +}; diff --git a/packages/v2/adapter-table-query-ops-postgres/src/searchVector.integration.spec.ts b/packages/v2/adapter-table-query-ops-postgres/src/searchVector.integration.spec.ts index c26a2b9e9f..16ac67193f 100644 --- a/packages/v2/adapter-table-query-ops-postgres/src/searchVector.integration.spec.ts +++ b/packages/v2/adapter-table-query-ops-postgres/src/searchVector.integration.spec.ts @@ -126,6 +126,7 @@ describeWithPostgres('PostgresTableSearchVectorAdvisor', () => { "coveredFieldDbNames": [ "fld_title", "fld_notes", + "fld_count", ], "indexKind": "gin_trgm", "languageConfig": "simple", @@ -134,9 +135,7 @@ describeWithPostgres('PostgresTableSearchVectorAdvisor', () => { "planStatus": "validated", "provider": "pg_trgm", "semantics": "substring", - "skippedReasons": [ - "non_text_value", - ], + "skippedReasons": [], } `); expect([ @@ -202,6 +201,7 @@ describeWithPostgres('PostgresTableSearchVectorAdvisor', () => { fieldId: field.fieldId, fieldDbName: field.fieldDbName ?? '', fieldType: field.fieldType, + ...(field.textProjection ? { textProjection: field.textProjection } : {}), })), allowLargeTableRewrite: true, }, @@ -452,6 +452,7 @@ describeWithPostgres('PostgresTableSearchVectorAdvisor', () => { fieldId: field.fieldId, fieldDbName: field.fieldDbName ?? '', fieldType: field.fieldType, + ...(field.textProjection ? { textProjection: field.textProjection } : {}), })), }, }) diff --git a/packages/v2/adapter-table-query-ops-postgres/src/searchVector.lifecycle.db.spec.ts b/packages/v2/adapter-table-query-ops-postgres/src/searchVector.lifecycle.db.spec.ts index 16bf0cba4c..d7060c0acc 100644 --- a/packages/v2/adapter-table-query-ops-postgres/src/searchVector.lifecycle.db.spec.ts +++ b/packages/v2/adapter-table-query-ops-postgres/src/searchVector.lifecycle.db.spec.ts @@ -78,7 +78,20 @@ const asStringArray = (value: unknown): string[] => { : []; }; -describe('generated substring search document schema lifecycle (db)', () => { +// Needs a real Postgres (env-provided URL or the search CI flag). Without the +// gate, plain `pnpm test-unit` on a machine with no Docker and no database +// URL fails while booting testcontainers. +const hasTestDatabaseUrl = Boolean( + process.env.TEABLE_V2_TEST_DATABASE_URL ?? + process.env.PRISMA_DATABASE_URL ?? + process.env.DATABASE_URL +); +const describeWithDb = + process.env.TEABLE_V2_RUN_SEARCH_VECTOR_PG_INTEGRATION === '1' || hasTestDatabaseUrl + ? describe + : describe.skip; + +describeWithDb('generated substring search document schema lifecycle (db)', () => { let testContainer: IV2NodeTestContainer; let commandBus: ICommandBus; let tableRepository: ITableRepository; @@ -232,8 +245,11 @@ describe('generated substring search document schema lifecycle (db)', () => { await runPendingMaintenance(); config = await expectReadyConfig(table); - expect(asStringArray(config.field_ids)).not.toContain(scoreField.id().toString()); + // Number fields now project into the search document (rounded to the + // field precision), so the converted field stays covered. + expect(asStringArray(config.field_ids)).toContain(scoreField.id().toString()); expect(await searchTotal(table, config, 'lifecycleunique')).toBe(1); + expect(await searchTotal(table, config, '88.00')).toBeGreaterThan(0); const deleteRegion = DeleteFieldCommand.create({ baseId: table.baseId().toString(), @@ -255,6 +271,34 @@ describe('generated substring search document schema lifecycle (db)', () => { expect(asStringArray(config.field_ids)).not.toContain(regionFieldId); expect(await searchTotal(table, config, 'SingaporeWest')).toBe(0); expect(await searchTotal(table, config, 'lifecycleunique')).toBe(1); + + // Table-level kill switch: drop removes the managed objects, disables the + // config, and search keeps working through the default ILIKE path. + const dropResult = await reconciler.reconcile(context, { table, mode: 'drop' }); + expect(dropResult._unsafeUnwrap()).toMatchObject({ action: 'dropped', status: 'disabled' }); + expect((await statusReader.read(context, table.id().toString()))._unsafeUnwrap()).toMatchObject( + { + state: 'disabled', + configured: false, + } + ); + const physicalAfterDrop = getTablePhysicalName(table)._unsafeUnwrap(); + const droppedState = await sql<{ column_exists: boolean; index_exists: boolean }>` + SELECT + EXISTS ( + SELECT 1 + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = ${physicalAfterDrop.schema} + AND c.relname = ${physicalAfterDrop.tableName} + AND a.attname = ${config.generated_column_name} + AND NOT a.attisdropped + ) AS column_exists, + to_regclass(${`${physicalAfterDrop.schema}.${config.index_name}`}) IS NOT NULL AS index_exists + `.execute(db); + expect(droppedState.rows[0]).toEqual({ column_exists: false, index_exists: false }); + expect(await searchTotalWithDefaultPath(table, 'lifecycleunique')).toBe(1); }, 120_000); it('falls back safely and removes new objects when real-DDL rebuild validation fails', async () => { const created = await createTable(); @@ -471,7 +515,11 @@ describe('generated substring search document schema lifecycle (db)', () => { coveredFieldIds, }, }); - return result._unsafeUnwrap().total; + const value_ = result._unsafeUnwrap(); + // Guard against silent fallback: every lifecycle assertion below would + // also pass on plain ILIKE, so require the indexed path to actually run. + expect(value_.searchAccessPath).toMatchObject({ used: 'generated_text' }); + return value_.total; }; const searchTotalWithDefaultPath = async (table: Table, value: string) => { diff --git a/packages/v2/adapter-table-query-ops-postgres/src/searchVector.ts b/packages/v2/adapter-table-query-ops-postgres/src/searchVector.ts index 9e68da4c82..32c4446dd5 100644 --- a/packages/v2/adapter-table-query-ops-postgres/src/searchVector.ts +++ b/packages/v2/adapter-table-query-ops-postgres/src/searchVector.ts @@ -1,4 +1,16 @@ -import { domainError, type IExecutionContext, type Table } from '@teable/v2-core'; +import { + LEGACY_MANAGED_SEARCH_DOCUMENT_COLUMN_PREFIX, + LEGACY_MANAGED_SEARCH_INDEX_PREFIX, + MANAGED_SCOPED_SEARCH_INDEX_PREFIX, + MANAGED_SEARCH_DOCUMENT_COLUMN_PREFIX, + MANAGED_SEARCH_INDEX_PREFIX, +} from '@teable/v2-adapter-db-postgres-shared'; +import { + domainError, + type IExecutionContext, + type SearchFieldTextProjection, + type Table, +} from '@teable/v2-core'; import { buildTableSearchVectorDefinition, SearchScopeHeatPolicy, @@ -16,21 +28,27 @@ import { err, ok } from 'neverthrow'; import { getTablePhysicalName, makePhysicalTableSql, quoteIdentifier } from './helpers'; import { readPostgresSearchAccessPathCapabilities } from './searchAccessPathCapability'; +import { + renderSearchTextProjectionSql, + sanitizeSearchTextProjection, + searchTextProjectionKey, +} from './searchDocumentProjection'; import type { UnknownPostgresDatabase } from './types'; const DEFAULT_LANGUAGE_CONFIG = 'simple'; const LARGE_TABLE_REWRITE_ESTIMATED_ROWS = 50_000; const MIN_RECOMMENDED_COST_IMPROVEMENT_PCT = 20; -// All generated columns and indexes this advisor manages carry these prefixes. -// The executor refuses to ADD/DROP anything that does not, so a hand-built or -// mistyped payload can never rewrite/drop a real user column or index. -const GENERATED_COLUMN_PREFIX = '__tqops_search_'; -const INDEX_NAME_PREFIX = 'idx_tqops_search_'; -const SCOPED_EXPRESSION_INDEX_PREFIX = 'idx_tqops_search_scope_'; -const LEGACY_GENERATED_COLUMN_PREFIX = '__tqops_tsv_'; -const LEGACY_INDEX_NAME_PREFIX = 'idx_tqops_tsv_'; -const SEARCH_DOCUMENT_DEFINITION_VERSION = 'v1'; +// All generated columns and indexes this advisor manages carry the shared +// prefixes from @teable/v2-table-query-ops. The executor refuses to ADD/DROP +// anything that does not, so a hand-built or mistyped payload can never +// rewrite/drop a real user column or index. +const GENERATED_COLUMN_PREFIX = MANAGED_SEARCH_DOCUMENT_COLUMN_PREFIX; +const INDEX_NAME_PREFIX = MANAGED_SEARCH_INDEX_PREFIX; +const SCOPED_EXPRESSION_INDEX_PREFIX = MANAGED_SCOPED_SEARCH_INDEX_PREFIX; +const LEGACY_GENERATED_COLUMN_PREFIX = LEGACY_MANAGED_SEARCH_DOCUMENT_COLUMN_PREFIX; +const LEGACY_INDEX_NAME_PREFIX = LEGACY_MANAGED_SEARCH_INDEX_PREFIX; +const SEARCH_DOCUMENT_DEFINITION_VERSION = 'v2'; const SEARCH_SEMANTICS_SAMPLE_LIMIT = 3; const SEARCH_SEMANTICS_FIELD_PREVIEW_LIMIT = 4; const SEARCH_SEMANTICS_TOKEN_LIMIT = 16; @@ -61,6 +79,7 @@ export type TableQuerySearchVectorFieldSummary = { readonly fieldType: string; readonly valueType?: string; readonly included: boolean; + readonly textProjection?: SearchFieldTextProjection; readonly skippedReason?: string; }; @@ -304,6 +323,7 @@ export type ExecuteTableSearchVectorInput = { readonly fieldId: string; readonly fieldDbName: string; readonly fieldType?: string; + readonly textProjection?: SearchFieldTextProjection; }[]; readonly searchScope?: 'all_fields' | 'selected_fields'; readonly allowLargeTableRewrite?: boolean; @@ -318,9 +338,19 @@ export type SearchVectorExecutionCandidateInput = { readonly fields: readonly { readonly fieldId: string; readonly fieldDbName: string; + readonly textProjection?: SearchFieldTextProjection; }[]; }; +export type DropTableSearchVectorResult = { + readonly action: 'dropped'; + readonly tableId: string; + readonly hadManagedObjects: boolean; + readonly candidateKey?: string; + readonly generatedColumnName?: string; + readonly indexName?: string; +}; + export type ExecuteTableSearchVectorResult = { readonly action: 'created' | 'rebuilt' | 'verified'; readonly createdOrVerified: boolean; @@ -453,9 +483,13 @@ type SearchAccessPathExecutionField = ExecuteTableSearchVectorInput['payload'][' const requireExecutionFields = ( fields: ExecuteTableSearchVectorInput['payload']['fields'] ): readonly SearchAccessPathExecutionField[] => { - const included = fields.filter((field): field is SearchAccessPathExecutionField => - Boolean(field.fieldDbName) - ); + const included = fields + .filter((field): field is SearchAccessPathExecutionField => Boolean(field.fieldDbName)) + .map((field) => ({ + ...field, + // Payloads arrive as JSON; never let an unvalidated projection reach DDL. + textProjection: sanitizeSearchTextProjection(field.textProjection), + })); if (!included.length) { throw new Error('Search access-path task payload must include at least one field'); } @@ -875,6 +909,70 @@ export class PostgresTableSearchVectorExecutor { }); } + /** + * Table-level kill switch: drop the managed generated column + GIN index and + * disable every active config row so the runtime falls back to plain ILIKE. + */ + async drop(tableId: string): Promise { + return this.metaDb.connection().execute(async (lockedMetaDb) => { + await sql` + SELECT pg_advisory_lock( + hashtext('teable.table_query_ops.search_vector'), + hashtext(${tableId}) + ) + `.execute(lockedMetaDb); + try { + const lockedDataDb = this.dataDb === this.metaDb ? lockedMetaDb : this.dataDb; + return await new PostgresTableSearchVectorExecutor(lockedMetaDb, lockedDataDb).dropUnlocked( + tableId + ); + } finally { + await sql` + SELECT pg_advisory_unlock( + hashtext('teable.table_query_ops.search_vector'), + hashtext(${tableId}) + ) + `.execute(lockedMetaDb); + } + }); + } + + private async dropUnlocked(tableId: string): Promise { + const tableMeta = await this.requireTableMeta(tableId); + const physical = splitPhysicalName(tableMeta.db_table_name, tableMeta.base_id); + const tableSql = makePhysicalTableSql(physical.schema, physical.tableName); + const currentConfig = await this.currentConfig(tableId); + if (currentConfig) { + assertManagedSearchVectorNames(currentConfig.generated_column_name, currentConfig.index_name); + await this.dropManagedIndex(physical.schema, currentConfig.index_name); + await this.dropManagedColumn(tableSql, currentConfig.generated_column_name); + } + await sql` + UPDATE table_query_search_vector_config + SET status = 'disabled', + last_inspection = ${JSON.stringify({ + state: 'disabled', + staleReasons: ['manually_dropped'], + })}::jsonb, + last_modified_time = now() + WHERE table_id = ${tableId} + AND status IN ('ready', 'stale', 'rebuild_pending') + `.execute(this.metaDb); + + return { + action: 'dropped', + tableId, + hadManagedObjects: Boolean(currentConfig), + ...(currentConfig + ? { + candidateKey: currentConfig.candidate_key, + generatedColumnName: currentConfig.generated_column_name, + indexName: currentConfig.index_name, + } + : {}), + }; + } + private async executeUnlocked( input: ExecuteTableSearchVectorInput ): Promise { @@ -899,6 +997,7 @@ export class PostgresTableSearchVectorExecutor { fieldDbName: field.fieldDbName, fieldType: field.fieldType ?? 'unknown', included: true, + ...(field.textProjection ? { textProjection: field.textProjection } : {}), })); const realDdlBeforePlan = validationMode === 'real_ddl' @@ -908,7 +1007,7 @@ export class PostgresTableSearchVectorExecutor { searchProbe, }) : undefined; - const expression = buildSearchDocumentExpression(fields.map((field) => field.fieldDbName)); + const expression = buildSearchDocumentExpression(fields); const tableSql = makePhysicalTableSql(physical.schema, physical.tableName); const currentConfig = await this.currentConfig(input.tableId); @@ -1336,6 +1435,21 @@ export class PostgresTableSearchVectorReconciler implements TableSearchVectorRec async reconcile(context: IExecutionContext, input: ReconcileTableSearchVectorInput) { try { + if (input.mode === 'drop') { + const executor = new PostgresTableSearchVectorExecutor(this.metaDb, this.dataDb); + const dropped = await executor.drop(input.table.id().toString()); + return ok({ + action: 'dropped', + tableId: dropped.tableId, + definitionKey: dropped.candidateKey ?? '', + generatedColumnName: dropped.generatedColumnName ?? '', + indexName: dropped.indexName ?? '', + languageConfig: DEFAULT_LANGUAGE_CONFIG, + fieldIds: [], + status: 'disabled', + }); + } + const advisor = new PostgresTableSearchVectorAdvisor(this.dataDb); const analysis = await advisor.analyze(context, { table: input.table, @@ -1370,6 +1484,7 @@ export class PostgresTableSearchVectorReconciler implements TableSearchVectorRec fields: recommendation.coveredFields.map((field) => ({ fieldId: field.fieldId, fieldDbName: field.fieldDbName ?? '', + ...(field.textProjection ? { textProjection: field.textProjection } : {}), })), }; const validationMode = input.validationMode ?? 'real_ddl'; @@ -1395,6 +1510,7 @@ export class PostgresTableSearchVectorReconciler implements TableSearchVectorRec fieldId: field.fieldId, fieldDbName: field.fieldDbName ?? '', fieldType: field.fieldType, + ...(field.textProjection ? { textProjection: field.textProjection } : {}), })), searchScope: recommendation.searchScope, allowLargeTableRewrite: input.allowLargeTableRewrite, @@ -1466,6 +1582,7 @@ export class PostgresTableSearchVectorReconciler implements TableSearchVectorRec fieldId: field.fieldId, fieldDbName: field.fieldDbName ?? '', fieldType: field.fieldType, + ...(field.textProjection ? { textProjection: field.textProjection } : {}), })), searchScope: recommendation.searchScope, // Schema maintenance must obey the same rewrite guard as an explicit @@ -1650,11 +1767,18 @@ const lengthBucket = (value: string | undefined): 'none' | 'short' | 'medium' | const buildSearchVectorNames = ( tableId: string, providerCapability: TableQuerySubstringSearchProviderCapability, - fields: readonly { readonly fieldId: string; readonly fieldDbName?: string }[] + fields: readonly { + readonly fieldId: string; + readonly fieldDbName?: string; + readonly textProjection?: SearchFieldTextProjection; + }[] ) => { const hash = stableHash( `${tableId}:substring:${providerCapability.provider}:${providerCapability.operatorClass}:${fields - .map((field) => `${field.fieldId}=${field.fieldDbName ?? ''}`) + .map( + (field) => + `${field.fieldId}=${field.fieldDbName ?? ''}:${searchTextProjectionKey(field.textProjection)}` + ) .join(',')}` ); return { @@ -1667,11 +1791,18 @@ const buildSearchVectorNames = ( const buildScopedExpressionIndexNames = ( tableId: string, providerCapability: TableQuerySubstringSearchProviderCapability, - fields: readonly { readonly fieldId: string; readonly fieldDbName?: string }[] + fields: readonly { + readonly fieldId: string; + readonly fieldDbName?: string; + readonly textProjection?: SearchFieldTextProjection; + }[] ) => { const hash = stableHash( `${tableId}:substring:${providerCapability.provider}:${providerCapability.operatorClass}:${fields - .map((field) => `${field.fieldId}=${field.fieldDbName ?? ''}`) + .map( + (field) => + `${field.fieldId}=${field.fieldDbName ?? ''}:${searchTextProjectionKey(field.textProjection)}` + ) .sort() .join(',')}` ); @@ -1681,9 +1812,19 @@ const buildScopedExpressionIndexNames = ( }; }; -const buildSearchDocumentExpression = (fieldDbNames: readonly string[]): string => { - const document = fieldDbNames - .map((fieldDbName) => `coalesce(${quoteIdentifier(fieldDbName)}::text, '')`) +type SearchDocumentExpressionField = { + readonly fieldDbName: string; + readonly textProjection?: SearchFieldTextProjection; +}; + +const buildSearchDocumentExpression = ( + fields: readonly SearchDocumentExpressionField[] +): string => { + const document = fields + .map( + (field) => + `coalesce(${renderSearchTextProjectionSql(quoteIdentifier(field.fieldDbName), field.textProjection)}, '')` + ) .join(` || E'\\n' || `); return `lower(${document || quoteLiteral('')})`; }; @@ -1695,7 +1836,10 @@ const buildSearchDocumentExpressionWithAlias = ( const document = fields .map( (field) => - `coalesce(${quoteIdentifier(alias)}.${quoteIdentifier(field.fieldDbName)}::text, '')` + `coalesce(${renderSearchTextProjectionSql( + `${quoteIdentifier(alias)}.${quoteIdentifier(field.fieldDbName)}`, + field.textProjection + )}, '')` ) .join(` || E'\\n' || `); return `lower(${document || quoteLiteral('')})`; @@ -2080,14 +2224,22 @@ const sampleSearchMatches = async ( })); }; +// The exact per-field predicate over the same canonical projections the +// generated document is built from. Using one projection on both sides is what +// keeps the document prefilter a superset of this baseline. +const buildFieldProjectionSql = (field: IncludedSearchVectorField, alias: string): string => + renderSearchTextProjectionSql( + `${quoteIdentifier(alias)}.${quoteIdentifier(field.fieldDbName)}`, + field.textProjection + ); + const buildIlikeWhere = ( fields: readonly IncludedSearchVectorField[], searchProbe: string ): ReturnType => { const pattern = `%${escapeLikeWildcards(searchProbe)}%`; const conditions = fields.map( - (field) => - sql`(${sql.raw(`${quoteIdentifier('t')}.${quoteIdentifier(field.fieldDbName)}`)})::text ILIKE ${pattern} ESCAPE '\\'` + (field) => sql`${sql.raw(buildFieldProjectionSql(field, 't'))} ILIKE ${pattern} ESCAPE '\\'` ); return conditions.reduce((acc, condition) => sql`${acc} OR ${condition}`, sql`false`); }; @@ -2121,7 +2273,10 @@ const inspectSearchVectorInventory = async ( readonly generatedColumnName: string; readonly indexName: string; }, - fields: readonly { readonly fieldDbName?: string }[], + fields: readonly { + readonly fieldDbName?: string; + readonly textProjection?: SearchFieldTextProjection; + }[], providerCapability: TableQuerySubstringSearchProviderCapability ): Promise => { const columnRows = await sql<{ @@ -2181,9 +2336,7 @@ const inspectSearchVectorInventory = async ( const column = columnRows.rows[0]; const index = indexRows.rows[0]; const expectedExpression = buildSearchDocumentExpression( - fields - .map((field) => field.fieldDbName) - .filter((fieldDbName): fieldDbName is string => Boolean(fieldDbName)) + fields.filter((field): field is SearchDocumentExpressionField => Boolean(field.fieldDbName)) ); const staleReasons = collectSearchVectorStaleReasons( column, @@ -2484,12 +2637,7 @@ const explainSearchBefore = async ( readonly searchProbe?: string; } ): Promise => { - const pattern = `%${escapeLikeWildcards(input.searchProbe ?? '')}%`; - const conditions = input.fields.map( - (field) => - sql`(${sql.raw(`${quoteIdentifier('t')}.${quoteIdentifier(field.fieldDbName)}`)})::text ILIKE ${pattern} ESCAPE '\\'` - ); - const where = conditions.reduce((acc, condition) => sql`${acc} OR ${condition}`, sql`false`); + const where = buildIlikeWhere(input.fields, input.searchProbe ?? ''); const rows = await sql` EXPLAIN (FORMAT JSON) SELECT 1 @@ -2560,7 +2708,7 @@ const buildBeforeSearchSql = (input: { const conditions = input.fields .map( (field) => - `(${quoteIdentifier('t')}.${quoteIdentifier(field.fieldDbName)})::text ILIKE :search_probe_like_pattern ESCAPE '\\\\'` + `${buildFieldProjectionSql(field, 't')} ILIKE :search_probe_like_pattern ESCAPE '\\\\'` ) .join(' OR '); return [ @@ -2638,7 +2786,7 @@ const buildBeforeSearchConditionsSql = (fields: readonly IncludedSearchVectorFie fields .map( (field) => - `(${quoteIdentifier('t')}.${quoteIdentifier(field.fieldDbName)})::text ILIKE :search_probe_like_pattern ESCAPE '\\\\'` + `${buildFieldProjectionSql(field, 't')} ILIKE :search_probe_like_pattern ESCAPE '\\\\'` ) .join(' OR ') || 'false'; @@ -2758,7 +2906,7 @@ const buildHypotheticalSearchVectorIndexStatement = (input: { readonly providerCapability: TableQuerySubstringSearchProviderCapability; readonly fields: readonly IncludedSearchVectorField[]; }): string => { - const expression = buildSearchDocumentExpression(input.fields.map((field) => field.fieldDbName)); + const expression = buildSearchDocumentExpression(input.fields); return `CREATE INDEX ON ${makePhysicalTableSql( input.physical.schema, input.physical.tableName diff --git a/packages/v2/adapter-table-query-ops-postgres/src/searchVectorStatus.spec.ts b/packages/v2/adapter-table-query-ops-postgres/src/searchVectorStatus.spec.ts new file mode 100644 index 0000000000..43a1cbe242 --- /dev/null +++ b/packages/v2/adapter-table-query-ops-postgres/src/searchVectorStatus.spec.ts @@ -0,0 +1,76 @@ +import type { IRecordSearchAccessPath } from '@teable/v2-core'; +import { describe, expect, it } from 'vitest'; + +import { toRecordSearchAccessPathFromConfig } from './searchVectorStatus'; + +const coveredFieldIdStrings = (accessPath: IRecordSearchAccessPath | undefined): string[] => + accessPath && accessPath.kind !== 'default' + ? accessPath.coveredFieldIds.map((id) => id.toString()) + : []; + +describe('toRecordSearchAccessPathFromConfig', () => { + it('converts a ready config row into a generated tsvector access path', () => { + const fieldId = `fld${'a'.repeat(16)}`; + + const accessPath = toRecordSearchAccessPathFromConfig({ + generatedColumnName: '__tqops_search_vector', + languageConfig: 'simple', + fieldIds: JSON.stringify([fieldId]), + searchScope: 'all_fields', + status: 'ready', + }); + + expect(accessPath).toMatchObject({ + kind: 'generated_tsvector', + generatedColumnName: '__tqops_search_vector', + languageConfig: 'simple', + searchScope: 'all_fields', + }); + expect(coveredFieldIdStrings(accessPath)).toEqual([fieldId]); + }); + + it('converts a ready substring config into a generated text access path', () => { + const fieldId = `fld${'b'.repeat(16)}`; + const accessPath = toRecordSearchAccessPathFromConfig({ + generatedColumnName: '__tqops_search_document', + semantics: 'substring', + accessPath: 'generated_text', + provider: 'pg_bigm', + fieldIds: [fieldId], + searchScope: 'all_fields', + status: 'ready', + }); + + expect(accessPath).toMatchObject({ + kind: 'generated_text', + generatedColumnName: '__tqops_search_document', + provider: 'pg_bigm', + searchScope: 'all_fields', + }); + expect(coveredFieldIdStrings(accessPath)).toEqual([fieldId]); + }); + + it('does not create an access path when covered fields are missing or invalid', () => { + expect( + toRecordSearchAccessPathFromConfig({ + generatedColumnName: '__tqops_search_vector', + languageConfig: 'simple', + fieldIds: JSON.stringify(['not-a-field']), + searchScope: 'all_fields', + status: 'ready', + }) + ).toBeUndefined(); + }); + + it('does not reactivate an older ready path when the latest config is pending', () => { + expect( + toRecordSearchAccessPathFromConfig({ + generatedColumnName: '__tqops_search_vector', + languageConfig: 'simple', + fieldIds: JSON.stringify([`fld${'a'.repeat(16)}`]), + searchScope: 'all_fields', + status: 'rebuild_pending', + }) + ).toBeUndefined(); + }); +}); diff --git a/packages/v2/adapter-table-query-ops-postgres/src/searchVectorStatus.ts b/packages/v2/adapter-table-query-ops-postgres/src/searchVectorStatus.ts index 26f494d941..1b269968e3 100644 --- a/packages/v2/adapter-table-query-ops-postgres/src/searchVectorStatus.ts +++ b/packages/v2/adapter-table-query-ops-postgres/src/searchVectorStatus.ts @@ -1,5 +1,6 @@ -import type { IExecutionContext } from '@teable/v2-core'; +import { FieldId, type IExecutionContext, type IRecordSearchAccessPath } from '@teable/v2-core'; import type { + TableSearchAccessPathResolver, TableSearchVectorStatus, TableSearchVectorStatusReader, TableSearchVectorStatusState, @@ -36,6 +37,118 @@ const disabledStatus = (tableId: string): TableSearchVectorStatus => ({ coveredFieldCount: 0, }); +export type SearchAccessPathConfigRow = { + readonly generatedColumnName: string; + readonly semantics?: string; + readonly accessPath?: string; + readonly provider?: string; + readonly languageConfig?: string | null; + readonly fieldIds: unknown; + readonly searchScope: string; + readonly status: string; +}; + +const parseFieldIds = (raw: unknown): readonly FieldId[] => { + const parsed = + typeof raw === 'string' + ? (() => { + try { + return JSON.parse(raw) as unknown; + } catch { + return undefined; + } + })() + : raw; + + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.flatMap((value) => { + const fieldIdResult = FieldId.create(value); + return fieldIdResult.isOk() ? [fieldIdResult.value] : []; + }); +}; + +export const toRecordSearchAccessPathFromConfig = ( + row: SearchAccessPathConfigRow | undefined +): IRecordSearchAccessPath | undefined => { + if (!row) { + return undefined; + } + + if (row.status !== 'ready') { + return undefined; + } + + const searchScope = + row.searchScope === 'all_fields' || row.searchScope === 'selected_fields' + ? row.searchScope + : undefined; + const coveredFieldIds = parseFieldIds(row.fieldIds); + if (!row.generatedColumnName || !searchScope || coveredFieldIds.length === 0) { + return undefined; + } + + if ( + row.semantics === 'substring' && + row.accessPath === 'generated_text' && + (row.provider === 'pg_trgm' || row.provider === 'pg_bigm') + ) { + return { + kind: 'generated_text', + generatedColumnName: row.generatedColumnName, + provider: row.provider, + searchScope, + coveredFieldIds, + }; + } + + if (!row.languageConfig) return undefined; + + return { + kind: 'generated_tsvector', + generatedColumnName: row.generatedColumnName, + languageConfig: row.languageConfig, + searchScope, + coveredFieldIds, + }; +}; + +export class PostgresTableSearchAccessPathResolver implements TableSearchAccessPathResolver { + constructor(private readonly metaDb: Kysely) {} + + async resolve(_context: IExecutionContext, tableId: string) { + try { + const relation = await sql<{ relation_name: string | null }>` + SELECT to_regclass('public.table_query_search_vector_config')::text AS relation_name + `.execute(this.metaDb); + if (!relation.rows[0]?.relation_name) return ok(undefined); + + const result = await sql` + SELECT + generated_column_name AS "generatedColumnName", + semantics, + access_path AS "accessPath", + provider, + language_config AS "languageConfig", + field_ids AS "fieldIds", + search_scope AS "searchScope", + status + FROM table_query_search_vector_config + WHERE table_id = ${tableId} + AND status IN ('ready', 'rebuild_pending', 'stale') + ORDER BY last_modified_time DESC NULLS LAST, created_time DESC NULLS LAST + LIMIT 1 + `.execute(this.metaDb); + + return ok(toRecordSearchAccessPathFromConfig(result.rows[0])); + } catch (error) { + return err(toInfrastructureError(error, 'Failed to resolve table search access path')); + } + } +} + export class PostgresTableSearchVectorStatusReader implements TableSearchVectorStatusReader { constructor(private readonly metaDb: Kysely) {} diff --git a/packages/v2/adapter-table-repository-postgres/package.json b/packages/v2/adapter-table-repository-postgres/package.json index 753d701944..f99e3be8ed 100644 --- a/packages/v2/adapter-table-repository-postgres/package.json +++ b/packages/v2/adapter-table-repository-postgres/package.json @@ -48,6 +48,7 @@ "devDependencies": { "@electric-sql/pglite": "0.3.14", "@teable/v2-adapter-db-postgres-pg": "workspace:*", + "@teable/v2-adapter-table-query-ops-postgres": "workspace:*", "@teable/v2-container-node-test": "workspace:*", "@teable/v2-table-templates": "workspace:*", "@teable/v2-tsdown-config": "workspace:*", diff --git a/packages/v2/adapter-table-repository-postgres/src/di/register.ts b/packages/v2/adapter-table-repository-postgres/src/di/register.ts index e9538b15d1..86e5605dbf 100644 --- a/packages/v2/adapter-table-repository-postgres/src/di/register.ts +++ b/packages/v2/adapter-table-repository-postgres/src/di/register.ts @@ -50,6 +50,7 @@ import { PostgresTableRecordRepository, PostgresRecordOrderCalculator, PostgresAttachmentLookupService, + PostgresCollaboratorDirectoryService, PostgresUserLookupService, } from '../record/repository'; import { @@ -156,6 +157,9 @@ export const registerV2TableRepositoryPostgresAdapter = ( c.register(v2CoreTokens.userLookupService, PostgresUserLookupService, { lifecycle: Lifecycle.Singleton, }); + c.register(v2CoreTokens.collaboratorDirectoryService, PostgresCollaboratorDirectoryService, { + lifecycle: Lifecycle.Singleton, + }); c.register(v2CoreTokens.attachmentLookupService, PostgresAttachmentLookupService, { lifecycle: Lifecycle.Singleton, diff --git a/packages/v2/adapter-table-repository-postgres/src/integration/commands/CreateRecordsHandler.db.spec.ts b/packages/v2/adapter-table-repository-postgres/src/integration/commands/CreateRecordsHandler.db.spec.ts index 0d6c9112ed..c2d9f502bb 100644 --- a/packages/v2/adapter-table-repository-postgres/src/integration/commands/CreateRecordsHandler.db.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/integration/commands/CreateRecordsHandler.db.spec.ts @@ -150,7 +150,8 @@ describe('CreateRecordsHandler (db)', () => { expect(sortedRows[1][titleDbField]).toBe('Second Record'); expect(sortedRows[1][amountDbField]).toBe(200); - expect(sortedRows[1][approvedDbField]).toBe(false); + // v1 contract: checkbox false is stored as null (T6520) + expect(sortedRows[1][approvedDbField]).toBeNull(); expect(sortedRows[2][titleDbField]).toBe('Third Record'); expect(sortedRows[2][amountDbField]).toBe(300); diff --git a/packages/v2/adapter-table-repository-postgres/src/integration/commands/UpdateRecordHandler.db.spec.ts b/packages/v2/adapter-table-repository-postgres/src/integration/commands/UpdateRecordHandler.db.spec.ts index ea1e08ad2e..5dbcb5ea17 100644 --- a/packages/v2/adapter-table-repository-postgres/src/integration/commands/UpdateRecordHandler.db.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/integration/commands/UpdateRecordHandler.db.spec.ts @@ -118,6 +118,130 @@ describe('UpdateRecordHandler (db)', () => { expect(row['__version']).toBe(2); }); + it('accepts crossed single/multi link cell shapes without typecast', async () => { + const { container, baseId } = getV2NodeTestContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const db = container.resolve>(v2PostgresDbTokens.db); + + const foreignNameFieldId = `fld${'s'.repeat(16)}`; + const foreignTableResult = await commandBus.execute( + createContext(), + CreateTableCommand.create({ + baseId: baseId.toString(), + name: 'Link Shape Foreign', + fields: [{ type: 'singleLineText', id: foreignNameFieldId, name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + })._unsafeUnwrap() + ); + const foreignTable = foreignTableResult._unsafeUnwrap().table; + + const hostNameFieldId = `fld${'t'.repeat(16)}`; + const singleLinkFieldId = `fld${'u'.repeat(16)}`; + const multiLinkFieldId = `fld${'v'.repeat(16)}`; + const hostTableResult = await commandBus.execute( + createContext(), + CreateTableCommand.create({ + baseId: baseId.toString(), + name: 'Link Shape Host', + fields: [ + { type: 'singleLineText', id: hostNameFieldId, name: 'Name', isPrimary: true }, + { + type: 'link', + id: singleLinkFieldId, + name: 'Single Link', + options: { + relationship: 'manyOne', + foreignTableId: foreignTable.id().toString(), + lookupFieldId: foreignNameFieldId, + isOneWay: true, + }, + }, + { + type: 'link', + id: multiLinkFieldId, + name: 'Multi Link', + options: { + relationship: 'manyMany', + foreignTableId: foreignTable.id().toString(), + lookupFieldId: foreignNameFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + })._unsafeUnwrap() + ); + const hostTable = hostTableResult._unsafeUnwrap().table; + + const foreignRecordResult = await commandBus.execute( + createContext(), + CreateRecordCommand.create({ + tableId: foreignTable.id().toString(), + fields: { + [foreignNameFieldId]: 'Linked Target', + }, + })._unsafeUnwrap() + ); + const foreignRecordId = foreignRecordResult._unsafeUnwrap().record.id().toString(); + const linkedItem = { id: foreignRecordId, title: 'Linked Target' }; + + const hostRecordResult = await commandBus.execute( + createContext(), + CreateRecordCommand.create({ + tableId: hostTable.id().toString(), + fields: { + [hostNameFieldId]: 'Host', + }, + })._unsafeUnwrap() + ); + const hostRecordId = hostRecordResult._unsafeUnwrap().record.id().toString(); + + const updateResult = await commandBus.execute( + createContext(), + UpdateRecordCommand.create({ + tableId: hostTable.id().toString(), + recordId: hostRecordId, + typecast: false, + fields: { + // V1 compatibility: single-value link accepts array, multi-value accepts object + [singleLinkFieldId]: [linkedItem], + [multiLinkFieldId]: linkedItem, + }, + })._unsafeUnwrap() + ); + updateResult._unsafeUnwrap(); + + const hostDbTableName = hostTable.dbTableName()._unsafeUnwrap().value()._unsafeUnwrap(); + const singleLinkDbField = hostTable + .getFields() + .find((field) => field.id().toString() === singleLinkFieldId) + ?.dbFieldName() + ._unsafeUnwrap() + .value() + ._unsafeUnwrap(); + const multiLinkDbField = hostTable + .getFields() + .find((field) => field.id().toString() === multiLinkFieldId) + ?.dbFieldName() + ._unsafeUnwrap() + .value() + ._unsafeUnwrap(); + + expect(singleLinkDbField).toBeDefined(); + expect(multiLinkDbField).toBeDefined(); + if (!singleLinkDbField || !multiLinkDbField) return; + + const rows = await (db as unknown as Kysely>>) + .selectFrom(hostDbTableName) + .select([singleLinkDbField, multiLinkDbField]) + .where('__id', '=', hostRecordId) + .execute(); + + expect(rows).toHaveLength(1); + expect(rows[0]?.[singleLinkDbField]).toEqual(linkedItem); + expect(rows[0]?.[multiLinkDbField]).toEqual([linkedItem]); + }); + it('normalizes checkbox false to null for name keys', async () => { const { container, baseId } = getV2NodeTestContainer(); const commandBus = container.resolve(v2CoreTokens.commandBus); @@ -330,4 +454,140 @@ describe('UpdateRecordHandler (db)', () => { expect(rows[0]?.[formulaDbField]).toBe('20250701-Other-Education Service'); }); + + it('rejects calendar-invalid date writes in strict mode and nulls them with typecast', async () => { + const { container, baseId } = getV2NodeTestContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const db = container.resolve>(v2PostgresDbTokens.db); + + const createTableResult = await commandBus.execute( + createContext(), + CreateTableCommand.create({ + baseId: baseId.toString(), + name: 'Invalid Calendar Date', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'date', + name: 'Due', + options: { + formatting: { + date: 'YYYY-MM-DD', + time: 'None', + timeZone: 'utc', + }, + }, + }, + ], + views: [{ type: 'grid' }], + })._unsafeUnwrap() + ); + const { table } = createTableResult._unsafeUnwrap(); + const tableId = table.id().toString(); + const titleField = table.getFields().find((field) => field.name().toString() === 'Title'); + const dateField = table.getFields().find((field) => field.name().toString() === 'Due'); + expect(titleField).toBeDefined(); + expect(dateField).toBeDefined(); + if (!titleField || !dateField) return; + + const createRecordResult = await commandBus.execute( + createContext(), + CreateRecordCommand.create({ + tableId, + fields: { + [titleField.id().toString()]: 'Seed', + [dateField.id().toString()]: '2026-03-01T00:00:00.000Z', + }, + })._unsafeUnwrap() + ); + const { record } = createRecordResult._unsafeUnwrap(); + + const invalidInputs = [ + '2026-02-30', + '2026-02-29', + '2026-01-32', + '2026-00-10', + '2026-13-01', + '2026-03-01 25:00', + '2026-03-01 10:61', + '2026-03-01 10:30:61', + '2026-02-30T00:00:00Z', + ]; + + for (const input of invalidInputs) { + const strictResult = await commandBus.execute( + createContext(), + UpdateRecordCommand.create({ + tableId, + recordId: record.id().toString(), + fieldKeyType: FieldKeyType.Name, + fields: { + Due: input, + }, + })._unsafeUnwrap() + ); + expect(strictResult.isErr()).toBe(true); + } + + const typecastResult = await commandBus.execute( + createContext(), + UpdateRecordCommand.create({ + tableId, + recordId: record.id().toString(), + typecast: true, + fieldKeyType: FieldKeyType.Name, + fields: { + Due: '2026-02-30', + }, + })._unsafeUnwrap() + ); + expect(typecastResult.isOk()).toBe(true); + + const validBoundaryResult = await commandBus.execute( + createContext(), + UpdateRecordCommand.create({ + tableId, + recordId: record.id().toString(), + fieldKeyType: FieldKeyType.Name, + fields: { + Due: '2024-02-29', + }, + })._unsafeUnwrap() + ); + expect(validBoundaryResult.isOk()).toBe(true); + + const dbTableName = table.dbTableName()._unsafeUnwrap().value()._unsafeUnwrap(); + const dateDbField = dateField.dbFieldName()._unsafeUnwrap().value()._unsafeUnwrap(); + const rows = await (db as unknown as Kysely>>) + .selectFrom(dbTableName) + .selectAll() + .where('__id', '=', record.id().toString()) + .execute(); + + expect(rows).toHaveLength(1); + // typecast null write lands first, then the valid leap-day boundary overwrites it. + // Assert the final persisted value from a separate read, not the command response. + expect(rows[0]?.[dateDbField]).toEqual(new Date('2024-02-29T00:00:00.000Z')); + + const typecastOnlyResult = await commandBus.execute( + createContext(), + UpdateRecordCommand.create({ + tableId, + recordId: record.id().toString(), + typecast: true, + fieldKeyType: FieldKeyType.Name, + fields: { + Due: '2026-13-01', + }, + })._unsafeUnwrap() + ); + expect(typecastOnlyResult.isOk()).toBe(true); + + const typecastRows = await (db as unknown as Kysely>>) + .selectFrom(dbTableName) + .selectAll() + .where('__id', '=', record.id().toString()) + .execute(); + expect(typecastRows[0]?.[dateDbField]).toBeNull(); + }); }); diff --git a/packages/v2/adapter-table-repository-postgres/src/integration/undo-redo/records/deleteRecords/undoRedo.db.spec.ts b/packages/v2/adapter-table-repository-postgres/src/integration/undo-redo/records/deleteRecords/undoRedo.db.spec.ts index 01d34a2490..24006c1e2a 100644 --- a/packages/v2/adapter-table-repository-postgres/src/integration/undo-redo/records/deleteRecords/undoRedo.db.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/integration/undo-redo/records/deleteRecords/undoRedo.db.spec.ts @@ -2,6 +2,7 @@ import { CreateRecordsCommand, DeleteRecordsCommand, + RECORD_REMOVAL_REASON, v2CoreTokens, type CreateRecordsResult, type DeleteRecordsResult, @@ -89,4 +90,161 @@ describe('undo-redo/deleteRecords (db)', () => { expect(harness.probe.names()).toEqual(['RedoCommand', 'DeleteRecordsCommand']); expect(await fetchRowById(harness.db, table, deletedId)).toBeUndefined(); }); + + // The undo entry embeds full snapshots; the replay must consult record_trash so + // records the user purged after the delete (emptied recycle bin, permanently + // deleted items) stay gone instead of resurrecting from the stack payload. + it('restores only records whose trash rows survived a partial purge', async () => { + if (!harness) throw new Error('Missing harness'); + + const table = await createBasicTable(harness, 'Undo Partially Purged'); + const titleField = findField(table, 'Title'); + + const createResult = await harness.execute( + CreateRecordsCommand.create({ + tableId: table.id().toString(), + records: [ + { fields: { [titleField.id().toString()]: 'Survivor' } }, + { fields: { [titleField.id().toString()]: 'Purged' } }, + ], + })._unsafeUnwrap() + ); + const survivorId = createResult.records[0]!.id().toString(); + const purgedId = createResult.records[1]!.id().toString(); + + await harness.execute( + DeleteRecordsCommand.create({ + tableId: table.id().toString(), + recordIds: [survivorId, purgedId], + })._unsafeUnwrap() + ); + + await harness.db + .deleteFrom('record_trash') + .where('table_id', '=', table.id().toString()) + .where('record_id', '=', purgedId) + .execute(); + + await harness.undo(table.id().toString()); + expect(await fetchRowById(harness.db, table, survivorId)).toBeDefined(); + expect(await fetchRowById(harness.db, table, purgedId)).toBeUndefined(); + }); + + // Archive redo replays the carried write-ahead rows. It must (a) skip rows whose + // archive snapshot the user purged between undo and redo — resurrecting them would + // override an explicit permanent delete — and (b) stamp the replayed rows with the + // replay time: the undo wrote a restore tombstone, and a row keeping its original + // (older) archive time would be suppressed by that tombstone once it reaches cold + // storage, then physically dropped by compaction. + it('archive redo re-persists only surviving rows and stamps them with the replay time', async () => { + if (!harness) throw new Error('Missing harness'); + + const table = await createBasicTable(harness, 'Redo Archived Partial Purge'); + const titleField = findField(table, 'Title'); + const tableId = table.id().toString(); + + const createResult = await harness.execute( + CreateRecordsCommand.create({ + tableId, + records: [ + { fields: { [titleField.id().toString()]: 'Survivor' } }, + { fields: { [titleField.id().toString()]: 'Purged' } }, + ], + })._unsafeUnwrap() + ); + const survivorId = createResult.records[0]!.id().toString(); + const purgedId = createResult.records[1]!.id().toString(); + + // mimic the archive orchestrator's write-ahead snapshots at an old archive time + const originalArchiveTime = new Date('2026-01-01T00:00:00.000Z'); + const archiveRow = (recordId: string) => ({ + recordId, + snapshot: JSON.stringify({ id: recordId, fields: {} }), + createdBy: harness!.context.actorId.toString(), + createdTime: originalArchiveTime.toISOString(), + operationId: 'oprredoarchivetest1', + }); + await harness.db + .insertInto('record_trash') + .values( + [survivorId, purgedId].map((recordId) => ({ + id: `rtrtest_${crypto.randomUUID()}`, + table_id: tableId, + record_id: recordId, + snapshot: JSON.stringify({ id: recordId, fields: {} }), + created_by: harness!.context.actorId.toString(), + created_time: originalArchiveTime, + reason: RECORD_REMOVAL_REASON.Archived, + operation_id: 'oprredoarchivetest1', + })) + ) + .execute(); + + await harness.execute( + DeleteRecordsCommand.create( + { tableId, recordIds: [survivorId, purgedId] }, + { + removalReason: RECORD_REMOVAL_REASON.Archived, + archiveRows: [archiveRow(survivorId), archiveRow(purgedId)], + } + )._unsafeUnwrap() + ); + + // permanently delete one archived record while both sit in the archive + await harness.db + .deleteFrom('record_trash') + .where('table_id', '=', tableId) + .where('record_id', '=', purgedId) + .execute(); + + await harness.undo(tableId); + expect(await fetchRowById(harness.db, table, survivorId)).toBeDefined(); + expect(await fetchRowById(harness.db, table, purgedId)).toBeUndefined(); + + await harness.redo(tableId); + expect(await fetchRowById(harness.db, table, survivorId)).toBeUndefined(); + expect(await fetchRowById(harness.db, table, purgedId)).toBeUndefined(); + + const archiveRowsAfterRedo = await harness.db + .selectFrom('record_trash') + .select(['record_id', 'created_time']) + .where('table_id', '=', tableId) + .where('reason', '=', RECORD_REMOVAL_REASON.Archived) + .execute(); + expect(archiveRowsAfterRedo.map((row) => row.record_id)).toEqual([survivorId]); + expect(new Date(archiveRowsAfterRedo[0]!.created_time as Date).getTime()).toBeGreaterThan( + originalArchiveTime.getTime() + ); + }); + + it('undo is a successful no-op after every trash row was purged', async () => { + if (!harness) throw new Error('Missing harness'); + + const table = await createBasicTable(harness, 'Undo Fully Purged'); + const titleField = findField(table, 'Title'); + + const createResult = await harness.execute( + CreateRecordsCommand.create({ + tableId: table.id().toString(), + records: [{ fields: { [titleField.id().toString()]: 'Gone' } }], + })._unsafeUnwrap() + ); + const recordId = createResult.records[0]!.id().toString(); + + await harness.execute( + DeleteRecordsCommand.create({ + tableId: table.id().toString(), + recordIds: [recordId], + })._unsafeUnwrap() + ); + + await harness.db + .deleteFrom('record_trash') + .where('table_id', '=', table.id().toString()) + .execute(); + + await harness.undo(table.id().toString()); + expect(harness.probe.names()).toEqual(['UndoCommand', 'RestoreRecordsCommand']); + expect(await fetchRowById(harness.db, table, recordId)).toBeUndefined(); + }); }); diff --git a/packages/v2/adapter-table-repository-postgres/src/integration/undo-redo/shared/undoRedoDbTestKit.ts b/packages/v2/adapter-table-repository-postgres/src/integration/undo-redo/shared/undoRedoDbTestKit.ts index ef35efaae8..3ea833a50e 100644 --- a/packages/v2/adapter-table-repository-postgres/src/integration/undo-redo/shared/undoRedoDbTestKit.ts +++ b/packages/v2/adapter-table-repository-postgres/src/integration/undo-redo/shared/undoRedoDbTestKit.ts @@ -8,7 +8,10 @@ import { ActorId, CreateTableCommand, MemoryCommandBus, + MemoryEventBus, MemoryUndoRedoStore, + RECORD_REMOVAL_REASON, + RecordsDeleted, RedoCommand, Table as TableAggregate, UndoCommand, @@ -67,6 +70,74 @@ const unwrap = (result: Result, label: s return result.value; }; +// The pure v2 container has no trash sink — the nestjs backend registers the +// RecordsDeleted → record_trash projection. The undo/redo replay of RestoreRecords +// consults record_trash (purge guard: rows the user purged must not resurrect), so +// the harness mimics the projection at the same seam: every published RecordsDeleted +// event writes one minimal row per deleted record. Archived removals persist their +// own write-ahead snapshots outside this container and are skipped, matching the +// backend projection. +// +// Row ids must be random, never a module-level counter: the database can outlive one +// test file (a shared testcontainers instance, or a pglite:// DIRECTORY path) while +// module state resets per file — sequential ids would collide on record_trash_pkey. +const trashSinkRowId = () => `rtrtest_${crypto.randomUUID()}`; + +class TrashSinkEventBus extends MemoryEventBus { + constructor( + handlerResolver: ConstructorParameters[0], + private readonly db: () => Kysely + ) { + super(handlerResolver); + } + + override async publish( + context: IExecutionContext, + event: Parameters[1] + ) { + const result = await super.publish(context, event); + if (result.isOk()) { + await this.sinkDeleted(context, [event]); + } + return result; + } + + override async publishMany( + context: IExecutionContext, + events: Parameters[1] + ) { + const result = await super.publishMany(context, events); + if (result.isOk()) { + await this.sinkDeleted(context, events); + } + return result; + } + + private async sinkDeleted( + context: IExecutionContext, + events: Parameters[1] + ) { + for (const event of events) { + if (!(event instanceof RecordsDeleted)) continue; + if (event.removalReason === RECORD_REMOVAL_REASON.Archived) continue; + if (event.recordIds.length === 0) continue; + await this.db() + .insertInto('record_trash') + .values( + event.recordIds.map((recordId) => ({ + id: trashSinkRowId(), + table_id: event.tableId.toString(), + record_id: recordId.toString(), + snapshot: '{}', + created_by: context.actorId.toString(), + reason: RECORD_REMOVAL_REASON.Deleted, + })) + ) + .execute(); + } + } +} + export type UndoRedoDbHarness = Awaited>; export const createUndoRedoDbHarness = async (options?: { @@ -77,6 +148,17 @@ export const createUndoRedoDbHarness = async (options?: { }); const probe = new CommandProbeMiddleware(); + const trashSinkEventBus = new TrashSinkEventBus(testContainer.container, () => + testContainer.container.resolve>(v2DataDbTokens.db) + ); + testContainer.container.registerInstance(v2CoreTokens.eventBus, trashSinkEventBus); + testContainer.eventBus = trashSinkEventBus; + // This harness mirrors the full app: the sink above writes record_trash rows + // on delete, so the delete-undo purge guard is sound here and stays on. + testContainer.container.registerInstance(v2CoreTokens.undoRedoReplayConfig, { + restorePurgeGuard: true, + }); + testContainer.container.registerInstance( v2CoreTokens.commandBus, new MemoryCommandBus(testContainer.container, [probe]) diff --git a/packages/v2/adapter-table-repository-postgres/src/record/buildFilledLinkValueExpression.ts b/packages/v2/adapter-table-repository-postgres/src/record/buildFilledLinkValueExpression.ts index d74c03d293..ee20dde378 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/buildFilledLinkValueExpression.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/buildFilledLinkValueExpression.ts @@ -45,9 +45,11 @@ export const buildFilledLinkValueExpression = (params: { return ok(sql`( SELECT COALESCE( jsonb_agg( - jsonb_build_object( - 'id', v.id, - 'title', COALESCE(v.title, ${sql.ref(`ft.${lookupDbFieldName}`)}::text) + jsonb_strip_nulls( + jsonb_build_object( + 'id', v.id, + 'title', COALESCE(v.title, ${sql.ref(`ft.${lookupDbFieldName}`)}::text) + ) ) ORDER BY v.ord ), @@ -64,9 +66,11 @@ export const buildFilledLinkValueExpression = (params: { } return ok(sql`( - SELECT jsonb_build_object( - 'id', v.id, - 'title', COALESCE(v.title, ${sql.ref(`ft.${lookupDbFieldName}`)}::text) + SELECT jsonb_strip_nulls( + jsonb_build_object( + 'id', v.id, + 'title', COALESCE(v.title, ${sql.ref(`ft.${lookupDbFieldName}`)}::text) + ) ) FROM (VALUES (${singleItem.id}, ${singleItem.title ?? null})) AS v(id, title) LEFT JOIN ${sql.table(foreignDbTableName)} ft ON ft.__id = v.id diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedContinuationFields.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedContinuationFields.spec.ts new file mode 100644 index 0000000000..af8f8f43ab --- /dev/null +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedContinuationFields.spec.ts @@ -0,0 +1,54 @@ +import { FieldId } from '@teable/v2-core'; +import { describe, expect, it } from 'vitest'; + +import { collectContinuationFieldIds } from './ComputedContinuationFields'; +import type { ComputedUpdatePlan } from './ComputedUpdatePlanner'; + +const fieldId = (suffix: string) => FieldId.create(`fld${suffix.repeat(16)}`)._unsafeUnwrap(); + +describe('collectContinuationFieldIds', () => { + it('continues only from fields whose values actually changed', () => { + const changed = fieldId('a'); + const unchanged = fieldId('b'); + const plan = { + steps: [{ tableId: {} as never, fieldIds: [changed, unchanged], level: 0 }], + edges: [], + } as unknown as ComputedUpdatePlan; + + expect( + collectContinuationFieldIds(plan, [ + { + tableId: 'tblxxxxxxxxxxxxxxxx', + recordChanges: [ + { + recordId: 'recxxxxxxxxxxxxxxxx', + oldVersion: 1, + changes: [{ fieldId: changed.toString(), newValue: 'changed' }], + }, + ], + }, + ]).map((id) => id.toString()) + ).toEqual([changed.toString()]); + }); + + it('stops after a step that produced no actual changes', () => { + const plan = { + steps: [{ tableId: {} as never, fieldIds: [fieldId('a')], level: 0 }], + edges: [], + } as unknown as ComputedUpdatePlan; + + expect(collectContinuationFieldIds(plan, [])).toEqual([]); + }); + + it('uses propagation targets for an edge-only stage', () => { + const target = fieldId('c'); + const plan = { + steps: [], + edges: [{ toFieldId: target, propagationTargetFieldIds: [target] }], + } as unknown as ComputedUpdatePlan; + + expect(collectContinuationFieldIds(plan, []).map((id) => id.toString())).toEqual([ + target.toString(), + ]); + }); +}); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedContinuationFields.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedContinuationFields.ts new file mode 100644 index 0000000000..27dc338088 --- /dev/null +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedContinuationFields.ts @@ -0,0 +1,44 @@ +import type { FieldId } from '@teable/v2-core'; + +import type { StepChangeData } from './ComputedFieldUpdater'; +import type { ComputedUpdatePlan } from './ComputedUpdatePlanner'; + +/** + * Fields that may start the next cascade stage. + * + * Executed steps continue only from values that were actually changed by the + * UPDATE. This makes an unchanged recomputation a fixed point instead of + * feeding every planned field back into reciprocal link edges forever. + * Propagation-only stages have no UPDATE changes to inspect, so their edge + * targets are the outputs that must be computed next. + */ +export const collectContinuationFieldIds = ( + plan: ComputedUpdatePlan, + changesByStep: ReadonlyArray +): FieldId[] => { + if (plan.steps.length === 0) { + const edgeTargets = new Map(); + for (const edge of plan.edges) { + for (const fieldId of edge.propagationTargetFieldIds ?? [edge.toFieldId]) { + edgeTargets.set(fieldId.toString(), fieldId); + } + } + return [...edgeTargets.values()]; + } + + const plannedFields = new Map(); + for (const step of plan.steps) { + for (const fieldId of step.fieldIds) plannedFields.set(fieldId.toString(), fieldId); + } + + const changedFields = new Map(); + for (const stepChange of changesByStep) { + for (const recordChange of stepChange.recordChanges) { + for (const change of recordChange.changes) { + const fieldId = plannedFields.get(change.fieldId); + if (fieldId) changedFields.set(change.fieldId, fieldId); + } + } + } + return [...changedFields.values()]; +}; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedFieldCascadeAfterSchemaUpdate.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedFieldCascadeAfterSchemaUpdate.ts index 74b1949de9..0a547d96bd 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedFieldCascadeAfterSchemaUpdate.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedFieldCascadeAfterSchemaUpdate.ts @@ -198,6 +198,11 @@ export class ComputedFieldCascadeAfterSchemaUpdate { includeComputedSeedFields: true, } ); + // Steps-only by design: this cascade executes per-step backfills (no + // propagation machinery), and its planner always includes the altered + // table's own step (includeComputedSeedFields) — steps.length === 0 truly + // means no work, unlike the strategy/worker paths where edge-only plans + // are executable. if (plan.steps.length === 0) return ok(undefined); const sortedSteps = [...plan.steps].sort((a, b) => a.level - b.level); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedFieldUpdater.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedFieldUpdater.ts index 7ee301e62a..2640bc1d29 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedFieldUpdater.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedFieldUpdater.ts @@ -4,6 +4,7 @@ import { } from '@teable/v2-adapter-db-postgres-shared'; import { domainError, + tableDataSafetyLimitErrors, FieldType, FieldCondition, LinkRelationship, @@ -40,6 +41,17 @@ import { type SameTableFieldLevel, } from '../query-builder/computed/SameTableBatchQueryBuilder'; import { TableRecordConditionWhereVisitor } from '../visitors/TableRecordConditionWhereVisitor'; +import { + STAGE_LEDGER_TABLE, + type ComputedStageLedgerSettlementMode, + appendStageLedgerPartialBatch, + clearStageLedger, + collectStageOutputSeedGroups, + pushStageLedgerFrontierHead, + retireStageLedgerFrontierHead, + stageLedgerHasFrontier, + seedStageLedgerFrontierHead, +} from './ComputedStageLedger'; import { COMPUTED_UPDATE_LOCK_UNAVAILABLE_CODE, type ComputedUpdateLockConfig, @@ -75,7 +87,10 @@ const DIRTY_GENERATION_COL = 'generation'; const BEFORE_IMAGE_TABLE = 'pg_temp.tmp_computed_before_image'; const BEFORE_IMAGE_SNAPSHOT_COL = 'field_values'; const SAME_TABLE_BATCH_CHUNK_TRIGGER = 1000; +/** Dirty-count threshold above which a table collects as seed-all instead of ids. */ +const DEFAULT_SEED_ALL_THRESHOLD = 5000; const SAME_TABLE_BATCH_CHUNK_SIZE = 500; +const JSON_SAME_TABLE_BATCH_CHUNK_SIZE = 25; const COMPUTED_UPDATE_FIELD_CHUNK_SIZE = 16; const DISTINCT_HOST_KEY_UNCHUNK_MAX_DIRTY_RECORDS = 50_000; const DISTINCT_HOST_KEY_UNCHUNK_MAX_KEYS = 5_000; @@ -149,9 +164,58 @@ export type StepChangeData = { /** * Result of computed update execution with optional change data. */ +/** + * Outcome of running dirty propagation under options.maxDirtyRecords. + * - 'exceeded' (abort mode): propagation stopped, no steps were executed; the + * caller must retry with a smaller stage plan. + * - 'partial' (partial mode): propagation stopped at the budget but the steps + * ran against the materialized batch; the caller must continue with the + * processed records excluded until propagation completes. + */ +export type ComputedUpdateDirtyBudgetOutcome = + | { status: 'exceeded'; dirtyRecordsAtAbort: number } + | { + status: 'partial'; + propagatedDirtyRecords: number; + /** + * Which side of the budget cut this batch short. + * - 'seeding': bounded whole-table seeding has more source rows to seed; + * the seeded slice's propagation completed, so the slice may retire. + * - 'propagation': the seeded slice's targets are not fully materialized; + * sources must NOT retire — the next batch re-seeds the slice and + * progresses via target-side exclusions. + * - 'both': seeding truncated and the slice's propagation also truncated. + */ + truncated: 'seeding' | 'propagation' | 'both'; + /** + * How many frontier-queue rows this batch seeded (a stable prefix). + * Settlement retires exactly this prefix once propagation completed. + */ + frontierConsumed?: number; + /** + * Highest run-ledger seq among the consumed frontier prefix; settlement + * retires ledger rows up to this seq once propagation completed. + */ + frontierMaxSeq?: string; + /** + * Advanced whole-table seeding cursors (last __id seeded per table). + * Settlement persists them once the slice's propagation completed. + */ + seedAllCursors?: Readonly>; + /** + * Every table this batch seeded in whole-table form (explicit seed-all and + * the implicit schema-update case alike). Settlement normalizes them into + * explicit seedAllTableIds on the continuation, so classification never + * re-derives the implicit case from the (migrated-away) seed fields. + */ + wholeTableSeedTables?: ReadonlyArray; + }; + export type ComputedUpdateResult = { /** Change data by step, used for event generation */ changesByStep: ReadonlyArray; + /** Present only when a dirty budget cut this run short; absent = complete run. */ + dirtyBudget?: ComputedUpdateDirtyBudgetOutcome; }; const stepKey = (step: UpdateStep): string => `${step.tableId.toString()}|${step.level}`; @@ -234,6 +298,8 @@ type AllTargetReasonCounts = Partial>; export type DirtyPropagationStats = { plannedAllTargetReasonCounts: AllTargetReasonCounts; runtimeAllTargetFallbackReasonCounts: AllTargetReasonCounts; + /** Present only when maxDirtyRecords stopped propagation early. */ + dirtyBudget?: ComputedUpdateDirtyBudgetOutcome; }; const incrementAllTargetReasonCount = ( @@ -324,6 +390,41 @@ type ComputedUpdateLockOptions = { wait?: boolean; }; +/** + * Seed-input eligibility, shared by execute() and prepareDirtyState() so the + * rules cannot drift: seedAllTableIds is real seed input (a continuation + * carrying only seed-all tables must not be mistaken for a schema-update + * "seed everything" run), and the stage ledger's frontier queue counts too — + * probed only when everything else is empty. + */ +const resolveSeedInputEligibility = async ( + db: Kysely, + plan: ComputedUpdatePlan, + ledgerScopeId: string | undefined +): Promise< + Result<{ noSeedInput: boolean; shouldSeedAllForSchemaUpdate: boolean }, DomainError> +> => { + const noExplicitSeedInput = + plan.seedRecordIds.length === 0 && + plan.extraSeedRecords.length === 0 && + (plan.seedAllTableIds ?? []).length === 0; + let ledgerFrontierPresent = false; + if ( + (plan.steps.length > 0 || plan.edges.length > 0) && + noExplicitSeedInput && + ledgerScopeId !== undefined + ) { + const hasFrontier = await stageLedgerHasFrontier(db, ledgerScopeId); + if (hasFrontier.isErr()) return err(hasFrontier.error); + ledgerFrontierPresent = hasFrontier.value; + } + const noSeedInput = noExplicitSeedInput && !ledgerFrontierPresent; + return ok({ + noSeedInput, + shouldSeedAllForSchemaUpdate: noSeedInput && plan.changeType === 'update', + }); +}; + /** * Execute computed field update plans using UPDATE...FROM. * @@ -372,11 +473,27 @@ export class ComputedFieldUpdater { plan: ComputedUpdatePlan, context: IExecutionContext, run?: ComputedUpdateRunContext, - options?: { collectChanges?: boolean; lockWait?: boolean } + options?: { + collectChanges?: boolean; + lockWait?: boolean; + maxDirtyRecords?: number; + dirtyBudgetMode?: 'abort' | 'partial'; + /** + * Scope id (the continuation chain's root task id) of the staged + * execution's durable stage ledger. When set, the frontier queue seeds + * from — and processed targets anti-join against — + * computed_update_stage_ledger instead of plan-carried record arrays. + */ + ledgerScopeId?: string; + } ): Promise> { - const noSeedInput = plan.seedRecordIds.length === 0 && plan.extraSeedRecords.length === 0; - const shouldSeedAllForSchemaUpdate = noSeedInput && plan.changeType === 'update'; - if (plan.steps.length === 0 || (noSeedInput && !shouldSeedAllForSchemaUpdate)) { + if (plan.steps.length === 0 && plan.edges.length === 0) { + return ok({ changesByStep: [] }); + } + const executeDb = resolvePostgresDbOrTx(this.db, context) as unknown as Kysely; + const eligibility = await resolveSeedInputEligibility(executeDb, plan, options?.ledgerScopeId); + if (eligibility.isErr()) return err(eligibility.error); + if (eligibility.value.noSeedInput && !eligibility.value.shouldSeedAllForSchemaUpdate) { return ok({ changesByStep: [] }); } @@ -450,6 +567,7 @@ export class ComputedFieldUpdater { seedTableId: effectivePlan.seedTableId.toString(), changeType: effectivePlan.changeType, seedRecordIds: effectivePlan.seedRecordIds.map((r) => r.toString()), + ledgerScopeId: options?.ledgerScopeId, steps: effectivePlan.steps.map((s) => ({ tableId: s.tableId.toString(), level: s.level, @@ -495,7 +613,30 @@ export class ComputedFieldUpdater { safeTry( async function* (this: ComputedFieldUpdater) { currentPhase = 'prepare_dirty_state'; - const prepared = yield* await this.prepareDirtyState(effectivePlan, context); + const prepared = yield* await this.prepareDirtyState(effectivePlan, context, { + maxDirtyRecords: options?.maxDirtyRecords, + dirtyBudgetMode: options?.dirtyBudgetMode, + ledgerScopeId: options?.ledgerScopeId, + }); + const dirtyBudget = prepared.propagationStats.dirtyBudget; + if (dirtyBudget?.status === 'exceeded') { + mainSpan?.setAttribute('computed.dirtyBudgetOutcome', 'exceeded'); + runLogger.warn('computed:run:dirty_budget_exceeded', { + maxDirtyRecords: options?.maxDirtyRecords, + dirtyRecordsAtAbort: dirtyBudget.dirtyRecordsAtAbort, + stepCount: effectivePlan.steps.length, + edgeCount: effectivePlan.edges.length, + }); + return ok({ changesByStep: [], dirtyBudget }); + } + if (dirtyBudget?.status === 'partial') { + mainSpan?.setAttribute('computed.dirtyBudgetOutcome', 'partial'); + runLogger.info('computed:run:dirty_budget_partial', { + maxDirtyRecords: options?.maxDirtyRecords, + propagatedDirtyRecords: dirtyBudget.propagatedDirtyRecords, + stepCount: effectivePlan.steps.length, + }); + } mainSpan?.setAttribute('computed.totalDirtyRecords', prepared.totalDirtyRecords); mainSpan?.setAttribute('computed.affectedTableCount', prepared.dirtyStats.length); const runtimeFallbackCount = countAllTargetReasonOccurrences( @@ -540,7 +681,10 @@ export class ComputedFieldUpdater { durationMs: Date.now() - runStartTime, }); - return ok({ changesByStep: stepsResult.changesByStep }); + return ok({ + changesByStep: stepsResult.changesByStep, + ...(dirtyBudget ? { dirtyBudget } : {}), + }); }.bind(this) ); @@ -716,12 +860,22 @@ export class ComputedFieldUpdater { */ async prepareDirtyState( plan: ComputedUpdatePlan, - context: IExecutionContext + context: IExecutionContext, + options?: { + maxDirtyRecords?: number; + dirtyBudgetMode?: 'abort' | 'partial'; + /** @see execute — durable stage ledger scope for staged executions. */ + ledgerScopeId?: string; + } ): Promise> { - const noSeedInput = plan.seedRecordIds.length === 0 && plan.extraSeedRecords.length === 0; - const shouldSeedAllForSchemaUpdate = noSeedInput && plan.changeType === 'update'; - if (plan.steps.length === 0 || (noSeedInput && !shouldSeedAllForSchemaUpdate)) { - const db = resolvePostgresDbOrTx(this.db, context) as unknown as Kysely; + const db = resolvePostgresDbOrTx(this.db, context) as unknown as Kysely; + const eligibilityResult = await resolveSeedInputEligibility(db, plan, options?.ledgerScopeId); + if (eligibilityResult.isErr()) return err(eligibilityResult.error); + const { noSeedInput, shouldSeedAllForSchemaUpdate } = eligibilityResult.value; + if ( + (plan.steps.length === 0 && plan.edges.length === 0) || + (noSeedInput && !shouldSeedAllForSchemaUpdate) + ) { return ok({ db, tableById: new Map(), @@ -731,8 +885,6 @@ export class ComputedFieldUpdater { }); } - const db = resolvePostgresDbOrTx(this.db, context) as unknown as Kysely; - return safeTry( async function* (this: ComputedFieldUpdater) { // Helper to run work within a span context so child DB operations are properly nested @@ -768,10 +920,94 @@ export class ComputedFieldUpdater { resetBeforeImageTable(db) ); - // Seed dirty records - wrap with span + // Targets already computed by earlier partial batches of this run stage + // live in the run-scoped ledger (kind 'excluded') and are anti-joined + // directly by budgeted seeding/propagation, so every batch's LIMIT slots + // go only to genuinely-new rows — no per-batch copy into the transaction. + const exclusionScopeId = options?.ledgerScopeId; + + const maxDirtyRecords = + options?.maxDirtyRecords !== undefined && options.maxDirtyRecords > 0 + ? Math.trunc(options.maxDirtyRecords) + : undefined; + const partialMode = maxDirtyRecords !== undefined && options?.dirtyBudgetMode === 'partial'; + // Seeding and propagation share one budget pool: seeding may take at most + // half so propagation always keeps at least one slot, keeping the + // per-transaction ceiling at explicit seeds + stageMaxDirtyRecords total. + const partialSeedingBudget = + partialMode && maxDirtyRecords !== undefined + ? Math.max(1, Math.floor(maxDirtyRecords / 2)) + : undefined; + let seedingConsumed = 0; + let frontierConsumed = 0; + let frontierMaxSeq: string | undefined; + const advancedSeedAllCursors: Record = {}; + const wholeTableSeededTables = new Set(); + let seedAllBudgetOutcome: DirtyPropagationStats['dirtyBudget']; + + // Seed dirty records - wrap with span. Full-table seeding (explicit seed-all + // tables and schema-update runs alike) is budget-bounded: at most `limit` + // rows materialize per table, and truncation surfaces as a budget outcome + // instead of flooding the transaction. yield* await runWithSpan( 'teable.ComputedFieldUpdater.seedDirtyRecords', async () => { + const seedWholeTable = async (table: Table): Promise> => { + const tableKey = table.id().toString(); + wholeTableSeededTables.add(tableKey); + const cursor = plan.seedAllCursors?.[tableKey]; + if (maxDirtyRecords === undefined) { + if (cursor === undefined) { + return seedAllDirtyRecordsForTable(db, table); + } + // Cursored plan executed without a budget: seed the remainder only. + const remainderResult = await seedAllDirtyRecordsForTableBounded( + db, + table, + Number.MAX_SAFE_INTEGER, + exclusionScopeId, + cursor + ); + return remainderResult.isErr() ? err(remainderResult.error) : ok(undefined); + } + + const seedingBudget = partialSeedingBudget ?? maxDirtyRecords; + const remaining = seedingBudget - seedingConsumed; + if (remaining <= 0) { + seedAllBudgetOutcome = partialMode + ? { + status: 'partial', + propagatedDirtyRecords: seedingConsumed, + truncated: 'seeding', + } + : { status: 'exceeded', dirtyRecordsAtAbort: seedingConsumed }; + return ok(undefined); + } + const limit = partialMode ? remaining : remaining + 1; + const seededResult = await seedAllDirtyRecordsForTableBounded( + db, + table, + limit, + exclusionScopeId, + cursor + ); + if (seededResult.isErr()) return err(seededResult.error); + seedingConsumed += seededResult.value.count; + if (seededResult.value.lastRecordId !== undefined) { + advancedSeedAllCursors[tableKey] = seededResult.value.lastRecordId; + } + if (seededResult.value.count >= limit) { + seedAllBudgetOutcome = partialMode + ? { + status: 'partial', + propagatedDirtyRecords: seedingConsumed, + truncated: 'seeding', + } + : { status: 'exceeded', dirtyRecordsAtAbort: seedingConsumed }; + } + return ok(undefined); + }; + if (noSeedInput && shouldSeedAllForSchemaUpdate) { const seedTable = tableById.get(plan.seedTableId.toString()); if (!seedTable) { @@ -781,10 +1017,12 @@ export class ComputedFieldUpdater { }) ); } - return seedAllDirtyRecordsForTable(db, seedTable); + // Fall through: self-referential schema-update continuations carry a + // frontier that must still seed, or its unfinished propagation is lost. + const wholeTableResult = await seedWholeTable(seedTable); + if (wholeTableResult.isErr()) return wholeTableResult; } - // Seed "all records" tables via efficient SQL (skip individual record IDs) for (const tableId of plan.seedAllTableIds ?? []) { const table = tableById.get(tableId.toString()); if (!table) { @@ -794,16 +1032,85 @@ export class ComputedFieldUpdater { }) ); } - const result = await seedAllDirtyRecordsForTable(db, table); + const result = await seedWholeTable(table); if (result.isErr()) return result; + if (seedAllBudgetOutcome?.status === 'exceeded') break; } - // Seed individual records for remaining tables - if (plan.seedRecordIds.length > 0) { - const seedResult = await seedDirtyRecords(db, plan.seedTableId, plan.seedRecordIds); - if (seedResult.isErr()) return seedResult; + // Seed individual records for remaining tables. In abort mode they + // count against the budget BEFORE materializing: a seed set that + // cannot fit reports exceeded immediately (zero extra rows), the + // caller shrinks to the floor, and floor entry migrates the seeds + // into the frontier queue — so every budgeted transaction + // materializes at most stageMaxDirtyRecords dirty rows (+1 abort + // probe sentinel), regardless of the seed set's size. + const explicitSeedCount = + plan.seedRecordIds.length + + plan.extraSeedRecords.reduce((sum, group) => sum + group.recordIds.length, 0); + if ( + !partialMode && + maxDirtyRecords !== undefined && + !seedAllBudgetOutcome && + seedingConsumed + explicitSeedCount > maxDirtyRecords + ) { + seedAllBudgetOutcome = { + status: 'exceeded', + dirtyRecordsAtAbort: seedingConsumed + explicitSeedCount, + }; + } + if (seedAllBudgetOutcome?.status !== 'exceeded') { + if (plan.seedRecordIds.length > 0) { + const seedResult = await seedDirtyRecords(db, plan.seedTableId, plan.seedRecordIds); + if (seedResult.isErr()) return seedResult; + } + const extraResult = await seedExtraDirtyRecords(db, plan.extraSeedRecords); + if (extraResult.isErr()) return extraResult; } - return seedExtraDirtyRecords(db, plan.extraSeedRecords); + // Frontier queue: sources for the next self-referential generations, + // stored seq-ordered in the run ledger. Only a budget-bounded HEAD + // seeds per batch (sharing the seeding pool with whole-table slices) + // so a wide generation cannot flood the transaction; the rest of the + // queue waits for later batches. The consumed head (count + max seq) + // is reported so settlement can retire exactly the sources whose + // propagation completed. + if (exclusionScopeId !== undefined && seedAllBudgetOutcome?.status !== 'exceeded') { + const seedingBudget = partialSeedingBudget ?? maxDirtyRecords; + // The frontier never overdraws the seeding pool: with the pool + // exhausted the queue simply waits for the next batch (reported as + // a seeding truncation below), keeping the per-transaction total at + // exactly maxDirtyRecords — seeding <= floor(budget/2), propagation + // gets the remainder. + const frontierLimit = + seedingBudget === undefined + ? undefined + : Math.max(0, seedingBudget - seedingConsumed); + const headResult = + frontierLimit === 0 + ? // Pool exhausted: seed nothing, but a non-empty queue must + // still surface as a remainder (else the stage would complete + // and drop it). + (await stageLedgerHasFrontier(db, exclusionScopeId)).map((hasQueue) => ({ + consumed: 0, + maxSeq: null, + remainder: hasQueue, + })) + : await seedStageLedgerFrontierHead(db, exclusionScopeId, frontierLimit); + if (headResult.isErr()) return err(headResult.error); + frontierConsumed = headResult.value.consumed; + frontierMaxSeq = headResult.value.maxSeq ?? undefined; + seedingConsumed += frontierConsumed; + // An unseeded queue remainder is a seeding truncation: without the + // partial outcome the stage would complete and drop the queue, + // losing the remaining sources' propagation entirely. + if (partialMode && headResult.value.remainder && !seedAllBudgetOutcome) { + seedAllBudgetOutcome = { + status: 'partial', + propagatedDirtyRecords: seedingConsumed, + truncated: 'seeding', + }; + } + } + return ok(undefined); }, { seedCount: plan.seedRecordIds.length, @@ -819,13 +1126,76 @@ export class ComputedFieldUpdater { } ); - // Propagate dirty records - wrap with span so propagateEdge spans are children + // Propagate dirty records - wrap with span so propagateEdge spans are children. + // Abort mode stops before propagation once seeding exceeded the budget. + // Partial mode ALWAYS propagates the seeded batch — skipping propagation + // after truncated seeding would produce zero targets, zero exclusions, and + // an identical continuation (an infinite loop). Seeding (whole-table slices + // + frontier prefix) and propagation share one maxDirtyRecords pool, so the + // per-transaction ceiling is that pool plus the upstream-bounded explicit + // seeds. const propagationStats = (yield* await runWithSpan( 'teable.ComputedFieldUpdater.propagateDirtyRecords', - () => propagateDirtyRecords(db, plan.edges, tableById, context), + async () => { + if (seedAllBudgetOutcome && !partialMode) { + return ok({ + ...emptyDirtyPropagationStats(), + dirtyBudget: seedAllBudgetOutcome, + }); + } + const propagateResult = await propagateDirtyRecords( + db, + plan.edges, + tableById, + context, + { + maxDirtyRecords: + partialMode && maxDirtyRecords !== undefined + ? Math.max(1, maxDirtyRecords - seedingConsumed) + : options?.maxDirtyRecords, + dirtyBudgetMode: options?.dirtyBudgetMode, + exclusionScopeId, + } + ); + if (propagateResult.isErr() || !seedAllBudgetOutcome) return propagateResult; + + const stats = propagateResult.value; + const propagationOutcome = stats.dirtyBudget; + const propagationTruncated = + propagationOutcome?.status === 'partial' && + (propagationOutcome.truncated === 'propagation' || + propagationOutcome.truncated === 'both'); + return ok({ + ...stats, + dirtyBudget: { + status: 'partial' as const, + propagatedDirtyRecords: + seedingConsumed + + (propagationOutcome?.status === 'partial' + ? propagationOutcome.propagatedDirtyRecords + : 0), + truncated: propagationTruncated ? ('both' as const) : ('seeding' as const), + }, + }); + }, { 'propagate.edgeCount': plan.edges.length } )) as DirtyPropagationStats; + // Attach the consumed frontier prefix to partial outcomes so settlement + // can retire exactly the sources whose propagation completed. + if (propagationStats.dirtyBudget?.status === 'partial') { + propagationStats.dirtyBudget = { + ...propagationStats.dirtyBudget, + ...(frontierConsumed > 0 ? { frontierConsumed, frontierMaxSeq } : {}), + ...(Object.keys(advancedSeedAllCursors).length > 0 + ? { seedAllCursors: { ...plan.seedAllCursors, ...advancedSeedAllCursors } } + : {}), + ...(wholeTableSeededTables.size > 0 + ? { wholeTableSeedTables: [...wholeTableSeededTables] } + : {}), + }; + } + // Collect dirty stats - wrap with span const dirtyStats = yield* await runWithSpan( 'teable.ComputedFieldUpdater.collectDirtyStats', @@ -1091,77 +1461,78 @@ export class ComputedFieldUpdater { const shouldChunkFields = collectChanges && !collapsedBatch && fieldIds.length > COMPUTED_UPDATE_FIELD_CHUNK_SIZE; - const formulaOnlyFieldLevelsResult = safeTry( - function* () { - if (fieldIds.length === 0) return ok([]); + const formulaOnlyFieldLevelsResult = ((): Result => { + if (fieldIds.length === 0) return ok([]); - if (collapsedBatch && !collapsedBatch.tableId.equals(step.tableId)) { - return err(domainError.validation({ message: 'Collapsed batch table mismatch' })); - } + if (collapsedBatch && !collapsedBatch.tableId.equals(step.tableId)) { + return err(domainError.validation({ message: 'Collapsed batch table mismatch' })); + } - const allowedFieldIds = new Set(fieldIds.map((id) => id.toString())); - const sourceSteps = collapsedBatch - ? [...collapsedBatch.steps].sort((a, b) => a.level - b.level) - : [step]; - const fieldLevels: SameTableFieldLevel[] = []; - - for (const sourceStep of sourceSteps) { - const levelFieldIds: FieldId[] = []; - for (const fieldId of sourceStep.fieldIds) { - if (!allowedFieldIds.has(fieldId.toString())) continue; - const field = yield* table.getField((f) => f.id().equals(fieldId)); - if (!field.type().equals(FieldType.formula())) { - return ok([]); - } - levelFieldIds.push(fieldId); - } - if (levelFieldIds.length > 0) { - fieldLevels.push({ level: sourceStep.level, fieldIds: levelFieldIds }); + const allowedFieldIds = new Set(fieldIds.map((id) => id.toString())); + const sourceSteps = collapsedBatch + ? [...collapsedBatch.steps].sort((a, b) => a.level - b.level) + : [step]; + const fieldLevels: SameTableFieldLevel[] = []; + + for (const sourceStep of sourceSteps) { + const levelFieldIds: FieldId[] = []; + for (const fieldId of sourceStep.fieldIds) { + if (!allowedFieldIds.has(fieldId.toString())) continue; + const fieldResult = table.getField((f) => f.id().equals(fieldId)); + // Deleted between planning and execution — nothing to compute. + if (fieldResult.isErr()) continue; + if (!fieldResult.value.type().equals(FieldType.formula())) { + return ok([]); } + levelFieldIds.push(fieldId); + } + if (levelFieldIds.length > 0) { + fieldLevels.push({ level: sourceStep.level, fieldIds: levelFieldIds }); } - - return ok(fieldLevels); } - ); + + return ok(fieldLevels); + })(); if (formulaOnlyFieldLevelsResult.isErr()) return err(formulaOnlyFieldLevelsResult.error); // Formula-only same-table steps use a CTE chain so formula dependencies are computed // once and later formulas read CTE columns instead of recursively inlining expressions. if (formulaOnlyFieldLevelsResult.value.length > 0 && !shouldChunkFields) { - const hasJsonTargets = await this.hasJsonTargetColumns(db, tableName, table, fieldIds); - if (hasJsonTargets) { - stepSpan?.setAttribute('step.sameTableCollapsedSkipped', true); - stepSpan?.setAttribute('step.sameTableCollapsedSkipReason', 'json_target_column'); - } - - if (!hasJsonTargets) { - const batchBuilder = new SameTableBatchQueryBuilder(db, this.typeValidationStrategy); - const chunkedRecordIds = - dirtyCount > SAME_TABLE_BATCH_CHUNK_TRIGGER - ? await this.getDirtyRecordIdChunks(db, step.tableId) - : []; - const effectiveChunks = chunkedRecordIds.length > 1 ? chunkedRecordIds : [undefined]; + const batchBuilder = new SameTableBatchQueryBuilder(db, this.typeValidationStrategy); + const chunkedRecordIds = this.hasJsonBackedFormulaTarget( + table, + formulaOnlyFieldLevelsResult.value + ) + ? await this.getDirtyRecordIdChunks( + db, + step.tableId, + JSON_SAME_TABLE_BATCH_CHUNK_SIZE, + true + ) + : dirtyCount > SAME_TABLE_BATCH_CHUNK_TRIGGER + ? await this.getDirtyRecordIdChunks(db, step.tableId) + : []; + const effectiveChunks = chunkedRecordIds.length > 0 ? chunkedRecordIds : [undefined]; - stepSpan?.setAttribute('step.sameTableChunkCount', effectiveChunks.length); - stepSpan?.setAttribute('step.sameTableChunked', effectiveChunks.length > 1); + stepSpan?.setAttribute('step.sameTableChunkCount', effectiveChunks.length); + stepSpan?.setAttribute('step.sameTableChunked', effectiveChunks.length > 1); - const batchQueryPlans: ComputedUpdateQueryPlan[] = []; - for (const recordIds of effectiveChunks) { - const batchResult = yield* batchBuilder.build({ - table, - fieldLevels: formulaOnlyFieldLevelsResult.value, - ...(recordIds ? { recordIds } : {}), - dirtyFilter: { - tableId: step.tableId.toString(), - dirtyTableName: DIRTY_TABLE, - tableIdColumn: DIRTY_TABLE_ID_COL, - recordIdColumn: DIRTY_RECORD_ID_COL, - }, - }); - batchQueryPlans.push({ selectQuery: batchResult.selectQuery, fieldIds }); - } - queryPlans = batchQueryPlans; + const batchQueryPlans: ComputedUpdateQueryPlan[] = []; + for (const recordIds of effectiveChunks) { + const batchResult = yield* batchBuilder.build({ + table, + fieldLevels: formulaOnlyFieldLevelsResult.value, + ...(recordIds ? { recordIds } : {}), + dirtyFilter: { + tableId: step.tableId.toString(), + dirtyTableName: DIRTY_TABLE, + tableIdColumn: DIRTY_TABLE_ID_COL, + recordIdColumn: DIRTY_RECORD_ID_COL, + }, + }); + batchQueryPlans.push({ selectQuery: batchResult.selectQuery, fieldIds }); } + queryPlans = batchQueryPlans; } const fieldChunks = shouldChunkFields @@ -1603,7 +1974,7 @@ export class ComputedFieldUpdater { if (bytes > limits.computed.maxComputedCellValueBytes) { return err( domainError.validation({ - code: 'validation.limit.computed_cell_value_max_bytes', + code: tableDataSafetyLimitErrors.computedCellValueMaxBytes.code, message: 'Table data safety limit exceeded: validation.limit.computed_cell_value_max_bytes', details: { @@ -1613,6 +1984,10 @@ export class ComputedFieldUpdater { attempted: bytes, max: limits.computed.maxComputedCellValueBytes, }, + localization: { + i18nKey: tableDataSafetyLimitErrors.computedCellValueMaxBytes.i18nKey, + context: { max: limits.computed.maxComputedCellValueBytes }, + }, }) ); } @@ -1678,55 +2053,36 @@ export class ComputedFieldUpdater { } } - private async hasJsonTargetColumns( - db: Kysely, - tableName: string, + private hasJsonBackedFormulaTarget( table: Table, - fieldIds: ReadonlyArray - ): Promise { - try { - const tableNameParts = tableName.split('.'); - if (tableNameParts.length !== 2) return false; - const [schemaName, physicalTableName] = tableNameParts; - - const columnNames: string[] = []; - for (const fieldId of fieldIds) { - const fieldResult = table.getField((f) => f.id().equals(fieldId)); - if (fieldResult.isErr()) continue; - const dbFieldNameResult = fieldResult.value.dbFieldName().andThen((n) => n.value()); - if (dbFieldNameResult.isErr()) continue; - columnNames.push(dbFieldNameResult.value); - } - - if (columnNames.length === 0) return false; - - const columns = await db - .selectFrom('information_schema.columns') - .select(['column_name', 'data_type', 'udt_name']) - .where('table_schema', '=', schemaName) - .where('table_name', '=', physicalTableName) - .where('column_name', 'in', columnNames) - .execute(); - - return columns.some((column) => { - const dataType = String(column.data_type).toLowerCase(); - const udtName = String(column.udt_name).toLowerCase(); - return ( - dataType === 'json' || dataType === 'jsonb' || udtName === 'json' || udtName === 'jsonb' - ); - }); - } catch { - return false; - } + fieldLevels: ReadonlyArray + ): boolean { + return fieldLevels.some((level) => + level.fieldIds.some((fieldId) => { + const fieldResult = table.getField((field) => field.id().equals(fieldId)); + if (fieldResult.isErr()) return false; + return fieldResult.value + .dbFieldType() + .andThen((type) => type.value()) + .map((type) => { + const normalized = type.trim().toUpperCase(); + return normalized === 'JSON' || normalized === 'JSONB'; + }) + .unwrapOr(false); + }) + ); } private async getDirtyRecordIdChunks( db: Kysely, - tableId: TableId + tableId: TableId, + chunkSize = SAME_TABLE_BATCH_CHUNK_SIZE, + includeSingleton = false ): Promise>> { const recordIds = await this.getDirtyRecordIdsForTable(db, tableId); if (recordIds.length === 0) return []; - return splitIntoChunks(recordIds, SAME_TABLE_BATCH_CHUNK_SIZE); + if (!includeSingleton && recordIds.length <= chunkSize) return [recordIds]; + return splitIntoChunks(recordIds, chunkSize); } private async getDirtyRecordIdsForTable( @@ -1869,7 +2225,17 @@ export class ComputedFieldUpdater { */ async collectDirtySeedGroups( context: IExecutionContext, - tableIds: ReadonlyArray + tableIds: ReadonlyArray, + options?: { + /** + * 'auto' (default) switches a table to the seed-all form above seedAllThreshold; + * 'exact-ids' always returns record ids — required for partial-batch exclusion + * sets, where a seed-all form would wrongly exclude unprocessed rows. + */ + representation?: 'auto' | 'exact-ids'; + /** Row count above which 'auto' returns a table as seed-all instead of ids. */ + seedAllThreshold?: number; + } ): Promise> { const uniqueTableIds = [...new Set(tableIds.map((id) => id.toString()))]; if (uniqueTableIds.length === 0) return ok({ groups: [], seedAllTableIds: [] }); @@ -1892,7 +2258,10 @@ export class ComputedFieldUpdater { .groupBy(DIRTY_TABLE_ID_COL) .execute(); - const SEED_ALL_THRESHOLD = 5000; + const SEED_ALL_THRESHOLD = + options?.representation === 'exact-ids' + ? Number.POSITIVE_INFINITY + : options?.seedAllThreshold ?? DEFAULT_SEED_ALL_THRESHOLD; const seedAllTableIds: TableId[] = []; const normalTableIds: string[] = []; @@ -1954,6 +2323,147 @@ export class ComputedFieldUpdater { ); } } + + /** + * Push explicit seed groups onto the run ledger's frontier queue head (floor + * entry seed migration). Must run in the worker's stage transaction BEFORE + * execute so the batch seeds them as the queue's budget-bounded head. + */ + async pushStageLedgerFrontierSeeds( + context: IExecutionContext, + scopeId: string, + groups: ReadonlyArray<{ tableId: TableId; recordIds: ReadonlyArray }> + ): Promise> { + const db = resolvePostgresDbOrTx(this.db, context) as unknown as Kysely; + return pushStageLedgerFrontierHead( + db, + scopeId, + groups.map((group) => ({ + tableId: group.tableId.toString(), + recordIds: group.recordIds.map((recordId) => recordId.toString()), + })) + ); + } + + /** + * Settle a partial batch entirely SQL-side, in the stage transaction, while + * the batch's dirty temp table is still alive: + * 1. retire the consumed frontier head when its propagation completed; + * 2. (self-referential stages) append rows NEW this batch to the queue tail; + * 3. add the batch's processed step-table rows to the exclusion ledger. + * Returns per-table processed counts for the continuation's dirty stats. + */ + async settleStageLedgerPartialBatch( + context: IExecutionContext, + params: { + scopeId: string; + stepTableIds: ReadonlyArray; + appendFrontier: boolean; + /** Highest consumed frontier seq; null when propagation truncated (no retire). */ + retireFrontierUpToSeq: string | null; + /** Ledger lifecycle: 'carry-sources' while deferred edge chunks remain. */ + settlementMode: ComputedStageLedgerSettlementMode; + } + ): Promise< + Result< + { + processedByTable: Array<{ tableId: string; recordCount: number }>; + newFrontierRows: number; + retiredFrontierRows: number; + }, + DomainError + > + > { + const db = resolvePostgresDbOrTx(this.db, context) as unknown as Kysely; + let retiredFrontierRows = 0; + if (params.retireFrontierUpToSeq !== null) { + const retired = await retireStageLedgerFrontierHead( + db, + params.scopeId, + params.retireFrontierUpToSeq, + { preserveAsConsumed: params.settlementMode === 'carry-sources' } + ); + if (retired.isErr()) return err(retired.error); + retiredFrontierRows = retired.value; + } + const appended = await appendStageLedgerPartialBatch( + db, + params.scopeId, + params.stepTableIds.map((tableId) => tableId.toString()), + { appendFrontier: params.appendFrontier } + ); + if (appended.isErr()) return err(appended.error); + return ok({ + processedByTable: appended.value.processedByTable, + newFrontierRows: appended.value.newFrontierRows, + retiredFrontierRows, + }); + } + + /** + * Collect a completed stage's dirty outputs (batch dirty rows ∪ the scope's + * exclusion ledger) as next-stage seed groups, entirely SQL-side: no union is + * materialized anywhere, per-table counts pick the representation, and the + * total exact ids fetched into JS are hard-capped (overflow tables convert to + * whole-table seeds). + */ + async collectStageOutputSeedGroups( + context: IExecutionContext, + params: { + scopeId: string; + tableIds: ReadonlyArray; + seedAllThreshold?: number; + exactIdsTotalCap: number; + /** Ledger lifecycle: 'carry-sources' collects preserved consumed sources. */ + settlementMode: ComputedStageLedgerSettlementMode; + } + ): Promise> { + const db = resolvePostgresDbOrTx(this.db, context) as unknown as Kysely; + const collected = await collectStageOutputSeedGroups( + db, + params.scopeId, + [...new Set(params.tableIds.map((tableId) => tableId.toString()))], + { + seedAllThreshold: params.seedAllThreshold ?? DEFAULT_SEED_ALL_THRESHOLD, + exactIdsTotalCap: params.exactIdsTotalCap, + includeConsumedSources: params.settlementMode === 'carry-sources', + } + ); + if (collected.isErr()) return err(collected.error); + const tableIdByKey = new Map(params.tableIds.map((tableId) => [tableId.toString(), tableId])); + const toTableId = (key: string): Result => { + const existing = tableIdByKey.get(key); + return existing ? ok(existing) : TableId.create(key); + }; + const groups: ComputedSeedGroup[] = []; + for (const group of collected.value.groups) { + const tableId = toTableId(group.tableId); + if (tableId.isErr()) return err(tableId.error); + const recordIds: RecordId[] = []; + for (const rawRecordId of group.recordIds) { + const recordId = RecordId.create(rawRecordId); + if (recordId.isErr()) return err(recordId.error); + recordIds.push(recordId.value); + } + groups.push({ tableId: tableId.value, recordIds }); + } + const seedAllTableIds: TableId[] = []; + for (const key of collected.value.seedAllTableIds) { + const tableId = toTableId(key); + if (tableId.isErr()) return err(tableId.error); + seedAllTableIds.push(tableId.value); + } + return ok({ groups, seedAllTableIds }); + } + + /** Drop all stage-ledger state (stage completion or chain dead-letter). */ + async clearTaskStageLedger( + context: IExecutionContext, + scopeId: string + ): Promise> { + const db = resolvePostgresDbOrTx(this.db, context) as unknown as Kysely; + return clearStageLedger(db, scopeId); + } } const splitIntoChunks = (values: ReadonlyArray, chunkSize: number): T[][] => { @@ -2061,6 +2571,84 @@ const seedExtraDirtyRecords = async ( return ok(undefined); }; +/** + * Budget-bounded variant of full-table seeding: materializes at most `limit` rows + * per statement, skipping rows already dirty (earlier tables / re-seeded exclusions) + * and, when active, targets processed by earlier partial batches. Returns the + * candidate count so callers can detect truncation exactly like the propagation + * inserts do. + */ +const seedAllDirtyRecordsForTableBounded = async ( + db: Kysely, + table: Table, + limit: number, + exclusionScopeId: string | undefined, + cursor?: string +): Promise> => { + const tableNameResult = table.dbTableName().andThen((dbTableName) => dbTableName.value()); + if (tableNameResult.isErr()) return err(tableNameResult.error); + + try { + let src = db + .selectFrom(`${tableNameResult.value} as t` as keyof DynamicDB) + .select([ + sql.lit(table.id().toString()).as(DIRTY_TABLE_ID_COL), + sql.ref('t.__id').as(DIRTY_RECORD_ID_COL), + sql.lit(0).as(DIRTY_GENERATION_COL), + ]) + .where( + sql`not exists ( + select 1 from ${sql.table(DIRTY_TABLE)} as existing_dirty + where existing_dirty.${sql.raw(DIRTY_TABLE_ID_COL)} = ${sql.lit(table.id().toString())} + and existing_dirty.${sql.raw(DIRTY_RECORD_ID_COL)} = t.__id + )` + ); + if (exclusionScopeId !== undefined) { + src = src.where( + sql`not exists ( + select 1 from ${sql.table(STAGE_LEDGER_TABLE)} as processed_target + where processed_target.scope_id = ${exclusionScopeId} + and processed_target.kind = 'excluded' + and processed_target.table_id = ${sql.lit(table.id().toString())} + and processed_target.record_id = t.__id + )` + ); + } + // Cursor resume: a PK-ordered index range scan gives O(1) durable state per + // table instead of a per-row exclusion ledger, and bounds the scan itself. + if (cursor !== undefined) { + src = src.where(sql`t.__id > ${sql.lit(cursor)}`); + } + src = src.orderBy(sql.ref('t.__id')).limit(limit); + + const columnList = sql.raw( + `${DIRTY_TABLE_ID_COL}, ${DIRTY_RECORD_ID_COL}, ${DIRTY_GENERATION_COL}` + ); + const compiled = sql<{ cnt: number; last_id: string | null }>` + with src as materialized (${src}), + ins as ( + insert into ${sql.table(DIRTY_TABLE)} (${columnList}) + select ${columnList} from src + on conflict (${sql.raw(`${DIRTY_TABLE_ID_COL}, ${DIRTY_RECORD_ID_COL}`)}) do nothing + ) + select count(*)::int as cnt, max(${sql.raw(DIRTY_RECORD_ID_COL)}) as last_id from src + `.compile(db); + + const result = await db.executeQuery(compiled); + const row = result.rows[0] as { cnt?: number; last_id?: string | null } | undefined; + return ok({ + count: Number(row?.cnt ?? 0), + lastRecordId: row?.last_id ?? undefined, + }); + } catch (error) { + return err( + domainError.infrastructure({ + message: `Failed to seed bounded all dirty records: ${describeError(error)}`, + }) + ); + } +}; + const seedAllDirtyRecordsForTable = async ( db: Kysely, table: Table @@ -2229,11 +2817,90 @@ const toAffectedRowCount = (value: unknown): number | undefined => { return undefined; }; +const countDirtyRecords = async (db: Kysely): Promise => { + const result = await db + .selectFrom(DIRTY_TABLE) + .select(sql`count(*)`.as('cnt')) + .executeTakeFirst(); + return Number(result?.cnt ?? 0); +}; + +type PropagateDirtyOptions = { + maxDirtyRecords?: number; + /** + * 'abort': stop at the budget and report exceeded (dirty state is unusable). + * 'partial': stop at the budget but keep the materialized batch usable; the + * budget then counts only propagated rows so seed-heavy tasks still progress. + */ + dirtyBudgetMode?: 'abort' | 'partial'; + /** Ledger scope whose 'excluded' rows must be anti-joined out of targets. */ + exclusionScopeId?: string; +}; + +/** + * Budgeted single-edge propagation insert. The CTE materializes at most `limit` + * candidate rows (anti-joined against the dirty table, so every counted row is + * genuinely new), inserts them, and returns the candidate count — no DISTINCT + * over a giant UNION ALL, so executor memory stays bounded by `limit` plus the + * edge's own join work instead of the whole fan-out. + */ +const runBudgetedPropagationInsert = async ( + db: Kysely, + query: DirtySelectQuery, + generation: number, + limit: number, + exclusionScopeId: string | undefined +): Promise => { + let src = db + .selectFrom(query.as('propagated')) + .select([ + sql.ref(`propagated.${DIRTY_TABLE_ID_COL}`).as(DIRTY_TABLE_ID_COL), + sql.ref(`propagated.${DIRTY_RECORD_ID_COL}`).as(DIRTY_RECORD_ID_COL), + sql.lit(generation + 1).as(DIRTY_GENERATION_COL), + ]) + .where( + sql`not exists ( + select 1 from ${sql.table(DIRTY_TABLE)} as existing_dirty + where existing_dirty.${sql.raw(DIRTY_TABLE_ID_COL)} = propagated.${sql.raw(DIRTY_TABLE_ID_COL)} + and existing_dirty.${sql.raw(DIRTY_RECORD_ID_COL)} = propagated.${sql.raw(DIRTY_RECORD_ID_COL)} + )` + ); + if (exclusionScopeId !== undefined) { + src = src.where( + sql`not exists ( + select 1 from ${sql.table(STAGE_LEDGER_TABLE)} as processed_target + where processed_target.scope_id = ${exclusionScopeId} + and processed_target.kind = 'excluded' + and processed_target.table_id = propagated.${sql.raw(DIRTY_TABLE_ID_COL)} + and processed_target.record_id = propagated.${sql.raw(DIRTY_RECORD_ID_COL)} + )` + ); + } + src = src.limit(limit); + + const columnList = sql.raw( + `${DIRTY_TABLE_ID_COL}, ${DIRTY_RECORD_ID_COL}, ${DIRTY_GENERATION_COL}` + ); + const compiled = sql<{ cnt: number }>` + with src as materialized (${src}), + ins as ( + insert into ${sql.table(DIRTY_TABLE)} (${columnList}) + select ${columnList} from src + on conflict (${sql.raw(`${DIRTY_TABLE_ID_COL}, ${DIRTY_RECORD_ID_COL}`)}) do nothing + ) + select count(*)::int as cnt from src + `.compile(db); + + const result = await db.executeQuery(compiled); + return Number((result.rows[0] as { cnt?: number } | undefined)?.cnt ?? 0); +}; + const propagateDirtyRecords = async ( db: Kysely, edges: ReadonlyArray, tableById: Map, - context?: IExecutionContext + context?: IExecutionContext, + options?: PropagateDirtyOptions ): Promise> => { try { // Build trace info for all edges once @@ -2244,6 +2911,17 @@ const propagateDirtyRecords = async ( ); const runtimeAllTargetFallbackReasonCounts: AllTargetReasonCounts = {}; + const maxDirtyRecords = + options?.maxDirtyRecords !== undefined && options.maxDirtyRecords > 0 + ? Math.trunc(options.maxDirtyRecords) + : undefined; + const partialMode = maxDirtyRecords !== undefined && options?.dirtyBudgetMode === 'partial'; + // Abort mode budgets the full dirty set (seeds included). Partial mode budgets + // only rows this batch propagates, so seed-heavy tasks still make progress. + let dirtyRecordTotal = + maxDirtyRecords !== undefined && !partialMode ? await countDirtyRecords(db) : 0; + let dirtyBudget: DirtyPropagationStats['dirtyBudget']; + let maxFrontierGenerations = 1; for ( @@ -2259,7 +2937,9 @@ const propagateDirtyRecords = async ( db, traceInfo.edge, tableById, - frontierGeneration + frontierGeneration, + // Budget mode leaves dedup to ON CONFLICT so LIMIT can stop scans early. + maxDirtyRecords === undefined ); if (selectResult.isErr()) { return err(selectResult.error); @@ -2294,6 +2974,50 @@ const propagateDirtyRecords = async ( break; } + if (maxDirtyRecords !== undefined) { + // Budgeted path: one bounded statement per edge instead of a single giant + // UNION ALL — smaller executor footprint and an abort point between edges. + let generationInserted = 0; + for (const preparedSelect of selectQueries) { + const remaining = maxDirtyRecords - dirtyRecordTotal; + if (remaining <= 0) { + dirtyBudget = partialMode + ? { + status: 'partial', + propagatedDirtyRecords: dirtyRecordTotal, + truncated: 'propagation', + } + : { status: 'exceeded', dirtyRecordsAtAbort: dirtyRecordTotal }; + break; + } + // Abort mode probes one row past the budget to distinguish "exactly fits" + // from "there was more"; partial mode caps at the budget exactly. + const limit = partialMode ? remaining : remaining + 1; + const srcCount = await runBudgetedPropagationInsert( + db, + preparedSelect.query, + frontierGeneration, + limit, + options?.exclusionScopeId + ); + generationInserted += srcCount; + dirtyRecordTotal += srcCount; + if (srcCount >= limit) { + dirtyBudget = partialMode + ? { + status: 'partial', + propagatedDirtyRecords: dirtyRecordTotal, + truncated: 'propagation', + } + : { status: 'exceeded', dirtyRecordsAtAbort: dirtyRecordTotal }; + break; + } + } + if (dirtyBudget) break; + if (generationInserted === 0) break; + continue; + } + // Create a single span for the batched propagation const batchSpan = context?.tracer?.startSpan( 'teable.ComputedFieldUpdater.propagateDirtyBatch', @@ -2386,6 +3110,7 @@ const propagateDirtyRecords = async ( return ok({ plannedAllTargetReasonCounts, runtimeAllTargetFallbackReasonCounts, + ...(dirtyBudget ? { dirtyBudget } : {}), }); } catch (error) { return err( @@ -2405,6 +3130,11 @@ type DirtySelectParams = { sourceTableId: string; targetTableId: string; dirtyGeneration: number; + /** + * DISTINCT is a blocking executor node: under a dirty budget the per-edge LIMIT + * must be able to stop the scan early, so dedup is left to ON CONFLICT instead. + */ + distinct: boolean; }; const buildDirtySelectQuery = ( @@ -2420,6 +3150,7 @@ const buildDirtySelectQuery = ( sourceTableId, targetTableId, dirtyGeneration, + distinct, } = params; if ( @@ -2442,10 +3173,9 @@ const buildDirtySelectQuery = ( .select([ sql.lit(targetTableId).as(DIRTY_TABLE_ID_COL), sql.ref('t.__id').as(DIRTY_RECORD_ID_COL), - ]) - .distinct(); + ]); - return ok(select as unknown as DirtySelectQuery); + return ok((distinct ? select.distinct() : select) as unknown as DirtySelectQuery); } // Symmetric case: FK is on source table (fkHostTable = sourceTable) @@ -2461,10 +3191,9 @@ const buildDirtySelectQuery = ( .select([ sql.lit(targetTableId).as(DIRTY_TABLE_ID_COL), sql.ref(`s.${selfKey}`).as(DIRTY_RECORD_ID_COL), - ]) - .distinct(); + ]); - return ok(select as unknown as DirtySelectQuery); + return ok((distinct ? select.distinct() : select) as unknown as DirtySelectQuery); } if (relationship.equals(LinkRelationship.oneMany())) { @@ -2480,10 +3209,9 @@ const buildDirtySelectQuery = ( .select([ sql.lit(targetTableId).as(DIRTY_TABLE_ID_COL), sql.ref(`j.${selfKey}`).as(DIRTY_RECORD_ID_COL), - ]) - .distinct(); + ]); - return ok(select as unknown as DirtySelectQuery); + return ok((distinct ? select.distinct() : select) as unknown as DirtySelectQuery); } const selfKey = yield* linkField.selfKeyNameString(); @@ -2496,10 +3224,9 @@ const buildDirtySelectQuery = ( .select([ sql.lit(targetTableId).as(DIRTY_TABLE_ID_COL), sql.ref(`f.${selfKey}`).as(DIRTY_RECORD_ID_COL), - ]) - .distinct(); + ]); - return ok(select as unknown as DirtySelectQuery); + return ok((distinct ? select.distinct() : select) as unknown as DirtySelectQuery); } const fkHostTableName = yield* linkField.fkHostTableNameString(); @@ -2513,10 +3240,9 @@ const buildDirtySelectQuery = ( .select([ sql.lit(targetTableId).as(DIRTY_TABLE_ID_COL), sql.ref(`j.${selfKey}`).as(DIRTY_RECORD_ID_COL), - ]) - .distinct(); + ]); - return ok(select as unknown as DirtySelectQuery); + return ok((distinct ? select.distinct() : select) as unknown as DirtySelectQuery); }); }; @@ -2524,7 +3250,8 @@ const buildGatedAllTargetSelect = ( db: Kysely, edge: Pick, targetDbName: string, - dirtyGeneration: number + dirtyGeneration: number, + distinct: boolean ): DirtySelectQuery => { const dirtyGate = db .selectFrom(`${DIRTY_TABLE} as d`) @@ -2534,14 +3261,14 @@ const buildGatedAllTargetSelect = ( .limit(1) .as('dg'); - return db + const select = db .selectFrom(`${targetDbName} as t`) .innerJoin(dirtyGate, (join) => join.onTrue()) .select([ sql.lit(edge.toTableId.toString()).as(DIRTY_TABLE_ID_COL), sql.ref('t.__id').as(DIRTY_RECORD_ID_COL), - ]) - .distinct() as unknown as DirtySelectQuery; + ]); + return (distinct ? select.distinct() : select) as unknown as DirtySelectQuery; }; /** @@ -2552,7 +3279,8 @@ const buildPropagationSelect = ( db: Kysely, edge: ComputedDependencyEdge, tableById: Map, - dirtyGeneration: number + dirtyGeneration: number, + distinct: boolean ): Result => { return safeTry(function* () { const targetTable = tableById.get(edge.toTableId.toString()); @@ -2566,7 +3294,7 @@ const buildPropagationSelect = ( if (edge.propagationMode === 'allTargetRecords') { const targetDbName = yield* targetTable.dbTableName().andThen((name) => name.value()); - const select = buildGatedAllTargetSelect(db, edge, targetDbName, dirtyGeneration); + const select = buildGatedAllTargetSelect(db, edge, targetDbName, dirtyGeneration, distinct); return ok({ query: select as unknown as DirtySelectQuery }); } @@ -2590,7 +3318,7 @@ const buildPropagationSelect = ( // Fallback to allTargetRecords if filter is invalid const targetDbName = yield* targetTable.dbTableName().andThen((name) => name.value()); return ok({ - query: buildGatedAllTargetSelect(db, edge, targetDbName, dirtyGeneration), + query: buildGatedAllTargetSelect(db, edge, targetDbName, dirtyGeneration, distinct), runtimeAllTargetFallbackReason: 'conditional_runtime_invalid_filter', }); } @@ -2600,7 +3328,7 @@ const buildPropagationSelect = ( // No filter - fallback to allTargetRecords const targetDbName = yield* targetTable.dbTableName().andThen((name) => name.value()); return ok({ - query: buildGatedAllTargetSelect(db, edge, targetDbName, dirtyGeneration), + query: buildGatedAllTargetSelect(db, edge, targetDbName, dirtyGeneration, distinct), runtimeAllTargetFallbackReason: 'conditional_runtime_empty_filter', }); } @@ -2614,7 +3342,7 @@ const buildPropagationSelect = ( // fallback to allTargetRecords so the field can still be recalculated/cleared const targetDbName = yield* targetTable.dbTableName().andThen((name) => name.value()); return ok({ - query: buildGatedAllTargetSelect(db, edge, targetDbName, dirtyGeneration), + query: buildGatedAllTargetSelect(db, edge, targetDbName, dirtyGeneration, distinct), runtimeAllTargetFallbackReason: 'conditional_runtime_invalid_condition_spec', }); } @@ -2623,7 +3351,7 @@ const buildPropagationSelect = ( // No spec generated - fallback to allTargetRecords const targetDbName = yield* targetTable.dbTableName().andThen((name) => name.value()); return ok({ - query: buildGatedAllTargetSelect(db, edge, targetDbName, dirtyGeneration), + query: buildGatedAllTargetSelect(db, edge, targetDbName, dirtyGeneration, distinct), runtimeAllTargetFallbackReason: 'conditional_runtime_missing_condition_spec', }); } @@ -2710,10 +3438,13 @@ const buildPropagationSelect = ( sql.lit(edge.toTableId.toString()).as(DIRTY_TABLE_ID_COL), sql.ref('t.__id').as(DIRTY_RECORD_ID_COL), ]) - .where(matchCondition) - .distinct(); + .where(matchCondition); - return ok({ query: targetDrivenSelect as unknown as DirtySelectQuery }); + return ok({ + query: (distinct + ? targetDrivenSelect.distinct() + : targetDrivenSelect) as unknown as DirtySelectQuery, + }); } if (!edge.linkFieldId) return err(domainError.validation({ message: 'Missing linkFieldId' })); @@ -2767,6 +3498,7 @@ const buildPropagationSelect = ( sourceTableId: edge.fromTableId.toString(), targetTableId: edge.toTableId.toString(), dirtyGeneration, + distinct, }); return ok({ query: selectQuery }); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedStageLedger.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedStageLedger.ts new file mode 100644 index 0000000000..c963ed0654 --- /dev/null +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedStageLedger.ts @@ -0,0 +1,410 @@ +import { domainError, type DomainError } from '@teable/v2-core'; +import { sql, type Kysely } from 'kysely'; +import { err, ok, type Result } from 'neverthrow'; +import type { DynamicDB } from '../query-builder'; + +/** + * Durable per-stage state for budget-staged computed updates, stored in + * `computed_update_stage_ledger` and keyed by a ledger SCOPE — the id of the + * continuation chain's root task. Chains are serial, so each scope has exactly + * one writer at a time, and parallel chunk-split tasks of the same run never + * share state. Rows are written once per record and shared by every + * continuation of the chain instead of being copied between task payloads, so + * both the payload size and the JS heap stay O(1) in the stage's total fan-out: + * - kind 'excluded': targets already computed by earlier partial batches of the + * current run stage. Seeding and propagation anti-join this set directly. + * - kind 'frontier': seq-ordered queue of sources whose outgoing propagation is + * not finished (self-referential generations and migrated explicit seeds). + * Each batch seeds only the queue head; settlement retires exactly the + * consumed head once its propagation completed. + * - kind 'consumed': retired frontier sources preserved while the stage still + * has deferred edge chunks; the completed stage hands them to the deferred + * continuation as seeds so later chunks re-propagate from the same sources. + */ +export const STAGE_LEDGER_TABLE = 'computed_update_stage_ledger'; + +/** + * How a stage settles its ledger lifecycle: + * - 'stage-final': no deferred work follows — retired frontier rows delete, and + * collection covers only the stage's own outputs. + * - 'carry-sources': deferred edge chunks follow — retired frontier rows become + * kind='consumed', and collection includes them so the continuation re-seeds + * the same sources. One value drives BOTH sides; passing them separately is + * how sources get lost. + */ +export type ComputedStageLedgerSettlementMode = 'stage-final' | 'carry-sources'; + +const DIRTY_TABLE = 'pg_temp.tmp_computed_dirty'; + +const infrastructureError = (message: string, error: unknown): DomainError => + domainError.infrastructure({ + message: `${message}: ${error instanceof Error ? error.message : String(error)}`, + }); + +/** True when the scope still has queued frontier sources awaiting propagation. */ +export const stageLedgerHasFrontier = async ( + db: Kysely, + scopeId: string +): Promise> => { + try { + const row = await db + .selectFrom(STAGE_LEDGER_TABLE) + .select(sql`1`.as('one')) + .where('scope_id', '=', scopeId) + .where('kind', '=', 'frontier') + .limit(1) + .executeTakeFirst(); + return ok(row !== undefined); + } catch (error) { + return err(infrastructureError('Failed to probe stage ledger frontier', error)); + } +}; + +/** + * Seed the frontier queue head into the dirty temp table, bounded by `limit`. + * Returns the consumed row count, the highest seq consumed (for retirement) and + * whether unseeded queue rows remain (a seeding truncation for the batch). + */ +export const seedStageLedgerFrontierHead = async ( + db: Kysely, + scopeId: string, + limit: number | undefined +): Promise< + Result<{ consumed: number; maxSeq: string | null; remainder: boolean }, DomainError> +> => { + try { + const boundedLimit = limit === undefined ? undefined : Math.max(1, Math.trunc(limit)); + let head = db + .selectFrom(STAGE_LEDGER_TABLE) + .select(['table_id', 'record_id', 'seq']) + .where('scope_id', '=', scopeId) + .where('kind', '=', 'frontier') + .orderBy('seq', 'asc') + .orderBy('table_id', 'asc') + .orderBy('record_id', 'asc'); + if (boundedLimit !== undefined) { + head = head.limit(boundedLimit); + } + const inserted = await db.executeQuery( + sql<{ consumed: string | number | bigint; max_seq: string | null }>` + with head as materialized (${head}), + ins as ( + insert into ${sql.table(DIRTY_TABLE)} (table_id, record_id) + select table_id, record_id from head + on conflict (table_id, record_id) do nothing + ) + select count(*) as consumed, max(seq)::text as max_seq from head + `.compile(db) + ); + const row = inserted.rows[0]; + const consumed = Number(row?.consumed ?? 0); + const maxSeq = row?.max_seq ?? null; + if (boundedLimit === undefined || consumed < boundedLimit) { + return ok({ consumed, maxSeq, remainder: false }); + } + const rest = await db + .selectFrom(STAGE_LEDGER_TABLE) + .select(sql`1`.as('one')) + .where('scope_id', '=', scopeId) + .where('kind', '=', 'frontier') + .where('seq', '>', maxSeq === null ? 0 : sql`${maxSeq}::bigint`) + .limit(1) + .executeTakeFirst(); + return ok({ consumed, maxSeq, remainder: rest !== undefined }); + } catch (error) { + return err(infrastructureError('Failed to seed stage ledger frontier head', error)); + } +}; + +/** + * Retire the consumed frontier head once its propagation completed untruncated. + * When the stage still has deferred edge chunks that read from these sources + * (preserveAsConsumed), the rows move to kind 'consumed' instead of being + * deleted: the completed stage then hands them to the deferred continuation as + * seeds, so later chunks can re-propagate from the same sources — retiring them + * outright would silently drop every target only the later chunks reach. + */ +export const retireStageLedgerFrontierHead = async ( + db: Kysely, + scopeId: string, + maxSeqConsumed: string, + options?: { preserveAsConsumed?: boolean } +): Promise> => { + try { + if (options?.preserveAsConsumed) { + await db.executeQuery( + sql` + insert into ${sql.table(STAGE_LEDGER_TABLE)} (scope_id, kind, table_id, record_id, seq) + select scope_id, 'consumed', table_id, record_id, seq + from ${sql.table(STAGE_LEDGER_TABLE)} + where scope_id = ${scopeId} and kind = 'frontier' + and seq <= ${maxSeqConsumed}::bigint + on conflict (scope_id, kind, table_id, record_id) do nothing + `.compile(db) + ); + } + const result = await db + .deleteFrom(STAGE_LEDGER_TABLE) + .where('scope_id', '=', scopeId) + .where('kind', '=', 'frontier') + .where('seq', '<=', sql`${maxSeqConsumed}::bigint`) + .executeTakeFirst(); + return ok(Number(result.numDeletedRows ?? 0)); + } catch (error) { + return err(infrastructureError('Failed to retire stage ledger frontier head', error)); + } +}; + +/** + * Push explicit seed groups onto the frontier queue HEAD (floor-entry seed + * migration): they seed before older queue rows, preserving the pre-migration + * ordering where explicit seeds were the batch's first sources. + */ +export const pushStageLedgerFrontierHead = async ( + db: Kysely, + scopeId: string, + groups: ReadonlyArray<{ tableId: string; recordIds: ReadonlyArray }> +): Promise> => { + const rows = groups.flatMap((group) => + group.recordIds.map((recordId) => ({ tableId: group.tableId, recordId })) + ); + if (rows.length === 0) return ok(0); + try { + // Fix the seq base once up front: batched inserts below must not shift it. + const minRow = await db + .selectFrom(STAGE_LEDGER_TABLE) + .select(sql`min(seq)::text`.as('min_seq')) + .where('scope_id', '=', scopeId) + .where('kind', '=', 'frontier') + .executeTakeFirst(); + const base = BigInt(minRow?.min_seq ?? '0') - BigInt(rows.length); + let pushed = 0; + const batchSize = 500; + for (let i = 0; i < rows.length; i += batchSize) { + const batch = rows.slice(i, i + batchSize); + const values = sql.join( + batch.map( + (row, index) => + sql`(${scopeId}, 'frontier', ${row.tableId}, ${row.recordId}, ${(base + BigInt(i + index)).toString()}::bigint)` + ) + ); + const result = await db.executeQuery( + sql` + insert into ${sql.table(STAGE_LEDGER_TABLE)} (scope_id, kind, table_id, record_id, seq) + values ${values} + on conflict (scope_id, kind, table_id, record_id) do nothing + `.compile(db) + ); + pushed += Number(result.numAffectedRows ?? 0); + } + return ok(pushed); + } catch (error) { + return err(infrastructureError('Failed to push stage ledger frontier head', error)); + } +}; + +/** + * Fold a partial batch's outputs into the stage ledger, entirely SQL-side: + * 1. (self-referential stages) append rows NEW this batch — dirty rows of the + * stage's step tables not yet excluded — to the frontier queue tail; + * 2. add every dirty row of the stage's step tables to the exclusion ledger. + * Order matters: the frontier append's anti-join against 'excluded' must see the + * PRE-batch exclusion state, so it runs first. + * Returns per-table processed counts for the continuation task's dirty stats. + */ +export const appendStageLedgerPartialBatch = async ( + db: Kysely, + scopeId: string, + stepTableIds: ReadonlyArray, + options: { appendFrontier: boolean } +): Promise< + Result< + { + processedByTable: Array<{ tableId: string; recordCount: number }>; + newFrontierRows: number; + newExcludedRows: number; + }, + DomainError + > +> => { + if (stepTableIds.length === 0) { + return ok({ processedByTable: [], newFrontierRows: 0, newExcludedRows: 0 }); + } + try { + const tableFilter = sql.join(stepTableIds.map((tableId) => sql`${tableId}`)); + let newFrontierRows = 0; + if (options.appendFrontier) { + const frontier = await db.executeQuery( + sql` + insert into ${sql.table(STAGE_LEDGER_TABLE)} (scope_id, kind, table_id, record_id, seq) + select ${scopeId}, 'frontier', d.table_id, d.record_id, + coalesce(( + select max(seq) from ${sql.table(STAGE_LEDGER_TABLE)} + where scope_id = ${scopeId} and kind = 'frontier' + ), 0) + row_number() over (order by d.table_id, d.record_id) + from ${sql.table(DIRTY_TABLE)} as d + where d.table_id in (${tableFilter}) + and not exists ( + select 1 from ${sql.table(STAGE_LEDGER_TABLE)} as l + where l.scope_id = ${scopeId} and l.kind = 'excluded' + and l.table_id = d.table_id and l.record_id = d.record_id + ) + on conflict (scope_id, kind, table_id, record_id) do nothing + `.compile(db) + ); + newFrontierRows = Number(frontier.numAffectedRows ?? 0); + } + const excluded = await db.executeQuery( + sql` + insert into ${sql.table(STAGE_LEDGER_TABLE)} (scope_id, kind, table_id, record_id) + select ${scopeId}, 'excluded', d.table_id, d.record_id + from ${sql.table(DIRTY_TABLE)} as d + where d.table_id in (${tableFilter}) + on conflict (scope_id, kind, table_id, record_id) do nothing + `.compile(db) + ); + const processed = await db.executeQuery( + sql<{ table_id: string; cnt: string | number | bigint }>` + select table_id, count(*) as cnt from ${sql.table(DIRTY_TABLE)} + where table_id in (${tableFilter}) + group by table_id + `.compile(db) + ); + return ok({ + processedByTable: processed.rows.map((row) => ({ + tableId: String(row.table_id), + recordCount: Number(row.cnt), + })), + newFrontierRows, + newExcludedRows: Number(excluded.numAffectedRows ?? 0), + }); + } catch (error) { + return err(infrastructureError('Failed to append stage ledger partial batch', error)); + } +}; + +/** + * Collect a COMPLETED stage's dirty outputs — the union of the batch's dirty + * temp table and the scope's exclusion ledger (rows processed by earlier + * partial batches) — as next-stage seed groups, without ever materializing the + * union anywhere: counting and id retrieval run directly over the two sources. + * Representation per table: + * - count >= seedAllThreshold: whole-table seed (no ids fetched); + * - otherwise exact ids, BUT the total ids fetched across all tables is capped + * by exactIdsTotalCap — tables are converted to whole-table seeds (largest + * dirty count first, where whole-table amplification is relatively smallest) + * until the remainder fits, so JS memory and payload stay hard-bounded no + * matter how many tables sit just under the threshold. + */ +export const collectStageOutputSeedGroups = async ( + db: Kysely, + scopeId: string, + tableIds: ReadonlyArray, + options: { + seedAllThreshold: number; + exactIdsTotalCap: number; + /** + * Include kind='consumed' rows (frontier sources whose propagation for THIS + * stage's edges completed, preserved for deferred edge chunks). Set only + * when the stage defers edges that must re-propagate from those sources. + */ + includeConsumedSources?: boolean; + } +): Promise< + Result< + { groups: Array<{ tableId: string; recordIds: string[] }>; seedAllTableIds: string[] }, + DomainError + > +> => { + if (tableIds.length === 0) return ok({ groups: [], seedAllTableIds: [] }); + try { + // Provably-disjoint branches instead of a deduplicating UNION: the ledger + // branch anti-joins the dirty table, so no hash/sort dedup over the full + // fan-out ever runs; each branch is a bounded scan / index probe. The + // per-table predicate is pushed into BOTH branches on id retrieval. + const consumedBranch = (tableFilter: ReturnType) => + options.includeConsumedSources + ? sql` + union all + select c.table_id, c.record_id from ${sql.table(STAGE_LEDGER_TABLE)} as c + where c.scope_id = ${scopeId} and c.kind = 'consumed' and c.table_id in (${tableFilter}) + and not exists ( + select 1 from ${sql.table(DIRTY_TABLE)} as d + where d.table_id = c.table_id and d.record_id = c.record_id + ) + and not exists ( + select 1 from ${sql.table(STAGE_LEDGER_TABLE)} as x + where x.scope_id = ${scopeId} and x.kind = 'excluded' + and x.table_id = c.table_id and x.record_id = c.record_id + ) + ` + : sql``; + const disjointSource = (tableFilter: ReturnType) => sql` + select table_id, record_id from ${sql.table(DIRTY_TABLE)} + where table_id in (${tableFilter}) + union all + select l.table_id, l.record_id from ${sql.table(STAGE_LEDGER_TABLE)} as l + where l.scope_id = ${scopeId} and l.kind = 'excluded' and l.table_id in (${tableFilter}) + and not exists ( + select 1 from ${sql.table(DIRTY_TABLE)} as d + where d.table_id = l.table_id and d.record_id = l.record_id + ) + ${consumedBranch(tableFilter)} + `; + const allTablesFilter = sql.join(tableIds.map((tableId) => sql`${tableId}`)); + const counts = await db.executeQuery( + sql<{ table_id: string; cnt: string | number | bigint }>` + select table_id, count(*) as cnt from (${disjointSource(allTablesFilter)}) as stage_output + group by table_id + `.compile(db) + ); + const tables = counts.rows + .map((row) => ({ tableId: String(row.table_id), count: Number(row.cnt) })) + .filter((table) => table.count > 0); + + const seedAllTableIds = tables + .filter((table) => table.count >= options.seedAllThreshold) + .map((table) => table.tableId); + // Ascending count keeps the most tables in exact-id form under the cap; + // the largest under-threshold tables convert to whole-table seeds first. + const exactCandidates = tables + .filter((table) => table.count < options.seedAllThreshold) + .sort((left, right) => left.count - right.count); + const groups: Array<{ tableId: string; recordIds: string[] }> = []; + let fetchedTotal = 0; + for (const table of exactCandidates) { + if (fetchedTotal + table.count > options.exactIdsTotalCap) { + seedAllTableIds.push(table.tableId); + continue; + } + const ids = await db.executeQuery( + sql<{ record_id: string }>` + select record_id from (${disjointSource(sql.join([sql`${table.tableId}`]))}) as stage_output + `.compile(db) + ); + const recordIds = ids.rows.map((row) => String(row.record_id)); + if (recordIds.length === 0) continue; + fetchedTotal += recordIds.length; + groups.push({ tableId: table.tableId, recordIds }); + } + return ok({ groups, seedAllTableIds }); + } catch (error) { + return err(infrastructureError('Failed to collect stage output seed groups', error)); + } +}; + +/** Drop all ledger state for a scope (stage completion or chain dead-letter). */ +export const clearStageLedger = async ( + db: Kysely, + scopeId: string +): Promise> => { + try { + const result = await db + .deleteFrom(STAGE_LEDGER_TABLE) + .where('scope_id', '=', scopeId) + .executeTakeFirst(); + return ok(Number(result.numDeletedRows ?? 0)); + } catch (error) { + return err(infrastructureError('Failed to clear stage ledger', error)); + } +}; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedStagePlanSplitter.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedStagePlanSplitter.ts new file mode 100644 index 0000000000..686c598084 --- /dev/null +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedStagePlanSplitter.ts @@ -0,0 +1,553 @@ +import type { RecordId, TableId } from '@teable/v2-core'; + +import type { + ComputedDependencyEdge, + ComputedSeedGroup, + ComputedUpdatePlan, + SameTableBatch, + UpdateStep, +} from './ComputedUpdatePlanner'; + +/** + * Resource budget for a single computed-update stage transaction. + * A value of 0 disables that dimension; staging is off when every dimension is 0. + */ +export type ComputedStageBudget = { + maxSteps: number; + maxFields: number; + maxEdges: number; +}; + +export type ComputedStagePlanSplit = { + /** Bounded plan to execute in the current transaction. */ + stagePlan: ComputedUpdatePlan; + /** + * Remainder of the plan, or null when the whole plan fits the budget. + * Deferred steps keep their original dependency levels so a follow-up task + * executes them in the same topological order. A step whose field list was + * hard-split appears in both halves with disjoint field subsets. + */ + deferred: { + steps: ReadonlyArray; + edges: ReadonlyArray; + sameTableBatches: ReadonlyArray; + } | null; +}; + +const isComputedStageBudgetEnabled = (budget: ComputedStageBudget): boolean => + budget.maxSteps > 0 || budget.maxFields > 0 || budget.maxEdges > 0; + +/** Merge seed groups by table, deduplicating record ids (stable order). */ +export const mergeComputedSeedGroups = ( + base: ReadonlyArray, + incoming: ReadonlyArray +): ComputedSeedGroup[] => { + const byTable = new Map(); + const seenByTable = new Map>(); + for (const group of [...base, ...incoming]) { + const tableKey = group.tableId.toString(); + let entry = byTable.get(tableKey); + let seen = seenByTable.get(tableKey); + if (!entry || !seen) { + entry = { tableId: group.tableId, recordIds: [] }; + seen = new Set(); + byTable.set(tableKey, entry); + seenByTable.set(tableKey, seen); + } + for (const recordId of group.recordIds) { + const recordKey = recordId.toString(); + if (seen.has(recordKey)) continue; + seen.add(recordKey); + entry.recordIds.push(recordId); + } + } + return [...byTable.values()].filter((group) => group.recordIds.length > 0); +}; + +const stepKeyOf = (step: UpdateStep): string => `${step.tableId.toString()}|${step.level}`; + +const countPlanSeedRecords = (plan: ComputedUpdatePlan): number => + plan.seedRecordIds.length + + plan.extraSeedRecords.reduce((sum, group) => sum + group.recordIds.length, 0); + +/** + * Field-granular target keys of a propagation edge, or null when unknown + * (payloads serialized before propagationTargetFieldIds existed). Unknown + * targets force table-granular partitioning for that edge. + */ +const edgeTargetFieldKeys = (edge: ComputedDependencyEdge): string[] | null => { + if (!edge.propagationTargetFieldIds || edge.propagationTargetFieldIds.length === 0) return null; + const keys = new Set(edge.propagationTargetFieldIds.map((fieldId) => fieldId.toString())); + keys.add(edge.toFieldId.toString()); + return [...keys]; +}; + +const collectStepFieldKeys = (steps: ReadonlyArray): Set => { + const keys = new Set(); + for (const step of steps) { + for (const fieldId of step.fieldIds) { + keys.add(fieldId.toString()); + } + } + return keys; +}; + +/** Retained field keys per step key, so field-split steps filter batches correctly. */ +const collectRetainedFieldsByStepKey = ( + steps: ReadonlyArray +): Map> => { + const retained = new Map>(); + for (const step of steps) { + const key = stepKeyOf(step); + const fields = retained.get(key) ?? new Set(); + for (const fieldId of step.fieldIds) { + fields.add(fieldId.toString()); + } + retained.set(key, fields); + } + return retained; +}; + +const splitSameTableBatches = ( + batches: ReadonlyArray, + retainedFieldsByStepKey: ReadonlyMap> +): SameTableBatch[] => { + const result: SameTableBatch[] = []; + for (const batch of batches) { + const steps: UpdateStep[] = []; + for (const step of batch.steps) { + const retained = retainedFieldsByStepKey.get(stepKeyOf(step)); + if (!retained) continue; + const fieldIds = step.fieldIds.filter((fieldId) => retained.has(fieldId.toString())); + if (fieldIds.length === 0) continue; + steps.push(fieldIds.length === step.fieldIds.length ? step : { ...step, fieldIds }); + } + if (steps.length === 0) continue; + result.push({ + tableId: batch.tableId, + steps, + minLevel: Math.min(...steps.map((step) => step.level)), + maxLevel: Math.max(...steps.map((step) => step.level)), + }); + } + return result; +}; + +/** + * Split a dependency plan into a budget-bounded stage plan plus a deferred remainder. + * + * Steps are taken as a level-ordered prefix, so every executed field has all of its + * upstream dependencies committed in the same stage or an earlier one. A first step + * that alone exceeds maxFields is hard-split by fields (same-level fields are + * mutually independent), making maxFields a true per-transaction cap. Propagation + * edges partition by target field where the (deduplicated) target list is known; + * edges from legacy payloads without target info partition by target table. + */ +export const splitComputedPlanForStageBudget = ( + plan: ComputedUpdatePlan, + budget: ComputedStageBudget +): ComputedStagePlanSplit => { + if (!isComputedStageBudgetEnabled(budget)) { + return { stagePlan: plan, deferred: null }; + } + + const orderedSteps = [...plan.steps].sort((a, b) => a.level - b.level); + + const edgesByTargetField = new Map(); + const edgesByTargetTable = new Map(); + plan.edges.forEach((edge, index) => { + const targetKeys = edgeTargetFieldKeys(edge); + if (targetKeys) { + for (const fieldKey of targetKeys) { + const indices = edgesByTargetField.get(fieldKey) ?? []; + indices.push(index); + edgesByTargetField.set(fieldKey, indices); + } + return; + } + const tableKey = edge.toTableId.toString(); + const indices = edgesByTargetTable.get(tableKey) ?? []; + indices.push(index); + edgesByTargetTable.set(tableKey, indices); + }); + + const stageSteps: UpdateStep[] = []; + const stageTableKeys = new Set(); + const stageEdgeIndices = new Set(); + let stageFieldCount = 0; + let deferredLeadStep: UpdateStep | null = null; + let consumedStepCount = 0; + + // Edges into fields/tables hosting no step at all default to the current + // stage (subject to the hard cap below, where their overflow defers as an + // edge-only continuation); count them against the edge budget upfront so + // stageMaxEdges reflects the SQL that will actually execute. + const allStepFieldKeys = collectStepFieldKeys(orderedSteps); + const allStepTableKeys = new Set(orderedSteps.map((step) => step.tableId.toString())); + const edgeTargetsAnyOf = ( + edge: ComputedDependencyEdge, + fieldKeys: ReadonlySet, + tableKeys: ReadonlySet + ): boolean => { + const targetKeys = edgeTargetFieldKeys(edge); + if (targetKeys) return targetKeys.some((key) => fieldKeys.has(key)); + return tableKeys.has(edge.toTableId.toString()); + }; + plan.edges.forEach((edge, index) => { + if (!edgeTargetsAnyOf(edge, allStepFieldKeys, allStepTableKeys)) stageEdgeIndices.add(index); + }); + + const collectNewEdgeIndices = (step: UpdateStep): number[] => { + const indices: number[] = []; + for (const fieldId of step.fieldIds) { + for (const index of edgesByTargetField.get(fieldId.toString()) ?? []) { + if (!stageEdgeIndices.has(index) && !indices.includes(index)) indices.push(index); + } + } + if (!stageTableKeys.has(step.tableId.toString())) { + for (const index of edgesByTargetTable.get(step.tableId.toString()) ?? []) { + if (!stageEdgeIndices.has(index) && !indices.includes(index)) indices.push(index); + } + } + return indices; + }; + + const takeStep = (step: UpdateStep, newEdgeIndices: number[]): void => { + stageSteps.push(step); + stageTableKeys.add(step.tableId.toString()); + stageFieldCount += step.fieldIds.length; + for (const index of newEdgeIndices) stageEdgeIndices.add(index); + }; + + for (const step of orderedSteps) { + const newEdgeIndices = collectNewEdgeIndices(step); + const withinSteps = budget.maxSteps <= 0 || stageSteps.length < budget.maxSteps; + const withinFields = + budget.maxFields <= 0 || stageFieldCount + step.fieldIds.length <= budget.maxFields; + const withinEdges = + budget.maxEdges <= 0 || stageEdgeIndices.size + newEdgeIndices.length <= budget.maxEdges; + + if (withinSteps && withinFields && withinEdges) { + takeStep(step, newEdgeIndices); + consumedStepCount += 1; + continue; + } + + if (stageSteps.length > 0) break; + + // First step over budget: hard-split by fields when the field or edge budget + // is the binding constraint; otherwise run it whole so every stage progresses. + // Greedy in field order, so the taken set is a prefix and the remainder step + // carries the rest at the same level. A single field's edges are irreducible. + if (budget.maxFields > 0 || budget.maxEdges > 0) { + const countedEdges = new Set(stageEdgeIndices); + const tableEdgeIndices = (edgesByTargetTable.get(step.tableId.toString()) ?? []).filter( + (index) => !countedEdges.has(index) + ); + const takenFieldIds: (typeof step.fieldIds)[number][] = []; + for (const fieldId of step.fieldIds) { + const newEdges = (edgesByTargetField.get(fieldId.toString()) ?? []).filter( + (index) => !countedEdges.has(index) + ); + // Table-granular (legacy) edges attach with the first field taken. + const newEdgeTotal = + newEdges.length + (takenFieldIds.length === 0 ? tableEdgeIndices.length : 0); + const withinFieldBudget = budget.maxFields <= 0 || takenFieldIds.length < budget.maxFields; + const withinEdgeBudget = + budget.maxEdges <= 0 || countedEdges.size + newEdgeTotal <= budget.maxEdges; + if (takenFieldIds.length > 0 && !(withinFieldBudget && withinEdgeBudget)) break; + takenFieldIds.push(fieldId); + for (const index of newEdges) countedEdges.add(index); + if (takenFieldIds.length === 1) { + for (const index of tableEdgeIndices) countedEdges.add(index); + } + } + + if (takenFieldIds.length < step.fieldIds.length) { + const partialStep = { ...step, fieldIds: takenFieldIds }; + takeStep(partialStep, collectNewEdgeIndices(partialStep)); + deferredLeadStep = { ...step, fieldIds: step.fieldIds.slice(takenFieldIds.length) }; + consumedStepCount += 1; + break; + } + } + + takeStep(step, newEdgeIndices); + consumedStepCount += 1; + break; + } + + const remainingSteps = orderedSteps.slice(consumedStepCount); + if ( + !deferredLeadStep && + remainingSteps.length === 0 && + // Even a fully-consumed plan must pass the hard edge cap below. + (budget.maxEdges <= 0 || plan.edges.length <= budget.maxEdges) + ) { + return { stagePlan: plan, deferred: null }; + } + + const deferredSteps: UpdateStep[] = deferredLeadStep + ? [deferredLeadStep, ...remainingSteps] + : remainingSteps; + + const stageFieldKeys = collectStepFieldKeys(stageSteps); + const deferredFieldKeys = collectStepFieldKeys(deferredSteps); + const deferredTableKeys = new Set(deferredSteps.map((step) => step.tableId.toString())); + + // Edges into fields/tables with no step at all (e.g. delete plans that + // filtered seed-table steps) default to the stage plan here; the hard cap + // below may still defer their overflow as an edge-only continuation. + const isEdgeInStage = (edge: ComputedDependencyEdge): boolean => + edgeTargetsAnyOf(edge, stageFieldKeys, stageTableKeys) || + !edgeTargetsAnyOf(edge, deferredFieldKeys, deferredTableKeys); + const isEdgeInDeferred = (edge: ComputedDependencyEdge): boolean => + edgeTargetsAnyOf(edge, deferredFieldKeys, deferredTableKeys); + + let stageEdges = plan.edges.filter(isEdgeInStage); + let deferredEdges = plan.edges.filter(isEdgeInDeferred); + let finalStageSteps: UpdateStep[] = stageSteps; + let finalDeferredSteps: UpdateStep[] = deferredSteps; + + // Hard edge cap over ALL stage edges — orphan edges included. Overflow edges + // defer; edge-only continuations (a plan with edges but no steps) make that + // safe even for orphans, since propagation alone is executable work. For an + // overflowed FIELD edge the hosting field must not compute before all of its + // edges have propagated, so the field itself moves to the deferred stage when + // no retained stage field depends on it — the final chunk then computes it + // exactly once over the accumulated dirty targets, instead of every chunk + // recomputing the previous chunks' rows. Only when a retained stage field + // depends on the hosting field (it must compute now) does the deferred stage + // carry a duplicate field step, re-computing overflow targets later. + if (budget.maxEdges > 0 && stageEdges.length > budget.maxEdges) { + const keptEdges = stageEdges.slice(0, budget.maxEdges); + const overflowEdges = stageEdges.slice(budget.maxEdges); + stageEdges = keptEdges; + const alreadyDeferred = new Set(deferredEdges); + deferredEdges = [ + ...deferredEdges, + ...overflowEdges.filter((edge) => !alreadyDeferred.has(edge)), + ]; + + // Hosting fields / tables of the overflow edges (orphans host nothing). + const overflowFieldKeys = new Set(); + const overflowTableKeys = new Set(); + for (const edge of overflowEdges) { + const targetKeys = edgeTargetFieldKeys(edge); + if (targetKeys) { + for (const key of targetKeys) overflowFieldKeys.add(key); + } else { + overflowTableKeys.add(edge.toTableId.toString()); + } + } + + const affectsField = (step: UpdateStep, fieldKey: string): boolean => + overflowFieldKeys.has(fieldKey) || overflowTableKeys.has(step.tableId.toString()); + const moveCandidateKeys = new Set(); + for (const step of stageSteps) { + for (const fieldId of step.fieldIds) { + const key = fieldId.toString(); + if (affectsField(step, key)) moveCandidateKeys.add(key); + } + } + // A candidate may move only if no RETAINED stage field depends on it + // (retained = stage fields minus all candidates, so co-moving chains stay + // movable). Dependents are read off the plan's own edges. + const retainedFieldKeys = new Set(); + const retainedTableKeys = new Set(); + for (const step of stageSteps) { + let stepRetainsFields = false; + for (const fieldId of step.fieldIds) { + const key = fieldId.toString(); + if (moveCandidateKeys.has(key)) continue; + retainedFieldKeys.add(key); + stepRetainsFields = true; + } + if (stepRetainsFields) retainedTableKeys.add(step.tableId.toString()); + } + const isSafeToMove = (fieldKey: string): boolean => + !plan.edges.some((edge) => { + if (edge.fromFieldId.toString() !== fieldKey) return false; + const targetKeys = edgeTargetFieldKeys(edge); + if (targetKeys) return targetKeys.some((key) => retainedFieldKeys.has(key)); + return retainedTableKeys.has(edge.toTableId.toString()); + }); + + const movedFieldKeys = new Set(); + const duplicatedFieldKeys = new Set(); + for (const key of moveCandidateKeys) { + if (isSafeToMove(key)) movedFieldKeys.add(key); + else duplicatedFieldKeys.add(key); + } + + const trimmedStageSteps: UpdateStep[] = []; + for (const step of stageSteps) { + const keptFieldIds = step.fieldIds.filter( + (fieldId) => !movedFieldKeys.has(fieldId.toString()) + ); + if (keptFieldIds.length === 0) continue; + trimmedStageSteps.push( + keptFieldIds.length === step.fieldIds.length ? step : { ...step, fieldIds: keptFieldIds } + ); + } + finalStageSteps = trimmedStageSteps; + + const deferredByStepKey = new Map(finalDeferredSteps.map((step) => [stepKeyOf(step), step])); + const extraDeferredSteps: UpdateStep[] = []; + for (const step of stageSteps) { + const existingDeferred = deferredByStepKey.get(stepKeyOf(step)); + const existingFieldKeys = new Set( + existingDeferred?.fieldIds.map((fieldId) => fieldId.toString()) ?? [] + ); + const neededFieldIds = step.fieldIds.filter((fieldId) => { + const key = fieldId.toString(); + if (existingFieldKeys.has(key)) return false; + return movedFieldKeys.has(key) || duplicatedFieldKeys.has(key); + }); + if (neededFieldIds.length === 0) continue; + if (existingDeferred) { + deferredByStepKey.set(stepKeyOf(step), { + ...existingDeferred, + fieldIds: [...existingDeferred.fieldIds, ...neededFieldIds], + }); + } else { + extraDeferredSteps.push({ ...step, fieldIds: neededFieldIds }); + } + } + finalDeferredSteps = [...extraDeferredSteps, ...deferredByStepKey.values()].sort( + (a, b) => a.level - b.level + ); + } + + if (finalDeferredSteps.length === 0 && deferredEdges.length === 0) { + return { stagePlan: plan, deferred: null }; + } + + const seedRecordCount = countPlanSeedRecords(plan); + + return { + stagePlan: { + ...plan, + steps: finalStageSteps, + edges: stageEdges, + sameTableBatches: splitSameTableBatches( + plan.sameTableBatches, + collectRetainedFieldsByStepKey(finalStageSteps) + ), + estimatedComplexity: finalStageSteps.length + stageEdges.length + seedRecordCount, + }, + deferred: { + steps: finalDeferredSteps, + edges: deferredEdges, + sameTableBatches: splitSameTableBatches( + plan.sameTableBatches, + collectRetainedFieldsByStepKey(finalDeferredSteps) + ), + }, + }; +}; + +/** + * Build the continuation plan for the deferred remainder of a split stage. + * + * Seeds (original plan seeds, stage dirty records, and seed-all tables alike) are + * narrowed to tables the deferred work can still read from: source tables of + * deferred edges (direct dependencies on the original mutation) and tables hosting + * deferred steps (same-table same-record chains have no cross-record edge to + * witness them). If narrowing would leave the continuation with no seeds at all, + * seeds are kept unnarrowed — an empty seed set would flip execution into + * schema-update "seed everything" semantics. Must be enqueued in the same + * transaction that commits the stage. + */ +export const buildDeferredStagePlan = (params: { + plan: ComputedUpdatePlan; + deferred: NonNullable; + dirtySeedGroups: ReadonlyArray; + dirtySeedAllTableIds: ReadonlyArray; +}): ComputedUpdatePlan => { + const { plan, deferred } = params; + + const relevantSeedTableKeys = new Set(); + for (const edge of deferred.edges) { + relevantSeedTableKeys.add(edge.fromTableId.toString()); + } + for (const step of deferred.steps) { + relevantSeedTableKeys.add(step.tableId.toString()); + } + + const seedTableKey = plan.seedTableId.toString(); + + const buildSeedState = (narrow: boolean) => { + const isRelevant = (tableKey: string): boolean => + !narrow || relevantSeedTableKeys.has(tableKey); + + const seedAllByKey = new Map(); + for (const tableId of [...(plan.seedAllTableIds ?? []), ...params.dirtySeedAllTableIds]) { + const tableKey = tableId.toString(); + if (isRelevant(tableKey)) seedAllByKey.set(tableKey, tableId); + } + + const seedRecordIds = isRelevant(seedTableKey) ? plan.seedRecordIds : ([] as RecordId[]); + const seedRecordKeys = new Set(seedRecordIds.map((id) => id.toString())); + + const extraByTable = new Map(); + const extraRecordKeys = new Map>(); + const appendExtraSeeds = (group: ComputedSeedGroup): void => { + const tableKey = group.tableId.toString(); + if (!isRelevant(tableKey) || seedAllByKey.has(tableKey)) return; + let entry = extraByTable.get(tableKey); + let seen = extraRecordKeys.get(tableKey); + if (!entry || !seen) { + entry = { tableId: group.tableId, recordIds: [] }; + seen = new Set(); + extraByTable.set(tableKey, entry); + extraRecordKeys.set(tableKey, seen); + } + for (const recordId of group.recordIds) { + const recordKey = recordId.toString(); + if (seen.has(recordKey)) continue; + if (tableKey === seedTableKey && seedRecordKeys.has(recordKey)) continue; + seen.add(recordKey); + entry.recordIds.push(recordId); + } + }; + + for (const group of plan.extraSeedRecords) appendExtraSeeds(group); + for (const group of params.dirtySeedGroups) appendExtraSeeds(group); + + return { + seedRecordIds, + extraSeedRecords: [...extraByTable.values()].filter((group) => group.recordIds.length > 0), + seedAllTableIds: [...seedAllByKey.values()], + }; + }; + + let seedState = buildSeedState(true); + if ( + seedState.seedRecordIds.length === 0 && + seedState.extraSeedRecords.length === 0 && + seedState.seedAllTableIds.length === 0 + ) { + seedState = buildSeedState(false); + } + + const { seedRecordIds, extraSeedRecords, seedAllTableIds } = seedState; + const seedRecordCount = + seedRecordIds.length + extraSeedRecords.reduce((sum, group) => sum + group.recordIds.length, 0); + + return { + ...plan, + steps: deferred.steps, + edges: deferred.edges, + sameTableBatches: deferred.sameTableBatches, + seedRecordIds, + extraSeedRecords, + estimatedComplexity: deferred.steps.length + deferred.edges.length + seedRecordCount, + cycleInfo: undefined, + seedAllTableIds: seedAllTableIds.length > 0 ? seedAllTableIds : undefined, + // A deferred continuation only exists after propagation completed; the + // worker clears the run ledger with the stage (exclusions re-enter as seeds + // SQL-side), so no per-stage durable state carries over here. + seedAllCursors: undefined, + }; +}; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedTaskFailureClassifier.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedTaskFailureClassifier.spec.ts index c4d3a8b6c5..b737346c91 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedTaskFailureClassifier.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedTaskFailureClassifier.spec.ts @@ -48,6 +48,20 @@ describe('classifyComputedTaskFailure', () => { }); }); + it('classifies stale field references as non-retryable obsolete plans', () => { + const failure = classifyComputedTaskFailure( + domainError.notFound({ + message: 'Field not found', + }) + ); + + expect(failure).toEqual({ + failureKind: 'obsolete_plan', + failureReason: 'stale_field_reference', + retryable: false, + }); + }); + it('keeps unknown infrastructure errors retryable', () => { const failure = classifyComputedTaskFailure( domainError.infrastructure({ diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedTaskFailureClassifier.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedTaskFailureClassifier.ts index cd6b416f1b..0ff57b37b0 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedTaskFailureClassifier.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedTaskFailureClassifier.ts @@ -6,13 +6,15 @@ export type ComputedTaskFailureKind = | 'transient' | 'statement_timeout' | 'computed_code_bug' - | 'data_safety_limit'; + | 'data_safety_limit' + | 'obsolete_plan'; export type ComputedTaskFailureReason = | 'unknown' | 'statement_timeout' | 'postgres_sql_generation_error' - | 'computed_cell_value_max_bytes'; + | 'computed_cell_value_max_bytes' + | 'stale_field_reference'; export type ComputedTaskFailureClassification = { failureKind: ComputedTaskFailureKind; @@ -44,6 +46,14 @@ const isSqlGenerationBugMessage = (message: string): boolean => { return SQL_GENERATION_BUG_PATTERNS.some((pattern) => pattern.test(normalized)); }; +/** + * Persisted plans can reference fields deleted between planning and execution. + * Retrying cannot resurrect the field, so a retry loop only amplifies load + * (max_attempts claims per task) before dead-lettering anyway. + */ +const isStaleFieldReferenceMessage = (message: string): boolean => + /\bfield not found\b/i.test(message); + export const classifyComputedTaskFailure = ( error: DomainError ): ComputedTaskFailureClassification => { @@ -73,6 +83,14 @@ export const classifyComputedTaskFailure = ( }; } + if (isStaleFieldReferenceMessage(message)) { + return { + failureKind: 'obsolete_plan', + failureReason: 'stale_field_reference', + retryable: false, + }; + } + return { failureKind: 'transient', failureReason: 'unknown', diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedUpdatePlanner.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedUpdatePlanner.ts index e9cf374f9b..09dfa1062b 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedUpdatePlanner.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedUpdatePlanner.ts @@ -201,6 +201,19 @@ export type ComputedUpdatePlan = { * Used to avoid storing/loading individual record IDs when the full table is dirty. */ seedAllTableIds?: ReadonlyArray; + /** + * Whole-table seeding resume cursors (last seeded __id per table id). O(1) + * durable state replacing per-row exclusion ledgers for full-table sources; + * cursors only advance once the seeded slice's propagation completed. + */ + seedAllCursors?: Readonly>; + /** + * Stage-ledger scope: the continuation chain's root task id. Keys the durable + * exclusion ledger + frontier queue in computed_update_stage_ledger. Chains + * are serial, so the scope has one writer; parallel chunk-split tasks of the + * same run get distinct scopes. + */ + ledgerScopeId?: string; }; const emptyComputedUpdatePlan = ( diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/UpdateFromSelectBuilder.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/UpdateFromSelectBuilder.ts index 6295930a7d..88488b2654 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/UpdateFromSelectBuilder.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/UpdateFromSelectBuilder.ts @@ -539,9 +539,11 @@ const buildFieldMappings = ( ); const lastFieldIdByColumn = new Map(); - for (const fieldId of fieldIds) { - yield* table.getField((candidate) => candidate.id().equals(fieldId)); - } + // Plans are persisted; a field can be deleted between planning and + // execution. A deleted field simply has nothing left to update, so skip it + // instead of failing the whole step (a hard error here is classified as + // transient and retried to dead letter). An all-deleted step degrades to a + // no-op via projectionPlan.isEmpty(). for (const field of selectedFields) { const dbFieldName = yield* field.dbFieldName(); @@ -717,11 +719,23 @@ class UpdateAssignmentPlan { const target = sql.raw(quoteRef(tableAlias, this.column)); const projected = this.buildProjectedRef(eb, projectionAlias); - if (isTemporalDbFieldType(this.normalizedDbType)) { + // Always cast both sides to a shared comparable type. PostgreSQL implements + // IS DISTINCT FROM via `=`, so mixed physical/projection types + // (double precision vs text, jsonb vs text, etc.) fail hard during backfill. + // Temporal columns already used text; extend the same defense to every type. + if (this.normalizedDbType === 'jsonb') { + return sql`(${target})::jsonb IS DISTINCT FROM (${projected})::jsonb`; + } + + if (isTemporalDbFieldType(this.normalizedDbType) || this.normalizedDbType === 'text') { return sql`(${target})::text IS DISTINCT FROM (${projected})::text`; } - return sql`${target} IS DISTINCT FROM ${projected}`; + if (isNumericDbFieldType(this.normalizedDbType) || this.normalizedDbType === 'boolean') { + return sql`(${target})::${sql.raw(this.normalizedDbType)} IS DISTINCT FROM (${projected})::${sql.raw(this.normalizedDbType)}`; + } + + return sql`(${target})::text IS DISTINCT FROM (${projected})::text`; } } diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/ComputedFieldUpdater.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/ComputedFieldUpdater.spec.ts index c53695ca5c..280526c642 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/ComputedFieldUpdater.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/ComputedFieldUpdater.spec.ts @@ -2,6 +2,7 @@ import { ActorId, BaseId, DbFieldName, + DbFieldType, FormulaExpression, FieldId, FieldName, @@ -971,7 +972,7 @@ describe('ComputedFieldUpdater', () => { "sql": "update "bseaaaaaaaaaaaaaaaa"."tblbbbbbbbbbbbbbbbb" as "u" set "__version" = "u"."__version" + 1, "col_link" = "c"."__set_col_link" from (select "c_src"."__id" as "__id", (CASE WHEN "c_src"."col_link" IS NULL THEN NULL::jsonb ELSE to_jsonb("c_src"."col_link") - END) as "__set_col_link" from (select "t"."__id" as "__id", "t"."__version" as "__version", "lat_fldeeeeeeeeeeeeeeee_0"."col_link" as "col_link" from "bseaaaaaaaaaaaaaaaa"."tblbbbbbbbbbbbbbbbb" as "t" inner join "pg_temp"."tmp_computed_dirty" as "__dirty" on "t"."__id" = "__dirty"."record_id" and "__dirty"."table_id" = $1 inner join lateral (select jsonb_agg(jsonb_strip_nulls(jsonb_build_object('id', "f"."__id", 'title', ("f"."col_name")::text)) ORDER BY (SELECT "j"."__order" FROM "bseaaaaaaaaaaaaaaaa"."junction_fldeeeeeeeeeeeeeeee_fldffffffffffffffff" AS j WHERE "j"."__fk_fldffffffffffffffff" = "t"."__id" AND "j"."__fk_fldeeeeeeeeeeeeeeee" = "f"."__id"), (SELECT "j"."__id" FROM "bseaaaaaaaaaaaaaaaa"."junction_fldeeeeeeeeeeeeeeee_fldffffffffffffffff" AS j WHERE "j"."__fk_fldffffffffffffffff" = "t"."__id" AND "j"."__fk_fldeeeeeeeeeeeeeeee" = "f"."__id")) as "col_link" from "bseaaaaaaaaaaaaaaaa"."tblcccccccccccccccc" as "f" where "f"."__id" IN (SELECT "j"."__fk_fldeeeeeeeeeeeeeeee" FROM "bseaaaaaaaaaaaaaaaa"."junction_fldeeeeeeeeeeeeeeee_fldffffffffffffffff" AS j WHERE "j"."__fk_fldffffffffffffffff" = "t"."__id")) as "lat_fldeeeeeeeeeeeeeeee_0" on true) as "c_src") as "c" where "u"."__id" = "c"."__id" and ("u"."col_link" IS DISTINCT FROM "c"."__set_col_link")", + END) as "__set_col_link" from (select "t"."__id" as "__id", "t"."__version" as "__version", "lat_fldeeeeeeeeeeeeeeee_0"."col_link" as "col_link" from "bseaaaaaaaaaaaaaaaa"."tblbbbbbbbbbbbbbbbb" as "t" inner join "pg_temp"."tmp_computed_dirty" as "__dirty" on "t"."__id" = "__dirty"."record_id" and "__dirty"."table_id" = $1 inner join lateral (select jsonb_agg(jsonb_strip_nulls(jsonb_build_object('id', "f"."__id", 'title', ("f"."col_name")::text)) ORDER BY (SELECT "j"."__order" FROM "bseaaaaaaaaaaaaaaaa"."junction_fldeeeeeeeeeeeeeeee_fldffffffffffffffff" AS j WHERE "j"."__fk_fldffffffffffffffff" = "t"."__id" AND "j"."__fk_fldeeeeeeeeeeeeeeee" = "f"."__id"), (SELECT "j"."__id" FROM "bseaaaaaaaaaaaaaaaa"."junction_fldeeeeeeeeeeeeeeee_fldffffffffffffffff" AS j WHERE "j"."__fk_fldffffffffffffffff" = "t"."__id" AND "j"."__fk_fldeeeeeeeeeeeeeeee" = "f"."__id")) as "col_link" from "bseaaaaaaaaaaaaaaaa"."tblcccccccccccccccc" as "f" where "f"."__id" IN (SELECT "j"."__fk_fldeeeeeeeeeeeeeeee" FROM "bseaaaaaaaaaaaaaaaa"."junction_fldeeeeeeeeeeeeeeee_fldffffffffffffffff" AS j WHERE "j"."__fk_fldffffffffffffffff" = "t"."__id")) as "lat_fldeeeeeeeeeeeeeeee_0" on true) as "c_src") as "c" where "u"."__id" = "c"."__id" and (("u"."col_link")::jsonb IS DISTINCT FROM ("c"."__set_col_link")::jsonb)", }, ] `); @@ -1991,7 +1992,7 @@ describe('ComputedFieldUpdater', () => { WHEN BTRIM(("c_src"."col_rollup_b")::text) ~ '^[+-]?([0-9]+([.][0-9]+)?|[.][0-9]+)([eE][+-]?[0-9]+)?$' THEN BTRIM(("c_src"."col_rollup_b")::text)::double precision ELSE NULL - END as "__set_col_rollup_b" from (select "t"."__id" as "__id", "t"."__version" as "__version", "lat_fldpppppppppppppppp_0"."col_lookup_b" as "col_lookup_b", "lat_fldpppppppppppppppp_0"."col_rollup_b" as "col_rollup_b" from "bseaaaaaaaaaaaaaaaa"."tblllllllllllllllll" as "t" inner join "pg_temp"."tmp_computed_dirty" as "__dirty" on "t"."__id" = "__dirty"."record_id" and "__dirty"."table_id" = $1 inner join lateral (select jsonb_agg(to_jsonb("f"."col_source_name")) FILTER (WHERE "f"."col_source_name" IS NOT NULL) as "col_lookup_b", CAST(COALESCE(SUM("f"."col_source_score"), 0) AS DOUBLE PRECISION) as "col_rollup_b" from "bseaaaaaaaaaaaaaaaa"."tblkkkkkkkkkkkkkkkk" as "f" where "f"."__id" = "t"."__fk_fldpppppppppppppppp") as "lat_fldpppppppppppppppp_0" on true) as "c_src") as "c" where "u"."__id" = "c"."__id" and ("u"."col_lookup_b" IS DISTINCT FROM "c"."__set_col_lookup_b" OR "u"."col_rollup_b" IS DISTINCT FROM "c"."__set_col_rollup_b")", + END as "__set_col_rollup_b" from (select "t"."__id" as "__id", "t"."__version" as "__version", "lat_fldpppppppppppppppp_0"."col_lookup_b" as "col_lookup_b", "lat_fldpppppppppppppppp_0"."col_rollup_b" as "col_rollup_b" from "bseaaaaaaaaaaaaaaaa"."tblllllllllllllllll" as "t" inner join "pg_temp"."tmp_computed_dirty" as "__dirty" on "t"."__id" = "__dirty"."record_id" and "__dirty"."table_id" = $1 inner join lateral (select jsonb_agg(to_jsonb("f"."col_source_name")) FILTER (WHERE "f"."col_source_name" IS NOT NULL) as "col_lookup_b", CAST(COALESCE(SUM("f"."col_source_score"), 0) AS DOUBLE PRECISION) as "col_rollup_b" from "bseaaaaaaaaaaaaaaaa"."tblkkkkkkkkkkkkkkkk" as "f" where "f"."__id" = "t"."__fk_fldpppppppppppppppp") as "lat_fldpppppppppppppppp_0" on true) as "c_src") as "c" where "u"."__id" = "c"."__id" and (("u"."col_lookup_b")::jsonb IS DISTINCT FROM ("c"."__set_col_lookup_b")::jsonb OR ("u"."col_rollup_b")::double precision IS DISTINCT FROM ("c"."__set_col_rollup_b")::double precision)", }, { "parameters": [ @@ -2006,7 +2007,7 @@ describe('ComputedFieldUpdater', () => { "sql": "update "bseaaaaaaaaaaaaaaaa"."tblmmmmmmmmmmmmmmmm" as "u" set "__version" = "u"."__version" + 1, "col_lookup_c" = "c"."__set_col_lookup_c" from (select "c_src"."__id" as "__id", (CASE WHEN "c_src"."col_lookup_c" IS NULL THEN NULL::jsonb ELSE ("c_src"."col_lookup_c")::jsonb - END) as "__set_col_lookup_c" from (select "t"."__id" as "__id", "t"."__version" as "__version", "lat_fldtttttttttttttttt_0"."col_lookup_c" as "col_lookup_c" from "bseaaaaaaaaaaaaaaaa"."tblmmmmmmmmmmmmmmmm" as "t" inner join "pg_temp"."tmp_computed_dirty" as "__dirty" on "t"."__id" = "__dirty"."record_id" and "__dirty"."table_id" = $1 inner join lateral (select jsonb_agg(to_jsonb("f"."col_rollup_b")) FILTER (WHERE "f"."col_rollup_b" IS NOT NULL) as "col_lookup_c" from "bseaaaaaaaaaaaaaaaa"."tblllllllllllllllll" as "f" where "f"."__id" = "t"."__fk_fldtttttttttttttttt") as "lat_fldtttttttttttttttt_0" on true) as "c_src") as "c" where "u"."__id" = "c"."__id" and ("u"."col_lookup_c" IS DISTINCT FROM "c"."__set_col_lookup_c")", + END) as "__set_col_lookup_c" from (select "t"."__id" as "__id", "t"."__version" as "__version", "lat_fldtttttttttttttttt_0"."col_lookup_c" as "col_lookup_c" from "bseaaaaaaaaaaaaaaaa"."tblmmmmmmmmmmmmmmmm" as "t" inner join "pg_temp"."tmp_computed_dirty" as "__dirty" on "t"."__id" = "__dirty"."record_id" and "__dirty"."table_id" = $1 inner join lateral (select jsonb_agg(to_jsonb("f"."col_rollup_b")) FILTER (WHERE "f"."col_rollup_b" IS NOT NULL) as "col_lookup_c" from "bseaaaaaaaaaaaaaaaa"."tblllllllllllllllll" as "f" where "f"."__id" = "t"."__fk_fldtttttttttttttttt") as "lat_fldtttttttttttttttt_0" on true) as "c_src") as "c" where "u"."__id" = "c"."__id" and (("u"."col_lookup_c")::jsonb IS DISTINCT FROM ("c"."__set_col_lookup_c")::jsonb)", }, ] `); @@ -2099,6 +2100,86 @@ describe('ComputedFieldUpdater', () => { } }); + it('chunks JSON-backed same-table formula batches below the dirty threshold', async () => { + const { baseId, table, plusOneFieldId, doubleFieldId } = createSameTableFormulaChainTable(); + for (const fieldId of [plusOneFieldId, doubleFieldId]) { + table + .getField((field) => field.id().equals(fieldId)) + ._unsafeUnwrap() + .setDbFieldType(DbFieldType.rehydrate('JSON')._unsafeUnwrap()) + ._unsafeUnwrap(); + } + + const plan: ComputedUpdatePlan = { + baseId, + seedTableId: table.id(), + seedRecordIds: createSequentialRecordIds(265), + extraSeedRecords: [], + steps: [ + { tableId: table.id(), fieldIds: [plusOneFieldId], level: 0 }, + { tableId: table.id(), fieldIds: [doubleFieldId], level: 1 }, + ], + edges: [], + estimatedComplexity: 2, + changeType: 'update', + sameTableBatches: [ + { + tableId: table.id(), + steps: [ + { tableId: table.id(), fieldIds: [plusOneFieldId], level: 0 }, + { tableId: table.id(), fieldIds: [doubleFieldId], level: 1 }, + ], + minLevel: 0, + maxLevel: 1, + }, + ], + }; + + const { db, driver } = createRecordingDb(); + const updater = new ComputedFieldUpdater( + createTableRepository([table]), + createLogger(), + db as unknown as Kysely, + undefined, + createTypeValidationStrategy() + ); + const updaterInternal = updater as unknown as { + getDirtyCountForTable: () => Promise; + getDirtyRecordIdChunks: ( + db: unknown, + tableId: unknown, + chunkSize?: number, + includeSingleton?: boolean + ) => Promise>>; + }; + updaterInternal.getDirtyCountForTable = async () => 265; + updaterInternal.getDirtyRecordIdChunks = async (_db, _tableId, chunkSize, includeSingleton) => { + expect(chunkSize).toBe(25); + expect(includeSingleton).toBe(true); + return Array.from({ length: 11 }, (_, chunkIndex) => + Array.from( + { length: chunkIndex === 10 ? 15 : 25 }, + (_, index) => `rec${(chunkIndex * 25 + index).toString().padStart(16, '0')}` + ) + ); + }; + + const result = await updater.execute(plan, { + actorId: ActorId.create(ACTOR_ID)._unsafeUnwrap(), + }); + expect(result.isOk()).toBe(true); + + const updateQueries = driver.queries.filter((query) => + query.sql.startsWith('update "bseaaaaaaaaaaaaaaaa"."tblzzzzzzzzzzzzzzzz" as "u"') + ); + expect(updateQueries).toHaveLength(11); + for (const query of updateQueries) { + expect(query.sql).toContain('AS "__record_ids"("__id")'); + expect(query.sql).toContain('"level_0" AS MATERIALIZED'); + expect(query.sql).toContain('"level_1" AS MATERIALIZED'); + } + }); + it('chunks lateral lookup updates when dirty records exceed threshold', async () => { const { baseId, diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/ComputedStagePlanSplitter.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/ComputedStagePlanSplitter.spec.ts new file mode 100644 index 0000000000..84738b649e --- /dev/null +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/ComputedStagePlanSplitter.spec.ts @@ -0,0 +1,416 @@ +import { BaseId, FieldId, RecordId, TableId } from '@teable/v2-core'; +import { describe, expect, it } from 'vitest'; + +import { + buildDeferredStagePlan, + splitComputedPlanForStageBudget, +} from '../ComputedStagePlanSplitter'; +import type { ComputedStageBudget } from '../ComputedStagePlanSplitter'; +import type { + ComputedDependencyEdge, + ComputedUpdatePlan, + UpdateStep, +} from '../ComputedUpdatePlanner'; + +const baseId = BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap(); +const tableA = TableId.create(`tbl${'a'.repeat(16)}`)._unsafeUnwrap(); +const tableB = TableId.create(`tbl${'b'.repeat(16)}`)._unsafeUnwrap(); +const tableC = TableId.create(`tbl${'c'.repeat(16)}`)._unsafeUnwrap(); +const fieldA1 = FieldId.create(`fld${'a'.repeat(15)}1`)._unsafeUnwrap(); +const fieldA2 = FieldId.create(`fld${'a'.repeat(15)}2`)._unsafeUnwrap(); +const fieldB1 = FieldId.create(`fld${'b'.repeat(15)}1`)._unsafeUnwrap(); +const fieldC1 = FieldId.create(`fld${'c'.repeat(15)}1`)._unsafeUnwrap(); +const recordA1 = RecordId.create(`rec${'a'.repeat(15)}1`)._unsafeUnwrap(); +const recordB1 = RecordId.create(`rec${'b'.repeat(15)}1`)._unsafeUnwrap(); +const recordB2 = RecordId.create(`rec${'b'.repeat(15)}2`)._unsafeUnwrap(); + +const edge = ( + fromTableId: TableId, + fromFieldId: FieldId, + toTableId: TableId, + toFieldId: FieldId, + order: number +): ComputedDependencyEdge => ({ + fromFieldId, + toFieldId, + fromTableId, + toTableId, + propagationMode: 'linkTraversal', + order, +}); + +const steps: UpdateStep[] = [ + { tableId: tableA, fieldIds: [fieldA1, fieldA2], level: 0 }, + { tableId: tableB, fieldIds: [fieldB1], level: 1 }, + { tableId: tableC, fieldIds: [fieldC1], level: 2 }, +]; + +const edges: ComputedDependencyEdge[] = [ + edge(tableA, fieldA1, tableB, fieldB1, 0), + edge(tableB, fieldB1, tableC, fieldC1, 1), +]; + +const createPlan = (overrides: Partial = {}): ComputedUpdatePlan => ({ + baseId, + seedTableId: tableA, + seedRecordIds: [recordA1], + extraSeedRecords: [], + beforeImageRecords: [], + steps, + edges, + estimatedComplexity: 6, + changeType: 'update', + sameTableBatches: [ + { tableId: tableA, steps: [steps[0]], minLevel: 0, maxLevel: 0 }, + { tableId: tableB, steps: [steps[1]], minLevel: 1, maxLevel: 1 }, + { tableId: tableC, steps: [steps[2]], minLevel: 2, maxLevel: 2 }, + ], + ...overrides, +}); + +const budget = (overrides: Partial = {}): ComputedStageBudget => ({ + maxSteps: 0, + maxFields: 0, + maxEdges: 0, + ...overrides, +}); + +describe('splitComputedPlanForStageBudget', () => { + it('returns the whole plan when staging is disabled', () => { + const plan = createPlan(); + const split = splitComputedPlanForStageBudget(plan, budget()); + + expect(split.stagePlan).toBe(plan); + expect(split.deferred).toBeNull(); + }); + + it('returns the whole plan when it fits the budget', () => { + const plan = createPlan(); + const split = splitComputedPlanForStageBudget(plan, budget({ maxSteps: 10 })); + + expect(split.stagePlan).toBe(plan); + expect(split.deferred).toBeNull(); + }); + + it('takes a level-ordered step prefix and partitions edges by target table', () => { + const plan = createPlan(); + const split = splitComputedPlanForStageBudget(plan, budget({ maxSteps: 2 })); + + expect(split.stagePlan.steps).toEqual([steps[0], steps[1]]); + expect(split.stagePlan.edges).toEqual([edges[0]]); + expect(split.stagePlan.sameTableBatches.map((batch) => batch.tableId)).toEqual([ + tableA, + tableB, + ]); + expect(split.deferred).not.toBeNull(); + expect(split.deferred?.steps).toEqual([steps[2]]); + expect(split.deferred?.edges).toEqual([edges[1]]); + expect(split.deferred?.sameTableBatches.map((batch) => batch.tableId)).toEqual([tableC]); + }); + + it('keeps an edge in both partitions when its target table spans stage and deferred steps', () => { + const bothSteps: UpdateStep[] = [ + { tableId: tableB, fieldIds: [fieldB1], level: 0 }, + { tableId: tableB, fieldIds: [fieldC1], level: 1 }, + ]; + const sharedEdge = edge(tableA, fieldA1, tableB, fieldB1, 0); + const plan = createPlan({ steps: bothSteps, edges: [sharedEdge], sameTableBatches: [] }); + + const split = splitComputedPlanForStageBudget(plan, budget({ maxSteps: 1 })); + + expect(split.stagePlan.steps).toEqual([bothSteps[0]]); + expect(split.stagePlan.edges).toEqual([sharedEdge]); + expect(split.deferred?.steps).toEqual([bothSteps[1]]); + expect(split.deferred?.edges).toEqual([sharedEdge]); + }); + + it('applies the field budget across step field counts', () => { + const plan = createPlan(); + const split = splitComputedPlanForStageBudget(plan, budget({ maxFields: 3 })); + + expect(split.stagePlan.steps).toEqual([steps[0], steps[1]]); + expect(split.deferred?.steps).toEqual([steps[2]]); + }); + + it('applies the edge budget by counting edges into stage tables', () => { + const plan = createPlan(); + const split = splitComputedPlanForStageBudget(plan, budget({ maxEdges: 1 })); + + expect(split.stagePlan.steps).toEqual([steps[0], steps[1]]); + expect(split.deferred?.steps).toEqual([steps[2]]); + }); + + it('hard-splits a first step that alone exceeds the field budget', () => { + const plan = createPlan(); + const split = splitComputedPlanForStageBudget(plan, budget({ maxFields: 1 })); + + expect(split.stagePlan.steps).toEqual([{ tableId: tableA, fieldIds: [fieldA1], level: 0 }]); + expect(split.deferred?.steps).toEqual([ + { tableId: tableA, fieldIds: [fieldA2], level: 0 }, + steps[1], + steps[2], + ]); + }); + + it('splits same-table batches by retained fields when a step is field-split', () => { + const wideStep: UpdateStep = { tableId: tableA, fieldIds: [fieldA1, fieldA2], level: 0 }; + const plan = createPlan({ + steps: [wideStep], + edges: [], + sameTableBatches: [{ tableId: tableA, steps: [wideStep], minLevel: 0, maxLevel: 0 }], + }); + + const split = splitComputedPlanForStageBudget(plan, budget({ maxFields: 1 })); + + expect(split.stagePlan.sameTableBatches).toEqual([ + { + tableId: tableA, + steps: [{ tableId: tableA, fieldIds: [fieldA1], level: 0 }], + minLevel: 0, + maxLevel: 0, + }, + ]); + expect(split.deferred?.sameTableBatches).toEqual([ + { + tableId: tableA, + steps: [{ tableId: tableA, fieldIds: [fieldA2], level: 0 }], + minLevel: 0, + maxLevel: 0, + }, + ]); + }); + + it('partitions edges by target field when propagation targets are known', () => { + const bothSteps: UpdateStep[] = [ + { tableId: tableB, fieldIds: [fieldB1], level: 0 }, + { tableId: tableB, fieldIds: [fieldC1], level: 1 }, + ]; + // Same target table, but this edge only feeds the deferred field. + const deferredOnlyEdge: ComputedDependencyEdge = { + ...edge(tableA, fieldA1, tableB, fieldC1, 0), + propagationTargetFieldIds: [fieldC1], + }; + const plan = createPlan({ steps: bothSteps, edges: [deferredOnlyEdge], sameTableBatches: [] }); + + const split = splitComputedPlanForStageBudget(plan, budget({ maxSteps: 1 })); + + expect(split.stagePlan.edges).toEqual([]); + expect(split.deferred?.edges).toEqual([deferredOnlyEdge]); + }); + + it('keeps a deduplicated edge in both partitions when its targets span the split', () => { + const bothSteps: UpdateStep[] = [ + { tableId: tableB, fieldIds: [fieldB1], level: 0 }, + { tableId: tableB, fieldIds: [fieldC1], level: 1 }, + ]; + const sharedEdge: ComputedDependencyEdge = { + ...edge(tableA, fieldA1, tableB, fieldB1, 0), + propagationTargetFieldIds: [fieldB1, fieldC1], + }; + const plan = createPlan({ steps: bothSteps, edges: [sharedEdge], sameTableBatches: [] }); + + const split = splitComputedPlanForStageBudget(plan, budget({ maxSteps: 1 })); + + expect(split.stagePlan.edges).toEqual([sharedEdge]); + expect(split.deferred?.edges).toEqual([sharedEdge]); + }); + + it('field-splits the first step when its edge count exceeds the edge budget', () => { + const fieldB2 = FieldId.create(`fld${'b'.repeat(15)}2`)._unsafeUnwrap(); + const fieldB3 = FieldId.create(`fld${'b'.repeat(15)}3`)._unsafeUnwrap(); + const wideStep: UpdateStep = { + tableId: tableB, + fieldIds: [fieldB1, fieldB2, fieldB3], + level: 0, + }; + const edgeFor = (toFieldId: FieldId, order: number): ComputedDependencyEdge => ({ + ...edge(tableA, fieldA1, tableB, toFieldId, order), + propagationTargetFieldIds: [toFieldId], + }); + const plan = createPlan({ + steps: [wideStep], + edges: [edgeFor(fieldB1, 0), edgeFor(fieldB2, 1), edgeFor(fieldB3, 2)], + sameTableBatches: [], + }); + + const split = splitComputedPlanForStageBudget(plan, budget({ maxEdges: 2 })); + + expect(split.stagePlan.steps).toEqual([ + { tableId: tableB, fieldIds: [fieldB1, fieldB2], level: 0 }, + ]); + expect(split.stagePlan.edges).toHaveLength(2); + expect(split.deferred?.steps).toEqual([{ tableId: tableB, fieldIds: [fieldB3], level: 0 }]); + expect(split.deferred?.edges).toHaveLength(1); + }); + + it('chunks a single field with excess edges into edge-only stages, computing the field once', () => { + // One target field owning 5 propagation edges under maxEdges=2: the stage + // runs exactly 2 of them as pure propagation — the hosting field MOVES to + // the deferred stage (nothing retained depends on it), so it computes once + // over the accumulated dirty targets instead of once per chunk. + const manyEdges = Array.from({ length: 5 }, (_, index) => ({ + ...edge(tableA, fieldA1, tableB, fieldB1, index), + propagationTargetFieldIds: [fieldB1], + })); + const plan = createPlan({ + steps: [{ tableId: tableB, fieldIds: [fieldB1], level: 0 }], + edges: manyEdges, + }); + const split = splitComputedPlanForStageBudget(plan, { + maxSteps: 0, + maxFields: 0, + maxEdges: 2, + }); + + expect(split.stagePlan.edges).toHaveLength(2); + // Edge-only stage: the hosting field deferred wholesale, no duplication. + expect(split.stagePlan.steps).toHaveLength(0); + expect(split.deferred).not.toBeNull(); + expect(split.deferred!.edges).toHaveLength(3); + expect(split.deferred!.steps).toHaveLength(1); + expect(split.deferred!.steps[0].fieldIds.map((id) => id.toString())).toEqual([ + fieldB1.toString(), + ]); + // Stage + deferred cover every edge exactly once. + const stageOrders = split.stagePlan.edges.map((e) => e.order).sort(); + const deferredOrders = split.deferred!.edges.map((e) => e.order).sort(); + expect([...stageOrders, ...deferredOrders].sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4]); + }); + + it('hard-caps orphan edges via an edge-only deferred continuation', () => { + // 5 orphan edges (no hosting step anywhere) under maxEdges=2: exactly 2 run + // now; the other 3 defer as a step-less, edge-only continuation instead of + // breaking the per-transaction cap. + const orphanFieldX = FieldId.create(`fld${'x'.repeat(15)}1`)._unsafeUnwrap(); + const orphanEdges = Array.from({ length: 5 }, (_, index) => ({ + ...edge(tableA, fieldA1, tableC, orphanFieldX, index), + propagationTargetFieldIds: [orphanFieldX], + })); + const plan = createPlan({ + steps: [{ tableId: tableB, fieldIds: [fieldB1], level: 0 }], + edges: orphanEdges, + }); + const split = splitComputedPlanForStageBudget(plan, { + maxSteps: 0, + maxFields: 0, + maxEdges: 2, + }); + + expect(split.stagePlan.edges.map((e) => e.order)).toEqual([0, 1]); + expect(split.stagePlan.steps).toHaveLength(1); + expect(split.deferred).not.toBeNull(); + expect(split.deferred!.edges.map((e) => e.order)).toEqual([2, 3, 4]); + // Orphans host no step: the continuation is pure propagation. + expect(split.deferred!.steps.map((step) => step.fieldIds.length)).toEqual([]); + }); + + it('keeps edges into tables without any step in the stage plan', () => { + const orphanEdge = edge(tableA, fieldA1, tableA, fieldA2, 2); + const plan = createPlan({ edges: [...edges, orphanEdge] }); + // tableA hosts a step, so this exercises a table absent from deferred steps. + const split = splitComputedPlanForStageBudget(plan, budget({ maxSteps: 2 })); + + expect(split.stagePlan.edges).toContainEqual(orphanEdge); + expect(split.deferred?.edges).not.toContainEqual(orphanEdge); + }); +}); + +describe('buildDeferredStagePlan', () => { + it('narrows seeds to tables the deferred work reads from', () => { + const plan = createPlan(); + const split = splitComputedPlanForStageBudget(plan, budget({ maxSteps: 2 })); + + const continuation = buildDeferredStagePlan({ + plan, + deferred: split.deferred!, + dirtySeedGroups: [ + { tableId: tableA, recordIds: [recordA1] }, + { tableId: tableB, recordIds: [recordB1, recordB2] }, + ], + dirtySeedAllTableIds: [], + }); + + expect(continuation.steps).toEqual([steps[2]]); + expect(continuation.edges).toEqual([edges[1]]); + expect(continuation.seedTableId).toBe(tableA); + // Deferred work only reads from tableB (edge source) and tableC (deferred step); + // tableA's original seeds and dirty rows are no longer reachable inputs. + expect(continuation.seedRecordIds).toEqual([]); + expect(continuation.extraSeedRecords).toEqual([ + { tableId: tableB, recordIds: [recordB1, recordB2] }, + ]); + expect(continuation.seedAllTableIds).toBeUndefined(); + expect(continuation.beforeImageRecords).toBe(plan.beforeImageRecords); + expect(continuation.changeType).toBe('update'); + }); + + it('keeps original seeds when a deferred edge reads from the seed table', () => { + const directEdge: ComputedDependencyEdge = { + ...edge(tableA, fieldA1, tableC, fieldC1, 2), + propagationTargetFieldIds: [fieldC1], + }; + const plan = createPlan({ edges: [...edges, directEdge] }); + const split = splitComputedPlanForStageBudget(plan, budget({ maxSteps: 2 })); + + const continuation = buildDeferredStagePlan({ + plan, + deferred: split.deferred!, + dirtySeedGroups: [{ tableId: tableB, recordIds: [recordB1] }], + dirtySeedAllTableIds: [], + }); + + expect(continuation.edges).toContainEqual(directEdge); + expect(continuation.seedRecordIds).toEqual([recordA1]); + }); + + it('keeps seeds unnarrowed when narrowing would leave the continuation seedless', () => { + // Deferred work on tableC only, but the stage produced no dirty rows at all: + // an empty seed set would flip execution into schema-update seed-all semantics. + const plan = createPlan(); + const split = splitComputedPlanForStageBudget(plan, budget({ maxSteps: 2 })); + + const continuation = buildDeferredStagePlan({ + plan, + deferred: split.deferred!, + dirtySeedGroups: [], + dirtySeedAllTableIds: [], + }); + + expect(continuation.seedRecordIds).toEqual([recordA1]); + }); + + it('unions seed-all tables and drops their per-record groups', () => { + const plan = createPlan({ seedAllTableIds: [tableC] }); + const split = splitComputedPlanForStageBudget(plan, budget({ maxSteps: 2 })); + + const continuation = buildDeferredStagePlan({ + plan, + deferred: split.deferred!, + dirtySeedGroups: [{ tableId: tableB, recordIds: [recordB1] }], + dirtySeedAllTableIds: [tableB], + }); + + expect(continuation.seedAllTableIds?.map((id) => id.toString()).sort()).toEqual( + [tableB.toString(), tableC.toString()].sort() + ); + expect(continuation.extraSeedRecords).toEqual([]); + }); + + it('merges duplicate dirty groups with existing extra seeds', () => { + const plan = createPlan({ + extraSeedRecords: [{ tableId: tableB, recordIds: [recordB1] }], + }); + const split = splitComputedPlanForStageBudget(plan, budget({ maxSteps: 2 })); + + const continuation = buildDeferredStagePlan({ + plan, + deferred: split.deferred!, + dirtySeedGroups: [{ tableId: tableB, recordIds: [recordB1, recordB2] }], + dirtySeedAllTableIds: [], + }); + + expect(continuation.extraSeedRecords).toEqual([ + { tableId: tableB, recordIds: [recordB1, recordB2] }, + ]); + }); +}); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UpdateFromSelectBuilder.lookup.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UpdateFromSelectBuilder.lookup.spec.ts index e3b7c92161..f4a54937c0 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UpdateFromSelectBuilder.lookup.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UpdateFromSelectBuilder.lookup.spec.ts @@ -503,6 +503,39 @@ describe('UpdateFromSelectBuilder - Lookup Fields', () => { expect(updateResult.value.sql).not.toContain('"c_src"."col_lookup" ->> 0'); }); + it('casts scalar lookup distinct comparisons to the target numeric type', () => { + const db = createTestDb(); + const { table, lookupFieldId } = createTableWithLookup({ + isMultipleCellValue: false, + relationship: 'manyOne', + }); + + // Simulate a text-typed SELECT projection (jsonb ->> 0 / CASE null branch) + // against a double precision physical column. Without shared casts this + // becomes `double precision = text` and aborts schema backfill. + const selectQuery = db.selectNoFrom(() => [ + sql`'rec_1'`.as('__id'), + sql`'12.5'::text`.as('col_lookup'), + ]) as never; + + const builder = new UpdateFromSelectBuilder(db); + const updateResult = builder.build({ + table, + fieldIds: [lookupFieldId], + selectQuery, + }); + + expect(updateResult.isOk()).toBe(true); + if (updateResult.isErr()) return; + + expect(updateResult.value.sql).toContain( + '("u"."col_lookup")::double precision IS DISTINCT FROM ("c"."__set_col_lookup")::double precision' + ); + expect(updateResult.value.sql).not.toContain( + '"u"."col_lookup" IS DISTINCT FROM "c"."__set_col_lookup"' + ); + }); + it('casts json lookup sources instead of calling to_jsonb on unknown inputs', () => { const db = createTestDb(); const { table, lookupFieldId } = createTableWithLookup({ diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UpdateFromSelectBuilder.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UpdateFromSelectBuilder.spec.ts index c187f6948d..d65f913a9d 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UpdateFromSelectBuilder.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UpdateFromSelectBuilder.spec.ts @@ -257,7 +257,7 @@ describe('UpdateFromSelectBuilder', () => { WHEN BTRIM(("c_src"."col_score")::text) ~ '^[+-]?([0-9]+([.][0-9]+)?|[.][0-9]+)([eE][+-]?[0-9]+)?$' THEN BTRIM(("c_src"."col_score")::text)::double precision ELSE NULL - END as "__set_col_score" from (select "t"."__id" as "__id", "t"."__version" as "__version", NULLIF(BTRIM((1)::text), '')::double precision as "col_score" from "bseaaaaaaaaaaaaaaaa"."tblbbbbbbbbbbbbbbbb" as "t" where "t"."__id" in (select "d"."record_id" from "tmp_computed_dirty" as "d" where "d"."table_id" = $1)) as "c_src") as "c" where "u"."__id" = "c"."__id" and ("u"."col_score" IS DISTINCT FROM "c"."__set_col_score")" + END as "__set_col_score" from (select "t"."__id" as "__id", "t"."__version" as "__version", NULLIF(BTRIM((1)::text), '')::double precision as "col_score" from "bseaaaaaaaaaaaaaaaa"."tblbbbbbbbbbbbbbbbb" as "t" where "t"."__id" in (select "d"."record_id" from "tmp_computed_dirty" as "d" where "d"."table_id" = $1)) as "c_src") as "c" where "u"."__id" = "c"."__id" and (("u"."col_score")::double precision IS DISTINCT FROM ("c"."__set_col_score")::double precision)" ` ); }); @@ -295,8 +295,12 @@ describe('UpdateFromSelectBuilder', () => { expect(sqlText).toContain('COALESCE(SUM("f"."col_amount"), 0)'); expect(sqlText).toContain('inner join "tmp_computed_dirty"'); expect(sqlText).toContain('"__version" = "u"."__version" + 1'); - expect(sqlText).toContain('"u"."col_total" IS DISTINCT FROM'); - expect(sqlText).toContain('"u"."col_total_copy" IS DISTINCT FROM'); + expect(sqlText).toContain( + '("u"."col_total")::double precision IS DISTINCT FROM ("c"."__set_col_total")::double precision' + ); + expect(sqlText).toContain( + '("u"."col_total_copy")::double precision IS DISTINCT FROM ("c"."__set_col_total_copy")::double precision' + ); }); it('updates a shared physical column only once when duplicate fields reference it', () => { @@ -407,6 +411,54 @@ describe('UpdateFromSelectBuilder', () => { expect(updateResult.value.sql).toBe('select 1 where false'); }); + it('skips plan field ids that were deleted between planning and execution', () => { + const db = createTestDb(); + const { table, formulaFieldId } = createFormulaTable(); + const deletedFieldId = FieldId.create(`fld${'z'.repeat(16)}`)._unsafeUnwrap(); + + const selectBuilder = new ComputedTableRecordQueryBuilder(db, { typeValidationStrategy }) + .from(table) + .select([formulaFieldId]); + const selectResult = selectBuilder.build(); + expect(selectResult.isOk()).toBe(true); + if (selectResult.isErr()) return; + + const builder = new UpdateFromSelectBuilder(db); + const updateResult = builder.build({ + table, + fieldIds: [formulaFieldId, deletedFieldId], + selectQuery: selectResult.value, + }); + + expect(updateResult.isOk()).toBe(true); + if (updateResult.isErr()) return; + expect(updateResult.value.sql).toContain('"col_score" = "c"."__set_col_score"'); + }); + + it('degrades to a no-op when every plan field id was deleted', () => { + const db = createTestDb(); + const { table, formulaFieldId } = createFormulaTable(); + const deletedFieldId = FieldId.create(`fld${'z'.repeat(16)}`)._unsafeUnwrap(); + + const selectBuilder = new ComputedTableRecordQueryBuilder(db, { typeValidationStrategy }) + .from(table) + .select([formulaFieldId]); + const selectResult = selectBuilder.build(); + expect(selectResult.isOk()).toBe(true); + if (selectResult.isErr()) return; + + const builder = new UpdateFromSelectBuilder(db); + const updateResult = builder.build({ + table, + fieldIds: [deletedFieldId], + selectQuery: selectResult.value, + }); + + expect(updateResult.isOk()).toBe(true); + if (updateResult.isErr()) return; + expect(updateResult.value.sql).toBe('select 1 where false'); + }); + it('can omit __version increment for externally versioned field chunks', () => { const db = createTestDb(); const { table, formulaFieldId } = createFormulaTable(); @@ -510,7 +562,7 @@ describe('UpdateFromSelectBuilder', () => { WHEN BTRIM(("c_src"."col_score")::text) ~ '^[+-]?([0-9]+([.][0-9]+)?|[.][0-9]+)([eE][+-]?[0-9]+)?$' THEN BTRIM(("c_src"."col_score")::text)::double precision ELSE NULL - END as "__set_col_score" from (select "t"."__id" as "__id", "t"."__version" as "__version", NULLIF(BTRIM((1)::text), '')::double precision as "col_score" from "bseaaaaaaaaaaaaaaaa"."tblbbbbbbbbbbbbbbbb" as "t" inner join "tmp_computed_dirty" as "__dirty" on "t"."__id" = "__dirty"."record_id" and "__dirty"."table_id" = $1) as "c_src") as "c" where "u"."__id" = "c"."__id" and ("u"."col_score" IS DISTINCT FROM "c"."__set_col_score")" + END as "__set_col_score" from (select "t"."__id" as "__id", "t"."__version" as "__version", NULLIF(BTRIM((1)::text), '')::double precision as "col_score" from "bseaaaaaaaaaaaaaaaa"."tblbbbbbbbbbbbbbbbb" as "t" inner join "tmp_computed_dirty" as "__dirty" on "t"."__id" = "__dirty"."record_id" and "__dirty"."table_id" = $1) as "c_src") as "c" where "u"."__id" = "c"."__id" and (("u"."col_score")::double precision IS DISTINCT FROM ("c"."__set_col_score")::double precision)" ` ); }); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedOutboxWakeup.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedOutboxWakeup.ts index 597fdc3adf..48ad6851e5 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedOutboxWakeup.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedOutboxWakeup.ts @@ -13,6 +13,10 @@ export type ComputedOutboxWakeup = Readonly<{ availableAt: Date; emittedAt: Date; cause: ComputedOutboxWakeupCause; + /** W3C traceparent captured at enqueue time so the worker stays in the write trace. */ + traceparent?: string; + /** Optional W3C tracestate paired with traceparent. */ + tracestate?: string; }>; export type ComputedOutboxWakeupPublishOutcome = { status: 'accepted' } | { status: 'disabled' }; @@ -40,6 +44,8 @@ export const createComputedOutboxWakeup = (params: { baseId: string; availableAt?: Date; cause: ComputedOutboxWakeupCause; + traceparent?: string; + tracestate?: string; }): ComputedOutboxWakeup => ({ schemaVersion: 1, wakeupId: params.wakeupId ?? generatePrefixedId(WAKEUP_ID_PREFIX, WAKEUP_ID_BODY_LENGTH), @@ -48,4 +54,6 @@ export const createComputedOutboxWakeup = (params: { availableAt: params.availableAt ?? new Date(), emittedAt: new Date(), cause: params.cause, + ...(params.traceparent ? { traceparent: params.traceparent } : {}), + ...(params.tracestate ? { tracestate: params.tracestate } : {}), }); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedUpdateOutbox.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedUpdateOutbox.ts index 19c60d2355..3b6e22393a 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedUpdateOutbox.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedUpdateOutbox.ts @@ -80,7 +80,31 @@ import type { const OUTBOX_TABLE = 'computed_update_outbox'; const OUTBOX_SEED_TABLE = 'computed_update_outbox_seed'; const DEAD_LETTER_TABLE = 'computed_update_dead_letter'; +const STAGE_LEDGER_TABLE = 'computed_update_stage_ledger'; const PENDING_SEED_UNIQUE_INDEX = 'computed_update_outbox_pending_unique_idx'; +const SPACE_DATA_DB_BINDING_TABLE = 'space_data_db_binding'; + +/** + * The shared container (data db === meta db) claims from the instance-wide + * outbox. Once a space is bound to an external data database, the physical + * tables the shared container can reach are an orphaned pre-switch copy — + * executing that space's tasks here would read and write stale data. Fence + * them out entirely; the space's own container serves its outbox. + */ +export const buildComputedTaskNotForeignBoundCondition = ( + eb: ExpressionBuilder, + alias: string +) => + eb.not( + eb.exists( + eb + .selectFrom(`${SPACE_DATA_DB_BINDING_TABLE} as sdb`) + .innerJoin('base as fbb', (join) => join.onRef('fbb.id', '=', `${alias}.base_id`)) + .select(sql`1`.as('one')) + .whereRef('sdb.space_id', '=', 'fbb.space_id') + .where('sdb.mode', '!=', 'default') + ) + ); const DEFAULT_STATUS = 'pending'; const OUTBOX_ID_PREFIX = 'cuo'; @@ -398,34 +422,73 @@ export class ComputedUpdateOutbox implements IComputedUpdateOutbox { }, context?: IExecutionContext ): Promise { - const wakeup = createComputedOutboxWakeup(params); - const publishSafely = async () => { - try { - const outcome = await this.wakeupPublisher.publish(wakeup); - if (outcome.status === 'disabled') return; - this.logger.debug('computed:outbox:wakeup_published', { - taskId: wakeup.taskId, - baseId: wakeup.baseId, - wakeupId: wakeup.wakeupId, - availableAt: wakeup.availableAt, - cause: wakeup.cause, - }); - } catch (error) { - this.wakeupPublisher.recordSkip?.('publish_failed'); - this.logger.warn('computed:outbox:wakeup_publish_failed', { - taskId: wakeup.taskId, - baseId: wakeup.baseId, - wakeupId: wakeup.wakeupId, - cause: wakeup.cause, - ...toErrorLogFields(error), + // Capture the write-path parent before afterCommit so the worker can join the same trace. + const carrier = context?.tracer?.capturePropagationCarrier?.(); + const wakeup = createComputedOutboxWakeup({ + ...params, + ...(carrier?.traceparent ? { traceparent: carrier.traceparent } : {}), + ...(carrier?.tracestate ? { tracestate: carrier.tracestate } : {}), + }); + + const publishSafely = async (afterCommit: boolean) => { + const run = async () => { + const span = context?.tracer?.startSpan('teable.outbox.scheduleWakeup.publish', { + 'outbox.taskId': wakeup.taskId, + 'outbox.baseId': wakeup.baseId, + 'outbox.wakeupId': wakeup.wakeupId, + 'outbox.wakeupCause': wakeup.cause, + 'outbox.hasTraceparent': Boolean(wakeup.traceparent), + 'outbox.afterCommit': afterCommit, }); + const publishWork = async () => { + try { + const outcome = await this.wakeupPublisher.publish(wakeup); + span?.setAttribute('outbox.publishStatus', outcome.status); + if (outcome.status === 'disabled') return; + this.logger.debug('computed:outbox:wakeup_published', { + taskId: wakeup.taskId, + baseId: wakeup.baseId, + wakeupId: wakeup.wakeupId, + availableAt: wakeup.availableAt, + cause: wakeup.cause, + hasTraceparent: Boolean(wakeup.traceparent), + }); + } catch (error) { + span?.recordError(error instanceof Error ? error.message : String(error)); + this.wakeupPublisher.recordSkip?.('publish_failed'); + this.logger.warn('computed:outbox:wakeup_publish_failed', { + taskId: wakeup.taskId, + baseId: wakeup.baseId, + wakeupId: wakeup.wakeupId, + cause: wakeup.cause, + ...toErrorLogFields(error), + }); + } + }; + + try { + if (span && context?.tracer) { + await context.tracer.withSpan(span, publishWork); + } else { + await publishWork(); + } + } finally { + span?.end(); + } + }; + + // afterCommit may leave the request ALS; re-enter the captured parent when available. + if (carrier && context?.tracer?.runWithPropagationCarrier) { + await context.tracer.runWithPropagationCarrier(carrier, run); + return; } + await run(); }; const transaction = getUnitOfWorkTransaction(context, 'data'); if (transaction) { if (transaction.afterCommit) { - transaction.afterCommit(publishSafely); + transaction.afterCommit(() => publishSafely(true)); } else { this.wakeupPublisher.recordSkip?.('no_after_commit'); this.logger.warn('computed:outbox:wakeup_skipped_without_after_commit_hook', { @@ -437,7 +500,8 @@ export class ComputedUpdateOutbox implements IComputedUpdateOutbox { } return; } - await publishSafely(); + + await publishSafely(false); } async enqueueOrMerge( @@ -970,11 +1034,14 @@ export class ComputedUpdateOutbox implements IComputedUpdateOutbox { .selectAll('o') .where('o.status', '=', 'processing') .where(sql`("locked_at" is null or "locked_at" <= ${reclaimBefore})`) - .where((eb) => - buildComputedTaskNotPausedCondition(eb, 'o', now, { + .where((eb) => { + const notPaused = buildComputedTaskNotPausedCondition(eb, 'o', now, { includeSpaceScope: includeSpaceScopeInSql, - }) - ) + }); + return includeSpaceScopeInSql + ? eb.and([notPaused, buildComputedTaskNotForeignBoundCondition(eb, 'o')]) + : notPaused; + }) .orderBy('locked_at', 'asc') .orderBy('created_at', 'asc') .limit(reclaimLimit) @@ -991,11 +1058,14 @@ export class ComputedUpdateOutbox implements IComputedUpdateOutbox { .selectAll('o') .where('o.status', '=', DEFAULT_STATUS) .where('o.next_run_at', '<=', now) - .where((eb) => - buildComputedTaskNotPausedCondition(eb, 'o', now, { + .where((eb) => { + const notPaused = buildComputedTaskNotPausedCondition(eb, 'o', now, { includeSpaceScope: includeSpaceScopeInSql, - }) - ) + }); + return includeSpaceScopeInSql + ? eb.and([notPaused, buildComputedTaskNotForeignBoundCondition(eb, 'o')]) + : notPaused; + }) .where((eb) => buildProcessingConcurrencyCondition(eb, 'o', reclaimBefore, this.config) ) @@ -1394,6 +1464,12 @@ export class ComputedUpdateOutbox implements IComputedUpdateOutbox { } } + if (this.db === this.metaDb && (await this.isTaskForeignBound(db, row))) { + // Same indefinite parking as a permanent pause: this container must never + // execute a task whose space's data lives in an external database. + return { status: 'deferred', reason: 'paused', retryAt: null }; + } + const pauseRetryAt = await this.getPauseRetryAt(db, row, now, context); if (pauseRetryAt !== undefined) { return { status: 'deferred', reason: 'paused', retryAt: pauseRetryAt }; @@ -1407,6 +1483,25 @@ export class ComputedUpdateOutbox implements IComputedUpdateOutbox { return null; } + private async isTaskForeignBound( + db: Kysely | Transaction, + row: OutboxRow + ): Promise { + const base = (await db + .selectFrom('base') + .select('space_id') + .where('id', '=', String(row.base_id)) + .executeTakeFirst()) as { space_id: string | null } | undefined; + if (base?.space_id == null) return false; + const bound = await db + .selectFrom(SPACE_DATA_DB_BINDING_TABLE) + .select(sql`1`.as('one')) + .where('space_id', '=', String(base.space_id)) + .where('mode', '!=', 'default') + .executeTakeFirst(); + return bound != null; + } + private async getPauseRetryAt( db: Kysely | Transaction, row: OutboxRow, @@ -1755,9 +1850,7 @@ export class ComputedUpdateOutbox implements IComputedUpdateOutbox { .updateTable(OUTBOX_TABLE) .set({ status: DEFAULT_STATUS, - ...(mergeLockAcquired - ? {} - : { plan_hash: uniquifyPlanHash(params.task.planHash) }), + ...(mergeLockAcquired ? {} : { plan_hash: uniquifyPlanHash(params.task.planHash) }), next_run_at: nextRunAt, last_error: params.reason, locked_at: null, @@ -1909,6 +2002,18 @@ export class ComputedUpdateOutbox implements IComputedUpdateOutbox { await trx.deleteFrom(OUTBOX_TABLE).where('id', '=', task.id).execute(); await trx.deleteFrom(OUTBOX_SEED_TABLE).where('task_id', '=', task.id).execute(); + // A dead-lettered task ends its continuation chain: drop the chain's + // durable stage-ledger state (scope = chain root task id; a task + // that never staged has no rows and this is a no-op). + await trx + .deleteFrom(STAGE_LEDGER_TABLE) + .where( + 'scope_id', + '=', + (!isBackfill && !isSeed && (task as ComputedUpdateOutboxItem).ledgerScopeId) || + task.id + ) + .execute(); const terminalActivity = await this.activityProjector.onTaskFailed( { @@ -2050,7 +2155,13 @@ export class ComputedUpdateOutbox implements IComputedUpdateOutbox { dirtyStats: task.dirtyStats, beforeImageRecords: task.beforeImageRecords, seedAllTableIds: seedAllTableIds.length > 0 ? seedAllTableIds : undefined, + sameTableBatches: task.sameTableBatches, orchestration: task.orchestration, + seedAllCursors: + task.seedAllCursors && Object.keys(task.seedAllCursors).length > 0 + ? task.seedAllCursors + : undefined, + ledgerScopeId: task.ledgerScopeId, }), run_id: task.runId, origin_run_ids: task.originRunIds, @@ -2132,6 +2243,14 @@ export class ComputedUpdateOutbox implements IComputedUpdateOutbox { seedAllTableIds: seedAllTableIds && seedAllTableIds.length > 0 ? seedAllTableIds : undefined, orchestration: mergedOrchestration, + sameTableBatches: task.sameTableBatches ?? parseSameTableBatchDtos(existing.dirty_stats), + // Same-hash tasks describe the same run state; keep the furthest + // per-table cursor so a retry-merge never rewinds seeding progress. + seedAllCursors: mergeSeedAllCursors( + parseSeedAllCursors(existing.dirty_stats), + task.seedAllCursors + ), + ledgerScopeId: task.ledgerScopeId ?? parseLedgerScopeId(existing.dirty_stats), }), run_id: mergedRunId, origin_run_ids: mergedOriginRunIds, @@ -2563,12 +2682,15 @@ const toOutboxItem = ( extraSeedRecords, beforeImageRecords: parseBeforeImageRecordDtos(row.dirty_stats), steps: parseJsonArray(row.steps) ?? [], + sameTableBatches: parseSameTableBatchDtos(row.dirty_stats), edges: parseJsonArray(row.edges) ?? [], estimatedComplexity: Number(row.estimated_complexity ?? 0), changeType: String(row.change_type) as ComputedUpdateOutboxItem['changeType'], planHash: String(row.plan_hash), dirtyStats: parseDirtyStats(row.dirty_stats), seedAllTableIds: parseSeedAllTableIds(row.dirty_stats), + seedAllCursors: parseSeedAllCursors(row.dirty_stats), + ledgerScopeId: parseLedgerScopeId(row.dirty_stats), orchestration: parseRealtimeOrchestration(row.dirty_stats), runId: String(row.run_id ?? ''), originRunIds: parseStringArray(row.origin_run_ids), @@ -2669,6 +2791,50 @@ const parseBeforeImageRecordDtos = ( ); }; +const parseSameTableBatchDtos = (value: unknown): ComputedUpdateOutboxItem['sameTableBatches'] => { + const parsed = parseJsonValue(value); + if (Array.isArray(parsed) || parsed == null || typeof parsed !== 'object') return undefined; + const raw = (parsed as { sameTableBatches?: unknown }).sameTableBatches; + if (!Array.isArray(raw)) return undefined; + return raw as ComputedUpdateOutboxItem['sameTableBatches']; +}; + +/** + * Keep the furthest per-table cursor (cursors are ascending record-id + * watermarks) so a retry-merge never rewinds whole-table seeding progress. + */ +const mergeSeedAllCursors = ( + existing: Record | undefined, + incoming: Readonly> | undefined +): Record | undefined => { + if (!existing && !incoming) return undefined; + const merged: Record = { ...(existing ?? {}) }; + for (const [tableId, cursor] of Object.entries(incoming ?? {})) { + const current = merged[tableId]; + merged[tableId] = current === undefined || cursor > current ? cursor : current; + } + return Object.keys(merged).length > 0 ? merged : undefined; +}; + +const parseLedgerScopeId = (value: unknown): string | undefined => { + const parsed = parseJsonValue(value); + if (Array.isArray(parsed) || parsed == null || typeof parsed !== 'object') return undefined; + const raw = (parsed as { ledgerScopeId?: unknown }).ledgerScopeId; + return typeof raw === 'string' && raw.length > 0 ? raw : undefined; +}; + +const parseSeedAllCursors = (value: unknown): Record | undefined => { + const parsed = parseJsonValue(value); + if (Array.isArray(parsed) || parsed == null || typeof parsed !== 'object') return undefined; + const raw = (parsed as { seedAllCursors?: unknown }).seedAllCursors; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const cursors: Record = {}; + for (const [tableId, recordId] of Object.entries(raw as Record)) { + if (typeof recordId === 'string') cursors[tableId] = recordId; + } + return Object.keys(cursors).length > 0 ? cursors : undefined; +}; + const parseSeedAllTableIds = (value: unknown): string[] | undefined => { const parsed = parseJsonValue(value); if (Array.isArray(parsed) || parsed == null || typeof parsed !== 'object') return undefined; @@ -2822,6 +2988,7 @@ const computedOutboxItemToTaskInput = ( beforeImageRecords: task.beforeImageRecords, steps: task.steps, edges: task.edges, + sameTableBatches: task.sameTableBatches, estimatedComplexity: task.estimatedComplexity, changeType: task.changeType, runId: task.runId, @@ -2835,6 +3002,12 @@ const computedOutboxItemToTaskInput = ( affectedTableIds: task.affectedTableIds, affectedFieldIds: task.affectedFieldIds, syncMaxLevel: task.syncMaxLevel, + // Durable continuation state must survive a retry-merge into a same-hash + // pending task: whole-table seed markers, seeding cursors, and the stage + // ledger scope. Dropping any of them would silently rewind or lose progress. + seedAllTableIds: task.seedAllTableIds, + seedAllCursors: task.seedAllCursors, + ledgerScopeId: task.ledgerScopeId, }); const seedOutboxItemToTaskInput = (task: SeedOutboxItem): ComputedUpdateSeedTaskInput => ({ @@ -3332,6 +3505,7 @@ const buildDeadLetterValues = ( dirtyStats: computedTask.dirtyStats, beforeImageRecords: computedTask.beforeImageRecords, orchestration: computedTask.orchestration, + sameTableBatches: computedTask.sameTableBatches, }), origin_run_ids: computedTask.originRunIds, run_total_steps: computedTask.runTotalSteps, diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedUpdateOutboxPayload.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedUpdateOutboxPayload.spec.ts index 70bbb606fb..2114b28cd7 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedUpdateOutboxPayload.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedUpdateOutboxPayload.spec.ts @@ -13,6 +13,8 @@ const BASE_ID = `bse${'a'.repeat(16)}`; const TABLE_ID = `tbl${'b'.repeat(16)}`; const EXTRA_TABLE_ID = `tbl${'x'.repeat(16)}`; const FIELD_ID = `fld${'c'.repeat(16)}`; +const SECOND_FIELD_ID = `fld${'f'.repeat(16)}`; +const THIRD_FIELD_ID = `fld${'g'.repeat(16)}`; const RECORD_ID = `rec${'d'.repeat(16)}`; const EXTRA_RECORD_ID = `rec${'e'.repeat(16)}`; const RUN_ID = `cur${'r'.repeat(16)}`; @@ -35,14 +37,93 @@ const createPlan = (): ComputedUpdatePlan => ({ fieldIds: [FieldId.create(FIELD_ID)._unsafeUnwrap()], level: 0, }, + { + tableId: TableId.create(TABLE_ID)._unsafeUnwrap(), + fieldIds: [FieldId.create(SECOND_FIELD_ID)._unsafeUnwrap()], + level: 1, + }, ], edges: [], estimatedComplexity: 1, changeType: 'update', - sameTableBatches: [], + sameTableBatches: [ + { + tableId: TableId.create(TABLE_ID)._unsafeUnwrap(), + steps: [ + { + tableId: TableId.create(TABLE_ID)._unsafeUnwrap(), + fieldIds: [FieldId.create(FIELD_ID)._unsafeUnwrap()], + level: 0, + }, + { + tableId: TableId.create(TABLE_ID)._unsafeUnwrap(), + fieldIds: [FieldId.create(SECOND_FIELD_ID)._unsafeUnwrap()], + level: 1, + }, + ], + minLevel: 0, + maxLevel: 1, + }, + ], }); describe('ComputedUpdateOutboxPayload', () => { + it('uses step outputs without refeeding edge targets when steps exist', () => { + const plan = { + ...createPlan(), + edges: [ + { + fromFieldId: FieldId.create(FIELD_ID)._unsafeUnwrap(), + toFieldId: FieldId.create(THIRD_FIELD_ID)._unsafeUnwrap(), + fromTableId: TableId.create(TABLE_ID)._unsafeUnwrap(), + toTableId: TableId.create(EXTRA_TABLE_ID)._unsafeUnwrap(), + propagationMode: 'linkTraversal' as const, + order: 0, + }, + ], + } satisfies ComputedUpdatePlan; + const task = buildOutboxTaskInput({ + plan, + syncMaxLevel: 0, + hasher: testHasher, + runId: RUN_ID, + originRunIds: [RUN_ID], + runTotalSteps: plan.steps.length, + runCompletedStepsBefore: 0, + }); + + expect(task.affectedFieldIds).toEqual([FIELD_ID, SECOND_FIELD_ID]); + }); + + it('tracks edge target tables for edge-only tasks', () => { + const plan = { + ...createPlan(), + steps: [], + sameTableBatches: [], + edges: [ + { + fromFieldId: FieldId.create(FIELD_ID)._unsafeUnwrap(), + toFieldId: FieldId.create(SECOND_FIELD_ID)._unsafeUnwrap(), + fromTableId: TableId.create(TABLE_ID)._unsafeUnwrap(), + toTableId: TableId.create(EXTRA_TABLE_ID)._unsafeUnwrap(), + propagationMode: 'linkTraversal' as const, + order: 0, + }, + ], + } satisfies ComputedUpdatePlan; + const task = buildOutboxTaskInput({ + plan, + syncMaxLevel: 0, + hasher: testHasher, + runId: RUN_ID, + originRunIds: [RUN_ID], + runTotalSteps: 0, + runCompletedStepsBefore: 0, + }); + + expect(task.affectedTableIds).toContain(EXTRA_TABLE_ID); + }); + it('preserves the earliest before-image values when merging DTOs', () => { const merged = mergeBeforeImageRecordDtos( [ @@ -120,6 +201,7 @@ describe('ComputedUpdateOutboxPayload', () => { seedRecordIds: task.seedRecordIds, extraSeedRecords: task.extraSeedRecords, steps: task.steps, + sameTableBatches: task.sameTableBatches, edges: task.edges, estimatedComplexity: task.estimatedComplexity, changeType: task.changeType, @@ -132,6 +214,16 @@ describe('ComputedUpdateOutboxPayload', () => { expect(deserialized.value.seedTableId.toString()).toBe(TABLE_ID); expect(deserialized.value.seedRecordIds[0].toString()).toBe(RECORD_ID); expect(deserialized.value.steps[0].fieldIds[0].toString()).toBe(FIELD_ID); + expect(deserialized.value.sameTableBatches).toHaveLength(1); + expect( + deserialized.value.sameTableBatches[0]?.steps.map((step) => ({ + level: step.level, + fieldIds: step.fieldIds.map((fieldId) => fieldId.toString()), + })) + ).toEqual([ + { level: 0, fieldIds: [FIELD_ID] }, + { level: 1, fieldIds: [SECOND_FIELD_ID] }, + ]); expect(deserialized.value.extraSeedRecords[0].tableId.toString()).toBe(EXTRA_TABLE_ID); expect(deserialized.value.extraSeedRecords[0].recordIds[0].toString()).toBe(EXTRA_RECORD_ID); expect(deserialized.value.edges[0]?.allTargetRecordsReasons).toEqual(['conditional_delete']); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedUpdateOutboxPayload.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedUpdateOutboxPayload.ts index 5e36da2572..d9dd86ca64 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedUpdateOutboxPayload.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedUpdateOutboxPayload.ts @@ -16,6 +16,7 @@ import type { ComputedBeforeImageRecord, ComputedDependencyEdge, ComputedUpdatePlan, + SameTableBatch, UpdateStep, } from '../ComputedUpdatePlanner'; import { isAllTargetRecordsReason } from '../ComputedUpdatePlanner'; @@ -26,9 +27,22 @@ export type ComputedUpdateStepDto = { level: number; }; +export type SameTableBatchDto = { + tableId: string; + steps: ComputedUpdateStepDto[]; + minLevel: number; + maxLevel: number; +}; + export type ComputedDependencyEdgeDto = { fromFieldId: string; toFieldId: string; + /** + * All target computed fields covered by this (deduplicated) propagation edge. + * Absent only on payloads serialized before this field existed; consumers must + * fall back to table-granular handling for such edges. + */ + propagationTargetFieldIds?: string[]; fromTableId: string; toTableId: string; linkFieldId?: string; @@ -100,6 +114,7 @@ export type ComputedUpdateOutboxPayload = { extraSeedRecords: ComputedUpdateSeedGroupDto[]; beforeImageRecords: ComputedBeforeImageRecordDto[]; steps: ComputedUpdateStepDto[]; + sameTableBatches?: SameTableBatchDto[]; edges: ComputedDependencyEdgeDto[]; estimatedComplexity: number; changeType: ComputedUpdatePlan['changeType']; @@ -111,6 +126,10 @@ export type ComputedUpdateOutboxPayload = { stageDepth?: number; /** Table IDs where ALL records should be seeded as dirty (avoids storing individual record IDs) */ seedAllTableIds?: string[]; + /** Whole-table seeding resume cursors (last seeded __id per table id). */ + seedAllCursors?: Record; + /** Stage-ledger scope (continuation chain root task id). */ + ledgerScopeId?: string; orchestration?: ComputedRealtimeOrchestrationDto; }; @@ -152,13 +171,30 @@ export const serializeComputedUpdatePlan = ( extraSeedRecords: serializeSeedGroups(plan.extraSeedRecords), beforeImageRecords: serializeBeforeImageRecords(plan.beforeImageRecords ?? []), steps: plan.steps.map(serializeStep), + sameTableBatches: plan.sameTableBatches.map(serializeSameTableBatch), edges: plan.edges.map(serializeEdge), estimatedComplexity: plan.estimatedComplexity, changeType: plan.changeType, seedAllTableIds: plan.seedAllTableIds?.map((id) => id.toString()), + seedAllCursors: + plan.seedAllCursors && Object.keys(plan.seedAllCursors).length > 0 + ? { ...plan.seedAllCursors } + : undefined, + ledgerScopeId: plan.ledgerScopeId, }; }; +/** + * Lineage-scoped idempotency key for stage continuations: same-shape continuations + * from different runs, stages, or predecessor tasks must never merge in the outbox, + * or run progress, retries, and activity attribution blur together. + */ +export const buildContinuationPlanHash = ( + basePlanHash: string, + lineage: { runId: string; stageIndex: number; predecessorTaskId: string } +): string => + `${basePlanHash}:run:${lineage.runId}:stage:${lineage.stageIndex}:from:${lineage.predecessorTaskId}`; + export const computePlanHash = (payload: ComputedUpdateOutboxPayload, hasher: IHasher): string => { const hashInput = { baseId: payload.baseId, @@ -186,10 +222,18 @@ export const buildOutboxTaskInput = (params: { }): ComputedUpdateOutboxTaskInput => { const payload = serializeComputedUpdatePlan(params.plan); const affectedTableIds = params.affectedTableIds ?? [ - ...new Set(payload.steps.map((step) => step.tableId)), + ...new Set([ + ...payload.steps.map((step) => step.tableId), + ...payload.edges.map((edge) => edge.toTableId), + ]), ]; + const stepOutputFieldIds = payload.steps.flatMap((step) => step.fieldIds); const affectedFieldIds = params.affectedFieldIds ?? [ - ...new Set(payload.steps.flatMap((step) => step.fieldIds)), + ...new Set( + stepOutputFieldIds.length > 0 + ? stepOutputFieldIds + : payload.edges.flatMap((edge) => edge.propagationTargetFieldIds ?? [edge.toFieldId]) + ), ]; return { @@ -279,6 +323,53 @@ export const deserializeComputedUpdatePlan = ( ); if (stepsResult.isErr()) return err(stepsResult.error); + const sameTableBatchesResult = (payload.sameTableBatches ?? []).reduce< + Result + >( + (acc, batch) => + acc.andThen((batches) => + TableId.create(batch.tableId).andThen((tableId) => { + const batchStepsResult = batch.steps.reduce>( + (stepAcc, step) => + stepAcc.andThen((steps) => + TableId.create(step.tableId) + .andThen((stepTableId) => + step.fieldIds + .reduce>( + (fieldAcc, fieldId) => + fieldAcc.andThen((fieldIds) => + FieldId.create(fieldId).map((id) => { + fieldIds.push(id); + return fieldIds; + }) + ), + ok([]) + ) + .map((fieldIds) => ({ tableId: stepTableId, fieldIds, level: step.level })) + ) + .map((resolved) => { + steps.push(resolved); + return steps; + }) + ), + ok([]) + ); + + return batchStepsResult.map((steps) => { + batches.push({ + tableId, + steps, + minLevel: batch.minLevel, + maxLevel: batch.maxLevel, + }); + return batches; + }); + }) + ), + ok([]) + ); + if (sameTableBatchesResult.isErr()) return err(sameTableBatchesResult.error); + const edgesResult = payload.edges.reduce>( (acc, edge) => acc.andThen((edges) => { @@ -332,11 +423,28 @@ export const deserializeComputedUpdatePlan = ( } const filterCondition = filterConditionResult?.value; + const targetFieldIdsResult = (edge.propagationTargetFieldIds ?? []).reduce< + Result + >( + (targetAcc, targetFieldId) => + targetAcc.andThen((targetIds) => + FieldId.create(targetFieldId).map((id) => { + targetIds.push(id); + return targetIds; + }) + ), + ok([]) + ); + if (targetFieldIdsResult.isErr()) return err(targetFieldIdsResult.error); + const propagationTargetFieldIds = + targetFieldIdsResult.value.length > 0 ? targetFieldIdsResult.value : undefined; + if (edge.linkFieldId) { return FieldId.create(edge.linkFieldId).map((linkFieldId) => ({ linkFieldId, fromFieldId, toFieldId, + propagationTargetFieldIds, fromTableId, toTableId, propagationMode: propagationMode ?? 'linkTraversal', @@ -349,6 +457,7 @@ export const deserializeComputedUpdatePlan = ( linkFieldId: undefined, fromFieldId, toFieldId, + propagationTargetFieldIds, fromTableId, toTableId, propagationMode: propagationMode ?? 'allTargetRecords', @@ -389,14 +498,17 @@ export const deserializeComputedUpdatePlan = ( edges: edgesResult.value, estimatedComplexity: payload.estimatedComplexity, changeType, - // Note: sameTableBatches are derived from steps at planning time, - // so we don't serialize/deserialize them. They will be empty for outbox tasks. - sameTableBatches: [], + sameTableBatches: sameTableBatchesResult.value, seedAllTableIds: payload.seedAllTableIds?.reduce((acc, id) => { const result = TableId.create(id); if (result.isOk()) acc.push(result.value); return acc; }, []), + seedAllCursors: + payload.seedAllCursors && Object.keys(payload.seedAllCursors).length > 0 + ? { ...payload.seedAllCursors } + : undefined, + ledgerScopeId: payload.ledgerScopeId, }); }; @@ -406,9 +518,23 @@ const serializeStep = (step: UpdateStep): ComputedUpdateStepDto => ({ level: step.level, }); +const serializeSameTableBatch = (batch: SameTableBatch): SameTableBatchDto => ({ + tableId: batch.tableId.toString(), + steps: batch.steps.map(serializeStep), + minLevel: batch.minLevel, + maxLevel: batch.maxLevel, +}); + const serializeEdge = (edge: ComputedDependencyEdge): ComputedDependencyEdgeDto => ({ fromFieldId: edge.fromFieldId.toString(), toFieldId: edge.toFieldId.toString(), + // Always emit targets (defaulting to toFieldId) so deserialized edges keep + // field-granular target info; presence marks the info as trustworthy. + propagationTargetFieldIds: [ + ...new Set( + (edge.propagationTargetFieldIds ?? [edge.toFieldId]).map((fieldId) => fieldId.toString()) + ), + ], fromTableId: edge.fromTableId.toString(), toTableId: edge.toTableId.toString(), linkFieldId: edge.linkFieldId?.toString(), diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/IComputedUpdateOutbox.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/IComputedUpdateOutbox.ts index 54fcb19b11..0f35d995b1 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/IComputedUpdateOutbox.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/IComputedUpdateOutbox.ts @@ -14,6 +14,17 @@ import type { import type { ComputedUpdateSeedTaskInput } from './ComputedUpdateSeedPayload'; import type { FieldBackfillOutboxTaskInput } from './FieldBackfillOutboxPayload'; +/** + * ROLLOUT NOTE: the stage-budget dimensions are ON by default (no runtime + * toggle — a deliberate product decision to keep the fix active everywhere). + * The FIRST upgrade from a pre-stage version has a one-time mixed-fleet + * window: a pre-stage worker that claims a ledger continuation (empty seed + * set + ledgerScopeId) misreads it as a schema-update whole-table recompute + * and runs it as one unbounded transaction. Values converge (whole-table + * recompute is idempotent) but the transaction is the incident-class size, + * so schedule that first upgrade all-at-once or in a quiet window. Upgrades + * between stage-aware versions have no such window. + */ export type ComputedUpdateOutboxConfig = { /** Inline seed storage limit before spilling to computed_update_outbox_seed. */ seedInlineLimit: number; @@ -71,6 +82,53 @@ export type ComputedUpdateOutboxConfig = { * A value of 0 disables the database-side timeout. */ taskStatementTimeoutMs: number; + /** + * Maximum dependency-plan steps executed per worker transaction. Plans above the + * budget run as a level-ordered prefix; remaining steps continue in a follow-up + * outbox task committed atomically with the current stage. 0 disables staging. + */ + stageMaxSteps: number; + /** + * Maximum computed fields (summed across steps) executed per worker transaction. + * 0 disables the field budget. + */ + stageMaxFields: number; + /** + * Maximum dirty-propagation edges evaluated per worker transaction. Under a + * dirty budget each edge runs as its own bounded statement; this caps how many + * such statements one stage executes. 0 disables the edge budget. + */ + stageMaxEdges: number; + /** + * Maximum dirty records (seeds + propagated) materialized per stage transaction. + * Propagation stops at a generation boundary once the running total exceeds this; + * the worker then retries the stage with half as many steps until it fits, so the + * static step/field/edge budgets get a data-driven hard backstop. A stage already + * reduced to a single step record-batches instead: it executes the bounded batch + * and continues with processed targets excluded until propagation completes. + * Full-table (seed-all / schema-update) seeding, the stage-ledger frontier + * queue, and explicit seeds all count against this budget: partial (floor) + * batches migrate seeds into the queue and share one seeding+propagation + * pool; abort-mode stages refuse to materialize a seed set that does not + * fit and shrink to the floor instead. Every budgeted transaction therefore + * materializes at most this many dirty rows (+1 abort-probe sentinel row, + * discarded with the aborted attempt) — the transaction-level hard cap. + * Values of 1 are clamped to 2 so both pools keep a slot. 0 disables. + */ + stageMaxDirtyRecords: number; + /** + * Hard cap on exact seed record ids a completed stage may fetch into JS (and + * hence carry into follow-up planning) across ALL its tables. Tables whose + * union of batch outputs and exclusion ledger would push past the cap are + * collected as whole-table seeds instead, so stage-completion memory stays + * bounded even when many tables sit just under stageSeedAllThreshold. + */ + stageMaxCollectedSeedIds: number; + /** + * Dirty-row count per table above which stage continuations switch from explicit + * record ids to a seed-all representation. 0 falls back to the built-in default. + */ + stageSeedAllThreshold: number; }; export const defaultComputedUpdateOutboxConfig: ComputedUpdateOutboxConfig = { @@ -90,6 +148,14 @@ export const defaultComputedUpdateOutboxConfig: ComputedUpdateOutboxConfig = { maxConcurrentProcessingPerBase: 2, maxConcurrentProcessingPerSeedTable: 2, taskStatementTimeoutMs: 60 * 1000, + // Wide dependency graphs (hub tables with hundreds of computed fields) must not run + // as one transaction on small BYODB instances; bound each stage and continue via outbox. + stageMaxSteps: 10, + stageMaxFields: 32, + stageMaxEdges: 12, + stageMaxDirtyRecords: 5000, + stageMaxCollectedSeedIds: 25_000, + stageSeedAllThreshold: 5000, }; export const normalizeComputedUpdateOutboxConfig = ( @@ -115,6 +181,16 @@ export const normalizeComputedUpdateOutboxConfig = ( Math.trunc(config.maxConcurrentProcessingPerSeedTable) ), taskStatementTimeoutMs: Math.max(0, Math.trunc(config.taskStatementTimeoutMs)), + stageMaxSteps: Math.max(0, Math.trunc(config.stageMaxSteps)), + stageMaxFields: Math.max(0, Math.trunc(config.stageMaxFields)), + stageMaxEdges: Math.max(0, Math.trunc(config.stageMaxEdges)), + stageMaxDirtyRecords: (() => { + const value = Math.max(0, Math.trunc(config.stageMaxDirtyRecords)); + // A 1-row budget cannot give both the seeding and propagation pools a slot. + return value === 1 ? 2 : value; + })(), + stageMaxCollectedSeedIds: Math.max(1, Math.trunc(config.stageMaxCollectedSeedIds)), + stageSeedAllThreshold: Math.max(0, Math.trunc(config.stageSeedAllThreshold)), }; }; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/__tests__/ComputedUpdateOutbox.deadlock.pglite.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/__tests__/ComputedUpdateOutbox.deadlock.pglite.spec.ts index 667bff3cc7..11a70d2c29 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/__tests__/ComputedUpdateOutbox.deadlock.pglite.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/__tests__/ComputedUpdateOutbox.deadlock.pglite.spec.ts @@ -291,6 +291,15 @@ describe('ComputedUpdateOutbox deadlock (pglite integration)', () => { .addColumn('name', 'text') .execute(); + await db.schema + .createTable('space_data_db_binding') + .ifNotExists() + .addColumn('id', 'text', (col) => col.primaryKey()) + .addColumn('space_id', 'text', (col) => col.notNull()) + .addColumn('mode', 'text', (col) => col.notNull()) + .addColumn('state', 'text', (col) => col.notNull()) + .execute(); + await db.schema .createTable('table_meta') .ifNotExists() @@ -351,6 +360,22 @@ describe('ComputedUpdateOutbox deadlock (pglite integration)', () => { ON "computed_update_outbox_seed"("task_id", "table_id", "record_id") `.execute(db); + await db.schema + .createTable('computed_update_stage_ledger') + .ifNotExists() + .addColumn('scope_id', 'text', (col) => col.notNull()) + .addColumn('kind', 'text', (col) => col.notNull()) + .addColumn('table_id', 'text', (col) => col.notNull()) + .addColumn('record_id', 'text', (col) => col.notNull()) + .addColumn('seq', 'bigint', (col) => col.notNull().defaultTo(0)) + .addPrimaryKeyConstraint('computed_update_stage_ledger_pkey', [ + 'scope_id', + 'kind', + 'table_id', + 'record_id', + ]) + .execute(); + await sql` CREATE UNIQUE INDEX IF NOT EXISTS "computed_update_outbox_pending_unique_idx" ON "computed_update_outbox"("base_id", "seed_table_id", "plan_hash", "change_type") @@ -381,6 +406,7 @@ describe('ComputedUpdateOutbox deadlock (pglite integration)', () => { await db.deleteFrom('computed_update_pause_scope').execute(); await db.deleteFrom('computed_update_outbox_seed').execute(); await db.deleteFrom('computed_update_outbox').execute(); + await db.deleteFrom('space_data_db_binding').execute(); await db.deleteFrom('table_meta').execute(); await db.deleteFrom('base').execute(); await db.deleteFrom('space').execute(); @@ -430,6 +456,63 @@ describe('ComputedUpdateOutbox deadlock (pglite integration)', () => { expect(claimed._unsafeUnwrap()?.id).toBe('cuo-due-by-id'); }); + it('fences out tasks whose space is bound to an external data database', async () => { + const now = new Date('2026-01-05T12:00:00Z'); + await db + .insertInto('space_data_db_binding') + .values({ id: 'sdbforeign1', space_id: PRIMARY_SPACE_ID, mode: 'byodb', state: 'ready' }) + .execute(); + await insertOutboxRow(db, { + id: 'cuo-foreign-bound', + status: 'pending', + baseId: PRIMARY_BASE_ID, + nextRunAt: now, + }); + await insertOutboxRow(db, { + id: 'cuo-locally-bound', + status: 'pending', + baseId: SECONDARY_BASE_ID, + seedTableId: SECONDARY_SEED_TABLE_ID, + nextRunAt: now, + }); + + const outbox = createTestOutbox(db); + const batch = await outbox.claimBatch({ workerId: 'queue-worker', limit: 10, now }); + expect(batch.isOk()).toBe(true); + expect(batch._unsafeUnwrap().map((task) => task.id)).toEqual(['cuo-locally-bound']); + + const byId = await outbox.claimById({ + taskId: 'cuo-foreign-bound', + workerId: 'queue-worker', + now, + }); + expect(byId.isOk()).toBe(true); + expect(byId._unsafeUnwrap()).toBeNull(); + }); + + it('still claims tasks whose space has a default-mode binding row', async () => { + const now = new Date('2026-01-05T12:00:00Z'); + await db + .insertInto('space_data_db_binding') + .values({ id: 'sdbdefault1', space_id: PRIMARY_SPACE_ID, mode: 'default', state: 'ready' }) + .execute(); + await insertOutboxRow(db, { + id: 'cuo-default-bound', + status: 'pending', + baseId: PRIMARY_BASE_ID, + nextRunAt: now, + }); + + const outbox = createTestOutbox(db); + const claimed = await outbox.claimById({ + taskId: 'cuo-default-bound', + workerId: 'queue-worker', + now, + }); + expect(claimed.isOk()).toBe(true); + expect(claimed._unsafeUnwrap()?.id).toBe('cuo-default-bound'); + }); + it('does not take over an active processing task by default', async () => { const now = new Date('2026-01-05T12:00:00Z'); await insertOutboxRow(db, { @@ -1057,6 +1140,96 @@ describe('ComputedUpdateOutbox deadlock (pglite integration)', () => { ]); }); + it('preserves durable staging state when a computed retry merges into a pending task', async () => { + const now = new Date('2026-01-05T12:00:20Z'); + const planHash = 'same-computed-plan'; + const cursorRecordId = `rec${'p'.repeat(16)}`; + const leaseOwner = 'worker-old:cuc_old2'; + + await insertOutboxRow(db, { + id: 'cuo-pending-comp', + status: 'pending', + planHash, + seedRecordIds: [createRecordId(3).toString()], + dirtyStats: { dirtyStats: [] }, + createdAt: new Date(now.getTime() - 10_000), + updatedAt: new Date(now.getTime() - 10_000), + }); + await insertOutboxRow(db, { + id: 'cuo-processing-comp', + status: 'processing', + planHash, + seedRecordIds: [createRecordId(4).toString()], + dirtyStats: { dirtyStats: [] }, + lockedAt: new Date(now.getTime() - 100), + lockedBy: leaseOwner, + createdAt: new Date(now.getTime() - 1_000), + updatedAt: new Date(now.getTime() - 100), + }); + + const publisher = new RecordingWakeupPublisher(); + const outbox = createTestOutbox(db, publisher); + const task = { + id: 'cuo-processing-comp', + baseId: PRIMARY_BASE_ID, + seedTableId: PRIMARY_SEED_TABLE_ID, + seedRecordIds: [createRecordId(4).toString()], + extraSeedRecords: [], + beforeImageRecords: [], + steps: [], + sameTableBatches: [], + edges: [], + estimatedComplexity: 1, + changeType: 'update' as const, + planHash, + dirtyStats: [], + // Durable staging state that a retry-merge must not drop. + seedAllTableIds: [PRIMARY_SEED_TABLE_ID], + seedAllCursors: { [PRIMARY_SEED_TABLE_ID]: cursorRecordId }, + ledgerScopeId: 'cuo-chain-root', + runId: 'run-processing-comp', + originRunIds: [], + runTotalSteps: 1, + runCompletedStepsBefore: 0, + affectedTableIds: [PRIMARY_SEED_TABLE_ID], + affectedFieldIds: [], + syncMaxLevel: 0, + status: 'processing' as const, + attempts: 0, + maxAttempts: 8, + nextRunAt: now, + lockedAt: new Date(now.getTime() - 100), + lockedBy: leaseOwner, + lastError: null, + createdAt: new Date(now.getTime() - 1_000), + updatedAt: new Date(now.getTime() - 100), + }; + + const released = await outbox.releaseForRetry({ + task: task as never, + reason: 'lock unavailable', + retryDelayMs: 0, + now, + }); + + expect(released.isOk()).toBe(true); + expect(released._unsafeUnwrap()).toBe(true); + + const rows = await db + .selectFrom('computed_update_outbox') + .select(['id', 'status', 'dirty_stats']) + .execute(); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe('cuo-pending-comp'); + const envelope = + typeof rows[0].dirty_stats === 'string' + ? JSON.parse(rows[0].dirty_stats) + : (rows[0].dirty_stats as Record); + expect(envelope.seedAllTableIds).toEqual([PRIMARY_SEED_TABLE_ID]); + expect(envelope.seedAllCursors).toEqual({ [PRIMARY_SEED_TABLE_ID]: cursorRecordId }); + expect(envelope.ledgerScopeId).toBe('cuo-chain-root'); + }); + it('reclaims stale processing tasks after the lease expires', async () => { const now = new Date('2026-01-05T12:00:10Z'); await insertOutboxRow(db, { diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/__tests__/ComputedUpdateOutbox.nonblocking.pg.integration.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/__tests__/ComputedUpdateOutbox.nonblocking.pg.integration.spec.ts index 1cc092940a..d5d912a45c 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/__tests__/ComputedUpdateOutbox.nonblocking.pg.integration.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/__tests__/ComputedUpdateOutbox.nonblocking.pg.integration.spec.ts @@ -65,6 +65,14 @@ const createTaskInput = (planHash: string): ComputedUpdateOutboxTaskInput => ({ extraSeedRecords: [], beforeImageRecords: [], steps: [{ level: 0, tableId: SEED_TABLE_ID, fieldIds: [FIELD_ID] }], + sameTableBatches: [ + { + tableId: SEED_TABLE_ID, + steps: [{ level: 0, tableId: SEED_TABLE_ID, fieldIds: [FIELD_ID] }], + minLevel: 0, + maxLevel: 0, + }, + ], edges: [], estimatedComplexity: 1, changeType: 'update', @@ -198,6 +206,22 @@ describeNonBlocking('ComputedUpdateOutbox non-blocking locks (pg integration)', CREATE UNIQUE INDEX "computed_update_outbox_seed_task_id_table_id_record_id_key" ON "computed_update_outbox_seed"("task_id", "table_id", "record_id") `.execute(db); + + await db.schema + .createTable('computed_update_stage_ledger') + .ifNotExists() + .addColumn('scope_id', 'text', (col) => col.notNull()) + .addColumn('kind', 'text', (col) => col.notNull()) + .addColumn('table_id', 'text', (col) => col.notNull()) + .addColumn('record_id', 'text', (col) => col.notNull()) + .addColumn('seq', 'bigint', (col) => col.notNull().defaultTo(0)) + .addPrimaryKeyConstraint('computed_update_stage_ledger_pkey', [ + 'scope_id', + 'kind', + 'table_id', + 'record_id', + ]) + .execute(); await sql` CREATE UNIQUE INDEX "computed_update_outbox_pending_unique_idx" ON "computed_update_outbox"("base_id", "seed_table_id", "plan_hash", "change_type") @@ -255,6 +279,10 @@ describeNonBlocking('ComputedUpdateOutbox non-blocking locks (pg integration)', expect(unwrap(first).merged).toBe(false); expect(unwrap(second).merged).toBe(true); expect(unwrap(second).taskId).toBe(unwrap(first).taskId); + const claimed = await outbox.claimBatch({ workerId: 'merge-worker', limit: 10 }); + expect(unwrap(claimed)[0]?.sameTableBatches).toEqual( + createTaskInput('plan-free').sameTableBatches + ); }); it('enqueues without waiting while another session holds the merge lock', async () => { diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/strategies/HybridWithOutboxStrategy.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/strategies/HybridWithOutboxStrategy.ts index d2bf981cfb..f0fd87a447 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/strategies/HybridWithOutboxStrategy.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/strategies/HybridWithOutboxStrategy.ts @@ -25,6 +25,7 @@ import { buildBeforeImageRecordsFromStepChanges, mergeBeforeImageRecords, } from '../ComputedBeforeImageFromChanges'; +import { collectContinuationFieldIds } from '../ComputedContinuationFields'; import type { ComputedFieldUpdater, ComputedUpdateResult, @@ -173,9 +174,44 @@ export class HybridWithOutboxStrategy implements IUpdateStrategy { context: IExecutionContext, options?: UpdateStrategyExecuteOptions ): Promise> { + const rootSpan = context.tracer?.startSpan('teable.computed.hybrid.execute', { + 'computed.baseId': plan.baseId.toString(), + 'computed.seedTableId': plan.seedTableId.toString(), + 'computed.changeType': plan.changeType, + 'computed.dispatchMode': this.config.dispatchMode, + 'computed.syncPolicy': this.config.syncPolicy, + 'computed.planStepCount': plan.steps.length, + 'computed.seedRecordCount': plan.seedRecordIds.length, + }); + + try { + if (rootSpan && context.tracer) { + return await context.tracer.withSpan(rootSpan, () => + this.executeInner(updater, plan, context, options, rootSpan) + ); + } + return await this.executeInner(updater, plan, context, options, rootSpan); + } finally { + rootSpan?.end(); + } + } + + private async executeInner( + updater: ComputedFieldUpdater, + plan: ComputedUpdatePlan, + context: IExecutionContext, + options: UpdateStrategyExecuteOptions | undefined, + rootSpan: ReturnType['startSpan']> | undefined + ): Promise> { + // Edge-only plans (delete/orphan propagation: edges but no steps) are real + // executable work, and seed-all tables are real seed input — neither may be + // dropped as a no-op here or the propagation never reaches the fixed lower + // layers. if ( - plan.steps.length === 0 || - (plan.seedRecordIds.length === 0 && plan.extraSeedRecords.length === 0) + (plan.steps.length === 0 && plan.edges.length === 0) || + (plan.seedRecordIds.length === 0 && + plan.extraSeedRecords.length === 0 && + (plan.seedAllTableIds ?? []).length === 0) ) { return ok(undefined); } @@ -199,7 +235,7 @@ export class HybridWithOutboxStrategy implements IUpdateStrategy { // Accumulate sync changes from all stages const allSyncChangesByStep: StepChangeData[] = []; - while (currentPlan.steps.length > 0) { + while (currentPlan.steps.length > 0 || currentPlan.edges.length > 0) { const prepared = await updater.prepareDirtyState(currentPlan, context); if (prepared.isErr()) return err(prepared.error); @@ -209,6 +245,12 @@ export class HybridWithOutboxStrategy implements IUpdateStrategy { this.config ); + rootSpan?.setAttributes({ + 'computed.syncStepCount': syncSteps.length, + 'computed.asyncStepCount': asyncSteps.length, + 'computed.syncMaxLevel': syncMaxLevel, + }); + const phase = asyncSteps.length === 0 ? 'full' : 'sync'; const run = createComputedUpdateRun({ runId, @@ -469,7 +511,10 @@ export class HybridWithOutboxStrategy implements IUpdateStrategy { pendingSteps: 0, }); - const nextSeedFieldIds = collectStepFieldIds(currentPlan); + const nextSeedFieldIds = collectContinuationFieldIds( + currentPlan, + syncResult.value.changesByStep + ); const tableIds = collectStepTableIds(currentPlan); const seedGroupsResult = await updater.collectDirtySeedGroups(context, tableIds); if (seedGroupsResult.isErr()) return err(seedGroupsResult.error); @@ -499,7 +544,7 @@ export class HybridWithOutboxStrategy implements IUpdateStrategy { })) .filter((step) => step.fieldIds.length > 0); - if (filteredSteps.length === 0) break; + if (filteredSteps.length === 0 && nextPlanResult.value.edges.length === 0) break; currentPlan = { ...nextPlanResult.value, steps: filteredSteps }; totalSteps += currentPlan.steps.length; @@ -511,6 +556,11 @@ export class HybridWithOutboxStrategy implements IUpdateStrategy { scheduleDispatch(context: IExecutionContext): void { // 'external' mode: no inline dispatch; BullMQ owns asynchronous delivery. if (this.config.dispatchMode === 'external') { + context.tracer?.getActiveSpan()?.setAttributes({ + 'computed.dispatchMode': 'external', + 'computed.dispatchSkipped': true, + 'computed.dispatchSkipReason': 'external_mode', + }); this.logger.debug('computed:outbox:dispatch_skipped', { reason: 'external_mode', message: 'Task enqueued, waiting for an external wake-up', @@ -584,7 +634,9 @@ export class HybridWithOutboxStrategy implements IUpdateStrategy { prepared: PreparedDirtyState, changesByStep: ReadonlyArray ): Promise> { - if (plan.edges.length === 0) return ok({ ...plan, steps: [], edges: [] }); + // NOTE: no plan.edges shortcut here — see ComputedUpdateWorker.planNextStage: + // an edge-less stage's changes may still have cross-record downstream work. + if (seedFieldIds.length === 0) return ok({ ...plan, steps: [], edges: [] }); const seedSplit = splitSeedGroupsForPlan(seedGroups, plan.seedTableId); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/strategies/SyncInTransactionStrategy.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/strategies/SyncInTransactionStrategy.ts index 407fb40ea9..119a68119e 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/strategies/SyncInTransactionStrategy.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/strategies/SyncInTransactionStrategy.ts @@ -4,6 +4,7 @@ import { err, ok } from 'neverthrow'; import type { Result } from 'neverthrow'; import { v2RecordRepositoryPostgresTokens } from '../../di/tokens'; +import { collectContinuationFieldIds } from '../ComputedContinuationFields'; import type { ComputedFieldUpdater, ComputedUpdateResult, @@ -41,9 +42,15 @@ export class SyncInTransactionStrategy implements IUpdateStrategy { context: IExecutionContext, _options?: UpdateStrategyExecuteOptions ): Promise> { + // Edge-only plans (delete/orphan propagation: edges but no steps) are real + // executable work, and seed-all tables are real seed input — neither may be + // dropped as a no-op here or the propagation never reaches the fixed lower + // layers. if ( - plan.steps.length === 0 || - (plan.seedRecordIds.length === 0 && plan.extraSeedRecords.length === 0) + (plan.steps.length === 0 && plan.edges.length === 0) || + (plan.seedRecordIds.length === 0 && + plan.extraSeedRecords.length === 0 && + (plan.seedAllTableIds ?? []).length === 0) ) { return ok(undefined); } @@ -61,13 +68,13 @@ export class SyncInTransactionStrategy implements IUpdateStrategy { // Track already-updated fields to prevent duplicate updates across stages. // Without this, computed fields in the dependency chain would be updated multiple times - // because collectStepFieldIds passes them as changedFieldIds to the next stage. + // because actual field changes pass them as changedFieldIds to the next stage. const updatedFieldIds = new Set(); // Accumulate changes from all stages const allChangesByStep: StepChangeData[] = []; - while (currentPlan.steps.length > 0) { + while (currentPlan.steps.length > 0 || currentPlan.edges.length > 0) { const run = createComputedUpdateRun({ runId, originRunIds, @@ -104,7 +111,10 @@ export class SyncInTransactionStrategy implements IUpdateStrategy { const { groups: seedGroups, seedAllTableIds } = seedGroupsResult.value; - const nextSeedFieldIds = collectStepFieldIds(currentPlan); + const nextSeedFieldIds = collectContinuationFieldIds( + currentPlan, + stageResult.value.changesByStep + ); const nextPlanResult = await this.planNextStage( currentPlan, context, @@ -126,7 +136,7 @@ export class SyncInTransactionStrategy implements IUpdateStrategy { })) .filter((step) => step.fieldIds.length > 0); - if (filteredSteps.length === 0) break; + if (filteredSteps.length === 0 && nextPlanResult.value.edges.length === 0) break; currentPlan = { ...nextPlanResult.value, steps: filteredSteps }; totalSteps += currentPlan.steps.length; @@ -170,20 +180,16 @@ export class SyncInTransactionStrategy implements IUpdateStrategy { } } -const collectStepFieldIds = (plan: ComputedUpdatePlan): FieldId[] => { - const ids = new Map(); - for (const step of plan.steps) { - for (const fieldId of step.fieldIds) { - ids.set(fieldId.toString(), fieldId); - } - } - return [...ids.values()]; -}; - +/** Output tables of a plan: step tables plus propagation edge target tables. */ const collectStepTableIds = (plan: ComputedUpdatePlan): TableId[] => { const ids = new Map(); for (const step of plan.steps) { ids.set(step.tableId.toString(), step.tableId); } + // Edge-only stages produce their outputs solely in edge target tables; those + // dirty rows must still feed next-stage planning. + for (const edge of plan.edges) { + ids.set(edge.toTableId.toString(), edge.toTableId); + } return [...ids.values()]; }; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdateWorker.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdateWorker.spec.ts index 83a029286a..d9c3ab3e75 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdateWorker.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdateWorker.spec.ts @@ -26,6 +26,7 @@ import type { ComputedUpdatePlan, ComputedUpdatePlanner } from '../ComputedUpdat import type { ComputedUpdateOutboxItem } from '../outbox/ComputedUpdateOutboxPayload'; import { defaultComputedUpdateOutboxConfig, + normalizeComputedUpdateOutboxConfig, type SeedOutboxItem, type IComputedUpdateOutbox, } from '../outbox/IComputedUpdateOutbox'; @@ -87,6 +88,14 @@ const createLockResult = () => const createUpdaterStub = (overrides: Record = {}) => ({ acquireLocks: vi.fn().mockResolvedValue(createLockResult()), + pushStageLedgerFrontierSeeds: vi.fn().mockResolvedValue(ok(0)), + settleStageLedgerPartialBatch: vi + .fn() + .mockResolvedValue(ok({ processedByTable: [], newFrontierRows: 0, retiredFrontierRows: 0 })), + collectStageOutputSeedGroups: vi + .fn() + .mockResolvedValue(ok({ groups: [], seedAllTableIds: [] })), + clearTaskStageLedger: vi.fn().mockResolvedValue(ok(0)), ...overrides, }) as unknown as ComputedFieldUpdater; @@ -166,6 +175,23 @@ const createMockSeedTask = (overrides: Partial = {}): SeedOutbox }); describe('ComputedUpdateWorker', () => { + describe('config normalization', () => { + it('clamps a 1-row dirty budget to 2 so seeding and propagation both get a slot', () => { + const normalized = normalizeComputedUpdateOutboxConfig({ + ...defaultComputedUpdateOutboxConfig, + stageMaxDirtyRecords: 1, + }); + expect(normalized.stageMaxDirtyRecords).toBe(2); + + expect( + normalizeComputedUpdateOutboxConfig({ + ...defaultComputedUpdateOutboxConfig, + stageMaxDirtyRecords: 0, + }).stageMaxDirtyRecords + ).toBe(0); + }); + }); + describe('seed record chunking', () => { it('keeps 4k seed tasks whole by default', () => { const seedRecordIds = Array.from( @@ -613,6 +639,71 @@ describe('ComputedUpdateWorker', () => { expect(markFailed).not.toHaveBeenCalled(); }); + it('executes edge-only seed plans instead of marking them done', async () => { + // A propagation-only plan (edges, no steps — orphan/delete shapes) is + // real executable work; the seed path must not short-circuit it. + const task = createMockSeedTask(); + const baseId = BaseId.create(BASE_ID)._unsafeUnwrap(); + const tableId = TableId.create(TABLE_ID)._unsafeUnwrap(); + const recordId = RecordId.create(RECORD_ID)._unsafeUnwrap(); + const plan: ComputedUpdatePlan = { + baseId, + seedTableId: tableId, + seedRecordIds: [recordId], + extraSeedRecords: [], + beforeImageRecords: [], + steps: [], + edges: [ + { + fromFieldId: FieldId.create(FIELD_ID)._unsafeUnwrap(), + toFieldId: FieldId.create(`fld${'e'.repeat(16)}`)._unsafeUnwrap(), + fromTableId: tableId, + toTableId: TableId.create(`tbl${'b'.repeat(16)}`)._unsafeUnwrap(), + propagationMode: 'linkTraversal', + order: 0, + } as unknown as ComputedUpdatePlan['edges'][number], + ], + estimatedComplexity: 1, + changeType: 'delete', + sameTableBatches: [], + }; + const execute = vi.fn().mockResolvedValue(ok({ changesByStep: [] })); + const markDone = vi.fn().mockResolvedValue(ok(true)); + const outbox = createOutboxStub({ + claimBatch: vi.fn().mockResolvedValue(ok([task])), + markDone, + }); + const updater = createUpdaterStub({ execute }); + const planner = { + planStage: vi.fn().mockResolvedValue(ok(plan)), + } as unknown as ComputedUpdatePlanner; + const table = { id: () => tableId, baseId: () => baseId } as unknown as Table; + const tableRepository = { + ...createTableRepository(), + findOne: vi.fn().mockResolvedValue(ok(table)), + } as ITableRepository; + const worker = new ComputedUpdateWorker( + outbox, + defaultComputedUpdateOutboxConfig, + updater, + planner, + createUnitOfWork(), + createLogger(), + createHasher(), + tableRepository, + createBackfillService(), + createEventBus() + ); + + const result = await worker.runOnce({ workerId: 'worker-1', limit: 10 }); + + expect(result.isOk()).toBe(true); + expect(execute).toHaveBeenCalledTimes(1); + expect(execute.mock.calls[0][0].steps).toHaveLength(0); + expect(execute.mock.calls[0][0].edges).toHaveLength(1); + expect(markDone).toHaveBeenCalledWith(task, expect.anything()); + }); + it('registers planned computed targets for seed tasks before execution', async () => { const task = createMockSeedTask(); const baseId = BaseId.create(BASE_ID)._unsafeUnwrap(); @@ -894,9 +985,14 @@ describe('ComputedUpdateWorker', () => { markDone, }); + const collectStageOutputSeedGroups = vi + .fn() + .mockResolvedValue(ok({ groups: [], seedAllTableIds: [] })); + const clearTaskStageLedger = vi.fn().mockResolvedValue(ok(0)); const updater = createUpdaterStub({ execute: vi.fn().mockResolvedValue(ok({ changesByStep: [] })), - collectDirtySeedGroups: vi.fn().mockResolvedValue(ok({ groups: [], seedAllTableIds: [] })), + collectStageOutputSeedGroups, + clearTaskStageLedger, }); const planner = { @@ -928,6 +1024,13 @@ describe('ComputedUpdateWorker', () => { expect(result.isOk()).toBe(true); expect(result._unsafeUnwrap()).toBe(1); + // Stage completed: outputs collect over dirty ∪ exclusion ledger (no fold + // back into the transaction) and the chain's ledger state is dropped. + expect(collectStageOutputSeedGroups).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ scopeId: task.id }) + ); + expect(clearTaskStageLedger).toHaveBeenCalledWith(expect.anything(), task.id); expect(markDone).toHaveBeenCalledWith(task, expect.anything()); }); @@ -1113,10 +1216,11 @@ describe('ComputedUpdateWorker', () => { // the worker should mark the task done without re-planning. const task = createMockTask({ changeType: 'insert', + steps: [], edges: [ { fromFieldId: FIELD_ID, - toFieldId: `fld${'e'.repeat(16)}`, + toFieldId: FIELD_ID, fromTableId: TABLE_ID, toTableId: TABLE_ID, order: 0, @@ -1132,7 +1236,7 @@ describe('ComputedUpdateWorker', () => { const updater = createUpdaterStub({ execute: vi.fn().mockResolvedValue(ok({ changesByStep: [] })), - collectDirtySeedGroups: vi.fn().mockResolvedValue( + collectStageOutputSeedGroups: vi.fn().mockResolvedValue( ok({ groups: [ { @@ -1546,3 +1650,796 @@ describe('ComputedUpdateWorker', () => { }); }); }); + +describe('ComputedUpdateWorker stage budget', () => { + const TABLE_ID_B = `tbl${'e'.repeat(16)}`; + const TABLE_ID_C = `tbl${'f'.repeat(16)}`; + const FIELD_ID_B = `fld${'g'.repeat(16)}`; + const FIELD_ID_C = `fld${'h'.repeat(16)}`; + const RECORD_ID_B = `rec${'i'.repeat(16)}`; + + const stagedConfig = { + ...defaultComputedUpdateOutboxConfig, + stageMaxSteps: 2, + stageMaxFields: 0, + stageMaxEdges: 0, + // These cases exercise STATIC step splitting; the dirty budget's one-level- + // per-transaction clamp would otherwise override maxSteps. + stageMaxDirtyRecords: 0, + }; + + const threeStepTaskFields = { + steps: [ + { level: 0, tableId: TABLE_ID, fieldIds: [FIELD_ID] }, + { level: 1, tableId: TABLE_ID_B, fieldIds: [FIELD_ID_B] }, + { level: 2, tableId: TABLE_ID_C, fieldIds: [FIELD_ID_C] }, + ], + edges: [ + { + fromFieldId: FIELD_ID, + toFieldId: FIELD_ID_B, + fromTableId: TABLE_ID, + toTableId: TABLE_ID_B, + linkFieldId: FIELD_ID_B, + propagationMode: 'linkTraversal' as const, + order: 0, + }, + { + fromFieldId: FIELD_ID_B, + toFieldId: FIELD_ID_C, + fromTableId: TABLE_ID_B, + toTableId: TABLE_ID_C, + linkFieldId: FIELD_ID_C, + propagationMode: 'linkTraversal' as const, + order: 1, + }, + ], + affectedTableIds: [TABLE_ID, TABLE_ID_B, TABLE_ID_C], + affectedFieldIds: [FIELD_ID, FIELD_ID_B, FIELD_ID_C], + runTotalSteps: 3, + }; + + it('executes a bounded stage and enqueues the deferred continuation for computed tasks', async () => { + const task = createMockTask(threeStepTaskFields); + const execute = vi.fn().mockResolvedValue(ok({ changesByStep: [] })); + const dirtyTableId = TableId.create(TABLE_ID_B)._unsafeUnwrap(); + const dirtyRecordId = RecordId.create(RECORD_ID_B)._unsafeUnwrap(); + const collectStageOutputSeedGroups = vi + .fn() + .mockResolvedValue( + ok({ groups: [{ tableId: dirtyTableId, recordIds: [dirtyRecordId] }], seedAllTableIds: [] }) + ); + const enqueueOrMerge = vi.fn().mockResolvedValue(ok({ taskId: 'cont', merged: false })); + const markDone = vi.fn().mockResolvedValue(ok(true)); + const outbox = createOutboxStub({ + claimBatch: vi.fn().mockResolvedValue(ok([task])), + enqueueOrMerge, + markDone, + }); + const worker = new ComputedUpdateWorker( + outbox, + stagedConfig, + createUpdaterStub({ execute, collectStageOutputSeedGroups }), + // planner must stay untouched: budget continuations do not replan + {} as ComputedUpdatePlanner, + createUnitOfWork(), + createLogger(), + createHasher(), + createTableRepository(), + createBackfillService(), + createEventBus() + ); + + const result = await worker.runOnce({ workerId: 'worker-1', limit: 10 }); + + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toBe(1); + + const executedPlan = execute.mock.calls[0][0]; + expect(executedPlan.steps.map((step: { level: number }) => step.level)).toEqual([0, 1]); + expect(executedPlan.edges).toHaveLength(1); + + const collectParams = collectStageOutputSeedGroups.mock.calls[0][1]; + expect(collectParams.tableIds.map((id: { toString(): string }) => id.toString())).toEqual([ + TABLE_ID, + TABLE_ID_B, + ]); + expect(collectParams.exactIdsTotalCap).toBe( + defaultComputedUpdateOutboxConfig.stageMaxCollectedSeedIds + ); + + expect(enqueueOrMerge).toHaveBeenCalledTimes(1); + const continuation = enqueueOrMerge.mock.calls[0][0]; + expect(continuation.steps).toEqual([threeStepTaskFields.steps[2]]); + expect(continuation.edges.map((e: { toTableId: string }) => e.toTableId)).toEqual([TABLE_ID_C]); + expect(continuation.runTotalSteps).toBe(3); + expect(continuation.runCompletedStepsBefore).toBe(2); + expect(continuation.stageDepth).toBe(0); + // Seeds narrow to tables the deferred work reads from: only tableB remains. + expect(continuation.seedRecordIds).toEqual([]); + expect(continuation.extraSeedRecords).toEqual([ + { tableId: TABLE_ID_B, recordIds: [RECORD_ID_B] }, + ]); + // Lineage-scoped idempotency key: same-shape continuations from other runs, + // stages, or predecessor tasks must not merge. + expect(continuation.planHash).toBe('hash123:run:run123:stage:2:from:cuo123456789012345'); + expect(markDone).toHaveBeenCalledWith(task, expect.anything()); + }); + + it('splits seed task plans and defers the remainder without replanning', async () => { + const task = createMockSeedTask(); + const baseId = BaseId.create(BASE_ID)._unsafeUnwrap(); + const tableA = TableId.create(TABLE_ID)._unsafeUnwrap(); + const tableB = TableId.create(TABLE_ID_B)._unsafeUnwrap(); + const tableC = TableId.create(TABLE_ID_C)._unsafeUnwrap(); + const fieldA = FieldId.create(FIELD_ID)._unsafeUnwrap(); + const fieldB = FieldId.create(FIELD_ID_B)._unsafeUnwrap(); + const fieldC = FieldId.create(FIELD_ID_C)._unsafeUnwrap(); + const recordA = RecordId.create(RECORD_ID)._unsafeUnwrap(); + const table = { + id: () => tableA, + baseId: () => baseId, + } as unknown as Table; + const plan: ComputedUpdatePlan = { + baseId, + seedTableId: tableA, + seedRecordIds: [recordA], + extraSeedRecords: [], + beforeImageRecords: [], + steps: [ + { tableId: tableA, fieldIds: [fieldA], level: 0 }, + { tableId: tableB, fieldIds: [fieldB], level: 1 }, + { tableId: tableC, fieldIds: [fieldC], level: 2 }, + ], + edges: [ + { + fromFieldId: fieldA, + toFieldId: fieldB, + fromTableId: tableA, + toTableId: tableB, + linkFieldId: fieldB, + propagationMode: 'linkTraversal', + order: 0, + }, + { + fromFieldId: fieldB, + toFieldId: fieldC, + fromTableId: tableB, + toTableId: tableC, + linkFieldId: fieldC, + propagationMode: 'linkTraversal', + order: 1, + }, + ], + estimatedComplexity: 6, + changeType: 'update', + sameTableBatches: [], + }; + const planStage = vi.fn().mockResolvedValue(ok(plan)); + const execute = vi.fn().mockResolvedValue(ok({ changesByStep: [] })); + const collectDirtySeedGroups = vi + .fn() + .mockResolvedValue(ok({ groups: [], seedAllTableIds: [] })); + const enqueueOrMerge = vi.fn().mockResolvedValue(ok({ taskId: 'cont', merged: false })); + const markDone = vi.fn().mockResolvedValue(ok(true)); + const outbox = createOutboxStub({ + claimBatch: vi.fn().mockResolvedValue(ok([task])), + enqueueOrMerge, + markDone, + }); + const worker = new ComputedUpdateWorker( + outbox, + stagedConfig, + createUpdaterStub({ execute, collectDirtySeedGroups }), + { planStage } as unknown as ComputedUpdatePlanner, + createUnitOfWork(), + createLogger(), + createHasher(), + { + ...createTableRepository(), + findOne: vi.fn().mockResolvedValue(ok(table)), + } as ITableRepository, + createBackfillService(), + createEventBus() + ); + + const result = await worker.runOnce({ workerId: 'worker-1', limit: 10 }); + + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toBe(1); + // one initial planStage call, no replanning for the continuation + expect(planStage).toHaveBeenCalledTimes(1); + + const executedPlan = execute.mock.calls[0][0]; + expect(executedPlan.steps.map((step: { level: number }) => step.level)).toEqual([0, 1]); + + expect(enqueueOrMerge).toHaveBeenCalledTimes(1); + const continuation = enqueueOrMerge.mock.calls[0][0]; + expect(continuation.steps).toEqual([{ tableId: TABLE_ID_C, fieldIds: [FIELD_ID_C], level: 2 }]); + expect(continuation.runTotalSteps).toBe(3); + expect(continuation.runCompletedStepsBefore).toBe(2); + expect(continuation.stageDepth).toBe(0); + expect(markDone).toHaveBeenCalledWith(task, expect.anything()); + }); +}); + +describe('ComputedUpdateWorker dirty-record budget', () => { + const TABLE_ID_B = `tbl${'e'.repeat(16)}`; + const TABLE_ID_C = `tbl${'f'.repeat(16)}`; + const FIELD_ID_B = `fld${'g'.repeat(16)}`; + const FIELD_ID_C = `fld${'h'.repeat(16)}`; + + it('shrinks the stage and retries when propagation exceeds the dirty budget', async () => { + const task = createMockTask({ + steps: [ + { level: 0, tableId: TABLE_ID, fieldIds: [FIELD_ID] }, + { level: 0, tableId: TABLE_ID_B, fieldIds: [FIELD_ID_B] }, + { level: 0, tableId: TABLE_ID_C, fieldIds: [FIELD_ID_C] }, + ], + edges: [ + { + fromFieldId: FIELD_ID, + toFieldId: FIELD_ID_B, + fromTableId: TABLE_ID, + toTableId: TABLE_ID_B, + linkFieldId: FIELD_ID_B, + propagationMode: 'linkTraversal' as const, + order: 0, + }, + { + fromFieldId: FIELD_ID_B, + toFieldId: FIELD_ID_C, + fromTableId: TABLE_ID_B, + toTableId: TABLE_ID_C, + linkFieldId: FIELD_ID_C, + propagationMode: 'linkTraversal' as const, + order: 1, + }, + ], + affectedTableIds: [TABLE_ID, TABLE_ID_B, TABLE_ID_C], + affectedFieldIds: [FIELD_ID, FIELD_ID_B, FIELD_ID_C], + runTotalSteps: 3, + }); + const execute = vi + .fn() + .mockResolvedValueOnce( + ok({ changesByStep: [], dirtyBudget: { status: 'exceeded', dirtyRecordsAtAbort: 42 } }) + ) + .mockResolvedValue(ok({ changesByStep: [] })); + const collectDirtySeedGroups = vi + .fn() + .mockResolvedValue(ok({ groups: [], seedAllTableIds: [] })); + const enqueueOrMerge = vi.fn().mockResolvedValue(ok({ taskId: 'cont', merged: false })); + const markDone = vi.fn().mockResolvedValue(ok(true)); + const outbox = createOutboxStub({ + claimBatch: vi.fn().mockResolvedValue(ok([task])), + enqueueOrMerge, + markDone, + }); + const worker = new ComputedUpdateWorker( + outbox, + { + ...defaultComputedUpdateOutboxConfig, + stageMaxSteps: 0, + stageMaxFields: 0, + stageMaxEdges: 0, + stageMaxDirtyRecords: 10, + }, + createUpdaterStub({ execute, collectDirtySeedGroups }), + {} as ComputedUpdatePlanner, + createUnitOfWork(), + createLogger(), + createHasher(), + createTableRepository(), + createBackfillService(), + createEventBus() + ); + + const result = await worker.runOnce({ workerId: 'worker-1', limit: 10 }); + + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toBe(1); + expect(execute).toHaveBeenCalledTimes(2); + + // First attempt: full 3-step plan probed under the budget in abort mode. + expect(execute.mock.calls[0][0].steps).toHaveLength(3); + expect(execute.mock.calls[0][3]).toMatchObject({ + maxDirtyRecords: 10, + dirtyBudgetMode: 'abort', + }); + + // Retry after shrink: floor(3/2) = 1 step; single-step stages record-batch + // in partial mode instead of running unguarded. + expect(execute.mock.calls[1][0].steps.map((step: { level: number }) => step.level)).toEqual([ + 0, + ]); + expect(execute.mock.calls[1][3]).toMatchObject({ + maxDirtyRecords: 10, + dirtyBudgetMode: 'partial', + }); + + expect(enqueueOrMerge).toHaveBeenCalledTimes(1); + const continuation = enqueueOrMerge.mock.calls[0][0]; + expect(continuation.steps.map((step: { level: number }) => step.level)).toEqual([0, 0]); + expect(continuation.runCompletedStepsBefore).toBe(1); + expect(markDone).toHaveBeenCalledWith(task, expect.anything()); + }); + + it('re-enqueues a partial floor batch with its outputs settled into the stage ledger', async () => { + const task = createMockTask({ + steps: [{ level: 0, tableId: TABLE_ID_B, fieldIds: [FIELD_ID_B] }], + edges: [ + { + fromFieldId: FIELD_ID, + toFieldId: FIELD_ID_B, + fromTableId: TABLE_ID, + toTableId: TABLE_ID_B, + linkFieldId: FIELD_ID_B, + propagationMode: 'linkTraversal' as const, + order: 0, + }, + ], + affectedTableIds: [TABLE_ID, TABLE_ID_B], + affectedFieldIds: [FIELD_ID, FIELD_ID_B], + runTotalSteps: 1, + }); + const execute = vi.fn().mockResolvedValue( + ok({ + changesByStep: [], + dirtyBudget: { + status: 'partial', + propagatedDirtyRecords: 10, + truncated: 'seeding', + // The migrated explicit seed was the seeded queue head; propagation + // completed, so the consumed head retires. + frontierConsumed: 1, + frontierMaxSeq: '0', + }, + }) + ); + const pushStageLedgerFrontierSeeds = vi.fn().mockResolvedValue(ok(1)); + const settleStageLedgerPartialBatch = vi.fn().mockResolvedValue( + ok({ + processedByTable: [{ tableId: TABLE_ID_B, recordCount: 1 }], + newFrontierRows: 0, + retiredFrontierRows: 1, + }) + ); + const collectDirtySeedGroups = vi.fn(); + const enqueueOrMerge = vi.fn().mockResolvedValue(ok({ taskId: 'batch2', merged: false })); + const markDone = vi.fn().mockResolvedValue(ok(true)); + const outbox = createOutboxStub({ + claimBatch: vi.fn().mockResolvedValue(ok([task])), + enqueueOrMerge, + markDone, + }); + const worker = new ComputedUpdateWorker( + outbox, + { + ...defaultComputedUpdateOutboxConfig, + stageMaxSteps: 0, + stageMaxFields: 0, + stageMaxEdges: 0, + stageMaxDirtyRecords: 10, + }, + createUpdaterStub({ + execute, + collectDirtySeedGroups, + pushStageLedgerFrontierSeeds, + settleStageLedgerPartialBatch, + }), + {} as ComputedUpdatePlanner, + createUnitOfWork(), + createLogger(), + createHasher(), + createTableRepository(), + createBackfillService(), + createEventBus() + ); + + const result = await worker.runOnce({ workerId: 'worker-1', limit: 10 }); + + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toBe(1); + // Single step, no self-referential edges: executed in partial mode with the + // stage-ledger scope (chain root = this task). + expect(execute).toHaveBeenCalledTimes(1); + expect(execute.mock.calls[0][3]).toMatchObject({ + maxDirtyRecords: 10, + dirtyBudgetMode: 'partial', + ledgerScopeId: task.id, + }); + // Floor entry migrates the explicit seed into the ledger's frontier queue. + expect(pushStageLedgerFrontierSeeds).toHaveBeenCalledTimes(1); + expect(pushStageLedgerFrontierSeeds.mock.calls[0][1]).toBe(task.id); + expect( + pushStageLedgerFrontierSeeds.mock.calls[0][2].map( + (group: { tableId: { toString(): string }; recordIds: Array<{ toString(): string }> }) => ({ + tableId: group.tableId.toString(), + recordIds: group.recordIds.map((id) => id.toString()), + }) + ) + ).toEqual([{ tableId: TABLE_ID, recordIds: [RECORD_ID] }]); + // Settlement is SQL-side: exclusions and retirement never surface as arrays. + expect(settleStageLedgerPartialBatch).toHaveBeenCalledTimes(1); + expect(settleStageLedgerPartialBatch.mock.calls[0][1]).toMatchObject({ + scopeId: task.id, + appendFrontier: false, + retireFrontierUpToSeq: '0', + }); + expect(collectDirtySeedGroups).not.toHaveBeenCalled(); + + expect(enqueueOrMerge).toHaveBeenCalledTimes(1); + const continuation = enqueueOrMerge.mock.calls[0][0]; + // The step is not complete: same plan continues, keyed to the same ledger + // scope, with only O(1) durable state in the payload. + expect(continuation.steps).toEqual(task.steps); + expect(continuation.runCompletedStepsBefore).toBe(0); + expect(continuation.ledgerScopeId).toBe(task.id); + expect(continuation.seedRecordIds).toEqual([]); + expect(continuation.extraSeedRecords).toEqual([]); + expect(continuation.affectedFieldIds).toEqual([]); + expect(continuation.dirtyStats).toEqual([{ tableId: TABLE_ID_B, recordCount: 1 }]); + expect(continuation.planHash).toBe('hash123:run:run123:stage:0:from:cuo123456789012345'); + expect(markDone).toHaveBeenCalledWith(task, expect.anything()); + }); + + it('keeps the frontier queue unretired while propagation truncates', async () => { + const task = createMockTask({ + steps: [{ level: 0, tableId: TABLE_ID_B, fieldIds: [FIELD_ID_B] }], + edges: [ + { + fromFieldId: FIELD_ID, + toFieldId: FIELD_ID_B, + fromTableId: TABLE_ID, + toTableId: TABLE_ID_B, + linkFieldId: FIELD_ID_B, + propagationMode: 'linkTraversal' as const, + order: 0, + }, + ], + affectedTableIds: [TABLE_ID, TABLE_ID_B], + affectedFieldIds: [FIELD_ID, FIELD_ID_B], + runTotalSteps: 1, + }); + const execute = vi.fn().mockResolvedValue( + ok({ + changesByStep: [], + dirtyBudget: { + status: 'partial', + propagatedDirtyRecords: 10, + truncated: 'propagation', + frontierConsumed: 1, + frontierMaxSeq: '0', + }, + }) + ); + const pushStageLedgerFrontierSeeds = vi.fn().mockResolvedValue(ok(1)); + const settleStageLedgerPartialBatch = vi + .fn() + .mockResolvedValue(ok({ processedByTable: [], newFrontierRows: 0, retiredFrontierRows: 0 })); + const enqueueOrMerge = vi.fn().mockResolvedValue(ok({ taskId: 'batch2', merged: false })); + const markDone = vi.fn().mockResolvedValue(ok(true)); + const outbox = createOutboxStub({ + claimBatch: vi.fn().mockResolvedValue(ok([task])), + enqueueOrMerge, + markDone, + }); + const worker = new ComputedUpdateWorker( + outbox, + { + ...defaultComputedUpdateOutboxConfig, + stageMaxSteps: 0, + stageMaxFields: 0, + stageMaxEdges: 0, + stageMaxDirtyRecords: 10, + }, + createUpdaterStub({ execute, pushStageLedgerFrontierSeeds, settleStageLedgerPartialBatch }), + {} as ComputedUpdatePlanner, + createUnitOfWork(), + createLogger(), + createHasher(), + createTableRepository(), + createBackfillService(), + createEventBus() + ); + + const result = await worker.runOnce({ workerId: 'worker-1', limit: 10 }); + + expect(result.isOk()).toBe(true); + // Propagation truncated: the consumed head's targets are unfinished, so the + // queue must NOT retire — the same head re-seeds next batch and progresses + // via target exclusions. + expect(settleStageLedgerPartialBatch.mock.calls[0][1]).toMatchObject({ + retireFrontierUpToSeq: null, + }); + const continuation = enqueueOrMerge.mock.calls[0][0]; + expect(continuation.seedRecordIds).toEqual([]); + expect(continuation.ledgerScopeId).toBe(task.id); + }); + + it('appends the self-referential frontier during SQL-side settlement', async () => { + const task = createMockTask({ + steps: [{ level: 0, tableId: TABLE_ID_B, fieldIds: [FIELD_ID_B] }], + edges: [ + { + fromFieldId: FIELD_ID_B, + toFieldId: FIELD_ID_B, + fromTableId: TABLE_ID_B, + toTableId: TABLE_ID_B, + linkFieldId: FIELD_ID_B, + propagationMode: 'linkTraversal' as const, + order: 0, + }, + ], + affectedTableIds: [TABLE_ID_B], + // A previous partial batch changed FIELD_ID; this batch adds FIELD_ID_B. + affectedFieldIds: [FIELD_ID], + runTotalSteps: 1, + }); + const execute = vi.fn().mockResolvedValue( + ok({ + changesByStep: [ + { + tableId: TABLE_ID_B, + recordChanges: [ + { + recordId: RECORD_ID, + oldVersion: 1, + changes: [{ fieldId: FIELD_ID_B, newValue: 'next-batch' }], + }, + ], + }, + ], + dirtyBudget: { + status: 'partial', + propagatedDirtyRecords: 10, + truncated: 'seeding', + frontierConsumed: 1, + frontierMaxSeq: '0', + }, + }) + ); + const settleStageLedgerPartialBatch = vi.fn().mockResolvedValue( + ok({ + processedByTable: [{ tableId: TABLE_ID_B, recordCount: 1 }], + newFrontierRows: 1, + retiredFrontierRows: 1, + }) + ); + const enqueueOrMerge = vi.fn().mockResolvedValue(ok({ taskId: 'batch2', merged: false })); + const markDone = vi.fn().mockResolvedValue(ok(true)); + const outbox = createOutboxStub({ + claimBatch: vi.fn().mockResolvedValue(ok([task])), + enqueueOrMerge, + markDone, + }); + const worker = new ComputedUpdateWorker( + outbox, + { + ...defaultComputedUpdateOutboxConfig, + stageMaxSteps: 0, + stageMaxFields: 0, + stageMaxEdges: 0, + stageMaxDirtyRecords: 10, + }, + createUpdaterStub({ execute, settleStageLedgerPartialBatch }), + {} as ComputedUpdatePlanner, + createUnitOfWork(), + createLogger(), + createHasher(), + createTableRepository(), + createBackfillService(), + createEventBus() + ); + + const result = await worker.runOnce({ workerId: 'worker-1', limit: 10 }); + + expect(result.isOk()).toBe(true); + // Self-referential single-step stages stay budgeted in plain partial mode. + expect(execute.mock.calls[0][3]).toMatchObject({ + maxDirtyRecords: 10, + dirtyBudgetMode: 'partial', + }); + // Settlement appends rows NEW this batch to the queue tail (the next + // generation's sources) and excludes every processed step-table row — + // entirely inside the ledger. + expect(settleStageLedgerPartialBatch.mock.calls[0][1]).toMatchObject({ + scopeId: task.id, + appendFrontier: true, + retireFrontierUpToSeq: '0', + }); + const continuation = enqueueOrMerge.mock.calls[0][0]; + expect(continuation.ledgerScopeId).toBe(task.id); + expect(continuation.extraSeedRecords).toEqual([]); + // Fresh tasks ignore their broad input scope and start accumulation from + // this batch's actual changes. + expect(continuation.affectedFieldIds).toEqual([FIELD_ID_B]); + expect(markDone).toHaveBeenCalledWith(task, expect.anything()); + }); + + it('normalizes implicit schema-update seeding to cursors on partial continuations', async () => { + // No per-record seeds at all on an update plan: the seed table is implicitly + // whole-table seeded. The continuation must carry an explicit seedAllTableIds + // entry (reported by the batch itself) plus the advanced cursor. + const task = createMockTask({ + seedRecordIds: [], + extraSeedRecords: [], + steps: [{ level: 0, tableId: TABLE_ID_C, fieldIds: [FIELD_ID_C] }], + edges: [ + { + fromFieldId: FIELD_ID, + toFieldId: FIELD_ID_C, + fromTableId: TABLE_ID, + toTableId: TABLE_ID_C, + linkFieldId: FIELD_ID_C, + propagationMode: 'linkTraversal' as const, + order: 0, + }, + ], + affectedTableIds: [TABLE_ID, TABLE_ID_C], + affectedFieldIds: [FIELD_ID, FIELD_ID_C], + runTotalSteps: 1, + }); + const cursorRecordId = `rec${'m'.repeat(16)}`; + const execute = vi.fn().mockResolvedValue( + ok({ + changesByStep: [], + dirtyBudget: { + status: 'partial', + propagatedDirtyRecords: 10, + truncated: 'seeding', + seedAllCursors: { [TABLE_ID]: cursorRecordId }, + wholeTableSeedTables: [TABLE_ID], + }, + }) + ); + const settleStageLedgerPartialBatch = vi + .fn() + .mockResolvedValue(ok({ processedByTable: [], newFrontierRows: 0, retiredFrontierRows: 0 })); + const enqueueOrMerge = vi.fn().mockResolvedValue(ok({ taskId: 'batch2', merged: false })); + const markDone = vi.fn().mockResolvedValue(ok(true)); + const outbox = createOutboxStub({ + claimBatch: vi.fn().mockResolvedValue(ok([task])), + enqueueOrMerge, + markDone, + }); + const worker = new ComputedUpdateWorker( + outbox, + { + ...defaultComputedUpdateOutboxConfig, + stageMaxSteps: 0, + stageMaxFields: 0, + stageMaxEdges: 0, + stageMaxDirtyRecords: 10, + }, + createUpdaterStub({ execute, settleStageLedgerPartialBatch }), + {} as ComputedUpdatePlanner, + createUnitOfWork(), + createLogger(), + createHasher(), + createTableRepository(), + createBackfillService(), + createEventBus() + ); + + const result = await worker.runOnce({ workerId: 'worker-1', limit: 10 }); + + expect(result.isOk()).toBe(true); + // Only target (step) tables feed the exclusion ledger; sources use cursors. + expect( + settleStageLedgerPartialBatch.mock.calls[0][1].stepTableIds.map( + (id: { toString(): string }) => id.toString() + ) + ).toEqual([TABLE_ID_C]); + + const continuation = enqueueOrMerge.mock.calls[0][0]; + expect(continuation.seedAllTableIds).toEqual([TABLE_ID]); + // Propagation completed (truncated: 'seeding'): the cursor advances. + expect(continuation.seedAllCursors).toEqual({ [TABLE_ID]: cursorRecordId }); + }); + + it('keeps the inherited ledger scope across partial continuations and retires the consumed head', async () => { + const chainRootTaskId = 'cuoroot12345678901'; + const task = createMockTask({ + seedRecordIds: [], + extraSeedRecords: [], + ledgerScopeId: chainRootTaskId, + steps: [{ level: 0, tableId: TABLE_ID_B, fieldIds: [FIELD_ID_B] }], + edges: [ + { + fromFieldId: FIELD_ID_B, + toFieldId: FIELD_ID_B, + fromTableId: TABLE_ID_B, + toTableId: TABLE_ID_B, + linkFieldId: FIELD_ID_B, + propagationMode: 'linkTraversal' as const, + order: 0, + }, + ], + affectedTableIds: [TABLE_ID_B], + affectedFieldIds: [FIELD_ID], + runTotalSteps: 1, + }); + const execute = vi.fn().mockResolvedValue( + ok({ + changesByStep: [ + { + tableId: TABLE_ID_B, + recordChanges: [ + { + recordId: RECORD_ID, + oldVersion: 1, + changes: [{ fieldId: FIELD_ID_B, newValue: 'next-batch' }], + }, + ], + }, + ], + dirtyBudget: { + status: 'partial', + propagatedDirtyRecords: 10, + truncated: 'seeding', + // The seeded queue head (seq up to 7) whose propagation completed. + frontierConsumed: 2, + frontierMaxSeq: '7', + }, + }) + ); + const pushStageLedgerFrontierSeeds = vi.fn(); + const settleStageLedgerPartialBatch = vi.fn().mockResolvedValue( + ok({ + processedByTable: [{ tableId: TABLE_ID_B, recordCount: 2 }], + newFrontierRows: 1, + retiredFrontierRows: 2, + }) + ); + const enqueueOrMerge = vi.fn().mockResolvedValue(ok({ taskId: 'batch3', merged: false })); + const markDone = vi.fn().mockResolvedValue(ok(true)); + const outbox = createOutboxStub({ + claimBatch: vi.fn().mockResolvedValue(ok([task])), + enqueueOrMerge, + markDone, + }); + const worker = new ComputedUpdateWorker( + outbox, + { + ...defaultComputedUpdateOutboxConfig, + stageMaxSteps: 0, + stageMaxFields: 0, + stageMaxEdges: 0, + stageMaxDirtyRecords: 10, + }, + createUpdaterStub({ + execute, + pushStageLedgerFrontierSeeds, + settleStageLedgerPartialBatch, + }), + {} as ComputedUpdatePlanner, + createUnitOfWork(), + createLogger(), + createHasher(), + createTableRepository(), + createBackfillService(), + createEventBus() + ); + + const result = await worker.runOnce({ workerId: 'worker-1', limit: 10 }); + + expect(result.isOk()).toBe(true); + // A continuation inherits the chain root's scope: the ledger stays keyed to + // one chain even across many batches (and never to the shared runId, which + // parallel chunk-split tasks reuse). + expect(execute.mock.calls[0][3]).toMatchObject({ ledgerScopeId: chainRootTaskId }); + // No seeds to migrate on a continuation. + expect(pushStageLedgerFrontierSeeds).not.toHaveBeenCalled(); + // Propagation completed: exactly the consumed head (seq <= 7) retires. + expect(settleStageLedgerPartialBatch.mock.calls[0][1]).toMatchObject({ + scopeId: chainRootTaskId, + appendFrontier: true, + retireFrontierUpToSeq: '7', + }); + const continuation = enqueueOrMerge.mock.calls[0][0]; + expect(continuation.ledgerScopeId).toBe(chainRootTaskId); + expect(continuation.affectedFieldIds).toEqual([FIELD_ID, FIELD_ID_B]); + }); +}); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdateWorker.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdateWorker.ts index 89a74b4c73..0295849a68 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdateWorker.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdateWorker.ts @@ -39,8 +39,19 @@ import { buildBeforeImageRecordsFromStepChanges, mergeBeforeImageRecords, } from '../ComputedBeforeImageFromChanges'; +import { collectContinuationFieldIds } from '../ComputedContinuationFields'; import type { ComputedFieldBackfillService } from '../ComputedFieldBackfillService'; -import type { ComputedFieldUpdater, StepChangeData } from '../ComputedFieldUpdater'; +import type { + ComputedFieldUpdater, + ComputedUpdateResult, + StepChangeData, +} from '../ComputedFieldUpdater'; +import type { ComputedStagePlanSplit } from '../ComputedStagePlanSplitter'; +import { + buildDeferredStagePlan, + mergeComputedSeedGroups, + splitComputedPlanForStageBudget, +} from '../ComputedStagePlanSplitter'; import type { ComputedTaskFailureClassification } from '../ComputedTaskFailureClassifier'; import { classifyComputedTaskFailure } from '../ComputedTaskFailureClassifier'; import { isComputedUpdateLockUnavailable } from '../ComputedUpdateLock'; @@ -51,6 +62,7 @@ import type { } from '../ComputedUpdatePlanner'; import { splitSeedGroupsForPlan } from '../ComputedUpdatePlanner'; import { createComputedUpdateRun, toRunSpanAttributes } from '../ComputedUpdateRun'; +import type { ComputedUpdateRunContext } from '../ComputedUpdateRun'; import { toErrorLogFields } from '../errorLog'; import type { ComputedBeforeImageRecordDto, @@ -61,6 +73,7 @@ import type { ComputedUpdateOutboxTaskInput, } from '../outbox/ComputedUpdateOutboxPayload'; import { + buildContinuationPlanHash, buildOutboxTaskInput, deserializeComputedUpdatePlan, } from '../outbox/ComputedUpdateOutboxPayload'; @@ -268,6 +281,7 @@ export const splitComputedTaskForSeedRecordLimit = ( extraSeedRecords: chunk.extraSeedRecords, beforeImageRecords: filterBeforeImageRecords(task.beforeImageRecords, chunk.seedRecordIds), steps: task.steps, + sameTableBatches: task.sameTableBatches, edges: task.edges, estimatedComplexity: Math.max(1, Math.ceil(task.estimatedComplexity / chunks.length)), changeType: task.changeType, @@ -290,6 +304,7 @@ export const splitComputedTaskForSeedRecordLimit = ( affectedTableIds: task.affectedTableIds, affectedFieldIds: task.affectedFieldIds, syncMaxLevel: task.syncMaxLevel, + seedAllCursors: task.seedAllCursors, })); }; @@ -472,6 +487,19 @@ class ClaimedTaskLeaseManager { } } +const mergeSeedAllTableIdLists = ( + base: ReadonlyArray, + extraTableIdStrings: ReadonlyArray +): TableId[] => { + const merged = new Map(base.map((tableId) => [tableId.toString(), tableId])); + for (const tableIdString of extraTableIdStrings) { + if (merged.has(tableIdString)) continue; + const created = TableId.create(tableIdString); + if (created.isOk()) merged.set(tableIdString, created.value); + } + return [...merged.values()]; +}; + /** * Background worker that processes computed update outbox tasks. * @@ -734,15 +762,44 @@ export class ComputedUpdateWorker { tracer?: ITracer, requestId?: string ): Promise> { - if (isFieldBackfillOutboxItem(task)) { - return this.processFieldBackfillTask(task, actorId, tracer, requestId); - } + const taskKind = isFieldBackfillOutboxItem(task) + ? 'field-backfill' + : isSeedOutboxItem(task) + ? 'seed' + : 'computed'; + const startedAt = performance.now(); + const span = tracer?.startSpan('teable.worker.processClaimedTask', { + 'outbox.taskId': task.id, + 'outbox.taskKind': taskKind, + 'outbox.baseId': task.baseId, + 'outbox.attempts': task.attempts, + 'outbox.taskAgeMs': Math.max(0, Date.now() - task.createdAt.getTime()), + }); - if (isSeedOutboxItem(task)) { - return this.processSeedTask(task, actorId, tracer, requestId); - } + const run = async (): Promise> => { + if (isFieldBackfillOutboxItem(task)) { + return this.processFieldBackfillTask(task, actorId, tracer, requestId); + } + + if (isSeedOutboxItem(task)) { + return this.processSeedTask(task, actorId, tracer, requestId); + } - return this.processComputedTask(task as ComputedUpdateOutboxItem, actorId, tracer, requestId); + return this.processComputedTask(task as ComputedUpdateOutboxItem, actorId, tracer, requestId); + }; + + try { + const result = span && tracer ? await tracer.withSpan(span, run) : await run(); + span?.setAttribute('outbox.processMs', Math.round(performance.now() - startedAt)); + if (result.isErr()) { + span?.recordError(result.error.message); + } else { + span?.setAttribute('outbox.processed', result.value); + } + return result; + } finally { + span?.end(); + } } private async processComputedTask( @@ -768,6 +825,7 @@ export class ComputedUpdateWorker { | 'collect_dirty_seed_groups' | 'plan_next_stage' | 'enqueue_next_stage' + | 'enqueue_stage_continuation' | 'mark_done' = 'deserialize_plan'; const logTaskFailure = (error: unknown, failure?: ComputedTaskFailureClassification) => { this.logger.error('computed:outbox:task_failed', { @@ -801,6 +859,9 @@ export class ComputedUpdateWorker { return err(planResult.error); } + const stageSplit = this.splitPlanForStageBudget(planResult.value); + const stagePlan = stageSplit.stagePlan; + const totalSteps = computedTask.runTotalSteps > 0 ? computedTask.runTotalSteps @@ -839,23 +900,29 @@ export class ComputedUpdateWorker { }); failurePhase = 'acquire_locks'; - const lockResult = await this.updater.acquireLocks(planResult.value, txContext, { + const lockResult = await this.updater.acquireLocks(stagePlan, txContext, { logContext: runLogContext, wait: false, }); if (lockResult.isErr()) return err(lockResult.error); failurePhase = 'execute_plan'; - const stageResult = await this.updater.execute(planResult.value, txContext, run, { - collectChanges: true, - // Non-blocking target locks: overlapping writers requeue instead of overwriting - // computed columns with stale concurrent snapshots. - lockWait: false, + // Stage-ledger scope: continuations inherit the chain root's task id; + // fresh tasks (including parallel chunk splits) start their own scope. + const ledgerScopeId = computedTask.ledgerScopeId ?? computedTask.id; + const stageExecution = await this.runStageWithinDirtyBudget({ + plan: planResult.value, + initialSplit: stageSplit, + context: txContext, + run, + ledgerScopeId, + logContext: runLogContext, }); - if (stageResult.isErr()) return err(stageResult.error); + if (stageExecution.isErr()) return err(stageExecution.error); + const { split: finalSplit, result: stageChanges, selfReferential } = stageExecution.value; const events = buildComputedUpdateEvents( - stageResult.value.changesByStep, + stageChanges.changesByStep, planResult.value.baseId, computedTask.orchestration ); @@ -869,30 +936,55 @@ export class ComputedUpdateWorker { }); } - const completedStepsAfter = computedTask.runCompletedStepsBefore + computedTask.steps.length; - failurePhase = 'collect_dirty_seed_groups'; - const seedGroupsResult = await this.updater.collectDirtySeedGroups( - txContext, - stageTableIdsResult.value + const stageContinuationFieldIdsResult = collectStageContinuationFieldIds( + planResult.value, + stageChanges.changesByStep, + computedTask.ledgerScopeId ? computedTask.affectedFieldIds : [] ); - if (seedGroupsResult.isErr()) return err(seedGroupsResult.error); - - const { groups: seedGroups, seedAllTableIds } = seedGroupsResult.value; + if (stageContinuationFieldIdsResult.isErr()) { + return err(stageContinuationFieldIdsResult.error); + } + const stageContinuationFieldIds = stageContinuationFieldIdsResult.value; + + const settleResult = await this.settleStage({ + task: computedTask, + plan: planResult.value, + finalSplit, + stageChanges, + continuationFieldIds: stageContinuationFieldIds, + selfReferential, + fallbackCollectTableIds: stageTableIdsResult.value, + runId: run.runId, + ledgerScopeId, + originRunIds: [...run.originRunIds], + runTotalSteps: totalSteps, + runCompletedStepsBefore: computedTask.runCompletedStepsBefore, + stageDepth: computedTask.stageDepth ?? 0, + orchestration: computedTask.orchestration, + context: txContext, + logContext: runLogContext, + setPhase: (phase) => { + failurePhase = phase; + }, + }); + if (settleResult.isErr()) return err(settleResult.error); + if (settleResult.value.kind === 'done') return ok(settleResult.value.processed); + const { seedGroups, seedAllTableIds, completedStepsAfter } = settleResult.value; failurePhase = 'plan_next_stage'; const nextPlanResult = await this.planNextStage( planResult.value, txContext, - stageFieldIdsResult.value, + stageContinuationFieldIds, seedGroups, seedAllTableIds, - stageResult.value.changesByStep + stageChanges.changesByStep ); if (nextPlanResult.isErr()) return err(nextPlanResult.error); const currentStageDepth = computedTask.stageDepth ?? 0; - if (nextPlanResult.value.steps.length > 0) { + if (nextPlanResult.value.steps.length > 0 || nextPlanResult.value.edges.length > 0) { if (currentStageDepth >= MAX_STAGE_DEPTH) { this.logger.warn('computed:worker:max_stage_depth_reached', { taskId: computedTask.id, @@ -1379,6 +1471,7 @@ export class ComputedUpdateWorker { | 'collect_dirty_seed_groups' | 'plan_next_stage' | 'enqueue_next_stage' + | 'enqueue_stage_continuation' | 'mark_done' = 'deserialize_seed_payload'; const logSeedFailure = ( error: unknown, @@ -1474,8 +1567,9 @@ export class ComputedUpdateWorker { const plan: ComputedUpdatePlan = planResult.value; - // If no steps, nothing to do - if (plan.steps.length === 0) { + // A plan with edges but no steps is still executable work (propagation-only + // orphan/delete plans); only a plan with neither is a no-op. + if (plan.steps.length === 0 && plan.edges.length === 0) { this.logger.debug('computed:worker:seed_no_steps', { taskId: task.id, ...runLogContext, @@ -1484,6 +1578,11 @@ export class ComputedUpdateWorker { return doneResult; } + // Bound the first transaction to the stage budget; activity registration below + // still advertises the full plan so pending targets stay visible across stages. + const stageSplit = this.splitPlanForStageBudget(plan); + const stagePlan = stageSplit.stagePlan; + failurePhase = 'project_activity'; const batchProgress = toComputedActivityBatch(task.orchestration); const activityResult = await this.outbox.registerPlannedTaskActivity( @@ -1521,22 +1620,30 @@ export class ComputedUpdateWorker { }); failurePhase = 'acquire_locks'; - const lockResult = await this.updater.acquireLocks(plan, txContext, { + const lockResult = await this.updater.acquireLocks(stagePlan, txContext, { logContext: runLogContext, wait: false, }); if (lockResult.isErr()) return err(lockResult.error); failurePhase = 'execute_plan'; - const stageResult = await this.updater.execute(plan, txContext, run, { - collectChanges: true, - lockWait: false, + // Seed tasks are always chain roots: their partial continuations carry + // this scope forward as computed tasks. + const ledgerScopeId = task.id; + const stageExecution = await this.runStageWithinDirtyBudget({ + plan, + initialSplit: stageSplit, + context: txContext, + run, + ledgerScopeId, + logContext: runLogContext, }); - if (stageResult.isErr()) return err(stageResult.error); + if (stageExecution.isErr()) return err(stageExecution.error); + const { split: finalSplit, result: stageChanges, selfReferential } = stageExecution.value; // Publish events for computed updates const events = buildComputedUpdateEvents( - stageResult.value.changesByStep, + stageChanges.changesByStep, plan.baseId, task.orchestration ); @@ -1550,13 +1657,34 @@ export class ComputedUpdateWorker { }); } - // Collect seed groups for next stage - const stageTableIds = plan.steps.map((step) => step.tableId); - failurePhase = 'collect_dirty_seed_groups'; - const seedGroupsResult = await this.updater.collectDirtySeedGroups(txContext, stageTableIds); - if (seedGroupsResult.isErr()) return err(seedGroupsResult.error); + const stageContinuationFieldIds = collectContinuationFieldIds( + plan, + stageChanges.changesByStep + ); - const { groups: seedGroups, seedAllTableIds } = seedGroupsResult.value; + const settleResult = await this.settleStage({ + task, + plan, + finalSplit, + stageChanges, + continuationFieldIds: stageContinuationFieldIds, + selfReferential, + runId: run.runId, + ledgerScopeId, + originRunIds: [...run.originRunIds], + runTotalSteps: plan.steps.length, + runCompletedStepsBefore: 0, + stageDepth: 0, + orchestration: task.orchestration, + context: txContext, + logContext: runLogContext, + setPhase: (phase) => { + failurePhase = phase; + }, + }); + if (settleResult.isErr()) return err(settleResult.error); + if (settleResult.value.kind === 'done') return ok(settleResult.value.processed); + const { seedGroups, seedAllTableIds } = settleResult.value; // Plan next stage if needed // If there are no cross-record propagation edges, the plan is purely same-record @@ -1567,21 +1695,20 @@ export class ComputedUpdateWorker { if (!doneResult.value) return ok(false); return ok(true); } - const stageFieldIds = plan.steps.flatMap((step) => step.fieldIds); failurePhase = 'plan_next_stage'; const nextPlanResult = await this.planNextStage( plan, txContext, - stageFieldIds, + stageContinuationFieldIds, seedGroups, seedAllTableIds, - stageResult.value.changesByStep + stageChanges.changesByStep ); if (nextPlanResult.isErr()) return err(nextPlanResult.error); - // Enqueue next stage if there are more steps + // Enqueue next stage if there is more work (steps, or propagation-only edges) // Seed tasks start at depth 0, so the first follow-up is depth 1 - if (nextPlanResult.value.steps.length > 0) { + if (nextPlanResult.value.steps.length > 0 || nextPlanResult.value.edges.length > 0) { const nextTask = buildOutboxTaskInput({ plan: nextPlanResult.value, dirtyStats: seedGroups.map((group) => ({ @@ -1642,6 +1769,555 @@ export class ComputedUpdateWorker { return ok(executeResult.value); } + private splitPlanForStageBudget(plan: ComputedUpdatePlan): ComputedStagePlanSplit { + let maxSteps = this.outboxConfig.stageMaxSteps; + if (this.outboxConfig.stageMaxDirtyRecords > 0 && plan.steps.length > 1) { + const firstLevel = Math.min(...plan.steps.map((step) => step.level)); + const firstLevelStepCount = plan.steps.filter((step) => step.level === firstLevel).length; + const hasLaterLevel = plan.steps.some((step) => step.level > firstLevel); + if (hasLaterLevel) { + // A partial dirty batch commits only a subset of the current level's + // records. Executing a dependent level in that same transaction makes + // its stage-wide settlement ambiguous: earlier batches may have changed + // an upstream field while only the final batch's downstream values are + // current. Commit one dependency level completely before the next one. + maxSteps = maxSteps > 0 ? Math.min(maxSteps, firstLevelStepCount) : firstLevelStepCount; + } + } + return splitComputedPlanForStageBudget(plan, { + maxSteps, + maxFields: this.outboxConfig.stageMaxFields, + maxEdges: this.outboxConfig.stageMaxEdges, + }); + } + + /** + * Execute a stage under the dirty-record budget. When propagation aborts over + * budget (no steps ran), retry with half as many steps until the stage fits. + * A single-step stage runs unguarded: its fan-out cannot be reduced here, and + * seed splitting plus statement timeouts remain the caps for that case. + */ + private async runStageWithinDirtyBudget(params: { + plan: ComputedUpdatePlan; + initialSplit: ComputedStagePlanSplit; + context: IExecutionContext; + run: ComputedUpdateRunContext; + /** Stage-ledger scope: the continuation chain's root task id. */ + ledgerScopeId: string; + logContext: Record; + }): Promise< + Result< + { + split: ComputedStagePlanSplit; + result: ComputedUpdateResult; + selfReferential: boolean; + }, + DomainError + > + > { + const maxDirtyRecords = this.outboxConfig.stageMaxDirtyRecords; + let split = params.initialSplit; + for (;;) { + const stepCount = split.stagePlan.steps.length; + if (maxDirtyRecords <= 0) { + const result = await this.updater.execute(split.stagePlan, params.context, params.run, { + collectChanges: true, + // Non-blocking target locks: overlapping writers requeue instead of overwriting + // computed columns with stale concurrent snapshots. + lockWait: false, + // Continuations may still carry stage-ledger state (e.g. after a + // budget config change); the ledger frontier must drain even + // unbudgeted. + ledgerScopeId: params.ledgerScopeId, + }); + if (result.isErr()) return err(result.error); + return ok({ split, result: result.value, selfReferential: false }); + } + + if (stepCount <= 1) { + // Floor: batch the single step by target records — a record's computed value + // depends only on its own sources, so executing a partial dirty set is safe. + // Self-referential plans (the floor table feeds itself) additionally carry + // this batch's processed rows as next-batch seeds (the frontier), so later + // generations stay reachable while the exclusion set keeps every batch's + // budget slots reserved for genuinely-new rows. + const floorTableKey = split.stagePlan.steps[0]?.tableId.toString(); + const selfReferential = split.stagePlan.edges.some( + (edge) => edge.fromTableId.toString() === floorTableKey + ); + if (selfReferential) { + this.logger.info('computed:worker:stage_dirty_budget_floor_self_referential', { + maxDirtyRecords, + ...params.logContext, + }); + } + // Enter the queue regime before the first partial batch: explicit seeds + // migrate into the run ledger's frontier queue HEAD, so every batch — + // including the first — stays inside the shared stageMaxDirtyRecords + // pool (the batch seeds only the queue's budget-bounded head; retirement + // follows the consumed-head seq rule). The push is part of the stage + // transaction: it rolls back with a failed batch and is idempotent on + // retry via the ledger's primary key. + const migratedSeedGroups = mergeComputedSeedGroups( + split.stagePlan.seedRecordIds.length > 0 + ? [ + { + tableId: split.stagePlan.seedTableId, + recordIds: split.stagePlan.seedRecordIds, + }, + ] + : [], + split.stagePlan.extraSeedRecords + ); + let floorSplit: ComputedStagePlanSplit = split; + if (migratedSeedGroups.length > 0) { + const pushResult = await this.updater.pushStageLedgerFrontierSeeds( + params.context, + params.ledgerScopeId, + migratedSeedGroups + ); + if (pushResult.isErr()) return err(pushResult.error); + this.logger.debug('computed:worker:seeds_migrated_to_ledger', { + ledgerScopeId: params.ledgerScopeId, + migratedSeedGroups: migratedSeedGroups.map((group) => ({ + tableId: group.tableId.toString(), + recordIds: group.recordIds.map((recordId) => recordId.toString()), + })), + ...params.logContext, + }); + floorSplit = { + ...split, + stagePlan: { + ...split.stagePlan, + seedRecordIds: [], + extraSeedRecords: [], + }, + }; + } + const result = await this.updater.execute( + floorSplit.stagePlan, + params.context, + params.run, + { + collectChanges: true, + lockWait: false, + maxDirtyRecords, + dirtyBudgetMode: 'partial' as const, + ledgerScopeId: params.ledgerScopeId, + } + ); + if (result.isErr()) return err(result.error); + return ok({ split: floorSplit, result: result.value, selfReferential }); + } + + const result = await this.updater.execute(split.stagePlan, params.context, params.run, { + collectChanges: true, + lockWait: false, + maxDirtyRecords, + dirtyBudgetMode: 'abort', + ledgerScopeId: params.ledgerScopeId, + }); + if (result.isErr()) return err(result.error); + const dirtyBudget = result.value.dirtyBudget; + if (dirtyBudget?.status !== 'exceeded') { + return ok({ split, result: result.value, selfReferential: false }); + } + + const shrinkBudget = { + maxSteps: Math.max(1, Math.floor(stepCount / 2)), + maxFields: this.outboxConfig.stageMaxFields, + maxEdges: this.outboxConfig.stageMaxEdges, + }; + this.logger.info('computed:worker:stage_dirty_budget_shrink', { + stepCount, + nextMaxSteps: shrinkBudget.maxSteps, + maxDirtyRecords, + dirtyRecordsAtAbort: dirtyBudget.dirtyRecordsAtAbort, + ...params.logContext, + }); + split = splitComputedPlanForStageBudget(params.plan, shrinkBudget); + } + } + + /** + * Shared stage settlement for both worker task kinds: collect the stage's dirty + * outputs and finish partial batches and deferred continuations in place. Returns + * 'continue' with the merged seed groups when the caller should proceed to its + * own next-stage planning. + */ + private async settleStage(params: { + task: AnyOutboxItem; + plan: ComputedUpdatePlan; + finalSplit: ComputedStagePlanSplit; + stageChanges: ComputedUpdateResult; + /** Actual output fields accumulated across every partial batch in this stage. */ + continuationFieldIds: ReadonlyArray; + selfReferential: boolean; + /** Collect scope when the stage ran whole (no deferral, no partial batch). */ + fallbackCollectTableIds?: ReadonlyArray; + runId: string; + /** Stage-ledger scope: the continuation chain's root task id. */ + ledgerScopeId: string; + originRunIds: ReadonlyArray; + runTotalSteps: number; + /** Run progress before this stage executed. */ + runCompletedStepsBefore: number; + stageDepth: number; + orchestration?: ComputedRealtimeOrchestrationDto; + context: IExecutionContext; + logContext: Record; + setPhase: ( + phase: 'collect_dirty_seed_groups' | 'enqueue_stage_continuation' | 'mark_done' + ) => void; + }): Promise< + Result< + | { kind: 'done'; processed: boolean } + | { + kind: 'continue'; + seedGroups: ComputedSeedGroup[]; + seedAllTableIds: TableId[]; + completedStepsAfter: number; + }, + DomainError + > + > { + const { finalSplit, stageChanges, plan } = params; + const finalStagePlan = finalSplit.stagePlan; + const partialOutcome = + stageChanges.dirtyBudget?.status === 'partial' ? stageChanges.dirtyBudget : undefined; + const completedStepsAfter = + params.runCompletedStepsBefore + (partialOutcome ? 0 : finalStagePlan.steps.length); + + params.setPhase('collect_dirty_seed_groups'); + // Stage OUTPUT tables: step tables plus propagation target tables. Edge-only + // stages (orphan-edge continuations) have no steps at all — their outputs + // live entirely in the edges' target tables, which must still feed the + // exclusion ledger (partial batches) and the completed-stage collection. + const stageStepTables = [ + ...new Map([ + ...finalStagePlan.steps.map((step) => [step.tableId.toString(), step.tableId] as const), + ...finalStagePlan.edges.map((edge) => [edge.toTableId.toString(), edge.toTableId] as const), + ]).values(), + ]; + // Whole-table SOURCE progress is tracked by per-table cursors (advanced only + // when the slice's propagation completed), so the exclusion ledger only ever + // holds processed TARGET rows from the stage's step tables. + const propagationTruncated = + partialOutcome !== undefined && + (partialOutcome.truncated === 'propagation' || partialOutcome.truncated === 'both'); + // One lifecycle decision drives frontier retirement AND collection: while + // deferred edge chunks remain, consumed sources carry forward. + const settlementMode = finalSplit.deferred !== null ? 'carry-sources' : 'stage-final'; + + if (partialOutcome) { + // The frontier is a seq-ordered queue in the run ledger: each batch seeds + // only its budget-bounded HEAD. If propagation completed, exactly the + // consumed head retires; otherwise the queue stays (the head re-seeds next + // batch and progresses via target exclusions). Settlement is SQL-side — + // no record ids cross into JS: + // - self-referential stages append rows NEW this batch (dirty step-table + // rows not yet excluded) to the queue tail; + // - every processed step-table row joins the exclusion ledger. + const ledgerResult = await this.updater.settleStageLedgerPartialBatch(params.context, { + scopeId: params.ledgerScopeId, + stepTableIds: stageStepTables, + appendFrontier: params.selfReferential, + retireFrontierUpToSeq: + !propagationTruncated && partialOutcome.frontierMaxSeq !== undefined + ? partialOutcome.frontierMaxSeq + : null, + settlementMode, + }); + if (ledgerResult.isErr()) return err(ledgerResult.error); + // Whole-table seeding progress: cursors advance only once the slice's + // propagation completed; a truncated slice re-seeds from the old cursor. + const seedAllCursors = propagationTruncated + ? plan.seedAllCursors + : partialOutcome.seedAllCursors ?? plan.seedAllCursors; + // Normalize every whole-table-seeded source (explicit seed-all and the + // implicit schema-update case alike, as reported by the batch itself) to + // explicit seedAllTableIds: continuations must not re-derive the implicit + // classification once seeds have migrated into the frontier queue. + const wholeTableSeedTables = mergeSeedAllTableIdLists( + plan.seedAllTableIds ?? [], + partialOutcome.wholeTableSeedTables ?? [] + ); + const finishResult = await this.finishStageWithPartialBatch({ + task: params.task, + setPhase: params.setPhase, + plan, + affectedFieldIds: params.continuationFieldIds.map((fieldId) => fieldId.toString()), + processedStats: ledgerResult.value.processedByTable, + ledgerStats: { + newFrontierRows: ledgerResult.value.newFrontierRows, + retiredFrontierRows: ledgerResult.value.retiredFrontierRows, + }, + seedAllTableIds: wholeTableSeedTables, + seedAllCursors, + runId: params.runId, + ledgerScopeId: params.ledgerScopeId, + originRunIds: params.originRunIds, + runTotalSteps: params.runTotalSteps, + runCompletedStepsBefore: completedStepsAfter, + stageDepth: params.stageDepth, + orchestration: params.orchestration, + context: params.context, + logContext: params.logContext, + }); + if (finishResult.isErr()) return err(finishResult.error); + return ok({ kind: 'done', processed: finishResult.value }); + } + + // Stage completed. Records processed by earlier partial batches are real + // dirty outputs of the stage and re-enter follow-up planning as seeds. The + // collection runs over the union of the dirty temp table and the exclusion + // ledger WITHOUT materializing it anywhere (no fold back into the + // transaction), keeping the final batch inside the dirty budget; per-table + // counts pick seed-all vs exact-id representation and the total exact ids + // are hard-capped. The stage's ledger state drops with it. + // Stages with deferred work also collect the deferred edges' SOURCE tables: + // preserved consumed sources (and any still-dirty source rows) must seed the + // continuation, or edge chunks after the first would propagate from nothing. + const deferredSourceTables = finalSplit.deferred + ? [ + ...new Map( + finalSplit.deferred.edges.map( + (edge) => [edge.fromTableId.toString(), edge.fromTableId] as const + ) + ).values(), + ] + : []; + const dirtyCollectionTableIds = finalSplit.deferred + ? [ + ...new Map( + [...stageStepTables, ...deferredSourceTables].map( + (tableId) => [tableId.toString(), tableId] as const + ) + ).values(), + ] + : params.fallbackCollectTableIds ?? stageStepTables; + const seedGroupsResult = await this.updater.collectStageOutputSeedGroups(params.context, { + scopeId: params.ledgerScopeId, + tableIds: dirtyCollectionTableIds, + seedAllThreshold: this.outboxConfig.stageSeedAllThreshold || undefined, + exactIdsTotalCap: this.outboxConfig.stageMaxCollectedSeedIds, + settlementMode, + }); + if (seedGroupsResult.isErr()) return err(seedGroupsResult.error); + const { groups: seedGroups, seedAllTableIds } = seedGroupsResult.value; + const clearResult = await this.updater.clearTaskStageLedger( + params.context, + params.ledgerScopeId + ); + if (clearResult.isErr()) return err(clearResult.error); + + if (finalSplit.deferred) { + const finishResult = await this.finishStageWithContinuation({ + task: params.task, + setPhase: params.setPhase, + plan, + deferred: finalSplit.deferred, + seedGroups, + seedAllTableIds, + runId: params.runId, + originRunIds: params.originRunIds, + runTotalSteps: params.runTotalSteps, + runCompletedStepsBefore: completedStepsAfter, + stageDepth: params.stageDepth, + orchestration: params.orchestration, + context: params.context, + logContext: params.logContext, + }); + if (finishResult.isErr()) return err(finishResult.error); + return ok({ kind: 'done', processed: finishResult.value }); + } + + return ok({ kind: 'continue', seedGroups, seedAllTableIds, completedStepsAfter }); + } + + /** + * Shared tail for a partial floor batch: the batch's outputs are already in + * the run ledger (SQL-side settlement), so the continuation is the same plan + * with only O(1) durable state — seed-all tables, cursors, and the lineage + * hash. The step is not complete, so run progress does not advance. + */ + private async finishStageWithPartialBatch(params: { + task: AnyOutboxItem; + setPhase: (phase: 'enqueue_stage_continuation' | 'mark_done') => void; + plan: ComputedUpdatePlan; + /** Bounded by schema width; never grows with the stage's record fan-out. */ + affectedFieldIds: ReadonlyArray; + /** Per-table processed counts from the batch's ledger settlement. */ + processedStats: ReadonlyArray<{ tableId: string; recordCount: number }>; + /** Ledger movement counts, logged for observability. */ + ledgerStats?: { newFrontierRows: number; retiredFrontierRows: number }; + /** Whole-table seed tables, normalized to explicit form on continuations. */ + seedAllTableIds?: ReadonlyArray; + /** Whole-table seeding resume cursors to persist on the continuation. */ + seedAllCursors?: Readonly>; + runId: string; + /** Stage-ledger scope carried to the continuation so the chain stays keyed. */ + ledgerScopeId: string; + originRunIds: ReadonlyArray; + runTotalSteps: number; + runCompletedStepsBefore: number; + stageDepth: number; + orchestration?: ComputedRealtimeOrchestrationDto; + context: IExecutionContext; + logContext: Record; + }): Promise> { + params.setPhase('enqueue_stage_continuation'); + const seedAllTableIds = params.seedAllTableIds ?? params.plan.seedAllTableIds; + const continuationPlan: ComputedUpdatePlan = { + ...params.plan, + ledgerScopeId: params.ledgerScopeId, + seedAllTableIds: seedAllTableIds && seedAllTableIds.length > 0 ? seedAllTableIds : undefined, + seedAllCursors: params.seedAllCursors, + // Explicit seeds live in the ledger queue (or retired with it) on partial + // batches. + seedRecordIds: [], + extraSeedRecords: [], + }; + const builtTask = buildOutboxTaskInput({ + plan: continuationPlan, + dirtyStats: [...params.processedStats], + syncMaxLevel: 0, + hasher: this.hasher, + runId: params.runId, + originRunIds: [...params.originRunIds], + runTotalSteps: params.runTotalSteps, + runCompletedStepsBefore: params.runCompletedStepsBefore, + stageDepth: params.stageDepth, + orchestration: params.orchestration, + affectedFieldIds: [...params.affectedFieldIds], + }); + const nextTask = { + ...builtTask, + planHash: buildContinuationPlanHash(builtTask.planHash, { + runId: params.runId, + stageIndex: params.runCompletedStepsBefore, + predecessorTaskId: params.task.id, + }), + }; + + const enqueueResult = await this.outbox.enqueueOrMerge(nextTask, params.context); + if (enqueueResult.isErr()) return err(enqueueResult.error); + + this.logger.info('computed:worker:stage_partial_batch_enqueued', { + continuationTaskId: enqueueResult.value.taskId, + processedRecordCount: params.processedStats.reduce( + (sum, group) => sum + group.recordCount, + 0 + ), + newFrontierRows: params.ledgerStats?.newFrontierRows, + retiredFrontierRows: params.ledgerStats?.retiredFrontierRows, + ...params.logContext, + }); + + params.setPhase('mark_done'); + const doneResult = await this.outbox.markDone(params.task, params.context); + if (doneResult.isErr()) return err(doneResult.error); + return ok(doneResult.value); + } + + /** + * Shared tail for both worker task kinds: enqueue the stage continuation and mark + * the current task done inside the caller's transaction. Returns markDone's outcome. + */ + private async finishStageWithContinuation( + params: Omit< + Parameters[0], + 'predecessorTaskId' + > & { + task: AnyOutboxItem; + setPhase: (phase: 'enqueue_stage_continuation' | 'mark_done') => void; + } + ): Promise> { + params.setPhase('enqueue_stage_continuation'); + const continuationResult = await this.enqueueStageContinuation({ + ...params, + predecessorTaskId: params.task.id, + }); + if (continuationResult.isErr()) return err(continuationResult.error); + + params.setPhase('mark_done'); + const doneResult = await this.outbox.markDone(params.task, params.context); + if (doneResult.isErr()) return err(doneResult.error); + return ok(doneResult.value); + } + + /** + * Enqueue the deferred remainder of a budget-split plan as a follow-up outbox task. + * Must run inside the same transaction that commits the executed stage and marks the + * current task done, so the continuation exists iff the stage's writes are durable. + */ + private async enqueueStageContinuation(params: { + plan: ComputedUpdatePlan; + deferred: NonNullable; + seedGroups: ReadonlyArray; + seedAllTableIds: ReadonlyArray; + runId: string; + originRunIds: ReadonlyArray; + runTotalSteps: number; + runCompletedStepsBefore: number; + stageDepth: number; + predecessorTaskId: string; + orchestration?: ComputedRealtimeOrchestrationDto; + context: IExecutionContext; + logContext: Record; + }): Promise> { + const continuationPlan = buildDeferredStagePlan({ + plan: params.plan, + deferred: params.deferred, + dirtySeedGroups: params.seedGroups, + dirtySeedAllTableIds: params.seedAllTableIds, + }); + + const builtTask = buildOutboxTaskInput({ + plan: continuationPlan, + dirtyStats: params.seedGroups.map((group) => ({ + tableId: group.tableId.toString(), + recordCount: group.recordIds.length, + })), + syncMaxLevel: 0, + hasher: this.hasher, + runId: params.runId, + originRunIds: [...params.originRunIds], + // Field-split stages can execute more step-slices than the original plan + // counted; keep the total ahead of completed so progress never overflows. + runTotalSteps: Math.max( + params.runTotalSteps, + params.runCompletedStepsBefore + params.deferred.steps.length + ), + runCompletedStepsBefore: params.runCompletedStepsBefore, + stageDepth: params.stageDepth, + orchestration: params.orchestration, + }); + const nextTask = { + ...builtTask, + planHash: buildContinuationPlanHash(builtTask.planHash, { + runId: params.runId, + stageIndex: params.runCompletedStepsBefore, + predecessorTaskId: params.predecessorTaskId, + }), + }; + + const enqueueResult = await this.outbox.enqueueOrMerge(nextTask, params.context); + if (enqueueResult.isErr()) return err(enqueueResult.error); + + this.logger.info('computed:worker:stage_continuation_enqueued', { + continuationTaskId: enqueueResult.value.taskId, + merged: enqueueResult.value.merged, + executedSteps: params.runCompletedStepsBefore, + deferredSteps: params.deferred.steps.length, + deferredEdges: params.deferred.edges.length, + continuationSeedGroups: continuationPlan.extraSeedRecords.length, + continuationSeedAllTables: continuationPlan.seedAllTableIds?.length ?? 0, + ...params.logContext, + }); + return ok(undefined); + } + private async planNextStage( plan: ComputedUpdatePlan, context: IExecutionContext, @@ -1650,7 +2326,11 @@ export class ComputedUpdateWorker { seedAllTableIds?: ReadonlyArray, changesByStep: ReadonlyArray = [] ): Promise> { - if (plan.edges.length === 0) return ok({ ...plan, steps: [], edges: [] }); + // NOTE: do NOT shortcut on plan.edges being empty. Budget-split stages can + // execute a step whose outgoing edges were assigned to a SIBLING stage + // (e.g. a link-title edge classified as orphan because its hosting step + // lives outside this task), so an edge-less stage's changes may still have + // cross-record downstream work that only a fresh planner pass can see. if (seedFieldIds.length === 0 && (!seedAllTableIds || seedAllTableIds.length === 0)) return ok({ ...plan, steps: [], edges: [] }); @@ -1733,10 +2413,12 @@ const toPayload = (task: ComputedUpdateOutboxItem): ComputedUpdateOutboxPayload extraSeedRecords: task.extraSeedRecords, beforeImageRecords: task.beforeImageRecords, steps: task.steps, + sameTableBatches: task.sameTableBatches, edges: task.edges, estimatedComplexity: task.estimatedComplexity, changeType: task.changeType, seedAllTableIds: task.seedAllTableIds, + seedAllCursors: task.seedAllCursors, }); const collectSeedFieldIds = ( @@ -1760,10 +2442,43 @@ const collectSeedFieldIds = ( ids.set(parsed.value.toString(), parsed.value); } } + if (ids.size === 0) { + // Edge-only tasks carry no step fields: their propagation targets are the + // stage's outputs and must still drive downstream planning. + for (const edge of task.edges) { + for (const rawFieldId of edge.propagationTargetFieldIds ?? [edge.toFieldId]) { + const parsed = FieldId.create(rawFieldId); + if (parsed.isErr()) return err(parsed.error); + ids.set(parsed.value.toString(), parsed.value); + } + } + } return ok([...ids.values()]); }; +/** + * Union this batch's real outputs with outputs carried by earlier partial + * batches of the same stage. The set is bounded by schema width and resets + * when a new stage is planned. + */ +const collectStageContinuationFieldIds = ( + plan: ComputedUpdatePlan, + changesByStep: ReadonlyArray, + carriedFieldIds: ReadonlyArray +): Result, DomainError> => { + const ids = new Map(); + for (const rawFieldId of carriedFieldIds) { + const fieldId = FieldId.create(rawFieldId); + if (fieldId.isErr()) return err(fieldId.error); + ids.set(fieldId.value.toString(), fieldId.value); + } + for (const fieldId of collectContinuationFieldIds(plan, changesByStep)) { + ids.set(fieldId.toString(), fieldId); + } + return ok([...ids.values()]); +}; + const collectSeedTableIds = ( task: ComputedUpdateOutboxItem ): Result, DomainError> => { @@ -1776,13 +2491,16 @@ const collectSeedTableIds = ( ids.set(parsed.value.toString(), parsed.value); } - if (ids.size > 0) return ok([...ids.values()]); - for (const step of task.steps) { const parsed = TableId.create(step.tableId); if (parsed.isErr()) return err(parsed.error); ids.set(parsed.value.toString(), parsed.value); } + for (const edge of task.edges) { + const parsed = TableId.create(edge.toTableId); + if (parsed.isErr()) return err(parsed.error); + ids.set(parsed.value.toString(), parsed.value); + } return ok([...ids.values()]); }; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/normalizeLinkItems.ts b/packages/v2/adapter-table-repository-postgres/src/record/normalizeLinkItems.ts index e2556974f7..651fec936d 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/normalizeLinkItems.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/normalizeLinkItems.ts @@ -6,8 +6,17 @@ export const normalizeStoredLinkItems = ( } const items = Array.isArray(rawValue) ? rawValue : [rawValue]; - return items.filter( - (item): item is { id: string; title?: string } => - !!item && typeof item === 'object' && 'id' in item && typeof item.id === 'string' - ); + return items + .filter( + (item): item is { id: string; title?: string | null } => + !!item && typeof item === 'object' && 'id' in item && typeof item.id === 'string' + ) + .map((item) => { + const title = item.title; + if (typeof title === 'string') { + return { id: item.id, title }; + } + // Drop null/undefined titles so writes match jsonb_strip_nulls storage. + return { id: item.id }; + }); }; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.spec.ts index d2916ff923..3aec1c4a3b 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.spec.ts @@ -1430,6 +1430,101 @@ describe('ComputedTableRecordQueryBuilder', () => { ); }); + test('keeps lookup laterals separate when sort or limit differs', () => { + const baseId = BaseId.create(BASE_ID)._unsafeUnwrap(); + const mainTableId = TableId.create(MAIN_TABLE_ID)._unsafeUnwrap(); + const foreignTableId = TableId.create(FOREIGN_TABLE_ID)._unsafeUnwrap(); + const linkFieldId = FieldId.create(LINK_FIELD_ID)._unsafeUnwrap(); + const targetFieldId = FieldId.create(LOOKUP_TARGET_FIELD_ID)._unsafeUnwrap(); + const lookupFieldIds = ['a', 'b', 'c', 'd'].map((suffix) => + FieldId.create(`fld${suffix.repeat(16)}`)._unsafeUnwrap() + ); + + const foreignBuilder = Table.builder() + .withId(foreignTableId) + .withBaseId(baseId) + .withName(TableName.create('ForeignTable')._unsafeUnwrap()); + foreignBuilder + .field() + .singleLineText() + .withId(targetFieldId) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .done(); + foreignBuilder.view().defaultGrid().done(); + const foreignTable = foreignBuilder.build()._unsafeUnwrap(); + foreignTable + .getFields()[0] + .setDbFieldName(DbFieldName.rehydrate('col_title')._unsafeUnwrap()) + ._unsafeUnwrap(); + + const linkConfig = LinkFieldConfig.create({ + relationship: 'manyMany', + foreignTableId: foreignTableId.toString(), + lookupFieldId: targetFieldId.toString(), + symmetricFieldId: SYMMETRIC_FIELD_ID, + })._unsafeUnwrap(); + const conditions = [ + { sort: { fieldId: targetFieldId.toString(), order: 'asc' as const }, limit: 1 }, + { sort: { fieldId: targetFieldId.toString(), order: 'desc' as const }, limit: 1 }, + { sort: { fieldId: targetFieldId.toString(), order: 'desc' as const }, limit: 2 }, + { limit: 1 }, + ]; + + const mainBuilder = Table.builder() + .withId(mainTableId) + .withBaseId(baseId) + .withName(TableName.create('MainTable')._unsafeUnwrap()); + mainBuilder + .field() + .singleLineText() + .withName(FieldName.create('Name')._unsafeUnwrap()) + .done(); + mainBuilder + .field() + .link() + .withId(linkFieldId) + .withName(FieldName.create('Link')._unsafeUnwrap()) + .withConfig(linkConfig) + .done(); + lookupFieldIds.forEach((lookupFieldId, index) => { + const lookupOptions = LookupOptions.create({ + linkFieldId: linkFieldId.toString(), + foreignTableId: foreignTableId.toString(), + lookupFieldId: targetFieldId.toString(), + ...conditions[index], + })._unsafeUnwrap(); + mainBuilder + .field() + .lookup() + .withId(lookupFieldId) + .withName(FieldName.create(`Lookup ${index + 1}`)._unsafeUnwrap()) + .withLookupOptions(lookupOptions) + .withInnerField(foreignTable.getFields()[0]) + .done(); + }); + mainBuilder.view().defaultGrid().done(); + + const mainTable = mainBuilder.build({ foreignTables: [foreignTable] })._unsafeUnwrap(); + mainTable.getFields().forEach((field, index) => { + field.setDbFieldName(DbFieldName.rehydrate(`col_${index}`)._unsafeUnwrap())._unsafeUnwrap(); + }); + + const db = createTestDb(); + const foreignTables = new Map([[foreignTableId.toString(), foreignTable]]); + const { sql } = compileQuery( + db, + new ComputedTableRecordQueryBuilder(db, { foreignTables, typeValidationStrategy }) + .from(mainTable) + .select(lookupFieldIds) + ); + + expect(sql.match(/inner join lateral/g) || []).toHaveLength(4); + expect(sql).toContain('order by "f"."col_title" asc, (SELECT "j"."__id"'); + expect(sql).toMatch( + /order by \(SELECT "j"\."__order"[\s\S]+?\(SELECT "j"\."__id"[\s\S]+?limit \$\d+/ + ); + }); + test('coerces single-value number lookup json scalar before boolean formula comparison', () => { const db = createTestDb(); const baseId = BaseId.create(BASE_ID)._unsafeUnwrap(); @@ -2100,6 +2195,25 @@ describe('ComputedTableRecordQueryBuilder', () => { expect(sql).toContain('inner join lateral'); }); + test('orders limit-only rollup rows before truncating full-row reads', () => { + const db = createTestDb(); + const { mainTable, foreignTable, foreignTableId } = createRollupTable('sum({values})', { + relationship: 'manyMany', + limit: 1, + }); + const foreignTables = new Map([[foreignTableId.toString(), foreignTable]]); + const { sql } = compileQuery( + db, + new ComputedTableRecordQueryBuilder(db, { foreignTables, typeValidationStrategy }).from( + mainTable + ) + ); + + expect(sql).toMatch( + /order by \(SELECT "j"\."__order"[\s\S]+?\(SELECT "j"\."__id"[\s\S]+?limit \$\d+/ + ); + }); + test('rollup sum snapshot', () => { const db = createTestDb(); const { mainTable, foreignTable, foreignTableId } = createRollupTable('sum({values})'); @@ -2732,7 +2846,7 @@ describe('ComputedTableRecordQueryBuilder', () => { // Different residual predicates become independent set-based host joins. expect(sql).not.toContain('inner join lateral'); expect((sql.match(/left join \(select/g) || []).length).toBe(2); - expect(sql).toContain('"f"."col_category" = "h"."col_category_ref"'); + expect(sql).toContain('to_jsonb("f"."col_category") = to_jsonb("h"."col_category_ref")'); expect(sql).toContain('"f"."col_status" = $'); expect(parameters).toEqual(expect.arrayContaining(['active', 'inactive'])); expect(sql).toContain('group by "h"."col_category_ref"'); @@ -2881,10 +2995,10 @@ describe('ComputedTableRecordQueryBuilder', () => { 'coalesce("cond_fldcccccccccccccccc"."__host_key", \'\'::text) = coalesce("t"."col_category_ref", \'\'::text)' ); expect(sql).toContain('left join "bseaaaaaaaaaaaaaaaa"."tblffffffffffffffff" as "f"'); - expect(sql).toContain('"f"."col_category" = "h"."col_category_ref"'); + expect(sql).toContain('to_jsonb("f"."col_category") = to_jsonb("h"."col_category_ref")'); expect(sql).toContain('group by "h"."col_category_ref"'); expect(sql).toMatchInlineSnapshot( - `"select "t"."__id" as "__id", "t"."__version" as "__version", "t"."col_category_ref" as "col_category_ref", "cond_fldcccccccccccccccc"."col_conditional_rollup" as "col_conditional_rollup" from "bseaaaaaaaaaaaaaaaa"."tblmmmmmmmmmmmmmmmm" as "t" left join (select "h"."col_category_ref" as "__host_key", CAST(COALESCE(SUM("f"."col_number"), 0) AS DOUBLE PRECISION) as "col_conditional_rollup" from (select distinct "h"."col_category_ref" as "col_category_ref" from "bseaaaaaaaaaaaaaaaa"."tblmmmmmmmmmmmmmmmm" as "h") as "h" left join "bseaaaaaaaaaaaaaaaa"."tblffffffffffffffff" as "f" on "f"."col_category" = "h"."col_category_ref" group by "h"."col_category_ref") as "cond_fldcccccccccccccccc" on ("cond_fldcccccccccccccccc"."__host_key" is null) = ("t"."col_category_ref" is null) and coalesce("cond_fldcccccccccccccccc"."__host_key", ''::text) = coalesce("t"."col_category_ref", ''::text)"` + `"select "t"."__id" as "__id", "t"."__version" as "__version", "t"."col_category_ref" as "col_category_ref", "cond_fldcccccccccccccccc"."col_conditional_rollup" as "col_conditional_rollup" from "bseaaaaaaaaaaaaaaaa"."tblmmmmmmmmmmmmmmmm" as "t" left join (select "h"."col_category_ref" as "__host_key", CAST(COALESCE(SUM("f"."col_number"), 0) AS DOUBLE PRECISION) as "col_conditional_rollup" from (select distinct "h"."col_category_ref" as "col_category_ref" from "bseaaaaaaaaaaaaaaaa"."tblmmmmmmmmmmmmmmmm" as "h") as "h" left join "bseaaaaaaaaaaaaaaaa"."tblffffffffffffffff" as "f" on to_jsonb("f"."col_category") = to_jsonb("h"."col_category_ref") group by "h"."col_category_ref") as "cond_fldcccccccccccccccc" on ("cond_fldcccccccccccccccc"."__host_key" is null) = ("t"."col_category_ref" is null) and coalesce("cond_fldcccccccccccccccc"."__host_key", ''::text) = coalesce("t"."col_category_ref", ''::text)"` ); }); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.ts index bde01070fe..87c31c001d 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.ts @@ -64,6 +64,12 @@ const F = 'f'; // foreign table alias in lateral const H = 'h'; // host table alias in set-based aggregate joins const DEFAULT_CONDITIONAL_ORDER_BY = { column: '__auto_number', direction: 'asc' } as const; +type ResolvedConditionalOrderBy = { + column: string; + direction: 'asc' | 'desc'; + tieBreaker?: LinkOrderBy; +}; + const parsePositiveInt = (raw: string | undefined, fallback: number): number => { if (!raw) return fallback; const parsed = Number(raw); @@ -1019,7 +1025,7 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder if ( (columnType.type !== 'lookup' && columnType.type !== 'rollup') || !columnType.condition || - !columnType.condition.hasFilter() + columnType.condition.isEmpty() ) { return ''; } @@ -1103,7 +1109,9 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder columns: [], condition: (columnType.type === 'lookup' || columnType.type === 'rollup') && - columnType.condition?.hasFilter() + (columnType.condition?.hasFilter() || + columnType.condition?.hasSort() || + columnType.condition?.hasLimit()) ? columnType.condition : undefined, }); @@ -1262,10 +1270,31 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder continue; } + // Optional condition sort/limit (plain lookup/rollup options). The + // schema accepted them but the pipeline ignored them (T6520): the + // sort overrides the link-order aggregation ranking and the limit + // restricts the correlated row source per host record. + const resolvedConditionSort = lateral.condition?.hasSort() + ? yield* this.resolveConditionalSort(foreignTable, lateral.condition) + : null; + const conditionLimit = lateral.condition?.hasLimit() + ? lateral.condition.limit() + : undefined; + const linkOrderBy = lateral.columns.reduce( + (current, column) => + current ?? ('orderBy' in column.columnType ? column.columnType.orderBy : undefined), + undefined + ); + const conditionSort = resolvedConditionSort + ? { ...resolvedConditionSort, tieBreaker: linkOrderBy } + : null; + const selectExprs: AliasedRawBuilder[] = []; for (const col of lateral.columns) { selectExprs.push( - yield* this.buildLateralSelectExpr(foreignTable, col.columnType, col.outputAlias) + yield* this.buildLateralSelectExpr(foreignTable, col.columnType, col.outputAlias, { + orderByOverride: conditionSort ?? undefined, + }) ); } @@ -1276,13 +1305,36 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder lateral.condition ); - let baseQuery = this.db - .selectFrom(`${foreignTableName} as ${F}`) - .select(selectExprs) - .where(joinCondition); + let baseQuery; + if (conditionSort || conditionLimit !== undefined) { + let rowsQuery = this.db + .selectFrom(`${foreignTableName} as ${F}`) + .selectAll(F) + .where(joinCondition); + if (filterWhere !== null) { + rowsQuery = rowsQuery.where(filterWhere); + } + const rowsOrderBy = conditionSort + ? buildResolvedConditionalOrderByExpr(conditionSort) + : conditionLimit !== undefined + ? buildLinkOrderByExpr(linkOrderBy) ?? sql.ref(`${F}.__auto_number`) + : null; + if (rowsOrderBy) { + rowsQuery = rowsQuery.orderBy(rowsOrderBy); + } + if (conditionLimit !== undefined) { + rowsQuery = rowsQuery.limit(conditionLimit); + } + baseQuery = this.db.selectFrom(rowsQuery.as(F)).select(selectExprs); + } else { + baseQuery = this.db + .selectFrom(`${foreignTableName} as ${F}`) + .select(selectExprs) + .where(joinCondition); - if (filterWhere !== null) { - baseQuery = baseQuery.where(filterWhere); + if (filterWhere !== null) { + baseQuery = baseQuery.where(filterWhere); + } } subqueries.push({ query: baseQuery.as(lateral.alias), joinMode: 'lateral' }); @@ -1572,6 +1624,10 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder const condition = match(firstColumnType) .with({ type: 'conditionalLookup' }, (c) => c.condition) .with({ type: 'conditionalRollup' }, (c) => c.condition) + // Plain lookup/rollup conditions carry optional sort/limit too — + // without this they were accepted by the schema but never applied. + .with({ type: 'lookup' }, (c) => c.condition) + .with({ type: 'rollup' }, (c) => c.condition) .otherwise(() => undefined); const sourceOnlyRollupGroup = isSourceOnlyConditionalRollupGroup( @@ -1589,6 +1645,12 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder const sortClause = condition ? yield* this.resolveConditionalSort(foreignTable, condition) : null; + const linkOrderBy = lateral.columns.reduce( + (current, column) => + current ?? ('orderBy' in column.columnType ? column.columnType.orderBy : undefined), + undefined + ); + const resolvedSortClause = sortClause ? { ...sortClause, tieBreaker: linkOrderBy } : null; const configuredLimit = condition?.limit(); const isConditionalDerived = firstColumnType?.type === 'conditionalLookup' || @@ -1600,7 +1662,9 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder !condition?.hasLimit(); const limitValue = canUseUnboundedOrderlessRollup ? undefined - : configuredLimit ?? CONDITIONAL_QUERY_DEFAULT_LIMIT; + : isConditionalDerived + ? configuredLimit ?? CONDITIONAL_QUERY_DEFAULT_LIMIT + : configuredLimit; const useUncorrelatedRollupFastPath = this.shouldUseConditionalRollupFastPath(foreignTable, firstColumnType) && !sharedFieldRefGroup; @@ -1608,11 +1672,13 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder isConditionalDerived && !canUseUnboundedOrderlessRollup ? DEFAULT_CONDITIONAL_ORDER_BY : undefined; - const orderByForSelect = sortClause ?? defaultOrderBy; + const orderByForSelect = resolvedSortClause ?? defaultOrderBy; const orderByForLimit = - sortClause ?? - (limitValue !== undefined && isConditionalDerived - ? DEFAULT_CONDITIONAL_ORDER_BY + resolvedSortClause ?? + (limitValue !== undefined + ? isConditionalDerived + ? DEFAULT_CONDITIONAL_ORDER_BY + : linkOrderBy ?? DEFAULT_CONDITIONAL_ORDER_BY : null); const needsSubquery = Boolean(orderByForLimit || limitValue); const sourceAlias = needsSubquery ? `${lateral.alias}_src` : F; @@ -1645,10 +1711,11 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder baseQuery = baseQuery.where(whereClause); } if (orderByForLimit !== null) { - baseQuery = baseQuery.orderBy( - sql.ref(`${F}.${orderByForLimit.column}`), - orderByForLimit.direction - ); + const orderByExpr = + 'source' in orderByForLimit + ? buildLinkOrderByExpr(orderByForLimit) + : buildResolvedConditionalOrderByExpr(orderByForLimit); + if (orderByExpr) baseQuery = baseQuery.orderBy(orderByExpr); } if (limitValue !== undefined) { baseQuery = baseQuery.limit(limitValue); @@ -2125,7 +2192,8 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder private buildLateralSelectExpr( foreignTable: Table, columnType: LateralColumnType, - outputAlias: string + outputAlias: string, + options?: { orderByOverride?: ResolvedConditionalOrderBy } ): Result, DomainError> { return ( match(columnType) @@ -2237,13 +2305,13 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder ) .with({ type: 'lookup' }, ({ foreignFieldId, orderBy, isMultiValue }) => this.buildLookupAggExpr(foreignTable, foreignFieldId, outputAlias, { - orderBy, + orderBy: options?.orderByOverride ?? orderBy, isMultiValue, }) ) .with({ type: 'rollup' }, ({ foreignFieldId, expression, orderBy }) => this.buildRollupAggregateExpr(foreignTable, foreignFieldId, expression, { - orderBy, + orderBy: options?.orderByOverride ?? orderBy, }).map((expr: RawBuilder) => expr.as(outputAlias)) ) // Conditional types are handled in buildConditionalJoins, not here @@ -2440,7 +2508,7 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder outputAlias: string, options?: { tableAlias?: string; - orderBy?: LinkOrderBy | { column: string; direction: 'asc' | 'desc' }; + orderBy?: LinkOrderBy | ResolvedConditionalOrderBy; isMultiValue?: boolean; } ): Result, DomainError> { @@ -2456,7 +2524,7 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder const orderByExpr = orderBy ? 'source' in orderBy ? buildLinkOrderByExpr(orderBy) - : sql`${sql.ref(`${tableAlias}.${orderBy.column}`)} ${sql.raw(orderBy.direction)}` + : buildResolvedConditionalOrderByExpr(orderBy, tableAlias) : null; // Include leading space in orderByRef so no trailing space when empty const orderByRef = orderByExpr ? sql` order by ${orderByExpr}` : sql``; @@ -2520,7 +2588,7 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder expression: RollupFunction, options?: { tableAlias?: string; - orderBy?: LinkOrderBy | { column: string; direction: 'asc' | 'desc' }; + orderBy?: LinkOrderBy | ResolvedConditionalOrderBy; filterWhere?: Expression; } ): Result, DomainError> { @@ -2528,9 +2596,7 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder const orderByExpr = options?.orderBy ? 'source' in options.orderBy ? buildLinkOrderByExpr(options.orderBy) - : sql`${sql.ref(`${tableAlias}.${options.orderBy.column}`)} ${sql.raw( - options.orderBy.direction - )}` + : buildResolvedConditionalOrderByExpr(options.orderBy, tableAlias) : null; const orderBySql = orderByExpr ? sql` ORDER BY ${orderByExpr}` : sql``; const filterAgg = (agg: RawBuilder): RawBuilder => @@ -2940,29 +3006,56 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder } } -const buildLinkOrderByExpr = (orderBy?: LinkOrderBy): RawBuilder | null => { +const buildResolvedConditionalOrderByExpr = ( + orderBy: ResolvedConditionalOrderBy, + tableAlias = F +): RawBuilder => { + if ( + orderBy.column === '__auto_number' && + (!orderBy.tieBreaker || orderBy.tieBreaker.source === 'foreign') + ) { + return sql`${sql.ref(`${tableAlias}.${orderBy.column}`)} ${sql.raw(orderBy.direction)}`; + } + const tieBreaker = buildStableTieBreakerExpr(orderBy.tieBreaker, tableAlias); + return sql`${sql.ref(`${tableAlias}.${orderBy.column}`)} ${sql.raw( + orderBy.direction + )}, ${tieBreaker} asc`; +}; + +const buildStableTieBreakerExpr = (orderBy?: LinkOrderBy, tableAlias = F): RawBuilder => { + if (!orderBy || orderBy.source === 'foreign') { + return sql.ref(`${tableAlias}.__auto_number`); + } + + return sql`(SELECT ${sql.ref(`j.__id`)} FROM ${sql.table(orderBy.junctionTable)} AS j WHERE ${sql.ref(`j.${orderBy.selfKey}`)} = ${sql.ref(`${T}.__id`)} AND ${sql.ref(`j.${orderBy.foreignKey}`)} = ${sql.ref(`${tableAlias}.__id`)})`; +}; + +const buildLinkOrderByExpr = ( + orderBy?: LinkOrderBy, + tableAlias = F +): RawBuilder | null => { if (!orderBy) return null; if (orderBy.source === 'foreign') { // If explicit order column exists, use it with __auto_number as tie-breaker if (orderBy.column) { - return sql`${sql.ref(`${F}.${orderBy.column}`)}, ${sql.ref(`${F}.__auto_number`)}`; + return sql`${sql.ref(`${tableAlias}.${orderBy.column}`)}, ${sql.ref(`${tableAlias}.__auto_number`)}`; } // No explicit order column - use __auto_number to maintain insertion/creation order // Foreign tables (regular data tables) have __auto_number column that reflects creation order // This is critical for tests that expect stable ordering based on record creation time - return sql`${sql.ref(`${F}.__auto_number`)}`; + return sql`${sql.ref(`${tableAlias}.__auto_number`)}`; } // Junction-based ordering (ManyMany, OneMany one-way) if (orderBy.column) { // Explicit order column exists - use it with junction __id as tie-breaker // This ensures stable ordering when multiple records have the same order value - return sql`(SELECT ${sql.ref(`j.${orderBy.column}`)} FROM ${sql.table(orderBy.junctionTable)} AS j WHERE ${sql.ref(`j.${orderBy.selfKey}`)} = ${sql.ref(`${T}.__id`)} AND ${sql.ref(`j.${orderBy.foreignKey}`)} = ${sql.ref(`${F}.__id`)}), (SELECT ${sql.ref(`j.__id`)} FROM ${sql.table(orderBy.junctionTable)} AS j WHERE ${sql.ref(`j.${orderBy.selfKey}`)} = ${sql.ref(`${T}.__id`)} AND ${sql.ref(`j.${orderBy.foreignKey}`)} = ${sql.ref(`${F}.__id`)})`; + return sql`(SELECT ${sql.ref(`j.${orderBy.column}`)} FROM ${sql.table(orderBy.junctionTable)} AS j WHERE ${sql.ref(`j.${orderBy.selfKey}`)} = ${sql.ref(`${T}.__id`)} AND ${sql.ref(`j.${orderBy.foreignKey}`)} = ${sql.ref(`${tableAlias}.__id`)}), (SELECT ${sql.ref(`j.__id`)} FROM ${sql.table(orderBy.junctionTable)} AS j WHERE ${sql.ref(`j.${orderBy.selfKey}`)} = ${sql.ref(`${T}.__id`)} AND ${sql.ref(`j.${orderBy.foreignKey}`)} = ${sql.ref(`${tableAlias}.__id`)})`; } // No explicit order column - use junction table's __id to maintain insertion order // Junction tables only have __id (serial), not __auto_number // This is critical for tests that expect stable ordering based on link creation order - return sql`(SELECT ${sql.ref(`j.__id`)} FROM ${sql.table(orderBy.junctionTable)} AS j WHERE ${sql.ref(`j.${orderBy.selfKey}`)} = ${sql.ref(`${T}.__id`)} AND ${sql.ref(`j.${orderBy.foreignKey}`)} = ${sql.ref(`${F}.__id`)})`; + return sql`(SELECT ${sql.ref(`j.__id`)} FROM ${sql.table(orderBy.junctionTable)} AS j WHERE ${sql.ref(`j.${orderBy.selfKey}`)} = ${sql.ref(`${T}.__id`)} AND ${sql.ref(`j.${orderBy.foreignKey}`)} = ${sql.ref(`${tableAlias}.__id`)})`; }; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.spec.ts index db6cb3e278..4dc7e0742d 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.spec.ts @@ -1,6 +1,7 @@ import { BaseId, DbFieldName, + DbFieldType, createCreatedByField, createFormulaField, createLastModifiedByField, @@ -730,6 +731,33 @@ describe('SameTableBatchQueryBuilder', () => { 'FROM "bseaaaaaaaaaaaaaaaa"."tblcccccccccccccccc" AS u, "level_0", "level_1"' ); }); + it('materializes JSON-backed formula CTE levels', () => { + const db = createMockKysely(); + const builder = new SameTableBatchQueryBuilder(db, typeValidationStrategy); + const { table, plusOneId, plusOneDoubleId } = createChainedFormulaTable(); + + for (const fieldId of [plusOneId, plusOneDoubleId]) { + table + .getField((field) => field.id().equals(fieldId)) + ._unsafeUnwrap() + .setDbFieldType(DbFieldType.rehydrate('JSON')._unsafeUnwrap()) + ._unsafeUnwrap(); + } + + const result = builder.build({ + table, + fieldLevels: [ + { level: 0, fieldIds: [plusOneId] }, + { level: 1, fieldIds: [plusOneDoubleId] }, + ], + dirtyFilter: { tableId: table.id().toString() }, + }); + + expect(result.isOk()).toBe(true); + const compiled = result._unsafeUnwrap().selectQuery.compile(db); + expect(compiled.sql).toContain('"level_0" AS MATERIALIZED'); + expect(compiled.sql).toContain('"level_1" AS MATERIALIZED'); + }); it('builds returning updates from a same-table CTE chain', () => { const db = createMockKysely(); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.ts index b08d70b321..cc64c6015b 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.ts @@ -338,10 +338,12 @@ export class SameTableBatchQueryBuilder { columnName: string; errorColumnName?: string; }> = []; + let materialized = false; // Build select expressions for each field in this level for (const field of fields) { const columnName = yield* this.getColumnName(field); + materialized ||= this.isJsonBackedField(field); const expr = yield* this.buildFieldExpression(table, field, previousCteColumns); computedFragments.push( @@ -366,6 +368,7 @@ export class SameTableBatchQueryBuilder { level, fragments: [...carryForwardFragments, ...computedFragments], previousCteName: ctes.length > 0 ? ctes[ctes.length - 1].name : undefined, + materialized, }) ); @@ -636,6 +639,15 @@ export class SameTableBatchQueryBuilder { return 'scalar'; } + private isJsonBackedField(field: Field): boolean { + const dbFieldType = field + .dbFieldType() + .andThen((type) => type.value()) + .map((type) => type.trim().toUpperCase()) + .unwrapOr(''); + return dbFieldType === 'JSON' || dbFieldType === 'JSONB'; + } + private isJsonStorageFieldType(fieldType: FieldType): boolean { const typeString = fieldType.toString(); return ( diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchSqlPlan.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchSqlPlan.ts index a26d6c95ee..1a8904f8b1 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchSqlPlan.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchSqlPlan.ts @@ -11,6 +11,7 @@ type CteLevelSqlPlanParams = { level: number; previousCteName?: string; fragments: ReadonlyArray; + materialized: boolean; }; const normalizeExpressionKey = (sqlText: string): string => sqlText.replace(/\s+/g, ' ').trim(); @@ -62,6 +63,7 @@ export class CteLevelSqlPlan { readonly level: number; readonly previousCteName?: string; readonly fragments: ReadonlyArray; + readonly materialized: boolean; readonly cseBindings: ReadonlyArray; private readonly cseBindingsByKey: ReadonlyMap; @@ -73,6 +75,7 @@ export class CteLevelSqlPlan { this.level = params.level; this.previousCteName = params.previousCteName; this.fragments = params.fragments; + this.materialized = params.materialized; this.cseBindings = cseBindings; this.cseBindingsByKey = new Map(cseBindings.map((binding) => [binding.normalizedKey, binding])); } @@ -138,6 +141,7 @@ export class CteLevelSqlPlan { buildCteSql(fromClause: string): string { const selectColumns = this.buildSelectColumnsSql(); const cseJoin = this.buildCseJoinSql(); - return `${quoteIdentifier(this.name)} AS (SELECT ${quoteRef('t', '__id')}, ${selectColumns} ${fromClause}${cseJoin})`; + const materialized = this.materialized ? ' MATERIALIZED' : ''; + return `${quoteIdentifier(this.name)} AS${materialized} (SELECT ${quoteRef('t', '__id')}, ${selectColumns} ${fromClause}${cseJoin})`; } } diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/__snapshots__/SameTableBatchQueryBuilder.spec.ts.snap b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/__snapshots__/SameTableBatchQueryBuilder.spec.ts.snap index 31e9d43509..512af65106 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/__snapshots__/SameTableBatchQueryBuilder.spec.ts.snap +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/__snapshots__/SameTableBatchQueryBuilder.spec.ts.snap @@ -6,5 +6,5 @@ exports[`SameTableBatchQueryBuilder > build() > uses scalar lookup columns direc WHEN BTRIM(("c_src"."LookupAmountDoubled")::text) ~ '^[+-]?([0-9]+([.][0-9]+)?|[.][0-9]+)([eE][+-]?[0-9]+)?$' THEN BTRIM(("c_src"."LookupAmountDoubled")::text)::double precision ELSE NULL - END as "__set_LookupAmountDoubled" from (WITH "level_0" AS (SELECT "t"."__id", (NULLIF(BTRIM((ROUND(((COALESCE(("t"."LookupAmount")::double precision, 0) * COALESCE((2)::double precision, 0)))::double precision::numeric, (2)::double precision::integer))::text), '')::double precision) as "LookupAmountDoubled" FROM "bseaaaaaaaaaaaaaaaa"."tbltttttttttttttttt" AS "t") SELECT "u"."__id", "level_0"."LookupAmountDoubled" as "LookupAmountDoubled" FROM "bseaaaaaaaaaaaaaaaa"."tbltttttttttttttttt" AS "u" JOIN "level_0" ON "u"."__id" = "level_0"."__id") as "c_src") as "c" where "u"."__id" = "c"."__id" and ("u"."LookupAmountDoubled" IS DISTINCT FROM "c"."__set_LookupAmountDoubled")" + END as "__set_LookupAmountDoubled" from (WITH "level_0" AS (SELECT "t"."__id", (NULLIF(BTRIM((ROUND(((COALESCE(("t"."LookupAmount")::double precision, 0) * COALESCE((2)::double precision, 0)))::double precision::numeric, (2)::double precision::integer))::text), '')::double precision) as "LookupAmountDoubled" FROM "bseaaaaaaaaaaaaaaaa"."tbltttttttttttttttt" AS "t") SELECT "u"."__id", "level_0"."LookupAmountDoubled" as "LookupAmountDoubled" FROM "bseaaaaaaaaaaaaaaaa"."tbltttttttttttttttt" AS "u" JOIN "level_0" ON "u"."__id" = "level_0"."__id") as "c_src") as "c" where "u"."__id" = "c"."__id" and (("u"."LookupAmountDoubled")::double precision IS DISTINCT FROM ("c"."__set_LookupAmountDoubled")::double precision)" `; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/dateLikeOrderBy.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/dateLikeOrderBy.ts index 72d6d0e593..c2c84a9d68 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/dateLikeOrderBy.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/dateLikeOrderBy.ts @@ -24,11 +24,12 @@ const getPostgresDateSortFormatString = (date: string): string => { } }; -export const buildDateLikeOrderExpression = ( - field: unknown, - tableAlias: string, - column: string -): RawBuilder | null => { +const resolveDateLikeFormatting = ( + field: unknown +): { + fieldType: { equals: (other: unknown) => boolean }; + formatting: DateTimeFormattingLike; +} | null => { const candidate = field as DateLikeField; const fieldType = candidate.type?.(); const formatting = candidate.formatting?.(); @@ -42,12 +43,66 @@ export const buildDateLikeOrderExpression = ( fieldType.equals(FieldType.createdTime()) || fieldType.equals(FieldType.lastModifiedTime()); - if (!isDateLike || formatting.time() !== TimeFormatting.None) { + return isDateLike ? { fieldType, formatting } : null; +}; + +export const buildDateLikeOrderExpression = ( + field: unknown, + tableAlias: string, + column: string +): RawBuilder | null => { + const dateLike = resolveDateLikeFormatting(field); + if (!dateLike || dateLike.formatting.time() !== TimeFormatting.None) { + return null; + } + + const columnRef = sql.ref(`${tableAlias}.${column}`); + const localizedExpr = sql`timezone(${dateLike.formatting.timeZone().toString()}, ${columnRef})`; + + return sql`to_char(${localizedExpr}, ${getPostgresDateSortFormatString(dateLike.formatting.date())})`; +}; + +const resolveDateTruncUnit = (date: string, time: string): 'year' | 'month' | 'day' | 'minute' => { + switch (date) { + case DateFormattingPreset.Y: + return 'year'; + case DateFormattingPreset.M: + case DateFormattingPreset.YM: + return 'month'; + default: + return time !== TimeFormatting.None ? 'minute' : 'day'; + } +}; + +const IANA_TIME_ZONE_PATTERN = /^[\w+\-/]+$/; + +/** + * V1 parity group key for date-like fields: truncate in the field's local time + * at the formatting granularity and key the group as timestamptz, matching + * `TIMEZONE(tz, DATE_TRUNC(unit, TIMEZONE(tz, col)))` in the legacy group query. + * + * timeZone/unit are inlined as literals (not bound parameters) so the SELECT, + * GROUP BY and ORDER BY renderings stay byte-identical — with parameters the + * numbering differs per position and PostgreSQL rejects the grouped query. + */ +export const buildDateLikeGroupExpression = ( + field: unknown, + tableAlias: string, + column: string +): RawBuilder | null => { + const dateLike = resolveDateLikeFormatting(field); + if (!dateLike) { return null; } + const timeZone = dateLike.formatting.timeZone().toString(); + if (!IANA_TIME_ZONE_PATTERN.test(timeZone)) { + return null; + } + const unit = resolveDateTruncUnit(dateLike.formatting.date(), dateLike.formatting.time()); const columnRef = sql.ref(`${tableAlias}.${column}`); - const localizedExpr = sql`timezone(${formatting.timeZone().toString()}, ${columnRef})`; - return sql`to_char(${localizedExpr}, ${getPostgresDateSortFormatString(formatting.date())})`; + return sql`timezone(${sql.lit(timeZone)}, date_trunc(${sql.lit( + unit + )}, timezone(${sql.lit(timeZone)}, ${columnRef})))`; }; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/insert/RecordInsertBuilder.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/insert/RecordInsertBuilder.spec.ts index 8e53f8d2b0..bb3c2ccd37 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/insert/RecordInsertBuilder.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/insert/RecordInsertBuilder.spec.ts @@ -268,5 +268,6 @@ describe('RecordInsertBuilder', () => { expect(normalizeSql(compiled.sql)).toContain('LEFT JOIN "bseLegacy"."Legacy_Name" ft'); expect(normalizeSql(compiled.sql)).toContain('"ft"."Primary_Field"'); expect(normalizeSql(compiled.sql)).not.toContain(`"${foreignTable.id().toString()}"`); + expect(normalizeSql(compiled.sql)).toContain('jsonb_strip_nulls'); }); }); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/StoredFieldSelectVisitor.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/StoredFieldSelectVisitor.ts index 6b49b7b202..c548b2ed82 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/StoredFieldSelectVisitor.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/StoredFieldSelectVisitor.ts @@ -28,6 +28,8 @@ import { import { sql, type AliasedRawBuilder } from 'kysely'; import type { Result } from 'neverthrow'; +import { buildStoredFieldValueExpression } from './storedFieldValueExpression'; + /** * Visitor that generates simple SELECT expressions for stored column values. * All fields are selected directly from the table without any computation. @@ -42,6 +44,19 @@ export class StoredFieldSelectVisitor implements IFieldVisitor sql`${sql.ref(`${this.tableAlias}.${colName}`)}`.as(colName)); } + private selectComputedColumn( + field: Field + ): Result, DomainError> { + return field + .dbFieldName() + .andThen((dbFieldName) => dbFieldName.value()) + .andThen((colName) => + buildStoredFieldValueExpression(field, this.tableAlias, colName).map(({ expression }) => + expression.as(colName) + ) + ); + } + visitSingleLineTextField( field: SingleLineTextField ): Result, DomainError> { @@ -128,9 +143,9 @@ export class StoredFieldSelectVisitor implements IFieldVisitor, DomainError> { - return this.selectColumn(field); + return this.selectComputedColumn(field); } visitLinkField(field: LinkField): Result, DomainError> { @@ -138,22 +153,22 @@ export class StoredFieldSelectVisitor implements IFieldVisitor, DomainError> { - return this.selectColumn(field); + return this.selectComputedColumn(field); } visitRollupField(field: RollupField): Result, DomainError> { - return this.selectColumn(field); + return this.selectComputedColumn(field); } visitConditionalRollupField( field: ConditionalRollupField ): Result, DomainError> { - return this.selectColumn(field); + return this.selectComputedColumn(field); } visitConditionalLookupField( field: ConditionalLookupField ): Result, DomainError> { - return this.selectColumn(field); + return this.selectComputedColumn(field); } } diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/StoredTableRecordQueryBuilder.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/StoredTableRecordQueryBuilder.spec.ts index e51c862757..9b4022a477 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/StoredTableRecordQueryBuilder.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/StoredTableRecordQueryBuilder.spec.ts @@ -1,10 +1,16 @@ import { BaseId, + CellValueMultiplicity, + CellValueType, DateFormattingPreset, DateTimeFormatting, DbFieldName, + DbFieldType, + FieldHasError, + FieldId, FieldName, FieldType, + FormulaExpression, RecordConditionFieldReferenceValue, Table, TableId, @@ -175,6 +181,102 @@ describe('StoredTableRecordQueryBuilder', () => { expect(parameters).toEqual([]); }); + test('projects typed nulls instead of stale stored values for errored computed fields', () => { + const db = createTestDb(); + const primaryFieldId = FieldId.create(`fld${'p'.repeat(16)}`)._unsafeUnwrap(); + const formulaFieldId = FieldId.create(`fld${'f'.repeat(16)}`)._unsafeUnwrap(); + const builder = Table.builder() + .withId(TableId.create(MAIN_TABLE_ID)._unsafeUnwrap()) + .withBaseId(BaseId.create(BASE_ID)._unsafeUnwrap()) + .withName(TableName.create('ErroredComputedTable')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(primaryFieldId) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .primary() + .done(); + builder + .field() + .formula() + .withId(formulaFieldId) + .withName(FieldName.create('Broken formula')._unsafeUnwrap()) + .withExpression(FormulaExpression.create(`{${primaryFieldId.toString()}}`)._unsafeUnwrap()) + .withResultType({ + cellValueType: CellValueType.string(), + isMultipleCellValue: CellValueMultiplicity.single(), + }) + .done(); + builder.view().defaultGrid().done(); + const table = builder.build()._unsafeUnwrap(); + const formulaField = table + .getField((field) => field.id().equals(formulaFieldId)) + ._unsafeUnwrap(); + formulaField + .setDbFieldName(DbFieldName.rehydrate('col_broken_formula')._unsafeUnwrap()) + ._unsafeUnwrap(); + formulaField.setDbFieldType(DbFieldType.rehydrate('TEXT')._unsafeUnwrap())._unsafeUnwrap(); + formulaField.setHasError(FieldHasError.error()); + + const qb = new StoredTableRecordQueryBuilder(db); + const { sql } = compileQuery( + db, + qb.from(table).select([formulaFieldId]).orderBy(formulaFieldId, 'asc') + ); + + expect(sql).toContain('NULL::text as "col_broken_formula"'); + expect(sql).toContain('order by NULL::text is null desc, NULL::text asc'); + expect(sql).not.toContain('"t"."col_broken_formula"'); + }); + + test('emits uncast NULL for errored computed fields with unknown dbFieldType', () => { + const db = createTestDb(); + const primaryFieldId = FieldId.create(`fld${'p'.repeat(16)}`)._unsafeUnwrap(); + const formulaFieldId = FieldId.create(`fld${'f'.repeat(16)}`)._unsafeUnwrap(); + const builder = Table.builder() + .withId(TableId.create(MAIN_TABLE_ID)._unsafeUnwrap()) + .withBaseId(BaseId.create(BASE_ID)._unsafeUnwrap()) + .withName(TableName.create('ErroredComputedTable')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(primaryFieldId) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .primary() + .done(); + builder + .field() + .formula() + .withId(formulaFieldId) + .withName(FieldName.create('Broken formula')._unsafeUnwrap()) + .withExpression(FormulaExpression.create(`{${primaryFieldId.toString()}}`)._unsafeUnwrap()) + .withResultType({ + cellValueType: CellValueType.string(), + isMultipleCellValue: CellValueMultiplicity.single(), + }) + .done(); + builder.view().defaultGrid().done(); + const table = builder.build()._unsafeUnwrap(); + const formulaField = table + .getField((field) => field.id().equals(formulaFieldId)) + ._unsafeUnwrap(); + formulaField + .setDbFieldName(DbFieldName.rehydrate('col_broken_formula')._unsafeUnwrap()) + ._unsafeUnwrap(); + // Persisted metadata is untrusted: must never reach raw SQL as a cast. + formulaField + .setDbFieldType(DbFieldType.rehydrate(`text) FROM x --`)._unsafeUnwrap()) + ._unsafeUnwrap(); + formulaField.setHasError(FieldHasError.error()); + + const qb = new StoredTableRecordQueryBuilder(db); + const { sql } = compileQuery(db, qb.from(table).select([formulaFieldId])); + + expect(sql).toContain('NULL as "col_broken_formula"'); + expect(sql).not.toContain('NULL::'); + expect(sql).not.toContain('FROM x'); + }); + test('applies limit and offset', () => { const db = createTestDb(); const table = createTableWithAllFields(); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/StoredTableRecordQueryBuilder.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/StoredTableRecordQueryBuilder.ts index 452aa06315..82b8bdab03 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/StoredTableRecordQueryBuilder.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/StoredTableRecordQueryBuilder.ts @@ -9,19 +9,11 @@ import { type Table, type TableRecord, } from '@teable/v2-core'; -import { - sql, - type AliasedRawBuilder, - type Expression, - type Kysely, - type RawBuilder, - type SqlBool, -} from 'kysely'; +import { sql, type AliasedRawBuilder, type Expression, type Kysely, type SqlBool } from 'kysely'; import type { Result } from 'neverthrow'; import { err, ok, safeTry } from 'neverthrow'; import { TableRecordConditionWhereVisitor } from '../../visitors'; -import { buildDateLikeOrderExpression } from '../dateLikeOrderBy'; import type { DynamicDB, IQueryBuilderDeps, @@ -30,18 +22,19 @@ import type { QB, } from '../ITableRecordQueryBuilder'; import type { QueryMode } from '../TableRecordQueryBuilderManager'; +import { + buildStoredFieldOrderByClauses, + type StoredFieldOrderByClause, +} from './storedFieldOrderBy'; import { StoredFieldSelectVisitor } from './StoredFieldSelectVisitor'; +import { buildStoredFieldValueExpression } from './storedFieldValueExpression'; const T = 't'; // main table alias type ResolvedOrderBy = { column: string; direction: 'asc' | 'desc'; - expression?: RawBuilder; - userLikeMode?: 'single' | 'multiple'; - userLikeSource?: 'field' | 'system'; - selectChoiceMode?: 'single' | 'multiple'; - selectChoiceOrder?: ReadonlyArray; + clauses?: ReadonlyArray; }; export interface IStoredQueryBuilderOptions { @@ -171,27 +164,15 @@ export class StoredTableRecordQueryBuilder implements ITableRecordQueryBuilder { ); for (const orderBy of resolvedOrderBy) { - if (orderBy.selectChoiceMode && orderBy.selectChoiceOrder?.length) { - query = this.applySelectChoiceOrderBy( - query, - orderBy.column, - orderBy.direction, - orderBy.selectChoiceMode, - orderBy.selectChoiceOrder - ); - } else if (orderBy.userLikeMode) { - query = this.applyUserLikeOrderBy( - query, - orderBy.column, - orderBy.direction, - orderBy.userLikeMode, - orderBy.userLikeSource ?? 'field' - ); + if (orderBy.clauses) { + for (const clause of orderBy.clauses) { + query = query.orderBy(clause.expression, clause.direction); + } } else { // Align null ordering with v1: ASC => nulls first, DESC => nulls last. // Without this, PostgreSQL defaults to ASC NULLS LAST / DESC NULLS FIRST, // which is the opposite of v1, causing row offset mismatches during paste. - const columnRef = orderBy.expression ?? sql`${sql.ref(`${T}.${orderBy.column}`)}`; + const columnRef = sql`${sql.ref(`${T}.${orderBy.column}`)}`; const nullOrderDirection: 'asc' | 'desc' = orderBy.direction === 'asc' ? 'desc' : 'asc'; query = query .orderBy(sql`${columnRef} is null`, nullOrderDirection) @@ -236,69 +217,35 @@ export class StoredTableRecordQueryBuilder implements ITableRecordQueryBuilder { .getField((f) => f.id().equals(orderByColumn as FieldId)) .andThen((field) => { const fieldType = field.type(); - const isUserLike = - fieldType.equals(FieldType.user()) || - fieldType.equals(FieldType.link()) || - fieldType.equals(FieldType.createdBy()) || - fieldType.equals(FieldType.lastModifiedBy()); - const resolveDateLikeOrderBy = (column: string) => { - const expression = buildDateLikeOrderExpression(field, T, column); - return ok(expression ? { column, direction, expression } : { column, direction }); - }; - - if (fieldType.equals(FieldType.createdTime())) { - return resolveDateLikeOrderBy('__created_time'); - } - if (fieldType.equals(FieldType.lastModifiedTime())) { - return resolveDateLikeOrderBy('__last_modified_time'); - } - if (fieldType.equals(FieldType.createdBy())) { - return ok({ - column: '__created_by', - direction, - userLikeMode: 'single', - userLikeSource: 'system', - }); - } - if (fieldType.equals(FieldType.lastModifiedBy())) { - return ok({ - column: '__last_modified_by', - direction, - userLikeMode: 'single', - userLikeSource: 'system', - }); - } - if (fieldType.equals(FieldType.autoNumber())) { - return ok({ column: '__auto_number', direction }); - } - - const selectChoiceOrder = this.extractSelectChoiceOrder(field); - const multiplicityResult = isUserLike ? field.isMultipleCellValue() : undefined; - if (multiplicityResult?.isErr()) { - return err(multiplicityResult.error); - } - const multiplicity = multiplicityResult?.isOk() ? multiplicityResult.value : undefined; - return field.dbFieldName().andThen((dbFieldName) => - dbFieldName.value().map((column) => ({ - column, - direction, - expression: buildDateLikeOrderExpression(field, T, column) ?? undefined, - ...(isUserLike - ? { - userLikeMode: (multiplicity?.isMultiple() ? 'multiple' : 'single') as Exclude< - ResolvedOrderBy['userLikeMode'], - undefined - >, - userLikeSource: 'field' as const, - } - : {}), - ...(selectChoiceOrder - ? { - selectChoiceMode: selectChoiceOrder.mode, - selectChoiceOrder: selectChoiceOrder.values, - } - : {}), - })) + const systemColumn = fieldType.equals(FieldType.createdTime()) + ? '__created_time' + : fieldType.equals(FieldType.lastModifiedTime()) + ? '__last_modified_time' + : fieldType.equals(FieldType.createdBy()) + ? '__created_by' + : fieldType.equals(FieldType.lastModifiedBy()) + ? '__last_modified_by' + : fieldType.equals(FieldType.autoNumber()) + ? '__auto_number' + : undefined; + const columnResult = systemColumn + ? ok(systemColumn) + : field.dbFieldName().andThen((dbFieldName) => dbFieldName.value()); + return columnResult.andThen((column) => + buildStoredFieldValueExpression(field, T, column).andThen( + ({ expression, usesErrorFallback }) => + buildStoredFieldOrderByClauses( + field, + column, + direction, + T, + usesErrorFallback ? expression : undefined + ).map((clauses) => ({ + column, + direction, + clauses, + })) + ) ); }); } @@ -306,161 +253,6 @@ export class StoredTableRecordQueryBuilder implements ITableRecordQueryBuilder { return ok({ column: orderByColumn, direction }); } - private extractSelectChoiceOrder( - field: unknown - ): { mode: 'single' | 'multiple'; values: string[] } | undefined { - const candidate = field as { - type?: () => { equals: (other: unknown) => boolean }; - selectOptions?: () => ReadonlyArray<{ name: () => { toString: () => string } }>; - innerField?: () => { isOk: () => boolean; value: unknown }; - isMultipleCellValue?: () => { isOk: () => boolean; value: { isMultiple: () => boolean } }; - }; - const fieldType = candidate.type?.(); - if (!fieldType) { - return undefined; - } - - const toChoiceNames = ( - options: ReadonlyArray<{ name: () => { toString: () => string } }> | undefined - ): string[] | undefined => { - if (!options?.length) { - return undefined; - } - const names = options.map((option) => option.name().toString()).filter(Boolean); - return names.length ? names : undefined; - }; - - if ( - fieldType.equals(FieldType.singleSelect()) || - fieldType.equals(FieldType.multipleSelect()) - ) { - const values = toChoiceNames(candidate.selectOptions?.()); - if (!values) { - return undefined; - } - return { - mode: fieldType.equals(FieldType.multipleSelect()) ? 'multiple' : 'single', - values, - }; - } - - if (fieldType.equals(FieldType.lookup())) { - const innerFieldResult = candidate.innerField?.(); - if (!innerFieldResult?.isOk()) { - return undefined; - } - const innerField = innerFieldResult.value as { - type?: () => { equals: (other: unknown) => boolean }; - selectOptions?: () => ReadonlyArray<{ name: () => { toString: () => string } }>; - }; - const innerType = innerField.type?.(); - if (!innerType) { - return undefined; - } - if ( - !innerType.equals(FieldType.singleSelect()) && - !innerType.equals(FieldType.multipleSelect()) - ) { - return undefined; - } - const values = toChoiceNames(innerField.selectOptions?.()); - if (!values) { - return undefined; - } - // Lookup values are usually arrays; prefer multiple mode unless we know it is single-valued. - let mode: 'single' | 'multiple' = 'multiple'; - const multiplicityResult = candidate.isMultipleCellValue?.(); - const multiplicity = multiplicityResult?.isOk() ? multiplicityResult.value : undefined; - if ( - innerType.equals(FieldType.singleSelect()) && - multiplicity && - !multiplicity.isMultiple() - ) { - mode = 'single'; - } - return { mode, values }; - } - - return undefined; - } - - /** - * Align user/link ordering with v1: - * - single: sort by `title` - * - multiple: sort by `titles[]` text projection - * - null ordering: ASC => null first, DESC => null last - */ - private applyUserLikeOrderBy( - query: QB, - column: string, - direction: 'asc' | 'desc', - mode: 'single' | 'multiple', - source: 'field' | 'system' - ): QB { - const columnRef = sql.ref(`${T}.${column}`); - // Keep v1 parity for user/link fields: cast stored value to jsonb and sort by title. - // System fields (createdBy/lastModifiedBy) may be scalar strings, so keep to_jsonb(). - const columnJson = source === 'field' ? sql`${columnRef}::jsonb` : sql`to_jsonb(${columnRef})`; - const arrayLikeColumnJson = - source === 'field' - ? sql`CASE - WHEN jsonb_typeof(${columnJson}) = 'array' THEN ${columnJson} - WHEN jsonb_typeof(${columnJson}) = 'object' THEN jsonb_build_array(${columnJson}) - ELSE '[]'::jsonb - END` - : sql`CASE - WHEN jsonb_typeof(${columnJson}) = 'array' THEN ${columnJson} - ELSE '[]'::jsonb - END`; - const titleExpr = - mode === 'multiple' - ? sql`jsonb_path_query_array(${arrayLikeColumnJson}, '$[*].title')::text` - : source === 'field' - ? sql`${columnJson} ->> 'title'` - : sql`coalesce(${columnJson} ->> 'title', ${columnJson} ->> 'name', ${columnJson} #>> '{}')`; - - const nullOrderDirection: 'asc' | 'desc' = direction === 'asc' ? 'desc' : 'asc'; - - return query - .orderBy(sql`${titleExpr} is null`, nullOrderDirection) - .orderBy(titleExpr, direction); - } - - private applySelectChoiceOrderBy( - query: QB, - column: string, - direction: 'asc' | 'desc', - mode: 'single' | 'multiple', - choiceOrder: ReadonlyArray - ): QB { - const columnRef = sql.ref(`${T}.${column}`); - const choiceArrayLiteral = sql`ARRAY[${sql.join( - choiceOrder.map((name) => sql`${name}`), - sql`, ` - )}]`; - - const choiceIndexExpr = - mode === 'multiple' - ? sql`CASE - WHEN ${columnRef} IS NULL THEN NULL - WHEN jsonb_typeof(${columnRef}::jsonb) = 'array' - THEN ARRAY_POSITION(${choiceArrayLiteral}, jsonb_path_query_first(${columnRef}::jsonb, '$[0]') #>> '{}') - ELSE ARRAY_POSITION(${choiceArrayLiteral}, ${columnRef}::text) - END` - : sql`ARRAY_POSITION(${choiceArrayLiteral}, ${columnRef}::text)`; - - const nullOrderDirection: 'asc' | 'desc' = direction === 'asc' ? 'desc' : 'asc'; - let ordered = query - .orderBy(sql`${choiceIndexExpr} is null`, nullOrderDirection) - .orderBy(choiceIndexExpr, direction); - - if (mode === 'multiple') { - ordered = ordered.orderBy(sql`${columnRef}::jsonb::text`, direction); - } - - return ordered; - } - private buildWhereCondition(): Result | null, DomainError> { if (this.whereSpecs.length === 0) { return ok(null); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/index.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/index.ts index 2d7c46ff54..003327f757 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/index.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/index.ts @@ -1,2 +1,4 @@ export * from './StoredFieldSelectVisitor'; export * from './StoredTableRecordQueryBuilder'; +export * from './storedFieldOrderBy'; +export * from './storedFieldValueExpression'; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/storedFieldOrderBy.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/storedFieldOrderBy.spec.ts new file mode 100644 index 0000000000..994adf2f66 --- /dev/null +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/storedFieldOrderBy.spec.ts @@ -0,0 +1,139 @@ +import { + DateTimeFormatting, + FieldId, + FieldName, + LookupField, + LookupOptions, + NumberFormatting, + NumberFormattingType, + TimeFormatting, + createCheckboxField, + createDateField, + createNumberField, + createSingleLineTextField, + type Field, +} from '@teable/v2-core'; +import { + DummyDriver, + Kysely, + PostgresAdapter, + PostgresIntrospector, + PostgresQueryCompiler, +} from 'kysely'; +import { describe, expect, test } from 'vitest'; + +import type { DynamicDB } from '../ITableRecordQueryBuilder'; +import { + buildStoredFieldOrderByClauses, + type StoredFieldOrderByClause, +} from './storedFieldOrderBy'; + +const createTestDb = () => + new Kysely({ + dialect: { + createAdapter: () => new PostgresAdapter(), + createDriver: () => new DummyDriver(), + createIntrospector: (db) => new PostgresIntrospector(db), + createQueryCompiler: () => new PostgresQueryCompiler(), + }, + }); + +const createMultipleLookup = (innerField: Field): LookupField => { + const lookupOptions = LookupOptions.create({ + linkFieldId: `fld${'l'.repeat(16)}`, + lookupFieldId: innerField.id().toString(), + foreignTableId: `tbl${'f'.repeat(16)}`, + })._unsafeUnwrap(); + + return LookupField.create({ + id: FieldId.create(`fld${'u'.repeat(16)}`)._unsafeUnwrap(), + name: FieldName.create('Lookup')._unsafeUnwrap(), + innerField, + lookupOptions, + isMultipleCellValue: true, + })._unsafeUnwrap(); +}; + +const compileOrderBy = (clauses: ReadonlyArray): string => { + const db = createTestDb(); + const query = clauses.reduce( + (builder, clause) => builder.orderBy(clause.expression, clause.direction), + db.selectFrom('records as t').selectAll() + ); + return query.compile().sql; +}; + +const orderSqlFor = (innerField: Field): string => { + const result = buildStoredFieldOrderByClauses( + createMultipleLookup(innerField), + 'lookup_values', + 'asc', + 't' + ); + expect(result.isOk()).toBe(true); + return compileOrderBy(result._unsafeUnwrap()); +}; + +describe('storedFieldOrderBy', () => { + test.each([ + [ + 'string', + createSingleLineTextField({ + id: FieldId.create(`fld${'s'.repeat(16)}`)._unsafeUnwrap(), + name: FieldName.create('Text')._unsafeUnwrap(), + })._unsafeUnwrap(), + ], + [ + 'boolean', + createCheckboxField({ + id: FieldId.create(`fld${'b'.repeat(16)}`)._unsafeUnwrap(), + name: FieldName.create('Checked')._unsafeUnwrap(), + })._unsafeUnwrap(), + ], + ] as const)('pushes multiple lookup %s first-value ordering into SQL', (_type, innerField) => { + const sql = orderSqlFor(innerField); + + expect(sql).toContain(`jsonb_typeof("t"."lookup_values"::jsonb) = 'array'`); + expect(sql).toContain(`END ->> 0 is null desc`); + expect(sql).toContain(`END ->> 0 asc`); + expect(sql).not.toContain('"t"."lookup_values"::jsonb::text'); + }); + + test('pushes formatted multiple lookup number ordering into SQL', () => { + const formatting = NumberFormatting.create({ + type: NumberFormattingType.Decimal, + precision: 1, + })._unsafeUnwrap(); + const innerField = createNumberField({ + id: FieldId.create(`fld${'n'.repeat(16)}`)._unsafeUnwrap(), + name: FieldName.create('Amount')._unsafeUnwrap(), + formatting, + })._unsafeUnwrap(); + const sql = orderSqlFor(innerField); + + expect(sql).toContain("string_agg(trim(to_char((lookup_element #>> '{}')::numeric"); + expect(sql).toContain("'999999990D0'"); + expect(sql).toContain("', ' ORDER BY lookup_ordinality"); + expect(sql).toContain('WITH ORDINALITY AS lookup_values(lookup_element, lookup_ordinality)'); + }); + + test('pushes formatted multiple lookup date ordering into SQL', () => { + const formatting = DateTimeFormatting.create({ + date: 'M/D/YYYY', + time: TimeFormatting.None, + timeZone: 'utc', + })._unsafeUnwrap(); + const innerField = createDateField({ + id: FieldId.create(`fld${'d'.repeat(16)}`)._unsafeUnwrap(), + name: FieldName.create('Due')._unsafeUnwrap(), + formatting, + })._unsafeUnwrap(); + const sql = orderSqlFor(innerField); + + expect(sql).toContain( + "string_agg(TO_CHAR((lookup_element #>> '{}')::timestamptz AT TIME ZONE 'UTC', 'FMMM/FMDD/YYYY')" + ); + expect(sql).toContain("', ' ORDER BY lookup_ordinality"); + expect(sql).toContain('WITH ORDINALITY AS lookup_values(lookup_element, lookup_ordinality)'); + }); +}); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/storedFieldOrderBy.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/storedFieldOrderBy.ts new file mode 100644 index 0000000000..e56d774a10 --- /dev/null +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/storedFieldOrderBy.ts @@ -0,0 +1,200 @@ +import { FieldType, type DomainError, type Field, type LookupField } from '@teable/v2-core'; +import { formatFieldValueAsStringSql } from '@teable/v2-formula-sql-pg'; +import { sql, type RawBuilder } from 'kysely'; +import { err, ok, type Result } from 'neverthrow'; + +import { buildDateLikeOrderExpression } from '../dateLikeOrderBy'; + +export type StoredFieldOrderByClause = { + readonly expression: RawBuilder; + readonly direction: 'asc' | 'desc'; +}; + +const extractSelectChoiceOrder = ( + field: Field +): { mode: 'single' | 'multiple'; values: string[] } | undefined => { + const candidate = field as Field & { + selectOptions?: () => ReadonlyArray<{ name: () => { toString: () => string } }>; + innerField?: () => { isOk: () => boolean; value: unknown }; + }; + const fieldType = field.type(); + const toChoiceNames = ( + options: ReadonlyArray<{ name: () => { toString: () => string } }> | undefined + ): string[] | undefined => { + const names = options?.map((option) => option.name().toString()).filter(Boolean); + return names?.length ? names : undefined; + }; + + if (fieldType.equals(FieldType.singleSelect()) || fieldType.equals(FieldType.multipleSelect())) { + const values = toChoiceNames(candidate.selectOptions?.()); + return values + ? { + mode: fieldType.equals(FieldType.multipleSelect()) ? 'multiple' : 'single', + values, + } + : undefined; + } + + if (!fieldType.equals(FieldType.lookup())) return undefined; + const innerFieldResult = candidate.innerField?.(); + if (!innerFieldResult?.isOk()) return undefined; + + const innerField = innerFieldResult.value as { + type?: () => { equals: (other: unknown) => boolean }; + selectOptions?: () => ReadonlyArray<{ name: () => { toString: () => string } }>; + }; + const innerType = innerField.type?.(); + if ( + !innerType || + (!innerType.equals(FieldType.singleSelect()) && !innerType.equals(FieldType.multipleSelect())) + ) { + return undefined; + } + const values = toChoiceNames(innerField.selectOptions?.()); + if (!values) return undefined; + + const multiplicityResult = field.isMultipleCellValue(); + const isMultiple = + multiplicityResult.isOk() && + (innerType.equals(FieldType.multipleSelect()) || multiplicityResult.value.isMultiple()); + return { mode: isMultiple ? 'multiple' : 'single', values }; +}; + +const withNullOrdering = ( + expression: RawBuilder, + direction: 'asc' | 'desc' +): StoredFieldOrderByClause[] => [ + { + expression: sql`${expression} is null`, + direction: direction === 'asc' ? 'desc' : 'asc', + }, + { expression, direction }, +]; + +const buildMultipleLookupOrderExpression = ( + field: Field, + columnRef: RawBuilder +): Result | undefined, DomainError> => { + if (!field.type().equals(FieldType.lookup())) return ok(undefined); + + const lookupField = field as LookupField; + return lookupField.isMultipleCellValue().andThen((multiplicity) => { + if (!multiplicity.isMultiple()) return ok(undefined); + + return lookupField.innerField().map((innerField) => { + const innerType = innerField.type(); + const normalizedArray = sql`CASE + WHEN ${columnRef} IS NULL THEN '[]'::jsonb + WHEN jsonb_typeof(${columnRef}::jsonb) = 'array' THEN ${columnRef}::jsonb + WHEN jsonb_typeof(${columnRef}::jsonb) = 'null' THEN '[]'::jsonb + ELSE jsonb_build_array(${columnRef}::jsonb) + END`; + + // v1 compares plain text and checkbox lookups by their first value. + if ( + innerType.equals(FieldType.singleLineText()) || + innerType.equals(FieldType.longText()) || + innerType.equals(FieldType.checkbox()) + ) { + return sql`${normalizedArray} ->> 0`; + } + + // v1 compares number and date lookups by the complete display string. + if (innerType.equals(FieldType.number()) || innerType.equals(FieldType.date())) { + const elementSql = `lookup_element #>> '{}'`; + const formattedElementSql = formatFieldValueAsStringSql(innerField, elementSql); + const elementExpression = formattedElementSql + ? sql.raw(formattedElementSql) + : sql.raw(elementSql); + return sql`( + SELECT string_agg(${elementExpression}, ', ' ORDER BY lookup_ordinality) + FROM jsonb_array_elements(${normalizedArray}) + WITH ORDINALITY AS lookup_values(lookup_element, lookup_ordinality) + )`; + } + + return undefined; + }); + }); +}; + +export const buildStoredFieldOrderByClauses = ( + field: Field, + column: string, + direction: 'asc' | 'desc', + tableAlias: string, + columnExpression?: RawBuilder +): Result, DomainError> => { + const fieldType = field.type(); + const columnRef = columnExpression ?? sql.ref(`${tableAlias}.${column}`); + const selectChoiceOrder = extractSelectChoiceOrder(field); + + if (selectChoiceOrder) { + const choiceArrayLiteral = sql`ARRAY[${sql.join( + selectChoiceOrder.values.map((name) => sql`${name}`), + sql`, ` + )}]`; + const choiceIndexExpression = + selectChoiceOrder.mode === 'multiple' + ? sql`CASE + WHEN ${columnRef} IS NULL THEN NULL + WHEN jsonb_typeof(${columnRef}::jsonb) = 'array' + THEN ARRAY_POSITION(${choiceArrayLiteral}, jsonb_path_query_first(${columnRef}::jsonb, '$[0]') #>> '{}') + ELSE ARRAY_POSITION(${choiceArrayLiteral}, ${columnRef}::text) + END` + : sql`ARRAY_POSITION(${choiceArrayLiteral}, ${columnRef}::text)`; + const clauses = withNullOrdering(choiceIndexExpression, direction); + return ok( + selectChoiceOrder.mode === 'multiple' + ? [...clauses, { expression: sql`${columnRef}::jsonb::text`, direction }] + : clauses + ); + } + + const multipleLookupOrderExpression = buildMultipleLookupOrderExpression(field, columnRef); + if (multipleLookupOrderExpression.isErr()) return err(multipleLookupOrderExpression.error); + if (multipleLookupOrderExpression.value) { + return ok(withNullOrdering(multipleLookupOrderExpression.value, direction)); + } + + const isUserLike = + fieldType.equals(FieldType.user()) || + fieldType.equals(FieldType.link()) || + fieldType.equals(FieldType.createdBy()) || + fieldType.equals(FieldType.lastModifiedBy()); + if (isUserLike) { + const multiplicityResult = field.isMultipleCellValue(); + if (multiplicityResult.isErr()) return err(multiplicityResult.error); + + const source = + fieldType.equals(FieldType.createdBy()) || fieldType.equals(FieldType.lastModifiedBy()) + ? 'system' + : 'field'; + const columnJson = source === 'field' ? sql`${columnRef}::jsonb` : sql`to_jsonb(${columnRef})`; + const arrayLikeColumnJson = + source === 'field' + ? sql`CASE + WHEN jsonb_typeof(${columnJson}) = 'array' THEN ${columnJson} + WHEN jsonb_typeof(${columnJson}) = 'object' THEN jsonb_build_array(${columnJson}) + ELSE '[]'::jsonb + END` + : sql`CASE + WHEN jsonb_typeof(${columnJson}) = 'array' THEN ${columnJson} + ELSE '[]'::jsonb + END`; + const titleExpression = multiplicityResult.value.isMultiple() + ? sql`jsonb_path_query_array(${arrayLikeColumnJson}, '$[*].title')::text` + : source === 'field' + ? sql`${columnJson} ->> 'title'` + : sql`coalesce(${columnJson} ->> 'title', ${columnJson} ->> 'name', ${columnJson} #>> '{}')`; + return ok(withNullOrdering(titleExpression, direction)); + } + + // An explicit columnExpression (error fallback, grouped date bucket) is + // already the value to order by; rebuilding from the raw column would + // reference an ungrouped column in grouped queries. + const dateExpression = columnExpression + ? null + : buildDateLikeOrderExpression(field, tableAlias, column); + return ok(withNullOrdering(dateExpression ?? sql`${columnRef}`, direction)); +}; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/storedFieldValueExpression.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/storedFieldValueExpression.ts new file mode 100644 index 0000000000..fe2fd564f1 --- /dev/null +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/storedFieldValueExpression.ts @@ -0,0 +1,58 @@ +import { type DomainError, type Field } from '@teable/v2-core'; +import { sql, type RawBuilder } from 'kysely'; +import { ok, type Result } from 'neverthrow'; + +export type StoredFieldValueExpression = { + readonly expression: RawBuilder; + readonly usesErrorFallback: boolean; +}; + +// Whitelist: dbFieldType is persisted metadata and must never be interpolated +// into raw SQL. Unknown or legacy values fall back to an uncast NULL. +const NULL_CAST_TYPE_BY_DB_FIELD_TYPE: Readonly> = { + JSON: 'jsonb', + REAL: 'double precision', + DATETIME: 'timestamptz', + BOOLEAN: 'boolean', + TEXT: 'text', + INTEGER: 'integer', +}; + +const buildTypedNullExpression = (field: Field): Result, DomainError> => + field + .dbFieldType() + .andThen((dbFieldType) => dbFieldType.value()) + .map((dbFieldType) => { + const castType = NULL_CAST_TYPE_BY_DB_FIELD_TYPE[dbFieldType.trim().toUpperCase()]; + return castType === undefined ? sql.raw('NULL') : sql.raw(`NULL::${castType}`); + }) + .orElse(() => + field + .isMultipleCellValue() + .map((multiplicity) => sql.raw(multiplicity.isMultiple() ? 'NULL::jsonb' : 'NULL')) + ); + +/** + * Resolve the effective stored read expression for every SQL consumer. + * + * Errored computed fields must behave as NULL even when a stale physical value + * remains in PostgreSQL. Keeping this policy here prevents stored projection, + * ordering, grouping, and search from disagreeing about the same record value. + */ +export const buildStoredFieldValueExpression = ( + field: Field, + tableAlias: string, + column: string +): Result => { + if (field.computed().toBoolean() && field.hasError().isError()) { + return buildTypedNullExpression(field).map((expression) => ({ + expression, + usesErrorFallback: true, + })); + } + + return ok({ + expression: sql.ref(`${tableAlias}.${column}`), + usesErrorFallback: false, + }); +}; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/update/RecordUpdateBuilder.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/update/RecordUpdateBuilder.ts index e0a69d9608..05bddf71b7 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/update/RecordUpdateBuilder.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/update/RecordUpdateBuilder.ts @@ -9,6 +9,7 @@ import type { LinkField, RecordId, SetAttachmentValueSpec, + SetButtonValueSpec, SetCheckboxValueSpec, SetDateValueSpec, SetLinkValueByTitleSpec, @@ -310,6 +311,10 @@ class LinkValueCollectorVisitor implements ICellValueSpecVisitor { return ok(undefined); } + visitSetButtonValue(_spec: SetButtonValueSpec): Result { + return ok(undefined); + } + visitSetUserValue(_spec: SetUserValueSpec): Result { return ok(undefined); } diff --git a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresCollaboratorDirectoryService.ts b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresCollaboratorDirectoryService.ts new file mode 100644 index 0000000000..a6524506a1 --- /dev/null +++ b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresCollaboratorDirectoryService.ts @@ -0,0 +1,107 @@ +import { + domainError, + type CollaboratorDirectoryUser, + type DomainError, + type ICollaboratorDirectoryService, + type IExecutionContext, + type BaseId, +} from '@teable/v2-core'; +import { inject, injectable } from '@teable/v2-di'; +import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; +import type { Kysely } from 'kysely'; +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { v2RecordRepositoryPostgresTokens } from '../di/tokens'; + +@injectable() +export class PostgresCollaboratorDirectoryService implements ICollaboratorDirectoryService { + constructor( + @inject(v2RecordRepositoryPostgresTokens.metaDb) + private readonly db: Kysely + ) {} + + async listBaseUsers( + _context: IExecutionContext, + baseId: BaseId, + options: Parameters[2] + ): Promise, DomainError>> { + try { + const base = await this.db + .selectFrom('base') + .select('space_id') + .where('id', '=', baseId.toString()) + .executeTakeFirst(); + if (!base) { + return err( + domainError.notFound({ + code: 'base.not_found', + message: `Base not found: ${baseId.toString()}`, + }) + ); + } + + let query = this.db + .selectFrom('collaborator') + .innerJoin('users', 'users.id', 'collaborator.principal_id') + .select(['users.id', 'users.name', 'users.avatar']) + .where('collaborator.resource_id', 'in', [baseId.toString(), base.space_id]) + .where((eb) => + eb.or([eb('users.is_system', 'is', null), eb('users.is_system', '=', false)]) + ) + .orderBy('collaborator.created_time', 'desc') + .offset(options.pagination.offset().toNumber()) + .limit(options.pagination.limit().toNumber()); + if (options.search) { + query = query.where('users.name', 'ilike', `%${options.search}%`); + } + const rows = await query.execute(); + return ok(rows.map(PostgresCollaboratorDirectoryService.mapUser)); + } catch (error) { + return err( + domainError.infrastructure({ + message: 'Failed to list base collaborators', + details: { error: (error as Error)?.message ?? String(error) }, + }) + ); + } + } + + async listUsersByIds( + _context: IExecutionContext, + userIds: ReadonlyArray, + options: Parameters[2] + ): Promise, DomainError>> { + const uniqueIds = [...new Set(userIds.filter(Boolean))]; + if (!uniqueIds.length) return ok([]); + + try { + let query = this.db + .selectFrom('users') + .select(['id', 'name', 'avatar']) + .where('id', 'in', uniqueIds) + .offset(options.pagination.offset().toNumber()) + .limit(options.pagination.limit().toNumber()); + if (options.search) { + query = query.where('name', 'ilike', `%${options.search}%`); + } + const rows = await query.execute(); + return ok(rows.map(PostgresCollaboratorDirectoryService.mapUser)); + } catch (error) { + return err( + domainError.infrastructure({ + message: 'Failed to list referenced collaborators', + details: { error: (error as Error)?.message ?? String(error) }, + }) + ); + } + } + + private static mapUser(row: { + readonly id: string; + readonly name: string; + readonly avatar: string | null; + }): CollaboratorDirectoryUser { + return { id: row.id, name: row.name, avatar: row.avatar }; + } +} diff --git a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordQueryRepository.pglite.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordQueryRepository.pglite.spec.ts index 0e1ceb0f65..e57134c4b3 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordQueryRepository.pglite.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordQueryRepository.pglite.spec.ts @@ -3,14 +3,28 @@ import { PGlite } from '@electric-sql/pglite'; import { ActorId, BaseId, + CellValueMultiplicity, + CellValueType, DbFieldName, + DateFormattingPreset, + DateTimeFormatting, + DbFieldType, + FieldHasError, FieldId, FieldName, + FormulaExpression, + OffsetPagination, + PageLimit, + PageOffset, + RecordSearch, RecordByIdsSpec, RecordId, + SelectOption, Table, TableId, TableName, + TimeFormatting, + UserMultiplicity, type ILogger, type ITableRepository, } from '@teable/v2-core'; @@ -28,6 +42,7 @@ import { import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { TableRecordQueryBuilderManager } from '../query-builder'; +import { PostgresCollaboratorDirectoryService } from './PostgresCollaboratorDirectoryService'; import { PostgresTableRecordQueryRepository } from './PostgresTableRecordQueryRepository'; class RecordingDriver { @@ -123,6 +138,9 @@ const createLogger = (): ILogger => { type SeededRow = { name: string; age: number; + status?: string | null; + staleComputed?: string | null; + date?: string | null; }; const setupRepositoryFixture = async ({ @@ -130,16 +148,25 @@ const setupRepositoryFixture = async ({ createdSchemas, seed, rows, + statusOptions, + includeErroredFormula, + dateFieldTimeZone, }: { db: Kysely; createdSchemas: string[]; seed: string; rows: ReadonlyArray; + statusOptions?: ReadonlyArray; + includeErroredFormula?: boolean; + dateFieldTimeZone?: string; }) => { const baseId = BaseId.create(createId('bse', seed))._unsafeUnwrap(); const tableId = TableId.create(createId('tbl', seed))._unsafeUnwrap(); const nameFieldId = FieldId.create(createId('fld', `n-${seed}`))._unsafeUnwrap(); const ageFieldId = FieldId.create(createId('fld', `a-${seed}`))._unsafeUnwrap(); + const statusFieldId = FieldId.create(createId('fld', `s-${seed}`))._unsafeUnwrap(); + const formulaFieldId = FieldId.create(createId('fld', `f-${seed}`))._unsafeUnwrap(); + const dateFieldId = FieldId.create(createId('fld', `d-${seed}`))._unsafeUnwrap(); const builder = Table.builder() .withBaseId(baseId) @@ -158,6 +185,45 @@ const setupRepositoryFixture = async ({ .withId(ageFieldId) .withName(FieldName.create('Age')._unsafeUnwrap()) .done(); + if (statusOptions?.length) { + builder + .field() + .singleSelect() + .withId(statusFieldId) + .withName(FieldName.create('Status')._unsafeUnwrap()) + .withOptions( + statusOptions.map((name) => SelectOption.create({ name, color: 'blue' })._unsafeUnwrap()) + ) + .done(); + } + if (dateFieldTimeZone) { + builder + .field() + .date() + .withId(dateFieldId) + .withName(FieldName.create('Due')._unsafeUnwrap()) + .withFormatting( + DateTimeFormatting.create({ + date: 'YYYY-MM-DD', + time: TimeFormatting.None, + timeZone: dateFieldTimeZone, + })._unsafeUnwrap() + ) + .done(); + } + if (includeErroredFormula) { + builder + .field() + .formula() + .withId(formulaFieldId) + .withName(FieldName.create('Broken formula')._unsafeUnwrap()) + .withExpression(FormulaExpression.create(`{${nameFieldId.toString()}}`)._unsafeUnwrap()) + .withResultType({ + cellValueType: CellValueType.string(), + isMultipleCellValue: CellValueMultiplicity.single(), + }) + .done(); + } builder.view().defaultGrid().done(); const table = builder.build()._unsafeUnwrap(); @@ -171,6 +237,30 @@ const setupRepositoryFixture = async ({ ._unsafeUnwrap() .setDbFieldName(DbFieldName.rehydrate('col_age')._unsafeUnwrap()) ._unsafeUnwrap(); + if (statusOptions?.length) { + table + .getField((field) => field.id().equals(statusFieldId)) + ._unsafeUnwrap() + .setDbFieldName(DbFieldName.rehydrate('col_status')._unsafeUnwrap()) + ._unsafeUnwrap(); + } + if (dateFieldTimeZone) { + table + .getField((field) => field.id().equals(dateFieldId)) + ._unsafeUnwrap() + .setDbFieldName(DbFieldName.rehydrate('col_date')._unsafeUnwrap()) + ._unsafeUnwrap(); + } + if (includeErroredFormula) { + const formulaField = table + .getField((field) => field.id().equals(formulaFieldId)) + ._unsafeUnwrap(); + formulaField + .setDbFieldName(DbFieldName.rehydrate('col_broken_formula')._unsafeUnwrap()) + ._unsafeUnwrap(); + formulaField.setDbFieldType(DbFieldType.rehydrate('TEXT')._unsafeUnwrap())._unsafeUnwrap(); + formulaField.setHasError(FieldHasError.error()); + } const schemaName = baseId.toString(); const tableName = tableId.toString(); @@ -188,7 +278,10 @@ const setupRepositoryFixture = async ({ __last_modified_time timestamptz, __last_modified_by text, col_name text, - col_age integer + col_age integer, + col_status text, + col_broken_formula text, + col_date timestamptz ) `.execute(db); @@ -206,7 +299,10 @@ const setupRepositoryFixture = async ({ __last_modified_time, __last_modified_by, col_name, - col_age + col_age, + col_status, + col_broken_formula, + col_date ) VALUES ( ${recordId}, @@ -217,7 +313,10 @@ const setupRepositoryFixture = async ({ ${'2025-01-02T00:00:00.000Z'}, ${'usr_modifier'}, ${row.name}, - ${row.age} + ${row.age}, + ${row.status ?? null}, + ${row.staleComputed ?? null}, + ${row.date ?? null} ) `.execute(db); } @@ -236,6 +335,9 @@ const setupRepositoryFixture = async ({ table, nameFieldId, ageFieldId, + statusFieldId, + formulaFieldId, + dateFieldId, insertedRecordIds, }; }; @@ -404,6 +506,199 @@ describe('PostgresTableRecordQueryRepository projection (pglite)', () => { expect(record.fields).not.toHaveProperty(ageFieldId.toString()); }); + it('extracts distinct IDs from single and multiple User Fields within the Record scope', async () => { + const seed = 'collaborators'; + const baseId = BaseId.create(createId('bse', seed))._unsafeUnwrap(); + const tableId = TableId.create(createId('tbl', seed))._unsafeUnwrap(); + const nameFieldId = FieldId.create(createId('fld', 'collab-name'))._unsafeUnwrap(); + const ownerFieldId = FieldId.create(createId('fld', 'collab-owner'))._unsafeUnwrap(); + const teamFieldId = FieldId.create(createId('fld', 'collab-team'))._unsafeUnwrap(); + const builder = Table.builder() + .withBaseId(baseId) + .withId(tableId) + .withName(TableName.create('Collaborator records')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(nameFieldId) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + builder + .field() + .user() + .withId(ownerFieldId) + .withName(FieldName.create('Owner')._unsafeUnwrap()) + .done(); + builder + .field() + .user() + .withId(teamFieldId) + .withName(FieldName.create('Team')._unsafeUnwrap()) + .withMultiplicity(UserMultiplicity.multiple()) + .done(); + builder.view().defaultGrid().done(); + const table = builder.build()._unsafeUnwrap(); + for (const [fieldId, dbFieldName] of [ + [nameFieldId, 'col_name'], + [ownerFieldId, 'col_owner'], + [teamFieldId, 'col_team'], + ] as const) { + table + .getField((field) => field.id().equals(fieldId)) + ._unsafeUnwrap() + .setDbFieldName(DbFieldName.rehydrate(dbFieldName)._unsafeUnwrap()) + ._unsafeUnwrap(); + } + + const schemaName = baseId.toString(); + const fullTableName = `${schemaName}.${tableId.toString()}`; + createdSchemas.push(schemaName); + await sql`CREATE SCHEMA ${sql.id(schemaName)}`.execute(db); + await sql` + CREATE TABLE ${sql.table(fullTableName)} ( + __id text PRIMARY KEY, + __version integer NOT NULL, + __auto_number integer, + __created_time timestamptz, + __created_by text, + __last_modified_time timestamptz, + __last_modified_by text, + col_name text, + col_owner jsonb, + col_team jsonb + ) + `.execute(db); + const firstRecordId = createId('rec', 'collab-first'); + const secondRecordId = createId('rec', 'collab-second'); + await sql` + INSERT INTO ${sql.table(fullTableName)} ( + __id, __version, col_name, col_owner, col_team + ) VALUES + ( + ${firstRecordId}, 1, 'First', + ${JSON.stringify({ id: 'usr1', title: 'Alice' })}::jsonb, + ${JSON.stringify([ + { id: 'usr1', title: 'Alice' }, + { id: 'usr2', title: 'Bob' }, + ])}::jsonb + ), + ( + ${secondRecordId}, 1, 'Second', + ${JSON.stringify({ id: 'usr1', title: 'Alice' })}::jsonb, + ${JSON.stringify([ + { id: 'usr2', title: 'Bob' }, + { id: 'usr3', title: 'Carol' }, + ])}::jsonb + ) + `.execute(db); + const manager = new TableRecordQueryBuilderManager( + db, + {} as unknown as ITableRepository, + new Pg16TypeValidationStrategy() + ); + const repository = new PostgresTableRecordQueryRepository(manager, db, createLogger()); + const context = { actorId: ActorId.create('tester')._unsafeUnwrap() }; + const ownerField = table + .getField((field) => field.id().equals(ownerFieldId)) + ._unsafeUnwrap() as Parameters[2]; + const teamField = table + .getField((field) => field.id().equals(teamFieldId)) + ._unsafeUnwrap() as Parameters[2]; + const firstRecordSpec = RecordByIdsSpec.create([ + RecordId.create(firstRecordId)._unsafeUnwrap(), + ]); + + expect( + [...(await repository.findDistinctUserIds(context, table, ownerField))._unsafeUnwrap()].sort() + ).toEqual(['usr1']); + expect( + [ + ...( + await repository.findDistinctUserIds(context, table, teamField, firstRecordSpec) + )._unsafeUnwrap(), + ].sort() + ).toEqual(['usr1', 'usr2']); + expect(driver.queries.at(-1)?.sql).toContain('jsonb_array_elements'); + }); + + it('lists Base/Space collaborators by name only and excludes system users', async () => { + await sql` + CREATE TABLE users ( + id text PRIMARY KEY, + name text NOT NULL, + email text, + avatar text, + is_system boolean + ) + `.execute(db); + await sql` + CREATE TABLE "base" ( + id text PRIMARY KEY, + space_id text NOT NULL + ) + `.execute(db); + await sql` + CREATE TABLE collaborator ( + id text PRIMARY KEY, + resource_type text NOT NULL, + resource_id text NOT NULL, + principal_id text NOT NULL, + principal_type text NOT NULL, + created_time timestamptz NOT NULL + ) + `.execute(db); + const baseId = BaseId.create(createId('bse', 'directory'))._unsafeUnwrap(); + const spaceId = createId('spc', 'directory'); + await sql` + INSERT INTO "base" (id, space_id) VALUES (${baseId.toString()}, ${spaceId}) + `.execute(db); + await sql` + INSERT INTO users (id, name, email, avatar, is_system) VALUES + ('usr-alice', 'Alice', 'private-alice@example.com', 'alice.png', false), + ('usr-bob', 'Bob', 'private-bob@example.com', NULL, NULL), + ('usr-system', 'System', 'system@example.com', NULL, true) + `.execute(db); + await sql` + INSERT INTO collaborator ( + id, resource_type, resource_id, principal_id, principal_type, created_time + ) VALUES + ('clb-alice', 'base', ${baseId.toString()}, 'usr-alice', 'user', '2025-01-03'), + ('clb-bob', 'space', ${spaceId}, 'usr-bob', 'user', '2025-01-02'), + ('clb-system', 'space', ${spaceId}, 'usr-system', 'user', '2025-01-01') + `.execute(db); + const service = new PostgresCollaboratorDirectoryService(db); + const context = { actorId: ActorId.create('tester')._unsafeUnwrap() }; + const firstPage = OffsetPagination.create( + PageLimit.create(1)._unsafeUnwrap(), + PageOffset.zero() + ); + const defaultPage = OffsetPagination.create( + PageLimit.create(50)._unsafeUnwrap(), + PageOffset.zero() + ); + + expect( + (await service.listBaseUsers(context, baseId, { pagination: firstPage }))._unsafeUnwrap() + ).toEqual([{ id: 'usr-alice', name: 'Alice', avatar: 'alice.png' }]); + expect( + ( + await service.listBaseUsers(context, baseId, { + pagination: defaultPage, + search: 'private-alice@example.com', + }) + )._unsafeUnwrap() + ).toEqual([]); + expect( + ( + await service.listUsersByIds(context, ['usr-alice', 'usr-bob', 'usr-alice'], { + pagination: defaultPage, + search: 'Bob', + }) + )._unsafeUnwrap() + ).toEqual([{ id: 'usr-bob', name: 'Bob', avatar: null }]); + }); + it('keeps an empty projection as id-only instead of falling back to all fields', async () => { const fixture = await setupRepositoryFixture({ db, @@ -475,6 +770,143 @@ describe('PostgresTableRecordQueryRepository projection (pglite)', () => { ]); }); + it('aggregates ordered group counts inside the filtered record scope', async () => { + const fixture = await setupRepositoryFixture({ + db, + createdSchemas, + seed: 'group-counts', + rows: [ + { name: 'A', age: 10 }, + { name: 'B', age: 20 }, + { name: 'C', age: 20 }, + { name: 'D', age: 30 }, + ], + }); + const allowedIds = fixture.insertedRecordIds.slice(1); + + const result = await fixture.repository.find( + fixture.context, + fixture.table, + RecordByIdsSpec.create(allowedIds.map((id) => RecordId.create(id)._unsafeUnwrap())), + { + mode: 'stored', + includeTotal: true, + groupBy: [{ fieldId: fixture.ageFieldId, direction: 'desc' }], + groupLimit: 5_000, + } + ); + + expect(result.isOk()).toBe(true); + if (result.isErr()) return; + expect(result.value.total).toBe(3); + expect(result.value.groups).toEqual([ + { fields: { [fixture.ageFieldId.toString()]: 30 }, count: 1 }, + { fields: { [fixture.ageFieldId.toString()]: 20 }, count: 2 }, + ]); + expect(driver.queries.some((query) => query.sql.includes('group by'))).toBe(true); + }); + + it('groups errored computed fields as null instead of stale stored values', async () => { + const fixture = await setupRepositoryFixture({ + db, + createdSchemas, + seed: 'errored-group', + includeErroredFormula: true, + rows: [ + { name: 'A', age: 10, staleComputed: 'stale-a' }, + { name: 'B', age: 20, staleComputed: 'stale-b' }, + ], + }); + + const result = await fixture.repository.find(fixture.context, fixture.table, undefined, { + mode: 'stored', + includeTotal: false, + groupBy: [{ fieldId: fixture.formulaFieldId, direction: 'asc' }], + groupLimit: 5_000, + }); + + expect(result.isOk()).toBe(true); + if (result.isErr()) return; + expect(result.value.groups).toEqual([ + { fields: { [fixture.formulaFieldId.toString()]: null }, count: 2 }, + ]); + const groupQuery = driver.queries.find((query) => query.sql.includes('group by')); + expect(groupQuery?.sql).toContain('NULL::text'); + expect(groupQuery?.sql).not.toContain('"t"."col_broken_formula"'); + }); + + it('orders single-select groups by configured option order with v1 null semantics', async () => { + const fixture = await setupRepositoryFixture({ + db, + createdSchemas, + seed: 'group-select-order', + statusOptions: ['Done', 'Blocked', 'Open'], + rows: [ + { name: 'A', age: 10, status: 'Open' }, + { name: 'B', age: 20, status: null }, + { name: 'C', age: 30, status: 'Done' }, + { name: 'D', age: 40, status: 'Blocked' }, + ], + }); + + const result = await fixture.repository.find(fixture.context, fixture.table, undefined, { + mode: 'stored', + includeTotal: false, + groupBy: [{ fieldId: fixture.statusFieldId, direction: 'asc' }], + groupLimit: 5_000, + }); + + expect(result.isOk()).toBe(true); + if (result.isErr()) return; + expect(result.value.total).toBe(4); + expect( + result.value.groups?.map((group) => group.fields[fixture.statusFieldId.toString()]) + ).toEqual([null, 'Done', 'Blocked', 'Open']); + }); + + it('buckets date groups at the field formatting granularity in its time zone', async () => { + const fixture = await setupRepositoryFixture({ + db, + createdSchemas, + seed: 'group-date-bucket', + dateFieldTimeZone: 'Asia/Shanghai', + rows: [ + // 2026-06-02 02:00 and 10:00 local (+08): same local day, one bucket. + { name: 'A', age: 10, date: '2026-06-01T18:00:00.000Z' }, + { name: 'B', age: 20, date: '2026-06-02T02:00:00.000Z' }, + // 2026-06-03 04:00 local: next day. + { name: 'C', age: 30, date: '2026-06-02T20:00:00.000Z' }, + { name: 'D', age: 40, date: null }, + ], + }); + + const result = await fixture.repository.find(fixture.context, fixture.table, undefined, { + mode: 'stored', + includeTotal: false, + groupBy: [{ fieldId: fixture.dateFieldId, direction: 'asc' }], + groupLimit: 5_000, + }); + + expect(result.isOk()).toBe(true); + if (result.isErr()) return; + + const groups = result.value.groups?.map((group) => { + const value = group.fields[fixture.dateFieldId.toString()]; + return { + value: value == null ? null : new Date(value as string).toISOString(), + count: group.count, + }; + }); + // Day buckets keyed as timestamptz of local midnight (V1 parity). + expect(groups).toEqual([ + { value: null, count: 1 }, + { value: '2026-06-01T16:00:00.000Z', count: 2 }, + { value: '2026-06-02T16:00:00.000Z', count: 1 }, + ]); + const groupQuery = driver.queries.find((query) => query.sql.includes('group by')); + expect(groupQuery?.sql).toContain('date_trunc'); + }); + it('re-checks view row order column existence after it is created', async () => { const fixture = await setupRepositoryFixture({ db, @@ -526,6 +958,87 @@ describe('PostgresTableRecordQueryRepository projection (pglite)', () => { ]); }); + it('projects matched fields with search-result row numbering', async () => { + const fixture = await setupRepositoryFixture({ + db, + createdSchemas, + seed: 'search-match-index', + rows: [ + { name: 'Alpha', age: 10 }, + { name: 'Beta', age: 20 }, + { name: 'Alpha two', age: 30 }, + ], + }); + const search = { + search: RecordSearch.fromTuple(['Alpha', fixture.nameFieldId.toString(), true]), + visibleFieldIds: [fixture.nameFieldId], + }; + const pagination = OffsetPagination.create( + PageLimit.create(1)._unsafeUnwrap(), + PageOffset.create(1)._unsafeUnwrap() + ); + + const result = await fixture.repository.find(fixture.context, fixture.table, undefined, { + mode: 'stored', + includeTotal: false, + pagination, + search, + includeSearchFieldMatches: true, + searchIndexMode: 'matched', + }); + + expect(result.isOk()).toBe(true); + if (result.isErr()) return; + expect(result.value.searchMatches).toEqual([ + { + index: 2, + fieldId: fixture.nameFieldId, + recordId: RecordId.create(fixture.insertedRecordIds[2]!)._unsafeUnwrap(), + }, + ]); + }); + + it('projects complete filtered/sorted row numbers for search matches', async () => { + const fixture = await setupRepositoryFixture({ + db, + createdSchemas, + seed: 'search-view-index', + rows: [ + { name: 'Alpha', age: 10 }, + { name: 'Beta', age: 20 }, + { name: 'Alpha two', age: 30 }, + ], + }); + const search = { + search: RecordSearch.fromTuple(['Alpha', fixture.nameFieldId.toString(), true]), + visibleFieldIds: [fixture.nameFieldId], + }; + const pagination = OffsetPagination.create( + PageLimit.create(1)._unsafeUnwrap(), + PageOffset.create(1)._unsafeUnwrap() + ); + + const result = await fixture.repository.find(fixture.context, fixture.table, undefined, { + mode: 'stored', + includeTotal: false, + pagination, + search, + includeSearchFieldMatches: true, + searchIndexMode: 'view', + }); + + expect(result.isOk()).toBe(true); + if (result.isErr()) return; + expect(result.value.searchMatches).toEqual([ + { + index: 3, + fieldId: fixture.nameFieldId, + recordId: RecordId.create(fixture.insertedRecordIds[2]!)._unsafeUnwrap(), + }, + ]); + expect(driver.queries.some((query) => query.sql.includes('row_number() over ()'))).toBe(true); + }); + it('streams correct pages for cursor pagination and respects projection', async () => { const fixture = await setupRepositoryFixture({ db, @@ -630,4 +1143,412 @@ describe('PostgresTableRecordQueryRepository projection (pglite)', () => { expect(driver.queries[0].sql).not.toContain(' offset '); expect(driver.queries[0].sql).not.toContain('"__auto_number" >'); }); + + it('aggregates totals through the existing Table Record repository', async () => { + const fixture = await setupRepositoryFixture({ + db, + createdSchemas, + seed: 'aggregate-total', + rows: [ + { name: 'A', age: 10 }, + { name: 'B', age: 20 }, + { name: 'B', age: 20 }, + ], + }); + const viewId = fixture.table.defaultView()._unsafeUnwrap().id().toString(); + const aggregation = fixture.table + .createRecordAggregation({ + viewId, + fields: [ + { fieldId: fixture.nameFieldId.toString(), statisticFunc: 'unique' }, + { fieldId: fixture.ageFieldId.toString(), statisticFunc: 'count' }, + { fieldId: fixture.ageFieldId.toString(), statisticFunc: 'sum' }, + { fieldId: fixture.ageFieldId.toString(), statisticFunc: 'average' }, + ], + }) + ._unsafeUnwrap(); + + const result = await fixture.repository.aggregate(fixture.context, fixture.table, aggregation); + + expect(result.isOk()).toBe(true); + expect( + result._unsafeUnwrap().map(({ fieldId, statisticFunc, value, groupValues }) => ({ + fieldId: fieldId.toString(), + statisticFunc, + value, + groupValues, + })) + ).toEqual([ + { + fieldId: fixture.nameFieldId.toString(), + statisticFunc: 'unique', + value: 2, + groupValues: undefined, + }, + { + fieldId: fixture.ageFieldId.toString(), + statisticFunc: 'count', + value: 3, + groupValues: undefined, + }, + { + fieldId: fixture.ageFieldId.toString(), + statisticFunc: 'sum', + value: 50, + groupValues: undefined, + }, + { + fieldId: fixture.ageFieldId.toString(), + statisticFunc: 'average', + value: 50 / 3, + groupValues: undefined, + }, + ]); + expect(driver.queries.at(-1)?.sql).toContain('with "record_aggregation_scope" as'); + }); + + it('returns every requested group prefix and respects the record condition spec', async () => { + const fixture = await setupRepositoryFixture({ + db, + createdSchemas, + seed: 'aggregate-group', + rows: [ + { name: 'A', age: 10 }, + { name: 'A', age: 20 }, + { name: 'B', age: 30 }, + ], + }); + const aggregation = fixture.table + .createRecordAggregation({ + viewId: fixture.table.defaultView()._unsafeUnwrap().id().toString(), + fields: [{ fieldId: fixture.ageFieldId.toString(), statisticFunc: 'sum' }], + groupBy: [ + { fieldId: fixture.nameFieldId.toString(), order: 'asc' }, + { fieldId: fixture.ageFieldId.toString(), order: 'asc' }, + ], + }) + ._unsafeUnwrap(); + const selectedIds = [ + RecordId.create(fixture.insertedRecordIds[0]!)._unsafeUnwrap(), + RecordId.create(fixture.insertedRecordIds[1]!)._unsafeUnwrap(), + ]; + + const result = await fixture.repository.aggregate( + fixture.context, + fixture.table, + aggregation, + RecordByIdsSpec.create(selectedIds) + ); + + expect(result.isOk()).toBe(true); + expect( + result._unsafeUnwrap().map(({ statisticFunc, value, groupValues }) => ({ + statisticFunc, + value, + groupValues, + })) + ).toEqual([ + { statisticFunc: 'sum', value: 30, groupValues: undefined }, + { statisticFunc: 'sum', value: 30, groupValues: ['A'] }, + { statisticFunc: 'sum', value: 10, groupValues: ['A', 10] }, + { statisticFunc: 'sum', value: 20, groupValues: ['A', 20] }, + ]); + }); + + it('orders group rows and applies visible-row search before aggregation', async () => { + const fixture = await setupRepositoryFixture({ + db, + createdSchemas, + seed: 'aggregate-search', + rows: [ + { name: 'Alpha', age: 10 }, + { name: 'Alpine', age: 20 }, + { name: 'Beta', age: 30 }, + ], + }); + const aggregation = fixture.table + .createRecordAggregation({ + viewId: fixture.table.defaultView()._unsafeUnwrap().id().toString(), + fields: [{ fieldId: fixture.ageFieldId.toString(), statisticFunc: 'count' }], + groupBy: [{ fieldId: fixture.nameFieldId.toString(), order: 'desc' }], + }) + ._unsafeUnwrap(); + + const result = await fixture.repository.aggregate( + fixture.context, + fixture.table, + aggregation, + undefined, + { + search: { + search: RecordSearch.fromTuple(['Al', fixture.nameFieldId.toString(), true]), + visibleFieldIds: [fixture.nameFieldId], + }, + } + ); + + expect( + result._unsafeUnwrap().map(({ value, groupValues }) => ({ value, groupValues })) + ).toEqual([ + { value: 2, groupValues: undefined }, + { value: 1, groupValues: ['Alpine'] }, + { value: 1, groupValues: ['Alpha'] }, + ]); + expect(driver.queries.at(-1)?.sql).toContain('order by "a"."col_name" desc'); + }); + + it('aggregates flattened multiple values and attachment sizes without a legacy query adapter', async () => { + const seed = 'aggregate-json'; + const baseId = BaseId.create(createId('bse', seed))._unsafeUnwrap(); + const tableId = TableId.create(createId('tbl', seed))._unsafeUnwrap(); + const nameFieldId = FieldId.create(createId('fld', `n-${seed}`))._unsafeUnwrap(); + const tagsFieldId = FieldId.create(createId('fld', `t-${seed}`))._unsafeUnwrap(); + const filesFieldId = FieldId.create(createId('fld', `f-${seed}`))._unsafeUnwrap(); + const builder = Table.builder() + .withBaseId(baseId) + .withId(tableId) + .withName(TableName.create('JSON Aggregation')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(nameFieldId) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + builder + .field() + .multipleSelect() + .withId(tagsFieldId) + .withName(FieldName.create('Tags')._unsafeUnwrap()) + .done(); + builder + .field() + .attachment() + .withId(filesFieldId) + .withName(FieldName.create('Files')._unsafeUnwrap()) + .done(); + builder.view().defaultGrid().done(); + const table = builder.build()._unsafeUnwrap(); + for (const [fieldId, dbFieldName] of [ + [nameFieldId, 'col_name'], + [tagsFieldId, 'col_tags'], + [filesFieldId, 'col_files'], + ] as const) { + table + .getField((field) => field.id().equals(fieldId)) + ._unsafeUnwrap() + .setDbFieldName(DbFieldName.rehydrate(dbFieldName)._unsafeUnwrap()) + ._unsafeUnwrap(); + } + + const schemaName = baseId.toString(); + const fullTableName = `${schemaName}.${tableId.toString()}`; + createdSchemas.push(schemaName); + await sql`CREATE SCHEMA ${sql.id(schemaName)}`.execute(db); + await sql` + CREATE TABLE ${sql.table(fullTableName)} ( + __id text PRIMARY KEY, + __version integer NOT NULL, + __auto_number integer, + __created_time timestamptz, + __created_by text, + __last_modified_time timestamptz, + __last_modified_by text, + col_name text, + col_tags jsonb, + col_files jsonb + ) + `.execute(db); + const rows = [ + { name: 'A', tags: ['A', 'B'], files: [{ size: 10 }, { size: 20 }] }, + { name: 'B', tags: ['B'], files: [{ size: 5 }] }, + { name: 'C', tags: null, files: null }, + ]; + for (const [index, row] of rows.entries()) { + await sql` + INSERT INTO ${sql.table(fullTableName)} ( + __id, __version, __auto_number, __created_time, __created_by, + __last_modified_time, __last_modified_by, col_name, col_tags, col_files + ) VALUES ( + ${createId('rec', `${index}-${seed}`)}, 1, ${index + 1}, + ${'2025-01-01T00:00:00.000Z'}, ${'usr_creator'}, + ${'2025-01-02T00:00:00.000Z'}, ${'usr_modifier'}, + ${row.name}, ${row.tags ? JSON.stringify(row.tags) : null}::jsonb, + ${row.files ? JSON.stringify(row.files) : null}::jsonb + ) + `.execute(db); + } + const manager = new TableRecordQueryBuilderManager( + db, + {} as unknown as ITableRepository, + new Pg16TypeValidationStrategy() + ); + const repository = new PostgresTableRecordQueryRepository(manager, db, createLogger()); + const context = { actorId: ActorId.create('tester')._unsafeUnwrap() }; + const aggregation = table + .createRecordAggregation({ + viewId: table.defaultView()._unsafeUnwrap().id().toString(), + fields: [ + { fieldId: tagsFieldId.toString(), statisticFunc: 'unique' }, + { fieldId: tagsFieldId.toString(), statisticFunc: 'percentUnique' }, + { fieldId: filesFieldId.toString(), statisticFunc: 'totalAttachmentSize' }, + ], + }) + ._unsafeUnwrap(); + + const result = await repository.aggregate(context, table, aggregation); + + expect( + result._unsafeUnwrap().map(({ statisticFunc, value }) => ({ statisticFunc, value })) + ).toEqual([ + { statisticFunc: 'unique', value: 2 }, + { statisticFunc: 'percentUnique', value: 200 / 3 }, + { statisticFunc: 'totalAttachmentSize', value: 35 }, + ]); + }); + + it('collects inclusive calendar days with timezone, null-end fallback, search, and top-ten ids', async () => { + const seed = 'calendar-daily'; + const baseId = BaseId.create(createId('bse', seed))._unsafeUnwrap(); + const tableId = TableId.create(createId('tbl', seed))._unsafeUnwrap(); + const nameFieldId = FieldId.create(createId('fld', `n-${seed}`))._unsafeUnwrap(); + const startFieldId = FieldId.create(createId('fld', `s-${seed}`))._unsafeUnwrap(); + const endFieldId = FieldId.create(createId('fld', `e-${seed}`))._unsafeUnwrap(); + const formatting = DateTimeFormatting.create({ + date: DateFormattingPreset.ISO, + time: TimeFormatting.Hour24, + timeZone: 'Asia/Singapore', + })._unsafeUnwrap(); + const builder = Table.builder() + .withBaseId(baseId) + .withId(tableId) + .withName(TableName.create('Calendar Daily')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(nameFieldId) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + builder + .field() + .date() + .withId(startFieldId) + .withName(FieldName.create('Start')._unsafeUnwrap()) + .withFormatting(formatting) + .done(); + builder + .field() + .date() + .withId(endFieldId) + .withName(FieldName.create('End')._unsafeUnwrap()) + .withFormatting(formatting) + .done(); + builder.view().calendar().defaultName().done(); + const table = builder.build()._unsafeUnwrap(); + for (const [fieldId, dbFieldName] of [ + [nameFieldId, 'col_name'], + [startFieldId, 'col_start'], + [endFieldId, 'col_end'], + ] as const) { + table + .getField((field) => field.id().equals(fieldId)) + ._unsafeUnwrap() + .setDbFieldName(DbFieldName.rehydrate(dbFieldName)._unsafeUnwrap()) + ._unsafeUnwrap(); + } + + const schemaName = baseId.toString(); + const fullTableName = `${schemaName}.${tableId.toString()}`; + createdSchemas.push(schemaName); + await sql`CREATE SCHEMA ${sql.id(schemaName)}`.execute(db); + await sql` + CREATE TABLE ${sql.table(fullTableName)} ( + __id text PRIMARY KEY, + __version integer NOT NULL, + __auto_number integer, + __created_time timestamptz, + __created_by text, + __last_modified_time timestamptz, + __last_modified_by text, + col_name text, + col_start timestamptz, + col_end timestamptz + ) + `.execute(db); + const rows = [ + { + name: 'Alpha span', + start: '2024-12-31T16:30:00.000Z', + end: '2025-01-02T16:30:00.000Z', + }, + { name: 'Alpha null', start: '2025-01-01T17:00:00.000Z', end: null }, + { name: 'Beta excluded', start: '2025-01-01T18:00:00.000Z', end: null }, + ...Array.from({ length: 9 }, (_, index) => ({ + name: `Alpha ${index}`, + start: new Date( + Date.parse('2025-01-01T19:00:00.000Z') + index * 60 * 60 * 1000 + ).toISOString(), + end: null, + })), + ]; + for (const [index, row] of rows.entries()) { + await sql` + INSERT INTO ${sql.table(fullTableName)} ( + __id, __version, __auto_number, __created_time, __created_by, + __last_modified_time, __last_modified_by, col_name, col_start, col_end + ) VALUES ( + ${createId('rec', `${index}-${seed}`)}, 1, ${index + 1}, + ${'2025-01-01T00:00:00.000Z'}, ${'usr_creator'}, + ${'2025-01-02T00:00:00.000Z'}, ${'usr_modifier'}, + ${row.name}, ${row.start}, ${row.end} + ) + `.execute(db); + } + const manager = new TableRecordQueryBuilderManager( + db, + {} as unknown as ITableRepository, + new Pg16TypeValidationStrategy() + ); + const repository = new PostgresTableRecordQueryRepository(manager, db, createLogger()); + const context = { actorId: ActorId.create('tester')._unsafeUnwrap() }; + const calendar = table + .createRecordCalendarDailyCollection({ + viewId: table.defaultView()._unsafeUnwrap().id().toString(), + startFieldId: startFieldId.toString(), + endFieldId: endFieldId.toString(), + }) + ._unsafeUnwrap(); + + const result = await repository.calendarDailyCollection( + context, + table, + calendar, + { + startDate: '2025-01-01T00:00:00+08:00', + endDate: '2025-01-03T00:00:00+08:00', + }, + undefined, + { + search: { + search: RecordSearch.fromTuple(['Alpha', nameFieldId.toString(), true]), + visibleFieldIds: [nameFieldId], + }, + } + ); + + expect( + result._unsafeUnwrap().map((entry) => ({ + date: entry.date, + count: entry.count, + recordIds: entry.recordIds.length, + })) + ).toEqual([ + { date: '2025-01-01', count: 1, recordIds: 1 }, + { date: '2025-01-02', count: 11, recordIds: 10 }, + { date: '2025-01-03', count: 1, recordIds: 1 }, + ]); + expect(driver.queries.at(-1)?.sql).toContain('generate_series'); + expect(driver.queries.at(-1)?.sql).not.toContain('knex'); + }); }); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordQueryRepository.ts b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordQueryRepository.ts index 05cbae505b..928ddd2080 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordQueryRepository.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordQueryRepository.ts @@ -4,24 +4,40 @@ import { isDomainError, v2CoreTokens, type DomainError, + type FieldOrderBy, FieldType, + type Field, type IExecutionContext, type IRecordReadQuerySource, type IRecordSearchAccessPathResolution, + type RecordQuerySearch, type ITableRecordQueryRepository, + type ITableRecordAggregationQueryRepository, + type ITableRecordCalendarQueryRepository, + type ITableRecordCollaboratorQueryRepository, + type TableRecordAggregation, + type TableRecordAggregationGroup, + type TableRecordAggregationValue, + type TableRecordCalendarDailyCollection, + type TableRecordCalendarDailyCollectionEntry, RecordByIdSpec, type ITableRecordQueryOptions, type ITableRecordQueryResult, + type ITableRecordSearchMatch, type ITableRecordQueryStreamOptions, - type RecordId, type ISpecification, type ITableRecordConditionSpecVisitor, type Table, type TableRecordReadModel, type TableRecord, + type ViewCollaboratorField, + viewCollaboratorFieldIsMultiple, type TableRecordQueryMode, + FieldId, + RecordId, type ITableRecordStreamPagination, type ITableRecordStreamPaginationStrategy, + type LastModifiedByField, OffsetPagination, PageLimit, PageOffset, @@ -34,24 +50,35 @@ import { import { inject, injectable } from '@teable/v2-di'; import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; import { CompiledQuery, sql } from 'kysely'; -import type { Expression, Kysely, SqlBool } from 'kysely'; +import type { Expression, Kysely, RawBuilder, SqlBool } from 'kysely'; import { err, ok, safeTry } from 'neverthrow'; import type { Result } from 'neverthrow'; import { v2RecordRepositoryPostgresTokens } from '../di/tokens'; -import { FieldOutputColumnVisitor } from '../query-builder'; -import type { - TableRecordQueryBuilderManager, - FieldOutputColumn, - DynamicDB, +import { + buildStoredFieldValueExpression, + buildStoredFieldOrderByClauses, + FieldOutputColumnVisitor, + type DynamicDB, + type FieldOutputColumn, + type StoredFieldOrderByClause, + type TableRecordQueryBuilderManager, } from '../query-builder'; +import { buildDateLikeGroupExpression } from '../query-builder/dateLikeOrderBy'; +import { buildUserJsonObjectFromSnapshotExpr } from '../query-builder/userSnapshotSql'; import { buildRecordWhereClause } from './buildRecordWhereClause'; import { CursorStreamPaginationStrategy } from './CursorStreamPaginationStrategy'; import { OffsetStreamPaginationStrategy } from './OffsetStreamPaginationStrategy'; import { + buildRecordSearchFieldMatches, buildRecordSearchWhereClause, buildRecordSearchWherePlan, + type RecordSearchFieldMatch, } from './RecordSearchWhereBuilder'; +import { + buildTableRecordAggregationExpression, + normalizeTableRecordAggregationValue, +} from './TableRecordAggregationSql'; const RECORD_ID_COLUMN = '__id'; const RECORD_VERSION_COLUMN = '__version'; @@ -60,6 +87,60 @@ const ORDER_COLUMN_CACHE_TTL_MS = 5_000; const LEGACY_AVATAR_PREFIX = '/api/attachments/read/public/avatar/'; const TABLE_QUERY_SQL_DIAGNOSTICS_CONTEXT_KEY = Symbol.for('teable.v2.tableOps.sqlDiagnostics'); +const buildGroupFieldValueExpression = ( + field: Field, + column: string +): Result< + { expression: RawBuilder; usesValueExpressionForOrder: boolean }, + DomainError +> => { + const storedValue = buildStoredFieldValueExpression(field, TABLE_ALIAS, column); + if (storedValue.isErr()) { + return err(storedValue.error); + } + if (storedValue.value.usesErrorFallback) { + return ok({ + expression: storedValue.value.expression, + usesValueExpressionForOrder: true, + }); + } + + const dateGroupExpression = buildDateLikeGroupExpression(field, TABLE_ALIAS, column); + if (dateGroupExpression) { + return ok({ + expression: dateGroupExpression, + usesValueExpressionForOrder: true, + }); + } + + const columnRef = sql.ref(`${TABLE_ALIAS}.${column}`); + if (field.type().equals(FieldType.createdBy())) { + return ok({ + expression: buildUserJsonObjectFromSnapshotExpr( + columnRef, + sql.ref(`${TABLE_ALIAS}.__created_by`) + ), + usesValueExpressionForOrder: true, + }); + } + if ( + field.type().equals(FieldType.lastModifiedBy()) && + (field as LastModifiedByField).isTrackAll() + ) { + return ok({ + expression: buildUserJsonObjectFromSnapshotExpr( + columnRef, + sql.ref(`${TABLE_ALIAS}.__last_modified_by`) + ), + usesValueExpressionForOrder: true, + }); + } + return ok({ + expression: storedValue.value.expression, + usesValueExpressionForOrder: false, + }); +}; + type OrderColumnExistsCacheEntry = { exists: boolean; cachedAt: number; @@ -166,7 +247,13 @@ const createRepositoryFindTraceAttributes = ( }; @injectable() -export class PostgresTableRecordQueryRepository implements ITableRecordQueryRepository { +export class PostgresTableRecordQueryRepository + implements + ITableRecordQueryRepository, + ITableRecordAggregationQueryRepository, + ITableRecordCalendarQueryRepository, + ITableRecordCollaboratorQueryRepository +{ private readonly orderColumnExistsCache = new Map(); private readonly defaultStreamPaginationStrategy = new OffsetStreamPaginationStrategy(); private readonly streamPaginationStrategies: ReadonlyArray = @@ -181,6 +268,322 @@ export class PostgresTableRecordQueryRepository implements ITableRecordQueryRepo private readonly logger: ILogger ) {} + async findDistinctUserIds( + context: IExecutionContext, + table: Table, + field: ViewCollaboratorField, + spec?: ISpecification + ): Promise, DomainError>> { + const span = context.tracer?.startSpan('teable.repository.record.find_distinct_user_ids', { + tableId: table.id().toString(), + fieldId: field.id().toString(), + }); + + try { + const queryBuilderResult = await this.queryBuilderManager.createBuilder(context, table, { + mode: 'stored', + }); + if (queryBuilderResult.isErr()) return err(queryBuilderResult.error); + const queryBuilder = queryBuilderResult.value; + queryBuilder.select([field.id()]); + if (spec) queryBuilder.where(spec); + const scopedQueryResult = queryBuilder.build(); + if (scopedQueryResult.isErr()) return err(scopedQueryResult.error); + + const columnAliasResult = new FieldOutputColumnVisitor().getColumnAlias(field); + if (columnAliasResult.isErr()) return err(columnAliasResult.error); + const scopeAlias = 'record_collaborator_scope'; + const column = sql.ref(`a.${columnAliasResult.value}`); + const userIdExpression = viewCollaboratorFieldIsMultiple(field) + ? sql`jsonb_array_elements(COALESCE(${column}::jsonb, '[]'::jsonb))->>'id'` + : sql`${column}::jsonb->>'id'`; + const dynamicDb = this.db as unknown as Kysely; + const query = dynamicDb + .with(scopeAlias, () => scopedQueryResult.value) + .selectFrom(`${scopeAlias} as a`) + .select(userIdExpression.as('user_id')) + .distinct(); + const compiled = query.compile(); + this.recordSqlDiagnostic(context, 'record_find_distinct_user_ids', compiled); + const rows = await dynamicDb.executeQuery<{ user_id: string | null }>(compiled); + return ok(rows.rows.flatMap((row) => (row.user_id ? [row.user_id] : []))); + } catch (error) { + return err( + domainError.infrastructure({ + message: 'Failed to query distinct user IDs', + details: { error: (error as Error)?.message ?? String(error) }, + }) + ); + } finally { + span?.end(); + } + } + + async aggregate( + context: IExecutionContext, + table: Table, + aggregation: TableRecordAggregation, + spec?: ISpecification, + options?: { + readonly maxGroupPoints?: number; + readonly search?: RecordQuerySearch; + } + ): Promise, DomainError>> { + if (!aggregation.fields.length) return ok([]); + + const span = context.tracer?.startSpan('teable.repository.record.aggregate', { + tableId: table.id().toString(), + fieldCount: aggregation.fields.length, + groupDepth: aggregation.groupBy.length, + }); + + try { + const queryBuilderResult = await this.queryBuilderManager.createBuilder(context, table, { + // Aggregation follows the same persisted-value contract as ListTableRecordsHandler. + mode: 'stored', + }); + if (queryBuilderResult.isErr()) return err(queryBuilderResult.error); + const queryBuilder = queryBuilderResult.value; + const searchFieldsResult = options?.search + ? options.search.search.resolveFields(table, { + visibleFieldIds: options.search.visibleFieldIds, + }) + : ok([]); + if (searchFieldsResult.isErr()) return err(searchFieldsResult.error); + const projection = [ + ...new Map( + [ + ...aggregation.fields.map(({ fieldId }) => fieldId), + ...aggregation.groupBy.map(({ fieldId }) => fieldId), + ...searchFieldsResult.value.map((field) => field.id()), + ].map((fieldId) => [fieldId.toString(), fieldId]) + ).values(), + ]; + queryBuilder.select(projection); + if (spec) queryBuilder.where(spec); + const scopedQueryResult = queryBuilder.build(); + if (scopedQueryResult.isErr()) return err(scopedQueryResult.error); + const searchWherePlan = buildRecordSearchWherePlan(table, options?.search, { + tableAlias: 'a', + }); + if (searchWherePlan.isErr()) return err(searchWherePlan.error); + + const dynamicDb = this.db as unknown as Kysely; + const fieldColumns = new Map(); + const fieldsById = new Map(); + for (const fieldId of projection) { + const fieldResult = table.getField((field) => field.id().equals(fieldId)); + if (fieldResult.isErr()) return err(fieldResult.error); + const aliasResult = new FieldOutputColumnVisitor().getColumnAlias(fieldResult.value); + if (aliasResult.isErr()) return err(aliasResult.error); + fieldColumns.set(fieldId.toString(), aliasResult.value); + fieldsById.set(fieldId.toString(), fieldResult.value); + } + + const values: TableRecordAggregationValue[] = []; + const levels: ReadonlyArray> = [ + [], + ...aggregation.groupBy.map((_, index) => aggregation.groupBy.slice(0, index + 1)), + ]; + + for (const groupFields of levels) { + const scopeAlias = 'record_aggregation_scope'; + const aggregateAliases = aggregation.fields.map((_, index) => `__aggregation_${index}`); + const groupAliases = groupFields.map((_, index) => `__group_${index}`); + let aggregateQuery = dynamicDb + .with(scopeAlias, () => scopedQueryResult.value) + .selectFrom(`${scopeAlias} as a`) + .select( + aggregation.fields.map((aggregationField, index) => { + const field = fieldsById.get(aggregationField.fieldId.toString())!; + const columnName = fieldColumns.get(aggregationField.fieldId.toString())!; + return buildTableRecordAggregationExpression( + field, + columnName, + aggregationField.statisticFunc + ).as(aggregateAliases[index]!); + }) + ); + if (searchWherePlan.value.condition !== null) { + aggregateQuery = aggregateQuery.where(searchWherePlan.value.condition); + } + + for (const [index, group] of groupFields.entries()) { + const column = sql.ref(`a.${fieldColumns.get(group.fieldId.toString())!}`); + aggregateQuery = aggregateQuery + .select(column.as(groupAliases[index]!)) + .groupBy(column) + .orderBy(column, group.order); + } + if (groupFields.length) { + aggregateQuery = aggregateQuery.limit(options?.maxGroupPoints ?? 5_000); + } + + const compiled = aggregateQuery.compile(); + this.recordSqlDiagnostic(context, 'record_aggregate', compiled); + const rows = await dynamicDb.executeQuery>(compiled); + for (const row of rows.rows) { + const groupValues = groupAliases.map((alias, index) => + normalizeStoredGroupValue( + fieldsById.get(groupFields[index]!.fieldId.toString())!, + row[alias] + ) + ); + aggregation.fields.forEach((aggregationField, index) => { + values.push({ + fieldId: aggregationField.fieldId, + statisticFunc: aggregationField.statisticFunc, + value: normalizeTableRecordAggregationValue( + row[aggregateAliases[index]!], + aggregationField.statisticFunc + ), + ...(groupValues.length ? { groupValues } : {}), + }); + }); + } + } + + return ok(values); + } catch (error) { + return err(buildUnexpectedQueryError('Failed to aggregate table records', error)); + } finally { + span?.end(); + } + } + + async calendarDailyCollection( + context: IExecutionContext, + table: Table, + calendar: TableRecordCalendarDailyCollection, + range: { + readonly startDate: string; + readonly endDate: string; + }, + spec?: ISpecification, + options?: { + readonly search?: RecordQuerySearch; + } + ): Promise, DomainError>> { + const span = context.tracer?.startSpan('teable.repository.record.calendar_daily_collection', { + tableId: table.id().toString(), + startFieldId: calendar.startFieldId.toString(), + endFieldId: calendar.endFieldId.toString(), + }); + + try { + const queryBuilderResult = await this.queryBuilderManager.createBuilder(context, table, { + mode: 'stored', + }); + if (queryBuilderResult.isErr()) return err(queryBuilderResult.error); + const queryBuilder = queryBuilderResult.value; + const searchFieldsResult = options?.search + ? options.search.search.resolveFields(table, { + visibleFieldIds: options.search.visibleFieldIds, + }) + : ok([]); + if (searchFieldsResult.isErr()) return err(searchFieldsResult.error); + const projection = [ + ...new Map( + [ + calendar.startFieldId, + calendar.endFieldId, + ...searchFieldsResult.value.map((field) => field.id()), + ].map((fieldId) => [fieldId.toString(), fieldId]) + ).values(), + ]; + queryBuilder.select(projection); + if (spec) queryBuilder.where(spec); + const scopedQueryResult = queryBuilder.build(); + if (scopedQueryResult.isErr()) return err(scopedQueryResult.error); + + const searchWherePlan = buildRecordSearchWherePlan(table, options?.search, { + tableAlias: 'a', + }); + if (searchWherePlan.isErr()) return err(searchWherePlan.error); + + const startFieldResult = table.getField((field) => field.id().equals(calendar.startFieldId)); + if (startFieldResult.isErr()) return err(startFieldResult.error); + const endFieldResult = table.getField((field) => field.id().equals(calendar.endFieldId)); + if (endFieldResult.isErr()) return err(endFieldResult.error); + const outputVisitor = new FieldOutputColumnVisitor(); + const startColumnResult = outputVisitor.getColumnAlias(startFieldResult.value); + if (startColumnResult.isErr()) return err(startColumnResult.error); + const endColumnResult = outputVisitor.getColumnAlias(endFieldResult.value); + if (endColumnResult.isErr()) return err(endColumnResult.error); + + const dynamicDb = this.db as unknown as Kysely; + const scopeAlias = 'record_calendar_scope'; + const timeZone = calendar.timeZone.toString(); + const startColumn = sql.ref(`a.${startColumnResult.value}`); + const endColumn = sql.ref(`a.${endColumnResult.value}`); + const dateSeries = sql<{ date: Date }>`( + SELECT date::date AS date + FROM generate_series( + (${range.startDate}::timestamptz AT TIME ZONE ${timeZone})::date, + (${range.endDate}::timestamptz AT TIME ZONE ${timeZone})::date, + '1 day'::interval + ) AS date + )`.as('dates'); + + let query = dynamicDb + .with(scopeAlias, () => scopedQueryResult.value) + .selectFrom(`${scopeAlias} as a`) + .innerJoin(dateSeries, (join) => join.onTrue()) + .select([ + sql`to_char(${sql.ref('dates.date')}, 'YYYY-MM-DD')`.as('date'), + sql`count(*)`.as('count'), + sql>`( + array_agg(${sql.ref(`a.${RECORD_ID_COLUMN}`)} ORDER BY ${startColumn}) + )[1:10]`.as('record_ids'), + ]) + .where( + sql` + (${startColumn}::timestamptz AT TIME ZONE ${timeZone})::date + <= (${range.endDate}::timestamptz AT TIME ZONE ${timeZone})::date + AND ( + COALESCE(${endColumn}::timestamptz, ${startColumn}::timestamptz) + AT TIME ZONE ${timeZone} + )::date >= (${range.startDate}::timestamptz AT TIME ZONE ${timeZone})::date + AND (${startColumn}::timestamptz AT TIME ZONE ${timeZone})::date + <= ${sql.ref('dates.date')} + AND ( + COALESCE(${endColumn}::timestamptz, ${startColumn}::timestamptz) + AT TIME ZONE ${timeZone} + )::date >= ${sql.ref('dates.date')} + ` + ) + .groupBy(sql.ref('dates.date')) + .orderBy(sql.ref('dates.date'), 'asc'); + if (searchWherePlan.value.condition !== null) { + query = query.where(searchWherePlan.value.condition); + } + + const compiled = query.compile(); + this.recordSqlDiagnostic(context, 'record_calendar_daily_collection', compiled); + const rows = await dynamicDb.executeQuery<{ + date: string; + count: string; + record_ids: ReadonlyArray; + }>(compiled); + const entries: TableRecordCalendarDailyCollectionEntry[] = []; + for (const row of rows.rows) { + const recordIdsResult = row.record_ids.map((recordId) => RecordId.create(recordId)); + const firstError = recordIdsResult.find((result) => result.isErr()); + if (firstError?.isErr()) return err(firstError.error); + entries.push({ + date: row.date, + count: Number(row.count), + recordIds: recordIdsResult.map((result) => result._unsafeUnwrap()), + }); + } + return ok(entries); + } catch (error) { + return err(buildUnexpectedQueryError('Failed to query calendar daily collection', error)); + } finally { + span?.end(); + } + } + async find( context: IExecutionContext, table: Table, @@ -285,6 +688,11 @@ export class PostgresTableRecordQueryRepository implements ITableRecordQueryRepo options, searchWherePlan.value.usedAccessPath ); + const searchFieldMatches = options?.includeSearchFieldMatches + ? yield* buildRecordSearchFieldMatches(table, options.search, { + tableAlias: TABLE_ALIAS, + }) + : []; const actualSearchAttributes = createRepositoryFindTraceAttributes( table, options, @@ -321,6 +729,13 @@ export class PostgresTableRecordQueryRepository implements ITableRecordQueryRepo if (searchWherePlan.value.condition !== null) { builtQuery = builtQuery.where(searchWherePlan.value.condition); } + for (const [index, match] of searchFieldMatches.entries()) { + builtQuery = builtQuery.select( + sql`CASE WHEN ${match.condition} THEN true ELSE false END`.as( + `__search_match_${index}` + ) + ); + } // Add order columns to the query if requested if (orderColumns.length > 0) { @@ -340,9 +755,47 @@ export class PostgresTableRecordQueryRepository implements ITableRecordQueryRepo table, options?.projectionFieldIds ); + const groupFieldColumns = options?.groupBy?.length + ? yield* new FieldOutputColumnVisitor().collect( + table, + options.groupBy.map((item) => item.fieldId) + ) + : []; + const groupFields: Array< + FieldOrderBy & { + column: string; + valueExpression: RawBuilder; + orderByClauses: ReadonlyArray; + } + > = []; + for (const item of options?.groupBy ?? []) { + const column = groupFieldColumns.find((candidate) => + candidate.fieldId.equals(item.fieldId) + )?.columnAlias; + if (!column) { + return err( + domainError.notFound({ + message: `Group field column not found: ${item.fieldId.toString()}`, + }) + ); + } + const field = yield* table.getField((candidate) => candidate.id().equals(item.fieldId)); + const { expression: valueExpression, usesValueExpressionForOrder } = + yield* buildGroupFieldValueExpression(field, column); + const orderByClauses = yield* buildStoredFieldOrderByClauses( + field, + column, + item.direction, + TABLE_ALIAS, + usesValueExpressionForOrder ? valueExpression : undefined + ); + groupFields.push({ ...item, column, valueExpression, orderByClauses }); + } try { - const shouldQueryTotal = options?.includeTotal !== false; + // Group metadata needs the full scoped row count to represent + // group-limit overflow as the legacy-compatible Unknown bucket. + const shouldQueryTotal = groupFields.length > 0 || options?.includeTotal !== false; const recordsDbSpan = context.tracer?.startSpan( 'teable.table.query.db.records', createRepositoryFindTraceAttributes( @@ -401,15 +854,84 @@ export class PostgresTableRecordQueryRepository implements ITableRecordQueryRepo .finally(() => countDbSpan?.end()) : Promise.resolve<{ count: string }>({ count: '0' }); - const [rows, countResult] = await Promise.all([rowsPromise, countPromise]); + let groupCompiled: CompiledQuery> | undefined; + if (groupFields.length) { + let groupQuery = dynamicDb + .selectFrom(`${sourceTableName} as ${TABLE_ALIAS}`) + .select( + groupFields.map((item) => + sql`${item.valueExpression}`.as(item.fieldId.toString()) + ) + ) + .select(sql`count(*)`.as('__count')) + .$if(whereClause.value !== null, (qb) => + qb.where(whereClause.value as Expression) + ) + .$if(searchWherePlan.value.condition !== null, (qb) => + qb.where(searchWherePlan.value.condition as Expression) + ) + .groupBy(groupFields.map((item) => item.valueExpression)); + + for (const item of groupFields) { + for (const clause of item.orderByClauses) { + groupQuery = groupQuery.orderBy(clause.expression, clause.direction); + } + } + if (options?.groupLimit) { + groupQuery = groupQuery.limit(options.groupLimit); + } + groupCompiled = this.withRecordReadQuerySource(groupQuery.compile(), readQuerySource); + this.recordSqlDiagnostic(context, 'record_group', groupCompiled); + } + const groupsPromise = groupCompiled + ? dynamicDb.executeQuery>(groupCompiled).then((result) => + result.rows.map((row) => { + const fields: Record = {}; + for (const item of groupFields) { + fields[item.fieldId.toString()] = row[item.fieldId.toString()]; + } + return { + fields, + count: Number(row.__count), + }; + }) + ) + : Promise.resolve(undefined); + + const [rows, countResult, groups] = await Promise.all([ + rowsPromise, + countPromise, + groupsPromise, + ]); const records = mapRowsToReadModels(fieldColumns, rows, orderColumns); const total = shouldQueryTotal ? parseInt(countResult.count, 10) : records.length; + const viewIndexByRecordId = + options?.includeSearchFieldMatches && options.searchIndexMode === 'view' + ? yield* await this.loadViewIndexes( + context, + table, + spec, + options, + rows.map((row) => String(row[RECORD_ID_COLUMN])) + ) + : undefined; + const searchMatches = options?.includeSearchFieldMatches + ? yield* this.mapSearchMatches( + rows, + searchFieldMatches, + options.pagination?.offset().toNumber() ?? 0, + options.searchIndexMode ?? 'matched', + viewIndexByRecordId + ) + : undefined; return ok({ records, total, + ...(groups ? { groups } : {}), ...(searchAccessPath ? { searchAccessPath } : {}), + ...(searchMatches ? { searchMatches } : {}), }); } catch (error) { span?.recordError(describeError(error)); @@ -430,6 +952,122 @@ export class PostgresTableRecordQueryRepository implements ITableRecordQueryRepo } } + private mapSearchMatches( + rows: ReadonlyArray>, + fieldMatches: ReadonlyArray, + offset: number, + mode: 'matched' | 'view', + viewIndexByRecordId?: ReadonlyMap + ): Result, DomainError> { + return safeTry(function* () { + const result: ITableRecordSearchMatch[] = []; + for (const [rowOffset, row] of rows.entries()) { + const rawRecordId = String(row[RECORD_ID_COLUMN]); + const recordId = yield* RecordId.create(rawRecordId); + const index = + mode === 'view' ? viewIndexByRecordId?.get(rawRecordId) : offset + rowOffset + 1; + if (index == null) { + return err( + domainError.notFound({ + code: 'record.index_not_found', + message: `Record index not found: ${rawRecordId}`, + }) + ); + } + + for (const [fieldOffset, match] of fieldMatches.entries()) { + if (row[`__search_match_${fieldOffset}`] !== true) continue; + result.push({ + index, + fieldId: yield* FieldId.create(match.field.id().toString()), + recordId, + }); + } + } + return ok(result); + }); + } + + private async loadViewIndexes( + context: IExecutionContext, + table: Table, + spec: ISpecification | undefined, + options: ITableRecordQueryOptions, + recordIds: ReadonlyArray + ): Promise, DomainError>> { + if (!recordIds.length) return ok(new Map()); + + return safeTry( + async function* (this: PostgresTableRecordQueryRepository) { + const readQuerySource = this.getRecordReadQuerySource(options); + const queryBuilder = yield* await this.queryBuilderManager.createBuilder(context, table, { + mode: resolveQueryMode(table, options.mode), + sourceTableName: readQuerySource?.tableName, + }); + const explicitRecordIdsOrder = options.recordIdsOrder; + if (!explicitRecordIdsOrder?.length) { + if (options.orderBy?.length) { + const dbTableName = yield* table.dbTableName(); + const fullTableName = yield* dbTableName.value(); + const [schemaName, tableName] = fullTableName.split('.'); + const dynamicDb = this.db as unknown as Kysely; + for (const sort of options.orderBy) { + if (isFieldOrderBy(sort)) { + queryBuilder.orderBy(sort.fieldId, sort.direction); + } else if (isSystemColumnOrderBy(sort)) { + if ( + sort.column.startsWith('__row_') && + !(await this.getOrderColumnExists(dynamicDb, schemaName, tableName, sort.column)) + ) { + queryBuilder.orderBy('__auto_number', 'asc'); + } else { + queryBuilder.orderBy(sort.column as '__auto_number', sort.direction); + } + } + } + } else { + queryBuilder.orderBy('__auto_number', 'asc'); + } + } + if (spec) queryBuilder.where(spec); + + let viewRows = yield* queryBuilder.build(); + if (explicitRecordIdsOrder?.length) { + const orderedIds = explicitRecordIdsOrder.map((recordId) => recordId.toString()); + viewRows = viewRows.orderBy( + sql`array_position(${orderedIds}::text[], ${sql.ref(`${TABLE_ALIAS}.${RECORD_ID_COLUMN}`)})` + ); + } + + const dynamicDb = this.db as unknown as Kysely; + const indexedRows = dynamicDb + .selectFrom(viewRows.as('view_rows')) + .select(sql.ref(`view_rows.${RECORD_ID_COLUMN}`).as(RECORD_ID_COLUMN)) + .select(sql`row_number() over ()`.as('__row_index')) + .as('indexed_rows'); + const compiled = this.withRecordReadQuerySource( + dynamicDb + .selectFrom(indexedRows) + .select([ + sql.ref(`indexed_rows.${RECORD_ID_COLUMN}`).as(RECORD_ID_COLUMN), + sql.ref('indexed_rows.__row_index').as('__row_index'), + ]) + .where(sql.ref(`indexed_rows.${RECORD_ID_COLUMN}`), 'in', recordIds) + .compile(), + readQuerySource + ); + this.recordSqlDiagnostic(context, 'record_search_view_index', compiled); + const rows = await dynamicDb.executeQuery<{ + __id: string; + __row_index: string | number; + }>(compiled); + return ok( + new Map(rows.rows.map((row) => [String(row[RECORD_ID_COLUMN]), Number(row.__row_index)])) + ); + }.bind(this) + ); + } + async findOne( context: IExecutionContext, table: Table, @@ -622,6 +1260,7 @@ export class PostgresTableRecordQueryRepository implements ITableRecordQueryRepo mode: options?.mode, pagination, orderBy: options?.orderBy, + includeOrders: options?.includeOrders, includeTotal: false, projectionFieldIds: options?.projectionFieldIds, search: options?.search, @@ -906,6 +1545,20 @@ const mapRowsToReadModels = ( }); }; +const normalizeStoredGroupValue = (field: Field, value: unknown): unknown => { + if (value instanceof Date) { + return value.toISOString(); + } + if ( + field.type().equals(FieldType.user()) || + field.type().equals(FieldType.createdBy()) || + field.type().equals(FieldType.lastModifiedBy()) + ) { + return normalizeStoredUserAvatarUrls(value); + } + return value; +}; + const normalizeStoredUserAvatarUrls = (value: unknown): unknown => { if (typeof value === 'string') { if (!value.includes(LEGACY_AVATAR_PREFIX)) { diff --git a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.ts b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.ts index 952f845483..dc72e5c76f 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.ts @@ -1,4 +1,4 @@ -import { tableI18nKeys } from '@teable/i18n-keys'; +import { sdkErrorI18nKeys } from '@teable/i18n-keys'; import * as core from '@teable/v2-core'; import { domainError, @@ -7,10 +7,12 @@ import { type DomainError, type IHasher, type DeleteManyResult, + generatePrefixedId, generateUuid, type RecordMutationResult, type BatchRecordMutationResult, type InsertOptions, + type ArchiveTrashRowInput, } from '@teable/v2-core'; import { inject, injectable } from '@teable/v2-di'; import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; @@ -37,6 +39,7 @@ import type { } from '../computed'; import { buildSeedTaskInput } from '../computed'; import { v2RecordRepositoryPostgresTokens } from '../di/tokens'; +import { normalizeStoredLinkItems } from '../normalizeLinkItems'; import type { DynamicDB } from '../query-builder'; import { RecordInsertBuilder, @@ -56,7 +59,6 @@ import { type OutgoingLinkDeleteOp, } from '../visitors'; import { CellValueMutateVisitor } from '../visitors/CellValueMutateVisitor'; -import { normalizeStoredLinkItems } from '../normalizeLinkItems'; import type { LinkExclusivityConstraint } from '../visitors/LinkExclusivityConstraintCollector'; import { buildRecordWhereClause } from './buildRecordWhereClause'; import type { @@ -296,6 +298,10 @@ const cleanupRestoredRecordTrash = async ( await db.deleteFrom('table_trash').where('id', 'in', staleTrashIds).execute(); }; +// Mirrors @teable/core generateRecordTrashId ('rtr' + 16 random chars). +const RECORD_TRASH_ID_PREFIX = 'rtr'; +const RECORD_TRASH_ID_LENGTH = 16; + /** * Internal insert options that extend core InsertOptions with PostgreSQL-specific flags. */ @@ -1012,9 +1018,7 @@ const loadBeforeImageForRecord = async ( ) ); } catch (error) { - return err( - wrapDatabaseError(error, 'query', { tableName, recordId: recordId.toString() }, undefined) - ); + return err(wrapDatabaseError(error, 'query', { tableName, recordId: recordId.toString() })); } }; @@ -1339,9 +1343,7 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor }); } catch (error) { await snapshotCaptureSession?.abort(); - return err( - wrapDatabaseError(error, 'insert', { tableName, fields: table.getFields() }, context.$t) - ); + return err(wrapDatabaseError(error, 'insert', { tableName, fields: table.getFields() })); } }.bind(this) ); @@ -1611,6 +1613,16 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor this.logger.debug(`insertMany:table=${tableName}`, { count: records.length }); + // Restore of archived records: drop the attachment reference rows kept at archive + // time BEFORE the insert statements write fresh ones, or usage double-counts. + if (options?.cleanupAttachmentRefRecordIds?.length) { + await db + .deleteFrom('attachments_table') + .where('table_id', '=', table.id().toString()) + .where('record_id', 'in', [...options.cleanupAttachmentRefRecordIds]) + .execute(); + } + // Legacy CreatedBy/LastModifiedBy columns may still be GENERATED ALWAYS even when // field meta says otherwise — strip them so PostgreSQL accepts the INSERT (T6146). await stripPhysicallyGeneratedColumnsFromInsertValues( @@ -1747,9 +1759,7 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor }); } catch (error) { await snapshotCaptureSession?.abort(); - return err( - wrapDatabaseError(error, 'insert', { tableName, fields: table.getFields() }, context.$t) - ); + return err(wrapDatabaseError(error, 'insert', { tableName, fields: table.getFields() })); } }.bind(this) ); @@ -2029,7 +2039,9 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor fieldIds.set(fieldId.toString(), fieldId); } - const changedFieldIds = this.expandComputedSeedFieldIds(table, [...fieldIds.values()]); + const changedFieldIds = this.expandComputedSeedFieldIds(table, [...fieldIds.values()], { + includeZeroReferenceFormulas: true, + }); if (changedFieldIds.length === 0) { return ok(undefined); } @@ -2157,6 +2169,9 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor .updateTable(tableName) .set(setClauses) .where(RECORD_ID_COLUMN, '=', recordIdStr); + if (options?.expectedVersion != null) { + updateQuery = updateQuery.where(VERSION_COLUMN, '=', options.expectedVersion); + } if (distinctUserFieldWhere) { updateQuery = updateQuery.where(distinctUserFieldWhere); } @@ -2218,16 +2233,11 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor } catch (error) { await snapshotCaptureSession?.abort(); return err( - wrapDatabaseError( - error, - 'update', - { - tableName, - recordId: recordIdStr, - fields: table.getFields(), - }, - context.$t - ) + wrapDatabaseError(error, 'update', { + tableName, + recordId: recordIdStr, + fields: table.getFields(), + }) ); } }.bind(this) @@ -2460,15 +2470,10 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor }); } catch (error) { return err( - wrapDatabaseError( - error, - 'update', - { - tableName, - fields: table.getFields(), - }, - context.$t - ) + wrapDatabaseError(error, 'update', { + tableName, + fields: table.getFields(), + }) ); } }.bind(this) @@ -2776,12 +2781,7 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor } catch (error) { batchSpan?.recordError(describeError(error)); return err( - wrapDatabaseError( - error, - 'update', - { tableName, fields: table.getFields() }, - context.$t - ) + wrapDatabaseError(error, 'update', { tableName, fields: table.getFields() }) ); } finally { batchSpan?.end(); @@ -2890,7 +2890,12 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor } const normalizedImpact = this.normalizeImpactHint(impact); - if (this.computedUpdateStrategy.mode === 'sync' && !options.forceOutbox) { + const shouldExecuteInline = + !options.forceOutbox && + (this.computedUpdateStrategy.mode === 'sync' || + (this.computedUpdateStrategy.mode === 'hybrid' && recordIds.length === 1)); + + if (shouldExecuteInline) { const planInput = { baseId: table.baseId(), seedTableId: table.id(), @@ -2918,7 +2923,8 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor } const plan = planResult.value; - if (plan.steps.length === 0) { + // Edge-only plans (delete/orphan propagation) are executable work. + if (plan.steps.length === 0 && plan.edges.length === 0) { return ok(undefined); } @@ -2947,9 +2953,10 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor return ok(undefined); } - // For hybrid/async mode, skip planStage to minimize transaction lock hold time. - // The worker will plan when it processes the seed task asynchronously. - // This matches the pattern used by runComputedUpdate (single-record path). + // A one-record hybrid batch should match the single-record repository path so + // seedTableOnly can make bounded computed values visible in the write response. + // Larger and explicitly deferred batches stay plan-free here to preserve short + // row-lock hold times; the worker plans them from the durable seed task. const seedTask = buildSeedTaskInput({ baseId: table.baseId(), seedTableId: table.id(), @@ -3046,6 +3053,85 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor return ok(undefined); } + // Re-persists archive snapshot rows (reason 'archived') during the redo replay of an + // archive operation — write-ahead within the surrounding delete transaction. + async insertArchiveTrashRows( + context: core.IExecutionContext, + table: core.Table, + rows: ReadonlyArray + ): Promise> { + if (rows.length === 0) { + return ok(undefined); + } + + try { + const db = resolvePostgresDbOrTx(this.db, context) as unknown as Kysely; + await db + .insertInto('record_trash') + .values( + rows.map((row) => ({ + id: generatePrefixedId(RECORD_TRASH_ID_PREFIX, RECORD_TRASH_ID_LENGTH), + table_id: table.id().toString(), + record_id: row.recordId, + snapshot: row.snapshot, + created_by: row.createdBy, + created_time: new Date(row.createdTime), + operation_id: row.operationId ?? null, + reason: 'archived', + record_created_time: row.recordCreatedTime ? new Date(row.recordCreatedTime) : null, + record_created_by: row.recordCreatedBy ?? null, + record_last_modified_time: row.recordLastModifiedTime + ? new Date(row.recordLastModifiedTime) + : null, + record_last_modified_by: row.recordLastModifiedBy ?? null, + })) + ) + .execute(); + return ok(undefined); + } catch (error) { + return err(wrapDatabaseError(error, 'insert', { tableName: 'record_trash' })); + } + } + + // Of the given ids, the ones that still hold a record_trash row with the given + // reason — the undo-replay purge guard (see the port doc). Chunked to stay under + // the bind-parameter limit on bulk-operation-sized id lists. + async listTrashedRecordIds( + context: core.IExecutionContext, + table: core.Table, + recordIds: ReadonlyArray, + reason: core.IRecordRemovalReason + ): Promise, DomainError>> { + const existing = new Set(); + if (recordIds.length === 0) { + return ok(existing); + } + + try { + const db = resolvePostgresDbOrTx(this.db, context) as unknown as Kysely; + const tableId = table.id().toString(); + const CHUNK = 5000; + for (let index = 0; index < recordIds.length; index += CHUNK) { + const rows = await db + .selectFrom('record_trash') + .select('record_id') + .distinct() + .where('table_id', '=', tableId) + .where('reason', '=', reason) + .where('record_id', 'in', [...recordIds.slice(index, index + CHUNK)]) + .execute(); + for (const row of rows) { + if (typeof row.record_id === 'string') { + existing.add(row.record_id); + } + } + } + return ok(existing); + } catch (error) { + return err(wrapDatabaseError(error, 'query', { tableName: 'record_trash' })); + } + } + async deleteMany( context: core.IExecutionContext, table: core.Table, @@ -3264,16 +3350,11 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor } catch (error) { await snapshotCaptureSession?.abort(); return err( - wrapDatabaseError( - error, - 'delete', - { - tableName, - count: recordIds.length, - fields: table.getFields(), - }, - context.$t - ) + wrapDatabaseError(error, 'delete', { + tableName, + count: recordIds.length, + fields: table.getFields(), + }) ); } }.bind(this) @@ -3403,11 +3484,27 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor extraSeedRecords: ReadonlyArray = [], beforeImageRecords: ReadonlyArray = [] ): Promise> { - const changedFieldIds = record - .fields() - .entries() - .map((entry) => entry.fieldId); - const expandedChangedFieldIds = this.expandComputedSeedFieldIds(table, changedFieldIds); + const changedFieldIdMap = new Map(); + for (const entry of record.fields().entries()) { + changedFieldIdMap.set(entry.fieldId.toString(), entry.fieldId); + } + // For inserts, include ALL table fields as "changed" so formulas that + // depend on fields not explicitly provided (which have null values) are + // still computed — matching the batch insert path. Without this, a record + // created with zero field values never computes any referenced formula. + if (changeType === 'insert') { + for (const field of table.getFields()) { + if (field.type().equals(core.FieldType.link())) { + continue; + } + const fieldId = field.id(); + changedFieldIdMap.set(fieldId.toString(), fieldId); + } + } + const changedFieldIds = [...changedFieldIdMap.values()]; + const expandedChangedFieldIds = this.expandComputedSeedFieldIds(table, changedFieldIds, { + includeZeroReferenceFormulas: changeType === 'insert', + }); // If no changed fields, nothing to compute if (expandedChangedFieldIds.length === 0) { @@ -3465,7 +3562,8 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor } const plan = planResult.value; - if (plan.steps.length > 0) { + // Edge-only plans (delete/orphan propagation) are executable work. + if (plan.steps.length > 0 || plan.edges.length > 0) { const executeResult = await withRepositoryTraceSpan( context, 'runComputedUpdate.execute', @@ -3590,7 +3688,9 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor } } - const changedFieldIds = this.expandComputedSeedFieldIds(table, [...fieldIds.values()]); + const changedFieldIds = this.expandComputedSeedFieldIds(table, [...fieldIds.values()], { + includeZeroReferenceFormulas: changeType === 'insert', + }); // If no changed fields, nothing to compute if (changedFieldIds.length === 0) { @@ -3636,7 +3736,8 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor } const plan = planResult.value; - if (plan.steps.length > 0) { + // Edge-only plans (delete/orphan propagation) are executable work. + if (plan.steps.length > 0 || plan.edges.length > 0) { const executeResult = await this.computedUpdateStrategy.execute( this.computedFieldUpdater, plan, @@ -3776,7 +3877,8 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor } const plan = planResult.value; - if (plan.steps.length > 0) { + // Edge-only plans (delete/orphan propagation) are executable work. + if (plan.steps.length > 0 || plan.edges.length > 0) { const executeResult = await this.computedUpdateStrategy.execute( this.computedFieldUpdater, plan, @@ -3892,9 +3994,10 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor private expandComputedSeedFieldIds( table: core.Table, - changedFieldIds: ReadonlyArray + changedFieldIds: ReadonlyArray, + options?: { includeZeroReferenceFormulas?: boolean } ): core.FieldId[] { - if (changedFieldIds.length === 0) { + if (changedFieldIds.length === 0 && !options?.includeZeroReferenceFormulas) { return []; } @@ -3919,6 +4022,17 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor dependsOnChangedField = refsResult.value.some((depId) => changedSet.has(depId.toString()) ); + // Formulas without field references (RECORD_ID(), AUTO_NUMBER(), + // literals) depend only on the row existing. They never match a + // changed-field set, so insert seeding must include them explicitly + // or new records keep a null formula cell forever (T6520). + if ( + !dependsOnChangedField && + options?.includeZeroReferenceFormulas && + refsResult.value.length === 0 + ) { + dependsOnChangedField = true; + } } } @@ -3998,7 +4112,8 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor } const plan = planResult.value; - if (plan.steps.length > 0) { + // Edge-only plans (delete/orphan propagation) are executable work. + if (plan.steps.length > 0 || plan.edges.length > 0) { const executeResult = await this.computedUpdateStrategy.execute( this.computedFieldUpdater, plan, @@ -4611,20 +4726,6 @@ const acquireLinkedRecordLocks = async ( * @param constraints - Array of exclusivity constraints to validate * @returns Ok if all constraints pass, Err with validation error if any fail */ -const i18nOrFallback = ( - t: core.IExecutionContext['$t'], - key: Parameters>[0], - fallback: string, - options?: Record -): string => { - if (!t) return fallback; - try { - return t(key, options); - } catch { - return fallback; - } -}; - const validateLinkExclusivityConstraints = async ( context: core.IExecutionContext, db: Kysely, @@ -4742,16 +4843,13 @@ const validateLinkExclusivityConstraints = async ( if (conflictingRecords.length > 0) { const firstConstraint = group.constraints[0]; const conflictingIds = conflictingRecords.map((r) => r.record_id as string); - const message = i18nOrFallback( - context.$t, - tableI18nKeys.validation.link.one_many_duplicate, - 'Cannot link record(s): already linked to another record. In one-to-many relationships, each record can only belong to one parent.', - undefined - ); + const message = + 'Cannot link record(s): already linked to another record. In one-to-many relationships, each record can only belong to one parent.'; return err( domainError.validation({ message, code: 'validation.link.one_many_duplicate', + localization: { i18nKey: sdkErrorI18nKeys.custom.linkOneManyDuplicate }, details: { fieldId: firstConstraint.fieldId.toString(), conflictingRecordIds: conflictingIds, @@ -4784,16 +4882,13 @@ const validateLinkExclusivityConstraints = async ( if (conflictingRecords.length > 0) { const firstConstraint = group.constraints[0]; const conflictingIds = conflictingRecords.map((r) => r.foreign_id as string); - const message = i18nOrFallback( - context.$t, - tableI18nKeys.validation.link.one_many_duplicate, - 'Cannot link record(s): already linked to another record. In one-to-many relationships, each record can only belong to one parent.', - undefined - ); + const message = + 'Cannot link record(s): already linked to another record. In one-to-many relationships, each record can only belong to one parent.'; return err( domainError.validation({ message, code: 'validation.link.one_many_duplicate', + localization: { i18nKey: sdkErrorI18nKeys.custom.linkOneManyDuplicate }, details: { fieldId: firstConstraint.fieldId.toString(), conflictingRecordIds: conflictingIds, @@ -4855,17 +4950,14 @@ const validateInsertExclusivityConstraints = async ( for (const foreignRecordId of constraint.linkedForeignRecordIds) { const existingSourceId = seenForeignRecordIds.get(foreignRecordId); if (existingSourceId && existingSourceId !== constraint.sourceRecordId) { - const message = i18nOrFallback( - context.$t, - tableI18nKeys.validation.link.batch_duplicate, - 'Cannot link record(s): already linked by another record in the same batch. In one-to-many relationships, each record can only belong to one parent.', - undefined - ); + const message = + 'Cannot link record(s): already linked by another record in the same batch. In one-to-many relationships, each record can only belong to one parent.'; // Two different source records trying to link the same foreign record return err( domainError.validation({ message, code: 'validation.link.batch_duplicate', + localization: { i18nKey: sdkErrorI18nKeys.custom.linkBatchDuplicate }, details: { fieldId: fieldIdStr, foreignRecordId, @@ -4975,17 +5067,14 @@ const validateInsertExclusivityConstraints = async ( if (conflictingRecords.length > 0) { const conflictingIds = conflictingRecords.map((r) => r.record_id as string); - const message = i18nOrFallback( - context.$t, - tableI18nKeys.validation.link.one_many_duplicate, - 'Cannot link record(s): already linked to another record. In one-to-many relationships, each record can only belong to one parent.', - undefined - ); + const message = + 'Cannot link record(s): already linked to another record. In one-to-many relationships, each record can only belong to one parent.'; const firstConstraint = group.constraints[0]; return err( domainError.validation({ message, code: 'validation.link.one_many_duplicate', + localization: { i18nKey: sdkErrorI18nKeys.custom.linkOneManyDuplicate }, details: { fieldId: firstConstraint.fieldId.toString(), conflictingRecordIds: conflictingIds, @@ -5012,17 +5101,14 @@ const validateInsertExclusivityConstraints = async ( if (conflictingRecords.length > 0) { const conflictingIds = conflictingRecords.map((r) => r.foreign_id as string); - const message = i18nOrFallback( - context.$t, - tableI18nKeys.validation.link.one_many_duplicate, - 'Cannot link record(s): already linked to another record. In one-to-many relationships, each record can only belong to one parent.', - undefined - ); + const message = + 'Cannot link record(s): already linked to another record. In one-to-many relationships, each record can only belong to one parent.'; const firstConstraint = group.constraints[0]; return err( domainError.validation({ message, code: 'validation.link.one_many_duplicate', + localization: { i18nKey: sdkErrorI18nKeys.custom.linkOneManyDuplicate }, details: { fieldId: firstConstraint.fieldId.toString(), conflictingRecordIds: conflictingIds, diff --git a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.update.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.update.spec.ts index 416c4c4bd3..8f5df1ac53 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.update.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.update.spec.ts @@ -1957,7 +1957,7 @@ describe('PostgresTableRecordRepository.updateOne', () => { }); // ============================================================================= -// Tests: hybrid/async mode skips planStage in transaction +// Tests: hybrid/async computed update routing // ============================================================================= const createHybridRepository = ( @@ -2228,13 +2228,14 @@ describe('PostgresTableRecordRepository hybrid/async computed update', () => { vi.useRealTimers(); }); - it('skips planStage and enqueues seed task directly in hybrid mode for updateManyStream', async () => { + it('plans and executes hybrid policy for updateManyStream when work is not deferred', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); const baseId = BaseId.create(BASE_ID)._unsafeUnwrap(); const tableId = TableId.create(TABLE_ID)._unsafeUnwrap(); const textFieldId = FieldId.create(NAME_FIELD_ID)._unsafeUnwrap(); + const computedFieldId = FieldId.create(`fld${'n'.repeat(16)}`)._unsafeUnwrap(); const recordIdA = RecordId.create(RECORD_ID)._unsafeUnwrap(); const actorId = ActorId.create(ACTOR_ID)._unsafeUnwrap(); @@ -2249,6 +2250,14 @@ describe('PostgresTableRecordRepository hybrid/async computed update', () => { .withName(FieldName.create('Name')._unsafeUnwrap()) .primary() .done(); + builder + .field() + .formula() + .withId(computedFieldId) + .withName(FieldName.create('Computed Name')._unsafeUnwrap()) + .withExpression(FormulaExpression.create(`{${textFieldId.toString()}} & ""`)._unsafeUnwrap()) + .withDependencies([textFieldId]) + .done(); builder.view().defaultGrid().done(); const table = builder.build()._unsafeUnwrap(); @@ -2257,13 +2266,33 @@ describe('PostgresTableRecordRepository hybrid/async computed update', () => { ._unsafeUnwrap() .setDbFieldName(DbFieldName.rehydrate('col_name')._unsafeUnwrap()) ._unsafeUnwrap(); + table + .getField((field) => field.id().equals(computedFieldId)) + ._unsafeUnwrap() + .setDbFieldName(DbFieldName.rehydrate('col_computed_name')._unsafeUnwrap()) + ._unsafeUnwrap(); const updateResult = table .updateRecord(recordIdA, new Map([[NAME_FIELD_ID, 'Alice']])) ._unsafeUnwrap(); - const planStageSpy = vi.fn(); + const planStageSpy = vi.fn().mockResolvedValue( + ok({ + baseId: table.baseId(), + seedTableId: table.id(), + seedRecordIds: [recordIdA], + extraSeedRecords: [], + beforeImageRecords: [], + changedFieldIds: [textFieldId], + changeType: 'update' as const, + steps: [{ tableId, fieldIds: [computedFieldId], level: 0 }], + edges: [], + estimatedComplexity: 1, + sameTableBatches: [], + }) + ); const enqueueSeedTaskSpy = vi.fn().mockResolvedValue(ok({ taskId: 'seed-1', merged: false })); + const executeSpy = vi.fn().mockResolvedValue(ok({ changesByStep: [] })); const scheduleDispatchSpy = vi.fn(); const computedUpdatePlanner = { @@ -2281,7 +2310,7 @@ describe('PostgresTableRecordRepository hybrid/async computed update', () => { const computedUpdateStrategy = { mode: 'hybrid' as const, name: 'hybrid', - execute: async () => ok(undefined), + execute: executeSpy, scheduleDispatch: scheduleDispatchSpy, }; @@ -2309,20 +2338,87 @@ describe('PostgresTableRecordRepository hybrid/async computed update', () => { const result = await repo.updateManyStream({ actorId }, table, batches()); expect(result.isOk()).toBe(true); - // planStage must NOT be called in hybrid mode - expect(planStageSpy).not.toHaveBeenCalled(); + expect(planStageSpy).toHaveBeenCalledTimes(1); + expect(executeSpy).toHaveBeenCalledTimes(1); + expect(enqueueSeedTaskSpy).not.toHaveBeenCalled(); + expect(scheduleDispatchSpy).not.toHaveBeenCalled(); - // enqueueSeedTask must be called with the seed data - expect(enqueueSeedTaskSpy).toHaveBeenCalledTimes(1); - const seedTask = enqueueSeedTaskSpy.mock.calls[0][0]; - expect(seedTask.seedTableId).toBe(tableId.toString()); - expect(seedTask.seedRecordIds).toContain(recordIdA.toString()); - expect(seedTask.changeType).toBe('update'); + vi.useRealTimers(); + }); - // scheduleDispatch must be called - expect(scheduleDispatchSpy).toHaveBeenCalledTimes(1); + it('keeps multi-record hybrid batches on the plan-free outbox path', async () => { + const baseId = BaseId.create(BASE_ID)._unsafeUnwrap(); + const tableId = TableId.create(TABLE_ID)._unsafeUnwrap(); + const textFieldId = FieldId.create(NAME_FIELD_ID)._unsafeUnwrap(); + const recordIdA = RecordId.create(RECORD_ID)._unsafeUnwrap(); + const recordIdB = RecordId.create(`rec${'b'.repeat(16)}`)._unsafeUnwrap(); + const actorId = ActorId.create(ACTOR_ID)._unsafeUnwrap(); - vi.useRealTimers(); + const builder = Table.builder() + .withId(tableId) + .withBaseId(baseId) + .withName(TableName.create('HybridMultiRecordTable')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(textFieldId) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + builder.view().defaultGrid().done(); + const table = builder.build()._unsafeUnwrap(); + + const planStageSpy = vi.fn(); + const enqueueSeedTaskSpy = vi + .fn() + .mockResolvedValue(ok({ taskId: 'seed-many', merged: false })); + const scheduleDispatchSpy = vi.fn(); + const computedUpdatePlanner = { + plan: async () => ok({ steps: [] }), + planStage: planStageSpy, + resolveBeforeImageRequirements: async () => + ok({ needsBeforeImage: false, requiredFieldIds: [] }), + } as unknown as ComputedUpdatePlanner; + const computedUpdateOutbox = { + ...createNoopOutbox(), + enqueueSeedTask: enqueueSeedTaskSpy, + }; + const repo = createHybridRepository(createRecordingDb().db, table, { + computedUpdatePlanner, + computedUpdateOutbox, + computedUpdateStrategy: { + mode: 'hybrid', + name: 'hybrid', + execute: vi.fn(), + scheduleDispatch: scheduleDispatchSpy, + }, + }); + + const result = await ( + repo as unknown as { + runComputedUpdateManyByIds( + context: { actorId: ActorId }, + table: Table, + recordIds: ReadonlyArray, + impact: { + valueFieldIds: ReadonlyArray; + linkFieldIds: ReadonlyArray; + } + ): Promise<{ isOk(): boolean }>; + } + ).runComputedUpdateManyByIds({ actorId }, table, [recordIdA, recordIdB], { + valueFieldIds: [textFieldId], + linkFieldIds: [], + }); + + expect(result.isOk()).toBe(true); + expect(planStageSpy).not.toHaveBeenCalled(); + expect(enqueueSeedTaskSpy).toHaveBeenCalledTimes(1); + expect(enqueueSeedTaskSpy.mock.calls[0][0].seedRecordIds).toEqual([ + recordIdA.toString(), + recordIdB.toString(), + ]); + expect(scheduleDispatchSpy).toHaveBeenCalledTimes(1); }); it('still calls planStage in sync mode for updateManyStream', async () => { @@ -2570,7 +2666,7 @@ describe('PostgresTableRecordRepository hybrid/async computed update', () => { vi.useRealTimers(); }); - it('does not call planStage in hybrid mode for updateMany (non-computed fields skip early)', async () => { + it('plans hybrid updateMany and exits when there are no computed steps', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); @@ -2609,7 +2705,21 @@ describe('PostgresTableRecordRepository hybrid/async computed update', () => { value: 'pending', })._unsafeUnwrap(); - const planStageSpy = vi.fn(); + const planStageSpy = vi.fn().mockResolvedValue( + ok({ + baseId: table.baseId(), + seedTableId: table.id(), + seedRecordIds: [recordId], + extraSeedRecords: [], + beforeImageRecords: [], + changedFieldIds: [textFieldId], + changeType: 'update' as const, + steps: [], + edges: [], + estimatedComplexity: 0, + sameTableBatches: [], + }) + ); const enqueueSeedTaskSpy = vi.fn().mockResolvedValue(ok({ taskId: 'seed-2', merged: false })); const computedUpdatePlanner = { @@ -2647,9 +2757,8 @@ describe('PostgresTableRecordRepository hybrid/async computed update', () => { const result = await repo.updateMany({ actorId }, table, filterSpec, mutateSpec); expect(result.isOk()).toBe(true); - // planStage must NOT be called in hybrid mode — even if no computed fields - // exist, the code path should branch on strategy.mode before calling planStage - expect(planStageSpy).not.toHaveBeenCalled(); + expect(planStageSpy).toHaveBeenCalledTimes(1); + expect(enqueueSeedTaskSpy).not.toHaveBeenCalled(); vi.useRealTimers(); }); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/repository/RecordSearchWhereBuilder.pglite.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/repository/RecordSearchWhereBuilder.pglite.spec.ts index 31704fbe7f..a59cae3b7c 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/repository/RecordSearchWhereBuilder.pglite.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/repository/RecordSearchWhereBuilder.pglite.spec.ts @@ -1,4 +1,5 @@ import { PGlite } from '@electric-sql/pglite'; +import { renderSearchTextProjectionSql } from '@teable/v2-adapter-table-query-ops-postgres'; import { BaseId, DbFieldName, @@ -10,6 +11,7 @@ import { TableId, TableName, type IRecordSearchAccessPath, + type SearchFieldTextProjection, UserMultiplicity, } from '@teable/v2-core'; import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; @@ -90,6 +92,8 @@ type SearchFixture = { tags: FieldId; due: FieldId; checkbox: FieldId; + notes: FieldId; + amount: FieldId; }; }; @@ -199,10 +203,14 @@ const setupSearchFixture = async ({ db, createdSchemas, seed, + withExtras = false, }: { db: Kysely; createdSchemas: string[]; seed: string; + // Adds a longText + number field so parity tests can cover the multiline and + // rounded_number projections without disturbing the base fixture shape. + withExtras?: boolean; }): Promise => { const baseId = BaseId.create(createId('bse', seed))._unsafeUnwrap(); const tableId = TableId.create(createId('tbl', seed))._unsafeUnwrap(); @@ -212,6 +220,8 @@ const setupSearchFixture = async ({ const tagsFieldId = FieldId.create(createId('fld', `t-${seed}`))._unsafeUnwrap(); const dueFieldId = FieldId.create(createId('fld', `d-${seed}`))._unsafeUnwrap(); const checkboxFieldId = FieldId.create(createId('fld', `b-${seed}`))._unsafeUnwrap(); + const notesFieldId = FieldId.create(createId('fld', `l-${seed}`))._unsafeUnwrap(); + const amountFieldId = FieldId.create(createId('fld', `a-${seed}`))._unsafeUnwrap(); const alphaOption = SelectOption.create({ name: 'Alpha', color: 'blue' })._unsafeUnwrap(); const betaOption = SelectOption.create({ name: 'Beta', color: 'green' })._unsafeUnwrap(); @@ -262,6 +272,20 @@ const setupSearchFixture = async ({ .withId(checkboxFieldId) .withName(FieldName.create('Checkbox')._unsafeUnwrap()) .done(); + if (withExtras) { + builder + .field() + .longText() + .withId(notesFieldId) + .withName(FieldName.create('Notes')._unsafeUnwrap()) + .done(); + builder + .field() + .number() + .withId(amountFieldId) + .withName(FieldName.create('Amount')._unsafeUnwrap()) + .done(); + } builder.view().defaultGrid().done(); const table = builder.build()._unsafeUnwrap(); @@ -295,6 +319,18 @@ const setupSearchFixture = async ({ ._unsafeUnwrap() .setDbFieldName(DbFieldName.rehydrate('col_checkbox')._unsafeUnwrap()) ._unsafeUnwrap(); + if (withExtras) { + table + .getField((field) => field.id().equals(notesFieldId)) + ._unsafeUnwrap() + .setDbFieldName(DbFieldName.rehydrate('col_notes')._unsafeUnwrap()) + ._unsafeUnwrap(); + table + .getField((field) => field.id().equals(amountFieldId)) + ._unsafeUnwrap() + .setDbFieldName(DbFieldName.rehydrate('col_amount')._unsafeUnwrap()) + ._unsafeUnwrap(); + } const schemaName = baseId.toString(); const tableName = tableId.toString(); @@ -311,7 +347,9 @@ const setupSearchFixture = async ({ col_collaborators jsonb, col_tags jsonb, col_due timestamp with time zone, - col_checkbox boolean + col_checkbox boolean, + col_notes text, + col_amount double precision ) `.execute(db); @@ -319,7 +357,7 @@ const setupSearchFixture = async ({ const bravoRecordId = createId('rec', `bravo-${seed}`); await sql` - INSERT INTO ${sql.table(fullTableName)} (__id, __auto_number, col_name, col_owner, col_collaborators, col_tags, col_due, col_checkbox) + INSERT INTO ${sql.table(fullTableName)} (__id, __auto_number, col_name, col_owner, col_collaborators, col_tags, col_due, col_checkbox, col_notes, col_amount) VALUES ( ${alphaRecordId}, 1, @@ -328,12 +366,14 @@ const setupSearchFixture = async ({ ${JSON.stringify([{ title: 'Alice Visible', name: 'alice@example.com', id: 'usr_a' }])}::jsonb, ${JSON.stringify(['Alpha', 'Beta'])}::jsonb, ${'2026-02-24T00:00:00.000Z'}::timestamptz, - ${true} + ${true}, + ${'shipment foo\nbar\tbaz line'}, + ${1.5} ) `.execute(db); await sql` - INSERT INTO ${sql.table(fullTableName)} (__id, __auto_number, col_name, col_owner, col_collaborators, col_tags, col_due, col_checkbox) + INSERT INTO ${sql.table(fullTableName)} (__id, __auto_number, col_name, col_owner, col_collaborators, col_tags, col_due, col_checkbox, col_notes, col_amount) VALUES ( ${bravoRecordId}, 2, @@ -342,7 +382,9 @@ const setupSearchFixture = async ({ ${JSON.stringify([{ title: 'Team Visible', name: 'team-hidden@example.com', id: 'usr_b' }])}::jsonb, ${JSON.stringify(['Gamma', 'Delta'])}::jsonb, ${'2026-02-25T00:00:00.000Z'}::timestamptz, - ${false} + ${false}, + ${'plain single line'}, + ${22} ) `.execute(db); @@ -360,6 +402,8 @@ const setupSearchFixture = async ({ tags: tagsFieldId, due: dueFieldId, checkbox: checkboxFieldId, + notes: notesFieldId, + amount: amountFieldId, }, }; }; @@ -546,9 +590,12 @@ describe('RecordSearchWhereBuilder (pglite)', () => { ).resolves.toEqual([fixture.recordIds.alpha]); }); - it('matches date fields in global visible-row search', async () => { + it('skips date fields in global visible-row search, matching hide-not-match semantics', async () => { const fixture = await setupSearchFixture({ db, createdSchemas, seed: 'global-date' }); + // All-field search no longer matches by date (aligned with + // supportsHideNotMatchField); dates stay searchable when addressed + // explicitly by field key. await expect( findMatchingRecordIds({ db, @@ -556,6 +603,15 @@ describe('RecordSearchWhereBuilder (pglite)', () => { fullTableName: fixture.fullTableName, search: RecordSearch.fromTuple(['2026-02-24', '', true]), }) + ).resolves.toEqual([]); + + await expect( + findMatchingRecordIds({ + db, + table: fixture.table, + fullTableName: fixture.fullTableName, + search: RecordSearch.fromTuple(['2026-02-24', fixture.fieldIds.due.toString(), true]), + }) ).resolves.toEqual([fixture.recordIds.alpha]); }); @@ -574,7 +630,7 @@ describe('RecordSearchWhereBuilder (pglite)', () => { expect(compiled.sql.toLowerCase()).not.toContain('to_char('); }); - it('compiles multiple-select searches to a text-cast ILIKE (gin_trgm-sargable) instead of a jsonb_array_elements subquery', async () => { + it('compiles multiple-select searches to the joined-list projection instead of a jsonb_array_elements subquery', async () => { const fixture = await setupSearchFixture({ db, createdSchemas, seed: 'multi-select-sql' }); const compiled = compileSearchQuery({ @@ -585,7 +641,11 @@ describe('RecordSearchWhereBuilder (pglite)', () => { }); const lower = compiled.sql.toLowerCase(); - expect(lower).toContain('::text ilike'); + // The plain_list projection matches the `a, b` cell text and is the same + // expression the generated search document stores, so the document + // prefilter stays a superset of this predicate. + expect(lower).toContain(`btrim(replace(btrim(`); + expect(lower).toContain(' ilike '); expect(lower).not.toContain('jsonb_array_elements'); }); @@ -746,6 +806,214 @@ describe('RecordSearchWhereBuilder (pglite)', () => { expect(plan.usedAccessPath).toBe('default'); }); + // Builds the generated document with the SAME renderer the ops executor + // uses for real DDL, so these parity tests break whenever the DDL-side and + // query-side projections drift apart. + const addGeneratedSearchDocument = async ( + fixture: SearchFixture, + parts: ReadonlyArray<{ column: string; projection: SearchFieldTextProjection }> + ) => { + const expression = `lower(${parts + .map( + (part) => + `coalesce(${renderSearchTextProjectionSql(`"${part.column}"`, part.projection)}, '')` + ) + .join(` || E'\\n' || `)})`; + const [schemaName, tableName] = fixture.fullTableName.split('.'); + await sql + .raw( + `ALTER TABLE "${schemaName}"."${tableName}" ADD COLUMN __tqops_search_document text GENERATED ALWAYS AS (${expression}) STORED` + ) + .execute(db); + }; + + const expectGeneratedTextParity = async ( + fixture: SearchFixture, + search: RecordSearch, + accessPath: IRecordSearchAccessPath, + expectedIds: readonly string[] + ) => { + const plan = buildRecordSearchWherePlan( + fixture.table, + { search }, + { tableAlias: 't', searchAccessPath: accessPath } + )._unsafeUnwrap(); + expect(plan.usedAccessPath).toBe('generated_text'); + + const optimized = await findMatchingRecordIds({ + db, + table: fixture.table, + fullTableName: fixture.fullTableName, + search, + searchAccessPath: accessPath, + }); + const legacy = await findMatchingRecordIds({ + db, + table: fixture.table, + fullTableName: fixture.fullTableName, + search, + }); + expect(optimized).toEqual(legacy); + expect(optimized).toEqual(expectedIds); + }; + + it('keeps longText line-break matches when the generated document prefilter is active', async () => { + const fixture = await setupSearchFixture({ + db, + createdSchemas, + seed: 'multiline-doc', + withExtras: true, + }); + await addGeneratedSearchDocument(fixture, [ + { column: 'col_notes', projection: { kind: 'multiline' } }, + ]); + const accessPath: IRecordSearchAccessPath = { + kind: 'generated_text', + generatedColumnName: '__tqops_search_document', + provider: 'pg_trgm', + searchScope: 'selected_fields', + coveredFieldIds: [fixture.fieldIds.notes], + }; + + // 'foo bar' spans a newline and 'bar baz' spans a tab in the stored cell. + // The oracle normalizes both to spaces; the document must do the same or + // the prefilter drops the row. + await expectGeneratedTextParity( + fixture, + RecordSearch.fromTuple(['foo bar', fixture.fieldIds.notes.toString(), true]), + accessPath, + [fixture.recordIds.alpha] + ); + await expectGeneratedTextParity( + fixture, + RecordSearch.fromTuple(['bar baz', fixture.fieldIds.notes.toString(), true]), + accessPath, + [fixture.recordIds.alpha] + ); + }); + + it('keeps cross-element structured title matches through the document prefilter', async () => { + const fixture = await setupSearchFixture({ db, createdSchemas, seed: 'cross-element' }); + await sql` + UPDATE ${sql.table(fixture.fullTableName)} + SET col_collaborators = ${JSON.stringify([ + { title: 'Alice', id: 'usr_a' }, + { title: 'Bob', id: 'usr_b' }, + ])}::jsonb + WHERE __id = ${fixture.recordIds.alpha} + `.execute(db); + await addGeneratedSearchDocument(fixture, [ + { column: 'col_collaborators', projection: { kind: 'structured_title_list' } }, + ]); + const accessPath: IRecordSearchAccessPath = { + kind: 'generated_text', + generatedColumnName: '__tqops_search_document', + provider: 'pg_trgm', + searchScope: 'selected_fields', + coveredFieldIds: [fixture.fieldIds.collaborators], + }; + + // 'alice, bob' only matches when the projection joins titles with ', ' — + // the raw jsonb text has quotes between elements and would drop the row. + await expectGeneratedTextParity( + fixture, + RecordSearch.fromTuple(['alice, bob', fixture.fieldIds.collaborators.toString(), true]), + accessPath, + [fixture.recordIds.alpha] + ); + }); + + it('keeps quoted structured titles matchable through the document prefilter', async () => { + const fixture = await setupSearchFixture({ db, createdSchemas, seed: 'quoted-title' }); + await sql` + UPDATE ${sql.table(fixture.fullTableName)} + SET col_owner = ${JSON.stringify({ title: 'A"B quoted', id: 'usr_q' })}::jsonb + WHERE __id = ${fixture.recordIds.alpha} + `.execute(db); + await addGeneratedSearchDocument(fixture, [ + { column: 'col_owner', projection: { kind: 'structured_title' } }, + ]); + const accessPath: IRecordSearchAccessPath = { + kind: 'generated_text', + generatedColumnName: '__tqops_search_document', + provider: 'pg_trgm', + searchScope: 'selected_fields', + coveredFieldIds: [fixture.fieldIds.owner], + }; + + await expectGeneratedTextParity( + fixture, + RecordSearch.fromTuple(['a"b', fixture.fieldIds.owner.toString(), true]), + accessPath, + [fixture.recordIds.alpha] + ); + }); + + it('keeps rounded number matches through the document prefilter', async () => { + const fixture = await setupSearchFixture({ + db, + createdSchemas, + seed: 'rounded-number', + withExtras: true, + }); + await addGeneratedSearchDocument(fixture, [ + { column: 'col_amount', projection: { kind: 'rounded_number', precision: 2 } }, + ]); + const accessPath: IRecordSearchAccessPath = { + kind: 'generated_text', + generatedColumnName: '__tqops_search_document', + provider: 'pg_trgm', + searchScope: 'selected_fields', + coveredFieldIds: [fixture.fieldIds.amount], + }; + + // The oracle matches the ROUND(col, precision) rendering ('1.50'), so the + // document must store the same rendered text, not the raw '1.5'. + await expectGeneratedTextParity( + fixture, + RecordSearch.fromTuple(['1.50', fixture.fieldIds.amount.toString(), true]), + accessPath, + [fixture.recordIds.alpha] + ); + }); + + it('uses the generated document for all-field search on tables with date and checkbox fields', async () => { + const fixture = await setupSearchFixture({ db, createdSchemas, seed: 'mixed-eligible' }); + await addGeneratedSearchDocument(fixture, [ + { column: 'col_name', projection: { kind: 'plain' } }, + { column: 'col_owner', projection: { kind: 'structured_title' } }, + { column: 'col_collaborators', projection: { kind: 'structured_title_list' } }, + { column: 'col_tags', projection: { kind: 'plain_list' } }, + ]); + const accessPath: IRecordSearchAccessPath = { + kind: 'generated_text', + generatedColumnName: '__tqops_search_document', + provider: 'pg_trgm', + searchScope: 'all_fields', + coveredFieldIds: [ + fixture.fieldIds.name, + fixture.fieldIds.owner, + fixture.fieldIds.collaborators, + fixture.fieldIds.tags, + ], + }; + + // Date and checkbox fields produce no all-field predicate, so they no + // longer disqualify the indexed document path. + await expectGeneratedTextParity( + fixture, + RecordSearch.fromTuple(['Alpha', '', true]), + accessPath, + [fixture.recordIds.alpha] + ); + await expectGeneratedTextParity( + fixture, + RecordSearch.fromTuple(['Team Visible', '', true]), + accessPath, + [fixture.recordIds.bravo] + ); + }); + it('compiles field-scoped search to generated tsvector when explicitly requested and covered', async () => { const fixture = await setupSearchFixture({ db, createdSchemas, seed: 'fts-sql' }); const search = RecordSearch.fromTuple(['Alpha', fixture.fieldIds.name.toString(), true]); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/repository/RecordSearchWhereBuilder.ts b/packages/v2/adapter-table-repository-postgres/src/record/repository/RecordSearchWhereBuilder.ts index f5d5dca70d..3ff2aa00ae 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/repository/RecordSearchWhereBuilder.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/repository/RecordSearchWhereBuilder.ts @@ -1,5 +1,4 @@ import { - CellValueType, type ConditionalLookupField, type ConditionalRollupField, type CreatedTimeField, @@ -10,18 +9,22 @@ import { FieldType, FieldValueTypeVisitor, type FormulaField, + isSearchFieldTextProjection, type LastModifiedTimeField, type LookupField, - type NumberField, - type NumberFormatting, type RecordQuerySearch, type IRecordSearchAccessPath, + resolveSearchFieldTextShape, type RollupField, + type SearchFieldTextProjection, + type SearchFieldTextShape, type Table, } from '@teable/v2-core'; import { sql, type Expression, type SqlBool } from 'kysely'; import { ok, safeTry } from 'neverthrow'; import type { Result } from 'neverthrow'; + +import { buildStoredFieldValueExpression } from '../query-builder/stored/storedFieldValueExpression'; import { getDateSearchRange } from './dateSearchRange'; const fieldValueTypeVisitor = new FieldValueTypeVisitor(); @@ -29,10 +32,6 @@ const escapeLikeWildcards = (input: string): string => { return input.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_'); }; -const escapePostgresRegex = (input: string): string => { - return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -}; - const isPostgresIdentifier = (input: string): boolean => { return /^[a-z_]\w*$/i.test(input); }; @@ -42,57 +41,62 @@ type RecordSearchWhereBuilderOptions = { readonly searchAccessPath?: IRecordSearchAccessPath; }; +// Multi-value cells are physically jsonb; a direct cast plus text-level array +// wrapping keeps this expression identical to the generated document DDL, +// which must stay immutable — to_jsonb() and jsonb_build_array() are only +// STABLE and are rejected by generated columns. const normalizeToJsonArray = (columnRef: Expression) => sql`CASE - WHEN jsonb_typeof(to_jsonb(${columnRef})) = 'array' THEN to_jsonb(${columnRef}) - WHEN to_jsonb(${columnRef}) IS NULL THEN '[]'::jsonb - ELSE jsonb_build_array(to_jsonb(${columnRef})) + WHEN jsonb_typeof((${columnRef})::jsonb) = 'array' THEN (${columnRef})::jsonb + WHEN (${columnRef})::jsonb IS NULL THEN '[]'::jsonb + ELSE ('[' || ((${columnRef})::jsonb)::text || ']')::jsonb END`; -const buildLongTextExpression = (columnRef: Expression) => sql`REPLACE( - REPLACE(REPLACE(${columnRef}, CHR(13), ' '::text), CHR(10), ' '::text), +const buildMultilineExpression = (columnRef: Expression) => sql`REPLACE( + REPLACE(REPLACE((${columnRef})::text, CHR(13), ' '::text), CHR(10), ' '::text), CHR(9), ' '::text )`; -const buildStructuredSingleCondition = (columnRef: Expression, searchValue: string) => { - return sql`((${columnRef})::jsonb #>> '{title}') ILIKE ${`%${escapeLikeWildcards(searchValue)}%`} ESCAPE '\\'`; -}; +// jsonb renders arrays as `["a", "b"]`; strip the brackets, collapse the +// `", "` element separators, and trim the outer quotes so the projected text +// matches the `a, b` string users see in a cell. Titles containing quotes or +// backslashes keep their jsonb escaping — the same projection runs in the +// default predicate, the generated document, and the scoped rechecks, so all +// paths agree on the (slightly escaped) text they match against. +const buildJoinedJsonArrayText = (arrayExpr: Expression) => + sql`btrim(replace(btrim((${arrayExpr})::text, '[]'), '", "', ', '), '"')`; + +const buildStructuredTitleListText = (columnRef: Expression) => + buildJoinedJsonArrayText( + sql`jsonb_path_query_array(${normalizeToJsonArray(columnRef)}, ${sql.raw(`'$[*].**."title"'`)})` + ); -const buildPlainMultipleCondition = (columnRef: Expression, searchValue: string) => { - const arrayExpr = normalizeToJsonArray(columnRef); - return sql` - EXISTS ( - SELECT 1 - FROM ( - SELECT string_agg(elem.value, ', ') AS aggregated - FROM jsonb_array_elements_text(${arrayExpr}) AS elem(value) - ) AS sub - WHERE sub.aggregated ~* ${escapePostgresRegex(searchValue)} - ) - `; +const buildSearchProjectionText = ( + columnRef: Expression, + projection: SearchFieldTextProjection +): Expression => { + switch (projection.kind) { + case 'plain': + return sql`(${columnRef})::text`; + case 'multiline': + return buildMultilineExpression(columnRef); + case 'structured_title': + return sql`((${columnRef})::jsonb #>> '{title}')`; + case 'structured_title_list': + return buildStructuredTitleListText(columnRef); + case 'plain_list': + return buildJoinedJsonArrayText(normalizeToJsonArray(columnRef)); + case 'rounded_number': + return sql`ROUND((${columnRef})::numeric, ${projection.precision})::text`; + } }; -const buildStructuredMultipleCondition = (columnRef: Expression, searchValue: string) => { - const arrayExpr = normalizeToJsonArray(columnRef); - return sql` - EXISTS ( - WITH RECURSIVE f(e) AS ( - SELECT ${arrayExpr} - UNION ALL - SELECT jsonb_array_elements(f.e) - FROM f - WHERE jsonb_typeof(f.e) = 'array' - ) - SELECT 1 - FROM ( - SELECT string_agg((e ->> 'title')::text, ', ') AS aggregated - FROM f - WHERE jsonb_typeof(e) <> 'array' - ) AS sub - WHERE sub.aggregated ~* ${escapePostgresRegex(searchValue)} - ) - `; -}; +const buildProjectionContainsCondition = ( + columnRef: Expression, + projection: SearchFieldTextProjection, + searchValue: string +) => + sql`${buildSearchProjectionText(columnRef, projection)} ILIKE ${`%${escapeLikeWildcards(searchValue)}%`} ESCAPE '\\'`; const buildNumberMultipleCondition = ( columnRef: Expression, @@ -133,73 +137,6 @@ const buildDateMultipleCondition = ( `; }; -const resolveSearchShapeSourceField = (field: Field): Field => { - if ( - field.type().equals(FieldType.lookup()) || - field.type().equals(FieldType.conditionalLookup()) - ) { - const innerField = field.type().equals(FieldType.lookup()) - ? (field as LookupField).innerField() - : (field as ConditionalLookupField).innerField(); - if (innerField.isOk()) { - return resolveSearchShapeSourceField(innerField.value); - } - } - - return field; -}; - -const isStructuredStringField = (field: Field): boolean => { - const sourceField = resolveSearchShapeSourceField(field); - return ( - sourceField.type().equals(FieldType.user()) || - sourceField.type().equals(FieldType.createdBy()) || - sourceField.type().equals(FieldType.lastModifiedBy()) || - sourceField.type().equals(FieldType.link()) || - sourceField.type().equals(FieldType.attachment()) - ); -}; - -const isLongTextField = (field: Field): boolean => { - return resolveSearchShapeSourceField(field).type().equals(FieldType.longText()); -}; - -const isMultipleSelectField = (field: Field): boolean => { - return resolveSearchShapeSourceField(field).type().equals(FieldType.multipleSelect()); -}; - -const resolveNumberFormatting = (field: Field): NumberFormatting | undefined => { - if ( - field.type().equals(FieldType.lookup()) || - field.type().equals(FieldType.conditionalLookup()) - ) { - const innerField = field.type().equals(FieldType.lookup()) - ? (field as LookupField).innerField() - : (field as ConditionalLookupField).innerField(); - return innerField.isOk() ? resolveNumberFormatting(innerField.value) : undefined; - } - - if (field.type().equals(FieldType.number())) { - return (field as NumberField).formatting(); - } - - if ( - field.type().equals(FieldType.formula()) || - field.type().equals(FieldType.rollup()) || - field.type().equals(FieldType.conditionalRollup()) - ) { - const formatting = field.type().equals(FieldType.formula()) - ? (field as FormulaField).formatting() - : field.type().equals(FieldType.rollup()) - ? (field as RollupField).formatting() - : (field as ConditionalRollupField).formatting(); - - return formatting instanceof DateTimeFormatting ? undefined : formatting; - } - - return undefined; -}; - const resolveDateTimeFormatting = (field: Field): DateTimeFormatting | undefined => { if ( field.type().equals(FieldType.lookup()) || @@ -240,10 +177,6 @@ const resolveDateTimeFormatting = (field: Field): DateTimeFormatting | undefined return undefined; }; -const resolveNumberPrecision = (field: Field): number => { - return resolveNumberFormatting(field)?.precision().toNumber() ?? 0; -}; - const resolveColumnRef = ( field: Field, tableAlias: string @@ -251,7 +184,22 @@ const resolveColumnRef = ( return field .dbFieldName() .andThen((dbFieldName) => dbFieldName.value()) - .map((dbFieldName) => sql.ref(`${tableAlias}.${dbFieldName}`) as Expression); + .andThen((dbFieldName) => buildStoredFieldValueExpression(field, tableAlias, dbFieldName)) + .map(({ expression }) => expression as Expression); +}; + +/** + * Whether a field produces a search predicate for this search. Date fields are + * excluded from all-field searches (matching hide-not-match semantics); they + * remain searchable when addressed explicitly by field key. + */ +const shapeProducesCondition = ( + shape: SearchFieldTextShape, + search: RecordQuerySearch['search'] +): boolean => { + if (shape.kind === 'none') return false; + if (shape.kind === 'date_range' && search.searchesAllFields()) return false; + return true; }; const buildFieldSearchCondition = ( @@ -260,37 +208,16 @@ const buildFieldSearchCondition = ( tableAlias: string ): Result | undefined, DomainError> => { return safeTry(function* () { - if (field.type().equals(FieldType.button())) { + const shape = yield* resolveSearchFieldTextShape(field); + if (!shapeProducesCondition(shape, search)) { return ok(undefined); } const columnRef = yield* resolveColumnRef(field, tableAlias); - const fieldValueType = yield* field.accept(fieldValueTypeVisitor); - const cellValueType = fieldValueType.cellValueType; - const isMultiple = fieldValueType.isMultipleCellValue.isMultiple(); - - if (cellValueType.equals(CellValueType.boolean()) && search.searchesAllFields()) { - return ok(undefined); - } - - if (isStructuredStringField(field)) { - return ok( - isMultiple - ? buildStructuredMultipleCondition(columnRef, search.value) - : buildStructuredSingleCondition(columnRef, search.value) - ); - } - if (cellValueType.equals(CellValueType.number())) { - const precision = resolveNumberPrecision(field); - return ok( - isMultiple - ? buildNumberMultipleCondition(columnRef, search.value, precision) - : sql`ROUND(${columnRef}::numeric, ${precision})::text ILIKE ${`%${escapeLikeWildcards(search.value)}%`} ESCAPE '\\'` - ); - } - - if (cellValueType.equals(CellValueType.dateTime())) { + if (shape.kind === 'date_range') { + const fieldValueType = yield* field.accept(fieldValueTypeVisitor); + const isMultiple = fieldValueType.isMultipleCellValue.isMultiple(); const formatting = resolveDateTimeFormatting(field); const range = getDateSearchRange(search.value, formatting); if (!range) { @@ -304,32 +231,41 @@ const buildFieldSearchCondition = ( ); } - if (cellValueType.equals(CellValueType.boolean())) { - return ok(undefined); + if (shape.kind === 'rounded_number_list') { + return ok(buildNumberMultipleCondition(columnRef, search.value, shape.precision)); } - if (isMultiple) { - if (isMultipleSelectField(field)) { - // multipleSelect stores a plain string[] of option names. Match the whole cell as text so - // the predicate is sargable against the gin_trgm index (built on the same "
"::text - // expression) instead of a jsonb_array_elements + regex subquery that cannot use it. Trades - // negligible precision (JSON brackets/quotes become matchable) for index usage. - return ok( - sql`(${columnRef})::text ILIKE ${`%${escapeLikeWildcards(search.value)}%`} ESCAPE '\\'` - ); - } - return ok(buildPlainMultipleCondition(columnRef, search.value)); + if (isSearchFieldTextProjection(shape)) { + return ok(buildProjectionContainsCondition(columnRef, shape, search.value)); } - if (isLongTextField(field)) { - return ok( - sql`${buildLongTextExpression(columnRef)} ILIKE ${`%${escapeLikeWildcards(search.value)}%`} ESCAPE '\\'` - ); - } + return ok(undefined); + }); +}; - return ok( - sql`${columnRef} ILIKE ${`%${escapeLikeWildcards(search.value)}%`} ESCAPE '\\'` - ); +export type RecordSearchFieldMatch = { + readonly field: Field; + readonly condition: Expression; +}; + +export const buildRecordSearchFieldMatches = ( + table: Table, + recordSearch: RecordQuerySearch | undefined, + options?: Pick +): Result, DomainError> => { + if (!recordSearch) return ok([]); + + return safeTry(function* () { + const tableAlias = options?.tableAlias ?? 't'; + const fields = yield* recordSearch.search.resolveFields(table, { + visibleFieldIds: recordSearch.visibleFieldIds, + }); + const matches: RecordSearchFieldMatch[] = []; + for (const field of fields) { + const condition = yield* buildFieldSearchCondition(field, recordSearch.search, tableAlias); + if (condition) matches.push({ field, condition }); + } + return ok(matches); }); }; @@ -338,34 +274,14 @@ const buildSearchVectorDocumentPart = ( tableAlias: string ): Result, DomainError> => safeTry(function* () { + const shape = yield* resolveSearchFieldTextShape(field); const columnRef = yield* resolveColumnRef(field, tableAlias); - const fieldValueType = yield* field.accept(fieldValueTypeVisitor); - const isMultiple = fieldValueType.isMultipleCellValue.isMultiple(); - if (isStructuredStringField(field)) { - if (!isMultiple) { - return ok(sql`coalesce((${columnRef})::jsonb #>> '{title}', '')`); - } - const arrayExpr = normalizeToJsonArray(columnRef); - return ok(sql`coalesce(( - WITH RECURSIVE f(e) AS ( - SELECT ${arrayExpr} - UNION ALL - SELECT jsonb_array_elements(f.e) - FROM f - WHERE jsonb_typeof(f.e) = 'array' - ) - SELECT string_agg((e ->> 'title')::text, ', ') - FROM f - WHERE jsonb_typeof(e) <> 'array' - ), '')`); + if (isSearchFieldTextProjection(shape)) { + return ok(sql`coalesce(${buildSearchProjectionText(columnRef, shape)}, '')`); } - return ok( - isLongTextField(field) - ? sql`coalesce(${buildLongTextExpression(columnRef)}, '')` - : sql`coalesce((${columnRef})::text, '')` - ); + return ok(sql`coalesce((${columnRef})::text, '')`); }); const buildGeneratedTsvectorSearchCondition = ( resolvedFields: ReadonlyArray, @@ -467,32 +383,46 @@ const buildGeneratedTextSearchCondition = ( if (accessPath.provider === 'pg_trgm' && probeLength < 3) return ok(undefined); if (accessPath.provider === 'pg_bigm' && probeLength < 2) return ok(undefined); - const resolvedFieldIds = new Set(resolvedFields.map((field) => field.id().toString())); const coveredFieldIds = new Set(accessPath.coveredFieldIds.map((fieldId) => fieldId.toString())); - if ( - !coveredFieldIds.size || - resolvedFields.some((field) => !coveredFieldIds.has(field.id().toString())) - ) { - return ok(undefined); - } - if ( - recordSearch.search.searchesAllFields() && - (accessPath.searchScope !== 'all_fields' || coveredFieldIds.size !== resolvedFieldIds.size) - ) { - return ok(undefined); - } + if (!coveredFieldIds.size) return ok(undefined); - return buildDefaultSearchCondition(resolvedFields, recordSearch, tableAlias).map( - (exactCondition) => { - if (!exactCondition) return undefined; - const pattern = `%${escapeLikeWildcards(recordSearch.search.value)}%`; - const documentRef = sql.ref(`${tableAlias}.${accessPath.generatedColumnName}`); - // pg_bigm indexes LIKE only. Keeping both providers on a normalized document gives the - // runtime one predicate shape; the original field predicate below remains the result oracle. - const indexedPrefilter = sql`${documentRef} LIKE lower(${pattern}) ESCAPE '\\'`; - return sql`(${indexedPrefilter}) AND (${exactCondition})`; + return safeTry(function* () { + // The prefilter is sound as long as every field that contributes a + // predicate has its projected text inside the generated document. Fields + // that never produce a predicate for this search (checkbox/button, dates + // in an all-field search) cannot cause a miss, so they neither need + // coverage nor block the indexed path. + const conditionFields: Field[] = []; + for (const field of resolvedFields) { + const shape = yield* resolveSearchFieldTextShape(field); + if (!shapeProducesCondition(shape, recordSearch.search)) continue; + // Shapes without a document projection (scoped date search, multi-value + // numbers) cannot be prefiltered by the document; fall back entirely. + if (!isSearchFieldTextProjection(shape)) { + return ok(undefined); + } + conditionFields.push(field); } - ); + + if (!conditionFields.length) return ok(undefined); + if (conditionFields.some((field) => !coveredFieldIds.has(field.id().toString()))) { + return ok(undefined); + } + + const exactCondition = yield* buildDefaultSearchCondition( + resolvedFields, + recordSearch, + tableAlias + ); + if (!exactCondition) return ok(undefined); + + const pattern = `%${escapeLikeWildcards(recordSearch.search.value)}%`; + const documentRef = sql.ref(`${tableAlias}.${accessPath.generatedColumnName}`); + // pg_bigm indexes LIKE only. Keeping both providers on a normalized document gives the + // runtime one predicate shape; the original field predicate below remains the result oracle. + const indexedPrefilter = sql`${documentRef} LIKE lower(${pattern}) ESCAPE '\\'`; + return ok(sql`(${indexedPrefilter}) AND (${exactCondition})`); + }); }; export type RecordSearchWherePlan = { diff --git a/packages/v2/adapter-table-repository-postgres/src/record/repository/TableRecordAggregationSql.ts b/packages/v2/adapter-table-repository-postgres/src/record/repository/TableRecordAggregationSql.ts new file mode 100644 index 0000000000..cbe736f4d9 --- /dev/null +++ b/packages/v2/adapter-table-repository-postgres/src/record/repository/TableRecordAggregationSql.ts @@ -0,0 +1,155 @@ +import { + FieldValueTypeVisitor, + type Field, + type TableRecordAggregationFunction, +} from '@teable/v2-core'; +import { sql, type RawBuilder } from 'kysely'; + +const percentFunctions: ReadonlyArray = [ + 'percentEmpty', + 'percentFilled', + 'percentUnique', + 'percentChecked', + 'percentUnChecked', +]; + +const flattenedFunctions: ReadonlyArray = [ + 'unique', + 'max', + 'min', + 'sum', + 'average', + 'percentUnique', + 'earliestDate', + 'latestDate', + 'dateRangeOfDays', + 'dateRangeOfMonths', +]; + +export const buildTableRecordAggregationExpression = ( + field: Field, + columnName: string, + statisticFunc: TableRecordAggregationFunction +): RawBuilder => { + const column = sql.ref(`a.${columnName}`); + const fieldType = field.type().toString(); + const valueType = field.accept(new FieldValueTypeVisitor())._unsafeUnwrap(); + const isMultiple = valueType.isMultipleCellValue.toBoolean(); + const isUserLike = ['user', 'createdBy', 'lastModifiedBy'].includes(fieldType); + const flattenedValues = sql`jsonb_array_elements_text( + jsonb_path_query_array( + coalesce(jsonb_agg(${column}::jsonb) filter (where ${column} is not null), '[]'::jsonb), + '$[*][*]' + ) + ) as flattened(value)`; + const numericValue = sql`nullif(regexp_replace(value, '[^0-9.+-]', '', 'g'), '')::double precision`; + const denominator = sql`greatest(count(*), 1)`; + + if (isMultiple && flattenedFunctions.includes(statisticFunc)) { + switch (statisticFunc) { + case 'unique': + return sql`(select count(distinct value) from ${flattenedValues})`; + case 'max': + return sql`(select max(${numericValue}) from ${flattenedValues})`; + case 'min': + return sql`(select min(${numericValue}) from ${flattenedValues})`; + case 'sum': + return sql`(select sum(${numericValue}) from ${flattenedValues})`; + case 'average': + return sql`(select avg(${numericValue}) from ${flattenedValues})`; + case 'percentUnique': + return sql`(select count(distinct value) * 100.0 / greatest(count(*), 1) from ${flattenedValues})`; + case 'earliestDate': + return sql`(select min(value::timestamptz) from ${flattenedValues})`; + case 'latestDate': + return sql`(select max(value::timestamptz) from ${flattenedValues})`; + case 'dateRangeOfDays': + return sql`(select extract(day from (max(value::timestamptz) - min(value::timestamptz)))::integer from ${flattenedValues})`; + case 'dateRangeOfMonths': + return sql`(select ( + extract(year from age(max(value::timestamptz), min(value::timestamptz))) * 12 + + extract(month from age(max(value::timestamptz), min(value::timestamptz))) + )::integer from ${flattenedValues})`; + } + } + + switch (statisticFunc) { + case 'count': + return sql`count(*)`; + case 'empty': + return sql`count(*) - count(${column})`; + case 'filled': + return sql`count(${column})`; + case 'unique': + return isUserLike + ? sql`count(distinct (${column}::jsonb ->> 'id'))` + : sql`count(distinct ${column})`; + case 'max': + return sql`max(${column})`; + case 'min': + return sql`min(${column})`; + case 'sum': + return sql`sum(${column})`; + case 'average': + return sql`avg(${column})`; + case 'checked': + return isMultiple + ? sql`sum(case when ${column}::jsonb @> '[true]'::jsonb then 1 else 0 end)` + : sql`sum(case when ${column} = true then 1 else 0 end)`; + case 'unChecked': + return isMultiple + ? sql`sum(case when ${column} is null or not (${column}::jsonb @> '[true]'::jsonb) then 1 else 0 end)` + : sql`sum(case when ${column} = false or ${column} is null then 1 else 0 end)`; + case 'percentEmpty': + return sql`(count(*) - count(${column})) * 100.0 / ${denominator}`; + case 'percentFilled': + return sql`count(${column}) * 100.0 / ${denominator}`; + case 'percentUnique': + return isUserLike + ? sql`count(distinct (${column}::jsonb ->> 'id')) * 100.0 / ${denominator}` + : sql`count(distinct ${column}) * 100.0 / ${denominator}`; + case 'percentChecked': + return isMultiple + ? sql`sum(case when ${column}::jsonb @> '[true]'::jsonb then 1 else 0 end) * 100.0 / ${denominator}` + : sql`sum(case when ${column} = true then 1 else 0 end) * 100.0 / ${denominator}`; + case 'percentUnChecked': + return isMultiple + ? sql`sum(case when ${column} is null or not (${column}::jsonb @> '[true]'::jsonb) then 1 else 0 end) * 100.0 / ${denominator}` + : sql`sum(case when ${column} = false or ${column} is null then 1 else 0 end) * 100.0 / ${denominator}`; + case 'earliestDate': + return sql`min(${column})`; + case 'latestDate': + return sql`max(${column})`; + case 'dateRangeOfDays': + return sql`extract(day from (max(${column}) - min(${column})))::integer`; + case 'dateRangeOfMonths': + return sql`( + extract(year from age(max(${column}), min(${column}))) * 12 + + extract(month from age(max(${column}), min(${column}))) + )::integer`; + case 'totalAttachmentSize': + return sql`sum(coalesce(( + select sum((element.value ->> 'size')::integer) + from jsonb_array_elements(coalesce(${column}::jsonb, '[]'::jsonb)) as element(value) + ), 0))`; + } +}; + +export const normalizeTableRecordAggregationValue = ( + value: unknown, + statisticFunc: TableRecordAggregationFunction +): number | string | null => { + if (value == null) return percentFunctions.includes(statisticFunc) ? 0 : null; + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'bigint' || typeof value === 'number') return Number(value); + if ( + statisticFunc !== 'earliestDate' && + statisticFunc !== 'latestDate' && + typeof value === 'string' && + value.trim() !== '' && + Number.isFinite(Number(value)) + ) { + return Number(value); + } + return String(value); +}; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/repository/index.ts b/packages/v2/adapter-table-repository-postgres/src/record/repository/index.ts index 0bc874a610..11249d7a5a 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/repository/index.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/repository/index.ts @@ -4,5 +4,6 @@ export * from './PostgresRecordMutationSnapshotCaptureService'; export * from './PostgresRecordOrderCalculator'; export * from './PostgresAttachmentLookupService'; export * from './PostgresUserLookupService'; +export * from './PostgresCollaboratorDirectoryService'; export * from './OffsetStreamPaginationStrategy'; export * from './CursorStreamPaginationStrategy'; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/visitors/CellValueMutateVisitor.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/visitors/CellValueMutateVisitor.spec.ts index 6ddcac5954..2ca376ec32 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/visitors/CellValueMutateVisitor.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/visitors/CellValueMutateVisitor.spec.ts @@ -2,6 +2,7 @@ import { CellValue, FieldId, SetAttachmentValueSpec, + SetButtonValueSpec, SetLinkValueByTitleSpec, SetLinkValueSpec, SetUserValueByIdentifierSpec, @@ -197,6 +198,21 @@ const createForeignTable = (params: { }); describe('CellValueMutateVisitor', () => { + it('persists aggregate-created Button values as JSONB', () => { + const field = createField({ + fieldId: 'buttonField', + type: 'button', + dbFieldName: 'button_col', + }); + const visitor = createVisitor(field); + const spec = new SetButtonValueSpec(field.id(), CellValue.fromValidated({ count: 3 })); + + expect(visitor.visitSetButtonValue(spec).isOk()).toBe(true); + const result = visitor.build()._unsafeUnwrap(); + expect(result.setClauses.button_col).toBe(JSON.stringify({ count: 3 })); + expect(result.changedFieldIds.map(String)).toEqual([field.id().toString()]); + }); + it('returns an error when user identifiers are not pre-resolved', () => { const visitor = createVisitor(); const spec = SetUserValueByIdentifierSpec.create(mkFieldId('userField'), ['alice'], false); @@ -395,6 +411,7 @@ describe('CellValueMutateVisitor', () => { expect(normalizeSql(built.mainUpdate.sql)).toContain('LEFT JOIN "bseLegacy"."Legacy_Name" ft'); expect(normalizeSql(built.mainUpdate.sql)).toContain('"ft"."Primary_Field"'); expect(normalizeSql(built.mainUpdate.sql)).not.toContain(`"${foreignTable.id().toString()}"`); + expect(normalizeSql(built.mainUpdate.sql)).toContain('jsonb_strip_nulls'); }); it('rejects oversized multi-link title fill writes before compiling SQL', () => { diff --git a/packages/v2/adapter-table-repository-postgres/src/record/visitors/CellValueMutateVisitor.ts b/packages/v2/adapter-table-repository-postgres/src/record/visitors/CellValueMutateVisitor.ts index 8fc2818bd0..ab2600ced2 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/visitors/CellValueMutateVisitor.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/visitors/CellValueMutateVisitor.ts @@ -23,6 +23,7 @@ import type { SetUserValueSpec, SetUserValueByIdentifierSpec, Table, + SetButtonValueSpec, } from '@teable/v2-core'; import { CellValue, @@ -400,6 +401,10 @@ export class CellValueMutateVisitor implements ICellValueSpecVisitor { if (!Array.isArray(rawValue)) { return rawValue; } + // Align with v1: empty multi-select arrays are stored as null. + if (rawValue.length === 0) { + return null; + } const options = field.selectOptions(); const nameById = new Map(options.map((opt) => [opt.id().toString(), opt.name().toString()])); @@ -519,6 +524,10 @@ export class CellValueMutateVisitor implements ICellValueSpecVisitor { return ok(undefined); } + visitSetButtonValue(spec: SetButtonValueSpec): Result { + return this.addJsonValue(spec.fieldId, spec.value.toValue()); + } + visitSetUserValue(spec: SetUserValueSpec): Result { return this.addJsonValue(spec.fieldId, spec.value.toValue()); } diff --git a/packages/v2/adapter-table-repository-postgres/src/record/visitors/TableRecordConditionWhereVisitor.dateUtil.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/visitors/TableRecordConditionWhereVisitor.dateUtil.spec.ts new file mode 100644 index 0000000000..acb5687377 --- /dev/null +++ b/packages/v2/adapter-table-repository-postgres/src/record/visitors/TableRecordConditionWhereVisitor.dateUtil.spec.ts @@ -0,0 +1,75 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { DateUtil } from './TableRecordConditionWhereVisitor'; + +/** + * Regression tests for the zero-offset timezone bug (T6520). + * + * dayjs's timezone plugin stores the zone offset in a field that later code + * checks for truthiness, so instances whose current offset is exactly 0 + * (UTC, Etc/GMT) computed startOf/endOf against the host-local calendar, + * making filter date ranges host-timezone-dependent. The host timezone is + * forced to a non-UTC zone here so the failure mode is reproducible on UTC + * CI hosts too. + */ +describe('DateUtil zero-offset timezones', () => { + const originalTz = process.env.TZ; + + beforeAll(() => { + // Fixed +07:00 zone without DST — Node re-reads TZ per Date operation + process.env.TZ = 'Asia/Bangkok'; + expect(new Date().getTimezoneOffset()).toBe(-420); + }); + + afterAll(() => { + if (originalTz === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTz; + } + }); + + it.each(['UTC', 'Etc/UTC', 'Etc/GMT'])( + 'computes day boundaries on the UTC calendar for %s', + (zone) => { + const dateUtil = new DateUtil(zone); + const startOfDay = dateUtil.date().startOf('day'); + expect(startOfDay.toISOString()).toMatch(/T00:00:00\.000Z$/); + + const nextDayEnd = dateUtil.offset('day', 1, startOfDay).endOf('day'); + expect(nextDayEnd.toISOString()).toMatch(/T23:59:59\.999Z$/); + // the end bound must land one calendar day after the start, not collapse back + expect(nextDayEnd.diff(startOfDay, 'hour')).toBe(47); + } + ); + + it('keeps zone-local day boundaries for non-zero-offset timezones', () => { + const dateUtil = new DateUtil('Asia/Tokyo'); + const startOfDay = dateUtil.date().startOf('day'); + // Tokyo midnight is 15:00 UTC of the previous day + expect(startOfDay.toISOString()).toMatch(/T15:00:00\.000Z$/); + + const nextDayEnd = dateUtil.offset('day', 1, startOfDay).endOf('day'); + expect(nextDayEnd.diff(startOfDay, 'hour')).toBe(47); + }); + + it('parses explicit values onto the UTC calendar for zero-offset zones', () => { + const dateUtil = new DateUtil('UTC'); + const parsed = dateUtil.date('2026-06-10T05:30:00.000Z'); + expect(parsed.startOf('day').toISOString()).toBe('2026-06-10T00:00:00.000Z'); + expect(parsed.endOf('day').toISOString()).toBe('2026-06-10T23:59:59.999Z'); + }); + + it('restores the IANA zone when an offset crosses into daylight saving time', () => { + const dateUtil = new DateUtil('Europe/London'); + const winter = dateUtil.date('2026-03-15T12:00:00.000Z'); + + expect(winter.utcOffset()).toBe(0); + + const summer = dateUtil.offset('month', 1, winter); + expect(summer.utcOffset()).toBe(60); + expect(summer.format('HH:mm')).toBe('12:00'); + expect(summer.startOf('month').toISOString()).toBe('2026-03-31T23:00:00.000Z'); + expect(summer.endOf('month').toISOString()).toBe('2026-04-30T22:59:59.999Z'); + }); +}); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/visitors/TableRecordConditionWhereVisitor.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/visitors/TableRecordConditionWhereVisitor.spec.ts index e386c29cc5..0dc33ecfc4 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/visitors/TableRecordConditionWhereVisitor.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/visitors/TableRecordConditionWhereVisitor.spec.ts @@ -525,7 +525,7 @@ describe('TableRecordConditionWhereVisitor NULL handling', () => { }); describe('date field reference comparisons', () => { - test('scalar number lookup is scalar number field reference uses direct equality', () => { + test('scalar number lookup is scalar number field reference uses drift-safe jsonb equality', () => { const { lookupField, scoreField } = createScalarNumberLookupReferenceFields(); const value = RecordConditionFieldReferenceValue.create(scoreField)._unsafeUnwrap(); const spec = lookupField.spec().create({ operator: 'is', value }); @@ -543,7 +543,9 @@ describe('TableRecordConditionWhereVisitor NULL handling', () => { if (where.isErr()) return; const { sql, parameters } = compileWhere(db, where.value); - expect(sql).toBe('"f"."col_lookup_score" = "h"."col_score"'); + // to_jsonb keeps numeric equality semantics while tolerating v1-era + // metadata drift where a scalar-typed field sits on a jsonb column. + expect(sql).toBe('to_jsonb("f"."col_lookup_score") = to_jsonb("h"."col_score")'); expect(sql).not.toContain('jsonb_array_elements_text'); expect(parameters).toEqual([]); }); @@ -704,6 +706,22 @@ describe('TableRecordConditionWhereVisitor NULL handling', () => { expect(sql).toContain('"t"."col_due_at" between $1 and $2'); expect(parameters).toEqual(['2025-12-15T11:00:00.000Z', '2025-12-15T11:00:00.000Z']); }); + + test('datetime dateRange preserves the requested time bounds', () => { + const value = RecordConditionDateValue.create({ + mode: 'dateRange', + exactDate: '2025-12-15T09:00:00.000Z', + exactDateEnd: '2025-12-15T17:00:00.000Z', + timeZone: 'utc', + })._unsafeUnwrap(); + const spec = dueAtField.spec().create({ operator: 'is', value }); + expect(spec.isOk()).toBe(true); + if (spec.isErr()) return; + + const { sql, parameters } = buildWhereFor(db, spec.value); + expect(sql).toContain('"t"."col_due_at" between $1 and $2'); + expect(parameters).toEqual(['2025-12-15T09:00:00.000Z', '2025-12-15T17:00:00.000Z']); + }); }); describe('incoming link selection specs', () => { diff --git a/packages/v2/adapter-table-repository-postgres/src/record/visitors/TableRecordConditionWhereVisitor.ts b/packages/v2/adapter-table-repository-postgres/src/record/visitors/TableRecordConditionWhereVisitor.ts index 3d57e4f14a..27a4493776 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/visitors/TableRecordConditionWhereVisitor.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/visitors/TableRecordConditionWhereVisitor.ts @@ -225,16 +225,28 @@ export interface TableRecordConditionWhereVisitorOptions { hostTableAlias?: string; } -class DateUtil { +export class DateUtil { constructor(private readonly timeZone: string) {} date(value?: dayjs.ConfigType): Dayjs { - return dayjs(value).utc().tz(this.timeZone); + const zoned = dayjs(value).utc().tz(this.timeZone); + // dayjs's timezone plugin stores the zone offset in a field that later code + // checks for truthiness, so instances whose current offset is exactly 0 + // (UTC, Etc/GMT, London in winter) fall back to the host-local calendar in + // startOf/endOf and produce host-timezone-dependent ranges. Pure utc-mode + // instances share the same day boundaries and are immune. + if (zoned.utcOffset() === 0) { + return dayjs(value).utc(); + } + return zoned; } offset(dateField: ManipulateType, offset: number, value = this.date()): Dayjs { if (offset === 0) return value; - return value[offset > 0 ? 'add' : 'subtract'](Math.abs(offset), dateField); + const shifted = value[offset > 0 ? 'add' : 'subtract'](Math.abs(offset), dateField); + // Keep the source wall-clock time while recalculating the target date's + // IANA offset. The outer date() retains the zero-offset dayjs workaround. + return this.date(shifted.tz(this.timeZone, true)); } offsetDay(offset: number, value = this.date()): Dayjs { @@ -486,6 +498,7 @@ const resolveDateRange = ( const mode = value.mode(); const numberOfDays = value.numberOfDays(); const exactDate = value.exactDate(); + const exactDateEnd = value.exactDateEnd(); const dateUtil = new DateUtil(value.timeZone().toString()); const requireExactDate = (): Result => { @@ -528,6 +541,23 @@ const resolveDateRange = ( }); }; + const determineDateRangeSpan = (): Result<[Dayjs, Dayjs], DomainError> => { + return requireExactDate().andThen((rawStart) => { + if (!exactDateEnd) { + return err( + core.domainError.unexpected({ message: 'Date condition requires exactDateEnd' }) + ); + } + const hasTimeFormat = formatting != null && formatting.time() !== core.TimeFormatting.None; + const start = dateUtil.date(rawStart); + const end = dateUtil.date(exactDateEnd); + return ok<[Dayjs, Dayjs]>([ + hasTimeFormat ? start : start.startOf('day'), + hasTimeFormat ? end : end.endOf('day'), + ]); + }); + }; + const determineExactDateTimeRange = (): Result<[Dayjs, Dayjs], DomainError> => { return requireExactDate().map((raw) => { const parsed = dateUtil.date(raw); @@ -578,8 +608,8 @@ const resolveDateRange = ( weekStart: 1, }); const cursorDate = match(relativeMode) - .with('next', () => dateUtil.date().add(1, unit)) - .with('last', () => dateUtil.date().subtract(1, unit)) + .with('next', () => dateUtil.offset(unit, 1)) + .with('last', () => dateUtil.offset(unit, -1)) .with('current', () => dateUtil.date()) .exhaustive(); return [cursorDate.startOf(unit).startOf('day'), cursorDate.endOf(unit).endOf('day')]; @@ -606,6 +636,7 @@ const resolveDateRange = ( .with('daysAgo', () => calculateDateRangeForOffsetDays(true)) .with('daysFromNow', () => calculateDateRangeForOffsetDays(false)) .with('exactDate', () => determineExactDateRange()) + .with('dateRange', () => determineDateRangeSpan()) .with('exactDateTime', () => determineExactDateTimeRange()) .with('exactFormatDate', () => determineExactFormatDateRange()) .with('currentWeek', () => ok(generateRelativeDateFromCurrentDateRange('current', 'week'))) @@ -719,6 +750,10 @@ const buildIsCondition = ( const isUserOrLinkLike = fieldIsUserOrLink(field) || fieldIsLookupWithUserOrLinkInner(field); if (core.isRecordConditionDateValue(value)) { const range = yield* resolveDateRange(value, resolveDateFormatting(field)); + // v1 parity: an inverted dateRange (start > end) is skipped, not an error + if (value.mode() === 'dateRange' && Date.parse(range.start) > Date.parse(range.end)) { + return ok(sql`true`); + } if (isMultiple || fieldIsJson(field)) { const normalizedArray = normalizeToJsonArray(columnRef); return ok(sql`EXISTS ( @@ -776,7 +811,12 @@ const buildIsCondition = ( !isMultipleRaw && !(yield* fieldIsMultiple(referenceField)) ) { - return ok(sql`${columnRef} = ${rightColumnRef}`); + // Compare via to_jsonb: the route is classified from field metadata + // only, and v1-era metadata drift can leave a physical jsonb column + // behind a scalar-typed field. A bare `=` then fails with + // `operator does not exist: jsonb = text`; jsonb comparison keeps + // numeric equality semantics and tolerates the drift. + return ok(sql`to_jsonb(${columnRef}) = to_jsonb(${rightColumnRef})`); } const arrayLikeMatch = yield* buildArrayLikeFieldReferenceIsCondition( @@ -797,7 +837,8 @@ const buildIsCondition = ( return ok(sql`1 = 0`); } - return ok(sql`${columnRef} = ${rightColumnRef}`); + // See the generic branch above: metadata drift makes bare `=` unsafe. + return ok(sql`to_jsonb(${columnRef}) = to_jsonb(${rightColumnRef})`); } const literalOperand = yield* expectLiteralOperand(operand); @@ -859,6 +900,10 @@ const buildIsNotCondition = ( const isMultiple = isArrayLikeOutputField(field, isMultipleRaw); const isUserOrLinkLike = fieldIsUserOrLink(field) || fieldIsLookupWithUserOrLinkInner(field); if (core.isRecordConditionDateValue(value)) { + // v1 parity: dateRange only supports is/isWithIn — with isNot the condition is skipped + if (value.mode() === 'dateRange') { + return ok(sql`true`); + } const range = yield* resolveDateRange(value, resolveDateFormatting(field)); if (isMultiple || fieldIsJson(field)) { const normalizedArray = normalizeToJsonArray(columnRef); @@ -935,7 +980,8 @@ const buildIsNotCondition = ( return ok(sql`1 = 1`); } - return ok(sql`${columnRef} is distinct from ${rightColumnRef}`); + // See buildIsCondition: metadata drift makes a bare comparison unsafe. + return ok(sql`to_jsonb(${columnRef}) is distinct from to_jsonb(${rightColumnRef})`); } const literalOperand = yield* expectLiteralOperand(operand); @@ -1211,6 +1257,10 @@ const buildIsWithinCondition = ( const column = yield* resolveColumn(field, tableAlias); const dateValue = yield* resolveDateValue(value); const range = yield* resolveDateRange(dateValue, resolveDateFormatting(field)); + // v1 parity: an inverted dateRange (start > end) is skipped, not an error + if (dateValue.mode() === 'dateRange' && Date.parse(range.start) > Date.parse(range.end)) { + return ok(sql`true`); + } const columnRef = sql.ref(column); const isMultiple = isArrayLikeOutputField(field, yield* fieldIsMultiple(field)); if (isMultiple || fieldIsJson(field)) { diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/repositories/PostgresTableSchemaRepository.ts b/packages/v2/adapter-table-repository-postgres/src/schema/repositories/PostgresTableSchemaRepository.ts index 822b67bc61..352dc55c9c 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/repositories/PostgresTableSchemaRepository.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/repositories/PostgresTableSchemaRepository.ts @@ -30,6 +30,11 @@ import { RecordsBatchUpdated, } from '@teable/v2-core'; import { inject, injectable } from '@teable/v2-di'; +import { + formulaSqlPgTokens, + Pg16TypeValidationStrategy, + type IPgTypeValidationStrategy, +} from '@teable/v2-formula-sql-pg'; import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; import { sql } from 'kysely'; import type { @@ -52,6 +57,7 @@ import { } from '../../shared/db'; import { createSchemaNotNullViolationError, + createSchemaUniqueViolationError, isNotNullViolation, isUniqueViolation, } from '../../shared/errors'; @@ -142,7 +148,9 @@ export class PostgresTableSchemaRepository implements ITableSchemaRepository { @inject(v2RecordRepositoryPostgresTokens.computedDependencyGraph) private readonly fieldDependencyGraph: FieldDependencyGraph, @inject(v2RecordRepositoryPostgresTokens.metaDb) - private readonly metaDb: Kysely = db + private readonly metaDb: Kysely = db, + @inject(formulaSqlPgTokens.typeValidationStrategy) + private readonly typeValidationStrategy: IPgTypeValidationStrategy = new Pg16TypeValidationStrategy() ) {} private resolveMetaDb( @@ -731,6 +739,7 @@ export class PostgresTableSchemaRepository implements ITableSchemaRepository { recordUpdates.push(update); }, }, + typeValidationStrategy: repository.typeValidationStrategy, }); yield* mutateSpec.accept(visitor); const statements = yield* visitor.where(); @@ -748,16 +757,11 @@ export class PostgresTableSchemaRepository implements ITableSchemaRepository { }); } catch (error) { if (isUniqueViolation(error)) { - return err( - domainError.validation({ - message: 'Cannot complete update: unique constraint violated', - code: 'validation.field.unique', - }) - ); + return err(createSchemaUniqueViolationError(error, tableName, table.getFields())); } if (isNotNullViolation(error)) { - return err(createSchemaNotNullViolationError(error, table.getFields(), context.$t)); + return err(createSchemaNotNullViolationError(error, table.getFields())); } return err( diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/helpers/StatementBuilders.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/helpers/StatementBuilders.ts index 92ee238c61..c61a603a01 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/helpers/StatementBuilders.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/helpers/StatementBuilders.ts @@ -57,7 +57,7 @@ export const dropColumnStatement = ( columnName: string ): TableSchemaStatementBuilder => dataStatement( - sql`alter table ${buildTableIdentifier(target)} drop column if exists ${sql.ref( + sql`alter table if exists ${buildTableIdentifier(target)} drop column if exists ${sql.ref( columnName )} cascade` ); diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/DependencyChangeDetectorVisitor.spec.ts b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/DependencyChangeDetectorVisitor.spec.ts index 40514f72f9..6d4eb8ea74 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/DependencyChangeDetectorVisitor.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/DependencyChangeDetectorVisitor.spec.ts @@ -61,6 +61,7 @@ describe('DependencyChangeDetectorVisitor', () => { const noOpMethods = [ 'visit', 'visitTableRename', + 'visitTableAddView', 'visitTableAddSelectOptions', 'visitTableDuplicateField', 'visitTableRemoveField', @@ -68,6 +69,8 @@ describe('DependencyChangeDetectorVisitor', () => { 'visitTableUpdateViewQueryDefaults', 'visitTableByBaseId', 'visitTableById', + 'visitTableByViewId', + 'visitTableWithViewIds', 'visitTableByIncomingReferenceToTable', 'visitTableByIds', 'visitTableByName', diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/DependencyChangeDetectorVisitor.ts b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/DependencyChangeDetectorVisitor.ts index 77d7f539bc..6b336393a8 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/DependencyChangeDetectorVisitor.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/DependencyChangeDetectorVisitor.ts @@ -6,9 +6,22 @@ import type { ITableSpecVisitor, TableAddFieldSpec, TableAddFieldsSpec, + TableAddViewSpec, + TableEnsureViewRowOrderSpec, + TableRemoveViewSpec, + TableRenameViewSpec, + TableUpdateViewDescriptionSpec, + TableUpdateViewLockedSpec, + TableUpdateViewOrderSpec, + TableUpdateViewOptionsSpec, + TableUpdateViewShareIdSpec, + TableUpdateViewShareMetaSpec, + TableUpdateViewShareStateSpec, TableAddSelectOptionsSpec, TableByBaseIdSpec, TableByIdSpec, + TableByViewIdSpec, + TableWithViewIdsSpec, TableByIncomingReferenceToTableSpec, TableByIdsSpec, TableByNameLikeSpec, @@ -18,6 +31,7 @@ import type { TableUpdateViewColumnMetaSpec, TableUpdateViewQueryDefaultsSpec, TableRenameSpec, + TableUpdatePropertiesSpec, // Common field update specs TableUpdateFieldNameSpec, TableUpdateFieldDbFieldNameSpec, @@ -107,6 +121,10 @@ export class DependencyChangeDetectorVisitor implements ITableSpecVisitor return ok(undefined); } + visitTableUpdateProperties(_spec: TableUpdatePropertiesSpec): Result { + return ok(undefined); + } + visitTableAddField(spec: TableAddFieldSpec): Result { return this.markField(spec.field()); } @@ -121,6 +139,36 @@ export class DependencyChangeDetectorVisitor implements ITableSpecVisitor return ok(undefined); } + visitTableAddView(_spec: TableAddViewSpec): Result { + return ok(undefined); + } + + visitTableEnsureViewRowOrder(_spec: TableEnsureViewRowOrderSpec): Result { + return ok(undefined); + } + + visitTableRemoveView(_spec: TableRemoveViewSpec): Result { + return ok(undefined); + } + + visitTableRenameView(_spec: TableRenameViewSpec): Result { + return ok(undefined); + } + + visitTableUpdateViewDescription( + _spec: TableUpdateViewDescriptionSpec + ): Result { + return ok(undefined); + } + + visitTableUpdateViewLocked(_spec: TableUpdateViewLockedSpec): Result { + return ok(undefined); + } + + visitTableUpdateViewOrder(_spec: TableUpdateViewOrderSpec): Result { + return ok(undefined); + } + private markField(field: Field): Result { const type = field.type().toString(); // Only computed fields create dependencies @@ -154,6 +202,22 @@ export class DependencyChangeDetectorVisitor implements ITableSpecVisitor return ok(undefined); } + visitTableUpdateViewOptions(_spec: TableUpdateViewOptionsSpec): Result { + return ok(undefined); + } + + visitTableUpdateViewShareMeta(_spec: TableUpdateViewShareMetaSpec): Result { + return ok(undefined); + } + + visitTableUpdateViewShareId(_spec: TableUpdateViewShareIdSpec): Result { + return ok(undefined); + } + + visitTableUpdateViewShareState(_spec: TableUpdateViewShareStateSpec): Result { + return ok(undefined); + } + visitTableUpdateViewQueryDefaults( _spec: TableUpdateViewQueryDefaultsSpec ): Result { @@ -168,6 +232,14 @@ export class DependencyChangeDetectorVisitor implements ITableSpecVisitor return ok(undefined); } + visitTableByViewId(_spec: TableByViewIdSpec): Result { + return ok(undefined); + } + + visitTableWithViewIds(_spec: TableWithViewIdsSpec): Result { + return ok(undefined); + } + visitTableByIncomingReferenceToTable( _spec: TableByIncomingReferenceToTableSpec ): Result { diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/FieldTypeConversionVisitor.ts b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/FieldTypeConversionVisitor.ts index d62e548909..31849d201c 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/FieldTypeConversionVisitor.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/FieldTypeConversionVisitor.ts @@ -33,7 +33,11 @@ import { type SingleSelectField, type UserField, } from '@teable/v2-core'; -import { formatNumberStringSql } from '@teable/v2-formula-sql-pg'; +import { + formatNumberStringSql, + Pg16TypeValidationStrategy, + type IPgTypeValidationStrategy, +} from '@teable/v2-formula-sql-pg'; import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; import type { CompiledQuery, Kysely } from 'kysely'; import { sql } from 'kysely'; @@ -56,6 +60,7 @@ export type FieldConversionParams = { fieldId?: string; tableLocationsById?: ReadonlyMap; fieldsById?: ReadonlyMap; + typeValidationStrategy?: IPgTypeValidationStrategy; }; const createCompiledStatementBuilder = ( @@ -73,6 +78,20 @@ const quoteLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'` const ISO_DATE_OR_DATETIME_SQL_REGEX = '^[0-9]{4}-[0-9]{2}-[0-9]{2}([T ][0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?([Zz]|[+-][0-9]{2}(:?[0-9]{2})?)?)?$'; +const buildRatingConversionExpression = (valueExpression: string, max: number): string => + `CASE WHEN (${valueExpression}) IS NULL OR ROUND((${valueExpression})::double precision) < 1 THEN NULL ELSE LEAST(ROUND((${valueExpression})::double precision), ${max}) END`; + +const DEFAULT_TYPE_VALIDATION_STRATEGY = new Pg16TypeValidationStrategy(); + +const safeTimestampCastSql = ( + valueSql: string, + strategy: IPgTypeValidationStrategy = DEFAULT_TYPE_VALIDATION_STRATEGY +): string => { + const isIsoDate = `(${valueSql}) ~ ${quoteLiteral(ISO_DATE_OR_DATETIME_SQL_REGEX)}`; + const isValidTimestamp = strategy.isValidForType(`(${valueSql})::text`, 'timestamptz'); + return `CASE WHEN ${isIsoDate} AND ${isValidTimestamp} THEN (${valueSql})::timestamptz ELSE NULL END`; +}; + const SELECT_CHOICE_NAME_MAX_LENGTH = DEFAULT_TABLE_DATA_SAFETY_LIMITS.fieldOptions.maxSelectChoiceNameLength; @@ -976,7 +995,7 @@ const buildFormulaMigrationStatements = ( tmp, whereNotNull, cellValueType, - newType, + newField, oldField, params ); @@ -1087,12 +1106,12 @@ const buildLookupToBasicFieldMigrationStatements = ( return numericValueExpression; case 'rating': { const max = (newField as RatingField).ratingMax().toNumber(); - return `CASE WHEN (${firstValueExpression}) ~ '^-?[0-9]+(\\.[0-9]+)?$' THEN GREATEST(0, LEAST(FLOOR((${firstValueExpression})::double precision), ${max})) ELSE NULL END`; + return `CASE WHEN (${firstValueExpression}) ~ '^-?[0-9]+(\\.[0-9]+)?$' THEN ${buildRatingConversionExpression(firstValueExpression, max)} ELSE NULL END`; } case 'checkbox': return `CASE WHEN lower((${firstValueExpression})::text) IN ('true', 't', '1', 'yes', 'y') THEN TRUE WHEN lower((${firstValueExpression})::text) IN ('false', 'f', '0', 'no', 'n') THEN FALSE WHEN (${firstValueExpression}) IS NOT NULL AND (${firstValueExpression}) <> '' THEN TRUE ELSE NULL END`; case 'date': - return `CASE WHEN (${firstValueExpression}) ~ ${quoteLiteral(ISO_DATE_OR_DATETIME_SQL_REGEX)} THEN (${firstValueExpression})::timestamptz ELSE NULL END`; + return safeTimestampCastSql(firstValueExpression, params.typeValidationStrategy); case 'singleSelect': return firstValueExpression; case 'multipleSelect': @@ -1134,10 +1153,11 @@ function buildFormulaMigrationSql( tmp: string, whereNotNull: string, cellValueType: CellValueType | undefined, - newType: string, + newField: Field, oldField: FormulaField, - _params: FieldConversionParams + params: FieldConversionParams ): string | null { + const newType = newField.type().toString(); const isDateTime = cellValueType?.equals(CellValueType.dateTime()); const isNumber = cellValueType?.equals(CellValueType.number()); const isString = cellValueType?.equals(CellValueType.string()); @@ -1174,8 +1194,8 @@ function buildFormulaMigrationSql( // --- Target: rating --- if (newType === 'rating') { if (isNumber) { - // Clamp number to valid rating range [1, max] - return `UPDATE ${tbl} SET ${dst} = CASE WHEN ${tmp} >= 1 THEN LEAST(${tmp}, ${dst}) ELSE NULL END ${whereNotNull}`; + const max = (newField as RatingField).ratingMax().toNumber(); + return `UPDATE ${tbl} SET ${dst} = ${buildRatingConversionExpression(tmp, max)} ${whereNotNull}`; } return null; } @@ -1188,7 +1208,7 @@ function buildFormulaMigrationSql( } if (isString) { // Try to parse string as timestamp - return `UPDATE ${tbl} SET ${dst} = CASE WHEN ${tmp} ~ ${quoteLiteral(ISO_DATE_OR_DATETIME_SQL_REGEX)} THEN ${tmp}::timestamptz ELSE NULL END ${whereNotNull}`; + return `UPDATE ${tbl} SET ${dst} = ${safeTimestampCastSql(tmp, params.typeValidationStrategy)} ${whereNotNull}`; } // number, boolean → date: incompatible return null; @@ -2036,14 +2056,14 @@ class TextFieldConversionVisitor extends BaseFieldConversionVisitor { visitRatingField( field: RatingField ): Result, DomainError> { - // Text → Rating: parse as number, floor to integer, clamp to [0, max] + // Text → Rating: parse, round, clamp to max, and map values below 1 to NULL const { dbFieldName } = this.params; const col = `"${dbFieldName}"`; const max = field.ratingMax().toNumber(); return ok([ this.alterColumnTypeUsing( 'double precision', - `CASE WHEN ${col} ~ '^-?[0-9]+(\\.[0-9]+)?$' THEN GREATEST(0, LEAST(FLOOR(${col}::double precision), ${max})) ELSE NULL END` + `CASE WHEN ${col} ~ '^-?[0-9]+(\\.[0-9]+)?$' THEN ${buildRatingConversionExpression(col, max)} ELSE NULL END` ), ]); } @@ -2071,7 +2091,7 @@ class TextFieldConversionVisitor extends BaseFieldConversionVisitor { return ok([ this.alterColumnTypeUsing( 'timestamptz', - `CASE WHEN ${col} ~ ${quoteLiteral(ISO_DATE_OR_DATETIME_SQL_REGEX)} THEN ${col}::timestamptz ELSE NULL END` + safeTimestampCastSql(col, this.params.typeValidationStrategy) ), ]); } @@ -2284,7 +2304,7 @@ class NumberFieldConversionVisitor extends BaseFieldConversionVisitor { visitRatingField( field: RatingField ): Result, DomainError> { - // Number → Rating: floor decimals to integer and clamp to [0, max] + // Number → Rating: round, clamp to max, and map values below 1 to NULL const { db, dbFieldName } = this.params; const fullTableName = this.fullTableName; const max = field.ratingMax().toNumber(); @@ -2292,7 +2312,7 @@ class NumberFieldConversionVisitor extends BaseFieldConversionVisitor { { scope: 'data', compile: () => - sql`UPDATE ${sql.raw(fullTableName)} SET "${sql.raw(dbFieldName)}" = GREATEST(0, LEAST(FLOOR("${sql.raw(dbFieldName)}"), ${sql.val(max)}))`.compile( + sql`UPDATE ${sql.raw(fullTableName)} SET "${sql.raw(dbFieldName)}" = ${sql.raw(buildRatingConversionExpression(quoteIdent(dbFieldName), max))}`.compile( db ), }, diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/FieldValueChangeCollectorVisitor.coverage.spec.ts b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/FieldValueChangeCollectorVisitor.coverage.spec.ts index 3eb1e2b730..2b47360ecc 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/FieldValueChangeCollectorVisitor.coverage.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/FieldValueChangeCollectorVisitor.coverage.spec.ts @@ -25,12 +25,15 @@ describe('FieldValueChangeCollectorVisitor coverage', () => { 'visitTableRename', 'visitTableAddField', 'visitTableAddFields', + 'visitTableAddView', 'visitTableAddSelectOptions', 'visitTableRemoveField', 'visitTableUpdateViewColumnMeta', 'visitTableUpdateViewQueryDefaults', 'visitTableByBaseId', 'visitTableById', + 'visitTableByViewId', + 'visitTableWithViewIds', 'visitTableByIncomingReferenceToTable', 'visitTableByIds', 'visitTableByName', diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/FieldValueChangeCollectorVisitor.ts b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/FieldValueChangeCollectorVisitor.ts index 443c2fdd9f..5c97895ef5 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/FieldValueChangeCollectorVisitor.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/FieldValueChangeCollectorVisitor.ts @@ -5,9 +5,22 @@ import type { ITableSpecVisitor, TableAddFieldSpec, TableAddFieldsSpec, + TableAddViewSpec, + TableEnsureViewRowOrderSpec, + TableRemoveViewSpec, + TableRenameViewSpec, + TableUpdateViewDescriptionSpec, + TableUpdateViewLockedSpec, + TableUpdateViewOrderSpec, + TableUpdateViewOptionsSpec, + TableUpdateViewShareIdSpec, + TableUpdateViewShareMetaSpec, + TableUpdateViewShareStateSpec, TableAddSelectOptionsSpec, TableByBaseIdSpec, TableByIdSpec, + TableByViewIdSpec, + TableWithViewIdsSpec, TableByIncomingReferenceToTableSpec, TableByIdsSpec, TableByNameLikeSpec, @@ -17,6 +30,7 @@ import type { TableUpdateViewColumnMetaSpec, TableUpdateViewQueryDefaultsSpec, TableRenameSpec, + TableUpdatePropertiesSpec, // Common field update specs TableUpdateFieldNameSpec, TableUpdateFieldTypeSpec, @@ -126,6 +140,10 @@ export class FieldValueChangeCollectorVisitor implements ITableSpecVisitor return ok(undefined); } + visitTableUpdateProperties(_spec: TableUpdatePropertiesSpec): Result { + return ok(undefined); + } + visitTableAddField(_spec: TableAddFieldSpec): Result { return ok(undefined); } @@ -134,6 +152,36 @@ export class FieldValueChangeCollectorVisitor implements ITableSpecVisitor return ok(undefined); } + visitTableAddView(_spec: TableAddViewSpec): Result { + return ok(undefined); + } + + visitTableEnsureViewRowOrder(_spec: TableEnsureViewRowOrderSpec): Result { + return ok(undefined); + } + + visitTableRemoveView(_spec: TableRemoveViewSpec): Result { + return ok(undefined); + } + + visitTableRenameView(_spec: TableRenameViewSpec): Result { + return ok(undefined); + } + + visitTableUpdateViewDescription( + _spec: TableUpdateViewDescriptionSpec + ): Result { + return ok(undefined); + } + + visitTableUpdateViewLocked(_spec: TableUpdateViewLockedSpec): Result { + return ok(undefined); + } + + visitTableUpdateViewOrder(_spec: TableUpdateViewOrderSpec): Result { + return ok(undefined); + } + visitTableAddSelectOptions(_spec: TableAddSelectOptionsSpec): Result { return ok(undefined); } @@ -153,6 +201,22 @@ export class FieldValueChangeCollectorVisitor implements ITableSpecVisitor return ok(undefined); } + visitTableUpdateViewOptions(_spec: TableUpdateViewOptionsSpec): Result { + return ok(undefined); + } + + visitTableUpdateViewShareMeta(_spec: TableUpdateViewShareMetaSpec): Result { + return ok(undefined); + } + + visitTableUpdateViewShareId(_spec: TableUpdateViewShareIdSpec): Result { + return ok(undefined); + } + + visitTableUpdateViewShareState(_spec: TableUpdateViewShareStateSpec): Result { + return ok(undefined); + } + visitTableUpdateViewQueryDefaults( _spec: TableUpdateViewQueryDefaultsSpec ): Result { @@ -167,6 +231,14 @@ export class FieldValueChangeCollectorVisitor implements ITableSpecVisitor return ok(undefined); } + visitTableByViewId(_spec: TableByViewIdSpec): Result { + return ok(undefined); + } + + visitTableWithViewIds(_spec: TableWithViewIdsSpec): Result { + return ok(undefined); + } + visitTableByIncomingReferenceToTable( _spec: TableByIncomingReferenceToTableSpec ): Result { diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableAddFieldCollectorVisitor.spec.ts b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableAddFieldCollectorVisitor.spec.ts index 181e0322c4..068df393a7 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableAddFieldCollectorVisitor.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableAddFieldCollectorVisitor.spec.ts @@ -34,6 +34,7 @@ describe('TableAddFieldCollectorVisitor', () => { const noOpMethods = [ 'visit', 'visitTableRename', + 'visitTableAddView', 'visitTableAddSelectOptions', 'visitTableDuplicateField', 'visitTableRemoveField', @@ -41,6 +42,8 @@ describe('TableAddFieldCollectorVisitor', () => { 'visitTableUpdateViewQueryDefaults', 'visitTableByBaseId', 'visitTableById', + 'visitTableByViewId', + 'visitTableWithViewIds', 'visitTableByIncomingReferenceToTable', 'visitTableByIds', 'visitTableByName', diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableAddFieldCollectorVisitor.ts b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableAddFieldCollectorVisitor.ts index 5bb180eb48..13d37f8eac 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableAddFieldCollectorVisitor.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableAddFieldCollectorVisitor.ts @@ -5,9 +5,22 @@ import type { ITableSpecVisitor, TableAddFieldSpec, TableAddFieldsSpec, + TableAddViewSpec, + TableEnsureViewRowOrderSpec, + TableRemoveViewSpec, + TableRenameViewSpec, + TableUpdateViewDescriptionSpec, + TableUpdateViewLockedSpec, + TableUpdateViewOrderSpec, + TableUpdateViewOptionsSpec, + TableUpdateViewShareIdSpec, + TableUpdateViewShareMetaSpec, + TableUpdateViewShareStateSpec, TableAddSelectOptionsSpec, TableByBaseIdSpec, TableByIdSpec, + TableByViewIdSpec, + TableWithViewIdsSpec, TableByIncomingReferenceToTableSpec, TableByIdsSpec, TableByNameLikeSpec, @@ -17,6 +30,7 @@ import type { TableUpdateViewColumnMetaSpec, TableUpdateViewQueryDefaultsSpec, TableRenameSpec, + TableUpdatePropertiesSpec, // Common field update specs TableUpdateFieldNameSpec, TableUpdateFieldDbFieldNameSpec, @@ -85,6 +99,10 @@ export class TableAddFieldCollectorVisitor implements ITableSpecVisitor { return ok(undefined); } + visitTableUpdateProperties(_spec: TableUpdatePropertiesSpec): Result { + return ok(undefined); + } + visitTableAddField(spec: TableAddFieldSpec): Result { this.fieldsValue.push(spec.field()); return ok(undefined); @@ -95,6 +113,36 @@ export class TableAddFieldCollectorVisitor implements ITableSpecVisitor { return ok(undefined); } + visitTableAddView(_spec: TableAddViewSpec): Result { + return ok(undefined); + } + + visitTableEnsureViewRowOrder(_spec: TableEnsureViewRowOrderSpec): Result { + return ok(undefined); + } + + visitTableRemoveView(_spec: TableRemoveViewSpec): Result { + return ok(undefined); + } + + visitTableRenameView(_spec: TableRenameViewSpec): Result { + return ok(undefined); + } + + visitTableUpdateViewDescription( + _spec: TableUpdateViewDescriptionSpec + ): Result { + return ok(undefined); + } + + visitTableUpdateViewLocked(_spec: TableUpdateViewLockedSpec): Result { + return ok(undefined); + } + + visitTableUpdateViewOrder(_spec: TableUpdateViewOrderSpec): Result { + return ok(undefined); + } + visitTableAddSelectOptions(_spec: TableAddSelectOptionsSpec): Result { return ok(undefined); } @@ -111,6 +159,22 @@ export class TableAddFieldCollectorVisitor implements ITableSpecVisitor { return ok(undefined); } + visitTableUpdateViewOptions(_spec: TableUpdateViewOptionsSpec): Result { + return ok(undefined); + } + + visitTableUpdateViewShareMeta(_spec: TableUpdateViewShareMetaSpec): Result { + return ok(undefined); + } + + visitTableUpdateViewShareId(_spec: TableUpdateViewShareIdSpec): Result { + return ok(undefined); + } + + visitTableUpdateViewShareState(_spec: TableUpdateViewShareStateSpec): Result { + return ok(undefined); + } + visitTableUpdateViewQueryDefaults( _spec: TableUpdateViewQueryDefaultsSpec ): Result { @@ -125,6 +189,14 @@ export class TableAddFieldCollectorVisitor implements ITableSpecVisitor { return ok(undefined); } + visitTableByViewId(_spec: TableByViewIdSpec): Result { + return ok(undefined); + } + + visitTableWithViewIds(_spec: TableWithViewIdsSpec): Result { + return ok(undefined); + } + visitTableByIncomingReferenceToTable( _spec: TableByIncomingReferenceToTableSpec ): Result { diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableSchemaUpdateVisitor.coverage.spec.ts b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableSchemaUpdateVisitor.coverage.spec.ts index 349a9ddc68..597312f3c0 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableSchemaUpdateVisitor.coverage.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableSchemaUpdateVisitor.coverage.spec.ts @@ -119,6 +119,8 @@ describe('TableSchemaUpdateVisitor coverage', () => { const errorMethods = [ 'visitTableByBaseId', 'visitTableById', + 'visitTableByViewId', + 'visitTableWithViewIds', 'visitTableByIncomingReferenceToTable', 'visitTableByIds', 'visitTableByNameLike', diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableSchemaUpdateVisitor.ts b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableSchemaUpdateVisitor.ts index b5423c3af7..dc971bacfd 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableSchemaUpdateVisitor.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableSchemaUpdateVisitor.ts @@ -1,3 +1,7 @@ +import { + managedSearchDocumentColumnPrefixes, + managedSearchPrefixLikePattern, +} from '@teable/v2-adapter-db-postgres-shared'; import { AbstractSpecFilterVisitor, DbFieldName, @@ -5,15 +9,28 @@ import { FieldValueTypeVisitor, FormulaField, LookupField, + TableAddViewSpec, } from '@teable/v2-core'; import type { TableAddFieldSpec, TableAddFieldsSpec, + TableEnsureViewRowOrderSpec, + TableRemoveViewSpec, + TableRenameViewSpec, + TableUpdateViewDescriptionSpec, + TableUpdateViewLockedSpec, + TableUpdateViewOrderSpec, + TableUpdateViewOptionsSpec, + TableUpdateViewShareIdSpec, + TableUpdateViewShareMetaSpec, + TableUpdateViewShareStateSpec, TableAddSelectOptionsSpec, TableDuplicateFieldSpec, TableRemoveFieldSpec, TableByBaseIdSpec, TableByIdSpec, + TableByViewIdSpec, + TableWithViewIdsSpec, TableByIncomingReferenceToTableSpec, TableByIdsSpec, TableByNameLikeSpec, @@ -23,6 +40,7 @@ import type { ITableSpecVisitor, DomainError, TableRenameSpec, + TableUpdatePropertiesSpec, // Common field update specs TableUpdateFieldNameSpec, TableUpdateFieldDbFieldNameSpec, @@ -77,6 +95,7 @@ import type { Field, RecordUpdateDTO, } from '@teable/v2-core'; +import type { IPgTypeValidationStrategy } from '@teable/v2-formula-sql-pg'; import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; import type { Kysely, QueryExecutorProvider } from 'kysely'; import { sql } from 'kysely'; @@ -107,6 +126,7 @@ type TableSchemaUpdateVisitorParams = { recordUpdateCollector?: { add(update: RecordUpdateDTO): void; }; + typeValidationStrategy?: IPgTypeValidationStrategy; }; type SelectOptionRecordUpdateRow = { @@ -219,8 +239,12 @@ export class TableSchemaUpdateVisitor AND NOT a.attisdropped AND a.attgenerated = 's' AND ( - a.attname LIKE '\\_\\_tqops\\_tsv\\_%' ESCAPE '\\' - OR a.attname LIKE '\\_\\_tqops\\_search\\_%' ESCAPE '\\' + ${managedSearchDocumentColumnPrefixes + .map( + (prefix) => + `a.attname LIKE ${quoteSqlLiteral(managedSearchPrefixLikePattern(prefix))} ESCAPE '\\'` + ) + .join('\n OR ')} ) LOOP EXECUTE format( @@ -570,6 +594,7 @@ export class TableSchemaUpdateVisitor tableId: this.params.tableId, dbFieldName: dbFieldNameResult.value, fieldId: field.id().toString(), + typeValidationStrategy: this.params.typeValidationStrategy, }, previousFieldResult.value, nextFieldResult.value @@ -634,6 +659,13 @@ export class TableSchemaUpdateVisitor return this.addCond(statements).map(() => statements); } + visitTableUpdateProperties( + _spec: TableUpdatePropertiesSpec + ): Result { + const statements: ReadonlyArray = []; + return this.addCond(statements).map(() => statements); + } + visitTableAddField( spec: TableAddFieldSpec ): Result, DomainError> { @@ -678,6 +710,88 @@ export class TableSchemaUpdateVisitor }); } + visitTableAddView( + spec: TableAddViewSpec + ): Result, DomainError> { + if (spec.view().type().toString() !== 'grid') { + const statements: ReadonlyArray = []; + return this.addCond(statements).map(() => statements); + } + + const { db, schema, tableName } = this.params; + const quoteIdentifier = (value: string): string => `"${value.replaceAll('"', '""')}"`; + const fullTableName = schema + ? `${quoteIdentifier(schema)}.${quoteIdentifier(tableName)}` + : quoteIdentifier(tableName); + const columnName = spec.view().id().toRowOrderColumnName(); + const indexName = `idx_${columnName}`; + const statements: ReadonlyArray = [ + { + scope: 'data', + compile: () => + sql`ALTER TABLE ${sql.raw(fullTableName)} ADD COLUMN IF NOT EXISTS ${sql.ref(columnName)} double precision`.compile( + db + ), + }, + { + scope: 'data', + compile: () => + sql`UPDATE ${sql.raw(fullTableName)} SET ${sql.ref(columnName)} = "__auto_number" WHERE ${sql.ref(columnName)} IS NULL`.compile( + db + ), + }, + { + scope: 'data', + compile: () => + sql`CREATE INDEX IF NOT EXISTS ${sql.raw(quoteIdentifier(indexName))} ON ${sql.raw(fullTableName)} (${sql.ref(columnName)})`.compile( + db + ), + }, + ]; + return this.addCond(statements).map(() => statements); + } + + visitTableEnsureViewRowOrder( + spec: TableEnsureViewRowOrderSpec + ): Result, DomainError> { + return this.visitTableAddView(TableAddViewSpec.create(spec.view())); + } + + visitTableRemoveView( + _spec: TableRemoveViewSpec + ): Result, DomainError> { + const statements: ReadonlyArray = []; + return this.addCond(statements).map(() => statements); + } + + visitTableRenameView( + _spec: TableRenameViewSpec + ): Result, DomainError> { + const statements: ReadonlyArray = []; + return this.addCond(statements).map(() => statements); + } + + visitTableUpdateViewDescription( + _spec: TableUpdateViewDescriptionSpec + ): Result, DomainError> { + const statements: ReadonlyArray = []; + return this.addCond(statements).map(() => statements); + } + + visitTableUpdateViewLocked( + _spec: TableUpdateViewLockedSpec + ): Result, DomainError> { + const statements: ReadonlyArray = []; + return this.addCond(statements).map(() => statements); + } + + visitTableUpdateViewOrder( + _spec: TableUpdateViewOrderSpec + ): Result, DomainError> { + const statements: ReadonlyArray = []; + return this.addCond(statements).map(() => statements); + } + visitTableRemoveField( spec: TableRemoveFieldSpec ): Result, DomainError> { @@ -702,6 +816,34 @@ export class TableSchemaUpdateVisitor return this.addCond(statements).map(() => statements); } + visitTableUpdateViewOptions( + _: TableUpdateViewOptionsSpec + ): Result, DomainError> { + const statements: ReadonlyArray = []; + return this.addCond(statements).map(() => statements); + } + + visitTableUpdateViewShareMeta( + _spec: TableUpdateViewShareMetaSpec + ): Result, DomainError> { + const statements: ReadonlyArray = []; + return this.addCond(statements).map(() => statements); + } + + visitTableUpdateViewShareId( + _spec: TableUpdateViewShareIdSpec + ): Result, DomainError> { + const statements: ReadonlyArray = []; + return this.addCond(statements).map(() => statements); + } + + visitTableUpdateViewShareState( + _spec: TableUpdateViewShareStateSpec + ): Result, DomainError> { + const statements: ReadonlyArray = []; + return this.addCond(statements).map(() => statements); + } + visitTableUpdateViewQueryDefaults( _: TableUpdateViewQueryDefaultsSpec ): Result, DomainError> { @@ -778,6 +920,26 @@ export class TableSchemaUpdateVisitor ); } + visitTableByViewId( + _: TableByViewIdSpec + ): Result, DomainError> { + return err( + domainError.validation({ + message: 'TableByViewIdSpec is not supported for table schema updates', + }) + ); + } + + visitTableWithViewIds( + _: TableWithViewIdsSpec + ): Result, DomainError> { + return err( + domainError.validation({ + message: 'TableWithViewIdsSpec is not supported for table schema updates', + }) + ); + } + visitTableByIncomingReferenceToTable( _: TableByIncomingReferenceToTableSpec ): Result, DomainError> { @@ -884,6 +1046,42 @@ export class TableSchemaUpdateVisitor } const dbFieldName = dbFieldNameResult.value; + // A type conversion rebuilds the field definition, which resets the + // unique/notNull flags in the domain model (matching v1's contract: + // conversion clears validation constraints). The in-place ALTER TYPE + // keeps the underlying constraint/index alive, so drop them explicitly — + // otherwise a ghost constraint keeps rejecting writes that the field + // metadata says are allowed. Dropping before the conversion also lets + // casts that collapse values (e.g. '42'/'042' → 42) succeed like v1. + const constraintCleanupStatements: TableSchemaStatementBuilder[] = []; + const { schema, tableName } = visitor.params; + const fullTableName = schema ? `"${schema}"."${tableName}"` : `"${tableName}"`; + if (oldField.unique().toBoolean() && !newField.unique().toBoolean()) { + const constraintName = `${tableName}_${dbFieldName}_unique`; + const quotedIndexName = schema ? `"${schema}"."${constraintName}"` : `"${constraintName}"`; + constraintCleanupStatements.push({ + scope: 'data', + compile: () => + sql`ALTER TABLE ${sql.raw(fullTableName)} DROP CONSTRAINT IF EXISTS ${sql.ref(constraintName)}`.compile( + visitor.params.db + ), + }); + constraintCleanupStatements.push({ + scope: 'data', + compile: () => + sql`DROP INDEX IF EXISTS ${sql.raw(quotedIndexName)}`.compile(visitor.params.db), + }); + } + if (oldField.notNull().toBoolean() && !newField.notNull().toBoolean()) { + constraintCleanupStatements.push({ + scope: 'data', + compile: () => + sql`ALTER TABLE ${sql.raw(fullTableName)} ALTER COLUMN ${sql.ref(dbFieldName)} DROP NOT NULL`.compile( + visitor.params.db + ), + }); + } + // Generate conversion statements const conversionParams: FieldConversionParams = { db: visitor.params.db, @@ -894,6 +1092,7 @@ export class TableSchemaUpdateVisitor fieldId: newField.id().toString(), tableLocationsById: visitor.params.tableLocationsById, fieldsById: yield* visitor.buildCurrentTableFieldMetadataById(), + typeValidationStrategy: visitor.params.typeValidationStrategy, }; const conversionStatements = yield* generateFieldConversionStatements( @@ -915,6 +1114,7 @@ export class TableSchemaUpdateVisitor visitor.markSearchVectorConfigRebuildPendingStatement('source_field_type_changed'), visitor.dropManagedSearchVectorColumnsStatement(), dropSearchIdx, + ...constraintCleanupStatements, ...conversionStatements, ...referenceStatements, ...(createSearchIdx ? [createSearchIdx] : []), diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/__tests__/FieldTypeConversionVisitor.spec.ts b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/__tests__/FieldTypeConversionVisitor.spec.ts index 4ec0959ca8..e81f6d0ac5 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/__tests__/FieldTypeConversionVisitor.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/__tests__/FieldTypeConversionVisitor.spec.ts @@ -271,11 +271,12 @@ describe('FieldTypeConversionVisitor', () => { }); describe('text -> rating', () => { - it('should generate SQL to parse and clamp values to max rating', () => { + it('should generate SQL to round and normalize values to the valid rating range', () => { const sqls = getVisitorSqls(mkTextField(), mkRatField(5)); expect(sqls).toHaveLength(1); - expect(sqls[0]).toContain('GREATEST(0'); - expect(sqls[0]).toContain('LEAST(FLOOR'); + expect(sqls[0]).toContain('ROUND'); + expect(sqls[0]).toContain('< 1 THEN NULL'); + expect(sqls[0]).toContain('LEAST'); }); it('should respect the rating max from field configuration', () => { @@ -454,14 +455,14 @@ describe('FieldTypeConversionVisitor', () => { }); describe('number -> rating', () => { - it('should generate SQL to clamp values to rating max', () => { + it('should generate SQL to round and normalize values to the valid rating range', () => { const sqls = getVisitorSqls(mkSrcNumField(), mkRatField(5)); expect(sqls).toHaveLength(1); const sql = sqls[0]; expect(sql).toContain('UPDATE'); - expect(sql).toContain('GREATEST(0'); - expect(sql).toContain('LEAST(FLOOR'); - expect(sql).toContain('$1'); + expect(sql).toContain('ROUND'); + expect(sql).toContain('< 1 THEN NULL'); + expect(sql).toContain('LEAST'); }); }); @@ -1284,6 +1285,18 @@ describe('FieldTypeConversionVisitor', () => { expect(migrateSql).not.toContain('::text'); }); + it('should round and normalize number formula values to the valid rating range', () => { + const formulaField = mkFormulaNumberField(); + const ratingField = mkRatField(5); + const sqls = getConversionSqls(formulaField, ratingField); + + const migrateSql = sqls.find((s) => s.includes('UPDATE') && s.includes('__tmp_formula_src_')); + expect(migrateSql).toContain('ROUND'); + expect(migrateSql).toContain('< 1 THEN NULL'); + expect(migrateSql).toContain('LEAST'); + expect(migrateSql).toContain('5'); + }); + it('should produce NULL for datetime formula → number (incompatible)', () => { const formulaField = mkFormulaDateTimeField(); const numField = mkNumField(); diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/__tests__/TableSchemaUpdateVisitor.spec.ts b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/__tests__/TableSchemaUpdateVisitor.spec.ts index b9a6f2364e..d5b2d901e0 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/__tests__/TableSchemaUpdateVisitor.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/__tests__/TableSchemaUpdateVisitor.spec.ts @@ -16,12 +16,19 @@ import { Table, TableAddFieldSpec, TableAddFieldsSpec, + TableAddViewSpec, + TableEnsureViewRowOrderSpec, TableId, TableRemoveFieldSpec, TableName, TableUpdateFieldHasErrorSpec, TableUpdateFieldTypeSpec, UpdateLinkRelationshipSpec, + ViewColumnMeta, + ViewId, + ViewName, + ViewQueryDefaults, + createGridView, } from '@teable/v2-core'; import { describe, expect, it } from 'vitest'; @@ -30,6 +37,67 @@ import { createTestDb } from './helpers/createTestDb'; import { createDtField } from './helpers/fieldFactories'; describe('TableSchemaUpdateVisitor', () => { + describe('visitTableAddView', () => { + it('adds, backfills and indexes the row-order column for a grid view', () => { + const db = createTestDb(); + const tableBuilder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Test Table')._unsafeUnwrap()); + tableBuilder + .field() + .singleLineText() + .withName(FieldName.create('Name')._unsafeUnwrap()) + .done(); + tableBuilder.view().defaultGrid().done(); + const table = tableBuilder.build()._unsafeUnwrap(); + const view = createGridView({ + id: ViewId.create(`viw${'b'.repeat(16)}`)._unsafeUnwrap(), + name: ViewName.create('Planning')._unsafeUnwrap(), + })._unsafeUnwrap(); + view + .setColumnMeta( + ViewColumnMeta.forView({ + viewType: view.type(), + fields: table.getFields(), + primaryFieldId: table.primaryFieldId(), + })._unsafeUnwrap() + ) + ._unsafeUnwrap(); + view.setQueryDefaults(ViewQueryDefaults.empty())._unsafeUnwrap(); + + const visitor = new TableSchemaUpdateVisitor({ + db, + schema: 'public', + tableName: 'table_data', + tableId: table.id().toString(), + table, + }); + const statements = visitor.visitTableAddView(TableAddViewSpec.create(view))._unsafeUnwrap(); + const sqls = statements.map((statement) => statement.compile(db).sql); + + expect(sqls).toHaveLength(3); + expect(sqls[0].toLowerCase()).toContain('add column if not exists'); + expect(sqls[0]).toContain(view.id().toRowOrderColumnName()); + expect(sqls[1]).toContain('"__auto_number"'); + expect(sqls[2].toLowerCase()).toBe( + `create index if not exists "idx_${view.id().toRowOrderColumnName()}" on "public"."table_data" ("${view.id().toRowOrderColumnName()}")` + ); + + const ensureVisitor = new TableSchemaUpdateVisitor({ + db, + schema: 'public', + tableName: 'table_data', + tableId: table.id().toString(), + table, + }); + const ensureSqls = ensureVisitor + .visitTableEnsureViewRowOrder(TableEnsureViewRowOrderSpec.create(view)) + ._unsafeUnwrap() + .map((statement) => statement.compile(db).sql); + expect(ensureSqls).toEqual(sqls); + }); + }); + describe('visitTableUpdateFieldConstraints', () => { describe('NOT NULL constraint', () => { it.todo( diff --git a/packages/v2/adapter-table-repository-postgres/src/shared/errors.spec.ts b/packages/v2/adapter-table-repository-postgres/src/shared/errors.spec.ts index 10e3a296c5..36c3a75118 100644 --- a/packages/v2/adapter-table-repository-postgres/src/shared/errors.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/shared/errors.spec.ts @@ -1,8 +1,9 @@ -import type { Field, IExecutionContext } from '@teable/v2-core'; +import type { Field } from '@teable/v2-core'; import { ok } from 'neverthrow'; import { describe, expect, it } from 'vitest'; import { createSchemaNotNullViolationError, + createSchemaUniqueViolationError, describeError, extractNotNullColumn, extractUniqueColumn, @@ -203,6 +204,50 @@ describe('PostgreSQL error utilities', () => { }); }); + describe('createSchemaUniqueViolationError', () => { + const fields = [ + stubField('fldAbc123', 'Email Address', 'fld_email'), + stubField('fldDef456', 'Other Field', 'fld_other'), + ]; + + it('returns a semantic unique-field message with field details and localization', () => { + const result = createSchemaUniqueViolationError( + { code: PG_UNIQUE_VIOLATION, constraint: 'test_table_fld_email_unique' }, + 'test_table', + fields + ); + + expect(result.tags).toContain('validation'); + expect(result.code).toBe('validation.field.unique_existing_values'); + expect(result.message).toBe( + 'Cannot mark field "Email Address" as unique because existing records contain duplicate values.' + ); + expect(result.details).toEqual({ + fieldId: 'fldAbc123', + fieldName: 'Email Address', + }); + expect(result.localization).toEqual({ + i18nKey: 'httpErrors.custom.fieldUniqueExistingValues', + context: { fieldName: 'Email Address' }, + }); + }); + + it('falls back to a generic message without localization when the field cannot be resolved', () => { + const result = createSchemaUniqueViolationError( + { code: PG_UNIQUE_VIOLATION, constraint: 'unrelated_constraint' }, + 'test_table', + fields + ); + + expect(result.code).toBe('validation.field.unique_existing_values'); + expect(result.message).toBe( + 'Cannot mark this field as unique because existing records contain duplicate values.' + ); + expect(result.details).toBeUndefined(); + expect(result.localization).toBeUndefined(); + }); + }); + describe('createSchemaNotNullViolationError', () => { const fields = [ stubField('fldDef456', 'Required Field', 'fld_required'), @@ -216,7 +261,7 @@ describe('PostgreSQL error utilities', () => { ); expect(result.tags).toContain('validation'); - expect(result.code).toBe('validation.field.not_null'); + expect(result.code).toBe('validation.field.required_existing_values'); expect(result.message).toBe( 'Cannot mark field "Required Field" as required because existing records contain empty values.' ); @@ -224,18 +269,10 @@ describe('PostgreSQL error utilities', () => { fieldId: 'fldDef456', fieldName: 'Required Field', }); - }); - - it('uses the execution-context translator when field name is known', () => { - const t: NonNullable = (key, options) => - `${key}:${String(options?.fieldName)}`; - const result = createSchemaNotNullViolationError( - { code: PG_NOT_NULL_VIOLATION, column: 'fld_required' }, - fields, - t - ); - - expect(result.message).toBe('validation.field.requiredExistingValues:Required Field'); + expect(result.localization).toEqual({ + i18nKey: 'httpErrors.custom.fieldRequiredExistingValues', + context: { fieldName: 'Required Field' }, + }); }); it('falls back to a generic semantic message when the column cannot be resolved', () => { @@ -244,11 +281,12 @@ describe('PostgreSQL error utilities', () => { fields ); - expect(result.code).toBe('validation.field.not_null'); + expect(result.code).toBe('validation.field.required_existing_values'); expect(result.message).toBe( 'Cannot mark this field as required because existing records contain empty values.' ); expect(result.details).toBeUndefined(); + expect(result.localization).toBeUndefined(); }); }); @@ -264,6 +302,7 @@ describe('PostgreSQL error utilities', () => { expect(result.tags).toContain('validation'); expect(result.code).toBe('validation.field.not_null'); expect(result.message).toBe('Cannot complete insert: field cannot be empty'); + expect(result.localization).toBeUndefined(); }); it('wraps unique violation as validation error', () => { @@ -282,6 +321,7 @@ describe('PostgreSQL error utilities', () => { expect(result.tags).toContain('validation'); expect(result.code).toBe('validation.link.one_one_duplicate'); expect(result.message).toContain('one-to-one relationship'); + expect(result.localization).toEqual({ i18nKey: 'httpErrors.custom.linkOneOneDuplicate' }); }); it('wraps unknown error as infrastructure error', () => { @@ -367,6 +407,10 @@ describe('PostgreSQL error utilities', () => { 'Cannot complete insert: field fldAbc123 must have a unique value' ); expect(result.details).toEqual({ fieldId: 'fldAbc123', fieldName: 'Email Address' }); + expect(result.localization).toEqual({ + i18nKey: 'httpErrors.custom.recordFieldValueDuplicate', + context: { fieldName: 'Email Address' }, + }); }); it('includes fieldId in message and fieldName in details for not-null violation', () => { @@ -383,6 +427,10 @@ describe('PostgreSQL error utilities', () => { expect(result.code).toBe('validation.field.not_null'); expect(result.message).toBe('Cannot complete insert: field fldDef456 cannot be empty'); expect(result.details).toEqual({ fieldId: 'fldDef456', fieldName: 'Required Field' }); + expect(result.localization).toEqual({ + i18nKey: 'httpErrors.custom.recordFieldValueNotNull', + context: { fieldName: 'Required Field' }, + }); }); it('falls back to generic unique message when column not in fields', () => { diff --git a/packages/v2/adapter-table-repository-postgres/src/shared/errors.ts b/packages/v2/adapter-table-repository-postgres/src/shared/errors.ts index 6074fd5742..d514093236 100644 --- a/packages/v2/adapter-table-repository-postgres/src/shared/errors.ts +++ b/packages/v2/adapter-table-repository-postgres/src/shared/errors.ts @@ -1,11 +1,5 @@ -import { tableI18nKeys } from '@teable/i18n-keys'; -import { - domainError, - isDomainError, - type DomainError, - type Field, - type IExecutionContext, -} from '@teable/v2-core'; +import { sdkErrorI18nKeys } from '@teable/i18n-keys'; +import { domainError, isDomainError, type DomainError, type Field } from '@teable/v2-core'; export const describeError = (error: unknown): string => { if (isDomainError(error)) return error.message; @@ -81,22 +75,6 @@ export interface WrapDatabaseErrorContext { fields?: ReadonlyArray; } -const i18nOrFallback = ( - t: IExecutionContext['$t'], - key: Parameters>[0], - fallback: string, - options?: Record -): string => { - if (!t) { - return fallback; - } - try { - return t(key, options); - } catch { - return fallback; - } -}; - /** * Extract the column name from a PostgreSQL not-null violation error. * PG includes the `column` property on 23502 errors. @@ -146,30 +124,52 @@ const findFieldByColumn = ( }); }; +export const createSchemaUniqueViolationError = ( + error: unknown, + tableName: string, + fields: ReadonlyArray | undefined +): DomainError => { + const column = extractUniqueColumn(error, tableName); + const field = findFieldByColumn(column, fields); + const fieldId = field?.id().toString(); + const fieldName = field?.name().toString(); + + return domainError.validation({ + message: fieldName + ? `Cannot mark field "${fieldName}" as unique because existing records contain duplicate values.` + : 'Cannot mark this field as unique because existing records contain duplicate values.', + code: 'validation.field.unique_existing_values', + ...(field && { details: { fieldId, fieldName } }), + ...(fieldName && { + localization: { + i18nKey: sdkErrorI18nKeys.custom.fieldUniqueExistingValues, + context: { fieldName }, + }, + }), + }); +}; + export const createSchemaNotNullViolationError = ( error: unknown, - fields: ReadonlyArray | undefined, - t?: IExecutionContext['$t'] + fields: ReadonlyArray | undefined ): DomainError => { const column = extractNotNullColumn(error); const field = findFieldByColumn(column, fields); const fieldId = field?.id().toString(); const fieldName = field?.name().toString(); - const fallback = fieldName - ? `Cannot mark field "${fieldName}" as required because existing records contain empty values.` - : 'Cannot mark this field as required because existing records contain empty values.'; return domainError.validation({ message: fieldName - ? i18nOrFallback( - t, - tableI18nKeys.validation.field.requiredExistingValues, - fallback, - { fieldName } - ) - : fallback, - code: 'validation.field.not_null', + ? `Cannot mark field "${fieldName}" as required because existing records contain empty values.` + : 'Cannot mark this field as required because existing records contain empty values.', + code: 'validation.field.required_existing_values', ...(field && { details: { fieldId, fieldName } }), + ...(fieldName && { + localization: { + i18nKey: sdkErrorI18nKeys.custom.fieldRequiredExistingValues, + context: { fieldName }, + }, + }), }); }; @@ -180,29 +180,35 @@ export const createSchemaNotNullViolationError = ( export const wrapDatabaseError = ( error: unknown, operation: DatabaseOperation, - context: WrapDatabaseErrorContext, - t?: IExecutionContext['$t'] + context: WrapDatabaseErrorContext ): DomainError => { // Check for link field unique constraint violation if (isLinkUniqueViolation(error)) { return domainError.validation({ - message: i18nOrFallback( - t, - tableI18nKeys.validation.link.one_one_duplicate, - `Cannot complete ${operation}: the target record is already linked by another record in a one-to-one relationship` - ), + message: `Cannot complete ${operation}: the target record is already linked by another record in a one-to-one relationship`, code: 'validation.link.one_one_duplicate', + localization: { i18nKey: sdkErrorI18nKeys.custom.linkOneOneDuplicate }, }); } + // For unique / not-null violations the field may not resolve from the PG + // constraint metadata; without a field name there is nothing to interpolate, + // so no localization is attached and the client shows the English message. if (isUniqueViolation(error)) { const column = extractUniqueColumn(error, context.tableName); const field = findFieldByColumn(column, context.fields); const fieldId = field?.id().toString(); + const fieldName = field?.name().toString(); return domainError.validation({ message: `Cannot complete ${operation}: field ${fieldId ?? ''} must have a unique value`, code: 'validation.field.unique', - ...(field && { details: { fieldId, fieldName: field.name().toString() } }), + ...(field && { details: { fieldId, fieldName } }), + ...(fieldName && { + localization: { + i18nKey: sdkErrorI18nKeys.custom.recordFieldValueDuplicate, + context: { fieldName }, + }, + }), }); } @@ -210,10 +216,17 @@ export const wrapDatabaseError = ( const column = extractNotNullColumn(error); const field = findFieldByColumn(column, context.fields); const fieldId = field?.id().toString(); + const fieldName = field?.name().toString(); return domainError.validation({ message: `Cannot complete ${operation}: field ${fieldId ?? ''} cannot be empty`, code: 'validation.field.not_null', - ...(field && { details: { fieldId, fieldName: field.name().toString() } }), + ...(field && { details: { fieldId, fieldName } }), + ...(fieldName && { + localization: { + i18nKey: sdkErrorI18nKeys.custom.recordFieldValueNotNull, + context: { fieldName }, + }, + }), }); } diff --git a/packages/v2/benchmark-node/src/row-ops.bench.ts b/packages/v2/benchmark-node/src/row-ops.bench.ts index 682ec2eda1..155bce5e8f 100644 --- a/packages/v2/benchmark-node/src/row-ops.bench.ts +++ b/packages/v2/benchmark-node/src/row-ops.bench.ts @@ -451,7 +451,9 @@ const getScenario = (tableCount: number, profileId: string): IScenarioState => { }; const setup = async () => { - const testContainer = await createV2NodeTestContainer(); + // The delete-row benches time the delete path; the record_trash sink would add + // one INSERT per iteration and skew the numbers. + const testContainer = await createV2NodeTestContainer({ trashSink: false }); testContainer.container.registerInstance(v2CoreTokens.logger, new NoopLogger()); dispose = testContainer.dispose; baseId = testContainer.baseId.toString(); diff --git a/packages/v2/command-explain/src/utils/FieldCommandExplainHarness.ts b/packages/v2/command-explain/src/utils/FieldCommandExplainHarness.ts index 566dffc08c..060e94152a 100644 --- a/packages/v2/command-explain/src/utils/FieldCommandExplainHarness.ts +++ b/packages/v2/command-explain/src/utils/FieldCommandExplainHarness.ts @@ -223,6 +223,7 @@ export class CaptureTableSchemaRepository implements ITableSchemaRepository { const captureCascadeStatements = this.captureCascadeStatements.bind(this); const captureCascadePlanStatements = this.captureCascadePlanStatements.bind(this); const db = this.options.db; + const typeValidationStrategy = this.options.typeValidationStrategy; return safeTry(async function* () { yield* ensureDbFieldNames(table.getFields()); @@ -240,6 +241,7 @@ export class CaptureTableSchemaRepository implements ITableSchemaRepository { tableName, tableId: table.id().toString(), table, + typeValidationStrategy, }); yield* mutateSpec.accept(visitor); diff --git a/packages/v2/container-node-test/src/SpyLogger.ts b/packages/v2/container-node-test/src/SpyLogger.ts index dcc0662ad5..d99c3e8da5 100644 --- a/packages/v2/container-node-test/src/SpyLogger.ts +++ b/packages/v2/container-node-test/src/SpyLogger.ts @@ -19,6 +19,8 @@ export interface ComputedPlanLogEntry { seedTableId: string; changeType?: 'insert' | 'update' | 'delete'; seedRecordIds: string[]; + /** Stage-ledger scope when the plan executes under staged budgets. */ + ledgerScopeId?: string; steps: Array<{ tableId: string; level: number; @@ -117,6 +119,21 @@ export class SpyLogger implements ILogger { return plans[plans.length - 1]; } + /** + * Seed groups the staged worker migrated into the stage-ledger frontier queue + * (floor entry). Together with plan seedRecordIds this reconstructs a task's + * effective seed set for assertions. + */ + getMigratedSeedGroups(): Array<{ tableId: string; recordIds: string[] }> { + return this.entries + .filter((e) => e.message === 'computed:worker:seeds_migrated_to_ledger') + .flatMap( + (e) => + (e.context as { migratedSeedGroups?: Array<{ tableId: string; recordIds: string[] }> }) + .migratedSeedGroups ?? [] + ); + } + /** * Clear all captured entries. */ diff --git a/packages/v2/container-node-test/src/TrashSinkEventBus.ts b/packages/v2/container-node-test/src/TrashSinkEventBus.ts new file mode 100644 index 0000000000..7cefd177c7 --- /dev/null +++ b/packages/v2/container-node-test/src/TrashSinkEventBus.ts @@ -0,0 +1,79 @@ +import { + MemoryEventBus, + RECORD_REMOVAL_REASON, + RecordsDeleted, + type IExecutionContext, +} from '@teable/v2-core'; +import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; +import type { Kysely } from 'kysely'; + +// record_trash is not part of V1TeableDatabase; the index signature makes the insert compile. +type TrashSinkDb = V1TeableDatabase & Record>; + +// The pure v2 container has no trash sink — the nestjs backend registers the +// RecordsDeleted → record_trash projection. The undo/redo replay of RestoreRecords +// consults record_trash (purge guard: rows the user purged must not resurrect), so +// the test container mimics the projection at the same seam: every published +// RecordsDeleted event writes one minimal row per deleted record. Archived removals +// persist their own write-ahead snapshots outside this container and are skipped, +// matching the backend projection. The snapshot column is a stub ('{}') — enough for +// the purge guard's existence check, useless for restore-from-snapshot tests. +// +// Row ids must be random, never a module-level counter: the database can outlive one +// test file (a shared testcontainers instance, or a pglite:// DIRECTORY path) while +// module state resets per file — sequential ids would collide on record_trash_pkey. +const trashSinkRowId = () => `rtrtest_${crypto.randomUUID()}`; + +export class TrashSinkEventBus extends MemoryEventBus { + private readonly db: Kysely; + + constructor( + handlerResolver: ConstructorParameters[0], + db: Kysely + ) { + super(handlerResolver); + this.db = db as unknown as Kysely; + } + + override async publish( + context: IExecutionContext, + event: Parameters[1] + ) { + return this.publishMany(context, [event]); + } + + override async publishMany( + context: IExecutionContext, + events: Parameters[1] + ) { + const result = await super.publishMany(context, events); + if (result.isOk()) { + await this.sinkDeleted(context, events); + } + return result; + } + + private async sinkDeleted( + context: IExecutionContext, + events: Parameters[1] + ) { + for (const event of events) { + if (!(event instanceof RecordsDeleted)) continue; + if (event.removalReason === RECORD_REMOVAL_REASON.Archived) continue; + if (event.recordIds.length === 0) continue; + await this.db + .insertInto('record_trash') + .values( + event.recordIds.map((recordId) => ({ + id: trashSinkRowId(), + table_id: event.tableId.toString(), + record_id: recordId.toString(), + snapshot: '{}', + created_by: context.actorId.toString(), + reason: RECORD_REMOVAL_REASON.Deleted, + })) + ) + .execute(); + } + } +} diff --git a/packages/v2/container-node-test/src/index.ts b/packages/v2/container-node-test/src/index.ts index 97566b71a8..99d04cadb9 100644 --- a/packages/v2/container-node-test/src/index.ts +++ b/packages/v2/container-node-test/src/index.ts @@ -36,11 +36,13 @@ import { StaticTableDataSafetyLimitPlugin, TableDataSafetyLimitCommandBusMiddleware, v2CoreTokens, - type IHasher, - type ILogger, - type ITableRepository, - type LogContext, - type TableDataSafetyLimitConfig, +} from '@teable/v2-core'; +import type { + IHasher, + ILogger, + ITableRepository, + LogContext, + TableDataSafetyLimitConfig, } from '@teable/v2-core'; import type { DependencyContainer } from '@teable/v2-di'; import { Lifecycle, container } from '@teable/v2-di'; @@ -51,6 +53,7 @@ import type { Kysely } from 'kysely'; import { sql } from 'kysely'; import { SpyLogger, type ComputedPlanLogEntry } from './SpyLogger'; +import { TrashSinkEventBus } from './TrashSinkEventBus'; /** * Node.js crypto-based hasher implementation for tests. @@ -137,6 +140,10 @@ export interface IV2NodeTestContainerOptions { computedUpdate?: IV2TableRepositoryPostgresConfig['computedUpdate']; logToConsole?: boolean; logLevel?: V2NodeTestContainerLogLevel; + // Set false to keep the plain MemoryEventBus without the record_trash sink — + // benchmarks measuring delete paths opt out so the extra INSERT per delete + // does not skew timings. + trashSink?: boolean; } export type V2NodeTestContainerLogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug'; @@ -385,7 +392,11 @@ export const createV2NodeTestContainer = async ( : [] ); const queryBus = new MemoryQueryBus(c); - const eventBus = new MemoryEventBus(c); + // The bus choice must happen here, before the eager singletons resolved below + // (ComputedUpdateWorker/Outbox) capture it by constructor injection — a + // replace-after-build at a call site would split events across two buses. + const eventBus = + options.trashSink === false ? new MemoryEventBus(c) : new TrashSinkEventBus(c, dataDb); c.registerInstance(v2CoreTokens.commandBus, commandBus); c.registerInstance(v2CoreTokens.internalCommandBus, commandBus); diff --git a/packages/v2/container-node/src/index.ts b/packages/v2/container-node/src/index.ts index da5e4d9e5d..e73ca1a077 100644 --- a/packages/v2/container-node/src/index.ts +++ b/packages/v2/container-node/src/index.ts @@ -80,6 +80,13 @@ export interface IV2NodePgContainerOptions { commandBusMiddlewares?: ReadonlyArray; queryBusMiddlewares?: ReadonlyArray; computedUpdate?: IV2TableRepositoryPostgresConfig['computedUpdate']; + /** + * Enable the delete-undo purge guard. Only turn this on when the hosting app + * writes record_trash rows for v2 deletes (nestjs V2RecordTrashService); + * standalone containers have no trash sink, and with the guard on every + * delete-undo would silently restore nothing. + */ + undoRedoRestorePurgeGuard?: boolean; tableQueryOps?: RegisterV2TableOpsOptions & { ensureSchema?: boolean; }; @@ -251,6 +258,12 @@ export const registerV2NodePgDependencies = async ( } c.registerInstance(v2CoreTokens.tableDataSafetyLimits, tableDataSafetyLimits); + // The delete-undo purge guard is opt-in: pre-register before the core + // defaults so registerV2CoreServices keeps the caller's choice. + c.registerInstance(v2CoreTokens.undoRedoReplayConfig, { + restorePurgeGuard: Boolean(options.undoRedoRestorePurgeGuard), + }); + // Register core services (uses defaults unless already registered) registerV2CoreServices(c, { lifecycle: Lifecycle.Singleton }); diff --git a/packages/v2/contract-http-client/src/index.ts b/packages/v2/contract-http-client/src/index.ts index ab69055c12..d7bf0d9a5b 100644 --- a/packages/v2/contract-http-client/src/index.ts +++ b/packages/v2/contract-http-client/src/index.ts @@ -60,6 +60,7 @@ export const createV2HttpClient = ( domainErrorCode: parsedError.data.error.code, tags: parsedError.data.error.tags, details: parsedError.data.error.details, + localization: parsedError.data.error.localization, }, }); } diff --git a/packages/v2/contract-http-express/package.json b/packages/v2/contract-http-express/package.json index 07cc01004e..8607fa214d 100644 --- a/packages/v2/contract-http-express/package.json +++ b/packages/v2/contract-http-express/package.json @@ -14,6 +14,12 @@ "types": "./src/index.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./table-query-ops": { + "@teable/source": "./src/tableQueryOps.ts", + "types": "./src/tableQueryOps.ts", + "import": "./dist/tableQueryOps.js", + "require": "./dist/tableQueryOps.cjs" } }, "files": [ diff --git a/packages/v2/contract-http-express/src/tableQueryOps.ts b/packages/v2/contract-http-express/src/tableQueryOps.ts new file mode 100644 index 0000000000..98a841a318 --- /dev/null +++ b/packages/v2/contract-http-express/src/tableQueryOps.ts @@ -0,0 +1,31 @@ +import type { IHandlerResolver } from '@teable/v2-contract-http'; +import { createV2TableQueryOpsOrpcRouter } from '@teable/v2-contract-http-implementation/table-query-ops'; +import { createV2OpenApiNodeHandler } from '@teable/v2-contract-http-openapi'; +import type { IExecutionContext } from '@teable/v2-core'; +import * as express from 'express'; + +export interface IV2TableQueryOpsExpressRouterOptions { + createContainer?: () => IHandlerResolver | Promise; + createExecutionContext?: () => IExecutionContext | Promise; + allowSearchAccessPathMutation?: boolean; +} + +export const createV2TableQueryOpsExpressRouter = ( + options: IV2TableQueryOpsExpressRouterOptions = {} +): express.Router => { + const router = express.Router(); + const orpcRouter = createV2TableQueryOpsOrpcRouter({ + createContainer: options.createContainer, + createExecutionContext: options.createExecutionContext, + allowSearchAccessPathMutation: options.allowSearchAccessPathMutation, + }); + const handler = createV2OpenApiNodeHandler(orpcRouter); + + router.use(async (req, res, next) => { + const result = await handler.handle(req, res, { context: {} }); + if (result.matched) return; + next(); + }); + + return router; +}; diff --git a/packages/v2/contract-http-express/tsdown.config.ts b/packages/v2/contract-http-express/tsdown.config.ts index 608e210bc5..d260b006b8 100644 --- a/packages/v2/contract-http-express/tsdown.config.ts +++ b/packages/v2/contract-http-express/tsdown.config.ts @@ -1,4 +1,7 @@ import { v2TsdownBaseConfig } from '@teable/v2-tsdown-config'; import { defineConfig } from 'tsdown'; -export default defineConfig(v2TsdownBaseConfig); +export default defineConfig({ + ...v2TsdownBaseConfig, + entry: ['src/index.ts', 'src/tableQueryOps.ts'], +}); diff --git a/packages/v2/contract-http-implementation/package.json b/packages/v2/contract-http-implementation/package.json index 715e88d310..55131642aa 100644 --- a/packages/v2/contract-http-implementation/package.json +++ b/packages/v2/contract-http-implementation/package.json @@ -22,6 +22,13 @@ "import": "./dist/handlers/index.js", "module": "./dist/handlers/index.js", "require": "./dist/handlers/index.cjs" + }, + "./table-query-ops": { + "@teable/source": "./src/tableQueryOpsRouter.ts", + "types": "./src/tableQueryOpsRouter.ts", + "import": "./dist/tableQueryOpsRouter.js", + "module": "./dist/tableQueryOpsRouter.js", + "require": "./dist/tableQueryOpsRouter.cjs" } }, "files": [ @@ -43,7 +50,8 @@ "@teable/v2-command-explain": "workspace:*", "@teable/v2-container-node": "workspace:*", "@teable/v2-core": "workspace:*", - "@teable/v2-contract-http": "workspace:*" + "@teable/v2-contract-http": "workspace:*", + "@teable/v2-table-query-ops": "workspace:*" }, "devDependencies": { "@teable/eslint-config-bases": "workspace:^", diff --git a/packages/v2/contract-http-implementation/src/handlers/bases/duplicateBase.ts b/packages/v2/contract-http-implementation/src/handlers/bases/duplicateBase.ts new file mode 100644 index 0000000000..78cd1b0944 --- /dev/null +++ b/packages/v2/contract-http-implementation/src/handlers/bases/duplicateBase.ts @@ -0,0 +1,46 @@ +import type { IDuplicateBaseEndpointResult } from '@teable/v2-contract-http'; +import { + mapDomainErrorToHttpError, + mapDomainErrorToHttpStatus, + mapDuplicateBaseResultToDto, +} from '@teable/v2-contract-http'; +import { DuplicateBaseByIdCommand } from '@teable/v2-core'; +import type { DuplicateBaseByIdResult, ICommandBus, IExecutionContext } from '@teable/v2-core'; + +export const executeDuplicateBaseEndpoint = async ( + context: IExecutionContext, + rawBody: unknown, + commandBus: ICommandBus +): Promise => { + const commandResult = DuplicateBaseByIdCommand.create(rawBody); + if (commandResult.isErr()) { + const error = commandResult.error; + return { + status: mapDomainErrorToHttpStatus(error), + body: { ok: false, error: mapDomainErrorToHttpError(error) }, + }; + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + const error = result.error; + return { + status: mapDomainErrorToHttpStatus(error), + body: { ok: false, error: mapDomainErrorToHttpError(error) }, + }; + } + + const mapped = mapDuplicateBaseResultToDto(result.value); + if (mapped.isErr()) { + const error = mapped.error; + return { + status: mapDomainErrorToHttpStatus(error), + body: { ok: false, error: mapDomainErrorToHttpError(error) }, + }; + } + + return { status: 201, body: { ok: true, data: mapped.value } }; +}; diff --git a/packages/v2/contract-http-implementation/src/handlers/bases/index.ts b/packages/v2/contract-http-implementation/src/handlers/bases/index.ts index 5c53d9330f..6bb77d8b25 100644 --- a/packages/v2/contract-http-implementation/src/handlers/bases/index.ts +++ b/packages/v2/contract-http-implementation/src/handlers/bases/index.ts @@ -1,2 +1,3 @@ export * from './createBase'; +export * from './duplicateBase'; export * from './listBases'; diff --git a/packages/v2/contract-http-implementation/src/handlers/index.ts b/packages/v2/contract-http-implementation/src/handlers/index.ts index 99b78652a1..ff8f5ebd98 100644 --- a/packages/v2/contract-http-implementation/src/handlers/index.ts +++ b/packages/v2/contract-http-implementation/src/handlers/index.ts @@ -1,2 +1,3 @@ export * from './bases'; export * from './tables'; +export * from './tableQueryOps'; diff --git a/packages/v2/contract-http-implementation/src/handlers/tableQueryOps/index.ts b/packages/v2/contract-http-implementation/src/handlers/tableQueryOps/index.ts new file mode 100644 index 0000000000..c07e42fe19 --- /dev/null +++ b/packages/v2/contract-http-implementation/src/handlers/tableQueryOps/index.ts @@ -0,0 +1 @@ +export * from './searchAccessPath'; diff --git a/packages/v2/contract-http-implementation/src/handlers/tableQueryOps/searchAccessPath.spec.ts b/packages/v2/contract-http-implementation/src/handlers/tableQueryOps/searchAccessPath.spec.ts new file mode 100644 index 0000000000..aacbc6f9b8 --- /dev/null +++ b/packages/v2/contract-http-implementation/src/handlers/tableQueryOps/searchAccessPath.spec.ts @@ -0,0 +1,162 @@ +import type { IExecutionContext, ITableRepository, Table } from '@teable/v2-core'; +import type { + TableSearchAccessPathCapabilityReader, + TableSearchAccessPathReconciler, + TableSearchVectorStatusReader, +} from '@teable/v2-table-query-ops'; +import { describe, expect, it, vi } from 'vitest'; + +import { + executeGetSearchAccessPathCapabilitiesEndpoint, + executeGetSearchAccessPathStatusEndpoint, + executeReconcileSearchAccessPathEndpoint, +} from './searchAccessPath'; + +const tableId = `tbl${'a'.repeat(16)}`; +const context = {} as IExecutionContext; +const table = {} as Table; +const ok = (value: T) => ({ isErr: () => false, value }); + +const tableRepository = (value: Table | null = table) => + ({ + findOne: vi.fn().mockResolvedValue(ok(value)), + }) as unknown as ITableRepository; + +describe('search access path HTTP handlers', () => { + it('returns native managed status for an existing table', async () => { + const statusReader = { + read: vi.fn().mockResolvedValue( + ok({ + tableId, + state: 'ready', + configured: true, + semantics: 'substring', + provider: 'pg_trgm', + accessPath: 'generated_text', + coveredFieldCount: 2, + }) + ), + } as TableSearchVectorStatusReader; + + const result = await executeGetSearchAccessPathStatusEndpoint( + context, + { tableId }, + tableRepository(), + statusReader + ); + + expect(result).toMatchObject({ + status: 200, + body: { ok: true, data: { status: { state: 'ready', coveredFieldCount: 2 } } }, + }); + expect(statusReader.read).toHaveBeenCalledWith(context, tableId); + }); + + it('returns not found before reading status for a missing table', async () => { + const statusReader = { + read: vi.fn(), + } as unknown as TableSearchVectorStatusReader; + + const result = await executeGetSearchAccessPathStatusEndpoint( + context, + { tableId }, + tableRepository(null), + statusReader + ); + + expect(result.status).toBe(404); + expect(statusReader.read).not.toHaveBeenCalled(); + }); + + it('returns database capabilities through the capability reader', async () => { + const capabilityReader = { + read: vi.fn().mockResolvedValue( + ok([ + { + provider: 'pg_trgm', + extensionName: 'pg_trgm', + operatorClass: 'gin_trgm_ops', + operatorClassInstalled: true, + minimumProbeLength: 3, + state: 'ready', + installed: true, + available: true, + preloaded: true, + }, + ]) + ), + } as TableSearchAccessPathCapabilityReader; + + const result = await executeGetSearchAccessPathCapabilitiesEndpoint(context, capabilityReader); + + expect(result).toMatchObject({ + status: 200, + body: { ok: true, data: { capabilities: [{ provider: 'pg_trgm', state: 'ready' }] } }, + }); + }); + + it('rejects reconcile when the host mutation guard is disabled', async () => { + const reconciler = { reconcile: vi.fn() } as unknown as TableSearchAccessPathReconciler; + + const result = await executeReconcileSearchAccessPathEndpoint( + context, + { tableId, mode: 'rebuild' }, + tableRepository(), + reconciler, + false + ); + + expect(result).toMatchObject({ + status: 403, + body: { + ok: false, + error: { code: 'table_query_ops.search_access_path_mutation_disabled' }, + }, + }); + expect(reconciler.reconcile).not.toHaveBeenCalled(); + }); + + it('delegates an allowed reconcile with guarded adapter controls', async () => { + const reconciler = { + reconcile: vi.fn().mockResolvedValue( + ok({ + action: 'created', + tableId, + definitionKey: 'definition-key', + generatedColumnName: '__teable_search', + indexName: 'idx_teable_search', + languageConfig: 'simple', + semantics: 'substring', + provider: 'pg_trgm', + fieldIds: ['fld-primary'], + status: 'ready', + }) + ), + } as TableSearchAccessPathReconciler; + + const result = await executeReconcileSearchAccessPathEndpoint( + context, + { + tableId, + mode: 'create', + semantics: 'substring', + provider: 'pg_trgm', + fieldIds: ['fld-primary'], + }, + tableRepository(), + reconciler, + true + ); + + expect(result.status).toBe(200); + expect(reconciler.reconcile).toHaveBeenCalledWith( + context, + expect.objectContaining({ + table, + mode: 'create', + validationMode: 'real_ddl', + allowLargeTableRewrite: false, + }) + ); + }); +}); diff --git a/packages/v2/contract-http-implementation/src/handlers/tableQueryOps/searchAccessPath.ts b/packages/v2/contract-http-implementation/src/handlers/tableQueryOps/searchAccessPath.ts new file mode 100644 index 0000000000..28280bd323 --- /dev/null +++ b/packages/v2/contract-http-implementation/src/handlers/tableQueryOps/searchAccessPath.ts @@ -0,0 +1,128 @@ +import type { + IGetSearchAccessPathCapabilitiesEndpointResult, + IGetSearchAccessPathStatusEndpointResult, + IGetSearchAccessPathStatusInput, + IReconcileSearchAccessPathEndpointResult, + IReconcileSearchAccessPathInput, +} from '@teable/v2-contract-http'; +import { mapDomainErrorToHttpError, mapDomainErrorToHttpStatus } from '@teable/v2-contract-http'; +import { + domainError, + TableByIdSpec, + TableId, + type DomainError, + type IExecutionContext, + type ITableRepository, + type Table, +} from '@teable/v2-core'; +import type { + ReconcileTableSearchAccessPathInput, + TableSearchAccessPathCapabilityReader, + TableSearchAccessPathReconciler, + TableSearchVectorStatusReader, +} from '@teable/v2-table-query-ops'; + +const errorResult = (error: DomainError) => ({ + status: mapDomainErrorToHttpStatus(error), + body: { ok: false as const, error: mapDomainErrorToHttpError(error) }, +}); + +const findTable = async ( + context: IExecutionContext, + tableId: string, + tableRepository: ITableRepository +): Promise<{ table: Table } | { error: DomainError }> => { + const tableIdResult = TableId.create(tableId); + if (tableIdResult.isErr()) return { error: tableIdResult.error }; + + const tableResult = await tableRepository.findOne( + context, + TableByIdSpec.create(tableIdResult.value) + ); + if (tableResult.isErr()) return { error: tableResult.error }; + if (!tableResult.value) { + return { + error: domainError.notFound({ + code: 'table.not_found', + message: 'Table not found', + details: { tableId }, + }), + }; + } + return { table: tableResult.value }; +}; + +export const executeGetSearchAccessPathStatusEndpoint = async ( + context: IExecutionContext, + input: IGetSearchAccessPathStatusInput, + tableRepository: ITableRepository, + statusReader: TableSearchVectorStatusReader +): Promise => { + const tableResult = await findTable(context, input.tableId, tableRepository); + if ('error' in tableResult) return errorResult(tableResult.error); + + const statusResult = await statusReader.read(context, input.tableId); + if (statusResult.isErr()) return errorResult(statusResult.error); + return { status: 200, body: { ok: true, data: { status: statusResult.value } } }; +}; + +export const executeGetSearchAccessPathCapabilitiesEndpoint = async ( + context: IExecutionContext, + capabilityReader: TableSearchAccessPathCapabilityReader +): Promise => { + const capabilitiesResult = await capabilityReader.read(context); + if (capabilitiesResult.isErr()) return errorResult(capabilitiesResult.error); + return { + status: 200, + body: { ok: true, data: { capabilities: [...capabilitiesResult.value] } }, + }; +}; + +export const executeReconcileSearchAccessPathEndpoint = async ( + context: IExecutionContext, + input: IReconcileSearchAccessPathInput, + tableRepository: ITableRepository, + reconciler: TableSearchAccessPathReconciler, + allowSearchAccessPathMutation: boolean +): Promise => { + if (!allowSearchAccessPathMutation) { + return errorResult( + domainError.forbidden({ + code: 'table_query_ops.search_access_path_mutation_disabled', + message: 'Managed search access-path mutation is disabled by the host', + }) + ); + } + + const tableResult = await findTable(context, input.tableId, tableRepository); + if ('error' in tableResult) return errorResult(tableResult.error); + + const reconcileInput: ReconcileTableSearchAccessPathInput = { + table: tableResult.table, + mode: input.mode, + ...(input.expectedDefinitionKey !== undefined + ? { expectedDefinitionKey: input.expectedDefinitionKey } + : {}), + ...(input.semantics !== undefined ? { semantics: input.semantics } : {}), + ...(input.provider !== undefined ? { provider: input.provider } : {}), + ...(input.languageConfig !== undefined ? { languageConfig: input.languageConfig } : {}), + ...(input.fieldIds !== undefined ? { fieldIds: input.fieldIds } : {}), + ...(input.searchProbe !== undefined ? { searchProbe: input.searchProbe } : {}), + validationMode: 'real_ddl', + allowLargeTableRewrite: false, + }; + const reconcileResult = await reconciler.reconcile(context, reconcileInput); + if (reconcileResult.isErr()) return errorResult(reconcileResult.error); + return { + status: 200, + body: { + ok: true, + data: { + result: { + ...reconcileResult.value, + fieldIds: [...reconcileResult.value.fieldIds], + }, + }, + }, + }; +}; diff --git a/packages/v2/contract-http-implementation/src/handlers/tables/createView.ts b/packages/v2/contract-http-implementation/src/handlers/tables/createView.ts new file mode 100644 index 0000000000..06152270ac --- /dev/null +++ b/packages/v2/contract-http-implementation/src/handlers/tables/createView.ts @@ -0,0 +1,52 @@ +import type { ICreateViewEndpointResult } from '@teable/v2-contract-http'; +import { + mapCreateViewResultToDto, + mapDomainErrorToHttpError, + mapDomainErrorToHttpStatus, +} from '@teable/v2-contract-http'; +import { CreateViewCommand } from '@teable/v2-core'; +import type { CreateViewResult, ICommandBus, IExecutionContext } from '@teable/v2-core'; + +export const executeCreateViewEndpoint = async ( + context: IExecutionContext, + rawBody: unknown, + commandBus: ICommandBus +): Promise => { + const commandResult = CreateViewCommand.create(rawBody); + if (commandResult.isErr()) { + const error = commandResult.error; + return { + status: mapDomainErrorToHttpStatus(error), + body: { ok: false, error: mapDomainErrorToHttpError(error) }, + }; + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + const error = result.error; + return { + status: mapDomainErrorToHttpStatus(error), + body: { ok: false, error: mapDomainErrorToHttpError(error) }, + }; + } + + const mapped = mapCreateViewResultToDto(result.value); + if (mapped.isErr()) { + const error = mapped.error; + return { + status: mapDomainErrorToHttpStatus(error), + body: { ok: false, error: mapDomainErrorToHttpError(error) }, + }; + } + + return { + status: 200, + body: { + ok: true, + data: mapped.value, + }, + }; +}; diff --git a/packages/v2/contract-http-implementation/src/handlers/tables/deleteRecords.ts b/packages/v2/contract-http-implementation/src/handlers/tables/deleteRecords.ts index 80a3bdb3b5..eec778f732 100644 --- a/packages/v2/contract-http-implementation/src/handlers/tables/deleteRecords.ts +++ b/packages/v2/contract-http-implementation/src/handlers/tables/deleteRecords.ts @@ -5,14 +5,21 @@ import { mapDomainErrorToHttpStatus, } from '@teable/v2-contract-http'; import { DeleteRecordsCommand } from '@teable/v2-core'; -import type { DeleteRecordsResult, ICommandBus, IExecutionContext } from '@teable/v2-core'; +import type { + DeleteRecordsResult, + ICommandBus, + IDeleteRecordsCommandOptions, + IExecutionContext, +} from '@teable/v2-core'; export const executeDeleteRecordsEndpoint = async ( context: IExecutionContext, rawBody: unknown, - commandBus: ICommandBus + commandBus: ICommandBus, + // Internal-only options (e.g. archive removal); never populated from the HTTP contract. + options?: IDeleteRecordsCommandOptions ): Promise => { - const commandResult = DeleteRecordsCommand.create(rawBody); + const commandResult = DeleteRecordsCommand.create(rawBody, options); if (commandResult.isErr()) { const error = commandResult.error; return { diff --git a/packages/v2/contract-http-implementation/src/handlers/tables/getView.ts b/packages/v2/contract-http-implementation/src/handlers/tables/getView.ts new file mode 100644 index 0000000000..2eb9d7720a --- /dev/null +++ b/packages/v2/contract-http-implementation/src/handlers/tables/getView.ts @@ -0,0 +1,40 @@ +import type { IGetViewEndpointResult } from '@teable/v2-contract-http'; +import { + mapDomainErrorToHttpError, + mapDomainErrorToHttpStatus, + mapGetViewResultToDto, +} from '@teable/v2-contract-http'; +import { GetViewQuery } from '@teable/v2-core'; +import type { GetViewResult, IExecutionContext, IQueryBus } from '@teable/v2-core'; + +export const executeGetViewEndpoint = async ( + context: IExecutionContext, + rawInput: unknown, + queryBus: IQueryBus +): Promise => { + const queryResult = GetViewQuery.create(rawInput); + if (queryResult.isErr()) { + const error = queryResult.error; + return { + status: mapDomainErrorToHttpStatus(error), + body: { ok: false, error: mapDomainErrorToHttpError(error) }, + }; + } + + const result = await queryBus.execute(context, queryResult.value); + if (result.isErr()) { + const error = result.error; + return { + status: mapDomainErrorToHttpStatus(error), + body: { ok: false, error: mapDomainErrorToHttpError(error) }, + }; + } + + return { + status: 200, + body: { + ok: true, + data: mapGetViewResultToDto(result.value), + }, + }; +}; diff --git a/packages/v2/contract-http-implementation/src/handlers/tables/index.ts b/packages/v2/contract-http-implementation/src/handlers/tables/index.ts index 370d8ac368..6ce623178a 100644 --- a/packages/v2/contract-http-implementation/src/handlers/tables/index.ts +++ b/packages/v2/contract-http-implementation/src/handlers/tables/index.ts @@ -4,6 +4,7 @@ export * from './submitRecord'; export * from './createRecords'; export * from './createTable'; export * from './createTables'; +export * from './createView'; export * from './deleteField'; export * from './deleteRecords'; export * from './deleteTable'; @@ -11,15 +12,19 @@ export * from './restoreTable'; export * from './explainCommand'; export * from './getRecordById'; export * from './getTableById'; +export * from './getView'; export * from './getComputeActivity'; export * from './importCsv'; export * from './importRecords'; export * from './listTableRecords'; export * from './listTables'; +export * from './listViews'; +export * from './viewOperations'; export * from './paste'; export * from './clear'; export * from './deleteByRange'; export * from './renameTable'; +export * from './updateTableProperties'; export * from './updateRecord'; export * from './updateField'; export * from './updateRecords'; diff --git a/packages/v2/contract-http-implementation/src/handlers/tables/listTableRecords.ts b/packages/v2/contract-http-implementation/src/handlers/tables/listTableRecords.ts index b0672f7d5b..c538f02374 100644 --- a/packages/v2/contract-http-implementation/src/handlers/tables/listTableRecords.ts +++ b/packages/v2/contract-http-implementation/src/handlers/tables/listTableRecords.ts @@ -11,9 +11,12 @@ import type { IRecordReadQuerySource, IRecordSearchAccessPath, ListTableRecordsResult, + RecordQueryPluginScope, } from '@teable/v2-core'; export interface IListTableRecordsEndpointOptions { + readonly queryScope?: RecordQueryPluginScope; + /** @deprecated Prefer queryScope */ readonly recordReadQuerySource?: IRecordReadQuerySource; readonly recordSearchAccessPath?: IRecordSearchAccessPath; } diff --git a/packages/v2/contract-http-implementation/src/handlers/tables/listViews.ts b/packages/v2/contract-http-implementation/src/handlers/tables/listViews.ts new file mode 100644 index 0000000000..9daadad95c --- /dev/null +++ b/packages/v2/contract-http-implementation/src/handlers/tables/listViews.ts @@ -0,0 +1,43 @@ +import type { IListViewsEndpointResult } from '@teable/v2-contract-http'; +import { + mapDomainErrorToHttpError, + mapDomainErrorToHttpStatus, + mapListViewsResultToDto, +} from '@teable/v2-contract-http'; +import { ListViewsQuery } from '@teable/v2-core'; +import type { IExecutionContext, IQueryBus, ListViewsResult } from '@teable/v2-core'; + +export const executeListViewsEndpoint = async ( + context: IExecutionContext, + rawInput: unknown, + queryBus: IQueryBus +): Promise => { + const queryResult = ListViewsQuery.create(rawInput); + if (queryResult.isErr()) { + const error = queryResult.error; + return { + status: mapDomainErrorToHttpStatus(error), + body: { ok: false, error: mapDomainErrorToHttpError(error) }, + }; + } + + const result = await queryBus.execute( + context, + queryResult.value + ); + if (result.isErr()) { + const error = result.error; + return { + status: mapDomainErrorToHttpStatus(error), + body: { ok: false, error: mapDomainErrorToHttpError(error) }, + }; + } + + return { + status: 200, + body: { + ok: true, + data: mapListViewsResultToDto(result.value), + }, + }; +}; diff --git a/packages/v2/contract-http-implementation/src/handlers/tables/updateTableProperties.ts b/packages/v2/contract-http-implementation/src/handlers/tables/updateTableProperties.ts new file mode 100644 index 0000000000..85e3a25bf0 --- /dev/null +++ b/packages/v2/contract-http-implementation/src/handlers/tables/updateTableProperties.ts @@ -0,0 +1,50 @@ +import { + mapDomainErrorToHttpError, + mapDomainErrorToHttpStatus, + mapUpdateTablePropertiesResultToDto, + type IUpdateTablePropertiesEndpointResult, +} from '@teable/v2-contract-http'; +import { + UpdateTablePropertiesCommand, + type ICommandBus, + type IExecutionContext, + type UpdateTablePropertiesResult, +} from '@teable/v2-core'; + +export const executeUpdateTablePropertiesEndpoint = async ( + context: IExecutionContext, + rawBody: unknown, + commandBus: ICommandBus +): Promise => { + const commandResult = UpdateTablePropertiesCommand.create(rawBody); + if (commandResult.isErr()) { + const error = commandResult.error; + return { + status: mapDomainErrorToHttpStatus(error), + body: { ok: false, error: mapDomainErrorToHttpError(error) }, + }; + } + + const result = await commandBus.execute< + UpdateTablePropertiesCommand, + UpdateTablePropertiesResult + >(context, commandResult.value); + if (result.isErr()) { + const error = result.error; + return { + status: mapDomainErrorToHttpStatus(error), + body: { ok: false, error: mapDomainErrorToHttpError(error) }, + }; + } + + const mapped = mapUpdateTablePropertiesResultToDto(result.value); + if (mapped.isErr()) { + const error = mapped.error; + return { + status: mapDomainErrorToHttpStatus(error), + body: { ok: false, error: mapDomainErrorToHttpError(error) }, + }; + } + + return { status: 200, body: { ok: true, data: mapped.value } }; +}; diff --git a/packages/v2/contract-http-implementation/src/handlers/tables/viewOperations.ts b/packages/v2/contract-http-implementation/src/handlers/tables/viewOperations.ts new file mode 100644 index 0000000000..68f3473e96 --- /dev/null +++ b/packages/v2/contract-http-implementation/src/handlers/tables/viewOperations.ts @@ -0,0 +1,448 @@ +import { + installViewPluginInputSchema, + mapApplyViewManualSortResultToDto, + mapDomainErrorToHttpError, + mapDomainErrorToHttpStatus, + mapGetViewFilterLinkRecordsResultToDto, + mapGetViewPluginInstallResultToDto, + mapGetViewSnapshotsResultToDto, + mapInstallViewPluginResultToDto, + mapUpdateViewPluginStorageResultToDto, + mapViewMutationResultToDto, + mapViewShareMutationResultToDto, + mapViewShareStateResultToDto, +} from '@teable/v2-contract-http'; +import type { + HttpErrorStatus, + IApiErrorResponseDto, + IApiOkResponseDto, + IApplyViewManualSortResponseDataDto, + IGetViewFilterLinkRecordsResponseDataDto, + IGetViewPluginInstallResponseDataDto, + IGetViewSnapshotsResponseDataDto, + IInstallViewPluginResponseDataDto, + IUpdateViewPluginStorageResponseDataDto, + IViewMutationResponseDataDto, + IViewShareMutationResponseDataDto, + IViewShareStateResponseDataDto, +} from '@teable/v2-contract-http'; +import { + ApplyViewManualSortCommand, + type ApplyViewManualSortResult, + CreateViewCommand, + type CreateViewResult, + DeleteViewCommand, + type DeleteViewResult, + DisableViewShareCommand, + type DisableViewShareResult, + domainError, + DuplicateViewCommand, + type DuplicateViewResult, + EnableViewShareCommand, + type EnableViewShareResult, + GetViewFilterLinkRecordsQuery, + type GetViewFilterLinkRecordsResult, + GetViewPluginInstallQuery, + type GetViewPluginInstallResult, + GetViewSnapshotsQuery, + type GetViewSnapshotsResult, + type ICommandBus, + type DomainError, + type IExecutionContext, + type IPublicCommand, + type Result, + type IQueryBus, + ListViewsQuery, + type ListViewsResult, + RefreshViewShareIdCommand, + type RefreshViewShareIdResult, + RenameViewCommand, + type RenameViewResult, + UpdateViewColumnMetaCommand, + type UpdateViewColumnMetaResult, + UpdateViewDescriptionCommand, + type UpdateViewDescriptionResult, + UpdateViewFilterCommand, + type UpdateViewFilterResult, + UpdateViewGroupCommand, + type UpdateViewGroupResult, + UpdateViewLockedCommand, + type UpdateViewLockedResult, + UpdateViewOptionsCommand, + type UpdateViewOptionsResult, + UpdateViewOrderCommand, + type UpdateViewOrderResult, + UpdateViewPluginStorageCommand, + type UpdateViewPluginStorageResult, + UpdateViewShareMetaCommand, + type UpdateViewShareMetaResult, + UpdateViewSortCommand, + type UpdateViewSortResult, +} from '@teable/v2-core'; + +type EndpointResult = + | { status: 200; body: IApiOkResponseDto } + | { status: HttpErrorStatus; body: IApiErrorResponseDto }; + +type Factory = { + create(raw: unknown): Result; +}; + +const errorEndpointResult = (error: DomainError): EndpointResult => ({ + status: mapDomainErrorToHttpStatus(error), + body: { ok: false, error: mapDomainErrorToHttpError(error) }, +}); + +const executeCommand = async ( + context: IExecutionContext, + rawBody: unknown, + commandBus: ICommandBus, + factory: Factory, + mapResult: (result: TResult) => Result +): Promise> => { + const commandResult = factory.create(rawBody); + if (commandResult.isErr()) return errorEndpointResult(commandResult.error); + + const result = await commandBus.execute(context, commandResult.value); + if (result.isErr()) return errorEndpointResult(result.error); + + const mapped = mapResult(result.value); + if (mapped.isErr()) return errorEndpointResult(mapped.error); + return { status: 200, body: { ok: true, data: mapped.value } }; +}; + +const executePlainCommand = async ( + context: IExecutionContext, + rawBody: unknown, + commandBus: ICommandBus, + factory: Factory, + mapResult: (result: TResult) => TData +): Promise> => { + const commandResult = factory.create(rawBody); + if (commandResult.isErr()) return errorEndpointResult(commandResult.error); + + const result = await commandBus.execute(context, commandResult.value); + if (result.isErr()) return errorEndpointResult(result.error); + return { status: 200, body: { ok: true, data: mapResult(result.value) } }; +}; + +const executeQuery = async ( + context: IExecutionContext, + rawInput: unknown, + queryBus: IQueryBus, + factory: Factory, + mapResult: (result: TResult) => TData +): Promise> => { + const queryResult = factory.create(rawInput); + if (queryResult.isErr()) return errorEndpointResult(queryResult.error); + + const result = await queryBus.execute(context, queryResult.value); + if (result.isErr()) return errorEndpointResult(result.error); + return { status: 200, body: { ok: true, data: mapResult(result.value) } }; +}; + +export const executeDeleteViewEndpoint = ( + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => + executeCommand( + context, + input, + commandBus, + DeleteViewCommand, + mapViewMutationResultToDto + ); + +export const executeDuplicateViewEndpoint = ( + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => + executeCommand( + context, + input, + commandBus, + DuplicateViewCommand, + mapViewMutationResultToDto + ); + +type ViewMutationResult = Parameters[0]; + +const executeMutation = ( + factory: Factory, + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => + executeCommand( + context, + input, + commandBus, + factory, + mapViewMutationResultToDto + ); + +export const executeRenameViewEndpoint = ( + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => + executeMutation( + RenameViewCommand, + context, + input, + commandBus + ); + +export const executeUpdateViewDescriptionEndpoint = ( + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => + executeMutation( + UpdateViewDescriptionCommand, + context, + input, + commandBus + ); + +export const executeUpdateViewLockedEndpoint = ( + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => + executeMutation( + UpdateViewLockedCommand, + context, + input, + commandBus + ); + +export const executeUpdateViewOrderEndpoint = ( + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => + executeMutation( + UpdateViewOrderCommand, + context, + input, + commandBus + ); + +export const executeUpdateViewColumnMetaEndpoint = ( + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => + executeMutation( + UpdateViewColumnMetaCommand, + context, + input, + commandBus + ); + +export const executeUpdateViewFilterEndpoint = ( + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => + executeMutation( + UpdateViewFilterCommand, + context, + input, + commandBus + ); + +export const executeUpdateViewSortEndpoint = ( + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => + executeMutation( + UpdateViewSortCommand, + context, + input, + commandBus + ); + +export const executeUpdateViewGroupEndpoint = ( + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => + executeMutation( + UpdateViewGroupCommand, + context, + input, + commandBus + ); + +export const executeUpdateViewOptionsEndpoint = ( + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => + executeMutation( + UpdateViewOptionsCommand, + context, + input, + commandBus + ); + +export const executeApplyViewManualSortEndpoint = ( + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => + executeCommand< + ApplyViewManualSortCommand, + ApplyViewManualSortResult, + IApplyViewManualSortResponseDataDto + >(context, input, commandBus, ApplyViewManualSortCommand, mapApplyViewManualSortResultToDto); + +export const executeUpdateViewShareMetaEndpoint = ( + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => + executePlainCommand< + UpdateViewShareMetaCommand, + UpdateViewShareMetaResult, + IViewShareStateResponseDataDto + >(context, input, commandBus, UpdateViewShareMetaCommand, mapViewShareStateResultToDto); + +export const executeRefreshViewShareIdEndpoint = ( + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => + executePlainCommand< + RefreshViewShareIdCommand, + RefreshViewShareIdResult, + IViewShareMutationResponseDataDto + >(context, input, commandBus, RefreshViewShareIdCommand, mapViewShareMutationResultToDto); + +export const executeEnableViewShareEndpoint = ( + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => + executePlainCommand< + EnableViewShareCommand, + EnableViewShareResult, + IViewShareMutationResponseDataDto + >(context, input, commandBus, EnableViewShareCommand, mapViewShareMutationResultToDto); + +export const executeDisableViewShareEndpoint = ( + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => + executePlainCommand< + DisableViewShareCommand, + DisableViewShareResult, + IViewShareStateResponseDataDto + >(context, input, commandBus, DisableViewShareCommand, mapViewShareStateResultToDto); + +export const executeInstallViewPluginEndpoint = async ( + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => { + const parsed = installViewPluginInputSchema.safeParse(input); + if (!parsed.success) { + return errorEndpointResult( + domainError.validation({ + message: 'Invalid install View plugin input', + details: parsed.error.flatten(), + }) + ); + } + return executeCommand( + context, + { + tableId: parsed.data.tableId, + view: { + type: 'plugin', + name: parsed.data.name, + options: { pluginId: parsed.data.pluginId }, + }, + }, + commandBus, + CreateViewCommand, + mapInstallViewPluginResultToDto + ); +}; + +export const executeUpdateViewPluginStorageEndpoint = ( + context: IExecutionContext, + input: unknown, + commandBus: ICommandBus +) => + executePlainCommand< + UpdateViewPluginStorageCommand, + UpdateViewPluginStorageResult, + IUpdateViewPluginStorageResponseDataDto + >( + context, + input, + commandBus, + UpdateViewPluginStorageCommand, + mapUpdateViewPluginStorageResultToDto + ); + +export const executeGetViewFilterLinkRecordsEndpoint = ( + context: IExecutionContext, + input: unknown, + queryBus: IQueryBus +) => + executeQuery< + GetViewFilterLinkRecordsQuery, + GetViewFilterLinkRecordsResult, + IGetViewFilterLinkRecordsResponseDataDto + >( + context, + input, + queryBus, + GetViewFilterLinkRecordsQuery, + mapGetViewFilterLinkRecordsResultToDto + ); + +export const executeGetViewSnapshotsEndpoint = ( + context: IExecutionContext, + input: unknown, + queryBus: IQueryBus +) => + executeQuery( + context, + input, + queryBus, + GetViewSnapshotsQuery, + mapGetViewSnapshotsResultToDto + ); + +export const executeListViewDocIdsEndpoint = ( + context: IExecutionContext, + input: unknown, + queryBus: IQueryBus +) => + executeQuery( + context, + input, + queryBus, + ListViewsQuery, + (result) => ({ ids: result.views.map((view) => view.id) }) + ); + +export const executeGetViewPluginInstallEndpoint = ( + context: IExecutionContext, + input: unknown, + queryBus: IQueryBus +) => + executeQuery< + GetViewPluginInstallQuery, + GetViewPluginInstallResult, + IGetViewPluginInstallResponseDataDto + >(context, input, queryBus, GetViewPluginInstallQuery, mapGetViewPluginInstallResultToDto); diff --git a/packages/v2/contract-http-implementation/src/handlers/tables/viewRead.spec.ts b/packages/v2/contract-http-implementation/src/handlers/tables/viewRead.spec.ts new file mode 100644 index 0000000000..6feefa91ff --- /dev/null +++ b/packages/v2/contract-http-implementation/src/handlers/tables/viewRead.spec.ts @@ -0,0 +1,136 @@ +import { + ActorId, + domainError, + GetViewQuery, + GetViewResult, + type IExecutionContext, + type IQueryBus, + ListViewsQuery, + ListViewsResult, + err, + ok, + type ViewQueryResultView, +} from '@teable/v2-core'; +import { describe, expect, it, vi } from 'vitest'; + +import { executeGetViewEndpoint } from './getView'; +import { executeListViewsEndpoint } from './listViews'; + +const tableId = `tbl${'a'.repeat(16)}`; +const viewId = `viw${'a'.repeat(16)}`; +const context: IExecutionContext = { + actorId: ActorId.create('system')._unsafeUnwrap(), +}; +const view: ViewQueryResultView = { + id: viewId, + version: 2, + name: 'Planning', + type: 'grid', + options: { rowHeight: 'short' }, + createdBy: 'system', + createdTime: '2026-07-31T00:00:00.000Z', + columnMeta: {}, +}; + +const createQueryBus = (execute: IQueryBus['execute']): IQueryBus => ({ execute }); + +describe('executeGetViewEndpoint', () => { + it('validates nominal IDs before dispatching', async () => { + const execute = vi.fn(); + + const result = await executeGetViewEndpoint( + context, + { tableId: 'invalid', viewId: 'invalid' }, + createQueryBus(execute) + ); + + expect(result.status).toBe(400); + expect(result.body.ok).toBe(false); + expect(execute).not.toHaveBeenCalled(); + }); + + it('dispatches GetViewQuery and preserves the aggregate-backed projection', async () => { + const execute = vi.fn(async (_context, query) => { + expect(query).toBeInstanceOf(GetViewQuery); + expect(query.tableId.toString()).toBe(tableId); + expect(query.viewId.toString()).toBe(viewId); + return ok(GetViewResult.create(view)); + }); + + const result = await executeGetViewEndpoint( + context, + { tableId, viewId }, + createQueryBus(execute) + ); + + expect(result).toEqual({ + status: 200, + body: { ok: true, data: { view } }, + }); + }); + + it('maps a missing View child to the HTTP error contract', async () => { + const execute = vi.fn(async () => + err(domainError.notFound({ code: 'view.not_found', message: 'View not found' })) + ); + + const result = await executeGetViewEndpoint( + context, + { tableId, viewId }, + createQueryBus(execute) + ); + + expect(result.status).toBe(404); + expect(result.body).toMatchObject({ + ok: false, + error: { code: 'view.not_found' }, + }); + }); +}); + +describe('executeListViewsEndpoint', () => { + it('dispatches ListViewsQuery with the requested projection', async () => { + const execute = vi.fn(async (_context, query) => { + expect(query).toBeInstanceOf(ListViewsQuery); + expect(query.tableId.toString()).toBe(tableId); + expect(query.viewIds?.map((id) => id.toString())).toEqual([viewId]); + return ok(ListViewsResult.create([view])); + }); + + const result = await executeListViewsEndpoint( + context, + { tableId, viewIds: [viewId, viewId] }, + createQueryBus(execute) + ); + + expect(result).toEqual({ + status: 200, + body: { ok: true, data: { views: [view] } }, + }); + }); + + it('rejects an invalid projected View ID before dispatching', async () => { + const execute = vi.fn(); + + const result = await executeListViewsEndpoint( + context, + { tableId, viewIds: ['invalid'] }, + createQueryBus(execute) + ); + + expect(result.status).toBe(400); + expect(execute).not.toHaveBeenCalled(); + }); + + it('maps unexpected repository failures to the HTTP error contract', async () => { + const execute = vi.fn(async () => err(domainError.unexpected({ message: 'query failed' }))); + + const result = await executeListViewsEndpoint(context, { tableId }, createQueryBus(execute)); + + expect(result.status).toBe(500); + expect(result.body).toMatchObject({ + ok: false, + error: { message: 'query failed' }, + }); + }); +}); diff --git a/packages/v2/contract-http-implementation/src/router.ts b/packages/v2/contract-http-implementation/src/router.ts index 786fdc82de..c239d99a16 100644 --- a/packages/v2/contract-http-implementation/src/router.ts +++ b/packages/v2/contract-http-implementation/src/router.ts @@ -1,18 +1,24 @@ import { ORPCError, implement } from '@orpc/server'; import type { IExplainService } from '@teable/v2-command-explain'; import { v2CommandExplainTokens } from '@teable/v2-command-explain'; -import type { IHandlerResolver } from '@teable/v2-contract-http'; +import type { + HttpErrorStatus, + IApiErrorResponseDto, + IHandlerResolver, +} from '@teable/v2-contract-http'; import { v2Contract } from '@teable/v2-contract-http'; import { ActorId, type ICommandBus, type IComputedActivityReader, + type IDomainErrorLocalization, type IExecutionContext, type IQueryBus, v2CoreTokens, } from '@teable/v2-core'; import { executeCreateBaseEndpoint } from './handlers/bases/createBase'; +import { executeDuplicateBaseEndpoint } from './handlers/bases/duplicateBase'; import { executeListBasesEndpoint } from './handlers/bases/listBases'; import { executeClearEndpoint } from './handlers/tables/clear'; import { executeCreateFieldEndpoint } from './handlers/tables/createField'; @@ -20,6 +26,7 @@ import { executeCreateRecordEndpoint } from './handlers/tables/createRecord'; import { executeCreateRecordsEndpoint } from './handlers/tables/createRecords'; import { executeCreateTableEndpoint } from './handlers/tables/createTable'; import { executeCreateTablesEndpoint } from './handlers/tables/createTables'; +import { executeCreateViewEndpoint } from './handlers/tables/createView'; import { executeDeleteByRangeEndpoint } from './handlers/tables/deleteByRange'; import { executeDeleteFieldEndpoint } from './handlers/tables/deleteField'; import { executeDeleteRecordsEndpoint } from './handlers/tables/deleteRecords'; @@ -36,13 +43,15 @@ import { executeExplainUpdateFieldEndpoint, executeExplainUpdateRecordEndpoint, } from './handlers/tables/explainCommand'; -import { executeGetRecordByIdEndpoint } from './handlers/tables/getRecordById'; import { executeGetComputeActivityEndpoint } from './handlers/tables/getComputeActivity'; +import { executeGetRecordByIdEndpoint } from './handlers/tables/getRecordById'; import { executeGetTableByIdEndpoint } from './handlers/tables/getTableById'; +import { executeGetViewEndpoint } from './handlers/tables/getView'; import { executeImportCsvEndpoint } from './handlers/tables/importCsv'; import { executeImportRecordsEndpoint } from './handlers/tables/importRecords'; import { executeListTableRecordsEndpoint } from './handlers/tables/listTableRecords'; import { executeListTablesEndpoint } from './handlers/tables/listTables'; +import { executeListViewsEndpoint } from './handlers/tables/listViews'; import { executePasteEndpoint } from './handlers/tables/paste'; import { executeRenameTableEndpoint } from './handlers/tables/renameTable'; import { executeReorderRecordsEndpoint } from './handlers/tables/reorderRecords'; @@ -51,6 +60,31 @@ import { executeSubmitRecordEndpoint } from './handlers/tables/submitRecord'; import { executeUpdateFieldEndpoint } from './handlers/tables/updateField'; import { executeUpdateRecordEndpoint } from './handlers/tables/updateRecord'; import { executeUpdateRecordsEndpoint } from './handlers/tables/updateRecords'; +import { executeUpdateTablePropertiesEndpoint } from './handlers/tables/updateTableProperties'; +import { + executeApplyViewManualSortEndpoint, + executeDeleteViewEndpoint, + executeDisableViewShareEndpoint, + executeDuplicateViewEndpoint, + executeEnableViewShareEndpoint, + executeGetViewFilterLinkRecordsEndpoint, + executeGetViewPluginInstallEndpoint, + executeGetViewSnapshotsEndpoint, + executeInstallViewPluginEndpoint, + executeListViewDocIdsEndpoint, + executeRefreshViewShareIdEndpoint, + executeRenameViewEndpoint, + executeUpdateViewColumnMetaEndpoint, + executeUpdateViewDescriptionEndpoint, + executeUpdateViewFilterEndpoint, + executeUpdateViewGroupEndpoint, + executeUpdateViewLockedEndpoint, + executeUpdateViewOptionsEndpoint, + executeUpdateViewOrderEndpoint, + executeUpdateViewPluginStorageEndpoint, + executeUpdateViewShareMetaEndpoint, + executeUpdateViewSortEndpoint, +} from './handlers/tables/viewOperations'; export interface IV2OrpcRouterOptions { createContainer?: () => IHandlerResolver | Promise; @@ -113,6 +147,7 @@ export const createV2OrpcRouter = (options: IV2OrpcRouterOptions = {}) => { code: string; tags: readonly string[]; details?: Record; + localization?: IDomainErrorLocalization; } ): never => { throw new ORPCError(orpcCode, { @@ -121,12 +156,67 @@ export const createV2OrpcRouter = (options: IV2OrpcRouterOptions = {}) => { domainCode: errorBody.code, domainTags: errorBody.tags, details: errorBody.details, + localization: errorBody.localization, }, }); }; const os = implement(v2Contract); + type ViewRouteResult = + | { status: 200; body: TBody } + | { status: HttpErrorStatus; body: IApiErrorResponseDto }; + + const unwrapViewRouteResult = (result: ViewRouteResult): TBody => { + if (result.status === 200) return result.body; + if (result.status === 400) throwDomainError('BAD_REQUEST', result.body.error); + if (result.status === 403) throwDomainError('FORBIDDEN', result.body.error); + if (result.status === 404) throwDomainError('NOT_FOUND', result.body.error); + return throwDomainError('INTERNAL_SERVER_ERROR', result.body.error); + }; + + const runViewCommandRoute = async ( + input: TInput, + execute: ( + context: IExecutionContext, + input: TInput, + commandBus: ICommandBus + ) => Promise> + ): Promise => { + const container = await resolveContainer(); + let executionContext: IExecutionContext; + try { + executionContext = await createExecutionContext(); + } catch { + throw new ORPCError('INTERNAL_SERVER_ERROR', { + message: executionContextErrorMessage, + }); + } + const commandBus = container.resolve(v2CoreTokens.commandBus); + return unwrapViewRouteResult(await execute(executionContext, input, commandBus)); + }; + + const runViewQueryRoute = async ( + input: TInput, + execute: ( + context: IExecutionContext, + input: TInput, + queryBus: IQueryBus + ) => Promise> + ): Promise => { + const container = await resolveContainer(); + let executionContext: IExecutionContext; + try { + executionContext = await createExecutionContext(); + } catch { + throw new ORPCError('INTERNAL_SERVER_ERROR', { + message: executionContextErrorMessage, + }); + } + const queryBus = container.resolve(v2CoreTokens.queryBus); + return unwrapViewRouteResult(await execute(executionContext, input, queryBus)); + }; + const basesCreate = os.bases.create.handler(async ({ input }) => { const container = await resolveContainer(); @@ -175,6 +265,27 @@ export const createV2OrpcRouter = (options: IV2OrpcRouterOptions = {}) => { throwDomainError('INTERNAL_SERVER_ERROR', result.body.error); }); + const basesDuplicate = os.bases.duplicate.handler(async ({ input }) => { + const container = await resolveContainer(); + + let executionContext: IExecutionContext; + try { + executionContext = await createExecutionContext(); + } catch { + throw new ORPCError('INTERNAL_SERVER_ERROR', { + message: executionContextErrorMessage, + }); + } + + const commandBus = container.resolve(v2CoreTokens.commandBus); + const result = await executeDuplicateBaseEndpoint(executionContext, input, commandBus); + if (result.status === 201) return result.body; + if (result.status === 400) throwDomainError('BAD_REQUEST', result.body.error); + if (result.status === 403) throwDomainError('FORBIDDEN', result.body.error); + if (result.status === 404) throwDomainError('NOT_FOUND', result.body.error); + throwDomainError('INTERNAL_SERVER_ERROR', result.body.error); + }); + const tablesCreate = os.tables.create.handler(async ({ input }) => { const container = await resolveContainer(); @@ -251,6 +362,34 @@ export const createV2OrpcRouter = (options: IV2OrpcRouterOptions = {}) => { throwDomainError('INTERNAL_SERVER_ERROR', result.body.error); }); + const tablesCreateView = os.tables.createView.handler(async ({ input }) => { + const container = await resolveContainer(); + + let executionContext: IExecutionContext; + try { + executionContext = await createExecutionContext(); + } catch { + throw new ORPCError('INTERNAL_SERVER_ERROR', { + message: executionContextErrorMessage, + }); + } + + const commandBus = container.resolve(v2CoreTokens.commandBus); + const result = await executeCreateViewEndpoint(executionContext, input, commandBus); + + if (result.status === 200) return result.body; + + if (result.status === 400) { + throwDomainError('BAD_REQUEST', result.body.error); + } + + if (result.status === 404) { + throwDomainError('NOT_FOUND', result.body.error); + } + + throwDomainError('INTERNAL_SERVER_ERROR', result.body.error); + }); + const tablesDuplicateTable = os.tables.duplicateTable.handler(async ({ input }) => { const container = await resolveContainer(); @@ -772,6 +911,34 @@ export const createV2OrpcRouter = (options: IV2OrpcRouterOptions = {}) => { throwDomainError('INTERNAL_SERVER_ERROR', result.body.error); }); + const tablesGetView = os.tables.getView.handler(async ({ input }) => { + const container = await resolveContainer(); + + let executionContext: IExecutionContext; + try { + executionContext = await createExecutionContext(); + } catch { + throw new ORPCError('INTERNAL_SERVER_ERROR', { + message: executionContextErrorMessage, + }); + } + + const queryBus = container.resolve(v2CoreTokens.queryBus); + const result = await executeGetViewEndpoint(executionContext, input, queryBus); + + if (result.status === 200) return result.body; + + if (result.status === 400) { + throwDomainError('BAD_REQUEST', result.body.error); + } + + if (result.status === 404) { + throwDomainError('NOT_FOUND', result.body.error); + } + + throwDomainError('INTERNAL_SERVER_ERROR', result.body.error); + }); + const tablesDelete = os.tables.delete.handler(async ({ input }) => { const container = await resolveContainer(); @@ -852,6 +1019,34 @@ export const createV2OrpcRouter = (options: IV2OrpcRouterOptions = {}) => { throwDomainError('INTERNAL_SERVER_ERROR', result.body.error); }); + const tablesListViews = os.tables.listViews.handler(async ({ input }) => { + const container = await resolveContainer(); + + let executionContext: IExecutionContext; + try { + executionContext = await createExecutionContext(); + } catch { + throw new ORPCError('INTERNAL_SERVER_ERROR', { + message: executionContextErrorMessage, + }); + } + + const queryBus = container.resolve(v2CoreTokens.queryBus); + const result = await executeListViewsEndpoint(executionContext, input, queryBus); + + if (result.status === 200) return result.body; + + if (result.status === 400) { + throwDomainError('BAD_REQUEST', result.body.error); + } + + if (result.status === 404) { + throwDomainError('NOT_FOUND', result.body.error); + } + + throwDomainError('INTERNAL_SERVER_ERROR', result.body.error); + }); + const tablesListRecords = os.tables.listRecords.handler(async ({ input }) => { const container = await resolveContainer(); @@ -908,6 +1103,28 @@ export const createV2OrpcRouter = (options: IV2OrpcRouterOptions = {}) => { throwDomainError('INTERNAL_SERVER_ERROR', result.body.error); }); + const tablesUpdateProperties = os.tables.updateProperties.handler(async ({ input }) => { + const container = await resolveContainer(); + + let executionContext: IExecutionContext; + try { + executionContext = await createExecutionContext(); + } catch { + throw new ORPCError('INTERNAL_SERVER_ERROR', { + message: executionContextErrorMessage, + }); + } + + const commandBus = container.resolve(v2CoreTokens.commandBus); + const result = await executeUpdateTablePropertiesEndpoint(executionContext, input, commandBus); + + if (result.status === 200) return result.body; + if (result.status === 400) throwDomainError('BAD_REQUEST', result.body.error); + if (result.status === 403) throwDomainError('FORBIDDEN', result.body.error); + if (result.status === 404) throwDomainError('NOT_FOUND', result.body.error); + throwDomainError('INTERNAL_SERVER_ERROR', result.body.error); + }); + const tablesImportCsv = os.tables.importCsv.handler(async ({ input }) => { const container = await resolveContainer(); @@ -1186,9 +1403,77 @@ export const createV2OrpcRouter = (options: IV2OrpcRouterOptions = {}) => { throwDomainError('INTERNAL_SERVER_ERROR', result.body.error); }); + const tablesDeleteView = os.tables.deleteView.handler(({ input }) => + runViewCommandRoute(input, executeDeleteViewEndpoint) + ); + const tablesDuplicateView = os.tables.duplicateView.handler(({ input }) => + runViewCommandRoute(input, executeDuplicateViewEndpoint) + ); + const tablesRenameView = os.tables.renameView.handler(({ input }) => + runViewCommandRoute(input, executeRenameViewEndpoint) + ); + const tablesUpdateViewDescription = os.tables.updateViewDescription.handler(({ input }) => + runViewCommandRoute(input, executeUpdateViewDescriptionEndpoint) + ); + const tablesUpdateViewLocked = os.tables.updateViewLocked.handler(({ input }) => + runViewCommandRoute(input, executeUpdateViewLockedEndpoint) + ); + const tablesUpdateViewOrder = os.tables.updateViewOrder.handler(({ input }) => + runViewCommandRoute(input, executeUpdateViewOrderEndpoint) + ); + const tablesUpdateViewColumnMeta = os.tables.updateViewColumnMeta.handler(({ input }) => + runViewCommandRoute(input, executeUpdateViewColumnMetaEndpoint) + ); + const tablesUpdateViewFilter = os.tables.updateViewFilter.handler(({ input }) => + runViewCommandRoute(input, executeUpdateViewFilterEndpoint) + ); + const tablesUpdateViewSort = os.tables.updateViewSort.handler(({ input }) => + runViewCommandRoute(input, executeUpdateViewSortEndpoint) + ); + const tablesUpdateViewGroup = os.tables.updateViewGroup.handler(({ input }) => + runViewCommandRoute(input, executeUpdateViewGroupEndpoint) + ); + const tablesUpdateViewOptions = os.tables.updateViewOptions.handler(({ input }) => + runViewCommandRoute(input, executeUpdateViewOptionsEndpoint) + ); + const tablesApplyViewManualSort = os.tables.applyViewManualSort.handler(({ input }) => + runViewCommandRoute(input, executeApplyViewManualSortEndpoint) + ); + const tablesUpdateViewShareMeta = os.tables.updateViewShareMeta.handler(({ input }) => + runViewCommandRoute(input, executeUpdateViewShareMetaEndpoint) + ); + const tablesRefreshViewShareId = os.tables.refreshViewShareId.handler(({ input }) => + runViewCommandRoute(input, executeRefreshViewShareIdEndpoint) + ); + const tablesEnableViewShare = os.tables.enableViewShare.handler(({ input }) => + runViewCommandRoute(input, executeEnableViewShareEndpoint) + ); + const tablesDisableViewShare = os.tables.disableViewShare.handler(({ input }) => + runViewCommandRoute(input, executeDisableViewShareEndpoint) + ); + const tablesGetViewFilterLinkRecords = os.tables.getViewFilterLinkRecords.handler(({ input }) => + runViewQueryRoute(input, executeGetViewFilterLinkRecordsEndpoint) + ); + const tablesGetViewSnapshots = os.tables.getViewSnapshots.handler(({ input }) => + runViewQueryRoute(input, executeGetViewSnapshotsEndpoint) + ); + const tablesListViewDocIds = os.tables.listViewDocIds.handler(({ input }) => + runViewQueryRoute(input, executeListViewDocIdsEndpoint) + ); + const tablesInstallViewPlugin = os.tables.installViewPlugin.handler(({ input }) => + runViewCommandRoute(input, executeInstallViewPluginEndpoint) + ); + const tablesGetViewPluginInstall = os.tables.getViewPluginInstall.handler(({ input }) => + runViewQueryRoute(input, executeGetViewPluginInstallEndpoint) + ); + const tablesUpdateViewPluginStorage = os.tables.updateViewPluginStorage.handler(({ input }) => + runViewCommandRoute(input, executeUpdateViewPluginStorageEndpoint) + ); + return os.router({ bases: { create: basesCreate, + duplicate: basesDuplicate, list: basesList, }, tables: { @@ -1196,6 +1481,7 @@ export const createV2OrpcRouter = (options: IV2OrpcRouterOptions = {}) => { createTables: tablesCreateTables, duplicateTable: tablesDuplicateTable, createField: tablesCreateField, + createView: tablesCreateView, updateField: tablesUpdateField, explainCreateField: tablesExplainCreateField, explainUpdateField: tablesExplainUpdateField, @@ -1219,11 +1505,36 @@ export const createV2OrpcRouter = (options: IV2OrpcRouterOptions = {}) => { getById: tablesGetById, getComputeActivity: tablesGetComputeActivity, getRecord: tablesGetRecord, + getView: tablesGetView, importCsv: tablesImportCsv, importRecords: tablesImportRecords, list: tablesList, listRecords: tablesListRecords, + listViews: tablesListViews, + deleteView: tablesDeleteView, + duplicateView: tablesDuplicateView, + renameView: tablesRenameView, + updateViewDescription: tablesUpdateViewDescription, + updateViewLocked: tablesUpdateViewLocked, + updateViewOrder: tablesUpdateViewOrder, + updateViewColumnMeta: tablesUpdateViewColumnMeta, + updateViewFilter: tablesUpdateViewFilter, + updateViewSort: tablesUpdateViewSort, + updateViewGroup: tablesUpdateViewGroup, + updateViewOptions: tablesUpdateViewOptions, + applyViewManualSort: tablesApplyViewManualSort, + updateViewShareMeta: tablesUpdateViewShareMeta, + refreshViewShareId: tablesRefreshViewShareId, + enableViewShare: tablesEnableViewShare, + disableViewShare: tablesDisableViewShare, + getViewFilterLinkRecords: tablesGetViewFilterLinkRecords, + getViewSnapshots: tablesGetViewSnapshots, + listViewDocIds: tablesListViewDocIds, + installViewPlugin: tablesInstallViewPlugin, + getViewPluginInstall: tablesGetViewPluginInstall, + updateViewPluginStorage: tablesUpdateViewPluginStorage, rename: tablesRename, + updateProperties: tablesUpdateProperties, explainCreateRecord: tablesExplainCreateRecord, explainUpdateRecord: tablesExplainUpdateRecord, explainDeleteRecords: tablesExplainDeleteRecords, diff --git a/packages/v2/contract-http-implementation/src/tableQueryOpsRouter.ts b/packages/v2/contract-http-implementation/src/tableQueryOpsRouter.ts new file mode 100644 index 0000000000..e1b927b55d --- /dev/null +++ b/packages/v2/contract-http-implementation/src/tableQueryOpsRouter.ts @@ -0,0 +1,151 @@ +import { ORPCError, implement } from '@orpc/server'; +import { + type HttpErrorStatus, + type IApiErrorResponseDto, + type IHandlerResolver, + v2TableQueryOpsContract, +} from '@teable/v2-contract-http'; +import { + ActorId, + type IExecutionContext, + type ITableRepository, + v2CoreTokens, +} from '@teable/v2-core'; +import type { + TableSearchAccessPathCapabilityReader, + TableSearchAccessPathReconciler, + TableSearchVectorStatusReader, +} from '@teable/v2-table-query-ops'; +import { v2TableOpsTokens } from '@teable/v2-table-query-ops'; + +import { + executeGetSearchAccessPathCapabilitiesEndpoint, + executeGetSearchAccessPathStatusEndpoint, + executeReconcileSearchAccessPathEndpoint, +} from './handlers/tableQueryOps/searchAccessPath'; + +export interface IV2TableQueryOpsOrpcRouterOptions { + createContainer?: () => IHandlerResolver | Promise; + createExecutionContext?: () => IExecutionContext | Promise; + allowSearchAccessPathMutation?: boolean; +} + +export const createV2TableQueryOpsOrpcRouter = ( + options: IV2TableQueryOpsOrpcRouterOptions = {} +) => { + let defaultContainerPromise: Promise | undefined; + const createContainer = + options.createContainer ?? + (() => { + if (!defaultContainerPromise) { + defaultContainerPromise = import('@teable/v2-container-node').then( + ({ createV2NodePgContainer }) => createV2NodePgContainer() + ); + } + return defaultContainerPromise; + }); + const createExecutionContext = + options.createExecutionContext ?? + (() => { + const actorIdResult = ActorId.create('system'); + if (actorIdResult.isErr()) throw actorIdResult.error; + return { actorId: actorIdResult.value }; + }); + + const resolveContainer = async (): Promise => { + try { + return await Promise.resolve(createContainer()); + } catch (error) { + throw new ORPCError('INTERNAL_SERVER_ERROR', { + message: `Failed to create table-query-ops container: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } + }; + + const resolveExecutionContext = async (): Promise => { + try { + return await Promise.resolve(createExecutionContext()); + } catch { + throw new ORPCError('INTERNAL_SERVER_ERROR', { + message: 'Failed to resolve table-query-ops execution context', + }); + } + }; + + const throwEndpointError = (status: HttpErrorStatus, body: IApiErrorResponseDto): never => { + const code = + status === 400 + ? 'BAD_REQUEST' + : status === 401 + ? 'UNAUTHORIZED' + : status === 403 + ? 'FORBIDDEN' + : status === 404 + ? 'NOT_FOUND' + : status === 501 + ? 'NOT_IMPLEMENTED' + : 'INTERNAL_SERVER_ERROR'; + throw new ORPCError(code, { + message: body.error.message, + data: { + domainCode: body.error.code, + domainTags: body.error.tags, + details: body.error.details, + localization: body.error.localization, + }, + }); + }; + + const os = implement(v2TableQueryOpsContract); + + const getStatus = os.searchAccessPath.getStatus.handler(async ({ input }) => { + const [container, context] = await Promise.all([resolveContainer(), resolveExecutionContext()]); + const result = await executeGetSearchAccessPathStatusEndpoint( + context, + input, + container.resolve(v2CoreTokens.tableRepository), + container.resolve(v2TableOpsTokens.searchVectorStatusReader) + ); + if (result.status === 200) return result.body; + return throwEndpointError(result.status, result.body); + }); + + const getCapabilities = os.searchAccessPath.getCapabilities.handler(async () => { + const [container, context] = await Promise.all([resolveContainer(), resolveExecutionContext()]); + const result = await executeGetSearchAccessPathCapabilitiesEndpoint( + context, + container.resolve( + v2TableOpsTokens.searchAccessPathCapabilityReader + ) + ); + if (result.status === 200) return result.body; + return throwEndpointError(result.status, result.body); + }); + + const reconcile = os.searchAccessPath.reconcile.handler(async ({ input }) => { + const [container, context] = await Promise.all([resolveContainer(), resolveExecutionContext()]); + const result = await executeReconcileSearchAccessPathEndpoint( + context, + input, + container.resolve(v2CoreTokens.tableRepository), + container.resolve( + v2TableOpsTokens.searchAccessPathReconciler + ), + options.allowSearchAccessPathMutation ?? false + ); + if (result.status === 200) return result.body; + return throwEndpointError(result.status, result.body); + }); + + return os.router({ + searchAccessPath: { + getStatus, + getCapabilities, + reconcile, + }, + }); +}; + +export type V2TableQueryOpsOrpcRouter = ReturnType; diff --git a/packages/v2/contract-http-implementation/tsdown.config.ts b/packages/v2/contract-http-implementation/tsdown.config.ts index 0290090b5f..4219b73d06 100644 --- a/packages/v2/contract-http-implementation/tsdown.config.ts +++ b/packages/v2/contract-http-implementation/tsdown.config.ts @@ -3,5 +3,5 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ ...v2TsdownBaseConfig, - entry: ['src/index.ts', 'src/handlers/index.ts'], + entry: ['src/index.ts', 'src/handlers/index.ts', 'src/tableQueryOpsRouter.ts'], }); diff --git a/packages/v2/contract-http-openapi/src/openapi.ts b/packages/v2/contract-http-openapi/src/openapi.ts index b219920ea8..95fa1a102b 100644 --- a/packages/v2/contract-http-openapi/src/openapi.ts +++ b/packages/v2/contract-http-openapi/src/openapi.ts @@ -14,6 +14,10 @@ interface DomainErrorData { domainCode?: string; domainTags?: string[]; details?: Record; + localization?: { + i18nKey: string; + context?: Record; + }; } const getDomainErrorData = (error: unknown): DomainErrorData | undefined => { @@ -47,6 +51,11 @@ const getErrorDetails = (error: unknown): Record | undefined => return domainData?.details; }; +const getErrorLocalization = (error: unknown): DomainErrorData['localization'] => { + const domainData = getDomainErrorData(error); + return domainData?.localization; +}; + const encodeErrorResponse = (error: unknown) => ({ ok: false as const, error: { @@ -54,6 +63,7 @@ const encodeErrorResponse = (error: unknown) => ({ message: getErrorMessage(error), tags: getErrorTags(error), details: getErrorDetails(error), + localization: getErrorLocalization(error), }, }); diff --git a/packages/v2/contract-http/src/base/duplicateBase.ts b/packages/v2/contract-http/src/base/duplicateBase.ts new file mode 100644 index 0000000000..8df6904a6a --- /dev/null +++ b/packages/v2/contract-http/src/base/duplicateBase.ts @@ -0,0 +1,65 @@ +import type { + DomainError, + DuplicateBaseByIdResult, + IDuplicateBaseByIdCommandInput, +} from '@teable/v2-core'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import type { IDomainEventDto } from '../shared/domainEvent'; +import { domainEventDtoSchema, mapDomainEventToDto } from '../shared/domainEvent'; +import { + apiErrorResponseDtoSchema, + apiOkResponseDtoSchema, + type HttpErrorStatus, + type IApiErrorResponseDto, + type IApiOkResponseDto, + type IApiResponseDto, +} from '../shared/http'; +import type { IBaseDto } from './dto'; +import { baseDtoSchema, mapBaseToDto } from './dto'; + +export type IDuplicateBaseRequestDto = IDuplicateBaseByIdCommandInput; + +export interface IDuplicateBaseResponseDataDto { + base: IBaseDto; + tableIdMap: Record; + fieldIdMap: Record; + viewIdMap: Record; + recordsLength: number; + events: Array; +} + +export type IDuplicateBaseResponseDto = IApiResponseDto; +export type IDuplicateBaseOkResponseDto = IApiOkResponseDto; +export type IDuplicateBaseErrorResponseDto = IApiErrorResponseDto; + +export type IDuplicateBaseEndpointResult = + | { status: 201; body: IDuplicateBaseOkResponseDto } + | { status: HttpErrorStatus; body: IDuplicateBaseErrorResponseDto }; + +export const duplicateBaseResponseDataSchema = z.object({ + base: baseDtoSchema, + tableIdMap: z.record(z.string(), z.string()), + fieldIdMap: z.record(z.string(), z.string()), + viewIdMap: z.record(z.string(), z.string()), + recordsLength: z.number().int().nonnegative(), + events: z.array(domainEventDtoSchema), +}); + +export const duplicateBaseOkResponseSchema = apiOkResponseDtoSchema( + duplicateBaseResponseDataSchema +); +export const duplicateBaseErrorResponseSchema = apiErrorResponseDtoSchema; + +export const mapDuplicateBaseResultToDto = ( + result: DuplicateBaseByIdResult +): Result => + mapBaseToDto(result.base).map((base) => ({ + base, + tableIdMap: { ...result.tableIdMap }, + fieldIdMap: { ...result.fieldIdMap }, + viewIdMap: { ...result.viewIdMap }, + recordsLength: result.recordsLength, + events: result.events.map(mapDomainEventToDto), + })); diff --git a/packages/v2/contract-http/src/contract.ts b/packages/v2/contract-http/src/contract.ts index e75e913eab..cb332f1566 100644 --- a/packages/v2/contract-http/src/contract.ts +++ b/packages/v2/contract-http/src/contract.ts @@ -1,13 +1,18 @@ import { oc } from '@orpc/contract'; import type { AnyContractRouter } from '@orpc/contract'; import { + applyViewManualSortInputSchema, createBaseInputSchema, + duplicateBaseByIdInputSchema, createFieldInputSchema, createRecordInputSchema, createRecordsInputSchema, submitRecordInputSchema, createTableInputSchema, createTablesInputSchema, + createViewInputSchema, + deleteViewInputSchema, + disableViewShareInputSchema, deleteByRangeCommandInputSchema, deleteFieldInputSchema, deleteRecordsInputSchema, @@ -15,25 +20,46 @@ import { duplicateFieldInputSchema, duplicateRecordInputSchema, duplicateTableInputSchema, + duplicateViewInputSchema, + enableViewShareInputSchema, getRecordByIdInputSchema, getTableByIdInputSchema, + getViewInputSchema, + getViewFilterLinkRecordsInputSchema, + getViewPluginInstallInputSchema, + getViewSnapshotsInputSchema, getComputeActivityInputSchema, importCsvInputSchema, importRecordsInputSchema, listBasesInputSchema, listTableRecordsInputSchema, listTablesInputSchema, + listViewsInputSchema, pasteCommandInputSchema, clearCommandInputSchema, renameTableInputSchema, + updateTablePropertiesInputSchema, + renameViewInputSchema, + refreshViewShareIdInputSchema, restoreTableInputSchema, updateFieldInputSchema, updateRecordInputSchema, updateRecordsInputSchema, + updateViewColumnMetaInputSchema, + updateViewDescriptionInputSchema, + updateViewFilterInputSchema, + updateViewGroupInputSchema, + updateViewLockedInputSchema, + updateViewOptionsInputSchema, + updateViewOrderInputSchema, + updateViewPluginStorageInputSchema, + updateViewShareMetaInputSchema, + updateViewSortInputSchema, reorderRecordsInputSchema, } from '@teable/v2-core'; import { createBaseOkResponseSchema } from './base/createBase'; +import { duplicateBaseOkResponseSchema } from './base/duplicateBase'; import { listBasesOkResponseSchema } from './base/listBases'; import { clearOkResponseSchema } from './table/clear'; import { createFieldOkResponseSchema } from './table/createField'; @@ -41,6 +67,7 @@ import { createRecordOkResponseSchema } from './table/createRecord'; import { createRecordsOkResponseSchema } from './table/createRecords'; import { createTableErrorResponseSchema, createTableOkResponseSchema } from './table/createTable'; import { createTablesOkResponseSchema } from './table/createTables'; +import { createViewOkResponseSchema } from './table/createView'; import { deleteByRangeOkResponseSchema } from './table/deleteByRange'; import { deleteFieldOkResponseSchema } from './table/deleteField'; import { deleteRecordsOkResponseSchema } from './table/deleteRecords'; @@ -58,13 +85,15 @@ import { explainUpdateFieldInputSchema, explainUpdateRecordInputSchema, } from './table/explainCommand'; +import { getComputeActivityOkResponseSchema } from './table/getComputeActivity'; import { getRecordByIdOkResponseSchema } from './table/getRecordById'; import { getTableByIdOkResponseSchema } from './table/getTableById'; -import { getComputeActivityOkResponseSchema } from './table/getComputeActivity'; +import { getViewOkResponseSchema } from './table/getView'; import { importCsvOkResponseSchema } from './table/importCsv'; import { importRecordsOkResponseSchema } from './table/importRecords'; import { listTableRecordsOkResponseSchema } from './table/listTableRecords'; import { listTablesOkResponseSchema } from './table/listTables'; +import { listViewsOkResponseSchema } from './table/listViews'; import { pasteOkResponseSchema } from './table/paste'; import { renameTableOkResponseSchema } from './table/renameTable'; import { reorderRecordsOkResponseSchema } from './table/reorderRecords'; @@ -73,12 +102,28 @@ import { submitRecordOkResponseSchema } from './table/submitRecord'; import { updateFieldOkResponseSchema } from './table/updateField'; import { updateRecordOkResponseSchema } from './table/updateRecord'; import { updateRecordsOkResponseSchema } from './table/updateRecords'; +import { updateTablePropertiesOkResponseSchema } from './table/updateTableProperties'; +import { + applyViewManualSortOkResponseSchema, + getViewFilterLinkRecordsOkResponseSchema, + getViewPluginInstallOkResponseSchema, + getViewSnapshotsOkResponseSchema, + installViewPluginInputSchema, + installViewPluginOkResponseSchema, + listViewDocIdsOkResponseSchema, + updateViewPluginStorageOkResponseSchema, + viewMutationOkResponseSchema, + viewShareMutationOkResponseSchema, + viewShareStateOkResponseSchema, +} from './table/viewOperations'; const BASES_CREATE_PATH = '/bases/create'; +const BASES_DUPLICATE_PATH = '/bases/duplicate'; const BASES_LIST_PATH = '/bases/list'; const TABLES_CREATE_FIELD_PATH = '/tables/createField'; const TABLES_CREATE_PATH = '/tables/create'; const TABLES_CREATE_TABLES_PATH = '/tables/createTables'; +const TABLES_CREATE_VIEW_PATH = '/tables/createView'; const TABLES_CREATE_RECORD_PATH = '/tables/createRecord'; const TABLES_SUBMIT_RECORD_PATH = '/tables/submitRecord'; const TABLES_CREATE_RECORDS_PATH = '/tables/createRecords'; @@ -95,14 +140,17 @@ const TABLES_EXPLAIN_DELETE_RECORDS_PATH = '/tables/explainDeleteRecords'; const TABLES_GET_PATH = '/tables/get'; const TABLES_GET_COMPUTE_ACTIVITY_PATH = '/tables/getComputeActivity'; const TABLES_GET_RECORD_PATH = '/tables/getRecord'; +const TABLES_GET_VIEW_PATH = '/tables/getView'; const TABLES_IMPORT_CSV_PATH = '/tables/importCsv'; const TABLES_IMPORT_RECORDS_PATH = '/tables/importRecords'; const TABLES_LIST_RECORDS_PATH = '/tables/listRecords'; const TABLES_LIST_PATH = '/tables/list'; +const TABLES_LIST_VIEWS_PATH = '/tables/listViews'; const TABLES_PASTE_PATH = '/tables/paste'; const TABLES_CLEAR_PATH = '/tables/clear'; const TABLES_DELETE_BY_RANGE_PATH = '/tables/deleteByRange'; const TABLES_RENAME_PATH = '/tables/rename'; +const TABLES_UPDATE_PROPERTIES_PATH = '/tables/updateProperties'; const TABLES_RESTORE_PATH = '/tables/restore'; const TABLES_UPDATE_FIELD_PATH = '/tables/updateField'; const TABLES_UPDATE_RECORD_PATH = '/tables/updateRecord'; @@ -111,6 +159,28 @@ const TABLES_REORDER_RECORDS_PATH = '/tables/reorderRecords'; const TABLES_DUPLICATE_FIELD_PATH = '/tables/duplicateField'; const TABLES_DUPLICATE_RECORD_PATH = '/tables/duplicateRecord'; const TABLES_DUPLICATE_TABLE_PATH = '/tables/duplicateTable'; +const TABLES_DELETE_VIEW_PATH = '/tables/deleteView'; +const TABLES_DUPLICATE_VIEW_PATH = '/tables/duplicateView'; +const TABLES_RENAME_VIEW_PATH = '/tables/renameView'; +const TABLES_UPDATE_VIEW_DESCRIPTION_PATH = '/tables/updateViewDescription'; +const TABLES_UPDATE_VIEW_LOCKED_PATH = '/tables/updateViewLocked'; +const TABLES_UPDATE_VIEW_ORDER_PATH = '/tables/updateViewOrder'; +const TABLES_UPDATE_VIEW_COLUMN_META_PATH = '/tables/updateViewColumnMeta'; +const TABLES_UPDATE_VIEW_FILTER_PATH = '/tables/updateViewFilter'; +const TABLES_UPDATE_VIEW_SORT_PATH = '/tables/updateViewSort'; +const TABLES_UPDATE_VIEW_GROUP_PATH = '/tables/updateViewGroup'; +const TABLES_UPDATE_VIEW_OPTIONS_PATH = '/tables/updateViewOptions'; +const TABLES_APPLY_VIEW_MANUAL_SORT_PATH = '/tables/applyViewManualSort'; +const TABLES_UPDATE_VIEW_SHARE_META_PATH = '/tables/updateViewShareMeta'; +const TABLES_REFRESH_VIEW_SHARE_ID_PATH = '/tables/refreshViewShareId'; +const TABLES_ENABLE_VIEW_SHARE_PATH = '/tables/enableViewShare'; +const TABLES_DISABLE_VIEW_SHARE_PATH = '/tables/disableViewShare'; +const TABLES_GET_VIEW_FILTER_LINK_RECORDS_PATH = '/tables/getViewFilterLinkRecords'; +const TABLES_GET_VIEW_SNAPSHOTS_PATH = '/tables/getViewSnapshots'; +const TABLES_LIST_VIEW_DOC_IDS_PATH = '/tables/listViewDocIds'; +const TABLES_INSTALL_VIEW_PLUGIN_PATH = '/tables/installViewPlugin'; +const TABLES_GET_VIEW_PLUGIN_INSTALL_PATH = '/tables/getViewPluginInstall'; +const TABLES_UPDATE_VIEW_PLUGIN_STORAGE_PATH = '/tables/updateViewPluginStorage'; export const v2Contract: AnyContractRouter = { bases: { @@ -124,6 +194,16 @@ export const v2Contract: AnyContractRouter = { }) .input(createBaseInputSchema) .output(createBaseOkResponseSchema), + duplicate: oc + .route({ + method: 'POST', + path: BASES_DUPLICATE_PATH, + successStatus: 201, + summary: 'Duplicate base', + tags: ['bases'], + }) + .input(duplicateBaseByIdInputSchema) + .output(duplicateBaseOkResponseSchema), list: oc .route({ method: 'GET', @@ -166,6 +246,16 @@ export const v2Contract: AnyContractRouter = { }) .input(createFieldInputSchema) .output(createFieldOkResponseSchema), + createView: oc + .route({ + method: 'POST', + path: TABLES_CREATE_VIEW_PATH, + successStatus: 200, + summary: 'Create view', + tags: ['tables'], + }) + .input(createViewInputSchema) + .output(createViewOkResponseSchema), explainCreateField: oc .route({ method: 'POST', @@ -326,6 +416,16 @@ export const v2Contract: AnyContractRouter = { }) .input(getRecordByIdInputSchema) .output(getRecordByIdOkResponseSchema), + getView: oc + .route({ + method: 'GET', + path: TABLES_GET_VIEW_PATH, + successStatus: 200, + summary: 'Get view', + tags: ['tables'], + }) + .input(getViewInputSchema) + .output(getViewOkResponseSchema), importCsv: oc .route({ method: 'POST', @@ -366,6 +466,16 @@ export const v2Contract: AnyContractRouter = { }) .input(listTablesInputSchema) .output(listTablesOkResponseSchema), + listViews: oc + .route({ + method: 'GET', + path: TABLES_LIST_VIEWS_PATH, + successStatus: 200, + summary: 'List views', + tags: ['tables'], + }) + .input(listViewsInputSchema) + .output(listViewsOkResponseSchema), rename: oc .route({ method: 'POST', @@ -376,6 +486,16 @@ export const v2Contract: AnyContractRouter = { }) .input(renameTableInputSchema) .output(renameTableOkResponseSchema), + updateProperties: oc + .route({ + method: 'POST', + path: TABLES_UPDATE_PROPERTIES_PATH, + successStatus: 200, + summary: 'Update table properties', + tags: ['tables'], + }) + .input(updateTablePropertiesInputSchema) + .output(updateTablePropertiesOkResponseSchema), updateRecord: oc .route({ method: 'POST', @@ -456,6 +576,226 @@ export const v2Contract: AnyContractRouter = { }) .input(deleteByRangeCommandInputSchema) .output(deleteByRangeOkResponseSchema), + deleteView: oc + .route({ + method: 'POST', + path: TABLES_DELETE_VIEW_PATH, + successStatus: 200, + summary: 'Delete view', + tags: ['tables'], + }) + .input(deleteViewInputSchema) + .output(viewMutationOkResponseSchema), + duplicateView: oc + .route({ + method: 'POST', + path: TABLES_DUPLICATE_VIEW_PATH, + successStatus: 200, + summary: 'Duplicate view', + tags: ['tables'], + }) + .input(duplicateViewInputSchema) + .output(viewMutationOkResponseSchema), + renameView: oc + .route({ + method: 'POST', + path: TABLES_RENAME_VIEW_PATH, + successStatus: 200, + summary: 'Rename view', + tags: ['tables'], + }) + .input(renameViewInputSchema) + .output(viewMutationOkResponseSchema), + updateViewDescription: oc + .route({ + method: 'POST', + path: TABLES_UPDATE_VIEW_DESCRIPTION_PATH, + successStatus: 200, + summary: 'Update view description', + tags: ['tables'], + }) + .input(updateViewDescriptionInputSchema) + .output(viewMutationOkResponseSchema), + updateViewLocked: oc + .route({ + method: 'POST', + path: TABLES_UPDATE_VIEW_LOCKED_PATH, + successStatus: 200, + summary: 'Update view lock state', + tags: ['tables'], + }) + .input(updateViewLockedInputSchema) + .output(viewMutationOkResponseSchema), + updateViewOrder: oc + .route({ + method: 'POST', + path: TABLES_UPDATE_VIEW_ORDER_PATH, + successStatus: 200, + summary: 'Update view order', + tags: ['tables'], + }) + .input(updateViewOrderInputSchema) + .output(viewMutationOkResponseSchema), + updateViewColumnMeta: oc + .route({ + method: 'POST', + path: TABLES_UPDATE_VIEW_COLUMN_META_PATH, + successStatus: 200, + summary: 'Update view column metadata', + tags: ['tables'], + }) + .input(updateViewColumnMetaInputSchema) + .output(viewMutationOkResponseSchema), + updateViewFilter: oc + .route({ + method: 'POST', + path: TABLES_UPDATE_VIEW_FILTER_PATH, + successStatus: 200, + summary: 'Update view filter', + tags: ['tables'], + }) + .input(updateViewFilterInputSchema) + .output(viewMutationOkResponseSchema), + updateViewSort: oc + .route({ + method: 'POST', + path: TABLES_UPDATE_VIEW_SORT_PATH, + successStatus: 200, + summary: 'Update view sort', + tags: ['tables'], + }) + .input(updateViewSortInputSchema) + .output(viewMutationOkResponseSchema), + updateViewGroup: oc + .route({ + method: 'POST', + path: TABLES_UPDATE_VIEW_GROUP_PATH, + successStatus: 200, + summary: 'Update view group', + tags: ['tables'], + }) + .input(updateViewGroupInputSchema) + .output(viewMutationOkResponseSchema), + updateViewOptions: oc + .route({ + method: 'POST', + path: TABLES_UPDATE_VIEW_OPTIONS_PATH, + successStatus: 200, + summary: 'Update view options', + tags: ['tables'], + }) + .input(updateViewOptionsInputSchema) + .output(viewMutationOkResponseSchema), + applyViewManualSort: oc + .route({ + method: 'POST', + path: TABLES_APPLY_VIEW_MANUAL_SORT_PATH, + successStatus: 200, + summary: 'Apply view manual sort', + tags: ['tables'], + }) + .input(applyViewManualSortInputSchema) + .output(applyViewManualSortOkResponseSchema), + updateViewShareMeta: oc + .route({ + method: 'POST', + path: TABLES_UPDATE_VIEW_SHARE_META_PATH, + successStatus: 200, + summary: 'Update view share metadata', + tags: ['tables'], + }) + .input(updateViewShareMetaInputSchema) + .output(viewShareStateOkResponseSchema), + refreshViewShareId: oc + .route({ + method: 'POST', + path: TABLES_REFRESH_VIEW_SHARE_ID_PATH, + successStatus: 200, + summary: 'Refresh view share ID', + tags: ['tables'], + }) + .input(refreshViewShareIdInputSchema) + .output(viewShareMutationOkResponseSchema), + enableViewShare: oc + .route({ + method: 'POST', + path: TABLES_ENABLE_VIEW_SHARE_PATH, + successStatus: 200, + summary: 'Enable view sharing', + tags: ['tables'], + }) + .input(enableViewShareInputSchema) + .output(viewShareMutationOkResponseSchema), + disableViewShare: oc + .route({ + method: 'POST', + path: TABLES_DISABLE_VIEW_SHARE_PATH, + successStatus: 200, + summary: 'Disable view sharing', + tags: ['tables'], + }) + .input(disableViewShareInputSchema) + .output(viewShareStateOkResponseSchema), + getViewFilterLinkRecords: oc + .route({ + method: 'GET', + path: TABLES_GET_VIEW_FILTER_LINK_RECORDS_PATH, + successStatus: 200, + summary: 'Get records referenced by view filters', + tags: ['tables'], + }) + .input(getViewFilterLinkRecordsInputSchema) + .output(getViewFilterLinkRecordsOkResponseSchema), + getViewSnapshots: oc + .route({ + method: 'GET', + path: TABLES_GET_VIEW_SNAPSHOTS_PATH, + successStatus: 200, + summary: 'Get view snapshots', + tags: ['tables'], + }) + .input(getViewSnapshotsInputSchema) + .output(getViewSnapshotsOkResponseSchema), + listViewDocIds: oc + .route({ + method: 'GET', + path: TABLES_LIST_VIEW_DOC_IDS_PATH, + successStatus: 200, + summary: 'List view document IDs', + tags: ['tables'], + }) + .input(listViewsInputSchema) + .output(listViewDocIdsOkResponseSchema), + installViewPlugin: oc + .route({ + method: 'POST', + path: TABLES_INSTALL_VIEW_PLUGIN_PATH, + successStatus: 200, + summary: 'Install a plugin view', + tags: ['tables'], + }) + .input(installViewPluginInputSchema) + .output(installViewPluginOkResponseSchema), + getViewPluginInstall: oc + .route({ + method: 'GET', + path: TABLES_GET_VIEW_PLUGIN_INSTALL_PATH, + successStatus: 200, + summary: 'Get plugin view installation metadata', + tags: ['tables'], + }) + .input(getViewPluginInstallInputSchema) + .output(getViewPluginInstallOkResponseSchema), + updateViewPluginStorage: oc + .route({ + method: 'POST', + path: TABLES_UPDATE_VIEW_PLUGIN_STORAGE_PATH, + successStatus: 200, + summary: 'Update plugin view storage', + tags: ['tables'], + }) + .input(updateViewPluginStorageInputSchema) + .output(updateViewPluginStorageOkResponseSchema), explainCreateRecord: oc .route({ method: 'POST', diff --git a/packages/v2/contract-http/src/index.ts b/packages/v2/contract-http/src/index.ts index 2532787de2..f1fdb43a7e 100644 --- a/packages/v2/contract-http/src/index.ts +++ b/packages/v2/contract-http/src/index.ts @@ -4,6 +4,7 @@ export * from './shared/domainEvent'; export * from './shared/http'; export * from './base/dto'; export * from './base/createBase'; +export * from './base/duplicateBase'; export * from './base/listBases'; export * from './table/createField'; export * from './table/createRecord'; @@ -11,6 +12,7 @@ export * from './table/submitRecord'; export * from './table/createRecords'; export * from './table/createTable'; export * from './table/createTables'; +export * from './table/createView'; export * from './table/deleteField'; export * from './table/deleteTable'; export * from './table/restoreTable'; @@ -18,13 +20,16 @@ export * from './table/deleteRecords'; export * from './table/explainCommand'; export * from './table/getRecordById'; export * from './table/getTableById'; +export * from './table/getView'; export * from './table/getComputeActivity'; export * from './table/enrichTableComputeActivity'; export * from './table/importCsv'; export * from './table/importRecords'; export * from './table/listTableRecords'; export * from './table/listTables'; +export * from './table/listViews'; export * from './table/renameTable'; +export * from './table/updateTableProperties'; export * from './table/updateField'; export * from './table/updateRecord'; export * from './table/updateRecords'; @@ -37,4 +42,7 @@ export * from './table/clear'; export * from './table/deleteByRange'; export * from './table/dto'; export * from './table/recordDto'; +export * from './table/viewReadDto'; +export * from './table/viewOperations'; export * from './table/mapTableDtoToDomain'; +export * from './tableQueryOps'; diff --git a/packages/v2/contract-http/src/shared/http.ts b/packages/v2/contract-http/src/shared/http.ts index 779d0c9c67..5d6cbbd5d0 100644 --- a/packages/v2/contract-http/src/shared/http.ts +++ b/packages/v2/contract-http/src/shared/http.ts @@ -1,4 +1,4 @@ -import type { DomainError } from '@teable/v2-core'; +import type { DomainError, IDomainErrorLocalization } from '@teable/v2-core'; import { domainErrorTagValues, isConflictError, @@ -16,6 +16,10 @@ export interface IHttpErrorDto { message: string; tags: ReadonlyArray<(typeof domainErrorTagValues)[number]>; details?: Readonly>; + localization?: IDomainErrorLocalization; + /** Diagnostic only — non-enumerable, never serialized into HTTP bodies. */ + stack?: string; + cause?: unknown; } export interface IApiErrorResponseDto { @@ -44,15 +48,43 @@ export const apiErrorResponseDtoSchema = z.object({ message: z.string(), tags: z.array(z.enum(domainErrorTagValues)), details: z.record(z.string(), z.unknown()).optional(), + localization: z + .object({ + i18nKey: z.string(), + context: z.record(z.string(), z.unknown()).optional(), + }) + .optional(), }), }); -export const mapDomainErrorToHttpError = (error: DomainError): IHttpErrorDto => ({ - code: error.code, - message: error.message, - tags: error.tags, - details: error.details, -}); +export const mapDomainErrorToHttpError = (error: DomainError): IHttpErrorDto => { + const dto: IHttpErrorDto = { + code: error.code, + message: error.message, + tags: error.tags, + details: error.details, + localization: error.localization, + }; + // Keep creation-site diagnostics available for throwV2Error/Sentry without + // leaking them into JSON response bodies (non-enumerable). + if (error.stack) { + Object.defineProperty(dto, 'stack', { + value: error.stack, + enumerable: false, + configurable: true, + writable: true, + }); + } + if (error.cause !== undefined) { + Object.defineProperty(dto, 'cause', { + value: error.cause, + enumerable: false, + configurable: true, + writable: true, + }); + } + return dto; +}; export const mapDomainErrorToHttpStatus = (error: DomainError): HttpErrorStatus => { if (isNotFoundError(error)) return 404; diff --git a/packages/v2/contract-http/src/table/createView.ts b/packages/v2/contract-http/src/table/createView.ts new file mode 100644 index 0000000000..b3b18aae33 --- /dev/null +++ b/packages/v2/contract-http/src/table/createView.ts @@ -0,0 +1,53 @@ +import type { CreateViewResult, DomainError, ICreateViewCommandInput } from '@teable/v2-core'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import type { IDomainEventDto } from '../shared/domainEvent'; +import { domainEventDtoSchema, mapDomainEventToDto } from '../shared/domainEvent'; +import { + apiErrorResponseDtoSchema, + apiOkResponseDtoSchema, + type HttpErrorStatus, + type IApiErrorResponseDto, + type IApiOkResponseDto, + type IApiResponseDto, +} from '../shared/http'; +import type { ITableDto } from './dto'; +import { mapTableToDto, tableDtoSchema } from './dto'; + +export type ICreateViewRequestDto = ICreateViewCommandInput; + +export interface ICreateViewResponseDataDto { + table: ITableDto; + viewId: string; + events: Array; +} + +export type ICreateViewResponseDto = IApiResponseDto; + +export type ICreateViewOkResponseDto = IApiOkResponseDto; +export type ICreateViewErrorResponseDto = IApiErrorResponseDto; + +export type ICreateViewEndpointResult = + | { status: 200; body: ICreateViewOkResponseDto } + | { status: HttpErrorStatus; body: ICreateViewErrorResponseDto }; + +export const createViewResponseDataSchema = z.object({ + table: tableDtoSchema, + viewId: z.string(), + events: z.array(domainEventDtoSchema), +}); + +export const createViewOkResponseSchema = apiOkResponseDtoSchema(createViewResponseDataSchema); + +export const createViewErrorResponseSchema = apiErrorResponseDtoSchema; + +export const mapCreateViewResultToDto = ( + result: CreateViewResult +): Result => { + return mapTableToDto(result.table).map((table) => ({ + table, + viewId: result.viewId.toString(), + events: result.events.map(mapDomainEventToDto), + })); +}; diff --git a/packages/v2/contract-http/src/table/dto.spec.ts b/packages/v2/contract-http/src/table/dto.spec.ts index 2787eb96ca..7b9078f3e2 100644 --- a/packages/v2/contract-http/src/table/dto.spec.ts +++ b/packages/v2/contract-http/src/table/dto.spec.ts @@ -1,9 +1,8 @@ -import { describe, expect, it } from 'vitest'; - import { DefaultTableMapper } from '@teable/v2-core'; import type { ITablePersistenceDTO } from '@teable/v2-core'; +import { describe, expect, it } from 'vitest'; -import { mapTableToDto } from './dto'; +import { mapTableToDto, tableDtoSchema } from './dto'; describe('mapTableToDto', () => { it('maps direct link fields without requiring link db config', () => { @@ -18,6 +17,8 @@ describe('mapTableToDto', () => { id: tableId, baseId, name: 'Direct Link Source', + description: 'Table description', + icon: '📊', dbTableName: `${baseId}.${tableId}`, primaryFieldId, fields: [ @@ -59,6 +60,10 @@ describe('mapTableToDto', () => { } const linkField = mapped.value.fields.find((field) => field.id === linkFieldId); + expect(tableDtoSchema.parse(mapped.value)).toMatchObject({ + description: 'Table description', + icon: '📊', + }); expect(linkField).toMatchObject({ id: linkFieldId, type: 'link', @@ -158,4 +163,64 @@ describe('mapTableToDto', () => { }, }); }); + + it('maps AI field config through the public table DTO', () => { + const baseId = `bse${'a'.repeat(16)}`; + const tableId = `tbl${'b'.repeat(16)}`; + const primaryFieldId = `fld${'c'.repeat(16)}`; + const aiFieldId = `fld${'d'.repeat(16)}`; + const viewId = `viw${'e'.repeat(16)}`; + const aiConfig = { + type: 'summary', + sourceFieldId: primaryFieldId, + prompt: 'Summarize the name', + }; + + const dto: ITablePersistenceDTO = { + id: tableId, + baseId, + name: 'AI Config Source', + dbTableName: `${baseId}.${tableId}`, + primaryFieldId, + fields: [ + { + id: primaryFieldId, + name: 'Name', + type: 'singleLineText', + }, + { + id: aiFieldId, + name: 'AI Summary', + type: 'singleLineText', + aiConfig, + }, + ], + views: [ + { + id: viewId, + type: 'grid', + name: 'Grid', + columnMeta: { + [primaryFieldId]: { order: 0 }, + [aiFieldId]: { order: 1 }, + }, + }, + ], + }; + + const table = new DefaultTableMapper().toDomain(dto)._unsafeUnwrap(); + const mapped = mapTableToDto(table); + + expect(mapped.isOk()).toBe(true); + if (mapped.isErr()) { + return; + } + + expect( + tableDtoSchema.parse(mapped.value).fields.find((field) => field.id === aiFieldId) + ).toMatchObject({ + id: aiFieldId, + aiConfig, + }); + }); }); diff --git a/packages/v2/contract-http/src/table/dto.ts b/packages/v2/contract-http/src/table/dto.ts index 6918bce9de..9a3b1f18e3 100644 --- a/packages/v2/contract-http/src/table/dto.ts +++ b/packages/v2/contract-http/src/table/dto.ts @@ -61,6 +61,8 @@ const columnMetaSchema = z.record(z.string(), columnMetaEntrySchema); export const viewDtoSchema = z.object({ id: z.string(), name: z.string(), + description: z.string().optional(), + icon: z.string().optional(), type: z.enum(['grid', 'calendar', 'kanban', 'form', 'gallery', 'plugin']), columnMeta: columnMetaSchema, }); @@ -168,6 +170,7 @@ const baseFieldDtoSchema = z.object({ unique: z.boolean().optional(), isComputed: z.boolean().optional(), hasError: z.boolean().optional(), + aiConfig: z.unknown().nullable().optional(), isLookup: z.boolean().optional(), lookupOptions: lookupOptionsSchema.optional(), conditionalLookupOptions: conditionalLookupOptionsSchema.optional(), @@ -473,6 +476,8 @@ export const tableDtoSchema = z.object({ id: z.string(), baseId: z.string(), name: z.string(), + description: z.string().optional(), + icon: z.string().optional(), dbTableName: z.string().optional(), fields: z.array(fieldDtoSchema), views: z.array(viewDtoSchema), @@ -504,6 +509,7 @@ class FieldToDtoVisitor implements IFieldVisitor { unique?: boolean; isComputed?: boolean; hasError?: boolean; + aiConfig?: unknown | null; } { const notNull = field.notNull().toBoolean(); const unique = field.unique().toBoolean(); @@ -520,6 +526,7 @@ class FieldToDtoVisitor implements IFieldVisitor { unique, ...(isComputed ? { isComputed } : {}), ...(hasError ? { hasError } : {}), + ...(field.aiConfig() !== undefined ? { aiConfig: field.aiConfig() } : {}), }; } @@ -1006,6 +1013,8 @@ export const mapTableToDto = (table: Table): Result => { id: table.id().toString(), baseId: table.baseId().toString(), name: table.name().toString(), + ...(table.description() !== undefined ? { description: table.description() } : {}), + ...(table.icon() !== undefined ? { icon: table.icon() } : {}), dbTableName, fields: [...fields], views: [...views], diff --git a/packages/v2/contract-http/src/table/getView.ts b/packages/v2/contract-http/src/table/getView.ts new file mode 100644 index 0000000000..bcbc0f25fe --- /dev/null +++ b/packages/v2/contract-http/src/table/getView.ts @@ -0,0 +1,38 @@ +import type { GetViewResult, IGetViewQueryInput } from '@teable/v2-core'; +import { z } from 'zod'; + +import { + apiErrorResponseDtoSchema, + apiOkResponseDtoSchema, + type HttpErrorStatus, + type IApiErrorResponseDto, + type IApiOkResponseDto, + type IApiResponseDto, +} from '../shared/http'; +import type { IViewReadDto } from './viewReadDto'; +import { viewReadDtoSchema } from './viewReadDto'; + +export type IGetViewRequestDto = IGetViewQueryInput; + +export interface IGetViewResponseDataDto { + view: IViewReadDto; +} + +export type IGetViewResponseDto = IApiResponseDto; +export type IGetViewOkResponseDto = IApiOkResponseDto; +export type IGetViewErrorResponseDto = IApiErrorResponseDto; + +export type IGetViewEndpointResult = + | { status: 200; body: IGetViewOkResponseDto } + | { status: HttpErrorStatus; body: IGetViewErrorResponseDto }; + +export const getViewResponseDataSchema = z.object({ + view: viewReadDtoSchema, +}); + +export const getViewOkResponseSchema = apiOkResponseDtoSchema(getViewResponseDataSchema); +export const getViewErrorResponseSchema = apiErrorResponseDtoSchema; + +export const mapGetViewResultToDto = (result: GetViewResult): IGetViewResponseDataDto => ({ + view: result.view, +}); diff --git a/packages/v2/contract-http/src/table/listTableRecords.ts b/packages/v2/contract-http/src/table/listTableRecords.ts index 1e7832665e..4690c22a34 100644 --- a/packages/v2/contract-http/src/table/listTableRecords.ts +++ b/packages/v2/contract-http/src/table/listTableRecords.ts @@ -36,6 +36,21 @@ export interface IListTableRecordsResponseDataDto { records: ITableRecordDto[]; /** Pagination metadata */ pagination: IListTableRecordsPaginationDto; + /** Ordered leaf group buckets, included only when explicitly requested. */ + groups?: IListTableRecordsGroupDto[]; + /** Search match indexes, included only when explicitly requested. */ + searchMatches?: IListTableRecordsSearchMatchDto[]; +} + +export interface IListTableRecordsGroupDto { + fields: Record; + count: number; +} + +export interface IListTableRecordsSearchMatchDto { + index: number; + fieldId: string; + recordId: string; } export type IListTableRecordsResponseDto = IApiResponseDto; @@ -54,9 +69,22 @@ export const listTableRecordsPaginationSchema = z.object({ hasMore: z.boolean(), }); +export const listTableRecordsGroupSchema = z.object({ + fields: z.record(z.string(), z.unknown()), + count: z.number().int().nonnegative(), +}); + +export const listTableRecordsSearchMatchSchema = z.object({ + index: z.number().int().positive(), + fieldId: z.string().min(1), + recordId: z.string().min(1), +}); + export const listTableRecordsResponseDataSchema = z.object({ records: z.array(tableRecordDtoSchema), pagination: listTableRecordsPaginationSchema, + groups: z.array(listTableRecordsGroupSchema).optional(), + searchMatches: z.array(listTableRecordsSearchMatchSchema).optional(), }); export const listTableRecordsOkResponseSchema = apiOkResponseDtoSchema( @@ -76,5 +104,17 @@ export const mapListTableRecordsResultToDto = ( limit: result.limit, hasMore: result.offset + records.length < result.total, }, + ...(result.groups + ? { groups: result.groups.map(({ fields, count }) => ({ fields: { ...fields }, count })) } + : {}), + ...(result.searchMatches + ? { + searchMatches: result.searchMatches.map(({ index, fieldId, recordId }) => ({ + index, + fieldId: fieldId.toString(), + recordId: recordId.toString(), + })), + } + : {}), })); }; diff --git a/packages/v2/contract-http/src/table/listViews.ts b/packages/v2/contract-http/src/table/listViews.ts new file mode 100644 index 0000000000..114b8b34aa --- /dev/null +++ b/packages/v2/contract-http/src/table/listViews.ts @@ -0,0 +1,38 @@ +import type { IListViewsQueryInput, ListViewsResult } from '@teable/v2-core'; +import { z } from 'zod'; + +import { + apiErrorResponseDtoSchema, + apiOkResponseDtoSchema, + type HttpErrorStatus, + type IApiErrorResponseDto, + type IApiOkResponseDto, + type IApiResponseDto, +} from '../shared/http'; +import type { IViewReadDto } from './viewReadDto'; +import { viewReadDtoSchema } from './viewReadDto'; + +export type IListViewsRequestDto = IListViewsQueryInput; + +export interface IListViewsResponseDataDto { + views: ReadonlyArray; +} + +export type IListViewsResponseDto = IApiResponseDto; +export type IListViewsOkResponseDto = IApiOkResponseDto; +export type IListViewsErrorResponseDto = IApiErrorResponseDto; + +export type IListViewsEndpointResult = + | { status: 200; body: IListViewsOkResponseDto } + | { status: HttpErrorStatus; body: IListViewsErrorResponseDto }; + +export const listViewsResponseDataSchema = z.object({ + views: z.array(viewReadDtoSchema), +}); + +export const listViewsOkResponseSchema = apiOkResponseDtoSchema(listViewsResponseDataSchema); +export const listViewsErrorResponseSchema = apiErrorResponseDtoSchema; + +export const mapListViewsResultToDto = (result: ListViewsResult): IListViewsResponseDataDto => ({ + views: result.views, +}); diff --git a/packages/v2/contract-http/src/table/mapTableDtoToDomain.ts b/packages/v2/contract-http/src/table/mapTableDtoToDomain.ts index d106e5c718..5c25f1ebb5 100644 --- a/packages/v2/contract-http/src/table/mapTableDtoToDomain.ts +++ b/packages/v2/contract-http/src/table/mapTableDtoToDomain.ts @@ -37,6 +37,7 @@ import { Table, TableId, TableName, + TableProperties, TextDefaultValue, UserDefaultValue, UserMultiplicity, @@ -480,16 +481,22 @@ export const mapTableDtoToDomain = (table: ITableDto): Result sequenceResults(table.views.map(mapViewDtoToDomain)).andThen((views) => optional(table.dbTableName, DbTableName.rehydrate).andThen((dbTableName) => { - const props = { - id, - baseId, - name, - primaryFieldId, - fields, - views, - ...(dbTableName ? { dbTableName } : {}), - }; - return Table.rehydrate(props); + return TableProperties.create({ + ...(table.description !== undefined ? { description: table.description } : {}), + ...(table.icon !== undefined ? { icon: table.icon } : {}), + }).andThen((properties) => { + const props = { + id, + baseId, + name, + properties, + primaryFieldId, + fields, + views, + ...(dbTableName ? { dbTableName } : {}), + }; + return Table.rehydrate(props); + }); }) ) ) diff --git a/packages/v2/contract-http/src/table/updateTableProperties.ts b/packages/v2/contract-http/src/table/updateTableProperties.ts new file mode 100644 index 0000000000..271e9e1d78 --- /dev/null +++ b/packages/v2/contract-http/src/table/updateTableProperties.ts @@ -0,0 +1,55 @@ +import type { + DomainError, + IUpdateTablePropertiesCommandInput, + UpdateTablePropertiesResult, +} from '@teable/v2-core'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import type { IDomainEventDto } from '../shared/domainEvent'; +import { domainEventDtoSchema, mapDomainEventToDto } from '../shared/domainEvent'; +import { + apiErrorResponseDtoSchema, + apiOkResponseDtoSchema, + type HttpErrorStatus, + type IApiErrorResponseDto, + type IApiOkResponseDto, + type IApiResponseDto, +} from '../shared/http'; +import type { ITableDto } from './dto'; +import { mapTableToDto, tableDtoSchema } from './dto'; + +export type IUpdateTablePropertiesRequestDto = IUpdateTablePropertiesCommandInput; + +export interface IUpdateTablePropertiesResponseDataDto { + table: ITableDto; + events: Array; +} + +export type IUpdateTablePropertiesResponseDto = + IApiResponseDto; +export type IUpdateTablePropertiesOkResponseDto = + IApiOkResponseDto; +export type IUpdateTablePropertiesErrorResponseDto = IApiErrorResponseDto; + +export type IUpdateTablePropertiesEndpointResult = + | { status: 200; body: IUpdateTablePropertiesOkResponseDto } + | { status: HttpErrorStatus; body: IUpdateTablePropertiesErrorResponseDto }; + +export const updateTablePropertiesResponseDataSchema = z.object({ + table: tableDtoSchema, + events: z.array(domainEventDtoSchema), +}); + +export const updateTablePropertiesOkResponseSchema = apiOkResponseDtoSchema( + updateTablePropertiesResponseDataSchema +); +export const updateTablePropertiesErrorResponseSchema = apiErrorResponseDtoSchema; + +export const mapUpdateTablePropertiesResultToDto = ( + result: UpdateTablePropertiesResult +): Result => + mapTableToDto(result.table).map((table) => ({ + table, + events: result.events.map(mapDomainEventToDto), + })); diff --git a/packages/v2/contract-http/src/table/viewOperations.ts b/packages/v2/contract-http/src/table/viewOperations.ts new file mode 100644 index 0000000000..ad46ff45f0 --- /dev/null +++ b/packages/v2/contract-http/src/table/viewOperations.ts @@ -0,0 +1,272 @@ +import type { + ApplyViewManualSortResult, + CreateViewResult, + DomainError, + GetViewFilterLinkRecordsResult, + GetViewPluginInstallResult, + GetViewSnapshotsResult, + IDomainEvent, + Table, + UpdateViewPluginStorageResult, + ViewId, +} from '@teable/v2-core'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainEventDtoSchema, mapDomainEventToDto } from '../shared/domainEvent'; +import { + apiErrorResponseDtoSchema, + apiOkResponseDtoSchema, + type HttpErrorStatus, + type IApiErrorResponseDto, + type IApiOkResponseDto, +} from '../shared/http'; +import { mapTableToDto, tableDtoSchema, type ITableDto } from './dto'; +import { viewReadDtoSchema } from './viewReadDto'; + +export const viewMutationResponseDataSchema = z.object({ + table: tableDtoSchema, + viewId: z.string(), + events: z.array(domainEventDtoSchema), +}); + +export type IViewMutationResponseDataDto = z.infer; +export type IViewMutationOkResponseDto = IApiOkResponseDto; +export type IViewMutationEndpointResult = + | { status: 200; body: IViewMutationOkResponseDto } + | { status: HttpErrorStatus; body: IApiErrorResponseDto }; + +export const viewMutationOkResponseSchema = apiOkResponseDtoSchema(viewMutationResponseDataSchema); + +export const viewShareMutationResponseDataSchema = z.object({ + viewId: z.string(), + shareId: z.string(), +}); +export type IViewShareMutationResponseDataDto = z.infer; +export type IViewShareMutationEndpointResult = + | { status: 200; body: IApiOkResponseDto } + | { status: HttpErrorStatus; body: IApiErrorResponseDto }; +export const viewShareMutationOkResponseSchema = apiOkResponseDtoSchema( + viewShareMutationResponseDataSchema +); + +export const viewShareStateResponseDataSchema = z.object({ + viewId: z.string(), +}); +export type IViewShareStateResponseDataDto = z.infer; +export type IViewShareStateEndpointResult = + | { status: 200; body: IApiOkResponseDto } + | { status: HttpErrorStatus; body: IApiErrorResponseDto }; +export const viewShareStateOkResponseSchema = apiOkResponseDtoSchema( + viewShareStateResponseDataSchema +); + +export const applyViewManualSortResponseDataSchema = viewMutationResponseDataSchema.extend({ + updatedRecordCount: z.number().int().nonnegative(), +}); +export type IApplyViewManualSortResponseDataDto = z.infer< + typeof applyViewManualSortResponseDataSchema +>; +export type IApplyViewManualSortEndpointResult = + | { status: 200; body: IApiOkResponseDto } + | { status: HttpErrorStatus; body: IApiErrorResponseDto }; +export const applyViewManualSortOkResponseSchema = apiOkResponseDtoSchema( + applyViewManualSortResponseDataSchema +); + +type ViewMutationResult = { + readonly table: Table; + readonly viewId: ViewId; + readonly events: ReadonlyArray; +}; + +export const mapViewMutationResultToDto = ( + result: ViewMutationResult +): Result => + mapTableToDto(result.table).map((table) => ({ + table, + viewId: result.viewId.toString(), + events: result.events.map(mapDomainEventToDto), + })); + +export const mapViewShareMutationResultToDto = (result: { + readonly viewId: ViewId; + readonly shareId: string; +}): IViewShareMutationResponseDataDto => ({ + viewId: result.viewId.toString(), + shareId: result.shareId, +}); + +export const mapViewShareStateResultToDto = (result: { + readonly viewId: ViewId; +}): IViewShareStateResponseDataDto => ({ + viewId: result.viewId.toString(), +}); + +export const mapApplyViewManualSortResultToDto = ( + result: ApplyViewManualSortResult +): Result => + mapViewMutationResultToDto(result).map((mapped) => ({ + ...mapped, + updatedRecordCount: result.updatedRecordCount, + })); + +export const getViewFilterLinkRecordsResponseDataSchema = z.object({ + groups: z.array( + z.object({ + tableId: z.string(), + records: z.array(z.object({ id: z.string(), title: z.string().optional() })), + }) + ), +}); +export type IGetViewFilterLinkRecordsResponseDataDto = z.infer< + typeof getViewFilterLinkRecordsResponseDataSchema +>; +export type IGetViewFilterLinkRecordsEndpointResult = + | { status: 200; body: IApiOkResponseDto } + | { status: HttpErrorStatus; body: IApiErrorResponseDto }; +export const getViewFilterLinkRecordsOkResponseSchema = apiOkResponseDtoSchema( + getViewFilterLinkRecordsResponseDataSchema +); +export const mapGetViewFilterLinkRecordsResultToDto = ( + result: GetViewFilterLinkRecordsResult +): IGetViewFilterLinkRecordsResponseDataDto => ({ + groups: result.groups.map((group) => ({ + tableId: group.tableId, + records: group.records.map((record) => ({ ...record })), + })), +}); + +export const getViewSnapshotsResponseDataSchema = z.object({ + snapshots: z.array( + z.object({ + id: z.string(), + v: z.number().int().nonnegative(), + type: z.literal('json0'), + data: viewReadDtoSchema, + }) + ), +}); +export type IGetViewSnapshotsResponseDataDto = z.infer; +export type IGetViewSnapshotsEndpointResult = + | { status: 200; body: IApiOkResponseDto } + | { status: HttpErrorStatus; body: IApiErrorResponseDto }; +export const getViewSnapshotsOkResponseSchema = apiOkResponseDtoSchema( + getViewSnapshotsResponseDataSchema +); +export const mapGetViewSnapshotsResultToDto = ( + result: GetViewSnapshotsResult +): IGetViewSnapshotsResponseDataDto => ({ + snapshots: result.snapshots.map((snapshot) => ({ + id: snapshot.id, + v: snapshot.version, + type: 'json0', + data: snapshot.view, + })), +}); + +export const listViewDocIdsResponseDataSchema = z.object({ ids: z.array(z.string()) }); +export type IListViewDocIdsResponseDataDto = z.infer; +export type IListViewDocIdsEndpointResult = + | { status: 200; body: IApiOkResponseDto } + | { status: HttpErrorStatus; body: IApiErrorResponseDto }; +export const listViewDocIdsOkResponseSchema = apiOkResponseDtoSchema( + listViewDocIdsResponseDataSchema +); + +export const getViewPluginInstallResponseDataSchema = z.object({ + pluginId: z.string(), + pluginInstallId: z.string(), + baseId: z.string(), + name: z.string(), + url: z.string().optional(), + storage: z.record(z.string(), z.unknown()).optional(), +}); +export type IGetViewPluginInstallResponseDataDto = z.infer< + typeof getViewPluginInstallResponseDataSchema +>; +export type IGetViewPluginInstallEndpointResult = + | { status: 200; body: IApiOkResponseDto } + | { status: HttpErrorStatus; body: IApiErrorResponseDto }; +export const getViewPluginInstallOkResponseSchema = apiOkResponseDtoSchema( + getViewPluginInstallResponseDataSchema +); +export const mapGetViewPluginInstallResultToDto = ( + result: GetViewPluginInstallResult +): IGetViewPluginInstallResponseDataDto => ({ + pluginId: result.installation.pluginId, + pluginInstallId: result.installation.id, + baseId: result.installation.baseId, + name: result.installation.name, + ...(result.installation.url !== undefined ? { url: result.installation.url } : {}), + ...(result.installation.storage !== undefined + ? { storage: { ...result.installation.storage } } + : {}), +}); + +export const installViewPluginInputSchema = z + .object({ + tableId: z.string(), + pluginId: z.string().min(1), + name: z.string().optional(), + }) + .strict(); + +export const installViewPluginResponseDataSchema = z.object({ + table: tableDtoSchema, + viewId: z.string(), + pluginId: z.string(), + pluginInstallId: z.string(), + name: z.string(), + events: z.array(domainEventDtoSchema), +}); +export type IInstallViewPluginResponseDataDto = z.infer; +export type IInstallViewPluginEndpointResult = + | { status: 200; body: IApiOkResponseDto } + | { status: HttpErrorStatus; body: IApiErrorResponseDto }; +export const installViewPluginOkResponseSchema = apiOkResponseDtoSchema( + installViewPluginResponseDataSchema +); + +export const mapInstallViewPluginResultToDto = ( + result: CreateViewResult +): Result => + result.table.getView(result.viewId).andThen((view) => + mapTableToDto(result.table).map((table: ITableDto) => { + const options = view.options() as { pluginId: string; pluginInstallId: string }; + return { + table, + viewId: result.viewId.toString(), + pluginId: options.pluginId, + pluginInstallId: options.pluginInstallId, + name: view.name().toString(), + events: result.events.map(mapDomainEventToDto), + }; + }) + ); + +export const updateViewPluginStorageResponseDataSchema = z.object({ + tableId: z.string(), + viewId: z.string(), + pluginInstallId: z.string(), + storage: z.record(z.string(), z.unknown()).optional(), +}); +export type IUpdateViewPluginStorageResponseDataDto = z.infer< + typeof updateViewPluginStorageResponseDataSchema +>; +export type IUpdateViewPluginStorageEndpointResult = + | { status: 200; body: IApiOkResponseDto } + | { status: HttpErrorStatus; body: IApiErrorResponseDto }; +export const updateViewPluginStorageOkResponseSchema = apiOkResponseDtoSchema( + updateViewPluginStorageResponseDataSchema +); +export const mapUpdateViewPluginStorageResultToDto = ( + result: UpdateViewPluginStorageResult +): IUpdateViewPluginStorageResponseDataDto => ({ + tableId: result.tableId, + viewId: result.viewId, + pluginInstallId: result.pluginInstallId, + ...(result.storage !== undefined ? { storage: { ...result.storage } } : {}), +}); + +export const viewOperationsErrorResponseSchema = apiErrorResponseDtoSchema; diff --git a/packages/v2/contract-http/src/table/viewReadDto.spec.ts b/packages/v2/contract-http/src/table/viewReadDto.spec.ts new file mode 100644 index 0000000000..9f9a314382 --- /dev/null +++ b/packages/v2/contract-http/src/table/viewReadDto.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import { viewReadDtoSchema } from './viewReadDto'; + +const baseView = { + id: `viw${'a'.repeat(16)}`, + name: 'View', + createdBy: 'system', + createdTime: '2026-07-31T00:00:00.000Z', + columnMeta: {}, +}; + +describe('viewReadDtoSchema', () => { + it.each(['grid', 'kanban', 'gallery', 'calendar', 'form', 'plugin'] as const)( + 'accepts the %s View subtype', + (type) => { + const result = viewReadDtoSchema.safeParse({ ...baseView, type }); + + expect(result.success).toBe(true); + } + ); + + it('preserves the complete public View projection', () => { + const result = viewReadDtoSchema.parse({ + ...baseView, + version: 3, + type: 'grid', + description: 'Planning', + order: 2, + options: { rowHeight: 'short' }, + filter: { conjunction: 'and', filterSet: [] }, + sort: { + sortObjs: [{ fieldId: 'fldTitle', order: 'asc' }], + manualSort: false, + }, + group: [{ fieldId: 'fldStatus', order: 'desc' }], + isLocked: true, + shareId: 'shrCredential', + enableShare: true, + shareMeta: { + allowCopy: false, + includeHiddenField: true, + includeRecords: true, + password: 'secret', + submit: { requireLogin: true }, + allowEdit: true, + }, + lastModifiedBy: 'editor', + lastModifiedTime: '2026-07-31T01:00:00.000Z', + columnMeta: { + fldTitle: { order: 0, width: 240, custom: 'preserved' }, + }, + }); + + expect(result).toMatchObject({ + version: 3, + shareId: 'shrCredential', + sort: { manualSort: false }, + columnMeta: { + fldTitle: { custom: 'preserved' }, + }, + }); + }); + + it('rejects an unknown View subtype', () => { + const result = viewReadDtoSchema.safeParse({ ...baseView, type: 'timeline' }); + + expect(result.success).toBe(false); + }); + + it('rejects malformed sort and group directions', () => { + const result = viewReadDtoSchema.safeParse({ + ...baseView, + type: 'grid', + sort: { sortObjs: [{ fieldId: 'fldTitle', order: 'sideways' }] }, + group: [{ fieldId: 'fldStatus', order: 'sideways' }], + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/v2/contract-http/src/table/viewReadDto.ts b/packages/v2/contract-http/src/table/viewReadDto.ts new file mode 100644 index 0000000000..76e5e0bc85 --- /dev/null +++ b/packages/v2/contract-http/src/table/viewReadDto.ts @@ -0,0 +1,54 @@ +import type { ViewQueryResultView } from '@teable/v2-core'; +import { z } from 'zod'; + +const viewColumnMetaEntryDtoSchema = z.looseObject({ + order: z.number().nullable().optional(), + visible: z.boolean().optional(), + hidden: z.boolean().optional(), + width: z.number().optional(), + required: z.boolean().optional(), + statisticFunc: z.string().nullable().optional(), +}); + +const viewShareMetaDtoSchema = z.object({ + allowCopy: z.boolean().optional(), + includeHiddenField: z.boolean().optional(), + password: z.string().optional(), + includeRecords: z.boolean().optional(), + submit: z.object({ requireLogin: z.boolean().optional() }).optional(), + allowEdit: z.boolean().optional(), +}); + +const viewOrderItemDtoSchema = z.object({ + fieldId: z.string(), + order: z.enum(['asc', 'desc']), +}); + +export const viewReadDtoSchema: z.ZodType = z.object({ + id: z.string(), + version: z.number().int().nonnegative().optional(), + name: z.string(), + type: z.enum(['grid', 'kanban', 'gallery', 'calendar', 'form', 'plugin']), + description: z.string().optional(), + order: z.number().optional(), + options: z.unknown().optional(), + filter: z.unknown().optional(), + sort: z + .object({ + sortObjs: z.array(viewOrderItemDtoSchema), + manualSort: z.boolean().optional(), + }) + .optional(), + group: z.array(viewOrderItemDtoSchema).optional(), + isLocked: z.boolean().optional(), + shareId: z.string().optional(), + enableShare: z.boolean().optional(), + shareMeta: viewShareMetaDtoSchema.optional(), + createdBy: z.string(), + lastModifiedBy: z.string().optional(), + createdTime: z.string(), + lastModifiedTime: z.string().optional(), + columnMeta: z.record(z.string(), viewColumnMetaEntryDtoSchema), +}); + +export type IViewReadDto = ViewQueryResultView; diff --git a/packages/v2/contract-http/src/tableQueryOps/contract.ts b/packages/v2/contract-http/src/tableQueryOps/contract.ts new file mode 100644 index 0000000000..8b7b883755 --- /dev/null +++ b/packages/v2/contract-http/src/tableQueryOps/contract.ts @@ -0,0 +1,45 @@ +import { oc } from '@orpc/contract'; + +import { + getSearchAccessPathCapabilitiesInputSchema, + getSearchAccessPathCapabilitiesOkResponseSchema, + getSearchAccessPathStatusInputSchema, + getSearchAccessPathStatusOkResponseSchema, + reconcileSearchAccessPathInputSchema, + reconcileSearchAccessPathOkResponseSchema, +} from './searchAccessPath'; + +export const v2TableQueryOpsContract = { + searchAccessPath: { + getStatus: oc + .route({ + method: 'GET', + path: '/table-query-ops/search-access-path/status', + successStatus: 200, + summary: 'Get managed search access-path status', + tags: ['table-query-ops'], + }) + .input(getSearchAccessPathStatusInputSchema) + .output(getSearchAccessPathStatusOkResponseSchema), + getCapabilities: oc + .route({ + method: 'GET', + path: '/table-query-ops/search-access-path/capabilities', + successStatus: 200, + summary: 'Get managed search access-path capabilities', + tags: ['table-query-ops'], + }) + .input(getSearchAccessPathCapabilitiesInputSchema) + .output(getSearchAccessPathCapabilitiesOkResponseSchema), + reconcile: oc + .route({ + method: 'POST', + path: '/table-query-ops/search-access-path/reconcile', + successStatus: 200, + summary: 'Reconcile a managed search access path', + tags: ['table-query-ops'], + }) + .input(reconcileSearchAccessPathInputSchema) + .output(reconcileSearchAccessPathOkResponseSchema), + }, +}; diff --git a/packages/v2/contract-http/src/tableQueryOps/index.ts b/packages/v2/contract-http/src/tableQueryOps/index.ts new file mode 100644 index 0000000000..1684468674 --- /dev/null +++ b/packages/v2/contract-http/src/tableQueryOps/index.ts @@ -0,0 +1,2 @@ +export * from './contract'; +export * from './searchAccessPath'; diff --git a/packages/v2/contract-http/src/tableQueryOps/searchAccessPath.spec.ts b/packages/v2/contract-http/src/tableQueryOps/searchAccessPath.spec.ts new file mode 100644 index 0000000000..47f4646077 --- /dev/null +++ b/packages/v2/contract-http/src/tableQueryOps/searchAccessPath.spec.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; + +import { + getSearchAccessPathCapabilitiesOkResponseSchema, + getSearchAccessPathStatusOkResponseSchema, + reconcileSearchAccessPathInputSchema, + reconcileSearchAccessPathOkResponseSchema, +} from './searchAccessPath'; + +describe('search access path HTTP contract', () => { + it('exposes the native managed status model', () => { + expect( + getSearchAccessPathStatusOkResponseSchema.parse({ + ok: true, + data: { + status: { + tableId: 'tbl-status', + state: 'ready', + configured: true, + languageConfig: 'simple', + semantics: 'substring', + provider: 'pg_trgm', + accessPath: 'generated_text', + coveredFieldCount: 3, + }, + }, + }) + ).toMatchObject({ ok: true, data: { status: { state: 'ready' } } }); + + expect( + getSearchAccessPathStatusOkResponseSchema.safeParse({ + ok: true, + data: { + status: { + tableId: 'tbl-status', + activated: true, + configured: true, + coveredFieldCount: 3, + }, + }, + }).success + ).toBe(false); + }); + + it.each(['disabled', 'rebuild_pending', 'stale', 'unknown'] as const)( + 'accepts native managed status %s', + (state) => { + expect( + getSearchAccessPathStatusOkResponseSchema.parse({ + ok: true, + data: { + status: { + tableId: 'tbl-status', + state, + configured: state !== 'disabled', + coveredFieldCount: 0, + }, + }, + }).data.status.state + ).toBe(state); + } + ); + + it('exposes provider capabilities without v1 abnormal/repair flags', () => { + expect( + getSearchAccessPathCapabilitiesOkResponseSchema.parse({ + ok: true, + data: { + capabilities: [ + { + provider: 'pg_trgm', + extensionName: 'pg_trgm', + operatorClass: 'gin_trgm_ops', + operatorClassInstalled: true, + minimumProbeLength: 3, + state: 'ready', + installed: true, + available: true, + preloaded: true, + }, + ], + }, + }).data.capabilities + ).toHaveLength(1); + }); + + it.each(['create', 'rebuild', 'drop'] as const)('accepts reconcile mode %s', (mode) => { + expect( + reconcileSearchAccessPathInputSchema.parse({ + tableId: 'tbl-reconcile', + mode, + expectedDefinitionKey: 'definition-key', + semantics: 'substring', + provider: 'pg_trgm', + languageConfig: 'simple', + fieldIds: ['fld-primary'], + searchProbe: 'needle', + }) + ).toMatchObject({ tableId: 'tbl-reconcile', mode }); + }); + + it('does not expose adapter execution controls in the public input', () => { + expect( + reconcileSearchAccessPathInputSchema.safeParse({ + tableId: 'tbl-reconcile', + mode: 'create', + validationMode: 'real_ddl', + }).success + ).toBe(false); + expect( + reconcileSearchAccessPathInputSchema.safeParse({ + tableId: 'tbl-reconcile', + mode: 'create', + allowLargeTableRewrite: true, + }).success + ).toBe(false); + }); + + it.each([ + { expectedDefinitionKey: 'x'.repeat(513) }, + { languageConfig: 'x'.repeat(129) }, + { fieldIds: ['x'.repeat(129)] }, + { fieldIds: Array.from({ length: 501 }, (_, index) => `fld-${index}`) }, + { searchProbe: 'x'.repeat(2_001) }, + ])('bounds public reconcile text and list inputs %#', (extra) => { + expect( + reconcileSearchAccessPathInputSchema.safeParse({ + tableId: 'tbl-reconcile', + mode: 'create', + ...extra, + }).success + ).toBe(false); + }); + + it('maps the reconciler result without v1 index state aliases', () => { + expect( + reconcileSearchAccessPathOkResponseSchema.parse({ + ok: true, + data: { + result: { + action: 'created', + tableId: 'tbl-reconcile', + definitionKey: 'definition-key', + generatedColumnName: '__teable_search', + indexName: 'idx_teable_search', + languageConfig: 'simple', + semantics: 'substring', + provider: 'pg_trgm', + fieldIds: ['fld-primary'], + status: 'ready', + }, + }, + }).data.result + ).toMatchObject({ action: 'created', status: 'ready' }); + }); +}); diff --git a/packages/v2/contract-http/src/tableQueryOps/searchAccessPath.ts b/packages/v2/contract-http/src/tableQueryOps/searchAccessPath.ts new file mode 100644 index 0000000000..90db166392 --- /dev/null +++ b/packages/v2/contract-http/src/tableQueryOps/searchAccessPath.ts @@ -0,0 +1,162 @@ +import { z } from 'zod'; + +import { + apiErrorResponseDtoSchema, + apiOkResponseDtoSchema, + type HttpErrorStatus, + type IApiErrorResponseDto, + type IApiOkResponseDto, + type IApiResponseDto, +} from '../shared/http'; + +export const searchAccessPathStatusStateSchema = z.enum([ + 'disabled', + 'ready', + 'rebuild_pending', + 'stale', + 'unknown', +]); + +export const searchAccessPathStatusSchema = z + .object({ + tableId: z.string(), + state: searchAccessPathStatusStateSchema, + configured: z.boolean(), + languageConfig: z.string().optional(), + semantics: z.enum(['substring', 'lexical']).optional(), + provider: z.enum(['pg_trgm', 'pg_bigm', 'tsvector']).optional(), + accessPath: z.enum(['generated_text', 'generated_tsvector']).optional(), + coveredFieldCount: z.number().int().nonnegative(), + }) + .strict(); + +export const getSearchAccessPathStatusInputSchema = z + .object({ + tableId: z.string(), + }) + .strict(); + +export const getSearchAccessPathStatusResponseDataSchema = z + .object({ + status: searchAccessPathStatusSchema, + }) + .strict(); + +export type IGetSearchAccessPathStatusInput = z.infer; +export type IGetSearchAccessPathStatusResponseData = z.infer< + typeof getSearchAccessPathStatusResponseDataSchema +>; +export type IGetSearchAccessPathStatusResponse = + IApiResponseDto; +export type IGetSearchAccessPathStatusOkResponse = + IApiOkResponseDto; +export type IGetSearchAccessPathStatusErrorResponse = IApiErrorResponseDto; +export type IGetSearchAccessPathStatusEndpointResult = + | { status: 200; body: IGetSearchAccessPathStatusOkResponse } + | { status: HttpErrorStatus; body: IGetSearchAccessPathStatusErrorResponse }; + +export const getSearchAccessPathStatusOkResponseSchema = apiOkResponseDtoSchema( + getSearchAccessPathStatusResponseDataSchema +); +export const getSearchAccessPathStatusErrorResponseSchema = apiErrorResponseDtoSchema; + +export const searchAccessPathCapabilitySchema = z + .object({ + provider: z.enum(['pg_trgm', 'pg_bigm']), + extensionName: z.enum(['pg_trgm', 'pg_bigm']), + operatorClass: z.enum(['gin_trgm_ops', 'gin_bigm_ops']), + operatorClassSchema: z.string().optional(), + operatorClassInstalled: z.boolean(), + minimumProbeLength: z.number().int().nonnegative(), + state: z.enum([ + 'ready', + 'requires_database_extension', + 'requires_cluster_restart', + 'unavailable', + ]), + installed: z.boolean(), + available: z.boolean(), + preloaded: z.boolean(), + reason: z.string().optional(), + }) + .strict(); + +export const getSearchAccessPathCapabilitiesInputSchema = z.object({}).strict(); +export const getSearchAccessPathCapabilitiesResponseDataSchema = z + .object({ + capabilities: z.array(searchAccessPathCapabilitySchema), + }) + .strict(); + +export type IGetSearchAccessPathCapabilitiesInput = z.infer< + typeof getSearchAccessPathCapabilitiesInputSchema +>; +export type IGetSearchAccessPathCapabilitiesResponseData = z.infer< + typeof getSearchAccessPathCapabilitiesResponseDataSchema +>; +export type IGetSearchAccessPathCapabilitiesResponse = + IApiResponseDto; +export type IGetSearchAccessPathCapabilitiesOkResponse = + IApiOkResponseDto; +export type IGetSearchAccessPathCapabilitiesErrorResponse = IApiErrorResponseDto; +export type IGetSearchAccessPathCapabilitiesEndpointResult = + | { status: 200; body: IGetSearchAccessPathCapabilitiesOkResponse } + | { status: HttpErrorStatus; body: IGetSearchAccessPathCapabilitiesErrorResponse }; + +export const getSearchAccessPathCapabilitiesOkResponseSchema = apiOkResponseDtoSchema( + getSearchAccessPathCapabilitiesResponseDataSchema +); +export const getSearchAccessPathCapabilitiesErrorResponseSchema = apiErrorResponseDtoSchema; + +export const reconcileSearchAccessPathInputSchema = z + .object({ + tableId: z.string(), + mode: z.enum(['create', 'rebuild', 'drop']), + expectedDefinitionKey: z.string().max(512).optional(), + semantics: z.enum(['substring', 'lexical']).optional(), + provider: z.enum(['pg_trgm', 'pg_bigm', 'tsvector']).optional(), + languageConfig: z.string().max(128).optional(), + fieldIds: z.array(z.string().max(128)).min(1).max(500).optional(), + searchProbe: z.string().max(2_000).optional(), + }) + .strict(); + +export const reconcileSearchAccessPathResultSchema = z + .object({ + action: z.enum(['created', 'rebuilt', 'verified', 'dropped']), + tableId: z.string(), + definitionKey: z.string(), + generatedColumnName: z.string(), + indexName: z.string(), + languageConfig: z.string(), + semantics: z.enum(['substring', 'lexical']).optional(), + provider: z.enum(['pg_trgm', 'pg_bigm', 'tsvector']).optional(), + fieldIds: z.array(z.string()), + status: z.enum(['ready', 'disabled']), + planEvidence: z.unknown().optional(), + }) + .strict(); + +export const reconcileSearchAccessPathResponseDataSchema = z + .object({ + result: reconcileSearchAccessPathResultSchema, + }) + .strict(); + +export type IReconcileSearchAccessPathInput = z.infer; +export type IReconcileSearchAccessPathResponseData = z.infer< + typeof reconcileSearchAccessPathResponseDataSchema +>; +export type IReconcileSearchAccessPathResponse = + IApiResponseDto; +export type IReconcileSearchAccessPathOkResponse = + IApiOkResponseDto; +export type IReconcileSearchAccessPathErrorResponse = IApiErrorResponseDto; +export type IReconcileSearchAccessPathEndpointResult = + | { status: 200; body: IReconcileSearchAccessPathOkResponse } + | { status: HttpErrorStatus; body: IReconcileSearchAccessPathErrorResponse }; + +export const reconcileSearchAccessPathOkResponseSchema = apiOkResponseDtoSchema( + reconcileSearchAccessPathResponseDataSchema +); +export const reconcileSearchAccessPathErrorResponseSchema = apiErrorResponseDtoSchema; diff --git a/packages/v2/core/docs/RECORD_READ_ARCHITECTURE.md b/packages/v2/core/docs/RECORD_READ_ARCHITECTURE.md new file mode 100644 index 0000000000..c6bde808da --- /dev/null +++ b/packages/v2/core/docs/RECORD_READ_ARCHITECTURE.md @@ -0,0 +1,179 @@ +# Record Read Architecture (Pure V2) + +Declaration: If the folder I belong to changes, please update me. + +## Goal + +`GET /api/table/:tableId/record` (and get-by-id) must be a **pure V2 read stack**: + +1. No V1 `RecordService` calls (`getSnapshotBulkWithPermission`, `getDocIdsByQuery` on the list path). +2. Handlers do **not** know authority-matrix. Permission enters as **generic scope**. +3. No outer-assembled permission view CTE (`view_cte_tmp` / raw `cteSql`) that the query repository depends on. +4. Tests first, including authority-matrix read cases. + +## Current hybrid (to retire) + +``` +RecordOpenApiV2Service.getRecords + ├─ EE getReadQuerySource → opaque CTE SQL + enabledFieldIds + ├─ V2 ListTableRecords(projection: []) → ordered ids (FROM view_cte_tmp) + └─ V1 getSnapshotBulkWithPermission → cell payload (wrapView CTE again) +``` + +Problems: dual CTE generation, SQL ownership inverted (upper layer builds FROM target), V1 payload path, handler half-encodes authz via `enabledFieldIds`. + +## Target + +``` +OpenAPI adapter + → RecordQueryPluginRunner.prepare / guard / getScope + → ListTableRecordsQuery(options.queryScope) + → ListTableRecordsHandler (AND recordSpec, intersect readableFieldIds) + → TableRecordQueryRepository.find (physical table, stored mode) + → map TableRecordReadModel → IRecordsVo (V2 export) +``` + +Write path already uses `IRecordWritePlugin` + `RecordWritePluginScope`. Read mirrors that with `IRecordQueryPlugin` + `RecordQueryPluginScope`. + +## Scope model + +```ts +interface RecordQueryPluginScope { + /** Row visibility: AND-ed into the query condition tree */ + recordSpec?: ISpecification; + /** Static field allow-list; undefined = all fields; empty = no user fields */ + readableFieldIds?: ReadonlySet; + /** + * Conditional field visibility (replaces CTE CASE WHEN). + * Applied as SELECT masks in the adapter — not as outer WITH view. + */ + fieldMasks?: ReadonlyArray<{ + fieldId: string; + visibleWhen: ISpecification; + }>; +} +``` + +| Authority effect | Mechanism | +| ---------------------- | ---------------------------------------------------------- | +| Table-level deny | `guard()` fail → HTTP 403 (product default) | +| Row filter | `recordSpec` → WHERE | +| Static field deny | `readableFieldIds` → projection + strip filter/sort/search | +| Conditional field read | `fieldMasks` → SELECT CASE (phase after static scope) | + +## Invariants + +1. Handlers never import authority-matrix or Nest permission services. +2. Repository never accepts raw permission SQL strings for reads (after cutover). +3. Scope is data (specs + field sets), not “rewrite FROM”. +4. Community: no plugin registered → unrestricted. +5. List remains **stored mode** (not computed). +6. **No V1 services on the pure record-read path** — not only `RecordService` / + `getSnapshotBulk*`, but also `FieldService`, `AggregationService`, + `RecordPermissionService`, and any other Nest V1 feature service. + Field metadata comes from the **V2 `Table` aggregate** already loaded for the + request (`table.getFields()`, `fieldIds()`, `getOrderedVisibleFieldIds`, + domain field `formatting()` / type). Do **not** invent a `V2FieldService` + that wraps V1; do not re-query field VOs for projection / filter meta / + cell text formatting on this path. +7. Adversarial review of pure-V2 reads must **BLOCK** any new V1 call on + `getRecords` / `getRecord` (and list handler). Residual hybrid only if + explicitly listed under “Remaining gaps” (today: search-hit extra and the + conditional-mask ShareDB compatibility fallback). + +## Phased delivery + +| Phase | Deliverable | Status | +| ----- | --------------------------------------------------------------------------------------------- | ------ | +| 0 | This doc + call-site inventory | done | +| 1 | `IRecordQueryPlugin` + runner + DI | done | +| 1b | EE `getRecordReadPolicy` + record query plugin | done | +| 2 | Handler applies `queryScope` (AND `recordSpec`, field allow-list); CTE skipped when scope set | done | +| 3 | Full payload from V2 list projection; no V1 snapshot bulk on getRecords | done | +| 4 | `fieldMasks` applied post-read (domain `isSatisfiedBy` null-out) | done | +| 5 | Prefer scope over `IRecordReadQuerySource` on list | done | +| 6 | Review hardenings: 403 getOne, empty allow-list, keepPrimary, DB masks, no FieldService | done | +| 6b | Handler defaults projection to `readableFieldIds` when client projection omitted | done | +| 7a | V2 group counts/headers/collapse from the same filter/search/permission scope | done | +| 7b | Pure search-hit `queryExtra` (no V1 `getDocIdsByQuery`) | open | +| 8 | EE authority e2e under V2 canary for pure getRecords/getRecord | done | +| 8b | ShareDB table query IDs + initial snapshots use V2 list/getByIds under the same canary | done | +| 9 | Optional SQL CASE for fieldMasks / side-channel close | open | + +**Merge gate (review standard, still do not auto-merge):** Phases **0–6b + 8** closed. + +- Pure list/get payload: unit + EE canary e2e (`v2-authz-plugin.e2e-spec.ts` → `pure v2 record read (T6411)`) +- Phase **7b** (search-hit `queryExtra`) and **9** (SQL CASE) remain **documented residuals** — not + merge blockers for pure payload path. + +### Current pure path + +OpenAPI V2 `getRecords` / `getRecord` and ShareDB table initial reads: + +1. Load table aggregate +2. `RecordQueryPluginRunner.prepare` → `guard` → `getScope` +3. `ListTableRecordsQuery` with real projection + `queryScope` (no CTE when scope present); + ShareDB query IDs use row-scoped `list`; snapshots use `getByIds` with host-controlled + `keepPrimaryKey` for subscribed-document/version continuity, retaining non-primary field scope + - Group counts and header points are aggregated by the V2 repository from the same composed + record spec, search predicate, and configured group ordering. Collapsed headers are translated + into a V2 exclusion filter and the visible page is queried again through the same scope. + - When ShareDB requests group/search/order semantics over conditionally masked fields, a plugin + may opt into `legacyPermissionQueryCompatible`. The legacy permission query supplies the + mask-aware ordering/extras, then V2 revalidates every returned ID through the merged scope. + The runner exports this capability only when every access-restricting plugin opts in. +4. Map `TableRecordReadModel` → `IRecord`; snapshots preserve the same read model's stored + `version` as ShareDB `v` + +### Authority parity notes + +| Case | Behavior | +| ------------------------ | ------------------------------------------------------------- | +| Table deny | plugin `guard` → 403 | +| Row filter (list) | `recordSpec` AND into WHERE | +| Row filter (getOne) | empty under scope + exists without scope → **403** RESTRICTED | +| `filterLinkCellSelected` | `keepPrimaryKey` → `skipRecordSpec` + force primary field | +| Static field deny | empty/non-empty `readableFieldIds` | +| Conditional field | `fieldMasks` post-read (expand projection when masks present) | + +Remaining gaps (explicit hybrid residuals — do not grow this list without review): + +- Search-hit `queryExtra` may still call V1 `getDocIdsByQuery`. Group metadata is V2 for static + field permissions and row scopes; conditionally masked group/order on ShareDB still uses the + all-plugins-compatible legacy ordering contract above until SQL CASE support lands. +- selection and realtime operation delivery / subscription invalidation remain hybrid +- share-view ShareDB row reads still use the dedicated V1 share scope; initial record projection is + intersected with the server-owned shared-field allow-list +- filter/sort side-channels on masked physical columns (prefer SQL CASE later) +- `CellFormat.Text` is value-shape display text (not full V1 `cellValue2String` formatting) + +## Call-site inventory (read-adjacent) + +### Pure-V2 getRecords path (done) + +| Site | Now | +| ------------------------------------------ | --------------------------------------------------- | +| `record-open-api-v2.service.ts#getRecords` | pure V2 list + plugin scope; no snapshot bulk | +| `record-open-api-v2.service.ts#getRecord` | V2 via selectedRecordIds | +| ShareDB table socket doc IDs | V2 list scope; V2 branch bypasses V1 actor cache | +| ShareDB table socket initial snapshots | V2 getByIds; stored version + field-scoped payload | +| EE authority matrix | `RecordQueryPlugin` injects filter/projection scope | + +### Out of first cut (inventory only) + +| Site | Note | +| ------------------------------------------------ | ------------------------------------- | +| `record.service.getRecords` (V1 engine) | Keep until V1 retired | +| `selection.service` snapshot/docIds | Separate migration | +| Share-view ShareDB row reads | Dedicated share-scope migration | +| ShareDB per-subscriber realtime op redaction | Separate transport/authz migration | +| Stream delete / pre-read `recordReadQuerySource` | Follow-up; same scope model preferred | +| Write handlers that re-query with read source | Follow-up | + +## Related files + +- Port: `ports/RecordQueryPlugin.ts` +- Runner: `application/services/RecordQueryPluginRunner.ts` +- Register: `di/registerRecordQueryPlugin.ts` +- List query: `queries/ListTableRecordsHandler.ts` +- Write analogue: `ports/RecordWritePlugin.ts` diff --git a/packages/v2/core/src/application/projections/ARCHITECTURE.md b/packages/v2/core/src/application/projections/ARCHITECTURE.md index 017a36a757..9a327f34dc 100644 --- a/packages/v2/core/src/application/projections/ARCHITECTURE.md +++ b/packages/v2/core/src/application/projections/ARCHITECTURE.md @@ -17,3 +17,30 @@ Declaration: If the folder I belong to changes, please update me, especially cor - `FieldCreatedRealtimeProjection.ts` - Role: realtime projection; Purpose: publish field snapshots on create. - `FieldDeletedRealtimeProjection.ts` - Role: realtime projection; Purpose: delete field snapshots on remove. - `ViewColumnMetaUpdatedRealtimeProjection.ts` - Role: realtime projection; Purpose: update view column meta snapshots when field is added/removed. +- `ViewCreatedRealtimeProjection.ts` - Role: realtime projection; Purpose: append the created View to + the Table document and publish its standalone document. +- `ViewDeletedRealtimeProjection.ts` - Role: realtime projection; Purpose: refresh the Table View list + and remove the deleted View document. +- `ViewDescriptionUpdatedRealtimeProjection.ts` - Role: realtime projection; Purpose: publish View + description changes. +- `ViewFilterUpdatedRealtimeProjection.ts` - Role: realtime projection; Purpose: publish View filter + changes and refresh query defaults. +- `ViewGroupUpdatedRealtimeProjection.ts` - Role: realtime projection; Purpose: publish View group + changes and refresh query defaults. +- `ViewLockedUpdatedRealtimeProjection.ts` - Role: realtime projection; Purpose: publish View lock + state changes. +- `ViewSortUpdatedRealtimeProjection.ts` - Role: realtime projection; Purpose: refresh query defaults + in both the Table document and standalone View document. +- `ViewManualSortAppliedRealtimeProjection.ts` - Role: realtime projection; Purpose: invalidate + record collection queries after bulk row-order materialization commits. +- `ViewOptionsUpdatedRealtimeProjection.ts` - Role: realtime projection; Purpose: publish + type-specific View option changes. +- `ViewOrderUpdatedRealtimeProjection.ts` - Role: realtime projection; Purpose: publish the complete + Table View ordering after a reorder. +- `ViewRenamedRealtimeProjection.ts` - Role: realtime projection; Purpose: publish View name changes. +- `ViewShareIdRefreshedRealtimeProjection.ts` - Role: realtime projection; Purpose: publish the + current credential after share ID rotation. +- `ViewShareMetaUpdatedRealtimeProjection.ts` - Role: realtime projection; Purpose: publish current + View share metadata. +- `ViewShareStateRealtimeProjection.ts` - Role: realtime projection; Purpose: publish enable and + disable share state through event-specific handlers. diff --git a/packages/v2/core/src/application/projections/RealtimeProjections.spec.ts b/packages/v2/core/src/application/projections/RealtimeProjections.spec.ts index 4df83b5322..7b005cb97e 100644 --- a/packages/v2/core/src/application/projections/RealtimeProjections.spec.ts +++ b/packages/v2/core/src/application/projections/RealtimeProjections.spec.ts @@ -16,6 +16,20 @@ import { RecordsDeleted } from '../../domain/table/events/RecordsDeleted'; import { RecordUpdated } from '../../domain/table/events/RecordUpdated'; import { TableCreated } from '../../domain/table/events/TableCreated'; import { ViewColumnMetaUpdated } from '../../domain/table/events/ViewColumnMetaUpdated'; +import { ViewDeleted } from '../../domain/table/events/ViewDeleted'; +import { ViewDescriptionUpdated } from '../../domain/table/events/ViewDescriptionUpdated'; +import { ViewFilterUpdated } from '../../domain/table/events/ViewFilterUpdated'; +import { ViewGroupUpdated } from '../../domain/table/events/ViewGroupUpdated'; +import { ViewLockedUpdated } from '../../domain/table/events/ViewLockedUpdated'; +import { ViewManualSortApplied } from '../../domain/table/events/ViewManualSortApplied'; +import { ViewOptionsUpdated } from '../../domain/table/events/ViewOptionsUpdated'; +import { ViewOrderUpdated } from '../../domain/table/events/ViewOrderUpdated'; +import { ViewRenamed } from '../../domain/table/events/ViewRenamed'; +import { ViewShareDisabled } from '../../domain/table/events/ViewShareDisabled'; +import { ViewShareEnabled } from '../../domain/table/events/ViewShareEnabled'; +import { ViewShareIdRefreshed } from '../../domain/table/events/ViewShareIdRefreshed'; +import { ViewShareMetaUpdated } from '../../domain/table/events/ViewShareMetaUpdated'; +import { ViewSortUpdated } from '../../domain/table/events/ViewSortUpdated'; import { FieldId } from '../../domain/table/fields/FieldId'; import { FieldName } from '../../domain/table/fields/FieldName'; import { LinkFieldConfig } from '../../domain/table/fields/types/LinkFieldConfig'; @@ -27,11 +41,14 @@ import { SelectOption } from '../../domain/table/fields/types/SelectOption'; import { RecordId } from '../../domain/table/records/RecordId'; import { TableAddSelectOptionsSpec } from '../../domain/table/specs/TableAddSelectOptionsSpec'; import { TableUpdateFieldTypeSpec } from '../../domain/table/specs/TableUpdateFieldTypeSpec'; +import { TableUpdateViewShareIdSpec } from '../../domain/table/specs/TableUpdateViewShareIdSpec'; import { TableEventGeneratingSpecVisitor } from '../../domain/table/specs/visitors/TableEventGeneratingSpecVisitor'; import { Table } from '../../domain/table/Table'; import { TableId } from '../../domain/table/TableId'; import { TableName } from '../../domain/table/TableName'; import { ViewId } from '../../domain/table/views/ViewId'; +import { ViewName } from '../../domain/table/views/ViewName'; +import { ViewOrder } from '../../domain/table/views/ViewOrder'; import type { IAttachmentUrlSignerService } from '../../ports/AttachmentUrlSignerService'; import { createEventDispatchScope } from '../../ports/EventHandler'; import type { IExecutionContext } from '../../ports/ExecutionContext'; @@ -57,6 +74,22 @@ import { setRealtimeProjectionSchedulerForTest } from './scheduleRealtimeProject import { TableCreatedRealtimeProjection } from './TableCreatedRealtimeProjection'; import { buildRecordCollection } from './TableRecordRealtimeDTO'; import { ViewColumnMetaUpdatedRealtimeProjection } from './ViewColumnMetaUpdatedRealtimeProjection'; +import { ViewDeletedRealtimeProjection } from './ViewDeletedRealtimeProjection'; +import { ViewDescriptionUpdatedRealtimeProjection } from './ViewDescriptionUpdatedRealtimeProjection'; +import { ViewFilterUpdatedRealtimeProjection } from './ViewFilterUpdatedRealtimeProjection'; +import { ViewGroupUpdatedRealtimeProjection } from './ViewGroupUpdatedRealtimeProjection'; +import { ViewLockedUpdatedRealtimeProjection } from './ViewLockedUpdatedRealtimeProjection'; +import { ViewManualSortAppliedRealtimeProjection } from './ViewManualSortAppliedRealtimeProjection'; +import { ViewOptionsUpdatedRealtimeProjection } from './ViewOptionsUpdatedRealtimeProjection'; +import { ViewOrderUpdatedRealtimeProjection } from './ViewOrderUpdatedRealtimeProjection'; +import { ViewRenamedRealtimeProjection } from './ViewRenamedRealtimeProjection'; +import { ViewShareIdRefreshedRealtimeProjection } from './ViewShareIdRefreshedRealtimeProjection'; +import { ViewShareMetaUpdatedRealtimeProjection } from './ViewShareMetaUpdatedRealtimeProjection'; +import { + ViewShareDisabledRealtimeProjection, + ViewShareEnabledRealtimeProjection, +} from './ViewShareStateRealtimeProjection'; +import { ViewSortUpdatedRealtimeProjection } from './ViewSortUpdatedRealtimeProjection'; const fieldUpdateSemantics = { type: { @@ -134,6 +167,7 @@ class FakeRealtimeEngine implements IRealtimeEngine { options?: RealtimeApplyChangeOptions; }> = []; deletes: RealtimeDocId[] = []; + invalidations: Array<{ collection: string; change: RealtimeChange }> = []; async ensure(_context: IExecutionContext, docId: RealtimeDocId, initial: unknown) { this.ensures.push({ docId, initial }); @@ -154,6 +188,15 @@ class FakeRealtimeEngine implements IRealtimeEngine { this.deletes.push(docId); return ok(undefined); } + + async invalidateCollection( + _context: IExecutionContext, + collection: string, + change: RealtimeChange + ) { + this.invalidations.push({ collection, change }); + return ok(undefined); + } } class FakeTableRepository implements ITableRepository { @@ -187,6 +230,10 @@ class FakeTableRepository implements ITableRepository { return ok(undefined); } + async restore() { + return ok(undefined); + } + async delete() { return ok(undefined); } @@ -530,6 +577,35 @@ describe('Realtime projections', () => { }); }); + it('invalidates the record collection after View manual sort materializes row order', async () => { + const table = buildTable('2', '5', '7'); + const viewId = table.views()[0]!.id(); + const engine = new FakeRealtimeEngine(); + const projection = new ViewManualSortAppliedRealtimeProjection(engine); + const event = ViewManualSortApplied.create({ + baseId: table.baseId(), + tableId: table.id(), + viewId, + sort: [{ fieldId: table.primaryFieldId().toString(), order: 'asc' }], + }); + + const result = await projection.handle(createContext(), event); + result._unsafeUnwrap(); + + expect(engine.invalidations).toEqual([ + { + collection: buildRecordCollection(table.id().toString()), + change: { + type: 'set', + path: ['fields', viewId.toRowOrderColumnName()], + value: null, + oldValue: null, + }, + }, + ]); + expect(engine.changes).toHaveLength(0); + }); + it('projects batch record creations', async () => { const table = buildTable('1', '2', '3'); const engine = new FakeRealtimeEngine(); @@ -646,6 +722,7 @@ describe('Realtime projections', () => { }, applyChange: async () => ok(undefined), delete: async () => ok(undefined), + invalidateCollection: async () => ok(undefined), }; const projection = new RecordsBatchCreatedRealtimeProjection(engine); @@ -701,6 +778,7 @@ describe('Realtime projections', () => { }, applyChange: async () => ok(undefined), delete: async () => ok(undefined), + invalidateCollection: async () => ok(undefined), }; const projection = new RecordsBatchCreatedRealtimeProjection(engine); @@ -827,6 +905,7 @@ describe('Realtime projections', () => { }); return ok(undefined); }, + invalidateCollection: async () => ok(undefined), }; const projection = new RecordsDeletedRealtimeProjection(engine); @@ -883,6 +962,7 @@ describe('Realtime projections', () => { inFlight -= 1; return ok(undefined); }, + invalidateCollection: async () => ok(undefined), }; const projection = new RecordsBatchUpdatedRealtimeProjection(engine); @@ -1013,6 +1093,844 @@ describe('Realtime projections', () => { expect(engine.deletes).toHaveLength(1); }); + it('projects View deletion to the Table document and removes the View document', async () => { + const originalTable = buildTable('v', 'w', 'x'); + const createResult = originalTable.createView({ type: 'grid', name: 'Temporary' }); + const tableWithView = createResult._unsafeUnwrap().updateResult.table; + const deletedViewId = createResult._unsafeUnwrap().view.id(); + const table = tableWithView.deleteView(deletedViewId)._unsafeUnwrap().updateResult.table; + const engine = new FakeRealtimeEngine(); + const repository = new FakeTableRepository(table); + const mapper = new DefaultTableMapper(); + const projection = new ViewDeletedRealtimeProjection(engine, repository, mapper); + const realtimeTasks = captureRealtimeTasks(); + const event = ViewDeleted.create({ + baseId: table.baseId(), + tableId: table.id(), + viewId: deletedViewId, + }); + + (await projection.handle(createContext(), event))._unsafeUnwrap(); + expect(realtimeTasks).toHaveLength(1); + await realtimeTasks[0]!(); + + expect(engine.ensures[0]?.docId.toString()).toBe( + `tbl_${table.baseId().toString()}/${table.id().toString()}` + ); + expect(engine.changes[0]?.change).toMatchObject({ + type: 'set', + path: ['views'], + }); + expect(engine.deletes[0]?.toString()).toBe( + `viw_${table.id().toString()}/${deletedViewId.toString()}` + ); + }); + + it('projects View rename to the Table and standalone View documents with persisted version', async () => { + const originalTable = buildTable('r', 's', 't'); + const targetView = originalTable.views()[0]!; + const renamed = originalTable + .renameView(targetView.id(), ViewName.create('Renamed view')._unsafeUnwrap()) + ._unsafeUnwrap().updateResult.table; + const engine = new FakeRealtimeEngine(); + const repository = new FakeTableRepository(renamed); + const mapper = new DefaultTableMapper(); + const projection = new ViewRenamedRealtimeProjection(engine, repository, mapper); + const realtimeTasks = captureRealtimeTasks(); + const event = ViewRenamed.create({ + baseId: renamed.baseId(), + tableId: renamed.id(), + viewId: targetView.id(), + previousName: targetView.name(), + nextName: ViewName.create('Renamed view')._unsafeUnwrap(), + oldVersion: 11, + newVersion: 12, + }); + + (await projection.handle(createContext(), event))._unsafeUnwrap(); + expect(realtimeTasks).toHaveLength(1); + await realtimeTasks[0]!(); + + expect(engine.ensures.map(({ docId }) => docId.toString())).toEqual([ + `tbl_${renamed.baseId().toString()}/${renamed.id().toString()}`, + `viw_${renamed.id().toString()}/${targetView.id().toString()}`, + ]); + expect(engine.changes[0]).toMatchObject({ + docId: expect.objectContaining({}), + change: { + type: 'set', + path: ['views', 0, 'name'], + value: 'Renamed view', + }, + }); + expect(engine.changes[0]?.docId.toString()).toBe( + `tbl_${renamed.baseId().toString()}/${renamed.id().toString()}` + ); + expect(engine.changes[1]?.docId.toString()).toBe( + `viw_${renamed.id().toString()}/${targetView.id().toString()}` + ); + expect(engine.changes[1]?.change).toEqual({ + type: 'set', + path: ['name'], + value: 'Renamed view', + }); + expect(engine.changes[1]?.options).toEqual({ version: 11 }); + }); + + it('projects View description to Table and standalone View documents with persisted version', async () => { + const originalTable = buildTable('u', 'v', 'w'); + const targetView = originalTable.views()[0]!; + const updated = originalTable + .updateViewDescription(targetView.id(), 'Updated description') + ._unsafeUnwrap().updateResult.table; + const engine = new FakeRealtimeEngine(); + const repository = new FakeTableRepository(updated); + const mapper = new DefaultTableMapper(); + const projection = new ViewDescriptionUpdatedRealtimeProjection(engine, repository, mapper); + const realtimeTasks = captureRealtimeTasks(); + const event = ViewDescriptionUpdated.create({ + baseId: updated.baseId(), + tableId: updated.id(), + viewId: targetView.id(), + previousDescription: undefined, + nextDescription: 'Updated description', + oldVersion: 12, + newVersion: 13, + }); + + (await projection.handle(createContext(), event))._unsafeUnwrap(); + expect(realtimeTasks).toHaveLength(1); + await realtimeTasks[0]!(); + + expect(engine.ensures.map(({ docId }) => docId.toString())).toEqual([ + `tbl_${updated.baseId().toString()}/${updated.id().toString()}`, + `viw_${updated.id().toString()}/${targetView.id().toString()}`, + ]); + expect(engine.changes[0]).toMatchObject({ + change: { + type: 'set', + path: ['views', 0, 'description'], + value: 'Updated description', + }, + }); + expect(engine.changes[1]?.change).toEqual({ + type: 'set', + path: ['description'], + value: 'Updated description', + }); + expect(engine.changes[1]?.options).toEqual({ version: 12 }); + }); + + it('projects View filter query defaults to Table and standalone View documents', async () => { + const originalTable = buildTable('f', 'g', 'h'); + const targetView = originalTable.views()[0]!; + const filter = { + conjunction: 'and' as const, + filterSet: [ + { + fieldId: originalTable.primaryFieldId().toString(), + operator: 'is' as const, + value: 'alpha', + }, + ], + }; + const query = { + filter: { + conjunction: 'and', + items: [ + { + fieldId: originalTable.primaryFieldId().toString(), + operator: 'is', + value: 'alpha', + }, + ], + }, + }; + const updated = originalTable.updateViewFilter(targetView.id(), filter)._unsafeUnwrap() + .updateResult!.table; + const engine = new FakeRealtimeEngine(); + const repository = new FakeTableRepository(updated); + const mapper = new DefaultTableMapper(); + const projection = new ViewFilterUpdatedRealtimeProjection(engine, repository, mapper); + const realtimeTasks = captureRealtimeTasks(); + const event = ViewFilterUpdated.create({ + baseId: updated.baseId(), + tableId: updated.id(), + viewId: targetView.id(), + previousFilter: undefined, + nextFilter: filter, + oldVersion: 13, + newVersion: 14, + }); + + (await projection.handle(createContext(), event))._unsafeUnwrap(); + expect(realtimeTasks).toHaveLength(1); + await realtimeTasks[0]!(); + + expect(engine.ensures.map(({ docId }) => docId.toString())).toEqual([ + `tbl_${updated.baseId().toString()}/${updated.id().toString()}`, + `viw_${updated.id().toString()}/${targetView.id().toString()}`, + ]); + expect(engine.changes[0]?.change).toEqual([ + { + type: 'set', + path: ['views', 0, 'query'], + value: query, + }, + { + type: 'set', + path: ['views', 0, 'sourceFilter'], + value: filter, + oldValue: undefined, + }, + { + type: 'set', + path: ['views', 0, 'filter'], + value: filter, + oldValue: undefined, + }, + ]); + expect(engine.changes[1]?.change).toEqual([ + { + type: 'set', + path: ['query'], + value: query, + }, + { + type: 'set', + path: ['sourceFilter'], + value: filter, + oldValue: undefined, + }, + { + type: 'set', + path: ['filter'], + value: filter, + oldValue: undefined, + }, + ]); + expect(engine.changes[1]?.options).toEqual({ version: 13 }); + }); + + it('projects a cleared View filter as a valid object deletion', async () => { + const originalTable = buildTable('i', 'j', 'k'); + const targetView = originalTable.views()[0]!; + const filter = { + conjunction: 'and' as const, + filterSet: [ + { + fieldId: originalTable.primaryFieldId().toString(), + operator: 'is' as const, + value: 'alpha', + }, + ], + }; + const filtered = originalTable.updateViewFilter(targetView.id(), filter)._unsafeUnwrap() + .updateResult!.table; + const cleared = filtered.updateViewFilter(targetView.id(), null)._unsafeUnwrap().updateResult! + .table; + const engine = new FakeRealtimeEngine(); + const repository = new FakeTableRepository(cleared); + const mapper = new FakeTableMapper((table) => { + const dto = new DefaultTableMapper().toDTO(table)._unsafeUnwrap(); + return { + ...dto, + views: dto.views.map((view) => { + const persistedView = { ...view }; + delete persistedView.sourceFilter; + return persistedView; + }), + }; + }); + const projection = new ViewFilterUpdatedRealtimeProjection(engine, repository, mapper); + const realtimeTasks = captureRealtimeTasks(); + const event = ViewFilterUpdated.create({ + baseId: cleared.baseId(), + tableId: cleared.id(), + viewId: targetView.id(), + previousFilter: filter, + nextFilter: null, + oldVersion: 14, + newVersion: 15, + }); + + (await projection.handle(createContext(), event))._unsafeUnwrap(); + await realtimeTasks[0]!(); + + expect(engine.changes[0]?.change).toEqual( + expect.arrayContaining([ + { + type: 'set', + path: ['views', 0, 'sourceFilter'], + value: undefined, + oldValue: filter, + }, + { + type: 'set', + path: ['views', 0, 'filter'], + value: undefined, + oldValue: filter, + }, + ]) + ); + expect(engine.changes[1]?.change).toEqual( + expect.arrayContaining([ + { type: 'set', path: ['sourceFilter'], value: undefined, oldValue: filter }, + { type: 'set', path: ['filter'], value: undefined, oldValue: filter }, + ]) + ); + }); + + it('projects View sort query defaults to Table and standalone View documents', async () => { + const originalTable = buildTable('x', 'y', 'z'); + const targetView = originalTable.views()[0]!; + const sort = { + sortObjs: [{ fieldId: originalTable.primaryFieldId().toString(), order: 'desc' as const }], + manualSort: false, + }; + const updated = originalTable.updateViewSort(targetView.id(), sort)._unsafeUnwrap() + .updateResult!.table; + const engine = new FakeRealtimeEngine(); + const repository = new FakeTableRepository(updated); + const mapper = new DefaultTableMapper(); + const projection = new ViewSortUpdatedRealtimeProjection(engine, repository, mapper); + const realtimeTasks = captureRealtimeTasks(); + const event = ViewSortUpdated.create({ + baseId: updated.baseId(), + tableId: updated.id(), + viewId: targetView.id(), + previousSort: null, + nextSort: sort, + oldVersion: 13, + newVersion: 14, + }); + + (await projection.handle(createContext(), event))._unsafeUnwrap(); + expect(realtimeTasks).toHaveLength(1); + await realtimeTasks[0]!(); + + const query = { sort: sort.sortObjs, manualSort: false }; + expect(engine.ensures.map(({ docId }) => docId.toString())).toEqual([ + `tbl_${updated.baseId().toString()}/${updated.id().toString()}`, + `viw_${updated.id().toString()}/${targetView.id().toString()}`, + ]); + expect(engine.changes[0]?.change).toEqual([ + { + type: 'set', + path: ['views', 0, 'query'], + value: query, + }, + ]); + expect(engine.changes[1]?.change).toEqual([ + { + type: 'set', + path: ['query'], + value: query, + }, + ]); + expect(engine.changes[1]?.options).toEqual({ version: 13 }); + }); + + it('projects View group query defaults to Table and standalone View documents', async () => { + const originalTable = buildTable('x', 'y', 'z'); + const targetView = originalTable.views()[0]!; + const group = [{ fieldId: originalTable.primaryFieldId().toString(), order: 'desc' as const }]; + const updated = originalTable.updateViewGroup(targetView.id(), group)._unsafeUnwrap() + .updateResult!.table; + const engine = new FakeRealtimeEngine(); + const repository = new FakeTableRepository(updated); + const mapper = new DefaultTableMapper(); + const projection = new ViewGroupUpdatedRealtimeProjection(engine, repository, mapper); + const realtimeTasks = captureRealtimeTasks(); + const event = ViewGroupUpdated.create({ + baseId: updated.baseId(), + tableId: updated.id(), + viewId: targetView.id(), + previousGroup: null, + nextGroup: group, + oldVersion: 14, + newVersion: 15, + }); + + (await projection.handle(createContext(), event))._unsafeUnwrap(); + expect(realtimeTasks).toHaveLength(1); + await realtimeTasks[0]!(); + + const query = { group }; + expect(engine.ensures.map(({ docId }) => docId.toString())).toEqual([ + `tbl_${updated.baseId().toString()}/${updated.id().toString()}`, + `viw_${updated.id().toString()}/${targetView.id().toString()}`, + ]); + expect(engine.changes[0]?.change).toEqual([ + { + type: 'set', + path: ['views', 0, 'query'], + value: query, + }, + ]); + expect(engine.changes[1]?.change).toEqual([ + { + type: 'set', + path: ['query'], + value: query, + }, + ]); + expect(engine.changes[1]?.options).toEqual({ version: 14 }); + }); + + it('projects View options to Table and standalone View documents', async () => { + const originalTable = buildTable('x', 'y', 'z'); + const targetView = originalTable.views()[0]!; + const nextOptions = { rowHeight: 'tall', fieldNameDisplayLines: 2 }; + const updated = originalTable.updateViewOptions(targetView.id(), nextOptions)._unsafeUnwrap() + .updateResult!.table; + const engine = new FakeRealtimeEngine(); + const repository = new FakeTableRepository(updated); + const mapper = new DefaultTableMapper(); + const projection = new ViewOptionsUpdatedRealtimeProjection(engine, repository, mapper); + const realtimeTasks = captureRealtimeTasks(); + const event = ViewOptionsUpdated.create({ + baseId: updated.baseId(), + tableId: updated.id(), + viewId: targetView.id(), + previousOptions: undefined, + nextOptions, + oldVersion: 15, + newVersion: 16, + }); + + (await projection.handle(createContext(), event))._unsafeUnwrap(); + expect(realtimeTasks).toHaveLength(1); + await realtimeTasks[0]!(); + + expect(engine.ensures.map(({ docId }) => docId.toString())).toEqual([ + `tbl_${updated.baseId().toString()}/${updated.id().toString()}`, + `viw_${updated.id().toString()}/${targetView.id().toString()}`, + ]); + expect(engine.changes[0]?.change).toEqual([ + { + type: 'set', + path: ['views', 0, 'options'], + value: nextOptions, + }, + ]); + expect(engine.changes[1]?.change).toEqual([ + { + type: 'set', + path: ['options'], + value: nextOptions, + }, + ]); + expect(engine.changes[1]?.options).toEqual({ version: 15 }); + }); + + it('projects View share metadata to Table and standalone View documents', async () => { + const originalTable = buildTable('x', 'y', 'z'); + const targetView = originalTable.views()[0]!; + const nextShareMeta = { allowCopy: true, submit: { requireLogin: true } }; + const updated = originalTable + .updateViewShareMeta(targetView.id(), nextShareMeta) + ._unsafeUnwrap().updateResult!.table; + const engine = new FakeRealtimeEngine(); + const repository = new FakeTableRepository(updated); + const mapper = new DefaultTableMapper(); + const projection = new ViewShareMetaUpdatedRealtimeProjection(engine, repository, mapper); + const realtimeTasks = captureRealtimeTasks(); + const event = ViewShareMetaUpdated.create({ + baseId: updated.baseId(), + tableId: updated.id(), + viewId: targetView.id(), + previousShareMeta: undefined, + nextShareMeta, + oldVersion: 16, + newVersion: 17, + }); + + (await projection.handle(createContext(), event))._unsafeUnwrap(); + await realtimeTasks[0]!(); + + expect(engine.changes[0]?.change).toEqual([ + { + type: 'set', + path: ['views', 0, 'shareMeta'], + value: nextShareMeta, + }, + ]); + expect(engine.changes[1]?.change).toEqual([ + { + type: 'set', + path: ['shareMeta'], + value: nextShareMeta, + }, + ]); + expect(engine.changes[1]?.options).toEqual({ version: 16 }); + }); + + it('projects only the current View share password metadata after replacement', async () => { + const originalTable = buildTable('x', 'y', 'z'); + const targetView = originalTable.views()[0]!; + const previousShareMeta = { password: 'previous-password', allowCopy: false }; + const nextShareMeta = { password: 'current-password', allowCopy: true }; + const withPreviousShareMeta = originalTable + .updateViewShareMeta(targetView.id(), previousShareMeta) + ._unsafeUnwrap().updateResult!.table; + const updated = withPreviousShareMeta + .updateViewShareMeta(targetView.id(), nextShareMeta) + ._unsafeUnwrap().updateResult!.table; + const engine = new FakeRealtimeEngine(); + const repository = new FakeTableRepository(updated); + const mapper = new DefaultTableMapper(); + const projection = new ViewShareMetaUpdatedRealtimeProjection(engine, repository, mapper); + const realtimeTasks = captureRealtimeTasks(); + const event = ViewShareMetaUpdated.create({ + baseId: updated.baseId(), + tableId: updated.id(), + viewId: targetView.id(), + previousShareMeta, + nextShareMeta, + oldVersion: 17, + newVersion: 18, + }); + + (await projection.handle(createContext(), event))._unsafeUnwrap(); + await realtimeTasks[0]!(); + + expect(engine.changes[0]?.change).toEqual([ + { + type: 'set', + path: ['views', 0, 'shareMeta'], + value: nextShareMeta, + }, + ]); + expect(engine.changes[1]?.change).toEqual([ + { + type: 'set', + path: ['shareMeta'], + value: nextShareMeta, + }, + ]); + expect( + JSON.stringify({ + ensures: engine.ensures.map(({ initial }) => initial), + changes: engine.changes.map(({ change }) => change), + }) + ).not.toContain(previousShareMeta.password); + }); + + it('projects a refreshed View share ID to Table and standalone View documents', async () => { + const originalTable = buildTable('x', 'y', 'z'); + const targetView = originalTable.views()[0]!; + const enabled = originalTable.enableViewShare(targetView.id())._unsafeUnwrap(); + const previousShareId = enabled.shareId; + const nextShareId = `shr${'b'.repeat(16)}`; + const updated = TableUpdateViewShareIdSpec.create(targetView.id(), previousShareId, nextShareId) + .mutate(enabled.updateResult.table) + ._unsafeUnwrap(); + const engine = new FakeRealtimeEngine(); + const repository = new FakeTableRepository(updated); + const mapper = new DefaultTableMapper(); + const projection = new ViewShareIdRefreshedRealtimeProjection(engine, repository, mapper); + const realtimeTasks = captureRealtimeTasks(); + const event = ViewShareIdRefreshed.create({ + baseId: updated.baseId(), + tableId: updated.id(), + viewId: targetView.id(), + previousShareId, + nextShareId, + oldVersion: 17, + newVersion: 18, + }); + + (await projection.handle(createContext(), event))._unsafeUnwrap(); + await realtimeTasks[0]!(); + + expect(engine.changes[0]?.change).toEqual([ + { + type: 'set', + path: ['views', 0, 'shareId'], + value: nextShareId, + }, + ]); + expect(engine.changes[1]?.change).toEqual([ + { + type: 'set', + path: ['shareId'], + value: nextShareId, + }, + ]); + expect(engine.changes[1]?.options).toEqual({ version: 17 }); + expect( + JSON.stringify({ + ensures: engine.ensures.map(({ initial }) => initial), + changes: engine.changes.map(({ change }) => change), + }) + ).not.toContain(previousShareId); + }); + + it('projects an enabled View share state to Table and standalone View documents', async () => { + const originalTable = buildTable('x', 'y', 'z'); + const targetView = originalTable.views()[0]!; + const enabled = originalTable.enableViewShare(targetView.id())._unsafeUnwrap(); + const updated = enabled.updateResult.table; + const engine = new FakeRealtimeEngine(); + const repository = new FakeTableRepository(updated); + const mapper = new DefaultTableMapper(); + const projection = new ViewShareEnabledRealtimeProjection(engine, repository, mapper); + const realtimeTasks = captureRealtimeTasks(); + const event = ViewShareEnabled.create({ + baseId: updated.baseId(), + tableId: updated.id(), + viewId: targetView.id(), + shareId: enabled.shareId, + shareMeta: enabled.view.shareMeta()!, + oldVersion: 18, + newVersion: 19, + }); + + (await projection.handle(createContext(), event))._unsafeUnwrap(); + await realtimeTasks[0]!(); + + expect(engine.changes[0]?.change).toEqual([ + { + type: 'set', + path: ['views', 0, 'enableShare'], + value: true, + }, + { + type: 'set', + path: ['views', 0, 'shareId'], + value: enabled.shareId, + }, + { + type: 'set', + path: ['views', 0, 'shareMeta'], + value: { includeRecords: true }, + }, + ]); + expect(engine.changes[1]?.change).toEqual([ + { type: 'set', path: ['enableShare'], value: true }, + { type: 'set', path: ['shareId'], value: enabled.shareId }, + { type: 'set', path: ['shareMeta'], value: { includeRecords: true } }, + ]); + expect(engine.changes[1]?.options).toEqual({ version: 18 }); + }); + + it('projects only the newly issued credential when re-enabling View sharing', async () => { + const originalTable = buildTable('x', 'y', 'z'); + const targetView = originalTable.views()[0]!; + const firstEnabled = originalTable.enableViewShare(targetView.id())._unsafeUnwrap(); + const disabled = firstEnabled.updateResult.table + .disableViewShare(targetView.id()) + ._unsafeUnwrap(); + const reenabled = disabled.updateResult.table.enableViewShare(targetView.id())._unsafeUnwrap(); + const updated = reenabled.updateResult.table; + const engine = new FakeRealtimeEngine(); + const repository = new FakeTableRepository(updated); + const mapper = new DefaultTableMapper(); + const projection = new ViewShareEnabledRealtimeProjection(engine, repository, mapper); + const realtimeTasks = captureRealtimeTasks(); + const event = ViewShareEnabled.create({ + baseId: updated.baseId(), + tableId: updated.id(), + viewId: targetView.id(), + shareId: reenabled.shareId, + shareMeta: reenabled.view.shareMeta()!, + oldVersion: 20, + newVersion: 21, + }); + + (await projection.handle(createContext(), event))._unsafeUnwrap(); + await realtimeTasks[0]!(); + + expect(reenabled.shareId).not.toBe(firstEnabled.shareId); + expect(engine.changes[0]?.change).toEqual([ + { type: 'set', path: ['views', 0, 'enableShare'], value: true }, + { + type: 'set', + path: ['views', 0, 'shareId'], + value: reenabled.shareId, + }, + { + type: 'set', + path: ['views', 0, 'shareMeta'], + value: { includeRecords: true }, + }, + ]); + expect(engine.changes[1]?.change).toEqual([ + { type: 'set', path: ['enableShare'], value: true }, + { type: 'set', path: ['shareId'], value: reenabled.shareId }, + { type: 'set', path: ['shareMeta'], value: { includeRecords: true } }, + ]); + expect( + JSON.stringify({ + ensures: engine.ensures.map(({ initial }) => initial), + changes: engine.changes.map(({ change }) => change), + }) + ).not.toContain(firstEnabled.shareId); + }); + + it('projects a disabled View share state while retaining its credential metadata', async () => { + const originalTable = buildTable('x', 'y', 'z'); + const targetView = originalTable.views()[0]!; + const enabled = originalTable.enableViewShare(targetView.id())._unsafeUnwrap(); + const disabled = enabled.updateResult.table.disableViewShare(targetView.id())._unsafeUnwrap(); + const updated = disabled.updateResult.table; + const engine = new FakeRealtimeEngine(); + const repository = new FakeTableRepository(updated); + const mapper = new DefaultTableMapper(); + const projection = new ViewShareDisabledRealtimeProjection(engine, repository, mapper); + const realtimeTasks = captureRealtimeTasks(); + const event = ViewShareDisabled.create({ + baseId: updated.baseId(), + tableId: updated.id(), + viewId: targetView.id(), + previousShareId: enabled.shareId, + shareMeta: disabled.view.shareMeta(), + oldVersion: 19, + newVersion: 20, + }); + + (await projection.handle(createContext(), event))._unsafeUnwrap(); + await realtimeTasks[0]!(); + + expect(engine.changes[0]?.change).toEqual([ + { + type: 'set', + path: ['views', 0, 'enableShare'], + value: false, + }, + { + type: 'set', + path: ['views', 0, 'shareId'], + value: enabled.shareId, + }, + { + type: 'set', + path: ['views', 0, 'shareMeta'], + value: { includeRecords: true }, + }, + ]); + expect(engine.changes[1]?.change).toEqual([ + { type: 'set', path: ['enableShare'], value: false }, + { type: 'set', path: ['shareId'], value: enabled.shareId }, + { type: 'set', path: ['shareMeta'], value: { includeRecords: true } }, + ]); + expect(engine.changes[1]?.options).toEqual({ version: 19 }); + }); + + it('projects View locked state to Table and standalone View documents with persisted version', async () => { + const originalTable = buildTable('x', 'y', 'z'); + const targetView = originalTable.views()[0]!; + const updated = originalTable.updateViewLocked(targetView.id(), true)._unsafeUnwrap() + .updateResult.table; + const engine = new FakeRealtimeEngine(); + const repository = new FakeTableRepository(updated); + const mapper = new DefaultTableMapper(); + const projection = new ViewLockedUpdatedRealtimeProjection(engine, repository, mapper); + const realtimeTasks = captureRealtimeTasks(); + const event = ViewLockedUpdated.create({ + baseId: updated.baseId(), + tableId: updated.id(), + viewId: targetView.id(), + previousIsLocked: undefined, + nextIsLocked: true, + oldVersion: 13, + newVersion: 14, + }); + + (await projection.handle(createContext(), event))._unsafeUnwrap(); + expect(realtimeTasks).toHaveLength(1); + await realtimeTasks[0]!(); + + expect(engine.ensures.map(({ docId }) => docId.toString())).toEqual([ + `tbl_${updated.baseId().toString()}/${updated.id().toString()}`, + `viw_${updated.id().toString()}/${targetView.id().toString()}`, + ]); + expect(engine.changes[0]).toMatchObject({ + change: { + type: 'set', + path: ['views', 0, 'isLocked'], + value: true, + oldValue: undefined, + }, + }); + expect(engine.changes[1]?.change).toEqual({ + type: 'set', + path: ['isLocked'], + value: true, + oldValue: undefined, + }); + expect(engine.changes[1]?.options).toEqual({ version: 13 }); + }); + + it('advances the standalone View version for an unchanged omitted locked state', async () => { + const table = buildTable('l', 'm', 'n'); + const targetView = table.views()[0]!; + const engine = new FakeRealtimeEngine(); + const repository = new FakeTableRepository(table); + const mapper = new DefaultTableMapper(); + const projection = new ViewLockedUpdatedRealtimeProjection(engine, repository, mapper); + const realtimeTasks = captureRealtimeTasks(); + const event = ViewLockedUpdated.create({ + baseId: table.baseId(), + tableId: table.id(), + viewId: targetView.id(), + previousIsLocked: undefined, + nextIsLocked: undefined, + oldVersion: 14, + newVersion: 15, + }); + + (await projection.handle(createContext(), event))._unsafeUnwrap(); + await realtimeTasks[0]!(); + + expect(engine.changes).toHaveLength(1); + expect(engine.changes[0]?.change).toEqual({ + type: 'set', + path: ['id'], + value: targetView.id().toString(), + oldValue: targetView.id().toString(), + }); + expect(engine.changes[0]?.options).toEqual({ version: 14 }); + }); + + it('projects View order to Table and standalone View documents with persisted version', async () => { + const table = buildTable('o', 'p', 'q'); + const targetView = table.views()[0]!; + targetView.setOrder(ViewOrder.rehydrate(2.5)._unsafeUnwrap())._unsafeUnwrap(); + const engine = new FakeRealtimeEngine(); + const repository = new FakeTableRepository(table); + const mapper = new DefaultTableMapper(); + const projection = new ViewOrderUpdatedRealtimeProjection(engine, repository, mapper); + const realtimeTasks = captureRealtimeTasks(); + const event = ViewOrderUpdated.create({ + baseId: table.baseId(), + tableId: table.id(), + viewId: targetView.id(), + previousOrder: ViewOrder.rehydrate(3)._unsafeUnwrap(), + nextOrder: ViewOrder.rehydrate(2.5)._unsafeUnwrap(), + oldVersion: 15, + newVersion: 16, + }); + + (await projection.handle(createContext(), event))._unsafeUnwrap(); + await realtimeTasks[0]!(); + + expect(engine.changes[0]?.change).toEqual({ + type: 'set', + path: ['views', 0, 'order'], + value: 2.5, + oldValue: 3, + }); + expect(engine.changes[1]?.change).toEqual({ + type: 'set', + path: ['order'], + value: 2.5, + oldValue: 3, + }); + expect(engine.changes[1]?.options).toEqual({ version: 15 }); + }); + it('updates view column meta when view exists', async () => { const table = buildTable('c', 'd', 'e'); const viewId = table.views()[0]?.id() ?? ViewId.create(`viw${'a'.repeat(16)}`)._unsafeUnwrap(); @@ -1053,11 +1971,13 @@ describe('Realtime projections', () => { expect(engine.changes[1]?.docId.toString()).toBe( `viw_${table.id().toString()}/${viewId.toString()}` ); - expect(engine.changes[1]?.change).toEqual({ - type: 'set', - path: ['columnMeta'], - value: buildTableDto(table).views[0]?.columnMeta, - }); + expect(engine.changes[1]?.change).toEqual([ + { + type: 'set', + path: ['columnMeta'], + value: buildTableDto(table).views[0]?.columnMeta, + }, + ]); expect(engine.changes[1]?.options).toEqual({ version: 7 }); }); @@ -1902,14 +2822,17 @@ describe('Realtime projections', () => { const lookupOptionsChange = changes.find( (change) => JSON.stringify(change.path) === JSON.stringify(['lookupOptions']) ); - expect(lookupOptionsChange?.value).toEqual( + expect(lookupOptionsChange).toEqual( expect.objectContaining({ - linkFieldId: linkFieldId.toString(), - lookupFieldId: foreignTargetFieldId.toString(), - foreignTableId: foreignTableId.toString(), - fkHostTableName: expect.any(String), - selfKeyName: expect.any(String), - foreignKeyName: expect.any(String), + type: 'set', + value: expect.objectContaining({ + linkFieldId: linkFieldId.toString(), + lookupFieldId: foreignTargetFieldId.toString(), + foreignTableId: foreignTableId.toString(), + fkHostTableName: expect.any(String), + selfKeyName: expect.any(String), + foreignKeyName: expect.any(String), + }), }) ); diff --git a/packages/v2/core/src/application/projections/ViewColumnMetaUpdatedRealtimeProjection.ts b/packages/v2/core/src/application/projections/ViewColumnMetaUpdatedRealtimeProjection.ts index f9c4483216..a49f138ceb 100644 --- a/packages/v2/core/src/application/projections/ViewColumnMetaUpdatedRealtimeProjection.ts +++ b/packages/v2/core/src/application/projections/ViewColumnMetaUpdatedRealtimeProjection.ts @@ -7,6 +7,7 @@ import { ViewColumnMetaUpdated } from '../../domain/table/events/ViewColumnMetaU import type { IEventDispatchScope, IEventHandler } from '../../ports/EventHandler'; import type * as ExecutionContextPort from '../../ports/ExecutionContext'; import * as TableMapperPort from '../../ports/mappers/TableMapper'; +import type { RealtimeChange } from '../../ports/RealtimeChange'; import { RealtimeDocId } from '../../ports/RealtimeDocId'; import * as RealtimeEnginePort from '../../ports/RealtimeEngine'; import * as TableRepositoryPort from '../../ports/TableRepository'; @@ -33,7 +34,12 @@ const canUseColumnMetaSnapshot = ( const fieldId = event.fieldId.toString(); const fieldInSnapshot = Boolean(view.columnMeta[fieldId]); - return event.fieldInColumnMeta ? fieldInSnapshot : !fieldInSnapshot; + const fieldStateMatches = event.fieldInColumnMeta ? fieldInSnapshot : !fieldInSnapshot; + if (!fieldStateMatches) return false; + if (event.optionsChange) { + return JSON.stringify(view.options) === JSON.stringify(event.optionsChange.nextOptions); + } + return true; }; const reserveViewColumnMetaRealtimeProjection = ( @@ -111,13 +117,21 @@ export class ViewColumnMetaUpdatedRealtimeProjection yield* (await realtimeEngine.ensure(context, docId, snapshot)).safeUnwrap(); // Keep the table snapshot in sync for table-level consumers. - yield* ( - await realtimeEngine.applyChange(context, docId, { + const tableChanges: RealtimeChange[] = [ + { type: 'set', path: ['views', viewIndex, 'columnMeta'], value: viewDto.columnMeta, - }) - ).safeUnwrap(); + }, + ]; + if (event.optionsChange) { + tableChanges.push({ + type: 'set', + path: ['views', viewIndex, 'options'], + value: viewDto.options, + }); + } + yield* (await realtimeEngine.applyChange(context, docId, tableChanges)).safeUnwrap(); // Keep the standalone view document in sync for ShareDB/SDK view subscriptions. const viewCollection = `${viewCollectionPrefix}_${event.tableId.toString()}`; @@ -127,18 +141,23 @@ export class ViewColumnMetaUpdatedRealtimeProjection ).safeUnwrap(); yield* (await realtimeEngine.ensure(context, viewDocId, viewDto)).safeUnwrap(); - return realtimeEngine.applyChange( - context, - viewDocId, + const viewChanges: RealtimeChange[] = [ { type: 'set', path: ['columnMeta'], value: viewDto.columnMeta, }, - { - version: event.oldVersion, - } - ); + ]; + if (event.optionsChange) { + viewChanges.push({ + type: 'set', + path: ['options'], + value: viewDto.options, + }); + } + return realtimeEngine.applyChange(context, viewDocId, viewChanges, { + version: event.oldVersion, + }); } finally { releasePending(); } diff --git a/packages/v2/core/src/application/projections/ViewCreatedRealtimeProjection.ts b/packages/v2/core/src/application/projections/ViewCreatedRealtimeProjection.ts new file mode 100644 index 0000000000..98b96c2715 --- /dev/null +++ b/packages/v2/core/src/application/projections/ViewCreatedRealtimeProjection.ts @@ -0,0 +1,89 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../domain/shared/DomainError'; +import { ViewCreated } from '../../domain/table/events/ViewCreated'; +import type { IEventDispatchScope, IEventHandler } from '../../ports/EventHandler'; +import type * as ExecutionContextPort from '../../ports/ExecutionContext'; +import * as TableMapperPort from '../../ports/mappers/TableMapper'; +import { RealtimeDocId } from '../../ports/RealtimeDocId'; +import * as RealtimeEnginePort from '../../ports/RealtimeEngine'; +import * as TableRepositoryPort from '../../ports/TableRepository'; +import { v2CoreTokens } from '../../ports/tokens'; +import { ProjectionHandler } from './Projection'; +import { loadRealtimeTableSnapshot } from './RealtimeTableSnapshotCache'; +import { + getRealtimeProjectionScope, + scheduleRealtimeProjection, +} from './scheduleRealtimeProjection'; + +const tableCollectionPrefix = 'tbl'; +const viewCollectionPrefix = 'viw'; + +@ProjectionHandler(ViewCreated) +@injectable() +export class ViewCreatedRealtimeProjection implements IEventHandler { + constructor( + @inject(v2CoreTokens.realtimeEngine) + private readonly realtimeEngine: RealtimeEnginePort.IRealtimeEngine, + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableMapper) + private readonly tableMapper: TableMapperPort.ITableMapper + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + event: ViewCreated, + dispatchScope?: IEventDispatchScope + ): Promise> { + const { realtimeEngine, tableRepository, tableMapper } = this; + return scheduleRealtimeProjection( + context, + ViewCreatedRealtimeProjection.name, + (context, scope) => + safeTry(async function* () { + const snapshot = yield* ( + await loadRealtimeTableSnapshot(context, { + baseId: event.baseId, + tableId: event.tableId, + tableRepository, + tableMapper, + tableSnapshotCache: scope.tableSnapshotCache, + isSnapshotUsable: (candidate) => + candidate.views.some((view) => view.id === event.viewId.toString()), + }) + ).safeUnwrap(); + const viewDto = snapshot.views.find((view) => view.id === event.viewId.toString()); + if (!viewDto) { + return err( + domainError.validation({ + message: `Missing view snapshot for ${event.viewId.toString()}`, + }) + ); + } + + const tableDocId = yield* RealtimeDocId.fromParts( + `${tableCollectionPrefix}_${event.baseId.toString()}`, + event.tableId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, tableDocId, snapshot)).safeUnwrap(); + yield* ( + await realtimeEngine.applyChange(context, tableDocId, { + type: 'set', + path: ['views'], + value: snapshot.views, + }) + ).safeUnwrap(); + + const viewDocId = yield* RealtimeDocId.fromParts( + `${viewCollectionPrefix}_${event.tableId.toString()}`, + event.viewId.toString() + ).safeUnwrap(); + return realtimeEngine.ensure(context, viewDocId, viewDto); + }), + getRealtimeProjectionScope(dispatchScope) + ); + } +} diff --git a/packages/v2/core/src/application/projections/ViewDeletedRealtimeProjection.ts b/packages/v2/core/src/application/projections/ViewDeletedRealtimeProjection.ts new file mode 100644 index 0000000000..c976c76487 --- /dev/null +++ b/packages/v2/core/src/application/projections/ViewDeletedRealtimeProjection.ts @@ -0,0 +1,81 @@ +import { inject, injectable } from '@teable/v2-di'; +import { safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../domain/shared/DomainError'; +import { ViewDeleted } from '../../domain/table/events/ViewDeleted'; +import type { IEventDispatchScope, IEventHandler } from '../../ports/EventHandler'; +import type * as ExecutionContextPort from '../../ports/ExecutionContext'; +import * as TableMapperPort from '../../ports/mappers/TableMapper'; +import { RealtimeDocId } from '../../ports/RealtimeDocId'; +import * as RealtimeEnginePort from '../../ports/RealtimeEngine'; +import * as TableRepositoryPort from '../../ports/TableRepository'; +import { v2CoreTokens } from '../../ports/tokens'; +import { ProjectionHandler } from './Projection'; +import { loadRealtimeTableSnapshot } from './RealtimeTableSnapshotCache'; +import { + getRealtimeProjectionScope, + scheduleRealtimeProjection, +} from './scheduleRealtimeProjection'; + +const tableCollectionPrefix = 'tbl'; +const viewCollectionPrefix = 'viw'; + +@ProjectionHandler(ViewDeleted) +@injectable() +export class ViewDeletedRealtimeProjection implements IEventHandler { + constructor( + @inject(v2CoreTokens.realtimeEngine) + private readonly realtimeEngine: RealtimeEnginePort.IRealtimeEngine, + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableMapper) + private readonly tableMapper: TableMapperPort.ITableMapper + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + event: ViewDeleted, + dispatchScope?: IEventDispatchScope + ): Promise> { + const { realtimeEngine, tableRepository, tableMapper } = this; + return scheduleRealtimeProjection( + context, + ViewDeletedRealtimeProjection.name, + (context, scope) => + safeTry(async function* () { + const snapshot = yield* ( + await loadRealtimeTableSnapshot(context, { + baseId: event.baseId, + tableId: event.tableId, + tableRepository, + tableMapper, + tableSnapshotCache: scope.tableSnapshotCache, + isSnapshotUsable: (candidate) => + candidate.views.every((view) => view.id !== event.viewId.toString()), + }) + ).safeUnwrap(); + + const tableDocId = yield* RealtimeDocId.fromParts( + `${tableCollectionPrefix}_${event.baseId.toString()}`, + event.tableId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, tableDocId, snapshot)).safeUnwrap(); + yield* ( + await realtimeEngine.applyChange(context, tableDocId, { + type: 'set', + path: ['views'], + value: snapshot.views, + }) + ).safeUnwrap(); + + const viewDocId = yield* RealtimeDocId.fromParts( + `${viewCollectionPrefix}_${event.tableId.toString()}`, + event.viewId.toString() + ).safeUnwrap(); + return realtimeEngine.delete(context, viewDocId); + }), + getRealtimeProjectionScope(dispatchScope) + ); + } +} diff --git a/packages/v2/core/src/application/projections/ViewDescriptionUpdatedRealtimeProjection.ts b/packages/v2/core/src/application/projections/ViewDescriptionUpdatedRealtimeProjection.ts new file mode 100644 index 0000000000..49ec34494d --- /dev/null +++ b/packages/v2/core/src/application/projections/ViewDescriptionUpdatedRealtimeProjection.ts @@ -0,0 +1,101 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../domain/shared/DomainError'; +import { ViewDescriptionUpdated } from '../../domain/table/events/ViewDescriptionUpdated'; +import type { IEventDispatchScope, IEventHandler } from '../../ports/EventHandler'; +import type * as ExecutionContextPort from '../../ports/ExecutionContext'; +import * as TableMapperPort from '../../ports/mappers/TableMapper'; +import { RealtimeDocId } from '../../ports/RealtimeDocId'; +import * as RealtimeEnginePort from '../../ports/RealtimeEngine'; +import * as TableRepositoryPort from '../../ports/TableRepository'; +import { v2CoreTokens } from '../../ports/tokens'; +import { ProjectionHandler } from './Projection'; +import { loadRealtimeTableSnapshot } from './RealtimeTableSnapshotCache'; +import { + getRealtimeProjectionScope, + scheduleRealtimeProjection, +} from './scheduleRealtimeProjection'; + +const tableCollectionPrefix = 'tbl'; +const viewCollectionPrefix = 'viw'; + +@ProjectionHandler(ViewDescriptionUpdated) +@injectable() +export class ViewDescriptionUpdatedRealtimeProjection + implements IEventHandler +{ + constructor( + @inject(v2CoreTokens.realtimeEngine) + private readonly realtimeEngine: RealtimeEnginePort.IRealtimeEngine, + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableMapper) + private readonly tableMapper: TableMapperPort.ITableMapper + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + event: ViewDescriptionUpdated, + dispatchScope?: IEventDispatchScope + ): Promise> { + const { realtimeEngine, tableRepository, tableMapper } = this; + return scheduleRealtimeProjection( + context, + ViewDescriptionUpdatedRealtimeProjection.name, + (context, scope) => + safeTry(async function* () { + const snapshot = yield* ( + await loadRealtimeTableSnapshot(context, { + baseId: event.baseId, + tableId: event.tableId, + tableRepository, + tableMapper, + tableSnapshotCache: scope.tableSnapshotCache, + isSnapshotUsable: (candidate) => + candidate.views.some( + (view) => + view.id === event.viewId.toString() && + view.description === event.nextDescription + ), + }) + ).safeUnwrap(); + + const viewIndex = snapshot.views.findIndex((view) => view.id === event.viewId.toString()); + if (viewIndex === -1) return ok(undefined); + const viewDto = snapshot.views[viewIndex]; + + const tableDocId = yield* RealtimeDocId.fromParts( + `${tableCollectionPrefix}_${event.baseId.toString()}`, + event.tableId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, tableDocId, snapshot)).safeUnwrap(); + yield* ( + await realtimeEngine.applyChange(context, tableDocId, { + type: 'set', + path: ['views', viewIndex, 'description'], + value: viewDto.description, + }) + ).safeUnwrap(); + + const viewDocId = yield* RealtimeDocId.fromParts( + `${viewCollectionPrefix}_${event.tableId.toString()}`, + event.viewId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, viewDocId, viewDto)).safeUnwrap(); + return realtimeEngine.applyChange( + context, + viewDocId, + { + type: 'set', + path: ['description'], + value: event.nextDescription, + }, + { version: event.oldVersion } + ); + }), + getRealtimeProjectionScope(dispatchScope) + ); + } +} diff --git a/packages/v2/core/src/application/projections/ViewFilterUpdatedRealtimeProjection.ts b/packages/v2/core/src/application/projections/ViewFilterUpdatedRealtimeProjection.ts new file mode 100644 index 0000000000..9c08810be0 --- /dev/null +++ b/packages/v2/core/src/application/projections/ViewFilterUpdatedRealtimeProjection.ts @@ -0,0 +1,120 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../domain/shared/DomainError'; +import { ViewFilterUpdated } from '../../domain/table/events/ViewFilterUpdated'; +import type { IEventDispatchScope, IEventHandler } from '../../ports/EventHandler'; +import type * as ExecutionContextPort from '../../ports/ExecutionContext'; +import * as TableMapperPort from '../../ports/mappers/TableMapper'; +import { RealtimeDocId } from '../../ports/RealtimeDocId'; +import * as RealtimeEnginePort from '../../ports/RealtimeEngine'; +import * as TableRepositoryPort from '../../ports/TableRepository'; +import { v2CoreTokens } from '../../ports/tokens'; +import { ProjectionHandler } from './Projection'; +import { loadRealtimeTableSnapshot } from './RealtimeTableSnapshotCache'; +import { + getRealtimeProjectionScope, + scheduleRealtimeProjection, +} from './scheduleRealtimeProjection'; + +const tableCollectionPrefix = 'tbl'; +const viewCollectionPrefix = 'viw'; + +@ProjectionHandler(ViewFilterUpdated) +@injectable() +export class ViewFilterUpdatedRealtimeProjection implements IEventHandler { + constructor( + @inject(v2CoreTokens.realtimeEngine) + private readonly realtimeEngine: RealtimeEnginePort.IRealtimeEngine, + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableMapper) + private readonly tableMapper: TableMapperPort.ITableMapper + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + event: ViewFilterUpdated, + dispatchScope?: IEventDispatchScope + ): Promise> { + const { realtimeEngine, tableRepository, tableMapper } = this; + return scheduleRealtimeProjection( + context, + ViewFilterUpdatedRealtimeProjection.name, + (context, scope) => + safeTry(async function* () { + const snapshot = yield* ( + await loadRealtimeTableSnapshot(context, { + baseId: event.baseId, + tableId: event.tableId, + tableRepository, + tableMapper, + tableSnapshotCache: scope.tableSnapshotCache, + isSnapshotUsable: (candidate) => { + const view = candidate.views.find( + (candidateView) => candidateView.id === event.viewId.toString() + ); + return JSON.stringify(view?.sourceFilter) === JSON.stringify(event.nextFilter); + }, + }) + ).safeUnwrap(); + const viewIndex = snapshot.views.findIndex((view) => view.id === event.viewId.toString()); + if (viewIndex === -1) return ok(undefined); + const viewDto = snapshot.views[viewIndex]; + + const tableDocId = yield* RealtimeDocId.fromParts( + `${tableCollectionPrefix}_${event.baseId.toString()}`, + event.tableId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, tableDocId, snapshot)).safeUnwrap(); + yield* ( + await realtimeEngine.applyChange(context, tableDocId, [ + { type: 'set', path: ['views', viewIndex, 'query'], value: viewDto.query }, + { + type: 'set', + path: ['views', viewIndex, 'sourceFilter'], + value: viewDto.sourceFilter, + oldValue: event.previousFilter, + }, + // Legacy clients (IViewVo) read `filter`, not the v2-internal `sourceFilter`. + { + type: 'set', + path: ['views', viewIndex, 'filter'], + value: viewDto.sourceFilter, + oldValue: event.previousFilter, + }, + ]) + ).safeUnwrap(); + + const viewDocId = yield* RealtimeDocId.fromParts( + `${viewCollectionPrefix}_${event.tableId.toString()}`, + event.viewId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, viewDocId, viewDto)).safeUnwrap(); + return realtimeEngine.applyChange( + context, + viewDocId, + [ + { type: 'set', path: ['query'], value: viewDto.query }, + { + type: 'set', + path: ['sourceFilter'], + value: viewDto.sourceFilter, + oldValue: event.previousFilter, + }, + // Keep ShareDB view docs aligned with the HTTP VO shape (`filter`). + { + type: 'set', + path: ['filter'], + value: viewDto.sourceFilter, + oldValue: event.previousFilter, + }, + ], + { version: event.oldVersion } + ); + }), + getRealtimeProjectionScope(dispatchScope) + ); + } +} diff --git a/packages/v2/core/src/application/projections/ViewGroupUpdatedRealtimeProjection.ts b/packages/v2/core/src/application/projections/ViewGroupUpdatedRealtimeProjection.ts new file mode 100644 index 0000000000..1b58309848 --- /dev/null +++ b/packages/v2/core/src/application/projections/ViewGroupUpdatedRealtimeProjection.ts @@ -0,0 +1,100 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry, type Result } from 'neverthrow'; + +import type { DomainError } from '../../domain/shared/DomainError'; +import { ViewGroupUpdated } from '../../domain/table/events/ViewGroupUpdated'; +import type { ViewGroupDTO } from '../../domain/table/views/ViewGroup'; +import type { IEventDispatchScope, IEventHandler } from '../../ports/EventHandler'; +import type * as ExecutionContextPort from '../../ports/ExecutionContext'; +import * as TableMapperPort from '../../ports/mappers/TableMapper'; +import { RealtimeDocId } from '../../ports/RealtimeDocId'; +import * as RealtimeEnginePort from '../../ports/RealtimeEngine'; +import * as TableRepositoryPort from '../../ports/TableRepository'; +import { v2CoreTokens } from '../../ports/tokens'; +import { ProjectionHandler } from './Projection'; +import { loadRealtimeTableSnapshot } from './RealtimeTableSnapshotCache'; +import { + getRealtimeProjectionScope, + scheduleRealtimeProjection, +} from './scheduleRealtimeProjection'; + +const tableCollectionPrefix = 'tbl'; +const viewCollectionPrefix = 'viw'; + +const queryGroupMatches = ( + query: { group?: unknown } | undefined, + group: ViewGroupDTO +): boolean => { + const projected = query?.group === undefined ? null : query.group; + return JSON.stringify(projected) === JSON.stringify(group); +}; + +@ProjectionHandler(ViewGroupUpdated) +@injectable() +export class ViewGroupUpdatedRealtimeProjection implements IEventHandler { + constructor( + @inject(v2CoreTokens.realtimeEngine) + private readonly realtimeEngine: RealtimeEnginePort.IRealtimeEngine, + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableMapper) + private readonly tableMapper: TableMapperPort.ITableMapper + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + event: ViewGroupUpdated, + dispatchScope?: IEventDispatchScope + ): Promise> { + const { realtimeEngine, tableRepository, tableMapper } = this; + return scheduleRealtimeProjection( + context, + ViewGroupUpdatedRealtimeProjection.name, + (context, scope) => + safeTry(async function* () { + const snapshot = yield* ( + await loadRealtimeTableSnapshot(context, { + baseId: event.baseId, + tableId: event.tableId, + tableRepository, + tableMapper, + tableSnapshotCache: scope.tableSnapshotCache, + isSnapshotUsable: (candidate) => { + const view = candidate.views.find( + (candidateView) => candidateView.id === event.viewId.toString() + ); + return queryGroupMatches(view?.query, event.nextGroup); + }, + }) + ).safeUnwrap(); + const viewIndex = snapshot.views.findIndex((view) => view.id === event.viewId.toString()); + if (viewIndex === -1) return ok(undefined); + const viewDto = snapshot.views[viewIndex]; + + const tableDocId = yield* RealtimeDocId.fromParts( + `${tableCollectionPrefix}_${event.baseId.toString()}`, + event.tableId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, tableDocId, snapshot)).safeUnwrap(); + yield* ( + await realtimeEngine.applyChange(context, tableDocId, [ + { type: 'set', path: ['views', viewIndex, 'query'], value: viewDto.query }, + ]) + ).safeUnwrap(); + + const viewDocId = yield* RealtimeDocId.fromParts( + `${viewCollectionPrefix}_${event.tableId.toString()}`, + event.viewId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, viewDocId, viewDto)).safeUnwrap(); + return realtimeEngine.applyChange( + context, + viewDocId, + [{ type: 'set', path: ['query'], value: viewDto.query }], + { version: event.oldVersion } + ); + }), + getRealtimeProjectionScope(dispatchScope) + ); + } +} diff --git a/packages/v2/core/src/application/projections/ViewLockedUpdatedRealtimeProjection.ts b/packages/v2/core/src/application/projections/ViewLockedUpdatedRealtimeProjection.ts new file mode 100644 index 0000000000..cfb3318d4e --- /dev/null +++ b/packages/v2/core/src/application/projections/ViewLockedUpdatedRealtimeProjection.ts @@ -0,0 +1,111 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../domain/shared/DomainError'; +import { ViewLockedUpdated } from '../../domain/table/events/ViewLockedUpdated'; +import type { IEventDispatchScope, IEventHandler } from '../../ports/EventHandler'; +import type * as ExecutionContextPort from '../../ports/ExecutionContext'; +import * as TableMapperPort from '../../ports/mappers/TableMapper'; +import { RealtimeDocId } from '../../ports/RealtimeDocId'; +import * as RealtimeEnginePort from '../../ports/RealtimeEngine'; +import * as TableRepositoryPort from '../../ports/TableRepository'; +import { v2CoreTokens } from '../../ports/tokens'; +import { ProjectionHandler } from './Projection'; +import { loadRealtimeTableSnapshot } from './RealtimeTableSnapshotCache'; +import { + getRealtimeProjectionScope, + scheduleRealtimeProjection, +} from './scheduleRealtimeProjection'; + +const tableCollectionPrefix = 'tbl'; +const viewCollectionPrefix = 'viw'; + +@ProjectionHandler(ViewLockedUpdated) +@injectable() +export class ViewLockedUpdatedRealtimeProjection implements IEventHandler { + constructor( + @inject(v2CoreTokens.realtimeEngine) + private readonly realtimeEngine: RealtimeEnginePort.IRealtimeEngine, + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableMapper) + private readonly tableMapper: TableMapperPort.ITableMapper + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + event: ViewLockedUpdated, + dispatchScope?: IEventDispatchScope + ): Promise> { + const { realtimeEngine, tableRepository, tableMapper } = this; + return scheduleRealtimeProjection( + context, + ViewLockedUpdatedRealtimeProjection.name, + (context, scope) => + safeTry(async function* () { + const snapshot = yield* ( + await loadRealtimeTableSnapshot(context, { + baseId: event.baseId, + tableId: event.tableId, + tableRepository, + tableMapper, + tableSnapshotCache: scope.tableSnapshotCache, + isSnapshotUsable: (candidate) => + candidate.views.some( + (view) => + view.id === event.viewId.toString() && view.isLocked === event.nextIsLocked + ), + }) + ).safeUnwrap(); + + const viewIndex = snapshot.views.findIndex((view) => view.id === event.viewId.toString()); + if (viewIndex === -1) return ok(undefined); + const viewDto = snapshot.views[viewIndex]; + const hasStateChange = + event.previousIsLocked !== undefined || event.nextIsLocked !== undefined; + + const tableDocId = yield* RealtimeDocId.fromParts( + `${tableCollectionPrefix}_${event.baseId.toString()}`, + event.tableId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, tableDocId, snapshot)).safeUnwrap(); + if (hasStateChange) { + yield* ( + await realtimeEngine.applyChange(context, tableDocId, { + type: 'set', + path: ['views', viewIndex, 'isLocked'], + value: viewDto.isLocked, + oldValue: event.previousIsLocked, + }) + ).safeUnwrap(); + } + + const viewDocId = yield* RealtimeDocId.fromParts( + `${viewCollectionPrefix}_${event.tableId.toString()}`, + event.viewId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, viewDocId, viewDto)).safeUnwrap(); + return realtimeEngine.applyChange( + context, + viewDocId, + hasStateChange + ? { + type: 'set', + path: ['isLocked'], + value: event.nextIsLocked, + oldValue: event.previousIsLocked, + } + : { + type: 'set', + path: ['id'], + value: event.viewId.toString(), + oldValue: event.viewId.toString(), + }, + { version: event.oldVersion } + ); + }), + getRealtimeProjectionScope(dispatchScope) + ); + } +} diff --git a/packages/v2/core/src/application/projections/ViewManualSortAppliedRealtimeProjection.ts b/packages/v2/core/src/application/projections/ViewManualSortAppliedRealtimeProjection.ts new file mode 100644 index 0000000000..defc0441f6 --- /dev/null +++ b/packages/v2/core/src/application/projections/ViewManualSortAppliedRealtimeProjection.ts @@ -0,0 +1,35 @@ +import { inject, injectable } from '@teable/v2-di'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../domain/shared/DomainError'; +import { ViewManualSortApplied } from '../../domain/table/events/ViewManualSortApplied'; +import type { IEventHandler } from '../../ports/EventHandler'; +import type * as ExecutionContextPort from '../../ports/ExecutionContext'; +import * as RealtimeEnginePort from '../../ports/RealtimeEngine'; +import { v2CoreTokens } from '../../ports/tokens'; +import { ProjectionHandler } from './Projection'; +import { buildRecordCollection } from './TableRecordRealtimeDTO'; + +@ProjectionHandler(ViewManualSortApplied) +@injectable() +export class ViewManualSortAppliedRealtimeProjection + implements IEventHandler +{ + constructor( + @inject(v2CoreTokens.realtimeEngine) + private readonly realtimeEngine: RealtimeEnginePort.IRealtimeEngine + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + event: ViewManualSortApplied + ): Promise> { + const collection = buildRecordCollection(event.tableId.toString()); + return this.realtimeEngine.invalidateCollection(context, collection, { + type: 'set', + path: ['fields', event.viewId.toRowOrderColumnName()], + value: null, + oldValue: null, + }); + } +} diff --git a/packages/v2/core/src/application/projections/ViewOptionsUpdatedRealtimeProjection.ts b/packages/v2/core/src/application/projections/ViewOptionsUpdatedRealtimeProjection.ts new file mode 100644 index 0000000000..c89d7ab66c --- /dev/null +++ b/packages/v2/core/src/application/projections/ViewOptionsUpdatedRealtimeProjection.ts @@ -0,0 +1,91 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry, type Result } from 'neverthrow'; + +import type { DomainError } from '../../domain/shared/DomainError'; +import { ViewOptionsUpdated } from '../../domain/table/events/ViewOptionsUpdated'; +import type { IEventDispatchScope, IEventHandler } from '../../ports/EventHandler'; +import type * as ExecutionContextPort from '../../ports/ExecutionContext'; +import * as TableMapperPort from '../../ports/mappers/TableMapper'; +import { RealtimeDocId } from '../../ports/RealtimeDocId'; +import * as RealtimeEnginePort from '../../ports/RealtimeEngine'; +import * as TableRepositoryPort from '../../ports/TableRepository'; +import { v2CoreTokens } from '../../ports/tokens'; +import { ProjectionHandler } from './Projection'; +import { loadRealtimeTableSnapshot } from './RealtimeTableSnapshotCache'; +import { + getRealtimeProjectionScope, + scheduleRealtimeProjection, +} from './scheduleRealtimeProjection'; + +const tableCollectionPrefix = 'tbl'; +const viewCollectionPrefix = 'viw'; + +@ProjectionHandler(ViewOptionsUpdated) +@injectable() +export class ViewOptionsUpdatedRealtimeProjection implements IEventHandler { + constructor( + @inject(v2CoreTokens.realtimeEngine) + private readonly realtimeEngine: RealtimeEnginePort.IRealtimeEngine, + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableMapper) + private readonly tableMapper: TableMapperPort.ITableMapper + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + event: ViewOptionsUpdated, + dispatchScope?: IEventDispatchScope + ): Promise> { + const { realtimeEngine, tableRepository, tableMapper } = this; + return scheduleRealtimeProjection( + context, + ViewOptionsUpdatedRealtimeProjection.name, + (context, scope) => + safeTry(async function* () { + const snapshot = yield* ( + await loadRealtimeTableSnapshot(context, { + baseId: event.baseId, + tableId: event.tableId, + tableRepository, + tableMapper, + tableSnapshotCache: scope.tableSnapshotCache, + isSnapshotUsable: (candidate) => { + const view = candidate.views.find( + (candidateView) => candidateView.id === event.viewId.toString() + ); + return JSON.stringify(view?.options) === JSON.stringify(event.nextOptions); + }, + }) + ).safeUnwrap(); + const viewIndex = snapshot.views.findIndex((view) => view.id === event.viewId.toString()); + if (viewIndex === -1) return ok(undefined); + const viewDto = snapshot.views[viewIndex]; + + const tableDocId = yield* RealtimeDocId.fromParts( + `${tableCollectionPrefix}_${event.baseId.toString()}`, + event.tableId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, tableDocId, snapshot)).safeUnwrap(); + yield* ( + await realtimeEngine.applyChange(context, tableDocId, [ + { type: 'set', path: ['views', viewIndex, 'options'], value: viewDto.options }, + ]) + ).safeUnwrap(); + + const viewDocId = yield* RealtimeDocId.fromParts( + `${viewCollectionPrefix}_${event.tableId.toString()}`, + event.viewId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, viewDocId, viewDto)).safeUnwrap(); + return realtimeEngine.applyChange( + context, + viewDocId, + [{ type: 'set', path: ['options'], value: viewDto.options }], + { version: event.oldVersion } + ); + }), + getRealtimeProjectionScope(dispatchScope) + ); + } +} diff --git a/packages/v2/core/src/application/projections/ViewOrderUpdatedRealtimeProjection.ts b/packages/v2/core/src/application/projections/ViewOrderUpdatedRealtimeProjection.ts new file mode 100644 index 0000000000..0dd270ab5a --- /dev/null +++ b/packages/v2/core/src/application/projections/ViewOrderUpdatedRealtimeProjection.ts @@ -0,0 +1,94 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../domain/shared/DomainError'; +import { ViewOrderUpdated } from '../../domain/table/events/ViewOrderUpdated'; +import type { IEventDispatchScope, IEventHandler } from '../../ports/EventHandler'; +import type * as ExecutionContextPort from '../../ports/ExecutionContext'; +import * as TableMapperPort from '../../ports/mappers/TableMapper'; +import { RealtimeDocId } from '../../ports/RealtimeDocId'; +import * as RealtimeEnginePort from '../../ports/RealtimeEngine'; +import * as TableRepositoryPort from '../../ports/TableRepository'; +import { v2CoreTokens } from '../../ports/tokens'; +import { ProjectionHandler } from './Projection'; +import { loadRealtimeTableSnapshot } from './RealtimeTableSnapshotCache'; +import { + getRealtimeProjectionScope, + scheduleRealtimeProjection, +} from './scheduleRealtimeProjection'; + +const tableCollectionPrefix = 'tbl'; +const viewCollectionPrefix = 'viw'; + +@ProjectionHandler(ViewOrderUpdated) +@injectable() +export class ViewOrderUpdatedRealtimeProjection implements IEventHandler { + constructor( + @inject(v2CoreTokens.realtimeEngine) + private readonly realtimeEngine: RealtimeEnginePort.IRealtimeEngine, + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableMapper) + private readonly tableMapper: TableMapperPort.ITableMapper + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + event: ViewOrderUpdated, + dispatchScope?: IEventDispatchScope + ): Promise> { + const { realtimeEngine, tableRepository, tableMapper } = this; + return scheduleRealtimeProjection( + context, + ViewOrderUpdatedRealtimeProjection.name, + (context, scope) => + safeTry(async function* () { + const snapshot = yield* ( + await loadRealtimeTableSnapshot(context, { + baseId: event.baseId, + tableId: event.tableId, + tableRepository, + tableMapper, + tableSnapshotCache: scope.tableSnapshotCache, + }) + ).safeUnwrap(); + const viewIndex = snapshot.views.findIndex((view) => view.id === event.viewId.toString()); + if (viewIndex === -1) return ok(undefined); + const viewDto = snapshot.views[viewIndex]!; + + const tableDocId = yield* RealtimeDocId.fromParts( + `${tableCollectionPrefix}_${event.baseId.toString()}`, + event.tableId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, tableDocId, snapshot)).safeUnwrap(); + yield* ( + await realtimeEngine.applyChange(context, tableDocId, { + type: 'set', + path: ['views', viewIndex, 'order'], + value: event.nextOrder.toNumber(), + oldValue: event.previousOrder.toNumber(), + }) + ).safeUnwrap(); + + const viewDocId = yield* RealtimeDocId.fromParts( + `${viewCollectionPrefix}_${event.tableId.toString()}`, + event.viewId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, viewDocId, viewDto)).safeUnwrap(); + return realtimeEngine.applyChange( + context, + viewDocId, + { + type: 'set', + path: ['order'], + value: event.nextOrder.toNumber(), + oldValue: event.previousOrder.toNumber(), + }, + { version: event.oldVersion } + ); + }), + getRealtimeProjectionScope(dispatchScope) + ); + } +} diff --git a/packages/v2/core/src/application/projections/ViewRenamedRealtimeProjection.ts b/packages/v2/core/src/application/projections/ViewRenamedRealtimeProjection.ts new file mode 100644 index 0000000000..995d8b4540 --- /dev/null +++ b/packages/v2/core/src/application/projections/ViewRenamedRealtimeProjection.ts @@ -0,0 +1,98 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../domain/shared/DomainError'; +import { ViewRenamed } from '../../domain/table/events/ViewRenamed'; +import type { IEventDispatchScope, IEventHandler } from '../../ports/EventHandler'; +import type * as ExecutionContextPort from '../../ports/ExecutionContext'; +import * as TableMapperPort from '../../ports/mappers/TableMapper'; +import { RealtimeDocId } from '../../ports/RealtimeDocId'; +import * as RealtimeEnginePort from '../../ports/RealtimeEngine'; +import * as TableRepositoryPort from '../../ports/TableRepository'; +import { v2CoreTokens } from '../../ports/tokens'; +import { ProjectionHandler } from './Projection'; +import { loadRealtimeTableSnapshot } from './RealtimeTableSnapshotCache'; +import { + getRealtimeProjectionScope, + scheduleRealtimeProjection, +} from './scheduleRealtimeProjection'; + +const tableCollectionPrefix = 'tbl'; +const viewCollectionPrefix = 'viw'; + +@ProjectionHandler(ViewRenamed) +@injectable() +export class ViewRenamedRealtimeProjection implements IEventHandler { + constructor( + @inject(v2CoreTokens.realtimeEngine) + private readonly realtimeEngine: RealtimeEnginePort.IRealtimeEngine, + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableMapper) + private readonly tableMapper: TableMapperPort.ITableMapper + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + event: ViewRenamed, + dispatchScope?: IEventDispatchScope + ): Promise> { + const { realtimeEngine, tableRepository, tableMapper } = this; + return scheduleRealtimeProjection( + context, + ViewRenamedRealtimeProjection.name, + (context, scope) => + safeTry(async function* () { + const snapshot = yield* ( + await loadRealtimeTableSnapshot(context, { + baseId: event.baseId, + tableId: event.tableId, + tableRepository, + tableMapper, + tableSnapshotCache: scope.tableSnapshotCache, + isSnapshotUsable: (candidate) => + candidate.views.some( + (view) => + view.id === event.viewId.toString() && view.name === event.nextName.toString() + ), + }) + ).safeUnwrap(); + + const viewIndex = snapshot.views.findIndex((view) => view.id === event.viewId.toString()); + if (viewIndex === -1) return ok(undefined); + const viewDto = snapshot.views[viewIndex]; + + const tableDocId = yield* RealtimeDocId.fromParts( + `${tableCollectionPrefix}_${event.baseId.toString()}`, + event.tableId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, tableDocId, snapshot)).safeUnwrap(); + yield* ( + await realtimeEngine.applyChange(context, tableDocId, { + type: 'set', + path: ['views', viewIndex, 'name'], + value: viewDto.name, + }) + ).safeUnwrap(); + + const viewDocId = yield* RealtimeDocId.fromParts( + `${viewCollectionPrefix}_${event.tableId.toString()}`, + event.viewId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, viewDocId, viewDto)).safeUnwrap(); + return realtimeEngine.applyChange( + context, + viewDocId, + { + type: 'set', + path: ['name'], + value: event.nextName.toString(), + }, + { version: event.oldVersion } + ); + }), + getRealtimeProjectionScope(dispatchScope) + ); + } +} diff --git a/packages/v2/core/src/application/projections/ViewShareIdRefreshedRealtimeProjection.ts b/packages/v2/core/src/application/projections/ViewShareIdRefreshedRealtimeProjection.ts new file mode 100644 index 0000000000..cbc571e207 --- /dev/null +++ b/packages/v2/core/src/application/projections/ViewShareIdRefreshedRealtimeProjection.ts @@ -0,0 +1,91 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry, type Result } from 'neverthrow'; + +import type { DomainError } from '../../domain/shared/DomainError'; +import { ViewShareIdRefreshed } from '../../domain/table/events/ViewShareIdRefreshed'; +import type { IEventDispatchScope, IEventHandler } from '../../ports/EventHandler'; +import type * as ExecutionContextPort from '../../ports/ExecutionContext'; +import * as TableMapperPort from '../../ports/mappers/TableMapper'; +import { RealtimeDocId } from '../../ports/RealtimeDocId'; +import * as RealtimeEnginePort from '../../ports/RealtimeEngine'; +import * as TableRepositoryPort from '../../ports/TableRepository'; +import { v2CoreTokens } from '../../ports/tokens'; +import { ProjectionHandler } from './Projection'; +import { loadRealtimeTableSnapshot } from './RealtimeTableSnapshotCache'; +import { + getRealtimeProjectionScope, + scheduleRealtimeProjection, +} from './scheduleRealtimeProjection'; + +@ProjectionHandler(ViewShareIdRefreshed) +@injectable() +export class ViewShareIdRefreshedRealtimeProjection implements IEventHandler { + constructor( + @inject(v2CoreTokens.realtimeEngine) + private readonly realtimeEngine: RealtimeEnginePort.IRealtimeEngine, + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableMapper) + private readonly tableMapper: TableMapperPort.ITableMapper + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + event: ViewShareIdRefreshed, + dispatchScope?: IEventDispatchScope + ): Promise> { + const { realtimeEngine, tableRepository, tableMapper } = this; + return scheduleRealtimeProjection( + context, + ViewShareIdRefreshedRealtimeProjection.name, + (context, scope) => + safeTry(async function* () { + const snapshot = yield* ( + await loadRealtimeTableSnapshot(context, { + baseId: event.baseId, + tableId: event.tableId, + tableRepository, + tableMapper, + tableSnapshotCache: scope.tableSnapshotCache, + isSnapshotUsable: (candidate) => + candidate.views.some( + (view) => + view.id === event.viewId.toString() && view.shareId === event.nextShareId + ), + }) + ).safeUnwrap(); + const viewIndex = snapshot.views.findIndex((view) => view.id === event.viewId.toString()); + if (viewIndex === -1) return ok(undefined); + const viewDto = snapshot.views[viewIndex]!; + + const tableDocId = yield* RealtimeDocId.fromParts( + `tbl_${event.baseId.toString()}`, + event.tableId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, tableDocId, snapshot)).safeUnwrap(); + yield* ( + await realtimeEngine.applyChange(context, tableDocId, [ + { + type: 'set', + path: ['views', viewIndex, 'shareId'], + value: viewDto.shareId, + }, + ]) + ).safeUnwrap(); + + const viewDocId = yield* RealtimeDocId.fromParts( + `viw_${event.tableId.toString()}`, + event.viewId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, viewDocId, viewDto)).safeUnwrap(); + return realtimeEngine.applyChange( + context, + viewDocId, + [{ type: 'set', path: ['shareId'], value: viewDto.shareId }], + { version: event.oldVersion } + ); + }), + getRealtimeProjectionScope(dispatchScope) + ); + } +} diff --git a/packages/v2/core/src/application/projections/ViewShareMetaUpdatedRealtimeProjection.ts b/packages/v2/core/src/application/projections/ViewShareMetaUpdatedRealtimeProjection.ts new file mode 100644 index 0000000000..b371d645f2 --- /dev/null +++ b/packages/v2/core/src/application/projections/ViewShareMetaUpdatedRealtimeProjection.ts @@ -0,0 +1,92 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry, type Result } from 'neverthrow'; + +import type { DomainError } from '../../domain/shared/DomainError'; +import { ViewShareMetaUpdated } from '../../domain/table/events/ViewShareMetaUpdated'; +import type { IEventDispatchScope, IEventHandler } from '../../ports/EventHandler'; +import type * as ExecutionContextPort from '../../ports/ExecutionContext'; +import * as TableMapperPort from '../../ports/mappers/TableMapper'; +import { RealtimeDocId } from '../../ports/RealtimeDocId'; +import * as RealtimeEnginePort from '../../ports/RealtimeEngine'; +import * as TableRepositoryPort from '../../ports/TableRepository'; +import { v2CoreTokens } from '../../ports/tokens'; +import { ProjectionHandler } from './Projection'; +import { loadRealtimeTableSnapshot } from './RealtimeTableSnapshotCache'; +import { + getRealtimeProjectionScope, + scheduleRealtimeProjection, +} from './scheduleRealtimeProjection'; + +@ProjectionHandler(ViewShareMetaUpdated) +@injectable() +export class ViewShareMetaUpdatedRealtimeProjection implements IEventHandler { + constructor( + @inject(v2CoreTokens.realtimeEngine) + private readonly realtimeEngine: RealtimeEnginePort.IRealtimeEngine, + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableMapper) + private readonly tableMapper: TableMapperPort.ITableMapper + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + event: ViewShareMetaUpdated, + dispatchScope?: IEventDispatchScope + ): Promise> { + const { realtimeEngine, tableRepository, tableMapper } = this; + return scheduleRealtimeProjection( + context, + ViewShareMetaUpdatedRealtimeProjection.name, + (context, scope) => + safeTry(async function* () { + const snapshot = yield* ( + await loadRealtimeTableSnapshot(context, { + baseId: event.baseId, + tableId: event.tableId, + tableRepository, + tableMapper, + tableSnapshotCache: scope.tableSnapshotCache, + isSnapshotUsable: (candidate) => { + const view = candidate.views.find( + (candidateView) => candidateView.id === event.viewId.toString() + ); + return JSON.stringify(view?.shareMeta) === JSON.stringify(event.nextShareMeta); + }, + }) + ).safeUnwrap(); + const viewIndex = snapshot.views.findIndex((view) => view.id === event.viewId.toString()); + if (viewIndex === -1) return ok(undefined); + const viewDto = snapshot.views[viewIndex]!; + + const tableDocId = yield* RealtimeDocId.fromParts( + `tbl_${event.baseId.toString()}`, + event.tableId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, tableDocId, snapshot)).safeUnwrap(); + yield* ( + await realtimeEngine.applyChange(context, tableDocId, [ + { + type: 'set', + path: ['views', viewIndex, 'shareMeta'], + value: viewDto.shareMeta, + }, + ]) + ).safeUnwrap(); + + const viewDocId = yield* RealtimeDocId.fromParts( + `viw_${event.tableId.toString()}`, + event.viewId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, viewDocId, viewDto)).safeUnwrap(); + return realtimeEngine.applyChange( + context, + viewDocId, + [{ type: 'set', path: ['shareMeta'], value: viewDto.shareMeta }], + { version: event.oldVersion } + ); + }), + getRealtimeProjectionScope(dispatchScope) + ); + } +} diff --git a/packages/v2/core/src/application/projections/ViewShareStateRealtimeProjection.ts b/packages/v2/core/src/application/projections/ViewShareStateRealtimeProjection.ts new file mode 100644 index 0000000000..917b363d7a --- /dev/null +++ b/packages/v2/core/src/application/projections/ViewShareStateRealtimeProjection.ts @@ -0,0 +1,146 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry, type Result } from 'neverthrow'; + +import type { DomainError } from '../../domain/shared/DomainError'; +import { ViewShareDisabled } from '../../domain/table/events/ViewShareDisabled'; +import { ViewShareEnabled } from '../../domain/table/events/ViewShareEnabled'; +import type { IEventDispatchScope, IEventHandler } from '../../ports/EventHandler'; +import type * as ExecutionContextPort from '../../ports/ExecutionContext'; +import type { ITableViewPersistenceDTO } from '../../ports/mappers/TableMapper'; +import * as TableMapperPort from '../../ports/mappers/TableMapper'; +import { RealtimeDocId } from '../../ports/RealtimeDocId'; +import * as RealtimeEnginePort from '../../ports/RealtimeEngine'; +import * as TableRepositoryPort from '../../ports/TableRepository'; +import { v2CoreTokens } from '../../ports/tokens'; +import { ProjectionHandler } from './Projection'; +import { loadRealtimeTableSnapshot } from './RealtimeTableSnapshotCache'; +import { + getRealtimeProjectionScope, + scheduleRealtimeProjection, +} from './scheduleRealtimeProjection'; + +type ViewShareStateEvent = ViewShareEnabled | ViewShareDisabled; + +abstract class ViewShareStateRealtimeProjection + implements IEventHandler +{ + constructor( + protected readonly realtimeEngine: RealtimeEnginePort.IRealtimeEngine, + protected readonly tableRepository: TableRepositoryPort.ITableRepository, + protected readonly tableMapper: TableMapperPort.ITableMapper + ) {} + + protected abstract isSnapshotUsable(view: ITableViewPersistenceDTO, event: TEvent): boolean; + + async handle( + context: ExecutionContextPort.IExecutionContext, + event: TEvent, + dispatchScope?: IEventDispatchScope + ): Promise> { + const { realtimeEngine, tableRepository, tableMapper } = this; + const isSnapshotUsable = this.isSnapshotUsable.bind(this); + return scheduleRealtimeProjection( + context, + this.constructor.name, + (context, scope) => + safeTry(async function* () { + const snapshot = yield* ( + await loadRealtimeTableSnapshot(context, { + baseId: event.baseId, + tableId: event.tableId, + tableRepository, + tableMapper, + tableSnapshotCache: scope.tableSnapshotCache, + isSnapshotUsable: (candidate) => { + const view = candidate.views.find( + (candidateView) => candidateView.id === event.viewId.toString() + ); + if (!view) return false; + return isSnapshotUsable(view, event); + }, + }) + ).safeUnwrap(); + const viewIndex = snapshot.views.findIndex((view) => view.id === event.viewId.toString()); + if (viewIndex === -1) return ok(undefined); + const viewDto = snapshot.views[viewIndex]!; + const changes = [ + { type: 'set' as const, path: ['enableShare'] as const, value: viewDto.enableShare }, + { type: 'set' as const, path: ['shareId'] as const, value: viewDto.shareId }, + { type: 'set' as const, path: ['shareMeta'] as const, value: viewDto.shareMeta }, + ]; + + const tableDocId = yield* RealtimeDocId.fromParts( + `tbl_${event.baseId.toString()}`, + event.tableId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, tableDocId, snapshot)).safeUnwrap(); + yield* ( + await realtimeEngine.applyChange( + context, + tableDocId, + changes.map((change) => ({ + ...change, + path: ['views', viewIndex, ...change.path], + })) + ) + ).safeUnwrap(); + + const viewDocId = yield* RealtimeDocId.fromParts( + `viw_${event.tableId.toString()}`, + event.viewId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, viewDocId, viewDto)).safeUnwrap(); + return realtimeEngine.applyChange(context, viewDocId, changes, { + version: event.oldVersion, + }); + }), + getRealtimeProjectionScope(dispatchScope) + ); + } +} + +@ProjectionHandler(ViewShareEnabled) +@injectable() +export class ViewShareEnabledRealtimeProjection extends ViewShareStateRealtimeProjection { + constructor( + @inject(v2CoreTokens.realtimeEngine) + realtimeEngine: RealtimeEnginePort.IRealtimeEngine, + @inject(v2CoreTokens.tableRepository) + tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableMapper) + tableMapper: TableMapperPort.ITableMapper + ) { + super(realtimeEngine, tableRepository, tableMapper); + } + + protected isSnapshotUsable(view: ITableViewPersistenceDTO, event: ViewShareEnabled): boolean { + return ( + view.enableShare === true && + view.shareId === event.shareId && + JSON.stringify(view.shareMeta) === JSON.stringify(event.shareMeta) + ); + } +} + +@ProjectionHandler(ViewShareDisabled) +@injectable() +export class ViewShareDisabledRealtimeProjection extends ViewShareStateRealtimeProjection { + constructor( + @inject(v2CoreTokens.realtimeEngine) + realtimeEngine: RealtimeEnginePort.IRealtimeEngine, + @inject(v2CoreTokens.tableRepository) + tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableMapper) + tableMapper: TableMapperPort.ITableMapper + ) { + super(realtimeEngine, tableRepository, tableMapper); + } + + protected isSnapshotUsable(view: ITableViewPersistenceDTO, event: ViewShareDisabled): boolean { + return ( + view.enableShare === false && + view.shareId === event.previousShareId && + JSON.stringify(view.shareMeta) === JSON.stringify(event.shareMeta) + ); + } +} diff --git a/packages/v2/core/src/application/projections/ViewSortUpdatedRealtimeProjection.ts b/packages/v2/core/src/application/projections/ViewSortUpdatedRealtimeProjection.ts new file mode 100644 index 0000000000..20f95547d8 --- /dev/null +++ b/packages/v2/core/src/application/projections/ViewSortUpdatedRealtimeProjection.ts @@ -0,0 +1,106 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry, type Result } from 'neverthrow'; + +import type { DomainError } from '../../domain/shared/DomainError'; +import { ViewSortUpdated } from '../../domain/table/events/ViewSortUpdated'; +import type { ViewSortDTO } from '../../domain/table/views/ViewSort'; +import type { IEventDispatchScope, IEventHandler } from '../../ports/EventHandler'; +import type * as ExecutionContextPort from '../../ports/ExecutionContext'; +import * as TableMapperPort from '../../ports/mappers/TableMapper'; +import { RealtimeDocId } from '../../ports/RealtimeDocId'; +import * as RealtimeEnginePort from '../../ports/RealtimeEngine'; +import * as TableRepositoryPort from '../../ports/TableRepository'; +import { v2CoreTokens } from '../../ports/tokens'; +import { ProjectionHandler } from './Projection'; +import { loadRealtimeTableSnapshot } from './RealtimeTableSnapshotCache'; +import { + getRealtimeProjectionScope, + scheduleRealtimeProjection, +} from './scheduleRealtimeProjection'; + +const tableCollectionPrefix = 'tbl'; +const viewCollectionPrefix = 'viw'; + +const querySortMatches = ( + query: { sort?: unknown; manualSort?: unknown } | undefined, + sort: ViewSortDTO +): boolean => { + const projected = + query?.sort === undefined && query?.manualSort === undefined + ? null + : { + sortObjs: query?.sort ?? [], + ...(query?.manualSort !== undefined ? { manualSort: query.manualSort } : {}), + }; + return JSON.stringify(projected) === JSON.stringify(sort); +}; + +@ProjectionHandler(ViewSortUpdated) +@injectable() +export class ViewSortUpdatedRealtimeProjection implements IEventHandler { + constructor( + @inject(v2CoreTokens.realtimeEngine) + private readonly realtimeEngine: RealtimeEnginePort.IRealtimeEngine, + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableMapper) + private readonly tableMapper: TableMapperPort.ITableMapper + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + event: ViewSortUpdated, + dispatchScope?: IEventDispatchScope + ): Promise> { + const { realtimeEngine, tableRepository, tableMapper } = this; + return scheduleRealtimeProjection( + context, + ViewSortUpdatedRealtimeProjection.name, + (context, scope) => + safeTry(async function* () { + const snapshot = yield* ( + await loadRealtimeTableSnapshot(context, { + baseId: event.baseId, + tableId: event.tableId, + tableRepository, + tableMapper, + tableSnapshotCache: scope.tableSnapshotCache, + isSnapshotUsable: (candidate) => { + const view = candidate.views.find( + (candidateView) => candidateView.id === event.viewId.toString() + ); + return querySortMatches(view?.query, event.nextSort); + }, + }) + ).safeUnwrap(); + const viewIndex = snapshot.views.findIndex((view) => view.id === event.viewId.toString()); + if (viewIndex === -1) return ok(undefined); + const viewDto = snapshot.views[viewIndex]; + + const tableDocId = yield* RealtimeDocId.fromParts( + `${tableCollectionPrefix}_${event.baseId.toString()}`, + event.tableId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, tableDocId, snapshot)).safeUnwrap(); + yield* ( + await realtimeEngine.applyChange(context, tableDocId, [ + { type: 'set', path: ['views', viewIndex, 'query'], value: viewDto.query }, + ]) + ).safeUnwrap(); + + const viewDocId = yield* RealtimeDocId.fromParts( + `${viewCollectionPrefix}_${event.tableId.toString()}`, + event.viewId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, viewDocId, viewDto)).safeUnwrap(); + return realtimeEngine.applyChange( + context, + viewDocId, + [{ type: 'set', path: ['query'], value: viewDto.query }], + { version: event.oldVersion } + ); + }), + getRealtimeProjectionScope(dispatchScope) + ); + } +} diff --git a/packages/v2/core/src/application/services/ARCHITECTURE.md b/packages/v2/core/src/application/services/ARCHITECTURE.md index b547c6d32b..9abb30a708 100644 --- a/packages/v2/core/src/application/services/ARCHITECTURE.md +++ b/packages/v2/core/src/application/services/ARCHITECTURE.md @@ -28,3 +28,7 @@ Declaration: If the folder I belong to changes, please update me, especially cor - `TableQueryService.ts` - Role: application service; Purpose: common table lookup operations (getById, getByIdInBase, exists) used across CommandHandlers and QueryHandlers. - `TableUpdateFlow.ts` - Role: application service; Purpose: shared table update workflow (mutate + persist + publish). +- `ViewPluginCreationService.ts` - Role: application service; Purpose: resolve external Plugin + definitions and prepare/install Plugin View integrations around aggregate creation. +- `ViewManualSortService.ts` - Role: application service; Purpose: execute aggregate-declared + row-order schema preparation and stream stable row-order updates through record repositories. diff --git a/packages/v2/core/src/application/services/DeleteByRangeApplicationService.ts b/packages/v2/core/src/application/services/DeleteByRangeApplicationService.ts index d6bf91c77c..76452294f1 100644 --- a/packages/v2/core/src/application/services/DeleteByRangeApplicationService.ts +++ b/packages/v2/core/src/application/services/DeleteByRangeApplicationService.ts @@ -12,7 +12,12 @@ import { } from '../../commands/shared/orderBy'; import { ensureRecordIdsWithinScope } from '../../commands/shared/recordWriteScope'; import { resolveDeleteStreamBatchSize } from '../../commands/shared/streamBatchSize'; -import { domainError, isNotFoundError, type DomainError } from '../../domain/shared/DomainError'; +import { + domainError, + isNotFoundError, + type DomainError, + type IDomainErrorLocalization, +} from '../../domain/shared/DomainError'; import type { IDomainEvent } from '../../domain/shared/DomainEvent'; import { generateUuid } from '../../domain/shared/IdGenerator'; import { OffsetPagination } from '../../domain/shared/pagination/OffsetPagination'; @@ -36,12 +41,11 @@ import * as EventBusPort from '../../ports/EventBus'; import type { IExecutionContext } from '../../ports/ExecutionContext'; import { AsyncIterableQueue } from '../../ports/memory/AsyncIterableQueue'; import { RecordWriteOperationKind } from '../../ports/RecordWritePlugin'; -import type { - ITableRecordQueryRepository, - TableRecordOrderBy, -} from '../../ports/TableRecordQueryRepository'; +import { ITableRecordQueryRepository } from '../../ports/TableRecordQueryRepository'; +import type { TableRecordOrderBy } from '../../ports/TableRecordQueryRepository'; import type { TableRecordReadModel } from '../../ports/TableRecordReadModel'; -import type { DeleteManyResult, ITableRecordRepository } from '../../ports/TableRecordRepository'; +import { ITableRecordRepository } from '../../ports/TableRecordRepository'; +import type { DeleteManyResult } from '../../ports/TableRecordRepository'; import { v2CoreTokens } from '../../ports/tokens'; import type { SpanAttributes } from '../../ports/Tracer'; import * as UnitOfWorkPort from '../../ports/UnitOfWork'; @@ -168,6 +172,7 @@ export interface DeleteByRangeStreamErrorEvent { recordIds: string[]; message: string; code?: string; + localization?: IDomainErrorLocalization; } export type DeleteByRangeStreamEvent = @@ -1713,13 +1718,14 @@ export class DeleteByRangeApplicationService { private createErrorEvent( error: DomainError, - details: Omit + details: Omit ): DeleteByRangeStreamErrorEvent { return { id: 'error', ...details, code: error.code, message: error.message, + ...(error.localization && { localization: error.localization }), }; } diff --git a/packages/v2/core/src/application/services/DuplicateRecordsApplicationService.ts b/packages/v2/core/src/application/services/DuplicateRecordsApplicationService.ts index 174aa373e4..d642bde8b6 100644 --- a/packages/v2/core/src/application/services/DuplicateRecordsApplicationService.ts +++ b/packages/v2/core/src/application/services/DuplicateRecordsApplicationService.ts @@ -12,7 +12,11 @@ import { resolveOrderBy, } from '../../commands/shared/orderBy'; import { resolveSelectionStreamBatchSize } from '../../commands/shared/streamBatchSize'; -import { domainError, type DomainError } from '../../domain/shared/DomainError'; +import { + domainError, + type DomainError, + type IDomainErrorLocalization, +} from '../../domain/shared/DomainError'; import type { IDomainEvent } from '../../domain/shared/DomainEvent'; import { generateUuid } from '../../domain/shared/IdGenerator'; import { OffsetPagination } from '../../domain/shared/pagination/OffsetPagination'; @@ -150,6 +154,7 @@ export interface DuplicateRecordsStreamErrorEvent { recordIds: string[]; message: string; code?: string; + localization?: IDomainErrorLocalization; } export type DuplicateRecordsStreamEvent = @@ -1250,13 +1255,14 @@ export class DuplicateRecordsApplicationService { private createErrorEvent( error: DomainError, - details: Omit + details: Omit ): DuplicateRecordsStreamErrorEvent { return { id: 'error', ...details, code: error.code, message: error.message, + ...(error.localization && { localization: error.localization }), }; } } diff --git a/packages/v2/core/src/application/services/ForeignTableLoaderService.ts b/packages/v2/core/src/application/services/ForeignTableLoaderService.ts index b40ce757ad..8b95de71ea 100644 --- a/packages/v2/core/src/application/services/ForeignTableLoaderService.ts +++ b/packages/v2/core/src/application/services/ForeignTableLoaderService.ts @@ -11,6 +11,7 @@ import type { ICellValueSpecVisitor, } from '../../domain/table/records/specs/values/ICellValueSpecVisitor'; import type { SetAttachmentValueSpec } from '../../domain/table/records/specs/values/SetAttachmentValueSpec'; +import type { SetButtonValueSpec } from '../../domain/table/records/specs/values/SetButtonValueSpec'; import type { SetCheckboxValueSpec } from '../../domain/table/records/specs/values/SetCheckboxValueSpec'; import type { SetDateValueSpec } from '../../domain/table/records/specs/values/SetDateValueSpec'; import type { SetLinkValueByTitleSpec } from '../../domain/table/records/specs/values/SetLinkValueByTitleSpec'; @@ -112,6 +113,9 @@ class MissingLinkTitleForeignTableCollector implements ICellValueSpecVisitor { visitSetAttachmentValue(_spec: SetAttachmentValueSpec): Result { return ok(undefined); } + visitSetButtonValue(_spec: SetButtonValueSpec): Result { + return ok(undefined); + } visitSetUserValue(_spec: SetUserValueSpec): Result { return ok(undefined); diff --git a/packages/v2/core/src/application/services/LinkTitleResolverService.ts b/packages/v2/core/src/application/services/LinkTitleResolverService.ts index ff92243ff6..e978af9cda 100644 --- a/packages/v2/core/src/application/services/LinkTitleResolverService.ts +++ b/packages/v2/core/src/application/services/LinkTitleResolverService.ts @@ -19,6 +19,7 @@ import type { ICellValueSpecVisitor, } from '../../domain/table/records/specs/values/ICellValueSpecVisitor'; import type { SetAttachmentValueSpec } from '../../domain/table/records/specs/values/SetAttachmentValueSpec'; +import type { SetButtonValueSpec } from '../../domain/table/records/specs/values/SetButtonValueSpec'; import type { SetCheckboxValueSpec } from '../../domain/table/records/specs/values/SetCheckboxValueSpec'; import type { SetDateValueSpec } from '../../domain/table/records/specs/values/SetDateValueSpec'; import { SetLinkValueByTitleSpec } from '../../domain/table/records/specs/values/SetLinkValueByTitleSpec'; @@ -99,6 +100,9 @@ class LinkTitleCollectorVisitor implements ICellValueSpecVisitor { visitSetAttachmentValue(_spec: SetAttachmentValueSpec): Result { return ok(undefined); } + visitSetButtonValue(_spec: SetButtonValueSpec): Result { + return ok(undefined); + } visitSetUserValue(_spec: SetUserValueSpec): Result { return ok(undefined); diff --git a/packages/v2/core/src/application/services/RecordMutationSpecResolverService.ts b/packages/v2/core/src/application/services/RecordMutationSpecResolverService.ts index 62214efb2e..2d98ba2284 100644 --- a/packages/v2/core/src/application/services/RecordMutationSpecResolverService.ts +++ b/packages/v2/core/src/application/services/RecordMutationSpecResolverService.ts @@ -9,6 +9,7 @@ import type { ICellValueSpecVisitor, } from '../../domain/table/records/specs/values/ICellValueSpecVisitor'; import { SetAttachmentValueSpec } from '../../domain/table/records/specs/values/SetAttachmentValueSpec'; +import type { SetButtonValueSpec } from '../../domain/table/records/specs/values/SetButtonValueSpec'; import { SetLinkValueByTitleSpec } from '../../domain/table/records/specs/values/SetLinkValueByTitleSpec'; import type { SetLinkValueSpec } from '../../domain/table/records/specs/values/SetLinkValueSpec'; import type { SetRowOrderValueSpec } from '../../domain/table/records/specs/values/SetRowOrderValueSpec'; @@ -76,6 +77,9 @@ class SpecResolutionCollector implements ICellValueSpecVisitor { this.addSpec(spec); return ok(undefined); } + visitSetButtonValue(_spec: SetButtonValueSpec): Result { + return ok(undefined); + } visitSetUserValue(spec: SetUserValueSpec): Result { this.addSpec(spec); return ok(undefined); diff --git a/packages/v2/core/src/application/services/RecordQueryPluginRunner.spec.ts b/packages/v2/core/src/application/services/RecordQueryPluginRunner.spec.ts new file mode 100644 index 0000000000..3acb4363da --- /dev/null +++ b/packages/v2/core/src/application/services/RecordQueryPluginRunner.spec.ts @@ -0,0 +1,443 @@ +import { err, ok } from 'neverthrow'; +import { describe, expect, it } from 'vitest'; + +import { ActorId } from '../../domain/shared/ActorId'; +import { domainError } from '../../domain/shared/DomainError'; +import type { ISpecification } from '../../domain/shared/specification/ISpecification'; +import type { ITableRecordConditionSpecVisitor } from '../../domain/table/records/specs/ITableRecordConditionSpecVisitor'; +import type { TableRecord } from '../../domain/table/records/TableRecord'; +import type { Table } from '../../domain/table/Table'; +import type { IExecutionContext } from '../../ports/ExecutionContext'; +import type { ILogger, LogContext } from '../../ports/Logger'; +import { DefaultTableMapper } from '../../ports/mappers/defaults/DefaultTableMapper'; +import { + RecordQueryOperationKind, + type IRecordQueryPlugin, + type RecordQueryPluginContextMap, + type RecordQueryPluginScope, +} from '../../ports/RecordQueryPlugin'; +import { RecordQueryPluginRunner } from './RecordQueryPluginRunner'; + +const tableMapper = new DefaultTableMapper(); + +const createTable = (tableId = 'tblTraceRecordQuery'): Table => + ({ + id: () => ({ + toString: () => tableId, + }), + clone: () => ok(createTable(tableId)), + }) as unknown as Table; + +const createListContext = (): RecordQueryPluginContextMap['list'] => ({ + kind: RecordQueryOperationKind.list, + executionContext: { + actorId: ActorId.create('system')._unsafeUnwrap(), + } as IExecutionContext, + table: createTable(), + payload: { + limit: 100, + offset: 0, + }, +}); + +class FakeLogger implements ILogger { + child(): ILogger { + return this; + } + + scope(): ILogger { + return this; + } + + debug(): void { + return undefined; + } + + info(): void { + return undefined; + } + + warn(): void { + return undefined; + } + + error(_message: string, _context?: LogContext): void { + return undefined; + } +} + +const createFakeSpec = ( + label: string +): ISpecification => + ({ + label, + isSatisfiedBy: () => true, + mutate: () => { + throw new Error('not implemented'); + }, + accept: () => { + throw new Error('not implemented'); + }, + }) as unknown as ISpecification; + +describe('RecordQueryPluginRunner', () => { + it('returns undefined scope when no plugins are registered', async () => { + const runner = new RecordQueryPluginRunner([], new FakeLogger(), tableMapper); + const execution = (await runner.prepare(createListContext()))._unsafeUnwrap(); + + await expect(execution.guard()).resolves.toEqual(ok(undefined)); + expect(execution.getScope()).toEqual(ok(undefined)); + }); + + it('merges recordSpec with AND and intersects readableFieldIds', async () => { + const specA = createFakeSpec('a'); + const specB = createFakeSpec('b'); + + const pluginA: IRecordQueryPlugin = { + name: 'pluginA', + enforce: 'pre', + supports: () => true, + scope: () => + ok({ + recordSpec: specA, + readableFieldIds: new Set(['fldA', 'fldB', 'fldC']), + } satisfies RecordQueryPluginScope), + }; + + const pluginB: IRecordQueryPlugin = { + name: 'pluginB', + supports: () => true, + scope: () => + ok({ + recordSpec: specB, + readableFieldIds: new Set(['fldB', 'fldC', 'fldD']), + } satisfies RecordQueryPluginScope), + }; + + const runner = new RecordQueryPluginRunner([pluginA, pluginB], new FakeLogger(), tableMapper); + const execution = (await runner.prepare(createListContext()))._unsafeUnwrap(); + const scope = execution.getScope()._unsafeUnwrap(); + + expect(scope?.readableFieldIds).toEqual(new Set(['fldB', 'fldC'])); + expect(scope?.recordSpec).toBeDefined(); + // AndSpec composition: not the same object as a single input + expect(scope?.recordSpec).not.toBe(specA); + expect(scope?.recordSpec).not.toBe(specB); + }); + + it('fails closed when any plugin guard returns an error', async () => { + const allowPlugin: IRecordQueryPlugin = { + name: 'allow', + supports: () => true, + guard: () => ok(undefined), + }; + const denyPlugin: IRecordQueryPlugin = { + name: 'deny', + enforce: 'pre', + supports: () => true, + guard: () => + err( + domainError.forbidden({ + code: 'record.query.denied', + message: 'No read access', + }) + ), + }; + + const runner = new RecordQueryPluginRunner( + [allowPlugin, denyPlugin], + new FakeLogger(), + tableMapper + ); + const execution = (await runner.prepare(createListContext()))._unsafeUnwrap(); + const guardResult = await execution.guard(); + + expect(guardResult.isErr()).toBe(true); + if (guardResult.isErr()) { + expect(guardResult.error.message).toContain('No read access'); + } + }); + + it('skips plugins by name via runner options', async () => { + const skipped: IRecordQueryPlugin = { + name: 'skipped', + supports: () => true, + scope: () => + ok({ + readableFieldIds: new Set(['fldOnlySkipped']), + }), + }; + const kept: IRecordQueryPlugin = { + name: 'kept', + supports: () => true, + scope: () => + ok({ + readableFieldIds: new Set(['fldKept']), + }), + }; + + const runner = new RecordQueryPluginRunner([skipped, kept], new FakeLogger(), tableMapper); + const execution = ( + await runner.prepare(createListContext(), { + runnerOptions: { skipPluginNames: new Set(['skipped']) }, + }) + )._unsafeUnwrap(); + + expect(execution.getScope()._unsafeUnwrap()?.readableFieldIds).toEqual(new Set(['fldKept'])); + }); + + it('merges fieldMasks for the same fieldId with AND visibility', async () => { + const maskA = createFakeSpec('maskA'); + const maskB = createFakeSpec('maskB'); + + const pluginA: IRecordQueryPlugin = { + name: 'a', + supports: () => true, + scope: () => + ok({ + fieldMasks: [{ fieldId: 'fldX', visibleWhen: maskA }], + }), + }; + const pluginB: IRecordQueryPlugin = { + name: 'b', + supports: () => true, + scope: () => + ok({ + fieldMasks: [{ fieldId: 'fldX', visibleWhen: maskB }], + }), + }; + + const runner = new RecordQueryPluginRunner([pluginA, pluginB], new FakeLogger(), tableMapper); + const scope = (await runner.prepare(createListContext())) + ._unsafeUnwrap() + .getScope() + ._unsafeUnwrap(); + + expect(scope?.fieldMasks).toHaveLength(1); + expect(scope?.fieldMasks?.[0]?.fieldId).toBe('fldX'); + expect(scope?.fieldMasks?.[0]?.visibleWhen).toBeDefined(); + expect(scope?.fieldMasks?.[0]?.visibleWhen).not.toBe(maskA); + }); + + it('keeps legacy permission compatibility only when every restricting plugin declares it', async () => { + const compatiblePlugin: IRecordQueryPlugin = { + name: 'compatible', + supports: () => true, + scope: () => + ok({ + readableFieldIds: new Set(['fldA']), + legacyPermissionQueryCompatible: true, + }), + }; + const incompatiblePlugin: IRecordQueryPlugin = { + name: 'incompatible', + supports: () => true, + scope: () => + ok({ + recordSpec: createFakeSpec('tenant'), + }), + }; + + const compatibleScope = ( + await new RecordQueryPluginRunner([compatiblePlugin], new FakeLogger(), tableMapper).prepare( + createListContext() + ) + ) + ._unsafeUnwrap() + .getScope() + ._unsafeUnwrap(); + const mixedScope = ( + await new RecordQueryPluginRunner( + [compatiblePlugin, incompatiblePlugin], + new FakeLogger(), + tableMapper + ).prepare(createListContext()) + ) + ._unsafeUnwrap() + .getScope() + ._unsafeUnwrap(); + + expect(compatibleScope?.legacyPermissionQueryCompatible).toBe(true); + expect(mixedScope?.legacyPermissionQueryCompatible).toBeUndefined(); + }); + + it('preserves empty readableFieldIds as deny-all fields (not unrestricted)', async () => { + const plugin: IRecordQueryPlugin = { + name: 'denyAllFields', + supports: () => true, + scope: () => + ok({ + readableFieldIds: new Set(), + }), + }; + + const runner = new RecordQueryPluginRunner([plugin], new FakeLogger(), tableMapper); + const scope = (await runner.prepare(createListContext())) + ._unsafeUnwrap() + .getScope() + ._unsafeUnwrap(); + + expect(scope?.readableFieldIds).toEqual(new Set()); + expect(scope?.readableFieldIds).not.toBeUndefined(); + }); + + it('intersects empty readableFieldIds with a non-empty allow-list to empty', async () => { + const empty: IRecordQueryPlugin = { + name: 'empty', + supports: () => true, + scope: () => ok({ readableFieldIds: new Set() }), + }; + const partial: IRecordQueryPlugin = { + name: 'partial', + supports: () => true, + scope: () => ok({ readableFieldIds: new Set(['fldA', 'fldB']) }), + }; + + const runner = new RecordQueryPluginRunner([empty, partial], new FakeLogger(), tableMapper); + const scope = (await runner.prepare(createListContext())) + ._unsafeUnwrap() + .getScope() + ._unsafeUnwrap(); + + expect(scope?.readableFieldIds).toEqual(new Set()); + }); + + it('applies forceReadableFieldIds within a single plugin before allow-list merge', async () => { + const plugin: IRecordQueryPlugin = { + name: 'scoped', + supports: () => true, + scope: () => + ok({ + readableFieldIds: new Set(['fldA']), + forceReadableFieldIds: new Set(['fldPrimary']), + }), + }; + + const runner = new RecordQueryPluginRunner([plugin], new FakeLogger(), tableMapper); + const scope = (await runner.prepare(createListContext())) + ._unsafeUnwrap() + .getScope() + ._unsafeUnwrap(); + + expect(scope?.readableFieldIds).toEqual(new Set(['fldA', 'fldPrimary'])); + expect(scope?.forceReadableFieldIds).toEqual(new Set(['fldPrimary'])); + }); + + it('does not let one plugin skipRecordSpec erase another plugin row filter', async () => { + const tenantSpec = createFakeSpec('tenant'); + const uxPlugin: IRecordQueryPlugin = { + name: 'uxKeepPrimary', + supports: () => true, + scope: () => + ok({ + skipRecordSpec: true, + forceReadableFieldIds: new Set(['fldPrimary']), + }), + }; + const tenantPlugin: IRecordQueryPlugin = { + name: 'tenantIsolation', + supports: () => true, + scope: () => + ok({ + recordSpec: tenantSpec, + readableFieldIds: new Set(['fldA', 'fldPrimary']), + }), + }; + + const runner = new RecordQueryPluginRunner( + [uxPlugin, tenantPlugin], + new FakeLogger(), + tableMapper + ); + const scope = (await runner.prepare(createListContext())) + ._unsafeUnwrap() + .getScope() + ._unsafeUnwrap(); + + expect(scope?.recordSpec).toBe(tenantSpec); + expect(scope?.skipRecordSpec).toBeUndefined(); + expect(scope?.readableFieldIds).toEqual(new Set(['fldA', 'fldPrimary'])); + }); + + it('does not let forceReadable re-open fields denied by another plugin', async () => { + const forcePlugin: IRecordQueryPlugin = { + name: 'forcePrimary', + supports: () => true, + scope: () => + ok({ + readableFieldIds: new Set(['fldSecret']), + forceReadableFieldIds: new Set(['fldSecret']), + }), + }; + const denyPlugin: IRecordQueryPlugin = { + name: 'denyAll', + supports: () => true, + scope: () => + ok({ + readableFieldIds: new Set(), + }), + }; + + const runner = new RecordQueryPluginRunner( + [forcePlugin, denyPlugin], + new FakeLogger(), + tableMapper + ); + const scope = (await runner.prepare(createListContext())) + ._unsafeUnwrap() + .getScope() + ._unsafeUnwrap(); + + expect(scope?.readableFieldIds).toEqual(new Set()); + }); + + it('clips fieldMasks to the effective readable allow-list', async () => { + const maskKeep = createFakeSpec('keep'); + const maskDrop = createFakeSpec('drop'); + const plugin: IRecordQueryPlugin = { + name: 'masked', + supports: () => true, + scope: () => + ok({ + readableFieldIds: new Set(['fldKeep']), + fieldMasks: [ + { fieldId: 'fldKeep', visibleWhen: maskKeep }, + { fieldId: 'fldDrop', visibleWhen: maskDrop }, + ], + }), + }; + + const runner = new RecordQueryPluginRunner([plugin], new FakeLogger(), tableMapper); + const scope = (await runner.prepare(createListContext())) + ._unsafeUnwrap() + .getScope() + ._unsafeUnwrap(); + + expect(scope?.fieldMasks?.map((mask) => mask.fieldId)).toEqual(['fldKeep']); + }); + + it('ignores plugins that do not support the operation kind', async () => { + const listOnly: IRecordQueryPlugin = { + name: 'listOnly', + supports: (kind) => kind === RecordQueryOperationKind.list, + scope: () => ok({ readableFieldIds: new Set(['fldList']) }), + }; + const getOneOnly: IRecordQueryPlugin = { + name: 'getOneOnly', + supports: (kind) => kind === RecordQueryOperationKind.getOne, + scope: () => ok({ readableFieldIds: new Set(['fldGetOne']) }), + }; + + const runner = new RecordQueryPluginRunner( + [listOnly, getOneOnly], + new FakeLogger(), + tableMapper + ); + const scope = (await runner.prepare(createListContext())) + ._unsafeUnwrap() + .getScope() + ._unsafeUnwrap(); + + expect(scope?.readableFieldIds).toEqual(new Set(['fldList'])); + }); +}); diff --git a/packages/v2/core/src/application/services/RecordQueryPluginRunner.ts b/packages/v2/core/src/application/services/RecordQueryPluginRunner.ts new file mode 100644 index 0000000000..7ff1d5d98d --- /dev/null +++ b/packages/v2/core/src/application/services/RecordQueryPluginRunner.ts @@ -0,0 +1,581 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../domain/shared/DomainError'; +import { composeAndSpecsOrUndefined } from '../../domain/shared/specification/composeAndSpecs'; +import type { ISpecification } from '../../domain/shared/specification/ISpecification'; +import type { ITableRecordConditionSpecVisitor } from '../../domain/table/records/specs/ITableRecordConditionSpecVisitor'; +import type { TableRecord } from '../../domain/table/records/TableRecord'; +import * as LoggerPort from '../../ports/Logger'; +import * as TableMapperPort from '../../ports/mappers/TableMapper'; +import type { + IRecordQueryPlugin, + RecordQueryFieldMask, + RecordQueryPluginContext, + RecordQueryPluginEnforce, + RecordQueryPluginRunnerOptions, + RecordQueryPluginScope, +} from '../../ports/RecordQueryPlugin'; +import { v2CoreTokens } from '../../ports/tokens'; +import { + createPluginTraceContext, + createTeableSpanAttributes, + TeableSpanAttributes, + type ISpan, + type SpanAttributes, +} from '../../ports/Tracer'; + +type PreparedPluginEntry = { + readonly plugin: IRecordQueryPlugin; + readonly preparedState: unknown; + readonly scope?: RecordQueryPluginScope; +}; + +type RecordQueryPluginContextSanitizer = ( + context: RecordQueryPluginContext +) => Result; + +type RecordQueryPluginPhase = 'supports' | 'prepare' | 'scope' | 'guard'; + +const enforceOrder = (enforce?: RecordQueryPluginEnforce): number => { + if (enforce === 'pre') return 0; + if (enforce === 'post') return 2; + return 1; +}; + +const createEnforceGroups = ( + items: ReadonlyArray, + getEnforce: (item: T) => RecordQueryPluginEnforce | undefined +): T[][] => { + const groups: [T[], T[], T[]] = [[], [], []]; + + for (const item of items) { + groups[enforceOrder(getEnforce(item))].push(item); + } + + return groups.filter((group) => group.length > 0); +}; + +const sanitizeRecordQueryPluginContext = ( + context: RecordQueryPluginContext, + tableMapper: TableMapperPort.ITableMapper +): Result => { + return context.table + .clone(tableMapper) + .map((table) => ({ ...context, table }) as RecordQueryPluginContext); +}; + +const getTableId = (table: RecordQueryPluginContext['table']): string | undefined => { + try { + return table.id().toString(); + } catch { + return undefined; + } +}; + +const describeError = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + try { + return JSON.stringify(error); + } catch { + return String(error); + } +}; + +const createRecordQueryPluginTraceAttributes = ( + context: RecordQueryPluginContext, + pluginName: string, + phase: RecordQueryPluginPhase +): SpanAttributes => { + const tableId = getTableId(context.table); + return createTeableSpanAttributes('plugin', 'recordQueryPlugin.execution', { + [TeableSpanAttributes.PLUGIN]: pluginName, + [TeableSpanAttributes.PLUGIN_TYPE]: 'record_query', + [TeableSpanAttributes.OPERATION_KIND]: context.kind, + [TeableSpanAttributes.OPERATION]: `recordQueryPlugin.${phase}`, + [TeableSpanAttributes.PLUGIN_PHASE]: phase, + ...(tableId ? { [TeableSpanAttributes.TABLE_ID]: tableId } : {}), + }); +}; + +const withRecordQueryPluginTraceContext = ( + context: RecordQueryPluginContext, + pluginName: string, + phase: Exclude, + activeSpan?: ISpan +): RecordQueryPluginContext => { + return { + ...context, + trace: createPluginTraceContext({ + tracer: context.executionContext.tracer, + activeSpan, + attributes: createRecordQueryPluginTraceAttributes(context, pluginName, phase), + spanNamePrefix: `teable.recordQueryPlugin.${pluginName}`, + operationPrefix: `recordQueryPlugin.${phase}`, + }), + }; +}; + +const withRecordQueryPluginSpan = async ( + context: RecordQueryPluginContext, + pluginName: string, + phase: Exclude, + callback: (context: RecordQueryPluginContext) => Promise +): Promise => { + const tracer = context.executionContext.tracer; + const span = tracer?.startSpan( + `teable.recordQueryPlugin.${phase}`, + createRecordQueryPluginTraceAttributes(context, pluginName, phase) + ); + const pluginContext = withRecordQueryPluginTraceContext(context, pluginName, phase, span); + + if (!span || !tracer) { + return callback(pluginContext); + } + + return tracer.withSpan(span, async () => { + try { + return await callback(pluginContext); + } catch (error) { + span.recordError(describeError(error)); + throw error; + } finally { + span.end(); + } + }); +}; + +const withRecordQueryPluginExecutionSpan = async ( + context: RecordQueryPluginContext, + pluginName: string, + callback: () => Promise +): Promise => { + const tracer = context.executionContext.tracer; + const span = tracer?.startSpan( + 'teable.recordQueryPlugin.execution', + createTeableSpanAttributes('plugin', 'recordQueryPlugin.execution', { + ...createRecordQueryPluginTraceAttributes(context, pluginName, 'prepare'), + }) + ); + + if (!span || !tracer) { + return callback(); + } + + return tracer.withSpan(span, async () => { + try { + return await callback(); + } catch (error) { + span.recordError(describeError(error)); + throw error; + } finally { + span.end(); + } + }); +}; + +const intersectDefinedSets = ( + sets: ReadonlyArray | undefined> +): ReadonlySet | undefined => { + const definedSets = sets.filter((set): set is ReadonlySet => set != null); + if (!definedSets.length) { + return undefined; + } + + const [firstSet, ...restSets] = definedSets; + return new Set([...firstSet].filter((value) => restSets.every((set) => set.has(value)))); +}; + +const unionDefinedSets = ( + sets: ReadonlyArray | undefined> +): ReadonlySet | undefined => { + const definedSets = sets.filter((set): set is ReadonlySet => set != null); + if (!definedSets.length) { + return undefined; + } + const merged = new Set(); + for (const set of definedSets) { + for (const value of set) { + merged.add(value); + } + } + return merged; +}; + +/** + * Merge field masks by fieldId. When multiple plugins mask the same field, + * visibleWhen is AND-ed (stricter visibility). + */ +const mergeFieldMasks = ( + maskLists: ReadonlyArray | undefined> +): ReadonlyArray | undefined => { + const byFieldId = new Map< + string, + ISpecification[] + >(); + + for (const masks of maskLists) { + if (!masks?.length) { + continue; + } + for (const mask of masks) { + const existing = byFieldId.get(mask.fieldId) ?? []; + existing.push(mask.visibleWhen); + byFieldId.set(mask.fieldId, existing); + } + } + + if (!byFieldId.size) { + return undefined; + } + + const merged: RecordQueryFieldMask[] = []; + for (const [fieldId, specs] of byFieldId) { + const visibleWhen = composeAndSpecsOrUndefined(specs); + if (!visibleWhen) { + continue; + } + merged.push({ fieldId, visibleWhen }); + } + + return merged.length ? merged : undefined; +}; + +export class RecordQueryPluginExecution { + constructor( + private readonly context: RecordQueryPluginContext, + private readonly preparedPlugins: ReadonlyArray, + private readonly sanitizeContext: RecordQueryPluginContextSanitizer + ) {} + + async guard(): Promise> { + for (const group of createEnforceGroups( + this.preparedPlugins, + (entry) => entry.plugin.enforce + )) { + const results = await Promise.all( + group.map((entry) => this.invokeGuard(this.context, entry)) + ); + + for (const result of results) { + if (result.isErr()) { + return err(result.error); + } + } + } + + return ok(undefined); + } + + getScope(): Result { + // Monotonic merge: resolve skip/force inside each plugin first, then + // AND recordSpecs and INTERSECT field allow-lists. A UX plugin that + // skipRecordSpecs or forceReadable must not erase another plugin's + // tenant isolation or deny-all allow-list. + const perPluginRecordSpecs: Array< + ISpecification + > = []; + const perPluginReadable: Array | undefined> = []; + const perPluginMasks: Array | undefined> = []; + const perPluginForce: Array | undefined> = []; + let anySkipRecordSpec = false; + let hasRestrictingScope = false; + let allRestrictingScopesLegacyCompatible = true; + + for (const entry of this.preparedPlugins) { + const scope = entry.scope; + if (!scope) { + continue; + } + + const restrictsAccess = Boolean( + scope.recordSpec || + scope.skipRecordSpec || + scope.readableFieldIds != null || + scope.fieldMasks?.length + ); + if (restrictsAccess) { + hasRestrictingScope = true; + allRestrictingScopesLegacyCompatible &&= scope.legacyPermissionQueryCompatible === true; + } + + // skipRecordSpec only drops THIS plugin's row filter contribution. + if (scope.skipRecordSpec) { + anySkipRecordSpec = true; + } else if (scope.recordSpec) { + perPluginRecordSpecs.push(scope.recordSpec); + } + + // forceReadable is applied only within this plugin's allow-list before + // cross-plugin intersection — it cannot re-open fields another plugin denied. + let localReadable = scope.readableFieldIds; + if (localReadable != null && scope.forceReadableFieldIds?.size) { + localReadable = new Set([...localReadable, ...scope.forceReadableFieldIds]); + } + perPluginReadable.push(localReadable); + perPluginForce.push(scope.forceReadableFieldIds); + perPluginMasks.push(scope.fieldMasks); + } + + const recordSpec = composeAndSpecsOrUndefined(perPluginRecordSpecs); + const readableFieldIds = intersectDefinedSets(perPluginReadable); + // Exported for observability / single-plugin keepPrimary consumers only. + // Not re-unioned into readableFieldIds after intersection. + const forceReadableFieldIds = unionDefinedSets(perPluginForce); + // All contributing plugins skipped their row filter AND no remaining specs. + const skipRecordSpec = anySkipRecordSpec && recordSpec == null; + const legacyPermissionQueryCompatible = + hasRestrictingScope && allRestrictingScopesLegacyCompatible; + let fieldMasks = mergeFieldMasks(perPluginMasks); + + // Clip masks to the effective allow-list when present. + if (fieldMasks && readableFieldIds != null) { + fieldMasks = fieldMasks.filter((mask) => readableFieldIds.has(mask.fieldId)); + if (!fieldMasks.length) { + fieldMasks = undefined; + } + } + + if (!recordSpec && !skipRecordSpec && readableFieldIds == null && !fieldMasks?.length) { + return ok(undefined); + } + + return ok({ + ...(recordSpec ? { recordSpec } : {}), + ...(skipRecordSpec ? { skipRecordSpec: true } : {}), + ...(readableFieldIds != null ? { readableFieldIds } : {}), + ...(forceReadableFieldIds ? { forceReadableFieldIds } : {}), + ...(fieldMasks?.length ? { fieldMasks } : {}), + ...(legacyPermissionQueryCompatible ? { legacyPermissionQueryCompatible: true } : {}), + }); + } + + getRecordSpec(): Result< + ISpecification | undefined, + DomainError + > { + return this.getScope().map((scope) => scope?.recordSpec); + } + + getReadableFieldIds(): Result | undefined, DomainError> { + return this.getScope().map((scope) => scope?.readableFieldIds); + } + + getPreparedStateFor(plugin: IRecordQueryPlugin): unknown { + return this.preparedPlugins.find((entry) => entry.plugin === plugin)?.preparedState; + } + + private async invokeGuard( + context: RecordQueryPluginContext, + entry: PreparedPluginEntry + ): Promise> { + const plugin = entry.plugin; + if (!plugin.guard) { + return ok(undefined); + } + + const pluginContextResult = this.sanitizeContext(context); + if (pluginContextResult.isErr()) { + return err(pluginContextResult.error); + } + + try { + const result = await withRecordQueryPluginExecutionSpan( + pluginContextResult.value, + plugin.name, + async () => + withRecordQueryPluginSpan( + pluginContextResult.value, + plugin.name, + 'guard', + async (pluginContext) => plugin.guard!.call(plugin, pluginContext, entry.preparedState) + ) + ); + if (result.isErr()) { + return err(result.error); + } + return ok(undefined); + } catch (error) { + return err( + domainError.fromUnknown(error, { + code: 'record_query_plugin.guard_failed', + details: { + operation: context.kind, + plugin: plugin.name, + }, + }) + ); + } + } +} + +@injectable() +export class RecordQueryPluginRunner { + constructor( + @inject(v2CoreTokens.recordQueryPlugins) + private readonly plugins: IRecordQueryPlugin[], + @inject(v2CoreTokens.logger) + private readonly logger: LoggerPort.ILogger, + @inject(v2CoreTokens.tableMapper) + private readonly tableMapper: TableMapperPort.ITableMapper + ) {} + + async prepare( + context: RecordQueryPluginContext, + options?: { + previousExecution?: RecordQueryPluginExecution; + runnerOptions?: RecordQueryPluginRunnerOptions; + } + ): Promise> { + const preparedPlugins: PreparedPluginEntry[] = []; + const matchedPluginsResult = this.resolvePlugins(context, options?.runnerOptions); + if (matchedPluginsResult.isErr()) { + return err(matchedPluginsResult.error); + } + const matchedPlugins = matchedPluginsResult.value; + + for (const group of createEnforceGroups(matchedPlugins, (plugin) => plugin.enforce)) { + const results = await Promise.all( + group.map((plugin) => + this.preparePlugin( + plugin, + context, + options?.previousExecution?.getPreparedStateFor(plugin) + ) + ) + ); + + for (const result of results) { + if (result.isErr()) { + return err(result.error); + } + preparedPlugins.push(result.value); + } + } + + this.logger.debug('Record query plugins prepared', { + operation: context.kind, + pluginCount: preparedPlugins.length, + }); + + return ok( + new RecordQueryPluginExecution(context, preparedPlugins, (pluginContext) => + sanitizeRecordQueryPluginContext(pluginContext, this.tableMapper) + ) + ); + } + + private async preparePlugin( + plugin: IRecordQueryPlugin, + context: RecordQueryPluginContext, + previousPreparedState?: unknown + ): Promise> { + const pluginContextResult = sanitizeRecordQueryPluginContext(context, this.tableMapper); + if (pluginContextResult.isErr()) { + return err(pluginContextResult.error); + } + + const pluginContext = pluginContextResult.value; + let preparedState: unknown = undefined; + + if (plugin.prepare) { + try { + const result = await withRecordQueryPluginExecutionSpan( + pluginContext, + plugin.name, + async () => + withRecordQueryPluginSpan( + pluginContext, + plugin.name, + 'prepare', + async (preparedContext) => + plugin.prepare!.call(plugin, preparedContext, previousPreparedState) + ) + ); + if (result.isErr()) { + return err(result.error); + } + preparedState = result.value; + } catch (error) { + return err( + domainError.fromUnknown(error, { + code: 'record_query_plugin.prepare_failed', + details: { + operation: context.kind, + plugin: plugin.name, + }, + }) + ); + } + } + + let scope: RecordQueryPluginScope | undefined; + if (plugin.scope) { + try { + const result = await withRecordQueryPluginExecutionSpan( + pluginContext, + plugin.name, + async () => + withRecordQueryPluginSpan(pluginContext, plugin.name, 'scope', async (scopeContext) => + plugin.scope!.call(plugin, scopeContext, preparedState) + ) + ); + if (result.isErr()) { + return err(result.error); + } + scope = result.value; + } catch (error) { + return err( + domainError.fromUnknown(error, { + code: 'record_query_plugin.scope_failed', + details: { + operation: context.kind, + plugin: plugin.name, + }, + }) + ); + } + } + + return ok({ plugin, preparedState, scope }); + } + + private resolvePlugins( + context: RecordQueryPluginContext, + options?: RecordQueryPluginRunnerOptions + ): Result, DomainError> { + const matchedPlugins: IRecordQueryPlugin[] = []; + + for (const plugin of this.plugins) { + if (options?.skipPluginNames?.has(plugin.name)) { + continue; + } + + try { + if (plugin.supports(context.kind)) { + matchedPlugins.push(plugin); + } + } catch (error) { + return err( + domainError.fromUnknown(error, { + code: 'record_query_plugin.supports_failed', + details: { + operation: context.kind, + plugin: plugin.name, + }, + }) + ); + } + } + + return ok( + matchedPlugins.sort((left, right) => enforceOrder(left.enforce) - enforceOrder(right.enforce)) + ); + } +} diff --git a/packages/v2/core/src/application/services/TableDataSafetyLimitFieldOperationPlugin.ts b/packages/v2/core/src/application/services/TableDataSafetyLimitFieldOperationPlugin.ts index b40c8c585e..047636f92f 100644 --- a/packages/v2/core/src/application/services/TableDataSafetyLimitFieldOperationPlugin.ts +++ b/packages/v2/core/src/application/services/TableDataSafetyLimitFieldOperationPlugin.ts @@ -6,6 +6,7 @@ import type { IDomainContext } from '../../domain/shared/DomainContext'; import type { DomainError } from '../../domain/shared/DomainError'; import { ensureWithinTableDataSafetyLimit, + tableDataSafetyLimitErrors, measureJsonBytes, resolveTableDataSafetyLimits, type ResolvedTableDataSafetyLimitConfig, @@ -19,10 +20,8 @@ import { type FieldOperationPluginContext, type IFieldOperationPlugin, } from '../../ports/FieldOperationPlugin'; -import { - createDefaultTableDataSafetyLimitComposer, - TableDataSafetyLimitComposer, -} from './TableDataSafetyLimitComposer'; +import type { TableDataSafetyLimitComposer } from './TableDataSafetyLimitComposer'; +import { createDefaultTableDataSafetyLimitComposer } from './TableDataSafetyLimitComposer'; type PreparedTableDataSafetyFieldLimitState = { readonly domainContext: IDomainContext | undefined; @@ -112,7 +111,7 @@ const ensureDisplayText = ( limits: ResolvedTableDataSafetyLimitConfig ): Result => { const nameResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.name_max_length', + tableDataSafetyLimitErrors.nameMaxLength, field.name().toString().length, limits.displayText.maxNameLength, { @@ -125,7 +124,7 @@ const ensureDisplayText = ( const description = fieldDescription(field); if (description == null) return ok(undefined); return ensureWithinTableDataSafetyLimit( - 'validation.limit.description_max_length', + tableDataSafetyLimitErrors.descriptionMaxLength, description.length, limits.displayText.maxDescriptionLength, { @@ -141,7 +140,7 @@ const ensureSelectFieldLimits = ( ): Result => { const options = selectOptions(field); const optionsBytesResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.field_options_max_bytes', + tableDataSafetyLimitErrors.fieldOptionsMaxBytes, measureJsonBytes(options), limits.fieldOptions.maxBytes, { @@ -152,7 +151,7 @@ const ensureSelectFieldLimits = ( if (optionsBytesResult.isErr()) return optionsBytesResult; const choiceCountResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.select_choices_max', + tableDataSafetyLimitErrors.selectChoicesMax, options.length, limits.fieldOptions.maxSelectChoices, { @@ -164,7 +163,7 @@ const ensureSelectFieldLimits = ( for (const option of options) { const nameResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.select_choice_name_max_length', + tableDataSafetyLimitErrors.selectChoiceNameMaxLength, option.name.length, limits.fieldOptions.maxSelectChoiceNameLength, { @@ -177,7 +176,7 @@ const ensureSelectFieldLimits = ( } return ensureWithinTableDataSafetyLimit( - 'validation.limit.select_default_values_max', + tableDataSafetyLimitErrors.selectDefaultValuesMax, selectDefaultValues(field).length, limits.fieldOptions.maxSelectDefaultValues, { @@ -192,7 +191,7 @@ const ensureFormulaLength = ( limits: ResolvedTableDataSafetyLimitConfig ): Result => { return ensureWithinTableDataSafetyLimit( - 'validation.limit.formula_max_length', + tableDataSafetyLimitErrors.formulaMaxLength, formulaExpression(field).length, limits.computed.maxFormulaLength, { @@ -225,7 +224,7 @@ const ensureRawUpdateLimits = ( ): Result => { if (fieldUpdate.name != null) { const nameResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.name_max_length', + tableDataSafetyLimitErrors.nameMaxLength, fieldUpdate.name.length, limits.displayText.maxNameLength, { target: 'field.name' } @@ -234,7 +233,7 @@ const ensureRawUpdateLimits = ( } if (fieldUpdate.description != null) { const descriptionResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.description_max_length', + tableDataSafetyLimitErrors.descriptionMaxLength, fieldUpdate.description.length, limits.displayText.maxDescriptionLength, { target: 'field.description' } @@ -243,7 +242,7 @@ const ensureRawUpdateLimits = ( } if (fieldUpdate.options != null) { const optionsBytesResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.field_options_max_bytes', + tableDataSafetyLimitErrors.fieldOptionsMaxBytes, measureJsonBytes(fieldUpdate.options), limits.fieldOptions.maxBytes, { target: 'field.options' } @@ -252,7 +251,7 @@ const ensureRawUpdateLimits = ( for (const name of rawSelectOptionNames(fieldUpdate.options)) { const nameResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.select_choice_name_max_length', + tableDataSafetyLimitErrors.selectChoiceNameMaxLength, name.length, limits.fieldOptions.maxSelectChoiceNameLength, { target: 'field.options.choices.name' } @@ -262,7 +261,7 @@ const ensureRawUpdateLimits = ( if (mayContainSelectDefaultValues(fieldUpdate)) { const defaultValuesResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.select_default_values_max', + tableDataSafetyLimitErrors.selectDefaultValuesMax, rawDefaultValueCount(fieldUpdate.options), limits.fieldOptions.maxSelectDefaultValues, { target: 'field.options.defaultValue' } @@ -274,7 +273,7 @@ const ensureRawUpdateLimits = ( const expression = rawFormulaExpression(fieldUpdate); if (expression != null) { return ensureWithinTableDataSafetyLimit( - 'validation.limit.formula_max_length', + tableDataSafetyLimitErrors.formulaMaxLength, expression.length, limits.computed.maxFormulaLength, { target: 'field.options.expression' } diff --git a/packages/v2/core/src/application/services/TableDataSafetyLimitRecordWritePlugin.ts b/packages/v2/core/src/application/services/TableDataSafetyLimitRecordWritePlugin.ts index 79b5015382..52d869e577 100644 --- a/packages/v2/core/src/application/services/TableDataSafetyLimitRecordWritePlugin.ts +++ b/packages/v2/core/src/application/services/TableDataSafetyLimitRecordWritePlugin.ts @@ -4,6 +4,7 @@ import type { Result } from 'neverthrow'; import type { DomainError } from '../../domain/shared/DomainError'; import { ensureWithinTableDataSafetyLimit, + tableDataSafetyLimitErrors, measureJsonBytes, resolveTableDataSafetyLimits, } from '../../domain/shared/TableDataSafetyLimits'; @@ -13,10 +14,8 @@ import { type RecordWriteFieldValues, type RecordWritePluginContext, } from '../../ports/RecordWritePlugin'; -import { - createDefaultTableDataSafetyLimitComposer, - TableDataSafetyLimitComposer, -} from './TableDataSafetyLimitComposer'; +import type { TableDataSafetyLimitComposer } from './TableDataSafetyLimitComposer'; +import { createDefaultTableDataSafetyLimitComposer } from './TableDataSafetyLimitComposer'; type PreparedTableDataSafetyRecordLimitState = { readonly limits: ReturnType; @@ -102,7 +101,7 @@ export class TableDataSafetyLimitRecordWritePlugin ): Result { const limits = preparedState?.limits ?? resolveTableDataSafetyLimits(); const recordCountResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.records_per_mutation_max', + tableDataSafetyLimitErrors.recordsPerMutationMax, recordCountFromContext(context), limits.recordValues.maxRecordsPerMutation, { @@ -116,7 +115,7 @@ export class TableDataSafetyLimitRecordWritePlugin for (let recordIndex = 0; recordIndex < records.length; recordIndex++) { const record = records[recordIndex]!; const recordBytesResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.record_fields_max_bytes', + tableDataSafetyLimitErrors.recordFieldsMaxBytes, measureJsonBytes(Object.fromEntries(record)), limits.recordValues.maxRecordFieldsBytes, { @@ -129,7 +128,7 @@ export class TableDataSafetyLimitRecordWritePlugin for (const [fieldId, value] of record.entries()) { const cellBytesResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.cell_value_max_bytes', + tableDataSafetyLimitErrors.cellValueMaxBytes, measureJsonBytes(value), limits.recordValues.maxCellValueBytes, { diff --git a/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.ts b/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.ts index 1ec55c1a91..756be95384 100644 --- a/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.ts +++ b/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.ts @@ -6,6 +6,7 @@ import type { IDomainContext } from '../../domain/shared/DomainContext'; import type { DomainError } from '../../domain/shared/DomainError'; import { ensureWithinTableDataSafetyLimit, + tableDataSafetyLimitErrors, resolveTableDataSafetyLimits, type ResolvedTableDataSafetyLimitConfig, } from '../../domain/shared/TableDataSafetyLimits'; @@ -17,9 +18,9 @@ import type { } from '../../ports/TableOperationPlugin'; import { TableOperationKind } from '../../ports/TableOperationPlugin'; import type { ITableRepository } from '../../ports/TableRepository'; +import type { TableDataSafetyLimitComposer } from './TableDataSafetyLimitComposer'; import { ensureTableDataSafetyFieldLimits } from './TableDataSafetyLimitFieldOperationPlugin'; import { ensureTableDataSafetyViewConfigLimits } from './TableDataSafetyLimitViewOperationPlugin'; -import { TableDataSafetyLimitComposer } from './TableDataSafetyLimitComposer'; type PreparedTableDataSafetyOperationLimitState = { readonly domainContext: IDomainContext | undefined; @@ -76,7 +77,7 @@ export class TableDataSafetyLimitTableOperationPlugin const limits = preparedState?.limits ?? resolveTableDataSafetyLimits(); const nameResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.name_max_length', + tableDataSafetyLimitErrors.nameMaxLength, tableNameLength(context), limits.displayText.maxNameLength, { target: 'table.name' } @@ -165,7 +166,7 @@ export class TableDataSafetyLimitTableOperationPlugin domainContext: IDomainContext | undefined ): Result { const fieldsResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.create_table_fields_max', + tableDataSafetyLimitErrors.createTableFieldsMax, payload.fieldCount, limits.tableSchema.maxCreateTableFields, { target: 'table.fields' } @@ -176,7 +177,7 @@ export class TableDataSafetyLimitTableOperationPlugin if (fieldsPerTableResult.isErr()) return fieldsPerTableResult; const viewsResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.create_table_views_max', + tableDataSafetyLimitErrors.createTableViewsMax, payload.viewCount, limits.tableSchema.maxCreateTableViews, { target: 'table.views' } @@ -184,7 +185,7 @@ export class TableDataSafetyLimitTableOperationPlugin if (viewsResult.isErr()) return viewsResult; const viewsPerTableResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.views_per_table_max', + tableDataSafetyLimitErrors.viewsPerTableMax, payload.viewCount > 0 ? payload.viewCount : 1, limits.tableSchema.maxViewsPerTable, { target: 'table.views' } @@ -192,7 +193,7 @@ export class TableDataSafetyLimitTableOperationPlugin if (viewsPerTableResult.isErr()) return viewsPerTableResult; const recordsResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.create_table_records_max', + tableDataSafetyLimitErrors.createTableRecordsMax, payload.recordCount, limits.tableSchema.maxCreateTableRecords, { target: 'table.records' } @@ -201,7 +202,7 @@ export class TableDataSafetyLimitTableOperationPlugin for (const [index, viewName] of payload.viewNames.entries()) { const viewNameResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.name_max_length', + tableDataSafetyLimitErrors.nameMaxLength, viewName.length, limits.displayText.maxNameLength, { @@ -228,7 +229,7 @@ export class TableDataSafetyLimitTableOperationPlugin if (fieldsPerTableResult.isErr()) return fieldsPerTableResult; const viewsPerTableResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.views_per_table_max', + tableDataSafetyLimitErrors.viewsPerTableMax, table.views().length > 0 ? table.views().length : 1, limits.tableSchema.maxViewsPerTable, { target: 'table.views' } @@ -264,7 +265,7 @@ export class TableDataSafetyLimitTableOperationPlugin limits: ResolvedTableDataSafetyLimitConfig ): Result { return ensureWithinTableDataSafetyLimit( - 'validation.limit.fields_per_table_max', + tableDataSafetyLimitErrors.fieldsPerTableMax, fieldCount, limits.tableSchema.maxFieldsPerTable, { target: 'table.fields' } @@ -314,7 +315,7 @@ export class TableDataSafetyLimitTableOperationPlugin const existingTableCount = existingTableCountResult.value; return ensureWithinTableDataSafetyLimit( - 'validation.limit.tables_per_base_max', + tableDataSafetyLimitErrors.tablesPerBaseMax, existingTableCount + addedTableCount, limits.tableSchema.maxTablesPerBase, { diff --git a/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.spec.ts b/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.spec.ts index 2f2380c58b..c318a1c528 100644 --- a/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.spec.ts +++ b/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.spec.ts @@ -121,6 +121,16 @@ describe('TableDataSafetyLimitViewOperationPlugin', () => { }, { viewConfig: { maxFilterItems: 1 } }, ], + [ + 'validation.limit.view_filter_items_max', + ViewOperationKind.create, + { + tableId: 'tblTest', + currentViewCount: 1, + view: { filter: { conjunction: 'and', items: [filterItem, filterItem] } }, + }, + { viewConfig: { maxFilterItems: 1 } }, + ], [ 'validation.limit.view_filter_depth_max', ViewOperationKind.update, @@ -136,6 +146,21 @@ describe('TableDataSafetyLimitViewOperationPlugin', () => { }, { viewConfig: { maxFilterDepth: 1 } }, ], + [ + 'validation.limit.view_filter_depth_max', + ViewOperationKind.create, + { + tableId: 'tblTest', + currentViewCount: 1, + view: { + filter: { + conjunction: 'and', + items: [{ conjunction: 'and', items: [filterItem] }], + }, + }, + }, + { viewConfig: { maxFilterDepth: 1 } }, + ], [ 'validation.limit.view_sort_items_max', ViewOperationKind.update, diff --git a/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.ts b/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.ts index d5a256b1df..9b2ddfa457 100644 --- a/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.ts +++ b/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.ts @@ -4,6 +4,7 @@ import type { Result } from 'neverthrow'; import type { DomainError } from '../../domain/shared/DomainError'; import { ensureWithinTableDataSafetyLimit, + tableDataSafetyLimitErrors, measureJsonBytes, resolveTableDataSafetyLimits, type ResolvedTableDataSafetyLimitConfig, @@ -16,7 +17,7 @@ import { } from '../../ports/ViewOperationPlugin'; import { createDefaultTableDataSafetyLimitComposer, - TableDataSafetyLimitComposer, + type TableDataSafetyLimitComposer, } from './TableDataSafetyLimitComposer'; type PreparedTableDataSafetyViewLimitState = { @@ -24,27 +25,29 @@ type PreparedTableDataSafetyViewLimitState = { }; type FilterSetLike = { - readonly filterSet: ReadonlyArray; + readonly filterSet?: ReadonlyArray; + readonly items?: ReadonlyArray; }; type FilterNode = FilterSetLike | Readonly>; type FilterMeasureResult = { itemCount: number; depth: number }; -const isFilterSet = (value: unknown): value is FilterSetLike => - Boolean( - value && - typeof value === 'object' && - 'filterSet' in value && - Array.isArray((value as { filterSet?: unknown }).filterSet) - ); +const filterChildren = (value: unknown): ReadonlyArray | undefined => { + if (!value || typeof value !== 'object') return undefined; + const group = value as FilterSetLike; + if (Array.isArray(group.filterSet)) return group.filterSet; + if (Array.isArray(group.items)) return group.items; + return undefined; +}; const measureFilter = (filter: unknown): FilterMeasureResult => { if (filter == null) return { itemCount: 0, depth: 0 }; const visit = (node: unknown, depth: number): FilterMeasureResult => { - if (!isFilterSet(node)) return { itemCount: 1, depth }; + const children = filterChildren(node); + if (!children) return { itemCount: 1, depth }; - return node.filterSet.reduce( + return children.reduce( (acc, child) => { const childResult = visit(child, depth + 1); return { @@ -76,7 +79,7 @@ export const ensureTableDataSafetyViewOperationLimits = ( if (context.kind === ViewOperationKind.create || context.kind === ViewOperationKind.duplicate) { const addedViewCount = context.payload.addedViewCount ?? 1; const viewsPerTableResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.views_per_table_max', + tableDataSafetyLimitErrors.viewsPerTableMax, context.payload.currentViewCount + addedViewCount, limits.tableSchema.maxViewsPerTable, { @@ -100,7 +103,7 @@ export const ensureTableDataSafetyViewConfigLimits = ( ): Result => { if (view.name != null) { const nameResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.name_max_length', + tableDataSafetyLimitErrors.nameMaxLength, view.name.length, limits.displayText.maxNameLength, { target: 'view.name' } @@ -110,7 +113,7 @@ export const ensureTableDataSafetyViewConfigLimits = ( if (view.description != null) { const descriptionResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.description_max_length', + tableDataSafetyLimitErrors.descriptionMaxLength, view.description.length, limits.displayText.maxDescriptionLength, { target: 'view.description' } @@ -121,7 +124,7 @@ export const ensureTableDataSafetyViewConfigLimits = ( if (view.filter !== undefined) { const { itemCount, depth } = measureFilter(view.filter); const filterItemsResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.view_filter_items_max', + tableDataSafetyLimitErrors.viewFilterItemsMax, itemCount, limits.viewConfig.maxFilterItems, { target: 'view.filter' } @@ -129,7 +132,7 @@ export const ensureTableDataSafetyViewConfigLimits = ( if (filterItemsResult.isErr()) return filterItemsResult; const filterDepthResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.view_filter_depth_max', + tableDataSafetyLimitErrors.viewFilterDepthMax, depth, limits.viewConfig.maxFilterDepth, { target: 'view.filter' } @@ -139,7 +142,7 @@ export const ensureTableDataSafetyViewConfigLimits = ( if (view.sort !== undefined) { const sortResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.view_sort_items_max', + tableDataSafetyLimitErrors.viewSortItemsMax, sortItemCount(view.sort), limits.viewConfig.maxSortItems, { target: 'view.sort' } @@ -149,7 +152,7 @@ export const ensureTableDataSafetyViewConfigLimits = ( if (view.group !== undefined) { const groupResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.view_group_items_max', + tableDataSafetyLimitErrors.viewGroupItemsMax, groupItemCount(view.group), limits.viewConfig.maxGroupItems, { target: 'view.group' } @@ -159,7 +162,7 @@ export const ensureTableDataSafetyViewConfigLimits = ( if (view.options !== undefined) { const optionsResult = ensureWithinTableDataSafetyLimit( - 'validation.limit.view_options_max_bytes', + tableDataSafetyLimitErrors.viewOptionsMaxBytes, measureJsonBytes(view.options), limits.viewConfig.maxOptionsBytes, { target: 'view.options' } diff --git a/packages/v2/core/src/application/services/TableUpdateFlow.spec.ts b/packages/v2/core/src/application/services/TableUpdateFlow.spec.ts index 61c74f915d..c2c9e026fd 100644 --- a/packages/v2/core/src/application/services/TableUpdateFlow.spec.ts +++ b/packages/v2/core/src/application/services/TableUpdateFlow.spec.ts @@ -8,6 +8,16 @@ import { domainError, type DomainError } from '../../domain/shared/DomainError'; import type { IDomainEvent } from '../../domain/shared/DomainEvent'; import type { ISpecification } from '../../domain/shared/specification/ISpecification'; import { ViewColumnMetaUpdated } from '../../domain/table/events/ViewColumnMetaUpdated'; +import { ViewDescriptionUpdated } from '../../domain/table/events/ViewDescriptionUpdated'; +import { ViewGroupUpdated } from '../../domain/table/events/ViewGroupUpdated'; +import { ViewLockedUpdated } from '../../domain/table/events/ViewLockedUpdated'; +import { ViewOptionsUpdated } from '../../domain/table/events/ViewOptionsUpdated'; +import { ViewRenamed } from '../../domain/table/events/ViewRenamed'; +import { ViewShareDisabled } from '../../domain/table/events/ViewShareDisabled'; +import { ViewShareEnabled } from '../../domain/table/events/ViewShareEnabled'; +import { ViewShareIdRefreshed } from '../../domain/table/events/ViewShareIdRefreshed'; +import { ViewShareMetaUpdated } from '../../domain/table/events/ViewShareMetaUpdated'; +import { ViewSortUpdated } from '../../domain/table/events/ViewSortUpdated'; import { FieldId } from '../../domain/table/fields/FieldId'; import { FieldName } from '../../domain/table/fields/FieldName'; import { SingleLineTextField } from '../../domain/table/fields/types/SingleLineTextField'; @@ -18,6 +28,7 @@ import { TableId } from '../../domain/table/TableId'; import { TableName } from '../../domain/table/TableName'; import type { TableSortKey } from '../../domain/table/TableSortKey'; import { ViewColumnMeta } from '../../domain/table/views/ViewColumnMeta'; +import { ViewName } from '../../domain/table/views/ViewName'; import type { IEventBus } from '../../ports/EventBus'; import type { IExecutionContext, @@ -366,6 +377,387 @@ describe('TableUpdateFlow', () => { expect(eventBus.published.some((event) => event instanceof ViewColumnMetaUpdated)).toBe(true); }); + it('attaches persisted view versions to ViewRenamed events', async () => { + const table = buildTable(); + const eventBus = new FakeEventBus(); + const repository = new FakeTableRepository(); + const view = table.views()[0]!; + repository.updateOne = async () => + ok({ + viewVersionChanges: [ + { + viewId: view.id().toString(), + oldVersion: 7, + newVersion: 8, + }, + ], + }); + const flow = new TableUpdateFlow( + repository, + new FakeTableSchemaRepository(), + eventBus, + new FakeUnitOfWork() + ); + + const result = await flow.execute(createContext(), { table }, (tableToUpdate) => + tableToUpdate + .renameView(view.id(), ViewName.create('Renamed')._unsafeUnwrap()) + .map(({ updateResult }) => updateResult) + ); + + const viewEvent = result + ._unsafeUnwrap() + .events.find((event): event is ViewRenamed => event instanceof ViewRenamed); + expect(viewEvent).toMatchObject({ + oldVersion: 7, + newVersion: 8, + previousName: view.name(), + nextName: ViewName.create('Renamed')._unsafeUnwrap(), + }); + expect(eventBus.published.some((event) => event instanceof ViewRenamed)).toBe(true); + }); + + it('attaches persisted view versions to ViewDescriptionUpdated events', async () => { + const table = buildTable(); + const eventBus = new FakeEventBus(); + const repository = new FakeTableRepository(); + const view = table.views()[0]!; + repository.updateOne = async () => + ok({ + viewVersionChanges: [ + { + viewId: view.id().toString(), + oldVersion: 8, + newVersion: 9, + }, + ], + }); + const flow = new TableUpdateFlow( + repository, + new FakeTableSchemaRepository(), + eventBus, + new FakeUnitOfWork() + ); + + const result = await flow.execute(createContext(), { table }, (tableToUpdate) => + tableToUpdate + .updateViewDescription(view.id(), 'Updated') + .map(({ updateResult }) => updateResult) + ); + + const viewEvent = result + ._unsafeUnwrap() + .events.find( + (event): event is ViewDescriptionUpdated => event instanceof ViewDescriptionUpdated + ); + expect(viewEvent).toMatchObject({ + oldVersion: 8, + newVersion: 9, + previousDescription: undefined, + nextDescription: 'Updated', + }); + expect(eventBus.published.some((event) => event instanceof ViewDescriptionUpdated)).toBe(true); + }); + + it('attaches persisted view versions to ViewLockedUpdated events', async () => { + const table = buildTable(); + const eventBus = new FakeEventBus(); + const repository = new FakeTableRepository(); + const view = table.views()[0]!; + repository.updateOne = async () => + ok({ + viewVersionChanges: [ + { + viewId: view.id().toString(), + oldVersion: 9, + newVersion: 10, + }, + ], + }); + const flow = new TableUpdateFlow( + repository, + new FakeTableSchemaRepository(), + eventBus, + new FakeUnitOfWork() + ); + + const result = await flow.execute(createContext(), { table }, (tableToUpdate) => + tableToUpdate.updateViewLocked(view.id(), true).map(({ updateResult }) => updateResult) + ); + + const viewEvent = result + ._unsafeUnwrap() + .events.find((event): event is ViewLockedUpdated => event instanceof ViewLockedUpdated); + expect(viewEvent).toMatchObject({ + oldVersion: 9, + newVersion: 10, + previousIsLocked: undefined, + nextIsLocked: true, + }); + expect(eventBus.published.some((event) => event instanceof ViewLockedUpdated)).toBe(true); + }); + + it('attaches persisted view versions to ViewSortUpdated events', async () => { + const table = buildTable(); + const eventBus = new FakeEventBus(); + const repository = new FakeTableRepository(); + const view = table.views()[0]!; + repository.updateOne = async () => + ok({ + viewVersionChanges: [ + { + viewId: view.id().toString(), + oldVersion: 10, + newVersion: 11, + }, + ], + }); + const flow = new TableUpdateFlow( + repository, + new FakeTableSchemaRepository(), + eventBus, + new FakeUnitOfWork() + ); + const sort = { + sortObjs: [{ fieldId: table.primaryFieldId().toString(), order: 'desc' as const }], + manualSort: false, + }; + + const result = await flow.execute(createContext(), { table }, (tableToUpdate) => + tableToUpdate.updateViewSort(view.id(), sort).map(({ updateResult }) => updateResult!) + ); + + const viewEvent = result + ._unsafeUnwrap() + .events.find((event): event is ViewSortUpdated => event instanceof ViewSortUpdated); + expect(viewEvent).toMatchObject({ + oldVersion: 10, + newVersion: 11, + previousSort: null, + nextSort: sort, + }); + expect(eventBus.published.some((event) => event instanceof ViewSortUpdated)).toBe(true); + }); + + it('attaches persisted view versions to ViewGroupUpdated events', async () => { + const table = buildTable(); + const eventBus = new FakeEventBus(); + const repository = new FakeTableRepository(); + const view = table.views()[0]!; + repository.updateOne = async () => + ok({ + viewVersionChanges: [ + { + viewId: view.id().toString(), + oldVersion: 11, + newVersion: 12, + }, + ], + }); + const flow = new TableUpdateFlow( + repository, + new FakeTableSchemaRepository(), + eventBus, + new FakeUnitOfWork() + ); + const group = [{ fieldId: table.primaryFieldId().toString(), order: 'asc' as const }]; + + const result = await flow.execute(createContext(), { table }, (tableToUpdate) => + tableToUpdate.updateViewGroup(view.id(), group).map(({ updateResult }) => updateResult!) + ); + + const viewEvent = result + ._unsafeUnwrap() + .events.find((event): event is ViewGroupUpdated => event instanceof ViewGroupUpdated); + expect(viewEvent).toMatchObject({ + oldVersion: 11, + newVersion: 12, + previousGroup: null, + nextGroup: group, + }); + expect(eventBus.published.some((event) => event instanceof ViewGroupUpdated)).toBe(true); + }); + + it('attaches persisted view versions to ViewOptionsUpdated events', async () => { + const table = buildTable(); + const eventBus = new FakeEventBus(); + const repository = new FakeTableRepository(); + const view = table.views()[0]!; + repository.updateOne = async () => + ok({ + viewVersionChanges: [ + { + viewId: view.id().toString(), + oldVersion: 12, + newVersion: 13, + }, + ], + }); + const flow = new TableUpdateFlow( + repository, + new FakeTableSchemaRepository(), + eventBus, + new FakeUnitOfWork() + ); + const options = { rowHeight: 'tall' }; + + const result = await flow.execute(createContext(), { table }, (tableToUpdate) => + tableToUpdate.updateViewOptions(view.id(), options).map(({ updateResult }) => updateResult!) + ); + + const viewEvent = result + ._unsafeUnwrap() + .events.find((event): event is ViewOptionsUpdated => event instanceof ViewOptionsUpdated); + expect(viewEvent).toMatchObject({ + oldVersion: 12, + newVersion: 13, + previousOptions: undefined, + nextOptions: options, + }); + expect(eventBus.published.some((event) => event instanceof ViewOptionsUpdated)).toBe(true); + }); + + it('attaches persisted view versions to ViewShareMetaUpdated events', async () => { + const table = buildTable(); + const eventBus = new FakeEventBus(); + const repository = new FakeTableRepository(); + const view = table.views()[0]!; + repository.updateOne = async () => + ok({ + viewVersionChanges: [ + { + viewId: view.id().toString(), + oldVersion: 13, + newVersion: 14, + }, + ], + }); + const flow = new TableUpdateFlow( + repository, + new FakeTableSchemaRepository(), + eventBus, + new FakeUnitOfWork() + ); + const shareMeta = { allowCopy: true }; + + const result = await flow.execute(createContext(), { table }, (tableToUpdate) => + tableToUpdate + .updateViewShareMeta(view.id(), shareMeta) + .map(({ updateResult }) => updateResult!) + ); + + const viewEvent = result + ._unsafeUnwrap() + .events.find((event): event is ViewShareMetaUpdated => event instanceof ViewShareMetaUpdated); + expect(viewEvent).toMatchObject({ + oldVersion: 13, + newVersion: 14, + previousShareMeta: undefined, + nextShareMeta: shareMeta, + }); + expect(eventBus.published.some((event) => event instanceof ViewShareMetaUpdated)).toBe(true); + }); + + it('attaches persisted view versions to ViewShareIdRefreshed events', async () => { + const source = buildTable(); + const created = source + .createView({ + type: 'grid', + name: 'Public View', + enableShare: true, + shareId: `shr${'a'.repeat(16)}`, + }) + ._unsafeUnwrap(); + const table = created.updateResult.table; + table.pullDomainEvents(); + const view = created.view; + const eventBus = new FakeEventBus(); + const repository = new FakeTableRepository(); + repository.updateOne = async () => + ok({ + viewVersionChanges: [ + { + viewId: view.id().toString(), + oldVersion: 14, + newVersion: 15, + }, + ], + }); + const flow = new TableUpdateFlow( + repository, + new FakeTableSchemaRepository(), + eventBus, + new FakeUnitOfWork() + ); + + const result = await flow.execute(createContext(), { table }, (tableToUpdate) => + tableToUpdate.refreshViewShareId(view.id()).map(({ updateResult }) => updateResult) + ); + + const viewEvent = result + ._unsafeUnwrap() + .events.find((event): event is ViewShareIdRefreshed => event instanceof ViewShareIdRefreshed); + expect(viewEvent).toMatchObject({ + oldVersion: 14, + newVersion: 15, + previousShareId: `shr${'a'.repeat(16)}`, + nextShareId: expect.stringMatching(/^shr[0-9a-zA-Z]{16}$/), + }); + expect(eventBus.published.some((event) => event instanceof ViewShareIdRefreshed)).toBe(true); + }); + + it('attaches persisted view versions to View share lifecycle events', async () => { + const table = buildTable(); + const view = table.views()[0]!; + const eventBus = new FakeEventBus(); + const repository = new FakeTableRepository(); + repository.updateOne = async () => + ok({ + viewVersionChanges: [ + { + viewId: view.id().toString(), + oldVersion: 20, + newVersion: 21, + }, + ], + }); + const flow = new TableUpdateFlow( + repository, + new FakeTableSchemaRepository(), + eventBus, + new FakeUnitOfWork() + ); + + const enabledResult = await flow.execute(createContext(), { table }, (tableToUpdate) => + tableToUpdate.enableViewShare(view.id()).map(({ updateResult }) => updateResult) + ); + const enabledTable = enabledResult._unsafeUnwrap().table; + const enabledEvent = enabledResult + ._unsafeUnwrap() + .events.find((event): event is ViewShareEnabled => event instanceof ViewShareEnabled); + expect(enabledEvent).toMatchObject({ oldVersion: 20, newVersion: 21 }); + + repository.updateOne = async () => + ok({ + viewVersionChanges: [ + { + viewId: view.id().toString(), + oldVersion: 21, + newVersion: 22, + }, + ], + }); + const disabledResult = await flow.execute(createContext(), { table: enabledTable }, (next) => + next.disableViewShare(view.id()).map(({ updateResult }) => updateResult) + ); + const disabledEvent = disabledResult + ._unsafeUnwrap() + .events.find((event): event is ViewShareDisabled => event instanceof ViewShareDisabled); + expect(disabledEvent).toMatchObject({ oldVersion: 21, newVersion: 22 }); + expect(eventBus.published.some((event) => event instanceof ViewShareEnabled)).toBe(true); + expect(eventBus.published.some((event) => event instanceof ViewShareDisabled)).toBe(true); + }); + it('lets deferred tasks observe the latest table state in the transaction scope', async () => { const table = buildTable(); const observedNames: string[] = []; diff --git a/packages/v2/core/src/application/services/TableUpdateFlow.ts b/packages/v2/core/src/application/services/TableUpdateFlow.ts index 89506c1413..bde794c620 100644 --- a/packages/v2/core/src/application/services/TableUpdateFlow.ts +++ b/packages/v2/core/src/application/services/TableUpdateFlow.ts @@ -13,11 +13,25 @@ import { type DomainError, } from '../../domain/shared/DomainError'; import type { IDomainEvent } from '../../domain/shared/DomainEvent'; -import type { ISpecification } from '../../domain/shared/specification/ISpecification'; import { flattenAndSpecs } from '../../domain/shared/specification/composeAndSpecs'; +import type { ISpecification } from '../../domain/shared/specification/ISpecification'; import { FieldOptionsAdded } from '../../domain/table/events/FieldOptionsAdded'; import { FieldUpdated } from '../../domain/table/events/FieldUpdated'; import { ViewColumnMetaUpdated } from '../../domain/table/events/ViewColumnMetaUpdated'; +import { ViewCreated } from '../../domain/table/events/ViewCreated'; +import { ViewDescriptionUpdated } from '../../domain/table/events/ViewDescriptionUpdated'; +import { ViewFilterUpdated } from '../../domain/table/events/ViewFilterUpdated'; +import { ViewGroupUpdated } from '../../domain/table/events/ViewGroupUpdated'; +import { ViewLockedUpdated } from '../../domain/table/events/ViewLockedUpdated'; +import { ViewOptionsUpdated } from '../../domain/table/events/ViewOptionsUpdated'; +import { ViewOrderUpdated } from '../../domain/table/events/ViewOrderUpdated'; +import { ViewRenamed } from '../../domain/table/events/ViewRenamed'; +import { ViewShareDisabled } from '../../domain/table/events/ViewShareDisabled'; +import { ViewShareEnabled } from '../../domain/table/events/ViewShareEnabled'; +import { ViewShareIdRefreshed } from '../../domain/table/events/ViewShareIdRefreshed'; +import { ViewShareMetaUpdated } from '../../domain/table/events/ViewShareMetaUpdated'; +import { ViewSortUpdated } from '../../domain/table/events/ViewSortUpdated'; +import { ViewVersion } from '../../domain/table/views/ViewVersion'; import { RemoveSymmetricLinkFieldSpec, UpdateLinkConfigSpec, @@ -26,6 +40,7 @@ import { import type { ITableSpecVisitor } from '../../domain/table/specs/ITableSpecVisitor'; import { TableAddFieldSpec } from '../../domain/table/specs/TableAddFieldSpec'; import { TableAddFieldsSpec } from '../../domain/table/specs/TableAddFieldsSpec'; +import { TableAddViewSpec } from '../../domain/table/specs/TableAddViewSpec'; import { TableDuplicateFieldSpec } from '../../domain/table/specs/TableDuplicateFieldSpec'; import { TableRemoveFieldSpec } from '../../domain/table/specs/TableRemoveFieldSpec'; import { TableUpdateFieldConstraintsSpec } from '../../domain/table/specs/TableUpdateFieldConstraintsSpec'; @@ -121,17 +136,21 @@ const mayRequirePhysicalSchemaRepair = ( if ( spec instanceof TableAddFieldSpec || spec instanceof TableAddFieldsSpec || + (spec instanceof TableAddViewSpec && spec.view().type().toString() === 'grid') || spec instanceof TableDuplicateFieldSpec || spec instanceof TableRemoveFieldSpec || spec instanceof TableUpdateFieldDbFieldNameSpec || spec instanceof TableUpdateFieldConstraintsSpec || - spec instanceof UpdateLinkConfigSpec || spec instanceof UpdateLinkRelationshipSpec || spec instanceof RemoveSymmetricLinkFieldSpec ) { return true; } + if (spec instanceof UpdateLinkConfigSpec) { + return spec.isRelationshipChanging() || spec.isOneWayChanging(); + } + return spec instanceof TableUpdateFieldTypeSpec && spec.isTypeConversion(); }); @@ -232,6 +251,7 @@ export class TableUpdateFlow { latestTable, mutateSpec ); + handler.applyPersistedViewVersions(latestTable, tableUpdatePersistResult); tableMetadataPersisted = true; const dataPhaseResult = yield* await handler.unitOfWork.withTransaction( metaTransactionContext, @@ -388,6 +408,25 @@ export class TableUpdateFlow { }); } + /** + * The persisted view versions advanced during this update; sync the aggregate + * instance we return so a follow-up update reusing it (e.g. dependent-field + * cleanup after a field delete) does not fail the optimistic view-version + * guard with a stale expectation. + */ + private applyPersistedViewVersions( + table: Table, + persistResult: TableRepositoryPort.TableUpdatePersistResult | void + ): void { + for (const change of persistResult?.viewVersionChanges ?? []) { + const viewResult = table.getViewById(change.viewId); + if (viewResult.isErr()) continue; + const versionResult = ViewVersion.rehydrate(change.newVersion); + if (versionResult.isErr()) continue; + viewResult.value.advanceVersion(versionResult.value); + } + } + private attachPersistedEventVersions( events: ReadonlyArray, persistResult: TableRepositoryPort.TableUpdatePersistResult | void @@ -477,6 +516,240 @@ export class TableUpdateFlow { viewId: event.viewId, fieldId: event.fieldId, fieldInColumnMeta: event.fieldInColumnMeta, + changes: event.changes, + optionsChange: event.optionsChange, + oldVersion: versionChange.oldVersion, + newVersion: versionChange.newVersion, + }); + } + + if (event instanceof ViewCreated) { + if (event.oldVersion != null && event.newVersion != null) { + return event; + } + const queue = queueByViewId.get(event.viewId.toString()); + const versionChange = queue?.shift(); + if (!versionChange) return event; + return ViewCreated.create({ + tableId: event.tableId, + baseId: event.baseId, + viewId: event.viewId, + oldVersion: versionChange.oldVersion, + newVersion: versionChange.newVersion, + }); + } + + if (event instanceof ViewRenamed) { + if (event.oldVersion != null && event.newVersion != null) { + return event; + } + const queue = queueByViewId.get(event.viewId.toString()); + const versionChange = queue?.shift(); + if (!versionChange) return event; + return ViewRenamed.create({ + tableId: event.tableId, + baseId: event.baseId, + viewId: event.viewId, + previousName: event.previousName, + nextName: event.nextName, + oldVersion: versionChange.oldVersion, + newVersion: versionChange.newVersion, + }); + } + + if (event instanceof ViewDescriptionUpdated) { + if (event.oldVersion != null && event.newVersion != null) { + return event; + } + const queue = queueByViewId.get(event.viewId.toString()); + const versionChange = queue?.shift(); + if (!versionChange) return event; + return ViewDescriptionUpdated.create({ + tableId: event.tableId, + baseId: event.baseId, + viewId: event.viewId, + previousDescription: event.previousDescription, + nextDescription: event.nextDescription, + oldVersion: versionChange.oldVersion, + newVersion: versionChange.newVersion, + }); + } + + if (event instanceof ViewFilterUpdated) { + if (event.oldVersion != null && event.newVersion != null) { + return event; + } + const queue = queueByViewId.get(event.viewId.toString()); + const versionChange = queue?.shift(); + if (!versionChange) return event; + return ViewFilterUpdated.create({ + tableId: event.tableId, + baseId: event.baseId, + viewId: event.viewId, + previousFilter: event.previousFilter, + nextFilter: event.nextFilter, + oldVersion: versionChange.oldVersion, + newVersion: versionChange.newVersion, + }); + } + + if (event instanceof ViewSortUpdated) { + if (event.oldVersion != null && event.newVersion != null) { + return event; + } + const queue = queueByViewId.get(event.viewId.toString()); + const versionChange = queue?.shift(); + if (!versionChange) return event; + return ViewSortUpdated.create({ + tableId: event.tableId, + baseId: event.baseId, + viewId: event.viewId, + previousSort: event.previousSort, + nextSort: event.nextSort, + oldVersion: versionChange.oldVersion, + newVersion: versionChange.newVersion, + }); + } + + if (event instanceof ViewGroupUpdated) { + if (event.oldVersion != null && event.newVersion != null) { + return event; + } + const queue = queueByViewId.get(event.viewId.toString()); + const versionChange = queue?.shift(); + if (!versionChange) return event; + return ViewGroupUpdated.create({ + tableId: event.tableId, + baseId: event.baseId, + viewId: event.viewId, + previousGroup: event.previousGroup, + nextGroup: event.nextGroup, + oldVersion: versionChange.oldVersion, + newVersion: versionChange.newVersion, + }); + } + + if (event instanceof ViewOptionsUpdated) { + if (event.oldVersion != null && event.newVersion != null) { + return event; + } + const queue = queueByViewId.get(event.viewId.toString()); + const versionChange = queue?.shift(); + if (!versionChange) return event; + return ViewOptionsUpdated.create({ + tableId: event.tableId, + baseId: event.baseId, + viewId: event.viewId, + previousOptions: event.previousOptions, + nextOptions: event.nextOptions, + oldVersion: versionChange.oldVersion, + newVersion: versionChange.newVersion, + }); + } + + if (event instanceof ViewShareMetaUpdated) { + if (event.oldVersion != null && event.newVersion != null) { + return event; + } + const queue = queueByViewId.get(event.viewId.toString()); + const versionChange = queue?.shift(); + if (!versionChange) return event; + return ViewShareMetaUpdated.create({ + tableId: event.tableId, + baseId: event.baseId, + viewId: event.viewId, + previousShareMeta: event.previousShareMeta, + nextShareMeta: event.nextShareMeta, + oldVersion: versionChange.oldVersion, + newVersion: versionChange.newVersion, + }); + } + + if (event instanceof ViewShareIdRefreshed) { + if (event.oldVersion != null && event.newVersion != null) { + return event; + } + const queue = queueByViewId.get(event.viewId.toString()); + const versionChange = queue?.shift(); + if (!versionChange) return event; + return ViewShareIdRefreshed.create({ + tableId: event.tableId, + baseId: event.baseId, + viewId: event.viewId, + previousShareId: event.previousShareId, + nextShareId: event.nextShareId, + oldVersion: versionChange.oldVersion, + newVersion: versionChange.newVersion, + }); + } + + if (event instanceof ViewShareEnabled) { + if (event.oldVersion != null && event.newVersion != null) { + return event; + } + const queue = queueByViewId.get(event.viewId.toString()); + const versionChange = queue?.shift(); + if (!versionChange) return event; + return ViewShareEnabled.create({ + tableId: event.tableId, + baseId: event.baseId, + viewId: event.viewId, + shareId: event.shareId, + shareMeta: event.shareMeta, + oldVersion: versionChange.oldVersion, + newVersion: versionChange.newVersion, + }); + } + + if (event instanceof ViewShareDisabled) { + if (event.oldVersion != null && event.newVersion != null) { + return event; + } + const queue = queueByViewId.get(event.viewId.toString()); + const versionChange = queue?.shift(); + if (!versionChange) return event; + return ViewShareDisabled.create({ + tableId: event.tableId, + baseId: event.baseId, + viewId: event.viewId, + previousShareId: event.previousShareId, + shareMeta: event.shareMeta, + oldVersion: versionChange.oldVersion, + newVersion: versionChange.newVersion, + }); + } + + if (event instanceof ViewLockedUpdated) { + if (event.oldVersion != null && event.newVersion != null) { + return event; + } + const queue = queueByViewId.get(event.viewId.toString()); + const versionChange = queue?.shift(); + if (!versionChange) return event; + return ViewLockedUpdated.create({ + tableId: event.tableId, + baseId: event.baseId, + viewId: event.viewId, + previousIsLocked: event.previousIsLocked, + nextIsLocked: event.nextIsLocked, + oldVersion: versionChange.oldVersion, + newVersion: versionChange.newVersion, + }); + } + + if (event instanceof ViewOrderUpdated) { + if (event.oldVersion != null && event.newVersion != null) { + return event; + } + const queue = queueByViewId.get(event.viewId.toString()); + const versionChange = queue?.shift(); + if (!versionChange) return event; + return ViewOrderUpdated.create({ + tableId: event.tableId, + baseId: event.baseId, + viewId: event.viewId, + previousOrder: event.previousOrder, + nextOrder: event.nextOrder, oldVersion: versionChange.oldVersion, newVersion: versionChange.newVersion, }); diff --git a/packages/v2/core/src/application/services/UndoRedoStackService.spec.ts b/packages/v2/core/src/application/services/UndoRedoStackService.spec.ts index c031cafc2d..3f9f71770d 100644 --- a/packages/v2/core/src/application/services/UndoRedoStackService.spec.ts +++ b/packages/v2/core/src/application/services/UndoRedoStackService.spec.ts @@ -4,8 +4,13 @@ import { describe, expect, it } from 'vitest'; import type { ApplyFieldSnapshotCommand } from '../../commands/ApplyFieldSnapshotCommand'; import type { ApplyRecordOrdersCommand } from '../../commands/ApplyRecordOrdersCommand'; +import type { ApplyViewSnapshotCommand } from '../../commands/ApplyViewSnapshotCommand'; import type { DeleteFieldCommand } from '../../commands/DeleteFieldCommand'; +import type { DeleteViewCommand } from '../../commands/DeleteViewCommand'; +import type { DisableViewShareCommand } from '../../commands/DisableViewShareCommand'; +import type { EnableViewShareCommand } from '../../commands/EnableViewShareCommand'; import type { ReplayFieldTypeConversionCommand } from '../../commands/ReplayFieldTypeConversionCommand'; +import type { SetButtonValueCommand } from '../../commands/SetButtonValueCommand'; import type { UpdateRecordCommand } from '../../commands/UpdateRecordCommand'; import type { UpdateRecordsCommand } from '../../commands/UpdateRecordsCommand'; import { ActorId } from '../../domain/shared/ActorId'; @@ -322,6 +327,55 @@ describe('UndoRedoStackService', () => { expect(entry.recordVersionAfter).toBe(4); }); + it('replays Button snapshots with the aggregate-only Button command', async () => { + const store = new MemoryUndoRedoStore(); + const bus = new FakeCommandBus(); + const service = new UndoRedoStackService(store, bus); + const context = buildContext(); + const { tableId, recordId } = buildRecordIds(); + const fieldId = `fld${'c'.repeat(16)}`; + + await service.appendButtonValueUpdateFromSnapshot(toUndoRedoStackAppendContext(context), { + tableId, + recordId, + fieldId, + snapshot: { + previous: { + recordId: recordId.toString(), + fields: {}, + }, + current: { + recordId: recordId.toString(), + fields: { [fieldId]: { count: 1 } }, + }, + oldVersion: 7, + newVersion: 8, + }, + }); + + const entries = (await store.list(buildScope(context, tableId)))._unsafeUnwrap(); + expect(entries).toHaveLength(1); + expect(entries[0]?.undoCommand).toMatchObject({ + type: 'SetButtonValue', + payload: { fieldId, value: null }, + }); + expect(entries[0]?.redoCommand).toMatchObject({ + type: 'SetButtonValue', + payload: { fieldId, value: { count: 1 } }, + }); + + await service.applyUndo(toUndoRedoStackReplayContext(context), tableId, context.windowId); + const undoCommand = bus.lastCommand as SetButtonValueCommand; + expect(undoCommand.fieldId.toString()).toBe(fieldId); + expect(undoCommand.value).toBeNull(); + expect(bus.lastContext?.undoRedo?.mode).toBe('undo'); + + await service.applyRedo(toUndoRedoStackReplayContext(context), tableId, context.windowId); + const redoCommand = bus.lastCommand as SetButtonValueCommand; + expect(redoCommand.value).toEqual({ count: 1 }); + expect(bus.lastContext?.undoRedo?.mode).toBe('redo'); + }); + it('executes apply-record-orders undo entries via the command bus', async () => { const store = new MemoryUndoRedoStore(); const bus = new FakeCommandBus(); @@ -404,6 +458,81 @@ describe('UndoRedoStackService', () => { expect(applyFieldSnapshotCommand.snapshot.field.id).toBe(fieldId); }); + it('executes View snapshot replay and delete through v2 commands', async () => { + const store = new MemoryUndoRedoStore(); + const bus = new FakeCommandBus(); + const service = new UndoRedoStackService(store, bus); + const context = buildContext(); + const { tableId } = buildRecordIds(); + const viewId = `viw${'v'.repeat(16)}`; + const snapshot = { + id: viewId, + name: 'Planning', + type: 'grid' as const, + order: 2, + properties: { + description: 'Restored by v2', + isLocked: true, + }, + columnMeta: { + [`fld${'f'.repeat(16)}`]: { order: 0, width: 320 }, + }, + query: {}, + options: { rowHeight: 'tall' }, + }; + + await service.appendEntry(toUndoRedoStackAppendContext(context), tableId, { + undoCommand: createUndoRedoCommand('ApplyViewSnapshot', { + tableId: tableId.toString(), + snapshot, + }), + redoCommand: createUndoRedoCommand('DeleteView', { + tableId: tableId.toString(), + viewId, + }), + }); + + await service.applyUndo(toUndoRedoStackReplayContext(context), tableId, context.windowId); + expect(bus.lastContext?.undoRedo?.mode).toBe('undo'); + const applyViewSnapshotCommand = bus.lastCommand as ApplyViewSnapshotCommand; + expect(applyViewSnapshotCommand.snapshot).toEqual(snapshot); + + await service.applyRedo(toUndoRedoStackReplayContext(context), tableId, context.windowId); + expect(bus.lastContext?.undoRedo?.mode).toBe('redo'); + const deleteViewCommand = bus.lastCommand as DeleteViewCommand; + expect(deleteViewCommand.viewId.toString()).toBe(viewId); + }); + + it('replays View share lifecycle commands without carrying share credentials', async () => { + const store = new MemoryUndoRedoStore(); + const bus = new FakeCommandBus(); + const service = new UndoRedoStackService(store, bus); + const context = buildContext(); + const { tableId } = buildRecordIds(); + const viewId = `viw${'s'.repeat(16)}`; + + await service.appendEntry(toUndoRedoStackAppendContext(context), tableId, { + undoCommand: createUndoRedoCommand('EnableViewShare', { + tableId: tableId.toString(), + viewId, + }), + redoCommand: createUndoRedoCommand('DisableViewShare', { + tableId: tableId.toString(), + viewId, + }), + }); + + await service.applyUndo(toUndoRedoStackReplayContext(context), tableId, context.windowId); + const enableCommand = bus.lastCommand as EnableViewShareCommand; + expect(enableCommand.viewId.toString()).toBe(viewId); + expect(bus.lastContext?.undoRedo?.mode).toBe('undo'); + + await service.applyRedo(toUndoRedoStackReplayContext(context), tableId, context.windowId); + const disableCommand = bus.lastCommand as DisableViewShareCommand; + expect(disableCommand.viewId.toString()).toBe(viewId); + expect(bus.lastContext?.undoRedo?.mode).toBe('redo'); + }); + it('executes field type conversion replay via the command bus', async () => { const store = new MemoryUndoRedoStore(); const bus = new FakeCommandBus(); diff --git a/packages/v2/core/src/application/services/UndoRedoStackService.ts b/packages/v2/core/src/application/services/UndoRedoStackService.ts index c5cb2e1fc0..3a8292f41a 100644 --- a/packages/v2/core/src/application/services/UndoRedoStackService.ts +++ b/packages/v2/core/src/application/services/UndoRedoStackService.ts @@ -4,13 +4,19 @@ import type { Result } from 'neverthrow'; import { ApplyFieldSnapshotCommand } from '../../commands/ApplyFieldSnapshotCommand'; import { ApplyRecordOrdersCommand } from '../../commands/ApplyRecordOrdersCommand'; +import { ApplyViewSnapshotCommand } from '../../commands/ApplyViewSnapshotCommand'; import { DeleteFieldCommand } from '../../commands/DeleteFieldCommand'; import { DeleteRecordsCommand } from '../../commands/DeleteRecordsCommand'; +import { DeleteViewCommand } from '../../commands/DeleteViewCommand'; +import { DisableViewShareCommand } from '../../commands/DisableViewShareCommand'; +import { EnableViewShareCommand } from '../../commands/EnableViewShareCommand'; import { ReplayFieldTypeConversionCommand } from '../../commands/ReplayFieldTypeConversionCommand'; import { RestoreRecordsCommand } from '../../commands/RestoreRecordsCommand'; +import { SetButtonValueCommand } from '../../commands/SetButtonValueCommand'; import { UpdateRecordCommand } from '../../commands/UpdateRecordCommand'; import { UpdateRecordsCommand } from '../../commands/UpdateRecordsCommand'; import { domainError, type DomainError } from '../../domain/shared/DomainError'; +import { RECORD_REMOVAL_REASON } from '../../domain/table/events/RecordsDeleted'; import { FieldKeyType } from '../../domain/table/fields/FieldKeyType'; import type { RecordId } from '../../domain/table/records/RecordId'; import { TableId } from '../../domain/table/TableId'; @@ -31,8 +37,10 @@ import { isSupportedUndoRedoCommandVersion, type IUndoRedoStore, type UndoEntry, + type UndoRedoArchiveTrashRow, type UndoRedoCommandData, type UndoRedoCommandLeafData, + type UndoRedoSetButtonValueCommandData, type UndoRedoUpdateCommandData, type UndoRedoUpdateRecordsCommandData, type UndoScope, @@ -65,6 +73,13 @@ export type RecordSnapshotUndoRedoInput = { readonly redoCommandsBefore?: ReadonlyArray; }; +export type ButtonValueSnapshotUndoRedoInput = { + readonly tableId: TableId; + readonly recordId: RecordId; + readonly fieldId: string; + readonly snapshot: RecordUpdateSnapshot; +}; + export type RecordDeleteUndoRedoInput = { readonly tableId: TableId; readonly deletedRecords: ReadonlyArray; @@ -74,6 +89,14 @@ export type RecordDeleteUndoRedoInput = { readonly redoCommandsBefore?: ReadonlyArray; }; +export type RecordArchiveUndoRedoInput = { + readonly tableId: TableId; + readonly archivedRecords: ReadonlyArray; + readonly recordIds: ReadonlyArray; + readonly archiveRows: ReadonlyArray; + readonly groupId?: string; +}; + export type RecordCreateUndoRedoInput = { readonly tableId: TableId; readonly createdRecords: ReadonlyArray; @@ -180,13 +203,37 @@ const describeError = (error: unknown): string => { * those snapshots, translates them into stack entries, persists them into the * stack store, and replays stored commands back through the command bus. */ +/** + * Replay behavior knobs for undo/redo command reconstruction. + * + * restorePurgeGuard: when replaying a delete-undo as RestoreRecords, require a + * surviving record_trash row (reason 'deleted') per record so a replay cannot + * resurrect records the user purged from the recycle bin. The trash rows are + * written by the app layer (nestjs V2RecordTrashService), not by the v2 delete + * command itself — deployments without that sink (standalone v2 containers: + * e2e, devtools) must keep the guard off or every delete-undo silently + * restores nothing. Purging is only possible where the sink exists, so the + * guard is exactly as available as the risk it defends against. The archived + * variant is not gated: archive snapshots are written inside the v2 delete + * transaction itself. + */ +export interface IUndoRedoReplayConfig { + readonly restorePurgeGuard: boolean; +} + +export const defaultUndoRedoReplayConfig: IUndoRedoReplayConfig = { + restorePurgeGuard: false, +}; + @injectable() export class UndoRedoStackService { constructor( @inject(v2CoreTokens.undoRedoStore) private readonly undoRedoStore: IUndoRedoStore, @inject(v2CoreTokens.commandBus) - private readonly commandBus: CommandBusPort.ICommandBus + private readonly commandBus: CommandBusPort.ICommandBus, + @inject(v2CoreTokens.undoRedoReplayConfig) + private readonly replayConfig: IUndoRedoReplayConfig = defaultUndoRedoReplayConfig ) {} @TraceSpan({ @@ -276,6 +323,66 @@ export class UndoRedoStackService { }); } + @TraceSpan({ + component: 'service', + attributes: (_context, params: ButtonValueSnapshotUndoRedoInput) => ({ + [TeableSpanAttributes.TABLE_ID]: params.tableId.toString(), + [TeableSpanAttributes.RECORD_ID]: params.recordId.toString(), + [TeableSpanAttributes.FIELD_ID]: params.fieldId, + 'teable.undo_redo.mode': 'button_value_update_snapshot', + }), + }) + async appendButtonValueUpdateFromSnapshot( + context: UndoRedoStackAppendContext, + params: ButtonValueSnapshotUndoRedoInput + ): Promise> { + const normalizeValue = ( + value: unknown + ): Result<{ readonly count: number } | null, DomainError> => { + if (value == null) return ok(null); + if ( + typeof value !== 'object' || + Array.isArray(value) || + typeof (value as { count?: unknown }).count !== 'number' || + !Number.isInteger((value as { count: number }).count) || + (value as { count: number }).count < 0 + ) { + return err( + domainError.validation({ + code: 'button.undo_value_invalid', + message: `Invalid Button value captured for undo/redo: ${params.fieldId}`, + }) + ); + } + return ok({ count: (value as { count: number }).count }); + }; + + const oldValue = normalizeValue(params.snapshot.previous.fields[params.fieldId]); + if (oldValue.isErr()) return err(oldValue.error); + const newValue = normalizeValue(params.snapshot.current.fields[params.fieldId]); + if (newValue.isErr()) return err(newValue.error); + + const basePayload = { + tableId: params.tableId.toString(), + recordId: params.recordId.toString(), + fieldId: params.fieldId, + } as const; + const undoCommand: UndoRedoSetButtonValueCommandData = buildUndoRedoCommand('SetButtonValue', { + ...basePayload, + value: oldValue.value, + }); + const redoCommand: UndoRedoSetButtonValueCommandData = buildUndoRedoCommand('SetButtonValue', { + ...basePayload, + value: newValue.value, + }); + return this.appendEntry(context, params.tableId, { + undoCommand, + redoCommand, + recordVersionBefore: params.snapshot.oldVersion, + recordVersionAfter: params.snapshot.newVersion, + }); + } + @TraceSpan({ component: 'service', attributes: (_context, params: RecordDeleteUndoRedoInput) => ({ @@ -312,6 +419,36 @@ export class UndoRedoStackService { }); } + @TraceSpan({ + component: 'service', + attributes: (_context, params: RecordArchiveUndoRedoInput) => ({ + [TeableSpanAttributes.TABLE_ID]: params.tableId.toString(), + 'teable.undo_redo.mode': 'record_archive', + 'teable.undo_redo.record_count': params.archivedRecords.length, + }), + }) + async appendRecordArchive( + context: UndoRedoStackAppendContext, + params: RecordArchiveUndoRedoInput + ): Promise> { + if (!params.archivedRecords.length) { + return ok(undefined); + } + + return this.appendEntry(context, params.tableId, { + ...(params.groupId ? { groupId: params.groupId } : {}), + undoCommand: buildUndoRedoCommand('RestoreArchivedRecords', { + tableId: params.tableId.toString(), + records: params.archivedRecords.map((snapshot) => toUndoRedoRestoreRecord(snapshot)), + }), + redoCommand: buildUndoRedoCommand('ArchiveRecords', { + tableId: params.tableId.toString(), + recordIds: params.recordIds, + archiveRows: params.archiveRows, + }), + }); + } + @TraceSpan({ component: 'service', attributes: (_context, params: RecordCreateUndoRedoInput) => ({ @@ -489,6 +626,9 @@ export class UndoRedoStackService { case 'UpdateRecord': { return UpdateRecordCommand.create(commandData.payload); } + case 'SetButtonValue': { + return SetButtonValueCommand.create(commandData.payload); + } case 'UpdateRecords': { return UpdateRecordsCommand.create(commandData.payload); } @@ -496,7 +636,32 @@ export class UndoRedoStackService { return DeleteRecordsCommand.create(commandData.payload); } case 'RestoreRecords': { - return RestoreRecordsCommand.create(commandData.payload); + // requireTrashRowReason: the entry embeds full snapshots; a replay after the + // user emptied the recycle bin must not resurrect the purged records. Only + // deployments whose app layer writes the trash rows enable this — see + // IUndoRedoReplayConfig.restorePurgeGuard. + return RestoreRecordsCommand.create( + commandData.payload, + this.replayConfig.restorePurgeGuard + ? { requireTrashRowReason: RECORD_REMOVAL_REASON.Deleted } + : {} + ); + } + case 'RestoreArchivedRecords': { + // The kept attachment reference rows must go before the insert rebuilds them, + // and the restore's trash cleanup removes the archive snapshot rows. + // requireTrashRowReason: same purge guard as RestoreRecords, against the + // archived snapshot rows (permanently deleted archive items stay deleted). + return RestoreRecordsCommand.create(commandData.payload, { + cleanupAttachmentRefs: true, + requireTrashRowReason: RECORD_REMOVAL_REASON.Archived, + }); + } + case 'ArchiveRecords': { + return DeleteRecordsCommand.create( + { tableId: commandData.payload.tableId, recordIds: commandData.payload.recordIds }, + { removalReason: 'archived', archiveRows: commandData.payload.archiveRows } + ); } case 'ApplyRecordOrders': { return ApplyRecordOrdersCommand.create(commandData.payload); @@ -510,6 +675,18 @@ export class UndoRedoStackService { case 'ReplayFieldTypeConversion': { return ReplayFieldTypeConversionCommand.create(commandData.payload); } + case 'ApplyViewSnapshot': { + return ApplyViewSnapshotCommand.create(commandData.payload); + } + case 'DeleteView': { + return DeleteViewCommand.create(commandData.payload); + } + case 'EnableViewShare': { + return EnableViewShareCommand.create(commandData.payload); + } + case 'DisableViewShare': { + return DisableViewShareCommand.create(commandData.payload); + } case 'Batch': { return err(domainError.validation({ message: 'Batch undo/redo command must be expanded' })); } @@ -688,6 +865,10 @@ export class UndoRedoStackService { return commandData.payload.recordIds.length; case 'RestoreRecords': return commandData.payload.records.length; + case 'ArchiveRecords': + return commandData.payload.recordIds.length; + case 'RestoreArchivedRecords': + return commandData.payload.records.length; case 'ApplyRecordOrders': return commandData.payload.records.length; default: @@ -756,10 +937,14 @@ export class UndoRedoStackService { attributes[TeableSpanAttributes.TABLE_ID] = commandData.payload.tableId; } - if (commandData.type === 'UpdateRecord') { + if (commandData.type === 'UpdateRecord' || commandData.type === 'SetButtonValue') { attributes[TeableSpanAttributes.RECORD_ID] = commandData.payload.recordId; } + if (commandData.type === 'SetButtonValue') { + attributes[TeableSpanAttributes.FIELD_ID] = commandData.payload.fieldId; + } + if (commandData.type === 'UpdateRecords') { attributes['teable.undo_redo.record_count'] = commandData.payload.records.length; } @@ -775,6 +960,18 @@ export class UndoRedoStackService { attributes[TeableSpanAttributes.FIELD_ID] = commandData.payload.snapshot.field.id; } + if (commandData.type === 'ApplyViewSnapshot') { + attributes['teable.view_id'] = commandData.payload.snapshot.id; + } + + if ( + commandData.type === 'DeleteView' || + commandData.type === 'EnableViewShare' || + commandData.type === 'DisableViewShare' + ) { + attributes['teable.view_id'] = commandData.payload.viewId; + } + if (commandData.type === 'Batch') { attributes['teable.undo_redo.batch_size'] = commandData.payload.length; } diff --git a/packages/v2/core/src/application/services/ViewManualSortService.spec.ts b/packages/v2/core/src/application/services/ViewManualSortService.spec.ts new file mode 100644 index 0000000000..eea1cf2980 --- /dev/null +++ b/packages/v2/core/src/application/services/ViewManualSortService.spec.ts @@ -0,0 +1,142 @@ +import { ok, type Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import { BaseId } from '../../domain/base/BaseId'; +import { ActorId } from '../../domain/shared/ActorId'; +import type { DomainError } from '../../domain/shared/DomainError'; +import { FieldName } from '../../domain/table/fields/FieldName'; +import { SetRowOrderValueSpec } from '../../domain/table/records/specs/values/SetRowOrderValueSpec'; +import { Table } from '../../domain/table/Table'; +import { TableId } from '../../domain/table/TableId'; +import { TableName } from '../../domain/table/TableName'; +import { TableEnsureViewRowOrderSpec } from '../../domain/table/specs/TableEnsureViewRowOrderSpec'; +import type { IExecutionContext } from '../../ports/ExecutionContext'; +import type { TableRecordReadModel } from '../../ports/TableRecordReadModel'; +import type { ITableRecordQueryRepository } from '../../ports/TableRecordQueryRepository'; +import type { + ITableRecordRepository, + UpdateManyStreamBatchInput, +} from '../../ports/TableRecordRepository'; +import { isUpdateManyStreamBatch } from '../../ports/TableRecordRepository'; +import type { ITableSchemaRepository } from '../../ports/TableSchemaRepository'; +import type { IUnitOfWork } from '../../ports/UnitOfWork'; +import { ViewManualSortService } from './ViewManualSortService'; + +const context: IExecutionContext = { actorId: ActorId.create('actor')._unsafeUnwrap() }; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'b'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Manual sort records')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +describe('ViewManualSortService', () => { + it('prepares aggregate-declared row-order storage in an isolated data transaction', async () => { + const table = buildTable(); + const storageSpec = TableEnsureViewRowOrderSpec.create(table.views()[0]!); + const transactionContext = { ...context, transaction: {} } as IExecutionContext; + const update = vi.fn(async () => ok(table)); + const withTransaction = vi.fn( + async ( + _context: IExecutionContext, + work: (context: IExecutionContext) => Promise> + ) => work(transactionContext) + ); + const service = new ViewManualSortService( + { update } as unknown as ITableSchemaRepository, + { withTransaction } as unknown as IUnitOfWork, + {} as ITableRecordQueryRepository, + {} as ITableRecordRepository + ); + + const result = await service.prepareStorage(context, table, storageSpec); + + expect(result.isOk()).toBe(true); + expect(withTransaction).toHaveBeenCalledWith(context, expect.any(Function), { + scope: 'data', + }); + expect(update).toHaveBeenCalledWith(transactionContext, table, storageSpec); + }); + + it('uses TableRecord query/write repositories and skips unchanged row orders', async () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const records: TableRecordReadModel[] = [ + { + id: `rec${'a'.repeat(16)}`, + fields: {}, + version: 1, + autoNumber: 2, + orders: { [viewId.toString()]: 2 }, + }, + { + id: `rec${'b'.repeat(16)}`, + fields: {}, + version: 1, + autoNumber: 1, + orders: { [viewId.toString()]: 1 }, + }, + ]; + const findStream = vi.fn(async function* ( + ..._args: Parameters + ): AsyncIterable> { + for (const record of records) yield ok(record); + }); + const updates: Array<{ recordId: string; order: number }> = []; + const updateManyStream = vi.fn( + async ( + _context: IExecutionContext, + _table: Table, + batches: + | Iterable> + | AsyncIterable> + ) => { + for await (const batchResult of batches) { + if (batchResult.isErr()) return batchResult; + const batch = isUpdateManyStreamBatch(batchResult.value) + ? batchResult.value.updates + : batchResult.value; + for (const update of batch) { + const spec = update.mutateSpec as SetRowOrderValueSpec; + updates.push({ recordId: update.record.id().toString(), order: spec.orderValue }); + } + } + return ok({ totalUpdated: updates.length, updatedRecords: [] }); + } + ); + const service = new ViewManualSortService( + {} as ITableSchemaRepository, + {} as IUnitOfWork, + { findStream } as unknown as ITableRecordQueryRepository, + { updateManyStream } as unknown as ITableRecordRepository + ); + + const result = await service.materialize(context, table, viewId, [ + { fieldId: table.getFields()[0]!.id().toString(), order: 'desc' }, + ]); + + expect(result._unsafeUnwrap()).toEqual({ updatedCount: 2 }); + expect(updates).toEqual([ + { recordId: records[0]!.id, order: 1 }, + { recordId: records[1]!.id, order: 2 }, + ]); + expect(findStream).toHaveBeenCalledWith( + context, + table, + undefined, + expect.objectContaining({ + mode: 'stored', + includeOrders: true, + projectionFieldIds: [], + orderBy: [ + { fieldId: table.getFields()[0]!.id(), direction: 'desc' }, + { column: '__auto_number', direction: 'asc' }, + ], + }) + ); + }); +}); diff --git a/packages/v2/core/src/application/services/ViewManualSortService.ts b/packages/v2/core/src/application/services/ViewManualSortService.ts new file mode 100644 index 0000000000..90f01557ae --- /dev/null +++ b/packages/v2/core/src/application/services/ViewManualSortService.ts @@ -0,0 +1,129 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok, type Result } from 'neverthrow'; + +import { mergeOrderBy, resolveOrderBy } from '../../commands/shared/orderBy'; +import type { DomainError } from '../../domain/shared/DomainError'; +import type { ISpecification } from '../../domain/shared/specification/ISpecification'; +import { RecordUpdateResult } from '../../domain/table/records/RecordUpdateResult'; +import { RecordId } from '../../domain/table/records/RecordId'; +import { SetRowOrderValueSpec } from '../../domain/table/records/specs/values/SetRowOrderValueSpec'; +import { TableRecord } from '../../domain/table/records/TableRecord'; +import type { Table } from '../../domain/table/Table'; +import type { ITableSpecVisitor } from '../../domain/table/specs/ITableSpecVisitor'; +import type { ViewId } from '../../domain/table/views/ViewId'; +import type { ViewSortItem } from '../../domain/table/views/ViewSort'; +import type * as ExecutionContextPort from '../../ports/ExecutionContext'; +import * as TableRecordQueryRepositoryPort from '../../ports/TableRecordQueryRepository'; +import * as TableRecordRepositoryPort from '../../ports/TableRecordRepository'; +import * as TableSchemaRepositoryPort from '../../ports/TableSchemaRepository'; +import { v2CoreTokens } from '../../ports/tokens'; +import type * as UnitOfWorkPort from '../../ports/UnitOfWork'; + +export type ViewManualSortMaterializeResult = { + readonly updatedCount: number; +}; + +@injectable() +export class ViewManualSortService { + private static readonly UPDATE_BATCH_SIZE = 500; + + constructor( + @inject(v2CoreTokens.tableSchemaRepository) + private readonly tableSchemaRepository: TableSchemaRepositoryPort.ITableSchemaRepository, + @inject(v2CoreTokens.unitOfWork) + private readonly unitOfWork: UnitOfWorkPort.IUnitOfWork, + @inject(v2CoreTokens.tableRecordQueryRepository) + private readonly tableRecordQueryRepository: TableRecordQueryRepositoryPort.ITableRecordQueryRepository, + @inject(v2CoreTokens.tableRecordRepository) + private readonly tableRecordRepository: TableRecordRepositoryPort.ITableRecordRepository + ) {} + + async prepareStorage( + context: ExecutionContextPort.IExecutionContext, + table: Table, + storageSpec: ISpecification + ): Promise> { + return this.unitOfWork.withTransaction( + context, + async (transactionContext) => + (await this.tableSchemaRepository.update(transactionContext, table, storageSpec)).map( + () => undefined + ), + { scope: 'data' } + ); + } + + async materialize( + context: ExecutionContextPort.IExecutionContext, + table: Table, + viewId: ViewId, + sort: ReadonlyArray + ): Promise> { + const resolvedSort = resolveOrderBy(sort); + if (resolvedSort.isErr()) return err(resolvedSort.error); + const orderBy = mergeOrderBy(undefined, resolvedSort.value, undefined); + const batches = this.buildUpdateBatches(context, table, viewId, orderBy); + const updateResult = await this.tableRecordRepository.updateManyStream(context, table, batches); + return updateResult.map((result) => ({ updatedCount: result.totalUpdated })); + } + + private async *buildUpdateBatches( + context: ExecutionContextPort.IExecutionContext, + table: Table, + viewId: ViewId, + orderBy: ReadonlyArray | undefined + ): AsyncGenerator, DomainError>> { + const viewIdText = viewId.toString(); + const records = this.tableRecordQueryRepository.findStream(context, table, undefined, { + mode: 'stored', + orderBy, + includeOrders: true, + projectionFieldIds: [], + batchSize: ViewManualSortService.UPDATE_BATCH_SIZE, + }); + let nextOrder = 1; + let batch: RecordUpdateResult[] = []; + + for await (const recordResult of records) { + if (recordResult.isErr()) { + yield err(recordResult.error); + return; + } + + const record = recordResult.value; + const previousOrder = record.orders?.[viewIdText]; + if (previousOrder !== nextOrder) { + const recordIdResult = RecordId.create(record.id); + if (recordIdResult.isErr()) { + yield err(recordIdResult.error); + return; + } + const tableRecordResult = TableRecord.create({ + id: recordIdResult.value, + tableId: table.id(), + fieldValues: [], + }); + if (tableRecordResult.isErr()) { + yield err(tableRecordResult.error); + return; + } + batch.push( + RecordUpdateResult.create( + tableRecordResult.value, + new SetRowOrderValueSpec(viewId, nextOrder) + ) + ); + } + nextOrder += 1; + + if (batch.length >= ViewManualSortService.UPDATE_BATCH_SIZE) { + yield ok(batch); + batch = []; + } + } + + if (batch.length > 0) { + yield ok(batch); + } + } +} diff --git a/packages/v2/core/src/application/services/ViewPluginCreationService.spec.ts b/packages/v2/core/src/application/services/ViewPluginCreationService.spec.ts new file mode 100644 index 0000000000..cb8fb6345b --- /dev/null +++ b/packages/v2/core/src/application/services/ViewPluginCreationService.spec.ts @@ -0,0 +1,196 @@ +import { err, ok } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import { BaseId } from '../../domain/base/BaseId'; +import { ActorId } from '../../domain/shared/ActorId'; +import { domainError } from '../../domain/shared/DomainError'; +import { FieldName } from '../../domain/table/fields/FieldName'; +import { Table } from '../../domain/table/Table'; +import { TableName } from '../../domain/table/TableName'; +import type { IExecutionContext } from '../../ports/ExecutionContext'; +import type { IViewPluginRepository } from '../../ports/ViewPluginRepository'; +import { ViewPluginCreationService } from './ViewPluginCreationService'; + +const context: IExecutionContext = { + actorId: ActorId.create('actor')._unsafeUnwrap(), +}; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Plugins')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +const buildRepository = (): IViewPluginRepository => ({ + findViewPlugin: vi.fn(async () => + ok({ + id: 'plg-view', + name: 'Plugin default', + logo: 'https://example.test/logo.png', + }) + ), + insertViewPluginInstallation: vi.fn(async () => ok(undefined)), + findViewPluginInstallationByViewId: vi.fn(async () => + ok({ + storage: JSON.stringify({ copied: true }), + }) + ), + getViewPluginInstallation: vi.fn(async () => err(domainError.notFound({ message: 'Not used' }))), + updateViewPluginStorage: vi.fn(async () => err(domainError.notFound({ message: 'Not used' }))), +}); + +describe('ViewPluginCreationService', () => { + it('leaves non-Plugin creation input untouched', async () => { + const repository = buildRepository(); + const service = new ViewPluginCreationService(repository); + const input = { type: 'grid' as const, name: 'Grid' }; + + const prepared = await service.prepare(context, buildTable(), input); + + expect(prepared._unsafeUnwrap()).toEqual({ input }); + expect(repository.findViewPlugin).not.toHaveBeenCalled(); + }); + + it('resolves Plugin defaults and completes the installation from the created View', async () => { + const repository = buildRepository(); + const service = new ViewPluginCreationService(repository); + const table = buildTable(); + + const prepared = ( + await service.prepare(context, table, { + type: 'plugin', + name: '', + options: { + pluginId: 'plg-view', + pluginInstallId: 'ignored', + pluginLogo: 'ignored', + }, + }) + )._unsafeUnwrap(); + const created = table.createView(prepared.input)._unsafeUnwrap().view; + const installation = service.completeInstallation(prepared, created); + + expect(prepared.input).toMatchObject({ + type: 'plugin', + name: 'Plugin default', + options: { + pluginId: 'plg-view', + pluginLogo: 'https://example.test/logo.png', + }, + }); + expect(prepared.input.options).not.toMatchObject({ pluginInstallId: 'ignored' }); + expect(installation).toMatchObject({ + pluginId: 'plg-view', + baseId: table.baseId().toString(), + viewId: created.id().toString(), + name: 'Plugin default', + }); + expect(installation?.id).toBe( + (prepared.input.options as { pluginInstallId: string }).pluginInstallId + ); + }); + + it('rejects a Plugin View without pluginId and propagates repository failures', async () => { + const missingIdService = new ViewPluginCreationService(buildRepository()); + const missingId = await missingIdService.prepare(context, buildTable(), { + type: 'plugin', + options: {}, + }); + expect(missingId.isErr()).toBe(true); + + const repository = buildRepository(); + vi.mocked(repository.findViewPlugin).mockResolvedValue( + err(domainError.notFound({ message: 'Plugin not found' })) + ); + const missingPluginService = new ViewPluginCreationService(repository); + const missingPlugin = await missingPluginService.prepare(context, buildTable(), { + type: 'plugin', + options: { pluginId: 'missing' }, + }); + expect(missingPlugin._unsafeUnwrapErr().code).toBe('not_found'); + }); + + it('leaves non-Plugin duplication input empty without touching the integration repository', async () => { + const repository = buildRepository(); + const service = new ViewPluginCreationService(repository); + const table = buildTable(); + + const prepared = await service.prepareDuplicate(context, table, table.views()[0]!.id()); + + expect(prepared._unsafeUnwrap()).toEqual({ input: {} }); + expect(repository.findViewPlugin).not.toHaveBeenCalled(); + expect(repository.findViewPluginInstallationByViewId).not.toHaveBeenCalled(); + }); + + it('prepares a new Plugin installation and preserves the source storage by View identity', async () => { + const repository = buildRepository(); + const service = new ViewPluginCreationService(repository); + const table = buildTable(); + const source = table + .createView({ + type: 'plugin', + name: 'Plugin', + options: { + pluginId: 'plg-view', + pluginInstallId: 'pli-stale-option', + pluginLogo: 'old-logo', + }, + }) + ._unsafeUnwrap(); + const currentTable = source.updateResult.table; + + const prepared = ( + await service.prepareDuplicate(context, currentTable, source.view.id()) + )._unsafeUnwrap(); + const duplicated = currentTable + .duplicateView(source.view.id(), prepared.input) + ._unsafeUnwrap().view; + const installation = service.completeInstallation(prepared, duplicated); + + expect(repository.findViewPluginInstallationByViewId).toHaveBeenCalledWith( + context, + source.view.id().toString() + ); + expect(prepared.input.pluginOptions).toMatchObject({ + pluginId: 'plg-view', + pluginLogo: 'https://example.test/logo.png', + }); + expect(prepared.input.pluginOptions?.pluginInstallId).not.toBe('pli-stale-option'); + expect(installation).toMatchObject({ + id: prepared.input.pluginOptions?.pluginInstallId, + viewId: duplicated.id().toString(), + name: 'Plugin 2', + storage: JSON.stringify({ copied: true }), + }); + }); + + it('propagates a missing source Plugin installation', async () => { + const repository = buildRepository(); + vi.mocked(repository.findViewPluginInstallationByViewId).mockResolvedValue( + err(domainError.notFound({ message: 'Plugin installation not found' })) + ); + const service = new ViewPluginCreationService(repository); + const table = buildTable(); + const source = table + .createView({ + type: 'plugin', + options: { + pluginId: 'plg-view', + pluginInstallId: 'pli-source', + pluginLogo: 'old-logo', + }, + }) + ._unsafeUnwrap(); + + const result = await service.prepareDuplicate( + context, + source.updateResult.table, + source.view.id() + ); + + expect(result._unsafeUnwrapErr().code).toBe('not_found'); + }); +}); diff --git a/packages/v2/core/src/application/services/ViewPluginCreationService.ts b/packages/v2/core/src/application/services/ViewPluginCreationService.ts new file mode 100644 index 0000000000..63dca0b1ab --- /dev/null +++ b/packages/v2/core/src/application/services/ViewPluginCreationService.ts @@ -0,0 +1,146 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok, type Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../domain/shared/DomainError'; +import { generatePrefixedId } from '../../domain/shared/IdGenerator'; +import type { + Table, + TableCreateViewInput, + TableDuplicateViewOptions, +} from '../../domain/table/Table'; +import type { View } from '../../domain/table/views/View'; +import type { ViewId } from '../../domain/table/views/ViewId'; +import type { IExecutionContext } from '../../ports/ExecutionContext'; +import { v2CoreTokens } from '../../ports/tokens'; +import { + type IViewPluginRepository, + type ViewPluginInstallation, +} from '../../ports/ViewPluginRepository'; + +type ViewPluginInstallationSeed = Omit; + +export type PreparedViewCreation = { + readonly input: TableCreateViewInput; + readonly pluginInstallation?: ViewPluginInstallationSeed; +}; + +export type PreparedViewDuplication = { + readonly input: TableDuplicateViewOptions; + readonly pluginInstallation?: ViewPluginInstallationSeed; +}; + +@injectable() +export class ViewPluginCreationService { + constructor( + @inject(v2CoreTokens.viewPluginRepository) + private readonly viewPluginRepository: IViewPluginRepository + ) {} + + async prepare( + context: IExecutionContext, + table: Table, + input: TableCreateViewInput + ): Promise> { + if (input.type !== 'plugin') return ok({ input }); + + const options = + input.options && typeof input.options === 'object' && !Array.isArray(input.options) + ? (input.options as Record) + : {}; + const pluginId = options.pluginId; + if (typeof pluginId !== 'string') { + return err(domainError.validation({ message: 'Plugin View requires pluginId' })); + } + + const pluginResult = await this.viewPluginRepository.findViewPlugin(context, pluginId); + if (pluginResult.isErr()) return err(pluginResult.error); + + const plugin = pluginResult.value; + const pluginInstallId = generatePrefixedId('pli', 16); + return ok({ + input: { + ...input, + name: input.name || plugin.name, + options: { + pluginId: plugin.id, + pluginInstallId, + pluginLogo: plugin.logo, + }, + }, + pluginInstallation: { + id: pluginInstallId, + pluginId: plugin.id, + baseId: table.baseId().toString(), + }, + }); + } + + async prepareDuplicate( + context: IExecutionContext, + table: Table, + sourceViewId: ViewId + ): Promise> { + const sourceViewResult = table.getView(sourceViewId); + if (sourceViewResult.isErr()) return err(sourceViewResult.error); + const sourceView = sourceViewResult.value; + if (sourceView.type().toString() !== 'plugin') return ok({ input: {} }); + + const options = + sourceView.options() && + typeof sourceView.options() === 'object' && + !Array.isArray(sourceView.options()) + ? (sourceView.options() as Record) + : {}; + const pluginId = options.pluginId; + if (typeof pluginId !== 'string') { + return err(domainError.validation({ message: 'Plugin View requires pluginId' })); + } + + const pluginResult = await this.viewPluginRepository.findViewPlugin(context, pluginId); + if (pluginResult.isErr()) return err(pluginResult.error); + const sourceInstallationResult = + await this.viewPluginRepository.findViewPluginInstallationByViewId( + context, + sourceViewId.toString() + ); + if (sourceInstallationResult.isErr()) return err(sourceInstallationResult.error); + + const plugin = pluginResult.value; + const sourceInstallation = sourceInstallationResult.value; + const pluginInstallId = generatePrefixedId('pli', 16); + return ok({ + input: { + pluginOptions: { + pluginId: plugin.id, + pluginInstallId, + pluginLogo: plugin.logo, + }, + }, + pluginInstallation: { + id: pluginInstallId, + pluginId: plugin.id, + baseId: table.baseId().toString(), + storage: sourceInstallation.storage, + }, + }); + } + + completeInstallation( + prepared: PreparedViewCreation | PreparedViewDuplication, + view: View + ): ViewPluginInstallation | undefined { + if (!prepared.pluginInstallation) return undefined; + return { + ...prepared.pluginInstallation, + viewId: view.id().toString(), + name: view.name().toString(), + }; + } + + insertInstallation( + context: IExecutionContext, + installation: ViewPluginInstallation + ): Promise> { + return this.viewPluginRepository.insertViewPluginInstallation(context, installation); + } +} diff --git a/packages/v2/core/src/application/services/ViewUndoRedoService.spec.ts b/packages/v2/core/src/application/services/ViewUndoRedoService.spec.ts new file mode 100644 index 0000000000..7a87e16529 --- /dev/null +++ b/packages/v2/core/src/application/services/ViewUndoRedoService.spec.ts @@ -0,0 +1,149 @@ +import { ok } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import { BaseId } from '../../domain/base/BaseId'; +import { ActorId } from '../../domain/shared/ActorId'; +import { FieldName } from '../../domain/table/fields/FieldName'; +import { Table } from '../../domain/table/Table'; +import { TableName } from '../../domain/table/TableName'; +import { ViewColumnMeta } from '../../domain/table/views/ViewColumnMeta'; +import { ViewName } from '../../domain/table/views/ViewName'; +import { ViewOrder } from '../../domain/table/views/ViewOrder'; +import { ViewQueryDefaults } from '../../domain/table/views/ViewQueryDefaults'; +import type { IExecutionContext } from '../../ports/ExecutionContext'; +import type { UndoEntry } from '../../ports/UndoRedoStore'; +import { ViewUndoRedoService } from './ViewUndoRedoService'; + +const buildTable = () => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'v'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + builder.view().grid().withName(ViewName.create('Second')._unsafeUnwrap()).done(); + const table = builder.build()._unsafeUnwrap(); + const fieldId = table.primaryFieldId().toString(); + table.views().forEach((view, index) => { + view.setColumnMeta( + ViewColumnMeta.create({ [fieldId]: { order: 0, width: 200 + index } })._unsafeUnwrap() + ); + view.setQueryDefaults(ViewQueryDefaults.rehydrate({})._unsafeUnwrap()); + view.setOptions({ rowHeight: index === 0 ? 'short' : 'tall' }); + view.setOrder(ViewOrder.rehydrate(index)._unsafeUnwrap()); + }); + return table; +}; + +const context: IExecutionContext = { + actorId: ActorId.create('actor')._unsafeUnwrap(), + windowId: 'window', +}; + +const setup = () => { + const appendEntry = vi.fn(async (..._args: unknown[]) => ok(undefined)); + const service = new ViewUndoRedoService({ appendEntry } as never); + return { service, appendEntry }; +}; + +describe('ViewUndoRedoService', () => { + it('captures replayable View child state and records create/delete commands', async () => { + const table = buildTable(); + const { service, appendEntry } = setup(); + const snapshot = service.capture(table, table.views()[0]!.id().toString())._unsafeUnwrap(); + + expect(snapshot).toMatchObject({ + id: table.views()[0]!.id().toString(), + type: 'grid', + order: 0, + columnMeta: { + [table.primaryFieldId().toString()]: { order: 0 }, + }, + options: { rowHeight: 'short' }, + }); + + await service.appendCreate(context, table, snapshot); + let entry = appendEntry.mock.calls[0]![2] as Omit< + UndoEntry, + 'scope' | 'createdAt' | 'requestId' + >; + expect(entry.undoCommand).toMatchObject({ + type: 'DeleteView', + payload: { tableId: table.id().toString(), viewId: snapshot.id }, + }); + expect(entry.redoCommand).toMatchObject({ + type: 'ApplyViewSnapshot', + payload: { snapshot }, + }); + + await service.appendDelete(context, table, snapshot); + entry = appendEntry.mock.calls[1]![2] as Omit; + expect(entry.undoCommand.type).toBe('ApplyViewSnapshot'); + expect(entry.redoCommand.type).toBe('DeleteView'); + }); + + it('records every changed View as one batch and ignores audit-only changes', async () => { + const table = buildTable(); + const { service, appendEntry } = setup(); + const previous = service.captureAll(table)._unsafeUnwrap(); + const changed = previous.map((snapshot, index) => ({ + ...snapshot, + order: (snapshot.order ?? 0) + 10, + auditMetadata: { + createdBy: 'actor', + createdTime: 'now', + lastModifiedTime: `later-${index}`, + }, + })); + + await service.appendUpdate(context, table, previous, changed); + const entry = appendEntry.mock.calls[0]![2] as Omit< + UndoEntry, + 'scope' | 'createdAt' | 'requestId' + >; + expect(entry.undoCommand.type).toBe('Batch'); + expect(entry.redoCommand.type).toBe('Batch'); + if (entry.undoCommand.type !== 'Batch') throw new Error('Expected a batch'); + expect(entry.undoCommand.payload).toHaveLength(2); + expect(entry.undoCommand.payload.every((command) => command.type === 'ApplyViewSnapshot')).toBe( + true + ); + + appendEntry.mockClear(); + const auditOnly = previous.map((snapshot) => ({ + ...snapshot, + auditMetadata: { + createdBy: 'actor', + createdTime: 'now', + lastModifiedTime: 'later', + }, + })); + await service.appendUpdate(context, table, previous, auditOnly); + expect(appendEntry).not.toHaveBeenCalled(); + }); + + it('records share lifecycle commands without persisting a revoked credential', async () => { + const table = buildTable(); + const viewId = table.views()[0]!.id().toString(); + const { service, appendEntry } = setup(); + + await service.appendShareLifecycle(context, table, viewId, 'enable'); + let entry = appendEntry.mock.calls[0]![2] as Omit< + UndoEntry, + 'scope' | 'createdAt' | 'requestId' + >; + expect(entry.undoCommand).toMatchObject({ + type: 'DisableViewShare', + payload: { tableId: table.id().toString(), viewId }, + }); + expect(entry.redoCommand).toMatchObject({ + type: 'EnableViewShare', + payload: { tableId: table.id().toString(), viewId }, + }); + + await service.appendShareLifecycle(context, table, viewId, 'disable'); + entry = appendEntry.mock.calls[1]![2] as Omit; + expect(entry.undoCommand.type).toBe('EnableViewShare'); + expect(entry.redoCommand.type).toBe('DisableViewShare'); + expect(JSON.stringify(entry)).not.toContain('shr'); + }); +}); diff --git a/packages/v2/core/src/application/services/ViewUndoRedoService.ts b/packages/v2/core/src/application/services/ViewUndoRedoService.ts new file mode 100644 index 0000000000..7a1853b501 --- /dev/null +++ b/packages/v2/core/src/application/services/ViewUndoRedoService.ts @@ -0,0 +1,149 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../domain/shared/DomainError'; +import type { Table } from '../../domain/table/Table'; +import { captureViewSnapshot, type ViewSnapshotValue } from '../../domain/table/views/ViewSnapshot'; +import type * as ExecutionContextPort from '../../ports/ExecutionContext'; +import { v2CoreTokens } from '../../ports/tokens'; +import { + composeUndoRedoCommands, + createUndoRedoCommand, + type UndoRedoCommandLeafData, +} from '../../ports/UndoRedoStore'; +import { toUndoRedoStackAppendContext, UndoRedoStackService } from './UndoRedoStackService'; + +const withoutAuditMetadata = ({ + auditMetadata: _auditMetadata, + ...snapshot +}: ViewSnapshotValue): Omit => snapshot; + +@injectable() +export class ViewUndoRedoService { + constructor( + @inject(v2CoreTokens.undoRedoService) + private readonly undoRedoStackService: UndoRedoStackService + ) {} + + capture(table: Table, viewId: string): Result { + return table.getViewById(viewId).andThen(captureViewSnapshot); + } + + captureAll(table: Table): Result, DomainError> { + const snapshots: ViewSnapshotValue[] = []; + for (const view of table.views()) { + const snapshotResult = captureViewSnapshot(view); + if (snapshotResult.isErr()) return err(snapshotResult.error); + snapshots.push(snapshotResult.value); + } + return ok(snapshots); + } + + async appendCreate( + context: ExecutionContextPort.IExecutionContext, + table: Table, + snapshot: ViewSnapshotValue + ): Promise> { + return this.undoRedoStackService.appendEntry( + toUndoRedoStackAppendContext(context), + table.id(), + { + undoCommand: createUndoRedoCommand('DeleteView', { + tableId: table.id().toString(), + viewId: snapshot.id, + }), + redoCommand: createUndoRedoCommand('ApplyViewSnapshot', { + tableId: table.id().toString(), + snapshot, + }), + } + ); + } + + async appendDelete( + context: ExecutionContextPort.IExecutionContext, + table: Table, + snapshot: ViewSnapshotValue + ): Promise> { + return this.undoRedoStackService.appendEntry( + toUndoRedoStackAppendContext(context), + table.id(), + { + undoCommand: createUndoRedoCommand('ApplyViewSnapshot', { + tableId: table.id().toString(), + snapshot, + }), + redoCommand: createUndoRedoCommand('DeleteView', { + tableId: table.id().toString(), + viewId: snapshot.id, + }), + } + ); + } + + async appendUpdate( + context: ExecutionContextPort.IExecutionContext, + table: Table, + previousSnapshots: ReadonlyArray, + nextSnapshots: ReadonlyArray + ): Promise> { + const previousById = new Map(previousSnapshots.map((snapshot) => [snapshot.id, snapshot])); + const changedPairs = nextSnapshots.flatMap((next) => { + const previous = previousById.get(next.id); + if ( + !previous || + JSON.stringify(withoutAuditMetadata(previous)) === + JSON.stringify(withoutAuditMetadata(next)) + ) { + return []; + } + return [{ previous, next }]; + }); + if (changedPairs.length === 0) return ok(undefined); + + const undoCommands: UndoRedoCommandLeafData[] = changedPairs.map(({ previous }) => + createUndoRedoCommand('ApplyViewSnapshot', { + tableId: table.id().toString(), + snapshot: previous, + }) + ); + const redoCommands: UndoRedoCommandLeafData[] = changedPairs.map(({ next }) => + createUndoRedoCommand('ApplyViewSnapshot', { + tableId: table.id().toString(), + snapshot: next, + }) + ); + + return this.undoRedoStackService.appendEntry( + toUndoRedoStackAppendContext(context), + table.id(), + { + undoCommand: composeUndoRedoCommands(undoCommands), + redoCommand: composeUndoRedoCommands(redoCommands), + } + ); + } + + async appendShareLifecycle( + context: ExecutionContextPort.IExecutionContext, + table: Table, + viewId: string, + action: 'enable' | 'disable' + ): Promise> { + const payload = { tableId: table.id().toString(), viewId }; + return this.undoRedoStackService.appendEntry( + toUndoRedoStackAppendContext(context), + table.id(), + action === 'enable' + ? { + undoCommand: createUndoRedoCommand('DisableViewShare', payload), + redoCommand: createUndoRedoCommand('EnableViewShare', payload), + } + : { + undoCommand: createUndoRedoCommand('EnableViewShare', payload), + redoCommand: createUndoRedoCommand('DisableViewShare', payload), + } + ); + } +} diff --git a/packages/v2/core/src/application/services/presignAttachmentCellValue.spec.ts b/packages/v2/core/src/application/services/presignAttachmentCellValue.spec.ts new file mode 100644 index 0000000000..161aded5d4 --- /dev/null +++ b/packages/v2/core/src/application/services/presignAttachmentCellValue.spec.ts @@ -0,0 +1,103 @@ +import { ok } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { IAttachmentUrlSignerService } from '../../ports/AttachmentUrlSignerService'; +import { + normalizeAttachmentCellValue, + presignAttachmentCellValue, + presignAttachmentFieldMaps, +} from './presignAttachmentCellValue'; + +const item = { + id: 'att1', + name: 'photo.png', + path: 'table/photo.png', + token: 'tok-photo', + size: 12, + mimetype: 'image/png', +}; + +describe('presignAttachmentCellValue', () => { + it('normalizes single object and array cells', () => { + expect(normalizeAttachmentCellValue(null)).toBeNull(); + expect(normalizeAttachmentCellValue(item)).toEqual([item]); + expect(normalizeAttachmentCellValue([item])).toEqual([item]); + expect(normalizeAttachmentCellValue('x')).toBeNull(); + }); + + it('signs a cell via the port without needing a service instance', async () => { + const signer: IAttachmentUrlSignerService = { + signItems: vi.fn().mockResolvedValue( + ok( + new Map([ + [ + 'tok-photo', + { + presignedUrl: 'https://cdn.example/photo', + smThumbnailUrl: 'https://cdn.example/photo-sm', + lgThumbnailUrl: 'https://cdn.example/photo-lg', + }, + ], + ]) + ) + ), + invalidatePreview: vi.fn().mockResolvedValue(ok(undefined)), + }; + + const result = await presignAttachmentCellValue([item], signer); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toEqual([ + { + ...item, + presignedUrl: 'https://cdn.example/photo', + smThumbnailUrl: 'https://cdn.example/photo-sm', + lgThumbnailUrl: 'https://cdn.example/photo-lg', + }, + ]); + expect(signer.signItems).toHaveBeenCalledTimes(1); + }); + + it('batch-signs attachment keys across records with one signer call', async () => { + const signer: IAttachmentUrlSignerService = { + signItems: vi.fn().mockResolvedValue( + ok( + new Map([ + ['tok-photo', { presignedUrl: 'https://cdn.example/photo' }], + ['tok-doc', { presignedUrl: 'https://cdn.example/doc' }], + ]) + ) + ), + invalidatePreview: vi.fn().mockResolvedValue(ok(undefined)), + }; + + const doc = { + id: 'att2', + name: 'doc.pdf', + path: 'table/doc.pdf', + token: 'tok-doc', + size: 3, + mimetype: 'application/pdf', + }; + + const result = await presignAttachmentFieldMaps( + [ + { Title: 'a', Files: [item] }, + { Title: 'b', Files: [doc], Files2: [item] }, + ], + new Set(['Files', 'Files2']), + signer + ); + + expect(result.isOk()).toBe(true); + const maps = result._unsafeUnwrap(); + expect(maps[0]?.Files).toEqual([{ ...item, presignedUrl: 'https://cdn.example/photo' }]); + expect(maps[1]?.Files).toEqual([{ ...doc, presignedUrl: 'https://cdn.example/doc' }]); + expect(maps[1]?.Files2).toEqual([{ ...item, presignedUrl: 'https://cdn.example/photo' }]); + // Deduped tokens → single batch call. + expect(signer.signItems).toHaveBeenCalledTimes(1); + const signedTokens = (signer.signItems as ReturnType).mock.calls[0]![0].map( + (r: { token: string }) => r.token + ); + expect(signedTokens.sort()).toEqual(['tok-doc', 'tok-photo']); + }); +}); diff --git a/packages/v2/core/src/application/services/presignAttachmentCellValue.ts b/packages/v2/core/src/application/services/presignAttachmentCellValue.ts new file mode 100644 index 0000000000..5ed39c4547 --- /dev/null +++ b/packages/v2/core/src/application/services/presignAttachmentCellValue.ts @@ -0,0 +1,157 @@ +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../domain/shared/DomainError'; +import type { + AttachmentSignedUrls, + AttachmentSignRequest, + IAttachmentUrlSignerService, +} from '../../ports/AttachmentUrlSignerService'; + +/** + * Minimal attachment item shape used for read-path presentation signing. + * Matches stored / OpenAPI attachment cell objects without depending on V1 types. + */ +export type AttachmentCellItem = { + readonly token?: string; + readonly path?: string; + readonly name?: string; + readonly mimetype?: string; + readonly presignedUrl?: string; + readonly smThumbnailUrl?: string; + readonly lgThumbnailUrl?: string; + readonly [key: string]: unknown; +}; + +export type AttachmentCellValue = ReadonlyArray; + +/** + * Normalize a stored attachment cell (array or single object) to an array, or null. + * Pure: no I/O, no Nest / RecordService coupling. + */ +export const normalizeAttachmentCellValue = (cellValue: unknown): AttachmentCellValue | null => { + if (cellValue == null) { + return null; + } + if (Array.isArray(cellValue)) { + return cellValue as AttachmentCellValue; + } + if (typeof cellValue === 'object') { + return [cellValue as AttachmentCellItem]; + } + return null; +}; + +const extractSignRequests = (items: AttachmentCellValue): AttachmentSignRequest[] => { + const requests: AttachmentSignRequest[] = []; + for (const item of items) { + if (!item.token || !item.path || !item.mimetype) { + continue; + } + requests.push({ + token: item.token, + path: item.path, + mimetype: item.mimetype, + name: item.name, + }); + } + return requests; +}; + +const applySignedUrls = ( + items: AttachmentCellValue, + signed: ReadonlyMap +): AttachmentCellValue => + items.map((item) => { + if (!item.token) { + return item; + } + const urls = signed.get(item.token); + if (!urls) { + return item; + } + return { + ...item, + ...(urls.presignedUrl !== undefined ? { presignedUrl: urls.presignedUrl } : {}), + ...(urls.smThumbnailUrl !== undefined ? { smThumbnailUrl: urls.smThumbnailUrl } : {}), + ...(urls.lgThumbnailUrl !== undefined ? { lgThumbnailUrl: urls.lgThumbnailUrl } : {}), + }; + }); + +/** + * Sign one attachment cell via the pure-V2 {@link IAttachmentUrlSignerService} port. + * + * Free function (no class instance / Nest DI / RecordService). The only I/O is + * through the injected signer port (storage + optional thumbnail lookup). + */ +export const presignAttachmentCellValue = async ( + cellValue: unknown, + signer: IAttachmentUrlSignerService +): Promise> => + safeTry(async function* () { + const items = normalizeAttachmentCellValue(cellValue); + if (!items) { + return ok(cellValue); + } + const requests = extractSignRequests(items); + if (!requests.length) { + return ok(items); + } + const signed = yield* await signer.signItems(requests); + return ok(applySignedUrls(items, signed)); + }); + +/** + * Batch-sign attachment field values across many record field maps. + * + * Collects all sign requests first, calls {@link IAttachmentUrlSignerService.signItems} + * once, then rewrites attachment cells in place on shallow-cloned field maps. + */ +export const presignAttachmentFieldMaps = async ( + fieldMaps: ReadonlyArray>, + attachmentFieldKeys: ReadonlySet, + signer: IAttachmentUrlSignerService +): Promise>, DomainError>> => + safeTry(async function* () { + if (!fieldMaps.length || !attachmentFieldKeys.size) { + return ok(fieldMaps); + } + + type CellRef = { mapIndex: number; fieldKey: string; items: AttachmentCellValue }; + const refs: CellRef[] = []; + const requests: AttachmentSignRequest[] = []; + const seenTokens = new Set(); + + for (let mapIndex = 0; mapIndex < fieldMaps.length; mapIndex++) { + const fields = fieldMaps[mapIndex]!; + for (const fieldKey of attachmentFieldKeys) { + const items = normalizeAttachmentCellValue(fields[fieldKey]); + if (!items?.length) { + continue; + } + refs.push({ mapIndex, fieldKey, items }); + for (const request of extractSignRequests(items)) { + if (seenTokens.has(request.token)) { + continue; + } + seenTokens.add(request.token); + requests.push(request); + } + } + } + + if (!refs.length) { + return ok(fieldMaps); + } + + const signed = + requests.length > 0 + ? yield* await signer.signItems(requests) + : new Map(); + + const nextMaps = fieldMaps.map((fields) => ({ ...fields })); + for (const { mapIndex, fieldKey, items } of refs) { + nextMaps[mapIndex]![fieldKey] = applySignedUrls(items, signed); + } + return ok(nextMaps); + }); diff --git a/packages/v2/core/src/commands/ARCHITECTURE.md b/packages/v2/core/src/commands/ARCHITECTURE.md index 5709cb0c97..0a50841c8f 100644 --- a/packages/v2/core/src/commands/ARCHITECTURE.md +++ b/packages/v2/core/src/commands/ARCHITECTURE.md @@ -42,6 +42,9 @@ Declaration: If the folder I belong to changes, please update me, especially cor - `DuplicateRecordsStreamHandler.ts` - Role: command handler; Purpose: expose bulk row duplication as an async progress stream. - `DeleteFieldCommand.ts` - Role: command DTO + schema; Purpose: validate inputs for deleting a field. - `DeleteFieldHandler.ts` - Role: command handler; Purpose: remove field metadata/schema and publish events. +- `DeleteViewCommand.ts` - Role: command DTO + schema; Purpose: validate Table-owned View deletion. +- `DeleteViewHandler.ts` - Role: command handler; Purpose: lock and load Table aggregate roots, persist + View removal and cross-Table Link filter cleanup, then publish events after commit. - `FieldValidation.ts` - Role: helper; Purpose: decide notNull/unique support by field type. - `DeleteTableCommand.ts` - Role: command DTO + schema; Purpose: validate inputs for deletion. - `DeleteTableHandler.ts` - Role: command handler; Purpose: delete table state/schema and publish events. @@ -54,6 +57,13 @@ Declaration: If the folder I belong to changes, please update me, especially cor - `ImportDotTeaStructureHandler.ts` - Role: command handler; Purpose: import dottea structure tables. - `RenameTableCommand.ts` - Role: command DTO + schema; Purpose: validate inputs for renaming. - `RenameTableHandler.ts` - Role: command handler; Purpose: persist table rename and publish events. +- `UpdateViewSortCommand.ts` - Role: command DTO + schema; Purpose: validate the public View sort + contract for a Table-owned View. +- `UpdateViewSortHandler.ts` - Role: command handler; Purpose: orchestrate Table aggregate mutation, + View operation policy, persistence, events, and undo/redo for sort updates. +- `ApplyViewManualSortCommand.ts` - Role: command DTO; Purpose: validate View manual-sort inputs. +- `ApplyViewManualSortHandler.ts` - Role: command handler; Purpose: prepare aggregate-declared + row-order storage, persist View state, materialize record order, and append View history. - `TableFieldSpecs.ts` - Role: parsing helpers; Purpose: shared field input schema + spec builders. - `UpdateRecordCommand.ts` - Role: command DTO + schema; Purpose: validate inputs for updating a record. - `UpdateRecordHandler.ts` - Role: command handler; Purpose: update record, persist, publish. diff --git a/packages/v2/core/src/commands/ApplyFieldSnapshotCommand.ts b/packages/v2/core/src/commands/ApplyFieldSnapshotCommand.ts index 88cf6efcb7..2e41e9379c 100644 --- a/packages/v2/core/src/commands/ApplyFieldSnapshotCommand.ts +++ b/packages/v2/core/src/commands/ApplyFieldSnapshotCommand.ts @@ -4,10 +4,10 @@ import { z } from 'zod'; import { BaseId } from '../domain/base/BaseId'; import { domainError, type DomainError } from '../domain/shared/DomainError'; -import type { ViewQueryDefaultsDTO } from '../domain/table/views/ViewQueryDefaults'; import type { LinkForeignTableReference } from '../domain/table/fields/visitors/LinkForeignTableReferenceVisitor'; -import { recordFilterSchema } from '../queries/RecordFilterDto'; import { TableId } from '../domain/table/TableId'; +import type { ViewQueryDefaultsDTO } from '../domain/table/views/ViewQueryDefaults'; +import { recordFilterSchema } from '../queries/RecordFilterDto'; import { tableFieldInputSchema } from '../schemas/field'; import { parseTableFieldSpec, resolveTableFieldInputName } from './TableFieldSpecs'; import { TableUpdateCommand } from './TableUpdateCommand'; @@ -77,9 +77,10 @@ export const resolveFieldSnapshotForeignTableReferences = ( } return resolveTableFieldInputName(field, []).andThen((resolved) => - parseTableFieldSpec(resolved, { isPrimary: field.isPrimary === true }).andThen((spec) => - spec.foreignTableReferences() - ) + parseTableFieldSpec(resolved, { + isPrimary: field.isPrimary === true, + aiConfigMode: 'trustedRehydrate', + }).andThen((spec) => spec.foreignTableReferences()) ); }; diff --git a/packages/v2/core/src/commands/ApplyViewManualSortCommand.ts b/packages/v2/core/src/commands/ApplyViewManualSortCommand.ts new file mode 100644 index 0000000000..e65dcceb50 --- /dev/null +++ b/packages/v2/core/src/commands/ApplyViewManualSortCommand.ts @@ -0,0 +1,41 @@ +import { err, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; +import { viewSortItemSchema, type ViewSortItem } from '../domain/table/views/ViewSort'; +import { PublicCommand } from './PublicCommand'; + +export const applyViewManualSortInputSchema = z.object({ + tableId: z.string(), + viewId: z.string(), + sort: z.array(viewSortItemSchema), +}); + +export class ApplyViewManualSortCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId, + readonly sort: ReadonlyArray + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = applyViewManualSortInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid ApplyViewManualSortCommand input', + details: z.formatError(parsed.error), + }) + ); + } + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map( + (viewId) => new ApplyViewManualSortCommand(tableId, viewId, parsed.data.sort) + ) + ); + } +} diff --git a/packages/v2/core/src/commands/ApplyViewManualSortHandler.spec.ts b/packages/v2/core/src/commands/ApplyViewManualSortHandler.spec.ts new file mode 100644 index 0000000000..9a41eaa948 --- /dev/null +++ b/packages/v2/core/src/commands/ApplyViewManualSortHandler.spec.ts @@ -0,0 +1,202 @@ +import { err, ok, type Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import type { ViewManualSortService } from '../application/services/ViewManualSortService'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import type { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { ViewManualSortApplied } from '../domain/table/events/ViewManualSortApplied'; +import { ViewSortUpdated } from '../domain/table/events/ViewSortUpdated'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { TableUpdateViewQueryDefaultsSpec } from '../domain/table/specs/TableUpdateViewQueryDefaultsSpec'; +import { Table } from '../domain/table/Table'; +import type { TableUpdateResult } from '../domain/table/TableMutator'; +import { TableName } from '../domain/table/TableName'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { ApplyViewManualSortCommand } from './ApplyViewManualSortCommand'; +import { ApplyViewManualSortHandler } from './ApplyViewManualSortHandler'; + +const context: IExecutionContext = { actorId: ActorId.create('actor')._unsafeUnwrap() }; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +class FakeTableUpdateFlow { + calls = 0; + mutateSpec?: TableUpdateResult['mutateSpec']; + + async execute( + contextValue: IExecutionContext, + target: { table: Table }, + mutate: (table: Table) => Result, + options?: { + hooks?: { + afterPersist?: ( + context: IExecutionContext, + table: Table, + spec: TableUpdateResult['mutateSpec'] + ) => Promise, DomainError>>; + }; + } + ) { + this.calls += 1; + const result = mutate(target.table); + if (result.isErr()) return err(result.error); + this.mutateSpec = result.value.mutateSpec; + const events = result.value.table.pullDomainEvents(); + const hookResult = await options?.hooks?.afterPersist?.( + contextValue, + result.value.table, + result.value.mutateSpec + ); + if (hookResult?.isErr()) return err(hookResult.error); + return ok({ + table: result.value.table, + events, + postPersistEvents: [], + }); + } +} + +const createHandler = (table: Table, plugins: IViewOperationPlugin[] = []) => { + const tableRepository = { findOne: vi.fn(async () => ok(table)) } as unknown as ITableRepository; + const tableUpdateFlow = new FakeTableUpdateFlow(); + const prepareStorage = vi.fn(async () => ok(undefined)); + const materialize = vi.fn(async () => ok({ updatedCount: 3 })); + const undoRedo = { + capture: vi.fn((_, viewId: string) => ok({ id: viewId } as never)), + appendUpdate: vi.fn(async () => ok(undefined)), + } as unknown as ViewUndoRedoService; + return { + handler: new ApplyViewManualSortHandler( + tableRepository, + tableUpdateFlow as unknown as TableUpdateFlow, + { prepareStorage, materialize } as unknown as ViewManualSortService, + new ViewOperationPluginRunner(plugins), + undoRedo + ), + tableRepository, + tableUpdateFlow, + prepareStorage, + materialize, + undoRedo, + }; +}; + +describe('ApplyViewManualSortCommand', () => { + it('validates identifiers, empty sort, and sort directions', () => { + expect( + ApplyViewManualSortCommand.create({ tableId: 'bad', viewId: 'bad', sort: [] }).isErr() + ).toBe(true); + const table = buildTable(); + const ids = { tableId: table.id().toString(), viewId: table.views()[0]!.id().toString() }; + expect(ApplyViewManualSortCommand.create({ ...ids, sort: [] }).isOk()).toBe(true); + expect( + ApplyViewManualSortCommand.create({ + ...ids, + sort: [{ fieldId: table.getFields()[0]!.id().toString(), order: 'up' }], + }).isErr() + ).toBe(true); + }); +}); + +describe('ApplyViewManualSortHandler', () => { + it('coordinates the Table aggregate, record materialization, plugin policy, events, and history', async () => { + const table = buildTable(); + const sort = [{ fieldId: table.getFields()[0]!.id().toString(), order: 'desc' as const }]; + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler(table, [ + { + name: 'capture', + supports: (kind) => kind === ViewOperationKind.update, + prepare, + guard: () => ok(undefined), + }, + ]); + const command = ApplyViewManualSortCommand.create({ + tableId: table.id().toString(), + viewId: table.views()[0]!.id().toString(), + sort, + })._unsafeUnwrap(); + + const result = (await setup.handler.handle(context, command))._unsafeUnwrap(); + + expect(setup.tableRepository.findOne).toHaveBeenCalledOnce(); + expect(setup.tableUpdateFlow.mutateSpec).toBeInstanceOf(TableUpdateViewQueryDefaultsSpec); + expect(setup.prepareStorage).toHaveBeenCalledWith(context, table, expect.anything()); + expect(setup.materialize).toHaveBeenCalledWith(context, result.table, command.viewId, sort); + expect(result.updatedRecordCount).toBe(3); + expect(result.events.some((event) => event instanceof ViewSortUpdated)).toBe(true); + expect(result.events.some((event) => event instanceof ViewManualSortApplied)).toBe(true); + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'update', + payload: expect.objectContaining({ + patch: { sort: { sortObjs: sort, manualSort: true } }, + }), + }) + ); + expect(setup.undoRedo.capture).toHaveBeenCalledTimes(2); + expect(setup.undoRedo.appendUpdate).toHaveBeenCalledOnce(); + }); + + it('skips record writes, plugins, persistence, and history for an identical manual state', async () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const sort = [{ fieldId: table.getFields()[0]!.id().toString(), order: 'asc' as const }]; + const current = table.applyViewManualSort(viewId, sort)._unsafeUnwrap().table; + current.pullDomainEvents(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler(current, [ + { name: 'capture', supports: () => true, prepare, guard: () => ok(undefined) }, + ]); + const command = ApplyViewManualSortCommand.create({ + tableId: current.id().toString(), + viewId: viewId.toString(), + sort, + })._unsafeUnwrap(); + + expect((await setup.handler.handle(context, command)).isOk()).toBe(true); + expect(setup.tableUpdateFlow.calls).toBe(0); + expect(setup.materialize).not.toHaveBeenCalled(); + expect(setup.prepareStorage).not.toHaveBeenCalled(); + expect(prepare).not.toHaveBeenCalled(); + expect(setup.undoRedo.appendUpdate).not.toHaveBeenCalled(); + }); + + it('does not materialize or append history when the View update guard rejects', async () => { + const table = buildTable(); + const setup = createHandler(table, [ + { + name: 'reject', + supports: () => true, + guard: () => err(domainError.forbidden({ message: 'rejected' })), + }, + ]); + const command = ApplyViewManualSortCommand.create({ + tableId: table.id().toString(), + viewId: table.views()[0]!.id().toString(), + sort: [], + })._unsafeUnwrap(); + + expect((await setup.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'forbidden' + ); + expect(setup.tableUpdateFlow.calls).toBe(0); + expect(setup.materialize).not.toHaveBeenCalled(); + expect(setup.prepareStorage).not.toHaveBeenCalled(); + expect(setup.undoRedo.appendUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/v2/core/src/commands/ApplyViewManualSortHandler.ts b/packages/v2/core/src/commands/ApplyViewManualSortHandler.ts new file mode 100644 index 0000000000..b842667f68 --- /dev/null +++ b/packages/v2/core/src/commands/ApplyViewManualSortHandler.ts @@ -0,0 +1,161 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry, type Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewManualSortService } from '../application/services/ViewManualSortService'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import type { DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { Table as TableAggregate, type Table } from '../domain/table/Table'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type { ViewSortDTO, ViewSortItem } from '../domain/table/views/ViewSort'; +import type * as ExecutionContextPort from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { ApplyViewManualSortCommand } from './ApplyViewManualSortCommand'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; + +export class ApplyViewManualSortResult { + private constructor( + readonly table: Table, + readonly viewId: ViewId, + readonly sort: ReadonlyArray, + readonly previousSort: ViewSortDTO, + readonly nextSort: ViewSortDTO, + readonly updatedRecordCount: number, + readonly events: ReadonlyArray + ) {} + + static create(params: { + table: Table; + viewId: ViewId; + sort: ReadonlyArray; + previousSort: ViewSortDTO; + nextSort: ViewSortDTO; + updatedRecordCount: number; + events: ReadonlyArray; + }): ApplyViewManualSortResult { + return new ApplyViewManualSortResult( + params.table, + params.viewId, + params.sort.map((item) => ({ ...item })), + params.previousSort, + params.nextSort, + params.updatedRecordCount, + [...params.events] + ); + } +} + +@CommandHandler(ApplyViewManualSortCommand) +@injectable() +export class ApplyViewManualSortHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow, + @inject(v2CoreTokens.viewManualSortService) + private readonly viewManualSortService: ViewManualSortService, + @inject(v2CoreTokens.viewOperationPluginRunner) + private readonly viewOperationPluginRunner: ViewOperationPluginRunner, + @inject(v2CoreTokens.viewUndoRedoService) + private readonly viewUndoRedoService: ViewUndoRedoService + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + command: ApplyViewManualSortCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* TableAggregate.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const previousSnapshot = yield* handler.viewUndoRedoService.capture( + table, + command.viewId.toString() + ); + const manualSortResult = yield* table.applyViewManualSort(command.viewId, command.sort); + + if (!manualSortResult.updateResult) { + return ok( + ApplyViewManualSortResult.create({ + table, + viewId: command.viewId, + sort: manualSortResult.sort, + previousSort: manualSortResult.previousSort, + nextSort: manualSortResult.nextSort, + updatedRecordCount: 0, + events: [], + }) + ); + } + + const pluginExecution = yield* await handler.viewOperationPluginRunner.prepare({ + kind: ViewOperationKind.update, + executionContext: context, + payload: { + tableId: table.id().toString(), + viewId: command.viewId.toString(), + patch: { sort: manualSortResult.nextSort }, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + yield* await handler.viewManualSortService.prepareStorage( + context, + table, + manualSortResult.rowOrderStorageSpec + ); + + let updatedRecordCount = 0; + const update = yield* await handler.tableUpdateFlow.execute( + context, + { table }, + () => ok(manualSortResult.updateResult!), + { + hooks: { + afterPersist: async (transactionContext, persistedTable) => { + const materialized = await handler.viewManualSortService.materialize( + transactionContext, + persistedTable, + command.viewId, + manualSortResult.sort + ); + return materialized.map((result) => { + updatedRecordCount = result.updatedCount; + return []; + }); + }, + }, + } + ); + const nextSnapshot = yield* handler.viewUndoRedoService.capture( + update.table, + command.viewId.toString() + ); + yield* await handler.viewUndoRedoService.appendUpdate( + context, + update.table, + [previousSnapshot], + [nextSnapshot] + ); + + return ok( + ApplyViewManualSortResult.create({ + table: update.table, + viewId: command.viewId, + sort: manualSortResult.sort, + previousSort: manualSortResult.previousSort, + nextSort: manualSortResult.nextSort, + updatedRecordCount, + events: update.events, + }) + ); + }); + } +} diff --git a/packages/v2/core/src/commands/ApplyViewSnapshotCommand.ts b/packages/v2/core/src/commands/ApplyViewSnapshotCommand.ts new file mode 100644 index 0000000000..dec553849b --- /dev/null +++ b/packages/v2/core/src/commands/ApplyViewSnapshotCommand.ts @@ -0,0 +1,90 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import type { ViewSnapshotValue } from '../domain/table/views/ViewSnapshot'; +import { PublicCommand } from './PublicCommand'; + +export const viewSnapshotSchema = z + .object({ + id: z.string(), + name: z.string(), + type: z.enum(['grid', 'calendar', 'kanban', 'form', 'gallery', 'plugin']), + order: z.number().finite().optional(), + properties: z + .object({ + description: z.string().optional(), + isLocked: z.boolean().optional(), + enableShare: z.boolean().optional(), + shareId: z.string().optional(), + shareMeta: z + .object({ + allowCopy: z.boolean().optional(), + includeHiddenField: z.boolean().optional(), + password: z.string().min(3).optional(), + includeRecords: z.boolean().optional(), + submit: z.object({ requireLogin: z.boolean().optional() }).optional(), + allowEdit: z.boolean().optional(), + }) + .strict() + .optional(), + }) + .strict(), + columnMeta: z.record(z.string(), z.record(z.string(), z.unknown())), + query: z + .object({ + filter: z.unknown().optional(), + sort: z.array(z.object({ fieldId: z.string(), order: z.enum(['asc', 'desc']) })).optional(), + group: z + .array(z.object({ fieldId: z.string(), order: z.enum(['asc', 'desc']) })) + .optional(), + manualSort: z.boolean().optional(), + }) + .strict(), + sourceFilter: z.unknown().optional(), + options: z.unknown().optional(), + auditMetadata: z + .object({ + createdBy: z.string().min(1), + createdTime: z.string().min(1), + lastModifiedBy: z.string().min(1).optional(), + lastModifiedTime: z.string().min(1).optional(), + }) + .strict() + .optional(), + }) + .strict(); + +const applyViewSnapshotInputSchema = z + .object({ + tableId: z.string(), + snapshot: viewSnapshotSchema, + }) + .strict(); + +export class ApplyViewSnapshotCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly snapshot: ViewSnapshotValue + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = applyViewSnapshotInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid ApplyViewSnapshotCommand input', + details: z.formatError(parsed.error), + }) + ); + } + + return TableId.create(parsed.data.tableId).map( + (tableId) => new ApplyViewSnapshotCommand(tableId, parsed.data.snapshot as ViewSnapshotValue) + ); + } +} diff --git a/packages/v2/core/src/commands/ApplyViewSnapshotHandler.spec.ts b/packages/v2/core/src/commands/ApplyViewSnapshotHandler.spec.ts new file mode 100644 index 0000000000..b1c94b9145 --- /dev/null +++ b/packages/v2/core/src/commands/ApplyViewSnapshotHandler.spec.ts @@ -0,0 +1,273 @@ +import { err, ok, type Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { TableAddViewSpec } from '../domain/table/specs/TableAddViewSpec'; +import { Table } from '../domain/table/Table'; +import type { TableUpdateResult } from '../domain/table/TableMutator'; +import { TableName } from '../domain/table/TableName'; +import { captureViewSnapshot, type ViewSnapshotValue } from '../domain/table/views/ViewSnapshot'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { ApplyViewSnapshotCommand } from './ApplyViewSnapshotCommand'; +import { ApplyViewSnapshotHandler } from './ApplyViewSnapshotHandler'; + +const context: IExecutionContext = { actorId: ActorId.create('actor')._unsafeUnwrap() }; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'c'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Snapshot replay')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + const table = builder.build()._unsafeUnwrap(); + table.pullDomainEvents(); + return table; +}; + +class FakeTableUpdateFlow { + calls = 0; + mutateSpec?: TableUpdateResult['mutateSpec']; + + async execute( + _context: IExecutionContext, + target: { table: Table }, + mutate: (table: Table) => Result + ) { + this.calls += 1; + const result = mutate(target.table); + if (result.isErr()) return err(result.error); + this.mutateSpec = result.value.mutateSpec; + return ok({ + table: result.value.table, + events: result.value.table.pullDomainEvents(), + postPersistEvents: [], + }); + } +} + +const createHandler = (params: { + tableResult: Result; + plugins?: IViewOperationPlugin[]; +}) => { + const repository = { + findOne: vi.fn(async () => params.tableResult), + } as unknown as ITableRepository; + const flow = new FakeTableUpdateFlow(); + const handler = new ApplyViewSnapshotHandler( + repository, + flow as unknown as TableUpdateFlow, + new ViewOperationPluginRunner(params.plugins) + ); + return { handler, repository, flow }; +}; + +const commandFor = (table: Table, snapshot: ViewSnapshotValue): ApplyViewSnapshotCommand => + ApplyViewSnapshotCommand.create({ + tableId: table.id().toString(), + snapshot, + })._unsafeUnwrap(); + +describe('ApplyViewSnapshotCommand', () => { + it('validates the Table identifier and complete snapshot shape', () => { + const table = buildTable(); + const snapshot = captureViewSnapshot(table.views()[0]!)._unsafeUnwrap(); + + expect( + ApplyViewSnapshotCommand.create({ tableId: table.id().toString(), snapshot }).isOk() + ).toBe(true); + expect(ApplyViewSnapshotCommand.create({ tableId: 'bad', snapshot }).isErr()).toBe(true); + expect( + ApplyViewSnapshotCommand.create({ + tableId: table.id().toString(), + snapshot: { ...snapshot, properties: { unexpected: true } }, + }).isErr() + ).toBe(true); + }); +}); + +describe('ApplyViewSnapshotHandler', () => { + it('updates an existing View through aggregate behavior and the update plugin boundary', async () => { + const table = buildTable(); + const current = table.views()[0]!; + const snapshot: ViewSnapshotValue = { + ...captureViewSnapshot(current)._unsafeUnwrap(), + name: 'Restored name', + properties: { + description: 'Restored description', + isLocked: true, + shareMeta: { allowCopy: false }, + }, + }; + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler({ + tableResult: ok(table), + plugins: [ + { + name: 'capture', + supports: (kind) => kind === ViewOperationKind.update, + prepare, + guard: () => ok(undefined), + }, + ], + }); + + const result = ( + await setup.handler.handle(context, commandFor(table, snapshot)) + )._unsafeUnwrap(); + const restored = result.table.getView(current.id())._unsafeUnwrap(); + + expect(setup.repository.findOne).toHaveBeenCalledOnce(); + expect(setup.flow.calls).toBe(1); + expect(restored.name().toString()).toBe('Restored name'); + expect(restored.description()).toBe('Restored description'); + expect(restored.isLocked()).toBe(true); + expect(restored.shareMeta()).toEqual({ allowCopy: false }); + expect(result.table.pullDomainEvents()).toHaveLength(0); + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: ViewOperationKind.update, + payload: expect.objectContaining({ + tableId: table.id().toString(), + viewId: current.id().toString(), + patch: expect.objectContaining({ + name: 'Restored name', + description: 'Restored description', + isLocked: true, + shareMeta: { allowCopy: false }, + }), + }), + }) + ); + }); + + it('restores a missing child as an unshared View through the Table create boundary', async () => { + const table = buildTable(); + const created = table + .createView({ + type: 'grid', + name: 'Deleted shared View', + description: 'Restore me', + enableShare: true, + shareId: `shr${'r'.repeat(16)}`, + }) + ._unsafeUnwrap(); + const captured = captureViewSnapshot(created.view)._unsafeUnwrap(); + const legacySnapshot: ViewSnapshotValue = { + ...captured, + properties: { + ...captured.properties, + enableShare: true, + shareId: `shr${'x'.repeat(16)}`, + }, + }; + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler({ + tableResult: ok(table), + plugins: [ + { + name: 'capture', + supports: (kind) => kind === ViewOperationKind.create, + prepare, + guard: () => ok(undefined), + }, + ], + }); + + const result = ( + await setup.handler.handle(context, commandFor(table, legacySnapshot)) + )._unsafeUnwrap(); + const restored = result.table.getView(created.view.id())._unsafeUnwrap(); + + expect(setup.flow.mutateSpec).toBeInstanceOf(TableAddViewSpec); + expect(restored.enableShare()).toBeUndefined(); + expect(restored.shareId()).toBeUndefined(); + expect(restored.description()).toBe('Restore me'); + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: ViewOperationKind.create, + payload: expect.objectContaining({ + tableId: table.id().toString(), + currentViewCount: 1, + addedViewCount: 1, + view: expect.objectContaining({ + name: 'Deleted shared View', + description: 'Restore me', + }), + }), + }) + ); + }); + + it('returns without plugins or persistence when the snapshot is identical', async () => { + const table = buildTable(); + const snapshot = captureViewSnapshot(table.views()[0]!)._unsafeUnwrap(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler({ + tableResult: ok(table), + plugins: [ + { + name: 'capture', + supports: () => true, + prepare, + }, + ], + }); + + const result = await setup.handler.handle(context, commandFor(table, snapshot)); + + expect(result._unsafeUnwrap().table).toBe(table); + expect(prepare).not.toHaveBeenCalled(); + expect(setup.flow.calls).toBe(0); + }); + + it('does not persist a changed snapshot when plugin policy rejects replay', async () => { + const table = buildTable(); + const snapshot: ViewSnapshotValue = { + ...captureViewSnapshot(table.views()[0]!)._unsafeUnwrap(), + name: 'Rejected replay', + }; + const setup = createHandler({ + tableResult: ok(table), + plugins: [ + { + name: 'reject', + supports: () => true, + guard: () => err(domainError.forbidden({ message: 'Replay rejected' })), + }, + ], + }); + + const result = await setup.handler.handle(context, commandFor(table, snapshot)); + + expect(result._unsafeUnwrapErr().code).toBe('forbidden'); + expect(setup.flow.calls).toBe(0); + }); + + it('propagates repository and snapshot rehydration failures before persistence', async () => { + const table = buildTable(); + const snapshot = captureViewSnapshot(table.views()[0]!)._unsafeUnwrap(); + const missing = createHandler({ + tableResult: err(domainError.notFound({ message: 'Missing Table' })), + }); + + expect( + (await missing.handler.handle(context, commandFor(table, snapshot)))._unsafeUnwrapErr().code + ).toBe('not_found'); + expect(missing.flow.calls).toBe(0); + + const invalidSnapshot = { ...snapshot, id: 'invalid-view-id' }; + const invalid = createHandler({ tableResult: ok(table) }); + const result = await invalid.handler.handle(context, commandFor(table, invalidSnapshot)); + + expect(result._unsafeUnwrapErr().code).toBe('validation.invalid'); + expect(invalid.flow.calls).toBe(0); + }); +}); diff --git a/packages/v2/core/src/commands/ApplyViewSnapshotHandler.ts b/packages/v2/core/src/commands/ApplyViewSnapshotHandler.ts new file mode 100644 index 0000000000..d87e663681 --- /dev/null +++ b/packages/v2/core/src/commands/ApplyViewSnapshotHandler.ts @@ -0,0 +1,114 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import type { DomainError } from '../domain/shared/DomainError'; +import { Table as TableAggregate, type Table } from '../domain/table/Table'; +import { rehydrateViewSnapshot } from '../domain/table/views/ViewSnapshot'; +import * as ExecutionContextPort from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { TeableSpanAttributes } from '../ports/Tracer'; +import { TraceSpan } from '../ports/TraceSpan'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { ApplyViewSnapshotCommand } from './ApplyViewSnapshotCommand'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; + +export class ApplyViewSnapshotResult { + private constructor(readonly table: Table) {} + + static create(table: Table): ApplyViewSnapshotResult { + return new ApplyViewSnapshotResult(table); + } +} + +@CommandHandler(ApplyViewSnapshotCommand) +@injectable() +export class ApplyViewSnapshotHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow, + @inject(v2CoreTokens.viewOperationPluginRunner) + private readonly viewOperationPluginRunner: ViewOperationPluginRunner + ) {} + + @TraceSpan({ + attributes: (_context, command: ApplyViewSnapshotCommand) => ({ + [TeableSpanAttributes.TABLE_ID]: command.tableId.toString(), + 'teable.view_id': command.snapshot.id, + 'teable.undo_redo.command_type': 'ApplyViewSnapshot', + }), + }) + async handle( + context: ExecutionContextPort.IExecutionContext, + command: ApplyViewSnapshotCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* TableAggregate.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const snapshotView = yield* rehydrateViewSnapshot(command.snapshot); + const snapshotQueryDefaults = yield* snapshotView.queryDefaults(); + const isRestore = table.getView(snapshotView.id()).isErr(); + const snapshotResult = yield* table.applyViewSnapshot(snapshotView); + if (!snapshotResult.updateResult) { + return ok(ApplyViewSnapshotResult.create(table)); + } + + const pluginExecution = yield* await handler.viewOperationPluginRunner.prepare( + isRestore + ? { + kind: ViewOperationKind.create, + executionContext: context, + payload: { + tableId: table.id().toString(), + currentViewCount: table.views().length, + addedViewCount: 1, + view: { + name: snapshotView.name().toString(), + description: snapshotView.description(), + filter: snapshotQueryDefaults.filter(), + sort: snapshotQueryDefaults.sort(), + group: snapshotQueryDefaults.group(), + options: snapshotView.options(), + }, + }, + isTransactionBound: false, + } + : { + kind: ViewOperationKind.update, + executionContext: context, + payload: { + tableId: table.id().toString(), + viewId: snapshotView.id().toString(), + patch: { + name: snapshotView.name().toString(), + description: snapshotView.description(), + isLocked: snapshotView.isLocked(), + shareMeta: snapshotView.shareMeta(), + order: command.snapshot.order, + columnMeta: command.snapshot.columnMeta, + filter: snapshotQueryDefaults.filter(), + sort: snapshotQueryDefaults.sort(), + group: snapshotQueryDefaults.group(), + options: snapshotView.options(), + }, + }, + isTransactionBound: false, + } + ); + yield* await pluginExecution.guard(); + + const updateResult = yield* await handler.tableUpdateFlow.execute(context, { table }, () => + ok(snapshotResult.updateResult!) + ); + return ok(ApplyViewSnapshotResult.create(updateResult.table)); + }); + } +} diff --git a/packages/v2/core/src/commands/ClearHandler.ts b/packages/v2/core/src/commands/ClearHandler.ts index 2564800b18..059ad09d65 100644 --- a/packages/v2/core/src/commands/ClearHandler.ts +++ b/packages/v2/core/src/commands/ClearHandler.ts @@ -12,7 +12,11 @@ import { toUndoRedoStackAppendContext, UndoRedoStackService, } from '../application/services/UndoRedoStackService'; -import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { + domainError, + type DomainError, + type IDomainErrorLocalization, +} from '../domain/shared/DomainError'; import { generateUuid } from '../domain/shared/IdGenerator'; import { OffsetPagination } from '../domain/shared/pagination/OffsetPagination'; import { PageLimit } from '../domain/shared/pagination/PageLimit'; @@ -171,6 +175,7 @@ export interface ClearStreamErrorEvent { recordIds: string[]; message: string; code?: string; + localization?: IDomainErrorLocalization; } export type ClearStreamEvent = @@ -1356,6 +1361,7 @@ export class ClearStreamApplicationService extends ClearHandler { recordIds: [...details.recordIds], message: error.message, code: error.code, + ...(error.localization && { localization: error.localization }), }; } } diff --git a/packages/v2/core/src/commands/ClickButtonCommand.ts b/packages/v2/core/src/commands/ClickButtonCommand.ts new file mode 100644 index 0000000000..dca00dd94d --- /dev/null +++ b/packages/v2/core/src/commands/ClickButtonCommand.ts @@ -0,0 +1,66 @@ +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { FieldId } from '../domain/table/fields/FieldId'; +import { RecordId } from '../domain/table/records/RecordId'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; + +const clickButtonInputSchema = z.object({ + tableId: z.string(), + recordId: z.string(), + fieldId: z.string(), + shareScope: z + .object({ + viewId: z.string(), + includeHiddenFields: z.boolean().default(false), + includeRecords: z.boolean().default(false), + }) + .optional(), +}); + +export type IClickButtonCommandInput = z.input; + +export class ClickButtonCommand { + private constructor( + readonly tableId: TableId, + readonly recordId: RecordId, + readonly fieldId: FieldId, + readonly shareScope: + | { + readonly viewId: ViewId; + readonly includeHiddenFields: boolean; + readonly includeRecords: boolean; + } + | undefined + ) {} + + static create(raw: unknown): Result { + const parsed = clickButtonInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + code: 'button.command_invalid', + message: 'Invalid ClickButtonCommand input', + details: z.formatError(parsed.error), + }) + ); + } + + return safeTry(function* () { + const tableId = yield* TableId.create(parsed.data.tableId); + const recordId = yield* RecordId.create(parsed.data.recordId); + const fieldId = yield* FieldId.create(parsed.data.fieldId); + const shareScope = parsed.data.shareScope + ? { + viewId: yield* ViewId.create(parsed.data.shareScope.viewId), + includeHiddenFields: parsed.data.shareScope.includeHiddenFields, + includeRecords: parsed.data.shareScope.includeRecords, + } + : undefined; + return ok(new ClickButtonCommand(tableId, recordId, fieldId, shareScope)); + }); + } +} diff --git a/packages/v2/core/src/commands/ClickButtonHandler.ts b/packages/v2/core/src/commands/ClickButtonHandler.ts new file mode 100644 index 0000000000..7390b54e49 --- /dev/null +++ b/packages/v2/core/src/commands/ClickButtonHandler.ts @@ -0,0 +1,302 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { requireRecordUpdateSnapshot } from '../application/services/RecordMutationSnapshotContract'; +import { RecordWritePluginRunner } from '../application/services/RecordWritePluginRunner'; +import { + toUndoRedoStackAppendContext, + UndoRedoStackService, +} from '../application/services/UndoRedoStackService'; +import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { ButtonClicked } from '../domain/table/events/ButtonClicked'; +import type { RecordFieldChangeDTO } from '../domain/table/events/RecordFieldValuesDTO'; +import { RecordUpdated } from '../domain/table/events/RecordUpdated'; +import { FieldKeyType } from '../domain/table/fields/FieldKeyType'; +import type { ButtonClickPlan } from '../domain/table/methods/createButtonClickPlan'; +import { RecordConditionSpecBuilder } from '../domain/table/records/specs/RecordConditionSpecBuilder'; +import { TableRecord } from '../domain/table/records/TableRecord'; +import { Table } from '../domain/table/Table'; +import * as ButtonClickWorkflowServicePort from '../ports/ButtonClickWorkflowService'; +import * as EventBusPort from '../ports/EventBus'; +import { IExecutionContext } from '../ports/ExecutionContext'; +import { RecordWriteOperationKind } from '../ports/RecordWritePlugin'; +import * as TableRecordQueryRepositoryPort from '../ports/TableRecordQueryRepository'; +import type { TableRecordReadModel } from '../ports/TableRecordReadModel'; +import * as TableRecordRepositoryPort from '../ports/TableRecordRepository'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { TraceSpan } from '../ports/TraceSpan'; +import * as UnitOfWorkPort from '../ports/UnitOfWork'; +import { + buildSanitizedRecordConditionSpec, + replaceCurrentUserTagInFilter, +} from '../queries/RecordFilterMapper'; +import { ClickButtonCommand } from './ClickButtonCommand'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { toTableRecord } from './shared/toTableRecord'; + +export class ClickButtonResult { + private constructor( + readonly tableId: string, + readonly fieldId: string, + readonly runId: string, + readonly record: TableRecord, + readonly events: ReadonlyArray + ) {} + + static create( + tableId: string, + fieldId: string, + runId: string, + record: TableRecord, + events: ReadonlyArray + ): ClickButtonResult { + return new ClickButtonResult(tableId, fieldId, runId, record, [...events]); + } +} + +const buildScopedUpdateForbiddenError = (tableId: string) => + domainError.forbidden({ + code: 'record_write_plugin.scope_forbidden', + message: 'Record write target includes rows outside the allowed scope.', + details: { + operation: RecordWriteOperationKind.updateOne, + tableId, + requestedRecordCount: 1, + authorizedRecordCount: 0, + }, + }); + +@CommandHandler(ClickButtonCommand) +@injectable() +export class ClickButtonHandler implements ICommandHandler { + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableRecordRepository) + private readonly tableRecordRepository: TableRecordRepositoryPort.ITableRecordRepository, + @inject(v2CoreTokens.tableRecordQueryRepository) + private readonly tableRecordQueryRepository: TableRecordQueryRepositoryPort.ITableRecordQueryRepository, + @inject(v2CoreTokens.recordWritePluginRunner) + private readonly recordWritePluginRunner: RecordWritePluginRunner, + @inject(v2CoreTokens.eventBus) + private readonly eventBus: EventBusPort.IEventBus, + @inject(v2CoreTokens.buttonClickWorkflowService) + private readonly buttonClickWorkflowService: ButtonClickWorkflowServicePort.IButtonClickWorkflowService, + @inject(v2CoreTokens.undoRedoService) + private readonly undoRedoStackService: UndoRedoStackService, + @inject(v2CoreTokens.unitOfWork) + private readonly unitOfWork: UnitOfWorkPort.IUnitOfWork + ) {} + + @TraceSpan() + async handle( + context: IExecutionContext, + command: ClickButtonCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpecBuilder = Table.specs().byId(command.tableId); + if (command.shareScope) tableSpecBuilder.withViewId(command.shareScope.viewId); + const tableSpec = yield* tableSpecBuilder.build(); + const table = yield* (await handler.tableRepository.findOne(context, tableSpec)).mapErr( + (error) => { + if (!isNotFoundError(error)) return error; + return command.shareScope + ? domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${command.shareScope.viewId.toString()}`, + }) + : domainError.notFound({ + code: 'table.not_found', + message: `Table not found: ${command.tableId.toString()}`, + }); + } + ); + const plan = yield* table.createButtonClickPlan({ + fieldId: command.fieldId, + shareScope: command.shareScope, + }); + const currentRecord = yield* await handler.loadCurrentRecord(context, table, command, plan); + const currentRecordEntity = yield* toTableRecord(table, currentRecord); + const currentValue = currentRecord.fields[command.fieldId.toString()]; + const recordUpdate = yield* plan.click(table, command.recordId, currentValue); + const nextValue = recordUpdate.record.fields().get(command.fieldId)?.toValue(); + const fieldValues = new Map([[command.fieldId.toString(), nextValue]]); + + const pluginExecution = yield* await handler.recordWritePluginRunner.prepare({ + kind: RecordWriteOperationKind.updateOne, + executionContext: context, + table, + payload: { + recordId: command.recordId, + fieldValues, + fieldKeyType: FieldKeyType.Id, + typecast: false, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + const pluginRecordSpec = yield* pluginExecution.getRecordSpec(); + if (pluginRecordSpec && !pluginRecordSpec.isSatisfiedBy(currentRecordEntity)) { + return err(buildScopedUpdateForbiddenError(table.id().toString())); + } + const allowedFieldIds = + yield* pluginExecution.getUpdateFieldIdsForRecord(currentRecordEntity); + if (allowedFieldIds && !allowedFieldIds.has(command.fieldId.toString())) { + return err( + domainError.forbidden({ + code: 'record_write_plugin.update_fields_forbidden', + message: 'Button Field is outside the allowed update scope.', + details: { + operation: RecordWriteOperationKind.updateOne, + tableId: table.id().toString(), + deniedFieldIds: [command.fieldId.toString()], + }, + }) + ); + } + + const mutation = yield* await handler.unitOfWork.withTransaction( + context, + async (transactionContext) => + safeTry(async function* () { + yield* await pluginExecution.beforePersist(transactionContext); + const result = yield* await handler.tableRecordRepository.updateOne( + transactionContext, + table, + command.recordId, + recordUpdate.mutateSpec, + { expectedVersion: currentRecord.version } + ); + if (result.mutationApplied === false) { + return err( + domainError.conflict({ + code: 'button.concurrent_click_conflict', + message: 'Button was changed by another request; retry the click.', + details: { + recordId: command.recordId.toString(), + expectedVersion: currentRecord.version, + }, + }) + ); + } + return ok(result); + }) + ); + + const snapshot = yield* requireRecordUpdateSnapshot( + { + operation: 'update', + tableId: table.id().toString(), + recordId: command.recordId.toString(), + }, + mutation.updateSnapshot + ); + const oldValue = snapshot.previous.fields[command.fieldId.toString()]; + const newValue = snapshot.current.fields[command.fieldId.toString()]; + const changes: RecordFieldChangeDTO[] = [ + { + fieldId: command.fieldId.toString(), + oldValue, + newValue, + }, + ]; + const count = + newValue != null && typeof newValue === 'object' && !Array.isArray(newValue) + ? Number((newValue as { count?: unknown }).count) || 0 + : 0; + const buttonClicked = ButtonClicked.create({ + tableId: table.id(), + baseId: table.baseId(), + recordId: command.recordId, + fieldId: command.fieldId, + count, + workflowId: plan.workflowId(), + }); + const events: IDomainEvent[] = [ + RecordUpdated.create({ + tableId: table.id(), + baseId: table.baseId(), + recordId: command.recordId, + oldVersion: snapshot.oldVersion, + newVersion: snapshot.newVersion, + changes, + source: 'user', + }), + buttonClicked, + ]; + yield* await handler.eventBus.publishMany(context, events); + yield* await handler.undoRedoStackService.appendButtonValueUpdateFromSnapshot( + toUndoRedoStackAppendContext(context), + { + tableId: table.id(), + recordId: command.recordId, + snapshot, + fieldId: command.fieldId.toString(), + } + ); + await pluginExecution.afterCommit(); + const workflowResult = yield* await handler.buttonClickWorkflowService.trigger( + context, + buttonClicked + ); + + const responseRecord = yield* TableRecord.fromRawFieldValues({ + id: command.recordId.toString(), + tableId: table.id(), + fields: { [command.fieldId.toString()]: newValue }, + }); + return ok( + ClickButtonResult.create( + table.id().toString(), + command.fieldId.toString(), + workflowResult.runId, + responseRecord, + events + ) + ); + }); + } + + private async loadCurrentRecord( + context: IExecutionContext, + table: Table, + command: ClickButtonCommand, + plan: ButtonClickPlan + ): Promise> { + if (!command.shareScope) { + return this.tableRecordQueryRepository.findOne(context, table, command.recordId, { + mode: 'stored', + }); + } + + const filter = replaceCurrentUserTagInFilter( + table, + plan.viewFilter(), + context.actorId.toString() + ); + const viewCondition = buildSanitizedRecordConditionSpec(table, filter); + if (viewCondition.isErr()) return err(viewCondition.error); + const specBuilder = RecordConditionSpecBuilder.create().recordId(command.recordId); + if (viewCondition.value) specBuilder.addConditionSpec(viewCondition.value); + const spec = specBuilder.build(); + if (spec.isErr()) return err(spec.error); + const result = await this.tableRecordQueryRepository.find(context, table, spec.value, { + mode: 'stored', + includeTotal: false, + }); + if (result.isErr()) return err(result.error); + const record = result.value.records[0]; + if (record) return ok(record); + return err( + domainError.forbidden({ + code: 'button.shared_record_forbidden', + message: 'Record is outside the shared View scope.', + details: { recordId: command.recordId.toString() }, + }) + ); + } +} diff --git a/packages/v2/core/src/commands/CreateBaseHandler.spec.ts b/packages/v2/core/src/commands/CreateBaseHandler.spec.ts index dac5b63ca3..e1b74cb9f8 100644 --- a/packages/v2/core/src/commands/CreateBaseHandler.spec.ts +++ b/packages/v2/core/src/commands/CreateBaseHandler.spec.ts @@ -28,6 +28,10 @@ class FakeBaseRepository implements IBaseRepository { return ok(base as never); } + async delete(_context: IExecutionContext, _baseId: BaseId) { + return ok(undefined); + } + async findOne(_context: IExecutionContext, _baseId: BaseId) { return ok(null); } diff --git a/packages/v2/core/src/commands/CreateFieldHandler.spec.ts b/packages/v2/core/src/commands/CreateFieldHandler.spec.ts index 2779cbaa73..d903f8e7c4 100644 --- a/packages/v2/core/src/commands/CreateFieldHandler.spec.ts +++ b/packages/v2/core/src/commands/CreateFieldHandler.spec.ts @@ -861,7 +861,7 @@ describe('CreateFieldHandler', () => { } expect(result.error.code).toBe(TABLE_FIELD_LIMIT_ERROR_CODE); - expect(result.error.message).toContain('limit:1'); + expect(result.error.message).toBe('Table "Host" can have at most 1 fields.'); expect(result.error.details).toMatchObject({ tableName: 'Host', currentFieldCount: 1, @@ -947,8 +947,7 @@ describe('CreateFieldHandler', () => { } expect(result.error.code).toBe(TABLE_FIELD_LIMIT_ERROR_CODE); - expect(result.error.message).toContain('limit:3'); - expect(result.error.message).toContain('table:Foreign'); + expect(result.error.message).toBe('Table "Foreign" can have at most 3 fields.'); expect(result.error.details).toMatchObject({ tableName: 'Foreign', currentFieldCount: 3, diff --git a/packages/v2/core/src/commands/CreateTableCommand.ts b/packages/v2/core/src/commands/CreateTableCommand.ts index 27930b0481..8517af9f10 100644 --- a/packages/v2/core/src/commands/CreateTableCommand.ts +++ b/packages/v2/core/src/commands/CreateTableCommand.ts @@ -6,6 +6,7 @@ import { z } from 'zod'; import { BaseId } from '../domain/base/BaseId'; import { domainError, type DomainError } from '../domain/shared/DomainError'; import { DbTableName } from '../domain/table/DbTableName'; +import { FieldType } from '../domain/table/fields/FieldType'; import type { LinkForeignTableReference } from '../domain/table/fields/visitors/LinkForeignTableReferenceVisitor'; import { RecordId } from '../domain/table/records/RecordId'; import { Table } from '../domain/table/Table'; @@ -205,6 +206,21 @@ export class CreateTableCommand { const primaryIndex = primaryIndexes[0] ?? 0; + // v1 parity (T6520): the primary field type is restricted at creation just + // like on conversion — a checkbox/attachment/... first field is rejected + // instead of being silently promoted to primary. + const primaryFieldInput = fieldsToUse[primaryIndex]; + if (primaryFieldInput) { + const primaryTypeResult = FieldType.create(primaryFieldInput.type); + if (primaryTypeResult.isOk() && !primaryTypeResult.value.isPrimarySupported()) { + return err( + domainError.validation({ + message: `Field type ${primaryFieldInput.type} is not supported as primary field`, + }) + ); + } + } + const fieldsWithPrimaryFlag = fieldsToUse.map((field, index) => index === primaryIndex && field.isPrimary !== true ? { ...field, isPrimary: true } : field ); diff --git a/packages/v2/core/src/commands/CreateViewCommand.spec.ts b/packages/v2/core/src/commands/CreateViewCommand.spec.ts new file mode 100644 index 0000000000..77722d2e81 --- /dev/null +++ b/packages/v2/core/src/commands/CreateViewCommand.spec.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; + +import { CreateViewCommand } from './CreateViewCommand'; + +describe('CreateViewCommand', () => { + it('parses the v2 create-view input', () => { + const result = CreateViewCommand.create({ + tableId: `tbl${'b'.repeat(16)}`, + view: { + name: 'Planning', + type: 'grid', + columnMeta: { + [`fld${'c'.repeat(16)}`]: { order: 0, width: 240 }, + }, + options: { rowHeight: 'short' }, + description: 'Planning details', + filter: { + conjunction: 'and', + items: [ + { + fieldId: `fld${'c'.repeat(16)}`, + operator: 'is', + value: 'alpha', + }, + ], + }, + sort: [{ fieldId: `fld${'c'.repeat(16)}`, order: 'desc' }], + group: [{ fieldId: `fld${'c'.repeat(16)}`, order: 'asc' }], + manualSort: false, + isLocked: true, + enableShare: true, + shareId: 'shr-planning', + shareMeta: { allowCopy: false }, + }, + }); + + expect(result.isOk()).toBe(true); + const command = result._unsafeUnwrap(); + expect(command.view.type).toBe('grid'); + expect(command.view.name).toBe('Planning'); + expect(command.view.description).toBe('Planning details'); + expect(command.view.isLocked).toBe(true); + }); + + it('rejects an unsupported view type', () => { + const result = CreateViewCommand.create({ + tableId: `tbl${'b'.repeat(16)}`, + view: { type: 'timeline' }, + }); + + expect(result.isErr()).toBe(true); + }); + + it('rejects a share password shorter than the public contract minimum', () => { + const result = CreateViewCommand.create({ + tableId: `tbl${'b'.repeat(16)}`, + view: { + type: 'grid', + shareMeta: { password: 'ab' }, + }, + }); + + expect(result.isErr()).toBe(true); + }); + + it('accepts an empty filter group for the legacy View contract', () => { + const result = CreateViewCommand.create({ + tableId: `tbl${'b'.repeat(16)}`, + view: { + type: 'grid', + filter: { conjunction: 'and', items: [] }, + }, + }); + + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().view.filter).toEqual({ + conjunction: 'and', + items: [], + }); + }); + + it('rejects an unvalidated source-filter metadata payload', () => { + const result = CreateViewCommand.create({ + tableId: `tbl${'b'.repeat(16)}`, + view: { + type: 'grid', + sourceFilter: { + conjunction: 'and', + filterSet: [{ arbitraryMetadata: 'must not persist' }], + }, + }, + }); + + expect(result.isErr()).toBe(true); + }); + + it('derives the canonical filter when only the public source filter is provided', () => { + const fieldId = `fld${'c'.repeat(16)}`; + const result = CreateViewCommand.create({ + tableId: `tbl${'b'.repeat(16)}`, + view: { + type: 'grid', + sourceFilter: { + conjunction: 'and', + filterSet: [ + { + fieldId, + operator: 'IN', + isSymbol: true, + value: 'alpha', + }, + ], + }, + }, + }); + + expect(result._unsafeUnwrap().view.filter).toEqual({ + conjunction: 'and', + items: [{ fieldId, operator: 'isAnyOf', value: ['alpha'] }], + }); + }); + + it('uses the source filter as the single authority when a mismatched canonical filter is passed', () => { + const fieldId = `fld${'c'.repeat(16)}`; + const result = CreateViewCommand.create({ + tableId: `tbl${'b'.repeat(16)}`, + view: { + type: 'grid', + filter: { fieldId, operator: 'is', value: 'mismatched' }, + sourceFilter: { + conjunction: 'and', + filterSet: [ + { + fieldId, + operator: '=', + isSymbol: true, + value: 'authoritative', + }, + ], + }, + }, + }); + + expect(result._unsafeUnwrap().view.filter).toEqual({ + conjunction: 'and', + items: [{ fieldId, operator: 'is', value: 'authoritative' }], + }); + }); +}); diff --git a/packages/v2/core/src/commands/CreateViewCommand.ts b/packages/v2/core/src/commands/CreateViewCommand.ts new file mode 100644 index 0000000000..3b35c67766 --- /dev/null +++ b/packages/v2/core/src/commands/CreateViewCommand.ts @@ -0,0 +1,91 @@ +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import type { TableCreateViewInput } from '../domain/table/Table'; +import { TableId } from '../domain/table/TableId'; +import { viewRecordFilterSchema } from '../domain/table/views/ViewQueryDefaults'; +import { ViewSourceFilter, viewSourceFilterSchema } from '../domain/table/views/ViewSourceFilter'; +import { PublicCommand } from './PublicCommand'; + +const viewColumnMetaEntrySchema = z.looseObject({ + order: z.number().nullable().optional(), + visible: z.boolean().optional(), + hidden: z.boolean().optional(), + width: z.number().optional(), + required: z.boolean().optional(), + statisticFunc: z.string().nullable().optional(), +}); + +const createViewConfigSchema = z.object({ + name: z.string().optional(), + type: z.enum(['grid', 'calendar', 'kanban', 'form', 'gallery', 'plugin']), + description: z.string().optional(), + columnMeta: z.record(z.string(), viewColumnMetaEntrySchema).optional(), + options: z.unknown().optional(), + filter: viewRecordFilterSchema.optional().nullable(), + sourceFilter: viewSourceFilterSchema.optional(), + sort: z + .array(z.object({ fieldId: z.string().min(1), order: z.enum(['asc', 'desc']) })) + .optional(), + group: z + .array(z.object({ fieldId: z.string().min(1), order: z.enum(['asc', 'desc']) })) + .optional(), + manualSort: z.boolean().optional(), + isLocked: z.boolean().optional(), + order: z.number().optional(), + enableShare: z.boolean().optional(), + shareId: z.string().optional(), + shareMeta: z + .object({ + allowCopy: z.boolean().optional(), + includeHiddenField: z.boolean().optional(), + password: z.string().min(3).optional(), + includeRecords: z.boolean().optional(), + submit: z.object({ requireLogin: z.boolean().optional() }).optional(), + allowEdit: z.boolean().optional(), + }) + .optional(), +}); + +export const createViewInputSchema = z.object({ + tableId: z.string(), + view: createViewConfigSchema, +}); + +export type ICreateViewCommandInput = z.input; + +export class CreateViewCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly view: TableCreateViewInput + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = createViewInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid CreateViewCommand input', + details: z.formatError(parsed.error), + }) + ); + } + + return TableId.create(parsed.data.tableId).andThen((tableId) => { + if (parsed.data.view.sourceFilter === undefined) { + return ok(new CreateViewCommand(tableId, parsed.data.view)); + } + return ViewSourceFilter.create(parsed.data.view.sourceFilter).map( + (sourceFilter) => + new CreateViewCommand(tableId, { + ...parsed.data.view, + filter: sourceFilter.toCanonical(), + }) + ); + }); + } +} diff --git a/packages/v2/core/src/commands/CreateViewHandler.spec.ts b/packages/v2/core/src/commands/CreateViewHandler.spec.ts new file mode 100644 index 0000000000..ff40e914fa --- /dev/null +++ b/packages/v2/core/src/commands/CreateViewHandler.spec.ts @@ -0,0 +1,296 @@ +import { err, ok, type Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { ViewPluginCreationService } from '../application/services/ViewPluginCreationService'; +import type { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { ViewCreated } from '../domain/table/events/ViewCreated'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { TableAddViewSpec } from '../domain/table/specs/TableAddViewSpec'; +import { Table } from '../domain/table/Table'; +import type { TableUpdateResult } from '../domain/table/TableMutator'; +import { TableName } from '../domain/table/TableName'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import type { IViewPluginRepository } from '../ports/ViewPluginRepository'; +import { CreateViewCommand } from './CreateViewCommand'; +import { CreateViewHandler } from './CreateViewHandler'; + +const context: IExecutionContext = { actorId: ActorId.create('actor')._unsafeUnwrap() }; +const transactionContext: IExecutionContext = { + actorId: ActorId.create('transaction')._unsafeUnwrap(), +}; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'d'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Create View')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + const table = builder.build()._unsafeUnwrap(); + table.pullDomainEvents(); + return table; +}; + +const buildPluginRepository = (): IViewPluginRepository => ({ + findViewPlugin: vi.fn(async () => + ok({ + id: 'plg-sheet', + name: 'Plugin default', + logo: 'https://example.test/logo.png', + }) + ), + insertViewPluginInstallation: vi.fn(async () => ok(undefined)), + findViewPluginInstallationByViewId: vi.fn(async () => + err(domainError.notFound({ message: 'Not used' })) + ), + getViewPluginInstallation: vi.fn(async () => err(domainError.notFound({ message: 'Not used' }))), + updateViewPluginStorage: vi.fn(async () => err(domainError.notFound({ message: 'Not used' }))), +}); + +class FakeTableUpdateFlow { + calls = 0; + mutateSpec?: TableUpdateResult['mutateSpec']; + + async execute( + _context: IExecutionContext, + target: { table: Table }, + mutate: (table: Table) => Result, + options?: { + hooks?: { + prepare?: ( + context: IExecutionContext, + table: Table, + spec: TableUpdateResult['mutateSpec'] + ) => Promise, DomainError>>; + }; + } + ) { + this.calls += 1; + const result = mutate(target.table); + if (result.isErr()) return err(result.error); + this.mutateSpec = result.value.mutateSpec; + if (options?.hooks?.prepare) { + const hookResult = await options.hooks.prepare( + transactionContext, + result.value.table, + result.value.mutateSpec + ); + if (hookResult.isErr()) return err(hookResult.error); + } + return ok({ + table: result.value.table, + events: result.value.table.pullDomainEvents(), + postPersistEvents: [], + }); + } +} + +const createHandler = (params: { + tableResult: Result; + pluginRepository?: IViewPluginRepository; + operationPlugins?: IViewOperationPlugin[]; + undoFailure?: DomainError; +}) => { + const repository = { + findOne: vi.fn(async () => params.tableResult), + } as unknown as ITableRepository; + const flow = new FakeTableUpdateFlow(); + const pluginRepository = params.pluginRepository ?? buildPluginRepository(); + const capture = vi.fn((_table: Table, viewId: string) => ok({ id: viewId } as never)); + const appendCreate = vi.fn(async () => + params.undoFailure ? err(params.undoFailure) : ok(undefined) + ); + const handler = new CreateViewHandler( + repository, + flow as unknown as TableUpdateFlow, + new ViewOperationPluginRunner(params.operationPlugins), + new ViewPluginCreationService(pluginRepository), + { capture, appendCreate } as unknown as ViewUndoRedoService + ); + return { handler, repository, flow, pluginRepository, capture, appendCreate }; +}; + +describe('CreateViewCommand', () => { + it('validates Table IDs, View types, and source filters', () => { + const table = buildTable(); + expect( + CreateViewCommand.create({ + tableId: table.id().toString(), + view: { type: 'grid', name: 'Valid' }, + }).isOk() + ).toBe(true); + expect(CreateViewCommand.create({ tableId: 'bad', view: { type: 'grid' } }).isErr()).toBe(true); + expect( + CreateViewCommand.create({ + tableId: table.id().toString(), + view: { type: 'unsupported' }, + }).isErr() + ).toBe(true); + }); +}); + +describe('CreateViewHandler', () => { + it('creates an ordinary View through Table behavior, plugin policy, persistence, events, and undo', async () => { + const table = buildTable(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler({ + tableResult: ok(table), + operationPlugins: [ + { + name: 'capture', + supports: (kind) => kind === ViewOperationKind.create, + prepare, + guard: () => ok(undefined), + }, + ], + }); + const command = CreateViewCommand.create({ + tableId: table.id().toString(), + view: { + type: 'gallery', + name: 'Gallery', + description: 'Created through the aggregate', + }, + })._unsafeUnwrap(); + + const result = (await setup.handler.handle(context, command))._unsafeUnwrap(); + const view = result.table.getView(result.viewId)._unsafeUnwrap(); + + expect(setup.repository.findOne).toHaveBeenCalledOnce(); + expect(setup.flow.mutateSpec).toBeInstanceOf(TableAddViewSpec); + expect(view.name().toString()).toBe('Gallery'); + expect(view.description()).toBe('Created through the aggregate'); + expect(result.events.some((event) => event instanceof ViewCreated)).toBe(true); + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: ViewOperationKind.create, + payload: expect.objectContaining({ + tableId: table.id().toString(), + currentViewCount: 1, + addedViewCount: 1, + view: expect.objectContaining({ + name: 'Gallery', + description: 'Created through the aggregate', + }), + }), + }) + ); + expect(setup.capture).toHaveBeenCalledWith(result.table, result.viewId.toString()); + expect(setup.appendCreate).toHaveBeenCalledWith( + context, + result.table, + expect.objectContaining({ id: result.viewId.toString() }) + ); + expect(setup.pluginRepository.findViewPlugin).not.toHaveBeenCalled(); + }); + + it('creates PluginInstallation in the Table persistence transaction for a Plugin View', async () => { + const table = buildTable(); + const pluginRepository = buildPluginRepository(); + const setup = createHandler({ tableResult: ok(table), pluginRepository }); + const command = CreateViewCommand.create({ + tableId: table.id().toString(), + view: { + type: 'plugin', + name: '', + options: { pluginId: 'plg-sheet' }, + }, + })._unsafeUnwrap(); + + const result = (await setup.handler.handle(context, command))._unsafeUnwrap(); + const view = result.table.getView(result.viewId)._unsafeUnwrap(); + const options = view.options() as { + pluginId: string; + pluginInstallId: string; + pluginLogo: string; + }; + + expect(view.name().toString()).toBe('Plugin default'); + expect(options).toMatchObject({ + pluginId: 'plg-sheet', + pluginLogo: 'https://example.test/logo.png', + }); + expect(options.pluginInstallId).toMatch(/^pli[0-9a-zA-Z]{16}$/); + expect(pluginRepository.insertViewPluginInstallation).toHaveBeenCalledWith(transactionContext, { + id: options.pluginInstallId, + pluginId: 'plg-sheet', + baseId: table.baseId().toString(), + viewId: result.viewId.toString(), + name: 'Plugin default', + }); + }); + + it('stops before persistence and undo when plugin preparation or operation policy fails', async () => { + const table = buildTable(); + const pluginRepository = buildPluginRepository(); + vi.mocked(pluginRepository.findViewPlugin).mockResolvedValue( + err(domainError.notFound({ message: 'Plugin missing' })) + ); + const missingPlugin = createHandler({ tableResult: ok(table), pluginRepository }); + const pluginCommand = CreateViewCommand.create({ + tableId: table.id().toString(), + view: { type: 'plugin', options: { pluginId: 'missing' } }, + })._unsafeUnwrap(); + + expect( + (await missingPlugin.handler.handle(context, pluginCommand))._unsafeUnwrapErr().code + ).toBe('not_found'); + expect(missingPlugin.flow.calls).toBe(0); + expect(missingPlugin.appendCreate).not.toHaveBeenCalled(); + + const rejected = createHandler({ + tableResult: ok(table), + operationPlugins: [ + { + name: 'reject', + supports: () => true, + guard: () => err(domainError.forbidden({ message: 'View limit reached' })), + }, + ], + }); + const gridCommand = CreateViewCommand.create({ + tableId: table.id().toString(), + view: { type: 'grid', name: 'Rejected' }, + })._unsafeUnwrap(); + + expect((await rejected.handler.handle(context, gridCommand))._unsafeUnwrapErr().code).toBe( + 'forbidden' + ); + expect(rejected.flow.calls).toBe(0); + expect(rejected.appendCreate).not.toHaveBeenCalled(); + }); + + it('propagates repository and undo failures at their orchestration boundaries', async () => { + const table = buildTable(); + const command = CreateViewCommand.create({ + tableId: table.id().toString(), + view: { type: 'grid', name: 'View' }, + })._unsafeUnwrap(); + const missing = createHandler({ + tableResult: err(domainError.notFound({ message: 'Missing Table' })), + }); + + expect((await missing.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'not_found' + ); + expect(missing.flow.calls).toBe(0); + + const undoRejected = createHandler({ + tableResult: ok(table), + undoFailure: domainError.unexpected({ message: 'Undo store unavailable' }), + }); + expect((await undoRejected.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'unexpected' + ); + expect(undoRejected.flow.calls).toBe(1); + expect(undoRejected.appendCreate).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/v2/core/src/commands/CreateViewHandler.ts b/packages/v2/core/src/commands/CreateViewHandler.ts new file mode 100644 index 0000000000..b0d5d02e34 --- /dev/null +++ b/packages/v2/core/src/commands/CreateViewHandler.ts @@ -0,0 +1,117 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { ViewPluginCreationService } from '../application/services/ViewPluginCreationService'; +import { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import type { DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { Table as TableAggregate, type Table } from '../domain/table/Table'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type * as ExecutionContextPort from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { CreateViewCommand } from './CreateViewCommand'; + +export class CreateViewResult { + private constructor( + readonly table: Table, + readonly viewId: ViewId, + readonly events: ReadonlyArray + ) {} + + static create( + table: Table, + viewId: ViewId, + events: ReadonlyArray + ): CreateViewResult { + return new CreateViewResult(table, viewId, [...events]); + } +} + +@CommandHandler(CreateViewCommand) +@injectable() +export class CreateViewHandler implements ICommandHandler { + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow, + @inject(v2CoreTokens.viewOperationPluginRunner) + private readonly viewOperationPluginRunner: ViewOperationPluginRunner, + @inject(v2CoreTokens.viewPluginCreationService) + private readonly viewPluginCreationService: ViewPluginCreationService, + @inject(v2CoreTokens.viewUndoRedoService) + private readonly viewUndoRedoService: ViewUndoRedoService + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + command: CreateViewCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* TableAggregate.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const prepared = yield* await handler.viewPluginCreationService.prepare( + context, + table, + command.view + ); + const createResult = yield* table.createView(prepared.input); + const { view, updateResult: viewUpdateResult } = createResult; + const pluginInstallation = handler.viewPluginCreationService.completeInstallation( + prepared, + view + ); + const queryDefaults = yield* view.queryDefaults(); + + const pluginExecution = yield* await handler.viewOperationPluginRunner.prepare({ + kind: ViewOperationKind.create, + executionContext: context, + payload: { + tableId: table.id().toString(), + currentViewCount: table.views().length, + addedViewCount: 1, + view: { + name: view.name().toString(), + description: view.description(), + filter: queryDefaults.filter(), + sort: queryDefaults.sort(), + group: queryDefaults.group(), + options: view.options(), + }, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + + const installation = pluginInstallation; + const updateResult = yield* await handler.tableUpdateFlow.execute( + context, + { table }, + () => ok(viewUpdateResult), + installation + ? { + hooks: { + prepare: async (transactionContext) => + handler.viewPluginCreationService + .insertInstallation(transactionContext, installation) + .then((result) => result.map(() => [] as ReadonlyArray)), + }, + } + : undefined + ); + const snapshot = yield* handler.viewUndoRedoService.capture( + updateResult.table, + view.id().toString() + ); + yield* await handler.viewUndoRedoService.appendCreate(context, updateResult.table, snapshot); + return ok(CreateViewResult.create(updateResult.table, view.id(), updateResult.events)); + }); + } +} diff --git a/packages/v2/core/src/commands/DeleteFieldHandler.ts b/packages/v2/core/src/commands/DeleteFieldHandler.ts index eae610ad0f..7eaf996127 100644 --- a/packages/v2/core/src/commands/DeleteFieldHandler.ts +++ b/packages/v2/core/src/commands/DeleteFieldHandler.ts @@ -25,16 +25,15 @@ import { } from '../domain/table/OnTeableFieldDeleted'; import type { ITableSpecVisitor } from '../domain/table/specs/ITableSpecVisitor'; import { TableUpdateViewColumnMetaSpec } from '../domain/table/specs/TableUpdateViewColumnMetaSpec'; +import { TableUpdateViewOptionsSpec } from '../domain/table/specs/TableUpdateViewOptionsSpec'; import { TableUpdateViewQueryDefaultsSpec } from '../domain/table/specs/TableUpdateViewQueryDefaultsSpec'; import { Table as TableAggregate } from '../domain/table/Table'; import type { Table } from '../domain/table/Table'; import { TableUpdateResult } from '../domain/table/TableMutator'; import { implementsOnTeableViewFieldDeleted } from '../domain/table/views/OnTeableViewFieldDeleted'; import * as ExecutionContextPort from '../ports/ExecutionContext'; -import type { - IFieldDeleteSnapshotSink, - IFieldDeleteSnapshotSinkCompletion, -} from '../ports/FieldDeleteSnapshotSink'; +import { IFieldDeleteSnapshotSink } from '../ports/FieldDeleteSnapshotSink'; +import type { IFieldDeleteSnapshotSinkCompletion } from '../ports/FieldDeleteSnapshotSink'; import { FieldOperationKind, FieldOperationTargetKind } from '../ports/FieldOperationPlugin'; import * as TableRepositoryPort from '../ports/TableRepository'; import { v2CoreTokens } from '../ports/tokens'; @@ -446,9 +445,24 @@ export class DeleteFieldHandler implements ICommandHandler; +export type { IRecordRemovalReason }; + +export interface IDeleteRecordsCommandOptions { + /** + * Why the records are removed. 'archived' suppresses the trash snapshot projection and + * the delete undo capture: the archive orchestrator persists its own snapshot before + * deleting. Never accepted from the HTTP contract — internal callers only. + */ + removalReason?: IRecordRemovalReason; + /** + * Pre-built archive snapshot rows (removalReason 'archived' only). On the original + * archive they feed the undo entry's redo command; on a redo replay the handler + * re-persists them (write-ahead, same transaction) before deleting — an undo removed + * them. Never accepted from the HTTP contract — internal callers only. + */ + archiveRows?: ReadonlyArray; + /** + * Undo-stack group id (removalReason 'archived' only): a streamed archive spans + * several delete commands, one shared id pops them as a single undo/redo step. + * Never accepted from the HTTP contract — internal callers only. + */ + archiveGroupId?: string; +} + export class DeleteRecordsCommand { + readonly removalReason?: IRecordRemovalReason; + readonly archiveRows?: ReadonlyArray; + readonly archiveGroupId?: string; + private constructor( readonly tableId: TableId, - readonly recordIds: ReadonlyArray - ) {} + readonly recordIds: ReadonlyArray, + options?: IDeleteRecordsCommandOptions + ) { + this.removalReason = options?.removalReason; + this.archiveRows = options?.archiveRows; + this.archiveGroupId = options?.archiveGroupId; + } - static create(raw: unknown): Result { + static create( + raw: unknown, + options?: IDeleteRecordsCommandOptions + ): Result { const parsed = deleteRecordsInputSchema.safeParse(raw); if (!parsed.success) { return err( @@ -32,7 +70,7 @@ export class DeleteRecordsCommand { return TableId.create(parsed.data.tableId).andThen((tableId) => parseRecordIds(parsed.data.recordIds).map( - (recordIds) => new DeleteRecordsCommand(tableId, recordIds) + (recordIds) => new DeleteRecordsCommand(tableId, recordIds, options) ) ); } diff --git a/packages/v2/core/src/commands/DeleteRecordsHandler.ts b/packages/v2/core/src/commands/DeleteRecordsHandler.ts index bfa75d4ca5..1cdccc59f6 100644 --- a/packages/v2/core/src/commands/DeleteRecordsHandler.ts +++ b/packages/v2/core/src/commands/DeleteRecordsHandler.ts @@ -127,6 +127,7 @@ export class DeleteRecordsHandler if (pluginBeforePersist.isErr()) { return err(pluginBeforePersist.error); } + const deleteResult = await handler.tableRecordRepository.deleteMany( transactionContext, table, @@ -141,6 +142,45 @@ export class DeleteRecordsHandler return err(deleteResult.error); } + // Redo of an archive: the undo removed the archive snapshot rows, so they + // are re-persisted before this transaction commits. Runs after the delete + // only to learn which rows it actually removed: the carried payload is + // narrowed to them (a row purged from the archive meanwhile must stay + // purged) and stamped with the replay time so the re-archived row sorts + // after the undo's restore tombstone instead of being suppressed by it in + // cold storage. The original archive call never enters here — its rows + // were written by the orchestrator before the command ran. + if ( + command.removalReason === 'archived' && + command.archiveRows?.length && + context.undoRedo?.mode === 'redo' + ) { + const deletedIds = new Set( + (deleteResult.value.deletedRecords ?? []).map((snapshot) => snapshot.recordId) + ); + const replayTime = new Date().toISOString(); + const replayRows = command.archiveRows + .filter((row) => deletedIds.has(row.recordId)) + .map((row) => ({ ...row, createdTime: replayTime })); + if (replayRows.length > 0) { + if (!handler.tableRecordRepository.insertArchiveTrashRows) { + return err( + domainError.validation({ + message: 'Repository does not support archive snapshot persistence', + }) + ); + } + const persistResult = await handler.tableRecordRepository.insertArchiveTrashRows( + transactionContext, + table, + replayRows + ); + if (persistResult.isErr()) { + return err(persistResult.error); + } + } + } + return ok(deleteResult.value); } ); @@ -182,28 +222,51 @@ export class DeleteRecordsHandler chunkIndex: 0, scope: 'operation', }, + removalReason: command.removalReason, }), ]; yield* await handler.eventBus.publishMany(context, events); if (recordSnapshots.length > 0) { - yield* await handler.undoRedoStackService.appendRecordDelete( - toUndoRedoStackAppendContext(context), - { - tableId: table.id(), - deletedRecords: recordSnapshots.map((snapshot) => ({ - recordId: snapshot.id, - fields: snapshot.fields, - ...(snapshot.version !== undefined ? { version: snapshot.version } : {}), - ...(snapshot.orders ? { orders: snapshot.orders } : {}), - ...(snapshot.autoNumber !== undefined ? { autoNumber: snapshot.autoNumber } : {}), - ...(snapshot.createdTime ? { createdTime: snapshot.createdTime } : {}), - ...(snapshot.createdBy ? { createdBy: snapshot.createdBy } : {}), - ...(snapshot.lastModifiedTime ? { lastModifiedTime: snapshot.lastModifiedTime } : {}), - ...(snapshot.lastModifiedBy ? { lastModifiedBy: snapshot.lastModifiedBy } : {}), - })), - } - ); + const stackRecords = recordSnapshots.map((snapshot) => ({ + recordId: snapshot.id, + fields: snapshot.fields, + ...(snapshot.version !== undefined ? { version: snapshot.version } : {}), + ...(snapshot.orders ? { orders: snapshot.orders } : {}), + ...(snapshot.autoNumber !== undefined ? { autoNumber: snapshot.autoNumber } : {}), + ...(snapshot.createdTime ? { createdTime: snapshot.createdTime } : {}), + ...(snapshot.createdBy ? { createdBy: snapshot.createdBy } : {}), + ...(snapshot.lastModifiedTime ? { lastModifiedTime: snapshot.lastModifiedTime } : {}), + ...(snapshot.lastModifiedBy ? { lastModifiedBy: snapshot.lastModifiedBy } : {}), + })); + + if (command.removalReason !== 'archived') { + yield* await handler.undoRedoStackService.appendRecordDelete( + toUndoRedoStackAppendContext(context), + { + tableId: table.id(), + deletedRecords: stackRecords, + } + ); + } else if (command.archiveRows?.length) { + // Archive removals get a dedicated entry: undo restores the records AND cleans + // the archive snapshot rows + kept attachment refs; redo re-persists the carried + // snapshot rows before deleting again. A plain delete entry would leave the + // record in both places after undo and lose the archive snapshot on redo. + // The carried rows are narrowed to what this run actually deleted so a later + // undo/redo round cannot resurrect rows purged in between. + const deletedIdSet = new Set(deletedRecordIds); + yield* await handler.undoRedoStackService.appendRecordArchive( + toUndoRedoStackAppendContext(context), + { + tableId: table.id(), + archivedRecords: stackRecords, + recordIds: deletedRecordIds, + archiveRows: command.archiveRows.filter((row) => deletedIdSet.has(row.recordId)), + ...(command.archiveGroupId ? { groupId: command.archiveGroupId } : {}), + } + ); + } } await pluginExecution.afterCommit(); diff --git a/packages/v2/core/src/commands/DeleteViewCommand.ts b/packages/v2/core/src/commands/DeleteViewCommand.ts new file mode 100644 index 0000000000..03028f815d --- /dev/null +++ b/packages/v2/core/src/commands/DeleteViewCommand.ts @@ -0,0 +1,40 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; +import { PublicCommand } from './PublicCommand'; + +export const deleteViewInputSchema = z.object({ + tableId: z.string(), + viewId: z.string(), +}); + +export type IDeleteViewCommandInput = z.input; + +export class DeleteViewCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = deleteViewInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid DeleteViewCommand input', + details: z.formatError(parsed.error), + }) + ); + } + + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map((viewId) => new DeleteViewCommand(tableId, viewId)) + ); + } +} diff --git a/packages/v2/core/src/commands/DeleteViewHandler.spec.ts b/packages/v2/core/src/commands/DeleteViewHandler.spec.ts new file mode 100644 index 0000000000..5aef414017 --- /dev/null +++ b/packages/v2/core/src/commands/DeleteViewHandler.spec.ts @@ -0,0 +1,308 @@ +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import type { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import { BaseId } from '../domain/base/BaseId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { FieldUpdated } from '../domain/table/events/FieldUpdated'; +import { ViewDeleted } from '../domain/table/events/ViewDeleted'; +import { FieldId } from '../domain/table/fields/FieldId'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { LinkFieldConfig } from '../domain/table/fields/types/LinkFieldConfig'; +import type { ITableSpecVisitor } from '../domain/table/specs/ITableSpecVisitor'; +import type { Table } from '../domain/table/Table'; +import { Table as TableAggregate } from '../domain/table/Table'; +import { TableId } from '../domain/table/TableId'; +import type { TableUpdateResult } from '../domain/table/TableMutator'; +import { TableName } from '../domain/table/TableName'; +import type { IEventBus } from '../ports/EventBus'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository, TableFindOneOptions } from '../ports/TableRepository'; +import type { IUnitOfWork } from '../ports/UnitOfWork'; +import { DeleteViewCommand } from './DeleteViewCommand'; +import { DeleteViewHandler } from './DeleteViewHandler'; + +const baseId = BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap(); +const tableId = (seed: string) => TableId.create(`tbl${seed.repeat(16)}`)._unsafeUnwrap(); +const fieldId = (seed: string) => FieldId.create(`fld${seed.repeat(16)}`)._unsafeUnwrap(); + +const buildPlainTable = (seed: string): Table => { + const builder = TableAggregate.builder() + .withId(tableId(seed)) + .withBaseId(baseId) + .withName(TableName.create(`Table ${seed}`)._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(fieldId(seed)) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .primary() + .done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +const buildLinkedTables = () => { + const sourceTableId = tableId('b'); + const foreignTableId = tableId('c'); + const sourceLinkFieldId = fieldId('d'); + const foreignLinkFieldId = fieldId('e'); + + const sourceBuilder = TableAggregate.builder() + .withId(sourceTableId) + .withBaseId(baseId) + .withName(TableName.create('Source')._unsafeUnwrap()); + sourceBuilder + .field() + .singleLineText() + .withId(fieldId('b')) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .primary() + .done(); + sourceBuilder + .field() + .link() + .withId(sourceLinkFieldId) + .withName(FieldName.create('Foreign')._unsafeUnwrap()) + .withConfig( + LinkFieldConfig.create({ + relationship: 'manyMany', + foreignTableId: foreignTableId.toString(), + lookupFieldId: fieldId('c').toString(), + symmetricFieldId: foreignLinkFieldId.toString(), + isOneWay: false, + })._unsafeUnwrap() + ) + .done(); + sourceBuilder.view().defaultGrid().done(); + const originalSource = sourceBuilder.build()._unsafeUnwrap(); + const createdView = originalSource + .createView({ type: 'grid', name: 'Temporary' }) + ._unsafeUnwrap(); + const source = createdView.updateResult.table; + source.pullDomainEvents(); + + const foreignBuilder = TableAggregate.builder() + .withId(foreignTableId) + .withBaseId(baseId) + .withName(TableName.create('Foreign')._unsafeUnwrap()); + foreignBuilder + .field() + .singleLineText() + .withId(fieldId('c')) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .primary() + .done(); + foreignBuilder + .field() + .link() + .withId(foreignLinkFieldId) + .withName(FieldName.create('Source')._unsafeUnwrap()) + .withConfig( + LinkFieldConfig.create({ + relationship: 'manyMany', + foreignTableId: sourceTableId.toString(), + lookupFieldId: fieldId('b').toString(), + symmetricFieldId: sourceLinkFieldId.toString(), + filterByViewId: createdView.view.id().toString(), + isOneWay: false, + })._unsafeUnwrap() + ) + .done(); + foreignBuilder.view().defaultGrid().done(); + + return { + source, + foreign: foreignBuilder.build()._unsafeUnwrap(), + targetViewId: createdView.view.id(), + }; +}; + +class FakeTableRepository { + readonly findOneOptions: Array = []; + + constructor(private readonly responses: Array>) {} + + async findOne( + _context: IExecutionContext, + _spec: { accept(visitor: ITableSpecVisitor): Result }, + options?: TableFindOneOptions + ): Promise> { + this.findOneOptions.push(options); + return ( + this.responses.shift() ?? + err(domainError.unexpected({ message: 'Missing fake Table response' })) + ); + } +} + +type FlowHookResult = + | ReadonlyArray + | { readonly table?: Table; readonly events: ReadonlyArray }; + +class FakeTableUpdateFlow { + readonly updatedTables: Table[] = []; + + async execute( + context: IExecutionContext, + target: { table: Table }, + mutate: (table: Table) => Result, + options?: { + hooks?: { + afterPersist?: ( + context: IExecutionContext, + table: Table, + spec: TableUpdateResult['mutateSpec'] + ) => Promise>; + }; + } + ) { + const mutationResult = mutate(target.table); + if (mutationResult.isErr()) return err(mutationResult.error); + let table = mutationResult.value.table; + const events = [...table.pullDomainEvents()]; + this.updatedTables.push(table); + + const hook = options?.hooks?.afterPersist; + if (hook) { + const hookResult = await hook(context, table, mutationResult.value.mutateSpec); + if (hookResult.isErr()) return err(hookResult.error); + const normalized = Array.isArray(hookResult.value) + ? { events: hookResult.value } + : hookResult.value; + events.push(...normalized.events); + table = normalized.table ?? table; + } + + return ok({ table, events, postPersistEvents: [] }); + } +} + +class FakeUnitOfWork implements IUnitOfWork { + calls = 0; + + async withTransaction( + context: IExecutionContext, + work: (context: IExecutionContext) => Promise> + ): Promise> { + this.calls += 1; + return work(context); + } +} + +class FakeEventBus implements IEventBus { + published: IDomainEvent[] = []; + + async publish(_context: IExecutionContext, event: IDomainEvent) { + this.published.push(event); + return ok(undefined); + } + + async publishMany(_context: IExecutionContext, events: ReadonlyArray) { + this.published.push(...events); + return ok(undefined); + } +} + +const createContext = (): IExecutionContext => + ({ + actorId: { toString: () => 'system' }, + }) as IExecutionContext; + +const createHandler = (repository: FakeTableRepository) => { + const flow = new FakeTableUpdateFlow(); + const unitOfWork = new FakeUnitOfWork(); + const eventBus = new FakeEventBus(); + const undoRedo = { + capture: vi.fn((_table: Table, viewId: string) => ok({ id: viewId } as never)), + appendDelete: vi.fn(async () => ok(undefined)), + } as unknown as ViewUndoRedoService; + const handler = new DeleteViewHandler( + repository as unknown as ITableRepository, + flow as unknown as TableUpdateFlow, + unitOfWork, + eventBus, + undoRedo + ); + return { handler, flow, unitOfWork, eventBus }; +}; + +describe('DeleteViewCommand', () => { + it('validates nominal Table and View identifiers', () => { + expect(DeleteViewCommand.create({ tableId: 'bad', viewId: 'bad' }).isErr()).toBe(true); + }); +}); + +describe('DeleteViewHandler', () => { + it('locks Table aggregate roots, clears foreign Link dependencies, and publishes events', async () => { + const setup = buildLinkedTables(); + const repository = new FakeTableRepository([ok(setup.source), ok(setup.foreign)]); + const { handler, flow, unitOfWork, eventBus } = createHandler(repository); + const command = DeleteViewCommand.create({ + tableId: setup.source.id().toString(), + viewId: setup.targetViewId.toString(), + })._unsafeUnwrap(); + + const result = await handler.handle(createContext(), command); + + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().table.views()).toHaveLength(1); + expect(unitOfWork.calls).toBe(1); + expect(repository.findOneOptions).toEqual([{ lock: 'forUpdate' }, { lock: 'forUpdate' }]); + expect(flow.updatedTables).toHaveLength(2); + expect(eventBus.published.some((event) => event instanceof ViewDeleted)).toBe(true); + expect(eventBus.published.some((event) => event instanceof FieldUpdated)).toBe(true); + }); + + it('keeps deletion valid when an orphan foreign Table is missing', async () => { + const setup = buildLinkedTables(); + const repository = new FakeTableRepository([ + ok(setup.source), + err(domainError.notFound({ message: 'Foreign Table missing' })), + ]); + const { handler, flow, eventBus } = createHandler(repository); + const command = DeleteViewCommand.create({ + tableId: setup.source.id().toString(), + viewId: setup.targetViewId.toString(), + })._unsafeUnwrap(); + + const result = await handler.handle(createContext(), command); + + expect(result.isOk()).toBe(true); + expect(flow.updatedTables).toHaveLength(1); + expect(eventBus.published).toHaveLength(1); + expect(eventBus.published[0]).toBeInstanceOf(ViewDeleted); + }); + + it('rejects deletion of the last View before persistence or event publication', async () => { + const table = buildPlainTable('f'); + const repository = new FakeTableRepository([ok(table)]); + const { handler, flow, eventBus } = createHandler(repository); + const command = DeleteViewCommand.create({ + tableId: table.id().toString(), + viewId: table.views()[0]!.id().toString(), + })._unsafeUnwrap(); + + const result = await handler.handle(createContext(), command); + + expect(result._unsafeUnwrapErr().code).toBe('view.cannot_delete_last'); + expect(flow.updatedTables).toHaveLength(0); + expect(eventBus.published).toHaveLength(0); + }); + + it('maps a missing source aggregate to table.not_found', async () => { + const repository = new FakeTableRepository([err(domainError.notFound({ message: 'Missing' }))]); + const { handler } = createHandler(repository); + const command = DeleteViewCommand.create({ + tableId: tableId('g').toString(), + viewId: `viw${'g'.repeat(16)}`, + })._unsafeUnwrap(); + + const result = await handler.handle(createContext(), command); + + expect(result._unsafeUnwrapErr().code).toBe('table.not_found'); + }); +}); diff --git a/packages/v2/core/src/commands/DeleteViewHandler.ts b/packages/v2/core/src/commands/DeleteViewHandler.ts new file mode 100644 index 0000000000..6f605963d4 --- /dev/null +++ b/packages/v2/core/src/commands/DeleteViewHandler.ts @@ -0,0 +1,193 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import type { FieldId } from '../domain/table/fields/FieldId'; +import { Table as TableAggregate, type Table } from '../domain/table/Table'; +import type { TableId } from '../domain/table/TableId'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type { ViewSnapshotValue } from '../domain/table/views/ViewSnapshot'; +import * as EventBusPort from '../ports/EventBus'; +import type * as ExecutionContextPort from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import * as UnitOfWorkPort from '../ports/UnitOfWork'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { DeleteViewCommand } from './DeleteViewCommand'; + +export class DeleteViewResult { + private constructor( + readonly table: Table, + readonly viewId: ViewId, + readonly deletedSnapshot: ViewSnapshotValue, + readonly events: ReadonlyArray + ) {} + + static create( + table: Table, + viewId: ViewId, + deletedSnapshot: ViewSnapshotValue, + events: ReadonlyArray + ): DeleteViewResult { + return new DeleteViewResult(table, viewId, deletedSnapshot, [...events]); + } +} + +type LinkDependencyGroup = { + readonly foreignTableId: TableId; + readonly fieldIds: ReadonlyArray; +}; + +@CommandHandler(DeleteViewCommand) +@injectable() +export class DeleteViewHandler implements ICommandHandler { + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow, + @inject(v2CoreTokens.unitOfWork) + private readonly unitOfWork: UnitOfWorkPort.IUnitOfWork, + @inject(v2CoreTokens.eventBus) + private readonly eventBus: EventBusPort.IEventBus, + @inject(v2CoreTokens.viewUndoRedoService) + private readonly viewUndoRedoService: ViewUndoRedoService + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + command: DeleteViewCommand + ): Promise> { + const handler = this; + const transactionResult = await this.unitOfWork.withTransaction( + context, + async (transactionContext) => + safeTry(async function* () { + const tableSpec = yield* TableAggregate.specs().byId(command.tableId).build(); + const tableResult = await handler.tableRepository.findOne(transactionContext, tableSpec, { + lock: 'forUpdate', + }); + if (tableResult.isErr()) { + if (isNotFoundError(tableResult.error)) { + return err( + domainError.notFound({ code: 'table.not_found', message: 'Table not found' }) + ); + } + return err(tableResult.error); + } + + const table = tableResult.value; + const deletedSnapshot = yield* handler.viewUndoRedoService.capture( + table, + command.viewId.toString() + ); + const deleteResult = yield* table.deleteView(command.viewId); + const dependencyGroups = handler.groupLinkDependencies(deleteResult.linkDependencies); + + const updateResult = yield* await handler.tableUpdateFlow.execute( + transactionContext, + { table }, + () => ok(deleteResult.updateResult), + { + publishEvents: false, + hooks: { + afterPersist: async (currentContext, updatedTable) => + safeTry(async function* () { + const cleanupEvents: IDomainEvent[] = []; + let latestSourceTable = updatedTable; + + for (const group of dependencyGroups) { + const foreignTableSpec = yield* TableAggregate.specs() + .byId(group.foreignTableId) + .build(); + const foreignTableResult = await handler.tableRepository.findOne( + currentContext, + foreignTableSpec, + { lock: 'forUpdate' } + ); + if (foreignTableResult.isErr()) { + if (isNotFoundError(foreignTableResult.error)) continue; + return err(foreignTableResult.error); + } + + const cleanupResult = + yield* foreignTableResult.value.clearViewFilterDependencies( + command.viewId, + group.fieldIds + ); + if (!cleanupResult) continue; + + const foreignUpdateResult = yield* await handler.tableUpdateFlow.execute( + currentContext, + { table: foreignTableResult.value }, + () => ok(cleanupResult), + { publishEvents: false } + ); + cleanupEvents.push( + ...foreignUpdateResult.events, + ...foreignUpdateResult.postPersistEvents + ); + if (group.foreignTableId.equals(command.tableId)) { + latestSourceTable = foreignUpdateResult.table; + } + } + + return ok({ table: latestSourceTable, events: cleanupEvents }); + }), + }, + } + ); + + const events = [...updateResult.events, ...updateResult.postPersistEvents]; + return ok( + DeleteViewResult.create(updateResult.table, command.viewId, deletedSnapshot, events) + ); + }), + { scope: 'meta' } + ); + + if (transactionResult.isErr()) return err(transactionResult.error); + if (transactionResult.value.events.length > 0) { + const publishResult = await this.eventBus.publishMany( + context, + transactionResult.value.events + ); + if (publishResult.isErr()) return err(publishResult.error); + } + const undoRedoResult = await this.viewUndoRedoService.appendDelete( + context, + transactionResult.value.table, + transactionResult.value.deletedSnapshot + ); + if (undoRedoResult.isErr()) return err(undoRedoResult.error); + return ok(transactionResult.value); + } + + private groupLinkDependencies( + dependencies: ReadonlyArray<{ + foreignTableId: TableId; + symmetricFieldId: FieldId; + }> + ): ReadonlyArray { + const groups = new Map(); + for (const dependency of dependencies) { + const key = dependency.foreignTableId.toString(); + const group = groups.get(key) ?? { + foreignTableId: dependency.foreignTableId, + fieldIds: [], + }; + if (!group.fieldIds.some((fieldId) => fieldId.equals(dependency.symmetricFieldId))) { + group.fieldIds.push(dependency.symmetricFieldId); + } + groups.set(key, group); + } + + return [...groups.values()].sort((left, right) => + left.foreignTableId.toString().localeCompare(right.foreignTableId.toString()) + ); + } +} diff --git a/packages/v2/core/src/commands/DisableViewShareCommand.ts b/packages/v2/core/src/commands/DisableViewShareCommand.ts new file mode 100644 index 0000000000..23a0ca1b56 --- /dev/null +++ b/packages/v2/core/src/commands/DisableViewShareCommand.ts @@ -0,0 +1,42 @@ +import { err, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; +import { PublicCommand } from './PublicCommand'; + +export const disableViewShareInputSchema = z + .object({ + tableId: z.string(), + viewId: z.string(), + }) + .strict(); + +export type IDisableViewShareCommandInput = z.input; + +export class DisableViewShareCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = disableViewShareInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid DisableViewShareCommand input', + details: z.formatError(parsed.error), + }) + ); + } + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map( + (viewId) => new DisableViewShareCommand(tableId, viewId) + ) + ); + } +} diff --git a/packages/v2/core/src/commands/DisableViewShareHandler.spec.ts b/packages/v2/core/src/commands/DisableViewShareHandler.spec.ts new file mode 100644 index 0000000000..5063c3c1bf --- /dev/null +++ b/packages/v2/core/src/commands/DisableViewShareHandler.spec.ts @@ -0,0 +1,219 @@ +import { err, ok, type Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import type { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { ViewShareDisabled } from '../domain/table/events/ViewShareDisabled'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { TableUpdateViewShareStateSpec } from '../domain/table/specs/TableUpdateViewShareStateSpec'; +import { Table } from '../domain/table/Table'; +import type { TableUpdateResult } from '../domain/table/TableMutator'; +import { TableName } from '../domain/table/TableName'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { DisableViewShareCommand } from './DisableViewShareCommand'; +import { DisableViewShareHandler } from './DisableViewShareHandler'; + +const context: IExecutionContext = { actorId: ActorId.create('actor')._unsafeUnwrap() }; + +const buildSharedTable = (): { table: Table; viewId: ViewId; shareId: string } => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'b'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Share lifecycle')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + const original = builder.build()._unsafeUnwrap(); + const viewId = original.views()[0]!.id(); + const enabled = original.enableViewShare(viewId)._unsafeUnwrap(); + enabled.updateResult.table.pullDomainEvents(); + return { + table: enabled.updateResult.table, + viewId, + shareId: enabled.shareId, + }; +}; + +class FakeTableUpdateFlow { + calls = 0; + mutateSpec?: TableUpdateResult['mutateSpec']; + + async execute( + _context: IExecutionContext, + target: { table: Table }, + mutate: (table: Table) => Result + ) { + this.calls += 1; + const result = mutate(target.table); + if (result.isErr()) return err(result.error); + this.mutateSpec = result.value.mutateSpec; + return ok({ + table: result.value.table, + events: result.value.table.pullDomainEvents(), + postPersistEvents: [], + }); + } +} + +const createHandler = (params: { + tableResult: Result; + plugins?: IViewOperationPlugin[]; + undoFailure?: DomainError; +}) => { + const repository = { + findOne: vi.fn(async () => params.tableResult), + } as unknown as ITableRepository; + const flow = new FakeTableUpdateFlow(); + const appendShareLifecycle = vi.fn(async () => + params.undoFailure ? err(params.undoFailure) : ok(undefined) + ); + const handler = new DisableViewShareHandler( + repository, + flow as unknown as TableUpdateFlow, + new ViewOperationPluginRunner(params.plugins), + { appendShareLifecycle } as unknown as ViewUndoRedoService + ); + return { handler, repository, flow, appendShareLifecycle }; +}; + +describe('DisableViewShareCommand', () => { + it('validates Table and View identifiers', () => { + expect(DisableViewShareCommand.create({ tableId: 'bad', viewId: 'bad' }).isErr()).toBe(true); + }); +}); + +describe('DisableViewShareHandler', () => { + it('disables sharing through the Table aggregate and records lifecycle undo history', async () => { + const { table, viewId, shareId } = buildSharedTable(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler({ + tableResult: ok(table), + plugins: [ + { + name: 'capture', + supports: (kind) => kind === ViewOperationKind.update, + prepare, + guard: () => ok(undefined), + }, + ], + }); + const command = DisableViewShareCommand.create({ + tableId: table.id().toString(), + viewId: viewId.toString(), + })._unsafeUnwrap(); + + const result = (await setup.handler.handle(context, command))._unsafeUnwrap(); + const disabledView = result.table.getView(viewId)._unsafeUnwrap(); + + expect(setup.repository.findOne).toHaveBeenCalledOnce(); + expect(setup.flow.mutateSpec).toBeInstanceOf(TableUpdateViewShareStateSpec); + expect(result.previousShareId).toBe(shareId); + expect(disabledView.enableShare()).toBe(false); + expect(disabledView.shareId()).toBe(shareId); + expect(result.events.some((event) => event instanceof ViewShareDisabled)).toBe(true); + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: ViewOperationKind.update, + payload: { + tableId: table.id().toString(), + viewId: viewId.toString(), + patch: { + enableShare: false, + shareId, + shareMeta: { includeRecords: true }, + }, + }, + }) + ); + expect(setup.appendShareLifecycle).toHaveBeenCalledWith( + context, + result.table, + viewId.toString(), + 'disable' + ); + }); + + it('rejects an already disabled View before plugins, persistence, or undo history', async () => { + const shared = buildSharedTable(); + const table = shared.table.disableViewShare(shared.viewId)._unsafeUnwrap().updateResult.table; + table.pullDomainEvents(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler({ + tableResult: ok(table), + plugins: [ + { + name: 'capture', + supports: () => true, + prepare, + }, + ], + }); + const command = DisableViewShareCommand.create({ + tableId: table.id().toString(), + viewId: shared.viewId.toString(), + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result._unsafeUnwrapErr().code).toBe('validation.invalid'); + expect(prepare).not.toHaveBeenCalled(); + expect(setup.flow.calls).toBe(0); + expect(setup.appendShareLifecycle).not.toHaveBeenCalled(); + }); + + it('does not persist or append history when plugin policy rejects disabling', async () => { + const { table, viewId } = buildSharedTable(); + const setup = createHandler({ + tableResult: ok(table), + plugins: [ + { + name: 'reject', + supports: () => true, + guard: () => err(domainError.forbidden({ message: 'Disable rejected' })), + }, + ], + }); + const command = DisableViewShareCommand.create({ + tableId: table.id().toString(), + viewId: viewId.toString(), + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result._unsafeUnwrapErr().code).toBe('forbidden'); + expect(setup.flow.calls).toBe(0); + expect(setup.appendShareLifecycle).not.toHaveBeenCalled(); + }); + + it('propagates repository and undo history failures at their orchestration boundaries', async () => { + const { table, viewId } = buildSharedTable(); + const command = DisableViewShareCommand.create({ + tableId: table.id().toString(), + viewId: viewId.toString(), + })._unsafeUnwrap(); + const missing = createHandler({ + tableResult: err(domainError.notFound({ message: 'Missing Table' })), + }); + + expect((await missing.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'not_found' + ); + expect(missing.flow.calls).toBe(0); + + const undoRejected = createHandler({ + tableResult: ok(table), + undoFailure: domainError.unexpected({ message: 'Undo store unavailable' }), + }); + expect((await undoRejected.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'unexpected' + ); + expect(undoRejected.flow.calls).toBe(1); + expect(undoRejected.appendShareLifecycle).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/v2/core/src/commands/DisableViewShareHandler.ts b/packages/v2/core/src/commands/DisableViewShareHandler.ts new file mode 100644 index 0000000000..1511c7a989 --- /dev/null +++ b/packages/v2/core/src/commands/DisableViewShareHandler.ts @@ -0,0 +1,99 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry, type Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import type { DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { Table as TableAggregate, type Table } from '../domain/table/Table'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type * as ExecutionContextPort from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { DisableViewShareCommand } from './DisableViewShareCommand'; + +export class DisableViewShareResult { + private constructor( + readonly table: Table, + readonly viewId: ViewId, + readonly previousShareId: string | undefined, + readonly events: ReadonlyArray + ) {} + + static create(params: { + table: Table; + viewId: ViewId; + previousShareId: string | undefined; + events: ReadonlyArray; + }): DisableViewShareResult { + return new DisableViewShareResult(params.table, params.viewId, params.previousShareId, [ + ...params.events, + ]); + } +} + +@CommandHandler(DisableViewShareCommand) +@injectable() +export class DisableViewShareHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow, + @inject(v2CoreTokens.viewOperationPluginRunner) + private readonly viewOperationPluginRunner: ViewOperationPluginRunner, + @inject(v2CoreTokens.viewUndoRedoService) + private readonly viewUndoRedoService: ViewUndoRedoService + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + command: DisableViewShareCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* TableAggregate.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const shareResult = yield* table.disableViewShare(command.viewId); + + const pluginExecution = yield* await handler.viewOperationPluginRunner.prepare({ + kind: ViewOperationKind.update, + executionContext: context, + payload: { + tableId: table.id().toString(), + viewId: command.viewId.toString(), + patch: { + enableShare: false, + shareId: shareResult.shareId, + shareMeta: shareResult.view.shareMeta(), + }, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + + const update = yield* await handler.tableUpdateFlow.execute(context, { table }, () => + ok(shareResult.updateResult) + ); + yield* await handler.viewUndoRedoService.appendShareLifecycle( + context, + update.table, + command.viewId.toString(), + 'disable' + ); + return ok( + DisableViewShareResult.create({ + table: update.table, + viewId: command.viewId, + previousShareId: shareResult.previousShareId, + events: update.events, + }) + ); + }); + } +} diff --git a/packages/v2/core/src/commands/DuplicateBaseByIdCommand.spec.ts b/packages/v2/core/src/commands/DuplicateBaseByIdCommand.spec.ts new file mode 100644 index 0000000000..521772247c --- /dev/null +++ b/packages/v2/core/src/commands/DuplicateBaseByIdCommand.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { DuplicateBaseByIdCommand } from './DuplicateBaseByIdCommand'; + +describe('DuplicateBaseByIdCommand', () => { + it('creates a structure-only command by default', () => { + const sourceBaseId = `bse${'a'.repeat(16)}`; + const result = DuplicateBaseByIdCommand.create({ sourceBaseId }); + + const command = result._unsafeUnwrap(); + expect(command.sourceBaseId.toString()).toBe(sourceBaseId); + expect(command.withRecords).toBe(false); + expect(command.batchSize).toBe(500); + }); + + it('accepts explicit target identity, name and record options', () => { + const sourceBaseId = `bse${'a'.repeat(16)}`; + const targetBaseId = `bse${'b'.repeat(16)}`; + const result = DuplicateBaseByIdCommand.create({ + sourceBaseId, + targetBaseId, + name: 'Copy', + withRecords: true, + batchSize: 1000, + }); + + const command = result._unsafeUnwrap(); + expect(command.targetBaseId?.toString()).toBe(targetBaseId); + expect(command.baseName?.toString()).toBe('Copy'); + expect(command.withRecords).toBe(true); + expect(command.batchSize).toBe(1000); + }); + + it.each([ + { sourceBaseId: 123 }, + { sourceBaseId: `bse${'a'.repeat(16)}`, name: '' }, + { sourceBaseId: `bse${'a'.repeat(16)}`, name: 'x'.repeat(101) }, + { sourceBaseId: `bse${'a'.repeat(16)}`, batchSize: 0 }, + { sourceBaseId: `bse${'a'.repeat(16)}`, unknown: true }, + ])('rejects invalid input %#', (input) => { + expect(DuplicateBaseByIdCommand.create(input).isErr()).toBe(true); + }); +}); diff --git a/packages/v2/core/src/commands/DuplicateBaseByIdCommand.ts b/packages/v2/core/src/commands/DuplicateBaseByIdCommand.ts new file mode 100644 index 0000000000..d87976a0b0 --- /dev/null +++ b/packages/v2/core/src/commands/DuplicateBaseByIdCommand.ts @@ -0,0 +1,67 @@ +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { BaseId } from '../domain/base/BaseId'; +import { BaseName } from '../domain/base/BaseName'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { DEFAULT_TABLE_DATA_SAFETY_LIMITS } from '../domain/shared/TableDataSafetyLimits'; +import { MAX_SELECTION_STREAM_BATCH_SIZE } from './shared/streamBatchSize'; + +export const duplicateBaseByIdInputSchema = z + .object({ + sourceBaseId: z.string(), + targetBaseId: z.string().optional(), + name: z.string().max(DEFAULT_TABLE_DATA_SAFETY_LIMITS.displayText.maxNameLength).optional(), + withRecords: z.boolean().default(false), + batchSize: z.number().int().min(1).max(MAX_SELECTION_STREAM_BATCH_SIZE).optional(), + }) + .strict(); + +export type IDuplicateBaseByIdCommandInput = z.input; + +export class DuplicateBaseByIdCommand { + readonly __publicCommandBrand = 'public' as const; + + private constructor( + readonly sourceBaseId: BaseId, + readonly targetBaseId: BaseId | undefined, + readonly baseName: BaseName | undefined, + readonly withRecords: boolean, + readonly batchSize: number + ) {} + + static create(raw: unknown): Result { + const parsed = duplicateBaseByIdInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid DuplicateBaseByIdCommand input', + details: z.formatError(parsed.error), + }) + ); + } + + const targetBaseIdResult: Result = + parsed.data.targetBaseId !== undefined + ? BaseId.create(parsed.data.targetBaseId) + : ok(undefined); + const baseNameResult: Result = + parsed.data.name !== undefined ? BaseName.create(parsed.data.name) : ok(undefined); + + return BaseId.create(parsed.data.sourceBaseId).andThen((sourceBaseId) => + targetBaseIdResult.andThen((targetBaseId) => + baseNameResult.map( + (baseName) => + new DuplicateBaseByIdCommand( + sourceBaseId, + targetBaseId, + baseName, + parsed.data.withRecords, + parsed.data.batchSize ?? 500 + ) + ) + ) + ); + } +} diff --git a/packages/v2/core/src/commands/DuplicateBaseByIdHandler.spec.ts b/packages/v2/core/src/commands/DuplicateBaseByIdHandler.spec.ts new file mode 100644 index 0000000000..f8554e94a8 --- /dev/null +++ b/packages/v2/core/src/commands/DuplicateBaseByIdHandler.spec.ts @@ -0,0 +1,89 @@ +import { ok } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import { Base } from '../domain/base/Base'; +import { BaseId } from '../domain/base/BaseId'; +import { BaseName } from '../domain/base/BaseName'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError } from '../domain/shared/DomainError'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import { DuplicateBaseByIdCommand } from './DuplicateBaseByIdCommand'; +import { DuplicateBaseByIdHandler } from './DuplicateBaseByIdHandler'; + +describe('DuplicateBaseByIdHandler', () => { + it('uses bounded base transactions, preserves the domain error, and cleans up failure', async () => { + const sourceBaseId = BaseId.create(`bse${'s'.repeat(16)}`)._unsafeUnwrap(); + const sourceBase = Base.builder() + .withId(sourceBaseId) + .withName(BaseName.create('Source')._unsafeUnwrap()) + .build() + ._unsafeUnwrap(); + const context: IExecutionContext = { + actorId: ActorId.create('system')._unsafeUnwrap(), + }; + let insertedBaseId: BaseId | undefined; + const baseRepository = { + findOne: vi.fn(async () => ok(sourceBase)), + insert: vi.fn(async (_context: IExecutionContext, base: Base) => { + insertedBaseId = base.id(); + return ok(base); + }), + delete: vi.fn(async () => ok(undefined)), + }; + const tableRepository = { + find: vi.fn(async () => ok([])), + }; + const forbidden = domainError.forbidden({ + code: 'duplicate_base.forbidden', + message: 'duplicate forbidden', + }); + const commandBus = { + execute: vi.fn(async (_context: IExecutionContext) => { + const stream = (async function* () { + const event = { + id: 'error' as const, + code: forbidden.code, + message: forbidden.message, + error: forbidden, + }; + yield event; + })(); + return ok(stream); + }), + }; + const transactionScopes: string[] = []; + const unitOfWork = { + withTransaction: vi.fn( + async ( + _context: IExecutionContext, + callback: (transactionContext: IExecutionContext) => Promise, + options?: { scope?: string } + ) => { + transactionScopes.push(options?.scope ?? 'data'); + return callback(context); + } + ), + }; + const handler = new DuplicateBaseByIdHandler( + baseRepository as never, + tableRepository as never, + {} as never, + {} as never, + commandBus as never, + { publishMany: vi.fn(async () => ok(undefined)) } as never, + unitOfWork as never + ); + const command = DuplicateBaseByIdCommand.create({ + sourceBaseId: sourceBaseId.toString(), + withRecords: false, + })._unsafeUnwrap(); + + const result = await handler.handle(context, command); + + expect(result._unsafeUnwrapErr()).toBe(forbidden); + expect(insertedBaseId).toBeDefined(); + expect(baseRepository.delete).toHaveBeenCalledWith(context, insertedBaseId); + expect(transactionScopes).toEqual(['meta', 'meta']); + expect(commandBus.execute).toHaveBeenCalledWith(context, expect.anything()); + }); +}); diff --git a/packages/v2/core/src/commands/DuplicateBaseByIdHandler.ts b/packages/v2/core/src/commands/DuplicateBaseByIdHandler.ts new file mode 100644 index 0000000000..6ad13e5779 --- /dev/null +++ b/packages/v2/core/src/commands/DuplicateBaseByIdHandler.ts @@ -0,0 +1,324 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { Base } from '../domain/base/Base'; +import { BaseName } from '../domain/base/BaseName'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { TableByBaseIdSpec } from '../domain/table/specs/TableByBaseIdSpec'; +import type { Table } from '../domain/table/Table'; +import * as BaseRepositoryPort from '../ports/BaseRepository'; +import * as CommandBusPort from '../ports/CommandBus'; +import type { NormalizedDotTeaField, NormalizedDotTeaStructure } from '../ports/DotTeaParser'; +import * as EventBusPort from '../ports/EventBus'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import * as TableMapperPort from '../ports/mappers/TableMapper'; +import * as TableRecordQueryRepositoryPort from '../ports/TableRecordQueryRepository'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import * as UnitOfWorkPort from '../ports/UnitOfWork'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { DeleteTableCommand } from './DeleteTableCommand'; +import { DuplicateBaseByIdCommand } from './DuplicateBaseByIdCommand'; +import { + DuplicateBaseCommand, + type DuplicateBaseDoneEvent, + type DuplicateBaseErrorEvent, + type DuplicateBaseResult, + type DuplicateBaseSource, +} from './DuplicateBaseCommand'; + +const errorFromDuplicateEvent = (event: DuplicateBaseErrorEvent): DomainError => { + if (event.error) return event.error; + const params = { + code: event.code ?? 'duplicate_base.failed', + message: event.message, + }; + return domainError.unexpected(params); +}; + +export class DuplicateBaseByIdResult { + private constructor( + readonly base: Base, + readonly tableIdMap: Readonly>, + readonly fieldIdMap: Readonly>, + readonly viewIdMap: Readonly>, + readonly recordsLength: number, + readonly events: ReadonlyArray + ) {} + + static create( + base: Base, + duplicate: DuplicateBaseDoneEvent, + events: ReadonlyArray + ): DuplicateBaseByIdResult { + return new DuplicateBaseByIdResult( + base, + duplicate.tableIdMap, + duplicate.fieldIdMap, + duplicate.viewIdMap, + duplicate.recordsLength, + [...events] + ); + } +} + +const asRecord = (value: unknown): Record | undefined => + value && typeof value === 'object' ? (value as Record) : undefined; + +const toNormalizedField = ( + field: TableMapperPort.ITableFieldPersistenceDTO, + primaryFieldId: string +): NormalizedDotTeaField => { + const lookupOptions = 'lookupOptions' in field ? asRecord(field.lookupOptions) : undefined; + const options = field.isLookup ? lookupOptions : asRecord(field.options); + const config = 'config' in field ? asRecord(field.config) : undefined; + + return { + id: field.id, + name: field.name, + type: field.isLookup ? 'lookup' : field.type, + isPrimary: field.id === primaryFieldId, + ...(field.dbFieldName ? { dbFieldName: field.dbFieldName } : {}), + ...(field.description !== undefined ? { description: field.description } : {}), + ...(field.aiConfig !== undefined ? { aiConfig: field.aiConfig } : {}), + ...(field.notNull !== undefined ? { notNull: field.notNull } : {}), + ...(field.unique !== undefined ? { unique: field.unique } : {}), + ...(options ? { options } : {}), + ...(config ? { config } : {}), + ...('cellValueType' in field && field.cellValueType + ? { cellValueType: field.cellValueType } + : {}), + ...(field.isMultipleCellValue !== undefined + ? { isMultipleCellValue: field.isMultipleCellValue } + : {}), + }; +}; + +@CommandHandler(DuplicateBaseByIdCommand) +@injectable() +export class DuplicateBaseByIdHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.baseRepository) + private readonly baseRepository: BaseRepositoryPort.IBaseRepository, + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableRecordQueryRepository) + private readonly tableRecordQueryRepository: TableRecordQueryRepositoryPort.ITableRecordQueryRepository, + @inject(v2CoreTokens.tableMapper) + private readonly tableMapper: TableMapperPort.ITableMapper, + @inject(v2CoreTokens.internalCommandBus) + private readonly commandBus: CommandBusPort.ICommandBus, + @inject(v2CoreTokens.eventBus) + private readonly eventBus: EventBusPort.IEventBus, + @inject(v2CoreTokens.unitOfWork) + private readonly unitOfWork: UnitOfWorkPort.IUnitOfWork + ) {} + + async handle( + context: IExecutionContext, + command: DuplicateBaseByIdCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const sourceBase = yield* await handler.baseRepository.findOne(context, command.sourceBaseId); + if (!sourceBase) { + return err( + domainError.notFound({ + code: 'base.not_found', + message: 'Source base not found', + details: { baseId: command.sourceBaseId.toString() }, + }) + ); + } + + const sourceTables = yield* await handler.tableRepository.find( + context, + TableByBaseIdSpec.create(command.sourceBaseId) + ); + const snapshot = yield* handler.toSnapshot(sourceTables, command.sourceBaseId.toString()); + + const targetName = + command.baseName ?? (yield* BaseName.create(`${sourceBase.name().toString()} (Copy)`)); + + const baseBuilder = Base.builder().withName(targetName); + if (command.targetBaseId) baseBuilder.withId(command.targetBaseId); + const targetBase = yield* baseBuilder.build(); + + yield* await handler.unitOfWork.withTransaction( + context, + async (transactionContext) => handler.baseRepository.insert(transactionContext, targetBase), + { scope: 'meta' } + ); + + const source = handler.createSource( + context, + snapshot.structure, + snapshot.tableSnapshots, + new Map(sourceTables.map((table) => [table.id().toString(), table])), + command.batchSize + ); + const duplicateResult = await handler.executeDuplicate(context, targetBase, source, command); + if (duplicateResult.isErr()) { + const cleanupResult = await handler.cleanupFailedDuplicate(context, targetBase); + if (cleanupResult.isErr()) { + return err( + domainError.infrastructure({ + code: 'duplicate_base.cleanup_failed', + message: `${duplicateResult.error.message}; cleanup failed: ${cleanupResult.error.message}`, + details: { + duplicateErrorCode: duplicateResult.error.code, + cleanupErrorCode: cleanupResult.error.code, + }, + cause: duplicateResult.error, + }) + ); + } + return err(duplicateResult.error); + } + const duplicate = duplicateResult.value; + + const events = targetBase.pullDomainEvents(); + yield* await handler.eventBus.publishMany(context, events); + return ok(DuplicateBaseByIdResult.create(targetBase, duplicate, events)); + }); + } + + private async executeDuplicate( + context: IExecutionContext, + targetBase: Base, + source: DuplicateBaseSource, + command: DuplicateBaseByIdCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const duplicateCommand = yield* DuplicateBaseCommand.createFromSource({ + baseId: targetBase.id().toString(), + source, + withRecords: command.withRecords, + batchSize: command.batchSize, + }); + const stream = yield* await handler.commandBus.execute< + DuplicateBaseCommand, + DuplicateBaseResult + >(context, duplicateCommand); + + let done: DuplicateBaseDoneEvent | undefined; + for await (const event of stream) { + if (event.id === 'error') return err(errorFromDuplicateEvent(event)); + if (event.id === 'done') done = event; + } + if (!done) { + return err( + domainError.invariant({ + code: 'duplicate_base.missing_result', + message: 'Duplicate base completed without a result', + }) + ); + } + return ok(done); + }); + } + + private async cleanupFailedDuplicate( + context: IExecutionContext, + targetBase: Base + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tables = yield* await handler.tableRepository.find( + context, + TableByBaseIdSpec.create(targetBase.id()), + { state: 'all' } + ); + for (const table of [...tables].reverse()) { + const deleteCommand = yield* DeleteTableCommand.create({ + baseId: targetBase.id().toString(), + tableId: table.id().toString(), + mode: 'permanent', + }); + yield* await handler.commandBus.execute( + context, + deleteCommand + ); + } + yield* await handler.unitOfWork.withTransaction( + context, + async (transactionContext) => + handler.baseRepository.delete(transactionContext, targetBase.id()), + { scope: 'meta' } + ); + return ok(undefined); + }); + } + + private toSnapshot( + tables: ReadonlyArray
, + sourceBaseId: string + ): Result< + { + structure: NormalizedDotTeaStructure; + tableSnapshots: ReadonlyMap; + }, + DomainError + > { + const mapped = tables.map((table) => this.tableMapper.toDTO(table)); + const firstError = mapped.find((result) => result.isErr()); + if (firstError?.isErr()) return err(firstError.error); + + const snapshots = mapped.map((result) => result._unsafeUnwrap()); + return ok({ + structure: { + id: sourceBaseId, + tables: snapshots.map((dto) => ({ + id: dto.id, + name: dto.name, + fields: dto.fields.map((field) => toNormalizedField(field, dto.primaryFieldId)), + views: dto.views.map((view) => ({ id: view.id, name: view.name, type: view.type })), + })), + }, + tableSnapshots: new Map(snapshots.map((dto) => [dto.id, dto])), + }); + } + + private createSource( + context: IExecutionContext, + structure: NormalizedDotTeaStructure, + tableSnapshots: ReadonlyMap, + tablesById: ReadonlyMap, + batchSize: number + ): DuplicateBaseSource { + const recordRepository = this.tableRecordQueryRepository; + return { + structure, + tableSnapshots, + async *records(tableId: string) { + const table = tablesById.get(tableId); + if (!table) return; + + for await (const recordResult of recordRepository.findStream(context, table, undefined, { + mode: 'stored', + includeOrders: true, + batchSize, + })) { + if (recordResult.isErr()) throw recordResult.error; + const record = recordResult.value; + yield { + recordId: record.id, + fields: record.fields, + version: record.version, + orders: record.orders, + autoNumber: record.autoNumber, + createdTime: record.createdTime, + createdBy: record.createdBy, + lastModifiedTime: record.lastModifiedTime, + lastModifiedBy: record.lastModifiedBy, + }; + } + }, + }; + } +} diff --git a/packages/v2/core/src/commands/DuplicateBaseCommand.ts b/packages/v2/core/src/commands/DuplicateBaseCommand.ts index ec19d63d9f..c5b4e29133 100644 --- a/packages/v2/core/src/commands/DuplicateBaseCommand.ts +++ b/packages/v2/core/src/commands/DuplicateBaseCommand.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import { BaseId } from '../domain/base/BaseId'; import { domainError, type DomainError } from '../domain/shared/DomainError'; import type { NormalizedDotTeaStructure } from '../ports/DotTeaParser'; +import type { ITablePersistenceDTO } from '../ports/mappers/TableMapper'; import { MAX_SELECTION_STREAM_BATCH_SIZE } from './shared/streamBatchSize'; export interface DuplicateBaseRecordInput { @@ -27,6 +28,8 @@ export interface DuplicateBaseRecordReadOptions { export interface DuplicateBaseSource { structure: NormalizedDotTeaStructure; + /** Exact v2 snapshots for native duplication; portable imports may omit this map. */ + tableSnapshots?: ReadonlyMap; records( tableId: string, options?: DuplicateBaseRecordReadOptions @@ -65,6 +68,8 @@ export interface DuplicateBaseErrorEvent { id: 'error'; message: string; code?: string; + /** In-process diagnostic; non-enumerable so streaming contracts stay stable. */ + error?: DomainError; } export type DuplicateBaseEvent = diff --git a/packages/v2/core/src/commands/DuplicateBaseHandler.spec.ts b/packages/v2/core/src/commands/DuplicateBaseHandler.spec.ts index f3f3fedeed..6b1dadd2c2 100644 --- a/packages/v2/core/src/commands/DuplicateBaseHandler.spec.ts +++ b/packages/v2/core/src/commands/DuplicateBaseHandler.spec.ts @@ -22,6 +22,9 @@ const createHandler = ( eventBus?: unknown; unitOfWork?: unknown; computedFieldBackfillService?: unknown; + tableMapper?: unknown; + recordWritePluginRunner?: unknown; + tableOperationPluginRunner?: unknown; } = {} ) => new DuplicateBaseHandler( @@ -32,7 +35,10 @@ const createHandler = ( (overrides.unitOfWork ?? {}) as never, (overrides.computedFieldBackfillService ?? { executeSyncMany: vi.fn(async () => ok(undefined)), - }) as never + }) as never, + overrides.tableMapper as never, + overrides.recordWritePluginRunner as never, + overrides.tableOperationPluginRunner as never ); describe('DuplicateBaseHandler', () => { @@ -86,9 +92,18 @@ describe('DuplicateBaseHandler', () => { yield ok(updates); }), }; + const pluginExecution = { + guard: vi.fn(async () => ok(undefined)), + beforePersist: vi.fn(async () => ok(undefined)), + afterCommit: vi.fn(async () => undefined), + }; + const recordWritePluginRunner = { + prepare: vi.fn(async () => ok(pluginExecution)), + }; const handler = createHandler({ tableRecordRepository, unitOfWork, + recordWritePluginRunner, }); const sourceTextFieldId = 'fldaaaaaaaaaaaaaaaa'; const sourceLinkFieldId = 'fldbbbbbbbbbbbbbbbb'; @@ -170,6 +185,16 @@ describe('DuplicateBaseHandler', () => { assumeEmptyLinkState: true, }) ); + expect(recordWritePluginRunner.prepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'duplicateStream', + table: targetTable, + payload: expect.objectContaining({ recordCount: 1, batchSize: 500 }), + }) + ); + expect(pluginExecution.guard).toHaveBeenCalledOnce(); + expect(pluginExecution.beforePersist).toHaveBeenCalledOnce(); + expect(pluginExecution.afterCommit).toHaveBeenCalledOnce(); }); it('skips restore updates for two-way one-many inverse link fields', async () => { @@ -553,11 +578,16 @@ describe('DuplicateBaseHandler', () => { callback({ tx: true }) ), }; + const tablePluginExecution = { guard: vi.fn(async () => ok(undefined)) }; + const tableOperationPluginRunner = { + prepare: vi.fn(async () => ok(tablePluginExecution)), + }; const handler = createHandler({ foreignTableLoaderService: { load: vi.fn(async () => ok([])) }, tableCreationService, eventBus: { publishMany: vi.fn(async () => ok(undefined)) }, unitOfWork, + tableOperationPluginRunner, }); const sourceTableId = 'tblSourceA'; const sourceFieldId = 'fldaaaaaaaaaaaaaaaa'; @@ -592,6 +622,22 @@ describe('DuplicateBaseHandler', () => { sideEffectOptions: { skipFieldCreationSideEffects: true }, }) ); + expect(tableOperationPluginRunner.prepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'createMany', + payload: expect.objectContaining({ + baseId: expect.objectContaining({}), + tables: [ + expect.objectContaining({ + fieldCount: 1, + viewCount: 1, + recordCount: 0, + }), + ], + }), + }) + ); + expect(tablePluginExecution.guard).toHaveBeenCalledOnce(); }); it('remaps aiConfig references when duplicating fields', async () => { diff --git a/packages/v2/core/src/commands/DuplicateBaseHandler.ts b/packages/v2/core/src/commands/DuplicateBaseHandler.ts index 285dce6524..681becbd68 100644 --- a/packages/v2/core/src/commands/DuplicateBaseHandler.ts +++ b/packages/v2/core/src/commands/DuplicateBaseHandler.ts @@ -3,9 +3,12 @@ import { err, ok, safeTry } from 'neverthrow'; import type { Result } from 'neverthrow'; import { ForeignTableLoaderService } from '../application/services/ForeignTableLoaderService'; +import { RecordWritePluginRunner } from '../application/services/RecordWritePluginRunner'; import { TableCreationService } from '../application/services/TableCreationService'; +import { TableOperationPluginRunner } from '../application/services/TableOperationPluginRunner'; import type { BaseId } from '../domain/base/BaseId'; import { domainError, isDomainError, type DomainError } from '../domain/shared/DomainError'; +import { TableCreated } from '../domain/table/events/TableCreated'; import { FieldId } from '../domain/table/fields/FieldId'; import { validateForeignTablesForFields } from '../domain/table/fields/ForeignTableRelatedField'; import type { LinkForeignTableReference } from '../domain/table/fields/visitors/LinkForeignTableReferenceVisitor'; @@ -13,13 +16,20 @@ import { calculateBatchSize } from '../domain/table/methods/records/calculateBat import { RecordId } from '../domain/table/records/RecordId'; import { TableRecord } from '../domain/table/records/TableRecord'; import { TableRecordCellValue } from '../domain/table/records/TableRecordFields'; +import { resolveFormulaFields } from '../domain/table/resolveFormulaFields'; import type { Table } from '../domain/table/Table'; import { TableId } from '../domain/table/TableId'; import { ViewId } from '../domain/table/views/ViewId'; -import type { IComputedFieldBackfillService } from '../ports/ComputedFieldBackfillService'; +import { IComputedFieldBackfillService } from '../ports/ComputedFieldBackfillService'; +import { NoopLogger } from '../ports/defaults/NoopLogger'; import type { NormalizedDotTeaStructure } from '../ports/DotTeaParser'; import * as EventBusPort from '../ports/EventBus'; import type { IExecutionContext } from '../ports/ExecutionContext'; +import { DefaultTableMapper } from '../ports/mappers/defaults/DefaultTableMapper'; +import { ITableMapper } from '../ports/mappers/TableMapper'; +import type { ITablePersistenceDTO } from '../ports/mappers/TableMapper'; +import { RecordWriteOperationKind } from '../ports/RecordWritePlugin'; +import { TableOperationKind } from '../ports/TableOperationPlugin'; import type { InsertManyStreamBatch, RecordRestoreSystemValues, @@ -81,6 +91,23 @@ const replaceMappedIds = (value: T, replacements: ReadonlyMap return JSON.parse(serialized) as T; }; +const resetDuplicatedViewIdentity = ( + view: ITablePersistenceDTO['views'][number] +): ITablePersistenceDTO['views'][number] => { + const { + version: _version, + enableShare: _enableShare, + shareId: _shareId, + shareMeta: _shareMeta, + createdBy: _createdBy, + createdTime: _createdTime, + lastModifiedBy: _lastModifiedBy, + lastModifiedTime: _lastModifiedTime, + ...portableState + } = view; + return { ...portableState, enableShare: false }; +}; + @CommandHandler(DuplicateBaseCommand) @injectable() export class DuplicateBaseHandler @@ -98,7 +125,20 @@ export class DuplicateBaseHandler @inject(v2CoreTokens.unitOfWork) private readonly unitOfWork: UnitOfWorkPort.IUnitOfWork, @inject(v2CoreTokens.computedFieldBackfillService) - private readonly computedFieldBackfillService: IComputedFieldBackfillService + private readonly computedFieldBackfillService: IComputedFieldBackfillService, + @inject(v2CoreTokens.tableMapper) + private readonly tableMapper: ITableMapper = new DefaultTableMapper(), + @inject(v2CoreTokens.recordWritePluginRunner) + private readonly recordWritePluginRunner: RecordWritePluginRunner = new RecordWritePluginRunner( + [], + new NoopLogger(), + new DefaultTableMapper() + ), + @inject(v2CoreTokens.tableOperationPluginRunner) + private readonly tableOperationPluginRunner: TableOperationPluginRunner = new TableOperationPluginRunner( + [], + new NoopLogger() + ) ) {} async handle( @@ -220,10 +260,28 @@ export class DuplicateBaseHandler normalized ); + const replacements = new Map([ + ...(normalized.id ? ([[normalized.id, command.baseId.toString()]] as const) : []), + ...Object.entries(tableIdMap), + ...Object.entries(fieldIdMap), + ...Object.entries(viewIdMap), + ]); const buildResults = yield* sequence( remapped.tables.map((table, tableIndex) => { const tableId = table.id!; const tableName = table.name ?? `Table ${tableIndex + 1}`; + const sourceTableId = normalized.tables[tableIndex]?.id; + const snapshot = sourceTableId + ? command.source.tableSnapshots?.get(sourceTableId) + : undefined; + if (snapshot) { + return handler.buildTableFromSnapshot(snapshot, { + baseId: command.baseId, + tableId, + tableName, + replacements, + }); + } return buildTableFromInput( { baseId: command.baseId.toString(), @@ -250,13 +308,31 @@ export class DuplicateBaseHandler name: view.name, })), }, - { executionContext: context } + { executionContext: context, aiConfigMode: 'trustedRehydrate' } ); }) ); const builtTables = buildResults.map((r) => r.table); const referencesByTable = buildResults.map((r) => r.foreignTableReferences); + const tablePluginExecution = yield* await handler.tableOperationPluginRunner.prepare({ + kind: TableOperationKind.createMany, + executionContext: context, + payload: { + baseId: command.baseId, + tables: builtTables.map((table) => ({ + baseId: command.baseId, + tableName: table.name(), + table, + fieldCount: table.getFields().length, + viewCount: table.views().length, + recordCount: 0, + viewNames: table.views().map((view) => view.name().toString()), + })), + }, + isTransactionBound: false, + }); + yield* await tablePluginExecution.guard(); const allReferences = uniqueForeignTableReferences(referencesByTable.flat()); const internalTableIds = new Set(builtTables.map((table) => table.id().toString())); const externalReferences = allReferences.filter( @@ -315,6 +391,44 @@ export class DuplicateBaseHandler }); } + private buildTableFromSnapshot( + snapshot: ITablePersistenceDTO, + params: { + baseId: BaseId; + tableId: string; + tableName: string; + replacements: ReadonlyMap; + } + ) { + const remapped = replaceMappedIds(snapshot, params.replacements); + const dto: ITablePersistenceDTO = { + ...remapped, + id: params.tableId, + baseId: params.baseId.toString(), + name: params.tableName, + dbTableName: `${params.baseId.toString()}.${params.tableId}`, + fields: remapped.fields, + views: remapped.views.map(resetDuplicatedViewIdentity), + }; + + return this.tableMapper.toDomain(dto).andThen((table) => + resolveFormulaFields(table).andThen(() => + table.foreignTableReferences().map((foreignTableReferences) => { + table.addDomainEvent( + TableCreated.create({ + tableId: table.id(), + baseId: table.baseId(), + tableName: table.name(), + fieldIds: table.fieldIds(), + viewIds: table.viewIds(), + }) + ); + return { table, fieldSpecs: [], foreignTableReferences }; + }) + ) + ); + } + private async backfillComputedFields( context: IExecutionContext, targetTables: ReadonlyArray
@@ -433,21 +547,50 @@ export class DuplicateBaseHandler this.getSourceLinkFieldIds(command, params.sourceTableId) )) { const currentBatchIndex = batchIndex; + const pluginExecutionResult = await this.recordWritePluginRunner.prepare({ + kind: RecordWriteOperationKind.duplicateStream, + executionContext: context, + table: params.targetTable, + payload: { + sourceRecordIds: batch.records.map((record) => record.id()), + recordsFieldValues: batch.records.map(tableRecordToRecordWriteFieldValues), + batchSize, + recordCount: batch.records.length, + }, + isTransactionBound: false, + }); + if (pluginExecutionResult.isErr()) { + yield this.errorEvent(pluginExecutionResult.error); + return; + } + const pluginExecution = pluginExecutionResult.value; + const guardResult = await pluginExecution.guard(); + if (guardResult.isErr()) { + yield this.errorEvent(guardResult.error); + return; + } + + const recordRepository = this.tableRecordRepository; const result = await this.unitOfWork.withTransaction(context, async (transactionContext) => - this.tableRecordRepository.insertManyStream( - transactionContext, - params.targetTable, - [batch], - { - skipComputedUpdates: true, - skipChangedFields: true, - } - ) + safeTry<{ totalInserted: number }, DomainError>(async function* () { + yield* await pluginExecution.beforePersist(transactionContext); + const inserted = yield* await recordRepository.insertManyStream( + transactionContext, + params.targetTable, + [batch], + { + skipComputedUpdates: true, + skipChangedFields: true, + } + ); + return ok(inserted); + }) ); if (result.isErr()) { yield this.errorEvent(result.error); return; } + await pluginExecution.afterCommit(); totalInserted += result.value.totalInserted; yield { @@ -686,10 +829,25 @@ export class DuplicateBaseHandler } private errorEvent(error: DomainError): DuplicateBaseEvent { - return { + const event: DuplicateBaseEvent = { id: 'error', message: error.message, code: error.code, }; + Object.defineProperty(event, 'error', { + value: error, + enumerable: false, + configurable: false, + writable: false, + }); + return event; } } + +const tableRecordToRecordWriteFieldValues = (record: TableRecord): ReadonlyMap => + new Map( + record + .fields() + .entries() + .map((entry) => [entry.fieldId.toString(), entry.value.toValue()] as const) + ); diff --git a/packages/v2/core/src/commands/DuplicateFieldHandler.spec.ts b/packages/v2/core/src/commands/DuplicateFieldHandler.spec.ts index 4c3be8e995..6c20bfffca 100644 --- a/packages/v2/core/src/commands/DuplicateFieldHandler.spec.ts +++ b/packages/v2/core/src/commands/DuplicateFieldHandler.spec.ts @@ -245,7 +245,7 @@ describe('DuplicateFieldHandler', () => { } expect(result.error.code).toBe(TABLE_FIELD_LIMIT_ERROR_CODE); - expect(result.error.message).toContain('limit:2'); + expect(result.error.message).toBe('Table "Duplicate Field Table" can have at most 2 fields.'); expect(result.error.details).toMatchObject({ tableName: 'Duplicate Field Table', currentFieldCount: 2, diff --git a/packages/v2/core/src/commands/DuplicateViewCommand.ts b/packages/v2/core/src/commands/DuplicateViewCommand.ts new file mode 100644 index 0000000000..d0c7196c6f --- /dev/null +++ b/packages/v2/core/src/commands/DuplicateViewCommand.ts @@ -0,0 +1,40 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; +import { PublicCommand } from './PublicCommand'; + +export const duplicateViewInputSchema = z.object({ + tableId: z.string(), + viewId: z.string(), +}); + +export type IDuplicateViewCommandInput = z.input; + +export class DuplicateViewCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = duplicateViewInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid DuplicateViewCommand input', + details: z.formatError(parsed.error), + }) + ); + } + + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map((viewId) => new DuplicateViewCommand(tableId, viewId)) + ); + } +} diff --git a/packages/v2/core/src/commands/DuplicateViewHandler.spec.ts b/packages/v2/core/src/commands/DuplicateViewHandler.spec.ts new file mode 100644 index 0000000000..5264f40978 --- /dev/null +++ b/packages/v2/core/src/commands/DuplicateViewHandler.spec.ts @@ -0,0 +1,220 @@ +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { ViewPluginCreationService } from '../application/services/ViewPluginCreationService'; +import type { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { TableAddViewSpec } from '../domain/table/specs/TableAddViewSpec'; +import { Table } from '../domain/table/Table'; +import type { TableUpdateResult } from '../domain/table/TableMutator'; +import { TableName } from '../domain/table/TableName'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import type { IViewPluginRepository, ViewPluginInstallation } from '../ports/ViewPluginRepository'; +import { DuplicateViewCommand } from './DuplicateViewCommand'; +import { DuplicateViewHandler } from './DuplicateViewHandler'; + +const context: IExecutionContext = { + actorId: ActorId.create('actor')._unsafeUnwrap(), +}; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +const buildPluginRepository = (): IViewPluginRepository => ({ + findViewPlugin: vi.fn(async () => + ok({ + id: 'plg-view', + name: 'Plugin', + logo: 'https://example.test/plugin.png', + }) + ), + findViewPluginInstallationByViewId: vi.fn(async () => + ok({ storage: JSON.stringify({ copied: true }) }) + ), + insertViewPluginInstallation: vi.fn(async () => ok(undefined)), + getViewPluginInstallation: vi.fn(async () => err(domainError.notFound({ message: 'Not used' }))), + updateViewPluginStorage: vi.fn(async () => err(domainError.notFound({ message: 'Not used' }))), +}); + +class FakeTableUpdateFlow { + calls = 0; + mutateSpec?: TableUpdateResult['mutateSpec']; + + async execute( + currentContext: IExecutionContext, + target: { table: Table }, + mutate: (table: Table) => Result, + options?: { + hooks?: { + prepare?: ( + context: IExecutionContext, + table: Table, + spec: TableUpdateResult['mutateSpec'] + ) => Promise, DomainError>>; + }; + } + ) { + this.calls += 1; + const result = mutate(target.table); + if (result.isErr()) return err(result.error); + this.mutateSpec = result.value.mutateSpec; + if (options?.hooks?.prepare) { + const hookResult = await options.hooks.prepare( + currentContext, + result.value.table, + result.value.mutateSpec + ); + if (hookResult.isErr()) return err(hookResult.error); + } + return ok({ table: result.value.table, events: [], postPersistEvents: [] }); + } +} + +const createHandler = ( + table: Table, + pluginRepository = buildPluginRepository(), + operationPlugins: IViewOperationPlugin[] = [] +) => { + const tableRepository = { + findOne: vi.fn(async () => ok(table)), + } as unknown as ITableRepository; + const tableUpdateFlow = new FakeTableUpdateFlow(); + const pluginService = new ViewPluginCreationService(pluginRepository); + const pluginRunner = new ViewOperationPluginRunner(operationPlugins); + const undoRedo = { + capture: vi.fn((_table: Table, viewId: string) => ok({ id: viewId } as never)), + appendCreate: vi.fn(async () => ok(undefined)), + } as unknown as ViewUndoRedoService; + const handler = new DuplicateViewHandler( + tableRepository, + tableUpdateFlow as unknown as TableUpdateFlow, + pluginRunner, + pluginService, + undoRedo + ); + return { handler, tableRepository, tableUpdateFlow, pluginRepository }; +}; + +describe('DuplicateViewCommand', () => { + it('validates Table and View identifiers', () => { + expect(DuplicateViewCommand.create({ tableId: 'bad', viewId: 'bad' }).isErr()).toBe(true); + }); +}); + +describe('DuplicateViewHandler', () => { + it('orchestrates the Table aggregate, duplicate operation plugin, and TableAddViewSpec', async () => { + const table = buildTable(); + const sourceView = table.views()[0]!; + const seenOperations: unknown[] = []; + const operationPlugin: IViewOperationPlugin = { + name: 'capture', + supports: (kind) => kind === ViewOperationKind.duplicate, + prepare: (pluginContext) => { + seenOperations.push(pluginContext); + return ok(undefined); + }, + guard: () => ok(undefined), + }; + const setup = createHandler(table, buildPluginRepository(), [operationPlugin]); + const command = DuplicateViewCommand.create({ + tableId: table.id().toString(), + viewId: sourceView.id().toString(), + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().table.views()).toHaveLength(2); + expect(result._unsafeUnwrap().viewId.equals(sourceView.id())).toBe(false); + expect(setup.tableRepository.findOne).toHaveBeenCalledOnce(); + expect(setup.tableUpdateFlow.calls).toBe(1); + expect(setup.tableUpdateFlow.mutateSpec).toBeInstanceOf(TableAddViewSpec); + expect(seenOperations).toEqual([ + expect.objectContaining({ + kind: 'duplicate', + payload: expect.objectContaining({ + tableId: table.id().toString(), + currentViewCount: 1, + addedViewCount: 1, + sourceViewId: sourceView.id().toString(), + }), + }), + ]); + }); + + it('copies Plugin storage into the new installation in the persistence transaction', async () => { + const baseTable = buildTable(); + const sourceResult = baseTable + .createView({ + type: 'plugin', + name: 'Plugin', + options: { + pluginId: 'plg-view', + pluginInstallId: 'pli-stale-option', + pluginLogo: 'old-logo', + }, + }) + ._unsafeUnwrap(); + const table = sourceResult.updateResult.table; + const pluginRepository = buildPluginRepository(); + const setup = createHandler(table, pluginRepository); + const command = DuplicateViewCommand.create({ + tableId: table.id().toString(), + viewId: sourceResult.view.id().toString(), + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result.isOk()).toBe(true); + expect(pluginRepository.findViewPluginInstallationByViewId).toHaveBeenCalledWith( + context, + sourceResult.view.id().toString() + ); + const installation = vi.mocked(pluginRepository.insertViewPluginInstallation).mock + .calls[0]?.[1] as ViewPluginInstallation; + const duplicatedView = result + ._unsafeUnwrap() + .table.getView(result._unsafeUnwrap().viewId) + ._unsafeUnwrap(); + expect(installation.storage).toBe(JSON.stringify({ copied: true })); + expect(installation.viewId).toBe(duplicatedView.id().toString()); + expect((duplicatedView.options() as { pluginInstallId: string }).pluginInstallId).toBe( + installation.id + ); + expect(installation.id).not.toBe('pli-stale-option'); + }); + + it('does not persist when the duplicate operation guard rejects the request', async () => { + const table = buildTable(); + const operationPlugin: IViewOperationPlugin = { + name: 'reject', + supports: (kind) => kind === ViewOperationKind.duplicate, + guard: () => err(domainError.forbidden({ message: 'View limit reached' })), + }; + const setup = createHandler(table, buildPluginRepository(), [operationPlugin]); + const command = DuplicateViewCommand.create({ + tableId: table.id().toString(), + viewId: table.views()[0]!.id().toString(), + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result._unsafeUnwrapErr().code).toBe('forbidden'); + expect(setup.tableUpdateFlow.calls).toBe(0); + }); +}); diff --git a/packages/v2/core/src/commands/DuplicateViewHandler.ts b/packages/v2/core/src/commands/DuplicateViewHandler.ts new file mode 100644 index 0000000000..445378c438 --- /dev/null +++ b/packages/v2/core/src/commands/DuplicateViewHandler.ts @@ -0,0 +1,120 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { ViewPluginCreationService } from '../application/services/ViewPluginCreationService'; +import { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import type { DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { Table as TableAggregate, type Table } from '../domain/table/Table'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type * as ExecutionContextPort from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { DuplicateViewCommand } from './DuplicateViewCommand'; + +export class DuplicateViewResult { + private constructor( + readonly table: Table, + readonly viewId: ViewId, + readonly events: ReadonlyArray + ) {} + + static create( + table: Table, + viewId: ViewId, + events: ReadonlyArray + ): DuplicateViewResult { + return new DuplicateViewResult(table, viewId, [...events]); + } +} + +@CommandHandler(DuplicateViewCommand) +@injectable() +export class DuplicateViewHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow, + @inject(v2CoreTokens.viewOperationPluginRunner) + private readonly viewOperationPluginRunner: ViewOperationPluginRunner, + @inject(v2CoreTokens.viewPluginCreationService) + private readonly viewPluginCreationService: ViewPluginCreationService, + @inject(v2CoreTokens.viewUndoRedoService) + private readonly viewUndoRedoService: ViewUndoRedoService + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + command: DuplicateViewCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* TableAggregate.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const prepared = yield* await handler.viewPluginCreationService.prepareDuplicate( + context, + table, + command.viewId + ); + const duplicateResult = yield* table.duplicateView(command.viewId, prepared.input); + const { view, updateResult: viewUpdateResult } = duplicateResult; + const pluginInstallation = handler.viewPluginCreationService.completeInstallation( + prepared, + view + ); + const queryDefaults = yield* view.queryDefaults(); + + const pluginExecution = yield* await handler.viewOperationPluginRunner.prepare({ + kind: ViewOperationKind.duplicate, + executionContext: context, + payload: { + tableId: table.id().toString(), + currentViewCount: table.views().length, + addedViewCount: 1, + sourceViewId: command.viewId.toString(), + view: { + name: view.name().toString(), + description: view.description(), + filter: queryDefaults.filter(), + sort: queryDefaults.sort(), + group: queryDefaults.group(), + options: view.options(), + }, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + + const installation = pluginInstallation; + const updateResult = yield* await handler.tableUpdateFlow.execute( + context, + { table }, + () => ok(viewUpdateResult), + installation + ? { + hooks: { + prepare: async (transactionContext) => + handler.viewPluginCreationService + .insertInstallation(transactionContext, installation) + .then((result) => result.map(() => [] as ReadonlyArray)), + }, + } + : undefined + ); + const snapshot = yield* handler.viewUndoRedoService.capture( + updateResult.table, + view.id().toString() + ); + yield* await handler.viewUndoRedoService.appendCreate(context, updateResult.table, snapshot); + return ok(DuplicateViewResult.create(updateResult.table, view.id(), updateResult.events)); + }); + } +} diff --git a/packages/v2/core/src/commands/EnableViewShareCommand.ts b/packages/v2/core/src/commands/EnableViewShareCommand.ts new file mode 100644 index 0000000000..860c403925 --- /dev/null +++ b/packages/v2/core/src/commands/EnableViewShareCommand.ts @@ -0,0 +1,40 @@ +import { err, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; +import { PublicCommand } from './PublicCommand'; + +export const enableViewShareInputSchema = z + .object({ + tableId: z.string(), + viewId: z.string(), + }) + .strict(); + +export type IEnableViewShareCommandInput = z.input; + +export class EnableViewShareCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = enableViewShareInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid EnableViewShareCommand input', + details: z.formatError(parsed.error), + }) + ); + } + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map((viewId) => new EnableViewShareCommand(tableId, viewId)) + ); + } +} diff --git a/packages/v2/core/src/commands/EnableViewShareHandler.spec.ts b/packages/v2/core/src/commands/EnableViewShareHandler.spec.ts new file mode 100644 index 0000000000..b87cc76ed6 --- /dev/null +++ b/packages/v2/core/src/commands/EnableViewShareHandler.spec.ts @@ -0,0 +1,222 @@ +import { err, ok, type Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import type { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { ViewShareEnabled } from '../domain/table/events/ViewShareEnabled'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { TableUpdateViewShareStateSpec } from '../domain/table/specs/TableUpdateViewShareStateSpec'; +import { Table } from '../domain/table/Table'; +import type { TableUpdateResult } from '../domain/table/TableMutator'; +import { TableName } from '../domain/table/TableName'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { EnableViewShareCommand } from './EnableViewShareCommand'; +import { EnableViewShareHandler } from './EnableViewShareHandler'; + +const context: IExecutionContext = { actorId: ActorId.create('actor')._unsafeUnwrap() }; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Share lifecycle')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + const table = builder.build()._unsafeUnwrap(); + table.pullDomainEvents(); + return table; +}; + +class FakeTableUpdateFlow { + calls = 0; + mutateSpec?: TableUpdateResult['mutateSpec']; + + constructor(private readonly failure?: DomainError) {} + + async execute( + _context: IExecutionContext, + target: { table: Table }, + mutate: (table: Table) => Result + ) { + this.calls += 1; + if (this.failure) return err(this.failure); + const result = mutate(target.table); + if (result.isErr()) return err(result.error); + this.mutateSpec = result.value.mutateSpec; + return ok({ + table: result.value.table, + events: result.value.table.pullDomainEvents(), + postPersistEvents: [], + }); + } +} + +const createHandler = (params: { + tableResult: Result; + plugins?: IViewOperationPlugin[]; + flowFailure?: DomainError; + undoFailure?: DomainError; +}) => { + const repository = { + findOne: vi.fn(async () => params.tableResult), + } as unknown as ITableRepository; + const flow = new FakeTableUpdateFlow(params.flowFailure); + const appendShareLifecycle = vi.fn(async () => + params.undoFailure ? err(params.undoFailure) : ok(undefined) + ); + const undoRedo = { appendShareLifecycle } as unknown as ViewUndoRedoService; + const handler = new EnableViewShareHandler( + repository, + flow as unknown as TableUpdateFlow, + new ViewOperationPluginRunner(params.plugins), + undoRedo + ); + return { handler, repository, flow, appendShareLifecycle }; +}; + +describe('EnableViewShareCommand', () => { + it('validates Table and View identifiers', () => { + expect(EnableViewShareCommand.create({ tableId: 'bad', viewId: 'bad' }).isErr()).toBe(true); + }); +}); + +describe('EnableViewShareHandler', () => { + it('enables sharing through the Table aggregate, plugin policy, persistence, and undo history', async () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler({ + tableResult: ok(table), + plugins: [ + { + name: 'capture', + supports: (kind) => kind === ViewOperationKind.update, + prepare, + guard: () => ok(undefined), + }, + ], + }); + const command = EnableViewShareCommand.create({ + tableId: table.id().toString(), + viewId: viewId.toString(), + })._unsafeUnwrap(); + + const result = (await setup.handler.handle(context, command))._unsafeUnwrap(); + const sharedView = result.table.getView(viewId)._unsafeUnwrap(); + + expect(setup.repository.findOne).toHaveBeenCalledOnce(); + expect(setup.flow.calls).toBe(1); + expect(setup.flow.mutateSpec).toBeInstanceOf(TableUpdateViewShareStateSpec); + expect(result.shareId).toMatch(/^shr[0-9a-zA-Z]{16}$/); + expect(sharedView.enableShare()).toBe(true); + expect(sharedView.shareId()).toBe(result.shareId); + expect(result.events.some((event) => event instanceof ViewShareEnabled)).toBe(true); + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: ViewOperationKind.update, + payload: { + tableId: table.id().toString(), + viewId: viewId.toString(), + patch: { + enableShare: true, + shareId: result.shareId, + shareMeta: { includeRecords: true }, + }, + }, + }) + ); + expect(setup.appendShareLifecycle).toHaveBeenCalledWith( + context, + result.table, + viewId.toString(), + 'enable' + ); + }); + + it('rejects an already shared View before plugins, persistence, or undo history', async () => { + const original = buildTable(); + const viewId = original.views()[0]!.id(); + const table = original.enableViewShare(viewId)._unsafeUnwrap().updateResult.table; + table.pullDomainEvents(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler({ + tableResult: ok(table), + plugins: [ + { + name: 'capture', + supports: () => true, + prepare, + }, + ], + }); + const command = EnableViewShareCommand.create({ + tableId: table.id().toString(), + viewId: viewId.toString(), + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result._unsafeUnwrapErr().code).toBe('validation.invalid'); + expect(prepare).not.toHaveBeenCalled(); + expect(setup.flow.calls).toBe(0); + expect(setup.appendShareLifecycle).not.toHaveBeenCalled(); + }); + + it('does not persist or append history when plugin policy rejects sharing', async () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const setup = createHandler({ + tableResult: ok(table), + plugins: [ + { + name: 'reject', + supports: () => true, + guard: () => err(domainError.forbidden({ message: 'Sharing rejected' })), + }, + ], + }); + const command = EnableViewShareCommand.create({ + tableId: table.id().toString(), + viewId: viewId.toString(), + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result._unsafeUnwrapErr().code).toBe('forbidden'); + expect(setup.flow.calls).toBe(0); + expect(setup.appendShareLifecycle).not.toHaveBeenCalled(); + }); + + it('propagates repository and undo history failures at their orchestration boundaries', async () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const command = EnableViewShareCommand.create({ + tableId: table.id().toString(), + viewId: viewId.toString(), + })._unsafeUnwrap(); + const missing = createHandler({ + tableResult: err(domainError.notFound({ message: 'Missing Table' })), + }); + + expect((await missing.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'not_found' + ); + expect(missing.flow.calls).toBe(0); + + const undoRejected = createHandler({ + tableResult: ok(table), + undoFailure: domainError.unexpected({ message: 'Undo store unavailable' }), + }); + expect((await undoRejected.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'unexpected' + ); + expect(undoRejected.flow.calls).toBe(1); + expect(undoRejected.appendShareLifecycle).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/v2/core/src/commands/EnableViewShareHandler.ts b/packages/v2/core/src/commands/EnableViewShareHandler.ts new file mode 100644 index 0000000000..c0edabb639 --- /dev/null +++ b/packages/v2/core/src/commands/EnableViewShareHandler.ts @@ -0,0 +1,99 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry, type Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import type { DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { Table as TableAggregate, type Table } from '../domain/table/Table'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type * as ExecutionContextPort from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { EnableViewShareCommand } from './EnableViewShareCommand'; + +export class EnableViewShareResult { + private constructor( + readonly table: Table, + readonly viewId: ViewId, + readonly shareId: string, + readonly events: ReadonlyArray + ) {} + + static create(params: { + table: Table; + viewId: ViewId; + shareId: string; + events: ReadonlyArray; + }): EnableViewShareResult { + return new EnableViewShareResult(params.table, params.viewId, params.shareId, [ + ...params.events, + ]); + } +} + +@CommandHandler(EnableViewShareCommand) +@injectable() +export class EnableViewShareHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow, + @inject(v2CoreTokens.viewOperationPluginRunner) + private readonly viewOperationPluginRunner: ViewOperationPluginRunner, + @inject(v2CoreTokens.viewUndoRedoService) + private readonly viewUndoRedoService: ViewUndoRedoService + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + command: EnableViewShareCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* TableAggregate.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const shareResult = yield* table.enableViewShare(command.viewId); + + const pluginExecution = yield* await handler.viewOperationPluginRunner.prepare({ + kind: ViewOperationKind.update, + executionContext: context, + payload: { + tableId: table.id().toString(), + viewId: command.viewId.toString(), + patch: { + enableShare: true, + shareId: shareResult.shareId, + shareMeta: shareResult.view.shareMeta(), + }, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + + const update = yield* await handler.tableUpdateFlow.execute(context, { table }, () => + ok(shareResult.updateResult) + ); + yield* await handler.viewUndoRedoService.appendShareLifecycle( + context, + update.table, + command.viewId.toString(), + 'enable' + ); + return ok( + EnableViewShareResult.create({ + table: update.table, + viewId: command.viewId, + shareId: shareResult.shareId, + events: update.events, + }) + ); + }); + } +} diff --git a/packages/v2/core/src/commands/ImportCsvHandler.spec.ts b/packages/v2/core/src/commands/ImportCsvHandler.spec.ts index 6b73a804fa..d77bb3bc53 100644 --- a/packages/v2/core/src/commands/ImportCsvHandler.spec.ts +++ b/packages/v2/core/src/commands/ImportCsvHandler.spec.ts @@ -353,7 +353,9 @@ describe('ImportCsvHandler', () => { ); expect(insertedFieldValues[0].get(noteFieldId)?.toValue()).toBe('hello'); expect(insertedFieldValues[1].has(noteFieldId)).toBe(false); - expect(eventBus.published.some(isRecordsBatchCreatedEvent)).toBe(true); + const batchCreatedEvents = eventBus.published.filter(isRecordsBatchCreatedEvent); + expect(batchCreatedEvents.length).toBeGreaterThan(0); + expect(batchCreatedEvents.every((event) => event.source.type === 'import')).toBe(true); expect(eventBus.published.length).toBeGreaterThan(0); expect(tableRepository.provisionStateChanges.map(({ state }) => state)).toEqual([ 'pending', diff --git a/packages/v2/core/src/commands/ImportCsvHandler.ts b/packages/v2/core/src/commands/ImportCsvHandler.ts index 5d2d3b114b..daf70870f0 100644 --- a/packages/v2/core/src/commands/ImportCsvHandler.ts +++ b/packages/v2/core/src/commands/ImportCsvHandler.ts @@ -13,6 +13,7 @@ import { import type { DomainError } from '../domain/shared/DomainError'; import { domainError } from '../domain/shared/DomainError'; import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { tableDataSafetyLimitErrors } from '../domain/shared/TableDataSafetyLimits'; import type { RecordValuesDTO } from '../domain/table/events/RecordFieldValuesDTO'; import { RecordsBatchCreated } from '../domain/table/events/RecordsBatchCreated'; import { FieldName } from '../domain/table/fields/FieldName'; @@ -255,13 +256,17 @@ export class ImportCsvHandler implements ICommandHandler command.maxRowCount) { return err( domainError.validation({ - code: 'validation.limit.rows_per_table_max', + code: tableDataSafetyLimitErrors.rowsPerTableMax.code, message: `Exceed max row limit: ${command.maxRowCount}`, details: { max: command.maxRowCount, maxRowCount: command.maxRowCount, rowCount: rows.length, }, + localization: { + i18nKey: tableDataSafetyLimitErrors.rowsPerTableMax.i18nKey, + context: { max: command.maxRowCount }, + }, }) ); } @@ -688,6 +693,7 @@ export class ImportCsvHandler implements ICommandHandler maxRowCount) { throw domainError.validation({ - code: 'validation.limit.rows_per_table_max', + code: tableDataSafetyLimitErrors.rowsPerTableMax.code, message: `Exceed max row limit: ${maxRowCount}`, details: { max: maxRowCount, maxRowCount, rowCount, }, + localization: { + i18nKey: tableDataSafetyLimitErrors.rowsPerTableMax.i18nKey, + context: { max: maxRowCount }, + }, }); } diff --git a/packages/v2/core/src/commands/ImportRecordsHandler.spec.ts b/packages/v2/core/src/commands/ImportRecordsHandler.spec.ts index 534f351de4..45fd263300 100644 --- a/packages/v2/core/src/commands/ImportRecordsHandler.spec.ts +++ b/packages/v2/core/src/commands/ImportRecordsHandler.spec.ts @@ -9,8 +9,8 @@ import { BaseId } from '../domain/base/BaseId'; import { ActorId } from '../domain/shared/ActorId'; import { domainError, type DomainError } from '../domain/shared/DomainError'; import type { IDomainEvent } from '../domain/shared/DomainEvent'; -import { isRecordsBatchCreatedEvent } from '../domain/table/events/RecordsBatchCreated'; import type { ISpecification } from '../domain/shared/specification/ISpecification'; +import { isRecordsBatchCreatedEvent } from '../domain/table/events/RecordsBatchCreated'; import { FieldId } from '../domain/table/fields/FieldId'; import { FieldName } from '../domain/table/fields/FieldName'; import type { RecordId } from '../domain/table/records/RecordId'; @@ -687,6 +687,9 @@ describe('ImportRecordsHandler', () => { expect(published).toHaveLength(2); expect(published[0]).toBe(event); expect(isRecordsBatchCreatedEvent(published[1])).toBe(true); + expect(isRecordsBatchCreatedEvent(published[1]) && published[1].source.type === 'import').toBe( + true + ); expect(tableRecordRepository.inserted).toHaveLength(1); expect(tableRecordRepository.insertManyStreamOptions).toMatchObject({ deferComputedUpdates: true, diff --git a/packages/v2/core/src/commands/ImportRecordsHandler.ts b/packages/v2/core/src/commands/ImportRecordsHandler.ts index d5aa6f1d7e..9951594c60 100644 --- a/packages/v2/core/src/commands/ImportRecordsHandler.ts +++ b/packages/v2/core/src/commands/ImportRecordsHandler.ts @@ -9,26 +9,27 @@ import { RecordWriteSideEffectService } from '../application/services/RecordWrit import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; import { domainError, isDomainError, type DomainError } from '../domain/shared/DomainError'; import type { IDomainEvent } from '../domain/shared/DomainEvent'; -import { RecordsBatchCreated } from '../domain/table/events/RecordsBatchCreated'; +import { tableDataSafetyLimitErrors } from '../domain/shared/TableDataSafetyLimits'; import type { RecordValuesDTO } from '../domain/table/events/RecordFieldValuesDTO'; +import { RecordsBatchCreated } from '../domain/table/events/RecordsBatchCreated'; import type { ICellValueSpec } from '../domain/table/records/specs/values/ICellValueSpecVisitor'; import type { TableRecord } from '../domain/table/records/TableRecord'; import { TableByIdSpec } from '../domain/table/specs/TableByIdSpec'; import type { Table } from '../domain/table/Table'; import * as EventBusPort from '../ports/EventBus'; import type { IExecutionContext } from '../ports/ExecutionContext'; -import { - RecordWriteOperationKind, - type RecordWriteFieldValues, - type RecordWriteImportAppendPayload, - type RecordWritePluginOrchestration, -} from '../ports/RecordWritePlugin'; import type { IImportParseResult, IImportProgress, SourceColumnMap, } from '../ports/import/IImportSource'; import * as IImportSourceRegistryPort from '../ports/import/IImportSourceRegistry'; +import { + RecordWriteOperationKind, + type RecordWriteFieldValues, + type RecordWriteImportAppendPayload, + type RecordWritePluginOrchestration, +} from '../ports/RecordWritePlugin'; import * as TableRecordRepositoryPort from '../ports/TableRecordRepository'; import * as TableRepositoryPort from '../ports/TableRepository'; import { v2CoreTokens } from '../ports/tokens'; @@ -149,13 +150,17 @@ export class ImportRecordsHandler if (error instanceof MaxRowCountExceededError) { return err( domainError.validation({ - code: 'validation.limit.rows_per_table_max', + code: tableDataSafetyLimitErrors.rowsPerTableMax.code, message: `Exceed max row limit: ${error.maxRowCount}`, details: { max: error.maxRowCount, maxRowCount: error.maxRowCount, rowCount: error.rowCount, }, + localization: { + i18nKey: tableDataSafetyLimitErrors.rowsPerTableMax.i18nKey, + context: { max: error.maxRowCount }, + }, }) ); } @@ -201,10 +206,8 @@ export class ImportRecordsHandler // 6. Stream insert via insertManyStream // Use deferComputedUpdates to avoid blocking the response while computed fields update - let insertResult: TableRecordRepositoryPort.InsertManyStreamResult; - insertResult = yield* await handler.unitOfWork.withTransaction( - context, - async (transactionContext) => { + const insertResult: TableRecordRepositoryPort.InsertManyStreamResult = + yield* await handler.unitOfWork.withTransaction(context, async (transactionContext) => { try { const recordBatches = handler.createRecordBatchesStream( transactionContext, @@ -239,8 +242,7 @@ export class ImportRecordsHandler }) ); } - } - ); + }); // 8. Publish all collected events if (state.events.length > 0) { @@ -389,6 +391,7 @@ export class ImportRecordsHandler tableId: state.table.id(), baseId: state.table.baseId(), records: eventRecords, + source: { type: 'import' }, orchestration: { operationId: state.operationId, totalRecordCount: state.totalRecordCount, diff --git a/packages/v2/core/src/commands/PasteHandler.spec.ts b/packages/v2/core/src/commands/PasteHandler.spec.ts index a857ffdfe8..dd6883df81 100644 --- a/packages/v2/core/src/commands/PasteHandler.spec.ts +++ b/packages/v2/core/src/commands/PasteHandler.spec.ts @@ -492,7 +492,12 @@ class FakeTableRecordRepository implements ITableRecordRepository { ): Promise> { this.updateCalls += 1; if (this.updateManyStreamErrorAtCall === this.updateCalls) { - return err(domainError.infrastructure({ message: 'connection exhausted' })); + return err( + domainError.infrastructure({ + message: 'connection exhausted', + localization: { i18nKey: 'httpErrors.custom.linkOneManyDuplicate' }, + }) + ); } this.updateStreamContexts.push(context); this.updateStreamOptions.push(options ?? {}); @@ -2709,6 +2714,7 @@ describe('PasteHandler', () => { processedCount: 1, updatedCount: 1, message: 'connection exhausted', + localization: { i18nKey: 'httpErrors.custom.linkOneManyDuplicate' }, }); expect(recordRepository.updateCalls).toBe(2); }); diff --git a/packages/v2/core/src/commands/PasteHandler.ts b/packages/v2/core/src/commands/PasteHandler.ts index 1e301762ee..88bd0a45f1 100644 --- a/packages/v2/core/src/commands/PasteHandler.ts +++ b/packages/v2/core/src/commands/PasteHandler.ts @@ -28,7 +28,11 @@ import { toUndoRedoStackAppendContext, UndoRedoStackService, } from '../application/services/UndoRedoStackService'; -import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { + domainError, + type DomainError, + type IDomainErrorLocalization, +} from '../domain/shared/DomainError'; import type { IDomainEvent } from '../domain/shared/DomainEvent'; import { generateUuid } from '../domain/shared/IdGenerator'; import { OffsetPagination } from '../domain/shared/pagination/OffsetPagination'; @@ -273,6 +277,7 @@ export interface PasteStreamErrorEvent { recordIds: string[]; message: string; code?: string; + localization?: IDomainErrorLocalization; } export type PasteStreamEvent = @@ -3252,6 +3257,7 @@ export class PasteStreamApplicationService extends PasteHandler { recordIds: summary.recordIds, message: error.message, code: error.code, + ...(error.localization && { localization: error.localization }), }; } } diff --git a/packages/v2/core/src/commands/PublicCommandBranding.ts b/packages/v2/core/src/commands/PublicCommandBranding.ts index c044ea4921..7979e35e9b 100644 --- a/packages/v2/core/src/commands/PublicCommandBranding.ts +++ b/packages/v2/core/src/commands/PublicCommandBranding.ts @@ -8,10 +8,26 @@ declare module './ApplyRecordOrdersCommand' { interface ApplyRecordOrdersCommand extends IPublicCommand {} } +declare module './ApplyViewManualSortCommand' { + interface ApplyViewManualSortCommand extends IPublicCommand {} +} + declare module './ClearCommand' { interface ClearCommand extends IPublicCommand {} } +declare module './ClickButtonCommand' { + interface ClickButtonCommand extends IPublicCommand {} +} + +declare module './SetButtonValueCommand' { + interface SetButtonValueCommand extends IPublicCommand {} +} + +declare module './ResetButtonCommand' { + interface ResetButtonCommand extends IPublicCommand {} +} + declare module './CreateBaseCommand' { interface CreateBaseCommand extends IPublicCommand {} } diff --git a/packages/v2/core/src/commands/RefreshViewShareIdCommand.ts b/packages/v2/core/src/commands/RefreshViewShareIdCommand.ts new file mode 100644 index 0000000000..7b07874319 --- /dev/null +++ b/packages/v2/core/src/commands/RefreshViewShareIdCommand.ts @@ -0,0 +1,40 @@ +import { err, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; +import { PublicCommand } from './PublicCommand'; + +export const refreshViewShareIdInputSchema = z + .object({ + tableId: z.string(), + viewId: z.string(), + }) + .strict(); + +export class RefreshViewShareIdCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = refreshViewShareIdInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid RefreshViewShareIdCommand input', + details: z.formatError(parsed.error), + }) + ); + } + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map( + (viewId) => new RefreshViewShareIdCommand(tableId, viewId) + ) + ); + } +} diff --git a/packages/v2/core/src/commands/RefreshViewShareIdHandler.spec.ts b/packages/v2/core/src/commands/RefreshViewShareIdHandler.spec.ts new file mode 100644 index 0000000000..ed419209f6 --- /dev/null +++ b/packages/v2/core/src/commands/RefreshViewShareIdHandler.spec.ts @@ -0,0 +1,201 @@ +import { err, ok, type Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { ViewShareIdRefreshed } from '../domain/table/events/ViewShareIdRefreshed'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { TableUpdateViewShareIdSpec } from '../domain/table/specs/TableUpdateViewShareIdSpec'; +import { Table } from '../domain/table/Table'; +import type { TableUpdateResult } from '../domain/table/TableMutator'; +import { TableName } from '../domain/table/TableName'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { RefreshViewShareIdCommand } from './RefreshViewShareIdCommand'; +import { RefreshViewShareIdHandler } from './RefreshViewShareIdHandler'; + +const context: IExecutionContext = { actorId: ActorId.create('actor')._unsafeUnwrap() }; + +const buildSharedTable = (): { table: Table; viewId: ViewId } => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Shared Views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + const created = builder + .build() + ._unsafeUnwrap() + .createView({ + type: 'grid', + name: 'Public View', + enableShare: true, + shareId: `shr${'s'.repeat(16)}`, + }) + ._unsafeUnwrap(); + created.updateResult.table.pullDomainEvents(); + return { table: created.updateResult.table, viewId: created.view.id() }; +}; + +class FakeTableUpdateFlow { + calls = 0; + mutateSpec?: TableUpdateResult['mutateSpec']; + + async execute( + _context: IExecutionContext, + target: { table: Table }, + mutate: (table: Table) => Result + ) { + this.calls += 1; + const result = mutate(target.table); + if (result.isErr()) return err(result.error); + this.mutateSpec = result.value.mutateSpec; + return ok({ + table: result.value.table, + events: result.value.table.pullDomainEvents(), + postPersistEvents: [], + }); + } +} + +const createHandlerFromResult = ( + tableResult: Result, + plugins: IViewOperationPlugin[] = [] +) => { + const repository = { + findOne: vi.fn(async () => tableResult), + } as unknown as ITableRepository; + const flow = new FakeTableUpdateFlow(); + return { + handler: new RefreshViewShareIdHandler( + repository, + flow as unknown as TableUpdateFlow, + new ViewOperationPluginRunner(plugins) + ), + repository, + flow, + }; +}; + +const createHandler = (table: Table, plugins: IViewOperationPlugin[] = []) => + createHandlerFromResult(ok(table), plugins); + +describe('RefreshViewShareIdCommand', () => { + it('validates aggregate and child IDs', () => { + const { table, viewId } = buildSharedTable(); + expect( + RefreshViewShareIdCommand.create({ + tableId: table.id().toString(), + viewId: viewId.toString(), + }).isOk() + ).toBe(true); + expect(RefreshViewShareIdCommand.create({ tableId: 'bad', viewId: 'bad' }).isErr()).toBe(true); + }); +}); + +describe('RefreshViewShareIdHandler', () => { + it('orchestrates aggregate rotation, plugin policy, and persistence without snapshot history', async () => { + const { table, viewId } = buildSharedTable(); + const previousShareId = table.getView(viewId)._unsafeUnwrap().shareId(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler(table, [ + { + name: 'capture', + supports: (kind) => kind === ViewOperationKind.update, + prepare, + guard: () => ok(undefined), + }, + ]); + const command = RefreshViewShareIdCommand.create({ + tableId: table.id().toString(), + viewId: viewId.toString(), + })._unsafeUnwrap(); + + const result = (await setup.handler.handle(context, command))._unsafeUnwrap(); + + expect(setup.repository.findOne).toHaveBeenCalledOnce(); + expect(setup.flow.mutateSpec).toBeInstanceOf(TableUpdateViewShareIdSpec); + expect(result.previousShareId).toBe(previousShareId); + expect(result.shareId).toMatch(/^shr[0-9a-zA-Z]{16}$/); + expect(result.shareId).not.toBe(previousShareId); + expect(result.table.getView(viewId)._unsafeUnwrap().shareId()).toBe(result.shareId); + expect(result.events.some((event) => event instanceof ViewShareIdRefreshed)).toBe(true); + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'update', + payload: expect.objectContaining({ patch: { shareId: result.shareId } }), + }) + ); + }); + + it('does not persist when plugin policy rejects the rotation', async () => { + const { table, viewId } = buildSharedTable(); + const setup = createHandler(table, [ + { + name: 'reject', + supports: () => true, + guard: () => err(domainError.forbidden({ message: 'rejected' })), + }, + ]); + const command = RefreshViewShareIdCommand.create({ + tableId: table.id().toString(), + viewId: viewId.toString(), + })._unsafeUnwrap(); + + expect((await setup.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'forbidden' + ); + expect(setup.flow.calls).toBe(0); + }); + + it('rejects rotation for a disabled View before plugin policy or persistence', async () => { + const shared = buildSharedTable(); + const table = shared.table.disableViewShare(shared.viewId)._unsafeUnwrap().updateResult.table; + table.pullDomainEvents(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler(table, [ + { + name: 'capture', + supports: () => true, + prepare, + }, + ]); + const command = RefreshViewShareIdCommand.create({ + tableId: table.id().toString(), + viewId: shared.viewId.toString(), + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result._unsafeUnwrapErr().code).toBe('validation.invalid'); + expect(prepare).not.toHaveBeenCalled(); + expect(setup.flow.calls).toBe(0); + }); + + it('propagates repository failure before aggregate mutation or plugin policy', async () => { + const { table, viewId } = buildSharedTable(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandlerFromResult(err(domainError.notFound({ message: 'Missing Table' })), [ + { + name: 'capture', + supports: () => true, + prepare, + }, + ]); + const command = RefreshViewShareIdCommand.create({ + tableId: table.id().toString(), + viewId: viewId.toString(), + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result._unsafeUnwrapErr().code).toBe('not_found'); + expect(prepare).not.toHaveBeenCalled(); + expect(setup.flow.calls).toBe(0); + }); +}); diff --git a/packages/v2/core/src/commands/RefreshViewShareIdHandler.ts b/packages/v2/core/src/commands/RefreshViewShareIdHandler.ts new file mode 100644 index 0000000000..d96721e872 --- /dev/null +++ b/packages/v2/core/src/commands/RefreshViewShareIdHandler.ts @@ -0,0 +1,93 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry, type Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import type { DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { Table as TableAggregate, type Table } from '../domain/table/Table'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type * as ExecutionContextPort from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { RefreshViewShareIdCommand } from './RefreshViewShareIdCommand'; + +export class RefreshViewShareIdResult { + private constructor( + readonly table: Table, + readonly viewId: ViewId, + readonly previousShareId: string | undefined, + readonly shareId: string, + readonly events: ReadonlyArray + ) {} + + static create(params: { + table: Table; + viewId: ViewId; + previousShareId: string | undefined; + shareId: string; + events: ReadonlyArray; + }): RefreshViewShareIdResult { + return new RefreshViewShareIdResult( + params.table, + params.viewId, + params.previousShareId, + params.shareId, + [...params.events] + ); + } +} + +@CommandHandler(RefreshViewShareIdCommand) +@injectable() +export class RefreshViewShareIdHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow, + @inject(v2CoreTokens.viewOperationPluginRunner) + private readonly viewOperationPluginRunner: ViewOperationPluginRunner + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + command: RefreshViewShareIdCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* TableAggregate.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const refreshResult = yield* table.refreshViewShareId(command.viewId); + + const pluginExecution = yield* await handler.viewOperationPluginRunner.prepare({ + kind: ViewOperationKind.update, + executionContext: context, + payload: { + tableId: table.id().toString(), + viewId: command.viewId.toString(), + patch: { shareId: refreshResult.nextShareId }, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + + const update = yield* await handler.tableUpdateFlow.execute(context, { table }, () => + ok(refreshResult.updateResult) + ); + return ok( + RefreshViewShareIdResult.create({ + table: update.table, + viewId: command.viewId, + previousShareId: refreshResult.previousShareId, + shareId: refreshResult.nextShareId, + events: update.events, + }) + ); + }); + } +} diff --git a/packages/v2/core/src/commands/RenameViewCommand.ts b/packages/v2/core/src/commands/RenameViewCommand.ts new file mode 100644 index 0000000000..57f52806c5 --- /dev/null +++ b/packages/v2/core/src/commands/RenameViewCommand.ts @@ -0,0 +1,47 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; +import { ViewName } from '../domain/table/views/ViewName'; +import { PublicCommand } from './PublicCommand'; + +export const renameViewInputSchema = z.object({ + tableId: z.string(), + viewId: z.string(), + name: z.string(), +}); + +export type IRenameViewCommandInput = z.input; + +export class RenameViewCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId, + readonly name: ViewName + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = renameViewInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid RenameViewCommand input', + details: z.formatError(parsed.error), + }) + ); + } + + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).andThen((viewId) => + ViewName.create(parsed.data.name).map( + (name) => new RenameViewCommand(tableId, viewId, name) + ) + ) + ); + } +} diff --git a/packages/v2/core/src/commands/RenameViewHandler.spec.ts b/packages/v2/core/src/commands/RenameViewHandler.spec.ts new file mode 100644 index 0000000000..1d4fa79662 --- /dev/null +++ b/packages/v2/core/src/commands/RenameViewHandler.spec.ts @@ -0,0 +1,158 @@ +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import type { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { TableRenameViewSpec } from '../domain/table/specs/TableRenameViewSpec'; +import { Table } from '../domain/table/Table'; +import type { TableUpdateResult } from '../domain/table/TableMutator'; +import { TableName } from '../domain/table/TableName'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { RenameViewCommand } from './RenameViewCommand'; +import { RenameViewHandler } from './RenameViewHandler'; + +const context: IExecutionContext = { + actorId: ActorId.create('actor')._unsafeUnwrap(), +}; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +class FakeTableUpdateFlow { + calls = 0; + mutateSpec?: TableUpdateResult['mutateSpec']; + + async execute( + _context: IExecutionContext, + target: { table: Table }, + mutate: (table: Table) => Result + ) { + this.calls += 1; + const result = mutate(target.table); + if (result.isErr()) return err(result.error); + this.mutateSpec = result.value.mutateSpec; + return ok({ table: result.value.table, events: [], postPersistEvents: [] }); + } +} + +const createHandler = (table: Table, operationPlugins: IViewOperationPlugin[] = []) => { + const tableRepository = { + findOne: vi.fn(async () => ok(table)), + } as unknown as ITableRepository; + const tableUpdateFlow = new FakeTableUpdateFlow(); + const pluginRunner = new ViewOperationPluginRunner(operationPlugins); + const undoRedo = { + capture: vi.fn(() => ok({ id: table.views()[0]!.id().toString() } as never)), + appendUpdate: vi.fn(async () => ok(undefined)), + } as unknown as ViewUndoRedoService; + const handler = new RenameViewHandler( + tableRepository, + tableUpdateFlow as unknown as TableUpdateFlow, + pluginRunner, + undoRedo + ); + return { handler, tableRepository, tableUpdateFlow }; +}; + +describe('RenameViewCommand', () => { + it('validates Table and View identifiers', () => { + expect(RenameViewCommand.create({ tableId: 'bad', viewId: 'bad', name: 'Name' }).isErr()).toBe( + true + ); + }); +}); + +describe('RenameViewHandler', () => { + it('orchestrates the Table aggregate, update guard, and TableRenameViewSpec', async () => { + const table = buildTable(); + const target = table.views()[0]!; + const seenOperations: unknown[] = []; + const operationPlugin: IViewOperationPlugin = { + name: 'capture', + supports: (kind) => kind === ViewOperationKind.update, + prepare: (pluginContext) => { + seenOperations.push(pluginContext); + return ok(undefined); + }, + guard: () => ok(undefined), + }; + const setup = createHandler(table, [operationPlugin]); + const command = RenameViewCommand.create({ + tableId: table.id().toString(), + viewId: target.id().toString(), + name: 'Renamed', + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().previousName.toString()).toBe(target.name().toString()); + expect(result._unsafeUnwrap().nextName.toString()).toBe('Renamed'); + expect( + result._unsafeUnwrap().table.getView(target.id())._unsafeUnwrap().name().toString() + ).toBe('Renamed'); + expect(setup.tableRepository.findOne).toHaveBeenCalledOnce(); + expect(setup.tableUpdateFlow.calls).toBe(1); + expect(setup.tableUpdateFlow.mutateSpec).toBeInstanceOf(TableRenameViewSpec); + expect(seenOperations).toEqual([ + expect.objectContaining({ + kind: 'update', + payload: { + tableId: table.id().toString(), + viewId: target.id().toString(), + patch: { name: 'Renamed' }, + }, + }), + ]); + }); + + it('does not persist when the update operation guard rejects the request', async () => { + const table = buildTable(); + const operationPlugin: IViewOperationPlugin = { + name: 'reject', + supports: (kind) => kind === ViewOperationKind.update, + guard: () => err(domainError.forbidden({ message: 'View update rejected' })), + }; + const setup = createHandler(table, [operationPlugin]); + const command = RenameViewCommand.create({ + tableId: table.id().toString(), + viewId: table.views()[0]!.id().toString(), + name: 'Rejected', + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result._unsafeUnwrapErr().code).toBe('forbidden'); + expect(setup.tableUpdateFlow.calls).toBe(0); + }); + + it('returns an aggregate error and skips persistence for a missing View', async () => { + const table = buildTable(); + const setup = createHandler(table); + const command = RenameViewCommand.create({ + tableId: table.id().toString(), + viewId: `viw${'z'.repeat(16)}`, + name: 'Missing', + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result._unsafeUnwrapErr().code).toBe('view.not_found'); + expect(setup.tableUpdateFlow.calls).toBe(0); + }); +}); diff --git a/packages/v2/core/src/commands/RenameViewHandler.ts b/packages/v2/core/src/commands/RenameViewHandler.ts new file mode 100644 index 0000000000..35f8726568 --- /dev/null +++ b/packages/v2/core/src/commands/RenameViewHandler.ts @@ -0,0 +1,107 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import type { DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { Table as TableAggregate, type Table } from '../domain/table/Table'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type { ViewName } from '../domain/table/views/ViewName'; +import type * as ExecutionContextPort from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { RenameViewCommand } from './RenameViewCommand'; + +export class RenameViewResult { + private constructor( + readonly table: Table, + readonly viewId: ViewId, + readonly previousName: ViewName, + readonly nextName: ViewName, + readonly events: ReadonlyArray + ) {} + + static create(params: { + table: Table; + viewId: ViewId; + previousName: ViewName; + nextName: ViewName; + events: ReadonlyArray; + }): RenameViewResult { + return new RenameViewResult(params.table, params.viewId, params.previousName, params.nextName, [ + ...params.events, + ]); + } +} + +@CommandHandler(RenameViewCommand) +@injectable() +export class RenameViewHandler implements ICommandHandler { + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow, + @inject(v2CoreTokens.viewOperationPluginRunner) + private readonly viewOperationPluginRunner: ViewOperationPluginRunner, + @inject(v2CoreTokens.viewUndoRedoService) + private readonly viewUndoRedoService: ViewUndoRedoService + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + command: RenameViewCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* TableAggregate.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const previousSnapshot = yield* handler.viewUndoRedoService.capture( + table, + command.viewId.toString() + ); + const renameResult = yield* table.renameView(command.viewId, command.name); + + const pluginExecution = yield* await handler.viewOperationPluginRunner.prepare({ + kind: ViewOperationKind.update, + executionContext: context, + payload: { + tableId: table.id().toString(), + viewId: command.viewId.toString(), + patch: { name: command.name.toString() }, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + + const updateResult = yield* await handler.tableUpdateFlow.execute(context, { table }, () => + ok(renameResult.updateResult) + ); + const nextSnapshot = yield* handler.viewUndoRedoService.capture( + updateResult.table, + command.viewId.toString() + ); + yield* await handler.viewUndoRedoService.appendUpdate( + context, + updateResult.table, + [previousSnapshot], + [nextSnapshot] + ); + + return ok( + RenameViewResult.create({ + table: updateResult.table, + viewId: command.viewId, + previousName: renameResult.previousName, + nextName: renameResult.nextName, + events: updateResult.events, + }) + ); + }); + } +} diff --git a/packages/v2/core/src/commands/ResetButtonCommand.ts b/packages/v2/core/src/commands/ResetButtonCommand.ts new file mode 100644 index 0000000000..054fd20981 --- /dev/null +++ b/packages/v2/core/src/commands/ResetButtonCommand.ts @@ -0,0 +1,44 @@ +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { FieldId } from '../domain/table/fields/FieldId'; +import { RecordId } from '../domain/table/records/RecordId'; +import { TableId } from '../domain/table/TableId'; + +const resetButtonInputSchema = z.object({ + tableId: z.string(), + recordId: z.string(), + fieldId: z.string(), +}); + +export type IResetButtonCommandInput = z.input; + +export class ResetButtonCommand { + private constructor( + readonly tableId: TableId, + readonly recordId: RecordId, + readonly fieldId: FieldId + ) {} + + static create(raw: unknown): Result { + const parsed = resetButtonInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + code: 'button.reset_command_invalid', + message: 'Invalid ResetButtonCommand input', + details: z.formatError(parsed.error), + }) + ); + } + + return safeTry(function* () { + const tableId = yield* TableId.create(parsed.data.tableId); + const recordId = yield* RecordId.create(parsed.data.recordId); + const fieldId = yield* FieldId.create(parsed.data.fieldId); + return ok(new ResetButtonCommand(tableId, recordId, fieldId)); + }); + } +} diff --git a/packages/v2/core/src/commands/ResetButtonHandler.ts b/packages/v2/core/src/commands/ResetButtonHandler.ts new file mode 100644 index 0000000000..7be939c3f4 --- /dev/null +++ b/packages/v2/core/src/commands/ResetButtonHandler.ts @@ -0,0 +1,181 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { requireRecordUpdateSnapshot } from '../application/services/RecordMutationSnapshotContract'; +import { RecordWritePluginRunner } from '../application/services/RecordWritePluginRunner'; +import { + toUndoRedoStackAppendContext, + UndoRedoStackService, +} from '../application/services/UndoRedoStackService'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import type { RecordFieldChangeDTO } from '../domain/table/events/RecordFieldValuesDTO'; +import { RecordUpdated } from '../domain/table/events/RecordUpdated'; +import { FieldKeyType } from '../domain/table/fields/FieldKeyType'; +import type { TableRecord } from '../domain/table/records/TableRecord'; +import { Table } from '../domain/table/Table'; +import * as EventBusPort from '../ports/EventBus'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import { RecordWriteOperationKind } from '../ports/RecordWritePlugin'; +import * as TableRecordQueryRepositoryPort from '../ports/TableRecordQueryRepository'; +import * as TableRecordRepositoryPort from '../ports/TableRecordRepository'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import * as UnitOfWorkPort from '../ports/UnitOfWork'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { ResetButtonCommand } from './ResetButtonCommand'; +import { toTableRecord } from './shared/toTableRecord'; + +export class ResetButtonResult { + private constructor( + readonly record: TableRecord, + readonly events: ReadonlyArray + ) {} + + static create(record: TableRecord, events: ReadonlyArray): ResetButtonResult { + return new ResetButtonResult(record, [...events]); + } +} + +@CommandHandler(ResetButtonCommand) +@injectable() +export class ResetButtonHandler implements ICommandHandler { + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableRecordRepository) + private readonly tableRecordRepository: TableRecordRepositoryPort.ITableRecordRepository, + @inject(v2CoreTokens.tableRecordQueryRepository) + private readonly tableRecordQueryRepository: TableRecordQueryRepositoryPort.ITableRecordQueryRepository, + @inject(v2CoreTokens.recordWritePluginRunner) + private readonly recordWritePluginRunner: RecordWritePluginRunner, + @inject(v2CoreTokens.eventBus) + private readonly eventBus: EventBusPort.IEventBus, + @inject(v2CoreTokens.undoRedoService) + private readonly undoRedoStackService: UndoRedoStackService, + @inject(v2CoreTokens.unitOfWork) + private readonly unitOfWork: UnitOfWorkPort.IUnitOfWork + ) {} + + async handle( + context: IExecutionContext, + command: ResetButtonCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* Table.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const reset = yield* table.resetButtonValue({ + recordId: command.recordId, + fieldId: command.fieldId, + }); + const currentRecord = yield* await handler.tableRecordQueryRepository.findOne( + context, + table, + command.recordId, + { mode: 'stored' } + ); + const currentRecordEntity = yield* toTableRecord(table, currentRecord); + const responseRecord = yield* reset.mutateSpec.mutate(currentRecordEntity); + const fieldValues = new Map([[command.fieldId.toString(), null]]); + const pluginExecution = yield* await handler.recordWritePluginRunner.prepare({ + kind: RecordWriteOperationKind.updateOne, + executionContext: context, + table, + payload: { + recordId: command.recordId, + fieldValues, + fieldKeyType: FieldKeyType.Id, + typecast: false, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + const recordSpec = yield* pluginExecution.getRecordSpec(); + if (recordSpec && !recordSpec.isSatisfiedBy(currentRecordEntity)) { + return err( + domainError.forbidden({ + code: 'record_write_plugin.scope_forbidden', + message: 'Button reset is outside the allowed Record scope.', + }) + ); + } + const allowedFieldIds = + yield* pluginExecution.getUpdateFieldIdsForRecord(currentRecordEntity); + if (allowedFieldIds && !allowedFieldIds.has(command.fieldId.toString())) { + return err( + domainError.forbidden({ + code: 'record_write_plugin.update_fields_forbidden', + message: 'Button reset is outside the allowed Field scope.', + }) + ); + } + if (currentRecord.fields[command.fieldId.toString()] == null) { + return ok(ResetButtonResult.create(responseRecord, [])); + } + + const mutation = yield* await handler.unitOfWork.withTransaction( + context, + async (transactionContext) => + safeTry(async function* () { + yield* await pluginExecution.beforePersist(transactionContext); + const result = yield* await handler.tableRecordRepository.updateOne( + transactionContext, + table, + command.recordId, + reset.mutateSpec, + { expectedVersion: currentRecord.version } + ); + if (result.mutationApplied === false) { + return err( + domainError.conflict({ + code: 'button.reset_conflict', + message: 'Button changed while resetting its count.', + }) + ); + } + return ok(result); + }) + ); + const snapshot = yield* requireRecordUpdateSnapshot( + { + operation: 'update', + tableId: table.id().toString(), + recordId: command.recordId.toString(), + }, + mutation.updateSnapshot + ); + const changes: RecordFieldChangeDTO[] = [ + { + fieldId: command.fieldId.toString(), + oldValue: snapshot.previous.fields[command.fieldId.toString()], + newValue: snapshot.current.fields[command.fieldId.toString()], + }, + ]; + const events: IDomainEvent[] = [ + RecordUpdated.create({ + tableId: table.id(), + baseId: table.baseId(), + recordId: command.recordId, + oldVersion: snapshot.oldVersion, + newVersion: snapshot.newVersion, + changes, + source: 'user', + }), + ]; + yield* await handler.eventBus.publishMany(context, events); + yield* await handler.undoRedoStackService.appendButtonValueUpdateFromSnapshot( + toUndoRedoStackAppendContext(context), + { + tableId: table.id(), + recordId: command.recordId, + snapshot, + fieldId: command.fieldId.toString(), + } + ); + await pluginExecution.afterCommit(); + return ok(ResetButtonResult.create(responseRecord, events)); + }); + } +} diff --git a/packages/v2/core/src/commands/RestoreRecordsCommand.ts b/packages/v2/core/src/commands/RestoreRecordsCommand.ts index b6761953e4..f6fb3a9d06 100644 --- a/packages/v2/core/src/commands/RestoreRecordsCommand.ts +++ b/packages/v2/core/src/commands/RestoreRecordsCommand.ts @@ -3,6 +3,7 @@ import type { Result } from 'neverthrow'; import { z } from 'zod'; import { domainError, type DomainError } from '../domain/shared/DomainError'; +import type { IRecordRemovalReason } from '../domain/table/events/RecordsDeleted'; import { TableId } from '../domain/table/TableId'; export const restoreRecordsInputSchema = z.object({ @@ -29,13 +30,41 @@ export type IRestoreRecordsCommandInput = z.input['records'][number]; +export interface IRestoreRecordsCommandOptions { + /** + * Delete the attachments_table reference rows of the restored records before + * re-inserting them. Used when restoring archived records: archiving kept the + * reference rows (they count toward attachment usage), and the restore insert + * rebuilds them — without the cleanup they would be double-counted. Never accepted + * from the HTTP contract — internal callers only. + */ + cleanupAttachmentRefs?: boolean; + /** + * Restore ONLY records that still hold a record_trash row with this reason; the + * rest are silently skipped. Undo/redo replay sets it: the stack entry carries + * full snapshots, so a replay after the user purged the trash rows (permanently + * deleted archive items, emptied the recycle bin) would otherwise resurrect + * them — the v1 stack is immune because it resolves undo FROM record_trash. + * Regular restores must NOT set it: the trash cold-fallback restore feeds + * snapshots whose rows already sank out of PG, and the archive restore runs + * while its rows are being deleted in the same flow. Never accepted from the + * HTTP contract — internal callers only. + */ + requireTrashRowReason?: IRecordRemovalReason; +} + export class RestoreRecordsCommand { private constructor( readonly tableId: TableId, - readonly records: ReadonlyArray + readonly records: ReadonlyArray, + readonly cleanupAttachmentRefs?: boolean, + readonly requireTrashRowReason?: IRecordRemovalReason ) {} - static create(raw: unknown): Result { + static create( + raw: unknown, + options?: IRestoreRecordsCommandOptions + ): Result { const parsed = restoreRecordsInputSchema.safeParse(raw); if (!parsed.success) { return err( @@ -47,7 +76,13 @@ export class RestoreRecordsCommand { } return TableId.create(parsed.data.tableId).map( - (tableId) => new RestoreRecordsCommand(tableId, parsed.data.records) + (tableId) => + new RestoreRecordsCommand( + tableId, + parsed.data.records, + options?.cleanupAttachmentRefs, + options?.requireTrashRowReason + ) ); } } diff --git a/packages/v2/core/src/commands/RestoreRecordsHandler.ts b/packages/v2/core/src/commands/RestoreRecordsHandler.ts index 3c384b894b..2a0ebd0929 100644 --- a/packages/v2/core/src/commands/RestoreRecordsHandler.ts +++ b/packages/v2/core/src/commands/RestoreRecordsHandler.ts @@ -63,11 +63,34 @@ export class RestoreRecordsHandler } const table = tableResult.value; + + // Replay guard: an undo entry carries full snapshots, so a replay after the + // trash rows disappeared (purged by the user, or restored through another + // path) would resurrect them — restore only the survivors. An empty survivor + // set is a successful no-op. Without the optional port method the check is + // skipped and the replay behaves as before. + let records = command.records; + if (command.requireTrashRowReason && this.tableRecordRepository.listTrashedRecordIds) { + const surviving = await this.tableRecordRepository.listTrashedRecordIds( + context, + table, + records.map((record) => record.recordId), + command.requireTrashRowReason + ); + if (surviving.isErr()) { + return err(surviving.error); + } + records = records.filter((record) => surviving.value.has(record.recordId)); + if (records.length === 0) { + return ok(RestoreRecordsResult.create(0, [])); + } + } + let restoredCount = 0; const events: IDomainEvent[] = []; - const batchSize = resolveRestoreRecordsBatchSize(command.records.length); + const batchSize = resolveRestoreRecordsBatchSize(records.length); - for (const batch of this.restoreRecordBatches(command.records, batchSize)) { + for (const batch of this.restoreRecordBatches(records, batchSize)) { const records = this.buildTableRecords(table, batch); if (records.isErr()) { return err(records.error); @@ -86,6 +109,9 @@ export class RestoreRecordsHandler { restoreRecordsById, cleanupTrashRecordIds: batch.map((record) => record.recordId), + ...(command.cleanupAttachmentRefs + ? { cleanupAttachmentRefRecordIds: batch.map((record) => record.recordId) } + : {}), } ); return ok(undefined); diff --git a/packages/v2/core/src/commands/SetButtonValueCommand.ts b/packages/v2/core/src/commands/SetButtonValueCommand.ts new file mode 100644 index 0000000000..7c06c07db5 --- /dev/null +++ b/packages/v2/core/src/commands/SetButtonValueCommand.ts @@ -0,0 +1,56 @@ +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { FieldId } from '../domain/table/fields/FieldId'; +import { RecordId } from '../domain/table/records/RecordId'; +import type { ButtonCellValue } from '../domain/table/records/specs/values/SetButtonValueSpec'; +import { TableId } from '../domain/table/TableId'; + +const buttonValueSchema = z + .object({ + count: z.number().int().nonnegative(), + }) + .strict() + .nullable(); + +const setButtonValueInputSchema = z.object({ + tableId: z.string(), + recordId: z.string(), + fieldId: z.string(), + value: buttonValueSchema, +}); + +/** + * Internal public command used only by the persisted undo/redo command bus. + * HTTP record input never constructs this command. + */ +export class SetButtonValueCommand { + private constructor( + readonly tableId: TableId, + readonly recordId: RecordId, + readonly fieldId: FieldId, + readonly value: ButtonCellValue | null + ) {} + + static create(raw: unknown): Result { + const parsed = setButtonValueInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + code: 'button.replay_command_invalid', + message: 'Invalid SetButtonValueCommand input', + details: z.formatError(parsed.error), + }) + ); + } + + return safeTry(function* () { + const tableId = yield* TableId.create(parsed.data.tableId); + const recordId = yield* RecordId.create(parsed.data.recordId); + const fieldId = yield* FieldId.create(parsed.data.fieldId); + return ok(new SetButtonValueCommand(tableId, recordId, fieldId, parsed.data.value)); + }); + } +} diff --git a/packages/v2/core/src/commands/SetButtonValueHandler.ts b/packages/v2/core/src/commands/SetButtonValueHandler.ts new file mode 100644 index 0000000000..01883f6ff2 --- /dev/null +++ b/packages/v2/core/src/commands/SetButtonValueHandler.ts @@ -0,0 +1,161 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { requireRecordUpdateSnapshot } from '../application/services/RecordMutationSnapshotContract'; +import { RecordWritePluginRunner } from '../application/services/RecordWritePluginRunner'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import type { RecordFieldChangeDTO } from '../domain/table/events/RecordFieldValuesDTO'; +import { RecordUpdated } from '../domain/table/events/RecordUpdated'; +import { FieldKeyType } from '../domain/table/fields/FieldKeyType'; +import { Table } from '../domain/table/Table'; +import * as EventBusPort from '../ports/EventBus'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import { RecordWriteOperationKind } from '../ports/RecordWritePlugin'; +import * as TableRecordQueryRepositoryPort from '../ports/TableRecordQueryRepository'; +import * as TableRecordRepositoryPort from '../ports/TableRecordRepository'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import * as UnitOfWorkPort from '../ports/UnitOfWork'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { SetButtonValueCommand } from './SetButtonValueCommand'; +import { toTableRecord } from './shared/toTableRecord'; + +export class SetButtonValueResult { + private constructor(readonly events: ReadonlyArray) {} + + static create(events: ReadonlyArray): SetButtonValueResult { + return new SetButtonValueResult([...events]); + } +} + +@CommandHandler(SetButtonValueCommand) +@injectable() +export class SetButtonValueHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableRecordRepository) + private readonly tableRecordRepository: TableRecordRepositoryPort.ITableRecordRepository, + @inject(v2CoreTokens.tableRecordQueryRepository) + private readonly tableRecordQueryRepository: TableRecordQueryRepositoryPort.ITableRecordQueryRepository, + @inject(v2CoreTokens.recordWritePluginRunner) + private readonly recordWritePluginRunner: RecordWritePluginRunner, + @inject(v2CoreTokens.eventBus) + private readonly eventBus: EventBusPort.IEventBus, + @inject(v2CoreTokens.unitOfWork) + private readonly unitOfWork: UnitOfWorkPort.IUnitOfWork + ) {} + + async handle( + context: IExecutionContext, + command: SetButtonValueCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* Table.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const currentRecord = yield* await handler.tableRecordQueryRepository.findOne( + context, + table, + command.recordId, + { mode: 'stored' } + ); + const currentRecordEntity = yield* toTableRecord(table, currentRecord); + const update = yield* table.setButtonValue({ + recordId: command.recordId, + fieldId: command.fieldId, + value: command.value, + }); + const fieldValues = new Map([[command.fieldId.toString(), command.value]]); + const pluginExecution = yield* await handler.recordWritePluginRunner.prepare({ + kind: RecordWriteOperationKind.updateOne, + executionContext: context, + table, + payload: { + recordId: command.recordId, + fieldValues, + fieldKeyType: FieldKeyType.Id, + typecast: false, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + const recordSpec = yield* pluginExecution.getRecordSpec(); + if (recordSpec && !recordSpec.isSatisfiedBy(currentRecordEntity)) { + return err( + domainError.forbidden({ + code: 'record_write_plugin.scope_forbidden', + message: 'Button Field replay is outside the allowed Record scope.', + }) + ); + } + const allowedFieldIds = + yield* pluginExecution.getUpdateFieldIdsForRecord(currentRecordEntity); + if (allowedFieldIds && !allowedFieldIds.has(command.fieldId.toString())) { + return err( + domainError.forbidden({ + code: 'record_write_plugin.update_fields_forbidden', + message: 'Button Field replay is outside the allowed Field scope.', + }) + ); + } + + const mutation = yield* await handler.unitOfWork.withTransaction( + context, + async (transactionContext) => + safeTry(async function* () { + yield* await pluginExecution.beforePersist(transactionContext); + const result = yield* await handler.tableRecordRepository.updateOne( + transactionContext, + table, + command.recordId, + update.mutateSpec, + { expectedVersion: currentRecord.version } + ); + if (result.mutationApplied === false) { + return err( + domainError.conflict({ + code: 'button.replay_conflict', + message: 'Button changed while replaying undo/redo.', + }) + ); + } + return ok(result); + }) + ); + const snapshot = yield* requireRecordUpdateSnapshot( + { + operation: 'update', + tableId: table.id().toString(), + recordId: command.recordId.toString(), + }, + mutation.updateSnapshot + ); + const changes: RecordFieldChangeDTO[] = [ + { + fieldId: command.fieldId.toString(), + oldValue: snapshot.previous.fields[command.fieldId.toString()], + newValue: snapshot.current.fields[command.fieldId.toString()], + }, + ]; + const events: IDomainEvent[] = [ + RecordUpdated.create({ + tableId: table.id(), + baseId: table.baseId(), + recordId: command.recordId, + oldVersion: snapshot.oldVersion, + newVersion: snapshot.newVersion, + changes, + source: 'user', + }), + ]; + yield* await handler.eventBus.publishMany(context, events); + await pluginExecution.afterCommit(); + return ok(SetButtonValueResult.create(events)); + }); + } +} diff --git a/packages/v2/core/src/commands/TableFieldSpecs.spec.ts b/packages/v2/core/src/commands/TableFieldSpecs.spec.ts index 07044b4e74..b8d08abf46 100644 --- a/packages/v2/core/src/commands/TableFieldSpecs.spec.ts +++ b/packages/v2/core/src/commands/TableFieldSpecs.spec.ts @@ -1,14 +1,14 @@ +import { tableI18nKeys } from '@teable/i18n-keys'; import { describe, expect, it } from 'vitest'; -import { tableI18nKeys } from '@teable/i18n-keys'; -import { ActorId } from '../domain/shared/ActorId'; import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; import { FieldId } from '../domain/table/fields/FieldId'; import { FieldName } from '../domain/table/fields/FieldName'; import { ConditionalLookupField } from '../domain/table/fields/types/ConditionalLookupField'; import { Table } from '../domain/table/Table'; -import { TableName } from '../domain/table/TableName'; import { TableId } from '../domain/table/TableId'; +import { TableName } from '../domain/table/TableName'; import type { IExecutionContext } from '../ports/ExecutionContext'; import { collectForeignTableReferences, @@ -426,4 +426,62 @@ describe('TableFieldSpecs', () => { expect(field.aiConfig()).toEqual(aiConfig); }); + + it('rejects aiConfig on field types that do not support it', () => { + const specResult = parseSpec({ + type: 'checkbox', + name: 'Done', + aiConfig: { + type: 'summary', + modelKey: 'openai@gpt-4o@gpt', + sourceFieldId: `fld${'z'.repeat(16)}`, + }, + }); + + expect(specResult.isErr()).toBe(true); + expect(specResult._unsafeUnwrapErr().message).toMatch(/aiConfig/); + }); + + it('rejects aiConfig whose action type does not match the field type', () => { + const specResult = parseSpec({ + type: 'attachment', + name: 'Images', + aiConfig: { + type: 'summary', + modelKey: 'openai@gpt-4o@gpt', + sourceFieldId: `fld${'z'.repeat(16)}`, + }, + }); + + expect(specResult.isErr()).toBe(true); + expect(specResult._unsafeUnwrapErr().message).toMatch(/aiConfig/i); + }); + + it('rehydrates legacy aiConfig only through the explicit trusted mode', () => { + const input = { + type: 'checkbox' as const, + name: 'Legacy AI Drift', + aiConfig: { + type: 'summary', + modelKey: 'openai@gpt-4o@gpt', + sourceFieldId: `fld${'z'.repeat(16)}`, + }, + }; + const resolved = resolveTableFieldInputs([input], [])._unsafeUnwrap()[0]!; + + expect(parseTableFieldSpec(resolved, { isPrimary: false }).isErr()).toBe(true); + + const trustedSpec = parseTableFieldSpec(resolved, { + isPrimary: false, + aiConfigMode: 'trustedRehydrate', + })._unsafeUnwrap(); + const field = trustedSpec + .createField({ + baseId: BaseId.create(`bse${'e'.repeat(16)}`)._unsafeUnwrap(), + tableId: TableId.create(`tbl${'f'.repeat(16)}`)._unsafeUnwrap(), + }) + ._unsafeUnwrap(); + + expect(field.aiConfig()).toEqual(input.aiConfig); + }); }); diff --git a/packages/v2/core/src/commands/TableFieldSpecs.ts b/packages/v2/core/src/commands/TableFieldSpecs.ts index 8cc6a4d80f..b573c160e6 100644 --- a/packages/v2/core/src/commands/TableFieldSpecs.ts +++ b/packages/v2/core/src/commands/TableFieldSpecs.ts @@ -82,7 +82,7 @@ import type { TableBuilder } from '../domain/table/TableBuilder'; import { TableId } from '../domain/table/TableId'; import type { IExecutionContext } from '../ports/ExecutionContext'; import { getDomainContext } from '../ports/ExecutionContext'; -import { trackedFieldIdsSchema } from '../schemas/field'; +import { trackedFieldIdsSchema, validateFieldAiConfig } from '../schemas/field'; import type { ITableFieldInput, ResolvedTableFieldInput } from '../schemas/field'; import { checkFieldNotNullValidationEnabled, @@ -2455,10 +2455,21 @@ export const parseTableFieldSpec = ( isPrimary: boolean; executionContext?: IExecutionContext; bypassSelectFieldOptionLimit?: boolean; + aiConfigMode?: 'strict' | 'trustedRehydrate'; hostTable?: Table; foreignTables?: ReadonlyArray
; } ): Result => { + // v1 parity (T6520): aiConfig is validated against the field type — e.g. an + // attachment field carrying a text-style aiConfig is rejected instead of + // being stored as an opaque value. + if (options.aiConfigMode !== 'trustedRehydrate') { + const aiConfigValidation = validateFieldAiConfig(field.type, field.aiConfig); + if (!aiConfigValidation.valid) { + return err(domainError.validation({ message: aiConfigValidation.message })); + } + } + return optional(field.id, FieldId.create).andThen((id) => FieldName.create(field.name).andThen((name) => resolveFieldValidation(field).andThen((validation) => diff --git a/packages/v2/core/src/commands/TableFieldUpdateSpecs.same-type.spec.ts b/packages/v2/core/src/commands/TableFieldUpdateSpecs.same-type.spec.ts index 5304d53d02..87d23f92c3 100644 --- a/packages/v2/core/src/commands/TableFieldUpdateSpecs.same-type.spec.ts +++ b/packages/v2/core/src/commands/TableFieldUpdateSpecs.same-type.spec.ts @@ -724,7 +724,7 @@ describe('TableFieldUpdateSpecs same-type updates', () => { const specsResult = buildUpdateFieldSpecs(currentField, { dbFieldName: 'next_column_name', description: 'New description', - aiConfig: { prompt: 'fill this field' }, + aiConfig: { type: 'customization', modelKey: 'openai@gpt-4o@gpt', prompt: 'fill this field' }, }); expect(specsResult.isOk()).toBe(true); @@ -743,6 +743,28 @@ describe('TableFieldUpdateSpecs same-type updates', () => { ); }); + it('rejects aiConfig that does not match the field type on update', () => { + const { currentField } = buildHarness('r', 'r', (builder, fieldId) => { + builder + .field() + .singleLineText() + .withId(fieldId) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .done(); + }); + + const specsResult = buildUpdateFieldSpecs(currentField, { + aiConfig: { + type: 'imageGeneration', + modelKey: 'openai@gpt-4o@gpt', + sourceFieldId: `fld${'z'.repeat(16)}`, + }, + }); + + expect(specsResult.isErr()).toBe(true); + expect(specsResult._unsafeUnwrapErr().message).toMatch(/aiConfig/i); + }); + it('clears description and aiConfig when metadata is explicitly nulled', () => { const { currentField } = buildHarness('v', 'v', (builder, fieldId) => { builder diff --git a/packages/v2/core/src/commands/TableFieldUpdateSpecs.ts b/packages/v2/core/src/commands/TableFieldUpdateSpecs.ts index f38c9a776d..587c5148af 100644 --- a/packages/v2/core/src/commands/TableFieldUpdateSpecs.ts +++ b/packages/v2/core/src/commands/TableFieldUpdateSpecs.ts @@ -128,6 +128,7 @@ import type { Table } from '../domain/table/Table'; import { TableId } from '../domain/table/TableId'; import type { IExecutionContext } from '../ports/ExecutionContext'; import { getDomainContext } from '../ports/ExecutionContext'; +import { validateFieldAiConfig } from '../schemas/field'; import type { IUpdateTableFieldSpec } from './IUpdateTableFieldSpec'; // ============ Helper functions ============ @@ -3623,6 +3624,12 @@ export const buildUpdateFieldSpecs = ( } if (Object.prototype.hasOwnProperty.call(input, 'aiConfig')) { + // v1 parity (T6520): aiConfig must match the (possibly converted) field type. + const aiConfigFieldType = input.type ?? currentField.type().toString(); + const aiConfigValidation = validateFieldAiConfig(aiConfigFieldType, input.aiConfig); + if (!aiConfigValidation.valid) { + return err(domainError.validation({ message: aiConfigValidation.message })); + } specs.push( TableUpdateFieldAiConfigSpec.create(currentField.id(), null, input.aiConfig ?? null) ); diff --git a/packages/v2/core/src/commands/TableInputParser.ts b/packages/v2/core/src/commands/TableInputParser.ts index 0ee77a73a0..b801549858 100644 --- a/packages/v2/core/src/commands/TableInputParser.ts +++ b/packages/v2/core/src/commands/TableInputParser.ts @@ -4,6 +4,7 @@ import { match } from 'ts-pattern'; import { BaseId } from '../domain/base/BaseId'; import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { FieldType } from '../domain/table/fields/FieldType'; import type { LinkForeignTableReference } from '../domain/table/fields/visitors/LinkForeignTableReferenceVisitor'; import { Table } from '../domain/table/Table'; import type { TableBuildOptions, TableBuilder } from '../domain/table/TableBuilder'; @@ -157,6 +158,8 @@ class PluginViewSpec implements ITableViewSpec { export type TableInputParserOptions = { executionContext?: IExecutionContext; + /** Internal snapshot/import recovery only; public mutation boundaries stay strict. */ + aiConfigMode?: 'strict' | 'trustedRehydrate'; }; /** @@ -181,6 +184,21 @@ export function parseFieldSpecs( const primaryIndex = primaryIndexes[0] ?? 0; + // v1 parity (T6520): the primary field type is restricted at creation just + // like on conversion — a checkbox/attachment/... first field is rejected + // instead of being silently promoted to primary. + const primaryFieldInput = fieldsToUse[primaryIndex]; + if (primaryFieldInput) { + const primaryTypeResult = FieldType.create(primaryFieldInput.type); + if (primaryTypeResult.isOk() && !primaryTypeResult.value.isPrimarySupported()) { + return err( + domainError.validation({ + message: `Field type ${primaryFieldInput.type} is not supported as primary field`, + }) + ); + } + } + const fieldsWithPrimaryFlag = fieldsToUse.map((field, index) => index === primaryIndex && field.isPrimary !== true ? { ...field, isPrimary: true } : field ); @@ -190,6 +208,7 @@ export function parseFieldSpecs( parseTableFieldSpec(field, { isPrimary: index === primaryIndex, executionContext: options?.executionContext, + aiConfigMode: options?.aiConfigMode, }) ); diff --git a/packages/v2/core/src/commands/UpdateFieldHandler.spec.ts b/packages/v2/core/src/commands/UpdateFieldHandler.spec.ts index 64a2637db3..ff9efbe2f7 100644 --- a/packages/v2/core/src/commands/UpdateFieldHandler.spec.ts +++ b/packages/v2/core/src/commands/UpdateFieldHandler.spec.ts @@ -948,8 +948,7 @@ describe('UpdateFieldHandler', () => { } expect(result.error.code).toBe(TABLE_FIELD_LIMIT_ERROR_CODE); - expect(result.error.message).toContain('limit:3'); - expect(result.error.message).toContain('table:Foreign'); + expect(result.error.message).toBe('Table "Foreign" can have at most 3 fields.'); expect(result.error.details).toMatchObject({ tableName: 'Foreign', currentFieldCount: 3, diff --git a/packages/v2/core/src/commands/UpdateRecordsCommand.spec.ts b/packages/v2/core/src/commands/UpdateRecordsCommand.spec.ts index 57832c73ee..95cca5a4e7 100644 --- a/packages/v2/core/src/commands/UpdateRecordsCommand.spec.ts +++ b/packages/v2/core/src/commands/UpdateRecordsCommand.spec.ts @@ -228,7 +228,9 @@ describe('UpdateRecordsCommand', () => { expect(commandResult.isErr()).toBe(true); }); - it('rejects duplicate explicit record ids', () => { + // v1 parity: duplicate recordIds in one batch are merged field-by-field with + // last write winning, instead of rejecting the whole batch (T6520). + it('merges duplicate explicit record ids across different fields', () => { const duplicateRecordId = `rec${'a'.repeat(16)}`; const commandResult = UpdateRecordsCommand.create({ tableId, @@ -249,7 +251,90 @@ describe('UpdateRecordsCommand', () => { fieldKeyType: 'id', }); - expect(commandResult.isErr()).toBe(true); + expect(commandResult.isOk()).toBe(true); + const command = commandResult._unsafeUnwrap(); + expect(command.records).toHaveLength(1); + expect(command.records?.[0]?.recordId.toString()).toBe(duplicateRecordId); + expect(command.records?.[0]?.fieldValues.get(numberFieldId)).toBe(42); + expect(command.records?.[0]?.fieldValues.get(textFieldId)).toBe('again'); + }); + + it('merges duplicate explicit record ids with last write winning per field', () => { + const duplicateRecordId = `rec${'a'.repeat(16)}`; + const otherRecordId = `rec${'b'.repeat(16)}`; + const commandResult = UpdateRecordsCommand.create({ + tableId, + records: [ + { + id: duplicateRecordId, + fields: { + [textFieldId]: 'first', + [numberFieldId]: 1, + }, + }, + { + id: otherRecordId, + fields: { + [textFieldId]: 'other', + }, + }, + { + id: duplicateRecordId, + fields: { + [textFieldId]: 'second', + }, + }, + ], + fieldKeyType: 'id', + }); + + expect(commandResult.isOk()).toBe(true); + const command = commandResult._unsafeUnwrap(); + expect(command.records).toHaveLength(2); + expect(command.records?.map((record) => record.recordId.toString())).toEqual([ + duplicateRecordId, + otherRecordId, + ]); + expect(command.records?.[0]?.fieldValues.get(textFieldId)).toBe('second'); + expect(command.records?.[0]?.fieldValues.get(numberFieldId)).toBe(1); + expect(command.records?.[1]?.fieldValues.get(textFieldId)).toBe('other'); + }); + + it('uses the last duplicate occurrence when records are reordered', () => { + const duplicateRecordId = `rec${'a'.repeat(16)}`; + const otherRecordId = `rec${'b'.repeat(16)}`; + const commandResult = UpdateRecordsCommand.create({ + tableId, + records: [ + { + id: duplicateRecordId, + fields: { [textFieldId]: 'first', [numberFieldId]: 1 }, + }, + { + id: otherRecordId, + fields: { [textFieldId]: 'other' }, + }, + { + id: duplicateRecordId, + fields: { [textFieldId]: 'second' }, + }, + ], + order: { + viewId: `viw${'c'.repeat(16)}`, + anchorId: `rec${'c'.repeat(16)}`, + position: 'after', + }, + fieldKeyType: 'id', + }); + + expect(commandResult.isOk()).toBe(true); + const command = commandResult._unsafeUnwrap(); + expect(command.records?.map((record) => record.recordId.toString())).toEqual([ + otherRecordId, + duplicateRecordId, + ]); + expect(command.records?.[1]?.fieldValues.get(textFieldId)).toBe('second'); + expect(command.records?.[1]?.fieldValues.get(numberFieldId)).toBe(1); }); it('rejects empty filter groups', () => { diff --git a/packages/v2/core/src/commands/UpdateRecordsCommand.ts b/packages/v2/core/src/commands/UpdateRecordsCommand.ts index 892725e8d9..38d3a3479f 100644 --- a/packages/v2/core/src/commands/UpdateRecordsCommand.ts +++ b/packages/v2/core/src/commands/UpdateRecordsCommand.ts @@ -4,11 +4,11 @@ import { z } from 'zod'; import { domainError, type DomainError } from '../domain/shared/DomainError'; import { type FieldKeyType, fieldKeyTypeSchema } from '../domain/table/fields/FieldKeyType'; +import { RecordId } from '../domain/table/records/RecordId'; import { RecordInsertOrder, recordInsertOrderSchema, } from '../domain/table/records/RecordInsertOrder'; -import { RecordId } from '../domain/table/records/RecordId'; import { TableId } from '../domain/table/TableId'; import type { RecordWritePluginRunnerOptions } from '../ports/RecordWritePlugin'; import { recordFilterNodeSchema, type RecordFilterNode } from '../queries/RecordFilterDto'; @@ -123,7 +123,7 @@ export class UpdateRecordsCommand { return TableId.create(parsed.data.tableId).andThen((tableId) => parseRecordIds(parsed.data.recordIds).andThen((recordIds) => - parseRecordItems(parsed.data.records).andThen((records) => + parseRecordItems(parsed.data.records, parsed.data.order !== undefined).andThen((records) => parseOrder(parsed.data.order).map( (order) => new UpdateRecordsCommand( @@ -174,14 +174,18 @@ const parseRecordIds = ( }; const parseRecordItems = ( - records: ReadonlyArray> | undefined + records: ReadonlyArray> | undefined, + preserveLastOccurrenceOrder: boolean ): Result | undefined, DomainError> => { if (!records) { return ok(undefined); } - const parsed: IUpdateRecordsItem[] = []; - const seenIds = new Set(); + // v1 parity: duplicate recordIds in one batch are merged field-by-field with + // last write winning (v1 applies ops per record in input order). The batch + // UPDATE ... FROM (VALUES ...) SQL requires one row per record id — feeding + // it duplicates would make Postgres pick an arbitrary row. + const mergedById = new Map(); for (const rawRecord of records) { const recordIdResult = RecordId.create(rawRecord.id); @@ -195,23 +199,31 @@ const parseRecordItems = ( } const recordIdText = recordIdResult.value.toString(); - if (seenIds.has(recordIdText)) { - return err( - domainError.validation({ - message: 'Duplicate recordId in UpdateRecordsCommand', - details: { recordId: recordIdText }, - }) - ); + const existing = mergedById.get(recordIdText); + if (existing) { + const fieldValues = new Map(existing.fieldValues); + for (const [fieldKey, value] of Object.entries(rawRecord.fields)) { + fieldValues.set(fieldKey, value); + } + const merged = { recordId: existing.recordId, fieldValues }; + // v1 assigns order in request order, so the final duplicate occurrence + // determines the record's final position. Map reinsertion models that + // without changing the long-standing no-order response ordering. + if (preserveLastOccurrenceOrder) { + mergedById.delete(recordIdText); + } + mergedById.set(recordIdText, merged); + continue; } - seenIds.add(recordIdText); - parsed.push({ + const fieldValues = new Map(Object.entries(rawRecord.fields)); + mergedById.set(recordIdText, { recordId: recordIdResult.value, - fieldValues: new Map(Object.entries(rawRecord.fields)), + fieldValues, }); } - return ok(parsed as ReadonlyArray); + return ok([...mergedById.values()] as ReadonlyArray); }; const parseOrder = ( diff --git a/packages/v2/core/src/commands/UpdateTablePropertiesCommand.spec.ts b/packages/v2/core/src/commands/UpdateTablePropertiesCommand.spec.ts new file mode 100644 index 0000000000..0b77314a64 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateTablePropertiesCommand.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { UpdateTablePropertiesCommand } from './UpdateTablePropertiesCommand'; + +const baseId = `bse${'a'.repeat(16)}`; +const tableId = `tbl${'a'.repeat(16)}`; + +describe('UpdateTablePropertiesCommand', () => { + it('creates a partial table properties patch', () => { + const command = UpdateTablePropertiesCommand.create({ + baseId, + tableId, + description: 'Projects tracked by the team', + })._unsafeUnwrap(); + + expect(command.patch).toEqual({ description: 'Projects tracked by the team' }); + }); + + it('accepts null to clear table properties', () => { + const command = UpdateTablePropertiesCommand.create({ + baseId, + tableId, + description: null, + icon: null, + })._unsafeUnwrap(); + + expect(command.patch).toEqual({ description: null, icon: null }); + }); + + it.each([ + { baseId, tableId }, + { baseId, tableId, icon: 'not-an-emoji' }, + { baseId, tableId, description: 'x'.repeat(2_001) }, + { baseId, tableId, description: 'Description', unknown: true }, + ])('rejects invalid input %#', (input) => { + expect(UpdateTablePropertiesCommand.create(input).isErr()).toBe(true); + }); +}); diff --git a/packages/v2/core/src/commands/UpdateTablePropertiesCommand.ts b/packages/v2/core/src/commands/UpdateTablePropertiesCommand.ts new file mode 100644 index 0000000000..36050e1ee6 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateTablePropertiesCommand.ts @@ -0,0 +1,66 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { BaseId } from '../domain/base/BaseId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { DEFAULT_TABLE_DATA_SAFETY_LIMITS } from '../domain/shared/TableDataSafetyLimits'; +import { TableId } from '../domain/table/TableId'; +import type { TablePropertiesPatch } from '../domain/table/TableProperties'; +import { TableProperties } from '../domain/table/TableProperties'; +import { TableUpdateCommand } from './TableUpdateCommand'; + +export const updateTablePropertiesInputSchema = z + .object({ + baseId: z.string(), + tableId: z.string(), + description: z + .string() + .max(DEFAULT_TABLE_DATA_SAFETY_LIMITS.displayText.maxDescriptionLength) + .nullable() + .optional(), + icon: z.string().emoji().nullable().optional(), + }) + .strict() + .refine((value) => 'description' in value || 'icon' in value, { + message: 'At least one table property is required', + }); + +export type IUpdateTablePropertiesCommandInput = z.input; + +export class UpdateTablePropertiesCommand extends TableUpdateCommand { + private constructor( + readonly baseId: BaseId, + readonly tableId: TableId, + readonly patch: TablePropertiesPatch + ) { + super(baseId, tableId); + } + + static create(raw: unknown): Result { + const parsed = updateTablePropertiesInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid UpdateTablePropertiesCommand input', + details: { issues: parsed.error.issues }, + }) + ); + } + + const patch: TablePropertiesPatch = { + ...('description' in parsed.data ? { description: parsed.data.description } : {}), + ...('icon' in parsed.data ? { icon: parsed.data.icon } : {}), + }; + + return TableProperties.empty() + .withPatch(patch) + .andThen(() => + BaseId.create(parsed.data.baseId).andThen((baseId) => + TableId.create(parsed.data.tableId).map( + (tableId) => new UpdateTablePropertiesCommand(baseId, tableId, patch) + ) + ) + ); + } +} diff --git a/packages/v2/core/src/commands/UpdateTablePropertiesHandler.ts b/packages/v2/core/src/commands/UpdateTablePropertiesHandler.ts new file mode 100644 index 0000000000..22794d3cf0 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateTablePropertiesHandler.ts @@ -0,0 +1,49 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import type { DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import type { Table } from '../domain/table/Table'; +import * as ExecutionContextPort from '../ports/ExecutionContext'; +import { v2CoreTokens } from '../ports/tokens'; +import { TraceSpan } from '../ports/TraceSpan'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { UpdateTablePropertiesCommand } from './UpdateTablePropertiesCommand'; + +export class UpdateTablePropertiesResult { + private constructor( + readonly table: Table, + readonly events: ReadonlyArray + ) {} + + static create(table: Table, events: ReadonlyArray): UpdateTablePropertiesResult { + return new UpdateTablePropertiesResult(table, [...events]); + } +} + +@CommandHandler(UpdateTablePropertiesCommand) +@injectable() +export class UpdateTablePropertiesHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow + ) {} + + @TraceSpan() + async handle( + context: ExecutionContextPort.IExecutionContext, + command: UpdateTablePropertiesCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const updateResult = yield* await handler.tableUpdateFlow.execute(context, command, (table) => + table.update((mutator) => mutator.updateProperties(command.patch)) + ); + return ok(UpdateTablePropertiesResult.create(updateResult.table, updateResult.events)); + }); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewColumnMetaCommand.ts b/packages/v2/core/src/commands/UpdateViewColumnMetaCommand.ts new file mode 100644 index 0000000000..c21547b915 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewColumnMetaCommand.ts @@ -0,0 +1,71 @@ +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { FieldId } from '../domain/table/fields/FieldId'; +import { TableId } from '../domain/table/TableId'; +import type { ViewColumnMetaPatch } from '../domain/table/views/ViewColumnMeta'; +import { ViewId } from '../domain/table/views/ViewId'; +import { PublicCommand } from './PublicCommand'; + +const columnMetaPatchSchema = z + .object({ + order: z.number().optional(), + visible: z.boolean().optional(), + hidden: z.boolean().optional(), + width: z.number().optional(), + required: z.boolean().optional(), + statisticFunc: z.string().nullable().optional(), + }) + .strict(); + +export const updateViewColumnMetaInputSchema = z.object({ + tableId: z.string(), + viewId: z.string(), + columnMeta: z.array( + z.object({ + fieldId: z.string(), + columnMeta: columnMetaPatchSchema, + }) + ), +}); + +export type IUpdateViewColumnMetaCommandInput = z.input; + +export class UpdateViewColumnMetaCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId, + readonly patches: ReadonlyArray + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = updateViewColumnMetaInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid UpdateViewColumnMetaCommand input', + details: z.formatError(parsed.error), + }) + ); + } + + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).andThen((viewId) => { + const patches: ViewColumnMetaPatch[] = []; + for (const patch of parsed.data.columnMeta) { + const fieldIdResult = FieldId.create(patch.fieldId); + if (fieldIdResult.isErr()) return err(fieldIdResult.error); + patches.push({ + fieldId: fieldIdResult.value, + columnMeta: { ...patch.columnMeta }, + }); + } + return ok(new UpdateViewColumnMetaCommand(tableId, viewId, patches)); + }) + ); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewColumnMetaHandler.spec.ts b/packages/v2/core/src/commands/UpdateViewColumnMetaHandler.spec.ts new file mode 100644 index 0000000000..a0b0002b95 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewColumnMetaHandler.spec.ts @@ -0,0 +1,304 @@ +import { err, ok, type Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import type { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { ViewColumnMetaUpdated } from '../domain/table/events/ViewColumnMetaUpdated'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { TableUpdateViewColumnMetaSpec } from '../domain/table/specs/TableUpdateViewColumnMetaSpec'; +import { Table } from '../domain/table/Table'; +import type { TableUpdateResult } from '../domain/table/TableMutator'; +import { TableName } from '../domain/table/TableName'; +import type { ViewId } from '../domain/table/views/ViewId'; +import { captureViewSnapshot } from '../domain/table/views/ViewSnapshot'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { UpdateViewColumnMetaCommand } from './UpdateViewColumnMetaCommand'; +import { UpdateViewColumnMetaHandler } from './UpdateViewColumnMetaHandler'; + +const context: IExecutionContext = { actorId: ActorId.create('actor')._unsafeUnwrap() }; + +const buildTable = () => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'e'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Column metadata')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withName(FieldName.create('Primary')._unsafeUnwrap()) + .primary() + .done(); + builder.field().singleLineText().withName(FieldName.create('Secondary')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + const baseTable = builder.build()._unsafeUnwrap(); + const [primaryField, secondaryField] = baseTable.getFields(); + const created = baseTable + .createView({ + type: 'grid', + name: 'Frozen', + columnMeta: { + [primaryField!.id().toString()]: { order: 0 }, + [secondaryField!.id().toString()]: { order: 1 }, + }, + options: { frozenFieldId: secondaryField!.id().toString() }, + }) + ._unsafeUnwrap(); + const table = created.updateResult.table; + table.pullDomainEvents(); + return { + table, + viewId: created.view.id(), + primaryFieldId: primaryField!.id(), + secondaryFieldId: secondaryField!.id(), + }; +}; + +class FakeTableUpdateFlow { + calls = 0; + mutateSpec?: TableUpdateResult['mutateSpec']; + + async execute( + _context: IExecutionContext, + target: { table: Table }, + mutate: (table: Table) => Result + ) { + this.calls += 1; + const result = mutate(target.table); + if (result.isErr()) return err(result.error); + this.mutateSpec = result.value.mutateSpec; + return ok({ + table: result.value.table, + events: result.value.table.pullDomainEvents(), + postPersistEvents: [], + }); + } +} + +const createHandler = (params: { + tableResult: Result; + plugins?: IViewOperationPlugin[]; + undoFailure?: DomainError; +}) => { + const repository = { + findOne: vi.fn(async () => params.tableResult), + } as unknown as ITableRepository; + const flow = new FakeTableUpdateFlow(); + const capture = vi.fn((table: Table, viewId: string) => + table.getViewById(viewId).andThen(captureViewSnapshot) + ); + const appendUpdate = vi.fn(async () => + params.undoFailure ? err(params.undoFailure) : ok(undefined) + ); + const handler = new UpdateViewColumnMetaHandler( + repository, + flow as unknown as TableUpdateFlow, + new ViewOperationPluginRunner(params.plugins), + { capture, appendUpdate } as unknown as ViewUndoRedoService + ); + return { handler, repository, flow, capture, appendUpdate }; +}; + +const commandFor = ( + table: Table, + viewId: ViewId, + fieldId: string, + columnMeta: Record +) => + UpdateViewColumnMetaCommand.create({ + tableId: table.id().toString(), + viewId: viewId.toString(), + columnMeta: [{ fieldId, columnMeta }], + })._unsafeUnwrap(); + +describe('UpdateViewColumnMetaCommand', () => { + it('validates aggregate, child, Field IDs, and strict metadata patches', () => { + const fixture = buildTable(); + expect( + commandFor(fixture.table, fixture.viewId, fixture.secondaryFieldId.toString(), { width: 240 }) + ).toBeInstanceOf(UpdateViewColumnMetaCommand); + expect( + UpdateViewColumnMetaCommand.create({ + tableId: fixture.table.id().toString(), + viewId: fixture.viewId.toString(), + columnMeta: [{ fieldId: 'bad', columnMeta: {} }], + }).isErr() + ).toBe(true); + expect( + UpdateViewColumnMetaCommand.create({ + tableId: fixture.table.id().toString(), + viewId: fixture.viewId.toString(), + columnMeta: [ + { fieldId: fixture.secondaryFieldId.toString(), columnMeta: { unsupported: true } }, + ], + }).isErr() + ).toBe(true); + }); +}); + +describe('UpdateViewColumnMetaHandler', () => { + it('updates column metadata and frozen options through Table behavior, plugins, events, and undo', async () => { + const fixture = buildTable(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler({ + tableResult: ok(fixture.table), + plugins: [ + { + name: 'capture', + supports: (kind) => kind === ViewOperationKind.update, + prepare, + guard: () => ok(undefined), + }, + ], + }); + const command = commandFor(fixture.table, fixture.viewId, fixture.secondaryFieldId.toString(), { + order: 2, + width: 240, + }); + + const result = (await setup.handler.handle(context, command))._unsafeUnwrap(); + const view = result.table.getView(fixture.viewId)._unsafeUnwrap(); + + expect(setup.repository.findOne).toHaveBeenCalledOnce(); + expect(setup.flow.mutateSpec).toBeInstanceOf(TableUpdateViewColumnMetaSpec); + expect(result.changes).toHaveLength(1); + expect(result.nextColumnMeta.toDto()[fixture.secondaryFieldId.toString()]).toMatchObject({ + order: 2, + width: 240, + }); + expect(result.previousOptions).toEqual({ + frozenFieldId: fixture.secondaryFieldId.toString(), + }); + expect(result.nextOptions).toEqual({ frozenFieldId: fixture.primaryFieldId.toString() }); + expect(view.options()).toEqual({ frozenFieldId: fixture.primaryFieldId.toString() }); + expect(result.events.some((event) => event instanceof ViewColumnMetaUpdated)).toBe(true); + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: ViewOperationKind.update, + payload: { + tableId: fixture.table.id().toString(), + viewId: fixture.viewId.toString(), + patch: { + columnMeta: result.nextColumnMeta.toDto(), + options: { frozenFieldId: fixture.primaryFieldId.toString() }, + }, + }, + }) + ); + expect(setup.capture).toHaveBeenCalledTimes(2); + expect(setup.appendUpdate).toHaveBeenCalledWith( + context, + result.table, + [expect.objectContaining({ id: fixture.viewId.toString() })], + [expect.objectContaining({ id: fixture.viewId.toString() })] + ); + }); + + it('returns a no-op without plugins, persistence, or a second undo snapshot', async () => { + const fixture = buildTable(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler({ + tableResult: ok(fixture.table), + plugins: [{ name: 'capture', supports: () => true, prepare }], + }); + const command = UpdateViewColumnMetaCommand.create({ + tableId: fixture.table.id().toString(), + viewId: fixture.viewId.toString(), + columnMeta: [], + })._unsafeUnwrap(); + + const result = (await setup.handler.handle(context, command))._unsafeUnwrap(); + + expect(result.table).toBe(fixture.table); + expect(result.changes).toEqual([]); + expect(result.events).toEqual([]); + expect(prepare).not.toHaveBeenCalled(); + expect(setup.flow.calls).toBe(0); + expect(setup.capture).toHaveBeenCalledOnce(); + expect(setup.appendUpdate).not.toHaveBeenCalled(); + }); + + it('returns aggregate validation/not-found errors before plugins or persistence', async () => { + const fixture = buildTable(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler({ + tableResult: ok(fixture.table), + plugins: [{ name: 'capture', supports: () => true, prepare }], + }); + const hidePrimary = commandFor( + fixture.table, + fixture.viewId, + fixture.primaryFieldId.toString(), + { hidden: true } + ); + + expect((await setup.handler.handle(context, hidePrimary))._unsafeUnwrapErr().code).toBe( + 'view.primary_field_cannot_be_hidden' + ); + + const missingField = commandFor(fixture.table, fixture.viewId, `fld${'z'.repeat(16)}`, { + width: 100, + }); + expect((await setup.handler.handle(context, missingField))._unsafeUnwrapErr().code).toBe( + 'field.not_found' + ); + expect(prepare).not.toHaveBeenCalled(); + expect(setup.flow.calls).toBe(0); + expect(setup.appendUpdate).not.toHaveBeenCalled(); + }); + + it('does not persist or append undo when plugin policy rejects the update', async () => { + const fixture = buildTable(); + const setup = createHandler({ + tableResult: ok(fixture.table), + plugins: [ + { + name: 'reject', + supports: () => true, + guard: () => err(domainError.forbidden({ message: 'Metadata rejected' })), + }, + ], + }); + const command = commandFor(fixture.table, fixture.viewId, fixture.secondaryFieldId.toString(), { + width: 300, + }); + + expect((await setup.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'forbidden' + ); + expect(setup.flow.calls).toBe(0); + expect(setup.capture).toHaveBeenCalledOnce(); + expect(setup.appendUpdate).not.toHaveBeenCalled(); + }); + + it('propagates repository and undo failures at their orchestration boundaries', async () => { + const fixture = buildTable(); + const command = commandFor(fixture.table, fixture.viewId, fixture.secondaryFieldId.toString(), { + width: 320, + }); + const missing = createHandler({ + tableResult: err(domainError.notFound({ message: 'Missing Table' })), + }); + expect((await missing.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'not_found' + ); + expect(missing.capture).not.toHaveBeenCalled(); + expect(missing.flow.calls).toBe(0); + + const undoRejected = createHandler({ + tableResult: ok(fixture.table), + undoFailure: domainError.unexpected({ message: 'Undo store unavailable' }), + }); + expect((await undoRejected.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'unexpected' + ); + expect(undoRejected.flow.calls).toBe(1); + expect(undoRejected.capture).toHaveBeenCalledTimes(2); + expect(undoRejected.appendUpdate).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/v2/core/src/commands/UpdateViewColumnMetaHandler.ts b/packages/v2/core/src/commands/UpdateViewColumnMetaHandler.ts new file mode 100644 index 0000000000..095d79fe25 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewColumnMetaHandler.ts @@ -0,0 +1,143 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import type { DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { Table as TableAggregate, type Table } from '../domain/table/Table'; +import type { ViewColumnMeta, ViewColumnMetaChange } from '../domain/table/views/ViewColumnMeta'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type * as ExecutionContextPort from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { UpdateViewColumnMetaCommand } from './UpdateViewColumnMetaCommand'; + +export class UpdateViewColumnMetaResult { + private constructor( + readonly table: Table, + readonly viewId: ViewId, + readonly previousColumnMeta: ViewColumnMeta, + readonly nextColumnMeta: ViewColumnMeta, + readonly changes: ReadonlyArray, + readonly previousOptions: unknown, + readonly nextOptions: unknown, + readonly events: ReadonlyArray + ) {} + + static create(params: { + table: Table; + viewId: ViewId; + previousColumnMeta: ViewColumnMeta; + nextColumnMeta: ViewColumnMeta; + changes: ReadonlyArray; + previousOptions?: unknown; + nextOptions?: unknown; + events: ReadonlyArray; + }): UpdateViewColumnMetaResult { + return new UpdateViewColumnMetaResult( + params.table, + params.viewId, + params.previousColumnMeta, + params.nextColumnMeta, + [...params.changes], + params.previousOptions, + params.nextOptions, + [...params.events] + ); + } +} + +@CommandHandler(UpdateViewColumnMetaCommand) +@injectable() +export class UpdateViewColumnMetaHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow, + @inject(v2CoreTokens.viewOperationPluginRunner) + private readonly viewOperationPluginRunner: ViewOperationPluginRunner, + @inject(v2CoreTokens.viewUndoRedoService) + private readonly viewUndoRedoService: ViewUndoRedoService + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + command: UpdateViewColumnMetaCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* TableAggregate.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const previousSnapshot = yield* handler.viewUndoRedoService.capture( + table, + command.viewId.toString() + ); + const columnMetaResult = yield* table.updateViewColumnMeta(command.viewId, command.patches); + + if (!columnMetaResult.updateResult) { + return ok( + UpdateViewColumnMetaResult.create({ + table, + viewId: command.viewId, + previousColumnMeta: columnMetaResult.previousColumnMeta, + nextColumnMeta: columnMetaResult.nextColumnMeta, + changes: [], + events: [], + }) + ); + } + + const pluginExecution = yield* await handler.viewOperationPluginRunner.prepare({ + kind: ViewOperationKind.update, + executionContext: context, + payload: { + tableId: table.id().toString(), + viewId: command.viewId.toString(), + patch: { + columnMeta: columnMetaResult.nextColumnMeta.toDto(), + ...(columnMetaResult.nextOptions !== undefined + ? { options: columnMetaResult.nextOptions } + : {}), + }, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + + const update = yield* await handler.tableUpdateFlow.execute(context, { table }, () => + ok(columnMetaResult.updateResult!) + ); + const nextSnapshot = yield* handler.viewUndoRedoService.capture( + update.table, + command.viewId.toString() + ); + yield* await handler.viewUndoRedoService.appendUpdate( + context, + update.table, + [previousSnapshot], + [nextSnapshot] + ); + + return ok( + UpdateViewColumnMetaResult.create({ + table: update.table, + viewId: command.viewId, + previousColumnMeta: columnMetaResult.previousColumnMeta, + nextColumnMeta: columnMetaResult.nextColumnMeta, + changes: columnMetaResult.changes, + previousOptions: columnMetaResult.previousOptions, + nextOptions: columnMetaResult.nextOptions, + events: update.events, + }) + ); + }); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewDescriptionCommand.ts b/packages/v2/core/src/commands/UpdateViewDescriptionCommand.ts new file mode 100644 index 0000000000..9b4be4fef4 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewDescriptionCommand.ts @@ -0,0 +1,44 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; +import { PublicCommand } from './PublicCommand'; + +export const updateViewDescriptionInputSchema = z.object({ + tableId: z.string(), + viewId: z.string(), + description: z.string(), +}); + +export type IUpdateViewDescriptionCommandInput = z.input; + +export class UpdateViewDescriptionCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId, + readonly description: string + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = updateViewDescriptionInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid UpdateViewDescriptionCommand input', + details: z.formatError(parsed.error), + }) + ); + } + + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map( + (viewId) => new UpdateViewDescriptionCommand(tableId, viewId, parsed.data.description) + ) + ); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewDescriptionHandler.spec.ts b/packages/v2/core/src/commands/UpdateViewDescriptionHandler.spec.ts new file mode 100644 index 0000000000..f5b7089ae3 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewDescriptionHandler.spec.ts @@ -0,0 +1,162 @@ +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import type { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { TableUpdateViewDescriptionSpec } from '../domain/table/specs/TableUpdateViewDescriptionSpec'; +import { Table } from '../domain/table/Table'; +import type { TableUpdateResult } from '../domain/table/TableMutator'; +import { TableName } from '../domain/table/TableName'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { UpdateViewDescriptionCommand } from './UpdateViewDescriptionCommand'; +import { UpdateViewDescriptionHandler } from './UpdateViewDescriptionHandler'; + +const context: IExecutionContext = { + actorId: ActorId.create('actor')._unsafeUnwrap(), +}; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +class FakeTableUpdateFlow { + calls = 0; + mutateSpec?: TableUpdateResult['mutateSpec']; + + async execute( + _context: IExecutionContext, + target: { table: Table }, + mutate: (table: Table) => Result + ) { + this.calls += 1; + const result = mutate(target.table); + if (result.isErr()) return err(result.error); + this.mutateSpec = result.value.mutateSpec; + return ok({ table: result.value.table, events: [], postPersistEvents: [] }); + } +} + +const createHandler = (table: Table, operationPlugins: IViewOperationPlugin[] = []) => { + const tableRepository = { + findOne: vi.fn(async () => ok(table)), + } as unknown as ITableRepository; + const tableUpdateFlow = new FakeTableUpdateFlow(); + const pluginRunner = new ViewOperationPluginRunner(operationPlugins); + const undoRedo = { + capture: vi.fn(() => ok({ id: table.views()[0]!.id().toString() } as never)), + appendUpdate: vi.fn(async () => ok(undefined)), + } as unknown as ViewUndoRedoService; + const handler = new UpdateViewDescriptionHandler( + tableRepository, + tableUpdateFlow as unknown as TableUpdateFlow, + pluginRunner, + undoRedo + ); + return { handler, tableRepository, tableUpdateFlow }; +}; + +describe('UpdateViewDescriptionCommand', () => { + it('validates Table and View identifiers and requires a string description', () => { + expect( + UpdateViewDescriptionCommand.create({ + tableId: 'bad', + viewId: 'bad', + description: 1, + }).isErr() + ).toBe(true); + }); +}); + +describe('UpdateViewDescriptionHandler', () => { + it('orchestrates the Table aggregate, update guard, and description spec', async () => { + const table = buildTable(); + const target = table.views()[0]!; + const seenOperations: unknown[] = []; + const operationPlugin: IViewOperationPlugin = { + name: 'capture', + supports: (kind) => kind === ViewOperationKind.update, + prepare: (pluginContext) => { + seenOperations.push(pluginContext); + return ok(undefined); + }, + guard: () => ok(undefined), + }; + const setup = createHandler(table, [operationPlugin]); + const command = UpdateViewDescriptionCommand.create({ + tableId: table.id().toString(), + viewId: target.id().toString(), + description: 'After', + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().previousDescription).toBeUndefined(); + expect(result._unsafeUnwrap().nextDescription).toBe('After'); + expect(result._unsafeUnwrap().table.getView(target.id())._unsafeUnwrap().description()).toBe( + 'After' + ); + expect(setup.tableRepository.findOne).toHaveBeenCalledOnce(); + expect(setup.tableUpdateFlow.calls).toBe(1); + expect(setup.tableUpdateFlow.mutateSpec).toBeInstanceOf(TableUpdateViewDescriptionSpec); + expect(seenOperations).toEqual([ + expect.objectContaining({ + kind: 'update', + payload: { + tableId: table.id().toString(), + viewId: target.id().toString(), + patch: { description: 'After' }, + }, + }), + ]); + }); + + it('does not persist when the update operation guard rejects the request', async () => { + const table = buildTable(); + const operationPlugin: IViewOperationPlugin = { + name: 'reject', + supports: (kind) => kind === ViewOperationKind.update, + guard: () => err(domainError.forbidden({ message: 'View update rejected' })), + }; + const setup = createHandler(table, [operationPlugin]); + const command = UpdateViewDescriptionCommand.create({ + tableId: table.id().toString(), + viewId: table.views()[0]!.id().toString(), + description: 'Rejected', + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result._unsafeUnwrapErr().code).toBe('forbidden'); + expect(setup.tableUpdateFlow.calls).toBe(0); + }); + + it('returns an aggregate error and skips persistence for a missing View', async () => { + const table = buildTable(); + const setup = createHandler(table); + const command = UpdateViewDescriptionCommand.create({ + tableId: table.id().toString(), + viewId: `viw${'z'.repeat(16)}`, + description: 'Missing', + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result._unsafeUnwrapErr().code).toBe('view.not_found'); + expect(setup.tableUpdateFlow.calls).toBe(0); + }); +}); diff --git a/packages/v2/core/src/commands/UpdateViewDescriptionHandler.ts b/packages/v2/core/src/commands/UpdateViewDescriptionHandler.ts new file mode 100644 index 0000000000..96437b7a3d --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewDescriptionHandler.ts @@ -0,0 +1,115 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import type { DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { Table as TableAggregate, type Table } from '../domain/table/Table'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type * as ExecutionContextPort from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { UpdateViewDescriptionCommand } from './UpdateViewDescriptionCommand'; + +export class UpdateViewDescriptionResult { + private constructor( + readonly table: Table, + readonly viewId: ViewId, + readonly previousDescription: string | undefined, + readonly nextDescription: string, + readonly events: ReadonlyArray + ) {} + + static create(params: { + table: Table; + viewId: ViewId; + previousDescription: string | undefined; + nextDescription: string; + events: ReadonlyArray; + }): UpdateViewDescriptionResult { + return new UpdateViewDescriptionResult( + params.table, + params.viewId, + params.previousDescription, + params.nextDescription, + [...params.events] + ); + } +} + +@CommandHandler(UpdateViewDescriptionCommand) +@injectable() +export class UpdateViewDescriptionHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow, + @inject(v2CoreTokens.viewOperationPluginRunner) + private readonly viewOperationPluginRunner: ViewOperationPluginRunner, + @inject(v2CoreTokens.viewUndoRedoService) + private readonly viewUndoRedoService: ViewUndoRedoService + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + command: UpdateViewDescriptionCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* TableAggregate.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const previousSnapshot = yield* handler.viewUndoRedoService.capture( + table, + command.viewId.toString() + ); + const descriptionResult = yield* table.updateViewDescription( + command.viewId, + command.description + ); + + const pluginExecution = yield* await handler.viewOperationPluginRunner.prepare({ + kind: ViewOperationKind.update, + executionContext: context, + payload: { + tableId: table.id().toString(), + viewId: command.viewId.toString(), + patch: { description: command.description }, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + + const updateResult = yield* await handler.tableUpdateFlow.execute(context, { table }, () => + ok(descriptionResult.updateResult) + ); + const nextSnapshot = yield* handler.viewUndoRedoService.capture( + updateResult.table, + command.viewId.toString() + ); + yield* await handler.viewUndoRedoService.appendUpdate( + context, + updateResult.table, + [previousSnapshot], + [nextSnapshot] + ); + + return ok( + UpdateViewDescriptionResult.create({ + table: updateResult.table, + viewId: command.viewId, + previousDescription: descriptionResult.previousDescription, + nextDescription: descriptionResult.nextDescription, + events: updateResult.events, + }) + ); + }); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewFilterCommand.ts b/packages/v2/core/src/commands/UpdateViewFilterCommand.ts new file mode 100644 index 0000000000..326c1fe7f8 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewFilterCommand.ts @@ -0,0 +1,45 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; +import { + viewSourceFilterSchema, + type ViewSourceFilterDTO, +} from '../domain/table/views/ViewSourceFilter'; +import { PublicCommand } from './PublicCommand'; + +export const updateViewFilterInputSchema = z.object({ + tableId: z.string(), + viewId: z.string(), + filter: viewSourceFilterSchema, +}); + +export class UpdateViewFilterCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId, + readonly filter: ViewSourceFilterDTO | null + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = updateViewFilterInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid UpdateViewFilterCommand input', + details: z.formatError(parsed.error), + }) + ); + } + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map( + (viewId) => new UpdateViewFilterCommand(tableId, viewId, parsed.data.filter) + ) + ); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewFilterHandler.spec.ts b/packages/v2/core/src/commands/UpdateViewFilterHandler.spec.ts new file mode 100644 index 0000000000..2b98f23be2 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewFilterHandler.spec.ts @@ -0,0 +1,194 @@ +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import type { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { TableUpdateViewQueryDefaultsSpec } from '../domain/table/specs/TableUpdateViewQueryDefaultsSpec'; +import { Table } from '../domain/table/Table'; +import type { TableUpdateResult } from '../domain/table/TableMutator'; +import { TableName } from '../domain/table/TableName'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { UpdateViewFilterCommand } from './UpdateViewFilterCommand'; +import { UpdateViewFilterHandler } from './UpdateViewFilterHandler'; + +const context: IExecutionContext = { actorId: ActorId.create('actor')._unsafeUnwrap() }; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +class FakeTableUpdateFlow { + calls = 0; + mutateSpec?: TableUpdateResult['mutateSpec']; + + async execute( + _context: IExecutionContext, + target: { table: Table }, + mutate: (table: Table) => Result + ) { + this.calls += 1; + const result = mutate(target.table); + if (result.isErr()) return err(result.error); + this.mutateSpec = result.value.mutateSpec; + return ok({ + table: result.value.table, + events: result.value.table.pullDomainEvents(), + postPersistEvents: [], + }); + } +} + +const createHandler = (table: Table, plugins: IViewOperationPlugin[] = []) => { + const tableRepository = { findOne: vi.fn(async () => ok(table)) } as unknown as ITableRepository; + const tableUpdateFlow = new FakeTableUpdateFlow(); + const undoRedo = { + capture: vi.fn((_, viewId: string) => ok({ id: viewId } as never)), + appendUpdate: vi.fn(async () => ok(undefined)), + } as unknown as ViewUndoRedoService; + return { + handler: new UpdateViewFilterHandler( + tableRepository, + tableUpdateFlow as unknown as TableUpdateFlow, + new ViewOperationPluginRunner(plugins), + undoRedo + ), + tableRepository, + tableUpdateFlow, + undoRedo, + }; +}; + +describe('UpdateViewFilterCommand', () => { + it('validates identifiers and the public filter contract', () => { + expect( + UpdateViewFilterCommand.create({ tableId: 'bad', viewId: 'bad', filter: null }).isErr() + ).toBe(true); + const table = buildTable(); + expect( + UpdateViewFilterCommand.create({ + tableId: table.id().toString(), + viewId: table.views()[0]!.id().toString(), + filter: null, + }).isOk() + ).toBe(true); + }); +}); + +describe('UpdateViewFilterHandler', () => { + it('orchestrates aggregate mutation, plugin policy, persistence, and v2 history', async () => { + const table = buildTable(); + const filter = { + conjunction: 'and' as const, + filterSet: [ + { + fieldId: table.getFields()[0]!.id().toString(), + operator: 'is' as const, + value: 'After', + }, + ], + }; + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler(table, [ + { + name: 'capture', + supports: (kind) => kind === ViewOperationKind.update, + prepare, + guard: () => ok(undefined), + }, + ]); + const command = UpdateViewFilterCommand.create({ + tableId: table.id().toString(), + viewId: table.views()[0]!.id().toString(), + filter, + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result.isOk()).toBe(true); + expect(setup.tableRepository.findOne).toHaveBeenCalledOnce(); + expect(setup.tableUpdateFlow.mutateSpec).toBeInstanceOf(TableUpdateViewQueryDefaultsSpec); + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'update', + payload: expect.objectContaining({ patch: { filter } }), + }) + ); + expect(setup.undoRedo.capture).toHaveBeenCalledTimes(2); + expect(setup.undoRedo.appendUpdate).toHaveBeenCalledOnce(); + }); + + it('skips plugins, persistence, and history for an identical filter', async () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const filter = { + conjunction: 'and' as const, + filterSet: [ + { fieldId: table.getFields()[0]!.id().toString(), operator: 'is' as const, value: 'Same' }, + ], + }; + const current = table.updateViewFilter(viewId, filter)._unsafeUnwrap().updateResult!.table; + current.pullDomainEvents(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler(current, [ + { name: 'capture', supports: () => true, prepare, guard: () => ok(undefined) }, + ]); + const command = UpdateViewFilterCommand.create({ + tableId: current.id().toString(), + viewId: viewId.toString(), + filter, + })._unsafeUnwrap(); + + expect((await setup.handler.handle(context, command)).isOk()).toBe(true); + expect(setup.tableUpdateFlow.calls).toBe(0); + expect(prepare).not.toHaveBeenCalled(); + expect(setup.undoRedo.appendUpdate).not.toHaveBeenCalled(); + }); + + it('does not persist or append history when a guard rejects', async () => { + const table = buildTable(); + const seeded = table + .updateViewFilter(table.views()[0]!.id(), { + conjunction: 'and', + filterSet: [ + { + fieldId: table.getFields()[0]!.id().toString(), + operator: 'is', + value: 'seed', + }, + ], + }) + ._unsafeUnwrap().updateResult!.table; + const setup = createHandler(seeded, [ + { + name: 'reject', + supports: () => true, + guard: () => err(domainError.forbidden({ message: 'rejected' })), + }, + ]); + const command = UpdateViewFilterCommand.create({ + tableId: seeded.id().toString(), + viewId: seeded.views()[0]!.id().toString(), + filter: null, + })._unsafeUnwrap(); + + expect((await setup.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'forbidden' + ); + expect(setup.tableUpdateFlow.calls).toBe(0); + expect(setup.undoRedo.appendUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/v2/core/src/commands/UpdateViewFilterHandler.ts b/packages/v2/core/src/commands/UpdateViewFilterHandler.ts new file mode 100644 index 0000000000..6fec911d25 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewFilterHandler.ts @@ -0,0 +1,123 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import type { DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { Table as TableAggregate, type Table } from '../domain/table/Table'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type { ViewQueryDefaults } from '../domain/table/views/ViewQueryDefaults'; +import type * as ExecutionContextPort from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { UpdateViewFilterCommand } from './UpdateViewFilterCommand'; + +export class UpdateViewFilterResult { + private constructor( + readonly table: Table, + readonly viewId: ViewId, + readonly previousQueryDefaults: ViewQueryDefaults, + readonly nextQueryDefaults: ViewQueryDefaults, + readonly events: ReadonlyArray + ) {} + + static create(params: { + table: Table; + viewId: ViewId; + previousQueryDefaults: ViewQueryDefaults; + nextQueryDefaults: ViewQueryDefaults; + events: ReadonlyArray; + }): UpdateViewFilterResult { + return new UpdateViewFilterResult( + params.table, + params.viewId, + params.previousQueryDefaults, + params.nextQueryDefaults, + [...params.events] + ); + } +} + +@CommandHandler(UpdateViewFilterCommand) +@injectable() +export class UpdateViewFilterHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow, + @inject(v2CoreTokens.viewOperationPluginRunner) + private readonly viewOperationPluginRunner: ViewOperationPluginRunner, + @inject(v2CoreTokens.viewUndoRedoService) + private readonly viewUndoRedoService: ViewUndoRedoService + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + command: UpdateViewFilterCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* TableAggregate.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const previousSnapshot = yield* handler.viewUndoRedoService.capture( + table, + command.viewId.toString() + ); + const filterResult = yield* table.updateViewFilter(command.viewId, command.filter); + if (!filterResult.updateResult) { + return ok( + UpdateViewFilterResult.create({ + table, + viewId: command.viewId, + previousQueryDefaults: filterResult.previousQueryDefaults, + nextQueryDefaults: filterResult.nextQueryDefaults, + events: [], + }) + ); + } + + const pluginExecution = yield* await handler.viewOperationPluginRunner.prepare({ + kind: ViewOperationKind.update, + executionContext: context, + payload: { + tableId: table.id().toString(), + viewId: command.viewId.toString(), + patch: { filter: filterResult.nextQueryDefaults.sourceFilter() }, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + + const update = yield* await handler.tableUpdateFlow.execute(context, { table }, () => + ok(filterResult.updateResult!) + ); + const nextSnapshot = yield* handler.viewUndoRedoService.capture( + update.table, + command.viewId.toString() + ); + yield* await handler.viewUndoRedoService.appendUpdate( + context, + update.table, + [previousSnapshot], + [nextSnapshot] + ); + return ok( + UpdateViewFilterResult.create({ + table: update.table, + viewId: command.viewId, + previousQueryDefaults: filterResult.previousQueryDefaults, + nextQueryDefaults: filterResult.nextQueryDefaults, + events: update.events, + }) + ); + }); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewGroupCommand.ts b/packages/v2/core/src/commands/UpdateViewGroupCommand.ts new file mode 100644 index 0000000000..08f5c11a2f --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewGroupCommand.ts @@ -0,0 +1,41 @@ +import { err, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { viewGroupSchema, type ViewGroupDTO } from '../domain/table/views/ViewGroup'; +import { ViewId } from '../domain/table/views/ViewId'; +import { PublicCommand } from './PublicCommand'; + +export const updateViewGroupInputSchema = z.object({ + tableId: z.string(), + viewId: z.string(), + group: viewGroupSchema, +}); + +export class UpdateViewGroupCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId, + readonly group: ViewGroupDTO + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = updateViewGroupInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid UpdateViewGroupCommand input', + details: z.formatError(parsed.error), + }) + ); + } + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map( + (viewId) => new UpdateViewGroupCommand(tableId, viewId, parsed.data.group) + ) + ); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewGroupHandler.spec.ts b/packages/v2/core/src/commands/UpdateViewGroupHandler.spec.ts new file mode 100644 index 0000000000..ab9894baf5 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewGroupHandler.spec.ts @@ -0,0 +1,169 @@ +import { err, ok, type Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import type { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { TableUpdateViewQueryDefaultsSpec } from '../domain/table/specs/TableUpdateViewQueryDefaultsSpec'; +import { Table } from '../domain/table/Table'; +import type { TableUpdateResult } from '../domain/table/TableMutator'; +import { TableName } from '../domain/table/TableName'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { UpdateViewGroupCommand } from './UpdateViewGroupCommand'; +import { UpdateViewGroupHandler } from './UpdateViewGroupHandler'; + +const context: IExecutionContext = { actorId: ActorId.create('actor')._unsafeUnwrap() }; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +class FakeTableUpdateFlow { + calls = 0; + mutateSpec?: TableUpdateResult['mutateSpec']; + + async execute( + _context: IExecutionContext, + target: { table: Table }, + mutate: (table: Table) => Result + ) { + this.calls += 1; + const result = mutate(target.table); + if (result.isErr()) return err(result.error); + this.mutateSpec = result.value.mutateSpec; + return ok({ + table: result.value.table, + events: result.value.table.pullDomainEvents(), + postPersistEvents: [], + }); + } +} + +const createHandler = (table: Table, plugins: IViewOperationPlugin[] = []) => { + const tableRepository = { findOne: vi.fn(async () => ok(table)) } as unknown as ITableRepository; + const tableUpdateFlow = new FakeTableUpdateFlow(); + const undoRedo = { + capture: vi.fn((_, viewId: string) => ok({ id: viewId } as never)), + appendUpdate: vi.fn(async () => ok(undefined)), + } as unknown as ViewUndoRedoService; + return { + handler: new UpdateViewGroupHandler( + tableRepository, + tableUpdateFlow as unknown as TableUpdateFlow, + new ViewOperationPluginRunner(plugins), + undoRedo + ), + tableRepository, + tableUpdateFlow, + undoRedo, + }; +}; + +describe('UpdateViewGroupCommand', () => { + it('validates identifiers, directions, empty, and null groups', () => { + expect( + UpdateViewGroupCommand.create({ tableId: 'bad', viewId: 'bad', group: null }).isErr() + ).toBe(true); + const table = buildTable(); + const ids = { tableId: table.id().toString(), viewId: table.views()[0]!.id().toString() }; + expect(UpdateViewGroupCommand.create({ ...ids, group: null }).isOk()).toBe(true); + expect(UpdateViewGroupCommand.create({ ...ids, group: [] }).isOk()).toBe(true); + expect( + UpdateViewGroupCommand.create({ + ...ids, + group: [{ fieldId: table.getFields()[0]!.id().toString(), order: 'up' }], + }).isErr() + ).toBe(true); + }); +}); + +describe('UpdateViewGroupHandler', () => { + it('orchestrates aggregate mutation, plugin policy, persistence, and v2 history', async () => { + const table = buildTable(); + const group = [{ fieldId: table.getFields()[0]!.id().toString(), order: 'desc' as const }]; + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler(table, [ + { + name: 'capture', + supports: (kind) => kind === ViewOperationKind.update, + prepare, + guard: () => ok(undefined), + }, + ]); + const command = UpdateViewGroupCommand.create({ + tableId: table.id().toString(), + viewId: table.views()[0]!.id().toString(), + group, + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result.isOk()).toBe(true); + expect(setup.tableRepository.findOne).toHaveBeenCalledOnce(); + expect(setup.tableUpdateFlow.mutateSpec).toBeInstanceOf(TableUpdateViewQueryDefaultsSpec); + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'update', + payload: expect.objectContaining({ patch: { group } }), + }) + ); + expect(setup.undoRedo.capture).toHaveBeenCalledTimes(2); + expect(setup.undoRedo.appendUpdate).toHaveBeenCalledOnce(); + }); + + it('skips plugins, persistence, and history for an identical group', async () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const group = [{ fieldId: table.getFields()[0]!.id().toString(), order: 'asc' as const }]; + const current = table.updateViewGroup(viewId, group)._unsafeUnwrap().updateResult!.table; + current.pullDomainEvents(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler(current, [ + { name: 'capture', supports: () => true, prepare, guard: () => ok(undefined) }, + ]); + const command = UpdateViewGroupCommand.create({ + tableId: current.id().toString(), + viewId: viewId.toString(), + group, + })._unsafeUnwrap(); + + expect((await setup.handler.handle(context, command)).isOk()).toBe(true); + expect(setup.tableUpdateFlow.calls).toBe(0); + expect(prepare).not.toHaveBeenCalled(); + expect(setup.undoRedo.appendUpdate).not.toHaveBeenCalled(); + }); + + it('does not persist or append history when a guard rejects', async () => { + const table = buildTable(); + const setup = createHandler(table, [ + { + name: 'reject', + supports: () => true, + guard: () => err(domainError.forbidden({ message: 'rejected' })), + }, + ]); + const command = UpdateViewGroupCommand.create({ + tableId: table.id().toString(), + viewId: table.views()[0]!.id().toString(), + group: [], + })._unsafeUnwrap(); + + expect((await setup.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'forbidden' + ); + expect(setup.tableUpdateFlow.calls).toBe(0); + expect(setup.undoRedo.appendUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/v2/core/src/commands/UpdateViewGroupHandler.ts b/packages/v2/core/src/commands/UpdateViewGroupHandler.ts new file mode 100644 index 0000000000..144e28a15a --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewGroupHandler.ts @@ -0,0 +1,133 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry, type Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import type { DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { Table as TableAggregate, type Table } from '../domain/table/Table'; +import type { ViewGroupDTO } from '../domain/table/views/ViewGroup'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type { ViewQueryDefaults } from '../domain/table/views/ViewQueryDefaults'; +import type * as ExecutionContextPort from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { UpdateViewGroupCommand } from './UpdateViewGroupCommand'; + +export class UpdateViewGroupResult { + private constructor( + readonly table: Table, + readonly viewId: ViewId, + readonly previousGroup: ViewGroupDTO, + readonly nextGroup: ViewGroupDTO, + readonly previousQueryDefaults: ViewQueryDefaults, + readonly nextQueryDefaults: ViewQueryDefaults, + readonly events: ReadonlyArray + ) {} + + static create(params: { + table: Table; + viewId: ViewId; + previousGroup: ViewGroupDTO; + nextGroup: ViewGroupDTO; + previousQueryDefaults: ViewQueryDefaults; + nextQueryDefaults: ViewQueryDefaults; + events: ReadonlyArray; + }): UpdateViewGroupResult { + return new UpdateViewGroupResult( + params.table, + params.viewId, + params.previousGroup, + params.nextGroup, + params.previousQueryDefaults, + params.nextQueryDefaults, + [...params.events] + ); + } +} + +@CommandHandler(UpdateViewGroupCommand) +@injectable() +export class UpdateViewGroupHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow, + @inject(v2CoreTokens.viewOperationPluginRunner) + private readonly viewOperationPluginRunner: ViewOperationPluginRunner, + @inject(v2CoreTokens.viewUndoRedoService) + private readonly viewUndoRedoService: ViewUndoRedoService + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + command: UpdateViewGroupCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* TableAggregate.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const previousSnapshot = yield* handler.viewUndoRedoService.capture( + table, + command.viewId.toString() + ); + const groupResult = yield* table.updateViewGroup(command.viewId, command.group); + if (!groupResult.updateResult) { + return ok( + UpdateViewGroupResult.create({ + table, + viewId: command.viewId, + previousGroup: groupResult.previousGroup, + nextGroup: groupResult.nextGroup, + previousQueryDefaults: groupResult.previousQueryDefaults, + nextQueryDefaults: groupResult.nextQueryDefaults, + events: [], + }) + ); + } + + const pluginExecution = yield* await handler.viewOperationPluginRunner.prepare({ + kind: ViewOperationKind.update, + executionContext: context, + payload: { + tableId: table.id().toString(), + viewId: command.viewId.toString(), + patch: { group: groupResult.nextGroup }, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + + const update = yield* await handler.tableUpdateFlow.execute(context, { table }, () => + ok(groupResult.updateResult!) + ); + const nextSnapshot = yield* handler.viewUndoRedoService.capture( + update.table, + command.viewId.toString() + ); + yield* await handler.viewUndoRedoService.appendUpdate( + context, + update.table, + [previousSnapshot], + [nextSnapshot] + ); + return ok( + UpdateViewGroupResult.create({ + table: update.table, + viewId: command.viewId, + previousGroup: groupResult.previousGroup, + nextGroup: groupResult.nextGroup, + previousQueryDefaults: groupResult.previousQueryDefaults, + nextQueryDefaults: groupResult.nextQueryDefaults, + events: update.events, + }) + ); + }); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewLockedCommand.ts b/packages/v2/core/src/commands/UpdateViewLockedCommand.ts new file mode 100644 index 0000000000..43863051d9 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewLockedCommand.ts @@ -0,0 +1,44 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; +import { PublicCommand } from './PublicCommand'; + +export const updateViewLockedInputSchema = z.object({ + tableId: z.string(), + viewId: z.string(), + isLocked: z.boolean().optional(), +}); + +export type IUpdateViewLockedCommandInput = z.input; + +export class UpdateViewLockedCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId, + readonly isLocked: boolean | undefined + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = updateViewLockedInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid UpdateViewLockedCommand input', + details: z.formatError(parsed.error), + }) + ); + } + + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map( + (viewId) => new UpdateViewLockedCommand(tableId, viewId, parsed.data.isLocked) + ) + ); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewLockedHandler.spec.ts b/packages/v2/core/src/commands/UpdateViewLockedHandler.spec.ts new file mode 100644 index 0000000000..7f0ff70880 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewLockedHandler.spec.ts @@ -0,0 +1,180 @@ +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import type { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { TableUpdateViewLockedSpec } from '../domain/table/specs/TableUpdateViewLockedSpec'; +import { Table } from '../domain/table/Table'; +import type { TableUpdateResult } from '../domain/table/TableMutator'; +import { TableName } from '../domain/table/TableName'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { UpdateViewLockedCommand } from './UpdateViewLockedCommand'; +import { UpdateViewLockedHandler } from './UpdateViewLockedHandler'; + +const context: IExecutionContext = { + actorId: ActorId.create('actor')._unsafeUnwrap(), +}; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +class FakeTableUpdateFlow { + calls = 0; + mutateSpec?: TableUpdateResult['mutateSpec']; + + async execute( + _context: IExecutionContext, + target: { table: Table }, + mutate: (table: Table) => Result + ) { + this.calls += 1; + const result = mutate(target.table); + if (result.isErr()) return err(result.error); + this.mutateSpec = result.value.mutateSpec; + return ok({ table: result.value.table, events: [], postPersistEvents: [] }); + } +} + +const createHandler = (table: Table, operationPlugins: IViewOperationPlugin[] = []) => { + const tableRepository = { + findOne: vi.fn(async () => ok(table)), + } as unknown as ITableRepository; + const tableUpdateFlow = new FakeTableUpdateFlow(); + const pluginRunner = new ViewOperationPluginRunner(operationPlugins); + const undoRedo = { + capture: vi.fn(() => ok({ id: table.views()[0]!.id().toString() } as never)), + appendUpdate: vi.fn(async () => ok(undefined)), + } as unknown as ViewUndoRedoService; + const handler = new UpdateViewLockedHandler( + tableRepository, + tableUpdateFlow as unknown as TableUpdateFlow, + pluginRunner, + undoRedo + ); + return { handler, tableRepository, tableUpdateFlow }; +}; + +describe('UpdateViewLockedCommand', () => { + it('validates identifiers and accepts true, false, and omitted locked states', () => { + expect(UpdateViewLockedCommand.create({ tableId: 'bad', viewId: 'bad' }).isErr()).toBe(true); + const table = buildTable(); + const base = { tableId: table.id().toString(), viewId: table.views()[0]!.id().toString() }; + expect(UpdateViewLockedCommand.create({ ...base, isLocked: true }).isOk()).toBe(true); + expect(UpdateViewLockedCommand.create({ ...base, isLocked: false }).isOk()).toBe(true); + expect(UpdateViewLockedCommand.create(base).isOk()).toBe(true); + expect(UpdateViewLockedCommand.create({ ...base, isLocked: 'true' }).isErr()).toBe(true); + }); +}); + +describe('UpdateViewLockedHandler', () => { + it('orchestrates the Table aggregate, update guard, and locked-state spec', async () => { + const table = buildTable(); + const target = table.views()[0]!; + const seenOperations: unknown[] = []; + const operationPlugin: IViewOperationPlugin = { + name: 'capture', + supports: (kind) => kind === ViewOperationKind.update, + prepare: (pluginContext) => { + seenOperations.push(pluginContext); + return ok(undefined); + }, + guard: () => ok(undefined), + }; + const setup = createHandler(table, [operationPlugin]); + const command = UpdateViewLockedCommand.create({ + tableId: table.id().toString(), + viewId: target.id().toString(), + isLocked: true, + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().previousIsLocked).toBeUndefined(); + expect(result._unsafeUnwrap().nextIsLocked).toBe(true); + expect(result._unsafeUnwrap().table.getView(target.id())._unsafeUnwrap().isLocked()).toBe(true); + expect(setup.tableRepository.findOne).toHaveBeenCalledOnce(); + expect(setup.tableUpdateFlow.calls).toBe(1); + expect(setup.tableUpdateFlow.mutateSpec).toBeInstanceOf(TableUpdateViewLockedSpec); + expect(seenOperations).toEqual([ + expect.objectContaining({ + kind: 'update', + payload: { + tableId: table.id().toString(), + viewId: target.id().toString(), + patch: { isLocked: true }, + }, + }), + ]); + }); + + it('persists an omitted locked state as an aggregate-owned property removal', async () => { + const table = buildTable(); + const target = table.views()[0]!; + const locked = table.updateViewLocked(target.id(), true)._unsafeUnwrap().updateResult.table; + const setup = createHandler(locked); + const command = UpdateViewLockedCommand.create({ + tableId: locked.id().toString(), + viewId: target.id().toString(), + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result._unsafeUnwrap().previousIsLocked).toBe(true); + expect(result._unsafeUnwrap().nextIsLocked).toBeUndefined(); + expect( + result._unsafeUnwrap().table.getView(target.id())._unsafeUnwrap().isLocked() + ).toBeUndefined(); + expect(setup.tableUpdateFlow.calls).toBe(1); + }); + + it('does not persist when the update operation guard rejects the request', async () => { + const table = buildTable(); + const operationPlugin: IViewOperationPlugin = { + name: 'reject', + supports: (kind) => kind === ViewOperationKind.update, + guard: () => err(domainError.forbidden({ message: 'View update rejected' })), + }; + const setup = createHandler(table, [operationPlugin]); + const command = UpdateViewLockedCommand.create({ + tableId: table.id().toString(), + viewId: table.views()[0]!.id().toString(), + isLocked: true, + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result._unsafeUnwrapErr().code).toBe('forbidden'); + expect(setup.tableUpdateFlow.calls).toBe(0); + }); + + it('returns an aggregate error and skips persistence for a missing View', async () => { + const table = buildTable(); + const setup = createHandler(table); + const command = UpdateViewLockedCommand.create({ + tableId: table.id().toString(), + viewId: `viw${'z'.repeat(16)}`, + isLocked: true, + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result._unsafeUnwrapErr().code).toBe('view.not_found'); + expect(setup.tableUpdateFlow.calls).toBe(0); + }); +}); diff --git a/packages/v2/core/src/commands/UpdateViewLockedHandler.ts b/packages/v2/core/src/commands/UpdateViewLockedHandler.ts new file mode 100644 index 0000000000..c1014ae44e --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewLockedHandler.ts @@ -0,0 +1,112 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import type { DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { Table as TableAggregate, type Table } from '../domain/table/Table'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type * as ExecutionContextPort from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { UpdateViewLockedCommand } from './UpdateViewLockedCommand'; + +export class UpdateViewLockedResult { + private constructor( + readonly table: Table, + readonly viewId: ViewId, + readonly previousIsLocked: boolean | undefined, + readonly nextIsLocked: boolean | undefined, + readonly events: ReadonlyArray + ) {} + + static create(params: { + table: Table; + viewId: ViewId; + previousIsLocked: boolean | undefined; + nextIsLocked: boolean | undefined; + events: ReadonlyArray; + }): UpdateViewLockedResult { + return new UpdateViewLockedResult( + params.table, + params.viewId, + params.previousIsLocked, + params.nextIsLocked, + [...params.events] + ); + } +} + +@CommandHandler(UpdateViewLockedCommand) +@injectable() +export class UpdateViewLockedHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow, + @inject(v2CoreTokens.viewOperationPluginRunner) + private readonly viewOperationPluginRunner: ViewOperationPluginRunner, + @inject(v2CoreTokens.viewUndoRedoService) + private readonly viewUndoRedoService: ViewUndoRedoService + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + command: UpdateViewLockedCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* TableAggregate.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const previousSnapshot = yield* handler.viewUndoRedoService.capture( + table, + command.viewId.toString() + ); + const lockedResult = yield* table.updateViewLocked(command.viewId, command.isLocked); + + const pluginExecution = yield* await handler.viewOperationPluginRunner.prepare({ + kind: ViewOperationKind.update, + executionContext: context, + payload: { + tableId: table.id().toString(), + viewId: command.viewId.toString(), + patch: { isLocked: command.isLocked }, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + + const updateResult = yield* await handler.tableUpdateFlow.execute(context, { table }, () => + ok(lockedResult.updateResult) + ); + const nextSnapshot = yield* handler.viewUndoRedoService.capture( + updateResult.table, + command.viewId.toString() + ); + yield* await handler.viewUndoRedoService.appendUpdate( + context, + updateResult.table, + [previousSnapshot], + [nextSnapshot] + ); + + return ok( + UpdateViewLockedResult.create({ + table: updateResult.table, + viewId: command.viewId, + previousIsLocked: lockedResult.previousIsLocked, + nextIsLocked: lockedResult.nextIsLocked, + events: updateResult.events, + }) + ); + }); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewOptionsCommand.ts b/packages/v2/core/src/commands/UpdateViewOptionsCommand.ts new file mode 100644 index 0000000000..6893175b03 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewOptionsCommand.ts @@ -0,0 +1,40 @@ +import { err, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; +import { PublicCommand } from './PublicCommand'; + +export const updateViewOptionsInputSchema = z.object({ + tableId: z.string(), + viewId: z.string(), + options: z.record(z.string(), z.unknown()), +}); + +export class UpdateViewOptionsCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId, + readonly options: Readonly> + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = updateViewOptionsInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid UpdateViewOptionsCommand input', + details: z.formatError(parsed.error), + }) + ); + } + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map( + (viewId) => new UpdateViewOptionsCommand(tableId, viewId, parsed.data.options) + ) + ); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewOptionsHandler.spec.ts b/packages/v2/core/src/commands/UpdateViewOptionsHandler.spec.ts new file mode 100644 index 0000000000..399e9b75bc --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewOptionsHandler.spec.ts @@ -0,0 +1,162 @@ +import { err, ok, type Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import type { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { TableUpdateViewOptionsSpec } from '../domain/table/specs/TableUpdateViewOptionsSpec'; +import { Table } from '../domain/table/Table'; +import type { TableUpdateResult } from '../domain/table/TableMutator'; +import { TableName } from '../domain/table/TableName'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { UpdateViewOptionsCommand } from './UpdateViewOptionsCommand'; +import { UpdateViewOptionsHandler } from './UpdateViewOptionsHandler'; + +const context: IExecutionContext = { actorId: ActorId.create('actor')._unsafeUnwrap() }; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +class FakeTableUpdateFlow { + calls = 0; + mutateSpec?: TableUpdateResult['mutateSpec']; + + async execute( + _context: IExecutionContext, + target: { table: Table }, + mutate: (table: Table) => Result + ) { + this.calls += 1; + const result = mutate(target.table); + if (result.isErr()) return err(result.error); + this.mutateSpec = result.value.mutateSpec; + return ok({ + table: result.value.table, + events: result.value.table.pullDomainEvents(), + postPersistEvents: [], + }); + } +} + +const createHandler = (table: Table, plugins: IViewOperationPlugin[] = []) => { + const tableRepository = { findOne: vi.fn(async () => ok(table)) } as unknown as ITableRepository; + const tableUpdateFlow = new FakeTableUpdateFlow(); + const undoRedo = { + capture: vi.fn((_, viewId: string) => ok({ id: viewId } as never)), + appendUpdate: vi.fn(async () => ok(undefined)), + } as unknown as ViewUndoRedoService; + return { + handler: new UpdateViewOptionsHandler( + tableRepository, + tableUpdateFlow as unknown as TableUpdateFlow, + new ViewOperationPluginRunner(plugins), + undoRedo + ), + tableRepository, + tableUpdateFlow, + undoRedo, + }; +}; + +describe('UpdateViewOptionsCommand', () => { + it('validates identifiers and requires an options object', () => { + expect( + UpdateViewOptionsCommand.create({ tableId: 'bad', viewId: 'bad', options: {} }).isErr() + ).toBe(true); + const table = buildTable(); + const ids = { tableId: table.id().toString(), viewId: table.views()[0]!.id().toString() }; + expect(UpdateViewOptionsCommand.create({ ...ids, options: {} }).isOk()).toBe(true); + expect(UpdateViewOptionsCommand.create({ ...ids, options: null }).isErr()).toBe(true); + expect(UpdateViewOptionsCommand.create({ ...ids, options: [] }).isErr()).toBe(true); + }); +}); + +describe('UpdateViewOptionsHandler', () => { + it('orchestrates aggregate mutation, policy, persistence, and v2 history', async () => { + const table = buildTable(); + const options = { rowHeight: 'tall' }; + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler(table, [ + { + name: 'capture', + supports: (kind) => kind === ViewOperationKind.update, + prepare, + guard: () => ok(undefined), + }, + ]); + const command = UpdateViewOptionsCommand.create({ + tableId: table.id().toString(), + viewId: table.views()[0]!.id().toString(), + options, + })._unsafeUnwrap(); + + expect((await setup.handler.handle(context, command)).isOk()).toBe(true); + expect(setup.tableRepository.findOne).toHaveBeenCalledOnce(); + expect(setup.tableUpdateFlow.mutateSpec).toBeInstanceOf(TableUpdateViewOptionsSpec); + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'update', + payload: expect.objectContaining({ patch: { options } }), + }) + ); + expect(setup.undoRedo.capture).toHaveBeenCalledTimes(2); + expect(setup.undoRedo.appendUpdate).toHaveBeenCalledOnce(); + }); + + it('skips policy, persistence, and history for an identical patch', async () => { + const source = buildTable(); + const viewId = source.views()[0]!.id(); + const options = { rowHeight: 'tall' }; + const current = source.updateViewOptions(viewId, options)._unsafeUnwrap().updateResult!.table; + current.pullDomainEvents(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler(current, [ + { name: 'capture', supports: () => true, prepare, guard: () => ok(undefined) }, + ]); + const command = UpdateViewOptionsCommand.create({ + tableId: current.id().toString(), + viewId: viewId.toString(), + options, + })._unsafeUnwrap(); + + expect((await setup.handler.handle(context, command)).isOk()).toBe(true); + expect(setup.tableUpdateFlow.calls).toBe(0); + expect(prepare).not.toHaveBeenCalled(); + expect(setup.undoRedo.appendUpdate).not.toHaveBeenCalled(); + }); + + it('does not persist or append history when a guard rejects', async () => { + const table = buildTable(); + const setup = createHandler(table, [ + { + name: 'reject', + supports: () => true, + guard: () => err(domainError.forbidden({ message: 'rejected' })), + }, + ]); + const command = UpdateViewOptionsCommand.create({ + tableId: table.id().toString(), + viewId: table.views()[0]!.id().toString(), + options: { rowHeight: 'tall' }, + })._unsafeUnwrap(); + + expect((await setup.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'forbidden' + ); + expect(setup.tableUpdateFlow.calls).toBe(0); + expect(setup.undoRedo.appendUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/v2/core/src/commands/UpdateViewOptionsHandler.ts b/packages/v2/core/src/commands/UpdateViewOptionsHandler.ts new file mode 100644 index 0000000000..206a1c18d6 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewOptionsHandler.ts @@ -0,0 +1,121 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry, type Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import type { DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { Table as TableAggregate, type Table } from '../domain/table/Table'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type * as ExecutionContextPort from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { UpdateViewOptionsCommand } from './UpdateViewOptionsCommand'; + +export class UpdateViewOptionsResult { + private constructor( + readonly table: Table, + readonly viewId: ViewId, + readonly previousOptions: unknown, + readonly nextOptions: unknown, + readonly events: ReadonlyArray + ) {} + + static create(params: { + table: Table; + viewId: ViewId; + previousOptions: unknown; + nextOptions: unknown; + events: ReadonlyArray; + }): UpdateViewOptionsResult { + return new UpdateViewOptionsResult( + params.table, + params.viewId, + params.previousOptions, + params.nextOptions, + [...params.events] + ); + } +} + +@CommandHandler(UpdateViewOptionsCommand) +@injectable() +export class UpdateViewOptionsHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow, + @inject(v2CoreTokens.viewOperationPluginRunner) + private readonly viewOperationPluginRunner: ViewOperationPluginRunner, + @inject(v2CoreTokens.viewUndoRedoService) + private readonly viewUndoRedoService: ViewUndoRedoService + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + command: UpdateViewOptionsCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* TableAggregate.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const previousSnapshot = yield* handler.viewUndoRedoService.capture( + table, + command.viewId.toString() + ); + const optionsResult = yield* table.updateViewOptions(command.viewId, command.options); + if (!optionsResult.updateResult) { + return ok( + UpdateViewOptionsResult.create({ + table, + viewId: command.viewId, + previousOptions: optionsResult.previousOptions, + nextOptions: optionsResult.nextOptions, + events: [], + }) + ); + } + + const pluginExecution = yield* await handler.viewOperationPluginRunner.prepare({ + kind: ViewOperationKind.update, + executionContext: context, + payload: { + tableId: table.id().toString(), + viewId: command.viewId.toString(), + patch: { options: optionsResult.nextOptions }, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + + const update = yield* await handler.tableUpdateFlow.execute(context, { table }, () => + ok(optionsResult.updateResult!) + ); + const nextSnapshot = yield* handler.viewUndoRedoService.capture( + update.table, + command.viewId.toString() + ); + yield* await handler.viewUndoRedoService.appendUpdate( + context, + update.table, + [previousSnapshot], + [nextSnapshot] + ); + return ok( + UpdateViewOptionsResult.create({ + table: update.table, + viewId: command.viewId, + previousOptions: optionsResult.previousOptions, + nextOptions: optionsResult.nextOptions, + events: update.events, + }) + ); + }); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewOrderCommand.ts b/packages/v2/core/src/commands/UpdateViewOrderCommand.ts new file mode 100644 index 0000000000..dd71d43972 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewOrderCommand.ts @@ -0,0 +1,45 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; +import { PublicCommand } from './PublicCommand'; + +export const updateViewOrderInputSchema = z.object({ + tableId: z.string(), + viewId: z.string(), + anchorId: z.string(), + position: z.enum(['before', 'after']), +}); + +export class UpdateViewOrderCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId, + readonly anchorId: ViewId, + readonly position: 'before' | 'after' + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = updateViewOrderInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid UpdateViewOrderCommand input', + details: z.formatError(parsed.error), + }) + ); + } + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).andThen((viewId) => + ViewId.create(parsed.data.anchorId).map( + (anchorId) => new UpdateViewOrderCommand(tableId, viewId, anchorId, parsed.data.position) + ) + ) + ); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewOrderHandler.spec.ts b/packages/v2/core/src/commands/UpdateViewOrderHandler.spec.ts new file mode 100644 index 0000000000..7c645b1fb9 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewOrderHandler.spec.ts @@ -0,0 +1,241 @@ +import { err, ok, type Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import type { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { ViewOrderUpdated } from '../domain/table/events/ViewOrderUpdated'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { TableUpdateViewOrderSpec } from '../domain/table/specs/TableUpdateViewOrderSpec'; +import { Table } from '../domain/table/Table'; +import type { TableUpdateResult } from '../domain/table/TableMutator'; +import { TableName } from '../domain/table/TableName'; +import { ViewOrder } from '../domain/table/views/ViewOrder'; +import { captureViewSnapshot } from '../domain/table/views/ViewSnapshot'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { UpdateViewOrderCommand } from './UpdateViewOrderCommand'; +import { UpdateViewOrderHandler } from './UpdateViewOrderHandler'; + +const context: IExecutionContext = { actorId: ActorId.create('actor')._unsafeUnwrap() }; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'f'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('View order')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + let table = builder.build()._unsafeUnwrap(); + table = table.createView({ type: 'grid', name: 'Second' })._unsafeUnwrap().updateResult.table; + table = table.createView({ type: 'grid', name: 'Third' })._unsafeUnwrap().updateResult.table; + table.pullDomainEvents(); + table.views().forEach((view, index) => { + view.setOrder(ViewOrder.rehydrate(index)._unsafeUnwrap())._unsafeUnwrap(); + }); + return table; +}; + +class FakeTableUpdateFlow { + calls = 0; + mutateSpec?: TableUpdateResult['mutateSpec']; + + async execute( + _context: IExecutionContext, + target: { table: Table }, + mutate: (table: Table) => Result + ) { + this.calls += 1; + const result = mutate(target.table); + if (result.isErr()) return err(result.error); + this.mutateSpec = result.value.mutateSpec; + return ok({ + table: result.value.table, + events: result.value.table.pullDomainEvents(), + postPersistEvents: [], + }); + } +} + +const createHandler = (params: { + tableResult: Result; + plugins?: IViewOperationPlugin[]; + undoFailure?: DomainError; +}) => { + const repository = { + findOne: vi.fn(async () => params.tableResult), + } as unknown as ITableRepository; + const flow = new FakeTableUpdateFlow(); + const captureAll = vi.fn((table: Table) => + ok(table.views().map((view) => captureViewSnapshot(view)._unsafeUnwrap())) + ); + const appendUpdate = vi.fn(async () => + params.undoFailure ? err(params.undoFailure) : ok(undefined) + ); + const handler = new UpdateViewOrderHandler( + repository, + flow as unknown as TableUpdateFlow, + new ViewOperationPluginRunner(params.plugins), + { captureAll, appendUpdate } as unknown as ViewUndoRedoService + ); + return { handler, repository, flow, captureAll, appendUpdate }; +}; + +describe('UpdateViewOrderCommand', () => { + it('validates Table/View IDs and before/after positions', () => { + const table = buildTable(); + const [source, anchor] = table.views(); + expect( + UpdateViewOrderCommand.create({ + tableId: table.id().toString(), + viewId: source!.id().toString(), + anchorId: anchor!.id().toString(), + position: 'before', + }).isOk() + ).toBe(true); + expect( + UpdateViewOrderCommand.create({ + tableId: table.id().toString(), + viewId: source!.id().toString(), + anchorId: anchor!.id().toString(), + position: 'middle', + }).isErr() + ).toBe(true); + }); +}); + +describe('UpdateViewOrderHandler', () => { + it('reorders through Table behavior, plugin policy, persistence, events, and aggregate snapshots', async () => { + const table = buildTable(); + const [, anchor, source] = table.views(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler({ + tableResult: ok(table), + plugins: [ + { + name: 'capture', + supports: (kind) => kind === ViewOperationKind.update, + prepare, + guard: () => ok(undefined), + }, + ], + }); + const command = UpdateViewOrderCommand.create({ + tableId: table.id().toString(), + viewId: source!.id().toString(), + anchorId: anchor!.id().toString(), + position: 'before', + })._unsafeUnwrap(); + + const result = (await setup.handler.handle(context, command))._unsafeUnwrap(); + + expect(setup.repository.findOne).toHaveBeenCalledOnce(); + expect(setup.flow.mutateSpec).toBeInstanceOf(TableUpdateViewOrderSpec); + expect(result.previousOrder.toNumber()).toBe(2); + expect(result.nextOrder.toNumber()).toBe(0.5); + expect(result.changes).toHaveLength(1); + expect(result.events.some((event) => event instanceof ViewOrderUpdated)).toBe(true); + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: ViewOperationKind.update, + payload: { + tableId: table.id().toString(), + viewId: source!.id().toString(), + patch: { order: 0.5 }, + }, + }) + ); + expect(setup.captureAll).toHaveBeenCalledTimes(2); + expect(setup.appendUpdate).toHaveBeenCalledWith( + context, + result.table, + expect.arrayContaining([expect.objectContaining({ id: source!.id().toString() })]), + expect.arrayContaining([expect.objectContaining({ id: source!.id().toString() })]) + ); + }); + + it('returns a missing-anchor error before plugins, persistence, or next snapshots', async () => { + const table = buildTable(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler({ + tableResult: ok(table), + plugins: [{ name: 'capture', supports: () => true, prepare }], + }); + const command = UpdateViewOrderCommand.create({ + tableId: table.id().toString(), + viewId: table.views()[0]!.id().toString(), + anchorId: `viw${'z'.repeat(16)}`, + position: 'after', + })._unsafeUnwrap(); + + expect((await setup.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'view.anchor_not_found' + ); + expect(prepare).not.toHaveBeenCalled(); + expect(setup.flow.calls).toBe(0); + expect(setup.captureAll).toHaveBeenCalledOnce(); + expect(setup.appendUpdate).not.toHaveBeenCalled(); + }); + + it('does not persist or append undo when plugin policy rejects the reorder', async () => { + const table = buildTable(); + const [source, anchor] = table.views(); + const setup = createHandler({ + tableResult: ok(table), + plugins: [ + { + name: 'reject', + supports: () => true, + guard: () => err(domainError.forbidden({ message: 'Reorder rejected' })), + }, + ], + }); + const command = UpdateViewOrderCommand.create({ + tableId: table.id().toString(), + viewId: source!.id().toString(), + anchorId: anchor!.id().toString(), + position: 'after', + })._unsafeUnwrap(); + + expect((await setup.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'forbidden' + ); + expect(setup.flow.calls).toBe(0); + expect(setup.captureAll).toHaveBeenCalledOnce(); + expect(setup.appendUpdate).not.toHaveBeenCalled(); + }); + + it('propagates repository and undo failures at their orchestration boundaries', async () => { + const table = buildTable(); + const [source, anchor] = table.views(); + const command = UpdateViewOrderCommand.create({ + tableId: table.id().toString(), + viewId: source!.id().toString(), + anchorId: anchor!.id().toString(), + position: 'after', + })._unsafeUnwrap(); + const missing = createHandler({ + tableResult: err(domainError.notFound({ message: 'Missing Table' })), + }); + expect((await missing.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'not_found' + ); + expect(missing.captureAll).not.toHaveBeenCalled(); + expect(missing.flow.calls).toBe(0); + + const undoRejected = createHandler({ + tableResult: ok(table), + undoFailure: domainError.unexpected({ message: 'Undo store unavailable' }), + }); + expect((await undoRejected.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'unexpected' + ); + expect(undoRejected.flow.calls).toBe(1); + expect(undoRejected.captureAll).toHaveBeenCalledTimes(2); + expect(undoRejected.appendUpdate).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/v2/core/src/commands/UpdateViewOrderHandler.ts b/packages/v2/core/src/commands/UpdateViewOrderHandler.ts new file mode 100644 index 0000000000..1485c34e0b --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewOrderHandler.ts @@ -0,0 +1,116 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import type { DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import type { TableViewOrderChange } from '../domain/table/specs/TableUpdateViewOrderSpec'; +import { Table as TableAggregate, type Table } from '../domain/table/Table'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type { ViewOrder } from '../domain/table/views/ViewOrder'; +import type * as ExecutionContextPort from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { UpdateViewOrderCommand } from './UpdateViewOrderCommand'; + +export class UpdateViewOrderResult { + private constructor( + readonly table: Table, + readonly viewId: ViewId, + readonly previousOrder: ViewOrder, + readonly nextOrder: ViewOrder, + readonly changes: ReadonlyArray, + readonly events: ReadonlyArray + ) {} + + static create(params: { + table: Table; + viewId: ViewId; + previousOrder: ViewOrder; + nextOrder: ViewOrder; + changes: ReadonlyArray; + events: ReadonlyArray; + }): UpdateViewOrderResult { + return new UpdateViewOrderResult( + params.table, + params.viewId, + params.previousOrder, + params.nextOrder, + [...params.changes], + [...params.events] + ); + } +} + +@CommandHandler(UpdateViewOrderCommand) +@injectable() +export class UpdateViewOrderHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow, + @inject(v2CoreTokens.viewOperationPluginRunner) + private readonly viewOperationPluginRunner: ViewOperationPluginRunner, + @inject(v2CoreTokens.viewUndoRedoService) + private readonly viewUndoRedoService: ViewUndoRedoService + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + command: UpdateViewOrderCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* TableAggregate.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const previousSnapshots = yield* handler.viewUndoRedoService.captureAll(table); + const reorder = yield* table.updateViewOrder( + command.viewId, + command.anchorId, + command.position + ); + + const pluginExecution = yield* await handler.viewOperationPluginRunner.prepare({ + kind: ViewOperationKind.update, + executionContext: context, + payload: { + tableId: table.id().toString(), + viewId: command.viewId.toString(), + patch: { order: reorder.nextOrder.toNumber() }, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + + const update = yield* await handler.tableUpdateFlow.execute(context, { table }, () => + ok(reorder.updateResult) + ); + const nextSnapshots = yield* handler.viewUndoRedoService.captureAll(update.table); + yield* await handler.viewUndoRedoService.appendUpdate( + context, + update.table, + previousSnapshots, + nextSnapshots + ); + + return ok( + UpdateViewOrderResult.create({ + table: update.table, + viewId: command.viewId, + previousOrder: reorder.previousOrder, + nextOrder: reorder.nextOrder, + changes: reorder.changes, + events: update.events, + }) + ); + }); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewPluginStorageCommand.ts b/packages/v2/core/src/commands/UpdateViewPluginStorageCommand.ts new file mode 100644 index 0000000000..173a33c661 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewPluginStorageCommand.ts @@ -0,0 +1,54 @@ +import { err, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; +import { PublicCommand } from './PublicCommand'; + +export const updateViewPluginStorageInputSchema = z + .object({ + tableId: z.string(), + viewId: z.string(), + pluginInstallId: z.string().min(1), + storage: z.record(z.string(), z.unknown()).optional(), + }) + .strict(); + +export type IUpdateViewPluginStorageCommandInput = z.input< + typeof updateViewPluginStorageInputSchema +>; + +export class UpdateViewPluginStorageCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId, + readonly pluginInstallId: string, + readonly storage: Readonly> | undefined + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = updateViewPluginStorageInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid UpdateViewPluginStorageCommand input', + details: z.formatError(parsed.error), + }) + ); + } + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map( + (viewId) => + new UpdateViewPluginStorageCommand( + tableId, + viewId, + parsed.data.pluginInstallId, + parsed.data.storage + ) + ) + ); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewPluginStorageHandler.spec.ts b/packages/v2/core/src/commands/UpdateViewPluginStorageHandler.spec.ts new file mode 100644 index 0000000000..c3a301db8f --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewPluginStorageHandler.spec.ts @@ -0,0 +1,239 @@ +import { err, ok, type Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { Table } from '../domain/table/Table'; +import { TableName } from '../domain/table/TableName'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IUnitOfWork } from '../ports/UnitOfWork'; +import type { IViewPluginRepository } from '../ports/ViewPluginRepository'; +import { UpdateViewPluginStorageCommand } from './UpdateViewPluginStorageCommand'; +import { UpdateViewPluginStorageHandler } from './UpdateViewPluginStorageHandler'; + +const context: IExecutionContext = { actorId: ActorId.create('actor')._unsafeUnwrap() }; +const transactionContext: IExecutionContext = { + actorId: ActorId.create('transaction')._unsafeUnwrap(), +}; + +const buildTable = () => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'g'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Plugin storage')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + const created = builder + .build() + ._unsafeUnwrap() + .createView({ + type: 'plugin', + name: 'Sheet', + options: { + pluginId: 'plg-sheet', + pluginInstallId: 'pli-sheet', + pluginLogo: 'logo.svg', + }, + }) + ._unsafeUnwrap(); + const table = created.updateResult.table; + table.pullDomainEvents(); + return { table, viewId: created.view.id() }; +}; + +const buildPluginRepository = (): IViewPluginRepository => ({ + findViewPlugin: vi.fn(async () => err(domainError.notFound({ message: 'Not used' }))), + insertViewPluginInstallation: vi.fn(async () => + err(domainError.notFound({ message: 'Not used' })) + ), + findViewPluginInstallationByViewId: vi.fn(async () => + err(domainError.notFound({ message: 'Not used' })) + ), + getViewPluginInstallation: vi.fn(async () => err(domainError.notFound({ message: 'Not used' }))), + updateViewPluginStorage: vi.fn(async () => ok(undefined)), +}); + +class FakeUnitOfWork implements IUnitOfWork { + calls = 0; + options?: { scope?: 'meta' | 'data' }; + + constructor(private readonly failure?: DomainError) {} + + async withTransaction( + _context: IExecutionContext, + work: (context: IExecutionContext) => Promise>, + options?: { scope?: 'meta' | 'data' } + ): Promise> { + this.calls += 1; + this.options = options; + if (this.failure) return err(this.failure); + return work(transactionContext); + } +} + +const createHandler = (params: { + tableResult: Result; + pluginRepository?: IViewPluginRepository; + transactionFailure?: DomainError; +}) => { + const tableRepository = { + findOne: vi.fn(async () => params.tableResult), + } as unknown as ITableRepository; + const pluginRepository = params.pluginRepository ?? buildPluginRepository(); + const unitOfWork = new FakeUnitOfWork(params.transactionFailure); + const handler = new UpdateViewPluginStorageHandler(tableRepository, pluginRepository, unitOfWork); + return { handler, tableRepository, pluginRepository, unitOfWork }; +}; + +describe('UpdateViewPluginStorageCommand', () => { + it('validates identifiers, installation ID, and record-shaped storage', () => { + const fixture = buildTable(); + expect( + UpdateViewPluginStorageCommand.create({ + tableId: fixture.table.id().toString(), + viewId: fixture.viewId.toString(), + pluginInstallId: 'pli-sheet', + storage: { valid: true }, + }).isOk() + ).toBe(true); + expect( + UpdateViewPluginStorageCommand.create({ + tableId: fixture.table.id().toString(), + viewId: fixture.viewId.toString(), + pluginInstallId: '', + }).isErr() + ).toBe(true); + expect( + UpdateViewPluginStorageCommand.create({ + tableId: fixture.table.id().toString(), + viewId: fixture.viewId.toString(), + pluginInstallId: 'pli-sheet', + storage: [], + }).isErr() + ).toBe(true); + }); +}); + +describe('UpdateViewPluginStorageHandler', () => { + it('locks the Table aggregate and updates the independent installation in one meta transaction', async () => { + const fixture = buildTable(); + const pluginRepository = buildPluginRepository(); + const setup = createHandler({ + tableResult: ok(fixture.table), + pluginRepository, + }); + const storage = { nested: { values: [1, true, 'value'] } }; + const command = UpdateViewPluginStorageCommand.create({ + tableId: fixture.table.id().toString(), + viewId: fixture.viewId.toString(), + pluginInstallId: 'pli-sheet', + storage, + })._unsafeUnwrap(); + + const result = (await setup.handler.handle(context, command))._unsafeUnwrap(); + + expect(result).toMatchObject({ + tableId: fixture.table.id().toString(), + viewId: fixture.viewId.toString(), + pluginInstallId: 'pli-sheet', + storage, + }); + expect(setup.unitOfWork.calls).toBe(1); + expect(setup.unitOfWork.options).toEqual({ scope: 'meta' }); + expect(setup.tableRepository.findOne).toHaveBeenCalledWith( + transactionContext, + expect.anything(), + { lock: 'forUpdate' } + ); + expect(pluginRepository.updateViewPluginStorage).toHaveBeenCalledWith(transactionContext, { + baseId: fixture.table.baseId().toString(), + viewId: fixture.viewId.toString(), + pluginInstallId: 'pli-sheet', + storage, + }); + }); + + it('maps a repository not-found to view.not_found before touching PluginInstallation', async () => { + const fixture = buildTable(); + const setup = createHandler({ + tableResult: err(domainError.notFound({ message: 'Filtered Table/View not found' })), + }); + const command = UpdateViewPluginStorageCommand.create({ + tableId: fixture.table.id().toString(), + viewId: fixture.viewId.toString(), + pluginInstallId: 'pli-sheet', + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result._unsafeUnwrapErr().code).toBe('view.not_found'); + expect(setup.pluginRepository.updateViewPluginStorage).not.toHaveBeenCalled(); + }); + + it('rechecks aggregate membership when a stale repository result omits the requested View', async () => { + const fixture = buildTable(); + const missingViewId = `viw${'z'.repeat(16)}`; + const setup = createHandler({ tableResult: ok(fixture.table) }); + const command = UpdateViewPluginStorageCommand.create({ + tableId: fixture.table.id().toString(), + viewId: missingViewId, + pluginInstallId: 'pli-sheet', + storage: { rejected: true }, + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result._unsafeUnwrapErr().code).toBe('view.not_found'); + expect(setup.pluginRepository.updateViewPluginStorage).not.toHaveBeenCalled(); + }); + + it('propagates non-not-found repository and PluginInstallation failures', async () => { + const fixture = buildTable(); + const repositoryFailure = createHandler({ + tableResult: err(domainError.unexpected({ message: 'Database unavailable' })), + }); + const command = UpdateViewPluginStorageCommand.create({ + tableId: fixture.table.id().toString(), + viewId: fixture.viewId.toString(), + pluginInstallId: 'pli-sheet', + storage: { value: 1 }, + })._unsafeUnwrap(); + + expect((await repositoryFailure.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'unexpected' + ); + + const pluginRepository = buildPluginRepository(); + vi.mocked(pluginRepository.updateViewPluginStorage).mockResolvedValue( + err(domainError.notFound({ message: 'Installation mismatch' })) + ); + const installationFailure = createHandler({ + tableResult: ok(fixture.table), + pluginRepository, + }); + expect( + (await installationFailure.handler.handle(context, command))._unsafeUnwrapErr().code + ).toBe('not_found'); + }); + + it('does not query the aggregate when the meta transaction cannot start', async () => { + const fixture = buildTable(); + const setup = createHandler({ + tableResult: ok(fixture.table), + transactionFailure: domainError.unexpected({ message: 'Transaction unavailable' }), + }); + const command = UpdateViewPluginStorageCommand.create({ + tableId: fixture.table.id().toString(), + viewId: fixture.viewId.toString(), + pluginInstallId: 'pli-sheet', + })._unsafeUnwrap(); + + expect((await setup.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'unexpected' + ); + expect(setup.tableRepository.findOne).not.toHaveBeenCalled(); + expect(setup.pluginRepository.updateViewPluginStorage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/v2/core/src/commands/UpdateViewPluginStorageHandler.ts b/packages/v2/core/src/commands/UpdateViewPluginStorageHandler.ts new file mode 100644 index 0000000000..ac35edc762 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewPluginStorageHandler.ts @@ -0,0 +1,99 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok, type Result } from 'neverthrow'; + +import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError'; +import { Table } from '../domain/table/Table'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import * as UnitOfWorkPort from '../ports/UnitOfWork'; +import * as ViewPluginRepositoryPort from '../ports/ViewPluginRepository'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { UpdateViewPluginStorageCommand } from './UpdateViewPluginStorageCommand'; + +export class UpdateViewPluginStorageResult { + private constructor( + readonly tableId: string, + readonly viewId: string, + readonly pluginInstallId: string, + readonly storage: Readonly> | undefined + ) {} + + static create(command: UpdateViewPluginStorageCommand): UpdateViewPluginStorageResult { + return new UpdateViewPluginStorageResult( + command.tableId.toString(), + command.viewId.toString(), + command.pluginInstallId, + command.storage + ); + } +} + +@CommandHandler(UpdateViewPluginStorageCommand) +@injectable() +export class UpdateViewPluginStorageHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.viewPluginRepository) + private readonly viewPluginRepository: ViewPluginRepositoryPort.IViewPluginRepository, + @inject(v2CoreTokens.unitOfWork) + private readonly unitOfWork: UnitOfWorkPort.IUnitOfWork + ) {} + + async handle( + context: IExecutionContext, + command: UpdateViewPluginStorageCommand + ): Promise> { + const handler = this; + return this.unitOfWork.withTransaction( + context, + async (transactionContext) => { + const specResult = Table.specs().byId(command.tableId).withViewId(command.viewId).build(); + if (specResult.isErr()) return err(specResult.error); + + const tableResult = await handler.tableRepository.findOne( + transactionContext, + specResult.value, + { lock: 'forUpdate' } + ); + if (tableResult.isErr()) { + if (isNotFoundError(tableResult.error)) { + return err( + domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${command.viewId.toString()}`, + }) + ); + } + return err(tableResult.error); + } + + const viewResult = tableResult.value.getView(command.viewId); + if (viewResult.isErr()) { + return err( + domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${command.viewId.toString()}`, + }) + ); + } + + const updateResult = await handler.viewPluginRepository.updateViewPluginStorage( + transactionContext, + { + baseId: tableResult.value.baseId().toString(), + viewId: viewResult.value.id().toString(), + pluginInstallId: command.pluginInstallId, + storage: command.storage, + } + ); + if (updateResult.isErr()) return err(updateResult.error); + return ok(UpdateViewPluginStorageResult.create(command)); + }, + { scope: 'meta' } + ); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewShareMetaCommand.ts b/packages/v2/core/src/commands/UpdateViewShareMetaCommand.ts new file mode 100644 index 0000000000..3e28efe5e2 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewShareMetaCommand.ts @@ -0,0 +1,52 @@ +import { err, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; +import type { ViewShareMetaValue } from '../domain/table/views/ViewProperties'; +import { PublicCommand } from './PublicCommand'; + +export const updateViewShareMetaInputSchema = z + .object({ + tableId: z.string(), + viewId: z.string(), + shareMeta: z + .object({ + allowCopy: z.boolean().optional(), + includeHiddenField: z.boolean().optional(), + password: z.string().min(3).optional(), + includeRecords: z.boolean().optional(), + submit: z.object({ requireLogin: z.boolean().optional() }).strict().optional(), + allowEdit: z.boolean().optional(), + }) + .strict(), + }) + .strict(); + +export class UpdateViewShareMetaCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId, + readonly shareMeta: ViewShareMetaValue + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = updateViewShareMetaInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid UpdateViewShareMetaCommand input', + details: z.formatError(parsed.error), + }) + ); + } + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map( + (viewId) => new UpdateViewShareMetaCommand(tableId, viewId, parsed.data.shareMeta) + ) + ); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewShareMetaHandler.spec.ts b/packages/v2/core/src/commands/UpdateViewShareMetaHandler.spec.ts new file mode 100644 index 0000000000..eb43eca021 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewShareMetaHandler.spec.ts @@ -0,0 +1,158 @@ +import { err, ok, type Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import type { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { TableUpdateViewShareMetaSpec } from '../domain/table/specs/TableUpdateViewShareMetaSpec'; +import { Table } from '../domain/table/Table'; +import type { TableUpdateResult } from '../domain/table/TableMutator'; +import { TableName } from '../domain/table/TableName'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { UpdateViewShareMetaCommand } from './UpdateViewShareMetaCommand'; +import { UpdateViewShareMetaHandler } from './UpdateViewShareMetaHandler'; + +const context: IExecutionContext = { actorId: ActorId.create('actor')._unsafeUnwrap() }; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Shared Views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +class FakeTableUpdateFlow { + calls = 0; + mutateSpec?: TableUpdateResult['mutateSpec']; + + async execute( + _context: IExecutionContext, + target: { table: Table }, + mutate: (table: Table) => Result + ) { + this.calls += 1; + const result = mutate(target.table); + if (result.isErr()) return err(result.error); + this.mutateSpec = result.value.mutateSpec; + return ok({ + table: result.value.table, + events: result.value.table.pullDomainEvents(), + postPersistEvents: [], + }); + } +} + +const createHandler = (table: Table, plugins: IViewOperationPlugin[] = []) => { + const repository = { findOne: vi.fn(async () => ok(table)) } as unknown as ITableRepository; + const flow = new FakeTableUpdateFlow(); + const history = { + capture: vi.fn((_, viewId: string) => ok({ id: viewId } as never)), + appendUpdate: vi.fn(async () => ok(undefined)), + } as unknown as ViewUndoRedoService; + return { + handler: new UpdateViewShareMetaHandler( + repository, + flow as unknown as TableUpdateFlow, + new ViewOperationPluginRunner(plugins), + history + ), + repository, + flow, + history, + }; +}; + +describe('UpdateViewShareMetaCommand', () => { + it('validates ids and strict metadata values', () => { + const table = buildTable(); + const ids = { tableId: table.id().toString(), viewId: table.views()[0]!.id().toString() }; + expect(UpdateViewShareMetaCommand.create({ ...ids, shareMeta: {} }).isOk()).toBe(true); + expect( + UpdateViewShareMetaCommand.create({ ...ids, shareMeta: { password: 'ab' } }).isErr() + ).toBe(true); + expect( + UpdateViewShareMetaCommand.create({ ...ids, shareMeta: { unknown: true } }).isErr() + ).toBe(true); + }); +}); + +describe('UpdateViewShareMetaHandler', () => { + it('orchestrates aggregate mutation, plugin policy, persistence, and v2 history', async () => { + const table = buildTable(); + const shareMeta = { allowCopy: true, submit: { requireLogin: true } }; + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler(table, [ + { + name: 'capture', + supports: (kind) => kind === ViewOperationKind.update, + prepare, + guard: () => ok(undefined), + }, + ]); + const command = UpdateViewShareMetaCommand.create({ + tableId: table.id().toString(), + viewId: table.views()[0]!.id().toString(), + shareMeta, + })._unsafeUnwrap(); + + expect((await setup.handler.handle(context, command)).isOk()).toBe(true); + expect(setup.repository.findOne).toHaveBeenCalledOnce(); + expect(setup.flow.mutateSpec).toBeInstanceOf(TableUpdateViewShareMetaSpec); + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'update', + payload: expect.objectContaining({ patch: { shareMeta } }), + }) + ); + expect(setup.history.capture).toHaveBeenCalledTimes(2); + expect(setup.history.appendUpdate).toHaveBeenCalledOnce(); + }); + + it('skips persistence and history for an identical replacement', async () => { + const source = buildTable(); + const viewId = source.views()[0]!.id(); + const current = source.updateViewShareMeta(viewId, {})._unsafeUnwrap().updateResult!.table; + current.pullDomainEvents(); + const setup = createHandler(current); + const command = UpdateViewShareMetaCommand.create({ + tableId: current.id().toString(), + viewId: viewId.toString(), + shareMeta: {}, + })._unsafeUnwrap(); + + expect((await setup.handler.handle(context, command)).isOk()).toBe(true); + expect(setup.flow.calls).toBe(0); + expect(setup.history.appendUpdate).not.toHaveBeenCalled(); + }); + + it('does not persist when plugin policy rejects the update', async () => { + const table = buildTable(); + const setup = createHandler(table, [ + { + name: 'reject', + supports: () => true, + guard: () => err(domainError.forbidden({ message: 'rejected' })), + }, + ]); + const command = UpdateViewShareMetaCommand.create({ + tableId: table.id().toString(), + viewId: table.views()[0]!.id().toString(), + shareMeta: { allowEdit: true }, + })._unsafeUnwrap(); + + expect((await setup.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'forbidden' + ); + expect(setup.flow.calls).toBe(0); + expect(setup.history.appendUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/v2/core/src/commands/UpdateViewShareMetaHandler.ts b/packages/v2/core/src/commands/UpdateViewShareMetaHandler.ts new file mode 100644 index 0000000000..563ab5eda5 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewShareMetaHandler.ts @@ -0,0 +1,122 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry, type Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import type { DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { Table as TableAggregate, type Table } from '../domain/table/Table'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type { ViewShareMetaValue } from '../domain/table/views/ViewProperties'; +import type * as ExecutionContextPort from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { UpdateViewShareMetaCommand } from './UpdateViewShareMetaCommand'; + +export class UpdateViewShareMetaResult { + private constructor( + readonly table: Table, + readonly viewId: ViewId, + readonly previousShareMeta: ViewShareMetaValue | undefined, + readonly nextShareMeta: ViewShareMetaValue | undefined, + readonly events: ReadonlyArray + ) {} + + static create(params: { + table: Table; + viewId: ViewId; + previousShareMeta: ViewShareMetaValue | undefined; + nextShareMeta: ViewShareMetaValue | undefined; + events: ReadonlyArray; + }): UpdateViewShareMetaResult { + return new UpdateViewShareMetaResult( + params.table, + params.viewId, + params.previousShareMeta, + params.nextShareMeta, + [...params.events] + ); + } +} + +@CommandHandler(UpdateViewShareMetaCommand) +@injectable() +export class UpdateViewShareMetaHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow, + @inject(v2CoreTokens.viewOperationPluginRunner) + private readonly viewOperationPluginRunner: ViewOperationPluginRunner, + @inject(v2CoreTokens.viewUndoRedoService) + private readonly viewUndoRedoService: ViewUndoRedoService + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + command: UpdateViewShareMetaCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* TableAggregate.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const previousSnapshot = yield* handler.viewUndoRedoService.capture( + table, + command.viewId.toString() + ); + const shareMetaResult = yield* table.updateViewShareMeta(command.viewId, command.shareMeta); + if (!shareMetaResult.updateResult) { + return ok( + UpdateViewShareMetaResult.create({ + table, + viewId: command.viewId, + previousShareMeta: shareMetaResult.previousShareMeta, + nextShareMeta: shareMetaResult.nextShareMeta, + events: [], + }) + ); + } + + const pluginExecution = yield* await handler.viewOperationPluginRunner.prepare({ + kind: ViewOperationKind.update, + executionContext: context, + payload: { + tableId: table.id().toString(), + viewId: command.viewId.toString(), + patch: { shareMeta: shareMetaResult.nextShareMeta }, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + + const update = yield* await handler.tableUpdateFlow.execute(context, { table }, () => + ok(shareMetaResult.updateResult!) + ); + const nextSnapshot = yield* handler.viewUndoRedoService.capture( + update.table, + command.viewId.toString() + ); + yield* await handler.viewUndoRedoService.appendUpdate( + context, + update.table, + [previousSnapshot], + [nextSnapshot] + ); + return ok( + UpdateViewShareMetaResult.create({ + table: update.table, + viewId: command.viewId, + previousShareMeta: shareMetaResult.previousShareMeta, + nextShareMeta: shareMetaResult.nextShareMeta, + events: update.events, + }) + ); + }); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewSortCommand.ts b/packages/v2/core/src/commands/UpdateViewSortCommand.ts new file mode 100644 index 0000000000..f453547958 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewSortCommand.ts @@ -0,0 +1,41 @@ +import { err, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; +import { viewSortSchema, type ViewSortDTO } from '../domain/table/views/ViewSort'; +import { PublicCommand } from './PublicCommand'; + +export const updateViewSortInputSchema = z.object({ + tableId: z.string(), + viewId: z.string(), + sort: viewSortSchema, +}); + +export class UpdateViewSortCommand extends PublicCommand { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId, + readonly sort: ViewSortDTO + ) { + super(); + } + + static create(raw: unknown): Result { + const parsed = updateViewSortInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid UpdateViewSortCommand input', + details: z.formatError(parsed.error), + }) + ); + } + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map( + (viewId) => new UpdateViewSortCommand(tableId, viewId, parsed.data.sort) + ) + ); + } +} diff --git a/packages/v2/core/src/commands/UpdateViewSortHandler.spec.ts b/packages/v2/core/src/commands/UpdateViewSortHandler.spec.ts new file mode 100644 index 0000000000..8d99dd4dc2 --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewSortHandler.spec.ts @@ -0,0 +1,176 @@ +import { err, ok, type Result } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import type { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { TableUpdateViewQueryDefaultsSpec } from '../domain/table/specs/TableUpdateViewQueryDefaultsSpec'; +import { Table } from '../domain/table/Table'; +import type { TableUpdateResult } from '../domain/table/TableMutator'; +import { TableName } from '../domain/table/TableName'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { UpdateViewSortCommand } from './UpdateViewSortCommand'; +import { UpdateViewSortHandler } from './UpdateViewSortHandler'; + +const context: IExecutionContext = { actorId: ActorId.create('actor')._unsafeUnwrap() }; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +class FakeTableUpdateFlow { + calls = 0; + mutateSpec?: TableUpdateResult['mutateSpec']; + + async execute( + _context: IExecutionContext, + target: { table: Table }, + mutate: (table: Table) => Result + ) { + this.calls += 1; + const result = mutate(target.table); + if (result.isErr()) return err(result.error); + this.mutateSpec = result.value.mutateSpec; + return ok({ + table: result.value.table, + events: result.value.table.pullDomainEvents(), + postPersistEvents: [], + }); + } +} + +const createHandler = (table: Table, plugins: IViewOperationPlugin[] = []) => { + const tableRepository = { findOne: vi.fn(async () => ok(table)) } as unknown as ITableRepository; + const tableUpdateFlow = new FakeTableUpdateFlow(); + const undoRedo = { + capture: vi.fn((_, viewId: string) => ok({ id: viewId } as never)), + appendUpdate: vi.fn(async () => ok(undefined)), + } as unknown as ViewUndoRedoService; + return { + handler: new UpdateViewSortHandler( + tableRepository, + tableUpdateFlow as unknown as TableUpdateFlow, + new ViewOperationPluginRunner(plugins), + undoRedo + ), + tableRepository, + tableUpdateFlow, + undoRedo, + }; +}; + +describe('UpdateViewSortCommand', () => { + it('validates identifiers, directions, null, and manual sort', () => { + expect( + UpdateViewSortCommand.create({ tableId: 'bad', viewId: 'bad', sort: null }).isErr() + ).toBe(true); + const table = buildTable(); + const ids = { tableId: table.id().toString(), viewId: table.views()[0]!.id().toString() }; + expect(UpdateViewSortCommand.create({ ...ids, sort: null }).isOk()).toBe(true); + expect( + UpdateViewSortCommand.create({ ...ids, sort: { sortObjs: [], manualSort: true } }).isOk() + ).toBe(true); + expect( + UpdateViewSortCommand.create({ + ...ids, + sort: { sortObjs: [{ fieldId: table.getFields()[0]!.id().toString(), order: 'up' }] }, + }).isErr() + ).toBe(true); + }); +}); + +describe('UpdateViewSortHandler', () => { + it('orchestrates aggregate mutation, plugin policy, persistence, and v2 history', async () => { + const table = buildTable(); + const sort = { + sortObjs: [{ fieldId: table.getFields()[0]!.id().toString(), order: 'desc' as const }], + manualSort: false, + }; + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler(table, [ + { + name: 'capture', + supports: (kind) => kind === ViewOperationKind.update, + prepare, + guard: () => ok(undefined), + }, + ]); + const command = UpdateViewSortCommand.create({ + tableId: table.id().toString(), + viewId: table.views()[0]!.id().toString(), + sort, + })._unsafeUnwrap(); + + const result = await setup.handler.handle(context, command); + + expect(result.isOk()).toBe(true); + expect(setup.tableRepository.findOne).toHaveBeenCalledOnce(); + expect(setup.tableUpdateFlow.mutateSpec).toBeInstanceOf(TableUpdateViewQueryDefaultsSpec); + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'update', + payload: expect.objectContaining({ patch: { sort } }), + }) + ); + expect(setup.undoRedo.capture).toHaveBeenCalledTimes(2); + expect(setup.undoRedo.appendUpdate).toHaveBeenCalledOnce(); + }); + + it('skips plugins, persistence, and history for an identical sort', async () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const sort = { + sortObjs: [{ fieldId: table.getFields()[0]!.id().toString(), order: 'asc' as const }], + }; + const current = table.updateViewSort(viewId, sort)._unsafeUnwrap().updateResult!.table; + current.pullDomainEvents(); + const prepare = vi.fn(() => ok(undefined)); + const setup = createHandler(current, [ + { name: 'capture', supports: () => true, prepare, guard: () => ok(undefined) }, + ]); + const command = UpdateViewSortCommand.create({ + tableId: current.id().toString(), + viewId: viewId.toString(), + sort, + })._unsafeUnwrap(); + + expect((await setup.handler.handle(context, command)).isOk()).toBe(true); + expect(setup.tableUpdateFlow.calls).toBe(0); + expect(prepare).not.toHaveBeenCalled(); + expect(setup.undoRedo.appendUpdate).not.toHaveBeenCalled(); + }); + + it('does not persist or append history when a guard rejects', async () => { + const table = buildTable(); + const setup = createHandler(table, [ + { + name: 'reject', + supports: () => true, + guard: () => err(domainError.forbidden({ message: 'rejected' })), + }, + ]); + const command = UpdateViewSortCommand.create({ + tableId: table.id().toString(), + viewId: table.views()[0]!.id().toString(), + sort: { sortObjs: [] }, + })._unsafeUnwrap(); + + expect((await setup.handler.handle(context, command))._unsafeUnwrapErr().code).toBe( + 'forbidden' + ); + expect(setup.tableUpdateFlow.calls).toBe(0); + expect(setup.undoRedo.appendUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/v2/core/src/commands/UpdateViewSortHandler.ts b/packages/v2/core/src/commands/UpdateViewSortHandler.ts new file mode 100644 index 0000000000..24c2cf86bb --- /dev/null +++ b/packages/v2/core/src/commands/UpdateViewSortHandler.ts @@ -0,0 +1,133 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry, type Result } from 'neverthrow'; + +import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; +import type { DomainError } from '../domain/shared/DomainError'; +import type { IDomainEvent } from '../domain/shared/DomainEvent'; +import { Table as TableAggregate, type Table } from '../domain/table/Table'; +import type { ViewId } from '../domain/table/views/ViewId'; +import type { ViewQueryDefaults } from '../domain/table/views/ViewQueryDefaults'; +import type { ViewSortDTO } from '../domain/table/views/ViewSort'; +import type * as ExecutionContextPort from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { ViewOperationKind } from '../ports/ViewOperationPlugin'; +import { CommandHandler, type ICommandHandler } from './CommandHandler'; +import { UpdateViewSortCommand } from './UpdateViewSortCommand'; + +export class UpdateViewSortResult { + private constructor( + readonly table: Table, + readonly viewId: ViewId, + readonly previousSort: ViewSortDTO, + readonly nextSort: ViewSortDTO, + readonly previousQueryDefaults: ViewQueryDefaults, + readonly nextQueryDefaults: ViewQueryDefaults, + readonly events: ReadonlyArray + ) {} + + static create(params: { + table: Table; + viewId: ViewId; + previousSort: ViewSortDTO; + nextSort: ViewSortDTO; + previousQueryDefaults: ViewQueryDefaults; + nextQueryDefaults: ViewQueryDefaults; + events: ReadonlyArray; + }): UpdateViewSortResult { + return new UpdateViewSortResult( + params.table, + params.viewId, + params.previousSort, + params.nextSort, + params.previousQueryDefaults, + params.nextQueryDefaults, + [...params.events] + ); + } +} + +@CommandHandler(UpdateViewSortCommand) +@injectable() +export class UpdateViewSortHandler + implements ICommandHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableUpdateFlow) + private readonly tableUpdateFlow: TableUpdateFlow, + @inject(v2CoreTokens.viewOperationPluginRunner) + private readonly viewOperationPluginRunner: ViewOperationPluginRunner, + @inject(v2CoreTokens.viewUndoRedoService) + private readonly viewUndoRedoService: ViewUndoRedoService + ) {} + + async handle( + context: ExecutionContextPort.IExecutionContext, + command: UpdateViewSortCommand + ): Promise> { + const handler = this; + return safeTry(async function* () { + const tableSpec = yield* TableAggregate.specs().byId(command.tableId).build(); + const table = yield* await handler.tableRepository.findOne(context, tableSpec); + const previousSnapshot = yield* handler.viewUndoRedoService.capture( + table, + command.viewId.toString() + ); + const sortResult = yield* table.updateViewSort(command.viewId, command.sort); + if (!sortResult.updateResult) { + return ok( + UpdateViewSortResult.create({ + table, + viewId: command.viewId, + previousSort: sortResult.previousSort, + nextSort: sortResult.nextSort, + previousQueryDefaults: sortResult.previousQueryDefaults, + nextQueryDefaults: sortResult.nextQueryDefaults, + events: [], + }) + ); + } + + const pluginExecution = yield* await handler.viewOperationPluginRunner.prepare({ + kind: ViewOperationKind.update, + executionContext: context, + payload: { + tableId: table.id().toString(), + viewId: command.viewId.toString(), + patch: { sort: sortResult.nextSort }, + }, + isTransactionBound: false, + }); + yield* await pluginExecution.guard(); + + const update = yield* await handler.tableUpdateFlow.execute(context, { table }, () => + ok(sortResult.updateResult!) + ); + const nextSnapshot = yield* handler.viewUndoRedoService.capture( + update.table, + command.viewId.toString() + ); + yield* await handler.viewUndoRedoService.appendUpdate( + context, + update.table, + [previousSnapshot], + [nextSnapshot] + ); + return ok( + UpdateViewSortResult.create({ + table: update.table, + viewId: command.viewId, + previousSort: sortResult.previousSort, + nextSort: sortResult.nextSort, + previousQueryDefaults: sortResult.previousQueryDefaults, + nextQueryDefaults: sortResult.nextQueryDefaults, + events: update.events, + }) + ); + }); + } +} diff --git a/packages/v2/core/src/commands/ViewPluginEndpoints.spec.ts b/packages/v2/core/src/commands/ViewPluginEndpoints.spec.ts new file mode 100644 index 0000000000..c73729bf17 --- /dev/null +++ b/packages/v2/core/src/commands/ViewPluginEndpoints.spec.ts @@ -0,0 +1,193 @@ +import { err, ok } from 'neverthrow'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError } from '../domain/shared/DomainError'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { Table } from '../domain/table/Table'; +import { TableName } from '../domain/table/TableName'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import type { ITableRepository } from '../ports/TableRepository'; +import type { IUnitOfWork } from '../ports/UnitOfWork'; +import type { IViewPluginRepository } from '../ports/ViewPluginRepository'; +import { GetViewPluginInstallHandler } from '../queries/GetViewPluginInstallHandler'; +import { GetViewPluginInstallQuery } from '../queries/GetViewPluginInstallQuery'; +import { UpdateViewPluginStorageCommand } from './UpdateViewPluginStorageCommand'; +import { UpdateViewPluginStorageHandler } from './UpdateViewPluginStorageHandler'; + +const context: IExecutionContext = { + actorId: ActorId.create('actor')._unsafeUnwrap(), +}; + +const buildTable = () => { + const table = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Plugin endpoints')._unsafeUnwrap()) + .field() + .singleLineText() + .withName(FieldName.create('Name')._unsafeUnwrap()) + .done() + .view() + .defaultGrid() + .done() + .build() + ._unsafeUnwrap(); + return table + .createView({ + type: 'plugin', + name: 'Sheet', + options: { + pluginId: 'plg-sheet', + pluginInstallId: 'pli-sheet', + pluginLogo: 'logo.svg', + }, + }) + ._unsafeUnwrap().updateResult.table; +}; + +const buildPluginRepository = (): IViewPluginRepository => ({ + findViewPlugin: vi.fn(async () => ok({ id: 'plg-sheet', name: 'Sheet', logo: 'logo.svg' })), + insertViewPluginInstallation: vi.fn(async () => ok(undefined)), + findViewPluginInstallationByViewId: vi.fn(async () => ok({ storage: null })), + getViewPluginInstallation: vi.fn(async (_context, baseId, viewId) => + ok({ + id: 'pli-sheet', + pluginId: 'plg-sheet', + baseId, + viewId, + name: 'Sheet', + url: '/plugin/sheet', + storage: { loaded: true }, + }) + ), + updateViewPluginStorage: vi.fn(async () => ok(undefined)), +}); + +describe('Plugin View query and storage command', () => { + let table: ReturnType; + let tableRepository: ITableRepository; + let pluginRepository: IViewPluginRepository; + let unitOfWork: IUnitOfWork; + let viewId: string; + + beforeEach(() => { + table = buildTable(); + viewId = table + .views() + .find((view) => view.type().toString() === 'plugin')! + .id() + .toString(); + tableRepository = { + findOne: vi.fn(async () => ok(table)), + } as unknown as ITableRepository; + pluginRepository = buildPluginRepository(); + unitOfWork = { + withTransaction: vi.fn(async (currentContext, work) => work(currentContext)), + }; + }); + + it('loads the Table aggregate with its target View before reading the installation', async () => { + const handler = new GetViewPluginInstallHandler(tableRepository, pluginRepository); + const query = GetViewPluginInstallQuery.create({ + tableId: table.id().toString(), + viewId, + })._unsafeUnwrap(); + + const result = await handler.handle(context, query); + + expect(result._unsafeUnwrap().installation).toMatchObject({ + id: 'pli-sheet', + baseId: table.baseId().toString(), + viewId, + storage: { loaded: true }, + }); + expect(pluginRepository.getViewPluginInstallation).toHaveBeenCalledWith( + context, + table.baseId().toString(), + viewId + ); + }); + + it('rejects a View outside the loaded Table before querying PluginInstallation', async () => { + const handler = new GetViewPluginInstallHandler(tableRepository, pluginRepository); + const query = GetViewPluginInstallQuery.create({ + tableId: table.id().toString(), + viewId: `viw${'z'.repeat(16)}`, + })._unsafeUnwrap(); + + const result = await handler.handle(context, query); + + expect(result._unsafeUnwrapErr().code).toBe('view.not_found'); + expect(pluginRepository.getViewPluginInstallation).not.toHaveBeenCalled(); + }); + + it('updates storage only after the Table aggregate confirms View ownership', async () => { + const handler = new UpdateViewPluginStorageHandler( + tableRepository, + pluginRepository, + unitOfWork + ); + const storage = { nested: { rows: [1, true, 'value'] } }; + const command = UpdateViewPluginStorageCommand.create({ + tableId: table.id().toString(), + viewId, + pluginInstallId: 'pli-sheet', + storage, + })._unsafeUnwrap(); + + const result = await handler.handle(context, command); + + expect(result._unsafeUnwrap()).toMatchObject({ + tableId: table.id().toString(), + viewId, + pluginInstallId: 'pli-sheet', + storage, + }); + expect(pluginRepository.updateViewPluginStorage).toHaveBeenCalledWith(context, { + baseId: table.baseId().toString(), + viewId, + pluginInstallId: 'pli-sheet', + storage, + }); + expect(unitOfWork.withTransaction).toHaveBeenCalledWith(context, expect.any(Function), { + scope: 'meta', + }); + expect(tableRepository.findOne).toHaveBeenCalledWith(context, expect.anything(), { + lock: 'forUpdate', + }); + }); + + it('propagates installation mismatch and does not mutate the Table', async () => { + vi.mocked(pluginRepository.updateViewPluginStorage).mockResolvedValue( + err(domainError.notFound({ message: 'Plugin installation not found' })) + ); + const handler = new UpdateViewPluginStorageHandler( + tableRepository, + pluginRepository, + unitOfWork + ); + const command = UpdateViewPluginStorageCommand.create({ + tableId: table.id().toString(), + viewId, + pluginInstallId: 'pli-other', + storage: { rejected: true }, + })._unsafeUnwrap(); + + const result = await handler.handle(context, command); + + expect(result._unsafeUnwrapErr().code).toBe('not_found'); + expect(table.getView(command.viewId).isOk()).toBe(true); + }); + + it('validates storage as a record at the command boundary', () => { + expect( + UpdateViewPluginStorageCommand.create({ + tableId: table.id().toString(), + viewId, + pluginInstallId: 'pli-sheet', + storage: ['invalid'], + })._unsafeUnwrapErr().code + ).toBe('validation.invalid'); + }); +}); diff --git a/packages/v2/core/src/di/registerCoreServices.ts b/packages/v2/core/src/di/registerCoreServices.ts index 66bec2133a..485b1e9013 100644 --- a/packages/v2/core/src/di/registerCoreServices.ts +++ b/packages/v2/core/src/di/registerCoreServices.ts @@ -21,6 +21,7 @@ import { RecordBulkUpdateService } from '../application/services/RecordBulkUpdat import { NullRecordChangedValueDecoratorService } from '../application/services/RecordChangedValueDecoratorService'; import { RecordCreationService } from '../application/services/RecordCreationService'; import { RecordMutationSpecResolverService } from '../application/services/RecordMutationSpecResolverService'; +import { RecordQueryPluginRunner } from '../application/services/RecordQueryPluginRunner'; import { RecordReorderService } from '../application/services/RecordReorderService'; import { RecordWritePluginRunner } from '../application/services/RecordWritePluginRunner'; import { RecordWriteSideEffectService } from '../application/services/RecordWriteSideEffectService'; @@ -35,34 +36,43 @@ import { StaticTableDataSafetyLimitPlugin, TableDataSafetyLimitComposer, } from '../application/services/TableDataSafetyLimitComposer'; -import { TableDeletionSideEffectService } from '../application/services/TableDeletionSideEffectService'; -import { TableFieldLimitFieldOperationPlugin } from '../application/services/TableFieldLimitFieldOperationPlugin'; import { TableDataSafetyLimitFieldOperationPlugin } from '../application/services/TableDataSafetyLimitFieldOperationPlugin'; import { TableDataSafetyLimitRecordWritePlugin } from '../application/services/TableDataSafetyLimitRecordWritePlugin'; import { TableDataSafetyLimitTableOperationPlugin } from '../application/services/TableDataSafetyLimitTableOperationPlugin'; import { TableDataSafetyLimitViewOperationPlugin } from '../application/services/TableDataSafetyLimitViewOperationPlugin'; +import { TableDeletionSideEffectService } from '../application/services/TableDeletionSideEffectService'; +import { TableFieldLimitFieldOperationPlugin } from '../application/services/TableFieldLimitFieldOperationPlugin'; import { TableOperationPluginRunner } from '../application/services/TableOperationPluginRunner'; import { TableQueryService } from '../application/services/TableQueryService'; import { TableSchemaOperationRepairHandler } from '../application/services/TableSchemaOperationRepairHandler'; import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; -import { UndoRedoStackService } from '../application/services/UndoRedoStackService'; +import { + defaultUndoRedoReplayConfig, + UndoRedoStackService, +} from '../application/services/UndoRedoStackService'; import { UserValueResolverService } from '../application/services/UserValueResolverService'; +import { ViewManualSortService } from '../application/services/ViewManualSortService'; import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; +import { ViewPluginCreationService } from '../application/services/ViewPluginCreationService'; +import { ViewUndoRedoService } from '../application/services/ViewUndoRedoService'; import { PasteStreamApplicationService } from '../commands/PasteHandler'; import { RestoreFieldStreamApplicationService } from '../commands/RestoreFieldStreamHandler'; import { NoopAttachmentUrlSignerService } from '../ports/defaults/NoopAttachmentUrlSignerService'; +import { NoopButtonClickWorkflowService } from '../ports/defaults/NoopButtonClickWorkflowService'; import { NoopComputedFieldBackfillService } from '../ports/defaults/NoopComputedFieldBackfillService'; import { NoopFieldDeleteSnapshotSink } from '../ports/defaults/NoopFieldDeleteSnapshotSink'; import { NoopFieldTrashRepository } from '../ports/defaults/NoopFieldTrashRepository'; import { NoopRecordOrderCalculator } from '../ports/defaults/NoopRecordOrderCalculator'; import { NoopTableQueryObservability } from '../ports/defaults/NoopTableQueryObservability'; import { NoopUndoRedoStore } from '../ports/defaults/NoopUndoRedoStore'; +import { NoopViewPluginRepository } from '../ports/defaults/NoopViewPluginRepository'; import type { IFieldOperationPlugin } from '../ports/FieldOperationPlugin'; +import type { IRecordQueryPlugin } from '../ports/RecordQueryPlugin'; import type { IRecordWritePlugin } from '../ports/RecordWritePlugin'; import type { ITableDataSafetyLimitPlugin } from '../ports/TableDataSafetyLimitPlugin'; import type { ITableOperationPlugin } from '../ports/TableOperationPlugin'; -import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; import { v2CoreTokens } from '../ports/tokens'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; import { registerFieldOperationPlugin } from './registerFieldOperationPlugin'; import { registerRecordWritePlugin } from './registerRecordWritePlugin'; import { registerTableDataSafetyLimitPlugin } from './registerTableDataSafetyLimitPlugin'; @@ -101,6 +111,7 @@ import { registerViewOperationPlugin } from './registerViewOperationPlugin'; * | recordWritePluginRunner | RecordWritePluginRunner | Run typed record-write plugins | * | recordWriteSideEffectService | RecordWriteSideEffectService | Collect table side effects on record writes | * | recordCreationService | RecordCreationService | Shared single-record creation workflow | + * | viewPluginCreationService | ViewPluginCreationService | Prepare external Plugin View integrations | * | schemaOperationRunnerService | SchemaOperationRunnerService | Run repair handlers for schema operations | * * ## Usage @@ -222,6 +233,14 @@ export const registerV2CoreServices = ( }); } + if (!container.isRegistered(v2CoreTokens.viewUndoRedoService)) { + container.register(v2CoreTokens.viewUndoRedoService, ViewUndoRedoService, { lifecycle }); + } + + if (!container.isRegistered(v2CoreTokens.viewManualSortService)) { + container.register(v2CoreTokens.viewManualSortService, ViewManualSortService, { lifecycle }); + } + // FieldCrossTableUpdateSideEffectService - cross-table update side effects for field updates if (!container.isRegistered(v2CoreTokens.fieldCrossTableUpdateSideEffectService)) { container.register( @@ -343,6 +362,12 @@ export const registerV2CoreServices = ( }); } + if (!container.isRegistered(v2CoreTokens.buttonClickWorkflowService)) { + container.register(v2CoreTokens.buttonClickWorkflowService, NoopButtonClickWorkflowService, { + lifecycle, + }); + } + if (!container.isRegistered(v2CoreTokens.recordWritePlugins)) { container.registerInstance(v2CoreTokens.recordWritePlugins, [] as IRecordWritePlugin[]); } @@ -391,6 +416,16 @@ export const registerV2CoreServices = ( }); } + if (!container.isRegistered(v2CoreTokens.recordQueryPlugins)) { + container.registerInstance(v2CoreTokens.recordQueryPlugins, [] as IRecordQueryPlugin[]); + } + + if (!container.isRegistered(v2CoreTokens.recordQueryPluginRunner)) { + container.register(v2CoreTokens.recordQueryPluginRunner, RecordQueryPluginRunner, { + lifecycle, + }); + } + if (!container.isRegistered(v2CoreTokens.fieldOperationPlugins)) { container.registerInstance(v2CoreTokens.fieldOperationPlugins, [] as IFieldOperationPlugin[]); } @@ -457,6 +492,18 @@ export const registerV2CoreServices = ( }); } + if (!container.isRegistered(v2CoreTokens.viewPluginRepository)) { + container.register(v2CoreTokens.viewPluginRepository, NoopViewPluginRepository, { + lifecycle, + }); + } + + if (!container.isRegistered(v2CoreTokens.viewPluginCreationService)) { + container.register(v2CoreTokens.viewPluginCreationService, ViewPluginCreationService, { + lifecycle, + }); + } + // RecordMutationSpecResolverService - resolve external values in specs if (!container.isRegistered(v2CoreTokens.recordMutationSpecResolverService)) { container.register( @@ -562,6 +609,12 @@ export const registerV2CoreServices = ( container.registerInstance(v2CoreTokens.undoRedoStore, new NoopUndoRedoStore()); } + // Replay config default: the restore purge guard stays off unless the app + // layer that writes the record_trash rows opts in (see IUndoRedoReplayConfig). + if (!container.isRegistered(v2CoreTokens.undoRedoReplayConfig)) { + container.registerInstance(v2CoreTokens.undoRedoReplayConfig, defaultUndoRedoReplayConfig); + } + // UndoRedoStackService - per-window undo/redo stack append/replay if (!container.isRegistered(v2CoreTokens.undoRedoService)) { container.register(v2CoreTokens.undoRedoService, UndoRedoStackService, { lifecycle }); diff --git a/packages/v2/core/src/di/registerRecordQueryPlugin.spec.ts b/packages/v2/core/src/di/registerRecordQueryPlugin.spec.ts new file mode 100644 index 0000000000..8a1943e246 --- /dev/null +++ b/packages/v2/core/src/di/registerRecordQueryPlugin.spec.ts @@ -0,0 +1,100 @@ +import type { DependencyContainer } from '@teable/v2-di'; +import { describe, expect, it } from 'vitest'; + +import { + createContextualLogger, + createLogScopeContext, + type ILogger, + type LogContext, +} from '../ports/Logger'; +import type { IRecordQueryPlugin } from '../ports/RecordQueryPlugin'; +import { v2CoreTokens } from '../ports/tokens'; +import { registerRecordQueryPlugin } from './registerRecordQueryPlugin'; + +class FakeLogger implements ILogger { + readonly infos: Array<{ message: string; context?: LogContext }> = []; + + child(context: LogContext): ILogger { + return createContextualLogger(this, context); + } + + scope(scope: string, context?: LogContext): ILogger { + return this.child(createLogScopeContext(scope, context ?? {})); + } + + debug(): void { + return undefined; + } + + info(message: string, context?: LogContext): void { + this.infos.push({ message, context }); + } + + warn(): void { + return undefined; + } + + error(): void { + return undefined; + } +} + +const createPlugin = (name: string): IRecordQueryPlugin => ({ + name, + supports: () => true, +}); + +const createContainer = (logger: ILogger): DependencyContainer => { + const registrations = new Map(); + registrations.set(v2CoreTokens.logger, logger); + + return { + isRegistered(token: unknown) { + return registrations.has(token); + }, + registerInstance(token: unknown, instance: unknown) { + registrations.set(token, instance); + return this; + }, + resolve(token: unknown): T { + if (!registrations.has(token)) { + throw new Error(`Unexpected token: ${String(token)}`); + } + + return registrations.get(token) as T; + }, + } as unknown as DependencyContainer; +}; + +describe('registerRecordQueryPlugin', () => { + it('registers each unique plugin and reports total count', () => { + const logger = new FakeLogger(); + const container = createContainer(logger); + + const first = registerRecordQueryPlugin(container, createPlugin('alpha'), { + source: 'test-suite', + }); + const second = registerRecordQueryPlugin(container, createPlugin('beta'), { + source: 'test-suite', + }); + const duplicate = registerRecordQueryPlugin(container, createPlugin('alpha'), { + source: 'test-suite', + }); + + expect(first).toEqual({ + plugin: expect.objectContaining({ name: 'alpha' }), + registered: true, + totalPlugins: 1, + }); + expect(second).toEqual({ + plugin: expect.objectContaining({ name: 'beta' }), + registered: true, + totalPlugins: 2, + }); + expect(duplicate.registered).toBe(false); + expect(duplicate.totalPlugins).toBe(2); + expect(logger.infos.some((entry) => entry.message === 'Record query plugin registered')).toBe( + true + ); + }); +}); diff --git a/packages/v2/core/src/di/registerRecordQueryPlugin.ts b/packages/v2/core/src/di/registerRecordQueryPlugin.ts new file mode 100644 index 0000000000..bec23c0e4e --- /dev/null +++ b/packages/v2/core/src/di/registerRecordQueryPlugin.ts @@ -0,0 +1,73 @@ +import type { DependencyContainer } from '@teable/v2-di'; + +import { NoopLogger } from '../ports/defaults/NoopLogger'; +import type { ILogger } from '../ports/Logger'; +import type { IRecordQueryPlugin } from '../ports/RecordQueryPlugin'; +import { v2CoreTokens } from '../ports/tokens'; + +export interface IRegisterRecordQueryPluginOptions { + source?: string; + logger?: ILogger; +} + +export interface IRegisterRecordQueryPluginResult { + plugin: IRecordQueryPlugin; + registered: boolean; + totalPlugins: number; +} + +const resolveLogger = (container: DependencyContainer, explicitLogger?: ILogger): ILogger => { + if (explicitLogger) { + return explicitLogger; + } + + if (container.isRegistered(v2CoreTokens.logger)) { + return container.resolve(v2CoreTokens.logger); + } + + return new NoopLogger(); +}; + +const ensurePluginRegistry = (container: DependencyContainer): IRecordQueryPlugin[] => { + if (!container.isRegistered(v2CoreTokens.recordQueryPlugins)) { + container.registerInstance(v2CoreTokens.recordQueryPlugins, [] as IRecordQueryPlugin[]); + } + + return container.resolve(v2CoreTokens.recordQueryPlugins); +}; + +export const registerRecordQueryPlugin = ( + container: DependencyContainer, + plugin: IRecordQueryPlugin, + options: IRegisterRecordQueryPluginOptions = {} +): IRegisterRecordQueryPluginResult => { + const plugins = ensurePluginRegistry(container); + const logger = resolveLogger(container, options.logger).scope('recordQueryPlugin', { + plugin: plugin.name, + source: options.source, + }); + + const existingPlugin = plugins.find((registeredPlugin) => registeredPlugin.name === plugin.name); + if (existingPlugin) { + logger.info('Record query plugin already registered', { + totalPlugins: plugins.length, + }); + + return { + plugin: existingPlugin, + registered: false, + totalPlugins: plugins.length, + }; + } + + plugins.push(plugin); + logger.info('Record query plugin registered', { + totalPlugins: plugins.length, + }); + + return { + plugin, + registered: true, + totalPlugins: plugins.length, + }; +}; diff --git a/packages/v2/core/src/domain/shared/DomainError.spec.ts b/packages/v2/core/src/domain/shared/DomainError.spec.ts new file mode 100644 index 0000000000..86b9dfd282 --- /dev/null +++ b/packages/v2/core/src/domain/shared/DomainError.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { domainError, isDomainError, toError } from './DomainError'; + +describe('DomainError diagnostics', () => { + it('captures a non-enumerable creation-site stack', () => { + const error = domainError.infrastructure({ + message: 'Failed to load compute activity', + details: { tableId: 'tbl1', error: 'relation does not exist' }, + }); + + expect(error.stack).toEqual(expect.stringContaining('DomainError.spec.ts')); + expect(error.stack).not.toEqual(expect.stringContaining('at withTags')); + expect(Object.keys(error)).not.toContain('stack'); + expect(JSON.stringify(error)).not.toContain('stack'); + expect(JSON.parse(JSON.stringify(error))).toMatchObject({ + code: 'infrastructure', + message: 'Failed to load compute activity', + tags: ['infrastructure'], + details: { tableId: 'tbl1', error: 'relation does not exist' }, + }); + }); + + it('preserves the original Error stack via fromUnknown', () => { + const original = new Error('db unavailable'); + const wrapped = domainError.fromUnknown(original, { + code: 'infrastructure.db', + tags: ['infrastructure'], + }); + + expect(isDomainError(wrapped)).toBe(true); + expect(wrapped.code).toBe('infrastructure.db'); + expect(wrapped.tags).toEqual(['unexpected', 'infrastructure']); + expect(wrapped.stack).toBe(original.stack); + expect(wrapped.cause).toBe(original); + expect(Object.keys(wrapped)).not.toContain('cause'); + }); + + it('converts to a real Error for throw/Sentry boundaries', () => { + const domain = domainError.validation({ + code: 'validation.field.invalid', + message: 'bad field', + }); + const exception = toError(domain); + + expect(exception).toBeInstanceOf(Error); + expect(exception.name).toBe('DomainError:validation.field.invalid'); + expect(exception.message).toBe('bad field'); + expect(exception.stack).toBe(domain.stack); + expect((exception as Error & { code: string }).code).toBe('validation.field.invalid'); + expect((exception as Error & { domainError: unknown }).domainError).toBe(domain); + expect(isDomainError(exception)).toBe(false); + + // Domain payload stays non-enumerable so serialized Errors don't leak it. + expect(Object.keys(exception)).toEqual([]); + expect(JSON.stringify(exception)).toBe('{}'); + + // toError -> fromUnknown round trip is lossless: the original DomainError + // is unwrapped, preserving code/tags/details. + expect(domainError.fromUnknown(exception)).toBe(domain); + }); +}); diff --git a/packages/v2/core/src/domain/shared/DomainError.ts b/packages/v2/core/src/domain/shared/DomainError.ts index 9ef5fa196d..22082f3b9c 100644 --- a/packages/v2/core/src/domain/shared/DomainError.ts +++ b/packages/v2/core/src/domain/shared/DomainError.ts @@ -1,3 +1,5 @@ +import type { SdkErrorI18nKey } from '@teable/i18n-keys'; + /** * Domain Error Tags * @@ -64,6 +66,18 @@ export type DomainErrorTag = (typeof domainErrorTagValues)[number]; */ export type DomainErrorCode = string; +/** + * User-facing translation attached where the error is created. + * `i18nKey` selects a message in the frontend `sdk` locale namespace and + * `context` must cover every interpolation placeholder of that message — + * a site that cannot supply the placeholders must omit `localization` + * entirely so the client falls back to the English `message`. + */ +export interface IDomainErrorLocalization { + readonly i18nKey: SdkErrorI18nKey; + readonly context?: Readonly>; +} + /** * DomainError - A structured, serializable error representation for domain layer. * @@ -71,18 +85,26 @@ export type DomainErrorCode = string; * - Plain data object (not extending Error) to remain serializable across boundaries. * - No throw/exception semantics; errors are returned via Result. * - Immutable (all fields readonly) for predictable behavior. + * - Diagnostic `stack`/`cause` are non-enumerable so JSON/HTTP DTO paths stay clean, + * while Sentry and log boundaries can still attribute the creation site. * * Fields: * - `code`: Machine-readable identifier for error type (e.g., "validation.field.invalid"). * - `message`: Human-readable description suitable for logging or display. * - `tags`: Array of semantic tags for categorization and HTTP status mapping. * - `details`: Optional structured metadata (e.g., field name, expected vs actual values). + * - `localization`: Optional user-facing translation, attached at the throw site. + * - `stack`: Optional creation-site stack (non-enumerable diagnostic). + * - `cause`: Optional original thrown value when wrapping unknowns (non-enumerable). */ export interface DomainError { readonly code: DomainErrorCode; readonly message: string; readonly tags: ReadonlyArray; readonly details?: Readonly>; + readonly localization?: IDomainErrorLocalization; + readonly stack?: string; + readonly cause?: unknown; toString(): string; } @@ -95,37 +117,100 @@ type DomainErrorInput = { message: string; tags: ReadonlyArray; details?: Readonly>; + localization?: IDomainErrorLocalization; + stack?: string; + cause?: unknown; +}; + +const defineNonEnumerable = (target: object, key: string, value: unknown): void => { + Object.defineProperty(target, key, { + value, + enumerable: false, + configurable: true, + writable: true, + }); +}; + +const attachCreationStack = ( + error: DomainError, + constructorOpt: (...args: never[]) => unknown +): void => { + if (typeof Error.captureStackTrace === 'function') { + // V8 keeps the stack lazy: the string is only materialized when `stack` is + // read (Sentry/log boundaries), so hot validation paths don't pay for it. + // Passing the public factory as `constructorOpt` already trims every + // DomainError-internal frame, in source and compiled output alike. + Error.captureStackTrace(error, constructorOpt); + return; + } + const fallback = new Error(error.message).stack; + if (fallback) { + defineNonEnumerable(error, 'stack', fallback); + } }; /** - * Internal factory to create a frozen DomainError object. + * Internal factory to create a DomainError object. + * `constructorOpt` is the outermost factory frame to omit from the captured stack + * (the public factory itself, e.g. `domainError.validation`) so Sentry + * attributes the real call site. */ -const createError = (input: DomainErrorInput): DomainError => ({ - code: input.code, - message: input.message, - tags: input.tags, - details: input.details, - toString: () => input.message, -}); +function createError( + input: DomainErrorInput, + constructorOpt: (...args: never[]) => unknown = createError +): DomainError { + const error: DomainError = { + code: input.code, + message: input.message, + tags: input.tags, + details: input.details, + localization: input.localization, + toString: () => input.message, + }; + + if (input.stack) { + defineNonEnumerable(error, 'stack', input.stack); + } else { + attachCreationStack(error, constructorOpt); + } + + if (input.cause !== undefined) { + defineNonEnumerable(error, 'cause', input.cause); + } + + return error; +} type DomainErrorParams = { message: string; code?: DomainErrorCode; details?: Readonly>; tags?: ReadonlyArray; + localization?: IDomainErrorLocalization; + cause?: unknown; }; /** * Internal helper to merge base tags with user-provided params. * Ensures the primary tag is always present and deduplicates. */ -const withTags = (tags: ReadonlyArray, params: DomainErrorParams): DomainError => - createError({ - code: params.code ?? tags[0] ?? 'unexpected', - message: params.message, - tags: params.tags ? [...new Set([...tags, ...params.tags])] : tags, - details: params.details, - }); +function withTags( + tags: ReadonlyArray, + params: DomainErrorParams, + constructorOpt: (...args: never[]) => unknown = withTags +): DomainError { + return createError( + { + code: params.code ?? tags[0] ?? 'unexpected', + message: params.message, + tags: params.tags ? [...new Set([...tags, ...params.tags])] : tags, + details: params.details, + localization: params.localization, + cause: params.cause, + }, + constructorOpt + ); +} // --------------------------------------------------------------------------- // Domain Error Factory @@ -162,7 +247,11 @@ export const domainError = { * HTTP mapping: 400 Bad Request */ validation: (params: DomainErrorParams): DomainError => - withTags(['validation'], { code: params.code ?? 'validation.invalid', ...params }), + withTags( + ['validation'], + { code: params.code ?? 'validation.invalid', ...params }, + domainError.validation + ), /** * State conflict (duplicate, already exists). @@ -170,7 +259,7 @@ export const domainError = { * HTTP mapping: 409 Conflict */ conflict: (params: DomainErrorParams): DomainError => - withTags(['conflict'], { code: params.code ?? 'conflict', ...params }), + withTags(['conflict'], { code: params.code ?? 'conflict', ...params }, domainError.conflict), /** * Resource not found. @@ -178,7 +267,7 @@ export const domainError = { * HTTP mapping: 404 Not Found */ notFound: (params: DomainErrorParams): DomainError => - withTags(['not-found'], { code: params.code ?? 'not_found', ...params }), + withTags(['not-found'], { code: params.code ?? 'not_found', ...params }, domainError.notFound), /** * Domain invariant violation. @@ -187,7 +276,11 @@ export const domainError = { * HTTP mapping: 422 Unprocessable Entity (or 400 depending on context) */ invariant: (params: DomainErrorParams): DomainError => - withTags(['invariant'], { code: params.code ?? 'invariant.violation', ...params }), + withTags( + ['invariant'], + { code: params.code ?? 'invariant.violation', ...params }, + domainError.invariant + ), /** * Feature not implemented. @@ -196,7 +289,11 @@ export const domainError = { * HTTP mapping: 501 Not Implemented */ notImplemented: (params: DomainErrorParams): DomainError => - withTags(['not-implemented'], { code: params.code ?? 'not_implemented', ...params }), + withTags( + ['not-implemented'], + { code: params.code ?? 'not_implemented', ...params }, + domainError.notImplemented + ), /** * Authentication failure. @@ -204,7 +301,11 @@ export const domainError = { * HTTP mapping: 401 Unauthorized */ unauthorized: (params: DomainErrorParams): DomainError => - withTags(['unauthorized'], { code: params.code ?? 'unauthorized', ...params }), + withTags( + ['unauthorized'], + { code: params.code ?? 'unauthorized', ...params }, + domainError.unauthorized + ), /** * Authorization failure (authenticated but not permitted). @@ -212,7 +313,7 @@ export const domainError = { * HTTP mapping: 403 Forbidden */ forbidden: (params: DomainErrorParams): DomainError => - withTags(['forbidden'], { code: params.code ?? 'forbidden', ...params }), + withTags(['forbidden'], { code: params.code ?? 'forbidden', ...params }, domainError.forbidden), /** * Infrastructure or external service failure. @@ -221,7 +322,11 @@ export const domainError = { * HTTP mapping: 503 Service Unavailable (or 500) */ infrastructure: (params: DomainErrorParams): DomainError => - withTags(['infrastructure'], { code: params.code ?? 'infrastructure', ...params }), + withTags( + ['infrastructure'], + { code: params.code ?? 'infrastructure', ...params }, + domainError.infrastructure + ), /** * Catch-all for unclassified errors. @@ -230,26 +335,65 @@ export const domainError = { * HTTP mapping: 500 Internal Server Error */ unexpected: (params: DomainErrorParams): DomainError => - withTags(['unexpected'], { code: params.code ?? 'unexpected', ...params }), + withTags( + ['unexpected'], + { code: params.code ?? 'unexpected', ...params }, + domainError.unexpected + ), /** * Wrap unknown errors (e.g., caught exceptions) into DomainError. * If the error is already a DomainError, returns it unchanged. * * Use at system boundaries to normalize error types. + * When wrapping a real Error, preserves its stack as the diagnostic stack and + * keeps the original value on non-enumerable `cause`. */ fromUnknown: (error: unknown, params?: Omit): DomainError => { if (isDomainError(error)) { return error; } - const message = error instanceof Error ? error.message : String(error); - return withTags(['unexpected'], { - message, - code: params?.code ?? 'unexpected', - details: params?.details, - tags: params?.tags, - }); + if (error instanceof Error) { + // Errors produced by toError() carry the original DomainError; unwrap it + // so a toError -> fromUnknown round trip is lossless (code, tags, details). + const unwrapped = (error as { domainError?: unknown }).domainError; + if (isDomainError(unwrapped)) { + return unwrapped; + } + return createError( + { + code: params?.code ?? 'unexpected', + message: error.message ? error.message : error.name || String(error), + tags: params?.tags + ? [...new Set(['unexpected', ...params.tags])] + : ['unexpected'], + details: params?.details, + localization: params?.localization, + stack: error.stack, + cause: error, + }, + domainError.fromUnknown + ); + } + return withTags( + ['unexpected'], + { + message: String(error), + code: params?.code ?? 'unexpected', + details: params?.details, + tags: params?.tags, + localization: params?.localization, + cause: error, + }, + domainError.fromUnknown + ); }, + + /** + * Convert a DomainError into a real Error for throw/Sentry boundaries. + * Domain code still returns Result; only adapters should call this. + */ + toError: (error: DomainError): Error => toError(error), }; // --------------------------------------------------------------------------- @@ -261,7 +405,10 @@ export const domainError = { * Useful for handling errors at boundaries or in catch blocks. */ export const isDomainError = (error: unknown): error is DomainError => { - if (!error || typeof error !== 'object') return false; + // DomainError is intentionally a POJO, never a real Error. Rejecting Error + // instances keeps boundary wrappers from toError()/HttpException out of + // Result paths and fromUnknown passthrough. + if (!error || typeof error !== 'object' || error instanceof Error) return false; const candidate = error as DomainError; return ( typeof candidate.code === 'string' && @@ -282,6 +429,34 @@ export const hasTag = (error: DomainError, tag: DomainErrorTag): boolean => */ export const hasCode = (error: DomainError, code: DomainErrorCode): boolean => error.code === code; +/** + * Convert a DomainError into a real Error for HTTP/Sentry/unhandled boundaries. + * + * Domain code still returns Result; only adapters that must throw or report to + * exception trackers should call this. + */ +export const toError = (error: DomainError): Error => { + const exception = new Error(error.message); + // Plain assignment would make `name` an enumerable own property; keep it + // non-enumerable like a native Error's. + defineNonEnumerable(exception, 'name', `DomainError:${error.code}`); + if (error.stack) { + exception.stack = error.stack; + } + // Non-enumerable like the DomainError diagnostics: Sentry and boundary code + // read these by property access, while JSON serialization of the Error stays + // clean (a bare Error stringifies to `{}`). + defineNonEnumerable(exception, 'code', error.code); + defineNonEnumerable(exception, 'tags', error.tags); + defineNonEnumerable(exception, 'details', error.details); + defineNonEnumerable(exception, 'localization', error.localization); + defineNonEnumerable(exception, 'domainError', error); + if (error.cause !== undefined) { + defineNonEnumerable(exception, 'cause', error.cause); + } + return exception; +}; + // --------------------------------------------------------------------------- // Convenience Type Predicates // --------------------------------------------------------------------------- diff --git a/packages/v2/core/src/domain/shared/DomainEventName.ts b/packages/v2/core/src/domain/shared/DomainEventName.ts index 09b221d37b..ab984ba615 100644 --- a/packages/v2/core/src/domain/shared/DomainEventName.ts +++ b/packages/v2/core/src/domain/shared/DomainEventName.ts @@ -38,6 +38,10 @@ export class DomainEventName extends ValueObject { return new DomainEventName('TableRenamed'); } + static tablePropertiesUpdated(): DomainEventName { + return new DomainEventName('TablePropertiesUpdated'); + } + static fieldCreated(): DomainEventName { return new DomainEventName('FieldCreated'); } @@ -58,6 +62,66 @@ export class DomainEventName extends ValueObject { return new DomainEventName('ViewColumnMetaUpdated'); } + static viewCreated(): DomainEventName { + return new DomainEventName('ViewCreated'); + } + + static viewDeleted(): DomainEventName { + return new DomainEventName('ViewDeleted'); + } + + static viewRenamed(): DomainEventName { + return new DomainEventName('ViewRenamed'); + } + + static viewDescriptionUpdated(): DomainEventName { + return new DomainEventName('ViewDescriptionUpdated'); + } + + static viewFilterUpdated(): DomainEventName { + return new DomainEventName('ViewFilterUpdated'); + } + + static viewGroupUpdated(): DomainEventName { + return new DomainEventName('ViewGroupUpdated'); + } + + static viewOptionsUpdated(): DomainEventName { + return new DomainEventName('ViewOptionsUpdated'); + } + + static viewShareMetaUpdated(): DomainEventName { + return new DomainEventName('ViewShareMetaUpdated'); + } + + static viewShareIdRefreshed(): DomainEventName { + return new DomainEventName('ViewShareIdRefreshed'); + } + + static viewShareEnabled(): DomainEventName { + return new DomainEventName('ViewShareEnabled'); + } + + static viewShareDisabled(): DomainEventName { + return new DomainEventName('ViewShareDisabled'); + } + + static viewSortUpdated(): DomainEventName { + return new DomainEventName('ViewSortUpdated'); + } + + static viewManualSortApplied(): DomainEventName { + return new DomainEventName('ViewManualSortApplied'); + } + + static viewLockedUpdated(): DomainEventName { + return new DomainEventName('ViewLockedUpdated'); + } + + static viewOrderUpdated(): DomainEventName { + return new DomainEventName('ViewOrderUpdated'); + } + static baseCreated(): DomainEventName { return new DomainEventName('BaseCreated'); } @@ -74,6 +138,10 @@ export class DomainEventName extends ValueObject { return new DomainEventName('RecordUpdated'); } + static buttonClicked(): DomainEventName { + return new DomainEventName('ButtonClicked'); + } + static recordsBatchUpdated(): DomainEventName { return new DomainEventName('RecordsBatchUpdated'); } diff --git a/packages/v2/core/src/domain/shared/TableDataSafetyLimits.ts b/packages/v2/core/src/domain/shared/TableDataSafetyLimits.ts index 4d36fadc2e..9fc12f991e 100644 --- a/packages/v2/core/src/domain/shared/TableDataSafetyLimits.ts +++ b/packages/v2/core/src/domain/shared/TableDataSafetyLimits.ts @@ -1,3 +1,4 @@ +import { sdkErrorI18nKeys, type SdkErrorI18nKey } from '@teable/i18n-keys'; import { err, ok } from 'neverthrow'; import type { Result } from 'neverthrow'; @@ -132,8 +133,114 @@ export const measureJsonBytes = (value: unknown): number => { return new TextEncoder().encode(json === undefined ? 'undefined' : json).byteLength; }; +export interface ITableDataSafetyLimitError { + readonly code: string; + readonly i18nKey: SdkErrorI18nKey; +} + +/** + * The vocabulary of table data safety limit errors. Each entry pairs the + * machine-readable domain code with the user-facing message key, so throw + * sites reference one entry and the two can never drift apart. Every message + * interpolates exactly `{{max}}`. + */ +export const tableDataSafetyLimitErrors = { + fieldOptionsMaxBytes: { + code: 'validation.limit.field_options_max_bytes', + i18nKey: sdkErrorI18nKeys.limit.fieldOptionsMaxBytes, + }, + selectChoicesMax: { + code: 'validation.limit.select_choices_max', + i18nKey: sdkErrorI18nKeys.limit.selectChoicesMax, + }, + selectChoiceNameMaxLength: { + code: 'validation.limit.select_choice_name_max_length', + i18nKey: sdkErrorI18nKeys.limit.selectChoiceNameMaxLength, + }, + selectDefaultValuesMax: { + code: 'validation.limit.select_default_values_max', + i18nKey: sdkErrorI18nKeys.limit.selectDefaultValuesMax, + }, + cellValueMaxBytes: { + code: 'validation.limit.cell_value_max_bytes', + i18nKey: sdkErrorI18nKeys.limit.cellValueMaxBytes, + }, + recordFieldsMaxBytes: { + code: 'validation.limit.record_fields_max_bytes', + i18nKey: sdkErrorI18nKeys.limit.recordFieldsMaxBytes, + }, + recordsPerMutationMax: { + code: 'validation.limit.records_per_mutation_max', + i18nKey: sdkErrorI18nKeys.limit.recordsPerMutationMax, + }, + computedCellValueMaxBytes: { + code: 'validation.limit.computed_cell_value_max_bytes', + i18nKey: sdkErrorI18nKeys.limit.computedCellValueMaxBytes, + }, + formulaMaxLength: { + code: 'validation.limit.formula_max_length', + i18nKey: sdkErrorI18nKeys.limit.formulaMaxLength, + }, + tablesPerBaseMax: { + code: 'validation.limit.tables_per_base_max', + i18nKey: sdkErrorI18nKeys.limit.tablesPerBaseMax, + }, + fieldsPerTableMax: { + code: 'validation.limit.fields_per_table_max', + i18nKey: sdkErrorI18nKeys.limit.fieldsPerTableMax, + }, + rowsPerTableMax: { + code: 'validation.limit.rows_per_table_max', + i18nKey: sdkErrorI18nKeys.limit.rowsPerTableMax, + }, + viewsPerTableMax: { + code: 'validation.limit.views_per_table_max', + i18nKey: sdkErrorI18nKeys.limit.viewsPerTableMax, + }, + createTableFieldsMax: { + code: 'validation.limit.create_table_fields_max', + i18nKey: sdkErrorI18nKeys.limit.createTableFieldsMax, + }, + createTableViewsMax: { + code: 'validation.limit.create_table_views_max', + i18nKey: sdkErrorI18nKeys.limit.createTableViewsMax, + }, + createTableRecordsMax: { + code: 'validation.limit.create_table_records_max', + i18nKey: sdkErrorI18nKeys.limit.createTableRecordsMax, + }, + viewFilterItemsMax: { + code: 'validation.limit.view_filter_items_max', + i18nKey: sdkErrorI18nKeys.limit.viewFilterItemsMax, + }, + viewFilterDepthMax: { + code: 'validation.limit.view_filter_depth_max', + i18nKey: sdkErrorI18nKeys.limit.viewFilterDepthMax, + }, + viewSortItemsMax: { + code: 'validation.limit.view_sort_items_max', + i18nKey: sdkErrorI18nKeys.limit.viewSortItemsMax, + }, + viewGroupItemsMax: { + code: 'validation.limit.view_group_items_max', + i18nKey: sdkErrorI18nKeys.limit.viewGroupItemsMax, + }, + viewOptionsMaxBytes: { + code: 'validation.limit.view_options_max_bytes', + i18nKey: sdkErrorI18nKeys.limit.viewOptionsMaxBytes, + }, + nameMaxLength: { + code: 'validation.limit.name_max_length', + i18nKey: sdkErrorI18nKeys.limit.nameMaxLength, + }, + descriptionMaxLength: { + code: 'validation.limit.description_max_length', + i18nKey: sdkErrorI18nKeys.limit.descriptionMaxLength, + }, +} as const satisfies Record; + export const ensureWithinTableDataSafetyLimit = ( - code: string, + limit: ITableDataSafetyLimitError, attempted: number, max: number | undefined, details: Readonly> = {} @@ -144,13 +251,14 @@ export const ensureWithinTableDataSafetyLimit = ( return err( domainError.validation({ - code, - message: `Table data safety limit exceeded: ${code}`, + code: limit.code, + message: `Table data safety limit exceeded: ${limit.code}`, details: { ...details, attempted, max, }, + localization: { i18nKey: limit.i18nKey, context: { max } }, }) ); }; diff --git a/packages/v2/core/src/domain/table/Table.createRecordInputSchema.spec.ts b/packages/v2/core/src/domain/table/Table.createRecordInputSchema.spec.ts index 34445b1066..fb741ccf74 100644 --- a/packages/v2/core/src/domain/table/Table.createRecordInputSchema.spec.ts +++ b/packages/v2/core/src/domain/table/Table.createRecordInputSchema.spec.ts @@ -713,9 +713,9 @@ describe('Table.createRecordInputSchema', () => { expect(schema.safeParse({ [fieldId]: validLink }).success).toBe(true); expect(schema.safeParse({ [fieldId]: linkWithoutTitle }).success).toBe(true); expect(schema.safeParse({ [fieldId]: null }).success).toBe(true); - - // Invalid values - expect(schema.safeParse({ [fieldId]: [validLink] }).success).toBe(false); // array not allowed + // V1 compatibility: array input is accepted and normalized to the first item + expect(schema.safeParse({ [fieldId]: [validLink] }).success).toBe(true); + expect(schema.safeParse({ [fieldId]: [validLink] }).data?.[fieldId]).toEqual(validLink); expect(schema.safeParse({ [fieldId]: { title: 'No ID' } }).success).toBe(false); }); @@ -746,9 +746,9 @@ describe('Table.createRecordInputSchema', () => { expect(schema.safeParse({ [fieldId]: [validLink] }).success).toBe(true); expect(schema.safeParse({ [fieldId]: [validLink, linkWithoutTitle] }).success).toBe(true); expect(schema.safeParse({ [fieldId]: null }).success).toBe(true); - - // Invalid values - expect(schema.safeParse({ [fieldId]: validLink }).success).toBe(false); // not array + // V1 compatibility: single object input is accepted and wrapped as an array + expect(schema.safeParse({ [fieldId]: validLink }).success).toBe(true); + expect(schema.safeParse({ [fieldId]: validLink }).data?.[fieldId]).toEqual([validLink]); }); it('generates schema for required link field', () => { diff --git a/packages/v2/core/src/domain/table/Table.spec.ts b/packages/v2/core/src/domain/table/Table.spec.ts index 1afa77d021..3eec5c9ff3 100644 --- a/packages/v2/core/src/domain/table/Table.spec.ts +++ b/packages/v2/core/src/domain/table/Table.spec.ts @@ -1567,7 +1567,8 @@ describe('Table.createRecord with default values', () => { expect(record.fields().get(textFieldId._unsafeUnwrap())?.toValue()).toBe('Default Text'); expect(record.fields().get(numberFieldId._unsafeUnwrap())?.toValue()).toBe(50); - expect(record.fields().get(checkboxFieldId._unsafeUnwrap())?.toValue()).toBe(false); + // v1 contract: checkbox false normalizes to null in storage + expect(record.fields().get(checkboxFieldId._unsafeUnwrap())?.toValue()).toBeNull(); }); it('mixes explicit values with default values', () => { diff --git a/packages/v2/core/src/domain/table/Table.ts b/packages/v2/core/src/domain/table/Table.ts index 0f223317c1..f50d1704d8 100644 --- a/packages/v2/core/src/domain/table/Table.ts +++ b/packages/v2/core/src/domain/table/Table.ts @@ -3,6 +3,7 @@ import { err, ok } from 'neverthrow'; import type { Result } from 'neverthrow'; import { z } from 'zod'; import { type ITableMapper } from '../../ports/mappers/TableMapper'; +import type { RecordFilter } from '../../queries/RecordFilterDto'; import type { BaseId } from '../base/BaseId'; import { AggregateRoot } from '../shared/AggregateRoot'; import type { IDomainContext } from '../shared/DomainContext'; @@ -45,11 +46,66 @@ import { LinkForeignTableReferenceVisitor, type LinkForeignTableReference, } from './fields/visitors/LinkForeignTableReferenceVisitor'; +import { + applyViewManualSort as applyViewManualSortMethod, + type ApplyViewManualSortMethodResult, +} from './methods/applyViewManualSort'; +import { + applyViewSnapshot as applyViewSnapshotMethod, + type ApplyViewSnapshotMethodResult, +} from './methods/applyViewSnapshot'; +import { + createButtonClickPlan as createButtonClickPlanMethod, + type ButtonClickPlan, + type CreateButtonClickPlanParams, +} from './methods/createButtonClickPlan'; +import { + createCollapsedGroupExclusionFilter as createCollapsedGroupExclusionFilterMethod, + type CollapsedGroupValueRow, +} from './methods/createCollapsedGroupExclusionFilter'; +import { + createRecordAggregation as createRecordAggregationMethod, + type CreateRecordAggregationParams, +} from './methods/createRecordAggregation'; +import { + createRecordCalendarDailyCollection as createRecordCalendarDailyCollectionMethod, + type CreateRecordCalendarDailyCollectionParams, +} from './methods/createRecordCalendarDailyCollection'; +import { + createView as createViewMethod, + type CreateViewMethodParams, + type CreateViewMethodResult, +} from './methods/createView'; +import { + createViewCollaboratorsQueryPlan as createViewCollaboratorsQueryPlanMethod, + type CreateViewCollaboratorsQueryPlanParams, + type ViewCollaboratorsQueryPlan, +} from './methods/createViewCollaboratorsQueryPlan'; +import { + createViewLinkRecordsQueryPlan as createViewLinkRecordsQueryPlanMethod, + type CreateViewLinkRecordsQueryPlanParams, + type ViewLinkRecordsQueryPlan, +} from './methods/createViewLinkRecordsQueryPlan'; +import { + createViewSelectionCopyPlan as createViewSelectionCopyPlanMethod, + type CreateViewSelectionCopyPlanParams, + type ViewSelectionCopyPlan, +} from './methods/createViewSelectionCopyPlan'; +import { + clearViewFilterDependencies as clearViewFilterDependenciesMethod, + deleteView as deleteViewMethod, + type DeleteViewMethodResult, +} from './methods/deleteView'; import { duplicate as duplicateMethod, type DuplicateMethodParams as TableDuplicateParams, type DuplicateMethodResult as TableDuplicateResult, } from './methods/duplicate'; +import { + duplicateView as duplicateViewMethod, + type DuplicateViewMethodOptions, + type DuplicateViewMethodResult, +} from './methods/duplicateView'; import { getOrderedVisibleFieldIds as getOrderedVisibleFieldIdsMethod, type GetOrderedVisibleFieldIdsOptions, @@ -67,12 +123,75 @@ import { type UpdateRecordOptions, type UpdateRecordsStreamOptions, } from './methods/records'; +import { + refreshViewShareId as refreshViewShareIdMethod, + type RefreshViewShareIdMethodResult, +} from './methods/refreshViewShareId'; import { rename as renameMethod } from './methods/rename'; +import { renameView as renameViewMethod, type RenameViewMethodResult } from './methods/renameView'; +import { + resetButtonValue as resetButtonValueMethod, + type ResetButtonValueParams, +} from './methods/resetButtonValue'; +import { + setButtonValue as setButtonValueMethod, + type SetButtonValueParams, +} from './methods/setButtonValue'; +import { updateProperties as updatePropertiesMethod } from './methods/updateProperties'; +import { + updateViewColumnMeta as updateViewColumnMetaMethod, + type UpdateViewColumnMetaMethodResult, +} from './methods/updateViewColumnMeta'; +import { + updateViewDescription as updateViewDescriptionMethod, + type UpdateViewDescriptionMethodResult, +} from './methods/updateViewDescription'; +import { + updateViewFilter as updateViewFilterMethod, + type UpdateViewFilterMethodResult, +} from './methods/updateViewFilter'; +import { + updateViewGroup as updateViewGroupMethod, + type UpdateViewGroupMethodResult, +} from './methods/updateViewGroup'; +import { + updateViewLocked as updateViewLockedMethod, + type UpdateViewLockedMethodResult, +} from './methods/updateViewLocked'; +import { + updateViewOptions as updateViewOptionsMethod, + type UpdateViewOptionsMethodResult, +} from './methods/updateViewOptions'; +import { + updateViewOrder as updateViewOrderMethod, + type UpdateViewOrderMethodResult, + type ViewOrderPosition, +} from './methods/updateViewOrder'; +import { + updateViewShareMeta as updateViewShareMetaMethod, + type UpdateViewShareMetaMethodResult, +} from './methods/updateViewShareMeta'; +import { + disableViewShare as disableViewShareMethod, + enableViewShare as enableViewShareMethod, + type TableDisableViewShareResult, + type TableEnableViewShareResult, +} from './methods/updateViewShareState'; +import { + updateViewSort as updateViewSortMethod, + type UpdateViewSortMethodResult, +} from './methods/updateViewSort'; import { validateFormSubmission as validateFormSubmissionMethod } from './methods/validateFormSubmission'; +import { + viewFilterLinkReferences as viewFilterLinkReferencesMethod, + type ViewFilterLinkReference, +} from './methods/viewFilterLinkReferences'; import type { RecordCreateResult } from './records/RecordCreateResult'; import type { RecordId } from './records/RecordId'; import type { RecordUpdateResult } from './records/RecordUpdateResult'; import type { TableRecord } from './records/TableRecord'; +import type { TableRecordAggregation } from './records/TableRecordAggregation'; +import type { TableRecordCalendarDailyCollection } from './records/TableRecordCalendarDailyCollection'; import { resolveFormulaFields } from './resolveFormulaFields'; import type { ITableSpecVisitor } from './specs/ITableSpecVisitor'; import { TableSpecBuilder } from './specs/TableSpecBuilder'; @@ -81,11 +200,38 @@ import { TableBuilder } from './TableBuilder'; import type { TableId } from './TableId'; import { TableMutator, type TableUpdateResult } from './TableMutator'; import type { TableName } from './TableName'; +import { TableProperties, type TablePropertiesPatch } from './TableProperties'; import type { View } from './views/View'; -import { ViewColumnMeta, type ViewColumnMetaEntry } from './views/ViewColumnMeta'; +import { + ViewColumnMeta, + type ViewColumnMetaEntry, + type ViewColumnMetaPatch, +} from './views/ViewColumnMeta'; import type { ViewId } from './views/ViewId'; +import type { ViewName } from './views/ViewName'; +import type { ViewQueryGroupItem } from './views/ViewQueryDefaults'; import { CloneViewVisitor } from './views/visitors/CloneViewVisitor'; +export type TableCreateViewInput = CreateViewMethodParams; +export type TableCreateViewResult = CreateViewMethodResult; +export type TableDeleteViewResult = DeleteViewMethodResult; +export type TableDuplicateViewOptions = DuplicateViewMethodOptions; +export type TableDuplicateViewResult = DuplicateViewMethodResult; +export type TableRenameViewResult = RenameViewMethodResult; +export type TableUpdateViewDescriptionResult = UpdateViewDescriptionMethodResult; +export type TableUpdateViewLockedResult = UpdateViewLockedMethodResult; +export type TableUpdateViewOrderResult = UpdateViewOrderMethodResult; +export type TableUpdateViewOptionsResult = UpdateViewOptionsMethodResult; +export type TableUpdateViewColumnMetaResult = UpdateViewColumnMetaMethodResult; +export type TableUpdateViewFilterResult = UpdateViewFilterMethodResult; +export type TableUpdateViewGroupResult = UpdateViewGroupMethodResult; +export type TableUpdateViewSortResult = UpdateViewSortMethodResult; +export type TableRefreshViewShareIdResult = RefreshViewShareIdMethodResult; +export type TableApplyViewManualSortResult = ApplyViewManualSortMethodResult; +export type TableApplyViewSnapshotResult = ApplyViewSnapshotMethodResult; +export type TableViewFilterLinkReference = ViewFilterLinkReference; +export type TableButtonClickPlan = ButtonClickPlan; + export class Table extends AggregateRoot { private dbTableNameValue: DbTableName; @@ -93,6 +239,7 @@ export class Table extends AggregateRoot { id: TableId, private readonly baseIdValue: BaseId, private readonly nameValue: TableName, + private readonly propertiesValue: TableProperties, private readonly fieldsValue: ReadonlyArray, private readonly viewsValue: ReadonlyArray, private readonly primaryFieldIdValue: FieldId, @@ -120,6 +267,7 @@ export class Table extends AggregateRoot { props.id, props.baseId, props.name, + props.properties ?? TableProperties.empty(), props.fields, props.views, props.primaryFieldId, @@ -148,6 +296,7 @@ export class Table extends AggregateRoot { props.id, props.baseId, props.name, + props.properties ?? TableProperties.empty(), props.fields, props.views, props.primaryFieldId, @@ -172,6 +321,18 @@ export class Table extends AggregateRoot { return this.nameValue; } + properties(): TableProperties { + return this.propertiesValue; + } + + description(): string | undefined { + return this.propertiesValue.description(); + } + + icon(): string | undefined { + return this.propertiesValue.icon(); + } + dbTableName(): Result { const valueResult = this.dbTableNameValue.value(); if (valueResult.isErr()) return err(valueResult.error); @@ -271,6 +432,19 @@ export class Table extends AggregateRoot { return [...this.viewsValue]; } + defaultView(): Result { + const view = this.viewsValue[0]; + if (!view) { + return err( + domainError.notFound({ + code: 'view.not_found', + message: `View not found with tableId: ${this.id().toString()}`, + }) + ); + } + return ok(view); + } + /** * Get a view by its ID. * @param viewId - The view ID to find @@ -307,6 +481,12 @@ export class Table extends AggregateRoot { return ok(view); } + viewFilterLinkReferences( + viewId: ViewId + ): Result, DomainError> { + return viewFilterLinkReferencesMethod.call(this, viewId); + } + /** * Get ordered visible field IDs for a view. * @@ -324,6 +504,49 @@ export class Table extends AggregateRoot { return getOrderedVisibleFieldIdsMethod.call(this, viewId, options); } + createRecordAggregation( + params: CreateRecordAggregationParams + ): Result { + return createRecordAggregationMethod.call(this, params); + } + + createRecordCalendarDailyCollection( + params: CreateRecordCalendarDailyCollectionParams + ): Result { + return createRecordCalendarDailyCollectionMethod.call(this, params); + } + + createViewLinkRecordsQueryPlan( + params: CreateViewLinkRecordsQueryPlanParams + ): Result { + return createViewLinkRecordsQueryPlanMethod.call(this, params); + } + + createViewCollaboratorsQueryPlan( + params: CreateViewCollaboratorsQueryPlanParams + ): Result { + return createViewCollaboratorsQueryPlanMethod.call(this, params); + } + + createViewSelectionCopyPlan( + params: CreateViewSelectionCopyPlanParams + ): Result { + return createViewSelectionCopyPlanMethod.call(this, params); + } + + createCollapsedGroupExclusionFilter( + groupBy: ReadonlyArray, + groupedRows: ReadonlyArray, + collapsedGroupIds: ReadonlySet + ): Result { + return createCollapsedGroupExclusionFilterMethod.call( + this, + groupBy, + groupedRows, + collapsedGroupIds + ); + } + validateFormSubmission( formId: string, fieldValues: ReadonlyMap @@ -535,6 +758,18 @@ export class Table extends AggregateRoot { return createRecordMethod.call(this, fieldValues, options); } + createButtonClickPlan(params: CreateButtonClickPlanParams): Result { + return createButtonClickPlanMethod.call(this, params); + } + + setButtonValue(params: SetButtonValueParams): Result { + return setButtonValueMethod.call(this, params); + } + + resetButtonValue(params: ResetButtonValueParams): Result { + return resetButtonValueMethod.call(this, params); + } + /** * Update a record with the given field values. * @@ -753,6 +988,113 @@ export class Table extends AggregateRoot { return mutator.apply(); } + createView(input: TableCreateViewInput): Result { + return createViewMethod.call(this, input); + } + + applyViewSnapshot(snapshotView: View): Result { + return applyViewSnapshotMethod.call(this, snapshotView); + } + + deleteView(viewId: ViewId): Result { + return deleteViewMethod.call(this, viewId); + } + + duplicateView( + sourceViewId: ViewId, + options?: TableDuplicateViewOptions + ): Result { + return duplicateViewMethod.call(this, sourceViewId, options); + } + + renameView(viewId: ViewId, nextName: ViewName): Result { + return renameViewMethod.call(this, viewId, nextName); + } + + updateViewDescription( + viewId: ViewId, + nextDescription: string + ): Result { + return updateViewDescriptionMethod.call(this, viewId, nextDescription); + } + + updateViewFilter( + viewId: ViewId, + filter: unknown + ): Result { + return updateViewFilterMethod.call(this, viewId, filter); + } + + updateViewGroup(viewId: ViewId, group: unknown): Result { + return updateViewGroupMethod.call(this, viewId, group); + } + + updateViewOptions( + viewId: ViewId, + patch: unknown + ): Result { + return updateViewOptionsMethod.call(this, viewId, patch); + } + + updateViewShareMeta( + viewId: ViewId, + shareMeta: unknown + ): Result { + return updateViewShareMetaMethod.call(this, viewId, shareMeta); + } + + refreshViewShareId(viewId: ViewId): Result { + return refreshViewShareIdMethod.call(this, viewId); + } + + enableViewShare(viewId: ViewId): Result { + return enableViewShareMethod.call(this, viewId); + } + + disableViewShare(viewId: ViewId): Result { + return disableViewShareMethod.call(this, viewId); + } + + updateViewSort(viewId: ViewId, sort: unknown): Result { + return updateViewSortMethod.call(this, viewId, sort); + } + + applyViewManualSort( + viewId: ViewId, + sort: unknown + ): Result { + return applyViewManualSortMethod.call(this, viewId, sort); + } + + updateViewLocked( + viewId: ViewId, + nextIsLocked: boolean | undefined + ): Result { + return updateViewLockedMethod.call(this, viewId, nextIsLocked); + } + + updateViewOrder( + sourceViewId: ViewId, + anchorViewId: ViewId, + position: ViewOrderPosition + ): Result { + return updateViewOrderMethod.call(this, sourceViewId, anchorViewId, position); + } + + updateViewColumnMeta( + viewId: ViewId, + patches: ReadonlyArray + ): Result { + return updateViewColumnMetaMethod.call(this, viewId, patches); + } + + clearViewFilterDependencies( + viewId: ViewId, + fieldIds: ReadonlyArray + ): Result { + return clearViewFilterDependenciesMethod.call(this, viewId, fieldIds); + } + updateField( fieldId: FieldId, buildSpecs: ( @@ -825,6 +1167,10 @@ export class Table extends AggregateRoot { return renameMethod.call(this, nextName); } + updateProperties(patch: TablePropertiesPatch): Result { + return updatePropertiesMethod.call(this, patch); + } + addField( field: Field, options?: { @@ -874,6 +1220,7 @@ export class Table extends AggregateRoot { id: this.id(), baseId: this.baseIdValue, name: this.nameValue, + properties: this.propertiesValue, fields: nextFields, views: nextViewsResult.value, primaryFieldId: this.primaryFieldIdValue, @@ -895,6 +1242,64 @@ export class Table extends AggregateRoot { }); } + addView(view: View): Result { + if (this.viewsValue.some((existing) => existing.id().equals(view.id()))) { + return err(domainError.conflict({ message: 'View already exists' })); + } + if (this.viewsValue.some((existing) => existing.name().equals(view.name()))) { + return err(domainError.conflict({ message: 'View names must be unique' })); + } + const columnMetaResult = view.columnMeta(); + if (columnMetaResult.isErr()) return err(columnMetaResult.error); + const queryDefaultsResult = view.queryDefaults(); + if (queryDefaultsResult.isErr()) return err(queryDefaultsResult.error); + + const props: ITableBuildProps = { + id: this.id(), + baseId: this.baseIdValue, + name: this.nameValue, + properties: this.propertiesValue, + fields: this.fieldsValue, + views: [...this.viewsValue, view], + primaryFieldId: this.primaryFieldIdValue, + }; + if (this.dbTableNameValue.isRehydrated()) props.dbTableName = this.dbTableNameValue; + return Table.rehydrate(props); + } + + removeView(viewId: ViewId): Result { + if (this.viewsValue.length <= 1) { + return err( + domainError.validation({ + code: 'view.cannot_delete_last', + message: 'Cannot delete the last view in a table. A table must have at least one view.', + }) + ); + } + + const targetView = this.viewsValue.find((view) => view.id().equals(viewId)); + if (!targetView) { + return err( + domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${viewId.toString()}`, + }) + ); + } + + const props: ITableBuildProps = { + id: this.id(), + baseId: this.baseIdValue, + name: this.nameValue, + properties: this.propertiesValue, + fields: this.fieldsValue, + views: this.viewsValue.filter((view) => !view.id().equals(viewId)), + primaryFieldId: this.primaryFieldIdValue, + }; + if (this.dbTableNameValue.isRehydrated()) props.dbTableName = this.dbTableNameValue; + return Table.rehydrate(props); + } + removeField(fieldId: FieldId): Result { if (this.primaryFieldIdValue.equals(fieldId)) { return err( @@ -919,6 +1324,7 @@ export class Table extends AggregateRoot { id: this.id(), baseId: this.baseIdValue, name: this.nameValue, + properties: this.propertiesValue, fields: nextFields, views: nextViewsResult.value, primaryFieldId: this.primaryFieldIdValue, @@ -1028,6 +1434,7 @@ export class Table extends AggregateRoot { id: this.id(), baseId: this.baseIdValue, name: this.nameValue, + properties: this.propertiesValue, fields: nextFields, views: this.viewsValue, primaryFieldId: this.primaryFieldIdValue, @@ -1090,6 +1497,7 @@ export class Table extends AggregateRoot { id: this.id(), baseId: this.baseIdValue, name: this.nameValue, + properties: this.propertiesValue, fields: nextFields, views: this.viewsValue, primaryFieldId: this.primaryFieldIdValue, @@ -1181,6 +1589,7 @@ export class Table extends AggregateRoot { id: this.id(), baseId: this.baseIdValue, name: this.nameValue, + properties: this.propertiesValue, fields: nextFields, views: this.viewsValue, primaryFieldId: this.primaryFieldIdValue, diff --git a/packages/v2/core/src/domain/table/TableBuilder.ts b/packages/v2/core/src/domain/table/TableBuilder.ts index b34be2ee50..a78d053e03 100644 --- a/packages/v2/core/src/domain/table/TableBuilder.ts +++ b/packages/v2/core/src/domain/table/TableBuilder.ts @@ -11,8 +11,8 @@ import type { FieldName } from './fields/FieldName'; import { validateForeignTablesForFields } from './fields/ForeignTableRelatedField'; import { AttachmentField } from './fields/types/AttachmentField'; import { AutoNumberField } from './fields/types/AutoNumberField'; -import { ButtonField } from './fields/types/ButtonField'; import type { ButtonConfirm } from './fields/types/ButtonConfirm'; +import { ButtonField } from './fields/types/ButtonField'; import { ButtonLabel } from './fields/types/ButtonLabel'; import type { ButtonMaxCount } from './fields/types/ButtonMaxCount'; import type { ButtonResetCount } from './fields/types/ButtonResetCount'; @@ -79,6 +79,7 @@ import { resolveFormulaFields } from './resolveFormulaFields'; import type { Table } from './Table'; import { TableId } from './TableId'; import type { TableName } from './TableName'; +import type { TableProperties } from './TableProperties'; import { CalendarView } from './views/types/CalendarView'; import { FormView } from './views/types/FormView'; import { GalleryView } from './views/types/GalleryView'; @@ -95,6 +96,7 @@ export interface ITableBuildProps { id: TableId; baseId: BaseId; name: TableName; + properties?: TableProperties; fields: ReadonlyArray; views: ReadonlyArray; primaryFieldId: FieldId; diff --git a/packages/v2/core/src/domain/table/TableFieldLimit.ts b/packages/v2/core/src/domain/table/TableFieldLimit.ts index f32c582a70..b017c0b504 100644 --- a/packages/v2/core/src/domain/table/TableFieldLimit.ts +++ b/packages/v2/core/src/domain/table/TableFieldLimit.ts @@ -1,4 +1,4 @@ -import { tableI18nKeys } from '@teable/i18n-keys'; +import { sdkErrorI18nKeys } from '@teable/i18n-keys'; import { err, ok } from 'neverthrow'; import type { Result } from 'neverthrow'; @@ -44,23 +44,10 @@ export const buildTableFieldLimitFallbackMessage = ( maxFieldCount = DEFAULT_MAX_TABLE_FIELD_COUNT ): string => `Table "${tableName}" can have at most ${maxFieldCount} fields.`; -export const buildTableFieldLimitMessage = ( - details: TableFieldLimitErrorDetails, - domainContext?: IDomainContext -): string => { - if (!domainContext?.t) { - return buildTableFieldLimitFallbackMessage(details.tableName, details.maxFieldCount); - } - - try { - return domainContext.t(tableI18nKeys.validation.field.maxColumnLimit, { - tableName: details.tableName, - maxFieldCount: details.maxFieldCount, - }); - } catch { - return buildTableFieldLimitFallbackMessage(details.tableName, details.maxFieldCount); - } -}; +const buildTableFieldLimitLocalization = (details: TableFieldLimitErrorDetails) => ({ + i18nKey: sdkErrorI18nKeys.custom.fieldMaxColumnLimit, + context: { tableName: details.tableName, maxFieldCount: details.maxFieldCount }, +}); export const ensureTableFieldCountWithinLimit = ( table: Table, @@ -81,8 +68,9 @@ export const ensureTableFieldCountWithinLimit = ( return err( domainError.validation({ code: TABLE_FIELD_LIMIT_ERROR_CODE, - message: buildTableFieldLimitMessage(details, options?.domainContext), + message: buildTableFieldLimitFallbackMessage(details.tableName, details.maxFieldCount), details, + localization: buildTableFieldLimitLocalization(details), }) ); }; @@ -102,7 +90,10 @@ export const createTableFieldLimitExceededError = ( ); return domainError.validation({ code: TABLE_FIELD_LIMIT_ERROR_CODE, - message: options?.message ?? buildTableFieldLimitMessage(details, options?.domainContext), + message: + options?.message ?? + buildTableFieldLimitFallbackMessage(details.tableName, details.maxFieldCount), details, + localization: buildTableFieldLimitLocalization(details), }); }; diff --git a/packages/v2/core/src/domain/table/TableMutator.ts b/packages/v2/core/src/domain/table/TableMutator.ts index 4cf6606541..3b5a773d4f 100644 --- a/packages/v2/core/src/domain/table/TableMutator.ts +++ b/packages/v2/core/src/domain/table/TableMutator.ts @@ -17,14 +17,45 @@ import type { ITableSpecVisitor } from './specs/ITableSpecVisitor'; import { TableAddFieldSpec } from './specs/TableAddFieldSpec'; import { TableAddFieldsSpec } from './specs/TableAddFieldsSpec'; import { TableAddSelectOptionsSpec } from './specs/TableAddSelectOptionsSpec'; +import { TableAddViewSpec } from './specs/TableAddViewSpec'; import { TableDuplicateFieldSpec } from './specs/TableDuplicateFieldSpec'; import { TableRemoveFieldSpec } from './specs/TableRemoveFieldSpec'; +import { TableRemoveViewSpec } from './specs/TableRemoveViewSpec'; import { TableRenameSpec } from './specs/TableRenameSpec'; -import { TableUpdateViewColumnMetaSpec } from './specs/TableUpdateViewColumnMetaSpec'; +import { TableRenameViewSpec } from './specs/TableRenameViewSpec'; +import { TableUpdatePropertiesSpec } from './specs/TableUpdatePropertiesSpec'; +import { + TableUpdateViewColumnMetaSpec, + type TableViewColumnMetaUpdate, +} from './specs/TableUpdateViewColumnMetaSpec'; +import { TableUpdateViewDescriptionSpec } from './specs/TableUpdateViewDescriptionSpec'; +import { TableUpdateViewLockedSpec } from './specs/TableUpdateViewLockedSpec'; +import { + TableUpdateViewOptionsSpec, + type TableViewOptionsUpdate, +} from './specs/TableUpdateViewOptionsSpec'; +import { + TableUpdateViewOrderSpec, + type TableViewOrderChange, +} from './specs/TableUpdateViewOrderSpec'; +import { + TableUpdateViewQueryDefaultsSpec, + type TableViewQueryDefaultsUpdate, +} from './specs/TableUpdateViewQueryDefaultsSpec'; +import { TableUpdateViewShareIdSpec } from './specs/TableUpdateViewShareIdSpec'; +import { TableUpdateViewShareMetaSpec } from './specs/TableUpdateViewShareMetaSpec'; +import { + TableUpdateViewShareStateSpec, + type TableNextViewShareState, +} from './specs/TableUpdateViewShareStateSpec'; import { TableEventGeneratingSpecVisitor } from './specs/visitors/TableEventGeneratingSpecVisitor'; import type { Table } from './Table'; import type { TableName } from './TableName'; +import type { TablePropertiesPatch } from './TableProperties'; +import type { View } from './views/View'; import type { ViewId } from './views/ViewId'; +import type { ViewName } from './views/ViewName'; +import type { ViewShareMetaValue } from './views/ViewProperties'; class TableMutateSpecBuilder extends SpecBuilder { private constructor(private currentTable: Table) { @@ -48,6 +79,20 @@ class TableMutateSpecBuilder extends SpecBuilder): TableMutateSpecBuilder { + const spec = TableUpdateViewOrderSpec.create(changes); + const nextTableResult = spec.mutate(this.currentTable); + if (nextTableResult.isErr()) { + this.recordError(nextTableResult.error); + return this; + } + this.addSpec(spec); + this.currentTable = nextTableResult.value; + return this; + } + + updateViewColumnMeta(update: TableViewColumnMetaUpdate): TableMutateSpecBuilder { + const spec = TableUpdateViewColumnMetaSpec.create([update]); + const nextTableResult = spec.mutate(this.currentTable); + if (nextTableResult.isErr()) { + this.recordError(nextTableResult.error); + return this; + } + this.addSpec(spec); + this.currentTable = nextTableResult.value; + return this; + } + + updateViewOptions(update: TableViewOptionsUpdate): TableMutateSpecBuilder { + const spec = TableUpdateViewOptionsSpec.create(update); + const nextTableResult = spec.mutate(this.currentTable); + if (nextTableResult.isErr()) { + this.recordError(nextTableResult.error); + return this; + } + this.addSpec(spec); + this.currentTable = nextTableResult.value; + return this; + } + + updateViewShareMeta( + viewId: ViewId, + nextShareMeta: ViewShareMetaValue | undefined + ): TableMutateSpecBuilder { + const viewResult = this.currentTable.getView(viewId); + if (viewResult.isErr()) { + this.recordError(viewResult.error); + return this; + } + + const spec = TableUpdateViewShareMetaSpec.create( + viewId, + viewResult.value.shareMeta(), + nextShareMeta + ); + const nextTableResult = spec.mutate(this.currentTable); + if (nextTableResult.isErr()) { + this.recordError(nextTableResult.error); + return this; + } + this.addSpec(spec); + this.currentTable = nextTableResult.value; + return this; + } + + updateViewShareId(viewId: ViewId, nextShareId: string): TableMutateSpecBuilder { + const viewResult = this.currentTable.getView(viewId); + if (viewResult.isErr()) { + this.recordError(viewResult.error); + return this; + } + + const spec = TableUpdateViewShareIdSpec.create(viewId, viewResult.value.shareId(), nextShareId); + const nextTableResult = spec.mutate(this.currentTable); + if (nextTableResult.isErr()) { + this.recordError(nextTableResult.error); + return this; + } + this.addSpec(spec); + this.currentTable = nextTableResult.value; + return this; + } + + updateViewShareState(viewId: ViewId, nextState: TableNextViewShareState): TableMutateSpecBuilder { + const viewResult = this.currentTable.getView(viewId); + if (viewResult.isErr()) { + this.recordError(viewResult.error); + return this; + } + + const view = viewResult.value; + const spec = TableUpdateViewShareStateSpec.create( + viewId, + { + enableShare: view.enableShare() === true, + shareId: view.shareId(), + shareMeta: view.shareMeta(), + }, + nextState + ); + const nextTableResult = spec.mutate(this.currentTable); + if (nextTableResult.isErr()) { + this.recordError(nextTableResult.error); + return this; + } + this.addSpec(spec); + this.currentTable = nextTableResult.value; + return this; + } + + updateViewQueryDefaults(update: TableViewQueryDefaultsUpdate): TableMutateSpecBuilder { + const spec = TableUpdateViewQueryDefaultsSpec.create([update]); + const nextTableResult = spec.mutate(this.currentTable); + if (nextTableResult.isErr()) { + this.recordError(nextTableResult.error); + return this; + } + this.addSpec(spec); + this.currentTable = nextTableResult.value; + return this; + } + addFields( fields: ReadonlyArray, options?: { @@ -415,6 +675,12 @@ export class TableMutator { return this; } + updateProperties(patch: TablePropertiesPatch): TableMutator { + this.builder.updateProperties(patch); + this.hasUpdates = true; + return this; + } + addField( field: Field, options?: { @@ -431,6 +697,78 @@ export class TableMutator { return this; } + addView(view: View): TableMutator { + this.builder.addView(view); + this.hasUpdates = true; + return this; + } + + removeView(viewId: ViewId): TableMutator { + this.builder.removeView(viewId); + this.hasUpdates = true; + return this; + } + + renameView(viewId: ViewId, nextName: ViewName): TableMutator { + this.builder.renameView(viewId, nextName); + this.hasUpdates = true; + return this; + } + + updateViewDescription(viewId: ViewId, nextDescription: string | undefined): TableMutator { + this.builder.updateViewDescription(viewId, nextDescription); + this.hasUpdates = true; + return this; + } + + updateViewLocked(viewId: ViewId, nextIsLocked: boolean | undefined): TableMutator { + this.builder.updateViewLocked(viewId, nextIsLocked); + this.hasUpdates = true; + return this; + } + + updateViewOrder(changes: ReadonlyArray): TableMutator { + this.builder.updateViewOrder(changes); + this.hasUpdates = true; + return this; + } + + updateViewColumnMeta(update: TableViewColumnMetaUpdate): TableMutator { + this.builder.updateViewColumnMeta(update); + this.hasUpdates = true; + return this; + } + + updateViewOptions(update: TableViewOptionsUpdate): TableMutator { + this.builder.updateViewOptions(update); + this.hasUpdates = true; + return this; + } + + updateViewShareMeta(viewId: ViewId, nextShareMeta: ViewShareMetaValue | undefined): TableMutator { + this.builder.updateViewShareMeta(viewId, nextShareMeta); + this.hasUpdates = true; + return this; + } + + updateViewShareId(viewId: ViewId, nextShareId: string): TableMutator { + this.builder.updateViewShareId(viewId, nextShareId); + this.hasUpdates = true; + return this; + } + + updateViewShareState(viewId: ViewId, nextState: TableNextViewShareState): TableMutator { + this.builder.updateViewShareState(viewId, nextState); + this.hasUpdates = true; + return this; + } + + updateViewQueryDefaults(update: TableViewQueryDefaultsUpdate): TableMutator { + this.builder.updateViewQueryDefaults(update); + this.hasUpdates = true; + return this; + } + addFields( fields: ReadonlyArray, options?: { diff --git a/packages/v2/core/src/domain/table/TableProperties.ts b/packages/v2/core/src/domain/table/TableProperties.ts new file mode 100644 index 0000000000..94605039a2 --- /dev/null +++ b/packages/v2/core/src/domain/table/TableProperties.ts @@ -0,0 +1,70 @@ +import { err, ok, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../shared/DomainError'; +import { ValueObject } from '../shared/ValueObject'; + +const tablePropertiesSchema = z + .object({ + description: z.string().optional(), + icon: z.string().emoji().optional(), + }) + .strict(); + +export type TablePropertiesValue = z.infer; +export type TablePropertiesPatch = { + readonly description?: string | null; + readonly icon?: string | null; +}; + +export class TableProperties extends ValueObject { + private constructor(private readonly value: TablePropertiesValue) { + super(); + } + + static create(raw: unknown): Result { + const parsed = tablePropertiesSchema.safeParse(raw ?? {}); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid TableProperties', + details: { issues: parsed.error.issues }, + }) + ); + } + return ok(new TableProperties(parsed.data)); + } + + static empty(): TableProperties { + return new TableProperties({}); + } + + description(): string | undefined { + return this.value.description; + } + + icon(): string | undefined { + return this.value.icon; + } + + withPatch(patch: TablePropertiesPatch): Result { + const next = this.toDto(); + if ('description' in patch) { + if (patch.description == null) delete next.description; + else next.description = patch.description; + } + if ('icon' in patch) { + if (patch.icon == null) delete next.icon; + else next.icon = patch.icon; + } + return TableProperties.create(next); + } + + toDto(): TablePropertiesValue { + return { ...this.value }; + } + + equals(other: TableProperties): boolean { + return this.description() === other.description() && this.icon() === other.icon(); + } +} diff --git a/packages/v2/core/src/domain/table/events/ARCHITECTURE.md b/packages/v2/core/src/domain/table/events/ARCHITECTURE.md index b13cda59b7..01ad45ef03 100644 --- a/packages/v2/core/src/domain/table/events/ARCHITECTURE.md +++ b/packages/v2/core/src/domain/table/events/ARCHITECTURE.md @@ -16,6 +16,10 @@ Declaration: If the folder I belong to changes, please update me, especially cor - `FieldCreated.ts` - Role: domain event; Purpose: payload for field creation. - `FieldDeleted.ts` - Role: domain event; Purpose: payload for field deletion. - `ViewColumnMetaUpdated.ts` - Role: domain event; Purpose: payload for view column meta update when field is added/removed. +- `ViewSortUpdated.ts` - Role: domain event; Purpose: carry previous/next public sort payloads and + persisted View versions to v2 projections. +- `ViewManualSortApplied.ts` - Role: domain event; Purpose: notify native v2 projections after + aggregate-authorized record row-order materialization. ## Examples diff --git a/packages/v2/core/src/domain/table/events/ButtonClicked.ts b/packages/v2/core/src/domain/table/events/ButtonClicked.ts new file mode 100644 index 0000000000..c5ffe223bd --- /dev/null +++ b/packages/v2/core/src/domain/table/events/ButtonClicked.ts @@ -0,0 +1,41 @@ +import type { BaseId } from '../../base/BaseId'; +import { DomainEventName } from '../../shared/DomainEventName'; +import { OccurredAt } from '../../shared/OccurredAt'; +import type { FieldId } from '../fields/FieldId'; +import type { RecordId } from '../records/RecordId'; +import type { TableId } from '../TableId'; +import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; + +export class ButtonClicked extends AbstractTableUpdatedEvent { + readonly name = DomainEventName.buttonClicked(); + readonly occurredAt = OccurredAt.now(); + + private constructor( + tableId: TableId, + baseId: BaseId, + readonly recordId: RecordId, + readonly fieldId: FieldId, + readonly count: number, + readonly workflowId: string + ) { + super(tableId, baseId); + } + + static create(params: { + tableId: TableId; + baseId: BaseId; + recordId: RecordId; + fieldId: FieldId; + count: number; + workflowId: string; + }): ButtonClicked { + return new ButtonClicked( + params.tableId, + params.baseId, + params.recordId, + params.fieldId, + params.count, + params.workflowId + ); + } +} diff --git a/packages/v2/core/src/domain/table/events/RecordFieldValuesDTO.ts b/packages/v2/core/src/domain/table/events/RecordFieldValuesDTO.ts index b3c8d68e75..2ab959b2a6 100644 --- a/packages/v2/core/src/domain/table/events/RecordFieldValuesDTO.ts +++ b/packages/v2/core/src/domain/table/events/RecordFieldValuesDTO.ts @@ -21,7 +21,8 @@ export type RecordValuesDTO = { export type RecordCreateSource = | { type: 'user' } | { type: 'form'; formId: string } - | { type: 'tableDuplicate' }; + | { type: 'tableDuplicate' } + | { type: 'import' }; /** Audit source for record mutations whose product action differs from the base source. */ export type RecordAuditSource = 'paste'; diff --git a/packages/v2/core/src/domain/table/events/RecordsDeleted.ts b/packages/v2/core/src/domain/table/events/RecordsDeleted.ts index 4dee70688d..76280c05b6 100644 --- a/packages/v2/core/src/domain/table/events/RecordsDeleted.ts +++ b/packages/v2/core/src/domain/table/events/RecordsDeleted.ts @@ -5,6 +5,14 @@ import type { RecordId } from '../records/RecordId'; import type { TableId } from '../TableId'; import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; +export const RECORD_REMOVAL_REASON = { + Deleted: 'deleted', + Archived: 'archived', +} as const; + +export type IRecordRemovalReason = + (typeof RECORD_REMOVAL_REASON)[keyof typeof RECORD_REMOVAL_REASON]; + /** * Snapshot of a deleted record for undo/redo support. * Contains all necessary data to recreate the record. @@ -41,7 +49,8 @@ export class RecordsDeleted extends AbstractTableUpdatedEvent { baseId: BaseId, readonly recordIds: ReadonlyArray, readonly recordSnapshots: ReadonlyArray, - readonly orchestration?: IRecordsDeletedOrchestration + readonly orchestration?: IRecordsDeletedOrchestration, + readonly removalReason?: IRecordRemovalReason ) { super(tableId, baseId); } @@ -52,13 +61,15 @@ export class RecordsDeleted extends AbstractTableUpdatedEvent { recordIds: ReadonlyArray; recordSnapshots: ReadonlyArray; orchestration?: IRecordsDeletedOrchestration; + removalReason?: IRecordRemovalReason; }): RecordsDeleted { return new RecordsDeleted( params.tableId, params.baseId, params.recordIds, params.recordSnapshots, - params.orchestration + params.orchestration, + params.removalReason ); } } diff --git a/packages/v2/core/src/domain/table/events/TablePropertiesUpdated.ts b/packages/v2/core/src/domain/table/events/TablePropertiesUpdated.ts new file mode 100644 index 0000000000..622057ce83 --- /dev/null +++ b/packages/v2/core/src/domain/table/events/TablePropertiesUpdated.ts @@ -0,0 +1,34 @@ +import type { BaseId } from '../../base/BaseId'; +import { DomainEventName } from '../../shared/DomainEventName'; +import { OccurredAt } from '../../shared/OccurredAt'; +import type { TableId } from '../TableId'; +import type { TableProperties } from '../TableProperties'; +import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; + +export class TablePropertiesUpdated extends AbstractTableUpdatedEvent { + readonly name = DomainEventName.tablePropertiesUpdated(); + readonly occurredAt = OccurredAt.now(); + + private constructor( + tableId: TableId, + baseId: BaseId, + readonly previousProperties: TableProperties, + readonly nextProperties: TableProperties + ) { + super(tableId, baseId); + } + + static create(params: { + tableId: TableId; + baseId: BaseId; + previousProperties: TableProperties; + nextProperties: TableProperties; + }): TablePropertiesUpdated { + return new TablePropertiesUpdated( + params.tableId, + params.baseId, + params.previousProperties, + params.nextProperties + ); + } +} diff --git a/packages/v2/core/src/domain/table/events/ViewColumnMetaUpdated.ts b/packages/v2/core/src/domain/table/events/ViewColumnMetaUpdated.ts index b70372e109..a447cf0bc4 100644 --- a/packages/v2/core/src/domain/table/events/ViewColumnMetaUpdated.ts +++ b/packages/v2/core/src/domain/table/events/ViewColumnMetaUpdated.ts @@ -3,6 +3,7 @@ import { DomainEventName } from '../../shared/DomainEventName'; import { OccurredAt } from '../../shared/OccurredAt'; import type { FieldId } from '../fields/FieldId'; import type { TableId } from '../TableId'; +import type { ViewColumnMetaChange } from '../views/ViewColumnMeta'; import type { ViewId } from '../views/ViewId'; import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; @@ -16,6 +17,11 @@ export class ViewColumnMetaUpdated extends AbstractTableUpdatedEvent { readonly viewId: ViewId, readonly fieldId: FieldId, readonly fieldInColumnMeta: boolean, + readonly changes?: ReadonlyArray, + readonly optionsChange?: { + readonly previousOptions: unknown; + readonly nextOptions: unknown; + }, readonly oldVersion?: number, readonly newVersion?: number ) { @@ -28,6 +34,11 @@ export class ViewColumnMetaUpdated extends AbstractTableUpdatedEvent { viewId: ViewId; fieldId: FieldId; fieldInColumnMeta?: boolean; + changes?: ReadonlyArray; + optionsChange?: { + readonly previousOptions: unknown; + readonly nextOptions: unknown; + }; oldVersion?: number; newVersion?: number; }): ViewColumnMetaUpdated { @@ -37,6 +48,8 @@ export class ViewColumnMetaUpdated extends AbstractTableUpdatedEvent { params.viewId, params.fieldId, params.fieldInColumnMeta ?? true, + params.changes ? [...params.changes] : undefined, + params.optionsChange, params.oldVersion, params.newVersion ); diff --git a/packages/v2/core/src/domain/table/events/ViewCreated.ts b/packages/v2/core/src/domain/table/events/ViewCreated.ts new file mode 100644 index 0000000000..abe0ce0137 --- /dev/null +++ b/packages/v2/core/src/domain/table/events/ViewCreated.ts @@ -0,0 +1,37 @@ +import type { BaseId } from '../../base/BaseId'; +import { DomainEventName } from '../../shared/DomainEventName'; +import { OccurredAt } from '../../shared/OccurredAt'; +import type { TableId } from '../TableId'; +import type { ViewId } from '../views/ViewId'; +import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; + +export class ViewCreated extends AbstractTableUpdatedEvent { + readonly name = DomainEventName.viewCreated(); + readonly occurredAt = OccurredAt.now(); + + private constructor( + tableId: TableId, + baseId: BaseId, + readonly viewId: ViewId, + readonly oldVersion?: number, + readonly newVersion?: number + ) { + super(tableId, baseId); + } + + static create(params: { + tableId: TableId; + baseId: BaseId; + viewId: ViewId; + oldVersion?: number; + newVersion?: number; + }): ViewCreated { + return new ViewCreated( + params.tableId, + params.baseId, + params.viewId, + params.oldVersion, + params.newVersion + ); + } +} diff --git a/packages/v2/core/src/domain/table/events/ViewDeleted.ts b/packages/v2/core/src/domain/table/events/ViewDeleted.ts new file mode 100644 index 0000000000..0ad30dcd23 --- /dev/null +++ b/packages/v2/core/src/domain/table/events/ViewDeleted.ts @@ -0,0 +1,23 @@ +import type { BaseId } from '../../base/BaseId'; +import { DomainEventName } from '../../shared/DomainEventName'; +import { OccurredAt } from '../../shared/OccurredAt'; +import type { TableId } from '../TableId'; +import type { ViewId } from '../views/ViewId'; +import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; + +export class ViewDeleted extends AbstractTableUpdatedEvent { + readonly name = DomainEventName.viewDeleted(); + readonly occurredAt = OccurredAt.now(); + + private constructor( + tableId: TableId, + baseId: BaseId, + readonly viewId: ViewId + ) { + super(tableId, baseId); + } + + static create(params: { tableId: TableId; baseId: BaseId; viewId: ViewId }): ViewDeleted { + return new ViewDeleted(params.tableId, params.baseId, params.viewId); + } +} diff --git a/packages/v2/core/src/domain/table/events/ViewDescriptionUpdated.ts b/packages/v2/core/src/domain/table/events/ViewDescriptionUpdated.ts new file mode 100644 index 0000000000..f088b45282 --- /dev/null +++ b/packages/v2/core/src/domain/table/events/ViewDescriptionUpdated.ts @@ -0,0 +1,43 @@ +import type { BaseId } from '../../base/BaseId'; +import { DomainEventName } from '../../shared/DomainEventName'; +import { OccurredAt } from '../../shared/OccurredAt'; +import type { TableId } from '../TableId'; +import type { ViewId } from '../views/ViewId'; +import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; + +export class ViewDescriptionUpdated extends AbstractTableUpdatedEvent { + readonly name = DomainEventName.viewDescriptionUpdated(); + readonly occurredAt = OccurredAt.now(); + + private constructor( + tableId: TableId, + baseId: BaseId, + readonly viewId: ViewId, + readonly previousDescription: string | undefined, + readonly nextDescription: string | undefined, + readonly oldVersion?: number, + readonly newVersion?: number + ) { + super(tableId, baseId); + } + + static create(params: { + tableId: TableId; + baseId: BaseId; + viewId: ViewId; + previousDescription: string | undefined; + nextDescription: string | undefined; + oldVersion?: number; + newVersion?: number; + }): ViewDescriptionUpdated { + return new ViewDescriptionUpdated( + params.tableId, + params.baseId, + params.viewId, + params.previousDescription, + params.nextDescription, + params.oldVersion, + params.newVersion + ); + } +} diff --git a/packages/v2/core/src/domain/table/events/ViewFilterUpdated.ts b/packages/v2/core/src/domain/table/events/ViewFilterUpdated.ts new file mode 100644 index 0000000000..de5d85290c --- /dev/null +++ b/packages/v2/core/src/domain/table/events/ViewFilterUpdated.ts @@ -0,0 +1,44 @@ +import type { BaseId } from '../../base/BaseId'; +import { DomainEventName } from '../../shared/DomainEventName'; +import { OccurredAt } from '../../shared/OccurredAt'; +import type { TableId } from '../TableId'; +import type { ViewId } from '../views/ViewId'; +import type { ViewSourceFilterDTO } from '../views/ViewSourceFilter'; +import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; + +export class ViewFilterUpdated extends AbstractTableUpdatedEvent { + readonly name = DomainEventName.viewFilterUpdated(); + readonly occurredAt = OccurredAt.now(); + + private constructor( + tableId: TableId, + baseId: BaseId, + readonly viewId: ViewId, + readonly previousFilter: ViewSourceFilterDTO | null | undefined, + readonly nextFilter: ViewSourceFilterDTO | null | undefined, + readonly oldVersion?: number, + readonly newVersion?: number + ) { + super(tableId, baseId); + } + + static create(params: { + tableId: TableId; + baseId: BaseId; + viewId: ViewId; + previousFilter: ViewSourceFilterDTO | null | undefined; + nextFilter: ViewSourceFilterDTO | null | undefined; + oldVersion?: number; + newVersion?: number; + }): ViewFilterUpdated { + return new ViewFilterUpdated( + params.tableId, + params.baseId, + params.viewId, + params.previousFilter, + params.nextFilter, + params.oldVersion, + params.newVersion + ); + } +} diff --git a/packages/v2/core/src/domain/table/events/ViewGroupUpdated.ts b/packages/v2/core/src/domain/table/events/ViewGroupUpdated.ts new file mode 100644 index 0000000000..c443d4da84 --- /dev/null +++ b/packages/v2/core/src/domain/table/events/ViewGroupUpdated.ts @@ -0,0 +1,44 @@ +import type { BaseId } from '../../base/BaseId'; +import { DomainEventName } from '../../shared/DomainEventName'; +import { OccurredAt } from '../../shared/OccurredAt'; +import type { TableId } from '../TableId'; +import type { ViewGroupDTO } from '../views/ViewGroup'; +import type { ViewId } from '../views/ViewId'; +import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; + +export class ViewGroupUpdated extends AbstractTableUpdatedEvent { + readonly name = DomainEventName.viewGroupUpdated(); + readonly occurredAt = OccurredAt.now(); + + private constructor( + tableId: TableId, + baseId: BaseId, + readonly viewId: ViewId, + readonly previousGroup: ViewGroupDTO, + readonly nextGroup: ViewGroupDTO, + readonly oldVersion?: number, + readonly newVersion?: number + ) { + super(tableId, baseId); + } + + static create(params: { + tableId: TableId; + baseId: BaseId; + viewId: ViewId; + previousGroup: ViewGroupDTO; + nextGroup: ViewGroupDTO; + oldVersion?: number; + newVersion?: number; + }): ViewGroupUpdated { + return new ViewGroupUpdated( + params.tableId, + params.baseId, + params.viewId, + params.previousGroup, + params.nextGroup, + params.oldVersion, + params.newVersion + ); + } +} diff --git a/packages/v2/core/src/domain/table/events/ViewLockedUpdated.ts b/packages/v2/core/src/domain/table/events/ViewLockedUpdated.ts new file mode 100644 index 0000000000..b4cc2d8773 --- /dev/null +++ b/packages/v2/core/src/domain/table/events/ViewLockedUpdated.ts @@ -0,0 +1,43 @@ +import type { BaseId } from '../../base/BaseId'; +import { DomainEventName } from '../../shared/DomainEventName'; +import { OccurredAt } from '../../shared/OccurredAt'; +import type { TableId } from '../TableId'; +import type { ViewId } from '../views/ViewId'; +import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; + +export class ViewLockedUpdated extends AbstractTableUpdatedEvent { + readonly name = DomainEventName.viewLockedUpdated(); + readonly occurredAt = OccurredAt.now(); + + private constructor( + tableId: TableId, + baseId: BaseId, + readonly viewId: ViewId, + readonly previousIsLocked: boolean | undefined, + readonly nextIsLocked: boolean | undefined, + readonly oldVersion?: number, + readonly newVersion?: number + ) { + super(tableId, baseId); + } + + static create(params: { + tableId: TableId; + baseId: BaseId; + viewId: ViewId; + previousIsLocked: boolean | undefined; + nextIsLocked: boolean | undefined; + oldVersion?: number; + newVersion?: number; + }): ViewLockedUpdated { + return new ViewLockedUpdated( + params.tableId, + params.baseId, + params.viewId, + params.previousIsLocked, + params.nextIsLocked, + params.oldVersion, + params.newVersion + ); + } +} diff --git a/packages/v2/core/src/domain/table/events/ViewManualSortApplied.ts b/packages/v2/core/src/domain/table/events/ViewManualSortApplied.ts new file mode 100644 index 0000000000..c719c182a7 --- /dev/null +++ b/packages/v2/core/src/domain/table/events/ViewManualSortApplied.ts @@ -0,0 +1,35 @@ +import type { BaseId } from '../../base/BaseId'; +import { DomainEventName } from '../../shared/DomainEventName'; +import { OccurredAt } from '../../shared/OccurredAt'; +import type { TableId } from '../TableId'; +import type { ViewId } from '../views/ViewId'; +import type { ViewSortItem } from '../views/ViewSort'; +import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; + +export class ViewManualSortApplied extends AbstractTableUpdatedEvent { + readonly name = DomainEventName.viewManualSortApplied(); + readonly occurredAt = OccurredAt.now(); + + private constructor( + tableId: TableId, + baseId: BaseId, + readonly viewId: ViewId, + readonly sort: ReadonlyArray + ) { + super(tableId, baseId); + } + + static create(params: { + tableId: TableId; + baseId: BaseId; + viewId: ViewId; + sort: ReadonlyArray; + }): ViewManualSortApplied { + return new ViewManualSortApplied( + params.tableId, + params.baseId, + params.viewId, + params.sort.map((item) => ({ ...item })) + ); + } +} diff --git a/packages/v2/core/src/domain/table/events/ViewOptionsUpdated.ts b/packages/v2/core/src/domain/table/events/ViewOptionsUpdated.ts new file mode 100644 index 0000000000..3993c2e43a --- /dev/null +++ b/packages/v2/core/src/domain/table/events/ViewOptionsUpdated.ts @@ -0,0 +1,43 @@ +import type { BaseId } from '../../base/BaseId'; +import { DomainEventName } from '../../shared/DomainEventName'; +import { OccurredAt } from '../../shared/OccurredAt'; +import type { TableId } from '../TableId'; +import type { ViewId } from '../views/ViewId'; +import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; + +export class ViewOptionsUpdated extends AbstractTableUpdatedEvent { + readonly name = DomainEventName.viewOptionsUpdated(); + readonly occurredAt = OccurredAt.now(); + + private constructor( + tableId: TableId, + baseId: BaseId, + readonly viewId: ViewId, + readonly previousOptions: unknown, + readonly nextOptions: unknown, + readonly oldVersion?: number, + readonly newVersion?: number + ) { + super(tableId, baseId); + } + + static create(params: { + tableId: TableId; + baseId: BaseId; + viewId: ViewId; + previousOptions: unknown; + nextOptions: unknown; + oldVersion?: number; + newVersion?: number; + }): ViewOptionsUpdated { + return new ViewOptionsUpdated( + params.tableId, + params.baseId, + params.viewId, + params.previousOptions, + params.nextOptions, + params.oldVersion, + params.newVersion + ); + } +} diff --git a/packages/v2/core/src/domain/table/events/ViewOrderUpdated.ts b/packages/v2/core/src/domain/table/events/ViewOrderUpdated.ts new file mode 100644 index 0000000000..e1a13aa4da --- /dev/null +++ b/packages/v2/core/src/domain/table/events/ViewOrderUpdated.ts @@ -0,0 +1,44 @@ +import type { BaseId } from '../../base/BaseId'; +import { DomainEventName } from '../../shared/DomainEventName'; +import { OccurredAt } from '../../shared/OccurredAt'; +import type { TableId } from '../TableId'; +import type { ViewId } from '../views/ViewId'; +import type { ViewOrder } from '../views/ViewOrder'; +import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; + +export class ViewOrderUpdated extends AbstractTableUpdatedEvent { + readonly name = DomainEventName.viewOrderUpdated(); + readonly occurredAt = OccurredAt.now(); + + private constructor( + tableId: TableId, + baseId: BaseId, + readonly viewId: ViewId, + readonly previousOrder: ViewOrder, + readonly nextOrder: ViewOrder, + readonly oldVersion?: number, + readonly newVersion?: number + ) { + super(tableId, baseId); + } + + static create(params: { + tableId: TableId; + baseId: BaseId; + viewId: ViewId; + previousOrder: ViewOrder; + nextOrder: ViewOrder; + oldVersion?: number; + newVersion?: number; + }): ViewOrderUpdated { + return new ViewOrderUpdated( + params.tableId, + params.baseId, + params.viewId, + params.previousOrder, + params.nextOrder, + params.oldVersion, + params.newVersion + ); + } +} diff --git a/packages/v2/core/src/domain/table/events/ViewRenamed.ts b/packages/v2/core/src/domain/table/events/ViewRenamed.ts new file mode 100644 index 0000000000..a5e60eb8f4 --- /dev/null +++ b/packages/v2/core/src/domain/table/events/ViewRenamed.ts @@ -0,0 +1,44 @@ +import type { BaseId } from '../../base/BaseId'; +import { DomainEventName } from '../../shared/DomainEventName'; +import { OccurredAt } from '../../shared/OccurredAt'; +import type { TableId } from '../TableId'; +import type { ViewId } from '../views/ViewId'; +import type { ViewName } from '../views/ViewName'; +import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; + +export class ViewRenamed extends AbstractTableUpdatedEvent { + readonly name = DomainEventName.viewRenamed(); + readonly occurredAt = OccurredAt.now(); + + private constructor( + tableId: TableId, + baseId: BaseId, + readonly viewId: ViewId, + readonly previousName: ViewName, + readonly nextName: ViewName, + readonly oldVersion?: number, + readonly newVersion?: number + ) { + super(tableId, baseId); + } + + static create(params: { + tableId: TableId; + baseId: BaseId; + viewId: ViewId; + previousName: ViewName; + nextName: ViewName; + oldVersion?: number; + newVersion?: number; + }): ViewRenamed { + return new ViewRenamed( + params.tableId, + params.baseId, + params.viewId, + params.previousName, + params.nextName, + params.oldVersion, + params.newVersion + ); + } +} diff --git a/packages/v2/core/src/domain/table/events/ViewShareDisabled.ts b/packages/v2/core/src/domain/table/events/ViewShareDisabled.ts new file mode 100644 index 0000000000..9c6c3624dd --- /dev/null +++ b/packages/v2/core/src/domain/table/events/ViewShareDisabled.ts @@ -0,0 +1,44 @@ +import type { BaseId } from '../../base/BaseId'; +import { DomainEventName } from '../../shared/DomainEventName'; +import { OccurredAt } from '../../shared/OccurredAt'; +import type { TableId } from '../TableId'; +import type { ViewId } from '../views/ViewId'; +import type { ViewShareMetaValue } from '../views/ViewProperties'; +import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; + +export class ViewShareDisabled extends AbstractTableUpdatedEvent { + readonly name = DomainEventName.viewShareDisabled(); + readonly occurredAt = OccurredAt.now(); + + private constructor( + tableId: TableId, + baseId: BaseId, + readonly viewId: ViewId, + readonly previousShareId: string | undefined, + readonly shareMeta: ViewShareMetaValue | undefined, + readonly oldVersion?: number, + readonly newVersion?: number + ) { + super(tableId, baseId); + } + + static create(params: { + tableId: TableId; + baseId: BaseId; + viewId: ViewId; + previousShareId: string | undefined; + shareMeta: ViewShareMetaValue | undefined; + oldVersion?: number; + newVersion?: number; + }): ViewShareDisabled { + return new ViewShareDisabled( + params.tableId, + params.baseId, + params.viewId, + params.previousShareId, + params.shareMeta, + params.oldVersion, + params.newVersion + ); + } +} diff --git a/packages/v2/core/src/domain/table/events/ViewShareEnabled.ts b/packages/v2/core/src/domain/table/events/ViewShareEnabled.ts new file mode 100644 index 0000000000..24443aa1bd --- /dev/null +++ b/packages/v2/core/src/domain/table/events/ViewShareEnabled.ts @@ -0,0 +1,44 @@ +import type { BaseId } from '../../base/BaseId'; +import { DomainEventName } from '../../shared/DomainEventName'; +import { OccurredAt } from '../../shared/OccurredAt'; +import type { TableId } from '../TableId'; +import type { ViewId } from '../views/ViewId'; +import type { ViewShareMetaValue } from '../views/ViewProperties'; +import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; + +export class ViewShareEnabled extends AbstractTableUpdatedEvent { + readonly name = DomainEventName.viewShareEnabled(); + readonly occurredAt = OccurredAt.now(); + + private constructor( + tableId: TableId, + baseId: BaseId, + readonly viewId: ViewId, + readonly shareId: string, + readonly shareMeta: ViewShareMetaValue, + readonly oldVersion?: number, + readonly newVersion?: number + ) { + super(tableId, baseId); + } + + static create(params: { + tableId: TableId; + baseId: BaseId; + viewId: ViewId; + shareId: string; + shareMeta: ViewShareMetaValue; + oldVersion?: number; + newVersion?: number; + }): ViewShareEnabled { + return new ViewShareEnabled( + params.tableId, + params.baseId, + params.viewId, + params.shareId, + params.shareMeta, + params.oldVersion, + params.newVersion + ); + } +} diff --git a/packages/v2/core/src/domain/table/events/ViewShareIdRefreshed.ts b/packages/v2/core/src/domain/table/events/ViewShareIdRefreshed.ts new file mode 100644 index 0000000000..1f7453f4e9 --- /dev/null +++ b/packages/v2/core/src/domain/table/events/ViewShareIdRefreshed.ts @@ -0,0 +1,43 @@ +import type { BaseId } from '../../base/BaseId'; +import { DomainEventName } from '../../shared/DomainEventName'; +import { OccurredAt } from '../../shared/OccurredAt'; +import type { TableId } from '../TableId'; +import type { ViewId } from '../views/ViewId'; +import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; + +export class ViewShareIdRefreshed extends AbstractTableUpdatedEvent { + readonly name = DomainEventName.viewShareIdRefreshed(); + readonly occurredAt = OccurredAt.now(); + + private constructor( + tableId: TableId, + baseId: BaseId, + readonly viewId: ViewId, + readonly previousShareId: string | undefined, + readonly nextShareId: string, + readonly oldVersion?: number, + readonly newVersion?: number + ) { + super(tableId, baseId); + } + + static create(params: { + tableId: TableId; + baseId: BaseId; + viewId: ViewId; + previousShareId: string | undefined; + nextShareId: string; + oldVersion?: number; + newVersion?: number; + }): ViewShareIdRefreshed { + return new ViewShareIdRefreshed( + params.tableId, + params.baseId, + params.viewId, + params.previousShareId, + params.nextShareId, + params.oldVersion, + params.newVersion + ); + } +} diff --git a/packages/v2/core/src/domain/table/events/ViewShareMetaUpdated.ts b/packages/v2/core/src/domain/table/events/ViewShareMetaUpdated.ts new file mode 100644 index 0000000000..4dbacff1b0 --- /dev/null +++ b/packages/v2/core/src/domain/table/events/ViewShareMetaUpdated.ts @@ -0,0 +1,44 @@ +import type { BaseId } from '../../base/BaseId'; +import { DomainEventName } from '../../shared/DomainEventName'; +import { OccurredAt } from '../../shared/OccurredAt'; +import type { TableId } from '../TableId'; +import type { ViewId } from '../views/ViewId'; +import type { ViewShareMetaValue } from '../views/ViewProperties'; +import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; + +export class ViewShareMetaUpdated extends AbstractTableUpdatedEvent { + readonly name = DomainEventName.viewShareMetaUpdated(); + readonly occurredAt = OccurredAt.now(); + + private constructor( + tableId: TableId, + baseId: BaseId, + readonly viewId: ViewId, + readonly previousShareMeta: ViewShareMetaValue | undefined, + readonly nextShareMeta: ViewShareMetaValue | undefined, + readonly oldVersion?: number, + readonly newVersion?: number + ) { + super(tableId, baseId); + } + + static create(params: { + tableId: TableId; + baseId: BaseId; + viewId: ViewId; + previousShareMeta: ViewShareMetaValue | undefined; + nextShareMeta: ViewShareMetaValue | undefined; + oldVersion?: number; + newVersion?: number; + }): ViewShareMetaUpdated { + return new ViewShareMetaUpdated( + params.tableId, + params.baseId, + params.viewId, + params.previousShareMeta, + params.nextShareMeta, + params.oldVersion, + params.newVersion + ); + } +} diff --git a/packages/v2/core/src/domain/table/events/ViewSortUpdated.ts b/packages/v2/core/src/domain/table/events/ViewSortUpdated.ts new file mode 100644 index 0000000000..9ebcfc23c1 --- /dev/null +++ b/packages/v2/core/src/domain/table/events/ViewSortUpdated.ts @@ -0,0 +1,44 @@ +import type { BaseId } from '../../base/BaseId'; +import { DomainEventName } from '../../shared/DomainEventName'; +import { OccurredAt } from '../../shared/OccurredAt'; +import type { TableId } from '../TableId'; +import type { ViewId } from '../views/ViewId'; +import type { ViewSortDTO } from '../views/ViewSort'; +import { AbstractTableUpdatedEvent } from './AbstractTableUpdatedEvent'; + +export class ViewSortUpdated extends AbstractTableUpdatedEvent { + readonly name = DomainEventName.viewSortUpdated(); + readonly occurredAt = OccurredAt.now(); + + private constructor( + tableId: TableId, + baseId: BaseId, + readonly viewId: ViewId, + readonly previousSort: ViewSortDTO, + readonly nextSort: ViewSortDTO, + readonly oldVersion?: number, + readonly newVersion?: number + ) { + super(tableId, baseId); + } + + static create(params: { + tableId: TableId; + baseId: BaseId; + viewId: ViewId; + previousSort: ViewSortDTO; + nextSort: ViewSortDTO; + oldVersion?: number; + newVersion?: number; + }): ViewSortUpdated { + return new ViewSortUpdated( + params.tableId, + params.baseId, + params.viewId, + params.previousSort, + params.nextSort, + params.oldVersion, + params.newVersion + ); + } +} diff --git a/packages/v2/core/src/domain/table/fields/types/SelectFieldOptionWriteConfig.ts b/packages/v2/core/src/domain/table/fields/types/SelectFieldOptionWriteConfig.ts index 051e247651..79f54d0d60 100644 --- a/packages/v2/core/src/domain/table/fields/types/SelectFieldOptionWriteConfig.ts +++ b/packages/v2/core/src/domain/table/fields/types/SelectFieldOptionWriteConfig.ts @@ -3,7 +3,10 @@ import type { Result } from 'neverthrow'; import type { IDomainContext } from '../../../shared/DomainContext'; import { domainError, type DomainError } from '../../../shared/DomainError'; -import { DEFAULT_TABLE_DATA_SAFETY_LIMITS } from '../../../shared/TableDataSafetyLimits'; +import { + DEFAULT_TABLE_DATA_SAFETY_LIMITS, + tableDataSafetyLimitErrors, +} from '../../../shared/TableDataSafetyLimits'; export const ensureSelectFieldOptionCountWithinLimit = ( optionCount: number, @@ -38,8 +41,12 @@ export const ensureSelectFieldOptionNameWithinLimit = ( return err( domainError.validation({ - code: 'validation.limit.select_choice_name_max_length', + code: tableDataSafetyLimitErrors.selectChoiceNameMaxLength.code, message: `Select field option names cannot exceed ${maxChoiceNameLength} characters`, + localization: { + i18nKey: tableDataSafetyLimitErrors.selectChoiceNameMaxLength.i18nKey, + context: { max: maxChoiceNameLength }, + }, }) ); }; diff --git a/packages/v2/core/src/domain/table/fields/visitors/ARCHITECTURE.md b/packages/v2/core/src/domain/table/fields/visitors/ARCHITECTURE.md index 82e4e50340..68a92990be 100644 --- a/packages/v2/core/src/domain/table/fields/visitors/ARCHITECTURE.md +++ b/packages/v2/core/src/domain/table/fields/visitors/ARCHITECTURE.md @@ -16,6 +16,8 @@ Declaration: If the folder I belong to changes, please update me, especially cor - `FieldDeletionSideEffectVisitor.ts` - Role: visitor; Purpose: compute cross-table side effects for field deletion. - `FieldDeletionSideEffectVisitor.spec.ts` - Role: tests; Purpose: verify delete side effects for link fields. - `FieldFormVisibilityVisitor.ts` - Role: visitor; Purpose: decide form view visibility by field type. +- `FieldClipboardValueVisitor.ts` - Role: visitor; Purpose: format stored v2 Field values for + clipboard output without delegating to legacy Field instances. - `FieldValueTypeVisitor.ts` - Role: visitor; Purpose: derive cell value types and multiplicity. - `FieldValueTypeVisitor.spec.ts` - Role: tests; Purpose: verify value type visitor behavior. - `IFieldVisitor.ts` - Role: visitor interface; Purpose: declare per-field visit methods. diff --git a/packages/v2/core/src/domain/table/fields/visitors/FieldCellValueSchemaVisitor.spec.ts b/packages/v2/core/src/domain/table/fields/visitors/FieldCellValueSchemaVisitor.spec.ts index d77b0d294a..f65fbd5b9f 100644 --- a/packages/v2/core/src/domain/table/fields/visitors/FieldCellValueSchemaVisitor.spec.ts +++ b/packages/v2/core/src/domain/table/fields/visitors/FieldCellValueSchemaVisitor.spec.ts @@ -2,10 +2,15 @@ import { describe, expect, it } from 'vitest'; import { FieldId } from '../FieldId'; import { FieldName } from '../FieldName'; +import { DateField } from '../types/DateField'; +import { LinkField } from '../types/LinkField'; +import { LinkFieldConfig } from '../types/LinkFieldConfig'; import { MultipleSelectField } from '../types/MultipleSelectField'; import { SelectOption } from '../types/SelectOption'; import { SingleSelectField } from '../types/SingleSelectField'; import { DateField } from '../types/DateField'; +import { LinkField } from '../types/LinkField'; +import { LinkFieldConfig } from '../types/LinkFieldConfig'; import { FieldCellValueSchemaVisitor } from './FieldCellValueSchemaVisitor'; const createFieldId = (seed: string) => @@ -122,13 +127,15 @@ describe('FieldCellValueSchemaVisitor', () => { expect(parseResult.success).toBe(true); }); - it('generates schema that accepts empty array', () => { + it('normalizes empty array to null', () => { const schemaResult = field.accept(visitor); expect(schemaResult.isOk()).toBe(true); const schema = schemaResult._unsafeUnwrap(); const parseResult = schema.safeParse([]); expect(parseResult.success).toBe(true); + if (!parseResult.success) return; + expect(parseResult.data).toBeNull(); }); }); @@ -182,4 +189,62 @@ describe('FieldCellValueSchemaVisitor', () => { expect(parseResult.success).toBe(false); }); }); + + describe('visitLinkField', () => { + const validLink = { id: 'rec123', title: 'Linked Record' }; + const secondLink = { id: 'rec456', title: 'Second' }; + + const createLinkField = (relationship: 'manyOne' | 'manyMany') => + LinkField.create({ + id: createFieldId(relationship === 'manyOne' ? 'linksingle' : 'linkmulti'), + name: createFieldName(relationship === 'manyOne' ? 'Single Link' : 'Multi Link'), + config: LinkFieldConfig.create({ + relationship, + foreignTableId: `tbl${'f'.repeat(16)}`, + lookupFieldId: `fld${'l'.repeat(16)}`, + isOneWay: true, + })._unsafeUnwrap(), + })._unsafeUnwrap(); + + it('accepts object and array for single-value link fields', () => { + const schema = createLinkField('manyOne').accept(visitor)._unsafeUnwrap(); + + expect(schema.safeParse(validLink).success).toBe(true); + expect(schema.safeParse(validLink).data).toEqual(validLink); + expect(schema.safeParse([validLink, secondLink]).success).toBe(true); + expect(schema.safeParse([validLink, secondLink]).data).toEqual(validLink); + expect(schema.safeParse([]).success).toBe(false); + expect(schema.safeParse(null).success).toBe(true); + }); + + it('accepts array and object for multi-value link fields', () => { + const schema = createLinkField('manyMany').accept(visitor)._unsafeUnwrap(); + + expect(schema.safeParse([validLink]).success).toBe(true); + expect(schema.safeParse([validLink]).data).toEqual([validLink]); + expect(schema.safeParse(validLink).success).toBe(true); + expect(schema.safeParse(validLink).data).toEqual([validLink]); + expect(schema.safeParse([validLink, { ...validLink }]).success).toBe(false); + expect(schema.safeParse(null).success).toBe(true); + }); + + it('accepts and strips null titles for single-value legacy links', () => { + const schema = createLinkField('manyOne').accept(visitor)._unsafeUnwrap(); + + expect(schema.safeParse({ id: 'rec123' }).data).toEqual({ id: 'rec123' }); + expect(schema.safeParse({ id: 'rec123', title: undefined }).data).toEqual({ id: 'rec123' }); + expect(schema.safeParse({ id: 'rec123', title: null }).data).toEqual({ id: 'rec123' }); + }); + + it('accepts and strips null titles for multi-value legacy links', () => { + const schema = createLinkField('manyMany').accept(visitor)._unsafeUnwrap(); + + expect( + schema.safeParse([ + { id: 'rec123', title: null }, + { id: 'rec456', title: 'Named' }, + ]).data + ).toEqual([{ id: 'rec123' }, { id: 'rec456', title: 'Named' }]); + }); + }); }); diff --git a/packages/v2/core/src/domain/table/fields/visitors/FieldCellValueSchemaVisitor.ts b/packages/v2/core/src/domain/table/fields/visitors/FieldCellValueSchemaVisitor.ts index b073d71620..6265ec3482 100644 --- a/packages/v2/core/src/domain/table/fields/visitors/FieldCellValueSchemaVisitor.ts +++ b/packages/v2/core/src/domain/table/fields/visitors/FieldCellValueSchemaVisitor.ts @@ -48,10 +48,13 @@ const attachmentItemSchema = z.object({ }); // Link item schema -const linkItemSchema = z.object({ - id: z.string(), - title: z.string().optional(), -}); +// Accept title:null from persisted empty-primary links, then strip it. +const linkItemSchema = z + .object({ + id: z.string(), + title: z.string().nullish(), + }) + .transform(({ id, title }) => (title == null ? { id } : { id, title })); // User item schema const userItemSchema = z.object({ @@ -85,12 +88,14 @@ export class FieldCellValueSchemaVisitor extends AbstractFieldVisitor } visitSingleLineTextField(field: SingleLineTextField): Result { - const baseSchema = z.string(); + // Align with v1: empty string is stored as null. + const baseSchema = z.string().transform((val) => (val === '' ? null : val)); return ok(this.applyNullable(baseSchema, field.notNull().toBoolean())); } visitLongTextField(field: LongTextField): Result { - const baseSchema = z.string(); + // Align with v1: empty string is stored as null. + const baseSchema = z.string().transform((val) => (val === '' ? null : val)); return ok(this.applyNullable(baseSchema, field.notNull().toBoolean())); } @@ -142,25 +147,36 @@ export class FieldCellValueSchemaVisitor extends AbstractFieldVisitor const options = field.selectOptions(); if (options.length === 0) { // No options defined, only accept null/empty array (matching v1 behavior) - return ok(z.array(z.never()).nullable()); + return ok( + z + .array(z.never()) + .transform((val) => (val.length === 0 ? null : val)) + .nullable() + ); } // Accept both option IDs and names to align with v1 behavior. const optionValues = options.flatMap((opt) => [opt.id().toString(), opt.name().toString()]); // Deduplicate in case ID and name are the same const uniqueValues = [...new Set(optionValues)]; - const baseSchema = z.array(z.enum(uniqueValues as [string, ...string[]])); + // Align with v1: empty arrays are stored as null, not []. + const baseSchema = z + .array(z.enum(uniqueValues as [string, ...string[]])) + .transform((val) => (val.length === 0 ? null : val)); return ok(this.applyNullable(baseSchema, field.notNull().toBoolean())); } visitCheckboxField(field: CheckboxField): Result { - // Checkbox: boolean value (true = checked, false = unchecked) - const baseSchema = z.boolean(); + // Align with v1: checkbox only stores true or null (false -> null). + const baseSchema = z.boolean().transform((val) => (val === false ? null : val)); return ok(this.applyNullable(baseSchema, field.notNull().toBoolean())); } visitAttachmentField(field: AttachmentField): Result { - const baseSchema = z.array(attachmentItemSchema); + // Align with v1: empty attachment arrays are stored as null. + const baseSchema = z + .array(attachmentItemSchema) + .transform((val) => (val.length === 0 ? null : val)); return ok(this.applyNullable(baseSchema, field.notNull().toBoolean())); } @@ -229,20 +245,26 @@ export class FieldCellValueSchemaVisitor extends AbstractFieldVisitor visitLinkField(field: LinkField): Result { const isMultipleRelationship = field.relationship().isMultipleValue(); + // V1 compatibility: single/multi link cell shapes tolerate each other. + // Realtime and older integrations can briefly deliver the previous shape. if (isMultipleRelationship) { - // Add refine to check for duplicate IDs in the array - const baseSchema = z.array(linkItemSchema).refine( - (items) => { - const ids = items.map((item) => item.id); - return new Set(ids).size === ids.length; - }, - { message: 'Cannot set duplicate record IDs in the same link cell' } - ); - return ok(this.applyNullable(baseSchema, field.notNull().toBoolean())); - } else { - const baseSchema = linkItemSchema; + const baseSchema = z + .union([z.array(linkItemSchema), linkItemSchema]) + .transform((value) => (Array.isArray(value) ? value : [value])) + .refine( + (items) => { + const ids = items.map((item) => item.id); + return new Set(ids).size === ids.length; + }, + { message: 'Cannot set duplicate record IDs in the same link cell' } + ); return ok(this.applyNullable(baseSchema, field.notNull().toBoolean())); } + + const baseSchema = z + .union([linkItemSchema, z.array(linkItemSchema).nonempty()]) + .transform((value) => (Array.isArray(value) ? value[0] : value)); + return ok(this.applyNullable(baseSchema, field.notNull().toBoolean())); } visitConditionalRollupField(_field: ConditionalRollupField): Result { diff --git a/packages/v2/core/src/domain/table/fields/visitors/FieldClipboardValueVisitor.spec.ts b/packages/v2/core/src/domain/table/fields/visitors/FieldClipboardValueVisitor.spec.ts new file mode 100644 index 0000000000..87d3ac261f --- /dev/null +++ b/packages/v2/core/src/domain/table/fields/visitors/FieldClipboardValueVisitor.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; + +import { FieldId } from '../FieldId'; +import { FieldName } from '../FieldName'; +import { AttachmentField } from '../types/AttachmentField'; +import { DateField } from '../types/DateField'; +import { DateTimeFormatting } from '../types/DateTimeFormatting'; +import { MultipleSelectField } from '../types/MultipleSelectField'; +import { NumberField } from '../types/NumberField'; +import { NumberFormatting } from '../types/NumberFormatting'; +import { UserField } from '../types/UserField'; +import { UserMultiplicity } from '../types/UserMultiplicity'; +import { FieldClipboardValueVisitor, stringifyClipboardRows } from './FieldClipboardValueVisitor'; + +const fieldId = (seed: string) => FieldId.create(`fld${seed.repeat(16)}`)._unsafeUnwrap(); +const fieldName = (value: string) => FieldName.create(value)._unsafeUnwrap(); + +describe('FieldClipboardValueVisitor', () => { + it('formats decimal, percent and currency values with v1-compatible precision', () => { + const decimal = NumberField.create({ + id: fieldId('a'), + name: fieldName('Decimal'), + formatting: NumberFormatting.create({ type: 'decimal', precision: 2 })._unsafeUnwrap(), + })._unsafeUnwrap(); + const percent = NumberField.create({ + id: fieldId('b'), + name: fieldName('Percent'), + formatting: NumberFormatting.create({ type: 'percent', precision: 1 })._unsafeUnwrap(), + })._unsafeUnwrap(); + const currency = NumberField.create({ + id: fieldId('c'), + name: fieldName('Currency'), + formatting: NumberFormatting.create({ + type: 'currency', + precision: 2, + symbol: '$', + })._unsafeUnwrap(), + })._unsafeUnwrap(); + + expect(decimal.accept(new FieldClipboardValueVisitor(1.234))._unsafeUnwrap()).toBe('1.23'); + expect(percent.accept(new FieldClipboardValueVisitor(0.126))._unsafeUnwrap()).toBe('12.6%'); + expect(currency.accept(new FieldClipboardValueVisitor(-1234.5))._unsafeUnwrap()).toBe( + '-$1,234.50' + ); + expect(decimal.accept(new FieldClipboardValueVisitor(null))._unsafeUnwrap()).toBe(''); + }); + + it('formats dates in the Field timezone and selected display pattern', () => { + const field = DateField.create({ + id: fieldId('d'), + name: fieldName('Date'), + formatting: DateTimeFormatting.create({ + date: 'YYYY/MM/DD', + time: 'HH:mm', + timeZone: 'Asia/Singapore', + })._unsafeUnwrap(), + })._unsafeUnwrap(); + + expect( + field.accept(new FieldClipboardValueVisitor('2023-06-19T06:50:48.017Z'))._unsafeUnwrap() + ).toBe('2023/06/19 14:50'); + }); + + it('formats multiple select, user and attachment structured values', () => { + const select = MultipleSelectField.create({ + id: fieldId('e'), + name: fieldName('Tags'), + options: [], + })._unsafeUnwrap(); + const user = UserField.create({ + id: fieldId('f'), + name: fieldName('Owners'), + isMultiple: UserMultiplicity.multiple(), + })._unsafeUnwrap(); + const attachment = AttachmentField.create({ + id: fieldId('g'), + name: fieldName('Files'), + })._unsafeUnwrap(); + + expect( + select.accept(new FieldClipboardValueVisitor(['Alpha, Beta', 'Gamma']))._unsafeUnwrap() + ).toBe('"Alpha, Beta", Gamma'); + expect( + user + .accept( + new FieldClipboardValueVisitor([ + { id: 'usr1', title: 'Doe, Jane' }, + { id: 'usr2', title: 'Alex' }, + ]) + ) + ._unsafeUnwrap() + ).toBe('"Doe, Jane", Alex'); + expect( + attachment + .accept( + new FieldClipboardValueVisitor([ + { name: 'a.txt', token: 'tok1' }, + { name: 'b.png', token: 'tok2' }, + ]) + ) + ._unsafeUnwrap() + ).toBe('a.txt (tok1),b.png (tok2)'); + }); + + it('serializes TSV cells with tab, newline and quote escaping', () => { + expect( + stringifyClipboardRows([ + ['plain', 'with\ttab'], + ['line\nbreak', 'say "hello"'], + ]) + ).toBe('plain\t"with\ttab"\n"line\nbreak"\tsay "hello"'); + expect(stringifyClipboardRows([['a"b\nc']])).toBe('"a""b\nc"'); + }); +}); diff --git a/packages/v2/core/src/domain/table/fields/visitors/FieldClipboardValueVisitor.ts b/packages/v2/core/src/domain/table/fields/visitors/FieldClipboardValueVisitor.ts new file mode 100644 index 0000000000..52fe061379 --- /dev/null +++ b/packages/v2/core/src/domain/table/fields/visitors/FieldClipboardValueVisitor.ts @@ -0,0 +1,347 @@ +import { ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../../shared/DomainError'; +import type { AttachmentField } from '../types/AttachmentField'; +import type { AutoNumberField } from '../types/AutoNumberField'; +import type { ButtonField } from '../types/ButtonField'; +import type { CheckboxField } from '../types/CheckboxField'; +import type { ConditionalLookupField } from '../types/ConditionalLookupField'; +import type { ConditionalRollupField } from '../types/ConditionalRollupField'; +import type { CreatedByField } from '../types/CreatedByField'; +import type { CreatedTimeField } from '../types/CreatedTimeField'; +import type { DateField } from '../types/DateField'; +import { DateTimeFormatting } from '../types/DateTimeFormatting'; +import type { FormulaField } from '../types/FormulaField'; +import type { LastModifiedByField } from '../types/LastModifiedByField'; +import type { LastModifiedTimeField } from '../types/LastModifiedTimeField'; +import type { LinkField } from '../types/LinkField'; +import type { LongTextField } from '../types/LongTextField'; +import type { LookupField } from '../types/LookupField'; +import type { MultipleSelectField } from '../types/MultipleSelectField'; +import type { NumberField } from '../types/NumberField'; +import { NumberFormatting, NumberFormattingType } from '../types/NumberFormatting'; +import type { RatingField } from '../types/RatingField'; +import type { RollupField } from '../types/RollupField'; +import type { SingleLineTextField } from '../types/SingleLineTextField'; +import type { SingleSelectField } from '../types/SingleSelectField'; +import type { UserField } from '../types/UserField'; +import { AbstractFieldVisitor } from './AbstractFieldVisitor'; + +const asArray = (value: unknown): ReadonlyArray => + Array.isArray(value) ? value : [value]; + +const formatGeneric = (value: unknown, multiple: boolean): string => { + if (value == null) return ''; + if (multiple || Array.isArray(value)) { + return asArray(value) + .map((item) => (item == null ? '' : String(item))) + .join(', '); + } + return String(value); +}; + +const formatNumber = (value: unknown, formatting: NumberFormatting): string => { + if (value == null) return ''; + const number = Number(value); + const precision = formatting.precision().toNumber(); + if (formatting.type() === NumberFormattingType.Currency) { + const sign = number < 0 ? '-' : ''; + const formatted = Math.abs(number).toLocaleString('en-US', { + minimumFractionDigits: precision, + maximumFractionDigits: precision, + }); + return `${sign}${formatting.symbol() ?? '$'}${formatted}`; + } + if (formatting.type() === NumberFormattingType.Percent) { + return `${(number * 100).toFixed(precision)}%`; + } + return number.toFixed(precision); +}; + +const formatDate = (value: unknown, formatting: DateTimeFormatting): string => { + if (value == null) return ''; + const date = new Date(String(value)); + if (Number.isNaN(date.getTime())) return String(value); + + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: formatting.timeZone().toString(), + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }) + .formatToParts(date) + .reduce>((result, part) => { + if (part.type !== 'literal') result[part.type] = part.value; + return result; + }, {}); + const hour24 = Number(parts.hour ?? '0'); + const replacements: Record = { + YYYY: parts.year ?? '', + MM: parts.month ?? '', + M: String(Number(parts.month ?? '0')), + DD: parts.day ?? '', + D: String(Number(parts.day ?? '0')), + HH: String(hour24).padStart(2, '0'), + hh: String(hour24 % 12 || 12).padStart(2, '0'), + mm: parts.minute ?? '', + A: hour24 >= 12 ? 'PM' : 'AM', + }; + const pattern = + formatting.time() === 'None' ? formatting.date() : `${formatting.date()} ${formatting.time()}`; + return pattern.replace(/YYYY|MM|DD|HH|hh|mm|[MDA]/g, (token) => replacements[token] ?? token); +}; + +const formatWithMultiplicity = ( + value: unknown, + multiple: boolean, + formatOne: (item: unknown) => string, + separator = ', ' +): string => { + if (value == null) return ''; + if (!multiple && !Array.isArray(value)) return formatOne(value); + return asArray(value).map(formatOne).join(separator); +}; + +const formatStructuredTitle = (value: unknown, quoteComma: boolean): string => { + if (value == null || typeof value !== 'object') return ''; + const title = (value as { title?: unknown }).title; + const text = title == null ? '' : String(title); + return quoteComma && text.includes(',') ? `"${text}"` : text; +}; + +const formatComputed = ( + value: unknown, + cellValueType: string, + multiple: boolean, + formatting: NumberFormatting | DateTimeFormatting | undefined +): string => + formatWithMultiplicity(value, multiple, (item) => { + if (cellValueType === 'number') { + return formatNumber( + item, + formatting instanceof NumberFormatting ? formatting : NumberFormatting.default() + ); + } + if (cellValueType === 'dateTime') { + return formatDate( + item, + formatting instanceof DateTimeFormatting ? formatting : DateTimeFormatting.default() + ); + } + return item == null ? '' : String(item); + }); + +/** + * Convert a v2 Field cell value to the clipboard text owned by that Field definition. + */ +export class FieldClipboardValueVisitor extends AbstractFieldVisitor { + constructor( + private readonly value: unknown, + private readonly multiplicityOverride?: boolean + ) { + super(); + } + + visitSingleLineTextField(_field: SingleLineTextField): Result { + return ok(formatGeneric(this.value, this.multiplicityOverride ?? false)); + } + + visitLongTextField(_field: LongTextField): Result { + return ok(formatGeneric(this.value, this.multiplicityOverride ?? false)); + } + + visitNumberField(field: NumberField): Result { + return ok( + formatWithMultiplicity(this.value, this.multiplicityOverride ?? false, (item) => + formatNumber(item, field.formatting()) + ) + ); + } + + visitRatingField(_field: RatingField): Result { + return ok(formatGeneric(this.value, this.multiplicityOverride ?? false)); + } + + visitFormulaField(field: FormulaField): Result { + return field + .cellValueType() + .andThen((cellValueType) => + field + .isMultipleCellValue() + .map((multiple) => + formatComputed( + this.value, + cellValueType.toString(), + this.multiplicityOverride ?? multiple.isMultiple(), + field.formatting() + ) + ) + ); + } + + visitRollupField(field: RollupField): Result { + return field + .cellValueType() + .andThen((cellValueType) => + field + .isMultipleCellValue() + .map((multiple) => + formatComputed( + this.value, + cellValueType.toString(), + this.multiplicityOverride ?? multiple.isMultiple(), + field.formatting() + ) + ) + ); + } + + visitSingleSelectField(_field: SingleSelectField): Result { + const multiple = this.multiplicityOverride ?? false; + return ok( + formatWithMultiplicity(this.value, multiple, (item) => { + const text = item == null ? '' : String(item); + return multiple && text.includes(',') ? `"${text}"` : text; + }) + ); + } + + visitMultipleSelectField(_field: MultipleSelectField): Result { + return ok( + formatWithMultiplicity(this.value, true, (item) => { + const text = item == null ? '' : String(item); + return text.includes(',') ? `"${text}"` : text; + }) + ); + } + + visitCheckboxField(_field: CheckboxField): Result { + return ok(formatGeneric(this.value, this.multiplicityOverride ?? false)); + } + + visitAttachmentField(_field: AttachmentField): Result { + return ok( + formatWithMultiplicity( + this.value, + true, + (item) => { + if (item == null || typeof item !== 'object') return ''; + const attachment = item as { name?: unknown; token?: unknown }; + return `${String(attachment.name ?? '')} (${String(attachment.token ?? '')})`; + }, + ',' + ) + ); + } + + visitDateField(field: DateField): Result { + return ok( + formatWithMultiplicity(this.value, this.multiplicityOverride ?? false, (item) => + formatDate(item, field.formatting()) + ) + ); + } + + visitCreatedTimeField(field: CreatedTimeField): Result { + return ok(formatDate(this.value, field.formatting())); + } + + visitLastModifiedTimeField(field: LastModifiedTimeField): Result { + return ok(formatDate(this.value, field.formatting())); + } + + visitUserField(field: UserField): Result { + const multiple = this.multiplicityOverride ?? field.multiplicity().toBoolean(); + return ok( + formatWithMultiplicity(this.value, multiple, (item) => formatStructuredTitle(item, multiple)) + ); + } + + visitCreatedByField(_field: CreatedByField): Result { + const multiple = this.multiplicityOverride ?? false; + return ok( + formatWithMultiplicity(this.value, multiple, (item) => formatStructuredTitle(item, multiple)) + ); + } + + visitLastModifiedByField(_field: LastModifiedByField): Result { + const multiple = this.multiplicityOverride ?? false; + return ok( + formatWithMultiplicity(this.value, multiple, (item) => formatStructuredTitle(item, multiple)) + ); + } + + visitAutoNumberField(_field: AutoNumberField): Result { + return ok(formatGeneric(this.value, this.multiplicityOverride ?? false)); + } + + visitButtonField(_field: ButtonField): Result { + return ok(''); + } + + visitLinkField(_field: LinkField): Result { + return ok( + formatWithMultiplicity( + this.value, + this.multiplicityOverride ?? Array.isArray(this.value), + (item) => formatStructuredTitle(item, false) + ) + ); + } + + override visitLookupField(field: LookupField): Result { + return field + .isMultipleCellValue() + .andThen((multiple) => + field + .innerField() + .andThen((inner) => + inner.accept(new FieldClipboardValueVisitor(this.value, multiple.isMultiple())) + ) + ); + } + + visitConditionalRollupField(field: ConditionalRollupField): Result { + return field + .cellValueType() + .andThen((cellValueType) => + field + .isMultipleCellValue() + .map((multiple) => + formatComputed( + this.value, + cellValueType.toString(), + this.multiplicityOverride ?? multiple.isMultiple(), + field.formatting() + ) + ) + ); + } + + override visitConditionalLookupField(field: ConditionalLookupField): Result { + return field + .isMultipleCellValue() + .andThen((multiple) => + field + .innerField() + .andThen((inner) => + inner.accept(new FieldClipboardValueVisitor(this.value, multiple.isMultiple())) + ) + ); + } +} + +export const stringifyClipboardRows = (rows: ReadonlyArray>): string => + rows + .map((row) => + row + .map((cell) => + cell.includes('\t') || cell.includes('\n') ? `"${cell.replace(/"/g, '""')}"` : cell + ) + .join('\t') + ) + .join('\n'); diff --git a/packages/v2/core/src/domain/table/fields/visitors/FieldDateTimeZoneVisitor.ts b/packages/v2/core/src/domain/table/fields/visitors/FieldDateTimeZoneVisitor.ts new file mode 100644 index 0000000000..2b77a90661 --- /dev/null +++ b/packages/v2/core/src/domain/table/fields/visitors/FieldDateTimeZoneVisitor.ts @@ -0,0 +1,134 @@ +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../../shared/DomainError'; +import type { Field } from '../Field'; +import type { AttachmentField } from '../types/AttachmentField'; +import type { AutoNumberField } from '../types/AutoNumberField'; +import type { ButtonField } from '../types/ButtonField'; +import type { CheckboxField } from '../types/CheckboxField'; +import type { ConditionalLookupField } from '../types/ConditionalLookupField'; +import type { ConditionalRollupField } from '../types/ConditionalRollupField'; +import type { CreatedByField } from '../types/CreatedByField'; +import type { CreatedTimeField } from '../types/CreatedTimeField'; +import type { DateField } from '../types/DateField'; +import type { FormulaField } from '../types/FormulaField'; +import type { LastModifiedByField } from '../types/LastModifiedByField'; +import type { LastModifiedTimeField } from '../types/LastModifiedTimeField'; +import type { LinkField } from '../types/LinkField'; +import type { LongTextField } from '../types/LongTextField'; +import type { LookupField } from '../types/LookupField'; +import type { MultipleSelectField } from '../types/MultipleSelectField'; +import type { NumberField } from '../types/NumberField'; +import type { RatingField } from '../types/RatingField'; +import type { RollupField } from '../types/RollupField'; +import type { SingleLineTextField } from '../types/SingleLineTextField'; +import type { SingleSelectField } from '../types/SingleSelectField'; +import { TimeZone } from '../types/TimeZone'; +import type { UserField } from '../types/UserField'; +import type { IFieldVisitor } from './IFieldVisitor'; + +/** + * Resolves the timezone that defines calendar-day boundaries for a scalar + * DateTime Field. Callers must still validate the Field value type and + * multiplicity before accepting the result. + */ +export class FieldDateTimeZoneVisitor implements IFieldVisitor { + visitDateField(field: DateField): Result { + return ok(field.formatting().timeZone()); + } + + visitCreatedTimeField(field: CreatedTimeField): Result { + return ok(field.formatting().timeZone()); + } + + visitLastModifiedTimeField(field: LastModifiedTimeField): Result { + return ok(field.formatting().timeZone()); + } + + visitFormulaField(field: FormulaField): Result { + return ok(field.timeZone() ?? TimeZone.default()); + } + + visitRollupField(field: RollupField): Result { + return ok(field.timeZone() ?? TimeZone.default()); + } + + visitConditionalRollupField(field: ConditionalRollupField): Result { + return ok(field.timeZone() ?? TimeZone.default()); + } + + visitLookupField(field: LookupField): Result { + return field.innerField().andThen((inner) => inner.accept(this)); + } + + visitConditionalLookupField(field: ConditionalLookupField): Result { + return field.innerField().andThen((inner) => inner.accept(this)); + } + + visitSingleLineTextField(field: SingleLineTextField): Result { + return this.unsupported(field); + } + + visitLongTextField(field: LongTextField): Result { + return this.unsupported(field); + } + + visitNumberField(field: NumberField): Result { + return this.unsupported(field); + } + + visitRatingField(field: RatingField): Result { + return this.unsupported(field); + } + + visitSingleSelectField(field: SingleSelectField): Result { + return this.unsupported(field); + } + + visitMultipleSelectField(field: MultipleSelectField): Result { + return this.unsupported(field); + } + + visitCheckboxField(field: CheckboxField): Result { + return this.unsupported(field); + } + + visitAttachmentField(field: AttachmentField): Result { + return this.unsupported(field); + } + + visitUserField(field: UserField): Result { + return this.unsupported(field); + } + + visitCreatedByField(field: CreatedByField): Result { + return this.unsupported(field); + } + + visitLastModifiedByField(field: LastModifiedByField): Result { + return this.unsupported(field); + } + + visitAutoNumberField(field: AutoNumberField): Result { + return this.unsupported(field); + } + + visitButtonField(field: ButtonField): Result { + return this.unsupported(field); + } + + visitLinkField(field: LinkField): Result { + return this.unsupported(field); + } + + private unsupported(field: Field): Result { + return err( + domainError.validation({ + code: 'calendar.date_timezone_unavailable', + message: `Calendar timezone is unavailable for Field: ${field.id().toString()}`, + details: { fieldId: field.id().toString(), fieldType: field.type().toString() }, + }) + ); + } +} diff --git a/packages/v2/core/src/domain/table/fields/visitors/FieldToSpecVisitor.spec.ts b/packages/v2/core/src/domain/table/fields/visitors/FieldToSpecVisitor.spec.ts index 7648dae00b..3ea22954cd 100644 --- a/packages/v2/core/src/domain/table/fields/visitors/FieldToSpecVisitor.spec.ts +++ b/packages/v2/core/src/domain/table/fields/visitors/FieldToSpecVisitor.spec.ts @@ -8,6 +8,7 @@ import { SetLinkValueByTitleSpec } from '../../records/specs/values/SetLinkValue import { SetLinkValueSpec } from '../../records/specs/values/SetLinkValueSpec'; import { SetNumberValueSpec } from '../../records/specs/values/SetNumberValueSpec'; import type { SetRatingValueSpec } from '../../records/specs/values/SetRatingValueSpec'; +import { SetMultipleSelectValueSpec } from '../../records/specs/values/SetMultipleSelectValueSpec'; import { SetSingleLineTextValueSpec } from '../../records/specs/values/SetSingleLineTextValueSpec'; import type { SetSingleSelectValueSpec } from '../../records/specs/values/SetSingleSelectValueSpec'; import { SetUserValueSpec } from '../../records/specs/values/SetUserValueSpec'; @@ -198,10 +199,11 @@ describe('FieldToSpecVisitor', () => { expect(result.isOk()).toBe(true); }); - it('accepts empty array', () => { + it('normalizes empty array to null', () => { const visitor = FieldToSpecVisitor.create([], false); const result = field.accept(visitor); expect(result.isOk()).toBe(true); + expect((result._unsafeUnwrap() as SetMultipleSelectValueSpec).value.toValue()).toBeNull(); }); }); @@ -312,6 +314,13 @@ describe('FieldToSpecVisitor', () => { expect(result._unsafeUnwrap()).toBeInstanceOf(SetSingleLineTextValueSpec); }); + it('normalizes empty string to null', () => { + const visitor = FieldToSpecVisitor.create('', false); + const result = field.accept(visitor); + expect(result.isOk()).toBe(true); + expect((result._unsafeUnwrap() as SetSingleLineTextValueSpec).value.toValue()).toBeNull(); + }); + it('converts boolean to string', () => { const visitor = FieldToSpecVisitor.create(true, false); const result = field.accept(visitor); @@ -402,24 +411,59 @@ describe('FieldToSpecVisitor', () => { name: createFieldName('Score'), })._unsafeUnwrap(); + const ratingValue = (input: unknown, typecast: boolean) => { + const result = field.accept(FieldToSpecVisitor.create(input, typecast)); + expect(result.isOk()).toBe(true); + return (result._unsafeUnwrap() as SetRatingValueSpec).value.toValue(); + }; + it('rejects out-of-range values in non-typecast mode', () => { const visitor = FieldToSpecVisitor.create(9, false); const result = field.accept(visitor); expect(result.isErr()).toBe(true); }); - it('clamps out-of-range values in typecast mode', () => { - const visitor = FieldToSpecVisitor.create(9, true); + it('rejects non-integer values in non-typecast mode', () => { + const visitor = FieldToSpecVisitor.create(2.7, false); const result = field.accept(visitor); - expect(result.isOk()).toBe(true); - expect((result._unsafeUnwrap() as SetRatingValueSpec).value.toValue()).toBe(5); + expect(result.isErr()).toBe(true); }); - it('truncates parsed string values in typecast mode', () => { - const visitor = FieldToSpecVisitor.create('3.6', true); - const result = field.accept(visitor); - expect(result.isOk()).toBe(true); - expect((result._unsafeUnwrap() as SetRatingValueSpec).value.toValue()).toBe(3); + it('accepts integer values in non-typecast mode', () => { + expect(ratingValue(3, false)).toBe(3); + }); + + it('clamps out-of-range values in typecast mode', () => { + expect(ratingValue(9, true)).toBe(5); + expect(ratingValue(5.5, true)).toBe(5); + }); + + it('rounds in-range fractional numbers in typecast mode', () => { + // Regression: previously only out-of-range values were rounded/clamped, + // so 2.7 was stored as-is and broke equality filters / strict rewrite. + expect(ratingValue(2.7, true)).toBe(3); + expect(ratingValue(4.6, true)).toBe(5); + expect(ratingValue(2.4, true)).toBe(2); + }); + + it('maps below-one values to null in typecast mode', () => { + // Teable ratings are null-empty; 0/negatives must not become 1 star. + expect(ratingValue(0, true)).toBeNull(); + expect(ratingValue(0.4, true)).toBeNull(); + expect(ratingValue(-3, true)).toBeNull(); + }); + + it('parses and rounds string values consistently in typecast mode', () => { + expect(ratingValue('3', true)).toBe(3); + expect(ratingValue('2.7', true)).toBe(3); + expect(ratingValue('3.6', true)).toBe(4); + expect(ratingValue('abc', true)).toBeNull(); + expect(ratingValue('', true)).toBeNull(); + }); + + it('accepts null value', () => { + expect(ratingValue(null, true)).toBeNull(); + expect(ratingValue(null, false)).toBeNull(); }); }); @@ -511,14 +555,26 @@ describe('FieldToSpecVisitor', () => { const visitor = FieldToSpecVisitor.create('not-a-date', true); const result = field.accept(visitor); expect(result.isOk()).toBe(true); + expect((result._unsafeUnwrap() as SetDateValueSpec).value.toValue()).toBeNull(); + }); + + it('returns null for calendar-invalid date in typecast mode', () => { + const visitor = FieldToSpecVisitor.create('2026-02-30', true); + const result = field.accept(visitor); + expect(result.isOk()).toBe(true); + expect((result._unsafeUnwrap() as SetDateValueSpec).value.toValue()).toBeNull(); }); it('rejects invalid date in non-typecast mode', () => { const visitor = FieldToSpecVisitor.create('not-a-date', false); const result = field.accept(visitor); - // May reject or return null depending on parseDateValue implementation - // The important thing is it doesn't throw - expect(result.isOk() || result.isErr()).toBe(true); + expect(result.isErr()).toBe(true); + }); + + it('rejects calendar-invalid date in non-typecast mode', () => { + const visitor = FieldToSpecVisitor.create('2026-02-30', false); + const result = field.accept(visitor); + expect(result.isErr()).toBe(true); }); it('rejects lookup arrays in non-typecast mode', () => { @@ -717,4 +773,82 @@ describe('FieldToSpecVisitor', () => { expect(autoNumberField.accept(FieldToSpecVisitor.create(1, false)).isErr()).toBe(true); }); }); + + // v1 stores "empty" inputs as null: "" (text), false (checkbox) and [] + // (multi-value fields). The typecast path must match too (T6520). + describe('empty value normalization (v1 parity)', () => { + it('normalizes boolean false to null for checkbox', () => { + const field = CheckboxField.create({ + id: createFieldId('h'), + name: createFieldName('Done'), + })._unsafeUnwrap(); + const spec = field + .accept(FieldToSpecVisitor.create(false, false)) + ._unsafeUnwrap() as SetCheckboxValueSpec; + expect(spec.value.isNull()).toBe(true); + }); + + it('normalizes string "false" to null for checkbox in typecast mode', () => { + const field = CheckboxField.create({ + id: createFieldId('h'), + name: createFieldName('Done'), + })._unsafeUnwrap(); + const spec = field + .accept(FieldToSpecVisitor.create('false', true)) + ._unsafeUnwrap() as SetCheckboxValueSpec; + expect(spec.value.isNull()).toBe(true); + }); + + it('normalizes "" to null for singleLineText in typecast mode', () => { + const field = SingleLineTextField.create({ + id: createFieldId('f'), + name: createFieldName('Title'), + })._unsafeUnwrap(); + const spec = field + .accept(FieldToSpecVisitor.create('', true)) + ._unsafeUnwrap() as SetSingleLineTextValueSpec; + expect(spec.value.isNull()).toBe(true); + }); + + it('normalizes [] to null for link', () => { + const config = LinkFieldConfig.create({ + relationship: 'manyMany', + foreignTableId: 'tbl' + 'x'.repeat(16), + lookupFieldId: 'fld' + 'y'.repeat(16), + isOneWay: true, + })._unsafeUnwrap(); + const field = LinkField.create({ + id: createFieldId('l'), + name: createFieldName('Related'), + config, + })._unsafeUnwrap(); + const spec = field + .accept(FieldToSpecVisitor.create([], false)) + ._unsafeUnwrap() as SetLinkValueSpec; + expect(spec.value.isNull()).toBe(true); + }); + + it('normalizes [] to null for multi-value user', () => { + const field = UserField.create({ + id: createFieldId('u'), + name: createFieldName('Team'), + isMultiple: UserMultiplicity.multiple(), + })._unsafeUnwrap(); + const spec = field + .accept(FieldToSpecVisitor.create([], false)) + ._unsafeUnwrap() as SetUserValueSpec; + expect(spec.value.isNull()).toBe(true); + }); + + it('normalizes [] to null for attachment', () => { + const field = AttachmentField.create({ + id: createFieldId('g'), + name: createFieldName('Files'), + })._unsafeUnwrap(); + const spec = field + .accept(FieldToSpecVisitor.create([], false)) + ._unsafeUnwrap() as SetAttachmentValueSpec; + expect(spec.value.isNull()).toBe(true); + }); + }); }); diff --git a/packages/v2/core/src/domain/table/fields/visitors/FieldToSpecVisitor.ts b/packages/v2/core/src/domain/table/fields/visitors/FieldToSpecVisitor.ts index 3100e10dd6..7c26c4cb70 100644 --- a/packages/v2/core/src/domain/table/fields/visitors/FieldToSpecVisitor.ts +++ b/packages/v2/core/src/domain/table/fields/visitors/FieldToSpecVisitor.ts @@ -153,14 +153,15 @@ export class FieldToSpecVisitor extends AbstractFieldVisitor { return ok(new SetRatingValueSpec(field.id(), CellValue.null())); } - let numValue: number | null = null; const max = field.ratingMax().toNumber(); + let rawNumber: number | null = null; if (typeof this.value === 'number') { - numValue = this.value; + rawNumber = this.value; } else if (this.typecast) { - const parsed = parseInt(String(this.value), 10); - numValue = isNaN(parsed) ? null : parsed; + // Share the same numeric path as number inputs so "2.7" and 2.7 converge. + const parsed = parseFloat(String(this.value)); + rawNumber = Number.isFinite(parsed) ? parsed : null; } else { return err( domainError.validation({ @@ -176,26 +177,45 @@ export class FieldToSpecVisitor extends AbstractFieldVisitor { ); } - // Range check and clamp - if (numValue !== null) { - if (numValue < 1 || numValue > max) { - if (this.typecast) { - // Clamp to valid range - numValue = Math.min(Math.max(1, Math.round(numValue)), max); - } else { + // Typecast output must stay inside the strict rating domain: {null} ∪ {1..max}. + // Always round first; only then clamp / null out-of-range values. + let numValue: number | null = null; + if (rawNumber !== null) { + if (!Number.isFinite(rawNumber)) { + if (!this.typecast) { return err( domainError.validation({ code: 'validation.field.out_of_range', - message: `Rating must be between 1 and ${max}, got ${numValue}`, + message: `Rating must be between 1 and ${max}, got ${rawNumber}`, details: { fieldId: field.id().toString(), min: 1, max, - actualValue: numValue, + actualValue: rawNumber, }, }) ); } + numValue = null; + } else if (this.typecast) { + const rounded = Math.round(rawNumber); + // Teable ratings are null-empty; 0/negatives are not valid stars. + numValue = rounded < 1 ? null : Math.min(rounded, max); + } else if (!Number.isInteger(rawNumber) || rawNumber < 1 || rawNumber > max) { + return err( + domainError.validation({ + code: 'validation.field.out_of_range', + message: `Rating must be between 1 and ${max}, got ${rawNumber}`, + details: { + fieldId: field.id().toString(), + min: 1, + max, + actualValue: rawNumber, + }, + }) + ); + } else { + numValue = rawNumber; } } @@ -210,23 +230,32 @@ export class FieldToSpecVisitor extends AbstractFieldVisitor { } if (typeof this.value === 'boolean') { - return ok(new SetCheckboxValueSpec(field.id(), CellValue.fromValidated(this.value))); + // v1 contract: a checkbox is either true or null — false is stored as null + return ok( + new SetCheckboxValueSpec( + field.id(), + this.value ? CellValue.fromValidated(true) : CellValue.null() + ) + ); } if (this.typecast) { - // String "true"/"false" conversion + // String "true"/"false" conversion; falsy inputs clear the cell (v1 repair semantics) if (typeof this.value === 'string') { const lower = this.value.toLowerCase(); if (lower === 'true' || lower === '1') { return ok(new SetCheckboxValueSpec(field.id(), CellValue.fromValidated(true))); } if (lower === 'false' || lower === '0' || lower === '') { - return ok(new SetCheckboxValueSpec(field.id(), CellValue.fromValidated(false))); + return ok(new SetCheckboxValueSpec(field.id(), CellValue.null())); } } - // truthy → true, falsy → false - const finalValue = this.value ? true : false; - return ok(new SetCheckboxValueSpec(field.id(), CellValue.fromValidated(finalValue))); + return ok( + new SetCheckboxValueSpec( + field.id(), + this.value ? CellValue.fromValidated(true) : CellValue.null() + ) + ); } return err( @@ -399,10 +428,12 @@ export class FieldToSpecVisitor extends AbstractFieldVisitor { if (parsed.type === 'ids') { // Standard format: [{ id: 'recXxx', title?: string }] // Pass foreignTableId so the resolver can look up missing titles + // An empty list clears the cell (stored as null, matching v1) + const items = parsed.value as LinkItem[]; return ok( new SetLinkValueSpec( field.id(), - CellValue.fromValidated(parsed.value as LinkItem[]), + items.length === 0 ? CellValue.null() : CellValue.fromValidated(items), field.foreignTableId() ) ); @@ -442,8 +473,11 @@ export class FieldToSpecVisitor extends AbstractFieldVisitor { const parsed = this.parseUserItems(this.value); if (parsed.valid) { + // An empty list clears the cell (stored as null, matching v1) const normalizedValue = field.multiplicity().toBoolean() - ? parsed.items + ? parsed.items.length === 0 + ? null + : parsed.items : parsed.items[0] ?? null; return ok( new SetUserValueSpec( @@ -489,7 +523,14 @@ export class FieldToSpecVisitor extends AbstractFieldVisitor { const parsed = this.parseAttachmentValue(this.value); if (parsed.valid) { - return ok(new SetAttachmentValueSpec(field.id(), CellValue.fromValidated(parsed.value))); + // An empty list clears the cell (stored as null, matching v1) + const items = parsed.value; + return ok( + new SetAttachmentValueSpec( + field.id(), + items == null || items.length === 0 ? CellValue.null() : CellValue.fromValidated(items) + ) + ); } if (this.typecast) { @@ -600,7 +641,9 @@ export class FieldToSpecVisitor extends AbstractFieldVisitor { private repairToString(value: unknown): string | null { if (value == null) return null; - return String(value); + // v1 stores cleared text cells as null, never as "" + const str = String(value); + return str === '' ? null : str; } private valueToStringArray(value: unknown): string[] | null { @@ -691,7 +734,8 @@ export class FieldToSpecVisitor extends AbstractFieldVisitor { // Standard format: [{ id?, name?, token?, size?, mimetype?, ... }] if (Array.isArray(value)) { if (value.length === 0) { - return { valid: true, value: [] }; + // Align with v1: empty attachment arrays are stored as null. + return { valid: true, value: null }; } if (this.typecast && value.every((v) => typeof v === 'string')) { diff --git a/packages/v2/core/src/domain/table/fields/visitors/SearchFieldTextShape.ts b/packages/v2/core/src/domain/table/fields/visitors/SearchFieldTextShape.ts new file mode 100644 index 0000000000..cad92e73ff --- /dev/null +++ b/packages/v2/core/src/domain/table/fields/visitors/SearchFieldTextShape.ts @@ -0,0 +1,169 @@ +import { ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../../shared/DomainError'; +import type { Field } from '../Field'; +import { FieldType } from '../FieldType'; +import { CellValueType } from '../types/CellValueType'; +import type { ConditionalLookupField } from '../types/ConditionalLookupField'; +import type { ConditionalRollupField } from '../types/ConditionalRollupField'; +import { DateTimeFormatting } from '../types/DateTimeFormatting'; +import type { FormulaField } from '../types/FormulaField'; +import type { LookupField } from '../types/LookupField'; +import type { NumberField } from '../types/NumberField'; +import type { NumberFormatting } from '../types/NumberFormatting'; +import type { RollupField } from '../types/RollupField'; +import { FieldValueTypeVisitor } from './FieldValueTypeVisitor'; + +/** + * Canonical per-field text shape for substring search. + * + * Every SQL consumer — the default ILIKE predicate, the generated + * search-document column, and the scoped recheck expressions — derives its + * projection from this one shape. That single source is what guarantees the + * indexed document prefilter is a superset of the exact predicate: both sides + * project a cell to the same text before matching. + */ +export type SearchFieldTextProjection = + | { readonly kind: 'plain' } + | { readonly kind: 'multiline' } + | { readonly kind: 'plain_list' } + | { readonly kind: 'structured_title' } + | { readonly kind: 'structured_title_list' } + | { readonly kind: 'rounded_number'; readonly precision: number }; + +export type SearchFieldTextShape = + | SearchFieldTextProjection + | { readonly kind: 'none' } + | { readonly kind: 'date_range' } + | { readonly kind: 'rounded_number_list'; readonly precision: number }; + +const projectionKinds: ReadonlySet = new Set([ + 'plain', + 'multiline', + 'plain_list', + 'structured_title', + 'structured_title_list', + 'rounded_number', +]); + +export const isSearchFieldTextProjection = ( + shape: SearchFieldTextShape +): shape is SearchFieldTextProjection => projectionKinds.has(shape.kind); + +export const searchFieldTextProjectionKey = (projection: SearchFieldTextProjection): string => + projection.kind === 'rounded_number' + ? `rounded_number(${projection.precision})` + : projection.kind; + +const fieldValueTypeVisitor = new FieldValueTypeVisitor(); + +export const resolveSearchShapeSourceField = (field: Field): Field => { + if ( + field.type().equals(FieldType.lookup()) || + field.type().equals(FieldType.conditionalLookup()) + ) { + const innerField = field.type().equals(FieldType.lookup()) + ? (field as LookupField).innerField() + : (field as ConditionalLookupField).innerField(); + if (innerField.isOk()) { + return resolveSearchShapeSourceField(innerField.value); + } + } + + return field; +}; + +const isStructuredStringField = (field: Field): boolean => { + const sourceField = resolveSearchShapeSourceField(field); + return ( + sourceField.type().equals(FieldType.user()) || + sourceField.type().equals(FieldType.createdBy()) || + sourceField.type().equals(FieldType.lastModifiedBy()) || + sourceField.type().equals(FieldType.link()) || + sourceField.type().equals(FieldType.attachment()) + ); +}; + +const isLongTextField = (field: Field): boolean => { + return resolveSearchShapeSourceField(field).type().equals(FieldType.longText()); +}; + +export const resolveSearchNumberFormatting = (field: Field): NumberFormatting | undefined => { + if ( + field.type().equals(FieldType.lookup()) || + field.type().equals(FieldType.conditionalLookup()) + ) { + const innerField = field.type().equals(FieldType.lookup()) + ? (field as LookupField).innerField() + : (field as ConditionalLookupField).innerField(); + return innerField.isOk() ? resolveSearchNumberFormatting(innerField.value) : undefined; + } + + if (field.type().equals(FieldType.number())) { + return (field as NumberField).formatting(); + } + + if ( + field.type().equals(FieldType.formula()) || + field.type().equals(FieldType.rollup()) || + field.type().equals(FieldType.conditionalRollup()) + ) { + const formatting = field.type().equals(FieldType.formula()) + ? (field as FormulaField).formatting() + : field.type().equals(FieldType.rollup()) + ? (field as RollupField).formatting() + : (field as ConditionalRollupField).formatting(); + + return formatting instanceof DateTimeFormatting ? undefined : formatting; + } + + return undefined; +}; + +export const resolveSearchNumberPrecision = (field: Field): number => { + return resolveSearchNumberFormatting(field)?.precision().toNumber() ?? 0; +}; + +export const resolveSearchFieldTextShape = ( + field: Field +): Result => { + if (field.type().equals(FieldType.button())) { + return ok({ kind: 'none' }); + } + + return field.accept(fieldValueTypeVisitor).map(({ cellValueType, isMultipleCellValue }) => { + const isMultiple = isMultipleCellValue.isMultiple(); + + if (isStructuredStringField(field)) { + return isMultiple + ? ({ kind: 'structured_title_list' } as const) + : ({ kind: 'structured_title' } as const); + } + + if (cellValueType.equals(CellValueType.boolean())) { + return { kind: 'none' } as const; + } + + if (cellValueType.equals(CellValueType.number())) { + const precision = resolveSearchNumberPrecision(field); + return isMultiple + ? ({ kind: 'rounded_number_list', precision } as const) + : ({ kind: 'rounded_number', precision } as const); + } + + if (cellValueType.equals(CellValueType.dateTime())) { + return { kind: 'date_range' } as const; + } + + if (isMultiple) { + return { kind: 'plain_list' } as const; + } + + if (isLongTextField(field)) { + return { kind: 'multiline' } as const; + } + + return { kind: 'plain' } as const; + }); +}; diff --git a/packages/v2/core/src/domain/table/fields/visitors/SearchVectorFieldContributionVisitor.spec.ts b/packages/v2/core/src/domain/table/fields/visitors/SearchVectorFieldContributionVisitor.spec.ts index 36c9879e39..261aaad160 100644 --- a/packages/v2/core/src/domain/table/fields/visitors/SearchVectorFieldContributionVisitor.spec.ts +++ b/packages/v2/core/src/domain/table/fields/visitors/SearchVectorFieldContributionVisitor.spec.ts @@ -2,10 +2,16 @@ import { describe, expect, it } from 'vitest'; import { FieldId } from '../FieldId'; import { FieldName } from '../FieldName'; +import { AttachmentField } from '../types/AttachmentField'; import { CheckboxField } from '../types/CheckboxField'; +import { DateField } from '../types/DateField'; import { LongTextField } from '../types/LongTextField'; +import { MultipleSelectField } from '../types/MultipleSelectField'; import { NumberField } from '../types/NumberField'; +import { SelectOption } from '../types/SelectOption'; import { SingleLineTextField } from '../types/SingleLineTextField'; +import { UserField } from '../types/UserField'; +import { UserMultiplicity } from '../types/UserMultiplicity'; import { SearchVectorFieldContributionVisitor } from './SearchVectorFieldContributionVisitor'; const fieldId = (value: string) => FieldId.create(value)._unsafeUnwrap(); @@ -21,7 +27,7 @@ describe('SearchVectorFieldContributionVisitor', () => { id: fieldId('fld0000000000000001'), name: fieldName('Title'), })._unsafeUnwrap(), - expected: { included: true, textProjection: 'text_cast' }, + expected: { included: true, textProjection: { kind: 'plain' } }, }, { name: 'long text', @@ -29,7 +35,7 @@ describe('SearchVectorFieldContributionVisitor', () => { id: fieldId('fld0000000000000002'), name: fieldName('Notes'), })._unsafeUnwrap(), - expected: { included: true, textProjection: 'text_cast' }, + expected: { included: true, textProjection: { kind: 'multiline' } }, }, { name: 'number', @@ -37,7 +43,7 @@ describe('SearchVectorFieldContributionVisitor', () => { id: fieldId('fld0000000000000003'), name: fieldName('Amount'), })._unsafeUnwrap(), - expected: { included: false, skippedReason: 'non_text_value' }, + expected: { included: true, textProjection: { kind: 'rounded_number', precision: 2 } }, }, { name: 'checkbox', @@ -45,7 +51,49 @@ describe('SearchVectorFieldContributionVisitor', () => { id: fieldId('fld0000000000000004'), name: fieldName('Done'), })._unsafeUnwrap(), - expected: { included: false, skippedReason: 'unsupported_search_field_type' }, + expected: { included: false, skippedReason: 'non_text_value' }, + }, + { + name: 'date', + field: DateField.create({ + id: fieldId('fld0000000000000005'), + name: fieldName('Due'), + })._unsafeUnwrap(), + expected: { included: false, skippedReason: 'non_text_value' }, + }, + { + name: 'single user', + field: UserField.create({ + id: fieldId('fld0000000000000006'), + name: fieldName('Owner'), + })._unsafeUnwrap(), + expected: { included: true, textProjection: { kind: 'structured_title' } }, + }, + { + name: 'multiple user', + field: UserField.create({ + id: fieldId('fld0000000000000007'), + name: fieldName('Collaborators'), + isMultiple: UserMultiplicity.multiple(), + })._unsafeUnwrap(), + expected: { included: true, textProjection: { kind: 'structured_title_list' } }, + }, + { + name: 'multiple select', + field: MultipleSelectField.create({ + id: fieldId('fld0000000000000008'), + name: fieldName('Tags'), + options: [SelectOption.create({ name: 'Alpha', color: 'blue' })._unsafeUnwrap()], + })._unsafeUnwrap(), + expected: { included: true, textProjection: { kind: 'plain_list' } }, + }, + { + name: 'attachment', + field: AttachmentField.create({ + id: fieldId('fld0000000000000009'), + name: fieldName('Files'), + })._unsafeUnwrap(), + expected: { included: true, textProjection: { kind: 'structured_title_list' } }, }, ])('$name has an explicit contribution decision', ({ field, expected }) => { const result = field.accept(visitor)._unsafeUnwrap(); diff --git a/packages/v2/core/src/domain/table/fields/visitors/SearchVectorFieldContributionVisitor.ts b/packages/v2/core/src/domain/table/fields/visitors/SearchVectorFieldContributionVisitor.ts index d81bf803fe..39cde29c4a 100644 --- a/packages/v2/core/src/domain/table/fields/visitors/SearchVectorFieldContributionVisitor.ts +++ b/packages/v2/core/src/domain/table/fields/visitors/SearchVectorFieldContributionVisitor.ts @@ -6,7 +6,6 @@ import type { Field } from '../Field'; import type { AttachmentField } from '../types/AttachmentField'; import type { AutoNumberField } from '../types/AutoNumberField'; import type { ButtonField } from '../types/ButtonField'; -import { CellValueType } from '../types/CellValueType'; import type { CheckboxField } from '../types/CheckboxField'; import type { ConditionalLookupField } from '../types/ConditionalLookupField'; import type { ConditionalRollupField } from '../types/ConditionalRollupField'; @@ -28,13 +27,18 @@ import type { SingleSelectField } from '../types/SingleSelectField'; import type { UserField } from '../types/UserField'; import { FieldValueTypeVisitor } from './FieldValueTypeVisitor'; import type { IFieldVisitor } from './IFieldVisitor'; +import { + isSearchFieldTextProjection, + resolveSearchFieldTextShape, + type SearchFieldTextProjection, +} from './SearchFieldTextShape'; export type SearchDocumentFieldContribution = { readonly fieldId: string; readonly fieldType: string; readonly valueType?: string; readonly included: boolean; - readonly textProjection?: 'text_cast'; + readonly textProjection?: SearchFieldTextProjection; readonly skippedReason?: | 'non_text_value' | 'unsupported_search_field_type' @@ -43,14 +47,6 @@ export type SearchDocumentFieldContribution = { const valueTypeVisitor = new FieldValueTypeVisitor(); -const include = (field: Field): SearchDocumentFieldContribution => ({ - fieldId: field.id().toString(), - fieldType: field.type().toString(), - valueType: CellValueType.string().toString(), - included: true, - textProjection: 'text_cast', -}); - const skip = ( field: Field, skippedReason: SearchDocumentFieldContribution['skippedReason'], @@ -66,14 +62,27 @@ const skip = ( export class SearchDocumentFieldContributionVisitor implements IFieldVisitor { - private byValueType(field: Field): Result { - return field - .accept(valueTypeVisitor) - .map(({ cellValueType }) => - cellValueType.equals(CellValueType.string()) - ? include(field) - : skip(field, 'non_text_value', cellValueType.toString()) - ); + /** + * Include the field when its canonical search text shape has an + * expression-index-safe projection; skip it otherwise. Keeping this on the + * shared shape resolver is what keeps the generated document, the default + * ILIKE predicate, and the scoped rechecks projecting identical text. + */ + private byShape(field: Field): Result { + return resolveSearchFieldTextShape(field).andThen((shape) => + field.accept(valueTypeVisitor).map(({ cellValueType }) => { + if (isSearchFieldTextProjection(shape)) { + return { + fieldId: field.id().toString(), + fieldType: field.type().toString(), + valueType: cellValueType.toString(), + included: true, + textProjection: shape, + } satisfies SearchDocumentFieldContribution; + } + return skip(field, 'non_text_value', cellValueType.toString()); + }) + ); } private unsupported(field: Field): Result { @@ -85,47 +94,47 @@ export class SearchDocumentFieldContributionVisitor } visitSingleLineTextField(field: SingleLineTextField) { - return ok(include(field)); + return this.byShape(field); } visitLongTextField(field: LongTextField) { - return ok(include(field)); + return this.byShape(field); } visitNumberField(field: NumberField) { - return this.byValueType(field); + return this.byShape(field); } visitRatingField(field: RatingField) { - return this.byValueType(field); + return this.byShape(field); } visitFormulaField(field: FormulaField) { - return this.byValueType(field); + return this.byShape(field); } visitRollupField(field: RollupField) { - return this.byValueType(field); + return this.byShape(field); } visitSingleSelectField(field: SingleSelectField) { - return ok(include(field)); + return this.byShape(field); } visitMultipleSelectField(field: MultipleSelectField) { - return ok(include(field)); + return this.byShape(field); } visitCheckboxField(field: CheckboxField) { - return this.unsupported(field); + return this.byShape(field); } visitAttachmentField(field: AttachmentField) { - return this.unsupported(field); + return this.byShape(field); } visitDateField(field: DateField) { - return this.byValueType(field); + return this.byShape(field); } visitCreatedTimeField(field: CreatedTimeField) { @@ -137,7 +146,7 @@ export class SearchDocumentFieldContributionVisitor } visitUserField(field: UserField) { - return ok(include(field)); + return this.byShape(field); } visitCreatedByField(field: CreatedByField) { @@ -157,19 +166,19 @@ export class SearchDocumentFieldContributionVisitor } visitLinkField(field: LinkField) { - return ok(include(field)); + return this.byShape(field); } visitLookupField(field: LookupField) { - return this.byValueType(field); + return this.byShape(field); } visitConditionalRollupField(field: ConditionalRollupField) { - return this.byValueType(field); + return this.byShape(field); } visitConditionalLookupField(field: ConditionalLookupField) { - return this.byValueType(field); + return this.byShape(field); } } diff --git a/packages/v2/core/src/domain/table/fields/visitors/SetFieldValueSpecFactoryVisitor.spec.ts b/packages/v2/core/src/domain/table/fields/visitors/SetFieldValueSpecFactoryVisitor.spec.ts index 39375df708..d070870795 100644 --- a/packages/v2/core/src/domain/table/fields/visitors/SetFieldValueSpecFactoryVisitor.spec.ts +++ b/packages/v2/core/src/domain/table/fields/visitors/SetFieldValueSpecFactoryVisitor.spec.ts @@ -1,9 +1,26 @@ import { describe, expect, it } from 'vitest'; import { NoopCellValueSpec } from '../../records/specs/values/NoopCellValueSpec'; +import type { SetAttachmentValueSpec } from '../../records/specs/values/SetAttachmentValueSpec'; +import type { SetCheckboxValueSpec } from '../../records/specs/values/SetCheckboxValueSpec'; +import type { SetLinkValueSpec } from '../../records/specs/values/SetLinkValueSpec'; +import type { SetLongTextValueSpec } from '../../records/specs/values/SetLongTextValueSpec'; +import type { SetMultipleSelectValueSpec } from '../../records/specs/values/SetMultipleSelectValueSpec'; +import type { SetSingleLineTextValueSpec } from '../../records/specs/values/SetSingleLineTextValueSpec'; +import type { SetUserValueSpec } from '../../records/specs/values/SetUserValueSpec'; import { FieldId } from '../FieldId'; import { FieldName } from '../FieldName'; +import { AttachmentField } from '../types/AttachmentField'; import { ButtonField } from '../types/ButtonField'; +import { CheckboxField } from '../types/CheckboxField'; +import { LinkField } from '../types/LinkField'; +import { LinkFieldConfig } from '../types/LinkFieldConfig'; +import { LongTextField } from '../types/LongTextField'; +import { MultipleSelectField } from '../types/MultipleSelectField'; +import { SelectOption } from '../types/SelectOption'; +import { SingleLineTextField } from '../types/SingleLineTextField'; +import { UserField } from '../types/UserField'; +import { UserMultiplicity } from '../types/UserMultiplicity'; import { SetFieldValueSpecFactoryVisitor } from './SetFieldValueSpecFactoryVisitor'; const createFieldId = (seed: string) => @@ -24,4 +41,116 @@ describe('SetFieldValueSpecFactoryVisitor', () => { expect(result._unsafeUnwrap()).toBeInstanceOf(NoopCellValueSpec); }); }); + + // v1 stores "empty" inputs as null: "" (text), false (checkbox) and [] + // (multi-value fields). v2 must produce the same stored value (T6520). + describe('empty value normalization (v1 parity)', () => { + it('normalizes "" to null for singleLineText', () => { + const field = SingleLineTextField.create({ + id: createFieldId('t'), + name: createFieldName('Title'), + })._unsafeUnwrap(); + const spec = field + .accept(new SetFieldValueSpecFactoryVisitor('')) + ._unsafeUnwrap() as SetSingleLineTextValueSpec; + expect(spec.value.isNull()).toBe(true); + }); + + it('keeps non-empty text as-is for singleLineText', () => { + const field = SingleLineTextField.create({ + id: createFieldId('t'), + name: createFieldName('Title'), + })._unsafeUnwrap(); + const spec = field + .accept(new SetFieldValueSpecFactoryVisitor('hello')) + ._unsafeUnwrap() as SetSingleLineTextValueSpec; + expect(spec.value.toValue()).toBe('hello'); + }); + + it('normalizes "" to null for longText', () => { + const field = LongTextField.create({ + id: createFieldId('n'), + name: createFieldName('Notes'), + })._unsafeUnwrap(); + const spec = field + .accept(new SetFieldValueSpecFactoryVisitor('')) + ._unsafeUnwrap() as SetLongTextValueSpec; + expect(spec.value.isNull()).toBe(true); + }); + + it('normalizes false to null for checkbox', () => { + const field = CheckboxField.create({ + id: createFieldId('c'), + name: createFieldName('Done'), + })._unsafeUnwrap(); + const spec = field + .accept(new SetFieldValueSpecFactoryVisitor(false)) + ._unsafeUnwrap() as SetCheckboxValueSpec; + expect(spec.value.isNull()).toBe(true); + }); + + it('keeps true as-is for checkbox', () => { + const field = CheckboxField.create({ + id: createFieldId('c'), + name: createFieldName('Done'), + })._unsafeUnwrap(); + const spec = field + .accept(new SetFieldValueSpecFactoryVisitor(true)) + ._unsafeUnwrap() as SetCheckboxValueSpec; + expect(spec.value.toValue()).toBe(true); + }); + + it('normalizes [] to null for multipleSelect', () => { + const field = MultipleSelectField.create({ + id: createFieldId('m'), + name: createFieldName('Tags'), + options: [SelectOption.create({ id: 'opt1', name: 'One', color: 'red' })._unsafeUnwrap()], + })._unsafeUnwrap(); + const spec = field + .accept(new SetFieldValueSpecFactoryVisitor([])) + ._unsafeUnwrap() as SetMultipleSelectValueSpec; + expect(spec.value.isNull()).toBe(true); + }); + + it('normalizes [] to null for attachment', () => { + const field = AttachmentField.create({ + id: createFieldId('f'), + name: createFieldName('Files'), + })._unsafeUnwrap(); + const spec = field + .accept(new SetFieldValueSpecFactoryVisitor([])) + ._unsafeUnwrap() as SetAttachmentValueSpec; + expect(spec.value.isNull()).toBe(true); + }); + + it('normalizes [] to null for multi-value user', () => { + const field = UserField.create({ + id: createFieldId('u'), + name: createFieldName('Team'), + isMultiple: UserMultiplicity.multiple(), + })._unsafeUnwrap(); + const spec = field + .accept(new SetFieldValueSpecFactoryVisitor([])) + ._unsafeUnwrap() as SetUserValueSpec; + expect(spec.value.isNull()).toBe(true); + }); + + it('normalizes [] to null for link', () => { + const config = LinkFieldConfig.create({ + relationship: 'manyMany', + foreignTableId: 'tbl' + 'x'.repeat(16), + lookupFieldId: 'fld' + 'y'.repeat(16), + isOneWay: true, + })._unsafeUnwrap(); + const field = LinkField.create({ + id: createFieldId('l'), + name: createFieldName('Related'), + config, + })._unsafeUnwrap(); + const spec = field + .accept(new SetFieldValueSpecFactoryVisitor([])) + ._unsafeUnwrap() as SetLinkValueSpec; + expect(spec.value.isNull()).toBe(true); + }); + }); }); diff --git a/packages/v2/core/src/domain/table/fields/visitors/SetFieldValueSpecFactoryVisitor.ts b/packages/v2/core/src/domain/table/fields/visitors/SetFieldValueSpecFactoryVisitor.ts index c83f84e7c0..0f55960b1a 100644 --- a/packages/v2/core/src/domain/table/fields/visitors/SetFieldValueSpecFactoryVisitor.ts +++ b/packages/v2/core/src/domain/table/fields/visitors/SetFieldValueSpecFactoryVisitor.ts @@ -3,6 +3,7 @@ import type { Result } from 'neverthrow'; import { domainError, type DomainError } from '../../../shared/DomainError'; import type { ICellValueSpec } from '../../records/specs/values/ICellValueSpecVisitor'; +import { NoopCellValueSpec } from '../../records/specs/values/NoopCellValueSpec'; import { SetAttachmentValueSpec, type AttachmentItem, @@ -17,7 +18,6 @@ import { SetRatingValueSpec } from '../../records/specs/values/SetRatingValueSpe import { SetSingleLineTextValueSpec } from '../../records/specs/values/SetSingleLineTextValueSpec'; import { SetSingleSelectValueSpec } from '../../records/specs/values/SetSingleSelectValueSpec'; import { SetUserValueSpec, type UserItem } from '../../records/specs/values/SetUserValueSpec'; -import { NoopCellValueSpec } from '../../records/specs/values/NoopCellValueSpec'; import { CellValue } from '../../records/values/CellValue'; import type { AttachmentField } from '../types/AttachmentField'; import type { AutoNumberField } from '../types/AutoNumberField'; @@ -71,13 +71,29 @@ export class SetFieldValueSpecFactoryVisitor extends AbstractFieldVisitor(): T[] | null { + if (Array.isArray(this.value) && this.value.length === 0) { + return null; + } + return this.value as T[] | null; + } + visitSingleLineTextField(field: SingleLineTextField): Result { - const cellValue = CellValue.fromValidated(this.value as string | null); + const cellValue = CellValue.fromValidated(this.emptyStringToNull()); return ok(new SetSingleLineTextValueSpec(field.id(), cellValue)); } visitLongTextField(field: LongTextField): Result { - const cellValue = CellValue.fromValidated(this.value as string | null); + const cellValue = CellValue.fromValidated(this.emptyStringToNull()); return ok(new SetLongTextValueSpec(field.id(), cellValue)); } @@ -107,12 +123,13 @@ export class SetFieldValueSpecFactoryVisitor extends AbstractFieldVisitor { - const cellValue = CellValue.fromValidated(this.value as string[] | null); + const cellValue = CellValue.fromValidated(this.emptyArrayToNull()); return ok(new SetMultipleSelectValueSpec(field.id(), cellValue)); } visitCheckboxField(field: CheckboxField): Result { - if (this.value == null) { + // v1 contract: a checkbox is either true or null — false is stored as null + if (this.value == null || this.value === false) { return ok(new SetCheckboxValueSpec(field.id(), CellValue.null())); } @@ -126,7 +143,7 @@ export class SetFieldValueSpecFactoryVisitor extends AbstractFieldVisitor { const cellValue = CellValue.fromValidated( - this.value as AttachmentItem[] | null + this.emptyArrayToNull() ); return ok(new SetAttachmentValueSpec(field.id(), cellValue)); } @@ -151,7 +168,10 @@ export class SetFieldValueSpecFactoryVisitor extends AbstractFieldVisitor { - const cellValue = CellValue.fromValidated(this.value as UserItem[] | null); + const normalized = Array.isArray(this.value) + ? this.emptyArrayToNull() + : (this.value as UserItem[] | null); + const cellValue = CellValue.fromValidated(normalized); return ok(new SetUserValueSpec(field.id(), cellValue)); } @@ -176,7 +196,10 @@ export class SetFieldValueSpecFactoryVisitor extends AbstractFieldVisitor { - const cellValue = CellValue.fromValidated(this.value as LinkItem[] | null); + const normalized = Array.isArray(this.value) + ? this.emptyArrayToNull() + : (this.value as LinkItem[] | null); + const cellValue = CellValue.fromValidated(normalized); return ok(new SetLinkValueSpec(field.id(), cellValue, field.foreignTableId())); } diff --git a/packages/v2/core/src/domain/table/fields/visitors/dateValueParser.spec.ts b/packages/v2/core/src/domain/table/fields/visitors/dateValueParser.spec.ts index b865b37dd2..cc92c54a73 100644 --- a/packages/v2/core/src/domain/table/fields/visitors/dateValueParser.spec.ts +++ b/packages/v2/core/src/domain/table/fields/visitors/dateValueParser.spec.ts @@ -80,4 +80,25 @@ describe('parseDateValue', () => { formatterSpy.mockRestore(); }); + + it.each([ + '2026-02-30', + '2026-02-29', + '2026-01-32', + '2026-00-10', + '2026-13-01', + '2026-03-01 25:00', + '2026-03-01 10:61', + '2026-03-01 10:30:61', + '2026-02-30T00:00:00Z', + ])('rejects calendar-invalid date input %s', (input) => { + expect(parseDateValue(createDateField('utc'), input)).toBeUndefined(); + }); + + it.each([ + ['2024-02-29', '2024-02-29T00:00:00.000Z'], + ['2026-12-31 23:59:59', '2026-12-31T23:59:59.000Z'], + ])('accepts valid calendar boundary %s', (input, expected) => { + expect(parseDateValue(createDateField('utc'), input)).toBe(expected); + }); }); diff --git a/packages/v2/core/src/domain/table/fields/visitors/dateValueParser.ts b/packages/v2/core/src/domain/table/fields/visitors/dateValueParser.ts index 8959d143ae..af1b62a70c 100644 --- a/packages/v2/core/src/domain/table/fields/visitors/dateValueParser.ts +++ b/packages/v2/core/src/domain/table/fields/visitors/dateValueParser.ts @@ -6,6 +6,9 @@ const normalizeTimeZone = (timeZone: string) => const timeZoneFormatterCache = new Map(); +const DATE_COMPONENT_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})(?:[T\s](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?)?(?:[zZ]|[+-]\d{2}:\d{2})?$/; + const getTimeZoneFormatter = (timeZone: string): Intl.DateTimeFormat => { const normalizedTimeZone = normalizeTimeZone(timeZone); const cachedFormatter = timeZoneFormatterCache.get(normalizedTimeZone); @@ -19,7 +22,7 @@ const getTimeZoneFormatter = (timeZone: string): Intl.DateTimeFormat => { hour: '2-digit', minute: '2-digit', second: '2-digit', - hour12: false, + hourCycle: 'h23', }); timeZoneFormatterCache.set(normalizedTimeZone, formatter); return formatter; @@ -30,8 +33,9 @@ const getTimeZoneOffsetMinutes = (date: Date, timeZone: string): number => { const parts = formatter.formatToParts(date); const values: Record = {}; for (const part of parts) { - if (part.type === 'literal') continue; - values[part.type] = Number(part.value); + if (part.type !== 'literal') { + values[part.type] = Number(part.value); + } } const utcTime = Date.UTC( @@ -45,44 +49,97 @@ const getTimeZoneOffsetMinutes = (date: Date, timeZone: string): number => { return (utcTime - date.getTime()) / 60000; }; +type DateComponents = { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; + millisecond: number; +}; + +const parseDateComponents = (value: string): DateComponents | undefined => { + const match = value.match(DATE_COMPONENT_PATTERN); + if (!match) return undefined; + + return { + year: Number(match[1]), + month: Number(match[2]), + day: Number(match[3]), + hour: Number(match[4] ?? 0), + minute: Number(match[5] ?? 0), + second: Number(match[6] ?? 0), + millisecond: Number((match[7] ?? '0').padEnd(3, '0')), + }; +}; + +const isExactUtcDate = (date: Date, components: DateComponents): boolean => + !isNaN(date.getTime()) && + date.getUTCFullYear() === components.year && + date.getUTCMonth() === components.month - 1 && + date.getUTCDate() === components.day && + date.getUTCHours() === components.hour && + date.getUTCMinutes() === components.minute && + date.getUTCSeconds() === components.second && + date.getUTCMilliseconds() === components.millisecond; + +const isValidCalendarComponents = (components: DateComponents): boolean => { + // Validate the local/wall-clock components themselves. Date.UTC and Date.parse both + // roll invalid day/time values (2026-02-30 → 2026-03-02), so callers must reject before + // converting timezones or offsets. + const probe = new Date( + Date.UTC( + components.year, + components.month - 1, + components.day, + components.hour, + components.minute, + components.second, + components.millisecond + ) + ); + return isExactUtcDate(probe, components); +}; + const parseDateStringWithTimeZone = (value: string, timeZone: string): string | undefined => { const trimmed = value.trim(); if (!trimmed) return undefined; + const components = parseDateComponents(trimmed); + if (!components) { + const parsed = new Date(trimmed); + return isNaN(parsed.getTime()) ? undefined : parsed.toISOString(); + } + + if (!isValidCalendarComponents(components)) { + return undefined; + } + const hasTimeZoneSuffix = /[zZ]|[+-]\d{2}:\d{2}$/.test(trimmed); if (hasTimeZoneSuffix) { const parsed = new Date(trimmed); - if (!isNaN(parsed.getTime())) { - return parsed.toISOString(); - } + return isNaN(parsed.getTime()) ? undefined : parsed.toISOString(); } - const match = trimmed.match( - /^(\d{4})-(\d{2})-(\d{2})(?:[T\s](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?)?$/ + const utcBase = new Date( + Date.UTC( + components.year, + components.month - 1, + components.day, + components.hour, + components.minute, + components.second, + components.millisecond + ) ); - if (match) { - const year = Number(match[1]); - const month = Number(match[2]); - const day = Number(match[3]); - const hour = Number(match[4] ?? 0); - const minute = Number(match[5] ?? 0); - const second = Number(match[6] ?? 0); - const millisecond = Number((match[7] ?? '0').padEnd(3, '0')); - const utcBase = new Date(Date.UTC(year, month - 1, day, hour, minute, second, millisecond)); - const normalizedTimeZone = normalizeTimeZone(timeZone); - if (normalizedTimeZone === 'UTC') return utcBase.toISOString(); - - const offsetMinutes = getTimeZoneOffsetMinutes(utcBase, normalizedTimeZone); - const adjusted = new Date(utcBase.getTime() - offsetMinutes * 60000); - return adjusted.toISOString(); - } - const parsed = new Date(trimmed); - if (!isNaN(parsed.getTime())) { - return parsed.toISOString(); - } + const normalizedTimeZone = normalizeTimeZone(timeZone); + if (normalizedTimeZone === 'UTC') return utcBase.toISOString(); - return undefined; + const offsetMinutes = getTimeZoneOffsetMinutes(utcBase, normalizedTimeZone); + const adjusted = new Date(utcBase.getTime() - offsetMinutes * 60000); + return adjusted.toISOString(); }; export const parseDateValue = (field: DateField, value: unknown): string | null | undefined => { diff --git a/packages/v2/core/src/domain/table/methods/ARCHITECTURE.md b/packages/v2/core/src/domain/table/methods/ARCHITECTURE.md index 7a34cd07b8..3404c2bcd2 100644 --- a/packages/v2/core/src/domain/table/methods/ARCHITECTURE.md +++ b/packages/v2/core/src/domain/table/methods/ARCHITECTURE.md @@ -14,9 +14,28 @@ Declaration: If the folder I belong to changes, please update me, especially cor ## Files - `ARCHITECTURE.md` - Role: folder architecture note; Purpose: describe method extraction approach. +- `createView.ts` - Role: Table aggregate method; Purpose: create and initialize a View, enforce + Table-scoped defaults/invariants, and return the resulting mutation specification. +- `createViewLinkRecordsQueryPlan.ts` - Role: Table aggregate query planner; Purpose: validate an + owned View and Link Field, enforce share visibility, and choose candidate/selected Record scope. +- `createViewCollaboratorsQueryPlan.ts` - Role: Table aggregate query planner; Purpose: keep View + subtype, visible user-related Field, and all-versus-referenced collaborator policy inside Table. +- `createViewSelectionCopyPlan.ts` - Role: Table aggregate query planner; Purpose: bind clipboard + ranges and projections to an owned shared View, enforce share metadata, and expose bounded + Table Record read windows. +- `createCollapsedGroupExclusionFilter.ts` - Role: Table aggregate query planner; Purpose: turn + collapsed group paths into canonical Record filters using aggregate-owned Field semantics. +- `deleteView.ts` - Role: Table aggregate methods; Purpose: enforce owned/last-View invariants, derive + cross-aggregate Link cleanup plans, and clear matching Link filter dependencies. - `duplicate.ts` - Role: method function; Purpose: duplicate a table aggregate by remapping internal ids/references while preserving external-table semantics. - `rename.ts` - Role: method function; Purpose: rename table and emit TableRenamed event. +- `updateViewSort.ts` - Role: Table aggregate method; Purpose: validate owned sort Fields, preserve + unrelated View query defaults, and return a focused query-defaults mutation spec. +- `applyViewManualSort.ts` - Role: Table aggregate method; Purpose: validate Grid View ownership and + sort Fields, enable manual mode, and return row-order materialization/storage intent. - `validateFormSubmission.ts` - Role: method function; Purpose: validate form-submit constraints (view type, visible fields, required fields). +- `viewFilterLinkReferences.ts` - Role: Table aggregate query method; Purpose: resolve linked-record + references from an owned View filter and the Table's Link Fields. - `records/ARCHITECTURE.md` - Role: subfolder architecture note; Purpose: describe record method functions. diff --git a/packages/v2/core/src/domain/table/methods/applyViewManualSort.spec.ts b/packages/v2/core/src/domain/table/methods/applyViewManualSort.spec.ts new file mode 100644 index 0000000000..230dd71857 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/applyViewManualSort.spec.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { ViewManualSortApplied } from '../events/ViewManualSortApplied'; +import { ViewSortUpdated } from '../events/ViewSortUpdated'; +import { FieldName } from '../fields/FieldName'; +import { TableUpdateViewQueryDefaultsSpec } from '../specs/TableUpdateViewQueryDefaultsSpec'; +import { TableEnsureViewRowOrderSpec } from '../specs/TableEnsureViewRowOrderSpec'; +import { Table } from '../Table'; +import { TableId } from '../TableId'; +import { TableName } from '../TableName'; +import { ViewId } from '../views/ViewId'; + +const buildTable = (suffix = 'a'): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${suffix.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${suffix.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Manual sort views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.field().number().withName(FieldName.create('Amount')._unsafeUnwrap()).done(); + builder.field().button().withName(FieldName.create('Action')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +describe('Table.applyViewManualSort', () => { + it('owns validation and emits View state plus row-order materialization intent', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const sort = [ + { fieldId: table.getFields()[0]!.id().toString(), order: 'asc' as const }, + { fieldId: table.getFields()[1]!.id().toString(), order: 'desc' as const }, + ]; + + const result = table.applyViewManualSort(viewId, sort)._unsafeUnwrap(); + + expect(result.nextSort).toEqual({ sortObjs: sort, manualSort: true }); + expect(result.nextQueryDefaults.manualSort()).toBe(true); + expect(result.updateResult?.mutateSpec).toBeInstanceOf(TableUpdateViewQueryDefaultsSpec); + expect(result.rowOrderStorageSpec).toBeInstanceOf(TableEnsureViewRowOrderSpec); + const events = result.table.pullDomainEvents(); + expect(events).toHaveLength(2); + expect(events[0]).toBeInstanceOf(ViewSortUpdated); + expect(events[1]).toBeInstanceOf(ViewManualSortApplied); + expect(events[1]).toMatchObject({ viewId, sort }); + }); + + it('supports empty sort and treats an identical manual state as a no-op', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const first = table.applyViewManualSort(viewId, [])._unsafeUnwrap(); + expect(first.nextSort).toEqual({ sortObjs: [], manualSort: true }); + first.table.pullDomainEvents(); + + const identical = first.table.applyViewManualSort(viewId, [])._unsafeUnwrap(); + expect(identical.updateResult).toBeUndefined(); + expect(identical.table.pullDomainEvents()).toEqual([]); + }); + + it('rejects another aggregate child, missing fields, Button fields, and malformed sort', () => { + const table = buildTable('b'); + const another = buildTable('c'); + const viewId = table.views()[0]!.id(); + + expect(table.applyViewManualSort(another.views()[0]!.id(), [])._unsafeUnwrapErr().code).toBe( + 'view.not_found' + ); + expect( + table + .applyViewManualSort(viewId, [{ fieldId: `fld${'z'.repeat(16)}`, order: 'asc' }]) + ._unsafeUnwrapErr().code + ).toBe('field.not_found'); + expect( + table + .applyViewManualSort(viewId, [ + { fieldId: table.getFields()[2]!.id().toString(), order: 'desc' }, + ]) + ._unsafeUnwrapErr().code + ).toBe('view.sort_unsupported_field_type'); + expect( + table.applyViewManualSort(ViewId.create(`viw${'z'.repeat(16)}`)._unsafeUnwrap(), []).isErr() + ).toBe(true); + expect(table.applyViewManualSort(viewId, [{ fieldId: 'bad', order: 'up' }]).isErr()).toBe(true); + }); + + it('rejects manual row-order materialization for a non-Grid View', () => { + const table = buildTable('d'); + const galleryBuilder = Table.builder() + .withBaseId(table.baseId()) + .withId(TableId.create(`tbl${'e'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('View source')._unsafeUnwrap()); + galleryBuilder + .field() + .singleLineText() + .withName(FieldName.create('Name')._unsafeUnwrap()) + .done(); + galleryBuilder.view().gallery().defaultName().done(); + const gallery = galleryBuilder.build()._unsafeUnwrap().views()[0]!; + const update = table.update((mutator) => mutator.addView(gallery)); + const galleryTable = update._unsafeUnwrap().table; + const galleryView = galleryTable.views().find((view) => view.type().toString() === 'gallery')!; + + expect(galleryTable.applyViewManualSort(galleryView.id(), [])._unsafeUnwrapErr().code).toBe( + 'view.manual_sort_unsupported_type' + ); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/applyViewManualSort.ts b/packages/v2/core/src/domain/table/methods/applyViewManualSort.ts new file mode 100644 index 0000000000..8d2823baf6 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/applyViewManualSort.ts @@ -0,0 +1,88 @@ +import { err, ok, safeTry, type Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import { ViewManualSortApplied } from '../events/ViewManualSortApplied'; +import type { Table } from '../Table'; +import type { TableUpdateResult } from '../TableMutator'; +import { TableEnsureViewRowOrderSpec } from '../specs/TableEnsureViewRowOrderSpec'; +import type { ITableSpecVisitor } from '../specs/ITableSpecVisitor'; +import type { ISpecification } from '../../shared/specification/ISpecification'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; +import type { ViewQueryDefaults } from '../views/ViewQueryDefaults'; +import type { ViewSortDTO, ViewSortItem } from '../views/ViewSort'; +import { ViewType } from '../views/ViewType'; +import { updateViewSort } from './updateViewSort'; + +export type ApplyViewManualSortMethodResult = { + readonly table: Table; + readonly view: View; + readonly sort: ReadonlyArray; + readonly previousSort: ViewSortDTO; + readonly nextSort: ViewSortDTO; + readonly previousQueryDefaults: ViewQueryDefaults; + readonly nextQueryDefaults: ViewQueryDefaults; + readonly rowOrderStorageSpec: ISpecification; + readonly updateResult?: TableUpdateResult; +}; + +export function applyViewManualSort( + this: Table, + viewId: ViewId, + rawSort: unknown +): Result { + const table = this; + return safeTry(function* () { + const view = yield* table.getView(viewId); + if (!view.type().equals(ViewType.grid())) { + return err( + domainError.validation({ + code: 'view.manual_sort_unsupported_type', + message: `Manual sort requires a Grid view, received ${view.type().toString()}`, + }) + ); + } + + const sortResult = yield* updateViewSort.call(table, viewId, { + sortObjs: rawSort, + manualSort: true, + }); + const sort = sortResult.nextSort?.sortObjs ?? []; + const rowOrderStorageSpec = TableEnsureViewRowOrderSpec.create(view); + + if (!sortResult.updateResult) { + return ok({ + table, + view: sortResult.view, + sort, + previousSort: sortResult.previousSort, + nextSort: sortResult.nextSort, + previousQueryDefaults: sortResult.previousQueryDefaults, + nextQueryDefaults: sortResult.nextQueryDefaults, + rowOrderStorageSpec, + }); + } + + const nextTable = sortResult.updateResult.table; + nextTable.addDomainEvent( + ViewManualSortApplied.create({ + tableId: nextTable.id(), + baseId: nextTable.baseId(), + viewId, + sort, + }) + ); + + return ok({ + table: nextTable, + view: sortResult.view, + sort, + previousSort: sortResult.previousSort, + nextSort: sortResult.nextSort, + previousQueryDefaults: sortResult.previousQueryDefaults, + nextQueryDefaults: sortResult.nextQueryDefaults, + rowOrderStorageSpec, + updateResult: sortResult.updateResult, + }); + }); +} diff --git a/packages/v2/core/src/domain/table/methods/applyViewSnapshot.spec.ts b/packages/v2/core/src/domain/table/methods/applyViewSnapshot.spec.ts new file mode 100644 index 0000000000..b4e526ea68 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/applyViewSnapshot.spec.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { FieldName } from '../fields/FieldName'; +import { Table } from '../Table'; +import { TableName } from '../TableName'; +import { ViewColumnMeta } from '../views/ViewColumnMeta'; +import { ViewName } from '../views/ViewName'; +import { ViewOrder } from '../views/ViewOrder'; +import { ViewQueryDefaults } from '../views/ViewQueryDefaults'; +import { captureViewSnapshot, rehydrateViewSnapshot } from '../views/ViewSnapshot'; + +const buildTable = () => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'s'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Snapshot')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + builder.view().grid().withName(ViewName.create('Second')._unsafeUnwrap()).done(); + const table = builder.build()._unsafeUnwrap(); + const fieldId = table.primaryFieldId().toString(); + for (const [index, view] of table.views().entries()) { + view.setColumnMeta( + ViewColumnMeta.create({ [fieldId]: { order: 0, width: 200 + index } })._unsafeUnwrap() + ); + view.setQueryDefaults(ViewQueryDefaults.rehydrate({})._unsafeUnwrap()); + view.setOptions({ rowHeight: 'short' }); + view.setOrder(ViewOrder.rehydrate(index)._unsafeUnwrap()); + } + return table; +}; + +describe('Table.applyViewSnapshot', () => { + it('restores changed child state through existing Table mutation specs', () => { + const table = buildTable(); + const target = table.views()[0]!; + const snapshot = captureViewSnapshot(target)._unsafeUnwrap(); + const fieldId = table.primaryFieldId().toString(); + const nextView = rehydrateViewSnapshot({ + ...snapshot, + name: 'Restored name', + order: 12, + properties: { + ...snapshot.properties, + description: 'Restored description', + isLocked: true, + }, + columnMeta: { [fieldId]: { order: 0, width: 420, hidden: true } }, + options: { rowHeight: 'tall' }, + })._unsafeUnwrap(); + + const result = table.applyViewSnapshot(nextView)._unsafeUnwrap(); + expect(result.updateResult).toBeDefined(); + const updated = result.updateResult!.table.getView(target.id())._unsafeUnwrap(); + expect(updated.name().toString()).toBe('Restored name'); + expect(updated.description()).toBe('Restored description'); + expect(updated.isLocked()).toBe(true); + expect(updated.order()._unsafeUnwrap().toNumber()).toBe(12); + expect(updated.columnMeta()._unsafeUnwrap().toDto()[fieldId]).toEqual({ + order: 0, + width: 420, + hidden: true, + }); + expect(updated.options()).toEqual({ rowHeight: 'tall' }); + }); + + it('revives a missing View child with the same identity and full snapshot', () => { + const table = buildTable(); + const target = table.views()[0]!; + const snapshot = captureViewSnapshot(target)._unsafeUnwrap(); + const deletedTable = table.deleteView(target.id())._unsafeUnwrap().updateResult.table; + expect(deletedTable.getView(target.id()).isErr()).toBe(true); + + const snapshotView = rehydrateViewSnapshot(snapshot)._unsafeUnwrap(); + const result = deletedTable.applyViewSnapshot(snapshotView)._unsafeUnwrap(); + const restored = result.updateResult!.table.getView(target.id())._unsafeUnwrap(); + expect(restored.id().equals(target.id())).toBe(true); + expect(captureViewSnapshot(restored)._unsafeUnwrap()).toEqual(snapshot); + }); + + it('returns no mutation for an identical snapshot', () => { + const table = buildTable(); + const target = table.views()[0]!; + const snapshotView = rehydrateViewSnapshot( + captureViewSnapshot(target)._unsafeUnwrap() + )._unsafeUnwrap(); + + expect(table.applyViewSnapshot(snapshotView)._unsafeUnwrap().updateResult).toBeUndefined(); + }); + + it('clears optional description and options when the snapshot omits them', () => { + const originalTable = buildTable(); + const targetId = originalTable.views()[0]!.id(); + const table = originalTable + .update((mutator) => mutator.updateViewDescription(targetId, 'Temporary description')) + ._unsafeUnwrap().table; + const target = table.views()[0]!; + const snapshot = captureViewSnapshot(target)._unsafeUnwrap(); + const snapshotView = rehydrateViewSnapshot({ + ...snapshot, + properties: { + ...snapshot.properties, + description: undefined, + }, + options: undefined, + })._unsafeUnwrap(); + + const result = table.applyViewSnapshot(snapshotView)._unsafeUnwrap(); + const restored = result.updateResult!.table.getView(target.id())._unsafeUnwrap(); + expect(restored.description()).toBeUndefined(); + expect(restored.options()).toBeUndefined(); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/applyViewSnapshot.ts b/packages/v2/core/src/domain/table/methods/applyViewSnapshot.ts new file mode 100644 index 0000000000..d160a02939 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/applyViewSnapshot.ts @@ -0,0 +1,132 @@ +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import type { FieldId } from '../fields/FieldId'; +import { TableUpdateViewQueryDefaultsSpec } from '../specs/TableUpdateViewQueryDefaultsSpec'; +import type { Table } from '../Table'; +import type { TableUpdateResult } from '../TableMutator'; +import type { View } from '../views/View'; +import type { ViewColumnMetaChange, ViewColumnMetaValue } from '../views/ViewColumnMeta'; + +const jsonEquals = (left: unknown, right: unknown): boolean => + JSON.stringify(left) === JSON.stringify(right); + +const buildColumnMetaChanges = ( + fieldIdByValue: ReadonlyMap, + previous: ViewColumnMetaValue, + next: ViewColumnMetaValue +): ReadonlyArray => { + const changes: ViewColumnMetaChange[] = []; + for (const fieldId of new Set([...Object.keys(previous), ...Object.keys(next)])) { + const typedFieldId = fieldIdByValue.get(fieldId); + if (!typedFieldId || jsonEquals(previous[fieldId], next[fieldId])) continue; + changes.push({ + fieldId: typedFieldId, + ...(previous[fieldId] !== undefined ? { previousColumnMeta: previous[fieldId] } : {}), + nextColumnMeta: next[fieldId] ?? {}, + }); + } + return changes; +}; + +export type ApplyViewSnapshotMethodResult = { + readonly updateResult?: TableUpdateResult; +}; + +export const applyViewSnapshot = function ( + this: Table, + snapshotView: View +): Result { + const table = this; + return safeTry(function* () { + const currentResult = table.getView(snapshotView.id()); + if (currentResult.isErr()) { + const updateResult = yield* table.update((mutator) => mutator.addView(snapshotView)); + return ok({ updateResult }); + } + + const current = currentResult.value; + const previousMeta = yield* current.columnMeta(); + const nextMeta = yield* snapshotView.columnMeta(); + const previousQuery = yield* current.queryDefaults(); + const nextQuery = yield* snapshotView.queryDefaults(); + const previousOrder = current.order(); + const nextOrder = snapshotView.order(); + const fieldIdByValue = new Map( + table.getFields().map((field) => [field.id().toString(), field.id()] as const) + ); + const changes = buildColumnMetaChanges(fieldIdByValue, previousMeta.toDto(), nextMeta.toDto()); + const optionsChanged = !jsonEquals(current.options(), snapshotView.options()); + const shareMetaChanged = !jsonEquals(current.shareMeta(), snapshotView.shareMeta()); + const queryChanged = + !previousQuery.equals(nextQuery) || + !jsonEquals(previousQuery.sourceFilter(), nextQuery.sourceFilter()); + const orderChanged = + previousOrder.isOk() && nextOrder.isOk() && !previousOrder.value.equals(nextOrder.value); + + const hasChanges = + !current.name().equals(snapshotView.name()) || + current.description() !== snapshotView.description() || + current.isLocked() !== snapshotView.isLocked() || + changes.length > 0 || + optionsChanged || + shareMetaChanged || + queryChanged || + orderChanged; + if (!hasChanges) return ok({}); + + const updateResult = yield* table.update((mutator) => { + if (!current.name().equals(snapshotView.name())) { + mutator.renameView(snapshotView.id(), snapshotView.name()); + } + if (current.description() !== snapshotView.description()) { + mutator.updateViewDescription(snapshotView.id(), snapshotView.description()); + } + if (current.isLocked() !== snapshotView.isLocked()) { + mutator.updateViewLocked(snapshotView.id(), snapshotView.isLocked()); + } + if (shareMetaChanged) { + mutator.updateViewShareMeta(snapshotView.id(), snapshotView.shareMeta()); + } + if (changes.length > 0 || optionsChanged) { + mutator.updateViewColumnMeta({ + viewId: snapshotView.id(), + fieldId: changes[0]?.fieldId ?? table.primaryFieldId(), + columnMeta: nextMeta, + changes, + ...(optionsChanged + ? { + previousOptions: current.options(), + nextOptions: snapshotView.options(), + optionsChanged: true, + } + : {}), + }); + } + if (queryChanged) { + mutator.applySpecs([ + TableUpdateViewQueryDefaultsSpec.create([ + { + viewId: snapshotView.id(), + previousQueryDefaults: previousQuery, + queryDefaults: nextQuery, + }, + ]), + ]); + } + if (orderChanged && previousOrder.isOk() && nextOrder.isOk()) { + mutator.updateViewOrder([ + { + viewId: snapshotView.id(), + previousOrder: previousOrder.value, + nextOrder: nextOrder.value, + }, + ]); + } + return mutator; + }); + + return ok({ updateResult }); + }); +}; diff --git a/packages/v2/core/src/domain/table/methods/createButtonClickPlan.spec.ts b/packages/v2/core/src/domain/table/methods/createButtonClickPlan.spec.ts new file mode 100644 index 0000000000..ce009681fb --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/createButtonClickPlan.spec.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { FieldId } from '../fields/FieldId'; +import { FieldName } from '../fields/FieldName'; +import { ButtonMaxCount } from '../fields/types/ButtonMaxCount'; +import { ButtonResetCount } from '../fields/types/ButtonResetCount'; +import { ButtonWorkflow } from '../fields/types/ButtonWorkflow'; +import { RecordId } from '../records/RecordId'; +import { SetButtonValueSpec } from '../records/specs/values/SetButtonValueSpec'; +import { Table } from '../Table'; +import { TableId } from '../TableId'; +import { TableName } from '../TableName'; +import { ViewId } from '../views/ViewId'; + +const ids = { + base: BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap(), + table: TableId.create(`tbl${'b'.repeat(16)}`)._unsafeUnwrap(), + primary: FieldId.create(`fld${'c'.repeat(16)}`)._unsafeUnwrap(), + button: FieldId.create(`fld${'d'.repeat(16)}`)._unsafeUnwrap(), + view: ViewId.create(`viw${'e'.repeat(16)}`)._unsafeUnwrap(), + record: RecordId.create(`rec${'f'.repeat(16)}`)._unsafeUnwrap(), +}; + +const buildTable = (options?: { + active?: boolean; + workflowId?: string; + maxCount?: number; + resetCount?: boolean; +}) => { + const builder = Table.builder() + .withBaseId(ids.base) + .withId(ids.table) + .withName(TableName.create('Buttons')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(ids.primary) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + const button = builder + .field() + .button() + .withId(ids.button) + .withName(FieldName.create('Run')._unsafeUnwrap()); + if (options?.workflowId !== undefined || options?.active !== undefined) { + button.withWorkflow( + ButtonWorkflow.create({ + id: options.workflowId ?? `wfl${'g'.repeat(16)}`, + name: 'Run', + isActive: options.active ?? true, + })._unsafeUnwrap() + ); + } + if (options?.maxCount !== undefined) { + button.withMaxCount(ButtonMaxCount.create(options.maxCount)._unsafeUnwrap()); + } + if (options?.resetCount !== undefined) { + button.withResetCount(ButtonResetCount.create(options.resetCount)._unsafeUnwrap()); + } + button.done(); + builder.view().grid().withId(ids.view).defaultName().done(); + return builder.build()._unsafeUnwrap(); +}; + +const shareScope = { + viewId: ids.view, + includeHiddenFields: false, + includeRecords: true, +}; + +describe('Table.createButtonClickPlan', () => { + it('creates an internal Button mutation and increments an empty value', () => { + const workflowId = `wfl${'h'.repeat(16)}`; + const table = buildTable({ active: true, workflowId }); + const plan = table.createButtonClickPlan({ fieldId: ids.button, shareScope })._unsafeUnwrap(); + const update = plan.click(table, ids.record, undefined)._unsafeUnwrap(); + + expect(plan.workflowId()).toBe(workflowId); + expect(update.mutateSpec).toBeInstanceOf(SetButtonValueSpec); + expect(update.record.fields().get(ids.button)?.toValue()).toEqual({ count: 1 }); + }); + + it('increments the stored count', () => { + const table = buildTable({ active: true }); + const plan = table.createButtonClickPlan({ fieldId: ids.button })._unsafeUnwrap(); + + expect( + plan + .click(table, ids.record, { count: 4 }) + ._unsafeUnwrap() + .record.fields() + .get(ids.button) + ?.toValue() + ).toEqual({ count: 5 }); + }); + + it('rejects a missing or inactive workflow', () => { + expect( + buildTable().createButtonClickPlan({ fieldId: ids.button })._unsafeUnwrapErr() + ).toMatchObject({ code: 'button.workflow_not_active', tags: ['validation'] }); + expect( + buildTable({ active: false }) + .createButtonClickPlan({ fieldId: ids.button }) + ._unsafeUnwrapErr() + ).toMatchObject({ code: 'button.workflow_not_active', tags: ['validation'] }); + }); + + it('rejects a non-Button Field', () => { + expect( + buildTable({ active: true }) + .createButtonClickPlan({ fieldId: ids.primary }) + ._unsafeUnwrapErr() + ).toMatchObject({ code: 'button.field_type_invalid', tags: ['validation'] }); + }); + + it('rejects a click at the maximum count', () => { + const table = buildTable({ active: true, maxCount: 2 }); + const plan = table.createButtonClickPlan({ fieldId: ids.button })._unsafeUnwrap(); + + expect(plan.click(table, ids.record, { count: 2 })._unsafeUnwrapErr()).toMatchObject({ + code: 'button.click_count_reached_max', + tags: ['validation'], + }); + }); + + it('rejects shared clicks when records are disabled', () => { + expect( + buildTable({ active: true }) + .createButtonClickPlan({ + fieldId: ids.button, + shareScope: { ...shareScope, includeRecords: false }, + }) + ._unsafeUnwrapErr() + ).toMatchObject({ code: 'button.shared_records_disabled', tags: ['forbidden'] }); + }); + + it('rejects a hidden shared Button unless hidden Fields are included', () => { + const table = buildTable({ active: true }); + const hidden = table + .updateViewColumnMeta(ids.view, [{ fieldId: ids.button, columnMeta: { hidden: true } }]) + ._unsafeUnwrap().updateResult!.table; + + expect( + hidden.createButtonClickPlan({ fieldId: ids.button, shareScope })._unsafeUnwrapErr() + ).toMatchObject({ code: 'button.shared_field_hidden', tags: ['forbidden'] }); + expect( + hidden + .createButtonClickPlan({ + fieldId: ids.button, + shareScope: { ...shareScope, includeHiddenFields: true }, + }) + .isOk() + ).toBe(true); + }); + + it('builds the internal Button mutation used by undo replay', () => { + const table = buildTable({ active: false }); + const restored = table + .setButtonValue({ + recordId: ids.record, + fieldId: ids.button, + value: { count: 4 }, + }) + ._unsafeUnwrap(); + + expect(restored.mutateSpec).toBeInstanceOf(SetButtonValueSpec); + expect(restored.record.fields().get(ids.button)?.toValue()).toEqual({ count: 4 }); + }); +}); + +describe('Table.resetButtonValue', () => { + it('creates an internal null mutation when resetCount is enabled', () => { + const reset = buildTable({ resetCount: true }) + .resetButtonValue({ recordId: ids.record, fieldId: ids.button }) + ._unsafeUnwrap(); + + expect(reset.mutateSpec).toBeInstanceOf(SetButtonValueSpec); + expect(reset.record.fields().get(ids.button)?.toValue()).toBeNull(); + }); + + it('rejects reset when resetCount is absent or false', () => { + expect( + buildTable() + .resetButtonValue({ recordId: ids.record, fieldId: ids.button }) + ._unsafeUnwrapErr() + ).toMatchObject({ code: 'button.reset_not_supported', tags: ['validation'] }); + expect( + buildTable({ resetCount: false }) + .resetButtonValue({ recordId: ids.record, fieldId: ids.button }) + ._unsafeUnwrapErr() + ).toMatchObject({ code: 'button.reset_not_supported', tags: ['validation'] }); + }); + + it('rejects reset for a non-Button Field', () => { + expect( + buildTable({ resetCount: true }) + .resetButtonValue({ recordId: ids.record, fieldId: ids.primary }) + ._unsafeUnwrapErr() + ).toMatchObject({ code: 'button.field_type_invalid', tags: ['validation'] }); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/createButtonClickPlan.ts b/packages/v2/core/src/domain/table/methods/createButtonClickPlan.ts new file mode 100644 index 0000000000..201e34713a --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/createButtonClickPlan.ts @@ -0,0 +1,167 @@ +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { RecordFilter } from '../../../queries/RecordFilterDto'; +import { domainError, type DomainError } from '../../shared/DomainError'; +import type { FieldId } from '../fields/FieldId'; +import { ButtonField } from '../fields/types/ButtonField'; +import type { RecordId } from '../records/RecordId'; +import { RecordUpdateResult } from '../records/RecordUpdateResult'; +import { + type ButtonCellValue, + SetButtonValueSpec, +} from '../records/specs/values/SetButtonValueSpec'; +import { TableRecord } from '../records/TableRecord'; +import { CellValue } from '../records/values/CellValue'; +import type { Table } from '../Table'; +import type { ViewId } from '../views/ViewId'; + +export type CreateButtonClickPlanParams = { + readonly fieldId: FieldId; + readonly shareScope?: { + readonly viewId: ViewId; + readonly includeHiddenFields: boolean; + readonly includeRecords: boolean; + }; +}; + +export class ButtonClickPlan { + private constructor( + private readonly buttonField: ButtonField, + private readonly workflowIdValue: string, + private readonly viewFilterValue: RecordFilter | null | undefined + ) {} + + static create( + buttonField: ButtonField, + workflowId: string, + viewFilter: RecordFilter | null | undefined + ): ButtonClickPlan { + return new ButtonClickPlan(buttonField, workflowId, viewFilter); + } + + fieldId(): FieldId { + return this.buttonField.id(); + } + + workflowId(): string { + return this.workflowIdValue; + } + + viewFilter(): RecordFilter | null | undefined { + return this.viewFilterValue; + } + + click( + table: Table, + recordId: RecordId, + currentValue: unknown + ): Result { + const currentCount = ButtonClickPlan.readCount(currentValue); + const maxCount = this.buttonField.maxCount()?.toNumber() ?? 0; + const fieldId = this.fieldId(); + if (maxCount > 0 && currentCount >= maxCount) { + return err( + domainError.validation({ + code: 'button.click_count_reached_max', + message: `Button click count ${currentCount} reached max count ${maxCount}`, + details: { + fieldId: this.fieldId().toString(), + count: currentCount, + maxCount, + i18nKey: 'httpErrors.field.button.clickCountReachedMaxCount', + }, + }) + ); + } + + return safeTry(function* () { + const record = yield* TableRecord.create({ + id: recordId, + tableId: table.id(), + fieldValues: [], + }); + const value = CellValue.fromValidated({ count: currentCount + 1 }); + const mutateSpec = new SetButtonValueSpec(fieldId, value); + const updatedRecord = yield* mutateSpec.mutate(record); + return ok( + RecordUpdateResult.create( + updatedRecord, + mutateSpec, + new Map([[fieldId.toString(), fieldId.toString()]]) + ) + ); + }); + } + + private static readCount(value: unknown): number { + if (value == null || typeof value !== 'object' || Array.isArray(value)) return 0; + const count = (value as { count?: unknown }).count; + return typeof count === 'number' && Number.isFinite(count) && count > 0 ? Math.floor(count) : 0; + } +} + +export function createButtonClickPlan( + this: Table, + params: CreateButtonClickPlanParams +): Result { + return safeTry( + function* (this: Table) { + let viewFilter: RecordFilter | null | undefined; + if (params.shareScope) { + if (!params.shareScope.includeRecords) { + return err( + domainError.forbidden({ + code: 'button.shared_records_disabled', + message: 'Shared View does not include records', + details: { fieldId: params.fieldId.toString() }, + }) + ); + } + const view = yield* this.getView(params.shareScope.viewId); + if (!params.shareScope.includeHiddenFields) { + const visibleFieldIds = yield* this.getOrderedVisibleFieldIds( + params.shareScope.viewId.toString() + ); + if (!visibleFieldIds.some((fieldId) => fieldId.equals(params.fieldId))) { + return err( + domainError.forbidden({ + code: 'button.shared_field_hidden', + message: 'Field is hidden in the shared View', + details: { fieldId: params.fieldId.toString() }, + }) + ); + } + } + viewFilter = (yield* view.queryDefaults()).filter(); + } + + const field = yield* this.getField((candidate) => candidate.id().equals(params.fieldId)); + if (!(field instanceof ButtonField)) { + return err( + domainError.validation({ + code: 'button.field_type_invalid', + message: 'Field is not a Button field', + details: { fieldId: params.fieldId.toString() }, + }) + ); + } + const workflow = field.workflow()?.toDto(); + if (!workflow?.id || workflow.isActive !== true) { + return err( + domainError.validation({ + code: 'button.workflow_not_active', + message: `Button field's workflow ${workflow?.id ?? ''} is not active`, + details: { + fieldId: params.fieldId.toString(), + workflowId: workflow?.id, + i18nKey: 'httpErrors.workflow.notActive', + }, + }) + ); + } + + return ok(ButtonClickPlan.create(field, workflow.id, viewFilter)); + }.bind(this) + ); +} diff --git a/packages/v2/core/src/domain/table/methods/createCollapsedGroupExclusionFilter.ts b/packages/v2/core/src/domain/table/methods/createCollapsedGroupExclusionFilter.ts new file mode 100644 index 0000000000..c5bdddfaad --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/createCollapsedGroupExclusionFilter.ts @@ -0,0 +1,130 @@ +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { + RecordFilter, + RecordFilterCondition, + RecordFilterNode, +} from '../../../queries/RecordFilterDto'; +import { type DomainError } from '../../shared/DomainError'; +import { FieldValueTypeVisitor } from '../fields/visitors/FieldValueTypeVisitor'; +import type { Table } from '../Table'; +import type { ViewQueryGroupItem } from '../views/ViewQueryDefaults'; + +export type CollapsedGroupValueRow = { + readonly groupValues: ReadonlyArray; +}; + +const stringifyGroupValue = (value: unknown): number | string | null => { + if (typeof value === 'bigint' || typeof value === 'number') return Number(value); + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'string') return value; + if (value == null) return null; + return JSON.stringify(value); +}; + +const hashGroupFlag = (value: string): number => { + let hash = 5381; + let index = value.length; + while (index) hash = (hash * 33) ^ value.charCodeAt(--index); + return hash >>> 0; +}; + +const structuredIds = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(structuredIds); + if (value && typeof value === 'object' && 'id' in value) { + return String((value as { id: unknown }).id); + } + return value; +}; + +/** + * Translate collapsed group headers into the v1-compatible exclusion filter. + * + * The repository supplies grouped values, while the Table aggregate owns the Field + * semantics needed to turn each collapsed path into a safe record predicate. + */ +export function createCollapsedGroupExclusionFilter( + this: Table, + groupBy: ReadonlyArray, + groupedRows: ReadonlyArray, + collapsedGroupIds: ReadonlySet +): Result { + return safeTry( + function* (this: Table) { + if (!groupBy.length || !collapsedGroupIds.size) return ok(undefined); + + const previousValues: unknown[] = Array.from({ length: groupBy.length }, () => Symbol()); + const pathByGroupId = new Map>(); + for (const row of groupedRows) { + for (let depth = 0; depth < Math.min(groupBy.length, row.groupValues.length); depth++) { + const stringified = stringifyGroupValue(row.groupValues[depth]); + if (previousValues[depth] === stringified) continue; + previousValues[depth] = stringified; + for (let inner = depth + 1; inner < previousValues.length; inner++) { + previousValues[inner] = Symbol(); + } + const groupId = String( + hashGroupFlag( + `${groupBy[depth]!.fieldId}_${[...previousValues.slice(0, depth), stringified].join( + '_' + )}` + ) + ); + pathByGroupId.set(groupId, row.groupValues.slice(0, depth + 1)); + } + } + + const collapsedPaths: RecordFilterNode[] = []; + for (const groupId of collapsedGroupIds) { + const path = pathByGroupId.get(groupId); + if (!path) continue; + const conditions: RecordFilterCondition[] = []; + for (let depth = 0; depth < path.length; depth++) { + const group = groupBy[depth]; + if (!group) continue; + const field = yield* this.getField( + (candidate) => candidate.id().toString() === group.fieldId + ); + const valueType = yield* field.accept(new FieldValueTypeVisitor()); + const fieldType = field.type().toString(); + let value = path[depth]; + let operator: RecordFilterCondition['operator'] = 'isNot'; + + if ( + fieldType === 'checkbox' || + (fieldType === 'formula' && valueType.cellValueType.toString() === 'boolean') + ) { + operator = 'is'; + value = value ? false : null; + } else if (value == null) { + operator = 'isNotEmpty'; + } else if ( + valueType.isMultipleCellValue.isMultiple() && + [ + 'singleSelect', + 'multipleSelect', + 'user', + 'createdBy', + 'lastModifiedBy', + 'link', + ].includes(fieldType) + ) { + operator = 'isNotExactly'; + value = structuredIds(value); + } else if (['user', 'createdBy', 'lastModifiedBy', 'link'].includes(fieldType)) { + value = structuredIds(value); + } + + conditions.push({ fieldId: group.fieldId, operator, value: value as never }); + } + if (conditions.length) collapsedPaths.push({ conjunction: 'or', items: conditions }); + } + + const filter: RecordFilter | undefined = collapsedPaths.length + ? { conjunction: 'and', items: collapsedPaths } + : undefined; + return ok(filter); + }.bind(this) + ); +} diff --git a/packages/v2/core/src/domain/table/methods/createRecordAggregation.spec.ts b/packages/v2/core/src/domain/table/methods/createRecordAggregation.spec.ts new file mode 100644 index 0000000000..6b4b3591d3 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/createRecordAggregation.spec.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { FieldId } from '../fields/FieldId'; +import { FieldName } from '../fields/FieldName'; +import { Table } from '../Table'; +import { TableId } from '../TableId'; +import { TableName } from '../TableName'; + +const createTable = () => { + const textId = FieldId.create(`fld${'t'.repeat(16)}`)._unsafeUnwrap(); + const numberId = FieldId.create(`fld${'n'.repeat(16)}`)._unsafeUnwrap(); + const checkboxId = FieldId.create(`fld${'c'.repeat(16)}`)._unsafeUnwrap(); + const attachmentId = FieldId.create(`fld${'a'.repeat(16)}`)._unsafeUnwrap(); + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'b'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Aggregation')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(textId) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + builder + .field() + .number() + .withId(numberId) + .withName(FieldName.create('Amount')._unsafeUnwrap()) + .done(); + builder + .field() + .checkbox() + .withId(checkboxId) + .withName(FieldName.create('Done')._unsafeUnwrap()) + .done(); + builder + .field() + .attachment() + .withId(attachmentId) + .withName(FieldName.create('Files')._unsafeUnwrap()) + .done(); + builder.view().defaultGrid().done(); + const table = builder.build()._unsafeUnwrap(); + return { + table, + viewId: table.defaultView()._unsafeUnwrap().id(), + textId, + numberId, + checkboxId, + attachmentId, + }; +}; + +describe('Table.createRecordAggregation', () => { + it('derives the default statistic from the View child column metadata', () => { + const { table, viewId, numberId } = createTable(); + const updatedTable = table + .updateViewColumnMeta(viewId, [{ fieldId: numberId, columnMeta: { statisticFunc: 'sum' } }]) + ._unsafeUnwrap().updateResult!.table; + + const result = updatedTable.createRecordAggregation({ viewId: viewId.toString() }); + + expect( + result._unsafeUnwrap().fields.map(({ fieldId, statisticFunc }) => ({ + fieldId: fieldId.toString(), + statisticFunc, + })) + ).toEqual([{ fieldId: numberId.toString(), statisticFunc: 'sum' }]); + }); + + it('validates functions against the Field child value type', () => { + const { table, viewId, textId, numberId, checkboxId, attachmentId } = createTable(); + + expect( + table + .createRecordAggregation({ + viewId: viewId.toString(), + fields: [ + { fieldId: numberId.toString(), statisticFunc: 'average' }, + { fieldId: checkboxId.toString(), statisticFunc: 'percentChecked' }, + { fieldId: attachmentId.toString(), statisticFunc: 'totalAttachmentSize' }, + ], + }) + .isOk() + ).toBe(true); + + const invalid = table.createRecordAggregation({ + viewId: viewId.toString(), + fields: [{ fieldId: textId.toString(), statisticFunc: 'sum' }], + }); + expect(invalid._unsafeUnwrapErr()).toMatchObject({ + code: 'record_aggregation.function_not_supported', + tags: ['validation'], + }); + }); + + it('rejects unknown fields and aggregation functions', () => { + const { table, viewId, textId } = createTable(); + + expect( + table + .createRecordAggregation({ + viewId: viewId.toString(), + fields: [{ fieldId: `fld${'z'.repeat(16)}`, statisticFunc: 'count' }], + }) + ._unsafeUnwrapErr() + ).toMatchObject({ code: 'record_aggregation.field_not_found' }); + expect( + table + .createRecordAggregation({ + viewId: viewId.toString(), + fields: [{ fieldId: textId.toString(), statisticFunc: 'mystery' }], + }) + ._unsafeUnwrapErr() + ).toMatchObject({ code: 'record_aggregation.function_invalid' }); + }); + + it('protects hidden statistic and group fields unless the share explicitly includes them', () => { + const { table, viewId, numberId } = createTable(); + const updatedTable = table + .updateViewColumnMeta(viewId, [ + { fieldId: numberId, columnMeta: { hidden: true, statisticFunc: 'sum' } }, + ]) + ._unsafeUnwrap().updateResult!.table; + + for (const input of [ + { fields: [{ fieldId: numberId.toString(), statisticFunc: 'sum' }] }, + { groupBy: [{ fieldId: numberId.toString(), order: 'asc' as const }] }, + {}, + ]) { + const result = updatedTable.createRecordAggregation({ + viewId: viewId.toString(), + ...input, + }); + if ('fields' in input || 'groupBy' in input) { + expect(result._unsafeUnwrapErr()).toMatchObject({ + code: 'record_aggregation.field_hidden', + tags: ['forbidden'], + }); + } else { + expect(result._unsafeUnwrap().fields).toEqual([]); + } + } + + expect( + updatedTable + .createRecordAggregation({ + viewId: viewId.toString(), + fields: [{ fieldId: numberId.toString(), statisticFunc: 'sum' }], + groupBy: [{ fieldId: numberId.toString(), order: 'desc' }], + includeHiddenFields: true, + }) + .isOk() + ).toBe(true); + }); + + it('keeps ordered group fields inside the Table-owned specification and limits depth to three', () => { + const { table, viewId, textId, numberId, checkboxId, attachmentId } = createTable(); + + const aggregation = table + .createRecordAggregation({ + viewId: viewId.toString(), + fields: [{ fieldId: textId.toString(), statisticFunc: 'count' }], + groupBy: [ + { fieldId: textId.toString(), order: 'desc' }, + { fieldId: numberId.toString(), order: 'asc' }, + { fieldId: checkboxId.toString(), order: 'desc' }, + { fieldId: attachmentId.toString(), order: 'asc' }, + ], + }) + ._unsafeUnwrap(); + + expect( + aggregation.groupBy.map(({ fieldId, fieldType, order }) => ({ + fieldId: fieldId.toString(), + fieldType, + order, + })) + ).toEqual([ + { fieldId: textId.toString(), fieldType: 'singleLineText', order: 'desc' }, + { fieldId: numberId.toString(), fieldType: 'number', order: 'asc' }, + { fieldId: checkboxId.toString(), fieldType: 'checkbox', order: 'desc' }, + ]); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/createRecordAggregation.ts b/packages/v2/core/src/domain/table/methods/createRecordAggregation.ts new file mode 100644 index 0000000000..7240481e07 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/createRecordAggregation.ts @@ -0,0 +1,183 @@ +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import type { Field } from '../fields/Field'; +import { FieldValueTypeVisitor } from '../fields/visitors/FieldValueTypeVisitor'; +import { + TableRecordAggregation, + type TableRecordAggregationField, + type TableRecordAggregationFieldInput, + type TableRecordAggregationFunction, + type TableRecordAggregationGroup, + type TableRecordAggregationGroupInput, + tableRecordAggregationFunctionValues, +} from '../records/TableRecordAggregation'; +import type { Table } from '../Table'; + +export type CreateRecordAggregationParams = { + readonly viewId: string; + readonly fields?: ReadonlyArray; + readonly groupBy?: ReadonlyArray; + readonly includeHiddenFields?: boolean; +}; + +const commonFunctions: ReadonlyArray = [ + 'count', + 'empty', + 'filled', + 'unique', + 'percentEmpty', + 'percentFilled', + 'percentUnique', +]; + +const validFunctionsForField = ( + field: Field +): Result, DomainError> => { + return safeTry(function* () { + const fieldType = field.type().toString(); + // Keep aggregation validity tied to the Field child rather than HTTP DTO metadata. + const valueType = yield* field.accept(new FieldValueTypeVisitor()); + const isMultiple = valueType.isMultipleCellValue.toBoolean(); + let values: ReadonlyArray; + + if (fieldType === 'link') { + values = ['count', 'empty', 'filled', 'percentEmpty', 'percentFilled']; + } else if (['user', 'createdBy', 'lastModifiedBy'].includes(fieldType)) { + values = isMultiple + ? ['count', 'empty', 'filled', 'percentEmpty', 'percentFilled'] + : commonFunctions; + } else { + switch (valueType.cellValueType.toString()) { + case 'number': + values = ['sum', 'average', 'min', 'max', ...commonFunctions]; + break; + case 'dateTime': + values = [ + ...commonFunctions, + 'earliestDate', + 'latestDate', + 'dateRangeOfDays', + 'dateRangeOfMonths', + ]; + break; + case 'boolean': + values = ['count', 'checked', 'unChecked', 'percentChecked', 'percentUnChecked']; + break; + default: + values = commonFunctions; + } + } + + if (fieldType === 'attachment') { + values = [ + ...values.filter((value) => !['unique', 'percentUnique'].includes(value)), + 'totalAttachmentSize', + ]; + } + + return ok(new Set(values)); + }); +}; + +const resolveField = (table: Table, fieldId: string): Result => + table + .getField((field) => field.id().toString() === fieldId) + .mapErr(() => + domainError.validation({ + code: 'record_aggregation.field_not_found', + message: `Aggregation field not found: ${fieldId}`, + details: { fieldId }, + }) + ); + +const assertVisible = ( + visibleFieldIds: ReadonlySet | undefined, + fieldId: string +): Result => { + if (!visibleFieldIds || visibleFieldIds.has(fieldId)) return ok(undefined); + return err( + domainError.forbidden({ + code: 'record_aggregation.field_hidden', + message: 'field is hidden, not allowed', + details: { fieldId }, + }) + ); +}; + +export function createRecordAggregation( + this: Table, + params: CreateRecordAggregationParams +): Result { + return safeTry( + function* (this: Table) { + const view = yield* this.getViewById(params.viewId); + const visibleFieldIds = params.includeHiddenFields + ? undefined + : new Set((yield* this.getOrderedVisibleFieldIds(params.viewId)).map(String)); + const columnMeta = yield* view.columnMeta(); + const requestedFields = + params.fields ?? + Object.entries(columnMeta.toDto()) + .filter( + ([fieldId, meta]) => + typeof meta.statisticFunc === 'string' && + meta.statisticFunc && + (!visibleFieldIds || visibleFieldIds.has(fieldId)) + ) + .map(([fieldId, meta]) => ({ + fieldId, + statisticFunc: meta.statisticFunc!, + })); + + const fields: TableRecordAggregationField[] = []; + for (const input of requestedFields) { + const field = yield* resolveField(this, input.fieldId); + yield* assertVisible(visibleFieldIds, input.fieldId); + if ( + !tableRecordAggregationFunctionValues.includes( + input.statisticFunc as TableRecordAggregationFunction + ) + ) { + return err( + domainError.validation({ + code: 'record_aggregation.function_invalid', + message: `Unknown aggregation function: ${input.statisticFunc}`, + details: { fieldId: input.fieldId, statisticFunc: input.statisticFunc }, + }) + ); + } + const statisticFunc = input.statisticFunc as TableRecordAggregationFunction; + const validFunctions = yield* validFunctionsForField(field); + if (!validFunctions.has(statisticFunc)) { + return err( + domainError.validation({ + code: 'record_aggregation.function_not_supported', + message: `Aggregation function '${statisticFunc}' is not supported by field '${input.fieldId}'`, + details: { + fieldId: input.fieldId, + statisticFunc, + validFunctions: [...validFunctions], + }, + }) + ); + } + fields.push({ fieldId: field.id(), statisticFunc }); + } + + const groupBy: TableRecordAggregationGroup[] = []; + for (const group of params.groupBy?.slice(0, 3) ?? []) { + const field = yield* resolveField(this, group.fieldId); + yield* assertVisible(visibleFieldIds, group.fieldId); + groupBy.push({ + fieldId: field.id(), + fieldType: field.type().toString(), + order: group.order, + }); + } + + return ok(TableRecordAggregation.create(fields, groupBy)); + }.bind(this) + ); +} diff --git a/packages/v2/core/src/domain/table/methods/createRecordCalendarDailyCollection.spec.ts b/packages/v2/core/src/domain/table/methods/createRecordCalendarDailyCollection.spec.ts new file mode 100644 index 0000000000..3f2ca5dff8 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/createRecordCalendarDailyCollection.spec.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { FieldId } from '../fields/FieldId'; +import { FieldName } from '../fields/FieldName'; +import { + DateFormattingPreset, + DateTimeFormatting, + TimeFormatting, +} from '../fields/types/DateTimeFormatting'; +import { Table } from '../Table'; +import { TableId } from '../TableId'; +import { TableName } from '../TableName'; + +const createTable = () => { + const nameId = FieldId.create(`fld${'n'.repeat(16)}`)._unsafeUnwrap(); + const startId = FieldId.create(`fld${'s'.repeat(16)}`)._unsafeUnwrap(); + const endId = FieldId.create(`fld${'e'.repeat(16)}`)._unsafeUnwrap(); + const hiddenDateId = FieldId.create(`fld${'h'.repeat(16)}`)._unsafeUnwrap(); + const formatting = DateTimeFormatting.create({ + date: DateFormattingPreset.ISO, + time: TimeFormatting.Hour24, + timeZone: 'Asia/Singapore', + })._unsafeUnwrap(); + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'c'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Calendar')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(nameId) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + for (const [id, name] of [ + [startId, 'Start'], + [endId, 'End'], + [hiddenDateId, 'Hidden date'], + ] as const) { + builder + .field() + .date() + .withId(id) + .withName(FieldName.create(name)._unsafeUnwrap()) + .withFormatting(formatting) + .done(); + } + builder.view().calendar().defaultName().done(); + let table = builder.build()._unsafeUnwrap(); + const viewId = table.defaultView()._unsafeUnwrap().id(); + table = table + .updateViewOptions(viewId, { + startDateFieldId: startId.toString(), + endDateFieldId: endId.toString(), + titleFieldId: nameId.toString(), + }) + ._unsafeUnwrap().updateResult!.table; + table = table + .updateViewColumnMeta(viewId, [ + { fieldId: startId, columnMeta: { visible: false } }, + { fieldId: endId, columnMeta: { visible: false } }, + { fieldId: hiddenDateId, columnMeta: { visible: false } }, + ]) + ._unsafeUnwrap().updateResult!.table; + return { table, viewId, nameId, startId, endId, hiddenDateId }; +}; + +describe('Table.createRecordCalendarDailyCollection', () => { + it('derives scalar date fields and timezone inside the Table aggregate', () => { + const { table, viewId, startId, endId } = createTable(); + + const calendar = table + .createRecordCalendarDailyCollection({ + viewId: viewId.toString(), + startFieldId: startId.toString(), + endFieldId: endId.toString(), + }) + ._unsafeUnwrap(); + + expect(calendar.startFieldId.equals(startId)).toBe(true); + expect(calendar.endFieldId.equals(endId)).toBe(true); + expect(calendar.timeZone.toString()).toBe('Asia/Singapore'); + }); + + it('treats Calendar option fields as visible even when column metadata says otherwise', () => { + const { table, viewId, nameId, startId, endId, hiddenDateId } = createTable(); + + expect(table.getOrderedVisibleFieldIds(viewId.toString())._unsafeUnwrap().map(String)).toEqual([ + nameId.toString(), + startId.toString(), + endId.toString(), + ]); + expect( + table + .createRecordCalendarDailyCollection({ + viewId: viewId.toString(), + startFieldId: startId.toString(), + endFieldId: endId.toString(), + }) + .isOk() + ).toBe(true); + expect(hiddenDateId.toString()).not.toBe(startId.toString()); + }); + + it('falls back to the start field when the end field is omitted', () => { + const { table, viewId, startId } = createTable(); + const calendar = table + .createRecordCalendarDailyCollection({ + viewId: viewId.toString(), + startFieldId: startId.toString(), + }) + ._unsafeUnwrap(); + + expect(calendar.endFieldId.equals(startId)).toBe(true); + }); + + it('rejects hidden, missing, and non-date target fields without includeHiddenFields', () => { + const { table, viewId, nameId, startId, hiddenDateId } = createTable(); + + expect( + table + .createRecordCalendarDailyCollection({ + viewId: viewId.toString(), + startFieldId: hiddenDateId.toString(), + }) + ._unsafeUnwrapErr() + ).toMatchObject({ code: 'calendar.field_hidden', tags: ['forbidden'] }); + expect( + table + .createRecordCalendarDailyCollection({ + viewId: viewId.toString(), + startFieldId: nameId.toString(), + }) + ._unsafeUnwrapErr() + ).toMatchObject({ code: 'calendar.invalid_start_field', tags: ['validation'] }); + expect( + table + .createRecordCalendarDailyCollection({ + viewId: viewId.toString(), + startFieldId: startId.toString(), + endFieldId: `fld${'x'.repeat(16)}`, + }) + ._unsafeUnwrapErr() + ).toMatchObject({ code: 'calendar.invalid_end_field' }); + expect( + table + .createRecordCalendarDailyCollection({ + viewId: viewId.toString(), + startFieldId: hiddenDateId.toString(), + includeHiddenFields: true, + }) + .isOk() + ).toBe(true); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/createRecordCalendarDailyCollection.ts b/packages/v2/core/src/domain/table/methods/createRecordCalendarDailyCollection.ts new file mode 100644 index 0000000000..30e1edc1fe --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/createRecordCalendarDailyCollection.ts @@ -0,0 +1,96 @@ +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import type { Field } from '../fields/Field'; +import { FieldDateTimeZoneVisitor } from '../fields/visitors/FieldDateTimeZoneVisitor'; +import { FieldValueTypeVisitor } from '../fields/visitors/FieldValueTypeVisitor'; +import { TableRecordCalendarDailyCollection } from '../records/TableRecordCalendarDailyCollection'; +import type { Table } from '../Table'; + +export type CreateRecordCalendarDailyCollectionParams = { + readonly viewId: string; + readonly startFieldId: string; + readonly endFieldId?: string; + readonly includeHiddenFields?: boolean; +}; + +type CalendarFieldRole = 'start' | 'end'; + +const resolveCalendarField = ( + table: Table, + fieldId: string, + role: CalendarFieldRole, + visibleFieldIds: ReadonlySet | undefined +): Result => { + return safeTry(function* () { + const field = yield* table + .getField((candidate) => candidate.id().toString() === fieldId) + .mapErr(() => + domainError.validation({ + code: `calendar.invalid_${role}_field`, + message: `Invalid ${role} date field id`, + details: { fieldId }, + }) + ); + if (visibleFieldIds && !visibleFieldIds.has(fieldId)) { + return err( + domainError.forbidden({ + code: 'calendar.field_hidden', + message: 'field is hidden, not allowed', + details: { fieldId, role }, + }) + ); + } + const valueType = yield* field.accept(new FieldValueTypeVisitor()); + if ( + valueType.cellValueType.toString() !== 'dateTime' || + valueType.isMultipleCellValue.toBoolean() + ) { + return err( + domainError.validation({ + code: `calendar.invalid_${role}_field`, + message: `Invalid ${role} date field id`, + details: { + fieldId, + cellValueType: valueType.cellValueType.toString(), + isMultipleCellValue: valueType.isMultipleCellValue.toBoolean(), + }, + }) + ); + } + return ok(field); + }); +}; + +export function createRecordCalendarDailyCollection( + this: Table, + params: CreateRecordCalendarDailyCollectionParams +): Result { + return safeTry( + function* (this: Table) { + yield* this.getViewById(params.viewId); + const visibleFieldIds = params.includeHiddenFields + ? undefined + : new Set((yield* this.getOrderedVisibleFieldIds(params.viewId)).map(String)); + const startField = yield* resolveCalendarField( + this, + params.startFieldId, + 'start', + visibleFieldIds + ); + const endField = params.endFieldId + ? yield* resolveCalendarField(this, params.endFieldId, 'end', visibleFieldIds) + : startField; + const timeZone = yield* startField.accept(new FieldDateTimeZoneVisitor()); + + return ok( + TableRecordCalendarDailyCollection.create({ + startFieldId: startField.id(), + endFieldId: endField.id(), + timeZone, + }) + ); + }.bind(this) + ); +} diff --git a/packages/v2/core/src/domain/table/methods/createView.spec.ts b/packages/v2/core/src/domain/table/methods/createView.spec.ts new file mode 100644 index 0000000000..e30fb5cab3 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/createView.spec.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { FieldName } from '../fields/FieldName'; +import { TableAddViewSpec } from '../specs/TableAddViewSpec'; +import { Table } from '../Table'; +import { TableId } from '../TableId'; +import { TableName } from '../TableName'; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'b'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Planning')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Title')._unsafeUnwrap()).done(); + builder.field().number().withName(FieldName.create('Estimate')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +const buildTableWithViewDefaults = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'c'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'d'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Defaults')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Title')._unsafeUnwrap()).done(); + builder.field().attachment().withName(FieldName.create('Cover')._unsafeUnwrap()).done(); + builder.field().date().withName(FieldName.create('Start')._unsafeUnwrap()).done(); + builder.field().date().withName(FieldName.create('End')._unsafeUnwrap()).done(); + builder.field().button().withName(FieldName.create('Action')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +describe('Table.createView', () => { + it('creates a fully initialized View and returns its mutation spec', () => { + const table = buildTable(); + const [titleField] = table.getFields(); + const ignoredFieldId = `fld${'z'.repeat(16)}`; + + const result = table.createView({ + name: 'Delivery', + type: 'grid', + columnMeta: { + [titleField!.id().toString()]: { width: 240 }, + [ignoredFieldId]: { width: 320 }, + }, + options: { rowHeight: 'short' }, + }); + + expect(result.isOk()).toBe(true); + const { view, updateResult } = result._unsafeUnwrap(); + expect(view.name().toString()).toBe('Delivery'); + expect(view.type().toString()).toBe('grid'); + expect(view.options()).toEqual({ rowHeight: 'short' }); + expect(view.queryDefaults()._unsafeUnwrap().toDto()).toEqual({}); + expect(view.columnMeta()._unsafeUnwrap().toDto()).toEqual({ + [titleField!.id().toString()]: { order: 0, width: 240 }, + [table.getFields()[1]!.id().toString()]: { order: 1 }, + }); + expect(updateResult.mutateSpec).toBeInstanceOf(TableAddViewSpec); + expect(updateResult.table.views()).toHaveLength(2); + expect(updateResult.table.getView(view.id())._unsafeUnwrap()).toBe(view); + }); + + it('owns the default and unique View naming rules', () => { + const table = buildTable(); + const first = table.createView({ type: 'grid' })._unsafeUnwrap(); + const second = first.updateResult.table.createView({ type: 'grid' })._unsafeUnwrap(); + + expect(first.view.name().toString()).toBe('New view'); + expect(second.view.name().toString()).toBe('New view 2'); + }); + + it.each(['', ' Planning '])('preserves the public View name contract for %j', (name) => { + const result = buildTable().createView({ type: 'grid', name }); + + expect(result._unsafeUnwrap().view.name().toString()).toBe(name); + }); + + it.each([ + ['grid', undefined], + ['calendar', undefined], + ['kanban', undefined], + ['form', undefined], + ['gallery', undefined], + [ + 'plugin', + { + pluginId: 'plg-view', + pluginInstallId: 'pli-view', + pluginLogo: 'https://example.test/logo.png', + }, + ], + ] as const)('creates the %s View subtype inside the aggregate', (type, options) => { + const result = buildTable().createView({ type, options }); + + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().view.type().toString()).toBe(type); + }); + + it('owns legacy creation properties and query defaults', () => { + const table = buildTable(); + const fieldId = table.primaryFieldId().toString(); + const result = table.createView({ + type: 'grid', + description: 'Planning details', + filter: { + conjunction: 'and', + items: [{ fieldId, operator: 'is', value: 'alpha' }], + }, + sort: [{ fieldId, order: 'desc' }], + group: [{ fieldId, order: 'asc' }], + manualSort: false, + isLocked: true, + enableShare: true, + shareId: 'shr-planning', + shareMeta: { + allowCopy: false, + includeRecords: true, + submit: { requireLogin: true }, + }, + }); + + expect(result.isOk()).toBe(true); + const view = result._unsafeUnwrap().view; + expect(view.properties().toDto()).toEqual({ + description: 'Planning details', + isLocked: true, + enableShare: true, + shareId: 'shr-planning', + shareMeta: { + allowCopy: false, + includeRecords: true, + submit: { requireLogin: true }, + }, + }); + expect(view.queryDefaults()._unsafeUnwrap().toDto()).toEqual({ + filter: { + conjunction: 'and', + items: [{ fieldId, operator: 'is', value: 'alpha' }], + }, + sort: [{ fieldId, order: 'desc' }], + group: [{ fieldId, order: 'asc' }], + manualSort: false, + }); + }); + + it('owns Gallery, Calendar, and Form creation defaults', () => { + const table = buildTableWithViewDefaults(); + const [, coverField, startField, endField, buttonField] = table.getFields(); + + const gallery = table.createView({ type: 'gallery' })._unsafeUnwrap().view; + const calendar = table.createView({ type: 'calendar' })._unsafeUnwrap().view; + const form = table.createView({ type: 'form' })._unsafeUnwrap().view; + + expect(gallery.options()).toEqual({ coverFieldId: coverField!.id().toString() }); + expect(calendar.options()).toEqual({ + startDateFieldId: startField!.id().toString(), + endDateFieldId: endField!.id().toString(), + }); + const formMeta = form.columnMeta()._unsafeUnwrap().toDto(); + expect(formMeta[coverField!.id().toString()]?.visible).toBe(true); + expect(formMeta[startField!.id().toString()]?.visible).toBe(true); + expect(formMeta[endField!.id().toString()]?.visible).toBe(true); + expect(formMeta[buttonField!.id().toString()]?.visible).toBeUndefined(); + }); + + it('keeps type-required columns visible when input tries to hide them', () => { + const table = buildTableWithViewDefaults(); + const [primaryField, coverField, startField] = table.getFields(); + const gallery = table + .createView({ + type: 'gallery', + columnMeta: { [primaryField!.id().toString()]: { visible: false } }, + }) + ._unsafeUnwrap().view; + const form = table + .createView({ + type: 'form', + columnMeta: { + [coverField!.id().toString()]: { visible: false }, + [startField!.id().toString()]: { visible: false }, + }, + }) + ._unsafeUnwrap().view; + + expect( + gallery.columnMeta()._unsafeUnwrap().toDto()[primaryField!.id().toString()]?.visible + ).toBe(true); + expect(form.columnMeta()._unsafeUnwrap().toDto()[coverField!.id().toString()]?.visible).toBe( + true + ); + expect(form.columnMeta()._unsafeUnwrap().toDto()[startField!.id().toString()]?.visible).toBe( + true + ); + }); + + it('preserves an empty filter group as a valid View default', () => { + const result = buildTable().createView({ + type: 'grid', + filter: { conjunction: 'and', items: [] }, + }); + + expect(result._unsafeUnwrap().view.queryDefaults()._unsafeUnwrap().filter()).toEqual({ + conjunction: 'and', + items: [], + }); + }); + + it('keeps the source filter for lossless compatibility persistence', () => { + const sourceFilter = { + conjunction: 'and', + filterSet: [{ fieldId: 'fldLegacy', operator: 'IN', isSymbol: true, value: 'alpha' }], + }; + const result = buildTable().createView({ + type: 'grid', + filter: { + fieldId: 'fldLegacy', + operator: 'isAnyOf', + value: ['alpha'], + }, + sourceFilter, + }); + const defaults = result._unsafeUnwrap().view.queryDefaults()._unsafeUnwrap(); + + expect(defaults.filter()).toEqual({ + conjunction: 'and', + items: [ + { + fieldId: 'fldLegacy', + operator: 'isAnyOf', + value: ['alpha'], + }, + ], + }); + expect(defaults.sourceFilter()).toEqual(sourceFilter); + }); + + it('covers absent and explicitly configured type-default branches', () => { + const tableWithoutDefaults = buildTable(); + expect( + tableWithoutDefaults.createView({ type: 'gallery' })._unsafeUnwrap().view.options() + ).toEqual({}); + expect( + tableWithoutDefaults.createView({ type: 'calendar' })._unsafeUnwrap().view.options() + ).toBeUndefined(); + + const tableWithDefaults = buildTableWithViewDefaults(); + const [, coverField, startField] = tableWithDefaults.getFields(); + const gallery = tableWithDefaults + .createView({ + type: 'gallery', + options: { coverFieldId: startField!.id().toString(), isCoverFit: true }, + }) + ._unsafeUnwrap().view; + const calendar = tableWithDefaults + .createView({ + type: 'calendar', + options: { + startDateFieldId: startField!.id().toString(), + endDateFieldId: startField!.id().toString(), + }, + }) + ._unsafeUnwrap().view; + + expect(gallery.options()).toEqual({ + coverFieldId: startField!.id().toString(), + isCoverFit: true, + }); + expect(gallery.options()).not.toMatchObject({ coverFieldId: coverField!.id().toString() }); + expect(calendar.options()).toEqual({ + startDateFieldId: startField!.id().toString(), + endDateFieldId: startField!.id().toString(), + }); + }); + + it.each([ + ['grid', { rowHeight: 'unsupported' }], + ['gallery', { unexpected: true }], + ['calendar', { colorConfig: { type: 'custom', color: 'not-a-color' } }], + ['form', { coverUrl: 42 }], + ['kanban', { isCoverFit: 'yes' }], + ['plugin', { pluginId: 'plg', pluginInstallId: 'pli' }], + ] as const)('rejects invalid %s creation options inside the aggregate', (type, options) => { + expect(buildTable().createView({ type, options }).isErr()).toBe(true); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/createView.ts b/packages/v2/core/src/domain/table/methods/createView.ts new file mode 100644 index 0000000000..604fc45fe3 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/createView.ts @@ -0,0 +1,186 @@ +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import { FieldValueTypeVisitor } from '../fields/visitors/FieldValueTypeVisitor'; +import type { Table } from '../Table'; +import type { TableUpdateResult } from '../TableMutator'; +import type { View } from '../views/View'; +import { ViewColumnMeta, type ViewColumnMetaValue } from '../views/ViewColumnMeta'; +import { createView as createViewEntity } from '../views/ViewFactory'; +import { ViewId } from '../views/ViewId'; +import { ViewName } from '../views/ViewName'; +import { validateViewCreateOptions } from '../views/ViewOptions'; +import { ViewOrder } from '../views/ViewOrder'; +import { ViewProperties, type ViewShareMetaValue } from '../views/ViewProperties'; +import { + ViewQueryDefaults, + type ViewQueryDefaultsDTO, + type ViewQueryGroupItem, + type ViewQuerySortItem, +} from '../views/ViewQueryDefaults'; +import type { IViewTypeLiteral } from '../views/ViewType'; + +export type CreateViewMethodParams = { + readonly name?: string; + readonly type: IViewTypeLiteral; + readonly description?: string; + readonly columnMeta?: ViewColumnMetaValue; + readonly options?: unknown; + readonly filter?: ViewQueryDefaultsDTO['filter']; + readonly sourceFilter?: unknown; + readonly sort?: ReadonlyArray; + readonly group?: ReadonlyArray; + readonly manualSort?: boolean; + readonly isLocked?: boolean; + /** Explicit persisted order (import/duplicate fidelity); defaults to append-at-end. */ + readonly order?: number; + readonly enableShare?: boolean; + readonly shareId?: string; + readonly shareMeta?: ViewShareMetaValue; +}; + +export type CreateViewMethodResult = { + readonly view: View; + readonly updateResult: TableUpdateResult; +}; + +export const uniqueViewName = (name: string, existingNames: ReadonlyArray): string => { + if (!existingNames.includes(name)) return name; + + let baseName = name; + let suffix = 2; + if (Number.isNaN(Number(name))) { + const match = name.match(/^(.*)(\b\d+)$/); + if (match) { + baseName = match[1]?.trim() ?? name; + suffix = Number.parseInt(match[2] ?? `${suffix}`, 10); + } + } + while (existingNames.includes(`${baseName} ${suffix}`)) suffix += 1; + return `${baseName} ${suffix}`; +}; + +const mergeColumnMeta = ( + defaults: ViewColumnMetaValue, + overrides?: ViewColumnMetaValue +): ViewColumnMetaValue => + Object.fromEntries( + Object.entries(defaults).map(([fieldId, entry]) => [ + fieldId, + { + ...entry, + ...overrides?.[fieldId], + ...(entry.visible === true ? { visible: true } : {}), + }, + ]) + ); + +const asOptionsRecord = (options: unknown): Record => + options && typeof options === 'object' && !Array.isArray(options) + ? { ...(options as Record) } + : {}; + +const applyTypeDefaults = ( + table: Table, + type: IViewTypeLiteral, + inputOptions: unknown +): Result => { + if (type === 'gallery') { + const options = asOptionsRecord(inputOptions); + const coverFieldId = + options.coverFieldId ?? + table + .getFields() + .find((field) => field.type().toString() === 'attachment') + ?.id() + .toString(); + return ok({ + ...options, + ...(coverFieldId !== undefined ? { coverFieldId } : {}), + }); + } + + if (type !== 'calendar') return ok(inputOptions); + + return safeTry(function* () { + const dateFieldIds: string[] = []; + const visitor = new FieldValueTypeVisitor(); + for (const field of table.getFields()) { + const valueType = yield* field.accept(visitor); + if ( + valueType.cellValueType.toString() === 'dateTime' && + !valueType.isMultipleCellValue.toBoolean() + ) { + dateFieldIds.push(field.id().toString()); + } + } + if (!dateFieldIds.length) return ok(inputOptions); + + const options = asOptionsRecord(inputOptions); + return ok({ + ...options, + startDateFieldId: options.startDateFieldId ?? dateFieldIds[0], + endDateFieldId: options.endDateFieldId ?? dateFieldIds[1] ?? dateFieldIds[0], + }); + }); +}; + +export function createView( + this: Table, + input: CreateViewMethodParams +): Result { + const table = this; + return safeTry(function* () { + const viewId = yield* ViewId.generate(); + const name = yield* ViewName.create( + uniqueViewName( + input.name ?? 'New view', + table.views().map((view) => view.name().toString()) + ) + ); + const properties = yield* ViewProperties.create({ + ...(input.description !== undefined ? { description: input.description } : {}), + ...(input.isLocked !== undefined ? { isLocked: input.isLocked } : {}), + ...(input.enableShare !== undefined ? { enableShare: input.enableShare } : {}), + ...(input.shareId !== undefined ? { shareId: input.shareId } : {}), + ...(input.shareMeta !== undefined ? { shareMeta: input.shareMeta } : {}), + }); + const view = yield* createViewEntity({ + type: input.type, + id: viewId, + name, + properties, + }); + const defaultColumnMeta = yield* ViewColumnMeta.forView({ + viewType: view.type(), + fields: table.getFields(), + primaryFieldId: table.primaryFieldId(), + }); + const columnMeta = yield* ViewColumnMeta.create( + mergeColumnMeta(defaultColumnMeta.toDto(), input.columnMeta) + ); + + yield* view.setColumnMeta(columnMeta); + const queryDefaults = yield* ViewQueryDefaults.create( + { + ...(input.filter !== undefined ? { filter: input.filter } : {}), + ...(input.sort !== undefined ? { sort: [...input.sort] } : {}), + ...(input.group !== undefined ? { group: [...input.group] } : {}), + ...(input.manualSort !== undefined ? { manualSort: input.manualSort } : {}), + }, + { sourceFilter: input.sourceFilter } + ); + yield* view.setQueryDefaults(queryDefaults); + const options = yield* applyTypeDefaults(table, input.type, input.options); + const validatedOptions = yield* validateViewCreateOptions(input.type, options); + yield* view.setOptions(validatedOptions); + if (input.order !== undefined) { + const orderValue = yield* ViewOrder.rehydrate(input.order); + yield* view.setOrder(orderValue); + } + + const updateResult = yield* table.update((mutator) => mutator.addView(view)); + return ok({ view, updateResult }); + }); +} diff --git a/packages/v2/core/src/domain/table/methods/createViewCollaboratorsQueryPlan.spec.ts b/packages/v2/core/src/domain/table/methods/createViewCollaboratorsQueryPlan.spec.ts new file mode 100644 index 0000000000..cee96208f5 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/createViewCollaboratorsQueryPlan.spec.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { FieldId } from '../fields/FieldId'; +import { FieldName } from '../fields/FieldName'; +import { Table } from '../Table'; +import { TableId } from '../TableId'; +import { TableName } from '../TableName'; +import { ViewId } from '../views/ViewId'; + +const id = (prefix: 'bse' | 'tbl' | 'fld' | 'viw', seed: string) => `${prefix}${seed.repeat(16)}`; + +const buildTable = (viewType: 'grid' | 'form' | 'kanban' | 'plugin') => { + const primaryFieldId = FieldId.create(id('fld', 'p'))._unsafeUnwrap(); + const userFieldId = FieldId.create(id('fld', 'u'))._unsafeUnwrap(); + const viewId = ViewId.create(id('viw', 'v'))._unsafeUnwrap(); + const builder = Table.builder() + .withBaseId(BaseId.create(id('bse', 'b'))._unsafeUnwrap()) + .withId(TableId.create(id('tbl', 't'))._unsafeUnwrap()) + .withName(TableName.create('Collaborators')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(primaryFieldId) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + builder + .field() + .user() + .withId(userFieldId) + .withName(FieldName.create('Owner')._unsafeUnwrap()) + .done(); + builder.view()[viewType]().withId(viewId).defaultName().done(); + const table = builder.build()._unsafeUnwrap(); + return { table, viewId, userFieldId, primaryFieldId }; +}; + +describe('Table.createViewCollaboratorsQueryPlan', () => { + it.each(['form', 'kanban', 'plugin'] as const)( + 'uses the full member directory for a %s View with a visible user-related Field', + (viewType) => { + const fixture = buildTable(viewType); + + expect( + fixture.table.createViewCollaboratorsQueryPlan({ viewId: fixture.viewId })._unsafeUnwrap() + .mode + ).toBe('all'); + } + ); + + it('uses referenced users for ordinary Grid shares and preserves the View filter', () => { + const fixture = buildTable('grid'); + const filter = { + conjunction: 'and' as const, + filterSet: [ + { + fieldId: fixture.primaryFieldId.toString(), + operator: 'is' as const, + value: 'Visible', + }, + ], + }; + const table = fixture.table.updateViewFilter(fixture.viewId, filter)._unsafeUnwrap() + .updateResult!.table; + const plan = table + .createViewCollaboratorsQueryPlan({ + viewId: fixture.viewId, + fieldId: fixture.userFieldId, + }) + ._unsafeUnwrap(); + + expect(plan.mode).toBe('referenced'); + expect(plan.referencedField()._unsafeUnwrap().id().equals(fixture.userFieldId)).toBe(true); + expect(plan.recordFilter()).toEqual({ + conjunction: 'and', + items: filter.filterSet, + }); + }); + + it('allows a share editor to use the full directory for an ordinary Grid View', () => { + const fixture = buildTable('grid'); + + expect( + fixture.table + .createViewCollaboratorsQueryPlan({ + viewId: fixture.viewId, + canReadAllCollaborators: true, + }) + ._unsafeUnwrap().mode + ).toBe('all'); + }); + + it('returns an empty all-mode plan when the requested or visible Field is not user-related', () => { + const fixture = buildTable('form'); + const hidden = fixture.table + .updateViewColumnMeta(fixture.viewId, [ + { fieldId: fixture.userFieldId, columnMeta: { visible: false } }, + ]) + ._unsafeUnwrap().updateResult!.table; + + expect( + fixture.table + .createViewCollaboratorsQueryPlan({ + viewId: fixture.viewId, + fieldId: fixture.primaryFieldId, + }) + ._unsafeUnwrap().mode + ).toBe('empty'); + expect( + hidden.createViewCollaboratorsQueryPlan({ viewId: fixture.viewId })._unsafeUnwrap().mode + ).toBe('empty'); + expect( + hidden + .createViewCollaboratorsQueryPlan({ + viewId: fixture.viewId, + includeHiddenFields: true, + }) + ._unsafeUnwrap().mode + ).toBe('all'); + }); + + it('rejects missing, hidden, and non-user Fields for referenced Grid queries', () => { + const fixture = buildTable('grid'); + const hidden = fixture.table + .updateViewColumnMeta(fixture.viewId, [ + { fieldId: fixture.userFieldId, columnMeta: { hidden: true } }, + ]) + ._unsafeUnwrap().updateResult!.table; + + expect( + fixture.table.createViewCollaboratorsQueryPlan({ viewId: fixture.viewId })._unsafeUnwrapErr() + ).toMatchObject({ code: 'view_collaborators.field_required', tags: ['validation'] }); + expect( + hidden + .createViewCollaboratorsQueryPlan({ + viewId: fixture.viewId, + fieldId: fixture.userFieldId, + }) + ._unsafeUnwrapErr() + ).toMatchObject({ code: 'view_collaborators.field_hidden', tags: ['forbidden'] }); + expect( + fixture.table + .createViewCollaboratorsQueryPlan({ + viewId: fixture.viewId, + fieldId: fixture.primaryFieldId, + }) + ._unsafeUnwrapErr() + ).toMatchObject({ + code: 'view_collaborators.field_not_user_related', + tags: ['forbidden'], + }); + }); + + it('uses the full directory without a View when the Table has a user-related Field', () => { + const fixture = buildTable('grid'); + expect(fixture.table.createViewCollaboratorsQueryPlan({})._unsafeUnwrap().mode).toBe('all'); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/createViewCollaboratorsQueryPlan.ts b/packages/v2/core/src/domain/table/methods/createViewCollaboratorsQueryPlan.ts new file mode 100644 index 0000000000..b73995d0f2 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/createViewCollaboratorsQueryPlan.ts @@ -0,0 +1,150 @@ +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { RecordFilter } from '../../../queries/RecordFilterDto'; +import { domainError, type DomainError } from '../../shared/DomainError'; +import type { Field } from '../fields/Field'; +import type { FieldId } from '../fields/FieldId'; +import { CreatedByField } from '../fields/types/CreatedByField'; +import { LastModifiedByField } from '../fields/types/LastModifiedByField'; +import { UserField } from '../fields/types/UserField'; +import type { Table } from '../Table'; +import type { ViewId } from '../views/ViewId'; + +export type ViewCollaboratorField = UserField | CreatedByField | LastModifiedByField; +export type ViewCollaboratorsQueryMode = 'all' | 'referenced' | 'empty'; + +export type CreateViewCollaboratorsQueryPlanParams = { + readonly viewId?: ViewId; + readonly fieldId?: FieldId; + readonly includeHiddenFields?: boolean; + readonly canReadAllCollaborators?: boolean; +}; + +const isCollaboratorField = (field: Field): field is ViewCollaboratorField => + field instanceof UserField || + field instanceof CreatedByField || + field instanceof LastModifiedByField; + +export const viewCollaboratorFieldIsMultiple = (field: ViewCollaboratorField): boolean => + field instanceof UserField && field.multiplicity().toBoolean(); + +export class ViewCollaboratorsQueryPlan { + private constructor( + readonly mode: ViewCollaboratorsQueryMode, + private readonly fieldValue?: ViewCollaboratorField, + private readonly recordFilterValue?: RecordFilter | null + ) {} + + static all(): ViewCollaboratorsQueryPlan { + return new ViewCollaboratorsQueryPlan('all'); + } + + static referenced( + field: ViewCollaboratorField, + recordFilter?: RecordFilter | null + ): ViewCollaboratorsQueryPlan { + return new ViewCollaboratorsQueryPlan('referenced', field, recordFilter); + } + + static empty(): ViewCollaboratorsQueryPlan { + return new ViewCollaboratorsQueryPlan('empty'); + } + + referencedField(): Result { + if (this.mode === 'referenced' && this.fieldValue) return ok(this.fieldValue); + return err( + domainError.invariant({ + code: 'view_collaborators.referenced_field_unavailable', + message: 'Referenced collaborator field is unavailable for this query mode', + }) + ); + } + + recordFilter(): RecordFilter | null | undefined { + return this.recordFilterValue; + } +} + +/** + * Resolve collaborator visibility and query scope for a View owned by this Table. + * + * The application layer executes this immutable plan against the existing Table Record + * repository and the independent collaborator directory. It must not reinterpret View + * subtype, Field visibility, or user-related Field semantics. + */ +export function createViewCollaboratorsQueryPlan( + this: Table, + params: CreateViewCollaboratorsQueryPlanParams +): Result { + return safeTry( + function* (this: Table) { + const view = params.viewId ? yield* this.getView(params.viewId) : undefined; + const viewType = view?.type().toString(); + const canReadAll = + !view || + params.canReadAllCollaborators || + viewType === 'form' || + viewType === 'kanban' || + viewType === 'plugin'; + + if (canReadAll) { + const visibleFieldIds = + view && !params.includeHiddenFields + ? new Set( + (yield* this.getOrderedVisibleFieldIds(view.id().toString())).map((fieldId) => + fieldId.toString() + ) + ) + : undefined; + const hasCollaboratorField = this.getFields().some( + (field) => + (!params.fieldId || field.id().equals(params.fieldId)) && + (!visibleFieldIds || visibleFieldIds.has(field.id().toString())) && + isCollaboratorField(field) + ); + return ok( + hasCollaboratorField + ? ViewCollaboratorsQueryPlan.all() + : ViewCollaboratorsQueryPlan.empty() + ); + } + + if (!params.fieldId) { + return err( + domainError.validation({ + code: 'view_collaborators.field_required', + message: 'fieldId is required', + }) + ); + } + + if (!params.includeHiddenFields) { + const visibleFieldIds = yield* this.getOrderedVisibleFieldIds(view.id().toString()); + if (!visibleFieldIds.some((fieldId) => fieldId.equals(params.fieldId!))) { + return err( + domainError.forbidden({ + code: 'view_collaborators.field_hidden', + message: 'field is hidden, not allowed', + details: { fieldId: params.fieldId.toString() }, + }) + ); + } + } + + const field = yield* this.getField((candidate) => candidate.id().equals(params.fieldId!)); + if (!isCollaboratorField(field)) { + return err( + domainError.forbidden({ + code: 'view_collaborators.field_not_user_related', + message: 'field type is not user-related field', + details: { fieldId: params.fieldId.toString() }, + }) + ); + } + + const defaults = yield* view.queryDefaults(); + return ok(ViewCollaboratorsQueryPlan.referenced(field, defaults.filter())); + }.bind(this) + ); +} diff --git a/packages/v2/core/src/domain/table/methods/createViewLinkRecordsQueryPlan.spec.ts b/packages/v2/core/src/domain/table/methods/createViewLinkRecordsQueryPlan.spec.ts new file mode 100644 index 0000000000..c5d45caeba --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/createViewLinkRecordsQueryPlan.spec.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { FieldId } from '../fields/FieldId'; +import { FieldName } from '../fields/FieldName'; +import { LinkFieldConfig } from '../fields/types/LinkFieldConfig'; +import { Table } from '../Table'; +import { TableId } from '../TableId'; +import { TableName } from '../TableName'; +import { ViewId } from '../views/ViewId'; + +const id = (prefix: 'bse' | 'tbl' | 'fld' | 'viw', seed: string) => `${prefix}${seed.repeat(16)}`; + +const buildTable = (viewType: 'grid' | 'form' | 'plugin') => { + const foreignTableId = TableId.create(id('tbl', 'f'))._unsafeUnwrap(); + const lookupFieldId = FieldId.create(id('fld', 'l'))._unsafeUnwrap(); + const primaryFieldId = FieldId.create(id('fld', 'p'))._unsafeUnwrap(); + const linkFieldId = FieldId.create(id('fld', 'k'))._unsafeUnwrap(); + const viewId = ViewId.create(id('viw', 'v'))._unsafeUnwrap(); + const builder = Table.builder() + .withBaseId(BaseId.create(id('bse', 'b'))._unsafeUnwrap()) + .withId(TableId.create(id('tbl', 't'))._unsafeUnwrap()) + .withName(TableName.create('Host')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(primaryFieldId) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + builder + .field() + .link() + .withId(linkFieldId) + .withName(FieldName.create('Link')._unsafeUnwrap()) + .withConfig( + LinkFieldConfig.create({ + relationship: 'manyOne', + foreignTableId: foreignTableId.toString(), + lookupFieldId: lookupFieldId.toString(), + isOneWay: true, + })._unsafeUnwrap() + ) + .done(); + builder.view()[viewType]().withId(viewId).defaultName().done(); + const table = builder.build()._unsafeUnwrap(); + return { table, viewId, linkFieldId, primaryFieldId, foreignTableId, lookupFieldId }; +}; + +describe('Table.createViewLinkRecordsQueryPlan', () => { + it('makes Form Link selectors candidate queries regardless of the requested type', () => { + const fixture = buildTable('form'); + const plan = fixture.table + .createViewLinkRecordsQueryPlan({ + viewId: fixture.viewId, + fieldId: fixture.linkFieldId, + requestType: 'selected', + }) + ._unsafeUnwrap(); + + expect(plan.selectionType).toBe('candidate'); + expect(plan.foreignTableId().equals(fixture.foreignTableId)).toBe(true); + expect(plan.lookupFieldId().equals(fixture.lookupFieldId)).toBe(true); + expect(plan.linkFieldId().equals(fixture.linkFieldId)).toBe(true); + }); + + it('uses the requested candidate mode only for Plugin Views', () => { + const plugin = buildTable('plugin'); + const grid = buildTable('grid'); + + expect( + plugin.table + .createViewLinkRecordsQueryPlan({ + viewId: plugin.viewId, + fieldId: plugin.linkFieldId, + requestType: 'candidate', + }) + ._unsafeUnwrap().selectionType + ).toBe('candidate'); + expect( + plugin.table + .createViewLinkRecordsQueryPlan({ + viewId: plugin.viewId, + fieldId: plugin.linkFieldId, + }) + ._unsafeUnwrap().selectionType + ).toBe('selected'); + expect( + grid.table + .createViewLinkRecordsQueryPlan({ + viewId: grid.viewId, + fieldId: grid.linkFieldId, + requestType: 'candidate', + }) + ._unsafeUnwrap().selectionType + ).toBe('selected'); + }); + + it('rejects hidden Fields unless the share explicitly includes them', () => { + const fixture = buildTable('grid'); + const hiddenTable = fixture.table + .updateViewColumnMeta(fixture.viewId, [ + { fieldId: fixture.linkFieldId, columnMeta: { hidden: true } }, + ]) + ._unsafeUnwrap().updateResult!.table; + + expect( + hiddenTable + .createViewLinkRecordsQueryPlan({ + viewId: fixture.viewId, + fieldId: fixture.linkFieldId, + }) + ._unsafeUnwrapErr() + ).toMatchObject({ code: 'view_link_records.field_hidden', tags: ['forbidden'] }); + expect( + hiddenTable + .createViewLinkRecordsQueryPlan({ + viewId: fixture.viewId, + fieldId: fixture.linkFieldId, + includeHiddenFields: true, + }) + ._unsafeUnwrap().selectionType + ).toBe('selected'); + }); + + it('rejects a visible non-Link Field at the aggregate boundary', () => { + const fixture = buildTable('grid'); + + expect( + fixture.table + .createViewLinkRecordsQueryPlan({ + viewId: fixture.viewId, + fieldId: fixture.primaryFieldId, + }) + ._unsafeUnwrapErr() + ).toMatchObject({ code: 'view_link_records.field_not_link', tags: ['forbidden'] }); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/createViewLinkRecordsQueryPlan.ts b/packages/v2/core/src/domain/table/methods/createViewLinkRecordsQueryPlan.ts new file mode 100644 index 0000000000..900f4a6836 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/createViewLinkRecordsQueryPlan.ts @@ -0,0 +1,209 @@ +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import type { ISpecification } from '../../shared/specification/ISpecification'; +import type { FieldId } from '../fields/FieldId'; +import { FieldCondition } from '../fields/types/FieldCondition'; +import { LinkField } from '../fields/types/LinkField'; +import { IncomingLinkCandidateSpec } from '../records/specs/IncomingLinkCandidateSpec'; +import { IncomingLinkSelectedSpec } from '../records/specs/IncomingLinkSelectedSpec'; +import type { ITableRecordConditionSpecVisitor } from '../records/specs/ITableRecordConditionSpecVisitor'; +import type { TableRecord } from '../records/TableRecord'; +import type { Table } from '../Table'; +import type { TableId } from '../TableId'; +import type { ViewId } from '../views/ViewId'; + +export type ViewLinkRecordsRequestType = 'candidate' | 'selected'; +export type ViewLinkRecordsSelectionType = 'candidate' | 'selected'; + +export type CreateViewLinkRecordsQueryPlanParams = { + readonly viewId: ViewId; + readonly fieldId: FieldId; + readonly requestType?: ViewLinkRecordsRequestType; + readonly includeHiddenFields?: boolean; +}; + +const isJunctionTable = (dbTableName: string): boolean => { + if (dbTableName.includes('.')) { + return dbTableName.split('.')[1]?.startsWith('junction') ?? false; + } + return dbTableName.split('_')[1]?.startsWith('junction') ?? false; +}; + +export class ViewLinkRecordsQueryPlan { + private constructor( + private readonly linkFieldValue: LinkField, + readonly selectionType: ViewLinkRecordsSelectionType + ) {} + + static create( + linkField: LinkField, + selectionType: ViewLinkRecordsSelectionType + ): ViewLinkRecordsQueryPlan { + return new ViewLinkRecordsQueryPlan(linkField, selectionType); + } + + foreignTableId(): TableId { + return this.linkFieldValue.foreignTableId(); + } + + lookupFieldId(): FieldId { + return this.linkFieldValue.lookupFieldId(); + } + + linkFieldId(): FieldId { + return this.linkFieldValue.id(); + } + + filterByViewId(): ViewId | null | undefined { + return this.selectionType === 'candidate' ? this.linkFieldValue.filterByViewId() : undefined; + } + + validateTargetTable(targetTable: Table): Result { + if (!targetTable.id().equals(this.foreignTableId())) { + return err( + domainError.invariant({ + code: 'view_link_records.target_table_mismatch', + message: 'Link Record target Table does not match the Link Field', + }) + ); + } + return targetTable + .getField((field) => field.id().equals(this.lookupFieldId())) + .map(() => void 0); + } + + linkFilterSpec( + targetTable: Table + ): Result< + ISpecification | undefined, + DomainError + > { + if (this.selectionType !== 'candidate') return ok(undefined); + const filter = this.linkFieldValue.config().filter(); + if (filter == null) return ok(undefined); + return FieldCondition.create({ filter }).andThen((condition) => + condition.toRecordConditionSpec(targetTable).map((spec) => spec ?? undefined) + ); + } + + selectionSpec( + sourceTable: Table, + targetTable: Table + ): Result< + ISpecification | undefined, + DomainError + > { + return safeTry( + function* (this: ViewLinkRecordsQueryPlan) { + yield* this.validateTargetTable(targetTable); + const currentTableDbName = yield* targetTable + .dbTableName() + .andThen((dbTableName) => dbTableName.value()); + const hostTableDbName = yield* sourceTable + .dbTableName() + .andThen((dbTableName) => dbTableName.value()); + const selfKeyName = yield* this.linkFieldValue.selfKeyNameString(); + const fkHostTableName = yield* this.linkFieldValue.fkHostTableNameString(); + const foreignKeyName = yield* this.linkFieldValue.foreignKeyNameString(); + + if (this.selectionType === 'selected') { + return ok( + fkHostTableName === currentTableDbName || hostTableDbName === currentTableDbName + ? IncomingLinkSelectedSpec.create({ + mode: 'currentColumnNotNull', + selfKeyName, + }) + : IncomingLinkSelectedSpec.create({ + mode: 'hostReferenceExists', + selfKeyName, + fkHostTableName, + foreignKeyName, + }) + ); + } + + if (this.linkFieldValue.relationship().toString() === 'oneMany') { + return ok( + isJunctionTable(fkHostTableName) + ? IncomingLinkCandidateSpec.create({ + mode: 'junctionReferenceAvailable', + selfKeyName, + fkHostTableName, + foreignKeyName, + }) + : IncomingLinkCandidateSpec.create({ + mode: 'currentColumnAvailable', + selfKeyName, + }) + ); + } + if (this.linkFieldValue.relationship().toString() === 'oneOne') { + return ok( + selfKeyName === '__id' + ? IncomingLinkCandidateSpec.create({ + mode: 'hostReferenceAvailable', + selfKeyName, + fkHostTableName, + foreignKeyName, + }) + : IncomingLinkCandidateSpec.create({ + mode: 'currentColumnAvailable', + selfKeyName, + }) + ); + } + return ok(undefined); + }.bind(this) + ); + } +} + +/** + * Resolve the complete cross-table Record-query intent for a Link Field owned by this Table. + * + * The application layer may execute this plan against the existing Table Record query path, + * but it must not reinterpret View subtype, Field visibility, or Link configuration. + */ +export function createViewLinkRecordsQueryPlan( + this: Table, + params: CreateViewLinkRecordsQueryPlanParams +): Result { + return safeTry( + function* (this: Table) { + const view = yield* this.getView(params.viewId); + if (!params.includeHiddenFields) { + const visibleFieldIds = yield* this.getOrderedVisibleFieldIds(params.viewId.toString()); + if (!visibleFieldIds.some((fieldId) => fieldId.equals(params.fieldId))) { + return err( + domainError.forbidden({ + code: 'view_link_records.field_hidden', + message: 'field is hidden, not allowed', + details: { fieldId: params.fieldId.toString() }, + }) + ); + } + } + + const field = yield* this.getField((candidate) => candidate.id().equals(params.fieldId)); + if (!(field instanceof LinkField)) { + return err( + domainError.forbidden({ + code: 'view_link_records.field_not_link', + message: 'Field type is not link field', + details: { fieldId: params.fieldId.toString() }, + }) + ); + } + + const viewType = view.type().toString(); + const selectionType: ViewLinkRecordsSelectionType = + viewType === 'form' || (viewType === 'plugin' && params.requestType === 'candidate') + ? 'candidate' + : 'selected'; + + return ok(ViewLinkRecordsQueryPlan.create(field, selectionType)); + }.bind(this) + ); +} diff --git a/packages/v2/core/src/domain/table/methods/createViewSelectionCopyPlan.spec.ts b/packages/v2/core/src/domain/table/methods/createViewSelectionCopyPlan.spec.ts new file mode 100644 index 0000000000..9fb6f07d2e --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/createViewSelectionCopyPlan.spec.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { FieldId } from '../fields/FieldId'; +import { FieldName } from '../fields/FieldName'; +import { Table } from '../Table'; +import { TableId } from '../TableId'; +import { TableName } from '../TableName'; +import { ViewId } from '../views/ViewId'; + +const id = (prefix: 'bse' | 'tbl' | 'fld' | 'viw', seed: string) => `${prefix}${seed.repeat(16)}`; +const hash = (value: string) => { + let result = 5381; + let index = value.length; + while (index) result = (result * 33) ^ value.charCodeAt(--index); + return result >>> 0; +}; + +const buildTable = (shareMeta: { + allowCopy?: boolean; + includeRecords?: boolean; + includeHiddenField?: boolean; +}) => { + const primaryFieldId = FieldId.create(id('fld', 'p'))._unsafeUnwrap(); + const hiddenFieldId = FieldId.create(id('fld', 'h'))._unsafeUnwrap(); + const amountFieldId = FieldId.create(id('fld', 'a'))._unsafeUnwrap(); + const viewId = ViewId.create(id('viw', 'v'))._unsafeUnwrap(); + const builder = Table.builder() + .withBaseId(BaseId.create(id('bse', 'b'))._unsafeUnwrap()) + .withId(TableId.create(id('tbl', 't'))._unsafeUnwrap()) + .withName(TableName.create('Copy')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(primaryFieldId) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + builder + .field() + .singleLineText() + .withId(hiddenFieldId) + .withName(FieldName.create('Secret')._unsafeUnwrap()) + .done(); + builder + .field() + .number() + .withId(amountFieldId) + .withName(FieldName.create('Amount')._unsafeUnwrap()) + .done(); + builder.view().grid().withId(viewId).defaultName().done(); + const table = builder + .build() + ._unsafeUnwrap() + .updateViewColumnMeta(viewId, [ + { fieldId: primaryFieldId, columnMeta: { order: 0 } }, + { fieldId: hiddenFieldId, columnMeta: { order: 1, hidden: true } }, + { fieldId: amountFieldId, columnMeta: { order: 2 } }, + ]) + ._unsafeUnwrap().updateResult!.table; + const withMeta = table.updateViewShareMeta(viewId, shareMeta)._unsafeUnwrap().updateResult!.table; + const enabled = withMeta.enableViewShare(viewId)._unsafeUnwrap().updateResult.table; + return { table: enabled, viewId, primaryFieldId, hiddenFieldId, amountFieldId }; +}; + +describe('Table.createViewSelectionCopyPlan', () => { + it('bounds projection to visible fields while preserving requested order', () => { + const fixture = buildTable({ allowCopy: true, includeRecords: true }); + const plan = fixture.table + .createViewSelectionCopyPlan({ + viewId: fixture.viewId, + ranges: [ + [0, 0], + [1, 0], + ], + projection: [fixture.hiddenFieldId, fixture.amountFieldId, fixture.primaryFieldId], + }) + ._unsafeUnwrap(); + + expect(plan.fields.map((field) => field.id().toString())).toEqual([ + fixture.amountFieldId.toString(), + fixture.primaryFieldId.toString(), + ]); + expect(plan.recordWindows).toEqual([{ offset: 0, limit: 1 }]); + }); + + it('includes hidden fields only when the share metadata explicitly allows them', () => { + const fixture = buildTable({ + allowCopy: true, + includeRecords: true, + includeHiddenField: true, + }); + const plan = fixture.table + .createViewSelectionCopyPlan({ + viewId: fixture.viewId, + ranges: [ + [0, 0], + [2, 0], + ], + }) + ._unsafeUnwrap(); + + expect(plan.fields.map((field) => field.id().toString())).toEqual([ + fixture.primaryFieldId.toString(), + fixture.hiddenFieldId.toString(), + fixture.amountFieldId.toString(), + ]); + }); + + it('rejects caller query fields outside the shared View boundary', () => { + const fixture = buildTable({ allowCopy: true, includeRecords: true }); + + expect( + fixture.table + .createViewSelectionCopyPlan({ + viewId: fixture.viewId, + ranges: [ + [0, 0], + [0, 0], + ], + queryFieldIds: [fixture.hiddenFieldId.toString()], + }) + ._unsafeUnwrapErr() + ).toMatchObject({ + code: 'view_selection_copy.query_field_hidden', + tags: ['forbidden'], + }); + }); + + it('enforces share lifecycle and copy permission while allowing a trusted editor override', () => { + const fixture = buildTable({ allowCopy: false, includeRecords: true }); + expect( + fixture.table + .createViewSelectionCopyPlan({ + viewId: fixture.viewId, + ranges: [ + [0, 0], + [0, 0], + ], + }) + ._unsafeUnwrapErr() + ).toMatchObject({ code: 'view_selection_copy.not_allowed', tags: ['forbidden'] }); + expect( + fixture.table + .createViewSelectionCopyPlan({ + viewId: fixture.viewId, + canCopyAsEditor: true, + ranges: [ + [0, 0], + [0, 0], + ], + }) + ._unsafeUnwrap().fields + ).toHaveLength(1); + + const disabled = fixture.table.disableViewShare(fixture.viewId)._unsafeUnwrap() + .updateResult.table; + expect( + disabled + .createViewSelectionCopyPlan({ + viewId: fixture.viewId, + canCopyAsEditor: true, + ranges: [ + [0, 0], + [0, 0], + ], + }) + ._unsafeUnwrapErr().code + ).toBe('view_selection_copy.share_disabled'); + }); + + it('returns a no-record plan when the shared view excludes records', () => { + const fixture = buildTable({ allowCopy: true, includeRecords: false }); + const plan = fixture.table + .createViewSelectionCopyPlan({ + viewId: fixture.viewId, + ranges: [ + [0, 0], + [0, 1], + ], + }) + ._unsafeUnwrap(); + + expect(plan.recordsIncluded).toBe(false); + expect(plan.fields).toHaveLength(1); + }); + + it('preserves disjoint row and column ranges in request order', () => { + const fixture = buildTable({ allowCopy: true, includeRecords: true }); + const rows = fixture.table + .createViewSelectionCopyPlan({ + viewId: fixture.viewId, + type: 'rows', + ranges: [ + [3, 4], + [1, 1], + ], + }) + ._unsafeUnwrap(); + expect(rows.recordWindows).toEqual([ + { offset: 3, limit: 2 }, + { offset: 1, limit: 1 }, + ]); + expect(rows.requestedCellCount()._unsafeUnwrap()).toBe(6); + + const columns = fixture.table + .createViewSelectionCopyPlan({ + viewId: fixture.viewId, + type: 'columns', + ranges: [ + [1, 1], + [0, 0], + ], + }) + ._unsafeUnwrap(); + expect(columns.fields.map((field) => field.id().toString())).toEqual([ + fixture.amountFieldId.toString(), + fixture.primaryFieldId.toString(), + ]); + expect(columns.requestedCellCount(4)._unsafeUnwrap()).toBe(8); + }); + + it.each([ + { ranges: [[0, 0]] }, + { + ranges: [ + [1, 0], + [0, 0], + ], + }, + { type: 'rows' as const, ranges: [[2, 1]] }, + ])('rejects malformed or reversed ranges: $ranges', (input) => { + const fixture = buildTable({ allowCopy: true, includeRecords: true }); + expect( + fixture.table + .createViewSelectionCopyPlan({ + viewId: fixture.viewId, + ...input, + ranges: input.ranges as Array<[number, number]>, + }) + ._unsafeUnwrapErr().code + ).toBe('view_selection_copy.invalid_ranges'); + }); + + it('builds collapsed-group exclusions from aggregate-owned Field semantics', () => { + const fixture = buildTable({ allowCopy: true, includeRecords: true }); + const groupId = String(hash(`${fixture.primaryFieldId.toString()}_Alpha`)); + + expect( + fixture.table + .createCollapsedGroupExclusionFilter( + [{ fieldId: fixture.primaryFieldId.toString(), order: 'asc' }], + [{ groupValues: ['Alpha'] }, { groupValues: ['Beta'] }], + new Set([groupId]) + ) + ._unsafeUnwrap() + ).toEqual({ + conjunction: 'and', + items: [ + { + conjunction: 'or', + items: [ + { + fieldId: fixture.primaryFieldId.toString(), + operator: 'isNot', + value: 'Alpha', + }, + ], + }, + ], + }); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/createViewSelectionCopyPlan.ts b/packages/v2/core/src/domain/table/methods/createViewSelectionCopyPlan.ts new file mode 100644 index 0000000000..2d5edcb870 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/createViewSelectionCopyPlan.ts @@ -0,0 +1,234 @@ +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import type { Field } from '../fields/Field'; +import type { FieldId } from '../fields/FieldId'; +import type { Table } from '../Table'; +import type { ViewId } from '../views/ViewId'; + +export type ViewSelectionCopyRangeType = 'columns' | 'rows' | undefined; +export type ViewSelectionCopyRange = readonly [number, number]; + +export type CreateViewSelectionCopyPlanParams = { + readonly viewId: ViewId; + readonly canCopyAsEditor?: boolean; + readonly ranges: ReadonlyArray; + readonly type?: ViewSelectionCopyRangeType; + readonly projection?: ReadonlyArray; + readonly queryFieldIds?: ReadonlyArray; +}; + +export type ViewSelectionCopyRecordWindow = { + readonly offset: number; + readonly limit?: number; +}; + +export class ViewSelectionCopyPlan { + private constructor( + readonly type: ViewSelectionCopyRangeType, + readonly fields: ReadonlyArray, + readonly searchFieldIds: ReadonlyArray, + readonly recordWindows: ReadonlyArray, + readonly requestedRowCount: number | undefined, + readonly recordsIncluded: boolean + ) {} + + static create(params: { + type: ViewSelectionCopyRangeType; + fields: ReadonlyArray; + searchFieldIds: ReadonlyArray; + recordWindows: ReadonlyArray; + requestedRowCount?: number; + recordsIncluded?: boolean; + }): ViewSelectionCopyPlan { + return new ViewSelectionCopyPlan( + params.type, + params.fields, + params.searchFieldIds, + params.recordWindows, + params.requestedRowCount, + params.recordsIncluded ?? true + ); + } + + requestedCellCount(totalRows?: number): Result { + const rowCount = this.type === 'columns' ? totalRows : this.requestedRowCount; + if (rowCount == null) { + return err( + domainError.invariant({ + code: 'view_selection_copy.total_rows_required', + message: 'Total rows are required for a column selection', + }) + ); + } + return ok(rowCount * this.fields.length); + } +} + +const validateRanges = ( + ranges: ReadonlyArray, + type: ViewSelectionCopyRangeType +): Result => { + const expectedLength = type ? undefined : 2; + if (ranges.length === 0 || (expectedLength !== undefined && ranges.length !== expectedLength)) { + return err( + domainError.validation({ + code: 'view_selection_copy.invalid_ranges', + message: type + ? 'Row and column selections require at least one range' + : 'Cell selections require exactly two coordinates', + }) + ); + } + + for (const range of ranges) { + const [start, end] = range; + if ( + !Number.isInteger(start) || + !Number.isInteger(end) || + start < 0 || + end < 0 || + (type !== undefined && start > end) + ) { + return err( + domainError.validation({ + code: 'view_selection_copy.invalid_ranges', + message: 'Selection ranges must contain ascending non-negative integer coordinates', + details: { range }, + }) + ); + } + } + if (type === undefined && (ranges[0]![0] > ranges[1]![0] || ranges[0]![1] > ranges[1]![1])) { + return err( + domainError.validation({ + code: 'view_selection_copy.invalid_ranges', + message: 'Cell selection coordinates must be ordered from top-left to bottom-right', + }) + ); + } + return ok(undefined); +}; + +const selectProjectedVisibleFields = ( + table: Table, + visibleFieldIds: ReadonlyArray, + projection: ReadonlyArray | undefined +): ReadonlyArray => { + const visible = new Set(visibleFieldIds.map((fieldId) => fieldId.toString())); + const requested = projection?.length ? projection : visibleFieldIds; + const byId = new Map(table.getFields().map((field) => [field.id().toString(), field])); + const selected: Field[] = []; + const seen = new Set(); + + for (const fieldId of requested) { + const id = fieldId.toString(); + if (!visible.has(id) || seen.has(id)) continue; + const field = byId.get(id); + if (!field) continue; + seen.add(id); + selected.push(field); + } + return selected; +}; + +/** + * Build the immutable copy plan for a View owned by this Table. + * + * Share authorization, visible-field ordering, projection bounding and range semantics + * belong to the aggregate. The query handler only executes the returned record windows. + */ +export function createViewSelectionCopyPlan( + this: Table, + params: CreateViewSelectionCopyPlanParams +): Result { + return safeTry( + function* (this: Table) { + yield* validateRanges(params.ranges, params.type); + const view = yield* this.getView(params.viewId); + if (view.enableShare() !== true || !view.shareId()) { + return err( + domainError.forbidden({ + code: 'view_selection_copy.share_disabled', + message: 'Shared view is disabled', + }) + ); + } + const shareMeta = view.shareMeta(); + if (!shareMeta?.allowCopy && !params.canCopyAsEditor) { + return err( + domainError.forbidden({ + code: 'view_selection_copy.not_allowed', + message: 'not allowed to copy', + }) + ); + } + const visibleFieldIds = yield* this.getOrderedVisibleFieldIds(params.viewId.toString(), { + includeHiddenFields: shareMeta?.includeHiddenField === true, + }); + const visibleFieldIdSet = new Set(visibleFieldIds.map((fieldId) => fieldId.toString())); + for (const fieldId of params.queryFieldIds ?? []) { + if (!visibleFieldIdSet.has(fieldId)) { + return err( + domainError.forbidden({ + code: 'view_selection_copy.query_field_hidden', + message: 'Copy query references a field outside the shared View', + details: { fieldId }, + }) + ); + } + } + const availableFields = selectProjectedVisibleFields( + this, + visibleFieldIds, + params.projection + ); + + if (params.type === 'columns') { + const fields = params.ranges.flatMap(([start, end]) => + availableFields.slice(start, end + 1) + ); + return ok( + ViewSelectionCopyPlan.create({ + type: params.type, + fields, + searchFieldIds: visibleFieldIds, + recordWindows: [{ offset: 0 }], + recordsIncluded: shareMeta?.includeRecords !== false, + }) + ); + } + + if (params.type === 'rows') { + const recordWindows = params.ranges.map(([start, end]) => ({ + offset: start, + limit: end - start + 1, + })); + return ok( + ViewSelectionCopyPlan.create({ + type: params.type, + fields: availableFields, + searchFieldIds: visibleFieldIds, + recordWindows, + requestedRowCount: recordWindows.reduce((total, window) => total + window.limit!, 0), + recordsIncluded: shareMeta?.includeRecords !== false, + }) + ); + } + + const [start, end] = params.ranges; + const fields = availableFields.slice(start[0], end[0] + 1); + return ok( + ViewSelectionCopyPlan.create({ + type: undefined, + fields, + searchFieldIds: visibleFieldIds, + recordWindows: [{ offset: start[1], limit: end[1] - start[1] + 1 }], + requestedRowCount: end[1] - start[1] + 1, + recordsIncluded: shareMeta?.includeRecords !== false, + }) + ); + }.bind(this) + ); +} diff --git a/packages/v2/core/src/domain/table/methods/deleteView.spec.ts b/packages/v2/core/src/domain/table/methods/deleteView.spec.ts new file mode 100644 index 0000000000..ebc5acc25c --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/deleteView.spec.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { ViewDeleted } from '../events/ViewDeleted'; +import { FieldId } from '../fields/FieldId'; +import { FieldName } from '../fields/FieldName'; +import { LinkField } from '../fields/types/LinkField'; +import { LinkFieldConfig } from '../fields/types/LinkFieldConfig'; +import { TableRemoveViewSpec } from '../specs/TableRemoveViewSpec'; +import { Table } from '../Table'; +import { TableId } from '../TableId'; +import { TableName } from '../TableName'; +import { ViewId } from '../views/ViewId'; + +const baseId = BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap(); +const tableId = (seed: string) => TableId.create(`tbl${seed.repeat(16)}`)._unsafeUnwrap(); +const fieldId = (seed: string) => FieldId.create(`fld${seed.repeat(16)}`)._unsafeUnwrap(); +const viewId = (seed: string) => ViewId.create(`viw${seed.repeat(16)}`)._unsafeUnwrap(); + +const buildTable = (seed: string): Table => { + const builder = Table.builder() + .withId(tableId(seed)) + .withBaseId(baseId) + .withName(TableName.create(`Table ${seed}`)._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(fieldId(seed)) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .primary() + .done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +describe('Table.deleteView', () => { + it('removes an owned View and produces a mutation spec plus domain event', () => { + const table = buildTable('b'); + const created = table.createView({ type: 'kanban', name: 'Delivery' })._unsafeUnwrap(); + const targetViewId = created.view.id(); + const tableWithTwoViews = created.updateResult.table; + tableWithTwoViews.pullDomainEvents(); + + const result = tableWithTwoViews.deleteView(targetViewId)._unsafeUnwrap(); + + expect(result.deletedView.id().equals(targetViewId)).toBe(true); + expect(result.updateResult.mutateSpec).toBeInstanceOf(TableRemoveViewSpec); + expect(result.updateResult.table.views()).toHaveLength(1); + expect(result.updateResult.table.getView(targetViewId)._unsafeUnwrapErr().code).toBe( + 'view.not_found' + ); + const [event] = result.updateResult.table.pullDomainEvents(); + expect(event).toBeInstanceOf(ViewDeleted); + expect((event as ViewDeleted).viewId.equals(targetViewId)).toBe(true); + }); + + it('checks the last-View invariant before looking up the target View', () => { + const result = buildTable('c').deleteView(viewId('z')); + + expect(result._unsafeUnwrapErr()).toMatchObject({ + code: 'view.cannot_delete_last', + message: 'Cannot delete the last view in a table. A table must have at least one view.', + }); + }); + + it('rejects a missing View when another View keeps the aggregate valid', () => { + const first = buildTable('d'); + const table = first.createView({ type: 'gallery' })._unsafeUnwrap().updateResult.table; + + expect(table.deleteView(viewId('y'))._unsafeUnwrapErr().code).toBe('view.not_found'); + }); + + it('returns the cross-aggregate Link cleanup plan owned by the source Table', () => { + const foreignTable = buildTable('e'); + const symmetricFieldId = fieldId('f'); + const builder = Table.builder() + .withId(tableId('g')) + .withBaseId(baseId) + .withName(TableName.create('Source')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(fieldId('g')) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .primary() + .done(); + builder + .field() + .link() + .withId(fieldId('h')) + .withName(FieldName.create('Foreign')._unsafeUnwrap()) + .withConfig( + LinkFieldConfig.create({ + relationship: 'manyMany', + foreignTableId: foreignTable.id().toString(), + lookupFieldId: foreignTable.primaryFieldId().toString(), + symmetricFieldId: symmetricFieldId.toString(), + isOneWay: false, + })._unsafeUnwrap() + ) + .done(); + builder + .field() + .link() + .withId(fieldId('i')) + .withName(FieldName.create('One way')._unsafeUnwrap()) + .withConfig( + LinkFieldConfig.create({ + relationship: 'manyMany', + foreignTableId: foreignTable.id().toString(), + lookupFieldId: foreignTable.primaryFieldId().toString(), + isOneWay: true, + })._unsafeUnwrap() + ) + .done(); + builder.view().defaultGrid().done(); + const table = builder.build()._unsafeUnwrap().createView({ type: 'grid' })._unsafeUnwrap() + .updateResult.table; + const targetViewId = table.views()[1]!.id(); + + const result = table.deleteView(targetViewId)._unsafeUnwrap(); + + expect(result.linkDependencies).toHaveLength(1); + expect(result.linkDependencies[0]!.foreignTableId.equals(foreignTable.id())).toBe(true); + expect(result.linkDependencies[0]!.symmetricFieldId.equals(symmetricFieldId)).toBe(true); + }); +}); + +describe('Table.clearViewFilterDependencies', () => { + it('clears only matching Link filterByViewId values through a Table update spec', () => { + const referencedViewId = viewId('j'); + const matchingFieldId = fieldId('k'); + const untouchedFieldId = fieldId('l'); + const builder = Table.builder() + .withId(tableId('m')) + .withBaseId(baseId) + .withName(TableName.create('Foreign')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(fieldId('m')) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .primary() + .done(); + for (const [id, filter] of [ + [matchingFieldId, referencedViewId], + [untouchedFieldId, viewId('n')], + ] as const) { + builder + .field() + .link() + .withId(id) + .withName(FieldName.create(id.toString())._unsafeUnwrap()) + .withConfig( + LinkFieldConfig.create({ + relationship: 'manyMany', + foreignTableId: tableId('o').toString(), + lookupFieldId: fieldId('o').toString(), + filterByViewId: filter.toString(), + isOneWay: true, + })._unsafeUnwrap() + ) + .done(); + } + builder.view().defaultGrid().done(); + const table = builder.build()._unsafeUnwrap(); + + const result = table + .clearViewFilterDependencies(referencedViewId, [ + matchingFieldId, + untouchedFieldId, + fieldId('z'), + ]) + ._unsafeUnwrap(); + + expect(result).toBeDefined(); + const matching = result!.table + .getField((field) => field.id().equals(matchingFieldId)) + ._unsafeUnwrap(); + const untouched = result!.table + .getField((field) => field.id().equals(untouchedFieldId)) + ._unsafeUnwrap(); + expect(matching).toBeInstanceOf(LinkField); + expect((matching as LinkField).filterByViewId()).toBeNull(); + expect((untouched as LinkField).filterByViewId()?.equals(viewId('n'))).toBe(true); + }); + + it('returns no update when no candidate Link Field depends on the View', () => { + const table = buildTable('p'); + + expect( + table.clearViewFilterDependencies(viewId('q'), [table.primaryFieldId()])._unsafeUnwrap() + ).toBeUndefined(); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/deleteView.ts b/packages/v2/core/src/domain/table/methods/deleteView.ts new file mode 100644 index 0000000000..7e771247c9 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/deleteView.ts @@ -0,0 +1,84 @@ +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import type { FieldId } from '../fields/FieldId'; +import { LinkField } from '../fields/types/LinkField'; +import { LinkFieldConfig } from '../fields/types/LinkFieldConfig'; +import { UpdateLinkConfigSpec } from '../specs/field-updates/UpdateLinkConfigSpec'; +import type { Table } from '../Table'; +import type { TableId } from '../TableId'; +import type { TableUpdateResult } from '../TableMutator'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; + +export type ViewDeletionLinkDependency = { + readonly foreignTableId: TableId; + readonly symmetricFieldId: FieldId; +}; + +export type DeleteViewMethodResult = { + readonly deletedView: View; + readonly linkDependencies: ReadonlyArray; + readonly updateResult: TableUpdateResult; +}; + +export const deleteView = function ( + this: Table, + viewId: ViewId +): Result { + // Validate through the aggregate root first so the last-View invariant keeps + // precedence over target lookup, matching the legacy transaction. + const removalResult = this.removeView(viewId); + if (removalResult.isErr()) return err(removalResult.error); + + const deletedViewResult = this.getView(viewId); + if (deletedViewResult.isErr()) return err(deletedViewResult.error); + + const linkDependencies = this.getFields( + (field): field is LinkField => field instanceof LinkField + ).flatMap((field) => { + const symmetricFieldId = field.symmetricFieldId(); + return symmetricFieldId + ? [ + { + foreignTableId: field.foreignTableId(), + symmetricFieldId, + }, + ] + : []; + }); + + return this.update((mutator) => mutator.removeView(viewId)).map((updateResult) => ({ + deletedView: deletedViewResult.value, + linkDependencies, + updateResult, + })); +}; + +export const clearViewFilterDependencies = function ( + this: Table, + viewId: ViewId, + fieldIds: ReadonlyArray +): Result { + const specs: UpdateLinkConfigSpec[] = []; + + for (const fieldId of fieldIds) { + const fieldResult = this.getField((field) => field.id().equals(fieldId)); + if (fieldResult.isErr() || !(fieldResult.value instanceof LinkField)) continue; + + const field = fieldResult.value; + const filterByViewId = field.filterByViewId(); + if (!filterByViewId || !filterByViewId.equals(viewId)) continue; + + const nextConfigResult = field + .configDto() + .andThen((config) => LinkFieldConfig.create({ ...config, filterByViewId: null })); + if (nextConfigResult.isErr()) return err(nextConfigResult.error); + + specs.push(UpdateLinkConfigSpec.create(field.id(), field.config(), nextConfigResult.value)); + } + + if (specs.length === 0) return ok(undefined); + return this.update((mutator) => mutator.applySpecs(specs)).map((result) => result); +}; diff --git a/packages/v2/core/src/domain/table/methods/duplicateView.spec.ts b/packages/v2/core/src/domain/table/methods/duplicateView.spec.ts new file mode 100644 index 0000000000..460b50b003 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/duplicateView.spec.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { FieldName } from '../fields/FieldName'; +import { TableAddViewSpec } from '../specs/TableAddViewSpec'; +import { Table } from '../Table'; +import { TableId } from '../TableId'; +import { TableName } from '../TableName'; +import { ViewId } from '../views/ViewId'; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'b'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Planning')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Title')._unsafeUnwrap()).done(); + builder.field().attachment().withName(FieldName.create('Cover')._unsafeUnwrap()).done(); + builder.field().date().withName(FieldName.create('Start')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +describe('Table.duplicateView', () => { + it('duplicates the complete View state with new identity, unique name, and share id', () => { + const table = buildTable(); + const [titleField] = table.getFields(); + const sourceResult = table + .createView({ + type: 'grid', + name: 'Delivery 2', + description: 'Delivery details', + columnMeta: { + [titleField!.id().toString()]: { width: 280, hidden: true }, + }, + options: { rowHeight: 'extraTall', frozenColumnCount: 1 }, + filter: { + conjunction: 'and', + items: [{ fieldId: titleField!.id().toString(), operator: 'is', value: 'alpha' }], + }, + sourceFilter: { + conjunction: 'and', + filterSet: [ + { + fieldId: titleField!.id().toString(), + operator: '=', + isSymbol: true, + value: 'alpha', + }, + ], + }, + sort: [{ fieldId: titleField!.id().toString(), order: 'desc' }], + group: [{ fieldId: titleField!.id().toString(), order: 'asc' }], + manualSort: false, + isLocked: true, + enableShare: true, + shareId: `shr${'s'.repeat(16)}`, + shareMeta: { allowCopy: false, submit: { requireLogin: true } }, + }) + ._unsafeUnwrap(); + + const duplicated = sourceResult.updateResult.table + .duplicateView(sourceResult.view.id()) + ._unsafeUnwrap(); + + expect(duplicated.view.id().equals(sourceResult.view.id())).toBe(false); + expect(duplicated.view.name().toString()).toBe('Delivery 3'); + expect(duplicated.view.type().toString()).toBe('grid'); + expect(duplicated.view.description()).toBe('Delivery details'); + expect(duplicated.view.isLocked()).toBe(true); + expect(duplicated.view.enableShare()).toBe(true); + expect(duplicated.view.shareMeta()).toEqual({ + allowCopy: false, + submit: { requireLogin: true }, + }); + expect(duplicated.view.shareId()).toMatch(/^shr[0-9a-zA-Z]{16}$/); + expect(duplicated.view.shareId()).not.toBe(sourceResult.view.shareId()); + expect(duplicated.view.columnMeta()._unsafeUnwrap().toDto()).toEqual( + sourceResult.view.columnMeta()._unsafeUnwrap().toDto() + ); + expect(duplicated.view.queryDefaults()._unsafeUnwrap().toDto()).toEqual( + sourceResult.view.queryDefaults()._unsafeUnwrap().toDto() + ); + expect(duplicated.view.queryDefaults()._unsafeUnwrap().sourceFilter()).toEqual( + sourceResult.view.queryDefaults()._unsafeUnwrap().sourceFilter() + ); + expect(duplicated.view.options()).toEqual(sourceResult.view.options()); + expect(duplicated.updateResult.mutateSpec).toBeInstanceOf(TableAddViewSpec); + }); + + it.each(['grid', 'calendar', 'kanban', 'form', 'gallery'] as const)( + 'preserves the validated %s subtype options', + (type) => { + const table = buildTable(); + const optionsByType = { + grid: { rowHeight: 'short' as const }, + calendar: { startDateFieldId: null, endDateFieldId: null }, + kanban: { coverFieldId: null, isCoverFit: true }, + form: { coverUrl: '', submitLabel: '' }, + gallery: { coverFieldId: null, isFieldNameHidden: true }, + }; + const source = table + .createView({ type, name: type, options: optionsByType[type] }) + ._unsafeUnwrap(); + + const duplicated = source.updateResult.table + .duplicateView(source.view.id()) + ._unsafeUnwrap().view; + + expect(duplicated.type().toString()).toBe(type); + expect(duplicated.options()).toEqual(source.view.options()); + } + ); + + it('requires prepared Plugin integration data and replaces the installation options', () => { + const table = buildTable(); + const source = table + .createView({ + type: 'plugin', + name: 'Plugin', + options: { + pluginId: 'plg-source', + pluginInstallId: 'pli-source', + pluginLogo: 'source-logo', + }, + }) + ._unsafeUnwrap(); + + expect(source.updateResult.table.duplicateView(source.view.id()).isErr()).toBe(true); + + const duplicated = source.updateResult.table + .duplicateView(source.view.id(), { + pluginOptions: { + pluginId: 'plg-source', + pluginInstallId: 'pli-duplicate', + pluginLogo: 'fresh-logo', + }, + }) + ._unsafeUnwrap().view; + + expect(duplicated.options()).toEqual({ + pluginId: 'plg-source', + pluginInstallId: 'pli-duplicate', + pluginLogo: 'fresh-logo', + }); + }); + + it('rejects cross-aggregate and invalid Plugin override branches', () => { + const table = buildTable(); + const missing = ViewId.create(`viw${'z'.repeat(16)}`)._unsafeUnwrap(); + expect(table.duplicateView(missing).isErr()).toBe(true); + expect( + table.duplicateView(table.views()[0]!.id(), { + pluginOptions: { + pluginId: 'plg-source', + pluginInstallId: 'pli-duplicate', + pluginLogo: 'logo', + }, + }) + ).toSatisfy((result) => result.isErr() && result.error.code === 'validation.invalid'); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/duplicateView.ts b/packages/v2/core/src/domain/table/methods/duplicateView.ts new file mode 100644 index 0000000000..8d5d966733 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/duplicateView.ts @@ -0,0 +1,101 @@ +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import { generatePrefixedId } from '../../shared/IdGenerator'; +import type { Table } from '../Table'; +import type { TableUpdateResult } from '../TableMutator'; +import type { View } from '../views/View'; +import { ViewColumnMeta } from '../views/ViewColumnMeta'; +import { createView as createViewEntity } from '../views/ViewFactory'; +import type { ViewId } from '../views/ViewId'; +import { ViewId as ViewIdValue } from '../views/ViewId'; +import { ViewName } from '../views/ViewName'; +import { validateViewCreateOptions } from '../views/ViewOptions'; +import { ViewProperties } from '../views/ViewProperties'; +import { ViewQueryDefaults } from '../views/ViewQueryDefaults'; +import { uniqueViewName } from './createView'; + +export type DuplicateViewPluginOptions = { + readonly pluginId: string; + readonly pluginInstallId: string; + readonly pluginLogo: string; +}; + +export type DuplicateViewMethodOptions = { + readonly pluginOptions?: DuplicateViewPluginOptions; +}; + +export type DuplicateViewMethodResult = { + readonly view: View; + readonly updateResult: TableUpdateResult; +}; + +const shareIdPrefix = 'shr'; +const shareIdBodyLength = 16; + +export function duplicateView( + this: Table, + sourceViewId: ViewId, + input: DuplicateViewMethodOptions = {} +): Result { + const table = this; + return safeTry(function* () { + const source = yield* table.getView(sourceViewId); + const sourceType = source.type().toString(); + if (sourceType === 'plugin' && input.pluginOptions === undefined) { + return err( + domainError.validation({ + message: 'Plugin View duplication requires a prepared Plugin installation', + }) + ); + } + if (sourceType !== 'plugin' && input.pluginOptions !== undefined) { + return err( + domainError.validation({ + message: 'Plugin options can only be supplied when duplicating a Plugin View', + }) + ); + } + + const viewId = yield* ViewIdValue.generate(); + const name = yield* ViewName.create( + uniqueViewName( + source.name().toString(), + table.views().map((view) => view.name().toString()) + ) + ); + const propertiesValue = source.properties().toDto(); + const properties = yield* ViewProperties.create({ + ...propertiesValue, + ...(source.shareId() !== undefined + ? { shareId: generatePrefixedId(shareIdPrefix, shareIdBodyLength) } + : {}), + }); + const view = yield* createViewEntity({ + type: sourceType, + id: viewId, + name, + properties, + }); + + const sourceColumnMeta = yield* source.columnMeta(); + const columnMeta = yield* ViewColumnMeta.create(sourceColumnMeta.toDto()); + yield* view.setColumnMeta(columnMeta); + + const sourceQueryDefaults = yield* source.queryDefaults(); + const queryDefaults = yield* ViewQueryDefaults.create(sourceQueryDefaults.toDto(), { + sourceFilter: sourceQueryDefaults.sourceFilter(), + }); + yield* view.setQueryDefaults(queryDefaults); + + const options = yield* validateViewCreateOptions( + sourceType, + sourceType === 'plugin' ? input.pluginOptions : source.options() + ); + yield* view.setOptions(options); + + const updateResult = yield* table.update((mutator) => mutator.addView(view)); + return ok({ view, updateResult }); + }); +} diff --git a/packages/v2/core/src/domain/table/methods/getOrderedVisibleFieldIds.ts b/packages/v2/core/src/domain/table/methods/getOrderedVisibleFieldIds.ts index f2ae2dd24d..9561a2f923 100644 --- a/packages/v2/core/src/domain/table/methods/getOrderedVisibleFieldIds.ts +++ b/packages/v2/core/src/domain/table/methods/getOrderedVisibleFieldIds.ts @@ -8,21 +8,60 @@ import type { ViewColumnMetaEntry } from '../views/ViewColumnMeta'; /** * Check if a field is visible in the given view type based on its columnMeta entry. * - * - Grid view uses `hidden` property (default visible) - * - Form, Kanban, Gallery, Calendar, Plugin views use `visible` property + * - Grid and Plugin views use `hidden` (default visible) + * - Form requires `visible: true` + * - Kanban, Gallery, and Calendar default to visible and keep option-bound fields visible */ -function isFieldVisible(meta: ViewColumnMetaEntry | undefined, viewType: string): boolean { - // Form, Kanban, Gallery, Calendar, Plugin views use visible property - if (['form', 'kanban', 'gallery', 'calendar', 'plugin'].includes(viewType)) { +const asOptions = (value: unknown): Record => + value != null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; + +function isFieldVisible( + fieldId: string, + meta: ViewColumnMetaEntry | undefined, + viewType: string, + rawOptions: unknown +): boolean { + const options = asOptions(rawOptions); + + if (viewType === 'form') { return meta?.visible === true; } - // Grid view uses hidden property (default visible) + + if (viewType === 'kanban') { + return ( + fieldId === options.stackFieldId || + fieldId === options.coverFieldId || + meta?.visible !== false + ); + } + + if (viewType === 'gallery') { + return fieldId === options.coverFieldId || meta?.visible !== false; + } + + if (viewType === 'calendar') { + const colorConfig = asOptions(options.colorConfig); + const isColorField = colorConfig.type === 'field' && colorConfig.fieldId === fieldId; + return ( + isColorField || + fieldId === options.startDateFieldId || + fieldId === options.endDateFieldId || + fieldId === options.titleFieldId || + meta?.visible !== false + ); + } + + // Grid and Plugin views use hidden property (default visible). return meta?.hidden !== true; } export interface GetOrderedVisibleFieldIdsOptions { /** Custom field order. If provided, ignores view's columnMeta visibility. */ projection?: ReadonlyArray; + /** Preserve View ordering while including fields hidden by its column metadata. */ + includeHiddenFields?: boolean; } /** @@ -79,7 +118,10 @@ export function getOrderedVisibleFieldIds( .filter((field) => { const fieldIdStr = field.id().toString(); const meta = columnMeta[fieldIdStr]; - return isFieldVisible(meta, viewType); + return ( + options?.includeHiddenFields === true || + isFieldVisible(fieldIdStr, meta, viewType, view.options()) + ); }) .map((field) => { const fieldId = field.id(); diff --git a/packages/v2/core/src/domain/table/methods/records/recordBuilders.ts b/packages/v2/core/src/domain/table/methods/records/recordBuilders.ts index 4b9131e48a..0a3c4e8735 100644 --- a/packages/v2/core/src/domain/table/methods/records/recordBuilders.ts +++ b/packages/v2/core/src/domain/table/methods/records/recordBuilders.ts @@ -1,3 +1,4 @@ +import { sdkErrorI18nKeys } from '@teable/i18n-keys'; import { err, ok, safeTry } from 'neverthrow'; import type { Result } from 'neverthrow'; import { domainError, type DomainError } from '../../../shared/DomainError'; @@ -146,6 +147,27 @@ export function buildRecordWithSpec( } else { builder.set(field.field, defaultValue); } + } else if (!valuesAreValidated && field.field.notNull().toBoolean()) { + // v1 parity (T6520): reject missing notNull fields before any SQL runs. + // Relying on the database constraint means the INSERT has already + // consumed the auto-number sequence when it fails, leaving gaps — + // Postgres sequences do not roll back. Pre-validated internal flows + // (restore/duplicate) are exempt so legacy rows stay restorable. + return err( + domainError.validation({ + code: 'validation.field.not_null', + message: `Cannot create record: field "${field.field.name().toString()}" violates not-null constraint`, + details: { + fieldId: field.fieldId, + fieldName: field.field.name().toString(), + fieldType: field.field.type().toString(), + }, + localization: { + i18nKey: sdkErrorI18nKeys.custom.recordFieldValueNotNull, + context: { fieldName: field.field.name().toString() }, + }, + }) + ); } } diff --git a/packages/v2/core/src/domain/table/methods/refreshViewShareId.spec.ts b/packages/v2/core/src/domain/table/methods/refreshViewShareId.spec.ts new file mode 100644 index 0000000000..b7270ca352 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/refreshViewShareId.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { ViewShareIdRefreshed } from '../events/ViewShareIdRefreshed'; +import { FieldName } from '../fields/FieldName'; +import { TableUpdateViewShareIdSpec } from '../specs/TableUpdateViewShareIdSpec'; +import { Table } from '../Table'; +import { TableName } from '../TableName'; +import { ViewId } from '../views/ViewId'; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Shared Views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +const buildSharedTable = (shareId?: string): { table: Table; viewId: ViewId } => { + const created = buildTable() + .createView({ + type: 'grid', + name: 'Public View', + enableShare: true, + shareId, + }) + ._unsafeUnwrap(); + created.updateResult.table.pullDomainEvents(); + return { table: created.updateResult.table, viewId: created.view.id() }; +}; + +describe('Table.refreshViewShareId', () => { + it('rotates the credential and emits an irreversible focused event', () => { + const previousShareId = `shr${'s'.repeat(16)}`; + const { table, viewId } = buildSharedTable(previousShareId); + + const result = table.refreshViewShareId(viewId)._unsafeUnwrap(); + + expect(result.previousShareId).toBe(previousShareId); + expect(result.nextShareId).toMatch(/^shr[0-9a-zA-Z]{16}$/); + expect(result.nextShareId).not.toBe(previousShareId); + expect(result.view.shareId()).toBe(result.nextShareId); + expect(result.view.enableShare()).toBe(true); + expect(result.updateResult.mutateSpec).toBeInstanceOf(TableUpdateViewShareIdSpec); + const events = result.updateResult.table.pullDomainEvents(); + expect(events).toEqual([ + expect.objectContaining({ + previousShareId, + nextShareId: result.nextShareId, + viewId, + }), + ]); + expect(events.every((event) => event instanceof ViewShareIdRefreshed)).toBe(true); + }); + + it('mints a credential when sharing is enabled but the current ID is absent', () => { + const { table, viewId } = buildSharedTable(); + + const result = table.refreshViewShareId(viewId)._unsafeUnwrap(); + + expect(result.previousShareId).toBeUndefined(); + expect(result.nextShareId).toMatch(/^shr[0-9a-zA-Z]{16}$/); + }); + + it('rejects disabled sharing and a View outside the Table aggregate', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + + expect(table.refreshViewShareId(viewId)._unsafeUnwrapErr().code).toBe('validation.invalid'); + expect( + table + .refreshViewShareId(ViewId.create(`viw${'z'.repeat(16)}`)._unsafeUnwrap()) + ._unsafeUnwrapErr().code + ).toBe('view.not_found'); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/refreshViewShareId.ts b/packages/v2/core/src/domain/table/methods/refreshViewShareId.ts new file mode 100644 index 0000000000..3d4885d645 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/refreshViewShareId.ts @@ -0,0 +1,48 @@ +import { err, ok, safeTry, type Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import { generatePrefixedId } from '../../shared/IdGenerator'; +import type { Table } from '../Table'; +import type { TableUpdateResult } from '../TableMutator'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; + +const shareIdPrefix = 'shr'; +const shareIdBodyLength = 16; + +export type RefreshViewShareIdMethodResult = { + readonly view: View; + readonly previousShareId: string | undefined; + readonly nextShareId: string; + readonly updateResult: TableUpdateResult; +}; + +export function refreshViewShareId( + this: Table, + viewId: ViewId +): Result { + const table = this; + return safeTry(function* () { + const view = yield* table.getView(viewId); + if (view.enableShare() !== true) { + return err( + domainError.validation({ + message: `View ${viewId.toString()} has not been enabled share`, + }) + ); + } + + const previousShareId = view.shareId(); + const nextShareId = generatePrefixedId(shareIdPrefix, shareIdBodyLength); + const updateResult = yield* table.update((mutator) => + mutator.updateViewShareId(viewId, nextShareId) + ); + const nextView = yield* updateResult.table.getView(viewId); + return ok({ + view: nextView, + previousShareId, + nextShareId, + updateResult, + }); + }); +} diff --git a/packages/v2/core/src/domain/table/methods/rename.ts b/packages/v2/core/src/domain/table/methods/rename.ts index 98a1317d6f..cb248eca87 100644 --- a/packages/v2/core/src/domain/table/methods/rename.ts +++ b/packages/v2/core/src/domain/table/methods/rename.ts @@ -10,6 +10,7 @@ export function rename(this: Table, nextName: TableName): Result { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'b'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Planning')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Title')._unsafeUnwrap()).done(); + builder.field().attachment().withName(FieldName.create('Cover')._unsafeUnwrap()).done(); + builder.field().date().withName(FieldName.create('Start')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +describe('Table.renameView', () => { + it.each([ + ['grid', { rowHeight: 'extraTall', frozenColumnCount: 1 }], + ['calendar', { startDateFieldId: null, endDateFieldId: null }], + ['kanban', { coverFieldId: null, isCoverFit: true }], + ['form', { coverUrl: '', submitLabel: 'Send' }], + ['gallery', { coverFieldId: null, isFieldNameHidden: true }], + [ + 'plugin', + { + pluginId: 'plg-source', + pluginInstallId: 'pli-source', + pluginLogo: 'source-logo', + }, + ], + ] as const)('renames an owned %s View while preserving its complete state', (type, options) => { + const table = buildTable(); + const [titleField] = table.getFields(); + const created = table + .createView({ + type, + name: `${type} source`, + description: 'Delivery details', + columnMeta: { + [titleField!.id().toString()]: { width: 280, hidden: true }, + }, + options, + filter: { + conjunction: 'and', + items: [{ fieldId: titleField!.id().toString(), operator: 'is', value: 'alpha' }], + }, + sourceFilter: { + conjunction: 'and', + filterSet: [ + { + fieldId: titleField!.id().toString(), + operator: '=', + isSymbol: true, + value: 'alpha', + }, + ], + }, + sort: [{ fieldId: titleField!.id().toString(), order: 'desc' }], + group: [{ fieldId: titleField!.id().toString(), order: 'asc' }], + manualSort: false, + isLocked: true, + enableShare: true, + shareId: `shr${'s'.repeat(16)}`, + shareMeta: { allowCopy: false }, + }) + ._unsafeUnwrap(); + const source = created.view; + source + .setAuditMetadata( + ViewAuditMetadata.rehydrate({ + createdBy: 'usr-created', + createdTime: '2026-01-01T00:00:00.000Z', + lastModifiedBy: 'usr-modified', + lastModifiedTime: '2026-01-02T00:00:00.000Z', + })._unsafeUnwrap() + ) + ._unsafeUnwrap(); + const current = created.updateResult.table; + current.pullDomainEvents(); + + const result = current + .renameView(source.id(), ViewName.create(`${type} renamed`)._unsafeUnwrap()) + ._unsafeUnwrap(); + + expect(result.previousName.toString()).toBe(`${type} source`); + expect(result.nextName.toString()).toBe(`${type} renamed`); + expect(result.updateResult.mutateSpec).toBeInstanceOf(TableRenameViewSpec); + expect(result.view.id().equals(source.id())).toBe(true); + expect(result.view.type().toString()).toBe(type); + expect(result.view.description()).toBe(source.description()); + expect(result.view.properties()).toEqual(source.properties()); + expect(result.view.options()).toEqual(source.options()); + expect(result.view.columnMeta()._unsafeUnwrap().toDto()).toEqual( + source.columnMeta()._unsafeUnwrap().toDto() + ); + expect(result.view.queryDefaults()._unsafeUnwrap().toDto()).toEqual( + source.queryDefaults()._unsafeUnwrap().toDto() + ); + expect(result.view.queryDefaults()._unsafeUnwrap().sourceFilter()).toEqual( + source.queryDefaults()._unsafeUnwrap().sourceFilter() + ); + expect(result.view.auditMetadata()._unsafeUnwrap().toDto()).toEqual( + source.auditMetadata()._unsafeUnwrap().toDto() + ); + const [event] = result.updateResult.table.pullDomainEvents(); + expect(event).toBeInstanceOf(ViewRenamed); + expect(event).toMatchObject({ + previousName: result.previousName, + nextName: result.nextName, + viewId: source.id(), + }); + }); + + it('rejects a duplicate active View name inside the Table aggregate', () => { + const table = buildTable(); + const created = table.createView({ type: 'grid', name: 'Delivery' })._unsafeUnwrap() + .updateResult.table; + + const result = created.renameView( + created.views()[0]!.id(), + ViewName.create('Delivery')._unsafeUnwrap() + ); + + expect(result._unsafeUnwrapErr()).toMatchObject({ + code: 'conflict', + message: 'View names must be unique', + }); + }); + + it('allows empty and unchanged names because ViewName accepts both', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const empty = table.renameView(viewId, ViewName.create('')._unsafeUnwrap())._unsafeUnwrap(); + const unchanged = empty.updateResult.table + .renameView(viewId, ViewName.create('')._unsafeUnwrap()) + ._unsafeUnwrap(); + + expect(empty.view.name().toString()).toBe(''); + expect(unchanged.view.name().toString()).toBe(''); + }); + + it('rejects a View id outside the aggregate', () => { + const result = buildTable().renameView( + ViewId.create(`viw${'z'.repeat(16)}`)._unsafeUnwrap(), + ViewName.create('Missing')._unsafeUnwrap() + ); + + expect(result._unsafeUnwrapErr().code).toBe('view.not_found'); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/renameView.ts b/packages/v2/core/src/domain/table/methods/renameView.ts new file mode 100644 index 0000000000..cd35d39503 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/renameView.ts @@ -0,0 +1,37 @@ +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import type { Table } from '../Table'; +import type { TableUpdateResult } from '../TableMutator'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; +import type { ViewName } from '../views/ViewName'; + +export type RenameViewMethodResult = { + readonly previousName: ViewName; + readonly nextName: ViewName; + readonly view: View; + readonly updateResult: TableUpdateResult; +}; + +export function renameView( + this: Table, + viewId: ViewId, + nextName: ViewName +): Result { + const table = this; + return safeTry(function* () { + const previousView = yield* table.getView(viewId); + const previousName = previousView.name(); + const updateResult = yield* table.update((mutator) => mutator.renameView(viewId, nextName)); + const view = yield* updateResult.table.getView(viewId); + + return ok({ + previousName, + nextName, + view, + updateResult, + }); + }); +} diff --git a/packages/v2/core/src/domain/table/methods/resetButtonValue.ts b/packages/v2/core/src/domain/table/methods/resetButtonValue.ts new file mode 100644 index 0000000000..2e42052799 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/resetButtonValue.ts @@ -0,0 +1,51 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import type { FieldId } from '../fields/FieldId'; +import { ButtonField } from '../fields/types/ButtonField'; +import type { RecordId } from '../records/RecordId'; +import type { RecordUpdateResult } from '../records/RecordUpdateResult'; +import type { Table } from '../Table'; + +export type ResetButtonValueParams = { + readonly recordId: RecordId; + readonly fieldId: FieldId; +}; + +/** + * Builds the aggregate-authorized Button reset mutation. + * + * Reset eligibility belongs to the Button Field child owned by Table. The + * returned mutation remains an internal Button value spec and cannot be + * constructed through generic Record update input. + */ +export function resetButtonValue( + this: Table, + params: ResetButtonValueParams +): Result { + const field = this.getField((candidate) => candidate.id().equals(params.fieldId)); + if (field.isErr()) return err(field.error); + if (!(field.value instanceof ButtonField)) { + return err( + domainError.validation({ + code: 'button.field_type_invalid', + message: 'Field is not a Button field', + details: { fieldId: params.fieldId.toString() }, + }) + ); + } + if (field.value.resetCount()?.toBoolean() !== true) { + return err( + domainError.validation({ + code: 'button.reset_not_supported', + message: 'Button field does not support reset', + details: { + fieldId: params.fieldId.toString(), + i18nKey: 'httpErrors.field.button.notSupportReset', + }, + }) + ); + } + return this.setButtonValue({ ...params, value: null }); +} diff --git a/packages/v2/core/src/domain/table/methods/setButtonValue.ts b/packages/v2/core/src/domain/table/methods/setButtonValue.ts new file mode 100644 index 0000000000..4d98156815 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/setButtonValue.ts @@ -0,0 +1,66 @@ +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import type { FieldId } from '../fields/FieldId'; +import { ButtonField } from '../fields/types/ButtonField'; +import type { RecordId } from '../records/RecordId'; +import { RecordUpdateResult } from '../records/RecordUpdateResult'; +import { + type ButtonCellValue, + SetButtonValueSpec, +} from '../records/specs/values/SetButtonValueSpec'; +import { TableRecord } from '../records/TableRecord'; +import { CellValue } from '../records/values/CellValue'; +import type { Table } from '../Table'; + +export type SetButtonValueParams = { + readonly recordId: RecordId; + readonly fieldId: FieldId; + readonly value: ButtonCellValue | null; +}; + +/** + * Builds the internal Button value mutation used by undo/redo replay. + * + * The public record update path must continue to ignore Button values. Keeping + * this factory on Table makes the aggregate the only place that can authorize + * the internal persistence spec. + */ +export function setButtonValue( + this: Table, + params: SetButtonValueParams +): Result { + return safeTry( + function* (this: Table) { + const field = yield* this.getField((candidate) => candidate.id().equals(params.fieldId)); + if (!(field instanceof ButtonField)) { + return err( + domainError.validation({ + code: 'button.field_type_invalid', + message: 'Field is not a Button field', + details: { fieldId: params.fieldId.toString() }, + }) + ); + } + + const record = yield* TableRecord.create({ + id: params.recordId, + tableId: this.id(), + fieldValues: [], + }); + const spec = new SetButtonValueSpec( + params.fieldId, + CellValue.fromValidated(params.value) + ); + const updatedRecord = yield* spec.mutate(record); + return ok( + RecordUpdateResult.create( + updatedRecord, + spec, + new Map([[params.fieldId.toString(), params.fieldId.toString()]]) + ) + ); + }.bind(this) + ); +} diff --git a/packages/v2/core/src/domain/table/methods/updateProperties.spec.ts b/packages/v2/core/src/domain/table/methods/updateProperties.spec.ts new file mode 100644 index 0000000000..8696034355 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateProperties.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { TablePropertiesUpdated } from '../events/TablePropertiesUpdated'; +import { FieldName } from '../fields/FieldName'; +import { Table } from '../Table'; +import { TableName } from '../TableName'; + +const buildTable = () => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Projects')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +describe('Table.updateProperties', () => { + it('applies partial patches, clears values, and emits the aggregate event', () => { + const initial = buildTable(); + const firstUpdate = initial + .update((mutator) => + mutator.updateProperties({ description: 'Projects tracked by the team', icon: '📊' }) + ) + ._unsafeUnwrap(); + + expect(firstUpdate.table.description()).toBe('Projects tracked by the team'); + expect(firstUpdate.table.icon()).toBe('📊'); + expect(firstUpdate.table.pullDomainEvents()).toEqual([expect.any(TablePropertiesUpdated)]); + + const partialUpdate = firstUpdate.table + .update((mutator) => mutator.updateProperties({ description: 'Revised description' })) + ._unsafeUnwrap(); + expect(partialUpdate.table.description()).toBe('Revised description'); + expect(partialUpdate.table.icon()).toBe('📊'); + + const cleared = partialUpdate.table + .update((mutator) => mutator.updateProperties({ description: null, icon: null })) + ._unsafeUnwrap().table; + expect(cleared.description()).toBeUndefined(); + expect(cleared.icon()).toBeUndefined(); + }); + + it('does not emit an event when properties do not change', () => { + const table = buildTable(); + const updated = table + .update((mutator) => mutator.updateProperties({ description: null })) + ._unsafeUnwrap().table; + + expect(updated.pullDomainEvents()).toEqual([]); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/updateProperties.ts b/packages/v2/core/src/domain/table/methods/updateProperties.ts new file mode 100644 index 0000000000..40d2979019 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateProperties.ts @@ -0,0 +1,29 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import { Table } from '../Table'; +import type { ITableBuildProps } from '../TableBuilder'; +import type { TablePropertiesPatch } from '../TableProperties'; + +export function updateProperties( + this: Table, + patch: TablePropertiesPatch +): Result { + const propertiesResult = this.properties().withPatch(patch); + if (propertiesResult.isErr()) return err(propertiesResult.error); + + const props: ITableBuildProps = { + id: this.id(), + baseId: this.baseId(), + name: this.name(), + properties: propertiesResult.value, + fields: this.getFields(), + views: this.views(), + primaryFieldId: this.primaryFieldId(), + }; + const dbTableNameResult = this.dbTableName(); + if (dbTableNameResult.isOk()) props.dbTableName = dbTableNameResult.value; + + return Table.rehydrate(props); +} diff --git a/packages/v2/core/src/domain/table/methods/updateViewColumnMeta.ts b/packages/v2/core/src/domain/table/methods/updateViewColumnMeta.ts new file mode 100644 index 0000000000..494c9b61da --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewColumnMeta.ts @@ -0,0 +1,177 @@ +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import type { FieldId } from '../fields/FieldId'; +import type { TableViewColumnMetaUpdate } from '../specs/TableUpdateViewColumnMetaSpec'; +import type { Table } from '../Table'; +import type { TableUpdateResult } from '../TableMutator'; +import { + getDefaultViewColumnOrderByFieldId, + type ViewColumnMeta, + type ViewColumnMetaChange, + type ViewColumnMetaPatch, + type ViewColumnMetaValue, +} from '../views/ViewColumnMeta'; +import type { ViewId } from '../views/ViewId'; + +export type UpdateViewColumnMetaMethodResult = { + readonly viewId: ViewId; + readonly previousColumnMeta: ViewColumnMeta; + readonly nextColumnMeta: ViewColumnMeta; + readonly changes: ReadonlyArray; + readonly previousOptions?: unknown; + readonly nextOptions?: unknown; + readonly updateResult?: TableUpdateResult; +}; + +const isRecord = (value: unknown): value is Record => + value != null && typeof value === 'object' && !Array.isArray(value); + +const adjustFrozenFieldOptions = (params: { + viewType: string; + options: unknown; + previousColumnMeta: ViewColumnMetaValue; + nextColumnMeta: ViewColumnMetaValue; + patchedFieldIds: ReadonlySet; +}): unknown | undefined => { + if (params.viewType !== 'grid' || !isRecord(params.options)) return undefined; + const frozenFieldId = params.options.frozenFieldId; + if (typeof frozenFieldId !== 'string' || !params.patchedFieldIds.has(frozenFieldId)) { + return undefined; + } + + const oldOrder = params.previousColumnMeta[frozenFieldId]?.order; + const newOrder = params.nextColumnMeta[frozenFieldId]?.order; + if ( + typeof oldOrder !== 'number' || + typeof newOrder !== 'number' || + Object.is(oldOrder, newOrder) + ) { + return undefined; + } + + const originFieldIds = Object.keys(params.previousColumnMeta).sort((left, right) => { + const leftOrder = params.previousColumnMeta[left]?.order; + const rightOrder = params.previousColumnMeta[right]?.order; + return ( + (typeof leftOrder === 'number' ? leftOrder : 0) - + (typeof rightOrder === 'number' ? rightOrder : 0) + ); + }); + const frozenIndex = originFieldIds.indexOf(frozenFieldId); + const previousNeighborId = frozenIndex > 0 ? originFieldIds[frozenIndex - 1] : undefined; + const nextOptions = { ...params.options }; + if (previousNeighborId) { + nextOptions.frozenFieldId = previousNeighborId; + } else { + delete nextOptions.frozenFieldId; + } + return nextOptions; +}; + +const fieldNotFound = (table: Table, fieldIds: ReadonlyArray): DomainError => + domainError.notFound({ + code: 'field.not_found', + message: `Fields ${fieldIds.map((fieldId) => fieldId.toString()).join(', ')} not found in table ${table + .id() + .toString()}`, + }); + +export function updateViewColumnMeta( + this: Table, + viewId: ViewId, + patches: ReadonlyArray +): Result { + const table = this; + return safeTry(function* () { + const view = yield* table.getView(viewId); + const missingFieldIds = patches + .map(({ fieldId }) => fieldId) + .filter((fieldId) => !table.getFields().some((field) => field.id().equals(fieldId))); + if (missingFieldIds.length) { + return err(fieldNotFound(table, missingFieldIds)); + } + + const hidesPrimaryField = patches.some( + ({ fieldId, columnMeta }) => + fieldId.equals(table.primaryFieldId()) && columnMeta.hidden === true + ); + const viewType = view.type().toString(); + if (hidesPrimaryField && viewType !== 'calendar' && viewType !== 'form') { + return err( + domainError.validation({ + code: 'view.primary_field_cannot_be_hidden', + message: `Primary field can not be hidden for view type ${viewType}`, + }) + ); + } + + const previousColumnMeta = yield* view.columnMeta(); + if (patches.length === 0) { + return ok({ + viewId, + previousColumnMeta, + nextColumnMeta: previousColumnMeta, + changes: [], + }); + } + + const previousColumnMetaValue = previousColumnMeta.toDto(); + const defaultOrderByFieldId = getDefaultViewColumnOrderByFieldId( + table.getFields(), + table.primaryFieldId() + ); + const patchesWithOrders = patches.map((patch) => { + const fieldId = patch.fieldId.toString(); + const patchOrder = patch.columnMeta.order; + const previousOrder = previousColumnMetaValue[fieldId]?.order; + return { + ...patch, + columnMeta: { + ...patch.columnMeta, + order: + typeof patchOrder === 'number' + ? patchOrder + : typeof previousOrder === 'number' + ? previousOrder + : defaultOrderByFieldId.get(fieldId)!, + }, + }; + }); + const patchResult = yield* previousColumnMeta.applyPatches(patchesWithOrders); + const previousOptions = view.options(); + const nextOptions = adjustFrozenFieldOptions({ + viewType, + options: previousOptions, + previousColumnMeta: previousColumnMeta.toDto(), + nextColumnMeta: patchResult.columnMeta.toDto(), + patchedFieldIds: new Set(patches.map(({ fieldId }) => fieldId.toString())), + }); + if (patchResult.changes.length === 0 && nextOptions === undefined) { + return ok({ + viewId, + previousColumnMeta, + nextColumnMeta: patchResult.columnMeta, + changes: [], + }); + } + const update: TableViewColumnMetaUpdate = { + viewId, + fieldId: patches[0]!.fieldId, + columnMeta: patchResult.columnMeta, + changes: patchResult.changes, + ...(nextOptions !== undefined ? { previousOptions, nextOptions, optionsChanged: true } : {}), + }; + const updateResult = yield* table.update((mutator) => mutator.updateViewColumnMeta(update)); + + return ok({ + viewId, + previousColumnMeta, + nextColumnMeta: patchResult.columnMeta, + changes: patchResult.changes, + ...(nextOptions !== undefined ? { previousOptions, nextOptions } : {}), + updateResult, + }); + }); +} diff --git a/packages/v2/core/src/domain/table/methods/updateViewDescription.spec.ts b/packages/v2/core/src/domain/table/methods/updateViewDescription.spec.ts new file mode 100644 index 0000000000..930e753095 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewDescription.spec.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { ViewDescriptionUpdated } from '../events/ViewDescriptionUpdated'; +import { FieldName } from '../fields/FieldName'; +import { TableUpdateViewDescriptionSpec } from '../specs/TableUpdateViewDescriptionSpec'; +import { Table } from '../Table'; +import { TableId } from '../TableId'; +import { TableName } from '../TableName'; +import { ViewAuditMetadata } from '../views/ViewAuditMetadata'; +import { ViewId } from '../views/ViewId'; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'b'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Planning')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Title')._unsafeUnwrap()).done(); + builder.field().attachment().withName(FieldName.create('Cover')._unsafeUnwrap()).done(); + builder.field().date().withName(FieldName.create('Start')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +describe('Table.updateViewDescription', () => { + it.each([ + ['grid', { rowHeight: 'extraTall', frozenColumnCount: 1 }], + ['calendar', { startDateFieldId: null, endDateFieldId: null }], + ['kanban', { coverFieldId: null, isCoverFit: true }], + ['form', { coverUrl: '', submitLabel: 'Send' }], + ['gallery', { coverFieldId: null, isFieldNameHidden: true }], + [ + 'plugin', + { + pluginId: 'plg-source', + pluginInstallId: 'pli-source', + pluginLogo: 'source-logo', + }, + ], + ] as const)('updates an owned %s View while preserving all other state', (type, options) => { + const table = buildTable(); + const [titleField] = table.getFields(); + const created = table + .createView({ + type, + name: `${type} source`, + description: 'Before', + columnMeta: { + [titleField!.id().toString()]: { width: 280, hidden: true }, + }, + options, + filter: { + conjunction: 'and', + items: [{ fieldId: titleField!.id().toString(), operator: 'is', value: 'alpha' }], + }, + sourceFilter: { + conjunction: 'and', + filterSet: [ + { + fieldId: titleField!.id().toString(), + operator: '=', + isSymbol: true, + value: 'alpha', + }, + ], + }, + sort: [{ fieldId: titleField!.id().toString(), order: 'desc' }], + group: [{ fieldId: titleField!.id().toString(), order: 'asc' }], + manualSort: false, + isLocked: true, + enableShare: true, + shareId: `shr${'s'.repeat(16)}`, + shareMeta: { allowCopy: false }, + }) + ._unsafeUnwrap(); + const source = created.view; + source + .setAuditMetadata( + ViewAuditMetadata.rehydrate({ + createdBy: 'usr-created', + createdTime: '2026-01-01T00:00:00.000Z', + lastModifiedBy: 'usr-modified', + lastModifiedTime: '2026-01-02T00:00:00.000Z', + })._unsafeUnwrap() + ) + ._unsafeUnwrap(); + const current = created.updateResult.table; + current.pullDomainEvents(); + + const result = current.updateViewDescription(source.id(), `${type} after`)._unsafeUnwrap(); + + expect(result.previousDescription).toBe('Before'); + expect(result.nextDescription).toBe(`${type} after`); + expect(result.updateResult.mutateSpec).toBeInstanceOf(TableUpdateViewDescriptionSpec); + expect(result.view.id().equals(source.id())).toBe(true); + expect(result.view.name().equals(source.name())).toBe(true); + expect(result.view.type().toString()).toBe(type); + expect(result.view.description()).toBe(`${type} after`); + expect(result.view.properties().toDto()).toEqual({ + ...source.properties().toDto(), + description: `${type} after`, + }); + expect(result.view.options()).toEqual(source.options()); + expect(result.view.columnMeta()._unsafeUnwrap().toDto()).toEqual( + source.columnMeta()._unsafeUnwrap().toDto() + ); + expect(result.view.queryDefaults()._unsafeUnwrap().toDto()).toEqual( + source.queryDefaults()._unsafeUnwrap().toDto() + ); + expect(result.view.queryDefaults()._unsafeUnwrap().sourceFilter()).toEqual( + source.queryDefaults()._unsafeUnwrap().sourceFilter() + ); + expect(result.view.auditMetadata()._unsafeUnwrap().toDto()).toEqual( + source.auditMetadata()._unsafeUnwrap().toDto() + ); + const [event] = result.updateResult.table.pullDomainEvents(); + expect(event).toBeInstanceOf(ViewDescriptionUpdated); + expect(event).toMatchObject({ + previousDescription: 'Before', + nextDescription: `${type} after`, + viewId: source.id(), + }); + }); + + it('allows empty and unchanged descriptions and preserves their exact values', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const empty = table.updateViewDescription(viewId, '')._unsafeUnwrap(); + const unchanged = empty.updateResult.table.updateViewDescription(viewId, '')._unsafeUnwrap(); + + expect(empty.previousDescription).toBeUndefined(); + expect(empty.view.description()).toBe(''); + expect(unchanged.previousDescription).toBe(''); + expect(unchanged.view.description()).toBe(''); + }); + + it('rejects a View id outside the aggregate', () => { + const result = buildTable().updateViewDescription( + ViewId.create(`viw${'z'.repeat(16)}`)._unsafeUnwrap(), + 'Missing' + ); + + expect(result._unsafeUnwrapErr().code).toBe('view.not_found'); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/updateViewDescription.ts b/packages/v2/core/src/domain/table/methods/updateViewDescription.ts new file mode 100644 index 0000000000..6e2fb173fa --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewDescription.ts @@ -0,0 +1,38 @@ +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import type { Table } from '../Table'; +import type { TableUpdateResult } from '../TableMutator'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; + +export type UpdateViewDescriptionMethodResult = { + readonly previousDescription: string | undefined; + readonly nextDescription: string; + readonly view: View; + readonly updateResult: TableUpdateResult; +}; + +export function updateViewDescription( + this: Table, + viewId: ViewId, + nextDescription: string +): Result { + const table = this; + return safeTry(function* () { + const previousView = yield* table.getView(viewId); + const previousDescription = previousView.description(); + const updateResult = yield* table.update((mutator) => + mutator.updateViewDescription(viewId, nextDescription) + ); + const view = yield* updateResult.table.getView(viewId); + + return ok({ + previousDescription, + nextDescription, + view, + updateResult, + }); + }); +} diff --git a/packages/v2/core/src/domain/table/methods/updateViewFilter.spec.ts b/packages/v2/core/src/domain/table/methods/updateViewFilter.spec.ts new file mode 100644 index 0000000000..3d0822efb8 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewFilter.spec.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { ViewFilterUpdated } from '../events/ViewFilterUpdated'; +import { FieldName } from '../fields/FieldName'; +import { TableUpdateViewQueryDefaultsSpec } from '../specs/TableUpdateViewQueryDefaultsSpec'; +import { Table } from '../Table'; +import { TableId } from '../TableId'; +import { TableName } from '../TableName'; +import { ViewId } from '../views/ViewId'; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'b'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Filter views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.field().number().withName(FieldName.create('Amount')._unsafeUnwrap()).done(); + builder.field().date().withName(FieldName.create('Due')._unsafeUnwrap()).done(); + builder.field().button().withName(FieldName.create('Action')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +describe('Table.updateViewFilter', () => { + it('preserves a rich public filter and returns a focused aggregate spec and event', () => { + const table = buildTable(); + const [name, amount, due] = table.getFields(); + const viewId = table.views()[0]!.id(); + const filter = { + conjunction: 'and' as const, + filterSet: [ + { + fieldId: name!.id().toString(), + operator: '=' as const, + isSymbol: true as const, + value: 'alpha', + }, + { + conjunction: 'or' as const, + filterSet: [ + { + fieldId: amount!.id().toString(), + operator: 'isGreater' as const, + value: 3, + }, + { + fieldId: name!.id().toString(), + operator: 'is' as const, + value: { + type: 'field' as const, + fieldId: name!.id().toString(), + tableId: table.id().toString(), + }, + }, + ], + }, + { + fieldId: due!.id().toString(), + operator: 'is' as const, + value: { + mode: 'dateRange' as const, + exactDate: '2026-07-01T00:00:00.000Z', + exactDateEnd: '2026-07-31T23:59:59.000Z', + timeZone: 'UTC', + }, + }, + ], + }; + + const result = table.updateViewFilter(viewId, filter)._unsafeUnwrap(); + + expect(result.nextQueryDefaults.sourceFilter()).toEqual(filter); + expect(result.updateResult?.mutateSpec).toBeInstanceOf(TableUpdateViewQueryDefaultsSpec); + const [event] = result.updateResult?.table.pullDomainEvents() ?? []; + expect(event).toBeInstanceOf(ViewFilterUpdated); + expect(event).toMatchObject({ + viewId, + previousFilter: undefined, + nextFilter: filter, + }); + expect(result.updateResult?.table).toBeDefined(); + }); + + it('supports empty, incomplete, null, and identical no-op branches', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const empty = table + .updateViewFilter(viewId, { conjunction: 'and', filterSet: [] }) + ._unsafeUnwrap(); + const incompleteFilter = { + conjunction: 'and' as const, + filterSet: [ + { + fieldId: table.getFields()[0]!.id().toString(), + operator: 'isNot' as const, + value: null, + }, + ], + }; + const incomplete = empty + .updateResult!.table.updateViewFilter(viewId, incompleteFilter) + ._unsafeUnwrap(); + expect(incomplete.nextQueryDefaults.sourceFilter()).toEqual(incompleteFilter); + expect( + incomplete.updateResult!.table.updateViewFilter(viewId, incompleteFilter)._unsafeUnwrap() + .updateResult + ).toBeUndefined(); + const cleared = incomplete.updateResult!.table.updateViewFilter(viewId, null)._unsafeUnwrap(); + expect(cleared.nextQueryDefaults.sourceFilter()).toBeNull(); + expect(cleared.nextQueryDefaults.filter()).toBeNull(); + }); + + it('rejects missing children, unsupported fields, and incompatible operators', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + expect( + table + .updateViewFilter(ViewId.create(`viw${'z'.repeat(16)}`)._unsafeUnwrap(), null) + ._unsafeUnwrapErr().code + ).toBe('view.not_found'); + expect( + table + .updateViewFilter(viewId, { + conjunction: 'and', + filterSet: [{ fieldId: `fld${'z'.repeat(16)}`, operator: 'is', value: 'missing' }], + }) + ._unsafeUnwrapErr().code + ).toBe('field.not_found'); + expect( + table + .updateViewFilter(viewId, { + conjunction: 'and', + filterSet: [ + { + fieldId: table.getFields()[3]!.id().toString(), + operator: 'isEmpty', + value: null, + }, + ], + }) + ._unsafeUnwrapErr().code + ).toBe('view.filter_unsupported_field_type'); + expect( + table + .updateViewFilter(viewId, { + conjunction: 'and', + filterSet: [ + { + fieldId: table.getFields()[1]!.id().toString(), + operator: 'contains', + value: 'three', + }, + ], + }) + .isErr() + ).toBe(true); + }); + + it('rejects field references that claim another Table', () => { + const table = buildTable(); + const fieldId = table.getFields()[0]!.id().toString(); + const result = table.updateViewFilter(table.views()[0]!.id(), { + conjunction: 'and', + filterSet: [ + { + fieldId, + operator: 'is', + value: { type: 'field', fieldId, tableId: `tbl${'z'.repeat(16)}` }, + }, + ], + }); + expect(result._unsafeUnwrapErr().code).toBe('view.filter_field_table_mismatch'); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/updateViewFilter.ts b/packages/v2/core/src/domain/table/methods/updateViewFilter.ts new file mode 100644 index 0000000000..6bde5cc0ef --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewFilter.ts @@ -0,0 +1,188 @@ +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { + isRecordFilterCondition, + isRecordFilterDateValue, + isRecordFilterFieldReferenceValue, + isRecordFilterGroup, + isRecordFilterNot, + type RecordFilterNode, + type RecordFilterValue, +} from '../../../queries/RecordFilterDto'; +import { domainError, type DomainError } from '../../shared/DomainError'; +import type { Field } from '../fields/Field'; +import { FieldId } from '../fields/FieldId'; +import { FieldType } from '../fields/FieldType'; +import { + RecordConditionDateValue, + RecordConditionFieldReferenceValue, + RecordConditionLiteralListValue, + RecordConditionLiteralValue, + type RecordConditionValue, +} from '../records/specs/RecordConditionValues'; +import type { Table } from '../Table'; +import { TableId } from '../TableId'; +import type { TableUpdateResult } from '../TableMutator'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; +import { ViewQueryDefaults } from '../views/ViewQueryDefaults'; +import { ViewSourceFilter, type ViewSourceFilterDTO } from '../views/ViewSourceFilter'; + +export type UpdateViewFilterMethodResult = { + readonly view: View; + readonly previousQueryDefaults: ViewQueryDefaults; + readonly nextQueryDefaults: ViewQueryDefaults; + readonly updateResult?: TableUpdateResult; +}; + +const filterFieldNotFound = (table: Table, fieldId: string): DomainError => + domainError.notFound({ + code: 'field.not_found', + message: `Filter field ${fieldId} not found in table ${table.id().toString()}`, + }); + +const resolveOwnedField = (table: Table, rawFieldId: string): Result => + FieldId.create(rawFieldId).andThen((fieldId) => + table + .getField((candidate) => candidate.id().equals(fieldId)) + .mapErr(() => filterFieldNotFound(table, rawFieldId)) + ); + +const validateSourceReferences = ( + table: Table, + sourceFilter: ViewSourceFilterDTO | null +): Result => { + if (sourceFilter == null) return ok(undefined); + + const visit = (group: ViewSourceFilterDTO): Result => { + for (const item of group.filterSet) { + if ('filterSet' in item) { + const nested = visit(item); + if (nested.isErr()) return nested; + continue; + } + const fieldResult = resolveOwnedField(table, item.fieldId); + if (fieldResult.isErr()) return err(fieldResult.error); + if (fieldResult.value.type().equals(FieldType.button())) { + return err( + domainError.validation({ + code: 'view.filter_unsupported_field_type', + message: `Filter field ${item.fieldId} has unsupported Button type`, + }) + ); + } + const value = item.value; + if ( + value == null || + typeof value !== 'object' || + Array.isArray(value) || + !('type' in value) || + value.type !== 'field' + ) { + continue; + } + const reference = resolveOwnedField(table, value.fieldId); + if (reference.isErr()) return err(reference.error); + if (value.tableId !== undefined) { + const tableId = TableId.create(value.tableId); + if (tableId.isErr()) return err(tableId.error); + if (!tableId.value.equals(table.id())) { + return err( + domainError.validation({ + code: 'view.filter_field_table_mismatch', + message: `Filter field reference ${value.fieldId} belongs to another table`, + }) + ); + } + } + } + return ok(undefined); + }; + return visit(sourceFilter); +}; + +const toConditionValue = ( + table: Table, + rawValue: RecordFilterValue +): Result => { + if (rawValue === null) return ok(undefined); + if (isRecordFilterFieldReferenceValue(rawValue)) { + return resolveOwnedField(table, rawValue.fieldId).andThen((field) => { + if (rawValue.tableId !== undefined) { + const tableId = TableId.create(rawValue.tableId); + if (tableId.isErr()) return err(tableId.error); + if (!tableId.value.equals(table.id())) { + return err( + domainError.validation({ + code: 'view.filter_field_table_mismatch', + message: `Filter field reference ${rawValue.fieldId} belongs to another table`, + }) + ); + } + } + return RecordConditionFieldReferenceValue.create(field); + }); + } + if (isRecordFilterDateValue(rawValue)) return RecordConditionDateValue.create(rawValue); + if (Array.isArray(rawValue)) return RecordConditionLiteralListValue.create(rawValue); + return RecordConditionLiteralValue.create(rawValue); +}; + +const validateCanonicalNode = (table: Table, node: RecordFilterNode): Result => { + if (isRecordFilterNot(node)) return validateCanonicalNode(table, node.not); + if (isRecordFilterGroup(node)) { + for (const item of node.items) { + const result = validateCanonicalNode(table, item); + if (result.isErr()) return result; + } + return ok(undefined); + } + if (!isRecordFilterCondition(node)) { + return err(domainError.validation({ message: 'Invalid View filter condition' })); + } + return resolveOwnedField(table, node.fieldId).andThen((field) => { + if (node.value === null && (node.operator === 'is' || node.operator === 'isNot')) { + return ok(undefined); + } + return toConditionValue(table, node.value).andThen((value) => + field + .spec() + .create({ operator: node.operator, value }) + .map(() => undefined) + ); + }); +}; + +export function updateViewFilter( + this: Table, + viewId: ViewId, + rawFilter: unknown +): Result { + const table = this; + return safeTry(function* () { + const view = yield* table.getView(viewId); + const sourceFilter = yield* ViewSourceFilter.create(rawFilter); + yield* validateSourceReferences(table, sourceFilter.toDto()); + const canonicalFilter = sourceFilter.toCanonical(); + if (canonicalFilter !== null) yield* validateCanonicalNode(table, canonicalFilter); + + const previousQueryDefaults = yield* view.queryDefaults(); + const nextQueryDefaults = yield* ViewQueryDefaults.rehydrate( + { ...previousQueryDefaults.toDto(), filter: canonicalFilter }, + { sourceFilter: sourceFilter.toDto() } + ); + if (previousQueryDefaults.equals(nextQueryDefaults)) { + return ok({ view, previousQueryDefaults, nextQueryDefaults }); + } + const updateResult = yield* table.update((mutator) => + mutator.updateViewQueryDefaults({ + viewId, + previousQueryDefaults, + queryDefaults: nextQueryDefaults, + }) + ); + const nextView = yield* updateResult.table.getView(viewId); + return ok({ view: nextView, previousQueryDefaults, nextQueryDefaults, updateResult }); + }); +} diff --git a/packages/v2/core/src/domain/table/methods/updateViewGroup.spec.ts b/packages/v2/core/src/domain/table/methods/updateViewGroup.spec.ts new file mode 100644 index 0000000000..bd91c31472 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewGroup.spec.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { ViewGroupUpdated } from '../events/ViewGroupUpdated'; +import { FieldName } from '../fields/FieldName'; +import { TableUpdateViewQueryDefaultsSpec } from '../specs/TableUpdateViewQueryDefaultsSpec'; +import { Table } from '../Table'; +import { TableId } from '../TableId'; +import { TableName } from '../TableName'; +import { ViewId } from '../views/ViewId'; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'b'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Group views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.field().number().withName(FieldName.create('Amount')._unsafeUnwrap()).done(); + builder.field().button().withName(FieldName.create('Action')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +describe('Table.updateViewGroup', () => { + it('updates multiple group items through one focused aggregate spec and event', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const group = [ + { fieldId: table.getFields()[0]!.id().toString(), order: 'asc' as const }, + { fieldId: table.getFields()[1]!.id().toString(), order: 'desc' as const }, + ]; + + const result = table.updateViewGroup(viewId, group)._unsafeUnwrap(); + + expect(result.previousGroup).toBeNull(); + expect(result.nextGroup).toEqual(group); + expect(result.nextQueryDefaults.group()).toEqual(group); + expect(result.updateResult?.mutateSpec).toBeInstanceOf(TableUpdateViewQueryDefaultsSpec); + const [event] = result.updateResult?.table.pullDomainEvents() ?? []; + expect(event).toBeInstanceOf(ViewGroupUpdated); + expect(event).toMatchObject({ viewId, previousGroup: null, nextGroup: group }); + }); + + it('preserves empty, identical no-op, and clear branches without changing other defaults', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const sorted = table + .updateViewSort(viewId, { + sortObjs: [{ fieldId: table.getFields()[1]!.id().toString(), order: 'desc' }], + }) + ._unsafeUnwrap().updateResult!.table; + const empty = sorted.updateViewGroup(viewId, [])._unsafeUnwrap(); + expect(empty.nextQueryDefaults.group()).toEqual([]); + expect(empty.nextQueryDefaults.sort()).toEqual([ + { fieldId: table.getFields()[1]!.id().toString(), order: 'desc' }, + ]); + + const current = empty.updateResult!.table; + expect(current.updateViewGroup(viewId, [])._unsafeUnwrap().updateResult).toBeUndefined(); + + const cleared = current.updateViewGroup(viewId, null)._unsafeUnwrap(); + expect(cleared.nextQueryDefaults.group()).toBeUndefined(); + expect(cleared.nextGroup).toBeNull(); + expect(cleared.nextQueryDefaults.sort()).toEqual([ + { fieldId: table.getFields()[1]!.id().toString(), order: 'desc' }, + ]); + }); + + it('preserves duplicate group items accepted by the public contract', () => { + const table = buildTable(); + const fieldId = table.getFields()[0]!.id().toString(); + const group = [ + { fieldId, order: 'asc' as const }, + { fieldId, order: 'desc' as const }, + ]; + + expect(table.updateViewGroup(table.views()[0]!.id(), group)._unsafeUnwrap().nextGroup).toEqual( + group + ); + }); + + it('rejects missing aggregate children, missing fields, Button fields, and invalid input', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + expect( + table + .updateViewGroup(ViewId.create(`viw${'z'.repeat(16)}`)._unsafeUnwrap(), null) + ._unsafeUnwrapErr().code + ).toBe('view.not_found'); + expect( + table + .updateViewGroup(viewId, [{ fieldId: `fld${'z'.repeat(16)}`, order: 'asc' }]) + ._unsafeUnwrapErr().code + ).toBe('field.not_found'); + expect( + table + .updateViewGroup(viewId, [ + { fieldId: table.getFields()[2]!.id().toString(), order: 'desc' }, + ]) + ._unsafeUnwrapErr().code + ).toBe('view.group_unsupported_field_type'); + expect(table.updateViewGroup(viewId, [{ fieldId: 'bad', order: 'up' }]).isErr()).toBe(true); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/updateViewGroup.ts b/packages/v2/core/src/domain/table/methods/updateViewGroup.ts new file mode 100644 index 0000000000..83a60f066f --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewGroup.ts @@ -0,0 +1,101 @@ +import { err, ok, safeTry, type Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import { FieldId } from '../fields/FieldId'; +import { FieldType } from '../fields/FieldType'; +import type { Table } from '../Table'; +import type { TableUpdateResult } from '../TableMutator'; +import type { View } from '../views/View'; +import { ViewGroup, viewGroupDtoFromQueryDefaults, type ViewGroupDTO } from '../views/ViewGroup'; +import type { ViewId } from '../views/ViewId'; +import { ViewQueryDefaults } from '../views/ViewQueryDefaults'; + +export type UpdateViewGroupMethodResult = { + readonly view: View; + readonly previousGroup: ViewGroupDTO; + readonly nextGroup: ViewGroupDTO; + readonly previousQueryDefaults: ViewQueryDefaults; + readonly nextQueryDefaults: ViewQueryDefaults; + readonly updateResult?: TableUpdateResult; +}; + +const validateGroupFields = (table: Table, group: ViewGroupDTO): Result => { + if (group === null) return ok(undefined); + + for (const item of group) { + const fieldId = FieldId.create(item.fieldId); + if (fieldId.isErr()) return err(fieldId.error); + const field = table.getField((candidate) => candidate.id().equals(fieldId.value)); + if (field.isErr()) { + return err( + domainError.notFound({ + code: 'field.not_found', + message: `Group field ${item.fieldId} not found in table ${table.id().toString()}`, + }) + ); + } + if (field.value.type().equals(FieldType.button())) { + return err( + domainError.validation({ + code: 'view.group_unsupported_field_type', + message: `Group field ${item.fieldId} has unsupported Button type`, + }) + ); + } + } + return ok(undefined); +}; + +export function updateViewGroup( + this: Table, + viewId: ViewId, + rawGroup: unknown +): Result { + const table = this; + return safeTry(function* () { + const view = yield* table.getView(viewId); + const group = yield* ViewGroup.create(rawGroup); + const nextGroup = group.toDto(); + yield* validateGroupFields(table, nextGroup); + + const previousQueryDefaults = yield* view.queryDefaults(); + const previousGroup = viewGroupDtoFromQueryDefaults(previousQueryDefaults); + const { group: _previousGroup, ...preservedDefaults } = previousQueryDefaults.toDto(); + const nextQueryDefaults = yield* ViewQueryDefaults.rehydrate( + nextGroup === null + ? preservedDefaults + : { + ...preservedDefaults, + group: nextGroup, + }, + { sourceFilter: previousQueryDefaults.sourceFilter() } + ); + + if (previousQueryDefaults.equals(nextQueryDefaults)) { + return ok({ + view, + previousGroup, + nextGroup, + previousQueryDefaults, + nextQueryDefaults, + }); + } + + const updateResult = yield* table.update((mutator) => + mutator.updateViewQueryDefaults({ + viewId, + previousQueryDefaults, + queryDefaults: nextQueryDefaults, + }) + ); + const nextView = yield* updateResult.table.getView(viewId); + return ok({ + view: nextView, + previousGroup, + nextGroup, + previousQueryDefaults, + nextQueryDefaults, + updateResult, + }); + }); +} diff --git a/packages/v2/core/src/domain/table/methods/updateViewLocked.spec.ts b/packages/v2/core/src/domain/table/methods/updateViewLocked.spec.ts new file mode 100644 index 0000000000..ee8ebcbab1 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewLocked.spec.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { ViewLockedUpdated } from '../events/ViewLockedUpdated'; +import { FieldName } from '../fields/FieldName'; +import { TableUpdateViewLockedSpec } from '../specs/TableUpdateViewLockedSpec'; +import { Table } from '../Table'; +import { TableId } from '../TableId'; +import { TableName } from '../TableName'; +import { ViewAuditMetadata } from '../views/ViewAuditMetadata'; +import { ViewId } from '../views/ViewId'; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'b'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Planning')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Title')._unsafeUnwrap()).done(); + builder.field().attachment().withName(FieldName.create('Cover')._unsafeUnwrap()).done(); + builder.field().date().withName(FieldName.create('Start')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +describe('Table.updateViewLocked', () => { + it.each([ + ['grid', { rowHeight: 'extraTall', frozenColumnCount: 1 }], + ['calendar', { startDateFieldId: null, endDateFieldId: null }], + ['kanban', { coverFieldId: null, isCoverFit: true }], + ['form', { coverUrl: '', submitLabel: 'Send' }], + ['gallery', { coverFieldId: null, isFieldNameHidden: true }], + [ + 'plugin', + { + pluginId: 'plg-source', + pluginInstallId: 'pli-source', + pluginLogo: 'source-logo', + }, + ], + ] as const)('updates an owned %s View while preserving all other state', (type, options) => { + const table = buildTable(); + const [titleField] = table.getFields(); + const created = table + .createView({ + type, + name: `${type} source`, + description: 'Description', + columnMeta: { + [titleField!.id().toString()]: { width: 280, hidden: true }, + }, + options, + filter: { + conjunction: 'and', + items: [{ fieldId: titleField!.id().toString(), operator: 'is', value: 'alpha' }], + }, + sourceFilter: { + conjunction: 'and', + filterSet: [ + { + fieldId: titleField!.id().toString(), + operator: '=', + isSymbol: true, + value: 'alpha', + }, + ], + }, + sort: [{ fieldId: titleField!.id().toString(), order: 'desc' }], + group: [{ fieldId: titleField!.id().toString(), order: 'asc' }], + manualSort: false, + isLocked: false, + enableShare: true, + shareId: `shr${'s'.repeat(16)}`, + shareMeta: { allowCopy: false }, + }) + ._unsafeUnwrap(); + const source = created.view; + source + .setAuditMetadata( + ViewAuditMetadata.rehydrate({ + createdBy: 'usr-created', + createdTime: '2026-01-01T00:00:00.000Z', + lastModifiedBy: 'usr-modified', + lastModifiedTime: '2026-01-02T00:00:00.000Z', + })._unsafeUnwrap() + ) + ._unsafeUnwrap(); + const current = created.updateResult.table; + current.pullDomainEvents(); + + const result = current.updateViewLocked(source.id(), true)._unsafeUnwrap(); + + expect(result.previousIsLocked).toBe(false); + expect(result.nextIsLocked).toBe(true); + expect(result.updateResult.mutateSpec).toBeInstanceOf(TableUpdateViewLockedSpec); + expect(result.view.id().equals(source.id())).toBe(true); + expect(result.view.name().equals(source.name())).toBe(true); + expect(result.view.type().toString()).toBe(type); + expect(result.view.isLocked()).toBe(true); + expect(result.view.properties().toDto()).toEqual({ + ...source.properties().toDto(), + isLocked: true, + }); + expect(result.view.options()).toEqual(source.options()); + expect(result.view.columnMeta()._unsafeUnwrap().toDto()).toEqual( + source.columnMeta()._unsafeUnwrap().toDto() + ); + expect(result.view.queryDefaults()._unsafeUnwrap().toDto()).toEqual( + source.queryDefaults()._unsafeUnwrap().toDto() + ); + expect(result.view.queryDefaults()._unsafeUnwrap().sourceFilter()).toEqual( + source.queryDefaults()._unsafeUnwrap().sourceFilter() + ); + expect(result.view.auditMetadata()._unsafeUnwrap().toDto()).toEqual( + source.auditMetadata()._unsafeUnwrap().toDto() + ); + const [event] = result.updateResult.table.pullDomainEvents(); + expect(event).toBeInstanceOf(ViewLockedUpdated); + expect(event).toMatchObject({ + previousIsLocked: false, + nextIsLocked: true, + viewId: source.id(), + }); + }); + + it('preserves true, false, omitted, and unchanged states exactly', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const locked = table.updateViewLocked(viewId, true)._unsafeUnwrap(); + const unlocked = locked.updateResult.table.updateViewLocked(viewId, false)._unsafeUnwrap(); + const omitted = unlocked.updateResult.table.updateViewLocked(viewId, undefined)._unsafeUnwrap(); + const unchanged = omitted.updateResult.table + .updateViewLocked(viewId, undefined) + ._unsafeUnwrap(); + + expect(locked.previousIsLocked).toBeUndefined(); + expect(locked.view.isLocked()).toBe(true); + expect(unlocked.previousIsLocked).toBe(true); + expect(unlocked.view.isLocked()).toBe(false); + expect(omitted.previousIsLocked).toBe(false); + expect(omitted.view.isLocked()).toBeUndefined(); + expect(unchanged.previousIsLocked).toBeUndefined(); + expect(unchanged.view.isLocked()).toBeUndefined(); + }); + + it('rejects a View id outside the aggregate', () => { + const result = buildTable().updateViewLocked( + ViewId.create(`viw${'z'.repeat(16)}`)._unsafeUnwrap(), + true + ); + + expect(result._unsafeUnwrapErr().code).toBe('view.not_found'); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/updateViewLocked.ts b/packages/v2/core/src/domain/table/methods/updateViewLocked.ts new file mode 100644 index 0000000000..5733fe6277 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewLocked.ts @@ -0,0 +1,38 @@ +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import type { Table } from '../Table'; +import type { TableUpdateResult } from '../TableMutator'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; + +export type UpdateViewLockedMethodResult = { + readonly previousIsLocked: boolean | undefined; + readonly nextIsLocked: boolean | undefined; + readonly view: View; + readonly updateResult: TableUpdateResult; +}; + +export function updateViewLocked( + this: Table, + viewId: ViewId, + nextIsLocked: boolean | undefined +): Result { + const table = this; + return safeTry(function* () { + const previousView = yield* table.getView(viewId); + const previousIsLocked = previousView.isLocked(); + const updateResult = yield* table.update((mutator) => + mutator.updateViewLocked(viewId, nextIsLocked) + ); + const view = yield* updateResult.table.getView(viewId); + + return ok({ + previousIsLocked, + nextIsLocked, + view, + updateResult, + }); + }); +} diff --git a/packages/v2/core/src/domain/table/methods/updateViewOptions.spec.ts b/packages/v2/core/src/domain/table/methods/updateViewOptions.spec.ts new file mode 100644 index 0000000000..a0ef18b011 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewOptions.spec.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { ViewOptionsUpdated } from '../events/ViewOptionsUpdated'; +import { FieldName } from '../fields/FieldName'; +import { TableUpdateViewOptionsSpec } from '../specs/TableUpdateViewOptionsSpec'; +import { Table } from '../Table'; +import { TableName } from '../TableName'; +import type { IViewTypeLiteral } from '../views/ViewType'; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Options')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +const createView = (table: Table, type: IViewTypeLiteral, options?: unknown) => + table.createView({ type, options })._unsafeUnwrap(); + +describe('Table.updateViewOptions', () => { + it('shallow-merges grid options and emits a focused mutation event', () => { + const created = createView(buildTable(), 'grid', { + rowHeight: 'short', + fieldNameDisplayLines: 1, + }); + const result = created.updateResult.table + .updateViewOptions(created.view.id(), { rowHeight: 'tall' }) + ._unsafeUnwrap(); + + expect(result.previousOptions).toEqual({ + rowHeight: 'short', + fieldNameDisplayLines: 1, + }); + expect(result.nextOptions).toEqual({ + rowHeight: 'tall', + fieldNameDisplayLines: 1, + }); + expect(result.updateResult?.mutateSpec).toBeInstanceOf(TableUpdateViewOptionsSpec); + expect(result.view.options()).toEqual(result.nextOptions); + const event = result.updateResult?.table + .pullDomainEvents() + .find((candidate) => candidate instanceof ViewOptionsUpdated); + expect(event).toBeInstanceOf(ViewOptionsUpdated); + }); + + it.each([ + ['grid', { rowHeight: 'autoFit', frozenColumnCount: 2 }], + ['gallery', { coverFieldId: null, isCoverFit: true }], + ['kanban', { stackFieldId: 'fld-stack', isEmptyStackHidden: true }], + [ + 'calendar', + { + startDateFieldId: null, + colorConfig: { type: 'custom', color: 'blue' }, + }, + ], + ['form', { submitLabel: 'Send', coverUrl: 'https://example.test/cover' }], + [ + 'plugin', + { + pluginId: 'plg-view', + pluginInstallId: 'pli-view', + pluginLogo: 'https://example.test/logo.png', + }, + ], + ] as const)('validates and updates %s options inside the aggregate', (type, patch) => { + const created = + type === 'plugin' ? createView(buildTable(), type, patch) : createView(buildTable(), type); + const source = created.updateResult.table; + + const result = source.updateViewOptions(created.view.id(), patch)._unsafeUnwrap(); + + expect(result.nextOptions).toEqual(patch); + if (type === 'plugin') { + expect(result.updateResult).toBeUndefined(); + } else { + expect(result.updateResult).toBeDefined(); + } + }); + + it('preserves explicit null values and treats an identical patch as a no-op', () => { + const created = createView(buildTable(), 'gallery', { + coverFieldId: 'fld-cover', + isCoverFit: true, + }); + const cleared = created.updateResult.table + .updateViewOptions(created.view.id(), { coverFieldId: null }) + ._unsafeUnwrap(); + expect(cleared.nextOptions).toEqual({ coverFieldId: null, isCoverFit: true }); + + const noOp = cleared + .updateResult!.table.updateViewOptions(created.view.id(), { coverFieldId: null }) + ._unsafeUnwrap(); + expect(noOp.updateResult).toBeUndefined(); + }); + + it('rejects subtype mismatches, invalid values, and incomplete plugin patches', () => { + const grid = createView(buildTable(), 'grid'); + expect( + grid.updateResult.table.updateViewOptions(grid.view.id(), { submitLabel: 'Wrong' }).isErr() + ).toBe(true); + expect( + grid.updateResult.table.updateViewOptions(grid.view.id(), { rowHeight: 'huge' }).isErr() + ).toBe(true); + + const pluginOptions = { + pluginId: 'plg-view', + pluginInstallId: 'pli-view', + pluginLogo: 'https://example.test/logo.png', + }; + const plugin = createView(buildTable(), 'plugin', pluginOptions); + expect( + plugin.updateResult.table + .updateViewOptions(plugin.view.id(), { pluginLogo: 'next.png' }) + .isErr() + ).toBe(true); + }); + + it('rejects a View outside the loaded Table aggregate', () => { + const first = buildTable(); + const second = buildTable(); + expect(first.updateViewOptions(second.views()[0]!.id(), {}).isErr()).toBe(true); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/updateViewOptions.ts b/packages/v2/core/src/domain/table/methods/updateViewOptions.ts new file mode 100644 index 0000000000..2440cf7bd6 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewOptions.ts @@ -0,0 +1,51 @@ +import { ok, safeTry, type Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import type { Table } from '../Table'; +import type { TableUpdateResult } from '../TableMutator'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; +import { mergeAndValidateViewOptions } from '../views/ViewOptions'; + +export type UpdateViewOptionsMethodResult = { + readonly view: View; + readonly previousOptions: unknown; + readonly nextOptions: unknown; + readonly updateResult?: TableUpdateResult; +}; + +export function updateViewOptions( + this: Table, + viewId: ViewId, + patch: unknown +): Result { + const table = this; + return safeTry(function* () { + const view = yield* table.getView(viewId); + const previousOptions = view.options(); + const nextOptions = yield* mergeAndValidateViewOptions( + view.type().toString(), + previousOptions, + patch + ); + + if (JSON.stringify(previousOptions) === JSON.stringify(nextOptions)) { + return ok({ view, previousOptions, nextOptions }); + } + + const updateResult = yield* table.update((mutator) => + mutator.updateViewOptions({ + viewId, + previousOptions, + nextOptions, + }) + ); + const nextView = yield* updateResult.table.getView(viewId); + return ok({ + view: nextView, + previousOptions, + nextOptions, + updateResult, + }); + }); +} diff --git a/packages/v2/core/src/domain/table/methods/updateViewOrder.spec.ts b/packages/v2/core/src/domain/table/methods/updateViewOrder.spec.ts new file mode 100644 index 0000000000..370fb4895c --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewOrder.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { ViewOrderUpdated } from '../events/ViewOrderUpdated'; +import { FieldName } from '../fields/FieldName'; +import { TableUpdateViewOrderSpec } from '../specs/TableUpdateViewOrderSpec'; +import { Table } from '../Table'; +import { TableId } from '../TableId'; +import { TableName } from '../TableName'; +import { ViewId } from '../views/ViewId'; +import { ViewOrder } from '../views/ViewOrder'; + +const buildTable = (orders: ReadonlyArray = [0, 1, 2]): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'b'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Title')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + let table = builder.build()._unsafeUnwrap(); + while (table.views().length < orders.length) { + table = table + .createView({ type: 'grid', name: `View ${table.views().length + 1}` }) + ._unsafeUnwrap().updateResult.table; + } + table.pullDomainEvents(); + table.views().forEach((view, index) => { + view.setOrder(ViewOrder.rehydrate(orders[index])._unsafeUnwrap())._unsafeUnwrap(); + }); + return table; +}; + +const ids = (table: Table): string[] => table.views().map((view) => view.id().toString()); + +describe('Table.updateViewOrder', () => { + it('moves before and after an anchor with fractional and boundary coordinates', () => { + const beforeTable = buildTable(); + const [first, second, third] = beforeTable.views(); + const before = beforeTable.updateViewOrder(third!.id(), second!.id(), 'before')._unsafeUnwrap(); + + expect(before.previousOrder.toNumber()).toBe(2); + expect(before.nextOrder.toNumber()).toBe(0.5); + expect(ids(before.updateResult.table)).toEqual([ + first!.id().toString(), + third!.id().toString(), + second!.id().toString(), + ]); + expect(before.updateResult.mutateSpec).toBeInstanceOf(TableUpdateViewOrderSpec); + + const afterTable = buildTable(); + const [afterFirst, , afterThird] = afterTable.views(); + const after = afterTable + .updateViewOrder(afterFirst!.id(), afterThird!.id(), 'after') + ._unsafeUnwrap(); + expect(after.nextOrder.toNumber()).toBe(3); + expect(ids(after.updateResult.table).at(-1)).toBe(afterFirst!.id().toString()); + }); + + it('preserves legacy nearest-neighbor behavior when source is already adjacent', () => { + const table = buildTable(); + const [first, second] = table.views(); + const result = table.updateViewOrder(first!.id(), second!.id(), 'before')._unsafeUnwrap(); + + expect(result.previousOrder.toNumber()).toBe(0); + expect(result.nextOrder.toNumber()).toBe(0.5); + expect(ids(result.updateResult.table)).toEqual(ids(table)); + }); + + it('allows source and anchor to be the same View and still records the legacy update', () => { + const table = buildTable(); + const source = table.views()[1]!; + const result = table.updateViewOrder(source.id(), source.id(), 'after')._unsafeUnwrap(); + + expect(result.previousOrder.toNumber()).toBe(1); + expect(result.nextOrder.toNumber()).toBe(1.5); + expect(result.changes).toHaveLength(1); + }); + + it('normalizes every View when the anchor gap is exhausted, then applies the source move', () => { + const table = buildTable([0, 1 - Number.EPSILON, 1]); + const [source, neighbor, anchor] = table.views(); + const result = table.updateViewOrder(source!.id(), anchor!.id(), 'before')._unsafeUnwrap(); + + expect(result.changes).toHaveLength(4); + expect(result.changes.slice(0, 3).map((change) => change.nextOrder.toNumber())).toEqual([ + 0, 1, 2, + ]); + expect(result.nextOrder.toNumber()).toBe(1.5); + expect(ids(result.updateResult.table)).toEqual([ + neighbor!.id().toString(), + source!.id().toString(), + anchor!.id().toString(), + ]); + const events = result.updateResult.table.pullDomainEvents(); + expect(events).toHaveLength(4); + expect(events.every((event) => event instanceof ViewOrderUpdated)).toBe(true); + }); + + it('distinguishes a missing source from a missing anchor inside the aggregate', () => { + const table = buildTable(); + const missing = ViewId.create(`viw${'z'.repeat(16)}`)._unsafeUnwrap(); + expect( + table.updateViewOrder(missing, table.views()[0]!.id(), 'before')._unsafeUnwrapErr().code + ).toBe('view.not_found'); + expect( + table.updateViewOrder(table.views()[0]!.id(), missing, 'before')._unsafeUnwrapErr().code + ).toBe('view.anchor_not_found'); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/updateViewOrder.ts b/packages/v2/core/src/domain/table/methods/updateViewOrder.ts new file mode 100644 index 0000000000..38d6d63e5f --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewOrder.ts @@ -0,0 +1,117 @@ +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import type { TableViewOrderChange } from '../specs/TableUpdateViewOrderSpec'; +import type { Table } from '../Table'; +import type { TableUpdateResult } from '../TableMutator'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; +import { ViewOrder } from '../views/ViewOrder'; + +export type ViewOrderPosition = 'before' | 'after'; + +export type UpdateViewOrderMethodResult = { + readonly sourceViewId: ViewId; + readonly previousOrder: ViewOrder; + readonly nextOrder: ViewOrder; + readonly changes: ReadonlyArray; + readonly updateResult: TableUpdateResult; +}; + +type OrderedView = { view: View; order: number }; + +const findNeighbor = ( + views: ReadonlyArray, + anchorOrder: number, + position: ViewOrderPosition +): OrderedView | undefined => { + const candidates = views.filter(({ order }) => + position === 'before' ? order < anchorOrder : order > anchorOrder + ); + candidates.sort((left, right) => + position === 'before' ? right.order - left.order : left.order - right.order + ); + return candidates[0]; +}; + +const calculateOrder = ( + views: ReadonlyArray, + anchorOrder: number, + position: ViewOrderPosition +): number => { + const neighbor = findNeighbor(views, anchorOrder, position); + return neighbor + ? (neighbor.order + anchorOrder) / 2 + : anchorOrder + (position === 'before' ? -1 : 1); +}; + +export function updateViewOrder( + this: Table, + sourceViewId: ViewId, + anchorViewId: ViewId, + position: ViewOrderPosition +): Result { + const table = this; + return safeTry(function* () { + const sourceViewResult = table.getView(sourceViewId); + if (sourceViewResult.isErr()) { + return err( + domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${sourceViewId.toString()}`, + }) + ); + } + const anchorViewResult = table.getView(anchorViewId); + if (anchorViewResult.isErr()) { + return err( + domainError.notFound({ + code: 'view.anchor_not_found', + message: `Anchor View not found: ${anchorViewId.toString()}`, + }) + ); + } + + const sourceView = sourceViewResult.value; + const anchorView = anchorViewResult.value; + const previousOrder = yield* sourceView.order(); + const anchorOrder = yield* anchorView.order(); + const orderedViews: OrderedView[] = []; + for (const view of table.views()) { + orderedViews.push({ view, order: (yield* view.order()).toNumber() }); + } + + let calculated = calculateOrder(orderedViews, anchorOrder.toNumber(), position); + const changes: TableViewOrderChange[] = []; + + if (Math.abs(calculated - anchorOrder.toNumber()) < Number.EPSILON * 2) { + for (let index = 0; index < orderedViews.length; index += 1) { + const item = orderedViews[index]!; + changes.push({ + viewId: item.view.id(), + previousOrder: yield* ViewOrder.rehydrate(item.order), + nextOrder: yield* ViewOrder.rehydrate(index), + }); + item.order = index; + } + const normalizedAnchor = orderedViews.find(({ view }) => view.id().equals(anchorViewId)); + if (!normalizedAnchor) { + return err(domainError.invariant({ message: 'Normalized anchor View missing' })); + } + calculated = calculateOrder(orderedViews, normalizedAnchor.order, position); + } + + const nextOrder = yield* ViewOrder.rehydrate(calculated); + changes.push({ viewId: sourceViewId, previousOrder, nextOrder }); + const updateResult = yield* table.update((mutator) => mutator.updateViewOrder(changes)); + + return ok({ + sourceViewId, + previousOrder, + nextOrder, + changes, + updateResult, + }); + }); +} diff --git a/packages/v2/core/src/domain/table/methods/updateViewShareMeta.spec.ts b/packages/v2/core/src/domain/table/methods/updateViewShareMeta.spec.ts new file mode 100644 index 0000000000..91fbed68ae --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewShareMeta.spec.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { ViewShareMetaUpdated } from '../events/ViewShareMetaUpdated'; +import { FieldName } from '../fields/FieldName'; +import { TableUpdateViewShareMetaSpec } from '../specs/TableUpdateViewShareMetaSpec'; +import { Table } from '../Table'; +import { TableName } from '../TableName'; +import { ViewId } from '../views/ViewId'; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Shared Views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +describe('Table.updateViewShareMeta', () => { + it('replaces share metadata and emits a focused aggregate event', () => { + const table = buildTable(); + const view = table.views()[0]!; + const shareMeta = { + allowCopy: true, + includeHiddenField: true, + password: 'secret', + includeRecords: true, + submit: { requireLogin: true }, + allowEdit: true, + }; + + const result = table.updateViewShareMeta(view.id(), shareMeta)._unsafeUnwrap(); + + expect(result.previousShareMeta).toBeUndefined(); + expect(result.nextShareMeta).toEqual(shareMeta); + expect(result.view.shareMeta()).toEqual(shareMeta); + expect(result.updateResult?.mutateSpec).toBeInstanceOf(TableUpdateViewShareMetaSpec); + const events = result.updateResult?.table.pullDomainEvents() ?? []; + expect(events).toEqual([ + expect.objectContaining({ + previousShareMeta: undefined, + nextShareMeta: shareMeta, + viewId: view.id(), + }), + ]); + expect(events.every((event) => event instanceof ViewShareMetaUpdated)).toBe(true); + }); + + it('preserves empty metadata, supports snapshot clearing, and skips identical updates', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const empty = table.updateViewShareMeta(viewId, {})._unsafeUnwrap(); + expect(empty.view.shareMeta()).toEqual({}); + + const noOp = empty.updateResult!.table.updateViewShareMeta(viewId, {})._unsafeUnwrap(); + expect(noOp.updateResult).toBeUndefined(); + + const cleared = empty + .updateResult!.table.updateViewShareMeta(viewId, undefined) + ._unsafeUnwrap(); + expect(cleared.view.shareMeta()).toBeUndefined(); + }); + + it('rejects invalid metadata and a View outside the Table aggregate', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + + expect(table.updateViewShareMeta(viewId, { password: 'ab' }).isErr()).toBe(true); + expect(table.updateViewShareMeta(viewId, { allowCopy: 'yes' }).isErr()).toBe(true); + expect(table.updateViewShareMeta(viewId, { unknown: true }).isErr()).toBe(true); + expect( + table + .updateViewShareMeta(ViewId.create(`viw${'z'.repeat(16)}`)._unsafeUnwrap(), {}) + ._unsafeUnwrapErr().code + ).toBe('view.not_found'); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/updateViewShareMeta.ts b/packages/v2/core/src/domain/table/methods/updateViewShareMeta.ts new file mode 100644 index 0000000000..85f8717b5c --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewShareMeta.ts @@ -0,0 +1,44 @@ +import { ok, safeTry, type Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import type { Table } from '../Table'; +import type { TableUpdateResult } from '../TableMutator'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; +import { ViewProperties, type ViewShareMetaValue } from '../views/ViewProperties'; + +export type UpdateViewShareMetaMethodResult = { + readonly view: View; + readonly previousShareMeta: ViewShareMetaValue | undefined; + readonly nextShareMeta: ViewShareMetaValue | undefined; + readonly updateResult?: TableUpdateResult; +}; + +export function updateViewShareMeta( + this: Table, + viewId: ViewId, + shareMeta: unknown +): Result { + const table = this; + return safeTry(function* () { + const view = yield* table.getView(viewId); + const previousShareMeta = view.shareMeta(); + const properties = yield* ViewProperties.rehydrate({ shareMeta }); + const nextShareMeta = properties.shareMeta(); + + if (JSON.stringify(previousShareMeta) === JSON.stringify(nextShareMeta)) { + return ok({ view, previousShareMeta, nextShareMeta }); + } + + const updateResult = yield* table.update((mutator) => + mutator.updateViewShareMeta(viewId, nextShareMeta) + ); + const nextView = yield* updateResult.table.getView(viewId); + return ok({ + view: nextView, + previousShareMeta, + nextShareMeta, + updateResult, + }); + }); +} diff --git a/packages/v2/core/src/domain/table/methods/updateViewShareState.spec.ts b/packages/v2/core/src/domain/table/methods/updateViewShareState.spec.ts new file mode 100644 index 0000000000..057318f1b9 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewShareState.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { ViewShareDisabled } from '../events/ViewShareDisabled'; +import { ViewShareEnabled } from '../events/ViewShareEnabled'; +import { FieldName } from '../fields/FieldName'; +import { TableUpdateViewShareStateSpec } from '../specs/TableUpdateViewShareStateSpec'; +import { Table } from '../Table'; +import { TableName } from '../TableName'; +import { ViewId } from '../views/ViewId'; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Shared Views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +describe('Table View share lifecycle', () => { + it('enables sharing inside the aggregate, mints a credential, and defaults grid metadata', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + + const result = table.enableViewShare(viewId)._unsafeUnwrap(); + + expect(result.shareId).toMatch(/^shr[0-9a-zA-Z]{16}$/); + expect(result.view.enableShare()).toBe(true); + expect(result.view.shareId()).toBe(result.shareId); + expect(result.view.shareMeta()).toEqual({ includeRecords: true }); + expect(result.updateResult.mutateSpec).toBeInstanceOf(TableUpdateViewShareStateSpec); + expect(result.updateResult.table.pullDomainEvents()).toEqual([ + expect.objectContaining({ + viewId, + shareId: result.shareId, + shareMeta: { includeRecords: true }, + }), + ]); + }); + + it('uses empty default metadata for forms and preserves existing metadata', () => { + const formCreated = buildTable() + .createView({ type: 'form', name: 'Public form' }) + ._unsafeUnwrap(); + const formTable = formCreated.updateResult.table; + formTable.pullDomainEvents(); + + const formResult = formTable.enableViewShare(formCreated.view.id())._unsafeUnwrap(); + expect(formResult.view.shareMeta()).toEqual({}); + + const gridId = formResult.updateResult.table.views()[0]!.id(); + const withMeta = formResult.updateResult.table + .updateViewShareMeta(gridId, { allowCopy: true }) + ._unsafeUnwrap().updateResult!.table; + withMeta.pullDomainEvents(); + expect(withMeta.enableViewShare(gridId)._unsafeUnwrap().view.shareMeta()).toEqual({ + allowCopy: true, + }); + }); + + it('disables sharing while retaining credentials and metadata for aggregate state', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const enabled = table.enableViewShare(viewId)._unsafeUnwrap(); + enabled.updateResult.table.pullDomainEvents(); + + const result = enabled.updateResult.table.disableViewShare(viewId)._unsafeUnwrap(); + + expect(result.view.enableShare()).toBe(false); + expect(result.view.shareId()).toBe(enabled.shareId); + expect(result.view.shareMeta()).toEqual({ includeRecords: true }); + expect(result.updateResult.table.pullDomainEvents()).toEqual([ + expect.objectContaining({ + viewId, + previousShareId: enabled.shareId, + shareMeta: { includeRecords: true }, + }), + ]); + }); + + it('rejects repeated transitions and a View outside the Table aggregate', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + expect(table.disableViewShare(viewId)._unsafeUnwrapErr().code).toBe('validation.invalid'); + + const enabled = table.enableViewShare(viewId)._unsafeUnwrap().updateResult.table; + expect(enabled.enableViewShare(viewId)._unsafeUnwrapErr().code).toBe('validation.invalid'); + expect( + table + .enableViewShare(ViewId.create(`viw${'z'.repeat(16)}`)._unsafeUnwrap()) + ._unsafeUnwrapErr().code + ).toBe('view.not_found'); + }); + + it('emits focused enable and disable event types', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const enabled = table.enableViewShare(viewId)._unsafeUnwrap(); + expect(enabled.updateResult.table.pullDomainEvents()[0]).toBeInstanceOf(ViewShareEnabled); + + const disabled = enabled.updateResult.table.disableViewShare(viewId)._unsafeUnwrap(); + expect(disabled.updateResult.table.pullDomainEvents()[0]).toBeInstanceOf(ViewShareDisabled); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/updateViewShareState.ts b/packages/v2/core/src/domain/table/methods/updateViewShareState.ts new file mode 100644 index 0000000000..2aed68b540 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewShareState.ts @@ -0,0 +1,95 @@ +import { err, ok, safeTry, type Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import { generatePrefixedId } from '../../shared/IdGenerator'; +import type { Table } from '../Table'; +import type { TableUpdateResult } from '../TableMutator'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; +import type { ViewShareMetaValue } from '../views/ViewProperties'; +import { ViewType } from '../views/ViewType'; + +const shareIdPrefix = 'shr'; +const shareIdBodyLength = 16; + +type TableUpdateViewShareStateResult = { + readonly view: View; + readonly previousShareId: string | undefined; + readonly updateResult: TableUpdateResult; +}; + +export type TableEnableViewShareResult = TableUpdateViewShareStateResult & { + readonly shareId: string; +}; + +export type TableDisableViewShareResult = TableUpdateViewShareStateResult & { + readonly shareId: string | undefined; +}; + +const defaultShareMeta = (view: View): ViewShareMetaValue => + view.type().equals(ViewType.form()) ? {} : { includeRecords: true }; + +export function enableViewShare( + this: Table, + viewId: ViewId +): Result { + const table = this; + return safeTry(function* () { + const view = yield* table.getView(viewId); + if (view.enableShare() === true) { + return err( + domainError.validation({ + message: `View ${viewId.toString()} has already been enabled share`, + }) + ); + } + + const shareId = generatePrefixedId(shareIdPrefix, shareIdBodyLength); + const updateResult = yield* table.update((mutator) => + mutator.updateViewShareState(viewId, { + enableShare: true, + shareId, + shareMeta: view.shareMeta() ?? defaultShareMeta(view), + }) + ); + const nextView = yield* updateResult.table.getView(viewId); + return ok({ + view: nextView, + previousShareId: view.shareId(), + shareId, + updateResult, + }); + }); +} + +export function disableViewShare( + this: Table, + viewId: ViewId +): Result { + const table = this; + return safeTry(function* () { + const view = yield* table.getView(viewId); + if (view.enableShare() !== true) { + return err( + domainError.validation({ + message: `View ${viewId.toString()} has already been disabled share`, + }) + ); + } + + const updateResult = yield* table.update((mutator) => + mutator.updateViewShareState(viewId, { + enableShare: false, + shareId: view.shareId(), + shareMeta: view.shareMeta(), + }) + ); + const nextView = yield* updateResult.table.getView(viewId); + return ok({ + view: nextView, + previousShareId: view.shareId(), + shareId: view.shareId(), + updateResult, + }); + }); +} diff --git a/packages/v2/core/src/domain/table/methods/updateViewSort.spec.ts b/packages/v2/core/src/domain/table/methods/updateViewSort.spec.ts new file mode 100644 index 0000000000..5f8aef2535 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewSort.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { ViewSortUpdated } from '../events/ViewSortUpdated'; +import { FieldName } from '../fields/FieldName'; +import { TableUpdateViewQueryDefaultsSpec } from '../specs/TableUpdateViewQueryDefaultsSpec'; +import { Table } from '../Table'; +import { TableId } from '../TableId'; +import { TableName } from '../TableName'; +import { ViewId } from '../views/ViewId'; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'b'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Sort views')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.field().number().withName(FieldName.create('Amount')._unsafeUnwrap()).done(); + builder.field().button().withName(FieldName.create('Action')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +describe('Table.updateViewSort', () => { + it('updates multiple sort items through one focused aggregate spec and event', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const sort = { + sortObjs: [ + { fieldId: table.getFields()[0]!.id().toString(), order: 'asc' as const }, + { fieldId: table.getFields()[1]!.id().toString(), order: 'desc' as const }, + ], + manualSort: false, + }; + + const result = table.updateViewSort(viewId, sort)._unsafeUnwrap(); + + expect(result.previousSort).toBeNull(); + expect(result.nextSort).toEqual(sort); + expect(result.nextQueryDefaults.sort()).toEqual(sort.sortObjs); + expect(result.nextQueryDefaults.manualSort()).toBe(false); + expect(result.updateResult?.mutateSpec).toBeInstanceOf(TableUpdateViewQueryDefaultsSpec); + const [event] = result.updateResult?.table.pullDomainEvents() ?? []; + expect(event).toBeInstanceOf(ViewSortUpdated); + expect(event).toMatchObject({ viewId, previousSort: null, nextSort: sort }); + }); + + it('preserves empty, manual, identical no-op, and clear branches', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + const emptySort = { sortObjs: [] }; + const empty = table.updateViewSort(viewId, emptySort)._unsafeUnwrap(); + expect(empty.nextQueryDefaults.sort()).toEqual([]); + expect(empty.nextSort).toEqual(emptySort); + + const manual = empty + .updateResult!.table.updateViewSort(viewId, { sortObjs: [], manualSort: true }) + ._unsafeUnwrap(); + expect(manual.nextQueryDefaults.manualSort()).toBe(true); + expect( + manual + .updateResult!.table.updateViewSort(viewId, { sortObjs: [], manualSort: true }) + ._unsafeUnwrap().updateResult + ).toBeUndefined(); + + const cleared = manual.updateResult!.table.updateViewSort(viewId, null)._unsafeUnwrap(); + expect(cleared.nextQueryDefaults.sort()).toBeUndefined(); + expect(cleared.nextQueryDefaults.manualSort()).toBeUndefined(); + expect(cleared.nextSort).toBeNull(); + }); + + it('preserves duplicate sort items accepted by the public contract', () => { + const table = buildTable(); + const fieldId = table.getFields()[0]!.id().toString(); + const sort = { + sortObjs: [ + { fieldId, order: 'asc' as const }, + { fieldId, order: 'desc' as const }, + ], + }; + expect(table.updateViewSort(table.views()[0]!.id(), sort)._unsafeUnwrap().nextSort).toEqual( + sort + ); + }); + + it('rejects missing aggregate children, missing fields, Button fields, and invalid input', () => { + const table = buildTable(); + const viewId = table.views()[0]!.id(); + expect( + table + .updateViewSort(ViewId.create(`viw${'z'.repeat(16)}`)._unsafeUnwrap(), null) + ._unsafeUnwrapErr().code + ).toBe('view.not_found'); + expect( + table + .updateViewSort(viewId, { + sortObjs: [{ fieldId: `fld${'z'.repeat(16)}`, order: 'asc' }], + }) + ._unsafeUnwrapErr().code + ).toBe('field.not_found'); + expect( + table + .updateViewSort(viewId, { + sortObjs: [{ fieldId: table.getFields()[2]!.id().toString(), order: 'desc' }], + }) + ._unsafeUnwrapErr().code + ).toBe('view.sort_unsupported_field_type'); + expect( + table.updateViewSort(viewId, { sortObjs: [{ fieldId: 'bad', order: 'up' }] }).isErr() + ).toBe(true); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/updateViewSort.ts b/packages/v2/core/src/domain/table/methods/updateViewSort.ts new file mode 100644 index 0000000000..edadbc2f8f --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/updateViewSort.ts @@ -0,0 +1,106 @@ +import { err, ok, safeTry, type Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import { FieldId } from '../fields/FieldId'; +import { FieldType } from '../fields/FieldType'; +import type { Table } from '../Table'; +import type { TableUpdateResult } from '../TableMutator'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; +import { ViewQueryDefaults } from '../views/ViewQueryDefaults'; +import { ViewSort, viewSortDtoFromQueryDefaults, type ViewSortDTO } from '../views/ViewSort'; + +export type UpdateViewSortMethodResult = { + readonly view: View; + readonly previousSort: ViewSortDTO; + readonly nextSort: ViewSortDTO; + readonly previousQueryDefaults: ViewQueryDefaults; + readonly nextQueryDefaults: ViewQueryDefaults; + readonly updateResult?: TableUpdateResult; +}; + +const validateSortFields = (table: Table, sort: ViewSortDTO): Result => { + if (sort === null) return ok(undefined); + + for (const item of sort.sortObjs) { + const fieldId = FieldId.create(item.fieldId); + if (fieldId.isErr()) return err(fieldId.error); + const field = table.getField((candidate) => candidate.id().equals(fieldId.value)); + if (field.isErr()) { + return err( + domainError.notFound({ + code: 'field.not_found', + message: `Sort field ${item.fieldId} not found in table ${table.id().toString()}`, + }) + ); + } + if (field.value.type().equals(FieldType.button())) { + return err( + domainError.validation({ + code: 'view.sort_unsupported_field_type', + message: `Sort field ${item.fieldId} has unsupported Button type`, + }) + ); + } + } + return ok(undefined); +}; + +export function updateViewSort( + this: Table, + viewId: ViewId, + rawSort: unknown +): Result { + const table = this; + return safeTry(function* () { + const view = yield* table.getView(viewId); + const sort = yield* ViewSort.create(rawSort); + const nextSort = sort.toDto(); + yield* validateSortFields(table, nextSort); + + const previousQueryDefaults = yield* view.queryDefaults(); + const previousSort = viewSortDtoFromQueryDefaults(previousQueryDefaults); + const { + sort: _previousSort, + manualSort: _previousManualSort, + ...preservedDefaults + } = previousQueryDefaults.toDto(); + const nextQueryDefaults = yield* ViewQueryDefaults.rehydrate( + nextSort === null + ? preservedDefaults + : { + ...preservedDefaults, + sort: nextSort.sortObjs, + ...(nextSort.manualSort !== undefined ? { manualSort: nextSort.manualSort } : {}), + }, + { sourceFilter: previousQueryDefaults.sourceFilter() } + ); + + if (previousQueryDefaults.equals(nextQueryDefaults)) { + return ok({ + view, + previousSort, + nextSort, + previousQueryDefaults, + nextQueryDefaults, + }); + } + + const updateResult = yield* table.update((mutator) => + mutator.updateViewQueryDefaults({ + viewId, + previousQueryDefaults, + queryDefaults: nextQueryDefaults, + }) + ); + const nextView = yield* updateResult.table.getView(viewId); + return ok({ + view: nextView, + previousSort, + nextSort, + previousQueryDefaults, + nextQueryDefaults, + updateResult, + }); + }); +} diff --git a/packages/v2/core/src/domain/table/methods/viewFilterLinkReferences.spec.ts b/packages/v2/core/src/domain/table/methods/viewFilterLinkReferences.spec.ts new file mode 100644 index 0000000000..f4048fc788 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/viewFilterLinkReferences.spec.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { FieldId } from '../fields/FieldId'; +import { FieldName } from '../fields/FieldName'; +import { LinkFieldConfig } from '../fields/types/LinkFieldConfig'; +import { RecordId } from '../records/RecordId'; +import { Table } from '../Table'; +import { TableId } from '../TableId'; +import { TableName } from '../TableName'; +import { GridView } from '../views/types/GridView'; +import { ViewId } from '../views/ViewId'; +import { ViewName } from '../views/ViewName'; +import { ViewQueryDefaults } from '../views/ViewQueryDefaults'; + +const baseId = BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap(); +const tableId = (seed: string) => TableId.create(`tbl${seed.repeat(16)}`)._unsafeUnwrap(); +const fieldId = (seed: string) => FieldId.create(`fld${seed.repeat(16)}`)._unsafeUnwrap(); +const recordId = (seed: string) => RecordId.create(`rec${seed.repeat(16)}`)._unsafeUnwrap(); +const viewId = (seed: string) => ViewId.create(`viw${seed.repeat(16)}`)._unsafeUnwrap(); + +const buildForeignTable = (seed: string) => { + const builder = Table.builder() + .withId(tableId(seed)) + .withBaseId(baseId) + .withName(TableName.create(`Foreign ${seed}`)._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(fieldId(seed)) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .primary() + .done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +const buildSourceTable = (options?: { sourceFilter?: unknown }) => { + const foreignA = buildForeignTable('b'); + const foreignB = buildForeignTable('c'); + const linkFieldAId = fieldId('d'); + const linkFieldBId = fieldId('e'); + const nonLinkFieldId = fieldId('f'); + const ownedViewId = viewId('g'); + const builder = Table.builder() + .withId(tableId('h')) + .withBaseId(baseId) + .withName(TableName.create('Source')._unsafeUnwrap()); + + builder + .field() + .singleLineText() + .withId(nonLinkFieldId) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + builder + .field() + .link() + .withId(linkFieldAId) + .withName(FieldName.create('Foreign A')._unsafeUnwrap()) + .withConfig( + LinkFieldConfig.create({ + relationship: 'manyMany', + foreignTableId: foreignA.id().toString(), + lookupFieldId: foreignA.primaryFieldId().toString(), + isOneWay: true, + })._unsafeUnwrap() + ) + .done(); + builder + .field() + .link() + .withId(linkFieldBId) + .withName(FieldName.create('Foreign B')._unsafeUnwrap()) + .withConfig( + LinkFieldConfig.create({ + relationship: 'manyMany', + foreignTableId: foreignB.id().toString(), + lookupFieldId: foreignB.primaryFieldId().toString(), + isOneWay: true, + })._unsafeUnwrap() + ) + .done(); + builder.view().defaultGrid().done(); + const fieldsTable = builder.build()._unsafeUnwrap(); + const view = GridView.create({ + id: ownedViewId, + name: ViewName.create('Grid')._unsafeUnwrap(), + })._unsafeUnwrap(); + view + .setQueryDefaults( + ViewQueryDefaults.rehydrate( + {}, + { + sourceFilter: options?.sourceFilter ?? null, + } + )._unsafeUnwrap() + ) + ._unsafeUnwrap(); + const table = Table.rehydrate({ + id: fieldsTable.id(), + baseId: fieldsTable.baseId(), + name: fieldsTable.name(), + fields: fieldsTable.getFields(), + views: [view], + primaryFieldId: fieldsTable.primaryFieldId(), + })._unsafeUnwrap(); + + return { + table, + foreignA, + foreignB, + linkFieldAId, + linkFieldBId, + nonLinkFieldId, + ownedViewId, + }; +}; + +describe('Table.viewFilterLinkReferences', () => { + it('resolves nested Link Field references, groups by foreign Table, and deduplicates IDs', () => { + const recordA1 = recordId('i'); + const recordA2 = recordId('j'); + const recordB1 = recordId('k'); + const setup = buildSourceTable({ + sourceFilter: { + conjunction: 'and', + filterSet: [ + { + fieldId: fieldId('d').toString(), + operator: 'is', + value: recordA1.toString(), + }, + { + conjunction: 'or', + filterSet: [ + { + fieldId: fieldId('d').toString(), + operator: 'isAnyOf', + value: [recordA1.toString(), recordA2.toString(), 'not-a-record'], + }, + { + fieldId: fieldId('f').toString(), + operator: 'is', + value: recordB1.toString(), + }, + ], + }, + { + fieldId: fieldId('e').toString(), + operator: 'isAnyOf', + value: [recordB1.toString()], + }, + ], + }, + }); + + const result = setup.table.viewFilterLinkReferences(setup.ownedViewId)._unsafeUnwrap(); + + expect( + result.map((reference) => ({ + tableId: reference.foreignTableId.toString(), + lookupFieldId: reference.lookupFieldId.toString(), + recordIds: reference.recordIds.map((id) => id.toString()), + })) + ).toEqual([ + { + tableId: setup.foreignA.id().toString(), + lookupFieldId: setup.foreignA.primaryFieldId().toString(), + recordIds: [recordA1.toString(), recordA2.toString()], + }, + { + tableId: setup.foreignB.id().toString(), + lookupFieldId: setup.foreignB.primaryFieldId().toString(), + recordIds: [recordB1.toString()], + }, + ]); + }); + + it('returns no references for a null filter', () => { + const setup = buildSourceTable({ sourceFilter: null }); + + expect(setup.table.viewFilterLinkReferences(setup.ownedViewId)._unsafeUnwrap()).toEqual([]); + }); + + it('keeps an empty foreign Table group for an array containing no valid Record IDs', () => { + const setup = buildSourceTable({ + sourceFilter: { + conjunction: 'and', + filterSet: [ + { + fieldId: fieldId('d').toString(), + operator: 'isAnyOf', + value: ['invalid-record-id'], + }, + ], + }, + }); + + const [reference] = setup.table.viewFilterLinkReferences(setup.ownedViewId)._unsafeUnwrap(); + + expect(reference?.foreignTableId.equals(setup.foreignA.id())).toBe(true); + expect(reference?.recordIds).toEqual([]); + }); + + it('rejects a View that is not owned by the Table aggregate', () => { + const setup = buildSourceTable(); + + const result = setup.table.viewFilterLinkReferences(viewId('z')); + + expect(result._unsafeUnwrapErr().code).toBe('view.not_found'); + }); +}); diff --git a/packages/v2/core/src/domain/table/methods/viewFilterLinkReferences.ts b/packages/v2/core/src/domain/table/methods/viewFilterLinkReferences.ts new file mode 100644 index 0000000000..a1dc0c14f1 --- /dev/null +++ b/packages/v2/core/src/domain/table/methods/viewFilterLinkReferences.ts @@ -0,0 +1,120 @@ +import { err, ok, type Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import type { FieldId } from '../fields/FieldId'; +import { LinkField } from '../fields/types/LinkField'; +import { RecordId } from '../records/RecordId'; +import type { Table } from '../Table'; +import type { TableId } from '../TableId'; +import type { ViewId } from '../views/ViewId'; +import type { ViewSourceFilterDTO } from '../views/ViewSourceFilter'; + +export type ViewFilterLinkReference = { + readonly foreignTableId: TableId; + readonly lookupFieldId: FieldId; + readonly recordIds: ReadonlyArray; +}; + +type MutableViewFilterLinkReference = { + foreignTableId: TableId; + lookupFieldId: FieldId; + recordIdsByValue: Map; +}; + +const collectRecordIds = ( + value: unknown +): { + readonly hasReferenceValue: boolean; + readonly recordIds: ReadonlyArray; +} => { + const values = Array.isArray(value) + ? value + : typeof value === 'string' && value.startsWith('rec') + ? [value] + : []; + const recordIds: RecordId[] = []; + + for (const candidate of values) { + const recordIdResult = RecordId.create(candidate); + if (recordIdResult.isOk()) { + recordIds.push(recordIdResult.value); + } + } + + return { + hasReferenceValue: Array.isArray(value) || values.length > 0, + recordIds, + }; +}; + +export const viewFilterLinkReferences = function ( + this: Table, + viewId: ViewId +): Result, DomainError> { + const viewResult = this.getView(viewId); + if (viewResult.isErr()) return err(viewResult.error); + + const queryDefaultsResult = viewResult.value.queryDefaults(); + if (queryDefaultsResult.isErr()) return err(queryDefaultsResult.error); + + const sourceFilter = queryDefaultsResult.value.sourceFilter(); + if (!sourceFilter) return ok([]); + + const linkFields = this.getFields((field): field is LinkField => field instanceof LinkField); + const linkFieldById = new Map(linkFields.map((field) => [field.id().toString(), field] as const)); + const lookupFieldByForeignTableId = new Map( + linkFields.map( + (field) => + [ + field.foreignTableId().toString(), + { + foreignTableId: field.foreignTableId(), + lookupFieldId: field.lookupFieldId(), + }, + ] as const + ) + ); + const referencesByForeignTableId = new Map(); + + const visit = (group: ViewSourceFilterDTO): void => { + for (const item of group.filterSet) { + if ('filterSet' in item) { + visit(item); + continue; + } + + const linkField = linkFieldById.get(item.fieldId); + if (!linkField) continue; + + const collected = collectRecordIds(item.value); + if (!collected.hasReferenceValue) continue; + + const foreignTableId = linkField.foreignTableId().toString(); + const lookup = lookupFieldByForeignTableId.get(foreignTableId); + if (!lookup) continue; + + let reference = referencesByForeignTableId.get(foreignTableId); + if (!reference) { + reference = { + ...lookup, + recordIdsByValue: new Map(), + }; + referencesByForeignTableId.set(foreignTableId, reference); + } + + for (const recordId of collected.recordIds) { + reference.recordIdsByValue.set(recordId.toString(), recordId); + } + } + }; + + visit(sourceFilter); + + return ok( + [...referencesByForeignTableId.values()].map((reference) => ({ + foreignTableId: reference.foreignTableId, + lookupFieldId: reference.lookupFieldId, + recordIds: [...reference.recordIdsByValue.values()], + })) + ); +}; diff --git a/packages/v2/core/src/domain/table/records/TableRecord.spec.ts b/packages/v2/core/src/domain/table/records/TableRecord.spec.ts index 47e37faa70..4e78a4e380 100644 --- a/packages/v2/core/src/domain/table/records/TableRecord.spec.ts +++ b/packages/v2/core/src/domain/table/records/TableRecord.spec.ts @@ -94,3 +94,37 @@ describe('TableRecord.displayName', () => { expect(result._unsafeUnwrapErr().code).toBe('record.table_mismatch'); }); }); + +describe('TableRecord.displayValue', () => { + it('resolves a non-primary field through the owning Table definition', () => { + const table = buildSinglePrimaryTable(); + const builder = Table.builder() + .withId(table.id()) + .withBaseId(table.baseId()) + .withName(table.name()); + const primaryField = table.primaryField()._unsafeUnwrap(); + builder + .field() + .singleLineText() + .withId(primaryField.id()) + .withName(primaryField.name()) + .primary() + .done(); + builder + .field() + .multipleSelect() + .withName(FieldName.create('Tags')._unsafeUnwrap()) + .withOptions([selectOption('Alpha'), selectOption('Beta')]) + .done(); + builder.view().defaultGrid().done(); + const tableWithTags = builder.build()._unsafeUnwrap(); + const tagsField = tableWithTags.getFields()[1]!; + const record = TableRecord.create({ + id: recordId('d'), + tableId: tableWithTags.id(), + fieldValues: [{ fieldId: tagsField.id(), value: cell(['Alpha', 'Beta']) }], + })._unsafeUnwrap(); + + expect(record.displayValue(tableWithTags, tagsField.id())._unsafeUnwrap()).toBe('Alpha, Beta'); + }); +}); diff --git a/packages/v2/core/src/domain/table/records/TableRecord.ts b/packages/v2/core/src/domain/table/records/TableRecord.ts index 3b5a89d44f..79fd795b78 100644 --- a/packages/v2/core/src/domain/table/records/TableRecord.ts +++ b/packages/v2/core/src/domain/table/records/TableRecord.ts @@ -92,11 +92,18 @@ export class TableRecord extends Entity { * single or multi-valued. */ displayName(table: Table): Result { + return this.displayValue(table, table.primaryFieldId()); + } + + /** + * Resolve a field value to its public display text using the owning Table's Field definition. + */ + displayValue(table: Table, fieldId: FieldId): Result { if (!this.tableIdValue.equals(table.id())) { return err( domainError.invariant({ code: 'record.table_mismatch', - message: 'Cannot resolve display name with a different table', + message: 'Cannot resolve display value with a different table', details: { recordTableId: this.tableIdValue.toString(), tableId: table.id().toString(), @@ -105,18 +112,20 @@ export class TableRecord extends Entity { ); } - return table.primaryField().andThen((field) => - field.isMultipleCellValue().map((multiplicity) => { - const primaryValue = this.fieldsValue.get(field.id())?.toValue(); + return table + .getField((field) => field.id().equals(fieldId)) + .andThen((field) => + field.isMultipleCellValue().map((multiplicity) => { + const value = this.fieldsValue.get(field.id())?.toValue(); - if (multiplicity.isMultiple()) { - const displayValues = normalizeCellDisplayValues(primaryValue); - return displayValues.length > 0 ? displayValues.join(', ') : null; - } + if (multiplicity.isMultiple()) { + const displayValues = normalizeCellDisplayValues(value); + return displayValues.length > 0 ? displayValues.join(', ') : null; + } - return normalizeCellDisplayValue(primaryValue); - }) - ); + return normalizeCellDisplayValue(value); + }) + ); } /** diff --git a/packages/v2/core/src/domain/table/records/TableRecordAggregation.ts b/packages/v2/core/src/domain/table/records/TableRecordAggregation.ts new file mode 100644 index 0000000000..fb4ac92a20 --- /dev/null +++ b/packages/v2/core/src/domain/table/records/TableRecordAggregation.ts @@ -0,0 +1,61 @@ +import type { FieldId } from '../fields/FieldId'; + +export const tableRecordAggregationFunctionValues = [ + 'count', + 'empty', + 'filled', + 'unique', + 'max', + 'min', + 'sum', + 'average', + 'checked', + 'unChecked', + 'percentEmpty', + 'percentFilled', + 'percentUnique', + 'percentChecked', + 'percentUnChecked', + 'earliestDate', + 'latestDate', + 'dateRangeOfDays', + 'dateRangeOfMonths', + 'totalAttachmentSize', +] as const; + +export type TableRecordAggregationFunction = (typeof tableRecordAggregationFunctionValues)[number]; + +export type TableRecordAggregationFieldInput = { + readonly fieldId: string; + readonly statisticFunc: string; +}; + +export type TableRecordAggregationField = { + readonly fieldId: FieldId; + readonly statisticFunc: TableRecordAggregationFunction; +}; + +export type TableRecordAggregationGroupInput = { + readonly fieldId: string; + readonly order: 'asc' | 'desc'; +}; + +export type TableRecordAggregationGroup = { + readonly fieldId: FieldId; + readonly fieldType: string; + readonly order: 'asc' | 'desc'; +}; + +export class TableRecordAggregation { + private constructor( + readonly fields: ReadonlyArray, + readonly groupBy: ReadonlyArray + ) {} + + static create( + fields: ReadonlyArray, + groupBy: ReadonlyArray + ): TableRecordAggregation { + return new TableRecordAggregation([...fields], [...groupBy]); + } +} diff --git a/packages/v2/core/src/domain/table/records/TableRecordCalendarDailyCollection.ts b/packages/v2/core/src/domain/table/records/TableRecordCalendarDailyCollection.ts new file mode 100644 index 0000000000..e5e370968e --- /dev/null +++ b/packages/v2/core/src/domain/table/records/TableRecordCalendarDailyCollection.ts @@ -0,0 +1,22 @@ +import type { FieldId } from '../fields/FieldId'; +import type { TimeZone } from '../fields/types/TimeZone'; + +export class TableRecordCalendarDailyCollection { + private constructor( + readonly startFieldId: FieldId, + readonly endFieldId: FieldId, + readonly timeZone: TimeZone + ) {} + + static create(params: { + startFieldId: FieldId; + endFieldId: FieldId; + timeZone: TimeZone; + }): TableRecordCalendarDailyCollection { + return new TableRecordCalendarDailyCollection( + params.startFieldId, + params.endFieldId, + params.timeZone + ); + } +} diff --git a/packages/v2/core/src/domain/table/records/specs/ConditionNullSemantics.spec.ts b/packages/v2/core/src/domain/table/records/specs/ConditionNullSemantics.spec.ts new file mode 100644 index 0000000000..6b3cc5b71a --- /dev/null +++ b/packages/v2/core/src/domain/table/records/specs/ConditionNullSemantics.spec.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../../base/BaseId'; +import { FieldId } from '../../fields/FieldId'; +import { FieldName } from '../../fields/FieldName'; +import { ConditionalLookupField } from '../../fields/types/ConditionalLookupField'; +import { ConditionalLookupOptions } from '../../fields/types/ConditionalLookupOptions'; +import { LookupField } from '../../fields/types/LookupField'; +import { LookupOptions } from '../../fields/types/LookupOptions'; +import { SelectOption } from '../../fields/types/SelectOption'; +import { Table } from '../../Table'; +import { TableId } from '../../TableId'; +import { TableName } from '../../TableName'; +import { CheckboxConditionSpec } from './CheckboxConditionSpec'; +import { + conditionNullMatch, + conditionNullMatchForSpec, + fieldIsArrayLikeForFilter, +} from './ConditionNullSemantics'; +import { + RecordConditionFieldReferenceValue, + RecordConditionLiteralListValue, + RecordConditionLiteralValue, +} from './RecordConditionValues'; + +const buildTable = () => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Null Semantics')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Title')._unsafeUnwrap()).done(); + builder.field().checkbox().withName(FieldName.create('Done')._unsafeUnwrap()).done(); + builder.field().number().withName(FieldName.create('Score')._unsafeUnwrap()).done(); + builder + .field() + .multipleSelect() + .withName(FieldName.create('Tags')._unsafeUnwrap()) + .withOptions([SelectOption.create({ name: 'a', color: 'blue' })._unsafeUnwrap()]) + .done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +describe('conditionNullMatch', () => { + it('classifies emptiness and literal negatives', () => { + const table = buildTable(); + const title = table.getField((f) => f.name().toString() === 'Title')._unsafeUnwrap(); + expect(conditionNullMatch(title, 'isEmpty')).toBe('true'); + expect(conditionNullMatch(title, 'isNotEmpty')).toBe('false'); + expect(conditionNullMatch(title, 'isNot')).toBe('true'); + expect(conditionNullMatch(title, 'is')).toBe('unknown'); + expect(conditionNullMatch(title, 'contains')).toBe('unknown'); + }); + + it('doesNotContain empty string is false on NULL (NOT ILIKE %%)', () => { + const table = buildTable(); + const title = table.getField((f) => f.name().toString() === 'Title')._unsafeUnwrap(); + const empty = RecordConditionLiteralValue.create('')._unsafeUnwrap(); + const nonEmpty = RecordConditionLiteralValue.create('x')._unsafeUnwrap(); + expect(conditionNullMatch(title, 'doesNotContain', empty)).toBe('false'); + expect(conditionNullMatch(title, 'doesNotContain', nonEmpty)).toBe('true'); + }); + + it('only CheckboxConditionSpec maps NULL to unchecked (not plain boolean equality)', () => { + const table = buildTable(); + const done = table.getField((f) => f.name().toString() === 'Done')._unsafeUnwrap(); + const title = table.getField((f) => f.name().toString() === 'Title')._unsafeUnwrap(); + const falseLit = RecordConditionLiteralValue.create(false)._unsafeUnwrap(); + const trueLit = RecordConditionLiteralValue.create(true)._unsafeUnwrap(); + + const checkboxSpec = CheckboxConditionSpec.create(done, 'is', falseLit); + expect(conditionNullMatchForSpec(checkboxSpec)).toBe('true'); + expect(conditionNullMatchForSpec(CheckboxConditionSpec.create(done, 'is', trueLit))).toBe( + 'false' + ); + + const checkboxLookup = LookupField.create({ + id: FieldId.create(`fld${'l'.repeat(16)}`)._unsafeUnwrap(), + name: FieldName.create('Lookup Done')._unsafeUnwrap(), + innerField: done, + lookupOptions: LookupOptions.create({ + linkFieldId: `fld${'k'.repeat(16)}`, + lookupFieldId: done.id().toString(), + foreignTableId: `tbl${'f'.repeat(16)}`, + })._unsafeUnwrap(), + isMultipleCellValue: false, + })._unsafeUnwrap(); + const lookupSpec = checkboxLookup + .spec() + .create({ operator: 'is', value: falseLit }) + ._unsafeUnwrap(); + expect(lookupSpec).toBeInstanceOf(CheckboxConditionSpec); + expect(conditionNullMatchForSpec(lookupSpec)).toBe('true'); + + // Non-checkbox field form: ordinary equality stays UNKNOWN. + expect(conditionNullMatch(title, 'is', falseLit)).toBe('unknown'); + }); + + it('classifies multi-value positive ops and comparisons as false on NULL', () => { + const table = buildTable(); + const tags = table.getField((f) => f.name().toString() === 'Tags')._unsafeUnwrap(); + expect(fieldIsArrayLikeForFilter(tags)).toBe(true); + const list = RecordConditionLiteralListValue.create(['a'])._unsafeUnwrap(); + expect(conditionNullMatch(tags, 'hasAnyOf', list)).toBe('false'); + expect(conditionNullMatch(tags, 'isExactly', list)).toBe('false'); + expect(conditionNullMatch(tags, 'hasNoneOf', list)).toBe('true'); + expect(conditionNullMatch(tags, 'isNotExactly', list)).toBe('true'); + // Multi comparisons use EXISTS over [] → definite false. + expect( + conditionNullMatch(tags, 'isGreater', RecordConditionLiteralValue.create(5)._unsafeUnwrap()) + ).toBe('false'); + }); + + it('classifies real lookup and conditional-lookup comparison dispatch', () => { + const table = buildTable(); + const score = table.getField((f) => f.name().toString() === 'Score')._unsafeUnwrap(); + const scoreLookup = LookupField.create({ + id: FieldId.create(`fld${'q'.repeat(16)}`)._unsafeUnwrap(), + name: FieldName.create('Lookup Score')._unsafeUnwrap(), + innerField: score, + lookupOptions: LookupOptions.create({ + linkFieldId: `fld${'r'.repeat(16)}`, + lookupFieldId: score.id().toString(), + foreignTableId: `tbl${'s'.repeat(16)}`, + })._unsafeUnwrap(), + isMultipleCellValue: true, + })._unsafeUnwrap(); + const numberValue = RecordConditionLiteralValue.create(5)._unsafeUnwrap(); + const lookupSpec = scoreLookup + .spec() + .create({ operator: 'isGreater', value: numberValue }) + ._unsafeUnwrap(); + expect(conditionNullMatchForSpec(lookupSpec)).toBe('false'); + + const conditionalLookup = ConditionalLookupField.create({ + id: FieldId.create(`fld${'c'.repeat(16)}`)._unsafeUnwrap(), + name: FieldName.create('Conditional Score')._unsafeUnwrap(), + innerField: score, + conditionalLookupOptions: ConditionalLookupOptions.create({ + foreignTableId: TableId.create(`tbl${'d'.repeat(16)}`) + ._unsafeUnwrap() + .toString(), + lookupFieldId: score.id().toString(), + condition: { + filter: { + conjunction: 'and', + filterSet: [{ fieldId: score.id().toString(), operator: 'isNotEmpty' }], + }, + }, + })._unsafeUnwrap(), + isMultipleCellValue: true, + })._unsafeUnwrap(); + const conditionalSpec = conditionalLookup + .spec() + .create({ operator: 'isGreater', value: numberValue }) + ._unsafeUnwrap(); + expect(conditionNullMatchForSpec(conditionalSpec)).toBe('true'); + }); + + it('returns dynamic for field-reference RHS', () => { + const table = buildTable(); + const title = table.getField((f) => f.name().toString() === 'Title')._unsafeUnwrap(); + const fieldRef = RecordConditionFieldReferenceValue.create(title)._unsafeUnwrap(); + expect(conditionNullMatch(title, 'isNot', fieldRef)).toBe('dynamic'); + }); +}); diff --git a/packages/v2/core/src/domain/table/records/specs/ConditionNullSemantics.ts b/packages/v2/core/src/domain/table/records/specs/ConditionNullSemantics.ts new file mode 100644 index 0000000000..efdd2eacf1 --- /dev/null +++ b/packages/v2/core/src/domain/table/records/specs/ConditionNullSemantics.ts @@ -0,0 +1,214 @@ +import { Field } from '../../fields/Field'; +import { FieldType } from '../../fields/FieldType'; +import { FieldValueTypeVisitor } from '../../fields/visitors/FieldValueTypeVisitor'; +import { CheckboxConditionSpec } from './CheckboxConditionSpec'; +import { ConditionalLookupConditionSpec } from './ConditionalLookupConditionSpec'; +import type { RecordConditionOperator } from './RecordConditionOperators'; +import type { RecordValueConditionSpec } from './RecordConditionSpec'; +import { + isRecordConditionFieldReferenceValue, + isRecordConditionLiteralListValue, + isRecordConditionLiteralValue, + type RecordConditionValue, +} from './RecordConditionValues'; + +/** + * How a condition matches when the LHS cell is SQL NULL + * (including CASE WHEN mask THEN value END when the field is hidden). + * + * Align with Postgres predicates in TableRecordConditionWhereVisitor and with + * the **canonical condition spec** produced by FieldConditionSpecBuilder + * (e.g. Lookup<Checkbox> → CheckboxConditionSpec). + * + * - true: definite WHERE match + * - false: definite non-match + * - unknown: three-valued SQL + * - dynamic: row-dependent (field-reference RHS); callers must fail closed + */ +export type ConditionNullMatch = 'true' | 'false' | 'unknown' | 'dynamic'; + +const LITERAL_TRUE_ON_NULL = new Set(['isEmpty', 'isNot', 'hasNoneOf']); + +const ALWAYS_FALSE_ON_NULL = new Set(['isNotEmpty']); + +/** Positive membership / equality on array-like storage (NULL → `[]` first). */ +const ARRAY_LIKE_FALSE_ON_NULL = new Set([ + 'hasAnyOf', + 'hasAllOf', + 'isExactly', + 'isAnyOf', + 'is', + 'contains', +]); + +/** Numeric / date comparisons executed via EXISTS over JSON arrays. */ +const ARRAY_LIKE_COMPARISON_OPS = new Set([ + 'isGreater', + 'isGreaterEqual', + 'isLess', + 'isLessEqual', + 'isBefore', + 'isAfter', + 'isOnOrBefore', + 'isOnOrAfter', + 'isWithIn', +]); + +const jsonFieldSpecResult = Field.specs().isJson().build(); + +const fieldIsJsonForFilter = (field: Field): boolean => + jsonFieldSpecResult.isOk() && jsonFieldSpecResult.value.isSatisfiedBy(field); + +/** + * Match SQL visitor `isArrayLikeOutputField`: declared multi OR lookup / + * conditionalLookup output (forced to arrays for v1 parity). + */ +export const fieldIsArrayLikeForFilter = (field: Field): boolean => { + const type = field.type(); + if ( + type.equals(FieldType.multipleSelect()) || + type.equals(FieldType.attachment()) || + type.equals(FieldType.lookup()) || + type.equals(FieldType.conditionalLookup()) + ) { + return true; + } + const valueType = field.accept(new FieldValueTypeVisitor()); + if (valueType.isErr()) { + return false; + } + return valueType.value.isMultipleCellValue.isMultiple(); +}; + +const isEmptyLiteralList = (value: RecordConditionValue | undefined): boolean => { + if (!value || !isRecordConditionLiteralListValue(value)) { + return false; + } + return value.toValues().length === 0; +}; + +const isEmptyStringLiteral = (value: RecordConditionValue | undefined): boolean => + isRecordConditionLiteralValue(value) && value.toValue() === ''; + +const literalListContainsEmptyString = (value: RecordConditionValue | undefined): boolean => + isRecordConditionLiteralListValue(value) && value.toValues().some((item) => item === ''); + +const checkboxIsFalseNullMatch = (value: RecordConditionValue | undefined): ConditionNullMatch => { + if (isRecordConditionLiteralValue(value) && value.toValue() === false) { + return 'true'; + } + if (isRecordConditionLiteralValue(value) && value.toValue() === true) { + return 'false'; + } + return 'false'; +}; + +/** + * NULL-match for a **canonical** {@link RecordValueConditionSpec} (post + * FieldConditionSpecBuilder). Prefer this over field+operator alone so Lookup + * of Checkbox and ConditionalLookup special dispatch stay aligned with SQL. + */ +export const conditionNullMatchForSpec = ( + conditionSpec: RecordValueConditionSpec +): ConditionNullMatch => { + const field = conditionSpec.field(); + const operator = conditionSpec.operator() as RecordConditionOperator; + const value = conditionSpec.value(); + + if (value != null && isRecordConditionFieldReferenceValue(value)) { + return 'dynamic'; + } + + // CheckboxConditionSpec includes Lookup<Checkbox> (builder uses effective inner type). + if (conditionSpec instanceof CheckboxConditionSpec) { + return checkboxIsFalseNullMatch(value); + } + + // ConditionalLookup: numeric/date operators are dispatched as isEmpty in SQL. + if ( + conditionSpec instanceof ConditionalLookupConditionSpec && + ARRAY_LIKE_COMPARISON_OPS.has(operator) + ) { + return 'true'; + } + + return conditionNullMatch(field, operator, value); +}; + +/** + * Field + canonical operator/value form (no spec instance). Prefer + * {@link conditionNullMatchForSpec} when a built condition spec is available. + */ +export const conditionNullMatch = ( + field: Field, + operator: RecordConditionOperator, + value?: RecordConditionValue +): ConditionNullMatch => { + if (value != null && isRecordConditionFieldReferenceValue(value)) { + return 'dynamic'; + } + + if (operator === 'isEmpty') { + return 'true'; + } + if (ALWAYS_FALSE_ON_NULL.has(operator)) { + return 'false'; + } + + // Physical checkbox only in field-only form (no Lookup unwrapping). + if (field.type().equals(FieldType.checkbox()) && operator === 'is') { + return checkboxIsFalseNullMatch(value); + } + + // Scalar text uses COALESCE(NULL, '') NOT ILIKE '%%' → false. + // Array/JSON storage checks for matching elements first; [] has none, so NOT(false) → true. + if (operator === 'doesNotContain') { + if ( + isEmptyStringLiteral(value) && + !fieldIsArrayLikeForFilter(field) && + !fieldIsJsonForFilter(field) + ) { + return 'false'; + } + return 'true'; + } + + // Scalar list negatives use COALESCE(NULL, '') NOT IN (...). + // Array-like storage instead tests membership against [] and remains true. + if (operator === 'isNoneOf') { + if (!fieldIsArrayLikeForFilter(field) && literalListContainsEmptyString(value)) { + return 'false'; + } + return 'true'; + } + + if (operator === 'isNotExactly') { + if (isEmptyLiteralList(value)) { + return 'false'; + } + if (fieldIsArrayLikeForFilter(field)) { + return 'true'; + } + return 'true'; + } + + if (LITERAL_TRUE_ON_NULL.has(operator)) { + return 'true'; + } + + if (fieldIsArrayLikeForFilter(field)) { + if (ARRAY_LIKE_FALSE_ON_NULL.has(operator)) { + return 'false'; + } + // EXISTS over empty JSON array for numeric/date comparisons. + if (ARRAY_LIKE_COMPARISON_OPS.has(operator)) { + // ConditionalLookup comparison→isEmpty is handled in conditionNullMatchForSpec. + if (field.type().equals(FieldType.conditionalLookup())) { + return 'true'; + } + return 'false'; + } + } + + return 'unknown'; +}; diff --git a/packages/v2/core/src/domain/table/records/specs/RecordConditionOperators.ts b/packages/v2/core/src/domain/table/records/specs/RecordConditionOperators.ts index bf2d400bc8..59bca9fd7d 100644 --- a/packages/v2/core/src/domain/table/records/specs/RecordConditionOperators.ts +++ b/packages/v2/core/src/domain/table/records/specs/RecordConditionOperators.ts @@ -160,6 +160,7 @@ export const recordConditionDateModeSchema = z.enum([ 'daysAgo', 'daysFromNow', 'exactDate', + 'dateRange', 'exactDateTime', 'exactFormatDate', 'pastWeek', diff --git a/packages/v2/core/src/domain/table/records/specs/RecordConditionValues.spec.ts b/packages/v2/core/src/domain/table/records/specs/RecordConditionValues.spec.ts index 467f241b1b..628798d788 100644 --- a/packages/v2/core/src/domain/table/records/specs/RecordConditionValues.spec.ts +++ b/packages/v2/core/src/domain/table/records/specs/RecordConditionValues.spec.ts @@ -77,6 +77,13 @@ describe('RecordConditionValues', () => { exactDate: '2024-01-02T00:00:00.000Z', timeZone: 'utc', })._unsafeUnwrap(); + expect( + RecordConditionDateValue.create({ + mode: 'exactDate', + exactDate: '2024-01-02T00:00:00Z', + timeZone: 'utc', + }).isOk() + ).toBe(true); const same = RecordConditionDateValue.create({ mode: 'exactDate', exactDate: '2024-01-02T00:00:00.000Z', diff --git a/packages/v2/core/src/domain/table/records/specs/RecordConditionValues.ts b/packages/v2/core/src/domain/table/records/specs/RecordConditionValues.ts index 9d2bef77c0..9174220c28 100644 --- a/packages/v2/core/src/domain/table/records/specs/RecordConditionValues.ts +++ b/packages/v2/core/src/domain/table/records/specs/RecordConditionValues.ts @@ -18,10 +18,20 @@ const dateValueSchema = z .object({ mode: recordConditionDateModeSchema, numberOfDays: z.coerce.number().int().nonnegative().optional(), - exactDate: z.string().datetime({ precision: 3, offset: true }).optional(), + exactDate: z.string().datetime({ offset: true }).optional(), + exactDateEnd: z.string().datetime({ offset: true }).optional(), timeZone: z.string(), }) .superRefine((val, ctx) => { + if (val.mode === 'dateRange') { + if (!val.exactDate || !val.exactDateEnd) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "When mode is 'dateRange', exactDate and exactDateEnd are required", + }); + } + return; + } const requiresExact = val.mode === 'exactDate' || val.mode === 'exactDateTime' || val.mode === 'exactFormatDate'; const requiresDays = @@ -97,6 +107,7 @@ export class RecordConditionDateValue extends ValueObject { private readonly modeValue: RecordConditionDateMode, private readonly numberOfDaysValue: number | undefined, private readonly exactDateValue: string | undefined, + private readonly exactDateEndValue: string | undefined, private readonly timeZoneValue: TimeZone ) { super(); @@ -115,6 +126,7 @@ export class RecordConditionDateValue extends ValueObject { parsed.data.mode, parsed.data.numberOfDays, parsed.data.exactDate, + parsed.data.exactDateEnd, timeZoneResult.value ) ); @@ -125,6 +137,7 @@ export class RecordConditionDateValue extends ValueObject { this.modeValue === other.modeValue && this.numberOfDaysValue === other.numberOfDaysValue && this.exactDateValue === other.exactDateValue && + this.exactDateEndValue === other.exactDateEndValue && this.timeZoneValue.equals(other.timeZoneValue) ); } @@ -141,6 +154,10 @@ export class RecordConditionDateValue extends ValueObject { return this.exactDateValue; } + exactDateEnd(): string | undefined { + return this.exactDateEndValue; + } + timeZone(): TimeZone { return this.timeZoneValue; } @@ -150,6 +167,7 @@ export class RecordConditionDateValue extends ValueObject { mode: this.modeValue, numberOfDays: this.numberOfDaysValue, exactDate: this.exactDateValue, + exactDateEnd: this.exactDateEndValue, timeZone: this.timeZoneValue.toString(), }; } diff --git a/packages/v2/core/src/domain/table/records/specs/values/ICellValueSpecVisitor.ts b/packages/v2/core/src/domain/table/records/specs/values/ICellValueSpecVisitor.ts index e0f1b3c9e2..a70560ef29 100644 --- a/packages/v2/core/src/domain/table/records/specs/values/ICellValueSpecVisitor.ts +++ b/packages/v2/core/src/domain/table/records/specs/values/ICellValueSpecVisitor.ts @@ -8,6 +8,7 @@ import type { TableRecord } from '../../TableRecord'; // Forward declarations for SetValueSpec types import type { ClearFieldValueSpec } from './ClearFieldValueSpec'; import type { SetAttachmentValueSpec } from './SetAttachmentValueSpec'; +import type { SetButtonValueSpec } from './SetButtonValueSpec'; import type { SetCheckboxValueSpec } from './SetCheckboxValueSpec'; import type { SetDateValueSpec } from './SetDateValueSpec'; import type { SetLinkValueByTitleSpec } from './SetLinkValueByTitleSpec'; @@ -16,11 +17,11 @@ import type { SetLongTextValueSpec } from './SetLongTextValueSpec'; import type { SetMultipleSelectValueSpec } from './SetMultipleSelectValueSpec'; import type { SetNumberValueSpec } from './SetNumberValueSpec'; import type { SetRatingValueSpec } from './SetRatingValueSpec'; +import type { SetRowOrderValueSpec } from './SetRowOrderValueSpec'; import type { SetSingleLineTextValueSpec } from './SetSingleLineTextValueSpec'; import type { SetSingleSelectValueSpec } from './SetSingleSelectValueSpec'; import type { SetUserValueByIdentifierSpec } from './SetUserValueByIdentifierSpec'; import type { SetUserValueSpec } from './SetUserValueSpec'; -import type { SetRowOrderValueSpec } from './SetRowOrderValueSpec'; /** * Base interface for cell value mutation specifications. @@ -55,6 +56,7 @@ export interface ICellValueSpecVisitor extends ISpecVisitor { visitSetCheckboxValue(spec: SetCheckboxValueSpec): Result; visitSetDateValue(spec: SetDateValueSpec): Result; visitSetAttachmentValue(spec: SetAttachmentValueSpec): Result; + visitSetButtonValue(spec: SetButtonValueSpec): Result; visitSetLinkValue(spec: SetLinkValueSpec): Result; visitSetUserValue(spec: SetUserValueSpec): Result; visitSetUserValueByIdentifier(spec: SetUserValueByIdentifierSpec): Result; diff --git a/packages/v2/core/src/domain/table/records/specs/values/SetButtonValueSpec.ts b/packages/v2/core/src/domain/table/records/specs/values/SetButtonValueSpec.ts new file mode 100644 index 0000000000..b9c0b4cdd8 --- /dev/null +++ b/packages/v2/core/src/domain/table/records/specs/values/SetButtonValueSpec.ts @@ -0,0 +1,35 @@ +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../../../shared/DomainError'; +import { MutateOnlySpec } from '../../../../shared/specification/MutateOnlySpec'; +import type { FieldId } from '../../../fields/FieldId'; +import type { TableRecord } from '../../TableRecord'; +import type { CellValue } from '../../values/CellValue'; +import type { ICellValueSpecVisitor } from './ICellValueSpecVisitor'; + +export type ButtonCellValue = { + readonly count: number; +}; + +/** + * Internal Button value mutation. + * + * Generic record input never creates this spec. It is only produced by the + * owning Table aggregate after validating Button workflow and click limits. + */ +export class SetButtonValueSpec extends MutateOnlySpec { + constructor( + readonly fieldId: FieldId, + readonly value: CellValue + ) { + super(); + } + + mutate(record: TableRecord): Result { + return record.setFieldValue(this.fieldId, this.value); + } + + accept(visitor: ICellValueSpecVisitor): Result { + return visitor.visitSetButtonValue(this); + } +} diff --git a/packages/v2/core/src/domain/table/records/specs/values/SetFieldValueSpecFactory.ts b/packages/v2/core/src/domain/table/records/specs/values/SetFieldValueSpecFactory.ts index 5c8c0ea231..0166942b83 100644 --- a/packages/v2/core/src/domain/table/records/specs/values/SetFieldValueSpecFactory.ts +++ b/packages/v2/core/src/domain/table/records/specs/values/SetFieldValueSpecFactory.ts @@ -1,3 +1,4 @@ +import { sdkErrorI18nKeys } from '@teable/i18n-keys'; import { err, ok } from 'neverthrow'; import type { Result } from 'neverthrow'; @@ -10,9 +11,9 @@ import { type FieldCellValueSchema, } from '../../../fields/visitors/FieldCellValueSchemaVisitor'; import { SetFieldValueSpecFactoryVisitor } from '../../../fields/visitors/SetFieldValueSpecFactoryVisitor'; +import { ClearFieldValueSpec } from './ClearFieldValueSpec'; import type { ICellValueSpec } from './ICellValueSpecVisitor'; import { NoopCellValueSpec } from './NoopCellValueSpec'; -import { ClearFieldValueSpec } from './ClearFieldValueSpec'; /** * Factory for creating SetValueSpec instances. @@ -55,6 +56,10 @@ export class SetFieldValueSpecFactory { fieldName: field.name().toString(), fieldType: field.type().toString(), }, + localization: { + i18nKey: sdkErrorI18nKeys.custom.recordFieldValueNotNull, + context: { fieldName: field.name().toString() }, + }, }) ); } diff --git a/packages/v2/core/src/domain/table/specs/ARCHITECTURE.md b/packages/v2/core/src/domain/table/specs/ARCHITECTURE.md index 7fac0c00b2..2a02c886d4 100644 --- a/packages/v2/core/src/domain/table/specs/ARCHITECTURE.md +++ b/packages/v2/core/src/domain/table/specs/ARCHITECTURE.md @@ -15,6 +15,9 @@ Declaration: If the folder I belong to changes, please update me, especially cor - `ITableSpecVisitor.ts` - Role: visitor interface; Purpose: generic table-specific visit methods for query/update translation payloads. - `TableAddFieldSpec.ts` - Role: mutate spec; Purpose: append a field to a table. - `TableRemoveFieldSpec.ts` - Role: mutate spec; Purpose: remove a field from a table. +- `TableRemoveViewSpec.ts` - Role: mutate spec; Purpose: remove an owned View from a Table aggregate. +- `TableEnsureViewRowOrderSpec.ts` - Role: schema intent spec; Purpose: ensure physical row-order + storage for an existing aggregate-owned Grid View without adding or mutating that View. - `TableUpdateViewColumnMetaSpec.ts` - Role: mutate spec; Purpose: carry view column meta updates during table mutations. - `TableByBaseIdSpec.ts` - Role: spec; Purpose: filter by BaseId. - `TableByIdSpec.ts` - Role: spec; Purpose: filter by TableId. diff --git a/packages/v2/core/src/domain/table/specs/ITableSpecVisitor.ts b/packages/v2/core/src/domain/table/specs/ITableSpecVisitor.ts index ca706bcea5..9c2e328217 100644 --- a/packages/v2/core/src/domain/table/specs/ITableSpecVisitor.ts +++ b/packages/v2/core/src/domain/table/specs/ITableSpecVisitor.ts @@ -47,15 +47,20 @@ import type { import type { TableAddFieldSpec } from './TableAddFieldSpec'; import type { TableAddFieldsSpec } from './TableAddFieldsSpec'; import type { TableAddSelectOptionsSpec } from './TableAddSelectOptionsSpec'; +import type { TableAddViewSpec } from './TableAddViewSpec'; import type { TableByBaseIdSpec } from './TableByBaseIdSpec'; import type { TableByIdSpec } from './TableByIdSpec'; import type { TableByIdsSpec } from './TableByIdsSpec'; import type { TableByIncomingReferenceToTableSpec } from './TableByIncomingReferenceToTableSpec'; import type { TableByNameLikeSpec } from './TableByNameLikeSpec'; import type { TableByNameSpec } from './TableByNameSpec'; +import type { TableByViewIdSpec } from './TableByViewIdSpec'; import type { TableDuplicateFieldSpec } from './TableDuplicateFieldSpec'; +import type { TableEnsureViewRowOrderSpec } from './TableEnsureViewRowOrderSpec'; import type { TableRemoveFieldSpec } from './TableRemoveFieldSpec'; +import type { TableRemoveViewSpec } from './TableRemoveViewSpec'; import type { TableRenameSpec } from './TableRenameSpec'; +import type { TableRenameViewSpec } from './TableRenameViewSpec'; import type { TableUpdateFieldAiConfigSpec } from './TableUpdateFieldAiConfigSpec'; import type { TableUpdateFieldConstraintsSpec } from './TableUpdateFieldConstraintsSpec'; import type { TableUpdateFieldDbFieldNameSpec } from './TableUpdateFieldDbFieldNameSpec'; @@ -63,23 +68,48 @@ import type { TableUpdateFieldDescriptionSpec } from './TableUpdateFieldDescript import type { TableUpdateFieldHasErrorSpec } from './TableUpdateFieldHasErrorSpec'; import type { TableUpdateFieldNameSpec } from './TableUpdateFieldNameSpec'; import type { TableUpdateFieldTypeSpec } from './TableUpdateFieldTypeSpec'; +import type { TableUpdatePropertiesSpec } from './TableUpdatePropertiesSpec'; import type { TableUpdateViewColumnMetaSpec } from './TableUpdateViewColumnMetaSpec'; +import type { TableUpdateViewDescriptionSpec } from './TableUpdateViewDescriptionSpec'; +import type { TableUpdateViewLockedSpec } from './TableUpdateViewLockedSpec'; +import type { TableUpdateViewOptionsSpec } from './TableUpdateViewOptionsSpec'; +import type { TableUpdateViewOrderSpec } from './TableUpdateViewOrderSpec'; import type { TableUpdateViewQueryDefaultsSpec } from './TableUpdateViewQueryDefaultsSpec'; +import type { TableUpdateViewShareIdSpec } from './TableUpdateViewShareIdSpec'; +import type { TableUpdateViewShareMetaSpec } from './TableUpdateViewShareMetaSpec'; +import type { TableUpdateViewShareStateSpec } from './TableUpdateViewShareStateSpec'; +import type { TableWithViewIdsSpec } from './TableWithViewIdsSpec'; export interface ITableSpecVisitor extends ISpecVisitor { // ============ Existing specs ============ visitTableAddField(spec: TableAddFieldSpec): Result; visitTableAddFields(spec: TableAddFieldsSpec): Result; + visitTableAddView(spec: TableAddViewSpec): Result; + visitTableEnsureViewRowOrder(spec: TableEnsureViewRowOrderSpec): Result; + visitTableRemoveView(spec: TableRemoveViewSpec): Result; + visitTableRenameView(spec: TableRenameViewSpec): Result; + visitTableUpdateViewDescription( + spec: TableUpdateViewDescriptionSpec + ): Result; + visitTableUpdateViewLocked(spec: TableUpdateViewLockedSpec): Result; + visitTableUpdateViewOrder(spec: TableUpdateViewOrderSpec): Result; visitTableAddSelectOptions(spec: TableAddSelectOptionsSpec): Result; visitTableDuplicateField(spec: TableDuplicateFieldSpec): Result; visitTableRemoveField(spec: TableRemoveFieldSpec): Result; visitTableUpdateViewColumnMeta(spec: TableUpdateViewColumnMetaSpec): Result; + visitTableUpdateViewOptions(spec: TableUpdateViewOptionsSpec): Result; + visitTableUpdateViewShareId(spec: TableUpdateViewShareIdSpec): Result; + visitTableUpdateViewShareMeta(spec: TableUpdateViewShareMetaSpec): Result; + visitTableUpdateViewShareState(spec: TableUpdateViewShareStateSpec): Result; visitTableUpdateViewQueryDefaults( spec: TableUpdateViewQueryDefaultsSpec ): Result; visitTableRename(spec: TableRenameSpec): Result; + visitTableUpdateProperties(spec: TableUpdatePropertiesSpec): Result; visitTableByBaseId(spec: TableByBaseIdSpec): Result; visitTableById(spec: TableByIdSpec): Result; + visitTableByViewId(spec: TableByViewIdSpec): Result; + visitTableWithViewIds(spec: TableWithViewIdsSpec): Result; visitTableByIncomingReferenceToTable( spec: TableByIncomingReferenceToTableSpec ): Result; diff --git a/packages/v2/core/src/domain/table/specs/TableAddViewSpec.ts b/packages/v2/core/src/domain/table/specs/TableAddViewSpec.ts new file mode 100644 index 0000000000..5d08a4afd3 --- /dev/null +++ b/packages/v2/core/src/domain/table/specs/TableAddViewSpec.ts @@ -0,0 +1,31 @@ +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import { MutateOnlySpec } from '../../shared/specification/MutateOnlySpec'; +import type { Table } from '../Table'; +import type { View } from '../views/View'; +import type { ITableSpecVisitor } from './ITableSpecVisitor'; + +export class TableAddViewSpec< + V extends ITableSpecVisitor = ITableSpecVisitor, +> extends MutateOnlySpec { + private constructor(private readonly viewValue: View) { + super(); + } + + static create(view: View): TableAddViewSpec { + return new TableAddViewSpec(view); + } + + view(): View { + return this.viewValue; + } + + mutate(table: Table): Result { + return table.addView(this.viewValue); + } + + accept(visitor: V): Result { + return visitor.visitTableAddView(this).map(() => undefined); + } +} diff --git a/packages/v2/core/src/domain/table/specs/TableByViewIdSpec.ts b/packages/v2/core/src/domain/table/specs/TableByViewIdSpec.ts new file mode 100644 index 0000000000..8cf57808fb --- /dev/null +++ b/packages/v2/core/src/domain/table/specs/TableByViewIdSpec.ts @@ -0,0 +1,38 @@ +import { ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import type { ISpecification } from '../../shared/specification/ISpecification'; +import type { Table } from '../Table'; +import type { ViewId } from '../views/ViewId'; +import type { ITableSpecVisitor } from './ITableSpecVisitor'; + +/** + * Selects a Table that contains the requested View and allows repository + * adapters to hydrate only that child View. + */ +export class TableByViewIdSpec + implements ISpecification +{ + private constructor(private readonly viewIdValue: ViewId) {} + + static create(viewId: ViewId): TableByViewIdSpec { + return new TableByViewIdSpec(viewId); + } + + viewId(): ViewId { + return this.viewIdValue; + } + + isSatisfiedBy(table: Table): boolean { + return table.getView(this.viewIdValue).isOk(); + } + + mutate(table: Table): Result { + return ok(table); + } + + accept(visitor: V): Result { + return visitor.visitTableByViewId(this).map(() => undefined); + } +} diff --git a/packages/v2/core/src/domain/table/specs/TableEnsureViewRowOrderSpec.ts b/packages/v2/core/src/domain/table/specs/TableEnsureViewRowOrderSpec.ts new file mode 100644 index 0000000000..8f4f434824 --- /dev/null +++ b/packages/v2/core/src/domain/table/specs/TableEnsureViewRowOrderSpec.ts @@ -0,0 +1,35 @@ +import { ok, type Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import { MutateOnlySpec } from '../../shared/specification/MutateOnlySpec'; +import type { Table } from '../Table'; +import type { View } from '../views/View'; +import type { ITableSpecVisitor } from './ITableSpecVisitor'; + +/** + * Declares that an existing aggregate-owned Grid View needs physical row-order + * storage. The specification does not add or mutate the View itself. + */ +export class TableEnsureViewRowOrderSpec< + V extends ITableSpecVisitor = ITableSpecVisitor, +> extends MutateOnlySpec { + private constructor(private readonly viewValue: View) { + super(); + } + + static create(view: View): TableEnsureViewRowOrderSpec { + return new TableEnsureViewRowOrderSpec(view); + } + + view(): View { + return this.viewValue; + } + + mutate(table: Table): Result { + return ok(table); + } + + accept(visitor: V): Result { + return visitor.visitTableEnsureViewRowOrder(this).map(() => undefined); + } +} diff --git a/packages/v2/core/src/domain/table/specs/TableRemoveViewSpec.ts b/packages/v2/core/src/domain/table/specs/TableRemoveViewSpec.ts new file mode 100644 index 0000000000..26e31766d1 --- /dev/null +++ b/packages/v2/core/src/domain/table/specs/TableRemoveViewSpec.ts @@ -0,0 +1,31 @@ +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import { MutateOnlySpec } from '../../shared/specification/MutateOnlySpec'; +import type { Table } from '../Table'; +import type { View } from '../views/View'; +import type { ITableSpecVisitor } from './ITableSpecVisitor'; + +export class TableRemoveViewSpec< + V extends ITableSpecVisitor = ITableSpecVisitor, +> extends MutateOnlySpec { + private constructor(private readonly viewValue: View) { + super(); + } + + static create(view: View): TableRemoveViewSpec { + return new TableRemoveViewSpec(view); + } + + view(): View { + return this.viewValue; + } + + mutate(table: Table): Result { + return table.removeView(this.viewValue.id()); + } + + accept(visitor: V): Result { + return visitor.visitTableRemoveView(this).map(() => undefined); + } +} diff --git a/packages/v2/core/src/domain/table/specs/TableRenameViewSpec.ts b/packages/v2/core/src/domain/table/specs/TableRenameViewSpec.ts new file mode 100644 index 0000000000..ab46b5d4c8 --- /dev/null +++ b/packages/v2/core/src/domain/table/specs/TableRenameViewSpec.ts @@ -0,0 +1,113 @@ +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import { MutateOnlySpec } from '../../shared/specification/MutateOnlySpec'; +import { Table } from '../Table'; +import type { ITableBuildProps } from '../TableBuilder'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; +import type { ViewName } from '../views/ViewName'; +import { CloneViewVisitor } from '../views/visitors/CloneViewVisitor'; +import type { ITableSpecVisitor } from './ITableSpecVisitor'; + +export class TableRenameViewSpec< + V extends ITableSpecVisitor = ITableSpecVisitor, +> extends MutateOnlySpec { + private constructor( + private readonly viewIdValue: ViewId, + private readonly previousNameValue: ViewName, + private readonly nextNameValue: ViewName + ) { + super(); + } + + static create(viewId: ViewId, previousName: ViewName, nextName: ViewName): TableRenameViewSpec { + return new TableRenameViewSpec(viewId, previousName, nextName); + } + + viewId(): ViewId { + return this.viewIdValue; + } + + previousName(): ViewName { + return this.previousNameValue; + } + + nextName(): ViewName { + return this.nextNameValue; + } + + mutate(table: Table): Result { + const targetResult = table.getView(this.viewIdValue); + if (targetResult.isErr()) return err(targetResult.error); + + if ( + table + .views() + .some( + (view) => !view.id().equals(this.viewIdValue) && view.name().equals(this.nextNameValue) + ) + ) { + return err(domainError.conflict({ message: 'View names must be unique' })); + } + + const nextViews: View[] = []; + for (const view of table.views()) { + if (!view.id().equals(this.viewIdValue)) { + nextViews.push(view); + continue; + } + + const cloneResult = view.accept(new CloneViewVisitor({ name: this.nextNameValue })); + if (cloneResult.isErr()) return err(cloneResult.error); + const clone = cloneResult.value; + + const columnMetaResult = view.columnMeta(); + if (columnMetaResult.isErr()) return err(columnMetaResult.error); + const setColumnMetaResult = clone.setColumnMeta(columnMetaResult.value); + if (setColumnMetaResult.isErr()) return err(setColumnMetaResult.error); + + const queryDefaultsResult = view.queryDefaults(); + if (queryDefaultsResult.isErr()) return err(queryDefaultsResult.error); + const setQueryDefaultsResult = clone.setQueryDefaults(queryDefaultsResult.value); + if (setQueryDefaultsResult.isErr()) return err(setQueryDefaultsResult.error); + + const auditMetadataResult = view.auditMetadata(); + if (auditMetadataResult.isOk()) { + const setAuditMetadataResult = clone.setAuditMetadata(auditMetadataResult.value); + if (setAuditMetadataResult.isErr()) return err(setAuditMetadataResult.error); + } + + nextViews.push(clone); + } + + const props: ITableBuildProps = { + id: table.id(), + baseId: table.baseId(), + name: table.name(), + properties: table.properties(), + fields: table.getFields(), + views: nextViews, + primaryFieldId: table.primaryFieldId(), + }; + const dbTableNameResult = table.dbTableName(); + if (dbTableNameResult.isOk()) props.dbTableName = dbTableNameResult.value; + + return Table.rehydrate(props).andThen((nextTable) => { + const renamedView = nextTable.views().find((view) => view.id().equals(this.viewIdValue)); + if (!renamedView) { + return err( + domainError.invariant({ + message: `Renamed View missing from Table: ${this.viewIdValue.toString()}`, + }) + ); + } + return ok(nextTable); + }); + } + + accept(visitor: V): Result { + return visitor.visitTableRenameView(this).map(() => undefined); + } +} diff --git a/packages/v2/core/src/domain/table/specs/TableSpecBuilder.spec.ts b/packages/v2/core/src/domain/table/specs/TableSpecBuilder.spec.ts index 8bc1b7c29f..5378b91540 100644 --- a/packages/v2/core/src/domain/table/specs/TableSpecBuilder.spec.ts +++ b/packages/v2/core/src/domain/table/specs/TableSpecBuilder.spec.ts @@ -173,6 +173,28 @@ describe('TableSpecBuilder', () => { expect(specResult._unsafeUnwrap().isSatisfiedBy(otherTable)).toBe(false); }); + it('supports selecting a Table by a child View id', () => { + const baseId = BaseId.create(`bse${'k'.repeat(16)}`)._unsafeUnwrap(); + const table = buildTable(baseId, TableName.create('Views')._unsafeUnwrap()); + const otherTable = buildTable(baseId, TableName.create('Other views')._unsafeUnwrap()); + + const spec = table.specs().withViewId(table.views()[0].id()).build()._unsafeUnwrap(); + + expect(spec.isSatisfiedBy(table)).toBe(true); + expect(spec.isSatisfiedBy(otherTable)).toBe(false); + }); + + it('supports narrowing hydrated View children without changing the Table match', () => { + const baseId = BaseId.create(`bse${'l'.repeat(16)}`)._unsafeUnwrap(); + const table = buildTable(baseId, TableName.create('Views')._unsafeUnwrap()); + const otherTable = buildTable(baseId, TableName.create('Other views')._unsafeUnwrap()); + + const spec = table.specs().withViewIds([table.views()[0].id()]).build()._unsafeUnwrap(); + + expect(spec.isSatisfiedBy(table)).toBe(true); + expect(spec.isSatisfiedBy(otherTable)).toBe(true); + }); + it('supports incoming-reference specs across bases', () => { const foreignBaseId = BaseId.create(`bse${'i'.repeat(16)}`)._unsafeUnwrap(); const hostBaseId = BaseId.create(`bse${'j'.repeat(16)}`)._unsafeUnwrap(); diff --git a/packages/v2/core/src/domain/table/specs/TableSpecBuilder.ts b/packages/v2/core/src/domain/table/specs/TableSpecBuilder.ts index b37aeb5c69..15021a1018 100644 --- a/packages/v2/core/src/domain/table/specs/TableSpecBuilder.ts +++ b/packages/v2/core/src/domain/table/specs/TableSpecBuilder.ts @@ -8,6 +8,7 @@ import type { SpecBuilderMode } from '../../shared/specification/SpecBuilder'; import type { Table } from '../Table'; import type { TableId } from '../TableId'; import type { TableName } from '../TableName'; +import type { ViewId } from '../views/ViewId'; import type { ITableSpecVisitor } from './ITableSpecVisitor'; import { TableByBaseIdSpec } from './TableByBaseIdSpec'; import { TableByIdSpec } from './TableByIdSpec'; @@ -15,6 +16,8 @@ import { TableByIdsSpec } from './TableByIdsSpec'; import { TableByIncomingReferenceToTableSpec } from './TableByIncomingReferenceToTableSpec'; import { TableByNameLikeSpec } from './TableByNameLikeSpec'; import { TableByNameSpec } from './TableByNameSpec'; +import { TableByViewIdSpec } from './TableByViewIdSpec'; +import { TableWithViewIdsSpec } from './TableWithViewIdsSpec'; export class TableSpecBuilder extends SpecBuilder { private includeBaseId = true; @@ -53,6 +56,16 @@ export class TableSpecBuilder extends SpecBuilder): TableSpecBuilder { + this.addSpec(TableWithViewIdsSpec.create(viewIds)); + return this; + } + byIncomingReferenceToTable(tableId: TableId): TableSpecBuilder { this.addSpec(TableByIncomingReferenceToTableSpec.create(tableId)); return this; diff --git a/packages/v2/core/src/domain/table/specs/TableSpecs.spec.ts b/packages/v2/core/src/domain/table/specs/TableSpecs.spec.ts index 4c5cde43ff..b8558d2ee5 100644 --- a/packages/v2/core/src/domain/table/specs/TableSpecs.spec.ts +++ b/packages/v2/core/src/domain/table/specs/TableSpecs.spec.ts @@ -7,6 +7,7 @@ import { FieldName } from '../fields/FieldName'; import { LinkFieldConfig } from '../fields/types/LinkFieldConfig'; import { Table } from '../Table'; import { TableName } from '../TableName'; +import { ViewId } from '../views/ViewId'; import type { UpdateButtonColorSpec, UpdateButtonLabelSpec, @@ -53,15 +54,20 @@ import type { ITableSpecVisitor } from './ITableSpecVisitor'; import type { TableAddFieldSpec } from './TableAddFieldSpec'; import type { TableAddFieldsSpec } from './TableAddFieldsSpec'; import type { TableAddSelectOptionsSpec } from './TableAddSelectOptionsSpec'; +import type { TableAddViewSpec } from './TableAddViewSpec'; import { TableByBaseIdSpec } from './TableByBaseIdSpec'; import { TableByIdSpec } from './TableByIdSpec'; import { TableByIdsSpec } from './TableByIdsSpec'; import { TableByIncomingReferenceToTableSpec } from './TableByIncomingReferenceToTableSpec'; import { TableByNameLikeSpec } from './TableByNameLikeSpec'; import { TableByNameSpec } from './TableByNameSpec'; +import { TableByViewIdSpec } from './TableByViewIdSpec'; import type { TableDuplicateFieldSpec } from './TableDuplicateFieldSpec'; +import type { TableEnsureViewRowOrderSpec } from './TableEnsureViewRowOrderSpec'; import type { TableRemoveFieldSpec } from './TableRemoveFieldSpec'; +import type { TableRemoveViewSpec } from './TableRemoveViewSpec'; import type { TableRenameSpec } from './TableRenameSpec'; +import type { TableRenameViewSpec } from './TableRenameViewSpec'; import type { TableUpdateFieldAiConfigSpec } from './TableUpdateFieldAiConfigSpec'; import type { TableUpdateFieldConstraintsSpec } from './TableUpdateFieldConstraintsSpec'; import type { TableUpdateFieldDbFieldNameSpec } from './TableUpdateFieldDbFieldNameSpec'; @@ -69,8 +75,17 @@ import type { TableUpdateFieldDescriptionSpec } from './TableUpdateFieldDescript import type { TableUpdateFieldHasErrorSpec } from './TableUpdateFieldHasErrorSpec'; import type { TableUpdateFieldNameSpec } from './TableUpdateFieldNameSpec'; import type { TableUpdateFieldTypeSpec } from './TableUpdateFieldTypeSpec'; +import type { TableUpdatePropertiesSpec } from './TableUpdatePropertiesSpec'; import type { TableUpdateViewColumnMetaSpec } from './TableUpdateViewColumnMetaSpec'; +import type { TableUpdateViewDescriptionSpec } from './TableUpdateViewDescriptionSpec'; +import type { TableUpdateViewLockedSpec } from './TableUpdateViewLockedSpec'; +import type { TableUpdateViewOptionsSpec } from './TableUpdateViewOptionsSpec'; +import type { TableUpdateViewOrderSpec } from './TableUpdateViewOrderSpec'; import type { TableUpdateViewQueryDefaultsSpec } from './TableUpdateViewQueryDefaultsSpec'; +import type { TableUpdateViewShareIdSpec } from './TableUpdateViewShareIdSpec'; +import type { TableUpdateViewShareMetaSpec } from './TableUpdateViewShareMetaSpec'; +import type { TableUpdateViewShareStateSpec } from './TableUpdateViewShareStateSpec'; +import { TableWithViewIdsSpec } from './TableWithViewIdsSpec'; class SpyVisitor implements ITableSpecVisitor { readonly calls: string[] = []; @@ -89,6 +104,53 @@ class SpyVisitor implements ITableSpecVisitor { return ok(undefined); } + visitTableAddView(_: TableAddViewSpec): ReturnType { + this.calls.push('TableAddViewSpec'); + return ok(undefined); + } + + visitTableEnsureViewRowOrder( + _: TableEnsureViewRowOrderSpec + ): ReturnType { + this.calls.push('TableEnsureViewRowOrderSpec'); + return ok(undefined); + } + + visitTableRemoveView( + _: TableRemoveViewSpec + ): ReturnType { + this.calls.push('TableRemoveViewSpec'); + return ok(undefined); + } + + visitTableRenameView( + _: TableRenameViewSpec + ): ReturnType { + this.calls.push('TableRenameViewSpec'); + return ok(undefined); + } + + visitTableUpdateViewDescription( + _: TableUpdateViewDescriptionSpec + ): ReturnType { + this.calls.push('TableUpdateViewDescriptionSpec'); + return ok(undefined); + } + + visitTableUpdateViewLocked( + _: TableUpdateViewLockedSpec + ): ReturnType { + this.calls.push('TableUpdateViewLockedSpec'); + return ok(undefined); + } + + visitTableUpdateViewOrder( + _: TableUpdateViewOrderSpec + ): ReturnType { + this.calls.push('TableUpdateViewOrderSpec'); + return ok(undefined); + } + visitTableAddSelectOptions( _: TableAddSelectOptionsSpec ): ReturnType { @@ -117,6 +179,34 @@ class SpyVisitor implements ITableSpecVisitor { return ok(undefined); } + visitTableUpdateViewOptions( + _: TableUpdateViewOptionsSpec + ): ReturnType { + this.calls.push('TableUpdateViewOptionsSpec'); + return ok(undefined); + } + + visitTableUpdateViewShareMeta( + _: TableUpdateViewShareMetaSpec + ): ReturnType { + this.calls.push('TableUpdateViewShareMetaSpec'); + return ok(undefined); + } + + visitTableUpdateViewShareId( + _: TableUpdateViewShareIdSpec + ): ReturnType { + this.calls.push('TableUpdateViewShareIdSpec'); + return ok(undefined); + } + + visitTableUpdateViewShareState( + _: TableUpdateViewShareStateSpec + ): ReturnType { + this.calls.push('TableUpdateViewShareStateSpec'); + return ok(undefined); + } + visitTableUpdateViewQueryDefaults( _: TableUpdateViewQueryDefaultsSpec ): ReturnType { @@ -134,6 +224,18 @@ class SpyVisitor implements ITableSpecVisitor { return ok(undefined); } + visitTableByViewId(_: TableByViewIdSpec): ReturnType { + this.calls.push('TableByViewIdSpec'); + return ok(undefined); + } + + visitTableWithViewIds( + _: TableWithViewIdsSpec + ): ReturnType { + this.calls.push('TableWithViewIdsSpec'); + return ok(undefined); + } + visitTableByIncomingReferenceToTable( _: TableByIncomingReferenceToTableSpec ): ReturnType { @@ -163,6 +265,13 @@ class SpyVisitor implements ITableSpecVisitor { return ok(undefined); } + visitTableUpdateProperties( + _: TableUpdatePropertiesSpec + ): ReturnType { + this.calls.push('TableUpdatePropertiesSpec'); + return ok(undefined); + } + visitTableUpdateFieldName( _: TableUpdateFieldNameSpec ): ReturnType { @@ -605,6 +714,42 @@ describe('Table specs', () => { expect(visitor.calls).toContain('TableByNameSpec'); }); + it('selects a Table by a child View without changing aggregate ownership', () => { + const table = buildTable( + BaseId.create(`bse${'d'.repeat(16)}`)._unsafeUnwrap(), + TableName.create('Views')._unsafeUnwrap() + ); + const view = table.views()[0]; + const spec = TableByViewIdSpec.create(view.id()); + const missingSpec = TableByViewIdSpec.create( + ViewId.create(`viw${'z'.repeat(16)}`)._unsafeUnwrap() + ); + + expect(spec.isSatisfiedBy(table)).toBe(true); + expect(missingSpec.isSatisfiedBy(table)).toBe(false); + expect(spec.mutate(table)._unsafeUnwrap()).toBe(table); + + const visitor = new SpyVisitor(); + spec.accept(visitor)._unsafeUnwrap(); + expect(visitor.calls).toContain('TableByViewIdSpec'); + }); + + it('selects a View hydration subset without changing aggregate identity', () => { + const table = buildTable( + BaseId.create(`bse${'v'.repeat(16)}`)._unsafeUnwrap(), + TableName.create('Projected views')._unsafeUnwrap() + ); + const spec = TableWithViewIdsSpec.create([table.views()[0].id()]); + + expect(spec.viewIds()).toEqual([table.views()[0].id()]); + expect(spec.isSatisfiedBy(table)).toBe(true); + expect(spec.mutate(table)._unsafeUnwrap()).toBe(table); + + const visitor = new SpyVisitor(); + spec.accept(visitor)._unsafeUnwrap(); + expect(visitor.calls).toContain('TableWithViewIdsSpec'); + }); + it('evaluates name like specs', () => { const baseIdResult = BaseId.create(`bse${'e'.repeat(16)}`); const nameResult = TableName.create('Projects'); diff --git a/packages/v2/core/src/domain/table/specs/TableUpdatePropertiesSpec.ts b/packages/v2/core/src/domain/table/specs/TableUpdatePropertiesSpec.ts new file mode 100644 index 0000000000..b7cc74713c --- /dev/null +++ b/packages/v2/core/src/domain/table/specs/TableUpdatePropertiesSpec.ts @@ -0,0 +1,47 @@ +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import { MutateOnlySpec } from '../../shared/specification/MutateOnlySpec'; +import type { Table } from '../Table'; +import type { TableProperties, TablePropertiesPatch } from '../TableProperties'; +import type { ITableSpecVisitor } from './ITableSpecVisitor'; + +export class TableUpdatePropertiesSpec< + V extends ITableSpecVisitor = ITableSpecVisitor, +> extends MutateOnlySpec { + private constructor( + private readonly previousPropertiesValue: TableProperties, + private readonly nextPropertiesValue: TableProperties, + private readonly patchValue: TablePropertiesPatch + ) { + super(); + } + + static create( + previousProperties: TableProperties, + nextProperties: TableProperties, + patch: TablePropertiesPatch + ): TableUpdatePropertiesSpec { + return new TableUpdatePropertiesSpec(previousProperties, nextProperties, patch); + } + + previousProperties(): TableProperties { + return this.previousPropertiesValue; + } + + nextProperties(): TableProperties { + return this.nextPropertiesValue; + } + + patch(): TablePropertiesPatch { + return { ...this.patchValue }; + } + + mutate(table: Table): Result { + return table.updateProperties(this.patchValue); + } + + accept(visitor: V): Result { + return visitor.visitTableUpdateProperties(this).map(() => undefined); + } +} diff --git a/packages/v2/core/src/domain/table/specs/TableUpdateViewColumnMetaSpec.ts b/packages/v2/core/src/domain/table/specs/TableUpdateViewColumnMetaSpec.ts index c7c11241e6..cfd18631bb 100644 --- a/packages/v2/core/src/domain/table/specs/TableUpdateViewColumnMetaSpec.ts +++ b/packages/v2/core/src/domain/table/specs/TableUpdateViewColumnMetaSpec.ts @@ -6,7 +6,7 @@ import { MutateOnlySpec } from '../../shared/specification/MutateOnlySpec'; import type { FieldId } from '../fields/FieldId'; import { Table } from '../Table'; import type { View } from '../views/View'; -import { ViewColumnMeta } from '../views/ViewColumnMeta'; +import { ViewColumnMeta, type ViewColumnMetaChange } from '../views/ViewColumnMeta'; import type { ViewId } from '../views/ViewId'; import { CloneViewVisitor } from '../views/visitors/CloneViewVisitor'; import type { ITableSpecVisitor } from './ITableSpecVisitor'; @@ -15,6 +15,10 @@ export type TableViewColumnMetaUpdate = { viewId: ViewId; fieldId: FieldId; columnMeta: ViewColumnMeta; + changes?: ReadonlyArray; + previousOptions?: unknown; + nextOptions?: unknown; + optionsChanged?: boolean; }; export class TableUpdateViewColumnMetaSpec< @@ -167,26 +171,28 @@ export class TableUpdateViewColumnMetaSpec< return ok(t); } - const updatesByViewId = new Map(); + const updatesByViewId = new Map(); for (const update of this.updatesValue) { - updatesByViewId.set(update.viewId.toString(), update.columnMeta); + updatesByViewId.set(update.viewId.toString(), update); } const nextViews: View[] = []; for (const view of t.views()) { - const nextColumnMeta = updatesByViewId.get(view.id().toString()); - if (!nextColumnMeta) { + const update = updatesByViewId.get(view.id().toString()); + if (!update) { nextViews.push(view); continue; } - const cloneResult = view.accept(new CloneViewVisitor()); + const cloneResult = view.accept( + new CloneViewVisitor(update.optionsChanged ? { options: update.nextOptions } : undefined) + ); if (cloneResult.isErr()) { return err(cloneResult.error); } const clone = cloneResult.value; - const setColumnMetaResult = clone.setColumnMeta(nextColumnMeta); + const setColumnMetaResult = clone.setColumnMeta(update.columnMeta); if (setColumnMetaResult.isErr()) { return err(setColumnMetaResult.error); } @@ -201,6 +207,14 @@ export class TableUpdateViewColumnMetaSpec< return err(setQueryDefaultsResult.error); } + const auditMetadataResult = view.auditMetadata(); + if (auditMetadataResult.isOk()) { + const setAuditMetadataResult = clone.setAuditMetadata(auditMetadataResult.value); + if (setAuditMetadataResult.isErr()) { + return err(setAuditMetadataResult.error); + } + } + nextViews.push(clone); } @@ -208,6 +222,7 @@ export class TableUpdateViewColumnMetaSpec< id: t.id(), baseId: t.baseId(), name: t.name(), + properties: t.properties(), fields: t.getFields(), views: nextViews, primaryFieldId: t.primaryFieldId(), diff --git a/packages/v2/core/src/domain/table/specs/TableUpdateViewDescriptionSpec.ts b/packages/v2/core/src/domain/table/specs/TableUpdateViewDescriptionSpec.ts new file mode 100644 index 0000000000..5908ba06b8 --- /dev/null +++ b/packages/v2/core/src/domain/table/specs/TableUpdateViewDescriptionSpec.ts @@ -0,0 +1,113 @@ +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import { MutateOnlySpec } from '../../shared/specification/MutateOnlySpec'; +import { Table } from '../Table'; +import type { ITableBuildProps } from '../TableBuilder'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; +import { CloneViewVisitor } from '../views/visitors/CloneViewVisitor'; +import type { ITableSpecVisitor } from './ITableSpecVisitor'; + +export class TableUpdateViewDescriptionSpec< + V extends ITableSpecVisitor = ITableSpecVisitor, +> extends MutateOnlySpec { + private constructor( + private readonly viewIdValue: ViewId, + private readonly previousDescriptionValue: string | undefined, + private readonly nextDescriptionValue: string | undefined + ) { + super(); + } + + static create( + viewId: ViewId, + previousDescription: string | undefined, + nextDescription: string | undefined + ): TableUpdateViewDescriptionSpec { + return new TableUpdateViewDescriptionSpec(viewId, previousDescription, nextDescription); + } + + viewId(): ViewId { + return this.viewIdValue; + } + + previousDescription(): string | undefined { + return this.previousDescriptionValue; + } + + nextDescription(): string | undefined { + return this.nextDescriptionValue; + } + + mutate(table: Table): Result { + const targetResult = table.getView(this.viewIdValue); + if (targetResult.isErr()) return err(targetResult.error); + + const nextPropertiesResult = targetResult.value + .properties() + .withDescription(this.nextDescriptionValue); + if (nextPropertiesResult.isErr()) return err(nextPropertiesResult.error); + + const nextViews: View[] = []; + for (const view of table.views()) { + if (!view.id().equals(this.viewIdValue)) { + nextViews.push(view); + continue; + } + + const cloneResult = view.accept( + new CloneViewVisitor({ properties: nextPropertiesResult.value }) + ); + if (cloneResult.isErr()) return err(cloneResult.error); + const clone = cloneResult.value; + + const columnMetaResult = view.columnMeta(); + if (columnMetaResult.isErr()) return err(columnMetaResult.error); + const setColumnMetaResult = clone.setColumnMeta(columnMetaResult.value); + if (setColumnMetaResult.isErr()) return err(setColumnMetaResult.error); + + const queryDefaultsResult = view.queryDefaults(); + if (queryDefaultsResult.isErr()) return err(queryDefaultsResult.error); + const setQueryDefaultsResult = clone.setQueryDefaults(queryDefaultsResult.value); + if (setQueryDefaultsResult.isErr()) return err(setQueryDefaultsResult.error); + + const auditMetadataResult = view.auditMetadata(); + if (auditMetadataResult.isOk()) { + const setAuditMetadataResult = clone.setAuditMetadata(auditMetadataResult.value); + if (setAuditMetadataResult.isErr()) return err(setAuditMetadataResult.error); + } + + nextViews.push(clone); + } + + const props: ITableBuildProps = { + id: table.id(), + baseId: table.baseId(), + name: table.name(), + properties: table.properties(), + fields: table.getFields(), + views: nextViews, + primaryFieldId: table.primaryFieldId(), + }; + const dbTableNameResult = table.dbTableName(); + if (dbTableNameResult.isOk()) props.dbTableName = dbTableNameResult.value; + + return Table.rehydrate(props).andThen((nextTable) => { + const updatedView = nextTable.views().find((view) => view.id().equals(this.viewIdValue)); + if (!updatedView) { + return err( + domainError.invariant({ + message: `Updated View missing from Table: ${this.viewIdValue.toString()}`, + }) + ); + } + return ok(nextTable); + }); + } + + accept(visitor: V): Result { + return visitor.visitTableUpdateViewDescription(this).map(() => undefined); + } +} diff --git a/packages/v2/core/src/domain/table/specs/TableUpdateViewLockedSpec.ts b/packages/v2/core/src/domain/table/specs/TableUpdateViewLockedSpec.ts new file mode 100644 index 0000000000..80a373c15c --- /dev/null +++ b/packages/v2/core/src/domain/table/specs/TableUpdateViewLockedSpec.ts @@ -0,0 +1,111 @@ +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import { MutateOnlySpec } from '../../shared/specification/MutateOnlySpec'; +import { Table } from '../Table'; +import type { ITableBuildProps } from '../TableBuilder'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; +import { CloneViewVisitor } from '../views/visitors/CloneViewVisitor'; +import type { ITableSpecVisitor } from './ITableSpecVisitor'; + +export class TableUpdateViewLockedSpec< + V extends ITableSpecVisitor = ITableSpecVisitor, +> extends MutateOnlySpec { + private constructor( + private readonly viewIdValue: ViewId, + private readonly previousIsLockedValue: boolean | undefined, + private readonly nextIsLockedValue: boolean | undefined + ) { + super(); + } + + static create( + viewId: ViewId, + previousIsLocked: boolean | undefined, + nextIsLocked: boolean | undefined + ): TableUpdateViewLockedSpec { + return new TableUpdateViewLockedSpec(viewId, previousIsLocked, nextIsLocked); + } + + viewId(): ViewId { + return this.viewIdValue; + } + + previousIsLocked(): boolean | undefined { + return this.previousIsLockedValue; + } + + nextIsLocked(): boolean | undefined { + return this.nextIsLockedValue; + } + + mutate(table: Table): Result { + const targetResult = table.getView(this.viewIdValue); + if (targetResult.isErr()) return err(targetResult.error); + + const nextPropertiesResult = targetResult.value.properties().withLocked(this.nextIsLockedValue); + if (nextPropertiesResult.isErr()) return err(nextPropertiesResult.error); + + const nextViews: View[] = []; + for (const view of table.views()) { + if (!view.id().equals(this.viewIdValue)) { + nextViews.push(view); + continue; + } + + const cloneResult = view.accept( + new CloneViewVisitor({ properties: nextPropertiesResult.value }) + ); + if (cloneResult.isErr()) return err(cloneResult.error); + const clone = cloneResult.value; + + const columnMetaResult = view.columnMeta(); + if (columnMetaResult.isErr()) return err(columnMetaResult.error); + const setColumnMetaResult = clone.setColumnMeta(columnMetaResult.value); + if (setColumnMetaResult.isErr()) return err(setColumnMetaResult.error); + + const queryDefaultsResult = view.queryDefaults(); + if (queryDefaultsResult.isErr()) return err(queryDefaultsResult.error); + const setQueryDefaultsResult = clone.setQueryDefaults(queryDefaultsResult.value); + if (setQueryDefaultsResult.isErr()) return err(setQueryDefaultsResult.error); + + const auditMetadataResult = view.auditMetadata(); + if (auditMetadataResult.isOk()) { + const setAuditMetadataResult = clone.setAuditMetadata(auditMetadataResult.value); + if (setAuditMetadataResult.isErr()) return err(setAuditMetadataResult.error); + } + + nextViews.push(clone); + } + + const props: ITableBuildProps = { + id: table.id(), + baseId: table.baseId(), + name: table.name(), + properties: table.properties(), + fields: table.getFields(), + views: nextViews, + primaryFieldId: table.primaryFieldId(), + }; + const dbTableNameResult = table.dbTableName(); + if (dbTableNameResult.isOk()) props.dbTableName = dbTableNameResult.value; + + return Table.rehydrate(props).andThen((nextTable) => { + const updatedView = nextTable.views().find((view) => view.id().equals(this.viewIdValue)); + if (!updatedView) { + return err( + domainError.invariant({ + message: `Updated View missing from Table: ${this.viewIdValue.toString()}`, + }) + ); + } + return ok(nextTable); + }); + } + + accept(visitor: V): Result { + return visitor.visitTableUpdateViewLocked(this).map(() => undefined); + } +} diff --git a/packages/v2/core/src/domain/table/specs/TableUpdateViewOptionsSpec.ts b/packages/v2/core/src/domain/table/specs/TableUpdateViewOptionsSpec.ts new file mode 100644 index 0000000000..ccac7b1d0b --- /dev/null +++ b/packages/v2/core/src/domain/table/specs/TableUpdateViewOptionsSpec.ts @@ -0,0 +1,88 @@ +import { err, ok, type Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import { MutateOnlySpec } from '../../shared/specification/MutateOnlySpec'; +import { Table } from '../Table'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; +import { CloneViewVisitor } from '../views/visitors/CloneViewVisitor'; +import type { ITableSpecVisitor } from './ITableSpecVisitor'; + +export type TableViewOptionsUpdate = { + readonly viewId: ViewId; + readonly previousOptions: unknown; + readonly nextOptions: unknown; +}; + +export class TableUpdateViewOptionsSpec< + V extends ITableSpecVisitor = ITableSpecVisitor, +> extends MutateOnlySpec { + private constructor(private readonly updateValue: TableViewOptionsUpdate) { + super(); + } + + static create(update: TableViewOptionsUpdate): TableUpdateViewOptionsSpec { + return new TableUpdateViewOptionsSpec(update); + } + + update(): TableViewOptionsUpdate { + return this.updateValue; + } + + mutate(table: Table): Result { + const nextViews: View[] = []; + let found = false; + + for (const view of table.views()) { + if (!view.id().equals(this.updateValue.viewId)) { + nextViews.push(view); + continue; + } + found = true; + const cloneResult = view.accept( + new CloneViewVisitor({ options: this.updateValue.nextOptions }) + ); + if (cloneResult.isErr()) return err(cloneResult.error); + const clone = cloneResult.value; + + const columnMetaResult = view.columnMeta(); + if (columnMetaResult.isErr()) return err(columnMetaResult.error); + const setColumnMetaResult = clone.setColumnMeta(columnMetaResult.value); + if (setColumnMetaResult.isErr()) return err(setColumnMetaResult.error); + + const queryDefaultsResult = view.queryDefaults(); + if (queryDefaultsResult.isErr()) return err(queryDefaultsResult.error); + const setQueryDefaultsResult = clone.setQueryDefaults(queryDefaultsResult.value); + if (setQueryDefaultsResult.isErr()) return err(setQueryDefaultsResult.error); + + const auditMetadataResult = view.auditMetadata(); + if (auditMetadataResult.isOk()) { + const setAuditMetadataResult = clone.setAuditMetadata(auditMetadataResult.value); + if (setAuditMetadataResult.isErr()) return err(setAuditMetadataResult.error); + } + nextViews.push(clone); + } + + if (!found) return ok(table); + const nextTableResult = Table.rehydrate({ + id: table.id(), + baseId: table.baseId(), + name: table.name(), + properties: table.properties(), + fields: table.getFields(), + views: nextViews, + primaryFieldId: table.primaryFieldId(), + }); + if (nextTableResult.isErr()) return nextTableResult; + + const dbTableNameResult = table.dbTableName(); + if (dbTableNameResult.isErr()) return ok(nextTableResult.value); + return nextTableResult.value + .setDbTableName(dbTableNameResult.value) + .map(() => nextTableResult.value); + } + + accept(visitor: V): Result { + return visitor.visitTableUpdateViewOptions(this).map(() => undefined); + } +} diff --git a/packages/v2/core/src/domain/table/specs/TableUpdateViewOrderSpec.ts b/packages/v2/core/src/domain/table/specs/TableUpdateViewOrderSpec.ts new file mode 100644 index 0000000000..b99a39936d --- /dev/null +++ b/packages/v2/core/src/domain/table/specs/TableUpdateViewOrderSpec.ts @@ -0,0 +1,99 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import { MutateOnlySpec } from '../../shared/specification/MutateOnlySpec'; +import { Table } from '../Table'; +import type { ITableBuildProps } from '../TableBuilder'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; +import type { ViewOrder } from '../views/ViewOrder'; +import { CloneViewVisitor } from '../views/visitors/CloneViewVisitor'; +import type { ITableSpecVisitor } from './ITableSpecVisitor'; + +export type TableViewOrderChange = { + readonly viewId: ViewId; + readonly previousOrder: ViewOrder; + readonly nextOrder: ViewOrder; +}; + +export class TableUpdateViewOrderSpec< + V extends ITableSpecVisitor = ITableSpecVisitor, +> extends MutateOnlySpec { + private constructor(private readonly changesValue: ReadonlyArray) { + super(); + } + + static create(changes: ReadonlyArray): TableUpdateViewOrderSpec { + return new TableUpdateViewOrderSpec([...changes]); + } + + changes(): ReadonlyArray { + return [...this.changesValue]; + } + + mutate(table: Table): Result { + let views = [...table.views()]; + + for (const change of this.changesValue) { + const targetIndex = views.findIndex((view) => view.id().equals(change.viewId)); + if (targetIndex === -1) { + return err( + domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${change.viewId.toString()}`, + }) + ); + } + + const source = views[targetIndex]!; + const cloneResult = source.accept(new CloneViewVisitor({ order: change.nextOrder })); + if (cloneResult.isErr()) return err(cloneResult.error); + const clone = cloneResult.value; + + const columnMetaResult = source.columnMeta(); + if (columnMetaResult.isErr()) return err(columnMetaResult.error); + const setColumnMetaResult = clone.setColumnMeta(columnMetaResult.value); + if (setColumnMetaResult.isErr()) return err(setColumnMetaResult.error); + + const queryDefaultsResult = source.queryDefaults(); + if (queryDefaultsResult.isErr()) return err(queryDefaultsResult.error); + const setQueryDefaultsResult = clone.setQueryDefaults(queryDefaultsResult.value); + if (setQueryDefaultsResult.isErr()) return err(setQueryDefaultsResult.error); + + const auditMetadataResult = source.auditMetadata(); + if (auditMetadataResult.isOk()) { + const setAuditMetadataResult = clone.setAuditMetadata(auditMetadataResult.value); + if (setAuditMetadataResult.isErr()) return err(setAuditMetadataResult.error); + } + + views[targetIndex] = clone; + } + + const orderedViews: Array<{ view: View; order: number }> = []; + for (const view of views) { + const orderResult = view.order(); + if (orderResult.isErr()) return err(orderResult.error); + orderedViews.push({ view, order: orderResult.value.toNumber() }); + } + orderedViews.sort((left, right) => left.order - right.order); + views = orderedViews.map(({ view }) => view); + + const props: ITableBuildProps = { + id: table.id(), + baseId: table.baseId(), + name: table.name(), + properties: table.properties(), + fields: table.getFields(), + views: views as ReadonlyArray, + primaryFieldId: table.primaryFieldId(), + }; + const dbTableNameResult = table.dbTableName(); + if (dbTableNameResult.isOk()) props.dbTableName = dbTableNameResult.value; + return Table.rehydrate(props); + } + + accept(visitor: V): Result { + return visitor.visitTableUpdateViewOrder(this).map(() => undefined); + } +} diff --git a/packages/v2/core/src/domain/table/specs/TableUpdateViewQueryDefaultsSpec.ts b/packages/v2/core/src/domain/table/specs/TableUpdateViewQueryDefaultsSpec.ts index fa0c6a8fc1..4298236932 100644 --- a/packages/v2/core/src/domain/table/specs/TableUpdateViewQueryDefaultsSpec.ts +++ b/packages/v2/core/src/domain/table/specs/TableUpdateViewQueryDefaultsSpec.ts @@ -12,6 +12,7 @@ import type { ITableSpecVisitor } from './ITableSpecVisitor'; export type TableViewQueryDefaultsUpdate = { viewId: ViewId; + previousQueryDefaults?: ViewQueryDefaults; queryDefaults: ViewQueryDefaults; }; @@ -78,6 +79,7 @@ export class TableUpdateViewQueryDefaultsSpec< id: t.id(), baseId: t.baseId(), name: t.name(), + properties: t.properties(), fields: t.getFields(), views: nextViews, primaryFieldId: t.primaryFieldId(), diff --git a/packages/v2/core/src/domain/table/specs/TableUpdateViewShareIdSpec.ts b/packages/v2/core/src/domain/table/specs/TableUpdateViewShareIdSpec.ts new file mode 100644 index 0000000000..b6bd5cadbc --- /dev/null +++ b/packages/v2/core/src/domain/table/specs/TableUpdateViewShareIdSpec.ts @@ -0,0 +1,98 @@ +import { err, type Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import { MutateOnlySpec } from '../../shared/specification/MutateOnlySpec'; +import { Table } from '../Table'; +import type { ITableBuildProps } from '../TableBuilder'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; +import { CloneViewVisitor } from '../views/visitors/CloneViewVisitor'; +import type { ITableSpecVisitor } from './ITableSpecVisitor'; + +export class TableUpdateViewShareIdSpec< + V extends ITableSpecVisitor = ITableSpecVisitor, +> extends MutateOnlySpec { + private constructor( + private readonly viewIdValue: ViewId, + private readonly previousShareIdValue: string | undefined, + private readonly nextShareIdValue: string + ) { + super(); + } + + static create( + viewId: ViewId, + previousShareId: string | undefined, + nextShareId: string + ): TableUpdateViewShareIdSpec { + return new TableUpdateViewShareIdSpec(viewId, previousShareId, nextShareId); + } + + viewId(): ViewId { + return this.viewIdValue; + } + + previousShareId(): string | undefined { + return this.previousShareIdValue; + } + + nextShareId(): string { + return this.nextShareIdValue; + } + + mutate(table: Table): Result { + const targetResult = table.getView(this.viewIdValue); + if (targetResult.isErr()) return err(targetResult.error); + + const nextPropertiesResult = targetResult.value.properties().withShareId(this.nextShareIdValue); + if (nextPropertiesResult.isErr()) return err(nextPropertiesResult.error); + + const nextViews: View[] = []; + for (const view of table.views()) { + if (!view.id().equals(this.viewIdValue)) { + nextViews.push(view); + continue; + } + + const cloneResult = view.accept( + new CloneViewVisitor({ properties: nextPropertiesResult.value }) + ); + if (cloneResult.isErr()) return err(cloneResult.error); + const clone = cloneResult.value; + + const columnMetaResult = view.columnMeta(); + if (columnMetaResult.isErr()) return err(columnMetaResult.error); + const setColumnMetaResult = clone.setColumnMeta(columnMetaResult.value); + if (setColumnMetaResult.isErr()) return err(setColumnMetaResult.error); + + const queryDefaultsResult = view.queryDefaults(); + if (queryDefaultsResult.isErr()) return err(queryDefaultsResult.error); + const setQueryDefaultsResult = clone.setQueryDefaults(queryDefaultsResult.value); + if (setQueryDefaultsResult.isErr()) return err(setQueryDefaultsResult.error); + + const auditMetadataResult = view.auditMetadata(); + if (auditMetadataResult.isOk()) { + const setAuditMetadataResult = clone.setAuditMetadata(auditMetadataResult.value); + if (setAuditMetadataResult.isErr()) return err(setAuditMetadataResult.error); + } + nextViews.push(clone); + } + + const props: ITableBuildProps = { + id: table.id(), + baseId: table.baseId(), + name: table.name(), + properties: table.properties(), + fields: table.getFields(), + views: nextViews, + primaryFieldId: table.primaryFieldId(), + }; + const dbTableNameResult = table.dbTableName(); + if (dbTableNameResult.isOk()) props.dbTableName = dbTableNameResult.value; + return Table.rehydrate(props); + } + + accept(visitor: V): Result { + return visitor.visitTableUpdateViewShareId(this).map(() => undefined); + } +} diff --git a/packages/v2/core/src/domain/table/specs/TableUpdateViewShareMetaSpec.ts b/packages/v2/core/src/domain/table/specs/TableUpdateViewShareMetaSpec.ts new file mode 100644 index 0000000000..7d6f1a561a --- /dev/null +++ b/packages/v2/core/src/domain/table/specs/TableUpdateViewShareMetaSpec.ts @@ -0,0 +1,101 @@ +import { err, type Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import { MutateOnlySpec } from '../../shared/specification/MutateOnlySpec'; +import { Table } from '../Table'; +import type { ITableBuildProps } from '../TableBuilder'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; +import type { ViewShareMetaValue } from '../views/ViewProperties'; +import { CloneViewVisitor } from '../views/visitors/CloneViewVisitor'; +import type { ITableSpecVisitor } from './ITableSpecVisitor'; + +export class TableUpdateViewShareMetaSpec< + V extends ITableSpecVisitor = ITableSpecVisitor, +> extends MutateOnlySpec { + private constructor( + private readonly viewIdValue: ViewId, + private readonly previousShareMetaValue: ViewShareMetaValue | undefined, + private readonly nextShareMetaValue: ViewShareMetaValue | undefined + ) { + super(); + } + + static create( + viewId: ViewId, + previousShareMeta: ViewShareMetaValue | undefined, + nextShareMeta: ViewShareMetaValue | undefined + ): TableUpdateViewShareMetaSpec { + return new TableUpdateViewShareMetaSpec(viewId, previousShareMeta, nextShareMeta); + } + + viewId(): ViewId { + return this.viewIdValue; + } + + previousShareMeta(): ViewShareMetaValue | undefined { + return this.previousShareMetaValue; + } + + nextShareMeta(): ViewShareMetaValue | undefined { + return this.nextShareMetaValue; + } + + mutate(table: Table): Result { + const targetResult = table.getView(this.viewIdValue); + if (targetResult.isErr()) return err(targetResult.error); + + const nextPropertiesResult = targetResult.value + .properties() + .withShareMeta(this.nextShareMetaValue); + if (nextPropertiesResult.isErr()) return err(nextPropertiesResult.error); + + const nextViews: View[] = []; + for (const view of table.views()) { + if (!view.id().equals(this.viewIdValue)) { + nextViews.push(view); + continue; + } + + const cloneResult = view.accept( + new CloneViewVisitor({ properties: nextPropertiesResult.value }) + ); + if (cloneResult.isErr()) return err(cloneResult.error); + const clone = cloneResult.value; + + const columnMetaResult = view.columnMeta(); + if (columnMetaResult.isErr()) return err(columnMetaResult.error); + const setColumnMetaResult = clone.setColumnMeta(columnMetaResult.value); + if (setColumnMetaResult.isErr()) return err(setColumnMetaResult.error); + + const queryDefaultsResult = view.queryDefaults(); + if (queryDefaultsResult.isErr()) return err(queryDefaultsResult.error); + const setQueryDefaultsResult = clone.setQueryDefaults(queryDefaultsResult.value); + if (setQueryDefaultsResult.isErr()) return err(setQueryDefaultsResult.error); + + const auditMetadataResult = view.auditMetadata(); + if (auditMetadataResult.isOk()) { + const setAuditMetadataResult = clone.setAuditMetadata(auditMetadataResult.value); + if (setAuditMetadataResult.isErr()) return err(setAuditMetadataResult.error); + } + nextViews.push(clone); + } + + const props: ITableBuildProps = { + id: table.id(), + baseId: table.baseId(), + name: table.name(), + properties: table.properties(), + fields: table.getFields(), + views: nextViews, + primaryFieldId: table.primaryFieldId(), + }; + const dbTableNameResult = table.dbTableName(); + if (dbTableNameResult.isOk()) props.dbTableName = dbTableNameResult.value; + return Table.rehydrate(props); + } + + accept(visitor: V): Result { + return visitor.visitTableUpdateViewShareMeta(this).map(() => undefined); + } +} diff --git a/packages/v2/core/src/domain/table/specs/TableUpdateViewShareStateSpec.ts b/packages/v2/core/src/domain/table/specs/TableUpdateViewShareStateSpec.ts new file mode 100644 index 0000000000..a9dcdb8fa5 --- /dev/null +++ b/packages/v2/core/src/domain/table/specs/TableUpdateViewShareStateSpec.ts @@ -0,0 +1,119 @@ +import { err, type Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import { MutateOnlySpec } from '../../shared/specification/MutateOnlySpec'; +import { Table } from '../Table'; +import type { ITableBuildProps } from '../TableBuilder'; +import type { View } from '../views/View'; +import type { ViewId } from '../views/ViewId'; +import type { ViewShareMetaValue } from '../views/ViewProperties'; +import { CloneViewVisitor } from '../views/visitors/CloneViewVisitor'; +import type { ITableSpecVisitor } from './ITableSpecVisitor'; + +export type TableViewShareState = { + readonly enableShare: boolean; + readonly shareId: string | undefined; + readonly shareMeta: ViewShareMetaValue | undefined; +}; + +export type TableNextViewShareState = + | { + readonly enableShare: true; + readonly shareId: string; + readonly shareMeta: ViewShareMetaValue; + } + | { + readonly enableShare: false; + readonly shareId: string | undefined; + readonly shareMeta: ViewShareMetaValue | undefined; + }; + +export class TableUpdateViewShareStateSpec< + V extends ITableSpecVisitor = ITableSpecVisitor, +> extends MutateOnlySpec { + private constructor( + private readonly viewIdValue: ViewId, + private readonly previousStateValue: TableViewShareState, + private readonly nextStateValue: TableNextViewShareState + ) { + super(); + } + + static create( + viewId: ViewId, + previousState: TableViewShareState, + nextState: TableNextViewShareState + ): TableUpdateViewShareStateSpec { + return new TableUpdateViewShareStateSpec(viewId, previousState, nextState); + } + + viewId(): ViewId { + return this.viewIdValue; + } + + previousState(): TableViewShareState { + return this.previousStateValue; + } + + nextState(): TableNextViewShareState { + return this.nextStateValue; + } + + mutate(table: Table): Result { + const targetResult = table.getView(this.viewIdValue); + if (targetResult.isErr()) return err(targetResult.error); + + const nextPropertiesResult = targetResult.value + .properties() + .withShareState(this.nextStateValue); + if (nextPropertiesResult.isErr()) return err(nextPropertiesResult.error); + + const nextViews: View[] = []; + for (const view of table.views()) { + if (!view.id().equals(this.viewIdValue)) { + nextViews.push(view); + continue; + } + + const cloneResult = view.accept( + new CloneViewVisitor({ properties: nextPropertiesResult.value }) + ); + if (cloneResult.isErr()) return err(cloneResult.error); + const clone = cloneResult.value; + + const columnMetaResult = view.columnMeta(); + if (columnMetaResult.isErr()) return err(columnMetaResult.error); + const setColumnMetaResult = clone.setColumnMeta(columnMetaResult.value); + if (setColumnMetaResult.isErr()) return err(setColumnMetaResult.error); + + const queryDefaultsResult = view.queryDefaults(); + if (queryDefaultsResult.isErr()) return err(queryDefaultsResult.error); + const setQueryDefaultsResult = clone.setQueryDefaults(queryDefaultsResult.value); + if (setQueryDefaultsResult.isErr()) return err(setQueryDefaultsResult.error); + + const auditMetadataResult = view.auditMetadata(); + if (auditMetadataResult.isOk()) { + const setAuditMetadataResult = clone.setAuditMetadata(auditMetadataResult.value); + if (setAuditMetadataResult.isErr()) return err(setAuditMetadataResult.error); + } + nextViews.push(clone); + } + + const props: ITableBuildProps = { + id: table.id(), + baseId: table.baseId(), + name: table.name(), + properties: table.properties(), + fields: table.getFields(), + views: nextViews, + primaryFieldId: table.primaryFieldId(), + }; + const dbTableNameResult = table.dbTableName(); + if (dbTableNameResult.isOk()) props.dbTableName = dbTableNameResult.value; + return Table.rehydrate(props); + } + + accept(visitor: V): Result { + return visitor.visitTableUpdateViewShareState(this).map(() => undefined); + } +} diff --git a/packages/v2/core/src/domain/table/specs/TableWithViewIdsSpec.ts b/packages/v2/core/src/domain/table/specs/TableWithViewIdsSpec.ts new file mode 100644 index 0000000000..3e23ea00bd --- /dev/null +++ b/packages/v2/core/src/domain/table/specs/TableWithViewIdsSpec.ts @@ -0,0 +1,38 @@ +import { ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import type { ISpecification } from '../../shared/specification/ISpecification'; +import type { Table } from '../Table'; +import type { ViewId } from '../views/ViewId'; +import type { ITableSpecVisitor } from './ITableSpecVisitor'; + +/** + * Narrows repository hydration to the requested View children without changing + * which Table aggregate root matches the query. + */ +export class TableWithViewIdsSpec + implements ISpecification +{ + private constructor(private readonly viewIdsValue: ReadonlyArray) {} + + static create(viewIds: ReadonlyArray): TableWithViewIdsSpec { + return new TableWithViewIdsSpec([...viewIds]); + } + + viewIds(): ReadonlyArray { + return this.viewIdsValue; + } + + isSatisfiedBy(_table: Table): boolean { + return true; + } + + mutate(table: Table): Result { + return ok(table); + } + + accept(visitor: V): Result { + return visitor.visitTableWithViewIds(this).map(() => undefined); + } +} diff --git a/packages/v2/core/src/domain/table/specs/visitors/TableEventGeneratingSpecVisitor.ts b/packages/v2/core/src/domain/table/specs/visitors/TableEventGeneratingSpecVisitor.ts index 4447a3396d..a6d97b13ad 100644 --- a/packages/v2/core/src/domain/table/specs/visitors/TableEventGeneratingSpecVisitor.ts +++ b/packages/v2/core/src/domain/table/specs/visitors/TableEventGeneratingSpecVisitor.ts @@ -10,12 +10,29 @@ import { FieldDuplicated } from '../../events/FieldDuplicated'; import { FieldOptionsAdded } from '../../events/FieldOptionsAdded'; import { FieldUpdated } from '../../events/FieldUpdated'; import type { FieldUpdatedValueChange } from '../../events/FieldUpdated'; +import { TablePropertiesUpdated } from '../../events/TablePropertiesUpdated'; import { TableRenamed } from '../../events/TableRenamed'; import { ViewColumnMetaUpdated } from '../../events/ViewColumnMetaUpdated'; +import { ViewCreated } from '../../events/ViewCreated'; +import { ViewDeleted } from '../../events/ViewDeleted'; +import { ViewDescriptionUpdated } from '../../events/ViewDescriptionUpdated'; +import { ViewFilterUpdated } from '../../events/ViewFilterUpdated'; +import { ViewGroupUpdated } from '../../events/ViewGroupUpdated'; +import { ViewLockedUpdated } from '../../events/ViewLockedUpdated'; +import { ViewOptionsUpdated } from '../../events/ViewOptionsUpdated'; +import { ViewOrderUpdated } from '../../events/ViewOrderUpdated'; +import { ViewRenamed } from '../../events/ViewRenamed'; +import { ViewShareDisabled } from '../../events/ViewShareDisabled'; +import { ViewShareEnabled } from '../../events/ViewShareEnabled'; +import { ViewShareIdRefreshed } from '../../events/ViewShareIdRefreshed'; +import { ViewShareMetaUpdated } from '../../events/ViewShareMetaUpdated'; +import { ViewSortUpdated } from '../../events/ViewSortUpdated'; import { Field } from '../../fields/Field'; import type { FieldId } from '../../fields/FieldId'; import { FieldOptionsDtoVisitor } from '../../fields/visitors/FieldOptionsDtoVisitor'; import type { Table } from '../../Table'; +import { viewGroupDtoFromQueryDefaults } from '../../views/ViewGroup'; +import { viewSortDtoFromQueryDefaults } from '../../views/ViewSort'; import type { RemoveSymmetricLinkFieldSpec, UpdateButtonColorSpec, @@ -62,15 +79,20 @@ import type { ITableSpecVisitor } from '../ITableSpecVisitor'; import type { TableAddFieldSpec } from '../TableAddFieldSpec'; import type { TableAddFieldsSpec } from '../TableAddFieldsSpec'; import type { TableAddSelectOptionsSpec } from '../TableAddSelectOptionsSpec'; +import type { TableAddViewSpec } from '../TableAddViewSpec'; import type { TableByBaseIdSpec } from '../TableByBaseIdSpec'; import type { TableByIdSpec } from '../TableByIdSpec'; import type { TableByIdsSpec } from '../TableByIdsSpec'; import type { TableByIncomingReferenceToTableSpec } from '../TableByIncomingReferenceToTableSpec'; import type { TableByNameLikeSpec } from '../TableByNameLikeSpec'; import type { TableByNameSpec } from '../TableByNameSpec'; +import type { TableByViewIdSpec } from '../TableByViewIdSpec'; import type { TableDuplicateFieldSpec } from '../TableDuplicateFieldSpec'; +import type { TableEnsureViewRowOrderSpec } from '../TableEnsureViewRowOrderSpec'; import type { TableRemoveFieldSpec } from '../TableRemoveFieldSpec'; +import type { TableRemoveViewSpec } from '../TableRemoveViewSpec'; import type { TableRenameSpec } from '../TableRenameSpec'; +import type { TableRenameViewSpec } from '../TableRenameViewSpec'; import type { TableUpdateFieldAiConfigSpec } from '../TableUpdateFieldAiConfigSpec'; import type { TableUpdateFieldConstraintsSpec } from '../TableUpdateFieldConstraintsSpec'; import type { TableUpdateFieldDbFieldNameSpec } from '../TableUpdateFieldDbFieldNameSpec'; @@ -78,8 +100,17 @@ import type { TableUpdateFieldDescriptionSpec } from '../TableUpdateFieldDescrip import type { TableUpdateFieldHasErrorSpec } from '../TableUpdateFieldHasErrorSpec'; import type { TableUpdateFieldNameSpec } from '../TableUpdateFieldNameSpec'; import type { TableUpdateFieldTypeSpec } from '../TableUpdateFieldTypeSpec'; +import type { TableUpdatePropertiesSpec } from '../TableUpdatePropertiesSpec'; import type { TableUpdateViewColumnMetaSpec } from '../TableUpdateViewColumnMetaSpec'; +import type { TableUpdateViewDescriptionSpec } from '../TableUpdateViewDescriptionSpec'; +import type { TableUpdateViewLockedSpec } from '../TableUpdateViewLockedSpec'; +import type { TableUpdateViewOptionsSpec } from '../TableUpdateViewOptionsSpec'; +import type { TableUpdateViewOrderSpec } from '../TableUpdateViewOrderSpec'; import type { TableUpdateViewQueryDefaultsSpec } from '../TableUpdateViewQueryDefaultsSpec'; +import type { TableUpdateViewShareIdSpec } from '../TableUpdateViewShareIdSpec'; +import type { TableUpdateViewShareMetaSpec } from '../TableUpdateViewShareMetaSpec'; +import type { TableUpdateViewShareStateSpec } from '../TableUpdateViewShareStateSpec'; +import type { TableWithViewIdsSpec } from '../TableWithViewIdsSpec'; import { FieldUpdateSemanticsVisitor } from './FieldUpdateSemanticsVisitor'; /** @@ -136,6 +167,86 @@ export class TableEventGeneratingSpecVisitor implements ITableSpecVisitor return ok(undefined); } + visitTableAddView(spec: TableAddViewSpec): Result { + this.events.push( + ViewCreated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: spec.view().id(), + }) + ); + return ok(undefined); + } + + visitTableEnsureViewRowOrder(_spec: TableEnsureViewRowOrderSpec): Result { + return ok(undefined); + } + + visitTableRemoveView(spec: TableRemoveViewSpec): Result { + this.events.push( + ViewDeleted.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: spec.view().id(), + }) + ); + return ok(undefined); + } + + visitTableRenameView(spec: TableRenameViewSpec): Result { + this.events.push( + ViewRenamed.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: spec.viewId(), + previousName: spec.previousName(), + nextName: spec.nextName(), + }) + ); + return ok(undefined); + } + + visitTableUpdateViewDescription(spec: TableUpdateViewDescriptionSpec): Result { + this.events.push( + ViewDescriptionUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: spec.viewId(), + previousDescription: spec.previousDescription(), + nextDescription: spec.nextDescription(), + }) + ); + return ok(undefined); + } + + visitTableUpdateViewLocked(spec: TableUpdateViewLockedSpec): Result { + this.events.push( + ViewLockedUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: spec.viewId(), + previousIsLocked: spec.previousIsLocked(), + nextIsLocked: spec.nextIsLocked(), + }) + ); + return ok(undefined); + } + + visitTableUpdateViewOrder(spec: TableUpdateViewOrderSpec): Result { + for (const change of spec.changes()) { + this.events.push( + ViewOrderUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: change.viewId, + previousOrder: change.previousOrder, + nextOrder: change.nextOrder, + }) + ); + } + return ok(undefined); + } + private collectViewOrders( fieldId: FieldId ): Result>, DomainError> { @@ -209,6 +320,29 @@ export class TableEventGeneratingSpecVisitor implements ITableSpecVisitor visitTableUpdateViewColumnMeta(spec: TableUpdateViewColumnMetaSpec): Result { for (const update of spec.updates()) { const columnMetaDto = update.columnMeta.toDto(); + if (update.changes?.length || update.optionsChanged) { + const eventFieldId = update.changes?.[0]?.fieldId ?? update.fieldId; + const fieldId = eventFieldId.toString(); + this.events.push( + ViewColumnMetaUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: update.viewId, + fieldId: eventFieldId, + fieldInColumnMeta: Boolean(columnMetaDto[fieldId]), + changes: update.changes, + ...(update.optionsChanged + ? { + optionsChange: { + previousOptions: update.previousOptions, + nextOptions: update.nextOptions, + }, + } + : {}), + }) + ); + continue; + } const fieldId = update.fieldId.toString(); this.events.push( ViewColumnMetaUpdated.create({ @@ -224,8 +358,113 @@ export class TableEventGeneratingSpecVisitor implements ITableSpecVisitor } visitTableUpdateViewQueryDefaults( - _spec: TableUpdateViewQueryDefaultsSpec + spec: TableUpdateViewQueryDefaultsSpec ): Result { + for (const update of spec.updates()) { + const previous = update.previousQueryDefaults?.sourceFilter(); + const next = update.queryDefaults.sourceFilter(); + if (update.previousQueryDefaults && JSON.stringify(previous) !== JSON.stringify(next)) { + this.events.push( + ViewFilterUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: update.viewId, + previousFilter: previous, + nextFilter: next, + }) + ); + } + if (update.previousQueryDefaults) { + const previousGroup = viewGroupDtoFromQueryDefaults(update.previousQueryDefaults); + const nextGroup = viewGroupDtoFromQueryDefaults(update.queryDefaults); + if (JSON.stringify(previousGroup) !== JSON.stringify(nextGroup)) { + this.events.push( + ViewGroupUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: update.viewId, + previousGroup, + nextGroup, + }) + ); + } + const previousSort = viewSortDtoFromQueryDefaults(update.previousQueryDefaults); + const nextSort = viewSortDtoFromQueryDefaults(update.queryDefaults); + if (JSON.stringify(previousSort) !== JSON.stringify(nextSort)) { + this.events.push( + ViewSortUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: update.viewId, + previousSort, + nextSort, + }) + ); + } + } + } + return ok(undefined); + } + + visitTableUpdateViewOptions(spec: TableUpdateViewOptionsSpec): Result { + const update = spec.update(); + this.events.push( + ViewOptionsUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: update.viewId, + previousOptions: update.previousOptions, + nextOptions: update.nextOptions, + }) + ); + return ok(undefined); + } + + visitTableUpdateViewShareMeta(spec: TableUpdateViewShareMetaSpec): Result { + this.events.push( + ViewShareMetaUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: spec.viewId(), + previousShareMeta: spec.previousShareMeta(), + nextShareMeta: spec.nextShareMeta(), + }) + ); + return ok(undefined); + } + + visitTableUpdateViewShareId(spec: TableUpdateViewShareIdSpec): Result { + this.events.push( + ViewShareIdRefreshed.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: spec.viewId(), + previousShareId: spec.previousShareId(), + nextShareId: spec.nextShareId(), + }) + ); + return ok(undefined); + } + + visitTableUpdateViewShareState(spec: TableUpdateViewShareStateSpec): Result { + const nextState = spec.nextState(); + this.events.push( + nextState.enableShare + ? ViewShareEnabled.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: spec.viewId(), + shareId: nextState.shareId, + shareMeta: nextState.shareMeta, + }) + : ViewShareDisabled.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: spec.viewId(), + previousShareId: spec.previousState().shareId, + shareMeta: nextState.shareMeta, + }) + ); return ok(undefined); } @@ -241,6 +480,20 @@ export class TableEventGeneratingSpecVisitor implements ITableSpecVisitor return ok(undefined); } + visitTableUpdateProperties(spec: TableUpdatePropertiesSpec): Result { + if (!spec.previousProperties().equals(spec.nextProperties())) { + this.events.push( + TablePropertiesUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + previousProperties: spec.previousProperties(), + nextProperties: spec.nextProperties(), + }) + ); + } + return ok(undefined); + } + // Query specs do not generate events visitTableByBaseId(_spec: TableByBaseIdSpec): Result { return ok(undefined); @@ -250,6 +503,14 @@ export class TableEventGeneratingSpecVisitor implements ITableSpecVisitor return ok(undefined); } + visitTableByViewId(_spec: TableByViewIdSpec): Result { + return ok(undefined); + } + + visitTableWithViewIds(_spec: TableWithViewIdsSpec): Result { + return ok(undefined); + } + visitTableByIncomingReferenceToTable( _spec: TableByIncomingReferenceToTableSpec ): Result { diff --git a/packages/v2/core/src/domain/table/specs/visitors/TableSpecEventVisitor.ts b/packages/v2/core/src/domain/table/specs/visitors/TableSpecEventVisitor.ts index 83b2717145..ce3a388458 100644 --- a/packages/v2/core/src/domain/table/specs/visitors/TableSpecEventVisitor.ts +++ b/packages/v2/core/src/domain/table/specs/visitors/TableSpecEventVisitor.ts @@ -7,10 +7,27 @@ import type { ISpecification } from '../../../shared/specification/ISpecificatio import { FieldCreated } from '../../events/FieldCreated'; import { FieldDeleted } from '../../events/FieldDeleted'; import { FieldUpdated } from '../../events/FieldUpdated'; +import { TablePropertiesUpdated } from '../../events/TablePropertiesUpdated'; import { TableRenamed } from '../../events/TableRenamed'; import { ViewColumnMetaUpdated } from '../../events/ViewColumnMetaUpdated'; +import { ViewCreated } from '../../events/ViewCreated'; +import { ViewDeleted } from '../../events/ViewDeleted'; +import { ViewDescriptionUpdated } from '../../events/ViewDescriptionUpdated'; +import { ViewFilterUpdated } from '../../events/ViewFilterUpdated'; +import { ViewGroupUpdated } from '../../events/ViewGroupUpdated'; +import { ViewLockedUpdated } from '../../events/ViewLockedUpdated'; +import { ViewOptionsUpdated } from '../../events/ViewOptionsUpdated'; +import { ViewOrderUpdated } from '../../events/ViewOrderUpdated'; +import { ViewRenamed } from '../../events/ViewRenamed'; +import { ViewShareDisabled } from '../../events/ViewShareDisabled'; +import { ViewShareEnabled } from '../../events/ViewShareEnabled'; +import { ViewShareIdRefreshed } from '../../events/ViewShareIdRefreshed'; +import { ViewShareMetaUpdated } from '../../events/ViewShareMetaUpdated'; +import { ViewSortUpdated } from '../../events/ViewSortUpdated'; import type { FieldId } from '../../fields/FieldId'; import type { Table } from '../../Table'; +import { viewGroupDtoFromQueryDefaults } from '../../views/ViewGroup'; +import { viewSortDtoFromQueryDefaults } from '../../views/ViewSort'; import type { RemoveSymmetricLinkFieldSpec, UpdateButtonColorSpec, @@ -57,15 +74,20 @@ import type { ITableSpecVisitor } from '../ITableSpecVisitor'; import type { TableAddFieldSpec } from '../TableAddFieldSpec'; import type { TableAddFieldsSpec } from '../TableAddFieldsSpec'; import type { TableAddSelectOptionsSpec } from '../TableAddSelectOptionsSpec'; +import type { TableAddViewSpec } from '../TableAddViewSpec'; import type { TableByBaseIdSpec } from '../TableByBaseIdSpec'; import type { TableByIdSpec } from '../TableByIdSpec'; import type { TableByIdsSpec } from '../TableByIdsSpec'; import type { TableByIncomingReferenceToTableSpec } from '../TableByIncomingReferenceToTableSpec'; import type { TableByNameLikeSpec } from '../TableByNameLikeSpec'; import type { TableByNameSpec } from '../TableByNameSpec'; +import type { TableByViewIdSpec } from '../TableByViewIdSpec'; import type { TableDuplicateFieldSpec } from '../TableDuplicateFieldSpec'; +import type { TableEnsureViewRowOrderSpec } from '../TableEnsureViewRowOrderSpec'; import type { TableRemoveFieldSpec } from '../TableRemoveFieldSpec'; +import type { TableRemoveViewSpec } from '../TableRemoveViewSpec'; import type { TableRenameSpec } from '../TableRenameSpec'; +import type { TableRenameViewSpec } from '../TableRenameViewSpec'; import type { TableUpdateFieldAiConfigSpec } from '../TableUpdateFieldAiConfigSpec'; import type { TableUpdateFieldConstraintsSpec } from '../TableUpdateFieldConstraintsSpec'; import type { TableUpdateFieldDbFieldNameSpec } from '../TableUpdateFieldDbFieldNameSpec'; @@ -73,8 +95,17 @@ import type { TableUpdateFieldDescriptionSpec } from '../TableUpdateFieldDescrip import type { TableUpdateFieldHasErrorSpec } from '../TableUpdateFieldHasErrorSpec'; import type { TableUpdateFieldNameSpec } from '../TableUpdateFieldNameSpec'; import type { TableUpdateFieldTypeSpec } from '../TableUpdateFieldTypeSpec'; +import type { TableUpdatePropertiesSpec } from '../TableUpdatePropertiesSpec'; import type { TableUpdateViewColumnMetaSpec } from '../TableUpdateViewColumnMetaSpec'; +import type { TableUpdateViewDescriptionSpec } from '../TableUpdateViewDescriptionSpec'; +import type { TableUpdateViewLockedSpec } from '../TableUpdateViewLockedSpec'; +import type { TableUpdateViewOptionsSpec } from '../TableUpdateViewOptionsSpec'; +import type { TableUpdateViewOrderSpec } from '../TableUpdateViewOrderSpec'; import type { TableUpdateViewQueryDefaultsSpec } from '../TableUpdateViewQueryDefaultsSpec'; +import type { TableUpdateViewShareIdSpec } from '../TableUpdateViewShareIdSpec'; +import type { TableUpdateViewShareMetaSpec } from '../TableUpdateViewShareMetaSpec'; +import type { TableUpdateViewShareStateSpec } from '../TableUpdateViewShareStateSpec'; +import type { TableWithViewIdsSpec } from '../TableWithViewIdsSpec'; import { FieldUpdateSemanticsVisitor } from './FieldUpdateSemanticsVisitor'; /** @@ -164,6 +195,98 @@ export class TableSpecEventVisitor implements ITableSpecVisitor { return ok(undefined); } + visitTableAddView(spec: TableAddViewSpec>): Result { + this.eventsCollected.push( + ViewCreated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: spec.view().id(), + }) + ); + return ok(undefined); + } + + visitTableEnsureViewRowOrder( + _spec: TableEnsureViewRowOrderSpec> + ): Result { + return ok(undefined); + } + + visitTableRemoveView( + spec: TableRemoveViewSpec> + ): Result { + this.eventsCollected.push( + ViewDeleted.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: spec.view().id(), + }) + ); + return ok(undefined); + } + + visitTableRenameView( + spec: TableRenameViewSpec> + ): Result { + this.eventsCollected.push( + ViewRenamed.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: spec.viewId(), + previousName: spec.previousName(), + nextName: spec.nextName(), + }) + ); + return ok(undefined); + } + + visitTableUpdateViewDescription( + spec: TableUpdateViewDescriptionSpec> + ): Result { + this.eventsCollected.push( + ViewDescriptionUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: spec.viewId(), + previousDescription: spec.previousDescription(), + nextDescription: spec.nextDescription(), + }) + ); + return ok(undefined); + } + + visitTableUpdateViewLocked( + spec: TableUpdateViewLockedSpec> + ): Result { + this.eventsCollected.push( + ViewLockedUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: spec.viewId(), + previousIsLocked: spec.previousIsLocked(), + nextIsLocked: spec.nextIsLocked(), + }) + ); + return ok(undefined); + } + + visitTableUpdateViewOrder( + spec: TableUpdateViewOrderSpec> + ): Result { + for (const change of spec.changes()) { + this.eventsCollected.push( + ViewOrderUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: change.viewId, + previousOrder: change.previousOrder, + nextOrder: change.nextOrder, + }) + ); + } + return ok(undefined); + } + private collectViewOrders( fieldId: FieldId ): Result>, DomainError> { @@ -217,6 +340,29 @@ export class TableSpecEventVisitor implements ITableSpecVisitor { const updates = spec.updates(); for (const update of updates) { + if (update.changes?.length || update.optionsChanged) { + const eventFieldId = update.changes?.[0]?.fieldId ?? update.fieldId; + const metaDto = update.columnMeta.toDto(); + this.eventsCollected.push( + ViewColumnMetaUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: update.viewId, + fieldId: eventFieldId, + fieldInColumnMeta: Boolean(metaDto[eventFieldId.toString()]), + changes: update.changes, + ...(update.optionsChanged + ? { + optionsChange: { + previousOptions: update.previousOptions, + nextOptions: update.nextOptions, + }, + } + : {}), + }) + ); + continue; + } // Get column meta entries to find affected field IDs const metaDto = update.columnMeta.toDto(); for (const fieldIdStr of Object.keys(metaDto)) { @@ -240,8 +386,121 @@ export class TableSpecEventVisitor implements ITableSpecVisitor { } visitTableUpdateViewQueryDefaults( - _spec: TableUpdateViewQueryDefaultsSpec> + spec: TableUpdateViewQueryDefaultsSpec> ): Result { + for (const update of spec.updates()) { + const previous = update.previousQueryDefaults?.sourceFilter(); + const next = update.queryDefaults.sourceFilter(); + if (update.previousQueryDefaults && JSON.stringify(previous) !== JSON.stringify(next)) { + this.eventsCollected.push( + ViewFilterUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: update.viewId, + previousFilter: previous, + nextFilter: next, + }) + ); + } + if (update.previousQueryDefaults) { + const previousGroup = viewGroupDtoFromQueryDefaults(update.previousQueryDefaults); + const nextGroup = viewGroupDtoFromQueryDefaults(update.queryDefaults); + if (JSON.stringify(previousGroup) !== JSON.stringify(nextGroup)) { + this.eventsCollected.push( + ViewGroupUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: update.viewId, + previousGroup, + nextGroup, + }) + ); + } + const previousSort = viewSortDtoFromQueryDefaults(update.previousQueryDefaults); + const nextSort = viewSortDtoFromQueryDefaults(update.queryDefaults); + if (JSON.stringify(previousSort) !== JSON.stringify(nextSort)) { + this.eventsCollected.push( + ViewSortUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: update.viewId, + previousSort, + nextSort, + }) + ); + } + } + } + return ok(undefined); + } + + visitTableUpdateViewOptions( + spec: TableUpdateViewOptionsSpec> + ): Result { + const update = spec.update(); + this.eventsCollected.push( + ViewOptionsUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: update.viewId, + previousOptions: update.previousOptions, + nextOptions: update.nextOptions, + }) + ); + return ok(undefined); + } + + visitTableUpdateViewShareMeta( + spec: TableUpdateViewShareMetaSpec> + ): Result { + this.eventsCollected.push( + ViewShareMetaUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: spec.viewId(), + previousShareMeta: spec.previousShareMeta(), + nextShareMeta: spec.nextShareMeta(), + }) + ); + return ok(undefined); + } + + visitTableUpdateViewShareId( + spec: TableUpdateViewShareIdSpec> + ): Result { + this.eventsCollected.push( + ViewShareIdRefreshed.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: spec.viewId(), + previousShareId: spec.previousShareId(), + nextShareId: spec.nextShareId(), + }) + ); + return ok(undefined); + } + + visitTableUpdateViewShareState( + spec: TableUpdateViewShareStateSpec> + ): Result { + const nextState = spec.nextState(); + this.eventsCollected.push( + nextState.enableShare + ? ViewShareEnabled.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: spec.viewId(), + shareId: nextState.shareId, + shareMeta: nextState.shareMeta, + }) + : ViewShareDisabled.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + viewId: spec.viewId(), + previousShareId: spec.previousState().shareId, + shareMeta: nextState.shareMeta, + }) + ); return ok(undefined); } @@ -263,6 +522,22 @@ export class TableSpecEventVisitor implements ITableSpecVisitor { return ok(undefined); } + visitTableUpdateProperties( + spec: TableUpdatePropertiesSpec> + ): Result { + if (!spec.previousProperties().equals(spec.nextProperties())) { + this.eventsCollected.push( + TablePropertiesUpdated.create({ + tableId: this.table.id(), + baseId: this.table.baseId(), + previousProperties: spec.previousProperties(), + nextProperties: spec.nextProperties(), + }) + ); + } + return ok(undefined); + } + visitTableByBaseId(_spec: TableByBaseIdSpec>): Result { // Query-only spec, no events generated return ok(undefined); @@ -273,6 +548,18 @@ export class TableSpecEventVisitor implements ITableSpecVisitor { return ok(undefined); } + visitTableByViewId(_spec: TableByViewIdSpec>): Result { + // Query-only spec, no events generated + return ok(undefined); + } + + visitTableWithViewIds( + _spec: TableWithViewIdsSpec> + ): Result { + // Query-only child hydration spec, no events generated + return ok(undefined); + } + visitTableByIncomingReferenceToTable( _spec: TableByIncomingReferenceToTableSpec> ): Result { diff --git a/packages/v2/core/src/domain/table/specs/visitors/__tests__/TableEventGeneratingSpecVisitor.spec.ts b/packages/v2/core/src/domain/table/specs/visitors/__tests__/TableEventGeneratingSpecVisitor.spec.ts index c720f24dd0..79baaa4bf4 100644 --- a/packages/v2/core/src/domain/table/specs/visitors/__tests__/TableEventGeneratingSpecVisitor.spec.ts +++ b/packages/v2/core/src/domain/table/specs/visitors/__tests__/TableEventGeneratingSpecVisitor.spec.ts @@ -8,6 +8,7 @@ import { FieldOptionsAdded } from '../../../events/FieldOptionsAdded'; import { FieldUpdated } from '../../../events/FieldUpdated'; import { TableRenamed } from '../../../events/TableRenamed'; import { ViewColumnMetaUpdated } from '../../../events/ViewColumnMetaUpdated'; +import { ViewCreated } from '../../../events/ViewCreated'; import { DbFieldName } from '../../../fields/DbFieldName'; import { FieldId } from '../../../fields/FieldId'; import { FieldName } from '../../../fields/FieldName'; @@ -20,10 +21,15 @@ import { SingleSelectField } from '../../../fields/types/SingleSelectField'; import { Table } from '../../../Table'; import { TableName } from '../../../TableName'; import { ViewColumnMeta } from '../../../views/ViewColumnMeta'; +import { ViewId } from '../../../views/ViewId'; +import { ViewName } from '../../../views/ViewName'; +import { ViewQueryDefaults } from '../../../views/ViewQueryDefaults'; +import { GridView } from '../../../views/types/GridView'; import { RemoveSymmetricLinkFieldSpec } from '../../field-updates/RemoveSymmetricLinkFieldSpec'; import { UpdateNumberFormattingSpec } from '../../field-updates/UpdateNumberFormattingSpec'; import { TableAddFieldSpec } from '../../TableAddFieldSpec'; import { TableAddFieldsSpec } from '../../TableAddFieldsSpec'; +import { TableAddViewSpec } from '../../TableAddViewSpec'; import { TableAddSelectOptionsSpec } from '../../TableAddSelectOptionsSpec'; import { TableByBaseIdSpec } from '../../TableByBaseIdSpec'; import { TableByIdSpec } from '../../TableByIdSpec'; @@ -96,6 +102,31 @@ describe('TableEventGeneratingSpecVisitor', () => { expect(events[1]).toBeInstanceOf(FieldCreated); }); + it('generates ViewCreated for TableAddViewSpec', () => { + const table = buildTable(); + const view = GridView.create({ + id: ViewId.create(`viw${'b'.repeat(16)}`)._unsafeUnwrap(), + name: ViewName.create('Planning')._unsafeUnwrap(), + })._unsafeUnwrap(); + view + .setColumnMeta( + ViewColumnMeta.forView({ + viewType: view.type(), + fields: table.getFields(), + primaryFieldId: table.primaryFieldId(), + })._unsafeUnwrap() + ) + ._unsafeUnwrap(); + view.setQueryDefaults(ViewQueryDefaults.empty())._unsafeUnwrap(); + + const visitor = new TableEventGeneratingSpecVisitor(table); + TableAddViewSpec.create(view).accept(visitor)._unsafeUnwrap(); + + expect(visitor.getEvents()).toHaveLength(1); + expect(visitor.getEvents()[0]).toBeInstanceOf(ViewCreated); + expect((visitor.getEvents()[0] as ViewCreated).viewId.equals(view.id())).toBe(true); + }); + it('generates FieldOptionsAdded only when added options are non-empty', () => { const table = buildTable(); const visitor = new TableEventGeneratingSpecVisitor(table); diff --git a/packages/v2/core/src/domain/table/specs/visitors/__tests__/TableSpecEventVisitor.spec.ts b/packages/v2/core/src/domain/table/specs/visitors/__tests__/TableSpecEventVisitor.spec.ts index 2118a1a785..ef8f2744ec 100644 --- a/packages/v2/core/src/domain/table/specs/visitors/__tests__/TableSpecEventVisitor.spec.ts +++ b/packages/v2/core/src/domain/table/specs/visitors/__tests__/TableSpecEventVisitor.spec.ts @@ -505,7 +505,7 @@ describe('TableSpecEventVisitor', () => { [ protoInstance(TableAddSelectOptionsSpec), protoInstance(TableDuplicateFieldSpec), - protoInstance(TableUpdateViewQueryDefaultsSpec), + protoInstance(TableUpdateViewQueryDefaultsSpec, { updatesValue: [] }), protoInstance(TableByBaseIdSpec), protoInstance(TableByIdSpec), protoInstance(TableByIncomingReferenceToTableSpec), diff --git a/packages/v2/core/src/domain/table/views/ARCHITECTURE.md b/packages/v2/core/src/domain/table/views/ARCHITECTURE.md index 236f9ebcfd..a43a28576b 100644 --- a/packages/v2/core/src/domain/table/views/ARCHITECTURE.md +++ b/packages/v2/core/src/domain/table/views/ARCHITECTURE.md @@ -17,13 +17,26 @@ Declaration: If the folder I belong to changes, please update me, especially cor - `ARCHITECTURE.md` - Role: folder architecture note; Purpose: describe view abstractions. - `View.ts` - Role: view base; Purpose: shared view behavior + visitor entry. +- `ViewAuditMetadata.ts` - Role: value object; Purpose: preserve View creation and modification + attribution. - `ViewBasics.spec.ts` - Role: view tests; Purpose: verify view basics. - `ViewColumnMeta.ts` - Role: value object; Purpose: validate and build view column meta. - `ViewFactory.ts` - Role: factory; Purpose: create view subtypes. +- `ViewGroup.ts` - Role: value object; Purpose: validate and preserve grouped field configuration. - `ViewId.ts` - Role: value object; Purpose: ViewId validation and generation. - `ViewName.ts` - Role: value object; Purpose: ViewName validation and wrapping. +- `ViewOptions.ts` - Role: domain validation; Purpose: validate type-specific options during View creation. +- `ViewOrder.ts` - Role: value object; Purpose: validate and preserve aggregate-relative View order. +- `ViewProperties.ts` - Role: value object; Purpose: immutable description, lock, and share creation properties. +- `ViewQueryDefaults.ts` - Role: value object; Purpose: immutable canonical filter, sort, group, and manual-sort defaults. +- `ViewSnapshot.ts` - Role: domain snapshot; Purpose: capture and safely replay View state for v2 + undo and redo without restoring revoked share credentials. +- `ViewSort.ts` - Role: value object; Purpose: validate and preserve the public nullable View sort + payload while mapping it to query defaults. +- `ViewSourceFilter.ts` - Role: compatibility value object; Purpose: validate and preserve the public View filter while deriving its canonical v2 form. - `ViewType.spec.ts` - Role: value object tests; Purpose: verify ViewType validation. - `ViewType.ts` - Role: value object; Purpose: view type enum wrapper. +- `ViewVersion.ts` - Role: value object; Purpose: enforce non-negative optimistic View versions. ## Examples diff --git a/packages/v2/core/src/domain/table/views/OnTeableViewFieldDeleted.ts b/packages/v2/core/src/domain/table/views/OnTeableViewFieldDeleted.ts index 5ea3440ec0..cb892094ef 100644 --- a/packages/v2/core/src/domain/table/views/OnTeableViewFieldDeleted.ts +++ b/packages/v2/core/src/domain/table/views/OnTeableViewFieldDeleted.ts @@ -1,18 +1,24 @@ import type { Result } from 'neverthrow'; import type { DomainError } from '../../shared/DomainError'; -import type { FieldId } from '../fields/FieldId'; import type { Field } from '../fields/Field'; +import type { FieldId } from '../fields/FieldId'; import type { FieldDeletionContext } from '../OnTeableFieldDeleted'; import type { ViewColumnMeta } from './ViewColumnMeta'; import type { ViewId } from './ViewId'; import type { ViewQueryDefaults } from './ViewQueryDefaults'; +export type ViewFieldDeletionOptionsUpdate = { + previousOptions: unknown; + nextOptions: unknown; +}; + export type ViewFieldDeletionUpdate = { viewId: ViewId; fieldId: FieldId; columnMeta?: ViewColumnMeta; queryDefaults?: ViewQueryDefaults; + options?: ViewFieldDeletionOptionsUpdate; }; export interface OnTeableViewFieldDeleted { diff --git a/packages/v2/core/src/domain/table/views/View.ts b/packages/v2/core/src/domain/table/views/View.ts index da8fe47db2..3c151a45c7 100644 --- a/packages/v2/core/src/domain/table/views/View.ts +++ b/packages/v2/core/src/domain/table/views/View.ts @@ -6,24 +6,36 @@ import { domainError, type DomainError } from '../../shared/DomainError'; import { Entity } from '../../shared/Entity'; import type { Field } from '../fields/Field'; import type { FieldDeletionContext } from '../OnTeableFieldDeleted'; +import type { + OnTeableViewFieldDeleted, + ViewFieldDeletionOptionsUpdate, + ViewFieldDeletionUpdate, +} from './OnTeableViewFieldDeleted'; +import type { ViewAuditMetadata } from './ViewAuditMetadata'; import { ViewColumnMeta } from './ViewColumnMeta'; -import type { OnTeableViewFieldDeleted, ViewFieldDeletionUpdate } from './OnTeableViewFieldDeleted'; import type { ViewId } from './ViewId'; import type { ViewName } from './ViewName'; +import type { ViewOrder } from './ViewOrder'; +import { ViewProperties } from './ViewProperties'; import type { ViewQueryDefaults } from './ViewQueryDefaults'; import { ViewQueryDefaults as ViewQueryDefaultsValue } from './ViewQueryDefaults'; import type { ViewType } from './ViewType'; +import type { ViewVersion } from './ViewVersion'; import type { IViewVisitor } from './visitors/IViewVisitor'; export abstract class View extends Entity implements OnTeableViewFieldDeleted { private columnMetaValue: ViewColumnMeta | undefined; private queryDefaultsValue: ViewQueryDefaults | undefined; private optionsValue: unknown; + private auditMetadataValue: ViewAuditMetadata | undefined; + private orderValue: ViewOrder | undefined; + private versionValue: ViewVersion | undefined; protected constructor( id: ViewId, private readonly nameValue: ViewName, - private readonly typeValue: ViewType + private readonly typeValue: ViewType, + private readonly propertiesValue: ViewProperties = ViewProperties.empty() ) { super(id); } @@ -36,6 +48,30 @@ export abstract class View extends Entity implements OnTeableViewFieldDe return this.typeValue; } + properties(): ViewProperties { + return this.propertiesValue; + } + + description(): string | undefined { + return this.propertiesValue.description(); + } + + isLocked(): boolean | undefined { + return this.propertiesValue.isLocked(); + } + + enableShare(): boolean | undefined { + return this.propertiesValue.enableShare(); + } + + shareId(): string | undefined { + return this.propertiesValue.shareId(); + } + + shareMeta(): ReturnType { + return this.propertiesValue.shareMeta(); + } + columnMeta(): Result { if (!this.columnMetaValue) return err(domainError.invariant({ message: 'ViewColumnMeta not set' })); @@ -52,6 +88,27 @@ export abstract class View extends Entity implements OnTeableViewFieldDe return this.optionsValue; } + auditMetadata(): Result { + if (!this.auditMetadataValue) { + return err(domainError.invariant({ message: 'ViewAuditMetadata not set' })); + } + return ok(this.auditMetadataValue); + } + + order(): Result { + if (!this.orderValue) { + return err(domainError.invariant({ message: 'ViewOrder not set' })); + } + return ok(this.orderValue); + } + + version(): Result { + if (!this.versionValue) { + return err(domainError.invariant({ message: 'ViewVersion not set' })); + } + return ok(this.versionValue); + } + setColumnMeta(columnMeta: ViewColumnMeta): Result { if (this.columnMetaValue) { if (this.columnMetaValue.equals(columnMeta)) return ok(undefined); @@ -83,6 +140,43 @@ export abstract class View extends Entity implements OnTeableViewFieldDe return ok(undefined); } + setAuditMetadata(metadata: ViewAuditMetadata): Result { + if (this.auditMetadataValue) { + if (this.auditMetadataValue.equals(metadata)) return ok(undefined); + return err(domainError.invariant({ message: 'ViewAuditMetadata already set' })); + } + this.auditMetadataValue = metadata; + return ok(undefined); + } + + setOrder(order: ViewOrder): Result { + if (this.orderValue) { + if (this.orderValue.equals(order)) return ok(undefined); + return err(domainError.invariant({ message: 'ViewOrder already set' })); + } + this.orderValue = order; + return ok(undefined); + } + + setVersion(version: ViewVersion): Result { + if (this.versionValue) { + if (this.versionValue.equals(version)) return ok(undefined); + return err(domainError.invariant({ message: 'ViewVersion already set' })); + } + this.versionValue = version; + return ok(undefined); + } + + /** + * Move the persisted-version baseline forward after a successful repository + * update so a later update reusing the same aggregate instance does not + * carry a stale optimistic-lock expectation. Never moves backwards. + */ + advanceVersion(version: ViewVersion): void { + if (this.versionValue && version.toNumber() <= this.versionValue.toNumber()) return; + this.versionValue = version; + } + onFieldDeleted( deletedField: Field, context: FieldDeletionContext @@ -132,7 +226,11 @@ export abstract class View extends Entity implements OnTeableViewFieldDe ...(nextManualSort !== undefined ? { manualSort: nextManualSort } : {}), }; - const nextQueryDefaultsResult = ViewQueryDefaultsValue.rehydrate(nextQueryDefaultsDto); + const filterChanged = + JSON.stringify(currentQueryDefaultsDto.filter) !== JSON.stringify(nextFilter); + const nextQueryDefaultsResult = ViewQueryDefaultsValue.rehydrate(nextQueryDefaultsDto, { + sourceFilter: filterChanged ? undefined : currentQueryDefaults.sourceFilter(), + }); if (nextQueryDefaultsResult.isErr()) return err(nextQueryDefaultsResult.error); const nextQueryDefaults = nextQueryDefaultsResult.value; @@ -140,7 +238,13 @@ export abstract class View extends Entity implements OnTeableViewFieldDe nextQueryDefaultsValue = nextQueryDefaults; } - if (!nextColumnMetaValue && !nextQueryDefaultsValue) { + const nextOptionsValue = this.buildNextOptionsAfterFieldDeletion( + currentColumnMeta, + deletedFieldId, + context + ); + + if (!nextColumnMetaValue && !nextQueryDefaultsValue && !nextOptionsValue) { return ok(undefined); } @@ -149,9 +253,81 @@ export abstract class View extends Entity implements OnTeableViewFieldDe fieldId: deletedField.id(), columnMeta: nextColumnMetaValue, queryDefaults: nextQueryDefaultsValue, + options: nextOptionsValue, }); } + /** + * v1 parity (adjustFrozenField): when the field carrying the frozen boundary + * is deleted, the boundary moves to the previous column in display order; + * deleting the first frozen column clears the boundary. Without this the + * persisted options keep a dangling frozenFieldId (T6520). + */ + private buildNextOptionsAfterFieldDeletion( + currentColumnMeta: ViewColumnMeta, + deletedFieldId: string, + context: FieldDeletionContext + ): ViewFieldDeletionOptionsUpdate | undefined { + const options = this.optionsValue; + if (options == null || typeof options !== 'object') return undefined; + if ((options as { frozenFieldId?: unknown }).frozenFieldId !== deletedFieldId) { + return undefined; + } + + // Effective display order: primary field first, then table field order, + // overridden by explicit columnMeta.order entries (columnMeta is sparse). + // Use the pre-deletion table state so the deleted field still has both its + // position and its explicit columnMeta order (cleanup may have pruned the + // entry from the current view already). + const orderSourceTable = context.previousSourceTable ?? context.table; + const previousView = orderSourceTable + .views() + .find((candidate) => candidate.id().equals(this.id())); + const previousColumnMetaResult = previousView?.columnMeta(); + const meta = + previousColumnMetaResult?.isOk() === true + ? previousColumnMetaResult.value.toDto() + : currentColumnMeta.toDto(); + const fields = orderSourceTable.getFields(); + const primaryFieldResult = orderSourceTable.primaryField(); + const primaryFieldId = primaryFieldResult.isOk() + ? primaryFieldResult.value.id().toString() + : undefined; + const defaultOrdered = primaryFieldId + ? [ + ...fields.filter((field) => field.id().toString() === primaryFieldId), + ...fields.filter((field) => field.id().toString() !== primaryFieldId), + ] + : fields; + const orderedFieldIds = defaultOrdered + .map((field, index) => { + const fieldId = field.id().toString(); + return { fieldId, order: meta[fieldId]?.order ?? index }; + }) + .sort((a, b) => a.order - b.order) + .map((entry) => entry.fieldId); + + const index = orderedFieldIds.indexOf(deletedFieldId); + const survivingFieldIds = new Set( + context.sourceTable.getFields().map((field) => field.id().toString()) + ); + const previousFieldId = + index > 0 + ? orderedFieldIds + .slice(0, index) + .reverse() + .find((fieldId) => survivingFieldIds.has(fieldId)) + : undefined; + + const nextOptions: Record = { ...(options as Record) }; + if (previousFieldId) { + nextOptions.frozenFieldId = previousFieldId; + } else { + delete nextOptions.frozenFieldId; + } + return { previousOptions: options, nextOptions }; + } + private buildNextColumnMeta( currentColumnMeta: ViewColumnMeta, deletedFieldId: string, diff --git a/packages/v2/core/src/domain/table/views/ViewAuditMetadata.ts b/packages/v2/core/src/domain/table/views/ViewAuditMetadata.ts new file mode 100644 index 0000000000..0f0edece42 --- /dev/null +++ b/packages/v2/core/src/domain/table/views/ViewAuditMetadata.ts @@ -0,0 +1,49 @@ +import { err, ok, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import { ValueObject } from '../../shared/ValueObject'; + +const viewAuditMetadataSchema = z + .object({ + createdBy: z.string().min(1), + createdTime: z.string().min(1), + lastModifiedBy: z.string().min(1).optional(), + lastModifiedTime: z.string().min(1).optional(), + }) + .strict(); + +export type ViewAuditMetadataValue = z.infer; + +/** + * Audit metadata rehydrated with a View child entity. + * + * It is intentionally absent on newly constructed Views and does not participate + * in Table invariants or View mutation behavior. + */ +export class ViewAuditMetadata extends ValueObject { + private constructor(private readonly value: ViewAuditMetadataValue) { + super(); + } + + static rehydrate(raw: unknown): Result { + const parsed = viewAuditMetadataSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid ViewAuditMetadata', + details: z.formatError(parsed.error), + }) + ); + } + return ok(new ViewAuditMetadata(parsed.data)); + } + + toDto(): ViewAuditMetadataValue { + return { ...this.value }; + } + + equals(other: ViewAuditMetadata): boolean { + return JSON.stringify(this.value) === JSON.stringify(other.value); + } +} diff --git a/packages/v2/core/src/domain/table/views/ViewBasics.spec.ts b/packages/v2/core/src/domain/table/views/ViewBasics.spec.ts index 270266eb05..469a701451 100644 --- a/packages/v2/core/src/domain/table/views/ViewBasics.spec.ts +++ b/packages/v2/core/src/domain/table/views/ViewBasics.spec.ts @@ -7,6 +7,7 @@ import { GalleryView } from './types/GalleryView'; import { GridView } from './types/GridView'; import { KanbanView } from './types/KanbanView'; import { PluginView } from './types/PluginView'; +import { ViewAuditMetadata } from './ViewAuditMetadata'; import { createCalendarView, createFormView, @@ -17,6 +18,8 @@ import { } from './ViewFactory'; import { ViewId } from './ViewId'; import { ViewName } from './ViewName'; +import { ViewVersion } from './ViewVersion'; +import { CloneViewVisitor } from './visitors/CloneViewVisitor'; import type { IViewVisitor } from './visitors/IViewVisitor'; import { NoopViewVisitor } from './visitors/NoopViewVisitor'; @@ -45,8 +48,9 @@ class RecordingViewVisitor implements IViewVisitor { describe('ViewName', () => { it('validates view names', () => { - ViewName.create('Grid')._unsafeUnwrap(); - ViewName.create('')._unsafeUnwrapErr(); + expect(ViewName.create('Grid')._unsafeUnwrap().toString()).toBe('Grid'); + expect(ViewName.create('')._unsafeUnwrap().toString()).toBe(''); + expect(ViewName.create(' Grid ')._unsafeUnwrap().toString()).toBe(' Grid '); }); it('compares view names by value', () => { @@ -139,4 +143,31 @@ describe('View types and visitors', () => { ]; results.forEach((r) => r._unsafeUnwrap()); }); + + it('preserves persistence metadata when an existing View is cloned for a Table mutation', () => { + const view = createGridView({ + id: createViewId('c')._unsafeUnwrap(), + name: ViewName.create('Versioned View')._unsafeUnwrap(), + })._unsafeUnwrap(); + const auditMetadata = ViewAuditMetadata.rehydrate({ + createdBy: 'usrCreator', + createdTime: '2026-07-30T00:00:00.000Z', + lastModifiedBy: 'usrEditor', + lastModifiedTime: '2026-07-30T01:00:00.000Z', + })._unsafeUnwrap(); + const version = ViewVersion.rehydrate(7)._unsafeUnwrap(); + view.setAuditMetadata(auditMetadata)._unsafeUnwrap(); + view.setVersion(version)._unsafeUnwrap(); + + const clone = view + .accept( + new CloneViewVisitor({ + name: ViewName.create('Versioned View renamed')._unsafeUnwrap(), + }) + ) + ._unsafeUnwrap(); + + expect(clone.auditMetadata()._unsafeUnwrap().equals(auditMetadata)).toBe(true); + expect(clone.version()._unsafeUnwrap().toNumber()).toBe(7); + }); }); diff --git a/packages/v2/core/src/domain/table/views/ViewColumnMeta.spec.ts b/packages/v2/core/src/domain/table/views/ViewColumnMeta.spec.ts new file mode 100644 index 0000000000..db44eab4ba --- /dev/null +++ b/packages/v2/core/src/domain/table/views/ViewColumnMeta.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { FieldId } from '../fields/FieldId'; +import { ViewColumnMeta } from './ViewColumnMeta'; + +const fieldId = FieldId.create(`fld${'a'.repeat(16)}`)._unsafeUnwrap(); + +describe('ViewColumnMeta.applyPatches', () => { + it('merges repeated patches in request order and reports exact transitions', () => { + const metadata = ViewColumnMeta.create({ + [fieldId.toString()]: { order: 0, width: 180 }, + })._unsafeUnwrap(); + + const result = metadata + .applyPatches([ + { fieldId, columnMeta: { width: 240 } }, + { fieldId, columnMeta: { hidden: true } }, + ]) + ._unsafeUnwrap(); + + expect(result.columnMeta.toDto()[fieldId.toString()]).toEqual({ + order: 0, + width: 240, + hidden: true, + }); + expect(result.changes).toEqual([ + { + fieldId, + previousColumnMeta: { order: 0, width: 180 }, + nextColumnMeta: { order: 0, width: 240, hidden: true }, + }, + ]); + }); + + it('does not produce a change for an identical patch', () => { + const metadata = ViewColumnMeta.create({ + [fieldId.toString()]: { order: 0, width: 180 }, + })._unsafeUnwrap(); + + const result = metadata + .applyPatches([{ fieldId, columnMeta: { order: 0, width: 180 } }]) + ._unsafeUnwrap(); + + expect(result.columnMeta.equals(metadata)).toBe(true); + expect(result.changes).toEqual([]); + }); +}); diff --git a/packages/v2/core/src/domain/table/views/ViewColumnMeta.ts b/packages/v2/core/src/domain/table/views/ViewColumnMeta.ts index 6878613d43..ab98a4e061 100644 --- a/packages/v2/core/src/domain/table/views/ViewColumnMeta.ts +++ b/packages/v2/core/src/domain/table/views/ViewColumnMeta.ts @@ -21,6 +21,31 @@ export type ViewColumnMetaEntry = { export type ViewColumnMetaValue = Record; +export type ViewColumnMetaPatch = { + readonly fieldId: FieldId; + readonly columnMeta: ViewColumnMetaEntry; +}; + +export type ViewColumnMetaChange = { + readonly fieldId: FieldId; + readonly previousColumnMeta?: ViewColumnMetaEntry; + readonly nextColumnMeta: ViewColumnMetaEntry; +}; + +export const getDefaultViewColumnOrderByFieldId = ( + fields: ReadonlyArray, + primaryFieldId: FieldId +): ReadonlyMap => { + const fieldIds = fields.map((field) => field.id()); + const primaryIndex = fieldIds.findIndex((fieldId) => fieldId.equals(primaryFieldId)); + const orderedFieldIds = + primaryIndex === -1 + ? fieldIds + : [fieldIds[primaryIndex]!, ...fieldIds.filter((fieldId) => !fieldId.equals(primaryFieldId))]; + + return new Map(orderedFieldIds.map((fieldId, index) => [fieldId.toString(), index])); +}; + const viewColumnMetaEntrySchema: z.ZodType = z.looseObject({ order: z.number().nullable().optional(), visible: z.boolean().optional(), @@ -73,11 +98,14 @@ export class ViewColumnMeta extends ValueObject { fields: ReadonlyArray; primaryFieldId: FieldId; }): Result { - const orderedFieldIds = ViewColumnMeta.orderFieldIds(params.fields, params.primaryFieldId); + const defaultOrderByFieldId = getDefaultViewColumnOrderByFieldId( + params.fields, + params.primaryFieldId + ); const columnMeta: ViewColumnMetaValue = {}; - orderedFieldIds.forEach((fieldId, index) => { - columnMeta[fieldId.toString()] = { order: index }; + defaultOrderByFieldId.forEach((order, fieldId) => { + columnMeta[fieldId] = { order }; }); const viewType = params.viewType.toString(); @@ -114,18 +142,43 @@ export class ViewColumnMeta extends ValueObject { return ViewColumnMeta.cloneValue(this.value); } - private static orderFieldIds( - fields: ReadonlyArray, - primaryFieldId: FieldId - ): ReadonlyArray { - const fieldIds = fields.map((field) => field.id()); - const primaryIndex = fieldIds.findIndex((fieldId) => fieldId.equals(primaryFieldId)); - if (primaryIndex === -1) return fieldIds; - - return [ - fieldIds[primaryIndex], - ...fieldIds.filter((fieldId) => !fieldId.equals(primaryFieldId)), - ]; + applyPatches( + patches: ReadonlyArray + ): Result< + { columnMeta: ViewColumnMeta; changes: ReadonlyArray }, + DomainError + > { + const original = ViewColumnMeta.cloneValue(this.value); + const next = ViewColumnMeta.cloneValue(this.value); + const patchedFieldIds = new Map(); + + for (const patch of patches) { + const key = patch.fieldId.toString(); + patchedFieldIds.set(key, patch.fieldId); + next[key] = { + ...(next[key] ?? {}), + ...patch.columnMeta, + }; + } + + const changes: ViewColumnMetaChange[] = []; + for (const [key, fieldId] of patchedFieldIds) { + const previousColumnMeta = original[key] ? { ...original[key] } : undefined; + const nextColumnMeta = { ...(next[key] ?? {}) }; + if (previousColumnMeta && ViewColumnMeta.isSameEntry(previousColumnMeta, nextColumnMeta)) { + continue; + } + changes.push({ + fieldId, + ...(previousColumnMeta ? { previousColumnMeta } : {}), + nextColumnMeta, + }); + } + + return ViewColumnMeta.create(next).map((columnMeta) => ({ + columnMeta, + changes, + })); } private static cloneValue(value: ViewColumnMetaValue): ViewColumnMetaValue { diff --git a/packages/v2/core/src/domain/table/views/ViewFactory.ts b/packages/v2/core/src/domain/table/views/ViewFactory.ts index 6aceade74d..c98fbe0c86 100644 --- a/packages/v2/core/src/domain/table/views/ViewFactory.ts +++ b/packages/v2/core/src/domain/table/views/ViewFactory.ts @@ -10,29 +10,43 @@ import { PluginView } from './types/PluginView'; import type { View } from './View'; import type { ViewId } from './ViewId'; import type { ViewName } from './ViewName'; +import type { ViewProperties } from './ViewProperties'; +import type { IViewTypeLiteral } from './ViewType'; -export const createGridView = (params: { id: ViewId; name: ViewName }): Result => +export type ViewFactoryParams = { id: ViewId; name: ViewName; properties?: ViewProperties }; +export type TypedViewFactoryParams = ViewFactoryParams & { type: IViewTypeLiteral }; + +export const createView = (params: TypedViewFactoryParams): Result => { + switch (params.type) { + case 'grid': + return createGridView(params); + case 'calendar': + return createCalendarView(params); + case 'kanban': + return createKanbanView(params); + case 'form': + return createFormView(params); + case 'gallery': + return createGalleryView(params); + case 'plugin': + return createPluginView(params); + } +}; + +export const createGridView = (params: ViewFactoryParams): Result => GridView.create(params); -export const createKanbanView = (params: { - id: ViewId; - name: ViewName; -}): Result => KanbanView.create(params); +export const createKanbanView = (params: ViewFactoryParams): Result => + KanbanView.create(params); -export const createGalleryView = (params: { - id: ViewId; - name: ViewName; -}): Result => GalleryView.create(params); +export const createGalleryView = (params: ViewFactoryParams): Result => + GalleryView.create(params); -export const createCalendarView = (params: { - id: ViewId; - name: ViewName; -}): Result => CalendarView.create(params); +export const createCalendarView = (params: ViewFactoryParams): Result => + CalendarView.create(params); -export const createFormView = (params: { id: ViewId; name: ViewName }): Result => +export const createFormView = (params: ViewFactoryParams): Result => FormView.create(params); -export const createPluginView = (params: { - id: ViewId; - name: ViewName; -}): Result => PluginView.create(params); +export const createPluginView = (params: ViewFactoryParams): Result => + PluginView.create(params); diff --git a/packages/v2/core/src/domain/table/views/ViewFieldDeletion.spec.ts b/packages/v2/core/src/domain/table/views/ViewFieldDeletion.spec.ts index 1f1beb4347..02ae8cbdac 100644 --- a/packages/v2/core/src/domain/table/views/ViewFieldDeletion.spec.ts +++ b/packages/v2/core/src/domain/table/views/ViewFieldDeletion.spec.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest'; import { BaseId } from '../../base/BaseId'; -import { Table } from '../Table'; -import { TableId } from '../TableId'; -import { TableName } from '../TableName'; import { FieldId } from '../fields/FieldId'; import { FieldName } from '../fields/FieldName'; import { SingleLineTextField } from '../fields/types/SingleLineTextField'; +import { Table } from '../Table'; +import { TableId } from '../TableId'; +import { TableName } from '../TableName'; import { GridView } from './types/GridView'; import { ViewColumnMeta } from './ViewColumnMeta'; import { ViewId } from './ViewId'; @@ -14,6 +14,75 @@ import { ViewName } from './ViewName'; import { ViewQueryDefaults } from './ViewQueryDefaults'; describe('View.onFieldDeleted', () => { + it('moves a frozen boundary to the nearest surviving predecessor after a bulk delete', () => { + const baseId = BaseId.create(`bse${'z'.repeat(16)}`)._unsafeUnwrap(); + const tableId = TableId.create(`tbl${'z'.repeat(16)}`)._unsafeUnwrap(); + const viewId = ViewId.create(`viw${'z'.repeat(16)}`)._unsafeUnwrap(); + const firstFieldId = FieldId.create(`fld${'x'.repeat(16)}`)._unsafeUnwrap(); + const middleFieldId = FieldId.create(`fld${'y'.repeat(16)}`)._unsafeUnwrap(); + const frozenFieldId = FieldId.create(`fld${'z'.repeat(16)}`)._unsafeUnwrap(); + + const fields = [ + SingleLineTextField.create({ + id: firstFieldId, + name: FieldName.create('First')._unsafeUnwrap(), + })._unsafeUnwrap(), + SingleLineTextField.create({ + id: middleFieldId, + name: FieldName.create('Middle')._unsafeUnwrap(), + })._unsafeUnwrap(), + SingleLineTextField.create({ + id: frozenFieldId, + name: FieldName.create('Frozen')._unsafeUnwrap(), + })._unsafeUnwrap(), + ]; + const view = GridView.create({ + id: viewId, + name: ViewName.create('Grid')._unsafeUnwrap(), + })._unsafeUnwrap(); + view + .setColumnMeta( + ViewColumnMeta.create({ + [firstFieldId.toString()]: { order: 0 }, + [middleFieldId.toString()]: { order: 1 }, + [frozenFieldId.toString()]: { order: 2 }, + })._unsafeUnwrap() + ) + ._unsafeUnwrap(); + view.setQueryDefaults(ViewQueryDefaults.create({})._unsafeUnwrap())._unsafeUnwrap(); + const optionsResult = view.setOptions({ frozenFieldId: frozenFieldId.toString() }); + expect(optionsResult.isOk()).toBe(true); + if (optionsResult.isErr()) throw new Error(optionsResult.error.message); + + const previousTable = Table.rehydrate({ + id: tableId, + baseId, + name: TableName.create('Tasks')._unsafeUnwrap(), + fields, + views: [view], + primaryFieldId: firstFieldId, + })._unsafeUnwrap(); + const currentTable = Table.rehydrate({ + id: tableId, + baseId, + name: TableName.create('Tasks')._unsafeUnwrap(), + fields: [fields[0]], + views: [view], + primaryFieldId: firstFieldId, + })._unsafeUnwrap(); + const currentView = currentTable.getView(viewId)._unsafeUnwrap(); + + const update = currentView + .onFieldDeleted(fields[2], { + table: currentTable, + sourceTable: currentTable, + previousSourceTable: previousTable, + }) + ._unsafeUnwrap(); + + expect(update?.options?.nextOptions).toEqual({ frozenFieldId: firstFieldId.toString() }); + }); + it('updates column order and query defaults when a field is deleted', () => { const baseId = BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap(); const tableId = TableId.create(`tbl${'a'.repeat(16)}`)._unsafeUnwrap(); @@ -134,12 +203,32 @@ describe('View.onFieldDeleted', () => { ._unsafeUnwrap(); view .setQueryDefaults( - ViewQueryDefaults.create({ - sort: [ - { fieldId: amountFieldId.toString(), order: 'asc' }, - { fieldId: statusFieldId.toString(), order: 'asc' }, - ], - })._unsafeUnwrap() + ViewQueryDefaults.create( + { + filter: { + fieldId: ownerFieldId.toString(), + operator: 'isAnyOf', + value: ['alpha'], + }, + sort: [ + { fieldId: amountFieldId.toString(), order: 'asc' }, + { fieldId: statusFieldId.toString(), order: 'asc' }, + ], + }, + { + sourceFilter: { + conjunction: 'and', + filterSet: [ + { + fieldId: ownerFieldId.toString(), + operator: 'IN', + isSymbol: true, + value: 'alpha', + }, + ], + }, + } + )._unsafeUnwrap() ) ._unsafeUnwrap(); @@ -164,8 +253,104 @@ describe('View.onFieldDeleted', () => { ._unsafeUnwrap(); expect(update?.queryDefaults?.toDto()).toEqual({ + filter: { + conjunction: 'and', + items: [ + { + fieldId: ownerFieldId.toString(), + operator: 'isAnyOf', + value: ['alpha'], + }, + ], + }, sort: [{ fieldId: statusFieldId.toString(), order: 'asc' }], manualSort: false, }); + expect(update?.queryDefaults?.sourceFilter()).toEqual({ + conjunction: 'and', + filterSet: [ + { + fieldId: ownerFieldId.toString(), + operator: 'IN', + isSymbol: true, + value: 'alpha', + }, + ], + }); + }); + + it('does not report a query-default change when deleting a field unrelated to the query', () => { + const baseId = BaseId.create(`bse${'g'.repeat(16)}`)._unsafeUnwrap(); + const tableId = TableId.create(`tbl${'g'.repeat(16)}`)._unsafeUnwrap(); + const viewId = ViewId.create(`viw${'g'.repeat(16)}`)._unsafeUnwrap(); + const queriedFieldId = FieldId.create(`fld${'g'.repeat(16)}`)._unsafeUnwrap(); + const deletedFieldId = FieldId.create(`fld${'h'.repeat(16)}`)._unsafeUnwrap(); + const queriedField = SingleLineTextField.create({ + id: queriedFieldId, + name: FieldName.create('Queried')._unsafeUnwrap(), + })._unsafeUnwrap(); + const deletedField = SingleLineTextField.create({ + id: deletedFieldId, + name: FieldName.create('Unrelated')._unsafeUnwrap(), + })._unsafeUnwrap(); + const sourceFilter = { + conjunction: 'and' as const, + filterSet: [ + { + fieldId: queriedFieldId.toString(), + operator: '=' as const, + isSymbol: true as const, + value: 'alpha', + }, + ], + }; + const view = GridView.create({ + id: viewId, + name: ViewName.create('Grid')._unsafeUnwrap(), + })._unsafeUnwrap(); + view + .setColumnMeta( + ViewColumnMeta.create({ + [queriedFieldId.toString()]: { order: 0 }, + [deletedFieldId.toString()]: { order: 1 }, + })._unsafeUnwrap() + ) + ._unsafeUnwrap(); + view + .setQueryDefaults( + ViewQueryDefaults.create( + { + filter: { + fieldId: queriedFieldId.toString(), + operator: 'is', + value: 'alpha', + }, + }, + { sourceFilter } + )._unsafeUnwrap() + ) + ._unsafeUnwrap(); + + const previousTable = Table.rehydrate({ + id: tableId, + baseId, + name: TableName.create('Tasks')._unsafeUnwrap(), + fields: [queriedField, deletedField], + views: [view], + primaryFieldId: queriedFieldId, + })._unsafeUnwrap(); + const currentTable = previousTable.removeField(deletedFieldId)._unsafeUnwrap(); + const currentView = currentTable.getView(viewId)._unsafeUnwrap(); + + const update = currentView + .onFieldDeleted(deletedField, { + table: currentTable, + sourceTable: currentTable, + previousSourceTable: previousTable, + }) + ._unsafeUnwrap(); + + expect(update).toBeUndefined(); + expect(currentView.queryDefaults()._unsafeUnwrap().sourceFilter()).toEqual(sourceFilter); }); }); diff --git a/packages/v2/core/src/domain/table/views/ViewGroup.ts b/packages/v2/core/src/domain/table/views/ViewGroup.ts new file mode 100644 index 0000000000..1882ab8ffc --- /dev/null +++ b/packages/v2/core/src/domain/table/views/ViewGroup.ts @@ -0,0 +1,48 @@ +import { err, ok, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import { ValueObject } from '../../shared/ValueObject'; +import type { ViewQueryDefaults } from './ViewQueryDefaults'; + +export const viewGroupItemSchema = z.object({ + fieldId: z.string().min(1), + order: z.enum(['asc', 'desc']), +}); + +export const viewGroupSchema = z.array(viewGroupItemSchema).nullable(); + +export type ViewGroupItem = z.infer; +export type ViewGroupDTO = z.infer; + +export const viewGroupDtoFromQueryDefaults = (queryDefaults: ViewQueryDefaults): ViewGroupDTO => { + const group = queryDefaults.group(); + return group === undefined ? null : group.map((item) => ({ ...item })); +}; + +export class ViewGroup extends ValueObject { + private constructor(private readonly value: ViewGroupDTO) { + super(); + } + + static create(raw: unknown): Result { + const parsed = viewGroupSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid View group', + details: z.formatError(parsed.error), + }) + ); + } + return ok(new ViewGroup(parsed.data)); + } + + toDto(): ViewGroupDTO { + return this.value === null ? null : this.value.map((item) => ({ ...item })); + } + + equals(other: ViewGroup): boolean { + return JSON.stringify(this.value) === JSON.stringify(other.value); + } +} diff --git a/packages/v2/core/src/domain/table/views/ViewName.ts b/packages/v2/core/src/domain/table/views/ViewName.ts index 95a7c79651..e2f71ea538 100644 --- a/packages/v2/core/src/domain/table/views/ViewName.ts +++ b/packages/v2/core/src/domain/table/views/ViewName.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; import { domainError, type DomainError } from '../../shared/DomainError'; import { ValueObject } from '../../shared/ValueObject'; -const viewNameSchema = z.string().trim().min(1); +const viewNameSchema = z.string(); export class ViewName extends ValueObject { private constructor(private readonly value: string) { diff --git a/packages/v2/core/src/domain/table/views/ViewOptions.ts b/packages/v2/core/src/domain/table/views/ViewOptions.ts new file mode 100644 index 0000000000..8c433c253a --- /dev/null +++ b/packages/v2/core/src/domain/table/views/ViewOptions.ts @@ -0,0 +1,118 @@ +import { err, ok, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import { fieldColorSchema } from '../fields/types/FieldColor'; +import type { IViewTypeLiteral } from './ViewType'; + +const gridViewOptionsSchema = z + .object({ + rowHeight: z.enum(['short', 'medium', 'tall', 'extraTall', 'autoFit']).optional(), + fieldNameDisplayLines: z.number().min(1).max(3).optional(), + frozenColumnCount: z.number().min(0).optional(), + frozenFieldId: z.string().optional(), + }) + .strict(); + +const cardViewOptionsSchema = z + .object({ + coverFieldId: z.string().optional().nullable(), + isCoverFit: z.boolean().optional(), + isFieldNameHidden: z.boolean().optional(), + }) + .strict(); + +const kanbanViewOptionsSchema = cardViewOptionsSchema + .extend({ + stackFieldId: z.string().optional(), + isEmptyStackHidden: z.boolean().optional(), + }) + .strict(); + +const galleryViewOptionsSchema = cardViewOptionsSchema; + +const calendarViewOptionsSchema = z + .object({ + startDateFieldId: z.string().optional().nullable(), + endDateFieldId: z.string().optional().nullable(), + titleFieldId: z.string().optional().nullable(), + colorConfig: z + .object({ + type: z.enum(['field', 'custom']), + fieldId: z.string().optional().nullable(), + color: fieldColorSchema.optional().nullable(), + }) + .optional() + .nullable(), + }) + .strict(); + +const formViewOptionsSchema = z + .object({ + coverUrl: z.string().optional(), + logoUrl: z.string().optional(), + submitLabel: z.string().optional(), + }) + .strict(); + +const pluginViewOptionsSchema = z + .object({ + pluginId: z.string(), + pluginInstallId: z.string(), + pluginLogo: z.string(), + }) + .strict(); + +const schemaByType = { + grid: gridViewOptionsSchema, + calendar: calendarViewOptionsSchema, + kanban: kanbanViewOptionsSchema, + form: formViewOptionsSchema, + gallery: galleryViewOptionsSchema, + plugin: pluginViewOptionsSchema, +} satisfies Record; + +export const validateViewCreateOptions = ( + type: IViewTypeLiteral, + raw: unknown +): Result => { + if (raw === undefined && type !== 'plugin') return ok(undefined); + + const parsed = schemaByType[type].safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: `Invalid ${type} View options`, + details: z.formatError(parsed.error as z.ZodError), + }) + ); + } + return ok(parsed.data); +}; + +const asOptionsRecord = (value: unknown): Record => + value != null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; + +export const mergeAndValidateViewOptions = ( + type: IViewTypeLiteral, + current: unknown, + patch: unknown +): Result => { + const parsed = schemaByType[type].safeParse(patch); + if (!parsed.success) { + return err( + domainError.validation({ + code: 'view.options_invalid', + message: `Invalid ${type} View options`, + details: { issues: parsed.error.issues }, + }) + ); + } + + return ok({ + ...asOptionsRecord(current), + ...asOptionsRecord(parsed.data), + }); +}; diff --git a/packages/v2/core/src/domain/table/views/ViewOrder.ts b/packages/v2/core/src/domain/table/views/ViewOrder.ts new file mode 100644 index 0000000000..2fc8b118dd --- /dev/null +++ b/packages/v2/core/src/domain/table/views/ViewOrder.ts @@ -0,0 +1,35 @@ +import { err, ok, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import { ValueObject } from '../../shared/ValueObject'; + +const viewOrderSchema = z.number().finite(); + +/** + * Stable ordering coordinate for a View child inside the Table aggregate. + * + * Newly-created Views may not have one until persistence allocates it. Hydrated + * Views always carry it so aggregate behavior can calculate reorder specs. + */ +export class ViewOrder extends ValueObject { + private constructor(private readonly value: number) { + super(); + } + + static rehydrate(raw: unknown): Result { + const parsed = viewOrderSchema.safeParse(raw); + if (!parsed.success) { + return err(domainError.validation({ message: 'Invalid ViewOrder' })); + } + return ok(new ViewOrder(parsed.data)); + } + + toNumber(): number { + return this.value; + } + + equals(other: ViewOrder): boolean { + return this.value === other.value; + } +} diff --git a/packages/v2/core/src/domain/table/views/ViewProperties.ts b/packages/v2/core/src/domain/table/views/ViewProperties.ts new file mode 100644 index 0000000000..5946bfddfc --- /dev/null +++ b/packages/v2/core/src/domain/table/views/ViewProperties.ts @@ -0,0 +1,144 @@ +import { err, ok, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import { ValueObject } from '../../shared/ValueObject'; + +const viewShareMetaSchema = z + .object({ + allowCopy: z.boolean().optional(), + includeHiddenField: z.boolean().optional(), + password: z.string().min(3).optional(), + includeRecords: z.boolean().optional(), + submit: z.object({ requireLogin: z.boolean().optional() }).optional(), + allowEdit: z.boolean().optional(), + }) + .strict(); + +const viewPropertiesSchema = z + .object({ + description: z.string().optional(), + isLocked: z.boolean().optional(), + enableShare: z.boolean().optional(), + shareId: z.string().optional(), + shareMeta: viewShareMetaSchema.optional(), + }) + .strict(); + +export type ViewShareMetaValue = z.infer; +export type ViewPropertiesValue = z.infer; + +export class ViewProperties extends ValueObject { + private constructor(private readonly value: ViewPropertiesValue) { + super(); + } + + static create(raw: ViewPropertiesValue): Result { + return ViewProperties.fromRaw(raw); + } + + static rehydrate(raw: unknown): Result { + return ViewProperties.fromRaw(raw); + } + + static empty(): ViewProperties { + return new ViewProperties({}); + } + + description(): string | undefined { + return this.value.description; + } + + isLocked(): boolean | undefined { + return this.value.isLocked; + } + + enableShare(): boolean | undefined { + return this.value.enableShare; + } + + shareId(): string | undefined { + return this.value.shareId; + } + + shareMeta(): ViewShareMetaValue | undefined { + return this.value.shareMeta ? ViewProperties.cloneShareMeta(this.value.shareMeta) : undefined; + } + + withDescription(description: string | undefined): Result { + return ViewProperties.create({ + ...this.toDto(), + description, + }); + } + + withLocked(isLocked: boolean | undefined): Result { + return ViewProperties.create({ + ...this.toDto(), + isLocked, + }); + } + + withShareMeta(shareMeta: ViewShareMetaValue | undefined): Result { + return ViewProperties.create({ + ...this.toDto(), + shareMeta, + }); + } + + withShareId(shareId: string | undefined): Result { + return ViewProperties.create({ + ...this.toDto(), + shareId, + }); + } + + withShareState(params: { + enableShare: boolean; + shareId: string | undefined; + shareMeta: ViewShareMetaValue | undefined; + }): Result { + return ViewProperties.create({ + ...this.toDto(), + enableShare: params.enableShare, + shareId: params.shareId, + shareMeta: params.shareMeta, + }); + } + + toDto(): ViewPropertiesValue { + return { + ...(this.value.description !== undefined ? { description: this.value.description } : {}), + ...(this.value.isLocked !== undefined ? { isLocked: this.value.isLocked } : {}), + ...(this.value.enableShare !== undefined ? { enableShare: this.value.enableShare } : {}), + ...(this.value.shareId !== undefined ? { shareId: this.value.shareId } : {}), + ...(this.value.shareMeta + ? { shareMeta: ViewProperties.cloneShareMeta(this.value.shareMeta) } + : {}), + }; + } + + equals(other: ViewProperties): boolean { + return JSON.stringify(this.value) === JSON.stringify(other.value); + } + + private static fromRaw(raw: unknown): Result { + const parsed = viewPropertiesSchema.safeParse(raw ?? {}); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid ViewProperties', + details: z.formatError(parsed.error), + }) + ); + } + return ok(new ViewProperties(parsed.data)); + } + + private static cloneShareMeta(value: ViewShareMetaValue): ViewShareMetaValue { + return { + ...value, + ...(value.submit ? { submit: { ...value.submit } } : {}), + }; + } +} diff --git a/packages/v2/core/src/domain/table/views/ViewQueryDefaults.spec.ts b/packages/v2/core/src/domain/table/views/ViewQueryDefaults.spec.ts index 78f780f11c..11c91462d2 100644 --- a/packages/v2/core/src/domain/table/views/ViewQueryDefaults.spec.ts +++ b/packages/v2/core/src/domain/table/views/ViewQueryDefaults.spec.ts @@ -51,6 +51,199 @@ describe('ViewQueryDefaults', () => { expect(merged.filter()).toBeNull(); }); + it('keeps the lossless source filter separate from the canonical filter', () => { + const canonicalFilter: RecordFilter = { + fieldId: 'fldDefault', + operator: 'isAnyOf', + value: ['A'], + }; + const sourceFilter = { + conjunction: 'and', + filterSet: [{ fieldId: 'fldDefault', operator: 'IN', isSymbol: true, value: 'A' }], + }; + + const defaults = ViewQueryDefaults.create( + { filter: canonicalFilter }, + { sourceFilter } + )._unsafeUnwrap(); + + const derivedCanonicalFilter = { + conjunction: 'and' as const, + items: [canonicalFilter], + }; + expect(defaults.filter()).toEqual(derivedCanonicalFilter); + expect(defaults.sourceFilter()).toEqual(sourceFilter); + expect(defaults.toDto()).toEqual({ filter: derivedCanonicalFilter }); + }); + + it('owns immutable copies of the source filter', () => { + const sourceFilter = { + conjunction: 'and' as const, + filterSet: [ + { + fieldId: 'fldDefault', + operator: 'IN' as const, + isSymbol: true as const, + value: 'A', + }, + ], + }; + const defaults = ViewQueryDefaults.create({}, { sourceFilter })._unsafeUnwrap(); + + sourceFilter.filterSet[0]!.value = 'mutated input'; + expect(defaults.sourceFilter()).toEqual({ + conjunction: 'and', + filterSet: [ + { + fieldId: 'fldDefault', + operator: 'IN', + isSymbol: true, + value: 'A', + }, + ], + }); + + const returned = defaults.sourceFilter(); + if (returned) { + (returned.filterSet[0] as { value: string }).value = 'mutated output'; + } + expect(defaults.sourceFilter()).toEqual({ + conjunction: 'and', + filterSet: [ + { + fieldId: 'fldDefault', + operator: 'IN', + isSymbol: true, + value: 'A', + }, + ], + }); + }); + + it('rejects an arbitrary source filter payload', () => { + const result = ViewQueryDefaults.create( + {}, + { + sourceFilter: { + conjunction: 'and', + filterSet: [{ arbitraryMetadata: 'must not persist' }], + }, + } + ); + + expect(result.isErr()).toBe(true); + }); + + it('rejects source-filter operators and incomplete date values outside the public contract', () => { + const unsupportedOperator = ViewQueryDefaults.create( + {}, + { + sourceFilter: { + conjunction: 'and', + filterSet: [ + { + fieldId: 'fldDate', + operator: 'BETWEEN', + isSymbol: true, + value: [1, 2], + }, + ], + }, + } + ); + const incompleteDateRange = ViewQueryDefaults.create( + {}, + { + sourceFilter: { + conjunction: 'and', + filterSet: [ + { + fieldId: 'fldDate', + operator: 'is', + value: { + mode: 'dateRange', + exactDate: '2026-01-01T00:00:00Z', + timeZone: 'UTC', + }, + }, + ], + }, + } + ); + const unexpectedArray = ViewQueryDefaults.create( + {}, + { + sourceFilter: { + conjunction: 'and', + filterSet: [ + { + fieldId: 'fldNumber', + operator: 'isGreater', + value: [1, 2], + }, + ], + }, + } + ); + + expect(unsupportedOperator.isErr()).toBe(true); + expect(incompleteDateRange.isErr()).toBe(true); + expect(unexpectedArray.isErr()).toBe(true); + }); + + it('derives date-range canonical conditions from the public source filter', () => { + const defaults = ViewQueryDefaults.create( + {}, + { + sourceFilter: { + conjunction: 'and', + filterSet: [ + { + fieldId: 'fldDate', + operator: '=', + isSymbol: true, + value: { + mode: 'dateRange', + exactDate: '2026-01-01T00:00:00Z', + exactDateEnd: '2026-01-31T23:59:59Z', + timeZone: 'UTC', + }, + }, + ], + }, + } + )._unsafeUnwrap(); + + expect(defaults.filter()).toEqual({ + conjunction: 'and', + items: [ + { + conjunction: 'and', + items: [ + { + fieldId: 'fldDate', + operator: 'isOnOrAfter', + value: { + mode: 'exactDate', + exactDate: '2026-01-01T00:00:00Z', + timeZone: 'UTC', + }, + }, + { + fieldId: 'fldDate', + operator: 'isOnOrBefore', + value: { + mode: 'exactDate', + exactDate: '2026-01-31T23:59:59Z', + timeZone: 'UTC', + }, + }, + ], + }, + ], + }); + }); + it('merges sort with query taking precedence', () => { const defaults = ViewQueryDefaults.create({ sort: [ diff --git a/packages/v2/core/src/domain/table/views/ViewQueryDefaults.ts b/packages/v2/core/src/domain/table/views/ViewQueryDefaults.ts index 4888e1b098..4b17ef4615 100644 --- a/packages/v2/core/src/domain/table/views/ViewQueryDefaults.ts +++ b/packages/v2/core/src/domain/table/views/ViewQueryDefaults.ts @@ -1,40 +1,62 @@ import { err, ok, type Result } from 'neverthrow'; import { z } from 'zod'; -import { recordFilterSchema, type RecordFilter } from '../../../queries/RecordFilterDto'; +import { + recordFilterConditionSchema, + recordFilterConjunctionSchema, + type RecordFilter, + type RecordFilterNode, +} from '../../../queries/RecordFilterDto'; import { domainError, type DomainError } from '../../shared/DomainError'; import { ValueObject } from '../../shared/ValueObject'; - -const viewSortItemSchema = z.object({ - fieldId: z.string().min(1), - order: z.enum(['asc', 'desc']), -}); +import { viewSortItemSchema, type ViewSortItem } from './ViewSort'; +import { ViewSourceFilter, type ViewSourceFilterDTO } from './ViewSourceFilter'; const viewGroupItemSchema = z.object({ fieldId: z.string().min(1), order: z.enum(['asc', 'desc']), }); +const viewRecordFilterNodeSchema: z.ZodType = z.lazy(() => + z.union([ + recordFilterConditionSchema, + z.object({ + conjunction: recordFilterConjunctionSchema, + items: z.array(viewRecordFilterNodeSchema), + }), + z.object({ not: viewRecordFilterNodeSchema }), + ]) +); + +export const viewRecordFilterSchema: z.ZodType = + viewRecordFilterNodeSchema.nullable(); + const viewQueryDefaultsSchema = z .object({ - filter: recordFilterSchema.optional().nullable(), + filter: viewRecordFilterSchema.optional().nullable(), sort: z.array(viewSortItemSchema).optional(), group: z.array(viewGroupItemSchema).optional(), manualSort: z.boolean().optional(), }) .strict(); -export type ViewQuerySortItem = z.infer; +export type ViewQuerySortItem = ViewSortItem; export type ViewQueryGroupItem = z.infer; export type ViewQueryDefaultsDTO = z.infer; export class ViewQueryDefaults extends ValueObject { - private constructor(private readonly value: ViewQueryDefaultsDTO) { + private constructor( + private readonly value: ViewQueryDefaultsDTO, + private readonly sourceFilterValue?: ViewSourceFilter + ) { super(); } - static create(raw: ViewQueryDefaultsDTO): Result { + static create( + raw: ViewQueryDefaultsDTO, + options?: { sourceFilter?: unknown } + ): Result { const parsed = viewQueryDefaultsSchema.safeParse(raw ?? {}); if (!parsed.success) return err( @@ -43,10 +65,20 @@ export class ViewQueryDefaults extends ValueObject { details: z.formatError(parsed.error), }) ); - return ok(new ViewQueryDefaults(parsed.data)); + const sourceFilterResult = ViewQueryDefaults.parseSourceFilter(options?.sourceFilter); + if (sourceFilterResult.isErr()) return err(sourceFilterResult.error); + const canonicalResult = ViewQueryDefaults.withCanonicalSourceFilter( + parsed.data, + sourceFilterResult.value + ); + if (canonicalResult.isErr()) return err(canonicalResult.error); + return ok(new ViewQueryDefaults(canonicalResult.value, sourceFilterResult.value)); } - static rehydrate(raw: unknown): Result { + static rehydrate( + raw: unknown, + options?: { sourceFilter?: unknown } + ): Result { const parsed = viewQueryDefaultsSchema.safeParse(raw ?? {}); if (!parsed.success) return err( @@ -55,7 +87,14 @@ export class ViewQueryDefaults extends ValueObject { details: z.formatError(parsed.error), }) ); - return ok(new ViewQueryDefaults(parsed.data)); + const sourceFilterResult = ViewQueryDefaults.parseSourceFilter(options?.sourceFilter); + if (sourceFilterResult.isErr()) return err(sourceFilterResult.error); + const canonicalResult = ViewQueryDefaults.withCanonicalSourceFilter( + parsed.data, + sourceFilterResult.value + ); + if (canonicalResult.isErr()) return err(canonicalResult.error); + return ok(new ViewQueryDefaults(canonicalResult.value, sourceFilterResult.value)); } static empty(): ViewQueryDefaults { @@ -66,6 +105,10 @@ export class ViewQueryDefaults extends ValueObject { return this.value.filter; } + sourceFilter(): ViewSourceFilterDTO | null | undefined { + return this.sourceFilterValue?.toDto(); + } + sort(): ReadonlyArray | undefined { return this.value.sort ? [...this.value.sort] : undefined; } @@ -83,7 +126,13 @@ export class ViewQueryDefaults extends ValueObject { } equals(other: ViewQueryDefaults): boolean { - return ViewQueryDefaults.isSameValue(this.value, other.value); + return ( + ViewQueryDefaults.isSameValue(this.value, other.value) && + ((this.sourceFilterValue == null && other.sourceFilterValue == null) || + (this.sourceFilterValue != null && + other.sourceFilterValue != null && + this.sourceFilterValue.equals(other.sourceFilterValue))) + ); } merge(params: { @@ -163,4 +212,31 @@ export class ViewQueryDefaults extends ValueObject { private static isSameValue(left: ViewQueryDefaultsDTO, right: ViewQueryDefaultsDTO): boolean { return JSON.stringify(left) === JSON.stringify(right); } + + private static parseSourceFilter( + raw: unknown + ): Result { + if (raw === undefined) return ok(undefined); + return ViewSourceFilter.create(raw); + } + + private static withCanonicalSourceFilter( + value: ViewQueryDefaultsDTO, + sourceFilter: ViewSourceFilter | undefined + ): Result { + if (!sourceFilter) return ok(value); + const parsed = viewQueryDefaultsSchema.safeParse({ + ...value, + filter: sourceFilter.toCanonical(), + }); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid canonical ViewSourceFilter', + details: z.formatError(parsed.error), + }) + ); + } + return ok(parsed.data); + } } diff --git a/packages/v2/core/src/domain/table/views/ViewSnapshot.spec.ts b/packages/v2/core/src/domain/table/views/ViewSnapshot.spec.ts new file mode 100644 index 0000000000..9b3e9eadd1 --- /dev/null +++ b/packages/v2/core/src/domain/table/views/ViewSnapshot.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../../base/BaseId'; +import { FieldName } from '../fields/FieldName'; +import { Table } from '../Table'; +import { TableName } from '../TableName'; +import { captureViewSnapshot, rehydrateViewSnapshot } from './ViewSnapshot'; + +const buildTable = (): Table => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'s'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Snapshot')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Name')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + +describe('ViewSnapshot', () => { + it('captures replayable View state without public share credentials', () => { + const table = buildTable(); + const sharedView = table + .createView({ + type: 'grid', + name: 'Shared', + description: 'Replayable configuration', + enableShare: true, + shareId: `shr${'a'.repeat(16)}`, + shareMeta: { allowCopy: false, submit: { requireLogin: true } }, + }) + ._unsafeUnwrap().view; + + const snapshot = captureViewSnapshot(sharedView)._unsafeUnwrap(); + + expect(snapshot.properties).toEqual({ + description: 'Replayable configuration', + shareMeta: { allowCopy: false, submit: { requireLogin: true } }, + }); + expect(JSON.stringify(snapshot)).not.toContain(sharedView.shareId()); + }); + + it('sanitizes credentials from legacy snapshots when rehydrating', () => { + const table = buildTable(); + const source = table + .createView({ + type: 'grid', + name: 'Legacy snapshot', + shareMeta: { allowCopy: true }, + }) + ._unsafeUnwrap().view; + const snapshot = captureViewSnapshot(source)._unsafeUnwrap(); + const revokedShareId = `shr${'r'.repeat(16)}`; + + const restored = rehydrateViewSnapshot({ + ...snapshot, + properties: { + ...snapshot.properties, + enableShare: true, + shareId: revokedShareId, + }, + })._unsafeUnwrap(); + + expect(restored.enableShare()).toBeUndefined(); + expect(restored.shareId()).toBeUndefined(); + expect(restored.shareMeta()).toEqual({ allowCopy: true }); + }); +}); diff --git a/packages/v2/core/src/domain/table/views/ViewSnapshot.ts b/packages/v2/core/src/domain/table/views/ViewSnapshot.ts new file mode 100644 index 0000000000..1d5b999a01 --- /dev/null +++ b/packages/v2/core/src/domain/table/views/ViewSnapshot.ts @@ -0,0 +1,92 @@ +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../shared/DomainError'; +import type { View } from './View'; +import { ViewAuditMetadata, type ViewAuditMetadataValue } from './ViewAuditMetadata'; +import { ViewColumnMeta, type ViewColumnMetaValue } from './ViewColumnMeta'; +import { createView } from './ViewFactory'; +import { ViewId } from './ViewId'; +import { ViewName } from './ViewName'; +import { ViewOrder } from './ViewOrder'; +import { ViewProperties, type ViewPropertiesValue } from './ViewProperties'; +import { ViewQueryDefaults, type ViewQueryDefaultsDTO } from './ViewQueryDefaults'; +import type { IViewTypeLiteral } from './ViewType'; + +export type ViewSnapshotValue = { + readonly id: string; + readonly name: string; + readonly type: IViewTypeLiteral; + readonly order?: number; + readonly properties: ViewPropertiesValue; + readonly columnMeta: ViewColumnMetaValue; + readonly query: ViewQueryDefaultsDTO; + readonly sourceFilter?: unknown; + readonly options?: unknown; + readonly auditMetadata?: ViewAuditMetadataValue; +}; + +/** + * Public share credentials are lifecycle state, not replayable View + * configuration. Keeping them out of generic snapshots ensures undo/redo can + * never reactivate a credential that was revoked after the snapshot was + * written. + */ +const replaySafeProperties = ({ + enableShare: _enableShare, + shareId: _shareId, + ...properties +}: ViewPropertiesValue): ViewPropertiesValue => properties; + +export const captureViewSnapshot = (view: View): Result => + safeTry(function* () { + const columnMeta = yield* view.columnMeta(); + const query = yield* view.queryDefaults(); + const orderResult = view.order(); + const auditMetadataResult = view.auditMetadata(); + + return ok({ + id: view.id().toString(), + name: view.name().toString(), + type: view.type().toString(), + ...(orderResult.isOk() ? { order: orderResult.value.toNumber() } : {}), + properties: replaySafeProperties(view.properties().toDto()), + columnMeta: columnMeta.toDto(), + query: query.toDto(), + ...(query.sourceFilter() !== undefined ? { sourceFilter: query.sourceFilter() } : {}), + ...(view.options() !== undefined ? { options: view.options() } : {}), + ...(auditMetadataResult.isOk() ? { auditMetadata: auditMetadataResult.value.toDto() } : {}), + }); + }); + +export const rehydrateViewSnapshot = (snapshot: ViewSnapshotValue): Result => + safeTry(function* () { + const id = yield* ViewId.create(snapshot.id); + const name = yield* ViewName.create(snapshot.name); + // Sanitize again so undo entries captured before this invariant was added + // cannot restore a stale shareId. + const properties = yield* ViewProperties.rehydrate(replaySafeProperties(snapshot.properties)); + const view = yield* createView({ + id, + name, + type: snapshot.type, + properties, + }); + + yield* view.setColumnMeta(yield* ViewColumnMeta.rehydrate(snapshot.columnMeta)); + yield* view.setQueryDefaults( + yield* ViewQueryDefaults.rehydrate(snapshot.query, { + sourceFilter: snapshot.sourceFilter, + }) + ); + yield* view.setOptions(snapshot.options); + + if (snapshot.order !== undefined) { + yield* view.setOrder(yield* ViewOrder.rehydrate(snapshot.order)); + } + if (snapshot.auditMetadata) { + yield* view.setAuditMetadata(yield* ViewAuditMetadata.rehydrate(snapshot.auditMetadata)); + } + + return ok(view); + }); diff --git a/packages/v2/core/src/domain/table/views/ViewSort.ts b/packages/v2/core/src/domain/table/views/ViewSort.ts new file mode 100644 index 0000000000..5e95400f30 --- /dev/null +++ b/packages/v2/core/src/domain/table/views/ViewSort.ts @@ -0,0 +1,62 @@ +import { err, ok, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import { ValueObject } from '../../shared/ValueObject'; +import type { ViewQueryDefaults } from './ViewQueryDefaults'; + +export const viewSortItemSchema = z.object({ + fieldId: z.string().min(1), + order: z.enum(['asc', 'desc']), +}); + +export const viewSortSchema = z + .object({ + sortObjs: z.array(viewSortItemSchema), + manualSort: z.boolean().optional(), + }) + .nullable(); + +export type ViewSortItem = z.infer; +export type ViewSortDTO = z.infer; + +export const viewSortDtoFromQueryDefaults = (queryDefaults: ViewQueryDefaults): ViewSortDTO => { + const sortObjs = queryDefaults.sort(); + const manualSort = queryDefaults.manualSort(); + if (sortObjs === undefined && manualSort === undefined) return null; + return { + sortObjs: (sortObjs ?? []).map((item) => ({ ...item })), + ...(manualSort !== undefined ? { manualSort } : {}), + }; +}; + +export class ViewSort extends ValueObject { + private constructor(private readonly value: ViewSortDTO) { + super(); + } + + static create(raw: unknown): Result { + const parsed = viewSortSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid View sort', + details: z.formatError(parsed.error), + }) + ); + } + return ok(new ViewSort(parsed.data)); + } + + toDto(): ViewSortDTO { + if (this.value === null) return null; + return { + sortObjs: this.value.sortObjs.map((item) => ({ ...item })), + ...(this.value.manualSort !== undefined ? { manualSort: this.value.manualSort } : {}), + }; + } + + equals(other: ViewSort): boolean { + return JSON.stringify(this.value) === JSON.stringify(other.value); + } +} diff --git a/packages/v2/core/src/domain/table/views/ViewSourceFilter.ts b/packages/v2/core/src/domain/table/views/ViewSourceFilter.ts new file mode 100644 index 0000000000..41d23069b1 --- /dev/null +++ b/packages/v2/core/src/domain/table/views/ViewSourceFilter.ts @@ -0,0 +1,399 @@ +import { err, ok, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import type { + RecordFilter, + RecordFilterDateValue, + RecordFilterNode, + RecordFilterOperator, + RecordFilterValue, +} from '../../../queries/RecordFilterDto'; +import { domainError, type DomainError } from '../../shared/DomainError'; +import { ValueObject } from '../../shared/ValueObject'; +import { + recordConditionOperatorSchema, + recordConditionOperatorsExpectingArray, + recordConditionOperatorsExpectingNull, +} from '../records/specs/RecordConditionOperators'; + +const sourceFilterSymbolOperatorMap = new Map([ + ['=', 'is'], + ['!=', 'isNot'], + ['>', 'isGreater'], + ['>=', 'isGreaterEqual'], + ['<', 'isLess'], + ['<=', 'isLessEqual'], + ['LIKE', 'contains'], + ['NOT LIKE', 'doesNotContain'], + ['IN', 'isAnyOf'], + ['NOT IN', 'isNoneOf'], + ['HAS', 'hasAllOf'], + ['IS NULL', 'isEmpty'], + ['IS NOT NULL', 'isNotEmpty'], +]); + +const sourceFilterSymbolOperatorSchema = z.enum([ + '=', + '!=', + '>', + '>=', + '<', + '<=', + 'LIKE', + 'IN', + 'HAS', + 'NOT LIKE', + 'NOT IN', + 'IS NULL', + 'IS NOT NULL', +]); + +const sourceFilterDateModeSchema = z.enum([ + 'today', + 'tomorrow', + 'yesterday', + 'currentWeek', + 'currentMonth', + 'currentYear', + 'lastWeek', + 'lastMonth', + 'lastYear', + 'nextWeekPeriod', + 'nextMonthPeriod', + 'nextYearPeriod', + 'oneWeekAgo', + 'oneWeekFromNow', + 'oneMonthAgo', + 'oneMonthFromNow', + 'daysAgo', + 'daysFromNow', + 'exactDate', + 'exactDateTime', + 'exactFormatDate', + 'dateRange', + 'pastWeek', + 'pastMonth', + 'pastYear', + 'nextWeek', + 'nextMonth', + 'nextYear', + 'pastNumberOfDays', + 'nextNumberOfDays', +]); + +const sourceFilterDateValueSchema = z + .object({ + mode: sourceFilterDateModeSchema, + numberOfDays: z.coerce.number().int().nonnegative().optional(), + exactDate: z.string().datetime({ offset: true }).optional(), + exactDateEnd: z.string().datetime({ offset: true }).optional(), + timeZone: z.string().refine( + (value) => { + try { + new Intl.DateTimeFormat('en-US', { timeZone: value }).format(); + return true; + } catch { + return false; + } + }, + { message: 'Invalid timezone' } + ), + }) + .superRefine((value, context) => { + const exactDateModes = ['exactDate', 'exactDateTime', 'exactFormatDate']; + const numberOfDaysModes = ['daysAgo', 'daysFromNow', 'pastNumberOfDays', 'nextNumberOfDays']; + if (exactDateModes.includes(value.mode) && !value.exactDate) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['exactDate'], + message: `When mode is '${value.mode}', exactDate is required`, + }); + } + if (value.mode === 'dateRange') { + if (!value.exactDate) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['exactDate'], + message: "When mode is 'dateRange', exactDate is required", + }); + } + if (!value.exactDateEnd) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['exactDateEnd'], + message: "When mode is 'dateRange', exactDateEnd is required", + }); + } + } + if (numberOfDaysModes.includes(value.mode) && value.numberOfDays == null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['numberOfDays'], + message: `When mode is '${value.mode}', numberOfDays is required`, + }); + } + }); + +const sourceFilterLiteralValueSchema = z.union([z.string(), z.number(), z.boolean()]); +const sourceFilterValueSchema = z + .union([ + sourceFilterLiteralValueSchema, + sourceFilterLiteralValueSchema.array().nonempty(), + sourceFilterDateValueSchema, + z.object({ + type: z.literal('field'), + fieldId: z.string(), + tableId: z.string().optional(), + }), + ]) + .nullable(); + +const normalizeUnaryOperatorValue = (input: unknown): unknown => { + if (input == null || typeof input !== 'object') return input; + const value = input as Record; + if ( + typeof value.operator !== 'string' || + !recordConditionOperatorsExpectingNull.includes(value.operator as never) || + Object.prototype.hasOwnProperty.call(value, 'value') + ) { + return input; + } + return { ...value, value: null }; +}; + +const sourceFilterConditionSchema = z.preprocess( + normalizeUnaryOperatorValue, + z.union([ + z.object({ + isSymbol: z.literal(true), + fieldId: z.string(), + value: sourceFilterValueSchema, + operator: sourceFilterSymbolOperatorSchema, + }), + z + .object({ + isSymbol: z.literal(false).optional(), + fieldId: z.string(), + value: sourceFilterValueSchema, + operator: recordConditionOperatorSchema, + }) + .superRefine((value, context) => { + if (recordConditionOperatorsExpectingNull.includes(value.operator)) { + if (value.value !== null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['value'], + message: `Operator '${value.operator}' requires null`, + }); + } + return; + } + if ( + recordConditionOperatorsExpectingArray.includes(value.operator) && + !Array.isArray(value.value) && + !isFieldReference(value.value) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['value'], + message: `Operator '${value.operator}' requires an array value`, + }); + } + if ( + !recordConditionOperatorsExpectingArray.includes(value.operator) && + Array.isArray(value.value) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['value'], + message: `Operator '${value.operator}' does not allow an array value`, + }); + } + }), + ]) +); + +type ViewSourceFilterConditionDTO = z.infer; + +export type ViewSourceFilterDTO = { + conjunction: 'and' | 'or'; + filterSet: Array; +}; + +// v1 stores incomplete list-operator conditions (null or empty-array value) while +// the user is mid-editing a filter, and skips them at query time. Drop them here +// so rehydrating a persisted legacy filter cannot fail the whole table load. +const legacyListOperators: ReadonlyArray = [ + ...recordConditionOperatorsExpectingArray, + 'IN', + 'NOT IN', + 'HAS', +]; + +const isIncompleteLegacyListCondition = (input: unknown): boolean => { + if (input == null || typeof input !== 'object') return false; + const value = input as Record; + if (typeof value.operator !== 'string' || !legacyListOperators.includes(value.operator)) { + return false; + } + return value.value == null || (Array.isArray(value.value) && value.value.length === 0); +}; + +// A group whose conditions were all dropped filters nothing and must not fail +// (or survive) validation; a group that was ALREADY empty in the stored legacy +// filter is preserved verbatim (v1 kept them). +const emptiedByDrop = (before: unknown[], after: unknown[]): boolean => + before.length > 0 && after.length === 0; + +const dropIncompleteLegacyListConditions = (input: unknown): unknown => { + if (input == null || typeof input !== 'object' || !('filterSet' in input)) return input; + const group = input as Record; + if (!Array.isArray(group.filterSet)) return input; + const filterSet = group.filterSet + .map((item) => dropIncompleteLegacyListConditions(item)) + .filter((item) => item !== undefined && !isIncompleteLegacyListCondition(item)); + if (emptiedByDrop(group.filterSet, filterSet)) return undefined; + return { ...group, filterSet }; +}; + +const sourceFilterGroupSchema: z.ZodType = z.preprocess( + dropIncompleteLegacyListConditions, + z.object({ + conjunction: z.enum(['and', 'or']), + filterSet: z.array( + z.lazy(() => z.union([sourceFilterConditionSchema, sourceFilterGroupSchema])) + ), + }) +) as unknown as z.ZodType; + +export const viewSourceFilterSchema: z.ZodType = z.preprocess( + // A top-level filter reduced to nothing by the incomplete-condition cleanup + // filters nothing — normalize it to null like v1 does at query time. + (input) => dropIncompleteLegacyListConditions(input) ?? null, + sourceFilterGroupSchema.nullable() +) as unknown as z.ZodType; + +const isFieldReference = ( + value: unknown +): value is { type: 'field'; fieldId: string; tableId?: string } => + value != null && + typeof value === 'object' && + 'type' in value && + value.type === 'field' && + 'fieldId' in value && + typeof value.fieldId === 'string'; + +const isSourceFilterGroup = ( + value: ViewSourceFilterConditionDTO | ViewSourceFilterDTO +): value is ViewSourceFilterDTO => 'filterSet' in value; + +const mapDateRange = ( + condition: ViewSourceFilterConditionDTO, + operator: RecordFilterOperator +): RecordFilterNode | undefined => { + const value = condition.value; + if ( + (operator !== 'is' && operator !== 'isWithIn') || + value == null || + typeof value !== 'object' || + Array.isArray(value) || + !('mode' in value) || + value.mode !== 'dateRange' + ) { + return undefined; + } + + return { + conjunction: 'and', + items: [ + { + fieldId: condition.fieldId, + operator: 'isOnOrAfter', + value: { + mode: 'exactDate', + exactDate: value.exactDate, + timeZone: value.timeZone, + } as RecordFilterDateValue, + }, + { + fieldId: condition.fieldId, + operator: 'isOnOrBefore', + value: { + mode: 'exactDate', + exactDate: value.exactDateEnd, + timeZone: value.timeZone, + } as RecordFilterDateValue, + }, + ], + }; +}; + +const mapCondition = (condition: ViewSourceFilterConditionDTO): RecordFilterNode | null => { + const operator = + condition.isSymbol === true + ? sourceFilterSymbolOperatorMap.get(condition.operator) + : condition.operator; + if (!operator) return null; + + const dateRange = mapDateRange(condition, operator); + if (dateRange) return dateRange; + if (recordConditionOperatorsExpectingNull.includes(operator)) { + return { fieldId: condition.fieldId, operator, value: null }; + } + if (recordConditionOperatorsExpectingArray.includes(operator)) { + if (condition.value == null) return null; + const value = + Array.isArray(condition.value) || isFieldReference(condition.value) + ? condition.value + : [condition.value]; + return { + fieldId: condition.fieldId, + operator, + value: value as RecordFilterValue, + }; + } + if (condition.value == null && operator !== 'is' && operator !== 'isNot') return null; + return { + fieldId: condition.fieldId, + operator, + value: condition.value as RecordFilterValue, + }; +}; + +const mapGroup = (group: ViewSourceFilterDTO): RecordFilterNode => ({ + conjunction: group.conjunction, + items: group.filterSet + .map((item) => (isSourceFilterGroup(item) ? mapGroup(item) : mapCondition(item))) + .filter((item): item is RecordFilterNode => item != null), +}); + +export class ViewSourceFilter extends ValueObject { + private constructor(private readonly value: ViewSourceFilterDTO | null) { + super(); + } + + static create(raw: unknown): Result { + const parsed = viewSourceFilterSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid ViewSourceFilter', + details: z.formatError(parsed.error), + }) + ); + } + return ok(new ViewSourceFilter(parsed.data)); + } + + toDto(): ViewSourceFilterDTO | null { + return viewSourceFilterSchema.parse(this.value); + } + + toCanonical(): RecordFilter { + return this.value == null ? null : mapGroup(this.value); + } + + equals(other: ViewSourceFilter): boolean { + return JSON.stringify(this.value) === JSON.stringify(other.value); + } +} diff --git a/packages/v2/core/src/domain/table/views/ViewVersion.ts b/packages/v2/core/src/domain/table/views/ViewVersion.ts new file mode 100644 index 0000000000..775a38645b --- /dev/null +++ b/packages/v2/core/src/domain/table/views/ViewVersion.ts @@ -0,0 +1,34 @@ +import { err, ok, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../../shared/DomainError'; +import { ValueObject } from '../../shared/ValueObject'; + +const viewVersionSchema = z.number().int().nonnegative(); + +/** + * Persistence/realtime version rehydrated with a View child. + * + * New Views do not have a version until the Table aggregate is persisted. + */ +export class ViewVersion extends ValueObject { + private constructor(private readonly value: number) { + super(); + } + + static rehydrate(raw: unknown): Result { + const parsed = viewVersionSchema.safeParse(raw); + if (!parsed.success) { + return err(domainError.validation({ message: 'Invalid ViewVersion' })); + } + return ok(new ViewVersion(parsed.data)); + } + + toNumber(): number { + return this.value; + } + + equals(other: ViewVersion): boolean { + return this.value === other.value; + } +} diff --git a/packages/v2/core/src/domain/table/views/types/CalendarView.ts b/packages/v2/core/src/domain/table/views/types/CalendarView.ts index 3e1c2d0311..24f73ad35a 100644 --- a/packages/v2/core/src/domain/table/views/types/CalendarView.ts +++ b/packages/v2/core/src/domain/table/views/types/CalendarView.ts @@ -5,16 +5,21 @@ import type { DomainError } from '../../../shared/DomainError'; import { View } from '../View'; import type { ViewId } from '../ViewId'; import type { ViewName } from '../ViewName'; +import type { ViewProperties } from '../ViewProperties'; import { ViewType } from '../ViewType'; import type { IViewVisitor } from '../visitors/IViewVisitor'; export class CalendarView extends View { - private constructor(id: ViewId, name: ViewName) { - super(id, name, ViewType.calendar()); + private constructor(id: ViewId, name: ViewName, properties?: ViewProperties) { + super(id, name, ViewType.calendar(), properties); } - static create(params: { id: ViewId; name: ViewName }): Result { - return ok(new CalendarView(params.id, params.name)); + static create(params: { + id: ViewId; + name: ViewName; + properties?: ViewProperties; + }): Result { + return ok(new CalendarView(params.id, params.name, params.properties)); } accept(visitor: IViewVisitor): Result { diff --git a/packages/v2/core/src/domain/table/views/types/FormView.ts b/packages/v2/core/src/domain/table/views/types/FormView.ts index 4aad103c8d..84902965f2 100644 --- a/packages/v2/core/src/domain/table/views/types/FormView.ts +++ b/packages/v2/core/src/domain/table/views/types/FormView.ts @@ -5,16 +5,21 @@ import type { DomainError } from '../../../shared/DomainError'; import { View } from '../View'; import type { ViewId } from '../ViewId'; import type { ViewName } from '../ViewName'; +import type { ViewProperties } from '../ViewProperties'; import { ViewType } from '../ViewType'; import type { IViewVisitor } from '../visitors/IViewVisitor'; export class FormView extends View { - private constructor(id: ViewId, name: ViewName) { - super(id, name, ViewType.form()); + private constructor(id: ViewId, name: ViewName, properties?: ViewProperties) { + super(id, name, ViewType.form(), properties); } - static create(params: { id: ViewId; name: ViewName }): Result { - return ok(new FormView(params.id, params.name)); + static create(params: { + id: ViewId; + name: ViewName; + properties?: ViewProperties; + }): Result { + return ok(new FormView(params.id, params.name, params.properties)); } accept(visitor: IViewVisitor): Result { diff --git a/packages/v2/core/src/domain/table/views/types/GalleryView.ts b/packages/v2/core/src/domain/table/views/types/GalleryView.ts index 1f9ae1b898..75344ba5ef 100644 --- a/packages/v2/core/src/domain/table/views/types/GalleryView.ts +++ b/packages/v2/core/src/domain/table/views/types/GalleryView.ts @@ -5,16 +5,21 @@ import type { DomainError } from '../../../shared/DomainError'; import { View } from '../View'; import type { ViewId } from '../ViewId'; import type { ViewName } from '../ViewName'; +import type { ViewProperties } from '../ViewProperties'; import { ViewType } from '../ViewType'; import type { IViewVisitor } from '../visitors/IViewVisitor'; export class GalleryView extends View { - private constructor(id: ViewId, name: ViewName) { - super(id, name, ViewType.gallery()); + private constructor(id: ViewId, name: ViewName, properties?: ViewProperties) { + super(id, name, ViewType.gallery(), properties); } - static create(params: { id: ViewId; name: ViewName }): Result { - return ok(new GalleryView(params.id, params.name)); + static create(params: { + id: ViewId; + name: ViewName; + properties?: ViewProperties; + }): Result { + return ok(new GalleryView(params.id, params.name, params.properties)); } accept(visitor: IViewVisitor): Result { diff --git a/packages/v2/core/src/domain/table/views/types/GridView.ts b/packages/v2/core/src/domain/table/views/types/GridView.ts index 91bf6a1a73..3c39a9a06f 100644 --- a/packages/v2/core/src/domain/table/views/types/GridView.ts +++ b/packages/v2/core/src/domain/table/views/types/GridView.ts @@ -5,16 +5,21 @@ import type { DomainError } from '../../../shared/DomainError'; import { View } from '../View'; import type { ViewId } from '../ViewId'; import type { ViewName } from '../ViewName'; +import type { ViewProperties } from '../ViewProperties'; import { ViewType } from '../ViewType'; import type { IViewVisitor } from '../visitors/IViewVisitor'; export class GridView extends View { - private constructor(id: ViewId, name: ViewName) { - super(id, name, ViewType.grid()); + private constructor(id: ViewId, name: ViewName, properties?: ViewProperties) { + super(id, name, ViewType.grid(), properties); } - static create(params: { id: ViewId; name: ViewName }): Result { - return ok(new GridView(params.id, params.name)); + static create(params: { + id: ViewId; + name: ViewName; + properties?: ViewProperties; + }): Result { + return ok(new GridView(params.id, params.name, params.properties)); } accept(visitor: IViewVisitor): Result { diff --git a/packages/v2/core/src/domain/table/views/types/KanbanView.ts b/packages/v2/core/src/domain/table/views/types/KanbanView.ts index ef9a8e037c..72ebc817b1 100644 --- a/packages/v2/core/src/domain/table/views/types/KanbanView.ts +++ b/packages/v2/core/src/domain/table/views/types/KanbanView.ts @@ -5,16 +5,21 @@ import type { DomainError } from '../../../shared/DomainError'; import { View } from '../View'; import type { ViewId } from '../ViewId'; import type { ViewName } from '../ViewName'; +import type { ViewProperties } from '../ViewProperties'; import { ViewType } from '../ViewType'; import type { IViewVisitor } from '../visitors/IViewVisitor'; export class KanbanView extends View { - private constructor(id: ViewId, name: ViewName) { - super(id, name, ViewType.kanban()); + private constructor(id: ViewId, name: ViewName, properties?: ViewProperties) { + super(id, name, ViewType.kanban(), properties); } - static create(params: { id: ViewId; name: ViewName }): Result { - return ok(new KanbanView(params.id, params.name)); + static create(params: { + id: ViewId; + name: ViewName; + properties?: ViewProperties; + }): Result { + return ok(new KanbanView(params.id, params.name, params.properties)); } accept(visitor: IViewVisitor): Result { diff --git a/packages/v2/core/src/domain/table/views/types/PluginView.ts b/packages/v2/core/src/domain/table/views/types/PluginView.ts index 898efc0814..4e1a8d56c1 100644 --- a/packages/v2/core/src/domain/table/views/types/PluginView.ts +++ b/packages/v2/core/src/domain/table/views/types/PluginView.ts @@ -5,16 +5,21 @@ import type { DomainError } from '../../../shared/DomainError'; import { View } from '../View'; import type { ViewId } from '../ViewId'; import type { ViewName } from '../ViewName'; +import type { ViewProperties } from '../ViewProperties'; import { ViewType } from '../ViewType'; import type { IViewVisitor } from '../visitors/IViewVisitor'; export class PluginView extends View { - private constructor(id: ViewId, name: ViewName) { - super(id, name, ViewType.plugin()); + private constructor(id: ViewId, name: ViewName, properties?: ViewProperties) { + super(id, name, ViewType.plugin(), properties); } - static create(params: { id: ViewId; name: ViewName }): Result { - return ok(new PluginView(params.id, params.name)); + static create(params: { + id: ViewId; + name: ViewName; + properties?: ViewProperties; + }): Result { + return ok(new PluginView(params.id, params.name, params.properties)); } accept(visitor: IViewVisitor): Result { diff --git a/packages/v2/core/src/domain/table/views/visitors/CloneViewVisitor.ts b/packages/v2/core/src/domain/table/views/visitors/CloneViewVisitor.ts index 1ef1873dcd..f75990477b 100644 --- a/packages/v2/core/src/domain/table/views/visitors/CloneViewVisitor.ts +++ b/packages/v2/core/src/domain/table/views/visitors/CloneViewVisitor.ts @@ -1,4 +1,4 @@ -import type { Result } from 'neverthrow'; +import { ok, type Result } from 'neverthrow'; import type { DomainError } from '../../../shared/DomainError'; import type { CalendarView } from '../types/CalendarView'; @@ -16,11 +16,22 @@ import { createKanbanView, createPluginView, } from '../ViewFactory'; -import type { ViewId } from '../ViewId'; +import type { ViewFactoryParams } from '../ViewFactory'; import type { ViewName } from '../ViewName'; +import type { ViewOrder } from '../ViewOrder'; +import type { ViewProperties } from '../ViewProperties'; import type { IViewVisitor } from './IViewVisitor'; +export type CloneViewOverrides = { + readonly name?: ViewName; + readonly properties?: ViewProperties; + readonly order?: ViewOrder; + readonly options?: unknown; +}; + export class CloneViewVisitor implements IViewVisitor { + constructor(private readonly overrides: CloneViewOverrides = {}) {} + visitGridView(view: GridView): Result { return this.cloneView(view, createGridView); } @@ -47,10 +58,34 @@ export class CloneViewVisitor implements IViewVisitor { private cloneView( view: View, - factory: (params: { id: ViewId; name: ViewName }) => Result + factory: (params: ViewFactoryParams) => Result ): Result { - return factory({ id: view.id(), name: view.name() }).andThen((clone) => - clone.setOptions(view.options()).map(() => clone) + return factory({ + id: view.id(), + name: this.overrides.name ?? view.name(), + properties: this.overrides.properties ?? view.properties(), + }).andThen((clone) => + clone + .setOptions( + Object.prototype.hasOwnProperty.call(this.overrides, 'options') + ? this.overrides.options + : view.options() + ) + .andThen(() => { + const orderResult = this.overrides.order ? ok(this.overrides.order) : view.order(); + return orderResult.isOk() ? clone.setOrder(orderResult.value) : ok(undefined); + }) + .andThen(() => { + const auditMetadataResult = view.auditMetadata(); + return auditMetadataResult.isOk() + ? clone.setAuditMetadata(auditMetadataResult.value) + : ok(undefined); + }) + .andThen(() => { + const versionResult = view.version(); + return versionResult.isOk() ? clone.setVersion(versionResult.value) : ok(undefined); + }) + .map(() => clone) ); } } diff --git a/packages/v2/core/src/index.ts b/packages/v2/core/src/index.ts index 73c6feb688..8cce6e14c7 100644 --- a/packages/v2/core/src/index.ts +++ b/packages/v2/core/src/index.ts @@ -11,9 +11,47 @@ export * from './commands/CreateTablesHandler'; export * from './commands/CreateFieldCommand'; export * from './commands/CreateFieldHandler'; export * from './commands/CreateFieldsCommand'; +export * from './commands/CreateViewCommand'; +export * from './commands/CreateViewHandler'; +export * from './commands/ApplyViewManualSortCommand'; +export * from './commands/ApplyViewManualSortHandler'; +export * from './commands/DeleteViewCommand'; +export * from './commands/DeleteViewHandler'; +export * from './commands/DuplicateViewCommand'; +export * from './commands/DuplicateViewHandler'; +export * from './commands/RenameViewCommand'; +export * from './commands/RenameViewHandler'; +export * from './commands/UpdateViewDescriptionCommand'; +export * from './commands/UpdateViewDescriptionHandler'; +export * from './commands/UpdateViewFilterCommand'; +export * from './commands/UpdateViewFilterHandler'; +export * from './commands/UpdateViewGroupCommand'; +export * from './commands/UpdateViewGroupHandler'; +export * from './commands/UpdateViewOptionsCommand'; +export * from './commands/UpdateViewOptionsHandler'; +export * from './commands/UpdateViewPluginStorageCommand'; +export * from './commands/UpdateViewPluginStorageHandler'; +export * from './commands/UpdateViewShareMetaCommand'; +export * from './commands/UpdateViewShareMetaHandler'; +export * from './commands/EnableViewShareCommand'; +export * from './commands/EnableViewShareHandler'; +export * from './commands/DisableViewShareCommand'; +export * from './commands/DisableViewShareHandler'; +export * from './commands/RefreshViewShareIdCommand'; +export * from './commands/RefreshViewShareIdHandler'; +export * from './commands/UpdateViewSortCommand'; +export * from './commands/UpdateViewSortHandler'; +export * from './commands/UpdateViewLockedCommand'; +export * from './commands/UpdateViewLockedHandler'; +export * from './commands/UpdateViewOrderCommand'; +export * from './commands/UpdateViewOrderHandler'; +export * from './commands/UpdateViewColumnMetaCommand'; +export * from './commands/UpdateViewColumnMetaHandler'; export * from './commands/CreateFieldsHandler'; export * from './commands/ApplyFieldSnapshotCommand'; export * from './commands/ApplyFieldSnapshotHandler'; +export * from './commands/ApplyViewSnapshotCommand'; +export * from './commands/ApplyViewSnapshotHandler'; export * from './commands/ReplayFieldTypeConversionCommand'; export * from './commands/ReplayFieldTypeConversionHandler'; export * from './commands/UpdateFieldCommand'; @@ -34,6 +72,9 @@ export * from './application/services/TableDataSafetyLimitTableOperationPlugin'; export * from './application/services/TableDataSafetyLimitViewOperationPlugin'; export * from './application/services/TableOperationPluginRunner'; export * from './application/services/ViewOperationPluginRunner'; +export * from './application/services/ViewManualSortService'; +export * from './application/services/ViewUndoRedoService'; +export * from './application/services/ViewPluginCreationService'; export * from './application/services/FieldKeyResolverService'; export * from './application/services/FieldDeletionSideEffectService'; export * from './application/services/FieldUndoRedoReplayService'; @@ -46,6 +87,7 @@ export * from './application/services/LinkFieldUpdateSideEffectService'; export * from './application/services/LinkTitleResolverService'; export * from './application/services/TableDeletionSideEffectService'; export * from './application/services/AttachmentValueDecoratorService'; +export * from './application/services/presignAttachmentCellValue'; export * from './application/services/AttachmentValueResolverService'; export * from './application/services/RecordChangedValueDecoratorService'; export * from './application/services/RecordMutationSpecResolverService'; @@ -72,12 +114,26 @@ export * from './application/projections/TableCreatedRealtimeProjection'; export * from './application/projections/FieldCreatedRealtimeProjection'; export * from './application/projections/FieldDeletedRealtimeProjection'; export * from './application/projections/ViewColumnMetaUpdatedRealtimeProjection'; +export * from './application/projections/ViewCreatedRealtimeProjection'; +export * from './application/projections/ViewDeletedRealtimeProjection'; +export * from './application/projections/ViewRenamedRealtimeProjection'; +export * from './application/projections/ViewDescriptionUpdatedRealtimeProjection'; +export * from './application/projections/ViewFilterUpdatedRealtimeProjection'; +export * from './application/projections/ViewGroupUpdatedRealtimeProjection'; +export * from './application/projections/ViewOptionsUpdatedRealtimeProjection'; +export * from './application/projections/ViewShareMetaUpdatedRealtimeProjection'; +export * from './application/projections/ViewShareIdRefreshedRealtimeProjection'; +export * from './application/projections/ViewShareStateRealtimeProjection'; +export * from './application/projections/ViewSortUpdatedRealtimeProjection'; +export * from './application/projections/ViewLockedUpdatedRealtimeProjection'; +export * from './application/projections/ViewOrderUpdatedRealtimeProjection'; export * from './application/projections/FieldOptionsAddedRealtimeProjection'; export * from './application/projections/FieldUpdatedRealtimeProjection'; export * from './application/projections/TableRecordRealtimeDTO'; export * from './application/projections/RecordCreatedRealtimeProjection'; export * from './application/projections/RecordUpdatedRealtimeProjection'; export * from './application/projections/RecordReorderedRealtimeProjection'; +export * from './application/projections/ViewManualSortAppliedRealtimeProjection'; export * from './application/projections/RecordsBatchUpdatedRealtimeProjection'; export * from './application/projections/RecordsBatchCreatedRealtimeProjection'; export * from './application/projections/RecordsDeletedRealtimeProjection'; @@ -92,8 +148,16 @@ export * from './commands/RestoreTableCommand'; export * from './commands/RestoreTableHandler'; export * from './commands/RenameTableCommand'; export * from './commands/RenameTableHandler'; +export * from './commands/UpdateTablePropertiesCommand'; +export * from './commands/UpdateTablePropertiesHandler'; export * from './commands/CreateRecordCommand'; export * from './commands/CreateRecordHandler'; +export * from './commands/ClickButtonCommand'; +export * from './commands/ClickButtonHandler'; +export * from './commands/SetButtonValueCommand'; +export * from './commands/SetButtonValueHandler'; +export * from './commands/ResetButtonCommand'; +export * from './commands/ResetButtonHandler'; export * from './commands/SubmitRecordCommand'; export * from './commands/SubmitRecordHandler'; export * from './commands/CreateRecordsCommand'; @@ -128,6 +192,8 @@ export * from './commands/DuplicateRecordsStreamCommand'; export * from './commands/DuplicateRecordsStreamHandler'; export * from './commands/DuplicateBaseCommand'; export * from './commands/DuplicateBaseHandler'; +export * from './commands/DuplicateBaseByIdCommand'; +export * from './commands/DuplicateBaseByIdHandler'; export * from './commands/DuplicateTableCommand'; export * from './commands/DuplicateTableHandler'; export * from './commands/buildPhysicalTableDuplicatePlan'; @@ -159,6 +225,7 @@ export * from './commands/PublicCommand'; export * from './ports/DotTeaParser'; export * from './ports/AttachmentLookupService'; export * from './ports/AttachmentUrlSignerService'; +export * from './ports/ButtonClickWorkflowService'; export * from './ports/ComputedFieldBackfillService'; export * from './ports/ComputedUpdateDrainService'; export * from './ports/FieldOperationPlugin'; @@ -168,6 +235,7 @@ export * from './ports/TableOperationPlugin'; export * from './ports/ViewOperationPlugin'; export * from './ports/UserRenamePropagationService'; export * from './ports/UserLookupService'; +export * from './ports/CollaboratorDirectoryService'; export * from './ports/UserAvatarUrl'; export * from './ports/RecordOrderCalculator'; export * from './ports/SchemaOperationRepository'; @@ -180,17 +248,48 @@ export * from './schemas'; export * from './queries/GetTableByIdQuery'; export * from './queries/GetTableByIdHandler'; +export * from './queries/GetDefaultViewIdQuery'; +export * from './queries/GetDefaultViewIdHandler'; +export * from './queries/ListFieldsQuery'; +export * from './queries/ListFieldsHandler'; export * from './queries/GetRecordByIdQuery'; export * from './queries/GetRecordByIdHandler'; +export * from './queries/GetViewQuery'; +export * from './queries/GetViewHandler'; +export * from './queries/GetViewPluginInstallQuery'; +export * from './queries/GetViewPluginInstallHandler'; +export * from './queries/GetViewSnapshotsQuery'; +export * from './queries/GetViewSnapshotsHandler'; +export * from './queries/ListViewsQuery'; +export * from './queries/ListViewsHandler'; +export * from './queries/ViewQueryProjection'; +export * from './queries/GetViewFilterLinkRecordsQuery'; +export * from './queries/GetViewFilterLinkRecordsHandler'; +export * from './queries/GetViewLinkRecordsQuery'; +export * from './queries/GetViewLinkRecordsHandler'; +export * from './queries/GetViewCollaboratorsQuery'; +export * from './queries/GetViewCollaboratorsHandler'; +export * from './queries/GetViewSelectionCopyQuery'; +export * from './queries/GetViewSelectionCopyHandler'; export * from './queries/ListTableRecordsQuery'; export * from './queries/ListTableRecordsHandler'; +export * from './queries/AggregateTableRecordsQuery'; +export * from './queries/AggregateTableRecordsHandler'; +export * from './queries/GetCalendarDailyCollectionQuery'; +export * from './queries/GetCalendarDailyCollectionHandler'; export * from './queries/RecordFilterDto'; export * from './queries/RecordFilterMapper'; export * from './queries/RecordSearch'; +export * from './domain/table/records/TableRecordAggregation'; +export * from './domain/table/records/TableRecordCalendarDailyCollection'; +export * from './domain/table/methods/createViewSelectionCopyPlan'; +export * from './domain/table/methods/createCollapsedGroupExclusionFilter'; +export * from './domain/table/fields/visitors/FieldClipboardValueVisitor'; export * from './queries/ListTablesQuery'; export * from './queries/ListTablesHandler'; export * from './queries/QueryHandler'; export * from './di/registerFieldOperationPlugin'; +export * from './di/registerRecordQueryPlugin'; export * from './di/registerRecordWritePlugin'; export * from './di/registerTableDataSafetyLimitPlugin'; export * from './di/registerTableOperationPlugin'; @@ -228,6 +327,7 @@ export * from './queries/GetComputeActivityHandler'; export * from './application/projections/ComputedActivityRealtimeProjection'; export * from './domain/table/Table'; +export * from './domain/table/methods/createViewCollaboratorsQueryPlan'; export * from './domain/table/TableFieldLimit'; export * from './domain/table/ForeignTable'; export * from './domain/table/TableMutator'; @@ -248,6 +348,7 @@ export * from './domain/table/records/specs/RecordByIdsSpec'; export * from './domain/table/records/specs/IncomingLinkSelectedSpec'; export * from './domain/table/records/specs/IncomingLinkCandidateSpec'; export * from './domain/table/records/specs/RecordConditionSpec'; +export * from './domain/table/records/specs/ConditionNullSemantics'; export * from './domain/table/records/specs/RecordConditionSpecBuilder'; export * from './domain/table/records/specs/FieldConditionSpecBuilder'; export * from './domain/table/records/specs/RecordConditionSpecFactory'; @@ -273,6 +374,7 @@ export * from './domain/table/records/specs/values/ICellValueSpecVisitor'; export * from './domain/table/records/specs/values/SetSingleLineTextValueSpec'; export * from './domain/table/records/specs/values/SetLongTextValueSpec'; export * from './domain/table/records/specs/values/SetNumberValueSpec'; +export * from './domain/table/records/specs/values/SetButtonValueSpec'; export * from './domain/table/records/specs/values/SetRatingValueSpec'; export * from './domain/table/records/specs/values/SetSingleSelectValueSpec'; export * from './domain/table/records/specs/values/SetMultipleSelectValueSpec'; @@ -299,31 +401,65 @@ export * from './domain/table/events/TableDeleted'; export * from './domain/table/events/TableRestored'; export * from './domain/table/events/TableTrashed'; export * from './domain/table/events/TableRenamed'; +export * from './domain/table/events/TablePropertiesUpdated'; export * from './domain/table/events/FieldCreated'; export * from './domain/table/events/FieldDeleted'; export * from './domain/table/events/FieldDuplicated'; export * from './domain/table/events/ViewColumnMetaUpdated'; +export * from './domain/table/events/ViewCreated'; +export * from './domain/table/events/ViewDeleted'; +export * from './domain/table/events/ViewRenamed'; +export * from './domain/table/events/ViewDescriptionUpdated'; +export * from './domain/table/events/ViewFilterUpdated'; +export * from './domain/table/events/ViewGroupUpdated'; +export * from './domain/table/events/ViewOptionsUpdated'; +export * from './domain/table/events/ViewShareMetaUpdated'; +export * from './domain/table/events/ViewShareIdRefreshed'; +export * from './domain/table/events/ViewShareDisabled'; +export * from './domain/table/events/ViewShareEnabled'; +export * from './domain/table/events/ViewSortUpdated'; +export * from './domain/table/events/ViewManualSortApplied'; +export * from './domain/table/events/ViewLockedUpdated'; +export * from './domain/table/events/ViewOrderUpdated'; export * from './domain/table/events/FieldOptionsAdded'; export * from './domain/table/events/FieldUpdated'; export * from './domain/table/events/RecordFieldValuesDTO'; export * from './domain/table/events/RecordCreated'; export * from './domain/table/events/RecordsBatchCreated'; export * from './domain/table/events/RecordUpdated'; +export * from './domain/table/events/ButtonClicked'; export * from './domain/table/events/RecordReordered'; export * from './domain/table/events/RecordsBatchUpdated'; export * from './domain/table/events/RecordsDeleted'; export * from './domain/table/events/TableActionTriggerRequested'; export * from './domain/table/specs/TableByIdSpec'; +export * from './domain/table/specs/TableByViewIdSpec'; +export * from './domain/table/specs/TableWithViewIdsSpec'; export * from './domain/table/specs/TableByIncomingReferenceToTableSpec'; +export * from './domain/table/specs/TableUpdateViewOptionsSpec'; +export * from './domain/table/specs/TableUpdateViewShareMetaSpec'; +export * from './domain/table/specs/TableUpdateViewShareStateSpec'; +export * from './domain/table/specs/TableUpdateViewShareIdSpec'; export * from './domain/table/specs/TableRenameSpec'; +export * from './domain/table/specs/TableUpdatePropertiesSpec'; +export * from './domain/table/TableProperties'; export * from './domain/table/specs/TableByIdsSpec'; export * from './domain/table/specs/TableByNameLikeSpec'; export * from './domain/table/specs/TableByNameSpec'; export * from './domain/table/specs/TableAddFieldSpec'; +export * from './domain/table/specs/TableAddViewSpec'; +export * from './domain/table/specs/TableEnsureViewRowOrderSpec'; +export * from './domain/table/specs/TableRenameViewSpec'; +export * from './domain/table/specs/TableUpdateViewDescriptionSpec'; +export * from './domain/table/specs/TableUpdateViewLockedSpec'; +export * from './domain/table/specs/TableUpdateViewOrderSpec'; +export * from './domain/table/views/ViewOrder'; +export * from './domain/table/views/ViewSort'; export * from './domain/table/specs/TableAddFieldsSpec'; export * from './domain/table/specs/TableAddSelectOptionsSpec'; export * from './domain/table/specs/TableDuplicateFieldSpec'; export * from './domain/table/specs/TableRemoveFieldSpec'; +export * from './domain/table/specs/TableRemoveViewSpec'; export * from './domain/table/specs/TableUpdateViewColumnMetaSpec'; export * from './domain/table/specs/TableUpdateViewQueryDefaultsSpec'; export * from './domain/table/specs/TableUpdateFieldNameSpec'; @@ -354,6 +490,7 @@ export * from './domain/table/fields/OnTeableFieldUpdated'; export * from './domain/table/OnTeableFieldDeleted'; export * from './domain/table/OnTeableTableDeleted'; export * from './domain/table/fields/visitors/FieldValueTypeVisitor'; +export * from './domain/table/fields/visitors/SearchFieldTextShape'; export * from './domain/table/fields/visitors/SearchVectorFieldContributionVisitor'; export * from './domain/table/fields/visitors/LinkForeignTableReferenceVisitor'; export type { AttachmentField } from './domain/table/fields/types/AttachmentField'; @@ -458,9 +595,15 @@ export * from './domain/table/fields/specs/FieldIsStringValueSpec'; export * from './domain/table/fields/specs/FieldIsUserSpec'; export type { View } from './domain/table/views/View'; export * from './domain/table/views/ViewColumnMeta'; +export * from './domain/table/views/ViewSnapshot'; export * from './domain/table/views/ViewQueryDefaults'; +export * from './domain/table/views/ViewSourceFilter'; export * from './domain/table/views/ViewId'; export * from './domain/table/views/ViewName'; +export * from './domain/table/views/ViewOptions'; +export * from './domain/table/views/ViewProperties'; +export * from './domain/table/views/ViewAuditMetadata'; +export * from './domain/table/views/ViewVersion'; export * from './domain/table/views/ViewType'; export * from './domain/table/views/ViewFactory'; export * from './domain/table/views/OnTeableViewFieldDeleted'; @@ -481,6 +624,7 @@ export * from './ports/FieldDeleteSnapshotSink'; export * from './ports/HandlerResolver'; export * from './ports/BatchMutationOrchestration'; export * from './ports/ExecutionContext'; +export * from './ports/ViewPluginRepository'; export * from './ports/Logger'; export * from './ports/Tracer'; export * from './ports/TableQueryTraceAttributes'; @@ -497,6 +641,8 @@ export * from './ports/TableRecordStreamPaginationStrategy'; export * from './ports/TableRecordRepository'; export * from './ports/RecordWritePlugin'; export * from './application/services/RecordWritePluginRunner'; +export * from './ports/RecordQueryPlugin'; +export * from './application/services/RecordQueryPluginRunner'; export * from './application/services/TableUpdateTransactionScope'; export * from './ports/TableSchemaRepository'; export * from './ports/UnitOfWork'; diff --git a/packages/v2/core/src/ports/BaseRepository.ts b/packages/v2/core/src/ports/BaseRepository.ts index 8ff6ad81e5..9c7e725e99 100644 --- a/packages/v2/core/src/ports/BaseRepository.ts +++ b/packages/v2/core/src/ports/BaseRepository.ts @@ -13,6 +13,7 @@ export interface IFindBasesResult { export interface IBaseRepository { insert(context: IExecutionContext, base: Base): Promise>; + delete(context: IExecutionContext, baseId: BaseId): Promise>; findOne(context: IExecutionContext, baseId: BaseId): Promise>; find( context: IExecutionContext, diff --git a/packages/v2/core/src/ports/ButtonClickWorkflowService.ts b/packages/v2/core/src/ports/ButtonClickWorkflowService.ts new file mode 100644 index 0000000000..462ae5fd56 --- /dev/null +++ b/packages/v2/core/src/ports/ButtonClickWorkflowService.ts @@ -0,0 +1,22 @@ +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../domain/shared/DomainError'; +import type { ButtonClicked } from '../domain/table/events/ButtonClicked'; +import type { IExecutionContext } from './ExecutionContext'; + +export type ButtonClickWorkflowResult = Readonly<{ + runId: string; +}>; + +/** + * Application boundary for running the workflow selected by a Table-owned Button field. + * + * Core emits the ButtonClicked event after the aggregate mutation is committed. Enterprise + * automation can implement this port without leaking workflow infrastructure into Table. + */ +export interface IButtonClickWorkflowService { + trigger( + context: IExecutionContext, + event: ButtonClicked + ): Promise>; +} diff --git a/packages/v2/core/src/ports/CollaboratorDirectoryService.ts b/packages/v2/core/src/ports/CollaboratorDirectoryService.ts new file mode 100644 index 0000000000..9ea7e6ee47 --- /dev/null +++ b/packages/v2/core/src/ports/CollaboratorDirectoryService.ts @@ -0,0 +1,32 @@ +import type { Result } from 'neverthrow'; + +import type { BaseId } from '../domain/base/BaseId'; +import type { DomainError } from '../domain/shared/DomainError'; +import type { OffsetPagination } from '../domain/shared/pagination/OffsetPagination'; +import type { IExecutionContext } from './ExecutionContext'; + +export type CollaboratorDirectoryUser = { + readonly id: string; + readonly name: string; + readonly avatar?: string | null; +}; + +export interface ICollaboratorDirectoryService { + listBaseUsers( + context: IExecutionContext, + baseId: BaseId, + options: { + readonly pagination: OffsetPagination; + readonly search?: string; + } + ): Promise, DomainError>>; + + listUsersByIds( + context: IExecutionContext, + userIds: ReadonlyArray, + options: { + readonly pagination: OffsetPagination; + readonly search?: string; + } + ): Promise, DomainError>>; +} diff --git a/packages/v2/core/src/ports/RealtimeEngine.ts b/packages/v2/core/src/ports/RealtimeEngine.ts index d76d88d231..107575cdb5 100644 --- a/packages/v2/core/src/ports/RealtimeEngine.ts +++ b/packages/v2/core/src/ports/RealtimeEngine.ts @@ -28,4 +28,14 @@ export interface IRealtimeEngine { ): Promise>; delete(context: IExecutionContext, docId: RealtimeDocId): Promise>; + + /** + * Notify collection query subscribers when a bulk storage mutation has no + * meaningful per-document operation to publish. + */ + invalidateCollection( + context: IExecutionContext, + collection: string, + change: RealtimeChange + ): Promise>; } diff --git a/packages/v2/core/src/ports/RecordQueryPlugin.ts b/packages/v2/core/src/ports/RecordQueryPlugin.ts new file mode 100644 index 0000000000..52f205c87d --- /dev/null +++ b/packages/v2/core/src/ports/RecordQueryPlugin.ts @@ -0,0 +1,195 @@ +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../domain/shared/DomainError'; +import type { ISpecification } from '../domain/shared/specification/ISpecification'; +import type { ITableRecordConditionSpecVisitor } from '../domain/table/records/specs/ITableRecordConditionSpecVisitor'; +import type { TableRecord } from '../domain/table/records/TableRecord'; +import type { Table } from '../domain/table/Table'; +import type { IExecutionContext } from './ExecutionContext'; +import type { PluginTraceContext } from './Tracer'; + +/** + * Read-side operation kinds for record query plugins. + * Start with list / getOne / getByIds; extend later for search/aggregate. + */ +export const RecordQueryOperationKind = { + list: 'list', + getOne: 'getOne', + getByIds: 'getByIds', +} as const; + +export type RecordQueryOperationKind = + (typeof RecordQueryOperationKind)[keyof typeof RecordQueryOperationKind]; + +export type RecordQueryPluginEnforce = 'pre' | 'post'; + +export interface RecordQueryPluginRunnerOptions { + readonly skipPluginNames?: ReadonlySet; +} + +/** + * Soft constraints applied mechanically by query handlers. + * Handlers must not interpret authority-matrix policy — only this shape. + */ +export interface RecordQueryPluginScope { + /** + * Row visibility. AND-ed into the query condition tree with user/view filters + * unless {@link skipRecordSpec} is true. + */ + readonly recordSpec?: ISpecification; + + /** + * When true, handlers must not AND {@link recordSpec} into the list query. + * Used for link-selected UX (legacy keepPrimaryKey / skip row filter). + * Field allow-list and masks still apply. + * + * Merge semantics: skip only drops **this plugin's** row filter contribution. + * It must not erase other plugins' recordSpecs. + */ + readonly skipRecordSpec?: boolean; + + /** + * Static field allow-list. + * - `undefined`: all user fields allowed + * - empty set: no user fields (system columns only) + * - non-empty: only listed fields may be projected / used in filter-sort-search + * + * Cross-plugin merge is intersection (monotonic tighten). + */ + readonly readableFieldIds?: ReadonlySet; + + /** + * Field ids that must remain readable even when a static allow-list is present + * (e.g. primary field for link titles when skipRecordSpec is set). + * + * Merge semantics: applied **inside** this plugin's allow-list before + * cross-plugin intersection — cannot re-open fields another plugin denied. + */ + readonly forceReadableFieldIds?: ReadonlySet; + + /** + * Conditional field visibility (replaces outer permission-view CASE WHEN). + * Applied after read (null-out) using domain evaluation; never as a host-built CTE. + * Handlers must expand projection with mask dependency fields (not only static + * readable fields) and fail-closed when dependencies are missing. + */ + readonly fieldMasks?: ReadonlyArray; + + /** + * The legacy permission-aware record query is guaranteed to enforce the same + * access constraints as this scope. + * + * This is a narrow ShareDB compatibility capability for query features that + * V2 cannot yet express with conditional masks (group/search/order). The + * runner exports it only when every access-restricting plugin opts in. + */ + readonly legacyPermissionQueryCompatible?: true; +} + +export interface RecordQueryFieldMask { + readonly fieldId: string; + readonly visibleWhen: ISpecification; +} + +type RecordQueryPluginHookResult = Result | Promise>; + +interface IRecordQueryPluginContextBase { + readonly kind: TKind; + readonly executionContext: IExecutionContext; + readonly table: Table; + readonly payload: TPayload; + readonly trace?: PluginTraceContext; +} + +export type RecordQueryListPayload = { + readonly viewId?: string; + readonly ignoreViewQuery?: boolean; + /** Client-requested projection field keys/ids before scope intersection (optional). */ + readonly projectionFieldIds?: ReadonlyArray; + readonly limit?: number; + readonly offset?: number; + /** + * When true (e.g. filterLinkCellSelected), row recordSpec should be skipped and + * primary field force-included for link title UX. + */ + readonly keepPrimaryKey?: boolean; +}; + +export type RecordQueryGetOnePayload = { + readonly recordId: string; + readonly projectionFieldIds?: ReadonlyArray; + readonly viewId?: string; + readonly ignoreViewQuery?: boolean; + /** + * Host-controlled existence probe after a scoped getOne miss. + * + * Plugins with discretionary row filters (e.g. authority matrix) set + * {@link RecordQueryPluginScope.skipRecordSpec} so the host can distinguish + * 403 vs 404. Other plugins keep their row filters. Field allow-lists and + * masks still apply. Must not force-include fields (unlike list keepPrimary). + * + * Only the OpenAPI getOne 403/404 path may set this — not a general-purpose + * client flag. + */ + readonly existenceProbe?: boolean; +}; + +export type RecordQueryGetByIdsPayload = { + readonly recordIds: ReadonlyArray; + readonly projectionFieldIds?: ReadonlyArray; + readonly viewId?: string; + readonly ignoreViewQuery?: boolean; + /** + * Host-controlled ShareDB snapshot compatibility mode. Skips discretionary + * row filters and preserves the primary field while retaining static field + * allow-lists and non-primary conditional masks. + */ + readonly keepPrimaryKey?: boolean; +}; + +export type RecordQueryPluginContextMap = { + list: IRecordQueryPluginContextBase<'list', RecordQueryListPayload>; + getOne: IRecordQueryPluginContextBase<'getOne', RecordQueryGetOnePayload>; + getByIds: IRecordQueryPluginContextBase<'getByIds', RecordQueryGetByIdsPayload>; +}; + +export type RecordQueryPluginContext = RecordQueryPluginContextMap[RecordQueryOperationKind]; + +/** + * Outer-layer read authorization plugin. + * + * Domain query handlers never import authority-matrix types. They only consume + * merged {@link RecordQueryPluginScope} (recordSpec + readableFieldIds + fieldMasks). + */ +export interface IRecordQueryPlugin { + readonly name: string; + /** + * Ordering hint: `pre` → default → `post`. + */ + readonly enforce?: RecordQueryPluginEnforce; + + supports(operation: RecordQueryOperationKind): boolean; + + prepare?( + context: RecordQueryPluginContext, + previousPreparedState?: TPreparedState + ): RecordQueryPluginHookResult; + + /** + * Emit soft constraints. Handlers AND recordSpec and intersect field sets. + * `preparedState` is undefined when `prepare` was omitted. + */ + scope?( + context: RecordQueryPluginContext, + preparedState: TPreparedState | undefined + ): RecordQueryPluginHookResult; + + /** + * Hard deny (e.g. no table read). Fail closed. + * `preparedState` is undefined when `prepare` was omitted. + */ + guard?( + context: RecordQueryPluginContext, + preparedState: TPreparedState | undefined + ): RecordQueryPluginHookResult; +} diff --git a/packages/v2/core/src/ports/TableRecordQueryRepository.ts b/packages/v2/core/src/ports/TableRecordQueryRepository.ts index 7a529f023a..b0c1e49176 100644 --- a/packages/v2/core/src/ports/TableRecordQueryRepository.ts +++ b/packages/v2/core/src/ports/TableRecordQueryRepository.ts @@ -4,9 +4,15 @@ import type { DomainError } from '../domain/shared/DomainError'; import type { OffsetPagination } from '../domain/shared/pagination/OffsetPagination'; import type { ISpecification } from '../domain/shared/specification/ISpecification'; import type { FieldId } from '../domain/table/fields/FieldId'; +import type { ViewCollaboratorField } from '../domain/table/methods/createViewCollaboratorsQueryPlan'; import type { RecordId } from '../domain/table/records/RecordId'; import type { ITableRecordConditionSpecVisitor } from '../domain/table/records/specs/ITableRecordConditionSpecVisitor'; import type { TableRecord } from '../domain/table/records/TableRecord'; +import type { + TableRecordAggregation, + TableRecordAggregationFunction, +} from '../domain/table/records/TableRecordAggregation'; +import type { TableRecordCalendarDailyCollection } from '../domain/table/records/TableRecordCalendarDailyCollection'; import type { Table } from '../domain/table/Table'; import type { RecordQuerySearch } from '../queries/RecordSearch'; import type { IExecutionContext } from './ExecutionContext'; @@ -135,6 +141,27 @@ export interface ITableRecordQueryOptions { * Optional explicit read source used by permission-scoped record reads. */ readonly recordReadQuerySource?: IRecordReadQuerySource; + + /** + * Return the exact fields matching `search` for search-index projections. + * View identity and visible fields must already have been resolved from the + * Table aggregate by the application handler. + */ + readonly includeSearchFieldMatches?: boolean; + + /** + * `matched` numbers matching rows; `view` numbers the complete filtered/sorted View. + */ + readonly searchIndexMode?: 'matched' | 'view'; + + /** + * Optional grouped count metadata computed from the same filter/search scope. + * Field order is significant and defines the group hierarchy. + */ + readonly groupBy?: ReadonlyArray; + + /** Maximum number of leaf group buckets returned by the repository. */ + readonly groupLimit?: number; } /** @@ -192,6 +219,12 @@ export interface ITableRecordQueryStreamOptions { */ readonly projectionFieldIds?: ReadonlyArray; + /** + * Include per-view row-order values in streamed records. + * Used by bulk row-order materialization to skip unchanged rows. + */ + readonly includeOrders?: boolean; + /** * Optional stream pagination strategy. * When omitted, repository uses its default strategy. @@ -222,8 +255,34 @@ export interface ITableRecordQueryResult { readonly total: number; /** Actual search access path selected by the repository after SQL planning. */ readonly searchAccessPath?: IRecordSearchAccessPathResolution; + /** Exact per-field search hits, present only when explicitly requested. */ + readonly searchMatches?: ReadonlyArray; + /** Ordered leaf group buckets for compatibility presentation layers. */ + readonly groups?: ReadonlyArray; +} + +export interface ITableRecordGroup { + readonly fields: Readonly>; + readonly count: number; +} + +export interface ITableRecordSearchMatch { + readonly index: number; + readonly fieldId: FieldId; + readonly recordId: RecordId; } +export type TableRecordAggregationValue = { + readonly fieldId: FieldId; + readonly statisticFunc: TableRecordAggregationFunction; + readonly value: number | string | null; + /** + * Present for grouped values. The array contains the raw group values in the + * same order as the aggregation's groupBy prefix. + */ + readonly groupValues?: ReadonlyArray; +}; + export interface ITableRecordQueryRepository { /** * Find records matching the specification with pagination support. @@ -279,6 +338,68 @@ export interface ITableRecordQueryRepository { ): AsyncIterable>; } +/** + * Aggregate capability of the existing Table Record query repository. + * + * This deliberately shares the same repository implementation and DI token as + * record reads. It is not a View repository or a second aggregate boundary. + */ +export interface ITableRecordAggregationQueryRepository extends ITableRecordQueryRepository { + aggregate( + context: IExecutionContext, + table: Table, + aggregation: TableRecordAggregation, + spec?: ISpecification, + options?: { + readonly maxGroupPoints?: number; + readonly search?: RecordQuerySearch; + } + ): Promise, DomainError>>; +} + +export type TableRecordCalendarDailyCollectionEntry = { + readonly date: string; + readonly count: number; + readonly recordIds: ReadonlyArray; +}; + +/** + * Calendar read capability of the existing Table Record query repository. + * + * Calendar is a projection over records owned by a Table aggregate. It does not + * introduce a View or Calendar repository boundary. + */ +export interface ITableRecordCalendarQueryRepository extends ITableRecordQueryRepository { + calendarDailyCollection( + context: IExecutionContext, + table: Table, + calendar: TableRecordCalendarDailyCollection, + range: { + readonly startDate: string; + readonly endDate: string; + }, + spec?: ISpecification, + options?: { + readonly search?: RecordQuerySearch; + } + ): Promise, DomainError>>; +} + +/** + * Collaborator lookup capability of the existing Table Record query repository. + * + * User-related values remain record data owned by Table. This is intentionally not a + * View, Field, or collaborator repository. + */ +export interface ITableRecordCollaboratorQueryRepository extends ITableRecordQueryRepository { + findDistinctUserIds( + context: IExecutionContext, + table: Table, + field: ViewCollaboratorField, + spec?: ISpecification + ): Promise, DomainError>>; +} + /** * Type guard to check if an orderBy is a field-based order. */ diff --git a/packages/v2/core/src/ports/TableRecordRepository.ts b/packages/v2/core/src/ports/TableRecordRepository.ts index 03494f36ef..37b6e1b5f6 100644 --- a/packages/v2/core/src/ports/TableRecordRepository.ts +++ b/packages/v2/core/src/ports/TableRecordRepository.ts @@ -2,6 +2,7 @@ import type { Result } from 'neverthrow'; import type { DomainError } from '../domain/shared/DomainError'; import type { ISpecification } from '../domain/shared/specification/ISpecification'; +import type { IRecordRemovalReason } from '../domain/table/events/RecordsDeleted'; import type { RecordId } from '../domain/table/records/RecordId'; import type { RecordInsertOrder } from '../domain/table/records/RecordInsertOrder'; import type { RecordUpdateResult } from '../domain/table/records/RecordUpdateResult'; @@ -11,6 +12,7 @@ import type { TableRecord } from '../domain/table/records/TableRecord'; import type { Table } from '../domain/table/Table'; import type { IBatchMutationOrchestration } from './BatchMutationOrchestration'; import type { IExecutionContext } from './ExecutionContext'; +import type { UndoRedoArchiveTrashRow } from './UndoRedoStore'; export interface RecordStoredSnapshot { /** Stringified record id from storage. */ @@ -382,6 +384,14 @@ export interface InsertOptions { */ cleanupTrashRecordIds?: ReadonlyArray; + /** + * Optional record ids whose attachments_table reference rows must be deleted + * BEFORE the insert writes new ones (restore of archived records: archiving kept + * the reference rows and the insert rebuilds them — skipping the cleanup would + * double-count attachment usage). + */ + cleanupAttachmentRefRecordIds?: ReadonlyArray; + /** * When true, generate SQL to fill missing link titles by JOINing * the foreign table's primary field. Used in typecast mode when @@ -398,6 +408,14 @@ export interface InsertOptions { } export interface UpdateOptions { + /** + * Optional optimistic-concurrency guard. + * + * The adapter must apply the mutation only when the stored __version equals + * this value and report mutationApplied=false on a mismatch. + */ + expectedVersion?: number; + /** * Batch write orchestration metadata for realtime/computed projection grouping. */ @@ -605,4 +623,31 @@ export interface ITableRecordRepository { recordIdBatches: Iterable> | AsyncIterable>, options?: DeleteManyStreamOptions ): Promise>; + + /** + * Optional: insert archive snapshot rows into record_trash (reason 'archived'). + * Used by the redo replay of an archive operation to re-persist the snapshot + * (write-ahead, inside the delete transaction) that the undo removed. + */ + insertArchiveTrashRows?( + context: IExecutionContext, + table: Table, + rows: ReadonlyArray + ): Promise>; + + /** + * Optional: of the given record ids, return those that still hold a record_trash + * row with the given reason. Undo replay uses this to skip records whose trash + * rows disappeared after the stack entry was written (purged, or restored through + * another path) — the entry carries full snapshots, so an unchecked replay would + * resurrect explicitly purged data. + */ + listTrashedRecordIds?( + context: IExecutionContext, + table: Table, + recordIds: ReadonlyArray, + reason: IRecordRemovalReason + ): Promise, DomainError>>; } + +export type ArchiveTrashRowInput = UndoRedoArchiveTrashRow; diff --git a/packages/v2/core/src/ports/TableRepository.ts b/packages/v2/core/src/ports/TableRepository.ts index 088375b280..0833ee5114 100644 --- a/packages/v2/core/src/ports/TableRepository.ts +++ b/packages/v2/core/src/ports/TableRepository.ts @@ -24,6 +24,16 @@ export type TableFindOptions = IFindOptions & { state?: TableQueryState; }; +export type TableLockMode = 'forUpdate'; + +export type TableFindOneOptions = Pick & { + /** + * Requires a transaction-bound execution context. The repository locks the + * aggregate root row before hydrating its current child collection. + */ + lock?: TableLockMode; +}; + export type FieldVersionChange = { fieldId: string; oldVersion: number; @@ -70,7 +80,7 @@ export interface ITableRepository { findOne( context: IExecutionContext, spec: ISpecification, - options?: Pick + options?: TableFindOneOptions ): Promise>; find( context: IExecutionContext, diff --git a/packages/v2/core/src/ports/Tracer.ts b/packages/v2/core/src/ports/Tracer.ts index 0406250235..e3fab4a4dc 100644 --- a/packages/v2/core/src/ports/Tracer.ts +++ b/packages/v2/core/src/ports/Tracer.ts @@ -109,6 +109,15 @@ export interface ISpan { end(): void; } +/** + * W3C trace-context carrier used to hand a parent span across async boundaries + * (for example BullMQ wake-up jobs for the computed outbox worker). + */ +export type TracePropagationCarrier = Readonly<{ + traceparent?: string; + tracestate?: string; +}>; + export interface ITracer { /** * Start a new span with the given name and optional attributes. @@ -129,6 +138,21 @@ export interface ITracer { * Returns undefined if no span is active. */ getActiveSpan(): ISpan | undefined; + + /** + * Capture the active W3C trace context for async handoff. + * Optional: tracers without propagation support may omit this. + */ + capturePropagationCarrier?(): TracePropagationCarrier | undefined; + + /** + * Run work with the given W3C carrier as the active parent context. + * Optional: tracers without propagation support may omit this. + */ + runWithPropagationCarrier?( + carrier: TracePropagationCarrier | undefined, + callback: () => Promise + ): Promise; } export interface PluginTraceContext { diff --git a/packages/v2/core/src/ports/UndoRedoStore.ts b/packages/v2/core/src/ports/UndoRedoStore.ts index 983d231b39..37f35b1313 100644 --- a/packages/v2/core/src/ports/UndoRedoStore.ts +++ b/packages/v2/core/src/ports/UndoRedoStore.ts @@ -5,6 +5,7 @@ import type { DomainError } from '../domain/shared/DomainError'; import type { TableId } from '../domain/table/TableId'; import type { ViewColumnMetaValue } from '../domain/table/views/ViewColumnMeta'; import type { ViewQueryDefaultsDTO } from '../domain/table/views/ViewQueryDefaults'; +import type { ViewSnapshotValue } from '../domain/table/views/ViewSnapshot'; import type { ITableFieldInput } from '../schemas/field'; /** @@ -27,6 +28,13 @@ export type UndoRedoUpdateRecordPayload = { readonly typecast: boolean; }; +export type UndoRedoSetButtonValuePayload = { + readonly tableId: string; + readonly recordId: string; + readonly fieldId: string; + readonly value: { readonly count: number } | null; +}; + export type UndoRedoUpdateRecordsPayload = { readonly tableId: string; readonly records: ReadonlyArray<{ @@ -105,36 +113,98 @@ export type UndoRedoReplayFieldTypeConversionPayload = { readonly snapshot: UndoRedoFieldSnapshot; }; +export type UndoRedoApplyViewSnapshotPayload = { + readonly tableId: string; + readonly snapshot: ViewSnapshotValue; +}; + +export type UndoRedoDeleteViewPayload = { + readonly tableId: string; + readonly viewId: string; +}; + +export type UndoRedoViewShareLifecyclePayload = { + readonly tableId: string; + readonly viewId: string; +}; + export type UndoRedoCommandLeafType = | 'UpdateRecord' + | 'SetButtonValue' | 'UpdateRecords' | 'DeleteRecords' | 'RestoreRecords' + | 'ArchiveRecords' + | 'RestoreArchivedRecords' | 'ApplyRecordOrders' | 'DeleteField' | 'ApplyFieldSnapshot' - | 'ReplayFieldTypeConversion'; + | 'ReplayFieldTypeConversion' + | 'ApplyViewSnapshot' + | 'DeleteView' + | 'EnableViewShare' + | 'DisableViewShare'; export type UndoRedoCommandType = UndoRedoCommandLeafType | 'Batch'; export const undoRedoCommandVersions = { UpdateRecord: 1, + SetButtonValue: 1, UpdateRecords: 1, DeleteRecords: 1, RestoreRecords: 1, + ArchiveRecords: 1, + RestoreArchivedRecords: 1, ApplyRecordOrders: 1, DeleteField: 1, ApplyFieldSnapshot: 1, ReplayFieldTypeConversion: 1, + ApplyViewSnapshot: 1, + DeleteView: 1, + EnableViewShare: 1, + DisableViewShare: 1, Batch: 1, } as const satisfies Record; +// A record_trash row (reason 'archived') carried inside the undo entry so redo can +// re-persist the archive snapshot (write-ahead) before deleting the records again. +// Snapshots are the normalized cellValue form produced at original archive time — +// v2 cannot rebuild them, which is why they travel with the entry. Dates are ISO strings +// (entries are JSON). +export type UndoRedoArchiveTrashRow = { + readonly recordId: string; + readonly snapshot: string; + readonly createdBy: string; + readonly createdTime: string; + readonly operationId?: string; + readonly recordCreatedTime?: string; + readonly recordCreatedBy?: string; + readonly recordLastModifiedTime?: string; + readonly recordLastModifiedBy?: string; +}; + +export type UndoRedoArchiveRecordsPayload = { + readonly tableId: string; + readonly recordIds: ReadonlyArray; + readonly archiveRows: ReadonlyArray; +}; + +// Same shape as a plain restore; the dedicated type makes the replay clean up the +// attachment reference rows kept at archive time before re-inserting the records. +export type UndoRedoRestoreArchivedRecordsPayload = UndoRedoRestoreRecordsPayload; + export type UndoRedoUpdateCommandData = { readonly type: 'UpdateRecord'; readonly version: number; readonly payload: UndoRedoUpdateRecordPayload; }; +export type UndoRedoSetButtonValueCommandData = { + readonly type: 'SetButtonValue'; + readonly version: number; + readonly payload: UndoRedoSetButtonValuePayload; +}; + export type UndoRedoUpdateRecordsCommandData = { readonly type: 'UpdateRecords'; readonly version: number; @@ -153,6 +223,18 @@ export type UndoRedoRestoreRecordsCommandData = { readonly payload: UndoRedoRestoreRecordsPayload; }; +export type UndoRedoArchiveRecordsCommandData = { + readonly type: 'ArchiveRecords'; + readonly version: number; + readonly payload: UndoRedoArchiveRecordsPayload; +}; + +export type UndoRedoRestoreArchivedRecordsCommandData = { + readonly type: 'RestoreArchivedRecords'; + readonly version: number; + readonly payload: UndoRedoRestoreArchivedRecordsPayload; +}; + export type UndoRedoApplyRecordOrdersCommandData = { readonly type: 'ApplyRecordOrders'; readonly version: number; @@ -177,15 +259,46 @@ export type UndoRedoReplayFieldTypeConversionCommandData = { readonly payload: UndoRedoReplayFieldTypeConversionPayload; }; +export type UndoRedoApplyViewSnapshotCommandData = { + readonly type: 'ApplyViewSnapshot'; + readonly version: number; + readonly payload: UndoRedoApplyViewSnapshotPayload; +}; + +export type UndoRedoDeleteViewCommandData = { + readonly type: 'DeleteView'; + readonly version: number; + readonly payload: UndoRedoDeleteViewPayload; +}; + +export type UndoRedoEnableViewShareCommandData = { + readonly type: 'EnableViewShare'; + readonly version: number; + readonly payload: UndoRedoViewShareLifecyclePayload; +}; + +export type UndoRedoDisableViewShareCommandData = { + readonly type: 'DisableViewShare'; + readonly version: number; + readonly payload: UndoRedoViewShareLifecyclePayload; +}; + export type UndoRedoCommandLeafData = | UndoRedoUpdateCommandData + | UndoRedoSetButtonValueCommandData | UndoRedoUpdateRecordsCommandData | UndoRedoDeleteRecordsCommandData | UndoRedoRestoreRecordsCommandData + | UndoRedoArchiveRecordsCommandData + | UndoRedoRestoreArchivedRecordsCommandData | UndoRedoApplyRecordOrdersCommandData | UndoRedoDeleteFieldCommandData | UndoRedoApplyFieldSnapshotCommandData - | UndoRedoReplayFieldTypeConversionCommandData; + | UndoRedoReplayFieldTypeConversionCommandData + | UndoRedoApplyViewSnapshotCommandData + | UndoRedoDeleteViewCommandData + | UndoRedoEnableViewShareCommandData + | UndoRedoDisableViewShareCommandData; export type UndoRedoBatchCommandData = { readonly type: 'Batch'; @@ -197,25 +310,39 @@ export type UndoRedoCommandData = UndoRedoCommandLeafData | UndoRedoBatchCommand export type UndoRedoCommandPayloadByType = { UpdateRecord: UndoRedoUpdateRecordPayload; + SetButtonValue: UndoRedoSetButtonValuePayload; UpdateRecords: UndoRedoUpdateRecordsPayload; DeleteRecords: UndoRedoDeleteRecordsPayload; RestoreRecords: UndoRedoRestoreRecordsPayload; + ArchiveRecords: UndoRedoArchiveRecordsPayload; + RestoreArchivedRecords: UndoRedoRestoreArchivedRecordsPayload; ApplyRecordOrders: UndoRedoApplyRecordOrdersPayload; DeleteField: UndoRedoDeleteFieldPayload; ApplyFieldSnapshot: UndoRedoApplyFieldSnapshotPayload; ReplayFieldTypeConversion: UndoRedoReplayFieldTypeConversionPayload; + ApplyViewSnapshot: UndoRedoApplyViewSnapshotPayload; + DeleteView: UndoRedoDeleteViewPayload; + EnableViewShare: UndoRedoViewShareLifecyclePayload; + DisableViewShare: UndoRedoViewShareLifecyclePayload; Batch: ReadonlyArray; }; export type UndoRedoCommandDataByType = { UpdateRecord: UndoRedoUpdateCommandData; + SetButtonValue: UndoRedoSetButtonValueCommandData; UpdateRecords: UndoRedoUpdateRecordsCommandData; DeleteRecords: UndoRedoDeleteRecordsCommandData; RestoreRecords: UndoRedoRestoreRecordsCommandData; + ArchiveRecords: UndoRedoArchiveRecordsCommandData; + RestoreArchivedRecords: UndoRedoRestoreArchivedRecordsCommandData; ApplyRecordOrders: UndoRedoApplyRecordOrdersCommandData; DeleteField: UndoRedoDeleteFieldCommandData; ApplyFieldSnapshot: UndoRedoApplyFieldSnapshotCommandData; ReplayFieldTypeConversion: UndoRedoReplayFieldTypeConversionCommandData; + ApplyViewSnapshot: UndoRedoApplyViewSnapshotCommandData; + DeleteView: UndoRedoDeleteViewCommandData; + EnableViewShare: UndoRedoEnableViewShareCommandData; + DisableViewShare: UndoRedoDisableViewShareCommandData; Batch: UndoRedoBatchCommandData; }; diff --git a/packages/v2/core/src/ports/ViewOperationPlugin.ts b/packages/v2/core/src/ports/ViewOperationPlugin.ts index b1d3cd5b19..cbd6ce63a1 100644 --- a/packages/v2/core/src/ports/ViewOperationPlugin.ts +++ b/packages/v2/core/src/ports/ViewOperationPlugin.ts @@ -19,10 +19,16 @@ type ViewOperationPluginHookResult = Result | Promise>; +}; + +export type UpdateViewPluginStorageInput = { + readonly baseId: string; + readonly viewId: string; + readonly pluginInstallId: string; + readonly storage?: Readonly>; +}; + +export interface IViewPluginRepository { + findViewPlugin( + context: IExecutionContext, + pluginId: string + ): Promise>; + + insertViewPluginInstallation( + context: IExecutionContext, + installation: ViewPluginInstallation + ): Promise>; + + findViewPluginInstallationByViewId( + context: IExecutionContext, + viewId: string + ): Promise>; + + getViewPluginInstallation( + context: IExecutionContext, + baseId: string, + viewId: string + ): Promise>; + + updateViewPluginStorage( + context: IExecutionContext, + input: UpdateViewPluginStorageInput + ): Promise>; +} diff --git a/packages/v2/core/src/ports/defaults/NoopButtonClickWorkflowService.ts b/packages/v2/core/src/ports/defaults/NoopButtonClickWorkflowService.ts new file mode 100644 index 0000000000..cfbf85d2d9 --- /dev/null +++ b/packages/v2/core/src/ports/defaults/NoopButtonClickWorkflowService.ts @@ -0,0 +1,19 @@ +import { ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../domain/shared/DomainError'; +import type { ButtonClicked } from '../../domain/table/events/ButtonClicked'; +import type { + ButtonClickWorkflowResult, + IButtonClickWorkflowService, +} from '../ButtonClickWorkflowService'; +import type { IExecutionContext } from '../ExecutionContext'; + +export class NoopButtonClickWorkflowService implements IButtonClickWorkflowService { + async trigger( + _context: IExecutionContext, + _event: ButtonClicked + ): Promise> { + return ok({ runId: '' }); + } +} diff --git a/packages/v2/core/src/ports/defaults/NoopRealtimeEngine.ts b/packages/v2/core/src/ports/defaults/NoopRealtimeEngine.ts index b61c786a12..fa09c32ecb 100644 --- a/packages/v2/core/src/ports/defaults/NoopRealtimeEngine.ts +++ b/packages/v2/core/src/ports/defaults/NoopRealtimeEngine.ts @@ -29,4 +29,12 @@ export class NoopRealtimeEngine implements IRealtimeEngine { ): Promise> { return ok(undefined); } + + async invalidateCollection( + _context: IExecutionContext, + _collection: string, + _change: RealtimeChange + ): Promise> { + return ok(undefined); + } } diff --git a/packages/v2/core/src/ports/defaults/NoopTracer.ts b/packages/v2/core/src/ports/defaults/NoopTracer.ts index 6da7ec2c07..3727476530 100644 --- a/packages/v2/core/src/ports/defaults/NoopTracer.ts +++ b/packages/v2/core/src/ports/defaults/NoopTracer.ts @@ -1,5 +1,11 @@ /* eslint-disable @typescript-eslint/no-empty-function */ -import type { ISpan, ITracer, SpanAttributeValue, SpanAttributes } from '../Tracer'; +import type { + ISpan, + ITracer, + SpanAttributeValue, + SpanAttributes, + TracePropagationCarrier, +} from '../Tracer'; const noopSpan: ISpan = { setAttribute(_key: string, _value: SpanAttributeValue) {}, @@ -20,4 +26,15 @@ export class NoopTracer implements ITracer { getActiveSpan(): ISpan | undefined { return undefined; } + + capturePropagationCarrier(): TracePropagationCarrier | undefined { + return undefined; + } + + async runWithPropagationCarrier( + _carrier: TracePropagationCarrier | undefined, + callback: () => Promise + ): Promise { + return callback(); + } } diff --git a/packages/v2/core/src/ports/defaults/NoopViewPluginRepository.ts b/packages/v2/core/src/ports/defaults/NoopViewPluginRepository.ts new file mode 100644 index 0000000000..7b2265e3f9 --- /dev/null +++ b/packages/v2/core/src/ports/defaults/NoopViewPluginRepository.ts @@ -0,0 +1,50 @@ +import { err, ok, type Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../domain/shared/DomainError'; +import type { IExecutionContext } from '../ExecutionContext'; +import type { + IViewPluginRepository, + UpdateViewPluginStorageInput, + ViewPluginDefinition, + ViewPluginInstallation, + ViewPluginInstallationInfo, + ViewPluginInstallationSource, +} from '../ViewPluginRepository'; + +export class NoopViewPluginRepository implements IViewPluginRepository { + async findViewPlugin( + _context: IExecutionContext, + _pluginId: string + ): Promise> { + return err(domainError.notFound({ message: 'View plugin repository is not configured' })); + } + + async insertViewPluginInstallation( + _context: IExecutionContext, + _installation: ViewPluginInstallation + ): Promise> { + return ok(undefined); + } + + async findViewPluginInstallationByViewId( + _context: IExecutionContext, + _viewId: string + ): Promise> { + return err(domainError.notFound({ message: 'View plugin repository is not configured' })); + } + + async getViewPluginInstallation( + _context: IExecutionContext, + _baseId: string, + _viewId: string + ): Promise> { + return err(domainError.notFound({ message: 'View plugin installation is not configured' })); + } + + async updateViewPluginStorage( + _context: IExecutionContext, + _input: UpdateViewPluginStorageInput + ): Promise> { + return err(domainError.notFound({ message: 'View plugin installation is not configured' })); + } +} diff --git a/packages/v2/core/src/ports/defaults/index.ts b/packages/v2/core/src/ports/defaults/index.ts index 56376917b7..2963171552 100644 --- a/packages/v2/core/src/ports/defaults/index.ts +++ b/packages/v2/core/src/ports/defaults/index.ts @@ -1,4 +1,5 @@ export * from './NoopAttachmentUrlSignerService'; +export * from './NoopButtonClickWorkflowService'; export * from './NoopCsvParser'; export * from './NoopEventBus'; export * from './NoopFieldDeleteSnapshotSink'; diff --git a/packages/v2/core/src/ports/mappers/TableMapper.ts b/packages/v2/core/src/ports/mappers/TableMapper.ts index 4e9b3181a8..dcd2d1940b 100644 --- a/packages/v2/core/src/ports/mappers/TableMapper.ts +++ b/packages/v2/core/src/ports/mappers/TableMapper.ts @@ -2,7 +2,12 @@ import type { Result } from 'neverthrow'; import type { DomainError } from '../../domain/shared/DomainError'; import type { Table } from '../../domain/table/Table'; +import type { ViewAuditMetadataValue } from '../../domain/table/views/ViewAuditMetadata'; import type { ViewColumnMetaValue } from '../../domain/table/views/ViewColumnMeta'; +import type { + ViewPropertiesValue, + ViewShareMetaValue, +} from '../../domain/table/views/ViewProperties'; import type { ViewQueryDefaultsDTO } from '../../domain/table/views/ViewQueryDefaults'; export type ISingleLineTextFieldOptionsDTO = { @@ -334,9 +339,21 @@ export type ITableFieldPersistenceDTO = export type ITableViewPersistenceDTOBase = { id: string; name: string; + version?: number; + order?: number; + description?: ViewPropertiesValue['description']; columnMeta: ViewColumnMetaValue; query?: ViewQueryDefaultsDTO; + sourceFilter?: unknown; options?: unknown; + isLocked?: ViewPropertiesValue['isLocked']; + enableShare?: ViewPropertiesValue['enableShare']; + shareId?: ViewPropertiesValue['shareId']; + shareMeta?: ViewShareMetaValue; + createdBy?: ViewAuditMetadataValue['createdBy']; + createdTime?: ViewAuditMetadataValue['createdTime']; + lastModifiedBy?: ViewAuditMetadataValue['lastModifiedBy']; + lastModifiedTime?: ViewAuditMetadataValue['lastModifiedTime']; }; export type ITableViewPersistenceDTO = @@ -351,6 +368,8 @@ export type ITablePersistenceDTO = { id: string; baseId: string; name: string; + description?: string; + icon?: string; dbTableName?: string; primaryFieldId: string; fields: ReadonlyArray; diff --git a/packages/v2/core/src/ports/mappers/defaults/DefaultTableMapper.spec.ts b/packages/v2/core/src/ports/mappers/defaults/DefaultTableMapper.spec.ts index 50ad5bbd32..4852bf0b53 100644 --- a/packages/v2/core/src/ports/mappers/defaults/DefaultTableMapper.spec.ts +++ b/packages/v2/core/src/ports/mappers/defaults/DefaultTableMapper.spec.ts @@ -4,8 +4,8 @@ import { BaseId } from '../../../domain/base/BaseId'; import { FieldId } from '../../../domain/table/fields/FieldId'; import { FieldName } from '../../../domain/table/fields/FieldName'; import { AttachmentField } from '../../../domain/table/fields/types/AttachmentField'; -import { ButtonField } from '../../../domain/table/fields/types/ButtonField'; import { ButtonConfirm } from '../../../domain/table/fields/types/ButtonConfirm'; +import { ButtonField } from '../../../domain/table/fields/types/ButtonField'; import { ButtonLabel } from '../../../domain/table/fields/types/ButtonLabel'; import { ButtonMaxCount } from '../../../domain/table/fields/types/ButtonMaxCount'; import { ButtonResetCount } from '../../../domain/table/fields/types/ButtonResetCount'; @@ -41,8 +41,8 @@ import { SelectOption } from '../../../domain/table/fields/types/SelectOption'; import { SingleLineTextField } from '../../../domain/table/fields/types/SingleLineTextField'; import { SingleLineTextShowAs } from '../../../domain/table/fields/types/SingleLineTextShowAs'; import { SingleSelectField } from '../../../domain/table/fields/types/SingleSelectField'; -import { TimeZone } from '../../../domain/table/fields/types/TimeZone'; import { TextDefaultValue } from '../../../domain/table/fields/types/TextDefaultValue'; +import { TimeZone } from '../../../domain/table/fields/types/TimeZone'; import { UserDefaultValue } from '../../../domain/table/fields/types/UserDefaultValue'; import { UserField } from '../../../domain/table/fields/types/UserField'; import { UserMultiplicity } from '../../../domain/table/fields/types/UserMultiplicity'; @@ -56,10 +56,12 @@ import { GalleryView } from '../../../domain/table/views/types/GalleryView'; import { GridView } from '../../../domain/table/views/types/GridView'; import { KanbanView } from '../../../domain/table/views/types/KanbanView'; import { PluginView } from '../../../domain/table/views/types/PluginView'; +import { ViewAuditMetadata } from '../../../domain/table/views/ViewAuditMetadata'; import { ViewColumnMeta } from '../../../domain/table/views/ViewColumnMeta'; import { ViewId } from '../../../domain/table/views/ViewId'; import { ViewName } from '../../../domain/table/views/ViewName'; import { ViewQueryDefaults } from '../../../domain/table/views/ViewQueryDefaults'; +import { ViewVersion } from '../../../domain/table/views/ViewVersion'; import type { ITableFieldPersistenceDTO } from '../TableMapper'; import { DefaultTableMapper } from './DefaultTableMapper'; @@ -341,6 +343,25 @@ const buildFormulaTable = () => { }; describe('DefaultTableMapper', () => { + it('round-trips table description and icon', () => { + const mapper = new DefaultTableMapper(); + const dto = mapper.toDTO(buildTable())._unsafeUnwrap(); + const table = mapper + .toDomain({ + ...dto, + description: 'Projects tracked by the team', + icon: '📊', + }) + ._unsafeUnwrap(); + + expect(table.description()).toBe('Projects tracked by the team'); + expect(table.icon()).toBe('📊'); + expect(mapper.toDTO(table)._unsafeUnwrap()).toMatchObject({ + description: 'Projects tracked by the team', + icon: '📊', + }); + }); + it('maps tables to persistence dto and back', () => { const table = buildTable(); if (!table) return; @@ -378,6 +399,77 @@ describe('DefaultTableMapper', () => { fieldDbNameResult?._unsafeUnwrap(); }); + it('round-trips a lossless source filter separately from its canonical form', () => { + const table = buildTable(); + if (!table) return; + const canonicalFilter = { + fieldId: `fld${'a'.repeat(16)}`, + operator: 'isAnyOf' as const, + value: ['alpha'], + }; + const sourceFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: canonicalFilter.fieldId, + operator: 'IN', + isSymbol: true, + value: 'alpha', + }, + ], + }; + + const mapper = new DefaultTableMapper(); + const originalDto = mapper.toDTO(table)._unsafeUnwrap(); + const dto = { + ...originalDto, + views: originalDto.views.map((view, index) => + index === 0 + ? { + ...view, + query: { filter: canonicalFilter }, + sourceFilter, + } + : view + ), + }; + expect(dto.views[0]?.sourceFilter).toEqual(sourceFilter); + + const mappedResult = mapper.toDomain(dto); + expect( + mappedResult.isOk(), + mappedResult.isErr() ? JSON.stringify(mappedResult.error) : undefined + ).toBe(true); + const mapped = mappedResult._unsafeUnwrap(); + const mappedDefaults = mapped.views()[0]!.queryDefaults()._unsafeUnwrap(); + expect(mappedDefaults.filter()).toEqual({ + conjunction: 'and', + items: [canonicalFilter], + }); + expect(mappedDefaults.sourceFilter()).toEqual(sourceFilter); + }); + + it('round-trips read-only metadata on View child entities', () => { + const table = buildTable(); + const metadata = { + createdBy: 'creator', + createdTime: '2026-07-27T00:00:00.000Z', + lastModifiedBy: 'editor', + lastModifiedTime: '2026-07-27T01:00:00.000Z', + }; + table.views()[0].setAuditMetadata(ViewAuditMetadata.rehydrate(metadata)._unsafeUnwrap()); + table.views()[0].setVersion(ViewVersion.rehydrate(7)._unsafeUnwrap()); + const mapper = new DefaultTableMapper(); + + const dto = mapper.toDTO(table)._unsafeUnwrap(); + const mapped = mapper.toDomain(dto)._unsafeUnwrap(); + + expect(dto.views[0]).toMatchObject(metadata); + expect(dto.views[0]?.version).toBe(7); + expect(mapped.views()[0].auditMetadata()._unsafeUnwrap().toDto()).toEqual(metadata); + expect(mapped.views()[0].version()._unsafeUnwrap().toNumber()).toBe(7); + }); + it('deduplicates select choices by name when rehydrating persistence dto', () => { const mapper = new DefaultTableMapper(); const dto = mapper.toDTO(buildTable())._unsafeUnwrap(); diff --git a/packages/v2/core/src/ports/mappers/defaults/DefaultTableMapper.ts b/packages/v2/core/src/ports/mappers/defaults/DefaultTableMapper.ts index e0f9fe17c5..70a6a33da2 100644 --- a/packages/v2/core/src/ports/mappers/defaults/DefaultTableMapper.ts +++ b/packages/v2/core/src/ports/mappers/defaults/DefaultTableMapper.ts @@ -83,17 +83,23 @@ import { Table as TableAggregate } from '../../../domain/table/Table'; import type { ITableBuildProps } from '../../../domain/table/TableBuilder'; import { TableId } from '../../../domain/table/TableId'; import { TableName } from '../../../domain/table/TableName'; -import { CalendarView } from '../../../domain/table/views/types/CalendarView'; -import { FormView } from '../../../domain/table/views/types/FormView'; -import { GalleryView } from '../../../domain/table/views/types/GalleryView'; -import { GridView } from '../../../domain/table/views/types/GridView'; -import { KanbanView } from '../../../domain/table/views/types/KanbanView'; -import { PluginView } from '../../../domain/table/views/types/PluginView'; +import { TableProperties } from '../../../domain/table/TableProperties'; +import type { CalendarView } from '../../../domain/table/views/types/CalendarView'; +import type { FormView } from '../../../domain/table/views/types/FormView'; +import type { GalleryView } from '../../../domain/table/views/types/GalleryView'; +import type { GridView } from '../../../domain/table/views/types/GridView'; +import type { KanbanView } from '../../../domain/table/views/types/KanbanView'; +import type { PluginView } from '../../../domain/table/views/types/PluginView'; import type { View } from '../../../domain/table/views/View'; +import { ViewAuditMetadata } from '../../../domain/table/views/ViewAuditMetadata'; import { ViewColumnMeta } from '../../../domain/table/views/ViewColumnMeta'; +import { createView } from '../../../domain/table/views/ViewFactory'; import { ViewId } from '../../../domain/table/views/ViewId'; import { ViewName } from '../../../domain/table/views/ViewName'; +import { ViewOrder } from '../../../domain/table/views/ViewOrder'; +import { ViewProperties } from '../../../domain/table/views/ViewProperties'; import { ViewQueryDefaults } from '../../../domain/table/views/ViewQueryDefaults'; +import { ViewVersion } from '../../../domain/table/views/ViewVersion'; import type { IViewVisitor } from '../../../domain/table/views/visitors/IViewVisitor'; import type { IAutoNumberFieldOptionsDTO, @@ -928,14 +934,25 @@ class ViewToPersistenceVisitor implements IViewVisitor type: ITableViewPersistenceDTO['type'] ): Result { return view.columnMeta().andThen((columnMeta) => - view.queryDefaults().map((queryDefaults) => ({ - id: view.id().toString(), - name: view.name().toString(), - type, - columnMeta: columnMeta.toDto(), - query: queryDefaults.toDto(), - ...(view.options() !== undefined ? { options: view.options() } : {}), - })) + view.queryDefaults().map((queryDefaults) => { + const metadataResult = view.auditMetadata(); + const versionResult = view.version(); + return { + id: view.id().toString(), + name: view.name().toString(), + type, + ...(versionResult.isOk() ? { version: versionResult.value.toNumber() } : {}), + ...view.properties().toDto(), + columnMeta: columnMeta.toDto(), + query: queryDefaults.toDto(), + ...(queryDefaults.sourceFilter() !== undefined + ? { sourceFilter: queryDefaults.sourceFilter() } + : {}), + ...(view.options() !== undefined ? { options: view.options() } : {}), + ...(view.order().isOk() ? { order: view.order()._unsafeUnwrap().toNumber() } : {}), + ...(metadataResult.isOk() ? metadataResult.value.toDto() : {}), + }; + }) ); } } @@ -977,6 +994,8 @@ export class DefaultTableMapper implements ITableMapper { id: table.id().toString(), baseId: table.baseId().toString(), name: table.name().toString(), + ...(table.description() !== undefined ? { description: table.description() } : {}), + ...(table.icon() !== undefined ? { icon: table.icon() } : {}), ...(dbTableName ? { dbTableName } : {}), primaryFieldId: table.primaryFieldId().toString(), fields: [...fields], @@ -990,6 +1009,10 @@ export class DefaultTableMapper implements ITableMapper { const baseIdResult = BaseId.create(dto.baseId); const nameResult = TableName.create(dto.name); const primaryFieldIdResult = FieldId.create(dto.primaryFieldId); + const propertiesResult = TableProperties.create({ + ...(dto.description !== undefined ? { description: dto.description } : {}), + ...(dto.icon !== undefined ? { icon: dto.icon } : {}), + }); const fieldsResult = sequenceResults(dto.fields.map((f) => this.mapFieldToDomain(f))); const viewsResult = sequenceResults(dto.views.map((v) => this.mapViewToDomain(v))); @@ -999,20 +1022,23 @@ export class DefaultTableMapper implements ITableMapper { baseIdResult.andThen((baseId) => nameResult.andThen((name) => primaryFieldIdResult.andThen((primaryFieldId) => - fieldsResult.andThen((fields) => - viewsResult.andThen((views) => - dbTableNameResult.andThen((dbTableName) => { - const props: ITableBuildProps = { - id, - baseId, - name, - primaryFieldId, - fields, - views, - ...(dbTableName ? { dbTableName } : {}), - }; - return TableAggregate.rehydrate(props); - }) + propertiesResult.andThen((properties) => + fieldsResult.andThen((fields) => + viewsResult.andThen((views) => + dbTableNameResult.andThen((dbTableName) => { + const props: ITableBuildProps = { + id, + baseId, + name, + properties, + primaryFieldId, + fields, + views, + ...(dbTableName ? { dbTableName } : {}), + }; + return TableAggregate.rehydrate(props); + }) + ) ) ) ) @@ -1496,25 +1522,57 @@ export class DefaultTableMapper implements ITableMapper { private mapViewToDomain(dto: ITableViewPersistenceDTO): Result { return ViewId.create(dto.id).andThen((id) => ViewName.create(dto.name).andThen((name) => { - const viewResult = match(dto.type) - .with('grid', () => GridView.create({ id, name })) - .with('kanban', () => KanbanView.create({ id, name })) - .with('gallery', () => GalleryView.create({ id, name })) - .with('calendar', () => CalendarView.create({ id, name })) - .with('form', () => FormView.create({ id, name })) - .with('plugin', () => PluginView.create({ id, name })) - .exhaustive(); - - return viewResult.andThen((view) => - ViewColumnMeta.rehydrate(dto.columnMeta).andThen((columnMeta) => - view - .setColumnMeta(columnMeta) - .andThen(() => ViewQueryDefaults.rehydrate(dto.query ?? {})) - .andThen((queryDefaults) => view.setQueryDefaults(queryDefaults)) - .andThen(() => view.setOptions(dto.options)) - .map(() => view) - ) - ); + return ViewProperties.rehydrate({ + ...(dto.description !== undefined ? { description: dto.description } : {}), + ...(dto.isLocked !== undefined ? { isLocked: dto.isLocked } : {}), + ...(dto.enableShare !== undefined ? { enableShare: dto.enableShare } : {}), + ...(dto.shareId !== undefined ? { shareId: dto.shareId } : {}), + ...(dto.shareMeta !== undefined ? { shareMeta: dto.shareMeta } : {}), + }).andThen((properties) => { + const viewResult = createView({ type: dto.type, id, name, properties }); + + return viewResult.andThen((view) => + ViewColumnMeta.rehydrate(dto.columnMeta).andThen((columnMeta) => + view + .setColumnMeta(columnMeta) + .andThen(() => + ViewQueryDefaults.rehydrate(dto.query ?? {}, { + sourceFilter: dto.sourceFilter, + }) + ) + .andThen((queryDefaults) => view.setQueryDefaults(queryDefaults)) + .andThen(() => view.setOptions(dto.options)) + .andThen(() => + dto.order === undefined + ? ok(undefined) + : ViewOrder.rehydrate(dto.order).andThen((order) => view.setOrder(order)) + ) + .andThen(() => + dto.version === undefined + ? ok(undefined) + : ViewVersion.rehydrate(dto.version).andThen((version) => + view.setVersion(version) + ) + ) + .andThen(() => { + if (dto.createdBy === undefined && dto.createdTime === undefined) { + return ok(undefined); + } + return ViewAuditMetadata.rehydrate({ + createdBy: dto.createdBy, + createdTime: dto.createdTime, + ...(dto.lastModifiedBy !== undefined + ? { lastModifiedBy: dto.lastModifiedBy } + : {}), + ...(dto.lastModifiedTime !== undefined + ? { lastModifiedTime: dto.lastModifiedTime } + : {}), + }).andThen((metadata) => view.setAuditMetadata(metadata)); + }) + .map(() => view) + ) + ); + }); }) ); } diff --git a/packages/v2/core/src/ports/memory/AsyncMemoryEventBus.ts b/packages/v2/core/src/ports/memory/AsyncMemoryEventBus.ts index 21bc33b588..1e5fc87cc0 100644 --- a/packages/v2/core/src/ports/memory/AsyncMemoryEventBus.ts +++ b/packages/v2/core/src/ports/memory/AsyncMemoryEventBus.ts @@ -91,16 +91,27 @@ export class AsyncMemoryEventBus implements IEventBus { private shouldAwait(events: ReadonlyArray): boolean { if (!events.length) return false; - const awaitableEventNames = new Set([ - 'FieldCreated', - 'FieldUpdated', - 'FieldDeleted', - 'FieldDuplicated', - 'FieldOptionsAdded', - 'ViewColumnMetaUpdated', - ]); - - return events.every((event) => awaitableEventNames.has(event.name.toString())); + // Field/view schema events must finish before the HTTP response because + // callers immediately re-read schema. RecordsDeleted is also awaitable: + // Nest enables restorePurgeGuard, so undo of a delete filters survivors + // against record_trash. If trash projections stay fire-and-forget, a + // fast undo-stream (perf-lab setup, double-click undo) sees zero trash + // rows and restores nothing while still reporting fulfilled. + const awaitableEventNames: Record = { + FieldCreated: true, + FieldUpdated: true, + FieldDeleted: true, + FieldDuplicated: true, + FieldOptionsAdded: true, + ViewColumnMetaUpdated: true, + ViewRenamed: true, + ViewDescriptionUpdated: true, + ViewLockedUpdated: true, + ViewOrderUpdated: true, + RecordsDeleted: true, + }; + + return events.every((event) => awaitableEventNames[event.name.toString()] === true); } constructor( diff --git a/packages/v2/core/src/ports/memory/MemoryPorts.spec.ts b/packages/v2/core/src/ports/memory/MemoryPorts.spec.ts index 5fac94dca3..865c2e7cb4 100644 --- a/packages/v2/core/src/ports/memory/MemoryPorts.spec.ts +++ b/packages/v2/core/src/ports/memory/MemoryPorts.spec.ts @@ -527,6 +527,62 @@ describe('AsyncMemoryEventBus', () => { expect(publishResolved).toBe(true); }); + it('awaits RecordsDeleted projections before publish resolves', async () => { + class RecordsDeletedEvent implements IDomainEvent { + readonly name = DomainEventName.recordsDeleted(); + readonly occurredAt = OccurredAt.now(); + } + + let handled = false; + + @EventHandler(RecordsDeletedEvent) + class RecordsDeletedHandler implements IEventHandler { + async handle( + _context: IExecutionContext, + _event: RecordsDeletedEvent + ): ReturnType['handle']> { + handled = true; + return ok(undefined); + } + } + expect(RecordsDeletedHandler).toBeDefined(); + + const scheduledTasks: Array<() => Promise> = []; + const backgroundTasks: Array<() => Promise | void> = []; + const schedule: AsyncEventBusScheduler = (task) => { + scheduledTasks.push(task); + }; + + const resolver = new MapResolver(); + const bus = new AsyncMemoryEventBus(resolver, { schedule }); + const context: IExecutionContext = { + ...createContext(), + scheduleBackgroundTask: (task) => { + backgroundTasks.push(task); + }, + }; + + let publishResolved = false; + const publishPromise = bus.publish(context, new RecordsDeletedEvent()).then((result) => { + publishResolved = true; + return result; + }); + + await Promise.resolve(); + + expect(scheduledTasks).toHaveLength(1); + expect(backgroundTasks).toHaveLength(0); + expect(handled).toBe(false); + expect(publishResolved).toBe(false); + + await scheduledTasks.shift()?.(); + const publishResult = await publishPromise; + + publishResult._unsafeUnwrap(); + expect(handled).toBe(true); + expect(publishResolved).toBe(true); + }); + it('does not retain published events when recording is disabled', async () => { class PingEvent implements IDomainEvent { readonly name = DomainEventName.tableCreated(); diff --git a/packages/v2/core/src/ports/tokens.ts b/packages/v2/core/src/ports/tokens.ts index c92e5545c9..8ea44f1ae9 100644 --- a/packages/v2/core/src/ports/tokens.ts +++ b/packages/v2/core/src/ports/tokens.ts @@ -14,6 +14,8 @@ export const v2CoreTokens = { fieldUpdateSideEffectService: Symbol('v2.core.fieldUpdateSideEffectService'), fieldUndoRedoSnapshotService: Symbol('v2.core.fieldUndoRedoSnapshotService'), fieldUndoRedoReplayService: Symbol('v2.core.fieldUndoRedoReplayService'), + viewUndoRedoService: Symbol('v2.core.viewUndoRedoService'), + viewManualSortService: Symbol('v2.core.viewManualSortService'), fieldCrossTableUpdateSideEffectService: Symbol('v2.core.fieldCrossTableUpdateSideEffectService'), linkFieldUpdateSideEffectService: Symbol('v2.core.linkFieldUpdateSideEffectService'), foreignTableLoaderService: Symbol('v2.core.foreignTableLoaderService'), @@ -31,6 +33,7 @@ export const v2CoreTokens = { restoreFieldStreamApplicationService: Symbol('v2.core.restoreFieldStreamApplicationService'), recordBulkUpdateService: Symbol('v2.core.recordBulkUpdateService'), recordReorderService: Symbol('v2.core.recordReorderService'), + buttonClickWorkflowService: Symbol('v2.core.buttonClickWorkflowService'), recordOrderCalculator: Symbol('v2.core.recordOrderCalculator'), attachmentLookupService: Symbol('v2.core.attachmentLookupService'), attachmentUrlSignerService: Symbol('v2.core.attachmentUrlSignerService'), @@ -39,6 +42,7 @@ export const v2CoreTokens = { recordChangedValueDecoratorService: Symbol('v2.core.recordChangedValueDecoratorService'), userValueResolverService: Symbol('v2.core.userValueResolverService'), userLookupService: Symbol('v2.core.userLookupService'), + collaboratorDirectoryService: Symbol('v2.core.collaboratorDirectoryService'), userRenamePropagationService: Symbol('v2.core.userRenamePropagationService'), computedUpdateDrainService: Symbol('v2.core.computedUpdateDrainService'), computedFieldBackfillService: Symbol('v2.core.computedFieldBackfillService'), @@ -47,12 +51,16 @@ export const v2CoreTokens = { schemaOperationHandlers: Symbol('v2.core.schemaOperationHandlers'), recordWritePluginRunner: Symbol('v2.core.recordWritePluginRunner'), recordWritePlugins: Symbol('v2.core.recordWritePlugins'), + recordQueryPluginRunner: Symbol('v2.core.recordQueryPluginRunner'), + recordQueryPlugins: Symbol('v2.core.recordQueryPlugins'), fieldOperationPluginRunner: Symbol('v2.core.fieldOperationPluginRunner'), fieldOperationPlugins: Symbol('v2.core.fieldOperationPlugins'), tableOperationPluginRunner: Symbol('v2.core.tableOperationPluginRunner'), tableOperationPlugins: Symbol('v2.core.tableOperationPlugins'), viewOperationPluginRunner: Symbol('v2.core.viewOperationPluginRunner'), viewOperationPlugins: Symbol('v2.core.viewOperationPlugins'), + viewPluginCreationService: Symbol('v2.core.viewPluginCreationService'), + viewPluginRepository: Symbol('v2.core.viewPluginRepository'), tableDataSafetyLimitComposer: Symbol('v2.core.tableDataSafetyLimitComposer'), tableDataSafetyLimitPlugins: Symbol('v2.core.tableDataSafetyLimitPlugins'), tableMapper: Symbol('v2.core.tableMapper'), @@ -73,4 +81,5 @@ export const v2CoreTokens = { importSourceRegistry: Symbol('v2.core.importSourceRegistry'), undoRedoStore: Symbol('v2.core.undoRedoStore'), undoRedoService: Symbol('v2.core.undoRedoService'), + undoRedoReplayConfig: Symbol('v2.core.undoRedoReplayConfig'), } as const; diff --git a/packages/v2/core/src/queries/ARCHITECTURE.md b/packages/v2/core/src/queries/ARCHITECTURE.md index 650ec3d4ff..819880f75a 100644 --- a/packages/v2/core/src/queries/ARCHITECTURE.md +++ b/packages/v2/core/src/queries/ARCHITECTURE.md @@ -13,6 +13,26 @@ Declaration: If the folder I belong to changes, please update me, especially cor - `ARCHITECTURE.md` - Role: folder architecture note; Purpose: describe query layer scope. - `GetTableByIdHandler.ts` - Role: query handler; Purpose: find a table by spec. - `GetTableByIdQuery.ts` - Role: query DTO; Purpose: validate baseId/tableId and convert to value objects. +- `GetViewHandler.ts` - Role: query handler; Purpose: load a Table aggregate with one selected View child. +- `GetViewQuery.ts` - Role: query DTO; Purpose: validate Table/View IDs. +- `ListViewsHandler.ts` - Role: query handler; Purpose: project all active View children from a Table aggregate. +- `ListViewsQuery.ts` - Role: query DTO; Purpose: validate the owning Table ID. +- `ViewQueryProjection.ts` - Role: shared query projection; Purpose: map hydrated View children to the public read shape. +- `GetViewFilterLinkRecordsQuery.ts` - Role: query DTO; Purpose: validate the owning Table and View IDs. +- `GetViewFilterLinkRecordsHandler.ts` - Role: query handler; Purpose: load Table aggregates and + read the linked records referenced by an owned View filter. +- `GetViewLinkRecordsQuery.ts` - Role: query DTO; Purpose: validate the owning Table, View, and Field IDs. +- `GetViewLinkRecordsHandler.ts` - Role: query handler; Purpose: partially load the Table aggregate + and expose its validated cross-table Link Record query plan. +- `GetViewCollaboratorsQuery.ts` - Role: query DTO; Purpose: validate the owning Table, optional + View/User Field, authorization fact, search, and pagination. +- `GetViewCollaboratorsHandler.ts` - Role: query handler; Purpose: execute the Table-owned + collaborator plan through Table Record data and the independent collaborator directory. +- `GetViewSelectionCopyQuery.ts` - Role: query DTO; Purpose: validate shared clipboard range, + projection, filter, order, group, search, collapse, and threshold inputs. +- `GetViewSelectionCopyHandler.ts` - Role: query handler; Purpose: load the authorized partial + Table aggregate, execute its copy plan through the existing Table Record repository, and format + the selected v2 Field values as clipboard text. - `ListTableRecordsHandler.ts` - Role: query handler; Purpose: load records for a table. - `ListTableRecordsQuery.ts` - Role: query DTO; Purpose: validate baseId/tableId and optional record filters. - `ListTablesHandler.ts` - Role: query handler; Purpose: build specs and query with sort/pagination. diff --git a/packages/v2/core/src/queries/AggregateTableRecordsHandler.spec.ts b/packages/v2/core/src/queries/AggregateTableRecordsHandler.spec.ts new file mode 100644 index 0000000000..436c7fff9b --- /dev/null +++ b/packages/v2/core/src/queries/AggregateTableRecordsHandler.spec.ts @@ -0,0 +1,174 @@ +import { err, ok } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError } from '../domain/shared/DomainError'; +import { FieldId } from '../domain/table/fields/FieldId'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { Table } from '../domain/table/Table'; +import { TableId } from '../domain/table/TableId'; +import { TableName } from '../domain/table/TableName'; +import { NoopLogger } from '../ports/defaults/NoopLogger'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import { MemoryTableRepository } from '../ports/memory/MemoryTableRepository'; +import type { ITableRecordAggregationQueryRepository } from '../ports/TableRecordQueryRepository'; +import { AggregateTableRecordsHandler } from './AggregateTableRecordsHandler'; +import { AggregateTableRecordsQuery } from './AggregateTableRecordsQuery'; + +const context: IExecutionContext = { + actorId: ActorId.create('usr_current')._unsafeUnwrap(), +}; + +const buildTable = () => { + const textFieldId = FieldId.create(`fld${'a'.repeat(16)}`)._unsafeUnwrap(); + const numberFieldId = FieldId.create(`fld${'b'.repeat(16)}`)._unsafeUnwrap(); + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Aggregate Query')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(textFieldId) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + builder + .field() + .number() + .withId(numberFieldId) + .withName(FieldName.create('Amount')._unsafeUnwrap()) + .done(); + builder.view().defaultGrid().done(); + return { table: builder.build()._unsafeUnwrap(), textFieldId, numberFieldId }; +}; + +describe('AggregateTableRecordsQuery', () => { + it('parses the aggregate request without exposing repository options', () => { + const { table, numberFieldId } = buildTable(); + const query = AggregateTableRecordsQuery.create({ + tableId: table.id().toString(), + viewId: table.defaultView()._unsafeUnwrap().id().toString(), + fields: [{ fieldId: numberFieldId.toString(), statisticFunc: 'sum' }], + groupBy: [{ fieldId: numberFieldId.toString(), order: 'desc' }], + includeHiddenFields: true, + })._unsafeUnwrap(); + + expect(query.tableId.equals(table.id())).toBe(true); + expect(query.fields).toEqual([{ fieldId: numberFieldId.toString(), statisticFunc: 'sum' }]); + expect(query.includeHiddenFields).toBe(true); + }); + + it.each([ + undefined, + {}, + { tableId: 'bad', viewId: 'bad' }, + { tableId: `tbl${'a'.repeat(16)}`, viewId: `viw${'a'.repeat(16)}`, fields: [{}] }, + ])('rejects invalid input: %j', (input) => { + expect(AggregateTableRecordsQuery.create(input).isErr()).toBe(true); + }); +}); + +describe('AggregateTableRecordsHandler', () => { + it('loads Table with its View child, builds the record condition, and calls the Record repository', async () => { + const { table, textFieldId, numberFieldId } = buildTable(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(context, table); + const aggregate = vi.fn( + async (_context, aggregateTable, aggregation, spec, options) => { + expect(aggregateTable).toBe(table); + expect(spec).toBeDefined(); + expect( + aggregation.fields.map(({ fieldId, statisticFunc }) => ({ + fieldId: fieldId.toString(), + statisticFunc, + })) + ).toEqual([{ fieldId: numberFieldId.toString(), statisticFunc: 'sum' }]); + expect( + aggregation.groupBy.map(({ fieldId, order }) => ({ + fieldId: fieldId.toString(), + order, + })) + ).toEqual([{ fieldId: textFieldId.toString(), order: 'asc' }]); + expect(options?.search?.search.value).toBe('A'); + expect(options?.search?.visibleFieldIds?.map(String)).toContain(textFieldId.toString()); + return ok([ + { + fieldId: numberFieldId, + statisticFunc: 'sum', + value: 30, + }, + ]); + } + ); + const handler = new AggregateTableRecordsHandler( + tableRepository, + { aggregate } as unknown as ITableRecordAggregationQueryRepository, + new NoopLogger() + ); + const query = AggregateTableRecordsQuery.create({ + tableId: table.id().toString(), + viewId: table.defaultView()._unsafeUnwrap().id().toString(), + filter: { + fieldId: textFieldId.toString(), + operator: 'contains', + value: 'A', + }, + search: ['A', textFieldId.toString(), true], + fields: [{ fieldId: numberFieldId.toString(), statisticFunc: 'sum' }], + groupBy: [{ fieldId: textFieldId.toString(), order: 'asc' }], + })._unsafeUnwrap(); + + const result = await handler.handle(context, query); + + expect(result._unsafeUnwrap().values[0]).toMatchObject({ + statisticFunc: 'sum', + value: 30, + }); + expect(aggregate).toHaveBeenCalledOnce(); + }); + + it('maps a missing Table or child View to view.not_found before querying records', async () => { + const { table, numberFieldId } = buildTable(); + const aggregate = vi.fn(); + const handler = new AggregateTableRecordsHandler( + new MemoryTableRepository(), + { aggregate } as unknown as ITableRecordAggregationQueryRepository, + new NoopLogger() + ); + const query = AggregateTableRecordsQuery.create({ + tableId: table.id().toString(), + viewId: table.defaultView()._unsafeUnwrap().id().toString(), + fields: [{ fieldId: numberFieldId.toString(), statisticFunc: 'sum' }], + })._unsafeUnwrap(); + + const result = await handler.handle(context, query); + + expect(result._unsafeUnwrapErr().code).toBe('view.not_found'); + expect(aggregate).not.toHaveBeenCalled(); + }); + + it('propagates the Table Record repository failure', async () => { + const { table, numberFieldId } = buildTable(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(context, table); + const aggregate = vi.fn(async () => + err(domainError.infrastructure({ message: 'database unavailable' })) + ); + const handler = new AggregateTableRecordsHandler( + tableRepository, + { aggregate } as unknown as ITableRecordAggregationQueryRepository, + new NoopLogger() + ); + const query = AggregateTableRecordsQuery.create({ + tableId: table.id().toString(), + viewId: table.defaultView()._unsafeUnwrap().id().toString(), + fields: [{ fieldId: numberFieldId.toString(), statisticFunc: 'sum' }], + })._unsafeUnwrap(); + + const result = await handler.handle(context, query); + + expect(result._unsafeUnwrapErr().message).toBe('database unavailable'); + }); +}); diff --git a/packages/v2/core/src/queries/AggregateTableRecordsHandler.ts b/packages/v2/core/src/queries/AggregateTableRecordsHandler.ts new file mode 100644 index 0000000000..c8c8778711 --- /dev/null +++ b/packages/v2/core/src/queries/AggregateTableRecordsHandler.ts @@ -0,0 +1,136 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError'; +import type { TableRecordAggregationGroup } from '../domain/table/records/TableRecordAggregation'; +import { Table } from '../domain/table/Table'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import * as LoggerPort from '../ports/Logger'; +import { + ITableRecordAggregationQueryRepository, + type TableRecordAggregationValue, +} from '../ports/TableRecordQueryRepository'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { AggregateTableRecordsQuery } from './AggregateTableRecordsQuery'; +import { QueryHandler, type IQueryHandler } from './QueryHandler'; +import type { RecordFilter } from './RecordFilterDto'; +import { + buildSanitizedRecordConditionSpec, + replaceCurrentUserTagInFilter, + sanitizeRecordFilter, +} from './RecordFilterMapper'; +import { RecordSearch, resolveVisibleRowSearch } from './RecordSearch'; + +const mergeFilters = ( + defaultFilter: RecordFilter | null | undefined, + requestFilter: RecordFilter | null | undefined +): RecordFilter | undefined => { + if (!defaultFilter) return requestFilter ?? undefined; + if (!requestFilter) return defaultFilter; + return { conjunction: 'and', items: [defaultFilter, requestFilter] }; +}; + +export class AggregateTableRecordsResult { + private constructor( + readonly values: ReadonlyArray, + readonly groupBy: ReadonlyArray + ) {} + + static create( + values: ReadonlyArray, + groupBy: ReadonlyArray = [] + ): AggregateTableRecordsResult { + return new AggregateTableRecordsResult(values, groupBy); + } +} + +@QueryHandler(AggregateTableRecordsQuery) +@injectable() +export class AggregateTableRecordsHandler + implements IQueryHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableRecordQueryRepository) + private readonly tableRecordQueryRepository: ITableRecordAggregationQueryRepository, + @inject(v2CoreTokens.logger) + private readonly logger: LoggerPort.ILogger + ) {} + + async handle( + context: IExecutionContext, + query: AggregateTableRecordsQuery + ): Promise> { + const logger = this.logger.scope('query', { name: AggregateTableRecordsHandler.name }).child({ + tableId: query.tableId.toString(), + viewId: query.viewId.toString(), + }); + + return safeTry( + async function* (this: AggregateTableRecordsHandler) { + const tableSpec = yield* Table.specs().byId(query.tableId).withViewId(query.viewId).build(); + const table = yield* (await this.tableRepository.findOne(context, tableSpec)).mapErr( + (error) => + isNotFoundError(error) + ? domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${query.viewId.toString()}`, + }) + : error + ); + const view = yield* table.getView(query.viewId); + const defaults = yield* view.queryDefaults(); + const defaultFilter = replaceCurrentUserTagInFilter( + table, + defaults.filter(), + context.actorId.toString() + ); + const requestFilter = replaceCurrentUserTagInFilter( + table, + query.filter, + context.actorId.toString() + ); + const sanitizedDefaultFilter = yield* sanitizeRecordFilter(table, defaultFilter); + const sanitizedRequestFilter = yield* sanitizeRecordFilter(table, requestFilter); + const effectiveFilter = mergeFilters(sanitizedDefaultFilter, sanitizedRequestFilter); + const conditionSpec = yield* buildSanitizedRecordConditionSpec(table, effectiveFilter); + const aggregation = yield* table.createRecordAggregation({ + viewId: query.viewId.toString(), + fields: query.fields, + groupBy: query.groupBy, + includeHiddenFields: query.includeHiddenFields, + }); + const searchVisibleFieldIds = query.includeHiddenFields + ? table.fieldIds() + : yield* table.getOrderedVisibleFieldIds(query.viewId.toString()); + const visibleRowSearch = resolveVisibleRowSearch( + RecordSearch.fromOptionalTuple(query.search), + searchVisibleFieldIds + ); + + const values = yield* await this.tableRecordQueryRepository.aggregate( + context, + table, + aggregation, + conditionSpec, + { + maxGroupPoints: query.maxGroupPoints, + search: visibleRowSearch, + } + ); + logger.debug('AggregateTableRecordsHandler.success', { + fieldCount: aggregation.fields.length, + groupDepth: aggregation.groupBy.length, + valueCount: values.length, + }); + return ok(AggregateTableRecordsResult.create(values, aggregation.groupBy)); + }.bind(this) + ).orElse((error) => { + logger.error('AggregateTableRecordsHandler.failed', { error: error.toString() }); + return err(error); + }); + } +} diff --git a/packages/v2/core/src/queries/AggregateTableRecordsQuery.ts b/packages/v2/core/src/queries/AggregateTableRecordsQuery.ts new file mode 100644 index 0000000000..ff7df43768 --- /dev/null +++ b/packages/v2/core/src/queries/AggregateTableRecordsQuery.ts @@ -0,0 +1,83 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import type { + TableRecordAggregationFieldInput, + TableRecordAggregationGroupInput, +} from '../domain/table/records/TableRecordAggregation'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; +import { recordFilterSchema, type RecordFilter } from './RecordFilterDto'; +import { recordSearchInputSchema, type RecordSearchInput } from './RecordSearch'; + +const aggregationFieldSchema = z.object({ + fieldId: z.string().min(1), + statisticFunc: z.string().min(1), +}); + +const aggregationGroupSchema = z.object({ + fieldId: z.string().min(1), + order: z.enum(['asc', 'desc']), +}); + +export const aggregateTableRecordsInputSchema = z.object({ + tableId: z.string(), + viewId: z.string(), + filter: recordFilterSchema.optional(), + search: recordSearchInputSchema, + fields: z.array(aggregationFieldSchema).optional(), + groupBy: z.array(aggregationGroupSchema).optional(), + includeHiddenFields: z.boolean().optional(), +}); + +export type IAggregateTableRecordsQueryInput = z.input; + +export type IAggregateTableRecordsQueryOptions = { + readonly maxGroupPoints?: number; +}; + +export class AggregateTableRecordsQuery { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId, + readonly filter: RecordFilter | undefined, + readonly search: RecordSearchInput | undefined, + readonly fields: ReadonlyArray | undefined, + readonly groupBy: ReadonlyArray | undefined, + readonly includeHiddenFields: boolean, + readonly maxGroupPoints: number + ) {} + + static create( + raw: unknown, + options?: IAggregateTableRecordsQueryOptions + ): Result { + const parsed = aggregateTableRecordsInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid AggregateTableRecordsQuery input', + details: { issues: parsed.error.issues }, + }) + ); + } + + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map( + (viewId) => + new AggregateTableRecordsQuery( + tableId, + viewId, + parsed.data.filter, + parsed.data.search, + parsed.data.fields, + parsed.data.groupBy?.slice(0, 3), + parsed.data.includeHiddenFields ?? false, + Math.max(1, Math.floor(options?.maxGroupPoints ?? 5_000)) + ) + ) + ); + } +} diff --git a/packages/v2/core/src/queries/GetCalendarDailyCollectionHandler.spec.ts b/packages/v2/core/src/queries/GetCalendarDailyCollectionHandler.spec.ts new file mode 100644 index 0000000000..aac056ee5b --- /dev/null +++ b/packages/v2/core/src/queries/GetCalendarDailyCollectionHandler.spec.ts @@ -0,0 +1,203 @@ +import { err, ok } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError } from '../domain/shared/DomainError'; +import { FieldId } from '../domain/table/fields/FieldId'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { RecordId } from '../domain/table/records/RecordId'; +import { Table } from '../domain/table/Table'; +import { TableId } from '../domain/table/TableId'; +import { TableName } from '../domain/table/TableName'; +import { NoopLogger } from '../ports/defaults/NoopLogger'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import { MemoryTableRepository } from '../ports/memory/MemoryTableRepository'; +import type { ITableRecordCalendarQueryRepository } from '../ports/TableRecordQueryRepository'; +import { GetCalendarDailyCollectionHandler } from './GetCalendarDailyCollectionHandler'; +import { GetCalendarDailyCollectionQuery } from './GetCalendarDailyCollectionQuery'; + +const context: IExecutionContext = { + actorId: ActorId.create('usr_current')._unsafeUnwrap(), +}; + +const buildTable = () => { + const nameId = FieldId.create(`fld${'n'.repeat(16)}`)._unsafeUnwrap(); + const startId = FieldId.create(`fld${'s'.repeat(16)}`)._unsafeUnwrap(); + const endId = FieldId.create(`fld${'e'.repeat(16)}`)._unsafeUnwrap(); + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'d'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Calendar Query')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(nameId) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + builder.field().date().withId(startId).withName(FieldName.create('Start')._unsafeUnwrap()).done(); + builder.field().date().withId(endId).withName(FieldName.create('End')._unsafeUnwrap()).done(); + builder.view().calendar().defaultName().done(); + const table = builder.build()._unsafeUnwrap(); + return { table, nameId, startId, endId, viewId: table.defaultView()._unsafeUnwrap().id() }; +}; + +const buildQuery = ( + fixture: ReturnType, + overrides: Record = {} +) => + GetCalendarDailyCollectionQuery.create({ + tableId: fixture.table.id().toString(), + viewId: fixture.viewId.toString(), + startDate: '2025-01-01T00:00:00.000Z', + endDate: '2025-01-03T00:00:00.000Z', + startDateFieldId: fixture.startId.toString(), + endDateFieldId: fixture.endId.toString(), + ...overrides, + })._unsafeUnwrap(); + +describe('GetCalendarDailyCollectionQuery', () => { + it.each([ + undefined, + {}, + { tableId: 'bad', viewId: 'bad' }, + { + tableId: `tbl${'a'.repeat(16)}`, + viewId: `viw${'a'.repeat(16)}`, + startDate: '', + endDate: '', + startDateFieldId: '', + }, + ])('rejects invalid input: %j', (input) => { + expect(GetCalendarDailyCollectionQuery.create(input).isErr()).toBe(true); + }); +}); + +describe('GetCalendarDailyCollectionHandler', () => { + it('uses the Table aggregate plan, merges filter/search, and reads deduplicated records in bucket order', async () => { + const fixture = buildTable(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(context, fixture.table); + const firstId = RecordId.create(`rec${'a'.repeat(16)}`)._unsafeUnwrap(); + const secondId = RecordId.create(`rec${'b'.repeat(16)}`)._unsafeUnwrap(); + const calendarDailyCollection = vi.fn< + ITableRecordCalendarQueryRepository['calendarDailyCollection'] + >(async (_context, table, calendar, range, spec, options) => { + expect(table).toBe(fixture.table); + expect(calendar.startFieldId.equals(fixture.startId)).toBe(true); + expect(calendar.endFieldId.equals(fixture.endId)).toBe(true); + expect(range).toEqual({ + startDate: '2025-01-01T00:00:00.000Z', + endDate: '2025-01-03T00:00:00.000Z', + }); + expect(spec).toBeDefined(); + expect(options?.search?.search.value).toBe('Alpha'); + return ok([ + { date: '2025-01-01', count: 1, recordIds: [firstId] }, + { date: '2025-01-02', count: 2, recordIds: [secondId, firstId] }, + ]); + }); + const find = vi.fn( + async (_context, _table, _spec, options) => { + expect(options?.mode).toBe('stored'); + expect(options?.includeTotal).toBe(false); + expect(options?.recordIdsOrder?.map(String)).toEqual([ + firstId.toString(), + secondId.toString(), + ]); + expect(options?.projectionFieldIds?.map(String)).toEqual([ + fixture.nameId.toString(), + fixture.startId.toString(), + fixture.endId.toString(), + ]); + return ok({ + records: [ + { id: firstId.toString(), fields: { [fixture.nameId.toString()]: 'A' }, version: 1 }, + { id: secondId.toString(), fields: { [fixture.nameId.toString()]: 'B' }, version: 1 }, + ], + total: 2, + }); + } + ); + const handler = new GetCalendarDailyCollectionHandler( + tableRepository, + { calendarDailyCollection, find } as unknown as ITableRecordCalendarQueryRepository, + new NoopLogger() + ); + const query = buildQuery(fixture, { + filter: { + fieldId: fixture.nameId.toString(), + operator: 'contains', + value: 'A', + }, + search: ['Alpha', fixture.nameId.toString(), true], + }); + + const result = await handler.handle(context, query); + + expect(result._unsafeUnwrap().countMap).toEqual({ + '2025-01-01': 1, + '2025-01-02': 2, + }); + expect(result._unsafeUnwrap().records.map((record) => record.id)).toEqual([ + firstId.toString(), + secondId.toString(), + ]); + expect(calendarDailyCollection).toHaveBeenCalledOnce(); + expect(find).toHaveBeenCalledOnce(); + }); + + it('does not turn highlight-only search into a row filter and skips record fetch for empty buckets', async () => { + const fixture = buildTable(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(context, fixture.table); + const calendarDailyCollection = vi.fn< + ITableRecordCalendarQueryRepository['calendarDailyCollection'] + >(async (_context, _table, _calendar, _range, _spec, options) => { + expect(options?.search).toBeUndefined(); + return ok([]); + }); + const find = vi.fn(); + const handler = new GetCalendarDailyCollectionHandler( + tableRepository, + { calendarDailyCollection, find } as unknown as ITableRecordCalendarQueryRepository, + new NoopLogger() + ); + + const result = await handler.handle( + context, + buildQuery(fixture, { search: ['Alpha', fixture.nameId.toString(), false] }) + ); + + expect(result._unsafeUnwrap()).toMatchObject({ countMap: {}, records: [] }); + expect(find).not.toHaveBeenCalled(); + }); + + it('maps missing aggregate children and propagates repository failures', async () => { + const fixture = buildTable(); + const calendarDailyCollection = vi.fn< + ITableRecordCalendarQueryRepository['calendarDailyCollection'] + >(async () => err(domainError.infrastructure({ message: 'database unavailable' }))); + const missingHandler = new GetCalendarDailyCollectionHandler( + new MemoryTableRepository(), + { calendarDailyCollection } as unknown as ITableRecordCalendarQueryRepository, + new NoopLogger() + ); + expect( + (await missingHandler.handle(context, buildQuery(fixture)))._unsafeUnwrapErr() + ).toMatchObject({ code: 'view.not_found' }); + expect(calendarDailyCollection).not.toHaveBeenCalled(); + + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(context, fixture.table); + const failingHandler = new GetCalendarDailyCollectionHandler( + tableRepository, + { calendarDailyCollection } as unknown as ITableRecordCalendarQueryRepository, + new NoopLogger() + ); + expect( + (await failingHandler.handle(context, buildQuery(fixture)))._unsafeUnwrapErr() + ).toMatchObject({ message: 'database unavailable' }); + }); +}); diff --git a/packages/v2/core/src/queries/GetCalendarDailyCollectionHandler.ts b/packages/v2/core/src/queries/GetCalendarDailyCollectionHandler.ts new file mode 100644 index 0000000000..ff31557d5a --- /dev/null +++ b/packages/v2/core/src/queries/GetCalendarDailyCollectionHandler.ts @@ -0,0 +1,160 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError'; +import { RecordByIdsSpec } from '../domain/table/records/specs/RecordByIdsSpec'; +import { Table } from '../domain/table/Table'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import * as LoggerPort from '../ports/Logger'; +import { + ITableRecordCalendarQueryRepository, + type TableRecordCalendarDailyCollectionEntry, +} from '../ports/TableRecordQueryRepository'; +import type { TableRecordReadModel } from '../ports/TableRecordReadModel'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { GetCalendarDailyCollectionQuery } from './GetCalendarDailyCollectionQuery'; +import { QueryHandler, type IQueryHandler } from './QueryHandler'; +import type { RecordFilter } from './RecordFilterDto'; +import { + buildSanitizedRecordConditionSpec, + replaceCurrentUserTagInFilter, + sanitizeRecordFilter, +} from './RecordFilterMapper'; +import { RecordSearch, resolveVisibleRowSearch } from './RecordSearch'; + +const mergeFilters = ( + defaultFilter: RecordFilter | null | undefined, + requestFilter: RecordFilter | null | undefined +): RecordFilter | undefined => { + if (!defaultFilter) return requestFilter ?? undefined; + if (!requestFilter) return defaultFilter; + return { conjunction: 'and', items: [defaultFilter, requestFilter] }; +}; + +export class GetCalendarDailyCollectionResult { + private constructor( + readonly countMap: Readonly>, + readonly records: ReadonlyArray + ) {} + + static create( + entries: ReadonlyArray, + records: ReadonlyArray + ): GetCalendarDailyCollectionResult { + return new GetCalendarDailyCollectionResult( + Object.fromEntries(entries.map((entry) => [entry.date, entry.count])), + records + ); + } +} + +@QueryHandler(GetCalendarDailyCollectionQuery) +@injectable() +export class GetCalendarDailyCollectionHandler + implements IQueryHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableRecordQueryRepository) + private readonly tableRecordQueryRepository: ITableRecordCalendarQueryRepository, + @inject(v2CoreTokens.logger) + private readonly logger: LoggerPort.ILogger + ) {} + + async handle( + context: IExecutionContext, + query: GetCalendarDailyCollectionQuery + ): Promise> { + const logger = this.logger + .scope('query', { name: GetCalendarDailyCollectionHandler.name }) + .child({ + tableId: query.tableId.toString(), + viewId: query.viewId.toString(), + }); + + return safeTry( + async function* (this: GetCalendarDailyCollectionHandler) { + const tableSpec = yield* Table.specs().byId(query.tableId).withViewId(query.viewId).build(); + const table = yield* (await this.tableRepository.findOne(context, tableSpec)).mapErr( + (error) => + isNotFoundError(error) + ? domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${query.viewId.toString()}`, + }) + : error + ); + const view = yield* table.getView(query.viewId); + const defaults = yield* view.queryDefaults(); + const defaultFilter = replaceCurrentUserTagInFilter( + table, + defaults.filter(), + context.actorId.toString() + ); + const requestFilter = replaceCurrentUserTagInFilter( + table, + query.filter, + context.actorId.toString() + ); + const sanitizedDefaultFilter = yield* sanitizeRecordFilter(table, defaultFilter); + const sanitizedRequestFilter = yield* sanitizeRecordFilter(table, requestFilter); + const effectiveFilter = mergeFilters(sanitizedDefaultFilter, sanitizedRequestFilter); + const conditionSpec = yield* buildSanitizedRecordConditionSpec(table, effectiveFilter); + const calendar = yield* table.createRecordCalendarDailyCollection({ + viewId: query.viewId.toString(), + startFieldId: query.startDateFieldId, + endFieldId: query.endDateFieldId, + includeHiddenFields: query.includeHiddenFields, + }); + const visibleFieldIds = query.includeHiddenFields + ? table.fieldIds() + : yield* table.getOrderedVisibleFieldIds(query.viewId.toString()); + const visibleRowSearch = resolveVisibleRowSearch( + RecordSearch.fromOptionalTuple(query.search), + visibleFieldIds + ); + const entries = yield* await this.tableRecordQueryRepository.calendarDailyCollection( + context, + table, + calendar, + { startDate: query.startDate, endDate: query.endDate }, + conditionSpec, + { search: visibleRowSearch } + ); + const recordIds = [ + ...new Map( + entries.flatMap((entry) => + entry.recordIds.map((recordId) => [recordId.toString(), recordId] as const) + ) + ).values(), + ]; + if (!recordIds.length) { + return ok(GetCalendarDailyCollectionResult.create(entries, [])); + } + + const recordsResult = yield* await this.tableRecordQueryRepository.find( + context, + table, + RecordByIdsSpec.create(recordIds), + { + mode: 'stored', + projectionFieldIds: visibleFieldIds, + recordIdsOrder: recordIds, + includeTotal: false, + } + ); + logger.debug('GetCalendarDailyCollectionHandler.success', { + dateCount: entries.length, + recordCount: recordsResult.records.length, + }); + return ok(GetCalendarDailyCollectionResult.create(entries, recordsResult.records)); + }.bind(this) + ).orElse((error) => { + logger.error('GetCalendarDailyCollectionHandler.failed', { error: error.toString() }); + return err(error); + }); + } +} diff --git a/packages/v2/core/src/queries/GetCalendarDailyCollectionQuery.ts b/packages/v2/core/src/queries/GetCalendarDailyCollectionQuery.ts new file mode 100644 index 0000000000..e823c0eb5f --- /dev/null +++ b/packages/v2/core/src/queries/GetCalendarDailyCollectionQuery.ts @@ -0,0 +1,64 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; +import { recordFilterSchema, type RecordFilter } from './RecordFilterDto'; +import { recordSearchInputSchema, type RecordSearchInput } from './RecordSearch'; + +export const getCalendarDailyCollectionInputSchema = z.object({ + tableId: z.string(), + viewId: z.string(), + startDate: z.string().min(1), + endDate: z.string().min(1), + startDateFieldId: z.string().min(1), + endDateFieldId: z.string().optional(), + filter: recordFilterSchema.optional(), + search: recordSearchInputSchema, + includeHiddenFields: z.boolean().optional(), +}); + +export class GetCalendarDailyCollectionQuery { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId, + readonly startDate: string, + readonly endDate: string, + readonly startDateFieldId: string, + readonly endDateFieldId: string | undefined, + readonly filter: RecordFilter | undefined, + readonly search: RecordSearchInput | undefined, + readonly includeHiddenFields: boolean + ) {} + + static create(raw: unknown): Result { + const parsed = getCalendarDailyCollectionInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid GetCalendarDailyCollectionQuery input', + details: { issues: parsed.error.issues }, + }) + ); + } + + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map( + (viewId) => + new GetCalendarDailyCollectionQuery( + tableId, + viewId, + parsed.data.startDate, + parsed.data.endDate, + parsed.data.startDateFieldId, + parsed.data.endDateFieldId, + parsed.data.filter, + parsed.data.search, + parsed.data.includeHiddenFields ?? false + ) + ) + ); + } +} diff --git a/packages/v2/core/src/queries/GetDefaultViewIdHandler.spec.ts b/packages/v2/core/src/queries/GetDefaultViewIdHandler.spec.ts new file mode 100644 index 0000000000..878d8cd06a --- /dev/null +++ b/packages/v2/core/src/queries/GetDefaultViewIdHandler.spec.ts @@ -0,0 +1,137 @@ +import { err } from 'neverthrow'; +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError } from '../domain/shared/DomainError'; +import { FieldId } from '../domain/table/fields/FieldId'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { Table } from '../domain/table/Table'; +import { TableId } from '../domain/table/TableId'; +import { TableName } from '../domain/table/TableName'; +import { ViewId } from '../domain/table/views/ViewId'; +import { ViewName } from '../domain/table/views/ViewName'; +import { NoopLogger } from '../ports/defaults/NoopLogger'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import { MemoryTableRepository } from '../ports/memory/MemoryTableRepository'; +import type { ITableRepository } from '../ports/TableRepository'; +import { GetDefaultViewIdHandler } from './GetDefaultViewIdHandler'; +import { GetDefaultViewIdQuery } from './GetDefaultViewIdQuery'; + +const context: IExecutionContext = { + actorId: ActorId.create('system')._unsafeUnwrap(), +}; + +const buildTable = () => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Default View')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(FieldId.create(`fld${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .done(); + builder + .view() + .grid() + .withId(ViewId.create(`viw${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(ViewName.create('First')._unsafeUnwrap()) + .done(); + builder + .view() + .grid() + .withId(ViewId.create(`viw${'b'.repeat(16)}`)._unsafeUnwrap()) + .withName(ViewName.create('Second')._unsafeUnwrap()) + .done(); + return builder.build()._unsafeUnwrap(); +}; + +describe('GetDefaultViewIdQuery', () => { + it('creates a nominal Table ID', () => { + const table = buildTable(); + const query = GetDefaultViewIdQuery.create({ + tableId: table.id().toString(), + })._unsafeUnwrap(); + + expect(query.tableId.equals(table.id())).toBe(true); + }); + + it.each([undefined, {}, { tableId: 'invalid' }])('rejects invalid input: %j', (input) => { + const result = GetDefaultViewIdQuery.create(input); + + expect(result._unsafeUnwrapErr().code).toBe('validation.invalid'); + }); +}); + +describe('GetDefaultViewIdHandler', () => { + it('returns the first ordered View child selected by the Table aggregate', async () => { + const table = buildTable(); + const repository = new MemoryTableRepository(); + await repository.insert(context, table); + const handler = new GetDefaultViewIdHandler(repository, new NoopLogger()); + + const result = await handler.handle( + context, + GetDefaultViewIdQuery.create({ tableId: table.id().toString() })._unsafeUnwrap() + ); + + expect(result._unsafeUnwrap().viewId).toBe(`viw${'a'.repeat(16)}`); + }); + + it('maps a missing Table aggregate to table.not_found', async () => { + const table = buildTable(); + const handler = new GetDefaultViewIdHandler(new MemoryTableRepository(), new NoopLogger()); + + const result = await handler.handle( + context, + GetDefaultViewIdQuery.create({ tableId: table.id().toString() })._unsafeUnwrap() + ); + + expect(result._unsafeUnwrapErr()).toMatchObject({ + code: 'table.not_found', + message: 'Table not found', + }); + }); + + it('returns view.not_found when a rehydrated Table has no active View child', async () => { + const table = buildTable(); + const emptyTable = Table.rehydrate({ + id: table.id(), + baseId: table.baseId(), + name: table.name(), + fields: table.getFields(), + views: [], + primaryFieldId: table.primaryFieldId(), + })._unsafeUnwrap(); + const repository = new MemoryTableRepository(); + await repository.insert(context, emptyTable); + const handler = new GetDefaultViewIdHandler(repository, new NoopLogger()); + + const result = await handler.handle( + context, + GetDefaultViewIdQuery.create({ tableId: table.id().toString() })._unsafeUnwrap() + ); + + expect(result._unsafeUnwrapErr()).toMatchObject({ + code: 'view.not_found', + message: `View not found with tableId: ${table.id().toString()}`, + }); + }); + + it('propagates unexpected Table repository failures', async () => { + const table = buildTable(); + const repository = { + findOne: async () => err(domainError.unexpected({ message: 'query failed' })), + } as unknown as ITableRepository; + const handler = new GetDefaultViewIdHandler(repository, new NoopLogger()); + + const result = await handler.handle( + context, + GetDefaultViewIdQuery.create({ tableId: table.id().toString() })._unsafeUnwrap() + ); + + expect(result._unsafeUnwrapErr().message).toBe('query failed'); + }); +}); diff --git a/packages/v2/core/src/queries/GetDefaultViewIdHandler.ts b/packages/v2/core/src/queries/GetDefaultViewIdHandler.ts new file mode 100644 index 0000000000..d271de95ed --- /dev/null +++ b/packages/v2/core/src/queries/GetDefaultViewIdHandler.ts @@ -0,0 +1,61 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError'; +import { Table } from '../domain/table/Table'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import * as LoggerPort from '../ports/Logger'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { GetDefaultViewIdQuery } from './GetDefaultViewIdQuery'; +import { QueryHandler, type IQueryHandler } from './QueryHandler'; + +export class GetDefaultViewIdResult { + private constructor(readonly viewId: string) {} + + static create(viewId: string): GetDefaultViewIdResult { + return new GetDefaultViewIdResult(viewId); + } +} + +@QueryHandler(GetDefaultViewIdQuery) +@injectable() +export class GetDefaultViewIdHandler + implements IQueryHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.logger) + private readonly logger: LoggerPort.ILogger + ) {} + + async handle( + context: IExecutionContext, + query: GetDefaultViewIdQuery + ): Promise> { + const logger = this.logger.scope('query', { name: GetDefaultViewIdHandler.name }).child({ + tableId: query.tableId.toString(), + }); + logger.debug('GetDefaultViewIdHandler.start', { actorId: context.actorId.toString() }); + + const specResult = Table.specs().byId(query.tableId).build(); + if (specResult.isErr()) return err(specResult.error); + + const tableResult = await this.tableRepository.findOne(context, specResult.value); + if (tableResult.isErr()) { + if (isNotFoundError(tableResult.error)) { + return err(domainError.notFound({ code: 'table.not_found', message: 'Table not found' })); + } + return err(tableResult.error); + } + + const defaultViewResult = tableResult.value.defaultView(); + if (defaultViewResult.isErr()) return err(defaultViewResult.error); + + const viewId = defaultViewResult.value.id().toString(); + logger.debug('GetDefaultViewIdHandler.success', { viewId }); + return ok(GetDefaultViewIdResult.create(viewId)); + } +} diff --git a/packages/v2/core/src/queries/GetDefaultViewIdQuery.ts b/packages/v2/core/src/queries/GetDefaultViewIdQuery.ts new file mode 100644 index 0000000000..21bd4b49ed --- /dev/null +++ b/packages/v2/core/src/queries/GetDefaultViewIdQuery.ts @@ -0,0 +1,25 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; + +export const getDefaultViewIdInputSchema = z.object({ + tableId: z.string(), +}); + +export type IGetDefaultViewIdQueryInput = z.input; + +export class GetDefaultViewIdQuery { + private constructor(readonly tableId: TableId) {} + + static create(raw: unknown): Result { + const parsed = getDefaultViewIdInputSchema.safeParse(raw); + if (!parsed.success) { + return err(domainError.validation({ message: 'Invalid GetDefaultViewIdQuery input' })); + } + + return TableId.create(parsed.data.tableId).map((tableId) => new GetDefaultViewIdQuery(tableId)); + } +} diff --git a/packages/v2/core/src/queries/GetViewCollaboratorsHandler.spec.ts b/packages/v2/core/src/queries/GetViewCollaboratorsHandler.spec.ts new file mode 100644 index 0000000000..ec86ed407d --- /dev/null +++ b/packages/v2/core/src/queries/GetViewCollaboratorsHandler.spec.ts @@ -0,0 +1,184 @@ +import { err, ok } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { FieldId } from '../domain/table/fields/FieldId'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { Table } from '../domain/table/Table'; +import { TableId } from '../domain/table/TableId'; +import { TableName } from '../domain/table/TableName'; +import { ViewId } from '../domain/table/views/ViewId'; +import type { ICollaboratorDirectoryService } from '../ports/CollaboratorDirectoryService'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import { MemoryTableRepository } from '../ports/memory/MemoryTableRepository'; +import type { ITableRecordCollaboratorQueryRepository } from '../ports/TableRecordQueryRepository'; +import { GetViewCollaboratorsHandler } from './GetViewCollaboratorsHandler'; +import { GetViewCollaboratorsQuery } from './GetViewCollaboratorsQuery'; + +const context: IExecutionContext = { + actorId: ActorId.create(`usr${'a'.repeat(16)}`)._unsafeUnwrap(), +}; +const id = (prefix: 'bse' | 'tbl' | 'fld' | 'viw', seed: string) => `${prefix}${seed.repeat(16)}`; + +const buildTable = (viewType: 'grid' | 'form') => { + const tableId = TableId.create(id('tbl', 't'))._unsafeUnwrap(); + const primaryFieldId = FieldId.create(id('fld', 'p'))._unsafeUnwrap(); + const userFieldId = FieldId.create(id('fld', 'u'))._unsafeUnwrap(); + const viewId = ViewId.create(id('viw', 'v'))._unsafeUnwrap(); + const builder = Table.builder() + .withBaseId(BaseId.create(id('bse', 'b'))._unsafeUnwrap()) + .withId(tableId) + .withName(TableName.create('Collaborators')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(primaryFieldId) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + builder + .field() + .user() + .withId(userFieldId) + .withName(FieldName.create('Owner')._unsafeUnwrap()) + .done(); + builder.view()[viewType]().withId(viewId).defaultName().done(); + const table = builder.build()._unsafeUnwrap(); + return { table, tableId, viewId, userFieldId }; +}; + +const createRecordRepository = (userIds: ReadonlyArray) => { + const findDistinctUserIds = vi.fn(async () => ok(userIds)); + const repository: ITableRecordCollaboratorQueryRepository = { + findDistinctUserIds, + find: async () => err(new Error('not used') as never), + findOne: async () => err(new Error('not used') as never), + async *findStream() {}, + }; + return { repository, findDistinctUserIds }; +}; + +const createDirectory = () => { + const listBaseUsers = vi.fn(async () => + ok([{ id: 'usr-base', name: 'Base User', avatar: 'base.png' }]) + ); + const listUsersByIds = vi.fn(async () => + ok([{ id: 'usr-ref', name: 'Referenced User', avatar: null }]) + ); + return { + service: { listBaseUsers, listUsersByIds } satisfies ICollaboratorDirectoryService, + listBaseUsers, + listUsersByIds, + }; +}; + +describe('GetViewCollaboratorsQuery', () => { + it('defaults pagination and rejects malformed identifiers/windows', () => { + const fixture = buildTable('grid'); + const query = GetViewCollaboratorsQuery.create({ + tableId: fixture.tableId.toString(), + viewId: fixture.viewId.toString(), + fieldId: fixture.userFieldId.toString(), + skip: 5, + })._unsafeUnwrap(); + + expect(query.pagination.limit().toNumber()).toBe(50); + expect(query.pagination.offset().toNumber()).toBe(5); + expect( + GetViewCollaboratorsQuery.create({ tableId: 'invalid', take: 0 })._unsafeUnwrapErr().code + ).toBe('validation.invalid'); + }); +}); + +describe('GetViewCollaboratorsHandler', () => { + it('uses the directory directly for an all-mode Form plan', async () => { + const fixture = buildTable('form'); + const tables = new MemoryTableRepository(); + await tables.insert(context, fixture.table); + const records = createRecordRepository(['usr-ref']); + const directory = createDirectory(); + const handler = new GetViewCollaboratorsHandler(tables, records.repository, directory.service); + const query = GetViewCollaboratorsQuery.create({ + tableId: fixture.tableId.toString(), + viewId: fixture.viewId.toString(), + search: 'Base', + take: 10, + })._unsafeUnwrap(); + + expect((await handler.handle(context, query))._unsafeUnwrap().collaborators).toEqual([ + { userId: 'usr-base', userName: 'Base User', avatar: 'base.png' }, + ]); + expect(directory.listBaseUsers).toHaveBeenCalledWith( + context, + fixture.table.baseId(), + expect.objectContaining({ search: 'Base' }) + ); + expect(records.findDistinctUserIds).not.toHaveBeenCalled(); + expect(directory.listUsersByIds).not.toHaveBeenCalled(); + }); + + it('applies the referenced plan before looking up public-safe users', async () => { + const fixture = buildTable('grid'); + const tables = new MemoryTableRepository(); + await tables.insert(context, fixture.table); + const records = createRecordRepository(['usr-ref', 'usr-ref']); + const directory = createDirectory(); + const handler = new GetViewCollaboratorsHandler(tables, records.repository, directory.service); + const query = GetViewCollaboratorsQuery.create({ + tableId: fixture.tableId.toString(), + viewId: fixture.viewId.toString(), + fieldId: fixture.userFieldId.toString(), + search: 'Referenced', + })._unsafeUnwrap(); + + expect((await handler.handle(context, query))._unsafeUnwrap().collaborators).toEqual([ + { userId: 'usr-ref', userName: 'Referenced User', avatar: null }, + ]); + expect(records.findDistinctUserIds).toHaveBeenCalledOnce(); + expect(directory.listUsersByIds).toHaveBeenCalledWith( + context, + ['usr-ref', 'usr-ref'], + expect.objectContaining({ search: 'Referenced' }) + ); + expect(directory.listBaseUsers).not.toHaveBeenCalled(); + }); + + it('returns an empty result without querying the directory when no records reference users', async () => { + const fixture = buildTable('grid'); + const tables = new MemoryTableRepository(); + await tables.insert(context, fixture.table); + const records = createRecordRepository([]); + const directory = createDirectory(); + const handler = new GetViewCollaboratorsHandler(tables, records.repository, directory.service); + const query = GetViewCollaboratorsQuery.create({ + tableId: fixture.tableId.toString(), + viewId: fixture.viewId.toString(), + fieldId: fixture.userFieldId.toString(), + })._unsafeUnwrap(); + + expect((await handler.handle(context, query))._unsafeUnwrap().collaborators).toEqual([]); + expect(directory.listUsersByIds).not.toHaveBeenCalled(); + }); + + it('maps a missing partial aggregate to View not found', async () => { + const fixture = buildTable('grid'); + const records = createRecordRepository([]); + const directory = createDirectory(); + const handler = new GetViewCollaboratorsHandler( + new MemoryTableRepository(), + records.repository, + directory.service + ); + const query = GetViewCollaboratorsQuery.create({ + tableId: fixture.tableId.toString(), + viewId: fixture.viewId.toString(), + fieldId: fixture.userFieldId.toString(), + })._unsafeUnwrap(); + + expect((await handler.handle(context, query))._unsafeUnwrapErr()).toMatchObject({ + code: 'view.not_found', + tags: ['not-found'], + }); + }); +}); diff --git a/packages/v2/core/src/queries/GetViewCollaboratorsHandler.ts b/packages/v2/core/src/queries/GetViewCollaboratorsHandler.ts new file mode 100644 index 0000000000..a5f4a69076 --- /dev/null +++ b/packages/v2/core/src/queries/GetViewCollaboratorsHandler.ts @@ -0,0 +1,126 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError'; +import { RecordConditionSpecBuilder } from '../domain/table/records/specs/RecordConditionSpecBuilder'; +import { Table } from '../domain/table/Table'; +import { ICollaboratorDirectoryService } from '../ports/CollaboratorDirectoryService'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import { ITableRecordCollaboratorQueryRepository } from '../ports/TableRecordQueryRepository'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { GetViewCollaboratorsQuery } from './GetViewCollaboratorsQuery'; +import { QueryHandler, type IQueryHandler } from './QueryHandler'; +import { + buildRecordConditionSpec, + replaceCurrentUserTagInFilter, + sanitizeRecordFilter, +} from './RecordFilterMapper'; + +export type ViewCollaborator = { + readonly userId: string; + readonly userName: string; + readonly avatar?: string | null; +}; + +export class GetViewCollaboratorsResult { + private constructor(readonly collaborators: ReadonlyArray) {} + + static create(collaborators: ReadonlyArray): GetViewCollaboratorsResult { + return new GetViewCollaboratorsResult(collaborators); + } +} + +@QueryHandler(GetViewCollaboratorsQuery) +@injectable() +export class GetViewCollaboratorsHandler + implements IQueryHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableRecordQueryRepository) + private readonly tableRecordQueryRepository: ITableRecordCollaboratorQueryRepository, + @inject(v2CoreTokens.collaboratorDirectoryService) + private readonly collaboratorDirectoryService: ICollaboratorDirectoryService + ) {} + + async handle( + context: IExecutionContext, + query: GetViewCollaboratorsQuery + ): Promise> { + return safeTry( + async function* (this: GetViewCollaboratorsHandler) { + const specBuilder = Table.specs().byId(query.tableId); + if (query.viewId) specBuilder.withViewId(query.viewId); + const tableSpec = yield* specBuilder.build(); + const table = yield* (await this.tableRepository.findOne(context, tableSpec)).mapErr( + (error) => + isNotFoundError(error) && query.viewId + ? domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${query.viewId.toString()}`, + }) + : error + ); + const plan = yield* table.createViewCollaboratorsQueryPlan({ + viewId: query.viewId, + fieldId: query.fieldId, + includeHiddenFields: query.includeHiddenFields, + canReadAllCollaborators: query.canReadAllCollaborators, + }); + + if (plan.mode === 'empty') return ok(GetViewCollaboratorsResult.create([])); + + if (plan.mode === 'all') { + const users = yield* await this.collaboratorDirectoryService.listBaseUsers( + context, + table.baseId(), + { pagination: query.pagination, search: query.search } + ); + return ok(GetViewCollaboratorsResult.create(users.map(GetViewCollaboratorsHandler.map))); + } + + const field = yield* plan.referencedField(); + let conditionSpec; + const rawFilter = replaceCurrentUserTagInFilter( + table, + plan.recordFilter(), + context.actorId.toString() + ); + const sanitizedFilter = yield* sanitizeRecordFilter(table, rawFilter); + if (sanitizedFilter) { + const builder = RecordConditionSpecBuilder.create(); + builder.addConditionSpec(yield* buildRecordConditionSpec(table, sanitizedFilter)); + conditionSpec = yield* builder.build(); + } + const userIds = yield* await this.tableRecordQueryRepository.findDistinctUserIds( + context, + table, + field, + conditionSpec + ); + if (!userIds.length) return ok(GetViewCollaboratorsResult.create([])); + const users = yield* await this.collaboratorDirectoryService.listUsersByIds( + context, + userIds, + { pagination: query.pagination, search: query.search } + ); + return ok(GetViewCollaboratorsResult.create(users.map(GetViewCollaboratorsHandler.map))); + }.bind(this) + ); + } + + private static map(user: { + readonly id: string; + readonly name: string; + readonly avatar?: string | null; + }): ViewCollaborator { + return { + userId: user.id, + userName: user.name, + ...(user.avatar !== undefined ? { avatar: user.avatar } : {}), + }; + } +} diff --git a/packages/v2/core/src/queries/GetViewCollaboratorsQuery.ts b/packages/v2/core/src/queries/GetViewCollaboratorsQuery.ts new file mode 100644 index 0000000000..a96e7a85eb --- /dev/null +++ b/packages/v2/core/src/queries/GetViewCollaboratorsQuery.ts @@ -0,0 +1,65 @@ +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { OffsetPagination } from '../domain/shared/pagination/OffsetPagination'; +import { PageLimit } from '../domain/shared/pagination/PageLimit'; +import { PageOffset } from '../domain/shared/pagination/PageOffset'; +import { FieldId } from '../domain/table/fields/FieldId'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; + +const getViewCollaboratorsInputSchema = z.object({ + tableId: z.string(), + viewId: z.string().optional(), + fieldId: z.string().optional(), + includeHiddenFields: z.boolean().optional(), + canReadAllCollaborators: z.boolean().optional(), + search: z.string().optional(), + take: z.coerce.number().int().positive().optional(), + skip: z.coerce.number().int().nonnegative().optional(), +}); + +export class GetViewCollaboratorsQuery { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId | undefined, + readonly fieldId: FieldId | undefined, + readonly includeHiddenFields: boolean, + readonly canReadAllCollaborators: boolean, + readonly search: string | undefined, + readonly pagination: OffsetPagination + ) {} + + static create(raw: unknown): Result { + const parsed = getViewCollaboratorsInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid GetViewCollaboratorsQuery input', + details: { issues: parsed.error.issues }, + }) + ); + } + + return safeTry(function* () { + const tableId = yield* TableId.create(parsed.data.tableId); + const viewId = parsed.data.viewId ? yield* ViewId.create(parsed.data.viewId) : undefined; + const fieldId = parsed.data.fieldId ? yield* FieldId.create(parsed.data.fieldId) : undefined; + const limit = yield* PageLimit.create(parsed.data.take ?? 50); + const offset = yield* PageOffset.create(parsed.data.skip ?? 0); + return ok( + new GetViewCollaboratorsQuery( + tableId, + viewId, + fieldId, + parsed.data.includeHiddenFields ?? false, + parsed.data.canReadAllCollaborators ?? false, + parsed.data.search, + OffsetPagination.create(limit, offset) + ) + ); + }); + } +} diff --git a/packages/v2/core/src/queries/GetViewFilterLinkRecordsHandler.spec.ts b/packages/v2/core/src/queries/GetViewFilterLinkRecordsHandler.spec.ts new file mode 100644 index 0000000000..4ef681dde2 --- /dev/null +++ b/packages/v2/core/src/queries/GetViewFilterLinkRecordsHandler.spec.ts @@ -0,0 +1,281 @@ +import { err, ok } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError } from '../domain/shared/DomainError'; +import { FieldId } from '../domain/table/fields/FieldId'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { LinkFieldConfig } from '../domain/table/fields/types/LinkFieldConfig'; +import { RecordId } from '../domain/table/records/RecordId'; +import { RecordByIdsSpec } from '../domain/table/records/specs/RecordByIdsSpec'; +import { Table } from '../domain/table/Table'; +import { TableId } from '../domain/table/TableId'; +import { TableName } from '../domain/table/TableName'; +import { GridView } from '../domain/table/views/types/GridView'; +import { ViewId } from '../domain/table/views/ViewId'; +import { ViewName } from '../domain/table/views/ViewName'; +import { ViewQueryDefaults } from '../domain/table/views/ViewQueryDefaults'; +import { NoopLogger } from '../ports/defaults/NoopLogger'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import { MemoryTableRepository } from '../ports/memory/MemoryTableRepository'; +import type { + ITableRecordQueryOptions, + ITableRecordQueryRepository, +} from '../ports/TableRecordQueryRepository'; +import { + GetViewFilterLinkRecordsHandler, + type ViewFilterLinkRecordGroup, +} from './GetViewFilterLinkRecordsHandler'; +import { GetViewFilterLinkRecordsQuery } from './GetViewFilterLinkRecordsQuery'; + +const context: IExecutionContext = { + actorId: ActorId.create('system')._unsafeUnwrap(), +}; +const baseId = BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap(); +const tableId = (seed: string) => TableId.create(`tbl${seed.repeat(16)}`)._unsafeUnwrap(); +const fieldId = (seed: string) => FieldId.create(`fld${seed.repeat(16)}`)._unsafeUnwrap(); +const recordId = (seed: string) => RecordId.create(`rec${seed.repeat(16)}`)._unsafeUnwrap(); +const viewId = (seed: string) => ViewId.create(`viw${seed.repeat(16)}`)._unsafeUnwrap(); + +const buildTables = (options?: { missingLookupField?: boolean; withFilter?: boolean }) => { + const foreignBuilder = Table.builder() + .withId(tableId('b')) + .withBaseId(baseId) + .withName(TableName.create('Foreign')._unsafeUnwrap()); + foreignBuilder + .field() + .singleLineText() + .withId(fieldId('c')) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .primary() + .done(); + foreignBuilder.view().defaultGrid().done(); + const foreignTable = foreignBuilder.build()._unsafeUnwrap(); + const lookupFieldId = options?.missingLookupField ? fieldId('z') : foreignTable.primaryFieldId(); + const linkFieldId = fieldId('d'); + const ownedViewId = viewId('e'); + const firstRecordId = recordId('f'); + const secondRecordId = recordId('g'); + + const sourceBuilder = Table.builder() + .withId(tableId('h')) + .withBaseId(baseId) + .withName(TableName.create('Source')._unsafeUnwrap()); + sourceBuilder + .field() + .singleLineText() + .withId(fieldId('i')) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + sourceBuilder + .field() + .link() + .withId(linkFieldId) + .withName(FieldName.create('Foreign')._unsafeUnwrap()) + .withConfig( + LinkFieldConfig.create({ + relationship: 'manyMany', + foreignTableId: foreignTable.id().toString(), + lookupFieldId: lookupFieldId.toString(), + isOneWay: true, + })._unsafeUnwrap() + ) + .done(); + sourceBuilder.view().defaultGrid().done(); + const sourceFieldsTable = sourceBuilder.build()._unsafeUnwrap(); + const sourceView = GridView.create({ + id: ownedViewId, + name: ViewName.create('Grid')._unsafeUnwrap(), + })._unsafeUnwrap(); + sourceView + .setQueryDefaults( + ViewQueryDefaults.rehydrate( + {}, + { + sourceFilter: + options?.withFilter === false + ? null + : { + conjunction: 'and', + filterSet: [ + { + fieldId: linkFieldId.toString(), + operator: 'isAnyOf', + value: [firstRecordId.toString(), secondRecordId.toString()], + }, + ], + }, + } + )._unsafeUnwrap() + ) + ._unsafeUnwrap(); + const sourceTable = Table.rehydrate({ + id: sourceFieldsTable.id(), + baseId: sourceFieldsTable.baseId(), + name: sourceFieldsTable.name(), + fields: sourceFieldsTable.getFields(), + views: [sourceView], + primaryFieldId: sourceFieldsTable.primaryFieldId(), + })._unsafeUnwrap(); + + return { + sourceTable, + foreignTable, + lookupFieldId, + ownedViewId, + firstRecordId, + secondRecordId, + }; +}; + +const createRecordRepository = ( + find: ITableRecordQueryRepository['find'] +): ITableRecordQueryRepository => + ({ + find, + }) as unknown as ITableRecordQueryRepository; + +const execute = async ( + tables: ReturnType, + recordRepository: ITableRecordQueryRepository, + tableRepository = new MemoryTableRepository() +) => { + await tableRepository.insert(context, tables.sourceTable); + await tableRepository.insert(context, tables.foreignTable); + const handler = new GetViewFilterLinkRecordsHandler( + tableRepository, + recordRepository, + new NoopLogger() + ); + const query = GetViewFilterLinkRecordsQuery.create({ + tableId: tables.sourceTable.id().toString(), + viewId: tables.ownedViewId.toString(), + })._unsafeUnwrap(); + return handler.handle(context, query); +}; + +describe('GetViewFilterLinkRecordsQuery', () => { + it('creates nominal Table and View IDs', () => { + const tables = buildTables(); + const query = GetViewFilterLinkRecordsQuery.create({ + tableId: tables.sourceTable.id().toString(), + viewId: tables.ownedViewId.toString(), + })._unsafeUnwrap(); + + expect(query.tableId.equals(tables.sourceTable.id())).toBe(true); + expect(query.viewId.equals(tables.ownedViewId)).toBe(true); + }); + + it('rejects malformed identifiers', () => { + const result = GetViewFilterLinkRecordsQuery.create({ + tableId: 'invalid', + viewId: 'invalid', + }); + + expect(result._unsafeUnwrapErr().code).toBe('validation.invalid'); + }); +}); + +describe('GetViewFilterLinkRecordsHandler', () => { + it('loads Table aggregates and queries linked records in auto-number order', async () => { + const tables = buildTables(); + let capturedOptions: ITableRecordQueryOptions | undefined; + const find = vi.fn( + async (_context, table, spec, options) => { + expect(table.id().equals(tables.foreignTable.id())).toBe(true); + expect(spec).toBeInstanceOf(RecordByIdsSpec); + expect((spec as RecordByIdsSpec).recordIds().map((id) => id.toString())).toEqual([ + tables.firstRecordId.toString(), + tables.secondRecordId.toString(), + ]); + capturedOptions = options; + return ok({ + records: [ + { + id: tables.firstRecordId.toString(), + fields: { [tables.lookupFieldId.toString()]: 'Alpha' }, + version: 1, + }, + { + id: tables.secondRecordId.toString(), + fields: { [tables.lookupFieldId.toString()]: null }, + version: 1, + }, + ], + total: 2, + }); + } + ); + + const result = await execute(tables, createRecordRepository(find)); + + expect(result._unsafeUnwrap().groups).toEqual([ + { + tableId: tables.foreignTable.id().toString(), + records: [ + { id: tables.firstRecordId.toString(), title: 'Alpha' }, + { id: tables.secondRecordId.toString() }, + ], + }, + ]); + expect(capturedOptions).toMatchObject({ + mode: 'stored', + orderBy: [{ column: '__auto_number', direction: 'asc' }], + includeTotal: false, + }); + expect(capturedOptions?.projectionFieldIds?.[0]?.equals(tables.lookupFieldId)).toBe(true); + }); + + it('returns an empty result without querying records when the View has no filter references', async () => { + const tables = buildTables({ withFilter: false }); + const find = vi.fn(); + + const result = await execute(tables, createRecordRepository(find)); + + expect(result._unsafeUnwrap().groups).toEqual([]); + expect(find).not.toHaveBeenCalled(); + }); + + it('skips a foreign group whose Link lookup Field no longer exists', async () => { + const tables = buildTables({ missingLookupField: true }); + const find = vi.fn(); + + const result = await execute(tables, createRecordRepository(find)); + + expect(result._unsafeUnwrap().groups).toEqual([]); + expect(find).not.toHaveBeenCalled(); + }); + + it('maps an unknown Table/View association to view.not_found', async () => { + const tables = buildTables(); + const handler = new GetViewFilterLinkRecordsHandler( + new MemoryTableRepository(), + createRecordRepository(vi.fn()), + new NoopLogger() + ); + const query = GetViewFilterLinkRecordsQuery.create({ + tableId: tables.sourceTable.id().toString(), + viewId: tables.ownedViewId.toString(), + })._unsafeUnwrap(); + + const result = await handler.handle(context, query); + + expect(result._unsafeUnwrapErr()).toMatchObject({ + code: 'view.not_found', + message: `View not found: ${tables.ownedViewId.toString()}`, + }); + }); + + it('propagates linked-record repository failures', async () => { + const tables = buildTables(); + const repository = createRecordRepository(async () => + err(domainError.unexpected({ message: 'record query failed' })) + ); + + const result = await execute(tables, repository); + + expect(result._unsafeUnwrapErr().message).toBe('record query failed'); + }); +}); diff --git a/packages/v2/core/src/queries/GetViewFilterLinkRecordsHandler.ts b/packages/v2/core/src/queries/GetViewFilterLinkRecordsHandler.ts new file mode 100644 index 0000000000..46f032798b --- /dev/null +++ b/packages/v2/core/src/queries/GetViewFilterLinkRecordsHandler.ts @@ -0,0 +1,153 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError'; +import { RecordByIdsSpec } from '../domain/table/records/specs/RecordByIdsSpec'; +import { TableRecord } from '../domain/table/records/TableRecord'; +import { Table } from '../domain/table/Table'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import * as LoggerPort from '../ports/Logger'; +import * as TableRecordQueryRepositoryPort from '../ports/TableRecordQueryRepository'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { GetViewFilterLinkRecordsQuery } from './GetViewFilterLinkRecordsQuery'; +import { QueryHandler, type IQueryHandler } from './QueryHandler'; + +export type ViewFilterLinkRecord = { + readonly id: string; + readonly title?: string; +}; + +export type ViewFilterLinkRecordGroup = { + readonly tableId: string; + readonly records: ReadonlyArray; +}; + +export class GetViewFilterLinkRecordsResult { + private constructor(readonly groups: ReadonlyArray) {} + + static create(groups: ReadonlyArray): GetViewFilterLinkRecordsResult { + return new GetViewFilterLinkRecordsResult(groups); + } +} + +@QueryHandler(GetViewFilterLinkRecordsQuery) +@injectable() +export class GetViewFilterLinkRecordsHandler + implements IQueryHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableRecordQueryRepository) + private readonly tableRecordQueryRepository: TableRecordQueryRepositoryPort.ITableRecordQueryRepository, + @inject(v2CoreTokens.logger) + private readonly logger: LoggerPort.ILogger + ) {} + + async handle( + context: IExecutionContext, + query: GetViewFilterLinkRecordsQuery + ): Promise> { + const logger = this.logger + .scope('query', { + name: GetViewFilterLinkRecordsHandler.name, + }) + .child({ + tableId: query.tableId.toString(), + viewId: query.viewId.toString(), + }); + logger.debug('GetViewFilterLinkRecordsHandler.start', { + actorId: context.actorId.toString(), + }); + + const sourceSpecResult = Table.specs().byId(query.tableId).withViewId(query.viewId).build(); + if (sourceSpecResult.isErr()) return err(sourceSpecResult.error); + + const sourceTableResult = await this.tableRepository.findOne(context, sourceSpecResult.value); + if (sourceTableResult.isErr()) { + if (isNotFoundError(sourceTableResult.error)) { + return err( + domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${query.viewId.toString()}`, + }) + ); + } + return err(sourceTableResult.error); + } + + const referencesResult = sourceTableResult.value.viewFilterLinkReferences(query.viewId); + if (referencesResult.isErr()) return err(referencesResult.error); + + const groups: ViewFilterLinkRecordGroup[] = []; + for (const reference of referencesResult.value) { + const foreignSpecResult = Table.specs().byId(reference.foreignTableId).build(); + if (foreignSpecResult.isErr()) return err(foreignSpecResult.error); + + const foreignTableResult = await this.tableRepository.findOne( + context, + foreignSpecResult.value + ); + if (foreignTableResult.isErr()) return err(foreignTableResult.error); + + const lookupFieldResult = foreignTableResult.value.getField((field) => + field.id().equals(reference.lookupFieldId) + ); + if (lookupFieldResult.isErr()) continue; + + if (reference.recordIds.length === 0) { + groups.push({ + tableId: reference.foreignTableId.toString(), + records: [], + }); + continue; + } + + const recordsResult = await this.tableRecordQueryRepository.find( + context, + foreignTableResult.value, + RecordByIdsSpec.create(reference.recordIds), + { + mode: 'stored', + orderBy: [{ column: '__auto_number', direction: 'asc' }], + projectionFieldIds: [reference.lookupFieldId], + includeTotal: false, + } + ); + if (recordsResult.isErr()) return err(recordsResult.error); + + const records: ViewFilterLinkRecord[] = []; + for (const record of recordsResult.value.records) { + const domainRecordResult = TableRecord.fromRawFieldValues({ + id: record.id, + tableId: foreignTableResult.value.id(), + fields: record.fields, + }); + if (domainRecordResult.isErr()) return err(domainRecordResult.error); + + const titleResult = domainRecordResult.value.displayValue( + foreignTableResult.value, + reference.lookupFieldId + ); + if (titleResult.isErr()) return err(titleResult.error); + + records.push({ + id: record.id, + ...(titleResult.value ? { title: titleResult.value } : {}), + }); + } + + groups.push({ + tableId: reference.foreignTableId.toString(), + records, + }); + } + + logger.debug('GetViewFilterLinkRecordsHandler.success', { + groupCount: groups.length, + }); + return ok(GetViewFilterLinkRecordsResult.create(groups)); + } +} diff --git a/packages/v2/core/src/queries/GetViewFilterLinkRecordsQuery.ts b/packages/v2/core/src/queries/GetViewFilterLinkRecordsQuery.ts new file mode 100644 index 0000000000..f3959f1dbc --- /dev/null +++ b/packages/v2/core/src/queries/GetViewFilterLinkRecordsQuery.ts @@ -0,0 +1,38 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; + +export const getViewFilterLinkRecordsInputSchema = z.object({ + tableId: z.string(), + viewId: z.string(), +}); + +export type IGetViewFilterLinkRecordsQueryInput = z.input< + typeof getViewFilterLinkRecordsInputSchema +>; + +export class GetViewFilterLinkRecordsQuery { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId + ) {} + + static create(raw: unknown): Result { + const parsed = getViewFilterLinkRecordsInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ message: 'Invalid GetViewFilterLinkRecordsQuery input' }) + ); + } + + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map( + (viewId) => new GetViewFilterLinkRecordsQuery(tableId, viewId) + ) + ); + } +} diff --git a/packages/v2/core/src/queries/GetViewHandler.spec.ts b/packages/v2/core/src/queries/GetViewHandler.spec.ts new file mode 100644 index 0000000000..4e35e879f4 --- /dev/null +++ b/packages/v2/core/src/queries/GetViewHandler.spec.ts @@ -0,0 +1,224 @@ +import { err } from 'neverthrow'; +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError } from '../domain/shared/DomainError'; +import { FieldId } from '../domain/table/fields/FieldId'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { Table } from '../domain/table/Table'; +import { TableId } from '../domain/table/TableId'; +import { TableName } from '../domain/table/TableName'; +import { GridView } from '../domain/table/views/types/GridView'; +import { ViewAuditMetadata } from '../domain/table/views/ViewAuditMetadata'; +import { ViewColumnMeta } from '../domain/table/views/ViewColumnMeta'; +import { ViewId } from '../domain/table/views/ViewId'; +import { ViewName } from '../domain/table/views/ViewName'; +import { ViewProperties } from '../domain/table/views/ViewProperties'; +import { ViewQueryDefaults } from '../domain/table/views/ViewQueryDefaults'; +import { NoopLogger } from '../ports/defaults/NoopLogger'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import { MemoryTableRepository } from '../ports/memory/MemoryTableRepository'; +import type { ITableRepository } from '../ports/TableRepository'; +import { GetViewHandler } from './GetViewHandler'; +import { GetViewQuery } from './GetViewQuery'; + +const createContext = (): IExecutionContext => ({ + actorId: ActorId.create('system')._unsafeUnwrap(), +}); + +const fieldId = FieldId.create(`fld${'a'.repeat(16)}`)._unsafeUnwrap(); + +const buildTable = (options: { withAuditMetadata?: boolean } = {}) => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Views')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(fieldId) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .done(); + builder.view().defaultGrid().done(); + const baseTable = builder.build()._unsafeUnwrap(); + const view = GridView.create({ + id: ViewId.create(`viw${'a'.repeat(16)}`)._unsafeUnwrap(), + name: ViewName.create('All records')._unsafeUnwrap(), + properties: ViewProperties.create({ + description: 'Main view', + isLocked: true, + enableShare: true, + shareId: 'shr-test', + shareMeta: { allowCopy: true }, + })._unsafeUnwrap(), + })._unsafeUnwrap(); + view + .setColumnMeta( + ViewColumnMeta.rehydrate({ + [fieldId.toString()]: { order: 0, width: 220 }, + staleField: { order: 99 }, + })._unsafeUnwrap() + ) + ._unsafeUnwrap(); + view + .setQueryDefaults( + ViewQueryDefaults.rehydrate( + { + sort: [{ fieldId: 'staleField', order: 'desc' }], + manualSort: false, + group: [{ fieldId: 'staleField', order: 'asc' }], + }, + { + sourceFilter: { + conjunction: 'and', + filterSet: [{ fieldId: 'staleField', operator: 'is', value: 'Open' }], + }, + } + )._unsafeUnwrap() + ) + ._unsafeUnwrap(); + view.setOptions({ frozenColumnCount: 1 })._unsafeUnwrap(); + const table = Table.rehydrate({ + id: baseTable.id(), + baseId: baseTable.baseId(), + name: baseTable.name(), + fields: baseTable.getFields(), + views: [view], + primaryFieldId: baseTable.primaryFieldId(), + })._unsafeUnwrap(); + if (options.withAuditMetadata !== false) { + view + .setAuditMetadata( + ViewAuditMetadata.rehydrate({ + createdBy: 'system', + createdTime: '2026-07-27T00:00:00.000Z', + lastModifiedBy: 'editor', + lastModifiedTime: '2026-07-27T01:00:00.000Z', + })._unsafeUnwrap() + ) + ._unsafeUnwrap(); + } + return table; +}; + +describe('GetViewQuery', () => { + it('creates nominal Table and View IDs', () => { + const table = buildTable(); + const view = table.views()[0]; + const result = GetViewQuery.create({ + tableId: table.id().toString(), + viewId: view.id().toString(), + })._unsafeUnwrap(); + + expect(result.tableId.equals(table.id())).toBe(true); + expect(result.viewId.equals(view.id())).toBe(true); + }); + + it('rejects invalid IDs', () => { + const result = GetViewQuery.create({ tableId: 'invalid', viewId: 'invalid' }); + + expect(result._unsafeUnwrapErr().code).toBe('validation.invalid'); + }); +}); + +describe('GetViewHandler', () => { + it('loads a Table aggregate by Table and View specs and maps the selected child View', async () => { + const context = createContext(); + const table = buildTable(); + const view = table.views()[0]; + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(context, table); + const handler = new GetViewHandler(tableRepository, new NoopLogger()); + const query = GetViewQuery.create({ + tableId: table.id().toString(), + viewId: view.id().toString(), + })._unsafeUnwrap(); + + const result = await handler.handle(context, query); + + expect(result._unsafeUnwrap().view).toEqual({ + id: view.id().toString(), + name: 'All records', + type: 'grid', + description: 'Main view', + options: { frozenColumnCount: 1 }, + filter: { + conjunction: 'and', + filterSet: [{ fieldId: 'staleField', operator: 'is', value: 'Open' }], + }, + sort: { + sortObjs: [{ fieldId: 'staleField', order: 'desc' }], + manualSort: false, + }, + group: [{ fieldId: 'staleField', order: 'asc' }], + isLocked: true, + enableShare: true, + shareId: 'shr-test', + shareMeta: { allowCopy: true }, + createdBy: 'system', + createdTime: '2026-07-27T00:00:00.000Z', + lastModifiedBy: 'editor', + lastModifiedTime: '2026-07-27T01:00:00.000Z', + columnMeta: { [fieldId.toString()]: { order: 0, width: 220 } }, + }); + }); + + it('maps both an unknown Table and an unknown child View to view.not_found', async () => { + const context = createContext(); + const table = buildTable(); + const repository = new MemoryTableRepository(); + const handler = new GetViewHandler(repository, new NoopLogger()); + const missingTableQuery = GetViewQuery.create({ + tableId: table.id().toString(), + viewId: table.views()[0].id().toString(), + })._unsafeUnwrap(); + + const missingTable = await handler.handle(context, missingTableQuery); + await repository.insert(context, table); + const missingView = await handler.handle( + context, + GetViewQuery.create({ + tableId: table.id().toString(), + viewId: `viw${'b'.repeat(16)}`, + })._unsafeUnwrap() + ); + + expect(missingTable._unsafeUnwrapErr().code).toBe('view.not_found'); + expect(missingView._unsafeUnwrapErr().code).toBe('view.not_found'); + }); + + it('propagates unexpected Table repository failures', async () => { + const table = buildTable(); + const repository = { + findOne: async () => err(domainError.unexpected({ message: 'query failed' })), + } as unknown as ITableRepository; + const handler = new GetViewHandler(repository, new NoopLogger()); + const query = GetViewQuery.create({ + tableId: table.id().toString(), + viewId: table.views()[0].id().toString(), + })._unsafeUnwrap(); + + const result = await handler.handle(createContext(), query); + + expect(result._unsafeUnwrapErr().message).toBe('query failed'); + }); + + it('fails if audit metadata was not hydrated on the View child entity', async () => { + const context = createContext(); + const metadataFreeTable = buildTable({ withAuditMetadata: false }); + const repository = new MemoryTableRepository(); + await repository.insert(context, metadataFreeTable); + const handler = new GetViewHandler(repository, new NoopLogger()); + + const result = await handler.handle( + context, + GetViewQuery.create({ + tableId: metadataFreeTable.id().toString(), + viewId: metadataFreeTable.views()[0].id().toString(), + })._unsafeUnwrap() + ); + + expect(result._unsafeUnwrapErr().message).toBe('ViewAuditMetadata not set'); + }); +}); diff --git a/packages/v2/core/src/queries/GetViewHandler.ts b/packages/v2/core/src/queries/GetViewHandler.ts new file mode 100644 index 0000000000..e320445f8a --- /dev/null +++ b/packages/v2/core/src/queries/GetViewHandler.ts @@ -0,0 +1,77 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError'; +import { Table } from '../domain/table/Table'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import * as LoggerPort from '../ports/Logger'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { GetViewQuery } from './GetViewQuery'; +import { QueryHandler, type IQueryHandler } from './QueryHandler'; +import { projectViewForQuery, type ViewQueryResultView } from './ViewQueryProjection'; + +export type GetViewResultView = ViewQueryResultView; + +export class GetViewResult { + private constructor(readonly view: GetViewResultView) {} + + static create(view: GetViewResultView): GetViewResult { + return new GetViewResult(view); + } +} + +@QueryHandler(GetViewQuery) +@injectable() +export class GetViewHandler implements IQueryHandler { + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.logger) + private readonly logger: LoggerPort.ILogger + ) {} + + async handle( + context: IExecutionContext, + query: GetViewQuery + ): Promise> { + const logger = this.logger.scope('query', { name: GetViewHandler.name }).child({ + tableId: query.tableId.toString(), + viewId: query.viewId.toString(), + }); + logger.debug('GetViewHandler.start', { actorId: context.actorId.toString() }); + + const specResult = Table.specs().byId(query.tableId).withViewId(query.viewId).build(); + if (specResult.isErr()) return err(specResult.error); + + const tableResult = await this.tableRepository.findOne(context, specResult.value); + if (tableResult.isErr()) { + if (isNotFoundError(tableResult.error)) { + return err( + domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${query.viewId.toString()}`, + }) + ); + } + return err(tableResult.error); + } + + const viewResult = tableResult.value.getView(query.viewId); + if (viewResult.isErr()) { + return err( + domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${query.viewId.toString()}`, + }) + ); + } + + const resultView = projectViewForQuery(tableResult.value, viewResult.value); + if (resultView.isErr()) return err(resultView.error); + + logger.debug('GetViewHandler.success'); + return ok(GetViewResult.create(resultView.value)); + } +} diff --git a/packages/v2/core/src/queries/GetViewLinkRecordsHandler.spec.ts b/packages/v2/core/src/queries/GetViewLinkRecordsHandler.spec.ts new file mode 100644 index 0000000000..1ad0e1c14a --- /dev/null +++ b/packages/v2/core/src/queries/GetViewLinkRecordsHandler.spec.ts @@ -0,0 +1,166 @@ +import { err, ok } from 'neverthrow'; +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { FieldId } from '../domain/table/fields/FieldId'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { LinkFieldConfig } from '../domain/table/fields/types/LinkFieldConfig'; +import { Table } from '../domain/table/Table'; +import { TableId } from '../domain/table/TableId'; +import { TableName } from '../domain/table/TableName'; +import { ViewId } from '../domain/table/views/ViewId'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import { MemoryTableRepository } from '../ports/memory/MemoryTableRepository'; +import type { ITableRecordQueryRepository } from '../ports/TableRecordQueryRepository'; +import { GetViewLinkRecordsHandler } from './GetViewLinkRecordsHandler'; +import { GetViewLinkRecordsQuery } from './GetViewLinkRecordsQuery'; + +const context: IExecutionContext = { + actorId: ActorId.create('system')._unsafeUnwrap(), +}; +const id = (prefix: 'bse' | 'tbl' | 'fld' | 'viw', seed: string) => `${prefix}${seed.repeat(16)}`; + +const buildTable = () => { + const tableId = TableId.create(id('tbl', 't'))._unsafeUnwrap(); + const foreignTableId = TableId.create(id('tbl', 'f'))._unsafeUnwrap(); + const primaryFieldId = FieldId.create(id('fld', 'p'))._unsafeUnwrap(); + const lookupFieldId = FieldId.create(id('fld', 'l'))._unsafeUnwrap(); + const linkFieldId = FieldId.create(id('fld', 'k'))._unsafeUnwrap(); + const viewId = ViewId.create(id('viw', 'v'))._unsafeUnwrap(); + const targetBuilder = Table.builder() + .withBaseId(BaseId.create(id('bse', 'b'))._unsafeUnwrap()) + .withId(foreignTableId) + .withName(TableName.create('Target')._unsafeUnwrap()); + targetBuilder + .field() + .singleLineText() + .withId(lookupFieldId) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .primary() + .done(); + targetBuilder.view().defaultGrid().done(); + const targetTable = targetBuilder.build()._unsafeUnwrap(); + + const builder = Table.builder() + .withBaseId(BaseId.create(id('bse', 'b'))._unsafeUnwrap()) + .withId(tableId) + .withName(TableName.create('Host')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(primaryFieldId) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + builder + .field() + .link() + .withId(linkFieldId) + .withName(FieldName.create('Link')._unsafeUnwrap()) + .withConfig( + LinkFieldConfig.create({ + relationship: 'manyOne', + foreignTableId: foreignTableId.toString(), + lookupFieldId: lookupFieldId.toString(), + isOneWay: true, + })._unsafeUnwrap() + ) + .done(); + builder.view().plugin().withId(viewId).defaultName().done(); + return { + table: builder.build()._unsafeUnwrap(), + targetTable, + tableId, + viewId, + linkFieldId, + foreignTableId, + }; +}; + +const recordRepository: ITableRecordQueryRepository = { + find: async () => + ok({ + records: [{ id: `rec${'r'.repeat(16)}`, fields: { [id('fld', 'l')]: 'Alpha' }, version: 1 }], + total: 1, + }), + findOne: async () => err(new Error('not used') as never), + async *findStream() {}, +}; + +describe('GetViewLinkRecordsQuery', () => { + it('validates aggregate identifiers and request mode', () => { + expect( + GetViewLinkRecordsQuery.create({ + tableId: 'invalid', + viewId: 'invalid', + fieldId: 'invalid', + requestType: 'other', + })._unsafeUnwrapErr().code + ).toBe('validation.invalid'); + }); + + it('defaults take when only skip is provided and rejects invalid windows', () => { + const fixture = buildTable(); + const query = GetViewLinkRecordsQuery.create({ + tableId: fixture.tableId.toString(), + viewId: fixture.viewId.toString(), + fieldId: fixture.linkFieldId.toString(), + skip: 5, + })._unsafeUnwrap(); + + expect(query.pagination.limit().toNumber()).toBe(100); + expect(query.pagination.offset().toNumber()).toBe(5); + expect( + GetViewLinkRecordsQuery.create({ + tableId: fixture.tableId.toString(), + viewId: fixture.viewId.toString(), + fieldId: fixture.linkFieldId.toString(), + take: 1001, + })._unsafeUnwrapErr().code + ).toBe('validation.invalid'); + expect( + GetViewLinkRecordsQuery.create({ + tableId: fixture.tableId.toString(), + viewId: fixture.viewId.toString(), + fieldId: fixture.linkFieldId.toString(), + skip: -1, + })._unsafeUnwrapErr().code + ).toBe('validation.invalid'); + }); +}); + +describe('GetViewLinkRecordsHandler', () => { + it('loads the owning Table and returns its domain query plan', async () => { + const fixture = buildTable(); + const repository = new MemoryTableRepository(); + await repository.insert(context, fixture.table); + await repository.insert(context, fixture.targetTable); + const handler = new GetViewLinkRecordsHandler(repository, recordRepository); + const query = GetViewLinkRecordsQuery.create({ + tableId: fixture.tableId.toString(), + viewId: fixture.viewId.toString(), + fieldId: fixture.linkFieldId.toString(), + requestType: 'candidate', + })._unsafeUnwrap(); + + expect((await handler.handle(context, query))._unsafeUnwrap().records).toEqual([ + { id: `rec${'r'.repeat(16)}`, title: 'Alpha' }, + ]); + }); + + it('maps a missing partial aggregate to View not found', async () => { + const fixture = buildTable(); + const handler = new GetViewLinkRecordsHandler(new MemoryTableRepository(), recordRepository); + const query = GetViewLinkRecordsQuery.create({ + tableId: fixture.tableId.toString(), + viewId: fixture.viewId.toString(), + fieldId: fixture.linkFieldId.toString(), + })._unsafeUnwrap(); + + expect((await handler.handle(context, query))._unsafeUnwrapErr()).toMatchObject({ + code: 'view.not_found', + tags: ['not-found'], + }); + }); +}); diff --git a/packages/v2/core/src/queries/GetViewLinkRecordsHandler.ts b/packages/v2/core/src/queries/GetViewLinkRecordsHandler.ts new file mode 100644 index 0000000000..d9a0adcc65 --- /dev/null +++ b/packages/v2/core/src/queries/GetViewLinkRecordsHandler.ts @@ -0,0 +1,168 @@ +import { inject, injectable } from '@teable/v2-di'; +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { mergeOrderBy, resolveGroupByToOrderBy, resolveOrderBy } from '../commands/shared/orderBy'; +import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError'; +import { RecordConditionSpecBuilder } from '../domain/table/records/specs/RecordConditionSpecBuilder'; +import { TableRecord } from '../domain/table/records/TableRecord'; +import { Table } from '../domain/table/Table'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import * as TableRecordQueryRepositoryPort from '../ports/TableRecordQueryRepository'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { GetViewLinkRecordsQuery } from './GetViewLinkRecordsQuery'; +import { QueryHandler, type IQueryHandler } from './QueryHandler'; +import { + buildRecordConditionSpec, + replaceCurrentUserTagInFilter, + sanitizeRecordFilter, +} from './RecordFilterMapper'; +import { RecordSearch, resolveVisibleRowSearch } from './RecordSearch'; + +export type ViewLinkRecord = { + readonly id: string; + readonly title?: string; +}; + +export class GetViewLinkRecordsResult { + private constructor(readonly records: ReadonlyArray) {} + + static create(records: ReadonlyArray): GetViewLinkRecordsResult { + return new GetViewLinkRecordsResult(records); + } +} + +@QueryHandler(GetViewLinkRecordsQuery) +@injectable() +export class GetViewLinkRecordsHandler + implements IQueryHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.tableRecordQueryRepository) + private readonly tableRecordQueryRepository: TableRecordQueryRepositoryPort.ITableRecordQueryRepository + ) {} + + async handle( + context: IExecutionContext, + query: GetViewLinkRecordsQuery + ): Promise> { + return safeTry( + async function* (this: GetViewLinkRecordsHandler) { + const sourceSpec = yield* Table.specs() + .byId(query.tableId) + .withViewId(query.viewId) + .build(); + const sourceTable = yield* (await this.tableRepository.findOne(context, sourceSpec)).mapErr( + (error) => + isNotFoundError(error) + ? domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${query.viewId.toString()}`, + }) + : error + ); + const plan = yield* sourceTable.createViewLinkRecordsQueryPlan({ + viewId: query.viewId, + fieldId: query.fieldId, + requestType: query.requestType, + includeHiddenFields: query.includeHiddenFields, + }); + + const targetSpecBuilder = Table.specs().byId(plan.foreignTableId()); + const filterByViewId = plan.filterByViewId(); + if (filterByViewId) targetSpecBuilder.withViewId(filterByViewId); + const targetSpec = yield* targetSpecBuilder.build(); + const targetTable = yield* (await this.tableRepository.findOne(context, targetSpec)).mapErr( + (error) => { + if (!isNotFoundError(error)) return error; + return filterByViewId + ? domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${filterByViewId.toString()}`, + }) + : domainError.notFound({ + code: 'table.not_found', + message: `Table not found: ${plan.foreignTableId().toString()}`, + }); + } + ); + yield* plan.validateTargetTable(targetTable); + + const specBuilder = RecordConditionSpecBuilder.create(); + let hasConditionSpec = false; + const selectionSpec = yield* plan.selectionSpec(sourceTable, targetTable); + if (selectionSpec) { + specBuilder.addConditionSpec(selectionSpec); + hasConditionSpec = true; + } + const linkFilterSpec = yield* plan.linkFilterSpec(targetTable); + if (linkFilterSpec) { + specBuilder.addConditionSpec(linkFilterSpec); + hasConditionSpec = true; + } + + let orderBy: TableRecordQueryRepositoryPort.ITableRecordQueryOptions['orderBy']; + if (filterByViewId) { + const targetView = yield* targetTable.getView(filterByViewId); + const defaults = yield* targetView.queryDefaults(); + const defaultFilter = replaceCurrentUserTagInFilter( + targetTable, + defaults.filter(), + context.actorId.toString() + ); + const sanitizedDefaultFilter = yield* sanitizeRecordFilter(targetTable, defaultFilter); + if (sanitizedDefaultFilter) { + specBuilder.addConditionSpec( + yield* buildRecordConditionSpec(targetTable, sanitizedDefaultFilter) + ); + hasConditionSpec = true; + } + const effectiveSort = defaults.manualSort() ? [] : defaults.sort(); + orderBy = mergeOrderBy( + yield* resolveGroupByToOrderBy(defaults.group()), + yield* resolveOrderBy(effectiveSort), + filterByViewId.toString() + ); + } else { + orderBy = mergeOrderBy(undefined, undefined, undefined); + } + + const conditionSpec = hasConditionSpec ? yield* specBuilder.build() : undefined; + const search = resolveVisibleRowSearch( + RecordSearch.fromOptionalTuple( + query.search ? [query.search, plan.lookupFieldId().toString(), true] : undefined + ), + [plan.lookupFieldId()] + ); + const records = yield* await this.tableRecordQueryRepository.find( + context, + targetTable, + conditionSpec, + { + mode: 'stored', + pagination: query.pagination, + orderBy, + projectionFieldIds: [plan.lookupFieldId()], + search, + includeTotal: false, + } + ); + + const result: ViewLinkRecord[] = []; + for (const record of records.records) { + const domainRecord = yield* TableRecord.fromRawFieldValues({ + id: record.id, + tableId: targetTable.id(), + fields: record.fields, + }); + const title = yield* domainRecord.displayValue(targetTable, plan.lookupFieldId()); + result.push(title == null ? { id: record.id } : { id: record.id, title }); + } + return ok(GetViewLinkRecordsResult.create(result)); + }.bind(this) + ); + } +} diff --git a/packages/v2/core/src/queries/GetViewLinkRecordsQuery.ts b/packages/v2/core/src/queries/GetViewLinkRecordsQuery.ts new file mode 100644 index 0000000000..46627ca185 --- /dev/null +++ b/packages/v2/core/src/queries/GetViewLinkRecordsQuery.ts @@ -0,0 +1,68 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { OffsetPagination } from '../domain/shared/pagination/OffsetPagination'; +import { PageLimit } from '../domain/shared/pagination/PageLimit'; +import { PageOffset } from '../domain/shared/pagination/PageOffset'; +import { FieldId } from '../domain/table/fields/FieldId'; +import type { ViewLinkRecordsRequestType } from '../domain/table/methods/createViewLinkRecordsQueryPlan'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; + +const getViewLinkRecordsInputSchema = z.object({ + tableId: z.string(), + viewId: z.string(), + fieldId: z.string(), + requestType: z.enum(['candidate', 'selected']).optional(), + includeHiddenFields: z.boolean().optional(), + search: z.string().optional(), + take: z.coerce.number().int().positive().max(1000).optional(), + skip: z.coerce.number().int().nonnegative().optional(), +}); + +export class GetViewLinkRecordsQuery { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId, + readonly fieldId: FieldId, + readonly requestType: ViewLinkRecordsRequestType | undefined, + readonly includeHiddenFields: boolean, + readonly search: string | undefined, + readonly pagination: OffsetPagination + ) {} + + static create(raw: unknown): Result { + const parsed = getViewLinkRecordsInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid GetViewLinkRecordsQuery input', + details: { issues: parsed.error.issues }, + }) + ); + } + + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).andThen((viewId) => + FieldId.create(parsed.data.fieldId).andThen((fieldId) => + PageLimit.create(parsed.data.take ?? 100).andThen((limit) => + PageOffset.create(parsed.data.skip ?? 0).map( + (offset) => + new GetViewLinkRecordsQuery( + tableId, + viewId, + fieldId, + parsed.data.requestType, + parsed.data.includeHiddenFields ?? false, + parsed.data.search, + OffsetPagination.create(limit, offset) + ) + ) + ) + ) + ) + ); + } +} diff --git a/packages/v2/core/src/queries/GetViewPluginInstallHandler.ts b/packages/v2/core/src/queries/GetViewPluginInstallHandler.ts new file mode 100644 index 0000000000..ea3d716b06 --- /dev/null +++ b/packages/v2/core/src/queries/GetViewPluginInstallHandler.ts @@ -0,0 +1,72 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok, type Result } from 'neverthrow'; + +import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError'; +import { Table } from '../domain/table/Table'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import type { ViewPluginInstallationInfo } from '../ports/ViewPluginRepository'; +import * as ViewPluginRepositoryPort from '../ports/ViewPluginRepository'; +import { GetViewPluginInstallQuery } from './GetViewPluginInstallQuery'; +import { QueryHandler, type IQueryHandler } from './QueryHandler'; + +export class GetViewPluginInstallResult { + private constructor(readonly installation: ViewPluginInstallationInfo) {} + + static create(installation: ViewPluginInstallationInfo): GetViewPluginInstallResult { + return new GetViewPluginInstallResult(installation); + } +} + +@QueryHandler(GetViewPluginInstallQuery) +@injectable() +export class GetViewPluginInstallHandler + implements IQueryHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.viewPluginRepository) + private readonly viewPluginRepository: ViewPluginRepositoryPort.IViewPluginRepository + ) {} + + async handle( + context: IExecutionContext, + query: GetViewPluginInstallQuery + ): Promise> { + const specResult = Table.specs().byId(query.tableId).withViewId(query.viewId).build(); + if (specResult.isErr()) return err(specResult.error); + + const tableResult = await this.tableRepository.findOne(context, specResult.value); + if (tableResult.isErr()) { + if (isNotFoundError(tableResult.error)) { + return err( + domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${query.viewId.toString()}`, + }) + ); + } + return err(tableResult.error); + } + + const viewResult = tableResult.value.getView(query.viewId); + if (viewResult.isErr()) { + return err( + domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${query.viewId.toString()}`, + }) + ); + } + + const installationResult = await this.viewPluginRepository.getViewPluginInstallation( + context, + tableResult.value.baseId().toString(), + viewResult.value.id().toString() + ); + if (installationResult.isErr()) return err(installationResult.error); + return ok(GetViewPluginInstallResult.create(installationResult.value)); + } +} diff --git a/packages/v2/core/src/queries/GetViewPluginInstallQuery.ts b/packages/v2/core/src/queries/GetViewPluginInstallQuery.ts new file mode 100644 index 0000000000..0a7959dfd5 --- /dev/null +++ b/packages/v2/core/src/queries/GetViewPluginInstallQuery.ts @@ -0,0 +1,39 @@ +import { err, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; + +export const getViewPluginInstallInputSchema = z + .object({ + tableId: z.string(), + viewId: z.string(), + }) + .strict(); + +export type IGetViewPluginInstallQueryInput = z.input; + +export class GetViewPluginInstallQuery { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId + ) {} + + static create(raw: unknown): Result { + const parsed = getViewPluginInstallInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid GetViewPluginInstallQuery input', + details: z.formatError(parsed.error), + }) + ); + } + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map( + (viewId) => new GetViewPluginInstallQuery(tableId, viewId) + ) + ); + } +} diff --git a/packages/v2/core/src/queries/GetViewQuery.ts b/packages/v2/core/src/queries/GetViewQuery.ts new file mode 100644 index 0000000000..50c1e637af --- /dev/null +++ b/packages/v2/core/src/queries/GetViewQuery.ts @@ -0,0 +1,32 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; + +export const getViewInputSchema = z.object({ + tableId: z.string(), + viewId: z.string(), +}); + +export type IGetViewQueryInput = z.input; + +export class GetViewQuery { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId + ) {} + + static create(raw: unknown): Result { + const parsed = getViewInputSchema.safeParse(raw); + if (!parsed.success) { + return err(domainError.validation({ message: 'Invalid GetViewQuery input' })); + } + + return TableId.create(parsed.data.tableId).andThen((tableId) => + ViewId.create(parsed.data.viewId).map((viewId) => new GetViewQuery(tableId, viewId)) + ); + } +} diff --git a/packages/v2/core/src/queries/GetViewSelectionCopyHandler.spec.ts b/packages/v2/core/src/queries/GetViewSelectionCopyHandler.spec.ts new file mode 100644 index 0000000000..661f8d1b36 --- /dev/null +++ b/packages/v2/core/src/queries/GetViewSelectionCopyHandler.spec.ts @@ -0,0 +1,274 @@ +import { err, ok } from 'neverthrow'; +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { FieldId } from '../domain/table/fields/FieldId'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { NumberFormatting } from '../domain/table/fields/types/NumberFormatting'; +import { Table } from '../domain/table/Table'; +import { TableId } from '../domain/table/TableId'; +import { TableName } from '../domain/table/TableName'; +import { ViewId } from '../domain/table/views/ViewId'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import { MemoryTableRepository } from '../ports/memory/MemoryTableRepository'; +import type { + ITableRecordAggregationQueryRepository, + ITableRecordQueryOptions, +} from '../ports/TableRecordQueryRepository'; +import type { TableRecordReadModel } from '../ports/TableRecordReadModel'; +import { GetViewSelectionCopyHandler } from './GetViewSelectionCopyHandler'; +import { GetViewSelectionCopyQuery } from './GetViewSelectionCopyQuery'; + +const context: IExecutionContext = { + actorId: ActorId.create('system')._unsafeUnwrap(), +}; +const id = (prefix: 'bse' | 'tbl' | 'fld' | 'viw' | 'rec', seed: string) => + `${prefix}${seed.repeat(16)}`; +const hash = (value: string) => { + let result = 5381; + let index = value.length; + while (index) result = (result * 33) ^ value.charCodeAt(--index); + return result >>> 0; +}; + +const buildSharedTable = (shareMeta: { allowCopy?: boolean; includeRecords?: boolean }) => { + const tableId = TableId.create(id('tbl', 't'))._unsafeUnwrap(); + const nameFieldId = FieldId.create(id('fld', 'n'))._unsafeUnwrap(); + const amountFieldId = FieldId.create(id('fld', 'a'))._unsafeUnwrap(); + const viewId = ViewId.create(id('viw', 'v'))._unsafeUnwrap(); + const builder = Table.builder() + .withBaseId(BaseId.create(id('bse', 'b'))._unsafeUnwrap()) + .withId(tableId) + .withName(TableName.create('Copy')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(nameFieldId) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + builder + .field() + .number() + .withId(amountFieldId) + .withName(FieldName.create('Amount')._unsafeUnwrap()) + .withFormatting(NumberFormatting.create({ type: 'decimal', precision: 2 })._unsafeUnwrap()) + .done(); + builder.view().grid().withId(viewId).defaultName().done(); + const table = builder.build()._unsafeUnwrap(); + const withMeta = table.updateViewShareMeta(viewId, shareMeta)._unsafeUnwrap().updateResult!.table; + return { + table: withMeta.enableViewShare(viewId)._unsafeUnwrap().updateResult.table, + tableId, + viewId, + nameFieldId, + amountFieldId, + }; +}; + +const buildRecords = ( + nameFieldId: FieldId, + amountFieldId: FieldId +): ReadonlyArray => + ['Alpha', 'Beta', 'Gamma'].map((name, index) => ({ + id: id('rec', String(index + 1)), + fields: { + [nameFieldId.toString()]: name, + [amountFieldId.toString()]: index + 1.234, + }, + version: 1, + })); + +const createRecordRepository = (records: ReadonlyArray) => { + const calls: ITableRecordQueryOptions[] = []; + const specs: unknown[] = []; + const aggregationCalls: unknown[] = []; + const repository: ITableRecordAggregationQueryRepository = { + find: async (_context, _table, spec, options = {}) => { + calls.push(options); + specs.push(spec); + const offset = options.pagination?.offset().toNumber() ?? 0; + const limit = options.pagination?.limit().toNumber() ?? records.length; + return ok({ records: records.slice(offset, offset + limit), total: records.length }); + }, + findOne: async () => err(new Error('not used') as never), + async *findStream() {}, + aggregate: async (_context, _table, aggregation) => { + aggregationCalls.push(aggregation); + const fieldId = aggregation.fields[0]!.fieldId; + return ok( + records.map((record) => ({ + fieldId, + statisticFunc: 'count' as const, + value: 1, + groupValues: [record.fields[fieldId.toString()]], + })) + ); + }, + }; + return { repository, calls, specs, aggregationCalls }; +}; + +const createHandler = async ( + fixture: ReturnType, + records = buildRecords(fixture.nameFieldId, fixture.amountFieldId) +) => { + const tables = new MemoryTableRepository(); + await tables.insert(context, fixture.table); + const recordRepository = createRecordRepository(records); + return { + handler: new GetViewSelectionCopyHandler(tables, recordRepository.repository), + calls: recordRepository.calls, + specs: recordRepository.specs, + aggregationCalls: recordRepository.aggregationCalls, + }; +}; + +describe('GetViewSelectionCopyQuery', () => { + it('validates identifiers, ranges and max copy cells', () => { + expect( + GetViewSelectionCopyQuery.create( + { + tableId: 'invalid', + viewId: 'invalid', + ranges: [[0, 0]], + }, + { maxCopyCells: 10 } + )._unsafeUnwrapErr().code + ).toBe('validation.invalid'); + const fixture = buildSharedTable({ allowCopy: true, includeRecords: true }); + expect( + GetViewSelectionCopyQuery.create( + { + tableId: fixture.tableId.toString(), + viewId: fixture.viewId.toString(), + ranges: [[-1, 0]], + }, + { maxCopyCells: 10 } + )._unsafeUnwrapErr().code + ).toBe('validation.invalid'); + }); +}); + +describe('GetViewSelectionCopyHandler', () => { + it('formats a rectangular selection and uses stored projected record reads', async () => { + const fixture = buildSharedTable({ allowCopy: true, includeRecords: true }); + const { handler, calls } = await createHandler(fixture); + const query = GetViewSelectionCopyQuery.create( + { + tableId: fixture.tableId.toString(), + viewId: fixture.viewId.toString(), + ranges: [ + [0, 0], + [1, 1], + ], + }, + { maxCopyCells: 10 } + )._unsafeUnwrap(); + const result = (await handler.handle(context, query))._unsafeUnwrap(); + + expect(result.content).toBe('Alpha\t1.23\nBeta\t2.23'); + expect(result.fields.map((field) => field.id().toString())).toEqual([ + fixture.nameFieldId.toString(), + fixture.amountFieldId.toString(), + ]); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ mode: 'stored', includeTotal: false }); + expect(calls[0]?.projectionFieldIds?.map((fieldId) => fieldId.toString())).toEqual([ + fixture.nameFieldId.toString(), + fixture.amountFieldId.toString(), + ]); + }); + + it('preserves disjoint and overlapping row windows in request order', async () => { + const fixture = buildSharedTable({ allowCopy: true, includeRecords: true }); + const { handler, calls } = await createHandler(fixture); + const query = GetViewSelectionCopyQuery.create( + { + tableId: fixture.tableId.toString(), + viewId: fixture.viewId.toString(), + type: 'rows', + ranges: [ + [1, 2], + [0, 1], + ], + projection: [fixture.nameFieldId.toString()], + }, + { maxCopyCells: 10 } + )._unsafeUnwrap(); + + expect((await handler.handle(context, query))._unsafeUnwrap().content).toBe( + 'Beta\nGamma\nAlpha\nBeta' + ); + expect(calls.map((call) => call.pagination?.offset().toNumber())).toEqual([1, 0]); + }); + + it('uses the total row count to reject oversized column selections', async () => { + const fixture = buildSharedTable({ allowCopy: true, includeRecords: true }); + const { handler, calls } = await createHandler(fixture); + const query = GetViewSelectionCopyQuery.create( + { + tableId: fixture.tableId.toString(), + viewId: fixture.viewId.toString(), + type: 'columns', + ranges: [[0, 1]], + }, + { maxCopyCells: 5 } + )._unsafeUnwrap(); + const result = await handler.handle(context, query); + + expect(result._unsafeUnwrapErr()).toMatchObject({ + code: 'view_selection_copy.exceed_max_copy_cells', + tags: ['validation'], + }); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ mode: 'stored', includeTotal: true }); + }); + + it('does not query records when the share excludes them', async () => { + const fixture = buildSharedTable({ allowCopy: true, includeRecords: false }); + const { handler, calls } = await createHandler(fixture); + const query = GetViewSelectionCopyQuery.create( + { + tableId: fixture.tableId.toString(), + viewId: fixture.viewId.toString(), + ranges: [ + [0, 0], + [0, 1], + ], + }, + { maxCopyCells: 10 } + )._unsafeUnwrap(); + const result = (await handler.handle(context, query))._unsafeUnwrap(); + + expect(result.content).toBe(''); + expect(result.fields).toHaveLength(1); + expect(calls).toHaveLength(0); + }); + + it('uses the existing Table Record aggregation capability for collapsed groups', async () => { + const fixture = buildSharedTable({ allowCopy: true, includeRecords: true }); + const { handler, specs, aggregationCalls } = await createHandler(fixture); + const alphaGroupId = String(hash(`${fixture.nameFieldId.toString()}_Alpha`)); + const query = GetViewSelectionCopyQuery.create( + { + tableId: fixture.tableId.toString(), + viewId: fixture.viewId.toString(), + type: 'rows', + ranges: [[0, 0]], + projection: [fixture.nameFieldId.toString()], + groupBy: [{ fieldId: fixture.nameFieldId.toString(), order: 'asc' }], + collapsedGroupIds: [alphaGroupId], + }, + { maxCopyCells: 10 } + )._unsafeUnwrap(); + + const result = await handler.handle(context, query); + expect(result.isOk(), result.isErr() ? JSON.stringify(result.error) : undefined).toBe(true); + + expect(aggregationCalls).toHaveLength(1); + expect(specs).toHaveLength(1); + expect(specs[0]).toBeDefined(); + }); +}); diff --git a/packages/v2/core/src/queries/GetViewSelectionCopyHandler.ts b/packages/v2/core/src/queries/GetViewSelectionCopyHandler.ts new file mode 100644 index 0000000000..56343f39f9 --- /dev/null +++ b/packages/v2/core/src/queries/GetViewSelectionCopyHandler.ts @@ -0,0 +1,337 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { mergeOrderBy, resolveGroupByToOrderBy, resolveOrderBy } from '../commands/shared/orderBy'; +import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError'; +import { OffsetPagination } from '../domain/shared/pagination/OffsetPagination'; +import { PageLimit } from '../domain/shared/pagination/PageLimit'; +import { PageOffset } from '../domain/shared/pagination/PageOffset'; +import type { ISpecification } from '../domain/shared/specification/ISpecification'; +import type { Field } from '../domain/table/fields/Field'; +import { + FieldClipboardValueVisitor, + stringifyClipboardRows, +} from '../domain/table/fields/visitors/FieldClipboardValueVisitor'; +import type { ViewSelectionCopyPlan } from '../domain/table/methods/createViewSelectionCopyPlan'; +import type { ITableRecordConditionSpecVisitor } from '../domain/table/records/specs/ITableRecordConditionSpecVisitor'; +import type { TableRecord } from '../domain/table/records/TableRecord'; +import { Table } from '../domain/table/Table'; +import type { ViewQueryGroupItem } from '../domain/table/views/ViewQueryDefaults'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import { ITableRecordAggregationQueryRepository } from '../ports/TableRecordQueryRepository'; +import type { TableRecordOrderBy } from '../ports/TableRecordQueryRepository'; +import type { TableRecordReadModel } from '../ports/TableRecordReadModel'; +import { ITableRepository } from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { GetViewSelectionCopyQuery, type ViewSelectionCopySort } from './GetViewSelectionCopyQuery'; +import { QueryHandler, type IQueryHandler } from './QueryHandler'; +import { + isRecordFilterCondition, + isRecordFilterGroup, + isRecordFilterNot, + type RecordFilter, + type RecordFilterNode, +} from './RecordFilterDto'; +import { + buildSanitizedRecordConditionSpec, + replaceCurrentUserTagInFilter, + sanitizeRecordFilter, +} from './RecordFilterMapper'; +import { RecordSearch, resolveVisibleRowSearch, type RecordQuerySearch } from './RecordSearch'; + +const mergeFilters = ( + defaultFilter: RecordFilter | null | undefined, + requestFilter: RecordFilter | undefined +): RecordFilter | undefined => { + if (!defaultFilter) return requestFilter; + if (!requestFilter) return defaultFilter; + return { conjunction: 'and', items: [defaultFilter, requestFilter] }; +}; + +const mergeSort = ( + defaultSort: ReadonlyArray | undefined, + manualSort: boolean | undefined, + requestSort: ReadonlyArray | undefined +): ReadonlyArray | undefined => { + if (manualSort && !requestSort?.length) return []; + if (!defaultSort?.length) return requestSort; + if (!requestSort?.length) return defaultSort; + const merged = new Map(requestSort.map((item) => [item.fieldId, item])); + for (const item of defaultSort) { + if (!merged.has(item.fieldId)) merged.set(item.fieldId, item); + } + return [...merged.values()]; +}; + +const collectFilterFieldIds = (filter: RecordFilter | undefined, fieldIds: Set): void => { + const visit = (node: RecordFilterNode): void => { + if (isRecordFilterCondition(node)) { + fieldIds.add(node.fieldId); + if ( + node.value && + typeof node.value === 'object' && + 'fieldId' in node.value && + typeof node.value.fieldId === 'string' + ) { + fieldIds.add(node.value.fieldId); + } + return; + } + if (isRecordFilterGroup(node)) { + node.items.forEach(visit); + return; + } + if (isRecordFilterNot(node)) visit(node.not); + }; + if (filter) visit(filter); +}; + +export class GetViewSelectionCopyResult { + private constructor( + readonly content: string, + readonly fields: ReadonlyArray, + readonly primaryFieldId: ReturnType + ) {} + + static create( + content: string, + fields: ReadonlyArray, + primaryFieldId: ReturnType + ): GetViewSelectionCopyResult { + return new GetViewSelectionCopyResult(content, fields, primaryFieldId); + } +} + +@QueryHandler(GetViewSelectionCopyQuery) +@injectable() +export class GetViewSelectionCopyHandler + implements IQueryHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: ITableRepository, + @inject(v2CoreTokens.tableRecordQueryRepository) + private readonly tableRecordQueryRepository: ITableRecordAggregationQueryRepository + ) {} + + async handle( + context: IExecutionContext, + query: GetViewSelectionCopyQuery + ): Promise> { + return safeTry( + async function* (this: GetViewSelectionCopyHandler) { + const tableSpec = yield* Table.specs().byId(query.tableId).withViewId(query.viewId).build(); + const table = yield* (await this.tableRepository.findOne(context, tableSpec)).mapErr( + (error) => + isNotFoundError(error) + ? domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${query.viewId.toString()}`, + }) + : error + ); + const queryFieldIds = new Set(); + collectFilterFieldIds(query.filter, queryFieldIds); + query.orderBy?.forEach((item) => queryFieldIds.add(item.fieldId)); + query.groupBy?.forEach((item) => queryFieldIds.add(item.fieldId)); + query.search?.[1] + ?.split(',') + .map((fieldId) => fieldId.trim()) + .filter((fieldId) => fieldId.startsWith('fld')) + .forEach((fieldId) => queryFieldIds.add(fieldId)); + const plan = yield* table.createViewSelectionCopyPlan({ + viewId: query.viewId, + canCopyAsEditor: query.canCopyAsEditor, + ranges: query.ranges, + type: query.type, + projection: query.projection, + queryFieldIds: [...queryFieldIds], + }); + if (!plan.recordsIncluded || !plan.fields.length) { + return ok(GetViewSelectionCopyResult.create('', plan.fields, table.primaryFieldId())); + } + + const view = yield* table.getView(query.viewId); + const defaults = yield* view.queryDefaults(); + const defaultFilter = replaceCurrentUserTagInFilter( + table, + defaults.filter(), + context.actorId.toString() + ); + const requestFilter = replaceCurrentUserTagInFilter( + table, + query.filter, + context.actorId.toString() + ); + let effectiveFilter = mergeFilters( + yield* sanitizeRecordFilter(table, defaultFilter), + yield* sanitizeRecordFilter(table, requestFilter) + ); + const effectiveGroup: ReadonlyArray | undefined = query.groupBy?.length + ? query.groupBy.slice(0, 3) + : defaults.group(); + const effectiveSort = mergeSort(defaults.sort(), defaults.manualSort(), query.orderBy); + const orderBy = mergeOrderBy( + yield* resolveGroupByToOrderBy(effectiveGroup), + yield* resolveOrderBy(effectiveSort), + query.viewId.toString() + ); + const search = resolveVisibleRowSearch( + RecordSearch.fromOptionalTuple(query.search), + plan.searchFieldIds + ); + let conditionSpec = yield* buildSanitizedRecordConditionSpec(table, effectiveFilter); + if (query.collapsedGroupIds?.length && effectiveGroup?.length) { + const aggregation = yield* table.createRecordAggregation({ + viewId: query.viewId.toString(), + fields: [ + { + fieldId: table.primaryFieldId().toString(), + statisticFunc: 'count', + }, + ], + groupBy: effectiveGroup, + includeHiddenFields: false, + }); + const aggregationValues = yield* await this.tableRecordQueryRepository.aggregate( + context, + table, + aggregation, + conditionSpec, + { + maxGroupPoints: query.maxGroupPoints, + search, + } + ); + const groupedRows = aggregationValues + .filter((value) => value.groupValues?.length === effectiveGroup.length) + .map((value) => ({ groupValues: value.groupValues! })); + const collapsedFilter = yield* table.createCollapsedGroupExclusionFilter( + effectiveGroup, + groupedRows, + new Set(query.collapsedGroupIds) + ); + effectiveFilter = mergeFilters(effectiveFilter, collapsedFilter); + conditionSpec = yield* buildSanitizedRecordConditionSpec(table, effectiveFilter); + } + + const records = + plan.type === 'columns' + ? yield* await this.readColumnSelection( + context, + table, + plan, + conditionSpec, + orderBy, + search, + query.maxCopyCells + ) + : yield* await this.readBoundedSelection( + context, + table, + plan, + conditionSpec, + orderBy, + search, + query.maxCopyCells + ); + const rows: string[][] = []; + for (const record of records) { + const row: string[] = []; + for (const field of plan.fields) { + row.push( + yield* field.accept( + new FieldClipboardValueVisitor(record.fields[field.id().toString()]) + ) + ); + } + rows.push(row); + } + return ok( + GetViewSelectionCopyResult.create( + stringifyClipboardRows(rows), + plan.fields, + table.primaryFieldId() + ) + ); + }.bind(this) + ); + } + + private async readColumnSelection( + context: IExecutionContext, + table: Table, + plan: ViewSelectionCopyPlan, + conditionSpec: ISpecification | undefined, + orderBy: ReadonlyArray | undefined, + search: RecordQuerySearch | undefined, + maxCopyCells: number + ): Promise, DomainError>> { + const maxRows = Math.floor(maxCopyCells / plan.fields.length); + const pagination = OffsetPagination.create( + PageLimit.create(maxRows + 1)._unsafeUnwrap(), + PageOffset.create(0)._unsafeUnwrap() + ); + const result = await this.tableRecordQueryRepository.find(context, table, conditionSpec, { + pagination, + orderBy, + search, + mode: 'stored', + projectionFieldIds: plan.fields.map((field) => field.id()), + includeTotal: true, + }); + return result.andThen((value) => + plan.requestedCellCount(value.total).andThen((cellCount) => + cellCount > maxCopyCells + ? err( + domainError.validation({ + code: 'view_selection_copy.exceed_max_copy_cells', + message: `Exceed max copy cells ${maxCopyCells}`, + }) + ) + : ok(value.records) + ) + ); + } + + private async readBoundedSelection( + context: IExecutionContext, + table: Table, + plan: ViewSelectionCopyPlan, + conditionSpec: ISpecification | undefined, + orderBy: ReadonlyArray | undefined, + search: RecordQuerySearch | undefined, + maxCopyCells: number + ): Promise, DomainError>> { + const cellCount = plan.requestedCellCount(); + if (cellCount.isErr()) return err(cellCount.error); + if (cellCount.value > maxCopyCells) { + return err( + domainError.validation({ + code: 'view_selection_copy.exceed_max_copy_cells', + message: `Exceed max copy cells ${maxCopyCells}`, + }) + ); + } + + const records: TableRecordReadModel[] = []; + for (const window of plan.recordWindows) { + const pagination = OffsetPagination.create( + PageLimit.create(window.limit!)._unsafeUnwrap(), + PageOffset.create(window.offset)._unsafeUnwrap() + ); + const result = await this.tableRecordQueryRepository.find(context, table, conditionSpec, { + pagination, + orderBy, + search, + mode: 'stored', + projectionFieldIds: plan.fields.map((field) => field.id()), + includeTotal: false, + }); + if (result.isErr()) return err(result.error); + records.push(...result.value.records); + } + return ok(records); + } +} diff --git a/packages/v2/core/src/queries/GetViewSelectionCopyQuery.ts b/packages/v2/core/src/queries/GetViewSelectionCopyQuery.ts new file mode 100644 index 0000000000..d6bf58a20e --- /dev/null +++ b/packages/v2/core/src/queries/GetViewSelectionCopyQuery.ts @@ -0,0 +1,97 @@ +import { err, ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { FieldId } from '../domain/table/fields/FieldId'; +import type { ViewSelectionCopyRangeType } from '../domain/table/methods/createViewSelectionCopyPlan'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; +import { recordFilterSchema, type RecordFilter } from './RecordFilterDto'; +import { recordSearchInputSchema, type RecordSearchInput } from './RecordSearch'; + +const rangeSchema = z.tuple([z.number().int().nonnegative(), z.number().int().nonnegative()]); +const sortSchema = z.object({ + fieldId: z.string().min(1), + order: z.enum(['asc', 'desc']), +}); +const groupSchema = sortSchema; + +const getViewSelectionCopyInputSchema = z.object({ + tableId: z.string(), + viewId: z.string(), + canCopyAsEditor: z.boolean().optional(), + ranges: z.array(rangeSchema).min(1), + type: z.enum(['columns', 'rows']).optional(), + projection: z.array(z.string().min(1)).optional(), + filter: recordFilterSchema.optional(), + orderBy: z.array(sortSchema).optional(), + groupBy: z.array(groupSchema).optional(), + search: recordSearchInputSchema, + collapsedGroupIds: z.array(z.string()).optional(), +}); + +export type ViewSelectionCopySort = z.infer; +export type ViewSelectionCopyGroup = z.infer; + +export class GetViewSelectionCopyQuery { + private constructor( + readonly tableId: TableId, + readonly viewId: ViewId, + readonly canCopyAsEditor: boolean, + readonly ranges: ReadonlyArray, + readonly type: ViewSelectionCopyRangeType, + readonly projection: ReadonlyArray | undefined, + readonly filter: RecordFilter | undefined, + readonly orderBy: ReadonlyArray | undefined, + readonly groupBy: ReadonlyArray | undefined, + readonly search: RecordSearchInput | undefined, + readonly collapsedGroupIds: ReadonlyArray | undefined, + readonly maxCopyCells: number, + readonly maxGroupPoints: number + ) {} + + static create( + raw: unknown, + options: { readonly maxCopyCells: number; readonly maxGroupPoints?: number } + ): Result { + const parsed = getViewSelectionCopyInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid GetViewSelectionCopyQuery input', + details: { issues: parsed.error.issues }, + }) + ); + } + if (!Number.isInteger(options.maxCopyCells) || options.maxCopyCells <= 0) { + return err(domainError.validation({ message: 'Invalid maxCopyCells' })); + } + + return safeTry(function* () { + const tableId = yield* TableId.create(parsed.data.tableId); + const viewId = yield* ViewId.create(parsed.data.viewId); + const projection: FieldId[] = []; + for (const fieldId of parsed.data.projection ?? []) { + projection.push(yield* FieldId.create(fieldId)); + } + return ok( + new GetViewSelectionCopyQuery( + tableId, + viewId, + parsed.data.canCopyAsEditor ?? false, + parsed.data.ranges, + parsed.data.type, + projection.length ? projection : undefined, + parsed.data.filter, + parsed.data.orderBy, + parsed.data.groupBy, + parsed.data.search, + parsed.data.collapsedGroupIds, + options.maxCopyCells, + Math.max(1, Math.floor(options.maxGroupPoints ?? 5_000)) + ) + ); + }); + } +} diff --git a/packages/v2/core/src/queries/GetViewSnapshotsHandler.spec.ts b/packages/v2/core/src/queries/GetViewSnapshotsHandler.spec.ts new file mode 100644 index 0000000000..67add5f6d7 --- /dev/null +++ b/packages/v2/core/src/queries/GetViewSnapshotsHandler.spec.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { FieldId } from '../domain/table/fields/FieldId'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { Table } from '../domain/table/Table'; +import { TableId } from '../domain/table/TableId'; +import { TableName } from '../domain/table/TableName'; +import { GridView } from '../domain/table/views/types/GridView'; +import { ViewAuditMetadata } from '../domain/table/views/ViewAuditMetadata'; +import { ViewColumnMeta } from '../domain/table/views/ViewColumnMeta'; +import { ViewId } from '../domain/table/views/ViewId'; +import { ViewName } from '../domain/table/views/ViewName'; +import { ViewQueryDefaults } from '../domain/table/views/ViewQueryDefaults'; +import { ViewVersion } from '../domain/table/views/ViewVersion'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import { MemoryTableRepository } from '../ports/memory/MemoryTableRepository'; +import { GetViewSnapshotsHandler } from './GetViewSnapshotsHandler'; +import { GetViewSnapshotsQuery } from './GetViewSnapshotsQuery'; + +const context: IExecutionContext = { + actorId: ActorId.create('actor')._unsafeUnwrap(), +}; +const fieldId = FieldId.create(`fld${'a'.repeat(16)}`)._unsafeUnwrap(); + +const createView = (suffix: string, version?: number) => { + const view = GridView.create({ + id: ViewId.create(`viw${suffix.repeat(16)}`)._unsafeUnwrap(), + name: ViewName.create(`View ${suffix}`)._unsafeUnwrap(), + })._unsafeUnwrap(); + view + .setColumnMeta( + ViewColumnMeta.rehydrate({ + [fieldId.toString()]: { order: 0, width: 200 }, + [`fld${'z'.repeat(16)}`]: { order: 1, width: 300 }, + })._unsafeUnwrap() + ) + ._unsafeUnwrap(); + view.setQueryDefaults(ViewQueryDefaults.rehydrate({})._unsafeUnwrap())._unsafeUnwrap(); + view + .setAuditMetadata( + ViewAuditMetadata.rehydrate({ + createdBy: 'actor', + createdTime: '2026-07-29T00:00:00.000Z', + })._unsafeUnwrap() + ) + ._unsafeUnwrap(); + if (version !== undefined) { + view.setVersion(ViewVersion.rehydrate(version)._unsafeUnwrap())._unsafeUnwrap(); + } + return view; +}; + +const buildTable = (secondVersion: number | null = 9) => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Snapshot table')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(fieldId) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .done(); + builder.view().defaultGrid().done(); + const baseTable = builder.build()._unsafeUnwrap(); + + return Table.rehydrate({ + id: baseTable.id(), + baseId: baseTable.baseId(), + name: baseTable.name(), + fields: baseTable.getFields(), + views: [createView('a', 3), createView('b', secondVersion ?? undefined)], + primaryFieldId: baseTable.primaryFieldId(), + })._unsafeUnwrap(); +}; + +describe('GetViewSnapshotsQuery', () => { + it('validates every nominal ID and preserves an empty request', () => { + const table = buildTable(); + const empty = GetViewSnapshotsQuery.create({ + tableId: table.id().toString(), + viewIds: [], + })._unsafeUnwrap(); + + expect(empty.viewIds).toEqual([]); + expect( + GetViewSnapshotsQuery.create({ + tableId: table.id().toString(), + viewIds: ['invalid'], + })._unsafeUnwrapErr().code + ).toBe('validation.invalid'); + }); +}); + +describe('GetViewSnapshotsHandler', () => { + it('returns requested View children in request order with versions and sanitized metadata', async () => { + const table = buildTable(); + const repository = new MemoryTableRepository(); + await repository.insert(context, table); + const handler = new GetViewSnapshotsHandler(repository); + const requestedIds = [table.views()[1].id().toString(), table.views()[0].id().toString()]; + + const result = await handler.handle( + context, + GetViewSnapshotsQuery.create({ + tableId: table.id().toString(), + viewIds: requestedIds, + })._unsafeUnwrap() + ); + + expect(result._unsafeUnwrap().snapshots).toMatchObject([ + { + id: requestedIds[0], + version: 9, + view: { + id: requestedIds[0], + version: 9, + columnMeta: { [fieldId.toString()]: { order: 0, width: 200 } }, + }, + }, + { + id: requestedIds[1], + version: 3, + view: { + id: requestedIds[1], + version: 3, + columnMeta: { [fieldId.toString()]: { order: 0, width: 200 } }, + }, + }, + ]); + }); + + it('returns an empty result without loading a Table', async () => { + const handler = new GetViewSnapshotsHandler(new MemoryTableRepository()); + + const result = await handler.handle( + context, + GetViewSnapshotsQuery.create({ + tableId: `tbl${'a'.repeat(16)}`, + viewIds: [], + })._unsafeUnwrap() + ); + + expect(result._unsafeUnwrap().snapshots).toEqual([]); + }); + + it('rejects missing and duplicate child IDs with the legacy not-found semantics', async () => { + const table = buildTable(); + const repository = new MemoryTableRepository(); + await repository.insert(context, table); + const handler = new GetViewSnapshotsHandler(repository); + const existingId = table.views()[0].id().toString(); + + const missing = await handler.handle( + context, + GetViewSnapshotsQuery.create({ + tableId: table.id().toString(), + viewIds: [existingId, `viw${'z'.repeat(16)}`], + })._unsafeUnwrap() + ); + const duplicate = await handler.handle( + context, + GetViewSnapshotsQuery.create({ + tableId: table.id().toString(), + viewIds: [existingId, existingId], + })._unsafeUnwrap() + ); + + expect(missing._unsafeUnwrapErr()).toMatchObject({ + code: 'view.not_found', + message: `View not found: viw${'z'.repeat(16)}`, + }); + expect(duplicate._unsafeUnwrapErr()).toMatchObject({ + code: 'view.not_found', + message: `Duplicate view ids requested: ${existingId}`, + }); + }); + + it('fails when persisted View version metadata is unavailable', async () => { + const table = buildTable(null); + const repository = new MemoryTableRepository(); + await repository.insert(context, table); + const handler = new GetViewSnapshotsHandler(repository); + + const result = await handler.handle( + context, + GetViewSnapshotsQuery.create({ + tableId: table.id().toString(), + viewIds: [table.views()[1].id().toString()], + })._unsafeUnwrap() + ); + + expect(result._unsafeUnwrapErr().message).toBe('ViewVersion not set'); + }); +}); diff --git a/packages/v2/core/src/queries/GetViewSnapshotsHandler.ts b/packages/v2/core/src/queries/GetViewSnapshotsHandler.ts new file mode 100644 index 0000000000..d531fb0364 --- /dev/null +++ b/packages/v2/core/src/queries/GetViewSnapshotsHandler.ts @@ -0,0 +1,103 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok, type Result } from 'neverthrow'; + +import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError'; +import { Table } from '../domain/table/Table'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { GetViewSnapshotsQuery } from './GetViewSnapshotsQuery'; +import { QueryHandler, type IQueryHandler } from './QueryHandler'; +import { projectViewForQuery, type ViewQueryResultView } from './ViewQueryProjection'; + +export type ViewSnapshotQueryItem = { + readonly id: string; + readonly version: number; + readonly view: ViewQueryResultView; +}; + +export class GetViewSnapshotsResult { + private constructor(readonly snapshots: ReadonlyArray) {} + + static create(snapshots: ReadonlyArray): GetViewSnapshotsResult { + return new GetViewSnapshotsResult(snapshots); + } +} + +@QueryHandler(GetViewSnapshotsQuery) +@injectable() +export class GetViewSnapshotsHandler + implements IQueryHandler +{ + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository + ) {} + + async handle( + context: IExecutionContext, + query: GetViewSnapshotsQuery + ): Promise> { + if (query.viewIds.length === 0) { + return ok(GetViewSnapshotsResult.create([])); + } + + const specResult = Table.specs().byId(query.tableId).withViewIds(query.viewIds).build(); + if (specResult.isErr()) return err(specResult.error); + + const tableResult = await this.tableRepository.findOne(context, specResult.value); + if (tableResult.isErr()) { + if (isNotFoundError(tableResult.error)) { + return err(domainError.notFound({ code: 'table.not_found', message: 'Table not found' })); + } + return err(tableResult.error); + } + + const viewById = new Map(tableResult.value.views().map((view) => [view.id().toString(), view])); + const requestedIds = query.viewIds.map((viewId) => viewId.toString()); + const missingIds = requestedIds.filter((viewId) => !viewById.has(viewId)); + if (missingIds.length > 0) { + return err( + domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${missingIds.join(', ')}`, + }) + ); + } + if (new Set(requestedIds).size !== requestedIds.length) { + const duplicateIds = requestedIds.filter((viewId, index) => + requestedIds.includes(viewId, index + 1) + ); + return err( + domainError.notFound({ + code: 'view.not_found', + message: `Duplicate view ids requested: ${[...new Set(duplicateIds)].join(', ')}`, + }) + ); + } + + const snapshots: ViewSnapshotQueryItem[] = []; + for (const viewId of requestedIds) { + const view = viewById.get(viewId); + if (!view) { + return err( + domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${viewId}`, + }) + ); + } + const versionResult = view.version(); + if (versionResult.isErr()) return err(versionResult.error); + const projectionResult = projectViewForQuery(tableResult.value, view); + if (projectionResult.isErr()) return err(projectionResult.error); + snapshots.push({ + id: viewId, + version: versionResult.value.toNumber(), + view: projectionResult.value, + }); + } + + return ok(GetViewSnapshotsResult.create(snapshots)); + } +} diff --git a/packages/v2/core/src/queries/GetViewSnapshotsQuery.ts b/packages/v2/core/src/queries/GetViewSnapshotsQuery.ts new file mode 100644 index 0000000000..592a573ed6 --- /dev/null +++ b/packages/v2/core/src/queries/GetViewSnapshotsQuery.ts @@ -0,0 +1,45 @@ +import { err, type Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; + +export const getViewSnapshotsInputSchema = z + .object({ + tableId: z.string(), + viewIds: z.array(z.string()), + }) + .strict(); + +export type IGetViewSnapshotsQueryInput = z.input; + +export class GetViewSnapshotsQuery { + private constructor( + readonly tableId: TableId, + readonly viewIds: ReadonlyArray + ) {} + + static create(raw: unknown): Result { + const parsed = getViewSnapshotsInputSchema.safeParse(raw); + if (!parsed.success) { + return err( + domainError.validation({ + message: 'Invalid GetViewSnapshotsQuery input', + details: z.formatError(parsed.error), + }) + ); + } + + const viewIds: ViewId[] = []; + for (const rawViewId of parsed.data.viewIds) { + const viewIdResult = ViewId.create(rawViewId); + if (viewIdResult.isErr()) return err(viewIdResult.error); + viewIds.push(viewIdResult.value); + } + + return TableId.create(parsed.data.tableId).map( + (tableId) => new GetViewSnapshotsQuery(tableId, viewIds) + ); + } +} diff --git a/packages/v2/core/src/queries/ListBasesHandler.spec.ts b/packages/v2/core/src/queries/ListBasesHandler.spec.ts index 311f4aba60..2e674a7b35 100644 --- a/packages/v2/core/src/queries/ListBasesHandler.spec.ts +++ b/packages/v2/core/src/queries/ListBasesHandler.spec.ts @@ -26,6 +26,7 @@ describe('ListBasesHandler', () => { it('returns paginated results', async () => { const base = buildBase('a'); const repository: IBaseRepository = { + delete: async () => ok(undefined), insert: async () => ok(base), findOne: async () => ok(base), find: async (_context, pagination) => { @@ -46,6 +47,7 @@ describe('ListBasesHandler', () => { it('returns repository errors', async () => { const repository: IBaseRepository = { + delete: async () => ok(undefined), insert: async () => err(domainError.unexpected({ message: 'nope' })), findOne: async () => err(domainError.unexpected({ message: 'nope' })), find: async () => err(domainError.unexpected({ message: 'failed' })), @@ -61,6 +63,7 @@ describe('ListBasesHandler', () => { it('uses pagination from query', async () => { const base = buildBase('b'); const repository: IBaseRepository = { + delete: async () => ok(undefined), insert: async () => ok(base), findOne: async () => ok(base), find: async (_context, queryPagination) => { diff --git a/packages/v2/core/src/queries/ListFieldsHandler.spec.ts b/packages/v2/core/src/queries/ListFieldsHandler.spec.ts new file mode 100644 index 0000000000..aa35f3a99c --- /dev/null +++ b/packages/v2/core/src/queries/ListFieldsHandler.spec.ts @@ -0,0 +1,147 @@ +import { err } from 'neverthrow'; +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError } from '../domain/shared/DomainError'; +import { FieldId } from '../domain/table/fields/FieldId'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { Table } from '../domain/table/Table'; +import { TableId } from '../domain/table/TableId'; +import { TableName } from '../domain/table/TableName'; +import { ViewId } from '../domain/table/views/ViewId'; +import { ViewName } from '../domain/table/views/ViewName'; +import { NoopLogger } from '../ports/defaults/NoopLogger'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import { MemoryTableRepository } from '../ports/memory/MemoryTableRepository'; +import type { ITableRepository } from '../ports/TableRepository'; +import { ListFieldsHandler } from './ListFieldsHandler'; +import { ListFieldsQuery } from './ListFieldsQuery'; + +const context: IExecutionContext = { + actorId: ActorId.create('system')._unsafeUnwrap(), +}; + +const buildTable = () => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Fields')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(FieldId.create(`fld${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .done(); + builder + .field() + .number() + .withId(FieldId.create(`fld${'b'.repeat(16)}`)._unsafeUnwrap()) + .withName(FieldName.create('Amount')._unsafeUnwrap()) + .done(); + builder + .view() + .grid() + .withId(ViewId.create(`viw${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(ViewName.create('Grid')._unsafeUnwrap()) + .done(); + return builder.build()._unsafeUnwrap(); +}; + +describe('ListFieldsQuery', () => { + it('creates Table and optional View IDs', () => { + const table = buildTable(); + const query = ListFieldsQuery.create({ + tableId: table.id().toString(), + viewId: table.views()[0].id().toString(), + })._unsafeUnwrap(); + + expect(query.tableId.equals(table.id())).toBe(true); + expect(query.viewId?.equals(table.views()[0].id())).toBe(true); + }); + + it.each([ + undefined, + {}, + { tableId: 'invalid' }, + { tableId: `tbl${'a'.repeat(16)}`, viewId: 'invalid' }, + ])('rejects invalid input: %j', (input) => { + expect(ListFieldsQuery.create(input)._unsafeUnwrapErr().code).toBe('validation.invalid'); + }); +}); + +describe('ListFieldsHandler', () => { + it('returns Field children, primary identity, and only the requested View context', async () => { + const table = buildTable(); + const repository = new MemoryTableRepository(); + await repository.insert(context, table); + const handler = new ListFieldsHandler(repository, new NoopLogger()); + + const result = await handler.handle( + context, + ListFieldsQuery.create({ + tableId: table.id().toString(), + viewId: table.views()[0].id().toString(), + })._unsafeUnwrap() + ); + + const value = result._unsafeUnwrap(); + expect(value.fields.map((field) => field.id().toString())).toEqual([ + `fld${'a'.repeat(16)}`, + `fld${'b'.repeat(16)}`, + ]); + expect(value.primaryFieldId.toString()).toBe(`fld${'a'.repeat(16)}`); + expect(value.view?.id().toString()).toBe(`viw${'a'.repeat(16)}`); + }); + + it('returns fields without loading a requested View context', async () => { + const table = buildTable(); + const repository = new MemoryTableRepository(); + await repository.insert(context, table); + const handler = new ListFieldsHandler(repository, new NoopLogger()); + + const result = await handler.handle( + context, + ListFieldsQuery.create({ tableId: table.id().toString() })._unsafeUnwrap() + ); + + expect(result._unsafeUnwrap().view).toBeUndefined(); + }); + + it('distinguishes a missing requested View from a missing Table', async () => { + const table = buildTable(); + const repository = new MemoryTableRepository(); + await repository.insert(context, table); + const handler = new ListFieldsHandler(repository, new NoopLogger()); + + const missingView = await handler.handle( + context, + ListFieldsQuery.create({ + tableId: table.id().toString(), + viewId: `viw${'z'.repeat(16)}`, + })._unsafeUnwrap() + ); + const missingTable = await new ListFieldsHandler( + new MemoryTableRepository(), + new NoopLogger() + ).handle(context, ListFieldsQuery.create({ tableId: table.id().toString() })._unsafeUnwrap()); + + expect(missingView._unsafeUnwrapErr().code).toBe('view.not_found'); + expect(missingTable._unsafeUnwrapErr().code).toBe('table.not_found'); + }); + + it('propagates unexpected repository errors', async () => { + const table = buildTable(); + const repository = { + findOne: async () => err(domainError.unexpected({ message: 'query failed' })), + } as unknown as ITableRepository; + const handler = new ListFieldsHandler(repository, new NoopLogger()); + + const result = await handler.handle( + context, + ListFieldsQuery.create({ tableId: table.id().toString() })._unsafeUnwrap() + ); + + expect(result._unsafeUnwrapErr().message).toBe('query failed'); + }); +}); diff --git a/packages/v2/core/src/queries/ListFieldsHandler.ts b/packages/v2/core/src/queries/ListFieldsHandler.ts new file mode 100644 index 0000000000..93ab900334 --- /dev/null +++ b/packages/v2/core/src/queries/ListFieldsHandler.ts @@ -0,0 +1,85 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError'; +import type { Field } from '../domain/table/fields/Field'; +import type { FieldId } from '../domain/table/fields/FieldId'; +import { Table } from '../domain/table/Table'; +import type { View } from '../domain/table/views/View'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import * as LoggerPort from '../ports/Logger'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { ListFieldsQuery } from './ListFieldsQuery'; +import { QueryHandler, type IQueryHandler } from './QueryHandler'; + +export class ListFieldsResult { + private constructor( + readonly fields: ReadonlyArray, + readonly primaryFieldId: FieldId, + readonly view?: View + ) {} + + static create( + fields: ReadonlyArray, + primaryFieldId: FieldId, + view?: View + ): ListFieldsResult { + return new ListFieldsResult(fields, primaryFieldId, view); + } +} + +@QueryHandler(ListFieldsQuery) +@injectable() +export class ListFieldsHandler implements IQueryHandler { + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.logger) + private readonly logger: LoggerPort.ILogger + ) {} + + async handle( + context: IExecutionContext, + query: ListFieldsQuery + ): Promise> { + const logger = this.logger.scope('query', { name: ListFieldsHandler.name }).child({ + tableId: query.tableId.toString(), + }); + logger.debug('ListFieldsHandler.start', { actorId: context.actorId.toString() }); + + const specBuilder = Table.specs().byId(query.tableId); + if (query.viewId) specBuilder.withViewId(query.viewId); + const specResult = specBuilder.build(); + if (specResult.isErr()) return err(specResult.error); + + const tableResult = await this.tableRepository.findOne(context, specResult.value); + if (tableResult.isErr()) { + if (isNotFoundError(tableResult.error)) { + if (query.viewId) { + return err( + domainError.notFound({ + code: 'view.not_found', + message: `View not found: ${query.viewId.toString()}`, + }) + ); + } + return err(domainError.notFound({ code: 'table.not_found', message: 'Table not found' })); + } + return err(tableResult.error); + } + + const fields = tableResult.value.getFields(); + const viewResult = query.viewId ? tableResult.value.getView(query.viewId) : undefined; + if (viewResult?.isErr()) return err(viewResult.error); + logger.debug('ListFieldsHandler.success', { count: fields.length }); + return ok( + ListFieldsResult.create( + fields, + tableResult.value.primaryFieldId(), + viewResult?.isOk() ? viewResult.value : undefined + ) + ); + } +} diff --git a/packages/v2/core/src/queries/ListFieldsQuery.ts b/packages/v2/core/src/queries/ListFieldsQuery.ts new file mode 100644 index 0000000000..48608942a9 --- /dev/null +++ b/packages/v2/core/src/queries/ListFieldsQuery.ts @@ -0,0 +1,35 @@ +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; + +export const listFieldsInputSchema = z.object({ + tableId: z.string(), + viewId: z.string().optional(), +}); + +export type IListFieldsQueryInput = z.input; + +export class ListFieldsQuery { + private constructor( + readonly tableId: TableId, + readonly viewId?: ViewId + ) {} + + static create(raw: unknown): Result { + const parsed = listFieldsInputSchema.safeParse(raw); + if (!parsed.success) { + return err(domainError.validation({ message: 'Invalid ListFieldsQuery input' })); + } + + return TableId.create(parsed.data.tableId).andThen((tableId) => { + if (parsed.data.viewId == null) return ok(new ListFieldsQuery(tableId)); + return ViewId.create(parsed.data.viewId).map( + (viewId) => new ListFieldsQuery(tableId, viewId) + ); + }); + } +} diff --git a/packages/v2/core/src/queries/ListTableRecordsHandler.spec.ts b/packages/v2/core/src/queries/ListTableRecordsHandler.spec.ts index b4385bb1fd..cb55ea38ae 100644 --- a/packages/v2/core/src/queries/ListTableRecordsHandler.spec.ts +++ b/packages/v2/core/src/queries/ListTableRecordsHandler.spec.ts @@ -13,6 +13,7 @@ import { SelectOption } from '../domain/table/fields/types/SelectOption'; import { RecordId } from '../domain/table/records/RecordId'; import type { UserConditionSpec } from '../domain/table/records/specs/UserConditionSpec'; import { NoopRecordConditionSpecVisitor } from '../domain/table/records/specs/visitors/NoopRecordConditionSpecVisitor'; +import { TableRecord } from '../domain/table/records/TableRecord'; import { TableUpdateViewColumnMetaSpec } from '../domain/table/specs/TableUpdateViewColumnMetaSpec'; import { TableUpdateViewQueryDefaultsSpec } from '../domain/table/specs/TableUpdateViewQueryDefaultsSpec'; import { Table } from '../domain/table/Table'; @@ -36,6 +37,7 @@ import type { TableRecordReadModel } from '../ports/TableRecordReadModel'; import type { ITableRepository } from '../ports/TableRepository'; import { ListTableRecordsHandler } from './ListTableRecordsHandler'; import { ListTableRecordsQuery } from './ListTableRecordsQuery'; +import { buildRecordConditionSpec } from './RecordFilterMapper'; const createContext = (): IExecutionContext => { const actorId = ActorId.create('system')._unsafeUnwrap(); @@ -312,18 +314,21 @@ describe('ListTableRecordsHandler', () => { expect(visitor.userValues).toEqual([context.actorId.toString()]); }); - it('drops filters for disabled fields from the permission read source', async () => { + it('rejects client filters on statically unreadable fields before querying records', async () => { const table = buildTable(); const tableRepository = new MemoryTableRepository(); await tableRepository.insert(createContext(), table); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); const statusField = table .getField((field) => field.name().toString() === 'Status') ._unsafeUnwrap(); - const captured: { spec?: unknown } = {}; + let findCalled = false; const recordQueryRepo: ITableRecordQueryRepository = { - find: async (_context, _table, spec) => { - captured.spec = spec; + find: async () => { + findCalled = true; return ok({ records: [], total: 0 }); }, findOne: async () => err(domainError.notFound({ message: 'Not found' })), @@ -340,21 +345,77 @@ describe('ListTableRecordsHandler', () => { }, }, { - recordReadQuerySource: { - tableName: 'base.table', - cteName: 'read_source', - cteSql: 'select * from base.table', - enabledFieldIds: [], + queryScope: { + readableFieldIds: new Set([titleField.id().toString()]), }, } ); const handler = new ListTableRecordsHandler(tableRepository, recordQueryRepo, new NoopLogger()); const result = await handler.handle(createContext(), queryResult._unsafeUnwrap()); - expect(result.isOk()).toBe(true); - expect(captured.spec).toBeUndefined(); + expect(result._unsafeUnwrapErr().code).toBe('record.filter.unreadable_field'); + expect(findCalled).toBe(false); }); + it.each([ + { + kind: 'sort', + query: (tableId: string, statusId: string) => ({ + tableId, + sort: [{ fieldId: statusId, order: 'asc' as const }], + }), + code: 'record.sort.unreadable_field', + }, + { + kind: 'group', + query: (tableId: string, statusId: string) => ({ + tableId, + groupBy: [statusId], + }), + code: 'record.group.unreadable_field', + }, + ])( + 'rejects client $kind on statically unreadable fields before querying records', + async ({ query, code }) => { + const table = buildTable(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(createContext(), table); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const statusField = table + .getField((field) => field.name().toString() === 'Status') + ._unsafeUnwrap(); + + let findCalled = false; + const recordQueryRepo: ITableRecordQueryRepository = { + find: async () => { + findCalled = true; + return ok({ records: [], total: 0 }); + }, + findOne: async () => err(domainError.notFound({ message: 'Not found' })), + async *findStream() {}, + }; + const queryResult = ListTableRecordsQuery.create( + query(table.id().toString(), statusField.id().toString()), + { + queryScope: { + readableFieldIds: new Set([titleField.id().toString()]), + }, + } + )._unsafeUnwrap(); + + const result = await new ListTableRecordsHandler( + tableRepository, + recordQueryRepo, + new NoopLogger() + ).handle(createContext(), queryResult); + + expect(result._unsafeUnwrapErr().code).toBe(code); + expect(findCalled).toBe(false); + } + ); + it('maps missing tables to not found', async () => { const tableRepo: ITableRepository = { insert: async (_context, _table) => err(domainError.notFound({ message: 'Not found' })), @@ -735,17 +796,13 @@ describe('ListTableRecordsHandler', () => { ]); }); - it('drops disabled sort fields and limits visible-row search to enabled fields', async () => { + it('limits visible-row search and sorting to readable fields', async () => { const table = buildTable(); const tableRepository = new MemoryTableRepository(); await tableRepository.insert(createContext(), table); const titleField = table .getField((field) => field.name().toString() === 'Title') ._unsafeUnwrap(); - const statusField = table - .getField((field) => field.name().toString() === 'Status') - ._unsafeUnwrap(); - const captured: { options?: unknown } = {}; const recordQueryRepo: ITableRecordQueryRepository = { find: async (_context, _table, _spec, options) => { @@ -759,10 +816,7 @@ describe('ListTableRecordsHandler', () => { const queryResult = ListTableRecordsQuery.create( { tableId: table.id().toString(), - sort: [ - { fieldId: statusField.id().toString(), order: 'asc' }, - { fieldId: titleField.id().toString(), order: 'desc' }, - ], + sort: [{ fieldId: titleField.id().toString(), order: 'desc' }], search: ['hello', '', true], fieldKeyType: FieldKeyType.Id, }, @@ -1028,7 +1082,7 @@ describe('ListTableRecordsHandler', () => { ]); }); - it('keeps enabled conditions when disabled fields are sanitized out of filter groups', async () => { + it('rejects mixed client filter groups when any nested condition is unreadable', async () => { const table = buildTable(); const tableRepository = new MemoryTableRepository(); await tableRepository.insert(createContext(), table); @@ -1039,10 +1093,10 @@ describe('ListTableRecordsHandler', () => { .getField((field) => field.name().toString() === 'Status') ._unsafeUnwrap(); - const captured: { spec?: unknown } = {}; + let findCalled = false; const recordQueryRepo: ITableRecordQueryRepository = { - find: async (_context, _table, spec) => { - captured.spec = spec; + find: async () => { + findCalled = true; return ok({ records: [], total: 0 }); }, findOne: async () => err(domainError.notFound({ message: 'Not found' })), @@ -1082,8 +1136,8 @@ describe('ListTableRecordsHandler', () => { const handler = new ListTableRecordsHandler(tableRepository, recordQueryRepo, new NoopLogger()); const result = await handler.handle(createContext(), queryResult._unsafeUnwrap()); - expect(result.isOk()).toBe(true); - expect(captured.spec).toBeDefined(); + expect(result._unsafeUnwrapErr().code).toBe('record.filter.unreadable_field'); + expect(findCalled).toBe(false); }); it('resolves dbFieldName keys for filter and sort, then transforms response keys back to dbFieldName', async () => { @@ -1395,4 +1449,921 @@ describe('ListTableRecordsHandler', () => { }); expect(fallbacks).toHaveLength(1); }); + + it('defaults projection to queryScope.readableFieldIds when client projection is omitted', async () => { + const table = buildTable(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(createContext(), table); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const titleId = titleField.id().toString(); + + const captured: { options?: unknown } = {}; + const recordQueryRepo: ITableRecordQueryRepository = { + find: async (_context, _table, _spec, options) => { + captured.options = options; + return ok({ records: [], total: 0 }); + }, + findOne: async () => err(domainError.notFound({ message: 'Not found' })), + async *findStream() {}, + }; + + const query = ListTableRecordsQuery.create( + { tableId: table.id().toString() }, + { queryScope: { readableFieldIds: new Set([titleId]) } } + )._unsafeUnwrap(); + const handler = new ListTableRecordsHandler(tableRepository, recordQueryRepo, new NoopLogger()); + const result = await handler.handle(createContext(), query); + + expect(result.isOk()).toBe(true); + expect( + ( + captured.options as { + projectionFieldIds?: ReadonlyArray<{ toString(): string }>; + } + ).projectionFieldIds?.map((id) => id.toString()) + ).toEqual([titleId]); + }); + + it('defaults projection to empty array for empty readableFieldIds allow-list', async () => { + const table = buildTable(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(createContext(), table); + + const captured: { options?: unknown } = {}; + const recordQueryRepo: ITableRecordQueryRepository = { + find: async (_context, _table, _spec, options) => { + captured.options = options; + return ok({ records: [], total: 0 }); + }, + findOne: async () => err(domainError.notFound({ message: 'Not found' })), + async *findStream() {}, + }; + + const query = ListTableRecordsQuery.create( + { tableId: table.id().toString() }, + { queryScope: { readableFieldIds: new Set() } } + )._unsafeUnwrap(); + const handler = new ListTableRecordsHandler(tableRepository, recordQueryRepo, new NoopLogger()); + const result = await handler.handle(createContext(), query); + + expect(result.isOk()).toBe(true); + expect( + ( + captured.options as { + projectionFieldIds?: ReadonlyArray<{ toString(): string }>; + } + ).projectionFieldIds?.map((id) => id.toString()) + ).toEqual([]); + }); + + it('ANDs queryScope.recordSpec into the query plan', async () => { + const table = buildTable(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(createContext(), table); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + + const recordSpec = { + isSatisfiedBy: () => true, + accept: (visitor: { visit?: (spec: unknown) => void }) => { + visitor.visit?.(recordSpec); + return ok(undefined); + }, + } as never; + + const captured: { spec?: { accept?: (v: unknown) => unknown } } = {}; + const recordQueryRepo: ITableRecordQueryRepository = { + find: async (_context, _table, spec) => { + captured.spec = spec as { accept?: (v: unknown) => unknown }; + return ok({ records: [], total: 0 }); + }, + findOne: async () => err(domainError.notFound({ message: 'Not found' })), + async *findStream() {}, + }; + + const query = ListTableRecordsQuery.create( + { + tableId: table.id().toString(), + filter: { + fieldId: titleField.id().toString(), + operator: 'contains', + value: 'x', + }, + }, + { queryScope: { recordSpec } } + )._unsafeUnwrap(); + const handler = new ListTableRecordsHandler(tableRepository, recordQueryRepo, new NoopLogger()); + const result = await handler.handle(createContext(), query); + + expect(result.isOk()).toBe(true); + expect(captured.spec).toBeDefined(); + }); + + it('expands projection with mask dependency fields and evaluates visibleWhen', async () => { + const table = buildTable(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(createContext(), table); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const statusField = table + .getField((field) => field.name().toString() === 'Status') + ._unsafeUnwrap(); + const titleId = titleField.id().toString(); + const statusId = statusField.id().toString(); + + const openRecordId = createRecordId('o').toString(); + const closedRecordId = createRecordId('c').toString(); + const visibilityByRecordId = new Map([ + [openRecordId, true], + [closedRecordId, false], + ]); + const visibleWhen = { + field: () => statusField, + isSatisfiedBy: (record: { id: () => { toString: () => string } }) => + visibilityByRecordId.get(record.id().toString()) ?? false, + mutate: () => ok(undefined as never), + accept: () => ok(undefined), + } as never; + + const captured: { projection?: string[] } = {}; + const recordQueryRepo: ITableRecordQueryRepository = { + find: async (_context, _table, _spec, options) => { + captured.projection = ( + options as { projectionFieldIds?: ReadonlyArray<{ toString(): string }> } + ).projectionFieldIds?.map((id) => id.toString()); + return ok({ + records: [ + { + id: openRecordId, + version: 1, + fields: { [titleId]: 'Visible', [statusId]: 'Open' }, + }, + { + id: closedRecordId, + version: 1, + fields: { [titleId]: 'Hidden', [statusId]: 'Closed' }, + }, + ], + total: 2, + }); + }, + findOne: async () => err(domainError.notFound({ message: 'Not found' })), + async *findStream() {}, + }; + + const query = ListTableRecordsQuery.create( + { + tableId: table.id().toString(), + fieldKeyType: FieldKeyType.Id, + projection: [titleId], + }, + { + queryScope: { + // Status not statically returned, but required for mask evaluation + readableFieldIds: new Set([titleId]), + fieldMasks: [{ fieldId: titleId, visibleWhen }], + }, + } + )._unsafeUnwrap(); + const handler = new ListTableRecordsHandler(tableRepository, recordQueryRepo, new NoopLogger()); + const result = await handler.handle(createContext(), query); + + expect(result.isOk()).toBe(true); + // Mask dep Status must be projected for evaluation + expect(captured.projection).toContain(statusId); + const records = result._unsafeUnwrap().records; + expect(records).toHaveLength(2); + // Status never returned to client (only Title in projection) + expect(records[0]?.fields).toEqual({ [titleId]: 'Visible' }); + expect(records[1]?.fields).not.toHaveProperty(titleId); + expect(records[0]?.fields).not.toHaveProperty(statusId); + expect(records[1]?.fields).not.toHaveProperty(statusId); + }); + + it('keeps all table fields when masks expand an allow-all read without projection', async () => { + const builder = Table.builder() + .withBaseId(createBaseId('m')) + .withName(TableName.create('Masked Records')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Title')._unsafeUnwrap()).done(); + builder + .field() + .singleSelect() + .withName(FieldName.create('Status')._unsafeUnwrap()) + .withOptions([selectOption('Open')]) + .done(); + builder.field().singleLineText().withName(FieldName.create('Notes')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + const table = builder.build()._unsafeUnwrap(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(createContext(), table); + const titleId = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap() + .id() + .toString(); + const statusField = table + .getField((field) => field.name().toString() === 'Status') + ._unsafeUnwrap(); + const statusId = statusField.id().toString(); + const notesId = table + .getField((field) => field.name().toString() === 'Notes') + ._unsafeUnwrap() + .id() + .toString(); + + const recordId = createRecordId('m').toString(); + const visibleWhen = { + field: () => statusField, + isSatisfiedBy: () => true, + mutate: () => ok(undefined as never), + accept: () => ok(undefined), + } as never; + + const captured: { projection?: string[] } = {}; + const recordQueryRepo: ITableRecordQueryRepository = { + find: async (_context, _table, _spec, options) => { + captured.projection = ( + options as { projectionFieldIds?: ReadonlyArray<{ toString(): string }> } + ).projectionFieldIds?.map((id) => id.toString()); + return ok({ + records: [ + { + id: recordId, + version: 1, + fields: { [titleId]: 'Visible', [statusId]: 'Open', [notesId]: 'Keep me' }, + }, + ], + total: 1, + }); + }, + findOne: async () => err(domainError.notFound({ message: 'Not found' })), + async *findStream() {}, + }; + + const query = ListTableRecordsQuery.create( + { tableId: table.id().toString(), fieldKeyType: FieldKeyType.Id }, + { + // readableFieldIds undefined (allow-all) + no projection: masks must + // not collapse the projection to dependency fields only. + queryScope: { fieldMasks: [{ fieldId: titleId, visibleWhen }] }, + } + )._unsafeUnwrap(); + const handler = new ListTableRecordsHandler(tableRepository, recordQueryRepo, new NoopLogger()); + const result = await handler.handle(createContext(), query); + + expect(result.isOk()).toBe(true); + expect(captured.projection).toEqual(expect.arrayContaining([titleId, statusId, notesId])); + const records = result._unsafeUnwrap().records; + expect(records).toHaveLength(1); + expect(records[0]?.fields).toEqual({ + [titleId]: 'Visible', + [statusId]: 'Open', + [notesId]: 'Keep me', + }); + }); + + it('rejects right-hand field references to statically unreadable fields', async () => { + const table = buildTable(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(createContext(), table); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const statusField = table + .getField((field) => field.name().toString() === 'Status') + ._unsafeUnwrap(); + + let findCalled = false; + const recordQueryRepo: ITableRecordQueryRepository = { + find: async () => { + findCalled = true; + return ok({ records: [], total: 0 }); + }, + findOne: async () => err(domainError.notFound({ message: 'Not found' })), + async *findStream() {}, + }; + + const query = ListTableRecordsQuery.create( + { + tableId: table.id().toString(), + filter: { + fieldId: titleField.id().toString(), + operator: 'is', + value: { type: 'field', fieldId: statusField.id().toString() }, + }, + }, + { + queryScope: { + // Title allowed, Status denied — right-hand field references must fail closed. + readableFieldIds: new Set([titleField.id().toString()]), + }, + } + )._unsafeUnwrap(); + const handler = new ListTableRecordsHandler(tableRepository, recordQueryRepo, new NoopLogger()); + const result = await handler.handle(createContext(), query); + + expect(result._unsafeUnwrapErr().code).toBe('record.filter.unreadable_field'); + expect(findCalled).toBe(false); + }); + + it('rejects filter RHS field-references to conditionally masked fields (never fail-open)', async () => { + const table = buildTable(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(createContext(), table); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const statusField = table + .getField((field) => field.name().toString() === 'Status') + ._unsafeUnwrap(); + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => ok(undefined as never), + accept: () => ok(undefined), + } as never; + + let findCalled = false; + const recordQueryRepo: ITableRecordQueryRepository = { + find: async () => { + findCalled = true; + return ok({ records: [], total: 0 }); + }, + findOne: async () => err(domainError.notFound({ message: 'Not found' })), + async *findStream() {}, + }; + + const query = ListTableRecordsQuery.create( + { + tableId: table.id().toString(), + filter: { + fieldId: titleField.id().toString(), + operator: 'is', + value: { type: 'field', fieldId: statusField.id().toString() }, + }, + }, + { + queryScope: { + // Both fields statically readable — mask alone must still reject RHS. + readableFieldIds: new Set([titleField.id().toString(), statusField.id().toString()]), + fieldMasks: [{ fieldId: statusField.id().toString(), visibleWhen: neverVisible }], + }, + } + )._unsafeUnwrap(); + const handler = new ListTableRecordsHandler(tableRepository, recordQueryRepo, new NoopLogger()); + const result = await handler.handle(createContext(), query); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().message).toMatch(/conditionally masked/i); + expect(findCalled).toBe(false); + }); + + it('rejects client sort on a conditionally masked field', async () => { + const table = buildTable(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(createContext(), table); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => ok(undefined as never), + accept: () => ok(undefined), + } as never; + + let findCalled = false; + const recordQueryRepo: ITableRecordQueryRepository = { + find: async () => { + findCalled = true; + return ok({ records: [], total: 0 }); + }, + findOne: async () => err(domainError.notFound({ message: 'Not found' })), + async *findStream() {}, + }; + + const query = ListTableRecordsQuery.create( + { + tableId: table.id().toString(), + sort: [{ fieldId: titleField.id().toString(), order: 'asc' }], + }, + { + queryScope: { + readableFieldIds: new Set([titleField.id().toString()]), + fieldMasks: [{ fieldId: titleField.id().toString(), visibleWhen: neverVisible }], + }, + } + )._unsafeUnwrap(); + const handler = new ListTableRecordsHandler(tableRepository, recordQueryRepo, new NoopLogger()); + const result = await handler.handle(createContext(), query); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().code).toBe('record.sort.masked_field'); + expect(findCalled).toBe(false); + }); + + it('rejects client groupBy on a masked field with group error code (not sort)', async () => { + const table = buildTable(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(createContext(), table); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const titleId = titleField.id().toString(); + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => ok(undefined as never), + accept: () => ok(undefined), + } as never; + + let findCalled = false; + const recordQueryRepo: ITableRecordQueryRepository = { + find: async () => { + findCalled = true; + return ok({ records: [], total: 0 }); + }, + findOne: async () => err(domainError.notFound({ message: 'Not found' })), + async *findStream() {}, + }; + + // Mimic OpenAPI: groupBy also folded into sort. + const query = ListTableRecordsQuery.create( + { + tableId: table.id().toString(), + groupBy: [titleId], + sort: [{ fieldId: titleId, order: 'asc' }], + }, + { + queryScope: { + readableFieldIds: new Set([titleId]), + fieldMasks: [{ fieldId: titleId, visibleWhen: neverVisible }], + }, + } + )._unsafeUnwrap(); + const handler = new ListTableRecordsHandler(tableRepository, recordQueryRepo, new NoopLogger()); + const result = await handler.handle(createContext(), query); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().code).toBe('record.group.masked_field'); + expect(findCalled).toBe(false); + }); + + it('rejects masked groupBy by field name with group error code (not sort)', async () => { + const table = buildTable(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(createContext(), table); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const titleId = titleField.id().toString(); + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => ok(undefined as never), + accept: () => ok(undefined), + } as never; + + let findCalled = false; + const recordQueryRepo: ITableRecordQueryRepository = { + find: async () => { + findCalled = true; + return ok({ records: [], total: 0 }); + }, + findOne: async () => err(domainError.notFound({ message: 'Not found' })), + async *findStream() {}, + }; + + // groupBy uses name while sort is folded with the same fieldKeyType (name). + // Handler must resolve group keys to IDs before subtracting from sort. + const query = ListTableRecordsQuery.create( + { + tableId: table.id().toString(), + fieldKeyType: FieldKeyType.Name, + groupBy: ['Title'], + sort: [{ fieldId: 'Title', order: 'asc' }], + }, + { + queryScope: { + readableFieldIds: new Set([titleId]), + fieldMasks: [{ fieldId: titleId, visibleWhen: neverVisible }], + }, + } + )._unsafeUnwrap(); + const handler = new ListTableRecordsHandler(tableRepository, recordQueryRepo, new NoopLogger()); + const result = await handler.handle(createContext(), query); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().code).toBe('record.group.masked_field'); + expect(findCalled).toBe(false); + }); + + it.each([ + { + label: 'id', + fieldKey: (field: { id: () => { toString: () => string } }) => field.id().toString(), + }, + { + label: 'name', + fieldKey: (field: { name: () => { toString: () => string } }) => field.name().toString(), + }, + { + label: 'dbFieldName', + fieldKey: (field: { + dbFieldName: () => { + _unsafeUnwrap: () => { value: () => { _unsafeUnwrap: () => string } }; + }; + }) => field.dbFieldName()._unsafeUnwrap().value()._unsafeUnwrap(), + }, + ])('rejects explicit search on masked field by $label', async ({ fieldKey }) => { + const table = buildTable(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(createContext(), table); + const statusField = table + .getField((field) => field.name().toString() === 'Status') + ._unsafeUnwrap(); + const statusId = statusField.id().toString(); + statusField.setDbFieldName(DbFieldName.rehydrate('status_masked_col')._unsafeUnwrap()); + + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => ok(undefined as never), + accept: () => ok(undefined), + } as never; + + let findCalled = false; + const recordQueryRepo: ITableRecordQueryRepository = { + find: async () => { + findCalled = true; + return ok({ records: [], total: 0 }); + }, + findOne: async () => err(domainError.notFound({ message: 'Not found' })), + async *findStream() {}, + }; + + const query = ListTableRecordsQuery.create( + { + tableId: table.id().toString(), + search: ['secret', fieldKey(statusField), true], + }, + { + queryScope: { + readableFieldIds: new Set([statusId]), + fieldMasks: [{ fieldId: statusId, visibleWhen: neverVisible }], + }, + } + )._unsafeUnwrap(); + const handler = new ListTableRecordsHandler(tableRepository, recordQueryRepo, new NoopLogger()); + const result = await handler.handle(createContext(), query); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().code).toBe('record.search.masked_field'); + expect(findCalled).toBe(false); + }); + + it('filters view default sort/group by the final field allow-list', async () => { + const table = buildTable(); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const statusField = table + .getField((field) => field.name().toString() === 'Status') + ._unsafeUnwrap(); + const view = table.views()[0]!; + const tableWithDefaults = TableUpdateViewQueryDefaultsSpec.create([ + { + viewId: view.id(), + queryDefaults: ViewQueryDefaults.create({ + filter: { + fieldId: statusField.id().toString(), + operator: 'is', + value: 'Open', + }, + sort: [ + { fieldId: statusField.id().toString(), order: 'asc' }, + { fieldId: titleField.id().toString(), order: 'desc' }, + ], + group: [{ fieldId: statusField.id().toString(), order: 'asc' }], + manualSort: false, + })._unsafeUnwrap(), + }, + ]) + .mutate(table) + ._unsafeUnwrap(); + + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(createContext(), tableWithDefaults); + + const captured: { spec?: unknown; options?: unknown } = {}; + const recordQueryRepo: ITableRecordQueryRepository = { + find: async (_context, _table, spec, options) => { + captured.spec = spec; + captured.options = options; + return ok({ records: [], total: 0 }); + }, + findOne: async () => err(domainError.notFound({ message: 'Not found' })), + async *findStream() {}, + }; + + const query = ListTableRecordsQuery.create( + { + tableId: tableWithDefaults.id().toString(), + viewId: view.id().toString(), + fieldKeyType: FieldKeyType.Id, + }, + { + queryScope: { + // Status not allowed — view sort/group on Status must be stripped + readableFieldIds: new Set([titleField.id().toString()]), + }, + } + )._unsafeUnwrap(); + const handler = new ListTableRecordsHandler(tableRepository, recordQueryRepo, new NoopLogger()); + const result = await handler.handle(createContext(), query); + + expect(result.isOk()).toBe(true); + expect(captured.spec).toBeUndefined(); + const orderBy = ( + captured.options as { + orderBy?: Array<{ fieldId?: { toString: () => string }; column?: string }>; + } + ).orderBy; + const fieldIds = (orderBy ?? []) + .map((item) => item.fieldId?.toString()) + .filter((id): id is string => Boolean(id)); + expect(fieldIds).toEqual([titleField.id().toString()]); + expect(fieldIds).not.toContain(statusField.id().toString()); + }); + + describe('queryScope authorization matrix', () => { + it('builds grouped metadata from the same permission and client-filter scope', async () => { + const table = buildTable(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(createContext(), table); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const statusField = table + .getField((field) => field.name().toString() === 'Status') + ._unsafeUnwrap(); + const titleId = titleField.id().toString(); + const statusId = statusField.id().toString(); + const rowScope = buildRecordConditionSpec(table, { + fieldId: statusId, + operator: 'isNot', + value: 'Private', + })._unsafeUnwrap(); + const captured: { spec?: unknown; options?: unknown } = {}; + const groups = [ + { fields: { [statusId]: 'Open' }, count: 2 }, + { fields: { [statusId]: 'Closed' }, count: 1 }, + ]; + const recordQueryRepo: ITableRecordQueryRepository = { + find: async (_context, _table, spec, options) => { + captured.spec = spec; + captured.options = options; + return ok({ records: [], total: 3, groups }); + }, + findOne: async () => err(domainError.notFound({ message: 'Not found' })), + async *findStream() {}, + }; + const query = ListTableRecordsQuery.create( + { + tableId: table.id().toString(), + fieldKeyType: FieldKeyType.Id, + filter: { fieldId: titleId, operator: 'contains', value: 'ticket' }, + sort: [{ fieldId: statusId, order: 'asc' }], + groupBy: [statusId], + }, + { + queryScope: { recordSpec: rowScope }, + includeGroupMetadata: true, + } + )._unsafeUnwrap(); + + const result = await new ListTableRecordsHandler( + tableRepository, + recordQueryRepo, + new NoopLogger() + ).handle(createContext(), query); + + expect(result._unsafeUnwrap().groups).toEqual(groups); + expect( + ( + captured.options as { + groupBy?: Array<{ fieldId: { toString(): string }; direction: string }>; + } + ).groupBy + ).toEqual([{ fieldId: statusField.id(), direction: 'asc' }]); + + const combinedSpec = captured.spec as { + isSatisfiedBy(record: TableRecord): boolean; + }; + const matching = TableRecord.fromRawFieldValues({ + id: createRecordId('g').toString(), + tableId: table.id(), + fields: { [titleId]: 'ticket 1', [statusId]: 'Open' }, + })._unsafeUnwrap(); + const deniedByFilter = TableRecord.fromRawFieldValues({ + id: createRecordId('h').toString(), + tableId: table.id(), + fields: { [titleId]: 'note', [statusId]: 'Open' }, + })._unsafeUnwrap(); + const deniedByScope = TableRecord.fromRawFieldValues({ + id: createRecordId('i').toString(), + tableId: table.id(), + fields: { [titleId]: 'ticket 2', [statusId]: 'Private' }, + })._unsafeUnwrap(); + expect(combinedSpec.isSatisfiedBy(matching)).toBe(true); + expect(combinedSpec.isSatisfiedBy(deniedByFilter)).toBe(false); + expect(combinedSpec.isSatisfiedBy(deniedByScope)).toBe(false); + }); + + it('returns only records satisfying both the permission row scope and client filter', async () => { + const table = buildTable(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(createContext(), table); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const statusField = table + .getField((field) => field.name().toString() === 'Status') + ._unsafeUnwrap(); + const titleId = titleField.id().toString(); + const statusId = statusField.id().toString(); + const rowScope = buildRecordConditionSpec(table, { + fieldId: statusId, + operator: 'is', + value: 'Open', + })._unsafeUnwrap(); + const fixtures = [ + { + id: createRecordId('a').toString(), + fields: { [titleId]: 'filter match', [statusId]: 'Open' }, + version: 1, + }, + { + id: createRecordId('b').toString(), + fields: { [titleId]: 'scope only', [statusId]: 'Open' }, + version: 1, + }, + { + id: createRecordId('c').toString(), + fields: { [titleId]: 'filter match', [statusId]: null }, + version: 1, + }, + ] satisfies TableRecordReadModel[]; + const recordQueryRepo: ITableRecordQueryRepository = { + find: async (_context, _table, spec) => { + const records = fixtures.filter((record) => { + if (!spec) return true; + const domainRecord = TableRecord.fromRawFieldValues({ + id: record.id, + tableId: table.id(), + fields: record.fields, + })._unsafeUnwrap(); + return spec.isSatisfiedBy(domainRecord); + }); + return ok({ records, total: records.length }); + }, + findOne: async () => err(domainError.notFound({ message: 'Not found' })), + async *findStream() {}, + }; + const query = ListTableRecordsQuery.create( + { + tableId: table.id().toString(), + fieldKeyType: FieldKeyType.Id, + filter: { + fieldId: titleId, + operator: 'contains', + value: 'filter', + }, + }, + { queryScope: { recordSpec: rowScope } } + )._unsafeUnwrap(); + + const result = await new ListTableRecordsHandler( + tableRepository, + recordQueryRepo, + new NoopLogger() + ).handle(createContext(), query); + + expect(result._unsafeUnwrap().records.map((record) => record.id)).toEqual([ + createRecordId('a').toString(), + ]); + }); + + it('excludes conditionally masked fields from all-fields visible-row search', async () => { + const table = buildTable(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(createContext(), table); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const statusField = table + .getField((field) => field.name().toString() === 'Status') + ._unsafeUnwrap(); + const captured: { searchFieldIds?: string[] } = {}; + const recordQueryRepo: ITableRecordQueryRepository = { + find: async (_context, _table, _spec, options) => { + captured.searchFieldIds = options?.search?.visibleFieldIds?.map((id) => id.toString()); + return ok({ records: [], total: 0 }); + }, + findOne: async () => err(domainError.notFound({ message: 'Not found' })), + async *findStream() {}, + }; + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => ok(undefined as never), + accept: () => ok(undefined), + } as never; + const query = ListTableRecordsQuery.create( + { + tableId: table.id().toString(), + fieldKeyType: FieldKeyType.Id, + search: ['hidden-only-value', '', true], + }, + { + queryScope: { + readableFieldIds: new Set([titleField.id().toString(), statusField.id().toString()]), + fieldMasks: [{ fieldId: statusField.id().toString(), visibleWhen: neverVisible }], + }, + } + )._unsafeUnwrap(); + + const result = await new ListTableRecordsHandler( + tableRepository, + recordQueryRepo, + new NoopLogger() + ).handle(createContext(), query); + + expect(result.isOk()).toBe(true); + expect(captured.searchFieldIds).toEqual([titleField.id().toString()]); + }); + + it('strips conditionally masked fields from view-default sort and group', async () => { + const table = buildTable(); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const statusField = table + .getField((field) => field.name().toString() === 'Status') + ._unsafeUnwrap(); + const view = table.views()[0]!; + const tableWithDefaults = TableUpdateViewQueryDefaultsSpec.create([ + { + viewId: view.id(), + queryDefaults: ViewQueryDefaults.create({ + sort: [ + { fieldId: statusField.id().toString(), order: 'asc' }, + { fieldId: titleField.id().toString(), order: 'desc' }, + ], + group: [{ fieldId: statusField.id().toString(), order: 'asc' }], + manualSort: false, + })._unsafeUnwrap(), + }, + ]) + .mutate(table) + ._unsafeUnwrap(); + const tableRepository = new MemoryTableRepository(); + await tableRepository.insert(createContext(), tableWithDefaults); + const captured: { orderByFieldIds?: Array } = {}; + const recordQueryRepo: ITableRecordQueryRepository = { + find: async (_context, _table, _spec, options) => { + captured.orderByFieldIds = options?.orderBy?.map((item) => item.fieldId?.toString()); + return ok({ records: [], total: 0 }); + }, + findOne: async () => err(domainError.notFound({ message: 'Not found' })), + async *findStream() {}, + }; + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => ok(undefined as never), + accept: () => ok(undefined), + } as never; + const query = ListTableRecordsQuery.create( + { + tableId: tableWithDefaults.id().toString(), + viewId: view.id().toString(), + fieldKeyType: FieldKeyType.Id, + }, + { + queryScope: { + readableFieldIds: new Set([titleField.id().toString(), statusField.id().toString()]), + fieldMasks: [{ fieldId: statusField.id().toString(), visibleWhen: neverVisible }], + }, + } + )._unsafeUnwrap(); + + const result = await new ListTableRecordsHandler( + tableRepository, + recordQueryRepo, + new NoopLogger() + ).handle(createContext(), query); + + expect(result.isOk()).toBe(true); + expect(captured.orderByFieldIds).toContain(titleField.id().toString()); + expect(captured.orderByFieldIds).not.toContain(statusField.id().toString()); + }); + }); }); diff --git a/packages/v2/core/src/queries/ListTableRecordsHandler.ts b/packages/v2/core/src/queries/ListTableRecordsHandler.ts index 8157295c68..dada1f3d3a 100644 --- a/packages/v2/core/src/queries/ListTableRecordsHandler.ts +++ b/packages/v2/core/src/queries/ListTableRecordsHandler.ts @@ -9,6 +9,7 @@ import { resolveOrderBy as resolveQueryOrderBy, } from '../commands/shared/orderBy'; import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError'; +import { composeAndSpecsOrUndefined } from '../domain/shared/specification/composeAndSpecs'; import { type ISpecification } from '../domain/shared/specification/ISpecification'; import { FieldId } from '../domain/table/fields/FieldId'; import { FieldKeyType } from '../domain/table/fields/FieldKeyType'; @@ -20,7 +21,7 @@ import { IncomingLinkSelectedSpec } from '../domain/table/records/specs/Incoming import type { ITableRecordConditionSpecVisitor } from '../domain/table/records/specs/ITableRecordConditionSpecVisitor'; import { RecordByIdsSpec } from '../domain/table/records/specs/RecordByIdsSpec'; import { RecordConditionSpecBuilder } from '../domain/table/records/specs/RecordConditionSpecBuilder'; -import type { TableRecord } from '../domain/table/records/TableRecord'; +import { TableRecord } from '../domain/table/records/TableRecord'; import { TableByIdSpec } from '../domain/table/specs/TableByIdSpec'; import { TableByIncomingReferenceToTableSpec } from '../domain/table/specs/TableByIncomingReferenceToTableSpec'; import type { Table } from '../domain/table/Table'; @@ -28,6 +29,7 @@ import type { ViewQueryGroupItem } from '../domain/table/views/ViewQueryDefaults import { NoopTableQueryObservability } from '../ports/defaults/NoopTableQueryObservability'; import type { IExecutionContext } from '../ports/ExecutionContext'; import * as LoggerPort from '../ports/Logger'; +import type { RecordQueryFieldMask } from '../ports/RecordQueryPlugin'; import { ITableQueryObservability } from '../ports/TableQueryObservability'; import type { TableQueryObservabilityEvent } from '../ports/TableQueryObservability'; import { @@ -38,6 +40,7 @@ import { type TableSearchScope, } from '../ports/TableQueryTraceAttributes'; import * as TableRecordQueryRepositoryPort from '../ports/TableRecordQueryRepository'; +import type { ITableRecordGroup } from '../ports/TableRecordQueryRepository'; import type { TableRecordReadModel } from '../ports/TableRecordReadModel'; import * as TableRepositoryPort from '../ports/TableRepository'; import { v2CoreTokens } from '../ports/tokens'; @@ -64,16 +67,20 @@ export class ListTableRecordsResult { readonly records: ReadonlyArray, readonly total: number, readonly offset: number, - readonly limit: number + readonly limit: number, + readonly groups?: ReadonlyArray, + readonly searchMatches?: ReadonlyArray ) {} static create( records: ReadonlyArray, total: number, offset: number, - limit: number + limit: number, + groups?: ReadonlyArray, + searchMatches?: ReadonlyArray ): ListTableRecordsResult { - return new ListTableRecordsResult(records, total, offset, limit); + return new ListTableRecordsResult(records, total, offset, limit, groups, searchMatches); } } @@ -174,47 +181,300 @@ function resolveFilterNodeFieldKeys( } const getEnabledFieldIdSet = (query: ListTableRecordsQuery): ReadonlySet | undefined => { + // Prefer pure-V2 query scope field allow-list over legacy CTE enabledFieldIds. + if (query.queryScope?.readableFieldIds != null) { + return query.queryScope.readableFieldIds; + } const enabledFieldIds = query.recordReadQuerySource?.enabledFieldIds; return enabledFieldIds ? new Set(enabledFieldIds) : undefined; }; +/** + * Collect field ids referenced by a condition specification tree + * (left/right field of conditions + field-reference values). + * Used so mask evaluation can load dependency columns that are not returned. + */ +const collectFieldIdsFromSpec = (spec: unknown): ReadonlySet => { + const ids = new Set(); + const walk = (node: unknown): void => { + if (!node || typeof node !== 'object') { + return; + } + const candidate = node as { + leftSpec?: () => unknown; + rightSpec?: () => unknown; + innerSpec?: () => unknown; + field?: () => { id: () => { toString: () => string } }; + value?: () => unknown; + }; + if (typeof candidate.leftSpec === 'function' && typeof candidate.rightSpec === 'function') { + walk(candidate.leftSpec()); + walk(candidate.rightSpec()); + return; + } + if (typeof candidate.innerSpec === 'function') { + walk(candidate.innerSpec()); + return; + } + if (typeof candidate.field === 'function') { + try { + ids.add(candidate.field().id().toString()); + } catch { + // ignore non-field specs + } + } + if (typeof candidate.value === 'function') { + const value = candidate.value(); + if ( + value && + typeof value === 'object' && + typeof (value as { field?: unknown }).field === 'function' + ) { + try { + ids.add( + (value as { field: () => { id: () => { toString: () => string } } }) + .field() + .id() + .toString() + ); + } catch { + // ignore + } + } + } + }; + walk(spec); + return ids; +}; + +const collectMaskDependencyFieldIds = ( + fieldMasks: ReadonlyArray | undefined +): ReadonlySet => { + const ids = new Set(); + for (const mask of fieldMasks ?? []) { + for (const fieldId of collectFieldIdsFromSpec(mask.visibleWhen)) { + ids.add(fieldId); + } + } + return ids; +}; + +/** + * Apply conditional field masks (visibleWhen) after read. + * Fields that fail the mask are omitted from the result payload (null-out). + * + * Fail-closed: if a mask dependency field was not loaded into the evaluation + * projection, the masked field is stripped (never fail-open on missing deps). + */ +const applyFieldMasksToRecords = ( + table: Table, + records: ReadonlyArray, + fieldMasks: ReadonlyArray | undefined, + evaluationFieldIds?: ReadonlySet +): ReadonlyArray => { + if (!fieldMasks?.length || !records.length) { + return records; + } + + const maskDepsByFieldId = new Map( + fieldMasks.map((mask) => [mask.fieldId, collectFieldIdsFromSpec(mask.visibleWhen)] as const) + ); + + return records.map((record) => { + const domainRecordResult = TableRecord.fromRawFieldValues({ + id: record.id, + tableId: table.id(), + fields: record.fields, + }); + // Fail-closed: if we cannot evaluate masks, strip all masked fields. + if (domainRecordResult.isErr()) { + const nextFields = { ...record.fields }; + for (const mask of fieldMasks) { + delete nextFields[mask.fieldId]; + } + return { ...record, fields: nextFields }; + } + const domainRecord = domainRecordResult.value; + let changed = false; + const nextFields = { ...record.fields }; + for (const mask of fieldMasks) { + if (!Object.prototype.hasOwnProperty.call(nextFields, mask.fieldId)) { + continue; + } + const deps = maskDepsByFieldId.get(mask.fieldId); + // Fail-closed when a dependency was never loaded into the evaluation + // projection. (isEmpty/isNot on undefined fail-open — do not evaluate.) + // Null values that were projected still evaluate normally. + const missingFromProjection = + evaluationFieldIds != null && + deps != null && + [...deps].some((depId) => !evaluationFieldIds.has(depId)); + if (missingFromProjection) { + delete nextFields[mask.fieldId]; + changed = true; + continue; + } + if (!mask.visibleWhen.isSatisfiedBy(domainRecord)) { + delete nextFields[mask.fieldId]; + changed = true; + } + } + return changed ? { ...record, fields: nextFields } : record; + }); +}; + +type UnreadableFilterFieldPolicy = 'reject' | 'strip'; + +/** + * Apply the static readable-field allow-list to a filter. + * + * Explicit client filters reject unreadable fields so the query cannot silently + * broaden. Persisted view defaults strip stale unreadable fields so permission + * changes do not make the view unusable. + */ const sanitizeFilterByEnabledFieldIds = ( filter: RecordFilter | undefined, - enabledFieldIds: ReadonlySet | undefined -): RecordFilter | undefined => { - if (!filter || enabledFieldIds == null) { - return filter; + enabledFieldIds: ReadonlySet | undefined, + maskedFieldIds: ReadonlySet | undefined, + unreadableFieldPolicy: UnreadableFilterFieldPolicy +): Result => { + if (!filter || (enabledFieldIds == null && maskedFieldIds == null)) { + return ok(filter); } - const sanitizeNode = (node: RecordFilterNode): RecordFilterNode | undefined => { + const unreadableFieldResult = (): Result => + unreadableFieldPolicy === 'reject' + ? err( + domainError.validation({ + code: 'record.filter.unreadable_field', + message: 'Filter references a field that is not readable', + }) + ) + : ok(undefined); + + const sanitizeNode = ( + node: RecordFilterNode + ): Result => { if (isRecordFilterCondition(node)) { - return enabledFieldIds.has(node.fieldId) ? node : undefined; + if (enabledFieldIds != null && !enabledFieldIds.has(node.fieldId)) { + return unreadableFieldResult(); + } + if (isRecordFilterFieldReferenceValue(node.value)) { + if (enabledFieldIds != null && !enabledFieldIds.has(node.value.fieldId)) { + return unreadableFieldResult(); + } + if (maskedFieldIds?.has(node.value.fieldId)) { + return err( + domainError.validation({ + code: 'record.filter.masked_field_reference', + message: + 'Filter field reference to a conditionally masked field is not allowed until mask-aware SQL is available', + }) + ); + } + // LHS masked + field-ref RHS: NULL truth is row-dependent — fail closed. + if (maskedFieldIds?.has(node.fieldId)) { + return err( + domainError.validation({ + code: 'record.filter.masked_field_reference_lhs', + message: + 'Filter comparing a conditionally masked field to another field is not allowed until mask-aware SQL CASE WHEN is available', + }) + ); + } + } + return ok(node); } if (isRecordFilterGroup(node)) { - const items = node.items - .map((item) => sanitizeNode(item)) - .filter((item): item is RecordFilterNode => item != null); - - return items.length - ? { - conjunction: node.conjunction, - items, - } - : undefined; + const items: RecordFilterNode[] = []; + for (const item of node.items) { + const next = sanitizeNode(item); + if (next.isErr()) return err(next.error); + if (next.value != null) items.push(next.value); + } + return ok( + items.length + ? { + conjunction: node.conjunction, + items, + } + : undefined + ); } if (isRecordFilterNot(node)) { - const nextNode = sanitizeNode(node.not); - return nextNode ? { not: nextNode } : undefined; + return sanitizeNode(node.not).map((nextNode) => (nextNode ? { not: nextNode } : undefined)); } - return node; + return ok(node); }; return sanitizeNode(filter); }; +/** Static allow-list for server-owned view defaults only. */ +const filterSortByEnabledFieldIds = ( + sort: ReadonlyArray | undefined, + enabledFieldIds: ReadonlySet | undefined +): ReadonlyArray | undefined => { + if (!sort?.length || enabledFieldIds == null) { + return sort; + } + const filtered = sort.filter((item) => enabledFieldIds.has(item.fieldId)); + return filtered.length ? filtered : undefined; +}; + +const filterGroupByEnabledFieldIds = ( + group: ReadonlyArray | undefined, + enabledFieldIds: ReadonlySet | undefined +): ReadonlyArray | undefined => { + if (!group?.length || enabledFieldIds == null) { + return group; + } + const filtered = group.filter((item) => enabledFieldIds.has(item.fieldId)); + return filtered.length ? filtered : undefined; +}; + +const rejectUnreadableSortOrGroup = ( + kind: 'sort' | 'group', + items: ReadonlyArray<{ fieldId: string }> | undefined, + enabledFieldIds: ReadonlySet | undefined +): Result => { + if (!items?.length || enabledFieldIds == null) { + return ok(undefined); + } + if (!items.some((item) => !enabledFieldIds.has(item.fieldId))) { + return ok(undefined); + } + return err( + domainError.validation({ + code: `record.${kind}.unreadable_field`, + message: `${kind === 'sort' ? 'Sort' : 'Group'} references a field that is not readable`, + }) + ); +}; + +const rejectMaskedSortOrGroup = ( + kind: 'sort' | 'group', + items: ReadonlyArray<{ fieldId: string }> | undefined, + maskedFieldIds: ReadonlySet | undefined +): Result => { + if (!items?.length || !maskedFieldIds?.size) { + return ok(undefined); + } + const masked = items.find((item) => maskedFieldIds.has(item.fieldId)); + if (!masked) { + return ok(undefined); + } + return err( + domainError.validation({ + code: `record.${kind}.masked_field`, + message: `${kind === 'sort' ? 'Sort' : 'Group'} on a conditionally masked field is not allowed until mask-aware SQL CASE WHEN is available`, + }) + ); +}; + const nowMs = () => Date.now(); const resolveSearchAccessPath = ( @@ -452,8 +712,21 @@ const resolveProjectionFieldIds = ( fieldKeyType: FieldKeyType, enabledFieldIds?: ReadonlySet ): Result | undefined, DomainError> => { + // undefined projection + unrestricted → all columns (repo default). + // undefined projection + allow-list → only allow-list (empty set → []). + // Never treat empty allow-list as unrestricted. if (projection === undefined) { - return ok(undefined); + if (enabledFieldIds == null) { + return ok(undefined); + } + const fieldIds: FieldId[] = []; + for (const fieldIdText of enabledFieldIds) { + const fieldId = FieldId.create(fieldIdText); + if (fieldId.isOk()) { + fieldIds.push(fieldId.value); + } + } + return ok(fieldIds); } const fieldIds: FieldId[] = []; @@ -463,7 +736,7 @@ const resolveProjectionFieldIds = ( if (resolvedFieldId.isErr()) { return err(resolvedFieldId.error); } - if (enabledFieldIds && !enabledFieldIds.has(resolvedFieldId.value)) { + if (enabledFieldIds != null && !enabledFieldIds.has(resolvedFieldId.value)) { continue; } if (seen.has(resolvedFieldId.value)) { @@ -571,12 +844,7 @@ export class ListTableRecordsHandler } // silently ignore if the view no longer exists } - const resolvedSort = yield* resolveSortValues( - table, - query.sort, - query.fieldKeyType, - enabledFieldIds - ); + const resolvedSort = yield* resolveSortValues(table, query.sort, query.fieldKeyType); const effectiveQueryDefaults = effectiveView ? yield* effectiveView.queryDefaults() : undefined; @@ -586,16 +854,82 @@ export class ListTableRecordsHandler context.actorId.toString() ); const sanitizedDefaultFilter = yield* sanitizeRecordFilter(table, defaultFilter); - effectiveFilter = sanitizeFilterByEnabledFieldIds( - mergeFilterWithViewDefaults(sanitizedDefaultFilter, actorResolvedFilter), + // Conditionally masked fields: reject sort/group/search + RHS filter + // refs until SQL CASE WHEN (never silent drop). LHS filter uses + // three-valued mask rewrite in buildRecordConditionSpec. + const maskedFieldIds = query.queryScope?.fieldMasks?.length + ? new Set(query.queryScope.fieldMasks.map((mask) => mask.fieldId)) + : undefined; + const permissionSanitizedDefaultFilter = yield* sanitizeFilterByEnabledFieldIds( + sanitizedDefaultFilter, + enabledFieldIds, + maskedFieldIds, + 'strip' + ); + const permissionValidatedClientFilter = yield* sanitizeFilterByEnabledFieldIds( + actorResolvedFilter, + enabledFieldIds, + maskedFieldIds, + 'reject' + ); + effectiveFilter = mergeFilterWithViewDefaults( + permissionSanitizedDefaultFilter, + permissionValidatedClientFilter + ); + // Resolve groupBy to field IDs (same path as sort) before masked + // reject / subtraction — raw keys may be name/dbFieldName. + const resolvedGroupFieldIds: string[] = []; + for (const groupKey of query.groupBy ?? []) { + const groupFieldId = yield* FieldKeyResolverService.resolveFieldKey( + table, + groupKey, + query.fieldKeyType + ); + resolvedGroupFieldIds.push(groupFieldId); + } + const clientGroupFieldIds = new Set(resolvedGroupFieldIds); + // Group first so pure groupBy is not mislabeled as sort (OpenAPI + // merges groupBy into sort before calling list). + yield* rejectUnreadableSortOrGroup( + 'group', + resolvedGroupFieldIds.map((fieldId) => ({ fieldId })), enabledFieldIds ); - effectiveSort = mergeSortWithViewDefaults( + yield* rejectMaskedSortOrGroup( + 'group', + resolvedGroupFieldIds.map((fieldId) => ({ fieldId })), + maskedFieldIds + ); + // Sort-only keys: exclude groupBy fields that were folded into sort. + const clientSortOnly = resolvedSort?.filter( + (item) => !clientGroupFieldIds.has(item.fieldId) + ); + yield* rejectUnreadableSortOrGroup('sort', clientSortOnly, enabledFieldIds); + yield* rejectMaskedSortOrGroup('sort', clientSortOnly, maskedFieldIds); + // View-default sort/group may still mention masked fields — strip only + // those defaults (not client-provided keys, which were rejected above). + const viewDefaultSort = mergeSortWithViewDefaults( effectiveQueryDefaults?.sort(), effectiveQueryDefaults?.manualSort(), - resolvedSort + undefined + )?.filter((item) => !maskedFieldIds?.has(item.fieldId)); + effectiveSort = filterSortByEnabledFieldIds( + mergeSortWithViewDefaults(viewDefaultSort, undefined, resolvedSort), + enabledFieldIds + ); + effectiveGroup = filterGroupByEnabledFieldIds( + query.groupBy?.length + ? resolvedGroupFieldIds.map((fieldId) => ({ + fieldId, + order: + resolvedSort?.find((item) => item.fieldId === fieldId)?.order ?? + ('asc' as const), + })) + : effectiveQueryDefaults + ?.group() + ?.filter((item) => !maskedFieldIds?.has(item.fieldId)), + enabledFieldIds ); - effectiveGroup = query.groupBy?.length ? undefined : effectiveQueryDefaults?.group(); orderBy = mergeOrderBy( yield* resolveGroupByToOrderBy(effectiveGroup), yield* resolveQueryOrderBy(effectiveSort), @@ -608,13 +942,51 @@ export class ListTableRecordsHandler effectiveFilter, linkCandidatePlan ); - queryPlan = builtQueryPlan; + // AND pure-V2 permission row scope into the condition tree + // (unless link-selected keepPrimary / skipRecordSpec). + const rowScopeSpec = + query.queryScope?.skipRecordSpec || !query.queryScope?.recordSpec + ? undefined + : query.queryScope.recordSpec; + queryPlan = { + ...builtQueryPlan, + spec: composeAndSpecsOrUndefined( + [builtQueryPlan.spec, rowScopeSpec].filter( + (spec): spec is ISpecification => + spec != null + ) + ), + }; projectionFieldIds = yield* resolveProjectionFieldIds( table, query.projection, query.fieldKeyType, enabledFieldIds ); + // Expand projection with static readable fields + mask dependency + // fields so visibleWhen evaluation is not fail-open on missing columns. + // Dependencies are never returned — stripped after mask apply. + if (query.queryScope?.fieldMasks?.length) { + const maskDeps = collectMaskDependencyFieldIds(query.queryScope.fieldMasks); + // No projection + no allow-list means "all columns" — seed with + // every table field so expansion cannot collapse to mask deps. + const baseFieldIds = + projectionFieldIds == null && enabledFieldIds == null + ? table.fieldIds().map((id) => id.toString()) + : [ + ...(projectionFieldIds?.map((id) => id.toString()) ?? []), + ...(enabledFieldIds ?? []), + ]; + const expanded = new Set([...baseFieldIds, ...maskDeps]); + const expandedIds: FieldId[] = []; + for (const fieldIdText of expanded) { + const fieldId = FieldId.create(fieldIdText); + if (fieldId.isOk()) { + expandedIds.push(fieldId.value); + } + } + projectionFieldIds = expandedIds; + } observabilityEvent = createListRecordsObservabilityEvent(query, { hasFilter: Boolean(effectiveFilter), hasSort: Boolean(effectiveSort?.length), @@ -632,17 +1004,47 @@ export class ListTableRecordsHandler resolveShapeSpan?.end(); } - // 3. Resolve visible-row search through the repository - const searchVisibleFieldIds = + // 3. Resolve visible-row search through the repository. + // Conditionally masked fields: reject when the client targets them + // explicitly (id / name / dbFieldName via RecordSearch resolver); + // for all-fields search, exclude them from the search set. + const searchMaskedFieldIds = query.queryScope?.fieldMasks?.length + ? new Set(query.queryScope.fieldMasks.map((mask) => mask.fieldId)) + : undefined; + const requestedSearch = RecordSearch.fromOptionalTuple(query.search); + if ( + requestedSearch && + searchMaskedFieldIds?.size && + !requestedSearch.searchesAllFields() + ) { + for (const key of requestedSearch.fieldKeys() ?? []) { + const resolved = RecordSearch.resolveFieldKey(table, key); + if (resolved.isOk() && searchMaskedFieldIds.has(resolved.value.id().toString())) { + yield* err( + domainError.validation({ + code: 'record.search.masked_field', + message: + 'Search on a conditionally masked field is not allowed until mask-aware SQL is available', + }) + ); + } + } + } + const searchVisibleFieldIds = filterFieldIdsByEnabledFieldIds( query.viewId && !query.ignoreViewQuery - ? filterFieldIdsByEnabledFieldIds( - yield* table.getOrderedVisibleFieldIds(query.viewId), - enabledFieldIds + ? yield* table.getOrderedVisibleFieldIds(query.viewId) + : table.fieldIds(), + enabledFieldIds + ).filter((fieldId) => !searchMaskedFieldIds?.has(fieldId.toString())); + const projectedSearchVisibleFieldIds = + query.includeSearchFieldMatches && projectionFieldIds + ? searchVisibleFieldIds.filter((fieldId) => + projectionFieldIds.some((projectedFieldId) => projectedFieldId.equals(fieldId)) ) - : filterFieldIdsByEnabledFieldIds(table.fieldIds(), enabledFieldIds); + : searchVisibleFieldIds; const visibleRowSearch = resolveVisibleRowSearch( - RecordSearch.fromOptionalTuple(query.search), - searchVisibleFieldIds + requestedSearch, + projectedSearchVisibleFieldIds ); const searchAccessEvent = createListRecordsObservabilityEvent(query, { hasFilter: Boolean(effectiveFilter), @@ -678,6 +1080,16 @@ export class ListTableRecordsHandler includeTotal: query.includeTotal, recordReadQuerySource: query.recordReadQuerySource, searchAccessPath: query.recordSearchAccessPath, + includeSearchFieldMatches: query.includeSearchFieldMatches, + searchIndexMode: query.searchIndexMode, + ...(query.includeGroupMetadata && effectiveGroup?.length + ? { + groupBy: ((yield* resolveGroupByToOrderBy(effectiveGroup!)) ?? []).filter( + TableRecordQueryRepositoryPort.isFieldOrderBy + ), + groupLimit: query.groupLimit, + } + : {}), } ); if (queryRecordsResult.isOk()) { @@ -698,10 +1110,58 @@ export class ListTableRecordsHandler } const queryResult = yield* queryRecordsResult; - // 5. Transform response field keys if needed + // 5. Apply field masks, strip to requested projection, then remap keys + const requestedProjection = + query.projection === undefined + ? undefined + : new Set( + (yield* resolveProjectionFieldIds( + table, + query.projection, + query.fieldKeyType, + enabledFieldIds + ))?.map((id) => id.toString()) ?? [] + ); + // Field ids available for mask evaluation (readable + deps). Used for + // fail-closed checks when a dependency was not projected. + const evaluationFieldIds = projectionFieldIds + ? new Set(projectionFieldIds.map((id) => id.toString())) + : enabledFieldIds + ? new Set(enabledFieldIds) + : undefined; + let maskedRecords = applyFieldMasksToRecords( + table, + queryResult.records, + query.queryScope?.fieldMasks, + evaluationFieldIds + ); + // Strip mask dependency fields that were only loaded for evaluation. + if (requestedProjection) { + maskedRecords = maskedRecords.map((record) => { + const nextFields: Record = {}; + for (const [fieldId, value] of Object.entries(record.fields)) { + if (requestedProjection.has(fieldId)) { + nextFields[fieldId] = value; + } + } + return { ...record, fields: nextFields }; + }); + } else if (enabledFieldIds != null) { + // No explicit client projection: return only allow-listed fields + // (never leak internal mask dependency columns). + maskedRecords = maskedRecords.map((record) => { + const nextFields: Record = {}; + for (const [fieldId, value] of Object.entries(record.fields)) { + if (enabledFieldIds.has(fieldId)) { + nextFields[fieldId] = value; + } + } + return { ...record, fields: nextFields }; + }); + } const transformedRecords = query.fieldKeyType !== FieldKeyType.Id - ? queryResult.records.map((record) => ({ + ? maskedRecords.map((record) => ({ ...record, fields: FieldKeyResolverService.transformResponseKeys( table, @@ -709,7 +1169,7 @@ export class ListTableRecordsHandler query.fieldKeyType ), })) - : queryResult.records; + : maskedRecords; logger.debug('ListTableRecordsHandler.success', { count: queryResult.records.length, @@ -740,7 +1200,9 @@ export class ListTableRecordsHandler transformedRecords, queryResult.total, query.pagination.offset().toNumber(), - query.pagination.limit().toNumber() + query.pagination.limit().toNumber(), + queryResult.groups, + queryResult.searchMatches ) ); }.bind(this) @@ -800,7 +1262,9 @@ export class ListTableRecordsHandler let recordIdsOrder: ReadonlyArray | undefined; if (resolvedFilter) { - builder.addConditionSpec(yield* buildRecordConditionSpec(table, resolvedFilter)); + builder.addConditionSpec( + yield* buildRecordConditionSpec(table, resolvedFilter, query.queryScope?.fieldMasks) + ); hasSpec = true; } diff --git a/packages/v2/core/src/queries/ListTableRecordsQuery.spec.ts b/packages/v2/core/src/queries/ListTableRecordsQuery.spec.ts index cf0ef2db1e..7464e7f901 100644 --- a/packages/v2/core/src/queries/ListTableRecordsQuery.spec.ts +++ b/packages/v2/core/src/queries/ListTableRecordsQuery.spec.ts @@ -84,4 +84,52 @@ describe('ListTableRecordsQuery', () => { expect(result.isErr()).toBe(true); }); + + it('applies a bounded group metadata limit to public requests', () => { + const result = ListTableRecordsQuery.create({ + tableId: createTableId('f').toString(), + groupBy: [`fld${'a'.repeat(16)}`], + includeGroups: true, + }); + + expect(result.isOk()).toBe(true); + const query = result._unsafeUnwrap(); + expect(query.includeGroupMetadata).toBe(true); + expect(query.groupLimit).toBe(5_000); + }); + + it('preserves a trusted host group metadata limit', () => { + const result = ListTableRecordsQuery.create( + { + tableId: createTableId('g').toString(), + }, + { + includeGroupMetadata: true, + groupLimit: 25, + } + ); + + expect(result.isOk()).toBe(true); + const query = result._unsafeUnwrap(); + expect(query.includeGroupMetadata).toBe(true); + expect(query.groupLimit).toBe(25); + }); + + it('does not apply a group limit when group metadata is disabled', () => { + const result = ListTableRecordsQuery.create( + { + tableId: createTableId('h').toString(), + includeGroups: false, + }, + { + includeGroupMetadata: true, + groupLimit: 25, + } + ); + + expect(result.isOk()).toBe(true); + const query = result._unsafeUnwrap(); + expect(query.includeGroupMetadata).toBe(false); + expect(query.groupLimit).toBeUndefined(); + }); }); diff --git a/packages/v2/core/src/queries/ListTableRecordsQuery.ts b/packages/v2/core/src/queries/ListTableRecordsQuery.ts index 05e5b9c070..e124eb282f 100644 --- a/packages/v2/core/src/queries/ListTableRecordsQuery.ts +++ b/packages/v2/core/src/queries/ListTableRecordsQuery.ts @@ -8,6 +8,7 @@ import { PageLimit } from '../domain/shared/pagination/PageLimit'; import { PageOffset } from '../domain/shared/pagination/PageOffset'; import { type FieldKeyType, fieldKeyTypeSchema } from '../domain/table/fields/FieldKeyType'; import { TableId } from '../domain/table/TableId'; +import type { RecordQueryPluginScope } from '../ports/RecordQueryPlugin'; import type { IRecordReadQuerySource, IRecordSearchAccessPath, @@ -19,6 +20,8 @@ import { recordSearchInputSchema, type RecordSearchInput } from './RecordSearch' export const DEFAULT_RECORDS_LIMIT = 100; /** Maximum page size for records */ export const MAX_RECORDS_LIMIT = 1000; +/** Default maximum number of leaf group buckets returned with record metadata. */ +export const DEFAULT_GROUP_METADATA_LIMIT = 5_000; const parseJsonInput = (schema: TSchema) => z.preprocess((value) => { @@ -40,6 +43,10 @@ const incomingLinkSelectionSchema = z.union([ z.string().min(1), z.tuple([z.string().min(1), z.string().min(1)]), ]); +const queryBooleanSchema = z.union([ + z.boolean(), + z.enum(['true', 'false']).transform((value) => value === 'true'), +]); export type RecordSortValue = z.infer; export type RecordSearchValue = RecordSearchInput; @@ -56,6 +63,9 @@ export const listTableRecordsInputSchema = z selectedRecordIds: parseJsonInput(z.array(z.string().min(1))).optional(), projection: parseJsonInput(z.array(z.string().min(1))).optional(), includeTotal: z.coerce.boolean().optional(), + includeGroups: queryBooleanSchema.optional(), + includeSearchMatches: queryBooleanSchema.optional(), + searchIndexMode: z.enum(['matched', 'view']).optional(), viewId: z.string().min(1).optional(), ignoreViewQuery: z.coerce.boolean().optional(), limit: z.coerce.number().int().positive().max(MAX_RECORDS_LIMIT).optional(), @@ -77,8 +87,22 @@ export type IListTableRecordsQueryInput = z.input; export interface IListTableRecordsQueryOptions { + /** Trusted host fallback for grouped count metadata. */ + readonly includeGroupMetadata?: boolean; + /** Trusted host-only cap for leaf group buckets. */ + readonly groupLimit?: number; + /** + * Preferred pure-V2 permission scope (row filter + field allow-list + masks). + * When set, handlers should not depend on outer permission CTEs. + */ + readonly queryScope?: RecordQueryPluginScope; + /** + * @deprecated Prefer {@link queryScope}. Kept for transitional CTE-based reads. + */ readonly recordReadQuerySource?: IRecordReadQuerySource; readonly recordSearchAccessPath?: IRecordSearchAccessPath; + readonly includeSearchFieldMatches?: boolean; + readonly searchIndexMode?: 'matched' | 'view'; } export class ListTableRecordsQuery { @@ -95,8 +119,13 @@ export class ListTableRecordsQuery { readonly selectedRecordIds?: ReadonlyArray, readonly projection?: ReadonlyArray, readonly includeTotal?: boolean, + readonly includeSearchFieldMatches?: boolean, + readonly searchIndexMode?: 'matched' | 'view', + readonly includeGroupMetadata?: boolean, + readonly groupLimit?: number, readonly viewId?: string, readonly ignoreViewQuery?: boolean, + readonly queryScope?: RecordQueryPluginScope, readonly recordReadQuerySource?: IRecordReadQuerySource, readonly recordSearchAccessPath?: IRecordSearchAccessPath ) {} @@ -116,27 +145,38 @@ export class ListTableRecordsQuery { } return TableId.create(parsed.data.tableId).andThen((tableId) => - this.buildPagination(parsed.data).map( - (pagination) => - new ListTableRecordsQuery( - tableId, - parsed.data.filter, - pagination, - parsed.data.fieldKeyType, - parsed.data.sort, - parsed.data.search, - parsed.data.groupBy, - parsed.data.filterLinkCellSelected, - parsed.data.filterLinkCellCandidate, - parsed.data.selectedRecordIds, - parsed.data.projection, - parsed.data.includeTotal, - parsed.data.viewId, - parsed.data.ignoreViewQuery, - options?.recordReadQuerySource, - options?.recordSearchAccessPath - ) - ) + this.buildPagination(parsed.data).map((pagination) => { + const includeGroupMetadata = + parsed.data.includeGroups ?? options?.includeGroupMetadata ?? false; + const groupLimit = includeGroupMetadata + ? Math.max(1, Math.floor(options?.groupLimit ?? DEFAULT_GROUP_METADATA_LIMIT)) + : undefined; + + return new ListTableRecordsQuery( + tableId, + parsed.data.filter, + pagination, + parsed.data.fieldKeyType, + parsed.data.sort, + parsed.data.search, + parsed.data.groupBy, + parsed.data.filterLinkCellSelected, + parsed.data.filterLinkCellCandidate, + parsed.data.selectedRecordIds, + parsed.data.projection, + parsed.data.includeTotal, + parsed.data.includeSearchMatches ?? options?.includeSearchFieldMatches, + parsed.data.searchIndexMode ?? options?.searchIndexMode, + includeGroupMetadata, + groupLimit, + parsed.data.viewId, + parsed.data.ignoreViewQuery, + options?.queryScope, + // Prefer queryScope: do not pass CTE source when scope is present. + options?.queryScope ? undefined : options?.recordReadQuerySource, + options?.recordSearchAccessPath + ); + }) ); } diff --git a/packages/v2/core/src/queries/ListViewsHandler.spec.ts b/packages/v2/core/src/queries/ListViewsHandler.spec.ts new file mode 100644 index 0000000000..8a8ce97c23 --- /dev/null +++ b/packages/v2/core/src/queries/ListViewsHandler.spec.ts @@ -0,0 +1,316 @@ +import { err } from 'neverthrow'; +import { describe, expect, it } from 'vitest'; + +import { BaseId } from '../domain/base/BaseId'; +import { ActorId } from '../domain/shared/ActorId'; +import { domainError } from '../domain/shared/DomainError'; +import { FieldId } from '../domain/table/fields/FieldId'; +import { FieldName } from '../domain/table/fields/FieldName'; +import { Table } from '../domain/table/Table'; +import { TableId } from '../domain/table/TableId'; +import { TableName } from '../domain/table/TableName'; +import { GridView } from '../domain/table/views/types/GridView'; +import { KanbanView } from '../domain/table/views/types/KanbanView'; +import { ViewAuditMetadata } from '../domain/table/views/ViewAuditMetadata'; +import { ViewColumnMeta } from '../domain/table/views/ViewColumnMeta'; +import { ViewId } from '../domain/table/views/ViewId'; +import { ViewName } from '../domain/table/views/ViewName'; +import { ViewProperties } from '../domain/table/views/ViewProperties'; +import { ViewQueryDefaults } from '../domain/table/views/ViewQueryDefaults'; +import { NoopLogger } from '../ports/defaults/NoopLogger'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import { MemoryTableRepository } from '../ports/memory/MemoryTableRepository'; +import type { ITableRepository } from '../ports/TableRepository'; +import { ListViewsHandler } from './ListViewsHandler'; +import { ListViewsQuery } from './ListViewsQuery'; + +const createContext = (): IExecutionContext => ({ + actorId: ActorId.create('system')._unsafeUnwrap(), +}); + +const fieldId = FieldId.create(`fld${'a'.repeat(16)}`)._unsafeUnwrap(); + +const initializeView = ( + view: GridView | KanbanView, + options: { + withAuditMetadata?: boolean; + columnMeta?: Record; + queryDefaults?: ViewQueryDefaults; + } = {} +) => { + view + .setColumnMeta( + ViewColumnMeta.rehydrate( + options.columnMeta ?? { + [fieldId.toString()]: { order: 0, width: 220 }, + } + )._unsafeUnwrap() + ) + ._unsafeUnwrap(); + view + .setQueryDefaults(options.queryDefaults ?? ViewQueryDefaults.rehydrate({})._unsafeUnwrap()) + ._unsafeUnwrap(); + if (options.withAuditMetadata !== false) { + view + .setAuditMetadata( + ViewAuditMetadata.rehydrate({ + createdBy: 'system', + createdTime: '2026-07-27T00:00:00.000Z', + })._unsafeUnwrap() + ) + ._unsafeUnwrap(); + } + return view; +}; + +const buildTable = (options: { secondViewAuditMetadata?: boolean } = {}) => { + const builder = Table.builder() + .withBaseId(BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap()) + .withId(TableId.create(`tbl${'a'.repeat(16)}`)._unsafeUnwrap()) + .withName(TableName.create('Views')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(fieldId) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .done(); + builder.view().defaultGrid().done(); + const baseTable = builder.build()._unsafeUnwrap(); + + const gridView = initializeView( + GridView.create({ + id: ViewId.create(`viw${'a'.repeat(16)}`)._unsafeUnwrap(), + name: ViewName.create('First')._unsafeUnwrap(), + properties: ViewProperties.create({ + description: 'Rich view', + isLocked: true, + enableShare: true, + shareId: 'shr-list', + shareMeta: { allowCopy: true }, + })._unsafeUnwrap(), + })._unsafeUnwrap(), + { + columnMeta: { + [fieldId.toString()]: { order: 0, width: 220 }, + staleField: { order: 1, width: 300 }, + }, + queryDefaults: ViewQueryDefaults.rehydrate( + { + sort: [{ fieldId: fieldId.toString(), order: 'asc' }], + manualSort: false, + group: [{ fieldId: fieldId.toString(), order: 'desc' }], + }, + { + sourceFilter: { + conjunction: 'and', + filterSet: [{ fieldId: fieldId.toString(), operator: 'is', value: 'Open' }], + }, + } + )._unsafeUnwrap(), + } + ); + gridView.setOptions({ frozenColumnCount: 1 })._unsafeUnwrap(); + + const kanbanView = initializeView( + KanbanView.create({ + id: ViewId.create(`viw${'b'.repeat(16)}`)._unsafeUnwrap(), + name: ViewName.create('Second')._unsafeUnwrap(), + })._unsafeUnwrap(), + { withAuditMetadata: options.secondViewAuditMetadata !== false } + ); + + return Table.rehydrate({ + id: baseTable.id(), + baseId: baseTable.baseId(), + name: baseTable.name(), + fields: baseTable.getFields(), + views: [gridView, kanbanView], + primaryFieldId: baseTable.primaryFieldId(), + })._unsafeUnwrap(); +}; + +describe('ListViewsQuery', () => { + it('creates a nominal Table ID', () => { + const table = buildTable(); + const result = ListViewsQuery.create({ tableId: table.id().toString() })._unsafeUnwrap(); + + expect(result.tableId.equals(table.id())).toBe(true); + expect(result.viewIds).toBeUndefined(); + }); + + it('rejects an invalid Table ID', () => { + const result = ListViewsQuery.create({ tableId: 'invalid' }); + + expect(result._unsafeUnwrapErr().code).toBe('validation.invalid'); + }); + + it('validates and deduplicates an optional View projection', () => { + const table = buildTable(); + const viewId = table.views()[1].id().toString(); + const result = ListViewsQuery.create({ + tableId: table.id().toString(), + viewIds: [viewId, viewId], + })._unsafeUnwrap(); + + expect(result.viewIds?.map((id) => id.toString())).toEqual([viewId]); + expect( + ListViewsQuery.create({ + tableId: table.id().toString(), + viewIds: ['invalid'], + })._unsafeUnwrapErr().code + ).toBe('validation.invalid'); + }); +}); + +describe('ListViewsHandler', () => { + it('projects every View child in aggregate order and preserves public properties', async () => { + const context = createContext(); + const table = buildTable(); + const repository = new MemoryTableRepository(); + await repository.insert(context, table); + const handler = new ListViewsHandler(repository, new NoopLogger()); + + const result = await handler.handle( + context, + ListViewsQuery.create({ tableId: table.id().toString() })._unsafeUnwrap() + ); + + expect(result._unsafeUnwrap().views).toEqual([ + { + id: `viw${'a'.repeat(16)}`, + name: 'First', + type: 'grid', + description: 'Rich view', + options: { frozenColumnCount: 1 }, + filter: { + conjunction: 'and', + filterSet: [{ fieldId: fieldId.toString(), operator: 'is', value: 'Open' }], + }, + sort: { + sortObjs: [{ fieldId: fieldId.toString(), order: 'asc' }], + manualSort: false, + }, + group: [{ fieldId: fieldId.toString(), order: 'desc' }], + isLocked: true, + enableShare: true, + shareId: 'shr-list', + shareMeta: { allowCopy: true }, + createdBy: 'system', + createdTime: '2026-07-27T00:00:00.000Z', + columnMeta: { [fieldId.toString()]: { order: 0, width: 220 } }, + }, + { + id: `viw${'b'.repeat(16)}`, + name: 'Second', + type: 'kanban', + createdBy: 'system', + createdTime: '2026-07-27T00:00:00.000Z', + columnMeta: { [fieldId.toString()]: { order: 0, width: 220 } }, + }, + ]); + }); + + it('maps a missing Table aggregate to table.not_found', async () => { + const table = buildTable(); + const handler = new ListViewsHandler(new MemoryTableRepository(), new NoopLogger()); + + const result = await handler.handle( + createContext(), + ListViewsQuery.create({ tableId: table.id().toString() })._unsafeUnwrap() + ); + + expect(result._unsafeUnwrapErr()).toMatchObject({ + code: 'table.not_found', + message: 'Table not found', + }); + }); + + it('returns only projected View children in aggregate order', async () => { + const context = createContext(); + const table = buildTable(); + const repository = new MemoryTableRepository(); + await repository.insert(context, table); + const handler = new ListViewsHandler(repository, new NoopLogger()); + const projectedId = table.views()[1].id().toString(); + + const result = await handler.handle( + context, + ListViewsQuery.create({ + tableId: table.id().toString(), + viewIds: [projectedId, `viw${'z'.repeat(16)}`], + })._unsafeUnwrap() + ); + + expect(result._unsafeUnwrap().views.map((view) => view.id)).toEqual([projectedId]); + }); + + it('returns an empty projection without loading the repository', async () => { + const table = buildTable(); + const repository = { + findOne: async () => { + throw new Error('repository should not be called'); + }, + } as unknown as ITableRepository; + const handler = new ListViewsHandler(repository, new NoopLogger()); + + const result = await handler.handle( + createContext(), + ListViewsQuery.create({ + tableId: table.id().toString(), + viewIds: [], + })._unsafeUnwrap() + ); + + expect(result._unsafeUnwrap().views).toEqual([]); + }); + + it('propagates unexpected Table repository failures', async () => { + const table = buildTable(); + const repository = { + findOne: async () => err(domainError.unexpected({ message: 'query failed' })), + } as unknown as ITableRepository; + const handler = new ListViewsHandler(repository, new NoopLogger()); + + const result = await handler.handle( + createContext(), + ListViewsQuery.create({ tableId: table.id().toString() })._unsafeUnwrap() + ); + + expect(result._unsafeUnwrapErr().message).toBe('query failed'); + }); + + it('fails when any hydrated View child is missing required projection metadata', async () => { + const context = createContext(); + const table = buildTable({ secondViewAuditMetadata: false }); + const repository = new MemoryTableRepository(); + await repository.insert(context, table); + const handler = new ListViewsHandler(repository, new NoopLogger()); + + const result = await handler.handle( + context, + ListViewsQuery.create({ tableId: table.id().toString() })._unsafeUnwrap() + ); + + expect(result._unsafeUnwrapErr().message).toBe('ViewAuditMetadata not set'); + }); + + it('does not project metadata from unauthorized View children', async () => { + const context = createContext(); + const table = buildTable({ secondViewAuditMetadata: false }); + const repository = new MemoryTableRepository(); + await repository.insert(context, table); + const handler = new ListViewsHandler(repository, new NoopLogger()); + + const result = await handler.handle( + context, + ListViewsQuery.create({ + tableId: table.id().toString(), + viewIds: [table.views()[0].id().toString()], + })._unsafeUnwrap() + ); + + expect(result._unsafeUnwrap().views.map((view) => view.id)).toEqual([ + table.views()[0].id().toString(), + ]); + }); +}); diff --git a/packages/v2/core/src/queries/ListViewsHandler.ts b/packages/v2/core/src/queries/ListViewsHandler.ts new file mode 100644 index 0000000000..f6449f67c9 --- /dev/null +++ b/packages/v2/core/src/queries/ListViewsHandler.ts @@ -0,0 +1,73 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError'; +import { Table } from '../domain/table/Table'; +import type { IExecutionContext } from '../ports/ExecutionContext'; +import * as LoggerPort from '../ports/Logger'; +import * as TableRepositoryPort from '../ports/TableRepository'; +import { v2CoreTokens } from '../ports/tokens'; +import { ListViewsQuery } from './ListViewsQuery'; +import { QueryHandler, type IQueryHandler } from './QueryHandler'; +import { projectViewForQuery, type ViewQueryResultView } from './ViewQueryProjection'; + +export class ListViewsResult { + private constructor(readonly views: ReadonlyArray) {} + + static create(views: ReadonlyArray): ListViewsResult { + return new ListViewsResult(views); + } +} + +@QueryHandler(ListViewsQuery) +@injectable() +export class ListViewsHandler implements IQueryHandler { + constructor( + @inject(v2CoreTokens.tableRepository) + private readonly tableRepository: TableRepositoryPort.ITableRepository, + @inject(v2CoreTokens.logger) + private readonly logger: LoggerPort.ILogger + ) {} + + async handle( + context: IExecutionContext, + query: ListViewsQuery + ): Promise> { + const logger = this.logger.scope('query', { name: ListViewsHandler.name }).child({ + tableId: query.tableId.toString(), + }); + logger.debug('ListViewsHandler.start', { actorId: context.actorId.toString() }); + + if (query.viewIds?.length === 0) { + return ok(ListViewsResult.create([])); + } + + const specBuilder = Table.specs().byId(query.tableId); + if (query.viewIds) specBuilder.withViewIds(query.viewIds); + const specResult = specBuilder.build(); + if (specResult.isErr()) return err(specResult.error); + + const tableResult = await this.tableRepository.findOne(context, specResult.value); + if (tableResult.isErr()) { + if (isNotFoundError(tableResult.error)) { + return err(domainError.notFound({ code: 'table.not_found', message: 'Table not found' })); + } + return err(tableResult.error); + } + + const projectedViewIds = query.viewIds + ? new Set(query.viewIds.map((viewId) => viewId.toString())) + : undefined; + const views: ViewQueryResultView[] = []; + for (const view of tableResult.value.views()) { + if (projectedViewIds && !projectedViewIds.has(view.id().toString())) continue; + const viewResult = projectViewForQuery(tableResult.value, view); + if (viewResult.isErr()) return err(viewResult.error); + views.push(viewResult.value); + } + + logger.debug('ListViewsHandler.success', { count: views.length }); + return ok(ListViewsResult.create(views)); + } +} diff --git a/packages/v2/core/src/queries/ListViewsQuery.ts b/packages/v2/core/src/queries/ListViewsQuery.ts new file mode 100644 index 0000000000..3531595d1d --- /dev/null +++ b/packages/v2/core/src/queries/ListViewsQuery.ts @@ -0,0 +1,45 @@ +import { err } from 'neverthrow'; +import type { Result } from 'neverthrow'; +import { z } from 'zod'; + +import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { TableId } from '../domain/table/TableId'; +import { ViewId } from '../domain/table/views/ViewId'; + +export const listViewsInputSchema = z.object({ + tableId: z.string(), + viewIds: z.array(z.string()).optional(), +}); + +export type IListViewsQueryInput = z.input; + +export class ListViewsQuery { + private constructor( + readonly tableId: TableId, + readonly viewIds: ReadonlyArray | undefined + ) {} + + static create(raw: unknown): Result { + const parsed = listViewsInputSchema.safeParse(raw); + if (!parsed.success) { + return err(domainError.validation({ message: 'Invalid ListViewsQuery input' })); + } + + const viewIds: ViewId[] | undefined = parsed.data.viewIds ? [] : undefined; + if (viewIds) { + const seen = new Set(); + for (const rawViewId of parsed.data.viewIds ?? []) { + const viewIdResult = ViewId.create(rawViewId); + if (viewIdResult.isErr()) return err(viewIdResult.error); + const viewId = viewIdResult.value; + if (seen.has(viewId.toString())) continue; + seen.add(viewId.toString()); + viewIds.push(viewId); + } + } + + return TableId.create(parsed.data.tableId).map( + (tableId) => new ListViewsQuery(tableId, viewIds) + ); + } +} diff --git a/packages/v2/core/src/queries/RecordFilterDto.spec.ts b/packages/v2/core/src/queries/RecordFilterDto.spec.ts index 6e9f974f72..d1fae8107a 100644 --- a/packages/v2/core/src/queries/RecordFilterDto.spec.ts +++ b/packages/v2/core/src/queries/RecordFilterDto.spec.ts @@ -108,6 +108,20 @@ describe('RecordFilterDto', () => { expect(nullable.success).toBe(true); }); + it('accepts ISO date values without millisecond precision', () => { + const result = recordFilterConditionSchema.safeParse({ + fieldId: 'fld123', + operator: 'is', + value: { + mode: 'exactDate', + exactDate: '2026-07-01T00:00:00Z', + timeZone: 'UTC', + }, + }); + + expect(result.success).toBe(true); + }); + it('detects node shapes', () => { const condition: RecordFilterCondition = { fieldId: 'fld123', operator: 'is', value: 'a' }; const group: RecordFilterGroup = { conjunction: 'and', items: [condition] }; diff --git a/packages/v2/core/src/queries/RecordFilterDto.ts b/packages/v2/core/src/queries/RecordFilterDto.ts index 349dc54e88..d15756e1cf 100644 --- a/packages/v2/core/src/queries/RecordFilterDto.ts +++ b/packages/v2/core/src/queries/RecordFilterDto.ts @@ -18,10 +18,20 @@ const dateValueSchema = z .object({ mode: recordFilterDateModeSchema, numberOfDays: z.coerce.number().int().nonnegative().optional(), - exactDate: z.string().datetime({ precision: 3, offset: true }).optional(), + exactDate: z.string().datetime({ offset: true }).optional(), + exactDateEnd: z.string().datetime({ offset: true }).optional(), timeZone: z.string(), }) .superRefine((val, ctx) => { + if (val.mode === 'dateRange') { + if (!val.exactDate || !val.exactDateEnd) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "When mode is 'dateRange', exactDate and exactDateEnd are required", + }); + } + return; + } const requiresExact = val.mode === 'exactDate' || val.mode === 'exactDateTime' || val.mode === 'exactFormatDate'; const requiresDays = diff --git a/packages/v2/core/src/queries/RecordFilterMapper.spec.ts b/packages/v2/core/src/queries/RecordFilterMapper.spec.ts index 7b6047ecad..121cff18d5 100644 --- a/packages/v2/core/src/queries/RecordFilterMapper.spec.ts +++ b/packages/v2/core/src/queries/RecordFilterMapper.spec.ts @@ -1,8 +1,13 @@ import { describe, expect, it } from 'vitest'; import { BaseId } from '../domain/base/BaseId'; +import { FieldId } from '../domain/table/fields/FieldId'; import { FieldName } from '../domain/table/fields/FieldName'; +import { LinkFieldConfig } from '../domain/table/fields/types/LinkFieldConfig'; +import { LinkRelationship } from '../domain/table/fields/types/LinkRelationship'; +import { LookupOptions } from '../domain/table/fields/types/LookupOptions'; import { SelectOption } from '../domain/table/fields/types/SelectOption'; +import { SingleLineTextField } from '../domain/table/fields/types/SingleLineTextField'; import { RecordId } from '../domain/table/records/RecordId'; import { TableRecord } from '../domain/table/records/TableRecord'; import { TableRecordCellValue } from '../domain/table/records/TableRecordFields'; @@ -20,6 +25,7 @@ const baseId = (seed: string) => BaseId.create(`bse${seed.repeat(16)}`)._unsafeU const recordId = (seed: string) => RecordId.create(`rec${seed.repeat(16)}`)._unsafeUnwrap(); const cell = (value: unknown) => TableRecordCellValue.create(value)._unsafeUnwrap(); const selectOption = (name: string) => SelectOption.create({ name, color: 'blue' })._unsafeUnwrap(); +const fieldId = (seed: string) => FieldId.create(`fld${seed.repeat(16)}`)._unsafeUnwrap(); const buildTable = () => { const builder = Table.builder() @@ -39,11 +45,68 @@ const buildTable = () => { builder.field().user().withName(FieldName.create('Owner')._unsafeUnwrap()).done(); builder.field().createdBy().withName(FieldName.create('Creator')._unsafeUnwrap()).done(); builder.field().lastModifiedBy().withName(FieldName.create('Modifier')._unsafeUnwrap()).done(); + builder + .field() + .multipleSelect() + .withName(FieldName.create('Tags')._unsafeUnwrap()) + .withOptions([selectOption('a'), selectOption('b')]) + .done(); builder.view().defaultGrid().done(); return builder.build()._unsafeUnwrap(); }; +const buildTextLookupTable = () => { + const foreignTableId = TableId.create(`tbl${'l'.repeat(16)}`)._unsafeUnwrap(); + const foreignFieldId = fieldId('m'); + const linkId = fieldId('n'); + const lookupId = fieldId('o'); + const builder = Table.builder() + .withBaseId(baseId('l')) + .withName(TableName.create('Lookup Records')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withName(FieldName.create('Title')._unsafeUnwrap()) + .primary() + .done(); + builder + .field() + .link() + .withId(linkId) + .withName(FieldName.create('Link')._unsafeUnwrap()) + .withConfig( + LinkFieldConfig.create({ + relationship: LinkRelationship.manyOne().toString(), + foreignTableId: foreignTableId.toString(), + lookupFieldId: foreignFieldId.toString(), + })._unsafeUnwrap() + ) + .done(); + builder + .field() + .lookup() + .withId(lookupId) + .withName(FieldName.create('Lookup Text')._unsafeUnwrap()) + .withInnerField( + SingleLineTextField.create({ + id: foreignFieldId, + name: FieldName.create('Foreign Text')._unsafeUnwrap(), + })._unsafeUnwrap() + ) + .withLookupOptions( + LookupOptions.create({ + linkFieldId: linkId.toString(), + foreignTableId: foreignTableId.toString(), + lookupFieldId: foreignFieldId.toString(), + })._unsafeUnwrap() + ) + .withIsMultipleCellValue(false) + .done(); + builder.view().defaultGrid().done(); + return { table: builder.build()._unsafeUnwrap(), lookupId }; +}; + const buildRecord = (table: Table) => { const titleField = table.getField((field) => field.name().toString() === 'Title')._unsafeUnwrap(); const refField = table.getField((field) => field.name().toString() === 'Ref')._unsafeUnwrap(); @@ -120,6 +183,395 @@ describe('RecordFilterMapper', () => { expect(result._unsafeUnwrap().isSatisfiedBy(record)).toBe(true); }); + it('does not re-include hidden rows under NOT of a masked field condition', () => { + const table = buildTable(); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const titleId = titleField.id().toString(); + // Mask never satisfied → field always hidden (CASE WHEN → null). + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => { + throw new Error('not used'); + }, + accept: () => { + throw new Error('not used'); + }, + } as never; + + const filter: RecordFilter = { + not: { + fieldId: titleId, + operator: 'is', + value: 'Secret', + }, + }; + + // NOT(leaf) isTrue = leaf.isFalse = mask AND NOT cond → false when hidden. + const hiddenRecord = TableRecord.fromRawFieldValues({ + id: recordId('h').toString(), + tableId: table.id(), + fields: { [titleId]: 'Secret' }, + })._unsafeUnwrap(); + + const result = buildRecordConditionSpec(table, filter, [ + { fieldId: titleId, visibleWhen: neverVisible }, + ]); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().isSatisfiedBy(hiddenRecord)).toBe(false); + }); + + it('preserves SQL three-valued NOT(AND) when a masked operand is unknown', () => { + const table = buildTable(); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const doneField = table.getField((field) => field.name().toString() === 'Done')._unsafeUnwrap(); + const titleId = titleField.id().toString(); + const doneId = doneField.id().toString(); + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => { + throw new Error('not used'); + }, + accept: () => { + throw new Error('not used'); + }, + } as never; + + // NOT(maskedTitle = Secret AND Done is true) + // When Title is hidden and Done is false: SQL UNKNOWN AND false = false → NOT = true. + const filter: RecordFilter = { + not: { + conjunction: 'and', + items: [ + { fieldId: titleId, operator: 'is', value: 'Secret' }, + { fieldId: doneId, operator: 'is', value: true }, + ], + }, + }; + + const record = TableRecord.fromRawFieldValues({ + id: recordId('c').toString(), + tableId: table.id(), + fields: { + [titleId]: 'Secret', + [doneId]: false, + }, + })._unsafeUnwrap(); + + const result = buildRecordConditionSpec(table, filter, [ + { fieldId: titleId, visibleWhen: neverVisible }, + ]); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().isSatisfiedBy(record)).toBe(true); + }); + + it('matches hidden rows for null-is-true operators (isNot)', () => { + const table = buildTable(); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const titleId = titleField.id().toString(); + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => { + throw new Error('not used'); + }, + accept: () => { + throw new Error('not used'); + }, + } as never; + + // CASE WHEN false THEN value END isNot 'X' ⇔ NULL IS DISTINCT FROM 'X' ⇔ true + const filter: RecordFilter = { + fieldId: titleId, + operator: 'isNot', + value: 'Secret', + }; + const hiddenRecord = TableRecord.fromRawFieldValues({ + id: recordId('n').toString(), + tableId: table.id(), + fields: { [titleId]: 'Secret' }, + })._unsafeUnwrap(); + + const result = buildRecordConditionSpec(table, filter, [ + { fieldId: titleId, visibleWhen: neverVisible }, + ]); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().isSatisfiedBy(hiddenRecord)).toBe(true); + }); + + it('matches NOT(hidden isNotEmpty) because isNotEmpty is false on NULL', () => { + const table = buildTable(); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const titleId = titleField.id().toString(); + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => { + throw new Error('not used'); + }, + accept: () => { + throw new Error('not used'); + }, + } as never; + + // isNotEmpty on NULL is definite false → NOT is true + const filter: RecordFilter = { + not: { + fieldId: titleId, + operator: 'isNotEmpty', + value: null, + }, + }; + const hiddenRecord = TableRecord.fromRawFieldValues({ + id: recordId('e').toString(), + tableId: table.id(), + fields: { [titleId]: 'Secret' }, + })._unsafeUnwrap(); + + const result = buildRecordConditionSpec(table, filter, [ + { fieldId: titleId, visibleWhen: neverVisible }, + ]); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().isSatisfiedBy(hiddenRecord)).toBe(true); + }); + + it('matches hidden checkbox is+null (canonical is false / unchecked)', () => { + const table = buildTable(); + const doneField = table.getField((field) => field.name().toString() === 'Done')._unsafeUnwrap(); + const doneId = doneField.id().toString(); + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => { + throw new Error('not used'); + }, + accept: () => { + throw new Error('not used'); + }, + } as never; + + // V1: is + null → is false; SQL: false OR null → true when hidden + const filter: RecordFilter = { + fieldId: doneId, + operator: 'is', + value: null, + }; + const hiddenRecord = TableRecord.fromRawFieldValues({ + id: recordId('u').toString(), + tableId: table.id(), + fields: { [doneId]: true }, + })._unsafeUnwrap(); + + const result = buildRecordConditionSpec(table, filter, [ + { fieldId: doneId, visibleWhen: neverVisible }, + ]); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().isSatisfiedBy(hiddenRecord)).toBe(true); + }); + + it('does not match hidden checkbox isNot+null (canonical is true / checked)', () => { + const table = buildTable(); + const doneField = table.getField((field) => field.name().toString() === 'Done')._unsafeUnwrap(); + const doneId = doneField.id().toString(); + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => { + throw new Error('not used'); + }, + accept: () => { + throw new Error('not used'); + }, + } as never; + + // V1: isNot + null → is true; SQL: col = true does not match NULL when hidden + const filter: RecordFilter = { + fieldId: doneId, + operator: 'isNot', + value: null, + }; + const hiddenRecord = TableRecord.fromRawFieldValues({ + id: recordId('k').toString(), + tableId: table.id(), + fields: { [doneId]: false }, + })._unsafeUnwrap(); + + const result = buildRecordConditionSpec(table, filter, [ + { fieldId: doneId, visibleWhen: neverVisible }, + ]); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().isSatisfiedBy(hiddenRecord)).toBe(false); + }); + + it('matches NOT(hasAnyOf) on hidden multi-value field (NULL → [] → false)', () => { + const table = buildTable(); + const tagsField = table.getField((field) => field.name().toString() === 'Tags')._unsafeUnwrap(); + const tagsId = tagsField.id().toString(); + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => { + throw new Error('not used'); + }, + accept: () => { + throw new Error('not used'); + }, + } as never; + + const filter: RecordFilter = { + not: { + fieldId: tagsId, + operator: 'hasAnyOf', + value: ['a'], + }, + }; + const hiddenRecord = TableRecord.fromRawFieldValues({ + id: recordId('m').toString(), + tableId: table.id(), + fields: { [tagsId]: ['a'] }, + })._unsafeUnwrap(); + + const result = buildRecordConditionSpec(table, filter, [ + { fieldId: tagsId, visibleWhen: neverVisible }, + ]); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().isSatisfiedBy(hiddenRecord)).toBe(true); + }); + + it('does not match hidden text is (ordinary equality → UNKNOWN)', () => { + const table = buildTable(); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const titleId = titleField.id().toString(); + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => { + throw new Error('not used'); + }, + accept: () => { + throw new Error('not used'); + }, + } as never; + + const filter: RecordFilter = { + fieldId: titleId, + operator: 'is', + value: 'false', + }; + const hiddenRecord = TableRecord.fromRawFieldValues({ + id: recordId('b').toString(), + tableId: table.id(), + fields: { [titleId]: 'x' }, + })._unsafeUnwrap(); + + const result = buildRecordConditionSpec(table, filter, [ + { fieldId: titleId, visibleWhen: neverVisible }, + ]); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().isSatisfiedBy(hiddenRecord)).toBe(false); + }); + + it('does not match hidden doesNotContain empty string (NOT ILIKE %% is false)', () => { + const table = buildTable(); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const titleId = titleField.id().toString(); + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => { + throw new Error('not used'); + }, + accept: () => { + throw new Error('not used'); + }, + } as never; + + const filter: RecordFilter = { + fieldId: titleId, + operator: 'doesNotContain', + value: '', + }; + const hiddenRecord = TableRecord.fromRawFieldValues({ + id: recordId('z').toString(), + tableId: table.id(), + fields: { [titleId]: 'secret' }, + })._unsafeUnwrap(); + + const result = buildRecordConditionSpec(table, filter, [ + { fieldId: titleId, visibleWhen: neverVisible }, + ]); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().isSatisfiedBy(hiddenRecord)).toBe(false); + }); + + it('rejects masked LHS compared to a field-reference RHS (row-dependent NULL)', () => { + const table = buildTable(); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const refField = table.getField((field) => field.name().toString() === 'Ref')._unsafeUnwrap(); + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => { + throw new Error('not used'); + }, + accept: () => { + throw new Error('not used'); + }, + } as never; + + const filter: RecordFilter = { + fieldId: titleField.id().toString(), + operator: 'isNot', + value: { + type: 'field', + fieldId: refField.id().toString(), + }, + }; + + const result = buildRecordConditionSpec(table, filter, [ + { fieldId: titleField.id().toString(), visibleWhen: neverVisible }, + ]); + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().code).toBe('record.filter.masked_field_reference_lhs'); + }); + + it('rejects filter RHS field-references to conditionally masked fields', () => { + const table = buildTable(); + const titleField = table + .getField((field) => field.name().toString() === 'Title') + ._unsafeUnwrap(); + const refField = table.getField((field) => field.name().toString() === 'Ref')._unsafeUnwrap(); + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => { + throw new Error('not used'); + }, + accept: () => { + throw new Error('not used'); + }, + } as never; + + const filter: RecordFilter = { + fieldId: titleField.id().toString(), + operator: 'is', + value: { + type: 'field', + fieldId: refField.id().toString(), + }, + }; + + const result = buildRecordConditionSpec(table, filter, [ + { fieldId: refField.id().toString(), visibleWhen: neverVisible }, + ]); + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().message).toContain('conditionally masked'); + }); + it('supports not and or groups', () => { const table = buildTable(); const { record, titleField, dueField } = buildRecord(table); @@ -298,4 +750,70 @@ describe('RecordFilterMapper', () => { ], }); }); + + it('does not invert hidden text lookup doesNotContain empty string under NOT', () => { + const { table, lookupId } = buildTextLookupTable(); + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => { + throw new Error('not used'); + }, + accept: () => { + throw new Error('not used'); + }, + } as never; + const filter: RecordFilter = { + not: { + fieldId: lookupId.toString(), + operator: 'doesNotContain', + value: '', + }, + }; + const hiddenRecord = TableRecord.fromRawFieldValues({ + id: recordId('l').toString(), + tableId: table.id(), + fields: {}, + })._unsafeUnwrap(); + + const result = buildRecordConditionSpec(table, filter, [ + { fieldId: lookupId.toString(), visibleWhen: neverVisible }, + ]); + expect(result.isOk()).toBe(true); + // SQL: NOT(NOT jsonb_path_exists([], empty-regex)) = NOT(true) = false. + expect(result._unsafeUnwrap().isSatisfiedBy(hiddenRecord)).toBe(false); + }); + + it('does not match hidden single-select isNoneOf an empty string', () => { + const table = buildTable(); + const statusField = table + .getField((field) => field.name().toString() === 'Status') + ._unsafeUnwrap(); + const statusId = statusField.id().toString(); + const neverVisible = { + isSatisfiedBy: () => false, + mutate: () => { + throw new Error('not used'); + }, + accept: () => { + throw new Error('not used'); + }, + } as never; + const filter: RecordFilter = { + fieldId: statusId, + operator: 'isNoneOf', + value: [''], + }; + const hiddenRecord = TableRecord.fromRawFieldValues({ + id: recordId('p').toString(), + tableId: table.id(), + fields: {}, + })._unsafeUnwrap(); + + const result = buildRecordConditionSpec(table, filter, [ + { fieldId: statusId, visibleWhen: neverVisible }, + ]); + expect(result.isOk()).toBe(true); + // SQL: COALESCE(NULL, '') NOT IN ('') = false. + expect(result._unsafeUnwrap().isSatisfiedBy(hiddenRecord)).toBe(false); + }); }); diff --git a/packages/v2/core/src/queries/RecordFilterMapper.ts b/packages/v2/core/src/queries/RecordFilterMapper.ts index 7062e9f41d..c95b5f81ba 100644 --- a/packages/v2/core/src/queries/RecordFilterMapper.ts +++ b/packages/v2/core/src/queries/RecordFilterMapper.ts @@ -2,12 +2,19 @@ import { err, ok } from 'neverthrow'; import type { Result } from 'neverthrow'; import { domainError, type DomainError } from '../domain/shared/DomainError'; +import { andSpec } from '../domain/shared/specification/AndSpec'; import type { ISpecification } from '../domain/shared/specification/ISpecification'; import { notSpec } from '../domain/shared/specification/NotSpec'; +import { orSpec } from '../domain/shared/specification/OrSpec'; import { FieldId } from '../domain/table/fields/FieldId'; import { FieldType } from '../domain/table/fields/FieldType'; +import { + conditionNullMatch, + conditionNullMatchForSpec, + type ConditionNullMatch, +} from '../domain/table/records/specs/ConditionNullSemantics'; import type { ITableRecordConditionSpecVisitor } from '../domain/table/records/specs/ITableRecordConditionSpecVisitor'; -import { RecordConditionSpecBuilder } from '../domain/table/records/specs/RecordConditionSpecBuilder'; +import type { RecordConditionOperator } from '../domain/table/records/specs/RecordConditionOperators'; import type { RecordConditionValue } from '../domain/table/records/specs/RecordConditionValues'; import { RecordConditionDateValue, @@ -15,9 +22,11 @@ import { RecordConditionLiteralListValue, RecordConditionLiteralValue, } from '../domain/table/records/specs/RecordConditionValues'; +import { RecordValueConditionSpec } from '../domain/table/records/specs/RecordConditionSpec'; import type { TableRecord } from '../domain/table/records/TableRecord'; import type { Table } from '../domain/table/Table'; import { TableId } from '../domain/table/TableId'; +import type { RecordQueryFieldMask } from '../ports/RecordQueryPlugin'; import { isRecordFilterCondition, isRecordFilterDateValue, @@ -29,6 +38,11 @@ import { type RecordFilterValue, } from './RecordFilterDto'; +type FieldMaskMap = ReadonlyMap< + string, + ISpecification +>; + const currentUserFilterValue = 'Me'; const resolveField = (table: Table, rawFieldId: string) => { @@ -74,36 +88,188 @@ const buildConditionValue = ( return RecordConditionLiteralValue.create(rawValue); }; -const buildSpecFromNode = ( +/** + * Three-valued CASE WHEN null-when-hidden semantics, encoded as a dual polarity + * pair for 2-valued WHERE matching: + * - isTrue ⇔ formula is definitely true (WHERE includes row) + * - isFalse ⇔ formula is definitely false (WHERE includes NOT formula) + * + * Algebra (Kleene): + * NOT p: isTrue = p.isFalse, isFalse = p.isTrue + * p AND q: isTrue = p.isTrue ∧ q.isTrue, isFalse = p.isFalse ∨ q.isFalse + * p OR q: isTrue = p.isTrue ∨ q.isTrue, isFalse = p.isFalse ∧ q.isFalse + * + * Leaf NULL match (when mask M is false) comes from {@link conditionNullMatch} + * on the **canonical** operator/value after FieldConditionSpecBuilder: + * - true: isTrue = ¬M ∨ c, isFalse = M ∧ ¬c + * - false: isTrue = M ∧ c, isFalse = ¬M ∨ ¬c + * - unknown: isTrue = M ∧ c, isFalse = M ∧ ¬c + */ +type FilterPolarity = { + readonly isTrue: ISpecification; + readonly isFalse: ISpecification; +}; + +const buildLeafPolarity = ( + nullMatch: Exclude, + conditionSpec: ISpecification, + mask: ISpecification | undefined +): Result => { + return notSpec(conditionSpec).andThen((notCondition) => { + if (!mask) { + return ok({ isTrue: conditionSpec, isFalse: notCondition }); + } + + if (nullMatch === 'true') { + return notSpec(mask).andThen((notMask) => + orSpec(notMask, conditionSpec).andThen((isTrue) => + andSpec(mask, notCondition).map((isFalse) => ({ isTrue, isFalse })) + ) + ); + } + if (nullMatch === 'false') { + return andSpec(mask, conditionSpec).andThen((isTrue) => + notSpec(mask).andThen((notMask) => + orSpec(notMask, notCondition).map((isFalse) => ({ isTrue, isFalse })) + ) + ); + } + // unknown: hidden ⇒ both false. + return andSpec(mask, conditionSpec).andThen((isTrue) => + andSpec(mask, notCondition).map((isFalse) => ({ isTrue, isFalse })) + ); + }); +}; + +/** + * Prefer the built condition **spec type** (CheckboxConditionSpec, + * ConditionalLookupConditionSpec, …) so Lookup<Checkbox> and special + * visitor dispatch stay aligned with SQL. + */ +const resolveCanonicalNullMatch = ( + field: Parameters[0], + conditionSpec: ISpecification, + rawOperator: string +): ConditionNullMatch => { + if (conditionSpec instanceof RecordValueConditionSpec) { + return conditionNullMatchForSpec( + conditionSpec as RecordValueConditionSpec + ); + } + return conditionNullMatch(field, rawOperator as RecordConditionOperator); +}; + +const andPolarities = ( + left: FilterPolarity, + right: FilterPolarity +): Result => + andSpec(left.isTrue, right.isTrue).andThen((isTrue) => + orSpec(left.isFalse, right.isFalse).map((isFalse) => ({ isTrue, isFalse })) + ); + +const orPolarities = ( + left: FilterPolarity, + right: FilterPolarity +): Result => + orSpec(left.isTrue, right.isTrue).andThen((isTrue) => + andSpec(left.isFalse, right.isFalse).map((isFalse) => ({ isTrue, isFalse })) + ); + +const buildPolarityFromNode = ( table: Table, - node: RecordFilterNode -): Result, DomainError> => { + node: RecordFilterNode, + fieldMasks?: FieldMaskMap +): Result => { if (isRecordFilterCondition(node)) { + // RHS field-reference to a masked field would read raw values (relation oracle). + if ( + fieldMasks && + isRecordFilterFieldReferenceValue(node.value) && + fieldMasks.has(node.value.fieldId) + ) { + return err( + domainError.validation({ + message: 'Filter field reference to a conditionally masked field is not allowed', + }) + ); + } + // LHS masked + field-reference RHS: NULL truth is row-dependent + // (NULL IS DISTINCT FROM NULL = false, [] isNotExactly [] = false). Fail closed. + if (fieldMasks?.has(node.fieldId) && isRecordFilterFieldReferenceValue(node.value)) { + return err( + domainError.validation({ + code: 'record.filter.masked_field_reference_lhs', + message: + 'Filter comparing a conditionally masked field to another field is not allowed until mask-aware SQL CASE WHEN is available', + }) + ); + } return resolveField(table, node.fieldId).andThen((field) => buildConditionValue(table, node.value).andThen((value) => - field.spec().create({ operator: node.operator, value }) + field + .spec() + .create({ operator: node.operator, value }) + .andThen((conditionSpec) => { + const mask = fieldMasks?.get(node.fieldId); + if (!mask) { + // No mask: polarity does not rewrite for NULL-when-hidden. + return buildLeafPolarity('unknown', conditionSpec, undefined); + } + const nullMatch = resolveCanonicalNullMatch(field, conditionSpec, node.operator); + if (nullMatch === 'dynamic') { + return err( + domainError.validation({ + code: 'record.filter.masked_dynamic_null', + message: + 'Filter on a conditionally masked field has row-dependent NULL semantics and is not allowed', + }) + ); + } + return buildLeafPolarity(nullMatch, conditionSpec, mask); + }) ) ); } if (isRecordFilterNot(node)) { - return buildSpecFromNode(table, node.not).andThen((spec) => notSpec(spec)); + return buildPolarityFromNode(table, node.not, fieldMasks).map(({ isTrue, isFalse }) => ({ + isTrue: isFalse, + isFalse: isTrue, + })); } if (isRecordFilterGroup(node)) { - const mode = node.conjunction === 'and' ? 'and' : 'or'; - const builder = RecordConditionSpecBuilder.create(mode); + if (!node.items.length) { + return err(domainError.validation({ message: 'Filter group is empty' })); + } + let combined: FilterPolarity | undefined; for (const item of node.items) { - const childResult = buildSpecFromNode(table, item); + const childResult = buildPolarityFromNode(table, item, fieldMasks); if (childResult.isErr()) return err(childResult.error); - builder.addConditionSpec(childResult.value); + if (!combined) { + combined = childResult.value; + continue; + } + const next = + node.conjunction === 'and' + ? andPolarities(combined, childResult.value) + : orPolarities(combined, childResult.value); + if (next.isErr()) return err(next.error); + combined = next.value; } - return builder.build(); + return ok(combined!); } return err(domainError.validation({ message: 'Invalid record filter node' })); }; +const buildSpecFromNode = ( + table: Table, + node: RecordFilterNode, + fieldMasks?: FieldMaskMap +): Result, DomainError> => + buildPolarityFromNode(table, node, fieldMasks).map((polarity) => polarity.isTrue); + const sanitizeNode = ( table: Table, node: RecordFilterNode @@ -209,10 +375,14 @@ export function replaceCurrentUserTagInFilter( export const buildRecordConditionSpec = ( table: Table, - filter: RecordFilter + filter: RecordFilter, + fieldMasks?: ReadonlyArray ): Result, DomainError> => { if (!filter) return err(domainError.validation({ message: 'Filter is empty' })); - return buildSpecFromNode(table, filter); + const maskMap: FieldMaskMap | undefined = fieldMasks?.length + ? new Map(fieldMasks.map((mask) => [mask.fieldId, mask.visibleWhen])) + : undefined; + return buildSpecFromNode(table, filter, maskMap); }; export const sanitizeRecordFilter = ( diff --git a/packages/v2/core/src/queries/RecordSearch.ts b/packages/v2/core/src/queries/RecordSearch.ts index e96663d6df..99842554c7 100644 --- a/packages/v2/core/src/queries/RecordSearch.ts +++ b/packages/v2/core/src/queries/RecordSearch.ts @@ -201,8 +201,24 @@ export class RecordSearch { }); } - private resolveField(table: Table, fieldKey: string): Result { - const field = table.getFields().find((candidate) => this.matchesFieldKey(candidate, fieldKey)); + /** + * Resolve a search field key against a table field. + * Accepts field id, name, or dbFieldName — single source of truth for all + * callers (search execution, masked-field reject, etc.). + */ + static matchesFieldKey(field: Field, fieldKey: string): boolean { + if (field.id().toString() === fieldKey || field.name().toString() === fieldKey) { + return true; + } + + const dbFieldNameResult = field.dbFieldName().andThen((dbFieldName) => dbFieldName.value()); + return dbFieldNameResult.isOk() && dbFieldNameResult.value === fieldKey; + } + + static resolveFieldKey(table: Table, fieldKey: string): Result { + const field = table + .getFields() + .find((candidate) => RecordSearch.matchesFieldKey(candidate, fieldKey)); if (!field) { return err( @@ -216,6 +232,10 @@ export class RecordSearch { return ok(field); } + private resolveField(table: Table, fieldKey: string): Result { + return RecordSearch.resolveFieldKey(table, fieldKey); + } + private buildFieldCondition( field: Field ): Result { @@ -254,13 +274,4 @@ export class RecordSearch { return ok(getValidRecordConditionOperators(field, valueTypeResult.value).includes('contains')); } - - private matchesFieldKey(field: Field, fieldKey: string): boolean { - if (field.id().toString() === fieldKey || field.name().toString() === fieldKey) { - return true; - } - - const dbFieldNameResult = field.dbFieldName().andThen((dbFieldName) => dbFieldName.value()); - return dbFieldNameResult.isOk() && dbFieldNameResult.value === fieldKey; - } } diff --git a/packages/v2/core/src/queries/ViewQueryProjection.ts b/packages/v2/core/src/queries/ViewQueryProjection.ts new file mode 100644 index 0000000000..778d725e17 --- /dev/null +++ b/packages/v2/core/src/queries/ViewQueryProjection.ts @@ -0,0 +1,101 @@ +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../domain/shared/DomainError'; +import type { Table } from '../domain/table/Table'; +import type { View } from '../domain/table/views/View'; +import { + getDefaultViewColumnOrderByFieldId, + type ViewColumnMetaValue, +} from '../domain/table/views/ViewColumnMeta'; +import type { ViewShareMetaValue } from '../domain/table/views/ViewProperties'; +import type { + ViewQueryGroupItem, + ViewQuerySortItem, +} from '../domain/table/views/ViewQueryDefaults'; + +export type ViewQueryResultView = { + id: string; + version?: number; + name: string; + type: 'grid' | 'kanban' | 'gallery' | 'calendar' | 'form' | 'plugin'; + description?: string; + order?: number; + options?: unknown; + filter?: unknown; + sort?: { + sortObjs: ReadonlyArray; + manualSort?: boolean; + }; + group?: ReadonlyArray; + isLocked?: boolean; + shareId?: string; + enableShare?: boolean; + shareMeta?: ViewShareMetaValue; + createdBy: string; + lastModifiedBy?: string; + createdTime: string; + lastModifiedTime?: string; + columnMeta: ViewColumnMetaValue; +}; + +export const projectViewForQuery = ( + table: Table, + view: View +): Result => + safeTry(function* () { + const columnMeta = yield* view.columnMeta(); + const queryDefaults = yield* view.queryDefaults(); + const auditMetadata = yield* view.auditMetadata(); + const fields = table.getFields(); + const defaultOrderByFieldId = getDefaultViewColumnOrderByFieldId( + fields, + table.primaryFieldId() + ); + const rawColumnMeta = columnMeta.toDto(); + const sanitizedColumnMeta = Object.fromEntries( + Object.entries(rawColumnMeta) + .filter(([fieldId]) => defaultOrderByFieldId.has(fieldId)) + .map(([fieldId, entry]) => [ + fieldId, + { + ...entry, + order: + typeof entry.order === 'number' ? entry.order : defaultOrderByFieldId.get(fieldId)!, + }, + ]) + ); + const sourceFilter = queryDefaults.sourceFilter(); + const sortObjs = queryDefaults.sort(); + const manualSort = queryDefaults.manualSort(); + const group = queryDefaults.group(); + const metadata = auditMetadata.toDto(); + const order = view.order(); + const version = view.version(); + + return ok({ + id: view.id().toString(), + ...(version.isOk() ? { version: version.value.toNumber() } : {}), + name: view.name().toString(), + type: view.type().toString(), + ...(order.isOk() ? { order: order.value.toNumber() } : {}), + ...(view.description() ? { description: view.description() } : {}), + ...(view.options() !== undefined ? { options: view.options() } : {}), + ...(sourceFilter != null ? { filter: sourceFilter } : {}), + ...(sortObjs !== undefined || manualSort !== undefined + ? { + sort: { + sortObjs: sortObjs ?? [], + ...(manualSort !== undefined ? { manualSort } : {}), + }, + } + : {}), + ...(group?.length ? { group } : {}), + ...(view.isLocked() ? { isLocked: true } : {}), + ...(view.shareId() ? { shareId: view.shareId() } : {}), + ...(view.enableShare() ? { enableShare: true } : {}), + ...(view.shareMeta() ? { shareMeta: view.shareMeta() } : {}), + ...metadata, + columnMeta: sanitizedColumnMeta, + }); + }); diff --git a/packages/v2/core/src/schemas/field/fieldAiConfig.schema.ts b/packages/v2/core/src/schemas/field/fieldAiConfig.schema.ts new file mode 100644 index 0000000000..7c96ce6c37 --- /dev/null +++ b/packages/v2/core/src/schemas/field/fieldAiConfig.schema.ts @@ -0,0 +1,178 @@ +import { z } from 'zod'; + +/** + * Per-field-type aiConfig schemas, mirroring v1's + * `packages/core/src/models/field/ai-config` (T6520 parity): each field type + * accepts only its own AI action types, and field types without an entry do + * not accept an aiConfig at all. + */ + +const commonAiConfigSchema = z.object({ + modelKey: z.string(), + isAutoFill: z.boolean().nullable().optional(), + attachPrompt: z.string().optional(), +}); + +const sourceFieldIdSchema = z.string().startsWith('fld'); + +export const textFieldAiConfigSchema = z.discriminatedUnion('type', [ + commonAiConfigSchema.extend({ + type: z.literal('extraction'), + sourceFieldId: sourceFieldIdSchema, + }), + commonAiConfigSchema.extend({ + type: z.literal('summary'), + sourceFieldId: sourceFieldIdSchema, + }), + commonAiConfigSchema.extend({ + type: z.literal('translation'), + sourceFieldId: sourceFieldIdSchema, + targetLanguage: z.string(), + }), + commonAiConfigSchema.extend({ + type: z.literal('improvement'), + sourceFieldId: sourceFieldIdSchema, + }), + commonAiConfigSchema.extend({ + type: z.literal('customization'), + prompt: z.string(), + }), +]); + +export const singleSelectFieldAiConfigSchema = z.discriminatedUnion('type', [ + commonAiConfigSchema.extend({ + type: z.literal('classification'), + sourceFieldId: sourceFieldIdSchema, + }), + commonAiConfigSchema.extend({ + type: z.literal('customization'), + prompt: z.string(), + onlyAllowConfiguredOptions: z.boolean().optional(), + }), +]); + +export const multipleSelectFieldAiConfigSchema = z.discriminatedUnion('type', [ + commonAiConfigSchema.extend({ + type: z.literal('tag'), + sourceFieldId: sourceFieldIdSchema, + }), + commonAiConfigSchema.extend({ + type: z.literal('customization'), + prompt: z.string(), + onlyAllowConfiguredOptions: z.boolean().optional(), + }), +]); + +const attachmentAiConfigBaseSchema = commonAiConfigSchema.extend({ + n: z.number().min(1).max(10).optional(), + size: z + .string() + .regex(/^\d+x\d+$/, { message: 'Size must be in "widthxheight" format, e.g., "1024x1024"' }) + .optional(), + quality: z.enum(['low', 'medium', 'high']).optional(), + aspectRatio: z + .string() + .regex(/^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/, { + message: 'Aspect ratio must be in "width:height" format, e.g., "16:9"', + }) + .optional(), + resolution: z.enum(['1K', '2K', '4K']).optional(), +}); + +export const attachmentFieldAiConfigSchema = z.discriminatedUnion('type', [ + attachmentAiConfigBaseSchema.extend({ + type: z.literal('imageGeneration'), + sourceFieldId: sourceFieldIdSchema, + }), + attachmentAiConfigBaseSchema.extend({ + type: z.literal('imageCustomization'), + prompt: z.string(), + }), +]); + +export const ratingFieldAiConfigSchema = z.discriminatedUnion('type', [ + commonAiConfigSchema.extend({ + type: z.literal('rating'), + sourceFieldId: sourceFieldIdSchema, + }), + commonAiConfigSchema.extend({ + type: z.literal('customization'), + prompt: z.string(), + }), +]); + +export const dateFieldAiConfigSchema = z.discriminatedUnion('type', [ + commonAiConfigSchema.extend({ + type: z.literal('extraction'), + sourceFieldId: sourceFieldIdSchema, + }), + commonAiConfigSchema.extend({ + type: z.literal('customization'), + prompt: z.string(), + }), +]); + +/** + * Returns the aiConfig schema for a field type, or undefined when the field + * type does not support an aiConfig. + */ +export const getFieldAiConfigSchema = (fieldType: string): z.ZodType | undefined => { + switch (fieldType) { + case 'singleLineText': + case 'longText': + return textFieldAiConfigSchema; + case 'singleSelect': + return singleSelectFieldAiConfigSchema; + case 'multipleSelect': + return multipleSelectFieldAiConfigSchema; + case 'attachment': + return attachmentFieldAiConfigSchema; + case 'rating': + case 'number': + return ratingFieldAiConfigSchema; + case 'date': + return dateFieldAiConfigSchema; + default: + return undefined; + } +}; + +export type IFieldAiConfigValidationResult = + | { readonly valid: true } + | { readonly valid: false; readonly message: string }; + +/** + * Validates an aiConfig value against the field type it is attached to. + * `null`/`undefined` always validate (absent or explicitly cleared config). + */ +export const validateFieldAiConfig = ( + fieldType: string, + aiConfig: unknown +): IFieldAiConfigValidationResult => { + if (aiConfig === undefined || aiConfig === null) { + return { valid: true }; + } + + const schema = getFieldAiConfigSchema(fieldType); + if (!schema) { + return { + valid: false, + message: `Field type ${fieldType} does not support aiConfig`, + }; + } + + const parsed = schema.safeParse(aiConfig); + if (!parsed.success) { + const details = parsed.error.issues + .map((issue) => + issue.path.length > 0 ? `${issue.path.join('.')}: ${issue.message}` : issue.message + ) + .join('; '); + return { + valid: false, + message: `Invalid aiConfig for field type ${fieldType}: ${details}`, + }; + } + + return { valid: true }; +}; diff --git a/packages/v2/core/src/schemas/field/index.ts b/packages/v2/core/src/schemas/field/index.ts index bbeba3cdb3..bbe35e4028 100644 --- a/packages/v2/core/src/schemas/field/index.ts +++ b/packages/v2/core/src/schemas/field/index.ts @@ -47,6 +47,19 @@ export { userOptionsSchema, } from './tableField.schema'; +// Field aiConfig schemas +export { + attachmentFieldAiConfigSchema, + dateFieldAiConfigSchema, + getFieldAiConfigSchema, + multipleSelectFieldAiConfigSchema, + ratingFieldAiConfigSchema, + singleSelectFieldAiConfigSchema, + textFieldAiConfigSchema, + validateFieldAiConfig, +} from './fieldAiConfig.schema'; +export type { IFieldAiConfigValidationResult } from './fieldAiConfig.schema'; + // Main table field schema export { tableFieldInputSchema } from './tableField.schema'; export type { ITableFieldInput, ResolvedTableFieldInput } from './tableField.schema'; diff --git a/packages/v2/devtools/src/commands/records/list.ts b/packages/v2/devtools/src/commands/records/list.ts index 9bd333e713..1da5174e8f 100644 --- a/packages/v2/devtools/src/commands/records/list.ts +++ b/packages/v2/devtools/src/commands/records/list.ts @@ -33,10 +33,18 @@ const hideNotMatchRowOption = Options.boolean('hide-not-match-row').pipe( const searchAccessPathOption = Options.choice('search-access-path', [ 'default', + 'generated_text', 'generated_tsvector', ]).pipe( Options.withDefault('default' as const), - Options.withDescription('Search access path: default keeps ILIKE, generated_tsvector uses FTS') + Options.withDescription( + 'Search access path: default keeps ILIKE, generated_text uses the substring document + GIN prefilter, generated_tsvector uses FTS' + ) +); + +const searchProviderOption = Options.choice('search-provider', ['pg_bigm', 'pg_trgm']).pipe( + Options.withDefault('pg_trgm' as const), + Options.withDescription('Substring provider for --search-access-path generated_text') ); const searchVectorColumnOption = Options.text('search-vector-column').pipe( @@ -63,7 +71,8 @@ const handler = (args: { readonly search: Option.Option; readonly searchFields: Option.Option; readonly hideNotMatchRow: boolean; - readonly searchAccessPath: 'default' | 'generated_tsvector'; + readonly searchAccessPath: 'default' | 'generated_text' | 'generated_tsvector'; + readonly searchProvider: 'pg_bigm' | 'pg_trgm'; readonly searchVectorColumn: Option.Option; readonly searchVectorLanguageConfig: string; readonly searchVectorFieldIds: Option.Option; @@ -75,31 +84,30 @@ const handler = (args: { const searchFields = optionToUndefined(args.searchFields); const searchVectorColumn = optionToUndefined(args.searchVectorColumn); const searchVectorFieldIds = parseCsv(optionToUndefined(args.searchVectorFieldIds)); + const usesGeneratedAccessPath = args.searchAccessPath !== 'default'; - if (args.searchAccessPath === 'generated_tsvector' && !search) { + if (usesGeneratedAccessPath && !search) { return yield* Effect.fail( new ValidationError({ - message: '--search is required when --search-access-path generated_tsvector is used', + message: `--search is required when --search-access-path ${args.searchAccessPath} is used`, field: 'search', }) ); } - if (args.searchAccessPath === 'generated_tsvector' && !searchVectorColumn) { + if (usesGeneratedAccessPath && !searchVectorColumn) { return yield* Effect.fail( new ValidationError({ - message: - '--search-vector-column is required when --search-access-path generated_tsvector is used', + message: `--search-vector-column is required when --search-access-path ${args.searchAccessPath} is used`, field: 'search-vector-column', }) ); } - if (args.searchAccessPath === 'generated_tsvector' && searchVectorFieldIds.length === 0) { + if (usesGeneratedAccessPath && searchVectorFieldIds.length === 0) { return yield* Effect.fail( new ValidationError({ - message: - '--search-vector-field-ids is required when --search-access-path generated_tsvector is used', + message: `--search-vector-field-ids is required when --search-access-path ${args.searchAccessPath} is used`, field: 'search-vector-field-ids', }) ); @@ -136,7 +144,15 @@ const handler = (args: { searchScope: 'all_fields', coveredFieldIds: searchVectorFieldIds, } - : { kind: 'default' }, + : args.searchAccessPath === 'generated_text' + ? { + kind: 'generated_text', + generatedColumnName: searchVectorColumn as string, + provider: args.searchProvider, + searchScope: 'all_fields', + coveredFieldIds: searchVectorFieldIds, + } + : { kind: 'default' }, }) .pipe( Effect.catchAll((error) => @@ -171,6 +187,7 @@ export const recordsList = Command.make( searchFields: searchFieldsOption, hideNotMatchRow: hideNotMatchRowOption, searchAccessPath: searchAccessPathOption, + searchProvider: searchProviderOption, searchVectorColumn: searchVectorColumnOption, searchVectorLanguageConfig: searchVectorLanguageConfigOption, searchVectorFieldIds: searchVectorFieldIdsOption, diff --git a/packages/v2/devtools/src/commands/table-query-ops/execute-search-access-path.ts b/packages/v2/devtools/src/commands/table-query-ops/execute-search-access-path.ts index 4bd9aabbc6..e08feb6d2d 100644 --- a/packages/v2/devtools/src/commands/table-query-ops/execute-search-access-path.ts +++ b/packages/v2/devtools/src/commands/table-query-ops/execute-search-access-path.ts @@ -27,8 +27,11 @@ const executeOption = Options.boolean('execute').pipe( const allowLargeTableRewriteOption = Options.boolean('allow-large-table-rewrite').pipe( Options.withDefault(false) ); -const modeOption = Options.choice('mode', ['create', 'rebuild']).pipe( - Options.withDefault('create' as const) +const modeOption = Options.choice('mode', ['create', 'rebuild', 'drop']).pipe( + Options.withDefault('create' as const), + Options.withDescription( + 'drop removes the managed generated column + GIN index and disables the table config (kill switch)' + ) ); const noEnsureSchemaOption = Options.boolean('no-ensure-schema').pipe(Options.withDefault(false)); @@ -41,7 +44,7 @@ const handler = (args: { readonly fieldIds: Option.Option; readonly execute: boolean; readonly allowLargeTableRewrite: boolean; - readonly mode: 'create' | 'rebuild'; + readonly mode: 'create' | 'rebuild' | 'drop'; readonly noEnsureSchema: boolean; }) => Effect.gen(function* () { diff --git a/packages/v2/devtools/src/commands/table-query-ops/validate-search-access-path-temp-table.ts b/packages/v2/devtools/src/commands/table-query-ops/validate-search-access-path-temp-table.ts index da41da66ac..c4fa114436 100644 --- a/packages/v2/devtools/src/commands/table-query-ops/validate-search-access-path-temp-table.ts +++ b/packages/v2/devtools/src/commands/table-query-ops/validate-search-access-path-temp-table.ts @@ -5,6 +5,7 @@ import { ValidationError } from '../../errors/CliError'; import { Output } from '../../services/Output'; import { TableQueryOps } from '../../services/TableQueryOps'; import { connectionOption, optionToUndefined, parseCsv, tableIdOption } from '../shared'; +import { redactSearchVectorOutput } from './redact-search-vector-output'; const fieldIdsOption = Options.text('field-ids').pipe(Options.optional); const providerOption = Options.choice('provider', ['auto', 'pg_bigm', 'pg_trgm']).pipe( @@ -108,7 +109,7 @@ const handler = (args: { yield* output.success( 'table-query-ops.validate-search-access-path-temp-table', outputInput, - result + redactSearchVectorOutput(result) ); }); diff --git a/packages/v2/devtools/src/commands/table-query-ops/validate-search-vector-temp-table.ts b/packages/v2/devtools/src/commands/table-query-ops/validate-search-vector-temp-table.ts index d61f5072d2..fc66ecfcae 100644 --- a/packages/v2/devtools/src/commands/table-query-ops/validate-search-vector-temp-table.ts +++ b/packages/v2/devtools/src/commands/table-query-ops/validate-search-vector-temp-table.ts @@ -5,6 +5,7 @@ import { ValidationError } from '../../errors/CliError'; import { Output } from '../../services/Output'; import { TableQueryOps } from '../../services/TableQueryOps'; import { connectionOption, optionToUndefined, parseCsv, tableIdOption } from '../shared'; +import { redactSearchVectorOutput } from './redact-search-vector-output'; const searchProbeLengthBucket = (search: string): 'none' | 'short' | 'medium' | 'long' => { const length = search.trim().length; @@ -132,7 +133,11 @@ const handler = (args: { ) ); - yield* output.success('table-query-ops.validate-search-vector-temp-table', outputInput, result); + yield* output.success( + 'table-query-ops.validate-search-vector-temp-table', + outputInput, + redactSearchVectorOutput(result) + ); }); export const tableQueryOpsValidateSearchVectorTempTable = Command.make( diff --git a/packages/v2/devtools/src/layers/DebugDataLive.ts b/packages/v2/devtools/src/layers/DebugDataLive.ts index fd74e6ea81..b462470383 100644 --- a/packages/v2/devtools/src/layers/DebugDataLive.ts +++ b/packages/v2/devtools/src/layers/DebugDataLive.ts @@ -258,6 +258,12 @@ export const DebugDataLive = Layer.effect( ]), } : undefined; + const toFieldIds = (fieldIds: readonly string[]) => + fieldIds.map((fieldId) => { + const result = FieldId.create(fieldId); + if (result.isErr()) throw result.error; + return result.value; + }); const searchAccessPath = options?.searchAccessPath?.kind === 'generated_tsvector' ? { @@ -265,13 +271,17 @@ export const DebugDataLive = Layer.effect( generatedColumnName: options.searchAccessPath.generatedColumnName, languageConfig: options.searchAccessPath.languageConfig, searchScope: options.searchAccessPath.searchScope, - coveredFieldIds: options.searchAccessPath.coveredFieldIds.map((fieldId) => { - const result = FieldId.create(fieldId); - if (result.isErr()) throw result.error; - return result.value; - }), + coveredFieldIds: toFieldIds(options.searchAccessPath.coveredFieldIds), } - : options?.searchAccessPath; + : options?.searchAccessPath?.kind === 'generated_text' + ? { + kind: 'generated_text' as const, + generatedColumnName: options.searchAccessPath.generatedColumnName, + provider: options.searchAccessPath.provider, + searchScope: options.searchAccessPath.searchScope, + coveredFieldIds: toFieldIds(options.searchAccessPath.coveredFieldIds), + } + : options?.searchAccessPath; // 3. Query records const queryResult = await recordQueryRepo.find(context, table, undefined, { @@ -288,6 +298,11 @@ export const DebugDataLive = Layer.effect( fields: r.fields, })), total: queryResult.value.total, + // Surface the resolution so operators can see whether the + // requested access path actually ran or silently fell back. + ...(queryResult.value.searchAccessPath + ? { searchAccessPath: queryResult.value.searchAccessPath } + : {}), }; }, catch: (e) => CliError.fromUnknown(e), diff --git a/packages/v2/devtools/src/layers/TableQueryOpsLive.ts b/packages/v2/devtools/src/layers/TableQueryOpsLive.ts index cf041e7f64..8d9e88910f 100644 --- a/packages/v2/devtools/src/layers/TableQueryOpsLive.ts +++ b/packages/v2/devtools/src/layers/TableQueryOpsLive.ts @@ -5,6 +5,7 @@ import { mergeSearchVectorCoverage, PostgresTableSearchVectorAdvisor, registerV2TableOpsPostgresAdapter, + renderSearchTextProjectionSql, type AnalyzeTableSearchVectorResult, type UnknownPostgresDatabase, } from '@teable/v2-adapter-table-query-ops-postgres'; @@ -34,6 +35,7 @@ import { type ITableRecordQueryRepository, type ITableRepository, type ITracer, + type SearchFieldTextProjection, type Table, } from '@teable/v2-core'; import { @@ -467,6 +469,27 @@ type SearchAccessPathTempQueryPathResult = { readonly recordIds: readonly string[]; }; +// Durable output must not carry raw record ids (customer data); a +// deterministic set hash still lets two runs be compared for equality. +const stableRecordIdSetHash = (recordIds: readonly string[]): string => { + const joined = [...recordIds].sort().join('\n'); + let hash = 0; + for (let index = 0; index < joined.length; index += 1) { + hash = (hash * 31 + joined.charCodeAt(index)) >>> 0; + } + return `${recordIds.length}:${hash.toString(16).padStart(8, '0')}`; +}; + +const redactTempQueryPathResult = ({ + recordIds, + ...rest +}: SearchAccessPathTempQueryPathResult): Omit & { + readonly recordIdSetHash: string; +} => ({ + ...rest, + recordIdSetHash: stableRecordIdSetHash(recordIds), +}); + type SearchAccessPathTempPlanEvidence = { readonly explainStatus: 'validated' | 'failed'; readonly costBefore?: number; @@ -1549,12 +1572,19 @@ const createContext = (container: { }; const buildSearchDocumentGeneratedExpression = ( - fields: ReadonlyArray<{ readonly fieldDbName?: string }> + fields: ReadonlyArray<{ + readonly fieldDbName?: string; + readonly textProjection?: SearchFieldTextProjection; + }> ): string => { const document = fields - .map((field) => field.fieldDbName) - .filter((fieldDbName): fieldDbName is string => Boolean(fieldDbName)) - .map((fieldDbName) => `coalesce(${quoteIdentifier(fieldDbName)}::text, '')`) + .filter((field): field is { fieldDbName: string; textProjection?: SearchFieldTextProjection } => + Boolean(field.fieldDbName) + ) + .map( + (field) => + `coalesce(${renderSearchTextProjectionSql(quoteIdentifier(field.fieldDbName), field.textProjection)}, '')` + ) .join(` || E'\\n' || `); return `lower(${document || "''"})`; }; @@ -2354,9 +2384,11 @@ export const TableQueryOpsLive = Layer.effect( samples.push({ searchProbeLengthBucket: searchProbeLengthBucket(search), probeSource: input.probeSource ?? 'manual', - legacyIlikePath, - optimizedGeneratedTextPath, - ...exactComparison, + legacyIlikePath: redactTempQueryPathResult(legacyIlikePath), + optimizedGeneratedTextPath: redactTempQueryPathResult(optimizedGeneratedTextPath), + exactResultMatch: exactComparison.exactResultMatch, + missingFromOptimizedCount: exactComparison.missingFromOptimized.length, + unexpectedFromOptimizedCount: exactComparison.unexpectedFromOptimized.length, totalDeltaFromLegacy: optimizedGeneratedTextPath.total - legacyIlikePath.total, durationDeltaPctFromLegacy: durationDeltaPct( legacyIlikePath.durationMs, @@ -2442,6 +2474,37 @@ export const TableQueryOpsLive = Layer.effect( throw new Error('table-query-ops execute-search-access-path requires --table-id'); } const dryRun = !(input.execute ?? false); + if (input.mode === 'drop') { + if (dryRun) { + return { + scope: input, + dryRun, + action: 'dry_run', + result: { + note: 'Would drop the managed search document column + GIN index and disable the table config; rerun with --execute.', + }, + }; + } + await ensureRegistered(input.ensureSchema ?? true); + const context = createContext(container); + const tableRepository = container.resolve(v2CoreTokens.tableRepository); + const tableId = TableId.create(input.tableId); + if (tableId.isErr()) throw tableId.error; + const tableResult = await tableRepository.findOne( + context, + TableByIdSpec.create(tableId.value) + ); + if (tableResult.isErr()) throw tableResult.error; + const reconciler = container.resolve( + v2TableOpsTokens.searchVectorReconciler + ); + const dropResult = await reconciler.reconcile(context, { + table: tableResult.value, + mode: 'drop', + }); + if (dropResult.isErr()) throw dropResult.error; + return { scope: input, dryRun, action: 'dropped', result: dropResult.value }; + } const analysis = await analyzeSearchAccessPathsUnsafe({ tableId: input.tableId, fieldIds: input.fieldIds, diff --git a/packages/v2/devtools/src/services/DebugData.ts b/packages/v2/devtools/src/services/DebugData.ts index a8effb9a9d..30fa392250 100644 --- a/packages/v2/devtools/src/services/DebugData.ts +++ b/packages/v2/devtools/src/services/DebugData.ts @@ -12,6 +12,13 @@ import type { CliError } from '../errors'; /** Options for querying records via application layer */ export type RecordQuerySearchAccessPathOption = | { readonly kind: 'default' } + | { + readonly kind: 'generated_text'; + readonly generatedColumnName: string; + readonly provider: 'pg_bigm' | 'pg_trgm'; + readonly searchScope: 'all_fields' | 'selected_fields'; + readonly coveredFieldIds: readonly string[]; + } | { readonly kind: 'generated_tsvector'; readonly generatedColumnName: string; @@ -34,6 +41,12 @@ export interface RecordQueryOptions { export interface RecordQueryResult { readonly records: ReadonlyArray; readonly total: number; + /** How the repository resolved the requested search access path. */ + readonly searchAccessPath?: { + readonly requested: string; + readonly used: string; + readonly fallbackReason?: string; + }; } /** Single record read model from application layer */ diff --git a/packages/v2/devtools/src/services/TableQueryOps.ts b/packages/v2/devtools/src/services/TableQueryOps.ts index 2299d4370f..004eac8cd1 100644 --- a/packages/v2/devtools/src/services/TableQueryOps.ts +++ b/packages/v2/devtools/src/services/TableQueryOps.ts @@ -65,7 +65,7 @@ export interface TableQueryOpsAnalyzeSearchAccessPathsInput extends TableQueryOp } export interface TableQueryOpsExecuteSearchAccessPathInput extends TableQueryOpsScopeInput { - readonly mode?: 'create' | 'rebuild'; + readonly mode?: 'create' | 'rebuild' | 'drop'; readonly fieldIds?: readonly string[]; readonly provider?: TableQueryOpsSearchAccessPathProvider; readonly sampleSearch?: string; @@ -789,7 +789,7 @@ export type TableQueryOpsAnalyzeSearchVectorsResult = TableQueryOpsAnalyzeSearch export interface TableQueryOpsExecuteSearchAccessPathResult { readonly scope: TableQueryOpsExecuteSearchAccessPathInput; readonly dryRun: boolean; - readonly action: 'dry_run' | 'executed' | 'failed'; + readonly action: 'dry_run' | 'executed' | 'dropped' | 'failed'; readonly result?: unknown; readonly error?: string; } @@ -836,23 +836,25 @@ export interface TableQueryOpsSearchAccessPathTempTableValidationResult { readonly samples: readonly { readonly searchProbeLengthBucket: 'none' | 'short' | 'medium' | 'long'; readonly probeSource: TableQueryOpsSearchProbeSource; + // Record ids are customer data: durable output carries a deterministic + // set hash + counts instead of the raw id arrays. readonly legacyIlikePath: { readonly durationMs: number; readonly timing: TableQueryOpsSearchTimingSummary; readonly total: number; readonly returnedCount: number; - readonly recordIds: readonly string[]; + readonly recordIdSetHash: string; }; readonly optimizedGeneratedTextPath: { readonly durationMs: number; readonly timing: TableQueryOpsSearchTimingSummary; readonly total: number; readonly returnedCount: number; - readonly recordIds: readonly string[]; + readonly recordIdSetHash: string; }; readonly exactResultMatch: boolean; - readonly missingFromOptimized: readonly string[]; - readonly unexpectedFromOptimized: readonly string[]; + readonly missingFromOptimizedCount: number; + readonly unexpectedFromOptimizedCount: number; readonly totalDeltaFromLegacy: number; readonly durationDeltaPctFromLegacy: number; readonly planEvidence: { diff --git a/packages/v2/e2e/package.json b/packages/v2/e2e/package.json index 2529d5f5e8..32380125eb 100644 --- a/packages/v2/e2e/package.json +++ b/packages/v2/e2e/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@teable/v2-adapter-realtime-sharedb": "workspace:*", + "@teable/v2-adapter-table-query-ops-postgres": "workspace:*", "@teable/v2-container-node-test": "workspace:*", "@teable/v2-contract-http": "workspace:*", "@teable/v2-contract-http-client": "workspace:*", @@ -41,6 +42,7 @@ "@teable/v2-di": "workspace:*", "@teable/v2-import": "workspace:*", "@teable/v2-postgres-schema": "workspace:*", + "@teable/v2-table-query-ops": "workspace:*", "@teable/v2-table-templates": "workspace:*", "@teable/v2-utils": "workspace:*", "express": "4.21.1", diff --git a/packages/v2/e2e/src/__snapshots__/field-explain.anonymized-formula-update.sql b/packages/v2/e2e/src/__snapshots__/field-explain.anonymized-formula-update.sql index 1eb27901d9..a985c0a49a 100644 --- a/packages/v2/e2e/src/__snapshots__/field-explain.anonymized-formula-update.sql +++ b/packages/v2/e2e/src/__snapshots__/field-explain.anonymized-formula-update.sql @@ -19,4 +19,4 @@ update ""."" as "u" set "__version" = "u"."__version" + END ELSE to_jsonb((("t"."actual_value"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) - ))::text), '')))::text), '') || COALESCE(((' units (reference) -> delta ')::text), '')))::text), '') || COALESCE(((("t"."amount_delta")::text)::text), '')))::text), '') || COALESCE(((' units -- ')::text), '')))::text), '') || COALESCE((("t"."action_reason")::text), '')) END)) as "action_line_text" FROM ""."" AS "t" INNER JOIN "tmp_computed_dirty" AS "__dirty" ON "t"."__id" = "__dirty"."record_id" AND "__dirty"."table_id" = '') SELECT "u"."__id", "level_0"."delta_explanation" as "delta_explanation", "level_0"."action_line_text" as "action_line_text" FROM ""."" AS "u" JOIN "level_0" ON "u"."__id" = "level_0"."__id") as "c_src") as "c" where "u"."__id" = "c"."__id" and ("u"."delta_explanation" IS DISTINCT FROM "c"."__set_delta_explanation" OR "u"."action_line_text" IS DISTINCT FROM "c"."__set_action_line_text") \ No newline at end of file + ))::text), '')))::text), '') || COALESCE(((' units (reference) -> delta ')::text), '')))::text), '') || COALESCE(((("t"."amount_delta")::text)::text), '')))::text), '') || COALESCE(((' units -- ')::text), '')))::text), '') || COALESCE((("t"."action_reason")::text), '')) END)) as "action_line_text" FROM ""."" AS "t" INNER JOIN "tmp_computed_dirty" AS "__dirty" ON "t"."__id" = "__dirty"."record_id" AND "__dirty"."table_id" = '') SELECT "u"."__id", "level_0"."delta_explanation" as "delta_explanation", "level_0"."action_line_text" as "action_line_text" FROM ""."" AS "u" JOIN "level_0" ON "u"."__id" = "level_0"."__id") as "c_src") as "c" where "u"."__id" = "c"."__id" and (("u"."delta_explanation")::text IS DISTINCT FROM ("c"."__set_delta_explanation")::text OR ("u"."action_line_text")::text IS DISTINCT FROM ("c"."__set_action_line_text")::text) \ No newline at end of file diff --git a/packages/v2/e2e/src/aggregate-records.e2e.spec.ts b/packages/v2/e2e/src/aggregate-records.e2e.spec.ts new file mode 100644 index 0000000000..df17800d61 --- /dev/null +++ b/packages/v2/e2e/src/aggregate-records.e2e.spec.ts @@ -0,0 +1,938 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { + ActorId, + AggregateTableRecordsQuery, + v2CoreTokens, + type AggregateTableRecordsResult, + type IAggregateTableRecordsQueryInput, + type IQueryBus, + type RecordFilter, +} from '@teable/v2-core'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { + getSharedTestContext, + TEST_USER, + type SharedTestContext, +} from './shared/globalTestContext'; +import { + ensureAttachmentTables, + makeAttachmentCell, + seedAttachment, +} from './update-field/attachment/testUtils'; + +/** + * V1-parity e2e coverage for record aggregation, ported from + * apps/nestjs-backend/test/data-helpers/caces/aggregation-query/ case tables + * (text/number/single-select/date/checkbox statistics). + * + * v2 has no HTTP aggregation endpoint yet, so these tests execute + * AggregateTableRecordsQuery directly through the query bus resolved from the + * shared test container — the same mechanism contract-http-implementation uses + * for its query routes (container.resolve(v2CoreTokens.queryBus)). + * + * Unlike v1 (which asserts against the shared x_20 seed), this file builds its + * own fixture and derives every expected value from it, honoring T6520 + * semantics: cleared ""/false/[] values are stored as NULL and therefore count + * as Empty / UnChecked. + */ +describe('aggregate records via query bus (e2e, v1 parity)', () => { + let ctx: SharedTestContext; + let queryBus: IQueryBus; + const actorId = ActorId.create(TEST_USER.id)._unsafeUnwrap(); + + const aggregate = async ( + input: IAggregateTableRecordsQueryInput + ): Promise => { + const query = AggregateTableRecordsQuery.create(input); + if (query.isErr()) { + throw new Error(`Invalid aggregate query input: ${query.error.message}`); + } + const result = await queryBus.execute( + { actorId, windowId: 'e2e-window' }, + query.value + ); + if (result.isErr()) { + throw new Error(`Aggregate query failed: ${result.error.message}`); + } + return result.value; + }; + + const aggregateValue = async ( + tableId: string, + viewId: string, + fieldId: string, + statisticFunc: string, + filter?: RecordFilter + ): Promise => { + const result = await aggregate({ + tableId, + viewId, + fields: [{ fieldId, statisticFunc }], + ...(filter ? { filter } : {}), + }); + const entry = result.values.find( + (value) => value.fieldId.toString() === fieldId && value.statisticFunc === statisticFunc + ); + if (!entry) { + throw new Error(`No aggregation value returned for ${fieldId} ${statisticFunc}`); + } + return entry.value; + }; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + queryBus = ctx.testContainer.container.resolve(v2CoreTokens.queryBus); + }, 30000); + + // ---------------------------------------------------------------- + // Main fixture: 10 records across text/number/select/date/checkbox + // ---------------------------------------------------------------- + describe('v1-parity statistic matrix', () => { + let tableId: string; + let viewId: string; + let nameFieldId: string; + let notesFieldId: string; + let amountFieldId: string; + let ratingFieldId: string; + let statusFieldId: string; + let tagsFieldId: string; + let dueFieldId: string; + let doneFieldId: string; + let ownerFieldId: string; + let collaboratorsFieldId: string; + + // Fixture (10 rows). null = value omitted (stored NULL). + // # | Name | Amount | Status | Due (UTC) | Done + // 1 | Alpha | 10 | Todo | 2024-01-01T00:00:00.000Z | true + // 2 | Beta | 20 | Doing | 2024-01-11T00:00:00.000Z | true + // 3 | Beta | 30 | Done | 2024-01-31T00:00:00.000Z | true + // 4 | Gamma | 40 | Todo | null | false (T6520 → NULL) + // 5 | Delta | 100 | Todo | 2024-01-16T00:00:00.000Z | null + // 6 | Epsilon | null | null | null | null + // 7 | Zeta | null | null | null | null + // 8 | Eta | null | Done | null | null + // 9 | null | null | null | null | null + // 10 | null | null | null | null | null + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `Aggregation Parity ${Date.now()}`, + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Notes', type: 'longText' }, + { name: 'Amount', type: 'number' }, + { + name: 'Rating', + type: 'rating', + options: { max: 5, icon: 'star', color: 'yellowBright' }, + }, + { name: 'Status', type: 'singleSelect', options: ['Todo', 'Doing', 'Done'] }, + { name: 'Tags', type: 'multipleSelect', options: ['Red', 'Blue', 'Green'] }, + { name: 'Due', type: 'date' }, + { name: 'Done', type: 'checkbox' }, + { name: 'Owner', type: 'user', options: { isMultiple: false } }, + { name: 'Collaborators', type: 'user', options: { isMultiple: true } }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + viewId = table.views[0].id; + nameFieldId = table.fields.find((f) => f.name === 'Name')?.id ?? ''; + notesFieldId = table.fields.find((f) => f.name === 'Notes')?.id ?? ''; + amountFieldId = table.fields.find((f) => f.name === 'Amount')?.id ?? ''; + ratingFieldId = table.fields.find((f) => f.name === 'Rating')?.id ?? ''; + statusFieldId = table.fields.find((f) => f.name === 'Status')?.id ?? ''; + tagsFieldId = table.fields.find((f) => f.name === 'Tags')?.id ?? ''; + dueFieldId = table.fields.find((f) => f.name === 'Due')?.id ?? ''; + doneFieldId = table.fields.find((f) => f.name === 'Done')?.id ?? ''; + ownerFieldId = table.fields.find((f) => f.name === 'Owner')?.id ?? ''; + collaboratorsFieldId = table.fields.find((f) => f.name === 'Collaborators')?.id ?? ''; + + const rows: Array<{ + name?: string; + notes?: string; + amount?: number; + rating?: number; + status?: string; + tags?: string[]; + due?: string; + done?: boolean; + owner?: { id: string; title: string }; + collaborators?: Array<{ id: string; title: string }>; + }> = [ + { + name: 'Alpha', + notes: 'memo-a', + amount: 10, + rating: 5, + status: 'Todo', + tags: ['Red', 'Blue'], + due: '2024-01-01T00:00:00.000Z', + done: true, + owner: { id: ctx.testUser.id, title: ctx.testUser.name }, + collaborators: [{ id: ctx.testUser.id, title: ctx.testUser.name }], + }, + { + name: 'Beta', + amount: 20, + rating: 3, + status: 'Doing', + tags: ['Blue'], + due: '2024-01-11T00:00:00.000Z', + done: true, + owner: { id: ctx.testUser.id, title: ctx.testUser.name }, + collaborators: [{ id: ctx.testUser.id, title: ctx.testUser.name }], + }, + { + name: 'Beta', + notes: 'memo-b', + amount: 30, + rating: 4, + status: 'Done', + tags: ['Green'], + due: '2024-01-31T00:00:00.000Z', + done: true, + }, + // Explicit false exercises T6520: false stores NULL and counts as UnChecked. + { + name: 'Gamma', + notes: 'memo-a', + amount: 40, + rating: 1, + status: 'Todo', + tags: [], + done: false, + }, + { + name: 'Delta', + amount: 100, + rating: 2, + status: 'Todo', + tags: ['Red'], + due: '2024-01-16T00:00:00.000Z', + }, + { name: 'Epsilon', rating: 5 }, + { name: 'Zeta' }, + { name: 'Eta', status: 'Done' }, + {}, + {}, + ]; + + await ctx.createRecords( + tableId, + rows.map((row) => ({ + fields: { + ...(row.name !== undefined ? { [nameFieldId]: row.name } : {}), + ...(row.notes !== undefined ? { [notesFieldId]: row.notes } : {}), + ...(row.amount !== undefined ? { [amountFieldId]: row.amount } : {}), + ...(row.rating !== undefined ? { [ratingFieldId]: row.rating } : {}), + ...(row.status !== undefined ? { [statusFieldId]: row.status } : {}), + ...(row.tags !== undefined ? { [tagsFieldId]: row.tags } : {}), + ...(row.due !== undefined ? { [dueFieldId]: row.due } : {}), + ...(row.done !== undefined ? { [doneFieldId]: row.done } : {}), + ...(row.owner !== undefined ? { [ownerFieldId]: row.owner } : {}), + ...(row.collaborators !== undefined + ? { [collaboratorsFieldId]: row.collaborators } + : {}), + }, + })) + ); + await ctx.drainOutbox(); + }, 30000); + + // ---- TEXT (v1 TEXT_FIELD_CASES: Count/Empty/Filled/Unique/Percent*) ---- + // Name column: 8 filled, 2 empty, 7 distinct non-null values. + it.each([ + { statisticFunc: 'count', expected: 10 }, + { statisticFunc: 'empty', expected: 2 }, + { statisticFunc: 'filled', expected: 8 }, + { statisticFunc: 'unique', expected: 7 }, + { statisticFunc: 'percentEmpty', expected: 20 }, + { statisticFunc: 'percentFilled', expected: 80 }, + { statisticFunc: 'percentUnique', expected: 70 }, + ])('text field: $statisticFunc = $expected', async ({ statisticFunc, expected }) => { + const value = await aggregateValue(tableId, viewId, nameFieldId, statisticFunc); + expect(Number(value)).toBeCloseTo(expected, 4); + }); + + // ---- LONG TEXT (v1 LONG_TEXT_FIELD_CASES) ---- + // Notes column: three filled rows, seven empty rows, two distinct values. + it.each([ + { statisticFunc: 'count', expected: 10 }, + { statisticFunc: 'empty', expected: 7 }, + { statisticFunc: 'filled', expected: 3 }, + { statisticFunc: 'unique', expected: 2 }, + ])('long text field: $statisticFunc = $expected', async ({ statisticFunc, expected }) => { + const value = await aggregateValue(tableId, viewId, notesFieldId, statisticFunc); + expect(Number(value)).toBeCloseTo(expected, 4); + }); + + // ---- NUMBER (v1 NUMBER_FIELD_CASES: Sum/Average/Min/Max + Empty/Filled) ---- + // Amount column: 10, 20, 30, 40, 100 filled; 5 empty. + it.each([ + { statisticFunc: 'sum', expected: 200 }, + { statisticFunc: 'average', expected: 40 }, + { statisticFunc: 'min', expected: 10 }, + { statisticFunc: 'max', expected: 100 }, + { statisticFunc: 'count', expected: 10 }, + { statisticFunc: 'empty', expected: 5 }, + { statisticFunc: 'filled', expected: 5 }, + { statisticFunc: 'unique', expected: 5 }, + { statisticFunc: 'percentEmpty', expected: 50 }, + { statisticFunc: 'percentFilled', expected: 50 }, + { statisticFunc: 'percentUnique', expected: 50 }, + ])('number field: $statisticFunc = $expected', async ({ statisticFunc, expected }) => { + const value = await aggregateValue(tableId, viewId, amountFieldId, statisticFunc); + expect(Number(value)).toBeCloseTo(expected, 4); + }); + + // ---- RATING (v1 RATING_FIELD_CASES) ---- + // Rating column: 5, 3, 4, 1, 2, 5. + it.each([ + { statisticFunc: 'sum', expected: 20 }, + { statisticFunc: 'average', expected: 20 / 6 }, + { statisticFunc: 'min', expected: 1 }, + { statisticFunc: 'max', expected: 5 }, + ])('rating field: $statisticFunc = $expected', async ({ statisticFunc, expected }) => { + const value = await aggregateValue(tableId, viewId, ratingFieldId, statisticFunc); + expect(Number(value)).toBeCloseTo(expected, 4); + }); + + // ---- SINGLE SELECT (v1 SINGLE_SELECT_FIELD_CASES) ---- + // Status column: Todo x3, Doing x1, Done x2 → 6 filled, 4 empty, 3 unique. + it.each([ + { statisticFunc: 'count', expected: 10 }, + { statisticFunc: 'empty', expected: 4 }, + { statisticFunc: 'filled', expected: 6 }, + { statisticFunc: 'unique', expected: 3 }, + { statisticFunc: 'percentEmpty', expected: 40 }, + { statisticFunc: 'percentFilled', expected: 60 }, + { statisticFunc: 'percentUnique', expected: 30 }, + ])('single select field: $statisticFunc = $expected', async ({ statisticFunc, expected }) => { + const value = await aggregateValue(tableId, viewId, statusFieldId, statisticFunc); + expect(Number(value)).toBeCloseTo(expected, 4); + }); + + // ---- DATE (v1 DATE_FIELD_CASES: Empty/Filled + DateRangeOfDays) ---- + // Due column: 2024-01-01, 2024-01-11, 2024-01-16, 2024-01-31 → range 30 days. + it.each([ + { statisticFunc: 'count', expected: 10 }, + { statisticFunc: 'empty', expected: 6 }, + { statisticFunc: 'filled', expected: 4 }, + { statisticFunc: 'unique', expected: 4 }, + { statisticFunc: 'percentEmpty', expected: 60 }, + { statisticFunc: 'percentFilled', expected: 40 }, + { statisticFunc: 'percentUnique', expected: 40 }, + { statisticFunc: 'dateRangeOfDays', expected: 30 }, + { statisticFunc: 'dateRangeOfMonths', expected: 0 }, + ])('date field: $statisticFunc = $expected', async ({ statisticFunc, expected }) => { + const value = await aggregateValue(tableId, viewId, dueFieldId, statisticFunc); + expect(Number(value)).toBe(expected); + }); + + it('date field: earliestDate/latestDate return the boundary timestamps', async () => { + const earliest = await aggregateValue(tableId, viewId, dueFieldId, 'earliestDate'); + const latest = await aggregateValue(tableId, viewId, dueFieldId, 'latestDate'); + expect(new Date(String(earliest)).toISOString()).toBe('2024-01-01T00:00:00.000Z'); + expect(new Date(String(latest)).toISOString()).toBe('2024-01-31T00:00:00.000Z'); + }); + + // ---- CHECKBOX (v1 CHECKBOX_FIELD_CASES) ---- + // Done column: true x3; false/omitted are stored NULL (T6520) and count as + // UnChecked → checked 3, unChecked 7. + it.each([ + { statisticFunc: 'count', expected: 10 }, + { statisticFunc: 'checked', expected: 3 }, + { statisticFunc: 'unChecked', expected: 7 }, + { statisticFunc: 'percentChecked', expected: 30 }, + { statisticFunc: 'percentUnChecked', expected: 70 }, + ])('checkbox field: $statisticFunc = $expected', async ({ statisticFunc, expected }) => { + const value = await aggregateValue(tableId, viewId, doneFieldId, statisticFunc); + expect(Number(value)).toBeCloseTo(expected, 4); + }); + + // ---- MULTIPLE SELECT (v1 MULTIPLE_SELECT_FIELD_CASES) ---- + // Tags column: four filled rows, six empty rows, three distinct choices. + it.each([ + { statisticFunc: 'count', expected: 10 }, + { statisticFunc: 'empty', expected: 6 }, + { statisticFunc: 'filled', expected: 4 }, + { statisticFunc: 'unique', expected: 3 }, + { statisticFunc: 'percentEmpty', expected: 60 }, + { statisticFunc: 'percentFilled', expected: 40 }, + // Multi-value percentUnique uses distinct flattened values / all flattened values. + { statisticFunc: 'percentUnique', expected: 60 }, + ])('multiple select field: $statisticFunc = $expected', async ({ statisticFunc, expected }) => { + const value = await aggregateValue(tableId, viewId, tagsFieldId, statisticFunc); + expect(Number(value)).toBeCloseTo(expected, 4); + }); + + // ---- USER (v1 USER_FIELD_CASES) ---- + // Owner column: two filled rows referring to one distinct user. + it.each([ + { statisticFunc: 'count', expected: 10 }, + { statisticFunc: 'empty', expected: 8 }, + { statisticFunc: 'filled', expected: 2 }, + { statisticFunc: 'unique', expected: 1 }, + { statisticFunc: 'percentEmpty', expected: 80 }, + { statisticFunc: 'percentFilled', expected: 20 }, + { statisticFunc: 'percentUnique', expected: 10 }, + ])('user field: $statisticFunc = $expected', async ({ statisticFunc, expected }) => { + const value = await aggregateValue(tableId, viewId, ownerFieldId, statisticFunc); + expect(Number(value)).toBeCloseTo(expected, 4); + }); + + // ---- MULTIPLE USER (v1 MULTIPLE_USER_FIELD_CASES) ---- + it.each([ + { statisticFunc: 'count', expected: 10 }, + { statisticFunc: 'empty', expected: 8 }, + { statisticFunc: 'filled', expected: 2 }, + ])('multiple user field: $statisticFunc = $expected', async ({ statisticFunc, expected }) => { + const value = await aggregateValue(tableId, viewId, collaboratorsFieldId, statisticFunc); + expect(Number(value)).toBeCloseTo(expected, 4); + }); + + // ---- FILTERED AGGREGATION (v1 aggregation.e2e-spec.ts filter cases) ---- + it('applies a filter before aggregating (Status is Todo)', async () => { + const filter: RecordFilter = { + fieldId: statusFieldId, + operator: 'is', + value: 'Todo', + }; + // Rows 1, 4, 5: Amount 10 + 40 + 100, one checked (row 1). + expect(Number(await aggregateValue(tableId, viewId, amountFieldId, 'sum', filter))).toBe(150); + expect(Number(await aggregateValue(tableId, viewId, nameFieldId, 'count', filter))).toBe(3); + expect(Number(await aggregateValue(tableId, viewId, doneFieldId, 'checked', filter))).toBe(1); + expect(Number(await aggregateValue(tableId, viewId, doneFieldId, 'unChecked', filter))).toBe( + 2 + ); + }); + + it('supports multiple statistics for multiple fields in a single query', async () => { + const result = await aggregate({ + tableId, + viewId, + fields: [ + { fieldId: nameFieldId, statisticFunc: 'filled' }, + { fieldId: amountFieldId, statisticFunc: 'sum' }, + { fieldId: doneFieldId, statisticFunc: 'checked' }, + ], + }); + const byKey = new Map( + result.values.map((v) => [`${v.fieldId.toString()}:${v.statisticFunc}`, v.value]) + ); + expect(Number(byKey.get(`${nameFieldId}:filled`))).toBe(8); + expect(Number(byKey.get(`${amountFieldId}:sum`))).toBe(200); + expect(Number(byKey.get(`${doneFieldId}:checked`))).toBe(3); + }); + + it('applies hide-not-matching search before aggregating', async () => { + const result = await aggregate({ + tableId, + viewId, + search: ['Beta', nameFieldId, true], + fields: [{ fieldId: amountFieldId, statisticFunc: 'sum' }], + }); + + expect(result.values).toHaveLength(1); + expect(result.values[0]?.value).toBe(50); + }); + + it('applies hide-not-matching search to every requested statistic', async () => { + const result = await aggregate({ + tableId, + viewId, + search: ['Beta', nameFieldId, true], + fields: [ + { fieldId: amountFieldId, statisticFunc: 'sum' }, + { fieldId: amountFieldId, statisticFunc: 'average' }, + { fieldId: amountFieldId, statisticFunc: 'min' }, + { fieldId: amountFieldId, statisticFunc: 'max' }, + { fieldId: amountFieldId, statisticFunc: 'count' }, + ], + }); + const byFunction = new Map( + result.values.map(({ statisticFunc, value }) => [statisticFunc, value]) + ); + + expect(Number(byFunction.get('sum'))).toBe(50); + expect(Number(byFunction.get('average'))).toBe(25); + expect(Number(byFunction.get('min'))).toBe(20); + expect(Number(byFunction.get('max'))).toBe(30); + expect(Number(byFunction.get('count'))).toBe(2); + }); + + it('returns zero for percent statistics when the filter matches no records', async () => { + const result = await aggregate({ + tableId, + viewId, + filter: { + fieldId: nameFieldId, + operator: 'is', + value: 'does-not-exist', + }, + fields: [ + { fieldId: nameFieldId, statisticFunc: 'percentFilled' }, + { fieldId: nameFieldId, statisticFunc: 'percentUnique' }, + { fieldId: nameFieldId, statisticFunc: 'percentEmpty' }, + { fieldId: doneFieldId, statisticFunc: 'percentChecked' }, + { fieldId: doneFieldId, statisticFunc: 'percentUnChecked' }, + ], + }); + + expect(result.values).toHaveLength(5); + expect(result.values.every(({ value }) => Number(value) === 0)).toBe(true); + }); + + it('returns total and grouped aggregation buckets', async () => { + const result = await aggregate({ + tableId, + viewId, + fields: [{ fieldId: amountFieldId, statisticFunc: 'sum' }], + groupBy: [{ fieldId: statusFieldId, order: 'asc' }], + }); + + expect(result.groupBy.map((group) => group.fieldId.toString())).toEqual([statusFieldId]); + expect(result.values.find(({ groupValues }) => groupValues === undefined)?.value).toBe(200); + + const groups = result.values.filter(({ groupValues }) => groupValues !== undefined); + expect(groups).toHaveLength(4); + expect( + new Map( + groups + .filter(({ groupValues }) => groupValues?.[0] !== null) + .map(({ value, groupValues }) => [groupValues?.[0], value]) + ) + ).toEqual( + new Map([ + ['Todo', 150], + ['Doing', 20], + ['Done', 30], + ]) + ); + expect(groups.find(({ groupValues }) => groupValues?.[0] === null)?.value).toBeNull(); + }); + + it('orders text group buckets in the requested direction', async () => { + const groupValues = async (order: 'asc' | 'desc') => { + const result = await aggregate({ + tableId, + viewId, + fields: [{ fieldId: amountFieldId, statisticFunc: 'sum' }], + groupBy: [{ fieldId: nameFieldId, order }], + }); + return result.values + .filter(({ groupValues }) => groupValues !== undefined) + .map(({ groupValues }) => groupValues?.[0] ?? null); + }; + + const ascending = await groupValues('asc'); + const descending = await groupValues('desc'); + expect(descending).toEqual([...ascending].reverse()); + expect(ascending.filter((value) => value !== null)).toEqual([ + 'Alpha', + 'Beta', + 'Delta', + 'Epsilon', + 'Eta', + 'Gamma', + 'Zeta', + ]); + }); + + it('returns no values when no statistics are requested', async () => { + const result = await aggregate({ tableId, viewId }); + expect(result.values).toEqual([]); + }); + }); + + // ---------------------------------------------------------------- + // Link and lookup aggregation remains database-pushed through QueryBus. + // ---------------------------------------------------------------- + describe('link and lookup aggregation parity', () => { + it('aggregates link occupancy and filters a number aggregation by lookup values', async () => { + const source = await ctx.createTable({ + baseId: ctx.baseId, + name: `Aggregation Lookup Source ${Date.now()}`, + fields: [ + { name: 'Order', type: 'singleLineText', isPrimary: true }, + { name: 'Amount', type: 'number' }, + { name: 'Tag', type: 'singleLineText' }, + ], + views: [{ type: 'grid' }], + }); + const sourceNameFieldId = source.fields.find((field) => field.name === 'Order')?.id ?? ''; + const sourceTagFieldId = source.fields.find((field) => field.name === 'Tag')?.id ?? ''; + const sourceRecords = await ctx.createRecords(source.id, [ + { fields: { [sourceNameFieldId]: 'Order A', [sourceTagFieldId]: 'include' } }, + { fields: { [sourceNameFieldId]: 'Order B', [sourceTagFieldId]: 'exclude' } }, + ]); + + const target = await ctx.createTable({ + baseId: ctx.baseId, + name: `Aggregation Lookup Target ${Date.now()}`, + fields: [ + { name: 'Task', type: 'singleLineText', isPrimary: true }, + { name: 'Budget', type: 'number' }, + { + name: 'Orders', + type: 'link', + options: { + relationship: 'manyMany', + foreignTableId: source.id, + lookupFieldId: sourceNameFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + const taskFieldId = target.fields.find((field) => field.name === 'Task')?.id ?? ''; + const budgetFieldId = target.fields.find((field) => field.name === 'Budget')?.id ?? ''; + const linkFieldId = target.fields.find((field) => field.name === 'Orders')?.id ?? ''; + const targetWithLookup = await ctx.createField({ + baseId: ctx.baseId, + tableId: target.id, + field: { + name: 'Order Tags', + type: 'lookup', + options: { + foreignTableId: source.id, + linkFieldId, + lookupFieldId: sourceTagFieldId, + }, + }, + }); + const lookupFieldId = + targetWithLookup.fields.find((field) => field.name === 'Order Tags')?.id ?? ''; + + await ctx.createRecords(target.id, [ + { + fields: { + [taskFieldId]: 'Task A', + [budgetFieldId]: 10, + [linkFieldId]: [{ id: sourceRecords[0].id }], + }, + }, + { + fields: { + [taskFieldId]: 'Task B', + [budgetFieldId]: 30, + [linkFieldId]: [{ id: sourceRecords[1].id }], + }, + }, + { fields: { [taskFieldId]: 'Task C' } }, + ]); + await ctx.drainOutbox(); + + expect( + Number(await aggregateValue(target.id, target.views[0].id, linkFieldId, 'count')) + ).toBe(3); + expect( + Number(await aggregateValue(target.id, target.views[0].id, linkFieldId, 'empty')) + ).toBe(1); + expect( + Number(await aggregateValue(target.id, target.views[0].id, linkFieldId, 'filled')) + ).toBe(2); + expect( + Number(await aggregateValue(target.id, target.views[0].id, linkFieldId, 'percentEmpty')) + ).toBeCloseTo(100 / 3, 4); + expect( + Number(await aggregateValue(target.id, target.views[0].id, linkFieldId, 'percentFilled')) + ).toBeCloseTo(200 / 3, 4); + + // Merely projecting a lookup must not change aggregation of a stored number field. + expect( + Number(await aggregateValue(target.id, target.views[0].id, budgetFieldId, 'sum')) + ).toBe(40); + expect( + Number( + await aggregateValue(target.id, target.views[0].id, budgetFieldId, 'sum', { + fieldId: lookupFieldId, + operator: 'is', + value: 'include', + }) + ) + ).toBe(10); + }, 30000); + + it('sums decimal values from a multi-value number lookup without truncation', async () => { + const source = await ctx.createTable({ + baseId: ctx.baseId, + name: `Aggregation Decimal Lookup Source ${Date.now()}`, + fields: [ + { name: 'Order', type: 'singleLineText', isPrimary: true }, + { name: 'Amount', type: 'number' }, + ], + views: [{ type: 'grid' }], + }); + const sourceNameFieldId = source.fields.find((field) => field.name === 'Order')?.id ?? ''; + const sourceAmountFieldId = source.fields.find((field) => field.name === 'Amount')?.id ?? ''; + const amounts = [299.88, 42.12, 10.5]; + const sourceRecords = await ctx.createRecords( + source.id, + amounts.map((amount, index) => ({ + fields: { + [sourceNameFieldId]: `Order ${index + 1}`, + [sourceAmountFieldId]: amount, + }, + })) + ); + + const target = await ctx.createTable({ + baseId: ctx.baseId, + name: `Aggregation Decimal Lookup Target ${Date.now()}`, + fields: [ + { name: 'Summary', type: 'singleLineText', isPrimary: true }, + { + name: 'Orders', + type: 'link', + options: { + relationship: 'manyMany', + foreignTableId: source.id, + lookupFieldId: sourceNameFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + const summaryFieldId = target.fields.find((field) => field.name === 'Summary')?.id ?? ''; + const linkFieldId = target.fields.find((field) => field.name === 'Orders')?.id ?? ''; + const targetWithLookup = await ctx.createField({ + baseId: ctx.baseId, + tableId: target.id, + field: { + name: 'Order Amounts', + type: 'lookup', + options: { + foreignTableId: source.id, + linkFieldId, + lookupFieldId: sourceAmountFieldId, + }, + }, + }); + const lookupFieldId = + targetWithLookup.fields.find((field) => field.name === 'Order Amounts')?.id ?? ''; + + await ctx.createRecord(target.id, { + [summaryFieldId]: 'All Orders', + [linkFieldId]: sourceRecords.map((record) => ({ id: record.id })), + }); + await ctx.drainOutbox(); + + const sum = await aggregateValue(target.id, target.views[0].id, lookupFieldId, 'sum'); + expect(Number(sum)).toBeCloseTo(352.5, 4); + }, 30000); + }); + + describe('attachment aggregation parity', () => { + it('computes total attachment size for total and grouped buckets', async () => { + await ensureAttachmentTables(ctx); + const file10 = await seedAttachment(ctx, 10); + const file20a = await seedAttachment(ctx, 20); + const file20b = await seedAttachment(ctx, 20); + + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `Aggregation Attachment ${Date.now()}`, + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { + name: 'Group', + type: 'singleSelect', + options: ['A', 'B'], + }, + { name: 'Files', type: 'attachment' }, + ], + views: [{ type: 'grid' }], + }); + const nameFieldId = table.fields.find((field) => field.name === 'Name')?.id ?? ''; + const groupFieldId = table.fields.find((field) => field.name === 'Group')?.id ?? ''; + const attachmentFieldId = table.fields.find((field) => field.name === 'Files')?.id ?? ''; + + await ctx.createRecords(table.id, [ + { + fields: { + [nameFieldId]: 'A-10', + [groupFieldId]: 'A', + [attachmentFieldId]: makeAttachmentCell(file10, '10.bin'), + }, + }, + { + fields: { + [nameFieldId]: 'A-20', + [groupFieldId]: 'A', + [attachmentFieldId]: makeAttachmentCell(file20a, '20-a.bin'), + }, + }, + { + fields: { + [nameFieldId]: 'B-20', + [groupFieldId]: 'B', + [attachmentFieldId]: makeAttachmentCell(file20b, '20-b.bin'), + }, + }, + { fields: { [nameFieldId]: 'Ungrouped' } }, + ]); + await ctx.drainOutbox(); + + const result = await aggregate({ + tableId: table.id, + viewId: table.views[0].id, + fields: [{ fieldId: attachmentFieldId, statisticFunc: 'totalAttachmentSize' }], + groupBy: [{ fieldId: groupFieldId, order: 'asc' }], + }); + const total = result.values.find(({ groupValues }) => groupValues === undefined); + expect(Number(total?.value)).toBe(50); + + const groupedValues = result.values + .filter(({ groupValues }) => groupValues !== undefined) + .map(({ value }) => Number(value)) + .sort((left, right) => left - right); + expect(groupedValues).toEqual([0, 20, 30]); + }, 30000); + }); + + describe('search and empty-table aggregation parity', () => { + it('handles literal question marks and number precision in search bindings', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `Aggregation Search Bindings ${Date.now()}`, + fields: [ + { name: 'URL 1', type: 'singleLineText', isPrimary: true }, + { name: 'URL 2', type: 'singleLineText' }, + { + name: 'Number', + type: 'number', + options: { formatting: { type: 'decimal', precision: 1 } }, + }, + ], + views: [{ type: 'grid' }], + }); + const url1FieldId = table.fields.find((field) => field.name === 'URL 1')?.id ?? ''; + const url2FieldId = table.fields.find((field) => field.name === 'URL 2')?.id ?? ''; + const numberFieldId = table.fields.find((field) => field.name === 'Number')?.id ?? ''; + const url = 'https://example.com/path?param=value'; + await ctx.createRecords(table.id, [ + { fields: { [url1FieldId]: url, [url2FieldId]: 'no', [numberFieldId]: 10.1 } }, + { fields: { [url1FieldId]: 'no', [url2FieldId]: url, [numberFieldId]: 20.2 } }, + { fields: { [url1FieldId]: 'no', [url2FieldId]: 'no', [numberFieldId]: 30.3 } }, + ]); + + const questionMarkResult = await aggregate({ + tableId: table.id, + viewId: table.views[0].id, + search: [url, '', true], + fields: [{ fieldId: url1FieldId, statisticFunc: 'count' }], + }); + expect(Number(questionMarkResult.values[0]?.value)).toBe(2); + + const numberResult = await aggregate({ + tableId: table.id, + viewId: table.views[0].id, + search: ['10', numberFieldId, true], + fields: [{ fieldId: url1FieldId, statisticFunc: 'count' }], + }); + expect(Number(numberResult.values[0]?.value)).toBe(1); + }, 30000); + + it('returns count zero for an empty table', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `Aggregation Empty ${Date.now()}`, + fields: [{ name: 'Name', type: 'singleLineText', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const fieldId = table.fields.find((field) => field.name === 'Name')?.id ?? ''; + + expect(Number(await aggregateValue(table.id, table.views[0].id, fieldId, 'count'))).toBe(0); + }); + }); + + describe('aggregation contract errors', () => { + it('rejects invalid fields and unsupported functions', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `Aggregation Errors ${Date.now()}`, + fields: [{ name: 'Name', type: 'singleLineText', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const fieldId = table.fields.find((field) => field.name === 'Name')?.id ?? ''; + + await expect( + aggregate({ + tableId: table.id, + viewId: table.views[0].id, + fields: [{ fieldId: `fld${'f'.repeat(16)}`, statisticFunc: 'count' }], + }) + ).rejects.toThrow(); + await expect( + aggregate({ + tableId: table.id, + viewId: table.views[0].id, + fields: [{ fieldId, statisticFunc: 'sum' }], + }) + ).rejects.toThrow(); + }); + + it('rejects invalid table and view identifiers', async () => { + await expect( + aggregate({ + tableId: 'invalid-table-id', + viewId: `viw${'f'.repeat(16)}`, + }) + ).rejects.toThrow(); + await expect( + aggregate({ + tableId: `tbl${'f'.repeat(16)}`, + viewId: 'invalid-view-id', + }) + ).rejects.toThrow(); + }); + }); + + // ---------------------------------------------------------------- + // T6520: clearing values ("" / false) stores NULL → Empty / UnChecked + // ---------------------------------------------------------------- + describe('T6520 cleared-value semantics', () => { + it('counts cleared "" and false as Empty / UnChecked', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `Aggregation T6520 ${Date.now()}`, + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Done', type: 'checkbox' }, + ], + views: [{ type: 'grid' }], + }); + const tableId = table.id; + const viewId = table.views[0].id; + const nameFieldId = table.fields.find((f) => f.name === 'Name')?.id ?? ''; + const doneFieldId = table.fields.find((f) => f.name === 'Done')?.id ?? ''; + + const records = await ctx.createRecords(tableId, [ + { fields: { [nameFieldId]: 'A', [doneFieldId]: true } }, + { fields: { [nameFieldId]: 'B', [doneFieldId]: true } }, + { fields: { [nameFieldId]: 'C', [doneFieldId]: true } }, + ]); + await ctx.drainOutbox(); + + // Baseline: everything filled/checked. + expect(Number(await aggregateValue(tableId, viewId, nameFieldId, 'empty'))).toBe(0); + expect(Number(await aggregateValue(tableId, viewId, doneFieldId, 'checked'))).toBe(3); + + // Clear record B: "" and false must store NULL (T6520). + await ctx.updateRecord(tableId, records[1].id, { + [nameFieldId]: '', + [doneFieldId]: false, + }); + await ctx.drainOutbox(); + + expect(Number(await aggregateValue(tableId, viewId, nameFieldId, 'empty'))).toBe(1); + expect(Number(await aggregateValue(tableId, viewId, nameFieldId, 'filled'))).toBe(2); + expect(Number(await aggregateValue(tableId, viewId, doneFieldId, 'checked'))).toBe(2); + expect(Number(await aggregateValue(tableId, viewId, doneFieldId, 'unChecked'))).toBe(1); + }, 30000); + }); +}); diff --git a/packages/v2/e2e/src/auto-number.e2e.spec.ts b/packages/v2/e2e/src/auto-number.e2e.spec.ts index c87ffd6ef9..03c209d5b9 100644 --- a/packages/v2/e2e/src/auto-number.e2e.spec.ts +++ b/packages/v2/e2e/src/auto-number.e2e.spec.ts @@ -165,4 +165,49 @@ describe('v2 auto-number continuity (e2e)', () => { expect(updateRaw.error?.code).toMatch(/validation\.field\.(not_null|invalid_value)/); }); }); + + describe('auto-number continuity across failed creates (v1 auto-number.e2e-spec:45)', () => { + // Regression (T6520): missing notNull fields are rejected before any SQL + // runs, so a failed create no longer consumes the auto-number sequence. + it('does not consume auto numbers on validation-failed creates', async () => { + const titleId = 'fld' + 'pt'.repeat(8); + const requiredId = 'fld' + 'pr'.repeat(8); + const autoId = 'fld' + 'pa'.repeat(8); + const table = await createTable({ + baseId: ctx.baseId, + name: uniqueTableName('auto-number-continuity'), + fields: [ + { type: 'singleLineText', id: titleId, name: 'Title', isPrimary: true }, + { type: 'singleLineText', id: requiredId, name: 'Required', notNull: true }, + { type: 'autoNumber', id: autoId, name: 'No.' }, + ], + views: [{ type: 'grid' }], + }); + + const first = await ctx.createRecord(table.id, { + [titleId]: 'First', + [requiredId]: 'ok', + }); + + // Failing create: notNull "Required" omitted → validation error, no row inserted + const failedResponse = await fetch(`${ctx.baseUrl}/tables/createRecord`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tableId: table.id, fields: { [titleId]: 'Failing' } }), + }); + expect(failedResponse.status).toBeGreaterThanOrEqual(400); + + const second = await ctx.createRecord(table.id, { + [titleId]: 'Second', + [requiredId]: 'ok', + }); + + const records = await ctx.listRecords(table.id); + const firstAuto = records.find((r) => r.id === first.id)?.fields[autoId] as number; + const secondAuto = records.find((r) => r.id === second.id)?.fields[autoId] as number; + expect(typeof firstAuto).toBe('number'); + // v1 contract: the failed attempt must not leave a gap + expect(secondAuto).toBe(firstAuto + 1); + }); + }); }); diff --git a/packages/v2/e2e/src/base-duplicate.e2e.spec.ts b/packages/v2/e2e/src/base-duplicate.e2e.spec.ts index 8b53ee1f1d..7370e98096 100644 --- a/packages/v2/e2e/src/base-duplicate.e2e.spec.ts +++ b/packages/v2/e2e/src/base-duplicate.e2e.spec.ts @@ -1,28 +1,1087 @@ -import { describe, test } from 'vitest'; +import { createV2HttpClient } from '@teable/v2-contract-http-client'; +import { beforeAll, describe, expect, test } from 'vitest'; +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +/** + * v1 reference: community/apps/nestjs-backend/test/base-duplicate.e2e-spec.ts + * + * Native same-container base duplication is exposed through /bases/duplicate. + * Portable schema, record and computed-field cases run below; scenarios that + * still need space, node/plugin or last-visit contracts remain explicit todos. + * Shared expectations for every executable scenario: + * - drain the outbox before asserting computed values, + * - remapped IDs: link cell values must point at duplicated record IDs, + * - T6520: unchecked checkbox cells are stored as null and must stay null + * (never backfilled to false) in the duplicated tables. + */ describe('base duplicate parity (e2e)', () => { - test.todo( - '[V1 PARITY][API GAP] should duplicate base with link field, lookup field and records', - () => { - // BLOCKED BY V2 HTTP CONTRACT GAP - // - // V1 reference: - // - base-duplicate.e2e-spec.ts - // - "duplicate base with link field" - // - // Expected parity scenario: - // 1. Create table1/table2 in source base and create two-way link field. - // 2. Change symmetric relationship (oneMany <-> manyMany) to verify schema stability. - // 3. Add lookup field on linked table and write linked record values. - // 4. Duplicate base with records. - // 5. Assert: - // - linked values in duplicated tables still point to duplicated record IDs - // - lookup values are preserved and continue to update - // - no missing relation errors during subsequent field updates. - // - // Current blocker: - // - v2 contract-http has /bases/create and /bases/list only. - // - No /bases/duplicate endpoint is available yet. - } - ); + let ctx: SharedTestContext; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + }); + + // v1: "duplicate within current space" — duplicate without records; + // duplicated tables exist with schema only, record count is 0. + test('[V1 PARITY] duplicates base within the current space without records', async () => { + const createBaseResponse = await fetch(`${ctx.baseUrl}/bases/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Structure Source' }), + }); + expect(createBaseResponse.status).toBe(201); + const createBaseBody = (await createBaseResponse.json()) as { + ok: boolean; + data?: { base: { id: string } }; + }; + expect(createBaseBody.ok).toBe(true); + const sourceBaseId = createBaseBody.data?.base.id; + expect(sourceBaseId).toBeDefined(); + if (!sourceBaseId) return; + + const sourceTable = await ctx.createTable({ + baseId: sourceBaseId, + name: 'Projects', + fields: [ + { type: 'singleLineText', name: 'Name' }, + { type: 'checkbox', name: 'Done' }, + ], + }); + const client = createV2HttpClient({ baseUrl: ctx.baseUrl }); + const sourceNameFieldId = sourceTable.fields[0]!.id; + const sourceDoneFieldId = sourceTable.fields[1]!.id; + const properties = await client.tables.updateProperties({ + baseId: sourceBaseId, + tableId: sourceTable.id, + description: 'Portable project metadata', + icon: '📋', + }); + expect(properties.ok).toBe(true); + const richView = await client.tables.createView({ + tableId: sourceTable.id, + view: { + type: 'grid', + name: 'Planning', + description: 'Portable planning view', + columnMeta: { [sourceNameFieldId]: { width: 280 } }, + options: { rowHeight: 'short', frozenColumnCount: 1 }, + sort: [{ fieldId: sourceNameFieldId, order: 'asc' }], + group: [{ fieldId: sourceDoneFieldId, order: 'desc' }], + manualSort: false, + isLocked: true, + enableShare: true, + shareMeta: { allowCopy: false, password: 'secret' }, + }, + }); + expect(richView.ok).toBe(true); + if (!richView.ok) throw new Error(richView.error.message); + const sourceRichViewId = richView.data.viewId; + await ctx.createRecord(sourceTable.id, { + [sourceTable.fields[0]!.id]: 'Source record', + [sourceTable.fields[1]!.id]: true, + }); + + const duplicateResponse = await fetch(`${ctx.baseUrl}/bases/duplicate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + sourceBaseId, + name: 'Structure Copy', + withRecords: false, + }), + }); + const duplicateBody = (await duplicateResponse.json()) as { + ok: boolean; + data?: { + base: { id: string; name: string }; + tableIdMap: Record; + fieldIdMap: Record; + viewIdMap: Record; + }; + }; + expect(duplicateResponse.status, JSON.stringify(duplicateBody)).toBe(201); + expect(duplicateBody).toMatchObject({ + ok: true, + data: { + base: { name: 'Structure Copy' }, + tableIdMap: { [sourceTable.id]: expect.any(String) }, + }, + }); + + const targetBaseId = duplicateBody.data?.base.id; + const targetTableId = duplicateBody.data?.tableIdMap[sourceTable.id]; + const targetNameFieldId = duplicateBody.data?.fieldIdMap[sourceNameFieldId]; + const targetDoneFieldId = duplicateBody.data?.fieldIdMap[sourceDoneFieldId]; + const targetRichViewId = duplicateBody.data?.viewIdMap[sourceRichViewId]; + expect(targetBaseId).toBeDefined(); + expect(targetTableId).toBeDefined(); + expect(targetNameFieldId).toBeDefined(); + expect(targetDoneFieldId).toBeDefined(); + expect(targetRichViewId).toBeDefined(); + if ( + !targetBaseId || + !targetTableId || + !targetNameFieldId || + !targetDoneFieldId || + !targetRichViewId + ) + return; + + expect(targetTableId).not.toBe(sourceTable.id); + const targetTable = await ctx.getTableById(targetTableId, targetBaseId); + expect(targetTable).toMatchObject({ + id: targetTableId, + baseId: targetBaseId, + name: 'Projects', + description: 'Portable project metadata', + icon: '📋', + fields: [ + expect.objectContaining({ name: 'Name', type: 'singleLineText' }), + expect.objectContaining({ name: 'Done', type: 'checkbox' }), + ], + }); + const targetView = await client.tables.getView({ + tableId: targetTableId, + viewId: targetRichViewId, + }); + expect(targetView.ok).toBe(true); + if (!targetView.ok) throw new Error(targetView.error.message); + expect(targetView).toMatchObject({ + ok: true, + data: { + view: { + id: targetRichViewId, + name: 'Planning', + description: 'Portable planning view', + options: { rowHeight: 'short', frozenColumnCount: 1 }, + sort: { + sortObjs: [{ fieldId: targetNameFieldId, order: 'asc' }], + manualSort: false, + }, + group: [{ fieldId: targetDoneFieldId, order: 'desc' }], + isLocked: true, + columnMeta: { [targetNameFieldId]: { width: 280 } }, + }, + }, + }); + expect(targetView.data.view.enableShare ?? false).toBe(false); + expect(targetView.data.view).not.toHaveProperty('shareId'); + expect(targetView.data.view).not.toHaveProperty('shareMeta'); + await expect(ctx.listRecords(targetTableId, { baseId: targetBaseId })).resolves.toEqual([]); + }); + + // v1: "duplicate with records" — withRecords: true; every table keeps its + // record data, including null (unchecked) checkbox cells (T6520). + test('[V1 PARITY] duplicates base with records and preserves cell values', async () => { + const createBaseResponse = await fetch(`${ctx.baseUrl}/bases/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Record Source' }), + }); + expect(createBaseResponse.status).toBe(201); + const createBaseBody = (await createBaseResponse.json()) as { + data?: { base: { id: string } }; + }; + const sourceBaseId = createBaseBody.data?.base.id; + expect(sourceBaseId).toBeDefined(); + if (!sourceBaseId) return; + + const sourceTable = await ctx.createTable({ + baseId: sourceBaseId, + name: 'Tasks', + fields: [ + { type: 'singleLineText', name: 'Name' }, + { type: 'checkbox', name: 'Done' }, + ], + }); + const nameFieldId = sourceTable.fields[0]!.id; + const doneFieldId = sourceTable.fields[1]!.id; + await ctx.createRecord(sourceTable.id, { + [nameFieldId]: 'Checked', + [doneFieldId]: true, + }); + await ctx.createRecord(sourceTable.id, { [nameFieldId]: 'Unchecked' }); + + const duplicateResponse = await fetch(`${ctx.baseUrl}/bases/duplicate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sourceBaseId, withRecords: true }), + }); + const duplicateBody = (await duplicateResponse.json()) as { + ok: boolean; + data?: { + base: { id: string; name: string }; + tableIdMap: Record; + fieldIdMap: Record; + recordsLength: number; + }; + }; + expect(duplicateResponse.status, JSON.stringify(duplicateBody)).toBe(201); + expect(duplicateBody).toMatchObject({ + ok: true, + data: { + base: { name: 'Record Source (Copy)' }, + recordsLength: 2, + }, + }); + + const targetBaseId = duplicateBody.data?.base.id; + const targetTableId = duplicateBody.data?.tableIdMap[sourceTable.id]; + const targetNameFieldId = duplicateBody.data?.fieldIdMap[nameFieldId]; + const targetDoneFieldId = duplicateBody.data?.fieldIdMap[doneFieldId]; + expect(targetBaseId).toBeDefined(); + expect(targetTableId).toBeDefined(); + expect(targetNameFieldId).toBeDefined(); + expect(targetDoneFieldId).toBeDefined(); + if (!targetBaseId || !targetTableId || !targetNameFieldId || !targetDoneFieldId) return; + + const records = await ctx.listRecords(targetTableId, { baseId: targetBaseId }); + expect(records).toHaveLength(2); + const checked = records.find((record) => record.fields[targetNameFieldId] === 'Checked'); + const unchecked = records.find((record) => record.fields[targetNameFieldId] === 'Unchecked'); + expect(checked?.fields[targetDoneFieldId]).toBe(true); + expect(unchecked?.fields[targetDoneFieldId] ?? null).toBeNull(); + }); + + // v1: "duplicate base with link field" — two-way link between table1/table2, + // relationship changed oneMany <-> manyMany before duplicating, lookup on + // the linked table. Duplicated link cells must reference duplicated record + // IDs, lookup values must be preserved and continue to update. + test('[V1 PARITY] duplicates base with link field, lookup field and records', async () => { + const createBaseResponse = await fetch(`${ctx.baseUrl}/bases/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Link Source' }), + }); + const createBaseBody = (await createBaseResponse.json()) as { + data?: { base: { id: string } }; + }; + const sourceBaseId = createBaseBody.data?.base.id; + expect(createBaseResponse.status).toBe(201); + expect(sourceBaseId).toBeDefined(); + if (!sourceBaseId) return; + + const productNameId = `fld${'bdpname'.padEnd(16, '0')}`; + const productPriceId = `fld${'bdprice'.padEnd(16, '0')}`; + const orderNameId = `fld${'bdoname'.padEnd(16, '0')}`; + const orderProductId = `fld${'bdolink'.padEnd(16, '0')}`; + const orderPriceId = `fld${'bdolook'.padEnd(16, '0')}`; + const products = await ctx.createTable({ + baseId: sourceBaseId, + name: 'Products', + fields: [ + { type: 'singleLineText', id: productNameId, name: 'Name', isPrimary: true }, + { type: 'number', id: productPriceId, name: 'Price' }, + ], + views: [{ type: 'grid' }], + }); + const orders = await ctx.createTable({ + baseId: sourceBaseId, + name: 'Orders', + fields: [ + { type: 'singleLineText', id: orderNameId, name: 'Name', isPrimary: true }, + { + type: 'link', + id: orderProductId, + name: 'Product', + options: { + relationship: 'manyOne', + foreignTableId: products.id, + lookupFieldId: productNameId, + }, + }, + { + type: 'lookup', + id: orderPriceId, + name: 'Price lookup', + options: { + linkFieldId: orderProductId, + foreignTableId: products.id, + lookupFieldId: productPriceId, + }, + }, + ], + views: [{ type: 'grid' }], + }); + const product = await ctx.createRecord(products.id, { + [productNameId]: 'Keyboard', + [productPriceId]: 99, + }); + await ctx.createRecord(orders.id, { + [orderNameId]: 'Order 1', + [orderProductId]: { id: product.id }, + }); + await ctx.drainOutbox(); + + const duplicateResponse = await fetch(`${ctx.baseUrl}/bases/duplicate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sourceBaseId, withRecords: true }), + }); + const duplicateBody = (await duplicateResponse.json()) as { + ok: boolean; + data?: { + base: { id: string }; + tableIdMap: Record; + fieldIdMap: Record; + }; + error?: unknown; + }; + expect(duplicateResponse.status, JSON.stringify(duplicateBody)).toBe(201); + + const targetBaseId = duplicateBody.data?.base.id; + const targetProductsId = duplicateBody.data?.tableIdMap[products.id]; + const targetOrdersId = duplicateBody.data?.tableIdMap[orders.id]; + const targetProductPriceId = duplicateBody.data?.fieldIdMap[productPriceId]; + const targetOrderProductId = duplicateBody.data?.fieldIdMap[orderProductId]; + const targetOrderPriceId = duplicateBody.data?.fieldIdMap[orderPriceId]; + expect(targetBaseId).toBeDefined(); + expect(targetProductsId).toBeDefined(); + expect(targetOrdersId).toBeDefined(); + expect(targetProductPriceId).toBeDefined(); + expect(targetOrderProductId).toBeDefined(); + expect(targetOrderPriceId).toBeDefined(); + if ( + !targetBaseId || + !targetProductsId || + !targetOrdersId || + !targetProductPriceId || + !targetOrderProductId || + !targetOrderPriceId + ) + return; + + const [targetProduct] = await ctx.listRecords(targetProductsId, { baseId: targetBaseId }); + const [targetOrder] = await ctx.listRecords(targetOrdersId, { baseId: targetBaseId }); + expect(targetOrder?.fields[targetOrderProductId]).toEqual( + expect.objectContaining({ id: targetProduct?.id }) + ); + // Native v2 lookup values keep a uniform array shape regardless of link multiplicity. + expect(targetOrder?.fields[targetOrderPriceId]).toEqual([99]); + + expect(targetProduct).toBeDefined(); + if (!targetProduct) return; + await ctx.updateRecord(targetProductsId, targetProduct.id, { [targetProductPriceId]: 125 }); + await ctx.drainOutbox(); + const [updatedTargetOrder] = await ctx.listRecords(targetOrdersId, { baseId: targetBaseId }); + expect(updatedTargetOrder?.fields[targetOrderPriceId]).toEqual([125]); + }); + + // v1: "should duplicate base with bidirectional link field" + "duplicates + // bidirectional link records through v2 stream copy" — the symmetric field + // pair and the junction table rows must be copied and remapped. + test('[V1 PARITY] duplicates bidirectional link fields and junction data', async () => { + const createBaseResponse = await fetch(`${ctx.baseUrl}/bases/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Bidirectional Link Source' }), + }); + const createBaseBody = (await createBaseResponse.json()) as { + data?: { base: { id: string } }; + }; + const sourceBaseId = createBaseBody.data?.base.id; + expect(createBaseResponse.status).toBe(201); + expect(sourceBaseId).toBeDefined(); + if (!sourceBaseId) return; + + const projectNameId = `fld${'bdproject'.padEnd(16, '0')}`; + const taskNameId = `fld${'bdtask'.padEnd(16, '0')}`; + const taskProjectsId = `fld${'bdtwoway'.padEnd(16, '0')}`; + const projects = await ctx.createTable({ + baseId: sourceBaseId, + name: 'Projects', + fields: [{ type: 'singleLineText', id: projectNameId, name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const tasks = await ctx.createTable({ + baseId: sourceBaseId, + name: 'Tasks', + fields: [ + { type: 'singleLineText', id: taskNameId, name: 'Name', isPrimary: true }, + { + type: 'link', + id: taskProjectsId, + name: 'Projects', + options: { + relationship: 'manyMany', + foreignTableId: projects.id, + lookupFieldId: projectNameId, + }, + }, + ], + views: [{ type: 'grid' }], + }); + const projectsWithLink = await ctx.getTableById(projects.id, sourceBaseId); + const symmetricField = projectsWithLink.fields.find( + (field) => + field.type === 'link' && + (field.options as { symmetricFieldId?: string }).symmetricFieldId === taskProjectsId + ); + expect(symmetricField).toBeDefined(); + if (!symmetricField) return; + + const projectA = await ctx.createRecord(projects.id, { [projectNameId]: 'Project A' }); + const projectB = await ctx.createRecord(projects.id, { [projectNameId]: 'Project B' }); + const taskA = await ctx.createRecord(tasks.id, { + [taskNameId]: 'Task A', + [taskProjectsId]: [{ id: projectA.id }, { id: projectB.id }], + }); + const taskB = await ctx.createRecord(tasks.id, { + [taskNameId]: 'Task B', + [taskProjectsId]: [{ id: projectB.id }], + }); + await ctx.drainOutbox(); + + const sourceTasks = await ctx.listRecords(tasks.id, { baseId: sourceBaseId }); + expect( + sourceTasks.find((record) => record.id === taskA.id)?.fields[taskProjectsId] + ).toMatchObject([{ id: projectA.id }, { id: projectB.id }]); + expect( + sourceTasks.find((record) => record.id === taskB.id)?.fields[taskProjectsId] + ).toMatchObject([{ id: projectB.id }]); + + const duplicateResponse = await fetch(`${ctx.baseUrl}/bases/duplicate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sourceBaseId, withRecords: true }), + }); + const duplicateBody = (await duplicateResponse.json()) as { + data?: { + base: { id: string }; + tableIdMap: Record; + fieldIdMap: Record; + }; + }; + expect(duplicateResponse.status, JSON.stringify(duplicateBody)).toBe(201); + + const targetBaseId = duplicateBody.data?.base.id; + const targetProjectsId = duplicateBody.data?.tableIdMap[projects.id]; + const targetTasksId = duplicateBody.data?.tableIdMap[tasks.id]; + const targetTaskProjectsId = duplicateBody.data?.fieldIdMap[taskProjectsId]; + const targetSymmetricFieldId = duplicateBody.data?.fieldIdMap[symmetricField.id]; + expect(targetBaseId).toBeDefined(); + expect(targetProjectsId).toBeDefined(); + expect(targetTasksId).toBeDefined(); + if ( + !targetBaseId || + !targetProjectsId || + !targetTasksId || + !targetTaskProjectsId || + !targetSymmetricFieldId + ) + return; + + const targetTasks = await ctx.listRecords(targetTasksId, { baseId: targetBaseId }); + const targetProjects = await ctx.listRecords(targetProjectsId, { baseId: targetBaseId }); + const targetTaskA = targetTasks.find((record) => record.id === taskA.id); + const targetTaskB = targetTasks.find((record) => record.id === taskB.id); + const targetProjectA = targetProjects.find((record) => record.id === projectA.id); + const targetProjectB = targetProjects.find((record) => record.id === projectB.id); + + expect(targetTaskA?.fields[targetTaskProjectsId]).toMatchObject([ + { id: targetProjectA?.id }, + { id: targetProjectB?.id }, + ]); + expect(targetTaskB?.fields[targetTaskProjectsId]).toMatchObject([{ id: targetProjectB?.id }]); + expect(targetProjectA?.fields[targetSymmetricFieldId]).toMatchObject([{ id: targetTaskA?.id }]); + const targetProjectBLinks = targetProjectB?.fields[targetSymmetricFieldId]; + expect(targetProjectBLinks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: targetTaskA?.id }), + expect.objectContaining({ id: targetTaskB?.id }), + ]) + ); + expect(targetProjectBLinks).toHaveLength(2); + + const targetProjectsSchema = await ctx.getTableById(targetProjectsId, targetBaseId); + const targetSymmetricField = targetProjectsSchema.fields.find( + (field) => field.id === targetSymmetricFieldId + ); + expect(targetSymmetricField).toMatchObject({ + type: 'link', + options: expect.objectContaining({ + foreignTableId: targetTasksId, + symmetricFieldId: targetTaskProjectsId, + }), + }); + }); + + // v1: "duplicate base with tables which have primary formula field, + // expression with link field" — formula expression field IDs are remapped + // and the formula keeps evaluating in the duplicated base. + test('[V1 PARITY] duplicates primary formula field whose expression references a link field', async () => { + const createBaseResponse = await fetch(`${ctx.baseUrl}/bases/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Primary Formula Source' }), + }); + const createBaseBody = (await createBaseResponse.json()) as { + data?: { base: { id: string } }; + }; + const sourceBaseId = createBaseBody.data?.base.id; + expect(createBaseResponse.status).toBe(201); + expect(sourceBaseId).toBeDefined(); + if (!sourceBaseId) return; + + const categoryNameId = `fld${'bdcatname'.padEnd(16, '0')}`; + const categories = await ctx.createTable({ + baseId: sourceBaseId, + name: 'Categories', + fields: [{ type: 'singleLineText', id: categoryNameId, name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const categoryLinkId = `fld${'bdformlink'.padEnd(16, '0')}`; + const formulaPrimaryId = `fld${'bdformpri'.padEnd(16, '0')}`; + const items = await ctx.createTable({ + baseId: sourceBaseId, + name: 'Items', + fields: [ + { + type: 'link', + id: categoryLinkId, + name: 'Categories', + options: { + relationship: 'manyMany', + foreignTableId: categories.id, + lookupFieldId: categoryNameId, + isOneWay: true, + }, + }, + { + type: 'formula', + id: formulaPrimaryId, + name: 'Display', + isPrimary: true, + options: { expression: `{${categoryLinkId}}` }, + }, + ], + views: [{ type: 'grid' }], + }); + const category = await ctx.createRecord(categories.id, { [categoryNameId]: 'Hardware' }); + const sourceItem = await ctx.createRecord(items.id, { + [categoryLinkId]: [{ id: category.id }], + }); + await ctx.drainOutbox(); + const sourceItemValue = (await ctx.listRecords(items.id, { baseId: sourceBaseId })).find( + (record) => record.id === sourceItem.id + )?.fields[formulaPrimaryId]; + + const duplicateResponse = await fetch(`${ctx.baseUrl}/bases/duplicate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sourceBaseId, withRecords: true }), + }); + const duplicateBody = (await duplicateResponse.json()) as { + data?: { + base: { id: string }; + tableIdMap: Record; + fieldIdMap: Record; + }; + }; + expect(duplicateResponse.status, JSON.stringify(duplicateBody)).toBe(201); + + const targetBaseId = duplicateBody.data?.base.id; + const targetCategoriesId = duplicateBody.data?.tableIdMap[categories.id]; + const targetItemsId = duplicateBody.data?.tableIdMap[items.id]; + const targetCategoryNameId = duplicateBody.data?.fieldIdMap[categoryNameId]; + const targetCategoryLinkId = duplicateBody.data?.fieldIdMap[categoryLinkId]; + const targetFormulaPrimaryId = duplicateBody.data?.fieldIdMap[formulaPrimaryId]; + expect(targetBaseId).toBeDefined(); + expect(targetCategoriesId).toBeDefined(); + expect(targetItemsId).toBeDefined(); + expect(targetCategoryNameId).toBeDefined(); + expect(targetCategoryLinkId).toBeDefined(); + expect(targetFormulaPrimaryId).toBeDefined(); + if ( + !targetBaseId || + !targetCategoriesId || + !targetItemsId || + !targetCategoryNameId || + !targetCategoryLinkId || + !targetFormulaPrimaryId + ) + return; + + const sourceFormulaField = items.fields.find((field) => field.id === formulaPrimaryId); + expect(sourceFormulaField).toMatchObject({ type: 'formula' }); + if (!sourceFormulaField || sourceFormulaField.type !== 'formula') return; + const targetItemsSchema = await ctx.getTableById(targetItemsId, targetBaseId); + const targetFormulaField = targetItemsSchema.fields.find( + (field) => field.id === targetFormulaPrimaryId + ); + expect(targetFormulaField).toMatchObject({ + type: 'formula', + isPrimary: true, + cellValueType: sourceFormulaField?.cellValueType, + options: expect.objectContaining({ + expression: `{${targetCategoryLinkId}}`, + }), + }); + + const targetItem = (await ctx.listRecords(targetItemsId, { baseId: targetBaseId })).find( + (record) => record.id === sourceItem.id + ); + expect(targetItem?.fields[targetFormulaPrimaryId]).toEqual(sourceItemValue); + + await ctx.updateRecord(targetCategoriesId, category.id, { + [targetCategoryNameId]: 'Devices', + }); + await ctx.drainOutbox(); + const updatedTargetItem = (await ctx.listRecords(targetItemsId, { baseId: targetBaseId })).find( + (record) => record.id === sourceItem.id + ); + expect(updatedTargetItem?.fields[targetFormulaPrimaryId]).toContain('Devices'); + }); + + // v1: "duplicates formula, link, lookup, rollup, bidirectional link, and ai + // field config through v2" + "should duplicate ai field relative config" — + // computed chains keep working, aiConfig source field IDs are remapped to + // the duplicated fields. + test('[V1 PARITY] duplicates formula, link, lookup, rollup chains and AI field config', async () => { + const createBaseResponse = await fetch(`${ctx.baseUrl}/bases/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Computed Chain Source' }), + }); + const createBaseBody = (await createBaseResponse.json()) as { + data?: { base: { id: string } }; + }; + const sourceBaseId = createBaseBody.data?.base.id; + expect(createBaseResponse.status).toBe(201); + expect(sourceBaseId).toBeDefined(); + if (!sourceBaseId) return; + + const peopleNameId = `fld${'bdpersonname'.padEnd(16, '0')}`; + const peopleScoreId = `fld${'bdpersonscore'.padEnd(16, '0')}`; + const doubledScoreId = `fld${'bddoubled'.padEnd(16, '0')}`; + const aiSummaryId = `fld${'bdaisummary'.padEnd(16, '0')}`; + const people = await ctx.createTable({ + baseId: sourceBaseId, + name: 'People', + fields: [ + { type: 'singleLineText', id: peopleNameId, name: 'Name', isPrimary: true }, + { type: 'number', id: peopleScoreId, name: 'Score' }, + { + type: 'formula', + id: doubledScoreId, + name: 'Doubled Score', + options: { expression: `{${peopleScoreId}} * 2` }, + }, + { + type: 'singleLineText', + id: aiSummaryId, + name: 'AI Summary', + aiConfig: { + modelKey: 'aiGateway@test@teable', + isAutoFill: true, + type: 'summary', + sourceFieldId: peopleNameId, + }, + }, + ], + views: [{ type: 'grid' }], + }); + + const taskNameId = `fld${'bdctaskname'.padEnd(16, '0')}`; + const taskOwnerId = `fld${'bdcowner'.padEnd(16, '0')}`; + const taskOwnerNameId = `fld${'bdcownername'.padEnd(16, '0')}`; + const taskScoreSumId = `fld${'bdcscoresum'.padEnd(16, '0')}`; + const tasks = await ctx.createTable({ + baseId: sourceBaseId, + name: 'Tasks', + fields: [ + { type: 'singleLineText', id: taskNameId, name: 'Name', isPrimary: true }, + { + type: 'link', + id: taskOwnerId, + name: 'Owners', + options: { + relationship: 'manyMany', + foreignTableId: people.id, + lookupFieldId: peopleNameId, + }, + }, + { + type: 'lookup', + id: taskOwnerNameId, + name: 'Owner Name', + options: { + linkFieldId: taskOwnerId, + foreignTableId: people.id, + lookupFieldId: peopleNameId, + }, + }, + { + type: 'rollup', + id: taskScoreSumId, + name: 'Owner Score Sum', + options: { expression: 'sum({values})' }, + config: { + linkFieldId: taskOwnerId, + foreignTableId: people.id, + lookupFieldId: peopleScoreId, + }, + }, + ], + views: [{ type: 'grid' }], + }); + const peopleWithLink = await ctx.getTableById(people.id, sourceBaseId); + expect(peopleWithLink.fields.find((field) => field.id === aiSummaryId)).toMatchObject({ + aiConfig: expect.objectContaining({ sourceFieldId: peopleNameId }), + }); + const symmetricOwnerField = peopleWithLink.fields.find( + (field) => + field.type === 'link' && + (field.options as { symmetricFieldId?: string }).symmetricFieldId === taskOwnerId + ); + expect(symmetricOwnerField).toBeDefined(); + if (!symmetricOwnerField) return; + + const alice = await ctx.createRecord(people.id, { + [peopleNameId]: 'Alice', + [peopleScoreId]: 11, + }); + const task = await ctx.createRecord(tasks.id, { + [taskNameId]: 'Task A', + [taskOwnerId]: [{ id: alice.id }], + }); + await ctx.drainOutbox(); + + const duplicateResponse = await fetch(`${ctx.baseUrl}/bases/duplicate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sourceBaseId, withRecords: true }), + }); + const duplicateBody = (await duplicateResponse.json()) as { + data?: { + base: { id: string }; + tableIdMap: Record; + fieldIdMap: Record; + }; + }; + expect(duplicateResponse.status, JSON.stringify(duplicateBody)).toBe(201); + + const targetBaseId = duplicateBody.data?.base.id; + const targetPeopleId = duplicateBody.data?.tableIdMap[people.id]; + const targetTasksId = duplicateBody.data?.tableIdMap[tasks.id]; + const targetPeopleNameId = duplicateBody.data?.fieldIdMap[peopleNameId]; + const targetPeopleScoreId = duplicateBody.data?.fieldIdMap[peopleScoreId]; + const targetDoubledScoreId = duplicateBody.data?.fieldIdMap[doubledScoreId]; + const targetAiSummaryId = duplicateBody.data?.fieldIdMap[aiSummaryId]; + const targetTaskOwnerId = duplicateBody.data?.fieldIdMap[taskOwnerId]; + const targetTaskOwnerNameId = duplicateBody.data?.fieldIdMap[taskOwnerNameId]; + const targetTaskScoreSumId = duplicateBody.data?.fieldIdMap[taskScoreSumId]; + const targetSymmetricOwnerId = duplicateBody.data?.fieldIdMap[symmetricOwnerField.id]; + expect(targetBaseId).toBeDefined(); + expect(targetPeopleId).toBeDefined(); + expect(targetTasksId).toBeDefined(); + expect(targetPeopleNameId).toBeDefined(); + expect(targetPeopleScoreId).toBeDefined(); + expect(targetDoubledScoreId).toBeDefined(); + expect(targetAiSummaryId).toBeDefined(); + expect(targetTaskOwnerId).toBeDefined(); + expect(targetTaskOwnerNameId).toBeDefined(); + expect(targetTaskScoreSumId).toBeDefined(); + expect(targetSymmetricOwnerId).toBeDefined(); + if ( + !targetBaseId || + !targetPeopleId || + !targetTasksId || + !targetPeopleNameId || + !targetPeopleScoreId || + !targetDoubledScoreId || + !targetAiSummaryId || + !targetTaskOwnerId || + !targetTaskOwnerNameId || + !targetTaskScoreSumId || + !targetSymmetricOwnerId + ) + return; + + const targetPeopleSchema = await ctx.getTableById(targetPeopleId, targetBaseId); + const targetTasksSchema = await ctx.getTableById(targetTasksId, targetBaseId); + expect( + targetPeopleSchema.fields.find((field) => field.id === targetDoubledScoreId) + ).toMatchObject({ + type: 'formula', + options: expect.objectContaining({ expression: `{${targetPeopleScoreId}} * 2` }), + }); + expect(targetPeopleSchema.fields.find((field) => field.id === targetAiSummaryId)).toMatchObject( + { + aiConfig: expect.objectContaining({ sourceFieldId: targetPeopleNameId }), + } + ); + expect(targetTasksSchema.fields.find((field) => field.id === targetTaskOwnerId)).toMatchObject({ + type: 'link', + options: expect.objectContaining({ foreignTableId: targetPeopleId }), + }); + expect( + targetTasksSchema.fields.find((field) => field.id === targetTaskOwnerNameId) + ).toMatchObject({ + isLookup: true, + lookupOptions: expect.objectContaining({ + linkFieldId: targetTaskOwnerId, + foreignTableId: targetPeopleId, + lookupFieldId: targetPeopleNameId, + }), + }); + expect( + targetTasksSchema.fields.find((field) => field.id === targetTaskScoreSumId) + ).toMatchObject({ + type: 'rollup', + config: expect.objectContaining({ + linkFieldId: targetTaskOwnerId, + foreignTableId: targetPeopleId, + lookupFieldId: targetPeopleScoreId, + }), + }); + + const targetPerson = (await ctx.listRecords(targetPeopleId, { baseId: targetBaseId })).find( + (record) => record.id === alice.id + ); + const targetTask = (await ctx.listRecords(targetTasksId, { baseId: targetBaseId })).find( + (record) => record.id === task.id + ); + expect(targetPerson?.fields[targetDoubledScoreId]).toBe(22); + expect(targetPerson?.fields[targetSymmetricOwnerId]).toMatchObject([{ id: targetTask?.id }]); + expect(targetTask?.fields[targetTaskOwnerId]).toMatchObject([{ id: targetPerson?.id }]); + expect(targetTask?.fields[targetTaskOwnerNameId]).toEqual(['Alice']); + expect(targetTask?.fields[targetTaskScoreSumId]).toBe(11); + + await ctx.updateRecord(targetPeopleId, alice.id, { + [targetPeopleNameId]: 'Alice Updated', + [targetPeopleScoreId]: 13, + }); + await ctx.drainOutbox(); + const updatedTargetPerson = ( + await ctx.listRecords(targetPeopleId, { baseId: targetBaseId }) + ).find((record) => record.id === alice.id); + const updatedTargetTask = (await ctx.listRecords(targetTasksId, { baseId: targetBaseId })).find( + (record) => record.id === task.id + ); + expect(updatedTargetPerson?.fields[targetDoubledScoreId]).toBe(26); + expect(updatedTargetTask?.fields[targetTaskOwnerNameId]).toEqual(['Alice Updated']); + expect(updatedTargetTask?.fields[targetTaskScoreSumId]).toBe(13); + }); + + // v1: "should autoNumber work in a duplicated table" — existing autoNumber + // values are copied and new records continue the sequence. + test('[V1 PARITY] keeps autoNumber sequence working in a duplicated base', async () => { + const createBaseResponse = await fetch(`${ctx.baseUrl}/bases/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Auto Number Source' }), + }); + const createBaseBody = (await createBaseResponse.json()) as { + data?: { base: { id: string } }; + }; + const sourceBaseId = createBaseBody.data?.base.id; + expect(createBaseResponse.status).toBe(201); + expect(sourceBaseId).toBeDefined(); + if (!sourceBaseId) return; + + const sourceTable = await ctx.createTable({ + baseId: sourceBaseId, + name: 'Tickets', + fields: [ + { type: 'singleLineText', name: 'Title' }, + { type: 'autoNumber', name: 'No.' }, + ], + views: [{ type: 'grid' }], + }); + const sourceTitleId = sourceTable.fields[0]!.id; + const sourceAutoNumberId = sourceTable.fields[1]!.id; + await ctx.createRecord(sourceTable.id, { [sourceTitleId]: 'First' }); + await ctx.createRecord(sourceTable.id, { [sourceTitleId]: 'Second' }); + + const duplicateResponse = await fetch(`${ctx.baseUrl}/bases/duplicate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sourceBaseId, withRecords: true }), + }); + const duplicateBody = (await duplicateResponse.json()) as { + data?: { + base: { id: string }; + tableIdMap: Record; + fieldIdMap: Record; + }; + }; + expect(duplicateResponse.status, JSON.stringify(duplicateBody)).toBe(201); + + const targetBaseId = duplicateBody.data?.base.id; + const targetTableId = duplicateBody.data?.tableIdMap[sourceTable.id]; + const targetTitleId = duplicateBody.data?.fieldIdMap[sourceTitleId]; + const targetAutoNumberId = duplicateBody.data?.fieldIdMap[sourceAutoNumberId]; + expect(targetBaseId).toBeDefined(); + expect(targetTableId).toBeDefined(); + expect(targetTitleId).toBeDefined(); + expect(targetAutoNumberId).toBeDefined(); + if (!targetBaseId || !targetTableId || !targetTitleId || !targetAutoNumberId) return; + + const copiedRecords = await ctx.listRecords(targetTableId, { baseId: targetBaseId }); + const copiedNumbers = copiedRecords + .map((record) => record.fields[targetAutoNumberId]) + .filter((value): value is number => typeof value === 'number') + .sort((left, right) => left - right); + expect(copiedNumbers).toEqual([1, 2]); + + const third = await ctx.createRecord(targetTableId, { [targetTitleId]: 'Third' }); + const latestRecords = await ctx.listRecords(targetTableId, { baseId: targetBaseId }); + expect(latestRecords.find((record) => record.id === third.id)?.fields[targetAutoNumberId]).toBe( + 3 + ); + }); + + /** + * Space, node, plugin and last-visit behavior belongs to the Nest host + * coordinator rather than the same-container v2 HTTP harness. Executable + * `forceV2All` coverage lives in: + * community/apps/nestjs-backend/test/base-duplicate.e2e-spec.ts + * + * - cross-space/cross-base link and lookup downgrade, + * - duplication into another space, + * - folder, dashboard and plugin duplication, + * - selected nodes with parent-folder preservation, + * - disconnected link and lookup conversion for a partial graph, + * - last-visit seeding for the recent-base list. + */ + + // v1: "should duplicate link field data correctly with multiple records" — + // one-way multi-value link cells across several records stay consistent + // after record ID remapping. Bidirectional data is covered separately above. + test('[V1 PARITY] duplicates link field data correctly with multiple records', async () => { + const createBaseResponse = await fetch(`${ctx.baseUrl}/bases/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Multi-record Link Source' }), + }); + const createBaseBody = (await createBaseResponse.json()) as { + data?: { base: { id: string } }; + }; + const sourceBaseId = createBaseBody.data?.base.id; + expect(createBaseResponse.status).toBe(201); + expect(sourceBaseId).toBeDefined(); + if (!sourceBaseId) return; + + const categoryNameId = `fld${'bdmulticat'.padEnd(16, '0')}`; + const productNameId = `fld${'bdmultiprod'.padEnd(16, '0')}`; + const productCategoriesId = `fld${'bdmultilink'.padEnd(16, '0')}`; + const categories = await ctx.createTable({ + baseId: sourceBaseId, + name: 'Categories', + fields: [{ type: 'singleLineText', id: categoryNameId, name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const products = await ctx.createTable({ + baseId: sourceBaseId, + name: 'Products', + fields: [ + { type: 'singleLineText', id: productNameId, name: 'Name', isPrimary: true }, + { + type: 'link', + id: productCategoriesId, + name: 'Categories', + options: { + relationship: 'manyMany', + foreignTableId: categories.id, + lookupFieldId: categoryNameId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + + const categoryA = await ctx.createRecord(categories.id, { [categoryNameId]: 'Category A' }); + const categoryB = await ctx.createRecord(categories.id, { [categoryNameId]: 'Category B' }); + await ctx.createRecord(products.id, { + [productNameId]: 'Product A', + [productCategoriesId]: [{ id: categoryA.id }, { id: categoryB.id }], + }); + await ctx.createRecord(products.id, { + [productNameId]: 'Product B', + [productCategoriesId]: [{ id: categoryB.id }], + }); + await ctx.createRecord(products.id, { [productNameId]: 'Product C' }); + + const duplicateResponse = await fetch(`${ctx.baseUrl}/bases/duplicate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sourceBaseId, withRecords: true }), + }); + const duplicateBody = (await duplicateResponse.json()) as { + data?: { + base: { id: string }; + tableIdMap: Record; + fieldIdMap: Record; + }; + }; + expect(duplicateResponse.status, JSON.stringify(duplicateBody)).toBe(201); + + const targetBaseId = duplicateBody.data?.base.id; + const targetCategoriesId = duplicateBody.data?.tableIdMap[categories.id]; + const targetProductsId = duplicateBody.data?.tableIdMap[products.id]; + const targetCategoryNameId = duplicateBody.data?.fieldIdMap[categoryNameId]; + const targetProductNameId = duplicateBody.data?.fieldIdMap[productNameId]; + const targetProductCategoriesId = duplicateBody.data?.fieldIdMap[productCategoriesId]; + expect(targetBaseId).toBeDefined(); + expect(targetCategoriesId).toBeDefined(); + expect(targetProductsId).toBeDefined(); + expect(targetCategoryNameId).toBeDefined(); + expect(targetProductNameId).toBeDefined(); + expect(targetProductCategoriesId).toBeDefined(); + if ( + !targetBaseId || + !targetCategoriesId || + !targetProductsId || + !targetCategoryNameId || + !targetProductNameId || + !targetProductCategoriesId + ) + return; + + const targetCategories = await ctx.listRecords(targetCategoriesId, { baseId: targetBaseId }); + const targetProducts = await ctx.listRecords(targetProductsId, { baseId: targetBaseId }); + const targetCategoryA = targetCategories.find( + (record) => record.fields[targetCategoryNameId] === 'Category A' + ); + const targetCategoryB = targetCategories.find( + (record) => record.fields[targetCategoryNameId] === 'Category B' + ); + const targetProductA = targetProducts.find( + (record) => record.fields[targetProductNameId] === 'Product A' + ); + const targetProductB = targetProducts.find( + (record) => record.fields[targetProductNameId] === 'Product B' + ); + const targetProductC = targetProducts.find( + (record) => record.fields[targetProductNameId] === 'Product C' + ); + + expect(targetProductA?.fields[targetProductCategoriesId]).toMatchObject([ + { id: targetCategoryA?.id }, + { id: targetCategoryB?.id }, + ]); + expect(targetProductB?.fields[targetProductCategoriesId]).toMatchObject([ + { id: targetCategoryB?.id }, + ]); + const emptyLinkValue = targetProductC?.fields[targetProductCategoriesId]; + expect( + emptyLinkValue == null || (Array.isArray(emptyLinkValue) && emptyLinkValue.length === 0) + ).toBe(true); + + const targetProductsSchema = await ctx.getTableById(targetProductsId, targetBaseId); + expect( + targetProductsSchema.fields.find((field) => field.id === targetProductCategoriesId) + ).toMatchObject({ + type: 'link', + options: expect.objectContaining({ + relationship: 'manyMany', + foreignTableId: targetCategoriesId, + lookupFieldId: targetCategoryNameId, + isOneWay: true, + }), + }); + }); }); diff --git a/packages/v2/e2e/src/clear.e2e.spec.ts b/packages/v2/e2e/src/clear.e2e.spec.ts index e853220a78..20edf97872 100644 --- a/packages/v2/e2e/src/clear.e2e.spec.ts +++ b/packages/v2/e2e/src/clear.e2e.spec.ts @@ -1264,6 +1264,301 @@ describe('v2 http clear (e2e)', () => { }); }); + describe('clear with computed dependents (v1 parity)', () => { + it('should refresh formula and lookup dependents after clearing a column', async () => { + const companyTable = await ctx.createTable({ + baseId: ctx.baseId, + name: `Clear Companies ${Date.now()}`, + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'City', type: 'singleLineText' }, + ], + views: [{ type: 'grid' }], + }); + + const companyNameFieldId = companyTable.fields.find((f) => f.isPrimary)?.id ?? ''; + const companyCityFieldId = companyTable.fields.find((f) => f.name === 'City')?.id ?? ''; + + const companyTableWithFormula = await ctx.createField({ + baseId: ctx.baseId, + tableId: companyTable.id, + field: { + name: 'Name Tag', + type: 'formula', + options: { + expression: `IF({${companyNameFieldId}}, {${companyNameFieldId}}, "empty")`, + }, + }, + }); + const nameFormulaFieldId = + companyTableWithFormula.fields.find((field) => field.name === 'Name Tag')?.id ?? ''; + + const contactTable = await ctx.createTable({ + baseId: ctx.baseId, + name: `Clear Contacts ${Date.now()}`, + fields: [{ name: 'Person', type: 'singleLineText', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const personFieldId = contactTable.fields.find((f) => f.isPrimary)?.id ?? ''; + + const contactTableWithLink = await ctx.createField({ + baseId: ctx.baseId, + tableId: contactTable.id, + field: { + name: 'Company', + type: 'link', + options: { + relationship: 'manyOne', + foreignTableId: companyTable.id, + lookupFieldId: companyNameFieldId, + isOneWay: true, + }, + }, + }); + const linkFieldId = + contactTableWithLink.fields.find((field) => field.name === 'Company')?.id ?? ''; + + const contactTableWithLookup = await ctx.createField({ + baseId: ctx.baseId, + tableId: contactTable.id, + field: { + name: 'Company Name', + type: 'lookup', + options: { + linkFieldId, + foreignTableId: companyTable.id, + lookupFieldId: companyNameFieldId, + }, + }, + }); + const companyLookupFieldId = + contactTableWithLookup.fields.find((field) => field.name === 'Company Name')?.id ?? ''; + + const alpha = await ctx.createRecord(companyTable.id, { + [companyNameFieldId]: 'Alpha', + [companyCityFieldId]: 'Paris', + }); + const beta = await ctx.createRecord(companyTable.id, { + [companyNameFieldId]: 'Beta', + [companyCityFieldId]: 'Berlin', + }); + + await ctx.createRecord(contactTable.id, { + [personFieldId]: 'Alice', + [linkFieldId]: { id: alpha.id }, + }); + await ctx.createRecord(contactTable.id, { + [personFieldId]: 'Bob', + [linkFieldId]: { id: beta.id }, + }); + await ctx.drainOutbox(); + + const result = await ctx.clear({ + tableId: companyTable.id, + viewId: companyTable.views[0].id, + ranges: [[0, 0]], + type: 'columns', + }); + + expect(result.updatedCount).toBe(2); + await ctx.drainOutbox(); + + const companyRecords = await ctx.listRecords(companyTable.id); + expect(companyRecords.map((record) => record.fields[companyNameFieldId])).toEqual([ + null, + null, + ]); + expect(companyRecords.map((record) => record.fields[nameFormulaFieldId])).toEqual([ + 'empty', + 'empty', + ]); + expect(companyRecords.map((record) => record.fields[companyCityFieldId])).toEqual([ + 'Paris', + 'Berlin', + ]); + + const contactRecords = await ctx.listRecords(contactTable.id); + expect(contactRecords.map((record) => record.fields[companyLookupFieldId] ?? null)).toEqual([ + null, + null, + ]); + expect(contactRecords.map((record) => record.fields[personFieldId])).toEqual([ + 'Alice', + 'Bob', + ]); + }); + + it('should no-op when clearing a range that only covers a computed column', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `Clear Computed NoOp ${Date.now()}`, + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Score', type: 'number' }, + ], + views: [{ type: 'grid' }], + }); + + const nameFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + const scoreFieldId = table.fields.find((f) => f.name === 'Score')?.id ?? ''; + + const tableWithFormula = await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { + name: 'Doubled', + type: 'formula', + options: { expression: `{${scoreFieldId}} * 2` }, + }, + }); + const formulaFieldId = + tableWithFormula.fields.find((field) => field.name === 'Doubled')?.id ?? ''; + const formulaFieldIndex = tableWithFormula.fields.findIndex( + (field) => field.id === formulaFieldId + ); + + await ctx.createRecord(table.id, { + [nameFieldId]: 'Row 1', + [scoreFieldId]: 10, + }); + await ctx.drainOutbox(); + + const result = await ctx.clear({ + tableId: table.id, + viewId: table.views[0].id, + ranges: [ + [formulaFieldIndex, 0], + [formulaFieldIndex, 0], + ], + }); + + expect(result.updatedCount).toBe(0); + + const records = await ctx.listRecords(table.id); + expect(records[0].fields[nameFieldId]).toBe('Row 1'); + expect(records[0].fields[scoreFieldId]).toBe(10); + expect(records[0].fields[formulaFieldId]).toBe(20); + }); + + it('should skip computed columns but clear editable ones inside the same range', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `Clear Mixed Computed ${Date.now()}`, + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Score', type: 'number' }, + ], + views: [{ type: 'grid' }], + }); + + const nameFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + const scoreFieldId = table.fields.find((f) => f.name === 'Score')?.id ?? ''; + + const tableWithFormula = await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { + name: 'Name Copy', + type: 'formula', + options: { expression: `IF({${nameFieldId}}, {${nameFieldId}}, "empty")` }, + }, + }); + const formulaFieldId = + tableWithFormula.fields.find((field) => field.name === 'Name Copy')?.id ?? ''; + + await ctx.createRecord(table.id, { + [nameFieldId]: 'Row 1', + [scoreFieldId]: 10, + }); + await ctx.drainOutbox(); + + // Range covers all three columns including the trailing computed one. + const result = await ctx.clear({ + tableId: table.id, + viewId: table.views[0].id, + ranges: [ + [0, 0], + [2, 0], + ], + }); + + expect(result.updatedCount).toBe(1); + await ctx.drainOutbox(); + + const records = await ctx.listRecords(table.id); + expect(records[0].fields[nameFieldId]).toBeNull(); + expect(records[0].fields[scoreFieldId]).toBeNull(); + // Computed value refreshed from the cleared dependency, not cleared directly. + expect(records[0].fields[formulaFieldId]).toBe('empty'); + }); + }); + + describe('clear across field types (v1 parity)', () => { + it('should store null for every field type when clearing a whole row', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `Clear Field Types ${Date.now()}`, + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Notes', type: 'longText' }, + { name: 'Count', type: 'number' }, + { name: 'Done', type: 'checkbox' }, + { + name: 'Due', + type: 'date', + options: { + formatting: { date: 'YYYY-MM-DD', time: 'None', timeZone: 'utc' }, + }, + }, + { name: 'Status', type: 'singleSelect', options: ['A', 'B'] }, + { name: 'Tags', type: 'multipleSelect', options: ['X', 'Y'] }, + { + name: 'Stars', + type: 'rating', + options: { max: 5, icon: 'star', color: 'yellowBright' }, + }, + ], + views: [{ type: 'grid' }], + }); + + const fieldByName = new Map(table.fields.map((field) => [field.name, field.id])); + + await ctx.createRecord(table.id, { + [fieldByName.get('Name') ?? '']: 'Row 1', + [fieldByName.get('Notes') ?? '']: 'long note', + [fieldByName.get('Count') ?? '']: 42, + [fieldByName.get('Done') ?? '']: true, + [fieldByName.get('Due') ?? '']: '2026-01-01T00:00:00.000Z', + [fieldByName.get('Status') ?? '']: 'A', + [fieldByName.get('Tags') ?? '']: ['X', 'Y'], + [fieldByName.get('Stars') ?? '']: 3, + }); + + const result = await ctx.clear({ + tableId: table.id, + viewId: table.views[0].id, + ranges: [[0, 0]], + type: 'rows', + }); + + expect(result.updatedCount).toBe(1); + + const records = await ctx.listRecords(table.id); + for (const fieldName of [ + 'Name', + 'Notes', + 'Count', + 'Done', + 'Due', + 'Status', + 'Tags', + 'Stars', + ]) { + expect(records[0].fields[fieldByName.get(fieldName) ?? '']).toBeNull(); + } + }); + }); + describe('clear with search', () => { const notesFieldName = 'Notes'; const categoryFieldName = 'Category'; diff --git a/packages/v2/e2e/src/computed-formula-field-crud.e2e.spec.ts b/packages/v2/e2e/src/computed-formula-field-crud.e2e.spec.ts new file mode 100644 index 0000000000..b58e8b80ed --- /dev/null +++ b/packages/v2/e2e/src/computed-formula-field-crud.e2e.spec.ts @@ -0,0 +1,961 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * E2E tests for formula field CRUD backfill semantics. + * + * Ported from v1 spec: apps/nestjs-backend/test/formula-field.e2e-spec.ts. + * Unlike formula.e2e.spec.ts (formula evaluation on insert/update), these + * cases create the formula field AFTER records exist and assert the computed + * backfill of existing rows, plus formula recalculation contracts on record + * creation with omitted/blank references. + * + * Also ports: + * - apps/nestjs-backend/test/formula-conditional-numeric-cast-regression.e2e-spec.ts + */ +import { beforeAll, describe, expect, it } from 'vitest'; +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +type RecordShape = { id: string; fields: Record }; + +describe('v2 formula field CRUD backfill (e2e)', () => { + let ctx: SharedTestContext; + let fieldIdCounter = 0; + + const createFieldId = () => { + const suffix = `frmcrud${fieldIdCounter.toString(36)}`.padStart(16, '0'); + fieldIdCounter += 1; + return `fld${suffix}`; + }; + + const uniqueName = (prefix: string) => + `${prefix} ${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + + const drainOutbox = async (maxRounds = 10) => { + for (let i = 0; i < maxRounds; i += 1) { + const drained = await ctx.testContainer.processOutbox(); + if (drained === 0) break; + } + }; + + const listRecords = async (tableId: string): Promise => { + await drainOutbox(); + return ctx.listRecords(tableId); + }; + + const getRecord = async (tableId: string, recordId: string): Promise => { + const records = await listRecords(tableId); + const record = records.find((item) => item.id === recordId); + if (!record) throw new Error(`Record not found: ${recordId}`); + return record; + }; + + const createFormulaField = async (tableId: string, name: string, expression: string) => { + const fieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { type: 'formula', id: fieldId, name, options: { expression } }, + }); + return fieldId; + }; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + }, 120_000); + + // --------------------------------------------------------------------------- + // create formula field (backfills existing records) + // --------------------------------------------------------------------------- + + describe('create formula field backfills existing records', () => { + const setupBaseTable = async () => { + const textFieldId = createFieldId(); + const numberFieldId = createFieldId(); + const dateFieldId = createFieldId(); + const ratingFieldId = createFieldId(); + const checkboxFieldId = createFieldId(); + const selectFieldId = createFieldId(); + + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('Formula CRUD Base'), + fields: [ + { type: 'singleLineText', id: textFieldId, name: 'Text Field', isPrimary: true }, + { + type: 'number', + id: numberFieldId, + name: 'Number Field', + options: { formatting: { type: 'decimal', precision: 2 } }, + }, + { type: 'date', id: dateFieldId, name: 'Date Field' }, + { + type: 'rating', + id: ratingFieldId, + name: 'Rating Field', + options: { icon: 'star', max: 5, color: 'yellowBright' }, + }, + { type: 'checkbox', id: checkboxFieldId, name: 'Checkbox Field' }, + { + type: 'singleSelect', + id: selectFieldId, + name: 'Select Field', + options: { + choices: [ + { name: 'Option A', color: 'blue' }, + { name: 'Option B', color: 'red' }, + ], + }, + }, + ], + views: [{ type: 'grid' }], + }); + + const record1 = await ctx.createRecord(table.id, { + [textFieldId]: 'Hello World', + [numberFieldId]: 42.5, + [dateFieldId]: '2024-01-15T00:00:00.000Z', + [ratingFieldId]: 4, + [checkboxFieldId]: true, + [selectFieldId]: 'Option A', + }); + const record2 = await ctx.createRecord(table.id, { + [textFieldId]: 'Test String', + [numberFieldId]: 100, + [dateFieldId]: '2024-02-20T00:00:00.000Z', + [ratingFieldId]: 3, + [checkboxFieldId]: false, + [selectFieldId]: 'Option B', + }); + + return { + table, + textFieldId, + numberFieldId, + dateFieldId, + ratingFieldId, + checkboxFieldId, + selectFieldId, + record1, + record2, + }; + }; + + it('backfills formula referencing text field', async () => { + const { table, textFieldId, record1, record2 } = await setupBaseTable(); + const formulaFieldId = await createFormulaField( + table.id, + 'Text Formula', + `UPPER({${textFieldId}})` + ); + + const records = await listRecords(table.id); + expect(records.find((r) => r.id === record1.id)?.fields[formulaFieldId]).toBe('HELLO WORLD'); + expect(records.find((r) => r.id === record2.id)?.fields[formulaFieldId]).toBe('TEST STRING'); + }); + + it('backfills formula referencing number field', async () => { + const { table, numberFieldId, record1, record2 } = await setupBaseTable(); + const formulaFieldId = await createFormulaField( + table.id, + 'Number Formula', + `{${numberFieldId}} * 2` + ); + + const records = await listRecords(table.id); + expect(records.find((r) => r.id === record1.id)?.fields[formulaFieldId]).toBe(85); + expect(records.find((r) => r.id === record2.id)?.fields[formulaFieldId]).toBe(200); + }); + + it('backfills formula referencing date field', async () => { + const { table, dateFieldId, record1, record2 } = await setupBaseTable(); + const formulaFieldId = await createFormulaField( + table.id, + 'Date Formula', + `YEAR({${dateFieldId}})` + ); + + const records = await listRecords(table.id); + expect(records.find((r) => r.id === record1.id)?.fields[formulaFieldId]).toBe(2024); + expect(records.find((r) => r.id === record2.id)?.fields[formulaFieldId]).toBe(2024); + }); + + it('backfills formula referencing rating field', async () => { + const { table, ratingFieldId, record1, record2 } = await setupBaseTable(); + const formulaFieldId = await createFormulaField( + table.id, + 'Rating Formula', + `{${ratingFieldId}} + 1` + ); + + const records = await listRecords(table.id); + expect(records.find((r) => r.id === record1.id)?.fields[formulaFieldId]).toBe(5); + expect(records.find((r) => r.id === record2.id)?.fields[formulaFieldId]).toBe(4); + }); + + it('backfills formula referencing checkbox field', async () => { + const { table, checkboxFieldId, record1, record2 } = await setupBaseTable(); + const formulaFieldId = await createFormulaField( + table.id, + 'Checkbox Formula', + `IF({${checkboxFieldId}}, "Yes", "No")` + ); + + const records = await listRecords(table.id); + expect(records.find((r) => r.id === record1.id)?.fields[formulaFieldId]).toBe('Yes'); + expect(records.find((r) => r.id === record2.id)?.fields[formulaFieldId]).toBe('No'); + }); + + it('backfills formula referencing select field', async () => { + const { table, selectFieldId, record1, record2 } = await setupBaseTable(); + const formulaFieldId = await createFormulaField( + table.id, + 'Select Formula', + `CONCATENATE("Selected: ", {${selectFieldId}})` + ); + + const records = await listRecords(table.id); + expect(records.find((r) => r.id === record1.id)?.fields[formulaFieldId]).toBe( + 'Selected: Option A' + ); + expect(records.find((r) => r.id === record2.id)?.fields[formulaFieldId]).toBe( + 'Selected: Option B' + ); + }); + + it('substitutes numeric field as text', async () => { + const { table, numberFieldId, record1, record2 } = await setupBaseTable(); + const formulaFieldId = await createFormulaField( + table.id, + 'Number Substitute', + `SUBSTITUTE({${numberFieldId}}, "0", "X")` + ); + + const records = await listRecords(table.id); + expect(records.find((r) => r.id === record1.id)?.fields[formulaFieldId]).toBe('42.5'); + expect(records.find((r) => r.id === record2.id)?.fields[formulaFieldId]).toBe('1XX'); + }); + + it('backfills formula with multiple field references', async () => { + const { table, textFieldId, numberFieldId, record1, record2 } = await setupBaseTable(); + const formulaFieldId = await createFormulaField( + table.id, + 'Multi Field Formula', + `CONCATENATE({${textFieldId}}, " - ", {${numberFieldId}})` + ); + + const records = await listRecords(table.id); + expect(records.find((r) => r.id === record1.id)?.fields[formulaFieldId]).toBe( + 'Hello World - 42.5' + ); + expect(records.find((r) => r.id === record2.id)?.fields[formulaFieldId]).toBe( + 'Test String - 100' + ); + }); + }); + + // --------------------------------------------------------------------------- + // create formula referencing formula + // --------------------------------------------------------------------------- + + describe('create formula referencing formula', () => { + const setupNestedTable = async () => { + const numberFieldId = createFieldId(); + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('Nested Formula'), + fields: [{ type: 'number', id: numberFieldId, name: 'Number Field', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const record1 = await ctx.createRecord(table.id, { [numberFieldId]: 10 }); + const record2 = await ctx.createRecord(table.id, { [numberFieldId]: 20 }); + const baseFormulaFieldId = await createFormulaField( + table.id, + 'Base Formula', + `{${numberFieldId}} * 2` + ); + return { table, numberFieldId, baseFormulaFieldId, record1, record2 }; + }; + + it('backfills formula referencing another formula', async () => { + const { table, baseFormulaFieldId, record1, record2 } = await setupNestedTable(); + const nestedFormulaFieldId = await createFormulaField( + table.id, + 'Nested Formula', + `{${baseFormulaFieldId}} + 5` + ); + + const records = await listRecords(table.id); + expect(records.find((r) => r.id === record1.id)?.fields[nestedFormulaFieldId]).toBe(25); + expect(records.find((r) => r.id === record2.id)?.fields[nestedFormulaFieldId]).toBe(45); + }); + + it('backfills complex nested formula comparing formula to base field', async () => { + const { table, numberFieldId, baseFormulaFieldId, record1, record2 } = + await setupNestedTable(); + const complexFormulaFieldId = await createFormulaField( + table.id, + 'Complex Formula', + `IF({${baseFormulaFieldId}} > {${numberFieldId}}, "Greater", "Not Greater")` + ); + + const records = await listRecords(table.id); + expect(records.find((r) => r.id === record1.id)?.fields[complexFormulaFieldId]).toBe( + 'Greater' + ); + expect(records.find((r) => r.id === record2.id)?.fields[complexFormulaFieldId]).toBe( + 'Greater' + ); + }); + }); + + // --------------------------------------------------------------------------- + // create formula with link, lookup and rollup fields + // --------------------------------------------------------------------------- + + describe('create formula with link, lookup and rollup fields', () => { + const setupLinkedTables = async () => { + const foreignTitleFieldId = createFieldId(); + const foreignValueFieldId = createFieldId(); + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('Formula CRUD Related'), + fields: [ + { type: 'singleLineText', id: foreignTitleFieldId, name: 'Title', isPrimary: true }, + { type: 'number', id: foreignValueFieldId, name: 'Value' }, + ], + views: [{ type: 'grid' }], + }); + const foreignRecord1 = await ctx.createRecord(foreignTable.id, { + [foreignTitleFieldId]: 'Item A', + [foreignValueFieldId]: 100, + }); + const foreignRecord2 = await ctx.createRecord(foreignTable.id, { + [foreignTitleFieldId]: 'Item B', + [foreignValueFieldId]: 200, + }); + + const mainNameFieldId = createFieldId(); + const linkFieldId = createFieldId(); + const mainTable = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('Formula CRUD Main'), + fields: [ + { type: 'singleLineText', id: mainNameFieldId, name: 'Name', isPrimary: true }, + { + type: 'link', + id: linkFieldId, + name: 'Link', + options: { + relationship: 'manyOne', + foreignTableId: foreignTable.id, + lookupFieldId: foreignTitleFieldId, + }, + }, + ], + views: [{ type: 'grid' }], + }); + + const mainRecord1 = await ctx.createRecord(mainTable.id, { + [mainNameFieldId]: 'Record 1', + [linkFieldId]: { id: foreignRecord1.id }, + }); + const mainRecord2 = await ctx.createRecord(mainTable.id, { + [mainNameFieldId]: 'Record 2', + [linkFieldId]: { id: foreignRecord2.id }, + }); + + const lookupFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: mainTable.id, + field: { + type: 'lookup', + id: lookupFieldId, + name: 'Lookup Title', + options: { + linkFieldId, + foreignTableId: foreignTable.id, + lookupFieldId: foreignTitleFieldId, + }, + }, + }); + + const rollupFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: mainTable.id, + field: { + type: 'rollup', + id: rollupFieldId, + name: 'Rollup Value', + options: { expression: 'sum({values})' }, + config: { + linkFieldId, + foreignTableId: foreignTable.id, + lookupFieldId: foreignValueFieldId, + }, + }, + }); + + await drainOutbox(); + + return { + mainTable, + foreignTable, + mainNameFieldId, + linkFieldId, + lookupFieldId, + rollupFieldId, + mainRecord1, + mainRecord2, + }; + }; + + it('backfills formula referencing lookup field', async () => { + const { mainTable, lookupFieldId, mainRecord1, mainRecord2 } = await setupLinkedTables(); + const formulaFieldId = await createFormulaField( + mainTable.id, + 'Lookup Formula', + `{${lookupFieldId}}` + ); + + const records = await listRecords(mainTable.id); + const first = records.find((r) => r.id === mainRecord1.id); + const second = records.find((r) => r.id === mainRecord2.id); + expect(JSON.stringify(first?.fields[formulaFieldId])).toContain('Item A'); + expect(JSON.stringify(second?.fields[formulaFieldId])).toContain('Item B'); + }); + + it('backfills formula referencing rollup field', async () => { + const { mainTable, rollupFieldId, mainRecord1, mainRecord2 } = await setupLinkedTables(); + const formulaFieldId = await createFormulaField( + mainTable.id, + 'Rollup Formula', + `{${rollupFieldId}} * 2` + ); + + const records = await listRecords(mainTable.id); + expect(records.find((r) => r.id === mainRecord1.id)?.fields[formulaFieldId]).toBe(200); + expect(records.find((r) => r.id === mainRecord2.id)?.fields[formulaFieldId]).toBe(400); + }); + + // Regression (T6520): inserts treat all table fields as changed, so + // referenced formulas compute even for records created with zero fields. + it('falls back when rollup-based formula has no linked data', async () => { + const { mainTable, rollupFieldId } = await setupLinkedTables(); + const formulaFieldId = await createFormulaField( + mainTable.id, + 'Rollup Fallback', + `IF({${rollupFieldId}} > 0, "Has rollup", "No rollup")` + ); + + const created = await ctx.createRecord(mainTable.id, {}); + const record = await getRecord(mainTable.id, created.id); + expect(record.fields[formulaFieldId]).toBe('No rollup'); + }); + + it('backfills formula referencing link field', async () => { + const { mainTable, linkFieldId, mainRecord1, mainRecord2 } = await setupLinkedTables(); + const formulaFieldId = await createFormulaField( + mainTable.id, + 'Link Formula', + `IF({${linkFieldId}}, "Has Link", "No Link")` + ); + + const records = await listRecords(mainTable.id); + expect(records.find((r) => r.id === mainRecord1.id)?.fields[formulaFieldId]).toBe('Has Link'); + expect(records.find((r) => r.id === mainRecord2.id)?.fields[formulaFieldId]).toBe('Has Link'); + }); + + it('creates formula that indirectly references link field through another formula', async () => { + const { mainTable, linkFieldId, mainRecord1, mainRecord2 } = await setupLinkedTables(); + const formula2Id = await createFormulaField( + mainTable.id, + 'Formula 2', + `IF({${linkFieldId}}, "Has Link", "No Link")` + ); + const formula1Id = await createFormulaField( + mainTable.id, + 'Formula 1', + `CONCATENATE("Result: ", {${formula2Id}})` + ); + + const records = await listRecords(mainTable.id); + expect(records.find((r) => r.id === mainRecord1.id)?.fields[formula1Id]).toBe( + 'Result: Has Link' + ); + expect(records.find((r) => r.id === mainRecord2.id)?.fields[formula1Id]).toBe( + 'Result: Has Link' + ); + }); + + it('creates formula that indirectly references lookup field through another formula', async () => { + const { mainTable, lookupFieldId, mainRecord1, mainRecord2 } = await setupLinkedTables(); + const formula2Id = await createFormulaField( + mainTable.id, + 'Formula 2', + `CONCATENATE("Lookup: ", {${lookupFieldId}})` + ); + const formula1Id = await createFormulaField( + mainTable.id, + 'Formula 1', + `UPPER({${formula2Id}})` + ); + + const records = await listRecords(mainTable.id); + expect(records.find((r) => r.id === mainRecord1.id)?.fields[formula1Id]).toBe( + 'LOOKUP: ITEM A' + ); + expect(records.find((r) => r.id === mainRecord2.id)?.fields[formula1Id]).toBe( + 'LOOKUP: ITEM B' + ); + }); + + it('creates formula that indirectly references rollup field through another formula', async () => { + const { mainTable, rollupFieldId, mainRecord1, mainRecord2 } = await setupLinkedTables(); + const formula2Id = await createFormulaField( + mainTable.id, + 'Formula 2', + `{${rollupFieldId}} * 2` + ); + const formula1Id = await createFormulaField( + mainTable.id, + 'Formula 1', + `{${formula2Id}} + 10` + ); + + const records = await listRecords(mainTable.id); + expect(records.find((r) => r.id === mainRecord1.id)?.fields[formula1Id]).toBe(210); + expect(records.find((r) => r.id === mainRecord2.id)?.fields[formula1Id]).toBe(410); + }); + + it('creates multi-level formula chain rooted at a rollup field', async () => { + const { mainTable, rollupFieldId, mainRecord1, mainRecord2 } = await setupLinkedTables(); + const formula3Id = await createFormulaField(mainTable.id, 'Formula 3', `{${rollupFieldId}}`); + const formula2Id = await createFormulaField(mainTable.id, 'Formula 2', `{${formula3Id}} * 2`); + const formula1Id = await createFormulaField(mainTable.id, 'Formula 1', `{${formula2Id}} + 5`); + + const records = await listRecords(mainTable.id); + expect(records.find((r) => r.id === mainRecord1.id)?.fields[formula1Id]).toBe(205); + expect(records.find((r) => r.id === mainRecord2.id)?.fields[formula1Id]).toBe(405); + }); + }); + + // --------------------------------------------------------------------------- + // formula recalculation on record creation + // --------------------------------------------------------------------------- + + describe('formula recalculation on record creation', () => { + const setupStatusTable = async () => { + const nameFieldId = createFieldId(); + const statusFieldId = createFieldId(); + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('Formula Status'), + fields: [ + { type: 'singleLineText', id: nameFieldId, name: 'Name', isPrimary: true }, + { type: 'singleLineText', id: statusFieldId, name: 'Status' }, + ], + views: [{ type: 'grid' }], + }); + const statusFormulaFieldId = await createFormulaField( + table.id, + 'Status Formula', + `IF({${statusFieldId}}="", 1, 222222)` + ); + return { table, nameFieldId, statusFieldId, statusFormulaFieldId }; + }; + + it('calculates formula when referenced field is omitted on creation', async () => { + const { table, nameFieldId, statusFieldId, statusFormulaFieldId } = await setupStatusTable(); + const created = await ctx.createRecord(table.id, { [nameFieldId]: 'Missing status' }); + + const record = await getRecord(table.id, created.id); + expect(record.fields[statusFieldId] ?? null).toBeNull(); + expect(record.fields[statusFormulaFieldId]).toBe(1); + }); + + it('calculates alternate branch when referenced field has value', async () => { + const { table, nameFieldId, statusFieldId, statusFormulaFieldId } = await setupStatusTable(); + const created = await ctx.createRecord(table.id, { + [nameFieldId]: 'Has status', + [statusFieldId]: 'done', + }); + + const record = await getRecord(table.id, created.id); + expect(record.fields[statusFormulaFieldId]).toBe(222222); + }); + }); + + // --------------------------------------------------------------------------- + // formula recalculation referencing lookup dependencies + // --------------------------------------------------------------------------- + + describe('formula recalculation referencing lookup dependencies', () => { + const setupLookupFormulaTables = async () => { + const foreignTitleFieldId = createFieldId(); + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('Lookup Source'), + fields: [ + { type: 'singleLineText', id: foreignTitleFieldId, name: 'Title', isPrimary: true }, + ], + views: [{ type: 'grid' }], + }); + const itemA = await ctx.createRecord(foreignTable.id, { [foreignTitleFieldId]: 'Item A' }); + await ctx.createRecord(foreignTable.id, { [foreignTitleFieldId]: 'Item B' }); + + const nameFieldId = createFieldId(); + const linkFieldId = createFieldId(); + const mainTable = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('Lookup Host'), + fields: [ + { type: 'singleLineText', id: nameFieldId, name: 'Name', isPrimary: true }, + { + type: 'link', + id: linkFieldId, + name: 'Link', + options: { + relationship: 'manyOne', + foreignTableId: foreignTable.id, + lookupFieldId: foreignTitleFieldId, + }, + }, + ], + views: [{ type: 'grid' }], + }); + + const lookupFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: mainTable.id, + field: { + type: 'lookup', + id: lookupFieldId, + name: 'Lookup Title', + options: { + linkFieldId, + foreignTableId: foreignTable.id, + lookupFieldId: foreignTitleFieldId, + }, + }, + }); + + const formulaFieldId = await createFormulaField( + mainTable.id, + 'Lookup Formula', + `IF({${lookupFieldId}}="", "no lookup", {${lookupFieldId}})` + ); + + return { mainTable, nameFieldId, linkFieldId, lookupFieldId, formulaFieldId, itemA }; + }; + + it('computes lookup-based formula when link is omitted on creation', async () => { + const { mainTable, nameFieldId, formulaFieldId } = await setupLookupFormulaTables(); + const created = await ctx.createRecord(mainTable.id, { [nameFieldId]: 'No link' }); + + const record = await getRecord(mainTable.id, created.id); + expect(record.fields[formulaFieldId]).toBe('no lookup'); + }); + + it('computes lookup-based formula when link is provided on creation', async () => { + const { mainTable, nameFieldId, linkFieldId, lookupFieldId, formulaFieldId, itemA } = + await setupLookupFormulaTables(); + const created = await ctx.createRecord(mainTable.id, { + [nameFieldId]: 'Linked record', + [linkFieldId]: { id: itemA.id }, + }); + + const record = await getRecord(mainTable.id, created.id); + expect(JSON.stringify(record.fields[lookupFieldId])).toContain('Item A'); + expect(JSON.stringify(record.fields[formulaFieldId])).toContain('Item A'); + }); + }); + + // --------------------------------------------------------------------------- + // lookup formula with blank single select lookup + // --------------------------------------------------------------------------- + + describe('lookup formula with blank single select lookup', () => { + const setupOrdersTables = async () => { + const statusFieldId = createFieldId(); + const planFieldId = createFieldId(); + const ordersTable = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('Orders'), + fields: [ + { + type: 'singleSelect', + id: statusFieldId, + name: 'Status', + isPrimary: true, + options: { + choices: [ + { name: 'Paid', color: 'green' }, + { name: 'Deposit', color: 'blue' }, + ], + }, + }, + { + type: 'singleSelect', + id: planFieldId, + name: 'Plan', + options: { + choices: [ + { name: 'Plan2', color: 'cyan' }, + { name: 'Plan3', color: 'orange' }, + { name: 'Other', color: 'gray' }, + ], + }, + }, + ], + views: [{ type: 'grid' }], + }); + const paidOrder = await ctx.createRecord(ordersTable.id, { + [statusFieldId]: 'Paid', + [planFieldId]: 'Plan2', + }); + await ctx.createRecord(ordersTable.id, { + [statusFieldId]: 'Deposit', + [planFieldId]: 'Plan3', + }); + + const titleFieldId = createFieldId(); + const linkFieldId = createFieldId(); + const followupTable = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('Order Followups'), + fields: [ + { type: 'singleLineText', id: titleFieldId, name: 'Title', isPrimary: true }, + { + type: 'link', + id: linkFieldId, + name: 'Order', + options: { + relationship: 'manyOne', + foreignTableId: ordersTable.id, + lookupFieldId: statusFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + + const statusLookupFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: followupTable.id, + field: { + type: 'lookup', + id: statusLookupFieldId, + name: 'Lookup Status', + options: { + linkFieldId, + foreignTableId: ordersTable.id, + lookupFieldId: statusFieldId, + }, + }, + }); + + const planLookupFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: followupTable.id, + field: { + type: 'lookup', + id: planLookupFieldId, + name: 'Lookup Plan', + options: { + linkFieldId, + foreignTableId: ordersTable.id, + lookupFieldId: planFieldId, + }, + }, + }); + + const formulaFieldId = await createFormulaField( + followupTable.id, + 'Status Notice', + `IF( + {${statusLookupFieldId}}="Paid", + "No reminder", + IF( + AND( + {${statusLookupFieldId}}="Deposit", + OR( + {${planLookupFieldId}}="Plan2", + {${planLookupFieldId}}="Plan3" + ) + ), + "Installment follow-up", + "Tail follow-up" + ) + )` + ); + + return { + followupTable, + titleFieldId, + linkFieldId, + statusLookupFieldId, + planLookupFieldId, + formulaFieldId, + paidOrder, + }; + }; + + it('falls back when lookup is blank', async () => { + const { + followupTable, + titleFieldId, + statusLookupFieldId, + planLookupFieldId, + formulaFieldId, + } = await setupOrdersTables(); + const created = await ctx.createRecord(followupTable.id, { + [titleFieldId]: 'Unlinked order', + }); + + const record = await getRecord(followupTable.id, created.id); + expect(record.fields[statusLookupFieldId] ?? null).toBeNull(); + expect(record.fields[planLookupFieldId] ?? null).toBeNull(); + expect(record.fields[formulaFieldId]).toBe('Tail follow-up'); + }); + + it('uses lookup values when record is linked', async () => { + const { + followupTable, + titleFieldId, + linkFieldId, + statusLookupFieldId, + planLookupFieldId, + formulaFieldId, + paidOrder, + } = await setupOrdersTables(); + const created = await ctx.createRecord(followupTable.id, { + [titleFieldId]: 'Linked order', + [linkFieldId]: { id: paidOrder.id }, + }); + + const record = await getRecord(followupTable.id, created.id); + expect(JSON.stringify(record.fields[statusLookupFieldId])).toContain('Paid'); + expect(JSON.stringify(record.fields[planLookupFieldId])).toContain('Plan2'); + expect(record.fields[formulaFieldId]).toBe('No reminder'); + }); + + it('still falls back when record is created without any field values', async () => { + const { followupTable, statusLookupFieldId, planLookupFieldId, formulaFieldId } = + await setupOrdersTables(); + const created = await ctx.createRecord(followupTable.id, {}); + + const record = await getRecord(followupTable.id, created.id); + expect(record.fields[statusLookupFieldId] ?? null).toBeNull(); + expect(record.fields[planLookupFieldId] ?? null).toBeNull(); + expect(record.fields[formulaFieldId]).toBe('Tail follow-up'); + }); + + it('falls back when the only field sent is explicitly null', async () => { + const { + followupTable, + titleFieldId, + statusLookupFieldId, + planLookupFieldId, + formulaFieldId, + } = await setupOrdersTables(); + const created = await ctx.createRecord(followupTable.id, { [titleFieldId]: null }); + + const record = await getRecord(followupTable.id, created.id); + expect(record.fields[statusLookupFieldId] ?? null).toBeNull(); + expect(record.fields[planLookupFieldId] ?? null).toBeNull(); + expect(record.fields[formulaFieldId]).toBe('Tail follow-up'); + }); + }); + + // --------------------------------------------------------------------------- + // localized single select numeric coercion + // --------------------------------------------------------------------------- + + describe('localized single select numeric coercion', () => { + it('parses localized option labels through VALUE()', async () => { + const durationFieldId = createFieldId(); + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('Localized Duration'), + fields: [ + { + type: 'singleSelect', + id: durationFieldId, + name: '定型时长', + isPrimary: true, + options: { + preventAutoNewOptions: true, + choices: [ + { name: '0分钟', color: 'grayDark1' }, + { name: '20分钟', color: 'blueLight1' }, + { name: '30分钟', color: 'blueBright' }, + ], + }, + }, + ], + views: [{ type: 'grid' }], + }); + const record1 = await ctx.createRecord(table.id, { [durationFieldId]: '0分钟' }); + const record2 = await ctx.createRecord(table.id, { [durationFieldId]: '20分钟' }); + const record3 = await ctx.createRecord(table.id, { [durationFieldId]: '30分钟' }); + + const numericFieldId = await createFormulaField( + table.id, + '定型时长(数值)', + `VALUE({${durationFieldId}})` + ); + + const records = await listRecords(table.id); + expect(records.find((r) => r.id === record1.id)?.fields[numericFieldId]).toBe(0); + expect(records.find((r) => r.id === record2.id)?.fields[numericFieldId]).toBe(20); + expect(records.find((r) => r.id === record3.id)?.fields[numericFieldId]).toBe(30); + }); + }); + + // --------------------------------------------------------------------------- + // conditional numeric cast safety (regression) + // --------------------------------------------------------------------------- + + describe('conditional numeric cast safety (regression)', () => { + it('[V2 CONTRACT] creates rows safely and coerces malformed text by its numeric prefix', async () => { + const displayPriceFieldId = createFieldId(); + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('Numeric Cast Regression'), + fields: [ + { + type: 'singleLineText', + id: displayPriceFieldId, + name: 'DisplayPrice', + isPrimary: true, + }, + ], + views: [{ type: 'grid' }], + }); + const formulaFieldId = await createFormulaField( + table.id, + 'MemberContribution', + `(IF({${displayPriceFieldId}} < 40, 3, IF({${displayPriceFieldId}} < 50, 4, IF({${displayPriceFieldId}} < 75, 5, 8)))) * 1.6` + ); + + const malformed = await ctx.createRecord(table.id, { + [displayPriceFieldId]: '39.9339.93', + }); + const valid = await ctx.createRecord(table.id, { [displayPriceFieldId]: '39.93' }); + + const records = await listRecords(table.id); + const malformedRecord = records.find((r) => r.id === malformed.id); + const validRecord = records.find((r) => r.id === valid.id); + expect(malformedRecord).toBeDefined(); + // V2 intentionally parses the leading numeric prefix instead of treating + // the whole malformed string as non-numeric (the legacy v1 behavior). + expect(Number(malformedRecord?.fields[formulaFieldId])).toBeCloseTo(4.8, 6); + expect(Number(validRecord?.fields[formulaFieldId])).toBeCloseTo(4.8, 6); + }); + }); +}); diff --git a/packages/v2/e2e/src/computed-json-formula-cascade.e2e.spec.ts b/packages/v2/e2e/src/computed-json-formula-cascade.e2e.spec.ts new file mode 100644 index 0000000000..e0aae7ef12 --- /dev/null +++ b/packages/v2/e2e/src/computed-json-formula-cascade.e2e.spec.ts @@ -0,0 +1,276 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * Sanitized production-shaped regression for formula projections over direct and template-derived + * link arrays. + * + * The fixture retains only the failure-causing structure: a target table with roughly 265 rows, + * direct many-many links, a many-one template link, a lookup of the template's many-many links, + * and two ARRAY formulas combining both JSON inputs. Names and values are synthetic. + */ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +const TARGET_RECORD_COUNT = 265; +const CREATE_BATCH_SIZE = 100; + +const isPgliteConnection = () => { + const connectionString = + process.env.TEABLE_V2_TEST_DATABASE_URL ?? + process.env.PRISMA_DATABASE_URL ?? + process.env.DATABASE_URL; + return connectionString?.startsWith('pglite://') || connectionString === 'memory://'; +}; + +const linkTitles = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + if (typeof item === 'string') { + try { + return linkTitles([JSON.parse(item)]).at(0) ?? item; + } catch { + return item; + } + } + if (!item || typeof item !== 'object' || !('title' in item)) return ''; + const title = item.title; + return typeof title === 'string' ? title : ''; + }) + .filter(Boolean) + .sort(); +}; + +describe.skipIf(isPgliteConnection())('v2 JSON formula cascade (e2e)', () => { + let ctx: SharedTestContext; + let fieldSequence = 0; + let cleanupTableIds: string[] = []; + + const createFieldId = (label: string) => { + fieldSequence += 1; + const suffix = `${label}${fieldSequence}`.replaceAll(/[^a-z0-9]/gi, '').slice(0, 16); + return `fld${suffix.padEnd(16, '0')}`; + }; + + const createRecordsInBatches = async ( + tableId: string, + rows: Array<{ fields: Record }> + ) => { + const created: Array<{ id: string; fields: Record }> = []; + for (let offset = 0; offset < rows.length; offset += CREATE_BATCH_SIZE) { + created.push( + ...(await ctx.createRecords(tableId, rows.slice(offset, offset + CREATE_BATCH_SIZE))) + ); + } + return created; + }; + + beforeAll(async () => { + ctx = await getSharedTestContext({ dbMode: 'postgres' }); + expect(ctx.testContainer.connectionString).toMatch(/^postgres(?:ql)?:\/\//); + }, 120_000); + + afterEach(async () => { + for (const tableId of [...cleanupTableIds].reverse()) { + await ctx.deleteTable(tableId, { mode: 'permanent' }).catch(() => undefined); + } + await ctx.testContainer.db + .deleteFrom('computed_update_dead_letter') + .where('base_id', '=', ctx.baseId) + .execute(); + cleanupTableIds = []; + }, 180_000); + + it('computes direct and template-derived link arrays through the same-table CTE path', async () => { + const itemNameFieldId = createFieldId('itemName'); + const itemTable = await ctx.createTable({ + baseId: ctx.baseId, + name: `SyntheticItems_${Date.now()}`, + fields: [ + { + type: 'singleLineText', + id: itemNameFieldId, + name: 'Item name', + isPrimary: true, + }, + ], + views: [{ type: 'grid' }], + }); + cleanupTableIds.push(itemTable.id); + + const [itemA, itemB, itemC] = await ctx.createRecords(itemTable.id, [ + { fields: { [itemNameFieldId]: 'Alpha' } }, + { fields: { [itemNameFieldId]: 'Beta' } }, + { fields: { [itemNameFieldId]: 'Gamma' } }, + ]); + expect(itemA && itemB && itemC).toBeDefined(); + + const templateNameFieldId = createFieldId('templateName'); + const templateItemsFieldId = createFieldId('templateItems'); + const templateTable = await ctx.createTable({ + baseId: ctx.baseId, + name: `SyntheticTemplates_${Date.now()}`, + fields: [ + { + type: 'singleLineText', + id: templateNameFieldId, + name: 'Template name', + isPrimary: true, + }, + { + type: 'link', + id: templateItemsFieldId, + name: 'Template items', + options: { + relationship: 'manyMany', + foreignTableId: itemTable.id, + lookupFieldId: itemNameFieldId, + }, + }, + ], + views: [{ type: 'grid' }], + }); + cleanupTableIds.push(templateTable.id); + + const templateRecord = await ctx.createRecord(templateTable.id, { + [templateNameFieldId]: 'Template A', + [templateItemsFieldId]: [{ id: itemB!.id }, { id: itemC!.id }], + }); + + const targetNameFieldId = createFieldId('targetName'); + const directItemsFieldId = createFieldId('directItems'); + const templateLinkFieldId = createFieldId('templateLink'); + const templateItemsLookupFieldId = createFieldId('templateLookup'); + const compactItemsFormulaFieldId = createFieldId('compactItems'); + const uniqueItemsFormulaFieldId = createFieldId('uniqueItems'); + const targetTable = await ctx.createTable({ + baseId: ctx.baseId, + name: `SyntheticTargets_${Date.now()}`, + fields: [ + { + type: 'singleLineText', + id: targetNameFieldId, + name: 'Target name', + isPrimary: true, + }, + { + type: 'link', + id: directItemsFieldId, + name: 'Direct items', + options: { + relationship: 'manyMany', + foreignTableId: itemTable.id, + lookupFieldId: itemNameFieldId, + }, + }, + { + type: 'link', + id: templateLinkFieldId, + name: 'Template', + options: { + relationship: 'manyOne', + foreignTableId: templateTable.id, + lookupFieldId: templateNameFieldId, + }, + }, + { + type: 'lookup', + id: templateItemsLookupFieldId, + name: 'Template-derived items', + options: { + linkFieldId: templateLinkFieldId, + foreignTableId: templateTable.id, + lookupFieldId: templateItemsFieldId, + }, + }, + { + type: 'formula', + id: compactItemsFormulaFieldId, + name: 'Compact items', + options: { + expression: `ARRAY_COMPACT(ARRAY_UNIQUE(ARRAY_FLATTEN({${directItemsFieldId}}, {${templateItemsLookupFieldId}})))`, + }, + }, + { + type: 'formula', + id: uniqueItemsFormulaFieldId, + name: 'Unique items', + options: { + expression: `ARRAY_UNIQUE(ARRAY_FLATTEN({${directItemsFieldId}}, {${templateItemsLookupFieldId}}))`, + }, + }, + ], + views: [{ type: 'grid' }], + }); + cleanupTableIds.push(targetTable.id); + + const targets = await createRecordsInBatches( + targetTable.id, + Array.from({ length: TARGET_RECORD_COUNT }, (_, index) => ({ + fields: { + [targetNameFieldId]: `Target ${index.toString().padStart(3, '0')}`, + [directItemsFieldId]: [{ id: itemA!.id }, { id: itemB!.id }], + [templateLinkFieldId]: { id: templateRecord.id }, + }, + })) + ); + expect(targets).toHaveLength(TARGET_RECORD_COUNT); + await ctx.drainOutbox(); + + ctx.clearLogs(); + await ctx.updateRecord(templateTable.id, templateRecord.id, { + [templateItemsFieldId]: [{ id: itemB!.id }, { id: itemC!.id }, { id: itemA!.id }], + }); + await ctx.drainOutbox(); + + const [targetRecord] = await ctx.listRecordsWithoutDrain(targetTable.id, { limit: 1 }); + const compactTitles = linkTitles(targetRecord?.fields[compactItemsFormulaFieldId]); + const uniqueTitles = linkTitles(targetRecord?.fields[uniqueItemsFormulaFieldId]); + expect(new Set(compactTitles)).toEqual(new Set(['Alpha', 'Beta', 'Gamma'])); + expect(new Set(uniqueTitles)).toEqual(new Set(['Alpha', 'Beta', 'Gamma'])); + expect(compactTitles.filter((title) => title === 'Beta')).toHaveLength(2); + expect(uniqueTitles.filter((title) => title === 'Beta')).toHaveLength(2); + + const relevantFieldIds = [compactItemsFormulaFieldId, uniqueItemsFormulaFieldId]; + const deadLetters = await ctx.testContainer.db + .selectFrom('computed_update_dead_letter') + .select(['id', 'last_error', 'affected_field_ids']) + .where('base_id', '=', ctx.baseId) + .execute(); + expect( + deadLetters.filter((task) => + task.affected_field_ids.some((fieldId) => relevantFieldIds.includes(fieldId)) + ) + ).toEqual([]); + + const formulaStorage = await ctx.testContainer.db + .selectFrom('field') + .select(['id', 'db_field_name']) + .where('id', 'in', relevantFieldIds) + .execute(); + const formulaColumnNames = formulaStorage.map((field) => field.db_field_name); + expect(formulaColumnNames).toHaveLength(2); + + const targetSqlEntries = ctx.testContainer.spyLogger + .getEntriesByMessage('computed:update:table=') + .filter( + (entry) => + entry.message.includes(`.${targetTable.id}:`) && + formulaColumnNames.every((columnName) => entry.message.includes(`"${columnName}"`)) + ); + // 265 targets / JSON chunk size 25 = 11 chunks. Changed-only continuation + // (with stage budgets on by default) converges in ONE wave: the formulas + // compute correct values inside the staged chain, so the old second + // replan wave — 11 more chunked statements recomputing already-correct + // fields — no longer runs. Value assertions above prove correctness. + expect(targetSqlEntries).toHaveLength(11); + expect( + targetSqlEntries.every( + (entry) => + /:chunk=\d+\/11:sql:/.test(entry.message) && + /\bwith "level_\d+" as materialized\b/i.test(entry.message) && + /as "__record_ids"\("__id"\)/i.test(entry.message) + ) + ).toBe(true); + }, 600_000); +}); diff --git a/packages/v2/e2e/src/computed-stage-budget.e2e.spec.ts b/packages/v2/e2e/src/computed-stage-budget.e2e.spec.ts new file mode 100644 index 0000000000..e9cdc01926 --- /dev/null +++ b/packages/v2/e2e/src/computed-stage-budget.e2e.spec.ts @@ -0,0 +1,1080 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * Dependency-frontier stage budget (BYODB OOM mitigation). + * + * Wide/deep computed plans must not execute as one transaction: with a stage + * budget configured, the worker runs a level-ordered prefix per outbox task and + * continues via a deferred-stage task committed atomically with the stage. + * This spec forces stageMaxSteps=1 so a lookup + formula chain crosses several + * stages, then asserts the values still converge and no dead letters appear. + */ +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { IV2NodeTestContainer } from '@teable/v2-container-node-test'; +import { + createRecordOkResponseSchema, + createTableOkResponseSchema, + listTableRecordsOkResponseSchema, + updateRecordOkResponseSchema, +} from '@teable/v2-contract-http'; +import { createV2ExpressRouter } from '@teable/v2-contract-http-express'; +import { getRandomString } from '@teable/v2-core'; +import express from 'express'; +import { sql } from 'kysely'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { createE2eTestContainer } from './shared/createE2eTestContainer'; + +type TestHarness = { + testContainer: IV2NodeTestContainer; + baseId: string; + baseUrl: string; + close(): Promise; +}; + +const activeHarnesses = new Set(); + +const createFieldId = () => `fld${getRandomString(16)}`; + +const createHarness = async ( + outboxConfig: Record = { stageMaxSteps: 1 } +): Promise => { + const testContainer = await createE2eTestContainer({ + dbMode: 'pglite', + computedUpdate: { + hybridConfig: { + dispatchMode: 'external', + // Full async so every stage runs through the outbox worker. + syncPolicy: 'none', + }, + outboxConfig, + }, + }); + + const app = express(); + app.use( + createV2ExpressRouter({ + createContainer: () => testContainer.container, + }) + ); + + const server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)); + }); + + const address = server.address() as AddressInfo; + const harness: TestHarness = { + testContainer, + baseId: testContainer.baseId.toString(), + baseUrl: `http://127.0.0.1:${address.port}`, + close: async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + await testContainer.dispose(); + activeHarnesses.delete(harness); + }, + }; + + activeHarnesses.add(harness); + return harness; +}; + +afterEach(async () => { + while (activeHarnesses.size > 0) { + const harnesses = [...activeHarnesses]; + const harness = harnesses[harnesses.length - 1]; + if (!harness) break; + await harness.close(); + } +}); + +const createTable = async (harness: TestHarness, payload: Record) => { + const response = await fetch(`${harness.baseUrl}/tables/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + const rawBody = await response.json(); + expect(response.status, JSON.stringify(rawBody)).toBe(201); + const parsed = createTableOkResponseSchema.safeParse(rawBody); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) { + throw new Error(`Failed to create table: ${JSON.stringify(rawBody)}`); + } + return parsed.data.data.table; +}; + +const createRecord = async ( + harness: TestHarness, + tableId: string, + fields: Record +) => { + const response = await fetch(`${harness.baseUrl}/tables/createRecord`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tableId, fields }), + }); + const rawBody = await response.json(); + expect(response.status, JSON.stringify(rawBody)).toBe(201); + const parsed = createRecordOkResponseSchema.safeParse(rawBody); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) { + throw new Error(`Failed to create record: ${JSON.stringify(rawBody)}`); + } + return parsed.data.data.record; +}; + +const updateRecord = async ( + harness: TestHarness, + tableId: string, + recordId: string, + fields: Record +) => { + const response = await fetch(`${harness.baseUrl}/tables/updateRecord`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tableId, recordId, fields }), + }); + const rawBody = await response.json(); + expect(response.status, JSON.stringify(rawBody)).toBe(200); + const parsed = updateRecordOkResponseSchema.safeParse(rawBody); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) { + throw new Error(`Failed to update record: ${JSON.stringify(rawBody)}`); + } + return parsed.data.data.record; +}; + +const listRecords = async ( + harness: TestHarness, + tableId: string +): Promise }>> => { + const params = new URLSearchParams({ tableId }); + const response = await fetch(`${harness.baseUrl}/tables/listRecords?${params.toString()}`, { + method: 'GET', + headers: { 'content-type': 'application/json' }, + }); + const rawBody = await response.json(); + expect(response.status, JSON.stringify(rawBody)).toBe(200); + const parsed = listTableRecordsOkResponseSchema.safeParse(rawBody); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) { + throw new Error(`Failed to list records: ${JSON.stringify(rawBody)}`); + } + return parsed.data.data.records; +}; + +const parseArrayCell = (value: unknown): unknown[] => { + if (Array.isArray(value)) return value; + if (typeof value !== 'string') return []; + try { + const parsed = JSON.parse(value) as unknown; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +}; + +const cellText = (value: unknown): string => { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value.map((entry) => cellText(entry)).join(','); + } + if (typeof value === 'object' && value && 'title' in value) { + return String((value as { title?: unknown }).title ?? ''); + } + return String(value); +}; + +/** Drain the outbox to empty, returning the total number of processed tasks. */ +const drainOutbox = async (harness: TestHarness, rounds = 120): Promise => { + let totalProcessed = 0; + for (let i = 0; i < rounds; i += 1) { + const processed = await harness.testContainer.processOutboxOnce(); + totalProcessed += processed; + if (processed > 0) continue; + + const counts = await sql<{ cnt: number }>` + SELECT count(*)::int as cnt + FROM computed_update_outbox + WHERE status IN ('pending', 'processing') + `.execute(harness.testContainer.db); + if (Number(counts.rows[0]?.cnt ?? 0) === 0) { + return totalProcessed; + } + // Requeued tasks schedule next_run_at slightly in the future. + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error('Outbox did not quiesce under stage budget'); +}; + +describe('computed stage budget continuation (e2e)', () => { + it('converges a lookup + formula chain split across multiple bounded stages', async () => { + const harness = await createHarness(); + + const parentNameFieldId = createFieldId(); + const childLinkFieldId = createFieldId(); + const childLookupFieldId = createFieldId(); + const childL1FieldId = createFieldId(); + const childL2FieldId = createFieldId(); + + const parentTable = await createTable(harness, { + baseId: harness.baseId, + name: 'Parents', + fields: [{ type: 'singleLineText', id: parentNameFieldId, name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + + const childTable = await createTable(harness, { + baseId: harness.baseId, + name: 'Children', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'link', + id: childLinkFieldId, + name: 'Parent', + options: { + relationship: 'manyOne', + foreignTableId: parentTable.id, + lookupFieldId: parentNameFieldId, + }, + }, + { + type: 'lookup', + id: childLookupFieldId, + name: 'ParentName', + options: { + linkFieldId: childLinkFieldId, + foreignTableId: parentTable.id, + lookupFieldId: parentNameFieldId, + }, + }, + { + type: 'formula', + id: childL1FieldId, + name: 'L1', + options: { expression: `CONCATENATE({${childLookupFieldId}}, "-L1")` }, + }, + { + type: 'formula', + id: childL2FieldId, + name: 'L2', + options: { expression: `CONCATENATE({${childL1FieldId}}, "-L2")` }, + }, + ], + views: [{ type: 'grid' }], + }); + + const parent = await createRecord(harness, parentTable.id, { + [parentNameFieldId]: 'Alpha', + }); + const childA = await createRecord(harness, childTable.id, { + Title: 'A', + [childLinkFieldId]: { id: parent.id }, + }); + const childB = await createRecord(harness, childTable.id, { + Title: 'B', + [childLinkFieldId]: { id: parent.id }, + }); + + await drainOutbox(harness); + + const assertChildren = async (expected: string) => { + const records = await listRecords(harness, childTable.id); + for (const childId of [childA.id, childB.id]) { + const row = records.find((record) => record.id === childId); + expect(row).toBeDefined(); + const lookup = cellText( + parseArrayCell(row?.fields[childLookupFieldId])[0] ?? row?.fields[childLookupFieldId] + ); + expect(lookup).toBe(expected); + expect(cellText(row?.fields[childL1FieldId])).toBe(`${expected}-L1`); + expect(cellText(row?.fields[childL2FieldId])).toBe(`${expected}-L1-L2`); + } + }; + + await assertChildren('Alpha'); + + await updateRecord(harness, parentTable.id, parent.id, { + [parentNameFieldId]: 'Alpha-updated', + }); + + // stageMaxSteps=1 forces the seed task plus at least one deferred continuation. + const processed = await drainOutbox(harness); + expect(processed).toBeGreaterThanOrEqual(2); + + await assertChildren('Alpha-updated'); + + const dead = await sql<{ cnt: number }>` + SELECT count(*)::int as cnt FROM computed_update_dead_letter + `.execute(harness.testContainer.db); + expect(Number(dead.rows[0]?.cnt ?? 0)).toBe(0); + }, 120_000); + + it('hard-splits a wide same-level field fan across stages', async () => { + // One table, five same-level formulas on the same source field: the planner + // merges them into a single step, so only the field budget can split it. + const harness = await createHarness({ + stageMaxSteps: 0, + stageMaxFields: 2, + stageMaxEdges: 0, + stageMaxDirtyRecords: 0, + }); + + const sourceFieldId = createFieldId(); + const formulaFieldIds = Array.from({ length: 5 }, () => createFieldId()); + + const table = await createTable(harness, { + baseId: harness.baseId, + name: 'WideFan', + fields: [ + { type: 'singleLineText', id: sourceFieldId, name: 'Source', isPrimary: true }, + ...formulaFieldIds.map((fieldId, index) => ({ + type: 'formula', + id: fieldId, + name: `F${index}`, + options: { expression: `CONCATENATE({${sourceFieldId}}, "-F${index}")` }, + })), + ], + views: [{ type: 'grid' }], + }); + + const record = await createRecord(harness, table.id, { [sourceFieldId]: 'Seed' }); + await drainOutbox(harness); + + await updateRecord(harness, table.id, record.id, { [sourceFieldId]: 'Seed-updated' }); + + // 5 fields at 2 per stage: at least the seed task plus two continuations. + const processed = await drainOutbox(harness); + expect(processed).toBeGreaterThanOrEqual(3); + + const rows = await listRecords(harness, table.id); + const row = rows.find((entry) => entry.id === record.id); + expect(row).toBeDefined(); + for (const [index, fieldId] of formulaFieldIds.entries()) { + expect(cellText(row?.fields[fieldId])).toBe(`Seed-updated-F${index}`); + } + + const dead = await sql<{ cnt: number }>` + SELECT count(*)::int as cnt FROM computed_update_dead_letter + `.execute(harness.testContainer.db); + expect(Number(dead.rows[0]?.cnt ?? 0)).toBe(0); + }, 120_000); + + it('reaches targets only later edge chunks touch, across partial batches (AJ shape)', async () => { + // AJ-shaped lifecycle: one parent field fans out through THREE separate + // link/lookup pairs (3 edges into 3 lookup fields) under stageMaxEdges=2, + // so the plan chunks; stageMaxDirtyRecords=2 forces floor partial batches, + // migrating and retiring the parent seed through the ledger frontier. + // Half the children are reachable ONLY via the third link — the edge that + // runs in the deferred chunk. Without consumed-source preservation the + // deferred chunk would have no parent seeds left and those rows would stay + // stale forever. + const harness = await createHarness({ + stageMaxSteps: 0, + stageMaxFields: 0, + stageMaxEdges: 2, + stageMaxDirtyRecords: 2, + }); + + const parentNameFieldId = createFieldId(); + const linkFieldIds = [createFieldId(), createFieldId(), createFieldId()]; + const lookupFieldIds = [createFieldId(), createFieldId(), createFieldId()]; + + const parentTable = await createTable(harness, { + baseId: harness.baseId, + name: 'FanParents', + fields: [{ type: 'singleLineText', id: parentNameFieldId, name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + + const childTable = await createTable(harness, { + baseId: harness.baseId, + name: 'FanChildren', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + ...linkFieldIds.flatMap((linkFieldId, index) => [ + { + type: 'link', + id: linkFieldId, + name: `Link${index}`, + options: { + relationship: 'manyOne', + foreignTableId: parentTable.id, + lookupFieldId: parentNameFieldId, + }, + }, + { + type: 'lookup', + id: lookupFieldIds[index], + name: `Lookup${index}`, + options: { + linkFieldId, + foreignTableId: parentTable.id, + lookupFieldId: parentNameFieldId, + }, + }, + ]), + ], + views: [{ type: 'grid' }], + }); + + const parent = await createRecord(harness, parentTable.id, { + [parentNameFieldId]: 'Fan', + }); + // 3 children linked via ALL links; 3 linked ONLY via the last link. + const allLinkChildren = []; + for (let i = 0; i < 3; i += 1) { + allLinkChildren.push( + await createRecord(harness, childTable.id, { + Title: `All${i}`, + [linkFieldIds[0]]: { id: parent.id }, + [linkFieldIds[1]]: { id: parent.id }, + [linkFieldIds[2]]: { id: parent.id }, + }) + ); + } + const lastLinkChildren = []; + for (let i = 0; i < 3; i += 1) { + lastLinkChildren.push( + await createRecord(harness, childTable.id, { + Title: `Last${i}`, + [linkFieldIds[2]]: { id: parent.id }, + }) + ); + } + + await drainOutbox(harness); + + await updateRecord(harness, parentTable.id, parent.id, { + [parentNameFieldId]: 'Fan-updated', + }); + + // Chunked edges + floor partial batches: several tasks must run. + const processed = await drainOutbox(harness); + expect(processed).toBeGreaterThanOrEqual(2); + + const records = await listRecords(harness, childTable.id); + for (const child of allLinkChildren) { + const row = records.find((record) => record.id === child.id); + expect(row).toBeDefined(); + for (const lookupFieldId of lookupFieldIds) { + const lookup = cellText( + parseArrayCell(row?.fields[lookupFieldId])[0] ?? row?.fields[lookupFieldId] + ); + expect(lookup).toBe('Fan-updated'); + } + } + // The rows only the deferred chunk's edge reaches must not be stale. + for (const child of lastLinkChildren) { + const row = records.find((record) => record.id === child.id); + expect(row).toBeDefined(); + const lookup = cellText( + parseArrayCell(row?.fields[lookupFieldIds[2]])[0] ?? row?.fields[lookupFieldIds[2]] + ); + expect(lookup).toBe('Fan-updated'); + } + + const dead = await sql<{ cnt: number }>` + SELECT count(*)::int as cnt FROM computed_update_dead_letter + `.execute(harness.testContainer.db); + expect(Number(dead.rows[0]?.cnt ?? 0)).toBe(0); + // The stage ledger fully drains once the chain completes. + const ledger = await sql<{ cnt: number }>` + SELECT count(*)::int as cnt FROM computed_update_stage_ledger + `.execute(harness.testContainer.db); + expect(Number(ledger.rows[0]?.cnt ?? 0)).toBe(0); + }, 120_000); + + it('clears lookups after a parent delete under active stage budgets', async () => { + const harness = await createHarness({ + stageMaxSteps: 0, + stageMaxFields: 0, + stageMaxEdges: 2, + stageMaxDirtyRecords: 2, + }); + + const parentNameFieldId = createFieldId(); + const linkFieldIds = [createFieldId(), createFieldId(), createFieldId()]; + const lookupFieldIds = [createFieldId(), createFieldId(), createFieldId()]; + + const parentTable = await createTable(harness, { + baseId: harness.baseId, + name: 'DelParents', + fields: [{ type: 'singleLineText', id: parentNameFieldId, name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const childTable = await createTable(harness, { + baseId: harness.baseId, + name: 'DelChildren', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + ...linkFieldIds.flatMap((linkFieldId, index) => [ + { + type: 'link', + id: linkFieldId, + name: `Link${index}`, + options: { + relationship: 'manyOne', + foreignTableId: parentTable.id, + lookupFieldId: parentNameFieldId, + }, + }, + { + type: 'lookup', + id: lookupFieldIds[index], + name: `Lookup${index}`, + options: { + linkFieldId, + foreignTableId: parentTable.id, + lookupFieldId: parentNameFieldId, + }, + }, + ]), + ], + views: [{ type: 'grid' }], + }); + + const parent = await createRecord(harness, parentTable.id, { [parentNameFieldId]: 'Fan' }); + const children = []; + for (let i = 0; i < 3; i += 1) { + children.push( + await createRecord(harness, childTable.id, { + Title: `C${i}`, + [linkFieldIds[0]]: { id: parent.id }, + [linkFieldIds[1]]: { id: parent.id }, + [linkFieldIds[2]]: { id: parent.id }, + }) + ); + } + await drainOutbox(harness); + + const deleteResponse = await fetch(`${harness.baseUrl}/tables/deleteRecords`, { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tableId: parentTable.id, recordIds: [parent.id] }), + }); + expect(deleteResponse.status).toBe(200); + await drainOutbox(harness); + + const afterDelete = await listRecords(harness, childTable.id); + const staleCells: string[] = []; + for (const child of children) { + const row = afterDelete.find((record) => record.id === child.id); + for (const [index, lookupFieldId] of lookupFieldIds.entries()) { + const lookup = cellText( + parseArrayCell(row?.fields[lookupFieldId])[0] ?? row?.fields[lookupFieldId] + ); + if (lookup !== '') staleCells.push(`${child.id}/lookup${index}=${lookup}`); + } + } + expect(staleCells, 'stale lookups after parent delete').toEqual([]); + }, 120_000); + + it('converges when a continuation carries only a seed-all table', async () => { + // Low seed-all threshold turns the stage's dirty rows into seedAllTableIds and + // seed narrowing drops the original seeds — the continuation must not be + // mistaken for a schema-update "seed everything" run (which would seed the + // wrong table and leave downstream fields stale). + const harness = await createHarness({ + stageMaxSteps: 1, + stageMaxFields: 0, + stageMaxEdges: 0, + stageMaxDirtyRecords: 0, + stageSeedAllThreshold: 2, + }); + + const parentNameFieldId = createFieldId(); + const childLinkFieldId = createFieldId(); + const childLookupFieldId = createFieldId(); + const childL1FieldId = createFieldId(); + + const parentTable = await createTable(harness, { + baseId: harness.baseId, + name: 'Parents', + fields: [{ type: 'singleLineText', id: parentNameFieldId, name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + + const childTable = await createTable(harness, { + baseId: harness.baseId, + name: 'Children', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'link', + id: childLinkFieldId, + name: 'Parent', + options: { + relationship: 'manyOne', + foreignTableId: parentTable.id, + lookupFieldId: parentNameFieldId, + }, + }, + { + type: 'lookup', + id: childLookupFieldId, + name: 'ParentName', + options: { + linkFieldId: childLinkFieldId, + foreignTableId: parentTable.id, + lookupFieldId: parentNameFieldId, + }, + }, + { + type: 'formula', + id: childL1FieldId, + name: 'L1', + options: { expression: `CONCATENATE({${childLookupFieldId}}, "-L1")` }, + }, + ], + views: [{ type: 'grid' }], + }); + + const parent = await createRecord(harness, parentTable.id, { + [parentNameFieldId]: 'Root', + }); + const children = []; + for (let i = 0; i < 4; i += 1) { + children.push( + await createRecord(harness, childTable.id, { + Title: `C${i}`, + [childLinkFieldId]: { id: parent.id }, + }) + ); + } + + await drainOutbox(harness); + + await updateRecord(harness, parentTable.id, parent.id, { + [parentNameFieldId]: 'Root-updated', + }); + + const processed = await drainOutbox(harness); + expect(processed).toBeGreaterThanOrEqual(2); + + const records = await listRecords(harness, childTable.id); + for (const child of children) { + const row = records.find((record) => record.id === child.id); + expect(row).toBeDefined(); + const lookup = cellText( + parseArrayCell(row?.fields[childLookupFieldId])[0] ?? row?.fields[childLookupFieldId] + ); + expect(lookup).toBe('Root-updated'); + expect(cellText(row?.fields[childL1FieldId])).toBe('Root-updated-L1'); + } + + const dead = await sql<{ cnt: number }>` + SELECT count(*)::int as cnt FROM computed_update_dead_letter + `.execute(harness.testContainer.db); + expect(Number(dead.rows[0]?.cnt ?? 0)).toBe(0); + }, 120_000); + + it('makes progress when a seed-all source table exceeds the dirty budget', async () => { + // The continuation carries seedAllTableIds for a SOURCE table whose step lives + // downstream: bounded seeding truncates, and propagation must still run so the + // batch produces targets and exclusions — otherwise the continuation repeats + // the same first rows forever without progress. + const harness = await createHarness({ + stageMaxSteps: 1, + stageMaxFields: 0, + stageMaxEdges: 0, + stageMaxDirtyRecords: 2, + stageSeedAllThreshold: 2, + }); + + const parentNameFieldId = createFieldId(); + const midLinkFieldId = createFieldId(); + const midLookupFieldId = createFieldId(); + const leafLinkFieldId = createFieldId(); + const leafLookupFieldId = createFieldId(); + + const parentTable = await createTable(harness, { + baseId: harness.baseId, + name: 'Parents', + fields: [{ type: 'singleLineText', id: parentNameFieldId, name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + + const midTable = await createTable(harness, { + baseId: harness.baseId, + name: 'Mids', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'link', + id: midLinkFieldId, + name: 'Parent', + options: { + relationship: 'manyOne', + foreignTableId: parentTable.id, + lookupFieldId: parentNameFieldId, + }, + }, + { + type: 'lookup', + id: midLookupFieldId, + name: 'ParentName', + options: { + linkFieldId: midLinkFieldId, + foreignTableId: parentTable.id, + lookupFieldId: parentNameFieldId, + }, + }, + ], + views: [{ type: 'grid' }], + }); + + const leafTable = await createTable(harness, { + baseId: harness.baseId, + name: 'Leaves', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'link', + id: leafLinkFieldId, + name: 'Mid', + options: { + relationship: 'manyOne', + foreignTableId: midTable.id, + lookupFieldId: midLookupFieldId, + }, + }, + { + type: 'lookup', + id: leafLookupFieldId, + name: 'MidParentName', + options: { + linkFieldId: leafLinkFieldId, + foreignTableId: midTable.id, + lookupFieldId: midLookupFieldId, + }, + }, + ], + views: [{ type: 'grid' }], + }); + + const parent = await createRecord(harness, parentTable.id, { + [parentNameFieldId]: 'Origin', + }); + const leaves = []; + for (let i = 0; i < 4; i += 1) { + const mid = await createRecord(harness, midTable.id, { + Title: `M${i}`, + [midLinkFieldId]: { id: parent.id }, + }); + leaves.push( + await createRecord(harness, leafTable.id, { + Title: `L${i}`, + [leafLinkFieldId]: { id: mid.id }, + }) + ); + } + + await drainOutbox(harness); + + await updateRecord(harness, parentTable.id, parent.id, { + [parentNameFieldId]: 'Origin-updated', + }); + + const processed = await drainOutbox(harness); + expect(processed).toBeGreaterThanOrEqual(2); + + const records = await listRecords(harness, leafTable.id); + for (const leaf of leaves) { + const row = records.find((record) => record.id === leaf.id); + expect(row).toBeDefined(); + const lookup = cellText( + parseArrayCell(row?.fields[leafLookupFieldId])[0] ?? row?.fields[leafLookupFieldId] + ); + expect(lookup).toBe('Origin-updated'); + } + + const dead = await sql<{ cnt: number }>` + SELECT count(*)::int as cnt FROM computed_update_dead_letter + `.execute(harness.testContainer.db); + expect(Number(dead.rows[0]?.cnt ?? 0)).toBe(0); + }, 120_000); + + it('converges a self-referential link chain under the dirty budget', async () => { + // Self-link lookups propagate generation by generation within one table; the + // budgeted floor re-seeds processed batches as dirty so later generations are + // still reachable instead of running unguarded. + const harness = await createHarness({ + stageMaxSteps: 0, + stageMaxFields: 0, + stageMaxEdges: 0, + stageMaxDirtyRecords: 2, + }); + + const nameFieldId = createFieldId(); + const table = await createTable(harness, { + baseId: harness.baseId, + name: 'SelfChain', + fields: [{ type: 'singleLineText', id: nameFieldId, name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + + const createField = async (field: { id: string } & Record) => { + const response = await fetch(`${harness.baseUrl}/tables/createField`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ baseId: harness.baseId, tableId: table.id, field }), + }); + const rawBody: unknown = await response.json(); + expect(response.ok, JSON.stringify(rawBody)).toBe(true); + return String(field.id); + }; + + const selfLinkFieldId = await createField({ + id: createFieldId(), + type: 'link', + name: 'Parent', + options: { + relationship: 'manyOne', + foreignTableId: table.id, + lookupFieldId: nameFieldId, + }, + }); + const parentNameLookupId = await createField({ + id: createFieldId(), + type: 'lookup', + name: 'ParentName', + options: { + linkFieldId: selfLinkFieldId, + foreignTableId: table.id, + lookupFieldId: nameFieldId, + }, + }); + + const root = await createRecord(harness, table.id, { [nameFieldId]: 'N0' }); + let parentId = root.id; + const chain = [root]; + for (let i = 1; i <= 3; i += 1) { + const record = await createRecord(harness, table.id, { + [nameFieldId]: `N${i}`, + [selfLinkFieldId]: { id: parentId }, + }); + chain.push(record); + parentId = record.id; + } + + await drainOutbox(harness); + + await updateRecord(harness, table.id, root.id, { [nameFieldId]: 'N0-updated' }); + await drainOutbox(harness); + + const records = await listRecords(harness, table.id); + const lookupOf = (recordId: string) => { + const row = records.find((record) => record.id === recordId); + expect(row).toBeDefined(); + return cellText( + parseArrayCell(row?.fields[parentNameLookupId])[0] ?? row?.fields[parentNameLookupId] + ); + }; + // Only the direct child's lookup shows the changed name; deeper rows keep + // their own parents' names, which must all still be consistent. + expect(lookupOf(chain[1].id)).toBe('N0-updated'); + expect(lookupOf(chain[2].id)).toBe('N1'); + expect(lookupOf(chain[3].id)).toBe('N2'); + + const dead = await sql<{ cnt: number }>` + SELECT count(*)::int as cnt FROM computed_update_dead_letter + `.execute(harness.testContainer.db); + expect(Number(dead.rows[0]?.cnt ?? 0)).toBe(0); + }, 120_000); + + it('converges a wide self-referential fan under consecutive propagation truncation', async () => { + // One root with many self-linked children: a single generation wider than the + // propagation pool forces consecutive truncated batches; the bounded frontier + // prefix must keep re-seeding until every child's lookup lands. + const harness = await createHarness({ + stageMaxSteps: 0, + stageMaxFields: 0, + stageMaxEdges: 0, + stageMaxDirtyRecords: 2, + }); + + const nameFieldId = createFieldId(); + const table = await createTable(harness, { + baseId: harness.baseId, + name: 'WideSelfFan', + fields: [{ type: 'singleLineText', id: nameFieldId, name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + + const createField = async (field: { id: string } & Record) => { + const response = await fetch(`${harness.baseUrl}/tables/createField`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ baseId: harness.baseId, tableId: table.id, field }), + }); + const rawBody: unknown = await response.json(); + expect(response.ok, JSON.stringify(rawBody)).toBe(true); + return String(field.id); + }; + + const selfLinkFieldId = await createField({ + id: createFieldId(), + type: 'link', + name: 'Parent', + options: { + relationship: 'manyOne', + foreignTableId: table.id, + lookupFieldId: nameFieldId, + }, + }); + const parentNameLookupId = await createField({ + id: createFieldId(), + type: 'lookup', + name: 'ParentName', + options: { + linkFieldId: selfLinkFieldId, + foreignTableId: table.id, + lookupFieldId: nameFieldId, + }, + }); + + const root = await createRecord(harness, table.id, { [nameFieldId]: 'Hub' }); + const children = []; + for (let i = 0; i < 6; i += 1) { + children.push( + await createRecord(harness, table.id, { + [nameFieldId]: `Child${i}`, + [selfLinkFieldId]: { id: root.id }, + }) + ); + } + + await drainOutbox(harness); + + await updateRecord(harness, table.id, root.id, { [nameFieldId]: 'Hub-updated' }); + const processed = await drainOutbox(harness); + // Width 6 against a 2-row pool: several truncated batches are required. + expect(processed).toBeGreaterThanOrEqual(3); + + const records = await listRecords(harness, table.id); + for (const child of children) { + const row = records.find((record) => record.id === child.id); + expect(row).toBeDefined(); + const lookup = cellText( + parseArrayCell(row?.fields[parentNameLookupId])[0] ?? row?.fields[parentNameLookupId] + ); + expect(lookup).toBe('Hub-updated'); + } + + const dead = await sql<{ cnt: number }>` + SELECT count(*)::int as cnt FROM computed_update_dead_letter + `.execute(harness.testContainer.db); + expect(Number(dead.rows[0]?.cnt ?? 0)).toBe(0); + }, 120_000); + + it('converges under a tiny dirty-record budget via shrink-and-continue', async () => { + // Static staging off: only the runtime dirty budget drives the stage cuts. + // seedInlineLimit forces the exclusion ledger / frontier queue onto the + // per-row spill path (computed_update_outbox_seed) instead of payload JSON. + const harness = await createHarness({ + stageMaxSteps: 0, + stageMaxFields: 0, + stageMaxEdges: 0, + stageMaxDirtyRecords: 2, + seedInlineLimit: 2, + }); + + const parentNameFieldId = createFieldId(); + const childLinkFieldId = createFieldId(); + const childLookupFieldId = createFieldId(); + const childL1FieldId = createFieldId(); + + const parentTable = await createTable(harness, { + baseId: harness.baseId, + name: 'Parents', + fields: [{ type: 'singleLineText', id: parentNameFieldId, name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + + const childTable = await createTable(harness, { + baseId: harness.baseId, + name: 'Children', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'link', + id: childLinkFieldId, + name: 'Parent', + options: { + relationship: 'manyOne', + foreignTableId: parentTable.id, + lookupFieldId: parentNameFieldId, + }, + }, + { + type: 'lookup', + id: childLookupFieldId, + name: 'ParentName', + options: { + linkFieldId: childLinkFieldId, + foreignTableId: parentTable.id, + lookupFieldId: parentNameFieldId, + }, + }, + { + type: 'formula', + id: childL1FieldId, + name: 'L1', + options: { expression: `CONCATENATE({${childLookupFieldId}}, "-L1")` }, + }, + ], + views: [{ type: 'grid' }], + }); + + const parent = await createRecord(harness, parentTable.id, { + [parentNameFieldId]: 'Base', + }); + // Fan-out (4 children) exceeds the dirty budget (2) in a single propagation hop, + // exercising the shrink loop down to the unguarded single-step floor. + const children = []; + for (let i = 0; i < 4; i += 1) { + children.push( + await createRecord(harness, childTable.id, { + Title: `C${i}`, + [childLinkFieldId]: { id: parent.id }, + }) + ); + } + + await drainOutbox(harness); + + await updateRecord(harness, parentTable.id, parent.id, { + [parentNameFieldId]: 'Base-updated', + }); + + const processed = await drainOutbox(harness); + expect(processed).toBeGreaterThanOrEqual(2); + + const records = await listRecords(harness, childTable.id); + for (const child of children) { + const row = records.find((record) => record.id === child.id); + expect(row).toBeDefined(); + const lookup = cellText( + parseArrayCell(row?.fields[childLookupFieldId])[0] ?? row?.fields[childLookupFieldId] + ); + expect(lookup).toBe('Base-updated'); + expect(cellText(row?.fields[childL1FieldId])).toBe('Base-updated-L1'); + } + + const dead = await sql<{ cnt: number }>` + SELECT count(*)::int as cnt FROM computed_update_dead_letter + `.execute(harness.testContainer.db); + expect(Number(dead.rows[0]?.cnt ?? 0)).toBe(0); + }, 120_000); +}); diff --git a/packages/v2/e2e/src/computed-user-driven.e2e.spec.ts b/packages/v2/e2e/src/computed-user-driven.e2e.spec.ts new file mode 100644 index 0000000000..2b3de62f7e --- /dev/null +++ b/packages/v2/e2e/src/computed-user-driven.e2e.spec.ts @@ -0,0 +1,546 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * E2E tests for user-field-driven computed updates. + * + * Ported from v1 specs (v2-branch expectations): + * - apps/nestjs-backend/test/computed-user-field.e2e-spec.ts (CRUD section) + * - apps/nestjs-backend/test/computed-version-regression.e2e-spec.ts + * (value-level contract only; the v1 spec asserts v1 event payloads which do + * not exist in v2 — here we assert the equivalent HTTP-visible record state) + * + * Covers: + * - createdBy / lastModifiedBy field creation backfilling existing records + * - formulas depending on lastModifiedBy / lastModifiedTime + * - lastModifiedBy trackedFieldIds record-level semantics + * - multi-user formula persistence via computed updates + * - lookup of a multi-user field refreshing when the source user list changes + */ +import { sql } from 'kysely'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +type RecordShape = { id: string; fields: Record }; + +const parseMaybeJson = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return value; + try { + return JSON.parse(trimmed) as unknown; + } catch { + return value; + } +}; + +const asUserCell = (value: unknown): { id?: string; title?: string } | null => { + const parsed = parseMaybeJson(value); + if (parsed === null || parsed === undefined) return null; + if (typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as { id?: string; title?: string }; + } + return null; +}; + +const asUserCellArray = (value: unknown): Array<{ id?: string; title?: string }> => { + const parsed = parseMaybeJson(value); + if (Array.isArray(parsed)) { + return parsed.map((item) => asUserCell(item) ?? {}); + } + const single = asUserCell(parsed); + return single ? [single] : []; +}; + +describe('v2 user-field-driven computed updates (e2e)', () => { + let ctx: SharedTestContext; + let fieldIdCounter = 0; + + const createFieldId = () => { + const suffix = `usrdrv${fieldIdCounter.toString(36)}`.padStart(16, '0'); + fieldIdCounter += 1; + return `fld${suffix}`; + }; + + const uniqueName = (prefix: string) => + `${prefix} ${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + + const drainOutbox = async (maxRounds = 10) => { + for (let i = 0; i < maxRounds; i += 1) { + const drained = await ctx.testContainer.processOutbox(); + if (drained === 0) break; + } + }; + + const listRecords = async (tableId: string): Promise => { + await drainOutbox(); + return ctx.listRecords(tableId); + }; + + const getRecord = async (tableId: string, recordId: string): Promise => { + const records = await listRecords(tableId); + const record = records.find((item) => item.id === recordId); + if (!record) throw new Error(`Record not found: ${recordId}`); + return record; + }; + + const seedUser = async (id: string, name: string, email: string) => { + await sql` + insert into users (id, name, email) + values (${id}, ${name}, ${email}) + on conflict (id) do nothing + `.execute(ctx.testContainer.db); + }; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + }, 120_000); + + // --------------------------------------------------------------------------- + // createdBy / lastModifiedBy field creation + // --------------------------------------------------------------------------- + + // v1: computed-user-field.e2e-spec.ts > CRUD > should create a created by field + it('creates a createdBy field that backfills the creator for existing records', async () => { + const nameFieldId = createFieldId(); + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('CreatedBy Backfill'), + fields: [{ type: 'singleLineText', id: nameFieldId, name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + await ctx.createRecord(table.id, { [nameFieldId]: 'r1' }); + await ctx.createRecord(table.id, { [nameFieldId]: 'r2' }); + + const createdByFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { type: 'createdBy', id: createdByFieldId, name: 'Created By' }, + }); + + const records = await listRecords(table.id); + expect(records.length).toBe(2); + for (const record of records) { + const cell = asUserCell(record.fields[createdByFieldId]); + expect(cell).toMatchObject({ title: ctx.testUser.name }); + } + }); + + // v1: computed-user-field.e2e-spec.ts > CRUD > should create a last modified by field + // (v2 branch: untouched records are backfilled too) + it('backfills lastModifiedBy for all records on creation and updates on record update', async () => { + const nameFieldId = createFieldId(); + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('LastModifiedBy Backfill'), + fields: [{ type: 'singleLineText', id: nameFieldId, name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const record1 = await ctx.createRecord(table.id, { [nameFieldId]: 'r1' }); + const record2 = await ctx.createRecord(table.id, { [nameFieldId]: 'r2' }); + + await ctx.updateRecord(table.id, record1.id, { [nameFieldId]: 'test' }); + + const lmbFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { type: 'lastModifiedBy', id: lmbFieldId, name: 'Last Modified By' }, + }); + + const records = await listRecords(table.id); + const first = records.find((item) => item.id === record1.id); + const second = records.find((item) => item.id === record2.id); + expect(asUserCell(first?.fields[lmbFieldId])).toMatchObject({ title: ctx.testUser.name }); + // v2 contract: records that were never explicitly updated are backfilled as well + expect(asUserCell(second?.fields[lmbFieldId])).toMatchObject({ title: ctx.testUser.name }); + + await ctx.updateRecord(table.id, record2.id, { [nameFieldId]: 'test2' }); + const updated = await getRecord(table.id, record2.id); + expect(asUserCell(updated.fields[lmbFieldId])).toMatchObject({ title: ctx.testUser.name }); + }); + + // --------------------------------------------------------------------------- + // Formulas depending on system user/time fields + // --------------------------------------------------------------------------- + + // v1: computed-user-field.e2e-spec.ts > CRUD > + // should update formula result depends on a last modified by field + it('updates formula result depending on a lastModifiedBy field', async () => { + const nameFieldId = createFieldId(); + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('LMB Formula'), + fields: [{ type: 'singleLineText', id: nameFieldId, name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const record1 = await ctx.createRecord(table.id, { [nameFieldId]: 'r1' }); + const record2 = await ctx.createRecord(table.id, { [nameFieldId]: 'r2' }); + + await ctx.updateRecord(table.id, record1.id, { [nameFieldId]: 'test' }); + + const lmbFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { type: 'lastModifiedBy', id: lmbFieldId, name: 'Last Modified By' }, + }); + + const formulaFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { + type: 'formula', + id: formulaFieldId, + name: 'LMB Formula', + options: { expression: `{${lmbFieldId}}` }, + }, + }); + + const records = await listRecords(table.id); + const first = records.find((item) => item.id === record1.id); + const second = records.find((item) => item.id === record2.id); + expect(asUserCell(first?.fields[lmbFieldId])).toMatchObject({ title: ctx.testUser.name }); + expect(first?.fields[formulaFieldId]).toBe(ctx.testUser.name); + // v2 branch: backfilled records also compute the formula + expect(asUserCell(second?.fields[lmbFieldId])).toMatchObject({ title: ctx.testUser.name }); + expect(second?.fields[formulaFieldId]).toBe(ctx.testUser.name); + + await ctx.updateRecord(table.id, record2.id, { [nameFieldId]: 'test2' }); + const updated = await getRecord(table.id, record2.id); + expect(asUserCell(updated.fields[lmbFieldId])).toMatchObject({ title: ctx.testUser.name }); + expect(updated.fields[formulaFieldId]).toBe(ctx.testUser.name); + }); + + // v1: computed-user-field.e2e-spec.ts > CRUD > + // should update formula result depends on a last modified time field + it('updates formula result depending on a lastModifiedTime field', async () => { + const nameFieldId = createFieldId(); + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('LMT Formula'), + fields: [{ type: 'singleLineText', id: nameFieldId, name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const record1 = await ctx.createRecord(table.id, { [nameFieldId]: 'r1' }); + const record2 = await ctx.createRecord(table.id, { [nameFieldId]: 'r2' }); + + await ctx.updateRecord(table.id, record1.id, { [nameFieldId]: 'test' }); + + const lmtFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { type: 'lastModifiedTime', id: lmtFieldId, name: 'Last Modified Time' }, + }); + + const formulaFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { + type: 'formula', + id: formulaFieldId, + name: 'LMT Formula', + options: { expression: `{${lmtFieldId}}` }, + }, + }); + + const toInstant = (value: unknown): number => { + expect(value).toBeTruthy(); + const parsed = new Date(String(value)).getTime(); + expect(Number.isNaN(parsed)).toBe(false); + return parsed; + }; + + const records = await listRecords(table.id); + const first = records.find((item) => item.id === record1.id); + const second = records.find((item) => item.id === record2.id); + // Formula mirrors the lastModifiedTime field for every record (v2 backfills all rows) + expect(toInstant(first?.fields[formulaFieldId])).toBe(toInstant(first?.fields[lmtFieldId])); + expect(toInstant(second?.fields[formulaFieldId])).toBe(toInstant(second?.fields[lmtFieldId])); + + const before = toInstant(second?.fields[lmtFieldId]); + await ctx.updateRecord(table.id, record2.id, { [nameFieldId]: 'test2' }); + const updated = await getRecord(table.id, record2.id); + const after = toInstant(updated.fields[lmtFieldId]); + expect(after).toBeGreaterThanOrEqual(before); + expect(toInstant(updated.fields[formulaFieldId])).toBe(after); + }); + + // Port of computed-version-regression.e2e-spec.ts value contract: + // one record update recomputes LMB + LMT + same-table formula together. + it('recomputes lastModifiedBy, lastModifiedTime and dependent formula on a single update', async () => { + const titleFieldId = createFieldId(); + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('Version Alignment'), + fields: [{ type: 'singleLineText', id: titleFieldId, name: 'Title', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const record = await ctx.createRecord(table.id, { [titleFieldId]: 'before' }); + + const lmtFieldId = createFieldId(); + const lmbFieldId = createFieldId(); + const formulaFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { type: 'lastModifiedTime', id: lmtFieldId, name: 'LMT' }, + }); + await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { type: 'lastModifiedBy', id: lmbFieldId, name: 'LMB' }, + }); + await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { + type: 'formula', + id: formulaFieldId, + name: 'UpperTitle', + options: { expression: `UPPER({${titleFieldId}})` }, + }, + }); + + await ctx.updateRecord(table.id, record.id, { [titleFieldId]: 'after' }); + + const updated = await getRecord(table.id, record.id); + expect(typeof updated.fields[lmtFieldId]).toBe('string'); + expect(new Date(String(updated.fields[lmtFieldId])).getTime()).not.toBeNaN(); + expect(asUserCell(updated.fields[lmbFieldId])).toMatchObject({ id: ctx.testUser.id }); + expect(updated.fields[formulaFieldId]).toBe('AFTER'); + }); + + // --------------------------------------------------------------------------- + // lastModifiedBy trackedFieldIds semantics + // --------------------------------------------------------------------------- + + // v1: computed-user-field.e2e-spec.ts > CRUD > + // should allow configuring Last Modified By field to track specific fields only + it('tracks only configured fields for lastModifiedBy with trackedFieldIds', async () => { + const nameFieldId = createFieldId(); + const textFieldId = createFieldId(); + const numberFieldId = createFieldId(); + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('LMB Tracked Fields'), + fields: [ + { type: 'singleLineText', id: nameFieldId, name: 'Name', isPrimary: true }, + { type: 'singleLineText', id: textFieldId, name: 'Tracked Text' }, + { type: 'number', id: numberFieldId, name: 'Untracked Number' }, + ], + views: [{ type: 'grid' }], + }); + const record = await ctx.createRecord(table.id, { [nameFieldId]: 'r1' }); + + const lmbFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { + type: 'lastModifiedBy', + id: lmbFieldId, + name: 'Tracked LMB', + options: { trackedFieldIds: [textFieldId] }, + }, + }); + + await ctx.updateRecord(table.id, record.id, { [numberFieldId]: 1 }); + let current = await getRecord(table.id, record.id); + expect(current.fields[lmbFieldId] ?? null).toBeNull(); + + await ctx.updateRecord(table.id, record.id, { [textFieldId]: 'tracked change' }); + current = await getRecord(table.id, record.id); + expect(asUserCell(current.fields[lmbFieldId])).toMatchObject({ + id: ctx.testUser.id, + title: ctx.testUser.name, + }); + }); + + // v1: computed-user-field.e2e-spec.ts > CRUD > + // should fall back to track all when tracked fields are removed + it('falls back to tracking all fields when tracked fields are removed', async () => { + const nameFieldId = createFieldId(); + const textFieldId = createFieldId(); + const numberFieldId = createFieldId(); + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('LMB Tracked Removal'), + fields: [ + { type: 'singleLineText', id: nameFieldId, name: 'Name', isPrimary: true }, + { type: 'singleLineText', id: textFieldId, name: 'Tracked Text' }, + { type: 'number', id: numberFieldId, name: 'Other Number' }, + ], + views: [{ type: 'grid' }], + }); + const record = await ctx.createRecord(table.id, { [nameFieldId]: 'r1' }); + + const lmbFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { + type: 'lastModifiedBy', + id: lmbFieldId, + name: 'Tracked LMB', + options: { trackedFieldIds: [textFieldId] }, + }, + }); + + await ctx.updateRecord(table.id, record.id, { [numberFieldId]: 1 }); + let current = await getRecord(table.id, record.id); + expect(current.fields[lmbFieldId] ?? null).toBeNull(); + + await ctx.deleteField({ tableId: table.id, fieldId: textFieldId }); + + await ctx.updateRecord(table.id, record.id, { [numberFieldId]: 2 }); + current = await getRecord(table.id, record.id); + expect(asUserCell(current.fields[lmbFieldId])).toMatchObject({ + id: ctx.testUser.id, + title: ctx.testUser.name, + }); + }); + + // --------------------------------------------------------------------------- + // User fields in formulas and lookups + // --------------------------------------------------------------------------- + + // v1: computed-user-field.e2e-spec.ts > CRUD > + // should persist multi-user formula values via computed updates + it('persists multi-user formula values via computed updates', async () => { + const nameFieldId = createFieldId(); + const userFieldId = createFieldId(); + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('Multi-user Formula'), + fields: [ + { type: 'singleLineText', id: nameFieldId, name: 'Name', isPrimary: true }, + { + type: 'user', + id: userFieldId, + name: 'Members', + options: { isMultiple: true, shouldNotify: false }, + }, + ], + views: [{ type: 'grid' }], + }); + const record = await ctx.createRecord(table.id, { [nameFieldId]: 'r1' }); + + const formulaFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { + type: 'formula', + id: formulaFieldId, + name: 'Members Formula', + options: { expression: `{${userFieldId}}` }, + }, + }); + + await ctx.updateRecord(table.id, record.id, { + [userFieldId]: [{ id: ctx.testUser.id, title: ctx.testUser.name }], + }); + + const updated = await getRecord(table.id, record.id); + const members = asUserCellArray(updated.fields[userFieldId]); + expect(members).toEqual([expect.objectContaining({ title: ctx.testUser.name })]); + expect(JSON.stringify(updated.fields[formulaFieldId])).toContain(ctx.testUser.name); + }); + + // v1: computed-user-field.e2e-spec.ts > CRUD > + // should refresh linked lookup user field after source multi-user field changes + it('refreshes linked lookup user field after source multi-user field changes', async () => { + const secondaryUser = { + id: 'usrComputedUserDrvB', + name: 'Computed Lookup Bob', + email: 'bob+computed-user-driven@e2e.com', + }; + await seedUser(secondaryUser.id, secondaryUser.name, secondaryUser.email); + + const sourceNameFieldId = createFieldId(); + const sourceUserFieldId = createFieldId(); + const sourceTable = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('Lookup User Source'), + fields: [ + { type: 'singleLineText', id: sourceNameFieldId, name: 'Name', isPrimary: true }, + { + type: 'user', + id: sourceUserFieldId, + name: 'Members', + options: { isMultiple: true, shouldNotify: false }, + }, + ], + views: [{ type: 'grid' }], + }); + const sourceRecord = await ctx.createRecord(sourceTable.id, { + [sourceNameFieldId]: 'source-1', + [sourceUserFieldId]: [{ id: ctx.testUser.id, title: ctx.testUser.name }], + }); + + const hostNameFieldId = createFieldId(); + const hostLinkFieldId = createFieldId(); + const hostTable = await ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName('Lookup User Host'), + fields: [ + { type: 'singleLineText', id: hostNameFieldId, name: 'Title', isPrimary: true }, + { + type: 'link', + id: hostLinkFieldId, + name: 'Source', + options: { + relationship: 'manyOne', + foreignTableId: sourceTable.id, + lookupFieldId: sourceNameFieldId, + }, + }, + ], + views: [{ type: 'grid' }], + }); + + const lookupFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: hostTable.id, + field: { + type: 'lookup', + id: lookupFieldId, + name: 'Lookup Members', + options: { + linkFieldId: hostLinkFieldId, + foreignTableId: sourceTable.id, + lookupFieldId: sourceUserFieldId, + }, + }, + }); + + const hostRecord = await ctx.createRecord(hostTable.id, { + [hostNameFieldId]: 'host-1', + [hostLinkFieldId]: { id: sourceRecord.id }, + }); + await drainOutbox(); + + let hostRow = await getRecord(hostTable.id, hostRecord.id); + expect(asUserCellArray(hostRow.fields[lookupFieldId])).toEqual([ + expect.objectContaining({ id: ctx.testUser.id, title: ctx.testUser.name }), + ]); + + await ctx.updateRecord(sourceTable.id, sourceRecord.id, { + [sourceUserFieldId]: [ + { id: ctx.testUser.id, title: ctx.testUser.name }, + { id: secondaryUser.id, title: secondaryUser.name }, + ], + }); + await drainOutbox(); + + hostRow = await getRecord(hostTable.id, hostRecord.id); + expect(asUserCellArray(hostRow.fields[lookupFieldId])).toEqual([ + expect.objectContaining({ id: ctx.testUser.id, title: ctx.testUser.name }), + expect.objectContaining({ id: secondaryUser.id, title: secondaryUser.name }), + ]); + }); +}); diff --git a/packages/v2/e2e/src/computed.e2e.spec.ts b/packages/v2/e2e/src/computed.e2e.spec.ts index 987b3200d3..fe1f957a27 100644 --- a/packages/v2/e2e/src/computed.e2e.spec.ts +++ b/packages/v2/e2e/src/computed.e2e.spec.ts @@ -1449,6 +1449,95 @@ describe('v2 computed field updates (e2e)', () => { `); }); + /** + * Scenario: Boolean rollup over a checkbox lookup that includes an + * unchecked row. v1 reference: rollup.e2e-spec.ts:880 — unchecked + * checkboxes are stored as null (T6520), so {values} only contains true + * and and({values}) must stay true. + */ + it('evaluates and/or/xor over unchecked checkboxes as if they were absent', async () => { + const foreignPrimaryId = createFieldId(); + const foreignFlagId = createFieldId(); + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupBoolForeign', + fields: [ + { type: 'singleLineText', id: foreignPrimaryId, name: 'Label', isPrimary: true }, + { type: 'checkbox', id: foreignFlagId, name: 'Flag' }, + ], + views: [{ type: 'grid' }], + }); + const alpha = await ctx.createRecord(foreign.id, { + [foreignPrimaryId]: 'Alpha', + [foreignFlagId]: true, + }); + const beta = await ctx.createRecord(foreign.id, { + [foreignPrimaryId]: 'Beta', + [foreignFlagId]: false, + }); + + const hostPrimaryId = createFieldId(); + const hostLinkId = createFieldId(); + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupBoolHost', + fields: [ + { type: 'singleLineText', id: hostPrimaryId, name: 'Name', isPrimary: true }, + { + type: 'link', + id: hostLinkId, + name: 'Rows', + options: { + relationship: 'manyMany', + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + + const rollups = [ + { expression: 'and({values})', expected: true }, + { expression: 'or({values})', expected: true }, + { expression: 'xor({values})', expected: true }, + ]; + const rollupFieldIds: string[] = []; + for (const rollup of rollups) { + const rollupFieldId = createFieldId(); + rollupFieldIds.push(rollupFieldId); + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'rollup', + id: rollupFieldId, + name: `Rollup ${rollup.expression}`, + options: { expression: rollup.expression }, + config: { + linkFieldId: hostLinkId, + foreignTableId: foreign.id, + lookupFieldId: foreignFlagId, + }, + }, + }); + } + + const hostRecord = await ctx.createRecord(host.id, { + [hostPrimaryId]: 'Holder', + [hostLinkId]: [{ id: alpha.id }, { id: beta.id }], + }); + await ctx.testContainer.processOutbox(); + + const records = await listRecords(host.id); + const holder = records.find((r) => r.id === hostRecord.id); + expect(holder).toBeDefined(); + rollups.forEach((rollup, index) => { + expect(holder?.fields[rollupFieldIds[index]], rollup.expression).toBe(rollup.expected); + }); + }); + /** * Scenario: Rollup updates when link relation changes. */ @@ -1827,9 +1916,11 @@ describe('v2 computed field updates (e2e)', () => { { id: tableC.id, name: 'ChainC', fields: [{ id: cLookupFieldId, name: 'LookupB' }] }, ]); // Note: The exact steps depend on outbox processing; may show partial chain + // Stage budgets keep each step's ORIGINAL dependency level (the final + // staged plan executes the chain tail, still labeled L1). expect(printComputedSteps(plan!, nameMaps)).toMatchInlineSnapshot(` "[Computed Steps: 1] - L0: ChainC -> [LookupB] + L1: ChainC -> [LookupB] [Edges: 1]" `); }); @@ -2606,6 +2697,354 @@ describe('v2 computed field updates (e2e)', () => { // The scientific-notation string is ignored (coerces to NULL -> 0), valid numbers are summed. expect(total).toBe(9250); }); + + /** + * Scenario: Pure lookup chain across four tables. + * v1 reference: computed-orchestrator.e2e-spec.ts (propagates multi-level lookup chain across four tables) + * + * T1.A -> T2.L2 = lookup(T1.A) -> T3.L3 = lookup(T2.L2) -> T4.L4 = lookup(T3.L3) + * Updating T1.A must cascade through all three lookup levels. + */ + it('propagates multi-level lookup chain across four tables', async () => { + const asNumberArray = (value: unknown): unknown => { + if (typeof value === 'string') { + const parsed = parseJsonArrayCell(value); + if (parsed) return parsed; + } + return value; + }; + + // T1: base number + const t1NameFieldId = createFieldId(); + const t1ValueFieldId = createFieldId(); + const t1 = await ctx.createTable({ + baseId: ctx.baseId, + name: `Chain4_T1_${getRandomString(6)}`, + fields: [ + { type: 'singleLineText', id: t1NameFieldId, name: 'Name', isPrimary: true }, + { type: 'number', id: t1ValueFieldId, name: 'A' }, + ], + views: [{ type: 'grid' }], + }); + const t1Record = await ctx.createRecord(t1.id, { + [t1NameFieldId]: 'T1-1', + [t1ValueFieldId]: 2, + }); + + // T2: manyMany link -> T1, L2 = lookup(T1.A) + const t2NameFieldId = createFieldId(); + const t2LinkFieldId = createFieldId(); + const t2LookupFieldId = createFieldId(); + const t2 = await ctx.createTable({ + baseId: ctx.baseId, + name: `Chain4_T2_${getRandomString(6)}`, + fields: [ + { type: 'singleLineText', id: t2NameFieldId, name: 'Name', isPrimary: true }, + { + type: 'link', + id: t2LinkFieldId, + name: 'L_T1', + options: { + relationship: 'manyMany', + foreignTableId: t1.id, + lookupFieldId: t1NameFieldId, + }, + }, + ], + views: [{ type: 'grid' }], + }); + await ctx.createField({ + baseId: ctx.baseId, + tableId: t2.id, + field: { + type: 'lookup', + id: t2LookupFieldId, + name: 'L2', + options: { + linkFieldId: t2LinkFieldId, + foreignTableId: t1.id, + lookupFieldId: t1ValueFieldId, + }, + }, + }); + const t2Record = await ctx.createRecord(t2.id, { + [t2NameFieldId]: 'T2-1', + [t2LinkFieldId]: [{ id: t1Record.id }], + }); + + // T3: manyMany link -> T2, L3 = lookup(T2.L2) + const t3NameFieldId = createFieldId(); + const t3LinkFieldId = createFieldId(); + const t3LookupFieldId = createFieldId(); + const t3 = await ctx.createTable({ + baseId: ctx.baseId, + name: `Chain4_T3_${getRandomString(6)}`, + fields: [ + { type: 'singleLineText', id: t3NameFieldId, name: 'Name', isPrimary: true }, + { + type: 'link', + id: t3LinkFieldId, + name: 'L_T2', + options: { + relationship: 'manyMany', + foreignTableId: t2.id, + lookupFieldId: t2NameFieldId, + }, + }, + ], + views: [{ type: 'grid' }], + }); + await ctx.createField({ + baseId: ctx.baseId, + tableId: t3.id, + field: { + type: 'lookup', + id: t3LookupFieldId, + name: 'L3', + options: { + linkFieldId: t3LinkFieldId, + foreignTableId: t2.id, + lookupFieldId: t2LookupFieldId, + }, + }, + }); + const t3Record = await ctx.createRecord(t3.id, { + [t3NameFieldId]: 'T3-1', + [t3LinkFieldId]: [{ id: t2Record.id }], + }); + + // T4: manyMany link -> T3, L4 = lookup(T3.L3) + const t4NameFieldId = createFieldId(); + const t4LinkFieldId = createFieldId(); + const t4LookupFieldId = createFieldId(); + const t4 = await ctx.createTable({ + baseId: ctx.baseId, + name: `Chain4_T4_${getRandomString(6)}`, + fields: [ + { type: 'singleLineText', id: t4NameFieldId, name: 'Name', isPrimary: true }, + { + type: 'link', + id: t4LinkFieldId, + name: 'L_T3', + options: { + relationship: 'manyMany', + foreignTableId: t3.id, + lookupFieldId: t3NameFieldId, + }, + }, + ], + views: [{ type: 'grid' }], + }); + await ctx.createField({ + baseId: ctx.baseId, + tableId: t4.id, + field: { + type: 'lookup', + id: t4LookupFieldId, + name: 'L4', + options: { + linkFieldId: t4LinkFieldId, + foreignTableId: t3.id, + lookupFieldId: t3LookupFieldId, + }, + }, + }); + const t4Record = await ctx.createRecord(t4.id, { + [t4NameFieldId]: 'T4-1', + [t4LinkFieldId]: [{ id: t3Record.id }], + }); + + // Settle initial propagation across all levels + let t4Records = await listRecords(t4.id); + expect( + asNumberArray(t4Records.find((r) => r.id === t4Record.id)?.fields[t4LookupFieldId]) + ).toEqual([2]); + + // Update the root value; all three lookup levels must recompute + await ctx.updateRecord(t1.id, t1Record.id, { [t1ValueFieldId]: 9 }); + + const t2Records = await listRecords(t2.id); + const t3Records = await listRecords(t3.id); + t4Records = await listRecords(t4.id); + expect( + asNumberArray(t2Records.find((r) => r.id === t2Record.id)?.fields[t2LookupFieldId]) + ).toEqual([9]); + expect( + asNumberArray(t3Records.find((r) => r.id === t3Record.id)?.fields[t3LookupFieldId]) + ).toEqual([9]); + expect( + asNumberArray(t4Records.find((r) => r.id === t4Record.id)?.fields[t4LookupFieldId]) + ).toEqual([9]); + }); + + /** + * Scenario: Interleaved lookup dependencies across tables (table-level cycle + * without a field-level cycle). + * v1 reference: computed-orchestrator.e2e-spec.ts (handles interleaved lookup dependencies across tables) + * + * T2 looks up T1.A and T3.CBase; T3 looks up T2.LKP_A. + * Updating T1.A must propagate to T2.LKP_A and then T3.LKP_T2_A while + * T2.LKP_C remains untouched. + */ + it('handles interleaved lookup dependencies across tables', async () => { + const asNumberArray = (value: unknown): unknown => { + if (typeof value === 'string') { + const parsed = parseJsonArrayCell(value); + if (parsed) return parsed; + } + return value; + }; + + // T1: base number + const t1NameFieldId = createFieldId(); + const t1ValueFieldId = createFieldId(); + const t1 = await ctx.createTable({ + baseId: ctx.baseId, + name: `Interleave_T1_${getRandomString(6)}`, + fields: [ + { type: 'singleLineText', id: t1NameFieldId, name: 'Name', isPrimary: true }, + { type: 'number', id: t1ValueFieldId, name: 'A' }, + ], + views: [{ type: 'grid' }], + }); + const t1Record = await ctx.createRecord(t1.id, { + [t1NameFieldId]: 'T1-1', + [t1ValueFieldId]: 1, + }); + + // T3: base number consumed by T2 (creates the table-level cycle later) + const t3NameFieldId = createFieldId(); + const t3BaseFieldId = createFieldId(); + const t3 = await ctx.createTable({ + baseId: ctx.baseId, + name: `Interleave_T3_${getRandomString(6)}`, + fields: [ + { type: 'singleLineText', id: t3NameFieldId, name: 'Name', isPrimary: true }, + { type: 'number', id: t3BaseFieldId, name: 'CBase' }, + ], + views: [{ type: 'grid' }], + }); + const t3Record = await ctx.createRecord(t3.id, { + [t3NameFieldId]: 'T3-1', + [t3BaseFieldId]: 5, + }); + + // T2: lookup T1.A via link to T1; lookup T3.CBase via link to T3 + const t2NameFieldId = createFieldId(); + const t2LinkT1FieldId = createFieldId(); + const t2LookupAFieldId = createFieldId(); + const t2LinkT3FieldId = createFieldId(); + const t2LookupCFieldId = createFieldId(); + const t2 = await ctx.createTable({ + baseId: ctx.baseId, + name: `Interleave_T2_${getRandomString(6)}`, + fields: [ + { type: 'singleLineText', id: t2NameFieldId, name: 'Name', isPrimary: true }, + { + type: 'link', + id: t2LinkT1FieldId, + name: 'L_T1', + options: { + relationship: 'manyMany', + foreignTableId: t1.id, + lookupFieldId: t1NameFieldId, + }, + }, + { + type: 'link', + id: t2LinkT3FieldId, + name: 'L_T3', + options: { + relationship: 'manyMany', + foreignTableId: t3.id, + lookupFieldId: t3NameFieldId, + }, + }, + ], + views: [{ type: 'grid' }], + }); + await ctx.createField({ + baseId: ctx.baseId, + tableId: t2.id, + field: { + type: 'lookup', + id: t2LookupAFieldId, + name: 'LKP_A', + options: { + linkFieldId: t2LinkT1FieldId, + foreignTableId: t1.id, + lookupFieldId: t1ValueFieldId, + }, + }, + }); + await ctx.createField({ + baseId: ctx.baseId, + tableId: t2.id, + field: { + type: 'lookup', + id: t2LookupCFieldId, + name: 'LKP_C', + options: { + linkFieldId: t2LinkT3FieldId, + foreignTableId: t3.id, + lookupFieldId: t3BaseFieldId, + }, + }, + }); + const t2Record = await ctx.createRecord(t2.id, { + [t2NameFieldId]: 'T2-1', + [t2LinkT1FieldId]: [{ id: t1Record.id }], + [t2LinkT3FieldId]: [{ id: t3Record.id }], + }); + + // T3 also looks up T2.LKP_A (T2 depends on T3 and T3 depends on T2 at + // the table level, but there is no field-level cycle) + const t3LinkT2FieldId = createFieldId(); + const t3LookupFromT2FieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: t3.id, + field: { + type: 'link', + id: t3LinkT2FieldId, + name: 'L_T2', + options: { + relationship: 'manyMany', + foreignTableId: t2.id, + lookupFieldId: t2NameFieldId, + }, + }, + }); + await ctx.createField({ + baseId: ctx.baseId, + tableId: t3.id, + field: { + type: 'lookup', + id: t3LookupFromT2FieldId, + name: 'LKP_T2_A', + options: { + linkFieldId: t3LinkT2FieldId, + foreignTableId: t2.id, + lookupFieldId: t2LookupAFieldId, + }, + }, + }); + await ctx.updateRecord(t3.id, t3Record.id, { + [t3LinkT2FieldId]: [{ id: t2Record.id }], + }); + await drainOutbox(); + + // Update the root value and verify interleaved propagation + await ctx.updateRecord(t1.id, t1Record.id, { [t1ValueFieldId]: 7 }); + + const t2Records = await listRecords(t2.id); + const t3Records = await listRecords(t3.id); + const t2Row = t2Records.find((r) => r.id === t2Record.id); + const t3Row = t3Records.find((r) => r.id === t3Record.id); + expect(asNumberArray(t2Row?.fields[t2LookupAFieldId])).toEqual([7]); + expect(asNumberArray(t2Row?.fields[t2LookupCFieldId])).toEqual([5]); + expect(asNumberArray(t3Row?.fields[t3LookupFromT2FieldId])).toEqual([7]); + }); }); // =========================================================================== @@ -3551,22 +3990,22 @@ describe('v2 computed field updates (e2e)', () => { await ctx.testContainer.processOutbox(); await ctx.testContainer.processOutbox(); - // Verify computed plan includes the lookup field update - const plan = ctx.testContainer.getLastComputedPlan(); - if (plan) { - // Should have at least 2 steps: - // 1. Child.Parent (manyOne link) - level 0 - // 2. Child.ParentName (lookup) - level 1 - // Note: Parent.Children (oneMany) is correctly skipped (FK not in Parent table) - expect(plan.steps.length).toBeGreaterThanOrEqual(2); - - // Verify lookup field is in the steps - const lookupStep = plan.steps.find((s) => - s.fieldIds.some((f) => f.toString() === childLookupFieldId) - ); - expect(lookupStep).toBeDefined(); - expect(lookupStep!.level).toBe(1); // Lookup depends on symmetric link, so level 1 - } + // Verify computed plans include the lookup field update. Stage budgets + // execute one dependency level per transaction, so the steps spread + // across several staged plans — aggregate them. + const stagedPlans = ctx.testContainer.getComputedPlans(); + expect(stagedPlans.length).toBeGreaterThanOrEqual(1); + const allSteps = stagedPlans.flatMap((p) => p.steps); + // Across the run: + // 1. Child.Parent (manyOne link) - level 0 + // 2. Child.ParentName (lookup) - level 1 + // Note: Parent.Children (oneMany) is correctly skipped (FK not in Parent table) + expect(allSteps.length).toBeGreaterThanOrEqual(2); + const lookupStep = allSteps.find((s) => + s.fieldIds.some((f) => f.toString() === childLookupFieldId) + ); + expect(lookupStep).toBeDefined(); + expect(lookupStep!.level).toBe(1); // Lookup depends on symmetric link, so level 1 // Verify Child record now has the correct ParentName lookup value const childRecordsAfter = await listRecords(tableChild.id); @@ -5709,6 +6148,76 @@ describe('v2 computed field updates (e2e)', () => { -------------------------------------------" `); }); + + /** + * Scenario: The updateRecord HTTP response itself carries the recomputed + * same-record formula value (inline computed update), not just the + * written base fields. + * v1 reference: formula-inline-computed-update.e2e-spec.ts + * (returns the updated same-record formula value in the PATCH response) + */ + it('returns the updated same-record formula value in the update response', async () => { + const validFieldId = createFieldId(); + const priceFieldId = createFieldId(); + const orderTypeFieldId = createFieldId(); + const commissionFieldId = createFieldId(); + + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `InlineFormulaUpdate_${getRandomString(6)}`, + fields: [ + { + type: 'singleSelect', + id: validFieldId, + name: 'Commission Valid', + isPrimary: true, + options: { + choices: [ + { name: 'Yes', color: 'green' }, + { name: 'No', color: 'red' }, + ], + }, + }, + { type: 'number', id: priceFieldId, name: 'Price' }, + { + type: 'singleSelect', + id: orderTypeFieldId, + name: 'Order Type', + options: { + choices: [ + { name: 'New', color: 'blue' }, + { name: 'Renewal', color: 'yellow' }, + ], + }, + }, + { + type: 'formula', + id: commissionFieldId, + name: 'Commission', + options: { + expression: `IF({${validFieldId}} = "No", 0, IF({${priceFieldId}} > 0, ROUND(IF({${orderTypeFieldId}} = "New", {${priceFieldId}} * 0.15, {${priceFieldId}} * 0.10), 2), 0))`, + }, + }, + ], + views: [{ type: 'grid' }], + }); + + const record = await ctx.createRecord(table.id, { + [validFieldId]: 'Yes', + [priceFieldId]: 480, + [orderTypeFieldId]: 'New', + }); + + const initialRecords = await listRecords(table.id); + expect(initialRecords.find((r) => r.id === record.id)?.fields[commissionFieldId]).toBe(72); + + // The PATCH response record must include the recomputed formula inline + const responseRecord = await ctx.updateRecord(table.id, record.id, { + [validFieldId]: 'No', + }); + expect(responseRecord.fields[validFieldId]).toBe('No'); + expect(responseRecord.fields[commissionFieldId]).toBe(0); + }); }); describe('delete record', () => { @@ -6774,6 +7283,72 @@ describe('v2 computed field updates (e2e)', () => { // =========================================================================== describe('conditionalRollup field updates', () => { + /** + * Scenario: conditionalRollup with a dynamic "today" date filter. + * v1 reference: conditional-rollup.e2e-spec today-filter case (T6520 list). + */ + it('only counts foreign rows whose date is today with a dynamic today filter', async () => { + const foreignPrimaryId = createFieldId(); + const foreignDateId = createFieldId(); + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'CondRollupTodayForeign', + fields: [ + { type: 'singleLineText', id: foreignPrimaryId, name: 'Name', isPrimary: true }, + { type: 'date', id: foreignDateId, name: 'When' }, + ], + views: [{ type: 'grid' }], + }); + await ctx.createRecord(foreign.id, { + [foreignPrimaryId]: 'today-row', + [foreignDateId]: new Date().toISOString(), + }); + await ctx.createRecord(foreign.id, { + [foreignPrimaryId]: 'old-row', + [foreignDateId]: '2020-01-01T00:00:00.000Z', + }); + + const hostPrimaryId = createFieldId(); + const rollupId = createFieldId(); + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'CondRollupTodayHost', + fields: [ + { type: 'singleLineText', id: hostPrimaryId, name: 'Name', isPrimary: true }, + { + type: 'conditionalRollup', + id: rollupId, + name: 'Today Count', + options: { expression: 'countall({values})' }, + config: { + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryId, + condition: { + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: foreignDateId, + operator: 'is', + value: { mode: 'today', timeZone: 'UTC' }, + }, + ], + }, + }, + }, + }, + ], + views: [{ type: 'grid' }], + }); + + const holder = await ctx.createRecord(host.id, { [hostPrimaryId]: 'Holder' }); + await ctx.testContainer.processOutbox(); + + const records = await listRecords(host.id); + const row = records.find((r) => r.id === holder.id); + expect(row?.fields[rollupId]).toBe(1); + }); + /** * Scenario: ConditionalRollup with simple filter condition. * Foreign table has records with different values, filter by value > threshold. @@ -9070,15 +9645,16 @@ describe('v2 computed field updates (e2e)', () => { await ctx.testContainer.processOutbox(); const records = await listRecordsWithoutDrain(hostTable.id); - expectCellDisplay(records, 0, fieldIds[fieldIds.length - 1], '[true, false]'); + // v1 contract: unchecked checkboxes are stored as null, so only true survives the lookup + expectCellDisplay(records, 0, fieldIds[fieldIds.length - 1], '[true]'); expect(printTableSnapshot(hostTable.name, fieldNames, records, fieldIds)) .toMatchInlineSnapshot(` "[ConditionalLookup Boolean Host] - -------------------------- + ------------------------- # | Name | Active Flags - -------------------------- - R0 | Host1 | [true, false] - --------------------------" + ------------------------- + R0 | Host1 | [true] + -------------------------" `); }); @@ -10233,11 +10809,11 @@ describe('v2 computed field updates (e2e)', () => { expect(printTableSnapshot(hostTable.name, fieldNames, afterRecords, fieldIds)) .toMatchInlineSnapshot(` "[CL_IF_Host] - ------------------------------------------ - # | Name | Flag | ActiveAmounts | Delta - ------------------------------------------ - R0 | Host1 | false | [5] | 1 - ------------------------------------------" + ----------------------------------------- + # | Name | Flag | ActiveAmounts | Delta + ----------------------------------------- + R0 | Host1 | - | [5] | 1 + -----------------------------------------" `); }); }); @@ -10831,12 +11407,89 @@ describe('v2 computed field updates (e2e)', () => { * Scenario: Formula string concatenation over multi-value fields (e.g., multi-select) does not hit CASE type mismatches. * v1 reference: computed-orchestrator.e2e-spec.ts (computes string formula referencing multi-value field without CASE type mismatch) * - * NOTE: Implement after v2 "update columns" (computed persistence / SQL expression casting) is implemented, - * since this regression historically surfaced during computed persistence in Postgres. + * Historically Postgres errored with "CASE types text and jsonb cannot be + * matched" when a string formula concatenated a multi-value column inside + * IF branches during computed persistence. */ - test.todo( - 'String formula over multi-value fields: Implement after update-columns to ensure no CASE type mismatches in persisted computed SQL' - ); + it('computes string formula referencing multi-value field without CASE type mismatch', async () => { + const brandFieldId = createFieldId(); + const codeFieldId = createFieldId(); + const nameFieldId = createFieldId(); + + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `Formula_String_MultiValue_${getRandomString(6)}`, + fields: [ + { type: 'singleLineText', id: codeFieldId, name: 'Code', isPrimary: true }, + { + type: 'multipleSelect', + id: brandFieldId, + name: 'Brand List', + options: { + choices: [ + { name: 'Alpha', color: 'blue' }, + { name: 'Beta', color: 'red' }, + ], + }, + }, + { type: 'singleLineText', id: nameFieldId, name: 'Display Name' }, + ], + views: [{ type: 'grid' }], + }); + + const codeValue = 'BP-001'; + const nameValue = 'Sample Product'; + const record = await ctx.createRecord(table.id, { + [codeFieldId]: codeValue, + [brandFieldId]: ['Alpha', 'Beta'], + [nameFieldId]: nameValue, + }); + + const expression = ` +IF( + OR( + LEN({${brandFieldId}} & "") = 0, + LEN({${codeFieldId}} & "") = 0, + LEN({${nameFieldId}} & "") = 0 + ), + "", + "B:/版权品/" & + IF( + FIND(",", {${brandFieldId}} & "") > 0, + LEFT({${brandFieldId}} & "", FIND(",", {${brandFieldId}} & "") - 1), + {${brandFieldId}} + ) & + "/" & {${codeFieldId}} & " " & {${nameFieldId}} +)`.trim(); + + const formulaFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { + type: 'formula', + id: formulaFieldId, + name: 'Computed Path', + options: { expression }, + }, + }); + + let records = await listRecords(table.id); + const firstValue = records.find((r) => r.id === record.id)?.fields[formulaFieldId]; + expect(typeof firstValue).toBe('string'); + expect(String(firstValue).startsWith('B:/版权品/')).toBe(true); + expect(String(firstValue)).toContain('Alpha'); + expect(String(firstValue)).toContain(`${codeValue} ${nameValue}`); + + await ctx.updateRecord(table.id, record.id, { [brandFieldId]: ['Beta'] }); + + records = await listRecords(table.id); + const secondValue = records.find((r) => r.id === record.id)?.fields[formulaFieldId]; + expect(typeof secondValue).toBe('string'); + expect(String(secondValue).startsWith('B:/版权品/')).toBe(true); + expect(String(secondValue)).toContain('Beta'); + expect(String(secondValue)).toContain(`${codeValue} ${nameValue}`); + }); /** * Scenario: Single-value date lookups used directly by date formulas. @@ -11035,12 +11688,59 @@ describe('v2 computed field updates (e2e)', () => { /** * Scenario: Divide/modulo by zero does not crash computed persistence. * v1 reference: computed-orchestrator.e2e-spec.ts (handles divide and modulo by zero during computed persistence) - * - * NOTE: Implement after v2 "update columns" is implemented, to validate persistence/update stability. */ - test.todo( - 'Divide/modulo by zero in formula persistence: Implement after update-columns to ensure computed updates remain stable' - ); + it('handles divide and modulo by zero during computed persistence', async () => { + const numeratorFieldId = createFieldId(); + const denominatorFieldId = createFieldId(); + const ratioFieldId = createFieldId(); + const remainderFieldId = createFieldId(); + + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `Formula_Divide_Zero_${getRandomString(6)}`, + fields: [ + { type: 'number', id: numeratorFieldId, name: 'Numerator', isPrimary: true }, + { type: 'number', id: denominatorFieldId, name: 'Denominator' }, + { + type: 'formula', + id: ratioFieldId, + name: 'Ratio', + options: { expression: `{${numeratorFieldId}} / {${denominatorFieldId}}` }, + }, + { + type: 'formula', + id: remainderFieldId, + name: 'Remainder', + options: { expression: `{${numeratorFieldId}} % {${denominatorFieldId}}` }, + }, + ], + views: [{ type: 'grid' }], + }); + + const record = await ctx.createRecord(table.id, { + [numeratorFieldId]: 10, + [denominatorFieldId]: 0, + }); + + const records = await listRecords(table.id); + const row = records.find((r) => r.id === record.id); + expect(row?.fields[ratioFieldId] ?? null).toBeNull(); + expect(row?.fields[remainderFieldId] ?? null).toBeNull(); + + // Subsequent updates through the zero denominator stay stable as well + await ctx.updateRecord(table.id, record.id, { [numeratorFieldId]: 25 }); + const updatedRows = await listRecords(table.id); + const updated = updatedRows.find((r) => r.id === record.id); + expect(updated?.fields[ratioFieldId] ?? null).toBeNull(); + expect(updated?.fields[remainderFieldId] ?? null).toBeNull(); + + // Restoring a non-zero denominator computes real values again + await ctx.updateRecord(table.id, record.id, { [denominatorFieldId]: 4 }); + const finalRows = await listRecords(table.id); + const final = finalRows.find((r) => r.id === record.id); + expect(final?.fields[ratioFieldId]).toBe(6.25); + expect(final?.fields[remainderFieldId]).toBe(1); + }); }); // ============================================================================= @@ -11237,11 +11937,15 @@ describe('v2 computed field updates (e2e)', () => { await ctx.updateRecord(tableA.id, aRecords[0].id, { [aValueFieldId]: 999 }); await ctx.testContainer.processOutbox(); - // Verify computed plan - seed should be A0 + // Verify computed plan - seed should be A0. Staged execution may migrate + // the explicit seed into the stage-ledger frontier queue at floor entry. const plan = ctx.testContainer.getLastComputedPlan(); expect(plan).toBeDefined(); - expect(plan!.seedRecordIds.length).toBe(1); - expect(plan!.seedRecordIds[0]).toBe(aRecords[0].id); + const effectiveSeedIds = [ + ...plan!.seedRecordIds, + ...ctx.testContainer.spyLogger.getMigratedSeedGroups().flatMap((group) => group.recordIds), + ]; + expect(effectiveSeedIds).toEqual([aRecords[0].id]); // Verify only B records linked to A0 (B0, B5) have updated lookup values const afterBRecords = await listRecords(tableB.id); diff --git a/packages/v2/e2e/src/conditional-rollup-dynamic-date.e2e.spec.ts b/packages/v2/e2e/src/conditional-rollup-dynamic-date.e2e.spec.ts new file mode 100644 index 0000000000..df43ae9133 --- /dev/null +++ b/packages/v2/e2e/src/conditional-rollup-dynamic-date.e2e.spec.ts @@ -0,0 +1,501 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * V1-parity coverage for conditional rollup date filters (T6520). + * Ports the portable cases from + * apps/nestjs-backend/test/conditional-rollup.e2e-spec.ts: + * - "dynamic date filters": the today mode is covered by computed.e2e.spec.ts + * ("only counts foreign rows whose date is today with a dynamic today filter"); + * this file covers the remaining dynamic modes from the T6520 drift list: + * lastWeek / currentMonth / daysAgo, plus a plain rollup with a dynamic + * date filter in its config. + * - "date field reference filters": is / isAfter / isBefore / isOnOrBefore / + * isOnOrAfter comparisons against a host date field reference. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +describe('v2 conditional rollup dynamic date filters (e2e)', () => { + let ctx: SharedTestContext; + let fieldIdCounter = 0; + const runId = Math.random().toString(36).slice(2, 8).padEnd(6, '0'); + + const createFieldId = () => { + const suffix = fieldIdCounter.toString(36).padStart(10, '0'); + fieldIdCounter += 1; + return `fld${runId}${suffix}`; + }; + + const drainOutbox = async (rounds = 10) => { + for (let i = 0; i < rounds; i += 1) { + const drained = await ctx.testContainer.processOutbox(); + if (drained === 0) break; + } + }; + + const isoDaysAgo = (days: number) => new Date(Date.now() - days * DAY_MS).toISOString(); + + beforeAll(async () => { + ctx = await getSharedTestContext(); + }); + + describe('dynamic date filters', () => { + interface DynamicDateFixture { + foreignTableId: string; + hostTableId: string; + hostRecordId: string; + rollupFieldId: string; + foreignRecords: Array<{ id: string }>; + } + + const setupDynamicDateFixture = async (options: { + namePrefix: string; + expression: string; + filterValue: Record; + foreignRows: Array<{ name: string; date: string | null; hours: number }>; + dateFieldIdOut?: (fieldId: string) => void; + hoursFieldIdOut?: (fieldId: string) => void; + }): Promise => { + const foreignPrimaryFieldId = createFieldId(); + const foreignDateFieldId = createFieldId(); + const foreignHoursFieldId = createFieldId(); + const hostPrimaryFieldId = createFieldId(); + const rollupFieldId = createFieldId(); + + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: `${options.namePrefix} Foreign`, + fields: [ + { type: 'singleLineText', id: foreignPrimaryFieldId, name: 'Task', isPrimary: true }, + { type: 'date', id: foreignDateFieldId, name: 'Due Date' }, + { type: 'number', id: foreignHoursFieldId, name: 'Hours' }, + ], + }); + + const foreignRecords: Array<{ id: string }> = []; + for (const row of options.foreignRows) { + const record = await ctx.createRecord(foreign.id, { + [foreignPrimaryFieldId]: row.name, + [foreignDateFieldId]: row.date, + [foreignHoursFieldId]: row.hours, + }); + foreignRecords.push({ id: record.id }); + } + + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: `${options.namePrefix} Host`, + fields: [ + { type: 'singleLineText', id: hostPrimaryFieldId, name: 'Name', isPrimary: true }, + { + type: 'conditionalRollup', + id: rollupFieldId, + name: 'Dynamic Date Rollup', + options: { expression: options.expression }, + config: { + foreignTableId: foreign.id, + lookupFieldId: foreignHoursFieldId, + condition: { + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: foreignDateFieldId, + operator: 'is', + value: options.filterValue, + }, + ], + }, + }, + }, + }, + ], + }); + + const hostRecord = await ctx.createRecord(host.id, { [hostPrimaryFieldId]: 'Holder' }); + await drainOutbox(); + + return { + foreignTableId: foreign.id, + hostTableId: host.id, + hostRecordId: hostRecord.id, + rollupFieldId, + foreignRecords, + dateFieldId: foreignDateFieldId, + hoursFieldId: foreignHoursFieldId, + }; + }; + + const readRollupValue = async (fixture: DynamicDateFixture) => { + const records = await ctx.listRecords(fixture.hostTableId); + const record = records.find((r) => r.id === fixture.hostRecordId); + return record?.fields[fixture.rollupFieldId]; + }; + + it('honors lastWeek filters in conditional rollups and reacts to updates', async () => { + let fixture: Awaited> | undefined; + try { + fixture = await setupDynamicDateFixture({ + namePrefix: 'CondRollupLastWeek', + expression: 'sum({values})', + filterValue: { mode: 'lastWeek', timeZone: 'UTC' }, + foreignRows: [ + { name: 'last-week-row', date: isoDaysAgo(7), hours: 5 }, + { name: 'today-row', date: isoDaysAgo(0), hours: 3 }, + { name: 'old-row', date: isoDaysAgo(30), hours: 7 }, + ], + }); + + expect(await readRollupValue(fixture)).toEqual(5); + + // Move the old row into last week; the rollup must recompute. + await ctx.updateRecord(fixture.foreignTableId, fixture.foreignRecords[2].id, { + [fixture.dateFieldId]: isoDaysAgo(7), + }); + await drainOutbox(); + + expect(await readRollupValue(fixture)).toEqual(12); + } finally { + if (fixture) { + await ctx.deleteTable(fixture.hostTableId).catch(() => undefined); + await ctx.deleteTable(fixture.foreignTableId).catch(() => undefined); + } + } + }); + + it('honors currentMonth filters in conditional rollups and reacts to updates', async () => { + let fixture: Awaited> | undefined; + try { + fixture = await setupDynamicDateFixture({ + namePrefix: 'CondRollupCurrentMonth', + expression: 'countall({values})', + filterValue: { mode: 'currentMonth', timeZone: 'UTC' }, + foreignRows: [ + { name: 'this-month-row', date: isoDaysAgo(0), hours: 4 }, + { name: 'old-row', date: isoDaysAgo(40), hours: 6 }, + ], + }); + + expect(await readRollupValue(fixture)).toEqual(1); + + // Move the old row into the current month; the rollup must recompute. + await ctx.updateRecord(fixture.foreignTableId, fixture.foreignRecords[1].id, { + [fixture.dateFieldId]: isoDaysAgo(0), + }); + await drainOutbox(); + + expect(await readRollupValue(fixture)).toEqual(2); + } finally { + if (fixture) { + await ctx.deleteTable(fixture.hostTableId).catch(() => undefined); + await ctx.deleteTable(fixture.foreignTableId).catch(() => undefined); + } + } + }); + + it('honors daysAgo filters in conditional rollups', async () => { + let fixture: Awaited> | undefined; + try { + fixture = await setupDynamicDateFixture({ + namePrefix: 'CondRollupDaysAgo', + expression: 'countall({values})', + filterValue: { mode: 'daysAgo', numberOfDays: 3, timeZone: 'UTC' }, + foreignRows: [ + { name: 'three-days-ago-row', date: isoDaysAgo(3), hours: 4 }, + { name: 'today-row', date: isoDaysAgo(0), hours: 2 }, + { name: 'ten-days-ago-row', date: isoDaysAgo(10), hours: 6 }, + ], + }); + + expect(await readRollupValue(fixture)).toEqual(1); + } finally { + if (fixture) { + await ctx.deleteTable(fixture.hostTableId).catch(() => undefined); + await ctx.deleteTable(fixture.foreignTableId).catch(() => undefined); + } + } + }); + + // v1: conditional-rollup.e2e-spec.ts "should honor today filters in rollups" + // (dynamic date filter on a plain rollup's lookup options), transposed to lastWeek. + it('honors dynamic date filters on plain rollup fields', async () => { + const foreignPrimaryFieldId = createFieldId(); + const foreignDateFieldId = createFieldId(); + const foreignHoursFieldId = createFieldId(); + const hostPrimaryFieldId = createFieldId(); + const hostLinkFieldId = createFieldId(); + const rollupFieldId = createFieldId(); + + let foreignTableId: string | undefined; + let hostTableId: string | undefined; + + try { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupDynamicDate Foreign', + fields: [ + { type: 'singleLineText', id: foreignPrimaryFieldId, name: 'Task', isPrimary: true }, + { type: 'date', id: foreignDateFieldId, name: 'Due Date' }, + { type: 'number', id: foreignHoursFieldId, name: 'Hours' }, + ], + }); + foreignTableId = foreign.id; + + const lastWeekRow = await ctx.createRecord(foreign.id, { + [foreignPrimaryFieldId]: 'last-week-row', + [foreignDateFieldId]: isoDaysAgo(7), + [foreignHoursFieldId]: 5, + }); + const todayRow = await ctx.createRecord(foreign.id, { + [foreignPrimaryFieldId]: 'today-row', + [foreignDateFieldId]: isoDaysAgo(0), + [foreignHoursFieldId]: 3, + }); + + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupDynamicDate Host', + fields: [ + { type: 'singleLineText', id: hostPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + hostTableId = host.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'link', + id: hostLinkFieldId, + name: 'Tasks', + options: { + relationship: 'manyMany', + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'rollup', + id: rollupFieldId, + name: 'Last Week Hours', + options: { expression: 'sum({values})' }, + config: { + linkFieldId: hostLinkFieldId, + foreignTableId: foreign.id, + lookupFieldId: foreignHoursFieldId, + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: foreignDateFieldId, + operator: 'is', + value: { mode: 'lastWeek', timeZone: 'UTC' }, + }, + ], + }, + }, + }, + }); + + const hostRecord = await ctx.createRecord(host.id, { + [hostPrimaryFieldId]: 'Holder', + [hostLinkFieldId]: [{ id: lastWeekRow.id }, { id: todayRow.id }], + }); + await drainOutbox(); + + const records = await ctx.listRecords(host.id); + const record = records.find((r) => r.id === hostRecord.id); + expect(record?.fields[rollupFieldId]).toEqual(5); + } finally { + if (hostTableId) await ctx.deleteTable(hostTableId).catch(() => undefined); + if (foreignTableId) await ctx.deleteTable(foreignTableId).catch(() => undefined); + } + }); + }); + + describe('date field reference filters', () => { + // v1: conditional-rollup.e2e-spec.ts "date field reference filters" it.each matrix + let foreignTableId: string; + let hostTableId: string; + let targetTenRecordId: string; + let targetElevenRecordId: string; + let targetThirteenRecordId: string; + + const foreignTaskFieldId = createFieldId(); + const foreignDueDateFieldId = createFieldId(); + const foreignHoursFieldId = createFieldId(); + const hostNameFieldId = createFieldId(); + const hostTargetDateFieldId = createFieldId(); + + const dateReferenceScenarios: Array<{ + name: string; + operator: string; + expression: string; + expected: [unknown, unknown, unknown]; + fieldId: string; + }> = [ + { + name: 'aggregates matches when due date equals host target date', + operator: 'is', + expression: 'count({values})', + expected: [1, 1, 0], + fieldId: createFieldId(), + }, + { + name: 'sums hours occurring after the host target date', + operator: 'isAfter', + expression: 'sum({values})', + expected: [10, 7, 0], + fieldId: createFieldId(), + }, + { + name: 'sums hours occurring before the host target date', + operator: 'isBefore', + expression: 'sum({values})', + expected: [0, 5, 15], + fieldId: createFieldId(), + }, + { + name: 'counts records on or after the host target date', + operator: 'isOnOrAfter', + expression: 'count({values})', + expected: [3, 2, 0], + fieldId: createFieldId(), + }, + { + name: 'counts records on or before the host target date', + operator: 'isOnOrBefore', + expression: 'count({values})', + expected: [1, 2, 3], + fieldId: createFieldId(), + }, + ]; + + beforeAll(async () => { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'CondRollupDateRef Foreign', + fields: [ + { type: 'singleLineText', id: foreignTaskFieldId, name: 'Task', isPrimary: true }, + { + type: 'date', + id: foreignDueDateFieldId, + name: 'Due Date', + options: { + formatting: { date: 'YYYY-MM-DD', time: 'None', timeZone: 'utc' }, + }, + }, + { type: 'number', id: foreignHoursFieldId, name: 'Hours' }, + ], + }); + foreignTableId = foreign.id; + + await ctx.createRecord(foreign.id, { + [foreignTaskFieldId]: 'Spec Draft', + [foreignDueDateFieldId]: '2024-09-10T00:00:00.000Z', + [foreignHoursFieldId]: 5, + }); + await ctx.createRecord(foreign.id, { + [foreignTaskFieldId]: 'Review', + [foreignDueDateFieldId]: '2024-09-11T00:00:00.000Z', + [foreignHoursFieldId]: 3, + }); + await ctx.createRecord(foreign.id, { + [foreignTaskFieldId]: 'Finalize', + [foreignDueDateFieldId]: '2024-09-12T00:00:00.000Z', + [foreignHoursFieldId]: 7, + }); + + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'CondRollupDateRef Host', + fields: [ + { type: 'singleLineText', id: hostNameFieldId, name: 'Name', isPrimary: true }, + { + type: 'date', + id: hostTargetDateFieldId, + name: 'Target Date', + options: { + formatting: { date: 'YYYY-MM-DD', time: 'None', timeZone: 'utc' }, + }, + }, + ], + }); + hostTableId = host.id; + + for (const scenario of dateReferenceScenarios) { + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'conditionalRollup', + id: scenario.fieldId, + name: `date-ref ${scenario.operator} ${scenario.expression}`, + options: { expression: scenario.expression }, + config: { + foreignTableId: foreign.id, + lookupFieldId: foreignHoursFieldId, + condition: { + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: foreignDueDateFieldId, + operator: scenario.operator, + value: hostTargetDateFieldId, + isSymbol: true, + }, + ], + }, + }, + }, + }, + }); + } + + const targetTen = await ctx.createRecord(host.id, { + [hostNameFieldId]: 'Target 09-10', + [hostTargetDateFieldId]: '2024-09-10T12:34:56.000Z', + }); + targetTenRecordId = targetTen.id; + const targetEleven = await ctx.createRecord(host.id, { + [hostNameFieldId]: 'Target 09-11', + [hostTargetDateFieldId]: '2024-09-11T12:50:00.000Z', + }); + targetElevenRecordId = targetEleven.id; + const targetThirteen = await ctx.createRecord(host.id, { + [hostNameFieldId]: 'Target 09-13', + [hostTargetDateFieldId]: '2024-09-13T12:15:00.000Z', + }); + targetThirteenRecordId = targetThirteen.id; + + await drainOutbox(); + }); + + afterAll(async () => { + if (hostTableId) await ctx.deleteTable(hostTableId).catch(() => undefined); + if (foreignTableId) await ctx.deleteTable(foreignTableId).catch(() => undefined); + }); + + it.each(dateReferenceScenarios)('$name', async ({ fieldId, expected }) => { + const records = await ctx.listRecords(hostTableId); + const targetTen = records.find((record) => record.id === targetTenRecordId); + const targetEleven = records.find((record) => record.id === targetElevenRecordId); + const targetThirteen = records.find((record) => record.id === targetThirteenRecordId); + + expect([ + targetTen?.fields[fieldId], + targetEleven?.fields[fieldId], + targetThirteen?.fields[fieldId], + ]).toEqual(expected); + }); + }); +}); diff --git a/packages/v2/e2e/src/conditional-rollup-fast-path.e2e.spec.ts b/packages/v2/e2e/src/conditional-rollup-fast-path.e2e.spec.ts index 4b760587f3..bafed6ccd4 100644 --- a/packages/v2/e2e/src/conditional-rollup-fast-path.e2e.spec.ts +++ b/packages/v2/e2e/src/conditional-rollup-fast-path.e2e.spec.ts @@ -502,27 +502,27 @@ describe('conditional rollup simple-filter fast path (e2e)', () => { }); await ctx.drainOutbox(); - const targetStep = ctx - .getLastComputedPlan() - ?.steps.find((step) => step.tableId === targetTable.id); - const plan = ctx.getLastComputedPlan() as - | { - edges?: Array<{ - to?: string; - propagationMode?: string; - }>; - } - | undefined; - const targetEdges = plan?.edges?.filter((edge) => - rollupFieldIds.some((fieldId) => edge.to?.endsWith(`.${fieldId}`)) - ); - expect(targetEdges).toHaveLength(rollupFieldIds.length); + // Stage budgets may split the 27-edge plan across several bounded stages; + // aggregate the logged stage plans to assert full coverage. + const plans = ctx.testContainer.getComputedPlans() as Array<{ + steps?: Array<{ tableId?: string; fieldIds?: string[] }>; + edges?: Array<{ to?: string; propagationMode?: string }>; + }>; + const targetEdges = plans + .flatMap((plan) => plan.edges ?? []) + .filter((edge) => rollupFieldIds.some((fieldId) => edge.to?.endsWith(`.${fieldId}`))); + const coveredEdgeTargets = new Set(targetEdges.map((edge) => edge.to)); + expect(coveredEdgeTargets.size).toBe(rollupFieldIds.length); expect( - targetEdges?.every((edge) => + targetEdges.every((edge) => ['allTargetRecords', 'conditionalFiltered'].includes(edge.propagationMode ?? '') ) ).toBe(true); - expect(targetStep?.fieldIds).toHaveLength(rollupFieldIds.length); + const targetStageSteps = plans + .flatMap((plan) => plan.steps ?? []) + .filter((step) => step.tableId === targetTable.id); + const coveredStepFieldIds = new Set(targetStageSteps.flatMap((step) => step.fieldIds ?? [])); + expect(coveredStepFieldIds.size).toBe(rollupFieldIds.length); const sqlEntries = ctx.testContainer.spyLogger .getEntriesByMessage(/computed:update:/) @@ -532,8 +532,17 @@ describe('conditional rollup simple-filter fast path (e2e)', () => { (total, message) => total + message.split(sourceTableToken).length - 1, 0 ); - const expectedFieldChunks = Math.ceil(rollupFieldIds.length / 16); - expect(sourceScanCount).toBe(expectedFieldChunks); + // Scans are shared across all rollups within a stage: at most one scan per + // 16-field chunk per logged stage plan (plans that only planned, e.g. the + // hybrid sync phase, may not execute the target update at all), and never + // one scan per rollup field. + const maxExpectedFieldChunks = targetStageSteps.reduce( + (total, step) => total + Math.ceil((step.fieldIds?.length ?? 0) / 16), + 0 + ); + expect(sourceScanCount).toBeGreaterThan(0); + expect(sourceScanCount).toBeLessThanOrEqual(maxExpectedFieldChunks); + expect(sourceScanCount).toBeLessThan(rollupFieldIds.length); expect(await listRecordVersions(ctx, targetTable.id)).toEqual(previousTargetVersions); expect(getComputedSummaryEvents(ctx, targetTable.id, beforeEventCount)).toHaveLength(0); diff --git a/packages/v2/e2e/src/createField.e2e.spec.ts b/packages/v2/e2e/src/createField.e2e.spec.ts index 9a33bfc51a..b3604f5bf3 100644 --- a/packages/v2/e2e/src/createField.e2e.spec.ts +++ b/packages/v2/e2e/src/createField.e2e.spec.ts @@ -341,7 +341,7 @@ describe('v2 http createField (e2e)', () => { const parsed = createFieldErrorResponseSchema.safeParse(rawBody); expect(parsed.success).toBe(true); if (!parsed.success || parsed.data.ok) return; - expect(parsed.data.error.code).toBe('validation.field.not_null'); + expect(parsed.data.error.code).toBe('validation.field.required_existing_values'); } finally { await ctx.deleteTable(table.id).catch(() => undefined); } @@ -2601,4 +2601,452 @@ describe('v2 http createField (e2e)', () => { if (!created || created.type !== 'rollup') return; expect(created.config.lookupFieldId).toBe(table1NumberFieldId); }); + + describe('[V1 PARITY] field.e2e-spec.ts create/read coverage', () => { + const createFieldAndGet = async ( + targetTableId: string, + field: Record + ): Promise<{ id: string; name: string; options?: unknown; type: string; unique?: boolean }> => { + const fieldId = createFieldId(); + const response = await fetch(`${ctx.baseUrl}/tables/createField`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + baseId: ctx.baseId, + tableId: targetTableId, + field: { id: fieldId, ...field }, + }), + }); + const raw = await response.json(); + if (response.status !== 200) { + throw new Error(`CreateField failed for ${JSON.stringify(field)}: ${JSON.stringify(raw)}`); + } + const parsed = createFieldOkResponseSchema.safeParse(raw); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) { + throw new Error(`CreateField response invalid: ${JSON.stringify(raw)}`); + } + const created = parsed.data.data.table.fields.find((entry) => entry.id === fieldId); + if (!created) { + throw new Error(`Created field ${fieldId} missing from response`); + } + return created; + }; + + // v1 reference: field.e2e-spec.ts + // "should generate default name and options for field" > "basic field" / "formula field" + it('generates default names for fields created without a name', async () => { + const table = await createTable({ + baseId: ctx.baseId, + name: `Default Names ${Date.now()}`, + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + }); + + try { + const textField = await createFieldAndGet(table.id, { type: 'singleLineText' }); + expect(textField.name).toBe('Label'); + expect(textField.options).toEqual({}); + + const numberField = await createFieldAndGet(table.id, { type: 'number' }); + expect(numberField.name).toBe('Number'); + + const selectField = await createFieldAndGet(table.id, { type: 'singleSelect' }); + expect(selectField.name).toBe('Select'); + + const dateField = await createFieldAndGet(table.id, { type: 'date' }); + expect(dateField.name).toBe('Date'); + + const checkboxField = await createFieldAndGet(table.id, { type: 'checkbox' }); + expect(checkboxField.name).toBe('Done'); + expect(checkboxField.options).toEqual({}); + + const attachmentField = await createFieldAndGet(table.id, { type: 'attachment' }); + expect(attachmentField.name).toBe('Attachments'); + expect(attachmentField.options).toEqual({}); + + const buttonField = await createFieldAndGet(table.id, { type: 'button' }); + expect(buttonField.name).toBe('Button'); + + const autoNumberField = await createFieldAndGet(table.id, { type: 'autoNumber' }); + expect(autoNumberField.name).toBe('ID'); + expect(autoNumberField.options).toEqual({ expression: 'AUTO_NUMBER()' }); + + const formulaField = await createFieldAndGet(table.id, { + type: 'formula', + options: { expression: '"A"' }, + }); + expect(formulaField.name).toBe('Calculation'); + expect(formulaField.options).toMatchObject({ expression: '"A"' }); + } finally { + await ctx.deleteTable(table.id).catch(() => undefined); + } + }); + + // v1 behavior (implicit in field create flow): duplicate names are made + // unique with a numeric suffix instead of failing. + it('uniquifies duplicated field names with a numeric suffix', async () => { + const table = await createTable({ + baseId: ctx.baseId, + name: `Duplicate Names ${Date.now()}`, + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + }); + + try { + const first = await createFieldAndGet(table.id, { + type: 'singleLineText', + name: 'Same Name', + }); + expect(first.name).toBe('Same Name'); + + const second = await createFieldAndGet(table.id, { + type: 'singleLineText', + name: 'Same Name', + }); + expect(second.name).toBe('Same Name 2'); + + const third = await createFieldAndGet(table.id, { + type: 'singleLineText', + name: 'Same Name', + }); + expect(third.name).toBe('Same Name 3'); + } finally { + await ctx.deleteTable(table.id).catch(() => undefined); + } + }); + + // v1 reference: field.e2e-spec.ts "relational field" > + // "should generate semantic field name for link and lookup and rollup field" + it('generates semantic default names for link, lookup and rollup fields', async () => { + const suffix = Date.now(); + const foreignTable = await createTable({ + baseId: ctx.baseId, + name: `Semantic Foreign ${suffix}`, + fields: [{ type: 'singleLineText', name: 'Foreign Title', isPrimary: true }], + }); + const hostTable = await createTable({ + baseId: ctx.baseId, + name: `Semantic Host ${suffix}`, + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + }); + + try { + const foreignPrimary = foreignTable.fields.find((field) => field.isPrimary); + expect(foreignPrimary).toBeTruthy(); + if (!foreignPrimary) return; + + const linkField = await createFieldAndGet(hostTable.id, { + type: 'link', + options: { + relationship: 'oneMany', + foreignTableId: foreignTable.id, + lookupFieldId: foreignPrimary.id, + }, + }); + expect(linkField.name).toBe(foreignTable.name); + + const foreignTableAfterLink = await getTableById(foreignTable.id); + const symmetricField = foreignTableAfterLink.fields.find( + (field) => field.type === 'link' && field.options.symmetricFieldId === linkField.id + ); + expect(symmetricField?.name).toBe(hostTable.name); + + const lookupField = await createFieldAndGet(hostTable.id, { + type: 'lookup', + options: { + linkFieldId: linkField.id, + foreignTableId: foreignTable.id, + lookupFieldId: foreignPrimary.id, + }, + }); + expect(lookupField.name).toBe(`${foreignPrimary.name} (from ${foreignTable.name})`); + + const rollupField = await createFieldAndGet(hostTable.id, { + type: 'rollup', + options: { expression: 'sum({values})' }, + config: { + linkFieldId: linkField.id, + foreignTableId: foreignTable.id, + lookupFieldId: foreignPrimary.id, + }, + }); + expect(rollupField.name).toBe(`${foreignPrimary.name} Rollup (from ${foreignTable.name})`); + } finally { + await ctx.deleteTable(hostTable.id).catch(() => undefined); + await ctx.deleteTable(foreignTable.id).catch(() => undefined); + } + }); + + // v1 reference: field.e2e-spec.ts + // "creates Date field with custom formatting and timezone without cast errors" + it('creates Date field with custom formatting and timezone on a table with records', async () => { + const table = await createTable({ + baseId: ctx.baseId, + name: `Date Formatting ${Date.now()}`, + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + }); + + try { + await ctx.createRecords(table.id, [{ fields: {} }, { fields: {} }, { fields: {} }]); + + const dateField = await createFieldAndGet(table.id, { + type: 'date', + name: '日期', + options: { + formatting: { + date: 'YYYY-MM-DD', + time: 'None', + timeZone: 'Asia/Shanghai', + }, + }, + }); + expect(dateField.type).toBe('date'); + expect(dateField.options).toEqual({ + formatting: { + date: 'YYYY-MM-DD', + time: 'None', + timeZone: 'Asia/Shanghai', + }, + }); + + await ctx.drainOutbox(); + const records = await ctx.listRecordsWithoutDrain(table.id); + expect(records).toHaveLength(3); + for (const record of records) { + expect(record.fields[dateField.id] ?? null).toBeNull(); + } + } finally { + await ctx.deleteTable(table.id).catch(() => undefined); + } + }); + + it.each(['singleLineText', 'longText', 'number', 'date'] as const)( + '[V1 PARITY] accepts unique for %s fields', + async (type) => { + const created = await createFieldAndGet(tableId, { + type, + name: `Unique ${type} ${Date.now()}`, + unique: true, + }); + expect(created.unique).toBe(true); + } + ); + + it('[V1 PARITY] rejects unique for unsupported field types', async () => { + const hostTable = await createTable({ + baseId: ctx.baseId, + name: `Unique Matrix ${Date.now()}`, + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + }); + + try { + const link = await createFieldAndGet(hostTable.id, { + type: 'link', + name: 'Rollup Source', + options: { + relationship: 'manyOne', + foreignTableId, + lookupFieldId: foreignPrimaryFieldId, + }, + }); + const fields: Array> = [ + { type: 'attachment' }, + { type: 'user' }, + { type: 'checkbox' }, + { type: 'singleSelect' }, + { type: 'multipleSelect' }, + { type: 'rating' }, + { type: 'formula', options: { expression: '1' } }, + { + type: 'link', + options: { + relationship: 'manyOne', + foreignTableId, + lookupFieldId: foreignPrimaryFieldId, + }, + }, + { + type: 'lookup', + options: { + linkFieldId: link.id, + foreignTableId, + lookupFieldId: foreignPrimaryFieldId, + }, + }, + { + type: 'rollup', + options: { expression: 'counta({values})' }, + config: { + linkFieldId: link.id, + foreignTableId, + lookupFieldId: foreignPrimaryFieldId, + }, + }, + { type: 'createdTime' }, + { type: 'lastModifiedTime' }, + { type: 'autoNumber' }, + ]; + + for (const field of fields) { + const response = await fetch(`${ctx.baseUrl}/tables/createField`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + baseId: ctx.baseId, + tableId: hostTable.id, + field: { id: createFieldId(), name: `Unique ${field.type}`, unique: true, ...field }, + }), + }); + expect(response.status, `field type ${field.type as string}`).toBe(400); + } + } finally { + await ctx.deleteTable(hostTable.id).catch(() => undefined); + } + }); + + it('[V1 PARITY] rejects notNull for field types that v1 and v2 both disallow', async () => { + const link = await createFieldAndGet(tableId, { + type: 'link', + name: `Required Matrix Source ${Date.now()}`, + options: { + relationship: 'manyOne', + foreignTableId, + lookupFieldId: foreignPrimaryFieldId, + }, + }); + + for (const field of [ + { type: 'checkbox' }, + { type: 'formula', options: { expression: '1' } }, + { + type: 'rollup', + options: { expression: 'counta({values})' }, + config: { + linkFieldId: link.id, + foreignTableId, + lookupFieldId: foreignPrimaryFieldId, + }, + }, + { type: 'createdTime' }, + { type: 'lastModifiedTime' }, + { type: 'autoNumber' }, + ]) { + const response = await fetch(`${ctx.baseUrl}/tables/createField`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + baseId: ctx.baseId, + tableId, + field: { id: createFieldId(), name: `Required ${field.type}`, notNull: true, ...field }, + }), + }); + expect(response.status, `field type ${field.type}`).toBe(400); + } + }); + + it('[V2 CONTRACT] accepts notNull for every supported field type on an empty table', async () => { + const emptyTable = await createTable({ + baseId: ctx.baseId, + name: `Required Empty Table ${Date.now()}`, + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + + try { + const fields = [ + { id: createFieldId(), type: 'singleLineText', name: 'Required Text', notNull: true }, + { id: createFieldId(), type: 'longText', name: 'Required Notes', notNull: true }, + { id: createFieldId(), type: 'number', name: 'Required Number', notNull: true }, + { + id: createFieldId(), + type: 'singleSelect', + name: 'Required Status', + notNull: true, + options: { choices: [{ id: 'choRequired', name: 'Required', color: 'blue' }] }, + }, + { + id: createFieldId(), + type: 'multipleSelect', + name: 'Required Tags', + notNull: true, + options: { choices: [{ id: 'choRequiredTag', name: 'Required', color: 'green' }] }, + }, + { + id: createFieldId(), + type: 'user', + name: 'Required Owner', + notNull: true, + options: { isMultiple: false, shouldNotify: false }, + }, + { id: createFieldId(), type: 'date', name: 'Required Date', notNull: true }, + { + id: createFieldId(), + type: 'rating', + name: 'Required Rating', + notNull: true, + options: { max: 5, icon: 'star', color: 'yellowBright' }, + }, + { id: createFieldId(), type: 'attachment', name: 'Required Files', notNull: true }, + { + id: createFieldId(), + type: 'link', + name: 'Required Link', + notNull: true, + options: { + relationship: 'manyOne', + foreignTableId, + lookupFieldId: foreignPrimaryFieldId, + }, + }, + ] satisfies ITableFieldInput[]; + + for (const field of fields) { + const response = await fetch(`${ctx.baseUrl}/tables/createField`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ baseId: ctx.baseId, tableId: emptyTable.id, field }), + }); + const rawBody = await response.json(); + + expect(response.status, `${field.type}: ${JSON.stringify(rawBody)}`).toBe(200); + const parsed = createFieldOkResponseSchema.safeParse(rawBody); + expect(parsed.success, `${field.type}: ${JSON.stringify(rawBody)}`).toBe(true); + if (!parsed.success || !parsed.data.ok) continue; + + const created = parsed.data.data.table.fields.find((item) => item.id === field.id); + expect(created?.notNull, field.type).toBe(true); + } + } finally { + await ctx.deleteTable(emptyTable.id).catch(() => undefined); + } + }); + + // Regression (T6520): aiConfig is validated against the field type just + // like in v1 — an attachment field carrying a text-style aiConfig is + // rejected instead of being stored as an opaque value. + it('[V1 PARITY] rejects aiConfig on field types that do not support it', async () => { + const response = await fetch(`${ctx.baseUrl}/tables/createField`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + baseId: ctx.baseId, + tableId, + field: { + id: createFieldId(), + type: 'attachment', + aiConfig: { + type: 'summary', + modelKey: 'openai@gpt-4o@gpt', + sourceFieldId: tablePrimaryFieldId, + }, + }, + }), + }); + expect(response.status).toBe(400); + }); + + // v1 reference: field.e2e-spec.ts "/api/table/{tableId}/field (GET)" and + // "(GET) with projection" are not portable: the v2 HTTP contract has no + // standalone field listing endpoint; fields are read through /tables/get, + // which is covered by getTableById.e2e.spec.ts. + }); }); diff --git a/packages/v2/e2e/src/createRecord.e2e.spec.ts b/packages/v2/e2e/src/createRecord.e2e.spec.ts index fe1825236a..9a496f26d3 100644 --- a/packages/v2/e2e/src/createRecord.e2e.spec.ts +++ b/packages/v2/e2e/src/createRecord.e2e.spec.ts @@ -611,6 +611,320 @@ describe('v2 http createRecord (e2e)', () => { expect(hostRow.rows.length).toBe(1); expect(normalizeJsonArray(hostRow.rows[0].lookup_value)).toEqual([sourceStatusName]); }); + + /** + * v1 reference: record.e2e-spec.ts:529 — formula fields are calculated in + * the create response, both constant and field-dependent expressions. + */ + it('creates a record and auto calculates computed formula fields', async () => { + const titleId = createFieldId(); + const constFormulaId = createFieldId(); + const dependentFormulaId = createFieldId(); + + const table = await createTable({ + baseId: ctx.baseId, + name: 'Create Formula Compute', + fields: [ + { type: 'singleLineText', id: titleId, name: 'Title', isPrimary: true }, + { type: 'formula', id: constFormulaId, name: 'Const', options: { expression: '1 + 1' } }, + { + type: 'formula', + id: dependentFormulaId, + name: 'Suffixed', + options: { expression: `{${titleId}} & "1"` }, + }, + ], + views: [{ type: 'grid' }], + }); + + const record = await createRecord(table.id, { [titleId]: 'text value' }); + + expect(record.fields[constFormulaId]).toBe(2); + expect(record.fields[dependentFormulaId]).toBe('text value1'); + }); + + /** + * v1 reference: record.e2e-spec.ts:1689 — chained numeric formulas + * (f2 depends on f1) are computed in the create response. + */ + it('creates with chained numeric formulas (f2 depends on f1)', async () => { + const baseNumId = createFieldId(); + const f1Id = createFieldId(); + const f2Id = createFieldId(); + + const table = await createTable({ + baseId: ctx.baseId, + name: 'Create Numeric Formula Chain', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { type: 'number', id: baseNumId, name: 'Base' }, + { type: 'formula', id: f1Id, name: 'F1', options: { expression: `{${baseNumId}} + 1` } }, + { type: 'formula', id: f2Id, name: 'F2', options: { expression: `{${f1Id}} + 2` } }, + ], + views: [{ type: 'grid' }], + }); + + const record = await createRecord(table.id, { [baseNumId]: 10 }); + + expect(record.fields[f1Id]).toBe(11); + expect(record.fields[f2Id]).toBe(13); + }); + + /** + * v1 reference: record.e2e-spec.ts:1714 — chained string formulas are + * computed in the create response. + */ + it('creates with chained string formulas', async () => { + const txtId = createFieldId(); + const f1Id = createFieldId(); + const f2Id = createFieldId(); + + const table = await createTable({ + baseId: ctx.baseId, + name: 'Create String Formula Chain', + fields: [ + { type: 'singleLineText', id: txtId, name: 'Title', isPrimary: true }, + { type: 'formula', id: f1Id, name: 'F1', options: { expression: `{${txtId}} & '-x'` } }, + { type: 'formula', id: f2Id, name: 'F2', options: { expression: `{${f1Id}} & '-y'` } }, + ], + views: [{ type: 'grid' }], + }); + + const record = await createRecord(table.id, { [txtId]: 'abc' }); + + expect(record.fields[f1Id]).toBe('abc-x'); + expect(record.fields[f2Id]).toBe('abc-x-y'); + }); + + /** + * v1 reference: record.e2e-spec.ts:742 — a singleSelect default value is + * applied when the field is omitted on create. + */ + it('creates a record with default single select', async () => { + const table = await createTable({ + baseId: ctx.baseId, + name: 'Create Default Single Select', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'singleSelect', + name: 'Status', + options: { + choices: [{ name: 'default value' }], + defaultValue: 'default value', + }, + }, + ], + views: [{ type: 'grid' }], + }); + const statusFieldId = table.fields.find((f) => f.name === 'Status')?.id ?? ''; + + const record = await createRecord(table.id, {}); + + expect(record.fields[statusFieldId]).toBe('default value'); + }); + + /** + * v1 reference: record.e2e-spec.ts:762 — a multipleSelect default value is + * applied when the field is omitted on create. + */ + it('creates a record with default multiple select', async () => { + const table = await createTable({ + baseId: ctx.baseId, + name: 'Create Default Multiple Select', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'multipleSelect', + name: 'Tags', + options: { + choices: [{ name: 'default value' }, { name: 'default value2' }], + defaultValue: ['default value', 'default value2'], + }, + }, + ], + views: [{ type: 'grid' }], + }); + const tagsFieldId = table.fields.find((f) => f.name === 'Tags')?.id ?? ''; + + const record = await createRecord(table.id, {}); + + expect(record.fields[tagsFieldId]).toEqual(['default value', 'default value2']); + }); + + /** + * v1 reference: record.e2e-spec.ts:782 — a number default value is applied + * when the field is omitted on create. + */ + it('creates a record with default number', async () => { + const table = await createTable({ + baseId: ctx.baseId, + name: 'Create Default Number', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { type: 'number', name: 'Amount', options: { defaultValue: 1 } }, + ], + views: [{ type: 'grid' }], + }); + const amountFieldId = table.fields.find((f) => f.name === 'Amount')?.id ?? ''; + + const record = await createRecord(table.id, {}); + + expect(record.fields[amountFieldId]).toBe(1); + }); + + /** + * v1 reference: record.e2e-spec.ts:801 — user default values (explicit user + * id and the "me" alias) are resolved to full user cell values on create. + */ + it('creates a record with default user', async () => { + const table = await createTable({ + baseId: ctx.baseId, + name: 'Create Default User', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { type: 'user', name: 'Single Owner', options: { defaultValue: ctx.testUser.id } }, + { + type: 'user', + name: 'Me Team', + options: { isMultiple: true, defaultValue: ['me'] }, + }, + { + type: 'user', + name: 'Id Team', + options: { isMultiple: true, defaultValue: [ctx.testUser.id] }, + }, + ], + views: [{ type: 'grid' }], + }); + const singleOwnerFieldId = table.fields.find((f) => f.name === 'Single Owner')?.id ?? ''; + const meTeamFieldId = table.fields.find((f) => f.name === 'Me Team')?.id ?? ''; + const idTeamFieldId = table.fields.find((f) => f.name === 'Id Team')?.id ?? ''; + + const record = await createRecord(table.id, {}); + + const expectedUser = { + id: ctx.testUser.id, + title: ctx.testUser.name, + email: ctx.testUser.email, + }; + expect(record.fields[singleOwnerFieldId]).toMatchObject(expectedUser); + expect(record.fields[meTeamFieldId]).toMatchObject([expectedUser]); + expect(record.fields[idTeamFieldId]).toMatchObject([expectedUser]); + }); + + /** + * v1 reference: record.e2e-spec.ts:621 — creating a record still succeeds + * when a formula references a deleted field; the errored formula stays empty. + */ + it('creates a record when a formula references a deleted field', async () => { + const table = await createTable({ + baseId: ctx.baseId, + name: 'Create Errored Formula', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { type: 'number', name: 'Doomed' }, + ], + views: [{ type: 'grid' }], + }); + const titleFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + const doomedFieldId = table.fields.find((f) => f.name === 'Doomed')?.id ?? ''; + + const withFormula = await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { type: 'formula', name: 'Errored', options: { expression: `{${doomedFieldId}}` } }, + }); + const formulaFieldId = withFormula.fields.find((f) => f.name === 'Errored')?.id ?? ''; + + await ctx.deleteField({ tableId: table.id, fieldId: doomedFieldId }); + + const record = await createRecord(table.id, { [titleFieldId]: 'after delete' }); + + expect(record.fields[titleFieldId]).toBe('after delete'); + expect(record.fields[formulaFieldId] == null).toBe(true); + }); + + /** + * v1 reference: record.e2e-spec.ts:646 — creating a record with a link value + * still succeeds when the lookup/rollup source field was deleted; the + * errored computed fields stay empty. + */ + it('creates a record when lookup and rollup reference a deleted field', async () => { + const foreignTable = await createTable({ + baseId: ctx.baseId, + name: 'Errored Lookup Foreign', + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'number', name: 'Value' }, + ], + views: [{ type: 'grid' }], + }); + const foreignNameFieldId = foreignTable.fields.find((f) => f.isPrimary)?.id ?? ''; + const foreignValueFieldId = foreignTable.fields.find((f) => f.name === 'Value')?.id ?? ''; + const foreignRecord = await createRecord(foreignTable.id, { + [foreignNameFieldId]: 'Target', + [foreignValueFieldId]: 42, + }); + + const linkId = createFieldId(); + const table = await createTable({ + baseId: ctx.baseId, + name: 'Errored Lookup Host', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'link', + id: linkId, + name: 'Link', + options: { + relationship: 'manyOne', + foreignTableId: foreignTable.id, + lookupFieldId: foreignNameFieldId, + isOneWay: true, + }, + }, + { + type: 'lookup', + name: 'Value Lookup', + options: { + linkFieldId: linkId, + foreignTableId: foreignTable.id, + lookupFieldId: foreignValueFieldId, + }, + }, + ], + views: [{ type: 'grid' }], + }); + const titleFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + const lookupFieldId = table.fields.find((f) => f.name === 'Value Lookup')?.id ?? ''; + + const withRollup = await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { + type: 'rollup', + name: 'Value Sum', + options: { expression: 'sum({values})' }, + config: { + linkFieldId: linkId, + foreignTableId: foreignTable.id, + lookupFieldId: foreignValueFieldId, + }, + }, + }); + const rollupFieldId = withRollup.fields.find((f) => f.name === 'Value Sum')?.id ?? ''; + + await ctx.deleteField({ tableId: foreignTable.id, fieldId: foreignValueFieldId }); + + const record = await createRecord(table.id, { + [titleFieldId]: 'after source delete', + [linkId]: { id: foreignRecord.id }, + }); + + expect(record.fields[lookupFieldId] == null).toBe(true); + expect(record.fields[rollupFieldId] == null).toBe(true); + }); }); describe('v2 http createRecord with link fields (e2e)', () => { @@ -882,4 +1196,86 @@ describe('v2 http createRecord with link fields (e2e)', () => { // The error message should indicate a foreign key constraint violation expect(body.error?.message ?? '').toContain('Failed to insert record'); }); + + /** + * v1 reference: record.e2e-spec.ts:905 — a record can be created when a + * notNull-constrained link field is provided, even with an empty title and a + * dependent lookup field present. + */ + it('creates a record with a required (notNull) link field', async () => { + const foreignTable = await createTable({ + baseId: ctx.baseId, + name: 'Required Link Foreign', + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const requiredForeignNameFieldId = foreignTable.fields.find((f) => f.isPrimary)?.id ?? ''; + const foreignRecord = await createRecord(foreignTable.id, { + [requiredForeignNameFieldId]: 'Constraint Target', + }); + + const table = await createTable({ + baseId: ctx.baseId, + name: 'Required Link Main', + fields: [{ type: 'singleLineText', name: 'Title', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const titleFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + + const withLink = await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { + type: 'link', + name: 'Required Link', + options: { + relationship: 'manyMany', + foreignTableId: foreignTable.id, + lookupFieldId: requiredForeignNameFieldId, + isOneWay: true, + }, + }, + }); + const requiredLinkFieldId = withLink.fields.find((f) => f.name === 'Required Link')?.id ?? ''; + + await ctx.updateField({ + tableId: table.id, + fieldId: requiredLinkFieldId, + field: { notNull: true }, + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { + type: 'lookup', + name: 'Name Lookup', + options: { + linkFieldId: requiredLinkFieldId, + foreignTableId: foreignTable.id, + lookupFieldId: requiredForeignNameFieldId, + }, + }, + }); + + const response = await fetch(`${ctx.baseUrl}/tables/createRecord`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tableId: table.id, + fields: { + [titleFieldId]: 'Satisfies Constraint', + [requiredLinkFieldId]: [{ id: foreignRecord.id, title: '' }], + }, + }), + }); + + expect(response.status).toBe(201); + + const rawBody = await response.json(); + const parsed = createRecordOkResponseSchema.safeParse(rawBody); + expect(parsed.success).toBe(true); + if (!parsed.success) return; + expect(parsed.data.ok).toBe(true); + }); }); diff --git a/packages/v2/e2e/src/createRecordLink.e2e.spec.ts b/packages/v2/e2e/src/createRecordLink.e2e.spec.ts index 97424e764f..c865766577 100644 --- a/packages/v2/e2e/src/createRecordLink.e2e.spec.ts +++ b/packages/v2/e2e/src/createRecordLink.e2e.spec.ts @@ -1010,4 +1010,79 @@ describe('v2 http createRecord link fields (e2e)', () => { }); }); }); + + /** + * v1 reference: lin-field-not-null.e2e-spec (T1756, T6520 list) — the + * notNull constraint on a link field must gate record creation, and removing + * the constraint must lift the gate. + */ + describe('link field notNull constraint (T1756)', () => { + // Regression (T1756/T6520): notNull on link fields is enforced by the + // application-level pre-validation on create (link storage lives in + // FK/junction tables, so the DB column constraint cannot cover it). + it('rejects creating a record without a link value while notNull is set, allows after removing it', async () => { + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Link NotNull Foreign', + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const foreignTitleFieldId = foreignTable.fields.find((f) => f.isPrimary)?.id ?? ''; + const foreignRecord = await ctx.createRecord(foreignTable.id, { + [foreignTitleFieldId]: 'Target', + }); + + const mainTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Link NotNull Main', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'link', + name: 'Required Link', + notNull: true, + options: { + relationship: 'manyOne', + foreignTableId: foreignTable.id, + lookupFieldId: foreignTitleFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + const mainTitleFieldId = mainTable.fields.find((f) => f.isPrimary)?.id ?? ''; + const linkFieldId = mainTable.fields.find((f) => f.name === 'Required Link')?.id ?? ''; + + // With notNull: creating without a link value must fail + const failing = await fetch(`${ctx.baseUrl}/tables/createRecord`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tableId: mainTable.id, + fields: { [mainTitleFieldId]: 'No Link' }, + }), + }); + expect(failing.status).toBeGreaterThanOrEqual(400); + + // With notNull: creating with a link value succeeds + const withLink = await ctx.createRecord(mainTable.id, { + [mainTitleFieldId]: 'Has Link', + [linkFieldId]: { id: foreignRecord.id }, + }); + expect(withLink.id).toBeTruthy(); + + // Remove the constraint: creating without a link value succeeds + await ctx.updateField({ + baseId: ctx.baseId, + tableId: mainTable.id, + fieldId: linkFieldId, + field: { notNull: false }, + }); + const withoutLink = await ctx.createRecord(mainTable.id, { + [mainTitleFieldId]: 'No Link After Removal', + }); + expect(withoutLink.id).toBeTruthy(); + }); + }); }); diff --git a/packages/v2/e2e/src/createRecords.e2e.spec.ts b/packages/v2/e2e/src/createRecords.e2e.spec.ts index 31730302a8..6d9e4bbe83 100644 --- a/packages/v2/e2e/src/createRecords.e2e.spec.ts +++ b/packages/v2/e2e/src/createRecords.e2e.spec.ts @@ -390,6 +390,49 @@ describe('v2 http createRecords (e2e)', () => { expect(records[0].fields[conditionalLookup.id]).toEqual(['Alpha', 'Beta']); }); + + /** + * v1 reference: record.e2e-spec.ts:701 — after repeatedly creating and + * deleting fields with the same name, name-keyed record creation resolves + * to the live field. + */ + it('creates a record by name when duplicate name field is deleted', async () => { + const table = await createTable({ + baseId: ctx.baseId, + name: 'Batch Duplicate Name Field', + fields: [{ type: 'singleLineText', name: 'Title', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const fieldName = 'test-field'; + + for (let i = 0; i < 10; i += 1) { + const field = await createField(table.id, { type: 'singleLineText', name: fieldName }); + await ctx.deleteField({ tableId: table.id, fieldId: field.id }); + } + const liveField = await createField(table.id, { type: 'singleLineText', name: fieldName }); + + const response = await fetch(`${ctx.baseUrl}/tables/createRecords`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tableId: table.id, + fieldKeyType: 'name', + typecast: true, + records: [{ fields: { [fieldName]: 'test' } }], + }), + }); + + expect(response.status).toBe(201); + const rawBody = await response.json(); + const parsed = createRecordsOkResponseSchema.safeParse(rawBody); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) return; + + expect(parsed.data.data.records[0].fields[fieldName]).toBe('test'); + + const stored = await listRecords(table.id); + expect(stored.some((r) => r.fields[liveField.id] === 'test')).toBe(true); + }); }); describe('link fields - manyMany', () => { @@ -519,6 +562,111 @@ describe('v2 http createRecords (e2e)', () => { ); } }); + + /** + * v1 reference: record.e2e-spec.ts:1016 — concurrent batch creates with + * link values must not collide on internal ops/row indexes. + */ + it('creates records concurrently with link values without index conflicts', async () => { + const makeBatch = () => + createRecords(mainTableId, [ + { + fields: { + [mainTitleFieldId]: 'Concurrent A', + [linkFieldId]: [{ id: foreignRecordId1 }], + }, + }, + { + fields: { + [mainTitleFieldId]: 'Concurrent B', + [linkFieldId]: [{ id: foreignRecordId2 }], + }, + }, + { + fields: { + [mainTitleFieldId]: 'Concurrent C', + [linkFieldId]: [{ id: foreignRecordId3 }], + }, + }, + ]); + + const [firstBatch, secondBatch] = await Promise.all([makeBatch(), makeBatch()]); + + expect(firstBatch.length).toBe(3); + expect(secondBatch.length).toBe(3); + + await processOutbox(); + const allRecords = await listRecords(mainTableId); + const createdIds = [...firstBatch, ...secondBatch].map((record) => record.id); + expect(createdIds.filter((id) => allRecords.some((r) => r.id === id)).length).toBe(6); + }); + + /** + * v1 reference: record.e2e-spec.ts:1631 — the create response contains the + * rollup computed over the linked record set immediately. + */ + it('creates with link and computes rollup immediately', async () => { + const foreign = await createTable({ + baseId: ctx.baseId, + name: 'Rollup On Create Foreign', + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'number', name: 'Value' }, + ], + views: [{ type: 'grid' }], + }); + const nameFieldId = foreign.fields.find((f) => f.isPrimary)?.id ?? ''; + const valueFieldId = foreign.fields.find((f) => f.name === 'Value')?.id ?? ''; + const target1 = await createRecord(foreign.id, { + [nameFieldId]: 'Eleven', + [valueFieldId]: 11, + }); + const target2 = await createRecord(foreign.id, { + [nameFieldId]: 'Nine', + [valueFieldId]: 9, + }); + + const main = await createTable({ + baseId: ctx.baseId, + name: 'Rollup On Create Main', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'link', + name: 'Links', + options: { + relationship: 'manyMany', + foreignTableId: foreign.id, + lookupFieldId: nameFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + const mainLinkFieldId = main.fields.find((f) => f.name === 'Links')?.id ?? ''; + + const rollupField = await createField(main.id, { + type: 'rollup', + name: 'Sum', + options: { expression: 'sum({values})' }, + config: { + linkFieldId: mainLinkFieldId, + foreignTableId: foreign.id, + lookupFieldId: valueFieldId, + }, + }); + + const records = await createRecords(main.id, [ + { + fields: { + [mainLinkFieldId]: [{ id: target1.id }, { id: target2.id }], + }, + }, + ]); + + expect(records[0].fields[rollupField.id]).toBe(20); + }); }); describe('link fields - manyOne', () => { @@ -598,6 +746,59 @@ describe('v2 http createRecords (e2e)', () => { expect(parseInt(result.rows[0].count, 10)).toBeGreaterThanOrEqual(3); }); + + /** + * v1 reference: record.e2e-spec.ts:1596 — the create response contains the + * lookup computed from the linked record immediately. + */ + it('creates with link and computes lookup immediately', async () => { + const foreign = await createTable({ + baseId: ctx.baseId, + name: 'Lookup On Create Foreign', + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const nameFieldId = foreign.fields.find((f) => f.isPrimary)?.id ?? ''; + const target = await createRecord(foreign.id, { [nameFieldId]: 'LABEL_A' }); + + const main = await createTable({ + baseId: ctx.baseId, + name: 'Lookup On Create Main', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'link', + name: 'Parent', + options: { + relationship: 'manyOne', + foreignTableId: foreign.id, + lookupFieldId: nameFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + const mainLinkFieldId = main.fields.find((f) => f.name === 'Parent')?.id ?? ''; + + const lookupField = await createField(main.id, { + type: 'lookup', + name: 'Name Lookup', + options: { + linkFieldId: mainLinkFieldId, + foreignTableId: foreign.id, + lookupFieldId: nameFieldId, + }, + }); + + const records = await createRecords(main.id, [ + { fields: { [mainLinkFieldId]: { id: target.id } } }, + ]); + + const lookupValue = records[0].fields[lookupField.id]; + // assert the computed value without pinning the single/array lookup shape + expect(Array.isArray(lookupValue) ? lookupValue : [lookupValue]).toEqual(['LABEL_A']); + }); }); describe('database verification', () => { diff --git a/packages/v2/e2e/src/createTable.e2e.spec.ts b/packages/v2/e2e/src/createTable.e2e.spec.ts index 714512a1a8..789b322213 100644 --- a/packages/v2/e2e/src/createTable.e2e.spec.ts +++ b/packages/v2/e2e/src/createTable.e2e.spec.ts @@ -2,10 +2,19 @@ import { createTableOkResponseSchema } from '@teable/v2-contract-http'; import { createV2HttpClient } from '@teable/v2-contract-http-client'; import type { ICreateTableCommandInput } from '@teable/v2-core'; -import { createAllFieldTypesFields, tableTemplates } from '@teable/v2-table-templates'; +import { + createAllFieldTypesFields, + defaultTableTemplate, + tableTemplates, +} from '@teable/v2-table-templates'; import { sql } from 'kysely'; import { beforeAll, describe, expect, it } from 'vitest'; import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; +import { + ensureAttachmentTables, + makeAttachmentCell, + seedAttachment, +} from './update-field/attachment/testUtils'; describe('v2 http createTable (e2e)', () => { let ctx: SharedTestContext; @@ -303,6 +312,12 @@ describe('v2 http createTable (e2e)', () => { tagField.options && typeof tagField.options === 'object' && 'choices' in tagField.options ? (tagField.options as { choices?: Array<{ id?: string; name: string }> }).choices ?? [] : []; + await ensureAttachmentTables(ctx); + const attachment = await seedAttachment(ctx); + + // "Files" is notNull and [] normalizes to null (v1 contract), so seed a real attachment + await ensureAttachmentTables(ctx); + const seededAttachment = await seedAttachment(ctx); await ctx.createRecord(created.id, { Name: 'owner@example.com', @@ -315,7 +330,7 @@ describe('v2 http createTable (e2e)', () => { .map((choice) => choice.id) .filter((id): id is string => Boolean(id)), Done: true, - Files: [], + Files: makeAttachmentCell(seededAttachment, 'all-field-types.txt'), 'Due Date': '2025-02-10T00:00:00.000Z', Company: { id: companyRecord.id }, }); @@ -360,6 +375,132 @@ describe('v2 http createTable (e2e)', () => { expect(second.baseId).toBe(ctx.baseId); }); + it('[V1 PARITY][table-concurrency.e2e-spec.ts] avoids db name collisions when creating tables concurrently', async () => { + const sharedName = `Concurrent Table ${Math.random().toString(36).slice(2, 8)}`; + + const results = await Promise.allSettled( + Array.from({ length: 3 }, () => + ctx.createTable({ + baseId: ctx.baseId, + name: sharedName, + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + }) + ) + ); + + const rejected = results.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ); + expect(rejected.map((result) => result.reason)).toEqual([]); + + const tables = results.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [] + ); + expect(tables).toHaveLength(3); + + // All creates succeed with distinct table ids and distinct physical storage names. + expect(new Set(tables.map((table) => table.id)).size).toBe(3); + const dbTableNames = tables.map((table) => table.dbTableName); + expect(dbTableNames.every((name) => typeof name === 'string' && name.length > 0)).toBe(true); + expect(new Set(dbTableNames).size).toBe(3); + + // v2 keeps the requested display name on every table (v1 in V2 mode asserts the same). + expect(tables.map((table) => table.name)).toEqual([sharedName, sharedName, sharedName]); + }); + + it('[V1 PARITY][table.e2e-spec.ts] creates default primary field and grid view for an empty payload', async () => { + const created = await ctx.createTable({ + baseId: ctx.baseId, + name: 'new table', + }); + + expect(created.name).toBe('new table'); + expect(created.fields).toHaveLength(1); + expect(created.fields[0]?.type).toBe('singleLineText'); + expect(created.fields[0]?.isPrimary).toBe(true); + expect(created.views).toHaveLength(1); + expect(created.views[0]?.type).toBe('grid'); + + // v1's "3 empty records for an empty payload" default comes from its API + // layer (TablePipe fills DEFAULT_FIELDS/VIEWS/RECORD_DATA before the + // command runs — the FORCE_V2 bridge inherits it). The native v2 endpoint + // stays explicit; the same default table is available as the 'default' + // template (@teable/v2-table-templates), covered by the test below. + const records = await ctx.listRecords(created.id, { limit: 10 }); + expect(records).toHaveLength(0); + }); + + // v1 parity (T6520): Teable's default blank table is the 'default' template — + // Name/Count/Status fields, a grid view, and exactly 3 empty records. + it('creates the v1 default table from the default template', async () => { + const [created] = await ctx.createTables( + defaultTableTemplate.createInput(ctx.baseId, { namePrefix: 'default template table' }) + ); + + expect(created!.name).toBe('default template table'); + expect(created!.fields.map((field) => [field.name, field.type])).toEqual([ + ['Name', 'singleLineText'], + ['Count', 'number'], + ['Status', 'singleSelect'], + ]); + expect(created!.fields[0]?.isPrimary).toBe(true); + expect(created!.views).toHaveLength(1); + expect(created!.views[0]?.type).toBe('grid'); + + const records = await ctx.listRecords(created!.id, { limit: 10 }); + expect(records).toHaveLength(3); + for (const record of records) { + expect(Object.values(record.fields).every((value) => value === null)).toBe(true); + } + }); + + it('[V1 PARITY][table.e2e-spec.ts] creates table with ordered fields', async () => { + const amountFieldId = createFieldId(); + const created = await ctx.createTable({ + baseId: ctx.baseId, + name: 'ordered fields table', + fields: [ + { type: 'singleLineText', name: 'Single line text' }, + { type: 'formula', id: amountFieldId, name: 'Formula', options: { expression: '1 + 1' } }, + { type: 'longText', name: 'Long text' }, + ], + }); + + expect(created.fields.map((field) => field.type)).toEqual([ + 'singleLineText', + 'formula', + 'longText', + ]); + + const fetched = await ctx.getTableById(created.id); + expect(fetched.fields.map((field) => field.type)).toEqual([ + 'singleLineText', + 'formula', + 'longText', + ]); + }); + + // Regression (T6520): the primary field type is restricted at creation just + // like on conversion — a checkbox first field is rejected, not promoted. + it('[V1 PARITY][table.e2e-spec.ts] rejects createTable when first field has unsupported primary type', async () => { + const response = await fetch(`${ctx.baseUrl}/tables/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + baseId: ctx.baseId, + name: 'bad primary table', + fields: [ + { type: 'checkbox', name: 'Done' }, + { type: 'singleLineText', name: 'Note' }, + ], + }), + }); + + expect(response.status).toBeGreaterThanOrEqual(400); + const rawBody: unknown = await response.json(); + expect(JSON.stringify(rawBody)).toMatch(/primary/i); + }); + it('creates tables for every template with seeded records', async () => { let index = 0; for (const template of tableTemplates) { @@ -384,7 +525,8 @@ describe('v2 http createTable (e2e)', () => { const records = await ctx.listRecords(table.id, { limit: 1000 }); expect(records).toHaveLength(templateTable.defaultRecordCount); - if (templateTable.defaultRecordCount > 0) { + // The default template intentionally seeds empty records. + if (templateTable.defaultRecordCount > 0 && template.key !== 'default') { expect(Object.keys(records[0]!.fields)).not.toHaveLength(0); } diff --git a/packages/v2/e2e/src/createView.e2e.spec.ts b/packages/v2/e2e/src/createView.e2e.spec.ts new file mode 100644 index 0000000000..499e7f6dd8 --- /dev/null +++ b/packages/v2/e2e/src/createView.e2e.spec.ts @@ -0,0 +1,196 @@ +import { + createViewErrorResponseSchema, + createViewOkResponseSchema, +} from '@teable/v2-contract-http'; +import { createV2HttpClient } from '@teable/v2-contract-http-client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +describe('v2 http createView (e2e)', () => { + let ctx: SharedTestContext; + let tableId: string; + let primaryFieldId: string; + + const postCreateView = async (view: Record, targetTableId = tableId) => { + const response = await fetch(`${ctx.baseUrl}/tables/createView`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tableId: targetTableId, view }), + }); + return { response, body: await response.json() }; + }; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Create View Contract', + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'date', name: 'Start' }, + { + type: 'singleSelect', + name: 'Status', + options: { + choices: [ + { id: 'choTodo', name: 'Todo', color: 'blue' }, + { id: 'choDone', name: 'Done', color: 'green' }, + ], + }, + }, + ], + views: [{ type: 'grid', name: 'Seed' }], + }); + tableId = table.id; + const primaryField = table.fields.find((field) => field.isPrimary); + if (!primaryField) throw new Error('Primary field was not created'); + primaryFieldId = primaryField.id; + }); + + afterAll(async () => { + if (ctx && tableId) { + await ctx.deleteTable(tableId).catch(() => undefined); + } + }); + + it('creates a rich Grid View and returns the Table aggregate, View id, and v2 event', async () => { + const { response, body } = await postCreateView({ + type: 'grid', + name: 'Planning', + description: 'Planning details', + columnMeta: { + [primaryFieldId]: { width: 240 }, + }, + options: { rowHeight: 'short', frozenColumnCount: 1 }, + sourceFilter: { + conjunction: 'and', + filterSet: [ + { + fieldId: primaryFieldId, + operator: 'LIKE', + isSymbol: true, + value: 'alpha', + }, + ], + }, + sort: [{ fieldId: primaryFieldId, order: 'asc' }], + manualSort: false, + isLocked: true, + enableShare: true, + shareMeta: { allowCopy: false, password: 'secret' }, + }); + + expect(response.status, JSON.stringify(body)).toBe(200); + const parsed = createViewOkResponseSchema.safeParse(body); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) return; + + const created = parsed.data.data.table.views.find( + (view) => view.id === parsed.data.data.viewId + ); + expect(created).toMatchObject({ + id: parsed.data.data.viewId, + name: 'Planning', + type: 'grid', + }); + expect(created?.columnMeta[primaryFieldId]).toMatchObject({ order: 0, width: 240 }); + expect(parsed.data.data.events).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'ViewCreated' })]) + ); + }); + + it.each([ + ['grid', { rowHeight: 'medium' }], + ['kanban', { isEmptyStackHidden: true }], + ['gallery', { isFieldNameHidden: true }], + ['calendar', { titleFieldId: null }], + ['form', { submitLabel: 'Submit' }], + ] as const)('creates a %s View through the native contract', async (type, options) => { + const { response, body } = await postCreateView({ + type, + name: `Contract ${type}`, + options, + }); + + expect(response.status).toBe(200); + const parsed = createViewOkResponseSchema.safeParse(body); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) return; + + expect( + parsed.data.data.table.views.find((view) => view.id === parsed.data.data.viewId) + ).toMatchObject({ type, name: `Contract ${type}` }); + }); + + it('supports the typed client without changing the legacy REST contract', async () => { + const client = createV2HttpClient({ baseUrl: ctx.baseUrl }); + const result = await client.tables.createView({ + tableId, + view: { + type: 'grid', + name: 'Typed client', + options: { rowHeight: 'tall' }, + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.table.views).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: result.data.viewId, + name: 'Typed client', + type: 'grid', + }), + ]) + ); + }); + + it('uses the Table aggregate to make duplicate View names unique', async () => { + const first = await postCreateView({ type: 'grid', name: 'Duplicate contract name' }); + const second = await postCreateView({ type: 'grid', name: 'Duplicate contract name' }); + + expect(first.response.status).toBe(200); + expect(second.response.status).toBe(200); + const parsed = createViewOkResponseSchema.safeParse(second.body); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) return; + + expect( + parsed.data.data.table.views.find((view) => view.id === parsed.data.data.viewId)?.name + ).toBe('Duplicate contract name 2'); + }); + + it('rejects an unsupported View type at the contract boundary', async () => { + const { response } = await postCreateView({ type: 'timeline', name: 'Unsupported' }); + expect(response.status).toBe(400); + }); + + it('maps aggregate option validation failures to the v2 error contract', async () => { + const { response, body } = await postCreateView({ + type: 'grid', + name: 'Invalid options', + options: { rowHeight: 'giant' }, + }); + + expect(response.status).toBe(400); + const parsed = createViewErrorResponseSchema.safeParse(body); + expect(parsed.success).toBe(true); + if (!parsed.success || parsed.data.ok) return; + expect(parsed.data.error.message).toContain('Invalid grid View options'); + }); + + it('maps a missing Table aggregate to the v2 error contract', async () => { + const { response, body } = await postCreateView( + { type: 'grid', name: 'Missing Table' }, + `tbl${'f'.repeat(16)}` + ); + + expect(response.status).toBe(404); + const parsed = createViewErrorResponseSchema.safeParse(body); + expect(parsed.success).toBe(true); + if (!parsed.success || parsed.data.ok) return; + expect(parsed.data.error.tags).toContain('not-found'); + }); +}); diff --git a/packages/v2/e2e/src/deleteByRange.e2e.spec.ts b/packages/v2/e2e/src/deleteByRange.e2e.spec.ts index faa7f7d99a..527ad6c847 100644 --- a/packages/v2/e2e/src/deleteByRange.e2e.spec.ts +++ b/packages/v2/e2e/src/deleteByRange.e2e.spec.ts @@ -1210,6 +1210,111 @@ describe('v2 http deleteByRange (e2e)', () => { }); }); + describe('deleteByRange with lookup-backed formula filter (v1 parity)', () => { + it('should delete selection when filter compares text field to lookup-backed formula', async () => { + const detailTable = await ctx.createTable({ + baseId: ctx.baseId, + name: `Delete Order Details ${Date.now()}`, + fields: [{ name: 'External Number', type: 'singleLineText', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const externalNumberFieldId = detailTable.fields.find((field) => field.isPrimary)?.id ?? ''; + const detail1 = await ctx.createRecord(detailTable.id, { + [externalNumberFieldId]: 'ORD-001', + }); + await ctx.createRecord(detailTable.id, { [externalNumberFieldId]: 'ORD-002' }); + + const orderTable = await ctx.createTable({ + baseId: ctx.baseId, + name: `Delete Orders ${Date.now()}`, + fields: [{ name: 'Order Number', type: 'singleLineText', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const orderNumberFieldId = orderTable.fields.find((field) => field.isPrimary)?.id ?? ''; + + const orderTableWithLink = await ctx.createField({ + baseId: ctx.baseId, + tableId: orderTable.id, + field: { + name: 'Detail Link', + type: 'link', + options: { + relationship: 'manyOne', + foreignTableId: detailTable.id, + lookupFieldId: externalNumberFieldId, + isOneWay: true, + }, + }, + }); + const linkFieldId = + orderTableWithLink.fields.find((field) => field.name === 'Detail Link')?.id ?? ''; + + const orderTableWithLookup = await ctx.createField({ + baseId: ctx.baseId, + tableId: orderTable.id, + field: { + name: 'External Number Lookup', + type: 'lookup', + options: { + linkFieldId, + foreignTableId: detailTable.id, + lookupFieldId: externalNumberFieldId, + }, + }, + }); + const lookupFieldId = + orderTableWithLookup.fields.find((field) => field.name === 'External Number Lookup')?.id ?? + ''; + + const orderTableWithFormula = await ctx.createField({ + baseId: ctx.baseId, + tableId: orderTable.id, + field: { + name: 'Match Flag', + type: 'formula', + options: { + expression: `IF({${orderNumberFieldId}} = {${lookupFieldId}}, "match", "not-match")`, + }, + }, + }); + const formulaFieldId = + orderTableWithFormula.fields.find((field) => field.name === 'Match Flag')?.id ?? ''; + + const order1 = await ctx.createRecord(orderTable.id, { + [orderNumberFieldId]: 'ORD-001', + [linkFieldId]: { id: detail1.id }, + }); + const order2 = await ctx.createRecord(orderTable.id, { + [orderNumberFieldId]: 'ORD-002', + }); + await ctx.drainOutbox(); + + const beforeRecords = await ctx.listRecords(orderTable.id); + expect(beforeRecords.find((record) => record.id === order1.id)?.fields[formulaFieldId]).toBe( + 'match' + ); + + const result = await ctx.deleteByRange({ + tableId: orderTable.id, + viewId: orderTable.views[0].id, + ranges: [ + [0, 0], + [0, 0], + ], + filter: { + fieldId: formulaFieldId, + operator: 'is', + value: 'match', + }, + }); + + expect(result.deletedCount).toBe(1); + + const afterRecords = await ctx.listRecords(orderTable.id); + expect(afterRecords.map((record) => record.id)).toEqual([order2.id]); + }); + }); + describe('deleteByRange with search', () => { let searchTableId: string; let searchViewId: string; diff --git a/packages/v2/e2e/src/deleteField.e2e.spec.ts b/packages/v2/e2e/src/deleteField.e2e.spec.ts index a3e91211c5..e4df06e761 100644 --- a/packages/v2/e2e/src/deleteField.e2e.spec.ts +++ b/packages/v2/e2e/src/deleteField.e2e.spec.ts @@ -693,6 +693,64 @@ describe('v2 http deleteField (e2e)', () => { } }); + /** + * v1 reference: + * - community/apps/nestjs-backend/test/field.e2e-spec.ts + * case: should delete a formula field, a -> b delete b + */ + it('[V1 PARITY] cleans references when deleting the formula consumer itself', async () => { + let tableId: string | undefined; + try { + const sourceFieldId = createFieldId(); + const formulaFieldId = createFieldId(); + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Delete Formula Consumer', + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', id: sourceFieldId, name: 'Source' }, + ], + records: [{ fields: { Name: 'r1', [sourceFieldId]: 'value' } }], + }); + tableId = table.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { + type: 'formula', + id: formulaFieldId, + name: 'Formula Consumer', + options: { expression: `{${sourceFieldId}}` }, + }, + }); + await ctx.drainOutbox(); + + expect(await countReferenceRowsTo(formulaFieldId)).toBe(1); + expect(await countReferenceRowsFrom(sourceFieldId)).toBe(1); + + await ctx.deleteField({ tableId, fieldId: formulaFieldId }); + await ctx.drainOutbox(); + + // The reference from source -> formula must be removed together with + // the formula field, while the source field stays intact and editable. + expect(await countReferenceRowsTouching(formulaFieldId)).toBe(0); + expect(await countReferenceRowsFrom(sourceFieldId)).toBe(0); + + const tableAfter = await ctx.getTableById(tableId); + expect(tableAfter.fields.some((field) => field.id === formulaFieldId)).toBe(false); + const sourceAfter = tableAfter.fields.find((field) => field.id === sourceFieldId); + expect(sourceAfter?.type).toBe('singleLineText'); + expect(sourceAfter?.hasError).toBeFalsy(); + + const records = await ctx.listRecordsWithoutDrain(tableId); + expect(records).toHaveLength(1); + expect(records[0]?.fields[sourceFieldId]).toBe('value'); + } finally { + await safeDeleteTable(tableId); + } + }); + /** * v1 reference: * - community/apps/nestjs-backend/test/delete-field.e2e-spec.ts @@ -1625,6 +1683,50 @@ describe('v2 http deleteField (e2e)', () => { } }); + it('T6539: deletes orphan one-many link field after foreign host table is soft-deleted', async () => { + let hostTableId: string | undefined; + let foreignTableId: string | undefined; + + try { + const host = await createTable('Orphan Link Host'); + const foreign = await createTable('Deleted Foreign Host'); + hostTableId = host.tableId; + foreignTableId = foreign.tableId; + + const linkFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.tableId, + field: { + type: 'link', + id: linkFieldId, + name: 'Orphan One-Many Link', + options: { + relationship: 'oneMany', + foreignTableId: foreign.tableId, + lookupFieldId: foreign.primaryFieldId, + isOneWay: false, + }, + }, + }); + + await ctx.deleteTable(foreign.tableId, { mode: 'soft' }); + // Match the incident shape: metadata is soft-deleted and the data relation is absent. + await sql` + DROP TABLE ${sql.table(`${ctx.baseId}.${foreign.tableId}`)} CASCADE + `.execute(ctx.testContainer.dataDb); + foreignTableId = undefined; + + await ctx.deleteField({ tableId: host.tableId, fieldId: linkFieldId }); + + const hostAfter = await ctx.getTableById(host.tableId); + expect(hostAfter.fields.some((field) => field.id === linkFieldId)).toBe(false); + } finally { + await safeDeleteTable(hostTableId); + await safeDeleteTable(foreignTableId); + } + }); + it('T4927: deletes orphan one-way link field after foreign table is soft-deleted', async () => { let hostTableId: string | undefined; let foreignTableId: string | undefined; diff --git a/packages/v2/e2e/src/deleteRecords-with-links.e2e.spec.ts b/packages/v2/e2e/src/deleteRecords-with-links.e2e.spec.ts index bcef3a9154..b8b5a987b5 100644 --- a/packages/v2/e2e/src/deleteRecords-with-links.e2e.spec.ts +++ b/packages/v2/e2e/src/deleteRecords-with-links.e2e.spec.ts @@ -739,4 +739,144 @@ describe('v2 http deleteRecords with links (e2e)', () => { expect(afterRecords[0].fields[aRollupFieldId]).toBe(20); }); }); + + // =========================================================================== + // Deleting records that own link-derived fields / link constraints + // Ported from v1 link-api.e2e-spec.ts "Create two bi-link for two tables" + // =========================================================================== + + describe('deleting records that own link-derived fields or link constraints', () => { + it('deletes a record that has a lookup of the symmetric link field', async () => { + const tableB = await ctx.createTable({ + baseId: ctx.baseId, + name: 'DeleteLinkLookup_B', + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const bNameFieldId = tableB.fields.find((f) => f.isPrimary)?.id ?? ''; + const recordB = await ctx.createRecord(tableB.id, { [bNameFieldId]: 'TargetB' }); + + const tableA = await ctx.createTable({ + baseId: ctx.baseId, + name: 'DeleteLinkLookup_A', + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { + type: 'link', + name: 'LinkToB', + options: { + relationship: 'manyOne', + foreignTableId: tableB.id, + lookupFieldId: bNameFieldId, + isOneWay: false, + }, + }, + ], + views: [{ type: 'grid' }], + }); + const aNameFieldId = tableA.fields.find((f) => f.isPrimary)?.id ?? ''; + const aLinkField = tableA.fields.find((f) => f.type === 'link'); + if (!aLinkField || aLinkField.type !== 'link') throw new Error('Missing link field'); + const symmetricFieldId = aLinkField.options.symmetricFieldId ?? ''; + if (!symmetricFieldId) throw new Error('Missing symmetric field id'); + + // Lookup on tableA whose looked-up foreign field is itself a link field + // (tableB's symmetric link back to tableA) + const tableAWithLookup = await ctx.createField({ + baseId: ctx.baseId, + tableId: tableA.id, + field: { + type: 'lookup', + name: 'LookupOfSymmetricLink', + options: { + linkFieldId: aLinkField.id, + foreignTableId: tableB.id, + lookupFieldId: symmetricFieldId, + }, + }, + }); + expect(tableAWithLookup.fields.find((f) => f.name === 'LookupOfSymmetricLink')).toBeDefined(); + + const recordA = await ctx.createRecord(tableA.id, { + [aNameFieldId]: 'SourceA', + [aLinkField.id]: { id: recordB.id }, + }); + await ctx.testContainer.processOutbox(); + await ctx.testContainer.processOutbox(); + + // Deleting the record that owns both the link and the link-lookup must succeed + await ctx.deleteRecord(tableA.id, recordA.id); + await ctx.testContainer.processOutbox(); + + const afterRecords = await ctx.listRecords(tableA.id); + expect(afterRecords.find((r) => r.id === recordA.id)).toBeUndefined(); + + // Symmetric cell on tableB reads back empty (null/absent), never [] + const bRecords = await ctx.listRecords(tableB.id); + const bStored = bRecords.find((r) => r.id === recordB.id); + expect(bStored?.fields[symmetricFieldId] ?? undefined).toBeUndefined(); + }); + + it('deletes a record whose own link field has a notNull constraint', async () => { + const tableB = await ctx.createTable({ + baseId: ctx.baseId, + name: 'DeleteNotNullLink_B', + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const bNameFieldId = tableB.fields.find((f) => f.isPrimary)?.id ?? ''; + const recordB = await ctx.createRecord(tableB.id, { [bNameFieldId]: 'TargetB' }); + + const tableA = await ctx.createTable({ + baseId: ctx.baseId, + name: 'DeleteNotNullLink_A', + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { + type: 'link', + name: 'RequiredLinkToB', + options: { + relationship: 'manyOne', + foreignTableId: tableB.id, + lookupFieldId: bNameFieldId, + isOneWay: false, + }, + }, + ], + views: [{ type: 'grid' }], + }); + const aNameFieldId = tableA.fields.find((f) => f.isPrimary)?.id ?? ''; + const aLinkField = tableA.fields.find((f) => f.type === 'link'); + if (!aLinkField || aLinkField.type !== 'link') throw new Error('Missing link field'); + const symmetricFieldId = aLinkField.options.symmetricFieldId ?? ''; + + const recordA = await ctx.createRecord(tableA.id, { + [aNameFieldId]: 'SourceA', + [aLinkField.id]: { id: recordB.id }, + }); + await ctx.testContainer.processOutbox(); + + // Enable notNull on the link field after the record already has a value + const updatedTable = await ctx.updateField({ + tableId: tableA.id, + fieldId: aLinkField.id, + field: { notNull: true }, + }); + expect(updatedTable.fields.find((f) => f.id === aLinkField.id)?.notNull).toBe(true); + + // Deleting the record must succeed even though its own link field is notNull; + // the symmetric cleanup happens on tableB, not on the deleted row. + await ctx.deleteRecord(tableA.id, recordA.id); + await ctx.testContainer.processOutbox(); + + const afterRecords = await ctx.listRecords(tableA.id); + expect(afterRecords.find((r) => r.id === recordA.id)).toBeUndefined(); + + if (symmetricFieldId) { + const bRecords = await ctx.listRecords(tableB.id); + const bStored = bRecords.find((r) => r.id === recordB.id); + expect(bStored?.fields[symmetricFieldId] ?? undefined).toBeUndefined(); + } + }); + }); }); diff --git a/packages/v2/e2e/src/deleteRecords.e2e.spec.ts b/packages/v2/e2e/src/deleteRecords.e2e.spec.ts index d383e75682..f0b33eda00 100644 --- a/packages/v2/e2e/src/deleteRecords.e2e.spec.ts +++ b/packages/v2/e2e/src/deleteRecords.e2e.spec.ts @@ -71,4 +71,43 @@ describe('v2 http deleteRecords (e2e)', () => { expect(parsed.data.data.deletedRecordIds).toEqual([r1.id, r2.id]); } }); + + /** + * v1 reference: record.e2e-spec.ts:316 — a deleted record must no longer be + * readable through getRecord (404 after delete). + */ + it('returns 404 from getRecord after the record is deleted', async () => { + const record = await ctx.createRecord(tableId, { [primaryFieldId]: 'to delete' }); + + const getRecordStatus = async (recordId: string) => { + const params = new URLSearchParams({ tableId, recordId }); + const response = await fetch(`${ctx.baseUrl}/tables/getRecord?${params.toString()}`, { + method: 'GET', + }); + await response.text(); + return response.status; + }; + + expect(await getRecordStatus(record.id)).toBe(200); + + await ctx.deleteRecords(tableId, [record.id]); + + expect(await getRecordStatus(record.id)).toBe(404); + }); + + /** + * v1 reference: record.e2e-spec.ts:367 — creating a record right after a + * delete must succeed (no stale row-order/index conflicts). + */ + it('creates a record after deleting a record', async () => { + const record = await ctx.createRecord(tableId, { [primaryFieldId]: 'delete then create' }); + await ctx.deleteRecords(tableId, [record.id]); + + const created = await ctx.createRecord(tableId, { [primaryFieldId]: 'created after delete' }); + expect(created.id).toMatch(/^rec/); + + const records = await ctx.listRecords(tableId, { limit: 1000 }); + expect(records.some((r) => r.id === record.id)).toBe(false); + expect(records.some((r) => r.id === created.id)).toBe(true); + }); }); diff --git a/packages/v2/e2e/src/deleteTable.e2e.spec.ts b/packages/v2/e2e/src/deleteTable.e2e.spec.ts index 49421169cb..f4ae663950 100644 --- a/packages/v2/e2e/src/deleteTable.e2e.spec.ts +++ b/packages/v2/e2e/src/deleteTable.e2e.spec.ts @@ -697,6 +697,131 @@ describe('v2 http deleteTable (e2e)', () => { } }); + it('[V1 PARITY][trash.e2e-spec.ts] soft delete and restore of a same-base linked foreign table keeps incoming link and lookup fields working', async () => { + let foreignTableId: string | undefined; + let hostTableId: string | undefined; + + try { + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: nextName('Restore Same Base Foreign'), + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', name: 'Value' }, + ], + records: [{ fields: { Name: 'Foreign-A', Value: 'alpha' } }], + }); + foreignTableId = foreignTable.id; + + const foreignPrimaryFieldId = foreignTable.fields.find((field) => field.isPrimary)?.id; + const foreignValueFieldId = foreignTable.fields.find((field) => field.name === 'Value')?.id; + if (!foreignPrimaryFieldId || !foreignValueFieldId) { + throw new Error('Missing same-base foreign field ids'); + } + + const hostTable = await ctx.createTable({ + baseId: ctx.baseId, + name: nextName('Restore Same Base Host'), + fields: [{ type: 'singleLineText', name: 'Host Name', isPrimary: true }], + }); + hostTableId = hostTable.id; + + const hostPrimaryFieldId = hostTable.fields.find((field) => field.isPrimary)?.id; + if (!hostPrimaryFieldId) { + throw new Error('Missing same-base host primary field'); + } + + const tableWithLink = await ctx.createField({ + baseId: ctx.baseId, + tableId: hostTable.id, + field: { + type: 'link', + name: 'Same Base Link', + options: { + relationship: 'manyOne', + foreignTableId: foreignTable.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + const linkFieldId = tableWithLink.fields.find((field) => field.name === 'Same Base Link')?.id; + if (!linkFieldId) { + throw new Error('Missing same-base link field'); + } + + const tableWithLookup = await ctx.createField({ + baseId: ctx.baseId, + tableId: hostTable.id, + field: { + type: 'lookup', + name: 'Same Base Lookup', + options: { + linkFieldId, + foreignTableId: foreignTable.id, + lookupFieldId: foreignValueFieldId, + }, + }, + }); + const lookupFieldId = tableWithLookup.fields.find( + (field) => field.name === 'Same Base Lookup' + )?.id; + if (!lookupFieldId) { + throw new Error('Missing same-base lookup field'); + } + + const foreignRecord = (await ctx.listRecords(foreignTable.id)).at(0); + if (!foreignRecord) { + throw new Error('Missing same-base foreign record'); + } + + const hostRecord = await ctx.createRecord(hostTable.id, { + [hostPrimaryFieldId]: 'Host Same Base', + }); + await ctx.updateRecord(hostTable.id, hostRecord.id, { + [linkFieldId]: { id: foreignRecord.id }, + }); + await ctx.testContainer.processOutbox(); + + // v1 parity: deleting a linked foreign table surfaces it in base trash; + // restoring the trash item brings the table back. v2 reaches the same + // semantics via the delete + restore endpoints. + await ctx.deleteTable(foreignTable.id, { mode: 'soft' }); + await expect(ctx.getTableById(foreignTable.id)).rejects.toThrow(); + + const restored = await ctx.restoreTable(foreignTable.id); + expect(restored.id).toBe(foreignTable.id); + await ctx.testContainer.processOutbox(); + + // Foreign table comes back with its fields and records intact. + const restoredForeign = await ctx.getTableById(foreignTable.id); + expect(restoredForeign.fields.map((field) => field.id)).toEqual( + expect.arrayContaining([foreignPrimaryFieldId, foreignValueFieldId]) + ); + const restoredForeignRecords = await ctx.listRecords(foreignTable.id); + expect(restoredForeignRecords).toHaveLength(1); + expect(restoredForeignRecords[0]?.fields[foreignPrimaryFieldId]).toBe('Foreign-A'); + + // Incoming link and lookup fields on the host keep working after restore. + const restoredHost = await ctx.getTableById(hostTable.id); + expect(restoredHost.fields.find((field) => field.id === linkFieldId)?.type).toBe('link'); + const restoredLookupField = restoredHost.fields.find((field) => field.id === lookupFieldId); + expect(restoredLookupField?.isLookup).toBe(true); + expect(restoredLookupField?.hasError).toBeFalsy(); + + const restoredHostRecords = await ctx.listRecords(hostTable.id); + const restoredHostRecord = restoredHostRecords.find((record) => record.id === hostRecord.id); + expect(restoredHostRecord?.fields[linkFieldId]).toMatchObject({ + id: foreignRecord.id, + }); + expect(restoredHostRecord?.fields[lookupFieldId]).toEqual(['alpha']); + } finally { + await ctx.drainOutbox().catch(() => undefined); + await safeDeleteTable(hostTableId); + await safeDeleteTable(foreignTableId); + } + }); + it('clears formula over lookup values when deleting a foreign table', async () => { let foreignTableId: string | undefined; let hostTableId: string | undefined; diff --git a/packages/v2/e2e/src/duplicateField.e2e.spec.ts b/packages/v2/e2e/src/duplicateField.e2e.spec.ts index d3568c19cd..c37724c1a0 100644 --- a/packages/v2/e2e/src/duplicateField.e2e.spec.ts +++ b/packages/v2/e2e/src/duplicateField.e2e.spec.ts @@ -1,275 +1,11 @@ import { duplicateFieldOkResponseSchema } from '@teable/v2-contract-http'; import { beforeAll, describe, expect, it } from 'vitest'; import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; - -type DuplicateFieldCase = { - label: string; - fieldType: string; - fieldConfig?: Record; - recordValue: unknown; - expectedCopiedValue: unknown; - expectedNoCopyValue: unknown; - setupNotes: string; -}; - -type DuplicateFieldErrorCase = { - label: string; - fieldType: string; - fieldConfig?: Record; - recordValue: unknown; - expectedError: string; - setupNotes: string; -}; - -const duplicateFieldCases: DuplicateFieldCase[] = [ - { - label: 'singleLineText', - fieldType: 'singleLineText', - fieldConfig: {}, - recordValue: 'Hello', - expectedCopiedValue: 'Hello', - expectedNoCopyValue: undefined, - setupNotes: - 'Create table with primary Title + singleLineText field; set record value to "Hello".', - }, - { - label: 'longText', - fieldType: 'longText', - fieldConfig: {}, - recordValue: 'Long text value', - expectedCopiedValue: 'Long text value', - expectedNoCopyValue: undefined, - setupNotes: 'Create table with primary Title + longText field; set record value.', - }, - { - label: 'number', - fieldType: 'number', - fieldConfig: {}, - recordValue: 123.45, - expectedCopiedValue: 123.45, - expectedNoCopyValue: undefined, - setupNotes: 'Create table with primary Title + number field; set record value.', - }, - { - label: 'rating', - fieldType: 'rating', - fieldConfig: {}, - recordValue: 4, - expectedCopiedValue: 4, - expectedNoCopyValue: undefined, - setupNotes: 'Create table with primary Title + rating field; set record value.', - }, - { - label: 'checkbox', - fieldType: 'checkbox', - fieldConfig: {}, - recordValue: true, - expectedCopiedValue: true, - expectedNoCopyValue: undefined, - setupNotes: 'Create table with primary Title + checkbox field; set record value.', - }, - { - label: 'date', - fieldType: 'date', - fieldConfig: {}, - recordValue: '2024-01-02', - expectedCopiedValue: '2024-01-02', - expectedNoCopyValue: undefined, - setupNotes: 'Create table with primary Title + date field; set ISO date string.', - }, - { - label: 'singleSelect', - fieldType: 'singleSelect', - fieldConfig: { - options: [{ name: 'A', color: 'blue' }], - defaultValue: { name: 'A', color: 'blue' }, - }, - recordValue: 'A', - expectedCopiedValue: 'A', - expectedNoCopyValue: undefined, - setupNotes: 'Create table with primary Title + singleSelect field; select option A.', - }, - { - label: 'multipleSelect', - fieldType: 'multipleSelect', - fieldConfig: { - options: [ - { name: 'A', color: 'blue' }, - { name: 'B', color: 'green' }, - ], - }, - recordValue: ['A', 'B'], - expectedCopiedValue: ['A', 'B'], - expectedNoCopyValue: [], - setupNotes: 'Create table with primary Title + multipleSelect field; select A+B.', - }, - { - label: 'user', - fieldType: 'user', - fieldConfig: {}, - recordValue: [{ id: 'usrTestUserId' }], - expectedCopiedValue: [{ id: 'usrTestUserId' }], - expectedNoCopyValue: [], - setupNotes: 'Create table with primary Title + user field; set to ctx.testUser.', - }, - { - label: 'attachment', - fieldType: 'attachment', - fieldConfig: {}, - recordValue: [{ name: 'file.txt', url: 'https://example.com/file.txt' }], - expectedCopiedValue: [{ name: 'file.txt', url: 'https://example.com/file.txt' }], - expectedNoCopyValue: [], - setupNotes: 'Create table with primary Title + attachment field; use fake attachment value.', - }, - { - label: 'formula', - fieldType: 'formula', - fieldConfig: { expression: '{Number} + 1' }, - recordValue: 2, - expectedCopiedValue: 2, - expectedNoCopyValue: 2, - setupNotes: - 'Create table with number field + formula; create record with number=1; formula should read 2 on both source and duplicated field.', - }, - { - label: 'rollup', - fieldType: 'rollup', - fieldConfig: { expression: 'SUM(values)' }, - recordValue: 10, - expectedCopiedValue: 10, - expectedNoCopyValue: 10, - setupNotes: - 'Create link + rollup; create linked records; ensure rollup value is computed; duplicated field should compute same.', - }, - { - label: 'conditionalRollup', - fieldType: 'conditionalRollup', - fieldConfig: { expression: 'SUM(values)' }, - recordValue: 10, - expectedCopiedValue: 10, - expectedNoCopyValue: 10, - setupNotes: - 'Create conditionalRollup based on condition; computed value should match on duplicated field.', - }, - { - label: 'conditionalLookup', - fieldType: 'conditionalLookup', - fieldConfig: {}, - recordValue: 'Foo', - expectedCopiedValue: 'Foo', - expectedNoCopyValue: 'Foo', - setupNotes: - 'Create conditionalLookup over foreign table; computed value should match on duplicated field.', - }, - { - label: 'createdTime', - fieldType: 'createdTime', - fieldConfig: {}, - recordValue: '<>', - expectedCopiedValue: '<>', - expectedNoCopyValue: '<>', - setupNotes: - 'Create record and capture createdTime; duplicated field should read the same createdTime.', - }, - { - label: 'lastModifiedTime', - fieldType: 'lastModifiedTime', - fieldConfig: {}, - recordValue: '<>', - expectedCopiedValue: '<>', - expectedNoCopyValue: '<>', - setupNotes: 'Update record to set lastModifiedTime; duplicated field should read same value.', - }, - { - label: 'createdBy', - fieldType: 'createdBy', - fieldConfig: {}, - recordValue: { id: 'usrTestUserId' }, - expectedCopiedValue: { id: 'usrTestUserId' }, - expectedNoCopyValue: { id: 'usrTestUserId' }, - setupNotes: 'Create record as ctx.testUser; duplicated field should show same user.', - }, - { - label: 'lastModifiedBy', - fieldType: 'lastModifiedBy', - fieldConfig: {}, - recordValue: { id: 'usrTestUserId' }, - expectedCopiedValue: { id: 'usrTestUserId' }, - expectedNoCopyValue: { id: 'usrTestUserId' }, - setupNotes: 'Update record as ctx.testUser; duplicated field should show same user.', - }, - { - label: 'autoNumber', - fieldType: 'autoNumber', - fieldConfig: {}, - recordValue: 1, - expectedCopiedValue: 1, - expectedNoCopyValue: 1, - setupNotes: - 'Create record to get autoNumber; duplicated field should show same value for that record.', - }, - { - label: 'button', - fieldType: 'button', - fieldConfig: { label: 'Click', color: 'teal' }, - recordValue: null, - expectedCopiedValue: null, - expectedNoCopyValue: null, - setupNotes: 'Button field may be non-storable; verify duplicated field remains null.', - }, - { - label: 'link manyMany', - fieldType: 'link', - fieldConfig: { relationship: 'manyMany' }, - recordValue: [{ id: '<>' }], - expectedCopiedValue: [{ id: '<>' }], - expectedNoCopyValue: [], - setupNotes: - 'Create foreign table + records; set link values; duplicated field should reference same linked records.', - }, - { - label: 'link oneMany(one-way)', - fieldType: 'link', - fieldConfig: { relationship: 'oneMany', isOneWay: true }, - recordValue: [{ id: '<>' }], - expectedCopiedValue: [{ id: '<>' }], - expectedNoCopyValue: [], - setupNotes: - 'Create foreign table + records; set link values; duplicated field should reference same linked records.', - }, - { - label: 'link manyOne', - fieldType: 'link', - fieldConfig: { relationship: 'manyOne' }, - recordValue: { id: '<>' }, - expectedCopiedValue: { id: '<>' }, - expectedNoCopyValue: null, - setupNotes: - 'Create foreign table + records; set link value; duplicated field should reference same linked record.', - }, - { - label: 'link oneOne', - fieldType: 'link', - fieldConfig: { relationship: 'oneOne' }, - recordValue: { id: '<>' }, - expectedCopiedValue: { id: '<>' }, - expectedNoCopyValue: null, - setupNotes: - 'Create foreign table + records; set link value; duplicated field should reference same linked record.', - }, -]; - -const duplicateFieldErrorCases: DuplicateFieldErrorCase[] = [ - { - label: 'lookup', - fieldType: 'lookup', - fieldConfig: {}, - recordValue: '<>', - expectedError: 'field.lookup_cannot_duplicate', - setupNotes: - 'Create lookup field (link+lookup); duplicate should fail with lookup cannot duplicate error.', - }, -]; +import { + ensureAttachmentTables, + makeAttachmentCell, + seedAttachment, +} from './update-field/attachment/testUtils'; describe('duplicateField', () => { let ctx: SharedTestContext; @@ -278,6 +14,29 @@ describe('duplicateField', () => { ctx = await getSharedTestContext(); }); + const duplicateField = async (payload: { + tableId: string; + fieldId: string; + includeRecordValues: boolean; + newFieldName: string; + }): Promise => { + const response = await fetch(`${ctx.baseUrl}/tables/duplicateField`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ baseId: ctx.baseId, ...payload }), + }); + const raw = await response.json(); + if (response.status !== 200) { + throw new Error(`duplicateField failed for ${payload.fieldId}: ${JSON.stringify(raw)}`); + } + const parsed = duplicateFieldOkResponseSchema.safeParse(raw); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) { + throw new Error(`duplicateField response invalid: ${JSON.stringify(raw)}`); + } + return parsed.data.data.newFieldId; + }; + it('respects viewId and updates duplicated field order in target view meta', async () => { const table = await ctx.createTable({ baseId: ctx.baseId, @@ -897,7 +656,7 @@ describe('duplicateField', () => { lookupFieldId: scoreLookupFieldId, targetField: { type: 'rating' }, copiedValue: [4.7], - expectedValue: 4, + expectedValue: 5, }); await duplicateAndConvert({ lookupFieldId: doneLookupFieldId, @@ -933,15 +692,601 @@ describe('duplicateField', () => { } }); - describe.each(duplicateFieldCases)('duplicate field with values: $label', (caseInfo) => { - it.todo(`includeRecordValues=true should copy values; setup: ${caseInfo.setupNotes}`); + // v1 reference: field-duplicate.e2e-spec.ts + // - "duplicate all common fields" + // - "should duplicate text/number/checkbox fields and preserve all cell values" + it('[V1 PARITY] copies cell values and options for common field types when includeRecordValues=true', async () => { + let tableId: string | undefined; + + try { + await ensureAttachmentTables(ctx); + const seededAttachment = await seedAttachment(ctx); + const attachmentCell = makeAttachmentCell(seededAttachment, 'dup-field.txt'); + + const numberFieldId = `fld${'dupnum'.padEnd(16, '0')}`; + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `DupCommonFields-${Date.now()}`, + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', name: 'Text' }, + { type: 'longText', name: 'Long' }, + { type: 'number', id: numberFieldId, name: 'Num' }, + { + type: 'rating', + name: 'Rate', + options: { max: 5, icon: 'star', color: 'yellowBright' }, + }, + { type: 'checkbox', name: 'Check' }, + { type: 'date', name: 'Due' }, + { + type: 'singleSelect', + name: 'Status', + options: { + choices: [ + { id: 'choDupA', name: 'A', color: 'blue' }, + { id: 'choDupB', name: 'B', color: 'green' }, + ], + defaultValue: 'A', + }, + }, + { + type: 'multipleSelect', + name: 'Tags', + options: { + choices: [ + { id: 'choDupX', name: 'X', color: 'purple' }, + { id: 'choDupY', name: 'Y', color: 'orange' }, + ], + }, + }, + { type: 'user', name: 'Owner', options: { isMultiple: false, shouldNotify: false } }, + { type: 'attachment', name: 'Files' }, + { type: 'button', name: 'Action', options: { label: 'Click', color: 'teal' } }, + { type: 'formula', name: 'Score', options: { expression: `{${numberFieldId}} + 1` } }, + { type: 'autoNumber', name: 'Auto' }, + { type: 'createdTime', name: 'CTime' }, + { type: 'lastModifiedTime', name: 'MTime' }, + { type: 'createdBy', name: 'CBy' }, + { type: 'lastModifiedBy', name: 'MBy' }, + ], + }); + tableId = table.id; + + const fieldIdByName = new Map(table.fields.map((field) => [field.name, field.id])); + const requireFieldId = (name: string): string => { + const id = fieldIdByName.get(name); + if (!id) throw new Error(`Missing field ${name}`); + return id; + }; + + const filledRecord = await ctx.createRecord(table.id, { + [requireFieldId('Name')]: 'Row 1', + [requireFieldId('Text')]: 'Hello', + [requireFieldId('Long')]: 'Long text value', + [requireFieldId('Num')]: 123.45, + [requireFieldId('Rate')]: 4, + [requireFieldId('Check')]: true, + [requireFieldId('Due')]: '2024-01-02T00:00:00.000Z', + [requireFieldId('Status')]: 'A', + [requireFieldId('Tags')]: ['X', 'Y'], + [requireFieldId('Owner')]: { id: ctx.testUser.id, title: ctx.testUser.name }, + [requireFieldId('Files')]: attachmentCell, + }); + // T6520: unchecked checkbox is stored as null; the duplicated field must + // preserve that (no false backfill on the copied column). + const emptyRecord = await ctx.createRecord(table.id, { + [requireFieldId('Name')]: 'Row 2', + }); + + await ctx.drainOutbox(); + + const duplicatedNames = [ + 'Text', + 'Long', + 'Num', + 'Rate', + 'Check', + 'Due', + 'Status', + 'Tags', + 'Owner', + 'Files', + 'Action', + 'Score', + 'Auto', + 'CTime', + 'MTime', + 'CBy', + 'MBy', + ]; + + const duplicatedIdByName = new Map(); + for (const name of duplicatedNames) { + const newFieldId = await duplicateField({ + tableId: table.id, + fieldId: requireFieldId(name), + includeRecordValues: true, + newFieldName: `${name} copy`, + }); + duplicatedIdByName.set(name, newFieldId); + } + + await ctx.drainOutbox(); + + const latestTable = await ctx.getTableById(table.id); + for (const name of duplicatedNames) { + const sourceField = latestTable.fields.find((field) => field.id === requireFieldId(name)); + const duplicatedField = latestTable.fields.find( + (field) => field.id === duplicatedIdByName.get(name) + ); + expect(duplicatedField, `duplicated field ${name}`).toBeTruthy(); + expect(duplicatedField?.type).toBe(sourceField?.type); + // v1 parity: options are preserved verbatim (select choices, button + // label/color, formula expression, default values, ...). + expect(duplicatedField?.options, `options of ${name}`).toEqual(sourceField?.options); + } + + const records = await ctx.listRecordsWithoutDrain(table.id); + const filled = records.find((record) => record.id === filledRecord.id); + const empty = records.find((record) => record.id === emptyRecord.id); + expect(filled).toBeTruthy(); + expect(empty).toBeTruthy(); + if (!filled || !empty) return; + + for (const name of duplicatedNames) { + const sourceId = requireFieldId(name); + const duplicatedId = duplicatedIdByName.get(name); + if (!duplicatedId) throw new Error(`Missing duplicated field id for ${name}`); + expect(filled.fields[duplicatedId] ?? null, `copied value of ${name}`).toEqual( + filled.fields[sourceId] ?? null + ); + expect(empty.fields[duplicatedId] ?? null, `copied empty value of ${name}`).toEqual( + empty.fields[sourceId] ?? null + ); + } + + // T6520: unchecked checkbox must stay empty (null) on the copy. + const checkCopyId = duplicatedIdByName.get('Check'); + expect(checkCopyId).toBeTruthy(); + if (checkCopyId) { + expect(filled.fields[checkCopyId]).toBe(true); + expect(empty.fields[checkCopyId] ?? null).toBeNull(); + } + } finally { + if (tableId) { + await ctx.deleteTable(tableId).catch(() => undefined); + } + } }); - describe.each(duplicateFieldCases)('duplicate field without values: $label', (caseInfo) => { - it.todo(`includeRecordValues=false should not copy values; setup: ${caseInfo.setupNotes}`); + // v1 reference: field-duplicate.e2e-spec.ts "duplicate field" without copying + // record values (duplicate options only). + it('[V1 PARITY] does not copy stored values when includeRecordValues=false but recomputes computed fields', async () => { + let tableId: string | undefined; + + try { + const numberFieldId = `fld${'dupncv'.padEnd(16, '0')}`; + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `DupNoCopy-${Date.now()}`, + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', name: 'Text' }, + { type: 'number', id: numberFieldId, name: 'Num' }, + { type: 'checkbox', name: 'Check' }, + { + type: 'singleSelect', + name: 'Status', + options: { choices: [{ id: 'choNoCopyA', name: 'A', color: 'blue' }] }, + }, + { + type: 'multipleSelect', + name: 'Tags', + options: { choices: [{ id: 'choNoCopyX', name: 'X', color: 'purple' }] }, + }, + { type: 'formula', name: 'Score', options: { expression: `{${numberFieldId}} + 1` } }, + { type: 'autoNumber', name: 'Auto' }, + ], + }); + tableId = table.id; + + const fieldIdByName = new Map(table.fields.map((field) => [field.name, field.id])); + const requireFieldId = (name: string): string => { + const id = fieldIdByName.get(name); + if (!id) throw new Error(`Missing field ${name}`); + return id; + }; + + const record = await ctx.createRecord(table.id, { + [requireFieldId('Name')]: 'Row 1', + [requireFieldId('Text')]: 'Hello', + [requireFieldId('Num')]: 1, + [requireFieldId('Check')]: true, + [requireFieldId('Status')]: 'A', + [requireFieldId('Tags')]: ['X'], + }); + + await ctx.drainOutbox(); + + const staticNames = ['Text', 'Num', 'Check', 'Status', 'Tags']; + const computedNames = ['Score', 'Auto']; + const duplicatedIdByName = new Map(); + for (const name of [...staticNames, ...computedNames]) { + const newFieldId = await duplicateField({ + tableId: table.id, + fieldId: requireFieldId(name), + includeRecordValues: false, + newFieldName: `${name} nocopy`, + }); + duplicatedIdByName.set(name, newFieldId); + } + + await ctx.drainOutbox(); + + const records = await ctx.listRecordsWithoutDrain(table.id); + const row = records.find((entry) => entry.id === record.id); + expect(row).toBeTruthy(); + if (!row) return; + + for (const name of staticNames) { + const duplicatedId = duplicatedIdByName.get(name); + if (!duplicatedId) throw new Error(`Missing duplicated field id for ${name}`); + expect(row.fields[duplicatedId] ?? null, `no-copy value of ${name}`).toBeNull(); + } + + // Computed fields recompute from scratch even without copied values. + for (const name of computedNames) { + const duplicatedId = duplicatedIdByName.get(name); + if (!duplicatedId) throw new Error(`Missing duplicated field id for ${name}`); + expect(row.fields[duplicatedId] ?? null, `computed value of ${name}`).toEqual( + row.fields[requireFieldId(name)] ?? null + ); + } + } finally { + if (tableId) { + await ctx.deleteTable(tableId).catch(() => undefined); + } + } + }); + + // v1 reference: field-duplicate.e2e-spec.ts + // - "duplicate link fields" + // - "should duplicate link field and preserve all cell values" + it('[V1 PARITY] duplicates link fields as one-way copies preserving linked record values', async () => { + let hostTableId: string | undefined; + let foreignTableId: string | undefined; + + try { + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: `DupLinkValuesForeign-${Date.now()}`, + fields: [{ type: 'singleLineText', name: 'Title', isPrimary: true }], + }); + foreignTableId = foreignTable.id; + const foreignPrimaryFieldId = foreignTable.fields.find((field) => field.isPrimary)?.id; + expect(foreignPrimaryFieldId).toBeTruthy(); + if (!foreignPrimaryFieldId) return; + + const foreignRecord1 = await ctx.createRecord(foreignTable.id, { + [foreignPrimaryFieldId]: 'Foreign 1', + }); + const foreignRecord2 = await ctx.createRecord(foreignTable.id, { + [foreignPrimaryFieldId]: 'Foreign 2', + }); + + const hostTable = await ctx.createTable({ + baseId: ctx.baseId, + name: `DupLinkValuesHost-${Date.now()}`, + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + }); + hostTableId = hostTable.id; + const hostPrimaryFieldId = hostTable.fields.find((field) => field.isPrimary)?.id; + expect(hostPrimaryFieldId).toBeTruthy(); + if (!hostPrimaryFieldId) return; + + const linkCases = [ + { name: 'Link MM', relationship: 'manyMany', isOneWay: false, multi: true }, + { name: 'Link MO', relationship: 'manyOne', isOneWay: false, multi: false }, + { name: 'Link OM', relationship: 'oneMany', isOneWay: true, multi: true }, + { name: 'Link OO', relationship: 'oneOne', isOneWay: false, multi: false }, + ] as const; + + const linkFieldIdByName = new Map(); + for (const linkCase of linkCases) { + const updatedTable = await ctx.createField({ + baseId: ctx.baseId, + tableId: hostTable.id, + field: { + type: 'link', + name: linkCase.name, + options: { + relationship: linkCase.relationship, + foreignTableId: foreignTable.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: linkCase.isOneWay, + }, + }, + }); + const created = updatedTable.fields.find((field) => field.name === linkCase.name); + if (!created) throw new Error(`Missing link field ${linkCase.name}`); + linkFieldIdByName.set(linkCase.name, created.id); + } + + const hostRecord = await ctx.createRecord(hostTable.id, { + [hostPrimaryFieldId]: 'Host 1', + [linkFieldIdByName.get('Link MM')!]: [{ id: foreignRecord1.id }, { id: foreignRecord2.id }], + [linkFieldIdByName.get('Link MO')!]: { id: foreignRecord1.id }, + [linkFieldIdByName.get('Link OM')!]: [{ id: foreignRecord1.id }], + [linkFieldIdByName.get('Link OO')!]: { id: foreignRecord2.id }, + }); + + await ctx.drainOutbox(); + + const foreignFieldCountBefore = (await ctx.getTableById(foreignTable.id)).fields.length; + + const duplicatedIdByName = new Map(); + for (const linkCase of linkCases) { + const newFieldId = await duplicateField({ + tableId: hostTable.id, + fieldId: linkFieldIdByName.get(linkCase.name)!, + includeRecordValues: true, + newFieldName: `${linkCase.name} copy`, + }); + duplicatedIdByName.set(linkCase.name, newFieldId); + } + + await ctx.drainOutbox(); + + const latestHostTable = await ctx.getTableById(hostTable.id); + for (const linkCase of linkCases) { + const sourceField = latestHostTable.fields.find( + (field) => field.id === linkFieldIdByName.get(linkCase.name) + ); + const duplicatedField = latestHostTable.fields.find( + (field) => field.id === duplicatedIdByName.get(linkCase.name) + ); + expect(duplicatedField?.type, `type of ${linkCase.name}`).toBe('link'); + const sourceOptions = sourceField?.options as { + foreignTableId?: string; + relationship?: string; + }; + const duplicatedOptions = duplicatedField?.options as { + foreignTableId?: string; + relationship?: string; + isOneWay?: boolean; + symmetricFieldId?: string; + }; + expect(duplicatedOptions?.foreignTableId).toBe(sourceOptions?.foreignTableId); + expect(duplicatedOptions?.relationship).toBe(sourceOptions?.relationship); + // v1 parity: a duplicated link field is always created as one-way, no + // extra symmetric field appears in the foreign table. + expect(duplicatedOptions?.isOneWay, `isOneWay of ${linkCase.name} copy`).toBe(true); + expect(duplicatedOptions?.symmetricFieldId).toBeUndefined(); + } + + const foreignFieldCountAfter = (await ctx.getTableById(foreignTable.id)).fields.length; + expect(foreignFieldCountAfter).toBe(foreignFieldCountBefore); + + const records = await ctx.listRecordsWithoutDrain(hostTable.id); + const row = records.find((entry) => entry.id === hostRecord.id); + expect(row).toBeTruthy(); + if (!row) return; + + const linkedIds = (value: unknown): string[] => { + if (value == null) return []; + const entries = Array.isArray(value) ? value : [value]; + return entries + .map((entry) => (entry as { id?: string }).id) + .filter((id): id is string => typeof id === 'string') + .sort(); + }; + + for (const linkCase of linkCases) { + const sourceValue = row.fields[linkFieldIdByName.get(linkCase.name)!]; + const duplicatedValue = row.fields[duplicatedIdByName.get(linkCase.name)!]; + expect(linkedIds(duplicatedValue), `copied linked ids of ${linkCase.name}`).toEqual( + linkedIds(sourceValue) + ); + } + + // includeRecordValues=false keeps the copy empty. + const noCopyFieldId = await duplicateField({ + tableId: hostTable.id, + fieldId: linkFieldIdByName.get('Link MM')!, + includeRecordValues: false, + newFieldName: 'Link MM nocopy', + }); + await ctx.drainOutbox(); + const recordsAfterNoCopy = await ctx.listRecordsWithoutDrain(hostTable.id); + const rowAfterNoCopy = recordsAfterNoCopy.find((entry) => entry.id === hostRecord.id); + expect(linkedIds(rowAfterNoCopy?.fields[noCopyFieldId])).toEqual([]); + } finally { + if (hostTableId) { + await ctx.deleteTable(hostTableId).catch(() => undefined); + } + if (foreignTableId) { + await ctx.deleteTable(foreignTableId).catch(() => undefined); + } + } }); - describe.each(duplicateFieldErrorCases)('duplicate field error: $label', (caseInfo) => { - it.todo(`should fail with ${caseInfo.expectedError}; setup: ${caseInfo.setupNotes}`); + // v1 reference: field-duplicate.e2e-spec.ts + // - "duplicate rollup fields" + // - "duplicate lookup fields" + it('[V1 PARITY] duplicates rollup, conditional rollup and conditional lookup preserving computed values', async () => { + let hostTableId: string | undefined; + let foreignTableId: string | undefined; + + try { + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: `DupRollupForeign-${Date.now()}`, + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { type: 'singleLineText', name: 'Status' }, + { type: 'number', name: 'Amount' }, + ], + }); + foreignTableId = foreignTable.id; + const foreignPrimaryFieldId = foreignTable.fields.find((field) => field.isPrimary)?.id; + const foreignStatusFieldId = foreignTable.fields.find((field) => field.name === 'Status')?.id; + const foreignAmountFieldId = foreignTable.fields.find((field) => field.name === 'Amount')?.id; + expect(foreignPrimaryFieldId).toBeTruthy(); + expect(foreignStatusFieldId).toBeTruthy(); + expect(foreignAmountFieldId).toBeTruthy(); + if (!foreignPrimaryFieldId || !foreignStatusFieldId || !foreignAmountFieldId) return; + + const foreignRecord1 = await ctx.createRecord(foreignTable.id, { + [foreignPrimaryFieldId]: 'Active row', + [foreignStatusFieldId]: 'Active', + [foreignAmountFieldId]: 10, + }); + const foreignRecord2 = await ctx.createRecord(foreignTable.id, { + [foreignPrimaryFieldId]: 'Inactive row', + [foreignStatusFieldId]: 'Inactive', + [foreignAmountFieldId]: 20, + }); + + const hostTable = await ctx.createTable({ + baseId: ctx.baseId, + name: `DupRollupHost-${Date.now()}`, + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + }); + hostTableId = hostTable.id; + const hostPrimaryFieldId = hostTable.fields.find((field) => field.isPrimary)?.id; + expect(hostPrimaryFieldId).toBeTruthy(); + if (!hostPrimaryFieldId) return; + + const withLink = await ctx.createField({ + baseId: ctx.baseId, + tableId: hostTable.id, + field: { + type: 'link', + name: 'Foreign', + options: { + relationship: 'manyMany', + foreignTableId: foreignTable.id, + lookupFieldId: foreignPrimaryFieldId, + }, + }, + }); + const linkFieldId = withLink.fields.find((field) => field.name === 'Foreign')?.id; + expect(linkFieldId).toBeTruthy(); + if (!linkFieldId) return; + + const condition = { + filter: { + conjunction: 'and' as const, + filterSet: [{ fieldId: foreignStatusFieldId, operator: 'is', value: 'Active' }], + }, + }; + + const createAndGetId = async ( + field: Parameters[0]['field'], + name: string + ) => { + const updated = await ctx.createField({ + baseId: ctx.baseId, + tableId: hostTable.id, + field, + }); + const created = updated.fields.find((f) => f.name === name); + if (!created) throw new Error(`Missing created field: ${name}`); + return created.id; + }; + + const rollupFieldId = await createAndGetId( + { + type: 'rollup', + name: 'Amount Sum', + options: { expression: 'sum({values})' }, + config: { + linkFieldId, + foreignTableId: foreignTable.id, + lookupFieldId: foreignAmountFieldId, + }, + }, + 'Amount Sum' + ); + const conditionalRollupFieldId = await createAndGetId( + { + type: 'conditionalRollup', + name: 'Active Amount Sum', + options: { expression: 'sum({values})' }, + config: { + foreignTableId: foreignTable.id, + lookupFieldId: foreignAmountFieldId, + condition, + }, + }, + 'Active Amount Sum' + ); + const conditionalLookupFieldId = await createAndGetId( + { + type: 'conditionalLookup', + name: 'Active Titles', + options: { + foreignTableId: foreignTable.id, + lookupFieldId: foreignPrimaryFieldId, + condition, + }, + }, + 'Active Titles' + ); + + const hostRecord = await ctx.createRecord(hostTable.id, { + [hostPrimaryFieldId]: 'Host 1', + [linkFieldId]: [{ id: foreignRecord1.id }, { id: foreignRecord2.id }], + }); + + await ctx.drainOutbox(); + + const computedFieldIds = [rollupFieldId, conditionalRollupFieldId, conditionalLookupFieldId]; + const duplicatedIds = new Map(); + for (const fieldId of computedFieldIds) { + const newFieldId = await duplicateField({ + tableId: hostTable.id, + fieldId, + includeRecordValues: true, + newFieldName: `copy-${fieldId}`, + }); + duplicatedIds.set(fieldId, newFieldId); + } + + await ctx.drainOutbox(); + + const latestTable = await ctx.getTableById(hostTable.id); + for (const fieldId of computedFieldIds) { + const sourceField = latestTable.fields.find((field) => field.id === fieldId); + const duplicatedField = latestTable.fields.find( + (field) => field.id === duplicatedIds.get(fieldId) + ); + expect(duplicatedField, `duplicated computed field ${fieldId}`).toBeTruthy(); + expect(duplicatedField?.type).toBe(sourceField?.type); + expect(duplicatedField?.options).toEqual(sourceField?.options); + } + + const records = await ctx.listRecordsWithoutDrain(hostTable.id); + const row = records.find((entry) => entry.id === hostRecord.id); + expect(row).toBeTruthy(); + if (!row) return; + + expect(row.fields[rollupFieldId]).toBe(30); + expect(row.fields[duplicatedIds.get(rollupFieldId)!]).toBe(30); + expect(row.fields[conditionalRollupFieldId]).toBe(10); + expect(row.fields[duplicatedIds.get(conditionalRollupFieldId)!]).toBe(10); + expect(row.fields[conditionalLookupFieldId]).toEqual(row.fields[conditionalLookupFieldId]); + expect(row.fields[duplicatedIds.get(conditionalLookupFieldId)!]).toEqual( + row.fields[conditionalLookupFieldId] + ); + } finally { + if (hostTableId) { + await ctx.deleteTable(hostTableId).catch(() => undefined); + } + if (foreignTableId) { + await ctx.deleteTable(foreignTableId).catch(() => undefined); + } + } }); }); diff --git a/packages/v2/e2e/src/field-explain.e2e.spec.ts b/packages/v2/e2e/src/field-explain.e2e.spec.ts index c83c4f76b6..7bccea6ac8 100644 --- a/packages/v2/e2e/src/field-explain.e2e.spec.ts +++ b/packages/v2/e2e/src/field-explain.e2e.spec.ts @@ -777,7 +777,7 @@ describe('v2 field explain endpoints (e2e)', () => { "parameterCount": 0, "pgInputIsValidCount": 1, "regexpReplaceCount": 0, - "sqlLength": 3830, + "sqlLength": 3862, "stringAggCount": 1, }, "fields": [ @@ -792,7 +792,7 @@ describe('v2 field explain endpoints (e2e)', () => { "parameterCount": 1, "pgInputIsValidCount": 0, "regexpReplaceCount": 0, - "sqlLength": 2574, + "sqlLength": 2622, "stringAggCount": 2, }, "fields": [ @@ -807,7 +807,7 @@ describe('v2 field explain endpoints (e2e)', () => { "parameterCount": 1, "pgInputIsValidCount": 0, "regexpReplaceCount": 0, - "sqlLength": 2504, + "sqlLength": 2540, "stringAggCount": 0, }, "fields": [ @@ -822,7 +822,7 @@ describe('v2 field explain endpoints (e2e)', () => { "parameterCount": 1, "pgInputIsValidCount": 0, "regexpReplaceCount": 0, - "sqlLength": 1594, + "sqlLength": 1610, "stringAggCount": 1, }, "fields": [ @@ -836,7 +836,7 @@ describe('v2 field explain endpoints (e2e)', () => { "parameterCount": 1, "pgInputIsValidCount": 0, "regexpReplaceCount": 0, - "sqlLength": 1558, + "sqlLength": 1576, "stringAggCount": 0, }, "fields": [ @@ -845,7 +845,6 @@ describe('v2 field explain endpoints (e2e)', () => { }, ] `); - } finally { for (const tableIdToDelete of createdTableIds.reverse()) { await ctx.deleteTable(tableIdToDelete).catch(() => undefined); diff --git a/packages/v2/e2e/src/formula-metadata-coercion.e2e.spec.ts b/packages/v2/e2e/src/formula-metadata-coercion.e2e.spec.ts new file mode 100644 index 0000000000..a779466e14 --- /dev/null +++ b/packages/v2/e2e/src/formula-metadata-coercion.e2e.spec.ts @@ -0,0 +1,173 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * V2 Formula metadata-aware coercion E2E Tests + * + * Ported from v1 formula-metadata-coercion.e2e-spec.ts ("runtime formulas" group). + * + * The v1 spec has two halves: + * 1. SQL-generation assertions (generated columns / select-query conversion via + * dbProvider.convertFormulaToSelectQuery + information_schema inspection). + * These are v1 engine internals and are NOT portable to v2. + * 2. Runtime record-value assertions ("runtime formulas"), which are portable and + * covered here: + * - concatenates typed fields without redundant casts + * - evaluates AND conditions using typed operands + * - keeps BLANK as null in standalone formulas and IF branches across types + */ +import { describe, beforeAll, expect, it } from 'vitest'; +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +describe('v2 http formula metadata-aware coercion (e2e)', () => { + let ctx: SharedTestContext; + const uniqueName = (prefix: string) => + `${prefix} ${Date.now()}-${Math.random().toString(16).slice(2)}`; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + }, 30000); + + type CreateTableFields = Parameters[0]['fields']; + + const makeTable = async (name: string, fields: CreateTableFields) => + ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName(name), + fields, + views: [{ type: 'grid' }], + }); + + const makeFormula = async (tableId: string, name: string, expression: string) => { + const updatedTable = await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { + type: 'formula', + name, + options: { expression }, + }, + }); + const fieldId = updatedTable.fields.find((f) => f.name === name)?.id ?? ''; + expect(fieldId).not.toBe(''); + return fieldId; + }; + + const readFields = async (tableId: string, recordId: string) => { + const records = await ctx.listRecords(tableId); + const record = records.find((r) => r.id === recordId); + expect(record).toBeDefined(); + if (!record) throw new Error(`record ${recordId} not found`); + return record.fields; + }; + + describe('runtime formulas', () => { + it('concatenates typed fields without redundant casts', async () => { + const table = await makeTable('formula_metadata_concat', [ + { type: 'singleLineText', name: 'Label', isPrimary: true }, + { type: 'number', name: 'Qty' }, + ]); + const labelId = table.fields.find((f) => f.name === 'Label')?.id ?? ''; + const qtyId = table.fields.find((f) => f.name === 'Qty')?.id ?? ''; + + const concatId = await makeFormula( + table.id, + 'Label Qty', + `{${labelId}} & ' x ' & {${qtyId}} & '!'` + ); + + const record = await ctx.createRecord(table.id, { + [labelId]: 'Widget', + [qtyId]: 3, + }); + await ctx.drainOutbox(); + expect((await readFields(table.id, record.id))[concatId]).toBe('Widget x 3!'); + + await ctx.updateRecord(table.id, record.id, { [labelId]: 'Gadget', [qtyId]: 1 }); + await ctx.drainOutbox(); + expect((await readFields(table.id, record.id))[concatId]).toBe('Gadget x 1!'); + }); + + it('evaluates AND conditions using typed operands', async () => { + const table = await makeTable('formula_metadata_logic', [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { type: 'checkbox', name: 'Enabled' }, + { type: 'number', name: 'Attempts' }, + ]); + const enabledId = table.fields.find((f) => f.name === 'Enabled')?.id ?? ''; + const attemptsId = table.fields.find((f) => f.name === 'Attempts')?.id ?? ''; + + const logicId = await makeFormula( + table.id, + 'Should Trigger', + `IF(AND({${enabledId}}, {${attemptsId}}), 1, 0)` + ); + + const record = await ctx.createRecord(table.id, { + [enabledId]: true, + [attemptsId]: 0, + }); + await ctx.drainOutbox(); + expect((await readFields(table.id, record.id))[logicId]).toBe(0); + + await ctx.updateRecord(table.id, record.id, { [attemptsId]: 2 }); + await ctx.drainOutbox(); + expect((await readFields(table.id, record.id))[logicId]).toBe(1); + + // v1 stores unchecked (false) as null (T6520); AND(null, 2) is still falsy + await ctx.updateRecord(table.id, record.id, { [enabledId]: false }); + await ctx.drainOutbox(); + expect((await readFields(table.id, record.id))[logicId]).toBe(0); + }); + + it('keeps BLANK as null in standalone formulas and IF branches across types', async () => { + const dueDateValue = '2025-02-02T00:00:00.000Z'; + const table = await makeTable('formula_blank_runtime', [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { type: 'number', name: 'Amount' }, + { + type: 'date', + name: 'Due', + options: { + formatting: { date: 'YYYY-MM-DD', time: 'HH:mm', timeZone: 'utc' }, + }, + }, + ]); + const amountId = table.fields.find((f) => f.name === 'Amount')?.id ?? ''; + const dueId = table.fields.find((f) => f.name === 'Due')?.id ?? ''; + + const blankId = await makeFormula(table.id, 'Standalone Blank', 'BLANK()'); + const dateWhenTrueId = await makeFormula( + table.id, + 'Date When True', + `IF(TRUE, {${dueId}}, BLANK())` + ); + const dateWhenFalseId = await makeFormula( + table.id, + 'Blank When False', + `IF(FALSE, {${dueId}}, BLANK())` + ); + const numberWhenTrueId = await makeFormula( + table.id, + 'Number When True', + `IF(TRUE, {${amountId}}, BLANK())` + ); + const numberWhenFalseId = await makeFormula( + table.id, + 'Blank When False Number', + `IF(FALSE, {${amountId}}, BLANK())` + ); + + const record = await ctx.createRecord(table.id, { + [amountId]: 12, + [dueId]: dueDateValue, + }); + await ctx.drainOutbox(); + + const fields = await readFields(table.id, record.id); + expect(fields[blankId] ?? null).toBeNull(); + expect(fields[dateWhenTrueId]).toBe(dueDateValue); + expect(fields[dateWhenFalseId] ?? null).toBeNull(); + expect(fields[numberWhenTrueId]).toBe(12); + expect(fields[numberWhenFalseId] ?? null).toBeNull(); + }); + }); +}); diff --git a/packages/v2/e2e/src/formula.e2e.spec.ts b/packages/v2/e2e/src/formula.e2e.spec.ts index dcd13a7f8e..89643dc963 100644 --- a/packages/v2/e2e/src/formula.e2e.spec.ts +++ b/packages/v2/e2e/src/formula.e2e.spec.ts @@ -1330,7 +1330,9 @@ describe('v2 http formula (e2e)', () => { expect(assignedRecord).toBeDefined(); if (!unassignedRecord || !assignedRecord) return; - expect(unassignedRecord.fields[formulaFieldId]).toBe(null); + // Regression (T6520): records created without field values now compute + // referenced formulas, so the empty user branch materializes. + expect(unassignedRecord.fields[formulaFieldId]).toBe('unassigned'); expect(assignedRecord.fields[formulaFieldId]).toBe('assigned'); }); @@ -1930,11 +1932,11 @@ describe('v2 http formula (e2e)', () => { }); /** - * Scenario: boolean and number arithmetic + * Scenario: empty checkbox and number arithmetic * Formula:{checkboxField} + 1 - * Expect: true coerces to 1, false to 0 + * Expect: true remains true; false is normalized to null before evaluation */ - it('should coerce boolean to number - {checkboxField} + 1', async () => { + it('should treat an unchecked checkbox as empty - {checkboxField} + 1', async () => { const createTableResponse = await fetch(`${ctx.baseUrl}/tables/create`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -2028,7 +2030,8 @@ describe('v2 http formula (e2e)', () => { if (!trueRecordResult || !falseRecordResult) return; expect(trueRecordResult.fields[formulaFieldId]).toBe('true1'); - expect(falseRecordResult.fields[formulaFieldId]).toBe('false1'); + // v1 stores unchecked as null, and null + 1 evaluates to '1' (never 'false1') + expect(falseRecordResult.fields[formulaFieldId]).toBe('1'); }); /** @@ -17566,4 +17569,814 @@ describe('v2 http formula (e2e)', () => { expect(stored?.fields[formulaFieldId]).toEqual(['Alpha', 'Beta']); }); }); + + // ============================================================================ + // V1 parity ports (T6520): cases from v1 formula.e2e-spec.ts / + // formula-field.e2e-spec.ts that previously had no semantic v2 equivalent. + // ============================================================================ + describe('v1 parity ports (T6520)', () => { + type CreateTableFields = Parameters[0]['fields']; + + const parityTable = async (name: string, fields: CreateTableFields) => + ctx.createTable({ + baseId: ctx.baseId, + name: uniqueName(name), + fields, + views: [{ type: 'grid' }], + }); + + const parityFormula = async ( + tableIdParam: string, + name: string, + expression: string, + timeZone?: 'utc' | 'Asia/Shanghai' + ) => { + const updatedTable = await ctx.createField({ + baseId: ctx.baseId, + tableId: tableIdParam, + field: { + type: 'formula', + name, + options: timeZone ? { expression, timeZone } : { expression }, + }, + }); + const fieldId = updatedTable.fields.find((f) => f.name === name)?.id ?? ''; + expect(fieldId).not.toBe(''); + return fieldId; + }; + + const parityFields = async (tableIdParam: string, recordId: string) => { + const records = await ctx.listRecords(tableIdParam); + const record = records.find((r) => r.id === recordId); + expect(record).toBeDefined(); + if (!record) throw new Error(`record ${recordId} not found`); + return record.fields; + }; + + describe('binary comparison coercion (v1 operatorCases)', () => { + it('should evaluate text equals numeric literal - {textField} = 0', async () => { + const table = await parityTable('Parity Text Eq Zero', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', name: 'Txt' }, + ]); + const txtId = table.fields.find((f) => f.name === 'Txt')?.id ?? ''; + const formulaId = await parityFormula(table.id, 'Eq Zero', `{${txtId}} = 0`); + + const record = await ctx.createRecord(table.id, { [txtId]: '0' }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(true); + + await ctx.updateRecord(table.id, record.id, { [txtId]: '5' }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(false); + }); + + it('should evaluate text greater than numeric literal - {textField} > 2', async () => { + const table = await parityTable('Parity Text Gt Two', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', name: 'Txt' }, + ]); + const txtId = table.fields.find((f) => f.name === 'Txt')?.id ?? ''; + const formulaId = await parityFormula(table.id, 'Gt Two', `{${txtId}} > 2`); + + const record = await ctx.createRecord(table.id, { [txtId]: '10' }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(true); + + await ctx.updateRecord(table.id, record.id, { [txtId]: '1' }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(false); + }); + + it('should evaluate number less than string literal - {numberField} < "10"', async () => { + const table = await parityTable('Parity Num Lt String', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'number', name: 'Num' }, + ]); + const numId = table.fields.find((f) => f.name === 'Num')?.id ?? ''; + const formulaId = await parityFormula(table.id, 'Lt Ten', `{${numId}} < "10"`); + + const record = await ctx.createRecord(table.id, { [numId]: 3 }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(true); + + await ctx.updateRecord(table.id, record.id, { [numId]: 20 }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(false); + }); + + it('should evaluate text minus numeric literal - {textField} - 2', async () => { + const table = await parityTable('Parity Text Minus', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', name: 'Txt' }, + ]); + const txtId = table.fields.find((f) => f.name === 'Txt')?.id ?? ''; + const formulaId = await parityFormula(table.id, 'Minus Two', `{${txtId}} - 2`); + + const record = await ctx.createRecord(table.id, { [txtId]: '5' }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(3); + + await ctx.updateRecord(table.id, record.id, { [txtId]: '1' }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(-1); + }); + + it('should evaluate text divided by numeric literal - {textField} / 2', async () => { + const table = await parityTable('Parity Text Divide', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', name: 'Txt' }, + ]); + const txtId = table.fields.find((f) => f.name === 'Txt')?.id ?? ''; + const formulaId = await parityFormula(table.id, 'Div Two', `{${txtId}} / 2`); + + const record = await ctx.createRecord(table.id, { [txtId]: '8' }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(4); + + await ctx.updateRecord(table.id, record.id, { [txtId]: '3' }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBeCloseTo(1.5, 9); + }); + + it('should evaluate text multiplied by numeric literal - {textField} * 4', async () => { + const table = await parityTable('Parity Text Multiply', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', name: 'Txt' }, + ]); + const txtId = table.fields.find((f) => f.name === 'Txt')?.id ?? ''; + const formulaId = await parityFormula(table.id, 'Mul Four', `{${txtId}} * 4`); + + const record = await ctx.createRecord(table.id, { [txtId]: '3' }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(12); + + await ctx.updateRecord(table.id, record.id, { [txtId]: '5' }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(20); + }); + + it('should evaluate multi select equality against text - ARRAY_JOIN({multiSelect}, "") = {textField}', async () => { + const table = await parityTable('Parity MultiSelect Eq', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', name: 'Txt' }, + { type: 'multipleSelect', name: 'Tags', options: ['Alpha', 'Beta'] }, + ]); + const txtId = table.fields.find((f) => f.name === 'Txt')?.id ?? ''; + const tagsField = table.fields.find((f) => f.name === 'Tags'); + const tagsId = tagsField?.id ?? ''; + const choices = + (tagsField?.options as { choices?: Array<{ id: string; name: string }> })?.choices ?? []; + const alphaId = choices.find((c) => c.name === 'Alpha')?.id ?? ''; + const betaId = choices.find((c) => c.name === 'Beta')?.id ?? ''; + + const formulaId = await parityFormula( + table.id, + 'Tags Eq Text', + `ARRAY_JOIN({${tagsId}}, '') = {${txtId}}` + ); + + const record = await ctx.createRecord(table.id, { + [txtId]: 'Alpha', + [tagsId]: [alphaId], + }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(true); + + await ctx.updateRecord(table.id, record.id, { [tagsId]: [betaId] }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(false); + }); + + it('should evaluate user equality against text - TEXT_ALL({userField}) = {textField}', async () => { + const table = await parityTable('Parity User Eq', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', name: 'Txt' }, + { type: 'user', name: 'Assignee', options: { isMultiple: false, shouldNotify: false } }, + ]); + const txtId = table.fields.find((f) => f.name === 'Txt')?.id ?? ''; + const userId = table.fields.find((f) => f.name === 'Assignee')?.id ?? ''; + + const formulaId = await parityFormula( + table.id, + 'User Eq Text', + `TEXT_ALL({${userId}}) = {${txtId}}` + ); + + const record = await ctx.createRecord(table.id, { + [txtId]: 'System', + [userId]: { id: 'system', title: 'System' }, + }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(true); + + await ctx.updateRecord(table.id, record.id, { [txtId]: 'someone else' }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(false); + }); + }); + + describe('numeric function additions (v1 numericCases)', () => { + it('should evaluate ROUNDUP and ROUNDDOWN', async () => { + const table = await parityTable('Parity RoundUpDown', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'number', name: 'Num' }, + ]); + const numId = table.fields.find((f) => f.name === 'Num')?.id ?? ''; + const roundUpId = await parityFormula(table.id, 'RoundUp', `ROUNDUP({${numId}} / 7, 2)`); + const roundDownId = await parityFormula( + table.id, + 'RoundDown', + `ROUNDDOWN({${numId}} / 7, 2)` + ); + + const record = await ctx.createRecord(table.id, { [numId]: 12.345 }); + await ctx.drainOutbox(); + const fields = await parityFields(table.id, record.id); + expect(fields[roundUpId]).toBeCloseTo(Math.ceil((12.345 / 7) * 100) / 100, 9); + expect(fields[roundDownId]).toBeCloseTo(Math.floor((12.345 / 7) * 100) / 100, 9); + }); + + it('should evaluate SUM with multiple arguments and conditional logic', async () => { + const table = await parityTable('Parity Sum If', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'number', name: 'Num' }, + ]); + const numId = table.fields.find((f) => f.name === 'Num')?.id ?? ''; + const formulaId = await parityFormula( + table.id, + 'Sum If', + `SUM(IF({${numId}} > 20, {${numId}} - 20, {${numId}} + 20), {${numId}})` + ); + + const record = await ctx.createRecord(table.id, { [numId]: 25 }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(30); + + await ctx.updateRecord(table.id, record.id, { [numId]: 10 }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(40); + }); + }); + + describe('text function additions (v1 textCases)', () => { + it('should evaluate REGEXP_REPLACE on text fields', async () => { + const table = await parityTable('Parity Regexp Replace', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', name: 'Txt' }, + ]); + const txtId = table.fields.find((f) => f.name === 'Txt')?.id ?? ''; + const vowelsId = await parityFormula( + table.id, + 'Vowels', + `REGEXP_REPLACE({${txtId}}, "[aeiou]", "#")` + ); + const emailId = await parityFormula( + table.id, + 'Email Local', + `"user name:" & REGEXP_REPLACE({${txtId}}, '@.*', '')` + ); + + const vowelRecord = await ctx.createRecord(table.id, { [txtId]: 'Teable Rocks' }); + const emailRecord = await ctx.createRecord(table.id, { [txtId]: 'olivia@example.com' }); + await ctx.drainOutbox(); + + expect((await parityFields(table.id, vowelRecord.id))[vowelsId]).toBe( + 'Teable Rocks'.replace(/[aeiou]/g, '#') + ); + expect((await parityFields(table.id, emailRecord.id))[emailId]).toBe('user name:olivia'); + }); + + it('should calculate formula containing question mark literal', async () => { + const table = await parityTable('Parity Question Mark', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', name: 'Txt' }, + ]); + const txtId = table.fields.find((f) => f.name === 'Txt')?.id ?? ''; + const formulaId = await parityFormula( + table.id, + 'Url Formula', + `'https://example.com/?id=' & {${txtId}}` + ); + + const record = await ctx.createRecord(table.id, { [txtId]: 'abc' }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe( + 'https://example.com/?id=abc' + ); + }); + + it('should update records referencing spaced curly field identifiers', async () => { + const table = await parityTable('Parity Spaced Curly', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'number', name: 'Num' }, + { type: 'singleLineText', name: 'Txt' }, + ]); + const numId = table.fields.find((f) => f.name === 'Num')?.id ?? ''; + const txtId = table.fields.find((f) => f.name === 'Txt')?.id ?? ''; + const formulaId = await parityFormula( + table.id, + 'Spaced Formula', + `{ ${numId} } & '-' & { ${txtId} }` + ); + + const record = await ctx.createRecord(table.id, { [numId]: 5, [txtId]: 'old' }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe('5-old'); + + await ctx.updateRecord(table.id, record.id, { [numId]: 10, [txtId]: 'fresh' }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe('10-fresh'); + }); + + it('should keep BLANK() comparisons stable with spaced function calls', async () => { + const table = await parityTable('Parity Blank Spacing', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'number', name: 'Weight' }, + ]); + const numId = table.fields.find((f) => f.name === 'Weight')?.id ?? ''; + const compactId = await parityFormula(table.id, 'Blank Compact', `{${numId}} !=BLANK()`); + const spacedId = await parityFormula(table.id, 'Blank Spaced', `{${numId}} != BLANK()`); + + const record = await ctx.createRecord(table.id, { [numId]: 70 }); + await ctx.drainOutbox(); + const fields = await parityFields(table.id, record.id); + expect(fields[compactId]).toBe(true); + expect(fields[spacedId]).toBe(true); + }); + + it('should encode line breaks in long text with ENCODE_URL_COMPONENT', async () => { + const multilineInput = [ + 'Been using Teable lately — honestly impressed @teableio', + ' ', + 'Scattered work → AI-native system (for projects, CRM & marketing) in minutes 🚀', + 'teable.ai', + ].join('\n'); + + const table = await parityTable('Parity Encode Multiline', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'longText', name: 'Long' }, + ]); + const longId = table.fields.find((f) => f.name === 'Long')?.id ?? ''; + const formulaId = await parityFormula( + table.id, + 'Encoded', + `ENCODE_URL_COMPONENT({${longId}})` + ); + + const record = await ctx.createRecord(table.id, { [longId]: multilineInput }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe( + encodeURIComponent(multilineInput) + ); + }); + }); + + describe('logical and system functions (v1 logicalCases)', () => { + it('should evaluate RECORD_ID for existing records', async () => { + const table = await parityTable('Parity Record Id', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + ]); + // v1 logicalCases order: the record exists before the formula field is + // created, so the formula seed pass computes RECORD_ID(). + const record = await ctx.createRecord(table.id, {}); + const formulaId = await parityFormula(table.id, 'Rec Id', 'RECORD_ID()'); + + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(record.id); + }); + + // Regression (T6520): formulas without field references depend only on the + // row existing, so insert seeding includes them explicitly. + it('should populate RECORD_ID formula for newly created records', async () => { + const table = await parityTable('Parity Record Id Create', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + ]); + const formulaId = await parityFormula(table.id, 'Rec Id', 'RECORD_ID()'); + + const record = await ctx.createRecord(table.id, {}); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(record.id); + }); + + it('should evaluate AUTO_NUMBER formula matching the auto number field', async () => { + const table = await parityTable('Parity Auto Number Fn', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'autoNumber', name: 'Auto' }, + ]); + const autoId = table.fields.find((f) => f.name === 'Auto')?.id ?? ''; + const formulaId = await parityFormula(table.id, 'Auto Fn', 'AUTO_NUMBER()'); + const record = await ctx.createRecord(table.id, {}); + + await ctx.drainOutbox(); + const fields = await parityFields(table.id, record.id); + expect(typeof fields[autoId]).toBe('number'); + expect(fields[formulaId]).toBe(fields[autoId]); + }); + + it('should evaluate TEXT_ALL passthrough on text fields', async () => { + const table = await parityTable('Parity Text All', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', name: 'Txt' }, + ]); + const txtId = table.fields.find((f) => f.name === 'Txt')?.id ?? ''; + const formulaId = await parityFormula(table.id, 'Text All', `TEXT_ALL({${txtId}})`); + + const record = await ctx.createRecord(table.id, { [txtId]: 'Teable Rocks' }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe('Teable Rocks'); + }); + + it('should normalize truthiness for non-boolean logical inputs', async () => { + const table = await parityTable('Parity Logical Truthiness', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'number', name: 'Num' }, + { type: 'singleLineText', name: 'Txt' }, + ]); + const numId = table.fields.find((f) => f.name === 'Num')?.id ?? ''; + const txtId = table.fields.find((f) => f.name === 'Txt')?.id ?? ''; + const andId = await parityFormula(table.id, 'And', `AND({${numId}}, {${txtId}})`); + const orId = await parityFormula(table.id, 'Or', `OR({${numId}}, {${txtId}})`); + const notId = await parityFormula(table.id, 'Not', `NOT({${numId}})`); + + const record = await ctx.createRecord(table.id, { [numId]: 5, [txtId]: 'value' }); + await ctx.drainOutbox(); + let fields = await parityFields(table.id, record.id); + expect(fields[andId]).toBe(true); + expect(fields[orId]).toBe(true); + expect(fields[notId]).toBe(false); + + // v1 stores empty inputs ('' -> null, T6520); 0 stays 0 + await ctx.updateRecord(table.id, record.id, { [numId]: 0, [txtId]: '' }); + await ctx.drainOutbox(); + fields = await parityFields(table.id, record.id); + expect(fields[andId]).toBe(false); + expect(fields[orId]).toBe(false); + expect(fields[notId]).toBe(true); + + await ctx.updateRecord(table.id, record.id, { [numId]: null, [txtId]: 'fallback' }); + await ctx.drainOutbox(); + fields = await parityFields(table.id, record.id); + expect(fields[andId]).toBe(false); + expect(fields[orId]).toBe(true); + expect(fields[notId]).toBe(true); + }); + + it('should treat null numeric operands as zero for comparison operators', async () => { + const table = await parityTable('Parity Null Numeric Cmp', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'number', name: 'Left' }, + { type: 'number', name: 'Right' }, + ]); + const leftId = table.fields.find((f) => f.name === 'Left')?.id ?? ''; + const rightId = table.fields.find((f) => f.name === 'Right')?.id ?? ''; + const gtId = await parityFormula( + table.id, + 'Gt', + `IF({${leftId}} > {${rightId}}, 'left', 'right')` + ); + const ltId = await parityFormula( + table.id, + 'Lt', + `IF({${leftId}} < {${rightId}}, 'less', 'not-less')` + ); + const eqId = await parityFormula( + table.id, + 'Eq', + `IF({${leftId}} = {${rightId}}, 'equal', 'different')` + ); + + const recordInputs: Array> = [ + { [rightId]: -1 }, // null > -1 behaves like 0 > -1 + { [rightId]: 3 }, // null < 3 behaves like 0 < 3 + { [rightId]: 0 }, // null = 0 behaves like 0 = 0 + { [leftId]: 2 }, // 2 > null behaves like 2 > 0 + ]; + const created = [] as Array<{ id: string }>; + for (const input of recordInputs) { + created.push(await ctx.createRecord(table.id, input)); + } + await ctx.drainOutbox(); + + const expectations = [ + { gt: 'left', lt: 'not-less', eq: 'different' }, + { gt: 'right', lt: 'less', eq: 'different' }, + { gt: 'right', lt: 'not-less', eq: 'equal' }, + { gt: 'left', lt: 'not-less', eq: 'different' }, + ]; + + for (let index = 0; index < created.length; index += 1) { + const fields = await parityFields(table.id, created[index].id); + expect(fields[gtId]).toBe(expectations[index].gt); + expect(fields[ltId]).toBe(expectations[index].lt); + expect(fields[eqId]).toBe(expectations[index].eq); + } + }); + + it('should treat numeric IF fallbacks with blank branches as nulls', async () => { + const table = await parityTable('Parity Numeric If Blank', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'number', name: 'Cond' }, + { type: 'number', name: 'Sub' }, + { type: 'number', name: 'BlankCond' }, + { type: 'number', name: 'Fallback' }, + ]); + const condId = table.fields.find((f) => f.name === 'Cond')?.id ?? ''; + const subId = table.fields.find((f) => f.name === 'Sub')?.id ?? ''; + const blankCondId = table.fields.find((f) => f.name === 'BlankCond')?.id ?? ''; + const fallbackId = table.fields.find((f) => f.name === 'Fallback')?.id ?? ''; + const formulaId = await parityFormula( + table.id, + 'If Fallback', + `IF({${condId}} > 0, {${condId}} - {${subId}}, IF({${blankCondId}} > 0, '', {${fallbackId}}))` + ); + + const record = await ctx.createRecord(table.id, { + [condId]: 10, + [subId]: 3, + [blankCondId]: 0, + [fallbackId]: 5, + }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBeCloseTo(7, 9); + + await ctx.updateRecord(table.id, record.id, { [condId]: 0, [blankCondId]: 8 }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId] ?? null).toBeNull(); + + await ctx.updateRecord(table.id, record.id, { [blankCondId]: 0, [fallbackId]: -4 }); + await ctx.drainOutbox(); + expect(Number((await parityFields(table.id, record.id))[formulaId])).toBe(-4); + }); + + it('should compare multi select values against literals inside IF branches', async () => { + const table = await parityTable('Parity MultiSelect If', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'multipleSelect', name: 'Tags', options: ['Alpha', 'Beta'] }, + ]); + const tagsField = table.fields.find((f) => f.name === 'Tags'); + const tagsId = tagsField?.id ?? ''; + const choices = + (tagsField?.options as { choices?: Array<{ id: string; name: string }> })?.choices ?? []; + const alphaId = choices.find((c) => c.name === 'Alpha')?.id ?? ''; + const betaId = choices.find((c) => c.name === 'Beta')?.id ?? ''; + const formulaId = await parityFormula( + table.id, + 'Tags If', + `IF({${tagsId}} = "Alpha", 1, 2)` + ); + + const record = await ctx.createRecord(table.id, { [tagsId]: [alphaId] }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(1); + + await ctx.updateRecord(table.id, record.id, { [tagsId]: [betaId] }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId]).toBe(2); + }); + + it('should evaluate SWITCH formulas with numeric branches and blank literals', async () => { + const table = await parityTable('Parity Switch Blank', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleSelect', name: 'Status', options: ['light', 'medium', 'heavy'] }, + { type: 'number', name: 'Amount' }, + ]); + const statusField = table.fields.find((f) => f.name === 'Status'); + const statusId = statusField?.id ?? ''; + const choices = + (statusField?.options as { choices?: Array<{ id: string; name: string }> })?.choices ?? + []; + const lightId = choices.find((c) => c.name === 'light')?.id ?? ''; + const mediumId = choices.find((c) => c.name === 'medium')?.id ?? ''; + const heavyId = choices.find((c) => c.name === 'heavy')?.id ?? ''; + const amountId = table.fields.find((f) => f.name === 'Amount')?.id ?? ''; + const formulaId = await parityFormula( + table.id, + 'Switch Mixed', + `SWITCH({${statusId}}, "heavy", '', "medium", {${amountId}}, 123)` + ); + + const record = await ctx.createRecord(table.id, { + [statusId]: mediumId, + [amountId]: 42, + }); + await ctx.drainOutbox(); + expect(Number((await parityFields(table.id, record.id))[formulaId])).toBe(42); + + await ctx.updateRecord(table.id, record.id, { [statusId]: heavyId }); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId] ?? null).toBeNull(); + + await ctx.updateRecord(table.id, record.id, { [statusId]: lightId }); + await ctx.drainOutbox(); + expect(Number((await parityFields(table.id, record.id))[formulaId])).toBe(123); + }); + + it('should reject LAST_MODIFIED_TIME with non-field parameters', async () => { + const table = await parityTable('Parity LMT Literal', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + ]); + + const createFieldResponse = await fetch(`${ctx.baseUrl}/tables/createField`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + baseId: ctx.baseId, + tableId: table.id, + field: { + type: 'formula', + name: 'Invalid LMT', + options: { + expression: 'LAST_MODIFIED_TIME("literal param")', + }, + }, + }), + }); + expect(createFieldResponse.status).toBe(400); + }); + }); + + describe('date comparison boolean semantics (T5496)', () => { + it('should preserve boolean semantics for date comparisons nested in AND/OR', async () => { + const table = await parityTable('Parity T5496 Date Bool', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'date', name: 'Event' }, + ]); + const eventId = table.fields.find((f) => f.name === 'Event')?.id ?? ''; + + // IS_AFTER(Mar 1, Jun 1) = false, IS_BEFORE(Mar 1, Dec 1) = true => AND false + const andFalseId = await parityFormula( + table.id, + 'And Date False', + `AND(IS_AFTER({${eventId}}, '2024-06-01'), IS_BEFORE({${eventId}}, '2024-12-01'))` + ); + // IS_AFTER false, IS_SAME false => OR false + const orFalseId = await parityFormula( + table.id, + 'Or Date False', + `OR(IS_AFTER({${eventId}}, '2024-06-01'), IS_SAME({${eventId}}, '2024-06-01', 'day'))` + ); + // IS_AFTER true, IS_BEFORE true => AND true + const andTrueId = await parityFormula( + table.id, + 'And Date True', + `AND(IS_AFTER({${eventId}}, '2024-01-01'), IS_BEFORE({${eventId}}, '2024-12-01'))` + ); + + const record = await ctx.createRecord(table.id, { + [eventId]: '2024-03-01T00:00:00.000Z', + }); + await ctx.drainOutbox(); + const fields = await parityFields(table.id, record.id); + expect(fields[andFalseId]).toBe(false); + expect(fields[orFalseId]).toBe(false); + expect(fields[andTrueId]).toBe(true); + }); + }); + + describe('datetime additions (v1 datetimeDiffCases / safe calculate)', () => { + it('should evaluate DATETIME_DIFF for month/quarter/year spans', async () => { + const table = await parityTable('Parity Diff Spans', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + ]); + const spanCases = [ + { + unit: 'month', + start: '2024-01-31T00:00:00.000Z', + end: '2024-02-29T00:00:00.000Z', + }, + { + unit: 'months', + start: '2024-01-31T00:00:00.000Z', + end: '2024-02-29T00:00:00.000Z', + }, + { + unit: 'quarter', + start: '2025-01-01T00:00:00.000Z', + end: '2025-04-01T00:00:00.000Z', + }, + { + unit: 'quarters', + start: '2025-01-01T00:00:00.000Z', + end: '2025-04-01T00:00:00.000Z', + }, + { + unit: 'year', + start: '2024-01-01T00:00:00.000Z', + end: '2025-01-01T00:00:00.000Z', + }, + { + unit: 'years', + start: '2024-01-01T00:00:00.000Z', + end: '2025-01-01T00:00:00.000Z', + }, + ] as const; + + // The record must exist before the literal-only formula fields are + // created: v2 only computes no-field-reference formulas in the seed pass + // (see "Known drift (T6520)" note in the logical functions describe). + const record = await ctx.createRecord(table.id, {}); + + const fieldIds: string[] = []; + for (const { unit, start, end } of spanCases) { + fieldIds.push( + await parityFormula( + table.id, + `Diff ${unit}`, + `DATETIME_DIFF(DATETIME_PARSE("${end}"), DATETIME_PARSE("${start}"), '${unit}')`, + 'utc' + ) + ); + } + + await ctx.drainOutbox(); + const fields = await parityFields(table.id, record.id); + for (const fieldId of fieldIds) { + expect(Number(fields[fieldId])).toBeCloseTo(1, 6); + } + }); + + it('should calculate formula with timeZone - DAY over offset literals', async () => { + const table = await parityTable('Parity Day TimeZone', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + ]); + // Seed-path ordering: see "Known drift (T6520)" note about + // no-field-reference formulas and newly created records. + const record = await ctx.createRecord(table.id, {}); + + const day29Id = await parityFormula( + table.id, + 'Day Feb29', + "DAY('2024-02-29T00:00:00+08:00')", + 'Asia/Shanghai' + ); + const day27Id = await parityFormula( + table.id, + 'Day Feb27', + "DAY('2024-02-28T00:00:00+09:00')", + 'Asia/Shanghai' + ); + + await ctx.drainOutbox(); + const fields = await parityFields(table.id, record.id); + expect(fields[day29Id]).toBe(29); + expect(fields[day27Id]).toBe(27); + }); + + it('should treat DATETIME_PARSE without format as null when generated string is invalid', async () => { + const table = await parityTable('Parity Parse Guard', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'date', name: 'Birthday' }, + ]); + const dateId = table.fields.find((f) => f.name === 'Birthday')?.id ?? ''; + const formulaId = await parityFormula( + table.id, + 'Anniversary', + `DATETIME_PARSE(YEAR(TODAY()) & '-' & MONTH({${dateId}}) & '-' & DAY({${dateId}}))`, + 'utc' + ); + + const record = await ctx.createRecord(table.id, {}); + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId] ?? null).toBeNull(); + }); + }); + + describe('safe calculate (v1 parity)', () => { + it('should safe calculate error function - text multiplied literal', async () => { + const table = await parityTable('Parity Safe Calc', [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + ]); + // Seed-path ordering so the formula is actually evaluated for the record + // (see "Known drift (T6520)" note about no-field-reference formulas). + const record = await ctx.createRecord(table.id, {}); + + const createFieldResponse = await fetch(`${ctx.baseUrl}/tables/createField`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + baseId: ctx.baseId, + tableId: table.id, + field: { + type: 'formula', + name: 'Unsafe Product', + options: { + expression: "'x'*10", + }, + }, + }), + }); + expect(createFieldResponse.status).toBe(200); + const fieldRaw = await createFieldResponse.json(); + const fieldParsed = createFieldOkResponseSchema.safeParse(fieldRaw); + expect(fieldParsed.success).toBe(true); + if (!fieldParsed.success || !fieldParsed.data.ok) return; + const formulaId = + fieldParsed.data.data.table.fields.find((f) => f.name === 'Unsafe Product')?.id ?? ''; + + await ctx.drainOutbox(); + expect((await parityFields(table.id, record.id))[formulaId] ?? null).toBeNull(); + }); + }); + }); }); diff --git a/packages/v2/e2e/src/importRecords.e2e.spec.ts b/packages/v2/e2e/src/importRecords.e2e.spec.ts index c5ce3a48b8..68fb03cd85 100644 --- a/packages/v2/e2e/src/importRecords.e2e.spec.ts +++ b/packages/v2/e2e/src/importRecords.e2e.spec.ts @@ -176,12 +176,13 @@ describe('v2 http importRecords (e2e)', () => { expect(result.totalImported).toBe(4); - // Verify checkbox values + // Checkbox false is normalized to an empty cell for v1 storage parity. const records = await ctx.listRecords(table.id); const task1 = records.find((r) => r.fields[taskFieldId] === 'Task1'); const task2 = records.find((r) => r.fields[taskFieldId] === 'Task2'); expect(task1!.fields[completedFieldId]).toBe(true); - expect(task2!.fields[completedFieldId]).toBe(false); + // v1 contract: unchecked is stored as null, never false + expect(task2!.fields[completedFieldId] == null).toBe(true); }); }); diff --git a/packages/v2/e2e/src/link-empty-primary-title-null.e2e.spec.ts b/packages/v2/e2e/src/link-empty-primary-title-null.e2e.spec.ts new file mode 100644 index 0000000000..83795aafcf --- /dev/null +++ b/packages/v2/e2e/src/link-empty-primary-title-null.e2e.spec.ts @@ -0,0 +1,178 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { describe, beforeAll, expect, it } from 'vitest'; +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +/** + * T6508: empty-primary foreign records can persist link snapshots as {id, title:null}. + * Rewriting that value (UI reselect / import / automation) must not 400, and storage + * must omit null titles the same way computed recompute does via jsonb_strip_nulls. + * + * Fixture is sanitized/structure-equivalent: no customer ids or values. + */ +describe('v2 link empty primary title null rewrite (e2e)', () => { + let ctx: SharedTestContext; + let fieldIdCounter = 0; + + const createFieldId = () => { + const suffix = fieldIdCounter.toString(36).padStart(16, '0'); + fieldIdCounter += 1; + return `fld${suffix}`; + }; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + }); + + it('accepts rewriting a manyOne link that already points at an empty-primary record', async () => { + const hostNameFieldId = createFieldId(); + const hostLinkFieldId = createFieldId(); + const foreignNameFieldId = createFieldId(); + + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'ForeignEmptyPrimary', + fields: [ + { + type: 'singleLineText', + id: foreignNameFieldId, + name: 'name', + isPrimary: true, + }, + ], + }); + + const emptyPrimary = await ctx.createRecord(foreignTable.id, { + [foreignNameFieldId]: null, + }); + const titled = await ctx.createRecord(foreignTable.id, { + [foreignNameFieldId]: 'titled-foreign', + }); + + const hostTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'HostLinkEmptyPrimary', + fields: [ + { + type: 'singleLineText', + id: hostNameFieldId, + name: 'name', + isPrimary: true, + }, + { + type: 'link', + id: hostLinkFieldId, + name: 'link', + options: { + relationship: 'manyOne', + foreignTableId: foreignTable.id, + lookupFieldId: foreignNameFieldId, + isOneWay: true, + }, + }, + ], + }); + + const host = await ctx.createRecord(hostTable.id, { + [hostNameFieldId]: 'host-row', + [hostLinkFieldId]: { id: emptyPrimary.id }, + }); + await ctx.drainOutbox(); + + // First link to empty primary is allowed (id-only). + let listed = await ctx.listRecords(hostTable.id); + let row = listed.find((record) => record.id === host.id); + expect(row?.fields[hostLinkFieldId]).toEqual({ id: emptyPrimary.id }); + + // Rewrite with explicit title:null (read-back / UI reselect shape). + await ctx.updateRecord(hostTable.id, host.id, { + [hostLinkFieldId]: { id: emptyPrimary.id, title: null }, + }); + await ctx.drainOutbox(); + + listed = await ctx.listRecords(hostTable.id); + row = listed.find((record) => record.id === host.id); + expect(row?.fields[hostLinkFieldId]).toEqual({ id: emptyPrimary.id }); + expect(row?.fields[hostLinkFieldId]).not.toHaveProperty('title'); + + // Control: titled foreign still stores a title. + await ctx.updateRecord(hostTable.id, host.id, { + [hostLinkFieldId]: { id: titled.id }, + }); + await ctx.drainOutbox(); + + listed = await ctx.listRecords(hostTable.id); + row = listed.find((record) => record.id === host.id); + expect(row?.fields[hostLinkFieldId]).toEqual({ id: titled.id, title: 'titled-foreign' }); + }); + + it('accepts rewriting a manyMany link item with title:null for empty primary', async () => { + const hostNameFieldId = createFieldId(); + const hostLinkFieldId = createFieldId(); + const foreignNameFieldId = createFieldId(); + + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'ForeignEmptyPrimaryMulti', + fields: [ + { + type: 'singleLineText', + id: foreignNameFieldId, + name: 'name', + isPrimary: true, + }, + ], + }); + + const emptyPrimary = await ctx.createRecord(foreignTable.id, { + [foreignNameFieldId]: null, + }); + const titled = await ctx.createRecord(foreignTable.id, { + [foreignNameFieldId]: 'titled-foreign-2', + }); + + const hostTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'HostLinkEmptyPrimaryMulti', + fields: [ + { + type: 'singleLineText', + id: hostNameFieldId, + name: 'name', + isPrimary: true, + }, + { + type: 'link', + id: hostLinkFieldId, + name: 'links', + options: { + relationship: 'manyMany', + foreignTableId: foreignTable.id, + lookupFieldId: foreignNameFieldId, + isOneWay: true, + }, + }, + ], + }); + + const host = await ctx.createRecord(hostTable.id, { + [hostNameFieldId]: 'host-row-multi', + [hostLinkFieldId]: [{ id: emptyPrimary.id }, { id: titled.id }], + }); + await ctx.drainOutbox(); + + await ctx.updateRecord(hostTable.id, host.id, { + [hostLinkFieldId]: [ + { id: emptyPrimary.id, title: null }, + { id: titled.id, title: 'titled-foreign-2' }, + ], + }); + await ctx.drainOutbox(); + + const listed = await ctx.listRecords(hostTable.id); + const row = listed.find((record) => record.id === host.id); + expect(row?.fields[hostLinkFieldId]).toEqual([ + { id: emptyPrimary.id }, + { id: titled.id, title: 'titled-foreign-2' }, + ]); + }); +}); diff --git a/packages/v2/e2e/src/link-empty-title-rewrite.e2e.spec.ts b/packages/v2/e2e/src/link-empty-title-rewrite.e2e.spec.ts new file mode 100644 index 0000000000..ef07f025d8 --- /dev/null +++ b/packages/v2/e2e/src/link-empty-title-rewrite.e2e.spec.ts @@ -0,0 +1,181 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { beforeAll, describe, expect, it } from 'vitest'; +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +/** + * Structure-equivalent reproduction for empty foreign primary titles: + * - foreign primary is empty/null + * - host link is written by id only + * - same link value is written again (idempotent API/import path) + * - stored link is read back and written as-is + * + * Fixture uses neutral names only; no customer identifiers/values. + */ +describe('v2 link empty foreign title rewrite (e2e)', () => { + let ctx: SharedTestContext; + let fieldIdCounter = 0; + + const createFieldId = () => { + const suffix = fieldIdCounter.toString(36).padStart(16, '0'); + fieldIdCounter += 1; + return `fld${suffix}`; + }; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + }); + + it('keeps manyOne empty-title links rewriteable after idempotent writes', async () => { + const foreignTitleFieldId = createFieldId(); + const hostTitleFieldId = createFieldId(); + const linkFieldId = createFieldId(); + + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Empty Title Foreign ManyOne', + fields: [{ type: 'singleLineText', id: foreignTitleFieldId, name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + + const emptyForeign = await ctx.createRecord(foreignTable.id, { + [foreignTitleFieldId]: null, + }); + + const hostTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Empty Title Host ManyOne', + fields: [ + { type: 'singleLineText', id: hostTitleFieldId, name: 'Title', isPrimary: true }, + { + type: 'link', + id: linkFieldId, + name: 'Related', + options: { + relationship: 'manyOne', + foreignTableId: foreignTable.id, + lookupFieldId: foreignTitleFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + + const hostRecord = await ctx.createRecord(hostTable.id, { + [hostTitleFieldId]: 'Host Row', + [linkFieldId]: { id: emptyForeign.id }, + }); + + await ctx.updateRecord(hostTable.id, hostRecord.id, { + [linkFieldId]: { id: emptyForeign.id }, + }); + + const afterRepeat = (await ctx.listRecords(hostTable.id)).find((r) => r.id === hostRecord.id); + expect(afterRepeat).toBeDefined(); + const repeatedLink = afterRepeat?.fields[linkFieldId] as + | { id?: string; title?: string | null } + | undefined; + expect(repeatedLink?.id).toBe(emptyForeign.id); + expect(repeatedLink).not.toHaveProperty('title'); + + await expect( + ctx.updateRecord(hostTable.id, hostRecord.id, { + [linkFieldId]: repeatedLink, + }) + ).resolves.toBeDefined(); + + const afterRewrite = (await ctx.listRecords(hostTable.id)).find((r) => r.id === hostRecord.id); + const rewrittenLink = afterRewrite?.fields[linkFieldId] as + | { id?: string; title?: string | null } + | undefined; + expect(rewrittenLink?.id).toBe(emptyForeign.id); + expect(rewrittenLink).not.toHaveProperty('title'); + }); + + it('keeps manyMany empty-title links rewriteable after idempotent writes', async () => { + const foreignTitleFieldId = createFieldId(); + const hostTitleFieldId = createFieldId(); + const linkFieldId = createFieldId(); + + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Empty Title Foreign ManyMany', + fields: [{ type: 'singleLineText', id: foreignTitleFieldId, name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + + const emptyForeign = await ctx.createRecord(foreignTable.id, { + [foreignTitleFieldId]: null, + }); + const namedForeign = await ctx.createRecord(foreignTable.id, { + [foreignTitleFieldId]: 'Named Foreign', + }); + + const hostTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Empty Title Host ManyMany', + fields: [ + { type: 'singleLineText', id: hostTitleFieldId, name: 'Title', isPrimary: true }, + { + type: 'link', + id: linkFieldId, + name: 'Related', + options: { + relationship: 'manyMany', + foreignTableId: foreignTable.id, + lookupFieldId: foreignTitleFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + + const hostRecord = await ctx.createRecord(hostTable.id, { + [hostTitleFieldId]: 'Host Row', + [linkFieldId]: [{ id: emptyForeign.id }, { id: namedForeign.id }], + }); + + await ctx.updateRecord(hostTable.id, hostRecord.id, { + [linkFieldId]: [{ id: emptyForeign.id }, { id: namedForeign.id }], + }); + + const afterRepeat = (await ctx.listRecords(hostTable.id)).find((r) => r.id === hostRecord.id); + const repeatedLinks = afterRepeat?.fields[linkFieldId] as + | Array<{ id?: string; title?: string | null }> + | undefined; + expect(Array.isArray(repeatedLinks)).toBe(true); + expect(repeatedLinks?.map((link) => link.id).sort()).toEqual( + [emptyForeign.id, namedForeign.id].sort() + ); + const emptyLink = repeatedLinks?.find((link) => link.id === emptyForeign.id); + expect(emptyLink).toBeDefined(); + expect(emptyLink).not.toHaveProperty('title'); + + await expect( + ctx.updateRecord(hostTable.id, hostRecord.id, { + [linkFieldId]: repeatedLinks, + }) + ).resolves.toBeDefined(); + + // Compatibility path: already-persisted null titles must still be accepted. + await expect( + ctx.updateRecord(hostTable.id, hostRecord.id, { + [linkFieldId]: [ + { id: emptyForeign.id, title: null }, + { id: namedForeign.id, title: 'Named Foreign' }, + ], + }) + ).resolves.toBeDefined(); + + const afterNullishRewrite = (await ctx.listRecords(hostTable.id)).find( + (r) => r.id === hostRecord.id + ); + const rewrittenLinks = afterNullishRewrite?.fields[linkFieldId] as + | Array<{ id?: string; title?: string | null }> + | undefined; + const rewrittenEmpty = rewrittenLinks?.find((link) => link.id === emptyForeign.id); + expect(rewrittenEmpty?.id).toBe(emptyForeign.id); + expect(rewrittenEmpty).not.toHaveProperty('title'); + }); +}); diff --git a/packages/v2/e2e/src/link-multi-fields-same-table.e2e.spec.ts b/packages/v2/e2e/src/link-multi-fields-same-table.e2e.spec.ts new file mode 100644 index 0000000000..c66d3fbe28 --- /dev/null +++ b/packages/v2/e2e/src/link-multi-fields-same-table.e2e.spec.ts @@ -0,0 +1,294 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * E2E tests for multiple link fields between the same pair of tables. + * + * Ported from v1 link-api.e2e-spec.ts: + * - "Create two bi-link for two tables" (two same manyOne / two same oneMany links) + * - "multi link with depends same field" (link title + lookup/rollup refresh when the + * shared foreign primary field changes and several link fields depend on it) + */ +import { beforeAll, describe, expect, it } from 'vitest'; +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +describe('v2 link fields to the same foreign table (e2e)', () => { + let ctx: SharedTestContext; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + }, 120_000); + + const createTablePair = async (namePrefix: string) => { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: `${namePrefix} Foreign`, + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: `${namePrefix} Host`, + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const foreignPrimaryFieldId = foreign.fields.find((f) => f.isPrimary)?.id ?? ''; + const hostPrimaryFieldId = host.fields.find((f) => f.isPrimary)?.id ?? ''; + if (!foreignPrimaryFieldId || !hostPrimaryFieldId) { + throw new Error('Missing primary fields for table pair'); + } + return { foreign, host, foreignPrimaryFieldId, hostPrimaryFieldId }; + }; + + const createLinkField = async ( + tableId: string, + name: string, + relationship: 'manyOne' | 'oneMany' | 'manyMany' | 'oneOne', + foreignTableId: string, + lookupFieldId: string + ) => { + const table = await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { + type: 'link', + name, + options: { + relationship, + foreignTableId, + lookupFieldId, + isOneWay: false, + }, + }, + }); + const field = table.fields.find((f) => f.name === name); + if (!field) throw new Error(`Missing link field ${name}`); + return field; + }; + + it('updates one record through two manyOne links to the same foreign table', async () => { + const { foreign, host, foreignPrimaryFieldId, hostPrimaryFieldId } = + await createTablePair('TwoManyOne'); + + const linkA = await createLinkField( + host.id, + 'Link A', + 'manyOne', + foreign.id, + foreignPrimaryFieldId + ); + const linkB = await createLinkField( + host.id, + 'Link B', + 'manyOne', + foreign.id, + foreignPrimaryFieldId + ); + + const target = await ctx.createRecord(foreign.id, { [foreignPrimaryFieldId]: 'table2_1' }); + const hostRecord = await ctx.createRecord(host.id, { [hostPrimaryFieldId]: 'table1_1' }); + await ctx.testContainer.processOutbox(); + + // Set both manyOne cells to the same foreign record in a single update + await ctx.updateRecord(host.id, hostRecord.id, { + [linkA.id]: { id: target.id }, + [linkB.id]: { id: target.id }, + }); + await ctx.testContainer.processOutbox(); + await ctx.testContainer.processOutbox(); + + const records = await ctx.listRecords(host.id); + const stored = records.find((r) => r.id === hostRecord.id); + expect(stored?.fields[linkA.id]).toEqual({ id: target.id, title: 'table2_1' }); + expect(stored?.fields[linkB.id]).toEqual({ id: target.id, title: 'table2_1' }); + }); + + it('updates one record through two oneMany links to the same foreign table', async () => { + const { foreign, host, foreignPrimaryFieldId, hostPrimaryFieldId } = + await createTablePair('TwoOneMany'); + + const linkA = await createLinkField( + host.id, + 'Link A', + 'oneMany', + foreign.id, + foreignPrimaryFieldId + ); + const linkB = await createLinkField( + host.id, + 'Link B', + 'oneMany', + foreign.id, + foreignPrimaryFieldId + ); + + const target = await ctx.createRecord(foreign.id, { [foreignPrimaryFieldId]: 'table2_1' }); + const hostRecord = await ctx.createRecord(host.id, { [hostPrimaryFieldId]: 'table1_1' }); + await ctx.testContainer.processOutbox(); + + // Each oneMany field owns its own FK on the foreign table, so the same child + // can be linked through both fields without violating exclusivity. + await ctx.updateRecord(host.id, hostRecord.id, { + [linkA.id]: [{ id: target.id }], + [linkB.id]: [{ id: target.id }], + }); + await ctx.testContainer.processOutbox(); + await ctx.testContainer.processOutbox(); + + const records = await ctx.listRecords(host.id); + const stored = records.find((r) => r.id === hostRecord.id); + expect(stored?.fields[linkA.id]).toEqual([{ id: target.id, title: 'table2_1' }]); + expect(stored?.fields[linkB.id]).toEqual([{ id: target.id, title: 'table2_1' }]); + }); + + it('refreshes manyOne link title when foreign primary changes with sibling oneMany link present', async () => { + const { foreign, host, foreignPrimaryFieldId, hostPrimaryFieldId } = + await createTablePair('MixedManyOne'); + + const manyOneLink = await createLinkField( + host.id, + 'ManyOne Link', + 'manyOne', + foreign.id, + foreignPrimaryFieldId + ); + await createLinkField(host.id, 'OneMany Link', 'oneMany', foreign.id, foreignPrimaryFieldId); + + const target = await ctx.createRecord(foreign.id, { [foreignPrimaryFieldId]: 'x' }); + const hostRecord = await ctx.createRecord(host.id, { [hostPrimaryFieldId]: 'host' }); + await ctx.testContainer.processOutbox(); + + await ctx.updateRecord(host.id, hostRecord.id, { + [manyOneLink.id]: { id: target.id }, + }); + await ctx.testContainer.processOutbox(); + + // Change the shared foreign primary value: only the linked manyOne cell must refresh + await ctx.updateRecord(foreign.id, target.id, { [foreignPrimaryFieldId]: 'y' }); + await ctx.testContainer.processOutbox(); + await ctx.testContainer.processOutbox(); + + const records = await ctx.listRecords(host.id); + const stored = records.find((r) => r.id === hostRecord.id); + expect(stored?.fields[manyOneLink.id]).toEqual({ id: target.id, title: 'y' }); + }); + + it('refreshes oneMany link title and dependent lookups when foreign primary changes', async () => { + const { foreign, host, foreignPrimaryFieldId, hostPrimaryFieldId } = + await createTablePair('MixedOneMany'); + + const oneManyLink = await createLinkField( + host.id, + 'OneMany Link', + 'oneMany', + foreign.id, + foreignPrimaryFieldId + ); + const manyOneLink = await createLinkField( + host.id, + 'ManyOne Link', + 'manyOne', + foreign.id, + foreignPrimaryFieldId + ); + + const findFieldByName = (table: Awaited>, name: string) => { + const created = table.fields.find((f) => f.name === name); + if (!created) throw new Error(`Missing computed field ${name}`); + return created; + }; + + const lookupOneMany = findFieldByName( + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'lookup', + name: 'Lookup OneMany', + options: { + linkFieldId: oneManyLink.id, + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + }, + }, + }), + 'Lookup OneMany' + ); + const rollupOneMany = findFieldByName( + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'rollup', + name: 'Rollup OneMany', + options: { expression: 'countall({values})' }, + config: { + linkFieldId: oneManyLink.id, + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + }, + }, + }), + 'Rollup OneMany' + ); + const lookupManyOne = findFieldByName( + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'lookup', + name: 'Lookup ManyOne', + options: { + linkFieldId: manyOneLink.id, + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + }, + }, + }), + 'Lookup ManyOne' + ); + const rollupManyOne = findFieldByName( + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'rollup', + name: 'Rollup ManyOne', + options: { expression: 'countall({values})' }, + config: { + linkFieldId: manyOneLink.id, + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + }, + }, + }), + 'Rollup ManyOne' + ); + + const target = await ctx.createRecord(foreign.id, { [foreignPrimaryFieldId]: 'x' }); + const hostRecord = await ctx.createRecord(host.id, { [hostPrimaryFieldId]: 'host' }); + await ctx.testContainer.processOutbox(); + + await ctx.updateRecord(host.id, hostRecord.id, { + [oneManyLink.id]: [{ id: target.id }], + }); + await ctx.testContainer.processOutbox(); + await ctx.testContainer.processOutbox(); + + let records = await ctx.listRecords(host.id); + let stored = records.find((r) => r.id === hostRecord.id); + expect(stored?.fields[oneManyLink.id]).toEqual([{ id: target.id, title: 'x' }]); + + // Change the shared foreign primary value + await ctx.updateRecord(foreign.id, target.id, { [foreignPrimaryFieldId]: 'y' }); + await ctx.testContainer.processOutbox(); + await ctx.testContainer.processOutbox(); + + records = await ctx.listRecords(host.id); + stored = records.find((r) => r.id === hostRecord.id); + expect(stored?.fields[oneManyLink.id]).toEqual([{ id: target.id, title: 'y' }]); + expect(stored?.fields[lookupOneMany.id]).toEqual(['y']); + expect(stored?.fields[rollupOneMany.id]).toEqual(1); + // The unrelated manyOne link stays empty: lookup reads back empty, rollup counts 0 + expect(stored?.fields[lookupManyOne.id] ?? undefined).toBeUndefined(); + expect(stored?.fields[rollupManyOne.id]).toEqual(0); + }); +}); diff --git a/packages/v2/e2e/src/listRecords-group.e2e.spec.ts b/packages/v2/e2e/src/listRecords-group.e2e.spec.ts new file mode 100644 index 0000000000..4266d09d21 --- /dev/null +++ b/packages/v2/e2e/src/listRecords-group.e2e.spec.ts @@ -0,0 +1,743 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { listTableRecordsOkResponseSchema } from '@teable/v2-contract-http'; +import { createV2HttpClient } from '@teable/v2-contract-http-client'; +import { FieldKeyType } from '@teable/v2-core'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +/** + * v1-parity listRecords groupBy coverage. + * + * v1 reference: apps/nestjs-backend/test/group.e2e-spec.ts + * + * v2 surface notes: + * - `groupBy` is an array of field keys; the direction for a grouped field is + * taken from the matching `sort` entry (default asc). This mirrors v1's + * OpenAPI behaviour of folding groupBy into the sort chain. + * - The v2 HTTP listRecords response exposes native `groups` metadata through + * an explicit opt-in instead of copying v1's presentation-oriented + * `extra.groupPoints` shape. + * + * Not ported (v1 cases with no v2 HTTP surface): + * - v1 groupPoints/header ids (the v2 contract exposes value/count buckets). + * - view group PUT round-trip → covered by viewOperations.e2e.spec.ts. + */ +describe('v2 listRecords groupBy (e2e)', () => { + let ctx: SharedTestContext; + let client: ReturnType; + + const drainOutbox = async (rounds = 10) => { + for (let i = 0; i < rounds; i += 1) { + const drained = await ctx.testContainer.processOutbox(); + if (drained === 0) break; + } + }; + + const listOrdered = async ( + tableId: string, + options: { + sort?: Array<{ fieldId: string; order: 'asc' | 'desc' }>; + groupBy?: string[]; + viewId?: string; + } = {} + ) => { + await drainOutbox(); + + const params = new URLSearchParams({ tableId, fieldKeyType: FieldKeyType.Id }); + if (options.sort) params.set('sort', JSON.stringify(options.sort)); + if (options.groupBy) params.set('groupBy', JSON.stringify(options.groupBy)); + if (options.viewId) params.set('viewId', options.viewId); + + const response = await fetch(`${ctx.baseUrl}/tables/listRecords?${params.toString()}`, { + method: 'GET', + headers: { 'content-type': 'application/json' }, + }); + const rawBody = await response.json(); + if (response.status !== 200) { + throw new Error(`ListRecords failed: ${JSON.stringify(rawBody)}`); + } + const parsed = listTableRecordsOkResponseSchema.safeParse(rawBody); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) { + throw new Error(`ListRecords response invalid: ${JSON.stringify(rawBody)}`); + } + return parsed.data.data.records; + }; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + client = createV2HttpClient({ baseUrl: ctx.baseUrl }); + }, 60000); + + // ------------------------------------------------------------------ + // Single select grouping respects choice order + // v1: "Single select grouping respects choice order" + // ------------------------------------------------------------------ + describe('single select grouping respects choice order', () => { + let tableId: string; + let itemFieldId: string; + let statusFieldId: string; + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Group Single Select Order', + fields: [ + { name: 'Item', type: 'singleLineText', isPrimary: true }, + { + name: 'Stock Status', + type: 'singleSelect', + options: { + choices: [ + { id: 'choice-0', name: 'Out of stock', color: 'red' }, + { id: 'choice-1', name: 'In stock', color: 'green' }, + { id: 'choice-2', name: 'Backordered', color: 'blue' }, + ], + }, + }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + itemFieldId = table.fields.find((f) => f.name === 'Item')?.id ?? ''; + statusFieldId = table.fields.find((f) => f.name === 'Stock Status')?.id ?? ''; + + // Deliberately insert out of choice order to prove grouping reorders. + await ctx.createRecords(tableId, [ + { fields: { [itemFieldId]: 'record-in-1', [statusFieldId]: 'In stock' } }, + { fields: { [itemFieldId]: 'record-back-1', [statusFieldId]: 'Backordered' } }, + { fields: { [itemFieldId]: 'record-out-1', [statusFieldId]: 'Out of stock' } }, + { fields: { [itemFieldId]: 'record-out-2', [statusFieldId]: 'Out of stock' } }, + ]); + }, 60000); + + it('orders groups by choice order when ascending', async () => { + const records = await listOrdered(tableId, { groupBy: [statusFieldId] }); + expect(records.map((record) => record.fields[statusFieldId])).toEqual([ + 'Out of stock', + 'Out of stock', + 'In stock', + 'Backordered', + ]); + expect(records.map((record) => record.fields[itemFieldId])).toEqual([ + 'record-out-1', + 'record-out-2', + 'record-in-1', + 'record-back-1', + ]); + }); + + it('orders groups by reversed choice order when descending', async () => { + const records = await listOrdered(tableId, { + groupBy: [statusFieldId], + sort: [{ fieldId: statusFieldId, order: 'desc' }], + }); + expect(records.map((record) => record.fields[statusFieldId])).toEqual([ + 'Backordered', + 'In stock', + 'Out of stock', + 'Out of stock', + ]); + }); + }); + + // ------------------------------------------------------------------ + // Base cellValueType grouping + // v1: "OpenAPI ViewController raw group (e2e) base cellValueType" + // ------------------------------------------------------------------ + describe('base cell value type grouping', () => { + let tableId: string; + let nameFieldId: string; + let textFieldId: string; + let numberFieldId: string; + let dateFieldId: string; + let checkFieldId: string; + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Group Base Types', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Text', type: 'singleLineText' }, + { name: 'Number', type: 'number' }, + { name: 'Date', type: 'date' }, + { name: 'Check', type: 'checkbox' }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + nameFieldId = table.fields.find((f) => f.name === 'Name')?.id ?? ''; + textFieldId = table.fields.find((f) => f.name === 'Text')?.id ?? ''; + numberFieldId = table.fields.find((f) => f.name === 'Number')?.id ?? ''; + dateFieldId = table.fields.find((f) => f.name === 'Date')?.id ?? ''; + checkFieldId = table.fields.find((f) => f.name === 'Check')?.id ?? ''; + + await ctx.createRecords(tableId, [ + { + fields: { + [nameFieldId]: 'g1', + [textFieldId]: 'Beta', + [numberFieldId]: 20, + [dateFieldId]: '2024-02-20T12:00:00.000Z', + [checkFieldId]: true, + }, + }, + { + fields: { + [nameFieldId]: 'g2', + [textFieldId]: 'Alpha', + [numberFieldId]: 10, + [dateFieldId]: '2024-01-10T12:00:00.000Z', + [checkFieldId]: null, + }, + }, + { + fields: { + [nameFieldId]: 'g3', + [textFieldId]: 'Alpha', + [numberFieldId]: 30, + [dateFieldId]: '2024-03-15T12:00:00.000Z', + [checkFieldId]: true, + }, + }, + { fields: { [nameFieldId]: 'g4' } }, + ]); + }, 60000); + + it.each([ + { + label: 'string', + getFieldId: () => textFieldId, + // Alpha group (g2, g3 by auto number), Beta group, nulls first. + asc: ['g4', 'g2', 'g3', 'g1'], + desc: ['g1', 'g2', 'g3', 'g4'], + }, + { + label: 'number', + getFieldId: () => numberFieldId, + asc: ['g4', 'g2', 'g1', 'g3'], + desc: ['g3', 'g1', 'g2', 'g4'], + }, + { + label: 'dateTime', + getFieldId: () => dateFieldId, + asc: ['g4', 'g2', 'g1', 'g3'], + desc: ['g3', 'g1', 'g2', 'g4'], + }, + { + label: 'boolean', + getFieldId: () => checkFieldId, + // Unchecked cells are stored as null (T6520): g2/g4 form the null group. + asc: ['g2', 'g4', 'g1', 'g3'], + desc: ['g1', 'g3', 'g2', 'g4'], + }, + ])( + 'groups by $label cell value type in asc and desc order', + async ({ getFieldId, asc, desc }) => { + const fieldId = getFieldId(); + const ascRecords = await listOrdered(tableId, { groupBy: [fieldId] }); + expect(ascRecords.map((record) => record.fields[nameFieldId])).toEqual(asc); + + const descRecords = await listOrdered(tableId, { + groupBy: [fieldId], + sort: [{ fieldId, order: 'desc' }], + }); + expect(descRecords.map((record) => record.fields[nameFieldId])).toEqual(desc); + } + ); + + it('applies the sort inside groups when groupBy and sort target different fields', async () => { + // Group by text asc, sort number desc within each group. + const records = await listOrdered(tableId, { + groupBy: [textFieldId], + sort: [{ fieldId: numberFieldId, order: 'desc' }], + }); + expect(records.map((record) => record.fields[nameFieldId])).toEqual(['g4', 'g3', 'g2', 'g1']); + }); + }); + + // ------------------------------------------------------------------ + // Lookup single select respects choice order when sorting groups + // v1: "Lookup single select respects choice order when sorting groups" + // ------------------------------------------------------------------ + describe('lookup single select grouping respects choice order', () => { + let targetTableId: string; + let taskFieldId: string; + let categoryLookupFieldId: string; + + beforeAll(async () => { + // Choice order deliberately opposite to alphabetical. + const sourceTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Group Lookup Choice Source', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { + name: 'Category', + type: 'singleSelect', + options: { + choices: [ + { id: 'choice-0', name: 'Z-Type', color: 'blue' }, + { id: 'choice-1', name: 'A-Type', color: 'blue' }, + ], + }, + }, + ], + views: [{ type: 'grid' }], + }); + const sourceNameFieldId = sourceTable.fields.find((f) => f.isPrimary)?.id ?? ''; + const sourceCategoryFieldId = sourceTable.fields.find((f) => f.name === 'Category')?.id ?? ''; + const itemA = await ctx.createRecord(sourceTable.id, { + [sourceNameFieldId]: 'Item-A', + [sourceCategoryFieldId]: 'Z-Type', + }); + const itemB = await ctx.createRecord(sourceTable.id, { + [sourceNameFieldId]: 'Item-B', + [sourceCategoryFieldId]: 'A-Type', + }); + + const targetTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Group Lookup Choice Target', + fields: [ + { name: 'Task', type: 'singleLineText', isPrimary: true }, + { + name: 'Link', + type: 'link', + options: { + relationship: 'manyMany', + foreignTableId: sourceTable.id, + lookupFieldId: sourceNameFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + targetTableId = targetTable.id; + taskFieldId = targetTable.fields.find((f) => f.name === 'Task')?.id ?? ''; + const linkFieldId = targetTable.fields.find((f) => f.name === 'Link')?.id ?? ''; + + const withLookup = await ctx.createField({ + baseId: ctx.baseId, + tableId: targetTableId, + field: { + type: 'lookup', + name: 'Category', + options: { + foreignTableId: sourceTable.id, + lookupFieldId: sourceCategoryFieldId, + linkFieldId, + }, + }, + }); + categoryLookupFieldId = withLookup.fields.find((f) => f.name === 'Category')?.id ?? ''; + + // Link in reverse order so ordering must come from choice order. + await ctx.createRecords(targetTableId, [ + { fields: { [taskFieldId]: 'Task-B-Second', [linkFieldId]: [{ id: itemB.id }] } }, + { fields: { [taskFieldId]: 'Task-A-First', [linkFieldId]: [{ id: itemA.id }] } }, + ]); + }, 120000); + + it('sorts grouped records by the lookup choice order', async () => { + const records = await listOrdered(targetTableId, { groupBy: [categoryLookupFieldId] }); + expect(records.map((record) => record.fields[taskFieldId])).toEqual([ + 'Task-A-First', + 'Task-B-Second', + ]); + }); + }); + + // ------------------------------------------------------------------ + // Lookup multiple select respects choice order (first choice) + // v1: "Lookup multiple select respects choice order when sorting groups" + // ------------------------------------------------------------------ + describe('lookup multiple select grouping by first choice', () => { + let targetTableId: string; + let taskFieldId: string; + let tagsLookupFieldId: string; + + beforeAll(async () => { + const choiceOrder = ['Option-One', 'Option-Two', 'Option-Three']; + const sourceTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Group Lookup Multi Source', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { + name: 'Tags', + type: 'multipleSelect', + options: { + choices: choiceOrder.map((name, index) => ({ + id: `choice-${index}`, + name, + color: 'blue', + })), + }, + }, + ], + views: [{ type: 'grid' }], + }); + const sourceNameFieldId = sourceTable.fields.find((f) => f.isPrimary)?.id ?? ''; + const sourceTagsFieldId = sourceTable.fields.find((f) => f.name === 'Tags')?.id ?? ''; + const src1 = await ctx.createRecord(sourceTable.id, { + [sourceNameFieldId]: 'SRC-1', + [sourceTagsFieldId]: ['Option-Two', 'Option-One'], // first Option-Two + }); + const src2 = await ctx.createRecord(sourceTable.id, { + [sourceNameFieldId]: 'SRC-2', + [sourceTagsFieldId]: ['Option-One', 'Option-Three'], // first Option-One + }); + const src3 = await ctx.createRecord(sourceTable.id, { + [sourceNameFieldId]: 'SRC-3', + [sourceTagsFieldId]: ['Option-Three'], // first Option-Three + }); + + const targetTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Group Lookup Multi Target', + fields: [ + { name: 'Task', type: 'singleLineText', isPrimary: true }, + { + name: 'Link', + type: 'link', + options: { + relationship: 'manyMany', + foreignTableId: sourceTable.id, + lookupFieldId: sourceNameFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + targetTableId = targetTable.id; + taskFieldId = targetTable.fields.find((f) => f.name === 'Task')?.id ?? ''; + const linkFieldId = targetTable.fields.find((f) => f.name === 'Link')?.id ?? ''; + + const withLookup = await ctx.createField({ + baseId: ctx.baseId, + tableId: targetTableId, + field: { + type: 'lookup', + name: 'Tags', + options: { + foreignTableId: sourceTable.id, + lookupFieldId: sourceTagsFieldId, + linkFieldId, + }, + }, + }); + tagsLookupFieldId = withLookup.fields.find((f) => f.name === 'Tags')?.id ?? ''; + + await ctx.createRecords(targetTableId, [ + { fields: { [taskFieldId]: 'Task-TwoAndOne', [linkFieldId]: [{ id: src1.id }] } }, + { fields: { [taskFieldId]: 'Task-OneAndThree', [linkFieldId]: [{ id: src2.id }] } }, + { fields: { [taskFieldId]: 'Task-ThreeSolo', [linkFieldId]: [{ id: src3.id }] } }, + ]); + }, 120000); + + it('sorts lookup multiple select groups by choice order using the first choice', async () => { + const records = await listOrdered(targetTableId, { groupBy: [tagsLookupFieldId] }); + expect(records.map((record) => record.fields[taskFieldId])).toEqual([ + 'Task-OneAndThree', + 'Task-TwoAndOne', + 'Task-ThreeSolo', + ]); + }); + }); + + // ------------------------------------------------------------------ + // Two-level grouping: lookup single select then lookup text + // v1: "Lookup grouping keeps headers aligned" + // ------------------------------------------------------------------ + describe('two-level lookup grouping', () => { + let taskTableId: string; + let taskNameFieldId: string; + let categoryLookupFieldId: string; + let subjectLookupFieldId: string; + + beforeAll(async () => { + const categoryChoices = ['Teaching Contest', 'Faculty Contest', 'World Skills', 'Other']; + const projectDefinitions = [ + { name: 'Ethics Deck', category: 'Teaching Contest', subject: 'Ethics & Law' }, + { name: 'Culinary Basics', category: 'Faculty Contest', subject: 'Chinese Cuisine' }, + { name: 'Vision Health', category: 'World Skills', subject: 'Optometry' }, + { name: 'VR Deck A', category: 'Other', subject: 'VR Banking English' }, + { name: 'VR Deck B', category: 'Other', subject: 'VR Banking English - Final' }, + ]; + + const projectTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Group Lookup Projects', + fields: [ + { name: 'Project Name', type: 'singleLineText', isPrimary: true }, + { + name: 'Category', + type: 'singleSelect', + options: { + choices: categoryChoices.map((name, index) => ({ + id: `choice-${index}`, + name, + color: 'blue', + })), + }, + }, + { name: 'Subject', type: 'singleLineText' }, + ], + views: [{ type: 'grid' }], + }); + const projectNameFieldId = projectTable.fields.find((f) => f.isPrimary)?.id ?? ''; + const projectCategoryFieldId = + projectTable.fields.find((f) => f.name === 'Category')?.id ?? ''; + const projectSubjectFieldId = projectTable.fields.find((f) => f.name === 'Subject')?.id ?? ''; + + const projectRecords: Array<{ id: string }> = []; + for (const definition of projectDefinitions) { + projectRecords.push( + await ctx.createRecord(projectTable.id, { + [projectNameFieldId]: definition.name, + [projectCategoryFieldId]: definition.category, + [projectSubjectFieldId]: definition.subject, + }) + ); + } + + const taskTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Group Lookup Tasks', + fields: [ + { name: 'Task Name', type: 'singleLineText', isPrimary: true }, + { + name: 'Linked Project', + type: 'link', + options: { + relationship: 'manyMany', + foreignTableId: projectTable.id, + lookupFieldId: projectNameFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + taskTableId = taskTable.id; + taskNameFieldId = taskTable.fields.find((f) => f.name === 'Task Name')?.id ?? ''; + const linkFieldId = taskTable.fields.find((f) => f.name === 'Linked Project')?.id ?? ''; + + const withCategoryLookup = await ctx.createField({ + baseId: ctx.baseId, + tableId: taskTableId, + field: { + type: 'lookup', + name: 'Category', + options: { + foreignTableId: projectTable.id, + lookupFieldId: projectCategoryFieldId, + linkFieldId, + }, + }, + }); + categoryLookupFieldId = + withCategoryLookup.fields.find((f) => f.name === 'Category')?.id ?? ''; + + const withSubjectLookup = await ctx.createField({ + baseId: ctx.baseId, + tableId: taskTableId, + field: { + type: 'lookup', + name: 'Subject', + options: { + foreignTableId: projectTable.id, + lookupFieldId: projectSubjectFieldId, + linkFieldId, + }, + }, + }); + subjectLookupFieldId = withSubjectLookup.fields.find((f) => f.name === 'Subject')?.id ?? ''; + + // Insert tasks in reverse project order so grouping must reorder them. + const reversed = [...projectDefinitions.keys()].reverse(); + await ctx.createRecords( + taskTableId, + reversed.map((index) => ({ + fields: { + [taskNameFieldId]: `Task-${index + 1}-${projectDefinitions[index].name}`, + [linkFieldId]: [{ id: projectRecords[index].id }], + }, + })) + ); + }, 120000); + + it('groups by lookup single select then lookup text in expected order', async () => { + const records = await listOrdered(taskTableId, { + groupBy: [categoryLookupFieldId, subjectLookupFieldId], + }); + expect(records.map((record) => record.fields[taskNameFieldId])).toEqual([ + 'Task-1-Ethics Deck', + 'Task-2-Culinary Basics', + 'Task-3-Vision Health', + 'Task-4-VR Deck A', + 'Task-5-VR Deck B', + ]); + }); + }); + + // ------------------------------------------------------------------ + // Special characters in choice names + // v1: "Single select grouping with special characters in choice names" / + // "Multiple select grouping with special characters in choice names" + // ------------------------------------------------------------------ + describe('grouping with special characters in choice names', () => { + it('groups single select correctly when choice names contain "?"', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Group Special Char Single', + fields: [ + { name: 'Item', type: 'singleLineText', isPrimary: true }, + { + name: 'Status', + type: 'singleSelect', + options: { + choices: [ + { id: 'sc-choice-0', name: 'Pending?', color: 'red' }, + { id: 'sc-choice-1', name: 'Done!', color: 'green' }, + { id: 'sc-choice-2', name: 'N/A', color: 'blue' }, + ], + }, + }, + ], + views: [{ type: 'grid' }], + }); + const itemFieldId = table.fields.find((f) => f.name === 'Item')?.id ?? ''; + const statusFieldId = table.fields.find((f) => f.name === 'Status')?.id ?? ''; + + // Insert in reverse choice order. + await ctx.createRecords(table.id, [ + { fields: { [itemFieldId]: 'r3', [statusFieldId]: 'N/A' } }, + { fields: { [itemFieldId]: 'r2', [statusFieldId]: 'Done!' } }, + { fields: { [itemFieldId]: 'r1', [statusFieldId]: 'Pending?' } }, + ]); + + const records = await listOrdered(table.id, { groupBy: [statusFieldId] }); + expect(records.map((record) => record.fields[statusFieldId])).toEqual([ + 'Pending?', + 'Done!', + 'N/A', + ]); + expect(records.map((record) => record.fields[itemFieldId])).toEqual(['r1', 'r2', 'r3']); + }); + + it('groups multiple select correctly when choice names contain "?"', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Group Special Char Multi', + fields: [ + { name: 'Item', type: 'singleLineText', isPrimary: true }, + { + name: 'Tags', + type: 'multipleSelect', + options: { + choices: [ + { id: 'ms-choice-0', name: 'Alpha?', color: 'red' }, + { id: 'ms-choice-1', name: 'Beta!', color: 'green' }, + { id: 'ms-choice-2', name: 'Gamma', color: 'blue' }, + ], + }, + }, + ], + views: [{ type: 'grid' }], + }); + const itemFieldId = table.fields.find((f) => f.name === 'Item')?.id ?? ''; + const tagsFieldId = table.fields.find((f) => f.name === 'Tags')?.id ?? ''; + + await ctx.createRecords(table.id, [ + { fields: { [itemFieldId]: 'r3', [tagsFieldId]: ['Gamma'] } }, + { fields: { [itemFieldId]: 'r2', [tagsFieldId]: ['Beta!'] } }, + { fields: { [itemFieldId]: 'r1', [tagsFieldId]: ['Alpha?'] } }, + ]); + + const records = await listOrdered(table.id, { groupBy: [tagsFieldId] }); + expect(records).toHaveLength(3); + expect(records.map((record) => record.fields[itemFieldId])).toEqual(['r1', 'r2', 'r3']); + }); + }); + + // ------------------------------------------------------------------ + // View default group applies when listing with viewId + // v1: group is a view property consumed by getRecords(viewId) + // ------------------------------------------------------------------ + describe('view default group', () => { + it('applies the view group defaults when listing with viewId only', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Group View Defaults', + fields: [ + { name: 'Item', type: 'singleLineText', isPrimary: true }, + { + name: 'Status', + type: 'singleSelect', + options: { + choices: [ + { id: 'vg-choice-0', name: 'First', color: 'red' }, + { id: 'vg-choice-1', name: 'Second', color: 'green' }, + ], + }, + }, + ], + views: [{ type: 'grid' }], + }); + const viewId = table.views[0]?.id ?? ''; + const itemFieldId = table.fields.find((f) => f.name === 'Item')?.id ?? ''; + const statusFieldId = table.fields.find((f) => f.name === 'Status')?.id ?? ''; + + await ctx.createRecords(table.id, [ + { fields: { [itemFieldId]: 'r1', [statusFieldId]: 'First' } }, + { fields: { [itemFieldId]: 'r2', [statusFieldId]: 'Second' } }, + { fields: { [itemFieldId]: 'r3', [statusFieldId]: 'First' } }, + ]); + + const grouped = await client.tables.updateViewGroup({ + tableId: table.id, + viewId, + group: [{ fieldId: statusFieldId, order: 'desc' }], + }); + expect(grouped.ok).toBe(true); + + const records = await listOrdered(table.id, { viewId }); + expect(records.map((record) => record.fields[itemFieldId])).toEqual(['r2', 'r1', 'r3']); + }); + }); + + // ------------------------------------------------------------------ + // Button field cannot be used in view group + // v1: "should not allow to modify group for button field" + // ------------------------------------------------------------------ + describe('view group validation', () => { + it('rejects updating a view group with a button field', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Group Button Reject', + fields: [{ name: 'Name', type: 'singleLineText', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const withButton = await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { type: 'button', name: 'Push' }, + }); + const buttonFieldId = withButton.fields.find((f) => f.name === 'Push')?.id ?? ''; + expect(buttonFieldId).not.toBe(''); + + await expect( + client.tables.updateViewGroup({ + tableId: table.id, + viewId: table.views[0]?.id ?? '', + group: [{ fieldId: buttonFieldId, order: 'asc' }], + }) + ).rejects.toThrow(/Button/); + }); + }); +}); diff --git a/packages/v2/e2e/src/listRecords-metadata.e2e.spec.ts b/packages/v2/e2e/src/listRecords-metadata.e2e.spec.ts new file mode 100644 index 0000000000..004c274710 --- /dev/null +++ b/packages/v2/e2e/src/listRecords-metadata.e2e.spec.ts @@ -0,0 +1,99 @@ +import { listTableRecordsOkResponseSchema } from '@teable/v2-contract-http'; +import { FieldKeyType } from '@teable/v2-core'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +describe('v2 listRecords metadata contract (e2e)', () => { + let ctx: SharedTestContext; + let tableId: string; + let nameFieldId: string; + let statusFieldId: string; + let recordIds: string[]; + + const listRecords = async (input: Record) => { + for (let i = 0; i < 10; i += 1) { + if ((await ctx.testContainer.processOutbox()) === 0) break; + } + + const params = new URLSearchParams({ + tableId, + fieldKeyType: FieldKeyType.Id, + ...input, + }); + const response = await fetch(`${ctx.baseUrl}/tables/listRecords?${params.toString()}`); + const rawBody = await response.json(); + expect(response.status).toBe(200); + return { rawBody, parsed: listTableRecordsOkResponseSchema.parse(rawBody) }; + }; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'List Records Metadata Contract', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Status', type: 'singleSelect', options: ['Open', 'Closed'] }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + nameFieldId = table.fields.find((field) => field.name === 'Name')?.id ?? ''; + statusFieldId = table.fields.find((field) => field.name === 'Status')?.id ?? ''; + + const records = await ctx.createRecords(tableId, [ + { fields: { [nameFieldId]: 'match first', [statusFieldId]: 'Open' } }, + { fields: { [nameFieldId]: 'middle', [statusFieldId]: 'Closed' } }, + { fields: { [nameFieldId]: 'match last', [statusFieldId]: 'Open' } }, + ]); + recordIds = records.map((record) => record.id); + }, 60000); + + it('returns grouped counts from the full query scope when explicitly requested', async () => { + const { rawBody, parsed } = await listRecords({ + groupBy: JSON.stringify([statusFieldId]), + includeGroups: 'true', + limit: '1', + offset: '1', + }); + + expect(parsed.ok).toBe(true); + expect(rawBody).toMatchObject({ + ok: true, + data: { + groups: [ + { fields: { [statusFieldId]: 'Open' }, count: 2 }, + { fields: { [statusFieldId]: 'Closed' }, count: 1 }, + ], + }, + }); + }); + + it.each([ + { mode: 'matched', expectedIndex: 2 }, + { mode: 'view', expectedIndex: 3 }, + ] as const)('returns $mode search match indexes after pagination', async ({ mode, expectedIndex }) => { + const { rawBody, parsed } = await listRecords({ + search: JSON.stringify(['match', nameFieldId, true]), + includeSearchMatches: 'true', + searchIndexMode: mode, + projection: JSON.stringify([nameFieldId]), + limit: '1', + offset: '1', + }); + + expect(parsed.ok).toBe(true); + expect(rawBody).toMatchObject({ + ok: true, + data: { + searchMatches: [ + { + index: expectedIndex, + fieldId: nameFieldId, + recordId: recordIds[2], + }, + ], + }, + }); + }); +}); diff --git a/packages/v2/e2e/src/listRecords-operator-matrix.e2e.spec.ts b/packages/v2/e2e/src/listRecords-operator-matrix.e2e.spec.ts new file mode 100644 index 0000000000..0827341709 --- /dev/null +++ b/packages/v2/e2e/src/listRecords-operator-matrix.e2e.spec.ts @@ -0,0 +1,1432 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { listTableRecordsOkResponseSchema } from '@teable/v2-contract-http'; +import { FieldKeyType } from '@teable/v2-core'; +import { sql } from 'kysely'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +/** + * v1-parity operator × field-type matrix for listRecords filters. + * + * v1 references: + * - apps/nestjs-backend/test/comprehensive-field-filter.e2e-spec.ts + * - apps/nestjs-backend/test/record-filter-query-issues.e2e-spec.ts (T1781, T3109) + * - apps/nestjs-backend/test/data-helpers/caces/record-filter-query/* + * + * Semantics under test (T6520): cells cleared with ""/false/[] are stored as + * null, negative operators (isNot/doesNotContain/isNoneOf/hasNoneOf/ + * isNotExactly) include null rows, and isEmpty matches cleared cells. + * + * DateRange edge cases are covered in record-filter-is-with-in.e2e.spec.ts. + * This file also ports the deterministic v1 date and date-lookup mode matrices + * with a frozen clock and an isolated fixture. + */ +describe('v2 listRecords filter operator matrix (e2e)', () => { + let ctx: SharedTestContext; + + const drainOutbox = async (rounds = 10) => { + for (let i = 0; i < rounds; i += 1) { + const drained = await ctx.testContainer.processOutbox(); + if (drained === 0) break; + } + }; + + const listWithFilter = async (tableId: string, filter: unknown) => { + await drainOutbox(); + + const params = new URLSearchParams({ + tableId, + fieldKeyType: FieldKeyType.Id, + filter: JSON.stringify(filter), + }); + + const response = await fetch(`${ctx.baseUrl}/tables/listRecords?${params.toString()}`, { + method: 'GET', + headers: { 'content-type': 'application/json' }, + }); + + const rawBody = await response.json(); + if (response.status !== 200) { + throw new Error(`ListRecords failed: ${JSON.stringify(rawBody)}`); + } + + const parsed = listTableRecordsOkResponseSchema.safeParse(rawBody); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) { + throw new Error(`ListRecords response invalid: ${JSON.stringify(rawBody)}`); + } + + return parsed.data.data.records; + }; + + const expectFilterCount = async (tableId: string, filter: unknown, expected: number) => { + const records = await listWithFilter(tableId, filter); + expect(records).toHaveLength(expected); + return records; + }; + + const expectFilterNames = async ( + tableId: string, + filter: unknown, + nameFieldId: string, + expectedNames: string[] + ) => { + const records = await listWithFilter(tableId, filter); + expect(records.map((record) => String(record.fields[nameFieldId])).sort()).toEqual( + [...expectedNames].sort() + ); + }; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + }, 60000); + + // ------------------------------------------------------------------ + // Text (singleLineText + longText) + // v1: Text Field Filters / Long Text Field Filters / TEXT_FIELD_CASES + // ------------------------------------------------------------------ + describe('text field operators', () => { + let tableId: string; + let textFieldId: string; + let longTextFieldId: string; + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Filter Matrix Text', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Text', type: 'singleLineText' }, + { name: 'Long', type: 'longText' }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + textFieldId = table.fields.find((f) => f.name === 'Text')?.id ?? ''; + longTextFieldId = table.fields.find((f) => f.name === 'Long')?.id ?? ''; + + await ctx.createRecords(tableId, [ + { + fields: { + [textFieldId]: 'Test Text 1', + [longTextFieldId]: 'This is a long text content for testing', + }, + }, + { + fields: { + [textFieldId]: 'Test Text 2', + [longTextFieldId]: 'Another long text for testing purposes', + }, + }, + { fields: {} }, + ]); + }, 60000); + + it('is matches an exact value', async () => { + await expectFilterCount( + tableId, + { fieldId: textFieldId, operator: 'is', value: 'Test Text 1' }, + 1 + ); + }); + + it('is is case-sensitive (v1 TEXT_FIELD_CASES lower-case probe)', async () => { + await expectFilterCount( + tableId, + { fieldId: textFieldId, operator: 'is', value: 'test text 1' }, + 0 + ); + }); + + it('isNot excludes the value and keeps null rows', async () => { + await expectFilterCount( + tableId, + { fieldId: textFieldId, operator: 'isNot', value: 'Test Text 1' }, + 2 + ); + }); + + it('contains matches substrings', async () => { + await expectFilterCount( + tableId, + { fieldId: textFieldId, operator: 'contains', value: 'Test' }, + 2 + ); + }); + + it('contains is case-insensitive', async () => { + await expectFilterCount( + tableId, + { fieldId: textFieldId, operator: 'contains', value: 'test' }, + 2 + ); + }); + + it('doesNotContain keeps null rows', async () => { + await expectFilterCount( + tableId, + { fieldId: textFieldId, operator: 'doesNotContain', value: 'Test' }, + 1 + ); + }); + + it('isEmpty / isNotEmpty split null and non-null rows', async () => { + await expectFilterCount( + tableId, + { fieldId: textFieldId, operator: 'isEmpty', value: null }, + 1 + ); + await expectFilterCount( + tableId, + { fieldId: textFieldId, operator: 'isNotEmpty', value: null }, + 2 + ); + }); + + it('long text supports contains / doesNotContain / isEmpty / isNotEmpty', async () => { + await expectFilterCount( + tableId, + { fieldId: longTextFieldId, operator: 'contains', value: 'long text' }, + 2 + ); + await expectFilterCount( + tableId, + { fieldId: longTextFieldId, operator: 'doesNotContain', value: 'testing' }, + 1 + ); + await expectFilterCount( + tableId, + { fieldId: longTextFieldId, operator: 'isEmpty', value: null }, + 1 + ); + await expectFilterCount( + tableId, + { fieldId: longTextFieldId, operator: 'isNotEmpty', value: null }, + 2 + ); + }); + }); + + // ------------------------------------------------------------------ + // T1781: SQL LIKE wildcards must be escaped in contains filters + // ------------------------------------------------------------------ + describe('T1781 SQL LIKE wildcard escape', () => { + let tableId: string; + let textFieldId: string; + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Filter Matrix Like Wildcards', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Text', type: 'singleLineText' }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + textFieldId = table.fields.find((f) => f.name === 'Text')?.id ?? ''; + + const values = [ + 'Contains % percent sign', + 'Contains _ underscore', + 'Contains \\ backslash', + 'Normal text', + '100%', + '50%', + 'file_name.txt', + 'path\\to\\file', + '%_%', + null, + ]; + await ctx.createRecords( + tableId, + values.map((value) => ({ fields: value === null ? {} : { [textFieldId]: value } })) + ); + }, 60000); + + it.each([ + { op: 'contains', value: '%', expected: 4 }, + { op: 'contains', value: '_', expected: 3 }, + { op: 'contains', value: '\\', expected: 2 }, + { op: 'contains', value: '%_%', expected: 1 }, + { op: 'contains', value: '0%', expected: 2 }, + { op: 'doesNotContain', value: '%', expected: 6 }, + { op: 'doesNotContain', value: '_', expected: 7 }, + ])('$op "$value" -> $expected records', async ({ op, value, expected }) => { + await expectFilterCount(tableId, { fieldId: textFieldId, operator: op, value }, expected); + }); + }); + + // ------------------------------------------------------------------ + // Number + // v1: Number Field Filters / NUMBER_FIELD_CASES + // ------------------------------------------------------------------ + describe('number field operators', () => { + let tableId: string; + let nameFieldId: string; + let numberFieldId: string; + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Filter Matrix Number', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Number', type: 'number' }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + nameFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + numberFieldId = table.fields.find((f) => f.name === 'Number')?.id ?? ''; + + await ctx.createRecords(tableId, [ + { fields: { [nameFieldId]: 'Low', [numberFieldId]: 10.5 } }, + { fields: { [nameFieldId]: 'High', [numberFieldId]: 25.75 } }, + { fields: { [nameFieldId]: 'Empty' } }, + ]); + }, 60000); + + it.each([ + { op: 'is', value: 10.5, expected: 1 }, + { op: 'isNot', value: 10.5, expected: 2 }, + { op: 'isGreater', value: 20, expected: 1 }, + { op: 'isGreaterEqual', value: 10.5, expected: 2 }, + { op: 'isLess', value: 20, expected: 1 }, + { op: 'isLessEqual', value: 25.75, expected: 2 }, + { op: 'isEmpty', value: null, expected: 1 }, + { op: 'isNotEmpty', value: null, expected: 2 }, + ])('$op $value -> $expected records', async ({ op, value, expected }) => { + await expectFilterCount(tableId, { fieldId: numberFieldId, operator: op, value }, expected); + }); + + it('distinguishes greater-than from less-than results', async () => { + await expectFilterNames( + tableId, + { fieldId: numberFieldId, operator: 'isGreater', value: 20 }, + nameFieldId, + ['High'] + ); + await expectFilterNames( + tableId, + { fieldId: numberFieldId, operator: 'isLess', value: 20 }, + nameFieldId, + ['Low'] + ); + }); + }); + + // ------------------------------------------------------------------ + // Rating (number cell value type) + // v1: Rating Field Filters + // ------------------------------------------------------------------ + describe('rating field operators', () => { + let tableId: string; + let nameFieldId: string; + let ratingFieldId: string; + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Filter Matrix Rating', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { + name: 'Rating', + type: 'rating', + options: { max: 5, icon: 'star', color: 'yellowBright' }, + }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + nameFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + ratingFieldId = table.fields.find((f) => f.name === 'Rating')?.id ?? ''; + + await ctx.createRecords(tableId, [ + { fields: { [nameFieldId]: 'Four', [ratingFieldId]: 4 } }, + { fields: { [nameFieldId]: 'Three', [ratingFieldId]: 3 } }, + { fields: { [nameFieldId]: 'Empty' } }, + ]); + }, 60000); + + it.each([ + { op: 'is', value: 4, expected: 1 }, + { op: 'isNot', value: 4, expected: 2 }, + { op: 'isGreater', value: 3, expected: 1 }, + { op: 'isGreaterEqual', value: 3, expected: 2 }, + { op: 'isLess', value: 4, expected: 1 }, + { op: 'isLessEqual', value: 4, expected: 2 }, + { op: 'isEmpty', value: null, expected: 1 }, + { op: 'isNotEmpty', value: null, expected: 2 }, + ])('$op $value -> $expected records', async ({ op, value, expected }) => { + await expectFilterCount(tableId, { fieldId: ratingFieldId, operator: op, value }, expected); + }); + + it('distinguishes greater-than from less-than results', async () => { + await expectFilterNames( + tableId, + { fieldId: ratingFieldId, operator: 'isGreater', value: 3 }, + nameFieldId, + ['Four'] + ); + await expectFilterNames( + tableId, + { fieldId: ratingFieldId, operator: 'isLess', value: 4 }, + nameFieldId, + ['Three'] + ); + }); + }); + + // ------------------------------------------------------------------ + // Date — exactDate mode + // v1: Date Field Filters (comprehensive-field-filter) + // ------------------------------------------------------------------ + describe('date field operators with exactDate mode', () => { + let tableId: string; + let nameFieldId: string; + let dateFieldId: string; + + const exact = (isoDate: string) => ({ + mode: 'exactDate', + exactDate: isoDate, + timeZone: 'UTC', + }); + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Filter Matrix Date Exact', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Date', type: 'date' }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + nameFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + dateFieldId = table.fields.find((f) => f.name === 'Date')?.id ?? ''; + + await ctx.createRecords(tableId, [ + { + fields: { [nameFieldId]: 'Jan', [dateFieldId]: '2024-01-15T00:00:00.000Z' }, + }, + { + fields: { [nameFieldId]: 'Feb', [dateFieldId]: '2024-02-20T00:00:00.000Z' }, + }, + { fields: { [nameFieldId]: 'Empty' } }, + ]); + }, 60000); + + it.each([ + { op: 'is', date: '2024-01-15T00:00:00.000Z', expected: 1 }, + { op: 'isNot', date: '2024-01-15T00:00:00.000Z', expected: 2 }, + { op: 'isAfter', date: '2024-01-31T00:00:00.000Z', expected: 1 }, + { op: 'isBefore', date: '2024-02-01T00:00:00.000Z', expected: 1 }, + { op: 'isOnOrAfter', date: '2024-01-15T00:00:00.000Z', expected: 2 }, + { op: 'isOnOrBefore', date: '2024-02-20T00:00:00.000Z', expected: 2 }, + ])('$op exactDate $date -> $expected records', async ({ op, date, expected }) => { + await expectFilterCount( + tableId, + { fieldId: dateFieldId, operator: op, value: exact(date) }, + expected + ); + }); + + it('isEmpty / isNotEmpty split null and dated rows', async () => { + await expectFilterCount( + tableId, + { fieldId: dateFieldId, operator: 'isEmpty', value: null }, + 1 + ); + await expectFilterCount( + tableId, + { fieldId: dateFieldId, operator: 'isNotEmpty', value: null }, + 2 + ); + }); + + it('distinguishes dates before and after the boundary', async () => { + await expectFilterNames( + tableId, + { fieldId: dateFieldId, operator: 'isAfter', value: exact('2024-01-31T00:00:00.000Z') }, + nameFieldId, + ['Feb'] + ); + await expectFilterNames( + tableId, + { fieldId: dateFieldId, operator: 'isBefore', value: exact('2024-02-01T00:00:00.000Z') }, + nameFieldId, + ['Jan'] + ); + }); + }); + + // ------------------------------------------------------------------ + // Date — deterministic v1 date and date-lookup mode matrices + // v1: DATE_FIELD_CASES / DATE_LOOKUP_FIELD_CASES + // ------------------------------------------------------------------ + describe('date field and lookup operators with relative modes', () => { + type DateMatrixOperator = + | 'is' + | 'isNot' + | 'isBefore' + | 'isAfter' + | 'isOnOrBefore' + | 'isOnOrAfter' + | 'isWithIn'; + + type DateRow = { + name: string; + days: string[]; + }; + + type DateModeCase = { + mode: string; + startDay: string; + endDay: string; + field: 'date' | 'month'; + numberOfDays?: number; + exactDate?: string; + }; + + const timeZone = 'Asia/Singapore'; + const localNoonIso = (day: string) => `${day}T04:00:00.000Z`; + const directRows: DateRow[] = [ + { name: 'LastYearStart', days: ['2025-01-15'] }, + { name: 'LastYearSameDay', days: ['2025-06-15'] }, + { name: 'CurrentYearStart', days: ['2026-01-15'] }, + { name: 'LastMonthStart', days: ['2026-05-01'] }, + { name: 'OneMonthAgo', days: ['2026-05-15'] }, + { name: 'LastWeekStart', days: ['2026-06-08'] }, + { name: 'Yesterday', days: ['2026-06-14'] }, + { name: 'Today', days: ['2026-06-15'] }, + { name: 'Tomorrow', days: ['2026-06-16'] }, + { name: 'CurrentWeekEnd', days: ['2026-06-21'] }, + { name: 'OneWeekFromNow', days: ['2026-06-22'] }, + { name: 'NextWeekEnd', days: ['2026-06-28'] }, + { name: 'CurrentMonthEnd', days: ['2026-06-30'] }, + { name: 'NextMonthStart', days: ['2026-07-01'] }, + { name: 'OneMonthFromNow', days: ['2026-07-15'] }, + { name: 'NextMonthEnd', days: ['2026-07-31'] }, + { name: 'CurrentYearEnd', days: ['2026-12-31'] }, + { name: 'NextYearStart', days: ['2027-01-01'] }, + { name: 'NextYearSameDay', days: ['2027-06-15'] }, + { name: 'NextYearEnd', days: ['2027-12-31'] }, + { name: 'FutureYear', days: ['2028-01-01'] }, + { name: 'NoDate', days: [] }, + ]; + const lookupRows: DateRow[] = [ + ...directRows, + { + name: 'MixedPeriods', + days: ['2025-06-15', '2026-06-15', '2027-06-15'], + }, + ]; + + const dateModeCases: DateModeCase[] = [ + { mode: 'today', startDay: '2026-06-15', endDay: '2026-06-15', field: 'date' }, + { mode: 'tomorrow', startDay: '2026-06-16', endDay: '2026-06-16', field: 'date' }, + { mode: 'yesterday', startDay: '2026-06-14', endDay: '2026-06-14', field: 'date' }, + { mode: 'currentWeek', startDay: '2026-06-15', endDay: '2026-06-21', field: 'date' }, + { mode: 'lastWeek', startDay: '2026-06-08', endDay: '2026-06-14', field: 'date' }, + { mode: 'nextWeekPeriod', startDay: '2026-06-22', endDay: '2026-06-28', field: 'date' }, + { mode: 'currentMonth', startDay: '2026-06-01', endDay: '2026-06-30', field: 'date' }, + { mode: 'lastMonth', startDay: '2026-05-01', endDay: '2026-05-31', field: 'date' }, + { mode: 'nextMonthPeriod', startDay: '2026-07-01', endDay: '2026-07-31', field: 'date' }, + { mode: 'currentYear', startDay: '2026-01-01', endDay: '2026-12-31', field: 'date' }, + { mode: 'lastYear', startDay: '2025-01-01', endDay: '2025-12-31', field: 'date' }, + { mode: 'nextYearPeriod', startDay: '2027-01-01', endDay: '2027-12-31', field: 'date' }, + { mode: 'oneWeekAgo', startDay: '2026-06-08', endDay: '2026-06-08', field: 'date' }, + { mode: 'oneWeekFromNow', startDay: '2026-06-22', endDay: '2026-06-22', field: 'date' }, + { mode: 'oneMonthAgo', startDay: '2026-05-15', endDay: '2026-05-15', field: 'date' }, + { mode: 'oneMonthFromNow', startDay: '2026-07-15', endDay: '2026-07-15', field: 'date' }, + { + mode: 'daysAgo', + numberOfDays: 1, + startDay: '2026-06-14', + endDay: '2026-06-14', + field: 'date', + }, + { + mode: 'daysFromNow', + numberOfDays: 1, + startDay: '2026-06-16', + endDay: '2026-06-16', + field: 'date', + }, + { + mode: 'exactDate', + exactDate: localNoonIso('2026-06-08'), + startDay: '2026-06-08', + endDay: '2026-06-08', + field: 'date', + }, + { + mode: 'exactFormatDate', + exactDate: localNoonIso('2026-05-15'), + startDay: '2026-05-01', + endDay: '2026-05-31', + field: 'month', + }, + ]; + + const withinModeCases: DateModeCase[] = [ + { mode: 'pastWeek', startDay: '2026-06-08', endDay: '2026-06-15', field: 'date' }, + { mode: 'pastMonth', startDay: '2026-05-15', endDay: '2026-06-15', field: 'date' }, + { mode: 'pastYear', startDay: '2025-06-15', endDay: '2026-06-15', field: 'date' }, + { mode: 'nextWeek', startDay: '2026-06-15', endDay: '2026-06-22', field: 'date' }, + { mode: 'nextMonth', startDay: '2026-06-15', endDay: '2026-07-15', field: 'date' }, + { mode: 'nextYear', startDay: '2026-06-15', endDay: '2027-06-15', field: 'date' }, + { + mode: 'pastNumberOfDays', + numberOfDays: 1, + startDay: '2026-06-14', + endDay: '2026-06-15', + field: 'date', + }, + { + mode: 'nextNumberOfDays', + numberOfDays: 1, + startDay: '2026-06-15', + endDay: '2026-06-16', + field: 'date', + }, + ]; + + const comparisonOperators: DateMatrixOperator[] = [ + 'is', + 'isNot', + 'isBefore', + 'isAfter', + 'isOnOrBefore', + 'isOnOrAfter', + ]; + + let sourceTableId: string; + let sourceNameFieldId: string; + let dateFieldId: string; + let monthFieldId: string; + let lookupTableId: string; + let lookupNameFieldId: string; + let lookupDateFieldId: string; + let lookupMonthFieldId: string; + + const valueFor = ({ mode, numberOfDays, exactDate }: DateModeCase) => ({ + mode, + ...(numberOfDays === undefined ? {} : { numberOfDays }), + ...(exactDate === undefined ? {} : { exactDate }), + timeZone, + }); + + const matches = ( + days: string[], + operator: DateMatrixOperator, + { startDay, endDay }: DateModeCase + ) => { + const isWithin = days.some((day) => day >= startDay && day <= endDay); + if (operator === 'is' || operator === 'isWithIn') return isWithin; + if (operator === 'isNot') return !isWithin; + if (operator === 'isBefore') return days.some((day) => day < startDay); + if (operator === 'isAfter') return days.some((day) => day > endDay); + if (operator === 'isOnOrBefore') return days.some((day) => day <= endDay); + return days.some((day) => day >= startDay); + }; + + const expectedNames = (rows: DateRow[], operator: DateMatrixOperator, modeCase: DateModeCase) => + rows.filter((row) => matches(row.days, operator, modeCase)).map((row) => row.name); + + beforeAll(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-06-15T04:00:00.000Z')); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + beforeAll(async () => { + const sourceTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Filter Matrix Date Relative', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { + name: 'Date', + type: 'date', + options: { + formatting: { date: 'YYYY-MM-DD', time: 'None', timeZone }, + }, + }, + { + name: 'Month', + type: 'date', + options: { + formatting: { date: 'YYYY-MM', time: 'None', timeZone }, + }, + }, + ], + views: [{ type: 'grid' }], + }); + sourceTableId = sourceTable.id; + sourceNameFieldId = sourceTable.fields.find((f) => f.isPrimary)?.id ?? ''; + dateFieldId = sourceTable.fields.find((f) => f.name === 'Date')?.id ?? ''; + monthFieldId = sourceTable.fields.find((f) => f.name === 'Month')?.id ?? ''; + + const sourceRecords = await ctx.createRecords( + sourceTableId, + directRows.map((row) => ({ + fields: { + [sourceNameFieldId]: row.name, + ...(row.days[0] + ? { + [dateFieldId]: localNoonIso(row.days[0]), + [monthFieldId]: localNoonIso(row.days[0]), + } + : {}), + }, + })) + ); + const sourceRecordIdsByDay = new Map(); + directRows.forEach((row, index) => { + const day = row.days[0]; + const sourceRecord = sourceRecords[index]; + if (day && sourceRecord) sourceRecordIdsByDay.set(day, sourceRecord.id); + }); + + const lookupTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Filter Matrix Date Lookup Relative', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { + name: 'Link', + type: 'link', + options: { + relationship: 'manyMany', + foreignTableId: sourceTableId, + lookupFieldId: sourceNameFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + lookupTableId = lookupTable.id; + lookupNameFieldId = lookupTable.fields.find((f) => f.isPrimary)?.id ?? ''; + const linkFieldId = lookupTable.fields.find((f) => f.name === 'Link')?.id ?? ''; + + const withDateLookups = await ctx.createField({ + baseId: ctx.baseId, + tableId: lookupTableId, + field: { + type: 'lookup', + name: 'Lookup Date', + options: { + foreignTableId: sourceTableId, + lookupFieldId: dateFieldId, + linkFieldId, + }, + }, + }); + lookupDateFieldId = withDateLookups.fields.find((f) => f.name === 'Lookup Date')?.id ?? ''; + + const withMonthLookup = await ctx.createField({ + baseId: ctx.baseId, + tableId: lookupTableId, + field: { + type: 'lookup', + name: 'Lookup Month', + options: { + foreignTableId: sourceTableId, + lookupFieldId: monthFieldId, + linkFieldId, + }, + }, + }); + lookupMonthFieldId = withMonthLookup.fields.find((f) => f.name === 'Lookup Month')?.id ?? ''; + + await ctx.createRecords( + lookupTableId, + lookupRows.map((row) => ({ + fields: { + [lookupNameFieldId]: row.name, + [linkFieldId]: row.days.map((day) => { + const sourceRecordId = sourceRecordIdsByDay.get(day); + if (!sourceRecordId) throw new Error(`Missing source record for ${day}`); + return { id: sourceRecordId }; + }), + }, + })) + ); + }, 120000); + + const comparisonCases = dateModeCases.flatMap((modeCase) => + comparisonOperators.map((operator) => ({ operator, mode: modeCase.mode, modeCase })) + ); + + it.each(comparisonCases)('direct date: $operator $mode', async ({ operator, modeCase }) => { + const fieldId = modeCase.field === 'month' ? monthFieldId : dateFieldId; + await expectFilterNames( + sourceTableId, + { fieldId, operator, value: valueFor(modeCase) }, + sourceNameFieldId, + expectedNames(directRows, operator, modeCase) + ); + }); + + it.each(withinModeCases)('direct date: isWithIn $mode', async (modeCase) => { + await expectFilterNames( + sourceTableId, + { fieldId: dateFieldId, operator: 'isWithIn', value: valueFor(modeCase) }, + sourceNameFieldId, + expectedNames(directRows, 'isWithIn', modeCase) + ); + }); + + it.each(comparisonCases)('lookup date: $operator $mode', async ({ operator, modeCase }) => { + const fieldId = modeCase.field === 'month' ? lookupMonthFieldId : lookupDateFieldId; + await expectFilterNames( + lookupTableId, + { fieldId, operator, value: valueFor(modeCase) }, + lookupNameFieldId, + expectedNames(lookupRows, operator, modeCase) + ); + }); + + it.each(withinModeCases)('lookup date: isWithIn $mode', async (modeCase) => { + await expectFilterNames( + lookupTableId, + { fieldId: lookupDateFieldId, operator: 'isWithIn', value: valueFor(modeCase) }, + lookupNameFieldId, + expectedNames(lookupRows, 'isWithIn', modeCase) + ); + }); + + it.each([ + { + label: 'direct', + tableId: () => sourceTableId, + fieldId: () => dateFieldId, + rows: directRows, + }, + { + label: 'lookup', + tableId: () => lookupTableId, + fieldId: () => lookupDateFieldId, + rows: lookupRows, + }, + ])('$label date supports isEmpty and isNotEmpty', async ({ tableId, fieldId, rows }) => { + await expectFilterCount( + tableId(), + { fieldId: fieldId(), operator: 'isEmpty', value: null }, + rows.filter((row) => row.days.length === 0).length + ); + await expectFilterCount( + tableId(), + { fieldId: fieldId(), operator: 'isNotEmpty', value: null }, + rows.filter((row) => row.days.length > 0).length + ); + }); + + it('lookup date supports dateRange with any-value matching', async () => { + const modeCase: DateModeCase = { + mode: 'dateRange', + exactDate: localNoonIso('2026-06-14'), + startDay: '2026-06-14', + endDay: '2026-06-16', + field: 'date', + }; + await expectFilterNames( + lookupTableId, + { + fieldId: lookupDateFieldId, + operator: 'is', + value: { + ...valueFor(modeCase), + exactDateEnd: localNoonIso('2026-06-16'), + }, + }, + lookupNameFieldId, + expectedNames(lookupRows, 'is', modeCase) + ); + }); + }); + + // ------------------------------------------------------------------ + // Checkbox — only 'is' is valid; false/null both mean unchecked (T6520) + // v1: Checkbox Field Filters / CHECKBOX_FIELD_CASES / T1613 + // ------------------------------------------------------------------ + describe('checkbox field operators', () => { + let tableId: string; + let checkboxFieldId: string; + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Filter Matrix Checkbox', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Check', type: 'checkbox' }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + checkboxFieldId = table.fields.find((f) => f.name === 'Check')?.id ?? ''; + + await ctx.createRecords(tableId, [ + { fields: { [checkboxFieldId]: true } }, + // false is normalized to null on write (T6520); still counts as unchecked + { fields: { [checkboxFieldId]: false } }, + { fields: {} }, + ]); + }, 60000); + + it.each([ + { value: true, expected: 1 }, + { value: false, expected: 2 }, + { value: null, expected: 2 }, + ])('is $value -> $expected records', async ({ value, expected }) => { + await expectFilterCount( + tableId, + { fieldId: checkboxFieldId, operator: 'is', value }, + expected + ); + }); + }); + + // ------------------------------------------------------------------ + // Single select + // v1: Single Select Field Filters / SINGLE_SELECT_FIELD_CASES + // ------------------------------------------------------------------ + describe('single select field operators', () => { + let tableId: string; + let selectFieldId: string; + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Filter Matrix Single Select', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Select', type: 'singleSelect', options: ['Option 1', 'Option 2', 'Option 3'] }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + selectFieldId = table.fields.find((f) => f.name === 'Select')?.id ?? ''; + + await ctx.createRecords(tableId, [ + { fields: { [selectFieldId]: 'Option 1' } }, + { fields: { [selectFieldId]: 'Option 2' } }, + { fields: {} }, + ]); + }, 60000); + + it.each([ + { op: 'is', value: 'Option 1', expected: 1 }, + { op: 'isNot', value: 'Option 1', expected: 2 }, + { op: 'isAnyOf', value: ['Option 1', 'Option 2'], expected: 2 }, + { op: 'isNoneOf', value: ['Option 1'], expected: 2 }, + { op: 'isEmpty', value: null, expected: 1 }, + { op: 'isNotEmpty', value: null, expected: 2 }, + ])('$op -> $expected records', async ({ op, value, expected }) => { + await expectFilterCount(tableId, { fieldId: selectFieldId, operator: op, value }, expected); + }); + }); + + // ------------------------------------------------------------------ + // Multiple select + // v1: Multiple Select Field Filters / MULTIPLE_SELECT_FIELD_CASES + // ------------------------------------------------------------------ + describe('multiple select field operators', () => { + let tableId: string; + let tagsFieldId: string; + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Filter Matrix Multiple Select', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Tags', type: 'multipleSelect', options: ['Tag 1', 'Tag 2', 'Tag 3'] }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + tagsFieldId = table.fields.find((f) => f.name === 'Tags')?.id ?? ''; + + await ctx.createRecords(tableId, [ + { fields: { [tagsFieldId]: ['Tag 1', 'Tag 2'] } }, + { fields: { [tagsFieldId]: ['Tag 2', 'Tag 3'] } }, + { fields: {} }, + ]); + }, 60000); + + it.each([ + { op: 'hasAnyOf', value: ['Tag 1'], expected: 1 }, + { op: 'hasAllOf', value: ['Tag 1', 'Tag 2'], expected: 1 }, + { op: 'hasNoneOf', value: ['Tag 1'], expected: 2 }, + { op: 'isExactly', value: ['Tag 1', 'Tag 2'], expected: 1 }, + { op: 'isNotExactly', value: ['Tag 1', 'Tag 2'], expected: 2 }, + { op: 'isEmpty', value: null, expected: 1 }, + { op: 'isNotEmpty', value: null, expected: 2 }, + ])('$op -> $expected records', async ({ op, value, expected }) => { + await expectFilterCount(tableId, { fieldId: tagsFieldId, operator: op, value }, expected); + }); + }); + + // ------------------------------------------------------------------ + // User (single + multiple) + // v1: USER_FIELD_CASES / MULTIPLE_USER_FIELD_CASES + // ------------------------------------------------------------------ + describe('user field operators', () => { + let tableId: string; + let ownerFieldId: string; + let assigneesFieldId: string; + let aliceId: string; + const bobId = 'usrFilterMatrixBob'; + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Filter Matrix User', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Owner', type: 'user', options: { isMultiple: false } }, + { name: 'Assignees', type: 'user', options: { isMultiple: true } }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + ownerFieldId = table.fields.find((f) => f.name === 'Owner')?.id ?? ''; + assigneesFieldId = table.fields.find((f) => f.name === 'Assignees')?.id ?? ''; + + aliceId = ctx.testUser.id; + const alice = { id: aliceId, title: ctx.testUser.name }; + const bob = { id: bobId, title: 'Bob' }; + + await sql` + insert into users (id, name, email) + values (${bob.id}, ${bob.title}, ${'bob+filter-matrix@e2e.com'}) + on conflict (id) do nothing + `.execute(ctx.testContainer.db); + + await ctx.createRecords(tableId, [ + { fields: { [ownerFieldId]: alice, [assigneesFieldId]: [alice] } }, + { fields: { [ownerFieldId]: bob, [assigneesFieldId]: [alice, bob] } }, + { fields: {} }, + ]); + }, 60000); + + it('single user supports is / isNot / isAnyOf / isNoneOf / isEmpty / isNotEmpty', async () => { + await expectFilterCount( + tableId, + { fieldId: ownerFieldId, operator: 'is', value: aliceId }, + 1 + ); + await expectFilterCount( + tableId, + { fieldId: ownerFieldId, operator: 'isNot', value: aliceId }, + 2 + ); + await expectFilterCount( + tableId, + { fieldId: ownerFieldId, operator: 'isAnyOf', value: [aliceId, bobId] }, + 2 + ); + await expectFilterCount( + tableId, + { fieldId: ownerFieldId, operator: 'isNoneOf', value: [aliceId] }, + 2 + ); + await expectFilterCount( + tableId, + { fieldId: ownerFieldId, operator: 'isEmpty', value: null }, + 1 + ); + await expectFilterCount( + tableId, + { fieldId: ownerFieldId, operator: 'isNotEmpty', value: null }, + 2 + ); + }); + + it('multiple user supports hasAnyOf / hasAllOf / hasNoneOf / isExactly / isNotExactly / isEmpty / isNotEmpty', async () => { + await expectFilterCount( + tableId, + { fieldId: assigneesFieldId, operator: 'hasAnyOf', value: [bobId] }, + 1 + ); + await expectFilterCount( + tableId, + { fieldId: assigneesFieldId, operator: 'hasAllOf', value: [aliceId, bobId] }, + 1 + ); + await expectFilterCount( + tableId, + { fieldId: assigneesFieldId, operator: 'hasNoneOf', value: [bobId] }, + 2 + ); + await expectFilterCount( + tableId, + { fieldId: assigneesFieldId, operator: 'isExactly', value: [aliceId] }, + 1 + ); + await expectFilterCount( + tableId, + { fieldId: assigneesFieldId, operator: 'isNotExactly', value: [aliceId, bobId] }, + 2 + ); + await expectFilterCount( + tableId, + { fieldId: assigneesFieldId, operator: 'isEmpty', value: null }, + 1 + ); + await expectFilterCount( + tableId, + { fieldId: assigneesFieldId, operator: 'isNotEmpty', value: null }, + 2 + ); + }); + }); + + // ------------------------------------------------------------------ + // Link + lookup + rollup + formula (computed chain) + // v1: Link/Lookup/Rollup/Formula Field Filters (comprehensive-field-filter) + // ------------------------------------------------------------------ + describe('link, lookup, rollup and formula field operators', () => { + let mainTableId: string; + let linkFieldId: string; + let lookupTextFieldId: string; + let lookupNumberFieldId: string; + let rollupSumFieldId: string; + let formulaFieldId: string; + + beforeAll(async () => { + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Filter Matrix Related', + fields: [ + { name: 'Related Text', type: 'singleLineText', isPrimary: true }, + { name: 'Related Number', type: 'number' }, + ], + views: [{ type: 'grid' }], + }); + const relatedTextFieldId = foreignTable.fields.find((f) => f.isPrimary)?.id ?? ''; + const relatedNumberFieldId = + foreignTable.fields.find((f) => f.name === 'Related Number')?.id ?? ''; + + const related1 = await ctx.createRecord(foreignTable.id, { + [relatedTextFieldId]: 'Related Item 1', + [relatedNumberFieldId]: 100, + }); + const related2 = await ctx.createRecord(foreignTable.id, { + [relatedTextFieldId]: 'Related Item 2', + [relatedNumberFieldId]: 200, + }); + + const mainTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Filter Matrix Main', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Number', type: 'number' }, + { + name: 'Link', + type: 'link', + options: { + relationship: 'manyOne', + foreignTableId: foreignTable.id, + lookupFieldId: relatedTextFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + mainTableId = mainTable.id; + const numberFieldId = mainTable.fields.find((f) => f.name === 'Number')?.id ?? ''; + linkFieldId = mainTable.fields.find((f) => f.name === 'Link')?.id ?? ''; + + const lookupTextField = await ctx.createField({ + baseId: ctx.baseId, + tableId: mainTableId, + field: { + type: 'lookup', + name: 'Lookup Text', + options: { + foreignTableId: foreignTable.id, + lookupFieldId: relatedTextFieldId, + linkFieldId, + }, + }, + }); + lookupTextFieldId = lookupTextField.fields.find((f) => f.name === 'Lookup Text')?.id ?? ''; + + const lookupNumberField = await ctx.createField({ + baseId: ctx.baseId, + tableId: mainTableId, + field: { + type: 'lookup', + name: 'Lookup Number', + options: { + foreignTableId: foreignTable.id, + lookupFieldId: relatedNumberFieldId, + linkFieldId, + }, + }, + }); + lookupNumberFieldId = + lookupNumberField.fields.find((f) => f.name === 'Lookup Number')?.id ?? ''; + + const rollupField = await ctx.createField({ + baseId: ctx.baseId, + tableId: mainTableId, + field: { + type: 'rollup', + name: 'Rollup Sum', + options: { expression: 'sum({values})' }, + config: { + foreignTableId: foreignTable.id, + lookupFieldId: relatedNumberFieldId, + linkFieldId, + }, + }, + }); + rollupSumFieldId = rollupField.fields.find((f) => f.name === 'Rollup Sum')?.id ?? ''; + + const formulaField = await ctx.createField({ + baseId: ctx.baseId, + tableId: mainTableId, + field: { + type: 'formula', + name: 'Doubled', + options: { expression: `{${numberFieldId}} * 2` }, + }, + }); + formulaFieldId = formulaField.fields.find((f) => f.name === 'Doubled')?.id ?? ''; + + await ctx.createRecords(mainTableId, [ + { + fields: { + [numberFieldId]: 10.5, + [linkFieldId]: { id: related1.id }, + }, + }, + { + fields: { + [numberFieldId]: 25.75, + [linkFieldId]: { id: related2.id }, + }, + }, + { fields: {} }, + ]); + }, 120000); + + it('link supports isEmpty / isNotEmpty', async () => { + await expectFilterCount( + mainTableId, + { fieldId: linkFieldId, operator: 'isEmpty', value: null }, + 1 + ); + await expectFilterCount( + mainTableId, + { fieldId: linkFieldId, operator: 'isNotEmpty', value: null }, + 2 + ); + }); + + it('lookup text supports is / contains / isEmpty / isNotEmpty', async () => { + await expectFilterCount( + mainTableId, + { fieldId: lookupTextFieldId, operator: 'is', value: 'Related Item 1' }, + 1 + ); + await expectFilterCount( + mainTableId, + { fieldId: lookupTextFieldId, operator: 'contains', value: 'Related' }, + 2 + ); + await expectFilterCount( + mainTableId, + { fieldId: lookupTextFieldId, operator: 'isEmpty', value: null }, + 1 + ); + await expectFilterCount( + mainTableId, + { fieldId: lookupTextFieldId, operator: 'isNotEmpty', value: null }, + 2 + ); + }); + + it('lookup number supports is / isGreater / isEmpty / isNotEmpty', async () => { + await expectFilterCount( + mainTableId, + { fieldId: lookupNumberFieldId, operator: 'is', value: 100 }, + 1 + ); + await expectFilterCount( + mainTableId, + { fieldId: lookupNumberFieldId, operator: 'isGreater', value: 150 }, + 1 + ); + await expectFilterCount( + mainTableId, + { fieldId: lookupNumberFieldId, operator: 'isEmpty', value: null }, + 1 + ); + await expectFilterCount( + mainTableId, + { fieldId: lookupNumberFieldId, operator: 'isNotEmpty', value: null }, + 2 + ); + }); + + it('rollup sum supports is / isGreater / isLess / isEmpty / isNotEmpty (v1: sum over no links is 0)', async () => { + await expectFilterCount( + mainTableId, + { fieldId: rollupSumFieldId, operator: 'is', value: 100 }, + 1 + ); + await expectFilterCount( + mainTableId, + { fieldId: rollupSumFieldId, operator: 'isGreater', value: 150 }, + 1 + ); + // v1 comprehensive-field-filter: unlinked row rolls up to 0 → isLess 150 -> 2 + await expectFilterCount( + mainTableId, + { fieldId: rollupSumFieldId, operator: 'isLess', value: 150 }, + 2 + ); + await expectFilterCount( + mainTableId, + { fieldId: rollupSumFieldId, operator: 'isEmpty', value: null }, + 0 + ); + await expectFilterCount( + mainTableId, + { fieldId: rollupSumFieldId, operator: 'isNotEmpty', value: null }, + 3 + ); + }); + + it('number formula supports is / isGreater / isLess / isEmpty / isNotEmpty (v1: blank input evaluates to 0)', async () => { + await expectFilterCount( + mainTableId, + { fieldId: formulaFieldId, operator: 'is', value: 21 }, + 1 + ); + await expectFilterCount( + mainTableId, + { fieldId: formulaFieldId, operator: 'isGreater', value: 30 }, + 1 + ); + // v1 comprehensive-field-filter: {blank} * 2 evaluates to 0 → isLess 30 -> 2 + await expectFilterCount( + mainTableId, + { fieldId: formulaFieldId, operator: 'isLess', value: 30 }, + 2 + ); + await expectFilterCount( + mainTableId, + { fieldId: formulaFieldId, operator: 'isEmpty', value: null }, + 0 + ); + await expectFilterCount( + mainTableId, + { fieldId: formulaFieldId, operator: 'isNotEmpty', value: null }, + 3 + ); + }); + }); + + // ------------------------------------------------------------------ + // Group composition (AND / OR / nested, T3109) + // v1: Complex Filter Scenarios + T3109 nested filter conjunction + // ------------------------------------------------------------------ + describe('filter group composition', () => { + let tableId: string; + let textFieldId: string; + let numberFieldId: string; + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Filter Matrix Groups', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Text', type: 'singleLineText' }, + { name: 'Number', type: 'number' }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + textFieldId = table.fields.find((f) => f.name === 'Text')?.id ?? ''; + numberFieldId = table.fields.find((f) => f.name === 'Number')?.id ?? ''; + + await ctx.createRecords(tableId, [ + { fields: { [textFieldId]: 'Test Text 1', [numberFieldId]: 10.5 } }, + { fields: { [textFieldId]: 'Test Text 2', [numberFieldId]: 25.75 } }, + { fields: {} }, + ]); + }, 60000); + + it('combines conditions with AND', async () => { + await expectFilterCount( + tableId, + { + conjunction: 'and', + items: [ + { fieldId: textFieldId, operator: 'is', value: 'Test Text 1' }, + { fieldId: numberFieldId, operator: 'is', value: 10.5 }, + ], + }, + 1 + ); + }); + + it('combines a condition and a nested group with OR', async () => { + await expectFilterCount( + tableId, + { + conjunction: 'or', + items: [ + { fieldId: textFieldId, operator: 'isEmpty', value: null }, + { + conjunction: 'and', + items: [{ fieldId: numberFieldId, operator: 'isGreater', value: 20 }], + }, + ], + }, + 2 + ); + }); + + it('T3109: middle-level OR conjunction is preserved in 3-level nesting', async () => { + // Root(AND) → Group1(OR) → [Number=10.5, Group2(AND) → [Number=25.75]] + const records = await listWithFilter(tableId, { + conjunction: 'and', + items: [ + { + conjunction: 'or', + items: [ + { fieldId: numberFieldId, operator: 'is', value: 10.5 }, + { + conjunction: 'and', + items: [{ fieldId: numberFieldId, operator: 'is', value: 25.75 }], + }, + ], + }, + ], + }); + const numbers = records + .map((record) => record.fields[numberFieldId]) + .sort((a, b) => Number(a) - Number(b)); + expect(numbers).toEqual([10.5, 25.75]); + }); + }); +}); diff --git a/packages/v2/e2e/src/listRecords-search.e2e.spec.ts b/packages/v2/e2e/src/listRecords-search.e2e.spec.ts new file mode 100644 index 0000000000..1ff01c8626 --- /dev/null +++ b/packages/v2/e2e/src/listRecords-search.e2e.spec.ts @@ -0,0 +1,534 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { listTableRecordsOkResponseSchema } from '@teable/v2-contract-http'; +import { FieldKeyType } from '@teable/v2-core'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +/** + * v1-parity listRecords search coverage. + * + * v1 references: + * - apps/nestjs-backend/test/record-search-query.e2e-spec.ts + * - apps/nestjs-backend/test/aggregation-search.e2e-spec.ts (search half) + * + * v2 search tuple (recordSearchInputSchema): [value], [value, fieldKeys] or + * [value, fieldKeys, hideNotMatchRow]. Only hideNotMatchRow=true affects the + * returned rows; the highlight-only forms leave the row set untouched. + * + * Semantics under test (T6520): + * - substring match is case-insensitive (ILIKE). + * - number cells match their formatted text (ROUND to the field precision), + * so "19.0" matches a precision-1 value of 19 while "19.00" does not. + * - date cells match a whole formatted day when the field is targeted, and + * are excluded from all-field searches (v1 hide-not-match parity). + * - checkbox cells produce no search predicate; targeting only a checkbox + * field filters nothing and returns every row (v1 parity: x_20 checkbox + * search returned all 23 rows). + * - multi-value cells match against their joined "a, b" cell text. + * + * Not ported (different v2 HTTP shape): + * - v1 extra.searchHitIndex / highlight structure. The v2 contract exposes + * native `searchMatches` metadata through an explicit opt-in. + * - record/socket/doc-ids projection endpoint. + * - getSearchIndex / getSearchCount / getRecordIndex endpoints. + * - trgm/tsvector search-index management (toggleTableIndex, abnormal index + * list/repair, index rename on dbFieldName change, button index skip): + * generated-column access paths cannot be toggled through the v2 contract. + */ +describe('v2 listRecords search (e2e)', () => { + let ctx: SharedTestContext; + + const drainOutbox = async (rounds = 10) => { + for (let i = 0; i < rounds; i += 1) { + const drained = await ctx.testContainer.processOutbox(); + if (drained === 0) break; + } + }; + + const listWithSearch = async ( + tableId: string, + options: { + search?: [string] | [string, string] | [string, string, boolean]; + filter?: unknown; + sort?: Array<{ fieldId: string; order: 'asc' | 'desc' }>; + } = {} + ) => { + await drainOutbox(); + + const params = new URLSearchParams({ tableId, fieldKeyType: FieldKeyType.Id }); + if (options.search) params.set('search', JSON.stringify(options.search)); + if (options.filter) params.set('filter', JSON.stringify(options.filter)); + if (options.sort) params.set('sort', JSON.stringify(options.sort)); + + const response = await fetch(`${ctx.baseUrl}/tables/listRecords?${params.toString()}`, { + method: 'GET', + headers: { 'content-type': 'application/json' }, + }); + const rawBody = await response.json(); + if (response.status !== 200) { + throw new Error(`ListRecords failed: ${JSON.stringify(rawBody)}`); + } + const parsed = listTableRecordsOkResponseSchema.safeParse(rawBody); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) { + throw new Error(`ListRecords response invalid: ${JSON.stringify(rawBody)}`); + } + return parsed.data.data.records; + }; + + const expectSearchCount = async ( + tableId: string, + search: [string] | [string, string] | [string, string, boolean], + expected: number + ) => { + const records = await listWithSearch(tableId, { search }); + expect(records).toHaveLength(expected); + return records; + }; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + }, 60000); + + // ------------------------------------------------------------------ + // Basic field-type search matrix + // v1: "basis field search record" > "simple search fields" + // ------------------------------------------------------------------ + describe('basic field search', () => { + let tableId: string; + let textFieldId: string; + let longTextFieldId: string; + let numberFieldId: string; + let dateFieldId: string; + let checkboxFieldId: string; + let selectFieldId: string; + let tagsFieldId: string; + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Search Basic Types', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Text', type: 'singleLineText' }, + { name: 'Long', type: 'longText' }, + { + name: 'Number', + type: 'number', + options: { formatting: { type: 'decimal', precision: 1 } }, + }, + { + name: 'Date', + type: 'date', + options: { formatting: { date: 'YYYY-MM-DD', time: 'None', timeZone: 'utc' } }, + }, + { name: 'Check', type: 'checkbox' }, + { + name: 'Select', + type: 'singleSelect', + options: { + choices: [ + { id: 'cho1', name: 'test', color: 'blue' }, + { id: 'cho2', name: 'dev', color: 'green' }, + { id: 'cho3', name: 'other', color: 'red' }, + ], + }, + }, + { + name: 'Tags', + type: 'multipleSelect', + options: { + choices: [ + { id: 'choX', name: 'rap', color: 'blue' }, + { id: 'choY', name: 'rock', color: 'green' }, + { id: 'choZ', name: 'hiphop', color: 'red' }, + ], + }, + }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + textFieldId = table.fields.find((f) => f.name === 'Text')?.id ?? ''; + longTextFieldId = table.fields.find((f) => f.name === 'Long')?.id ?? ''; + numberFieldId = table.fields.find((f) => f.name === 'Number')?.id ?? ''; + dateFieldId = table.fields.find((f) => f.name === 'Date')?.id ?? ''; + checkboxFieldId = table.fields.find((f) => f.name === 'Check')?.id ?? ''; + selectFieldId = table.fields.find((f) => f.name === 'Select')?.id ?? ''; + tagsFieldId = table.fields.find((f) => f.name === 'Tags')?.id ?? ''; + + await ctx.createRecords(tableId, [ + { + fields: { + [textFieldId]: 'Text Field 19', + [numberFieldId]: 19, + [dateFieldId]: '2022-03-02T12:00:00.000Z', + [checkboxFieldId]: true, + [selectFieldId]: 'test', + [tagsFieldId]: ['hiphop', 'rock'], + }, + }, + { + fields: { + [textFieldId]: 'Text Field 20', + [numberFieldId]: 20.3, + [dateFieldId]: '2022-05-01T12:00:00.000Z', + [selectFieldId]: 'dev', + [tagsFieldId]: ['rap'], + }, + }, + { + fields: { + [textFieldId]: 'zebra Z', + [numberFieldId]: 100, + [longTextFieldId]: 'hello\nnewYork, London\nlove', + }, + }, + { fields: { [textFieldId]: '100 items' } }, + { fields: {} }, + ]); + }, 60000); + + it('matches a text substring case-insensitively when targeting a field', async () => { + await expectSearchCount(tableId, ['field 19', textFieldId, true], 1); + await expectSearchCount(tableId, ['Field', textFieldId, true], 2); + await expectSearchCount(tableId, ['TEXT FIELD', textFieldId, true], 2); + }); + + it('matches numbers against their formatted precision text', async () => { + // precision 1: 19 renders as "19.0" + await expectSearchCount(tableId, ['19.0', numberFieldId, true], 1); + await expectSearchCount(tableId, ['19.00', numberFieldId, true], 0); + // 20.3 contains "0.3" + await expectSearchCount(tableId, ['0.3', numberFieldId, true], 1); + }); + + it('does not match number fields for non-numeric text when targeted', async () => { + await expectSearchCount(tableId, ['apple', numberFieldId, true], 0); + }); + + it('matches dates against a whole formatted day when targeted', async () => { + await expectSearchCount(tableId, ['2022-03-02', dateFieldId, true], 1); + await expectSearchCount(tableId, ['2022-02-28', dateFieldId, true], 0); + }); + + it('returns all rows when targeting only a checkbox field (no search predicate, v1 parity)', async () => { + await expectSearchCount(tableId, ['true', checkboxFieldId, true], 5); + }); + + it('matches single select choice names', async () => { + await expectSearchCount(tableId, ['test', selectFieldId, true], 1); + await expectSearchCount(tableId, ['dev', selectFieldId, true], 1); + }); + + it('matches multiple select against the joined cell text', async () => { + await expectSearchCount(tableId, ['hiphop', tagsFieldId, true], 1); + await expectSearchCount(tableId, ['hiphop, rock', tagsFieldId, true], 1); + await expectSearchCount(tableId, ['rock, hiphop', tagsFieldId, true], 0); + }); + + it('finds no rows for a double quote probe', async () => { + await expectSearchCount(tableId, ['"', textFieldId, true], 0); + }); + + it('matches multiline long text with line breaks flattened to spaces', async () => { + await expectSearchCount(tableId, ['hello newYork, London love', longTextFieldId, true], 1); + }); + + it('supports comma-separated field keys', async () => { + // "100" appears in Number (100.0) and in Text ("100 items"). + await expectSearchCount(tableId, ['100', `${textFieldId},${numberFieldId}`, true], 2); + }); + + describe('global search', () => { + it('does not match number fields when searching non-numeric text', async () => { + const records = await expectSearchCount(tableId, ['zebra', '', true], 1); + expect(records[0]?.fields[textFieldId]).toBe('zebra Z'); + }); + + it('matches both text and number fields when searching numeric text', async () => { + // "100" -> text "100 items" + number 100.0 + await expectSearchCount(tableId, ['100', '', true], 2); + }); + + it('excludes date fields from all-field searches (hide-not-match parity)', async () => { + await expectSearchCount(tableId, ['2022-03-02', '', true], 0); + }); + }); + + describe('highlight-only search tuples do not filter rows', () => { + it('keeps all rows when the hide flag is omitted', async () => { + await expectSearchCount(tableId, ['field 19'], 5); + await expectSearchCount(tableId, ['field 19', textFieldId], 5); + }); + + it('keeps all rows when hideNotMatchRow is false', async () => { + await expectSearchCount(tableId, ['field 19', textFieldId, false], 5); + }); + }); + + describe('search combined with filter and sort', () => { + it('intersects the search row filter with the query filter', async () => { + const records = await listWithSearch(tableId, { + search: ['Field', textFieldId, true], + filter: { fieldId: numberFieldId, operator: 'isGreater', value: 19 }, + }); + expect(records).toHaveLength(1); + expect(records[0]?.fields[textFieldId]).toBe('Text Field 20'); + }); + + it('applies sort to the matching rows', async () => { + const records = await listWithSearch(tableId, { + search: ['Field', textFieldId, true], + sort: [{ fieldId: numberFieldId, order: 'desc' }], + }); + expect(records.map((record) => record.fields[textFieldId])).toEqual([ + 'Text Field 20', + 'Text Field 19', + ]); + }); + }); + }); + + // ------------------------------------------------------------------ + // Special characters + // v1: "search value with special characters" + // ------------------------------------------------------------------ + describe('search value with special characters', () => { + it('matches values containing "+" characters', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Search Special Characters', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Text', type: 'singleLineText' }, + ], + views: [{ type: 'grid' }], + }); + const textFieldId = table.fields.find((f) => f.name === 'Text')?.id ?? ''; + await ctx.createRecords(table.id, [ + { fields: { [textFieldId]: 'notepad++' } }, + { fields: { [textFieldId]: 'notepad' } }, + ]); + + await expectSearchCount(table.id, ['notepad++', textFieldId, true], 1); + }); + }); + + // ------------------------------------------------------------------ + // Computed / linked record fields (#2015) + // v1: "search linked record fields (#2015)" > "get records search results" + // ------------------------------------------------------------------ + describe('search linked record fields', () => { + let projectsTableId: string; + let projectFieldId: string; + let linkFieldId: string; + let lookupFieldId: string; + let rollupFieldId: string; + let formulaFieldId: string; + + beforeAll(async () => { + const peopleTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Search Link People', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Score', type: 'number' }, + ], + views: [{ type: 'grid' }], + }); + const peopleNameFieldId = peopleTable.fields.find((f) => f.isPrimary)?.id ?? ''; + const peopleScoreFieldId = peopleTable.fields.find((f) => f.name === 'Score')?.id ?? ''; + const alice = await ctx.createRecord(peopleTable.id, { + [peopleNameFieldId]: 'Alice Johnson', + [peopleScoreFieldId]: 100, + }); + const bob = await ctx.createRecord(peopleTable.id, { + [peopleNameFieldId]: 'Bob Smith', + [peopleScoreFieldId]: 200, + }); + + const projectsTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Search Link Projects', + fields: [ + { name: 'Project', type: 'singleLineText', isPrimary: true }, + { + name: 'Owner', + type: 'link', + options: { + relationship: 'manyMany', + foreignTableId: peopleTable.id, + lookupFieldId: peopleNameFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + projectsTableId = projectsTable.id; + projectFieldId = projectsTable.fields.find((f) => f.name === 'Project')?.id ?? ''; + linkFieldId = projectsTable.fields.find((f) => f.name === 'Owner')?.id ?? ''; + + const withLookup = await ctx.createField({ + baseId: ctx.baseId, + tableId: projectsTableId, + field: { + type: 'lookup', + name: 'Owner Name Lookup', + options: { + foreignTableId: peopleTable.id, + lookupFieldId: peopleNameFieldId, + linkFieldId, + }, + }, + }); + lookupFieldId = withLookup.fields.find((f) => f.name === 'Owner Name Lookup')?.id ?? ''; + + const withRollup = await ctx.createField({ + baseId: ctx.baseId, + tableId: projectsTableId, + field: { + type: 'rollup', + name: 'Owner Score Total', + options: { expression: 'sum({values})' }, + config: { + foreignTableId: peopleTable.id, + lookupFieldId: peopleScoreFieldId, + linkFieldId, + }, + }, + }); + rollupFieldId = withRollup.fields.find((f) => f.name === 'Owner Score Total')?.id ?? ''; + + const withFormula = await ctx.createField({ + baseId: ctx.baseId, + tableId: projectsTableId, + field: { + type: 'formula', + name: 'Project Uppercase', + options: { expression: `UPPER({${projectFieldId}})` }, + }, + }); + formulaFieldId = withFormula.fields.find((f) => f.name === 'Project Uppercase')?.id ?? ''; + + await ctx.createRecords(projectsTableId, [ + { + fields: { + [projectFieldId]: 'Website Redesign', + [linkFieldId]: [{ id: alice.id }], + }, + }, + { + fields: { + [projectFieldId]: 'Mobile App', + [linkFieldId]: [{ id: bob.id }], + }, + }, + ]); + }, 120000); + + const matchedProject = (records: Array<{ fields: Record }>) => + records.map((record) => record.fields[projectFieldId]); + + it.each([ + { label: 'link', getFieldId: () => linkFieldId, searchValue: 'Alice Johnson' }, + { label: 'lookup', getFieldId: () => lookupFieldId, searchValue: 'Alice Johnson' }, + { label: 'rollup', getFieldId: () => rollupFieldId, searchValue: '100' }, + { label: 'formula', getFieldId: () => formulaFieldId, searchValue: 'WEBSITE REDESIGN' }, + ])('$label field search hides non-matching rows', async ({ getFieldId, searchValue }) => { + const records = await expectSearchCount( + projectsTableId, + [searchValue, getFieldId(), true], + 1 + ); + expect(matchedProject(records)).toEqual(['Website Redesign']); + }); + + it.each([ + { label: 'link', searchValue: 'Alice Johnson' }, + { label: 'lookup', searchValue: 'Alice Johnson' }, + { label: 'rollup', searchValue: '100' }, + { label: 'formula', searchValue: 'WEBSITE REDESIGN' }, + ])( + '$label value matches in a global search hiding non-matching rows', + async ({ searchValue }) => { + const records = await expectSearchCount(projectsTableId, [searchValue, '', true], 1); + expect(matchedProject(records)).toEqual(['Website Redesign']); + } + ); + + it('keeps all rows for a targeted search without hideNotMatchRow', async () => { + await expectSearchCount(projectsTableId, ['Alice Johnson', linkFieldId, false], 2); + }); + }); + + // ------------------------------------------------------------------ + // Quoting regressions: uppercase / reserved-word db column names + // v1: "search quoting regressions" + // ------------------------------------------------------------------ + describe('search quoting regressions', () => { + it('returns results when searching an uppercase / reserved db column', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Search Quoting Regression', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Description', type: 'singleLineText' }, + { + name: 'Group', + type: 'singleSelect', + options: { + choices: [ + { id: 'choAlpha', name: 'Alpha', color: 'blue' }, + { id: 'choBeta', name: 'Beta', color: 'green' }, + ], + }, + }, + ], + views: [{ type: 'grid' }], + }); + const nameFieldId = table.fields.find((f) => f.name === 'Name')?.id ?? ''; + const descriptionFieldId = table.fields.find((f) => f.name === 'Description')?.id ?? ''; + const groupFieldId = table.fields.find((f) => f.name === 'Group')?.id ?? ''; + + await ctx.updateField({ + tableId: table.id, + fieldId: descriptionFieldId, + field: { dbFieldName: 'DESCRIPTION' }, + }); + await ctx.updateField({ + tableId: table.id, + fieldId: groupFieldId, + field: { dbFieldName: 'GROUP' }, + }); + + await ctx.createRecords(table.id, [ + { + fields: { + [nameFieldId]: 'Alpha row', + [descriptionFieldId]: 'ce target', + [groupFieldId]: 'Alpha', + }, + }, + { + fields: { + [nameFieldId]: 'Beta row', + [descriptionFieldId]: 'other value', + [groupFieldId]: 'Beta', + }, + }, + ]); + + const records = await expectSearchCount(table.id, ['ce target', descriptionFieldId, true], 1); + expect(records[0]?.fields[descriptionFieldId]).toBe('ce target'); + + // Reserved-word select column stays searchable and sortable. + const sorted = await listWithSearch(table.id, { + search: ['row', nameFieldId, true], + sort: [{ fieldId: groupFieldId, order: 'desc' }], + }); + expect(sorted.map((record) => record.fields[nameFieldId])).toEqual(['Beta row', 'Alpha row']); + }); + }); +}); diff --git a/packages/v2/e2e/src/listRecords-sort.e2e.spec.ts b/packages/v2/e2e/src/listRecords-sort.e2e.spec.ts new file mode 100644 index 0000000000..eb85bd84cb --- /dev/null +++ b/packages/v2/e2e/src/listRecords-sort.e2e.spec.ts @@ -0,0 +1,1049 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { listTableRecordsOkResponseSchema } from '@teable/v2-contract-http'; +import { createV2HttpClient } from '@teable/v2-contract-http-client'; +import { FieldKeyType } from '@teable/v2-core'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +/** + * v1-parity listRecords sort coverage. + * + * v1 references: + * - apps/nestjs-backend/test/comprehensive-field-sort.e2e-spec.ts + * - apps/nestjs-backend/test/sort.e2e-spec.ts + * + * Semantics under test (T6520): + * - cleared/blank cells are stored as null; ASC places nulls first, DESC + * places nulls last (v1 parity, see StoredTableRecordQueryBuilder). + * - unchecked checkbox (false) is normalized to null on write and therefore + * sorts with the null bucket. + * - single/multiple select (and select lookups) sort by choice order, using + * the first element for multi-value cells. + * - user/link cells sort by title. + * - date fields with time formatting `None` sort at day/month/year precision + * depending on the date preset (ties broken by __auto_number ASC). + * - query sort takes precedence over view default sort; view sort keys not + * present in the query sort are appended after it. + * + * Related coverage: + * - view sort PUT round-trip / clear with null → covered by + * viewOperations.e2e.spec.ts ("round-trips filter, sort, and group"). + * - x_20 lookup "Multiple CellValueType" oracle tests → covered below with + * deterministic multi-element values that distinguish display-key ordering + * from raw jsonb ordering. + */ +describe('v2 listRecords sort (e2e)', () => { + let ctx: SharedTestContext; + let client: ReturnType; + + const drainOutbox = async (rounds = 10) => { + for (let i = 0; i < rounds; i += 1) { + const drained = await ctx.testContainer.processOutbox(); + if (drained === 0) break; + } + }; + + const listOrdered = async ( + tableId: string, + options: { + sort?: Array<{ fieldId: string; order: 'asc' | 'desc' }>; + groupBy?: string[]; + viewId?: string; + } = {} + ) => { + await drainOutbox(); + + const params = new URLSearchParams({ tableId, fieldKeyType: FieldKeyType.Id }); + if (options.sort) params.set('sort', JSON.stringify(options.sort)); + if (options.groupBy) params.set('groupBy', JSON.stringify(options.groupBy)); + if (options.viewId) params.set('viewId', options.viewId); + + const response = await fetch(`${ctx.baseUrl}/tables/listRecords?${params.toString()}`, { + method: 'GET', + headers: { 'content-type': 'application/json' }, + }); + const rawBody = await response.json(); + if (response.status !== 200) { + throw new Error(`ListRecords failed: ${JSON.stringify(rawBody)}`); + } + const parsed = listTableRecordsOkResponseSchema.safeParse(rawBody); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) { + throw new Error(`ListRecords response invalid: ${JSON.stringify(rawBody)}`); + } + return parsed.data.data.records; + }; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + client = createV2HttpClient({ baseUrl: ctx.baseUrl }); + }, 60000); + + // ------------------------------------------------------------------ + // Comprehensive per-field-type sorting fixture + // v1: comprehensive-field-sort.e2e-spec.ts + // 4 records; r4 is the all-null row. + // ------------------------------------------------------------------ + describe('per field type sorting', () => { + let tableId: string; + let nameFieldId: string; + let textFieldId: string; + let numberFieldId: string; + let dateFieldId: string; + let checkboxFieldId: string; + let selectFieldId: string; + let tagsFieldId: string; + let ratingFieldId: string; + let linkFieldId: string; + let formulaFieldId: string; + let rollupFieldId: string; + let lookupTextFieldId: string; + let lookupNumberFieldId: string; + + const namesFor = async ( + sort: Array<{ fieldId: string; order: 'asc' | 'desc' }> + ): Promise> => { + const records = await listOrdered(tableId, { sort }); + expect(records).toHaveLength(4); + return records.map((record) => record.fields[nameFieldId]); + }; + + beforeAll(async () => { + const relatedTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Sort Related', + fields: [ + { name: 'Related Text', type: 'singleLineText', isPrimary: true }, + { name: 'Related Number', type: 'number' }, + ], + views: [{ type: 'grid' }], + }); + const relatedTextFieldId = relatedTable.fields.find((f) => f.isPrimary)?.id ?? ''; + const relatedNumberFieldId = + relatedTable.fields.find((f) => f.name === 'Related Number')?.id ?? ''; + const relatedAlpha = await ctx.createRecord(relatedTable.id, { + [relatedTextFieldId]: 'Alpha', + [relatedNumberFieldId]: 100, + }); + const relatedBeta = await ctx.createRecord(relatedTable.id, { + [relatedTextFieldId]: 'Beta', + [relatedNumberFieldId]: 200, + }); + const relatedGamma = await ctx.createRecord(relatedTable.id, { + [relatedTextFieldId]: 'Gamma', + [relatedNumberFieldId]: 300, + }); + + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Sort Main', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Text', type: 'singleLineText' }, + { + name: 'Number', + type: 'number', + options: { formatting: { type: 'decimal', precision: 2 } }, + }, + { name: 'Date', type: 'date' }, + { name: 'Check', type: 'checkbox' }, + { + name: 'Select', + type: 'singleSelect', + options: { + choices: [ + { id: 'opt1', name: 'High', color: 'red' }, + { id: 'opt2', name: 'Medium', color: 'blue' }, + { id: 'opt3', name: 'Low', color: 'green' }, + ], + }, + }, + { + name: 'Tags', + type: 'multipleSelect', + options: { + choices: [ + { id: 'tag1', name: 'Urgent', color: 'red' }, + { id: 'tag2', name: 'Important', color: 'blue' }, + { id: 'tag3', name: 'Normal', color: 'green' }, + ], + }, + }, + { + name: 'Rating', + type: 'rating', + options: { max: 5, icon: 'star', color: 'yellowBright' }, + }, + { + name: 'Link', + type: 'link', + options: { + relationship: 'manyOne', + foreignTableId: relatedTable.id, + lookupFieldId: relatedTextFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + nameFieldId = table.fields.find((f) => f.name === 'Name')?.id ?? ''; + textFieldId = table.fields.find((f) => f.name === 'Text')?.id ?? ''; + numberFieldId = table.fields.find((f) => f.name === 'Number')?.id ?? ''; + dateFieldId = table.fields.find((f) => f.name === 'Date')?.id ?? ''; + checkboxFieldId = table.fields.find((f) => f.name === 'Check')?.id ?? ''; + selectFieldId = table.fields.find((f) => f.name === 'Select')?.id ?? ''; + tagsFieldId = table.fields.find((f) => f.name === 'Tags')?.id ?? ''; + ratingFieldId = table.fields.find((f) => f.name === 'Rating')?.id ?? ''; + linkFieldId = table.fields.find((f) => f.name === 'Link')?.id ?? ''; + + const withFormula = await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { + type: 'formula', + name: 'Doubled', + options: { expression: `{${numberFieldId}} * 2` }, + }, + }); + formulaFieldId = withFormula.fields.find((f) => f.name === 'Doubled')?.id ?? ''; + + const withRollup = await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { + type: 'rollup', + name: 'Rollup Sum', + options: { expression: 'sum({values})' }, + config: { + foreignTableId: relatedTable.id, + lookupFieldId: relatedNumberFieldId, + linkFieldId, + }, + }, + }); + rollupFieldId = withRollup.fields.find((f) => f.name === 'Rollup Sum')?.id ?? ''; + + const withLookupText = await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { + type: 'lookup', + name: 'Lookup Text', + options: { + foreignTableId: relatedTable.id, + lookupFieldId: relatedTextFieldId, + linkFieldId, + }, + }, + }); + lookupTextFieldId = withLookupText.fields.find((f) => f.name === 'Lookup Text')?.id ?? ''; + + const withLookupNumber = await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { + type: 'lookup', + name: 'Lookup Number', + options: { + foreignTableId: relatedTable.id, + lookupFieldId: relatedNumberFieldId, + linkFieldId, + }, + }, + }); + lookupNumberFieldId = + withLookupNumber.fields.find((f) => f.name === 'Lookup Number')?.id ?? ''; + + await ctx.createRecords(tableId, [ + { + fields: { + [nameFieldId]: 'r1', + [textFieldId]: 'Charlie', + [numberFieldId]: 30.5, + [dateFieldId]: '2024-03-15T12:00:00.000Z', + [checkboxFieldId]: true, + [selectFieldId]: 'High', + [tagsFieldId]: ['Urgent', 'Important'], + [ratingFieldId]: 5, + [linkFieldId]: { id: relatedGamma.id }, + }, + }, + { + fields: { + [nameFieldId]: 'r2', + [textFieldId]: 'Alpha', + [numberFieldId]: 10.25, + [dateFieldId]: '2024-01-10T12:00:00.000Z', + // false is normalized to null on write (T6520): sorts as unchecked/null. + [checkboxFieldId]: false, + [selectFieldId]: 'Low', + [tagsFieldId]: ['Normal'], + [ratingFieldId]: 2, + [linkFieldId]: { id: relatedAlpha.id }, + }, + }, + { + fields: { + [nameFieldId]: 'r3', + [textFieldId]: 'Beta', + [numberFieldId]: 20.75, + [dateFieldId]: '2024-02-20T12:00:00.000Z', + [checkboxFieldId]: null, + [selectFieldId]: 'Medium', + [tagsFieldId]: ['Important', 'Normal'], + [ratingFieldId]: 4, + [linkFieldId]: { id: relatedBeta.id }, + }, + }, + { fields: { [nameFieldId]: 'r4' } }, + ]); + }, 120000); + + it('text sorts ascending A-Z with nulls first', async () => { + expect(await namesFor([{ fieldId: textFieldId, order: 'asc' }])).toEqual([ + 'r4', + 'r2', + 'r3', + 'r1', + ]); + }); + + it('text sorts descending Z-A with nulls last', async () => { + expect(await namesFor([{ fieldId: textFieldId, order: 'desc' }])).toEqual([ + 'r1', + 'r3', + 'r2', + 'r4', + ]); + }); + + it('number sorts ascending low to high with nulls first', async () => { + expect(await namesFor([{ fieldId: numberFieldId, order: 'asc' }])).toEqual([ + 'r4', + 'r2', + 'r3', + 'r1', + ]); + }); + + it('number sorts descending high to low with nulls last', async () => { + expect(await namesFor([{ fieldId: numberFieldId, order: 'desc' }])).toEqual([ + 'r1', + 'r3', + 'r2', + 'r4', + ]); + }); + + it('date sorts ascending earliest to latest with nulls first', async () => { + expect(await namesFor([{ fieldId: dateFieldId, order: 'asc' }])).toEqual([ + 'r4', + 'r2', + 'r3', + 'r1', + ]); + }); + + it('date sorts descending latest to earliest with nulls last', async () => { + expect(await namesFor([{ fieldId: dateFieldId, order: 'desc' }])).toEqual([ + 'r1', + 'r3', + 'r2', + 'r4', + ]); + }); + + it('checkbox sorts ascending with unchecked (false/null) first and true last', async () => { + // r2 was written as false but is stored as null (T6520): it stays in the + // unchecked bucket, ordered among nulls by __auto_number. + expect(await namesFor([{ fieldId: checkboxFieldId, order: 'asc' }])).toEqual([ + 'r2', + 'r3', + 'r4', + 'r1', + ]); + }); + + it('checkbox sorts descending with true first and unchecked last', async () => { + expect(await namesFor([{ fieldId: checkboxFieldId, order: 'desc' }])).toEqual([ + 'r1', + 'r2', + 'r3', + 'r4', + ]); + }); + + it('single select sorts ascending by choice order (High, Medium, Low)', async () => { + expect(await namesFor([{ fieldId: selectFieldId, order: 'asc' }])).toEqual([ + 'r4', + 'r1', + 'r3', + 'r2', + ]); + }); + + it('single select sorts descending by reversed choice order', async () => { + expect(await namesFor([{ fieldId: selectFieldId, order: 'desc' }])).toEqual([ + 'r2', + 'r3', + 'r1', + 'r4', + ]); + }); + + it('multiple select sorts ascending by first-choice order', async () => { + // First choices: r1 Urgent(1), r3 Important(2), r2 Normal(3). + expect(await namesFor([{ fieldId: tagsFieldId, order: 'asc' }])).toEqual([ + 'r4', + 'r1', + 'r3', + 'r2', + ]); + }); + + it('multiple select sorts descending by reversed first-choice order', async () => { + expect(await namesFor([{ fieldId: tagsFieldId, order: 'desc' }])).toEqual([ + 'r2', + 'r3', + 'r1', + 'r4', + ]); + }); + + it('rating sorts ascending with nulls first', async () => { + expect(await namesFor([{ fieldId: ratingFieldId, order: 'asc' }])).toEqual([ + 'r4', + 'r2', + 'r3', + 'r1', + ]); + }); + + it('rating sorts descending with nulls last', async () => { + expect(await namesFor([{ fieldId: ratingFieldId, order: 'desc' }])).toEqual([ + 'r1', + 'r3', + 'r2', + 'r4', + ]); + }); + + it('formula sorts ascending (blank input evaluates to 0, v1 parity)', async () => { + // Doubled: r1 61, r2 20.5, r3 41.5, r4 {blank}*2 = 0. + expect(await namesFor([{ fieldId: formulaFieldId, order: 'asc' }])).toEqual([ + 'r4', + 'r2', + 'r3', + 'r1', + ]); + }); + + it('formula sorts descending', async () => { + expect(await namesFor([{ fieldId: formulaFieldId, order: 'desc' }])).toEqual([ + 'r1', + 'r3', + 'r2', + 'r4', + ]); + }); + + it('link sorts ascending by linked title with nulls first', async () => { + // Titles: r1 Gamma, r2 Alpha, r3 Beta, r4 null. + expect(await namesFor([{ fieldId: linkFieldId, order: 'asc' }])).toEqual([ + 'r4', + 'r2', + 'r3', + 'r1', + ]); + }); + + it('rollup sum sorts ascending (unlinked row rolls up to 0, v1 parity)', async () => { + expect(await namesFor([{ fieldId: rollupFieldId, order: 'asc' }])).toEqual([ + 'r4', + 'r2', + 'r3', + 'r1', + ]); + }); + + it('rollup sum sorts descending', async () => { + expect(await namesFor([{ fieldId: rollupFieldId, order: 'desc' }])).toEqual([ + 'r1', + 'r3', + 'r2', + 'r4', + ]); + }); + + it('lookup text sorts ascending with nulls first', async () => { + expect(await namesFor([{ fieldId: lookupTextFieldId, order: 'asc' }])).toEqual([ + 'r4', + 'r2', + 'r3', + 'r1', + ]); + }); + + it('lookup number sorts descending with nulls last', async () => { + expect(await namesFor([{ fieldId: lookupNumberFieldId, order: 'desc' }])).toEqual([ + 'r1', + 'r3', + 'r2', + 'r4', + ]); + }); + }); + + // ------------------------------------------------------------------ + // Multi-key sort + // v1: comprehensive-field-sort "Multiple Field Sorting" + // ------------------------------------------------------------------ + describe('multi-key sort', () => { + let tableId: string; + let nameFieldId: string; + let catFieldId: string; + let valFieldId: string; + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Sort Multi Key', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Cat', type: 'singleLineText' }, + { name: 'Val', type: 'number' }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + nameFieldId = table.fields.find((f) => f.name === 'Name')?.id ?? ''; + catFieldId = table.fields.find((f) => f.name === 'Cat')?.id ?? ''; + valFieldId = table.fields.find((f) => f.name === 'Val')?.id ?? ''; + + await ctx.createRecords(tableId, [ + { fields: { [nameFieldId]: 'n1', [catFieldId]: 'B', [valFieldId]: 1 } }, + { fields: { [nameFieldId]: 'n2', [catFieldId]: 'A', [valFieldId]: 2 } }, + { fields: { [nameFieldId]: 'n3', [catFieldId]: 'A', [valFieldId]: 1 } }, + { fields: { [nameFieldId]: 'n4', [valFieldId]: 5 } }, + ]); + }, 60000); + + it('sorts by primary key ascending and secondary key descending', async () => { + const records = await listOrdered(tableId, { + sort: [ + { fieldId: catFieldId, order: 'asc' }, + { fieldId: valFieldId, order: 'desc' }, + ], + }); + expect(records.map((record) => record.fields[nameFieldId])).toEqual(['n4', 'n2', 'n3', 'n1']); + }); + }); + + // ------------------------------------------------------------------ + // Multiple select with "?" in choice names + // v1: comprehensive-field-sort "Multiple Select Sorting with Question Mark Choices" + // ------------------------------------------------------------------ + describe('multiple select with question mark choices', () => { + let tableId: string; + let tagFieldId: string; + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Sort Question Mark Tags', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { + name: 'Special', + type: 'multipleSelect', + options: { + choices: [ + { id: 'opt-a', name: 'Alpha?' }, + { id: 'opt-b', name: 'Beta' }, + { id: 'opt-c', name: 'Gamma' }, + ], + }, + }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + tagFieldId = table.fields.find((f) => f.name === 'Special')?.id ?? ''; + + await ctx.createRecords(tableId, [ + { fields: { [tagFieldId]: ['Beta'] } }, + { fields: { [tagFieldId]: ['Alpha?'] } }, + { fields: { [tagFieldId]: ['Gamma'] } }, + { fields: {} }, + ]); + }, 60000); + + const firstChoices = (records: Array<{ fields: Record }>) => + records.map((record) => { + const value = record.fields[tagFieldId] as string[] | null | undefined; + return value?.[0] ?? null; + }); + + it('sorts ascending by choice order with nulls first even when choices contain "?"', async () => { + const records = await listOrdered(tableId, { + sort: [{ fieldId: tagFieldId, order: 'asc' }], + }); + expect(firstChoices(records)).toEqual([null, 'Alpha?', 'Beta', 'Gamma']); + }); + + it('sorts descending by reversed choice order with nulls last', async () => { + const records = await listOrdered(tableId, { + sort: [{ fieldId: tagFieldId, order: 'desc' }], + }); + expect(firstChoices(records)).toEqual(['Gamma', 'Beta', 'Alpha?', null]); + }); + }); + + // ------------------------------------------------------------------ + // Multi-value lookup sorting + // v1: sort.e2e-spec "OpenAPI Sort (e2e) Multiple CellValueType" + // ------------------------------------------------------------------ + describe('multi-value lookup sort', () => { + let tableId: string; + let viewId: string; + let nameFieldId: string; + const lookupFieldIds: Record<'string' | 'number' | 'date' | 'boolean', string> = { + string: '', + number: '', + date: '', + boolean: '', + }; + + const cases = [ + { + valueType: 'string' as const, + asc: ['t3', 't1', 't2'], + desc: ['t1', 't2', 't3'], + }, + { + valueType: 'number' as const, + asc: ['t3', 't2', 't1'], + desc: ['t1', 't2', 't3'], + }, + { + valueType: 'date' as const, + asc: ['t3', 't2', 't1'], + desc: ['t1', 't2', 't3'], + }, + { + valueType: 'boolean' as const, + asc: ['t3', 't1', 't2'], + desc: ['t1', 't2', 't3'], + }, + ]; + + const namesFor = async ( + fieldId: string, + order: 'asc' | 'desc', + options: { viewId?: string } = {} + ) => { + const records = await listOrdered(tableId, { + ...options, + ...(options.viewId ? {} : { sort: [{ fieldId, order }] }), + }); + return records.map((record) => record.fields[nameFieldId]); + }; + + const setViewSort = async (fieldId: string, order: 'asc' | 'desc') => { + const response = await fetch(`${ctx.baseUrl}/tables/updateViewSort`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tableId, + viewId, + sort: { sortObjs: [{ fieldId, order }], manualSort: false }, + }), + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ ok: true }); + }; + + beforeAll(async () => { + const sourceTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Sort Lookup Source', + fields: [ + { name: 'Title', type: 'singleLineText', isPrimary: true }, + { name: 'Text', type: 'singleLineText' }, + { + name: 'Amount', + type: 'number', + options: { formatting: { type: 'decimal', precision: 1 } }, + }, + { + name: 'Due', + type: 'date', + options: { + formatting: { date: 'M/D/YYYY', time: 'None', timeZone: 'utc' }, + }, + }, + { name: 'Checked', type: 'checkbox' }, + ], + views: [{ type: 'grid' }], + }); + const titleFieldId = sourceTable.fields.find((f) => f.isPrimary)?.id ?? ''; + const textFieldId = sourceTable.fields.find((f) => f.name === 'Text')?.id ?? ''; + const amountFieldId = sourceTable.fields.find((f) => f.name === 'Amount')?.id ?? ''; + const dueFieldId = sourceTable.fields.find((f) => f.name === 'Due')?.id ?? ''; + const checkedFieldId = sourceTable.fields.find((f) => f.name === 'Checked')?.id ?? ''; + const [sourceA, sourceB, sourceC] = await ctx.createRecords(sourceTable.id, [ + { + fields: { + [titleFieldId]: 'A', + [textFieldId]: 'Beta', + [amountFieldId]: 2, + [dueFieldId]: '2025-01-01T00:00:00.000Z', + [checkedFieldId]: true, + }, + }, + { + fields: { + [titleFieldId]: 'B', + [textFieldId]: 'Zulu', + [amountFieldId]: 9, + [dueFieldId]: '2025-02-01T00:00:00.000Z', + [checkedFieldId]: true, + }, + }, + { + fields: { + [titleFieldId]: 'C', + [textFieldId]: 'Alpha', + [amountFieldId]: 10, + [dueFieldId]: '2026-01-01T00:00:00.000Z', + }, + }, + ]); + + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Sort Lookup Target', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { + name: 'Link', + type: 'link', + options: { + relationship: 'manyMany', + foreignTableId: sourceTable.id, + lookupFieldId: titleFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + viewId = table.views[0]?.id ?? ''; + nameFieldId = table.fields.find((f) => f.name === 'Name')?.id ?? ''; + const linkFieldId = table.fields.find((f) => f.name === 'Link')?.id ?? ''; + + for (const [valueType, sourceFieldId] of [ + ['string', textFieldId], + ['number', amountFieldId], + ['date', dueFieldId], + ['boolean', checkedFieldId], + ] as const) { + const fieldName = `Lookup ${valueType}`; + const result = await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { + type: 'lookup', + name: fieldName, + options: { + foreignTableId: sourceTable.id, + lookupFieldId: sourceFieldId, + linkFieldId, + }, + }, + }); + lookupFieldIds[valueType] = result.fields.find((f) => f.name === fieldName)?.id ?? ''; + } + + await ctx.createRecords(tableId, [ + { + fields: { + [nameFieldId]: 't1', + [linkFieldId]: [{ id: sourceA.id }, { id: sourceB.id }], + }, + }, + { + fields: { + [nameFieldId]: 't2', + [linkFieldId]: [{ id: sourceA.id }, { id: sourceC.id }], + }, + }, + { fields: { [nameFieldId]: 't3' } }, + ]); + }, 120000); + + it.each(cases)('sorts $valueType lookup arrays through query sort', async (testCase) => { + const fieldId = lookupFieldIds[testCase.valueType]; + expect(await namesFor(fieldId, 'asc')).toEqual(testCase.asc); + expect(await namesFor(fieldId, 'desc')).toEqual(testCase.desc); + }); + + it.each(cases)('sorts $valueType lookup arrays through view sort', async (testCase) => { + const fieldId = lookupFieldIds[testCase.valueType]; + for (const order of ['asc', 'desc'] as const) { + await setViewSort(fieldId, order); + expect(await namesFor(fieldId, order, { viewId })).toEqual(testCase[order]); + } + }); + }); + + // ------------------------------------------------------------------ + // View default sort vs query sort precedence + // v1: sort.e2e-spec "view sort property should be merged after by + // interface parameter orderBy" + // ------------------------------------------------------------------ + describe('view default sort vs query sort', () => { + let tableId: string; + let viewId: string; + let nameFieldId: string; + let aFieldId: string; + let bFieldId: string; + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Sort View Defaults', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'A', type: 'number' }, + { name: 'B', type: 'number' }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + viewId = table.views[0]?.id ?? ''; + nameFieldId = table.fields.find((f) => f.name === 'Name')?.id ?? ''; + aFieldId = table.fields.find((f) => f.name === 'A')?.id ?? ''; + bFieldId = table.fields.find((f) => f.name === 'B')?.id ?? ''; + + await ctx.createRecords(tableId, [ + { fields: { [nameFieldId]: 'n1', [aFieldId]: 1, [bFieldId]: 1 } }, + { fields: { [nameFieldId]: 'n2', [aFieldId]: 2, [bFieldId]: 1 } }, + { fields: { [nameFieldId]: 'n3', [aFieldId]: 1, [bFieldId]: 2 } }, + { fields: { [nameFieldId]: 'n4', [aFieldId]: 2, [bFieldId]: 2 } }, + ]); + + const sorted = await client.tables.updateViewSort({ + tableId, + viewId, + sort: { sortObjs: [{ fieldId: aFieldId, order: 'asc' }], manualSort: false }, + }); + expect(sorted.ok).toBe(true); + }, 60000); + + it('applies the view default sort when listing with viewId only', async () => { + const records = await listOrdered(tableId, { viewId }); + // A asc, ties broken by view row order / __auto_number. + expect(records.map((record) => record.fields[nameFieldId])).toEqual(['n1', 'n3', 'n2', 'n4']); + }); + + it('puts the query sort before the view default sort', async () => { + const records = await listOrdered(tableId, { + viewId, + sort: [{ fieldId: bFieldId, order: 'desc' }], + }); + // B desc first, then view default A asc as secondary key. + expect(records.map((record) => record.fields[nameFieldId])).toEqual(['n3', 'n4', 'n1', 'n2']); + }); + + it('lets the query sort override the view default order for the same field', async () => { + const records = await listOrdered(tableId, { + viewId, + sort: [{ fieldId: aFieldId, order: 'desc' }], + }); + expect(records.map((record) => record.fields[nameFieldId])).toEqual(['n2', 'n4', 'n1', 'n3']); + }); + }); + + // ------------------------------------------------------------------ + // Date formatting sort precision (time: None) + // v1: sort.e2e-spec "OpenAPI Sort (e2e) Date Formatting" + // ------------------------------------------------------------------ + describe('date formatting sort precision', () => { + let tableId: string; + let nameFieldId: string; + let yearFieldId: string; + let monthFieldId: string; + let dayFieldId: string; + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Sort Date Formatting', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { + name: 'Year', + type: 'date', + options: { formatting: { date: 'YYYY', time: 'None', timeZone: 'Asia/Singapore' } }, + }, + { + name: 'Month', + type: 'date', + options: { formatting: { date: 'YYYY-MM', time: 'None', timeZone: 'Asia/Singapore' } }, + }, + { + name: 'Day', + type: 'date', + options: { + formatting: { date: 'YYYY-MM-DD', time: 'None', timeZone: 'Asia/Singapore' }, + }, + }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + nameFieldId = table.fields.find((f) => f.name === 'Name')?.id ?? ''; + yearFieldId = table.fields.find((f) => f.name === 'Year')?.id ?? ''; + monthFieldId = table.fields.find((f) => f.name === 'Month')?.id ?? ''; + dayFieldId = table.fields.find((f) => f.name === 'Day')?.id ?? ''; + + // UTC 04:00 = 12:00 in Asia/Singapore: same calendar date in both zones. + const instants: Array<[string, string]> = [ + ['r1', '2024-01-10T04:00:00.000Z'], + ['r2', '2024-01-10T02:00:00.000Z'], + ['r3', '2023-05-01T04:00:00.000Z'], + ['r4', '2022-08-01T04:00:00.000Z'], + ['r5', '2022-05-01T04:00:00.000Z'], + ['r6', '2024-01-01T04:00:00.000Z'], + ]; + await ctx.createRecords( + tableId, + instants.map(([name, iso]) => ({ + fields: { + [nameFieldId]: name, + [yearFieldId]: iso, + [monthFieldId]: iso, + [dayFieldId]: iso, + }, + })) + ); + }, 60000); + + const namesFor = async (fieldId: string, order: 'asc' | 'desc') => { + const records = await listOrdered(tableId, { sort: [{ fieldId, order }] }); + return records.map((record) => record.fields[nameFieldId]); + }; + + it('YYYY preset sorts at year precision with __auto_number tie-break', async () => { + expect(await namesFor(yearFieldId, 'asc')).toEqual(['r4', 'r5', 'r3', 'r1', 'r2', 'r6']); + expect(await namesFor(yearFieldId, 'desc')).toEqual(['r1', 'r2', 'r6', 'r3', 'r4', 'r5']); + }); + + it('YYYY-MM preset sorts at month precision with __auto_number tie-break', async () => { + expect(await namesFor(monthFieldId, 'asc')).toEqual(['r5', 'r4', 'r3', 'r1', 'r2', 'r6']); + expect(await namesFor(monthFieldId, 'desc')).toEqual(['r1', 'r2', 'r6', 'r3', 'r4', 'r5']); + }); + + it('YYYY-MM-DD preset sorts at day precision (same-day rows keep insert order in both directions)', async () => { + expect(await namesFor(dayFieldId, 'asc')).toEqual(['r5', 'r4', 'r3', 'r6', 'r1', 'r2']); + expect(await namesFor(dayFieldId, 'desc')).toEqual(['r1', 'r2', 'r6', 'r3', 'r4', 'r5']); + }); + }); + + // ------------------------------------------------------------------ + // Created time precision + // v1: sort.e2e-spec "sort date should always use a second precision when + // formatting time is not none" / "precision should be day when time is none" + // ------------------------------------------------------------------ + describe('created time sort precision', () => { + let tableId: string; + let nameFieldId: string; + let timedFieldId: string; + let dayFieldId: string; + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Sort Created Time', + fields: [{ name: 'Name', type: 'singleLineText', isPrimary: true }], + views: [{ type: 'grid' }], + }); + tableId = table.id; + nameFieldId = table.fields.find((f) => f.name === 'Name')?.id ?? ''; + + await ctx.createRecord(tableId, { [nameFieldId]: 'first' }); + await new Promise((resolve) => setTimeout(resolve, 1100)); + await ctx.createRecord(tableId, { [nameFieldId]: 'second' }); + + const withTimed = await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { + type: 'createdTime', + name: 'Created Timed', + options: { formatting: { date: 'YYYY-MM-DD', time: 'HH:mm', timeZone: 'utc' } }, + }, + }); + timedFieldId = withTimed.fields.find((f) => f.name === 'Created Timed')?.id ?? ''; + + const withDay = await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { + type: 'createdTime', + name: 'Created Day', + options: { formatting: { date: 'YYYY-MM-DD', time: 'None', timeZone: 'utc' } }, + }, + }); + dayFieldId = withDay.fields.find((f) => f.name === 'Created Day')?.id ?? ''; + }, 60000); + + it('uses full timestamp precision when time formatting is not None', async () => { + const asc = await listOrdered(tableId, { sort: [{ fieldId: timedFieldId, order: 'asc' }] }); + const desc = await listOrdered(tableId, { sort: [{ fieldId: timedFieldId, order: 'desc' }] }); + expect(asc.map((record) => record.fields[nameFieldId])).toEqual(['first', 'second']); + expect(desc.map((record) => record.fields[nameFieldId])).toEqual(['second', 'first']); + }); + + it('uses day precision when time formatting is None (desc equals asc via tie-break)', async () => { + const asc = await listOrdered(tableId, { sort: [{ fieldId: dayFieldId, order: 'asc' }] }); + const desc = await listOrdered(tableId, { sort: [{ fieldId: dayFieldId, order: 'desc' }] }); + expect(asc.map((record) => record.fields[nameFieldId])).toEqual(['first', 'second']); + expect(desc.map((record) => record.fields[nameFieldId])).toEqual(['first', 'second']); + }); + }); + + // ------------------------------------------------------------------ + // Button field cannot be used in view sort + // v1: sort.e2e-spec "should not allow to modify sort for button field" + // ------------------------------------------------------------------ + describe('view sort validation', () => { + it('rejects updating a view sort with a button field', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Sort Button Reject', + fields: [{ name: 'Name', type: 'singleLineText', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const withButton = await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { type: 'button', name: 'Push' }, + }); + const buttonFieldId = withButton.fields.find((f) => f.name === 'Push')?.id ?? ''; + expect(buttonFieldId).not.toBe(''); + + await expect( + client.tables.updateViewSort({ + tableId: table.id, + viewId: table.views[0]?.id ?? '', + sort: { sortObjs: [{ fieldId: buttonFieldId, order: 'asc' }], manualSort: false }, + }) + ).rejects.toThrow(/unsupported Button type/); + }); + }); +}); diff --git a/packages/v2/e2e/src/listRecords-unary-filter.e2e.spec.ts b/packages/v2/e2e/src/listRecords-unary-filter.e2e.spec.ts index c92ab3bf03..61c4fd0e36 100644 --- a/packages/v2/e2e/src/listRecords-unary-filter.e2e.spec.ts +++ b/packages/v2/e2e/src/listRecords-unary-filter.e2e.spec.ts @@ -94,4 +94,107 @@ describe('v2 listRecords unary filter operators (e2e)', () => { expect(records).toHaveLength(1); expect(records[0]?.fields?.[statusFieldId] ?? null).toBeNull(); }); + + /** + * Cells cleared with "" (text) or [] (multi-value) are stored as null + * (T6520), so unary emptiness filters must treat them as empty. + * v1 reference: record-filter-query.e2e-spec isEmpty/isNotEmpty cases. + */ + it('treats cells cleared with "" and [] as empty', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'V2 Unary Filter Cleared Cells', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Text', type: 'singleLineText' }, + { name: 'Tags', type: 'multipleSelect', options: ['A'] }, + ], + views: [{ type: 'grid' }], + }); + const nameFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + const textFieldId = table.fields.find((f) => f.name === 'Text')?.id ?? ''; + const tagsFieldId = table.fields.find((f) => f.name === 'Tags')?.id ?? ''; + + const filled = await ctx.createRecord(table.id, { + [nameFieldId]: 'filled', + [textFieldId]: 'value', + [tagsFieldId]: ['A'], + }); + const cleared = await ctx.createRecord(table.id, { + [nameFieldId]: 'cleared', + [textFieldId]: 'temp', + [tagsFieldId]: ['A'], + }); + const untouched = await ctx.createRecord(table.id, { [nameFieldId]: 'untouched' }); + + await ctx.updateRecord(table.id, cleared.id, { [textFieldId]: '', [tagsFieldId]: [] }); + + const listWithFilter = async (filter: unknown) => { + const params = new URLSearchParams({ + tableId: table.id, + fieldKeyType: FieldKeyType.Id, + filter: JSON.stringify(filter), + }); + const response = await fetch(`${ctx.baseUrl}/tables/listRecords?${params.toString()}`, { + method: 'GET', + headers: { 'content-type': 'application/json' }, + }); + const rawBody = await response.json(); + expect(response.status).toBe(200); + const parsed = listTableRecordsOkResponseSchema.safeParse(rawBody); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) throw new Error('listRecords failed'); + return parsed.data.data.records; + }; + + const emptyText = await listWithFilter({ fieldId: textFieldId, operator: 'isEmpty' }); + expect(emptyText.map((r) => r.id).sort()).toEqual([cleared.id, untouched.id].sort()); + + const notEmptyText = await listWithFilter({ fieldId: textFieldId, operator: 'isNotEmpty' }); + expect(notEmptyText.map((r) => r.id)).toEqual([filled.id]); + + const emptyTags = await listWithFilter({ fieldId: tagsFieldId, operator: 'isEmpty' }); + expect(emptyTags.map((r) => r.id).sort()).toEqual([cleared.id, untouched.id].sort()); + }); + + /** + * v1 reference: record.e2e-spec.ts:92 — listRecords with a projection + * returns only the projected field in each record's fields map. + */ + it('returns only projected fields when projection is provided', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'V2 ListRecords Projection', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Count', type: 'number' }, + ], + views: [{ type: 'grid' }], + }); + const nameFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + const countFieldId = table.fields.find((f) => f.name === 'Count')?.id ?? ''; + + await ctx.createRecord(table.id, { [nameFieldId]: 'text', [countFieldId]: 1 }); + + const params = new URLSearchParams({ + tableId: table.id, + fieldKeyType: FieldKeyType.Id, + projection: JSON.stringify([nameFieldId]), + }); + const response = await fetch(`${ctx.baseUrl}/tables/listRecords?${params.toString()}`, { + method: 'GET', + headers: { 'content-type': 'application/json' }, + }); + + expect(response.status).toBe(200); + const rawBody = await response.json(); + const parsed = listTableRecordsOkResponseSchema.safeParse(rawBody); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) return; + + const records = parsed.data.data.records; + expect(records.length).toBeGreaterThan(0); + expect(Object.keys(records[0]?.fields ?? {})).toEqual([nameFieldId]); + expect(records[0]?.fields[nameFieldId]).toBe('text'); + }); }); diff --git a/packages/v2/e2e/src/lookup-field-behaviors.e2e.spec.ts b/packages/v2/e2e/src/lookup-field-behaviors.e2e.spec.ts new file mode 100644 index 0000000000..6b500185fd --- /dev/null +++ b/packages/v2/e2e/src/lookup-field-behaviors.e2e.spec.ts @@ -0,0 +1,1404 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * V1-parity coverage for plain lookup field behaviors (T6520). + * Ports the portable cases from apps/nestjs-backend/test/lookup.e2e-spec.ts: + * - "general lookup" looked-up field type matrix (text / number / singleSelect / + * multipleSelect / date) in both multi-value (host with many links) and + * single-value (manyOne link) directions + * - "lookup filter" cases: create with filter, filter reacting to source value + * changes, filter reacting to link add/remove, filter on a select field + * - lookup with sort and limit options (v2 lookup options support) + * - lookup value seeding when the field is created after links exist + * - many-many self-link lookup updated via the symmetric link field + * - system field lookup propagation (autoNumber / createdTime / + * lastModifiedTime / createdBy / lastModifiedBy) incl. nested lookups + * - multi-layer conditional lookup chains over lookup / rollup sources + * + * Link add/remove/replace propagation without filters is covered by + * computed.e2e.spec.ts (lookup field updates / link relationship types). + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +describe('v2 lookup field behaviors (e2e)', () => { + let ctx: SharedTestContext; + let fieldIdCounter = 0; + const runId = Math.random().toString(36).slice(2, 8).padEnd(6, '0'); + + const createFieldId = () => { + const suffix = fieldIdCounter.toString(36).padStart(10, '0'); + fieldIdCounter += 1; + return `fld${runId}${suffix}`; + }; + + const drainOutbox = async (rounds = 10) => { + for (let i = 0; i < rounds; i += 1) { + const drained = await ctx.testContainer.processOutbox(); + if (drained === 0) break; + } + }; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + }); + + describe('lookup of each looked-up field type', () => { + // v1: lookup.e2e-spec.ts "should update lookupField by edit the a looked up field" + let foreignTableId: string; + let multiHostTableId: string; + let singleHostTableId: string; + let foreignRecordId: string; + let multiHostRecordId: string; + + const foreignPrimaryFieldId = createFieldId(); + const foreignTextFieldId = createFieldId(); + const foreignNumberFieldId = createFieldId(); + const foreignSingleSelectFieldId = createFieldId(); + const foreignMultipleSelectFieldId = createFieldId(); + const foreignDateFieldId = createFieldId(); + const multiHostPrimaryFieldId = createFieldId(); + const multiHostLinkFieldId = createFieldId(); + const singleHostPrimaryFieldId = createFieldId(); + const singleHostLinkFieldId = createFieldId(); + + const nowIso = new Date().toISOString(); + + const typeCases: Array<{ + label: string; + foreignFieldId: string; + updateValue: unknown; + multiExpected: unknown; + singleExpected: unknown; + multiLookupFieldId: string; + singleLookupFieldId: string; + }> = [ + { + label: 'singleLineText', + foreignFieldId: foreignTextFieldId, + updateValue: 'lookup text', + multiExpected: ['lookup text'], + singleExpected: 'lookup text', + multiLookupFieldId: createFieldId(), + singleLookupFieldId: createFieldId(), + }, + { + label: 'number', + foreignFieldId: foreignNumberFieldId, + updateValue: 123, + multiExpected: [123], + singleExpected: 123, + multiLookupFieldId: createFieldId(), + singleLookupFieldId: createFieldId(), + }, + { + label: 'singleSelect', + foreignFieldId: foreignSingleSelectFieldId, + updateValue: 'todo', + multiExpected: ['todo'], + singleExpected: 'todo', + multiLookupFieldId: createFieldId(), + singleLookupFieldId: createFieldId(), + }, + { + label: 'multipleSelect', + foreignFieldId: foreignMultipleSelectFieldId, + updateValue: ['rap'], + multiExpected: ['rap'], + singleExpected: ['rap'], + multiLookupFieldId: createFieldId(), + singleLookupFieldId: createFieldId(), + }, + { + label: 'date', + foreignFieldId: foreignDateFieldId, + updateValue: nowIso, + multiExpected: [nowIso], + singleExpected: nowIso, + multiLookupFieldId: createFieldId(), + singleLookupFieldId: createFieldId(), + }, + ]; + + beforeAll(async () => { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'LookupTypes Foreign', + fields: [ + { type: 'singleLineText', id: foreignPrimaryFieldId, name: 'Name', isPrimary: true }, + { type: 'singleLineText', id: foreignTextFieldId, name: 'Text' }, + { type: 'number', id: foreignNumberFieldId, name: 'Num' }, + { + type: 'singleSelect', + id: foreignSingleSelectFieldId, + name: 'Single', + options: { + choices: [ + { id: 'choTodo', name: 'todo', color: 'blue' }, + { id: 'choDoing', name: 'doing', color: 'green' }, + { id: 'choDone', name: 'done', color: 'red' }, + ], + }, + }, + { + type: 'multipleSelect', + id: foreignMultipleSelectFieldId, + name: 'Multi', + options: { + choices: [ + { id: 'choRap', name: 'rap', color: 'blue' }, + { id: 'choRock', name: 'rock', color: 'green' }, + { id: 'choJazz', name: 'jazz', color: 'red' }, + ], + }, + }, + { type: 'date', id: foreignDateFieldId, name: 'When' }, + ], + }); + foreignTableId = foreign.id; + + const foreignRecord = await ctx.createRecord(foreign.id, { + [foreignPrimaryFieldId]: 'F1', + }); + foreignRecordId = foreignRecord.id; + + const multiHost = await ctx.createTable({ + baseId: ctx.baseId, + name: 'LookupTypes MultiHost', + fields: [ + { type: 'singleLineText', id: multiHostPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + multiHostTableId = multiHost.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: multiHost.id, + field: { + type: 'link', + id: multiHostLinkFieldId, + name: 'Links', + options: { + relationship: 'manyMany', + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + + const singleHost = await ctx.createTable({ + baseId: ctx.baseId, + name: 'LookupTypes SingleHost', + fields: [ + { type: 'singleLineText', id: singleHostPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + singleHostTableId = singleHost.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: singleHost.id, + field: { + type: 'link', + id: singleHostLinkFieldId, + name: 'Link', + options: { + relationship: 'manyOne', + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + + for (const typeCase of typeCases) { + await ctx.createField({ + baseId: ctx.baseId, + tableId: multiHost.id, + field: { + type: 'lookup', + id: typeCase.multiLookupFieldId, + name: `lookup ${typeCase.label} [multi]`, + options: { + foreignTableId: foreign.id, + linkFieldId: multiHostLinkFieldId, + lookupFieldId: typeCase.foreignFieldId, + }, + }, + }); + await ctx.createField({ + baseId: ctx.baseId, + tableId: singleHost.id, + field: { + type: 'lookup', + id: typeCase.singleLookupFieldId, + name: `lookup ${typeCase.label} [single]`, + options: { + foreignTableId: foreign.id, + linkFieldId: singleHostLinkFieldId, + lookupFieldId: typeCase.foreignFieldId, + }, + }, + }); + } + + const multiHostRecord = await ctx.createRecord(multiHost.id, { + [multiHostPrimaryFieldId]: 'MH1', + [multiHostLinkFieldId]: [{ id: foreignRecord.id }], + }); + multiHostRecordId = multiHostRecord.id; + + await ctx.createRecord(singleHost.id, { + [singleHostPrimaryFieldId]: 'SH1', + [singleHostLinkFieldId]: { id: foreignRecord.id }, + }); + + await drainOutbox(); + }); + + afterAll(async () => { + if (multiHostTableId) await ctx.deleteTable(multiHostTableId).catch(() => undefined); + if (singleHostTableId) await ctx.deleteTable(singleHostTableId).catch(() => undefined); + if (foreignTableId) await ctx.deleteTable(foreignTableId).catch(() => undefined); + }); + + it.each(typeCases)( + 'updates lookup field when editing a looked up $label field', + async ({ foreignFieldId, updateValue, multiExpected, multiLookupFieldId }) => { + await ctx.updateRecord(foreignTableId, foreignRecordId, { + [foreignFieldId]: updateValue, + }); + await drainOutbox(); + + const multiRecords = await ctx.listRecords(multiHostTableId); + const multiRecord = multiRecords.find((r) => r.id === multiHostRecordId); + expect(multiRecord?.fields[multiLookupFieldId]).toEqual(multiExpected); + } + ); + + it.each(typeCases)( + 'returns a uniform array shape for manyOne lookups of $label fields', + async ({ foreignFieldId, updateValue, multiExpected, singleLookupFieldId }) => { + await ctx.updateRecord(foreignTableId, foreignRecordId, { + [foreignFieldId]: updateValue, + }); + await drainOutbox(); + + const singleRecords = await ctx.listRecords(singleHostTableId); + const singleRecord = singleRecords.find( + (r) => r.fields[singleHostPrimaryFieldId] === 'SH1' + ); + const value = singleRecord?.fields[singleLookupFieldId]; + // v2 contract: lookup values are arrays regardless of link multiplicity + expect(value).toEqual(multiExpected); + } + ); + + // Intentional divergence from v1 (T6520, decided 2026-08-01): v1 returns + // single-value (manyOne) lookups as scalars, mirroring the link cell's + // multiplicity. The v2 engine keeps a uniform array shape for every lookup + // regardless of relationship — the value type does not change when a link + // is converted between single and multi. v1-facing scalar presentation + // belongs to the v1 compat boundary (as done for link cell shapes in + // T6510), not the engine. The kept implementation below documents what a + // v1-shape assertion would look like at the boundary layer. + /* + it.each(typeCases)( + 'returns scalar lookup value for looked up $label field via manyOne link', + async ({ foreignFieldId, updateValue, singleExpected, singleLookupFieldId }) => { + await ctx.updateRecord(foreignTableId, foreignRecordId, { + [foreignFieldId]: updateValue, + }); + await drainOutbox(); + + const singleRecords = await ctx.listRecords(singleHostTableId); + const singleRecord = singleRecords[0]; + // v1: (await expectLookup(table2, FieldType.SingleLineText, 'lookup text')).toEqual('lookup text'); + expect(singleRecord?.fields[singleLookupFieldId]).toEqual(singleExpected); + } + ); + */ + }); + + describe('lookup filter', () => { + // v1: lookup.e2e-spec.ts "should create a lookup field with filter" + "should update a lookup field with filter" + it('creates a lookup field with filter and recomputes when source values change', async () => { + const foreignPrimaryFieldId = createFieldId(); + const hostPrimaryFieldId = createFieldId(); + const hostLinkFieldId = createFieldId(); + const lookupFieldId = createFieldId(); + + let foreignTableId: string | undefined; + let hostTableId: string | undefined; + + try { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'LookupFilter Foreign', + fields: [ + { type: 'singleLineText', id: foreignPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + foreignTableId = foreign.id; + + const b1 = await ctx.createRecord(foreign.id, { [foreignPrimaryFieldId]: 'B1' }); + const b2 = await ctx.createRecord(foreign.id, { [foreignPrimaryFieldId]: 'B2' }); + const b3 = await ctx.createRecord(foreign.id, { [foreignPrimaryFieldId]: 'B3' }); + + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'LookupFilter Host', + fields: [ + { type: 'singleLineText', id: hostPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + hostTableId = host.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'link', + id: hostLinkFieldId, + name: 'Links', + options: { + relationship: 'manyMany', + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + + const hostRecord = await ctx.createRecord(host.id, { + [hostPrimaryFieldId]: 'H1', + [hostLinkFieldId]: [{ id: b1.id }, { id: b2.id }, { id: b3.id }], + }); + await drainOutbox(); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'lookup', + id: lookupFieldId, + name: 'Filtered Names', + options: { + foreignTableId: foreign.id, + linkFieldId: hostLinkFieldId, + lookupFieldId: foreignPrimaryFieldId, + filter: { + conjunction: 'and', + filterSet: [{ fieldId: foreignPrimaryFieldId, operator: 'isNot', value: 'B1' }], + }, + }, + }, + }); + await drainOutbox(); + + let records = await ctx.listRecords(host.id); + let record = records.find((r) => r.id === hostRecord.id); + expect([...((record?.fields[lookupFieldId] as string[]) ?? [])].sort()).toEqual([ + 'B2', + 'B3', + ]); + + // Rename all source values; the isNot 'B1' filter no longer excludes anything. + await ctx.updateRecord(foreign.id, b1.id, { [foreignPrimaryFieldId]: 'BB1' }); + await ctx.updateRecord(foreign.id, b2.id, { [foreignPrimaryFieldId]: 'BB2' }); + await ctx.updateRecord(foreign.id, b3.id, { [foreignPrimaryFieldId]: 'BB3' }); + await drainOutbox(); + + records = await ctx.listRecords(host.id); + record = records.find((r) => r.id === hostRecord.id); + expect([...((record?.fields[lookupFieldId] as string[]) ?? [])].sort()).toEqual([ + 'BB1', + 'BB2', + 'BB3', + ]); + } finally { + if (hostTableId) await ctx.deleteTable(hostTableId).catch(() => undefined); + if (foreignTableId) await ctx.deleteTable(foreignTableId).catch(() => undefined); + } + }); + + // v1: lookup.e2e-spec.ts "should update a lookup field with filter when add or remove records link" + it('updates a lookup field with filter when links are added or removed', async () => { + const foreignPrimaryFieldId = createFieldId(); + const hostPrimaryFieldId = createFieldId(); + const hostLinkFieldId = createFieldId(); + const lookupFieldId = createFieldId(); + + let foreignTableId: string | undefined; + let hostTableId: string | undefined; + + try { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'LookupFilterLinks Foreign', + fields: [ + { type: 'singleLineText', id: foreignPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + foreignTableId = foreign.id; + + const b1 = await ctx.createRecord(foreign.id, { [foreignPrimaryFieldId]: 'B1' }); + const b2 = await ctx.createRecord(foreign.id, { [foreignPrimaryFieldId]: 'B2' }); + const b3 = await ctx.createRecord(foreign.id, { [foreignPrimaryFieldId]: 'B3' }); + + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'LookupFilterLinks Host', + fields: [ + { type: 'singleLineText', id: hostPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + hostTableId = host.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'link', + id: hostLinkFieldId, + name: 'Links', + options: { + relationship: 'manyMany', + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'lookup', + id: lookupFieldId, + name: 'Filtered Names', + options: { + foreignTableId: foreign.id, + linkFieldId: hostLinkFieldId, + lookupFieldId: foreignPrimaryFieldId, + filter: { + conjunction: 'and', + filterSet: [{ fieldId: foreignPrimaryFieldId, operator: 'isNot', value: 'B1' }], + }, + }, + }, + }); + + const hostRecord = await ctx.createRecord(host.id, { + [hostPrimaryFieldId]: 'H1', + [hostLinkFieldId]: [{ id: b2.id }, { id: b3.id }], + }); + await drainOutbox(); + + let records = await ctx.listRecords(host.id); + let record = records.find((r) => r.id === hostRecord.id); + expect([...((record?.fields[lookupFieldId] as string[]) ?? [])].sort()).toEqual([ + 'B2', + 'B3', + ]); + + // Adding the filtered-out record must not change the lookup value. + await ctx.updateRecord(host.id, hostRecord.id, { + [hostLinkFieldId]: [{ id: b1.id }, { id: b2.id }, { id: b3.id }], + }); + await drainOutbox(); + + records = await ctx.listRecords(host.id); + record = records.find((r) => r.id === hostRecord.id); + expect([...((record?.fields[lookupFieldId] as string[]) ?? [])].sort()).toEqual([ + 'B2', + 'B3', + ]); + + // Only the filtered-out record linked -> lookup is empty. + await ctx.updateRecord(host.id, hostRecord.id, { + [hostLinkFieldId]: [{ id: b1.id }], + }); + await drainOutbox(); + + records = await ctx.listRecords(host.id); + record = records.find((r) => r.id === hostRecord.id); + expect(record?.fields[lookupFieldId] ?? null).toBeNull(); + + // No links at all -> lookup stays empty. + await ctx.updateRecord(host.id, hostRecord.id, { [hostLinkFieldId]: null }); + await drainOutbox(); + + records = await ctx.listRecords(host.id); + record = records.find((r) => r.id === hostRecord.id); + expect(record?.fields[lookupFieldId] ?? null).toBeNull(); + } finally { + if (hostTableId) await ctx.deleteTable(hostTableId).catch(() => undefined); + if (foreignTableId) await ctx.deleteTable(foreignTableId).catch(() => undefined); + } + }); + + // v1: lookup.e2e-spec.ts "should update a lookup field with fiter when update statusField in filterSet" + it('recomputes lookup with filter when the filtered select field changes', async () => { + const foreignPrimaryFieldId = createFieldId(); + const foreignStatusFieldId = createFieldId(); + const hostPrimaryFieldId = createFieldId(); + const hostLinkFieldId = createFieldId(); + const lookupFieldId = createFieldId(); + + let foreignTableId: string | undefined; + let hostTableId: string | undefined; + + try { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'LookupFilterStatus Foreign', + fields: [ + { type: 'singleLineText', id: foreignPrimaryFieldId, name: 'Name', isPrimary: true }, + { + type: 'singleSelect', + id: foreignStatusFieldId, + name: 'Status', + options: { + choices: [ + { id: 'choX', name: 'x', color: 'cyan' }, + { id: 'choY', name: 'y', color: 'blue' }, + ], + }, + }, + ], + }); + foreignTableId = foreign.id; + + const a1 = await ctx.createRecord(foreign.id, { + [foreignPrimaryFieldId]: 'A1', + [foreignStatusFieldId]: 'x', + }); + + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'LookupFilterStatus Host', + fields: [ + { type: 'singleLineText', id: hostPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + hostTableId = host.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'link', + id: hostLinkFieldId, + name: 'Links', + options: { + relationship: 'manyMany', + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'lookup', + id: lookupFieldId, + name: 'Active Names', + options: { + foreignTableId: foreign.id, + linkFieldId: hostLinkFieldId, + lookupFieldId: foreignPrimaryFieldId, + filter: { + conjunction: 'and', + filterSet: [{ fieldId: foreignStatusFieldId, operator: 'is', value: 'x' }], + }, + }, + }, + }); + + const hostRecord = await ctx.createRecord(host.id, { + [hostPrimaryFieldId]: 'H1', + [hostLinkFieldId]: [{ id: a1.id }], + }); + await drainOutbox(); + + let records = await ctx.listRecords(host.id); + let record = records.find((r) => r.id === hostRecord.id); + expect(record?.fields[lookupFieldId]).toEqual(['A1']); + + await ctx.updateRecord(foreign.id, a1.id, { [foreignStatusFieldId]: 'y' }); + await drainOutbox(); + + records = await ctx.listRecords(host.id); + record = records.find((r) => r.id === hostRecord.id); + expect(record?.fields[lookupFieldId] ?? null).toBeNull(); + } finally { + if (hostTableId) await ctx.deleteTable(hostTableId).catch(() => undefined); + if (foreignTableId) await ctx.deleteTable(foreignTableId).catch(() => undefined); + } + }); + }); + + describe('lookup sort and limit', () => { + // Regression (T6520): lookupOptionsSchema accepts sort/limit on plain + // lookup fields; the generic lateral now applies them (ORDER BY + LIMIT in + // the aggregation subquery). No v1 counterpart — plain lookups there have + // no sort/limit at all. + it('applies sort and limit options to lookup values', async () => { + const foreignPrimaryFieldId = createFieldId(); + const foreignNumberFieldId = createFieldId(); + const hostPrimaryFieldId = createFieldId(); + const hostLinkFieldId = createFieldId(); + const lookupFieldId = createFieldId(); + const tiedLookupFieldId = createFieldId(); + const limitOnlyLookupFieldId = createFieldId(); + + let foreignTableId: string | undefined; + let hostTableId: string | undefined; + + try { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'LookupSort Foreign', + fields: [ + { type: 'singleLineText', id: foreignPrimaryFieldId, name: 'Name', isPrimary: true }, + { type: 'number', id: foreignNumberFieldId, name: 'Num' }, + ], + }); + foreignTableId = foreign.id; + + const r1 = await ctx.createRecord(foreign.id, { + [foreignPrimaryFieldId]: 'R1', + [foreignNumberFieldId]: 3, + }); + const r2 = await ctx.createRecord(foreign.id, { + [foreignPrimaryFieldId]: 'R2', + [foreignNumberFieldId]: 1, + }); + const r3 = await ctx.createRecord(foreign.id, { + [foreignPrimaryFieldId]: 'R3', + [foreignNumberFieldId]: 2, + }); + const r4 = await ctx.createRecord(foreign.id, { + [foreignPrimaryFieldId]: 'R4', + [foreignNumberFieldId]: 3, + }); + + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'LookupSort Host', + fields: [ + { type: 'singleLineText', id: hostPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + hostTableId = host.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'link', + id: hostLinkFieldId, + name: 'Links', + options: { + relationship: 'manyMany', + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'lookup', + id: lookupFieldId, + name: 'Top Nums', + options: { + foreignTableId: foreign.id, + linkFieldId: hostLinkFieldId, + lookupFieldId: foreignNumberFieldId, + sort: { fieldId: foreignNumberFieldId, order: 'desc' }, + limit: 2, + }, + }, + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'lookup', + id: tiedLookupFieldId, + name: 'Top Names', + options: { + foreignTableId: foreign.id, + linkFieldId: hostLinkFieldId, + lookupFieldId: foreignPrimaryFieldId, + sort: { fieldId: foreignNumberFieldId, order: 'desc' }, + limit: 2, + }, + }, + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'lookup', + id: limitOnlyLookupFieldId, + name: 'First Nums', + options: { + foreignTableId: foreign.id, + linkFieldId: hostLinkFieldId, + lookupFieldId: foreignNumberFieldId, + limit: 2, + }, + }, + }); + + const hostRecord = await ctx.createRecord(host.id, { + [hostPrimaryFieldId]: 'H1', + [hostLinkFieldId]: [{ id: r1.id }, { id: r2.id }, { id: r3.id }, { id: r4.id }], + }); + await drainOutbox(); + + const records = await ctx.listRecords(host.id); + const record = records.find((r) => r.id === hostRecord.id); + expect(record?.fields[lookupFieldId]).toEqual([3, 3]); + expect(record?.fields[tiedLookupFieldId]).toEqual(['R1', 'R4']); + expect(record?.fields[limitOnlyLookupFieldId]).toEqual([3, 1]); + } finally { + if (hostTableId) await ctx.deleteTable(hostTableId).catch(() => undefined); + if (foreignTableId) await ctx.deleteTable(foreignTableId).catch(() => undefined); + } + }); + }); + + describe('lookup field creation and self links', () => { + // v1: lookup.e2e-spec.ts "should calculate when add a lookup field" + it('seeds lookup values when the lookup field is created after links exist', async () => { + const foreignPrimaryFieldId = createFieldId(); + const hostPrimaryFieldId = createFieldId(); + const hostLinkFieldId = createFieldId(); + const lookupFieldId = createFieldId(); + + let foreignTableId: string | undefined; + let hostTableId: string | undefined; + + try { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'LookupSeed Foreign', + fields: [ + { type: 'singleLineText', id: foreignPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + foreignTableId = foreign.id; + + const f1 = await ctx.createRecord(foreign.id, { [foreignPrimaryFieldId]: 'A2' }); + + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'LookupSeed Host', + fields: [ + { type: 'singleLineText', id: hostPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + hostTableId = host.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'link', + id: hostLinkFieldId, + name: 'Links', + options: { + relationship: 'manyMany', + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + + // establish links before the lookup field exists + const linked = await ctx.createRecord(host.id, { + [hostPrimaryFieldId]: 'Linked', + [hostLinkFieldId]: [{ id: f1.id }], + }); + const unlinked = await ctx.createRecord(host.id, { [hostPrimaryFieldId]: 'Unlinked' }); + await drainOutbox(); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'lookup', + id: lookupFieldId, + name: 'Seeded Names', + options: { + foreignTableId: foreign.id, + linkFieldId: hostLinkFieldId, + lookupFieldId: foreignPrimaryFieldId, + }, + }, + }); + await drainOutbox(); + + const records = await ctx.listRecords(host.id); + expect(records.find((r) => r.id === linked.id)?.fields[lookupFieldId]).toEqual(['A2']); + expect(records.find((r) => r.id === unlinked.id)?.fields[lookupFieldId] ?? null).toBeNull(); + } finally { + if (hostTableId) await ctx.deleteTable(hostTableId).catch(() => undefined); + if (foreignTableId) await ctx.deleteTable(foreignTableId).catch(() => undefined); + } + }); + + // v1: lookup.e2e-spec.ts "should update a many-many self-link lookup field" + it('updates a many-many self-link lookup field via the symmetric link', async () => { + const primaryFieldId = createFieldId(); + const linkFieldId = createFieldId(); + const lookupFieldId = createFieldId(); + + let tableId: string | undefined; + + try { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'LookupSelfLink Table', + fields: [{ type: 'singleLineText', id: primaryFieldId, name: 'Name', isPrimary: true }], + }); + tableId = table.id; + + // twoWay self manyMany link so we can write through the symmetric side + await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { + type: 'link', + id: linkFieldId, + name: 'Links', + options: { + relationship: 'manyMany', + foreignTableId: table.id, + lookupFieldId: primaryFieldId, + }, + }, + }); + + const tableMeta = await ctx.getTableById(table.id); + const symmetricField = tableMeta.fields.find( + (field) => + field.type === 'link' && + (field.options as { symmetricFieldId?: string } | undefined)?.symmetricFieldId === + linkFieldId + ); + expect(symmetricField).toBeDefined(); + const symmetricFieldId = symmetricField!.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { + type: 'lookup', + id: lookupFieldId, + name: 'Linked Names', + options: { + foreignTableId: table.id, + linkFieldId, + lookupFieldId: primaryFieldId, + }, + }, + }); + + const r0 = await ctx.createRecord(table.id, { [primaryFieldId]: 'B1' }); + const r1 = await ctx.createRecord(table.id, { [primaryFieldId]: 'B2' }); + await drainOutbox(); + + // writing through the symmetric side accumulates links on r0 + await ctx.updateRecord(table.id, r0.id, { [symmetricFieldId]: [{ id: r0.id }] }); + await ctx.updateRecord(table.id, r1.id, { [symmetricFieldId]: [{ id: r0.id }] }); + await drainOutbox(); + + const records = await ctx.listRecords(table.id); + const record = records.find((r) => r.id === r0.id); + expect([...((record?.fields[lookupFieldId] as string[]) ?? [])].sort()).toEqual([ + 'B1', + 'B2', + ]); + } finally { + if (tableId) await ctx.deleteTable(tableId).catch(() => undefined); + } + }); + }); + + describe('system field lookup propagation', () => { + // v1: lookup.e2e-spec.ts "should resolve lookup values for system fields" + // + "should resolve nested lookup values for system fields" + // + created-by lookup presence (essence of "should return created-by lookup + // value in updateRecords response"; the v1 response-shape / raw dbFieldName + // projection assertions are v1-API specific) + it('resolves lookup and nested lookup values for system fields', async () => { + const normalize = (value: unknown): unknown[] => { + if (value === undefined || value === null) return []; + const flattened: unknown[] = []; + const collect = (item: unknown) => { + if (Array.isArray(item)) { + item.forEach(collect); + } else { + flattened.push(item); + } + }; + collect(value); + return flattened; + }; + + const sourcePrimaryFieldId = createFieldId(); + const sourceAutoNumberFieldId = createFieldId(); + const sourceCreatedTimeFieldId = createFieldId(); + const sourceLastModifiedTimeFieldId = createFieldId(); + const sourceCreatedByFieldId = createFieldId(); + const sourceLastModifiedByFieldId = createFieldId(); + const hostPrimaryFieldId = createFieldId(); + const hostLinkFieldId = createFieldId(); + const consumerPrimaryFieldId = createFieldId(); + const consumerLinkFieldId = createFieldId(); + + const systemFieldSpecs = [ + { key: 'autoNumber', type: 'autoNumber', sourceFieldId: sourceAutoNumberFieldId }, + { key: 'createdTime', type: 'createdTime', sourceFieldId: sourceCreatedTimeFieldId }, + { + key: 'lastModifiedTime', + type: 'lastModifiedTime', + sourceFieldId: sourceLastModifiedTimeFieldId, + }, + { key: 'createdBy', type: 'createdBy', sourceFieldId: sourceCreatedByFieldId }, + { + key: 'lastModifiedBy', + type: 'lastModifiedBy', + sourceFieldId: sourceLastModifiedByFieldId, + }, + ].map((spec) => ({ + ...spec, + hostLookupFieldId: createFieldId(), + consumerLookupFieldId: createFieldId(), + })); + + let sourceTableId: string | undefined; + let hostTableId: string | undefined; + let consumerTableId: string | undefined; + + try { + const source = await ctx.createTable({ + baseId: ctx.baseId, + name: 'SystemLookup Source', + fields: [ + { type: 'singleLineText', id: sourcePrimaryFieldId, name: 'Title', isPrimary: true }, + { type: 'autoNumber', id: sourceAutoNumberFieldId, name: 'Auto Number Field' }, + { type: 'createdTime', id: sourceCreatedTimeFieldId, name: 'Created Time Field' }, + { + type: 'lastModifiedTime', + id: sourceLastModifiedTimeFieldId, + name: 'Last Modified Time Field', + }, + { type: 'createdBy', id: sourceCreatedByFieldId, name: 'Created By Field' }, + { + type: 'lastModifiedBy', + id: sourceLastModifiedByFieldId, + name: 'Last Modified By Field', + }, + ], + }); + sourceTableId = source.id; + + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'SystemLookup Host', + fields: [ + { type: 'singleLineText', id: hostPrimaryFieldId, name: 'Host Title', isPrimary: true }, + ], + }); + hostTableId = host.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'link', + id: hostLinkFieldId, + name: 'Link To Source', + options: { + relationship: 'manyMany', + foreignTableId: source.id, + lookupFieldId: sourcePrimaryFieldId, + isOneWay: true, + }, + }, + }); + + for (const spec of systemFieldSpecs) { + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'lookup', + id: spec.hostLookupFieldId, + name: `Lookup ${spec.key}`, + options: { + foreignTableId: source.id, + linkFieldId: hostLinkFieldId, + lookupFieldId: spec.sourceFieldId, + }, + }, + }); + } + + const consumer = await ctx.createTable({ + baseId: ctx.baseId, + name: 'SystemLookup Consumer', + fields: [ + { + type: 'singleLineText', + id: consumerPrimaryFieldId, + name: 'Consumer Title', + isPrimary: true, + }, + ], + }); + consumerTableId = consumer.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: consumer.id, + field: { + type: 'link', + id: consumerLinkFieldId, + name: 'Link To Host', + options: { + relationship: 'manyMany', + foreignTableId: host.id, + lookupFieldId: hostPrimaryFieldId, + isOneWay: true, + }, + }, + }); + + for (const spec of systemFieldSpecs) { + await ctx.createField({ + baseId: ctx.baseId, + tableId: consumer.id, + field: { + type: 'lookup', + id: spec.consumerLookupFieldId, + name: `Nested Lookup ${spec.key}`, + options: { + foreignTableId: host.id, + linkFieldId: consumerLinkFieldId, + lookupFieldId: spec.hostLookupFieldId, + }, + }, + }); + } + + const sourceRecord = await ctx.createRecord(source.id, { + [sourcePrimaryFieldId]: 'S1', + }); + const hostRecord = await ctx.createRecord(host.id, { + [hostPrimaryFieldId]: 'H1', + [hostLinkFieldId]: [{ id: sourceRecord.id }], + }); + const consumerRecord = await ctx.createRecord(consumer.id, { + [consumerPrimaryFieldId]: 'C1', + [consumerLinkFieldId]: [{ id: hostRecord.id }], + }); + await drainOutbox(); + + const sourceRecords = await ctx.listRecords(source.id); + const hostRecords = await ctx.listRecords(host.id); + const consumerRecords = await ctx.listRecords(consumer.id); + const storedSource = sourceRecords.find((r) => r.id === sourceRecord.id); + const storedHost = hostRecords.find((r) => r.id === hostRecord.id); + const storedConsumer = consumerRecords.find((r) => r.id === consumerRecord.id); + expect(storedSource).toBeDefined(); + expect(storedHost).toBeDefined(); + expect(storedConsumer).toBeDefined(); + + for (const spec of systemFieldSpecs) { + const sourceValue = normalize(storedSource?.fields[spec.sourceFieldId]); + const hostValue = normalize(storedHost?.fields[spec.hostLookupFieldId]); + const consumerValue = normalize(storedConsumer?.fields[spec.consumerLookupFieldId]); + + expect(sourceValue.length, `${spec.key} source value should exist`).toBeGreaterThan(0); + expect(hostValue, `${spec.key} host lookup should mirror the source`).toEqual( + sourceValue + ); + expect(consumerValue, `${spec.key} nested lookup should mirror the host`).toEqual( + hostValue + ); + } + } finally { + if (consumerTableId) await ctx.deleteTable(consumerTableId).catch(() => undefined); + if (hostTableId) await ctx.deleteTable(hostTableId).catch(() => undefined); + if (sourceTableId) await ctx.deleteTable(sourceTableId).catch(() => undefined); + } + }); + }); + + describe('conditional lookup chains', () => { + // v1: lookup.e2e-spec.ts "conditional lookup chains": + // "should resolve multi-layer conditional lookup returning text values" + // "should resolve multi-layer conditional lookup returning number values" + // "should compute conditional rollup values from nested lookups" + it('resolves multi-layer conditional lookups over lookup and rollup sources', async () => { + const flatten = (value: unknown): unknown[] => { + const flattened: unknown[] = []; + const collect = (item: unknown) => { + if (Array.isArray(item)) { + item.forEach(collect); + } else if (item !== null && item !== undefined) { + flattened.push(item); + } + }; + collect(value); + return flattened; + }; + + const leafNameFieldId = createFieldId(); + const leafScoreFieldId = createFieldId(); + const middleCategoryFieldId = createFieldId(); + const middleLinkFieldId = createFieldId(); + const middleNameLookupFieldId = createFieldId(); + const middleScoreLookupFieldId = createFieldId(); + const middleScoreRollupFieldId = createFieldId(); + const rootCategoryFilterFieldId = createFieldId(); + const rootCondNameLookupFieldId = createFieldId(); + const rootCondScoreLookupFieldId = createFieldId(); + const rootCondRollupFieldId = createFieldId(); + + let leafTableId: string | undefined; + let middleTableId: string | undefined; + let rootTableId: string | undefined; + + try { + const leaf = await ctx.createTable({ + baseId: ctx.baseId, + name: 'CondChain Leaf', + fields: [ + { type: 'singleLineText', id: leafNameFieldId, name: 'LeafName', isPrimary: true }, + { type: 'number', id: leafScoreFieldId, name: 'LeafScore' }, + ], + }); + leafTableId = leaf.id; + + const alpha = await ctx.createRecord(leaf.id, { + [leafNameFieldId]: 'Alpha', + [leafScoreFieldId]: 10, + }); + const beta = await ctx.createRecord(leaf.id, { + [leafNameFieldId]: 'Beta', + [leafScoreFieldId]: 20, + }); + const gamma = await ctx.createRecord(leaf.id, { + [leafNameFieldId]: 'Gamma', + [leafScoreFieldId]: 30, + }); + + const middle = await ctx.createTable({ + baseId: ctx.baseId, + name: 'CondChain Middle', + fields: [ + { + type: 'singleLineText', + id: middleCategoryFieldId, + name: 'Category', + isPrimary: true, + }, + ], + }); + middleTableId = middle.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: middle.id, + field: { + type: 'link', + id: middleLinkFieldId, + name: 'LeafLink', + options: { + relationship: 'manyMany', + foreignTableId: leaf.id, + lookupFieldId: leafNameFieldId, + isOneWay: true, + }, + }, + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: middle.id, + field: { + type: 'lookup', + id: middleNameLookupFieldId, + name: 'LeafNames', + options: { + foreignTableId: leaf.id, + linkFieldId: middleLinkFieldId, + lookupFieldId: leafNameFieldId, + }, + }, + }); + await ctx.createField({ + baseId: ctx.baseId, + tableId: middle.id, + field: { + type: 'lookup', + id: middleScoreLookupFieldId, + name: 'LeafScores', + options: { + foreignTableId: leaf.id, + linkFieldId: middleLinkFieldId, + lookupFieldId: leafScoreFieldId, + }, + }, + }); + await ctx.createField({ + baseId: ctx.baseId, + tableId: middle.id, + field: { + type: 'rollup', + id: middleScoreRollupFieldId, + name: 'LeafScoreTotal', + options: { expression: 'sum({values})' }, + config: { + linkFieldId: middleLinkFieldId, + foreignTableId: leaf.id, + lookupFieldId: leafScoreFieldId, + }, + }, + }); + + await ctx.createRecord(middle.id, { + [middleCategoryFieldId]: 'Hardware', + [middleLinkFieldId]: [{ id: alpha.id }], + }); + await ctx.createRecord(middle.id, { + [middleCategoryFieldId]: 'Hardware', + [middleLinkFieldId]: [{ id: beta.id }], + }); + await ctx.createRecord(middle.id, { + [middleCategoryFieldId]: 'Software', + [middleLinkFieldId]: [{ id: gamma.id }], + }); + + const categoryMatchFilter = { + conjunction: 'and' as const, + filterSet: [ + { + fieldId: middleCategoryFieldId, + operator: 'is', + value: rootCategoryFilterFieldId, + isSymbol: true, + }, + ], + }; + + const root = await ctx.createTable({ + baseId: ctx.baseId, + name: 'CondChain Root', + fields: [ + { + type: 'singleLineText', + id: rootCategoryFilterFieldId, + name: 'CategoryFilter', + isPrimary: true, + }, + ], + }); + rootTableId = root.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: root.id, + field: { + type: 'conditionalLookup', + id: rootCondNameLookupFieldId, + name: 'FilteredLeafNames', + options: { + foreignTableId: middle.id, + lookupFieldId: middleNameLookupFieldId, + condition: { filter: categoryMatchFilter }, + }, + }, + }); + await ctx.createField({ + baseId: ctx.baseId, + tableId: root.id, + field: { + type: 'conditionalLookup', + id: rootCondScoreLookupFieldId, + name: 'FilteredLeafScores', + options: { + foreignTableId: middle.id, + lookupFieldId: middleScoreLookupFieldId, + condition: { filter: categoryMatchFilter }, + }, + }, + }); + await ctx.createField({ + baseId: ctx.baseId, + tableId: root.id, + field: { + type: 'conditionalRollup', + id: rootCondRollupFieldId, + name: 'FilteredLeafScoreSum', + options: { expression: 'sum({values})' }, + config: { + foreignTableId: middle.id, + lookupFieldId: middleScoreRollupFieldId, + condition: { filter: categoryMatchFilter }, + }, + }, + }); + + const hardwareRoot = await ctx.createRecord(root.id, { + [rootCategoryFilterFieldId]: 'Hardware', + }); + const softwareRoot = await ctx.createRecord(root.id, { + [rootCategoryFilterFieldId]: 'Software', + }); + await drainOutbox(); + + const rootRecords = await ctx.listRecords(root.id); + const hardwareRecord = rootRecords.find((r) => r.id === hardwareRoot.id); + const softwareRecord = rootRecords.find((r) => r.id === softwareRoot.id); + + expect(flatten(hardwareRecord?.fields[rootCondNameLookupFieldId]).sort()).toEqual([ + 'Alpha', + 'Beta', + ]); + expect(flatten(softwareRecord?.fields[rootCondNameLookupFieldId])).toEqual(['Gamma']); + + expect(flatten(hardwareRecord?.fields[rootCondScoreLookupFieldId]).sort()).toEqual([ + 10, 20, + ]); + expect(flatten(softwareRecord?.fields[rootCondScoreLookupFieldId])).toEqual([30]); + + expect(hardwareRecord?.fields[rootCondRollupFieldId]).toEqual(30); + expect(softwareRecord?.fields[rootCondRollupFieldId]).toEqual(30); + } finally { + if (rootTableId) await ctx.deleteTable(rootTableId).catch(() => undefined); + if (middleTableId) await ctx.deleteTable(middleTableId).catch(() => undefined); + if (leafTableId) await ctx.deleteTable(leafTableId).catch(() => undefined); + } + }); + }); +}); diff --git a/packages/v2/e2e/src/numeric-coercion.e2e.spec.ts b/packages/v2/e2e/src/numeric-coercion.e2e.spec.ts index 59cd3c0c10..6406d3374c 100644 --- a/packages/v2/e2e/src/numeric-coercion.e2e.spec.ts +++ b/packages/v2/e2e/src/numeric-coercion.e2e.spec.ts @@ -736,7 +736,7 @@ describe('v2 numeric coercion (e2e)', () => { expect(pendingResult?.fields[formulaFieldId]).toBe('pending'); }); - it('compares checkbox values against zero', async () => { + it('treats an unchecked checkbox as empty when comparing against zero', async () => { const table = await ctx.createTable({ baseId: ctx.baseId, name: 'checkbox_zero_test', @@ -780,7 +780,7 @@ describe('v2 numeric coercion (e2e)', () => { [checkboxFieldId]: true, }); - // Create record with checkbox = false + // An unchecked checkbox is normalized to an empty cell. const inactiveRecord = await ctx.createRecord(table.id, { Name: 'Inactive User', [checkboxFieldId]: false, @@ -795,8 +795,8 @@ describe('v2 numeric coercion (e2e)', () => { // checkbox true (=1) should NOT compare equal to 0, returning 'active' expect(activeResult?.fields[formulaFieldId]).toBe('active'); - // checkbox false (=0) should compare equal to 0, returning 'inactive' - expect(inactiveResult?.fields[formulaFieldId]).toBe('inactive'); + // v1 contract: unchecked is stored as null, and null = 0 is false, so 'active' + expect(inactiveResult?.fields[formulaFieldId]).toBe('active'); }); }); }); diff --git a/packages/v2/e2e/src/paste.e2e.spec.ts b/packages/v2/e2e/src/paste.e2e.spec.ts index 5966546669..f421ad428a 100644 --- a/packages/v2/e2e/src/paste.e2e.spec.ts +++ b/packages/v2/e2e/src/paste.e2e.spec.ts @@ -5547,4 +5547,387 @@ describe('v2 http paste (e2e)', () => { await ctx.updateRecord(descTableId, descRecX1Id, { [descNameFieldId]: 'X1' }); }); }); + + describe('paste computed numeric coercion regression (v1 parity)', () => { + it('recomputes a numeric formula when pasted text contains multiple numeric fragments', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `Paste Numeric Coercion ${Date.now()}`, + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Score', type: 'number' }, + { name: 'WeightText', type: 'singleLineText' }, + ], + views: [{ type: 'grid' }], + }); + + const scoreFieldId = table.fields.find((field) => field.name === 'Score')?.id ?? ''; + const weightFieldId = table.fields.find((field) => field.name === 'WeightText')?.id ?? ''; + + const tableWithFormula = await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { + name: 'WeightedScore', + type: 'formula', + options: { expression: `{${scoreFieldId}} * {${weightFieldId}}` }, + }, + }); + const weightedScoreFieldId = + tableWithFormula.fields.find((field) => field.name === 'WeightedScore')?.id ?? ''; + + await ctx.createRecord(table.id, { + [table.fields.find((field) => field.isPrimary)?.id ?? '']: 'row-1', + [scoreFieldId]: 10, + [weightFieldId]: '0.5', + }); + await ctx.drainOutbox(); + + const result = await ctx.paste({ + tableId: table.id, + viewId: table.views[0].id, + projection: [weightFieldId], + content: '0.4/0.6', + ranges: [ + [0, 0], + [0, 0], + ], + }); + + expect(result.updatedCount).toBe(1); + await ctx.drainOutbox(); + + const records = await ctx.listRecords(table.id); + expect(records[0].fields[weightFieldId]).toBe('0.4/0.6'); + expect(records[0].fields[weightedScoreFieldId]).toBeCloseTo(4, 10); + }); + }); + + describe('paste lookup date into date field (v1 parity)', () => { + const dateFormatting = { date: 'YYYY-MM-DD', time: 'None', timeZone: 'utc' } as const; + + const setupLookupDateFixture = async (sourceDates: string[]) => { + const sourceTable = await ctx.createTable({ + baseId: ctx.baseId, + name: `Paste Lookup Date Source ${Date.now()}-${sourceDates.length}`, + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Activity Date', type: 'date', options: { formatting: dateFormatting } }, + ], + views: [{ type: 'grid' }], + }); + const sourceNameFieldId = sourceTable.fields.find((field) => field.isPrimary)?.id ?? ''; + const sourceDateFieldId = + sourceTable.fields.find((field) => field.name === 'Activity Date')?.id ?? ''; + + const sourceRecordIds: string[] = []; + for (const [index, date] of sourceDates.entries()) { + const record = await ctx.createRecord(sourceTable.id, { + [sourceNameFieldId]: `Activity ${index + 1}`, + [sourceDateFieldId]: date, + }); + sourceRecordIds.push(record.id); + } + + const hostTable = await ctx.createTable({ + baseId: ctx.baseId, + name: `Paste Lookup Date Host ${Date.now()}-${sourceDates.length}`, + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Target Date', type: 'date', options: { formatting: dateFormatting } }, + ], + views: [{ type: 'grid' }], + }); + const hostPrimaryFieldId = hostTable.fields.find((field) => field.isPrimary)?.id ?? ''; + const targetDateFieldId = + hostTable.fields.find((field) => field.name === 'Target Date')?.id ?? ''; + + const hostTableWithLink = await ctx.createField({ + baseId: ctx.baseId, + tableId: hostTable.id, + field: { + name: 'Activities', + type: 'link', + options: { + relationship: 'oneMany', + foreignTableId: sourceTable.id, + lookupFieldId: sourceNameFieldId, + isOneWay: true, + }, + }, + }); + const linkFieldId = + hostTableWithLink.fields.find((field) => field.name === 'Activities')?.id ?? ''; + + const hostTableWithLookup = await ctx.createField({ + baseId: ctx.baseId, + tableId: hostTable.id, + field: { + name: 'Date (from Activities)', + type: 'lookup', + options: { + linkFieldId, + foreignTableId: sourceTable.id, + lookupFieldId: sourceDateFieldId, + }, + }, + }); + const lookupDateFieldId = + hostTableWithLookup.fields.find((field) => field.name === 'Date (from Activities)')?.id ?? + ''; + + const hostRecord = await ctx.createRecord(hostTable.id, { + [hostPrimaryFieldId]: 'Row 1', + [linkFieldId]: sourceRecordIds.map((id) => ({ id })), + }); + await ctx.drainOutbox(); + + const targetDateFieldIndex = hostTableWithLookup.fields.findIndex( + (field) => field.id === targetDateFieldId + ); + + return { + hostTableId: hostTable.id, + hostViewId: hostTable.views[0].id, + hostRecordId: hostRecord.id, + targetDateFieldId, + targetDateFieldIndex, + lookupDateFieldId, + }; + }; + + const lookupSourceFieldMeta = { + name: 'Date (from Activities)', + type: 'date', + cellValueType: 'dateTime', + isComputed: true, + isLookup: true, + isMultipleCellValue: true, + options: { formatting: dateFormatting }, + }; + + it('pastes a raw lookup date array into a regular date field', async () => { + const fixture = await setupLookupDateFixture(['2026-02-15T00:00:00.000Z']); + + const records = await ctx.listRecords(fixture.hostTableId); + const lookupValue = records.find((record) => record.id === fixture.hostRecordId)?.fields[ + fixture.lookupDateFieldId + ]; + expect(lookupValue).toEqual(['2026-02-15T00:00:00.000Z']); + + const result = await ctx.paste({ + tableId: fixture.hostTableId, + viewId: fixture.hostViewId, + content: [[lookupValue]], + sourceFields: [lookupSourceFieldMeta], + ranges: [ + [fixture.targetDateFieldIndex, 0], + [fixture.targetDateFieldIndex, 0], + ], + }); + + expect(result.updatedCount).toBe(1); + + const afterRecords = await ctx.listRecords(fixture.hostTableId); + expect( + afterRecords.find((record) => record.id === fixture.hostRecordId)?.fields[ + fixture.targetDateFieldId + ] + ).toBe('2026-02-15T00:00:00.000Z'); + }); + + it('keeps the first raw lookup date when pasting multiple lookup dates', async () => { + const fixture = await setupLookupDateFixture([ + '2026-02-15T00:00:00.000Z', + '2026-02-20T00:00:00.000Z', + ]); + + const records = await ctx.listRecords(fixture.hostTableId); + const lookupValue = records.find((record) => record.id === fixture.hostRecordId)?.fields[ + fixture.lookupDateFieldId + ]; + expect(lookupValue).toEqual(['2026-02-15T00:00:00.000Z', '2026-02-20T00:00:00.000Z']); + + const result = await ctx.paste({ + tableId: fixture.hostTableId, + viewId: fixture.hostViewId, + content: [[lookupValue]], + sourceFields: [lookupSourceFieldMeta], + ranges: [ + [fixture.targetDateFieldIndex, 0], + [fixture.targetDateFieldIndex, 0], + ], + }); + + expect(result.updatedCount).toBe(1); + + const afterRecords = await ctx.listRecords(fixture.hostTableId); + expect( + afterRecords.find((record) => record.id === fixture.hostRecordId)?.fields[ + fixture.targetDateFieldId + ] + ).toBe('2026-02-15T00:00:00.000Z'); + }); + }); + + describe('paste empty-string normalization on update path (T6520)', () => { + it('stores null when pasting empty strings over existing values', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `Paste Empty String Update ${Date.now()}`, + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Count', type: 'number' }, + ], + views: [{ type: 'grid' }], + }); + + const nameFieldId = table.fields.find((field) => field.isPrimary)?.id ?? ''; + const countFieldId = table.fields.find((field) => field.name === 'Count')?.id ?? ''; + + await ctx.createRecord(table.id, { + [nameFieldId]: 'Filled', + [countFieldId]: 42, + }); + + const result = await ctx.paste({ + tableId: table.id, + viewId: table.views[0].id, + ranges: [ + [0, 0], + [1, 0], + ], + content: [['', '']], + typecast: true, + }); + + expect(result.updatedCount).toBe(1); + + const records = await ctx.listRecords(table.id); + expect(records[0].fields[nameFieldId]).toBeNull(); + expect(records[0].fields[countFieldId]).toBeNull(); + }); + + it('stores null when pasting an empty string without typecast', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `Paste Empty String No Typecast ${Date.now()}`, + fields: [{ name: 'Name', type: 'singleLineText', isPrimary: true }], + views: [{ type: 'grid' }], + }); + + const nameFieldId = table.fields.find((field) => field.isPrimary)?.id ?? ''; + await ctx.createRecord(table.id, { [nameFieldId]: 'Filled' }); + + const result = await ctx.paste({ + tableId: table.id, + viewId: table.views[0].id, + ranges: [ + [0, 0], + [0, 0], + ], + content: [['']], + }); + + expect(result.updatedCount).toBe(1); + + const records = await ctx.listRecords(table.id); + expect(records[0].fields[nameFieldId]).toBeNull(); + }); + }); + + describe('paste user by collaborator name (v1 parity)', () => { + it('resolves a collaborator by name when pasting text into a user field', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `Paste User By Name ${Date.now()}`, + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Assignee', type: 'user', options: { isMultiple: false } }, + ], + views: [{ type: 'grid' }], + }); + + const nameFieldId = table.fields.find((field) => field.isPrimary)?.id ?? ''; + const userFieldId = table.fields.find((field) => field.name === 'Assignee')?.id ?? ''; + const userFieldIndex = table.fields.findIndex((field) => field.id === userFieldId); + + await ctx.createRecord(table.id, { [nameFieldId]: 'Row 1' }); + + const result = await ctx.paste({ + tableId: table.id, + viewId: table.views[0].id, + ranges: [ + [userFieldIndex, 0], + [userFieldIndex, 0], + ], + content: [[ctx.testUser.name]], + typecast: true, + }); + + expect(result.updatedCount).toBe(1); + + const records = await ctx.listRecords(table.id); + expect(records[0].fields[userFieldId]).toMatchObject({ + id: ctx.testUser.id, + title: ctx.testUser.name, + }); + }); + }); + + describe('paste with incomplete view filters (v1 parity)', () => { + it('should ignore incomplete non-checkbox view filters before pasting', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `Paste Incomplete Filter ${Date.now()}`, + fields: [ + { name: 'Label', type: 'singleLineText', isPrimary: true }, + { name: 'Number', type: 'number' }, + { name: 'Status', type: 'singleSelect', options: ['To do', 'In progress', 'Done'] }, + ], + views: [{ type: 'grid' }], + }); + + const labelFieldId = table.fields.find((field) => field.isPrimary)?.id ?? ''; + const numberFieldIdLocal = table.fields.find((field) => field.name === 'Number')?.id ?? ''; + const statusFieldId = table.fields.find((field) => field.name === 'Status')?.id ?? ''; + const statusFieldIndex = table.fields.findIndex((field) => field.id === statusFieldId); + + const recordIds: string[] = []; + for (const label of ['row1', 'row2', 'row3', 'row4']) { + const record = await ctx.createRecord(table.id, { [labelFieldId]: label }); + recordIds.push(record.id); + } + + // Incomplete filter: operator requires a value but value is null. + await ctx.testContainer.db + .updateTable('view') + .set({ + filter: JSON.stringify({ + conjunction: 'and', + filterSet: [{ fieldId: numberFieldIdLocal, operator: 'is', value: null }], + }), + }) + .where('id', '=', table.views[0].id) + .execute(); + + const result = await ctx.paste({ + tableId: table.id, + viewId: table.views[0].id, + ranges: [ + [statusFieldIndex, 2], + [statusFieldIndex, 2], + ], + content: [['In progress']], + }); + + expect(result.updatedCount).toBe(1); + expect(result.createdCount).toBe(0); + + const records = await ctx.listRecords(table.id); + expect(records).toHaveLength(4); + const target = records.find((record) => record.id === recordIds[2]); + expect(target?.fields[statusFieldId]).toBe('In progress'); + }); + }); }); diff --git a/packages/v2/e2e/src/rating-typecast-domain.e2e.spec.ts b/packages/v2/e2e/src/rating-typecast-domain.e2e.spec.ts new file mode 100644 index 0000000000..9546a160e9 --- /dev/null +++ b/packages/v2/e2e/src/rating-typecast-domain.e2e.spec.ts @@ -0,0 +1,189 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { + createRecordOkResponseSchema, + getRecordByIdOkResponseSchema, + updateRecordOkResponseSchema, +} from '@teable/v2-contract-http'; +import { FieldKeyType } from '@teable/v2-core'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +/** + * T6515: rating typecast must only persist values in {null} ∪ {1..max}. + * + * Assertions intentionally re-read via getRecordById. Create/update response + * bodies can echo request values and are not proof of storage. + */ +describe('v2 rating typecast domain (e2e)', () => { + let ctx: SharedTestContext; + let tableId: string; + let titleFieldId: string; + let ratingFieldId: string; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `rating-typecast-${Date.now()}`, + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { type: 'rating', name: 'Score', options: { max: 5, icon: 'star', color: 'yellowBright' } }, + ], + views: [{ type: 'grid' }], + }); + + tableId = table.id; + titleFieldId = table.fields.find((field) => field.name === 'Title')?.id ?? ''; + ratingFieldId = table.fields.find((field) => field.name === 'Score')?.id ?? ''; + + if (!titleFieldId || !ratingFieldId) { + throw new Error('Failed to resolve rating typecast fixture fields'); + } + }, 30000); + + const getStoredRating = async (recordId: string): Promise => { + const params = new URLSearchParams({ tableId, recordId }); + const response = await fetch(`${ctx.baseUrl}/tables/getRecord?${params.toString()}`, { + method: 'GET', + }); + const raw = await response.json(); + expect(response.status).toBe(200); + const parsed = getRecordByIdOkResponseSchema.safeParse(raw); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) { + throw new Error(`getRecord failed: ${JSON.stringify(raw)}`); + } + return parsed.data.data.record.fields[ratingFieldId]; + }; + + const createWithTypecast = async (input: unknown, label: string) => { + const response = await fetch(`${ctx.baseUrl}/tables/createRecord`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tableId, + typecast: true, + fieldKeyType: FieldKeyType.Id, + fields: { + [titleFieldId]: label, + [ratingFieldId]: input, + }, + }), + }); + const raw = await response.json(); + expect(response.status).toBe(201); + const parsed = createRecordOkResponseSchema.safeParse(raw); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) { + throw new Error(`createRecord failed: ${JSON.stringify(raw)}`); + } + return parsed.data.data.record.id; + }; + + const updateWithTypecast = async (recordId: string, input: unknown) => { + const response = await fetch(`${ctx.baseUrl}/tables/updateRecord`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tableId, + recordId, + typecast: true, + fieldKeyType: FieldKeyType.Id, + fields: { + [ratingFieldId]: input, + }, + }), + }); + const raw = await response.json(); + expect(response.status).toBe(200); + const parsed = updateRecordOkResponseSchema.safeParse(raw); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) { + throw new Error(`updateRecord failed: ${JSON.stringify(raw)}`); + } + }; + + const updateStrict = async (recordId: string, input: unknown) => { + const response = await fetch(`${ctx.baseUrl}/tables/updateRecord`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tableId, + recordId, + typecast: false, + fieldKeyType: FieldKeyType.Id, + fields: { + [ratingFieldId]: input, + }, + }), + }); + const raw = await response.json(); + return { status: response.status, raw }; + }; + + const cases: Array<{ name: string; input: unknown; expected: number | null }> = [ + { name: 'integer', input: 3, expected: 3 }, + { name: 'fraction-round-up', input: 2.7, expected: 3 }, + { name: 'fraction-round-down', input: 2.4, expected: 2 }, + { name: 'fraction-near-max', input: 4.6, expected: 5 }, + { name: 'above-max', input: 5.5, expected: 5 }, + { name: 'far-above-max', input: 9, expected: 5 }, + { name: 'zero', input: 0, expected: null }, + { name: 'fraction-below-one', input: 0.4, expected: null }, + { name: 'negative', input: -3, expected: null }, + { name: 'string-integer', input: '3', expected: 3 }, + { name: 'string-fraction', input: '2.7', expected: 3 }, + { name: 'string-garbage', input: 'abc', expected: null }, + { name: 'empty-string', input: '', expected: null }, + { name: 'null', input: null, expected: null }, + ]; + + it.each(cases)( + 'createRecord typecast stores $name as $expected and allows strict rewrite', + async ({ input, expected, name }) => { + const recordId = await createWithTypecast(input, `create-${name}`); + const stored = await getStoredRating(recordId); + expect(stored ?? null).toBe(expected); + + // Stored value must always be accepted by the strict path. + const rewrite = await updateStrict(recordId, stored ?? null); + expect(rewrite.status).toBe(200); + const storedAfterStrict = await getStoredRating(recordId); + expect(storedAfterStrict ?? null).toBe(expected); + } + ); + + it.each(cases)( + 'updateRecord typecast stores $name as $expected and allows strict rewrite', + async ({ input, expected, name }) => { + const recordId = await createWithTypecast(null, `update-${name}`); + await updateWithTypecast(recordId, input); + + const stored = await getStoredRating(recordId); + expect(stored ?? null).toBe(expected); + + const rewrite = await updateStrict(recordId, stored ?? null); + expect(rewrite.status).toBe(200); + const storedAfterStrict = await getStoredRating(recordId); + expect(storedAfterStrict ?? null).toBe(expected); + } + ); + + it('rejects fractional rating without typecast on create', async () => { + const response = await fetch(`${ctx.baseUrl}/tables/createRecord`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tableId, + typecast: false, + fieldKeyType: FieldKeyType.Id, + fields: { + [titleFieldId]: 'strict-reject', + [ratingFieldId]: 2.7, + }, + }), + }); + expect(response.status).toBe(400); + }); +}); diff --git a/packages/v2/e2e/src/record-constraint-violations.e2e.spec.ts b/packages/v2/e2e/src/record-constraint-violations.e2e.spec.ts index 4abdc1d3d7..810b60e54b 100644 --- a/packages/v2/e2e/src/record-constraint-violations.e2e.spec.ts +++ b/packages/v2/e2e/src/record-constraint-violations.e2e.spec.ts @@ -128,6 +128,10 @@ describe('v2 constraint violation errors (P0)', () => { expect(parsed.data.error.code).toBe('validation.field.not_null'); expect(parsed.data.error.tags).toContain('validation'); expect(parsed.data.error.message).toMatch(/cannot be empty|not-null/); + expect(parsed.data.error.localization).toEqual({ + i18nKey: 'httpErrors.custom.recordFieldValueNotNull', + context: { fieldName: 'Required' }, + }); } }); @@ -316,7 +320,7 @@ describe('v2 constraint violation errors (P0)', () => { }); describe('constraint error message format', () => { - it('includes operation type in error message', async () => { + it('names the violated field in the create error message', async () => { const table = await createTable({ baseId: ctx.baseId, name: uniqueTableName('error-message-format'), @@ -328,9 +332,12 @@ describe('v2 constraint violation errors (P0)', () => { notNull: true, }); - // Insert should include 'insert' in error message + // A missing notNull field is rejected by application-level pre-validation + // before any SQL runs (T6520 auto-number continuity), so the message names + // the field instead of the SQL operation. const insertRaw = await createRecordRaw(table.id, {}, 400); - expect(insertRaw.error?.message).toContain('insert'); + expect(insertRaw.error?.code).toBe('validation.field.not_null'); + expect(insertRaw.error?.message).toContain('Required'); }); }); }); diff --git a/packages/v2/e2e/src/record-empty-value-normalization.e2e.spec.ts b/packages/v2/e2e/src/record-empty-value-normalization.e2e.spec.ts new file mode 100644 index 0000000000..2d9474c999 --- /dev/null +++ b/packages/v2/e2e/src/record-empty-value-normalization.e2e.spec.ts @@ -0,0 +1,209 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { updateRecordOkResponseSchema } from '@teable/v2-contract-http'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +/** + * E2E tests for the v1 empty-value normalization contract (T6520). + * + * v1 never stores "empty" scalar inputs verbatim: clearing a cell with + * false (checkbox), "" (text) or [] (multi-value fields) is stored as null, + * so reads return the cell as empty. v2 must behave the same on both the + * strict and typecast write paths. + */ +describe('v2 record empty value normalization (e2e)', () => { + let ctx: SharedTestContext; + let tableId: string; + let primaryFieldId: string; + let longTextFieldId: string; + let checkboxFieldId: string; + let multiSelectFieldId: string; + let userFieldId: string; + let attachmentFieldId: string; + let linkFieldId: string; + let foreignTableId: string; + let foreignRecordId: string; + + const expectEmpty = (value: unknown) => { + // stored null may surface as null or an absent key depending on serializer + expect(value == null).toBe(true); + }; + + const readRecord = async (recordId: string) => { + const records = await ctx.listRecords(tableId); + const record = records.find((r) => r.id === recordId); + expect(record).toBeDefined(); + return record!; + }; + + const updateWithTypecast = async (recordId: string, fields: Record) => { + const response = await fetch(`${ctx.baseUrl}/tables/updateRecord`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tableId, recordId, typecast: true, fields }), + }); + expect(response.status).toBe(200); + const parsed = updateRecordOkResponseSchema.safeParse(await response.json()); + expect(parsed.success).toBe(true); + }; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Empty Norm Foreign', + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + foreignTableId = foreignTable.id; + const foreignNameFieldId = foreignTable.fields.find((f) => f.isPrimary)?.id ?? ''; + const foreignRecord = await ctx.createRecord(foreignTableId, { + [foreignNameFieldId]: 'Target', + }); + foreignRecordId = foreignRecord.id; + + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Empty Norm Table', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { type: 'longText', name: 'Notes' }, + { type: 'checkbox', name: 'Done' }, + { type: 'multipleSelect', name: 'Tags', options: ['Tag A', 'Tag B'] }, + { type: 'user', name: 'Team', options: { isMultiple: true, shouldNotify: false } }, + { type: 'attachment', name: 'Files' }, + { + type: 'link', + name: 'Related', + options: { + relationship: 'manyMany', + foreignTableId: foreignTable.id, + lookupFieldId: foreignNameFieldId, + }, + }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + primaryFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + longTextFieldId = table.fields.find((f) => f.name === 'Notes')?.id ?? ''; + checkboxFieldId = table.fields.find((f) => f.name === 'Done')?.id ?? ''; + multiSelectFieldId = table.fields.find((f) => f.name === 'Tags')?.id ?? ''; + userFieldId = table.fields.find((f) => f.name === 'Team')?.id ?? ''; + attachmentFieldId = table.fields.find((f) => f.name === 'Files')?.id ?? ''; + linkFieldId = table.fields.find((f) => f.name === 'Related')?.id ?? ''; + }); + + it('stores false as null when resetting a checkbox (v1 record.e2e "use false to reset checkbox field")', async () => { + const record = await ctx.createRecord(tableId, { + [primaryFieldId]: 'checkbox reset', + [checkboxFieldId]: true, + }); + expect(record.fields[checkboxFieldId]).toBe(true); + + await ctx.updateRecord(tableId, record.id, { [checkboxFieldId]: false }); + + const updated = await readRecord(record.id); + expectEmpty(updated.fields[checkboxFieldId]); + }); + + it('stores "" as null when clearing singleLineText', async () => { + const record = await ctx.createRecord(tableId, { + [primaryFieldId]: 'text clear', + }); + await ctx.updateRecord(tableId, record.id, { [primaryFieldId]: '' }); + + const updated = await readRecord(record.id); + expectEmpty(updated.fields[primaryFieldId]); + }); + + it('stores "" as null when clearing longText', async () => { + const record = await ctx.createRecord(tableId, { + [primaryFieldId]: 'longtext clear', + [longTextFieldId]: 'some notes', + }); + await ctx.updateRecord(tableId, record.id, { [longTextFieldId]: '' }); + + const updated = await readRecord(record.id); + expectEmpty(updated.fields[longTextFieldId]); + }); + + it('stores [] as null when clearing multipleSelect', async () => { + const record = await ctx.createRecord(tableId, { + [primaryFieldId]: 'multiselect clear', + [multiSelectFieldId]: ['Tag A'], + }); + await ctx.updateRecord(tableId, record.id, { [multiSelectFieldId]: [] }); + + const updated = await readRecord(record.id); + expectEmpty(updated.fields[multiSelectFieldId]); + }); + + it('stores [] as null when clearing a multi-value user field', async () => { + const record = await ctx.createRecord(tableId, { + [primaryFieldId]: 'user clear', + }); + await ctx.updateRecord(tableId, record.id, { [userFieldId]: [] }); + + const updated = await readRecord(record.id); + expectEmpty(updated.fields[userFieldId]); + }); + + it('stores [] as null when clearing an attachment field', async () => { + const record = await ctx.createRecord(tableId, { + [primaryFieldId]: 'attachment clear', + }); + await ctx.updateRecord(tableId, record.id, { [attachmentFieldId]: [] }); + + const updated = await readRecord(record.id); + expectEmpty(updated.fields[attachmentFieldId]); + }); + + it('stores [] as null when clearing a link field', async () => { + const record = await ctx.createRecord(tableId, { + [primaryFieldId]: 'link clear', + [linkFieldId]: [{ id: foreignRecordId }], + }); + await ctx.updateRecord(tableId, record.id, { [linkFieldId]: [] }); + + const updated = await readRecord(record.id); + const value = updated.fields[linkFieldId]; + expect(value == null || (Array.isArray(value) && value.length === 0)).toBe(true); + if (Array.isArray(value)) { + // even if serialized as an array, storage must not keep a stale item + expect(value).toEqual([]); + } + }); + + it('normalizes empty values on record creation as well', async () => { + const record = await ctx.createRecord(tableId, { + [primaryFieldId]: 'create with empties', + [checkboxFieldId]: false, + [longTextFieldId]: '', + [multiSelectFieldId]: [], + }); + + const created = await readRecord(record.id); + expectEmpty(created.fields[checkboxFieldId]); + expectEmpty(created.fields[longTextFieldId]); + expectEmpty(created.fields[multiSelectFieldId]); + }); + + it('normalizes empty values on the typecast write path (v1 record-typecast "" → null)', async () => { + const record = await ctx.createRecord(tableId, { + [primaryFieldId]: 'typecast empties', + [checkboxFieldId]: true, + [longTextFieldId]: 'notes', + }); + + await updateWithTypecast(record.id, { + [checkboxFieldId]: 'false', + [longTextFieldId]: '', + }); + + const updated = await readRecord(record.id); + expectEmpty(updated.fields[checkboxFieldId]); + expectEmpty(updated.fields[longTextFieldId]); + }); +}); diff --git a/packages/v2/e2e/src/record-filter-is-with-in.e2e.spec.ts b/packages/v2/e2e/src/record-filter-is-with-in.e2e.spec.ts index d5b94433dc..d92ead009e 100644 --- a/packages/v2/e2e/src/record-filter-is-with-in.e2e.spec.ts +++ b/packages/v2/e2e/src/record-filter-is-with-in.e2e.spec.ts @@ -92,4 +92,151 @@ describe('record filter isWithIn (e2e)', () => { expect(tomorrowRecord).toBeDefined(); }); }); + + describe('dateRange mode (v1 record-filter-query.e2e-spec:160)', () => { + let tableId: string; + let nameFieldId: string; + let dateFieldId: string; + let dateTimeFieldId: string; + + const listRecordsWithFilter = async (filter: unknown) => { + const params = new URLSearchParams({ + tableId, + fieldKeyType: 'id', + filter: JSON.stringify(filter), + }); + const response = await fetch(`${ctx.baseUrl}/tables/listRecords?${params.toString()}`, { + method: 'GET', + headers: { 'content-type': 'application/json' }, + }); + const rawBody = (await response.json()) as { + ok: boolean; + data?: { records: Array<{ id: string; fields: Record }> }; + }; + expect(response.status).toBe(200); + if (!rawBody.ok || !rawBody.data) throw new Error('listRecords failed'); + return rawBody.data.records; + }; + + beforeAll(async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'DateRange Filter Mode', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Due', type: 'date' }, + { + name: 'Due At', + type: 'date', + options: { + formatting: { date: 'YYYY-MM-DD', time: 'HH:mm', timeZone: 'utc' }, + }, + }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + nameFieldId = table.fields.find((field) => field.isPrimary)?.id ?? ''; + dateFieldId = table.fields.find((field) => field.name === 'Due')?.id ?? ''; + dateTimeFieldId = table.fields.find((field) => field.name === 'Due At')?.id ?? ''; + + for (const [name, due, dueAt] of [ + ['Before', '2024-05-20T12:00:00.000Z', '2024-06-15T08:00:00.000Z'], + ['InRange1', '2024-06-05T12:00:00.000Z', '2024-06-15T09:00:00.000Z'], + ['InRange2', '2024-06-20T12:00:00.000Z', '2024-06-15T17:00:00.000Z'], + ['After', '2024-07-10T12:00:00.000Z', '2024-06-15T18:00:00.000Z'], + ] as const) { + await ctx.createRecord(tableId, { + [nameFieldId]: name, + [dateFieldId]: due, + [dateTimeFieldId]: dueAt, + }); + } + }, 30000); + + afterAll(async () => { + if (tableId) { + await ctx.deleteTable(tableId, { mode: 'permanent' }); + } + }); + + it('filters records with a valid dateRange value', async () => { + const records = await listRecordsWithFilter({ + fieldId: dateFieldId, + operator: 'is', + value: { + mode: 'dateRange', + exactDate: '2024-06-01T00:00:00.000Z', + exactDateEnd: '2024-06-30T00:00:00.000Z', + timeZone: 'UTC', + }, + }); + expect(records.map((record) => record.fields[nameFieldId]).sort()).toEqual([ + 'InRange1', + 'InRange2', + ]); + }); + + it('respects the timeZone when computing dateRange day bounds', async () => { + // In UTC+14 the range [2024-06-01, 2024-06-20] ends at 2024-06-20T09:59:59Z, + // so InRange2 (2024-06-20T12:00Z) falls outside it. + const records = await listRecordsWithFilter({ + fieldId: dateFieldId, + operator: 'is', + value: { + mode: 'dateRange', + exactDate: '2024-06-01T00:00:00.000Z', + exactDateEnd: '2024-06-20T00:00:00.000Z', + timeZone: 'Pacific/Kiritimati', + }, + }); + expect(records.map((record) => record.fields[nameFieldId])).toEqual(['InRange1']); + }); + + it('preserves time bounds when the date field includes time formatting', async () => { + const records = await listRecordsWithFilter({ + fieldId: dateTimeFieldId, + operator: 'is', + value: { + mode: 'dateRange', + exactDate: '2024-06-15T09:00:00.000Z', + exactDateEnd: '2024-06-15T17:00:00.000Z', + timeZone: 'UTC', + }, + }); + expect(records.map((record) => record.fields[nameFieldId]).sort()).toEqual([ + 'InRange1', + 'InRange2', + ]); + }); + + it('skips a dateRange filter whose start is after its end', async () => { + const records = await listRecordsWithFilter({ + fieldId: dateFieldId, + operator: 'is', + value: { + mode: 'dateRange', + exactDate: '2024-06-30T00:00:00.000Z', + exactDateEnd: '2024-06-01T00:00:00.000Z', + timeZone: 'Asia/Shanghai', + }, + }); + // v1 contract: the invalid filter is dropped and the query still returns all rows + expect(records.length).toBe(4); + }); + + it('skips a dateRange filter used with the isNot operator', async () => { + const records = await listRecordsWithFilter({ + fieldId: dateFieldId, + operator: 'isNot', + value: { + mode: 'dateRange', + exactDate: '2024-06-01T00:00:00.000Z', + exactDateEnd: '2024-06-30T00:00:00.000Z', + timeZone: 'Asia/Shanghai', + }, + }); + expect(records.length).toBe(4); + }); + }); }); diff --git a/packages/v2/e2e/src/record-filter-null.e2e.spec.ts b/packages/v2/e2e/src/record-filter-null.e2e.spec.ts index c6826024a2..43d4a0fa86 100644 --- a/packages/v2/e2e/src/record-filter-null.e2e.spec.ts +++ b/packages/v2/e2e/src/record-filter-null.e2e.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import type { RecordFilter } from '@teable/v2-core'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; /** @@ -261,4 +261,90 @@ describe('record filter NULL handling (e2e)', () => { expect(checked2?.fields[nameFieldId]).toBe('Checked2'); }); }); + + describe('masked-filter SQL three-valued parity matrix', () => { + it.each([ + { + label: 'NOT(array hasAnyOf) includes NULL normalized to an empty array', + buildFilter: (tagsFieldId: string): RecordFilter => ({ + not: { + fieldId: tagsFieldId, + operator: 'hasAnyOf', + value: ['A'], + }, + }), + }, + { + label: 'NOT(array hasAnyOf AND checkbox is true) preserves FALSE AND UNKNOWN', + buildFilter: (tagsFieldId: string, doneFieldId: string): RecordFilter => ({ + not: { + conjunction: 'and', + items: [ + { + fieldId: tagsFieldId, + operator: 'hasAnyOf', + value: ['A'], + }, + { + fieldId: doneFieldId, + operator: 'is', + value: true, + }, + ], + }, + }), + }, + ])('$label', async ({ buildFilter }) => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Masked Filter SQL Parity', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Tags', type: 'multipleSelect', options: ['A', 'B'] }, + { name: 'Done', type: 'checkbox' }, + ], + views: [{ type: 'grid' }], + }); + const viewId = table.views[0].id; + const nameFieldId = table.fields.find((field) => field.isPrimary)?.id ?? ''; + const tagsFieldId = table.fields.find((field) => field.name === 'Tags')?.id ?? ''; + const doneFieldId = table.fields.find((field) => field.name === 'Done')?.id ?? ''; + await ctx.createRecord(table.id, { + [nameFieldId]: 'Null row', + }); + await ctx.createRecord(table.id, { + [nameFieldId]: 'Both true', + [tagsFieldId]: ['A'], + [doneFieldId]: true, + }); + await ctx.createRecord(table.id, { + [nameFieldId]: 'Tag only', + [tagsFieldId]: ['A'], + }); + + const result = await ctx.paste({ + tableId: table.id, + viewId, + ranges: [ + [0, 0], + [0, 0], + ], + content: [['Matched NULL row']], + filter: buildFilter(tagsFieldId, doneFieldId), + }); + + expect(result.updatedCount).toBe(1); + const records = await ctx.listRecords(table.id); + expect(records.find((record) => record.fields[nameFieldId] === 'Matched NULL row')).toEqual( + expect.objectContaining({ + fields: expect.objectContaining({ + [tagsFieldId]: null, + [doneFieldId]: null, + }), + }) + ); + expect(records.find((record) => record.fields[nameFieldId] === 'Both true')).toBeDefined(); + expect(records.find((record) => record.fields[nameFieldId] === 'Tag only')).toBeDefined(); + }); + }); }); diff --git a/packages/v2/e2e/src/record-filter-user-field-reference.e2e.spec.ts b/packages/v2/e2e/src/record-filter-user-field-reference.e2e.spec.ts index 9c532fc901..ab833e628a 100644 --- a/packages/v2/e2e/src/record-filter-user-field-reference.e2e.spec.ts +++ b/packages/v2/e2e/src/record-filter-user-field-reference.e2e.spec.ts @@ -140,4 +140,32 @@ describe('v2 listRecords user field reference filter (e2e)', () => { expect(records).toHaveLength(1); expect(records[0]?.fields[nameFieldId]).toBe('Alpha'); }); + + /** + * v1 reference: link-view-user-filter.e2e-spec (T6522 list) — the dynamic + * 'Me' value must resolve to the requesting actor for user field filters. + */ + it("resolves the dynamic 'Me' value against single user fields", async () => { + const records = await listRecordsWithFilter({ + fieldId: ownerFieldId, + operator: 'is', + value: 'Me', + }); + + expect(records.map((record) => record.fields[nameFieldId]).sort()).toEqual(['Alpha', 'Beta']); + }); + + it("resolves the dynamic 'Me' value against multi user fields", async () => { + const records = await listRecordsWithFilter({ + fieldId: assigneesFieldId, + operator: 'hasAnyOf', + value: ['Me'], + }); + + expect(records.map((record) => record.fields[nameFieldId]).sort()).toEqual([ + 'Alpha', + 'Beta', + 'Gamma', + ]); + }); }); diff --git a/packages/v2/e2e/src/renameTable.e2e.spec.ts b/packages/v2/e2e/src/renameTable.e2e.spec.ts index ab06daf175..d2d69f99a4 100644 --- a/packages/v2/e2e/src/renameTable.e2e.spec.ts +++ b/packages/v2/e2e/src/renameTable.e2e.spec.ts @@ -70,4 +70,117 @@ describe('v2 http renameTable (e2e)', () => { expect(body.data.table.name).toBe('Renamed'); expect(body.data.events.some((event) => event.name === 'TableRenamed')).toBe(true); }); + + it('[V1 PARITY][table.e2e-spec.ts] rename persists across getTableById and listTables', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Simple Props Table', + fields: [{ type: 'singleLineText', name: 'Name' }], + }); + + await ctx.renameTable(table.id, 'newTableName'); + + const fetched = await ctx.getTableById(table.id); + expect(fetched.name).toBe('newTableName'); + + const listed = await ctx.listTables(); + expect(listed.find((listedTable) => listedTable.id === table.id)?.name).toBe('newTableName'); + }); + + it('[V1 PARITY][table.e2e-spec.ts] updates and clears table description and icon', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Table Properties', + fields: [{ type: 'singleLineText', name: 'Name' }], + }); + + const client = createV2HttpClient({ baseUrl: ctx.baseUrl }); + const updated = await client.tables.updateProperties({ + baseId: ctx.baseId, + tableId: table.id, + description: 'A useful table description', + icon: '📊', + }); + expect(updated).toMatchObject({ + ok: true, + data: { + table: { + id: table.id, + description: 'A useful table description', + icon: '📊', + }, + }, + }); + expect(updated.data?.events.some((event) => event.name === 'TablePropertiesUpdated')).toBe( + true + ); + + const fetched = await ctx.getTableById(table.id); + expect(fetched).toMatchObject({ + description: 'A useful table description', + icon: '📊', + }); + await expect(ctx.listTables()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: table.id, + description: 'A useful table description', + icon: '📊', + }), + ]) + ); + + const partiallyUpdated = await client.tables.updateProperties({ + baseId: ctx.baseId, + tableId: table.id, + description: 'A revised description', + }); + expect(partiallyUpdated).toMatchObject({ + ok: true, + data: { + table: { + description: 'A revised description', + icon: '📊', + }, + }, + }); + + const cleared = await client.tables.updateProperties({ + baseId: ctx.baseId, + tableId: table.id, + description: null, + icon: null, + }); + expect(cleared.ok).toBe(true); + if (!cleared.ok) return; + expect(cleared.data?.table).not.toHaveProperty('description'); + expect(cleared.data?.table).not.toHaveProperty('icon'); + expect(await ctx.getTableById(table.id)).not.toMatchObject({ + description: expect.anything(), + icon: expect.anything(), + }); + }); + + it('[V2 CONTRACT] rejects missing properties and invalid table icons', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Invalid Table Properties', + fields: [{ type: 'singleLineText', name: 'Name' }], + }); + + const request = async (body: Record) => { + const response = await fetch(`${ctx.baseUrl}/tables/updateProperties`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ baseId: ctx.baseId, tableId: table.id, ...body }), + }); + return { status: response.status, body: await response.json() }; + }; + + expect(await request({})).toMatchObject({ status: 400, body: { ok: false } }); + expect(await request({ icon: 'not-an-emoji' })).toMatchObject({ + status: 400, + body: { ok: false }, + }); + }); }); diff --git a/packages/v2/e2e/src/rollup-expressions.e2e.spec.ts b/packages/v2/e2e/src/rollup-expressions.e2e.spec.ts new file mode 100644 index 0000000000..003f145f4b --- /dev/null +++ b/packages/v2/e2e/src/rollup-expressions.e2e.spec.ts @@ -0,0 +1,1573 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * V1-parity coverage for plain rollup expressions (T6520). + * Ports the portable cases from apps/nestjs-backend/test/rollup.e2e-spec.ts: + * - "rollup expression coverage" it.each matrix (numbers / text sources) + * - link-title rollups (concatenate / array_join / array_unique) + * - array_compact over blank looked-up values + * - rollup of a formula field feeding multiple rollups + * - rollup with no link records + * - rollup targeting conditional computed fields (conditionalRollup / conditionalLookup) + * - manyOne-side rollups reacting to link edits from either side + * - average / sum / concatenate rollups reacting to link add / replace / remove + * - countall over flattened multipleSelect values + * - rollup value seeding when the field is created after links exist + * - count over null-bearing sources + numeric rollup validation (hasError) + * + * Boolean and/or/xor rollups over checkboxes are covered by + * computed.e2e.spec.ts ("evaluates and/or/xor over unchecked checkboxes ..."). + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; + +describe('v2 rollup expressions (e2e)', () => { + let ctx: SharedTestContext; + let fieldIdCounter = 0; + const runId = Math.random().toString(36).slice(2, 8).padEnd(6, '0'); + + const createFieldId = () => { + const suffix = fieldIdCounter.toString(36).padStart(10, '0'); + fieldIdCounter += 1; + return `fld${runId}${suffix}`; + }; + + const drainOutbox = async (rounds = 10) => { + for (let i = 0; i < rounds; i += 1) { + const drained = await ctx.testContainer.processOutbox(); + if (drained === 0) break; + } + }; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + }); + + describe('expression matrix over linked number/text sources', () => { + // v1: rollup.e2e-spec.ts "rollup expression coverage" + let foreignTableId: string; + let hostTableId: string; + let hostRecordId: string; + const labelFieldId = createFieldId(); + const amountFieldId = createFieldId(); + const hostPrimaryFieldId = createFieldId(); + const hostLinkFieldId = createFieldId(); + + const rollupCases: Array<{ + expression: string; + lookupFieldKey: 'amount' | 'label'; + expected: unknown; + fieldId: string; + }> = [ + { expression: 'countall({values})', lookupFieldKey: 'amount', expected: 2, fieldId: '' }, + { expression: 'counta({values})', lookupFieldKey: 'label', expected: 2, fieldId: '' }, + { expression: 'count({values})', lookupFieldKey: 'amount', expected: 2, fieldId: '' }, + { expression: 'sum({values})', lookupFieldKey: 'amount', expected: 30, fieldId: '' }, + { expression: 'average({values})', lookupFieldKey: 'amount', expected: 15, fieldId: '' }, + { expression: 'max({values})', lookupFieldKey: 'amount', expected: 20, fieldId: '' }, + { expression: 'min({values})', lookupFieldKey: 'amount', expected: 10, fieldId: '' }, + { + expression: 'array_join({values})', + lookupFieldKey: 'label', + expected: 'Alpha, Beta', + fieldId: '', + }, + { + expression: 'array_unique({values})', + lookupFieldKey: 'label', + expected: ['Alpha', 'Beta'], + fieldId: '', + }, + { + expression: 'array_compact({values})', + lookupFieldKey: 'label', + expected: ['Alpha', 'Beta'], + fieldId: '', + }, + { + expression: 'concatenate({values})', + lookupFieldKey: 'label', + expected: 'Alpha, Beta', + fieldId: '', + }, + ]; + + beforeAll(async () => { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupExpr Foreign', + fields: [ + { type: 'singleLineText', id: labelFieldId, name: 'Label', isPrimary: true }, + { type: 'number', id: amountFieldId, name: 'Amount' }, + ], + }); + foreignTableId = foreign.id; + + const alpha = await ctx.createRecord(foreign.id, { + [labelFieldId]: 'Alpha', + [amountFieldId]: 10, + }); + const beta = await ctx.createRecord(foreign.id, { + [labelFieldId]: 'Beta', + [amountFieldId]: 20, + }); + + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupExpr Host', + fields: [{ type: 'singleLineText', id: hostPrimaryFieldId, name: 'Name', isPrimary: true }], + }); + hostTableId = host.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'link', + id: hostLinkFieldId, + name: 'Links', + options: { + relationship: 'manyMany', + foreignTableId: foreign.id, + lookupFieldId: labelFieldId, + isOneWay: true, + }, + }, + }); + + for (const rollupCase of rollupCases) { + const rollupFieldId = createFieldId(); + rollupCase.fieldId = rollupFieldId; + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'rollup', + id: rollupFieldId, + name: `rollup ${rollupCase.expression} ${rollupCase.lookupFieldKey}`, + options: { expression: rollupCase.expression }, + config: { + linkFieldId: hostLinkFieldId, + foreignTableId: foreign.id, + lookupFieldId: rollupCase.lookupFieldKey === 'amount' ? amountFieldId : labelFieldId, + }, + }, + }); + } + + const hostRecord = await ctx.createRecord(host.id, { + [hostPrimaryFieldId]: 'Rollup Holder', + [hostLinkFieldId]: [{ id: alpha.id }, { id: beta.id }], + }); + hostRecordId = hostRecord.id; + + await drainOutbox(); + }); + + afterAll(async () => { + if (hostTableId) await ctx.deleteTable(hostTableId).catch(() => undefined); + if (foreignTableId) await ctx.deleteTable(foreignTableId).catch(() => undefined); + }); + + it.each(rollupCases)( + 'computes rollup using $expression over $lookupFieldKey', + async ({ expected, fieldId, expression }) => { + const records = await ctx.listRecords(hostTableId); + const record = records.find((r) => r.id === hostRecordId); + expect(record).toBeDefined(); + const value = record?.fields[fieldId]; + + if (Array.isArray(expected)) { + expect(Array.isArray(value), `${expression} should return an array`).toBe(true); + expect([...(value as unknown[])].sort()).toEqual([...expected].sort()); + } else if (typeof expected === 'string' && expected.includes(', ')) { + expect((value as string).split(', ').sort()).toEqual(expected.split(', ').sort()); + } else { + expect(value).toEqual(expected); + } + } + ); + }); + + describe('rolling up a link field', () => { + // v1: rollup.e2e-spec.ts "concatenates link titles ..." / "joins link titles ..." + it('concatenates and joins link titles when rolling up a link field', async () => { + const serviceTitleFieldId = createFieldId(); + const employeeNameFieldId = createFieldId(); + const employeeServicesLinkFieldId = createFieldId(); + const deptNameFieldId = createFieldId(); + const deptEmployeesLinkFieldId = createFieldId(); + const concatRollupFieldId = createFieldId(); + const joinRollupFieldId = createFieldId(); + + let servicesTableId: string | undefined; + let employeesTableId: string | undefined; + let departmentsTableId: string | undefined; + + try { + const services = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupLink Services', + fields: [ + { type: 'singleLineText', id: serviceTitleFieldId, name: 'Title', isPrimary: true }, + ], + }); + servicesTableId = services.id; + const international = await ctx.createRecord(services.id, { + [serviceTitleFieldId]: 'International', + }); + const btob = await ctx.createRecord(services.id, { [serviceTitleFieldId]: 'BtoB' }); + + const employees = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupLink Employees', + fields: [ + { type: 'singleLineText', id: employeeNameFieldId, name: 'Name', isPrimary: true }, + ], + }); + employeesTableId = employees.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: employees.id, + field: { + type: 'link', + id: employeeServicesLinkFieldId, + name: 'Services', + options: { + relationship: 'manyMany', + foreignTableId: services.id, + lookupFieldId: serviceTitleFieldId, + isOneWay: true, + }, + }, + }); + + const alice = await ctx.createRecord(employees.id, { + [employeeNameFieldId]: 'Alice', + [employeeServicesLinkFieldId]: [{ id: international.id }, { id: btob.id }], + }); + + const departments = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupLink Departments', + fields: [{ type: 'singleLineText', id: deptNameFieldId, name: 'Dept', isPrimary: true }], + }); + departmentsTableId = departments.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: departments.id, + field: { + type: 'link', + id: deptEmployeesLinkFieldId, + name: 'Employees', + options: { + relationship: 'manyMany', + foreignTableId: employees.id, + lookupFieldId: employeeNameFieldId, + isOneWay: true, + }, + }, + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: departments.id, + field: { + type: 'rollup', + id: concatRollupFieldId, + name: 'service_titles', + options: { expression: 'concatenate({values})' }, + config: { + linkFieldId: deptEmployeesLinkFieldId, + foreignTableId: employees.id, + lookupFieldId: employeeServicesLinkFieldId, + }, + }, + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: departments.id, + field: { + type: 'rollup', + id: joinRollupFieldId, + name: 'service_titles_join', + options: { expression: 'array_join({values})' }, + config: { + linkFieldId: deptEmployeesLinkFieldId, + foreignTableId: employees.id, + lookupFieldId: employeeServicesLinkFieldId, + }, + }, + }); + + const hr = await ctx.createRecord(departments.id, { + [deptNameFieldId]: 'HR', + [deptEmployeesLinkFieldId]: [{ id: alice.id }], + }); + + await drainOutbox(); + + const records = await ctx.listRecords(departments.id); + const record = records.find((r) => r.id === hr.id); + expect((record?.fields[concatRollupFieldId] as string).split(', ').sort()).toEqual( + ['International', 'BtoB'].sort() + ); + expect((record?.fields[joinRollupFieldId] as string).split(', ').sort()).toEqual( + ['International', 'BtoB'].sort() + ); + } finally { + if (departmentsTableId) await ctx.deleteTable(departmentsTableId).catch(() => undefined); + if (employeesTableId) await ctx.deleteTable(employeesTableId).catch(() => undefined); + if (servicesTableId) await ctx.deleteTable(servicesTableId).catch(() => undefined); + } + }); + + // v1: rollup.e2e-spec.ts "deduplicates link titles with array_unique when rolling up a link field" + it('deduplicates link titles with array_unique when rolling up a link field', async () => { + const serviceTitleFieldId = createFieldId(); + const employeeNameFieldId = createFieldId(); + const employeeServicesLinkFieldId = createFieldId(); + const deptNameFieldId = createFieldId(); + const deptEmployeesLinkFieldId = createFieldId(); + const uniqueRollupFieldId = createFieldId(); + + let servicesTableId: string | undefined; + let employeesTableId: string | undefined; + let departmentsTableId: string | undefined; + + try { + const services = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupLinkUnique Services', + fields: [ + { type: 'singleLineText', id: serviceTitleFieldId, name: 'Title', isPrimary: true }, + ], + }); + servicesTableId = services.id; + const international = await ctx.createRecord(services.id, { + [serviceTitleFieldId]: 'International', + }); + const btob = await ctx.createRecord(services.id, { [serviceTitleFieldId]: 'BtoB' }); + + const employees = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupLinkUnique Employees', + fields: [ + { type: 'singleLineText', id: employeeNameFieldId, name: 'Name', isPrimary: true }, + ], + }); + employeesTableId = employees.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: employees.id, + field: { + type: 'link', + id: employeeServicesLinkFieldId, + name: 'Services', + options: { + relationship: 'manyMany', + foreignTableId: services.id, + lookupFieldId: serviceTitleFieldId, + isOneWay: true, + }, + }, + }); + + const alice = await ctx.createRecord(employees.id, { + [employeeNameFieldId]: 'Alice', + [employeeServicesLinkFieldId]: [{ id: international.id }], + }); + const bob = await ctx.createRecord(employees.id, { + [employeeNameFieldId]: 'Bob', + [employeeServicesLinkFieldId]: [{ id: btob.id }], + }); + + const departments = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupLinkUnique Departments', + fields: [{ type: 'singleLineText', id: deptNameFieldId, name: 'Dept', isPrimary: true }], + }); + departmentsTableId = departments.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: departments.id, + field: { + type: 'link', + id: deptEmployeesLinkFieldId, + name: 'Employees', + options: { + relationship: 'manyMany', + foreignTableId: employees.id, + lookupFieldId: employeeNameFieldId, + isOneWay: true, + }, + }, + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: departments.id, + field: { + type: 'rollup', + id: uniqueRollupFieldId, + name: 'service_titles_unique', + options: { expression: 'array_unique({values})' }, + config: { + linkFieldId: deptEmployeesLinkFieldId, + foreignTableId: employees.id, + lookupFieldId: employeeServicesLinkFieldId, + }, + }, + }); + + const hr = await ctx.createRecord(departments.id, { + [deptNameFieldId]: 'HR', + [deptEmployeesLinkFieldId]: [{ id: alice.id }, { id: bob.id }], + }); + + await drainOutbox(); + + const records = await ctx.listRecords(departments.id); + const record = records.find((r) => r.id === hr.id); + const values = record?.fields[uniqueRollupFieldId] as string[]; + expect(values).toHaveLength(2); + expect(values).toEqual(expect.arrayContaining(['International', 'BtoB'])); + } finally { + if (departmentsTableId) await ctx.deleteTable(departmentsTableId).catch(() => undefined); + if (employeesTableId) await ctx.deleteTable(employeesTableId).catch(() => undefined); + if (servicesTableId) await ctx.deleteTable(servicesTableId).catch(() => undefined); + } + }); + }); + + describe('rollup corner cases', () => { + // v1: rollup.e2e-spec.ts "should create rollup fields with array join, unique, and compact expressions" (compact part) + it('removes blank looked-up values with array_compact', async () => { + const foreignPrimaryFieldId = createFieldId(); + const foreignTextFieldId = createFieldId(); + const hostPrimaryFieldId = createFieldId(); + const hostLinkFieldId = createFieldId(); + const compactRollupFieldId = createFieldId(); + + let foreignTableId: string | undefined; + let hostTableId: string | undefined; + + try { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupCompact Foreign', + fields: [ + { type: 'singleLineText', id: foreignPrimaryFieldId, name: 'Name', isPrimary: true }, + { type: 'singleLineText', id: foreignTextFieldId, name: 'Text' }, + ], + }); + foreignTableId = foreign.id; + + const r1 = await ctx.createRecord(foreign.id, { + [foreignPrimaryFieldId]: 'R1', + [foreignTextFieldId]: 'Gamma', + }); + const r2 = await ctx.createRecord(foreign.id, { + [foreignPrimaryFieldId]: 'R2', + [foreignTextFieldId]: '', + }); + const r3 = await ctx.createRecord(foreign.id, { + [foreignPrimaryFieldId]: 'R3', + [foreignTextFieldId]: null, + }); + + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupCompact Host', + fields: [ + { type: 'singleLineText', id: hostPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + hostTableId = host.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'link', + id: hostLinkFieldId, + name: 'Links', + options: { + relationship: 'manyMany', + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'rollup', + id: compactRollupFieldId, + name: 'compact_texts', + options: { expression: 'array_compact({values})' }, + config: { + linkFieldId: hostLinkFieldId, + foreignTableId: foreign.id, + lookupFieldId: foreignTextFieldId, + }, + }, + }); + + const hostRecord = await ctx.createRecord(host.id, { + [hostPrimaryFieldId]: 'Holder', + [hostLinkFieldId]: [{ id: r1.id }, { id: r2.id }, { id: r3.id }], + }); + + await drainOutbox(); + + const records = await ctx.listRecords(host.id); + const record = records.find((r) => r.id === hostRecord.id); + expect(record?.fields[compactRollupFieldId]).toEqual(['Gamma']); + } finally { + if (hostTableId) await ctx.deleteTable(hostTableId).catch(() => undefined); + if (foreignTableId) await ctx.deleteTable(foreignTableId).catch(() => undefined); + } + }); + + // v1: rollup.e2e-spec.ts "should update multiple field when rollup to sum a formula field" + it('updates multiple rollups when summing the same formula field', async () => { + const sourcePrimaryFieldId = createFieldId(); + const sourceNumberFieldId = createFieldId(); + const sourceFormulaFieldId = createFieldId(); + const hostPrimaryFieldId = createFieldId(); + const hostLinkFieldId = createFieldId(); + const rollup1FieldId = createFieldId(); + const rollup2FieldId = createFieldId(); + + let sourceTableId: string | undefined; + let hostTableId: string | undefined; + + try { + const source = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupFormula Source', + fields: [ + { type: 'singleLineText', id: sourcePrimaryFieldId, name: 'Name', isPrimary: true }, + { type: 'number', id: sourceNumberFieldId, name: 'Num' }, + { + type: 'formula', + id: sourceFormulaFieldId, + name: 'NumFormula', + options: { expression: `{${sourceNumberFieldId}}` }, + }, + ], + }); + sourceTableId = source.id; + + const r1 = await ctx.createRecord(source.id, { + [sourcePrimaryFieldId]: 'S1', + [sourceNumberFieldId]: 1, + }); + const r2 = await ctx.createRecord(source.id, { + [sourcePrimaryFieldId]: 'S2', + [sourceNumberFieldId]: 2, + }); + + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupFormula Host', + fields: [ + { type: 'singleLineText', id: hostPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + hostTableId = host.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'link', + id: hostLinkFieldId, + name: 'Sources', + options: { + relationship: 'manyMany', + foreignTableId: source.id, + lookupFieldId: sourcePrimaryFieldId, + isOneWay: true, + }, + }, + }); + + for (const rollupFieldId of [rollup1FieldId, rollup2FieldId]) { + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'rollup', + id: rollupFieldId, + name: `rollup ${rollupFieldId}`, + options: { expression: 'sum({values})' }, + config: { + linkFieldId: hostLinkFieldId, + foreignTableId: source.id, + lookupFieldId: sourceFormulaFieldId, + }, + }, + }); + } + + const hostRecord = await ctx.createRecord(host.id, { + [hostPrimaryFieldId]: 'Holder', + [hostLinkFieldId]: [{ id: r1.id }, { id: r2.id }], + }); + + await drainOutbox(); + + let records = await ctx.listRecords(host.id); + let record = records.find((r) => r.id === hostRecord.id); + expect(record?.fields[rollup1FieldId]).toEqual(3); + expect(record?.fields[rollup2FieldId]).toEqual(3); + + await ctx.updateRecord(source.id, r2.id, { [sourceNumberFieldId]: 3 }); + await drainOutbox(); + + records = await ctx.listRecords(host.id); + record = records.find((r) => r.id === hostRecord.id); + expect([record?.fields[rollup1FieldId], record?.fields[rollup2FieldId]]).toEqual([4, 4]); + } finally { + if (hostTableId) await ctx.deleteTable(hostTableId).catch(() => undefined); + if (sourceTableId) await ctx.deleteTable(sourceTableId).catch(() => undefined); + } + }); + + // v1: rollup.e2e-spec.ts "should calculate rollup event has no link record" + it('computes sum rollup as 0 when the host has no link record', async () => { + const foreignPrimaryFieldId = createFieldId(); + const foreignNumberFieldId = createFieldId(); + const hostPrimaryFieldId = createFieldId(); + const hostLinkFieldId = createFieldId(); + const rollupFieldId = createFieldId(); + + let foreignTableId: string | undefined; + let hostTableId: string | undefined; + + try { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupNoLink Foreign', + fields: [ + { type: 'singleLineText', id: foreignPrimaryFieldId, name: 'Name', isPrimary: true }, + { type: 'number', id: foreignNumberFieldId, name: 'Num' }, + ], + }); + foreignTableId = foreign.id; + + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupNoLink Host', + fields: [ + { type: 'singleLineText', id: hostPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + hostTableId = host.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'link', + id: hostLinkFieldId, + name: 'Links', + options: { + relationship: 'manyMany', + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'rollup', + id: rollupFieldId, + name: 'sum_no_links', + options: { expression: 'sum({values})' }, + config: { + linkFieldId: hostLinkFieldId, + foreignTableId: foreign.id, + lookupFieldId: foreignNumberFieldId, + }, + }, + }); + + const hostRecord = await ctx.createRecord(host.id, { [hostPrimaryFieldId]: 'Lonely' }); + + await drainOutbox(); + + const records = await ctx.listRecords(host.id); + const record = records.find((r) => r.id === hostRecord.id); + expect(record?.fields[rollupFieldId]).toEqual(0); + } finally { + if (hostTableId) await ctx.deleteTable(hostTableId).catch(() => undefined); + if (foreignTableId) await ctx.deleteTable(foreignTableId).catch(() => undefined); + } + }); + + // v1: rollup.e2e-spec.ts "should update many - one rollupField by remove a linkRecord from cell" + // + "should update many - one rollupField by replace a linkRecord from cell" + it('recomputes manyOne-side rollups when links change from either side', async () => { + const parentPrimaryFieldId = createFieldId(); + const parentAmountFieldId = createFieldId(); + const childPrimaryFieldId = createFieldId(); + const childLinkFieldId = createFieldId(); + const childSumRollupFieldId = createFieldId(); + const parentCountRollupFieldId = createFieldId(); + + let parentTableId: string | undefined; + let childTableId: string | undefined; + + try { + const parent = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupManyOne Parent', + fields: [ + { type: 'singleLineText', id: parentPrimaryFieldId, name: 'Name', isPrimary: true }, + { type: 'number', id: parentAmountFieldId, name: 'Amount' }, + ], + }); + parentTableId = parent.id; + + const p1 = await ctx.createRecord(parent.id, { + [parentPrimaryFieldId]: 'P1', + [parentAmountFieldId]: 123, + }); + const p2 = await ctx.createRecord(parent.id, { + [parentPrimaryFieldId]: 'P2', + [parentAmountFieldId]: null, + }); + + const child = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupManyOne Child', + fields: [ + { type: 'singleLineText', id: childPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + childTableId = child.id; + + // twoWay manyOne link so parents get a symmetric oneMany field + await ctx.createField({ + baseId: ctx.baseId, + tableId: child.id, + field: { + type: 'link', + id: childLinkFieldId, + name: 'Parent', + options: { + relationship: 'manyOne', + foreignTableId: parent.id, + lookupFieldId: parentPrimaryFieldId, + }, + }, + }); + + const parentMeta = await ctx.getTableById(parent.id); + const symmetricField = parentMeta.fields.find( + (field) => + field.type === 'link' && + (field.options as { symmetricFieldId?: string } | undefined)?.symmetricFieldId === + childLinkFieldId + ); + expect(symmetricField).toBeDefined(); + const symmetricFieldId = symmetricField!.id; + + // rollup on the manyOne (child) side: sum of the single linked parent amount + await ctx.createField({ + baseId: ctx.baseId, + tableId: child.id, + field: { + type: 'rollup', + id: childSumRollupFieldId, + name: 'Parent Amount Sum', + options: { expression: 'sum({values})' }, + config: { + linkFieldId: childLinkFieldId, + foreignTableId: parent.id, + lookupFieldId: parentAmountFieldId, + }, + }, + }); + + // rollup on the oneMany (parent) side: countall of linked children + await ctx.createField({ + baseId: ctx.baseId, + tableId: parent.id, + field: { + type: 'rollup', + id: parentCountRollupFieldId, + name: 'Child Count', + options: { expression: 'countall({values})' }, + config: { + linkFieldId: symmetricFieldId, + foreignTableId: child.id, + lookupFieldId: childPrimaryFieldId, + }, + }, + }); + + const c1 = await ctx.createRecord(child.id, { [childPrimaryFieldId]: 'C1' }); + const c2 = await ctx.createRecord(child.id, { [childPrimaryFieldId]: 'C2' }); + await drainOutbox(); + + const readChild = async (id: string) => { + const records = await ctx.listRecords(child.id); + return records.find((r) => r.id === id); + }; + const readParent = async (id: string) => { + const records = await ctx.listRecords(parent.id); + return records.find((r) => r.id === id); + }; + + // link both children from the parent (oneMany) side + await ctx.updateRecord(parent.id, p1.id, { + [symmetricFieldId]: [{ id: c1.id }, { id: c2.id }], + }); + await drainOutbox(); + + expect((await readChild(c1.id))?.fields[childSumRollupFieldId]).toEqual(123); + expect((await readChild(c2.id))?.fields[childSumRollupFieldId]).toEqual(123); + expect((await readParent(p1.id))?.fields[parentCountRollupFieldId]).toEqual(2); + + // remove one child from the parent side + await ctx.updateRecord(parent.id, p1.id, { [symmetricFieldId]: [{ id: c1.id }] }); + await drainOutbox(); + + expect((await readChild(c1.id))?.fields[childSumRollupFieldId]).toEqual(123); + expect((await readChild(c2.id))?.fields[childSumRollupFieldId]).toEqual(0); + expect((await readParent(p1.id))?.fields[parentCountRollupFieldId]).toEqual(1); + + // remove all links from the parent side + await ctx.updateRecord(parent.id, p1.id, { [symmetricFieldId]: null }); + await drainOutbox(); + + expect((await readChild(c1.id))?.fields[childSumRollupFieldId]).toEqual(0); + expect((await readParent(p1.id))?.fields[parentCountRollupFieldId]).toEqual(0); + + // re-add from the child (manyOne) side + await ctx.updateRecord(child.id, c1.id, { [childLinkFieldId]: { id: p1.id } }); + await drainOutbox(); + + expect((await readChild(c1.id))?.fields[childSumRollupFieldId]).toEqual(123); + expect((await readParent(p1.id))?.fields[parentCountRollupFieldId]).toEqual(1); + + // replace the child's parent: counts move between parents (v1 "replace" case) + await ctx.updateRecord(child.id, c1.id, { [childLinkFieldId]: { id: p2.id } }); + await drainOutbox(); + + expect((await readChild(c1.id))?.fields[childSumRollupFieldId]).toEqual(0); + expect((await readParent(p1.id))?.fields[parentCountRollupFieldId]).toEqual(0); + expect((await readParent(p2.id))?.fields[parentCountRollupFieldId]).toEqual(1); + } finally { + if (childTableId) await ctx.deleteTable(childTableId).catch(() => undefined); + if (parentTableId) await ctx.deleteTable(parentTableId).catch(() => undefined); + } + }); + + // v1: rollup.e2e-spec.ts "should calculate average in one - many rollup field" + // + "should update one - many rollupField by add a linkRecord from cell" (concatenate) + // + "should update one - many rollupField by replace a linkRecord from cell" (sum) + it('recomputes average, sum, and concatenate rollups when the link set changes', async () => { + const foreignPrimaryFieldId = createFieldId(); + const foreignNumberFieldId = createFieldId(); + const hostPrimaryFieldId = createFieldId(); + const hostLinkFieldId = createFieldId(); + const averageRollupFieldId = createFieldId(); + const sumRollupFieldId = createFieldId(); + const concatRollupFieldId = createFieldId(); + + let foreignTableId: string | undefined; + let hostTableId: string | undefined; + + try { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupLinkSet Foreign', + fields: [ + { type: 'singleLineText', id: foreignPrimaryFieldId, name: 'Name', isPrimary: true }, + { type: 'number', id: foreignNumberFieldId, name: 'Num' }, + ], + }); + foreignTableId = foreign.id; + + const f1 = await ctx.createRecord(foreign.id, { + [foreignPrimaryFieldId]: 'F1', + [foreignNumberFieldId]: 20, + }); + const f2 = await ctx.createRecord(foreign.id, { + [foreignPrimaryFieldId]: 'F2', + [foreignNumberFieldId]: 40, + }); + + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupLinkSet Host', + fields: [ + { type: 'singleLineText', id: hostPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + hostTableId = host.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'link', + id: hostLinkFieldId, + name: 'Links', + options: { + relationship: 'manyMany', + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + + const rollupSpecs = [ + { id: averageRollupFieldId, expression: 'average({values})', name: 'Avg' }, + { id: sumRollupFieldId, expression: 'sum({values})', name: 'Sum' }, + { id: concatRollupFieldId, expression: 'concatenate({values})', name: 'Concat' }, + ]; + for (const spec of rollupSpecs) { + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'rollup', + id: spec.id, + name: spec.name, + options: { expression: spec.expression }, + config: { + linkFieldId: hostLinkFieldId, + foreignTableId: foreign.id, + lookupFieldId: foreignNumberFieldId, + }, + }, + }); + } + + const hostRecord = await ctx.createRecord(host.id, { + [hostPrimaryFieldId]: 'Holder', + [hostLinkFieldId]: [{ id: f1.id }], + }); + await drainOutbox(); + + const readHost = async () => { + const records = await ctx.listRecords(host.id); + return records.find((r) => r.id === hostRecord.id); + }; + + let record = await readHost(); + expect(record?.fields[averageRollupFieldId]).toEqual(20); + expect(record?.fields[sumRollupFieldId]).toEqual(20); + expect(record?.fields[concatRollupFieldId]).toEqual('20'); + + // add a link + await ctx.updateRecord(host.id, hostRecord.id, { + [hostLinkFieldId]: [{ id: f1.id }, { id: f2.id }], + }); + await drainOutbox(); + + record = await readHost(); + expect(record?.fields[averageRollupFieldId]).toEqual(30); + expect(record?.fields[sumRollupFieldId]).toEqual(60); + expect((record?.fields[concatRollupFieldId] as string).split(', ').sort()).toEqual([ + '20', + '40', + ]); + + // replace the link set with a single other record + await ctx.updateRecord(host.id, hostRecord.id, { + [hostLinkFieldId]: [{ id: f2.id }], + }); + await drainOutbox(); + + record = await readHost(); + expect(record?.fields[averageRollupFieldId]).toEqual(40); + expect(record?.fields[sumRollupFieldId]).toEqual(40); + expect(record?.fields[concatRollupFieldId]).toEqual('40'); + } finally { + if (hostTableId) await ctx.deleteTable(hostTableId).catch(() => undefined); + if (foreignTableId) await ctx.deleteTable(foreignTableId).catch(() => undefined); + } + }); + + // v1: rollup.e2e-spec.ts "should roll up a flat array multiple select field -> one - many rollup field" + it('counts flattened multipleSelect values with countall', async () => { + const foreignPrimaryFieldId = createFieldId(); + const foreignTagsFieldId = createFieldId(); + const hostPrimaryFieldId = createFieldId(); + const hostLinkFieldId = createFieldId(); + const countAllRollupFieldId = createFieldId(); + + let foreignTableId: string | undefined; + let hostTableId: string | undefined; + + try { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupMultiSelect Foreign', + fields: [ + { type: 'singleLineText', id: foreignPrimaryFieldId, name: 'Name', isPrimary: true }, + { + type: 'multipleSelect', + id: foreignTagsFieldId, + name: 'Tags', + options: { + choices: [ + { id: 'choRap', name: 'rap', color: 'blue' }, + { id: 'choRock', name: 'rock', color: 'green' }, + { id: 'choHiphop', name: 'hiphop', color: 'red' }, + ], + }, + }, + ], + }); + foreignTableId = foreign.id; + + const f1 = await ctx.createRecord(foreign.id, { + [foreignPrimaryFieldId]: 'F1', + [foreignTagsFieldId]: ['rap', 'rock'], + }); + const f2 = await ctx.createRecord(foreign.id, { + [foreignPrimaryFieldId]: 'F2', + [foreignTagsFieldId]: ['rap', 'hiphop'], + }); + + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupMultiSelect Host', + fields: [ + { type: 'singleLineText', id: hostPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + hostTableId = host.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'link', + id: hostLinkFieldId, + name: 'Links', + options: { + relationship: 'manyMany', + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'rollup', + id: countAllRollupFieldId, + name: 'Tag Count', + options: { expression: 'countall({values})' }, + config: { + linkFieldId: hostLinkFieldId, + foreignTableId: foreign.id, + lookupFieldId: foreignTagsFieldId, + }, + }, + }); + + const hostRecord = await ctx.createRecord(host.id, { + [hostPrimaryFieldId]: 'Holder', + [hostLinkFieldId]: [{ id: f1.id }, { id: f2.id }], + }); + await drainOutbox(); + + const records = await ctx.listRecords(host.id); + const record = records.find((r) => r.id === hostRecord.id); + // flat array semantics: 2 records x 2 tags = 4 values + expect(record?.fields[countAllRollupFieldId]).toEqual(4); + } finally { + if (hostTableId) await ctx.deleteTable(hostTableId).catch(() => undefined); + if (foreignTableId) await ctx.deleteTable(foreignTableId).catch(() => undefined); + } + }); + + // v1: rollup.e2e-spec.ts "should calculate when add a rollup field" + it('seeds rollup values when the rollup field is created after links exist', async () => { + const foreignPrimaryFieldId = createFieldId(); + const hostPrimaryFieldId = createFieldId(); + const hostLinkFieldId = createFieldId(); + const rollupFieldId = createFieldId(); + + let foreignTableId: string | undefined; + let hostTableId: string | undefined; + + try { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupSeed Foreign', + fields: [ + { type: 'singleLineText', id: foreignPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + foreignTableId = foreign.id; + + const f1 = await ctx.createRecord(foreign.id, { [foreignPrimaryFieldId]: 'F1' }); + const f2 = await ctx.createRecord(foreign.id, { [foreignPrimaryFieldId]: 'F2' }); + + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupSeed Host', + fields: [ + { type: 'singleLineText', id: hostPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + hostTableId = host.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'link', + id: hostLinkFieldId, + name: 'Links', + options: { + relationship: 'manyMany', + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + + // establish links before the rollup field exists + const linked = await ctx.createRecord(host.id, { + [hostPrimaryFieldId]: 'Linked', + [hostLinkFieldId]: [{ id: f1.id }, { id: f2.id }], + }); + const unlinked = await ctx.createRecord(host.id, { [hostPrimaryFieldId]: 'Unlinked' }); + await drainOutbox(); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'rollup', + id: rollupFieldId, + name: 'Link Count', + options: { expression: 'countall({values})' }, + config: { + linkFieldId: hostLinkFieldId, + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + }, + }, + }); + await drainOutbox(); + + const records = await ctx.listRecords(host.id); + expect(records.find((r) => r.id === linked.id)?.fields[rollupFieldId]).toEqual(2); + expect(records.find((r) => r.id === unlinked.id)?.fields[rollupFieldId]).toEqual(0); + } finally { + if (hostTableId) await ctx.deleteTable(hostTableId).catch(() => undefined); + if (foreignTableId) await ctx.deleteTable(foreignTableId).catch(() => undefined); + } + }); + + // v1: rollup.e2e-spec.ts "should rollup a number field in one - many relationship" + // + "Rollup aggregation validation" > "keeps numeric aggregation valid for numeric sources" + it('keeps numeric rollups valid and skips null values with count', async () => { + const foreignPrimaryFieldId = createFieldId(); + const foreignNumberFieldId = createFieldId(); + const hostPrimaryFieldId = createFieldId(); + const hostLinkFieldId = createFieldId(); + const countRollupFieldId = createFieldId(); + const sumRollupFieldId = createFieldId(); + + let foreignTableId: string | undefined; + let hostTableId: string | undefined; + + try { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupNullCount Foreign', + fields: [ + { type: 'singleLineText', id: foreignPrimaryFieldId, name: 'Name', isPrimary: true }, + { type: 'number', id: foreignNumberFieldId, name: 'Num' }, + ], + }); + foreignTableId = foreign.id; + + const f1 = await ctx.createRecord(foreign.id, { + [foreignPrimaryFieldId]: 'F1', + [foreignNumberFieldId]: null, + }); + const f2 = await ctx.createRecord(foreign.id, { + [foreignPrimaryFieldId]: 'F2', + [foreignNumberFieldId]: 456, + }); + + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupNullCount Host', + fields: [ + { type: 'singleLineText', id: hostPrimaryFieldId, name: 'Name', isPrimary: true }, + ], + }); + hostTableId = host.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'link', + id: hostLinkFieldId, + name: 'Links', + options: { + relationship: 'manyMany', + foreignTableId: foreign.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'rollup', + id: countRollupFieldId, + name: 'Num Count', + options: { expression: 'count({values})' }, + config: { + linkFieldId: hostLinkFieldId, + foreignTableId: foreign.id, + lookupFieldId: foreignNumberFieldId, + }, + }, + }); + await ctx.createField({ + baseId: ctx.baseId, + tableId: host.id, + field: { + type: 'rollup', + id: sumRollupFieldId, + name: 'Num Sum', + options: { expression: 'sum({values})' }, + config: { + linkFieldId: hostLinkFieldId, + foreignTableId: foreign.id, + lookupFieldId: foreignNumberFieldId, + }, + }, + }); + + const hostRecord = await ctx.createRecord(host.id, { + [hostPrimaryFieldId]: 'Holder', + [hostLinkFieldId]: [{ id: f1.id }, { id: f2.id }], + }); + await drainOutbox(); + + // numeric aggregation over a numeric source carries no field error + const hostMeta = await ctx.getTableById(host.id); + expect(hostMeta.fields.find((f) => f.id === countRollupFieldId)?.hasError).toBeFalsy(); + expect(hostMeta.fields.find((f) => f.id === sumRollupFieldId)?.hasError).toBeFalsy(); + + const records = await ctx.listRecords(host.id); + const record = records.find((r) => r.id === hostRecord.id); + // count() only counts non-null numeric values + expect(record?.fields[countRollupFieldId]).toEqual(1); + expect(record?.fields[sumRollupFieldId]).toEqual(456); + } finally { + if (hostTableId) await ctx.deleteTable(hostTableId).catch(() => undefined); + if (foreignTableId) await ctx.deleteTable(foreignTableId).catch(() => undefined); + } + }); + }); + + describe('rollup targeting conditional computed fields', () => { + // v1: rollup.e2e-spec.ts "rollup targeting conditional computed fields" + it('rolls up conditionalRollup and conditionalLookup values across linked tables', async () => { + const leafItemFieldId = createFieldId(); + const leafCategoryFieldId = createFieldId(); + const leafScoreFieldId = createFieldId(); + const leafStatusFieldId = createFieldId(); + const middleSummaryFieldId = createFieldId(); + const middleTargetCategoryFieldId = createFieldId(); + const middleCondRollupFieldId = createFieldId(); + const middleCondLookupFieldId = createFieldId(); + const rootRegionFieldId = createFieldId(); + const rootLinkFieldId = createFieldId(); + const rootScoreRollupFieldId = createFieldId(); + const rootItemCountRollupFieldId = createFieldId(); + const rootItemConcatRollupFieldId = createFieldId(); + + let leafTableId: string | undefined; + let middleTableId: string | undefined; + let rootTableId: string | undefined; + + try { + const leaf = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupConditional Leaf', + fields: [ + { type: 'singleLineText', id: leafItemFieldId, name: 'Item', isPrimary: true }, + { type: 'singleLineText', id: leafCategoryFieldId, name: 'Category' }, + { type: 'number', id: leafScoreFieldId, name: 'Score' }, + { type: 'singleLineText', id: leafStatusFieldId, name: 'Status' }, + ], + }); + leafTableId = leaf.id; + + await ctx.createRecord(leaf.id, { + [leafItemFieldId]: 'Alpha', + [leafCategoryFieldId]: 'Hardware', + [leafScoreFieldId]: 60, + [leafStatusFieldId]: 'Active', + }); + await ctx.createRecord(leaf.id, { + [leafItemFieldId]: 'Beta', + [leafCategoryFieldId]: 'Hardware', + [leafScoreFieldId]: 40, + [leafStatusFieldId]: 'Inactive', + }); + await ctx.createRecord(leaf.id, { + [leafItemFieldId]: 'Gamma', + [leafCategoryFieldId]: 'Software', + [leafScoreFieldId]: 80, + [leafStatusFieldId]: 'Active', + }); + + const categoryMatchFilter = { + conjunction: 'and' as const, + filterSet: [ + { + fieldId: leafCategoryFieldId, + operator: 'is', + value: middleTargetCategoryFieldId, + isSymbol: true, + }, + { + fieldId: leafStatusFieldId, + operator: 'is', + value: 'Active', + }, + ], + }; + + const middle = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupConditional Middle', + fields: [ + { type: 'singleLineText', id: middleSummaryFieldId, name: 'Summary', isPrimary: true }, + { type: 'singleLineText', id: middleTargetCategoryFieldId, name: 'Target Category' }, + ], + }); + middleTableId = middle.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: middle.id, + field: { + type: 'conditionalRollup', + id: middleCondRollupFieldId, + name: 'Active Category Score', + options: { expression: 'sum({values})' }, + config: { + foreignTableId: leaf.id, + lookupFieldId: leafScoreFieldId, + condition: { filter: categoryMatchFilter }, + }, + }, + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: middle.id, + field: { + type: 'conditionalLookup', + id: middleCondLookupFieldId, + name: 'Active Item Names', + options: { + foreignTableId: leaf.id, + lookupFieldId: leafItemFieldId, + condition: { filter: categoryMatchFilter }, + }, + }, + }); + + const hardware = await ctx.createRecord(middle.id, { + [middleSummaryFieldId]: 'Hardware Overview', + [middleTargetCategoryFieldId]: 'Hardware', + }); + const software = await ctx.createRecord(middle.id, { + [middleSummaryFieldId]: 'Software Overview', + [middleTargetCategoryFieldId]: 'Software', + }); + + const root = await ctx.createTable({ + baseId: ctx.baseId, + name: 'RollupConditional Root', + fields: [ + { type: 'singleLineText', id: rootRegionFieldId, name: 'Region', isPrimary: true }, + ], + }); + rootTableId = root.id; + + await ctx.createField({ + baseId: ctx.baseId, + tableId: root.id, + field: { + type: 'link', + id: rootLinkFieldId, + name: 'Middle Connection', + options: { + relationship: 'manyMany', + foreignTableId: middle.id, + lookupFieldId: middleSummaryFieldId, + isOneWay: true, + }, + }, + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: root.id, + field: { + type: 'rollup', + id: rootScoreRollupFieldId, + name: 'Score Sum', + options: { expression: 'sum({values})' }, + config: { + linkFieldId: rootLinkFieldId, + foreignTableId: middle.id, + lookupFieldId: middleCondRollupFieldId, + }, + }, + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: root.id, + field: { + type: 'rollup', + id: rootItemCountRollupFieldId, + name: 'Active Item Count', + options: { expression: 'countall({values})' }, + config: { + linkFieldId: rootLinkFieldId, + foreignTableId: middle.id, + lookupFieldId: middleCondLookupFieldId, + }, + }, + }); + + // v1: "should concatenate conditional lookup values when rolled up" + await ctx.createField({ + baseId: ctx.baseId, + tableId: root.id, + field: { + type: 'rollup', + id: rootItemConcatRollupFieldId, + name: 'Active Item Concat', + options: { expression: 'concatenate({values})' }, + config: { + linkFieldId: rootLinkFieldId, + foreignTableId: middle.id, + lookupFieldId: middleCondLookupFieldId, + }, + }, + }); + + const north = await ctx.createRecord(root.id, { + [rootRegionFieldId]: 'North', + [rootLinkFieldId]: [{ id: hardware.id }], + }); + const global = await ctx.createRecord(root.id, { + [rootRegionFieldId]: 'Global', + [rootLinkFieldId]: [{ id: hardware.id }, { id: software.id }], + }); + const unlinked = await ctx.createRecord(root.id, { [rootRegionFieldId]: 'Unlinked' }); + + await drainOutbox(); + + const middleRecords = await ctx.listRecords(middle.id); + const hardwareRecord = middleRecords.find((r) => r.id === hardware.id); + const softwareRecord = middleRecords.find((r) => r.id === software.id); + expect(hardwareRecord?.fields[middleCondRollupFieldId]).toEqual(60); + expect(softwareRecord?.fields[middleCondRollupFieldId]).toEqual(80); + expect(hardwareRecord?.fields[middleCondLookupFieldId]).toEqual(['Alpha']); + expect(softwareRecord?.fields[middleCondLookupFieldId]).toEqual(['Gamma']); + + const rootRecords = await ctx.listRecords(root.id); + const northRecord = rootRecords.find((r) => r.id === north.id); + const globalRecord = rootRecords.find((r) => r.id === global.id); + const unlinkedRecord = rootRecords.find((r) => r.id === unlinked.id); + + expect(northRecord?.fields[rootScoreRollupFieldId]).toEqual(60); + expect(globalRecord?.fields[rootScoreRollupFieldId]).toEqual(140); + expect(unlinkedRecord?.fields[rootScoreRollupFieldId]).toEqual(0); + + expect(northRecord?.fields[rootItemCountRollupFieldId]).toEqual(1); + expect(globalRecord?.fields[rootItemCountRollupFieldId]).toEqual(2); + expect(unlinkedRecord?.fields[rootItemCountRollupFieldId]).toEqual(0); + + // concatenate over conditionalLookup arrays keeps each middle row's + // JSON-encoded array segment (v1 behaved the same; its test decoded + // the segments before asserting) + const decodeConcatRollup = (value: unknown): unknown[] => { + if (value == null || value === '') return []; + if (Array.isArray(value)) return value; + if (typeof value !== 'string') return [value]; + const tryParse = (input: string) => { + try { + return JSON.parse(input) as unknown; + } catch { + return undefined; + } + }; + const direct = tryParse(value); + if (direct !== undefined) return Array.isArray(direct) ? direct.flat() : [direct]; + return value + .split('],') + .map((part) => { + const normalized = part.trim(); + const withBracket = normalized.endsWith(']') ? normalized : `${normalized}]`; + const parsed = tryParse(withBracket); + return parsed ?? [normalized.replace(/^\[|"|'|\]$/g, '')]; + }) + .flat(); + }; + + expect(decodeConcatRollup(northRecord?.fields[rootItemConcatRollupFieldId])).toEqual([ + 'Alpha', + ]); + expect( + decodeConcatRollup(globalRecord?.fields[rootItemConcatRollupFieldId]).sort() + ).toEqual(['Alpha', 'Gamma']); + expect(decodeConcatRollup(unlinkedRecord?.fields[rootItemConcatRollupFieldId])).toEqual([]); + } finally { + if (rootTableId) await ctx.deleteTable(rootTableId).catch(() => undefined); + if (middleTableId) await ctx.deleteTable(middleTableId).catch(() => undefined); + if (leafTableId) await ctx.deleteTable(leafTableId).catch(() => undefined); + } + }); + }); +}); diff --git a/packages/v2/e2e/src/search-access-path-contract.e2e.spec.ts b/packages/v2/e2e/src/search-access-path-contract.e2e.spec.ts new file mode 100644 index 0000000000..4d524a6020 --- /dev/null +++ b/packages/v2/e2e/src/search-access-path-contract.e2e.spec.ts @@ -0,0 +1,203 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { registerV2TableOpsPostgresAdapter } from '@teable/v2-adapter-table-query-ops-postgres'; +import type { IV2NodeTestContainer } from '@teable/v2-container-node-test'; +import { + createTableOkResponseSchema, + getSearchAccessPathCapabilitiesOkResponseSchema, + getSearchAccessPathStatusOkResponseSchema, + reconcileSearchAccessPathOkResponseSchema, +} from '@teable/v2-contract-http'; +import { createV2ExpressRouter } from '@teable/v2-contract-http-express'; +import { createV2TableQueryOpsExpressRouter } from '@teable/v2-contract-http-express/table-query-ops'; +import { registerV2TableOps } from '@teable/v2-table-query-ops'; +import express from 'express'; +import { sql } from 'kysely'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createE2eTestContainer } from './shared/createE2eTestContainer'; + +describe('managed search access-path HTTP contract (postgres)', () => { + let testContainer: IV2NodeTestContainer; + let server: Server; + let baseUrl: string; + + beforeAll(async () => { + testContainer = await createE2eTestContainer({ dbMode: 'postgres' }); + registerV2TableOps(testContainer.container); + await registerV2TableOpsPostgresAdapter(testContainer.container, { + metaDb: testContainer.metaDb, + dataDb: testContainer.dataDb, + ensureSchema: true, + }); + await sql.raw('CREATE EXTENSION IF NOT EXISTS pg_trgm').execute(testContainer.dataDb); + + const app = express(); + app.use( + createV2ExpressRouter({ + createContainer: () => testContainer.container, + }) + ); + app.use( + createV2TableQueryOpsExpressRouter({ + createContainer: () => testContainer.container, + allowSearchAccessPathMutation: true, + }) + ); + + server = await new Promise((resolve) => { + const listeningServer = app.listen(0, '127.0.0.1', () => resolve(listeningServer)); + }); + const address = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${address.port}`; + }, 120_000); + + afterAll(async () => { + if (server) { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + await testContainer?.dispose(); + }); + + it('exposes the v2-native status, capability, and guarded reconcile lifecycle', async () => { + const createTableResponse = await fetch(`${baseUrl}/tables/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + baseId: testContainer.baseId.toString(), + name: 'Managed search access path', + fields: [{ type: 'singleLineText', name: 'Title' }], + }), + }); + const createTableBody = createTableOkResponseSchema.parse(await createTableResponse.json()); + expect(createTableResponse.status).toBe(201); + expect(createTableBody.ok).toBe(true); + if (!createTableBody.ok) return; + + const table = createTableBody.data.table; + const titleField = table.fields.find((field) => field.name === 'Title'); + if (!titleField) throw new Error('Expected Title field'); + + const tableStorage = await testContainer.db + .selectFrom('table_meta') + .select('db_table_name') + .where('id', '=', table.id) + .executeTakeFirstOrThrow(); + const fieldStorage = await testContainer.db + .selectFrom('field') + .select('db_field_name') + .where('id', '=', titleField.id) + .executeTakeFirstOrThrow(); + + await sql` + INSERT INTO ${sql.table(tableStorage.db_table_name)} + ("__id", "__created_by", "__version", ${sql.ref(fieldStorage.db_field_name)}) + SELECT + 'rec_search_contract_' || row_number::text, + 'system', + 1, + CASE + WHEN row_number = 4242 THEN 'needle package target' + ELSE md5(row_number::text) || md5((row_number + 100000)::text) + END + FROM generate_series(1, 30000) AS row_number + `.execute(testContainer.dataDb); + await sql`ANALYZE ${sql.table(tableStorage.db_table_name)}`.execute(testContainer.dataDb); + + const capabilitiesResponse = await fetch( + `${baseUrl}/table-query-ops/search-access-path/capabilities` + ); + const capabilitiesRawBody = await capabilitiesResponse.json(); + expect(capabilitiesResponse.status, JSON.stringify(capabilitiesRawBody)).toBe(200); + const capabilitiesBody = + getSearchAccessPathCapabilitiesOkResponseSchema.parse(capabilitiesRawBody); + expect(capabilitiesBody.ok).toBe(true); + if (!capabilitiesBody.ok) return; + expect(capabilitiesBody.data.capabilities).toEqual( + expect.arrayContaining([expect.objectContaining({ provider: 'pg_trgm', state: 'ready' })]) + ); + + const readStatus = async () => { + const response = await fetch( + `${baseUrl}/table-query-ops/search-access-path/status?tableId=${table.id}` + ); + const rawBody = await response.json(); + expect(response.status, JSON.stringify(rawBody)).toBe(200); + const body = getSearchAccessPathStatusOkResponseSchema.parse(rawBody); + expect(body.ok).toBe(true); + if (!body.ok) throw new Error('Expected search access-path status'); + return body.data.status; + }; + + expect(await readStatus()).toMatchObject({ + tableId: table.id, + state: 'disabled', + configured: false, + }); + + const reconcile = async (body: Record) => { + const response = await fetch(`${baseUrl}/table-query-ops/search-access-path/reconcile`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const rawBody = await response.json(); + expect(response.status, JSON.stringify(rawBody)).toBe(200); + const parsedBody = reconcileSearchAccessPathOkResponseSchema.parse(rawBody); + expect(parsedBody.ok).toBe(true); + if (!parsedBody.ok) throw new Error('Expected search access-path reconcile result'); + return parsedBody.data.result; + }; + + const created = await reconcile({ + tableId: table.id, + mode: 'create', + semantics: 'substring', + provider: 'pg_trgm', + languageConfig: 'simple', + fieldIds: [titleField.id], + searchProbe: 'needle package', + }); + expect(created).toMatchObject({ + action: 'created', + tableId: table.id, + status: 'ready', + }); + expect(created.planEvidence).toMatchObject({ + explainStatus: 'validated', + explainMethod: 'real_index', + usesCandidateIndex: true, + semanticsCompatible: true, + }); + expect(await readStatus()).toMatchObject({ + state: 'ready', + configured: true, + provider: 'pg_trgm', + accessPath: 'generated_text', + coveredFieldCount: 1, + }); + + const rebuilt = await reconcile({ + tableId: table.id, + mode: 'rebuild', + expectedDefinitionKey: created.definitionKey, + semantics: 'substring', + provider: 'pg_trgm', + languageConfig: 'simple', + fieldIds: [titleField.id], + searchProbe: 'needle package', + }); + expect(rebuilt).toMatchObject({ + action: 'rebuilt', + definitionKey: created.definitionKey, + status: 'ready', + }); + + const dropped = await reconcile({ tableId: table.id, mode: 'drop' }); + expect(dropped).toMatchObject({ action: 'dropped', status: 'disabled' }); + expect(await readStatus()).toMatchObject({ state: 'disabled', configured: false }); + }, 120_000); +}); diff --git a/packages/v2/e2e/src/shared/globalTestContext.ts b/packages/v2/e2e/src/shared/globalTestContext.ts index 871fb2f222..dfc8bd1660 100644 --- a/packages/v2/e2e/src/shared/globalTestContext.ts +++ b/packages/v2/e2e/src/shared/globalTestContext.ts @@ -52,24 +52,34 @@ import { deleteByRangeOkResponseSchema, } from '@teable/v2-contract-http'; import { createV2ExpressRouter } from '@teable/v2-contract-http-express'; -import type { - ICreateTableCommandInput, - ICreateFieldCommandInput, - ICreateTablesCommandInput, - IDuplicateTableCommandInput, - IPasteCommandInput, - IImportCsvCommandInput, - IImportRecordsCommandInput, - IUpdateFieldCommandInput, - IUpdateRecordsCommandInput, - RecordFilter, - RecordSearchInput, +import { + ActorId, + MemoryUndoRedoStore, + RECORD_REMOVAL_REASON, + RecordsDeleted, + v2CoreTokens, + type ICreateTableCommandInput, + type ICreateFieldCommandInput, + type ICreateTablesCommandInput, + type IDuplicateTableCommandInput, + type MemoryEventBus, + type IImportCsvCommandInput, + type IExecutionContext, + type IImportRecordsCommandInput, + type IPasteCommandInput, + type IUpdateFieldCommandInput, + type IUpdateRecordsCommandInput, + type RecordFilter, + type RecordSearchInput, } from '@teable/v2-core'; -import { ActorId, MemoryUndoRedoStore, v2CoreTokens } from '@teable/v2-core'; import { registerV2ImportServices } from '@teable/v2-import'; +import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; import express from 'express'; +import type { Kysely } from 'kysely'; import { createE2eTestContainer, type E2eDbMode } from './createE2eTestContainer'; +type DynamicDb = V1TeableDatabase & Record>; + // Default test user that will be used as the actorId for all API requests export const TEST_USER = { id: 'usrTestUserId', @@ -77,6 +87,51 @@ export const TEST_USER = { email: 'test@e2e.com', } as const; +const trashSinkRowId = () => `rtrtest_${crypto.randomUUID()}`; + +const installTrashSink = (eventBus: MemoryEventBus, db: Kysely): void => { + const publish = eventBus.publish.bind(eventBus); + const publishMany = eventBus.publishMany.bind(eventBus); + + const sinkDeleted = async ( + context: IExecutionContext, + events: Parameters[1] + ) => { + for (const event of events) { + if (!(event instanceof RecordsDeleted)) continue; + if (event.removalReason === RECORD_REMOVAL_REASON.Archived) continue; + if (event.recordIds.length === 0) continue; + + await db + .insertInto('record_trash') + .values( + event.recordIds.map((recordId) => ({ + id: trashSinkRowId(), + table_id: event.tableId.toString(), + record_id: recordId.toString(), + snapshot: '{}', + created_by: context.actorId.toString(), + reason: RECORD_REMOVAL_REASON.Deleted, + })) + ) + .execute(); + } + }; + + // Patch the existing singleton in place: command handlers already hold this + // instance, and replacing the DI token would split the published event history. + eventBus.publish = async (context, event) => { + const result = await publish(context, event); + if (result.isOk()) await sinkDeleted(context, [event]); + return result; + }; + eventBus.publishMany = async (context, events) => { + const result = await publishMany(context, events); + if (result.isOk()) await sinkDeleted(context, events); + return result; + }; +}; + export interface SharedTestContext { testContainer: IV2NodeTestContainer; baseId: string; @@ -413,6 +468,7 @@ const initSharedContext = async ( const testContainer = await time('create-test-container', async () => createE2eTestContainer({ dbMode }) ); + installTrashSink(testContainer.eventBus, testContainer.dataDb as Kysely); const baseId = testContainer.baseId.toString(); // Register import services (CSV, Excel adapters) @@ -566,7 +622,7 @@ const initSharedContext = async ( const response = await fetch(`${baseUrl}/tables/rename`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ tableId, name }), + body: JSON.stringify({ baseId, tableId, name }), }); if (!response.ok) { const errorText = await response.text(); diff --git a/packages/v2/e2e/src/update-field/attachment/testUtils.ts b/packages/v2/e2e/src/update-field/attachment/testUtils.ts index f4db60636a..e6c0694857 100644 --- a/packages/v2/e2e/src/update-field/attachment/testUtils.ts +++ b/packages/v2/e2e/src/update-field/attachment/testUtils.ts @@ -29,10 +29,12 @@ export const ensureAttachmentTables = async (ctx: SharedTestContext) => { `.execute(ctx.testContainer.db); }; -export const seedAttachment = async (ctx: SharedTestContext): Promise => { +export const seedAttachment = async ( + ctx: SharedTestContext, + size = 128 +): Promise => { const token = `tok_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const path = `table/${Math.random().toString(36).slice(2, 12)}`; - const size = 128; const mimetype = 'text/plain'; await sql` diff --git a/packages/v2/e2e/src/update-field/checkbox/update-properties.spec.ts b/packages/v2/e2e/src/update-field/checkbox/update-properties.spec.ts index f0f762b6f0..a40be9e1e7 100644 --- a/packages/v2/e2e/src/update-field/checkbox/update-properties.spec.ts +++ b/packages/v2/e2e/src/update-field/checkbox/update-properties.spec.ts @@ -119,12 +119,13 @@ describe('update-field: checkbox property updates', () => { field: { options: { defaultValue: false } }, }); - // Assert: New records get false by default + // The option remains false while new unchecked cells are stored as null. const updatedField = updatedTable.fields.find((f) => f.id === fieldId); expect((updatedField?.options as CheckboxFieldOptions | undefined)?.defaultValue).toBe(false); const r1 = await ctx.createRecord(tableId, {}); - expect(r1.fields[fieldId]).toBe(false); + // v1 contract: a false default is stored as null, so the cell stays empty + expect(r1.fields[fieldId] == null).toBe(true); // Cleanup await ctx.deleteField({ tableId, fieldId }); @@ -224,7 +225,7 @@ describe('update-field: checkbox conversions', () => { }); test('should convert checkbox to text', async () => { - // Setup: Create checkbox field with values: true, false, null + // Setup: Create checkbox field with checked and empty values. const fieldId = createFieldId(); await ctx.createField({ baseId: ctx.baseId, @@ -242,10 +243,11 @@ describe('update-field: checkbox conversions', () => { field: { type: 'singleLineText' }, }); - // Assert: Values become "true", "false", null + // Assert: Checked becomes "true" while unchecked and empty remain null. const records = await ctx.listRecords(tableId); expect(records.find((r) => r.id === r1.id)?.fields[fieldId]).toBe('true'); - expect(records.find((r) => r.id === r2.id)?.fields[fieldId]).toBe('false'); + // v1 contract: false input was stored as null, so it converts to an empty cell + expect(records.find((r) => r.id === r2.id)?.fields[fieldId]).toBeNull(); expect(records.find((r) => r.id === r3.id)?.fields[fieldId]).toBeNull(); // Cleanup @@ -254,7 +256,7 @@ describe('update-field: checkbox conversions', () => { }); test('should convert checkbox to number', async () => { - // Setup: Create checkbox field with values: true, false, null + // Setup: Create checkbox field with checked and empty values. const fieldId = createFieldId(); await ctx.createField({ baseId: ctx.baseId, @@ -272,10 +274,11 @@ describe('update-field: checkbox conversions', () => { field: { type: 'number' }, }); - // Assert: Values become 1, 0, null + // Assert: Checked becomes 1 while unchecked and empty remain null. const records = await ctx.listRecords(tableId); expect(records.find((r) => r.id === r1.id)?.fields[fieldId]).toBe(1); - expect(records.find((r) => r.id === r2.id)?.fields[fieldId]).toBe(0); + // v1 contract: false input was stored as null, so it converts to an empty cell + expect(records.find((r) => r.id === r2.id)?.fields[fieldId]).toBeNull(); expect(records.find((r) => r.id === r3.id)?.fields[fieldId]).toBeNull(); // Cleanup @@ -284,7 +287,7 @@ describe('update-field: checkbox conversions', () => { }); test('should convert checkbox to singleSelect with option generation', async () => { - // Setup: Create checkbox field with values: true, false + // Setup: Only checked values remain after false is normalized to null. const fieldId = createFieldId(); await ctx.createField({ baseId: ctx.baseId, @@ -301,18 +304,16 @@ describe('update-field: checkbox conversions', () => { field: { type: 'singleSelect' }, }); - // Assert: - // - Values become "true", "false" - // - Options auto-generated: [{name: "true", ...}, {name: "false", ...}] + // Assert: only the stored checked value generates a select option. const updatedField = updatedTable.fields.find((f) => f.id === fieldId); expect(updatedField?.type).toBe('singleSelect'); const options = updatedField?.options as { choices: { name: string }[] }; + // v1 contract: false is stored as null, so only 'true' exists to become a choice expect(options.choices.map((c) => c.name)).toContain('true'); - expect(options.choices.map((c) => c.name)).toContain('false'); const records = await ctx.listRecords(tableId); expect(records.find((r) => r.id === r1.id)?.fields[fieldId]).toBe('true'); - expect(records.find((r) => r.id === r2.id)?.fields[fieldId]).toBe('false'); + expect(records.find((r) => r.id === r2.id)?.fields[fieldId]).toBeNull(); // Cleanup await ctx.deleteField({ tableId, fieldId }); @@ -320,7 +321,7 @@ describe('update-field: checkbox conversions', () => { }); test('should convert checkbox to multipleSelect with option generation', async () => { - // Setup: Create checkbox field with values: true, false, null + // Setup: Only checked values remain after false is normalized to null. const fieldId = createFieldId(); await ctx.createField({ baseId: ctx.baseId, @@ -338,18 +339,16 @@ describe('update-field: checkbox conversions', () => { field: { type: 'multipleSelect' }, }); - // Assert: - // - Values become ["true"], ["false"], null - // - Options auto-generated: [{name: "true", ...}, {name: "false", ...}] + // Assert: only the stored checked value generates a select option. const updatedField = updatedTable.fields.find((f) => f.id === fieldId); expect(updatedField?.type).toBe('multipleSelect'); const options = updatedField?.options as { choices: { name: string }[] }; + // v1 contract: false is stored as null, so only 'true' exists to become a choice expect(options.choices.map((c) => c.name)).toContain('true'); - expect(options.choices.map((c) => c.name)).toContain('false'); const records = await ctx.listRecords(tableId); expect(records.find((r) => r.id === r1.id)?.fields[fieldId]).toEqual(['true']); - expect(records.find((r) => r.id === r2.id)?.fields[fieldId]).toEqual(['false']); + expect(records.find((r) => r.id === r2.id)?.fields[fieldId]).toBeNull(); expect(records.find((r) => r.id === r3.id)?.fields[fieldId]).toBeNull(); // Cleanup @@ -358,7 +357,7 @@ describe('update-field: checkbox conversions', () => { }); test('should convert checkbox to rating', async () => { - // Setup: Create checkbox field with values: true, false, null + // Setup: Create checkbox field with checked and empty values. const fieldId = createFieldId(); await ctx.createField({ baseId: ctx.baseId, @@ -379,13 +378,14 @@ describe('update-field: checkbox conversions', () => { }, }); - // Assert: Values become 5 (max), 0, null + // Assert: Checked becomes max while unchecked and empty remain null. const updatedField = updatedTable.fields.find((f) => f.id === fieldId); expect(updatedField?.type).toBe('rating'); const records = await ctx.listRecords(tableId); expect(records.find((r) => r.id === r1.id)?.fields[fieldId]).toBe(5); - expect(records.find((r) => r.id === r2.id)?.fields[fieldId]).toBe(0); + // v1 contract: false input was stored as null, so it converts to an empty cell + expect(records.find((r) => r.id === r2.id)?.fields[fieldId]).toBeNull(); expect(records.find((r) => r.id === r3.id)?.fields[fieldId]).toBeNull(); // Cleanup diff --git a/packages/v2/e2e/src/update-field/clear-default-value.spec.ts b/packages/v2/e2e/src/update-field/clear-default-value.spec.ts index f435a44f47..59444ac81d 100644 --- a/packages/v2/e2e/src/update-field/clear-default-value.spec.ts +++ b/packages/v2/e2e/src/update-field/clear-default-value.spec.ts @@ -150,7 +150,8 @@ describe('e2e API: clear field defaultValue T6107', () => { expect(fieldOptions(field).defaultValue).toBeUndefined(); const afterClear = await ctx.createRecord(tableId, { [primaryFieldId]: 'unchecked' }); - expect(afterClear.fields[fieldId] == null || afterClear.fields[fieldId] === false).toBe(true); + // v1 contract: checkbox cells are true or empty — false must never be stored + expect(afterClear.fields[fieldId] == null).toBe(true); await ctx.deleteField({ tableId, fieldId }); await ctx.deleteRecords(tableId, [beforeClear.id, afterClear.id]); diff --git a/packages/v2/e2e/src/update-field/computed/dependency-cascade.spec.ts b/packages/v2/e2e/src/update-field/computed/dependency-cascade.spec.ts index 0223c1a60c..0b1143a074 100644 --- a/packages/v2/e2e/src/update-field/computed/dependency-cascade.spec.ts +++ b/packages/v2/e2e/src/update-field/computed/dependency-cascade.spec.ts @@ -439,6 +439,39 @@ describe('update-field: computed dependency cascades', () => { } }); + // T6500: schema cascade backfill must tolerate text-typed SELECT projections + // against REAL/double precision lookup columns (IS DISTINCT FROM casts). + test('should backfill numeric lookup when source field converts text → number without type error', async () => { + let hostTableId: string | undefined; + let foreignTableId: string | undefined; + try { + const setup = await createLinkLookupTable(); + hostTableId = setup.hostTableId; + foreignTableId = setup.foreignTableId; + + // Source starts as text ("100"). Convert to number so dependent lookup + // cascade backfill re-runs with DISTINCT comparisons against REAL columns. + await ctx.updateField({ + tableId: setup.foreignTableId, + fieldId: setup.foreignSourceFieldId, + field: { type: 'number' }, + }); + await ctx.drainOutbox(); + + const hostRecords = await ctx.listRecordsWithoutDrain(setup.hostTableId); + const host = hostRecords[0]; + if (!host) throw new Error('No host record'); + + // Lookup of a number remains scalar-array shaped for manyOne. + expect(host.fields[setup.lookupFieldId]).toEqual([100]); + // Formula over lookup currently preserves the lookup cell shape. + expect(host.fields[setup.formulaFieldId]).toEqual([100]); + } finally { + await cleanupTable(hostTableId); + await cleanupTable(foreignTableId); + } + }); + test('[V1 PARITY] should propagate lookup value changes into dependent formula fields', async () => { let hostTableId: string | undefined; let foreignTableId: string | undefined; diff --git a/packages/v2/e2e/src/update-field/formula/conversion/to-date.spec.ts b/packages/v2/e2e/src/update-field/formula/conversion/to-date.spec.ts index 7debe9ac2b..f7b4a25b52 100644 --- a/packages/v2/e2e/src/update-field/formula/conversion/to-date.spec.ts +++ b/packages/v2/e2e/src/update-field/formula/conversion/to-date.spec.ts @@ -133,6 +133,54 @@ describe('update-field: formula → date conversion', () => { await ctx.deleteRecords(tableId, [rec.id]); }); + test('should null calendar-invalid string formula values without aborting conversion', async () => { + const textFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { type: 'singleLineText', id: textFieldId, name: 'Date Text Source' }, + }); + + const formulaFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { + type: 'formula', + id: formulaFieldId, + name: 'Formula Text Date', + options: { expression: `{${textFieldId}}` }, + }, + }); + + const values = ['2026-02-30', '2026-13-01', '2026-03-01']; + const records = await Promise.all( + values.map((value) => ctx.createRecord(tableId, { [textFieldId]: value })) + ); + await ctx.drainOutbox(); + + const updatedTable = await ctx.updateField({ + tableId, + fieldId: formulaFieldId, + field: { type: 'date' }, + }); + + expect(updatedTable.fields.find((field) => field.id === formulaFieldId)?.type).toBe('date'); + const convertedRecords = await ctx.listRecords(tableId); + const convertedValues = records.map( + (record) => + convertedRecords.find((candidate) => candidate.id === record.id)?.fields[formulaFieldId] + ); + expect(convertedValues).toEqual([null, null, '2026-03-01T00:00:00.000Z']); + + await ctx.deleteField({ tableId, fieldId: formulaFieldId }); + await ctx.deleteField({ tableId, fieldId: textFieldId }); + await ctx.deleteRecords( + tableId, + records.map((record) => record.id) + ); + }); + test('should handle null values', async () => { const dateFieldId = createFieldId(); await ctx.createField({ diff --git a/packages/v2/e2e/src/update-field/formula/conversion/to-rating.spec.ts b/packages/v2/e2e/src/update-field/formula/conversion/to-rating.spec.ts new file mode 100644 index 0000000000..4f9c32c70f --- /dev/null +++ b/packages/v2/e2e/src/update-field/formula/conversion/to-rating.spec.ts @@ -0,0 +1,117 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; + +import { getSharedTestContext, type SharedTestContext } from '../../../shared/globalTestContext'; + +describe('update-field: formula → rating conversion', () => { + let ctx: SharedTestContext; + let tableId: string; + let primaryFieldId: string; + let fieldIdCounter = 0; + + const createFieldId = () => { + const suffix = fieldIdCounter.toString(36).padStart(16, '0'); + fieldIdCounter += 1; + return `fld${suffix}`; + }; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Formula to Rating Conversion', + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + }); + tableId = table.id; + const primaryField = table.fields.find((field) => field.isPrimary); + if (!primaryField) throw new Error('No primary field'); + primaryFieldId = primaryField.id; + }); + + afterAll(async () => { + if (tableId) { + try { + await ctx.deleteTable(tableId); + } catch { + // Ignore cleanup errors + } + } + }); + + test('should round, clamp, preserve null, and accept strict rewrites when converting to rating', async () => { + const sourceFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { type: 'number', id: sourceFieldId, name: 'Source Value' }, + }); + + const formulaFieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { + type: 'formula', + id: formulaFieldId, + name: 'Calculated Value', + options: { expression: `{${sourceFieldId}}` }, + }, + }); + + const sourceValues = [2.7, 4.6, 0, -3, 9, 3, 0.4, null] as const; + const expectedValues = [3, 5, null, null, 5, 3, null, null] as const; + const records = []; + + for (const [index, value] of sourceValues.entries()) { + records.push( + await ctx.createRecord( + tableId, + value === null + ? { [primaryFieldId]: `Case ${index + 1}` } + : { [primaryFieldId]: `Case ${index + 1}`, [sourceFieldId]: value } + ) + ); + } + await ctx.drainOutbox(); + const computedRecords = await ctx.listRecords(tableId); + for (const [index, record] of records.entries()) { + expect(computedRecords.find((item) => item.id === record.id)?.fields[formulaFieldId]).toBe( + sourceValues[index] + ); + } + + const updatedTable = await ctx.updateField({ + tableId, + fieldId: formulaFieldId, + field: { type: 'rating', max: 5 }, + }); + + const updatedField = updatedTable.fields.find((field) => field.id === formulaFieldId); + expect(updatedField?.type).toBe('rating'); + + const convertedRecords = await ctx.listRecords(tableId); + for (const [index, record] of records.entries()) { + expect(convertedRecords.find((item) => item.id === record.id)?.fields[formulaFieldId]).toBe( + expectedValues[index] + ); + } + + for (const [index, record] of records.entries()) { + const expectedValue = expectedValues[index]; + if (expectedValue === null) continue; + + const rewritten = await ctx.updateRecord(tableId, record.id, { + [formulaFieldId]: expectedValue, + }); + expect(rewritten.fields[formulaFieldId]).toBe(expectedValue); + } + + await ctx.deleteRecords( + tableId, + records.map((record) => record.id) + ); + await ctx.deleteField({ tableId, fieldId: formulaFieldId }); + await ctx.deleteField({ tableId, fieldId: sourceFieldId }); + }); +}); diff --git a/packages/v2/e2e/src/update-field/link/conversion/general-conversion-cases.spec.ts b/packages/v2/e2e/src/update-field/link/conversion/general-conversion-cases.spec.ts index 399ae5f3a0..15954fd1fb 100644 --- a/packages/v2/e2e/src/update-field/link/conversion/general-conversion-cases.spec.ts +++ b/packages/v2/e2e/src/update-field/link/conversion/general-conversion-cases.spec.ts @@ -812,6 +812,223 @@ describe('update-field: link conversion general cases', () => { expect(links[0]?.id).toBe(b.id); }); + test('should convert one-way one-one to two-way one-one', async () => { + const tableA = await ctx.createTable({ + baseId: ctx.baseId, + name: nextName('v1p-ow-oo-tw-oo-a'), + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + }); + const tableB = await ctx.createTable({ + baseId: ctx.baseId, + name: nextName('v1p-ow-oo-tw-oo-b'), + fields: [{ type: 'singleLineText', name: 'Title', isPrimary: true }], + }); + + const linkTable = await ctx.createField({ + baseId: ctx.baseId, + tableId: tableA.id, + field: { + type: 'link', + name: 'Link', + options: { + relationship: 'oneOne', + foreignTableId: tableB.id, + lookupFieldId: primaryFieldId(tableB), + isOneWay: true, + }, + }, + }); + const linkField = linkTable.fields.find((f) => f.name === 'Link'); + if (!linkField) throw new Error('Link field missing'); + + const b = await ctx.createRecord(tableB.id, { [primaryFieldId(tableB)]: 'x' }); + const a = await ctx.createRecord(tableA.id, { + [primaryFieldId(tableA)]: 'a1', + [linkField.id]: { id: b.id }, + }); + + const updatedTable = await ctx.updateField({ + tableId: tableA.id, + fieldId: linkField.id, + field: { + options: { + relationship: 'oneOne', + foreignTableId: tableB.id, + lookupFieldId: primaryFieldId(tableB), + isOneWay: false, + }, + }, + }); + + await ctx.drainOutbox(); + + const updatedField = updatedTable.fields.find((f) => f.id === linkField.id); + expect(updatedField?.type).toBe('link'); + expect((updatedField?.options as { relationship?: string } | undefined)?.relationship).toBe( + 'oneOne' + ); + const symFieldId = extractSymmetricFieldId(updatedField); + expect(symFieldId).toBeDefined(); + + const rowsA = await ctx.listRecords(tableA.id); + const rowA = rowsA.find((r) => r.id === a.id); + const linksA = asLinkArray(rowA?.fields[linkField.id]); + expect(linksA[0]?.id).toBe(b.id); + expect(linksA[0]?.title).toBe('x'); + + const rowsB = await ctx.listRecords(tableB.id); + const rowB = rowsB.find((r) => r.id === b.id); + const symLinks = asLinkArray(rowB?.fields[symFieldId!]); + expect(symLinks[0]?.id).toBe(a.id); + }); + + test('should convert one-many to many-one link with 2 lookup and 2 formula fields', async () => { + const tableA = await ctx.createTable({ + baseId: ctx.baseId, + name: nextName('v1p-om-mo-deps-a'), + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + }); + const tableB = await ctx.createTable({ + baseId: ctx.baseId, + name: nextName('v1p-om-mo-deps-b'), + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { type: 'number', name: 'Count' }, + ], + }); + + const titleFieldId = primaryFieldId(tableB); + const countFieldId = tableB.fields.find((f) => f.name === 'Count')?.id; + if (!countFieldId) throw new Error('Count field missing'); + + const withLink = await ctx.createField({ + baseId: ctx.baseId, + tableId: tableA.id, + field: { + type: 'link', + name: 'Link', + options: { + relationship: 'oneMany', + foreignTableId: tableB.id, + lookupFieldId: titleFieldId, + isOneWay: true, + }, + }, + }); + const linkField = withLink.fields.find((f) => f.name === 'Link'); + if (!linkField) throw new Error('Link field missing'); + + const withLookup1 = await ctx.createField({ + baseId: ctx.baseId, + tableId: tableA.id, + field: { + type: 'lookup', + name: 'TitleLookup', + options: { + linkFieldId: linkField.id, + foreignTableId: tableB.id, + lookupFieldId: titleFieldId, + }, + }, + }); + const lookupField1 = withLookup1.fields.find((f) => f.name === 'TitleLookup'); + if (!lookupField1) throw new Error('TitleLookup field missing'); + + const withLookup2 = await ctx.createField({ + baseId: ctx.baseId, + tableId: tableA.id, + field: { + type: 'lookup', + name: 'CountLookup', + options: { + linkFieldId: linkField.id, + foreignTableId: tableB.id, + lookupFieldId: countFieldId, + }, + }, + }); + const lookupField2 = withLookup2.fields.find((f) => f.name === 'CountLookup'); + if (!lookupField2) throw new Error('CountLookup field missing'); + + const withFormula1 = await ctx.createField({ + baseId: ctx.baseId, + tableId: tableA.id, + field: { + type: 'formula', + name: 'Formula1', + options: { expression: `{${lookupField1.id}}` }, + }, + }); + const formulaField1 = withFormula1.fields.find((f) => f.name === 'Formula1'); + if (!formulaField1) throw new Error('Formula1 field missing'); + + const withFormula2 = await ctx.createField({ + baseId: ctx.baseId, + tableId: tableA.id, + field: { + type: 'formula', + name: 'Formula2', + options: { expression: `{${lookupField2.id}}` }, + }, + }); + const formulaField2 = withFormula2.fields.find((f) => f.name === 'Formula2'); + if (!formulaField2) throw new Error('Formula2 field missing'); + + const b1 = await ctx.createRecord(tableB.id, { + [titleFieldId]: 'x', + [countFieldId]: 1, + }); + const b2 = await ctx.createRecord(tableB.id, { [titleFieldId]: 'y' }); + const a1 = await ctx.createRecord(tableA.id, { + [primaryFieldId(tableA)]: 'a1', + [linkField.id]: [{ id: b1.id }, { id: b2.id }], + }); + + await ctx.drainOutbox(); + + const rowsBefore = await ctx.listRecords(tableA.id); + const rowBefore = rowsBefore.find((r) => r.id === a1.id); + expect(rowBefore?.fields[formulaField1.id]).toEqual(['x', 'y']); + expect(rowBefore?.fields[formulaField2.id]).toEqual([1]); + + const updatedTable = await ctx.updateField({ + tableId: tableA.id, + fieldId: linkField.id, + field: { + options: { + relationship: 'manyOne', + foreignTableId: tableB.id, + lookupFieldId: titleFieldId, + isOneWay: true, + }, + }, + }); + + await ctx.drainOutbox(); + + const updatedLink = updatedTable.fields.find((f) => f.id === linkField.id); + expect(updatedLink?.type).toBe('link'); + expect((updatedLink?.options as { relationship?: string } | undefined)?.relationship).toBe( + 'manyOne' + ); + + // v1: many-one link keeps only one value; dependent lookups/formulas flip to scalar + const refreshedTable = await ctx.getTableById(tableA.id); + const refreshedFormula1 = refreshedTable.fields.find((f) => f.id === formulaField1.id) as + | { isMultipleCellValue?: boolean } + | undefined; + const refreshedFormula2 = refreshedTable.fields.find((f) => f.id === formulaField2.id) as + | { isMultipleCellValue?: boolean } + | undefined; + expect(refreshedFormula1?.isMultipleCellValue).not.toBe(true); + expect(refreshedFormula2?.isMultipleCellValue).not.toBe(true); + + const rowsAfter = await ctx.listRecords(tableA.id); + const rowAfter = rowsAfter.find((r) => r.id === a1.id); + expect(rowAfter?.fields[formulaField1.id]).toEqual('x'); + expect(rowAfter?.fields[formulaField2.id]).toEqual(1); + }); + test('should convert one-way many-many to two-way many-many', async () => { const tableA = await ctx.createTable({ baseId: ctx.baseId, diff --git a/packages/v2/e2e/src/update-field/longText/conversion/to-rating.spec.ts b/packages/v2/e2e/src/update-field/longText/conversion/to-rating.spec.ts index ed937679b8..e29d15a84f 100644 --- a/packages/v2/e2e/src/update-field/longText/conversion/to-rating.spec.ts +++ b/packages/v2/e2e/src/update-field/longText/conversion/to-rating.spec.ts @@ -2,8 +2,8 @@ * E2E tests for converting LongText field to Rating. * * Conversion behavior (TextFieldConversionVisitor): - * - Numeric strings are parsed, floored, and clamped to [0, max]: "3.7" -> 3 - * - Non-numeric strings become null: "abc" -> null + * - Numeric strings are rounded and clamped to [1, max] + * - Values below 1 and non-numeric strings become null * - Null values remain null */ /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/packages/v2/e2e/src/update-field/multipleSelect/update-properties.spec.ts b/packages/v2/e2e/src/update-field/multipleSelect/update-properties.spec.ts index 6e979f22f5..7b032358b4 100644 --- a/packages/v2/e2e/src/update-field/multipleSelect/update-properties.spec.ts +++ b/packages/v2/e2e/src/update-field/multipleSelect/update-properties.spec.ts @@ -306,6 +306,41 @@ describe('update-field: multipleSelect property updates', () => { await ctx.deleteField({ tableId, fieldId }); }); + + test('should not accept duplicated name choices', async () => { + // V1 parity: field-converting.e2e-spec.ts "should not accept duplicated name choices" + const fieldId = createFieldId(); + const optionX = { id: 'choX', name: 'x', color: Colors.CyanBright }; + const optionY = { id: 'choY', name: 'y', color: Colors.BlueBright }; + await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { + type: 'multipleSelect', + id: fieldId, + name: 'Duplicated Choice Names', + options: { choices: [optionX, optionY] }, + }, + }); + + await expect( + ctx.updateField({ + tableId, + fieldId, + field: { + type: 'multipleSelect', + options: { + choices: [ + { id: 'choX', name: 'y', color: Colors.CyanBright }, + { id: 'choY', name: 'y', color: Colors.BlueBright }, + ], + }, + }, + }) + ).rejects.toThrow(); + + await ctx.deleteField({ tableId, fieldId }); + }); }); describe('update-field: multipleSelect conversions', () => { diff --git a/packages/v2/e2e/src/update-field/number/conversion/to-checkbox.spec.ts b/packages/v2/e2e/src/update-field/number/conversion/to-checkbox.spec.ts index 35366260fb..bf3ac02cc9 100644 --- a/packages/v2/e2e/src/update-field/number/conversion/to-checkbox.spec.ts +++ b/packages/v2/e2e/src/update-field/number/conversion/to-checkbox.spec.ts @@ -59,6 +59,7 @@ describe('update-field: number → checkbox conversion', () => { const records = await ctx.listRecords(tableId); const rec1 = records.find((r) => r.id === r1.id); + // TODO(T6520 drift): v1 repair(0) stores null; v2 field conversion still stores false expect(rec1?.fields[fieldId]).toBe(false); await ctx.deleteField({ tableId, fieldId }); diff --git a/packages/v2/e2e/src/update-field/number/conversion/to-rating.spec.ts b/packages/v2/e2e/src/update-field/number/conversion/to-rating.spec.ts index 6f6977ac2f..d7ad2a5581 100644 --- a/packages/v2/e2e/src/update-field/number/conversion/to-rating.spec.ts +++ b/packages/v2/e2e/src/update-field/number/conversion/to-rating.spec.ts @@ -70,13 +70,13 @@ describe('update-field: number → rating conversion', () => { const rec3 = records.find((r) => r.id === r3.id); expect(rec1?.fields[fieldId]).toBe(3); expect(rec2?.fields[fieldId]).toBe(5); - expect(rec3?.fields[fieldId]).toBe(0); + expect(rec3?.fields[fieldId]).toBeNull(); await ctx.deleteField({ tableId, fieldId }); await ctx.deleteRecords(tableId, [r1.id, r2.id, r3.id]); }); - test('should floor decimal values', async () => { + test('should round decimal values', async () => { const fieldId = await createNumberField('Decimal Number Field'); const r1 = await ctx.createRecord(tableId, { [fieldId]: 3.7 }); const r2 = await ctx.createRecord(tableId, { [fieldId]: 2.3 }); @@ -90,14 +90,14 @@ describe('update-field: number → rating conversion', () => { const records = await ctx.listRecords(tableId); const rec1 = records.find((r) => r.id === r1.id); const rec2 = records.find((r) => r.id === r2.id); - expect(rec1?.fields[fieldId]).toBe(3); + expect(rec1?.fields[fieldId]).toBe(4); expect(rec2?.fields[fieldId]).toBe(2); await ctx.deleteField({ tableId, fieldId }); await ctx.deleteRecords(tableId, [r1.id, r2.id]); }); - test('should map null values to max rating (current behavior)', async () => { + test('should preserve null values', async () => { const fieldId = await createNumberField('Nullable Number Field'); const r1 = await ctx.createRecord(tableId, { [fieldId]: 4 }); const r2 = await ctx.createRecord(tableId, { [primaryFieldId]: 'No value' }); @@ -115,13 +115,13 @@ describe('update-field: number → rating conversion', () => { const rec1 = records.find((r) => r.id === r1.id); const rec2 = records.find((r) => r.id === r2.id); expect(rec1?.fields[fieldId]).toBe(4); - expect(rec2?.fields[fieldId]).toBe(5); + expect(rec2?.fields[fieldId]).toBeNull(); await ctx.deleteField({ tableId, fieldId }); await ctx.deleteRecords(tableId, [r1.id, r2.id]); }); - test('should clamp negative values to 0', async () => { + test('should map negative values to null', async () => { const fieldId = await createNumberField('Negative Number Field'); const r1 = await ctx.createRecord(tableId, { [fieldId]: -5 }); const r2 = await ctx.createRecord(tableId, { [fieldId]: -0.5 }); @@ -135,8 +135,8 @@ describe('update-field: number → rating conversion', () => { const records = await ctx.listRecords(tableId); const rec1 = records.find((r) => r.id === r1.id); const rec2 = records.find((r) => r.id === r2.id); - expect(rec1?.fields[fieldId]).toBe(0); - expect(rec2?.fields[fieldId]).toBe(0); + expect(rec1?.fields[fieldId]).toBeNull(); + expect(rec2?.fields[fieldId]).toBeNull(); await ctx.deleteField({ tableId, fieldId }); await ctx.deleteRecords(tableId, [r1.id, r2.id]); diff --git a/packages/v2/e2e/src/update-field/singleLineText/constraint-validation.spec.ts b/packages/v2/e2e/src/update-field/singleLineText/constraint-validation.spec.ts index f91b43a48d..4205df6d61 100644 --- a/packages/v2/e2e/src/update-field/singleLineText/constraint-validation.spec.ts +++ b/packages/v2/e2e/src/update-field/singleLineText/constraint-validation.spec.ts @@ -73,13 +73,24 @@ describe('update-field: singleLineText constraint validation', () => { const duplicateB = await ctx.createRecord(tableId, { [fieldId]: '100' }); const nullRecord = await ctx.createRecord(tableId, {}); - await expect( - ctx.updateField({ - tableId, - fieldId, - field: { unique: true }, - }) - ).rejects.toThrow('validation.field.unique'); + const uniqueFailure = await updateFieldRaw(ctx, { + tableId, + fieldId, + field: { unique: true }, + }); + expect(uniqueFailure.status).toBe(400); + const parsedUniqueFailure = updateFieldErrorResponseSchema.safeParse(uniqueFailure.body); + expect(parsedUniqueFailure.success).toBe(true); + if (parsedUniqueFailure.success) { + expect(parsedUniqueFailure.data.error.code).toBe('validation.field.unique_existing_values'); + expect(parsedUniqueFailure.data.error.message).toBe( + 'Cannot mark field "TextField" as unique because existing records contain duplicate values.' + ); + expect(parsedUniqueFailure.data.error.localization).toEqual({ + i18nKey: 'httpErrors.custom.fieldUniqueExistingValues', + context: { fieldName: 'TextField' }, + }); + } await ctx.deleteRecord(tableId, duplicateB.id); @@ -100,7 +111,7 @@ describe('update-field: singleLineText constraint validation', () => { const parsedFailure = updateFieldErrorResponseSchema.safeParse(notNullFailure.body); expect(parsedFailure.success).toBe(true); if (parsedFailure.success) { - expect(parsedFailure.data.error.code).toBe('validation.field.not_null'); + expect(parsedFailure.data.error.code).toBe('validation.field.required_existing_values'); expect(parsedFailure.data.error.message).toBe( 'Cannot mark field "TextField" as required because existing records contain empty values.' ); @@ -174,4 +185,106 @@ describe('update-field: singleLineText constraint validation', () => { } } }); + + /** + * v1 parity (T6520): a type conversion rebuilds the field definition and + * clears its validation constraints in the domain model. The in-place + * ALTER TYPE used to leave the underlying unique index / NOT NULL column + * constraint alive as a ghost — invisible in the field metadata but still + * rejecting writes. Conversion now drops them together with the flags. + */ + test('drops the unique constraint when the field type is converted', async () => { + const fieldId = createFieldId(); + const tableName = createTableName(); + let tableId: string | undefined; + try { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: tableName, + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', id: fieldId, name: 'Code', unique: true }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + const nameFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + + const r1 = await ctx.createRecord(tableId, { + [nameFieldId]: 'R1', + [fieldId]: '42', + }); + expect(r1.id).toBeTruthy(); + + // Unique holds before conversion + const duplicate = await fetch(`${ctx.baseUrl}/tables/createRecord`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tableId, + fields: { [nameFieldId]: 'R2', [fieldId]: '42' }, + }), + }); + expect(duplicate.status).toBeGreaterThanOrEqual(400); + + // Convert the field type: the constraint is cleared with the flags + const converted = await ctx.updateField({ + baseId: ctx.baseId, + tableId, + fieldId, + field: { type: 'number' }, + }); + expect(converted.fields.find((f) => f.id === fieldId)?.unique).not.toBe(true); + + const afterConversion = await ctx.createRecord(tableId, { + [nameFieldId]: 'R3', + [fieldId]: 42, + }); + expect(afterConversion.id).toBeTruthy(); + const anotherDuplicate = await ctx.createRecord(tableId, { + [nameFieldId]: 'R4', + [fieldId]: 42, + }); + expect(anotherDuplicate.id).toBeTruthy(); + } finally { + if (tableId) { + await ctx.deleteTable(tableId).catch(() => undefined); + } + } + }); + + test('drops the ghost NOT NULL column constraint when the field type is converted', async () => { + const fieldId = createFieldId(); + const tableName = createTableName(); + let tableId: string | undefined; + try { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: tableName, + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', id: fieldId, name: 'Req', notNull: true }, + ], + views: [{ type: 'grid' }], + }); + tableId = table.id; + const nameFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + + const converted = await ctx.updateField({ + baseId: ctx.baseId, + tableId, + fieldId, + field: { type: 'number' }, + }); + expect(converted.fields.find((f) => f.id === fieldId)?.notNull).not.toBe(true); + + // The field metadata says optional — creating without the field must work + const created = await ctx.createRecord(tableId, { [nameFieldId]: 'NoReq' }); + expect(created.id).toBeTruthy(); + } finally { + if (tableId) { + await ctx.deleteTable(tableId).catch(() => undefined); + } + } + }); }); diff --git a/packages/v2/e2e/src/update-field/singleLineText/conversion/to-date.spec.ts b/packages/v2/e2e/src/update-field/singleLineText/conversion/to-date.spec.ts index ac50d57fed..71a1f20633 100644 --- a/packages/v2/e2e/src/update-field/singleLineText/conversion/to-date.spec.ts +++ b/packages/v2/e2e/src/update-field/singleLineText/conversion/to-date.spec.ts @@ -149,6 +149,54 @@ describe('update-field: singleLineText → date conversion', () => { await ctx.deleteRecords(tableId, [r1.id, r2.id]); }); + test('should null calendar-invalid ISO values without aborting conversion', async () => { + const fieldId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { type: 'singleLineText', id: fieldId, name: 'Calendar Validation' }, + }); + const values = [ + '2026-02-30', + '2026-13-01', + '2026-02-29', + '2026-00-10', + '2026-01-32', + 'abc', + '2026-03-01', + ]; + const records = await Promise.all( + values.map((value) => ctx.createRecord(tableId, { [fieldId]: value })) + ); + + const updatedTable = await ctx.updateField({ + tableId, + fieldId, + field: { type: 'date' }, + }); + + expect(updatedTable.fields.find((field) => field.id === fieldId)?.type).toBe('date'); + const convertedRecords = await ctx.listRecords(tableId); + const convertedValues = records.map( + (record) => convertedRecords.find((candidate) => candidate.id === record.id)?.fields[fieldId] + ); + expect(convertedValues).toEqual([ + null, + null, + null, + null, + null, + null, + '2026-03-01T00:00:00.000Z', + ]); + + await ctx.deleteField({ tableId, fieldId }); + await ctx.deleteRecords( + tableId, + records.map((record) => record.id) + ); + }); + test('should handle null values', async () => { // Setup: Create singleLineText with null values const fieldId = createFieldId(); diff --git a/packages/v2/e2e/src/update-field/singleLineText/conversion/to-rating.spec.ts b/packages/v2/e2e/src/update-field/singleLineText/conversion/to-rating.spec.ts index a0025f13da..b3d6f68b0a 100644 --- a/packages/v2/e2e/src/update-field/singleLineText/conversion/to-rating.spec.ts +++ b/packages/v2/e2e/src/update-field/singleLineText/conversion/to-rating.spec.ts @@ -2,9 +2,8 @@ * E2E tests for converting SingleLineText field to Rating. * * Conversion behavior: - * - Numeric strings are parsed to rating values - * - Values are clamped to valid range (0 to max) - * - Non-numeric strings become null + * - Numeric strings are rounded and clamped to [1, max] + * - Values below 1 and non-numeric strings become null * - Null values remain null */ /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/packages/v2/e2e/src/update-field/singleLineText/update-properties.spec.ts b/packages/v2/e2e/src/update-field/singleLineText/update-properties.spec.ts index 64dbdb4002..c60016257f 100644 --- a/packages/v2/e2e/src/update-field/singleLineText/update-properties.spec.ts +++ b/packages/v2/e2e/src/update-field/singleLineText/update-properties.spec.ts @@ -102,6 +102,34 @@ describe('update-field: singleLineText property updates', () => { await ctx.deleteRecords(tableId, [record1.id, record2.id]); }); + test('should prevent renaming field to a duplicated name', async () => { + // V1 parity: field-converting.e2e-spec.ts "should modify field name and prevent name duplicate" + const fieldAId = createFieldId(); + const fieldBId = createFieldId(); + await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { type: 'singleLineText', id: fieldAId, name: 'Dup Target' }, + }); + await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { type: 'singleLineText', id: fieldBId, name: 'Dup Source' }, + }); + + await expect( + ctx.updateField({ + tableId, + fieldId: fieldBId, + field: { name: 'Dup Target' }, + }) + ).rejects.toThrow(); + + // Cleanup + await ctx.deleteField({ tableId, fieldId: fieldAId }); + await ctx.deleteField({ tableId, fieldId: fieldBId }); + }); + test('should update field description only', async () => { const fieldId = createFieldId(); await ctx.createField({ diff --git a/packages/v2/e2e/src/update-field/singleSelect/update-properties.spec.ts b/packages/v2/e2e/src/update-field/singleSelect/update-properties.spec.ts index 18eda99893..1615181938 100644 --- a/packages/v2/e2e/src/update-field/singleSelect/update-properties.spec.ts +++ b/packages/v2/e2e/src/update-field/singleSelect/update-properties.spec.ts @@ -28,6 +28,20 @@ const createFieldId = () => { return `fld${suffix}`; }; +const isObjectRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const getDomainEventName = (event: unknown): string | undefined => { + if (!isObjectRecord(event)) { + return undefined; + } + const name = event['name']; + if (!isObjectRecord(name) || typeof name.toString !== 'function') { + return undefined; + } + return name.toString(); +}; + describe('update-field: singleSelect property updates', () => { let ctx: SharedTestContext; let tableId: string; @@ -471,6 +485,72 @@ describe('update-field: singleSelect property updates', () => { await ctx.deleteField({ tableId, fieldId }); }); + test('should not recompute dependent fields when only defaultValue changes', async () => { + // V1 parity: field-converting.e2e-spec.ts + // "should not recompute dependent fields when only defaultValue changes" + const fieldId = createFieldId(); + const formulaFieldId = createFieldId(); + const optionTodo = { id: 'choTodo', name: 'Todo', color: 'blueBright' }; + const optionDone = { id: 'choDone', name: 'Done', color: 'greenBright' }; + await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { + type: 'singleSelect', + id: fieldId, + name: 'Status Default Only', + options: { choices: [optionTodo, optionDone], defaultValue: 'Todo' }, + }, + }); + await ctx.createField({ + baseId: ctx.baseId, + tableId, + field: { + type: 'formula', + id: formulaFieldId, + name: 'Status Default Only Formula', + options: { expression: `{${fieldId}}` }, + }, + }); + + const record = await ctx.createRecord(tableId, { [fieldId]: 'Todo' }); + await ctx.drainOutbox(); + + const beforeEventCount = ctx.testContainer.eventBus.events().length; + + const updatedTable = await ctx.updateField({ + tableId, + fieldId, + field: { + type: 'singleSelect', + options: { choices: [optionTodo, optionDone], defaultValue: 'Done' }, + }, + }); + await ctx.drainOutbox(); + + const updatedField = updatedTable.fields.find((f) => f.id === fieldId); + expect(getSelectOptions(updatedField).defaultValue).toBe('Done'); + + // No record recomputation should have happened + const newEvents = ctx.testContainer.eventBus.events().slice(beforeEventCount); + const newEventNames = newEvents + .map((event) => getDomainEventName(event)) + .filter((eventName): eventName is string => Boolean(eventName)); + expect(newEventNames).not.toContain('RecordUpdated'); + expect(newEventNames).not.toContain('RecordsBatchUpdated'); + + // Existing record and dependent formula values stay unchanged + const records = await ctx.listRecords(tableId); + const row = records.find((r) => r.id === record.id); + expect(row?.fields[fieldId]).toBe('Todo'); + expect(row?.fields[formulaFieldId]).toBe('Todo'); + + // Cleanup + await ctx.deleteField({ tableId, fieldId: formulaFieldId }); + await ctx.deleteField({ tableId, fieldId }); + await ctx.deleteRecords(tableId, [record.id]); + }); + test('should clear defaultValue T6107', async () => { const fieldId = createFieldId(); const optionA = { id: 'choA', name: 'A', color: 'blueBright' }; diff --git a/packages/v2/e2e/src/update-field/user/conversion/to-user.spec.ts b/packages/v2/e2e/src/update-field/user/conversion/to-user.spec.ts index d196f7e256..c2a3f1551f 100644 --- a/packages/v2/e2e/src/update-field/user/conversion/to-user.spec.ts +++ b/packages/v2/e2e/src/update-field/user/conversion/to-user.spec.ts @@ -163,7 +163,7 @@ describe('update-field: user → user conversion (isMultiple toggle)', () => { await ctx.deleteRecords(tableId, [r1.id]); }); - test('should keep empty array when disabling isMultiple', async () => { + test('should keep empty cell when disabling isMultiple', async () => { const fieldId = await createUserField('Empty Array User Field', true); const r1 = await ctx.createRecord(tableId, { [fieldId]: [], @@ -176,7 +176,8 @@ describe('update-field: user → user conversion (isMultiple toggle)', () => { }); const records = await ctx.listRecords(tableId); - expect(records.find((r) => r.id === r1.id)?.fields[fieldId]).toEqual([]); + // v1 contract: [] input is stored as null, so the cell stays empty + expect(records.find((r) => r.id === r1.id)?.fields[fieldId] == null).toBe(true); await ctx.deleteField({ tableId, fieldId }); await ctx.deleteRecords(tableId, [r1.id]); diff --git a/packages/v2/e2e/src/update-field/user/update-properties.spec.ts b/packages/v2/e2e/src/update-field/user/update-properties.spec.ts index 268bdeb0bb..4888c5c58e 100644 --- a/packages/v2/e2e/src/update-field/user/update-properties.spec.ts +++ b/packages/v2/e2e/src/update-field/user/update-properties.spec.ts @@ -209,7 +209,8 @@ describe('update-field: user property updates', () => { }); const records = await ctx.listRecordsWithoutDrain(tableId); - expect(records.find((r) => r.id === r1.id)?.fields[fieldId]).toEqual([]); + // v1 contract: [] input is stored as null, so the cell stays empty + expect(records.find((r) => r.id === r1.id)?.fields[fieldId] == null).toBe(true); await ctx.deleteField({ tableId, fieldId }); await ctx.deleteRecords(tableId, [r1.id]); diff --git a/packages/v2/e2e/src/updateRecord.e2e.spec.ts b/packages/v2/e2e/src/updateRecord.e2e.spec.ts index 7a402851e3..ba9981e621 100644 --- a/packages/v2/e2e/src/updateRecord.e2e.spec.ts +++ b/packages/v2/e2e/src/updateRecord.e2e.spec.ts @@ -250,6 +250,80 @@ describe('v2 http updateRecord (e2e)', () => { expect(updated?.fields[numberFieldId]).toBe(99); }); + it.each([false, true] as const)( + 'normalizes empty cell writes to null (typecast=%s)', + async (typecast) => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: `Empty Normalize ${typecast ? 'typecast' : 'strict'}`, + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { type: 'singleLineText', name: 'Text' }, + { type: 'longText', name: 'Notes' }, + { type: 'checkbox', name: 'Done' }, + { + type: 'multipleSelect', + name: 'Tags', + options: ['Red', 'Yellow'], + }, + { type: 'attachment', name: 'Files' }, + ], + views: [{ type: 'grid' }], + }); + + const titleFieldId = table.fields.find((f) => f.name === 'Title')?.id ?? ''; + const textEmptyFieldId = table.fields.find((f) => f.name === 'Text')?.id ?? ''; + const notesFieldId = table.fields.find((f) => f.name === 'Notes')?.id ?? ''; + const checkboxFieldId = table.fields.find((f) => f.name === 'Done')?.id ?? ''; + const tagsFieldId = table.fields.find((f) => f.name === 'Tags')?.id ?? ''; + const filesFieldId = table.fields.find((f) => f.name === 'Files')?.id ?? ''; + const tagNames = + ( + table.fields.find((f) => f.name === 'Tags')?.options as { + choices?: Array<{ name: string }>; + } + )?.choices?.map((choice) => choice.name) ?? []; + + const record = await ctx.createRecord(table.id, { + [titleFieldId]: 'baseline', + [textEmptyFieldId]: 'has text', + [notesFieldId]: 'has notes', + [checkboxFieldId]: true, + [tagsFieldId]: tagNames, + }); + + const response = await fetch(`${ctx.baseUrl}/tables/updateRecord`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tableId: table.id, + recordId: record.id, + typecast, + fields: { + [textEmptyFieldId]: '', + [notesFieldId]: '', + [checkboxFieldId]: false, + [tagsFieldId]: [], + [filesFieldId]: [], + }, + }), + }); + expect(response.status).toBe(200); + + // Assert via a separate read path, not the update response echo. + const records = await ctx.listRecords(table.id); + const updated = records.find((r) => r.id === record.id); + expect(updated).toBeDefined(); + if (!updated) return; + + expect(updated.fields[textEmptyFieldId] ?? null).toBeNull(); + expect(updated.fields[notesFieldId] ?? null).toBeNull(); + expect(updated.fields[checkboxFieldId] ?? null).toBeNull(); + expect(updated.fields[tagsFieldId] ?? null).toBeNull(); + expect(updated.fields[filesFieldId] ?? null).toBeNull(); + } + ); + it.each(typecastCases)('updates a record with typecast $name', async (testCase) => { const fieldId = testCase.fieldId(); const record = await ctx.createRecord(typecastTableId, { @@ -453,6 +527,130 @@ describe('v2 http updateRecord (e2e)', () => { expect(linkArray.some((link) => link.id === foreignRecord.id)).toBe(true); }); + /** + * v1 reference: link-api.e2e-spec typecast link writes (T6520 list) — + * unmatched titles are dropped instead of erroring or creating records. + */ + it('drops unmatched titles when updating link fields with typecast', async () => { + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Typecast Link Unmatched Foreign', + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const foreignTitleFieldId = foreignTable.fields.find((f) => f.isPrimary)?.id ?? ''; + const known = await ctx.createRecord(foreignTable.id, { + [foreignTitleFieldId]: 'Known Row', + }); + const foreignCountBefore = (await ctx.listRecords(foreignTable.id)).length; + + const linkFieldId = createFieldId(); + const mainTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Typecast Link Unmatched Main', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'link', + id: linkFieldId, + name: 'Related', + options: { + relationship: 'manyMany', + foreignTableId: foreignTable.id, + lookupFieldId: foreignTitleFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + const mainTitleFieldId = mainTable.fields.find((f) => f.isPrimary)?.id ?? ''; + const record = await ctx.createRecord(mainTable.id, { [mainTitleFieldId]: 'Main Row' }); + + const response = await fetch(`${ctx.baseUrl}/tables/updateRecord`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tableId: mainTable.id, + recordId: record.id, + typecast: true, + fields: { + [linkFieldId]: ['Known Row', 'No Such Row'], + }, + }), + }); + expect(response.status).toBe(200); + + await processOutbox(); + + const records = await ctx.listRecords(mainTable.id); + const updated = records.find((r) => r.id === record.id); + const linkValue = (updated?.fields[linkFieldId] ?? []) as Array<{ id: string }>; + expect(linkValue.map((link) => link.id)).toEqual([known.id]); + + // v1 contract: unmatched titles never create foreign records + const foreignCountAfter = (await ctx.listRecords(foreignTable.id)).length; + expect(foreignCountAfter).toBe(foreignCountBefore); + }); + + /** + * v1 reference: link-api.e2e-spec "should not insert illegal value in link cel" — + * without typecast, a plain string in a link cell must be rejected with 4xx. + */ + it('rejects illegal string values written to a link cell without typecast', async () => { + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Illegal Link Value Foreign', + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const foreignTitleFieldId = foreignTable.fields.find((f) => f.isPrimary)?.id ?? ''; + await ctx.createRecord(foreignTable.id, { [foreignTitleFieldId]: 'Real Target' }); + + const linkFieldId = createFieldId(); + const mainTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Illegal Link Value Main', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'link', + id: linkFieldId, + name: 'Related', + options: { + relationship: 'manyMany', + foreignTableId: foreignTable.id, + lookupFieldId: foreignTitleFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + const mainTitleFieldId = mainTable.fields.find((f) => f.isPrimary)?.id ?? ''; + const record = await ctx.createRecord(mainTable.id, { [mainTitleFieldId]: 'Main Row' }); + + const response = await fetch(`${ctx.baseUrl}/tables/updateRecord`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tableId: mainTable.id, + recordId: record.id, + fields: { + [linkFieldId]: ['NO'], + }, + }), + }); + expect(response.status).toBeGreaterThanOrEqual(400); + expect(response.status).toBeLessThan(500); + + // The link cell stays empty (null/absent), never partially written + await processOutbox(); + const records = await ctx.listRecords(mainTable.id); + const stored = records.find((r) => r.id === record.id); + expect(stored?.fields[linkFieldId] ?? undefined).toBeUndefined(); + }); + it('updates formula chains in a real-world table', async () => { const amountFieldId = createFieldId(); const scoreFieldId = createFieldId(); diff --git a/packages/v2/e2e/src/updateRecords.e2e.spec.ts b/packages/v2/e2e/src/updateRecords.e2e.spec.ts index 73617c3944..1e85db4477 100644 --- a/packages/v2/e2e/src/updateRecords.e2e.spec.ts +++ b/packages/v2/e2e/src/updateRecords.e2e.spec.ts @@ -408,4 +408,459 @@ describe('v2 http updateRecords (e2e)', () => { expect(statusByTitle.get('Alpha')).toBe('Open'); expect(statusByTitle.get('Beta')).toBe('Open'); }); + + /** + * v1 reference: record.e2e-spec.ts:1431 — omitted fields in sparse explicit + * batch updates must be preserved, not cleared or re-validated. + */ + it('preserves omitted singleSelect values in sparse explicit batch updates', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'UpdateRecords Sparse Select', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'singleSelect', + name: 'Status', + options: { + choices: [ + { id: 'optOpen', name: 'Open', color: 'blue' }, + { id: 'optClosed', name: 'Closed', color: 'red' }, + ], + preventAutoNewOptions: true, + }, + }, + { type: 'singleLineText', name: 'Notes' }, + ], + views: [{ type: 'grid' }], + }); + const titleFieldId = table.fields.find((f) => f.name === 'Title')?.id ?? ''; + const statusFieldId = table.fields.find((f) => f.name === 'Status')?.id ?? ''; + const notesFieldId = table.fields.find((f) => f.name === 'Notes')?.id ?? ''; + + const alpha = await ctx.createRecord(table.id, { + [titleFieldId]: 'Alpha', + [statusFieldId]: 'Open', + }); + const beta = await ctx.createRecord(table.id, { + [titleFieldId]: 'Beta', + [statusFieldId]: 'Open', + }); + + await ctx.updateRecords({ + tableId: table.id, + records: [ + { id: alpha.id, fields: { [notesFieldId]: 'Touched' } }, + { id: beta.id, fields: { [statusFieldId]: 'Closed' } }, + ], + }); + + const records = await ctx.listRecords(table.id); + const alphaAfter = records.find((r) => r.id === alpha.id); + const betaAfter = records.find((r) => r.id === beta.id); + expect(alphaAfter?.fields[statusFieldId]).toBe('Open'); + expect(alphaAfter?.fields[notesFieldId]).toBe('Touched'); + expect(betaAfter?.fields[statusFieldId]).toBe('Closed'); + }); + + /** + * v1 reference: record.e2e-spec.ts:244 — with preventAutoNewOptions, typecast + * drops unknown option values instead of creating new choices. + */ + it('drops unknown option values under typecast when preventAutoNewOptions is set', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'UpdateRecords Prevent Auto Options', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'singleSelect', + name: 'Single', + options: { + choices: [{ id: 'optRed', name: 'red', color: 'red' }], + preventAutoNewOptions: true, + }, + }, + { + type: 'multipleSelect', + name: 'Multi', + options: { + choices: [{ id: 'optRedM', name: 'red', color: 'red' }], + preventAutoNewOptions: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + const singleFieldId = table.fields.find((f) => f.name === 'Single')?.id ?? ''; + const multiFieldId = table.fields.find((f) => f.name === 'Multi')?.id ?? ''; + const titleFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + + const r1 = await ctx.createRecord(table.id, { [titleFieldId]: 'R1' }); + const r2 = await ctx.createRecord(table.id, { [titleFieldId]: 'R2' }); + + const updated = await ctx.updateRecords({ + tableId: table.id, + typecast: true, + records: [ + { id: r1.id, fields: { [singleFieldId]: 'red' } }, + { id: r2.id, fields: { [singleFieldId]: 'blue' } }, + ], + }); + const updatedById = new Map(updated.records.map((r) => [r.id, r])); + expect(updatedById.get(r1.id)?.fields[singleFieldId]).toBe('red'); + // v1 contract: unknown option is dropped, no new option is created + expect(updatedById.get(r2.id)?.fields[singleFieldId] == null).toBe(true); + + const updatedMulti = await ctx.updateRecords({ + tableId: table.id, + typecast: true, + records: [{ id: r1.id, fields: { [multiFieldId]: ['red', 'blue'] } }], + }); + expect(updatedMulti.records[0]?.fields[multiFieldId]).toEqual(['red']); + + const refreshed = await ctx.getTableById(table.id); + const choiceNames = (fieldId: string) => + ( + (refreshed.fields.find((f) => f.id === fieldId)?.options as { + choices?: Array<{ name: string }>; + }) ?? {} + ).choices?.map((c) => c.name) ?? []; + expect(choiceNames(singleFieldId)).toEqual(['red']); + expect(choiceNames(multiFieldId)).toEqual(['red']); + }); + + /** + * v1 reference: record.e2e-spec.ts:1225 — duplicate updates targeting the + * same record in one batch are merged so the latest value wins. + */ + it('merges duplicate basic field updates to the latest', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'UpdateRecords Duplicate Basic', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { type: 'singleLineText', name: 'Text' }, + ], + views: [{ type: 'grid' }], + }); + const titleFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + const textFieldId = table.fields.find((f) => f.name === 'Text')?.id ?? ''; + + const record = await ctx.createRecord(table.id, { [titleFieldId]: 'Dup' }); + + await ctx.updateRecords({ + tableId: table.id, + records: [ + { id: record.id, fields: { [textFieldId]: 'v1' } }, + { id: record.id, fields: { [textFieldId]: 'v2' } }, + ], + }); + + const records = await ctx.listRecords(table.id); + expect(records.find((r) => r.id === record.id)?.fields[textFieldId]).toBe('v2'); + }); + + /** + * v1 reference: record.e2e-spec.ts:1242 — duplicate link updates (manyOne) + * for the same record are merged so the last link target wins. + */ + it('merges duplicate link updates (manyOne) so the last wins', async () => { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'UpdateRecords Duplicate Link Foreign', + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const foreignNameFieldId = foreign.fields.find((f) => f.isPrimary)?.id ?? ''; + const targetA = await ctx.createRecord(foreign.id, { [foreignNameFieldId]: 'A' }); + const targetB = await ctx.createRecord(foreign.id, { [foreignNameFieldId]: 'B' }); + + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'UpdateRecords Duplicate Link Main', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'link', + name: 'Link', + options: { + relationship: 'manyOne', + foreignTableId: foreign.id, + lookupFieldId: foreignNameFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + const titleFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + const linkFieldId = table.fields.find((f) => f.name === 'Link')?.id ?? ''; + + const record = await ctx.createRecord(table.id, { [titleFieldId]: 'Main' }); + + await ctx.updateRecords({ + tableId: table.id, + records: [ + { id: record.id, fields: { [linkFieldId]: { id: targetA.id } } }, + { id: record.id, fields: { [linkFieldId]: { id: targetB.id } } }, + ], + }); + + const records = await ctx.listRecords(table.id); + expect(records.find((r) => r.id === record.id)?.fields[linkFieldId]).toMatchObject({ + id: targetB.id, + }); + }); + + /** + * v1 reference: record.e2e-spec.ts:1268 — after merging duplicate updates, + * dependent formulas compute from the latest value. + */ + it('merges duplicate updates with formula: computed value reflects the latest', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'UpdateRecords Duplicate Formula', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { type: 'singleLineText', name: 'Text' }, + ], + views: [{ type: 'grid' }], + }); + const titleFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + const textFieldId = table.fields.find((f) => f.name === 'Text')?.id ?? ''; + + const withFormula = await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { type: 'formula', name: 'Echo', options: { expression: `{${textFieldId}}` } }, + }); + const formulaFieldId = withFormula.fields.find((f) => f.name === 'Echo')?.id ?? ''; + + const record = await ctx.createRecord(table.id, { [titleFieldId]: 'Dup Formula' }); + + await ctx.updateRecords({ + tableId: table.id, + records: [ + { id: record.id, fields: { [textFieldId]: 'first' } }, + { id: record.id, fields: { [textFieldId]: 'second' } }, + ], + }); + + const records = await ctx.listRecords(table.id); + expect(records.find((r) => r.id === record.id)?.fields[formulaFieldId]).toBe('second'); + }); + + /** + * v1 reference: record.e2e-spec.ts:1289 — after merging duplicate link + * updates, lookups reflect the latest link target. + */ + it('merges duplicate updates with lookup: value reflects the latest link target', async () => { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'UpdateRecords Duplicate Lookup Foreign', + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + views: [{ type: 'grid' }], + }); + const foreignNameFieldId = foreign.fields.find((f) => f.isPrimary)?.id ?? ''; + const targetA = await ctx.createRecord(foreign.id, { [foreignNameFieldId]: 'A' }); + const targetB = await ctx.createRecord(foreign.id, { [foreignNameFieldId]: 'B' }); + + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'UpdateRecords Duplicate Lookup Main', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'link', + name: 'Link', + options: { + relationship: 'manyOne', + foreignTableId: foreign.id, + lookupFieldId: foreignNameFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + const titleFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + const linkFieldId = table.fields.find((f) => f.name === 'Link')?.id ?? ''; + + const withLookup = await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { + type: 'lookup', + name: 'Name Lookup', + options: { + linkFieldId, + foreignTableId: foreign.id, + lookupFieldId: foreignNameFieldId, + }, + }, + }); + const lookupFieldId = withLookup.fields.find((f) => f.name === 'Name Lookup')?.id ?? ''; + + const record = await ctx.createRecord(table.id, { [titleFieldId]: 'Main' }); + + await ctx.updateRecords({ + tableId: table.id, + records: [ + { id: record.id, fields: { [linkFieldId]: { id: targetA.id } } }, + { id: record.id, fields: { [linkFieldId]: { id: targetB.id } } }, + ], + }); + + const records = await ctx.listRecords(table.id); + const lookupValue = records.find((r) => r.id === record.id)?.fields[lookupFieldId]; + // assert merge semantics without pinning the single/array lookup shape + expect(Array.isArray(lookupValue) ? lookupValue : [lookupValue]).toEqual(['B']); + }); + + /** + * v1 reference: record.e2e-spec.ts:1334 — after merging duplicate link-set + * updates, rollups aggregate over the latest link set only. + */ + it('merges duplicate updates with rollup: sum reflects the latest link set', async () => { + const foreign = await ctx.createTable({ + baseId: ctx.baseId, + name: 'UpdateRecords Duplicate Rollup Foreign', + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'number', name: 'Value' }, + ], + views: [{ type: 'grid' }], + }); + const foreignNameFieldId = foreign.fields.find((f) => f.isPrimary)?.id ?? ''; + const foreignValueFieldId = foreign.fields.find((f) => f.name === 'Value')?.id ?? ''; + const targetA = await ctx.createRecord(foreign.id, { + [foreignNameFieldId]: 'A', + [foreignValueFieldId]: 10, + }); + const targetB = await ctx.createRecord(foreign.id, { + [foreignNameFieldId]: 'B', + [foreignValueFieldId]: 7, + }); + const targetC = await ctx.createRecord(foreign.id, { + [foreignNameFieldId]: 'C', + [foreignValueFieldId]: 5, + }); + + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'UpdateRecords Duplicate Rollup Main', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'link', + name: 'Links', + options: { + relationship: 'manyMany', + foreignTableId: foreign.id, + lookupFieldId: foreignNameFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid' }], + }); + const titleFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + const linkFieldId = table.fields.find((f) => f.name === 'Links')?.id ?? ''; + + const withRollup = await ctx.createField({ + baseId: ctx.baseId, + tableId: table.id, + field: { + type: 'rollup', + name: 'Sum', + options: { expression: 'sum({values})' }, + config: { + linkFieldId, + foreignTableId: foreign.id, + lookupFieldId: foreignValueFieldId, + }, + }, + }); + const rollupFieldId = withRollup.fields.find((f) => f.name === 'Sum')?.id ?? ''; + + const record = await ctx.createRecord(table.id, { [titleFieldId]: 'Main' }); + + await ctx.updateRecords({ + tableId: table.id, + records: [ + { + id: record.id, + fields: { [linkFieldId]: [{ id: targetA.id }, { id: targetB.id }] }, + }, + { + id: record.id, + fields: { [linkFieldId]: [{ id: targetC.id }] }, + }, + ], + }); + + const records = await ctx.listRecords(table.id); + expect(records.find((r) => r.id === record.id)?.fields[rollupFieldId]).toBe(5); + }); + + /** + * v1 reference: record.e2e-spec.ts:1489 — a required (notNull) singleSelect + * must not fail validation for batch rows that omit the field. + */ + it('does not fail required singleSelect validation when omitted in another batch row', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'UpdateRecords Required Select Sparse', + fields: [ + { type: 'singleLineText', name: 'Title', isPrimary: true }, + { + type: 'singleSelect', + name: 'Status', + options: { + choices: [ + { id: 'optReqOpen', name: 'Open', color: 'blue' }, + { id: 'optReqClosed', name: 'Closed', color: 'red' }, + ], + preventAutoNewOptions: true, + }, + }, + { type: 'singleLineText', name: 'Notes' }, + ], + views: [{ type: 'grid' }], + }); + const titleFieldId = table.fields.find((f) => f.isPrimary)?.id ?? ''; + const statusFieldId = table.fields.find((f) => f.name === 'Status')?.id ?? ''; + const notesFieldId = table.fields.find((f) => f.name === 'Notes')?.id ?? ''; + + const alpha = await ctx.createRecord(table.id, { + [titleFieldId]: 'Alpha', + [statusFieldId]: 'Open', + }); + const beta = await ctx.createRecord(table.id, { + [titleFieldId]: 'Beta', + [statusFieldId]: 'Open', + }); + + await ctx.updateField({ + tableId: table.id, + fieldId: statusFieldId, + field: { notNull: true }, + }); + + await ctx.updateRecords({ + tableId: table.id, + records: [ + { id: alpha.id, fields: { [statusFieldId]: 'Closed' } }, + { id: beta.id, fields: { [notesFieldId]: 'Still open' } }, + ], + }); + + const records = await ctx.listRecords(table.id); + const alphaAfter = records.find((r) => r.id === alpha.id); + const betaAfter = records.find((r) => r.id === beta.id); + expect(alphaAfter?.fields[statusFieldId]).toBe('Closed'); + expect(betaAfter?.fields[statusFieldId]).toBe('Open'); + expect(betaAfter?.fields[notesFieldId]).toBe('Still open'); + }); }); diff --git a/packages/v2/e2e/src/viewOperations.e2e.spec.ts b/packages/v2/e2e/src/viewOperations.e2e.spec.ts new file mode 100644 index 0000000000..a7db8f2490 --- /dev/null +++ b/packages/v2/e2e/src/viewOperations.e2e.spec.ts @@ -0,0 +1,965 @@ +import { createV2HttpClient } from '@teable/v2-contract-http-client'; +import { sql } from 'kysely'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + getSharedTestContext, + TEST_USER, + type SharedTestContext, +} from './shared/globalTestContext'; + +describe('v2 http View operation contracts (e2e)', () => { + let ctx: SharedTestContext; + let client: ReturnType; + let tableId: string; + let foreignTableId: string; + let viewId: string; + let seedViewId: string; + let foreignViewId: string; + let primaryFieldId: string; + let statusFieldId: string; + let pluginViewId: string | undefined; + let pluginInstallId: string | undefined; + + const pluginId = `plg${'c'.repeat(16)}`; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + client = createV2HttpClient({ baseUrl: ctx.baseUrl }); + + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'View Operations Contract', + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { + type: 'singleSelect', + name: 'Status', + options: { + choices: [ + { id: 'choTodo', name: 'Todo', color: 'blue' }, + { id: 'choDone', name: 'Done', color: 'green' }, + ], + }, + }, + ], + views: [ + { type: 'grid', name: 'Seed' }, + { type: 'grid', name: 'Working' }, + ], + }); + tableId = table.id; + seedViewId = table.views[0]?.id ?? ''; + viewId = table.views[1]?.id ?? ''; + primaryFieldId = table.fields.find((field) => field.isPrimary)?.id ?? ''; + statusFieldId = table.fields.find((field) => field.name === 'Status')?.id ?? ''; + if (!seedViewId || !viewId || !primaryFieldId || !statusFieldId) { + throw new Error('View operation contract fixture is incomplete'); + } + + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Foreign View Operations Contract', + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + views: [{ type: 'grid', name: 'Foreign' }], + }); + foreignTableId = foreignTable.id; + foreignViewId = foreignTable.views[0]?.id ?? ''; + if (!foreignViewId) throw new Error('Foreign View fixture is incomplete'); + + await ctx.testContainer.db.schema + .createTable('plugin') + .ifNotExists() + .addColumn('id', 'varchar', (column) => column.primaryKey()) + .addColumn('name', 'varchar', (column) => column.notNull()) + .addColumn('logo', 'varchar', (column) => column.notNull()) + .addColumn('url', 'varchar') + .addColumn('status', 'varchar', (column) => column.notNull()) + .addColumn('positions', 'text', (column) => column.notNull()) + .addColumn('created_by', 'varchar', (column) => column.notNull()) + .execute(); + await ctx.testContainer.db.schema + .createTable('plugin_install') + .ifNotExists() + .addColumn('id', 'varchar', (column) => column.primaryKey()) + .addColumn('plugin_id', 'varchar', (column) => column.notNull()) + .addColumn('base_id', 'varchar', (column) => column.notNull()) + .addColumn('name', 'varchar', (column) => column.notNull()) + .addColumn('position_id', 'varchar', (column) => column.notNull()) + .addColumn('position', 'varchar', (column) => column.notNull()) + .addColumn('storage', 'text') + .addColumn('created_time', 'timestamptz', (column) => + column.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) + ) + .addColumn('created_by', 'varchar', (column) => column.notNull()) + .addColumn('last_modified_time', 'timestamptz') + .addColumn('last_modified_by', 'varchar') + .execute(); + + await ctx.testContainer.db + .insertInto('plugin') + .values({ + id: pluginId, + name: 'Contract Plugin', + logo: 'contract-plugin.svg', + url: 'https://example.test/plugin', + status: 'published', + positions: JSON.stringify(['view']), + created_by: TEST_USER.id, + }) + .execute(); + }); + + afterAll(async () => { + if (ctx && tableId) + await ctx.deleteTable(tableId, { mode: 'permanent' }).catch(() => undefined); + if (ctx && foreignTableId) { + await ctx.deleteTable(foreignTableId, { mode: 'permanent' }).catch(() => undefined); + } + if (ctx) { + await ctx.testContainer.db + .deleteFrom('plugin_install') + .where('plugin_id', '=', pluginId) + .execute() + .catch(() => undefined); + await ctx.testContainer.db + .deleteFrom('plugin') + .where('id', '=', pluginId) + .execute() + .catch(() => undefined); + } + }); + + it('exposes filter-link records, snapshots, and document IDs through aggregate reads', async () => { + const links = await client.tables.getViewFilterLinkRecords({ tableId, viewId }); + expect(links).toEqual({ ok: true, data: { groups: [] } }); + + const snapshots = await client.tables.getViewSnapshots({ + tableId, + viewIds: [viewId, seedViewId], + }); + expect(snapshots.ok).toBe(true); + if (!snapshots.ok) return; + expect(snapshots.data.snapshots.map((snapshot) => snapshot.id)).toEqual([viewId, seedViewId]); + expect(snapshots.data.snapshots[0]).toMatchObject({ + id: viewId, + type: 'json0', + data: { id: viewId, name: 'Working' }, + }); + + const docIds = await client.tables.listViewDocIds({ tableId }); + expect(docIds).toEqual({ + ok: true, + data: { ids: [seedViewId, viewId] }, + }); + }); + + it('runs every ordinary View lifecycle mutation through Table aggregate commands', async () => { + const renamed = await client.tables.renameView({ + tableId, + viewId, + name: 'Planning', + }); + expect(renamed.ok).toBe(true); + + const described = await client.tables.updateViewDescription({ + tableId, + viewId, + description: 'Planning details', + }); + expect(described.ok).toBe(true); + + const locked = await client.tables.updateViewLocked({ + tableId, + viewId, + isLocked: true, + }); + expect(locked.ok).toBe(true); + + const ordered = await client.tables.updateViewOrder({ + tableId, + viewId, + anchorId: seedViewId, + position: 'before', + }); + expect(ordered.ok).toBe(true); + + const columnMeta = await client.tables.updateViewColumnMeta({ + tableId, + viewId, + columnMeta: [{ fieldId: primaryFieldId, columnMeta: { width: 280 } }], + }); + expect(columnMeta.ok).toBe(true); + + const filtered = await client.tables.updateViewFilter({ + tableId, + viewId, + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: primaryFieldId, + operator: 'LIKE', + isSymbol: true, + value: 'alpha', + }, + ], + }, + }); + expect(filtered.ok).toBe(true); + + const sorted = await client.tables.updateViewSort({ + tableId, + viewId, + sort: { + sortObjs: [{ fieldId: primaryFieldId, order: 'asc' }], + manualSort: false, + }, + }); + expect(sorted.ok).toBe(true); + + const grouped = await client.tables.updateViewGroup({ + tableId, + viewId, + group: [{ fieldId: statusFieldId, order: 'desc' }], + }); + expect(grouped.ok).toBe(true); + + const options = await client.tables.updateViewOptions({ + tableId, + viewId, + options: { rowHeight: 'medium' }, + }); + expect(options.ok).toBe(true); + + const manualSort = await client.tables.applyViewManualSort({ + tableId, + viewId, + sort: [], + }); + expect(manualSort).toMatchObject({ + ok: true, + data: { viewId, updatedRecordCount: 0 }, + }); + + const current = await client.tables.getView({ tableId, viewId }); + expect(current).toMatchObject({ + ok: true, + data: { + view: { + id: viewId, + name: 'Planning', + description: 'Planning details', + isLocked: true, + options: { rowHeight: 'medium' }, + columnMeta: { [primaryFieldId]: { width: 280 } }, + }, + }, + }); + }); + + it('keeps share credentials replay-safe and out of non-credential responses', async () => { + const enabled = await client.tables.enableViewShare({ tableId, viewId }); + expect(enabled.ok).toBe(true); + if (!enabled.ok) return; + expect(enabled.data.shareId).toMatch(/^shr/); + const firstShareId = enabled.data.shareId; + + const metadata = await client.tables.updateViewShareMeta({ + tableId, + viewId, + shareMeta: { allowCopy: false, password: 'secret' }, + }); + expect(metadata).toEqual({ ok: true, data: { viewId } }); + expect(JSON.stringify(metadata)).not.toContain('secret'); + expect(JSON.stringify(metadata)).not.toContain(firstShareId); + + const refreshed = await client.tables.refreshViewShareId({ tableId, viewId }); + expect(refreshed.ok).toBe(true); + if (!refreshed.ok) return; + expect(refreshed.data.shareId).toMatch(/^shr/); + expect(refreshed.data.shareId).not.toBe(firstShareId); + expect(JSON.stringify(refreshed)).not.toContain(firstShareId); + + const disabled = await client.tables.disableViewShare({ tableId, viewId }); + expect(disabled).toEqual({ ok: true, data: { viewId } }); + expect(JSON.stringify(disabled)).not.toContain(refreshed.data.shareId); + }); + + it('duplicates and deletes View children without bypassing the aggregate', async () => { + const duplicated = await client.tables.duplicateView({ tableId, viewId }); + expect(duplicated.ok).toBe(true); + if (!duplicated.ok) return; + const duplicateViewId = duplicated.data.viewId; + expect(duplicateViewId).not.toBe(viewId); + expect(duplicated.data.table.views).toEqual( + expect.arrayContaining([expect.objectContaining({ id: duplicateViewId, name: 'Planning 2' })]) + ); + + const deleted = await client.tables.deleteView({ + tableId, + viewId: duplicateViewId, + }); + expect(deleted.ok).toBe(true); + if (!deleted.ok) return; + expect(deleted.data.table.views.some((view) => view.id === duplicateViewId)).toBe(false); + }); + + it('installs, reads, and updates a Plugin View through native contracts', async () => { + const installed = await client.tables.installViewPlugin({ + tableId, + pluginId, + name: 'Contract Plugin View', + }); + expect(installed.ok).toBe(true); + if (!installed.ok) return; + pluginViewId = installed.data.viewId; + pluginInstallId = installed.data.pluginInstallId; + expect(installed.data).toMatchObject({ + pluginId, + name: 'Contract Plugin View', + }); + + const metadata = await client.tables.getViewPluginInstall({ + tableId, + viewId: pluginViewId, + }); + expect(metadata).toMatchObject({ + ok: true, + data: { + pluginId, + pluginInstallId, + baseId: ctx.baseId, + name: 'Contract Plugin View', + url: 'https://example.test/plugin', + }, + }); + + const updated = await client.tables.updateViewPluginStorage({ + tableId, + viewId: pluginViewId, + pluginInstallId, + storage: { nested: { enabled: true }, count: 2 }, + }); + expect(updated).toEqual({ + ok: true, + data: { + tableId, + viewId: pluginViewId, + pluginInstallId, + storage: { nested: { enabled: true }, count: 2 }, + }, + }); + + const reread = await client.tables.getViewPluginInstall({ + tableId, + viewId: pluginViewId, + }); + expect(reread).toMatchObject({ + ok: true, + data: { storage: { nested: { enabled: true }, count: 2 } }, + }); + }); + + it('rejects malformed and cross-Table child identifiers at the interface boundary', async () => { + const malformed = await fetch(`${ctx.baseUrl}/tables/renameView`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tableId: 'invalid', viewId: 'invalid', name: 'Nope' }), + }); + expect(malformed.status).toBe(400); + + await expect( + client.tables.renameView({ + tableId, + viewId: foreignViewId, + name: 'Cross aggregate', + }) + ).rejects.toMatchObject({ status: 404 }); + + await expect( + client.tables.getViewSnapshots({ + tableId, + viewIds: [foreignViewId], + }) + ).rejects.toMatchObject({ status: 404 }); + }); +}); + +describe('v2 http View v1-parity coverage (e2e)', () => { + let ctx: SharedTestContext; + let client: ReturnType; + let tableId: string; + let nameFieldId: string; + let statusFieldId: string; + let notesFieldId: string; + let formViewId: string; + let recordIds: string[]; + + const getViewOrThrow = async (targetTableId: string, viewId: string) => { + const result = await client.tables.getView({ tableId: targetTableId, viewId }); + if (!result.ok) throw new Error(result.error.message); + return result.data.view; + }; + + const createGridView = async (name: string) => { + const created = await client.tables.createView({ + tableId, + view: { type: 'grid', name }, + }); + if (!created.ok) throw new Error(created.error.message); + return created.data.viewId; + }; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + client = createV2HttpClient({ baseUrl: ctx.baseUrl }); + + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'View V1 Parity', + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { + type: 'singleSelect', + name: 'Status', + options: { + choices: [ + { id: 'choTodo', name: 'Todo', color: 'blue' }, + { id: 'choDone', name: 'Done', color: 'green' }, + ], + }, + }, + { type: 'singleLineText', name: 'Notes' }, + ], + views: [ + { type: 'grid', name: 'Parity Grid' }, + { type: 'form', name: 'Parity Form' }, + ], + }); + tableId = table.id; + nameFieldId = table.fields.find((field) => field.isPrimary)?.id ?? ''; + statusFieldId = table.fields.find((field) => field.name === 'Status')?.id ?? ''; + notesFieldId = table.fields.find((field) => field.name === 'Notes')?.id ?? ''; + formViewId = table.views.find((view) => view.type === 'form')?.id ?? ''; + if (!nameFieldId || !statusFieldId || !notesFieldId || !formViewId) { + throw new Error('View parity fixture is incomplete'); + } + + const created = await ctx.createRecords(tableId, [ + { fields: { [nameFieldId]: 'Beta' } }, + { fields: { [nameFieldId]: 'Alpha' } }, + { fields: { [nameFieldId]: 'Beta' } }, + ]); + recordIds = created.map((record) => record.id); + }); + + afterAll(async () => { + if (ctx && tableId) + await ctx.deleteTable(tableId, { mode: 'permanent' }).catch(() => undefined); + }); + + it('round-trips filter, sort, and group through set and null clear', async () => { + const viewId = await createGridView('Query defaults roundtrip'); + + const filter = { + conjunction: 'and' as const, + filterSet: [{ fieldId: nameFieldId, operator: 'is' as const, value: 'Alpha' }], + }; + const filtered = await client.tables.updateViewFilter({ tableId, viewId, filter }); + expect(filtered.ok).toBe(true); + const sorted = await client.tables.updateViewSort({ + tableId, + viewId, + sort: { sortObjs: [{ fieldId: nameFieldId, order: 'asc' }], manualSort: false }, + }); + expect(sorted.ok).toBe(true); + const grouped = await client.tables.updateViewGroup({ + tableId, + viewId, + group: [{ fieldId: statusFieldId, order: 'desc' }], + }); + expect(grouped.ok).toBe(true); + + const populated = await getViewOrThrow(tableId, viewId); + expect(populated.filter).toEqual(filter); + expect(populated.sort).toEqual({ + sortObjs: [{ fieldId: nameFieldId, order: 'asc' }], + manualSort: false, + }); + expect(populated.group).toEqual([{ fieldId: statusFieldId, order: 'desc' }]); + + const filterCleared = await client.tables.updateViewFilter({ tableId, viewId, filter: null }); + expect(filterCleared.ok).toBe(true); + const sortCleared = await client.tables.updateViewSort({ tableId, viewId, sort: null }); + expect(sortCleared.ok).toBe(true); + const groupCleared = await client.tables.updateViewGroup({ tableId, viewId, group: null }); + expect(groupCleared.ok).toBe(true); + + const cleared = await getViewOrThrow(tableId, viewId); + expect(cleared.filter ?? null).toBeNull(); + expect(cleared.sort ?? null).toBeNull(); + expect(cleared.group ?? null).toBeNull(); + }); + + it('merges order, hidden, width, and statisticFunc column meta without covering prior patches', async () => { + const viewId = await createGridView('Column meta merge'); + + // v1 set-column-meta: sequential single-property patches must merge. + const orderPatch = await client.tables.updateViewColumnMeta({ + tableId, + viewId, + columnMeta: [{ fieldId: statusFieldId, columnMeta: { order: 10 } }], + }); + expect(orderPatch.ok).toBe(true); + const hiddenPatch = await client.tables.updateViewColumnMeta({ + tableId, + viewId, + columnMeta: [{ fieldId: statusFieldId, columnMeta: { hidden: true } }], + }); + expect(hiddenPatch.ok).toBe(true); + const widthPatch = await client.tables.updateViewColumnMeta({ + tableId, + viewId, + columnMeta: [{ fieldId: statusFieldId, columnMeta: { width: 200 } }], + }); + expect(widthPatch.ok).toBe(true); + + // v1 set-column-meta: one multi-property patch lands atomically. + const multiPatch = await client.tables.updateViewColumnMeta({ + tableId, + viewId, + columnMeta: [ + { + fieldId: notesFieldId, + columnMeta: { width: 200, statisticFunc: 'empty', hidden: true, order: 100 }, + }, + ], + }); + expect(multiPatch.ok).toBe(true); + + const view = await getViewOrThrow(tableId, viewId); + expect(view.columnMeta[statusFieldId]).toMatchObject({ + order: 10, + hidden: true, + width: 200, + }); + expect(view.columnMeta[notesFieldId]).toMatchObject({ + width: 200, + statisticFunc: 'empty', + hidden: true, + order: 100, + }); + }); + + it('rejects hiding the primary field, unknown fields, and malformed field ids', async () => { + const viewId = await createGridView('Column meta guards'); + + await expect( + client.tables.updateViewColumnMeta({ + tableId, + viewId, + columnMeta: [{ fieldId: nameFieldId, columnMeta: { hidden: true } }], + }) + ).rejects.toMatchObject({ status: 400 }); + + await expect( + client.tables.updateViewColumnMeta({ + tableId, + viewId, + columnMeta: [{ fieldId: `fld${'z'.repeat(16)}`, columnMeta: { width: 200 } }], + }) + ).rejects.toMatchObject({ status: 404 }); + + await expect( + client.tables.updateViewColumnMeta({ + tableId, + viewId, + columnMeta: [{ fieldId: 'fakeFieldID', columnMeta: { width: 200 } }], + }) + ).rejects.toMatchObject({ status: 400 }); + }); + + it('updates required and visible column meta on a form view', async () => { + const patched = await client.tables.updateViewColumnMeta({ + tableId, + viewId: formViewId, + columnMeta: [{ fieldId: statusFieldId, columnMeta: { required: true, visible: true } }], + }); + expect(patched.ok).toBe(true); + + const view = await getViewOrThrow(tableId, formViewId); + expect(view.columnMeta[statusFieldId]).toMatchObject({ required: true, visible: true }); + }); + + it('shifts the frozen boundary to the previous neighbor when the frozen column moves', async () => { + const viewId = await createGridView('Frozen boundary shift'); + + const frozen = await client.tables.updateViewOptions({ + tableId, + viewId, + options: { frozenFieldId: statusFieldId }, + }); + expect(frozen.ok).toBe(true); + const before = await getViewOrThrow(tableId, viewId); + expect((before.options as { frozenFieldId?: string }).frozenFieldId).toBe(statusFieldId); + + // Move the frozen column (index 1 of [Name, Status, Notes]) to the end. + const moved = await client.tables.updateViewColumnMeta({ + tableId, + viewId, + columnMeta: [{ fieldId: statusFieldId, columnMeta: { order: 9999 } }], + }); + expect(moved.ok).toBe(true); + + const after = await getViewOrThrow(tableId, viewId); + expect((after.options as { frozenFieldId?: string }).frozenFieldId).toBe(nameFieldId); + }); + + it('rejects options belonging to another view subtype without persistence', async () => { + await expect( + client.tables.updateViewOptions({ + tableId, + viewId: formViewId, + options: { rowHeight: 'short' }, + }) + ).rejects.toMatchObject({ status: 400 }); + + const submit = await client.tables.updateViewOptions({ + tableId, + viewId: formViewId, + options: { submitLabel: 'Confirm' }, + }); + expect(submit.ok).toBe(true); + const view = await getViewOrThrow(tableId, formViewId); + expect((view.options as { submitLabel?: string }).submitLabel).toBe('Confirm'); + expect((view.options as { rowHeight?: string }).rowHeight).toBeUndefined(); + }); + + // v1 parity (view-option.e2e-spec, T6520): deleting the field carrying the + // frozen boundary moves options.frozenFieldId to the previous visible column. + it('shifts the frozen boundary when the frozen field itself is deleted', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Frozen Delete Parity', + fields: [ + { type: 'singleLineText', name: 'First', isPrimary: true }, + { type: 'singleLineText', name: 'Middle' }, + { type: 'singleLineText', name: 'Last' }, + ], + views: [{ type: 'grid', name: 'Frozen' }], + }); + try { + const viewId = table.views[0]?.id ?? ''; + const firstFieldId = table.fields.find((field) => field.name === 'First')?.id ?? ''; + const middleFieldId = table.fields.find((field) => field.name === 'Middle')?.id ?? ''; + const frozen = await client.tables.updateViewOptions({ + tableId: table.id, + viewId, + options: { frozenFieldId: middleFieldId }, + }); + expect(frozen.ok).toBe(true); + + await ctx.deleteField({ tableId: table.id, fieldId: middleFieldId }); + + const result = await client.tables.getView({ tableId: table.id, viewId }); + if (!result.ok) throw new Error(result.error.message); + expect((result.data.view.options as { frozenFieldId?: string }).frozenFieldId).toBe( + firstFieldId + ); + } finally { + await ctx.deleteTable(table.id, { mode: 'permanent' }).catch(() => undefined); + } + }); + + it('clears the frozen boundary when the first frozen column is deleted', async () => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Frozen Delete First Column', + fields: [ + { type: 'singleLineText', name: 'First', isPrimary: true }, + { type: 'singleLineText', name: 'Second' }, + ], + views: [{ type: 'grid', name: 'Frozen' }], + }); + try { + const viewId = table.views[0]?.id ?? ''; + const secondFieldId = table.fields.find((field) => field.name === 'Second')?.id ?? ''; + // Move the field to the front first, then freeze it: deleting it leaves + // no previous column, so the boundary must clear entirely. + const reorder = await client.tables.updateViewColumnMeta({ + tableId: table.id, + viewId, + columnMeta: [{ fieldId: secondFieldId, columnMeta: { order: -1 } }], + }); + expect(reorder.ok).toBe(true); + const frozen = await client.tables.updateViewOptions({ + tableId: table.id, + viewId, + options: { frozenFieldId: secondFieldId }, + }); + expect(frozen.ok).toBe(true); + + await ctx.deleteField({ tableId: table.id, fieldId: secondFieldId }); + + const result = await client.tables.getView({ tableId: table.id, viewId }); + if (!result.ok) throw new Error(result.error.message); + expect( + (result.data.view.options as { frozenFieldId?: string }).frozenFieldId + ).toBeUndefined(); + } finally { + await ctx.deleteTable(table.id, { mode: 'permanent' }).catch(() => undefined); + } + }); + + it('materializes multi-row manual sort with stable ties and flags manualSort', async () => { + const viewId = await createGridView('Manual sort parity'); + + const applied = await client.tables.applyViewManualSort({ + tableId, + viewId, + sort: [{ fieldId: nameFieldId, order: 'desc' }], + }); + expect(applied).toMatchObject({ + ok: true, + data: { viewId }, + }); + + const view = await getViewOrThrow(tableId, viewId); + expect(view.sort).toEqual({ + sortObjs: [{ fieldId: nameFieldId, order: 'desc' }], + manualSort: true, + }); + + const ordered = await ctx.listRecords(tableId, { viewId }); + expect(ordered.map((record) => record.id)).toEqual([recordIds[0], recordIds[2], recordIds[1]]); + }); + + it('rejects manual sort on a non-Grid view', async () => { + const gallery = await client.tables.createView({ + tableId, + view: { type: 'gallery', name: 'Manual sort gallery' }, + }); + if (!gallery.ok) throw new Error(gallery.error.message); + + await expect( + client.tables.applyViewManualSort({ + tableId, + viewId: gallery.data.viewId, + sort: [], + }) + ).rejects.toMatchObject({ status: 400 }); + }); + + it('guards the share lifecycle against disabled refresh and repeated transitions', async () => { + const viewId = await createGridView('Share lifecycle guards'); + + await expect(client.tables.refreshViewShareId({ tableId, viewId })).rejects.toMatchObject({ + status: 400, + }); + + const enabled = await client.tables.enableViewShare({ tableId, viewId }); + expect(enabled.ok).toBe(true); + await expect(client.tables.enableViewShare({ tableId, viewId })).rejects.toMatchObject({ + status: 400, + }); + + const disabled = await client.tables.disableViewShare({ tableId, viewId }); + expect(disabled.ok).toBe(true); + await expect(client.tables.disableViewShare({ tableId, viewId })).rejects.toMatchObject({ + status: 400, + }); + }); + + it('toggles the locked flag on and off through the aggregate', async () => { + const viewId = await createGridView('Locked toggle'); + + const locked = await client.tables.updateViewLocked({ tableId, viewId, isLocked: true }); + expect(locked.ok).toBe(true); + expect((await getViewOrThrow(tableId, viewId)).isLocked).toBe(true); + + const unlocked = await client.tables.updateViewLocked({ tableId, viewId, isLocked: false }); + expect(unlocked.ok).toBe(true); + expect((await getViewOrThrow(tableId, viewId)).isLocked ?? false).toBe(false); + }); +}); + +describe('v2 http View filter-link-records v1 parity (e2e)', () => { + let ctx: SharedTestContext; + let client: ReturnType; + let hostTableId: string; + let linkTable1Id: string; + let linkTable2Id: string; + let plainFieldId: string; + let linkField1Id: string; + let linkField2Id: string; + let linkTable1RecordIds: string[]; + let linkTable2RecordIds: string[]; + let linkTable1ViewId: string; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + client = createV2HttpClient({ baseUrl: ctx.baseUrl }); + + const makeLinkTable = async (name: string, prefix: string) => { + const table = await ctx.createTable({ + baseId: ctx.baseId, + name, + fields: [{ type: 'singleLineText', name: 'Title', isPrimary: true }], + views: [{ type: 'grid', name: 'Grid' }], + }); + const titleFieldId = table.fields[0]?.id ?? ''; + const created = await ctx.createRecords(table.id, [ + { fields: { [titleFieldId]: `${prefix}_record1` } }, + { fields: { [titleFieldId]: `${prefix}_record2` } }, + { fields: { [titleFieldId]: `${prefix}_record3` } }, + ]); + return { + id: table.id, + titleFieldId, + viewId: table.views[0]?.id ?? '', + recordIds: created.map((record) => record.id), + }; + }; + + const linkTable1 = await makeLinkTable('Filter Link Table 1', 'link_table1'); + const linkTable2 = await makeLinkTable('Filter Link Table 2', 'link_table2'); + linkTable1Id = linkTable1.id; + linkTable2Id = linkTable2.id; + linkTable1RecordIds = linkTable1.recordIds; + linkTable2RecordIds = linkTable2.recordIds; + linkTable1ViewId = linkTable1.viewId; + + const host = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Filter Link Host', + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { + type: 'link', + name: 'Link One', + options: { + relationship: 'manyOne', + foreignTableId: linkTable1.id, + lookupFieldId: linkTable1.titleFieldId, + isOneWay: true, + }, + }, + { + type: 'link', + name: 'Link Two', + options: { + relationship: 'manyOne', + foreignTableId: linkTable2.id, + lookupFieldId: linkTable2.titleFieldId, + isOneWay: true, + }, + }, + ], + views: [{ type: 'grid', name: 'Host Grid' }], + }); + hostTableId = host.id; + plainFieldId = host.fields.find((field) => field.isPrimary)?.id ?? ''; + linkField1Id = host.fields.find((field) => field.name === 'Link One')?.id ?? ''; + linkField2Id = host.fields.find((field) => field.name === 'Link Two')?.id ?? ''; + if (!plainFieldId || !linkField1Id || !linkField2Id) { + throw new Error('Filter link fixture is incomplete'); + } + }); + + afterAll(async () => { + if (!ctx) return; + for (const id of [hostTableId, linkTable1Id, linkTable2Id]) { + if (id) await ctx.deleteTable(id, { mode: 'permanent' }).catch(() => undefined); + } + }); + + it('returns nested, deduplicated link records without the missing record id', async () => { + const missingRecordId = `rec${'z'.repeat(16)}`; + const created = await client.tables.createView({ + tableId: hostTableId, + view: { + type: 'grid', + name: 'Link filter view', + sourceFilter: { + conjunction: 'and', + filterSet: [ + { fieldId: linkField1Id, operator: 'is', value: linkTable1RecordIds[0] }, + { + conjunction: 'and', + filterSet: [ + { + fieldId: linkField1Id, + operator: 'isAnyOf', + value: [...linkTable1RecordIds, missingRecordId], + }, + ], + }, + { fieldId: linkField2Id, operator: 'is', value: linkTable2RecordIds[0] }, + { + conjunction: 'and', + filterSet: [ + { fieldId: linkField2Id, operator: 'isAnyOf', value: [linkTable2RecordIds[2]] }, + ], + }, + ], + }, + }, + }); + if (!created.ok) throw new Error(created.error.message); + + const links = await client.tables.getViewFilterLinkRecords({ + tableId: hostTableId, + viewId: created.data.viewId, + }); + expect(links.ok).toBe(true); + if (!links.ok) return; + expect(links.data.groups).toEqual([ + { + tableId: linkTable1Id, + records: [ + { id: linkTable1RecordIds[0], title: 'link_table1_record1' }, + { id: linkTable1RecordIds[1], title: 'link_table1_record2' }, + { id: linkTable1RecordIds[2], title: 'link_table1_record3' }, + ], + }, + { + tableId: linkTable2Id, + records: [ + { id: linkTable2RecordIds[0], title: 'link_table2_record1' }, + { id: linkTable2RecordIds[2], title: 'link_table2_record3' }, + ], + }, + ]); + }); + + it('returns no groups when the filter does not reference a Link field', async () => { + const created = await client.tables.createView({ + tableId: hostTableId, + view: { + type: 'grid', + name: 'No link references', + sourceFilter: { + conjunction: 'and', + filterSet: [{ fieldId: plainFieldId, operator: 'is', value: 'anything' }], + }, + }, + }); + if (!created.ok) throw new Error(created.error.message); + + const links = await client.tables.getViewFilterLinkRecords({ + tableId: hostTableId, + viewId: created.data.viewId, + }); + expect(links).toEqual({ ok: true, data: { groups: [] } }); + }); + + it('rejects a View owned by another Table with view.not_found', async () => { + await expect( + client.tables.getViewFilterLinkRecords({ + tableId: hostTableId, + viewId: linkTable1ViewId, + }) + ).rejects.toMatchObject({ status: 404 }); + }); +}); diff --git a/packages/v2/e2e/src/viewRead.e2e.spec.ts b/packages/v2/e2e/src/viewRead.e2e.spec.ts new file mode 100644 index 0000000000..554e20e3c3 --- /dev/null +++ b/packages/v2/e2e/src/viewRead.e2e.spec.ts @@ -0,0 +1,303 @@ +import { + getViewErrorResponseSchema, + getViewOkResponseSchema, + listViewsErrorResponseSchema, + listViewsOkResponseSchema, +} from '@teable/v2-contract-http'; +import { createV2HttpClient } from '@teable/v2-contract-http-client'; +import { + ActorId, + DeleteViewCommand, + type DeleteViewResult, + type ICommandBus, + v2CoreTokens, +} from '@teable/v2-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + getSharedTestContext, + TEST_USER, + type SharedTestContext, +} from './shared/globalTestContext'; + +describe('v2 http View read contracts (e2e)', () => { + let ctx: SharedTestContext; + let client: ReturnType; + let tableId: string; + let foreignTableId: string; + let primaryFieldId: string; + let statusFieldId: string; + let seedViewId: string; + let richViewId: string; + let kanbanViewId: string; + let formViewId: string; + let disposableViewId: string; + let foreignViewId: string; + + const getViewRaw = async (targetTableId: string, viewId: string) => { + const search = new URLSearchParams({ tableId: targetTableId, viewId }); + const response = await fetch(`${ctx.baseUrl}/tables/getView?${search.toString()}`); + return { response, body: await response.json() }; + }; + + const listViewsRaw = async (targetTableId: string) => { + const search = new URLSearchParams({ tableId: targetTableId }); + const response = await fetch(`${ctx.baseUrl}/tables/listViews?${search.toString()}`); + return { response, body: await response.json() }; + }; + + beforeAll(async () => { + ctx = await getSharedTestContext(); + client = createV2HttpClient({ baseUrl: ctx.baseUrl }); + + const table = await ctx.createTable({ + baseId: ctx.baseId, + name: 'View Read Contract', + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { + type: 'singleSelect', + name: 'Status', + options: { + choices: [ + { id: 'choTodo', name: 'Todo', color: 'blue' }, + { id: 'choDone', name: 'Done', color: 'green' }, + ], + }, + }, + ], + views: [{ type: 'grid', name: 'Seed' }], + }); + tableId = table.id; + primaryFieldId = table.fields.find((field) => field.isPrimary)?.id ?? ''; + statusFieldId = table.fields.find((field) => field.name === 'Status')?.id ?? ''; + seedViewId = table.views[0]?.id ?? ''; + if (!primaryFieldId || !statusFieldId || !seedViewId) { + throw new Error('View read contract fixture is incomplete'); + } + + const richResult = await client.tables.createView({ + tableId, + view: { + type: 'grid', + name: 'Planning', + description: 'Planning details', + columnMeta: { + [primaryFieldId]: { width: 240 }, + }, + options: { rowHeight: 'short', frozenColumnCount: 1 }, + sourceFilter: { + conjunction: 'and', + filterSet: [ + { + fieldId: primaryFieldId, + operator: 'LIKE', + isSymbol: true, + value: 'alpha', + }, + ], + }, + sort: [{ fieldId: primaryFieldId, order: 'asc' }], + group: [{ fieldId: statusFieldId, order: 'desc' }], + manualSort: false, + isLocked: true, + enableShare: true, + shareMeta: { allowCopy: false, includeRecords: true, password: 'secret' }, + }, + }); + if (!richResult.ok) throw new Error(richResult.error.message); + richViewId = richResult.data.viewId; + + const kanbanResult = await client.tables.createView({ + tableId, + view: { + type: 'kanban', + name: 'Board', + options: { stackFieldId: statusFieldId, isEmptyStackHidden: true }, + }, + }); + if (!kanbanResult.ok) throw new Error(kanbanResult.error.message); + kanbanViewId = kanbanResult.data.viewId; + + const formResult = await client.tables.createView({ + tableId, + view: { + type: 'form', + name: 'Intake', + options: { submitLabel: 'Send' }, + }, + }); + if (!formResult.ok) throw new Error(formResult.error.message); + formViewId = formResult.data.viewId; + + const disposableResult = await client.tables.createView({ + tableId, + view: { type: 'gallery', name: 'Disposable' }, + }); + if (!disposableResult.ok) throw new Error(disposableResult.error.message); + disposableViewId = disposableResult.data.viewId; + + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: 'Foreign View Read Contract', + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + views: [{ type: 'grid', name: 'Foreign' }], + }); + foreignTableId = foreignTable.id; + foreignViewId = foreignTable.views[0]?.id ?? ''; + if (!foreignViewId) throw new Error('Foreign View fixture is incomplete'); + }); + + afterAll(async () => { + if (ctx && tableId) await ctx.deleteTable(tableId).catch(() => undefined); + if (ctx && foreignTableId) await ctx.deleteTable(foreignTableId).catch(() => undefined); + }); + + it('gets a rich View child through the Table aggregate projection', async () => { + const { response, body } = await getViewRaw(tableId, richViewId); + + expect(response.status, JSON.stringify(body)).toBe(200); + const parsed = getViewOkResponseSchema.safeParse(body); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) return; + + expect(parsed.data.data.view).toMatchObject({ + id: richViewId, + name: 'Planning', + type: 'grid', + description: 'Planning details', + options: { rowHeight: 'short', frozenColumnCount: 1 }, + sort: { + sortObjs: [{ fieldId: primaryFieldId, order: 'asc' }], + manualSort: false, + }, + group: [{ fieldId: statusFieldId, order: 'desc' }], + isLocked: true, + enableShare: true, + shareMeta: { allowCopy: false, includeRecords: true, password: 'secret' }, + createdBy: TEST_USER.id, + }); + expect(parsed.data.data.view.createdTime).toBeTruthy(); + expect(parsed.data.data.view.columnMeta[primaryFieldId]).toMatchObject({ + order: 0, + width: 240, + }); + }); + + it('lists every View child in aggregate order with subtype-specific options', async () => { + const { response, body } = await listViewsRaw(tableId); + + expect(response.status, JSON.stringify(body)).toBe(200); + const parsed = listViewsOkResponseSchema.safeParse(body); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) return; + + expect(parsed.data.data.views.map((view) => view.id)).toEqual([ + seedViewId, + richViewId, + kanbanViewId, + formViewId, + disposableViewId, + ]); + expect(parsed.data.data.views).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: kanbanViewId, + type: 'kanban', + options: { stackFieldId: statusFieldId, isEmptyStackHidden: true }, + }), + expect.objectContaining({ + id: formViewId, + type: 'form', + options: { submitLabel: 'Send' }, + }), + ]) + ); + }); + + it('supports typed get/list clients and keeps projected results in aggregate order', async () => { + const getResult = await client.tables.getView({ tableId, viewId: formViewId }); + expect(getResult).toMatchObject({ + ok: true, + data: { view: { id: formViewId, name: 'Intake', type: 'form' } }, + }); + + const listResult = await client.tables.listViews({ + tableId, + viewIds: [formViewId, richViewId, formViewId], + }); + expect(listResult.ok).toBe(true); + if (!listResult.ok) return; + expect(listResult.data.views.map((view) => view.id)).toEqual([richViewId, formViewId]); + }); + + it('returns an empty typed projection for a View outside the requested Table scope', async () => { + const result = await client.tables.listViews({ tableId, viewIds: [foreignViewId] }); + + expect(result).toEqual({ ok: true, data: { views: [] } }); + }); + + it('rejects malformed Table and View identifiers at the contract boundary', async () => { + const invalidGet = await getViewRaw('invalid', 'invalid'); + expect(invalidGet.response.status).toBe(400); + expect(getViewErrorResponseSchema.safeParse(invalidGet.body).success).toBe(true); + + const response = await fetch( + `${ctx.baseUrl}/tables/listViews?${new URLSearchParams({ + tableId, + viewIds: 'invalid', + }).toString()}` + ); + expect(response.status).toBe(400); + expect(listViewsErrorResponseSchema.safeParse(await response.json()).success).toBe(true); + }); + + it('does not resolve a View child through the wrong Table aggregate', async () => { + const { response, body } = await getViewRaw(tableId, foreignViewId); + + expect(response.status).toBe(404); + const parsed = getViewErrorResponseSchema.safeParse(body); + expect(parsed.success).toBe(true); + if (!parsed.success || parsed.data.ok) return; + expect(parsed.data.error.code).toBe('view.not_found'); + }); + + it('maps missing View and Table aggregates to their query-specific errors', async () => { + const missingView = await getViewRaw(tableId, `viw${'z'.repeat(16)}`); + expect(missingView.response.status).toBe(404); + const parsedView = getViewErrorResponseSchema.safeParse(missingView.body); + expect(parsedView.success).toBe(true); + if (parsedView.success && !parsedView.data.ok) { + expect(parsedView.data.error.code).toBe('view.not_found'); + } + + const missingTable = await listViewsRaw(`tbl${'z'.repeat(16)}`); + expect(missingTable.response.status).toBe(404); + const parsedTable = listViewsErrorResponseSchema.safeParse(missingTable.body); + expect(parsedTable.success).toBe(true); + if (parsedTable.success && !parsedTable.data.ok) { + expect(parsedTable.data.error.code).toBe('table.not_found'); + } + }); + + it('cannot read a View after the Table aggregate deletes that child', async () => { + const commandBus = ctx.testContainer.container.resolve(v2CoreTokens.commandBus); + const command = DeleteViewCommand.create({ tableId, viewId: disposableViewId }); + expect(command.isOk()).toBe(true); + if (command.isErr()) return; + const actorId = ActorId.create(TEST_USER.id)._unsafeUnwrap(); + const deleted = await commandBus.execute( + { actorId }, + command.value + ); + expect(deleted.isOk()).toBe(true); + + const { response, body } = await getViewRaw(tableId, disposableViewId); + expect(response.status).toBe(404); + const parsed = getViewErrorResponseSchema.safeParse(body); + expect(parsed.success).toBe(true); + if (parsed.success && !parsed.data.ok) { + expect(parsed.data.error.code).toBe('view.not_found'); + } + }); +}); diff --git a/packages/v2/formula-sql-pg/src/FormulaSqlPgFunctions.ts b/packages/v2/formula-sql-pg/src/FormulaSqlPgFunctions.ts index 1ae9b64d18..a43ab732af 100644 --- a/packages/v2/formula-sql-pg/src/FormulaSqlPgFunctions.ts +++ b/packages/v2/formula-sql-pg/src/FormulaSqlPgFunctions.ts @@ -748,8 +748,9 @@ export class FormulaSqlPgFunctions extends FormulaSqlPgExpressionBuilder { return this.vectorizeUnaryNumericArray(textExpr, (valueSql: string) => valueSql, 'value'); } const numeric = this.coerceToNumber(textExpr, 'value'); + // v1 parity: VALUE(null|''|non-numeric) is null, never 0 (T6520) return makeExpr( - `COALESCE(${numeric.valueSql}, 0)`, + numeric.valueSql, 'number', false, numeric.errorConditionSql, diff --git a/packages/v2/formula-sql-pg/src/TranslatorEdgeCases.spec.ts b/packages/v2/formula-sql-pg/src/TranslatorEdgeCases.spec.ts index d41c98fa85..d86967a9bf 100644 --- a/packages/v2/formula-sql-pg/src/TranslatorEdgeCases.spec.ts +++ b/packages/v2/formula-sql-pg/src/TranslatorEdgeCases.spec.ts @@ -50,9 +50,9 @@ describe('FormulaSqlPgTranslator edge cases', () => { expect(result).toContain('#ERROR:DIV0'); }); - it('should treat blank VALUE input as zero', async () => { + it('should treat blank VALUE input as null (v1 parity, T6520)', async () => { const result = await executeFormulaAsText(testTable, 'ValueBlank'); - expect(result).toBe('0'); + expect(result).toBeNull(); }); }); diff --git a/packages/v2/formula-sql-pg/src/__snapshots__/ErrorHandling.spec.ts.snap b/packages/v2/formula-sql-pg/src/__snapshots__/ErrorHandling.spec.ts.snap index b8741633a4..0c5aa7ab74 100644 --- a/packages/v2/formula-sql-pg/src/__snapshots__/ErrorHandling.spec.ts.snap +++ b/packages/v2/formula-sql-pg/src/__snapshots__/ErrorHandling.spec.ts.snap @@ -521,7 +521,7 @@ exports[`error handling > 'ValueTypeError': 'Value with non-convertible type' 1` }, }, "result": "#ERROR:TYPE:cannot_cast_to_number", - "sql": "CASE WHEN TRUE THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE (COALESCE(NULL::double precision, 0))::text END", + "sql": "CASE WHEN TRUE THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE (NULL::double precision)::text END", } `; diff --git a/packages/v2/formula-sql-pg/src/__snapshots__/NumericFunctions.spec.ts.snap b/packages/v2/formula-sql-pg/src/__snapshots__/NumericFunctions.spec.ts.snap index 0000a7a78a..0b577d0d36 100644 --- a/packages/v2/formula-sql-pg/src/__snapshots__/NumericFunctions.spec.ts.snap +++ b/packages/v2/formula-sql-pg/src/__snapshots__/NumericFunctions.spec.ts.snap @@ -12864,7 +12864,7 @@ exports[`numeric functions > 'Value' with 'autoNumber' 1`] = ` }, }, "result": "1", - "sql": "COALESCE(("t"."AutoNumber")::double precision, 0)", + "sql": "("t"."AutoNumber")::double precision", } `; @@ -12883,7 +12883,7 @@ exports[`numeric functions > 'Value' with 'button' 1`] = ` }, }, "result": "#ERROR:TYPE:cannot_cast_to_number", - "sql": "CASE WHEN TRUE THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE (COALESCE(NULL::double precision, 0))::text END", + "sql": "CASE WHEN TRUE THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE (NULL::double precision)::text END", } `; @@ -12902,7 +12902,7 @@ exports[`numeric functions > 'Value' with 'checkbox' 1`] = ` }, }, "result": "1", - "sql": "COALESCE((CASE WHEN "t"."Checkbox" IS NULL THEN NULL WHEN "t"."Checkbox" THEN 1 ELSE 0 END)::double precision, 0)", + "sql": "(CASE WHEN "t"."Checkbox" IS NULL THEN NULL WHEN "t"."Checkbox" THEN 1 ELSE 0 END)::double precision", } `; @@ -12986,8 +12986,8 @@ exports[`numeric functions > 'Value' with 'conditionalRollup' 1`] = ` "rawValue": null, }, }, - "result": "0", - "sql": "COALESCE(("t"."ConditionalRollupType")::double precision, 0)", + "result": null, + "sql": "("t"."ConditionalRollupType")::double precision", } `; @@ -13014,7 +13014,7 @@ exports[`numeric functions > 'Value' with 'createdBy' 1`] = ` WHEN jsonb_typeof(to_jsonb("t"."CreatedBy")) = 'object' THEN COALESCE(to_jsonb("t"."CreatedBy")->>'title', to_jsonb("t"."CreatedBy")->>'name', to_jsonb("t"."CreatedBy") #>> '{}') WHEN jsonb_typeof(to_jsonb("t"."CreatedBy")) = 'array' THEN NULL ELSE to_jsonb("t"."CreatedBy") #>> '{}' - END) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE (COALESCE((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (CASE + END) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE ((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (CASE WHEN jsonb_typeof(to_jsonb("t"."CreatedBy")) = 'object' THEN COALESCE(to_jsonb("t"."CreatedBy")->>'title', to_jsonb("t"."CreatedBy")->>'name', to_jsonb("t"."CreatedBy") #>> '{}') WHEN jsonb_typeof(to_jsonb("t"."CreatedBy")) = 'array' THEN NULL ELSE to_jsonb("t"."CreatedBy") #>> '{}' @@ -13026,7 +13026,7 @@ exports[`numeric functions > 'Value' with 'createdBy' 1`] = ` WHEN jsonb_typeof(to_jsonb("t"."CreatedBy")) = 'object' THEN COALESCE(to_jsonb("t"."CreatedBy")->>'title', to_jsonb("t"."CreatedBy")->>'name', to_jsonb("t"."CreatedBy") #>> '{}') WHEN jsonb_typeof(to_jsonb("t"."CreatedBy")) = 'array' THEN NULL ELSE to_jsonb("t"."CreatedBy") #>> '{}' - END) AS val) AS v) END), 0))::text END", + END) AS val) AS v) END))::text END", } `; @@ -13049,7 +13049,7 @@ exports[`numeric functions > 'Value' with 'createdTime' 1`] = ` }, }, "result": "#ERROR:TYPE:cannot_cast_datetime_to_number_value", - "sql": "CASE WHEN TRUE THEN '#ERROR:TYPE:cannot_cast_datetime_to_number_value' ELSE (COALESCE(NULL::double precision, 0))::text END", + "sql": "CASE WHEN TRUE THEN '#ERROR:TYPE:cannot_cast_datetime_to_number_value' ELSE (NULL::double precision)::text END", } `; @@ -13072,7 +13072,7 @@ exports[`numeric functions > 'Value' with 'date' 1`] = ` }, }, "result": "#ERROR:TYPE:cannot_cast_datetime_to_number_value", - "sql": "CASE WHEN TRUE THEN '#ERROR:TYPE:cannot_cast_datetime_to_number_value' ELSE (COALESCE(NULL::double precision, 0))::text END", + "sql": "CASE WHEN TRUE THEN '#ERROR:TYPE:cannot_cast_datetime_to_number_value' ELSE (NULL::double precision)::text END", } `; @@ -13094,7 +13094,7 @@ exports[`numeric functions > 'Value' with 'formula' 1`] = ` }, }, "result": "10", - "sql": "COALESCE((10)::double precision, 0)", + "sql": "(10)::double precision", } `; @@ -13121,7 +13121,7 @@ exports[`numeric functions > 'Value' with 'lastModifiedBy' 1`] = ` WHEN jsonb_typeof(to_jsonb("t"."LastModifiedBy")) = 'object' THEN COALESCE(to_jsonb("t"."LastModifiedBy")->>'title', to_jsonb("t"."LastModifiedBy")->>'name', to_jsonb("t"."LastModifiedBy") #>> '{}') WHEN jsonb_typeof(to_jsonb("t"."LastModifiedBy")) = 'array' THEN NULL ELSE to_jsonb("t"."LastModifiedBy") #>> '{}' - END) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE (COALESCE((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (CASE + END) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE ((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (CASE WHEN jsonb_typeof(to_jsonb("t"."LastModifiedBy")) = 'object' THEN COALESCE(to_jsonb("t"."LastModifiedBy")->>'title', to_jsonb("t"."LastModifiedBy")->>'name', to_jsonb("t"."LastModifiedBy") #>> '{}') WHEN jsonb_typeof(to_jsonb("t"."LastModifiedBy")) = 'array' THEN NULL ELSE to_jsonb("t"."LastModifiedBy") #>> '{}' @@ -13133,7 +13133,7 @@ exports[`numeric functions > 'Value' with 'lastModifiedBy' 1`] = ` WHEN jsonb_typeof(to_jsonb("t"."LastModifiedBy")) = 'object' THEN COALESCE(to_jsonb("t"."LastModifiedBy")->>'title', to_jsonb("t"."LastModifiedBy")->>'name', to_jsonb("t"."LastModifiedBy") #>> '{}') WHEN jsonb_typeof(to_jsonb("t"."LastModifiedBy")) = 'array' THEN NULL ELSE to_jsonb("t"."LastModifiedBy") #>> '{}' - END) AS val) AS v) END), 0))::text END", + END) AS val) AS v) END))::text END", } `; @@ -13156,7 +13156,7 @@ exports[`numeric functions > 'Value' with 'lastModifiedTime' 1`] = ` }, }, "result": "#ERROR:TYPE:cannot_cast_datetime_to_number_value", - "sql": "CASE WHEN TRUE THEN '#ERROR:TYPE:cannot_cast_datetime_to_number_value' ELSE (COALESCE(NULL::double precision, 0))::text END", + "sql": "CASE WHEN TRUE THEN '#ERROR:TYPE:cannot_cast_datetime_to_number_value' ELSE (NULL::double precision)::text END", } `; @@ -13175,7 +13175,7 @@ exports[`numeric functions > 'Value' with 'link' 1`] = ` }, }, "result": "#ERROR:TYPE:cannot_cast_to_number", - "sql": "CASE WHEN TRUE THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE (COALESCE(NULL::double precision, 0))::text END", + "sql": "CASE WHEN TRUE THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE (NULL::double precision)::text END", } `; @@ -13194,11 +13194,11 @@ exports[`numeric functions > 'Value' with 'longText' 1`] = ` }, }, "result": "10", - "sql": "CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT "t"."LongText" AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT "t"."LongText" AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE (COALESCE((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT "t"."LongText" AS val) AS v) THEN NULL ELSE (SELECT (CASE + "sql": "CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT "t"."LongText" AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT "t"."LongText" AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE ((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT "t"."LongText" AS val) AS v) THEN NULL ELSE (SELECT (CASE WHEN NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NULL THEN NULL WHEN (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric')) THEN (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'))::double precision ELSE NULL - END) FROM (SELECT "t"."LongText" AS val) AS v) END), 0))::text END", + END) FROM (SELECT "t"."LongText" AS val) AS v) END))::text END", } `; @@ -13317,7 +13317,7 @@ exports[`numeric functions > 'Value' with 'number' 1`] = ` }, }, "result": "10", - "sql": "COALESCE(("t"."Number")::double precision, 0)", + "sql": "("t"."Number")::double precision", } `; @@ -13336,7 +13336,7 @@ exports[`numeric functions > 'Value' with 'rating' 1`] = ` }, }, "result": "4", - "sql": "COALESCE(("t"."Rating")::double precision, 0)", + "sql": "("t"."Rating")::double precision", } `; @@ -13357,8 +13357,8 @@ exports[`numeric functions > 'Value' with 'rollup' 1`] = ` "rawValue": null, }, }, - "result": "0", - "sql": "COALESCE(("t"."RollupType")::double precision, 0)", + "result": null, + "sql": "("t"."RollupType")::double precision", } `; @@ -13377,11 +13377,11 @@ exports[`numeric functions > 'Value' with 'singleLineText' 1`] = ` }, }, "result": "10", - "sql": "CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT "t"."SingleLineText" AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT "t"."SingleLineText" AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE (COALESCE((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT "t"."SingleLineText" AS val) AS v) THEN NULL ELSE (SELECT (CASE + "sql": "CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT "t"."SingleLineText" AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT "t"."SingleLineText" AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE ((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT "t"."SingleLineText" AS val) AS v) THEN NULL ELSE (SELECT (CASE WHEN NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NULL THEN NULL WHEN (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric')) THEN (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'))::double precision ELSE NULL - END) FROM (SELECT "t"."SingleLineText" AS val) AS v) END), 0))::text END", + END) FROM (SELECT "t"."SingleLineText" AS val) AS v) END))::text END", } `; @@ -13400,11 +13400,11 @@ exports[`numeric functions > 'Value' with 'singleSelect' 1`] = ` }, }, "result": "10", - "sql": "CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT "t"."SingleSelect" AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT "t"."SingleSelect" AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE (COALESCE((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT "t"."SingleSelect" AS val) AS v) THEN NULL ELSE (SELECT (CASE + "sql": "CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT "t"."SingleSelect" AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT "t"."SingleSelect" AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE ((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT "t"."SingleSelect" AS val) AS v) THEN NULL ELSE (SELECT (CASE WHEN NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NULL THEN NULL WHEN (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric')) THEN (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'))::double precision ELSE NULL - END) FROM (SELECT "t"."SingleSelect" AS val) AS v) END), 0))::text END", + END) FROM (SELECT "t"."SingleSelect" AS val) AS v) END))::text END", } `; @@ -13451,11 +13451,11 @@ exports[`numeric functions > 'ValueBad' uses constant input 1`] = ` "funcId": "ValueBad", "inputs": {}, "result": "#ERROR:TYPE:cannot_cast_to_number", - "sql": "CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT 'not-a-number' AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT 'not-a-number' AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE (COALESCE((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT 'not-a-number' AS val) AS v) THEN NULL ELSE (SELECT (CASE + "sql": "CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT 'not-a-number' AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT 'not-a-number' AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE ((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT 'not-a-number' AS val) AS v) THEN NULL ELSE (SELECT (CASE WHEN NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NULL THEN NULL WHEN (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric')) THEN (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'))::double precision ELSE NULL - END) FROM (SELECT 'not-a-number' AS val) AS v) END), 0))::text END", + END) FROM (SELECT 'not-a-number' AS val) AS v) END))::text END", } `; @@ -13465,10 +13465,10 @@ exports[`numeric functions > 'ValueComma' uses constant input 1`] = ` "funcId": "ValueComma", "inputs": {}, "result": "1000", - "sql": "CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT '1,000' AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT '1,000' AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE (COALESCE((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT '1,000' AS val) AS v) THEN NULL ELSE (SELECT (CASE + "sql": "CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT '1,000' AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT '1,000' AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE ((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT '1,000' AS val) AS v) THEN NULL ELSE (SELECT (CASE WHEN NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NULL THEN NULL WHEN (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric')) THEN (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'))::double precision ELSE NULL - END) FROM (SELECT '1,000' AS val) AS v) END), 0))::text END", + END) FROM (SELECT '1,000' AS val) AS v) END))::text END", } `; diff --git a/packages/v2/postgres-schema/src/index.ts b/packages/v2/postgres-schema/src/index.ts index bc7f36324a..3e304ca662 100644 --- a/packages/v2/postgres-schema/src/index.ts +++ b/packages/v2/postgres-schema/src/index.ts @@ -9,4 +9,6 @@ export type { V1TableMetaTable, V1TeableDatabase, V1ViewTable, + V1PluginTable, + V1PluginInstallTable, } from './v1/types'; diff --git a/packages/v2/postgres-schema/src/v1/types.ts b/packages/v2/postgres-schema/src/v1/types.ts index 45bde2db93..e7f0dcd5a7 100644 --- a/packages/v2/postgres-schema/src/v1/types.ts +++ b/packages/v2/postgres-schema/src/v1/types.ts @@ -12,6 +12,8 @@ export interface V1UserTable { id: string; name: string; email: string | null; + avatar: string | null; + is_system: boolean | null; // password: string; // created_time: ColumnType; // last_modified_time: ColumnType; @@ -50,6 +52,7 @@ export interface V1CollaboratorTable { resource_id: string; principal_id: string; principal_type: string; + created_time: ColumnType; } export interface V1TableMetaTable { @@ -127,6 +130,30 @@ export interface V1ViewTable { last_modified_by: string | null; } +export interface V1PluginTable { + id: string; + name: string; + logo: string; + url: string | null; + status: string; + positions: string; + created_by: string; +} + +export interface V1PluginInstallTable { + id: string; + plugin_id: string; + base_id: string; + name: string; + position_id: string; + position: string; + storage: string | null; + created_time: ColumnType; + created_by: string; + last_modified_time: ColumnType; + last_modified_by: string | null; +} + export interface V1ReferenceTable { id: string; from_field_id: string; @@ -194,6 +221,23 @@ export interface V1ComputedUpdateOutboxSeedTable { record_id: string; } +/** + * Durable per-stage state for budget-staged computed updates, keyed by the + * continuation chain's root task id (its scope): the processed-target exclusion + * ledger (kind 'excluded'), the seq-ordered frontier queue (kind 'frontier'), + * and retired frontier sources preserved for deferred edge chunks (kind + * 'consumed'). Rows are written once and shared by every continuation of the + * chain instead of being copied between task payloads. + */ +export interface V1ComputedUpdateStageLedgerTable { + scope_id: string; + /** 'excluded' | 'frontier' | 'consumed' */ + kind: string; + table_id: string; + record_id: string; + seq: bigint | number | string; +} + export interface V1ComputedUpdateDeadLetterTable { id: string; base_id: string; @@ -239,7 +283,7 @@ export interface V1TaskTable { export interface V1TaskRunTable { id: string; task_id: string; - base_id: string | null; + base_id: string; status: string; snapshot: string; depends_on_run_ids: ColumnType; @@ -247,7 +291,7 @@ export interface V1TaskRunTable { log: string | null; error_msg: string | null; started_time: ColumnType; - created_time: ColumnType; + created_time: ColumnType; last_modified_time: ColumnType; } @@ -319,18 +363,30 @@ export interface V1ComputedTaskFieldRefTable { created_at: ColumnType; } +export interface V1SpaceDataDbBindingTable { + id: string; + space_id: string; + data_db_connection_id: string | null; + mode: string; + state: string; +} + export interface V1TeableDatabase { users: V1UserTable; space: V1SpaceTable; base: V1BaseTable; + space_data_db_binding: V1SpaceDataDbBindingTable; collaborator: V1CollaboratorTable; table_meta: V1TableMetaTable; field: V1FieldTable; view: V1ViewTable; + plugin: V1PluginTable; + plugin_install: V1PluginInstallTable; reference: V1ReferenceTable; schema_operation: V1SchemaOperationTable; computed_update_outbox: V1ComputedUpdateOutboxTable; computed_update_outbox_seed: V1ComputedUpdateOutboxSeedTable; + computed_update_stage_ledger: V1ComputedUpdateStageLedgerTable; computed_update_dead_letter: V1ComputedUpdateDeadLetterTable; computed_field_activity: V1ComputedFieldActivityTable; computed_table_activity: V1ComputedTableActivityTable; diff --git a/packages/v2/table-query-ops/src/ports.ts b/packages/v2/table-query-ops/src/ports.ts index 3d6de4a190..45ae26c7f6 100644 --- a/packages/v2/table-query-ops/src/ports.ts +++ b/packages/v2/table-query-ops/src/ports.ts @@ -1,4 +1,10 @@ -import type { DomainError, IExecutionContext, Table, TableId } from '@teable/v2-core'; +import type { + DomainError, + IExecutionContext, + IRecordSearchAccessPath, + Table, + TableId, +} from '@teable/v2-core'; import { ok, type Result } from 'neverthrow'; import type { @@ -107,7 +113,9 @@ export interface TableQueryRemediationExecutor { export type ReconcileTableSearchAccessPathInput = { readonly table: Table; - readonly mode: 'create' | 'rebuild'; + // 'drop' removes the managed generated column + index and disables the + // table's config — the table-level kill switch for the indexed search path. + readonly mode: 'create' | 'rebuild' | 'drop'; readonly expectedDefinitionKey?: string; readonly semantics?: 'substring' | 'lexical'; readonly provider?: 'pg_trgm' | 'pg_bigm' | 'tsvector'; @@ -119,7 +127,7 @@ export type ReconcileTableSearchAccessPathInput = { }; export type ReconcileTableSearchAccessPathResult = { - readonly action: 'created' | 'rebuilt' | 'verified'; + readonly action: 'created' | 'rebuilt' | 'verified' | 'dropped'; readonly tableId: string; readonly definitionKey: string; readonly generatedColumnName: string; @@ -128,7 +136,7 @@ export type ReconcileTableSearchAccessPathResult = { readonly semantics?: 'substring' | 'lexical'; readonly provider?: 'pg_trgm' | 'pg_bigm' | 'tsvector'; readonly fieldIds: readonly string[]; - readonly status: 'ready'; + readonly status: 'ready' | 'disabled'; readonly planEvidence?: unknown; }; @@ -173,6 +181,18 @@ export interface TableSearchVectorStatusReader { ): Promise>; } +/** + * Resolves the ready-to-use record search access path for a table, or + * undefined when none is configured/ready. This is the read-path port the app + * layer uses instead of querying the config storage directly. + */ +export interface TableSearchAccessPathResolver { + resolve( + context: IExecutionContext, + tableId: string + ): Promise>; +} + export type TableSearchAccessPathProvider = 'pg_trgm' | 'pg_bigm'; export type TableSearchAccessPathCapabilityState = | 'ready' diff --git a/packages/v2/table-query-ops/src/searchVectorDefinition.spec.ts b/packages/v2/table-query-ops/src/searchVectorDefinition.spec.ts index 6883cdac78..a24519577d 100644 --- a/packages/v2/table-query-ops/src/searchVectorDefinition.spec.ts +++ b/packages/v2/table-query-ops/src/searchVectorDefinition.spec.ts @@ -59,15 +59,21 @@ describe('buildTableSearchAccessPathDefinition', () => { accessPath: 'generated_text', indexKind: 'gin_trgm', fields: [ - { fieldId: 'fld0000000000000001', fieldDbName: 'order_no' }, - { fieldId: 'fld0000000000000002', fieldDbName: 'notes' }, - ], - skippedFields: [ - { fieldId: 'fld0000000000000003', skippedReason: 'unsupported_search_field_type' }, + { + fieldId: 'fld0000000000000001', + fieldDbName: 'order_no', + textProjection: { kind: 'plain' }, + }, + { + fieldId: 'fld0000000000000002', + fieldDbName: 'notes', + textProjection: { kind: 'multiline' }, + }, ], + skippedFields: [{ fieldId: 'fld0000000000000003', skippedReason: 'non_text_value' }], }); expect(definition.definitionKey).toBe( - 'tbl0000000000000001:substring:pg_trgm:none:fld0000000000000001=order_no,fld0000000000000002=notes' + 'tbl0000000000000001:substring:pg_trgm:none:fld0000000000000001=order_no:plain,fld0000000000000002=notes:multiline' ); }); diff --git a/packages/v2/table-query-ops/src/searchVectorDefinition.ts b/packages/v2/table-query-ops/src/searchVectorDefinition.ts index 205e09972d..c13386d240 100644 --- a/packages/v2/table-query-ops/src/searchVectorDefinition.ts +++ b/packages/v2/table-query-ops/src/searchVectorDefinition.ts @@ -1,8 +1,10 @@ import { domainError, SearchDocumentFieldContributionVisitor, + searchFieldTextProjectionKey, type DomainError, type SearchDocumentFieldContribution, + type SearchFieldTextProjection, type Table, } from '@teable/v2-core'; import { err, ok } from 'neverthrow'; @@ -11,7 +13,7 @@ import type { Result } from 'neverthrow'; export type TableSearchDocumentFieldDefinition = SearchDocumentFieldContribution & { readonly included: true; readonly fieldDbName: string; - readonly textProjection: 'text_cast'; + readonly textProjection: SearchFieldTextProjection; }; export type TableSearchAccessPathDefinition = { @@ -37,10 +39,15 @@ export type BuildTableSearchAccessPathDefinitionOptions = { const languageConfigPattern = /^[\w.]+$/; -export const buildTableSearchAccessPathDefinition = ( - table: Table, - options: BuildTableSearchAccessPathDefinitionOptions = {} -): Result => { +type ResolvedDefinitionOptions = { + readonly semantics: 'substring' | 'lexical'; + readonly provider: 'pg_trgm' | 'pg_bigm' | 'tsvector'; + readonly languageConfig: string; +}; + +const resolveDefinitionOptions = ( + options: BuildTableSearchAccessPathDefinitionOptions +): Result => { const semantics = options.semantics ?? 'substring'; const provider = options.provider ?? (semantics === 'lexical' ? 'tsvector' : 'pg_trgm'); if (semantics === 'substring' && provider === 'tsvector') { @@ -55,15 +62,32 @@ export const buildTableSearchAccessPathDefinition = ( if (!languageConfigPattern.test(languageConfig)) { return err(domainError.validation({ message: 'Invalid search vector language config' })); } + return ok({ semantics, provider, languageConfig }); +}; - const selectedIds = options.fieldIds?.length ? new Set(options.fieldIds) : undefined; +type CollectedDocumentFields = { + readonly fields: readonly TableSearchDocumentFieldDefinition[]; + readonly skippedFields: readonly SearchDocumentFieldContribution[]; +}; + +const skippedContribution = ( + contribution: SearchDocumentFieldContribution +): SearchDocumentFieldContribution => ({ + ...contribution, + included: false, + skippedReason: 'unsupported_search_field_type', +}); + +const collectSearchDocumentFields = ( + table: Table, + selectedIds: ReadonlySet | undefined +): Result => { const visitor = new SearchDocumentFieldContributionVisitor(); const fields: TableSearchDocumentFieldDefinition[] = []; const skippedFields: SearchDocumentFieldContribution[] = []; for (const field of table.getFields()) { - const fieldId = field.id().toString(); - if (selectedIds && !selectedIds.has(fieldId)) continue; + if (selectedIds && !selectedIds.has(field.id().toString())) continue; const contribution = field.accept(visitor); if (contribution.isErr()) return err(contribution.error); @@ -73,12 +97,9 @@ export const buildTableSearchAccessPathDefinition = ( } const dbFieldName = field.dbFieldName().andThen((name) => name.value()); - if (dbFieldName.isErr()) { - skippedFields.push({ - ...contribution.value, - included: false, - skippedReason: 'unsupported_search_field_type', - }); + const textProjection = contribution.value.textProjection; + if (dbFieldName.isErr() || !textProjection) { + skippedFields.push(skippedContribution(contribution.value)); continue; } @@ -86,15 +107,50 @@ export const buildTableSearchAccessPathDefinition = ( ...contribution.value, included: true, fieldDbName: dbFieldName.value, - textProjection: 'text_cast', + textProjection, }); } - const tableId = table.id().toString(); - const definitionKey = `${tableId}:${semantics}:${provider}:${ - semantics === 'lexical' ? languageConfig : 'none' - }:${fields.map((field) => `${field.fieldId}=${field.fieldDbName}`).join(',')}`; + return ok({ fields, skippedFields }); +}; + +const buildDefinitionKey = ( + tableId: string, + resolved: ResolvedDefinitionOptions, + fields: readonly TableSearchDocumentFieldDefinition[] +): string => + `${tableId}:${resolved.semantics}:${resolved.provider}:${ + resolved.semantics === 'lexical' ? resolved.languageConfig : 'none' + }:${fields + .map( + (field) => + `${field.fieldId}=${field.fieldDbName}:${searchFieldTextProjectionKey(field.textProjection)}` + ) + .join(',')}`; + +const resolveIndexKind = ( + provider: ResolvedDefinitionOptions['provider'], + hasFields: boolean +): TableSearchAccessPathDefinition['indexKind'] => { + if (!hasFields) return 'none'; + if (provider === 'pg_bigm') return 'gin_bigm'; + return provider === 'pg_trgm' ? 'gin_trgm' : 'gin_tsvector'; +}; +export const buildTableSearchAccessPathDefinition = ( + table: Table, + options: BuildTableSearchAccessPathDefinitionOptions = {} +): Result => { + const resolvedOptions = resolveDefinitionOptions(options); + if (resolvedOptions.isErr()) return err(resolvedOptions.error); + const { semantics, provider, languageConfig } = resolvedOptions.value; + + const selectedIds = options.fieldIds?.length ? new Set(options.fieldIds) : undefined; + const collected = collectSearchDocumentFields(table, selectedIds); + if (collected.isErr()) return err(collected.error); + const { fields, skippedFields } = collected.value; + + const tableId = table.id().toString(); return ok({ tableId, baseId: table.baseId().toString(), @@ -108,15 +164,8 @@ export const buildTableSearchAccessPathDefinition = ( ? 'generated_text' : 'generated_tsvector' : 'none', - indexKind: - fields.length > 0 - ? provider === 'pg_bigm' - ? 'gin_bigm' - : provider === 'pg_trgm' - ? 'gin_trgm' - : 'gin_tsvector' - : 'none', - definitionKey, + indexKind: resolveIndexKind(provider, fields.length > 0), + definitionKey: buildDefinitionKey(tableId, resolvedOptions.value, fields), fields, skippedFields, }); diff --git a/packages/v2/table-query-ops/src/tokens.ts b/packages/v2/table-query-ops/src/tokens.ts index 97509bebea..2dc9a98b2f 100644 --- a/packages/v2/table-query-ops/src/tokens.ts +++ b/packages/v2/table-query-ops/src/tokens.ts @@ -10,6 +10,7 @@ export const v2TableOpsTokens = { searchVectorReconciler: Symbol('v2.tableOps.searchVectorReconciler'), searchAccessPathReconciler: Symbol('v2.tableOps.searchAccessPathReconciler'), searchVectorStatusReader: Symbol('v2.tableOps.searchVectorStatusReader'), + searchAccessPathResolver: Symbol('v2.tableOps.searchAccessPathResolver'), searchAccessPathCapabilityReader: Symbol('v2.tableOps.searchAccessPathCapabilityReader'), searchVectorSchemaMaintenanceScheduler: Symbol( 'v2.tableOps.searchVectorSchemaMaintenanceScheduler' diff --git a/packages/v2/table-templates/src/index.ts b/packages/v2/table-templates/src/index.ts index 1d6dbbf0e5..fe97a914d8 100644 --- a/packages/v2/table-templates/src/index.ts +++ b/packages/v2/table-templates/src/index.ts @@ -28,6 +28,8 @@ export { // Template field creators export { createAllBaseFields, + createDefaultTableFields, + createDefaultTableRecords, createAllFieldTypesFields, createContentCalendarFields, createPersonalFinanceFields, @@ -43,6 +45,7 @@ export { bugTriageTemplate, contentCalendarTemplate, crmTemplate, + defaultTableTemplate, hrManagementTemplate, personalFinanceTemplate, projectTrackerTemplate, @@ -57,6 +60,7 @@ import { bugTriageTemplate, contentCalendarTemplate, crmTemplate, + defaultTableTemplate, hrManagementTemplate, personalFinanceTemplate, projectTrackerTemplate, @@ -66,6 +70,7 @@ import { import type { TableTemplateDefinition } from './types'; export const tableTemplates = [ + defaultTableTemplate, simpleTableTemplate, allBaseFieldsTemplate, todoTemplate, diff --git a/packages/v2/table-templates/src/templates/default.ts b/packages/v2/table-templates/src/templates/default.ts new file mode 100644 index 0000000000..e8f880d757 --- /dev/null +++ b/packages/v2/table-templates/src/templates/default.ts @@ -0,0 +1,67 @@ +import type { ICreateTableRequestDto, ICreateTablesRequestDto } from '@teable/v2-contract-http'; + +import type { CreateTableTemplateInputOptions, TableTemplateDefinition } from '../types'; +import { createFieldId, createSelectOption } from '../utils'; + +/** + * Teable's default blank table, mirroring v1's API-layer defaults + * (nestjs-backend features/table/constant.ts): Name / Count / Status fields, + * a grid view, and exactly 3 empty records (T6520 parity). Hand-crafted + * instead of using `singleTable` because the seed intentionally carries + * exactly 3 empty records, below MIN_TEMPLATE_RECORDS. + */ + +const DEFAULT_TABLE_NAME = 'Table'; +const DEFAULT_RECORD_COUNT = 3; + +export const createDefaultTableFields = (): NonNullable => [ + { type: 'singleLineText', id: createFieldId(), name: 'Name' }, + { type: 'number', id: createFieldId(), name: 'Count' }, + { + type: 'singleSelect', + id: createFieldId(), + name: 'Status', + options: { + choices: [ + createSelectOption('light', 'grayBright'), + createSelectOption('medium', 'yellowBright'), + createSelectOption('heavy', 'tealBright'), + ], + }, + }, +]; + +export const createDefaultTableRecords = (): NonNullable => + Array.from({ length: DEFAULT_RECORD_COUNT }, () => ({ fields: {} })); + +export const defaultTableTemplate: TableTemplateDefinition = { + key: 'default', + name: 'Default', + description: 'Blank table with Name, Count and Status fields and 3 empty records.', + defaultRecordCount: DEFAULT_RECORD_COUNT, + tables: [ + { + key: 'default', + name: DEFAULT_TABLE_NAME, + description: 'Blank table with Name, Count and Status fields.', + fieldCount: 3, + defaultRecordCount: DEFAULT_RECORD_COUNT, + }, + ], + createInput: ( + baseId: string, + options?: CreateTableTemplateInputOptions + ): ICreateTablesRequestDto => ({ + baseId, + tables: [ + { + name: options?.namePrefix?.trim() || DEFAULT_TABLE_NAME, + fields: createDefaultTableFields(), + views: [{ type: 'grid', name: 'Grid view' }], + // The empty records are the template's content: include them unless + // the caller explicitly opts out. + records: options?.includeRecords ?? true ? createDefaultTableRecords() : undefined, + }, + ], + }), +}; diff --git a/packages/v2/table-templates/src/templates/index.ts b/packages/v2/table-templates/src/templates/index.ts index d12553a9b9..81e6feddb9 100644 --- a/packages/v2/table-templates/src/templates/index.ts +++ b/packages/v2/table-templates/src/templates/index.ts @@ -1,4 +1,9 @@ // Single-table templates +export { + defaultTableTemplate, + createDefaultTableFields, + createDefaultTableRecords, +} from './default'; export { simpleTableTemplate, createSimpleFields } from './simple'; export { allBaseFieldsTemplate, createAllBaseFields } from './all-base-fields'; export { todoTemplate, createTodoFields } from './todo'; diff --git a/plugins/src/app/sheet-form-view/components/SharePopover.tsx b/plugins/src/app/sheet-form-view/components/SharePopover.tsx index d3e47fe8ae..ebc77dc57e 100644 --- a/plugins/src/app/sheet-form-view/components/SharePopover.tsx +++ b/plugins/src/app/sheet-form-view/components/SharePopover.tsx @@ -145,6 +145,7 @@ export const SharePopover: React.FC<{