diff --git a/.github/workflows/docker-push.yml b/.github/workflows/docker-push.yml index a801779549..e10578db2e 100644 --- a/.github/workflows/docker-push.yml +++ b/.github/workflows/docker-push.yml @@ -101,7 +101,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22.18.0 + node-version: 22.22.3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index ffc6c2187d..98d75c1990 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [22.18.0] + node-version: [22.22.3] runtime: - mode: v1 force-v2-all: '' diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 1460568160..dd040178f0 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: - node-version: [22.18.0] + node-version: [22.22.3] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/manual-preview.yml b/.github/workflows/manual-preview.yml index d0e7d7adc4..5ff9de2a2d 100644 --- a/.github/workflows/manual-preview.yml +++ b/.github/workflows/manual-preview.yml @@ -66,7 +66,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22.18.0 + node-version: 22.22.3 - name: ⚙️ Install zx run: npm install -g zx diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 4107d07797..8820405e3f 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -11,11 +11,6 @@ on: description: 'Final English release note in Markdown' required: true type: string - media_sources: - description: 'JSON map from original media URL to downloadable source metadata' - required: false - default: '{}' - type: string publish_latest: description: 'Whether GitHub should mark this release as latest' required: false @@ -36,12 +31,6 @@ jobs: ref: develop fetch-depth: 0 - - name: Install FFmpeg when videos are present - if: contains(inputs.release_note, '.mp4') || contains(inputs.release_note, '.webm') || contains(inputs.release_note, '.mov') || contains(inputs.release_note, '.m4v') || contains(inputs.release_note, '.ogg') - run: | - sudo apt-get update - sudo apt-get install -y ffmpeg - - name: Build release body env: RELEASE_NOTE: ${{ inputs.release_note }} @@ -49,7 +38,6 @@ jobs: printf '%s\n' "$RELEASE_NOTE" > release-body.md - name: Create or update GitHub release - id: release env: GH_TOKEN: ${{ github.token }} RELEASE_ID: ${{ inputs.release_id }} @@ -73,7 +61,7 @@ jobs: } upsert_release() { - local release_body release_payload response_file release_api_id release_url + local release_body release_payload response_file release_api_id response_file="$(mktemp)" release_body="$(cat release-body.md)" release_payload="$(jq -nc \ @@ -103,200 +91,13 @@ jobs: --input - <<<"${release_payload}" >"${response_file}" fi - release_api_id="$(jq -r '.id' "${response_file}")" release_url="$(jq -r '.html_url // empty' "${response_file}")" - if [ -z "${release_url}" ] || [ -z "${release_api_id}" ]; then - echo "Missing release id/html_url in GitHub release response" >&2 + if [ -z "${release_url}" ]; then + echo "Missing html_url in GitHub release response" >&2 cat "${response_file}" >&2 return 1 fi - - echo "release_api_id=${release_api_id}" >> "${GITHUB_OUTPUT}" - echo "release_url=${release_url}" >> "${GITHUB_OUTPUT}" } ensure_tag upsert_release - - - name: Render media links and upload GIF previews - env: - GH_TOKEN: ${{ github.token }} - RELEASE_ID: ${{ inputs.release_id }} - RELEASE_API_ID: ${{ steps.release.outputs.release_api_id }} - MEDIA_SOURCES: ${{ inputs.media_sources }} - run: | - node <<'NODE' - const { execFileSync } = require('node:child_process'); - const crypto = require('node:crypto'); - const fs = require('node:fs/promises'); - const path = require('node:path'); - const os = require('node:os'); - - const repo = process.env.GITHUB_REPOSITORY; - const token = process.env.GH_TOKEN; - const releaseApiId = process.env.RELEASE_API_ID; - const mediaSources = (() => { - try { return JSON.parse(process.env.MEDIA_SOURCES || '{}') || {}; } - catch { return {}; } - })(); - - function getMediaType(url) { - const cleanUrl = String(url).split(/[?#]/)[0].toLowerCase(); - if (/^https:\/\/github\.com\/user-attachments\/assets\//.test(cleanUrl)) return 'githubAttachment'; - if (/^https:\/\/github\.com\/[^/]+\/[^/]+\/assets\/[^/]+\/[^/?#]+/.test(cleanUrl)) return 'githubAttachment'; - if (/\.(png|jpe?g|gif|webp|avif|svg)$/.test(cleanUrl)) return 'image'; - if (/\.(mp4|webm|ogg|mov|m4v)$/.test(cleanUrl)) return 'video'; - return null; - } - - function safeAlt(label, fallback) { - const value = String(label || fallback || 'Media').trim(); - return value.replace(/[\]\n\r]/g, '').replace(/^https?:\/\//i, '') || fallback || 'Media'; - } - - function sanitizeAssetName(name, fallbackUrl) { - const fallbackName = (() => { - try { - const pathname = new URL(fallbackUrl).pathname; - return decodeURIComponent(pathname.split('/').filter(Boolean).pop() || 'video.mp4'); - } catch { - return 'video.mp4'; - } - })(); - const rawName = String(name || fallbackName || 'video.mp4').trim(); - const ext = path.extname(rawName); - const base = path.basename(rawName, ext) || crypto.createHash('sha1').update(fallbackUrl).digest('hex').slice(0, 10); - const safeBase = base - .replace(/[\\/:*?"<>|#%{}]/g, '-') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') || 'video'; - return `${safeBase}-preview.gif`; - } - - function markdownImage(url, label) { - return `![${safeAlt(label, 'Media')}](${url})`; - } - - async function githubJson(method, apiPath, body) { - const options = { - method, - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${token}`, - 'X-GitHub-Api-Version': '2022-11-28', - }, - }; - if (body !== undefined) { - options.headers['Content-Type'] = 'application/json'; - options.body = JSON.stringify(body); - } - const response = await fetch(`https://api.github.com${apiPath}`, options); - if (!response.ok) throw new Error(`${method} ${apiPath} failed: HTTP ${response.status}: ${await response.text()}`); - if (response.status === 204) return null; - return response.json(); - } - - async function uploadGifAsset(filePath, assetName) { - const assets = await githubJson('GET', `/repos/${repo}/releases/${releaseApiId}/assets?per_page=100`); - const existing = assets.find((asset) => asset.name === assetName); - if (existing) await githubJson('DELETE', `/repos/${repo}/releases/assets/${existing.id}`); - - const buffer = await fs.readFile(filePath); - const response = await fetch(`https://uploads.github.com/repos/${repo}/releases/${releaseApiId}/assets?name=${encodeURIComponent(assetName)}`, { - method: 'POST', - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${token}`, - 'Content-Type': 'image/gif', - 'Content-Length': String(buffer.byteLength), - 'X-GitHub-Api-Version': '2022-11-28', - }, - body: buffer, - }); - if (!response.ok) throw new Error(`Upload ${assetName} failed: HTTP ${response.status}: ${await response.text()}`); - const asset = await response.json(); - return asset.browser_download_url; - } - - async function renderVideo(url, label) { - const source = mediaSources[url] || { url, name: label }; - const downloadUrl = source.url || url; - const assetName = sanitizeAssetName(source.name || label, url); - const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'release-video-')); - const inputPath = path.join(workDir, 'input-video'); - const outputPath = path.join(workDir, assetName); - - const response = await fetch(downloadUrl); - if (!response.ok) throw new Error(`Download video failed for ${url}: HTTP ${response.status}`); - await fs.writeFile(inputPath, Buffer.from(await response.arrayBuffer())); - - execFileSync('ffmpeg', [ - '-y', - '-i', inputPath, - '-t', '6', - '-vf', 'fps=12,scale=1280:-1:flags=lanczos,format=rgb24,split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=full[p];[s1][p]paletteuse=dither=sierra2_4a', - outputPath, - ], { stdio: 'inherit' }); - - const gifUrl = await uploadGifAsset(outputPath, assetName); - return `\n\nvideo preview\n\n`; - } - - async function renderUrl(url, label) { - const type = getMediaType(url); - if (type === 'image') return markdownImage(url, label); - if (type === 'githubAttachment') return `\n\n${url}\n\n`; - if (type === 'video') return renderVideo(url, label); - return null; - } - - async function renderMarkdown(markdown) { - let rendered = String(markdown || ''); - const markdownLinkPattern = /(!?)\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g; - const linkMatches = [...rendered.matchAll(markdownLinkPattern)]; - for (const match of linkMatches) { - const [fullMatch, bang, label, url] = match; - if (bang) continue; - const replacement = await renderUrl(url, label); - if (replacement) rendered = rendered.replace(fullMatch, replacement); - } - - const bareUrlPattern = /(^|[\s>])(https?:\/\/[^\s<>)]+)/g; - const bareMatches = [...rendered.matchAll(bareUrlPattern)]; - for (const match of bareMatches) { - const [fullMatch, prefix, url] = match; - const offset = match.index; - if (prefix === '(' || rendered[offset - 1] === '(' || rendered[offset - 1] === '"') continue; - const beforeUrl = rendered.slice(0, offset + prefix.length); - const lastOpenParen = beforeUrl.lastIndexOf('('); - const lastCloseParen = beforeUrl.lastIndexOf(')'); - if (lastOpenParen > lastCloseParen && /\[[^\]]*\]$/.test(beforeUrl.slice(0, lastOpenParen))) continue; - const replacement = await renderUrl(url); - if (replacement) rendered = rendered.replace(fullMatch, `${prefix}${replacement}`); - } - - return rendered.replace(/\n{3,}/g, '\n\n').trim(); - } - - (async () => { - const raw = await fs.readFile('release-body.md', 'utf8'); - const rendered = await renderMarkdown(raw); - await fs.writeFile('release-body.md', `${rendered}\n`); - })().catch((error) => { - console.error(error); - process.exit(1); - }); - NODE - - - name: Update GitHub release body with media previews - env: - GH_TOKEN: ${{ github.token }} - RELEASE_API_ID: ${{ steps.release.outputs.release_api_id }} - run: | - set -euo pipefail - release_body="$(cat release-body.md)" - jq -nc --arg body "${release_body}" '{ body: $body }' | - gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_API_ID}" \ - --method PATCH \ - --input - diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 7f8b57bcc4..1823c90995 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -22,7 +22,7 @@ jobs: strategy: matrix: - node-version: [22.18.0] + node-version: [22.22.3] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/v2-benchmark-tests.yml b/.github/workflows/v2-benchmark-tests.yml index 54eaf768eb..24f878814b 100644 --- a/.github/workflows/v2-benchmark-tests.yml +++ b/.github/workflows/v2-benchmark-tests.yml @@ -23,7 +23,7 @@ jobs: strategy: matrix: - node-version: [22.18.0] + node-version: [22.22.3] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/v2-core-tests.yml b/.github/workflows/v2-core-tests.yml index 81a9382a02..47de660f71 100644 --- a/.github/workflows/v2-core-tests.yml +++ b/.github/workflows/v2-core-tests.yml @@ -33,10 +33,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 22.18.0 + - name: Use Node.js 22.22.3 uses: actions/setup-node@v4 with: - node-version: 22.18.0 + node-version: 22.22.3 - name: 📥 Monorepo install uses: ./.github/actions/pnpm-install @@ -63,10 +63,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 22.18.0 + - name: Use Node.js 22.22.3 uses: actions/setup-node@v4 with: - node-version: 22.18.0 + node-version: 22.22.3 - name: 📥 Monorepo install uses: ./.github/actions/pnpm-install @@ -102,10 +102,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 22.18.0 + - name: Use Node.js 22.22.3 uses: actions/setup-node@v4 with: - node-version: 22.18.0 + node-version: 22.22.3 - name: 📥 Download all coverage artifacts uses: actions/download-artifact@v4 diff --git a/.npmrc b/.npmrc index 756b5df5d1..2f3a15b724 100644 --- a/.npmrc +++ b/.npmrc @@ -4,5 +4,5 @@ auto-install-peers=true lockfile=true # force use npmjs.org registry registry=https://registry.npmjs.org/ -use-node-version=22.18.0 +use-node-version=22.22.3 save-prefix='' diff --git a/Makefile b/Makefile index e6aef817dc..0ee15cfe19 100644 --- a/Makefile +++ b/Makefile @@ -42,8 +42,9 @@ SCRATCH ?= /tmp UNAME_S := $(shell uname -s) -# set param statement_cache_size=1 to avoid query error `ERROR: cached plan must not change result type` after alter column type (modify field type) -POSTGRES_PRISMA_DATABASE_URL ?= postgresql://teable:teable\@127.0.0.1:5432/teable?schema=public\&statement_cache_size=1 +# Disable prepared statement caching to avoid `ERROR: cached plan must not change result type` +# after field schema changes in tests. +POSTGRES_PRISMA_DATABASE_URL ?= postgresql://teable:teable\@127.0.0.1:5432/teable?schema=public\&statement_cache_size=0 # If the first make argument is "start", "stop"... ifeq (docker.start,$(firstword $(MAKECMDGOALS))) @@ -201,7 +202,7 @@ postgres.integration.test: docker.create.network $(DOCKER_COMPOSE_ARGS) $(DOCKER_COMPOSE) $(COMPOSE_FILE_ARGS) run -p 25432:5432 -d -T --no-deps --rm --name $$TEST_PG_CONTAINER_NAME teable-postgres; \ chmod +x scripts/wait-for; \ scripts/wait-for 127.0.0.1:25432 --timeout=15 -- echo 'pg database started successfully' && \ - export PRISMA_DATABASE_URL=postgresql://teable:teable@127.0.0.1:25432/e2e_test_teable?schema=public\&statement_cache_size=1\&connection_limit=20 && \ + export PRISMA_DATABASE_URL=postgresql://teable:teable@127.0.0.1:25432/e2e_test_teable?schema=public\&statement_cache_size=0\&connection_limit=20 && \ make postgres.mode && \ pnpm -F "./packages/**" run build && \ pnpm g:test-e2e-cover && \ diff --git a/apps/nestjs-backend/package.json b/apps/nestjs-backend/package.json index 9d69112059..5836d0e493 100644 --- a/apps/nestjs-backend/package.json +++ b/apps/nestjs-backend/package.json @@ -104,6 +104,7 @@ "eslint-config-next": "15.5.9", "get-tsconfig": "4.7.3", "istanbul-merge": "2.0.0", + "neverthrow": "8.2.0", "npm-run-all2": "6.1.2", "nyc": "15.1.0", "pg-mem": "3.0.5", @@ -167,7 +168,7 @@ "@opentelemetry/instrumentation-ioredis": "0.49.0", "@opentelemetry/instrumentation-nestjs-core": "0.49.0", "@opentelemetry/instrumentation-pg": "0.49.0", - "@opentelemetry/instrumentation-pino": "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", @@ -228,6 +229,7 @@ "keyv": "4.5.4", "knex": "3.1.0", "lodash": "4.17.21", + "markdown-it": "14.1.0", "mime-types": "2.1.35", "minio": "7.1.3", "ms": "2.1.3", diff --git a/apps/nestjs-backend/src/app.module.ts b/apps/nestjs-backend/src/app.module.ts index 714ed3783b..71e7f9a03e 100644 --- a/apps/nestjs-backend/src/app.module.ts +++ b/apps/nestjs-backend/src/app.module.ts @@ -10,10 +10,12 @@ import { ConfigModule } from './configs/config.module'; import { AccessTokenModule } from './features/access-token/access-token.module'; import { AggregationOpenApiModule } from './features/aggregation/open-api/aggregation-open-api.module'; import { AiModule } from './features/ai/ai.module'; +import { AirtableImportModule } from './features/airtable-import/airtable-import.module'; import { AttachmentsModule } from './features/attachments/attachments.module'; import { AuthModule } from './features/auth/auth.module'; import { BaseModule } from './features/base/base.module'; import { BaseNodeModule } from './features/base-node/base-node.module'; +import { BaseShareModule } from './features/base-share/base-share.module'; import { BuiltinAssetsInitModule } from './features/builtin-assets-init'; import { CanaryModule } from './features/canary'; import { ChatModule } from './features/chat/chat.module'; @@ -40,7 +42,6 @@ import { PluginPanelModule } from './features/plugin-panel/plugin-panel.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'; -import { BaseShareModule } from './features/base-share/base-share.module'; import { ShareModule } from './features/share/share.module'; import { SpaceModule } from './features/space/space.module'; import { TemplateOpenApiModule } from './features/template/template-open-api.module'; @@ -88,6 +89,7 @@ export const appModules = { NotificationModule, AccessTokenModule, ImportOpenApiModule, + AirtableImportModule, ExportOpenApiModule, PinModule, AdminOpenApiModule, diff --git a/apps/nestjs-backend/src/cache/types.ts b/apps/nestjs-backend/src/cache/types.ts index 942cc59623..9d02293e55 100644 --- a/apps/nestjs-backend/src/cache/types.ts +++ b/apps/nestjs-backend/src/cache/types.ts @@ -34,9 +34,11 @@ export interface ICacheStore { [key: `waitlist:invite-code:${string}`]: number; [key: `send-mail-rate-limit:${string}`]: boolean; [key: `oauth:token-rate:${string}:${string}`]: number; - [key: `automation:email:rate:${string}:${number}`]: number; + [key: `email:send:rate:${string}:${number}`]: number; [key: `automation:email-att:${string}`]: string[]; [key: `automation:fail-notify-count:${string}`]: number; + // Watchdog round-robin scan cursor per status (staleAt stored as ISO string). + [key: `automation:orphan-cursor:${string}`]: { staleAt: string; key: string }; // Distributed lock keys [key: `lock:${string}`]: string; [key: `import:result:manifest:${string}`]: { diff --git a/apps/nestjs-backend/src/configs/threshold.config.ts b/apps/nestjs-backend/src/configs/threshold.config.ts index 83a550b690..215b481a9b 100644 --- a/apps/nestjs-backend/src/configs/threshold.config.ts +++ b/apps/nestjs-backend/src/configs/threshold.config.ts @@ -20,6 +20,9 @@ export const thresholdConfig = registerAs('threshold', () => ({ bigTransactionTimeout: Number( process.env.BIG_TRANSACTION_TIMEOUT ?? 10 * 60 * 1000 /* 10 mins */ ), + // DB statement_timeout (ms) for the search query, so a slow / full-scan search is canceled + // and its connection released instead of being held for minutes. Tune via SEARCH_TIMEOUT. + searchTimeout: Number(process.env.SEARCH_TIMEOUT ?? 15_000 /* 15s */), automationGap: Number(process.env.AUTOMATION_GAP ?? 200), maxAttachmentUploadSize: Number(process.env.MAX_ATTACHMENT_UPLOAD_SIZE ?? Infinity), maxOpenapiAttachmentUploadSize: Number( diff --git a/apps/nestjs-backend/src/db-provider/filter-query/__tests__/numeric-text-filter.spec.ts b/apps/nestjs-backend/src/db-provider/filter-query/__tests__/numeric-text-filter.spec.ts new file mode 100644 index 0000000000..62ee78595c --- /dev/null +++ b/apps/nestjs-backend/src/db-provider/filter-query/__tests__/numeric-text-filter.spec.ts @@ -0,0 +1,68 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { + CellValueType, + DriverClient, + FieldType, + SingleLineTextFieldCore, + is, + isNot, +} from '@teable/core'; +import type { FieldCore, IFilter } from '@teable/core'; +import knex from 'knex'; +import type { IDbProvider } from '../../db.provider.interface'; +import { FilterQueryPostgres } from '../postgres/filter-query.postgres'; + +const knexBuilder = knex({ client: 'pg' }); +const dbProviderStub = { driver: DriverClient.Pg } as unknown as IDbProvider; + +function createTextField(id: string, dbFieldName: string): SingleLineTextFieldCore { + const field = new SingleLineTextFieldCore(); + field.id = id; + field.name = id; + field.dbFieldName = dbFieldName; + field.type = FieldType.SingleLineText; + field.options = SingleLineTextFieldCore.defaultOptions(); + field.cellValueType = CellValueType.String; + field.isMultipleCellValue = false; + field.isLookup = false; + field.updateDbFieldType(); + return field; +} + +function buildSql(field: FieldCore, filter: IFilter): string { + const qb = knexBuilder('main_table as main'); + new FilterQueryPostgres(qb, { [field.id]: field }, filter, undefined, dbProviderStub, { + selectionMap: new Map([[field.id, `"main"."${field.dbFieldName}"`]]), + }).appendQueryBuilder(); + return qb.toQuery().replace(/\s+/g, ' '); +} + +describe('numeric value on a text field filter', () => { + // A numeric-looking value (e.g. a tracking number) arrives as a JS number per + // literalValueSchema. Rendered to SQL it must be a quoted string, otherwise + // Postgres rejects "text = bigint". + const trackingNumber = 872985557030; + + it('renders "is" as a quoted string literal, not a bigint', () => { + const field = createTextField('fld_text', 'tracking_number'); + const filter: IFilter = { + conjunction: 'and', + filterSet: [{ fieldId: field.id, operator: is.value, value: trackingNumber }], + }; + + const sql = buildSql(field, filter); + expect(sql).toContain(`"main"."tracking_number" = '${trackingNumber}'`); + expect(sql).not.toMatch(new RegExp(`=\\s*${trackingNumber}(?!')`)); + }); + + it('renders "isNot" as a quoted string literal, not a bigint', () => { + const field = createTextField('fld_text', 'tracking_number'); + const filter: IFilter = { + conjunction: 'and', + filterSet: [{ fieldId: field.id, operator: isNot.value, value: trackingNumber }], + }; + + const sql = buildSql(field, filter); + expect(sql).toContain(`"main"."tracking_number" IS DISTINCT FROM '${trackingNumber}'`); + }); +}); diff --git a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/string-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/string-cell-value-filter.adapter.ts index be892185d5..99d64df3b7 100644 --- a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/string-cell-value-filter.adapter.ts +++ b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/string-cell-value-filter.adapter.ts @@ -1,5 +1,4 @@ import { - CellValueType, isFieldReferenceValue, type IFieldReferenceValue, type IFilterOperator, @@ -22,8 +21,10 @@ export class StringCellValueFilterAdapter extends CellValueFilterPostgres { builderClient.whereRaw(`${this.tableColumnRef} = ${ref}`); return builderClient; } - const parseValue = this.field.cellValueType === CellValueType.Number ? Number(value) : value; - builderClient.whereRaw(`${this.tableColumnRef} = ?`, [parseValue]); + // Column is text; coerce numeric/boolean literals to string so the comparison stays + // text = text. Otherwise a numeric value renders as a bigint literal and Postgres + // rejects it with "operator does not exist: text = bigint". + builderClient.whereRaw(`${this.tableColumnRef} = ?`, [String(value)]); return builderClient; } @@ -33,14 +34,12 @@ export class StringCellValueFilterAdapter extends CellValueFilterPostgres { value: ILiteralValue | IFieldReferenceValue, _dbProvider: IDbProvider ): Knex.QueryBuilder { - const { cellValueType } = this.field; if (isFieldReferenceValue(value)) { const ref = this.resolveFieldReference(value); builderClient.whereRaw(`${this.tableColumnRef} IS DISTINCT FROM ${ref}`); return builderClient; } - const parseValue = cellValueType === CellValueType.Number ? Number(value) : value; - builderClient.whereRaw(`${this.tableColumnRef} IS DISTINCT FROM ?`, [parseValue]); + builderClient.whereRaw(`${this.tableColumnRef} IS DISTINCT FROM ?`, [String(value)]); return builderClient; } diff --git a/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.spec.ts b/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.spec.ts index 069a3cf712..c932ed8176 100644 --- a/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.spec.ts +++ b/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.spec.ts @@ -22,6 +22,17 @@ const buildDateField = (): IFieldInstance => }, }) as IFieldInstance; +const buildMultipleSelectField = (): IFieldInstance => + ({ + id: 'fldMultiSelect0001', + dbFieldName: 'Tags', + cellValueType: CellValueType.String, + isMultipleCellValue: true, + isStructuredCellValue: false, + type: FieldType.MultipleSelect, + options: {}, + }) as IFieldInstance; + describe('SearchQueryPostgres', () => { const db = knex({ client: 'pg' }); @@ -52,4 +63,19 @@ describe('SearchQueryPostgres', () => { const compiled = builder.getQuery()?.toSQL(); expect(compiled?.sql).toBe('FALSE'); }); + + it('matches multipleSelect as a text cast so the gin_trgm index can be used', () => { + const field = buildMultipleSelectField(); + const builder = new SearchQueryPostgres( + db.queryBuilder(), + field, + ['Beta', 'fldMultiSelect0001'], + [] + ); + + const compiled = builder.getQuery()?.toSQL(); + expect(compiled?.sql).toContain('("Tags")::text ILIKE'); + expect(compiled?.sql).not.toContain('jsonb_array_elements'); + expect(compiled?.bindings).toEqual(['%Beta%']); + }); }); diff --git a/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.ts b/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.ts index 53283be572..20d514ae9c 100644 --- a/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.ts +++ b/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.ts @@ -97,9 +97,11 @@ export class SearchQueryPostgres extends SearchQueryAbstract { case CellValueType.String: { if (isStructuredCellValue) { return this.multipleJson(); - } else { - return this.multipleText(); } + if (field.type === FieldType.MultipleSelect) { + return this.multipleSelectText(); + } + return this.multipleText(); } case CellValueType.DateTime: { return this.multipleDate(); @@ -180,6 +182,16 @@ export class SearchQueryPostgres extends SearchQueryAbstract { ); } + // 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 the index. Trades negligible + // precision (JSON brackets/quotes become matchable) for index usage. + protected multipleSelectText() { + const { search, knex } = this; + const escapedSearchValue = escapeLikeWildcards(search[0]); + return knex.raw(`(${this.fieldName})::text ILIKE ? ESCAPE '\\'`, [`%${escapedSearchValue}%`]); + } + protected multipleNumber() { const { search, knex } = this; const searchValue = search[0]; diff --git a/apps/nestjs-backend/src/event-emitter/event-job/event-job.module.ts b/apps/nestjs-backend/src/event-emitter/event-job/event-job.module.ts index 51ec559dee..0ceda5de7e 100644 --- a/apps/nestjs-backend/src/event-emitter/event-job/event-job.module.ts +++ b/apps/nestjs-backend/src/event-emitter/event-job/event-job.module.ts @@ -15,6 +15,9 @@ const queueOptions: NestWorkerOptions = { }, }; +const isTestOrCI = process.env.CI || process.env.NODE_ENV === 'test' || process.env.VITEST; +const CONDITIONAL_MODULE_TIMEOUT = isTestOrCI ? 60000 : 5000; + @Module({ imports: [ConfigModule], }) @@ -26,11 +29,13 @@ export class EventJobModule { name, ...queueOptions, }), - (env) => Boolean(env.BACKEND_CACHE_REDIS_URI) + (env) => Boolean(env.BACKEND_CACHE_REDIS_URI), + { timeout: CONDITIONAL_MODULE_TIMEOUT } ), ConditionalModule.registerWhen( FallbackQueueModule.registerQueue(name), - (env) => !env.BACKEND_CACHE_REDIS_URI + (env) => !env.BACKEND_CACHE_REDIS_URI, + { timeout: CONDITIONAL_MODULE_TIMEOUT } ), ]); 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 6588e55e74..688450e277 100644 --- a/apps/nestjs-backend/src/event-emitter/events/event.enum.ts +++ b/apps/nestjs-backend/src/event-emitter/events/event.enum.ts @@ -51,6 +51,7 @@ export enum Events { SHARED_VIEW_CREATE = 'shared.view.create', SHARED_VIEW_DELETE = 'shared.view.delete', SHARED_VIEW_UPDATE = 'shared.view.update', + SHARED_VIEW_REFRESH = 'shared.view.refresh', USER_SIGNIN = 'user.signin', USER_SIGNUP = 'user.signup', @@ -67,6 +68,20 @@ export enum Events { COLLABORATOR_DELETE = 'collaborator.delete', COLLABORATOR_UPDATE = 'collaborator.update', + // Base-scope collaborator audit actions (parallel to the generic COLLABORATOR_* + // business events above, which are kept for internal pub/sub). Future space-level + // audit can mirror this with SPACE_COLLABORATOR_*. + BASE_COLLABORATOR_CREATE = 'base.collaborator.create', + BASE_COLLABORATOR_DELETE = 'base.collaborator.delete', + BASE_COLLABORATOR_UPDATE = 'base.collaborator.update', + + // Base/Node share lifecycle (covers both node-scoped and base-wide shares; + // payload.type distinguishes 'node' | 'base'). + BASE_SHARE_CREATE = 'base.share.create', + BASE_SHARE_UPDATE = 'base.share.update', + BASE_SHARE_DELETE = 'base.share.delete', + BASE_SHARE_REFRESH = 'base.share.refresh', + BASE_FOLDER_CREATE = 'base.folder.create', BASE_FOLDER_DELETE = 'base.folder.delete', BASE_FOLDER_UPDATE = 'base.folder.update', @@ -98,6 +113,7 @@ export enum Events { // completion point — past `lastChunk`, error-file write, and presence cleanup — // instead of racing against per-chunk audit emits. TABLE_IMPORT_FINISH = 'table.import.finish', + V2_TABLE_IMPORT_FINISH = 'v2.table.import.finish', // Composite-operation terminal signals. Currently only e2e tests subscribe; reserved // for future use by notifications / webhooks / billing. Each fires after the synchronous diff --git a/apps/nestjs-backend/src/event-emitter/listeners/action-trigger.listener.ts b/apps/nestjs-backend/src/event-emitter/listeners/action-trigger.listener.ts index 8030d2b54d..8e2dfd27fb 100644 --- a/apps/nestjs-backend/src/event-emitter/listeners/action-trigger.listener.ts +++ b/apps/nestjs-backend/src/event-emitter/listeners/action-trigger.listener.ts @@ -3,12 +3,10 @@ import { OnEvent } from '@nestjs/event-emitter'; import type { ITableActionKey, IGridColumn, IViewActionKey } from '@teable/core'; import { getActionTriggerChannel, OpName } from '@teable/core'; import { isEmpty } from 'lodash'; -import { ClsService } from 'nestjs-cls'; import { match } from 'ts-pattern'; -import { getV2CreateTableLegacyEventsFlag } from '../../features/v2/v2-create-table-compat.constants'; import { ShareDbService } from '../../share-db/share-db.service'; -import type { IClsStore } from '../../types/cls'; import type { + IChangeRecord, RecordCreateEvent, RecordDeleteEvent, RecordUpdateEvent, @@ -19,6 +17,26 @@ import type { } from '../events'; import { Events } from '../events'; +const collectChangedRecordFieldIds = (record: IChangeRecord | IChangeRecord[]): string[] => { + const records = Array.isArray(record) ? record : [record]; + const fieldIds = new Set(); + for (const changeRecord of records) { + for (const fieldId of Object.keys(changeRecord?.fields ?? {})) { + fieldIds.add(fieldId); + } + } + return [...fieldIds]; +}; + +const schemaRefreshFieldProperties = new Set(['type', 'options', 'dbFieldType']); + +const hasSchemaRefreshFieldChange = (field: FieldUpdateEvent['payload']['field']): boolean => { + const fields = Array.isArray(field) ? field : [field]; + return fields.some((changeField) => + Object.keys(changeField).some((key) => schemaRefreshFieldProperties.has(key)) + ); +}; + type IViewEvent = ViewUpdateEvent; type IRecordEvent = RecordCreateEvent | RecordDeleteEvent | RecordUpdateEvent; type IListenerEvent = @@ -37,10 +55,7 @@ export interface IActionTriggerData { export class ActionTriggerListener { private readonly logger = new Logger(ActionTriggerListener.name); - constructor( - private readonly shareDbService: ShareDbService, - private readonly cls: ClsService - ) {} + constructor(private readonly shareDbService: ShareDbService) {} @OnEvent(Events.TABLE_VIEW_UPDATE, { async: true }) @OnEvent(Events.TABLE_FIELD_UPDATE, { async: true }) @@ -48,13 +63,6 @@ export class ActionTriggerListener { @OnEvent(Events.TABLE_FIELD_DELETE, { async: true }) @OnEvent('table.record.*', { async: true }) private async listener(listenerEvent: IListenerEvent): Promise { - if ( - getV2CreateTableLegacyEventsFlag(this.cls) && - (this.isTableFieldCreateEvent(listenerEvent) || this.isTableRecordEvent(listenerEvent)) - ) { - return; - } - // Handling table view update events if (this.isTableViewUpdateEvent(listenerEvent)) { await this.handleTableViewUpdate(listenerEvent as ViewUpdateEvent); @@ -141,17 +149,21 @@ export class ActionTriggerListener { const { tableId } = event.payload; const buffer = match(event) - .returnType() - .with({ name: Events.TABLE_RECORD_CREATE }, () => ['addRecord']) - .with({ name: Events.TABLE_RECORD_UPDATE }, () => ['setRecord']) - .with({ name: Events.TABLE_RECORD_DELETE }, () => ['deleteRecord']) + .returnType() + .with({ name: Events.TABLE_RECORD_CREATE }, () => [{ actionKey: 'addRecord' as const }]) + .with({ name: Events.TABLE_RECORD_UPDATE }, (updateEvent) => [ + { + actionKey: 'setRecord' as const, + // changed cell field ids, letting field-aware listeners skip + // refreshes for irrelevant edits (same contract as the v2 emitter) + payload: { fieldIds: collectChangedRecordFieldIds(updateEvent.payload.record) }, + }, + ]) + .with({ name: Events.TABLE_RECORD_DELETE }, () => [{ actionKey: 'deleteRecord' as const }]) .otherwise(() => []); if (!isEmpty(buffer)) { - this.emitActionTrigger( - tableId, - buffer.map((actionKey) => ({ actionKey })) - ); + this.emitActionTrigger(tableId, buffer); } } @@ -178,9 +190,11 @@ export class ActionTriggerListener { } private isValidFieldUpdateOperation(event: FieldUpdateEvent): boolean | undefined { - const propertyKeys = ['options', 'dbFieldType']; const { propertyKey } = event.context.opMeta || {}; - return propertyKeys.includes(propertyKey as string); + return ( + schemaRefreshFieldProperties.has(propertyKey as string) || + hasSchemaRefreshFieldChange(event.payload.field) + ); } private isTableRecordEvent(event: IListenerEvent): boolean { diff --git a/apps/nestjs-backend/src/features/access-token/access-token.service.ts b/apps/nestjs-backend/src/features/access-token/access-token.service.ts index 2e3460af0b..70c1e24b44 100644 --- a/apps/nestjs-backend/src/features/access-token/access-token.service.ts +++ b/apps/nestjs-backend/src/features/access-token/access-token.service.ts @@ -142,6 +142,18 @@ export class AccessTokenService { @Audit({ action: Events.ACCESS_TOKEN_CREATE, resourceId: (input: { userId?: string }, ctx) => input.userId ?? ctx.cls.get('user.id')!, + userId: (input: { userId?: string }, ctx) => input.userId ?? ctx.cls.get('user.id'), + // Record the token's settings so the audit row shows what access was granted. NEVER the secret: + // the token `sign` is generated server-side and is not part of the input, so this is safe. + params: (input: CreateAccessTokenRo & { clientId?: string }) => ({ + name: input.name, + description: input.description, + scopes: input.scopes, + spaceIds: input.spaceIds, + baseIds: input.baseIds, + expiredTime: input.expiredTime, + hasFullAccess: input.hasFullAccess, + }), emit: true, }) async createAccessToken( diff --git a/apps/nestjs-backend/src/features/aggregation/aggregation.service.interface.ts b/apps/nestjs-backend/src/features/aggregation/aggregation.service.interface.ts index a016d80940..5c10c744cd 100644 --- a/apps/nestjs-backend/src/features/aggregation/aggregation.service.interface.ts +++ b/apps/nestjs-backend/src/features/aggregation/aggregation.service.interface.ts @@ -1,7 +1,7 @@ import type { IFilter, IGroup, ISortItem, StatisticsFunc } from '@teable/core'; import type { IAggregationField, - IQueryBaseRo, + IRowCountRo, IRawAggregationValue, IRawAggregations, IRawRowCountValue, @@ -60,7 +60,7 @@ export interface IAggregationService { * @param queryRo - Query parameters for filtering * @returns Promise - The row count result */ - performRowCount(tableId: string, queryRo: IQueryBaseRo): Promise; + performRowCount(tableId: string, queryRo: IRowCountRo): Promise; /** * Get field data for a table diff --git a/apps/nestjs-backend/src/features/aggregation/aggregation.service.ts b/apps/nestjs-backend/src/features/aggregation/aggregation.service.ts index c6f1d8ec10..02709a4727 100644 --- a/apps/nestjs-backend/src/features/aggregation/aggregation.service.ts +++ b/apps/nestjs-backend/src/features/aggregation/aggregation.service.ts @@ -1,5 +1,4 @@ import { Injectable, Logger } from '@nestjs/common'; -import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; import { CellValueType, HttpErrorCode, @@ -17,7 +16,7 @@ import { PrismaService } from '@teable/db-main-prisma'; import { StatisticsFunc } from '@teable/openapi'; import type { IAggregationField, - IQueryBaseRo, + IRowCountRo, IRawAggregationValue, IRawAggregations, IRawRowCountValue, @@ -501,7 +500,7 @@ export class AggregationService implements IAggregationService { * @returns Promise - The row count result * @throws NotImplementedException - This method is not yet implemented */ - async performRowCount(tableId: string, queryRo: IQueryBaseRo): Promise { + async performRowCount(tableId: string, queryRo: IRowCountRo): Promise { const { viewId, ignoreViewQuery, @@ -509,6 +508,7 @@ export class AggregationService implements IAggregationService { filterLinkCellSelected, selectedRecordIds, search, + projection, } = queryRo; // Retrieve the current user's ID to build user-related query conditions const currentUserId = this.cls.get('user.id'); @@ -534,6 +534,7 @@ export class AggregationService implements IAggregationService { filterLinkCellSelected, selectedRecordIds, search, + projection, withUserId: currentUserId, viewId: queryRo?.viewId, }); @@ -560,6 +561,7 @@ export class AggregationService implements IAggregationService { filterLinkCellSelected?: IGetRecordsRo['filterLinkCellSelected']; selectedRecordIds?: IGetRecordsRo['selectedRecordIds']; search?: [string, string?, boolean?]; + projection?: string[]; withUserId?: string; viewId?: string; }) { @@ -572,6 +574,7 @@ export class AggregationService implements IAggregationService { filterLinkCellSelected, selectedRecordIds, search, + projection, withUserId, viewId, } = params; @@ -605,10 +608,17 @@ export class AggregationService implements IAggregationService { ); if (search && search[2]) { + const enabledFieldIds = wrap.enabledFieldIds; + const searchProjection = projection + ? enabledFieldIds + ? projection.filter((fieldId) => enabledFieldIds.includes(fieldId)) + : projection + : enabledFieldIds; const searchFields = await this.recordService.getSearchFields( fieldInstanceMap, search, - viewId + viewId, + searchProjection ); const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId); qb.where((builder) => { @@ -1014,8 +1024,15 @@ export class AggregationService implements IAggregationService { this.logger.debug('getRecordIndexBySearchOrder sql: %s', sql); + const searchTimeout = this.thresholdConfig.searchTimeout; + try { return await this.withDataPrismaTransaction(tableId, async (prisma) => { + // Bound the search at the DB level: a short / CJK term can defeat the pg_trgm index and + // degrade to a full-table scan. SET LOCAL statement_timeout makes Postgres cancel the + // statement and release the pooled connection, instead of the client abandoning the + // request while the query keeps running and starves the connection pool. + await prisma.$executeRawUnsafe(`SET LOCAL statement_timeout = ${searchTimeout}`); const result = await prisma.$queryRawUnsafe<{ __id: string; fieldId: string }[]>(sql); // no result found @@ -1080,18 +1097,52 @@ export class AggregationService implements IAggregationService { recordId: item.__id, }; }); + // statement_timeout (above) is the real cap; give the JS-side tx timer headroom so + // Postgres cancels the statement first, instead of the data-tx default timeout firing early. }); } catch (error) { - if (error instanceof PrismaClientKnownRequestError && error.code === 'P2028') { - throw new CustomHttpException(`${error.message}`, HttpErrorCode.REQUEST_TIMEOUT, { - localization: { - i18nKey: 'httpErrors.aggregation.searchTimeOut', - }, - }); + if (this.isSearchTimeoutError(error)) { + throw new CustomHttpException( + `${(error as Error).message}`, + HttpErrorCode.REQUEST_TIMEOUT, + { + localization: { + i18nKey: 'httpErrors.aggregation.searchTimeOut', + }, + } + ); } throw error; } } + + /** + * Detects a search timeout from any of the layers involved. The search runs on the data-db + * Prisma client, whose error classes come from a *separate* generated runtime, so cross-package + * `instanceof` (PrismaClientKnownRequestError / TimeoutHttpException) is unreliable here — we + * duck-type on the error shape instead: + * - Postgres cancels the statement via `SET LOCAL statement_timeout` → SQLSTATE 57014, + * - Prisma's interactive-transaction timeout → code P2028, + * - the data-prisma proxy converts P2028 into a TimeoutHttpException → code 'request_timeout'. + * The DB-level cancellation (57014) is what actually releases the pooled connection. + */ + private isSearchTimeoutError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false; + } + const err = error as { code?: unknown; meta?: unknown; message?: unknown }; + const pgErrorCode = + typeof err.meta === 'object' && err.meta !== null + ? (err.meta as { code?: unknown }).code + : undefined; + const message = typeof err.message === 'string' ? err.message : ''; + return ( + err.code === 'P2028' || + err.code === 'request_timeout' || + pgErrorCode === '57014' || + /canceling statement due to statement timeout|Transaction already closed/i.test(message) + ); + } async getRecordIndex(tableId: string, queryRo: IRecordIndexRo): Promise { const { recordId } = queryRo; 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 7a152b2a86..86beba965b 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 @@ -18,10 +18,10 @@ import { groupPointsRoSchema, IAggregationRo, IGroupPointsRo, - IQueryBaseRo, + IRowCountRo, searchCountRoSchema, ISearchCountRo, - queryBaseSchema, + rowCountRoSchema, ICalendarDailyCollectionRo, ISearchIndexByQueryRo, searchIndexByQueryRoSchema, @@ -115,7 +115,7 @@ export class AggregationOpenApiController { @Permissions('table|read') async getRowCount( @Param('tableId') tableId: string, - @Query(new ZodValidationPipe(queryBaseSchema), TqlPipe) query?: IQueryBaseRo + @Query(new ZodValidationPipe(rowCountRoSchema), TqlPipe) query?: IRowCountRo ): Promise { return await this.getAggregationWithCache('row_count', tableId, query, () => this.aggregationOpenApiService.getRowCount(tableId, query) diff --git a/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.service.ts b/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.service.ts index aa28ad35a8..24145164f1 100644 --- a/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.service.ts +++ b/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.service.ts @@ -9,7 +9,7 @@ import type { ICalendarDailyCollectionVo, IGroupPointsRo, IGroupPointsVo, - IQueryBaseRo, + IRowCountRo, IRowCountVo, ISearchCountRo, IRecordIndexRo, @@ -69,7 +69,7 @@ export class AggregationOpenApiService { return { aggregations: result?.aggregations }; } - async getRowCount(tableId: string, query: IQueryBaseRo = {}): Promise { + async getRowCount(tableId: string, query: IRowCountRo = {}): Promise { const result = await this.aggregationService.performRowCount(tableId, query); return { rowCount: result.rowCount, diff --git a/apps/nestjs-backend/src/features/ai/util.ts b/apps/nestjs-backend/src/features/ai/util.ts index 60cb373dd0..17187eca81 100644 --- a/apps/nestjs-backend/src/features/ai/util.ts +++ b/apps/nestjs-backend/src/features/ai/util.ts @@ -88,28 +88,6 @@ const createOpenAICompatibleWrapper = ( }); }; -const createClaudeCodeWrapper = ( - options: Parameters[0] -): ReturnType => { - const baseFetch = createFixingFetch(); - const claudeCodeDefaultUa = 'claude-cli/2.1.71 (external, cli)'; - return createAnthropic({ - ...options, - fetch: async (input, init) => { - const initHeaders = (init?.headers ?? {}) as Record; - const ua = initHeaders['user-agent']; - return baseFetch(input, { - ...init, - headers: { - ...init?.headers, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'user-agent': ua?.includes('claude-cli') ? ua : claudeCodeDefaultUa, - }, - }); - }, - }); -}; - export const modelProviders = { [LLMProviderType.OPENAI]: createOpenAI, [LLMProviderType.ANTHROPIC]: createAnthropic, @@ -127,7 +105,6 @@ export const modelProviders = { [LLMProviderType.AMAZONBEDROCK]: createAmazonBedrock, [LLMProviderType.OPENROUTER]: createOpenRouter, [LLMProviderType.OPENAI_COMPATIBLE]: createOpenAICompatibleWrapper, - [LLMProviderType.CLAUDE_CODE]: createClaudeCodeWrapper, // AI_GATEWAY is handled separately in ai.service.ts using createGateway from 'ai' } as const; diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-api.client.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-api.client.ts new file mode 100644 index 0000000000..4366454fab --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-api.client.ts @@ -0,0 +1,168 @@ +import type { + IAirtableBaseItem, + IAirtableBaseSchemaResponse, + IAirtableListBasesResponse, + IAirtableListRecordsResponse, + IAirtableRecord, + IAirtableTable, +} from './airtable.types'; + +const airtableApiBaseUrl = 'https://api.airtable.com/v0'; +// Airtable allows 5 requests/second per base; stay slightly under it. +const minRequestIntervalMs = 220; +// Airtable documents a 30s penalty after a 429 response. +const rateLimitWaitMs = 30_000; +const maxRetries = 3; +const recordsPageSize = 100; + +export class AirtableApiError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly type?: string + ) { + super(message); + this.name = 'AirtableApiError'; + } +} + +/** The list-records pagination iterator expired (HTTP 422); listing must restart. */ +export class AirtableIteratorExpiredError extends AirtableApiError { + constructor() { + super('Airtable records iterator expired', 422, 'LIST_RECORDS_ITERATOR_NOT_AVAILABLE'); + this.name = 'AirtableIteratorExpiredError'; + } +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * The token is fetched per request so integration-backed imports keep working + * past the ~60 minute Airtable OAuth token lifetime (the provider refreshes + * server-side). + */ +export type IAirtableAccessTokenProvider = () => Promise | string; + +export class AirtableApiClient { + private lastRequestAt = 0; + + constructor(private readonly getAccessToken: IAirtableAccessTokenProvider) {} + + async listBases(): Promise { + const bases: IAirtableBaseItem[] = []; + let offset: string | undefined; + do { + const query = offset ? `?offset=${encodeURIComponent(offset)}` : ''; + const res = await this.request(`/meta/bases${query}`); + bases.push(...res.bases); + offset = res.offset; + } while (offset); + return bases; + } + + async getBaseSchema(airtableBaseId: string): Promise { + const res = await this.request( + `/meta/bases/${encodeURIComponent(airtableBaseId)}/tables` + ); + return res.tables; + } + + /** + * Yields pages of records (100 per page). Throws AirtableIteratorExpiredError + * when the pagination iterator expires; the caller may restart the listing + * and deduplicate already-processed records by id. + */ + async *listRecords(airtableBaseId: string, tableId: string): AsyncGenerator { + let offset: string | undefined; + do { + const params = new URLSearchParams({ + pageSize: String(recordsPageSize), + returnFieldsByFieldId: 'true', + cellFormat: 'json', + }); + if (offset) { + params.set('offset', offset); + } + const res = await this.request( + `/${encodeURIComponent(airtableBaseId)}/${encodeURIComponent(tableId)}?${params.toString()}` + ); + if (res.records.length > 0) { + yield res.records; + } + offset = res.offset; + } while (offset); + } + + private async throttle() { + const wait = this.lastRequestAt + minRequestIntervalMs - Date.now(); + if (wait > 0) { + await sleep(wait); + } + this.lastRequestAt = Date.now(); + } + + // eslint-disable-next-line sonarjs/cognitive-complexity + private async request(path: string): Promise { + let attempt = 0; + // Retry budget covers both rate-limit waits and transient network errors. + // eslint-disable-next-line no-constant-condition + while (true) { + await this.throttle(); + const accessToken = await this.getAccessToken(); + let response: Response; + try { + response = await fetch(`${airtableApiBaseUrl}${path}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + } catch (e) { + if (attempt >= maxRetries) { + throw new AirtableApiError( + `Failed to reach the Airtable API: ${e instanceof Error ? e.message : 'network error'}`, + 0 + ); + } + await sleep(1000 * 2 ** attempt); + attempt++; + continue; + } + + if (response.ok) { + return (await response.json()) as T; + } + + const errorBody = await this.parseError(response); + if (response.status === 429 && attempt < maxRetries) { + await sleep(rateLimitWaitMs); + attempt++; + continue; + } + if (response.status >= 500 && attempt < maxRetries) { + await sleep(1000 * 2 ** attempt); + attempt++; + continue; + } + if (response.status === 422 && errorBody.type === 'LIST_RECORDS_ITERATOR_NOT_AVAILABLE') { + throw new AirtableIteratorExpiredError(); + } + throw new AirtableApiError( + errorBody.message || `Airtable API request failed with status ${response.status}`, + response.status, + errorBody.type + ); + } + } + + private async parseError(response: Response): Promise<{ type?: string; message?: string }> { + try { + const body = (await response.json()) as { + error?: string | { type?: string; message?: string }; + }; + if (typeof body.error === 'string') { + return { type: body.error }; + } + return body.error ?? {}; + } catch { + return {}; + } + } +} diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-formula-translator.spec.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-formula-translator.spec.ts new file mode 100644 index 0000000000..05ac053296 --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-formula-translator.spec.ts @@ -0,0 +1,49 @@ +import { translateAirtableFormula } from './airtable-formula-translator'; + +const expr = (formula: string): string => { + const result = translateAirtableFormula(formula); + if (!result.ok) throw new Error(`expected ok, got: ${result.reason}`); + return result.expression; +}; + +describe('translateAirtableFormula', () => { + it('preserves field references, operators, and literals verbatim', () => { + expect(expr('{fldabTo0bWtgfSrxf}+1')).toBe('{fldabTo0bWtgfSrxf}+1'); + expect(expr('{fldA} & " - " & {fldB}')).toBe('{fldA} & " - " & {fldB}'); + expect(expr('({fldA} + {fldB}) / 2 >= 10')).toBe('({fldA} + {fldB}) / 2 >= 10'); + }); + + it('maps identical and renamed function names', () => { + expect(expr('CONCATENATE({fldA},"x")')).toBe('CONCATENATE({fldA},"x")'); + expect(expr('ARRAYJOIN({fldA},", ")')).toBe('ARRAY_JOIN({fldA},", ")'); + expect(expr('DATEADD({fldA},1,"days")')).toBe('DATE_ADD({fldA},1,"days")'); + expect(expr('REGEX_REPLACE({fldA},"a","b")')).toBe('REGEXP_REPLACE({fldA},"a","b")'); + expect(expr('ISERROR({fldA})')).toBe('IS_ERROR({fldA})'); + }); + + it('resolves function names case-insensitively to the Teable canonical name', () => { + expect(expr('if({fldA}>5,1,2)')).toBe('IF({fldA}>5,1,2)'); + }); + + it('converts TRUE()/FALSE() calls into boolean literals', () => { + expect(expr('IF({fldA},TRUE(),FALSE())')).toBe('IF({fldA},TRUE,FALSE)'); + }); + + it('does not translate text that merely looks like a function inside a string', () => { + expect(expr('"ARRAYJOIN(x)"')).toBe('"ARRAYJOIN(x)"'); + }); + + it('rejects unsupported functions so the importer can fall back to a snapshot', () => { + expect(translateAirtableFormula('REGEX_MATCH({fldA},"x")').ok).toBe(false); + expect(translateAirtableFormula('ARRAYSLICE({fldA},1,2)').ok).toBe(false); + }); + + it('rejects the unsupported "^" power operator', () => { + const result = translateAirtableFormula('{fldA}^2'); + expect(result.ok).toBe(false); + }); + + it('rejects an unrecognized bare identifier', () => { + expect(translateAirtableFormula('FOO').ok).toBe(false); + }); +}); diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-formula-translator.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-formula-translator.ts new file mode 100644 index 0000000000..f9a34798d9 --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-formula-translator.ts @@ -0,0 +1,253 @@ +/** + * Translates an Airtable formula into a Teable formula expression. + * + * The two languages are close enough to translate faithfully for a large common + * subset: both reference fields as `{fldXXX}`, share the same operators + * (`+ - * / & = != < > <= >= && ||`) and string/number/boolean literal syntax, + * and overlap heavily on function names. Translation therefore only needs to: + * - remap function names that differ (e.g. ARRAYJOIN → ARRAY_JOIN), + * - turn Airtable's `TRUE()` / `FALSE()` calls into Teable's TRUE / FALSE + * boolean literals, + * - and refuse anything it cannot represent (an unknown function, the `^` + * power operator Teable's grammar lacks) so the importer can fall back to a + * static value snapshot instead of emitting a broken formula. + * + * Field references are preserved verbatim as `{fldXXX}` (Airtable field ids); + * the import pipeline remaps those ids to the created Teable field ids, exactly + * as it does for every other field-id reference. + */ + +/** Airtable function name (upper-case) → Teable function name. */ +const airtableToTeableFunction: Record = { + // Numeric — identical names + ABS: 'ABS', + AVERAGE: 'AVERAGE', + CEILING: 'CEILING', + COUNT: 'COUNT', + COUNTA: 'COUNTA', + COUNTALL: 'COUNTALL', + EVEN: 'EVEN', + EXP: 'EXP', + FLOOR: 'FLOOR', + INT: 'INT', + LOG: 'LOG', + MAX: 'MAX', + MIN: 'MIN', + MOD: 'MOD', + ODD: 'ODD', + POWER: 'POWER', + ROUND: 'ROUND', + ROUNDDOWN: 'ROUNDDOWN', + ROUNDUP: 'ROUNDUP', + SQRT: 'SQRT', + SUM: 'SUM', + VALUE: 'VALUE', + + // Text — identical names + CONCATENATE: 'CONCATENATE', + ENCODE_URL_COMPONENT: 'ENCODE_URL_COMPONENT', + FIND: 'FIND', + LEFT: 'LEFT', + LEN: 'LEN', + LOWER: 'LOWER', + MID: 'MID', + REPLACE: 'REPLACE', + REPT: 'REPT', + RIGHT: 'RIGHT', + SEARCH: 'SEARCH', + SUBSTITUTE: 'SUBSTITUTE', + T: 'T', + TRIM: 'TRIM', + UPPER: 'UPPER', + // Text — renamed + REGEX_REPLACE: 'REGEXP_REPLACE', + + // Logical — identical names (TRUE/FALSE handled as literals, not here) + AND: 'AND', + BLANK: 'BLANK', + ERROR: 'ERROR', + IF: 'IF', + NOT: 'NOT', + OR: 'OR', + SWITCH: 'SWITCH', + XOR: 'XOR', + // Logical — renamed + ISERROR: 'IS_ERROR', + + // Date/time — identical names + CREATED_TIME: 'CREATED_TIME', + DATESTR: 'DATESTR', + DATETIME_DIFF: 'DATETIME_DIFF', + DATETIME_FORMAT: 'DATETIME_FORMAT', + DATETIME_PARSE: 'DATETIME_PARSE', + DAY: 'DAY', + FROMNOW: 'FROMNOW', + HOUR: 'HOUR', + IS_AFTER: 'IS_AFTER', + IS_BEFORE: 'IS_BEFORE', + IS_SAME: 'IS_SAME', + LAST_MODIFIED_TIME: 'LAST_MODIFIED_TIME', + MINUTE: 'MINUTE', + MONTH: 'MONTH', + NOW: 'NOW', + SECOND: 'SECOND', + SET_LOCALE: 'SET_LOCALE', + SET_TIMEZONE: 'SET_TIMEZONE', + TIMESTR: 'TIMESTR', + TODAY: 'TODAY', + TONOW: 'TONOW', + WEEKDAY: 'WEEKDAY', + WEEKNUM: 'WEEKNUM', + WORKDAY: 'WORKDAY', + WORKDAY_DIFF: 'WORKDAY_DIFF', + YEAR: 'YEAR', + // Date/time — renamed + DATEADD: 'DATE_ADD', + + // Array — renamed (Airtable drops the underscore) + ARRAYCOMPACT: 'ARRAY_COMPACT', + ARRAYFLATTEN: 'ARRAY_FLATTEN', + ARRAYJOIN: 'ARRAY_JOIN', + ARRAYUNIQUE: 'ARRAY_UNIQUE', + + // Record + RECORD_ID: 'RECORD_ID', +}; + +export type IFormulaTranslation = { ok: true; expression: string } | { ok: false; reason: string }; + +type ITokenType = 'string' | 'field' | 'number' | 'ident' | 'punct' | 'ws'; +interface IToken { + type: ITokenType; + raw: string; +} + +// Airtable function names and the operators we recognize are all ASCII; field +// names never reach the tokenizer (they are opaque `{...}` spans). +const isWhitespace = (c: string) => c === ' ' || c === '\t' || c === '\r' || c === '\n'; +const isDigit = (c: string) => c >= '0' && c <= '9'; +const isIdentStart = (c: string) => /[a-z_]/i.test(c); +const isIdentPart = (c: string) => /\w/.test(c); +const twoCharOperators = new Set(['!=', '<=', '>=', '&&', '||']); + +/** + * Splits a formula into structural tokens, keeping string and `{field}` spans + * opaque so their contents are never mistaken for operators or identifiers. + * Returns null on an unterminated string or field reference. + */ +// eslint-disable-next-line sonarjs/cognitive-complexity -- a flat lexer dispatch +const tokenize = (input: string): IToken[] | null => { + const tokens: IToken[] = []; + let i = 0; + while (i < input.length) { + const c = input[i]; + if (c === '"' || c === "'") { + let j = i + 1; + while (j < input.length && input[j] !== c) { + if (input[j] === '\\') j += 1; + j += 1; + } + if (j >= input.length) return null; + tokens.push({ type: 'string', raw: input.slice(i, j + 1) }); + i = j + 1; + } else if (c === '{') { + const end = input.indexOf('}', i + 1); + if (end < 0) return null; + tokens.push({ type: 'field', raw: input.slice(i, end + 1) }); + i = end + 1; + } else if (isWhitespace(c)) { + let j = i + 1; + while (j < input.length && isWhitespace(input[j])) j += 1; + tokens.push({ type: 'ws', raw: input.slice(i, j) }); + i = j; + } else if (isDigit(c) || (c === '.' && isDigit(input[i + 1] ?? ''))) { + let j = i + 1; + while (j < input.length && /[0-9.]/.test(input[j])) j += 1; + // optional exponent + if ((input[j] === 'e' || input[j] === 'E') && j < input.length) { + j += 1; + if (input[j] === '+' || input[j] === '-') j += 1; + while (j < input.length && isDigit(input[j])) j += 1; + } + tokens.push({ type: 'number', raw: input.slice(i, j) }); + i = j; + } else if (isIdentStart(c)) { + let j = i + 1; + while (j < input.length && isIdentPart(input[j])) j += 1; + tokens.push({ type: 'ident', raw: input.slice(i, j) }); + i = j; + } else { + const two = input.slice(i, i + 2); + if (twoCharOperators.has(two)) { + tokens.push({ type: 'punct', raw: two }); + i += 2; + } else { + tokens.push({ type: 'punct', raw: c }); + i += 1; + } + } + } + return tokens; +}; + +/** Index of the next non-whitespace token at or after `from`, or -1. */ +const nextSignificant = (tokens: IToken[], from: number): number => { + for (let k = from; k < tokens.length; k += 1) { + if (tokens[k].type !== 'ws') return k; + } + return -1; +}; + +// eslint-disable-next-line sonarjs/cognitive-complexity -- a flat token-rewrite loop +export const translateAirtableFormula = (formula: string): IFormulaTranslation => { + const trimmed = formula.trim(); + if (!trimmed) return { ok: false, reason: 'empty formula' }; + + const tokens = tokenize(formula); + if (!tokens) + return { ok: false, reason: 'unparseable formula (unterminated string or field reference)' }; + + const out: string[] = []; + for (let k = 0; k < tokens.length; k += 1) { + const token = tokens[k]; + if (token.type === 'punct') { + if (token.raw === '^') { + return { ok: false, reason: 'unsupported operator "^" (power)' }; + } + out.push(token.raw); + continue; + } + if (token.type !== 'ident') { + out.push(token.raw); + continue; + } + + const upper = token.raw.toUpperCase(); + const callParen = nextSignificant(tokens, k + 1); + const isCall = callParen >= 0 && tokens[callParen].raw === '('; + + if (upper === 'TRUE' || upper === 'FALSE') { + out.push(upper); + if (isCall) { + // Drop Airtable's empty `()`: TRUE() / FALSE() → TRUE / FALSE. + const closeParen = nextSignificant(tokens, callParen + 1); + if (closeParen < 0 || tokens[closeParen].raw !== ')') { + return { ok: false, reason: `${upper}() called with arguments` }; + } + k = closeParen; + } + continue; + } + + if (!isCall) { + return { ok: false, reason: `unrecognized name "${token.raw}"` }; + } + const mapped = airtableToTeableFunction[upper]; + if (!mapped) { + return { ok: false, reason: `unsupported function "${token.raw}"` }; + } + out.push(mapped); + } + + return { ok: true, expression: out.join('') }; +}; diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-import.controller.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-import.controller.ts new file mode 100644 index 0000000000..e55e430b80 --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-import.controller.ts @@ -0,0 +1,118 @@ +import { BadRequestException, Body, Controller, Logger, Post, Res } from '@nestjs/common'; +import { type Action } from '@teable/core'; +import { + importAirtableAnalyzeRoSchema, + importAirtableRoSchema, + type IImportAirtableAnalyzeRo, + type IImportAirtableAnalyzeVo, + type IImportAirtableRo, +} from '@teable/openapi'; +import { Response as ExpressResponse } from 'express'; +import { ClsService } from 'nestjs-cls'; +import type { IClsStore } from '../../types/cls'; +import { ZodValidationPipe } from '../../zod.validation.pipe'; +import { PermissionService } from '../auth/permission.service'; +import { AirtableApiError } from './airtable-api.client'; +import { AirtableImportService } from './airtable-import.service'; + +const formatAirtableImportError = (error: unknown): string => { + if (error instanceof AirtableApiError) { + if (error.status === 401) { + return 'Airtable rejected the access token. Check that the token is valid.'; + } + if (error.status === 403 || error.status === 404) { + return 'Airtable base not accessible. Grant the token access to the base and the "data.records:read" and "schema.bases:read" scopes.'; + } + return `Airtable API error: ${error.message}`; + } + return error instanceof Error && error.message ? error.message : 'Unknown import error'; +}; + +@Controller('api/base') +export class AirtableImportController { + private readonly logger = new Logger(AirtableImportController.name); + + constructor( + private readonly airtableImportService: AirtableImportService, + private readonly permissionService: PermissionService, + private readonly cls: ClsService + ) {} + + @Post('import-airtable/analyze') + async analyze( + @Body(new ZodValidationPipe(importAirtableAnalyzeRoSchema)) + analyzeRo: IImportAirtableAnalyzeRo + ): Promise { + try { + return await this.airtableImportService.analyze(analyzeRo); + } catch (error) { + if (error instanceof AirtableApiError) { + throw new BadRequestException(formatAirtableImportError(error)); + } + throw error; + } + } + + @Post('import-airtable/stream') + async importStream( + @Body(new ZodValidationPipe(importAirtableRoSchema)) + importAirtableRo: IImportAirtableRo, + @Res() res: ExpressResponse + ) { + // Authorize the real write target before doing anything: importing into an + // existing base needs table-import rights on THAT base — not base|create on + // whatever space the caller passes — while creating a new base needs + // base|create on the target space. (validPermissions intersects token scopes.) + const targetResourceId = importAirtableRo.baseId ?? importAirtableRo.spaceId; + if (!targetResourceId) { + // Unreachable via the zod schema (spaceId is required when baseId is absent), + // but keep the guard so the permission target is always a concrete resource. + throw new BadRequestException('Either baseId or spaceId is required.'); + } + const requiredPermissions: Action[] = importAirtableRo.baseId + ? ['base|table_import'] + : ['base|create']; + await this.permissionService.validPermissions( + targetResourceId, + requiredPermissions, + this.cls.get('accessTokenId') + ); + + const sseHeartbeatMs = 15_000; + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders(); + + const isStreamClosed = () => res.writableEnded || res.destroyed; + const sendEvent = (data: unknown) => { + if (isStreamClosed()) return; + res.write(`data: ${JSON.stringify(data)}\n\n`); + (res as ExpressResponse & { flush?: () => void }).flush?.(); + }; + const heartbeat = setInterval(() => { + if (isStreamClosed()) return; + res.write(': ping\n\n'); + (res as ExpressResponse & { flush?: () => void }).flush?.(); + }, sseHeartbeatMs); + res.on('close', () => clearInterval(heartbeat)); + + try { + const result = await this.airtableImportService.importBase(importAirtableRo, (progress) => { + sendEvent({ type: 'progress', ...progress }); + }); + sendEvent({ type: 'done', data: result }); + } catch (error) { + const reason = formatAirtableImportError(error); + this.logger.warn( + `[airtable-import] failed airtableBase=${importAirtableRo.airtableBaseId} ` + + `target=${importAirtableRo.baseId ?? 'new'} reason=${reason}` + ); + sendEvent({ type: 'error', message: reason }); + } finally { + clearInterval(heartbeat); + res.end(); + } + } +} diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-import.module.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-import.module.ts new file mode 100644 index 0000000000..3cb03287ea --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-import.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { AiModule } from '../ai/ai.module'; +import { AttachmentsModule } from '../attachments/attachments.module'; +import { StorageModule } from '../attachments/plugins/storage.module'; +import { PermissionModule } from '../auth/permission.module'; +import { BaseModule } from '../base/base.module'; +import { FieldOpenApiModule } from '../field/open-api/field-open-api.module'; +import { RecordOpenApiModule } from '../record/open-api/record-open-api.module'; +import { TableOpenApiModule } from '../table/open-api/table-open-api.module'; +import { ViewOpenApiModule } from '../view/open-api/view-open-api.module'; +import { AirtableImportController } from './airtable-import.controller'; +import { AirtableImportService } from './airtable-import.service'; + +@Module({ + imports: [ + BaseModule, + TableOpenApiModule, + RecordOpenApiModule, + FieldOpenApiModule, + ViewOpenApiModule, + AttachmentsModule, + StorageModule, + AiModule, + PermissionModule, + ], + controllers: [AirtableImportController], + providers: [AirtableImportService], + exports: [AirtableImportService], +}) +export class AirtableImportModule {} diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-import.service.spec.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-import.service.spec.ts new file mode 100644 index 0000000000..14e73f548e --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-import.service.spec.ts @@ -0,0 +1,88 @@ +import { FieldType, Relationship } from '@teable/core'; +import type { IImportAirtableIssue } from '@teable/openapi'; +import { describe, expect, it } from 'vitest'; +import { AirtableImportService } from './airtable-import.service'; +import type { IPlannedDirectField, IPlannedLinkField } from './airtable-schema-mapper'; + +const service = new AirtableImportService( + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + undefined +); + +const linkField = (prefersSingle: boolean): IPlannedLinkField => ({ + airtableFieldId: 'fldLink', + teableFieldId: 'fldTeable0000000001', + name: 'Link', + airtableForeignTableId: 'tblForeign', + prefersSingle, +}); + +describe('AirtableImportService.decideRelationship', () => { + it('follows the declared Airtable relationship', () => { + expect(service['decideRelationship'](linkField(true))).toBe(Relationship.ManyOne); + expect(service['decideRelationship'](linkField(false))).toBe(Relationship.ManyMany); + }); +}); + +describe('AirtableImportService.applyAiConfig', () => { + const plannedAiField = (): IPlannedDirectField => ({ + airtableFieldId: 'fldAi', + converter: 'aiText', + aiPromptParts: [ + { text: 'Summarize ' }, + { airtableFieldId: 'fldTitle', fieldName: 'Title' }, + { airtableFieldId: 'fldGone', fieldName: 'Gone' }, + ], + ro: { id: 'fldTeableAi00000001', name: 'Summary', type: FieldType.LongText, options: {} }, + }); + const fieldIdMap = { fldTitle: 'fldTeableTitle00001' }; + + it('builds a customization AI config with mapped field references', () => { + const issues: IImportAirtableIssue[] = []; + const ro = service['applyAiConfig']( + plannedAiField(), + 'openai@gpt@main', + fieldIdMap, + 'T', + issues + ); + expect(ro.aiConfig).toMatchObject({ + type: 'customization', + modelKey: 'openai@gpt@main', + prompt: 'Summarize {fldTeableTitle00001}Gone', + isAutoFill: false, + }); + expect(issues).toHaveLength(0); + }); + + it('keeps the snapshot and reports when no AI model is configured', () => { + const issues: IImportAirtableIssue[] = []; + const ro = service['applyAiConfig'](plannedAiField(), undefined, fieldIdMap, 'T', issues); + expect(ro.aiConfig).toBeUndefined(); + expect(issues[0]).toMatchObject({ + code: 'fieldDegraded', + fieldName: 'Summary', + fromType: 'aiText', + reason: 'no AI model is configured', + }); + }); + + it('passes non-AI fields through untouched', () => { + const issues: IImportAirtableIssue[] = []; + const planned: IPlannedDirectField = { + airtableFieldId: 'fldText', + converter: 'string', + ro: { name: 'Plain', type: FieldType.SingleLineText, options: {} }, + }; + expect(service['applyAiConfig'](planned, 'model@x@y', {}, 'T', issues)).toBe(planned.ro); + expect(issues).toHaveLength(0); + }); +}); 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 new file mode 100644 index 0000000000..8b55e0cd14 --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-import.service.ts @@ -0,0 +1,1628 @@ +import { Readable } from 'node:stream'; +import { BadRequestException, Inject, Injectable, Logger, Optional } from '@nestjs/common'; +import type { + IColumnMetaRo, + IFieldRo, + IFilter, + ILinkFieldOptions, + IUserFieldOptions, + IViewOptions, + ViewType, + CellValueType, +} from '@teable/core'; +import { FieldAIActionType, FieldKeyType, FieldType, Relationship } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import type { + IImportAirtableAnalyzeRo, + IImportAirtableAnalyzeVo, + IImportAirtableIssue, + IImportAirtableRo, + IImportAirtableVo, +} from '@teable/openapi'; +import { CollaboratorType, PrincipalType, UploadType } from '@teable/openapi'; +import { AiService } from '../ai/ai.service'; +import { AttachmentsService } from '../attachments/attachments.service'; +import StorageAdapter from '../attachments/plugins/adapter'; +import { InjectStorageAdapter } from '../attachments/plugins/storage'; +import { BaseService } from '../base/base.service'; +import { FieldOpenApiV2Service } from '../field/open-api/field-open-api-v2.service'; +import { RecordOpenApiV2Service } from '../record/open-api/record-open-api-v2.service'; +import { TableOpenApiV2Service } from '../table/open-api/table-open-api-v2.service'; +import { ViewOpenApiService } from '../view/open-api/view-open-api.service'; +import { AirtableApiClient, AirtableIteratorExpiredError } from './airtable-api.client'; +import { AirtableLinkRowSpill, type ISpilledLinkRow } from './airtable-link-spill'; +import { + convertAirtableCellValue, + convertCollaboratorCellValue, + extractLinkedRecordIds, + type IResolvedSpaceUser, +} from './airtable-record-converter'; +import type { + IAirtableImportPlan, + IAirtableTablePlan, + IPlannedDirectField, + IPlannedFormulaField, + IPlannedLinkField, +} from './airtable-schema-mapper'; +import { buildAirtableImportPlan } from './airtable-schema-mapper'; +import { AirtableShareClient, AirtableShareError } from './airtable-share.client'; +import { + AIRTABLE_IMPORT_TOKEN_RESOLVER, + type IAirtableImportTokenResolver, +} from './airtable-token-resolver'; +import { + mapAirtableFilter, + mapAirtableViewConfig, + type IImportFieldMeta, + type IViewConfigMapperContext, +} from './airtable-view-config-mapper'; +import type { IAirtableAttachment, IAirtableRecord, IAirtableTable } from './airtable.types'; + +export interface IAirtableImportProgress { + phase: string; + detail?: string; + tableName?: string; + tableIndex?: number; + totalTables?: number; + processedRows?: number; +} + +export type IAirtableImportProgressReporter = (progress: IAirtableImportProgress) => void; + +const linkUpdateBatchSize = 100; +const attachmentConcurrency = 3; +const maxListRestarts = 2; +const unknownErrorText = 'unknown error'; + +interface ILinkFieldRuntime { + plan: IPlannedLinkField; + tableAirtableId: string; + relationship: Relationship; +} + +/** A created Teable view paired with the Airtable view its config comes from. */ +interface IViewConfigTarget { + airtableViewId: string; + teableViewId: string; + teableViewType: ViewType; + tableId: string; + tableName: string; + 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); + + constructor( + private readonly baseService: BaseService, + private readonly tableOpenApiV2Service: TableOpenApiV2Service, + private readonly recordOpenApiV2Service: RecordOpenApiV2Service, + private readonly fieldOpenApiV2Service: FieldOpenApiV2Service, + private readonly viewOpenApiService: ViewOpenApiService, + private readonly attachmentsService: AttachmentsService, + private readonly prismaService: PrismaService, + private readonly aiService: AiService, + @InjectStorageAdapter() private readonly storageAdapter: StorageAdapter, + @Optional() + @Inject(AIRTABLE_IMPORT_TOKEN_RESOLVER) + private readonly tokenResolver?: IAirtableImportTokenResolver + ) {} + + /** + * Stages link-cell spill parts in the deployment's blob storage + * (local/S3/MinIO), mirroring how the .tea import streams its data files — + * no container-local temp files. + */ + private createLinkSpill(): AirtableLinkRowSpill { + const bucket = StorageAdapter.getBucket(UploadType.Import); + return new AirtableLinkRowSpill({ + upload: async (path, data) => { + await this.storageAdapter.uploadFileStream(bucket, path, data); + }, + download: async (path) => { + const stream = await this.storageAdapter.downloadFile(bucket, path); + return stream instanceof Readable + ? stream + : // eslint-disable-next-line @typescript-eslint/no-explicit-any + Readable.fromWeb(stream as any); + }, + cleanup: async (dir) => { + await this.storageAdapter.deleteDir(bucket, dir); + }, + }); + } + + /** + * Builds the per-request access token provider. With an integrationId the + * token stays server-side and is refreshed by the resolver; a raw + * accessToken (direct API usage) is used as-is. + */ + private createClient(ro: { integrationId?: string; accessToken?: string }): AirtableApiClient { + if (ro.integrationId) { + const resolver = this.tokenResolver; + if (!resolver) { + throw new BadRequestException('Airtable integrations are not available on this instance'); + } + const integrationId = ro.integrationId; + return new AirtableApiClient(() => resolver.resolveAccessToken(integrationId)); + } + if (!ro.accessToken) { + throw new BadRequestException('Either integrationId or accessToken is required'); + } + const accessToken = ro.accessToken; + return new AirtableApiClient(() => accessToken); + } + + async analyze(ro: IImportAirtableAnalyzeRo): Promise { + const client = this.createClient(ro); + if (!ro.airtableBaseId) { + const bases = await client.listBases(); + return { + bases: bases + .filter((base) => base.permissionLevel !== 'none') + .map(({ id, name, permissionLevel }) => ({ id, name, permissionLevel })), + }; + } + + const tables = await client.getBaseSchema(ro.airtableBaseId); + const plan = buildAirtableImportPlan(tables); + return { + base: { + id: ro.airtableBaseId, + tables: tables.map((table) => ({ + id: table.id, + name: table.name, + fieldCount: table.fields.length, + viewCount: table.views.length, + })), + issues: plan.issues, + }, + }; + } + + // eslint-disable-next-line sonarjs/cognitive-complexity -- orchestrates the linear import pipeline + async importBase( + ro: IImportAirtableRo, + onProgress?: IAirtableImportProgressReporter + ): Promise { + const importRecords = ro.importRecords ?? true; + const importAttachments = ro.importAttachments ?? true; + const importViewConfig = ro.importViewConfig === true; + const progress = (event: IAirtableImportProgress) => onProgress?.(event); + const client = this.createClient(ro); + const startedAt = Date.now(); + + progress({ phase: 'fetching_schema' }); + const airtableTables = await client.getBaseSchema(ro.airtableBaseId); + this.logger.log( + `[airtable-import] start airtableBase=${ro.airtableBaseId} target=${ro.baseId ?? 'new'} ` + + `tables=${airtableTables.length} source=${ro.integrationId ? 'integration' : 'pat'} ` + + `records=${importRecords} attachments=${importAttachments} viewConfig=${importViewConfig}` + ); + + // Resolve and validate the shared-base link up front (fail fast on a wrong or + // private link) and read the base model it unlocks, which carries the rollup + // aggregations the official API never exposes — so rollups can be recreated + // live instead of snapshotted. + const shareClient = importViewConfig ? await this.resolveShareClient(ro) : undefined; + const rollupSources = shareClient ? await shareClient.fetchApplicationModel() : undefined; + + const plan = buildAirtableImportPlan(airtableTables, rollupSources); + const issues: IImportAirtableIssue[] = [...plan.issues]; + const totalTables = plan.tables.length; + + // Import into an existing base (adding its tables) or create a fresh one. + // spaceId is only needed to create a new base; for an existing base the + // base's own space is used (see user-field resolution below). + let base; + if (ro.baseId) { + base = await this.baseService.getBaseById(ro.baseId); + } else { + if (!ro.spaceId) { + throw new BadRequestException('spaceId is required when baseId is not provided.'); + } + progress({ phase: 'creating_base', detail: ro.baseName }); + base = await this.baseService.createBase({ + spaceId: ro.spaceId, + name: ro.baseName ?? 'Imported base', + }); + } + const aiModelKey = await this.resolveAiModelKey(base.id); + + // airtable table id -> created teable table id + const tableIdMap: Record = {}; + const tableViewIds: Record = {}; + // airtable view id -> created teable view id; used for a link's + // "limit record selection to a view", regardless of importViewConfig. + const viewIdMap: Record = {}; + const viewTargets: IViewConfigTarget[] = []; + for (const [index, tablePlan] of plan.tables.entries()) { + progress({ + phase: 'creating_table', + tableName: tablePlan.name, + tableIndex: index + 1, + totalTables, + }); + const table = await this.tableOpenApiV2Service.createTable(base.id, { + name: tablePlan.name, + description: tablePlan.description, + fieldKeyType: FieldKeyType.Id, + fields: tablePlan.fields.map((field) => + this.applyAiConfig(field, aiModelKey, plan.fieldIdMap, tablePlan.name, issues) + ), + views: tablePlan.views, + records: [], + }); + tableIdMap[tablePlan.airtableTableId] = table.id; + tableViewIds[table.id] = (table.views ?? []).map((view) => view.id); + tablePlan.viewSources.forEach((source, viewIndex) => { + const createdView = (table.views ?? [])[viewIndex]; + if (createdView?.type === source.teableViewType) { + viewIdMap[source.airtableViewId] = createdView.id; + } + }); + if (importViewConfig) { + this.collectViewTargets(tablePlan, table.id, table.views ?? [], viewTargets); + } + } + + // Complete the table structure before data with link fields only — they + // follow the relationship Airtable declares. Derived fields (lookups, + // counts) and view configuration are applied AFTER the records, so a field + // that cannot be computed degrades to a reported issue instead of breaking + // record/link writes and aborting the whole import. + progress({ phase: 'creating_links' }); + const linkRuntimes = await this.createLinkFields({ plan, tableIdMap, viewIdMap, issues }); + + // Memory bounds for large bases: record pages, inserts, attachment + // transfers and link writes are all streamed/batched. The only data kept + // for the whole run is the old->new record id map of tables that links + // point at (needed to remap link cells); buffered link rows spill to the + // blob storage. + const linkTargetTables = new Set( + plan.tables.flatMap((table) => table.linkFields.map((link) => link.airtableForeignTableId)) + ); + const recordIdMaps = new Map>(); + // Single-link fields whose data turned out to hold several links — relaxed + // to many-to-many before fill so no link is dropped (Airtable's single-link + // is a soft per-cell preference, not an enforced 1:1). + const linkFieldsWithMulti = new Set(); + const linkSpill = this.createLinkSpill(); + + try { + if (importRecords) { + // Resolve user fields against the base's actual space, not ro.spaceId — + // for an existing-base import the two can differ (ro.spaceId is optional + // there), and the base's members are the correct mapping target. + const usersByEmail = await this.getSpaceUsersByEmail(base.spaceId); + for (const [index, tablePlan] of plan.tables.entries()) { + await this.importTableRecords({ + client, + ro, + tablePlan, + tableIndex: index + 1, + totalTables, + tableId: tableIdMap[tablePlan.airtableTableId], + usersByEmail, + importAttachments, + recordIdMaps, + linkSpill, + linkFieldsWithMulti, + issues, + progress, + }); + if (!linkTargetTables.has(tablePlan.airtableTableId)) { + // The map was only needed for in-table restart deduplication. + recordIdMaps.delete(tablePlan.airtableTableId); + } + } + + await this.relaxOversizedSingleLinks({ + plan, + linkRuntimes, + linkFieldsWithMulti, + tableIdMap, + issues, + }); + + await this.fillLinkValues({ + plan, + tableIdMap, + linkRuntimes, + recordIdMaps, + linkSpill, + issues, + progress, + }); + } + } finally { + await linkSpill.cleanup(); + } + + // Derived fields are computed over the imported data; create them last so a + // single uncomputable field is reported, never fatal to the whole import. + await this.createLookupFields(plan, tableIdMap, issues); + await this.createCountFields(plan, tableIdMap, issues); + await this.createRollupFields(plan, tableIdMap, airtableTables, issues); + await this.createFormulaFields(plan, tableIdMap, issues); + + // Restore Airtable's field order: links and derived fields are created in + // later phases and would otherwise sit at the end. Rewrite each view's + // column order to the source table's field order so the layout matches. + await this.reorderFields({ + airtableTables, + tableIdMap, + tableViewIds, + fieldIdMap: plan.fieldIdMap, + }); + + // View configuration last: every field now exists, so sorts/groups that + // reference a lookup or count resolve too. + if (shareClient) { + await this.applyViewConfigs({ + shareClient, + airtableTables, + plan, + viewTargets, + issues, + progress, + }); + } + + this.logImportOutcome(ro, base.id, tableIdMap, plan.fieldIdMap, issues, startedAt); + progress({ phase: 'import_done', detail: base.name }); + return { + base, + tableIdMap, + fieldIdMap: plan.fieldIdMap, + issues, + }; + } + + /** + * Emits structured, collectable logs of an import's outcome: one summary line + * (counts + issue breakdown + duration) and one line per issue (which field / + * view degraded or was skipped, and why). Skips are `warn`, degrades are `log`, + * so a log pipeline can alert on the former and aggregate both by type/reason. + */ + /** + * Restores the source field order. Plain fields are created in order with the + * table, but links and derived fields (lookup/count/rollup/formula) are added + * in later phases and land at the end; this rewrites every view's column order + * to the Airtable table's field order. Best-effort — a failure is logged, not + * fatal, since the data is already imported. + */ + private async reorderFields(params: { + airtableTables: IAirtableTable[]; + tableIdMap: Record; + tableViewIds: Record; + fieldIdMap: Record; + }): Promise { + const { airtableTables, tableIdMap, tableViewIds, fieldIdMap } = params; + for (const airtableTable of airtableTables) { + const tableId = tableIdMap[airtableTable.id]; + const viewIds = tableViewIds[tableId] ?? []; + if (!tableId || viewIds.length === 0) continue; + const columnMetaRo = airtableTable.fields + .map((field) => fieldIdMap[field.id]) + .filter((id): id is string => Boolean(id)) + .map((fieldId, order) => ({ fieldId, columnMeta: { order } })); + if (columnMetaRo.length === 0) continue; + for (const viewId of viewIds) { + try { + await this.viewOpenApiService.updateViewColumnMeta( + tableId, + viewId, + columnMetaRo as IColumnMetaRo + ); + } catch (error) { + this.logger.warn( + `[airtable-import] reorder failed for ${airtableTable.name}: ${ + error instanceof Error ? error.message : 'error' + }` + ); + } + } + } + } + + private logImportOutcome( + ro: IImportAirtableRo, + baseId: string, + tableIdMap: Record, + fieldIdMap: Record, + issues: IImportAirtableIssue[], + startedAt: number + ): void { + const byCode = issues.reduce>((acc, issue) => { + acc[issue.code] = (acc[issue.code] ?? 0) + 1; + return acc; + }, {}); + this.logger.log( + `[airtable-import] done base=${baseId} airtableBase=${ro.airtableBaseId} ` + + `tables=${Object.keys(tableIdMap).length} fields=${Object.keys(fieldIdMap).length} ` + + `issues=${issues.length} byCode=${JSON.stringify(byCode)} durationMs=${Date.now() - startedAt}` + ); + for (const issue of issues) { + const target = `${issue.tableName}/${issue.fieldName ?? issue.viewName ?? '?'}`; + const change = issue.toType ? ` -> ${issue.toType}` : issue.reason ? `: ${issue.reason}` : ''; + const line = `[airtable-import] ${issue.code} base=${baseId} ${target} (from=${issue.fromType ?? '?'}${change})`; + if (issue.code === 'fieldSkipped' || issue.code === 'viewSkipped') { + this.logger.warn(line); + } else { + this.logger.log(line); + } + } + } + + /** + * Resolves the AI model used for imported Airtable aiText fields from the + * base's AI configuration (space integration or instance settings). Returns + * undefined when no AI model is configured. + */ + private async resolveAiModelKey(baseId: string): Promise { + try { + const config = await this.aiService.getAIConfig(baseId); + return config.chatModel?.lg || undefined; + } catch { + return undefined; + } + } + + /** + * Turns an Airtable aiText field into a Teable AI field (custom prompt with + * field references) when an AI model is available; otherwise keeps the + * plain long-text snapshot and reports the degradation. + */ + private applyAiConfig( + planned: IPlannedDirectField, + aiModelKey: string | undefined, + fieldIdMap: Record, + tableName: string, + issues: IImportAirtableIssue[] + ): IFieldRo { + if (!planned.aiPromptParts) { + return planned.ro; + } + const prompt = planned.aiPromptParts + .map((part) => { + if (part.text != null) return part.text; + const teableFieldId = part.airtableFieldId && fieldIdMap[part.airtableFieldId]; + if (teableFieldId) return `{${teableFieldId}}`; + return part.fieldName ?? ''; + }) + .join(''); + + if (!aiModelKey || !prompt.trim()) { + issues.push({ + code: 'fieldDegraded', + tableName, + fieldName: planned.ro.name as string, + fromType: 'aiText', + toType: 'longText snapshot', + reason: aiModelKey ? 'the AI prompt is empty' : 'no AI model is configured', + }); + return planned.ro; + } + + return { + ...planned.ro, + aiConfig: { + type: FieldAIActionType.Customization, + modelKey: aiModelKey, + prompt, + // Keep imported snapshot values; users can enable auto-fill later to + // avoid triggering a generation for every imported record. + isAutoFill: false, + }, + } as IFieldRo; + } + + private async resolveShareClient(ro: IImportAirtableRo): Promise { + const shareClient = new AirtableShareClient(); + try { + await shareClient.resolveShare(ro.shareLink ?? ''); + shareClient.assertBaseMatch(ro.airtableBaseId); + } catch (error) { + if (error instanceof AirtableShareError) { + throw new BadRequestException(error.message); + } + throw error; + } + return shareClient; + } + + /** + * Pairs each created Teable view with the Airtable view it came from. The v2 + * create-table mapper preserves view order, so the i-th created view matches + * the i-th planned source; the type guard skips any unexpected drift. + */ + private collectViewTargets( + tablePlan: IAirtableTablePlan, + tableId: string, + createdViews: Array<{ id: string; type: ViewType; name: string }>, + targets: IViewConfigTarget[] + ) { + tablePlan.viewSources.forEach((source, index) => { + const created = createdViews[index]; + if (!created || created.type !== source.teableViewType) return; + targets.push({ + airtableViewId: source.airtableViewId, + teableViewId: created.id, + teableViewType: source.teableViewType, + tableId, + tableName: tablePlan.name, + viewName: (tablePlan.views[index]?.name as string) ?? created.name, + }); + }); + } + + /** + * Reads each view's configuration from the shared base and applies the mapped + * filter/sort/grouping/options. Every view and every aspect is isolated so a + * single failure degrades to an issue without affecting the rest. + */ + private async applyViewConfigs(params: { + shareClient: AirtableShareClient; + airtableTables: IAirtableTable[]; + plan: IAirtableImportPlan; + viewTargets: IViewConfigTarget[]; + issues: IImportAirtableIssue[]; + progress: (event: IAirtableImportProgress) => void; + }) { + const { shareClient, airtableTables, plan, viewTargets, issues, progress } = params; + if (viewTargets.length === 0) return; + progress({ phase: 'applying_view_config' }); + + const optionNames = this.buildSelectOptionNames(airtableTables); + const fieldMetaByTable = new Map>(); + + for (const target of viewTargets) { + try { + const config = await shareClient.fetchViewConfig(target.airtableViewId); + let metaMap = fieldMetaByTable.get(target.tableId); + if (!metaMap) { + metaMap = await this.fetchFieldMeta(target.tableId); + fieldMetaByTable.set(target.tableId, metaMap); + } + const ctx: IViewConfigMapperContext = { + resolveField: (columnId) => { + const teableFieldId = plan.fieldIdMap[columnId]; + return teableFieldId ? metaMap?.get(teableFieldId) : undefined; + }, + resolveSelectOptionName: (columnId, optionId) => optionNames.get(columnId)?.get(optionId), + }; + const mapped = mapAirtableViewConfig({ + teableViewType: target.teableViewType, + config, + ctx, + tableName: target.tableName, + viewName: target.viewName, + issues, + }); + await this.applyMappedViewConfig(target, mapped, issues); + } catch (error) { + this.logger.warn( + `Failed to import view config for "${target.viewName}": ${ + error instanceof Error ? error.message : unknownErrorText + }` + ); + issues.push({ + code: 'viewConfigDegraded', + tableName: target.tableName, + viewName: target.viewName, + reason: 'could not read the view configuration from the shared base', + }); + } + } + } + + private async applyMappedViewConfig( + target: IViewConfigTarget, + mapped: ReturnType, + issues: IImportAirtableIssue[] + ) { + const apply = async (label: string, run: () => Promise) => { + try { + await run(); + } catch (error) { + this.logger.warn( + `Failed to apply ${label} to view "${target.viewName}": ${ + error instanceof Error ? error.message : unknownErrorText + }` + ); + issues.push({ + code: 'viewConfigDegraded', + tableName: target.tableName, + viewName: target.viewName, + reason: `could not apply ${label}`, + }); + } + }; + + if (mapped.filter) { + await apply('filters', () => + this.viewOpenApiService.setViewProperty( + target.tableId, + target.teableViewId, + 'filter', + mapped.filter + ) + ); + } + if (mapped.sort) { + await apply('sorting', () => + this.viewOpenApiService.setViewProperty( + target.tableId, + target.teableViewId, + 'sort', + mapped.sort + ) + ); + } + if (mapped.group) { + await apply('grouping', () => + this.viewOpenApiService.setViewProperty( + target.tableId, + target.teableViewId, + 'group', + mapped.group + ) + ); + } + if (mapped.options) { + await apply('view options', () => + this.viewOpenApiService.patchViewOptions( + target.tableId, + target.teableViewId, + mapped.options as IViewOptions + ) + ); + } + } + + /** airtable select field id -> (option id -> option name) for filter mapping. */ + private buildSelectOptionNames(tables: IAirtableTable[]): Map> { + const map = new Map>(); + const selectFields = tables.flatMap((table) => + table.fields.filter( + (field) => field.type === 'singleSelect' || field.type === 'multipleSelects' + ) + ); + for (const field of selectFields) { + const choices = field.options?.choices; + if (!Array.isArray(choices)) continue; + const byId = new Map(); + for (const choice of choices) { + if (choice?.id && typeof choice.name === 'string') byId.set(choice.id, choice.name); + } + map.set(field.id, byId); + } + return map; + } + + private async fetchFieldMeta(tableId: string): Promise> { + const fields = await this.prismaService.field.findMany({ + where: { tableId, deletedTime: null }, + select: { id: true, type: true, cellValueType: true, isMultipleCellValue: true }, + }); + return new Map( + fields.map((field) => [ + field.id, + { + fieldId: field.id, + type: field.type as FieldType, + cellValueType: field.cellValueType as CellValueType, + isMultipleCellValue: field.isMultipleCellValue === true, + }, + ]) + ); + } + + private async getSpaceUsersByEmail(spaceId: string): Promise> { + const collaborators = await this.prismaService.collaborator.findMany({ + where: { + resourceId: spaceId, + resourceType: CollaboratorType.Space, + principalType: PrincipalType.User, + }, + select: { principalId: true }, + }); + if (collaborators.length === 0) { + return new Map(); + } + const users = await this.prismaService.user.findMany({ + where: { + id: { in: collaborators.map(({ principalId }) => principalId) }, + deletedTime: null, + }, + select: { id: true, name: true, email: true }, + }); + return new Map( + users.map((user) => [ + user.email.toLowerCase(), + { id: user.id, name: user.name, email: user.email }, + ]) + ); + } + + // eslint-disable-next-line sonarjs/cognitive-complexity + private async importTableRecords(params: { + client: AirtableApiClient; + ro: IImportAirtableRo; + tablePlan: IAirtableTablePlan; + tableIndex: number; + totalTables: number; + tableId: string; + usersByEmail: Map; + importAttachments: boolean; + recordIdMaps: Map>; + linkSpill: AirtableLinkRowSpill; + linkFieldsWithMulti: Set; + issues: IImportAirtableIssue[]; + progress: (event: IAirtableImportProgress) => void; + }) { + const { + client, + ro, + tablePlan, + tableIndex, + totalTables, + tableId, + usersByEmail, + importAttachments, + recordIdMaps, + linkSpill, + linkFieldsWithMulti, + issues, + progress, + } = params; + + progress({ + phase: 'table_records_start', + tableName: tablePlan.name, + tableIndex, + totalTables, + }); + + const recordIdMap = new Map(); + recordIdMaps.set(tablePlan.airtableTableId, recordIdMap); + const droppedCollaborators = new Map(); + const failedAttachments = new Map(); + let processedRows = 0; + let restarts = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + try { + for await (const page of client.listRecords(ro.airtableBaseId, tablePlan.airtableTableId)) { + // After an iterator restart, skip already imported records. + const records = page.filter((record) => !recordIdMap.has(record.id)); + if (records.length === 0) continue; + + const payloads = await this.buildRecordPayloads({ + tablePlan, + records, + usersByEmail, + importAttachments, + droppedCollaborators, + failedAttachments, + }); + // Teable assigns each create batch a descending order, which flips the + // rows in the view. Send the batch reversed so records keep the source + // order, then map results back by their original index. + const created = await this.recordOpenApiV2Service.createRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + typecast: true, + records: payloads.slice().reverse(), + }); + const createdInOrder = created.records.slice().reverse(); + records.forEach((record, recordIndex) => { + const createdRecord = createdInOrder[recordIndex]; + if (createdRecord) { + recordIdMap.set(record.id, createdRecord.id); + } + }); + await this.collectLinkRows({ + tablePlan, + records, + createdIds: createdInOrder.map((record) => record?.id), + linkSpill, + linkFieldsWithMulti, + }); + + processedRows += records.length; + progress({ + phase: 'table_records_progress', + tableName: tablePlan.name, + tableIndex, + totalTables, + processedRows, + }); + } + break; + } catch (error) { + if (error instanceof AirtableIteratorExpiredError && restarts < maxListRestarts) { + restarts++; + this.logger.warn( + `Airtable list-records iterator expired for table ${tablePlan.airtableTableId}, restarting (${restarts}/${maxListRestarts})` + ); + continue; + } + throw error; + } + } + + for (const [fieldName, count] of droppedCollaborators) { + issues.push({ + code: 'valuesDropped', + tableName: tablePlan.name, + fieldName, + count, + reason: 'collaborator is not a member of the space', + }); + } + for (const [fieldName, { count, firstError }] of failedAttachments) { + issues.push({ + code: 'valuesDropped', + tableName: tablePlan.name, + fieldName, + count, + reason: `attachment download failed: ${firstError}`, + }); + } + + progress({ + phase: 'table_records_done', + tableName: tablePlan.name, + tableIndex, + totalTables, + processedRows, + }); + } + + private async buildRecordPayloads(params: { + tablePlan: IAirtableTablePlan; + records: IAirtableRecord[]; + usersByEmail: Map; + importAttachments: boolean; + droppedCollaborators: Map; + failedAttachments: Map; + }): Promise }>> { + const { + tablePlan, + records, + usersByEmail, + importAttachments, + droppedCollaborators, + failedAttachments, + } = params; + + // eslint-disable-next-line sonarjs/cognitive-complexity + const payloads = records.map((record) => { + const fields: Record = {}; + for (const planned of tablePlan.fields) { + const raw = record.fields[planned.airtableFieldId]; + if (raw == null) continue; + const fieldId = planned.ro.id as string; + if (planned.converter === 'user') { + const isMultiple = (planned.ro.options as IUserFieldOptions)?.isMultiple === true; + const { value, droppedCount } = convertCollaboratorCellValue( + raw, + usersByEmail, + isMultiple + ); + if (droppedCount > 0) { + const name = planned.ro.name as string; + droppedCollaborators.set(name, (droppedCollaborators.get(name) ?? 0) + droppedCount); + } + if (value !== undefined) fields[fieldId] = value; + continue; + } + if (planned.converter === 'attachment') { + continue; // handled below, needs async upload + } + const value = convertAirtableCellValue(planned.converter, raw); + if (value !== undefined) fields[fieldId] = value; + } + return { fields }; + }); + + if (importAttachments) { + const attachmentFields = tablePlan.fields.filter( + (planned) => planned.converter === 'attachment' + ); + const cells: Array<{ + payloadIndex: number; + fieldId: string; + fieldName: string; + attachments: IAirtableAttachment[]; + }> = []; + records.forEach((record, payloadIndex) => { + for (const planned of attachmentFields) { + const raw = record.fields[planned.airtableFieldId]; + if (!Array.isArray(raw) || raw.length === 0) continue; + cells.push({ + payloadIndex, + fieldId: planned.ro.id as string, + fieldName: planned.ro.name as string, + attachments: raw as IAirtableAttachment[], + }); + } + }); + + await mapWithConcurrency(cells, attachmentConcurrency, async (cell) => { + const items = []; + for (const attachment of cell.attachments) { + if (typeof attachment?.url !== 'string') continue; + try { + items.push(await this.transferAttachment(attachment)); + } catch (error) { + const message = error instanceof Error ? error.message : unknownErrorText; + this.logger.warn( + `Failed to migrate Airtable attachment "${attachment.filename}": ${message}` + ); + const failure = failedAttachments.get(cell.fieldName); + failedAttachments.set(cell.fieldName, { + count: (failure?.count ?? 0) + 1, + firstError: failure?.firstError ?? message.slice(0, 200), + }); + } + } + if (items.length > 0) { + payloads[cell.payloadIndex].fields[cell.fieldId] = items; + } + }); + } + + return payloads; + } + + /** + * Streams an attachment straight from the Airtable CDN into the storage + * backend - no temp file. Airtable provides the size up front, which the + * presigned upload requires. + */ + private async transferAttachment(attachment: IAirtableAttachment) { + // Airtable gives the size up front, so reject an over-limit attachment + // before opening the CDN socket at all. + const maxSize = this.attachmentsService.thresholdConfig.maxOpenapiAttachmentUploadSize; + if (attachment.size && attachment.size > maxSize) { + throw new Error( + `attachment "${attachment.filename}" (${attachment.size} bytes) exceeds the ${maxSize}-byte upload limit` + ); + } + const response = await fetch(attachment.url); + if (!response.ok || !response.body) { + throw new Error(`download failed with status ${response.status}`); + } + const contentLength = attachment.size ?? Number(response.headers.get('content-length')); + if (!contentLength || !Number.isFinite(contentLength)) { + await response.body.cancel(); + throw new Error('attachment size is unknown'); + } + const contentType = + attachment.type ?? response.headers.get('content-type') ?? 'application/octet-stream'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stream = Readable.fromWeb(response.body as any); + try { + return await this.attachmentsService.uploadFromStream( + stream, + { filename: attachment.filename, contentType, contentLength }, + UploadType.Table + ); + } catch (error) { + // Release the still-open CDN socket on any upload failure (e.g. the + // size-limit check) instead of leaking it. + stream.destroy(); + throw error; + } + } + + private async collectLinkRows(params: { + tablePlan: IAirtableTablePlan; + records: IAirtableRecord[]; + createdIds: Array; + linkSpill: AirtableLinkRowSpill; + linkFieldsWithMulti: Set; + }) { + const { tablePlan, records, createdIds, linkSpill, linkFieldsWithMulti } = params; + if (tablePlan.linkFields.length === 0) return; + + const rows: ISpilledLinkRow[] = []; + records.forEach((record, index) => { + const teableRecordId = createdIds[index]; + if (!teableRecordId) return; + const cells: ISpilledLinkRow['cells'] = []; + for (const linkField of tablePlan.linkFields) { + const ids = extractLinkedRecordIds(record.fields[linkField.airtableFieldId]); + if (ids.length === 0) continue; + // Airtable's single-link is a soft per-cell limit, so a "single" field's + // data can still hold several links; remember those so the field is + // relaxed to many-to-many instead of truncating them. + if (ids.length > 1) linkFieldsWithMulti.add(linkField.airtableFieldId); + cells.push({ airtableFieldId: linkField.airtableFieldId, ids }); + } + if (cells.length > 0) { + rows.push({ teableRecordId, cells }); + } + }); + await linkSpill.append(tablePlan.airtableTableId, rows); + } + + /** + * Follows the relationship Airtable declares. One-to-* variants are not + * used because foreign-side uniqueness cannot be guaranteed before the + * records arrive; cells that violate a single link are truncated at fill + * time and reported. + */ + private decideRelationship(linkField: IPlannedLinkField): Relationship { + return linkField.prefersSingle ? Relationship.ManyOne : Relationship.ManyMany; + } + + // eslint-disable-next-line sonarjs/cognitive-complexity + private async createLinkFields(params: { + plan: IAirtableImportPlan; + tableIdMap: Record; + viewIdMap: Record; + issues: IImportAirtableIssue[]; + }): Promise> { + const { plan, tableIdMap, viewIdMap, issues } = params; + const runtimes = new Map(); + + for (const tablePlan of plan.tables) { + const tableId = tableIdMap[tablePlan.airtableTableId]; + for (const linkField of tablePlan.linkFields) { + const relationship = this.decideRelationship(linkField); + // Airtable's "limit record selection to a view" -> Teable filterByViewId. + // The referenced view lives in the foreign table; skip (and report) when it + // did not import so an invalid view id is never set. + let filterByViewId: string | undefined; + if (linkField.viewIdForRecordSelection) { + filterByViewId = viewIdMap[linkField.viewIdForRecordSelection]; + if (!filterByViewId) { + issues.push({ + code: 'viewConfigDegraded', + tableName: tablePlan.name, + fieldName: linkField.name, + reason: 'record-selection view was not imported', + }); + } + } + const fieldRo = { + id: linkField.teableFieldId, + name: linkField.name, + description: linkField.description, + type: FieldType.Link, + options: { + relationship, + foreignTableId: tableIdMap[linkField.airtableForeignTableId], + isOneWay: linkField.inverse == null, + ...(filterByViewId ? { filterByViewId } : {}), + }, + } as IFieldRo; + try { + const created = await this.fieldOpenApiV2Service.createField(tableId, fieldRo); + plan.fieldIdMap[linkField.airtableFieldId] = created.id; + runtimes.set(linkField.airtableFieldId, { + plan: linkField, + tableAirtableId: tablePlan.airtableTableId, + relationship, + }); + + const symmetricFieldId = (created.options as ILinkFieldOptions)?.symmetricFieldId; + if (linkField.inverse && symmetricFieldId) { + plan.fieldIdMap[linkField.inverse.airtableFieldId] = symmetricFieldId; + try { + await this.renameSymmetricLinkField( + tableIdMap[linkField.airtableForeignTableId], + symmetricFieldId, + linkField.inverse.name + ); + } catch (error) { + this.logger.warn( + `Failed to rename symmetric link field to "${linkField.inverse.name}": ${ + error instanceof Error ? error.message : unknownErrorText + }` + ); + } + } + } catch (error) { + this.logger.warn( + `Failed to create link field "${linkField.name}": ${ + error instanceof Error ? error.message : unknownErrorText + }` + ); + issues.push({ + code: 'fieldSkipped', + tableName: tablePlan.name, + fieldName: linkField.name, + fromType: 'multipleRecordLinks', + reason: 'failed to create link field', + }); + } + } + } + return runtimes; + } + + /** + * Renames the symmetric link field Teable auto-creates for a two-way link to + * the Airtable inverse field's name. A plain field update drops the owning + * link field; convert keeps the link by re-specifying its full options. + */ + private async renameSymmetricLinkField(tableId: string, fieldId: string, name: string) { + const symmetric = await this.fieldOpenApiV2Service.getField(tableId, fieldId); + await this.fieldOpenApiV2Service.convertField(tableId, fieldId, { + name, + type: symmetric.type, + options: symmetric.options, + } as Parameters[2]); + } + + // eslint-disable-next-line sonarjs/cognitive-complexity + private async createLookupFields( + plan: IAirtableImportPlan, + tableIdMap: Record, + issues: IImportAirtableIssue[] + ) { + for (const tablePlan of plan.tables) { + const tableId = tableIdMap[tablePlan.airtableTableId]; + for (const lookupField of tablePlan.lookupFields) { + const foreignPlan = plan.tables.find( + (candidate) => candidate.airtableTableId === lookupField.airtableForeignTableId + ); + const targetField = foreignPlan?.fields.find( + (candidate) => candidate.airtableFieldId === lookupField.airtableTargetFieldId + ); + const linkFieldId = plan.fieldIdMap[lookupField.airtableLinkFieldId]; + const lookupFieldId = plan.fieldIdMap[lookupField.airtableTargetFieldId]; + if (!targetField || !linkFieldId || !lookupFieldId) { + issues.push({ + code: 'fieldSkipped', + tableName: tablePlan.name, + fieldName: lookupField.name, + fromType: 'multipleLookupValues', + reason: 'link or target field was not imported', + }); + continue; + } + const fieldRo = { + id: lookupField.teableFieldId, + name: lookupField.name, + description: lookupField.description, + type: targetField.ro.type, + isLookup: true, + lookupOptions: { + foreignTableId: tableIdMap[lookupField.airtableForeignTableId], + linkFieldId, + lookupFieldId, + }, + } as IFieldRo; + try { + await this.fieldOpenApiV2Service.createField(tableId, fieldRo); + } catch (error) { + this.logger.warn( + `Failed to create lookup field "${lookupField.name}": ${ + error instanceof Error ? error.message : unknownErrorText + }` + ); + issues.push({ + code: 'fieldSkipped', + tableName: tablePlan.name, + fieldName: lookupField.name, + fromType: 'multipleLookupValues', + reason: `failed to create lookup field: ${ + error instanceof Error ? error.message : unknownErrorText + }`, + }); + } + } + } + } + + // eslint-disable-next-line sonarjs/cognitive-complexity + private async createCountFields( + plan: IAirtableImportPlan, + tableIdMap: Record, + issues: IImportAirtableIssue[] + ) { + for (const tablePlan of plan.tables) { + const tableId = tableIdMap[tablePlan.airtableTableId]; + for (const countField of tablePlan.countFields) { + const foreignPlan = plan.tables.find( + (candidate) => candidate.airtableTableId === countField.airtableForeignTableId + ); + const linkFieldId = plan.fieldIdMap[countField.airtableLinkFieldId]; + const foreignPrimary = foreignPlan?.fields[0]; + if (!linkFieldId || !foreignPrimary) { + issues.push({ + code: 'fieldSkipped', + tableName: tablePlan.name, + fieldName: countField.name, + fromType: 'count', + reason: 'link field was not imported', + }); + continue; + } + const fieldRo = { + id: countField.teableFieldId, + name: countField.name, + description: countField.description, + type: FieldType.Rollup, + options: { expression: 'countall({values})' }, + lookupOptions: { + foreignTableId: tableIdMap[countField.airtableForeignTableId], + linkFieldId, + lookupFieldId: foreignPrimary.ro.id as string, + }, + } as IFieldRo; + try { + await this.fieldOpenApiV2Service.createField(tableId, fieldRo); + } catch (error) { + this.logger.warn( + `Failed to create count field "${countField.name}": ${ + error instanceof Error ? error.message : unknownErrorText + }` + ); + issues.push({ + code: 'fieldSkipped', + tableName: tablePlan.name, + fieldName: countField.name, + fromType: 'count', + reason: `failed to create rollup field: ${ + error instanceof Error ? error.message : unknownErrorText + }`, + }); + } + } + } + } + + /** + * Recreates rollups as live Teable rollups using the aggregation and optional + * record-selection filter read from the shared base model. The filter is mapped + * best-effort — conditions that cannot be translated are dropped (and reported) + * so the rollup stays live rather than degrading to a snapshot. + */ + // eslint-disable-next-line sonarjs/cognitive-complexity -- per-rollup filter mapping + private async createRollupFields( + plan: IAirtableImportPlan, + tableIdMap: Record, + airtableTables: IAirtableTable[], + issues: IImportAirtableIssue[] + ) { + const rollups = plan.tables.flatMap((tablePlan) => + tablePlan.rollupFields.map((rollupField) => ({ tablePlan, rollupField })) + ); + if (rollups.length === 0) return; + const optionNames = this.buildSelectOptionNames(airtableTables); + + for (const { tablePlan, rollupField } of rollups) { + const linkFieldId = plan.fieldIdMap[rollupField.airtableLinkFieldId]; + const lookupFieldId = plan.fieldIdMap[rollupField.airtableForeignFieldId]; + const foreignTableId = tableIdMap[rollupField.airtableForeignTableId]; + if (!linkFieldId || !lookupFieldId || !foreignTableId) { + issues.push({ + code: 'fieldSkipped', + tableName: tablePlan.name, + fieldName: rollupField.name, + fromType: 'rollup', + reason: 'link or rolled-up field was not imported', + }); + continue; + } + + let filter: IFilter | undefined; + if (rollupField.filter) { + const metaMap = await this.fetchFieldMeta(foreignTableId); + let dropped = 0; + const ctx: IViewConfigMapperContext = { + resolveField: (columnId) => { + const teableFieldId = plan.fieldIdMap[columnId]; + return teableFieldId ? metaMap.get(teableFieldId) : undefined; + }, + resolveSelectOptionName: (columnId, optionId) => optionNames.get(columnId)?.get(optionId), + }; + filter = mapAirtableFilter(rollupField.filter, ctx, () => { + dropped += 1; + }); + if (dropped > 0) { + issues.push({ + code: 'fieldDegraded', + tableName: tablePlan.name, + fieldName: rollupField.name, + fromType: 'rollup', + toType: `live rollup (${dropped} filter condition(s) dropped)`, + }); + } + } + + const fieldRo = { + id: rollupField.teableFieldId, + name: rollupField.name, + description: rollupField.description, + type: FieldType.Rollup, + options: { expression: rollupField.expression }, + lookupOptions: { + foreignTableId, + linkFieldId, + lookupFieldId, + ...(filter ? { filter } : {}), + }, + } as IFieldRo; + try { + await this.fieldOpenApiV2Service.createField( + tableIdMap[tablePlan.airtableTableId], + fieldRo + ); + } catch (error) { + this.logger.warn( + `Failed to create rollup field "${rollupField.name}": ${ + error instanceof Error ? error.message : unknownErrorText + }` + ); + issues.push({ + code: 'fieldSkipped', + tableName: tablePlan.name, + fieldName: rollupField.name, + fromType: 'rollup', + reason: `failed to create rollup field: ${ + error instanceof Error ? error.message : unknownErrorText + }`, + }); + } + } + } + + /** + * Creates the translated formula fields last, after every other field exists. + * A formula may reference another formula, so fields are created in dependency + * passes: each pass retries the ones that failed (typically because a + * referenced formula did not exist yet) until a pass makes no progress. + */ + // eslint-disable-next-line sonarjs/cognitive-complexity -- dependency-pass retry loop + private async createFormulaFields( + plan: IAirtableImportPlan, + tableIdMap: Record, + issues: IImportAirtableIssue[] + ) { + const remapReferences = (expression: string) => + expression.replace(/\{(fld[a-zA-Z0-9]+)\}/g, (token, airtableFieldId) => { + const teableFieldId = plan.fieldIdMap[airtableFieldId]; + return teableFieldId ? `{${teableFieldId}}` : token; + }); + + let pending = plan.tables.flatMap((tablePlan) => + tablePlan.formulaFields.map((formulaField) => ({ tablePlan, formulaField })) + ); + while (pending.length > 0) { + const failed: { + tablePlan: IAirtableTablePlan; + formulaField: IPlannedFormulaField; + error: unknown; + }[] = []; + for (const { tablePlan, formulaField } of pending) { + const fieldRo = { + id: formulaField.teableFieldId, + name: formulaField.name, + description: formulaField.description, + type: FieldType.Formula, + options: { expression: remapReferences(formulaField.expression) }, + } as IFieldRo; + try { + await this.fieldOpenApiV2Service.createField( + tableIdMap[tablePlan.airtableTableId], + fieldRo + ); + } catch (error) { + failed.push({ tablePlan, formulaField, error }); + } + } + if (failed.length === pending.length) { + // No progress this pass: the remaining failures are real, report them. + for (const { tablePlan, formulaField, error } of failed) { + this.logger.warn( + `Failed to create formula field "${formulaField.name}": ${ + error instanceof Error ? error.message : unknownErrorText + }` + ); + issues.push({ + code: 'fieldSkipped', + tableName: tablePlan.name, + fieldName: formulaField.name, + fromType: 'formula', + reason: `failed to create formula field: ${ + error instanceof Error ? error.message : unknownErrorText + }`, + }); + } + break; + } + pending = failed.map(({ tablePlan, formulaField }) => ({ tablePlan, formulaField })); + } + } + + /** + * Airtable's "single record link" is a soft per-cell limit, not enforced + * uniqueness — a single-link field's data can still hold several links (e.g. + * the side of a 1:1 that several records point at). Teable's ManyOne would + * truncate those, so relax any single link whose data actually held multiple + * values to a many-to-many link (lossless) and report it, instead of dropping + * links. Runs before fill, while the field is still empty, so the conversion + * is trivially safe; a failed conversion falls back to truncate-and-report. + */ + private async relaxOversizedSingleLinks(params: { + plan: IAirtableImportPlan; + linkRuntimes: Map; + linkFieldsWithMulti: Set; + tableIdMap: Record; + issues: IImportAirtableIssue[]; + }): Promise { + const { plan, linkRuntimes, linkFieldsWithMulti, tableIdMap, issues } = params; + const tableNameByAirtableId = new Map(plan.tables.map((t) => [t.airtableTableId, t.name])); + for (const runtime of linkRuntimes.values()) { + if (runtime.relationship !== Relationship.ManyOne) continue; + if (!linkFieldsWithMulti.has(runtime.plan.airtableFieldId)) continue; + const tableId = tableIdMap[runtime.tableAirtableId]; + try { + await this.fieldOpenApiV2Service.convertField(tableId, runtime.plan.teableFieldId, { + name: runtime.plan.name, + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: tableIdMap[runtime.plan.airtableForeignTableId], + isOneWay: runtime.plan.inverse == null, + }, + } as Parameters[2]); + runtime.relationship = Relationship.ManyMany; + issues.push({ + code: 'fieldDegraded', + tableName: tableNameByAirtableId.get(runtime.tableAirtableId) ?? '', + fieldName: runtime.plan.name, + fromType: 'multipleRecordLinks', + toType: 'many-to-many link', + reason: 'single-link data contained multiple links; kept them all', + }); + } catch (error) { + // Keep ManyOne — fillLinkValues truncates the over-capacity cells and + // reports them, so the import still succeeds. + this.logger.warn( + `[airtable-import] could not relax single link "${runtime.plan.name}": ${ + error instanceof Error ? error.message : 'error' + }` + ); + } + } + } + + // eslint-disable-next-line sonarjs/cognitive-complexity + private async fillLinkValues(params: { + plan: IAirtableImportPlan; + tableIdMap: Record; + linkRuntimes: Map; + recordIdMaps: Map>; + linkSpill: AirtableLinkRowSpill; + issues: IImportAirtableIssue[]; + progress: (event: IAirtableImportProgress) => void; + }) { + const { plan, tableIdMap, linkRuntimes, recordIdMaps, linkSpill, issues, progress } = params; + + for (const tablePlan of plan.tables) { + if (tablePlan.linkFields.length === 0) continue; + const tableId = tableIdMap[tablePlan.airtableTableId]; + // Only write link fields that actually exist; one that failed to + // materialize is skipped and reported, never fatal to the import. + const existingFieldIds = new Set((await this.fetchFieldMeta(tableId)).keys()); + const droppedByField = new Map(); + const truncatedByField = new Map(); + const missingByField = new Set(); + let processedRows = 0; + let batch: Array<{ id: string; fields: Record }> = []; + + const flush = async () => { + if (batch.length === 0) return; + await this.recordOpenApiV2Service.updateRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + typecast: false, + records: batch, + }); + batch = []; + progress({ phase: 'filling_links', tableName: tablePlan.name, processedRows }); + }; + + // Rows stream back from the disk spill, so memory stays flat no matter + // how many link cells the base contains. + for await (const row of linkSpill.read(tablePlan.airtableTableId)) { + processedRows++; + const fields: Record = {}; + for (const cell of row.cells) { + const runtime = linkRuntimes.get(cell.airtableFieldId); + const teableFieldId = plan.fieldIdMap[cell.airtableFieldId]; + if (!runtime || !teableFieldId) continue; + if (!existingFieldIds.has(teableFieldId)) { + missingByField.add(runtime.plan.name); + continue; + } + const foreignMap = recordIdMaps.get(runtime.plan.airtableForeignTableId); + const mappedIds = cell.ids + .map((id) => foreignMap?.get(id)) + .filter((id): id is string => id != null); + const droppedCount = cell.ids.length - mappedIds.length; + if (droppedCount > 0) { + droppedByField.set( + runtime.plan.name, + (droppedByField.get(runtime.plan.name) ?? 0) + droppedCount + ); + } + if (mappedIds.length === 0) continue; + const isSingle = runtime.relationship === Relationship.ManyOne; + if (isSingle && mappedIds.length > 1) { + // The Airtable field declared single links but the data disagrees. + truncatedByField.set( + runtime.plan.name, + (truncatedByField.get(runtime.plan.name) ?? 0) + mappedIds.length - 1 + ); + } + fields[teableFieldId] = isSingle ? { id: mappedIds[0] } : mappedIds.map((id) => ({ id })); + } + if (Object.keys(fields).length > 0) { + batch.push({ id: row.teableRecordId, fields }); + if (batch.length >= linkUpdateBatchSize) { + await flush(); + } + } + } + await flush(); + + for (const [fieldName, count] of droppedByField) { + issues.push({ + code: 'valuesDropped', + tableName: tablePlan.name, + fieldName, + count, + reason: 'linked record not found', + }); + } + for (const [fieldName, count] of truncatedByField) { + issues.push({ + code: 'valuesDropped', + tableName: tablePlan.name, + fieldName, + count, + reason: 'kept only the first record of a single-link field', + }); + } + for (const fieldName of missingByField) { + issues.push({ + code: 'fieldSkipped', + tableName: tablePlan.name, + fieldName, + fromType: 'multipleRecordLinks', + reason: 'link field was not created; its values were skipped', + }); + } + } + } +} diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-link-spill.spec.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-link-spill.spec.ts new file mode 100644 index 0000000000..a87510792c --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-link-spill.spec.ts @@ -0,0 +1,65 @@ +import { Readable } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { + AirtableLinkRowSpill, + type ISpillStorage, + type ISpilledLinkRow, +} from './airtable-link-spill'; + +const row = (recordId: string, ids: string[]): ISpilledLinkRow => ({ + teableRecordId: recordId, + cells: [{ airtableFieldId: 'fldLink', ids }], +}); + +const createMemoryStorage = () => { + const objects = new Map(); + const storage: ISpillStorage = { + upload: async (path, data) => { + objects.set(path, data); + }, + download: async (path) => Readable.from(objects.get(path) ?? Buffer.alloc(0)), + cleanup: async (dir) => { + for (const key of [...objects.keys()]) { + if (key.startsWith(dir)) objects.delete(key); + } + }, + }; + return { storage, objects }; +}; + +describe('AirtableLinkRowSpill', () => { + it('fails with a clear error past the staging budget', async () => { + const { storage } = createMemoryStorage(); + const spill = new AirtableLinkRowSpill(storage, 16); + await expect( + spill.append('tblA', [row('rec1', ['recX']), row('rec2', ['recY'])]) + ).rejects.toThrow(/TEABLE_IMPORT_SPILL_MAX_BYTES/); + }); + + it('streams appended rows back per table in order and cleans up', async () => { + const { storage, objects } = createMemoryStorage(); + const spill = new AirtableLinkRowSpill(storage); + await spill.append('tblA', [row('rec1', ['recX']), row('rec2', ['recY', 'recZ'])]); + await spill.append('tblA', [row('rec3', ['recX'])]); + await spill.append('tblB', [row('rec9', ['recQ'])]); + + const readAll = async (tableId: string) => { + const rows: ISpilledLinkRow[] = []; + for await (const item of spill.read(tableId)) { + rows.push(item); + } + return rows; + }; + + const tableA = await readAll('tblA'); + expect(tableA.map((item) => item.teableRecordId)).toEqual(['rec1', 'rec2', 'rec3']); + expect(tableA[1].cells[0].ids).toEqual(['recY', 'recZ']); + expect(await readAll('tblB')).toHaveLength(1); + // A table that never buffered anything yields nothing. + expect(await readAll('tblC')).toHaveLength(0); + + expect(objects.size).toBeGreaterThan(0); + await spill.cleanup(); + expect(objects.size).toBe(0); + }); +}); diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-link-spill.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-link-spill.ts new file mode 100644 index 0000000000..2fe842fddf --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-link-spill.ts @@ -0,0 +1,110 @@ +import { randomUUID } from 'node:crypto'; +import { createInterface } from 'node:readline'; +import type { Readable } from 'node:stream'; + +export interface ISpilledLinkRow { + teableRecordId: string; + cells: Array<{ airtableFieldId: string; ids: string[] }>; +} + +/** + * Minimal IO the spill needs; the importer backs it with the deployment's + * StorageAdapter (local, S3 or MinIO) — the same staging pattern the .tea + * import uses, no container-local temp files. + */ +export interface ISpillStorage { + upload(path: string, data: Buffer): Promise; + download(path: string): Promise; + /** Removes everything under the given directory prefix. */ + cleanup(dir: string): Promise; +} + +const defaultSpillMaxBytes = 2 * 1024 * 1024 * 1024; // 2 GiB +const partMaxBytes = 4 * 1024 * 1024; // 4 MiB per uploaded part + +const getSpillMaxBytes = () => { + const fromEnv = Number(process.env.TEABLE_IMPORT_SPILL_MAX_BYTES); + return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : defaultSpillMaxBytes; +}; + +/** + * Staging area for the link cells collected during record import. Link values + * can only be written after every table's records exist (the old->new record + * id mapping must be complete), so rows are buffered — as JSONL parts in the + * blob storage, keeping the importer's memory flat for large bases. + */ +export class AirtableLinkRowSpill { + private readonly dir = `airtable-import/${randomUUID()}`; + private readonly pending = new Map(); + private readonly parts = new Map(); + private readonly maxBytes: number; + private bytesWritten = 0; + private uploadedAnything = false; + + constructor( + private readonly storage: ISpillStorage, + maxBytes = getSpillMaxBytes() + ) { + this.maxBytes = maxBytes; + } + + async append(airtableTableId: string, rows: ISpilledLinkRow[]): Promise { + if (rows.length === 0) return; + let pending = this.pending.get(airtableTableId); + if (!pending) { + pending = { lines: [], bytes: 0 }; + this.pending.set(airtableTableId, pending); + } + let addedBytes = 0; + for (const row of rows) { + const line = `${JSON.stringify(row)}\n`; + pending.lines.push(line); + addedBytes += Buffer.byteLength(line); + } + pending.bytes += addedBytes; + this.bytesWritten += addedBytes; + if (this.bytesWritten > this.maxBytes) { + throw new Error( + `The import link buffer exceeded ${this.maxBytes} bytes of staging storage; ` + + 'raise TEABLE_IMPORT_SPILL_MAX_BYTES to import this base' + ); + } + if (pending.bytes >= partMaxBytes) { + await this.flushPart(airtableTableId); + } + } + + private async flushPart(airtableTableId: string): Promise { + const pending = this.pending.get(airtableTableId); + if (!pending || pending.lines.length === 0) return; + const parts = this.parts.get(airtableTableId) ?? []; + const partPath = `${this.dir}/${airtableTableId}.part-${String(parts.length).padStart(5, '0')}.jsonl`; + await this.storage.upload(partPath, Buffer.from(pending.lines.join(''))); + parts.push(partPath); + this.parts.set(airtableTableId, parts); + this.pending.delete(airtableTableId); + this.uploadedAnything = true; + } + + /** Streams the table's rows back, part by part, line by line. */ + async *read(airtableTableId: string): AsyncGenerator { + await this.flushPart(airtableTableId); + for (const partPath of this.parts.get(airtableTableId) ?? []) { + const stream = await this.storage.download(partPath); + const lines = createInterface({ input: stream, crlfDelay: Infinity }); + for await (const line of lines) { + if (line.trim()) { + yield JSON.parse(line) as ISpilledLinkRow; + } + } + } + } + + async cleanup(): Promise { + this.pending.clear(); + this.parts.clear(); + if (this.uploadedAnything) { + await this.storage.cleanup(this.dir); + } + } +} diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-record-converter.spec.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-record-converter.spec.ts new file mode 100644 index 0000000000..a8d7dfe396 Binary files /dev/null and b/apps/nestjs-backend/src/features/airtable-import/airtable-record-converter.spec.ts differ diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-record-converter.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-record-converter.ts new file mode 100644 index 0000000000..9d5cc06f32 --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-record-converter.ts @@ -0,0 +1,171 @@ +import type { IAirtableCellConverter } from './airtable-schema-mapper'; +import type { IAirtableCollaborator } from './airtable.types'; + +/** + * Computed Airtable cells can carry error markers instead of values, e.g. + * `{"error": "#ERROR!"}` or `{"specialValue": "NaN"}`. + */ +const isErrorValue = (value: unknown): boolean => + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + ('error' in value || 'specialValue' in value); + +const sanitizeString = (value: string) => value.replace(/\0/g, ''); + +const toDisplayString = (value: unknown): string | undefined => { + if (value == null || isErrorValue(value)) return undefined; + if (typeof value === 'string') return sanitizeString(value); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) { + const parts = value.map(toDisplayString).filter((part): part is string => part != null); + return parts.length > 0 ? parts.join(', ') : undefined; + } + if (typeof value === 'object') { + const record = value as Record; + // Attachment-like, collaborator-like and button-like objects. + const label = record.filename ?? record.name ?? record.label ?? record.text ?? record.email; + if (typeof label === 'string') return sanitizeString(label); + return undefined; + } + return undefined; +}; + +const toNumber = (value: unknown): number | undefined => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +}; + +const collaboratorToText = (value: IAirtableCollaborator): string | undefined => { + if (value.name && value.email) return `${value.name} (${value.email})`; + return value.name ?? value.email ?? undefined; +}; + +/** + * Converts a raw Airtable cell value (`cellFormat=json`) to a Teable cell + * value for the given converter kind. Returns `undefined` when the cell + * should stay empty. `user` and `attachment` cells need external context + * (collaborator resolution, file re-upload) and are handled by the importer. + */ +// eslint-disable-next-line sonarjs/cognitive-complexity +export function convertAirtableCellValue(converter: IAirtableCellConverter, raw: unknown): unknown { + if (raw == null) return undefined; + switch (converter) { + case 'string': + return typeof raw === 'string' ? sanitizeString(raw) || undefined : toDisplayString(raw); + case 'number': + return isErrorValue(raw) ? undefined : toNumber(raw); + case 'boolean': + return raw === true ? true : undefined; + case 'dateTime': + return typeof raw === 'string' && !Number.isNaN(Date.parse(raw)) ? raw : undefined; + case 'stringArray': { + const values = Array.isArray(raw) ? raw : [raw]; + const names = values.filter((item): item is string => typeof item === 'string'); + return names.length > 0 ? names : undefined; + } + case 'barcode': + return typeof (raw as { text?: unknown }).text === 'string' + ? sanitizeString((raw as { text: string }).text) + : undefined; + case 'button': { + const { label, url } = raw as { label?: unknown; url?: unknown }; + const labelText = typeof label === 'string' ? label : undefined; + const urlText = typeof url === 'string' ? url : undefined; + if (labelText && urlText) return `${labelText} (${urlText})`; + return labelText ?? urlText ?? undefined; + } + case 'aiText': { + const value = (raw as { value?: unknown }).value; + return typeof value === 'string' ? sanitizeString(value) || undefined : undefined; + } + case 'collaboratorText': { + const collaborators = (Array.isArray(raw) ? raw : [raw]) as IAirtableCollaborator[]; + const parts = collaborators + .filter((item) => typeof item === 'object' && item !== null) + .map(collaboratorToText) + .filter((part): part is string => part != null); + return parts.length > 0 ? parts.join('; ') : undefined; + } + case 'snapshotText': + return toDisplayString(raw); + case 'snapshotNumber': { + if (isErrorValue(raw)) return undefined; + if (Array.isArray(raw)) return toNumber(raw[0]); + return toNumber(raw); + } + case 'snapshotDate': { + const value = Array.isArray(raw) ? raw[0] : raw; + return typeof value === 'string' && !Number.isNaN(Date.parse(value)) ? value : undefined; + } + case 'snapshotCheckbox': { + const value = Array.isArray(raw) ? raw[0] : raw; + return value === true ? true : undefined; + } + // Resolved asynchronously by the importer. + case 'user': + case 'attachment': + return undefined; + default: + return undefined; + } +} + +export interface IResolvedSpaceUser { + id: string; + name: string; + email: string; +} + +/** + * Converts Airtable collaborator cells to Teable user cell values by matching + * collaborator emails against the target space's collaborators. Returns the + * number of collaborators that could not be matched (and were dropped). + */ +export const convertCollaboratorCellValue = ( + raw: unknown, + usersByEmail: Map, + isMultiple: boolean +): { value: unknown; droppedCount: number } => { + const collaborators = (Array.isArray(raw) ? raw : [raw]).filter( + (item): item is IAirtableCollaborator => typeof item === 'object' && item !== null + ); + const resolved = []; + let droppedCount = 0; + for (const collaborator of collaborators) { + const user = collaborator.email + ? usersByEmail.get(collaborator.email.toLowerCase()) + : undefined; + if (user) { + resolved.push({ id: user.id, title: user.name, email: user.email }); + } else { + droppedCount++; + } + } + if (resolved.length === 0) { + return { value: undefined, droppedCount }; + } + return { value: isMultiple ? resolved : resolved[0], droppedCount }; +}; + +/** Extracts linked record ids from a multipleRecordLinks cell. */ +export const extractLinkedRecordIds = (raw: unknown): string[] => { + if (!Array.isArray(raw)) return []; + return raw + .map((item) => { + if (typeof item === 'string') return item; + if ( + typeof item === 'object' && + item !== null && + typeof (item as { id?: unknown }).id === 'string' + ) { + return (item as { id: string }).id; + } + return undefined; + }) + .filter((id): id is string => id != null); +}; diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-rollup-mapper.spec.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-rollup-mapper.spec.ts new file mode 100644 index 0000000000..31be09a914 --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-rollup-mapper.spec.ts @@ -0,0 +1,43 @@ +import { mapAirtableRollupAggregation } from './airtable-rollup-mapper'; + +describe('mapAirtableRollupAggregation', () => { + it('maps every Airtable rollup function to its Teable counterpart', () => { + const cases: Array<[string, string]> = [ + ['SUM(values)', 'sum({values})'], + ['AVERAGE(values)', 'average({values})'], + ['MAX(values)', 'max({values})'], + ['MIN(values)', 'min({values})'], + ['COUNT(values)', 'count({values})'], + ['COUNTA(values)', 'counta({values})'], + ['COUNTALL(values)', 'countall({values})'], + ['AND(values)', 'and({values})'], + ['OR(values)', 'or({values})'], + ['XOR(values)', 'xor({values})'], + ['ARRAYUNIQUE(values)', 'array_unique({values})'], + ['ARRAYCOMPACT(values)', 'array_compact({values})'], + ['CONCATENATE(values)', 'concatenate({values})'], + ]; + for (const [airtable, teable] of cases) { + expect(mapAirtableRollupAggregation(airtable)).toBe(teable); + } + }); + + it("drops ARRAYJOIN's separator argument (Teable joins with a comma)", () => { + expect(mapAirtableRollupAggregation('ARRAYJOIN(values)')).toBe('array_join({values})'); + expect(mapAirtableRollupAggregation('ARRAYJOIN(values, "; ")')).toBe('array_join({values})'); + }); + + it('is case-insensitive and tolerates whitespace', () => { + expect(mapAirtableRollupAggregation(' sum( values ) ')).toBe('sum({values})'); + }); + + it('returns null for compound or custom aggregations so the importer snapshots them', () => { + expect(mapAirtableRollupAggregation('SUM(values) * 2')).toBeNull(); + expect(mapAirtableRollupAggregation('ROUND(AVERAGE(values), 1)')).toBeNull(); + expect(mapAirtableRollupAggregation('SUM(values) / COUNTA(values)')).toBeNull(); + }); + + it('returns null for an unsupported function', () => { + expect(mapAirtableRollupAggregation('MEDIAN(values)')).toBeNull(); + }); +}); diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-rollup-mapper.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-rollup-mapper.ts new file mode 100644 index 0000000000..1d3dcfabce --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-rollup-mapper.ts @@ -0,0 +1,43 @@ +/** + * Maps an Airtable rollup aggregation (the `formulaTextParsed` of a rollup + * column, e.g. "SUM(values)") to a Teable rollup expression ("sum({values})"). + * + * Teable rollups accept a fixed set of aggregation functions, every one of which + * has a direct Airtable counterpart, so a single `FUNC(values)` aggregation + * translates 1:1. Anything outside that — a compound or custom aggregation such + * as "SUM(values) * 2" or "ROUND(AVERAGE(values), 1)" — has no live equivalent + * and returns null, so the importer falls back to a static value snapshot. + */ + +/** Airtable rollup function (upper-case) → Teable rollup function. */ +const airtableRollupToTeable: Record = { + SUM: 'sum', + AVERAGE: 'average', + MAX: 'max', + MIN: 'min', + COUNT: 'count', + COUNTA: 'counta', + COUNTALL: 'countall', + AND: 'and', + OR: 'or', + XOR: 'xor', + ARRAYJOIN: 'array_join', + ARRAYUNIQUE: 'array_unique', + ARRAYCOMPACT: 'array_compact', + CONCATENATE: 'concatenate', +}; + +// One `FUNC(values)` call and nothing else. ARRAYJOIN also accepts a trailing +// separator argument, which is dropped: Teable's array_join joins with a comma. +const singleAggregation = /^([a-z]+)\s*\(\s*values\s*(?:,[^)]*)?\)$/i; + +/** + * Returns the Teable rollup expression for an Airtable aggregation, or null when + * the aggregation is compound/custom and cannot be a live Teable rollup. + */ +export const mapAirtableRollupAggregation = (formulaTextParsed: string): string | null => { + const match = formulaTextParsed.trim().match(singleAggregation); + if (!match) return null; + const teableFn = airtableRollupToTeable[match[1].toUpperCase()]; + return teableFn ? `${teableFn}({values})` : null; +}; diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-schema-mapper.spec.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-schema-mapper.spec.ts new file mode 100644 index 0000000000..ada44180c6 --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-schema-mapper.spec.ts @@ -0,0 +1,454 @@ +import { createFieldRoSchema, FieldType, ViewType } from '@teable/core'; +import { describe, expect, it } from 'vitest'; +import { buildAirtableImportPlan } from './airtable-schema-mapper'; +import type { IAirtableTable } from './airtable.types'; + +const projectsLinkFieldId = 'fldLinkTasks000001'; +const tasksLinkFieldId = 'fldLinkProject0001'; + +const buildSchema = (): IAirtableTable[] => [ + { + id: 'tblProjects', + name: 'Projects', + primaryFieldId: 'fldName', + fields: [ + { id: 'fldName', name: 'Name', type: 'singleLineText' }, + { id: 'fldNotes', name: 'Notes', type: 'richText' }, + { + id: 'fldBudget', + name: 'Budget', + type: 'currency', + options: { precision: 2, symbol: '€' }, + }, + { + id: 'fldDone', + name: 'Done', + type: 'checkbox', + options: { icon: 'check', color: 'greenBright' }, + }, + { + id: 'fldPriority', + name: 'Priority', + type: 'singleSelect', + options: { + choices: [ + { id: 'selA', name: 'High', color: 'redBright' }, + { id: 'selB', name: 'Low' }, + { id: 'selC', name: '' }, + ], + }, + }, + { + id: 'fldDue', + name: 'Due', + type: 'dateTime', + options: { + timeZone: 'client', + dateFormat: { name: 'european' }, + timeFormat: { name: '24hour' }, + }, + }, + { + id: 'fldScore', + name: 'Score', + type: 'formula', + options: { + formula: '{fldBudget} * 2', + isValid: true, + result: { type: 'number', options: { precision: 1 } }, + }, + }, + { id: 'fldOwner', name: 'Owner', type: 'singleCollaborator' }, + { id: 'fldFiles', name: 'Files', type: 'multipleAttachments' }, + { + id: projectsLinkFieldId, + name: 'Tasks', + type: 'multipleRecordLinks', + options: { + linkedTableId: 'tblTasks', + prefersSingleRecordLink: false, + inverseLinkFieldId: tasksLinkFieldId, + isReversed: false, + }, + }, + { + id: 'fldTaskCount', + name: 'Task count', + type: 'count', + options: { recordLinkFieldId: projectsLinkFieldId, isValid: true }, + }, + { + id: 'fldTaskRollup', + name: 'Task rollup', + type: 'rollup', + // The aggregation is not in the official API options; it arrives via rollupSources. + options: { + recordLinkFieldId: projectsLinkFieldId, + fieldIdInLinkedTable: 'fldTitle', + isValid: true, + result: { type: 'number' }, + }, + }, + { id: 'fldAuto', name: 'Auto', type: 'autoNumber' }, + { id: 'fldDur', name: 'Duration', type: 'duration', options: { durationFormat: 'h:mm' } }, + { id: 'fldBtn', name: 'Open', type: 'button' }, + { + id: 'fldCreated', + name: 'Created at', + type: 'createdTime', + options: { result: { type: 'dateTime', options: { timeZone: 'Asia/Shanghai' } } }, + }, + ], + views: [ + { id: 'viwGrid', name: 'All projects', type: 'grid' }, + { id: 'viwTimeline', name: 'Roadmap', type: 'timeline' }, + { id: 'viwForm', name: 'Intake', type: 'form' }, + ], + }, + { + id: 'tblTasks', + name: 'Tasks', + primaryFieldId: 'fldTitle', + fields: [ + { id: 'fldTitle', name: 'Title', type: 'singleLineText' }, + { + id: tasksLinkFieldId, + name: 'Project', + type: 'multipleRecordLinks', + options: { + linkedTableId: 'tblProjects', + prefersSingleRecordLink: true, + inverseLinkFieldId: projectsLinkFieldId, + isReversed: false, + }, + }, + { + id: 'fldProjName', + name: 'Project name', + type: 'multipleLookupValues', + options: { + recordLinkFieldId: tasksLinkFieldId, + fieldIdInLinkedTable: 'fldName', + isValid: true, + result: { type: 'singleLineText' }, + }, + }, + { + id: 'fldBroken', + name: 'Broken formula', + type: 'formula', + options: { formula: '1/0', isValid: false, result: null }, + }, + { + id: 'fldAiSummary', + name: 'AI summary', + type: 'aiText', + options: { + prompt: ['Summarize ', { field: { fieldId: 'fldTitle' } }, ' briefly'], + referencedFieldIds: ['fldTitle'], + }, + }, + ], + views: [], + }, +]; + +// isPrimary is not part of createFieldRoSchema; it is forwarded to v2 by the +// internal create-table mapper (see table-open-api-v2.mapper.ts mapBaseField). +const isPrimary = (ro: unknown) => (ro as { isPrimary?: boolean }).isPrimary; + +describe('buildAirtableImportPlan', () => { + const plan = buildAirtableImportPlan(buildSchema()); + const projects = plan.tables[0]; + const tasks = plan.tables[1]; + + it('produces phase-1 field ros that pass the create-field contract', () => { + for (const table of plan.tables) { + for (const field of table.fields) { + expect( + () => createFieldRoSchema.parse(field.ro), + `${table.name}.${field.ro.name}` + ).not.toThrow(); + } + } + }); + + it('puts the primary field first with isPrimary set', () => { + expect(projects.fields[0].airtableFieldId).toBe('fldName'); + expect(isPrimary(projects.fields[0].ro)).toBe(true); + expect(isPrimary(tasks.fields[0].ro)).toBe(true); + }); + + it('maps plain field types with options', () => { + const byAirtableId = new Map(projects.fields.map((field) => [field.airtableFieldId, field])); + expect(byAirtableId.get('fldNotes')?.ro.type).toBe(FieldType.LongText); + expect(byAirtableId.get('fldBudget')?.ro.options).toMatchObject({ + formatting: { type: 'currency', precision: 2, symbol: '€' }, + }); + expect(byAirtableId.get('fldDue')?.ro.options).toMatchObject({ + formatting: { date: 'D/M/YYYY', time: 'HH:mm', timeZone: 'UTC' }, + }); + expect(byAirtableId.get('fldOwner')?.ro.type).toBe(FieldType.User); + expect(byAirtableId.get('fldFiles')?.ro.type).toBe(FieldType.Attachment); + }); + + it('passes select choice colors through and falls back when missing', () => { + const priority = projects.fields.find((field) => field.airtableFieldId === 'fldPriority'); + const choices = (priority?.ro.options as { choices: Array<{ name: string; color: string }> }) + .choices; + expect(choices[0]).toMatchObject({ name: 'High', color: 'redBright' }); + expect(choices[1].name).toBe('Low'); + expect(choices[1].color).toBeTruthy(); + // Airtable allows blank option names; Teable requires a non-empty name. + expect(choices[2].name.length).toBeGreaterThan(0); + }); + + it('owns a one-to-many link on the single-link side, independent of table order', () => { + // Projects.Tasks is multi and Tasks.Project is single; the single side must + // own the link so the relationship resolves to ManyOne (not ManyMany) no + // matter which table is traversed first. + expect(tasks.linkFields).toHaveLength(1); + expect(projects.linkFields).toHaveLength(0); + const link = tasks.linkFields[0]; + expect(link.airtableFieldId).toBe(tasksLinkFieldId); + expect(link.prefersSingle).toBe(true); + expect(link.inverse).toMatchObject({ + airtableFieldId: projectsLinkFieldId, + name: 'Tasks', + prefersSingle: false, + }); + expect(plan.fieldIdMap[tasksLinkFieldId]).toBeTruthy(); + expect(plan.fieldIdMap[projectsLinkFieldId]).toBeUndefined(); + }); + + it('plans count as a rollup and lookup as a real lookup', () => { + expect(projects.countFields).toHaveLength(1); + expect(projects.countFields[0]).toMatchObject({ + airtableLinkFieldId: projectsLinkFieldId, + airtableForeignTableId: 'tblTasks', + }); + expect(tasks.lookupFields).toHaveLength(1); + expect(tasks.lookupFields[0]).toMatchObject({ + airtableLinkFieldId: tasksLinkFieldId, + airtableForeignTableId: 'tblProjects', + airtableTargetFieldId: 'fldName', + }); + }); + + it('carries a normalized AI prompt for aiText fields without degrading them upfront', () => { + const aiField = tasks.fields.find((field) => field.airtableFieldId === 'fldAiSummary'); + expect(aiField?.ro.type).toBe(FieldType.LongText); + expect(aiField?.converter).toBe('aiText'); + expect(aiField?.aiPromptParts).toEqual([ + { text: 'Summarize ' }, + { airtableFieldId: 'fldTitle', fieldName: 'Title' }, + { text: ' briefly' }, + ]); + expect( + plan.issues.some( + (issue) => issue.fieldName === 'AI summary' && issue.code === 'fieldDegraded' + ) + ).toBe(false); + }); + + it('translates a compatible formula into a live Teable formula field', () => { + const score = projects.formulaFields.find((field) => field.airtableFieldId === 'fldScore'); + expect(score?.expression).toBe('{fldBudget} * 2'); + // A live formula is not duplicated as a snapshot, and is not reported as degraded. + expect(projects.fields.find((field) => field.airtableFieldId === 'fldScore')).toBeUndefined(); + expect(plan.issues.some((issue) => issue.fieldName === 'Score')).toBe(false); + }); + + it('snapshots an invalid formula instead of emitting a broken live formula', () => { + expect( + tasks.formulaFields.find((field) => field.airtableFieldId === 'fldBroken') + ).toBeUndefined(); + const snapshot = tasks.fields.find((field) => field.airtableFieldId === 'fldBroken'); + expect(snapshot?.converter).toMatch(/^snapshot/); + expect( + plan.issues.some( + (issue) => issue.fieldName === 'Broken formula' && issue.code === 'fieldDegraded' + ) + ).toBe(true); + }); + + it('snapshots a rollup when no shared base model supplies its aggregation', () => { + // the default `plan` is built without rollupSources + expect(projects.rollupFields).toHaveLength(0); + const snapshot = projects.fields.find((field) => field.airtableFieldId === 'fldTaskRollup'); + expect(snapshot?.converter).toMatch(/^snapshot/); + }); + + it('recreates a rollup as a live Teable rollup when the shared base model supplies its aggregation', () => { + const rollupSources = new Map([ + [ + 'fldTaskRollup', + { + relationColumnId: projectsLinkFieldId, + foreignTableRollupColumnId: 'fldTitle', + aggregation: 'COUNTA(values)', + filter: null, + }, + ], + ]); + const livePlan = buildAirtableImportPlan(buildSchema(), rollupSources); + const liveProjects = livePlan.tables[0]; + const rollup = liveProjects.rollupFields.find((f) => f.airtableFieldId === 'fldTaskRollup'); + expect(rollup?.expression).toBe('counta({values})'); + expect(rollup?.airtableForeignTableId).toBe('tblTasks'); + // a live rollup is not also emitted as a snapshot + expect(liveProjects.fields.find((f) => f.airtableFieldId === 'fldTaskRollup')).toBeUndefined(); + }); + + it('degrades unsupported types and reports issues', () => { + const byAirtableId = new Map(projects.fields.map((field) => [field.airtableFieldId, field])); + expect(byAirtableId.get('fldAuto')?.ro.type).toBe(FieldType.Number); + expect(byAirtableId.get('fldDur')?.ro.type).toBe(FieldType.Number); + expect(byAirtableId.get('fldBtn')?.ro.type).toBe(FieldType.SingleLineText); + expect(byAirtableId.get('fldCreated')?.ro.type).toBe(FieldType.Date); + const degradedFields = plan.issues + .filter((issue) => issue.code === 'fieldDegraded') + .map((issue) => issue.fieldName); + expect(degradedFields).toEqual( + expect.arrayContaining(['Auto', 'Duration', 'Open', 'Created at']) + ); + }); + + it('maps supported views and reports skipped ones, defaulting to a grid', () => { + expect(projects.views).toEqual([ + { name: 'All projects', type: ViewType.Grid }, + { name: 'Intake', type: ViewType.Form }, + ]); + expect( + plan.issues.some((issue) => issue.code === 'viewSkipped' && issue.viewName === 'Roadmap') + ).toBe(true); + expect(tasks.views).toEqual([{ type: ViewType.Grid }]); + }); + + it('covers every non-inverse field in the field id map', () => { + const schema = buildSchema(); + for (const table of schema) { + for (const field of table.fields) { + // Projects.Tasks (multi) is now the inverse side — the single-link + // Tasks.Project owns the link — so it is mapped at import time, not now. + if (field.id === projectsLinkFieldId) continue; + // formula/rollup are skipped (not imported), so they are not mapped. + if (field.type === 'formula' || field.type === 'rollup') continue; + expect(plan.fieldIdMap[field.id], `${table.name}.${field.name}`).toBeTruthy(); + } + } + }); +}); + +describe('buildAirtableImportPlan edge cases', () => { + it('degrades the primary field to a text snapshot when it maps to an incompatible type', () => { + const plan = buildAirtableImportPlan([ + { + id: 'tblA', + name: 'A', + primaryFieldId: 'fldChk', + fields: [ + { id: 'fldChk', name: 'Done', type: 'checkbox' }, + { id: 'fldText', name: 'Text', type: 'singleLineText' }, + ], + views: [], + }, + ]); + const table = plan.tables[0]; + expect(isPrimary(table.fields[0].ro)).toBe(true); + expect(table.fields[0].ro.type).toBe(FieldType.SingleLineText); + expect(table.fields[0].converter).toBe('snapshotText'); + // the checkbox itself is replaced, not duplicated + expect(table.fields.filter((field) => field.airtableFieldId === 'fldChk')).toHaveLength(1); + }); + + it('treats a link without a valid inverse as one-way and self-links as pairable', () => { + const plan = buildAirtableImportPlan([ + { + id: 'tblA', + name: 'A', + primaryFieldId: 'fldName', + fields: [ + { id: 'fldName', name: 'Name', type: 'singleLineText' }, + { + id: 'fldOneWay', + name: 'One way', + type: 'multipleRecordLinks', + options: { linkedTableId: 'tblA', prefersSingleRecordLink: false }, + }, + { + id: 'fldSelfA', + name: 'Parent', + type: 'multipleRecordLinks', + options: { + linkedTableId: 'tblA', + prefersSingleRecordLink: true, + inverseLinkFieldId: 'fldSelfB', + }, + }, + { + id: 'fldSelfB', + name: 'Children', + type: 'multipleRecordLinks', + options: { + linkedTableId: 'tblA', + prefersSingleRecordLink: false, + inverseLinkFieldId: 'fldSelfA', + }, + }, + ], + views: [], + }, + ]); + const table = plan.tables[0]; + expect(table.linkFields).toHaveLength(2); + const oneWay = table.linkFields.find((field) => field.airtableFieldId === 'fldOneWay'); + expect(oneWay?.inverse).toBeUndefined(); + const selfPair = table.linkFields.find((field) => field.airtableFieldId === 'fldSelfA'); + expect(selfPair?.inverse?.airtableFieldId).toBe('fldSelfB'); + }); + + it('carries a link "limit record selection to a view" into the plan', () => { + const plan = buildAirtableImportPlan([ + { + id: 'tblA', + name: 'A', + primaryFieldId: 'fldName', + fields: [ + { id: 'fldName', name: 'Name', type: 'singleLineText' }, + { + id: 'fldLink', + name: 'Linked', + type: 'multipleRecordLinks', + options: { + linkedTableId: 'tblA', + prefersSingleRecordLink: false, + viewIdForRecordSelection: 'viwLimit', + }, + }, + ], + views: [], + }, + ]); + const link = plan.tables[0].linkFields.find((field) => field.airtableFieldId === 'fldLink'); + expect(link?.viewIdForRecordSelection).toBe('viwLimit'); + }); + + it('falls back to a text snapshot for unknown future field types', () => { + const plan = buildAirtableImportPlan([ + { + id: 'tblA', + name: 'A', + primaryFieldId: 'fldName', + fields: [ + { id: 'fldName', name: 'Name', type: 'singleLineText' }, + { id: 'fldNew', name: 'Mystery', type: 'someFutureType' }, + ], + views: [], + }, + ]); + const mystery = plan.tables[0].fields.find((field) => field.airtableFieldId === 'fldNew'); + expect(mystery?.ro.type).toBe(FieldType.LongText); + expect(mystery?.converter).toBe('snapshotText'); + }); +}); diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-schema-mapper.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-schema-mapper.ts new file mode 100644 index 0000000000..0da00070a2 --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-schema-mapper.ts @@ -0,0 +1,987 @@ +import type { IFieldRo, IViewRo } from '@teable/core'; +import { + Colors, + DateFormattingPreset, + FieldType, + generateFieldId, + NumberFormattingType, + RatingIcon, + SingleLineTextDisplayType, + TimeFormatting, + ViewType, +} from '@teable/core'; +import type { IImportAirtableIssue } from '@teable/openapi'; +import { translateAirtableFormula } from './airtable-formula-translator'; +import { mapAirtableRollupAggregation } from './airtable-rollup-mapper'; +import type { IAirtableFilterGroup, IAirtableRollupSource } from './airtable-share.client'; +import type { + IAirtableField, + IAirtableFieldOptions, + IAirtableFieldResult, + IAirtableTable, +} from './airtable.types'; + +/** How raw Airtable cell values are converted before writing to Teable. */ +export type IAirtableCellConverter = + | 'string' + | 'number' + | 'boolean' + | 'dateTime' + | 'stringArray' + | 'user' + | 'attachment' + | 'barcode' + | 'button' + | 'aiText' + | 'collaboratorText' + | 'snapshotText' + | 'snapshotNumber' + | 'snapshotDate' + | 'snapshotCheckbox'; + +/** A normalized chunk of an Airtable AI field prompt: plain text or a field reference. */ +export interface IAiPromptPart { + text?: string; + airtableFieldId?: string; + fieldName?: string; +} + +export interface IPlannedDirectField { + airtableFieldId: string; + converter: IAirtableCellConverter; + ro: IFieldRo; + /** + * Present for Airtable aiText fields. The importer turns it into a Teable + * AI field config when the target base has an AI model configured. + */ + aiPromptParts?: IAiPromptPart[]; +} + +export interface IPlannedLinkField { + airtableFieldId: string; + teableFieldId: string; + name: string; + description?: string; + airtableForeignTableId: string; + prefersSingle: boolean; + /** The paired two-way field in the foreign table, mapped to the auto-created symmetric field. */ + inverse?: { airtableFieldId: string; name: string; prefersSingle: boolean }; + /** Airtable's "limit record selection to a view" — a view id in the foreign table. */ + viewIdForRecordSelection?: string; +} + +export interface IPlannedLookupField { + airtableFieldId: string; + teableFieldId: string; + name: string; + description?: string; + airtableLinkFieldId: string; + airtableForeignTableId: string; + airtableTargetFieldId: string; +} + +export interface IPlannedCountField { + airtableFieldId: string; + teableFieldId: string; + name: string; + description?: string; + airtableLinkFieldId: string; + airtableForeignTableId: string; +} + +export interface IPlannedFormulaField { + airtableFieldId: string; + teableFieldId: string; + name: string; + description?: string; + /** Translated Teable expression, still referencing fields by Airtable id (`{fldXXX}`). */ + expression: string; +} + +export interface IPlannedRollupField { + airtableFieldId: string; + teableFieldId: string; + name: string; + description?: string; + /** Teable rollup expression, e.g. `sum({values})`. */ + expression: string; + airtableLinkFieldId: string; + airtableForeignTableId: string; + airtableForeignFieldId: string; + /** Raw Airtable "only include records that meet conditions" filter, mapped at creation. */ + filter: IAirtableFilterGroup | null; +} + +/** The Airtable view each created Teable view came from (for view-config import). */ +export interface IPlannedViewSource { + airtableViewId: string; + teableViewType: ViewType; +} + +export interface IAirtableTablePlan { + airtableTableId: string; + name: string; + description?: string; + /** Phase-1 fields in creation order, primary first. */ + fields: IPlannedDirectField[]; + views: IViewRo[]; + /** Aligned index-wise with `views`; empty entries (default fallback) have no source. */ + viewSources: IPlannedViewSource[]; + /** Phase-2 fields, created after all tables and records exist. */ + linkFields: IPlannedLinkField[]; + lookupFields: IPlannedLookupField[]; + countFields: IPlannedCountField[]; + /** Phase-3 fields, created last so every referenced field id is already mapped. */ + formulaFields: IPlannedFormulaField[]; + /** Live rollups recreated from the shared base model, created with lookups/counts. */ + rollupFields: IPlannedRollupField[]; +} + +export interface IAirtableImportPlan { + tables: IAirtableTablePlan[]; + /** + * airtable field id -> pre-generated teable field id. Table ids cannot be + * client-specified, the importer records them from the create-table + * responses. Inverse link fields are mapped during import as well (to the + * auto-created symmetric fields). + */ + fieldIdMap: Record; + issues: IImportAirtableIssue[]; +} + +const maxNumberPrecision = 5; +const maxDescriptionLength = 1000; +const longTextSnapshotLabel = 'longText snapshot'; + +const teableColorValues = new Set(Object.values(Colors)); + +/** Rotating palette for select choices when Airtable has colored options disabled. */ +const defaultChoiceColors = [ + Colors.BlueLight1, + Colors.GreenLight1, + Colors.YellowLight1, + Colors.OrangeLight1, + Colors.RedLight1, + Colors.PurpleLight1, + Colors.PinkLight1, + Colors.CyanLight1, + Colors.TealLight1, + Colors.GrayLight1, +]; + +const viewTypeMap: Record = { + grid: ViewType.Grid, + form: ViewType.Form, + calendar: ViewType.Calendar, + gallery: ViewType.Gallery, + kanban: ViewType.Kanban, +}; + +const ratingIconMap: Record = { + star: RatingIcon.Star, + heart: RatingIcon.Heart, + thumbsUp: RatingIcon.ThumbUp, + flag: RatingIcon.Flame, + dot: RatingIcon.Moon, +}; + +const primaryIncompatibleTypes = new Set([FieldType.Attachment, FieldType.Checkbox]); + +const clampPrecision = (precision: unknown) => { + const value = typeof precision === 'number' ? Math.round(precision) : 0; + return Math.min(Math.max(value, 0), maxNumberPrecision); +}; + +const mapTimeZone = (timeZone: string | undefined) => { + if (!timeZone || timeZone === 'client' || timeZone === 'utc') { + return 'UTC'; + } + return timeZone; +}; + +const mapDateFormat = (options: IAirtableFieldOptions | undefined) => { + switch (options?.dateFormat?.name) { + case 'us': + return DateFormattingPreset.US; + case 'european': + return DateFormattingPreset.European; + default: + return DateFormattingPreset.ISO; + } +}; + +const mapTimeFormat = (options: IAirtableFieldOptions | undefined) => { + switch (options?.timeFormat?.name) { + case '12hour': + return TimeFormatting.Hour12; + case '24hour': + return TimeFormatting.Hour24; + default: + return TimeFormatting.None; + } +}; + +const buildDatetimeFormatting = ( + options: IAirtableFieldOptions | undefined, + withTime: boolean +) => ({ + date: mapDateFormat(options), + time: withTime ? mapTimeFormat(options) : TimeFormatting.None, + timeZone: mapTimeZone(options?.timeZone), +}); + +const mapRatingColor = (color: string | undefined) => { + if (color === Colors.YellowBright || color === 'orangeBright') { + return Colors.YellowBright; + } + if (color === Colors.RedBright || color === 'pinkBright' || color === 'purpleBright') { + return Colors.RedBright; + } + return Colors.TealBright; +}; + +const mapSelectChoices = (options: IAirtableFieldOptions | undefined) => { + const choices = options?.choices ?? []; + return choices.map((choice, index) => ({ + // Airtable allows blank option names; Teable requires at least one char. + name: choice.name?.trim() ? choice.name : `(blank ${index + 1})`, + color: (teableColorValues.has(choice.color ?? '') + ? choice.color + : defaultChoiceColors[index % defaultChoiceColors.length]) as Colors, + })); +}; + +const truncateDescription = (description: string) => + description.length > maxDescriptionLength + ? `${description.slice(0, maxDescriptionLength - 1)}…` + : description; + +/** Rewrites `{fldXXX}` references inside a formula to readable `{Field Name}` references. */ +const humanizeFormula = (formula: string, fieldNameById: Map) => + formula.replace(/\{(fld[a-zA-Z0-9]+)\}/g, (match, fieldId: string) => { + const name = fieldNameById.get(fieldId); + return name ? `{${name}}` : match; + }); + +interface IDirectMapping { + kind: 'direct'; + type: FieldType; + options?: IFieldRo['options']; + converter: IAirtableCellConverter; + degradedTo?: string; + aiPromptParts?: IAiPromptPart[]; +} + +/** + * Normalizes an Airtable aiText prompt (an array of text chunks and field + * reference objects) so the importer can rebuild it as a Teable AI prompt. + */ +const normalizeAiPromptParts = ( + prompt: unknown, + fieldNameById: Map +): IAiPromptPart[] => { + if (!Array.isArray(prompt)) return []; + const parts: IAiPromptPart[] = []; + for (const part of prompt) { + if (typeof part === 'string') { + parts.push({ text: part }); + continue; + } + if (part && typeof part === 'object') { + const record = part as { field?: { fieldId?: unknown }; fieldId?: unknown }; + const fieldId = record.field?.fieldId ?? record.fieldId; + if (typeof fieldId === 'string') { + parts.push({ airtableFieldId: fieldId, fieldName: fieldNameById.get(fieldId) }); + } + } + } + return parts; +}; + +interface ISkipMapping { + kind: 'skip'; + reason: string; +} + +type IFieldMapping = IDirectMapping | ISkipMapping; + +const textMapping = (showAs?: SingleLineTextDisplayType): IDirectMapping => ({ + kind: 'direct', + type: FieldType.SingleLineText, + options: showAs ? { showAs: { type: showAs } } : {}, + converter: 'string', +}); + +const numberMapping = ( + formattingType: NumberFormattingType, + options: IAirtableFieldOptions | undefined, + converter: IAirtableCellConverter = 'number' +): IDirectMapping => ({ + kind: 'direct', + type: FieldType.Number, + options: { + formatting: { + type: formattingType, + precision: clampPrecision(options?.precision), + ...(formattingType === NumberFormattingType.Currency + ? { symbol: options?.symbol ?? '$' } + : {}), + }, + }, + converter, +}); + +/** + * Maps the `result` config of a computed Airtable field (formula/rollup/lookup) + * to a plain Teable field that stores a static snapshot of the computed values. + */ +const snapshotMappingFromResult = ( + result: IAirtableFieldResult | null | undefined +): IDirectMapping => { + const options = result?.options as IAirtableFieldOptions | undefined; + switch (result?.type) { + case 'number': + return numberMapping(NumberFormattingType.Decimal, options, 'snapshotNumber'); + case 'percent': + return numberMapping(NumberFormattingType.Percent, options, 'snapshotNumber'); + case 'currency': + return numberMapping(NumberFormattingType.Currency, options, 'snapshotNumber'); + case 'duration': + case 'rating': + case 'autoNumber': + case 'count': + return { + kind: 'direct', + type: FieldType.Number, + options: { + formatting: { type: NumberFormattingType.Decimal, precision: clampPrecision(0) }, + }, + converter: 'snapshotNumber', + }; + case 'date': + case 'dateTime': + case 'createdTime': + case 'lastModifiedTime': + return { + kind: 'direct', + type: FieldType.Date, + options: { formatting: buildDatetimeFormatting(options, result.type !== 'date') }, + converter: 'snapshotDate', + }; + case 'checkbox': + return { + kind: 'direct', + type: FieldType.Checkbox, + options: {}, + converter: 'snapshotCheckbox', + }; + default: + return { + kind: 'direct', + type: FieldType.SingleLineText, + options: {}, + converter: 'snapshotText', + }; + } +}; + +// eslint-disable-next-line sonarjs/cognitive-complexity +const mapField = (field: IAirtableField, fieldNameById: Map): IFieldMapping => { + const { type, options } = field; + switch (type) { + case 'singleLineText': + return textMapping(); + case 'email': + return textMapping(SingleLineTextDisplayType.Email); + case 'url': + return textMapping(SingleLineTextDisplayType.Url); + case 'phoneNumber': + return textMapping(SingleLineTextDisplayType.Phone); + case 'multilineText': + case 'richText': + return { kind: 'direct', type: FieldType.LongText, options: {}, converter: 'string' }; + case 'number': + return numberMapping(NumberFormattingType.Decimal, options); + case 'percent': + return numberMapping(NumberFormattingType.Percent, options); + case 'currency': + return numberMapping(NumberFormattingType.Currency, options); + case 'duration': + case 'autoNumber': + // Teable has no duration type (Airtable stores duration as seconds) and + // cannot back-fill auto numbers — keep both as plain numbers. + return { + ...numberMapping(NumberFormattingType.Decimal, { precision: 0 }), + degradedTo: 'number', + }; + case 'rating': + return { + kind: 'direct', + type: FieldType.Rating, + options: { + icon: ratingIconMap[options?.icon ?? ''] ?? RatingIcon.Star, + color: mapRatingColor(options?.color), + max: Math.min(Math.max(options?.max ?? 5, 1), 10), + }, + converter: 'number', + }; + case 'checkbox': + return { kind: 'direct', type: FieldType.Checkbox, options: {}, converter: 'boolean' }; + case 'singleSelect': + return { + kind: 'direct', + type: FieldType.SingleSelect, + options: { choices: mapSelectChoices(options) }, + converter: 'string', + }; + case 'multipleSelects': + return { + kind: 'direct', + type: FieldType.MultipleSelect, + options: { choices: mapSelectChoices(options) }, + converter: 'stringArray', + }; + case 'externalSyncSource': + return { + kind: 'direct', + type: FieldType.SingleSelect, + options: { choices: mapSelectChoices(options) }, + converter: 'string', + degradedTo: 'singleSelect', + }; + case 'date': + return { + kind: 'direct', + type: FieldType.Date, + options: { formatting: buildDatetimeFormatting(options, false) }, + converter: 'dateTime', + }; + case 'dateTime': + return { + kind: 'direct', + type: FieldType.Date, + options: { formatting: buildDatetimeFormatting(options, true) }, + converter: 'dateTime', + }; + case 'createdTime': + case 'lastModifiedTime': { + // Teable would recompute these to the import time; keep the original + // values as a static date snapshot instead. + const result = options?.result; + const resultOptions = result?.options as IAirtableFieldOptions | undefined; + return { + kind: 'direct', + type: FieldType.Date, + options: { formatting: buildDatetimeFormatting(resultOptions, true) }, + converter: 'dateTime', + degradedTo: 'date snapshot', + }; + } + case 'createdBy': + case 'lastModifiedBy': + return { + kind: 'direct', + type: FieldType.SingleLineText, + options: {}, + converter: 'collaboratorText', + degradedTo: 'singleLineText snapshot', + }; + case 'singleCollaborator': + return { kind: 'direct', type: FieldType.User, options: {}, converter: 'user' }; + case 'multipleCollaborators': + return { + kind: 'direct', + type: FieldType.User, + options: { isMultiple: true }, + converter: 'user', + }; + case 'multipleAttachments': + return { kind: 'direct', type: FieldType.Attachment, options: {}, converter: 'attachment' }; + case 'barcode': + return { + kind: 'direct', + type: FieldType.SingleLineText, + options: {}, + converter: 'barcode', + degradedTo: 'singleLineText', + }; + case 'button': + return { + kind: 'direct', + type: FieldType.SingleLineText, + options: {}, + converter: 'button', + degradedTo: 'singleLineText', + }; + case 'aiText': + // Becomes a Teable AI field when the target base has an AI model + // configured; otherwise the importer reports it as a snapshot. + return { + kind: 'direct', + type: FieldType.LongText, + options: {}, + converter: 'aiText', + aiPromptParts: normalizeAiPromptParts(options?.prompt, fieldNameById), + }; + // formula and rollup are handled in the main loop (translate-or-snapshot). + default: + // Unknown / future Airtable field types degrade to a text snapshot so + // the import never fails on them. + return { + kind: 'direct', + type: FieldType.LongText, + options: {}, + converter: 'snapshotText', + degradedTo: longTextSnapshotLabel, + }; + } +}; + +const buildComputedDescription = ( + field: IAirtableField, + fieldNameById: Map +): string | undefined => { + if (field.type === 'formula' && field.options?.formula) { + const formula = humanizeFormula(field.options.formula, fieldNameById); + return truncateDescription(`Airtable formula: ${formula}`); + } + if (field.type === 'rollup') { + const linkName = fieldNameById.get(field.options?.recordLinkFieldId ?? ''); + const targetName = fieldNameById.get(field.options?.fieldIdInLinkedTable ?? ''); + if (linkName && targetName) { + return truncateDescription(`Airtable rollup of "${targetName}" via "${linkName}"`); + } + } + if (field.type === 'multipleLookupValues') { + const linkName = fieldNameById.get(field.options?.recordLinkFieldId ?? ''); + const targetName = fieldNameById.get(field.options?.fieldIdInLinkedTable ?? ''); + if (linkName && targetName) { + return truncateDescription(`Airtable lookup of "${targetName}" via "${linkName}"`); + } + } + return field.description ? truncateDescription(field.description) : undefined; +}; + +const mapViews = ( + table: IAirtableTable, + issues: IImportAirtableIssue[] +): { views: IViewRo[]; sources: IPlannedViewSource[] } => { + const views: IViewRo[] = []; + const sources: IPlannedViewSource[] = []; + for (const view of table.views) { + const viewType = viewTypeMap[view.type]; + if (!viewType) { + issues.push({ + code: 'viewSkipped', + tableName: table.name, + viewName: view.name, + fromType: view.type, + }); + continue; + } + views.push({ name: view.name, type: viewType } as IViewRo); + sources.push({ airtableViewId: view.id, teableViewType: viewType }); + } + if (views.length === 0) { + views.push({ type: ViewType.Grid } as IViewRo); + } + return { views, sources }; +}; + +/** + * Builds the full import plan for an Airtable base schema: phase-1 plain + * fields (created together with the tables), view shells, and phase-2 + * link/lookup/count fields (created once all tables and records exist). + */ +/* eslint-disable sonarjs/cognitive-complexity -- one large switch over Airtable field types */ +export const buildAirtableImportPlan = ( + tables: IAirtableTable[], + /** Rollup sources from the shared base model; absent when no share link was given. */ + rollupSources?: Map +): IAirtableImportPlan => { + const issues: IImportAirtableIssue[] = []; + const fieldIdMap: Record = {}; + const tableById = new Map(tables.map((table) => [table.id, table])); + const fieldNameById = new Map(); + for (const table of tables) { + for (const field of table.fields) { + fieldNameById.set(field.id, field.name); + } + } + + // Two-way links appear as a field on both tables; the second one of each + // pair is realized by renaming the symmetric field Teable auto-creates. + const inverseFieldIds = new Set(); + for (const table of tables) { + for (const field of table.fields) { + if (field.type !== 'multipleRecordLinks' || inverseFieldIds.has(field.id)) { + continue; + } + const inverseId = field.options?.inverseLinkFieldId; + const foreignTable = tableById.get(field.options?.linkedTableId ?? ''); + const inverseField = foreignTable?.fields.find( + (candidate) => candidate.id === inverseId && candidate.type === 'multipleRecordLinks' + ); + if (inverseField && inverseField.id !== field.id) { + // Own the link from the single-link side so a one-to-many keeps its + // ManyOne cardinality regardless of table/field traversal order. Only + // flips when exactly one side prefers a single link; ties (both single + // or both multi) keep traversal order. + const fieldSingle = field.options?.prefersSingleRecordLink === true; + const inverseSingle = inverseField.options?.prefersSingleRecordLink === true; + const inverseOwns = inverseSingle && !fieldSingle; + inverseFieldIds.add(inverseOwns ? field.id : inverseField.id); + } + } + } + + const plannedLinkIds = new Set(); + for (const table of tables) { + for (const field of table.fields) { + if ( + field.type === 'multipleRecordLinks' && + tableById.has(field.options?.linkedTableId ?? '') && + !inverseFieldIds.has(field.id) + ) { + plannedLinkIds.add(field.id); + } + } + } + + // eslint-disable-next-line sonarjs/cognitive-complexity + const tablePlans: IAirtableTablePlan[] = tables.map((table) => { + const { views, sources } = mapViews(table, issues); + const plan: IAirtableTablePlan = { + airtableTableId: table.id, + name: table.name, + description: table.description, + fields: [], + views, + viewSources: sources, + linkFields: [], + lookupFields: [], + countFields: [], + formulaFields: [], + rollupFields: [], + }; + + for (const field of table.fields) { + const description = buildComputedDescription(field, fieldNameById); + + if (field.type === 'multipleRecordLinks') { + if (inverseFieldIds.has(field.id)) { + // Realized as the symmetric side of the owning link field. + continue; + } + const foreignTable = tableById.get(field.options?.linkedTableId ?? ''); + if (!foreignTable) { + issues.push({ + code: 'fieldSkipped', + tableName: table.name, + fieldName: field.name, + fromType: field.type, + reason: 'linked table not found', + }); + continue; + } + const inverseId = field.options?.inverseLinkFieldId; + const inverseField = foreignTable.fields.find( + (candidate) => candidate.id === inverseId && inverseFieldIds.has(candidate.id) + ); + const teableFieldId = generateFieldId(); + fieldIdMap[field.id] = teableFieldId; + plan.linkFields.push({ + airtableFieldId: field.id, + teableFieldId, + name: field.name, + description, + airtableForeignTableId: foreignTable.id, + prefersSingle: field.options?.prefersSingleRecordLink === true, + viewIdForRecordSelection: field.options?.viewIdForRecordSelection, + inverse: inverseField + ? { + airtableFieldId: inverseField.id, + name: inverseField.name, + prefersSingle: inverseField.options?.prefersSingleRecordLink === true, + } + : undefined, + }); + continue; + } + + if (field.type === 'count') { + const linkFieldId = field.options?.recordLinkFieldId ?? ''; + const linkField = table.fields.find((candidate) => candidate.id === linkFieldId); + const foreignTableId = linkField?.options?.linkedTableId ?? ''; + if ( + field.options?.isValid !== false && + (plannedLinkIds.has(linkFieldId) || inverseFieldIds.has(linkFieldId)) && + tableById.has(foreignTableId) + ) { + const teableFieldId = generateFieldId(); + fieldIdMap[field.id] = teableFieldId; + plan.countFields.push({ + airtableFieldId: field.id, + teableFieldId, + name: field.name, + description, + airtableLinkFieldId: linkFieldId, + airtableForeignTableId: foreignTableId, + }); + continue; + } + const mapping = snapshotMappingFromResult({ type: 'count' }); + issues.push({ + code: 'fieldDegraded', + tableName: table.name, + fieldName: field.name, + fromType: field.type, + toType: 'number snapshot', + }); + const teableFieldId = generateFieldId(); + fieldIdMap[field.id] = teableFieldId; + plan.fields.push({ + airtableFieldId: field.id, + converter: mapping.converter, + ro: { + id: teableFieldId, + name: field.name, + description, + type: mapping.type, + options: mapping.options, + } as IFieldRo, + }); + continue; + } + + if (field.type === 'multipleLookupValues') { + const linkFieldId = field.options?.recordLinkFieldId ?? ''; + const targetFieldId = field.options?.fieldIdInLinkedTable ?? ''; + const linkField = table.fields.find((candidate) => candidate.id === linkFieldId); + const foreignTable = tableById.get(linkField?.options?.linkedTableId ?? ''); + const targetField = foreignTable?.fields.find( + (candidate) => candidate.id === targetFieldId + ); + const targetIsPlain = + targetField && + !['multipleRecordLinks', 'multipleLookupValues', 'rollup', 'count'].includes( + targetField.type + ); + if ( + field.options?.isValid !== false && + (plannedLinkIds.has(linkFieldId) || inverseFieldIds.has(linkFieldId)) && + foreignTable && + targetIsPlain + ) { + const teableFieldId = generateFieldId(); + fieldIdMap[field.id] = teableFieldId; + plan.lookupFields.push({ + airtableFieldId: field.id, + teableFieldId, + name: field.name, + description, + airtableLinkFieldId: linkFieldId, + airtableForeignTableId: foreignTable.id, + airtableTargetFieldId: targetFieldId, + }); + continue; + } + issues.push({ + code: 'fieldDegraded', + tableName: table.name, + fieldName: field.name, + fromType: field.type, + toType: longTextSnapshotLabel, + }); + const teableFieldId = generateFieldId(); + fieldIdMap[field.id] = teableFieldId; + plan.fields.push({ + airtableFieldId: field.id, + converter: 'snapshotText', + ro: { + id: teableFieldId, + name: field.name, + description, + type: FieldType.LongText, + options: {}, + } as IFieldRo, + }); + continue; + } + + if (field.type === 'formula' || field.type === 'rollup') { + // Recreate as a live computed field when possible: a formula when every + // function translates, a rollup when its aggregation maps and the base + // model (from the shared link) supplied its source. Otherwise the + // computed values are kept as a typed static snapshot so no data is lost. + const translation = + field.type === 'formula' && field.options?.formula && field.options?.isValid !== false + ? translateAirtableFormula(field.options.formula) + : null; + const teableFieldId = generateFieldId(); + fieldIdMap[field.id] = teableFieldId; + if (translation?.ok) { + plan.formulaFields.push({ + airtableFieldId: field.id, + teableFieldId, + name: field.name, + description, + expression: translation.expression, + }); + continue; + } + if (field.type === 'rollup') { + const source = rollupSources?.get(field.id); + const expression = source ? mapAirtableRollupAggregation(source.aggregation) : null; + const linkField = table.fields.find( + (candidate) => candidate.id === source?.relationColumnId + ); + const foreignTableId = linkField?.options?.linkedTableId ?? ''; + const linkIsImported = + !!source && + (plannedLinkIds.has(source.relationColumnId) || + inverseFieldIds.has(source.relationColumnId)); + if (source && expression && linkIsImported && tableById.has(foreignTableId)) { + plan.rollupFields.push({ + airtableFieldId: field.id, + teableFieldId, + name: field.name, + description, + expression, + airtableLinkFieldId: source.relationColumnId, + airtableForeignTableId: foreignTableId, + airtableForeignFieldId: source.foreignTableRollupColumnId, + filter: source.filter, + }); + continue; + } + } + const mapping = snapshotMappingFromResult(field.options?.result); + issues.push({ + code: 'fieldDegraded', + tableName: table.name, + fieldName: field.name, + fromType: field.type, + toType: `${mapping.type} snapshot`, + }); + plan.fields.push({ + airtableFieldId: field.id, + converter: mapping.converter, + ro: { + id: teableFieldId, + name: field.name, + description, + type: mapping.type, + options: mapping.options, + } as IFieldRo, + }); + continue; + } + + const mapping = mapField(field, fieldNameById); + if (mapping.kind === 'skip') { + issues.push({ + code: 'fieldSkipped', + tableName: table.name, + fieldName: field.name, + fromType: field.type, + reason: mapping.reason, + }); + continue; + } + if (mapping.degradedTo) { + issues.push({ + code: 'fieldDegraded', + tableName: table.name, + fieldName: field.name, + fromType: field.type, + toType: mapping.degradedTo, + }); + } + const teableFieldId = generateFieldId(); + fieldIdMap[field.id] = teableFieldId; + plan.fields.push({ + airtableFieldId: field.id, + converter: mapping.converter, + aiPromptParts: mapping.aiPromptParts, + ro: { + id: teableFieldId, + name: field.name, + description, + type: mapping.type, + options: mapping.options, + } as IFieldRo, + }); + } + + applyPrimaryField(table, plan, issues, fieldIdMap); + return plan; + }); + + return { tables: tablePlans, fieldIdMap, issues }; +}; +/* eslint-enable sonarjs/cognitive-complexity */ + +/** + * Ensures the table has a valid primary field as its first phase-1 field. + * When the Airtable primary field maps to something that cannot be a Teable + * primary (link, attachment, checkbox…), it is replaced by a text snapshot. + */ +const applyPrimaryField = ( + table: IAirtableTable, + plan: IAirtableTablePlan, + issues: IImportAirtableIssue[], + fieldIdMap: Record +) => { + const primaryIndex = plan.fields.findIndex( + (field) => field.airtableFieldId === table.primaryFieldId + ); + const primaryField = primaryIndex >= 0 ? plan.fields[primaryIndex] : undefined; + const isCompatible = primaryField && !primaryIncompatibleTypes.has(primaryField.ro.type); + + if (primaryField && isCompatible) { + plan.fields.splice(primaryIndex, 1); + plan.fields.unshift({ + ...primaryField, + ro: { ...primaryField.ro, isPrimary: true } as IFieldRo, + }); + return; + } + + // The Airtable primary maps to a phase-2 field or an incompatible type: + // degrade it (or synthesize one) as a text snapshot primary. + const airtableField = table.fields.find((field) => field.id === table.primaryFieldId); + const name = airtableField?.name ?? 'Name'; + if (primaryField) { + plan.fields.splice(primaryIndex, 1); + } + plan.linkFields = plan.linkFields.filter( + (field) => field.airtableFieldId !== table.primaryFieldId + ); + plan.lookupFields = plan.lookupFields.filter( + (field) => field.airtableFieldId !== table.primaryFieldId + ); + plan.countFields = plan.countFields.filter( + (field) => field.airtableFieldId !== table.primaryFieldId + ); + const teableFieldId = generateFieldId(); + fieldIdMap[table.primaryFieldId] = teableFieldId; + issues.push({ + code: 'fieldDegraded', + tableName: table.name, + fieldName: name, + fromType: airtableField?.type ?? 'unknown', + toType: 'singleLineText snapshot', + }); + plan.fields.unshift({ + airtableFieldId: table.primaryFieldId, + converter: 'snapshotText', + ro: { + id: teableFieldId, + name, + type: FieldType.SingleLineText, + options: {}, + isPrimary: true, + } as IFieldRo, + }); +}; diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-share.client.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-share.client.ts new file mode 100644 index 0000000000..f1e96e4ad8 --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-share.client.ts @@ -0,0 +1,382 @@ +import { Readable } from 'node:stream'; +import { finished } from 'node:stream/promises'; +import { parser } from 'stream-json'; +import Assembler from 'stream-json/Assembler'; +import { ignore } from 'stream-json/filters/Ignore'; + +/** + * Reads Airtable view configuration (filters/sorts/groups/kanban stacking) that + * the official Web API does not expose, through the same undocumented endpoint + * behind a public shared-base link that Baserow and NocoDB use. It is read-only, + * authenticated solely by the signed access policy embedded in the share page, + * and used as an opt-in enhancement that always degrades gracefully. + */ +const shareBaseUrl = 'https://airtable.com'; +const shareApiBaseUrl = 'https://airtable.com/v0.3'; +const browserUserAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'; +// Polite spacing between sequential reads of the undocumented private endpoint. +const minRequestIntervalMs = 200; +const sessionNotResolvedMessage = 'Share session not resolved.'; + +export type IAirtableShareErrorReason = + | 'not_public' + | 'not_a_base' + | 'requires_auth' + | 'base_mismatch' + | 'resolve_failed'; + +export class AirtableShareError extends Error { + constructor( + message: string, + public readonly reason: IAirtableShareErrorReason + ) { + super(message); + this.name = 'AirtableShareError'; + } +} + +/** Opaque, time-boxed session scraped from the share page; replayed verbatim. */ +export interface IAirtableShareSession { + appId: string; + accessPolicy: string; + requestId: string; + pageLoadId: string; + codeVersion: string; + cookie: string; +} + +export interface IAirtableFilterLeaf { + columnId: string; + operator: string; + value: unknown; +} + +export interface IAirtableFilterGroup { + conjunction: 'and' | 'or'; + filterSet: Array; +} + +export interface IAirtableSort { + columnId: string; + ascending: boolean; +} + +export interface IAirtableGroupLevel { + columnId: string; + order: 'ascending' | 'descending'; +} + +export interface IAirtableViewConfig { + filters: IAirtableFilterGroup | null; + sorts: IAirtableSort[] | null; + groupLevels: IAirtableGroupLevel[] | null; + metadata: Record | undefined; +} + +/** A rollup column's source, read from the base model the official API does not expose. */ +export interface IAirtableRollupSource { + /** Airtable link field the rollup summarizes through. */ + relationColumnId: string; + /** Airtable field, in the linked table, being rolled up. */ + foreignTableRollupColumnId: string; + /** Aggregation formula, e.g. "SUM(values)". */ + aggregation: string; + /** Optional "only include linked records that meet conditions" filter. */ + filter: IAirtableFilterGroup | null; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Locates a rollup's record-selection filter inside its typeOptions by shape + * (an object carrying a `filterSet`), so it is found regardless of the exact key. + */ +const findAirtableFilter = (typeOptions: Record): IAirtableFilterGroup | null => { + for (const value of Object.values(typeOptions)) { + if ( + value && + typeof value === 'object' && + Array.isArray((value as { filterSet?: unknown }).filterSet) + ) { + return value as IAirtableFilterGroup; + } + } + return null; +}; + +export class AirtableShareClient { + private lastRequestAt = 0; + private session?: IAirtableShareSession; + + /** appId carried by a canonical share URL, for a cheap client-side pre-check. */ + static parseBaseIdFromLink(shareLink: string): string | undefined { + return shareLink.match(/app[A-Za-z0-9]+/)?.[0]; + } + + /** + * Resolves a public shared-base link into a session by scraping the share + * page. Throws AirtableShareError with a typed reason on every failure mode. + */ + async resolveShare(shareLink: string): Promise { + const { html, cookie } = await this.fetchSharePage(this.buildShareUrl(shareLink)); + const session = this.parseShareSession(html, cookie); + this.session = session; + return session; + } + + private async fetchSharePage(shareUrl: string): Promise<{ html: string; cookie: string }> { + let response: Response; + try { + response = await fetch(shareUrl, { + // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name + headers: { 'User-Agent': browserUserAgent, Accept: 'text/html' }, + redirect: 'manual', + }); + } catch (e) { + throw new AirtableShareError( + `Failed to reach Airtable: ${e instanceof Error ? e.message : 'network error'}`, + 'resolve_failed' + ); + } + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location') ?? ''; + if (location.startsWith('/login')) { + throw new AirtableShareError( + 'The shared base is not public; turn on the public shared-base link in Airtable.', + 'requires_auth' + ); + } + throw new AirtableShareError('The share link is not publicly accessible.', 'not_public'); + } + if (!response.ok) { + throw new AirtableShareError( + `The share link is not accessible (status ${response.status}).`, + 'not_public' + ); + } + const cookie = (response.headers.getSetCookie?.() ?? []) + .map((entry) => entry.split(';')[0]) + .join('; '); + return { html: await response.text(), cookie }; + } + + private parseShareSession(html: string, cookie: string): IAirtableShareSession { + const requestId = html.match(/requestId: "(.*?)",/)?.[1]; + const initRaw = html.match(/window\.initData = (.*?);\n/)?.[1]; + if (!requestId || !initRaw) { + throw new AirtableShareError( + 'The link does not look like an Airtable shared base.', + 'not_a_base' + ); + } + let initData: { + sharedApplicationId?: string; + accessPolicy?: unknown; + pageLoadId?: string; + codeVersion?: string; + }; + try { + initData = JSON.parse(initRaw); + } catch { + throw new AirtableShareError('Failed to read the shared base metadata.', 'resolve_failed'); + } + const appId = initData.sharedApplicationId; + if (!appId || typeof initData.accessPolicy !== 'string') { + throw new AirtableShareError( + 'The link is a shared view, not a shared base. Share the whole base instead.', + 'not_a_base' + ); + } + return { + appId, + accessPolicy: initData.accessPolicy, + requestId, + pageLoadId: initData.pageLoadId ?? '', + codeVersion: initData.codeVersion ?? '', + cookie, + }; + } + + /** Asserts the resolved share points at the base the import is creating. */ + assertBaseMatch(expectedBaseId: string): void { + if (!this.session) { + throw new AirtableShareError(sessionNotResolvedMessage, 'resolve_failed'); + } + if (this.session.appId !== expectedBaseId) { + throw new AirtableShareError( + `The share link points at a different base (${this.session.appId}) than the one being imported (${expectedBaseId}).`, + 'base_mismatch' + ); + } + } + + /** + * Reads one view's configuration. The response also carries the view's full + * row ordering; those branches are dropped at the token level so memory stays + * flat regardless of how many records the view holds. + */ + async fetchViewConfig(viewId: string): Promise { + const session = this.session; + if (!session) { + throw new AirtableShareError(sessionNotResolvedMessage, 'resolve_failed'); + } + await this.throttle(); + + const params = new URLSearchParams({ + stringifiedObjectParams: JSON.stringify({ + mayOnlyIncludeRowAndCellDataForIncludedViews: true, + mayExcludeCellDataForLargeViews: true, + allowMsgpackOfResult: false, + }), + requestId: session.requestId, + accessPolicy: session.accessPolicy, + }); + const response = await fetch( + `${shareApiBaseUrl}/view/${encodeURIComponent(viewId)}/readData?${params.toString()}`, + { headers: this.apiHeaders(session) } + ); + if (!response.ok || !response.body) { + throw new AirtableShareError( + `Failed to read view ${viewId} (status ${response.status}).`, + 'resolve_failed' + ); + } + + const root = await this.assembleJson( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Readable.fromWeb(response.body as any), + /(?:^|\.)(rowOrder|signedUserContentUrls)(?:\.|$)/ + ); + const data = (root?.data ?? {}) as { + filters?: IAirtableFilterGroup; + lastSortsApplied?: { sortSet?: IAirtableSort[] }; + groupLevels?: IAirtableGroupLevel[]; + metadata?: Record; + }; + return { + filters: data.filters ?? null, + sorts: data.lastSortsApplied?.sortSet ?? null, + groupLevels: data.groupLevels ?? null, + metadata: data.metadata, + }; + } + + /** + * Reads the whole base model — which the official API never exposes enough of + * to recreate rollups — and returns each rollup's link/foreign field, + * aggregation, and optional filter. The model also embeds every record + * (`tableDatas`); that branch is dropped at the token level so memory stays + * flat regardless of base size. + */ + // eslint-disable-next-line sonarjs/cognitive-complexity -- flat schema walk + async fetchApplicationModel(): Promise> { + const session = this.session; + if (!session) { + throw new AirtableShareError(sessionNotResolvedMessage, 'resolve_failed'); + } + await this.throttle(); + + const params = new URLSearchParams({ + stringifiedObjectParams: JSON.stringify({}), + requestId: session.requestId, + accessPolicy: session.accessPolicy, + }); + const response = await fetch( + `${shareApiBaseUrl}/application/${encodeURIComponent(session.appId)}/read?${params.toString()}`, + { headers: this.apiHeaders(session) } + ); + if (!response.ok || !response.body) { + throw new AirtableShareError( + `Failed to read the base model (status ${response.status}).`, + 'resolve_failed' + ); + } + + const root = await this.assembleJson( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Readable.fromWeb(response.body as any), + /(?:^|\.)(tableDatas)(?:\.|$)/ + ); + const tableSchemas = ((root?.data as { tableSchemas?: unknown })?.tableSchemas ?? []) as Array<{ + columns?: Array<{ id?: string; type?: string; typeOptions?: Record }>; + }>; + + const rollups = new Map(); + for (const table of tableSchemas) { + for (const column of table.columns ?? []) { + if (column.type !== 'rollup' || !column.id || !column.typeOptions) continue; + const { relationColumnId, foreignTableRollupColumnId, formulaTextParsed } = + column.typeOptions; + if ( + typeof relationColumnId !== 'string' || + typeof foreignTableRollupColumnId !== 'string' || + typeof formulaTextParsed !== 'string' + ) { + continue; + } + rollups.set(column.id, { + relationColumnId, + foreignTableRollupColumnId, + aggregation: formulaTextParsed, + filter: findAirtableFilter(column.typeOptions), + }); + } + } + return rollups; + } + + /** Assembles the JSON value while dropping the matched (large) payload branches. */ + private async assembleJson( + stream: Readable, + ignoreBranches: RegExp + ): Promise> { + const tokens = stream.pipe(parser()).pipe(ignore({ filter: ignoreBranches })); + const assembler = Assembler.connectTo(tokens); + await finished(tokens); + return (assembler.current ?? {}) as Record; + } + + /** + * Builds the canonical `airtable.com/{appId}/{shrId}` URL. The share id alone + * (without the app prefix) 404s; the prefixed form is what Airtable's own + * "Copy link" produces and what other importers request. + */ + private buildShareUrl(shareLink: string): string { + const shareId = shareLink.match(/shr[A-Za-z0-9]+/)?.[0]; + if (!shareId) { + throw new AirtableShareError( + 'Enter a valid Airtable shared-base link (https://airtable.com/.../shr...).', + 'not_a_base' + ); + } + const appId = shareLink.match(/app[A-Za-z0-9]+/)?.[0]; + return appId ? `${shareBaseUrl}/${appId}/${shareId}` : `${shareBaseUrl}/${shareId}`; + } + + private apiHeaders(session: IAirtableShareSession): Record { + /* eslint-disable @typescript-eslint/naming-convention -- HTTP header names */ + return { + 'User-Agent': browserUserAgent, + Accept: 'application/json', + 'x-airtable-application-id': session.appId, + 'x-airtable-inter-service-client': 'webClient', + 'x-airtable-inter-service-client-code-version': session.codeVersion, + 'x-airtable-page-load-id': session.pageLoadId, + 'X-Requested-With': 'XMLHttpRequest', + 'x-time-zone': 'UTC', + 'x-user-locale': 'en', + cookie: session.cookie, + }; + /* eslint-enable @typescript-eslint/naming-convention */ + } + + private async throttle(): Promise { + const wait = this.lastRequestAt + minRequestIntervalMs - Date.now(); + if (wait > 0) { + await sleep(wait); + } + this.lastRequestAt = Date.now(); + } +} diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-token-resolver.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-token-resolver.ts new file mode 100644 index 0000000000..cc74ec8793 --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-token-resolver.ts @@ -0,0 +1,16 @@ +/** + * Resolves the Airtable access token of a connected user integration on the + * server, so OAuth tokens never travel through the browser. The community + * edition has no integration storage; the enterprise backend provides an + * implementation through this injection token (optional dependency). + */ +export const AIRTABLE_IMPORT_TOKEN_RESOLVER = 'AIRTABLE_IMPORT_TOKEN_RESOLVER'; + +export interface IAirtableImportTokenResolver { + /** + * Returns a currently valid access token for the given integration of the + * requesting user (refreshing it server-side when expired). Must reject + * when the integration does not exist or belongs to another user. + */ + resolveAccessToken(integrationId: string): Promise; +} diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-view-config-mapper.spec.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-view-config-mapper.spec.ts new file mode 100644 index 0000000000..e0c9e58276 --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-view-config-mapper.spec.ts @@ -0,0 +1,152 @@ +import { CellValueType, FieldType, RowHeightLevel, ViewType } from '@teable/core'; +import type { IImportAirtableIssue } from '@teable/openapi'; +import { describe, expect, it } from 'vitest'; +import type { IAirtableViewConfig } from './airtable-share.client'; +import { + mapAirtableViewConfig, + type IImportFieldMeta, + type IViewConfigMapperContext, +} from './airtable-view-config-mapper'; + +const field = ( + fieldId: string, + type: FieldType, + cellValueType: CellValueType, + isMultipleCellValue = false +): IImportFieldMeta => ({ fieldId, type, cellValueType, isMultipleCellValue }); + +const fields: Record = { + colText: field('fldText', FieldType.SingleLineText, CellValueType.String), + colNum: field('fldNum', FieldType.Number, CellValueType.Number), + colSel: field('fldSel', FieldType.SingleSelect, CellValueType.String), + colDate: field('fldDate', FieldType.Date, CellValueType.DateTime), + colUser: field('fldUser', FieldType.User, CellValueType.String), + colLink: field('fldLink', FieldType.Link, CellValueType.String, true), + colAtt: field('fldAtt', FieldType.Attachment, CellValueType.String, true), +}; + +const ctx: IViewConfigMapperContext = { + resolveField: (columnId) => fields[columnId], + resolveSelectOptionName: (columnId, optionId) => + columnId === 'colSel' ? { optHigh: 'High', optLow: 'Low' }[optionId] : undefined, +}; + +const emptyConfig = (over: Partial = {}): IAirtableViewConfig => ({ + filters: null, + sorts: null, + groupLevels: null, + metadata: undefined, + ...over, +}); + +const run = (config: IAirtableViewConfig, teableViewType = ViewType.Grid) => { + const issues: IImportAirtableIssue[] = []; + const result = mapAirtableViewConfig({ + teableViewType, + config, + ctx, + tableName: 'T', + viewName: 'V', + issues, + }); + return { result, issues }; +}; + +const filterOf = (...leaves: Array<{ columnId: string; operator: string; value: unknown }>) => + emptyConfig({ filters: { conjunction: 'and', filterSet: leaves } }); + +describe('mapAirtableViewConfig filters', () => { + it('maps text, number and select operators with mapped field ids and option names', () => { + const { result } = run( + filterOf( + { columnId: 'colText', operator: 'contains', value: '1' }, + { columnId: 'colNum', operator: '>', value: 5 }, + { columnId: 'colSel', operator: 'isAnyOf', value: ['optHigh', 'optLow'] } + ) + ); + expect(result.filter).toEqual({ + conjunction: 'and', + filterSet: [ + { fieldId: 'fldText', operator: 'contains', value: '1' }, + { fieldId: 'fldNum', operator: 'isGreater', value: 5 }, + { fieldId: 'fldSel', operator: 'isAnyOf', value: ['High', 'Low'] }, + ], + }); + }); + + it('keeps empty/not-empty with a null value and maps a date condition to a mode object', () => { + const { result } = run( + filterOf( + { columnId: 'colText', operator: 'isEmpty', value: null }, + { columnId: 'colDate', operator: '=', value: { mode: 'today' } } + ) + ); + expect(result.filter?.filterSet).toEqual([ + { fieldId: 'fldText', operator: 'isEmpty', value: null }, + { fieldId: 'fldDate', operator: 'is', value: { mode: 'today', timeZone: 'UTC' } }, + ]); + }); + + it('drops conditions that cannot be converted and reports them, never guessing', () => { + // attachment filename has no Teable equivalent; a link condition references + // specific records that cannot be remapped; an unknown field is skipped. + const { result, issues } = run( + filterOf( + { columnId: 'colAtt', operator: 'filename', value: 'a.png' }, + { columnId: 'colLink', operator: 'isAnyOf', value: ['rec1'] }, + { columnId: 'colLink', operator: 'isEmpty', value: null }, + { columnId: 'colGone', operator: 'contains', value: 'x' } + ) + ); + // Only the link "is empty" condition survives. + expect(result.filter?.filterSet).toEqual([ + { fieldId: 'fldLink', operator: 'isEmpty', value: null }, + ]); + expect(issues.every((issue) => issue.code === 'viewConfigDegraded')).toBe(true); + expect(issues.length).toBeGreaterThanOrEqual(3); + }); +}); + +describe('mapAirtableViewConfig sort, group and options', () => { + it('maps sorts and grid grouping by order', () => { + const { result } = run( + emptyConfig({ + sorts: [ + { columnId: 'colText', ascending: true }, + { columnId: 'colNum', ascending: false }, + ], + groupLevels: [{ columnId: 'colSel', order: 'descending' }], + }) + ); + expect(result.sort).toEqual({ + sortObjs: [ + { fieldId: 'fldText', order: 'asc' }, + { fieldId: 'fldNum', order: 'desc' }, + ], + manualSort: false, + }); + expect(result.group).toEqual([{ fieldId: 'fldSel', order: 'desc' }]); + }); + + it('reads the kanban stack from the group level (a collaborator field, not a guess)', () => { + const { result } = run( + emptyConfig({ + groupLevels: [{ columnId: 'colUser', order: 'ascending' }], + metadata: { kanban: { coverColumnId: 'colAtt' } }, + }), + ViewType.Kanban + ); + // For kanban the group level is the stack field; no row grouping is emitted. + expect(result.group).toBeUndefined(); + expect(result.options).toEqual({ stackFieldId: 'fldUser', coverFieldId: 'fldAtt' }); + }); + + it('maps known grid row heights and degrades unknown ones', () => { + expect(run(emptyConfig({ metadata: { grid: { rowHeight: 'tall' } } })).result.options).toEqual({ + rowHeight: RowHeightLevel.Tall, + }); + const unknown = run(emptyConfig({ metadata: { grid: { rowHeight: 'gigantic' } } })); + expect(unknown.result.options).toBeUndefined(); + expect(unknown.issues[0]?.code).toBe('viewConfigDegraded'); + }); +}); diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable-view-config-mapper.ts b/apps/nestjs-backend/src/features/airtable-import/airtable-view-config-mapper.ts new file mode 100644 index 0000000000..50e7859e06 --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable-view-config-mapper.ts @@ -0,0 +1,396 @@ +import type { + IDateTimeFieldOperator, + IFilter, + IFilterItem, + IFilterSet, + IGroup, + IOperator, + ISort, + ISortItem, +} from '@teable/core'; +import { + CellValueType, + FieldType, + getValidFilterOperators, + getValidFilterSubOperators, + RowHeightLevel, + SortFunc, + ViewType, +} from '@teable/core'; +import type { IImportAirtableIssue } from '@teable/openapi'; +import type { + IAirtableFilterGroup, + IAirtableFilterLeaf, + IAirtableViewConfig, +} from './airtable-share.client'; + +/** The Teable field a mapped view references, with what filtering needs. */ +export interface IImportFieldMeta { + fieldId: string; + type: FieldType; + cellValueType: CellValueType; + isMultipleCellValue: boolean; +} + +export interface IViewConfigMapperContext { + /** airtable columnId (a field id) -> imported Teable field, or undefined. */ + resolveField: (columnId: string) => IImportFieldMeta | undefined; + /** airtable select option id -> its name (Teable filters select by name). */ + resolveSelectOptionName: (columnId: string, optionId: string) => string | undefined; +} + +export interface IMappedViewConfig { + filter?: IFilter; + sort?: ISort; + group?: IGroup; + options?: Record; +} + +/** Airtable date-filter mode -> Teable date sub-operator (mode). */ +const dateModeMap: Record = { + today: 'today', + tomorrow: 'tomorrow', + yesterday: 'yesterday', + exactDate: 'exactDate', + daysAgo: 'daysAgo', + daysFromNow: 'daysFromNow', + oneWeekAgo: 'oneWeekAgo', + oneWeekFromNow: 'oneWeekFromNow', + oneMonthAgo: 'oneMonthAgo', + oneMonthFromNow: 'oneMonthFromNow', + pastWeek: 'pastWeek', + pastMonth: 'pastMonth', + pastYear: 'pastYear', + nextWeek: 'nextWeek', + nextMonth: 'nextMonth', + nextYear: 'nextYear', + pastNumberOfDays: 'pastNumberOfDays', + nextNumberOfDays: 'nextNumberOfDays', + thisCalendarWeek: 'currentWeek', + thisCalendarMonth: 'currentMonth', + thisCalendarYear: 'currentYear', +}; + +/** Airtable rowHeight -> Teable RowHeightLevel; unknown -> undefined (degrade). */ +const rowHeightMap: Record = { + short: RowHeightLevel.Short, + medium: RowHeightLevel.Medium, + tall: RowHeightLevel.Tall, + extraTall: RowHeightLevel.ExtraTall, + // legacy alias seen in other importers + xlarge: RowHeightLevel.ExtraTall, +}; + +const isSelectField = (meta: IImportFieldMeta) => + meta.type === FieldType.SingleSelect || meta.type === FieldType.MultipleSelect; + +const isMultiValued = (meta: IImportFieldMeta) => + meta.isMultipleCellValue === true || meta.type === FieldType.MultipleSelect; + +/** + * Fields whose Airtable filter value references specific records/collaborators + * (link, user, ...). Those ids cannot be remapped reliably at view-config time, + * so value-based conditions on them are dropped (empty/not-empty still apply). + */ +const isRecordReferenceField = (meta: IImportFieldMeta) => + meta.type === FieldType.Link || + meta.type === FieldType.User || + meta.type === FieldType.CreatedBy || + meta.type === FieldType.LastModifiedBy; + +// Operator dispatch: branchy by nature (field type x Airtable operator). +// eslint-disable-next-line sonarjs/cognitive-complexity +const mapOperator = (airtableOperator: string, meta: IImportFieldMeta): IOperator | undefined => { + if (airtableOperator === 'isEmpty') return 'isEmpty'; + if (airtableOperator === 'isNotEmpty') return 'isNotEmpty'; + + const isDate = meta.cellValueType === CellValueType.DateTime; + const multi = isMultiValued(meta); + + switch (airtableOperator) { + case '=': + if (isDate) return 'is'; + return multi ? 'isExactly' : 'is'; + case '!=': + if (isDate) return 'isNot'; + return multi ? 'isNotExactly' : 'isNot'; + case '<': + return isDate ? 'isBefore' : 'isLess'; + case '<=': + return isDate ? 'isOnOrBefore' : 'isLessEqual'; + case '>': + return isDate ? 'isAfter' : 'isGreater'; + case '>=': + return isDate ? 'isOnOrAfter' : 'isGreaterEqual'; + case 'contains': + return 'contains'; + case 'doesNotContain': + return 'doesNotContain'; + case 'isWithin': + return 'isWithIn'; + case 'isAnyOf': + case '|': + return multi ? 'hasAnyOf' : 'isAnyOf'; + case 'isNoneOf': + return multi ? 'hasNoneOf' : 'isNoneOf'; + case '&': + return multi ? 'hasAllOf' : 'isAnyOf'; + default: + // filename, filetype, and any unknown operator + return undefined; + } +}; + +const needsNoValue = (operator: IOperator) => operator === 'isEmpty' || operator === 'isNotEmpty'; + +/** Maps Airtable select option id(s) to the option name(s) Teable filters by. */ +const mapSelectFilterValue = ( + leaf: IAirtableFilterLeaf, + operator: IOperator, + meta: IImportFieldMeta, + ctx: IViewConfigMapperContext +): { value: IFilterItem['value'] } | undefined => { + const ids = Array.isArray(leaf.value) ? leaf.value : [leaf.value]; + const names = ids + .map((id) => + typeof id === 'string' ? ctx.resolveSelectOptionName(leaf.columnId, id) : undefined + ) + .filter((name): name is string => name != null); + if (names.length === 0) return undefined; + const multi = isMultiValued(meta) || operator === 'isAnyOf' || operator === 'isNoneOf'; + return { value: multi ? names : names[0] }; +}; + +/** Maps a scalar (number/boolean/text) Airtable filter value to Teable's. */ +const mapScalarFilterValue = ( + value: unknown, + cellValueType: CellValueType +): { value: IFilterItem['value'] } | undefined => { + if (cellValueType === CellValueType.Boolean) { + return { value: value === true || value === 'true' }; + } + if (cellValueType === CellValueType.Number) { + const num = typeof value === 'number' ? value : Number(value); + return Number.isFinite(num) ? { value: num } : undefined; + } + if (typeof value === 'string' || typeof value === 'number') { + return { value: String(value) }; + } + return undefined; +}; + +/** Drops a clause (returns undefined) or yields the mapped Teable value. */ +const mapFilterValue = ( + leaf: IAirtableFilterLeaf, + operator: IOperator, + meta: IImportFieldMeta, + ctx: IViewConfigMapperContext +): { value: IFilterItem['value'] } | undefined => { + if (needsNoValue(operator)) return { value: null }; + // Conditions that point at specific linked records/collaborators can't be + // remapped to the new ids here; keep the import honest by dropping them. + if (isRecordReferenceField(meta)) return undefined; + if (isSelectField(meta)) return mapSelectFilterValue(leaf, operator, meta, ctx); + if (meta.cellValueType === CellValueType.DateTime) { + return mapDateValue(leaf.value, operator, meta); + } + return mapScalarFilterValue(leaf.value, meta.cellValueType); +}; + +const mapDateValue = ( + rawValue: unknown, + operator: IOperator, + meta: IImportFieldMeta +): { value: IFilterItem['value'] } | undefined => { + if (!rawValue || typeof rawValue !== 'object') return undefined; + const source = rawValue as { mode?: string; numberOfDays?: number; exactDate?: string }; + const teableMode = source.mode ? dateModeMap[source.mode] : undefined; + if (!teableMode) return undefined; + + const validModes = getValidFilterSubOperators(meta.type, operator as IDateTimeFieldOperator); + if (!validModes || !validModes.includes(teableMode as never)) return undefined; + + const value: Record = { mode: teableMode, timeZone: 'UTC' }; + if (source.numberOfDays != null) value.numberOfDays = source.numberOfDays; + if (source.exactDate != null) value.exactDate = source.exactDate; + return { value: value as IFilterItem['value'] }; +}; + +const mapFilterLeaf = ( + leaf: IAirtableFilterLeaf, + ctx: IViewConfigMapperContext, + onDrop: (reason: string) => void +): IFilterItem | undefined => { + const meta = ctx.resolveField(leaf.columnId); + if (!meta) { + onDrop('a filter field was not imported'); + return undefined; + } + const operator = mapOperator(leaf.operator, meta); + if (!operator || !getValidFilterOperators(meta).includes(operator)) { + onDrop(`operator "${leaf.operator}" is not supported on this field`); + return undefined; + } + const mapped = mapFilterValue(leaf, operator, meta, ctx); + if (!mapped) { + onDrop(`a "${leaf.operator}" condition could not be converted`); + return undefined; + } + return { fieldId: meta.fieldId, operator, value: mapped.value }; +}; + +const isFilterGroup = ( + node: IAirtableFilterLeaf | IAirtableFilterGroup +): node is IAirtableFilterGroup => + (node as IAirtableFilterGroup).filterSet !== undefined && + (node as IAirtableFilterGroup).conjunction !== undefined; + +const mapFilterNode = ( + node: IAirtableFilterGroup, + ctx: IViewConfigMapperContext, + onDrop: (reason: string) => void +): IFilterSet | undefined => { + const filterSet: Array = []; + for (const child of node.filterSet ?? []) { + if (isFilterGroup(child)) { + const nested = mapFilterNode(child, ctx, onDrop); + if (nested && nested.filterSet.length > 0) filterSet.push(nested); + } else { + const leaf = mapFilterLeaf(child, ctx, onDrop); + if (leaf) filterSet.push(leaf); + } + } + if (filterSet.length === 0) return undefined; + return { conjunction: node.conjunction === 'or' ? 'or' : 'and', filterSet }; +}; + +/** + * Maps an Airtable filter group to a Teable filter. Shared by view-config import + * and rollup record-selection ("only include linked records that meet conditions"). + */ +export const mapAirtableFilter = ( + filter: IAirtableFilterGroup, + ctx: IViewConfigMapperContext, + onDrop: (reason: string) => void = () => undefined +): IFilter | undefined => mapFilterNode(filter, ctx, onDrop); + +const mapSorts = ( + config: IAirtableViewConfig, + ctx: IViewConfigMapperContext +): ISort | undefined => { + const sortObjs: ISortItem[] = []; + for (const sort of config.sorts ?? []) { + const meta = ctx.resolveField(sort.columnId); + if (!meta) continue; + sortObjs.push({ fieldId: meta.fieldId, order: sort.ascending ? SortFunc.Asc : SortFunc.Desc }); + } + return sortObjs.length > 0 ? { sortObjs, manualSort: false } : undefined; +}; + +const mapGroups = ( + config: IAirtableViewConfig, + ctx: IViewConfigMapperContext +): IGroup | undefined => { + const group = (config.groupLevels ?? []) + .map((level) => { + const meta = ctx.resolveField(level.columnId); + if (!meta) return undefined; + return { + fieldId: meta.fieldId, + order: level.order === 'descending' ? SortFunc.Desc : SortFunc.Asc, + }; + }) + .filter((item): item is { fieldId: string; order: SortFunc } => item != null); + return group.length > 0 ? group : undefined; +}; + +const metadataFieldId = ( + metadata: Record | undefined, + path: string[], + ctx: IViewConfigMapperContext +): string | undefined => { + let node: unknown = metadata; + for (const key of path) { + if (!node || typeof node !== 'object') return undefined; + node = (node as Record)[key]; + } + return typeof node === 'string' ? ctx.resolveField(node)?.fieldId : undefined; +}; + +const kanbanOptions = (config: IAirtableViewConfig, ctx: IViewConfigMapperContext) => { + const options: Record = {}; + // Airtable models the kanban "stack by" as the single group level. + const stackColumnId = config.groupLevels?.[0]?.columnId; + const stackFieldId = stackColumnId ? ctx.resolveField(stackColumnId)?.fieldId : undefined; + if (stackFieldId) options.stackFieldId = stackFieldId; + const coverFieldId = metadataFieldId(config.metadata, ['kanban', 'coverColumnId'], ctx); + if (coverFieldId) options.coverFieldId = coverFieldId; + return options; +}; + +const calendarOptions = (config: IAirtableViewConfig, ctx: IViewConfigMapperContext) => { + const ranges = ( + config.metadata?.calendar as { dateColumnRanges?: Array<{ startColumnId?: string }> } + )?.dateColumnRanges; + const startColumnId = ranges?.[0]?.startColumnId; + const startDateFieldId = startColumnId ? ctx.resolveField(startColumnId)?.fieldId : undefined; + return startDateFieldId ? { startDateFieldId } : {}; +}; + +const gridOptions = (config: IAirtableViewConfig, onDrop: (reason: string) => void) => { + const rawRowHeight = (config.metadata?.grid as { rowHeight?: string })?.rowHeight; + if (!rawRowHeight) return {}; + const rowHeight = rowHeightMap[rawRowHeight]; + if (rowHeight) return { rowHeight }; + onDrop(`row height "${rawRowHeight}" is not supported`); + return {}; +}; + +const mapOptions = ( + teableViewType: ViewType, + config: IAirtableViewConfig, + ctx: IViewConfigMapperContext, + onDrop: (reason: string) => void +): Record | undefined => { + const builders: Partial Record>> = { + [ViewType.Kanban]: () => kanbanOptions(config, ctx), + [ViewType.Gallery]: () => { + const coverFieldId = metadataFieldId(config.metadata, ['gallery', 'coverColumnId'], ctx); + return coverFieldId ? { coverFieldId } : {}; + }, + [ViewType.Calendar]: () => calendarOptions(config, ctx), + [ViewType.Grid]: () => gridOptions(config, onDrop), + }; + const options = builders[teableViewType]?.() ?? {}; + return Object.keys(options).length > 0 ? options : undefined; +}; + +/** + * Maps one Airtable view's configuration to Teable filter/sort/group/options. + * Anything that cannot be converted faithfully is dropped and reported, never + * guessed — the core import is unaffected. + */ +export const mapAirtableViewConfig = (params: { + teableViewType: ViewType; + config: IAirtableViewConfig; + ctx: IViewConfigMapperContext; + tableName: string; + viewName: string; + issues: IImportAirtableIssue[]; +}): IMappedViewConfig => { + const { teableViewType, config, ctx, tableName, viewName, issues } = params; + const droppedReasons = new Set(); + const onDrop = (reason: string) => droppedReasons.add(reason); + + const filter = config.filters ? mapFilterNode(config.filters, ctx, onDrop) : undefined; + const sort = mapSorts(config, ctx); + // For kanban the single group level is the stack field, not a row grouping. + const group = teableViewType === ViewType.Kanban ? undefined : mapGroups(config, ctx); + const options = mapOptions(teableViewType, config, ctx, onDrop); + + for (const reason of droppedReasons) { + issues.push({ code: 'viewConfigDegraded', tableName, viewName, reason }); + } + + return { filter: filter ?? undefined, sort, group, options }; +}; diff --git a/apps/nestjs-backend/src/features/airtable-import/airtable.types.ts b/apps/nestjs-backend/src/features/airtable-import/airtable.types.ts new file mode 100644 index 0000000000..5e598e39cb --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/airtable.types.ts @@ -0,0 +1,124 @@ +/** + * Typings for the subset of the official Airtable Web API used by the importer. + * https://airtable.com/developers/web/api + */ + +export interface IAirtableBaseItem { + id: string; + name: string; + permissionLevel: 'none' | 'read' | 'comment' | 'edit' | 'create'; +} + +export interface IAirtableListBasesResponse { + bases: IAirtableBaseItem[]; + offset?: string; +} + +export interface IAirtableSelectChoice { + id: string; + name: string; + color?: string; +} + +export interface IAirtableFieldResult { + type: string; + options?: Record; +} + +/** + * Per-type options. The official API documents one shape per field type; we + * keep a single permissive interface because the mapper only reads a few keys + * per type and unknown field types must not break the import. + */ +export interface IAirtableFieldOptions { + // number / percent / currency + precision?: number; + symbol?: string; + // rating / checkbox + max?: number; + icon?: string; + color?: string; + // duration + durationFormat?: string; + // date / dateTime + dateFormat?: { name?: string; format?: string }; + timeFormat?: { name?: string; format?: string }; + timeZone?: string; + // select-like + choices?: IAirtableSelectChoice[]; + // multipleRecordLinks + linkedTableId?: string; + prefersSingleRecordLink?: boolean; + inverseLinkFieldId?: string; + isReversed?: boolean; + /** "Limit record selection to a view" — the linked table's view id, when set. */ + viewIdForRecordSelection?: string; + // lookup / rollup / count + recordLinkFieldId?: string | null; + fieldIdInLinkedTable?: string | null; + isValid?: boolean; + result?: IAirtableFieldResult | null; + // formula + formula?: string; + referencedFieldIds?: string[] | null; + // aiText: array of text chunks and field reference objects + prompt?: unknown[]; +} + +export interface IAirtableField { + id: string; + name: string; + type: string; + description?: string; + options?: IAirtableFieldOptions; +} + +export interface IAirtableView { + id: string; + name: string; + type: string; +} + +export interface IAirtableTable { + id: string; + name: string; + description?: string; + primaryFieldId: string; + fields: IAirtableField[]; + views: IAirtableView[]; +} + +export interface IAirtableBaseSchemaResponse { + tables: IAirtableTable[]; +} + +export interface IAirtableAttachment { + id: string; + url: string; + filename: string; + size?: number; + type?: string; +} + +export interface IAirtableCollaborator { + id: string; + email?: string; + name?: string; +} + +export interface IAirtableRecord { + id: string; + createdTime: string; + fields: Record; +} + +export interface IAirtableListRecordsResponse { + records: IAirtableRecord[]; + offset?: string; +} + +export interface IAirtableApiError { + status: number; + type?: string; + message?: string; +} diff --git a/apps/nestjs-backend/src/features/airtable-import/test-scripts/airtable-build-test-base.mjs b/apps/nestjs-backend/src/features/airtable-import/test-scripts/airtable-build-test-base.mjs new file mode 100644 index 0000000000..f8032bb4b8 --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/test-scripts/airtable-build-test-base.mjs @@ -0,0 +1,363 @@ +#!/usr/bin/env node +/** + * Builds a comprehensive "kitchen sink" test base in Airtable via the official + * meta API, to exercise the Airtable -> Teable importer. Reproducible: delete + * the base and re-run anytime. + * + * Goal: hit as many field types AND option combinations as the API allows, so + * every mapping branch the importer reads is covered — number precision, + * currency symbol, rating icon/color/max, date format/time format/time zone, + * duration format, select colors + special names, checkbox icon/color, and + * (especially) the many LINK shapes: multi, single, self (multi + single), + * several links between the same two tables, and a one-to-many whose MULTI + * side is traversed first (the case that must still resolve to ManyOne). + * + * The API CANNOT create: computed fields (formula / rollup / lookup / count), + * button, autoNumber, createdTime/By, externalSyncSource, or custom views. + * Those are reported at the end for manual add. + * + * Usage: + * AIRTABLE_PAT=patXXX WORKSPACE=wspXXX node src/features/airtable-import/test-scripts/airtable-build-test-base.mjs + * (or BASE_ID=appXXX to add the tables to an existing base instead of creating one) + * + * PAT scopes: schema.bases:write, data.records:write, schema.bases:read, data.records:read. + */ +const PAT = process.env.AIRTABLE_PAT; +const WORKSPACE = process.env.WORKSPACE; +const BASE_NAME = 'Import Test — Kitchen Sink'; +const API = 'https://api.airtable.com/v0'; +if (!PAT || (!WORKSPACE && !process.env.BASE_ID)) { + console.error('Set AIRTABLE_PAT and WORKSPACE (wsp…), or BASE_ID (app…) to reuse a base.'); + process.exit(1); +} + +const req = async (method, path, body) => { + const res = await fetch(`${API}${path}`, { + method, + headers: { Authorization: `Bearer ${PAT}`, 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + const json = text ? JSON.parse(text) : null; + if (!res.ok) { + const err = new Error(`${method} ${path} -> ${res.status} ${text.slice(0, 300)}`); + err.status = res.status; + throw err; + } + return json; +}; + +const failedFields = []; +const fieldIds = {}; // name -> id (only successfully created) +const addField = async (baseId, tableId, field) => { + try { + const created = await req('POST', `/meta/bases/${baseId}/tables/${tableId}/fields`, field); + fieldIds[field.name] = created.id; + return created.id; + } catch (e) { + const reason = e.message.split('->')[1]?.trim() ?? e.message; + console.warn(` ⚠ "${field.name}" (${field.type}) skipped: ${reason}`); + failedFields.push(`${field.name} (${field.type}): ${reason}`); + return null; + } +}; + +const main = async () => { + // 1) Tables — three so links can be cross-table, self, and from a 3rd table. + let baseId; + let A; // All Fields + let B; // Linked + let C; // Self + if (process.env.BASE_ID) { + baseId = process.env.BASE_ID; + const mk = async (name, primary) => + ( + await req('POST', `/meta/bases/${baseId}/tables`, { + name, + fields: [{ name: primary, type: 'singleLineText' }], + }) + ).id; + A = await mk('All Fields', 'Name'); + B = await mk('Linked', 'Name'); + C = await mk('Self', 'Title'); + console.log(`using existing base ${baseId} (added All Fields/Linked/Self)`); + } else { + const base = await req('POST', '/meta/bases', { + workspaceId: WORKSPACE, + name: BASE_NAME, + tables: [ + { name: 'All Fields', fields: [{ name: 'Name', type: 'singleLineText' }] }, + { name: 'Linked', fields: [{ name: 'Name', type: 'singleLineText' }] }, + { name: 'Self', fields: [{ name: 'Title', type: 'singleLineText' }] }, + ], + }); + baseId = base.id; + const tid = (name) => base.tables.find((t) => t.name === name).id; + A = tid('All Fields'); + B = tid('Linked'); + C = tid('Self'); + console.log(`base ${baseId} created (All Fields=${A}, Linked=${B}, Self=${C})`); + } + + // 2) Linked table — targets for lookups / rollups / counts (added manually). + await addField(baseId, B, { name: 'Amount', type: 'number', options: { precision: 2 } }); + await addField(baseId, B, { + name: 'Category', + type: 'singleSelect', + options: { choices: [{ name: 'X' }, { name: 'Y' }, { name: 'Z' }] }, + }); + await addField(baseId, B, { + name: 'When', + type: 'date', + options: { dateFormat: { name: 'iso' } }, + }); + await addField(baseId, B, { name: 'Files', type: 'multipleAttachments' }); + + // 3) All Fields — one column per type, spread across option combinations. + const basicFields = [ + // text family (email/url/phone get showAs on the Teable side) + { name: 'Long text', type: 'multilineText' }, + { name: 'Rich text', type: 'richText' }, + { name: 'Email', type: 'email' }, + { name: 'URL', type: 'url' }, + { name: 'Phone', type: 'phoneNumber' }, + { name: 'Barcode', type: 'barcode' }, + // number precision / percent / currency symbols + { name: 'Int', type: 'number', options: { precision: 0 } }, + { name: 'Decimal 1', type: 'number', options: { precision: 1 } }, + { name: 'Decimal 4', type: 'number', options: { precision: 4 } }, + { name: 'Percent 0', type: 'percent', options: { precision: 0 } }, + { name: 'Percent 2', type: 'percent', options: { precision: 2 } }, + { name: 'Currency USD', type: 'currency', options: { precision: 2, symbol: '$' } }, + { name: 'Currency EUR', type: 'currency', options: { precision: 0, symbol: '€' } }, + { name: 'Currency CNY', type: 'currency', options: { precision: 2, symbol: '¥' } }, + // duration formats + { name: 'Dur h:mm', type: 'duration', options: { durationFormat: 'h:mm' } }, + { name: 'Dur h:mm:ss', type: 'duration', options: { durationFormat: 'h:mm:ss' } }, + { name: 'Dur millis', type: 'duration', options: { durationFormat: 'h:mm:ss.SSS' } }, + // rating icon / color / max + { + name: 'Rate star 5', + type: 'rating', + options: { max: 5, icon: 'star', color: 'yellowBright' }, + }, + { + name: 'Rate heart 10', + type: 'rating', + options: { max: 10, icon: 'heart', color: 'redBright' }, + }, + { name: 'Rate flag 3', type: 'rating', options: { max: 3, icon: 'flag', color: 'blueBright' } }, + // date format variants + { name: 'Date iso', type: 'date', options: { dateFormat: { name: 'iso' } } }, + { name: 'Date us', type: 'date', options: { dateFormat: { name: 'us' } } }, + { name: 'Date euro', type: 'date', options: { dateFormat: { name: 'european' } } }, + { name: 'Date friendly', type: 'date', options: { dateFormat: { name: 'friendly' } } }, + // dateTime: format × timeFormat × timeZone + { + name: 'DT utc 24', + type: 'dateTime', + options: { dateFormat: { name: 'iso' }, timeFormat: { name: '24hour' }, timeZone: 'utc' }, + }, + { + name: 'DT client 12', + type: 'dateTime', + options: { dateFormat: { name: 'us' }, timeFormat: { name: '12hour' }, timeZone: 'client' }, + }, + { + name: 'DT shanghai', + type: 'dateTime', + options: { + dateFormat: { name: 'iso' }, + timeFormat: { name: '24hour' }, + timeZone: 'Asia/Shanghai', + }, + }, + // select: many colors + special option names (emoji, comma) + { + name: 'Single select', + type: 'singleSelect', + options: { + choices: [ + { name: 'Alpha', color: 'blueBright' }, + { name: 'Beta', color: 'greenBright' }, + { name: 'Gamma', color: 'redBright' }, + { name: 'Delta 🚀', color: 'purpleBright' }, + { name: 'Has, comma', color: 'orangeBright' }, + ], + }, + }, + { + name: 'Multi select', + type: 'multipleSelects', + options: { + choices: [ + { name: 'One', color: 'cyanBright' }, + { name: 'Two', color: 'tealBright' }, + { name: 'Three', color: 'pinkBright' }, + { name: 'Four', color: 'grayBright' }, + ], + }, + }, + // checkbox icon / color + { name: 'Check green', type: 'checkbox', options: { icon: 'check', color: 'greenBright' } }, + { name: 'Check heart', type: 'checkbox', options: { icon: 'heart', color: 'redBright' } }, + // collaborators + attachments + { name: 'Single collaborator', type: 'singleCollaborator' }, + { name: 'Multi collaborator', type: 'multipleCollaborators' }, + { name: 'Attachments', type: 'multipleAttachments' }, + // system / auto — API rejects these; reported for manual add + { name: 'Created time', type: 'createdTime' }, + { name: 'Auto number', type: 'autoNumber' }, + ]; + for (const f of basicFields) await addField(baseId, A, f); + + // 4) LINKS — the many shapes (this is where the cardinality bugs live). + // Each two-way link auto-creates a symmetric field on the other table. + // NOTE: the API cannot set prefersSingleRecordLink at create time, so every + // link below is created as MULTI. Toggle the single-link ones in the UI + // afterwards (see the manual list) to get the single + one-to-many shapes. + const links = [ + // A -> B, two links between the SAME two tables (symmetric-collision test) + { t: A, name: 'Link to B', opt: { linkedTableId: B } }, + { t: A, name: 'Link to B 2', opt: { linkedTableId: B } }, + // self link on A + { t: A, name: 'Self link', opt: { linkedTableId: A } }, + // a link created from B -> A (gives A an auto symmetric). Toggle THIS to + // single-link in the UI: A (table[0]) is then the multi side seen first — + // the one-to-many that must still resolve to ManyOne. + { t: B, name: 'Link to A', opt: { linkedTableId: A } }, + // link from a third table + { t: C, name: 'C to A', opt: { linkedTableId: A } }, + ]; + for (const l of links) + await addField(baseId, l.t, { name: l.name, type: 'multipleRecordLinks', options: l.opt }); + + // 5) Records. B first (for ids), then A referencing them, then back/self links. + const createRecords = async (tableId, records) => { + const filtered = records.map((r) => ({ + fields: Object.fromEntries( + Object.entries(r.fields).filter( + ([k]) => k === 'Name' || k === 'Title' || fieldIds[k] != null + ) + ), + })); + const out = []; + for (let i = 0; i < filtered.length; i += 10) { + const res = await req('POST', `/${baseId}/${tableId}`, { + records: filtered.slice(i, i + 10), + typecast: true, + }); + out.push(...res.records); + } + return out; + }; + const patchRecords = async (tableId, records) => { + for (let i = 0; i < records.length; i += 10) + await req('PATCH', `/${baseId}/${tableId}`, { + records: records.slice(i, i + 10), + typecast: true, + }); + }; + + const linkedRecs = await createRecords( + B, + Array.from({ length: 8 }, (_, i) => ({ + fields: { Name: `Linked ${i + 1}`, Amount: (i + 1) * 10, Category: ['X', 'Y', 'Z'][i % 3] }, + })) + ); + const linkIds = linkedRecs.map((r) => r.id); + + const img = + 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png'; + const selectNames = ['Alpha', 'Beta', 'Gamma', 'Delta 🚀', 'Has, comma']; + const allRecords = Array.from({ length: 20 }, (_, i) => { + if (i === 0) return { fields: {} }; // an all-empty row + return { + fields: { + Name: `Row ${i}${i % 5 === 0 ? ' 🚀 unicode ' + 'x'.repeat(80) : ''}${i === 7 ? ' nulbyte' : ''}`, + 'Long text': `Notes ${i}\nsecond line${i === 7 ? '' : ''}`, + Email: `user${i}@example.com`, + URL: 'https://example.com/p?q=1', + Phone: '+1 555 0100', + Barcode: { text: `01234${i}` }, + Int: i % 2 === 0 ? i : -i, // include negatives + zero + 'Decimal 1': i + 0.25, + 'Decimal 4': i + 0.0001, + 'Percent 0': (i % 100) / 100, + 'Percent 2': (i * 3) / 100, + 'Currency USD': i * 9.99, + 'Currency EUR': i * 100, + 'Dur h:mm': i * 3661, // seconds + 'Rate star 5': (i % 5) + 1, // 1..5 (rating rejects 0) + 'Rate heart 10': (i % 10) + 1, // 1..10 + 'Single select': selectNames[i % selectNames.length], + 'Multi select': [['One'], ['One', 'Two'], ['Two', 'Three', 'Four']][i % 3], + 'Check green': i % 2 === 0, // false rows test boolean omission + 'Date iso': `2026-0${(i % 9) + 1}-15`, + 'DT utc 24': `2026-03-15T0${i % 9}:30:00.000Z`, + Attachments: + i % 6 === 0 ? [{ url: img }, { url: img }] : i % 3 === 0 ? [{ url: img }] : undefined, + 'Link to B': linkIds.slice(0, (i % 3) + 1), + 'Link to B 2': linkIds.slice(3, 5), + }, + }; + }); + const aRecs = await createRecords(A, allRecords); + const aIds = aRecs.map((r) => r.id); + + // self links (A->A) and the back link (B->A), now that A ids exist + if (fieldIds['Self link']) + await patchRecords( + A, + aIds.slice(1, 6).map((id, i) => ({ + id, + fields: { 'Self link': aIds.slice(0, (i % 2) + 1) }, + })) + ); + if (fieldIds['Link to A']) + await patchRecords( + B, + linkIds.slice(0, 5).map((id, i) => ({ id, fields: { 'Link to A': [aIds[i % aIds.length]] } })) + ); + + await createRecords( + C, + Array.from({ length: 4 }, (_, i) => ({ + fields: { Title: `Self ${i + 1}`, 'C to A': [aIds[i % aIds.length]] }, + })) + ); + + console.log( + `\n✓ built "${BASE_NAME}": ${aRecs.length} rows in All Fields, ${linkIds.length} in Linked, ${Object.keys(fieldIds).length} fields created.` + ); + if (failedFields.length) { + console.log(`\n⚠ ${failedFields.length} field(s) the API could not create — add in the UI:`); + for (const f of failedFields) console.log(` - ${f}`); + } + console.log('\nStill add manually in the Airtable UI (API cannot create these):'); + console.log( + ' • Formulas: numeric, text concat, date (DATEADD/DATETIME_FORMAT), logical (IF), a deliberately invalid one, and one using "^" (incompatible)' + ); + console.log( + ' • Toggle single-link on "Link to B 2" and "Link to A" (API cannot set prefersSingleRecordLink) — needed for single links + the one-to-many ManyOne case' + ); + console.log( + ' • single->many-to-many relax test: toggle a link to single, then link several records to the SAME target so its single-side data holds multiple values' + ); + console.log( + ' • On "Link to B": Rollup SUM/MAX/AVERAGE/COUNTALL/ARRAYJOIN/ARRAYUNIQUE, Count, Lookup (number + text + date)' + ); + console.log(' • aiText field (if testing AI mapping)'); + console.log(' • Rename one "Single select"/"Multi select" option to blank (empty name)'); + console.log( + ' • Views: Kanban×2 (by select + by collaborator), Calendar, Gallery, Timeline, List, plus a Grid with filter+sort+group+hidden fields+row height' + ); + console.log(' • In "Self": make the primary "Title" a formula'); + console.log(' • Turn on Share → Share to web to test view-config extraction'); + console.log(`\nBase id: ${baseId}`); +}; + +main().catch((e) => { + console.error('\nFATAL:', e.message); + process.exit(1); +}); diff --git a/apps/nestjs-backend/src/features/airtable-import/test-scripts/airtable-field-coverage.mjs b/apps/nestjs-backend/src/features/airtable-import/test-scripts/airtable-field-coverage.mjs new file mode 100644 index 0000000000..ebca89726e --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/test-scripts/airtable-field-coverage.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +/** + * Scans every Airtable base the PAT can read and tallies field-type and link + * relationship coverage — to confirm the real-base corpus already exercises the + * computed / lookup / link combinations the meta API cannot create. + * + * Usage: AIRTABLE_PAT=patXXX node src/features/airtable-import/test-scripts/airtable-field-coverage.mjs + */ +const PAT = process.env.AIRTABLE_PAT; +const API = 'https://api.airtable.com/v0'; +if (!PAT) { + console.error('Set AIRTABLE_PAT (schema.bases:read).'); + process.exit(1); +} +const h = { Authorization: `Bearer ${PAT}` }; +const get = async (p) => (await fetch(`${API}${p}`, { headers: h })).json(); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const main = async () => { + const { bases = [] } = await get('/meta/bases'); + const typeBases = new Map(); // fieldType -> Set(baseName) + const typeCount = new Map(); // fieldType -> total field count + const links = { + single: new Set(), + multi: 0, + self: new Set(), + oneWay: new Set(), + sameTablePair: new Set(), + }; + + for (const base of bases) { + let schema; + try { + schema = await get(`/meta/bases/${base.id}/tables`); + } catch { + continue; + } + await sleep(120); + for (const table of schema.tables ?? []) { + const linkTargets = new Map(); // linkedTableId -> count (for same-table-pair detection) + for (const f of table.fields ?? []) { + typeCount.set(f.type, (typeCount.get(f.type) ?? 0) + 1); + if (!typeBases.has(f.type)) typeBases.set(f.type, new Set()); + typeBases.get(f.type).add(base.name); + if (f.type === 'multipleRecordLinks') { + const o = f.options ?? {}; + if (o.prefersSingleRecordLink) links.single.add(base.name); + else links.multi++; + if (o.linkedTableId === table.id) links.self.add(base.name); + if (!o.inverseLinkFieldId) links.oneWay.add(base.name); + linkTargets.set(o.linkedTableId, (linkTargets.get(o.linkedTableId) ?? 0) + 1); + } + } + for (const n of linkTargets.values()) if (n > 1) links.sameTablePair.add(base.name); + } + } + + const tricky = [ + 'multipleLookupValues', + 'rollup', + 'count', + 'formula', + 'createdTime', + 'lastModifiedTime', + 'createdBy', + 'lastModifiedBy', + 'autoNumber', + 'button', + 'aiText', + 'multipleAttachments', + 'barcode', + 'multipleRecordLinks', + ]; + console.log( + `Scanned ${bases.length} bases.\n=== Field-type coverage (computed / hard-to-create) ===` + ); + for (const t of tricky) { + const b = typeBases.get(t); + if (b) + console.log( + ` ${t.padEnd(22)} ${String(typeCount.get(t)).padStart(4)} fields in ${b.size} bases` + ); + else console.log(` ${t.padEnd(22)} — not present in any base`); + } + console.log('\n=== All field types seen ==='); + console.log(' ' + [...typeCount.keys()].sort().join(', ')); + console.log('\n=== Link relationship combinations ==='); + console.log(` single-record links (prefersSingle): ${links.single.size} bases`); + console.log(` multi-record links: ${links.multi} fields`); + console.log(` self links (table -> itself): ${links.self.size} bases`); + console.log(` one-way links (no inverse): ${links.oneWay.size} bases`); + console.log(` multiple links between same 2 tables: ${links.sameTablePair.size} bases`); +}; + +main().catch((e) => console.error('FATAL:', e.message)); diff --git a/apps/nestjs-backend/src/features/airtable-import/test-scripts/airtable-import-smoke.mjs b/apps/nestjs-backend/src/features/airtable-import/test-scripts/airtable-import-smoke.mjs new file mode 100644 index 0000000000..ca3537cd66 --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/test-scripts/airtable-import-smoke.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +/** + * Airtable import smoke test against a LOCAL backend. + * + * Signs in, resolves the connected Airtable integration, lists the accessible + * Airtable bases (copy the templates you want to cover into your account first), + * imports each one through the real SSE endpoint, and reports per-base status + + * the degradation issues. Re-runnable for local regression testing. + * + * Usage: + * node src/features/airtable-import/test-scripts/airtable-import-smoke.mjs [nameFilter] [--keep] + * BACKEND=http://localhost:3603/api EMAIL=... PASSWORD=... node src/features/airtable-import/test-scripts/airtable-import-smoke.mjs + * + * nameFilter case-insensitive substring to limit which bases run (optional) + * --keep do not delete any created base (default: delete clean ones, + * keep the ones that failed or reported issues for inspection) + */ +const BACKEND = process.env.BACKEND ?? 'http://localhost:3603/api'; +const EMAIL = process.env.EMAIL; +const PASSWORD = process.env.PASSWORD; +if (!EMAIL || !PASSWORD) { + console.error('Set EMAIL and PASSWORD (a local Teable account) before running.'); + process.exit(1); +} +const args = process.argv.slice(2); +const keepAll = args.includes('--keep'); +const deleteAll = args.includes('--delete-all'); +const nameFilter = args.find((a) => !a.startsWith('--'))?.toLowerCase(); + +let cookie = ''; +const headers = () => ({ 'Content-Type': 'application/json', ...(cookie ? { cookie } : {}) }); +const captureCookie = (res) => { + const set = res.headers.getSetCookie?.() ?? []; + if (set.length) cookie = set.map((c) => c.split(';')[0]).join('; '); +}; + +const post = async (path, body) => { + const res = await fetch(`${BACKEND}${path}`, { + method: 'POST', + headers: headers(), + body: JSON.stringify(body), + }); + captureCookie(res); + return res; +}; +const get = async (path) => { + const res = await fetch(`${BACKEND}${path}`, { headers: headers() }); + captureCookie(res); + return res; +}; + +const signin = async () => { + const res = await post('/auth/signin', { email: EMAIL, password: PASSWORD }); + if (!res.ok) throw new Error(`signin failed: ${res.status} ${await res.text()}`); + console.log(`✓ signed in as ${EMAIL}`); +}; + +const getIntegrationId = async () => { + const res = await get('/user-integrations?provider=airtable'); + if (!res.ok) throw new Error(`integration list failed: ${res.status} ${await res.text()}`); + const { integrations = [] } = await res.json(); + const integration = integrations.find((i) => i.hasSecret) ?? integrations[0]; + if (!integration) throw new Error('no connected Airtable integration found'); + console.log(`✓ integration: ${integration.id}`); + return integration.id; +}; + +const getSpaceId = async () => { + if (process.env.SPACE_ID) return process.env.SPACE_ID; + const res = await get('/space'); + if (!res.ok) throw new Error(`space list failed: ${res.status} ${await res.text()}`); + const spaces = await res.json(); + if (!spaces.length) throw new Error('no space found'); + console.log(`✓ space: ${spaces[0].id} (${spaces[0].name})`); + return spaces[0].id; +}; + +const listBases = async (integrationId) => { + const res = await post('/base/import-airtable/analyze', { integrationId }); + if (!res.ok) throw new Error(`analyze failed: ${res.status} ${await res.text()}`); + const { bases = [] } = await res.json(); + return bases; +}; + +// Reads the SSE import stream to completion, returning the final vo or throwing. +const runImport = async (payload) => { + const res = await fetch(`${BACKEND}/base/import-airtable/stream`, { + method: 'POST', + headers: { ...headers(), Accept: 'text/event-stream' }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let result; + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const json = line.slice(6).trim(); + if (!json) continue; + const event = JSON.parse(json); + if (event.type === 'done') result = event.data; + else if (event.type === 'error') throw new Error(event.message); + } + } + if (!result) throw new Error('stream ended without a result'); + return result; +}; + +const deleteBase = async (baseId) => { + const res = await fetch(`${BACKEND}/base/${baseId}`, { method: 'DELETE', headers: headers() }); + return res.ok; +}; + +const main = async () => { + await signin(); + const [integrationId, spaceId] = [await getIntegrationId(), await getSpaceId()]; + let bases = await listBases(integrationId); + if (nameFilter) bases = bases.filter((b) => b.name.toLowerCase().includes(nameFilter)); + console.log( + `\nRunning ${bases.length} base(s)${nameFilter ? ` matching "${nameFilter}"` : ''}:\n` + ); + + const report = []; + for (const base of bases) { + const started = Date.now(); + const row = { name: base.name, status: '', tables: 0, issues: 0, kept: '', detail: '' }; + try { + const vo = await runImport({ + spaceId, + integrationId, + airtableBaseId: base.id, + baseName: `[smoke] ${base.name}`, + importRecords: true, + importAttachments: !process.env.NO_ATTACH, + }); + row.status = '✓ ok'; + row.tables = Object.keys(vo.tableIdMap).length; + row.issues = vo.issues.length; + const clean = vo.issues.length === 0; + if (deleteAll || (!keepAll && clean)) { + await deleteBase(vo.base.id); + row.kept = '(deleted)'; + } else { + row.kept = vo.base.id; + } + // surface the distinct issue reasons for quick triage + const reasons = [...new Set(vo.issues.map((i) => `${i.code}:${i.reason ?? i.toType ?? ''}`))]; + row.detail = reasons.slice(0, 6).join(' | '); + } catch (e) { + row.status = '✗ FAIL'; + row.detail = e instanceof Error ? e.message : String(e); + } + row.ms = Date.now() - started; + report.push(row); + console.log( + `${row.status.padEnd(7)} ${base.name.padEnd(28)} ${String(row.tables).padStart(2)}t ` + + `${String(row.issues).padStart(3)} issues ${String(row.ms).padStart(6)}ms ${row.kept}` + ); + if (row.detail) console.log(` ${row.detail}`); + } + + const failed = report.filter((r) => r.status.includes('FAIL')); + console.log( + `\n==== ${report.length} base(s): ${report.length - failed.length} ok, ${failed.length} failed ====` + ); + if (failed.length) { + console.log('FAILURES:'); + for (const f of failed) console.log(` ✗ ${f.name}: ${f.detail}`); + process.exitCode = 1; + } +}; + +main().catch((e) => { + console.error('\nFATAL:', e instanceof Error ? e.message : e); + process.exitCode = 1; +}); diff --git a/apps/nestjs-backend/src/features/airtable-import/test-scripts/airtable-verify-share.mjs b/apps/nestjs-backend/src/features/airtable-import/test-scripts/airtable-verify-share.mjs new file mode 100644 index 0000000000..33ef9149a2 --- /dev/null +++ b/apps/nestjs-backend/src/features/airtable-import/test-scripts/airtable-verify-share.mjs @@ -0,0 +1,78 @@ +/** + * End-to-end check of the view-config share path against the kitchen-sink test + * base. Mirrors src/.../airtable-share.client.ts (resolveShare + fetchViewConfig) + * in plain JS and prints the filters / sorts / groupLevels / metadata each view + * exposes, to confirm a share link actually serves importable view config. + * + * Usage: node src/features/airtable-import/test-scripts/airtable-verify-share.mjs + * (or SHARE_LINK / VIEW_IDS env) + */ +const LINK = process.argv[2] ?? process.env.SHARE_LINK; +const VIEW_IDS = (process.argv[3] ?? process.env.VIEW_IDS ?? '') + .split(',') + .map((v) => v.trim()) + .filter(Boolean); +if (!LINK || !VIEW_IDS.length) { + console.error( + 'Usage: airtable-verify-share.mjs (or SHARE_LINK / VIEW_IDS env).' + ); + process.exit(1); +} +const UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'; + +const main = async () => { + const pageRes = await fetch(LINK, { + headers: { 'User-Agent': UA, Accept: 'text/html' }, + redirect: 'manual', + }); + if (pageRes.status >= 300 && pageRes.status < 400) { + throw new Error(`not public (redirect to ${pageRes.headers.get('location')})`); + } + const cookie = (pageRes.headers.getSetCookie?.() ?? []).map((e) => e.split(';')[0]).join('; '); + const html = await pageRes.text(); + const requestId = html.match(/requestId: "(.*?)",/)?.[1]; + const initData = JSON.parse(html.match(/window\.initData = (.*?);\n/)?.[1] ?? '{}'); + const { sharedApplicationId: appId, accessPolicy, pageLoadId, codeVersion } = initData; + console.log(`resolved: appId=${appId} codeVersion=${String(codeVersion).slice(0, 8)}…`); + + const headers = { + 'User-Agent': UA, + Accept: 'application/json', + 'x-airtable-application-id': appId, + 'x-airtable-inter-service-client': 'webClient', + 'x-airtable-inter-service-client-code-version': codeVersion, + 'x-airtable-page-load-id': pageLoadId, + 'X-Requested-With': 'XMLHttpRequest', + 'x-time-zone': 'UTC', + 'x-user-locale': 'en', + cookie, + }; + for (const viewId of VIEW_IDS) { + const params = new URLSearchParams({ + stringifiedObjectParams: JSON.stringify({ + mayOnlyIncludeRowAndCellDataForIncludedViews: true, + mayExcludeCellDataForLargeViews: true, + allowMsgpackOfResult: false, + }), + requestId, + accessPolicy, + }); + const res = await fetch(`https://airtable.com/v0.3/view/${viewId}/readData?${params}`, { + headers, + }); + const d = (await res.json()).data ?? {}; + console.log(`\n# ${viewId} — ${res.status}`); + console.log(' filters: ', JSON.stringify(d.filters)); + console.log(' sorts: ', JSON.stringify(d.lastSortsApplied?.sortSet)); + console.log(' groupLevels:', JSON.stringify(d.groupLevels)); + console.log(' metadata: ', d.metadata ? Object.keys(d.metadata).join(', ') : null); + await new Promise((r) => setTimeout(r, 200)); + } + console.log('\n✓ share view-config read OK'); +}; + +main().catch((e) => { + console.error('FAIL:', e?.message ?? e); + process.exit(1); +}); diff --git a/apps/nestjs-backend/src/features/attachments/attachments.service.ts b/apps/nestjs-backend/src/features/attachments/attachments.service.ts index d303e8231e..240141b21d 100644 --- a/apps/nestjs-backend/src/features/attachments/attachments.service.ts +++ b/apps/nestjs-backend/src/features/attachments/attachments.service.ts @@ -286,6 +286,31 @@ export class AttachmentsService { return await this.notifyToAttachmentItem(token, filename); } + /** + * Streams an already-open file stream of a known size straight into + * storage — no temp file, works the same for local, S3 and MinIO backends. + */ + async uploadFromStream( + stream: Readable, + params: { filename: string; contentType: string; contentLength: number }, + uploadType: UploadType = UploadType.Table + ): Promise { + const MAX_FILE_SIZE = this.thresholdConfig.maxOpenapiAttachmentUploadSize; + const { filename, contentType, contentLength } = params; + if (contentLength > MAX_FILE_SIZE) { + this.throwFileSizeExceeded(MAX_FILE_SIZE); + } + + const { token, url } = await this.signature({ + type: uploadType, + contentLength, + contentType, + internal: true, + }); + await this.uploadStreamToStorage(url, stream, contentType, contentLength); + return await this.notifyToAttachmentItem(token, filename); + } + async uploadFromUrl( fileUrl: string, uploadType: UploadType = UploadType.Table diff --git a/apps/nestjs-backend/src/features/auth/decorators/permissions.decorator.ts b/apps/nestjs-backend/src/features/auth/decorators/permissions.decorator.ts index 8ade9c4780..14743f3633 100644 --- a/apps/nestjs-backend/src/features/auth/decorators/permissions.decorator.ts +++ b/apps/nestjs-backend/src/features/auth/decorators/permissions.decorator.ts @@ -2,6 +2,11 @@ import { SetMetadata } from '@nestjs/common'; import type { Action } from '@teable/core'; export const PERMISSIONS_KEY = 'permissions'; +export const ANY_PERMISSIONS_KEY = 'anyPermissions'; // eslint-disable-next-line @typescript-eslint/naming-convention export const Permissions = (...permissions: Action[]) => SetMetadata(PERMISSIONS_KEY, permissions); + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const AnyPermissions = (...permissionGroups: Action[][]) => + SetMetadata(ANY_PERMISSIONS_KEY, permissionGroups); diff --git a/apps/nestjs-backend/src/features/auth/guard/permission.guard.spec.ts b/apps/nestjs-backend/src/features/auth/guard/permission.guard.spec.ts new file mode 100644 index 0000000000..9c13202fb8 --- /dev/null +++ b/apps/nestjs-backend/src/features/auth/guard/permission.guard.spec.ts @@ -0,0 +1,125 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { ExecutionContext } from '@nestjs/common'; +import type { Reflector } from '@nestjs/core'; +import { HttpErrorCode, type Action } from '@teable/core'; +import type { ClsService } from 'nestjs-cls'; +import { CustomHttpException } from '../../../custom.exception'; +import type { IClsStore } from '../../../types/cls'; +import type { PermissionService } from '../permission.service'; +import { PermissionGuard } from './permission.guard'; + +vi.mock('../permission.service', () => ({ + PermissionService: class PermissionService {}, +})); + +const tableId = 'tblxxxxxxxxxxxx'; +const tableUpdatePermissions: Action[] = ['table|update']; +const instanceUpdatePermissions: Action[] = ['instance|update']; + +const createContext = (): ExecutionContext => + ({ + getHandler: vi.fn(), + getClass: vi.fn(), + switchToHttp: () => ({ + getRequest: () => ({ + params: { tableId }, + headers: {}, + }), + }), + }) as unknown as ExecutionContext; + +const createForbiddenError = (permissions: Action[]) => + new CustomHttpException( + `not allowed to operate ${permissions.join(', ')} on ${tableId}`, + HttpErrorCode.RESTRICTED_RESOURCE + ); + +describe('PermissionGuard', () => { + const createGuard = ({ + primaryPermissions, + anyPermissions, + isAdmin = false, + validPermissions, + }: { + primaryPermissions: Action[]; + anyPermissions?: Action[][]; + isAdmin?: boolean; + validPermissions: PermissionService['validPermissions']; + }) => { + const reflector = { + getAllAndOverride: vi.fn((key: string) => { + if (key === 'permissions') { + return primaryPermissions; + } + if (key === 'anyPermissions') { + return anyPermissions; + } + return undefined; + }), + } as unknown as Reflector; + const cls = { + get: vi.fn((key: string) => { + if (key === 'user.id') { + return 'usrxxxxxxxxxxxx'; + } + if (key === 'user.isAdmin') { + return isAdmin; + } + return undefined; + }), + set: vi.fn(), + } as unknown as ClsService; + const permissionService = { + validPermissions, + } as unknown as PermissionService; + + return { + guard: new PermissionGuard(reflector, cls, permissionService), + cls, + permissionService, + }; + }; + + it('keeps table update as the primary permission for alternative permission routes', async () => { + const validPermissions = vi.fn().mockResolvedValue(tableUpdatePermissions); + const { guard, cls } = createGuard({ + primaryPermissions: tableUpdatePermissions, + anyPermissions: [instanceUpdatePermissions], + validPermissions, + }); + + await expect(guard.canActivate(createContext())).resolves.toBe(true); + + expect(validPermissions).toHaveBeenCalledWith(tableId, tableUpdatePermissions, undefined); + expect(cls.get).not.toHaveBeenCalledWith('user.isAdmin'); + }); + + it('allows instance admins through alternative permissions when table update is unavailable', async () => { + const validPermissions = vi + .fn() + .mockRejectedValue(createForbiddenError(tableUpdatePermissions)); + const { guard } = createGuard({ + primaryPermissions: tableUpdatePermissions, + anyPermissions: [instanceUpdatePermissions], + isAdmin: true, + validPermissions, + }); + + await expect(guard.canActivate(createContext())).resolves.toBe(true); + }); + + it('still rejects users without table update or instance update', async () => { + const validPermissions = vi + .fn() + .mockRejectedValue(createForbiddenError(tableUpdatePermissions)); + const { guard } = createGuard({ + primaryPermissions: tableUpdatePermissions, + anyPermissions: [instanceUpdatePermissions], + validPermissions, + }); + + await expect(guard.canActivate(createContext())).rejects.toThrow( + `not allowed to operate table|update on ${tableId}` + ); + }); +}); diff --git a/apps/nestjs-backend/src/features/auth/guard/permission.guard.ts b/apps/nestjs-backend/src/features/auth/guard/permission.guard.ts index c5f52475f8..42cb61f1ab 100644 --- a/apps/nestjs-backend/src/features/auth/guard/permission.guard.ts +++ b/apps/nestjs-backend/src/features/auth/guard/permission.guard.ts @@ -8,7 +8,7 @@ import { CustomHttpException } from '../../../custom.exception'; import type { IClsStore } from '../../../types/cls'; import { AllowAnonymousType, IS_ALLOW_ANONYMOUS } from '../decorators/allow-anonymous.decorator'; import { IS_DISABLED_PERMISSION } from '../decorators/disabled-permission.decorator'; -import { PERMISSIONS_KEY } from '../decorators/permissions.decorator'; +import { ANY_PERMISSIONS_KEY, PERMISSIONS_KEY } from '../decorators/permissions.decorator'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import type { IResourceMeta } from '../decorators/resource_meta.decorator'; import { RESOURCE_META } from '../decorators/resource_meta.decorator'; @@ -323,6 +323,10 @@ export class PermissionGuard { context.getHandler(), context.getClass(), ]); + const anyPermissions = this.reflector.getAllAndOverride( + ANY_PERMISSIONS_KEY, + [context.getHandler(), context.getClass()] + ); const resourceId = this.getResourceId(context) || this.defaultResourceId(context); const accessTokenId = this.cls.get('accessTokenId'); if (accessTokenId && !permissions?.length) { @@ -337,6 +341,24 @@ export class PermissionGuard { if (!permissions?.length) { return true; } + if (anyPermissions?.length) { + try { + return await this.checkPermissions(resourceId, permissions); + } catch (error) { + for (const permissionGroup of anyPermissions) { + try { + return await this.checkPermissions(resourceId, permissionGroup); + } catch { + // Try the next alternative and preserve the primary error if all alternatives fail. + } + } + throw error; + } + } + return await this.checkPermissions(resourceId, permissions); + } + + private async checkPermissions(resourceId: string | undefined, permissions: Action[]) { // instance permission check if (permissions?.includes('instance|update')) { return this.instancePermissionChecker('instance|update'); 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 1f533440f8..1cd06dc9fc 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 @@ -20,6 +20,7 @@ import type { IBaseNodePresenceDeletePayload, IBaseNodePresenceUpdatePayload, IBaseNodeTableResourceMeta, + IUserInfoVo, V2Feature, } from '@teable/openapi'; import { BaseNodeResourceType } from '@teable/openapi'; @@ -56,10 +57,26 @@ type IBaseNodeEntry = { resourceType: string; resourceId: string; order: number; + createdBy?: string | null; + createdTime?: Date | string | null; + lastModifiedBy?: string | null; + lastModifiedTime?: Date | string | null; children: { id: string; order: number }[]; parent: { id: string } | null; }; +type IBaseNodeResourceInput = Omit< + IBaseNodeResourceMeta, + 'createdByUser' | 'lastModifiedByUser' | 'createdTime' | 'lastModifiedTime' +> & { + id: string; + type?: BaseNodeResourceType; + createdBy?: string | null; + createdTime?: Date | string | null; + lastModifiedBy?: string | null; + lastModifiedTime?: Date | string | null; +}; + @Injectable() export class BaseNodeService { private readonly logger = new Logger(BaseNodeService.name); @@ -115,6 +132,10 @@ export class BaseNodeService { resourceType: true, resourceId: true, order: true, + createdBy: true, + createdTime: true, + lastModifiedBy: true, + lastModifiedTime: true, children: { select: { id: true, order: true }, orderBy: { order: 'asc' as const }, @@ -190,68 +211,115 @@ export class BaseNodeService { private async entry2vo( entry: IBaseNodeEntry, - resource?: IBaseNodeResourceMeta + resource?: IBaseNodeResourceInput, + userMap?: Map ): Promise { - const resourceMeta = - resource || + const baseNodeEntry = omit(entry, [ + 'createdBy', + 'createdTime', + 'lastModifiedBy', + 'lastModifiedTime', + ]) as Omit; + const resourceType = baseNodeEntry.resourceType as BaseNodeResourceType; + const rawResource: IBaseNodeResourceInput = + resource ?? ( - await this.getNodeResource(entry.baseId, entry.resourceType as BaseNodeResourceType, [ - entry.resourceId, - ]) + await this.getNodeResource(baseNodeEntry.baseId, resourceType, [baseNodeEntry.resourceId]) )[0]; - const resourceMetaWithoutId = resource ? resource : omit(resourceMeta, 'id'); + const { id, type, createdBy, createdTime, lastModifiedBy, lastModifiedTime, ...resourceMeta } = + rawResource; + const map = userMap ?? (await this.getUserMap([createdBy, lastModifiedBy])); + const resolveUser = (userId?: string | null) => (userId ? map.get(userId) ?? null : null); + const formatDate = (value: unknown) => + value instanceof Date ? value.toISOString() : typeof value === 'string' ? value : null; + const resourceMetaWithAudit = { + ...resourceMeta, + createdTime: formatDate(createdTime), + createdByUser: resolveUser(createdBy), + lastModifiedTime: formatDate(lastModifiedTime), + lastModifiedByUser: resolveUser(lastModifiedBy), + } as IBaseNodeResourceMeta; const defaultUrl = this.generateDefaultUrl( - entry.baseId, - entry.resourceType as BaseNodeResourceType, - entry.resourceId, - resourceMetaWithoutId + baseNodeEntry.baseId, + resourceType, + baseNodeEntry.resourceId, + resourceMetaWithAudit ); return { - ...entry, - resourceType: entry.resourceType as BaseNodeResourceType, - resourceMeta: resourceMetaWithoutId, + ...baseNodeEntry, + resourceType, + resourceMeta: resourceMetaWithAudit, defaultUrl, }; } - protected getTableResources(baseId: string, ids?: string[]) { - return this.prismaService.tableMeta.findMany({ + private async getUserMap( + userIds: Array + ): Promise> { + const ids = [...new Set(userIds.filter(Boolean) as string[])]; + if (ids.length === 0) { + return new Map(); + } + + const users = await this.prismaService.user.findMany({ + where: { id: { in: ids } }, + select: { + id: true, + name: true, + email: true, + avatar: true, + }, + }); + + return new Map(users.map((user) => [user.id, user])); + } + + protected async getTableResources(baseId: string, ids?: string[]) { + return await this.prismaService.tableMeta.findMany({ where: { baseId, id: { in: ids ? ids : undefined }, deletedTime: null }, select: { id: true, name: true, icon: true, + createdBy: true, + createdTime: true, + lastModifiedBy: true, + lastModifiedTime: true, }, }); } - protected getDashboardResources(baseId: string, ids?: string[]) { - return this.prismaService.dashboard.findMany({ + protected async getDashboardResources(baseId: string, ids?: string[]) { + return await this.prismaService.dashboard.findMany({ where: { baseId, id: { in: ids ? ids : undefined } }, select: { id: true, name: true, + createdBy: true, + createdTime: true, + lastModifiedBy: true, + lastModifiedTime: true, }, }); } - protected getFolderResources(baseId: string, ids?: string[]) { - return this.prismaService.baseNodeFolder.findMany({ + protected async getFolderResources(baseId: string, ids?: string[]) { + return await this.prismaService.baseNodeFolder.findMany({ where: { baseId, id: { in: ids ? ids : undefined } }, select: { id: true, name: true, + createdBy: true, + createdTime: true, + lastModifiedBy: true, + lastModifiedTime: true, }, }); } - protected async getNodeResource( - baseId: string, - type: BaseNodeResourceType, - ids?: string[] - ): Promise { + protected async getNodeResource(baseId: string, type: BaseNodeResourceType, ids?: string[]) { switch (type) { case BaseNodeResourceType.Folder: return this.getFolderResources(baseId, ids); @@ -290,6 +358,9 @@ export class BaseNodeService { list.map((r) => ({ ...r, type: resourceTypes[index] })) ); + const userMap = await this.getUserMap( + resources.flatMap((resource) => [resource.createdBy, resource.lastModifiedBy]) + ); const resourceMap = keyBy(resources, (r) => `${r.type}_${r.id}`); const resourceKeys = new Set(resources.map((r) => `${r.type}_${r.id}`)); @@ -309,23 +380,13 @@ export class BaseNodeService { ); if (toCreate.length === 0 && toDelete.length === 0 && orphans.length === 0) { - return nodes.map((entry) => { - const key = `${entry.resourceType}_${entry.resourceId}`; - const resource = resourceMap[key]; - const resourceMeta = omit(resource, 'id'); - const defaultUrl = this.generateDefaultUrl( - baseId, - entry.resourceType as BaseNodeResourceType, - entry.resourceId, - resourceMeta - ); - return { - ...entry, - resourceType: entry.resourceType as BaseNodeResourceType, - resourceMeta, - defaultUrl, - }; - }); + return Promise.all( + nodes.map((entry) => { + const key = `${entry.resourceType}_${entry.resourceId}`; + const resource = resourceMap[key]; + return this.entry2vo(entry, resource, userMap); + }) + ); } const finalMenus = await this.prismaService.$tx(async (prisma) => { @@ -381,7 +442,7 @@ export class BaseNodeService { finalMenus.map(async (entry) => { const key = `${entry.resourceType}_${entry.resourceId}`; const resource = resourceMap[key]; - return await this.entry2vo(entry, omit(resource, 'id')); + return await this.entry2vo(entry, resource, userMap); }) ); } @@ -455,7 +516,7 @@ export class BaseNodeService { select: this.getSelect(), }); - const vo = await this.entry2vo(entry, omit(resource, 'id')); + const vo = await this.entry2vo(entry, resource); this.presenceHandler(baseId, (presence) => { presence.submit({ event: 'create', @@ -604,7 +665,7 @@ export class BaseNodeService { }; }); - const vo = await this.entry2vo(entry, omit(resource, 'id')); + const vo = await this.entry2vo(entry, resource); this.presenceHandler(baseId, (presence) => { presence.submit({ event: 'create', diff --git a/apps/nestjs-backend/src/features/base-share/base-share.service.ts b/apps/nestjs-backend/src/features/base-share/base-share.service.ts index 2d49402469..bb623580b8 100644 --- a/apps/nestjs-backend/src/features/base-share/base-share.service.ts +++ b/apps/nestjs-backend/src/features/base-share/base-share.service.ts @@ -5,9 +5,12 @@ import type { ICreateBaseShareRo, IUpdateBaseShareRo, IBaseShareVo } from '@teab import { BaseNodeResourceType } from '@teable/openapi'; import { ClsService } from 'nestjs-cls'; import { CustomHttpException } from '../../custom.exception'; +import { Events } from '../../event-emitter/events'; import { PerformanceCache, PerformanceCacheService } from '../../performance-cache'; import { generateBaseShareListCacheKey } from '../../performance-cache/generate-keys'; import type { IClsStore } from '../../types/cls'; +import { AuditScope } from '../audit/audit-scope'; +import { Audit } from '../audit/audit.decorator'; const baseShareNotFoundMessage = 'Base share not found'; const baseShareNotFoundKey = 'httpErrors.baseShare.notFound'; @@ -19,7 +22,8 @@ export class BaseShareService { constructor( private readonly prismaService: PrismaService, private readonly cls: ClsService, - private readonly performanceCacheService: PerformanceCacheService + private readonly performanceCacheService: PerformanceCacheService, + private readonly audit: AuditScope ) {} private async invalidateBaseShareListCache(baseId: string): Promise { @@ -75,6 +79,16 @@ export class BaseShareService { }; } + @Audit({ + action: Events.BASE_SHARE_CREATE, + resourceId: (baseId: string) => baseId, + params: (baseId: string, data: ICreateBaseShareRo) => ({ + baseId, + nodeId: data.nodeId ?? null, + type: data.nodeId ? 'node' : 'base', + }), + emit: (result: IBaseShareVo) => ({ shareId: result.shareId, enabled: result.enabled }), + }) async createBaseShare(baseId: string, data: ICreateBaseShareRo): Promise { const nodeId = data.nodeId ?? null; @@ -154,6 +168,22 @@ export class BaseShareService { return this.formatBaseShareVo(share); } + @Audit({ + action: Events.BASE_SHARE_UPDATE, + resourceId: (_baseId: string, shareId: string) => shareId, + params: (baseId: string, shareId: string, data: IUpdateBaseShareRo) => ({ + baseId, + shareId, + // First-pass: store raw request body so any field change is preserved. + // Password value itself is never logged — controller-layer secrets stay opaque. + changes: { ...data, password: data.password !== undefined ? '[set]' : undefined }, + }), + emit: (result: IBaseShareVo) => ({ + nodeId: result.nodeId, + type: result.nodeId ? 'node' : 'base', + enabled: result.enabled, + }), + }) async updateBaseShare( baseId: string, shareId: string, @@ -199,6 +229,12 @@ export class BaseShareService { return this.formatBaseShareVo(updated); } + @Audit({ + action: Events.BASE_SHARE_DELETE, + resourceId: (_baseId: string, shareId: string) => shareId, + params: (baseId: string, shareId: string) => ({ baseId, shareId }), + emit: true, + }) async deleteBaseShare(baseId: string, shareId: string): Promise { const share = await this.prismaService.baseShare.findFirst({ where: { baseId, shareId, enabled: true }, @@ -222,6 +258,12 @@ export class BaseShareService { await this.invalidateBaseShareListCache(baseId); } + @Audit({ + action: Events.BASE_SHARE_REFRESH, + resourceId: (_baseId: string, shareId: string) => shareId, + params: (baseId: string, shareId: string) => ({ baseId, oldShareId: shareId }), + emit: (result: IBaseShareVo) => ({ newShareId: result.shareId }), + }) async refreshBaseShareId(baseId: string, shareId: string): Promise { const share = await this.prismaService.baseShare.findFirst({ where: { baseId, shareId, enabled: true }, diff --git a/apps/nestjs-backend/src/features/base-sql-executor/allowed-functions.ts b/apps/nestjs-backend/src/features/base-sql-executor/allowed-functions.ts new file mode 100644 index 0000000000..c965202d92 --- /dev/null +++ b/apps/nestjs-backend/src/features/base-sql-executor/allowed-functions.ts @@ -0,0 +1,144 @@ +/** + * Whitelist of PostgreSQL functions allowed in user-facing sql-query API. + * Any function NOT in this set will be rejected at the AST validation layer. + * + * Criteria for inclusion: + * - IMMUTABLE or STABLE, or VOLATILE but side-effect-free (e.g. now(), timezone()) + * - Cannot execute embedded SQL (excludes query_to_xml, ts_stat, ts_rewrite, etc.) + * - Cannot modify database state (excludes setval, lo_create, set_config, etc.) + * - Cannot acquire locks or send notifications (excludes pg_advisory_lock, pg_notify, etc.) + * - Cannot generate unbounded rows from nothing (excludes generate_series) + */ +export const allowedFunctions = new Set([ + // ── Aggregation ── + 'avg', + 'bool_and', + 'bool_or', + 'count', + 'every', + 'json_agg', + 'jsonb_agg', + 'max', + 'min', + 'string_agg', + 'sum', + 'array_agg', + + // ── Window ── + 'cume_dist', + 'dense_rank', + 'first_value', + 'lag', + 'last_value', + 'lead', + 'nth_value', + 'ntile', + 'percent_rank', + 'rank', + 'row_number', + + // ── Math ── + 'abs', + 'ceil', + 'ceiling', + 'div', + 'floor', + 'greatest', + 'least', + 'mod', + 'power', + 'pow', + 'round', + 'sign', + 'sqrt', + 'trunc', + + // ── String ── + 'ascii', + 'char_length', + 'chr', + 'concat', + 'concat_ws', + 'initcap', + 'left', + 'length', + 'lower', + 'lpad', + 'ltrim', + 'md5', + 'position', + 'regexp_match', + 'regexp_matches', + 'regexp_replace', + 'repeat', + 'replace', + 'reverse', + 'right', + 'rpad', + 'rtrim', + 'split_part', + 'starts_with', + 'strpos', + 'substr', + 'substring', + 'translate', + 'trim', + 'upper', + + // ── Date / Time ── + 'age', + 'date_part', + 'date_trunc', + 'extract', + 'make_date', + 'make_timestamp', + 'now', + 'to_char', + 'to_date', + 'to_number', + 'to_timestamp', + 'timezone', + + // ── JSON / JSONB ── + 'json_array_elements', + 'json_array_elements_text', + 'json_array_length', + 'json_build_array', + 'json_build_object', + 'json_extract_path', + 'json_extract_path_text', + 'json_typeof', + 'jsonb_array_elements', + 'jsonb_array_elements_text', + 'jsonb_array_length', + 'jsonb_build_array', + 'jsonb_build_object', + 'jsonb_each', + 'jsonb_each_text', + 'jsonb_extract_path', + 'jsonb_extract_path_text', + 'jsonb_object_keys', + 'jsonb_pretty', + 'jsonb_set', + 'jsonb_strip_nulls', + 'jsonb_typeof', + 'to_json', + 'to_jsonb', + + // ── Array ── + 'array_append', + 'array_cat', + 'array_length', + 'array_position', + 'array_remove', + 'array_to_string', + 'string_to_array', + 'unnest', + + // ── Conditional ── + 'coalesce', + 'nullif', + + // ── Type conversion ── + 'cast', +]); diff --git a/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.spec.ts b/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.spec.ts new file mode 100644 index 0000000000..5983e1c6e2 --- /dev/null +++ b/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.spec.ts @@ -0,0 +1,90 @@ +import { ConfigService } from '@nestjs/config'; +import knex from 'knex'; +import { describe, expect, it, vi } from 'vitest'; +import { BaseSqlExecutorService } from './base-sql-executor.service'; + +const baseId = 'bsexxx'; +const tableDbName = 'bsexxx.tblOrders'; +const sql = 'SELECT count(*) FROM "bsexxx"."tblOrders"'; + +const createService = ({ + prismaService, + databaseRouter, +}: { + prismaService: unknown; + databaseRouter: unknown; +}) => + new BaseSqlExecutorService( + prismaService as never, + databaseRouter as never, + { + get: vi.fn().mockReturnValue('postgresql://teable:secret@localhost:5432/teable'), + } as unknown as ConfigService, + knex({ client: 'pg' }) as never, + { searchTimeout: 30_000 } as never + ); + +const createPrismaService = () => ({ + tableMeta: { + findMany: vi.fn().mockResolvedValue([{ dbTableName: tableDbName }]), + }, +}); + +describe('BaseSqlExecutorService', () => { + it('executes BYODB sql-query without creating or setting a read-only role', async () => { + const prismaService = createPrismaService(); + const transactionPrisma = { + $executeRawUnsafe: vi.fn().mockResolvedValue(0), + $queryRawUnsafe: vi.fn().mockResolvedValue([{ count: 1n }]), + }; + const databaseRouter = { + getDataDatabaseForBase: vi.fn().mockResolvedValue({ + isMetaFallback: false, + url: 'postgresql://teable:secret@byodb.example.com:5432/teable', + }), + dataPrismaExecutorForBase: vi.fn(), + dataPrismaTransactionForBase: vi.fn(async (_baseId: string, fn: (prisma: never) => unknown) => + fn(transactionPrisma as never) + ), + }; + const service = createService({ prismaService, databaseRouter }); + + await expect(service.executeQuerySql(baseId, sql)).resolves.toEqual([{ count: 1n }]); + + expect(databaseRouter.getDataDatabaseForBase).toHaveBeenCalledWith(baseId); + expect(databaseRouter.dataPrismaExecutorForBase).not.toHaveBeenCalled(); + expect(transactionPrisma.$executeRawUnsafe).toHaveBeenCalledWith('SET TRANSACTION READ ONLY'); + expect(transactionPrisma.$executeRawUnsafe.mock.calls).toEqual( + expect.not.arrayContaining([[expect.stringContaining('SET LOCAL ROLE')]]) + ); + }); + + it('keeps using the read-only role for default data storage', async () => { + const prismaService = createPrismaService(); + const rolePrisma = { + $queryRawUnsafe: vi.fn().mockResolvedValue([{ count: 1n }]), + }; + const transactionPrisma = { + $executeRawUnsafe: vi.fn().mockResolvedValue(0), + $queryRawUnsafe: vi.fn().mockResolvedValue([{ count: 1n }]), + }; + const databaseRouter = { + getDataDatabaseForBase: vi.fn().mockResolvedValue({ + isMetaFallback: true, + url: 'postgresql://teable:secret@default.example.com:5432/teable', + }), + dataPrismaExecutorForBase: vi.fn().mockResolvedValue(rolePrisma), + dataPrismaTransactionForBase: vi.fn(async (_baseId: string, fn: (prisma: never) => unknown) => + fn(transactionPrisma as never) + ), + }; + const service = createService({ prismaService, databaseRouter }); + + await expect(service.executeQuerySql(baseId, sql)).resolves.toEqual([{ count: 1n }]); + + expect(databaseRouter.dataPrismaExecutorForBase).toHaveBeenCalledWith(baseId); + expect(transactionPrisma.$executeRawUnsafe.mock.calls).toEqual( + expect.arrayContaining([[expect.stringContaining('SET LOCAL ROLE')]]) + ); + }); +}); diff --git a/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts b/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts index d8f21106e4..3940826edf 100644 --- a/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts +++ b/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts @@ -5,6 +5,7 @@ import { DriverClient, HttpErrorCode, parseDsn } from '@teable/core'; import { Prisma, PrismaService, getDatabaseUrl } from '@teable/db-main-prisma'; import { Knex } from 'knex'; import { InjectModel } from 'nest-knexjs'; +import { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config'; import { CustomHttpException } from '../../custom.exception'; import { DatabaseRouter } from '../../global/database-router.service'; import { DATA_KNEX } from '../../global/knex'; @@ -21,7 +22,8 @@ export class BaseSqlExecutorService { private readonly prismaService: PrismaService, private readonly databaseRouter: DatabaseRouter, private readonly configService: ConfigService, - @InjectModel(DATA_KNEX) private readonly knex: Knex + @InjectModel(DATA_KNEX) private readonly knex: Knex, + @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig ) { this.dsn = parseDsn(this.getDatabaseUrl()); this.driver = this.dsn.driver as DriverClient; @@ -120,9 +122,13 @@ export class BaseSqlExecutorService { return Boolean(roleExists[0].count); } - private async roleCheckAndCreate(baseId: string) { + private async roleCheckAndCreate(baseId: string): Promise { if (this.driver !== DriverClient.Pg) { - return; + return false; + } + const resolvedDataDb = await this.databaseRouter.getDataDatabaseForBase(baseId); + if (!resolvedDataDb.isMetaFallback) { + return false; } const roleName = this.getReadOnlyRoleName(baseId); if (!(await this.roleExits(roleName, baseId))) { @@ -137,30 +143,35 @@ export class BaseSqlExecutorService { this.logger.warn( `read only role ${roleName} already exists (concurrent creation), skipping` ); - return; + return true; } throw error; } } + return true; } - private async setRole( + private async setLocalRole( prisma: { $executeRawUnsafe(query: string): Promise }, baseId: string ) { const roleName = this.getReadOnlyRoleName(baseId); - await prisma.$executeRawUnsafe(this.knex.raw(`SET ROLE ??`, [roleName]).toQuery()); + await prisma.$executeRawUnsafe(this.knex.raw(`SET LOCAL ROLE ??`, [roleName]).toQuery()); } - private async resetRole(prisma: { $executeRawUnsafe(query: string): Promise }) { - await prisma.$executeRawUnsafe(this.knex.raw(`RESET ROLE`).toQuery()); + private async setTransactionReadOnly(prisma: { + $executeRawUnsafe(query: string): Promise; + }) { + await prisma.$executeRawUnsafe('SET TRANSACTION READ ONLY'); } - private async readonlyExecuteSql(baseId: string, sql: string) { - return this.databaseRouter.dataPrismaTransactionForBase(baseId, async (prisma) => { - await prisma.$executeRawUnsafe('SET TRANSACTION READ ONLY'); - return await prisma.$queryRawUnsafe(sql); - }); + private async setLocalStatementTimeout(prisma: { + $executeRawUnsafe(query: string): Promise; + }) { + const timeoutMs = this.thresholdConfig.searchTimeout; + await prisma.$executeRawUnsafe( + this.knex.raw(`SET LOCAL statement_timeout = ?`, [timeoutMs]).toQuery() + ); } /** @@ -197,23 +208,7 @@ export class BaseSqlExecutorService { database: this.driver, }); // 3. read only role check table access, only pg and pg version > 14 support - try { - await this.readonlyExecuteSql(baseId, sql); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - throw new CustomHttpException( - `read only check failed: ${error?.meta?.message || error?.message}`, - HttpErrorCode.VALIDATION_ERROR, - { - localization: { - i18nKey: 'httpErrors.baseSqlExecutor.readOnlyCheckFailed', - context: { - message: error?.meta?.message || error?.message, - }, - }, - } - ); - } + // TODO: need read only db connection for better security } async executeQuerySql( @@ -225,10 +220,14 @@ export class BaseSqlExecutorService { } ) { await this.safeCheckSql(baseId, sql, opts); - await this.roleCheckAndCreate(baseId); + const shouldSetLocalRole = await this.roleCheckAndCreate(baseId); return this.databaseRouter.dataPrismaTransactionForBase(baseId, async (prisma) => { try { - await this.setRole(prisma, baseId); + await this.setLocalStatementTimeout(prisma); + await this.setTransactionReadOnly(prisma); + if (shouldSetLocalRole) { + await this.setLocalRole(prisma, baseId); + } return await prisma.$queryRawUnsafe(sql); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { @@ -244,10 +243,6 @@ export class BaseSqlExecutorService { }, } ); - } finally { - await this.resetRole(prisma).catch((error) => { - console.log('resetRole error', error); - }); } }); } diff --git a/apps/nestjs-backend/src/features/base-sql-executor/utils.spec.ts b/apps/nestjs-backend/src/features/base-sql-executor/utils.spec.ts index dc37594fc4..e618634237 100644 --- a/apps/nestjs-backend/src/features/base-sql-executor/utils.spec.ts +++ b/apps/nestjs-backend/src/features/base-sql-executor/utils.spec.ts @@ -13,6 +13,14 @@ describe('base sql executor utils', () => { expect(() => validateRoleOperations('set role xxx;')).toThrow(); }); + it('should throw an error if the sql contains set local role', () => { + expect(() => validateRoleOperations('set local role xxx')).toThrow(); + }); + + it('should throw an error if the sql contains set session role', () => { + expect(() => validateRoleOperations('set session role xxx')).toThrow(); + }); + it('should throw an error if the sql contains set role with line break', () => { expect(() => validateRoleOperations(`set @@ -136,5 +144,74 @@ describe('base sql executor utils', () => { }) ).not.toThrow(); }); + + it.each([ + 'SELECT count(*) FROM "bseXXX"."tblYYY"', + 'SELECT sum("amount") FROM "bseXXX"."tblYYY"', + 'SELECT abs("amount") FROM "bseXXX"."tblYYY"', + 'SELECT round("amount", 2) FROM "bseXXX"."tblYYY"', + 'SELECT floor("amount") FROM "bseXXX"."tblYYY"', + 'SELECT ceil("amount") FROM "bseXXX"."tblYYY"', + 'SELECT ceiling("amount") FROM "bseXXX"."tblYYY"', + 'SELECT lower("name") FROM "bseXXX"."tblYYY"', + 'SELECT replace("name", \'a\', \'b\') FROM "bseXXX"."tblYYY"', + 'SELECT regexp_replace("name", \'a\', \'b\') FROM "bseXXX"."tblYYY"', + 'SELECT substring("name" from 1 for 3) FROM "bseXXX"."tblYYY"', + 'SELECT substr("name", 1, 3) FROM "bseXXX"."tblYYY"', + 'SELECT concat("first_name", "last_name") FROM "bseXXX"."tblYYY"', + 'SELECT concat_ws(\' \', "first_name", "last_name") FROM "bseXXX"."tblYYY"', + 'SELECT split_part("name", \'/\', 1) FROM "bseXXX"."tblYYY"', + 'SELECT coalesce("name", \'unknown\') FROM "bseXXX"."tblYYY"', + 'SELECT to_char("created_time", \'YYYY-MM-DD\') FROM "bseXXX"."tblYYY"', + 'SELECT extract(year from "created_time") FROM "bseXXX"."tblYYY"', + 'SELECT json_extract_path_text("payload"::json, \'name\') FROM "bseXXX"."tblYYY"', + ])('allows explicitly permitted function in %s', (sql) => { + expect(() => + checkTableAccess(sql, { + tableNames: ['bseXXX.tblYYY'], + database: DriverClient.Pg, + }) + ).not.toThrow(); + }); + + it.each([ + [ + "SELECT query_to_xml('SELECT rolname FROM pg_catalog.pg_roles', true, false, '')", + 'query_to_xml', + ], + [ + "SELECT query_to_xmlschema('SELECT relname FROM pg_catalog.pg_class', true, false, '')", + 'query_to_xmlschema', + ], + [ + "SELECT query_to_xml_and_xmlschema('SELECT table_name FROM information_schema.tables', true, false, '')", + 'query_to_xml_and_xmlschema', + ], + ["SELECT ts_stat('SELECT to_tsvector(''simple'', ''abc'')')", 'ts_stat'], + ["SELECT set_config('statement_timeout','0',true)", 'set_config'], + ['SELECT lo_create(0)', 'lo_create'], + ["SELECT lo_from_bytea(0, decode('414243','hex'))", 'lo_from_bytea'], + ['SELECT pg_try_advisory_lock(123456789)', 'pg_try_advisory_lock'], + ['SELECT pg_advisory_unlock_all()', 'pg_advisory_unlock_all'], + [ + "WITH n AS (SELECT pg_notify('cuppy_probe', 'blackbox')) SELECT 'ok' AS result FROM n", + 'pg_notify', + ], + ["SELECT pg_logical_emit_message(false, 'cuppy_probe', 'hello')", 'pg_logical_emit_message'], + ['SELECT pg_export_snapshot()', 'pg_export_snapshot'], + ['SELECT pg_lock_status()', 'pg_lock_status'], + ['SELECT pg_control_system()', 'pg_control_system'], + ['SELECT pg_current_wal_lsn()', 'pg_current_wal_lsn'], + ['SELECT pg_database_size(current_database())', 'pg_database_size'], + ['SELECT version()', 'version'], + ["SELECT pg_catalog.pg_notify('cuppy_probe', 'blackbox')", 'pg_notify'], + ])('blocks unapproved function %s', (sql, functionName) => { + expect(() => + checkTableAccess(sql, { + tableNames: [], + database: DriverClient.Pg, + }) + ).toThrow(new RegExp(`function ${functionName}`)); + }); }); }); diff --git a/apps/nestjs-backend/src/features/base-sql-executor/utils.ts b/apps/nestjs-backend/src/features/base-sql-executor/utils.ts index a56e1922e1..0474f82dc9 100644 --- a/apps/nestjs-backend/src/features/base-sql-executor/utils.ts +++ b/apps/nestjs-backend/src/features/base-sql-executor/utils.ts @@ -2,6 +2,9 @@ import { DriverClient, HttpErrorCode } from '@teable/core'; import type { AST } from 'node-sql-parser'; import { Parser } from 'node-sql-parser'; import { CustomHttpException } from '../../custom.exception'; +import { allowedFunctions } from './allowed-functions'; + +const whiteListCheckErrorKey = 'httpErrors.baseSqlExecutor.whiteListCheckError'; export const validateRoleOperations = (sql: string) => { const removeQuotedContent = (sql: string) => { @@ -11,7 +14,11 @@ export const validateRoleOperations = (sql: string) => { const normalizedSql = sql.toLowerCase().replace(/\s+/g, ' '); const sqlWithoutQuotes = removeQuotedContent(normalizedSql); - const roleOperationPatterns = [/set\s+role/, /reset\s+role/, /set\s+session/]; + const roleOperationPatterns = [ + /set\s+(?:local\s+|session\s+)?role/, + /reset\s+role/, + /set\s+session/, + ]; for (const pattern of roleOperationPatterns) { if (pattern.test(sqlWithoutQuotes)) { @@ -35,6 +42,79 @@ const databaseTypeMap = { [DriverClient.Pg]: 'postgresql', }; +const getFunctionName = (node: unknown): string | null => { + const functionNode = node as { + type?: unknown; + name?: { name?: Array<{ value?: unknown }> }; + }; + if (functionNode.type !== 'function') { + return null; + } + + const nameParts = functionNode.name?.name; + const lastNamePart = nameParts?.[nameParts.length - 1]?.value; + return typeof lastNamePart === 'string' ? lastNamePart.toLowerCase() : null; +}; + +const findUnallowedFunctionInArray = (values: unknown[]): string | null => { + for (const value of values) { + const unallowed = findUnallowedFunction(value); + if (unallowed) { + return unallowed; + } + } + + return null; +}; + +const findUnallowedFunctionInValue = (value: unknown): string | null => { + if (Array.isArray(value)) { + return findUnallowedFunctionInArray(value); + } + + return findUnallowedFunction(value); +}; + +function findUnallowedFunction(node: unknown): string | null { + if (!node || typeof node !== 'object') { + return null; + } + + const functionName = getFunctionName(node); + if (functionName && !allowedFunctions.has(functionName)) { + return functionName; + } + + for (const value of Object.values(node)) { + const unallowed = findUnallowedFunctionInValue(value); + if (unallowed) { + return unallowed; + } + } + + return null; +} + +const validateFunctionCalls = (ast: AST | AST[]) => { + const unallowed = findUnallowedFunction(ast); + if (!unallowed) { + return; + } + + throw new CustomHttpException( + `not allowed to execute sql with function ${unallowed}`, + HttpErrorCode.VALIDATION_ERROR, + { + localization: { + i18nKey: whiteListCheckErrorKey, + context: { + function: unallowed, + }, + }, + } + ); +}; + const collectWithNames = (ast?: AST) => { if (!ast) { return []; @@ -79,6 +159,7 @@ export const checkTableAccess = ( ); } })(); + validateFunctionCalls(ast); const withNames = Array.isArray(ast) ? ast.flatMap(collectWithNames) : collectWithNames(ast); const allowedTables = new Set([...withNames, ...tableNames]); const whiteColumnList = Array.from(allowedTables).map((table) => { @@ -95,6 +176,18 @@ export const checkTableAccess = ( } const sqlTableList = parser.tableList(sql, opt); + + if (!sqlTableList.length) { + throw new CustomHttpException( + 'SQL syntax error or no table accessed, please check your query', + HttpErrorCode.VALIDATION_ERROR, + { + localization: { + i18nKey: 'httpErrors.baseSqlExecutor.sqlSyntaxError', + }, + } + ); + } const invalidTableNames = sqlTableList .filter((t: string) => !whiteColumnList.includes(t)) .map((t: string) => t.split('::').pop()!); @@ -111,7 +204,7 @@ export const checkTableAccess = ( HttpErrorCode.VALIDATION_ERROR, { localization: { - i18nKey: 'httpErrors.baseSqlExecutor.whiteListCheckError', + i18nKey: whiteListCheckErrorKey, context: { message, }, diff --git a/apps/nestjs-backend/src/features/base/base-duplicate.service.spec.ts b/apps/nestjs-backend/src/features/base/base-duplicate.service.spec.ts index fc0f545ebb..746ad4dcaf 100644 --- a/apps/nestjs-backend/src/features/base/base-duplicate.service.spec.ts +++ b/apps/nestjs-backend/src/features/base/base-duplicate.service.spec.ts @@ -2,11 +2,10 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; import { FieldType, Relationship } from '@teable/core'; import { BaseDuplicateMode } from '@teable/openapi'; -import { v2RecordRepositoryPostgresTokens } from '@teable/v2-adapter-table-repository-postgres'; -import { TableByIdSpec, v2CoreTokens } from '@teable/v2-core'; +import { v2CoreTokens, type DuplicateBaseRecordReadOptions } from '@teable/v2-core'; import { GlobalModule } from '../../global/global.module'; import { BaseDuplicateService } from './base-duplicate.service'; -import type { BaseImportProgressCallback, IBaseImportProgress } from './base-import.service'; +import type { IBaseImportProgress } from './base-import.service'; import { BaseModule } from './base.module'; import type { ILinkFieldTableMap } from './utils'; @@ -150,7 +149,6 @@ describe('BaseDuplicateService normalizeDuplicateStructureForV2', () => { }); describe('BaseDuplicateService duplicateBaseV2', () => { - const okResult = (value: T) => ({ isErr: () => false, value }); type IServiceArgs = ConstructorParameters; const duplicateBaseName = 'Duplicated base'; const sourceTableName = 'Source table'; @@ -160,15 +158,20 @@ describe('BaseDuplicateService duplicateBaseV2', () => { buildDuplicateStructureConfig: (...args: unknown[]) => Promise; getCrossBaseLinkFieldTableMap: (...args: unknown[]) => Promise; getDisconnectedLinkFieldTableMap: (...args: unknown[]) => Promise; + getV2CrossBaseLinkFieldTableMap: (...args: unknown[]) => Promise; + getV2DisconnectedLinkFieldTableMap: (...args: unknown[]) => Promise; + getV2InternalLinkRelationTableMap: (...args: unknown[]) => Promise>; getDisconnectedLinkFieldIds: (...args: unknown[]) => Promise; normalizeDuplicateStructureForV2: (structure: unknown) => unknown; createDuplicateBaseSource: (...args: unknown[]) => { - records(tableId: string): AsyncIterable<{ fields: Record }>; + records( + tableId: string, + options?: DuplicateBaseRecordReadOptions + ): AsyncIterable<{ fields: Record }>; }; duplicateTableData: (...args: unknown[]) => Promise; duplicateAttachments: (...args: unknown[]) => Promise; duplicateLinkJunction: (...args: unknown[]) => Promise; - backfillDuplicatedBaseComputedFields: (...args: unknown[]) => Promise; }; it('should create the v2 execution context from the space data container', async () => { @@ -231,9 +234,9 @@ describe('BaseDuplicateService duplicateBaseV2', () => { {} as IServiceArgs[6], { get: vi.fn().mockReturnValue('usrTest') } as unknown as IServiceArgs[7], {} as IServiceArgs[8], - {} as IServiceArgs[9], - v2ContainerService as unknown as IServiceArgs[10], - v2ContextFactory as unknown as IServiceArgs[11] + v2ContainerService as unknown as IServiceArgs[9], + v2ContextFactory as unknown as IServiceArgs[10], + {} as IServiceArgs[11] ); const internals = service as unknown as IDuplicateServiceInternals; @@ -243,9 +246,9 @@ describe('BaseDuplicateService duplicateBaseV2', () => { structure, sourceDbTableNameByTableId, }); - vi.spyOn(internals, 'getCrossBaseLinkFieldTableMap').mockResolvedValue({}); - vi.spyOn(internals, 'getDisconnectedLinkFieldTableMap').mockResolvedValue({}); - vi.spyOn(internals, 'getDisconnectedLinkFieldIds').mockResolvedValue([]); + vi.spyOn(internals, 'getV2CrossBaseLinkFieldTableMap').mockResolvedValue({}); + vi.spyOn(internals, 'getV2DisconnectedLinkFieldTableMap').mockResolvedValue({}); + vi.spyOn(internals, 'getV2InternalLinkRelationTableMap').mockResolvedValue({}); vi.spyOn(internals, 'normalizeDuplicateStructureForV2').mockReturnValue(structure); vi.spyOn(internals, 'createDuplicateBaseSource').mockReturnValue(source); @@ -262,15 +265,17 @@ describe('BaseDuplicateService duplicateBaseV2', () => { 'bseSource', structure, {}, - sourceDbTableNameByTableId + sourceDbTableNameByTableId, + {} ); expect(commandBus.execute).toHaveBeenCalledWith(context, expect.any(Object)); }); - it('should create v2 structure first and copy records with raw table duplication', async () => { + it('should copy direct duplicate records through bulk SQL after v2 structure creation', async () => { const spaceId = 'spcTarget'; const targetBaseId = 'bseTarget'; const tableIdMap = { tblSource: 'tblTarget' }; + const targetTableId = tableIdMap.tblSource; const fieldIdMap = { fldLink: 'fldTargetLink' }; const viewIdMap = { viwSource: 'viwTarget' }; const context = { requestId: 'ctx' }; @@ -335,10 +340,10 @@ describe('BaseDuplicateService duplicateBaseV2', () => { {} as IServiceArgs[5], persistedComputedBackfillService as unknown as IServiceArgs[6], { get: vi.fn().mockReturnValue('usrTest') } as unknown as IServiceArgs[7], - {} as IServiceArgs[8], - dataDbClientManager as unknown as IServiceArgs[9], - v2ContainerService as unknown as IServiceArgs[10], - v2ContextFactory as unknown as IServiceArgs[11] + dataDbClientManager as unknown as IServiceArgs[8], + v2ContainerService as unknown as IServiceArgs[9], + v2ContextFactory as unknown as IServiceArgs[10], + {} as IServiceArgs[11] ); const internals = service as unknown as IDuplicateServiceInternals; const mergedLinkFieldTableMap = { @@ -349,17 +354,17 @@ describe('BaseDuplicateService duplicateBaseV2', () => { structure, sourceDbTableNameByTableId: { tblSource: sourceDbTableName }, }); - vi.spyOn(internals, 'getCrossBaseLinkFieldTableMap').mockResolvedValue({}); - vi.spyOn(internals, 'getDisconnectedLinkFieldTableMap').mockResolvedValue( + vi.spyOn(internals, 'getV2CrossBaseLinkFieldTableMap').mockResolvedValue({}); + vi.spyOn(internals, 'getV2DisconnectedLinkFieldTableMap').mockResolvedValue( mergedLinkFieldTableMap ); + vi.spyOn(internals, 'getV2InternalLinkRelationTableMap').mockResolvedValue({}); vi.spyOn(internals, 'getDisconnectedLinkFieldIds').mockResolvedValue(['fldDisconnected']); vi.spyOn(internals, 'normalizeDuplicateStructureForV2').mockReturnValue(structure); vi.spyOn(internals, 'createDuplicateBaseSource').mockReturnValue(source); vi.spyOn(internals, 'duplicateTableData').mockResolvedValue(12); vi.spyOn(internals, 'duplicateAttachments').mockResolvedValue(undefined); vi.spyOn(internals, 'duplicateLinkJunction').mockResolvedValue(undefined); - vi.spyOn(internals, 'backfillDuplicatedBaseComputedFields').mockResolvedValue(undefined); const result = await service.duplicateBaseV2({ fromBaseId: 'bseSource', @@ -368,15 +373,18 @@ describe('BaseDuplicateService duplicateBaseV2', () => { withRecords: true, }); - const executedCommand = commandBus.execute.mock.calls[0]?.[1] as { withRecords: boolean }; + const executedCommand = commandBus.execute.mock.calls[0]?.[1] as { + withRecords: boolean; + batchSize: number; + }; expect(executedCommand.withRecords).toBe(false); + expect(executedCommand.batchSize).toBe(500); expect(internals.duplicateTableData).toHaveBeenCalledWith( targetBaseId, tableIdMap, fieldIdMap, viewIdMap, - mergedLinkFieldTableMap, - undefined + mergedLinkFieldTableMap ); expect(internals.duplicateLinkJunction).toHaveBeenCalledWith( targetBaseId, @@ -385,69 +393,130 @@ describe('BaseDuplicateService duplicateBaseV2', () => { true, ['fldDisconnected'] ); - expect(persistedComputedBackfillService.recomputeForTables).toHaveBeenCalledWith(['tblTarget']); - expect(internals.backfillDuplicatedBaseComputedFields).toHaveBeenCalledWith( - container, - context, - ['tblTarget'] + expect(persistedComputedBackfillService.recomputeForTables).toHaveBeenCalledWith([ + targetTableId, + ]); + expect(internals.duplicateAttachments).toHaveBeenCalledWith( + targetBaseId, + tableIdMap, + fieldIdMap ); expect(result.recordsLength).toBe(12); }); - it('should synchronously backfill v2 computed and link fields after raw record copy', async () => { - const targetTableId = 'tblaaaaaaaaaaaaaaaa'; + it('should stream v2 duplicate records when source and target data databases differ', async () => { + const spaceId = 'spcTarget'; + const targetBaseId = 'bseTarget'; + const tableIdMap = { tblSource: 'tblTarget' }; + const fieldIdMap = { fldText: 'fldTargetText' }; + const viewIdMap = { viwSource: 'viwTarget' }; const context = { requestId: 'ctx' }; - const fields = [{ name: 'Owner' }]; - const table = { - getFields: vi.fn().mockReturnValue(fields), + const db = { dialect: 'pg' }; + const structure = { + name: duplicateBaseName, + icon: undefined, + tables: [{ id: 'tblSource', name: sourceTableName, fields: [], views: [] }], }; - const tableRepository = { - findOne: vi.fn().mockResolvedValue(okResult(table)), + const source = { + structure, + records: async function* () { + yield undefined as never; + }, }; - const backfillService = { - executeSyncMany: vi.fn().mockResolvedValue(okResult(undefined)), + const commandBus = { + execute: vi.fn().mockResolvedValue({ + isErr: () => false, + value: (async function* () { + yield { + id: 'done', + baseId: targetBaseId, + tableIdMap, + fieldIdMap, + viewIdMap, + recordsLength: 7, + }; + })(), + }), }; const container = { - resolve: vi.fn((token: symbol) => { - if (token === v2CoreTokens.tableRepository) { - return tableRepository; - } - if (token === v2RecordRepositoryPostgresTokens.computedFieldBackfillService) { - return backfillService; - } - throw new Error('Unexpected token'); - }), + resolve: vi.fn().mockReturnValueOnce(commandBus).mockReturnValueOnce(db), + }; + const baseUpdate = vi.fn(); + const baseImportService = { + createBaseV2: vi.fn().mockResolvedValue({ id: targetBaseId }), + restoreBaseExtrasV2: vi.fn().mockResolvedValue({ appIdMap: {}, workflowIdMap: {} }), + }; + const dataDbClientManager = { + getDataDatabaseForBase: vi + .fn() + .mockResolvedValueOnce({ cacheKey: 'source-db' }) + .mockResolvedValueOnce({ cacheKey: 'target-db' }), + }; + const v2ContainerService = { + getContainerForSpace: vi.fn().mockResolvedValue(container), + }; + const v2ContextFactory = { + createContext: vi.fn().mockResolvedValue(context), }; const service = new BaseDuplicateService( - {} as IServiceArgs[0], + { + txClient: vi.fn().mockReturnValue({ + base: { update: baseUpdate }, + }), + } as unknown as IServiceArgs[0], {} as IServiceArgs[1], {} as IServiceArgs[2], - {} as IServiceArgs[3], + baseImportService as unknown as IServiceArgs[3], {} as IServiceArgs[4], {} as IServiceArgs[5], {} as IServiceArgs[6], - {} as IServiceArgs[7], - {} as IServiceArgs[8], - {} as IServiceArgs[9], - {} as IServiceArgs[10], + { get: vi.fn().mockReturnValue('usrTest') } as unknown as IServiceArgs[7], + dataDbClientManager as unknown as IServiceArgs[8], + v2ContainerService as unknown as IServiceArgs[9], + v2ContextFactory as unknown as IServiceArgs[10], {} as IServiceArgs[11] ); const internals = service as unknown as IDuplicateServiceInternals; - await internals.backfillDuplicatedBaseComputedFields(container, context, [targetTableId]); + vi.spyOn(internals, 'buildDuplicateStructureConfig').mockResolvedValue({ + structure, + sourceDbTableNameByTableId: { tblSource: sourceDbTableName }, + }); + vi.spyOn(internals, 'getV2CrossBaseLinkFieldTableMap').mockResolvedValue({}); + vi.spyOn(internals, 'getV2DisconnectedLinkFieldTableMap').mockResolvedValue({}); + vi.spyOn(internals, 'getV2InternalLinkRelationTableMap').mockResolvedValue({}); + vi.spyOn(internals, 'getDisconnectedLinkFieldIds').mockResolvedValue([]); + vi.spyOn(internals, 'normalizeDuplicateStructureForV2').mockReturnValue(structure); + vi.spyOn(internals, 'createDuplicateBaseSource').mockReturnValue(source); + vi.spyOn(internals, 'duplicateTableData').mockResolvedValue(0); + vi.spyOn(internals, 'duplicateAttachments').mockResolvedValue(undefined); + vi.spyOn(internals, 'duplicateLinkJunction').mockResolvedValue(undefined); - expect(container.resolve).toHaveBeenCalledWith(v2CoreTokens.tableRepository); - expect(container.resolve).toHaveBeenCalledWith( - v2RecordRepositoryPostgresTokens.computedFieldBackfillService - ); - expect(tableRepository.findOne).toHaveBeenCalledWith(context, expect.any(TableByIdSpec)); - expect(backfillService.executeSyncMany).toHaveBeenCalledWith(context, { - table, - fields, - skipDistinctFilter: true, - includeOneManyTwoWay: true, + const result = await service.duplicateBaseV2({ + fromBaseId: 'bseSource', + spaceId, + name: duplicateBaseName, + withRecords: true, + }); + + expect(dataDbClientManager.getDataDatabaseForBase).toHaveBeenCalledWith('bseSource', { + useTransaction: true, }); + expect(dataDbClientManager.getDataDatabaseForBase).toHaveBeenCalledWith(targetBaseId, { + useTransaction: true, + }); + expect((commandBus.execute.mock.calls[0]?.[1] as { withRecords: boolean }).withRecords).toBe( + true + ); + expect(internals.duplicateTableData).not.toHaveBeenCalled(); + expect(internals.duplicateLinkJunction).not.toHaveBeenCalled(); + expect(internals.duplicateAttachments).toHaveBeenCalledWith( + targetBaseId, + tableIdMap, + fieldIdMap + ); + expect(result.recordsLength).toBe(7); }); it('should forward real row totals through duplicateBaseV2 progress events', async () => { @@ -473,13 +542,35 @@ describe('BaseDuplicateService duplicateBaseV2', () => { execute: vi.fn().mockResolvedValue({ isErr: () => false, value: (async function* () { + yield { + id: 'progress', + phase: 'table_data_start', + processedRows: 0, + totalRows: 12, + }; + yield { + id: 'progress', + phase: 'table_data_progress', + tableId: 'tblTarget', + tableName: sourceTableName, + processedRows: 5, + batchProcessedRows: 5, + currentBatch: 1, + totalRows: 12, + }; + yield { + id: 'progress', + phase: 'table_data_done', + processedRows: 12, + totalRows: 12, + }; yield { id: 'done', baseId: targetBaseId, tableIdMap, fieldIdMap, viewIdMap, - recordsLength: 0, + recordsLength: 12, }; })(), }), @@ -517,10 +608,10 @@ describe('BaseDuplicateService duplicateBaseV2', () => { {} as IServiceArgs[5], persistedComputedBackfillService as unknown as IServiceArgs[6], { get: vi.fn().mockReturnValue('usrTest') } as unknown as IServiceArgs[7], - {} as IServiceArgs[8], - dataDbClientManager as unknown as IServiceArgs[9], - v2ContainerService as unknown as IServiceArgs[10], - v2ContextFactory as unknown as IServiceArgs[11] + dataDbClientManager as unknown as IServiceArgs[8], + v2ContainerService as unknown as IServiceArgs[9], + v2ContextFactory as unknown as IServiceArgs[10], + {} as IServiceArgs[11] ); const internals = service as unknown as IDuplicateServiceInternals; const progressEvents: unknown[] = []; @@ -529,29 +620,14 @@ describe('BaseDuplicateService duplicateBaseV2', () => { structure, sourceDbTableNameByTableId: { tblSource: sourceDbTableName }, }); - vi.spyOn(internals, 'getCrossBaseLinkFieldTableMap').mockResolvedValue({}); - vi.spyOn(internals, 'getDisconnectedLinkFieldTableMap').mockResolvedValue({}); - vi.spyOn(internals, 'getDisconnectedLinkFieldIds').mockResolvedValue([]); + vi.spyOn(internals, 'getV2CrossBaseLinkFieldTableMap').mockResolvedValue({}); + vi.spyOn(internals, 'getV2DisconnectedLinkFieldTableMap').mockResolvedValue({}); + vi.spyOn(internals, 'getV2InternalLinkRelationTableMap').mockResolvedValue({}); vi.spyOn(internals, 'normalizeDuplicateStructureForV2').mockReturnValue(structure); vi.spyOn(internals, 'createDuplicateBaseSource').mockReturnValue(source); - vi.spyOn(internals, 'duplicateTableData').mockImplementation(async (...args: unknown[]) => { - const onProgress = args[5] as BaseImportProgressCallback | undefined; - onProgress?.({ phase: 'table_data_start', processedRows: 0, totalRows: 12 }); - onProgress?.({ - phase: 'table_data_progress', - tableId: 'tblTarget', - tableName: sourceTableName, - processedRows: 5, - batchProcessedRows: 5, - currentBatch: 1, - totalRows: 12, - }); - onProgress?.({ phase: 'table_data_done', processedRows: 12, totalRows: 12 }); - return 12; - }); + vi.spyOn(internals, 'duplicateTableData').mockResolvedValue(12); vi.spyOn(internals, 'duplicateAttachments').mockResolvedValue(undefined); vi.spyOn(internals, 'duplicateLinkJunction').mockResolvedValue(undefined); - vi.spyOn(internals, 'backfillDuplicatedBaseComputedFields').mockResolvedValue(undefined); const result = await service.duplicateBaseV2( { @@ -565,13 +641,9 @@ describe('BaseDuplicateService duplicateBaseV2', () => { (event: string | IBaseImportProgress) => progressEvents.push(event) ); - expect(internals.duplicateTableData).toHaveBeenCalledWith( - targetBaseId, - tableIdMap, - fieldIdMap, - viewIdMap, - {}, - expect.any(Function) + expect(internals.duplicateTableData).not.toHaveBeenCalled(); + expect((commandBus.execute.mock.calls[0]?.[1] as { withRecords: boolean }).withRecords).toBe( + true ); expect(progressEvents).toEqual( expect.arrayContaining([ @@ -661,10 +733,10 @@ describe('BaseDuplicateService duplicateBaseV2', () => { knex as unknown as IServiceArgs[5], {} as IServiceArgs[6], {} as IServiceArgs[7], - {} as IServiceArgs[8], { dataPrismaForBase: vi.fn().mockResolvedValue(dataPrisma), - } as unknown as IServiceArgs[9], + } as unknown as IServiceArgs[8], + {} as IServiceArgs[9], {} as IServiceArgs[10], {} as IServiceArgs[11] ); @@ -724,10 +796,10 @@ describe('BaseDuplicateService duplicateBaseV2', () => { {} as IServiceArgs[5], {} as IServiceArgs[6], {} as IServiceArgs[7], - {} as IServiceArgs[8], { dataKnexForBase: vi.fn().mockResolvedValue(dataKnex), - } as unknown as IServiceArgs[9], + } as unknown as IServiceArgs[8], + {} as IServiceArgs[9], {} as IServiceArgs[10], {} as IServiceArgs[11] ); @@ -768,7 +840,503 @@ describe('BaseDuplicateService duplicateBaseV2', () => { recordId: 'recSource', fields: { fldText: 'A' }, autoNumber: 1, + lastModifiedTime: null, + lastModifiedBy: null, }, ]); }); + + it('should skip internal v2 link relation reads during insert phase', async () => { + const sourceTableQuery = { + select: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi + .fn() + .mockResolvedValueOnce([ + { + __id: 'recSource', + __auto_number: 1, + fldStory: [{ id: 'recStale', title: 'Deleted story' }], + }, + ]) + .mockResolvedValueOnce([]), + }; + const dataKnex = vi.fn((tableName: string) => { + if (tableName === 'bseSource.tblSource') return sourceTableQuery; + throw new Error(`unexpected table ${tableName}`); + }); + const service = new BaseDuplicateService( + {} as IServiceArgs[0], + {} as IServiceArgs[1], + {} as IServiceArgs[2], + {} as IServiceArgs[3], + {} as IServiceArgs[4], + {} as IServiceArgs[5], + {} as IServiceArgs[6], + {} as IServiceArgs[7], + { dataKnexForBase: vi.fn().mockResolvedValue(dataKnex) } as unknown as IServiceArgs[8], + {} as IServiceArgs[9], + {} as IServiceArgs[10], + {} as IServiceArgs[11] + ); + const internals = service as unknown as IDuplicateServiceInternals; + const source = internals.createDuplicateBaseSource( + 'bseSource', + { + name: duplicateBaseName, + tables: [ + { + id: 'tblSource', + name: sourceTableName, + dbTableName: 'tblShortName', + fields: [ + { + id: 'fldStory', + name: 'Story', + dbFieldName: 'fldStory', + type: FieldType.Link, + }, + ], + views: [], + }, + ], + }, + {}, + { tblSource: 'bseSource.tblSource' }, + { + tblSource: [ + { + fieldId: 'fldStory', + dbFieldName: 'fldStory', + foreignTableId: 'tblStory', + lookupFieldId: 'fldStoryName', + relationship: 'manyMany', + fkHostTableName: 'bseSource.junction_fldStory', + selfKeyName: '__fk_self', + foreignKeyName: '__fk_foreign', + isOneWay: false, + isMultipleCellValue: true, + orderColumnName: '__order', + }, + ], + } + ); + + const records = []; + for await (const record of source.records('tblSource', { phase: 'insert' })) { + records.push(record); + } + + expect(dataKnex).toHaveBeenCalledWith('bseSource.tblSource'); + expect(dataKnex).not.toHaveBeenCalledWith('bseSource.junction_fldStory'); + expect(records[0].fields.fldStory).toEqual([{ id: 'recStale', title: 'Deleted story' }]); + }); + + it('should rebuild internal v2 link values from relation storage and ignore stale cache ids', async () => { + const sourceTableQuery = { + select: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi + .fn() + .mockResolvedValueOnce([ + { + __id: 'recSource', + __auto_number: 1, + fldStory: [ + { id: 'recExisting', title: 'Existing story' }, + { id: 'recStale', title: 'Deleted story' }, + ], + }, + ]) + .mockResolvedValueOnce([]), + }; + const junctionQuery = { + select: vi.fn().mockReturnThis(), + whereIn: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockResolvedValue([ + { + sourceRecordId: 'recSource', + foreignRecordId: 'recExisting', + }, + ]), + }; + const dataKnex = vi.fn((tableName: string) => { + if (tableName === 'bseSource.tblSource') return sourceTableQuery; + if (tableName === 'bseSource.junction_fldStory') return junctionQuery; + throw new Error(`unexpected table ${tableName}`); + }); + const service = new BaseDuplicateService( + {} as IServiceArgs[0], + {} as IServiceArgs[1], + {} as IServiceArgs[2], + {} as IServiceArgs[3], + {} as IServiceArgs[4], + {} as IServiceArgs[5], + {} as IServiceArgs[6], + {} as IServiceArgs[7], + { dataKnexForBase: vi.fn().mockResolvedValue(dataKnex) } as unknown as IServiceArgs[8], + {} as IServiceArgs[9], + {} as IServiceArgs[10], + {} as IServiceArgs[11] + ); + const internals = service as unknown as IDuplicateServiceInternals; + const source = internals.createDuplicateBaseSource( + 'bseSource', + { + name: duplicateBaseName, + tables: [ + { + id: 'tblSource', + name: sourceTableName, + dbTableName: 'tblShortName', + fields: [ + { + id: 'fldStory', + name: 'Story', + dbFieldName: 'fldStory', + type: FieldType.Link, + }, + ], + views: [], + }, + ], + }, + {}, + { tblSource: 'bseSource.tblSource' }, + { + tblSource: [ + { + fieldId: 'fldStory', + dbFieldName: 'fldStory', + foreignTableId: 'tblStory', + lookupFieldId: 'fldStoryName', + relationship: 'manyMany', + fkHostTableName: 'bseSource.junction_fldStory', + selfKeyName: '__fk_self', + foreignKeyName: '__fk_foreign', + isOneWay: false, + isMultipleCellValue: true, + orderColumnName: '__order', + }, + ], + } + ); + + const records = []; + for await (const record of source.records('tblSource', { phase: 'linkRestore' })) { + records.push(record); + } + + expect(dataKnex).toHaveBeenCalledWith('bseSource.tblSource'); + expect(dataKnex).toHaveBeenCalledWith('bseSource.junction_fldStory'); + expect(junctionQuery.whereIn).toHaveBeenCalledWith('__fk_self', ['recSource']); + expect(records[0].fields.fldStory).toEqual([{ id: 'recExisting' }]); + }); + + it('should rebuild many-one internal v2 link values from current table FK storage', async () => { + const sourceTableQuery = { + select: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi + .fn() + .mockResolvedValueOnce([ + { + __id: 'recChild', + __auto_number: 1, + fldParent: { id: 'recStale', title: 'Deleted parent' }, + }, + ]) + .mockResolvedValueOnce([]), + whereIn: vi.fn().mockReturnThis(), + whereNotNull: vi.fn().mockResolvedValue([ + { + sourceRecordId: 'recChild', + foreignRecordId: 'recParent', + }, + ]), + }; + const dataKnex = vi.fn((tableName: string) => { + if (tableName === 'bseSource.childTable') return sourceTableQuery; + throw new Error(`unexpected table ${tableName}`); + }); + const service = new BaseDuplicateService( + {} as IServiceArgs[0], + {} as IServiceArgs[1], + {} as IServiceArgs[2], + {} as IServiceArgs[3], + {} as IServiceArgs[4], + {} as IServiceArgs[5], + {} as IServiceArgs[6], + {} as IServiceArgs[7], + { dataKnexForBase: vi.fn().mockResolvedValue(dataKnex) } as unknown as IServiceArgs[8], + {} as IServiceArgs[9], + {} as IServiceArgs[10], + {} as IServiceArgs[11] + ); + const internals = service as unknown as IDuplicateServiceInternals; + const source = internals.createDuplicateBaseSource( + 'bseSource', + { + name: duplicateBaseName, + tables: [ + { + id: 'tblChild', + name: sourceTableName, + dbTableName: 'childTable', + fields: [ + { + id: 'fldParent', + name: 'Parent', + dbFieldName: 'fldParent', + type: FieldType.Link, + }, + ], + views: [], + }, + ], + }, + {}, + { tblChild: 'bseSource.childTable' }, + { + tblChild: [ + { + fieldId: 'fldParent', + dbFieldName: 'fldParent', + foreignTableId: 'tblParent', + lookupFieldId: 'fldParentName', + relationship: 'manyOne', + fkHostTableName: 'bseSource.childTable', + selfKeyName: '__id', + foreignKeyName: '__fk_parent', + isOneWay: false, + isMultipleCellValue: false, + orderColumnName: '__fk_parent_order', + }, + ], + } + ); + + const records = []; + for await (const record of source.records('tblChild', { phase: 'linkRestore' })) { + records.push(record); + } + + expect(dataKnex).toHaveBeenCalledWith('bseSource.childTable'); + expect(sourceTableQuery.whereIn).toHaveBeenCalledWith('__id', ['recChild']); + expect(sourceTableQuery.whereNotNull).toHaveBeenCalledWith('__fk_parent'); + expect(records[0].fields.fldParent).toEqual({ id: 'recParent' }); + }); + + it('should rebuild two-way one-many internal v2 link values from foreign table FK storage', async () => { + const sourceTableQuery = { + select: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi + .fn() + .mockResolvedValueOnce([ + { + __id: 'recParent', + __auto_number: 1, + fldChildren: [ + { id: 'recChildExisting', title: 'Existing child' }, + { id: 'recChildStale', title: 'Deleted child' }, + ], + }, + ]) + .mockResolvedValueOnce([]), + }; + const foreignTableQuery = { + select: vi.fn().mockReturnThis(), + whereIn: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockResolvedValue([ + { + sourceRecordId: 'recParent', + foreignRecordId: 'recChildExisting', + }, + ]), + }; + const dataKnex = vi.fn((tableName: string) => { + if (tableName === 'bseSource.parentTable') return sourceTableQuery; + if (tableName === 'bseSource.childTable') return foreignTableQuery; + throw new Error(`unexpected table ${tableName}`); + }); + const service = new BaseDuplicateService( + {} as IServiceArgs[0], + {} as IServiceArgs[1], + {} as IServiceArgs[2], + {} as IServiceArgs[3], + {} as IServiceArgs[4], + {} as IServiceArgs[5], + {} as IServiceArgs[6], + {} as IServiceArgs[7], + { dataKnexForBase: vi.fn().mockResolvedValue(dataKnex) } as unknown as IServiceArgs[8], + {} as IServiceArgs[9], + {} as IServiceArgs[10], + {} as IServiceArgs[11] + ); + const internals = service as unknown as IDuplicateServiceInternals; + const source = internals.createDuplicateBaseSource( + 'bseSource', + { + name: duplicateBaseName, + tables: [ + { + id: 'tblParent', + name: sourceTableName, + dbTableName: 'parentTable', + fields: [ + { + id: 'fldChildren', + name: 'Children', + dbFieldName: 'fldChildren', + type: FieldType.Link, + }, + ], + views: [], + }, + ], + }, + {}, + { tblParent: 'bseSource.parentTable' }, + { + tblParent: [ + { + fieldId: 'fldChildren', + dbFieldName: 'fldChildren', + foreignTableId: 'tblChild', + lookupFieldId: 'fldChildName', + relationship: 'oneMany', + fkHostTableName: 'bseSource.childTable', + selfKeyName: '__fk_parent', + foreignKeyName: '__id', + isOneWay: false, + isMultipleCellValue: true, + orderColumnName: '__fk_parent_order', + }, + ], + } + ); + + const records = []; + for await (const record of source.records('tblParent', { phase: 'linkRestore' })) { + records.push(record); + } + + expect(dataKnex).toHaveBeenCalledWith('bseSource.childTable'); + expect(foreignTableQuery.whereIn).toHaveBeenCalledWith('__fk_parent', ['recParent']); + expect(foreignTableQuery.orderBy).toHaveBeenCalledWith('__fk_parent_order', 'asc'); + expect(records[0].fields.fldChildren).toEqual([{ id: 'recChildExisting' }]); + }); + + it('should normalize postgres array literal link values when downgrading v2 cross-base links', async () => { + const query = { + select: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi + .fn() + .mockResolvedValueOnce([ + { + __id: 'recSource', + __auto_number: 1, + fldVendor: '{"{\\"id\\":\\"recVendor\\",\\"title\\":\\"Vendor A\\"}"}', + }, + ]) + .mockResolvedValueOnce([]), + }; + const dataKnex = vi.fn().mockReturnValue(query); + const service = new BaseDuplicateService( + {} as IServiceArgs[0], + {} as IServiceArgs[1], + {} as IServiceArgs[2], + {} as IServiceArgs[3], + {} as IServiceArgs[4], + {} as IServiceArgs[5], + {} as IServiceArgs[6], + {} as IServiceArgs[7], + { + dataKnexForBase: vi.fn().mockResolvedValue(dataKnex), + } as unknown as IServiceArgs[8], + {} as IServiceArgs[9], + {} as IServiceArgs[10], + {} as IServiceArgs[11] + ); + const internals = service as unknown as IDuplicateServiceInternals; + const source = internals.createDuplicateBaseSource( + 'bseSource', + { + name: duplicateBaseName, + tables: [ + { + id: 'tblSource', + name: sourceTableName, + dbTableName: 'tblSource', + fields: [ + { + id: 'fldVendor', + name: 'Vendor', + dbFieldName: 'fldVendor', + type: FieldType.SingleLineText, + }, + ], + views: [], + }, + ], + }, + { + tblSource: [ + { + dbFieldName: 'fldVendor', + selfKeyName: 'fk_fld_vendor', + isMultipleCellValue: true, + }, + ], + }, + { tblSource: 'bseSource.tblSource' } + ); + + const records = []; + for await (const record of source.records('tblSource')) { + records.push(record); + } + + expect(records[0].fields.fldVendor).toBe('Vendor A'); + }); + + it('should include nullable isLookup host link fields when building v2 cross-base maps', async () => { + const fieldFindMany = vi.fn().mockResolvedValue([]); + const service = new BaseDuplicateService( + { + txClient: vi.fn().mockReturnValue({ + field: { findMany: fieldFindMany }, + }), + } as unknown as IServiceArgs[0], + {} as IServiceArgs[1], + {} as IServiceArgs[2], + {} as IServiceArgs[3], + {} as IServiceArgs[4], + {} as IServiceArgs[5], + {} as IServiceArgs[6], + {} as IServiceArgs[7], + {} as IServiceArgs[8], + {} as IServiceArgs[9], + {} as IServiceArgs[10], + {} as IServiceArgs[11] + ); + const internals = service as unknown as IDuplicateServiceInternals; + + await internals.getV2CrossBaseLinkFieldTableMap({ tblSource: 'tblSource' }); + + expect(fieldFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: [{ isLookup: false }, { isLookup: null }], + }), + }) + ); + }); }); diff --git a/apps/nestjs-backend/src/features/base/base-duplicate.service.ts b/apps/nestjs-backend/src/features/base/base-duplicate.service.ts index 8a5f0202a8..92fb40eb0d 100644 --- a/apps/nestjs-backend/src/features/base/base-duplicate.service.ts +++ b/apps/nestjs-backend/src/features/base/base-duplicate.service.ts @@ -10,26 +10,18 @@ import { type IDuplicateBaseRo, } from '@teable/openapi'; import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg'; -import { - v2RecordRepositoryPostgresTokens, - type ComputedFieldBackfillService, -} from '@teable/v2-adapter-table-repository-postgres'; import { DuplicateBaseCommand, - TableByIdSpec, - TableId, v2CoreTokens, + type DuplicateBaseRecordReadOptions, type DotTeaFieldInput, type DuplicateBaseResult, type DuplicateBaseSource, type ICommandBus, - type IExecutionContext, - type ITableRepository, type NormalizedDotTeaField, type NormalizedDotTeaStructure, } from '@teable/v2-core'; -import type { DependencyContainer } from '@teable/v2-di'; -import { normalizeField } from '@teable/v2-dottea'; +import { normalizeFields } from '@teable/v2-dottea'; import { Knex } from 'knex'; import type { Kysely } from 'kysely'; import { groupBy, omit } from 'lodash'; @@ -58,8 +50,10 @@ import { mergeLinkFieldTableMaps } from './utils'; import type { ILinkFieldTableInfo, ILinkFieldTableMap } from './utils'; type DuplicatedBase = Awaited>['base']; -const v2DuplicateReadBatchSize = 500; +const v2DuplicateReadBatchSize = 1000; const v2DuplicateCopyBatchSize = 500; +const v2DuplicateLinkFieldBatchSize = 500; +const v2DuplicateTableIdQueryChunkSize = 100; type DuplicateStructureConfig = Awaited>; type DuplicateV2FieldConfig = Omit< DuplicateStructureConfig['tables'][number]['fields'][number], @@ -77,6 +71,33 @@ type DuplicateStructureConfigResult = { structure: DuplicateStructureConfig; sourceDbTableNameByTableId: Record; }; +type DuplicateBaseStructConfigInput = Parameters< + BaseExportService['generateBaseStructConfig'] +>[0] & { + includeWorkflowRuntimeState?: boolean; +}; +type DuplicateLinkFieldRaw = { + id: string; + tableId: string; + dbFieldName: string; + isMultipleCellValue: boolean | null; + meta: string | null; + options: string | null; +}; +type V2InternalLinkFieldTableInfo = { + fieldId: string; + dbFieldName: string; + foreignTableId: string; + lookupFieldId: string; + relationship: string; + fkHostTableName: string; + selfKeyName: string; + foreignKeyName: string; + isOneWay: boolean; + isMultipleCellValue: boolean; + orderColumnName?: string; +}; +type V2LinkCellItem = { id: string; title?: string }; type IDataPrismaExecutor = { $executeRawUnsafe(query: string, ...values: unknown[]): Promise; @@ -269,9 +290,9 @@ export class BaseDuplicateService { // allowCrossBase=true keeps same-space cross-base links intact. Mirrors // the v1 branch in duplicateBase above. const crossBaseLinkFieldTableMap: ILinkFieldTableMap = allowCrossBase - ? await this.getCrossBaseLinkFieldTableMap(sourceTableIdMap, spaceId) - : await this.getCrossBaseLinkFieldTableMap(sourceTableIdMap); - const disconnectedLinkFieldTableMap = await this.getDisconnectedLinkFieldTableMap( + ? await this.getV2CrossBaseLinkFieldTableMap(sourceTableIdMap, spaceId) + : await this.getV2CrossBaseLinkFieldTableMap(sourceTableIdMap); + const disconnectedLinkFieldTableMap = await this.getV2DisconnectedLinkFieldTableMap( sourceTableIdMap, fromBaseId, nodes, @@ -281,12 +302,8 @@ export class BaseDuplicateService { crossBaseLinkFieldTableMap, disconnectedLinkFieldTableMap ); - const disconnectedLinkFieldIds = await this.getDisconnectedLinkFieldIds( - sourceTableIdMap, - fromBaseId, - nodes, - skipParentNodes - ); + const internalLinkRelationTableMap = + await this.getV2InternalLinkRelationTableMap(sourceTableIdMap); const container = await this.v2ContainerService.getContainerForSpace(spaceId); const commandBus = container.resolve(v2CoreTokens.commandBus); const db = container.resolve>(v2PostgresDbTokens.db); @@ -299,8 +316,11 @@ export class BaseDuplicateService { baseId, duplicateMode !== BaseDuplicateMode.CopyShareBase ); + const useBulkRecordCopy = + Boolean(withRecords) && + !onProgress && + (await this.isSameDataDatabaseForRecordCopy(fromBaseId, base.id)); if (withRecords) { - await this.assertSameDataDatabaseForRecordCopy(fromBaseId, base.id); await prisma.base.update({ where: { id: base.id }, data: { @@ -315,12 +335,14 @@ export class BaseDuplicateService { fromBaseId, normalizedStructure, mergedLinkFieldTableMap, - sourceDbTableNameByTableId + sourceDbTableNameByTableId, + internalLinkRelationTableMap ); const commandResult = DuplicateBaseCommand.createFromSource({ baseId: base.id, source, - withRecords: false, + withRecords: Boolean(withRecords) && !useBulkRecordCopy, + batchSize: v2DuplicateCopyBatchSize, }); if (commandResult.isErr()) { throw new Error(commandResult.error.message); @@ -360,37 +382,38 @@ export class BaseDuplicateService { structure, { tableIdMap, fieldIdMap, viewIdMap }, duplicateMode, - onProgress, - { restoreEeResources: true } + onProgress ); if (withRecords) { - recordsLength = await this.duplicateTableData( - base.id, - tableIdMap, - fieldIdMap, - viewIdMap, - mergedLinkFieldTableMap, - onProgress - ); + if (useBulkRecordCopy) { + recordsLength = await this.duplicateTableData( + base.id, + tableIdMap, + fieldIdMap, + viewIdMap, + mergedLinkFieldTableMap + ); + const disconnectedLinkFieldIds = await this.getDisconnectedLinkFieldIds( + tableIdMap, + fromBaseId, + nodes, + skipParentNodes + ); + await this.duplicateLinkJunction( + base.id, + tableIdMap, + fieldIdMap, + allowCrossBase, + disconnectedLinkFieldIds + ); + await this.persistedComputedBackfillService.recomputeForTables(Object.values(tableIdMap)); + } onProgress?.({ phase: 'attachments_copying', processedRows: recordsLength, totalRows: recordsLength, }); await this.duplicateAttachments(base.id, tableIdMap, fieldIdMap); - await this.duplicateLinkJunction( - base.id, - tableIdMap, - fieldIdMap, - allowCrossBase, - disconnectedLinkFieldIds - ); - await this.persistedComputedBackfillService.recomputeForTables(Object.values(tableIdMap)); - await this.backfillDuplicatedBaseComputedFields( - container, - context, - Object.values(tableIdMap) - ); await prisma.base.update({ where: { id: base.id }, data: { @@ -454,12 +477,7 @@ export class BaseDuplicateService { } private async assertSameDataDatabaseForRecordCopy(sourceBaseId: string, targetBaseId: string) { - const [source, target] = await Promise.all([ - this.dataDbClientManager.getDataDatabaseForBase(sourceBaseId, { useTransaction: true }), - this.dataDbClientManager.getDataDatabaseForBase(targetBaseId, { useTransaction: true }), - ]); - - if (source.cacheKey === target.cacheKey) { + if (await this.isSameDataDatabaseForRecordCopy(sourceBaseId, targetBaseId)) { return; } @@ -469,6 +487,15 @@ export class BaseDuplicateService { ); } + private async isSameDataDatabaseForRecordCopy(sourceBaseId: string, targetBaseId: string) { + const [source, target] = await Promise.all([ + this.dataDbClientManager.getDataDatabaseForBase(sourceBaseId, { useTransaction: true }), + this.dataDbClientManager.getDataDatabaseForBase(targetBaseId, { useTransaction: true }), + ]); + + return source.cacheKey === target.cacheKey; + } + private async buildDuplicateStructureConfig( fromBaseId: string, baseName?: string, @@ -509,23 +536,10 @@ export class BaseDuplicateService { }, }); const tableIds = tableRaws.map(({ id }) => id); - const fieldRaws = await prisma.field.findMany({ - where: { - tableId: { in: tableIds }, - deletedTime: null, - }, - }); - const viewRaws = await prisma.view.findMany({ - where: { - tableId: { in: tableIds }, - deletedTime: null, - }, - orderBy: { - order: 'asc', - }, - }); + const fieldRaws = await this.baseExportService.findFieldsByTableIds(tableIds); + const viewRaws = await this.baseExportService.findViewsByTableIds(tableIds); - const structure = await this.baseExportService.generateBaseStructConfig({ + const structureInput: DuplicateBaseStructConfigInput = { baseRaw, tableRaws, fieldRaws, @@ -539,7 +553,9 @@ export class BaseDuplicateService { excludedTableIds, rootNodeIds, destSpaceId, - }); + includeWorkflowRuntimeState: false, + }; + const structure = await this.baseExportService.generateBaseStructConfig(structureInput); return { structure, @@ -555,26 +571,38 @@ export class BaseDuplicateService { sourceBaseId: string, structure: DuplicateV2StructureConfig, disconnectedLinkFieldTableMap: ILinkFieldTableMap, - sourceDbTableNameByTableId: Record = {} + sourceDbTableNameByTableId: Record = {}, + internalLinkRelationTableMap: Record = {} ): DuplicateBaseSource { const tableById = new Map(structure.tables.map((table) => [table.id, table])); - const readRows = (sourceDbTableName: string, crossBaseLinkInfo: ILinkFieldTableInfo[]) => - this.createSourceTableRecordRows(sourceBaseId, sourceDbTableName, crossBaseLinkInfo); + const readRows = ( + sourceDbTableName: string, + crossBaseLinkInfo: ILinkFieldTableInfo[], + internalLinkInfo: V2InternalLinkFieldTableInfo[] + ) => + this.createSourceTableRecordRows( + sourceBaseId, + sourceDbTableName, + crossBaseLinkInfo, + internalLinkInfo + ); const toRecordInput = (table: DuplicateV2TableConfig, row: Record) => this.toDuplicateBaseRecordInput(table, row); return { structure, - async *records(tableId: string) { + async *records(tableId: string, options?: DuplicateBaseRecordReadOptions) { const table = tableById.get(tableId); const sourceDbTableName = sourceDbTableNameByTableId[tableId] ?? table?.dbTableName; if (!table || !sourceDbTableName) { return; } + const shouldReadInternalLinks = options?.phase !== 'insert'; for await (const row of readRows( sourceDbTableName, - disconnectedLinkFieldTableMap[tableId] || [] + disconnectedLinkFieldTableMap[tableId] || [], + shouldReadInternalLinks ? internalLinkRelationTableMap[tableId] || [] : [] )) { yield toRecordInput(table, row); } @@ -616,29 +644,13 @@ export class BaseDuplicateService { return { ...structure, tables: structure.tables.map((table) => { - const tableFieldTypesById = new Map( - table.fields - .filter((field) => field.id) - .map( - (field) => - [ - field.id!, - field.isConditionalLookup - ? 'conditionalLookup' - : field.isLookup - ? 'lookup' - : field.type, - ] as const - ) - ); - return { ...table, - fields: table.fields.map((field) => { - const normalized = normalizeField(field as DotTeaFieldInput, tableFieldTypesById, { - availableTableIds, - fieldIdsByTableId, - }); + fields: normalizeFields(table.fields as ReadonlyArray, { + availableTableIds, + fieldIdsByTableId, + }).map((normalized, index) => { + const field = table.fields[index]!; const normalizedField = { ...field, ...normalized }; if ( @@ -723,12 +735,8 @@ export class BaseDuplicateService { ...(row.__auto_number ? { autoNumber: Number(row.__auto_number) } : {}), ...(row.__created_time ? { createdTime: this.toRestoreString(row.__created_time) } : {}), ...(row.__created_by ? { createdBy: this.toRestoreString(row.__created_by) } : {}), - ...(row.__last_modified_time - ? { lastModifiedTime: this.toRestoreString(row.__last_modified_time) } - : {}), - ...(row.__last_modified_by - ? { lastModifiedBy: this.toRestoreString(row.__last_modified_by) } - : {}), + lastModifiedTime: null, + lastModifiedBy: null, }; } @@ -1013,6 +1021,211 @@ export class BaseDuplicateService { return tableId2DbFieldNameMap; } + private async findV2DuplicateLinkFields(tableIds: string[]) { + const prisma = this.prismaService.txClient(); + const fields: DuplicateLinkFieldRaw[] = []; + + for (let index = 0; index < tableIds.length; index += v2DuplicateTableIdQueryChunkSize) { + const tableIdChunk = tableIds.slice(index, index + v2DuplicateTableIdQueryChunkSize); + let cursor: string | undefined; + let hasMore = true; + + while (hasMore) { + const page = await prisma.field.findMany({ + where: { + tableId: { in: tableIdChunk }, + type: FieldType.Link, + OR: [{ isLookup: false }, { isLookup: null }], + deletedTime: null, + }, + ...(cursor + ? { + cursor: { + id: cursor, + }, + skip: 1, + } + : {}), + select: { + id: true, + tableId: true, + dbFieldName: true, + isMultipleCellValue: true, + meta: true, + options: true, + }, + orderBy: [{ tableId: 'asc' }, { id: 'asc' }], + take: v2DuplicateLinkFieldBatchSize, + }); + + fields.push(...page); + + hasMore = page.length === v2DuplicateLinkFieldBatchSize; + if (hasMore) { + cursor = page[page.length - 1].id; + } + } + } + + return fields; + } + + private parseLinkFieldOptions(options: string | null): ILinkFieldOptions | undefined { + if (!options) { + return undefined; + } + + try { + return JSON.parse(options) as ILinkFieldOptions; + } catch { + return undefined; + } + } + + private resolveV2LinkOrderColumnName(options: ILinkFieldOptions): string | undefined { + switch (options.relationship) { + case 'manyMany': + return '__order'; + case 'oneMany': + return options.isOneWay ? '__order' : `${options.selfKeyName}_order`; + case 'manyOne': + case 'oneOne': + return `${options.foreignKeyName}_order`; + default: + return undefined; + } + } + + private hasV2LinkOrderColumn(meta: string | null): boolean { + if (!meta) { + return false; + } + + try { + return Boolean((JSON.parse(meta) as { hasOrderColumn?: unknown }).hasOrderColumn); + } catch { + return false; + } + } + + private buildLinkFieldTableMap( + tableIdMap: Record, + fields: DuplicateLinkFieldRaw[] + ): ILinkFieldTableMap { + const tableId2DbFieldNameMap: ILinkFieldTableMap = {}; + + Object.entries(groupBy(fields, 'tableId')).forEach(([tableId, fields]) => { + const info = fields.flatMap(({ dbFieldName, isMultipleCellValue, options }) => { + const parsedOptions = this.parseLinkFieldOptions(options); + if (!parsedOptions?.selfKeyName) { + return []; + } + return [ + { + dbFieldName, + selfKeyName: parsedOptions.selfKeyName, + isMultipleCellValue: !!isMultipleCellValue, + }, + ]; + }); + + if (info.length) { + tableId2DbFieldNameMap[tableId] = info; + tableId2DbFieldNameMap[tableIdMap[tableId]] = info; + } + }); + + return tableId2DbFieldNameMap; + } + + private async getV2InternalLinkRelationTableMap( + tableIdMap: Record + ): Promise> { + const tableIds = Object.keys(tableIdMap); + const fields = (await this.findV2DuplicateLinkFields(tableIds)).filter((field) => { + const options = this.parseLinkFieldOptions(field.options); + return ( + options?.foreignTableId && + !options.baseId && + tableIdMap[options.foreignTableId] && + options.fkHostTableName && + options.selfKeyName && + options.foreignKeyName + ); + }); + + if (!fields.length) { + return {}; + } + + const tableId2Info: Record = {}; + Object.entries(groupBy(fields, 'tableId')).forEach(([tableId, tableFields]) => { + const info = tableFields.flatMap((field) => { + const options = this.parseLinkFieldOptions(field.options); + const foreignTableId = options?.foreignTableId; + const lookupFieldId = options?.lookupFieldId; + if ( + !foreignTableId || + !lookupFieldId || + !options?.relationship || + !options.fkHostTableName || + !options.selfKeyName || + !options.foreignKeyName + ) { + return []; + } + + return [ + { + fieldId: field.id, + dbFieldName: field.dbFieldName, + foreignTableId, + lookupFieldId, + relationship: options.relationship, + fkHostTableName: options.fkHostTableName, + selfKeyName: options.selfKeyName, + foreignKeyName: options.foreignKeyName, + isOneWay: Boolean(options.isOneWay), + isMultipleCellValue: !!field.isMultipleCellValue, + orderColumnName: this.hasV2LinkOrderColumn(field.meta) + ? this.resolveV2LinkOrderColumnName(options) + : undefined, + }, + ]; + }); + + if (info.length) { + tableId2Info[tableId] = info; + tableId2Info[tableIdMap[tableId]] = info; + } + }); + + return tableId2Info; + } + + private async getV2DisconnectedLinkFieldTableMap( + tableIdMap: Record, + fromBaseId: string, + nodes?: string[], + skipParentNodes: boolean = false + ): Promise { + const { excludedTableIds } = await this.collectNodesAndResourceIds( + fromBaseId, + nodes, + skipParentNodes + ); + + if (!nodes?.length || !excludedTableIds?.length) { + return {}; + } + + const fields = (await this.findV2DuplicateLinkFields(Object.keys(tableIdMap))).filter((field) => + excludedTableIds.includes(this.parseLinkFieldOptions(field.options)?.foreignTableId ?? '') + ); + + return this.buildLinkFieldTableMap(tableIdMap, fields); + } + async previewCrossSpaceAffectedFields( fromBaseId: string, destSpaceId: string @@ -1127,6 +1340,42 @@ export class BaseDuplicateService { return tableId2DbFieldNameMap; } + private async getV2CrossBaseLinkFieldTableMap( + tableIdMap: Record, + destSpaceId?: string + ): Promise { + const linkFields = (await this.findV2DuplicateLinkFields(Object.keys(tableIdMap))).filter( + (field) => this.parseLinkFieldOptions(field.options)?.baseId + ); + + let crossBaseLinkFields = linkFields; + if (destSpaceId) { + const prisma = this.prismaService.txClient(); + const foreignBaseIds = Array.from( + new Set( + linkFields + .map((field) => this.parseLinkFieldOptions(field.options)?.baseId) + .filter((baseId): baseId is string => Boolean(baseId)) + ) + ); + const bases = foreignBaseIds.length + ? await prisma.base.findMany({ + where: { id: { in: foreignBaseIds }, deletedTime: null }, + select: { id: true, spaceId: true }, + }) + : []; + const crossSpaceBaseIds = new Set( + bases.filter((base) => base.spaceId !== destSpaceId).map((base) => base.id) + ); + crossBaseLinkFields = linkFields.filter((field) => { + const baseId = this.parseLinkFieldOptions(field.options)?.baseId; + return baseId && crossSpaceBaseIds.has(baseId); + }); + } + + return this.buildLinkFieldTableMap(tableIdMap, crossBaseLinkFields); + } + private async duplicateTableData( targetBaseId: string, tableIdMap: Record, @@ -1396,10 +1645,64 @@ export class BaseDuplicateService { return String(value); } + private parsePostgresTextArrayLiteral(value: string): string[] | undefined { + if (!value.startsWith('{') || !value.endsWith('}')) { + return undefined; + } + + const inner = value.slice(1, -1); + if (!inner) { + return []; + } + + const items: string[] = []; + let item = ''; + let inQuotes = false; + + for (let index = 0; index < inner.length; index += 1) { + const char = inner[index]; + + if (inQuotes) { + if (char === '\\') { + index += 1; + item += inner[index] ?? ''; + continue; + } + if (char === '"') { + inQuotes = false; + continue; + } + item += char; + continue; + } + + if (char === '"') { + inQuotes = true; + continue; + } + + if (char === ',') { + items.push(item); + item = ''; + continue; + } + + item += char; + } + + if (inQuotes) { + return undefined; + } + + items.push(item); + return items; + } + private async *createSourceTableRecordRows( sourceBaseId: string, sourceDbTableName: string, - crossBaseLinkInfo: ILinkFieldTableInfo[] + crossBaseLinkInfo: ILinkFieldTableInfo[], + internalLinkInfo: V2InternalLinkFieldTableInfo[] = [] ): AsyncGenerator> { const dataKnex = await this.dataDbClientManager.dataKnexForBase(sourceBaseId, { useTransaction: true, @@ -1416,8 +1719,18 @@ export class BaseDuplicateService { return; } - for (const row of rows) { - yield this.normalizeCrossBaseLinkColumns(row, crossBaseLinkInfo); + const sourceRecordIds = rows.flatMap((row) => + typeof row.__id === 'string' ? [row.__id] : [] + ); + const normalizedRows = await this.normalizeInternalLinkColumns( + dataKnex, + rows.map((row) => this.normalizeCrossBaseLinkColumns(row, crossBaseLinkInfo)), + sourceRecordIds, + internalLinkInfo + ); + + for (const row of normalizedRows) { + yield row; } lastAutoNumber = Number(rows[rows.length - 1]?.__auto_number ?? lastAutoNumber); @@ -1444,6 +1757,138 @@ export class BaseDuplicateService { return nextRow; } + private async normalizeInternalLinkColumns( + dataKnex: Knex, + rows: Record[], + sourceRecordIds: string[], + internalLinkInfo: V2InternalLinkFieldTableInfo[] + ) { + if (!rows.length || !internalLinkInfo.length) { + return rows; + } + + const rowsById = new Map( + rows.flatMap((row) => (typeof row.__id === 'string' ? ([[row.__id, row]] as const) : [])) + ); + for (const row of rows) { + for (const { dbFieldName } of internalLinkInfo) { + delete row[dbFieldName]; + } + } + + if (!sourceRecordIds.length) { + return rows; + } + + for (const info of internalLinkInfo) { + const relations = await this.findV2InternalLinkRelations(dataKnex, sourceRecordIds, info); + for (const [sourceRecordId, linkItems] of relations) { + const row = rowsById.get(sourceRecordId); + if (!row) continue; + if (info.isMultipleCellValue) { + row[info.dbFieldName] = linkItems; + } else if (linkItems[0]) { + row[info.dbFieldName] = linkItems[0]; + } + } + } + + return rows; + } + + private async findV2InternalLinkRelations( + dataKnex: Knex, + sourceRecordIds: string[], + info: V2InternalLinkFieldTableInfo + ): Promise> { + if (info.relationship === 'manyMany' || (info.relationship === 'oneMany' && info.isOneWay)) { + return this.findV2JunctionLinkRelations(dataKnex, sourceRecordIds, info); + } + + if ( + (info.relationship === 'manyOne' || info.relationship === 'oneOne') && + info.foreignKeyName !== '__id' + ) { + return this.findV2CurrentTableFkLinkRelations(dataKnex, sourceRecordIds, info); + } + + if ( + info.relationship === 'oneMany' || + ((info.relationship === 'manyOne' || info.relationship === 'oneOne') && + info.foreignKeyName === '__id') + ) { + return this.findV2ForeignTableFkLinkRelations(dataKnex, sourceRecordIds, info); + } + + return new Map(); + } + + private async findV2JunctionLinkRelations( + dataKnex: Knex, + sourceRecordIds: string[], + info: V2InternalLinkFieldTableInfo + ): Promise> { + const relationRows = await dataKnex(info.fkHostTableName) + .select({ + sourceRecordId: info.selfKeyName, + foreignRecordId: info.foreignKeyName, + }) + .whereIn(info.selfKeyName, sourceRecordIds) + .orderBy(info.orderColumnName || info.foreignKeyName, 'asc'); + + return this.groupLinkRelationRows(relationRows); + } + + private async findV2CurrentTableFkLinkRelations( + dataKnex: Knex, + sourceRecordIds: string[], + info: V2InternalLinkFieldTableInfo + ): Promise> { + const relationRows = await dataKnex(info.fkHostTableName) + .select({ + sourceRecordId: info.selfKeyName, + foreignRecordId: info.foreignKeyName, + }) + .whereIn(info.selfKeyName, sourceRecordIds) + .whereNotNull(info.foreignKeyName); + + return this.groupLinkRelationRows(relationRows); + } + + private async findV2ForeignTableFkLinkRelations( + dataKnex: Knex, + sourceRecordIds: string[], + info: V2InternalLinkFieldTableInfo + ): Promise> { + const query = dataKnex(info.fkHostTableName) + .select({ + sourceRecordId: info.selfKeyName, + foreignRecordId: info.foreignKeyName, + }) + .whereIn(info.selfKeyName, sourceRecordIds); + const relationRows = info.orderColumnName + ? await query.orderBy(info.orderColumnName, 'asc') + : await query; + + return this.groupLinkRelationRows(relationRows); + } + + private groupLinkRelationRows( + relationRows: Array<{ sourceRecordId?: unknown; foreignRecordId?: unknown }> + ): Map { + const relations = new Map(); + for (const { sourceRecordId, foreignRecordId } of relationRows) { + if (typeof sourceRecordId !== 'string' || typeof foreignRecordId !== 'string') { + continue; + } + const items = relations.get(sourceRecordId) ?? []; + items.push({ id: foreignRecordId }); + relations.set(sourceRecordId, items); + } + + return relations; + } + private toCrossBaseLinkTitle(value: unknown, isMultipleCellValue: boolean): unknown { if (value == null) { return value; @@ -1453,21 +1898,19 @@ export class BaseDuplicateService { try { return this.toCrossBaseLinkTitle(JSON.parse(value), isMultipleCellValue); } catch { + const arrayValue = this.parsePostgresTextArrayLiteral(value); + if (arrayValue) { + return this.toCrossBaseLinkTitle(arrayValue, isMultipleCellValue); + } return value; } } - if (isMultipleCellValue) { - return Array.isArray(value) - ? value - .map((item) => - item && typeof item === 'object' && 'title' in item - ? String((item as { title?: unknown }).title ?? '') - : '' - ) - .filter(Boolean) - .join(', ') - : value; + if (Array.isArray(value)) { + const titles = value + .map((item) => this.toCrossBaseLinkTitle(item, false)) + .filter((item): item is string => typeof item === 'string' && Boolean(item)); + return isMultipleCellValue ? titles.join(', ') : titles[0]; } return value && typeof value === 'object' && 'title' in value @@ -1515,44 +1958,4 @@ export class BaseDuplicateService { disconnectedLinkFieldIds ); } - - private async backfillDuplicatedBaseComputedFields( - container: DependencyContainer, - context: IExecutionContext, - targetTableIds: string[] - ) { - if (!targetTableIds.length) { - return; - } - - const tableRepository = container.resolve(v2CoreTokens.tableRepository); - const backfillService = container.resolve( - v2RecordRepositoryPostgresTokens.computedFieldBackfillService - ); - - for (const rawTableId of targetTableIds) { - const tableIdResult = TableId.create(rawTableId); - if (tableIdResult.isErr()) { - throw new Error(tableIdResult.error.message); - } - - const tableResult = await tableRepository.findOne( - context, - TableByIdSpec.create(tableIdResult.value) - ); - if (tableResult.isErr()) { - throw new Error(tableResult.error.message); - } - - const backfillResult = await backfillService.executeSyncMany(context, { - table: tableResult.value, - fields: tableResult.value.getFields(), - skipDistinctFilter: true, - includeOneManyTwoWay: true, - }); - if (backfillResult.isErr()) { - throw new Error(backfillResult.error.message); - } - } - } } diff --git a/apps/nestjs-backend/src/features/base/base-export.service.ts b/apps/nestjs-backend/src/features/base/base-export.service.ts index 850368fdfc..e61dc63d0e 100644 --- a/apps/nestjs-backend/src/features/base/base-export.service.ts +++ b/apps/nestjs-backend/src/features/base/base-export.service.ts @@ -23,7 +23,7 @@ import type { import archiver from 'archiver'; import { stringify } from 'csv-stringify/sync'; import { Knex } from 'knex'; -import { omit, pick } from 'lodash'; +import { groupBy, omit, pick } from 'lodash'; import { InjectModel } from 'nest-knexjs'; import { ClsService } from 'nestjs-cls'; import { IStorageConfig, StorageConfig } from '../../configs/storage'; @@ -47,6 +47,9 @@ import { EXCLUDE_SYSTEM_FIELDS } from './constant'; @Injectable() export class BaseExportService { public static CSV_CHUNK = 500; + public static FIELD_EXPORT_BATCH_SIZE = 500; + public static VIEW_EXPORT_BATCH_SIZE = 500; + private static readonly TABLE_ID_QUERY_CHUNK_SIZE = 100; public static FILE_SUFFIX = 'tea'; public static EXPORT_FIELD_COLUMNS = [ 'id', @@ -336,25 +339,8 @@ export class BaseExportService { }, }); const tableIds = tableRaws.map(({ id }) => id); - const fieldRaws = await prisma.field.findMany({ - where: { - tableId: { - in: tableIds, - }, - deletedTime: null, - }, - }); - const viewRaws = await prisma.view.findMany({ - where: { - tableId: { - in: tableIds, - }, - deletedTime: null, - }, - orderBy: { - order: 'asc', - }, - }); + const fieldRaws = await this.findFieldsByTableIds(tableIds); + const viewRaws = await this.findViewsByTableIds(tableIds); // 2. generate base structure json onProgress?.('exporting_structure'); @@ -504,6 +490,8 @@ export class BaseExportService { fieldRaws, destSpaceId ); + const fieldsByTableId = groupBy(fieldRaws, 'tableId'); + const viewsByTableId = groupBy(viewRaws, 'tableId'); const tables = [] as IBaseJson['tables']; for (const table of tableRaws) { const { name, description, order, id, icon, dbTableName } = table; @@ -516,18 +504,18 @@ export class BaseExportService { icon, dbTableName: realDbTableName, } as IBaseJson['tables'][number]; - const currentTableFields = fieldRaws.filter(({ tableId }) => tableId === id); + const currentTableFields = fieldsByTableId[id] ?? []; tableObject.fields = this.generateFieldConfig( currentTableFields, allowCrossBase, excludedTableIds, crossSpaceForeignBaseIds ); - tableObject.views = this.generateViewConfig(viewRaws.filter(({ tableId }) => tableId === id)); + tableObject.views = this.generateViewConfig(viewsByTableId[id] ?? []); tables.push(tableObject); } - const plugins = await this.generatePluginConfig(baseId, includedDashboardIds); + const plugins = await this.generatePluginConfig(baseId, includedDashboardIds, viewRaws); const folders = await this.generateFolderConfig(baseId, includedFolderIds); const nodes = await this.generateNodeConfig(baseId, includeNodes, rootNodeIds); @@ -919,6 +907,98 @@ export class BaseExportService { return await prisma.$queryRawUnsafe[]>(recordsQuery); } + async findFieldsByTableIds(tableIds: string[]) { + const prisma = this.prismaService.txClient(); + const fieldRaws: Field[] = []; + + for ( + let index = 0; + index < tableIds.length; + index += BaseExportService.TABLE_ID_QUERY_CHUNK_SIZE + ) { + const tableIdChunk = tableIds.slice( + index, + index + BaseExportService.TABLE_ID_QUERY_CHUNK_SIZE + ); + let cursor: string | undefined; + let hasMore = true; + + while (hasMore) { + const page = await prisma.field.findMany({ + where: { + tableId: { in: tableIdChunk }, + deletedTime: null, + }, + ...(cursor + ? { + cursor: { + id: cursor, + }, + skip: 1, + } + : {}), + orderBy: [{ tableId: 'asc' }, { id: 'asc' }], + take: BaseExportService.FIELD_EXPORT_BATCH_SIZE, + }); + + fieldRaws.push(...page); + + hasMore = page.length === BaseExportService.FIELD_EXPORT_BATCH_SIZE; + if (hasMore) { + cursor = page[page.length - 1].id; + } + } + } + + return fieldRaws; + } + + async findViewsByTableIds(tableIds: string[]) { + const prisma = this.prismaService.txClient(); + const viewRaws: View[] = []; + + for ( + let index = 0; + index < tableIds.length; + index += BaseExportService.TABLE_ID_QUERY_CHUNK_SIZE + ) { + const tableIdChunk = tableIds.slice( + index, + index + BaseExportService.TABLE_ID_QUERY_CHUNK_SIZE + ); + let cursor: string | undefined; + let hasMore = true; + + while (hasMore) { + const page = await prisma.view.findMany({ + where: { + tableId: { in: tableIdChunk }, + deletedTime: null, + }, + ...(cursor + ? { + cursor: { + id: cursor, + }, + skip: 1, + } + : {}), + orderBy: [{ tableId: 'asc' }, { order: 'asc' }, { id: 'asc' }], + take: BaseExportService.VIEW_EXPORT_BATCH_SIZE, + }); + + viewRaws.push(...page); + + hasMore = page.length === BaseExportService.VIEW_EXPORT_BATCH_SIZE; + if (hasMore) { + cursor = page[page.length - 1].id; + } + } + } + + return viewRaws; + } + /** * @description convert the cell value to the csv value * @param value - the cell value @@ -1437,7 +1517,7 @@ export class BaseExportService { }); } - async generatePluginConfig(baseId: string, includedDashboardIds?: string[]) { + async generatePluginConfig(baseId: string, includedDashboardIds?: string[], viewRaws?: View[]) { const pluginJson = {} as IBaseJson['plugins']; pluginJson[PluginPosition.Dashboard] = await this.generateDashboard( @@ -1447,35 +1527,17 @@ export class BaseExportService { pluginJson[PluginPosition.Panel] = await this.generatePluginPanel(baseId); - pluginJson[PluginPosition.View] = await this.generatePluginView(baseId); + pluginJson[PluginPosition.View] = await this.generatePluginView(baseId, viewRaws); return pluginJson; } - private async generatePluginView(baseId: string) { - const tableIds = await this.prismaService.txClient().tableMeta.findMany({ - where: { - baseId, - deletedTime: null, - }, - }); - - const prisma = this.prismaService.txClient(); - - const viewPluginRaws = await prisma.view.findMany({ - where: { - tableId: { - in: tableIds.map(({ id }) => id), - }, - type: ViewType.Plugin, - deletedTime: null, - }, - orderBy: { - createdTime: 'asc', - }, - }); + private async generatePluginView(baseId: string, viewRaws?: View[]) { + const viewPluginRaws = viewRaws + ? viewRaws.filter(({ type }) => type === ViewType.Plugin) + : await this.findPluginViewsByBaseId(baseId); - const viewPluginInstallRaws = await prisma.pluginInstall.findMany({ + const viewPluginInstallRaws = await this.prismaService.txClient().pluginInstall.findMany({ where: { positionId: { in: viewPluginRaws.map(({ id }) => id), @@ -1503,6 +1565,22 @@ export class BaseExportService { }) as unknown as IBaseJson['plugins'][PluginPosition.View]; } + private async findPluginViewsByBaseId(baseId: string) { + const tableIds = await this.prismaService.txClient().tableMeta.findMany({ + where: { + baseId, + deletedTime: null, + }, + select: { + id: true, + }, + }); + + return (await this.findViewsByTableIds(tableIds.map(({ id }) => id))).filter( + ({ type }) => type === ViewType.Plugin + ); + } + private async generatePluginPanel(baseId: string) { const prisma = this.prismaService.txClient(); const tableIds = await prisma.tableMeta.findMany({ diff --git a/apps/nestjs-backend/src/features/base/base-freeze.service.spec.ts b/apps/nestjs-backend/src/features/base/base-freeze.service.spec.ts new file mode 100644 index 0000000000..fb737fbf58 --- /dev/null +++ b/apps/nestjs-backend/src/features/base/base-freeze.service.spec.ts @@ -0,0 +1,67 @@ +import { HttpErrorCode } from '@teable/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CustomHttpException } from '../../custom.exception'; +import { BaseService } from './base.service'; + +describe('BaseService write freeze', () => { + const freezeError = new CustomHttpException( + 'Space data database migration is in progress', + HttpErrorCode.CONFLICT, + { + errorCode: 'SPACE_DATA_DB_MIGRATING', + migrationJobId: 'sdmjxxx', + } + ); + const prismaService = { + base: { + create: vi.fn(), + update: vi.fn(), + }, + }; + const migrationGuard = { + assertSpaceWritable: vi.fn(), + assertBaseWritable: vi.fn(), + }; + + const service = () => + new BaseService( + prismaService as never, + {} as never, + { get: vi.fn().mockReturnValue('usrxxx') } as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + migrationGuard as never + ); + + beforeEach(() => { + vi.clearAllMocks(); + migrationGuard.assertSpaceWritable.mockRejectedValue(freezeError); + migrationGuard.assertBaseWritable.mockRejectedValue(freezeError); + }); + + it('rejects base creation before calculating order or creating metadata when the target space is migrating', async () => { + await expect( + service().createBase({ name: 'Blocked base', spaceId: 'spcxxx' } as never) + ).rejects.toBe(freezeError); + + expect(migrationGuard.assertSpaceWritable).toHaveBeenCalledWith('spcxxx'); + expect(prismaService.base.create).not.toHaveBeenCalled(); + }); + + it('rejects base update and delete before metadata writes when the base space is migrating', async () => { + await expect(service().updateBase('bsexxx', { name: 'Blocked' })).rejects.toBe(freezeError); + await expect(service().deleteBase('bsexxx')).rejects.toBe(freezeError); + + expect(migrationGuard.assertBaseWritable).toHaveBeenCalledTimes(2); + expect(prismaService.base.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/nestjs-backend/src/features/base/base-import-processor/base-import-csv.processor.spec.ts b/apps/nestjs-backend/src/features/base/base-import-processor/base-import-csv.processor.spec.ts index 8d41623670..a1b9ffa07c 100644 --- a/apps/nestjs-backend/src/features/base/base-import-processor/base-import-csv.processor.spec.ts +++ b/apps/nestjs-backend/src/features/base/base-import-processor/base-import-csv.processor.spec.ts @@ -4,7 +4,7 @@ import { vi } from 'vitest'; import { BaseImportCsvQueueProcessor } from './base-import-csv.processor'; describe('BaseImportCsvQueueProcessor', () => { - it('writes imported record history into the routed data DB internal schema', async () => { + it('does not write record history when importing base records', async () => { const executedSql: string[] = []; const dataKnex = Knex({ client: 'pg' }); const dataPrisma = { @@ -64,6 +64,10 @@ describe('BaseImportCsvQueueProcessor', () => { columnInfo: vi.fn().mockReturnValue('SELECT * FROM columns'), }; processor.dataDbClientManager = dataDbClientManager; + Object.assign(processor, { + audit: { emitAtomic: vi.fn().mockResolvedValue(undefined) }, + cls: { get: vi.fn() }, + }); await processor.handleChunk( [{ __id: 'recImported', fldText: 'Imported value' }], @@ -82,10 +86,89 @@ describe('BaseImportCsvQueueProcessor', () => { ); expect(executedSql.some((sql) => sql.includes('"bse_data"."tbl_imported"'))).toBe(true); - expect(executedSql.some((sql) => sql.includes('"teable_internal"."record_history"'))).toBe( - true + // Base import inserts records only — it must not generate per-cell record history + // (the v1 hot path that bloated each chunk transaction and timed out on large tables). + expect(executedSql.some((sql) => sql.toLowerCase().includes('record_history'))).toBe(false); + + await dataKnex.destroy(); + }); + + it('drops ghost columns missing from the target table instead of aborting the insert', async () => { + const executedSql: string[] = []; + const dataKnex = Knex({ client: 'pg' }); + const dataPrisma = { + $queryRawUnsafe: vi + .fn() + // foreign keys info + .mockResolvedValueOnce([]) + // columnInfo: the freshly-created table only has __id + fldText, NOT the ghost column + .mockResolvedValueOnce([{ name: '__id' }, { name: 'fldText' }]), + $executeRawUnsafe: vi.fn(async (sql: string) => { + executedSql.push(sql); + return 1; + }), + }; + const prismaService = { + tableMeta: { + findUniqueOrThrow: vi.fn().mockResolvedValue({ dbTableName: 'bse_data.tbl_imported' }), + }, + txClient: vi.fn().mockReturnValue({ + attachmentsTable: { createMany: vi.fn().mockResolvedValue(undefined) }, + }), + }; + const dataDbClientManager = { + dataPrismaForBase: vi.fn().mockResolvedValue(dataPrisma), + dataKnexForBase: vi.fn().mockResolvedValue(dataKnex), + getDataDatabaseForBase: vi.fn().mockResolvedValue({ + url: 'postgresql://user:pass@example.test:5432/data?schema=teable_internal', + }), + }; + const processor = Object.create(BaseImportCsvQueueProcessor.prototype) as { + handleChunk: ( + results: Record[], + config: Record, + excludeDbFieldNames: string[] + ) => Promise; + prismaService: typeof prismaService; + dbProvider: { + getForeignKeysInfo: ReturnType; + columnInfo: ReturnType; + }; + dataDbClientManager: typeof dataDbClientManager; + }; + + processor.prismaService = prismaService; + processor.dbProvider = { + getForeignKeysInfo: vi.fn().mockReturnValue('SELECT * FROM foreign_keys'), + columnInfo: vi.fn().mockReturnValue('SELECT * FROM columns'), + }; + processor.dataDbClientManager = dataDbClientManager; + Object.assign(processor, { + audit: { emitAtomic: vi.fn().mockResolvedValue(undefined) }, + cls: { get: vi.fn() }, + }); + + await processor.handleChunk( + // fldGhost is an orphan physical column dumped into the .tea CSV with no matching field + [{ __id: 'recImported', fldText: 'Imported value', fldGhost: 'orphan value' }], + { + baseId: 'bseImport', + tableId: 'tblImport', + userId: 'usrImport', + fieldIdMap: {}, + viewIdMap: {}, + fkMap: {}, + attachmentsFields: [], + notNullFieldMap: new Map(), + fieldDbNameMap: new Map([['fldText', 'fldMappedText']]), + }, + [] ); - expect(executedSql.some((sql) => sql.includes('insert into "record_history"'))).toBe(false); + + const dataInsert = executedSql.find((sql) => sql.includes('"bse_data"."tbl_imported"')); + expect(dataInsert).toBeDefined(); + expect(dataInsert).toContain('"fldText"'); + expect(dataInsert).not.toContain('fldGhost'); await dataKnex.destroy(); }); diff --git a/apps/nestjs-backend/src/features/base/base-import-processor/base-import-csv.processor.ts b/apps/nestjs-backend/src/features/base/base-import-processor/base-import-csv.processor.ts index 84d8487977..07693ddb69 100644 --- a/apps/nestjs-backend/src/features/base/base-import-processor/base-import-csv.processor.ts +++ b/apps/nestjs-backend/src/features/base/base-import-processor/base-import-csv.processor.ts @@ -2,12 +2,7 @@ import { InjectQueue, OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq'; import { Injectable, Logger } from '@nestjs/common'; import type { IAttachmentCellValue, ILinkFieldOptions } from '@teable/core'; -import { - DbFieldType, - FieldType, - generateAttachmentId, - generateRecordHistoryId, -} from '@teable/core'; +import { DbFieldType, FieldType, generateAttachmentId } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import type { IBaseJson, ImportBaseRo } from '@teable/openapi'; import { CreateRecordAction, UploadType } from '@teable/openapi'; @@ -200,9 +195,6 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { }); } }); - const fieldDbNameMap = new Map( - table?.fields?.map(({ dbFieldName, id }) => [dbFieldName, fieldIdMap[id] ?? id]) ?? [] - ); const batchProcessor = new BatchProcessor>(async (chunk) => { totalRecordsCount += chunk.length; @@ -219,7 +211,6 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { fkMap, attachmentsFields, notNullFieldMap, - fieldDbNameMap, }, excludeDbFieldNames ); @@ -305,10 +296,6 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { return await fn(dataPrisma); } - private getDataDbInternalSchema(dataDbUrl: string) { - return new URL(dataDbUrl).searchParams.get('schema') || 'public'; - } - // Raw SQL chunk insert (no v2 events). Active BaseImport operation is set by // `handleBaseImportCsv` above; the `@Audit` atomic emit mode writes one audit row per // chunk with atomic record-create action and rootAction=BaseImport. @@ -327,20 +314,11 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { fkMap: Record; attachmentsFields: { dbFieldName: string; id: string }[]; notNullFieldMap: Map; - fieldDbNameMap: Map; }, excludeDbFieldNames: string[] ) { - const { - baseId, - tableId, - userId, - fieldIdMap, - attachmentsFields, - fkMap, - notNullFieldMap, - fieldDbNameMap, - } = config; + const { baseId, tableId, userId, fieldIdMap, attachmentsFields, fkMap, notNullFieldMap } = + config; const { dbTableName } = await this.prismaService.tableMeta.findUniqueOrThrow({ where: { id: tableId }, select: { @@ -364,22 +342,11 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { recordId: string; fieldId: string; }[]; - const recordHistoryList: { - id: string; - table_id: string; - record_id: string; - field_id: string; - before: string; - after: string; - created_by: string; - }[] = []; const dataPrisma = (await this.dataDbClientManager.dataPrismaForBase( baseId )) as IDataPrismaScopedClient; const dataKnex = await this.dataDbClientManager.dataKnexForBase(baseId); - const dataDb = await this.dataDbClientManager.getDataDatabaseForBase(baseId); - const dataDbInternalSchema = this.getDataDbInternalSchema(dataDb.url); await this.dataTransaction(dataPrisma, async (prisma) => { // delete foreign keys if(exist) then duplicate table data @@ -432,9 +399,24 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { : fkMap[name] || name; }); + // Authoritative set of columns that actually exist on the freshly-created table. + // The .tea CSV is dumped from the source data table's physical columns, which can + // include "ghost" columns left behind by deleted/renamed fields. Those have no field + // in the exported structure, so the new table lacks them; inserting them raw aborts the + // whole table transaction (42703 -> 25P02) and drops every record. Mirror the v2 import, + // which only restores columns present in the field metadata. + const realColumns = new Set(columnInfo.map(({ name }) => name)); + const recordsToInsert = newResult.map((result) => { const res = { ...result }; Object.entries(res).forEach(([key, value]) => { + // drop ghost business columns absent from the target table (system / __fk_ / __row_ + // columns are left to the dedicated handling below and the lacking-column ALTER step) + if (!key.startsWith('__') && !realColumns.has(key)) { + delete res[key]; + return; + } + if (res[key] === '') { const notNullInfo = notNullFieldMap.get(key); if (notNullInfo) { @@ -468,24 +450,6 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { }); }); } - - if (key.startsWith('__') && !key.startsWith('__fk_')) { - return; - } - - const sourceFieldId = key.startsWith('__fk_') ? key.slice(5) : key; - const fieldId = fieldIdMap[sourceFieldId] ?? fieldDbNameMap.get(key) ?? sourceFieldId; - if (fieldId && value !== '' && value != null) { - recordHistoryList.push({ - id: generateRecordHistoryId(), - table_id: tableId, - record_id: res['__id'] as string, - field_id: fieldId, - before: JSON.stringify({ data: null }), - after: JSON.stringify({ data: value }), - created_by: userId, - }); - } }); // default value set @@ -513,15 +477,6 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { const sql = dataKnex.table(dbTableName).insert(recordsToInsert).toQuery(); await prisma.$executeRawUnsafe(sql); - - if (recordHistoryList.length) { - const historySql = dataKnex - .withSchema(dataDbInternalSchema) - .insert(recordHistoryList) - .into('record_history') - .toQuery(); - await prisma.$executeRawUnsafe(historySql); - } }); // restore foreign keys with NOT VALID diff --git a/apps/nestjs-backend/src/features/base/base-import.service.spec.ts b/apps/nestjs-backend/src/features/base/base-import.service.spec.ts index 45deeffa89..89e61a8691 100644 --- a/apps/nestjs-backend/src/features/base/base-import.service.spec.ts +++ b/apps/nestjs-backend/src/features/base/base-import.service.spec.ts @@ -1,9 +1,10 @@ import type { Readable } from 'stream'; -import { DbFieldType, FieldType } from '@teable/core'; -import type { IBaseJson, ImportBaseRo } from '@teable/openapi'; +import { DbFieldType, FieldType, HttpErrorCode } from '@teable/core'; +import { BaseDuplicateMode, type IBaseJson, type ImportBaseRo } from '@teable/openapi'; import type { RestoreRecordInput } from '@teable/v2-core'; import archiver from 'archiver'; import { vi } from 'vitest'; +import { CustomHttpException } from '../../custom.exception'; import { BaseImportService, formatBaseImportError } from './base-import.service'; @@ -39,10 +40,29 @@ interface IProcessStructureService { createBaseStructure: ReturnType; } +interface IImportBaseV2Service { + importBaseV2( + importBaseRo: Pick, + onProgress?: (...args: unknown[]) => void + ): Promise; + storageAdapter: unknown; + readDotTeaStructure: ReturnType; + v2ContainerService: unknown; + v2ContextFactory: unknown; + createBaseV2: ReturnType; + restoreBaseExtrasV2: ReturnType; + importAttachmentsV2: ReturnType; + importTableDataV2: ReturnType; + importTableLinkFieldsV2: ReturnType; + audit: unknown; + cls: unknown; +} + const dbTableName = 'bse_test.tbl_test'; const jsonColumnName = 'json_col'; const textColumnName = 'text_col'; const textCellValue = 'plain text'; +const importedBaseName = 'Imported base'; const createService = () => Object.create(BaseImportService.prototype) as IRestoreRecordInputBuilder; @@ -55,6 +75,15 @@ const createZipStream = (structure: IBaseJson) => { }; describe('BaseImportService', () => { + const freezeError = new CustomHttpException( + 'Space data database migration is in progress', + HttpErrorCode.CONFLICT, + { + errorCode: 'SPACE_DATA_DB_MIGRATING', + migrationJobId: 'sdmjxxx', + } + ); + describe('formatBaseImportError', () => { it('falls back when an Error has an empty message', () => { expect(formatBaseImportError(new Error(''), 'Unknown import error')).toBe( @@ -84,6 +113,43 @@ describe('BaseImportService', () => { }); describe('processStructure', () => { + it('rejects import before downloading files or opening a transaction when the space is migrating', async () => { + const service = Object.create(BaseImportService.prototype) as { + importBase: BaseImportService['importBase']; + importBaseV2: BaseImportService['importBaseV2']; + audit: { withOperation: ReturnType }; + cls: { get: ReturnType }; + spaceDataDbMigrationGuard: { assertSpaceWritable: ReturnType }; + storageAdapter: { downloadFile: ReturnType }; + prismaService: { $tx: ReturnType }; + }; + service.audit = { + withOperation: vi.fn((_, fn: () => Promise) => fn()), + }; + service.cls = { get: vi.fn() }; + service.spaceDataDbMigrationGuard = { + assertSpaceWritable: vi.fn().mockRejectedValue(freezeError), + }; + service.storageAdapter = { downloadFile: vi.fn() }; + service.prismaService = { $tx: vi.fn() }; + const importRo = { + spaceId: 'spcImport', + notify: { path: 'imports/base.tea' }, + } as ImportBaseRo; + const onProgress = vi.fn(); + + await expect(service.importBase(importRo, onProgress)).rejects.toBe(freezeError); + await expect(service.importBaseV2(importRo, onProgress)).rejects.toBe(freezeError); + + expect(service.spaceDataDbMigrationGuard.assertSpaceWritable).toHaveBeenCalledTimes(2); + expect(service.spaceDataDbMigrationGuard.assertSpaceWritable).toHaveBeenCalledWith( + 'spcImport' + ); + expect(onProgress).not.toHaveBeenCalled(); + expect(service.storageAdapter.downloadFile).not.toHaveBeenCalled(); + expect(service.prismaService.$tx).not.toHaveBeenCalled(); + }); + it('passes transaction-aware data DB routing into structure creation', async () => { const service = Object.create(BaseImportService.prototype) as IProcessStructureService; const structure = { @@ -123,14 +189,14 @@ describe('BaseImportService', () => { it('creates imported base schemas through the space routed data client', async () => { const createdBase = { id: 'bseImported', - name: 'Imported base', + name: importedBaseName, spaceId: 'spcImport', order: 1, }; const baseCreate = vi.fn().mockResolvedValue(createdBase); const baseUpdate = vi.fn().mockResolvedValue({ ...createdBase, - name: 'Imported base', + name: importedBaseName, }); const routedExecute = vi.fn().mockResolvedValue(0); const fallbackExecute = vi.fn().mockResolvedValue(0); @@ -145,7 +211,9 @@ describe('BaseImportService', () => { cls: unknown; prismaService: unknown; dbProvider: unknown; - dataDbClientManager: unknown; + dataDbClientManager: { + dataPrismaForSpace: ReturnType; + }; dataPrismaService: unknown; }; @@ -174,10 +242,10 @@ describe('BaseImportService', () => { }; await expect( - service.createBase('spcImport', 'Imported base', 'icon', { useTransaction: true }) + service.createBase('spcImport', importedBaseName, 'icon', { useTransaction: true }) ).resolves.toMatchObject({ id: 'bseImported', - name: 'Imported base', + name: importedBaseName, }); expect(service.dataDbClientManager.dataPrismaForSpace).toHaveBeenCalledWith('spcImport', { @@ -188,6 +256,101 @@ describe('BaseImportService', () => { }); }); + describe('importBaseV2', () => { + it('restores edition extras during dottea import and returns their id maps', async () => { + const tableIdMap = { tblSource: 'tblImported' }; + const fieldIdMap = { fldSource: 'fldImported' }; + const viewIdMap = { viwSource: 'viwImported' }; + const appIdMap = { appSource: 'appImported' }; + const workflowIdMap = { wflSource: 'wflImported' }; + const commandBus = { + execute: vi.fn().mockResolvedValue({ + isErr: () => false, + value: { + tableIdMap, + fieldIdMap, + viewIdMap, + }, + }), + }; + const queryBus = {}; + const tableRecordRepository = {}; + const unitOfWork = {}; + const db = {}; + const context = {}; + const container = { + resolve: vi.fn((token: unknown) => { + const tokenText = String(token); + if (tokenText.includes('commandBus')) return commandBus; + if (tokenText.includes('queryBus')) return queryBus; + if (tokenText.includes('tableRecordRepository')) return tableRecordRepository; + if (tokenText.includes('unitOfWork')) return unitOfWork; + return db; + }), + }; + const structure = { + id: 'bseSource', + name: 'Source base', + icon: 'icon', + tables: [], + plugins: {}, + folders: [], + nodes: [], + } as unknown as IBaseJson; + const importedBase = { id: 'bseImported', name: 'Source base', spaceId: 'spcImport' }; + const service = Object.create(BaseImportService.prototype) as IImportBaseV2Service; + + service.storageAdapter = { + downloadFile: vi.fn().mockReturnValue({}), + }; + service.readDotTeaStructure = vi.fn().mockResolvedValue(structure); + service.v2ContainerService = { + getContainerForSpace: vi.fn().mockResolvedValue(container), + }; + service.v2ContextFactory = { + createContext: vi.fn().mockResolvedValue(context), + }; + service.createBaseV2 = vi.fn().mockResolvedValue(importedBase); + service.restoreBaseExtrasV2 = vi.fn().mockResolvedValue({ appIdMap, workflowIdMap }); + service.importAttachmentsV2 = vi.fn().mockResolvedValue(undefined); + service.importTableDataV2 = vi.fn().mockResolvedValue(undefined); + service.importTableLinkFieldsV2 = vi.fn().mockResolvedValue(undefined); + service.audit = { + withOperation: vi.fn((_resolved, run: () => Promise) => run()), + }; + service.cls = { + get: vi.fn().mockReturnValue('usrImport'), + }; + + await expect( + service.importBaseV2({ + spaceId: 'spcImport', + notify: { path: 'import.tea' } as ImportBaseRo['notify'], + }) + ).resolves.toMatchObject({ + base: importedBase, + tableIdMap, + fieldIdMap, + viewIdMap, + appIdMap, + workflowIdMap, + baseIdMap: { [structure.id]: importedBase.id }, + }); + + expect(service.restoreBaseExtrasV2).toHaveBeenCalledWith( + db, + importedBase.id, + structure, + { tableIdMap, fieldIdMap, viewIdMap }, + BaseDuplicateMode.Normal, + undefined + ); + expect(commandBus.execute).toHaveBeenCalledTimes(1); + expect(service.importTableDataV2).toHaveBeenCalledTimes(1); + expect(service.importTableLinkFieldsV2).toHaveBeenCalledTimes(1); + }); + }); + describe('toRestoreRecordInput', () => { it('serializes JSON extra column values for v2 dottea row restore', () => { const service = createService(); diff --git a/apps/nestjs-backend/src/features/base/base-import.service.ts b/apps/nestjs-backend/src/features/base/base-import.service.ts index 6c6efeec85..cbe4673903 100644 --- a/apps/nestjs-backend/src/features/base/base-import.service.ts +++ b/apps/nestjs-backend/src/features/base/base-import.service.ts @@ -1,5 +1,5 @@ import type { Readable } from 'stream'; -import { Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { DbFieldType, @@ -76,6 +76,7 @@ import { InjectStorageAdapter } from '../attachments/plugins/storage'; import { AuditScope } from '../audit/audit-scope'; import { Audit } from '../audit/audit.decorator'; import { FieldDuplicateService } from '../field/field-duplicate/field-duplicate.service'; +import { SpaceDataDbMigrationGuardService } from '../space/space-data-db-migration-guard.service'; import { TableService } from '../table/table.service'; import { V2ContainerService } from '../v2/v2-container.service'; import { V2ExecutionContextFactory } from '../v2/v2-execution-context.factory'; @@ -112,6 +113,7 @@ type IDataPrismaScopedClient = IDataPrismaExecutor & { const tableDataImportBatchSize = 100; const linkFieldImportBatchSize = 25; +const attachmentsDirPrefix = 'attachments/'; const stringifyErrorDetails = (details: unknown): string | undefined => { if (details === undefined || details === null) { @@ -181,9 +183,20 @@ export class BaseImportService { private readonly v2ContainerService: V2ContainerService, private readonly v2ContextFactory: V2ExecutionContextFactory, private readonly dataDbClientManager: DataDbClientManager, - private readonly audit: AuditScope + private readonly audit: AuditScope, + @Optional() + @Inject(SpaceDataDbMigrationGuardService) + private readonly spaceDataDbMigrationGuard?: SpaceDataDbMigrationGuardService ) {} + private async assertSpaceWritable(spaceId: string) { + await this.spaceDataDbMigrationGuard?.assertSpaceWritable(spaceId); + } + + private async assertBaseWritable(baseId: string) { + await this.spaceDataDbMigrationGuard?.assertBaseWritable(baseId); + } + private async getMaxOrder(spaceId: string) { const spaceAggregate = await this.prismaService.txClient().base.aggregate({ where: { spaceId, deletedTime: null }, @@ -198,6 +211,7 @@ export class BaseImportService { icon?: string, routingOptions?: IDataDbRoutingOptions ) { + await this.assertSpaceWritable(spaceId); const userId = this.cls.get('user.id'); const order = (await this.getMaxOrder(spaceId)) + 1; @@ -259,7 +273,9 @@ export class BaseImportService { updateExistingBase: boolean = true ): Promise { const userId = this.cls.get('user.id'); + await this.assertSpaceWritable(spaceId); if (baseId) { + await this.assertBaseWritable(baseId); const existingResult = await sql<{ id: string; name: string; @@ -351,8 +367,10 @@ export class BaseImportService { }) async importBase(importBaseRo: ImportBaseRo, onProgress?: BaseImportProgressCallback) { const { + spaceId, notify: { path }, } = importBaseRo; + await this.assertSpaceWritable(spaceId); const logId = this.audit.current()!.operationId; onProgress?.('parsing_structure'); @@ -418,6 +436,7 @@ export class BaseImportService { spaceId, notify: { path }, } = importBaseRo; + await this.assertSpaceWritable(spaceId); onProgress?.('importing_v2'); onProgress?.('parsing_structure'); @@ -473,7 +492,7 @@ export class BaseImportService { const { tableIdMap, fieldIdMap, viewIdMap } = result.value; onProgress?.('structure_created', base.id); - await this.restoreBaseExtrasV2( + const { appIdMap, workflowIdMap } = await this.restoreBaseExtrasV2( db, base.id, structure, @@ -512,6 +531,15 @@ export class BaseImportService { tableIdMap, fieldIdMap, viewIdMap, + appIdMap, + workflowIdMap, + // Source->new base id, so EE import can rewrite base-id references baked into app + // build artifacts (matches the v1 import/duplicate idMap; v2 dropped it). + baseIdMap: { [structure.id]: base.id }, + } as IImportBaseVo & { + appIdMap: Record; + workflowIdMap: Record; + baseIdMap: Record; }; } @@ -525,8 +553,7 @@ export class BaseImportService { viewIdMap: Record; }, duplicateMode: BaseDuplicateMode = BaseDuplicateMode.Normal, - onProgress?: BaseImportProgressCallback, - options?: { restoreEeResources?: boolean } + onProgress?: BaseImportProgressCallback ): Promise<{ appIdMap: Record; workflowIdMap: Record }> { const { tableIdMap, fieldIdMap, viewIdMap } = idMaps; let dashboardIdMap: Record = {}; @@ -545,20 +572,17 @@ export class BaseImportService { )); } - // Restore edition-specific resources (apps / workflows / authority matrix) and collect their - // id maps so the matching base_node rows can be remapped below. Community has none, so the - // hook is a no-op; the EE subclass overrides it. Gated to the duplicate/copy path - // (restoreEeResources) so the .tea import path keeps its current behavior untouched. - const { workflowIdMap = {}, appIdMap = {} } = options?.restoreEeResources - ? await this.restoreExtraBaseResourcesV2( - db, - baseId, - structure, - { tableIdMap, fieldIdMap, viewIdMap }, - duplicateMode, - onProgress - ) - : {}; + // Restore edition-specific resources (apps / workflows / authority matrix) through the v2 + // extension hook and collect their id maps so matching base_node rows can be remapped below. + // Community has none, so the hook is a no-op; EE overrides it for imported and duplicated bases. + const { workflowIdMap = {}, appIdMap = {} } = await this.restoreExtraBaseResourcesV2( + db, + baseId, + structure, + { tableIdMap, fieldIdMap, viewIdMap }, + duplicateMode, + onProgress + ); const hasFolders = Array.isArray(structure.folders) && structure.folders.length > 0; const hasNodes = Array.isArray(structure.nodes) && structure.nodes.length > 0; @@ -589,10 +613,10 @@ export class BaseImportService { } /** - * Hook for edition-specific (EE) base resources restored during a v2 duplicate/copy: - * apps, workflows, and the authority matrix. Community has none, so this is a no-op. - * The EE subclass overrides it to create those rows and returns their id maps so the - * caller can remap the corresponding base_node entries. + * Hook for edition-specific base resources restored during v2 `.tea` import and v2 duplicate/copy: + * apps, workflows, and the authority matrix. Community has none, so this is a no-op. The EE + * subclass overrides it to create those rows and returns their id maps so the caller can remap the + * corresponding base_node entries. */ protected async restoreExtraBaseResourcesV2( _db: Kysely, @@ -1085,35 +1109,92 @@ export class BaseImportService { } } + /** + * Stream the uploaded .tea straight from storage and hand only the entries selected by + * `match` to `consume`, one at a time. + * + * Uses the event-based `unzipper.Parse()` (NOT `{ forceStream: true }`) and, crucially, + * decides skip-vs-consume and calls `entry.autodrain()` for skipped entries + * SYNCHRONOUSLY inside the 'entry' event. That matters: unzipper begins inflating an + * entry (`zlib.createInflateRaw`) the moment it is emitted unless `autodrain()` is + * called before the next tick — a late/deferred autodrain still decompresses the entry. + * With `{ forceStream: true }` + `for await`, `autodrain()` always runs a tick too late, + * so a single entry whose deflate stream doesn't decode cleanly (e.g. an odd app-version + * zip, a truncated upload, or a data-descriptor boundary false-match) throws + * "unexpected end of file (Z_BUF_ERROR)" even though we only meant to skip it. Draining + * inline routes skipped entries through a PassThrough (no inflate), so they can never + * fail — the same approach the v1 CSV/junction importers use. + * + * Memory stays bounded to a single in-flight entry: the parser back-pressures on the + * current entry instead of buffering the archive (no temp file, no full-file buffer). + * `consume` may `await` before reading the entry; the parser simply waits. + */ + private async forEachDotTeaEntry( + path: string, + match: (entryPath: string, entry: unzipper.Entry) => boolean, + consume: (entry: unzipper.Entry) => Promise + ): Promise { + const zipStream = await this.storageAdapter.downloadFile( + StorageAdapter.getBucket(UploadType.Import), + path + ); + const parser = unzipper.Parse(); + + await new Promise((resolve, reject) => { + let settled = false; + let chain: Promise = Promise.resolve(); + + const fail = (error: unknown) => { + if (settled) return; + settled = true; + zipStream.destroy(); + reject(error instanceof Error ? error : new Error(String(error))); + }; + + parser.on('entry', (entry: unzipper.Entry) => { + // Synchronous skip: drain (PassThrough, no inflate) entries we don't need. + if (settled || !match(entry.path, entry)) { + entry.autodrain(); + return; + } + // Matched entries are consumed sequentially; the parser back-pressures until done. + chain = chain.then(() => consume(entry)).catch(fail); + }); + parser.on('error', fail); + zipStream.on('error', fail); + parser.on('close', () => { + chain + .then(() => { + if (!settled) { + settled = true; + resolve(); + } + }) + .catch(fail); + }); + + zipStream.pipe(parser); + }); + } + private async importAttachmentsV2(db: Kysely, path: string) { await this.importAttachmentFilesV2(db, path); await this.importAttachmentMetadataV2(db, path); } private async importAttachmentFilesV2(db: Kysely, path: string) { - const zipStream = await this.storageAdapter.downloadFile( - StorageAdapter.getBucket(UploadType.Import), - path - ); - const parser = unzipper.Parse({ forceStream: true }); - zipStream.pipe(parser); const bucket = StorageAdapter.getBucket(UploadType.Table); - try { - for await (const entry of parser as AsyncIterable) { + await this.forEachDotTeaEntry( + path, + (entryPath, entry) => + entryPath.startsWith(attachmentsDirPrefix) && + entry.type !== 'Directory' && + (entryPath.split('.').pop() ?? '') !== 'csv', + async (entry) => { const filePath = entry.path; const fileSuffix = filePath.split('.').pop() ?? ''; - - if ( - !filePath.startsWith('attachments/') || - entry.type === 'Directory' || - fileSuffix === 'csv' - ) { - entry.autodrain(); - continue; - } - - const token = filePath.replace('attachments/', '').split('.')[0]; + const token = filePath.replace(attachmentsDirPrefix, '').split('.')[0]; const isThumbnail = token.includes('thumbnail__'); const finalPath = isThumbnail ? `table/${token.split('__')[1].split('.')[0]}` @@ -1128,7 +1209,7 @@ export class BaseImportService { if (existing.rows[0]) { entry.autodrain(); - continue; + return; } await this.storageAdapter.uploadFileStream(bucket, finalPath, entry, { @@ -1136,32 +1217,19 @@ export class BaseImportService { 'Content-Type': this.getAttachmentMimeType(fileSuffix), }); } - } finally { - zipStream.destroy(); - } + ); } private async importAttachmentMetadataV2(db: Kysely, path: string) { - const zipStream = await this.storageAdapter.downloadFile( - StorageAdapter.getBucket(UploadType.Import), - path - ); - const parser = unzipper.Parse({ forceStream: true }); - zipStream.pipe(parser); const userId = this.cls.get('user.id'); - try { - for await (const entry of parser as AsyncIterable) { - const filePath = entry.path; - if ( - !filePath.startsWith('attachments/') || - entry.type === 'Directory' || - !filePath.endsWith('.csv') - ) { - entry.autodrain(); - continue; - } - + await this.forEachDotTeaEntry( + path, + (entryPath, entry) => + entryPath.startsWith(attachmentsDirPrefix) && + entry.type !== 'Directory' && + entryPath.endsWith('.csv'), + async (entry) => { const csvStream = entry.pipe( csvParser.default({ mapHeaders: ({ header }) => header.replace(/^\uFEFF/, ''), @@ -1216,9 +1284,7 @@ export class BaseImportService { `.execute(db); } } - } finally { - zipStream.destroy(); - } + ); } private getAttachmentMimeType(extension: string): string { @@ -1263,6 +1329,23 @@ export class BaseImportService { return extensionToMimeType[ext] || 'application/octet-stream'; } + /** A `tables/.csv` entry (excluding junction tables) whose table exists in the structure. */ + private isImportableTableCsv( + entryPath: string, + entry: unzipper.Entry, + tablesById: Map + ): boolean { + if ( + !entryPath.startsWith('tables/') || + entry.type === 'Directory' || + !entryPath.endsWith('.csv') || + entryPath.includes('junction_') + ) { + return false; + } + return tablesById.has(entryPath.replace('tables/', '').replace(/\.csv$/, '')); + } + private async importTableDataV2( path: string, baseId: string, @@ -1274,49 +1357,29 @@ export class BaseImportService { context: IExecutionContext, onProgress?: BaseImportProgressCallback ) { - const dotTeaDataStream = await this.storageAdapter.downloadFile( - StorageAdapter.getBucket(UploadType.Import), - path - ); - const parser = unzipper.Parse({ forceStream: true }); - dotTeaDataStream.pipe(parser); - const tablesById = new Map(structure.tables.map((table) => [table.id, table])); let importedTables = 0; - for await (const entry of parser as AsyncIterable) { - const filePath = entry.path; - const isTableCsv = - filePath.startsWith('tables/') && - entry.type !== 'Directory' && - filePath.endsWith('.csv') && - !filePath.includes('junction_'); - - if (!isTableCsv) { - entry.autodrain(); - continue; - } - - const tableId = filePath.replace('tables/', '').replace(/\.csv$/, ''); - const table = tablesById.get(tableId); - if (!table) { - entry.autodrain(); - continue; + await this.forEachDotTeaEntry( + path, + (entryPath, entry) => this.isImportableTableCsv(entryPath, entry, tablesById), + async (entry) => { + const tableId = entry.path.replace('tables/', '').replace(/\.csv$/, ''); + const table = tablesById.get(tableId)!; + importedTables++; + await this.importTableDataEntryV2( + entry, + table, + baseId, + tableIdMap[table.id] ?? table.id, + viewIdMap, + commandBus, + queryBus, + context, + onProgress + ); } - - importedTables++; - await this.importTableDataEntryV2( - entry, - table, - baseId, - tableIdMap[table.id] ?? table.id, - viewIdMap, - commandBus, - queryBus, - context, - onProgress - ); - } + ); if (importedTables === 0) { onProgress?.('table_data_empty'); @@ -1476,13 +1539,6 @@ export class BaseImportService { return; } - const dotTeaDataStream = await this.storageAdapter.downloadFile( - StorageAdapter.getBucket(UploadType.Import), - path - ); - const parser = unzipper.Parse({ forceStream: true }); - dotTeaDataStream.pipe(parser); - const tablesById = new Map(structure.tables.map((table) => [table.id, table])); let processedRows = 0; let currentBatch = 0; @@ -1513,37 +1569,24 @@ export class BaseImportService { }); }; - for await (const entry of parser as AsyncIterable) { - const filePath = entry.path; - const isTableCsv = - filePath.startsWith('tables/') && - entry.type !== 'Directory' && - filePath.endsWith('.csv') && - !filePath.includes('junction_'); - - if (!isTableCsv) { - entry.autodrain(); - continue; - } - - const tableId = filePath.replace('tables/', '').replace(/\.csv$/, ''); - const table = tablesById.get(tableId); - if (!table) { - entry.autodrain(); - continue; + await this.forEachDotTeaEntry( + path, + (entryPath, entry) => this.isImportableTableCsv(entryPath, entry, tablesById), + async (entry) => { + const tableId = entry.path.replace('tables/', '').replace(/\.csv$/, ''); + const table = tablesById.get(tableId)!; + await this.importTableLinkFieldEntryV2( + entry, + baseId, + tableIdMap[table.id] ?? table.id, + queryBus, + tableRecordRepository, + unitOfWork, + context, + onLinkBatchUpdated + ); } - - await this.importTableLinkFieldEntryV2( - entry, - baseId, - tableIdMap[table.id] ?? table.id, - queryBus, - tableRecordRepository, - unitOfWork, - context, - onLinkBatchUpdated - ); - } + ); if (processedRows > 0) { onProgress?.({ @@ -1563,55 +1606,35 @@ export class BaseImportService { queryBus: IQueryBus, context: IExecutionContext ) { - const dotTeaDataStream = await this.storageAdapter.downloadFile( - StorageAdapter.getBucket(UploadType.Import), - path - ); - const parser = unzipper.Parse({ forceStream: true }); - dotTeaDataStream.pipe(parser); - const tablesById = new Map(structure.tables.map((table) => [table.id, table])); let totalRows = 0; - for await (const entry of parser as AsyncIterable) { - const filePath = entry.path; - const isTableCsv = - filePath.startsWith('tables/') && - entry.type !== 'Directory' && - filePath.endsWith('.csv') && - !filePath.includes('junction_'); - - if (!isTableCsv) { - entry.autodrain(); - continue; - } - - const tableId = filePath.replace('tables/', '').replace(/\.csv$/, ''); - const table = tablesById.get(tableId); - if (!table) { - entry.autodrain(); - continue; - } - - const config = await this.buildTableDataImportConfig( - baseId, - tableIdMap[table.id] ?? table.id, - queryBus, - context - ); - const hasLinkFields = [...config.fieldsByDbFieldName.values()].some( - (field) => field.type === FieldType.Link && !this.isRestoreComputedField(field) - ); + await this.forEachDotTeaEntry( + path, + (entryPath, entry) => this.isImportableTableCsv(entryPath, entry, tablesById), + async (entry) => { + const tableId = entry.path.replace('tables/', '').replace(/\.csv$/, ''); + const table = tablesById.get(tableId)!; + const config = await this.buildTableDataImportConfig( + baseId, + tableIdMap[table.id] ?? table.id, + queryBus, + context + ); + const hasLinkFields = [...config.fieldsByDbFieldName.values()].some( + (field) => field.type === FieldType.Link && !this.isRestoreComputedField(field) + ); - if (!hasLinkFields) { - entry.autodrain(); - continue; - } + if (!hasLinkFields) { + entry.autodrain(); + return; + } - for await (const _record of this.createTableLinkFieldUpdateStream(entry, config)) { - totalRows += 1; + for await (const _record of this.createTableLinkFieldUpdateStream(entry, config)) { + totalRows += 1; + } } - } + ); return totalRows; } @@ -2153,26 +2176,19 @@ export class BaseImportService { ? tables.map(({ dbTableName: _, ...rest }) => rest) : tables; - // Skip computed field evaluation during structure creation — tables have no records yet, - // and calculations will run when data is actually imported/copied. - this.cls.set('skipFieldComputation', true); - let tableIdMap: Record; let fieldIdMap: Record; let viewIdMap: Record; let fkMap: Record; - try { - // create table - ({ tableIdMap, fieldIdMap, viewIdMap, fkMap } = await this.createTables( - newBase.id, - effectiveTables as IBaseJson['tables'], - onProgress, - routingOptions - )); - } finally { - this.cls.set('skipFieldComputation', false); - } + // create table + ({ tableIdMap, fieldIdMap, viewIdMap, fkMap } = await this.createTables( + newBase.id, + effectiveTables as IBaseJson['tables'], + onProgress, + routingOptions, + { skipComputedEvaluation: true } + )); this.logger.log(`base-duplicate-service: Duplicate base tables successfully`); @@ -2235,7 +2251,8 @@ export class BaseImportService { baseId: string, tables: IBaseJson['tables'], onProgress?: BaseImportProgressCallback, - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + options: { skipComputedEvaluation?: boolean } = {} ) { const tableIdMap: Record = {}; // Build a name lookup: oldTableId → tableName @@ -2260,11 +2277,14 @@ export class BaseImportService { tableIdMap, tableNameMap, onProgress, - routingOptions + routingOptions, + options ); this.logger.log(`base-duplicate-service: Duplicate table fields successfully`); - const viewIdMap = await this.createViews(tables, tableIdMap, fieldIdMap, onProgress); + const viewIdMap = await this.createViews(tables, tableIdMap, fieldIdMap, onProgress, { + ensureRowOrder: routingOptions?.useTransaction !== true, + }); this.logger.log(`base-duplicate-service: Duplicate table views successfully`); await this.fieldDuplicateService.repairFieldOptions(tables, tableIdMap, fieldIdMap, viewIdMap); @@ -2277,7 +2297,8 @@ export class BaseImportService { tableIdMap: Record, tableNameMap?: Record, onProgress?: BaseImportProgressCallback, - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + options: { skipComputedEvaluation?: boolean } = {} ) { const fieldMap: Record = {}; const fkMap: Record = {}; @@ -2350,16 +2371,27 @@ export class BaseImportService { }; emitFieldProgress('creating_common_fields', commonFields); - await this.fieldDuplicateService.createCommonFields(commonFields, fieldMap, routingOptions); + await this.fieldDuplicateService.createCommonFields( + commonFields, + fieldMap, + routingOptions, + options + ); emitFieldProgress('creating_button_fields', buttonFields); - await this.fieldDuplicateService.createButtonFields(buttonFields, fieldMap, routingOptions); + await this.fieldDuplicateService.createButtonFields( + buttonFields, + fieldMap, + routingOptions, + options + ); emitFieldProgress('creating_formula_fields', primaryFormulaFields); await this.fieldDuplicateService.createTmpPrimaryFormulaFields( primaryFormulaFields, fieldMap, - routingOptions + routingOptions, + options ); // main fix formula dbField type @@ -2372,7 +2404,8 @@ export class BaseImportService { await this.fieldDuplicateService.bootstrapPrimaryDependencyFields( primaryDependencyFields, fieldMap, - routingOptions + routingOptions, + options ); emitFieldProgress('creating_link_fields', linkFields); @@ -2381,7 +2414,8 @@ export class BaseImportService { tableIdMap, fieldMap, fkMap, - routingOptions + routingOptions, + options ); emitFieldProgress('creating_lookup_fields', dependencyFields); @@ -2390,7 +2424,8 @@ export class BaseImportService { tableIdMap, fieldMap, 'base', - routingOptions + routingOptions, + options ); // fix formula expression' field map @@ -2411,7 +2446,8 @@ export class BaseImportService { tables: IBaseJson['tables'], tableIdMap: Record, fieldMap: Record, - onProgress?: BaseImportProgressCallback + onProgress?: BaseImportProgressCallback, + options: { ensureRowOrder?: boolean } = {} ) { const viewMap: Record = {}; for (const table of tables) { @@ -2446,14 +2482,18 @@ export class BaseImportService { const newValue = keyString ? JSON.parse(keyString) : null; obj[key] = newValue; } - const newViewVo = await this.viewOpenApiService.createView(tableIdMap[tableId], { - name, - type, - description, - enableShare, - isLocked, - ...obj, - }); + const newViewVo = await this.viewOpenApiService.createView( + tableIdMap[tableId], + { + name, + type, + description, + enableShare, + isLocked, + ...obj, + }, + { ensureRowOrder: options.ensureRowOrder } + ); viewMap[viewId] = newViewVo.id; diff --git a/apps/nestjs-backend/src/features/base/base.service.spec.ts b/apps/nestjs-backend/src/features/base/base.service.spec.ts index cb66471f20..a4d70fb42b 100644 --- a/apps/nestjs-backend/src/features/base/base.service.spec.ts +++ b/apps/nestjs-backend/src/features/base/base.service.spec.ts @@ -21,6 +21,190 @@ describe('BaseService', () => { expect(service).toBeDefined(); }); + describe('enrichBaseListV2Status', () => { + const createService = () => { + const canaryService = { + shouldUseV2WithReason: vi.fn().mockResolvedValue({ useV2: true, reason: 'space_feature' }), + isSpaceInCanary: vi.fn().mockResolvedValue(true), + }; + + return { + service: new BaseService( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + canaryService as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never + ), + canaryService, + }; + }; + + it('adds canary-space v2 status for list items that are not new-base v2', async () => { + const { service, canaryService } = createService(); + + const result = await service.enrichBaseListV2Status([ + { id: 'bse1', spaceId: 'spc1', v2Enabled: false }, + ]); + + expect(canaryService.shouldUseV2WithReason).toHaveBeenCalledTimes(1); + expect(canaryService.shouldUseV2WithReason).toHaveBeenCalledWith('spc1', 'getRecords'); + expect(canaryService.isSpaceInCanary).toHaveBeenCalledWith('spc1'); + expect(result[0]).toMatchObject({ + id: 'bse1', + isCanary: true, + v2Status: { useV2: true, reason: 'space_feature' }, + }); + }); + + it('keeps new-base v2 status while batching space-level canary checks', async () => { + const { service, canaryService } = createService(); + + const result = await service.enrichBaseListV2Status([ + { id: 'bse1', spaceId: 'spc1', v2Enabled: true }, + { id: 'bse2', spaceId: 'spc1', v2Enabled: false }, + ]); + + expect(canaryService.shouldUseV2WithReason).toHaveBeenCalledTimes(1); + expect(result).toMatchObject([ + { + id: 'bse1', + v2Status: { useV2: true, reason: 'new_base' }, + }, + { + id: 'bse2', + v2Status: { useV2: true, reason: 'space_feature' }, + }, + ]); + }); + }); + + describe('getAllBaseList', () => { + const spaceId = 'spc1'; + const createdTime = new Date('2026-06-13T00:00:00.000Z'); + + const createService = (overrides?: { + createdBy?: string; + userList?: { id: string; name: string; avatar: string | null }[]; + spaceOwnerMap?: Map; + }) => { + const base = { + id: 'bse1', + name: 'Base', + order: 1, + spaceId, + icon: null, + createdBy: overrides?.createdBy ?? 'usr1', + createdTime, + lastModifiedTime: createdTime, + v2Enabled: false, + }; + const prismaService = { + base: { + findMany: vi.fn().mockResolvedValue([base]), + }, + user: { + findMany: vi + .fn() + .mockResolvedValue( + overrides?.userList ?? [{ id: base.createdBy, name: 'Nee', avatar: null }] + ), + }, + baseShare: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + const collaboratorService = { + getCurrentUserCollaboratorsBaseAndSpaceArray: vi.fn().mockResolvedValue({ + spaceIds: [spaceId], + baseIds: [], + roleMap: { [spaceId]: Role.Owner }, + }), + buildSpaceOwnerContext: vi.fn().mockResolvedValue({ + validCreatorSet: new Set(), + spaceOwnerMap: overrides?.spaceOwnerMap ?? new Map([[spaceId, base.createdBy]]), + }), + }; + const canaryService = { + shouldUseV2WithReason: vi.fn().mockResolvedValue({ useV2: true, reason: 'space_feature' }), + isSpaceInCanary: vi.fn().mockResolvedValue(true), + }; + const service = new BaseService( + prismaService as never, + {} as never, + {} as never, + collaboratorService as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + canaryService as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never + ); + return { service, canaryService, base }; + }; + + it('includes v2 status for canary-space bases from the all-base list endpoint', async () => { + const { service, canaryService, base } = createService(); + + const result = await service.getAllBaseList(); + + expect(canaryService.shouldUseV2WithReason).toHaveBeenCalledWith(spaceId, 'getRecords'); + expect(result[0]).toMatchObject({ + id: base.id, + role: Role.Owner, + isCanary: true, + v2Status: { useV2: true, reason: 'space_feature' }, + }); + expect(result[0]).not.toHaveProperty('v2Enabled'); + }); + + it('shows the real base creator even when the creator is not a space owner', async () => { + const { service, base } = createService({ + createdBy: 'usrCreator', + userList: [ + { id: 'usrCreator', name: 'Creator', avatar: null }, + { id: 'usrOwner', name: 'Owner', avatar: null }, + ], + spaceOwnerMap: new Map([[spaceId, 'usrOwner']]), + }); + + const result = await service.getAllBaseList(); + + expect(result[0].createdUser?.id).toBe(base.createdBy); + expect(result[0].createdUser?.id).toBe('usrCreator'); + }); + + it('falls back to a space owner when the creator user record is unresolvable', async () => { + const { service } = createService({ + createdBy: 'usrGone', + userList: [{ id: 'usrOwner', name: 'Owner', avatar: null }], + spaceOwnerMap: new Map([[spaceId, 'usrOwner']]), + }); + + const result = await service.getAllBaseList(); + + expect(result[0].createdUser?.id).toBe('usrOwner'); + }); + }); + describe('getBaseById', () => { const createService = (params: { base: { @@ -58,10 +242,10 @@ describe('BaseService', () => { service: new BaseService( prismaService as never, {} as never, - {} as never, cls as never, {} as never, {} as never, + {} as never, permissionService as never, {} as never, {} as never, @@ -173,16 +357,17 @@ describe('BaseService', () => { const { service } = { service: new BaseService( {} as never, - defaultDataPrisma as never, dataDbClientManager as never, {} as never, {} as never, {} as never, {} as never, + {} as never, tableOpenApiService as never, {} as never, {} as never, {} as never, + {} as never, dbProvider as never, {} as never, {} as never, @@ -218,6 +403,7 @@ describe('BaseService', () => { {} as never, {} as never, {} as never, + {} as never, { dropSchema: vi.fn().mockReturnValue('') } as never, {} as never, {} as never, diff --git a/apps/nestjs-backend/src/features/base/base.service.ts b/apps/nestjs-backend/src/features/base/base.service.ts index 3231bb598c..4290e1f28a 100644 --- a/apps/nestjs-backend/src/features/base/base.service.ts +++ b/apps/nestjs-backend/src/features/base/base.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; import { ActionPrefix, actionPrefixMap, @@ -11,27 +11,28 @@ import { type ILinkFieldOptions, } from '@teable/core'; import { PrismaService, ProvisionState } from '@teable/db-main-prisma'; +import { + CollaboratorType, + CreateRecordAction, + ResourceType, + BaseNodeResourceType, + BaseDuplicateMode, + UploadType, + IDuplicateBaseRo, + ICreateBaseFromTemplateRo, + LastVisitResourceType, +} from '@teable/openapi'; import type { IBaseErdVo, ICreateBaseFromTemplateVo, ICreateBaseRo, ICrossSpaceAffectedField, - IDuplicateBaseRo, IGetBasePermissionVo, IMoveBaseRo, IPublishBaseRo, IUpdateBaseRo, IUpdateOrderRo, } from '@teable/openapi'; -import { - CollaboratorType, - CreateRecordAction, - ResourceType, - BaseNodeResourceType, - BaseDuplicateMode, - UploadType, - type ICreateBaseFromTemplateRo, -} from '@teable/openapi'; import { isNumber, keyBy, pick, uniq } from 'lodash'; import { ClsService } from 'nestjs-cls'; import { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config'; @@ -52,9 +53,11 @@ import { AuditScope } from '../audit/audit-scope'; import { Audit } from '../audit/audit.decorator'; import { PermissionService } from '../auth/permission.service'; import { CanaryService } from '../canary'; +import type { IV2Decision } from '../canary'; import { CollaboratorService } from '../collaborator/collaborator.service'; import { FieldOpenApiService } from '../field/open-api/field-open-api.service'; import { GraphService } from '../graph/graph.service'; +import { SpaceDataDbMigrationGuardService } from '../space/space-data-db-migration-guard.service'; import { TableOpenApiService } from '../table/open-api/table-open-api.service'; import { BaseDuplicateV2Service } from './base-duplicate-v2.service'; import { BaseDuplicateService } from './base-duplicate.service'; @@ -70,6 +73,21 @@ type IDataPrismaExecutor = { $executeRawUnsafe(query: string, ...values: unknown[]): PromiseLike; }; +type IBaseListV2Source = { + spaceId: string; + v2Enabled?: boolean | null; +}; + +type IBaseListV2Context = { + spaceDecisionMap: Map; + canarySpaceMap: Map; +}; + +type IBaseListV2Info = { + isCanary?: boolean; + v2Status: IV2Decision; +}; + /** * Stable key for deduplicating orphan link-storage drops across both sides of * a symmetric pair. Both sides reference the same underlying junction (M:N) or @@ -126,13 +144,74 @@ export class BaseService { // Explicit @Inject after consecutive token-based @Inject decorators (SWC fails // to emit design:paramtypes metadata for plain class types in this position). @Inject(AuditScope) private readonly audit: AuditScope, - @Inject(EventEmitterService) private readonly eventEmitterService: EventEmitterService + @Inject(EventEmitterService) private readonly eventEmitterService: EventEmitterService, + @Optional() + @Inject(SpaceDataDbMigrationGuardService) + private readonly spaceDataDbMigrationGuard?: SpaceDataDbMigrationGuardService ) {} private getDataPrismaExecutor(prisma: IDataPrismaScopedClient): IDataPrismaExecutor { return prisma.txClient?.() ?? prisma; } + private async assertSpaceWritable(spaceId: string) { + await this.spaceDataDbMigrationGuard?.assertSpaceWritable(spaceId); + } + + private async buildBaseListV2Context(baseList: IBaseListV2Source[]) { + const spaceIds = uniq(baseList.map((base) => base.spaceId)); + const [spaceDecisionEntries, canarySpaceEntries] = await Promise.all([ + Promise.all( + spaceIds.map(async (spaceId) => { + return [ + spaceId, + await this.canaryService.shouldUseV2WithReason(spaceId, 'getRecords'), + ] as const; + }) + ), + Promise.all( + spaceIds.map(async (spaceId) => { + return [spaceId, await this.canaryService.isSpaceInCanary(spaceId)] as const; + }) + ), + ]); + + return { + spaceDecisionMap: new Map(spaceDecisionEntries), + canarySpaceMap: new Map(canarySpaceEntries), + }; + } + + private getBaseListV2Info(base: IBaseListV2Source, context: IBaseListV2Context): IBaseListV2Info { + return { + isCanary: context.canarySpaceMap.get(base.spaceId) || undefined, + v2Status: base.v2Enabled + ? { useV2: true, reason: 'new_base' } + : context.spaceDecisionMap.get(base.spaceId) ?? { + useV2: false, + reason: 'feature_not_enabled', + }, + }; + } + + async enrichBaseListV2Status( + baseList: T[] + ): Promise> { + if (!baseList.length) { + return []; + } + + const context = await this.buildBaseListV2Context(baseList); + return baseList.map((base) => ({ + ...base, + ...this.getBaseListV2Info(base, context), + })); + } + + private async assertBaseWritable(baseId: string) { + await this.spaceDataDbMigrationGuard?.assertBaseWritable(baseId); + } + private async getRoleByBaseId(baseId: string, spaceId: string) { const userId = this.cls.get('user.id'); const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id); @@ -241,12 +320,11 @@ export class BaseService { } const baseSpaceIds = uniq(baseList.map((base) => base.spaceId)); - const { validCreatorSet, spaceOwnerMap } = - await this.collaboratorService.buildSpaceOwnerContext(baseSpaceIds); + const { spaceOwnerMap } = await this.collaboratorService.buildSpaceOwnerContext(baseSpaceIds); const allBaseIds = baseList.map((base) => base.id); const allUserIds = uniq([...baseList.map((base) => base.createdBy), ...spaceOwnerMap.values()]); - const [userList, sharedBaseList] = await Promise.all([ + const [userList, sharedBaseList, baseListWithV2Status] = await Promise.all([ this.prismaService.user.findMany({ where: { id: { in: allUserIds } }, select: { id: true, name: true, avatar: true }, @@ -255,22 +333,25 @@ export class BaseService { where: { baseId: { in: allBaseIds }, nodeId: null, enabled: true }, select: { baseId: true }, }), + this.enrichBaseListV2Status(baseList), ]); const userMap = keyBy(userList, 'id'); const sharedBaseIds = new Set(sharedBaseList.map((s) => s.baseId)); - return baseList.map((base) => { + return baseListWithV2Status.map((base) => { const { v2Enabled, ...baseInfo } = base; - const isCreatorInSpace = validCreatorSet.has(`${base.spaceId}:${base.createdBy}`); - const displayUserId = isCreatorInSpace ? base.createdBy : spaceOwnerMap.get(base.spaceId); + // Show the real base creator; only when their user record is unresolvable + // (e.g. permanently deleted) fall back to a space owner. + const displayUserId = userMap[base.createdBy] + ? base.createdBy + : spaceOwnerMap.get(base.spaceId); const displayUser = displayUserId ? userMap[displayUserId] : undefined; return { ...baseInfo, role: roleMap[base.id] || roleMap[base.spaceId], isShared: sharedBaseIds.has(base.id), - v2Status: v2Enabled ? ({ useV2: true, reason: 'new_base' } as const) : undefined, lastModifiedTime: base.lastModifiedTime?.toISOString(), createdTime: base.createdTime?.toISOString(), createdUser: displayUser @@ -294,6 +375,7 @@ export class BaseService { async createBase(createBaseRo: ICreateBaseRo) { const userId = this.cls.get('user.id'); const { name, spaceId, icon } = createBaseRo; + await this.assertSpaceWritable(spaceId); const order = (await this.getMaxOrder(spaceId)) + 1; const base = await this.prismaService.base.create({ @@ -348,6 +430,7 @@ export class BaseService { } async updateBase(baseId: string, updateBaseRo: IUpdateBaseRo) { + await this.assertBaseWritable(baseId); const userId = this.cls.get('user.id'); return this.prismaService.base.update({ @@ -369,6 +452,7 @@ export class BaseService { } async shuffle(spaceId: string) { + await this.assertSpaceWritable(spaceId); const bases = await this.prismaService.base.findMany({ where: { spaceId, deletedTime: null }, select: { id: true }, @@ -389,6 +473,7 @@ export class BaseService { } async updateOrder(baseId: string, orderRo: IUpdateOrderRo) { + await this.assertBaseWritable(baseId); const { anchorId, position } = orderRo; const base = await this.prismaService.base @@ -447,6 +532,7 @@ export class BaseService { } async deleteBase(baseId: string) { + await this.assertBaseWritable(baseId); const userId = this.cls.get('user.id'); await this.prismaService.base.update({ @@ -465,7 +551,9 @@ export class BaseService { params: (ro: IDuplicateBaseRo) => ro as unknown as Record, }) async duplicateBase(duplicateBaseRo: IDuplicateBaseRo) { - const { fromBaseId } = duplicateBaseRo; + const { fromBaseId, spaceId } = duplicateBaseRo; + await this.assertBaseWritable(fromBaseId); + await this.assertSpaceWritable(spaceId); // Regular permission check, base update permission await this.checkBaseUpdatePermission(fromBaseId); @@ -486,6 +574,7 @@ export class BaseService { baseId: base.id, fromBaseId, }); + await this.markBaseVisited(base.id, spaceId); return base; } @@ -495,7 +584,9 @@ export class BaseService { params: (ro: IDuplicateBaseRo) => ro as unknown as Record, }) async duplicateBaseV2(duplicateBaseRo: IDuplicateBaseRo) { - const { fromBaseId } = duplicateBaseRo; + const { fromBaseId, spaceId } = duplicateBaseRo; + await this.assertBaseWritable(fromBaseId); + await this.assertSpaceWritable(spaceId); // Regular permission check, base update permission await this.checkBaseUpdatePermission(fromBaseId); @@ -509,6 +600,7 @@ export class BaseService { baseId: result.base.id, fromBaseId, }); + await this.markBaseVisited(result.base.id, spaceId); return result.base; } @@ -516,18 +608,49 @@ export class BaseService { duplicateBaseRo: IDuplicateBaseRo, onProgress?: BaseImportProgressCallback ) { - const { fromBaseId } = duplicateBaseRo; + const { fromBaseId, spaceId } = duplicateBaseRo; + await this.assertBaseWritable(fromBaseId); + await this.assertSpaceWritable(spaceId); await this.checkBaseUpdatePermission(fromBaseId); this.logger.log(`base-duplicate-service-v2: Start to duplicating base stream: ${fromBaseId}`); - return await this.baseDuplicateV2Service.duplicateBase( + const result = await this.baseDuplicateV2Service.duplicateBase( duplicateBaseRo, true, BaseDuplicateMode.Normal, onProgress ); + await this.markBaseVisited(result.base.id, spaceId); + return result; + } + + private async markBaseVisited(baseId: string, spaceId: string) { + const userId = this.cls.get('user.id'); + if (!userId) return; + await this.prismaService + .txClient() + .userLastVisit.upsert({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_resourceType_resourceId: { + userId, + resourceType: LastVisitResourceType.Base, + resourceId: baseId, + }, + }, + update: { lastVisitTime: new Date().toISOString() }, + create: { + userId, + resourceType: LastVisitResourceType.Base, + resourceId: baseId, + parentResourceId: spaceId, + }, + }) + .catch((error) => { + this.logger.warn(`Failed to seed last-visit for duplicated base ${baseId}: ${error}`); + }); } private async checkBaseUpdatePermission(baseId: string) { @@ -554,6 +677,10 @@ export class BaseService { createBaseFromTemplateRo: ICreateBaseFromTemplateRo ): Promise { const { spaceId, templateId, withRecords, baseId } = createBaseFromTemplateRo; + await this.assertSpaceWritable(spaceId); + if (baseId) { + await this.assertBaseWritable(baseId); + } const template = await this.prismaService.template.findUniqueOrThrow({ where: { id: templateId }, select: { @@ -705,6 +832,7 @@ export class BaseService { } async permanentDeleteBase(baseId: string, ignorePermissionCheck: boolean = false) { + await this.assertBaseWritable(baseId); if (!ignorePermissionCheck) { const accessTokenId = this.cls.get('accessTokenId'); await this.permissionService.validPermissions(baseId, ['base|delete'], accessTokenId, true); @@ -849,6 +977,8 @@ export class BaseService { async moveBase(baseId: string, moveBaseRo: IMoveBaseRo) { const { spaceId: targetSpaceId } = moveBaseRo; + await this.assertBaseWritable(baseId); + await this.assertSpaceWritable(targetSpaceId); // check if has the permission to create base in the target space await this.checkBaseCreatePermission(targetSpaceId); @@ -1150,6 +1280,7 @@ export class BaseService { } async publishBase(baseId: string, publishBaseRo: IPublishBaseRo) { + await this.assertBaseWritable(baseId); return await this.prismaService.$tx( async (prisma) => { const template = await prisma.template.findFirst({ diff --git a/apps/nestjs-backend/src/features/base/db-connection.service.spec.ts b/apps/nestjs-backend/src/features/base/db-connection.service.spec.ts index e4a62cd6a8..a76b388646 100644 --- a/apps/nestjs-backend/src/features/base/db-connection.service.spec.ts +++ b/apps/nestjs-backend/src/features/base/db-connection.service.spec.ts @@ -1,9 +1,64 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; +import { DriverClient, HttpErrorCode } from '@teable/core'; +import knex from 'knex'; +import { describe, expect, it, vi } from 'vitest'; import { GlobalModule } from '../../global/global.module'; import { BaseModule } from './base.module'; import { DbConnectionService } from './db-connection.service'; +const baseId = 'bsexxx'; +const migrationInProgressMessage = 'Space data database migration is in progress'; +const readOnlyRole = `read_only_role_${baseId}`; +const publicDatabaseProxy = 'db-proxy.example.com:15432'; +const defaultMaxBaseDBConnections = 10; +const byodbHost = 'byodb.example.com'; +const byodbDataUrl = `postgresql://teable:secret@${byodbHost}:5432/byodb_space?schema=internal_byodb`; +const defaultDataUrl = 'postgresql://teable:secret@default.example.com:5432/teable_data'; +const sqlBuilder = knex({ client: 'pg' }); + +const createPrismaServiceMock = () => { + const txPrisma = { + base: { + findFirstOrThrow: vi.fn().mockResolvedValue({ id: baseId }), + update: vi.fn().mockResolvedValue({ id: baseId }), + }, + }; + const prismaService = { + base: { + findFirst: vi.fn().mockResolvedValue({ id: baseId, schemaPass: 'readonly-pass' }), + }, + $tx: vi.fn((fn) => fn(txPrisma)), + }; + + return { prismaService, txPrisma }; +}; + +const createDbConnectionService = ({ + prismaService, + databaseRouter, + baseConfigOverrides, +}: { + prismaService: unknown; + databaseRouter: unknown; + baseConfigOverrides?: Partial<{ + publicDatabaseProxy?: string; + defaultMaxBaseDBConnections: number; + }>; +}) => + new DbConnectionService( + prismaService as never, + databaseRouter as never, + {} as never, + { driver: DriverClient.Pg } as never, + sqlBuilder as never, + { + publicDatabaseProxy, + defaultMaxBaseDBConnections, + ...baseConfigOverrides, + } as never + ); + describe('DbConnectionService', () => { let service: DbConnectionService; @@ -18,4 +73,241 @@ describe('DbConnectionService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + it.each([ + ['default data DB', defaultDataUrl, 'teable_data', 'db-proxy.example.com', 15432, true], + ['BYODB scoped data DB', byodbDataUrl, 'byodb_space', byodbHost, 5432, false], + ])( + 'retrieves an existing %s readonly connection through scoped routing', + async (_, dataUrl, db, host, port, isMetaFallback) => { + const { prismaService } = createPrismaServiceMock(); + const dataPrisma = { + $queryRaw: vi + .fn() + .mockResolvedValueOnce([{ count: 1n }]) + .mockResolvedValueOnce([{ count: 3n }]), + }; + const databaseRouter = { + dataPrismaForBase: vi.fn().mockResolvedValue(dataPrisma), + getDataDatabaseForBase: vi.fn().mockResolvedValue({ + url: dataUrl, + isMetaFallback, + }), + }; + const scopedService = createDbConnectionService({ prismaService, databaseRouter }); + + await expect(scopedService.retrieve(baseId)).resolves.toMatchObject({ + dsn: { + driver: DriverClient.Pg, + host, + port, + db, + user: readOnlyRole, + pass: 'readonly-pass', + params: { schema: baseId }, + }, + connection: { + max: defaultMaxBaseDBConnections, + current: 3, + }, + }); + + expect(databaseRouter.dataPrismaForBase).toHaveBeenCalledTimes(2); + expect(databaseRouter.dataPrismaForBase).toHaveBeenNthCalledWith(1, baseId); + expect(databaseRouter.dataPrismaForBase).toHaveBeenNthCalledWith(2, baseId); + expect(databaseRouter.getDataDatabaseForBase).toHaveBeenCalledWith(baseId); + } + ); + + it('creates a BYODB readonly connection on the scoped data DB', async () => { + const { prismaService, txPrisma } = createPrismaServiceMock(); + const dataPrisma = { + $executeRawUnsafe: vi.fn().mockResolvedValue(0), + }; + const databaseRouter = { + getDataDatabaseForBase: vi.fn().mockResolvedValue({ + url: byodbDataUrl, + isMetaFallback: false, + }), + dataPrismaExecutorForBase: vi.fn().mockResolvedValue(dataPrisma), + }; + const scopedService = createDbConnectionService({ prismaService, databaseRouter }); + + const result = await scopedService.create(baseId); + + expect(result).toMatchObject({ + dsn: { + driver: DriverClient.Pg, + host: byodbHost, + port: 5432, + db: 'byodb_space', + user: readOnlyRole, + params: { schema: baseId }, + }, + connection: { + max: defaultMaxBaseDBConnections, + current: 0, + }, + }); + expect(result?.url).toContain(`postgresql://${readOnlyRole}:`); + expect(result?.url).toContain(`@${byodbHost}:5432/byodb_space?schema=bsexxx`); + expect(databaseRouter.getDataDatabaseForBase).toHaveBeenCalledWith(baseId); + expect(databaseRouter.dataPrismaExecutorForBase).toHaveBeenCalledWith(baseId, { + useTransaction: true, + }); + expect(txPrisma.base.update).toHaveBeenCalledWith({ + where: { id: baseId }, + data: { schemaPass: expect.any(String) }, + }); + + const executedSql = dataPrisma.$executeRawUnsafe.mock.calls.map(([sql]) => sql).join('\n'); + expect(executedSql).toContain(`CREATE ROLE "${readOnlyRole}"`); + expect(executedSql).toContain(`GRANT USAGE ON SCHEMA "${baseId}" TO "${readOnlyRole}"`); + expect(executedSql).toContain( + `GRANT SELECT ON ALL TABLES IN SCHEMA "${baseId}" TO "${readOnlyRole}"` + ); + expect(executedSql).toContain( + `ALTER DEFAULT PRIVILEGES IN SCHEMA "${baseId}" GRANT SELECT ON TABLES TO "${readOnlyRole}"` + ); + }); + + it('creates a BYODB readonly connection with the direct scoped database host when no global proxy is configured', async () => { + const { prismaService } = createPrismaServiceMock(); + const databaseRouter = { + getDataDatabaseForBase: vi.fn().mockResolvedValue({ + url: byodbDataUrl, + isMetaFallback: false, + }), + dataPrismaExecutorForBase: vi.fn().mockResolvedValue({ + $executeRawUnsafe: vi.fn().mockResolvedValue(0), + }), + }; + const scopedService = createDbConnectionService({ + prismaService, + databaseRouter, + baseConfigOverrides: { publicDatabaseProxy: undefined }, + }); + + await expect(scopedService.create(baseId)).resolves.toMatchObject({ + dsn: { + host: byodbHost, + port: 5432, + db: 'byodb_space', + }, + }); + }); + + it('removes a BYODB readonly connection from the scoped data DB only', async () => { + const { prismaService, txPrisma } = createPrismaServiceMock(); + const dataPrisma = { + $executeRawUnsafe: vi.fn().mockResolvedValue(0), + }; + const databaseRouter = { + getDataDatabaseForBase: vi.fn(), + dataPrismaExecutorForBase: vi.fn().mockResolvedValue(dataPrisma), + }; + const scopedService = createDbConnectionService({ prismaService, databaseRouter }); + + await expect(scopedService.remove(baseId)).resolves.toBeUndefined(); + + expect(databaseRouter.dataPrismaExecutorForBase).toHaveBeenCalledWith(baseId, { + useTransaction: true, + }); + expect(databaseRouter.getDataDatabaseForBase).not.toHaveBeenCalled(); + expect(txPrisma.base.update).toHaveBeenCalledWith({ + where: { id: baseId }, + data: { schemaPass: null }, + }); + + const executedSql = dataPrisma.$executeRawUnsafe.mock.calls.map(([sql]) => sql).join('\n'); + expect(executedSql).toContain(`REVOKE USAGE ON SCHEMA "${baseId}" FROM "${readOnlyRole}"`); + expect(executedSql).toContain(`DROP ROLE IF EXISTS "${readOnlyRole}"`); + }); + + it('returns an explicit unavailable error when the scoped data DB cannot create readonly roles', async () => { + const { prismaService } = createPrismaServiceMock(); + const dataPrisma = { + $executeRawUnsafe: vi.fn().mockRejectedValue( + Object.assign(new Error('permission denied to create role'), { + code: '42501', + }) + ), + }; + const databaseRouter = { + getDataDatabaseForBase: vi.fn().mockResolvedValue({ + url: byodbDataUrl, + isMetaFallback: false, + }), + dataPrismaExecutorForBase: vi.fn().mockResolvedValue(dataPrisma), + }; + const scopedService = createDbConnectionService({ prismaService, databaseRouter }); + + await expect(scopedService.create(baseId)).rejects.toMatchObject({ + code: HttpErrorCode.DATABASE_CONNECTION_UNAVAILABLE, + data: { + action: 'create', + reason: 'readonly_role_privilege_unavailable', + }, + }); + }); + + it('rejects readonly connection creation while the base space is migrating', async () => { + const migrationError = new Error(migrationInProgressMessage); + const prismaService = { $tx: vi.fn() }; + const databaseRouter = { + getDataDatabaseForBase: vi.fn(), + dataPrismaExecutorForBase: vi.fn(), + }; + const migrationGuard = { + assertBaseWritable: vi.fn().mockRejectedValue(migrationError), + }; + const guardedService = new DbConnectionService( + prismaService as never, + databaseRouter as never, + {} as never, + { driver: DriverClient.Pg } as never, + {} as never, + { + publicDatabaseProxy: 'db.example.com:5432', + defaultMaxBaseDBConnections: 10, + } as never, + migrationGuard as never + ); + + await expect(guardedService.create(baseId)).rejects.toThrow(migrationInProgressMessage); + + expect(migrationGuard.assertBaseWritable).toHaveBeenCalledWith(baseId); + expect(databaseRouter.getDataDatabaseForBase).not.toHaveBeenCalled(); + expect(databaseRouter.dataPrismaExecutorForBase).not.toHaveBeenCalled(); + expect(prismaService.$tx).not.toHaveBeenCalled(); + }); + + it('rejects readonly connection removal while the base space is migrating', async () => { + const migrationError = new Error(migrationInProgressMessage); + const prismaService = { $tx: vi.fn() }; + const databaseRouter = { + dataPrismaExecutorForBase: vi.fn(), + }; + const migrationGuard = { + assertBaseWritable: vi.fn().mockRejectedValue(migrationError), + }; + const guardedService = new DbConnectionService( + prismaService as never, + databaseRouter as never, + {} as never, + { driver: DriverClient.Pg } as never, + {} as never, + { + publicDatabaseProxy: 'db.example.com:5432', + defaultMaxBaseDBConnections: 10, + } as never, + migrationGuard as never + ); + + await expect(guardedService.remove(baseId)).rejects.toThrow(migrationInProgressMessage); + + expect(migrationGuard.assertBaseWritable).toHaveBeenCalledWith(baseId); + expect(databaseRouter.dataPrismaExecutorForBase).not.toHaveBeenCalled(); + expect(prismaService.$tx).not.toHaveBeenCalled(); + }); }); diff --git a/apps/nestjs-backend/src/features/base/db-connection.service.ts b/apps/nestjs-backend/src/features/base/db-connection.service.ts index 3dedd78688..0906b8e94a 100644 --- a/apps/nestjs-backend/src/features/base/db-connection.service.ts +++ b/apps/nestjs-backend/src/features/base/db-connection.service.ts @@ -1,5 +1,5 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import type { IDsn } from '@teable/core'; import { DriverClient, HttpErrorCode, parseDsn } from '@teable/core'; @@ -14,6 +14,20 @@ import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; import { DatabaseRouter } from '../../global/database-router.service'; import { DATA_KNEX } from '../../global/knex'; +import { SpaceDataDbMigrationGuardService } from '../space/space-data-db-migration-guard.service'; + +const readonlyCapabilityErrorCodes = new Set(['0A000', '42501']); +const readonlyCapabilityErrorMessage = + /permission denied|insufficient privilege|must be owner|not allowed to create role|cannot create role/i; + +type IReadonlyDsnTarget = + | { + available: true; + host: string; + port: number; + db: string; + } + | { available: false }; @Injectable() export class DbConnectionService { @@ -25,9 +39,45 @@ export class DbConnectionService { private readonly configService: ConfigService, @InjectDbProvider() private readonly dbProvider: IDbProvider, @InjectModel(DATA_KNEX) private readonly knex: Knex, - @BaseConfig() private readonly baseConfig: IBaseConfig + @BaseConfig() private readonly baseConfig: IBaseConfig, + @Optional() + private readonly spaceDataDbMigrationGuard?: SpaceDataDbMigrationGuardService ) {} + private async assertBaseWritable(baseId: string) { + await this.spaceDataDbMigrationGuard?.assertBaseWritable(baseId); + } + + private isReadonlyCapabilityError(error: unknown): boolean { + if (error instanceof CustomHttpException) { + return false; + } + + const candidate = error as { code?: unknown; message?: unknown }; + const code = typeof candidate.code === 'string' ? candidate.code : undefined; + const message = typeof candidate.message === 'string' ? candidate.message : undefined; + + return ( + (code != null && readonlyCapabilityErrorCodes.has(code)) || + (message != null && readonlyCapabilityErrorMessage.test(message)) + ); + } + + private throwReadonlyUnavailable(error: unknown, action: 'create' | 'remove'): never { + if (!this.isReadonlyCapabilityError(error)) { + throw error; + } + + throw new CustomHttpException( + 'Readonly database connection is unavailable for this data database', + HttpErrorCode.DATABASE_CONNECTION_UNAVAILABLE, + { + action, + reason: 'readonly_role_privilege_unavailable', + } + ); + } + private getUrlFromDsn(dsn: IDsn): string { const { driver, host, port, db, user, pass, params } = dsn; if (driver !== DriverClient.Pg) { @@ -49,6 +99,39 @@ export class DbConnectionService { return `postgresql://${user}:${pass}@${host}:${port}/${db}?${paramString}`; } + private getDefaultReadonlyDsnTarget(db: string): IReadonlyDsnTarget { + const publicDatabaseProxy = this.baseConfig.publicDatabaseProxy; + if (!publicDatabaseProxy) { + this.logger.error('PUBLIC_DATABASE_PROXY is not found in env'); + return { available: false }; + } + + const { hostname, port } = new URL(`https://${publicDatabaseProxy}`); + return { + available: true, + host: hostname, + port: Number(port), + db, + }; + } + + private async getReadonlyDsnTarget(baseId: string): Promise { + const resolvedDataDb = await this.databaseRouter.getDataDatabaseForBase(baseId); + const { db, host, port } = parseDsn(resolvedDataDb.url); + const database = db ?? ''; + + if (resolvedDataDb.isMetaFallback) { + return this.getDefaultReadonlyDsnTarget(database); + } + + return { + available: true, + host, + port: Number(port), + db: database, + }; + } + async remove(baseId: string) { if (this.dbProvider.driver !== DriverClient.Pg) { throw new CustomHttpException('Unsupported database driver', HttpErrorCode.VALIDATION_ERROR, { @@ -60,68 +143,73 @@ export class DbConnectionService { }, }); } + await this.assertBaseWritable(baseId); const readOnlyRole = `read_only_role_${baseId}`; const schemaName = baseId; - return this.prismaService.$tx(async (prisma) => { - // Verify if the base exists and if the user is the owner - await prisma.base - .findFirstOrThrow({ - where: { id: baseId, deletedTime: null }, - }) - .catch(() => { - throw new CustomHttpException( - 'Only the base owner can remove a db connection', - HttpErrorCode.RESTRICTED_RESOURCE, - { - localization: { - i18nKey: 'httpErrors.dbConnection.onlyOwnerCanRemove', - context: { - baseId, + try { + return await this.prismaService.$tx(async (prisma) => { + // Verify if the base exists and if the user is the owner + await prisma.base + .findFirstOrThrow({ + where: { id: baseId, deletedTime: null }, + }) + .catch(() => { + throw new CustomHttpException( + 'Only the base owner can remove a db connection', + HttpErrorCode.RESTRICTED_RESOURCE, + { + localization: { + i18nKey: 'httpErrors.dbConnection.onlyOwnerCanRemove', + context: { + baseId, + }, }, - }, - } - ); + } + ); + }); + + const dataPrisma = await this.databaseRouter.dataPrismaExecutorForBase(baseId, { + useTransaction: true, }); - const dataPrisma = await this.databaseRouter.dataPrismaExecutorForBase(baseId, { - useTransaction: true, - }); + // Revoke permissions from the role for the schema + await dataPrisma.$executeRawUnsafe( + this.knex.raw('REVOKE USAGE ON SCHEMA ?? FROM ??', [schemaName, readOnlyRole]).toQuery() + ); - // Revoke permissions from the role for the schema - await dataPrisma.$executeRawUnsafe( - this.knex.raw('REVOKE USAGE ON SCHEMA ?? FROM ??', [schemaName, readOnlyRole]).toQuery() - ); - - await dataPrisma.$executeRawUnsafe( - this.knex - .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? REVOKE ALL ON TABLES FROM ??`, [ - schemaName, - readOnlyRole, - ]) - .toQuery() - ); - - // Revoke permissions from the role for the tables in schema - await dataPrisma.$executeRawUnsafe( - this.knex - .raw('REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA ?? FROM ??', [ - schemaName, - readOnlyRole, - ]) - .toQuery() - ); - - // drop the role - await dataPrisma.$executeRawUnsafe( - this.knex.raw('DROP ROLE IF EXISTS ??', [readOnlyRole]).toQuery() - ); - - await prisma.base.update({ - where: { id: baseId }, - data: { schemaPass: null }, + await dataPrisma.$executeRawUnsafe( + this.knex + .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? REVOKE ALL ON TABLES FROM ??`, [ + schemaName, + readOnlyRole, + ]) + .toQuery() + ); + + // Revoke permissions from the role for the tables in schema + await dataPrisma.$executeRawUnsafe( + this.knex + .raw('REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA ?? FROM ??', [ + schemaName, + readOnlyRole, + ]) + .toQuery() + ); + + // drop the role + await dataPrisma.$executeRawUnsafe( + this.knex.raw('DROP ROLE IF EXISTS ??', [readOnlyRole]).toQuery() + ); + + await prisma.base.update({ + where: { id: baseId }, + data: { schemaPass: null }, + }); }); - }); + } catch (error) { + this.throwReadonlyUnavailable(error, 'remove'); + } } private async roleExits(baseId: string, role: string): Promise { @@ -146,13 +234,6 @@ export class DbConnectionService { } const readOnlyRole = `read_only_role_${baseId}`; - const publicDatabaseProxy = this.baseConfig.publicDatabaseProxy; - if (!publicDatabaseProxy) { - this.logger.error('PUBLIC_DATABASE_PROXY is not found in env'); - return null; - } - - const { hostname: dbHostProxy, port: dbPortProxy } = new URL(`https://${publicDatabaseProxy}`); // Check if the base exists and the user is the owner const base = await this.prismaService.base.findFirst({ @@ -178,15 +259,17 @@ export class DbConnectionService { const currentConnections = await this.getConnectionCount(baseId, readOnlyRole); - const databaseUrl = await this.databaseRouter.getDataDatabaseUrlForBase(baseId); - const { db } = parseDsn(databaseUrl); + const readonlyTarget = await this.getReadonlyDsnTarget(baseId); + if (!readonlyTarget.available) { + return null; + } // Construct the DSN for the read-only role const dsn: IDbConnectionVo['dsn'] = { driver: DriverClient.Pg, - host: dbHostProxy, - port: Number(dbPortProxy), - db: db, + host: readonlyTarget.host, + port: readonlyTarget.port, + db: readonlyTarget.db, user: readOnlyRole, pass: base.schemaPass, params: { @@ -218,100 +301,99 @@ export class DbConnectionService { */ async create(baseId: string) { if (this.dbProvider.driver === DriverClient.Pg) { + await this.assertBaseWritable(baseId); + const readOnlyRole = `read_only_role_${baseId}`; const schemaName = baseId; const password = nanoid(); - const publicDatabaseProxy = this.baseConfig.publicDatabaseProxy; - if (!publicDatabaseProxy) { - this.logger.error('PUBLIC_DATABASE_PROXY is not found in env'); + const readonlyTarget = await this.getReadonlyDsnTarget(baseId); + if (!readonlyTarget.available) { return null; } - const { hostname: dbHostProxy, port: dbPortProxy } = new URL( - `https://${publicDatabaseProxy}` - ); - const databaseUrl = await this.databaseRouter.getDataDatabaseUrlForBase(baseId); - const { db } = parseDsn(databaseUrl); - - return this.prismaService.$tx(async (prisma) => { - await prisma.base - .findFirstOrThrow({ - where: { id: baseId, deletedTime: null }, - }) - .catch(() => { - throw new CustomHttpException( - 'Only base owner can create db connection', - HttpErrorCode.RESTRICTED_RESOURCE, - { - localization: { - i18nKey: 'httpErrors.dbConnection.onlyOwnerCanCreate', - context: { - baseId, + try { + return await this.prismaService.$tx(async (prisma) => { + await prisma.base + .findFirstOrThrow({ + where: { id: baseId, deletedTime: null }, + }) + .catch(() => { + throw new CustomHttpException( + 'Only base owner can create db connection', + HttpErrorCode.RESTRICTED_RESOURCE, + { + localization: { + i18nKey: 'httpErrors.dbConnection.onlyOwnerCanCreate', + context: { + baseId, + }, }, - }, - } - ); - }); + } + ); + }); - await prisma.base.update({ - where: { id: baseId }, - data: { schemaPass: password }, - }); - - const dataPrisma = await this.databaseRouter.dataPrismaExecutorForBase(baseId, { - useTransaction: true, - }); + await prisma.base.update({ + where: { id: baseId }, + data: { schemaPass: password }, + }); - // Create a read-only role - await dataPrisma.$executeRawUnsafe( - this.knex - .raw( - `CREATE ROLE ?? WITH LOGIN PASSWORD ? NOSUPERUSER NOINHERIT NOCREATEDB NOCREATEROLE NOREPLICATION CONNECTION LIMIT ?`, - [readOnlyRole, password, this.baseConfig.defaultMaxBaseDBConnections] - ) - .toQuery() - ); + const dataPrisma = await this.databaseRouter.dataPrismaExecutorForBase(baseId, { + useTransaction: true, + }); - await dataPrisma.$executeRawUnsafe( - this.knex.raw(`GRANT USAGE ON SCHEMA ?? TO ??`, [schemaName, readOnlyRole]).toQuery() - ); + // Create a read-only role + await dataPrisma.$executeRawUnsafe( + this.knex + .raw( + `CREATE ROLE ?? WITH LOGIN PASSWORD ? NOSUPERUSER NOINHERIT NOCREATEDB NOCREATEROLE NOREPLICATION CONNECTION LIMIT ?`, + [readOnlyRole, password, this.baseConfig.defaultMaxBaseDBConnections] + ) + .toQuery() + ); - await dataPrisma.$executeRawUnsafe( - this.knex - .raw(`GRANT SELECT ON ALL TABLES IN SCHEMA ?? TO ??`, [schemaName, readOnlyRole]) - .toQuery() - ); + await dataPrisma.$executeRawUnsafe( + this.knex.raw(`GRANT USAGE ON SCHEMA ?? TO ??`, [schemaName, readOnlyRole]).toQuery() + ); - await dataPrisma.$executeRawUnsafe( - this.knex - .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? GRANT SELECT ON TABLES TO ??`, [ - schemaName, - readOnlyRole, - ]) - .toQuery() - ); + await dataPrisma.$executeRawUnsafe( + this.knex + .raw(`GRANT SELECT ON ALL TABLES IN SCHEMA ?? TO ??`, [schemaName, readOnlyRole]) + .toQuery() + ); - const dsn: IDbConnectionVo['dsn'] = { - driver: DriverClient.Pg, - host: dbHostProxy, - port: Number(dbPortProxy), - db: db, - user: readOnlyRole, - pass: password, - params: { - schema: baseId, - }, - }; + await dataPrisma.$executeRawUnsafe( + this.knex + .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? GRANT SELECT ON TABLES TO ??`, [ + schemaName, + readOnlyRole, + ]) + .toQuery() + ); - return { - dsn, - connection: { - max: this.baseConfig.defaultMaxBaseDBConnections, - current: 0, - }, - url: this.getUrlFromDsn(dsn), - }; - }); + const dsn: IDbConnectionVo['dsn'] = { + driver: DriverClient.Pg, + host: readonlyTarget.host, + port: readonlyTarget.port, + db: readonlyTarget.db, + user: readOnlyRole, + pass: password, + params: { + schema: baseId, + }, + }; + + return { + dsn, + connection: { + max: this.baseConfig.defaultMaxBaseDBConnections, + current: 0, + }, + url: this.getUrlFromDsn(dsn), + }; + }); + } catch (error) { + this.throwReadonlyUnavailable(error, 'create'); + } } throw new CustomHttpException('Unsupported database driver', HttpErrorCode.VALIDATION_ERROR, { diff --git a/apps/nestjs-backend/src/features/canary/interceptors/v2-indicator.interceptor.spec.ts b/apps/nestjs-backend/src/features/canary/interceptors/v2-indicator.interceptor.spec.ts index 3861189d51..c584e7997f 100644 --- a/apps/nestjs-backend/src/features/canary/interceptors/v2-indicator.interceptor.spec.ts +++ b/apps/nestjs-backend/src/features/canary/interceptors/v2-indicator.interceptor.spec.ts @@ -1,6 +1,6 @@ import type { CallHandler, ExecutionContext } from '@nestjs/common'; -import { of } from 'rxjs'; -import { describe, expect, it, vi } from 'vitest'; +import { of, throwError } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TEABLE_REQUEST_ATTRIBUTION, V2IndicatorInterceptor, @@ -32,10 +32,13 @@ vi.mock('@sentry/nestjs', () => ({ })); describe('V2IndicatorInterceptor', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('records request attribution separately from v2 route tags', () => { const setAttributes = vi.fn(); getActiveSpan.mockReturnValue({ setAttributes }); - sentryScope.setTag.mockReset(); const cls = { get: vi.fn((key: string) => { @@ -76,4 +79,81 @@ describe('V2IndicatorInterceptor', () => { }); expect(sentryScope.setTag).toHaveBeenCalledWith(TEABLE_REQUEST_ATTRIBUTION, 'v2'); }); + + it('uses the final cls state after controller fallback', () => { + const values: Record = { + useV2: true, + v2Reason: 'canary', + v2Feature: 'importCsv', + }; + const cls = { + get: vi.fn((key: string) => values[key]), + }; + + const response = { setHeader: vi.fn() }; + const request = { + method: 'POST', + path: '/api/import/bse123', + params: {}, + }; + const context = { + switchToHttp: () => ({ + getResponse: () => response, + getRequest: () => request, + }), + } as unknown as ExecutionContext; + const next = { + handle: () => { + values.useV2 = false; + values.v2Reason = 'unsupported_feature'; + return of('ok'); + }, + } as CallHandler; + + const interceptor = new V2IndicatorInterceptor(cls as never); + interceptor.intercept(context, next).subscribe(); + + expect(response.setHeader).toHaveBeenCalledWith(X_TEABLE_V2_HEADER, 'false'); + expect(response.setHeader).toHaveBeenCalledWith( + X_TEABLE_V2_REASON_HEADER, + 'unsupported_feature' + ); + expect(response.setHeader).toHaveBeenCalledWith(X_TEABLE_V2_FEATURE_HEADER, 'importCsv'); + }); + + it('stamps the guard attribution before a handler error', () => { + const cls = { + get: vi.fn((key: string) => { + const values: Record = { + useV2: true, + v2Reason: 'space_feature', + v2Feature: 'deleteField', + }; + return values[key]; + }), + }; + + const response = { setHeader: vi.fn() }; + const context = { + switchToHttp: () => ({ + getResponse: () => response, + getRequest: () => ({ + method: 'DELETE', + path: '/api/table/tbl123/field', + params: { tableId: 'tbl123' }, + }), + }), + } as unknown as ExecutionContext; + const exception = new Error('delete failed'); + const next = { handle: () => throwError(() => exception) } as CallHandler; + + const interceptor = new V2IndicatorInterceptor(cls as never); + interceptor.intercept(context, next).subscribe({ error: () => undefined }); + + expect(response.setHeader).toHaveBeenCalledWith(X_TEABLE_V2_HEADER, 'true'); + expect(response.setHeader).toHaveBeenCalledWith(X_TEABLE_V2_REASON_HEADER, 'space_feature'); + expect(response.setHeader).toHaveBeenCalledWith(X_TEABLE_V2_FEATURE_HEADER, 'deleteField'); + expect(sentryScope.setTag).toHaveBeenCalledWith(TEABLE_REQUEST_ATTRIBUTION, 'v2'); + expect(sentryScope.setTag).toHaveBeenCalledWith('teable.v2.feature', 'deleteField'); + }); }); diff --git a/apps/nestjs-backend/src/features/canary/interceptors/v2-indicator.interceptor.ts b/apps/nestjs-backend/src/features/canary/interceptors/v2-indicator.interceptor.ts index 8a5ecd8d74..c70b1d5fe9 100644 --- a/apps/nestjs-backend/src/features/canary/interceptors/v2-indicator.interceptor.ts +++ b/apps/nestjs-backend/src/features/canary/interceptors/v2-indicator.interceptor.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/naming-convention */ import { Injectable, type NestInterceptor, @@ -6,48 +5,24 @@ import { type CallHandler, Logger, } from '@nestjs/common'; -import * as Sentry from '@sentry/nestjs'; import { trace } from '@opentelemetry/api'; import type { Response } from 'express'; import { ClsService } from 'nestjs-cls'; import type { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; import type { IClsStore } from '../../../types/cls'; - -export const X_TEABLE_V2_HEADER = 'x-teable-v2'; -export const X_TEABLE_V2_REASON_HEADER = 'x-teable-v2-reason'; -export const X_TEABLE_V2_FEATURE_HEADER = 'x-teable-v2-feature'; -export const TEABLE_REQUEST_ATTRIBUTION = 'teable.request.attribution'; - -type SentryScopeLike = { - setTag(key: string, value: string): void; -}; - -const getSentryScopes = (): SentryScopeLike[] => { - const sentryApi = Sentry as unknown as { - getCurrentScope?: () => SentryScopeLike | undefined; - getIsolationScope?: () => SentryScopeLike | undefined; - getCurrentHub?: () => { getScope?: () => SentryScopeLike | undefined }; - }; - - const scopes = [ - sentryApi.getCurrentScope?.(), - sentryApi.getIsolationScope?.(), - sentryApi.getCurrentHub?.()?.getScope?.(), - ].filter((scope): scope is SentryScopeLike => Boolean(scope)); - - return [...new Set(scopes)]; -}; - -const setSentryTag = (key: string, value: string | undefined) => { - if (value == null) { - return; - } - - for (const scope of getSentryScopes()) { - scope.setTag(key, value); - } -}; +import { + getV2Attribution, + getV2AttributionSpanAttributes, + setV2AttributionHeaders, + setV2AttributionOnCurrentSentryScopes, +} from '../v2-attribution'; +export { + TEABLE_REQUEST_ATTRIBUTION, + X_TEABLE_V2_FEATURE_HEADER, + X_TEABLE_V2_HEADER, + X_TEABLE_V2_REASON_HEADER, +} from '../v2-attribution'; /** * Interceptor that adds V2 indicator to response headers and logs. @@ -65,56 +40,22 @@ export class V2IndicatorInterceptor implements NestInterceptor { constructor(private readonly cls: ClsService) {} - private setHeaderIfPossible(response: Response, name: string, value: string) { - if (response.headersSent || response.writableEnded || response.destroyed) { - return; - } - - response.setHeader(name, value); - } - intercept(context: ExecutionContext, next: CallHandler): Observable { - const useV2 = this.cls.get('useV2'); - const v2Reason = this.cls.get('v2Reason'); - const v2Feature = this.cls.get('v2Feature'); - const response = context.switchToHttp().getResponse(); const request = context.switchToHttp().getRequest(); - // Add V2 indicator headers regardless of useV2 value - // This allows clients to understand why V2 was or wasn't used - this.setHeaderIfPossible(response, X_TEABLE_V2_HEADER, useV2 ? 'true' : 'false'); - if (v2Reason) { - this.setHeaderIfPossible(response, X_TEABLE_V2_REASON_HEADER, v2Reason); - } - if (v2Feature) { - this.setHeaderIfPossible(response, X_TEABLE_V2_FEATURE_HEADER, v2Feature); - } - - // Mirror V2 indicators into Sentry tags so issue search can distinguish v1/v2 requests. - setSentryTag('teable.version', useV2 ? 'v2' : 'v1'); - setSentryTag('teable.v2.enabled', useV2 ? 'true' : 'false'); - setSentryTag('teable.v2.reason', v2Reason); - setSentryTag('teable.v2.feature', v2Feature); - setSentryTag(TEABLE_REQUEST_ATTRIBUTION, useV2 ? 'v2' : 'v1'); - - // Add span attributes for tracing - const span = trace.getActiveSpan(); - if (span) { - span.setAttributes({ - [TEABLE_REQUEST_ATTRIBUTION]: useV2 ? 'v2' : 'v1', - 'teable.v2.enabled': useV2 ?? false, - ...(v2Reason && { 'teable.v2.reason': v2Reason }), - ...(v2Feature && { 'teable.v2.feature': v2Feature }), - }); - } - - if (!useV2) { - return next.handle(); - } + // Stamp the guard decision before the handler runs so thrown requests keep attribution. + this.annotateRequest(response); return next.handle().pipe( tap(() => { + // Re-stamp after success to reflect any controller-level fallback. + const attribution = this.annotateRequest(response); + + if (!attribution.useV2) { + return; + } + // Log V2 usage for tracing this.logger.debug({ message: 'V2 implementation used', @@ -122,10 +63,27 @@ export class V2IndicatorInterceptor implements NestInterceptor { path: request.path, tableId: request.params?.tableId, useV2: true, - v2Reason, - v2Feature, + v2Reason: attribution.v2Reason, + v2Feature: attribution.v2Feature, }); }) ); } + + private annotateRequest(response: Response) { + const attribution = getV2Attribution(this.cls); + + setV2AttributionHeaders(response, attribution); + setV2AttributionOnCurrentSentryScopes(attribution); + + const span = trace.getActiveSpan(); + if (span) { + const attributes = getV2AttributionSpanAttributes(attribution); + if (Object.keys(attributes).length) { + span.setAttributes(attributes); + } + } + + return attribution; + } } diff --git a/apps/nestjs-backend/src/features/canary/v2-attribution.ts b/apps/nestjs-backend/src/features/canary/v2-attribution.ts new file mode 100644 index 0000000000..b5db144e82 --- /dev/null +++ b/apps/nestjs-backend/src/features/canary/v2-attribution.ts @@ -0,0 +1,141 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import * as Sentry from '@sentry/nestjs'; +import type { V2Feature } from '@teable/openapi'; +import type { Response } from 'express'; +import type { ClsService } from 'nestjs-cls'; +import type { IClsStore, IV2Reason } from '../../types/cls'; + +export const X_TEABLE_V2_HEADER = 'x-teable-v2'; +export const X_TEABLE_V2_REASON_HEADER = 'x-teable-v2-reason'; +export const X_TEABLE_V2_FEATURE_HEADER = 'x-teable-v2-feature'; +export const TEABLE_REQUEST_ATTRIBUTION = 'teable.request.attribution'; + +export interface IV2Attribution { + useV2?: boolean; + v2Reason?: IV2Reason; + v2Feature?: V2Feature; +} + +type SentryScopeLike = { + setTag(key: string, value: string): void; +}; + +export const getV2Attribution = (cls?: ClsService): IV2Attribution => { + if (!cls) { + return {}; + } + + try { + return { + useV2: cls.get('useV2'), + v2Reason: cls.get('v2Reason'), + v2Feature: cls.get('v2Feature'), + }; + } catch { + return {}; + } +}; + +const getRequestAttribution = (useV2: boolean | undefined): 'v1' | 'v2' | undefined => { + if (useV2 === true) { + return 'v2'; + } + + if (useV2 === false) { + return 'v1'; + } + + return undefined; +}; + +const getSentryScopes = (): SentryScopeLike[] => { + const sentryApi = Sentry as unknown as { + getCurrentScope?: () => SentryScopeLike | undefined; + getIsolationScope?: () => SentryScopeLike | undefined; + getCurrentHub?: () => { getScope?: () => SentryScopeLike | undefined }; + }; + + const scopes = [ + sentryApi.getCurrentScope?.(), + sentryApi.getIsolationScope?.(), + sentryApi.getCurrentHub?.()?.getScope?.(), + ].filter((scope): scope is SentryScopeLike => Boolean(scope)); + + return [...new Set(scopes)]; +}; + +const setSentryTag = ( + scope: SentryScopeLike, + key: string, + value: string | boolean | undefined +) => { + if (value == null) { + return; + } + + scope.setTag(key, String(value)); +}; + +export const setV2AttributionOnSentryScope = ( + scope: SentryScopeLike, + attribution: IV2Attribution +) => { + const requestAttribution = getRequestAttribution(attribution.useV2); + + setSentryTag(scope, 'teable.version', requestAttribution); + setSentryTag(scope, 'teable.v2.enabled', attribution.useV2); + setSentryTag(scope, 'teable.v2.reason', attribution.v2Reason); + setSentryTag(scope, 'teable.v2.feature', attribution.v2Feature); + setSentryTag(scope, TEABLE_REQUEST_ATTRIBUTION, requestAttribution); +}; + +export const setV2AttributionOnCurrentSentryScopes = (attribution: IV2Attribution) => { + for (const scope of getSentryScopes()) { + setV2AttributionOnSentryScope(scope, attribution); + } +}; + +export const getV2AttributionSpanAttributes = (attribution: IV2Attribution) => { + const requestAttribution = getRequestAttribution(attribution.useV2); + + return { + ...(requestAttribution && { [TEABLE_REQUEST_ATTRIBUTION]: requestAttribution }), + ...(attribution.useV2 != null && { 'teable.v2.enabled': attribution.useV2 }), + ...(attribution.v2Reason && { 'teable.v2.reason': attribution.v2Reason }), + ...(attribution.v2Feature && { 'teable.v2.feature': attribution.v2Feature }), + }; +}; + +export const getV2AttributionLogContext = (attribution: IV2Attribution) => { + const requestAttribution = getRequestAttribution(attribution.useV2); + + if (!requestAttribution && !attribution.v2Reason && !attribution.v2Feature) { + return undefined; + } + + return { + attribution: requestAttribution, + enabled: attribution.useV2, + reason: attribution.v2Reason, + feature: attribution.v2Feature, + }; +}; + +export const setV2AttributionHeaders = ( + response: Response, + attribution: IV2Attribution +) => { + if (response.headersSent || response.writableEnded || response.destroyed) { + return; + } + + if (attribution.useV2 != null) { + response.setHeader(X_TEABLE_V2_HEADER, attribution.useV2 ? 'true' : 'false'); + } + if (attribution.v2Reason) { + response.setHeader(X_TEABLE_V2_REASON_HEADER, attribution.v2Reason); + } + if (attribution.v2Feature) { + response.setHeader(X_TEABLE_V2_FEATURE_HEADER, attribution.v2Feature); + } +}; diff --git a/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts b/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts index 7c7ba4837d..993d8647f8 100644 --- a/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts +++ b/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts @@ -36,6 +36,8 @@ import { import type { IClsStore } from '../../types/cls'; import { getMaxLevelRole } from '../../utils/get-max-level-role'; import { getPublicFullStorageUrl } from '../attachments/plugins/utils'; +import { AuditScope } from '../audit/audit-scope'; +import { Audit } from '../audit/audit.decorator'; @Injectable() export class CollaboratorService { @@ -43,6 +45,7 @@ export class CollaboratorService { private readonly prismaService: PrismaService, private readonly cls: ClsService, private readonly eventEmitterService: EventEmitterService, + private readonly audit: AuditScope, @InjectModel('CUSTOM_KNEX') private readonly knex: Knex, @InjectDbProvider() private readonly dbProvider: IDbProvider, @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig @@ -129,7 +132,15 @@ export class CollaboratorService { protected async getBaseCollaboratorBuilder( knex: Knex.QueryBuilder, baseId: string, - options?: { includeSystem?: boolean; search?: string; type?: PrincipalType; role?: IRole[] } + options?: { + includeSystem?: boolean; + search?: string; + type?: PrincipalType; + role?: IRole[]; + // Defaults to true. Set false (e.g. for anonymous share views) to avoid + // turning the picker search into an email-based membership oracle. + searchByEmail?: boolean; + } ) { const base = await this.prismaService .txClient() @@ -139,17 +150,18 @@ export class CollaboratorService { .from('collaborator') .leftJoin('users', 'collaborator.principal_id', 'users.id') .whereIn('collaborator.resource_id', [baseId, base.spaceId]); - const { includeSystem, search, type, role } = options ?? {}; + const { includeSystem, search, type, role, searchByEmail = true } = options ?? {}; if (!includeSystem) { builder.where((db) => { return db.whereNull('users.is_system').orWhere('users.is_system', false); }); } if (search) { - this.dbProvider.searchBuilder(builder, [ - ['users.name', search], - ['users.email', search], - ]); + const searchFields: [string, string][] = [['users.name', search]]; + if (searchByEmail) { + searchFields.push(['users.email', search]); + } + this.dbProvider.searchBuilder(builder, searchFields); } if (role?.length) { @@ -198,6 +210,7 @@ export class CollaboratorService { user_email: 'users.email', user_avatar: 'users.avatar', user_is_system: 'users.is_system', + last_sign_time: 'users.last_sign_time', }); builder.orderBy('collaborator.created_time', options?.orderBy ?? 'desc'); } @@ -228,6 +241,7 @@ export class CollaboratorService { user_email: string; user_avatar: string; user_is_system: boolean | null; + last_sign_time: Date | null; }[] >(builder.toQuery()); @@ -239,6 +253,7 @@ export class CollaboratorService { avatar: collaborator.user_avatar ? getPublicFullStorageUrl(collaborator.user_avatar) : null, role: collaborator.role_name as IRole, createdTime: collaborator.created_time.toISOString(), + lastSignTime: collaborator.last_sign_time?.toISOString() ?? null, resourceType: collaborator.resource_type as CollaboratorType, isSystem: collaborator.user_is_system || undefined, })); @@ -429,6 +444,7 @@ export class CollaboratorService { user_email: 'users.email', user_avatar: 'users.avatar', user_is_system: 'users.is_system', + last_sign_time: 'users.last_sign_time', }); builder.orderBy('collaborator.created_time', options?.orderBy ?? 'desc'); } @@ -460,6 +476,7 @@ export class CollaboratorService { user_email: string; user_avatar: string; user_is_system: boolean | null; + last_sign_time: Date | null; }[] >(builder.toQuery()); @@ -474,6 +491,7 @@ export class CollaboratorService { avatar: collaborator.user_avatar ? getPublicFullStorageUrl(collaborator.user_avatar) : null, role: collaborator.role_name as IRole, createdTime: collaborator.created_time.toISOString(), + lastSignTime: collaborator.last_sign_time?.toISOString() ?? null, base: baseMap[collaborator.resource_id], }; }); @@ -634,6 +652,18 @@ export class CollaboratorService { .txClient() .base.findUniqueOrThrow({ where: { id: resourceId }, select: { spaceId: true } }); spaceId = space.spaceId; + // Base-scope audit (space-scope audit not required by current spec). + await this.audit.emitAtomic({ + action: Events.BASE_COLLABORATOR_DELETE, + resourceId, + params: { + baseId: resourceId, + spaceId, + principalId, + principalType, + oldRole: targetColl.roleName, + }, + }); } this.eventEmitterService.emitAsync( Events.COLLABORATOR_DELETE, @@ -721,6 +751,22 @@ export class CollaboratorService { .txClient() .base.findUniqueOrThrow({ where: { id: resourceId }, select: { spaceId: true } }); spaceId = space.spaceId; + // Base-scope audit. Only emit when role actually changes — same-role PATCHes + // (e.g. no-op idempotency calls) shouldn't bloat the audit log. + if (targetColl.roleName !== role) { + await this.audit.emitAtomic({ + action: Events.BASE_COLLABORATOR_UPDATE, + resourceId, + params: { + baseId: resourceId, + spaceId, + principalId, + principalType, + oldRole: targetColl.roleName, + newRole: role, + }, + }); + } } else if (resourceType === CollaboratorType.Space) { spaceId = resourceId; } @@ -769,6 +815,20 @@ export class CollaboratorService { }; } + @Audit({ + action: Events.BASE_COLLABORATOR_CREATE, + resourceId: (input: { baseId: string }) => input.baseId, + params: (input: { + baseId: string; + role: IBaseRole; + collaborators: { principalId: string; principalType: PrincipalType }[]; + }) => ({ + baseId: input.baseId, + role: input.role, + collaborators: input.collaborators, + }), + emit: true, + }) async createBaseCollaborator({ collaborators, baseId, @@ -1036,7 +1096,10 @@ export class CollaboratorService { return this.getTotalBase(baseId, options); } - async getUserCollaborators(baseId: string, options?: IListBaseCollaboratorUserRo) { + async getUserCollaborators( + baseId: string, + options?: IListBaseCollaboratorUserRo & { searchByEmail?: boolean } + ) { const { skip = 0, take = 50 } = options ?? {}; const builder = this.knex.queryBuilder(); await this.getBaseCollaboratorBuilder(builder, baseId, options); diff --git a/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts b/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts index bcff52445f..145133ae42 100644 --- a/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts +++ b/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts @@ -1560,12 +1560,12 @@ export class FieldConvertingService { dbTableName, dbFieldName ); + const dropUniqueIndexSqls = unique + ? this.fieldService.getDropUniqueIndexSqls(dbTableName, matchedIndexes) + : []; const fieldValidationQuery = this.knex.schema .alterTable(dbTableName, (table) => { - if (unique) { - matchedIndexes.forEach((indexName) => table.dropUnique([dbFieldName], indexName)); - } if (notNull) table.setNullable(dbFieldName); }) .toSQL(); @@ -1573,6 +1573,7 @@ export class FieldConvertingService { const executeSqls = fieldValidationQuery .filter((s) => !s.sql.startsWith('PRAGMA')) .map(({ sql }) => sql); + executeSqls.push(...dropUniqueIndexSqls); for (const sql of executeSqls) { await this.databaseRouter.executeDataPrismaForTable(tableId, sql, { diff --git a/apps/nestjs-backend/src/features/field/field-duplicate/field-duplicate.service.ts b/apps/nestjs-backend/src/features/field/field-duplicate/field-duplicate.service.ts index 22f45640c6..b4a5a2ca33 100644 --- a/apps/nestjs-backend/src/features/field/field-duplicate/field-duplicate.service.ts +++ b/apps/nestjs-backend/src/features/field/field-duplicate/field-duplicate.service.ts @@ -37,6 +37,10 @@ import type { IFieldInstance } from '../model/factory'; import { createFieldInstanceByRaw } from '../model/factory'; import { FieldOpenApiService } from '../open-api/field-open-api.service'; +type FieldDuplicateCreateOptions = { + skipComputedEvaluation?: boolean; +}; + @Injectable() export class FieldDuplicateService { private readonly logger = new Logger(FieldDuplicateService.name); @@ -54,7 +58,8 @@ export class FieldDuplicateService { async createCommonFields( fields: IFieldWithTableIdJson[], fieldMap: Record, - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + createOptions: FieldDuplicateCreateOptions = {} ) { const byTable = new Map(); for (const field of fields) { @@ -77,7 +82,8 @@ export class FieldDuplicateService { const newFieldVos = await this.fieldOpenApiService.createFieldsByRo( targetTableId, fieldRos, - routingOptions + routingOptions, + createOptions ); for (let index = 0; index < tableFields.length; index++) { @@ -104,7 +110,8 @@ export class FieldDuplicateService { async createButtonFields( fields: IFieldWithTableIdJson[], fieldMap: Record, - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + createOptions: FieldDuplicateCreateOptions = {} ) { const newFields = fields.map((field) => { const { options } = field; @@ -116,13 +123,14 @@ export class FieldDuplicateService { }, }; }) as IFieldWithTableIdJson[]; - return await this.createCommonFields(newFields, fieldMap, routingOptions); + return await this.createCommonFields(newFields, fieldMap, routingOptions, createOptions); } async createTmpPrimaryFormulaFields( primaryFormulaFields: IFieldWithTableIdJson[], fieldMap: Record, - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + createOptions: FieldDuplicateCreateOptions = {} ) { const byTable = new Map(); for (const field of primaryFormulaFields) { @@ -148,7 +156,8 @@ export class FieldDuplicateService { const newFields = await this.fieldOpenApiService.createFieldsByRo( targetTableId, fieldRos, - routingOptions + routingOptions, + createOptions ); for (let index = 0; index < tableFields.length; index++) { @@ -316,7 +325,8 @@ export class FieldDuplicateService { tableIdMap: Record, fieldMap: Record, fkMap: Record, - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + createOptions: FieldDuplicateCreateOptions = {} ) { const selfLinkFields = linkFields.filter( ({ options, sourceTableId }) => @@ -345,7 +355,7 @@ export class FieldDuplicateService { ({ id }) => ![...selfLinkFields, ...crossBaseLinkFields].map(({ id }) => id).includes(id) ); - await this.createSelfLinkFields(selfLinkFields, fieldMap, fkMap, routingOptions); + await this.createSelfLinkFields(selfLinkFields, fieldMap, fkMap, routingOptions, createOptions); // deal with cross base link fields await this.createCommonLinkFields( @@ -354,7 +364,8 @@ export class FieldDuplicateService { fieldMap, fkMap, true, - routingOptions + routingOptions, + createOptions ); await this.createCommonLinkFields( @@ -363,7 +374,8 @@ export class FieldDuplicateService { fieldMap, fkMap, false, - routingOptions + routingOptions, + createOptions ); } @@ -372,7 +384,8 @@ export class FieldDuplicateService { fields: IFieldWithTableIdJson[], fieldMap: Record, fkMap: Record, - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + createOptions: FieldDuplicateCreateOptions = {} ) { const twoWaySelfLinkFields = fields.filter( ({ options }) => !(options as ILinkFieldOptions).isOneWay @@ -419,7 +432,8 @@ export class FieldDuplicateService { const newFieldVos = await this.fieldOpenApiService.createFieldsByRo( targetTableId, fieldRos, - routingOptions + routingOptions, + createOptions ); const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({ @@ -495,7 +509,8 @@ export class FieldDuplicateService { const newFieldVos = await this.fieldOpenApiService.createFieldsByRo( targetTableId, fieldRos, - routingOptions + routingOptions, + createOptions ); const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({ @@ -542,7 +557,8 @@ export class FieldDuplicateService { fieldMap: Record, fkMap: Record, allowCrossBase: boolean = false, - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + createOptions: FieldDuplicateCreateOptions = {} ) { const oneWayFields = fields.filter(({ options }) => (options as ILinkFieldOptions).isOneWay); const twoWayFields = fields.filter(({ options }) => !(options as ILinkFieldOptions).isOneWay); @@ -575,7 +591,8 @@ export class FieldDuplicateService { const newFieldVos = await this.fieldOpenApiService.createFieldsByRo( targetTableId, fieldRos, - routingOptions + routingOptions, + createOptions ); const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({ @@ -658,7 +675,8 @@ export class FieldDuplicateService { const newFieldVos = await this.fieldOpenApiService.createFieldsByRo( targetTableId, fieldRos, - routingOptions + routingOptions, + createOptions ); const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({ @@ -893,7 +911,8 @@ export class FieldDuplicateService { tableIdMap: Record, fieldMap: Record, scope: 'base' | 'table' = 'base', - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + createOptions: FieldDuplicateCreateOptions = {} ): Promise { if (!dependFields.length) return; @@ -922,7 +941,8 @@ export class FieldDuplicateService { fieldMap, scope, false, - routingOptions + routingOptions, + createOptions ); continue; } @@ -937,7 +957,8 @@ export class FieldDuplicateService { fieldMap, scope, true, - routingOptions + routingOptions, + createOptions ); } else if (!countMap[curField.id] || countMap[curField.id] < maxCount) { dependFields.push(curField); @@ -968,7 +989,8 @@ export class FieldDuplicateService { async bootstrapPrimaryDependencyFields( fields: IFieldWithTableIdJson[], sourceToTargetFieldMap: Record, - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + createOptions: FieldDuplicateCreateOptions = {} ) { for (const field of fields) { if (!field.isPrimary || !field.aiConfig || field.isLookup) continue; @@ -996,7 +1018,8 @@ export class FieldDuplicateService { name, }, undefined, - routingOptions + routingOptions, + createOptions ); await this.replenishmentConstraint(newField.id, targetTableId, order, { @@ -1018,7 +1041,8 @@ export class FieldDuplicateService { sourceToTargetFieldMap: Record, scope: 'base' | 'table' = 'base', hasError = false, - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + createOptions: FieldDuplicateCreateOptions = {} ) { const hasFieldError = Boolean(field.hasError); const isAiConfig = field.aiConfig && !field.isLookup; @@ -1035,7 +1059,8 @@ export class FieldDuplicateService { targetTableId, field, sourceToTargetFieldMap, - routingOptions + routingOptions, + createOptions ); return; } @@ -1057,7 +1082,8 @@ export class FieldDuplicateService { field, tableIdMap, sourceToTargetFieldMap, - routingOptions + routingOptions, + createOptions ); break; case isAiConfig: @@ -1065,7 +1091,8 @@ export class FieldDuplicateService { targetTableId, field as unknown as IFieldInstance, sourceToTargetFieldMap, - routingOptions + routingOptions, + createOptions ); break; case isRollup: @@ -1075,7 +1102,8 @@ export class FieldDuplicateService { field, tableIdMap, sourceToTargetFieldMap, - routingOptions + routingOptions, + createOptions ); break; case isConditionalRollup: @@ -1085,7 +1113,8 @@ export class FieldDuplicateService { field, tableIdMap, sourceToTargetFieldMap, - routingOptions + routingOptions, + createOptions ); break; case isFormula: @@ -1094,7 +1123,8 @@ export class FieldDuplicateService { field, sourceToTargetFieldMap, hasError || hasFieldError, - routingOptions + routingOptions, + createOptions ); } } @@ -1103,7 +1133,8 @@ export class FieldDuplicateService { targetTableId: string, field: IFieldWithTableIdJson, sourceToTargetFieldMap: Record, - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + createOptions: FieldDuplicateCreateOptions = {} ) { const { id, name, description, dbFieldName, order, notNull, unique, isPrimary } = field; @@ -1121,7 +1152,8 @@ export class FieldDuplicateService { targetTableId, createFieldRo, undefined, - routingOptions + routingOptions, + createOptions ); await this.replenishmentConstraint(newField.id, targetTableId, order, { @@ -1140,7 +1172,8 @@ export class FieldDuplicateService { field: IFieldWithTableIdJson, tableIdMap: Record, sourceToTargetFieldMap: Record, - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + createOptions: FieldDuplicateCreateOptions = {} ) { const { dbFieldName, @@ -1221,7 +1254,8 @@ export class FieldDuplicateService { }, }, undefined, - routingOptions + routingOptions, + createOptions ); if (hasError) { @@ -1275,7 +1309,8 @@ export class FieldDuplicateService { name, }, undefined, - routingOptions + routingOptions, + createOptions ); if (hasError) { @@ -1310,7 +1345,8 @@ export class FieldDuplicateService { fieldInstance: IFieldWithTableIdJson, tableIdMap: Record, sourceToTargetFieldMap: Record, - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + createOptions: FieldDuplicateCreateOptions = {} ) { const { dbFieldName, @@ -1355,7 +1391,8 @@ export class FieldDuplicateService { name, }, undefined, - routingOptions + routingOptions, + createOptions ); await this.replenishmentConstraint(newField.id, targetTableId, fieldInstance.order, { notNull, @@ -1388,7 +1425,8 @@ export class FieldDuplicateService { fieldInstance: IFieldWithTableIdJson, tableIdMap: Record, sourceToTargetFieldMap: Record, - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + createOptions: FieldDuplicateCreateOptions = {} ) { const { dbFieldName, @@ -1430,7 +1468,8 @@ export class FieldDuplicateService { name, }, undefined, - routingOptions + routingOptions, + createOptions ); await this.replenishmentConstraint(newField.id, targetTableId, fieldInstance.order, { @@ -1459,7 +1498,8 @@ export class FieldDuplicateService { fieldInstance: IFieldWithTableIdJson, sourceToTargetFieldMap: Record, hasError: boolean = false, - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + createOptions: FieldDuplicateCreateOptions = {} ) { const { type, @@ -1494,7 +1534,8 @@ export class FieldDuplicateService { name, }, undefined, - routingOptions + routingOptions, + createOptions ); await this.replenishmentConstraint(newField.id, targetTableId, fieldInstance.order, { notNull, @@ -1596,7 +1637,8 @@ export class FieldDuplicateService { targetTableId: string, fieldInstance: IFieldInstance, sourceToTargetFieldMap: Record, - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + createOptions: FieldDuplicateCreateOptions = {} ) { if (!fieldInstance.aiConfig) return; @@ -1615,7 +1657,8 @@ export class FieldDuplicateService { name, }, undefined, - routingOptions + routingOptions, + createOptions ); await this.replenishmentConstraint(newField.id, targetTableId, 1, { diff --git a/apps/nestjs-backend/src/features/field/field.service.ts b/apps/nestjs-backend/src/features/field/field.service.ts index 4fd74ed933..189f544164 100644 --- a/apps/nestjs-backend/src/features/field/field.service.ts +++ b/apps/nestjs-backend/src/features/field/field.service.ts @@ -744,6 +744,29 @@ export class FieldService implements IReadonlyAdapterService { .map((index) => index.name); } + getDropUniqueIndexSqls(dbTableName: string, indexNames: string[]) { + const [schemaName, tableName] = this.dbProvider.splitTableName(dbTableName); + const quoteTable = tableName + ? (constraintName: string) => + this.knex + .raw('ALTER TABLE ??.?? DROP CONSTRAINT IF EXISTS ??', [ + schemaName, + tableName, + constraintName, + ]) + .toQuery() + : (constraintName: string) => + this.knex + .raw('ALTER TABLE ?? DROP CONSTRAINT IF EXISTS ??', [dbTableName, constraintName]) + .toQuery(); + const quoteIndex = tableName + ? (indexName: string) => + this.knex.raw('DROP INDEX IF EXISTS ??.??', [schemaName, indexName]).toQuery() + : (indexName: string) => this.knex.raw('DROP INDEX IF EXISTS ??', [indexName]).toQuery(); + + return indexNames.flatMap((indexName) => [quoteTable(indexName), quoteIndex(indexName)]); + } + private async alterTableModifyFieldValidation( fieldId: string, key: 'unique' | 'notNull', @@ -777,15 +800,17 @@ export class FieldService implements IReadonlyAdapterService { const dbTableName = table.dbTableName; const matchedIndexes = await this.findUniqueIndexesForField(table.id, dbTableName, dbFieldName); + const dropUniqueIndexSqls = + key === 'unique' && !newValue ? this.getDropUniqueIndexSqls(dbTableName, matchedIndexes) : []; const fieldValidationSqls = this.knex.schema .alterTable(dbTableName, (table) => { if (key === 'unique') { - newValue - ? table.unique([dbFieldName], { - indexName: this.getFieldUniqueKeyName(dbTableName, dbFieldName, fieldId), - }) - : matchedIndexes.forEach((indexName) => table.dropUnique([dbFieldName], indexName)); + if (newValue) { + table.unique([dbFieldName], { + indexName: this.getFieldUniqueKeyName(dbTableName, dbFieldName, fieldId), + }); + } } if (key === 'notNull') { @@ -797,6 +822,7 @@ export class FieldService implements IReadonlyAdapterService { const executeSqls = fieldValidationSqls .filter((s) => !s.sql.startsWith('PRAGMA')) .map(({ sql }) => sql); + executeSqls.push(...dropUniqueIndexSqls); await handleDBValidationErrors({ fn: () => { 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 52e3b8dd32..946ce24808 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 @@ -2,7 +2,28 @@ /* eslint-disable sonarjs/no-duplicate-string */ /* eslint-disable @typescript-eslint/naming-convention */ import { CellValueType, DbFieldType, getDefaultFormatting, type IFieldVo } from '@teable/core'; +import { v2CoreTokens } from '@teable/v2-core'; import { describe, expect, it, vi } from 'vitest'; + +const { + executeDeleteFieldEndpoint, + executeDuplicateFieldEndpoint, + executeUpdateFieldEndpoint, + executeUpdateRecordEndpoint, +} = vi.hoisted(() => ({ + executeDeleteFieldEndpoint: vi.fn(), + executeDuplicateFieldEndpoint: vi.fn(), + executeUpdateFieldEndpoint: vi.fn(), + executeUpdateRecordEndpoint: vi.fn(), +})); + +vi.mock('@teable/v2-contract-http-implementation/handlers', () => ({ + executeDeleteFieldEndpoint, + executeDuplicateFieldEndpoint, + executeUpdateFieldEndpoint, + executeUpdateRecordEndpoint, +})); + import { FieldOpenApiV2Service } from './field-open-api-v2.service'; type ITestFieldOpenApiV2Service = { @@ -76,10 +97,194 @@ const createService = () => {} as never, {} as never, {} as never, - {} as never, {} as never ) as unknown as ITestFieldOpenApiV2Service; +const createV2ContainerService = (commandBus: unknown, tableQueryService: unknown) => { + const tracer = { + startSpan: vi.fn(() => ({ + setAttribute: vi.fn(), + setAttributes: vi.fn(), + recordError: vi.fn(), + end: vi.fn(), + })), + withSpan: vi.fn(async (_span, callback: () => Promise) => callback()), + getActiveSpan: vi.fn(), + }; + const container = { + resolve: vi.fn((token: { description?: string }) => { + if (token.description === 'v2.core.tracer') { + return tracer; + } + if (token.description === 'v2.core.commandBus') { + return commandBus; + } + if (token.description === 'v2.core.tableQueryService') { + return tableQueryService; + } + return undefined; + }), + }; + + return { + getContainerForTable: vi.fn().mockResolvedValue(container), + getContainer: vi.fn().mockResolvedValue(container), + }; +}; + +const createFieldSupplementService = () => ({ + assertSameSpaceLinkTarget: vi.fn(), +}); + +describe('FieldOpenApiV2Service deleteField', () => { + it('delegates delete state handling to the v2 delete endpoint', async () => { + const tableId = `tbl${'a'.repeat(16)}`; + const fieldId = `fld${'b'.repeat(16)}`; + const baseId = `bse${'d'.repeat(16)}`; + const context: Record = {}; + executeDeleteFieldEndpoint.mockResolvedValue({ + status: 200, + body: { ok: true }, + }); + const commandBus = { + execute: vi.fn().mockResolvedValue({ isErr: () => false }), + }; + const tableQueryService = { + getById: vi.fn().mockResolvedValue({ + isErr: () => false, + value: { + baseId: () => ({ toString: () => baseId }), + }, + }), + }; + const container = { + resolve: vi.fn((token: symbol) => { + if (token === v2CoreTokens.commandBus) return commandBus; + if (token === v2CoreTokens.tableQueryService) return tableQueryService; + throw new Error(`Unexpected token ${String(token)}`); + }), + }; + const dataLoaderService = { + field: { + invalidateTables: vi.fn(), + }, + }; + const service = new FieldOpenApiV2Service( + { + getContainerForTable: vi.fn().mockResolvedValue(container), + } as never, + { + createContext: vi.fn().mockResolvedValue(context), + } as never, + dataLoaderService as never, + {} as never, + { + get: vi.fn((key: string) => (key === 'user.id' ? `usr${'f'.repeat(16)}` : undefined)), + } as never, + {} as never, + {} as never + ); + + await service.deleteField(tableId, fieldId); + + expect(executeDeleteFieldEndpoint).toHaveBeenCalledWith( + context, + { + baseId, + tableId, + fieldId, + }, + commandBus + ); + expect(dataLoaderService.field.invalidateTables).toHaveBeenCalledWith([tableId]); + }); +}); + +describe('FieldOpenApiV2Service updateField', () => { + it('invalidates foreign table field cache for partial link field updates', async () => { + const tableId = `tbl${'a'.repeat(16)}`; + const foreignTableId = `tbl${'b'.repeat(16)}`; + const fieldId = `fld${'c'.repeat(16)}`; + const context: Record = {}; + const fieldDto = { + id: fieldId, + type: 'link', + name: 'Linked table', + options: { + relationship: 'manyMany', + foreignTableId, + lookupFieldId: `fld${'d'.repeat(16)}`, + symmetricFieldId: `fld${'e'.repeat(16)}`, + }, + }; + executeUpdateFieldEndpoint.mockResolvedValue({ + status: 200, + body: { ok: true }, + }); + const commandBus = {}; + const tableQueryService = { + getById: vi.fn().mockResolvedValue({ + isErr: () => false, + value: {}, + }), + }; + const tableMapper = { + toDTO: vi.fn().mockReturnValue({ + isErr: () => false, + value: { + fields: [fieldDto], + }, + }), + }; + 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; + throw new Error(`Unexpected token ${String(token)}`); + }), + }; + const dataLoaderService = { + field: { + invalidateTables: vi.fn(), + }, + }; + const service = new FieldOpenApiV2Service( + { + getContainerForTable: vi.fn().mockResolvedValue(container), + } as never, + { + createContext: vi.fn().mockResolvedValue(context), + } as never, + dataLoaderService as never, + {} as never, + { + get: vi.fn((key: string) => (key === 'user.id' ? `usr${'f'.repeat(16)}` : undefined)), + } as never, + {} as never, + {} as never + ); + + await service.updateField(tableId, fieldId, { name: 'Renamed linked table' }); + + expect(executeUpdateFieldEndpoint).toHaveBeenCalledWith( + context, + { + tableId, + fieldId, + field: { + name: 'Renamed linked table', + }, + }, + commandBus + ); + expect(dataLoaderService.field.invalidateTables).toHaveBeenCalledWith([ + tableId, + foreignTableId, + ]); + }); +}); + describe('FieldOpenApiV2Service mapConvertFieldToV2', () => { it('maps lookup convert options with filter/sort/limit', () => { const service = createService(); @@ -1033,8 +1238,7 @@ describe('FieldOpenApiV2Service normalizeFieldVo', () => { {} as never, {} as never, {} as never, - {} as never, - {} as never, + createFieldSupplementService() as never, {} as never ) as unknown as ITestFieldOpenApiV2Service; @@ -1424,17 +1628,12 @@ describe('FieldOpenApiV2Service createField', () => { }), }; const service = new FieldOpenApiV2Service( - { - getContainer: async () => ({ - resolve: vi.fn().mockReturnValueOnce(commandBus).mockReturnValueOnce(tableQueryService), - }), - } as never, + createV2ContainerService(commandBus, tableQueryService) as never, { createContext: async () => ({ requestId: 'reqTestId' }) } as never, { field: { invalidateTables: vi.fn() } } as never, {} as never, {} as never, - {} as never, - {} as never, + createFieldSupplementService() as never, {} as never ) as unknown as ITestFieldOpenApiV2Service; @@ -1494,17 +1693,12 @@ describe('FieldOpenApiV2Service createField', () => { }), }; const service = new FieldOpenApiV2Service( - { - getContainer: async () => ({ - resolve: vi.fn().mockReturnValueOnce(commandBus).mockReturnValueOnce(tableQueryService), - }), - } as never, + createV2ContainerService(commandBus, tableQueryService) as never, { createContext: async () => ({ requestId: 'reqTestId' }) } as never, { field: { invalidateTables: vi.fn() } } as never, {} as never, {} as never, - {} as never, - {} as never, + createFieldSupplementService() as never, {} as never ) as unknown as ITestFieldOpenApiV2Service; @@ -1581,17 +1775,12 @@ describe('FieldOpenApiV2Service createFields', () => { }), }; const service = new FieldOpenApiV2Service( - { - getContainer: async () => ({ - resolve: vi.fn().mockReturnValueOnce(commandBus).mockReturnValueOnce(tableQueryService), - }), - } as never, + createV2ContainerService(commandBus, tableQueryService) as never, { createContext: async () => ({ requestId: 'reqTestId' }) } as never, { field: { invalidateTables: vi.fn() } } as never, {} as never, {} as never, - {} as never, - {} as never, + createFieldSupplementService() as never, {} as never ) as unknown as ITestFieldOpenApiV2Service; 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 f9b494905b..467a0790a0 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 @@ -7,19 +7,12 @@ import { FieldKeyType, FieldType, generateFieldId, - generateOperationId, getDefaultFormatting, getDbFieldType, - ViewOpBuilder, - ViewType, type IConvertFieldRo, type IFieldRo, type IFieldVo, - type IGridColumnMeta, - type IGridViewOptions, - type IOtOperation, type IUpdateFieldRo, - type IViewVo, } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import type { IDuplicateFieldRo, IPlanFieldVo } from '@teable/openapi'; @@ -45,12 +38,15 @@ import { FieldId, type ICommandBus, type IExecutionContext, + type ISpan, type ITableMapper, + type ITracer, LinkFieldConfig, LinkRelationship, TableId, type Table, type TableQueryService, + TeableSpanAttributes, v2CoreTokens, } from '@teable/v2-core'; import { instanceToPlain } from 'class-transformer'; @@ -59,39 +55,18 @@ import { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exc import type { IClsStore } from '../../../types/cls'; import type { IOpsMap } from '../../calculation/utils/compose-maps'; import { DataLoaderService } from '../../data-loader/data-loader.service'; -import { - V2_FIELD_UPDATE_AUDIT_CONTEXT_KEY, - type IV2FieldUpdateAuditContext, -} from '../../v2/v2-audit-log.constants'; import { V2ContainerService } from '../../v2/v2-container.service'; import { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory'; -import { - V2_FIELD_DELETE_COMPAT_CONTEXT_KEY, - type IV2FieldDeleteCompatContext, -} from '../../v2/v2-field-delete-compat.constants'; -import { - V2_FIELD_CONVERT_UNDO_CONTEXT_KEY, - type IV2FieldConvertUndoContext, -} from '../../v2/v2-undo-redo.constants'; -import { adjustFrozenField } from '../../view/utils/derive-frozen-fields'; -import { ViewService } from '../../view/view.service'; import { FieldSupplementService } from '../field-calculate/field-supplement.service'; import { FieldOpenApiService } from './field-open-api.service'; const internalServerError = 'Internal server error'; // eslint-disable-next-line @typescript-eslint/naming-convention type ConvertFieldExecutionOptions = { - emitOperation?: boolean; suppressWindowId?: boolean; undoRedoMode?: 'undo' | 'redo' | 'normal'; }; -type IGridViewDeleteSnapshot = { - viewId: string; - options: IGridViewOptions; - columnMeta: IGridColumnMeta; -}; - type ITableDtoWithFields = { fields: ReadonlyArray>; }; @@ -102,6 +77,87 @@ type IPreparedLegacyCreateField = { nextAiConfig: IFieldVo['aiConfig'] | undefined; }; +const fieldOpenApiV2Component = 'service'; + +const withV2Span = async ( + context: IExecutionContext | undefined, + operation: string, + callback: () => Promise, + attributes: Record = {} +): Promise => { + const tracer = context?.tracer; + let span: ISpan | undefined; + try { + span = tracer?.startSpan(`teable.FieldOpenApiV2Service.${operation}`, { + [TeableSpanAttributes.VERSION]: 'v2', + [TeableSpanAttributes.COMPONENT]: fieldOpenApiV2Component, + [TeableSpanAttributes.HANDLER]: 'FieldOpenApiV2Service', + [TeableSpanAttributes.OPERATION]: `FieldOpenApiV2Service.${operation}`, + ...attributes, + }); + } catch { + span = undefined; + } + + if (!span || !tracer) { + return callback(); + } + + return tracer.withSpan(span, async () => { + try { + return await callback(); + } catch (error) { + span.recordError(error instanceof Error ? error.message : String(error)); + throw error; + } finally { + span.end(); + } + }); +}; + +const withTracerSpan = async ( + tracer: ITracer | undefined, + operation: string, + callback: () => Promise, + attributes: Record = {} +): Promise => { + let span: ISpan | undefined; + try { + span = tracer?.startSpan(`teable.FieldOpenApiV2Service.${operation}`, { + [TeableSpanAttributes.VERSION]: 'v2', + [TeableSpanAttributes.COMPONENT]: fieldOpenApiV2Component, + [TeableSpanAttributes.HANDLER]: 'FieldOpenApiV2Service', + [TeableSpanAttributes.OPERATION]: `FieldOpenApiV2Service.${operation}`, + ...attributes, + }); + } catch { + span = undefined; + } + + if (!span || !tracer) { + return callback(); + } + + return tracer.withSpan(span, async () => { + try { + return await callback(); + } catch (error) { + span.recordError(error instanceof Error ? error.message : String(error)); + throw error; + } finally { + span.end(); + } + }); +}; + +const getTableIdText = (table: Table): string | undefined => { + try { + return table.id().toString(); + } catch { + return undefined; + } +}; + @Injectable() export class FieldOpenApiV2Service { constructor( @@ -109,7 +165,6 @@ export class FieldOpenApiV2Service { private readonly v2ContextFactory: V2ExecutionContextFactory, private readonly dataLoaderService: DataLoaderService, private readonly fieldOpenApiService: FieldOpenApiService, - private readonly viewService: ViewService, private readonly cls: ClsService, private readonly fieldSupplementService: FieldSupplementService, private readonly prismaService: PrismaService @@ -162,79 +217,6 @@ export class FieldOpenApiV2Service { this.dataLoaderService.field.invalidateTables(ids); } - private async captureGridViewDeleteSnapshots( - tableId: string - ): Promise { - const views = await this.viewService.getViews(tableId); - return views.flatMap((view) => this.toGridViewDeleteSnapshot(view)); - } - - private toGridViewDeleteSnapshot(view: IViewVo): IGridViewDeleteSnapshot[] { - if (view.type !== ViewType.Grid) { - return []; - } - - const options = (view.options ?? {}) as IGridViewOptions; - const columnMeta = (view.columnMeta ?? {}) as IGridColumnMeta; - return [ - { - viewId: view.id, - options, - columnMeta, - }, - ]; - } - - private buildFrozenFieldDeleteOps( - viewSnapshots: ReadonlyArray, - fieldIds: ReadonlyArray - ): Record { - const columnMetaUpdate = Object.fromEntries(fieldIds.map((fieldId) => [fieldId, null])); - const opsMap: Record = {}; - - for (const snapshot of viewSnapshots) { - const nextOptions = adjustFrozenField( - snapshot.options, - snapshot.columnMeta, - columnMetaUpdate as unknown as IGridColumnMeta - ); - if (!nextOptions) { - continue; - } - - opsMap[snapshot.viewId] = [ - ViewOpBuilder.editor.setViewProperty.build({ - key: 'options', - oldValue: snapshot.options, - newValue: nextOptions, - }), - ]; - } - - return opsMap; - } - - private attachDeleteFieldCompatContext( - context: IExecutionContext, - tableId: string, - fieldIds: ReadonlyArray, - payload: Awaited>, - gridViewSnapshots: ReadonlyArray - ): void { - ( - context as IExecutionContext & { - [V2_FIELD_DELETE_COMPAT_CONTEXT_KEY]?: IV2FieldDeleteCompatContext; - } - )[V2_FIELD_DELETE_COMPAT_CONTEXT_KEY] = { - tableId, - userId: this.cls.get('user.id'), - operationId: generateOperationId(), - remainingFieldIds: new Set(fieldIds), - frozenFieldOps: this.buildFrozenFieldDeleteOps(gridViewSnapshots, fieldIds), - legacyDeletePayload: payload, - }; - } - private throwV2Error( error: { code: string; @@ -691,31 +673,56 @@ export class FieldOpenApiV2Service { table: Table; }> { const container = await this.v2ContainerService.getContainerForTable(tableId); - const commandBus = container.resolve(v2CoreTokens.commandBus); - const tableQueryService = container.resolve(v2CoreTokens.tableQueryService); - const context = await this.v2ContextFactory.createContext(container); - const tableIdResult = TableId.create(tableId); - if (tableIdResult.isErr()) { - throw new HttpException('Invalid table id', HttpStatus.BAD_REQUEST); - } - - const tableResult = await tableQueryService.getById(context, 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( - errMsg, - isNotFound ? HttpStatus.NOT_FOUND : HttpStatus.INTERNAL_SERVER_ERROR - ); - } + const tracer = container.resolve(v2CoreTokens.tracer); + return withTracerSpan( + tracer, + 'getCreateFieldContext', + async () => { + const commandBus = container.resolve(v2CoreTokens.commandBus); + const tableQueryService = container.resolve( + v2CoreTokens.tableQueryService + ); + const context = await withTracerSpan( + tracer, + 'createExecutionContext', + () => this.v2ContextFactory.createContext(container), + { + [TeableSpanAttributes.TABLE_ID]: tableId, + } + ); + const tableIdResult = TableId.create(tableId); + if (tableIdResult.isErr()) { + throw new HttpException('Invalid table id', HttpStatus.BAD_REQUEST); + } - return { - commandBus, - tableQueryService, - context, - table: tableResult.value, - }; + const tableResult = await withV2Span( + context, + 'loadCreateFieldTable', + () => tableQueryService.getById(context, tableIdResult.value), + { + [TeableSpanAttributes.TABLE_ID]: tableId, + } + ); + 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( + errMsg, + isNotFound ? HttpStatus.NOT_FOUND : HttpStatus.INTERNAL_SERVER_ERROR + ); + } + return { + commandBus, + tableQueryService, + context, + table: tableResult.value, + }; + }, + { + [TeableSpanAttributes.TABLE_ID]: tableId, + } + ); } private async prepareLegacyCreateField( @@ -729,12 +736,28 @@ export class FieldOpenApiV2Service { const nextAiConfig = hasAiConfig ? (rawFieldRo.aiConfig as IFieldVo['aiConfig'] | null | undefined) ?? null : undefined; - const mappedField = this.mapLegacyCreateFieldToV2(fieldRo); - const v2Field = await this.completeLegacyLinkDbConfigForCreate( - mappedField, - currentTable, - tableQueryService, - context + const currentTableId = getTableIdText(currentTable); + const mappedField = await withV2Span( + context, + 'mapLegacyCreateFieldToV2', + async () => this.mapLegacyCreateFieldToV2(fieldRo), + { + ...(currentTableId ? { [TeableSpanAttributes.TABLE_ID]: currentTableId } : {}), + } + ); + const v2Field = await withV2Span( + context, + 'completeLegacyLinkDbConfigForCreate', + () => + this.completeLegacyLinkDbConfigForCreate( + mappedField, + currentTable, + tableQueryService, + context + ), + { + ...(currentTableId ? { [TeableSpanAttributes.TABLE_ID]: currentTableId } : {}), + } ); return { @@ -780,13 +803,25 @@ export class FieldOpenApiV2Service { forceCompatLookupRead?: boolean; } ): Promise { - const createdFieldFromDomain = await this.extractFieldVoFromDomainTable( - table, - fieldId, - context + const createdFieldFromDomain = await withV2Span( + context, + 'extractFieldVoFromDomainTable', + () => this.extractFieldVoFromDomainTable(table, fieldId, context), + { + [TeableSpanAttributes.TABLE_ID]: tableId, + [TeableSpanAttributes.FIELD_ID]: fieldId, + } ); return options?.forceCompatLookupRead === true || createdFieldFromDomain.isLookup === true - ? await this.getFieldFromV2(tableId, fieldId, context) + ? await withV2Span( + context, + 'getCreatedFieldFromV2Compat', + () => this.getFieldFromV2(tableId, fieldId, context), + { + [TeableSpanAttributes.TABLE_ID]: tableId, + [TeableSpanAttributes.FIELD_ID]: fieldId, + } + ) : createdFieldFromDomain; } @@ -1285,14 +1320,23 @@ export class FieldOpenApiV2Service { ); } - const preparedField = await this.prepareLegacyCreateField( - fieldRo, - table, - tableQueryService, - context + const preparedField = await withV2Span( + context, + 'prepareLegacyCreateField', + () => this.prepareLegacyCreateField(fieldRo, table, tableQueryService, context), + { + [TeableSpanAttributes.TABLE_ID]: tableId, + } ); const { hasAiConfig, nextAiConfig, v2Field } = preparedField; - await this.assertCrossSpaceForV2Field(tableId, v2Field); + await withV2Span( + context, + 'assertCrossSpaceForV2Field', + () => this.assertCrossSpaceForV2Field(tableId, v2Field), + { + [TeableSpanAttributes.TABLE_ID]: tableId, + } + ); const legacyViewId = fieldRo && typeof fieldRo === 'object' && 'viewId' in fieldRo ? (fieldRo.viewId as string | undefined) @@ -1313,13 +1357,26 @@ export class FieldOpenApiV2Service { orderIndex: legacyOrder.orderIndex, } : undefined; - const commandResult = CreateFieldCommand.create({ - baseId: table.baseId().toString(), - tableId, - field: v2Field, - ...(typeof legacyViewId === 'string' ? { viewId: legacyViewId } : {}), - ...(normalizedOrder ? { order: normalizedOrder } : {}), - }); + const commandResult = await withV2Span( + context, + 'createCreateFieldCommand', + async () => + CreateFieldCommand.create( + { + baseId: table.baseId().toString(), + tableId, + field: v2Field, + ...(typeof legacyViewId === 'string' ? { viewId: legacyViewId } : {}), + ...(normalizedOrder ? { order: normalizedOrder } : {}), + }, + { + preloadedTable: table, + } + ), + { + [TeableSpanAttributes.TABLE_ID]: tableId, + } + ); if (commandResult.isErr()) { this.throwV2Error( @@ -1328,9 +1385,13 @@ export class FieldOpenApiV2Service { ); } - const result = await commandBus.execute( + const result = await withV2Span( context, - commandResult.value + 'executeCreateFieldCommand', + () => commandBus.execute(context, commandResult.value), + { + [TeableSpanAttributes.TABLE_ID]: tableId, + } ); if (result.isErr()) { @@ -1343,15 +1404,19 @@ export class FieldOpenApiV2Service { this.invalidateFieldLoader(this.collectFieldInvalidateTableIds(tableId, [v2Field])); if (typeof v2Field.id === 'string') { + const createdFieldId = v2Field.id; const shouldForceCompatLookupRead = v2Field.type === 'lookup' || v2Field.type === 'conditionalLookup'; - const createdField = await this.materializeCreatedFieldVo( - tableId, - result.value.table, - v2Field.id, + const createdField = await withV2Span( context, + 'materializeCreatedFieldVo', + () => + this.materializeCreatedFieldVo(tableId, result.value.table, createdFieldId, context, { + forceCompatLookupRead: shouldForceCompatLookupRead, + }), { - forceCompatLookupRead: shouldForceCompatLookupRead, + [TeableSpanAttributes.TABLE_ID]: tableId, + [TeableSpanAttributes.FIELD_ID]: createdFieldId, } ); @@ -1571,18 +1636,6 @@ export class FieldOpenApiV2Service { ); } - const [legacyDeletePayload, gridViewSnapshots] = await Promise.all([ - this.fieldOpenApiService.captureDeleteFieldsLegacyPayload(tableId, [fieldId]), - this.captureGridViewDeleteSnapshots(tableId), - ]); - this.attachDeleteFieldCompatContext( - context, - tableId, - [fieldId], - legacyDeletePayload, - gridViewSnapshots - ); - const result = await executeDeleteFieldEndpoint( context, { @@ -1626,18 +1679,6 @@ export class FieldOpenApiV2Service { ); } - const [legacyDeletePayload, gridViewSnapshots] = await Promise.all([ - this.fieldOpenApiService.captureDeleteFieldsLegacyPayload(tableId, fieldIds), - this.captureGridViewDeleteSnapshots(tableId), - ]); - this.attachDeleteFieldCompatContext( - context, - tableId, - fieldIds, - legacyDeletePayload, - gridViewSnapshots - ); - const commandResult = DeleteFieldsCommand.create({ baseId: tableResult.value.baseId().toString(), tableId, @@ -1677,27 +1718,25 @@ export class FieldOpenApiV2Service { const context = await this.v2ContextFactory.createContext(container); const currentField = await this.getFieldFromV2(tableId, fieldId, context); + const v2Field = this.mapLegacyUpdateFieldToV2( + updateFieldRo, + currentField as Record + ); const v2Input = { tableId, fieldId, - field: this.mapLegacyUpdateFieldToV2(updateFieldRo, currentField as Record), - }; - - ( - context as IExecutionContext & { - [V2_FIELD_UPDATE_AUDIT_CONTEXT_KEY]?: IV2FieldUpdateAuditContext; - } - )[V2_FIELD_UPDATE_AUDIT_CONTEXT_KEY] = { - tableId, - fieldId, - oldField: currentField, - inputField: { ...v2Input.field }, + field: v2Field, }; const result = await executeUpdateFieldEndpoint(context, v2Input, commandBus); if (result.status === 200 && result.body.ok) { - this.invalidateFieldLoader([tableId]); + this.invalidateFieldLoader( + this.collectFieldInvalidateTableIds(tableId, [ + currentField as Record, + v2Field, + ]) + ); return this.getFieldFromV2(tableId, fieldId, context); } @@ -1717,8 +1756,6 @@ export class FieldOpenApiV2Service { const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); const context = await this.v2ContextFactory.createContext(container); - const shouldTrackUndoContext = - executionOptions?.emitOperation !== false && Boolean(context.windowId && context.actorId); if (executionOptions?.undoRedoMode) { context.undoRedo = { mode: executionOptions.undoRedoMode }; } @@ -1726,24 +1763,13 @@ export class FieldOpenApiV2Service { delete context.windowId; } const currentField = await this.getFieldFromV2(tableId, fieldId, context); - if (shouldTrackUndoContext) { - ( - context as IExecutionContext & { - [V2_FIELD_CONVERT_UNDO_CONTEXT_KEY]?: IV2FieldConvertUndoContext; - } - )[V2_FIELD_CONVERT_UNDO_CONTEXT_KEY] = { - tableId, - fieldId, - oldField: currentField, - }; - } // v2 uses UpdateFieldCommand for both update and convert const v2Input = { tableId, fieldId, field: { ...this.mapConvertFieldToV2(convertFieldRo, currentField as Record), - replaceOptions: true, + updateMode: 'full', }, }; await this.assertCrossSpaceForV2Field(tableId, v2Input.field as Record); diff --git a/apps/nestjs-backend/src/features/field/open-api/field-open-api.controller.ts b/apps/nestjs-backend/src/features/field/open-api/field-open-api.controller.ts index ce2923ccd6..e2420d3e32 100644 --- a/apps/nestjs-backend/src/features/field/open-api/field-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/field/open-api/field-open-api.controller.ts @@ -165,7 +165,6 @@ export class FieldOpenApiController { ) { if (this.cls.get('useV2')) { return await this.fieldOpenApiV2Service.convertField(tableId, fieldId, updateFieldRo, { - emitOperation: Boolean(windowId), suppressWindowId: !windowId, }); } diff --git a/apps/nestjs-backend/src/features/field/open-api/field-open-api.service.ts b/apps/nestjs-backend/src/features/field/open-api/field-open-api.service.ts index 38f19a3073..d355070abc 100644 --- a/apps/nestjs-backend/src/features/field/open-api/field-open-api.service.ts +++ b/apps/nestjs-backend/src/features/field/open-api/field-open-api.service.ts @@ -1,13 +1,18 @@ /* eslint-disable sonarjs/cognitive-complexity */ /* eslint-disable @typescript-eslint/naming-convention */ -import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + Optional, +} from '@nestjs/common'; import { CellValueType, ColorConfigType, FieldKeyType, FieldOpBuilder, FieldType, - HttpErrorCode, ViewType, generateFieldId, generateOperationId, @@ -55,13 +60,13 @@ import { groupBy, isEqual, omit, pick } from 'lodash'; import { InjectModel } from 'nest-knexjs'; import { ClsService } from 'nestjs-cls'; import { ThresholdConfig, IThresholdConfig } from '../../../configs/threshold.config'; -import { CustomHttpException } from '../../../custom.exception'; import { FieldReferenceCompatibilityException } from '../../../db-provider/filter-query/cell-value-filter.abstract'; import { EventEmitterService } from '../../../event-emitter/event-emitter.service'; import { Events } from '../../../event-emitter/events'; import { IDataDbRoutingOptions } from '../../../global/data-db-client-manager.service'; import { DatabaseRouter } from '../../../global/database-router.service'; import type { IClsStore } from '../../../types/cls'; +import { majorFieldKeysChanged } from '../../../utils/major-field-keys-changed'; import { Timing } from '../../../utils/timing'; import { FieldCalculationService } from '../../calculation/field-calculation.service'; import type { IOpsMap } from '../../calculation/utils/compose-maps'; @@ -70,6 +75,7 @@ import { ComputedOrchestratorService } from '../../record/computed/services/comp import { RecordOpenApiService } from '../../record/open-api/record-open-api.service'; import { InjectRecordQueryBuilder, IRecordQueryBuilder } from '../../record/query-builder'; import { RecordService } from '../../record/record.service'; +import { SpaceDataDbMigrationGuardService } from '../../space/space-data-db-migration-guard.service'; import { TableIndexService } from '../../table/table-index.service'; import { ViewOpenApiService } from '../../view/open-api/view-open-api.service'; import { ViewService } from '../../view/view.service'; @@ -114,6 +120,7 @@ export type ILegacyDeleteFieldsPayloadSnapshot = { type CreateFieldsOptions = { restoreViewOrder?: boolean; + skipComputedEvaluation?: boolean; }; @Injectable() @@ -140,9 +147,15 @@ export class FieldOpenApiService { @InjectModel('CUSTOM_KNEX') private readonly knex: Knex, @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig, @InjectRecordQueryBuilder() private readonly recordQueryBuilder: IRecordQueryBuilder, - private readonly computedOrchestrator: ComputedOrchestratorService + private readonly computedOrchestrator: ComputedOrchestratorService, + @Optional() + private readonly spaceDataDbMigrationGuard?: SpaceDataDbMigrationGuardService ) {} + private async assertTableWritable(tableId: string) { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + } + async planField(tableId: string, fieldId: string) { return await this.graphService.planField(tableId, fieldId); } @@ -1161,6 +1174,7 @@ export class FieldOpenApiService { options: CreateFieldsOptions = {} ) { if (!fields.length) return; + await this.assertTableWritable(tableId); const orderedFields = this.sortCreateFieldsByDependencies(tableId, fields); @@ -1242,9 +1256,7 @@ export class FieldOpenApiService { } } - const skipComputation = this.cls.get('skipFieldComputation'); - - if (!skipComputation) { + if (!options.skipComputedEvaluation) { // Ensure dependent formula generated columns are recreated BEFORE // evaluating and returning values in the computed pipeline. // This avoids UPDATE ... RETURNING selecting non-existent generated columns @@ -1270,7 +1282,8 @@ export class FieldOpenApiService { await this.fieldService.resolvePending(tid, list); } } - } + }, + { skipComputedEvaluation: options.skipComputedEvaluation } ); return created; @@ -1288,16 +1301,18 @@ export class FieldOpenApiService { async createFieldsByRo( tableId: string, fieldRos: IFieldRo[], - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + options: Pick = {} ): Promise { if (!fieldRos.length) return []; + await this.assertTableWritable(tableId); const fieldVos = await this.fieldSupplementService.prepareCreateFields( tableId, fieldRos, undefined, routingOptions ); - await this.createFields(tableId, fieldVos, routingOptions); + await this.createFields(tableId, fieldVos, routingOptions, options); return fieldVos; } @@ -1367,8 +1382,10 @@ export class FieldOpenApiService { tableId: string, fieldRo: IFieldRo, windowId?: string, - routingOptions?: IDataDbRoutingOptions + routingOptions?: IDataDbRoutingOptions, + options: Pick = {} ) { + await this.assertTableWritable(tableId); const fieldVo = await this.fieldSupplementService.prepareCreateField( tableId, fieldRo, @@ -1406,7 +1423,8 @@ export class FieldOpenApiService { await this.fieldService.resolvePending(tid, [field.id]); } } - } + }, + { skipComputedEvaluation: options.skipComputedEvaluation } ); return created; }, @@ -1444,6 +1462,7 @@ export class FieldOpenApiService { @Timing() async deleteFields(tableId: string, fieldIds: string[], windowId?: string) { + await this.assertTableWritable(tableId); const { fields, fieldVos, columnsMeta, referenceMap, records } = await this.prismaService.$tx( async () => { const fieldRaws = await this.prismaService.txClient().field.findMany({ @@ -1563,6 +1582,7 @@ export class FieldOpenApiService { } async updateField(tableId: string, fieldId: string, updateFieldRo: IUpdateFieldRo) { + await this.assertTableWritable(tableId); const ops: IOtOperation[] = []; if (updateFieldRo.name) { const op = await this.updateUniqProperty(tableId, fieldId, 'name', updateFieldRo.name); @@ -1791,6 +1811,7 @@ export class FieldOpenApiService { updateFieldRo: IConvertFieldRo, windowId?: string ): Promise { + await this.assertTableWritable(tableId); const { oldFieldVo, newFieldVo, modifiedOps, references, supplementChange } = await this.prismaService.$tx( async () => { @@ -1816,9 +1837,15 @@ export class FieldOpenApiService { ); const shouldRecomputeSelf = this.fieldConvertingService.needCalculate(newField, oldField); - const filteredDependentFieldIds = shouldRecomputeSelf - ? dependentFieldIds - : dependentFieldIds.filter((id) => id !== newField.id); + const shouldRecomputeDependents = + shouldRecomputeSelf || + majorFieldKeysChanged(oldField, newField) || + Boolean(analysisResult.supplementChange); + const filteredDependentFieldIds = shouldRecomputeDependents + ? shouldRecomputeSelf + ? dependentFieldIds + : dependentFieldIds.filter((id) => id !== newField.id) + : []; const { compatibilityIssue } = await this.performConvertField({ tableId, @@ -1963,6 +1990,7 @@ export class FieldOpenApiService { tableId: string, fieldId: string ): Promise<{ field: IFieldVo; oldLinkOptions: ILinkFieldOptions }> { + await this.assertTableWritable(tableId); return this.prismaService.$tx( async () => { // Pass `options: {}` explicitly so stageAnalysis assigns the empty diff --git a/apps/nestjs-backend/src/features/field/restore-field-record-values.spec.ts b/apps/nestjs-backend/src/features/field/restore-field-record-values.spec.ts new file mode 100644 index 0000000000..9b9d845879 --- /dev/null +++ b/apps/nestjs-backend/src/features/field/restore-field-record-values.spec.ts @@ -0,0 +1,71 @@ +import { FieldKeyType } from '@teable/core'; +import { describe, expect, it, vi } from 'vitest'; +import { restoreFieldRecordValues } from './restore-field-record-values'; + +describe('restoreFieldRecordValues', () => { + it('restores only non-empty values in chunks', async () => { + const updater = { + updateRecords: vi.fn().mockResolvedValue([]), + }; + const nonEmptyRecords = Array.from({ length: 501 }, (_, index) => ({ + id: `rec${index}`, + fields: { + fldText: `value-${index}`, + }, + })); + const emptyRecords = Array.from({ length: 130 }, (_, index) => ({ + id: `rec-empty-${index}`, + fields: { + fldNull: null, + fldUndefined: undefined, + fldEmptyLink: [], + }, + })); + + await restoreFieldRecordValues('tblxxx', [...nonEmptyRecords, ...emptyRecords], updater); + + expect(updater.updateRecords).toHaveBeenCalledTimes(2); + expect(updater.updateRecords.mock.calls.map(([, ro]) => ro.records?.length)).toEqual([500, 1]); + expect(updater.updateRecords.mock.calls[0][1].fieldKeyType).toBe(FieldKeyType.Id); + expect( + updater.updateRecords.mock.calls.flatMap(([, ro]) => + (ro.records ?? []).flatMap((record) => Object.values(record.fields ?? {})) + ) + ).toEqual(nonEmptyRecords.map((record) => record.fields.fldText)); + }); + + it('keeps falsy cell values that are not empty defaults', async () => { + const updater = { + updateRecords: vi.fn().mockResolvedValue([]), + }; + + await restoreFieldRecordValues( + 'tblxxx', + [ + { + id: 'recxxx', + fields: { + fldZero: 0, + fldFalse: false, + fldEmptyText: '', + }, + }, + ], + updater + ); + + expect(updater.updateRecords).toHaveBeenCalledWith('tblxxx', { + fieldKeyType: FieldKeyType.Id, + records: [ + { + id: 'recxxx', + fields: { + fldZero: 0, + fldFalse: false, + fldEmptyText: '', + }, + }, + ], + }); + }); +}); diff --git a/apps/nestjs-backend/src/features/field/restore-field-record-values.ts b/apps/nestjs-backend/src/features/field/restore-field-record-values.ts new file mode 100644 index 0000000000..09a394c4a0 --- /dev/null +++ b/apps/nestjs-backend/src/features/field/restore-field-record-values.ts @@ -0,0 +1,56 @@ +import { FieldKeyType, HttpErrorCode } from '@teable/core'; +import type { IUpdateRecordsRo } from '@teable/openapi'; +import { CustomHttpException } from '../../custom.exception'; + +export const FIELD_RESTORE_RECORD_CHUNK_SIZE = 500; + +export type IFieldRestoreRecord = { + id: string; + fields?: Record | null; +}; + +type IFieldRecordValueUpdater = { + updateRecords( + tableId: string, + updateRecordsRo: Pick + ): Promise; +}; + +const isEmptyRestoreValue = (value: unknown) => { + return value == null || (Array.isArray(value) && value.length === 0); +}; + +export const compactFieldRestoreRecords = (records: IFieldRestoreRecord[] = []) => { + return records.flatMap((record) => { + const fields = Object.fromEntries( + Object.entries(record.fields ?? {}).filter(([, value]) => !isEmptyRestoreValue(value)) + ); + + return Object.keys(fields).length ? [{ id: record.id, fields }] : []; + }); +}; + +export const restoreFieldRecordValues = async ( + tableId: string, + records: IFieldRestoreRecord[] | undefined, + recordOpenApiService: IFieldRecordValueUpdater, + chunkSize = FIELD_RESTORE_RECORD_CHUNK_SIZE +) => { + const compactedRecords = compactFieldRestoreRecords(records); + if (!compactedRecords.length) return; + + try { + for (let i = 0; i < compactedRecords.length; i += chunkSize) { + await recordOpenApiService.updateRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + records: compactedRecords.slice(i, i + chunkSize), + }); + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + throw new CustomHttpException( + `Failed to restore field cell values for table ${tableId}: ${message}`, + HttpErrorCode.INTERNAL_SERVER_ERROR + ); + } +}; diff --git a/apps/nestjs-backend/src/features/import/open-api/import-open-api-freeze.service.spec.ts b/apps/nestjs-backend/src/features/import/open-api/import-open-api-freeze.service.spec.ts new file mode 100644 index 0000000000..b91088544c --- /dev/null +++ b/apps/nestjs-backend/src/features/import/open-api/import-open-api-freeze.service.spec.ts @@ -0,0 +1,146 @@ +import { HttpErrorCode } from '@teable/core'; +import { SUPPORTEDTYPE } from '@teable/openapi'; +import { vi } from 'vitest'; +import { CustomHttpException } from '../../../custom.exception'; +import { ImportOpenApiV2Service } from './import-open-api-v2.service'; +import { ImportOpenApiService } from './import-open-api.service'; + +describe('Import open API write freeze', () => { + const importCsvUrl = 'https://example.com/import.csv'; + const freezeError = new CustomHttpException( + 'Space data database migration is in progress', + HttpErrorCode.CONFLICT, + { + errorCode: 'SPACE_DATA_DB_MIGRATING', + migrationJobId: 'sdmjxxx', + } + ); + + it('rejects v2 create-table imports before resolving the v2 container', async () => { + const service = Object.create(ImportOpenApiV2Service.prototype) as { + createTableFromCsvImport: ImportOpenApiV2Service['createTableFromCsvImport']; + spaceDataDbMigrationGuard: { assertBaseWritable: ReturnType }; + v2ContainerService: { getContainerForBase: ReturnType }; + audit: { withOperation: ReturnType }; + cls: { get: ReturnType }; + }; + service.audit = { + withOperation: vi.fn((_, fn: () => Promise) => fn()), + }; + service.cls = { get: vi.fn() }; + service.spaceDataDbMigrationGuard = { + assertBaseWritable: vi.fn().mockRejectedValue(freezeError), + }; + service.v2ContainerService = { + getContainerForBase: vi.fn(), + }; + + await expect( + service.createTableFromCsvImport('bseImport', { + attachmentUrl: importCsvUrl, + fileType: SUPPORTEDTYPE.CSV, + worksheets: {}, + }) + ).rejects.toBe(freezeError); + + expect(service.spaceDataDbMigrationGuard.assertBaseWritable).toHaveBeenCalledWith('bseImport'); + expect(service.v2ContainerService.getContainerForBase).not.toHaveBeenCalled(); + }); + + it('rejects v2 inplace imports before resolving the v2 container', async () => { + const service = Object.create(ImportOpenApiV2Service.prototype) as { + importRecords: ImportOpenApiV2Service['importRecords']; + spaceDataDbMigrationGuard: { assertTableWritable: ReturnType }; + v2ContainerService: { getContainerForTable: ReturnType }; + audit: { withOperation: ReturnType }; + cls: { get: ReturnType }; + }; + service.audit = { + withOperation: vi.fn((_, fn: () => Promise) => fn()), + }; + service.cls = { get: vi.fn() }; + service.spaceDataDbMigrationGuard = { + assertTableWritable: vi.fn().mockRejectedValue(freezeError), + }; + service.v2ContainerService = { + getContainerForTable: vi.fn(), + }; + + await expect( + service.importRecords('bseImport', 'tblImport', { + attachmentUrl: importCsvUrl, + fileType: SUPPORTEDTYPE.CSV, + insertConfig: { + sourceColumnMap: {}, + sourceWorkSheetKey: 'sheet1', + excludeFirstRow: false, + }, + }) + ).rejects.toBe(freezeError); + + expect(service.spaceDataDbMigrationGuard.assertTableWritable).toHaveBeenCalledWith('tblImport'); + expect(service.v2ContainerService.getContainerForTable).not.toHaveBeenCalled(); + }); + + it('rejects queued create-table imports before checking queue capacity', async () => { + const service = Object.create(ImportOpenApiService.prototype) as { + createTableFromImport: ImportOpenApiService['createTableFromImport']; + spaceDataDbMigrationGuard: { assertBaseWritable: ReturnType }; + importTableCsvChunkQueueProcessor: { + queue: { getJobCountByTypes: ReturnType }; + }; + }; + service.spaceDataDbMigrationGuard = { + assertBaseWritable: vi.fn().mockRejectedValue(freezeError), + }; + service.importTableCsvChunkQueueProcessor = { + queue: { getJobCountByTypes: vi.fn() }, + }; + + await expect( + service.createTableFromImport('bseImport', { + attachmentUrl: importCsvUrl, + fileType: SUPPORTEDTYPE.CSV, + worksheets: {}, + }) + ).rejects.toBe(freezeError); + + expect(service.spaceDataDbMigrationGuard.assertBaseWritable).toHaveBeenCalledWith('bseImport'); + expect( + service.importTableCsvChunkQueueProcessor.queue.getJobCountByTypes + ).not.toHaveBeenCalled(); + }); + + it('rejects queued inplace imports before checking queue capacity', async () => { + const service = Object.create(ImportOpenApiService.prototype) as { + inplaceImportTable: ImportOpenApiService['inplaceImportTable']; + spaceDataDbMigrationGuard: { assertTableWritable: ReturnType }; + importTableCsvChunkQueueProcessor: { + queue: { getJobCountByTypes: ReturnType }; + }; + }; + service.spaceDataDbMigrationGuard = { + assertTableWritable: vi.fn().mockRejectedValue(freezeError), + }; + service.importTableCsvChunkQueueProcessor = { + queue: { getJobCountByTypes: vi.fn() }, + }; + + await expect( + service.inplaceImportTable('bseImport', 'tblImport', { + attachmentUrl: importCsvUrl, + fileType: SUPPORTEDTYPE.CSV, + insertConfig: { + sourceColumnMap: {}, + sourceWorkSheetKey: 'sheet1', + excludeFirstRow: false, + }, + }) + ).rejects.toBe(freezeError); + + expect(service.spaceDataDbMigrationGuard.assertTableWritable).toHaveBeenCalledWith('tblImport'); + expect( + service.importTableCsvChunkQueueProcessor.queue.getJobCountByTypes + ).not.toHaveBeenCalled(); + }); +}); 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 7d5f29dbef..41411ae987 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 @@ -1,11 +1,20 @@ -import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { Injectable, HttpException, HttpStatus, Logger, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { HttpErrorCode } from '@teable/core'; -import { CreateRecordAction, type IInplaceImportOptionRo } from '@teable/openapi'; +import { + CreateRecordAction, + SUPPORTEDTYPE, + type IImportOptionRo, + type IInplaceImportOptionRo, + type ITableFullVo, +} from '@teable/openapi'; +import { mapDomainErrorToHttpStatus } from '@teable/v2-contract-http'; import { v2CoreTokens, type ICommandBus, + ImportCsvCommand, + type ImportCsvResult, ImportRecordsCommand, type ImportRecordsResult, } from '@teable/v2-core'; @@ -18,6 +27,9 @@ import { Events } from '../../../event-emitter/events'; import type { IClsStore } from '../../../types/cls'; import { AuditScope } from '../../audit/audit-scope'; import { Audit } from '../../audit/audit.decorator'; +import { FieldOpenApiService } from '../../field/open-api/field-open-api.service'; +import { SpaceDataDbMigrationGuardService } from '../../space/space-data-db-migration-guard.service'; +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'; @@ -36,8 +48,12 @@ export class ImportOpenApiV2Service { private readonly cls: ClsService, private readonly configService: ConfigService, private readonly audit: AuditScope, + private readonly tableOpenApiService: TableOpenApiService, + private readonly fieldOpenApiService: FieldOpenApiService, private readonly eventEmitter: EventEmitter2, - @BaseConfig() private readonly baseConfig: IBaseConfig + @BaseConfig() private readonly baseConfig: IBaseConfig, + @Optional() + private readonly spaceDataDbMigrationGuard?: SpaceDataDbMigrationGuardService ) {} /** @@ -77,6 +93,121 @@ export class ImportOpenApiV2Service { }); } + /** + * Create a new table from a CSV file using V2 architecture via CommandBus. + * + * This adapts the existing V1 OpenAPI payload shape used by the UI + * (attachmentUrl + worksheets) into the V2 ImportCsvCommand URL source. + */ + @Audit({ + rootAction: CreateRecordAction.Import, + resourceId: (baseId: string) => baseId, + params: (_baseId: string, importOptions: IImportOptionRo) => ({ + fileType: importOptions.fileType, + }), + }) + async createTableFromCsvImport( + baseId: string, + importOptions: IImportOptionRo, + maxRowCount?: number + ): Promise { + await this.spaceDataDbMigrationGuard?.assertBaseWritable(baseId); + + if (importOptions.fileType !== SUPPORTEDTYPE.CSV) { + throw new HttpException( + 'V2 create-table import only supports CSV files', + HttpStatus.BAD_REQUEST + ); + } + + const worksheets = Object.values(importOptions.worksheets); + const worksheet = worksheets.find((item) => item.importData) ?? worksheets[0]; + if (!worksheet) { + return []; + } + + const container = await this.v2ContainerService.getContainerForBase(baseId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const resolvedUrl = this.resolveUrl(importOptions.attachmentUrl); + const normalizedMaxRowCount = + maxRowCount !== undefined && maxRowCount > 0 ? maxRowCount : undefined; + + const commandResult = ImportCsvCommand.createFromUrl({ + baseId, + csvUrl: resolvedUrl, + tableName: worksheet.name, + importData: worksheet.importData, + useFirstRowAsHeader: worksheet.useFirstRowAsHeader, + columns: worksheet.columns.length + ? worksheet.columns.map((column) => ({ + name: column.name, + sourceColumnIndex: column.sourceColumnIndex, + type: column.type, + })) + : undefined, + batchSize: normalizedMaxRowCount ? Math.min(normalizedMaxRowCount, 500) : 500, + maxRowCount: normalizedMaxRowCount, + }); + if (commandResult.isErr()) { + this.throwV2Error(commandResult.error, mapDomainErrorToHttpStatus(commandResult.error)); + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + this.logger.error('V2 import CSV failed', result.error); + this.eventEmitter.emit(Events.V2_TABLE_IMPORT_FINISH, { + baseId, + status: 'failed', + error: result.error.message, + }); + this.throwV2Error(result.error, mapDomainErrorToHttpStatus(result.error)); + } + + const tableId = result.value.table.id().toString(); + const table = (await this.tableOpenApiService.getTable(baseId, tableId)) as ITableFullVo; + table.fields = await this.fieldOpenApiService.getFields(tableId, {}); + table.records = []; + this.eventEmitter.emit(Events.V2_TABLE_IMPORT_FINISH, { + baseId, + tableId, + status: 'completed', + }); + return [table]; + } + + private validateImportProjection( + sourceColumnMap: Record, + projection?: string[] + ): void { + if (!projection) { + return; + } + + const fieldIds = Object.keys(sourceColumnMap); + const noUpdateFields = difference(fieldIds, projection); + if (noUpdateFields.length === 0) { + return; + } + + const tips = noUpdateFields.join(','); + throw new CustomHttpException( + `There is no permission to update these fields: ${tips}`, + HttpErrorCode.RESTRICTED_RESOURCE, + { + localization: { + i18nKey: 'httpErrors.permission.updateRecordWithDeniedFields', + context: { + fields: tips, + }, + }, + } + ); + } + /** * Import records using V2 architecture via CommandBus. * Appends records from a file (CSV/Excel) to an existing table. @@ -108,6 +239,8 @@ export class ImportOpenApiV2Service { maxRowCount?: number, projection?: string[] ): Promise<{ totalImported: number }> { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); @@ -116,26 +249,7 @@ export class ImportOpenApiV2Service { const { attachmentUrl, fileType, insertConfig } = importOptions; const { sourceColumnMap, sourceWorkSheetKey, excludeFirstRow } = insertConfig; - // Validate field permissions if projection is provided - if (projection) { - const fieldIds = Object.keys(sourceColumnMap); - const noUpdateFields = difference(fieldIds, projection); - if (noUpdateFields.length !== 0) { - const tips = noUpdateFields.join(','); - throw new CustomHttpException( - `There is no permission to update these fields: ${tips}`, - HttpErrorCode.RESTRICTED_RESOURCE, - { - localization: { - i18nKey: 'httpErrors.permission.updateRecordWithDeniedFields', - context: { - fields: tips, - }, - }, - } - ); - } - } + this.validateImportProjection(sourceColumnMap, projection); // Resolve relative URL to absolute URL const resolvedUrl = this.resolveUrl(attachmentUrl); @@ -171,6 +285,12 @@ export class ImportOpenApiV2Service { if (result.isErr()) { this.logger.error('V2 import records failed', result.error); + this.eventEmitter.emit(Events.V2_TABLE_IMPORT_FINISH, { + baseId, + tableId, + status: 'failed', + error: result.error.message, + }); // Map domain error to HTTP status const status = @@ -182,25 +302,15 @@ export class ImportOpenApiV2Service { ? HttpStatus.NOT_FOUND : HttpStatus.INTERNAL_SERVER_ERROR; - // Mirror the V1 worker (import-csv.processor.ts) terminal signal so e2e tests - // and downstream consumers wake up on V2 imports too. V1 emits on lastChunk + - // catch; V2 is synchronous so emits at the return-success/throw boundary. - this.eventEmitter.emit(Events.TABLE_IMPORT_FINISH, { - tableId, - baseId, - status: 'failed', - error: result.error.message, - }); - this.throwV2Error(result.error, status); } // No manual audit emit: ImportRecordsHandler publishes RecordsBatchCreated per batch. // The projection writes one audit_log row per batch naturally, keeping the atomic // record-create action and attaching rootAction=InplaceImport from this operation. - this.eventEmitter.emit(Events.TABLE_IMPORT_FINISH, { - tableId, + this.eventEmitter.emit(Events.V2_TABLE_IMPORT_FINISH, { baseId, + tableId, status: 'completed', }); return { totalImported: result.value.totalImported }; diff --git a/apps/nestjs-backend/src/features/import/open-api/import-open-api.controller.ts b/apps/nestjs-backend/src/features/import/open-api/import-open-api.controller.ts index b168fe42e9..0c44766ad5 100644 --- a/apps/nestjs-backend/src/features/import/open-api/import-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/import/open-api/import-open-api.controller.ts @@ -16,6 +16,7 @@ import { importOptionRoSchema, IInplaceImportOptionRo, inplaceImportOptionRoSchema, + SUPPORTEDTYPE, } from '@teable/openapi'; import type { ITableFullVo, IAnalyzeVo, IImportStatusVo } from '@teable/openapi'; import { ClsService } from 'nestjs-cls'; @@ -55,12 +56,22 @@ export class ImportController { } @Post(':baseId') + @UseV2Feature('importCsv') @Permissions('base|table_import') @TokenAccess() async createTableFromImport( @Param('baseId') baseId: string, @Body(new ZodValidationPipe(importOptionRoSchema)) importRo: IImportOptionRo ): Promise { + if (this.cls.get('useV2') && importRo.fileType === SUPPORTEDTYPE.CSV) { + return await this.importOpenApiV2Service.createTableFromCsvImport(baseId, importRo); + } + + if (this.cls.get('useV2')) { + this.cls.set('useV2', false); + this.cls.set('v2Reason', 'unsupported_feature'); + } + return await this.importOpenService.createTableFromImport(baseId, importRo); } diff --git a/apps/nestjs-backend/src/features/import/open-api/import-open-api.module.ts b/apps/nestjs-backend/src/features/import/open-api/import-open-api.module.ts index 30f739a884..612f02a915 100644 --- a/apps/nestjs-backend/src/features/import/open-api/import-open-api.module.ts +++ b/apps/nestjs-backend/src/features/import/open-api/import-open-api.module.ts @@ -4,6 +4,7 @@ import { CanaryModule } from '../../canary/canary.module'; import { FieldOpenApiModule } from '../../field/open-api/field-open-api.module'; import { NotificationModule } from '../../notification/notification.module'; import { RecordOpenApiModule } from '../../record/open-api/record-open-api.module'; +import { SpaceModule } from '../../space/space.module'; import { TableOpenApiModule } from '../../table/open-api/table-open-api.module'; import { V2Module } from '../../v2/v2.module'; import { ImportMetricsModule } from '../metrics/import-metrics.module'; @@ -23,6 +24,7 @@ import { ImportOpenApiService } from './import-open-api.service'; V2Module, CanaryModule, ImportMetricsModule, + SpaceModule, ], controllers: [ImportController], providers: [ImportOpenApiService, ImportOpenApiV2Service], diff --git a/apps/nestjs-backend/src/features/import/open-api/import-open-api.service.ts b/apps/nestjs-backend/src/features/import/open-api/import-open-api.service.ts index c33f009e0a..37cb8b79e4 100644 --- a/apps/nestjs-backend/src/features/import/open-api/import-open-api.service.ts +++ b/apps/nestjs-backend/src/features/import/open-api/import-open-api.service.ts @@ -20,6 +20,7 @@ import { AuditScope } from '../../audit/audit-scope'; import { FieldOpenApiService } from '../../field/open-api/field-open-api.service'; import { NotificationService } from '../../notification/notification.service'; import { RecordOpenApiService } from '../../record/open-api/record-open-api.service'; +import { SpaceDataDbMigrationGuardService } from '../../space/space-data-db-migration-guard.service'; import { DEFAULT_VIEWS, DEFAULT_FIELDS } from '../../table/constant'; import { TableOpenApiService } from '../../table/open-api/table-open-api.service'; import { ImportMetricsService } from '../metrics/import-metrics.service'; @@ -63,6 +64,8 @@ export class ImportOpenApiService { private readonly fieldOpenApiService: FieldOpenApiService, private readonly cacheService: CacheService, private readonly audit: AuditScope, + @Optional() + private readonly spaceDataDbMigrationGuard?: SpaceDataDbMigrationGuardService, @Optional() private readonly importMetrics?: ImportMetricsService ) {} @@ -113,6 +116,7 @@ export class ImportOpenApiService { } async createTableFromImport(baseId: string, importRo: IImportOptionRo, maxRowCount?: number) { + await this.spaceDataDbMigrationGuard?.assertBaseWritable(baseId); await this.checkImportConcurrencyLimit(); const userId = this.cls.get('user.id'); @@ -265,6 +269,7 @@ export class ImportOpenApiService { maxRowCount?: number, projection?: string[] ) { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); await this.checkImportConcurrencyLimit(); const userId = this.cls.get('user.id'); diff --git a/apps/nestjs-backend/src/features/integrity/integrity-v2.controller.ts b/apps/nestjs-backend/src/features/integrity/integrity-v2.controller.ts index 7339a7759e..22aef59c62 100644 --- a/apps/nestjs-backend/src/features/integrity/integrity-v2.controller.ts +++ b/apps/nestjs-backend/src/features/integrity/integrity-v2.controller.ts @@ -27,7 +27,7 @@ import { ClsService } from 'nestjs-cls'; import { z } from 'zod'; import type { IClsStore } from '../../types/cls'; import { ZodValidationPipe } from '../../zod.validation.pipe'; -import { Permissions } from '../auth/decorators/permissions.decorator'; +import { AnyPermissions, Permissions } from '../auth/decorators/permissions.decorator'; import { PermissionGuard } from '../auth/guard/permission.guard'; import { UseV2Feature } from '../canary/decorators/use-v2-feature.decorator'; import { V2FeatureGuard } from '../canary/guards/v2-feature.guard'; @@ -147,6 +147,7 @@ export class IntegrityV2Controller { @Post('table/:tableId/repair-stream') @Permissions('table|update') + @AnyPermissions(['instance|update']) @UseV2Feature(v2SchemaIntegrityFeature) async repairTable( @Param('tableId') tableId: string, diff --git a/apps/nestjs-backend/src/features/integrity/integrity-v2.service.spec.ts b/apps/nestjs-backend/src/features/integrity/integrity-v2.service.spec.ts index 97be4a32cd..cefdedb191 100644 --- a/apps/nestjs-backend/src/features/integrity/integrity-v2.service.spec.ts +++ b/apps/nestjs-backend/src/features/integrity/integrity-v2.service.spec.ts @@ -14,8 +14,10 @@ import { Table, TableId, TableName, + v2CoreTokens, type ITracer, } from '@teable/v2-core'; +import { ok } from 'neverthrow'; import { describe, expect, it, vi, beforeEach } from 'vitest'; import { IntegrityV2Service } from './integrity-v2.service'; @@ -378,7 +380,7 @@ describe('IntegrityV2Service repair telemetry', () => { ); expect(tableRepository.find).toHaveBeenCalledTimes(1); - expect(tableRepository.find.mock.calls[0]?.[2]).toEqual({ state: 'all' }); + expect(tableRepository.find.mock.calls[0]?.[2]).toEqual({ state: 'activeWithPending' }); expect(tableRepository.find.mock.calls[0]?.[1]).toMatchObject({ left: { baseIdValue: foreignBaseId, @@ -393,6 +395,70 @@ describe('IntegrityV2Service repair telemetry', () => { expect(lookupField.hasError().isError()).toBe(false); }); + it('loads active schema state for integrity table targets', async () => { + const tableId = createTableId('i').toString(); + const table = createTable(); + const tableRepository = { + findOne: vi.fn().mockResolvedValue(ok(table)), + find: vi.fn().mockResolvedValue(ok([table])), + }; + const db = {}; + const container = { + resolve: vi.fn((token) => { + if (token === v2CoreTokens.tableRepository) { + return tableRepository; + } + return db; + }), + }; + const service = new IntegrityV2Service( + { getContainerForTable: vi.fn().mockResolvedValue(container) } as never, + { createContext: vi.fn().mockResolvedValue({}) } as never + ); + + await service['resolveSchemaTarget'](tableId, { includeBaseTables: true }); + + expect(tableRepository.findOne).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + state: 'activeWithPending', + }); + expect(tableRepository.find).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + state: 'activeWithPending', + }); + }); + + it('loads active schema state for integrity base targets', async () => { + const baseId = createBaseId('i').toString(); + const table = createTable(); + const tableRepository = { + find: vi.fn().mockResolvedValue(ok([table])), + }; + const baseRepository = { + findOne: vi.fn().mockResolvedValue(ok({})), + }; + const db = {}; + const container = { + resolve: vi.fn((token) => { + if (token === v2CoreTokens.tableRepository) { + return tableRepository; + } + if (token === v2CoreTokens.baseRepository) { + return baseRepository; + } + return db; + }), + }; + const service = new IntegrityV2Service( + { getContainerForBase: vi.fn().mockResolvedValue(container) } as never, + { createContext: vi.fn().mockResolvedValue({}) } as never + ); + + await service['resolveBaseTarget'](baseId); + + expect(tableRepository.find).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + state: 'activeWithPending', + }); + }); + it('marks metadata reference check results as auto repairable', async () => { const service = new IntegrityV2Service({} as never, {} as never); const table = createTable(); diff --git a/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts b/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts index 8c187477e9..8ace9fd6a2 100644 --- a/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts +++ b/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts @@ -1,4 +1,4 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import * as Sentry from '@sentry/nestjs'; import type { IV2BaseSchemaIntegrityRepairRo, @@ -11,7 +11,7 @@ import type { IV2SchemaIntegrityRepairCapability, IV2SchemaIntegrityRepairRo, } from '@teable/openapi'; -import { v2DataDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import { v2DataDbTokens, v2MetaDbTokens } from '@teable/v2-adapter-db-postgres-pg'; import { checkTableMetaWithTables, createMetaRepairer, @@ -41,6 +41,7 @@ import { type IExecutionContext, type ITracer, type ITableRepository, + type TableQueryState, } from '@teable/v2-core'; import { V2ContainerService } from '../v2/v2-container.service'; import { V2ExecutionContextFactory } from '../v2/v2-execution-context.factory'; @@ -67,9 +68,15 @@ const integrityFailureKindAttribute = 'teable.integrity.failure_kind'; const integrityRuleIdAttribute = 'teable.integrity.rule_id'; const integrityOutcomeAttribute = 'teable.integrity.outcome'; const integrityRequiredAttribute = 'teable.integrity.required'; +// Physical schema integrity should not validate deleted tables or fields. The repository +// hydrates only fields/views with deleted_time IS NULL for activeWithPending. +const schemaIntegrityTableState: TableQueryState = 'activeWithPending'; +const slowIntegrityTableCheckMs = 10_000; @Injectable() export class IntegrityV2Service { + private readonly logger = new Logger(IntegrityV2Service.name); + constructor( private readonly v2ContainerService: V2ContainerService, private readonly v2ContextFactory: V2ExecutionContextFactory @@ -79,11 +86,12 @@ export class IntegrityV2Service { tableId: string, statuses?: IV2SchemaIntegrityFilterStatus[] ): Promise> { - const { table, tables, db, schema } = await this.resolveSchemaTarget(tableId, { + const { table, tables, db, metaDb, schema } = await this.resolveSchemaTarget(tableId, { includeBaseTables: true, }); const checker = createSchemaChecker({ db, + metaDb, introspector: new PostgresSchemaIntrospector(db), schema, }); @@ -95,16 +103,17 @@ export class IntegrityV2Service { tableId: string, repairRo: IV2SchemaIntegrityRepairRo ): Promise> { - const { table, tables, db, schema, context } = await this.resolveSchemaTarget(tableId, { + const { table, tables, db, metaDb, schema, context } = await this.resolveSchemaTarget(tableId, { includeBaseTables: true, }); const repairer = createSchemaRepairer({ db, + metaDb, introspector: new PostgresSchemaIntrospector(db), schema, }); - const metaRepairer = createMetaRepairer({ db }); + const metaRepairer = createMetaRepairer({ db: metaDb }); if (repairRo.fieldId && isMetaRuleId(repairRo.ruleId)) { return this.decorateRepairStream( @@ -186,9 +195,15 @@ export class IntegrityV2Service { baseId: string, statuses?: IV2SchemaIntegrityFilterStatus[] ): Promise> { - const { tables, metaTables, db, schema } = await this.resolveBaseTarget(baseId); + const startedAt = Date.now(); + this.logger.log(`Schema integrity base check target resolving started baseId=${baseId}`); + const { tables, metaTables, db, metaDb, schema } = await this.resolveBaseTarget(baseId); + this.logger.log( + `Schema integrity base check target resolving completed baseId=${baseId} tableCount=${tables.length} metaTableCount=${metaTables.length} elapsedMs=${Date.now() - startedAt}` + ); const checker = createSchemaChecker({ db, + metaDb, introspector: new PostgresSchemaIntrospector(db), schema, }); @@ -200,13 +215,15 @@ export class IntegrityV2Service { baseId: string, repairRo: IV2BaseSchemaIntegrityRepairRo ): Promise> { - const { tables, metaTables, db, schema, context } = await this.resolveBaseTarget(baseId); + const { tables, metaTables, db, metaDb, schema, context } = + await this.resolveBaseTarget(baseId); const repairer = createSchemaRepairer({ db, + metaDb, introspector: new PostgresSchemaIntrospector(db), schema, }); - const metaRepairer = createMetaRepairer({ db }); + const metaRepairer = createMetaRepairer({ db: metaDb }); return this.streamBaseRepairs(tables, metaTables, repairer, metaRepairer, repairRo, { tracer: context.tracer, @@ -232,7 +249,7 @@ export class IntegrityV2Service { const tableResult = await tableRepository.findOne( context, TableByIdSpec.create(parsedTableId.value), - { state: 'all' } + { state: schemaIntegrityTableState } ); if (tableResult.isErr()) { @@ -240,6 +257,7 @@ export class IntegrityV2Service { } const db = container.resolve(v2DataDbTokens.db); + const metaDb = container.resolve(v2MetaDbTokens.db); const table = tableResult.value; let tables: ReadonlyArray = [table]; @@ -247,7 +265,7 @@ export class IntegrityV2Service { const tablesResult = await tableRepository.find( context, TableByBaseIdSpec.create(table.baseId()), - { state: 'all' } + { state: schemaIntegrityTableState } ); if (tablesResult.isErr()) { @@ -261,6 +279,7 @@ export class IntegrityV2Service { table, tables, db, + metaDb, schema: table.baseId().toString(), context, }; @@ -289,7 +308,7 @@ export class IntegrityV2Service { const tablesResult = await tableRepository.find( context, TableByBaseIdSpec.create(parsedBaseId.value), - { state: 'all' } + { state: schemaIntegrityTableState } ); if (tablesResult.isErr()) { @@ -297,6 +316,7 @@ export class IntegrityV2Service { } const db = container.resolve(v2DataDbTokens.db); + const metaDb = container.resolve(v2MetaDbTokens.db); const metaTables = await this.loadReferencedForeignTables( tablesResult.value, tableRepository, @@ -310,6 +330,7 @@ export class IntegrityV2Service { tables, metaTables, db, + metaDb, schema: parsedBaseId.value.toString(), context, }; @@ -478,7 +499,9 @@ export class IntegrityV2Service { throw new HttpException(specResult.error.message, HttpStatus.INTERNAL_SERVER_ERROR); } - const tablesResult = await tableRepository.find(context, specResult.value, { state: 'all' }); + const tablesResult = await tableRepository.find(context, specResult.value, { + state: schemaIntegrityTableState, + }); if (tablesResult.isErr()) { throw new HttpException(tablesResult.error.message, HttpStatus.INTERNAL_SERVER_ERROR); } @@ -494,12 +517,28 @@ export class IntegrityV2Service { checker: ReturnType, statuses?: IV2SchemaIntegrityFilterStatus[] ): AsyncGenerator { + const baseId = table.baseId().toString(); + const tableId = table.id().toString(); + const tableName = table.name().toString(); + const schemaStartedAt = Date.now(); + this.logger.log( + `Schema integrity table schema check started baseId=${baseId} tableId=${tableId} tableName=${tableName}` + ); yield* this.decorateCheckStream(table, checker.checkTable(table), statuses); + const schemaElapsedMs = Date.now() - schemaStartedAt; + this.logIntegrityPhaseCompleted('schema', baseId, tableId, tableName, schemaElapsedMs); + + const metaStartedAt = Date.now(); + this.logger.log( + `Schema integrity table meta check started baseId=${baseId} tableId=${tableId} tableName=${tableName}` + ); yield* this.decorateMetaCheckStream( table, checkTableMetaWithTables(table, table.baseId(), allTables), statuses ); + const metaElapsedMs = Date.now() - metaStartedAt; + this.logIntegrityPhaseCompleted('meta', baseId, tableId, tableName, metaElapsedMs); } private async *streamBaseChecks( @@ -508,9 +547,25 @@ export class IntegrityV2Service { checker: ReturnType, statuses?: IV2SchemaIntegrityFilterStatus[] ): AsyncGenerator { + const baseId = tables[0]?.baseId().toString() ?? 'unknown'; + const startedAt = Date.now(); + this.logger.log( + `Schema integrity base check stream started baseId=${baseId} tableCount=${tables.length} metaTableCount=${metaTables.length}` + ); + let checkedTables = 0; for (const table of tables) { + const tableStartedAt = Date.now(); + this.logger.log( + `Schema integrity base check table started baseId=${baseId} tableId=${table.id().toString()} tableName=${table.name().toString()} index=${checkedTables + 1}/${tables.length}` + ); yield* this.streamTableChecks(table, metaTables, checker, statuses); + checkedTables += 1; + const tableElapsedMs = Date.now() - tableStartedAt; + this.logIntegrityTableCompleted(baseId, table, checkedTables, tables.length, tableElapsedMs); } + this.logger.log( + `Schema integrity base check stream completed baseId=${baseId} tableCount=${tables.length} elapsedMs=${Date.now() - startedAt}` + ); } private async *streamBaseRepairs( @@ -789,6 +844,36 @@ export class IntegrityV2Service { return `${table.id().toString()}:${id}`; } + private logIntegrityPhaseCompleted( + phase: 'schema' | 'meta', + baseId: string, + tableId: string, + tableName: string, + elapsedMs: number + ) { + const message = `Schema integrity table ${phase} check completed baseId=${baseId} tableId=${tableId} tableName=${tableName} elapsedMs=${elapsedMs}`; + if (elapsedMs >= slowIntegrityTableCheckMs) { + this.logger.warn(message); + return; + } + this.logger.log(message); + } + + private logIntegrityTableCompleted( + baseId: string, + table: Table, + checkedTables: number, + tableCount: number, + elapsedMs: number + ) { + const message = `Schema integrity base check table completed baseId=${baseId} tableId=${table.id().toString()} tableName=${table.name().toString()} index=${checkedTables}/${tableCount} elapsedMs=${elapsedMs}`; + if (elapsedMs >= slowIntegrityTableCheckMs) { + this.logger.warn(message); + return; + } + this.logger.log(message); + } + private toMutableDetails(details?: SchemaRepairResult['details']) { return details ? { diff --git a/apps/nestjs-backend/src/features/invitation/invitation.service.ts b/apps/nestjs-backend/src/features/invitation/invitation.service.ts index b490c717db..24d6d04c5b 100644 --- a/apps/nestjs-backend/src/features/invitation/invitation.service.ts +++ b/apps/nestjs-backend/src/features/invitation/invitation.service.ts @@ -81,7 +81,26 @@ export class InvitationService { @Audit({ action: Events.INVITATION_EMAIL_SEND, resourceId: (input: { resourceId: string }) => input.resourceId, - params: (input: { resourceType: CollaboratorType }) => ({ resourceType: input.resourceType }), + // Capture the inviter's user.id at decorator-resolve time (before the method + // runs). Inviting a brand-new email path runs `userService.createUser`, which + // mutates CLS user.id via runWith(cls.get(), ...) — that bleeds into the + // outer scope, and by the time the audit listener reads cls.get('user.id') + // it would see the invitee's id instead of the inviter's. Resolving userId + // up front pins the row to the inviter. + userId: (_input, ctx) => ctx.cls.get('user.id'), + params: (input: { + resourceId: string; + resourceType: CollaboratorType; + role: IRole; + emails: string[]; + }) => ({ + resourceType: input.resourceType, + role: input.role, + emails: input.emails, + ...(input.resourceType === CollaboratorType.Base + ? { baseId: input.resourceId } + : { spaceId: input.resourceId }), + }), emit: (_result: unknown, input: { emails: string[] }) => ({ emailCount: input.emails.length }), }) private async emailInvitation({ @@ -246,8 +265,14 @@ export class InvitationService { @Audit({ action: Events.INVITATION_LINK_CREATE, resourceId: (input: { resourceId: string }) => input.resourceId, - params: (input: { resourceType: CollaboratorType }) => ({ resourceType: input.resourceType }), - emit: true, + params: (input: { resourceId: string; resourceType: CollaboratorType; role: IRole }) => ({ + resourceType: input.resourceType, + role: input.role, + ...(input.resourceType === CollaboratorType.Base + ? { baseId: input.resourceId } + : { spaceId: input.resourceId }), + }), + emit: (result: ItemSpaceInvitationLinkVo) => ({ invitationId: result.invitationId }), }) async generateInvitationLink({ role, diff --git a/apps/nestjs-backend/src/features/mail-sender/open-api/mail-sender-open-api.controller.ts b/apps/nestjs-backend/src/features/mail-sender/open-api/mail-sender-open-api.controller.ts index 4e4304f1d2..4aedc6458b 100644 --- a/apps/nestjs-backend/src/features/mail-sender/open-api/mail-sender-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/mail-sender/open-api/mail-sender-open-api.controller.ts @@ -1,8 +1,14 @@ -import { Body, Controller, Post } from '@nestjs/common'; +import { Body, Controller, Param, Post } from '@nestjs/common'; import { HttpErrorCode } from '@teable/core'; -import { ITestMailTransportConfigRo, testMailTransportConfigRoSchema } from '@teable/openapi'; +import type { ISendEmailRo, ISendEmailVo } from '@teable/openapi'; +import { + ITestMailTransportConfigRo, + sendEmailRoSchema, + testMailTransportConfigRoSchema, +} from '@teable/openapi'; import { CustomHttpException } from '../../../custom.exception'; import { ZodValidationPipe } from '../../../zod.validation.pipe'; +import { Permissions } from '../../auth/decorators/permissions.decorator'; import { MailSenderOpenApiService } from './mail-sender-open-api.service'; @Controller('api/mail-sender') @@ -31,4 +37,14 @@ export class MailSenderOpenApiController { ); } } + + @Post(':baseId/send') + @Permissions('base|update') + async sendEmail( + @Param('baseId') baseId: string, + @Body(new ZodValidationPipe(sendEmailRoSchema)) + sendEmailRo: ISendEmailRo + ): Promise { + return this.mailSenderOpenApiService.sendEmail(baseId, sendEmailRo); + } } diff --git a/apps/nestjs-backend/src/features/mail-sender/open-api/mail-sender-open-api.service.ts b/apps/nestjs-backend/src/features/mail-sender/open-api/mail-sender-open-api.service.ts index 2f8eec259d..c232726476 100644 --- a/apps/nestjs-backend/src/features/mail-sender/open-api/mail-sender-open-api.service.ts +++ b/apps/nestjs-backend/src/features/mail-sender/open-api/mail-sender-open-api.service.ts @@ -1,14 +1,25 @@ import { Injectable } from '@nestjs/common'; -import type { ITestMailTransportConfigRo } from '@teable/openapi'; +import type { ISendEmailRo, ISendEmailVo, ITestMailTransportConfigRo } from '@teable/openapi'; +import { MailBodyType, MailTransporterType, MailType, toEmailArray } from '@teable/openapi'; +import MarkdownIt from 'markdown-it'; import { createTransport } from 'nodemailer'; import { IMailConfig, MailConfig } from '../../../configs/mail.config'; +import { type ISendMailOptions } from '../mail-helpers'; import { MailSenderService } from '../mail-sender.service'; +const markdownIt = MarkdownIt({ html: true, breaks: true }); + +type ISendEmailExtras = { + footerHtml?: string; + list?: ISendMailOptions['list']; + headers?: ISendMailOptions['headers']; +}; + @Injectable() export class MailSenderOpenApiService { constructor( - private readonly mailSenderService: MailSenderService, - @MailConfig() private readonly mailConfig: IMailConfig + protected readonly mailSenderService: MailSenderService, + @MailConfig() protected readonly mailConfig: IMailConfig ) {} async testTransportConfig(testMailTransportConfigRo: ITestMailTransportConfigRo): Promise { @@ -19,4 +30,35 @@ export class MailSenderOpenApiService { const option = await this.mailSenderService.sendTestEmailOptions({ message }); await this.mailSenderService.sendMailByConfig({ to, ...option }, transportConfig); } + + async sendEmail(_baseId: string, ro: ISendEmailRo & ISendEmailExtras): Promise { + const { subject, body, bodyType, replyTo, headers, smtp, footerHtml, list } = ro; + + const rendered = bodyType === MailBodyType.Markdown ? markdownIt.render(body) : body; + const html = rendered + (footerHtml ?? ''); + + const result = await this.mailSenderService.sendMail( + { + to: toEmailArray(ro.to), + cc: toEmailArray(ro.cc), + bcc: toEmailArray(ro.bcc), + subject, + html, + headers, + replyTo, + list, + }, + { + shouldThrow: true, + type: MailType.ApiSendEmailAction, + transportConfig: smtp, + transporterName: MailTransporterType.Automation, + } + ); + + return { + success: !!result, + message: result ? 'Email sent successfully' : 'Failed to send email', + }; + } } diff --git a/apps/nestjs-backend/src/features/next/next-controller.spec.ts b/apps/nestjs-backend/src/features/next/next-controller.spec.ts new file mode 100644 index 0000000000..f8e092fe56 --- /dev/null +++ b/apps/nestjs-backend/src/features/next/next-controller.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Request, Response } from 'express'; +import { NextController } from './next.controller'; +import type { NextService } from './next.service'; + +describe('NextController', () => { + const response = () => { + const res = { + status: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + }; + return res as unknown as Response & typeof res; + }; + + it('returns 404 for page routes when Next server is not started', async () => { + const controller = new NextController({ server: undefined } as unknown as NextService); + const res = response(); + + await controller.home({} as Request, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.send).toHaveBeenCalledWith('Not Found'); + }); + + it('delegates page routes to Next server when it is available', async () => { + const handler = vi.fn(); + const controller = new NextController({ + server: { getRequestHandler: () => handler }, + } as unknown as NextService); + const req = {} as Request; + const res = response(); + + await controller.home(req, res); + + expect(handler).toHaveBeenCalledWith(req, res); + expect(res.status).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/nestjs-backend/src/features/next/next.controller.ts b/apps/nestjs-backend/src/features/next/next.controller.ts index c32c2caf45..7903054d2a 100644 --- a/apps/nestjs-backend/src/features/next/next.controller.ts +++ b/apps/nestjs-backend/src/features/next/next.controller.ts @@ -77,6 +77,9 @@ export class NextController { 't/?*', ]) public async home(@Req() req: Request, @Res() res: Response) { + if (!this.nextService.server) { + return res.status(404).send('Not Found'); + } await this.nextService.server.getRequestHandler()(req, res); } diff --git a/apps/nestjs-backend/src/features/notification/notification.service.ts b/apps/nestjs-backend/src/features/notification/notification.service.ts index 715beec867..e5e75d1044 100644 --- a/apps/nestjs-backend/src/features/notification/notification.service.ts +++ b/apps/nestjs-backend/src/features/notification/notification.service.ts @@ -522,12 +522,16 @@ export class NotificationService { } async getNotifyList(userId: string, query: IGetNotifyListQuery): Promise { - const { notifyStates, cursor, severity } = query; + const { notifyStates, cursor, severity, notifyType } = query; const where: Prisma.NotificationWhereInput = { toUserId: userId, isRead: notifyStates === NotificationStatesEnum.Read, }; - const listWhere: Prisma.NotificationWhereInput = severity ? { ...where, severity } : where; + const listWhere: Prisma.NotificationWhereInput = { + ...where, + ...(severity ? { severity } : {}), + ...(notifyType ? { type: notifyType } : {}), + }; const [{ records, nextCursor }, summary] = await Promise.all([ this.getNotificationRecords(listWhere, cursor), diff --git a/apps/nestjs-backend/src/features/oauth/oauth.service.ts b/apps/nestjs-backend/src/features/oauth/oauth.service.ts index 030ae1f524..df793533fc 100644 --- a/apps/nestjs-backend/src/features/oauth/oauth.service.ts +++ b/apps/nestjs-backend/src/features/oauth/oauth.service.ts @@ -13,13 +13,16 @@ import type { import * as bcrypt from 'bcrypt'; import { pick } from 'lodash'; import { ClsService } from 'nestjs-cls'; +import { PerformanceCacheService } from '../../performance-cache'; +import { generateAccessTokenCacheKey } from '../../performance-cache/generate-keys'; import type { IClsStore } from '../../types/cls'; @Injectable() export class OAuthService { constructor( private readonly prismaService: PrismaService, - private readonly cls: ClsService + private readonly cls: ClsService, + private readonly performanceCacheService: PerformanceCacheService ) {} private convertToVo(ro: T) { @@ -162,9 +165,33 @@ export class OAuthService { } }; + private async deleteAccessTokens( + tx: Prisma.TransactionClient, + where: Prisma.AccessTokenWhereInput + ) { + const accessTokens = await tx.accessToken.findMany({ + where, + select: { id: true }, + }); + await tx.accessToken.deleteMany({ where }); + return accessTokens.map(({ id }) => id); + } + + private async invalidateAccessTokenCache(accessTokenIds: string[]) { + await Promise.all( + [...new Set(accessTokenIds)].map((id) => + this.performanceCacheService.del(generateAccessTokenCacheKey(id)) + ) + ); + } + async deleteOAuth(clientId: string): Promise { await this.validateOwnership(clientId); - await this.prismaService.$tx(async (prisma) => { + const accessTokenIds = await this.prismaService.$tx(async (prisma) => { + const accessTokens = await prisma.accessToken.findMany({ + where: { clientId }, + select: { id: true }, + }); await prisma.oAuthApp.delete({ where: { clientId, @@ -175,7 +202,9 @@ export class OAuthService { clientId, }, }); + return accessTokens.map(({ id }) => id); }); + await this.invalidateAccessTokenCache(accessTokenIds); } async getOAuthList(): Promise { @@ -238,7 +267,7 @@ export class OAuthService { async revokeAccess(clientId: string) { await this.validateOwnership(clientId); - await this.prismaService.$tx(async (prisma) => { + const accessTokenIds = await this.prismaService.$tx(async (prisma) => { await prisma.oAuthAppAuthorized.deleteMany({ where: { clientId }, }); @@ -247,16 +276,14 @@ export class OAuthService { clientId, }, }); - // delete access token - await prisma.accessToken.deleteMany({ - where: { clientId }, - }); + return await this.deleteAccessTokens(prisma, { clientId }); }); + await this.invalidateAccessTokenCache(accessTokenIds); } async revokeToken(clientId: string) { const userId = this.cls.get('user.id'); - await this.prismaService.$tx(async (prisma) => { + const accessTokenIds = await this.prismaService.$tx(async (prisma) => { await prisma.oAuthAppAuthorized.delete({ // eslint-disable-next-line @typescript-eslint/naming-convention where: { clientId_userId: { clientId, userId } }, @@ -269,10 +296,9 @@ export class OAuthService { }, }); - await prisma.accessToken.deleteMany({ - where: { clientId, userId }, - }); + return await this.deleteAccessTokens(prisma, { clientId, userId }); }); + await this.invalidateAccessTokenCache(accessTokenIds); } async getAuthorizedList(): Promise { diff --git a/apps/nestjs-backend/src/features/record/computed/services/computed-evaluator.service.spec.ts b/apps/nestjs-backend/src/features/record/computed/services/computed-evaluator.service.spec.ts new file mode 100644 index 0000000000..e5441af82c --- /dev/null +++ b/apps/nestjs-backend/src/features/record/computed/services/computed-evaluator.service.spec.ts @@ -0,0 +1,138 @@ +import { FieldType } from '@teable/core'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ComputedEvaluatorService as ComputedEvaluatorServiceType } from './computed-evaluator.service'; + +type IComputedEvaluatorServiceCtor = new (...args: unknown[]) => ComputedEvaluatorServiceType; + +vi.mock('@teable/db-main-prisma', () => ({ + PrismaService: class PrismaService {}, +})); + +vi.mock('../../../../global/database-router.service', () => ({ + DatabaseRouter: class DatabaseRouter {}, +})); + +vi.mock('../../query-builder', () => ({ + ['InjectRecordQueryBuilder']: () => () => undefined, +})); + +vi.mock('../../../calculation/batch.service', () => ({ + BatchService: class BatchService {}, +})); + +vi.mock('./record-computed-update.service', () => ({ + RecordComputedUpdateService: class RecordComputedUpdateService {}, +})); + +describe('ComputedEvaluatorService', () => { + const tableId = 'tblLookupRepoint'; + const lookupFieldId = 'fldLookup'; + const formulaFieldId = 'fldFormula'; + const recordId = 'recLookupRepoint'; + + const createField = (field: { + id: string; + type: FieldType; + dbFieldName: string; + isLookup?: boolean; + }) => + ({ + ...field, + convertDBValue2CellValue: (value: unknown) => value, + getIsPersistedAsGeneratedColumn: () => false, + }) as never; + + let createRecordQueryBuilder: ReturnType; + let updateFromSelect: ReturnType; + let saveRawOps: ReturnType; + let queryRaw: ReturnType; + let computedEvaluatorServiceClass: IComputedEvaluatorServiceCtor; + + beforeAll(async () => { + const module = await import('./computed-evaluator.service'); + computedEvaluatorServiceClass = + module.ComputedEvaluatorService as IComputedEvaluatorServiceCtor; + }); + + beforeEach(() => { + const qb = { + clone: vi.fn(), + whereIn: vi.fn().mockReturnThis(), + }; + qb.clone.mockReturnValue(qb); + createRecordQueryBuilder = vi.fn().mockResolvedValue({ + qb, + alias: '', + }); + updateFromSelect = vi.fn().mockResolvedValue([ + { + __id: recordId, + __version: 1, + __prev_version: 1, + lookup_value: 'Ada', + formula_value: 'Ada Lovelace', + __auto_number: 1, + }, + ]); + saveRawOps = vi.fn(); + queryRaw = vi.fn().mockResolvedValue([ + { + fromFieldId: lookupFieldId, + toFieldId: formulaFieldId, + }, + ]); + }); + + it('uses stored lookup columns only after lookup-like fields have been evaluated', async () => { + const service = new computedEvaluatorServiceClass( + { createRecordQueryBuilder } as never, + { updateFromSelect } as never, + { saveRawOps } as never, + { txClient: () => ({ $queryRaw: queryRaw }) } as never + ); + + await service.evaluate( + { + [tableId]: { + fieldIds: new Set([lookupFieldId, formulaFieldId]), + recordIds: new Set([recordId]), + }, + }, + { + excludeFieldIds: new Set([lookupFieldId, formulaFieldId]), + tableDomains: new Map([ + [ + tableId, + { + dbTableName: 'table_lookup_repoint', + fieldList: [ + createField({ + id: lookupFieldId, + type: FieldType.SingleLineText, + dbFieldName: 'lookup_value', + isLookup: true, + }), + createField({ + id: formulaFieldId, + type: FieldType.Formula, + dbFieldName: 'formula_value', + }), + ], + }, + ], + ]) as never, + } + ); + + expect(createRecordQueryBuilder).toHaveBeenCalledTimes(2); + expect(createRecordQueryBuilder.mock.calls[0][1]).toMatchObject({ + projection: [lookupFieldId], + preferStoredLookupFields: false, + }); + expect(createRecordQueryBuilder.mock.calls[1][1]).toMatchObject({ + projection: [formulaFieldId], + preferStoredLookupFields: true, + }); + expect(updateFromSelect).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/nestjs-backend/src/features/record/computed/services/computed-evaluator.service.ts b/apps/nestjs-backend/src/features/record/computed/services/computed-evaluator.service.ts index d7e6fa23c4..a6d9afa48d 100644 --- a/apps/nestjs-backend/src/features/record/computed/services/computed-evaluator.service.ts +++ b/apps/nestjs-backend/src/features/record/computed/services/computed-evaluator.service.ts @@ -1,7 +1,7 @@ /* eslint-disable sonarjs/cognitive-complexity */ import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import type { FieldCore, FormulaFieldCore, TableDomain } from '@teable/core'; +import type { FormulaFieldCore, TableDomain } from '@teable/core'; import { FieldType, IdPrefix, RecordOpBuilder, Tables } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import type { Knex } from 'knex'; @@ -106,6 +106,7 @@ export class ComputedEvaluatorService { projection: Array.from(validFieldIdSet), rawProjection: true, preferRawFieldReferences: true, + preferStoredLookupFields: this.shouldPreferStoredLookupFields(fieldInstances), projectionByTable, restrictRecordIds: builderRestrictRecordIds, tables: tablesOverride, @@ -284,6 +285,18 @@ export class ComputedEvaluatorService { .map((field) => field as unknown as IFieldInstance); } + private shouldPreferStoredLookupFields(fieldInstances: IFieldInstance[]): boolean { + if (!fieldInstances.length) { + return false; + } + return !fieldInstances.some( + (field) => + (field as unknown as { isLookup?: boolean }).isLookup === true || + field.type === FieldType.Rollup || + field.type === FieldType.ConditionalRollup + ); + } + private buildTablesOverride( tableId: string, tableDomains?: ReadonlyMap diff --git a/apps/nestjs-backend/src/features/record/computed/services/computed-orchestrator.service.ts b/apps/nestjs-backend/src/features/record/computed/services/computed-orchestrator.service.ts index 8682f8edbc..17d5e11cf1 100644 --- a/apps/nestjs-backend/src/features/record/computed/services/computed-orchestrator.service.ts +++ b/apps/nestjs-backend/src/features/record/computed/services/computed-orchestrator.service.ts @@ -3,11 +3,9 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { FieldType } from '@teable/core'; import type { TableDomain, LastModifiedByFieldCore, LastModifiedTimeFieldCore } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import { ClsService } from 'nestjs-cls'; import { InjectDbProvider } from '../../../../db-provider/db.provider'; import { IDbProvider } from '../../../../db-provider/db.provider.interface'; import { DatabaseRouter } from '../../../../global/database-router.service'; -import type { IClsStore } from '../../../../types/cls'; import { Timing } from '../../../../utils/timing'; import type { ICellContext } from '../../../calculation/utils/changes'; import { TableDomainQueryService } from '../../../table-domain/table-domain-query.service'; @@ -27,7 +25,6 @@ export class ComputedOrchestratorService { private readonly prismaService: PrismaService, private readonly databaseRouter: DatabaseRouter, private readonly tableDomainQueryService: TableDomainQueryService, - private readonly cls: ClsService, @InjectDbProvider() private readonly dbProvider: IDbProvider ) {} @@ -319,14 +316,15 @@ export class ComputedOrchestratorService { */ async computeCellChangesForFieldsAfterCreate( sources: IFieldChangeSource[], - update: () => Promise + update: () => Promise, + options: { skipComputedEvaluation?: boolean } = {} ): Promise<{ publishedOps: number; impact: Record; }> { await update(); - if (this.cls.get('skipFieldComputation')) { + if (options.skipComputedEvaluation) { return { publishedOps: 0, impact: {} }; } 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 f48bef0cbf..498b90d43f 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 @@ -340,6 +340,8 @@ describe('RecordOpenApiV2Service', () => { filterLinkCellCandidate ); expect((query as ListTableRecordsQuery).selectedRecordIds).toEqual(selectedRecordIds); + expect((query as ListTableRecordsQuery).projection).toEqual([]); + 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)}`, { @@ -353,6 +355,98 @@ 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 }); + + const result = await service.getRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + skip: 0, + take: 2, + groupBy, + }); + + expect(getDocIdsByQuery).toHaveBeenCalledWith( + tableId, + expect.objectContaining({ groupBy }), + true + ); + expect(result.extra).toEqual(extra); + }); + + it('skips grouped query extra when includeQueryExtra is false', async () => { + const tableId = `tbl${'c'.repeat(16)}`; + const groupBy = [{ fieldId: statusFieldId, order: SortFunc.Asc }]; + + const result = await service.getRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + skip: 0, + take: 2, + groupBy, + includeQueryExtra: false, + }); + + expect(getDocIdsByQuery).not.toHaveBeenCalled(); + expect(result.extra).toBeUndefined(); + + const query = execute.mock.calls[0]?.[1]; + expect(query).toBeInstanceOf(ListTableRecordsQuery); + expect((query as ListTableRecordsQuery).sort).toEqual(groupBy); + expect((query as ListTableRecordsQuery).groupBy).toEqual([statusFieldId]); + }); + + it('skips grouped query extra by default for projected record reads', async () => { + const tableId = `tbl${'c'.repeat(16)}`; + const groupBy = [{ fieldId: statusFieldId, order: SortFunc.Asc }]; + + const result = await service.getRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + skip: 0, + take: 2, + groupBy, + projection: [statusFieldId, noteFieldId], + }); + + expect(getDocIdsByQuery).not.toHaveBeenCalled(); + expect(result.extra).toBeUndefined(); + + const query = execute.mock.calls[0]?.[1]; + expect(query).toBeInstanceOf(ListTableRecordsQuery); + expect((query as ListTableRecordsQuery).sort).toEqual(groupBy); + expect((query as ListTableRecordsQuery).groupBy).toEqual([statusFieldId]); + }); + + 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 }); + + const result = await service.getRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + skip: 0, + take: 2, + groupBy, + projection: [statusFieldId, noteFieldId], + includeQueryExtra: true, + }); + + expect(getDocIdsByQuery).toHaveBeenCalledWith( + tableId, + expect.objectContaining({ groupBy, projection: [statusFieldId, noteFieldId] }), + true + ); + expect(result.extra).toEqual(extra); + }); + it('runs legacy snapshot compatibility reads against the table data client for BYODB tables', async () => { const tableId = `tbl${'c'.repeat(16)}`; const dataPrisma = { $queryRawUnsafe: vi.fn() }; 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 706697ca35..acb4334e9a 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 @@ -1,16 +1,18 @@ /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable sonarjs/cognitive-complexity */ -import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { Injectable, HttpException, HttpStatus, Optional } from '@nestjs/common'; import { trace } from '@opentelemetry/api'; import { CellFormat, CellValueType, FieldKeyType, FieldType, + HttpErrorCode, TimeFormatting, formatDateToString, isMeTag, parseClipboardText, + type IAttachmentItem, type IDatetimeFormatting, type IFieldVo, type IFilter, @@ -26,13 +28,18 @@ import type { IRecord, ICreateRecordsVo, IGetRecordsRo, + ICreateRecordsRo, + IUpdateRecordsRo, IPasteRo, + IPasteByIdStreamRo, IPasteVo, IRangesRo, + ISelectionIdMutationBaseRo, + ISelectionIdsRo, IRecordsVo, IRecordInsertOrderRo, } from '@teable/openapi'; -import { ICreateRecordsRo, IUpdateRecordsRo, RangeType } from '@teable/openapi'; +import { RangeType } from '@teable/openapi'; import { mapDomainErrorToHttpError, mapDomainErrorToHttpStatus } from '@teable/v2-contract-http'; import { executeCreateRecordsEndpoint, @@ -60,6 +67,7 @@ import { type IListTableRecordsQueryInput, type IPasteCommandInput, type IQueryBus, + type IRecordReadQuerySource, type PasteStreamResult, type RecordFilter, type RecordFilterDateValue, @@ -67,6 +75,7 @@ import { type RecordFilterNode, type RecordFilterOperator, type RecordFilterValue, + type RecordWritePluginRunnerOptions, } from '@teable/v2-core'; import type { DependencyContainer } from '@teable/v2-di'; import { pick } from 'lodash'; @@ -77,13 +86,13 @@ import { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exc import { DataDbClientManager } from '../../../global/data-db-client-manager.service'; import type { IClsStore } from '../../../types/cls'; 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 { buildUndoRedoEnginePreferenceKey } from '../../undo-redo/open-api/undo-redo-engine-preference'; -import { V2_RECORD_PASTE_AUDIT_CONTEXT_KEY } from '../../v2/v2-audit-log.constants'; import { V2ContainerService } from '../../v2/v2-container.service'; import { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory'; import { RecordPermissionService } from '../record-permission.service'; @@ -92,6 +101,9 @@ import { RecordService } from '../record.service'; const internalServerError = 'Internal server error'; const invalidFilterCode = 'validation.invalid_filter'; const dataTxClientKey = 'dataTx.client'; +const maxResolveSelectionRecordIdsPageSize = 1000; +const describeTraceError = (error: unknown): string => + error instanceof Error ? error.message : String(error); const v1SymbolOperatorMap: Record = { '=': 'is', '!=': 'isNot', @@ -122,7 +134,8 @@ export class RecordOpenApiV2Service { private readonly recordPermissionService: RecordPermissionService, private readonly aggregationService: AggregationService, private readonly dataDbClientManager: DataDbClientManager, - private readonly audit: AuditScope + private readonly audit: AuditScope, + @Optional() private readonly attachmentsService?: AttachmentsService ) {} private throwV2Error( @@ -227,25 +240,36 @@ export class RecordOpenApiV2Service { } const container = await this.v2ContainerService.getContainerForTable(tableId); - const context = await this.createV2ReadContext(tableId, query, container); - const enabledFieldIds = ( - context as IExecutionContext & { - recordReadQuerySource?: { enabledFieldIds?: string[] }; - } - ).recordReadQuerySource?.enabledFieldIds; + const { context, recordReadQuerySource } = await this.createV2ReadContext( + tableId, + query, + container + ); + const enabledFieldIds = recordReadQuerySource?.enabledFieldIds; const effectiveQuery = { ...query, ...this.sanitizeReadableSortAndGroup(query, enabledFieldIds), } satisfies IGetRecordsRo; const requestedFieldKeyType = query.fieldKeyType ?? FieldKeyType.Name; - const snapshotProjection = await this.resolveSnapshotProjection( - tableId, - query, - requestedFieldKeyType, - enabledFieldIds + 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( + context, + 'teable.RecordOpenApiV2Service.normalizeFilter', + { + 'record.read.has_filter': Boolean(query.filter), + }, + () => this.normalizeFilterForV2(tableId, query.filter) ); - const normalizedFilter = await this.normalizeFilterForV2(tableId, query.filter); const sortWithGroupFallback = this.mergeGroupByIntoSort( effectiveQuery.groupBy, effectiveQuery.orderBy @@ -255,38 +279,51 @@ export class RecordOpenApiV2Service { order: item.order, })); const normalizedGroupBy = effectiveQuery.groupBy?.map((item) => item.fieldId); - const queryExtra = this.shouldLoadQueryExtra(effectiveQuery) - ? await this.withTableDataClient(tableId, () => this.getQueryExtra(tableId, effectiveQuery)) - : undefined; + const queryExtra = await this.loadQueryExtraWithTrace(context, tableId, effectiveQuery); const queryBus = container.resolve(v2CoreTokens.queryBus); - const pageResult = await this.executeListRecordsEndpoint( + const pageResult = await this.withRecordReadSpan( + context, + 'teable.RecordOpenApiV2Service.listRecordIds', { - tableId, - // FieldKeyPipe has normalized request field keys to ids. - fieldKeyType: FieldKeyType.Id, - limit: query.take, - offset: query.skip, - ...(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 } - : {}), + '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, }, - context, - queryBus + () => + 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 ? { recordReadQuerySource } : undefined + ) ); const orderedRecords = pageResult.records; @@ -295,37 +332,66 @@ export class RecordOpenApiV2Service { } const recordIds = orderedRecords.map((record) => record.id); - const snapshots = await this.withTableDataClient(tableId, () => - this.recordService.getSnapshotBulkWithPermission( - tableId, - recordIds, - snapshotProjection, - requestedFieldKeyType, - query.cellFormat, - true - ) + const snapshots = await this.withRecordReadSpan( + context, + 'teable.RecordOpenApiV2Service.snapshotBulk', + { + 'record.read.record_count': recordIds.length, + 'record.read.has_snapshot_projection': Boolean(snapshotProjection), + }, + () => + this.withTableDataClient(tableId, () => + this.recordService.getSnapshotBulkWithPermission( + tableId, + recordIds, + snapshotProjection, + requestedFieldKeyType, + query.cellFormat, + true + ) + ) ); - if (snapshots.length !== recordIds.length) { - throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); - } + 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)); + 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); - } + if (records.length !== recordIds.length) { + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } - const normalizedRecords = await this.formatSystemDatetimeFields( - tableId, - records, - query.cellFormat, - sortWithGroupFallback?.map((item) => item.fieldId) + return records; + } + ); + + const normalizedRecords = await this.withRecordReadSpan( + context, + 'teable.RecordOpenApiV2Service.formatRecords', + { + 'record.read.record_count': records.length, + 'record.read.sorted_field_count': sortWithGroupFallback?.length ?? 0, + }, + () => + this.formatSystemDatetimeFields( + tableId, + records, + query.cellFormat, + sortWithGroupFallback?.map((item) => item.fieldId) + ) ); return queryExtra @@ -333,6 +399,51 @@ export class RecordOpenApiV2Service { : { 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; + } + + 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) { @@ -427,7 +538,9 @@ export class RecordOpenApiV2Service { return formatting as IDatetimeFormatting; } - private toProjectionMap(fieldKeys?: string | string[]): Record | undefined { + private toProjectionMap( + fieldKeys?: string | ReadonlyArray + ): Record | undefined { if (!fieldKeys) { return undefined; } @@ -447,7 +560,7 @@ export class RecordOpenApiV2Service { tableId: string, query: IGetRecordsRo, fieldKeyType: FieldKeyType, - enabledFieldIds?: string[] + enabledFieldIds?: ReadonlyArray ): Promise | undefined> { const explicitProjection = this.toProjectionMap( query.projection as unknown as string | string[] @@ -462,7 +575,7 @@ export class RecordOpenApiV2Service { } const visibleFields = await this.fieldService.getFieldsByQuery(tableId, { - projection: enabledFieldIds, + projection: [...enabledFieldIds], }); const projectionKeys = visibleFields .map((field) => { @@ -503,12 +616,13 @@ export class RecordOpenApiV2Service { private async executeListRecordsEndpoint( input: IListTableRecordsQueryInput, context: IExecutionContext, - queryBus: IQueryBus + queryBus: IQueryBus, + options?: { recordReadQuerySource?: IRecordReadQuerySource } ): Promise<{ records: Array<{ id: string; fields: Record }>; pagination: { hasMore: boolean }; }> { - const result = await executeListTableRecordsEndpoint(context, input, queryBus); + 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 }>, @@ -529,29 +643,32 @@ export class RecordOpenApiV2Service { tableId: string, query: Pick, container: DependencyContainer - ): Promise { + ): 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; + return { context }; } return { - ...context, + context, recordReadQuerySource: { tableName: readSource.tableName, cteName: readSource.cteName, cteSql: readSource.cteSql, enabledFieldIds: readSource.enabledFieldIds, }, - } as IExecutionContext; + }; } private sanitizeReadableSortAndGroup( query: Pick, - enabledFieldIds?: string[] + enabledFieldIds?: ReadonlyArray ): Pick { if (!enabledFieldIds?.length) { return { @@ -571,7 +688,49 @@ export class RecordOpenApiV2Service { } private shouldLoadQueryExtra(query: IGetRecordsRo): boolean { - return Boolean(query.search || query.groupBy?.length || query.collapsedGroupIds?.length); + if (query.includeQueryExtra === false) { + 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; + } + + private async loadQueryExtraWithTrace( + context: IExecutionContext, + tableId: string, + query: IGetRecordsRo + ): Promise { + const shouldLoad = this.shouldLoadQueryExtra(query); + + return await this.withRecordReadSpan( + context, + 'teable.RecordOpenApiV2Service.queryExtra', + { + 'record.read.query_extra_enabled': shouldLoad, + 'record.read.include_query_extra': query.includeQueryExtra !== false, + 'record.read.has_search': Boolean(query.search), + 'record.read.group_by_count': query.groupBy?.length ?? 0, + 'record.read.collapsed_group_count': query.collapsedGroupIds?.length ?? 0, + 'record.read.has_explicit_projection': Boolean(query.projection), + }, + () => + shouldLoad + ? this.withTableDataClient(tableId, () => this.getQueryExtra(tableId, query)) + : Promise.resolve(undefined) + ); } private async getQueryExtra( @@ -598,6 +757,50 @@ export class RecordOpenApiV2Service { return result.extra; } + private async withRecordReadSpan( + context: IExecutionContext, + name: string, + attributes: Record, + callback: () => Promise + ): Promise { + const span = context.tracer?.startSpan(name, attributes); + if (!span || !context.tracer) { + return await callback(); + } + + return await context.tracer.withSpan(span, async () => { + try { + return await callback(); + } catch (error) { + span.recordError(describeTraceError(error)); + throw error; + } finally { + span.end(); + } + }); + } + + private withRecordReadSyncSpan( + context: IExecutionContext, + name: string, + attributes: Record, + callback: () => T + ): T { + const span = context.tracer?.startSpan(name, attributes); + if (!span) { + return callback(); + } + + try { + return callback(); + } catch (error) { + span.recordError(describeTraceError(error)); + throw error; + } finally { + span.end(); + } + } + async updateRecord( tableId: string, recordId: string, @@ -650,7 +853,13 @@ export class RecordOpenApiV2Service { throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); } - async updateRecords(tableId: string, updateRecordsRo: IUpdateRecordsRo): Promise { + async updateRecords( + tableId: string, + updateRecordsRo: IUpdateRecordsRo, + options?: { + recordWritePluginRunnerOptions?: RecordWritePluginRunnerOptions; + } + ): Promise { const rawRecords = updateRecordsRo.records ?? []; const records = this.mergeDuplicateRecordUpdates(rawRecords); const recordIds = records.map((record) => record.id); @@ -689,7 +898,10 @@ export class RecordOpenApiV2Service { fieldKeyType: updateRecordsRo.fieldKeyType ?? FieldKeyType.Name, ...(updateRecordsRo.order ? { order: updateRecordsRo.order } : {}), }, - commandBus + commandBus, + { + recordWritePluginRunnerOptions: options?.recordWritePluginRunnerOptions, + } ); if (!(updateResult.status === 200 && updateResult.body.ok)) { if (!updateResult.body.ok) { @@ -711,6 +923,100 @@ export class RecordOpenApiV2Service { return updateResult.body.data.records; } + private async getValidateAttachmentRecord(tableId: string, recordId: string, fieldId: string) { + const field = await this.fieldService.getField(tableId, fieldId); + + if (field.type !== FieldType.Attachment) { + throw new CustomHttpException('Field is not an attachment', HttpErrorCode.VALIDATION_ERROR, { + localization: { + i18nKey: 'httpErrors.field.notAttachment', + }, + }); + } + + if (field.isComputed) { + throw new CustomHttpException('Field is computed', HttpErrorCode.VALIDATION_ERROR, { + localization: { + i18nKey: 'httpErrors.field.isComputed', + }, + }); + } + + const recordData = await this.recordService.getRecordsById(tableId, [recordId]); + const record = recordData.records[0]; + if (!record) { + throw new CustomHttpException(`Record ${recordId} not found`, HttpErrorCode.NOT_FOUND, { + localization: { + i18nKey: 'httpErrors.record.notFound', + }, + }); + } + return record; + } + + async uploadAttachment( + tableId: string, + recordId: string, + fieldId: string, + file?: Express.Multer.File, + fileUrl?: string + ) { + if (!file && !fileUrl) { + throw new CustomHttpException('No file or URL provided', HttpErrorCode.VALIDATION_ERROR, { + localization: { + i18nKey: 'httpErrors.record.noFileOrUrlProvided', + }, + }); + } + + if (!this.attachmentsService) { + throw new CustomHttpException(internalServerError, HttpErrorCode.INTERNAL_SERVER_ERROR); + } + + const record = await this.getValidateAttachmentRecord(tableId, recordId, fieldId); + const attachmentItem = file + ? await this.attachmentsService.uploadFile(file) + : await this.attachmentsService.uploadFromUrl(fileUrl as string); + + return await this.updateRecord(tableId, recordId, { + fieldKeyType: FieldKeyType.Id, + record: { + fields: { + [fieldId]: ((record.fields[fieldId] || []) as IAttachmentItem[]).concat(attachmentItem), + }, + }, + }); + } + + async insertAttachment( + tableId: string, + recordId: string, + fieldId: string, + attachments: IAttachmentItem[], + anchorId?: string + ) { + if (!attachments.length) { + throw new CustomHttpException('No attachments provided', HttpErrorCode.VALIDATION_ERROR); + } + + const record = await this.getValidateAttachmentRecord(tableId, recordId, fieldId); + const current = (record.fields[fieldId] || []) as IAttachmentItem[]; + const anchorIndex = anchorId ? current.findIndex((item) => item.id === anchorId) : -1; + const next = + anchorIndex >= 0 + ? [...current.slice(0, anchorIndex + 1), ...attachments, ...current.slice(anchorIndex + 1)] + : current.concat(attachments); + + return await this.updateRecord(tableId, recordId, { + fieldKeyType: FieldKeyType.Id, + record: { + fields: { + [fieldId]: next, + }, + }, + }); + } + async createRecords( tableId: string, createRecordsRo: ICreateRecordsRo, @@ -790,11 +1096,6 @@ export class RecordOpenApiV2Service { const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); const context = await this.v2ContextFactory.createContext(container); - ( - context as IExecutionContext & { - [V2_RECORD_PASTE_AUDIT_CONTEXT_KEY]?: boolean; - } - )[V2_RECORD_PASTE_AUDIT_CONTEXT_KEY] = true; const preparedPaste = await this.preparePasteCommandInput(tableId, pasteRo, options); const result = await executePasteEndpoint(context, preparedPaste.commandInput, commandBus); @@ -864,11 +1165,6 @@ export class RecordOpenApiV2Service { const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); const context = await this.v2ContextFactory.createContext(container); - ( - context as IExecutionContext & { - [V2_RECORD_PASTE_AUDIT_CONTEXT_KEY]?: boolean; - } - )[V2_RECORD_PASTE_AUDIT_CONTEXT_KEY] = true; const preparedPaste = await this.preparePasteCommandInput(tableId, pasteRo, options); const commandResult = PasteStreamCommand.create(preparedPaste.commandInput); @@ -893,6 +1189,81 @@ export class RecordOpenApiV2Service { return this.wrapStreamAndClearPreference(result.value, tableId); } + async pasteByIdStream( + tableId: string, + pasteRo: IPasteByIdStreamRo, + options?: { + updateFilter?: IFilterSet | null; + windowId?: string; + allowFieldExpansion?: boolean; + allowRecordExpansion?: boolean; + } + ): Promise> { + const fieldIds = this.resolveSelectedFieldIds(pasteRo.selection); + const recordIds = this.resolveSelectedRecordIds(pasteRo.selection); + const syntheticPasteRo: IPasteRo = { + ...pasteRo, + projection: fieldIds ?? pasteRo.projection, + ranges: [ + [0, 0], + [Math.max((fieldIds?.length ?? 1) - 1, 0), Math.max((recordIds?.length ?? 1) - 1, 0)], + ], + }; + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + + const preparedPaste = await this.preparePasteCommandInput(tableId, syntheticPasteRo, options); + const commandResult = PasteStreamCommand.create({ + ...preparedPaste.commandInput, + targetRecordIds: recordIds, + excludedTargetRecordIds: this.resolveExcludedRecordIds(pasteRo.selection), + targetFieldIds: fieldIds, + }); + if (commandResult.isErr()) { + this.throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + this.throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + + return this.wrapStreamAndClearPreference(result.value, tableId); + } + + private resolveSelectedRecordIds(selection: ISelectionIdsRo['selection']): string[] | undefined { + if (selection.allRecords) { + return []; + } + const excluded = new Set(selection.excludedRecordIds ?? []); + return selection.recordIds?.filter((recordId) => !excluded.has(recordId)); + } + + private resolveExcludedRecordIds(selection: ISelectionIdsRo['selection']): string[] | undefined { + if (!selection.allRecords) { + return undefined; + } + return selection.excludedRecordIds?.length ? selection.excludedRecordIds : undefined; + } + + private resolveSelectedFieldIds(selection: ISelectionIdsRo['selection']): string[] | undefined { + if (selection.allFields) { + return undefined; + } + const excluded = new Set(selection.excludedFieldIds ?? []); + return selection.fieldIds?.filter((fieldId) => !excluded.has(fieldId)); + } + private async preparePasteCommandInput( tableId: string, pasteRo: IPasteRo, @@ -1333,6 +1704,60 @@ export class RecordOpenApiV2Service { return this.wrapStreamAndClearPreference(result.value, tableId); } + async clearByIdStream( + tableId: string, + selectionRo: ISelectionIdsRo + ): Promise> { + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const rangeQuery = await this.normalizeRangeQuery(tableId, selectionRo); + const normalizedFilter = await this.normalizeFilterForV2(tableId, rangeQuery.filter); + const sortWithGroupFallback = this.mergeGroupByIntoSort(rangeQuery.groupBy, rangeQuery.orderBy); + + const commandResult = ClearStreamCommand.create({ + tableId, + viewId: rangeQuery.viewId, + ranges: [ + [0, 0], + [0, 0], + ], + projection: selectionRo.projection, + filter: normalizedFilter, + search: rangeQuery.search, + sort: sortWithGroupFallback, + groupBy: rangeQuery.groupBy?.map((item) => ({ + fieldId: item.fieldId, + order: item.order, + })), + ignoreViewQuery: rangeQuery.ignoreViewQuery, + targetRecordIds: selectionRo.selection.allRecords + ? [] + : this.resolveSelectedRecordIds(selectionRo.selection), + excludedTargetRecordIds: this.resolveExcludedRecordIds(selectionRo.selection), + targetFieldIds: this.resolveSelectedFieldIds(selectionRo.selection), + }); + if (commandResult.isErr()) { + this.throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + this.throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + + return this.wrapStreamAndClearPreference(result.value, tableId); + } + /** * Get record IDs from ranges for undo/redo support and permission checks. * This method queries the record IDs that will be affected by a range-based operation. @@ -1509,6 +1934,58 @@ export class RecordOpenApiV2Service { return this.wrapStreamAndClearPreference(result.value, tableId); } + async deleteByIdStream( + tableId: string, + selectionRo: ISelectionIdsRo + ): Promise> { + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + const rangeQuery = await this.normalizeRangeQuery(tableId, selectionRo); + const sortWithGroupFallback = this.mergeGroupByIntoSort(rangeQuery.groupBy, rangeQuery.orderBy); + + const commandResult = DeleteByRangeStreamCommand.create({ + tableId, + viewId: rangeQuery.viewId, + ranges: [ + [0, 0], + [0, 0], + ], + filter: await this.normalizeFilterForV2(tableId, rangeQuery.filter), + sort: sortWithGroupFallback?.map((item) => ({ + fieldId: item.fieldId, + order: item.order, + })), + search: rangeQuery.search, + groupBy: rangeQuery.groupBy?.map((item) => ({ + fieldId: item.fieldId, + order: item.order, + })), + ignoreViewQuery: rangeQuery.ignoreViewQuery, + targetRecordIds: this.resolveSelectedRecordIds(selectionRo.selection), + excludedTargetRecordIds: this.resolveExcludedRecordIds(selectionRo.selection), + }); + if (commandResult.isErr()) { + this.throwV2Error( + mapDomainErrorToHttpError(commandResult.error), + mapDomainErrorToHttpStatus(commandResult.error) + ); + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + this.throwV2Error( + mapDomainErrorToHttpError(result.error), + mapDomainErrorToHttpStatus(result.error) + ); + } + + return this.wrapStreamAndClearPreference(result.value, tableId); + } + async duplicateByRangeStream( tableId: string, rangesRo: IRangesRo @@ -1585,20 +2062,33 @@ export class RecordOpenApiV2Service { recordsBeforeDelete.push(...(page.records as IRecord[])); } - const v2Input = { - tableId, - recordIds, + await this.executeDeleteRecordsCommand(context, commandBus, tableId, recordIds); + + // Return records that were deleted (V1 format) + return { + records: recordsBeforeDelete, }; + } - const result = await executeDeleteRecordsEndpoint(context, v2Input, commandBus); + async deleteRecordsByIds(tableId: string, recordIds: string[], _windowId?: string): Promise { + 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); + } + + private async executeDeleteRecordsCommand( + context: IExecutionContext, + commandBus: ICommandBus, + tableId: string, + recordIds: string[] + ): Promise { + const result = await executeDeleteRecordsEndpoint(context, { tableId, recordIds }, commandBus); if (result.status === 200 && result.body.ok) { await this.clearUndoRedoEnginePreference(tableId); - - // Return records that were deleted (V1 format) - return { - records: recordsBeforeDelete, - }; + return; } if (!result.body.ok) { 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 de4af4478c..5d773e1b7c 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 @@ -166,6 +166,7 @@ export class RecordOpenApiController { ); } + @UseV2Feature('updateRecord') @Permissions('record|update') @Post(':recordId/:fieldId/uploadAttachment') @UseInterceptors(FileInterceptor('file')) @@ -185,6 +186,16 @@ export class RecordOpenApiController { }, }); + if (this.cls.get('useV2')) { + return await this.recordOpenApiV2Service.uploadAttachment( + tableId, + recordId, + fieldId, + file, + fileUrl + ); + } + return await this.recordOpenApiService.uploadAttachment( tableId, recordId, @@ -194,6 +205,7 @@ export class RecordOpenApiController { ); } + @UseV2Feature('updateRecord') @Permissions('record|update') @Post(':recordId/:fieldId/insertAttachment') async insertAttachment( @@ -211,6 +223,16 @@ export class RecordOpenApiController { }, }); + if (this.cls.get('useV2')) { + return await this.recordOpenApiV2Service.insertAttachment( + tableId, + recordId, + fieldId, + body.attachments, + body.anchorId + ); + } + return await this.recordOpenApiService.insertAttachment( tableId, recordId, diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.spec.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.spec.ts index 41a934ce7d..3c1059d05e 100644 --- a/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.spec.ts +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.spec.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import { RecordOpenApiService } from './record-open-api.service'; +const userId = 'usr1'; +const startDate = '2026-01-01T00:00:00.000Z'; +const endDate = '2026-01-02T00:00:00.000Z'; + const createService = ({ prismaService = {}, dataPrismaService = {}, @@ -14,7 +18,6 @@ const createService = ({ } = {}) => new RecordOpenApiService( prismaService as never, - dataPrismaService as never, recordService as never, {} as never, {} as never, @@ -26,7 +29,8 @@ const createService = ({ {} as never, (dataDbClientManager ?? { dataPrismaForTable: vi.fn().mockResolvedValue(dataPrismaService), - }) as never + }) as never, + {} as never ); describe('RecordOpenApiService', () => { @@ -43,13 +47,13 @@ describe('RecordOpenApiService', () => { fieldId: 'fld1', before: JSON.stringify({ meta: { type: 'singleLineText' }, data: 'old' }), after: JSON.stringify({ meta: { type: 'singleLineText' }, data: 'new' }), - createdTime: new Date('2026-01-01T00:00:00.000Z'), - createdBy: 'usr1', + createdTime: new Date(startDate), + createdBy: userId, }, ]); const userFindMany = vi.fn().mockResolvedValue([ { - id: 'usr1', + id: userId, name: 'Ada', email: 'ada@example.com', avatar: null, @@ -72,15 +76,12 @@ describe('RecordOpenApiService', () => { }, }); - const result = await service.getRecordHistory( - 'tbl1', - 'rec1', - { - startDate: '2026-01-01T00:00:00.000Z', - endDate: '2026-01-02T00:00:00.000Z', - }, - ['fld1'] - ); + const result = await service.getRecordHistory('tbl1', 'rec1', { + startDate, + endDate, + fieldIds: ['fld1'], + createdByIds: [userId], + }); expect(dataRecordHistoryFindMany).toHaveBeenCalledWith( expect.objectContaining({ @@ -88,6 +89,7 @@ describe('RecordOpenApiService', () => { tableId: 'tbl1', recordId: 'rec1', fieldId: { in: ['fld1'] }, + createdBy: { in: [userId] }, }), take: 21, orderBy: { createdTime: 'desc' }, @@ -96,7 +98,7 @@ describe('RecordOpenApiService', () => { expect(dataPrismaForTable).toHaveBeenCalledWith('tbl1'); expect(metaRecordHistoryFindMany).not.toHaveBeenCalled(); expect(userFindMany).toHaveBeenCalledWith({ - where: { id: { in: ['usr1'] } }, + where: { id: { in: [userId] } }, select: { id: true, name: true, @@ -112,15 +114,48 @@ describe('RecordOpenApiService', () => { fieldId: 'fld1', before: { meta: { type: 'singleLineText' }, data: 'old' }, after: { meta: { type: 'singleLineText' }, data: 'new' }, - createdTime: '2026-01-01T00:00:00.000Z', - createdBy: 'usr1', + createdTime: startDate, + createdBy: userId, }, ]); - expect(result.userMap.usr1).toEqual({ - id: 'usr1', + expect(result.userMap[userId]).toEqual({ + id: userId, name: 'Ada', email: 'ada@example.com', avatar: null, }); }); + + it('keeps field filtering when selected fields are outside projection', async () => { + const dataRecordHistoryFindMany = vi.fn().mockResolvedValue([]); + const userFindMany = vi.fn().mockResolvedValue([]); + + const service = createService({ + prismaService: { + user: { findMany: userFindMany }, + }, + dataPrismaService: { + recordHistory: { findMany: dataRecordHistoryFindMany }, + }, + }); + + await service.getRecordHistory( + 'tbl1', + 'rec1', + { + fieldIds: ['fldDenied'], + }, + ['fldAllowed'] + ); + + expect(dataRecordHistoryFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + tableId: 'tbl1', + recordId: 'rec1', + fieldId: { in: [] }, + }), + }) + ); + }); }); diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts index 524128c1ef..3294f2701b 100644 --- a/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts @@ -42,6 +42,21 @@ import type { IRecordInnerRo } from '../record.service'; import { RecordService } from '../record.service'; import type { IUpdateRecordsInternalRo } from '../type'; +const getAllowedRecordHistoryFieldIds = ( + fieldIds?: string[], + projectionIds?: string[] +): string[] | undefined => { + if (!fieldIds?.length) { + return projectionIds; + } + + if (!projectionIds?.length) { + return fieldIds; + } + + return fieldIds.filter((fieldId) => projectionIds.includes(fieldId)); +}; + @Injectable() export class RecordOpenApiService { constructor( @@ -184,8 +199,10 @@ export class RecordOpenApiService { query: IGetRecordHistoryQuery, projectionIds?: string[] ): Promise { - const { cursor, startDate, endDate } = query; + const { cursor, startDate, endDate, fieldIds, createdByIds } = query; const limit = 20; + const allowedFieldIds = getAllowedRecordHistoryFieldIds(fieldIds, projectionIds); + const shouldFilterByField = Boolean(fieldIds?.length || projectionIds?.length); const dateFilter: { [key: string]: Date } = {}; if (startDate) { @@ -201,7 +218,8 @@ export class RecordOpenApiService { tableId, ...(recordId ? { recordId } : {}), ...(Object.keys(dateFilter).length > 0 ? { createdTime: dateFilter } : {}), - ...(projectionIds?.length ? { fieldId: { in: projectionIds } } : {}), + ...(shouldFilterByField ? { fieldId: { in: allowedFieldIds ?? [] } } : {}), + ...(createdByIds?.length ? { createdBy: { in: createdByIds } } : {}), }, select: { id: true, diff --git a/apps/nestjs-backend/src/features/record/query-builder/field-cte-visitor.ts b/apps/nestjs-backend/src/features/record/query-builder/field-cte-visitor.ts index 54f5edeaa0..db57e9f38d 100644 --- a/apps/nestjs-backend/src/features/record/query-builder/field-cte-visitor.ts +++ b/apps/nestjs-backend/src/features/record/query-builder/field-cte-visitor.ts @@ -736,7 +736,8 @@ export class FieldCteVisitor implements IFieldVisitor { state: IMutableQueryBuilderState | undefined, private readonly dialect: IRecordQueryDialectProvider, projection?: string[], - expandFormulaReferences: boolean = true + expandFormulaReferences: boolean = true, + private readonly preferStoredLookupFields: boolean = false ) { this.state = state ?? new RecordQueryBuilderManager('table'); this._table = tables.mustGetEntryTable(); @@ -752,6 +753,15 @@ export class FieldCteVisitor implements IFieldVisitor { return this.state.getFieldCteMap(); } + private shouldUseStoredLookupField(field: FieldCore): boolean { + return ( + this.preferStoredLookupFields && + (field.isLookup || + field.type === FieldType.Rollup || + field.type === FieldType.ConditionalRollup) + ); + } + private unwrapSelectName(selection: IFieldSelectName | string): string { return typeof selection === 'string' ? selection : selection.toQuery(); } @@ -948,7 +958,7 @@ export class FieldCteVisitor implements IFieldVisitor { } } - private getBlockedLinkFieldIds(currentLinkFieldId: string): ReadonlySet | undefined { + private getBlockedLinkFieldIds(_currentLinkFieldId: string): ReadonlySet | undefined { if (!this.linkCteGenerationStack.size) { return undefined; } @@ -1354,10 +1364,6 @@ export class FieldCteVisitor implements IFieldVisitor { foreignAliasUsed, selectVisitor ); - const normalizedExpression = this.coerceConditionalLookupTargetExpression( - rawExpression, - targetField - ); const formattingVisitor = new FieldFormattingVisitor(rawExpression, this.dialect); const formattedExpression = targetField.accept(formattingVisitor); @@ -1944,6 +1950,10 @@ export class FieldCteVisitor implements IFieldVisitor { // This allows selecting lookup/rollup values even when the link fields themselves // are not part of the projection. for (const field of list) { + if (this.shouldUseStoredLookupField(field)) { + continue; + } + const linkFields = !this.expandFormulaReferences && field.type === FieldType.Formula ? [] @@ -1969,6 +1979,9 @@ export class FieldCteVisitor implements IFieldVisitor { } for (const field of list) { + if (this.shouldUseStoredLookupField(field)) { + continue; + } field.accept(this); } } diff --git a/apps/nestjs-backend/src/features/record/query-builder/field-select-visitor.ts b/apps/nestjs-backend/src/features/record/query-builder/field-select-visitor.ts index cd222cd5a8..1cecbc3859 100644 --- a/apps/nestjs-backend/src/features/record/query-builder/field-select-visitor.ts +++ b/apps/nestjs-backend/src/features/record/query-builder/field-select-visitor.ts @@ -28,8 +28,11 @@ import { DbFieldType, FieldType, isLinkLookupOptions, DriverClient } from '@teab // no driver-specific logic here; use dialect for differences import type { Knex } from 'knex'; import type { IDbProvider } from '../../../db-provider/db.provider.interface'; -import { AUTO_NUMBER_FIELD_NAME } from '../../field/constant'; -import { isSystemUserField } from '../../field/fields-utils'; +import { + AUTO_NUMBER_FIELD_NAME, + CREATED_BY_FIELD_NAME, + LAST_MODIFIED_BY_FIELD_NAME, +} from '../../field/constant'; import type { IFieldSelectName } from './field-select.type'; import type { IRecordSelectionMap, @@ -64,7 +67,8 @@ export class FieldSelectVisitor implements IFieldVisitor { private readonly preferRawFieldReferences: boolean = false, private readonly blockedLinkFieldIds?: ReadonlySet, private readonly readyLinkFieldIds?: ReadonlySet, - private readonly currentLinkFieldId?: string + private readonly currentLinkFieldId?: string, + private readonly preferStoredLookupFields: boolean = false ) {} private get tableAlias() { @@ -174,6 +178,18 @@ export class FieldSelectVisitor implements IFieldVisitor { return selector; } + private selectSystemUserSnapshot( + field: FieldCore, + systemColumnName: typeof CREATED_BY_FIELD_NAME | typeof LAST_MODIFIED_BY_FIELD_NAME + ): IFieldSelectName { + const snapshotRef = this.getColumnSelector(field) as string; + const alias = this.tableAlias; + const idFallbackRef = alias ? `"${alias}"."${systemColumnName}"` : `"${systemColumnName}"`; + const expr = this.dialect.buildUserJsonObjectFromSnapshot(snapshotRef, idFallbackRef); + this.state.setSelection(field.id, expr); + return this.qb.client.raw(expr); + } + // Typed NULL generation is delegated to the dialect implementation /** @@ -183,16 +199,16 @@ export class FieldSelectVisitor implements IFieldVisitor { private checkAndSelectLookupField(field: FieldCore): IFieldSelectName { // Check if this is a Lookup field if (field.isLookup) { + if (this.preferStoredLookupFields) { + const columnSelector = this.getColumnSelector(field); + this.state.setSelection(field.id, columnSelector); + return columnSelector; + } + const fieldCteMap = this.state.getFieldCteMap(); // Lookup has no standard column in base table. // When building from a materialized view, fallback to the view's column. if (this.shouldSelectRaw()) { - if (isSystemUserField(field) && !field.isLookup) { - const columnSelector = this.getColumnSelector(field) as string; - const expr = this.dialect.buildUserJsonObjectById(columnSelector); - this.state.setSelection(field.id, expr); - return this.qb.client.raw(expr); - } const columnSelector = this.getColumnSelector(field); this.state.setSelection(field.id, columnSelector); return columnSelector; @@ -460,6 +476,12 @@ export class FieldSelectVisitor implements IFieldVisitor { } visitRollupField(field: RollupFieldCore): IFieldSelectName { + if (this.preferStoredLookupFields) { + const columnSelector = this.getColumnSelector(field); + this.state.setSelection(field.id, columnSelector); + return columnSelector; + } + if (this.shouldSelectRaw()) { // In view context, select the view column directly const columnSelector = this.getColumnSelector(field); @@ -533,6 +555,12 @@ export class FieldSelectVisitor implements IFieldVisitor { } visitConditionalRollupField(field: ConditionalRollupFieldCore): IFieldSelectName { + if (this.preferStoredLookupFields) { + const columnSelector = this.getColumnSelector(field); + this.state.setSelection(field.id, columnSelector); + return columnSelector; + } + if (field.isLookup) { return this.checkAndSelectLookupField(field); } @@ -631,12 +659,8 @@ export class FieldSelectVisitor implements IFieldVisitor { if (field.isLookup) { return this.checkAndSelectLookupField(field); } - // Build JSON with user info from system column __created_by - const alias = this.tableAlias; - const idRef = alias ? `"${alias}"."__created_by"` : `"__created_by"`; - const expr = this.dialect.buildUserJsonObjectById(idRef); - this.state.setSelection(field.id, expr); - return this.qb.client.raw(expr); + + return this.selectSystemUserSnapshot(field, CREATED_BY_FIELD_NAME); } visitLastModifiedByField(field: LastModifiedByFieldCore): IFieldSelectName { @@ -646,12 +670,7 @@ export class FieldSelectVisitor implements IFieldVisitor { const trackAll = field.isTrackAll(); if (trackAll) { - // Build JSON with user info from system column __last_modified_by - const alias = this.tableAlias; - const idRef = alias ? `"${alias}"."__last_modified_by"` : `"__last_modified_by"`; - const expr = this.dialect.buildUserJsonObjectById(idRef); - this.state.setSelection(field.id, expr); - return this.qb.client.raw(expr); + return this.selectSystemUserSnapshot(field, LAST_MODIFIED_BY_FIELD_NAME); } return this.checkAndSelectLookupField(field); diff --git a/apps/nestjs-backend/src/features/record/query-builder/providers/pg-record-query-dialect.spec.ts b/apps/nestjs-backend/src/features/record/query-builder/providers/pg-record-query-dialect.spec.ts index 9324358393..885f524235 100644 --- a/apps/nestjs-backend/src/features/record/query-builder/providers/pg-record-query-dialect.spec.ts +++ b/apps/nestjs-backend/src/features/record/query-builder/providers/pg-record-query-dialect.spec.ts @@ -46,6 +46,36 @@ describe('PgRecordQueryDialect#linkExtractTitles', () => { }); }); +describe('PgRecordQueryDialect user snapshots', () => { + const dialect = new PgRecordQueryDialect({} as unknown as Knex); + + it('builds CreatedBy/LastModifiedBy display JSON without users joins', () => { + const sql = dialect.buildUserJsonObjectFromSnapshot( + '"t"."fld_created_by"', + '"t"."__created_by"' + ); + + expect(sql).toContain('to_jsonb("t"."fld_created_by")'); + expect(sql).toContain('"t"."__created_by"'); + expect(sql).toContain('jsonb_build_object'); + expect(sql).not.toContain('users'); + expect(sql).not.toContain('public.users'); + }); + + it('builds formula display text from snapshot with system id fallback', () => { + const sql = dialect.userTitleFromSnapshot( + '"t"."fld_last_modified_by"', + '"t"."__last_modified_by"' + ); + + expect(sql).toContain('to_jsonb("t"."fld_last_modified_by")'); + expect(sql).toContain("->>'title'"); + expect(sql).toContain('"t"."__last_modified_by"'); + expect(sql).not.toContain('users'); + expect(sql).not.toContain('public.users'); + }); +}); + describe('PgRecordQueryDialect#coerceToNumericForCompare', () => { const dialect = new PgRecordQueryDialect({} as unknown as Knex); diff --git a/apps/nestjs-backend/src/features/record/query-builder/providers/pg-record-query-dialect.ts b/apps/nestjs-backend/src/features/record/query-builder/providers/pg-record-query-dialect.ts index 4134964d57..66bb397eb1 100644 --- a/apps/nestjs-backend/src/features/record/query-builder/providers/pg-record-query-dialect.ts +++ b/apps/nestjs-backend/src/features/record/query-builder/providers/pg-record-query-dialect.ts @@ -294,17 +294,37 @@ export class PgRecordQueryDialect implements IRecordQueryDialectProvider { return `(${selectionSql}->>'title')`; } - selectUserNameById(idRef: string): string { - return `(SELECT u.name FROM users u WHERE u.id = ${idRef})`; + private userSnapshotJson(snapshotRef: string): string { + const snapshotJson = `to_jsonb(${snapshotRef})`; + return `(CASE + WHEN ${snapshotRef} IS NULL THEN NULL::jsonb + WHEN jsonb_typeof(${snapshotJson}) = 'object' THEN ${snapshotJson} + WHEN jsonb_typeof(${snapshotJson}) = 'string' THEN jsonb_build_object('id', ${snapshotRef}, 'title', ${snapshotRef}) + ELSE jsonb_build_object('id', ${snapshotRef}::text, 'title', ${snapshotRef}::text) + END)`; } - buildUserJsonObjectById(idRef: string): string { - return `( - SELECT jsonb_build_object('id', u.id, 'title', u.name, 'email', u.email) || - CASE WHEN u.is_system = true THEN jsonb_build_object('isSystem', true) ELSE '{}'::jsonb END - FROM users u - WHERE u.id = ${idRef} - )`; + userTitleFromSnapshot(snapshotRef: string, idFallbackRef?: string): string { + const snapshotJson = this.userSnapshotJson(snapshotRef); + const fallback = idFallbackRef ?? `${snapshotJson}->>'id'`; + return `COALESCE(${snapshotJson}->>'title', ${snapshotJson}->>'name', ${fallback})`; + } + + buildUserJsonObjectFromSnapshot(snapshotRef: string, idFallbackRef?: string): string { + const snapshotJson = this.userSnapshotJson(snapshotRef); + const fallback = idFallbackRef ?? `${snapshotJson}->>'id'`; + return `jsonb_strip_nulls( + CASE + WHEN ${snapshotJson} IS NULL AND ${idFallbackRef ? `${idFallbackRef} IS NULL` : 'TRUE'} THEN NULL::jsonb + ELSE COALESCE( + ${snapshotJson}, + jsonb_build_object('id', ${fallback}, 'title', ${fallback}) + ) || jsonb_build_object( + 'id', COALESCE(${snapshotJson}->>'id', ${fallback}), + 'title', COALESCE(${snapshotJson}->>'title', ${snapshotJson}->>'name', ${fallback}) + ) + END + )`; } flattenLookupCteValue( 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 eeaf81580f..7105fba7cf 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 @@ -49,6 +49,12 @@ export interface ICreateRecordQueryBuilderOptions { * Typically used alongside rawProjection when the consumer needs source values (e.g., jsonb) rather than formatted text. */ preferRawFieldReferences?: boolean; + /** + * When true, lookup-like computed fields use their persisted DB columns + * instead of expanding link/conditional CTEs. Intended for read paths where + * persisted computed values are the query source of truth. + */ + preferStoredLookupFields?: boolean; /** * Optional list of record IDs to restrict the query to before generating CTEs. * Useful when the caller intends to apply a final WHERE IN "__id" (...) filter anyway. @@ -90,6 +96,11 @@ export interface ICreateRecordAggregateBuilderOptions { currentUserId?: string; /** Optional projection to minimize CTE/select */ projection?: string[]; + /** + * When true, lookup-like computed fields use their persisted DB columns + * instead of expanding link/conditional CTEs. + */ + preferStoredLookupFields?: boolean; useQueryModel?: boolean; /** * Optional list of record IDs to restrict the query to before generating CTEs. diff --git a/apps/nestjs-backend/src/features/record/query-builder/record-query-builder.service.ts b/apps/nestjs-backend/src/features/record/query-builder/record-query-builder.service.ts index f4d859a3af..b36ed5e26a 100644 --- a/apps/nestjs-backend/src/features/record/query-builder/record-query-builder.service.ts +++ b/apps/nestjs-backend/src/features/record/query-builder/record-query-builder.service.ts @@ -157,7 +157,8 @@ export class RecordQueryBuilderService implements IRecordQueryBuilder { tables, state, options.projection, - options.preferRawFieldReferences ?? false + options.preferRawFieldReferences ?? false, + options.preferStoredLookupFields ?? false ); } @@ -181,6 +182,7 @@ export class RecordQueryBuilderService implements IRecordQueryBuilder { options: ICreateRecordQueryBuilderOptions ): Promise<{ qb: Knex.QueryBuilder; alias: string; selectionMap: IReadonlyRecordSelectionMap }> { const { tableId, filter, sort, currentUserId, restrictRecordIds } = options; + const preferStoredLookupFields = options.preferStoredLookupFields ?? !options.rawProjection; const { qb, alias, table, state } = await this.createQueryBuilder(from, tableId, { builder: options.builder, useQueryModel: options.useQueryModel, @@ -196,6 +198,7 @@ export class RecordQueryBuilderService implements IRecordQueryBuilder { hasSearch: options.hasSearch, restrictRecordIds, preferRawFieldReferences: options.preferRawFieldReferences, + preferStoredLookupFields, }); this.buildSelect( @@ -204,7 +207,8 @@ export class RecordQueryBuilderService implements IRecordQueryBuilder { state, options.projection, options.rawProjection, - options.preferRawFieldReferences ?? false + options.preferRawFieldReferences ?? false, + preferStoredLookupFields ); // Selection map collected as fields are visited. @@ -237,6 +241,7 @@ export class RecordQueryBuilderService implements IRecordQueryBuilder { defaultOrderField, limit, offset, + preferStoredLookupFields = true, } = options; const usePaginatedRange = limit !== undefined; // The tableCache path skips applyBasePaginationIfNeeded, which would silently @@ -255,9 +260,10 @@ export class RecordQueryBuilderService implements IRecordQueryBuilder { limit, offset, paginationMode: usePaginatedRange ? 'full' : undefined, + preferStoredLookupFields, }); - this.buildAggregateSelect(qb, table, state); + this.buildAggregateSelect(qb, table, state, options.projection, preferStoredLookupFields); const selectionMap = state.getSelectionMap(); if (filter) { @@ -305,7 +311,8 @@ export class RecordQueryBuilderService implements IRecordQueryBuilder { tables: Tables | undefined, state: IMutableQueryBuilderState, projection?: string[], - preferRawFieldReferences: boolean = false + preferRawFieldReferences: boolean = false, + preferStoredLookupFields: boolean = false ): void { if (!tables) { return; @@ -317,7 +324,8 @@ export class RecordQueryBuilderService implements IRecordQueryBuilder { state, this.dialect, projection, - !preferRawFieldReferences + !preferRawFieldReferences, + preferStoredLookupFields ); visitor.build(); } @@ -628,7 +636,8 @@ export class RecordQueryBuilderService implements IRecordQueryBuilder { state: IMutableQueryBuilderState, projection?: string[], rawProjection: boolean = false, - preferRawFieldReferences: boolean = false + preferRawFieldReferences: boolean = false, + preferStoredLookupFields: boolean = false ): this { const readyLinkFieldIds = this.getReadyLinkFieldIds(state); const visitor = new FieldSelectVisitor( @@ -641,7 +650,9 @@ export class RecordQueryBuilderService implements IRecordQueryBuilder { rawProjection, preferRawFieldReferences, undefined, - readyLinkFieldIds + readyLinkFieldIds, + undefined, + preferStoredLookupFields ); const alias = getTableAliasFromTable(table); @@ -672,7 +683,9 @@ export class RecordQueryBuilderService implements IRecordQueryBuilder { private buildAggregateSelect( qb: Knex.QueryBuilder, table: TableDomain, - state: IMutableQueryBuilderState + state: IMutableQueryBuilderState, + projection?: string[], + preferStoredLookupFields: boolean = false ): this { const readyLinkFieldIds = this.getReadyLinkFieldIds(state); const visitor = new FieldSelectVisitor( @@ -685,11 +698,15 @@ export class RecordQueryBuilderService implements IRecordQueryBuilder { false, false, undefined, - readyLinkFieldIds + readyLinkFieldIds, + undefined, + preferStoredLookupFields ); - // Add field-specific selections using visitor pattern - for (const field of table.fields.ordered) { + // Add field-specific selections using visitor pattern. Aggregations only need + // selections for fields referenced by aggregation/group/search/filter callers. + const orderedFields = getOrderedFieldsByProjection(table, projection, true) as FieldCore[]; + for (const field of orderedFields) { field.accept(visitor); } diff --git a/apps/nestjs-backend/src/features/record/query-builder/record-query-dialect.interface.ts b/apps/nestjs-backend/src/features/record/query-builder/record-query-dialect.interface.ts index 20a1730959..f639d92912 100644 --- a/apps/nestjs-backend/src/features/record/query-builder/record-query-dialect.interface.ts +++ b/apps/nestjs-backend/src/features/record/query-builder/record-query-dialect.interface.ts @@ -139,24 +139,16 @@ export interface IRecordQueryDialectProvider { jsonTitleFromExpr(selectionSql: string): string; /** - * Subquery snippet to select user name by id. - * @example - * ```ts - * dialect.selectUserNameById('"t"."__created_by"') - * // PG: (SELECT u.name FROM users u WHERE u.id = "t"."__created_by") - * ``` + * Extract a display title from a stored user JSON snapshot. + * Falls back to the system user id when the snapshot is missing or still scalar. */ - selectUserNameById(idRef: string): string; + userTitleFromSnapshot(snapshotRef: string, idFallbackRef?: string): string; /** - * Build a JSON object for system user fields: { id, title, email }. - * @example - * ```ts - * dialect.buildUserJsonObjectById('"t"."__created_by"') - * // PG: (SELECT jsonb_build_object('id', u.id, 'title', u.name, 'email', u.email) FROM users u WHERE u.id = "t"."__created_by") - * ``` + * Build a user JSON object from a stored user JSON snapshot. + * Falls back to a minimal { id, title } object from the system user id when missing. */ - buildUserJsonObjectById(idRef: string): string; + buildUserJsonObjectFromSnapshot(snapshotRef: string, idFallbackRef?: string): string; // Lookup CTE helpers diff --git a/apps/nestjs-backend/src/features/record/query-builder/sql-conversion.visitor.ts b/apps/nestjs-backend/src/features/record/query-builder/sql-conversion.visitor.ts index 7d38463274..9097d5f628 100644 --- a/apps/nestjs-backend/src/features/record/query-builder/sql-conversion.visitor.ts +++ b/apps/nestjs-backend/src/features/record/query-builder/sql-conversion.visitor.ts @@ -2254,17 +2254,22 @@ export class SelectColumnSqlConversionVisitor extends BaseSqlConversionVisitor { + const freezeError = new CustomHttpException( + 'Space data database migration is in progress', + HttpErrorCode.CONFLICT, + { + errorCode: 'SPACE_DATA_DB_MIGRATING', + migrationJobId: 'sdmjxxx', + } + ); + const createService = { + multipleCreateRecords: vi.fn(), + createRecords: vi.fn(), + createRecordsOnlySql: vi.fn(), + }; + const updateService = { + updateRecords: vi.fn(), + simpleUpdateRecords: vi.fn(), + }; + const deleteService = { + deleteRecord: vi.fn(), + deleteRecords: vi.fn(), + }; + const duplicateService = { + duplicateRecord: vi.fn(), + }; + const tableDomainQueryService = { + getTableDomainById: vi.fn(), + }; + const migrationGuard = { + assertTableWritable: vi.fn(), + }; + + const service = () => + new RecordModifyService( + createService as never, + updateService as never, + deleteService as never, + duplicateService as never, + tableDomainQueryService as never, + migrationGuard as never + ); + + beforeEach(() => { + vi.clearAllMocks(); + migrationGuard.assertTableWritable.mockRejectedValue(freezeError); + }); + + it('rejects record create before any table metadata or write work when the space is migrating', async () => { + await expect(service().multipleCreateRecords('tblxxx', { records: [] })).rejects.toBe( + freezeError + ); + + expect(migrationGuard.assertTableWritable).toHaveBeenCalledWith('tblxxx'); + expect(createService.multipleCreateRecords).not.toHaveBeenCalled(); + expect(tableDomainQueryService.getTableDomainById).not.toHaveBeenCalled(); + }); + + it('rejects record update/delete/duplicate before delegating when the space is migrating', async () => { + await expect(service().updateRecords('tblxxx', { records: [] })).rejects.toBe(freezeError); + await expect(service().deleteRecords('tblxxx', ['recxxx'])).rejects.toBe(freezeError); + await expect( + service().duplicateRecord('tblxxx', 'recxxx', { + viewId: 'viwxxx', + anchorId: 'recanchor', + position: 'after', + }) + ).rejects.toBe(freezeError); + + expect(migrationGuard.assertTableWritable).toHaveBeenCalledTimes(3); + expect(updateService.updateRecords).not.toHaveBeenCalled(); + expect(deleteService.deleteRecords).not.toHaveBeenCalled(); + expect(duplicateService.duplicateRecord).not.toHaveBeenCalled(); + }); + + it('allows non-migrating tables to continue through the normal write path', async () => { + migrationGuard.assertTableWritable.mockResolvedValue(undefined); + createService.multipleCreateRecords.mockResolvedValue({ records: [] }); + + await expect(service().multipleCreateRecords('tblother', { records: [] })).resolves.toEqual({ + records: [], + }); + + expect(migrationGuard.assertTableWritable).toHaveBeenCalledWith('tblother'); + expect(createService.multipleCreateRecords).toHaveBeenCalled(); + }); +}); diff --git a/apps/nestjs-backend/src/features/record/record-modify/record-modify.service.ts b/apps/nestjs-backend/src/features/record/record-modify/record-modify.service.ts index ec3d9cd065..fcec72bb1d 100644 --- a/apps/nestjs-backend/src/features/record/record-modify/record-modify.service.ts +++ b/apps/nestjs-backend/src/features/record/record-modify/record-modify.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Optional } from '@nestjs/common'; import { FieldKeyType } from '@teable/core'; import type { IMakeOptional } from '@teable/core'; import type { @@ -7,6 +7,7 @@ import type { ICreateRecordsVo, IRecordInsertOrderRo, } from '@teable/openapi'; +import { SpaceDataDbMigrationGuardService } from '../../space/space-data-db-migration-guard.service'; import { TableDomainQueryService } from '../../table-domain'; import type { IRecordInnerRo } from '../record.service'; import type { IUpdateRecordsInternalRo } from '../type'; @@ -22,18 +23,26 @@ export class RecordModifyService { private readonly updateService: RecordUpdateService, private readonly deleteService: RecordDeleteService, private readonly duplicateService: RecordDuplicateService, - private readonly tableDomainQueryService: TableDomainQueryService + private readonly tableDomainQueryService: TableDomainQueryService, + @Optional() + private readonly spaceDataDbMigrationGuard?: SpaceDataDbMigrationGuardService ) {} + private async assertTableWritable(tableId: string) { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + } + async updateRecords( tableId: string, updateRecordsRo: IUpdateRecordsInternalRo, windowId?: string ) { + await this.assertTableWritable(tableId); return this.updateService.updateRecords(tableId, updateRecordsRo, windowId); } async simpleUpdateRecords(tableId: string, updateRecordsRo: IUpdateRecordsInternalRo) { + await this.assertTableWritable(tableId); return this.updateService.simpleUpdateRecords(tableId, updateRecordsRo); } @@ -42,6 +51,7 @@ export class RecordModifyService { createRecordsRo: ICreateRecordsRo, ignoreMissingFields: boolean = false ): Promise { + await this.assertTableWritable(tableId); return this.createService.multipleCreateRecords(tableId, createRecordsRo, ignoreMissingFields); } @@ -51,6 +61,7 @@ export class RecordModifyService { fieldKeyType?: FieldKeyType, projection?: string[] ): Promise { + await this.assertTableWritable(tableId); const table = await this.tableDomainQueryService.getTableDomainById(tableId); return this.createService.createRecords( table, @@ -61,14 +72,17 @@ export class RecordModifyService { } async createRecordsOnlySql(tableId: string, createRecordsRo: ICreateRecordsRo): Promise { + await this.assertTableWritable(tableId); return this.createService.createRecordsOnlySql(tableId, createRecordsRo); } async deleteRecord(tableId: string, recordId: string, windowId?: string) { + await this.assertTableWritable(tableId); return this.deleteService.deleteRecord(tableId, recordId, windowId); } async deleteRecords(tableId: string, recordIds: string[], windowId?: string) { + await this.assertTableWritable(tableId); return this.deleteService.deleteRecords(tableId, recordIds, windowId); } @@ -78,6 +92,7 @@ export class RecordModifyService { order: IRecordInsertOrderRo, projection?: string[] ): Promise { + await this.assertTableWritable(tableId); return this.duplicateService.duplicateRecord(tableId, recordId, order, projection); } } 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 16fe4e29d1..947604413c 100644 --- a/apps/nestjs-backend/src/features/record/record.service.spec.ts +++ b/apps/nestjs-backend/src/features/record/record.service.spec.ts @@ -1,13 +1,295 @@ -import { FieldKeyType, FieldType } from '@teable/core'; +import { CellValueType, DbFieldType, FieldKeyType, FieldType } from '@teable/core'; +import type { IFieldVo } from '@teable/core'; import Knex from 'knex'; import { vi } from 'vitest'; import { RecordService } from './record.service'; +const { captureException, sentryScope, withScope } = vi.hoisted(() => { + const sentryScope = { + setContext: vi.fn(), + setLevel: vi.fn(), + setTag: vi.fn(), + }; + return { + captureException: vi.fn(), + sentryScope, + withScope: vi.fn((callback: (scope: typeof sentryScope) => void) => callback(sentryScope)), + }; +}); + +vi.mock('@sentry/nestjs', () => ({ + captureException, + withScope, +})); + describe('RecordService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('excludes pending fields from snapshot projections while keeping errored fields readable', async () => { + const createTextField = (id: string, overrides: Partial = {}): IFieldVo => ({ + id, + name: id, + type: FieldType.SingleLineText, + dbFieldName: id, + cellValueType: CellValueType.String, + dbFieldType: DbFieldType.Text, + options: JSON.stringify({}) as IFieldVo['options'], + ...overrides, + }); + const service = Object.create(RecordService.prototype) as { + dataLoaderService: { field: { load: ReturnType } }; + getFieldsByProjection: RecordService['getFieldsByProjection']; + }; + + service.dataLoaderService = { + field: { + load: vi.fn().mockResolvedValue([ + createTextField('fldReadable'), + createTextField('fldBroken', { hasError: true }), + createTextField('fldPending', { isPending: true }), + ]), + }, + }; + + const fields = await service.getFieldsByProjection( + 'tblSnapshot', + { + fldReadable: true, + fldBroken: true, + fldPending: true, + }, + FieldKeyType.Id, + { skipUnavailableFields: true } + ); + + expect(fields.map((field) => field.id)).toEqual(['fldReadable', 'fldBroken']); + }); + + it('presigns a single attachment object from lookup snapshots', async () => { + const service = Object.create(RecordService.prototype) as { + cacheService: { getMany: ReturnType }; + prismaService: { attachments: { findMany: ReturnType } }; + attachmentStorageService: { + getPreviewUrlByPath: ReturnType; + getTableThumbnailUrl: ReturnType; + }; + recordsPresignedUrl: ( + records: never, + fields: never, + fieldKeyType: FieldKeyType + ) => Promise } }>>; + }; + const attachment = { + id: 'actSingleAttachment', + name: 'image.png', + path: 'table/image-token', + size: 1024, + token: 'image-token', + width: 100, + height: 100, + mimetype: 'image/png', + }; + + service.cacheService = { + getMany: vi.fn().mockResolvedValue([]), + }; + service.prismaService = { + attachments: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + service.attachmentStorageService = { + getPreviewUrlByPath: vi.fn().mockResolvedValue('https://example.test/image-token'), + getTableThumbnailUrl: vi.fn(), + }; + + const records = [ + { + id: 'rec1', + v: 1, + type: 'json0', + data: { + fields: { + fldAttachment: attachment, + }, + }, + }, + ]; + const fields = [{ id: 'fldAttachment', type: FieldType.Attachment }]; + + const result = await service.recordsPresignedUrl( + records as never, + fields as never, + FieldKeyType.Id + ); + + expect(result[0]!.data.fields.fldAttachment).toEqual([ + expect.objectContaining({ + ...attachment, + presignedUrl: 'https://example.test/image-token', + smThumbnailUrl: 'https://example.test/image-token', + lgThumbnailUrl: 'https://example.test/image-token', + }), + ]); + }); + + it('captures attachment snapshot presign failures with table and field context', async () => { + const service = Object.create(RecordService.prototype) as { + cacheService: { getMany: ReturnType }; + logger: { error: ReturnType }; + recordsPresignedUrl: ( + records: never, + fields: never, + fieldKeyType: FieldKeyType, + context: never + ) => Promise } }>>; + }; + + service.cacheService = { + getMany: vi.fn().mockResolvedValue([]), + }; + service.logger = { + error: vi.fn(), + }; + + await expect( + service.recordsPresignedUrl( + [ + { + id: 'recBrokenAttachment', + v: 1, + type: 'json0', + data: { + fields: { + fldAttachment: { + token: 'attachment-token', + }, + }, + }, + }, + ] as never, + [ + { + id: 'fldAttachment', + dbFieldName: 'attachment_db_field', + name: 'Attachment', + type: FieldType.Attachment, + }, + ] as never, + FieldKeyType.Id, + { + tableId: 'tblSnapshot', + fieldKeyType: FieldKeyType.Id, + useQueryModel: true, + recordIds: ['recBrokenAttachment'], + } as never + ) + ).rejects.toThrow(); + + expect(service.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: 'tblSnapshot', + useQueryModel: true, + valueShapes: [ + expect.objectContaining({ + fieldId: 'fldAttachment', + hasMimetype: false, + hasToken: true, + recordId: 'recBrokenAttachment', + }), + ], + }), + expect.any(String) + ); + expect(withScope).toHaveBeenCalledTimes(1); + expect(sentryScope.setTag).toHaveBeenCalledWith('feature', 'record-snapshot-presigned-url'); + expect(sentryScope.setTag).toHaveBeenCalledWith('teable.version', 'v2'); + expect(sentryScope.setTag).toHaveBeenCalledWith('table.id', 'tblSnapshot'); + expect(sentryScope.setContext).toHaveBeenCalledWith( + 'record_snapshot_presigned_url', + expect.objectContaining({ + tableId: 'tblSnapshot', + attachmentFields: [ + expect.objectContaining({ + id: 'fldAttachment', + fieldKey: 'fldAttachment', + }), + ], + }) + ); + expect(captureException).toHaveBeenCalledWith(expect.any(TypeError), { + mechanism: { handled: true, type: 'record.snapshot.presigned_url' }, + }); + }); + + it('queries only record IDs when resolving doc IDs for count-like callers', async () => { + const dataKnex = Knex({ client: 'pg' }); + const queriedSql: string[] = []; + const alias = 't_tblDocIds'; + const queryBuilder = dataKnex + .from({ [alias]: 'bse_data.tbl_doc_ids' }) + .select(`${alias}.__id`) + .select( + dataKnex.raw( + `(SELECT jsonb_build_object('id', u.id, 'title', u.name) FROM users u WHERE u.id = "${alias}"."__created_by") as "CreatedBy"` + ) + ); + const service = Object.create(RecordService.prototype) as { + knex: ReturnType; + getGroupRelatedData: ReturnType; + buildFilterSortQuery: ReturnType; + getSearchHitIndex: ReturnType; + logger: { debug: ReturnType; error: ReturnType }; + recordPermissionService: { wrapView: ReturnType }; + databaseRouter: { queryDataPrismaForTable: ReturnType }; + getDocIdsByQuery: RecordService['getDocIdsByQuery']; + }; + + service.knex = dataKnex; + service.getGroupRelatedData = vi.fn().mockResolvedValue({ + groupPoints: [], + allGroupHeaderRefs: [], + filter: undefined, + }); + service.buildFilterSortQuery = vi.fn().mockResolvedValue({ + queryBuilder, + dbTableName: 'bse_data.tbl_doc_ids', + alias, + }); + service.getSearchHitIndex = vi.fn().mockResolvedValue(undefined); + service.logger = { + debug: vi.fn(), + error: vi.fn(), + }; + service.recordPermissionService = { + wrapView: vi.fn().mockResolvedValue({ + builder: dataKnex.queryBuilder(), + viewCte: undefined, + }), + }; + service.databaseRouter = { + queryDataPrismaForTable: vi.fn(async (_tableId: string, sql: string) => { + queriedSql.push(sql); + return [{ __id: 'recDocId' }]; + }), + }; + + await expect( + service.getDocIdsByQuery('tblDocIds', { skip: 0, take: 10 }, true) + ).resolves.toMatchObject({ ids: ['recDocId'] }); + + expect(queriedSql[0]).toContain(`"${alias}"."__id"`); + expect(queriedSql[0]).not.toContain('users'); + + await dataKnex.destroy(); + }); + it('writes SQL-only created record history into the routed data DB internal schema', async () => { const dataKnex = Knex({ client: 'pg' }); const executedSql: string[] = []; - const service = Object.create(RecordService.prototype) as RecordService & { + const service = Object.create(RecordService.prototype) as { creditCheck: ReturnType; getFieldsByProjection: ReturnType; getWritableCreatedTimeFieldNames: ReturnType; @@ -18,6 +300,7 @@ describe('RecordService', () => { dataKnexForTable: ReturnType; getDataDatabaseUrlForTable: ReturnType; }; + createRecordsOnlySql: RecordService['createRecordsOnlySql']; }; service.cls = { diff --git a/apps/nestjs-backend/src/features/record/record.service.ts b/apps/nestjs-backend/src/features/record/record.service.ts index d5b7c78267..19621a92e3 100644 --- a/apps/nestjs-backend/src/features/record/record.service.ts +++ b/apps/nestjs-backend/src/features/record/record.service.ts @@ -3,6 +3,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Prisma } from '@prisma/client'; +import * as Sentry from '@sentry/nestjs'; import type { CreatedByFieldCore, FieldCore, @@ -23,9 +24,9 @@ import type { import { and, CellFormat, - CellValueType, DbFieldType, DriverClient, + extractFieldIdsFromFilter, FieldKeyType, FieldType, generateRecordHistoryId, @@ -67,7 +68,6 @@ import { ThresholdConfig, IThresholdConfig } from '../../configs/threshold.confi import { CustomHttpException } from '../../custom.exception'; import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; -import { Events } from '../../event-emitter/events'; import { DatabaseRouter } from '../../global/database-router.service'; import { DATA_KNEX } from '../../global/knex/knex.module'; import { RawOpType } from '../../share-db/interface'; @@ -101,6 +101,16 @@ type IGeneratedColumnStateRow = { column_name: string; is_generated: string | null; }; +type IAttachmentCellValueLike = IAttachmentCellValue | IAttachmentCellValue[number]; +type IRecordsPresignedUrlContext = { + tableId?: string; + viewQueryDbTableName?: string; + fieldKeyType: FieldKeyType; + cellFormat?: CellFormat; + useQueryModel?: boolean; + recordIds?: string[]; + projectionFieldIds?: string[]; +}; function removeUndefined>(obj: T) { return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined)) as T; @@ -570,6 +580,45 @@ export class RecordService { } } + private resolveAggregateProjection(params: { + groupBy?: IGroup; + filter?: IFilter; + searchFields?: IFieldInstance[]; + allowedFieldIds?: string[]; + }): string[] | undefined { + const { groupBy, filter, searchFields, allowedFieldIds } = params; + const projectionSet = new Set(); + + groupBy?.forEach(({ fieldId }) => { + if (fieldId) { + projectionSet.add(fieldId); + } + }); + + if (filter) { + for (const fieldId of extractFieldIdsFromFilter(filter)) { + projectionSet.add(fieldId); + } + } + + searchFields?.forEach((fieldInstance) => { + projectionSet.add(fieldInstance.id); + }); + + if (projectionSet.size === 0) { + return undefined; + } + + const projectionArray = Array.from(projectionSet); + if (!allowedFieldIds?.length) { + return projectionArray; + } + + const allowedSet = new Set(allowedFieldIds); + const filtered = projectionArray.filter((fieldId) => allowedSet.has(fieldId)); + return filtered.length ? filtered : undefined; + } + private async sanitizeFilterByEnabledFields( tableId: string, filter: IFilter | undefined, @@ -830,6 +879,7 @@ export class RecordService { | 'groupBy' | 'filter' | 'search' + | 'projection' | 'filterLinkCellCandidate' | 'filterLinkCellSelected' | 'collapsedGroupIds' @@ -920,11 +970,19 @@ export class RecordService { } if (search && search[2] && fieldMap) { + // query.projection narrows search to the fields the caller displays + // (e.g. personal view visible columns); intersect it with the permission + // whitelist so it can only ever shrink the searchable set + const searchProjection = query.projection?.length + ? enabledFieldIds + ? query.projection.filter((fieldId) => enabledFieldIds.includes(fieldId)) + : query.projection + : enabledFieldIds; const searchFields = await this.getSearchFields( fieldMap, search, query?.viewId, - enabledFieldIds + searchProjection ); const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId); qb.where((builder) => { @@ -1667,7 +1725,8 @@ export class RecordService { public async getFieldsByProjection( tableId: string, projection?: { [fieldNameOrId: string]: boolean }, - fieldKeyType: FieldKeyType = FieldKeyType.Id + fieldKeyType: FieldKeyType = FieldKeyType.Id, + options: { skipUnavailableFields?: boolean } = {} ) { let fields = await this.dataLoaderService.field.load(tableId); if (projection) { @@ -1678,6 +1737,9 @@ export class RecordService { fields = fields.filter((field) => projectionFieldKeys.includes(field[fieldKeyType])); } } + if (options.skipUnavailableFields) { + fields = fields.filter((field) => !field.isPending); + } return fields.map((field) => createFieldInstanceByRaw(field)); } @@ -1692,9 +1754,9 @@ export class RecordService { if (field.type === FieldType.Attachment) { const fieldKey = field[fieldKeyType]; for (const record of records) { - const cellValue = record.data.fields[fieldKey]; + const cellValue = this.normalizeAttachmentCellValue(record.data.fields[fieldKey]); if (cellValue == null) continue; - (cellValue as IAttachmentCellValue).forEach((item) => { + cellValue.forEach((item) => { if (item.mimetype.startsWith('image/') && item.width && item.height) { const { smThumbnailPath, lgThumbnailPath } = generateTableThumbnailPath(item.path); previewToken.push(getTableThumbnailToken(smThumbnailPath)); @@ -1731,9 +1793,9 @@ export class RecordService { if (field.type === FieldType.Attachment) { const fieldKey = field[fieldKeyType]; for (const record of records) { - const cellValue = record.data.fields[fieldKey]; + const cellValue = this.normalizeAttachmentCellValue(record.data.fields[fieldKey]); if (cellValue == null) continue; - (cellValue as IAttachmentCellValue).forEach((item) => { + cellValue.forEach((item) => { if (isImage(item.mimetype) || isPdf(item.mimetype)) { thumbnailTokens.push(getTableThumbnailToken(item.token)); } @@ -1767,34 +1829,150 @@ export class RecordService { private async recordsPresignedUrl( records: ISnapshotBase[], fields: IFieldInstance[], - fieldKeyType: FieldKeyType + fieldKeyType: FieldKeyType, + context?: IRecordsPresignedUrlContext ) { - if (records.length === 0 || fields.findIndex((f) => f.type === FieldType.Attachment) === -1) { + try { + if (records.length === 0 || fields.findIndex((f) => f.type === FieldType.Attachment) === -1) { + return records; + } + const cacheTokenUrlMap = await this.getCachePreviewUrlTokenMap(records, fields, fieldKeyType); + const thumbnailPathTokenMap = await this.getThumbnailPathTokenMap( + records, + fields, + fieldKeyType + ); + for (const field of fields) { + if (field.type === FieldType.Attachment) { + const fieldKey = field[fieldKeyType]; + for (const record of records) { + const cellValue = this.normalizeAttachmentCellValue(record.data.fields[fieldKey]); + const presignedCellValue = await this.getAttachmentPresignedCellValue( + cellValue, + cacheTokenUrlMap, + thumbnailPathTokenMap + ); + if (presignedCellValue == null) continue; + + record.data.fields[fieldKey] = presignedCellValue; + } + } + } return records; + } catch (error) { + this.captureRecordSnapshotPresignedUrlError(error, records, fields, fieldKeyType, context); + throw error; } - const cacheTokenUrlMap = await this.getCachePreviewUrlTokenMap(records, fields, fieldKeyType); - const thumbnailPathTokenMap = await this.getThumbnailPathTokenMap( - records, - fields, - fieldKeyType - ); - for (const field of fields) { - if (field.type === FieldType.Attachment) { - const fieldKey = field[fieldKeyType]; - for (const record of records) { - const cellValue = record.data.fields[fieldKey]; - const presignedCellValue = await this.getAttachmentPresignedCellValue( - cellValue as IAttachmentCellValue, - cacheTokenUrlMap, - thumbnailPathTokenMap - ); - if (presignedCellValue == null) continue; - - record.data.fields[fieldKey] = presignedCellValue; - } + } + + private normalizeAttachmentCellValue(cellValue: unknown): IAttachmentCellValue | null { + if (cellValue == null) { + return null; + } + + return Array.isArray(cellValue) ? cellValue : [cellValue as IAttachmentCellValue[number]]; + } + + private captureRecordSnapshotPresignedUrlError( + error: unknown, + records: ISnapshotBase[], + fields: IFieldInstance[], + fieldKeyType: FieldKeyType, + context?: IRecordsPresignedUrlContext + ) { + const exception = error instanceof Error ? error : new Error(String(error)); + const attachmentFields = fields + .filter((field) => field.type === FieldType.Attachment) + .map((field) => + removeUndefined({ + id: field.id, + dbFieldName: field.dbFieldName, + name: field.name, + fieldKey: field[fieldKeyType], + }) + ); + const valueShapes = this.getAttachmentValueShapeSummaries(records, fields, fieldKeyType); + const recordIds = context?.recordIds ?? records.map((record) => record.id); + const logContext = removeUndefined({ + message: 'Record snapshot attachment presigned url failed', + error: exception.message, + tableId: context?.tableId, + viewQueryDbTableName: context?.viewQueryDbTableName, + fieldKeyType, + cellFormat: context?.cellFormat, + useQueryModel: context?.useQueryModel, + recordCount: records.length, + recordIds: recordIds.slice(0, 20), + projectionFieldIds: context?.projectionFieldIds, + attachmentFields, + valueShapes, + }); + + this.logger.error(logContext, exception.stack); + + Sentry.withScope((scope) => { + scope.setLevel('error'); + scope.setTag('feature', 'record-snapshot-presigned-url'); + scope.setTag('field_key_type', fieldKeyType); + scope.setTag('record_count', String(records.length)); + scope.setTag('attachment_field_count', String(attachmentFields.length)); + if (context?.useQueryModel) { + scope.setTag('teable.version', 'v2'); } + if (context?.tableId) { + scope.setTag('table.id', context.tableId); + } + scope.setContext('record_snapshot_presigned_url', logContext); + Sentry.captureException(exception, { + mechanism: { handled: true, type: 'record.snapshot.presigned_url' }, + }); + }); + } + + private getAttachmentValueShapeSummaries( + records: ISnapshotBase[], + fields: IFieldInstance[], + fieldKeyType: FieldKeyType + ) { + return fields + .filter((field) => field.type === FieldType.Attachment) + .flatMap((field) => { + const fieldKey = field[fieldKeyType]; + return records.map((record) => + removeUndefined({ + recordId: record.id, + fieldId: field.id, + dbFieldName: field.dbFieldName, + fieldKey, + ...this.getAttachmentValueShape(record.data.fields[fieldKey]), + }) + ); + }) + .slice(0, 20); + } + + private getAttachmentValueShape(cellValue: unknown) { + if (cellValue == null) { + return { valueType: 'null', isArray: false }; } - return records; + + const isArrayValue = Array.isArray(cellValue); + const sampleValue = isArrayValue ? cellValue[0] : cellValue; + const sampleRecord = + sampleValue && typeof sampleValue === 'object' && !Array.isArray(sampleValue) + ? (sampleValue as Record) + : undefined; + + return removeUndefined({ + valueType: isArrayValue ? 'array' : typeof cellValue, + isArray: isArrayValue, + arrayLength: isArrayValue ? cellValue.length : undefined, + itemValueType: Array.isArray(sampleValue) ? 'array' : typeof sampleValue, + itemKeys: sampleRecord ? Object.keys(sampleRecord).sort().slice(0, 20) : undefined, + hasToken: sampleRecord ? typeof sampleRecord.token === 'string' : undefined, + hasPath: sampleRecord ? typeof sampleRecord.path === 'string' : undefined, + hasMimetype: sampleRecord ? typeof sampleRecord.mimetype === 'string' : undefined, + }); } async invalidateAttachmentPresignedUrlCache(tokens: string[]) { @@ -1802,15 +1980,16 @@ export class RecordService { } async getAttachmentPresignedCellValue( - cellValue: IAttachmentCellValue | null, + cellValue: IAttachmentCellValueLike | null, cacheTokenUrlMap?: Record, thumbnailPathTokenMap?: Record ) { - if (cellValue == null) { + const normalizedCellValue = this.normalizeAttachmentCellValue(cellValue); + if (normalizedCellValue == null) { return null; } return await Promise.all( - cellValue.map(async (item) => { + normalizedCellValue.map(async (item) => { const { path, mimetype, token } = item; const presignedUrl = cacheTokenUrlMap?.[token] ?? @@ -1871,7 +2050,9 @@ export class RecordService { } ): Promise[]> { const { tableId, recordIds, projection, fieldKeyType, cellFormat } = query; - const fields = await this.getFieldsByProjection(tableId, projection, fieldKeyType); + const fields = await this.getFieldsByProjection(tableId, projection, fieldKeyType, { + skipUnavailableFields: true, + }); const fieldIds = fields.map((f) => f.id); const { qb: queryBuilder } = await this.recordQueryBuilder.createRecordQueryBuilder( @@ -1955,7 +2136,15 @@ export class RecordService { }; }); if (cellFormat === CellFormat.Json) { - return await this.recordsPresignedUrl(snapshots, fields, fieldKeyType); + return await this.recordsPresignedUrl(snapshots, fields, fieldKeyType, { + tableId, + viewQueryDbTableName, + fieldKeyType, + cellFormat, + useQueryModel: query.useQueryModel, + recordIds, + projectionFieldIds: fieldIds, + }); } return snapshots; } @@ -2053,7 +2242,7 @@ export class RecordService { }, useQueryModel ); - const { queryBuilder, dbTableName } = await this.buildFilterSortQuery( + const { queryBuilder, dbTableName, alias } = await this.buildFilterSortQuery( tableId, { ...query, @@ -2061,7 +2250,9 @@ export class RecordService { }, useQueryModel ); - // queryBuilder.select(this.knex.ref(`${selectDbTableName}.__id`)); + // This path only needs record IDs. Avoid evaluating display projections such as + // CreatedBy user lookups, which may reference meta-plane tables outside BYODB. + queryBuilder.clearSelect().select(`${alias}.__id`); skip && queryBuilder.offset(skip); if (take !== -1) { @@ -2181,6 +2372,10 @@ export class RecordService { return uniqBy( orderBy( Object.values(fieldInstanceMap) + // shared searchability predicate from @teable/core, also used by + // client-side highlighting; must run before the spread below which + // strips class methods + .filter((field) => field.isSearchable(search[0], { isSearchAllFields })) .map((field) => ({ ...field, isStructuredCellValue: field.isStructuredCellValue, @@ -2205,23 +2400,6 @@ export class RecordService { const searchArr = search?.[1]?.split(',') || []; return searchArr.includes(field.id); }) - .filter((field) => { - if (field.type === FieldType.Button) { - return false; - } - if (field.cellValueType === CellValueType.Boolean) { - return false; - } - if (isSearchAllFields) { - if (field.cellValueType === CellValueType.DateTime) { - return false; - } - if (field.cellValueType === CellValueType.Number && isNaN(Number(search[0]))) { - return false; - } - } - return true; - }) .map((field) => { return { ...field, @@ -2626,7 +2804,8 @@ export class RecordService { filter?: IFilter, search?: [string, string?, boolean?], viewId?: string, - useQueryModel = false + useQueryModel = false, + projection?: string[] ) { const withUserId = this.cls.get('user.id'); const wrap = await this.recordPermissionService.wrapView( @@ -2649,6 +2828,7 @@ export class RecordService { currentUserId: withUserId, useQueryModel, builder: wrap.builder, + projection, } ); @@ -2739,6 +2919,15 @@ export class RecordService { const withUserId = this.cls.get('user.id'); const shouldUseQueryModel = useQueryModel && !viewCte; + const searchFields = search?.[2] + ? await this.getSearchFields(fieldInstanceMap, search, viewId) + : []; + const aggregateProjection = this.resolveAggregateProjection({ + groupBy, + filter: mergedFilter, + searchFields, + allowedFieldIds: enabledFieldIds, + }); const { qb: queryBuilder, selectionMap } = await this.recordQueryBuilder.createRecordAggregateBuilder(viewCte ?? dbTableName, { tableId, @@ -2755,10 +2944,10 @@ export class RecordService { currentUserId: withUserId, useQueryModel: shouldUseQueryModel, builder: permissionBuilder, + projection: aggregateProjection, }); if (search && search[2]) { - const searchFields = await this.getSearchFields(fieldInstanceMap, search, viewId); const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId); queryBuilder.where((builder) => { this.dbProvider.searchQuery(builder, searchFields, tableIndex, search, { selectionMap }); @@ -2777,7 +2966,8 @@ export class RecordService { mergedFilter, search, viewId, - useQueryModel + useQueryModel, + aggregateProjection ); try { diff --git a/apps/nestjs-backend/src/features/selection/selection.controller.test.ts b/apps/nestjs-backend/src/features/selection/selection.controller.test.ts index bba7763c66..219779f237 100644 --- a/apps/nestjs-backend/src/features/selection/selection.controller.test.ts +++ b/apps/nestjs-backend/src/features/selection/selection.controller.test.ts @@ -32,7 +32,12 @@ describe('SelectionController', () => { let recordOpenApiV2Service: Mocked< Pick< RecordOpenApiV2Service, - 'clearStream' | 'deleteByRangeStream' | 'duplicateByRangeStream' | 'pasteStream' + | 'clearStream' + | 'deleteByRangeStream' + | 'deleteRecordsByIds' + | 'duplicateByRangeStream' + | 'pasteStream' + | 'resolveRecordIdsBySelection' > >; let cls: { get: ReturnType }; @@ -93,8 +98,10 @@ describe('SelectionController', () => { recordOpenApiV2Service = { clearStream: vi.fn(), deleteByRangeStream: vi.fn(), + deleteRecordsByIds: vi.fn(), duplicateByRangeStream: vi.fn(), pasteStream: vi.fn(), + resolveRecordIdsBySelection: vi.fn(), }; cls = { get: vi.fn(), diff --git a/apps/nestjs-backend/src/features/selection/selection.controller.ts b/apps/nestjs-backend/src/features/selection/selection.controller.ts index c5a4a9822c..73dd6e2e2b 100644 --- a/apps/nestjs-backend/src/features/selection/selection.controller.ts +++ b/apps/nestjs-backend/src/features/selection/selection.controller.ts @@ -7,6 +7,7 @@ import { Headers, Param, Patch, + Post, Query, Res, UseGuards, @@ -21,19 +22,32 @@ import type { IRangesToIdVo, IPasteVo, IDeleteVo, + IPasteByIdVo, ITemporaryPasteVo, + ICopyByIdRo, } from '@teable/openapi'; import { + IClearByIdRo, + IPasteByIdRo, + IDeleteByIdRo, + IPasteByIdStreamRo, IRangesToIdQuery, + ISelectionIdsRo, rangesToIdQuerySchema, + clearByIdRoSchema, + pasteByIdRoSchema, + deleteByIdRoSchema, rangesQuerySchema, IPasteRo, + pasteByIdStreamRoSchema, pasteRoSchema, rangesRoSchema, IRangesRo, + selectionIdsRoSchema, temporaryPasteRoSchema, ITemporaryPasteRo, IdReturnType, + copyByIdRoSchema, } from '@teable/openapi'; import { Response } from 'express'; import { ClsService } from 'nestjs-cls'; @@ -155,6 +169,158 @@ export class SelectionController { } } + private async pasteByIdWithV2(tableId: string, pasteRo: IPasteByIdRo): Promise { + const targetRecordIds = await this.recordOpenApiV2Service.resolveRecordIdsBySelection( + tableId, + pasteRo + ); + const { updatePayload, createPayload, fieldIds, recordIds, createdFieldIds } = + await this.selectionService.buildPasteByIdPayload(tableId, pasteRo, { + recordIds: targetRecordIds, + }); + const beforeSnapshot = await this.selectionService.createPasteByIdMutationSnapshot( + tableId, + fieldIds + ); + await this.recordOpenApiV2Service.updateRecords(tableId, updatePayload); + const createdRecordIds: string[] = []; + if (createPayload) { + const result = await this.recordOpenApiV2Service.createRecords(tableId, createPayload); + createdRecordIds.push(...result.records.map((record) => record.id)); + } + return this.selectionService.completePasteByIdResult( + tableId, + { recordIds, fieldIds, createdRecordIds, createdFieldIds }, + beforeSnapshot + ); + } + + protected async *createDeleteByIdSelectionStream( + tableId: string, + deleteRo: IDeleteByIdRo, + windowId?: string + ): AsyncIterable { + const recordIds = await this.selectionService.resolveRecordIdsBySelection(tableId, deleteRo); + yield { + id: 'progress', + phase: 'preparing', + batchIndex: -1, + totalCount: recordIds.length, + deletedCount: 0, + batchDeletedCount: 0, + }; + + if (this.cls.get('useV2')) { + await this.recordOpenApiV2Service.deleteRecordsByIds(tableId, recordIds, windowId); + } else { + await this.selectionService.deleteById(tableId, deleteRo, { windowId }); + } + + yield { + id: 'done', + totalCount: recordIds.length, + deletedCount: recordIds.length, + data: { + deletedCount: recordIds.length, + deletedRecordIds: recordIds, + }, + }; + } + + protected async *createClearByIdSelectionStream( + tableId: string, + clearRo: IClearByIdRo, + windowId?: string + ): AsyncIterable { + const recordIds = await this.selectionService.resolveRecordIdsBySelection(tableId, clearRo); + yield { + id: 'progress', + phase: 'preparing', + batchIndex: -1, + totalCount: recordIds.length, + processedCount: 0, + clearedCount: 0, + batchProcessedCount: 0, + batchClearedCount: 0, + }; + + if (this.cls.get('useV2')) { + const { payload } = await this.selectionService.buildClearByIdUpdatePayload(tableId, clearRo); + await this.recordOpenApiV2Service.updateRecords(tableId, payload); + } else { + await this.selectionService.clearById(tableId, clearRo, { windowId }); + } + + yield { + id: 'done', + totalCount: recordIds.length, + processedCount: recordIds.length, + clearedCount: recordIds.length, + data: { + clearedCount: recordIds.length, + clearedRecordIds: recordIds, + }, + }; + } + + protected getPasteStreamTotalCount(content: IPasteRo['content'] | IPasteByIdRo['content']) { + if (Array.isArray(content)) { + return content.length; + } + + const trimmedContent = content.trim(); + if (!trimmedContent) { + return 0; + } + return trimmedContent.split(/\r?\n/).length; + } + + protected getPasteByIdStreamTotalCount(pasteRo: IPasteByIdRo) { + return this.getPasteStreamTotalCount(pasteRo.content); + } + + protected async *createPasteByIdSelectionStream( + tableId: string, + pasteRo: IPasteByIdRo, + windowId?: string + ): AsyncIterable { + const totalCount = this.getPasteByIdStreamTotalCount(pasteRo); + yield { + id: 'progress', + phase: 'preparing', + batchIndex: -1, + totalCount, + processedCount: 0, + updatedCount: 0, + createdCount: 0, + batchProcessedCount: 0, + }; + + const result = this.cls.get('useV2') + ? await this.pasteByIdWithV2(tableId, pasteRo) + : await this.selectionService.pasteById(tableId, pasteRo, { windowId }); + + yield { + id: 'done', + totalCount, + processedCount: totalCount, + updatedCount: 0, + createdCount: 0, + data: { + updatedCount: 0, + createdCount: 0, + createdRecordIds: result.createdRecordIds ?? [], + pastedRecordIds: result.pastedRecordIds ?? result.selection.recordIds, + pastedFieldIds: result.pastedFieldIds ?? result.selection.fieldIds, + createdFieldIds: result.createdFieldIds ?? [], + createdChoiceIdsByFieldId: result.createdChoiceIdsByFieldId, + createdForeignRecordIds: result.createdForeignRecordIds ?? [], + skippedAttachments: result.skippedAttachments ?? [], + selection: result.selection, + }, + }; + } + @Permissions('record|read') @Get('/range-to-id') async getIdsFromRanges( @@ -173,6 +339,15 @@ export class SelectionController { return this.selectionService.copy(tableId, query); } + @Permissions('record|read', 'record|copy') + @Post('/copy-by-id') + async copyById( + @Param('tableId') tableId: string, + @Body(new ZodValidationPipe(copyByIdRoSchema), TqlPipe) copyRo: ICopyByIdRo + ): Promise { + return this.selectionService.copyById(tableId, copyRo); + } + @UseV2Feature('paste') @Permissions('record|update') @Patch('/paste') @@ -194,6 +369,21 @@ export class SelectionController { return { ranges }; } + @UseV2Feature('paste') + @Permissions('record|update') + @Patch('/paste-by-id') + async pasteById( + @Param('tableId') tableId: string, + @Body(new ZodValidationPipe(pasteByIdRoSchema), TqlPipe) pasteRo: IPasteByIdRo, + @Headers('x-window-id') windowId?: string + ): Promise { + if (this.cls.get('useV2')) { + return this.pasteByIdWithV2(tableId, pasteRo); + } + + return this.selectionService.pasteById(tableId, pasteRo, { windowId }); + } + @Permissions('record|read') @Patch('/temporaryPaste') async temporaryPaste( @@ -225,6 +415,32 @@ export class SelectionController { return null; } + @UseV2Feature('clear') + @Permissions('record|update') + @Patch('/clear-by-id') + async clearById( + @Param('tableId') tableId: string, + @Body(new ZodValidationPipe(clearByIdRoSchema), TqlPipe) clearRo: IClearByIdRo, + @Headers('x-window-id') windowId?: string + ) { + if (this.cls.get('useV2')) { + const recordIds = await this.recordOpenApiV2Service.resolveRecordIdsBySelection( + tableId, + clearRo + ); + const { payload } = await this.selectionService.buildClearByIdUpdatePayload( + tableId, + clearRo, + { recordIds } + ); + await this.recordOpenApiV2Service.updateRecords(tableId, payload); + return null; + } + + await this.selectionService.clearById(tableId, clearRo, { windowId }); + return null; + } + @UseV2Feature('clear') @Permissions('record|update') @Patch('/clear-stream') @@ -252,6 +468,28 @@ export class SelectionController { })); } + @UseV2Feature('clear') + @Permissions('record|update') + @Patch('/clear-by-id-stream') + async clearByIdStream( + @Param('tableId') tableId: string, + @Body(new ZodValidationPipe(selectionIdsRoSchema), TqlPipe) selectionRo: ISelectionIdsRo, + @Res() response: Response + ): Promise { + const stream = await this.recordOpenApiV2Service.clearByIdStream(tableId, selectionRo); + + await this.streamSelectionResponse(response, stream, (message) => ({ + id: 'error', + phase: 'clearing', + batchIndex: -1, + totalCount: 0, + processedCount: 0, + clearedCount: 0, + recordIds: [], + message, + })); + } + @UseV2Feature('deleteRecord') @Permissions('record|delete') @Delete('/delete') @@ -272,6 +510,26 @@ export class SelectionController { }); } + @UseV2Feature('deleteRecord') + @Permissions('record|delete') + @Post('/delete-by-id') + async deleteById( + @Param('tableId') tableId: string, + @Body(new ZodValidationPipe(deleteByIdRoSchema), TqlPipe) deleteRo: IDeleteByIdRo, + @Headers('x-window-id') windowId?: string + ): Promise { + if (this.cls.get('useV2')) { + const recordIds = await this.recordOpenApiV2Service.resolveRecordIdsBySelection( + tableId, + deleteRo + ); + await this.recordOpenApiV2Service.deleteRecordsByIds(tableId, recordIds, windowId); + return { ids: recordIds }; + } + + return this.selectionService.deleteById(tableId, deleteRo, { windowId }); + } + protected async *createLegacyDeleteSelectionStream( tableId: string, rangesRo: IRangesRo, @@ -413,16 +671,7 @@ export class SelectionController { } protected getLegacyPasteStreamTotalCount(pasteRo: IPasteRo) { - if (Array.isArray(pasteRo.content)) { - return pasteRo.content.length; - } - - const content = pasteRo.content.trim(); - if (!content) { - return 0; - } - - return content.split(/\r?\n/).length; + return this.getPasteStreamTotalCount(pasteRo.content); } protected async *createLegacyPasteSelectionStream( @@ -490,6 +739,31 @@ export class SelectionController { ); } + @UseV2Feature('deleteRecord') + @Permissions('record|delete') + @Patch('/delete-by-id-stream') + async deleteByIdStream( + @Param('tableId') tableId: string, + @Body(new ZodValidationPipe(selectionIdsRoSchema), TqlPipe) selectionRo: ISelectionIdsRo, + @Res() response: Response + ): Promise { + const stream = await this.recordOpenApiV2Service.deleteByIdStream(tableId, selectionRo); + + await this.streamSelectionResponse( + response, + stream, + (message) => ({ + id: 'error', + phase: 'deleting', + batchIndex: -1, + totalCount: 0, + deletedCount: 0, + recordIds: [], + message, + }) + ); + } + @UseV2Feature('paste') @Permissions('record|update') @Patch('/paste-stream') @@ -518,6 +792,32 @@ export class SelectionController { })); } + @UseV2Feature('paste') + @Permissions('record|update') + @Patch('/paste-by-id-stream') + async pasteByIdStream( + @Param('tableId') tableId: string, + @Body(new ZodValidationPipe(pasteByIdStreamRoSchema), TqlPipe) pasteRo: IPasteByIdStreamRo, + @Headers('x-window-id') windowId: string | undefined, + @Res() response: Response + ): Promise { + const stream = await this.recordOpenApiV2Service.pasteByIdStream(tableId, pasteRo, { + windowId, + }); + + await this.streamSelectionResponse(response, stream, (message) => ({ + id: 'error', + phase: 'pasting', + batchIndex: -1, + totalCount: 0, + processedCount: 0, + updatedCount: 0, + createdCount: 0, + recordIds: [], + message, + })); + } + @UseV2Feature('duplicateRecord') @Permissions('record|read', 'record|create') @Get('/duplicate-stream') diff --git a/apps/nestjs-backend/src/features/selection/selection.service.spec.ts b/apps/nestjs-backend/src/features/selection/selection.service.spec.ts index 18fa63004f..b6255c2c76 100644 --- a/apps/nestjs-backend/src/features/selection/selection.service.spec.ts +++ b/apps/nestjs-backend/src/features/selection/selection.service.spec.ts @@ -118,6 +118,51 @@ describe('selectionService', () => { expect(result?.content).toEqual('1\t2\n1\t2'); }); + + it('should return content from selected record and field ids', async () => { + const fields = [ + { id: 'field1', name: 'Field 1', type: FieldType.SingleLineText }, + { id: 'field2', name: 'Field 2', type: FieldType.SingleLineText }, + ] as IFieldVo[]; + const records = [ + { + id: 'record2', + fields: { + field1: 'A2', + field2: 'B2', + }, + }, + { + id: 'record1', + fields: { + field1: 'A1', + field2: 'B1', + }, + }, + ]; + vi.spyOn(selectionService, 'resolveRecordIdsBySelection').mockResolvedValue([ + 'record2', + 'record1', + ]); + vi.spyOn(selectionService, 'resolveFieldsBySelection').mockResolvedValue(fields); + vi.spyOn(selectionService, 'getRecordsByIdsForFields').mockResolvedValue(records); + + const result = await selectionService.copyById(tableId, { + viewId, + selection: { + recordIds: ['record2', 'record1'], + fieldIds: ['field1', 'field2'], + }, + }); + + expect(selectionService.getRecordsByIdsForFields).toHaveBeenCalledWith( + tableId, + ['record2', 'record1'], + ['field1', 'field2'] + ); + expect(result.content).toEqual('A2\tB2\nA1\tB1'); + expect(result.header).toEqual(fields); + }); }); describe('parseCopyContent', () => { diff --git a/apps/nestjs-backend/src/features/selection/selection.service.ts b/apps/nestjs-backend/src/features/selection/selection.service.ts index 3902d0209a..3f1f2721ee 100644 --- a/apps/nestjs-backend/src/features/selection/selection.service.ts +++ b/apps/nestjs-backend/src/features/selection/selection.service.ts @@ -8,6 +8,7 @@ import type { IFieldVo, INumberFieldOptionsRo, IRecord, + ISelectFieldOptions, ISingleLineTextFieldOptions, IUserFieldOptions, } from '@teable/core'; @@ -38,13 +39,19 @@ import type { IUpdateRecordsRo, IRangesToIdQuery, IRangesToIdVo, + ISelectionIdMutationBaseRo, + IClearByIdRo, + IPasteByIdRo, + IPasteByIdVo, + IDeleteByIdRo, IPasteRo, IPasteVo, IRangesRo, IDeleteVo, ITemporaryPasteVo, + ICopyByIdRo, } from '@teable/openapi'; -import { difference, pick } from 'lodash'; +import { difference, keyBy, pick } from 'lodash'; import { ClsService } from 'nestjs-cls'; import { ThresholdConfig, IThresholdConfig } from '../../configs/threshold.config'; import { CustomHttpException } from '../../custom.exception'; @@ -64,6 +71,12 @@ import { RecordOpenApiService } from '../record/open-api/record-open-api.service import { RecordService } from '../record/record.service'; import { IUpdateRecordsInternalRo } from '../record/type'; +const exceedMaxPasteCellsI18nKey = 'httpErrors.selection.exceedMaxPasteCells'; + +type IPasteByIdMutationSnapshot = { + choiceIdsByFieldId: Record; +}; + @Injectable() export class SelectionService { constructor( @@ -132,6 +145,33 @@ export class SelectionService { private async rowSelectionToIds(tableId: string, query: IRangesToIdQuery): Promise { const { type, ranges } = query; + const maxBatchSize = 1000; + const fetchRecordIdsByRange = async (start: number, end: number): Promise => { + const total = end - start + 1; + if (total <= 0) { + return []; + } + + let recordIds: string[] = []; + for (let offset = 0; offset < total; offset += maxBatchSize) { + const take = Math.min(maxBatchSize, total - offset); + const result = await this.recordService.getDocIdsByQuery( + tableId, + { + ...query, + skip: start + offset, + take, + }, + true + ); + recordIds = recordIds.concat(result.ids); + if (result.ids.length < take) { + break; + } + } + return recordIds; + }; + if (type === RangeType.Columns) { const result = await this.recordService.getDocIdsByQuery( tableId, @@ -160,16 +200,7 @@ export class SelectionService { ); } for (const [start, end] of ranges) { - const result = await this.recordService.getDocIdsByQuery( - tableId, - { - ...query, - skip: start, - take: end + 1 - start, - }, - true - ); - recordIds = recordIds.concat(result.ids); + recordIds = recordIds.concat(await fetchRecordIdsByRange(start, end)); } return recordIds; @@ -188,17 +219,7 @@ export class SelectionService { } ); } - const result = await this.recordService.getDocIdsByQuery( - tableId, - { - ...query, - skip: start[1], - take: end[1] + 1 - start[1], - }, - true - ); - - return result.ids; + return fetchRecordIdsByRange(start[1], end[1]); } private fieldsToProjection(fields: IFieldVo[], fieldKeyType: FieldKeyType) { @@ -337,6 +358,363 @@ export class SelectionService { } } + async resolveRecordIdsBySelection( + tableId: string, + selectionRo: Pick< + ISelectionIdMutationBaseRo, + | 'selection' + | 'viewId' + | 'ignoreViewQuery' + | 'filter' + | 'orderBy' + | 'groupBy' + | 'search' + | 'collapsedGroupIds' + > + ): Promise { + const { selection, ...queryRo } = selectionRo; + if (selection.recordIds) { + return selection.recordIds; + } + + const result = await this.recordService.getDocIdsByQuery( + tableId, + { + ...queryRo, + skip: 0, + take: -1, + fieldKeyType: FieldKeyType.Id, + }, + true + ); + const excludedIds = new Set(selection.excludeRecordIds ?? []); + return result.ids.filter((recordId) => !excludedIds.has(recordId)); + } + + async resolveFieldsBySelection( + tableId: string, + selectionRo: Pick + ): Promise { + const { selection, projection } = selectionRo; + if (selection.fieldIds?.length) { + return this.fieldService.getFieldsByQuery(tableId, { + projection: selection.fieldIds, + }); + } + + if (projection) { + return this.fieldService.getFieldsByQuery(tableId, { + projection, + }); + } + + return this.fieldService.getFieldsByQuery(tableId, { + viewId: selectionRo.viewId, + filterHidden: true, + }); + } + + async getRecordsByIdsForFields(tableId: string, recordIds: string[], fieldIds: string[]) { + if (!recordIds.length) { + return []; + } + + const projection = fieldIds.reduce>((acc, fieldId) => { + acc[fieldId] = true; + return acc; + }, {}); + const snapshots = await this.recordService.getSnapshotBulkWithPermission( + tableId, + recordIds, + projection, + FieldKeyType.Id, + undefined, + true + ); + const snapshotMap = keyBy(snapshots, (snapshot) => snapshot.data.id); + + return recordIds + .map((recordId) => snapshotMap[recordId]?.data) + .filter((record): record is IRecord => Boolean(record)) + .map((record) => ({ + id: record.id, + fields: record.fields, + })); + } + + async buildClearByIdUpdatePayload( + tableId: string, + clearRo: IClearByIdRo, + options: { recordIds?: string[] } = {} + ) { + const recordIds = + options.recordIds ?? (await this.resolveRecordIdsBySelection(tableId, clearRo)); + const fields = await this.resolveFieldsBySelection(tableId, clearRo); + const fieldIds = fields.map((field) => field.id); + const records = await this.getRecordsByIdsForFields(tableId, recordIds, fieldIds); + const fieldInstances = fields.map(createFieldInstanceByVo); + const updateRecords = this.tableDataToRecords({ + tableData: Array.from({ length: records.length }, () => []), + fields: fieldInstances, + }); + const updateRecordsRo = this.fillCells(records, updateRecords); + + return { + fieldIds, + recordIds: records.map((record) => record.id), + payload: { + ...updateRecordsRo, + fieldIds, + } as IUpdateRecordsInternalRo, + }; + } + + async buildPasteByIdPayload( + tableId: string, + pasteRo: IPasteByIdRo, + options: { recordIds?: string[] } = {} + ) { + const { content, header } = pasteRo; + const recordIds = + options.recordIds ?? (await this.resolveRecordIdsBySelection(tableId, pasteRo)); + const pasteContent = typeof content === 'string' ? this.parseCopyContent(content) : content; + let fields = await this.resolveFieldsBySelection(tableId, pasteRo); + const pasteContentSize = pasteContent.length * (pasteContent[0]?.length ?? 0); + + if (pasteContentSize > this.thresholdConfig.maxPasteCells) { + throw new CustomHttpException( + `Exceed max paste cells ${this.thresholdConfig.maxPasteCells}`, + HttpErrorCode.VALIDATION_ERROR, + { + localization: { + i18nKey: exceedMaxPasteCellsI18nKey, + }, + } + ); + } + + const contentColCount = pasteContent[0]?.length ?? 0; + const numColsToExpand = Math.max(0, contentColCount - fields.length); + const permissions = this.cls.get('permissions') ?? []; + const newFields = + numColsToExpand && permissions.includes('field|create') + ? await this.expandColumns({ tableId, header, numColsToExpand }) + : []; + fields = [...fields, ...newFields]; + const fieldIds = fields.map((field) => field.id); + + const tableData = this.expandPasteContent(pasteContent, [ + [0, 0], + [Math.max(fields.length - 1, 0), Math.max(recordIds.length - 1, 0)], + ]); + const sourceFields = header?.length + ? header.map((field) => createFieldInstanceByVo(field)) + : undefined; + const fieldInstances = fields.map(createFieldInstanceByVo); + const recordsFromClipboard = sourceFields + ? this.cellValueToRecords({ + tableData, + fields: fieldInstances, + sourceFields, + }) + : this.tableDataToRecords({ + tableData: tableData as string[][], + fields: fieldInstances, + }); + + const existingRecords = await this.getRecordsByIdsForFields(tableId, recordIds, fieldIds); + const updateRecordsRo = this.fillCells( + existingRecords, + recordsFromClipboard.slice(0, existingRecords.length) + ); + const newRecords = recordsFromClipboard.slice(existingRecords.length); + const updatePayload: IUpdateRecordsInternalRo = { + ...updateRecordsRo, + fieldIds, + typecast: true, + }; + const createPayload: ICreateRecordsRo | undefined = newRecords.length + ? { + fieldKeyType: FieldKeyType.Id, + typecast: true, + records: newRecords, + } + : undefined; + + return { + fieldIds, + recordIds: existingRecords.map((record) => record.id), + createdFieldIds: newFields.map((field) => field.id), + updatePayload, + createPayload, + ranges: [ + [0, 0], + [Math.max(fields.length - 1, 0), Math.max(tableData.length - 1, 0)], + ] as IPasteVo['ranges'], + }; + } + + async createPasteByIdMutationSnapshot( + tableId: string, + fieldIds: string[] + ): Promise { + const fields = fieldIds.length + ? await this.fieldService.getFieldsByQuery(tableId, { projection: fieldIds }) + : []; + const choiceIdsByFieldId: Record = {}; + + for (const field of fields) { + if ([FieldType.SingleSelect, FieldType.MultipleSelect].includes(field.type)) { + choiceIdsByFieldId[field.id] = ( + (field.options as ISelectFieldOptions | undefined)?.choices ?? [] + ) + .map((choice) => choice.id) + .filter(Boolean); + } + } + + return { + choiceIdsByFieldId, + }; + } + + async completePasteByIdResult( + tableId: string, + result: { + recordIds: string[]; + fieldIds: string[]; + createdRecordIds: string[]; + createdFieldIds: string[]; + }, + beforeSnapshot: IPasteByIdMutationSnapshot + ): Promise { + const afterSnapshot = await this.createPasteByIdMutationSnapshot(tableId, result.fieldIds); + const createdChoiceIdsByFieldId = Object.entries(afterSnapshot.choiceIdsByFieldId).reduce< + Record + >((acc, [fieldId, choiceIds]) => { + const createdChoiceIds = difference( + choiceIds, + beforeSnapshot.choiceIdsByFieldId[fieldId] ?? [] + ); + if (createdChoiceIds.length) { + acc[fieldId] = createdChoiceIds; + } + return acc; + }, {}); + const pastedRecordIds = [...result.recordIds, ...result.createdRecordIds]; + + return { + selection: { + recordIds: pastedRecordIds, + fieldIds: result.fieldIds, + }, + pastedRecordIds, + pastedFieldIds: result.fieldIds, + createdRecordIds: result.createdRecordIds.length ? result.createdRecordIds : undefined, + createdFieldIds: result.createdFieldIds.length ? result.createdFieldIds : undefined, + createdChoiceIdsByFieldId: Object.keys(createdChoiceIdsByFieldId).length + ? createdChoiceIdsByFieldId + : undefined, + skippedAttachments: [], + }; + } + + async clearById( + tableId: string, + clearRo: IClearByIdRo, + { + windowId, + permissionFilter, + }: { + windowId?: string; + permissionFilter?: (data: IUpdateRecordsRo) => Promise; + } = {} + ) { + const { payload, fieldIds } = await this.buildClearByIdUpdatePayload(tableId, clearRo); + const filteredUpdateRecordsRo = permissionFilter ? await permissionFilter(payload) : payload; + const maybeInternal = filteredUpdateRecordsRo as IUpdateRecordsInternalRo; + const finalPayload = + maybeInternal.fieldIds !== undefined ? maybeInternal : { ...maybeInternal, fieldIds }; + await this.recordOpenApiService.updateRecords(tableId, finalPayload, windowId); + return { recordIds: payload.records.map((record) => record.id) }; + } + + async pasteById( + tableId: string, + pasteRo: IPasteByIdRo, + { + permissionFilter, + windowId, + }: { + permissionFilter?: ( + type: 'create' | 'update', + data: ICreateRecordsRo | IUpdateRecordsRo + ) => Promise; + windowId?: string; + } = {} + ): Promise { + const { updatePayload, createPayload, fieldIds, recordIds, createdFieldIds } = + await this.buildPasteByIdPayload(tableId, pasteRo); + const beforeSnapshot = await this.createPasteByIdMutationSnapshot(tableId, fieldIds); + const filteredUpdatePayload = permissionFilter + ? await permissionFilter('update', updatePayload) + : updatePayload; + await this.recordOpenApiService.updateRecords( + tableId, + filteredUpdatePayload as IUpdateRecordsInternalRo, + windowId + ); + + const createdRecordIds: string[] = []; + if (createPayload) { + const filteredCreatePayload = permissionFilter + ? await permissionFilter('create', createPayload) + : createPayload; + const result = await this.recordOpenApiService.createRecords( + tableId, + filteredCreatePayload as ICreateRecordsRo, + undefined + ); + createdRecordIds.push(...result.records.map((record) => record.id)); + } + + return this.completePasteByIdResult( + tableId, + { recordIds, fieldIds, createdRecordIds, createdFieldIds }, + beforeSnapshot + ); + } + + async deleteById( + tableId: string, + deleteRo: IDeleteByIdRo, + { + windowId, + permissionFilter, + }: { + windowId?: string; + permissionFilter?: (recordIds: string[]) => Promise; + } = {} + ): Promise { + const recordIds = await this.resolveRecordIdsBySelection(tableId, deleteRo); + const filteredRecordIds = permissionFilter ? await permissionFilter(recordIds) : recordIds; + const diffRecordIds = difference(recordIds, filteredRecordIds); + if (diffRecordIds.length) { + throw new CustomHttpException( + `You don't have permission to delete records: ${diffRecordIds}`, + HttpErrorCode.RESTRICTED_RESOURCE, + { + localization: { + i18nKey: 'httpErrors.permission.deleteRecords', + context: { recordIds: diffRecordIds.join(',') }, + }, + } + ); + } + await this.recordOpenApiService.deleteRecords(tableId, filteredRecordIds, windowId); + return { ids: filteredRecordIds }; + } + private optionsRoToVoByCvType( cellValueType: CellValueType, options: IFieldOptionsVo = {} @@ -583,7 +961,7 @@ export class SelectionService { }) { const records: { fields: IRecord['fields'] }[] = tableData.map(() => ({ fields: {} })); fields.forEach((field, col) => { - const sourceField = sourceFields[col]; + const sourceField = sourceFields[col % sourceFields.length]; if (field.isComputed) { return; } @@ -684,6 +1062,41 @@ export class SelectionService { }; } + async copyById(tableId: string, copyRo: ICopyByIdRo) { + const recordIds = await this.resolveRecordIdsBySelection(tableId, copyRo); + const fields = await this.resolveFieldsBySelection(tableId, copyRo); + const cellCount = recordIds.length * fields.length; + + if (cellCount > this.thresholdConfig.maxCopyCells) { + throw new CustomHttpException( + `Exceed max copy cells ${this.thresholdConfig.maxCopyCells}`, + HttpErrorCode.VALIDATION_ERROR, + { + localization: { + i18nKey: 'httpErrors.selection.exceedMaxCopyCells', + }, + } + ); + } + + const records = await this.getRecordsByIdsForFields( + tableId, + recordIds, + fields.map((field) => field.id) + ); + const fieldInstances = fields.map(createFieldInstanceByVo); + const rectangleData = records.map((record) => + fieldInstances.map((fieldInstance) => + fieldInstance.cellValue2String(record.fields[fieldInstance.id] as never) + ) + ); + + return { + content: this.stringifyCopyContent(rectangleData), + header: fields, + }; + } + // If the pasted selection is twice the size of the content, // the content is automatically expanded to the selection size private expandPasteContent(pasteData: unknown[][], range: [[number, number], [number, number]]) { @@ -756,7 +1169,7 @@ export class SelectionService { HttpErrorCode.VALIDATION_ERROR, { localization: { - i18nKey: 'httpErrors.selection.exceedMaxPasteCells', + i18nKey: exceedMaxPasteCellsI18nKey, }, } ); @@ -774,7 +1187,7 @@ export class SelectionService { const tableData = this.expandPasteContent(pasteContent, rangeCell); const tableColCount = tableData[0].length; const effectFields = fields.slice(startColumnIndex, startColumnIndex + tableColCount); - const sourceFields = header && header.map((f) => createFieldInstanceByVo(f)); + const sourceFields = header?.length ? header.map((f) => createFieldInstanceByVo(f)) : undefined; let result: ITemporaryPasteVo = []; await this.prismaService.$tx(async () => { @@ -834,7 +1247,7 @@ export class SelectionService { HttpErrorCode.VALIDATION_ERROR, { localization: { - i18nKey: 'httpErrors.selection.exceedMaxPasteCells', + i18nKey: exceedMaxPasteCellsI18nKey, }, } ); @@ -844,7 +1257,7 @@ export class SelectionService { tableId, queryRo ); - const sourceFields = header && header.map((f) => createFieldInstanceByVo(f)); + const sourceFields = header?.length ? header.map((f) => createFieldInstanceByVo(f)) : undefined; const fields = await this.fieldService.getFieldInstances(tableId, { viewId, filterHidden: true, diff --git a/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.ts b/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.ts index f55e56c1f9..33898206c4 100644 --- a/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.ts +++ b/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.ts @@ -229,6 +229,7 @@ export class SettingOpenApiService { const availableIntegrationProviders: string[] = [ ...(process.env.GMAIL_CLIENT_ID ? ['gmail'] : []), ...(process.env.OUTLOOK_CLIENT_ID ? ['outlook'] : []), + ...(process.env.AIRTABLE_CLIENT_ID ? ['airtable'] : []), ]; return { diff --git a/apps/nestjs-backend/src/features/share/share-link-query.util.ts b/apps/nestjs-backend/src/features/share/share-link-query.util.ts new file mode 100644 index 0000000000..75b94132da --- /dev/null +++ b/apps/nestjs-backend/src/features/share/share-link-query.util.ts @@ -0,0 +1,20 @@ +/** + * A link "selection" query loads records that are already linked or explicitly chosen + * (via `filterLinkCellSelected` or `selectedRecordIds`), as opposed to the candidate + * list of records available to be newly linked (`filterLinkCellCandidate`). + * + * The link field's view scope (`filterByViewId`) and configured filter only constrain + * the candidate list, so selection queries must NOT be filtered by them — otherwise an + * already-linked record that falls outside the configured view disappears (renders + * blank / rowCount 0). See T4864. + * + * Note: `selectedRecordIds` combined with `filterLinkCellCandidate` is the "exclude + * these from the candidate list" case, which must keep the view scope. + */ +export const isLinkRecordSelectionQuery = (query?: { + filterLinkCellSelected?: unknown; + filterLinkCellCandidate?: unknown; + selectedRecordIds?: string[]; +}): boolean => + Boolean(query?.filterLinkCellSelected) || + (Boolean(query?.selectedRecordIds?.length) && !query?.filterLinkCellCandidate); 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 5566fd138a..b4a06e41b0 100644 --- a/apps/nestjs-backend/src/features/share/share-socket.service.ts +++ b/apps/nestjs-backend/src/features/share/share-socket.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { HttpErrorCode, type IGetFieldsQuery } from '@teable/core'; import type { IGetRecordsRo } from '@teable/openapi'; import { difference } from 'lodash'; @@ -7,6 +7,7 @@ import { FieldService } from '../field/field.service'; import { RecordService } from '../record/record.service'; import { ViewService } from '../view/view.service'; import type { IShareViewInfo } from './share-auth.service'; +import { isLinkRecordSelectionQuery } from './share-link-query.util'; @Injectable() export class ShareSocketService { @@ -113,10 +114,12 @@ export class ShareSocketService { const { id } = view ?? {}; const { filterByViewId } = linkOptions ?? {}; - const viewId = filterByViewId ?? id; - // if filterLinkCellSelected is not empty, use it as filter - const defaultFilter = linkOptions?.filter ?? query.filter; - const filter = !query.filterLinkCellSelected ? defaultFilter : undefined; + // Queries that load already-linked records (filterLinkCellSelected or explicit + // selectedRecordIds) must return them in full, even when they fall outside the link + // field's view scope. The view scope/filter only constrains the candidate list. T4864. + const isLinkSelectionQuery = Boolean(linkOptions) && isLinkRecordSelectionQuery(query); + const viewId = isLinkSelectionQuery ? id : filterByViewId ?? id; + const filter = isLinkSelectionQuery ? undefined : linkOptions?.filter ?? query.filter; let projection = query.projection; if (linkOptions) { diff --git a/apps/nestjs-backend/src/features/share/share.service.ts b/apps/nestjs-backend/src/features/share/share.service.ts index ee400c4332..b3b07833a4 100644 --- a/apps/nestjs-backend/src/features/share/share.service.ts +++ b/apps/nestjs-backend/src/features/share/share.service.ts @@ -51,6 +51,7 @@ import { RecordOpenApiService } from '../record/open-api/record-open-api.service import { RecordService } from '../record/record.service'; import { SelectionService } from '../selection/selection.service'; import type { IShareViewInfo } from './share-auth.service'; +import { isLinkRecordSelectionQuery } from './share-link-query.util'; import { ShareSocketService } from './share-socket.service'; export interface IJwtShareInfo { @@ -177,9 +178,11 @@ export class ShareService { if (query?.field) { Object.entries(query.field).forEach(([key, value]) => { const stats = value.map((fieldId) => { - // check field hidden + // check field hidden: guard on the aggregated fieldId, not the + // statistic func key — passing the func would never match a column + // and silently leak hidden-column aggregates to share visitors if (shareInfo.view) { - this.preCheckFieldHidden(shareInfo.view as IViewVo, key); + this.preCheckFieldHidden(shareInfo.view as IViewVo, fieldId); } return { fieldId, @@ -210,15 +213,19 @@ export class ShareService { const { id } = view ?? {}; const { filterByViewId } = linkOptions ?? {}; - const viewId = filterByViewId ?? id; const tableId = shareInfo.tableId; - // if filterLinkCellSelected is not empty, use it as filter - const defaultFilter = linkOptions?.filter ?? query?.filter; - const filter = query?.filterLinkCellSelected ? undefined : defaultFilter; + // The link field's view scope (filterByViewId)/filter only constrains which records + // can be newly linked (the candidate list). Queries that load already-linked records + // (filterLinkCellSelected or explicit selectedRecordIds) must count them in full, + // even when they fall outside that view — otherwise an already-linked record outside + // the configured view reports rowCount 0. T4864. + const isLinkSelectionQuery = Boolean(linkOptions) && isLinkRecordSelectionQuery(query); + const viewId = isLinkSelectionQuery ? id : filterByViewId ?? id; + const filter = isLinkSelectionQuery ? undefined : linkOptions?.filter ?? query?.filter; const result = await this.aggregationService.performRowCount(tableId, { + ...query, viewId, filter, - ...query, }); return { @@ -237,31 +244,40 @@ export class ShareService { } const { id, group } = view ?? {}; - const { filterByViewId, filter: linkFilter, visibleFieldIds } = linkOptions ?? {}; + const { filterByViewId, filter: linkFilter } = linkOptions ?? {}; const viewId = filterByViewId ?? id; - const fields = await this.fieldService.getFieldsByQuery(tableId, { - viewId, - filterHidden: Boolean(filterByViewId) || !shareMeta?.includeHiddenField, - }); - const filteredFields = visibleFieldIds?.length - ? fields.filter((f) => visibleFieldIds?.includes(f.id) || f.isPrimary) - : fields; - - // filterLinkCellSelected applies its own filter; skip the default. - const filter = query?.filterLinkCellSelected ? undefined : query?.filter ?? linkFilter; + // A client-supplied projection must never widen the share's field scope: + // intersect it with the visible fields so a crafted projection cannot read + // hidden columns (the default projection already lists only visible fields). + const shareVisibleFieldIds = await this.getShareVisibleFieldIds(shareInfo); + const visibleFieldIdSet = new Set(shareVisibleFieldIds); + const requestedFieldIds = query?.projection?.length + ? query.projection.filter((fieldId) => visibleFieldIdSet.has(fieldId)) + : shareVisibleFieldIds; + // Fall back to the full visible set when a (crafted) projection requested + // only hidden fields: an empty projection is read downstream as "all + // fields", which would leak every column. + const projection = requestedFieldIds.length ? requestedFieldIds : shareVisibleFieldIds; + + // Queries that load already-linked records (filterLinkCellSelected or explicit + // selectedRecordIds) must return them in full, even when they fall outside the link + // field's view scope — otherwise an already-linked record outside the configured + // view renders blank. The view scope/filter only constrains the candidate list. T4864. + const isLinkSelectionQuery = Boolean(linkOptions) && isLinkRecordSelectionQuery(query); + const filter = isLinkSelectionQuery ? undefined : query?.filter ?? linkFilter; return await this.recordService.getRecords( tableId, { - viewId, + viewId: isLinkSelectionQuery ? id : viewId, skip: query?.skip ?? 0, take: query?.take ?? 100, filter, orderBy: query?.orderBy, groupBy: query?.groupBy ?? group, fieldKeyType: FieldKeyType.Id, - projection: query?.projection ?? filteredFields.map((f) => f.id), + projection, search: query?.search, filterLinkCellCandidate: query?.filterLinkCellCandidate, filterLinkCellSelected: query?.filterLinkCellSelected, @@ -314,6 +330,25 @@ export class ShareService { }); } + // 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 — + // the share context carries no authority matrix, so this is the only column + // guard for the records/calendar read paths. + private async getShareVisibleFieldIds(shareInfo: IShareViewInfo): Promise { + const { tableId, view, linkOptions, shareMeta } = shareInfo; + const { filterByViewId, visibleFieldIds } = linkOptions ?? {}; + const viewId = filterByViewId ?? view?.id; + const fields = await this.fieldService.getFieldsByQuery(tableId, { + viewId, + filterHidden: Boolean(filterByViewId) || !shareMeta?.includeHiddenField, + }); + const filtered = visibleFieldIds?.length + ? fields.filter((f) => visibleFieldIds.includes(f.id) || f.isPrimary) + : fields; + return filtered.map((f) => f.id); + } + private preCheckFieldHidden(view: IViewVo, fieldId: string) { // hidden check if (!view.shareMeta?.includeHiddenField && !isNotHiddenField(fieldId, view)) { @@ -550,18 +585,12 @@ export class ShareService { const users = await this.prismaService.user.findMany({ where: { id: { in: userIds }, - ...(search - ? { - OR: [ - { name: { contains: search, mode: 'insensitive' } }, - { email: { contains: search, mode: 'insensitive' } }, - ], - } - : {}), + // Match by name only: searching by email would let anonymous viewers + // probe membership by email even though email is not returned. + ...(search ? { name: { contains: search, mode: 'insensitive' } } : {}), }, select: { id: true, - email: true, name: true, avatar: true, }, @@ -569,9 +598,10 @@ export class ShareService { take, }); - return users.map(({ id, email, name, avatar }) => ({ + // Email is intentionally omitted from share responses to avoid leaking the + // member directory to anonymous viewers. Picker selection is by id. + return users.map(({ id, name, avatar }) => ({ userId: id, - email, userName: name, avatar: avatar && getPublicFullStorageUrl(avatar), })); @@ -619,10 +649,13 @@ export class ShareService { skip, take, search, + // Anonymous share views must not allow probing membership by email. + searchByEmail: false, }); + // Email is intentionally omitted from share responses to avoid leaking the + // member directory to anonymous viewers. Picker selection is by id. return list.map((item) => ({ userId: item.id, - email: item.email, userName: item.name, avatar: item.avatar, })); @@ -640,10 +673,22 @@ export class ShareService { shareInfo: IShareViewInfo, query: IShareViewCalendarDailyCollectionRo ) { - return this.aggregationService.getCalendarDailyCollection(shareInfo.tableId, { + const result = await this.aggregationService.getCalendarDailyCollection(shareInfo.tableId, { ...query, viewId: shareInfo.view?.id, }); + // The daily collection returns full record snapshots; restrict them to the + // share's visible fields so hidden columns are not leaked to the visitor. + const visibleFieldIds = new Set(await this.getShareVisibleFieldIds(shareInfo)); + return { + ...result, + records: result.records.map((record) => ({ + ...record, + fields: Object.fromEntries( + Object.entries(record.fields).filter(([fieldId]) => visibleFieldIds.has(fieldId)) + ), + })), + }; } async buttonClick(shareInfo: IShareViewInfo, recordId: string, fieldId: string) { diff --git a/apps/nestjs-backend/src/features/space/data-db-baseline.service.ts b/apps/nestjs-backend/src/features/space/data-db-baseline.service.ts index 6777ea8ac4..065e6b977d 100644 --- a/apps/nestjs-backend/src/features/space/data-db-baseline.service.ts +++ b/apps/nestjs-backend/src/features/space/data-db-baseline.service.ts @@ -9,4 +9,8 @@ export class DataDbBaselineService { await this.migrationService.migrate(url, internalSchema); return this.migrationService.getLatestSchemaVersion(); } + + getLatestSchemaVersion() { + return this.migrationService.getLatestSchemaVersion(); + } } diff --git a/apps/nestjs-backend/src/features/space/data-db-binding.service.spec.ts b/apps/nestjs-backend/src/features/space/data-db-binding.service.spec.ts index 578a108da7..68cabf628e 100644 --- a/apps/nestjs-backend/src/features/space/data-db-binding.service.spec.ts +++ b/apps/nestjs-backend/src/features/space/data-db-binding.service.spec.ts @@ -4,6 +4,25 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DataDbBindingService } from './data-db-binding.service'; import { encryptDataDbUrl } from './data-db-url-secret'; +vi.mock('@teable/db-main-prisma', () => ({ + MetaPrismaService: class MetaPrismaService {}, + Prisma: {}, + PrismaModule: class PrismaModule {}, + PrismaService: class PrismaService {}, + ProvisionState: {}, + getDatabaseUrl: vi.fn(), +})); +vi.mock('@teable/db-data-prisma', () => ({ + DataPrismaModule: class DataPrismaModule {}, + DataPrismaService: class DataPrismaService {}, + PrismaClient: class PrismaClient {}, + getMetaDatabaseUrl: vi.fn(), +})); +vi.mock('@prisma/client', () => ({ + Prisma: {}, + PrismaClient: class PrismaClient {}, +})); + const dataUrl = 'postgresql://teable:secret@example.com:5432/teable_data'; const initializeEmptyTargetMode = 'initialize-empty'; const internalSchema = 'teable_meta_test'; @@ -52,6 +71,10 @@ describe('DataDbBindingService', () => { const dataDbMigrationService = { ensureConnectionMigrated: vi.fn(), }; + const spaceDataDbMigrationService = { + startMigrationForSpace: vi.fn(), + runMigrationJob: vi.fn(), + }; const byodbBinding = { mode: 'byodb', state: 'ready', @@ -83,6 +106,13 @@ describe('DataDbBindingService', () => { baselineService.initialize.mockReset().mockResolvedValue(schemaVersion); dataDbClientManager.invalidateConnection.mockReset(); dataDbMigrationService.ensureConnectionMigrated.mockReset().mockResolvedValue([]); + spaceDataDbMigrationService.startMigrationForSpace.mockReset().mockResolvedValue({ + jobId: 'sdmjxxx', + connectionId: 'dcnxxx', + }); + spaceDataDbMigrationService.runMigrationJob.mockReset().mockResolvedValue({ + state: 'succeeded', + }); prismaService.dataDbConnection.update.mockReset(); prismaService.spaceDataDbBinding.findUnique.mockReset().mockResolvedValue(byodbBinding); prismaService.spaceDataDbBinding.updateMany.mockReset(); @@ -142,6 +172,39 @@ describe('DataDbBindingService', () => { expect(dataDbClientManager.invalidateConnection).toHaveBeenCalledWith('dcnxxx'); }); + it('returns preflight errors before deriving binding metadata from an invalid URL', async () => { + preflightService.preflight.mockResolvedValue({ + ok: false, + provider: 'postgres', + classification: 'non-empty-unknown', + capabilities, + errors: [{ code: 'INVALID_DATABASE_URL', message: 'Invalid URL' }], + }); + const service = new DataDbBindingService( + prismaService as never, + preflightService as never, + baselineService as never, + dataDbClientManager as never + ); + + await expect( + service.createBindingForNewSpace('spcxxx', 'usrxxx', { + mode: 'byodb', + url: 'not-a-postgres-url', + targetMode: initializeEmptyTargetMode, + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + preflight: expect.objectContaining({ + errors: [expect.objectContaining({ code: 'INVALID_DATABASE_URL' })], + }), + }), + }); + expect(baselineService.initialize).not.toHaveBeenCalled(); + expect(txClient.dataDbConnection.upsert).not.toHaveBeenCalled(); + }); + it('generates an internal schema for new BYODB spaces when one is not provided', async () => { preflightService.preflight.mockResolvedValue({ ok: true, @@ -167,7 +230,7 @@ describe('DataDbBindingService', () => { expect(preflightService.preflight).toHaveBeenCalledWith({ url: dataUrl, targetMode: initializeEmptyTargetMode, - internalSchema: generatedInternalSchema, + internalSchema: undefined, }); expect(baselineService.initialize).toHaveBeenCalledWith(dataUrl, generatedInternalSchema); expect(txClient.dataDbConnection.upsert).toHaveBeenCalledWith( @@ -195,7 +258,7 @@ describe('DataDbBindingService', () => { const dataDb = { mode: 'byodb' as const, url: dataUrl, - targetMode: initializeEmptyTargetMode, + targetMode: 'initialize-empty' as const, internalSchema, }; await service.createBindingForNewSpace('spcxxx1', 'usrxxx', dataDb); @@ -389,7 +452,97 @@ describe('DataDbBindingService', () => { expect(dataDbClientManager.invalidateConnection).toHaveBeenCalledWith('dcnxxx'); }); - it('does not create a BYODB binding for an existing default space without adopt-existing mode', async () => { + it('queues a migration job for an existing default space with migrate-space target mode', async () => { + prismaService.spaceDataDbBinding.findUnique.mockResolvedValue(null); + const service = new DataDbBindingService( + prismaService as never, + preflightService as never, + baselineService as never, + dataDbClientManager as never, + dataDbMigrationService as never, + spaceDataDbMigrationService as never + ); + + await service.updateBindingForSpace( + 'spcxxx', + 'usrxxx', + { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + confirmLargeMigration: true, + }, + { allowSpaceMigration: true } + ); + + expect(spaceDataDbMigrationService.startMigrationForSpace).toHaveBeenCalledWith( + 'spcxxx', + 'usrxxx', + { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + confirmLargeMigration: true, + } + ); + expect(spaceDataDbMigrationService.runMigrationJob).not.toHaveBeenCalled(); + expect(preflightService.preflight).not.toHaveBeenCalled(); + expect(txClient.spaceDataDbBinding.upsert).not.toHaveBeenCalled(); + }); + + it('rejects migrate-space unless the caller allows admin-only space migration', async () => { + prismaService.spaceDataDbBinding.findUnique.mockResolvedValue(null); + const service = new DataDbBindingService( + prismaService as never, + preflightService as never, + baselineService as never, + dataDbClientManager as never, + dataDbMigrationService as never, + spaceDataDbMigrationService as never + ); + + await expect( + service.updateBindingForSpace('spcxxx', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.RESTRICTED_RESOURCE, + data: { errorCode: 'SPACE_DATA_DB_ADMIN_ONLY' }, + }); + expect(spaceDataDbMigrationService.startMigrationForSpace).not.toHaveBeenCalled(); + expect(preflightService.preflight).not.toHaveBeenCalled(); + expect(txClient.spaceDataDbBinding.upsert).not.toHaveBeenCalled(); + }); + + it('rejects migrate-space when the migration service is unavailable', async () => { + prismaService.spaceDataDbBinding.findUnique.mockResolvedValue(null); + const service = new DataDbBindingService( + prismaService as never, + preflightService as never, + baselineService as never, + dataDbClientManager as never, + dataDbMigrationService as never + ); + + await expect( + service.updateBindingForSpace( + 'spcxxx', + 'usrxxx', + { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }, + { allowSpaceMigration: true } + ) + ).rejects.toMatchObject({ code: HttpErrorCode.CONFLICT }); + expect(preflightService.preflight).not.toHaveBeenCalled(); + expect(txClient.spaceDataDbBinding.upsert).not.toHaveBeenCalled(); + }); + + it('does not create a BYODB binding for an existing default space without adopt-existing or migrate-space mode', async () => { prismaService.spaceDataDbBinding.findUnique.mockResolvedValue(null); const service = new DataDbBindingService( prismaService as never, @@ -411,6 +564,16 @@ describe('DataDbBindingService', () => { }); it('rejects credential updates that would move the space to a different data DB', async () => { + preflightService.preflight.mockResolvedValue({ + ok: true, + provider: 'postgres', + classification: 'teable-managed-compatible', + capabilities, + errors: [], + internalSchema, + displayHost: 'other.example.com:5432', + displayDatabase: 'teable_data', + }); const service = new DataDbBindingService( prismaService as never, preflightService as never, diff --git a/apps/nestjs-backend/src/features/space/data-db-binding.service.ts b/apps/nestjs-backend/src/features/space/data-db-binding.service.ts index 1bfffee7a9..c80199b3bb 100644 --- a/apps/nestjs-backend/src/features/space/data-db-binding.service.ts +++ b/apps/nestjs-backend/src/features/space/data-db-binding.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Optional } from '@nestjs/common'; import { HttpErrorCode } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import type { ICreateSpaceRo, IDataDbPreflightRo, IDataDbPreflightVo } from '@teable/openapi'; @@ -13,8 +13,17 @@ import { getDatabaseUrlDisplayParts, } from './data-db-preflight.service'; import { decryptDataDbUrl, encryptDataDbUrl } from './data-db-url-secret'; +import { + migrateSpaceTargetMode, + spaceDataDbAdminOnlyErrorCode, + spaceDataDbAdminOnlyMessage, +} from './space-data-db-migration.constants'; +import { SpaceDataDbMigrationService } from './space-data-db-migration.service'; type IDataDbCreateOptions = NonNullable; +type IDataDbUpdateOptions = { + allowSpaceMigration?: boolean; +}; type IPreparedDataDbBinding = { encryptedUrl: string; urlFingerprint: string; @@ -24,6 +33,20 @@ type IPreparedDataDbBinding = { schemaVersion: string | null; capabilities: IDataDbPreflightVo['capabilities']; }; +type IExistingDataDbConnection = { + id: string; + encryptedUrl: string; + displayHost: string | null; + displayDatabase: string | null; + internalSchema: string; + createdBy?: string | null; +}; +type IExistingBindingUpdateTarget = { + preflight: IDataDbPreflightVo; + internalSchema: string; + displayHost: string; + displayDatabase: string; +}; const initializeEmptyTargetMode = 'initialize-empty'; const adoptExistingTargetMode = 'adopt-existing'; @@ -43,7 +66,8 @@ export class DataDbBindingService { private readonly preflightService: DataDbPreflightService, private readonly baselineService: DataDbBaselineService, private readonly dataDbClientManager: DataDbClientManager, - private readonly dataDbMigrationService?: DataDbMigrationService + @Optional() private readonly dataDbMigrationService?: DataDbMigrationService, + @Optional() private readonly spaceDataDbMigrationService?: SpaceDataDbMigrationService ) {} async createBindingForNewSpace( @@ -187,7 +211,12 @@ export class DataDbBindingService { return applied; } - async updateBindingForSpace(spaceId: string, updatedBy: string, dataDb: IDataDbPreflightRo) { + async updateBindingForSpace( + spaceId: string, + updatedBy: string, + dataDb: IDataDbPreflightRo, + options: IDataDbUpdateOptions = {} + ) { if (!dataDb.url) { throw new CustomHttpException(dataDbUrlRequiredError, HttpErrorCode.VALIDATION_ERROR); } @@ -197,55 +226,23 @@ export class DataDbBindingService { include: { dataDbConnection: true }, }); if (binding?.mode !== 'byodb' || !binding.dataDbConnection?.encryptedUrl) { - if (dataDb.targetMode === adoptExistingTargetMode) { - await this.createBindingForExistingSpace(spaceId, updatedBy, dataDb); - return; - } - - throw new CustomHttpException( - 'BYODB data database binding was not found', - HttpErrorCode.NOT_FOUND - ); + await this.updateDefaultSpaceBinding(spaceId, updatedBy, dataDb, options); + return; } const current = binding.dataDbConnection; - const internalSchema = resolveDataDbInternalSchema( - dataDb.internalSchema ?? current.internalSchema, - dataDb.url - ); - const nextDisplayParts = getDatabaseUrlDisplayParts(dataDb.url); + const target = await this.prepareExistingBindingUpdateTarget(dataDb, current); + this.assertExistingBindingTargetUnchanged(current, target); - if ( - current.internalSchema !== internalSchema || - current.displayHost !== nextDisplayParts.displayHost || - current.displayDatabase !== nextDisplayParts.displayDatabase - ) { - throw new CustomHttpException( - 'Changing the BYODB database or internal schema is not supported yet', - HttpErrorCode.VALIDATION_ERROR - ); - } - - const preflight = await this.preflightService.preflight({ - url: dataDb.url, - targetMode: initializeEmptyTargetMode, - internalSchema, - }); - if (!preflight.ok) { - throw new CustomHttpException(buildPreflightErrorMessage(preflight), HttpErrorCode.CONFLICT, { - preflight, - }); - } - - const schemaVersion = await this.baselineService.initialize(dataDb.url, internalSchema); + const schemaVersion = await this.baselineService.initialize(dataDb.url, target.internalSchema); await this.prismaService.dataDbConnection.update({ where: { id: current.id }, data: { encryptedUrl: encryptDataDbUrl(dataDb.url), - urlFingerprint: fingerprintDataDbConnection(dataDb.url, internalSchema), + urlFingerprint: fingerprintDataDbConnection(dataDb.url, target.internalSchema), status: 'ready', schemaVersion, - capabilities: preflight.capabilities, + capabilities: target.preflight.capabilities, lastValidatedAt: new Date(), lastError: null, createdBy: current.createdBy ?? updatedBy, @@ -258,6 +255,101 @@ export class DataDbBindingService { await this.dataDbClientManager.invalidateConnection(current.id); } + private async updateDefaultSpaceBinding( + spaceId: string, + updatedBy: string, + dataDb: IDataDbPreflightRo, + options: IDataDbUpdateOptions + ) { + if (dataDb.targetMode === adoptExistingTargetMode) { + await this.createBindingForExistingSpace(spaceId, updatedBy, dataDb); + return; + } + if (dataDb.targetMode === migrateSpaceTargetMode) { + this.assertSpaceMigrationAllowed(options); + await this.startSpaceMigration(spaceId, updatedBy, dataDb); + return; + } + + throw new CustomHttpException( + 'BYODB data database binding was not found', + HttpErrorCode.NOT_FOUND + ); + } + + private assertSpaceMigrationAllowed(options: IDataDbUpdateOptions) { + if (options.allowSpaceMigration) { + return; + } + throw new CustomHttpException(spaceDataDbAdminOnlyMessage, HttpErrorCode.RESTRICTED_RESOURCE, { + errorCode: spaceDataDbAdminOnlyErrorCode, + }); + } + + private async startSpaceMigration( + spaceId: string, + updatedBy: string, + dataDb: IDataDbPreflightRo + ) { + if (!this.spaceDataDbMigrationService) { + throw new CustomHttpException( + 'Space data database migration service is unavailable', + HttpErrorCode.CONFLICT + ); + } + await this.spaceDataDbMigrationService.startMigrationForSpace(spaceId, updatedBy, dataDb); + } + + private async prepareExistingBindingUpdateTarget( + dataDb: IDataDbPreflightRo, + current: IExistingDataDbConnection + ): Promise { + const preflight = await this.preflightService.preflight({ + url: dataDb.url, + targetMode: initializeEmptyTargetMode, + internalSchema: dataDb.internalSchema ?? current.internalSchema, + }); + if (!preflight.ok) { + throw new CustomHttpException(buildPreflightErrorMessage(preflight), HttpErrorCode.CONFLICT, { + preflight, + }); + } + + const internalSchema = + preflight.internalSchema ?? + resolveDataDbInternalSchema(dataDb.internalSchema ?? current.internalSchema, dataDb.url); + const displayParts = + preflight.displayHost && preflight.displayDatabase != null + ? { + displayHost: preflight.displayHost, + displayDatabase: preflight.displayDatabase, + } + : getDatabaseUrlDisplayParts(dataDb.url); + + return { + preflight, + internalSchema, + ...displayParts, + }; + } + + private assertExistingBindingTargetUnchanged( + current: Pick, + next: Pick + ) { + if ( + current.internalSchema === next.internalSchema && + current.displayHost === next.displayHost && + current.displayDatabase === next.displayDatabase + ) { + return; + } + throw new CustomHttpException( + 'Changing the BYODB database or internal schema is not supported yet', + HttpErrorCode.VALIDATION_ERROR + ); + } + async createBindingForExistingSpace( spaceId: string, createdBy: string, @@ -328,11 +420,10 @@ export class DataDbBindingService { dataDb: IDataDbPreflightRo, targetMode: IDataDbPreflightRo['targetMode'] ): Promise { - const internalSchema = resolveDataDbInternalSchema(dataDb.internalSchema, dataDb.url); const preflight = await this.preflightService.preflight({ url: dataDb.url, targetMode, - internalSchema, + internalSchema: dataDb.internalSchema, }); if (!preflight.ok) { throw new CustomHttpException(buildPreflightErrorMessage(preflight), HttpErrorCode.CONFLICT, { @@ -340,9 +431,17 @@ export class DataDbBindingService { }); } + const internalSchema = + preflight.internalSchema ?? resolveDataDbInternalSchema(dataDb.internalSchema, dataDb.url); const schemaVersion = await this.baselineService.initialize(dataDb.url, internalSchema); - const { displayHost, displayDatabase } = getDatabaseUrlDisplayParts(dataDb.url); + const { displayHost, displayDatabase } = + preflight.displayHost && preflight.displayDatabase != null + ? { + displayHost: preflight.displayHost, + displayDatabase: preflight.displayDatabase, + } + : getDatabaseUrlDisplayParts(dataDb.url); return { encryptedUrl: encryptDataDbUrl(dataDb.url), urlFingerprint: fingerprintDataDbConnection(dataDb.url, internalSchema), diff --git a/apps/nestjs-backend/src/features/space/data-db-migration.service.spec.ts b/apps/nestjs-backend/src/features/space/data-db-migration.service.spec.ts index f194606a1b..5006001c68 100644 --- a/apps/nestjs-backend/src/features/space/data-db-migration.service.spec.ts +++ b/apps/nestjs-backend/src/features/space/data-db-migration.service.spec.ts @@ -52,6 +52,18 @@ const migrations: IDataDbMigration[] = [ const checksumSql = (sql: string) => createHash('sha256').update(sql).digest('hex'); describe('DataDbMigrationService', () => { + it('includes attachment support tables in the default data DB migrations', async () => { + const client = new FakeMigrationClient(); + const service = new DataDbMigrationService(undefined, () => client); + + await service.migrate('postgresql://teable:secret@example.com:5432/data', 'teable_test'); + + const executedSql = client.calls.map((call) => call.sql).join('\n'); + expect(executedSql).toContain('CREATE TABLE IF NOT EXISTS "attachments"'); + expect(executedSql).toContain('CREATE TABLE IF NOT EXISTS "attachments_table"'); + expect(executedSql).toContain('CREATE INDEX IF NOT EXISTS "attachments_table_attachment_id_idx"'); + }); + it('creates the internal schema, locks, runs pending migrations, and records them', async () => { const client = new FakeMigrationClient(); const service = new DataDbMigrationService(migrations, () => client); diff --git a/apps/nestjs-backend/src/features/space/data-db-migration.service.ts b/apps/nestjs-backend/src/features/space/data-db-migration.service.ts index d38e22dcf2..dd344b1f82 100644 --- a/apps/nestjs-backend/src/features/space/data-db-migration.service.ts +++ b/apps/nestjs-backend/src/features/space/data-db-migration.service.ts @@ -24,6 +24,7 @@ export const DATA_DB_MIGRATION_TABLE = '__teable_data_schema_migrations'; const migrationsRootCandidates = [ join(process.cwd(), 'community/packages/db-data-prisma/prisma/migrations'), + join(process.cwd(), '../../packages/db-data-prisma/prisma/migrations'), join(process.cwd(), '../../community/packages/db-data-prisma/prisma/migrations'), ]; const defaultMigrationStatementTimeoutMs = 300_000; 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 8e9941f77a..a1c0dc9bf2 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 @@ -6,8 +6,10 @@ import type { IDataDbPreflightClient } from './data-db-preflight.service'; import { DataDbPreflightService, fingerprintDatabaseUrl, + fingerprintDataDbConnection, maskDatabaseUrl, } from './data-db-preflight.service'; +import { encryptDataDbUrl } from './data-db-url-secret'; type IFakeDbState = { schemas?: string[]; @@ -15,9 +17,15 @@ type IFakeDbState = { functions?: string[]; databases?: string[]; failCreateSchema?: boolean; + failCreateTable?: boolean; + failCreateIndex?: boolean; + failCreateFunction?: boolean; + failCreateTrigger?: boolean; failConnect?: boolean; failMessage?: string; failCode?: string; + canCreateRole?: boolean; + canGrantPrivileges?: boolean; }; class FakePreflightClient implements IDataDbPreflightClient { @@ -32,14 +40,26 @@ class FakePreflightClient implements IDataDbPreflightClient { if (sql.includes('CREATE SCHEMA') && this.state.failCreateSchema) { throw new Error('permission denied for database'); } + if (sql.includes('CREATE TABLE') && this.state.failCreateTable) { + throw new Error('permission denied for table'); + } + if (sql.includes('CREATE INDEX') && this.state.failCreateIndex) { + throw new Error('permission denied for index'); + } + if (sql.includes('CREATE OR REPLACE FUNCTION') && this.state.failCreateFunction) { + throw new Error('permission denied for function'); + } + if (sql.includes('CREATE TRIGGER') && this.state.failCreateTrigger) { + throw new Error('permission denied for trigger'); + } if (sql.includes('SHOW server_version')) { return { rows: [{ server_version: '14.12' }] as T[] }; } if (sql.includes('has_database_privilege')) { - return { rows: [{ can_create: true }] as T[] }; + return { rows: [{ can_create: this.state.canGrantPrivileges ?? true }] as T[] }; } if (sql.includes('pg_roles')) { - return { rows: [{ can_create_role: false }] as T[] }; + return { rows: [{ can_create_role: this.state.canCreateRole ?? false }] as T[] }; } if (sql.includes('pg_stat_activity')) { return { rows: [{ count: '0' }] as T[] }; @@ -89,6 +109,27 @@ const DATA_SCHEMA_MIGRATION_TABLE = '__teable_data_schema_migrations'; const createService = (state: IFakeDbState) => new DataDbPreflightService(undefined, () => new FakePreflightClient(state)); +const defaultRelatedSpacesPrisma = () => ({ + field: { + findMany: vi.fn().mockResolvedValue([]), + }, + tableMeta: { + findMany: vi.fn().mockResolvedValue([]), + }, + space: { + findMany: vi.fn().mockResolvedValue([ + { + id: 'spcxxx', + name: 'Space', + baseGroup: [], + }, + ]), + }, + spaceDataDbBinding: { + findMany: vi.fn().mockResolvedValue([]), + }, +}); + describe('database URL helpers', () => { it('masks database URL passwords', () => { expect(maskDatabaseUrl(DATA_URL)).toBe('postgresql://teable:***@example.com:5432/teable_data'); @@ -241,6 +282,106 @@ describe('DataDbPreflightService', () => { expect(result.errors.map((error) => error.code)).toContain('DDL_PRIVILEGE_CHECK_FAILED'); }); + it('reports missing table or index privileges with partial DDL capabilities', async () => { + const missingTable = await createService({ + schemas: ['public'], + failCreateTable: true, + }).preflight({ + url: DATA_URL, + targetMode: 'initialize-empty', + }); + + expect(missingTable.ok).toBe(false); + expect(missingTable.capabilities).toMatchObject({ + createSchema: true, + createTable: false, + createFunction: false, + createTrigger: false, + }); + expect(missingTable.errors.map((error) => error.code)).toContain('DDL_PRIVILEGE_CHECK_FAILED'); + + const missingIndex = await createService({ + schemas: ['public'], + failCreateIndex: true, + }).preflight({ + url: DATA_URL, + targetMode: 'initialize-empty', + }); + + expect(missingIndex.ok).toBe(false); + expect(missingIndex.capabilities).toMatchObject({ + createSchema: true, + createTable: true, + createFunction: false, + createTrigger: false, + }); + expect(missingIndex.errors.map((error) => error.code)).toContain('DDL_PRIVILEGE_CHECK_FAILED'); + }); + + it('reports missing function or trigger privileges with partial DDL capabilities', async () => { + const missingFunction = await createService({ + schemas: ['public'], + failCreateFunction: true, + }).preflight({ + url: DATA_URL, + targetMode: 'initialize-empty', + }); + + expect(missingFunction.ok).toBe(false); + expect(missingFunction.capabilities).toMatchObject({ + createSchema: true, + createTable: true, + createFunction: false, + createTrigger: false, + }); + expect(missingFunction.errors.map((error) => error.code)).toContain( + 'DDL_PRIVILEGE_CHECK_FAILED' + ); + + const missingTrigger = await createService({ + schemas: ['public'], + failCreateTrigger: true, + }).preflight({ + url: DATA_URL, + targetMode: 'initialize-empty', + }); + + expect(missingTrigger.ok).toBe(false); + expect(missingTrigger.capabilities).toMatchObject({ + createSchema: true, + createTable: true, + createFunction: true, + createTrigger: false, + }); + expect(missingTrigger.errors.map((error) => error.code)).toContain( + 'DDL_PRIVILEGE_CHECK_FAILED' + ); + }); + + it('reports readonly sharing capability separately from core BYODB usability', async () => { + const result = await createService({ + schemas: ['public'], + tables: [], + canCreateRole: false, + canGrantPrivileges: false, + }).preflight({ + url: DATA_URL, + targetMode: 'initialize-empty', + }); + + expect(result.ok).toBe(true); + expect(result.classification).toBe('empty'); + expect(result.capabilities).toMatchObject({ + createSchema: true, + createTable: true, + createFunction: true, + createTrigger: true, + createRole: false, + grantPrivileges: false, + inspectActivity: true, + }); + }); + it('does not leak passwords in connection errors', async () => { const result = await createService({ failConnect: true, @@ -335,20 +476,172 @@ describe('DataDbPreflightService', () => { it('returns default summary when a space has no explicit binding', async () => { const service = new DataDbPreflightService({ + ...defaultRelatedSpacesPrisma(), spaceDataDbBinding: { + ...defaultRelatedSpacesPrisma().spaceDataDbBinding, findUnique: vi.fn().mockResolvedValue(null), }, + spaceDataDbMigrationJob: { + findFirst: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([]), + }, } as never); - await expect(service.getSummary('spcxxx')).resolves.toEqual({ + await expect(service.getSummary('spcxxx')).resolves.toMatchObject({ mode: 'default', state: 'ready', + relatedSpaces: expect.objectContaining({ + primarySpaceId: 'spcxxx', + hasCrossSpaceLinks: false, + }), }); }); + it('returns default summary when the migration job table is not available yet', async () => { + const service = new DataDbPreflightService({ + ...defaultRelatedSpacesPrisma(), + spaceDataDbBinding: { + ...defaultRelatedSpacesPrisma().spaceDataDbBinding, + findUnique: vi.fn().mockResolvedValue(null), + }, + spaceDataDbMigrationJob: { + findFirst: vi + .fn() + .mockRejectedValue( + Object.assign( + new Error('The table `public.space_data_db_migration_job` does not exist'), + { code: 'P2021' } + ) + ), + findMany: vi.fn().mockResolvedValue([]), + }, + } as never); + + await expect(service.getSummary('spcxxx')).resolves.toMatchObject({ + mode: 'default', + state: 'ready', + relatedSpaces: expect.objectContaining({ + primarySpaceId: 'spcxxx', + hasCrossSpaceLinks: false, + }), + }); + }); + + it('returns migrating BYODB summary when a default space has an active switch migration job', async () => { + const service = new DataDbPreflightService({ + ...defaultRelatedSpacesPrisma(), + spaceDataDbBinding: { + ...defaultRelatedSpacesPrisma().spaceDataDbBinding, + findUnique: vi.fn().mockResolvedValue(null), + }, + spaceDataDbMigrationJob: { + findFirst: vi.fn().mockResolvedValue({ + id: 'sdmjxxx', + state: 'copying', + switchOnCompletion: true, + targetInternalSchema: internalSchema, + lastError: null, + targetConnection: { + provider: 'postgres', + displayHost: 'example.com:5432', + displayDatabase: 'teable_data', + internalSchema, + schemaVersion: '20260421000000_init_data_db_baseline', + lastValidatedAt: new Date('2026-05-06T00:00:00.000Z'), + lastError: null, + encryptedUrl: 'encrypted-secret', + capabilities: { + createSchema: true, + createTable: true, + createFunction: true, + createTrigger: true, + createRole: false, + grantPrivileges: true, + inspectActivity: true, + }, + }, + }), + findMany: vi.fn().mockResolvedValue([]), + }, + } as never); + + const summary = await service.getSummary('spcxxx'); + + expect(summary).toMatchObject({ + mode: 'byodb', + state: 'migrating', + provider: 'postgres', + displayHost: 'example.com:5432', + displayDatabase: 'teable_data', + internalSchema, + migration: { + jobId: 'sdmjxxx', + state: 'copying', + targetInternalSchema: internalSchema, + }, + }); + expect(JSON.stringify(summary)).not.toContain('encrypted-secret'); + }); + + it('keeps a default-space summary while a test-only migration job is active', async () => { + const service = new DataDbPreflightService({ + ...defaultRelatedSpacesPrisma(), + spaceDataDbBinding: { + ...defaultRelatedSpacesPrisma().spaceDataDbBinding, + findUnique: vi.fn().mockResolvedValue(null), + }, + spaceDataDbMigrationJob: { + findFirst: vi.fn().mockResolvedValue({ + id: 'sdmjxxx', + state: 'waiting_worker', + switchOnCompletion: false, + targetInternalSchema: internalSchema, + lastError: null, + targetConnection: { + provider: 'postgres', + displayHost: 'example.com:5432', + displayDatabase: 'teable_data', + internalSchema, + schemaVersion: '20260421000000_init_data_db_baseline', + lastValidatedAt: new Date('2026-05-06T00:00:00.000Z'), + lastError: null, + encryptedUrl: 'encrypted-secret', + capabilities: { + createSchema: true, + createTable: true, + createFunction: true, + createTrigger: true, + createRole: false, + grantPrivileges: true, + inspectActivity: true, + }, + }, + }), + findMany: vi.fn().mockResolvedValue([]), + }, + } as never); + + const summary = await service.getSummary('spcxxx'); + + expect(summary).toMatchObject({ + mode: 'default', + state: 'ready', + migration: { + jobId: 'sdmjxxx', + state: 'waiting_worker', + targetInternalSchema: internalSchema, + switchOnCompletion: false, + }, + }); + expect(summary.displayHost).toBeUndefined(); + expect(JSON.stringify(summary)).not.toContain('encrypted-secret'); + }); + it('returns a BYODB summary without encrypted URL material', async () => { const service = new DataDbPreflightService({ + ...defaultRelatedSpacesPrisma(), spaceDataDbBinding: { + ...defaultRelatedSpacesPrisma().spaceDataDbBinding, findUnique: vi.fn().mockResolvedValue({ mode: 'byodb', state: 'ready', @@ -373,6 +666,10 @@ describe('DataDbPreflightService', () => { }, }), }, + spaceDataDbMigrationJob: { + findFirst: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([]), + }, } as never); const summary = await service.getSummary('spcxxx'); @@ -389,4 +686,44 @@ describe('DataDbPreflightService', () => { }); expect(JSON.stringify(summary)).not.toContain('encrypted-secret'); }); + + it('includes physical database fingerprints for related BYODB spaces', async () => { + const service = new DataDbPreflightService({ + ...defaultRelatedSpacesPrisma(), + spaceDataDbBinding: { + findUnique: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([ + { + spaceId: 'spcxxx', + mode: 'byodb', + state: 'ready', + dataDbConnection: { + id: 'dcnxxx', + encryptedUrl: encryptDataDbUrl(DATA_URL), + urlFingerprint: fingerprintDataDbConnection(DATA_URL, internalSchema), + displayHost: 'example.com:5432', + displayDatabase: 'teable_data', + internalSchema, + }, + }, + ]), + }, + spaceDataDbMigrationJob: { + findFirst: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([]), + }, + } as never); + + await expect(service.getSummary('spcxxx')).resolves.toMatchObject({ + relatedSpaces: { + spaces: [ + expect.objectContaining({ + spaceId: 'spcxxx', + dataDbUrlFingerprint: fingerprintDataDbConnection(DATA_URL, internalSchema), + dataDbDatabaseFingerprint: fingerprintDatabaseUrl(DATA_URL), + }), + ], + }, + }); + }); }); 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 195f6dc361..31d9ad0fcf 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 @@ -14,6 +14,8 @@ import type { import type { Knex } from 'knex'; import createKnex from 'knex'; import { resolveDataDbInternalSchema } from './data-db-internal-schema'; +import { activeSpaceDataDbMigrationStates } from './space-data-db-migration.constants'; +import { resolveSpaceDataDbRelatedSpaces } from './space-data-db-related-spaces'; type IPreflightCapabilities = IDataDbPreflightVo['capabilities']; type IPreflightClassification = IDataDbPreflightVo['classification']; @@ -77,6 +79,32 @@ const normalizeRawRows = (result: { rows?: T[] } | T[]): T[] => { return result.rows ?? []; }; +type IActiveMigrationSummary = { + id: string; + state: string; + targetInternalSchema: string; + switchOnCompletion?: boolean | null; + lastError: string | null; + inventory: unknown; + targetConnection: { + provider: 'postgres'; + displayHost: string | null; + displayDatabase: string | null; + internalSchema: string; + schemaVersion: string | null; + lastValidatedAt: Date | null; + lastError: string | null; + capabilities: unknown; + } | null; +}; + +type IDataDbSummaryPrisma = PrismaService & { + spaceDataDbMigrationJob?: { + findFirst(args: unknown): Promise; + findMany(args: unknown): Promise; + }; +}; + export const maskDatabaseUrl = (url: string): string => { const parsed = new URL(url); if (parsed.password) { @@ -267,6 +295,12 @@ export class DataDbPreflightService { async getSummary(spaceId: string): Promise { if (this.prismaService) { + const relatedSpaces = await resolveSpaceDataDbRelatedSpaces(this.prismaService, spaceId); + const migrationSummary = await this.getActiveMigrationSummary(spaceId); + if (migrationSummary) { + return migrationSummary; + } + const binding = await this.prismaService.spaceDataDbBinding.findUnique({ where: { spaceId }, include: { dataDbConnection: true }, @@ -285,8 +319,14 @@ export class DataDbPreflightService { capabilities: binding.dataDbConnection.capabilities as | IDataDbConnectionSummaryVo['capabilities'] | undefined, + relatedSpaces, }; } + return { + mode: 'default', + state: 'ready', + relatedSpaces, + }; } return { mode: 'default', @@ -294,6 +334,126 @@ export class DataDbPreflightService { }; } + private async getActiveMigrationSummary( + spaceId: string + ): Promise { + const migrationClient = (this.prismaService as IDataDbSummaryPrisma | undefined) + ?.spaceDataDbMigrationJob; + if (!migrationClient) { + return null; + } + + const job = await migrationClient + .findFirst({ + where: { + spaceId, + state: { in: [...activeSpaceDataDbMigrationStates] }, + }, + include: { targetConnection: true }, + orderBy: { createdTime: 'desc' }, + }) + .catch((error) => { + if (this.isMissingMigrationJobTableError(error)) { + return null; + } + throw error; + }); + const resolvedJob = + job ?? + (await migrationClient + .findMany({ + where: { + state: { in: [...activeSpaceDataDbMigrationStates] }, + }, + include: { targetConnection: true }, + orderBy: { createdTime: 'desc' }, + }) + .then((jobs) => + jobs.find((candidate) => + this.migrationInventoryContainsSpace(candidate.inventory, spaceId) + ) + ) + .catch((error) => { + if (this.isMissingMigrationJobTableError(error)) { + return null; + } + throw error; + })); + if (!resolvedJob) return null; + + const connection = resolvedJob.targetConnection; + const inventory = + resolvedJob.inventory && typeof resolvedJob.inventory === 'object' + ? (resolvedJob.inventory as { relatedSpaces?: IDataDbConnectionSummaryVo['relatedSpaces'] }) + : {}; + const migration = { + jobId: resolvedJob.id, + state: resolvedJob.state as NonNullable['state'], + targetInternalSchema: resolvedJob.targetInternalSchema, + switchOnCompletion: resolvedJob.switchOnCompletion === true, + lastError: resolvedJob.lastError, + }; + + if (resolvedJob.switchOnCompletion !== true) { + return { + mode: 'default', + state: 'ready', + migration, + relatedSpaces: inventory.relatedSpaces, + }; + } + + return { + mode: 'byodb', + state: 'migrating', + provider: connection?.provider ?? 'postgres', + displayHost: connection?.displayHost ?? undefined, + displayDatabase: connection?.displayDatabase ?? undefined, + internalSchema: connection?.internalSchema ?? resolvedJob.targetInternalSchema, + schemaVersion: connection?.schemaVersion ?? null, + lastValidatedAt: connection?.lastValidatedAt?.toISOString(), + lastError: resolvedJob.lastError ?? connection?.lastError ?? undefined, + capabilities: connection?.capabilities as + | IDataDbConnectionSummaryVo['capabilities'] + | undefined, + migration, + relatedSpaces: inventory.relatedSpaces, + }; + } + + private migrationInventoryContainsSpace(inventory: unknown, spaceId: string) { + if (!inventory || typeof inventory !== 'object') { + return false; + } + const candidate = inventory as { spaceIds?: unknown; relatedSpaces?: { spaces?: unknown } }; + return ( + (Array.isArray(candidate.spaceIds) && candidate.spaceIds.includes(spaceId)) || + (Array.isArray(candidate.relatedSpaces?.spaces) && + candidate.relatedSpaces.spaces.some( + (space) => + space && + typeof space === 'object' && + (space as { spaceId?: unknown }).spaceId === spaceId + )) + ); + } + + private isMissingMigrationJobTableError(error: unknown) { + const code = + typeof error === 'object' && error !== null && 'code' in error + ? String((error as { code?: unknown }).code) + : ''; + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('space_data_db_migration_job') && + (code === 'P2021' || + code === 'P2022' || + code === '42P01' || + message.includes('does not exist') || + message.includes('relation')) + ); + } + private buildResult({ errors, maskedUrl, 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 new file mode 100644 index 0000000000..795092f093 --- /dev/null +++ b/apps/nestjs-backend/src/features/space/space-data-db-admin-gate.test.ts @@ -0,0 +1,124 @@ +import { HttpErrorCode } from '@teable/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SpaceController } from './space.controller'; + +vi.mock('@teable/db-main-prisma', () => ({ + MetaPrismaService: class MetaPrismaService {}, + Prisma: {}, + PrismaModule: class PrismaModule {}, + PrismaService: class PrismaService {}, + ProvisionState: {}, + getDatabaseUrl: vi.fn(), +})); +vi.mock('@teable/db-data-prisma', () => ({ + DataPrismaModule: class DataPrismaModule {}, + DataPrismaService: class DataPrismaService {}, + PrismaClient: class PrismaClient {}, + getMetaDatabaseUrl: vi.fn(), +})); +vi.mock('@prisma/client', () => ({ + Prisma: {}, + PrismaClient: class PrismaClient {}, +})); +vi.mock('../invitation/invitation.service', () => ({ + InvitationService: class InvitationService {}, +})); +vi.mock('../collaborator/collaborator.service', () => ({ + CollaboratorService: class CollaboratorService {}, +})); +vi.mock('./data-db-binding.service', () => ({ + DataDbBindingService: class DataDbBindingService {}, +})); +vi.mock('./data-db-preflight.service', () => ({ + DataDbPreflightService: class DataDbPreflightService {}, +})); +vi.mock('./space-data-db-migration.service', () => ({ + SpaceDataDbMigrationService: class SpaceDataDbMigrationService {}, +})); +vi.mock('./space.service', () => ({ + SpaceService: class SpaceService {}, +})); + +describe('SpaceController data DB admin gate', () => { + let controller: SpaceController; + const dataDbPreflightService = { + preflight: vi.fn(), + getSummary: vi.fn(), + }; + const dataDbBindingService = { + updateBindingForSpace: vi.fn(), + }; + const cls = { + get: vi.fn().mockReturnValue('usrxxx'), + }; + const spaceDataDbMigrationService = { + cancelMigrationForSpace: vi.fn(), + getMigrationJobStatus: vi.fn(), + rollbackMigrationForSpace: vi.fn(), + }; + + beforeEach(() => { + dataDbPreflightService.preflight.mockReset(); + dataDbPreflightService.getSummary.mockReset(); + dataDbBindingService.updateBindingForSpace.mockReset(); + cls.get.mockClear(); + spaceDataDbMigrationService.cancelMigrationForSpace.mockReset(); + spaceDataDbMigrationService.getMigrationJobStatus.mockReset(); + spaceDataDbMigrationService.rollbackMigrationForSpace.mockReset(); + controller = new SpaceController( + {} as never, + {} as never, + {} as never, + dataDbPreflightService as never, + dataDbBindingService as never, + cls as never, + spaceDataDbMigrationService as never + ); + }); + + it('rejects migrate-space preflight requests from the non-admin space API', async () => { + await expect( + controller.preflightDataDb({ + url: 'postgresql://teable:secret@example.com:5432/teable_data', + targetMode: 'migrate-space', + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.RESTRICTED_RESOURCE, + data: { errorCode: 'SPACE_DATA_DB_ADMIN_ONLY' }, + }); + expect(dataDbPreflightService.preflight).not.toHaveBeenCalled(); + }); + + it('rejects migrate-space updates from the non-admin space API', async () => { + await expect( + controller.updateSpaceDataDb('spcxxx', { + url: 'postgresql://teable:secret@example.com:5432/teable_data', + targetMode: 'migrate-space', + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.RESTRICTED_RESOURCE, + data: { errorCode: 'SPACE_DATA_DB_ADMIN_ONLY' }, + }); + expect(dataDbBindingService.updateBindingForSpace).not.toHaveBeenCalled(); + }); + + it('rejects migration job operations from the non-admin space API', async () => { + await expect(controller.getSpaceDataDbMigration('spcxxx', 'sdmjxxx')).rejects.toMatchObject({ + code: HttpErrorCode.RESTRICTED_RESOURCE, + data: { errorCode: 'SPACE_DATA_DB_ADMIN_ONLY' }, + }); + await expect(controller.cancelSpaceDataDbMigration('spcxxx', 'sdmjxxx')).rejects.toMatchObject({ + code: HttpErrorCode.RESTRICTED_RESOURCE, + data: { errorCode: 'SPACE_DATA_DB_ADMIN_ONLY' }, + }); + await expect( + controller.rollbackSpaceDataDbMigration('spcxxx', 'sdmjxxx') + ).rejects.toMatchObject({ + code: HttpErrorCode.RESTRICTED_RESOURCE, + data: { errorCode: 'SPACE_DATA_DB_ADMIN_ONLY' }, + }); + expect(spaceDataDbMigrationService.getMigrationJobStatus).not.toHaveBeenCalled(); + expect(spaceDataDbMigrationService.cancelMigrationForSpace).not.toHaveBeenCalled(); + expect(spaceDataDbMigrationService.rollbackMigrationForSpace).not.toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 0000000000..0e9be30f87 --- /dev/null +++ b/apps/nestjs-backend/src/features/space/space-data-db-copy-plan.spec.ts @@ -0,0 +1,459 @@ +import { describe, expect, it } from 'vitest'; +import { + buildBaseSchemaDumpRestorePlan, + buildBaseSchemaDumpStreamRestorePlan, + buildBaseSchemaPgcopydbPlan, + buildBaseSchemaRestorePlan, + buildMigrationSharedTablePostgresFdwCopyPlans, + buildMigrationSharedTablePsqlCopyPlans, + buildSharedTablePostgresFdwCopyPlan, + buildSharedTableCopyPlan, + buildSharedTablePsqlCopyPlan, + postgresToolUrl, +} from './space-data-db-copy-plan'; + +const sourceHost = 'source.example'; +const targetHost = 'target.example'; +const sourceUrl = `postgresql://${sourceHost}/teable`; +const targetUrl = `postgresql://${targetHost}/teable`; +const workDir = '/tmp/sdmjxxx'; +const dumpFile = '/tmp/sdmjxxx/base-schemas.dump'; +const noOwnerArg = '--no-owner'; +const noAclArg = '--no-acl'; +const jobsArg = '--jobs'; +const pgcopydbWorkDirectory = '/tmp/sdmjxxx/pgcopydb-base-schemas'; +const pgcopydbFilterFile = '/tmp/sdmjxxx/pgcopydb-base-schemas.filter.ini'; +const psqlCommandArgs = ['--no-psqlrc', '--set', 'ON_ERROR_STOP=1', '--command']; +const fdwSourceUrl = + 'postgresql://source_user:source_secret@source.example:15432/teable_source?sslmode=require'; + +describe('space data DB copy plan', () => { + it('strips Prisma-only query params from URLs passed to PostgreSQL client tools', () => { + expect( + postgresToolUrl( + 'postgresql://teable:secret@127.0.0.1:5432/teable?schema=public&statement_cache_size=1&sslmode=require' + ) + ).toBe('postgresql://teable:secret@127.0.0.1:5432/teable?sslmode=require'); + }); + + it('percent-encodes user info for PostgreSQL client tool URIs', () => { + expect( + postgresToolUrl( + 'postgresql://user.name:pa%ss$word@target.example:5432/teable?schema=public&sslmode=require' + ) + ).toBe('postgresql://user.name:pa%25ss%24word@target.example:5432/teable?sslmode=require'); + }); + + it('does not double-encode already encoded PostgreSQL client tool user info', () => { + expect(postgresToolUrl('postgresql://user%40name:pa%25ss%24word@target.example/teable')).toBe( + 'postgresql://user%40name:pa%25ss%24word@target.example/teable' + ); + }); + + it('builds pg_dump and pg_restore spawn-array plans for base schemas', () => { + const plan = buildBaseSchemaDumpRestorePlan({ + sourceUrl, + targetUrl, + schemaNames: ['bsebbb', 'bseaaa'], + workDir, + jobs: 4, + }); + + expect(plan.dumpFile).toBe(dumpFile); + expect(plan.dump).toEqual({ + command: 'pg_dump', + args: [ + '--format=custom', + noOwnerArg, + noAclArg, + '--file', + dumpFile, + '--schema', + '"bseaaa"', + '--schema', + '"bsebbb"', + sourceUrl, + ], + }); + expect(plan.restore).toEqual({ + command: 'pg_restore', + args: [noOwnerArg, noAclArg, jobsArg, '4', '--dbname', targetUrl, dumpFile], + }); + }); + + it('requires at least one schema for base schema copy planning', () => { + expect(() => + buildBaseSchemaDumpRestorePlan({ + sourceUrl, + targetUrl, + schemaNames: [], + workDir, + }) + ).toThrow('At least one base schema'); + }); + + it('builds streaming pg_dump to pg_restore plans for base schemas', () => { + const plan = buildBaseSchemaDumpStreamRestorePlan({ + sourceUrl, + targetUrl, + schemaNames: ['bsebbb', 'bseaaa'], + }); + + expect(plan).toEqual({ + label: 'base-schemas', + schemaNames: ['bseaaa', 'bsebbb'], + source: { + command: 'pg_dump', + args: [ + '--format=custom', + noOwnerArg, + noAclArg, + '--schema', + '"bseaaa"', + '--schema', + '"bsebbb"', + sourceUrl, + ], + }, + target: { + command: 'pg_restore', + args: [noOwnerArg, noAclArg, '--dbname', targetUrl], + }, + }); + }); + + it('adds a pg_restore use-list file when base schema restore needs filtered TOC entries', () => { + const plan = buildBaseSchemaRestorePlan({ + targetUrl, + dumpFile, + jobs: 2, + restoreListFile: '/tmp/sdmjxxx/base-schemas.restore.list', + }); + + expect(plan).toEqual({ + command: 'pg_restore', + args: [ + noOwnerArg, + noAclArg, + jobsArg, + '2', + '--use-list', + '/tmp/sdmjxxx/base-schemas.restore.list', + '--dbname', + targetUrl, + dumpFile, + ], + }); + }); + + it('uses PostgreSQL-tool-compatible URLs for physical copy commands', () => { + const plan = buildBaseSchemaDumpRestorePlan({ + sourceUrl: + 'postgresql://source.example/teable?schema=public&statement_cache_size=1&sslmode=require', + targetUrl: + 'postgresql://target.example/teable?schema=public&connection_limit=10&sslmode=require', + schemaNames: ['bsexxx'], + workDir, + }); + + expect(plan.dump.args.at(-1)).toBe('postgresql://source.example/teable?sslmode=require'); + expect(plan.dump.args).toContain('"bsexxx"'); + expect(plan.restore.args).toContain('postgresql://target.example/teable?sslmode=require'); + }); + + it('uses percent-encoded PostgreSQL-tool-compatible URLs in physical copy commands', () => { + const rawSourceUrl = 'postgresql://source_user:source%secret$@source.example/teable'; + const rawTargetUrl = 'postgresql://target_user:target%secret$@target.example/teable'; + const safeSourceUrl = 'postgresql://source_user:source%25secret%24@source.example/teable'; + const safeTargetUrl = 'postgresql://target_user:target%25secret%24@target.example/teable'; + + const dumpRestorePlan = buildBaseSchemaDumpRestorePlan({ + sourceUrl: rawSourceUrl, + targetUrl: rawTargetUrl, + schemaNames: ['bsexxx'], + workDir, + }); + const streamPlan = buildBaseSchemaDumpStreamRestorePlan({ + sourceUrl: rawSourceUrl, + targetUrl: rawTargetUrl, + schemaNames: ['bsexxx'], + }); + const psqlPlan = buildSharedTablePsqlCopyPlan({ + sourceUrl: rawSourceUrl, + targetUrl: rawTargetUrl, + sourceSchema: 'public', + targetSchema: 'teable_meta_target', + table: 'record_history', + columns: ['id'], + whereSql: `"table_id" = ANY(ARRAY['tblxxx']::text[])`, + }); + const pgcopydbPlan = buildBaseSchemaPgcopydbPlan({ + sourceUrl: rawSourceUrl, + targetUrl: rawTargetUrl, + schemaNames: ['bsexxx'], + workDir, + }); + + expect(dumpRestorePlan.dump.args.at(-1)).toBe(safeSourceUrl); + expect(dumpRestorePlan.restore.args).toContain(safeTargetUrl); + expect(streamPlan.source.args.at(-1)).toBe(safeSourceUrl); + expect(streamPlan.target.args).toContain(safeTargetUrl); + expect(psqlPlan.source.args.at(-1)).toBe(safeSourceUrl); + expect(psqlPlan.target.args.at(-1)).toBe(safeTargetUrl); + expect(pgcopydbPlan.copy.args).toContain(safeSourceUrl); + expect(pgcopydbPlan.copy.args).toContain(safeTargetUrl); + }); + + it('builds an explicitly selected pgcopydb plan with a schema include filter', () => { + const plan = buildBaseSchemaPgcopydbPlan({ + sourceUrl, + targetUrl, + schemaNames: ['bsebbb', 'bseaaa'], + workDir, + jobs: 3, + }); + + expect(plan.workDirectory).toBe(pgcopydbWorkDirectory); + expect(plan.filterFile).toBe(pgcopydbFilterFile); + expect(plan.filterFileContent).toBe('[include-only-schema]\n"bseaaa"\n"bsebbb"\n'); + expect(plan.copy).toEqual({ + command: 'pgcopydb', + args: [ + 'copy', + 'db', + '--source', + sourceUrl, + '--target', + targetUrl, + '--dir', + pgcopydbWorkDirectory, + '--table-jobs', + '3', + '--index-jobs', + '3', + '--restore-jobs', + '3', + noOwnerArg, + noAclArg, + '--skip-large-objects', + '--filters', + pgcopydbFilterFile, + '--fail-fast', + '--restart', + ], + }); + }); + + it('uses PostgreSQL-tool-compatible URLs for pgcopydb commands', () => { + const plan = buildBaseSchemaPgcopydbPlan({ + sourceUrl: 'postgresql://source.example/teable?schema=public', + targetUrl: 'postgresql://target.example/teable?statement_cache_size=1', + schemaNames: ['bsexxx'], + workDir, + }); + + expect(plan.copy.args).toContain('postgresql://source.example/teable'); + expect(plan.copy.args).toContain('postgresql://target.example/teable'); + }); + + it('builds streaming COPY plans with explicit columns and scoped filters', () => { + const plan = buildSharedTableCopyPlan({ + sourceSchema: 'public', + targetSchema: 'teable_meta_target', + table: 'record_history', + columns: ['id', 'table_id', 'record_id', 'snapshot'], + whereSql: '"table_id" = ANY($1::text[])', + sourceBindings: [['tblxxx', 'tblyyy']], + }); + + expect(plan).toEqual({ + sourceSql: + 'COPY (SELECT "id", "table_id", "record_id", "snapshot" FROM "public"."record_history" WHERE "table_id" = ANY($1::text[])) TO STDOUT', + sourceBindings: [['tblxxx', 'tblyyy']], + targetSql: + 'COPY "teable_meta_target"."record_history" ("id", "table_id", "record_id", "snapshot") FROM STDIN', + }); + }); + + it('builds psql process plans for streaming shared-table COPY', () => { + const plan = buildSharedTablePsqlCopyPlan({ + sourceUrl, + targetUrl, + sourceSchema: 'public', + targetSchema: 'teable_meta_target', + table: 'record_history', + columns: ['id', 'table_id', 'record_id'], + whereSql: `"table_id" = ANY(ARRAY['tblxxx']::text[])`, + }); + + expect(plan.table).toBe('record_history'); + expect(plan.sourceSql).toBe( + `COPY (SELECT "id", "table_id", "record_id" FROM "public"."record_history" WHERE "table_id" = ANY(ARRAY['tblxxx']::text[])) TO STDOUT` + ); + expect(plan.targetSql).toBe( + 'COPY "teable_meta_target"."record_history" ("id", "table_id", "record_id") FROM STDIN' + ); + expect(plan.source).toEqual({ + command: 'psql', + args: [...psqlCommandArgs, plan.sourceSql, sourceUrl], + }); + expect(plan.target).toEqual({ + command: 'psql', + args: [...psqlCommandArgs, plan.targetSql, targetUrl], + }); + }); + + it('uses PostgreSQL-tool-compatible URLs for psql shared-table copy commands', () => { + const plan = buildSharedTablePsqlCopyPlan({ + sourceUrl: 'postgresql://source.example/teable?schema=public', + targetUrl: 'postgresql://target.example/teable?statement_cache_size=1', + sourceSchema: 'public', + targetSchema: 'teable_meta_target', + table: 'record_history', + columns: ['id'], + whereSql: `"table_id" = ANY(ARRAY['tblxxx']::text[])`, + }); + + expect(plan.source.args.at(-1)).toBe('postgresql://source.example/teable'); + expect(plan.target.args.at(-1)).toBe('postgresql://target.example/teable'); + }); + + it('builds postgres_fdw plans for explicitly selected shared-table DB-to-DB inserts', () => { + const plan = buildSharedTablePostgresFdwCopyPlan({ + sourceUrl: fdwSourceUrl, + targetUrl, + sourceSchema: 'public', + targetSchema: 'teable_meta_target', + table: 'record_history', + columns: ['id', 'table_id', 'record_id'], + whereSql: `"table_id" = ANY(ARRAY['tblxxx']::text[])`, + fdwSchema: 'sdmjxxx_fdw_0', + serverName: 'sdmjxxx_srv_0', + }); + + expect(plan.table).toBe('record_history'); + expect(plan.sql).toContain('CREATE EXTENSION IF NOT EXISTS postgres_fdw;'); + expect(plan.sql).toContain('CREATE SCHEMA "sdmjxxx_fdw_0";'); + expect(plan.sql).toContain( + `CREATE SERVER "sdmjxxx_srv_0" FOREIGN DATA WRAPPER postgres_fdw OPTIONS (host 'source.example', port '15432', dbname 'teable_source', sslmode 'require');` + ); + expect(plan.sql).toContain( + `CREATE USER MAPPING FOR CURRENT_USER SERVER "sdmjxxx_srv_0" OPTIONS (user 'source_user', password 'source_secret');` + ); + expect(plan.sql).toContain( + 'IMPORT FOREIGN SCHEMA "public" LIMIT TO ("record_history") FROM SERVER "sdmjxxx_srv_0" INTO "sdmjxxx_fdw_0";' + ); + expect(plan.sql).toContain( + `INSERT INTO "teable_meta_target"."record_history" ("id", "table_id", "record_id") SELECT "id", "table_id", "record_id" FROM "sdmjxxx_fdw_0"."record_history" WHERE "table_id" = ANY(ARRAY['tblxxx']::text[]);` + ); + expect(plan.sql).toContain('DROP SERVER "sdmjxxx_srv_0" CASCADE;'); + expect(plan.target).toEqual({ + command: 'psql', + args: [...psqlCommandArgs, plan.sql, targetUrl], + }); + }); + + it('builds scoped psql COPY plans for all migration shared tables', () => { + const plans = buildMigrationSharedTablePsqlCopyPlans({ + sourceUrl, + targetUrl, + sourceSchema: 'public', + targetSchema: 'teable_meta_target', + spaceId: "spc'x", + baseIds: ['bsexxx', 'bseyyy'], + tableIds: ['tblxxx', 'tblyyy'], + }); + + expect(plans.map((plan) => plan.table)).toEqual([ + 'record_history', + 'table_trash', + 'record_trash', + 'computed_update_outbox', + 'computed_update_dead_letter', + 'computed_update_outbox_seed', + 'computed_update_pause_scope', + '__undo_log', + ]); + expect(plans[0].sourceSql).toContain(`"table_id" = ANY(ARRAY['tblxxx', 'tblyyy']::text[])`); + 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[6].sourceSql).toContain(`"scope_id" = ANY(ARRAY['spc''x']::text[])`); + expect(plans[7].sourceSql).toContain( + `split_part("table_name", '.', 1) = ANY(ARRAY['bsexxx', 'bseyyy']::text[])` + ); + expect(plans.every((plan) => plan.source.args.includes(sourceUrl))).toBe(true); + expect(plans.every((plan) => plan.target.args.includes(targetUrl))).toBe(true); + }); + + it('uses deleted-table shared scope only for shared rows that reference table_id directly', () => { + const plans = buildMigrationSharedTablePsqlCopyPlans({ + sourceUrl, + targetUrl, + sourceSchema: 'public', + targetSchema: 'teable_meta_target', + spaceId: 'spcxxx', + baseIds: ['bsexxx'], + tableIds: ['tblactive'], + sharedTableIds: ['tblactive', 'tbldeleted'], + }); + + expect(plans.find((plan) => plan.table === 'record_history')?.sourceSql).toContain( + `"table_id" = ANY(ARRAY['tblactive', 'tbldeleted']::text[])` + ); + expect(plans.find((plan) => plan.table === 'table_trash')?.sourceSql).toContain( + `"table_id" = ANY(ARRAY['tblactive', 'tbldeleted']::text[])` + ); + expect(plans.find((plan) => plan.table === 'record_trash')?.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')?.sourceSql).toContain( + `"scope_id" = ANY(ARRAY['tblactive', 'tbldeleted']::text[])` + ); + }); + + it('builds scoped postgres_fdw plans for all migration shared tables', () => { + const plans = buildMigrationSharedTablePostgresFdwCopyPlans({ + sourceUrl, + targetUrl, + sourceSchema: 'public', + targetSchema: 'teable_meta_target', + spaceId: "spc'x", + baseIds: ['bsexxx', 'bseyyy'], + tableIds: ['tblxxx', 'tblyyy'], + fdwSchemaPrefix: 'sdmjxxx_fdw', + serverNamePrefix: 'sdmjxxx_srv', + }); + + expect(plans.map((plan) => plan.table)).toEqual([ + 'record_history', + 'table_trash', + 'record_trash', + 'computed_update_outbox', + 'computed_update_dead_letter', + 'computed_update_outbox_seed', + 'computed_update_pause_scope', + '__undo_log', + ]); + expect(plans[0].sql).toContain('FROM "sdmjxxx_fdw_0"."record_history"'); + expect(plans[5].sql).toContain('FROM "sdmjxxx_fdw_5"."computed_update_outbox"'); + expect(plans[6].sql).toContain(`"scope_id" = ANY(ARRAY['spc''x']::text[])`); + expect(plans.every((plan) => plan.target.args.includes(targetUrl))).toBe(true); + }); + + it('rejects unscoped shared table copy plans', () => { + expect(() => + buildSharedTableCopyPlan({ + sourceSchema: 'public', + targetSchema: 'teable_meta_target', + table: 'record_history', + columns: ['id'], + whereSql: '', + }) + ).toThrow('requires a scoped WHERE clause'); + }); +}); 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 new file mode 100644 index 0000000000..6a6ee210ea --- /dev/null +++ b/apps/nestjs-backend/src/features/space/space-data-db-copy-plan.ts @@ -0,0 +1,590 @@ +import path from 'path'; + +export type ISpaceDataDbProcessPlan = { + command: string; + args: string[]; +}; + +export type ISpaceDataDbProcessPipelinePlan = { + source: ISpaceDataDbProcessPlan; + target: ISpaceDataDbProcessPlan; + label?: string; +}; + +export type ISpaceDataDbDumpRestorePlan = { + dumpFile: string; + dump: ISpaceDataDbProcessPlan; + restoreList?: ISpaceDataDbProcessPlan; + restoreListFile?: string; + restore: ISpaceDataDbProcessPlan; +}; + +export type ISpaceDataDbDumpStreamRestorePlan = ISpaceDataDbProcessPipelinePlan & { + schemaNames: string[]; +}; + +export type ISpaceDataDbPgcopydbPlan = { + workDirectory: string; + filterFile: string; + filterFileContent: string; + copy: ISpaceDataDbProcessPlan; +}; + +export type ISharedTableCopyPlan = { + sourceSql: string; + sourceBindings: unknown[]; + targetSql: string; +}; + +export type ISharedTablePsqlCopyPlan = ISpaceDataDbProcessPipelinePlan & { + table: string; + sourceSql: string; + targetSql: string; +}; + +export type ISharedTablePostgresFdwCopyPlan = { + table: string; + sql: string; + target: ISpaceDataDbProcessPlan; +}; + +const quoteIdent = (identifier: string) => `"${identifier.replace(/"/g, '""')}"`; + +const qualify = (schema: string, table: string) => `${quoteIdent(schema)}.${quoteIdent(table)}`; + +const normalizeJobs = (jobs?: number) => Math.max(1, Math.floor(jobs ?? 1)); + +const literal = (value: string) => `'${value.replace(/'/g, "''")}'`; +const noOwnerArg = '--no-owner'; +const noAclArg = '--no-acl'; +const jobsArg = '--jobs'; +const pgcopydbBaseSchemasDir = 'pgcopydb-base-schemas'; +const pgcopydbFilterFileName = 'pgcopydb-base-schemas.filter.ini'; +const includeOnlySchemaSection = '[include-only-schema]'; +const prismaOnlyPostgresUrlParams = [ + 'schema', + 'statement_cache_size', + 'connection_limit', + 'pool_timeout', + 'pgbouncer', +] as const; + +const textArray = (values: string[]) => + values.length ? `ARRAY[${values.map(literal).join(', ')}]::text[]` : 'ARRAY[]::text[]'; + +const textArrayPredicate = (column: string, values: string[]) => + `${quoteIdent(column)} = ANY(${textArray(values)})`; + +const decodePostgresToolUserInfoComponent = (value: string) => { + try { + return decodeURIComponent(value); + } catch { + const escapedInvalidPercent = value.replace(/%(?![0-9a-f]{2})/gi, '%25'); + try { + return decodeURIComponent(escapedInvalidPercent); + } catch { + return value; + } + } +}; + +const encodePostgresToolUserInfoComponent = (value: string) => + encodeURIComponent(decodePostgresToolUserInfoComponent(value)); + +const postgresToolUserInfo = (parsed: URL) => { + if (!parsed.username) { + return ''; + } + const username = encodePostgresToolUserInfoComponent(parsed.username); + const password = parsed.password + ? `:${encodePostgresToolUserInfoComponent(parsed.password)}` + : ''; + return `${username}${password}@`; +}; + +export const postgresToolUrl = (url: string) => { + const parsed = new URL(url); + for (const param of prismaOnlyPostgresUrlParams) { + parsed.searchParams.delete(param); + } + return `${parsed.protocol}//${postgresToolUserInfo(parsed)}${parsed.host}${parsed.pathname}${ + parsed.search + }${parsed.hash}`; +}; + +export const buildBaseSchemaRestoreListPlan = (dumpFile: string): ISpaceDataDbProcessPlan => ({ + command: 'pg_restore', + args: ['--list', dumpFile], +}); + +export const buildBaseSchemaRestorePlan = (input: { + targetUrl: string; + dumpFile: string; + jobs?: number; + restoreListFile?: string; +}): ISpaceDataDbProcessPlan => { + const jobs = String(normalizeJobs(input.jobs)); + const targetUrl = postgresToolUrl(input.targetUrl); + return { + command: 'pg_restore', + args: [ + noOwnerArg, + noAclArg, + jobsArg, + jobs, + ...(input.restoreListFile ? ['--use-list', input.restoreListFile] : []), + '--dbname', + targetUrl, + input.dumpFile, + ], + }; +}; + +const psqlArgs = (url: string, command: string) => [ + '--no-psqlrc', + '--set', + 'ON_ERROR_STOP=1', + '--command', + command, + postgresToolUrl(url), +]; + +const parsePostgresUrl = (url: string) => { + const parsed = new URL(url); + const dbname = decodeURIComponent(parsed.pathname.replace(/^\//, '')); + if (!dbname) { + throw new Error('Source PostgreSQL URL must include a database name for postgres_fdw copy'); + } + + return { + host: parsed.hostname, + port: parsed.port || '5432', + dbname, + user: decodeURIComponent(parsed.username), + password: decodeURIComponent(parsed.password), + sslmode: parsed.searchParams.get('sslmode') ?? undefined, + }; +}; + +const fdwOptions = (options: Record) => + Object.entries(options) + .filter((entry): entry is [string, string] => Boolean(entry[1])) + .map(([key, value]) => `${key} ${literal(value)}`) + .join(', '); + +export const buildBaseSchemaDumpRestorePlan = (input: { + sourceUrl: string; + targetUrl: string; + schemaNames: string[]; + workDir: string; + jobs?: number; +}): ISpaceDataDbDumpRestorePlan => { + const schemaNames = [...input.schemaNames].sort(); + if (!schemaNames.length) { + throw new Error('At least one base schema is required for pg_dump planning'); + } + + const dumpFile = path.join(input.workDir, 'base-schemas.dump'); + const schemaArgs = schemaNames.flatMap((schemaName) => ['--schema', quoteIdent(schemaName)]); + const sourceUrl = postgresToolUrl(input.sourceUrl); + const targetUrl = postgresToolUrl(input.targetUrl); + + return { + dumpFile, + dump: { + command: 'pg_dump', + args: ['--format=custom', noOwnerArg, noAclArg, '--file', dumpFile, ...schemaArgs, sourceUrl], + }, + restore: buildBaseSchemaRestorePlan({ + targetUrl, + dumpFile, + jobs: input.jobs, + }), + }; +}; + +export const buildBaseSchemaDumpStreamRestorePlan = (input: { + sourceUrl: string; + targetUrl: string; + schemaNames: string[]; +}): ISpaceDataDbDumpStreamRestorePlan => { + const schemaNames = [...input.schemaNames].sort(); + if (!schemaNames.length) { + throw new Error('At least one base schema is required for pg_dump planning'); + } + + const schemaArgs = schemaNames.flatMap((schemaName) => ['--schema', quoteIdent(schemaName)]); + const sourceUrl = postgresToolUrl(input.sourceUrl); + const targetUrl = postgresToolUrl(input.targetUrl); + + return { + label: 'base-schemas', + schemaNames, + source: { + command: 'pg_dump', + args: ['--format=custom', noOwnerArg, noAclArg, ...schemaArgs, sourceUrl], + }, + target: { + command: 'pg_restore', + args: [noOwnerArg, noAclArg, '--dbname', targetUrl], + }, + }; +}; + +export const buildBaseSchemaPgcopydbPlan = (input: { + sourceUrl: string; + targetUrl: string; + schemaNames: string[]; + workDir: string; + jobs?: number; +}): ISpaceDataDbPgcopydbPlan => { + const schemaNames = [...input.schemaNames].sort(); + if (!schemaNames.length) { + throw new Error('At least one base schema is required for pgcopydb planning'); + } + + const jobs = String(normalizeJobs(input.jobs)); + const workDirectory = path.join(input.workDir, pgcopydbBaseSchemasDir); + const filterFile = path.join(input.workDir, pgcopydbFilterFileName); + const filterFileContent = [includeOnlySchemaSection, ...schemaNames.map(quoteIdent), ''].join( + '\n' + ); + + return { + workDirectory, + filterFile, + filterFileContent, + copy: { + command: 'pgcopydb', + args: [ + 'copy', + 'db', + '--source', + postgresToolUrl(input.sourceUrl), + '--target', + postgresToolUrl(input.targetUrl), + '--dir', + workDirectory, + '--table-jobs', + jobs, + '--index-jobs', + jobs, + '--restore-jobs', + jobs, + noOwnerArg, + noAclArg, + '--skip-large-objects', + '--filters', + filterFile, + '--fail-fast', + '--restart', + ], + }, + }; +}; + +export const buildSharedTableCopyPlan = (input: { + sourceSchema: string; + targetSchema: string; + table: string; + columns: string[]; + whereSql: string; + sourceBindings?: unknown[]; +}): ISharedTableCopyPlan => { + if (!input.columns.length) { + throw new Error(`Shared table ${input.table} requires an explicit column list`); + } + if (!input.whereSql.trim()) { + throw new Error(`Shared table ${input.table} requires a scoped WHERE clause`); + } + + const columns = input.columns.map(quoteIdent).join(', '); + return { + sourceSql: `COPY (SELECT ${columns} FROM ${qualify(input.sourceSchema, input.table)} WHERE ${ + input.whereSql + }) TO STDOUT`, + sourceBindings: input.sourceBindings ?? [], + targetSql: `COPY ${qualify(input.targetSchema, input.table)} (${columns}) FROM STDIN`, + }; +}; + +export const buildSharedTablePsqlCopyPlan = (input: { + sourceUrl: string; + targetUrl: string; + sourceSchema: string; + targetSchema: string; + table: string; + columns: string[]; + whereSql: string; +}): ISharedTablePsqlCopyPlan => { + const plan = buildSharedTableCopyPlan(input); + return { + table: input.table, + label: `shared-table:${input.table}`, + sourceSql: plan.sourceSql, + targetSql: plan.targetSql, + source: { + command: 'psql', + args: psqlArgs(input.sourceUrl, plan.sourceSql), + }, + target: { + command: 'psql', + args: psqlArgs(input.targetUrl, plan.targetSql), + }, + }; +}; + +export const buildSharedTablePostgresFdwCopyPlan = (input: { + sourceUrl: string; + targetUrl: string; + sourceSchema: string; + targetSchema: string; + table: string; + columns: string[]; + whereSql: string; + fdwSchema: string; + serverName: string; +}): ISharedTablePostgresFdwCopyPlan => { + if (!input.columns.length) { + throw new Error(`Shared table ${input.table} requires an explicit column list`); + } + if (!input.whereSql.trim()) { + throw new Error(`Shared table ${input.table} requires a scoped WHERE clause`); + } + + const source = parsePostgresUrl(input.sourceUrl); + const columns = input.columns.map(quoteIdent).join(', '); + const serverOptions = fdwOptions({ + host: source.host, + port: source.port, + dbname: source.dbname, + sslmode: source.sslmode, + }); + const userMappingOptions = fdwOptions({ + user: source.user, + password: source.password, + }); + const importLimit = quoteIdent(input.table); + const targetTable = qualify(input.targetSchema, input.table); + const foreignTable = qualify(input.fdwSchema, input.table); + + const sql = [ + 'BEGIN', + 'CREATE EXTENSION IF NOT EXISTS postgres_fdw', + `CREATE SCHEMA ${quoteIdent(input.fdwSchema)}`, + `CREATE SERVER ${quoteIdent( + input.serverName + )} FOREIGN DATA WRAPPER postgres_fdw OPTIONS (${serverOptions})`, + userMappingOptions + ? `CREATE USER MAPPING FOR CURRENT_USER SERVER ${quoteIdent( + input.serverName + )} OPTIONS (${userMappingOptions})` + : '', + `IMPORT FOREIGN SCHEMA ${quoteIdent(input.sourceSchema)} LIMIT TO (${importLimit}) FROM SERVER ${quoteIdent( + input.serverName + )} INTO ${quoteIdent(input.fdwSchema)}`, + `INSERT INTO ${targetTable} (${columns}) SELECT ${columns} FROM ${foreignTable} WHERE ${input.whereSql}`, + `DROP SERVER ${quoteIdent(input.serverName)} CASCADE`, + `DROP SCHEMA ${quoteIdent(input.fdwSchema)} CASCADE`, + 'COMMIT', + ] + .filter(Boolean) + .map((statement) => `${statement};`) + .join('\n'); + + return { + table: input.table, + sql, + target: { + command: 'psql', + args: psqlArgs(input.targetUrl, sql), + }, + }; +}; + +const computedOutboxColumns = [ + 'id', + 'base_id', + 'seed_table_id', + 'seed_record_ids', + 'change_type', + 'steps', + 'edges', + 'status', + 'attempts', + 'max_attempts', + 'next_run_at', + 'locked_at', + 'locked_by', + 'last_error', + 'estimated_complexity', + 'plan_hash', + 'dirty_stats', + 'run_id', + 'origin_run_ids', + 'run_total_steps', + 'run_completed_steps_before', + 'affected_table_ids', + 'affected_field_ids', + 'sync_max_level', + 'created_at', + 'updated_at', +]; + +const recordHistoryColumns = [ + 'id', + 'table_id', + 'record_id', + 'field_id', + 'before', + 'after', + 'created_time', + 'created_by', +]; + +const buildMigrationSharedTableDefinitions = (input: { + sourceSchema: string; + spaceId: string; + spaceIds?: string[]; + baseIds: string[]; + tableIds: string[]; + sharedTableIds?: string[]; +}) => { + const sharedTableIds = input.sharedTableIds?.length ? input.sharedTableIds : input.tableIds; + const tablePredicate = textArrayPredicate('table_id', sharedTableIds); + const basePredicate = textArrayPredicate('base_id', input.baseIds); + const spaceIds = input.spaceIds?.length ? input.spaceIds : [input.spaceId]; + const outboxSeedPredicate = [ + textArrayPredicate('table_id', input.tableIds), + `"task_id" IN (SELECT "id" FROM ${qualify( + input.sourceSchema, + 'computed_update_outbox' + )} WHERE ${basePredicate})`, + ].join(' AND '); + const pauseScopePredicate = [ + `("scope_type" = 'space' AND "scope_id" = ANY(${textArray(spaceIds)}))`, + `("scope_type" = 'base' AND "scope_id" = ANY(${textArray(input.baseIds)}))`, + `("scope_type" = 'table' AND "scope_id" = ANY(${textArray(sharedTableIds)}))`, + ].join(' OR '); + const undoPredicate = `split_part("table_name", '.', 1) = ANY(${textArray(input.baseIds)})`; + + return [ + { + table: 'record_history', + columns: recordHistoryColumns, + whereSql: tablePredicate, + }, + { + table: 'table_trash', + columns: ['id', 'table_id', 'resource_type', 'snapshot', 'created_time', 'created_by'], + whereSql: tablePredicate, + }, + { + table: 'record_trash', + columns: ['id', 'table_id', 'record_id', 'snapshot', 'created_time', 'created_by'], + whereSql: tablePredicate, + }, + { + table: 'computed_update_outbox', + columns: computedOutboxColumns, + whereSql: basePredicate, + }, + { + table: 'computed_update_dead_letter', + columns: [...computedOutboxColumns, 'trace_data', 'failed_at'], + whereSql: basePredicate, + }, + { + table: 'computed_update_outbox_seed', + columns: ['id', 'task_id', 'table_id', 'record_id'], + whereSql: outboxSeedPredicate, + }, + { + table: 'computed_update_pause_scope', + columns: [ + 'id', + 'scope_type', + 'scope_id', + 'paused_at', + 'paused_by', + 'resume_at', + 'reason', + 'updated_at', + 'updated_by', + ], + whereSql: pauseScopePredicate, + }, + { + table: '__undo_log', + columns: [ + 'id', + 'batch_id', + 'operation', + 'table_name', + 'record_id', + 'old_row', + 'new_row', + 'created_at', + ], + whereSql: undoPredicate, + }, + ]; +}; + +export const buildMigrationSharedTablePsqlCopyPlans = (input: { + sourceUrl: string; + targetUrl: string; + sourceSchema: string; + targetSchema: string; + spaceId: string; + spaceIds?: string[]; + baseIds: string[]; + tableIds: string[]; + sharedTableIds?: string[]; +}): ISharedTablePsqlCopyPlan[] => { + const shared = buildMigrationSharedTableDefinitions(input); + + return shared.map((item) => + buildSharedTablePsqlCopyPlan({ + sourceUrl: input.sourceUrl, + targetUrl: input.targetUrl, + sourceSchema: input.sourceSchema, + targetSchema: input.targetSchema, + table: item.table, + columns: item.columns, + whereSql: item.whereSql, + }) + ); +}; + +export const buildMigrationSharedTablePostgresFdwCopyPlans = (input: { + sourceUrl: string; + targetUrl: string; + sourceSchema: string; + targetSchema: string; + spaceId: string; + spaceIds?: string[]; + baseIds: string[]; + tableIds: string[]; + sharedTableIds?: string[]; + fdwSchemaPrefix: string; + serverNamePrefix: string; +}): ISharedTablePostgresFdwCopyPlan[] => { + const shared = buildMigrationSharedTableDefinitions(input); + + return shared.map((_item, index) => { + const fdwSchema = `${input.fdwSchemaPrefix}_${index}`; + const item = buildMigrationSharedTableDefinitions({ ...input, sourceSchema: fdwSchema })[index]; + return buildSharedTablePostgresFdwCopyPlan({ + sourceUrl: input.sourceUrl, + targetUrl: input.targetUrl, + sourceSchema: input.sourceSchema, + targetSchema: input.targetSchema, + table: item.table, + columns: item.columns, + whereSql: item.whereSql, + 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 new file mode 100644 index 0000000000..3921045efd --- /dev/null +++ b/apps/nestjs-backend/src/features/space/space-data-db-copy.integration.spec.ts @@ -0,0 +1,1250 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { spawnSync } from 'child_process'; +import { mkdtemp, mkdir, rm } from 'fs/promises'; +import { createServer } from 'net'; +import { tmpdir } from 'os'; +import path from 'path'; +import createKnex from 'knex'; +import { Client } from 'pg'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { DataDbClientManager } from '../../global/data-db-client-manager.service'; +import { DataDbRuntimeCacheService } from '../../global/data-db-runtime-cache.service'; +import { dataDbKnexClientFactory } from './data-db-preflight.service'; +import { encryptDataDbUrl } from './data-db-url-secret'; +import { buildMigrationSharedTablePsqlCopyPlans } from './space-data-db-copy-plan'; +import { SpaceDataDbCopyService } from './space-data-db-copy.service'; +import { SpaceDataDbMigrationService } from './space-data-db-migration.service'; +import { SpaceDataDbProcessRunnerService } from './space-data-db-process-runner.service'; + +const requiredPostgresBins = ['initdb', 'pg_ctl', 'pg_dump', 'pg_restore', 'psql']; +const hasPostgresBinaries = requiredPostgresBins.every( + (command) => spawnSync('which', [command], { stdio: 'ignore' }).status === 0 +); +const describeWithPostgres = hasPostgresBinaries ? describe : describe.skip; +const sourceDatabase = 'source_data_db'; +const targetDatabase = 'target_data_db'; +const targetMismatchDatabase = 'target_mismatch_data_db'; +const targetSchema = 'teable_meta_test'; +const targetMismatchSchema = 'teable_meta_mismatch'; +const baseId = 'bsecopy'; +const otherBaseId = 'bseother'; +const tableId = 'tblcopy'; +const linkedTableId = 'tbllinkedcopy'; +const spaceId = 'spccopy'; +const otherSpaceId = 'spcother'; +const jobId = 'sdmjcopy'; +const targetConnectionId = 'dcncopy'; +const mismatchJobId = 'sdmjmismatch'; +const mismatchTargetConnectionId = 'dcnmismatch'; +const mainRelationName = 'sheet1'; +const linkedRelationName = 'sheet2'; +const junctionRelationName = 'sheet1_sheet2_junction'; + +const buildCopyInventory = () => ({ + baseIds: [baseId], + tableIds: [tableId, linkedTableId], + dbTableNames: [ + `${baseId}.${mainRelationName}`, + `${baseId}.${linkedRelationName}`, + `${baseId}.${junctionRelationName}`, + ], + physicalSchemas: [ + { + schemaName: baseId, + totalBytes: 3072, + estimatedRows: 6, + relations: [ + { + schemaName: baseId, + relationName: mainRelationName, + relationKind: 'table', + totalBytes: 1024, + estimatedRows: 2, + }, + { + schemaName: baseId, + relationName: linkedRelationName, + relationKind: 'table', + totalBytes: 1024, + estimatedRows: 2, + }, + { + schemaName: baseId, + relationName: junctionRelationName, + relationKind: 'table', + totalBytes: 1024, + estimatedRows: 2, + }, + ], + }, + ], +}); + +const execFile = async (command: string, args: string[], options: { timeout?: number } = {}) => { + const { execFile: nodeExecFile } = await import('child_process'); + return await new Promise((resolve, reject) => { + nodeExecFile(command, args, { timeout: options.timeout ?? 30_000 }, (error, stdout, stderr) => { + if (!error) { + resolve(); + return; + } + reject( + new Error(`${command} ${args.join(' ')} failed: ${stderr || stdout || error.message}`) + ); + }); + }); +}; + +const getFreePort = async () => + await new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Could not allocate a local PostgreSQL test port')); + return; + } + server.close(() => resolve(address.port)); + }); + }); + +const currentUser = () => encodeURIComponent(process.env.USER || 'postgres'); + +const pgUrl = (port: number, database: string) => + `postgresql://${currentUser()}@127.0.0.1:${port}/${database}`; + +const queryCount = async (client: Client, sql: string, values: unknown[] = []) => { + const result = await client.query<{ count: string }>(sql, values); + return Number(result.rows[0]?.count ?? 0); +}; + +const expectProcessTiming = (result: { + startedAt: string; + completedAt: string; + durationMs: number; +}) => { + expect(Date.parse(result.startedAt)).not.toBeNaN(); + expect(Date.parse(result.completedAt)).not.toBeNaN(); + expect(result.durationMs).toBeGreaterThanOrEqual(0); +}; + +const createSharedTables = async (client: Client, schema: string) => { + await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`); + await client.query(` + CREATE OR REPLACE FUNCTION "${schema}"."__teable_capture_undo_row"() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + RETURN NEW; + END; + $$ + `); + await client.query(` + CREATE TABLE "${schema}"."record_history" ( + "id" text PRIMARY KEY, + "table_id" text, + "record_id" text, + "field_id" text, + "before" jsonb, + "after" jsonb, + "created_time" timestamp, + "created_by" text + ) + `); + await client.query(` + CREATE TABLE "${schema}"."table_trash" ( + "id" text PRIMARY KEY, + "table_id" text, + "resource_type" text, + "snapshot" jsonb, + "created_time" timestamp, + "created_by" text + ) + `); + await client.query(` + CREATE TABLE "${schema}"."record_trash" ( + "id" text PRIMARY KEY, + "table_id" text, + "record_id" text, + "snapshot" jsonb, + "created_time" timestamp, + "created_by" text + ) + `); + await client.query(` + CREATE TABLE "${schema}"."computed_update_outbox" ( + "id" text PRIMARY KEY, + "base_id" text, + "seed_table_id" text, + "seed_record_ids" text, + "change_type" text, + "steps" text, + "edges" text, + "status" text, + "attempts" text, + "max_attempts" text, + "next_run_at" text, + "locked_at" timestamp, + "locked_by" text, + "last_error" text, + "estimated_complexity" text, + "plan_hash" text, + "dirty_stats" text, + "run_id" text, + "origin_run_ids" text, + "run_total_steps" text, + "run_completed_steps_before" text, + "affected_table_ids" text, + "affected_field_ids" text, + "sync_max_level" text, + "created_at" timestamp, + "updated_at" timestamp + ) + `); + await client.query(` + CREATE TABLE "${schema}"."computed_update_dead_letter" ( + "id" text PRIMARY KEY, + "base_id" text, + "seed_table_id" text, + "seed_record_ids" text, + "change_type" text, + "steps" text, + "edges" text, + "status" text, + "attempts" text, + "max_attempts" text, + "next_run_at" text, + "locked_at" timestamp, + "locked_by" text, + "last_error" text, + "estimated_complexity" text, + "plan_hash" text, + "dirty_stats" text, + "run_id" text, + "origin_run_ids" text, + "run_total_steps" text, + "run_completed_steps_before" text, + "affected_table_ids" text, + "affected_field_ids" text, + "sync_max_level" text, + "created_at" timestamp, + "updated_at" timestamp, + "trace_data" jsonb, + "failed_at" timestamp + ) + `); + await client.query(` + CREATE TABLE "${schema}"."computed_update_outbox_seed" ( + "id" text PRIMARY KEY, + "task_id" text, + "table_id" text, + "record_id" text + ) + `); + await client.query(` + CREATE TABLE "${schema}"."computed_update_pause_scope" ( + "id" text PRIMARY KEY, + "scope_type" text, + "scope_id" text, + "paused_at" timestamp, + "paused_by" text, + "resume_at" timestamp, + "reason" text, + "updated_at" timestamp, + "updated_by" text + ) + `); + await client.query(` + CREATE TABLE "${schema}"."__undo_log" ( + "id" text PRIMARY KEY, + "batch_id" text, + "operation" text, + "table_name" text, + "record_id" text, + "old_row" jsonb, + "new_row" jsonb, + "created_at" timestamp + ) + `); +}; + +const seedSourceData = async (client: Client) => { + await client.query(`CREATE SCHEMA "${baseId}"`); + await client.query(` + CREATE TABLE "${baseId}"."${mainRelationName}" ( + "id" text PRIMARY KEY, + "__created_time" timestamp, + "__last_modified_time" timestamp, + "name" text, + CONSTRAINT "sheet1_name_nonempty" CHECK (length("name") > 0) + ) + `); + await client.query( + `CREATE INDEX "sheet1_name_idx" ON "${baseId}"."${mainRelationName}" ("name")` + ); + await client.query(` + CREATE OR REPLACE FUNCTION "${baseId}"."touch_sheet1_modified_time"() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + NEW."__last_modified_time" = COALESCE(NEW."__last_modified_time", now()); + RETURN NEW; + END; + $$ + `); + await client.query(` + CREATE TRIGGER "sheet1_touch_modified_time" + BEFORE INSERT OR UPDATE ON "${baseId}"."${mainRelationName}" + FOR EACH ROW + EXECUTE FUNCTION "${baseId}"."touch_sheet1_modified_time"() + `); + await client.query( + `INSERT INTO "${baseId}"."${mainRelationName}" VALUES + ('rec1', now(), now(), 'A'), + ('rec2', now(), now(), 'B')` + ); + await client.query(` + CREATE TABLE "${baseId}"."${linkedRelationName}" ( + "id" text PRIMARY KEY, + "__created_time" timestamp, + "title" text + ) + `); + await client.query( + `INSERT INTO "${baseId}"."${linkedRelationName}" VALUES + ('rec-linked-1', now(), 'Linked A'), + ('rec-linked-2', now(), 'Linked B')` + ); + await client.query(` + CREATE TABLE "${baseId}"."${junctionRelationName}" ( + "id" text PRIMARY KEY, + "sheet1_id" text NOT NULL REFERENCES "${baseId}"."${mainRelationName}" ("id"), + "sheet2_id" text NOT NULL REFERENCES "${baseId}"."${linkedRelationName}" ("id") + ) + `); + await client.query( + `INSERT INTO "${baseId}"."${junctionRelationName}" VALUES + ('lnk1', 'rec1', 'rec-linked-1'), + ('lnk2', 'rec2', 'rec-linked-2')` + ); + await client.query(`CREATE SCHEMA "${otherBaseId}"`); + await client.query(` + CREATE TABLE "${otherBaseId}"."${mainRelationName}" ( + "id" text PRIMARY KEY, + "__created_time" timestamp, + "name" text + ) + `); + await client.query( + `INSERT INTO "${otherBaseId}"."${mainRelationName}" VALUES ('rec-other-1', now(), 'Other space row')` + ); + + await createSharedTables(client, 'public'); + await client.query( + `INSERT INTO "public"."record_history" VALUES + ('rh1', $1, 'rec1', 'fld1', '{}'::jsonb, '{"name":"A"}'::jsonb, now(), 'usr'), + ('rh2', 'tblother', 'rec9', 'fld1', '{}'::jsonb, '{}'::jsonb, now(), 'usr')`, + [tableId] + ); + await client.query( + `INSERT INTO "public"."table_trash" VALUES + ('tt1', $1, 'table', '{}'::jsonb, now(), 'usr'), + ('tt2', 'tblother', 'table', '{}'::jsonb, now(), 'usr')`, + [tableId] + ); + await client.query( + `INSERT INTO "public"."record_trash" VALUES + ('rt1', $1, 'rec1', '{}'::jsonb, now(), 'usr'), + ('rt2', 'tblother', 'rec9', '{}'::jsonb, now(), 'usr')`, + [tableId] + ); + await client.query( + `INSERT INTO "public"."computed_update_outbox" + ("id", "base_id", "status", "created_at", "updated_at") + VALUES + ('cuo1', $1, 'pending', now(), now()), + ('cuo2', $2, 'pending', now(), now())`, + [baseId, otherBaseId] + ); + await client.query( + `INSERT INTO "public"."computed_update_dead_letter" + ("id", "base_id", "status", "created_at", "updated_at", "trace_data", "failed_at") + VALUES + ('cudl1', $1, 'failed', now(), now(), '{}'::jsonb, now()), + ('cudl2', $2, 'failed', now(), now(), '{}'::jsonb, now())`, + [baseId, otherBaseId] + ); + await client.query( + `INSERT INTO "public"."computed_update_outbox_seed" VALUES + ('seed1', 'cuo1', $1, 'rec1'), + ('seed2', 'cuo2', $1, 'rec9')`, + [tableId] + ); + await client.query( + `INSERT INTO "public"."computed_update_pause_scope" + ("id", "scope_type", "scope_id", "paused_at", "paused_by", "reason", "updated_at", "updated_by") + VALUES + ('pause-space', 'space', $1, now(), 'usr', $5, now(), 'usr'), + ('pause-base', 'base', $2, now(), 'usr', 'migration', now(), 'usr'), + ('pause-table', 'table', $3, now(), 'usr', 'migration', now(), 'usr'), + ('pause-other', 'space', $4, now(), 'usr', 'other', now(), 'usr')`, + [spaceId, baseId, tableId, otherSpaceId, `space-data-db-migration:${jobId}`] + ); + await client.query( + `INSERT INTO "public"."__undo_log" VALUES + ('undo1', 'batch1', 'update', $1, 'rec1', '{}'::jsonb, '{}'::jsonb, now()), + ('undo2', 'batch2', 'update', $2, 'rec9', '{}'::jsonb, '{}'::jsonb, now())`, + [`${baseId}.${mainRelationName}`, `${otherBaseId}.${mainRelationName}`] + ); +}; + +const waitUntil = async (predicate: () => boolean, timeoutMs = 1000, pollMs = 10) => { + const startedAt = Date.now(); + for (;;) { + if (predicate()) { + return; + } + if (Date.now() - startedAt >= timeoutMs) { + throw new Error('Timed out waiting for condition'); + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } +}; + +describeWithPostgres('SpaceDataDbCopyService integration', () => { + let rootDir: string; + let dataDir: string; + let socketDir: string; + let port: number; + + beforeAll(async () => { + rootDir = await mkdtemp(path.join(tmpdir(), 'teable-byodb-copy-')); + dataDir = path.join(rootDir, 'pgdata'); + socketDir = path.join(rootDir, 'socket'); + port = await getFreePort(); + await mkdir(socketDir); + await execFile('initdb', ['-D', dataDir, '--no-instructions', '--auth=trust']); + await execFile('pg_ctl', [ + '-D', + dataDir, + '-o', + `-F -p ${port} -k ${socketDir} -c listen_addresses=127.0.0.1`, + '-l', + path.join(rootDir, 'postgres.log'), + '-w', + 'start', + ]); + + const admin = new Client({ connectionString: pgUrl(port, 'postgres') }); + await admin.connect(); + try { + await admin.query(`CREATE DATABASE ${sourceDatabase}`); + await admin.query(`CREATE DATABASE ${targetDatabase}`); + await admin.query(`CREATE DATABASE ${targetMismatchDatabase}`); + } finally { + await admin.end(); + } + + const source = new Client({ connectionString: pgUrl(port, sourceDatabase) }); + const target = new Client({ connectionString: pgUrl(port, targetDatabase) }); + const targetMismatch = new Client({ connectionString: pgUrl(port, targetMismatchDatabase) }); + await Promise.all([source.connect(), target.connect(), targetMismatch.connect()]); + try { + await seedSourceData(source); + await createSharedTables(target, targetSchema); + await createSharedTables(targetMismatch, targetMismatchSchema); + } finally { + await Promise.all([source.end(), target.end(), targetMismatch.end()]); + } + }, 30_000); + + afterAll(async () => { + if (dataDir) { + await execFile('pg_ctl', ['-D', dataDir, '-m', 'fast', '-w', 'stop']).catch(() => undefined); + } + if (rootDir) { + await rm(rootDir, { recursive: true, force: true }); + } + }, 30_000); + + it('copies base schemas and validates/switches the migration job through real PostgreSQL tools', async () => { + const sourceUrl = pgUrl(port, sourceDatabase); + const targetUrl = pgUrl(port, targetDatabase); + const service = new SpaceDataDbCopyService(new SpaceDataDbProcessRunnerService()); + await mkdir(path.join(rootDir, 'work')); + + const targetConnection = { + id: targetConnectionId, + status: 'migrating', + internalSchema: targetSchema, + encryptedUrl: encryptDataDbUrl(targetUrl), + displayHost: '127.0.0.1', + displayDatabase: targetDatabase, + urlFingerprint: 'dbfp_copy_target', + }; + let currentBinding: { + mode: 'byodb'; + state: 'ready'; + dataDbConnection: typeof targetConnection; + } | null = null; + const txClient = { + dataDbConnection: { + update: vi.fn().mockImplementation(async (args) => { + if (args.where?.id === targetConnectionId) { + targetConnection.status = args.data.status ?? targetConnection.status; + } + return undefined; + }), + }, + spaceDataDbBinding: { + upsert: vi.fn().mockImplementation(async (args) => { + if (args.where?.spaceId === spaceId) { + currentBinding = { + mode: 'byodb', + state: 'ready', + dataDbConnection: targetConnection, + }; + } + return undefined; + }), + }, + spaceDataDbMigrationJob: { + update: vi.fn().mockResolvedValue(undefined), + }, + }; + const migrationJob = { + id: jobId, + spaceId, + targetConnectionId, + targetInternalSchema: targetSchema, + createdBy: 'usr', + startedAt: new Date('2026-05-06T00:00:00.000Z'), + completedAt: null, + inventory: buildCopyInventory(), + copyStats: null, + validationStats: null, + targetConnection: { + encryptedUrl: encryptDataDbUrl(targetUrl), + }, + }; + const prismaService = { + $tx: vi.fn(async (fn: (client: typeof txClient) => Promise) => fn(txClient)), + base: { + findUnique: vi.fn().mockImplementation(async (args) => { + if (args.where?.id === baseId) { + return { spaceId }; + } + if (args.where?.id === otherBaseId) { + return { spaceId: otherSpaceId }; + } + return null; + }), + }, + spaceDataDbBinding: { + findUnique: vi + .fn() + .mockImplementation(async (args) => + args.where?.spaceId === spaceId ? currentBinding : null + ), + }, + spaceDataDbMigrationJob: { + findUnique: vi.fn().mockResolvedValue(migrationJob), + update: vi.fn().mockResolvedValue(undefined), + }, + }; + const baselineService = { + getLatestSchemaVersion: vi.fn().mockReturnValue(null), + }; + const dataDbClientManager = { + getDataDatabaseForSpace: vi.fn().mockImplementation((_, options) => { + if (options?.previewBinding) { + return Promise.resolve({ + cacheKey: targetConnectionId, + connectionId: targetConnectionId, + internalSchema: targetSchema, + isMetaFallback: false, + url: targetUrl, + }); + } + return Promise.resolve({ + cacheKey: 'meta-fallback', + connectionId: undefined, + internalSchema: undefined, + isMetaFallback: true, + url: sourceUrl, + }); + }), + invalidateConnection: vi.fn(), + }; + const runtimeCache = new DataDbRuntimeCacheService(); + const sourceFallbackKnex = createKnex({ + client: 'pg', + connection: sourceUrl, + pool: { min: 0, max: 1 }, + }); + const routingManager = new DataDbClientManager( + prismaService as never, + {} as never, + sourceFallbackKnex, + runtimeCache + ); + const migrationService = new SpaceDataDbMigrationService( + prismaService as never, + {} as never, + baselineService as never, + dataDbClientManager as never, + service as never, + dataDbKnexClientFactory + ); + + try { + await expect(routingManager.dataKnexForBase(baseId)).resolves.toBe(sourceFallbackKnex); + + const baseCopyStats = await migrationService.copyBaseSchemasForJob(jobId, { + workDir: path.join(rootDir, 'work'), + jobs: 2, + timeoutMs: 30_000, + }); + + expect(baseCopyStats).toMatchObject({ + phase: 'base_schemas_completed', + baseSchemas: { + copiedRelationCount: 3, + totalCopiedRows: 6, + copiedRelations: expect.arrayContaining([ + expect.objectContaining({ + schemaName: baseId, + relationName: mainRelationName, + copiedRows: 2, + estimatedRows: 2, + }), + expect.objectContaining({ + schemaName: baseId, + relationName: linkedRelationName, + copiedRows: 2, + estimatedRows: 2, + }), + expect.objectContaining({ + schemaName: baseId, + relationName: junctionRelationName, + copiedRows: 2, + estimatedRows: 2, + }), + ]), + }, + }); + expectProcessTiming(baseCopyStats.baseSchemas.dump); + expectProcessTiming(baseCopyStats.baseSchemas.restore); + + const sharedResults = await service.copySharedTables( + buildMigrationSharedTablePsqlCopyPlans({ + sourceUrl, + targetUrl, + sourceSchema: 'public', + targetSchema, + spaceId, + baseIds: [baseId], + tableIds: [tableId, linkedTableId], + }), + { timeoutMs: 30_000 } + ); + + expect(sharedResults).toEqual( + expect.arrayContaining([ + expect.objectContaining({ table: 'record_history', copiedRows: 1 }), + expect.objectContaining({ table: 'table_trash', copiedRows: 1 }), + expect.objectContaining({ table: 'record_trash', copiedRows: 1 }), + expect.objectContaining({ table: 'computed_update_outbox', copiedRows: 1 }), + expect.objectContaining({ table: 'computed_update_dead_letter', copiedRows: 1 }), + expect.objectContaining({ table: 'computed_update_outbox_seed', copiedRows: 1 }), + expect.objectContaining({ table: 'computed_update_pause_scope', copiedRows: 3 }), + expect.objectContaining({ table: '__undo_log', copiedRows: 1 }), + ]) + ); + const historyCopy = sharedResults.find((result) => result.table === 'record_history'); + expect(historyCopy).toBeDefined(); + if (!historyCopy) { + throw new Error('Expected record_history copy result'); + } + expectProcessTiming(historyCopy.source); + expectProcessTiming(historyCopy.target); + + const target = new Client({ connectionString: targetUrl }); + await target.connect(); + try { + await expect( + queryCount(target, `SELECT COUNT(*) AS count FROM "${baseId}"."${mainRelationName}"`) + ).resolves.toBe(2); + await expect( + queryCount(target, `SELECT COUNT(*) AS count FROM "${baseId}"."${linkedRelationName}"`) + ).resolves.toBe(2); + await expect( + queryCount(target, `SELECT COUNT(*) AS count FROM "${baseId}"."${junctionRelationName}"`) + ).resolves.toBe(2); + await expect( + queryCount( + target, + ` + SELECT COUNT(*) AS count + FROM pg_indexes + WHERE schemaname = $1 + AND indexname = 'sheet1_name_idx' + `, + [baseId] + ) + ).resolves.toBe(1); + await expect( + queryCount( + target, + ` + SELECT COUNT(*) AS count + FROM pg_constraint con + JOIN pg_class c ON c.oid = con.conrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 + AND c.relname = $2 + AND con.conname = 'sheet1_name_nonempty' + `, + [baseId, mainRelationName] + ) + ).resolves.toBe(1); + await expect( + queryCount( + target, + ` + SELECT COUNT(*) AS count + FROM pg_trigger tg + JOIN pg_class c ON c.oid = tg.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 + AND c.relname = $2 + AND tg.tgname = 'sheet1_touch_modified_time' + AND NOT tg.tgisinternal + `, + [baseId, mainRelationName] + ) + ).resolves.toBe(1); + await expect( + queryCount( + target, + `SELECT COUNT(*) AS count FROM "${targetSchema}"."record_history" WHERE "table_id" = $1`, + [tableId] + ) + ).resolves.toBe(1); + await expect( + queryCount( + target, + `SELECT COUNT(*) AS count FROM "${targetSchema}"."record_history" WHERE "table_id" = 'tblother'` + ) + ).resolves.toBe(0); + await expect( + queryCount( + target, + `SELECT COUNT(*) AS count FROM "${targetSchema}"."table_trash" WHERE "table_id" = $1`, + [tableId] + ) + ).resolves.toBe(1); + await expect( + queryCount( + target, + `SELECT COUNT(*) AS count FROM "${targetSchema}"."table_trash" WHERE "table_id" = 'tblother'` + ) + ).resolves.toBe(0); + await expect( + queryCount( + target, + `SELECT COUNT(*) AS count FROM "${targetSchema}"."record_trash" WHERE "table_id" = $1`, + [tableId] + ) + ).resolves.toBe(1); + await expect( + queryCount( + target, + `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}"."computed_update_outbox" WHERE "base_id" = $1`, + [baseId] + ) + ).resolves.toBe(1); + await expect( + queryCount( + target, + `SELECT COUNT(*) AS count FROM "${targetSchema}"."computed_update_outbox" WHERE "base_id" = $1`, + [otherBaseId] + ) + ).resolves.toBe(0); + await expect( + queryCount( + target, + `SELECT COUNT(*) AS count FROM "${targetSchema}"."computed_update_dead_letter" WHERE "base_id" = $1`, + [baseId] + ) + ).resolves.toBe(1); + await expect( + queryCount( + target, + `SELECT COUNT(*) AS count FROM "${targetSchema}"."computed_update_dead_letter" WHERE "base_id" = $1`, + [otherBaseId] + ) + ).resolves.toBe(0); + await expect( + queryCount( + target, + `SELECT COUNT(*) AS count FROM "${targetSchema}"."computed_update_outbox_seed"` + ) + ).resolves.toBe(1); + await expect( + queryCount( + target, + `SELECT COUNT(*) AS count FROM "${targetSchema}"."computed_update_outbox_seed" WHERE "task_id" = 'cuo2'` + ) + ).resolves.toBe(0); + await expect( + queryCount( + target, + `SELECT COUNT(*) AS count FROM "${targetSchema}"."computed_update_pause_scope"` + ) + ).resolves.toBe(3); + await expect( + queryCount( + target, + `SELECT COUNT(*) AS count FROM "${targetSchema}"."computed_update_pause_scope" WHERE "scope_id" = $1`, + [otherSpaceId] + ) + ).resolves.toBe(0); + await expect( + queryCount(target, `SELECT COUNT(*) AS count FROM "${targetSchema}"."__undo_log"`) + ).resolves.toBe(1); + await expect( + queryCount( + target, + `SELECT COUNT(*) AS count FROM "${targetSchema}"."__undo_log" WHERE "table_name" = $1`, + [`${otherBaseId}.${mainRelationName}`] + ) + ).resolves.toBe(0); + } finally { + await target.end(); + } + + await expect(migrationService.validateAndSwitchJob(jobId)).resolves.toMatchObject({ + state: 'succeeded', + validationStats: expect.objectContaining({ + phase: 'validation_completed', + routeSmoke: expect.objectContaining({ + ok: true, + connectionId: targetConnectionId, + internalSchema: targetSchema, + }), + baseSchemas: expect.arrayContaining([ + expect.objectContaining({ object: `base:${baseId}.${mainRelationName}` }), + expect.objectContaining({ object: `base:${baseId}.${linkedRelationName}` }), + expect.objectContaining({ object: `base:${baseId}.${junctionRelationName}` }), + ]), + }), + }); + + const postSwitchKnex = await routingManager.dataKnexForBase(baseId); + expect(postSwitchKnex).not.toBe(sourceFallbackKnex); + await postSwitchKnex.raw( + `INSERT INTO "${baseId}"."${mainRelationName}" ("id", "__created_time", "name") VALUES (?, now(), ?)`, + ['rec-post-switch', 'BYODB target'] + ); + const otherSpaceKnex = await routingManager.dataKnexForBase(otherBaseId); + expect(otherSpaceKnex).toBe(sourceFallbackKnex); + await otherSpaceKnex.raw( + `INSERT INTO "${otherBaseId}"."${mainRelationName}" ("id", "__created_time", "name") VALUES (?, now(), ?)`, + ['rec-other-post-switch', 'Default source'] + ); + + const source = new Client({ connectionString: sourceUrl }); + const targetAfterSwitch = new Client({ connectionString: targetUrl }); + await Promise.all([source.connect(), targetAfterSwitch.connect()]); + try { + await expect( + queryCount( + source, + `SELECT COUNT(*) AS count FROM "${baseId}"."${mainRelationName}" WHERE "id" = 'rec-post-switch'` + ) + ).resolves.toBe(0); + await expect( + queryCount( + targetAfterSwitch, + `SELECT COUNT(*) AS count FROM "${baseId}"."${mainRelationName}" WHERE "id" = 'rec-post-switch'` + ) + ).resolves.toBe(1); + await expect( + queryCount( + source, + `SELECT COUNT(*) AS count FROM "${otherBaseId}"."${mainRelationName}" WHERE "id" = 'rec-other-post-switch'` + ) + ).resolves.toBe(1); + await expect( + queryCount( + targetAfterSwitch, + ` + SELECT COUNT(*) AS count + FROM information_schema.schemata + WHERE schema_name = $1 + `, + [otherBaseId] + ) + ).resolves.toBe(0); + } finally { + await Promise.all([source.end(), targetAfterSwitch.end()]); + } + + expect(txClient.dataDbConnection.update).toHaveBeenCalledWith({ + where: { id: targetConnectionId }, + data: expect.objectContaining({ status: 'ready', lastError: null }), + }); + expect(txClient.spaceDataDbBinding.upsert).toHaveBeenCalledWith({ + where: { spaceId }, + create: { + spaceId, + dataDbConnectionId: targetConnectionId, + mode: 'byodb', + state: 'ready', + createdBy: 'usr', + }, + update: { + dataDbConnectionId: targetConnectionId, + mode: 'byodb', + state: 'ready', + }, + }); + expect(txClient.spaceDataDbMigrationJob.update).toHaveBeenCalledWith({ + where: { id: jobId }, + data: expect.objectContaining({ state: 'succeeded', lastError: null }), + }); + expect(dataDbClientManager.invalidateConnection).toHaveBeenCalledWith(targetConnectionId); + } finally { + await Promise.all([routingManager.onModuleDestroy(), sourceFallbackKnex.destroy()]); + } + }, 60_000); + + it('fails validation and keeps source routing when target base rows drift before switch', async () => { + const sourceUrl = pgUrl(port, sourceDatabase); + const targetUrl = pgUrl(port, targetMismatchDatabase); + const service = new SpaceDataDbCopyService(new SpaceDataDbProcessRunnerService()); + await mkdir(path.join(rootDir, 'work-mismatch')); + + const targetConnection = { + id: mismatchTargetConnectionId, + status: 'migrating', + internalSchema: targetMismatchSchema, + encryptedUrl: encryptDataDbUrl(targetUrl), + displayHost: '127.0.0.1', + displayDatabase: targetMismatchDatabase, + urlFingerprint: 'dbfp_copy_target_mismatch', + }; + let currentBinding: { + mode: 'byodb'; + state: 'ready'; + dataDbConnection: typeof targetConnection; + } | null = null; + const txClient = { + dataDbConnection: { + update: vi.fn().mockImplementation(async (args) => { + if (args.where?.id === mismatchTargetConnectionId) { + targetConnection.status = args.data.status ?? targetConnection.status; + } + return undefined; + }), + }, + spaceDataDbBinding: { + upsert: vi.fn().mockImplementation(async (args) => { + if (args.where?.spaceId === spaceId) { + currentBinding = { + mode: 'byodb', + state: 'ready', + dataDbConnection: targetConnection, + }; + } + return undefined; + }), + }, + spaceDataDbMigrationJob: { + update: vi.fn().mockResolvedValue(undefined), + }, + }; + const migrationJob = { + id: mismatchJobId, + spaceId, + targetConnectionId: mismatchTargetConnectionId, + targetInternalSchema: targetMismatchSchema, + createdBy: 'usr', + startedAt: new Date('2026-05-06T00:00:00.000Z'), + completedAt: null, + inventory: buildCopyInventory(), + copyStats: null, + validationStats: null, + targetConnection: { + encryptedUrl: encryptDataDbUrl(targetUrl), + }, + }; + const prismaService = { + $tx: vi.fn(async (fn: (client: typeof txClient) => Promise) => fn(txClient)), + base: { + findUnique: vi.fn().mockImplementation(async (args) => { + if (args.where?.id === baseId) { + return { spaceId }; + } + return null; + }), + }, + spaceDataDbBinding: { + findUnique: vi + .fn() + .mockImplementation(async (args) => + args.where?.spaceId === spaceId ? currentBinding : null + ), + }, + spaceDataDbMigrationJob: { + findUnique: vi.fn().mockResolvedValue(migrationJob), + update: vi.fn().mockResolvedValue(undefined), + }, + }; + const dataDbClientManager = { + getDataDatabaseForSpace: vi.fn().mockImplementation((_, options) => { + if (options?.previewBinding) { + return Promise.resolve({ + cacheKey: mismatchTargetConnectionId, + connectionId: mismatchTargetConnectionId, + internalSchema: targetMismatchSchema, + isMetaFallback: false, + url: targetUrl, + }); + } + return Promise.resolve({ + cacheKey: 'meta-fallback', + connectionId: undefined, + internalSchema: undefined, + isMetaFallback: true, + url: sourceUrl, + }); + }), + invalidateConnection: vi.fn(), + }; + const sourceFallbackKnex = createKnex({ + client: 'pg', + connection: sourceUrl, + pool: { min: 0, max: 1 }, + }); + const routingManager = new DataDbClientManager( + prismaService as never, + {} as never, + sourceFallbackKnex, + new DataDbRuntimeCacheService() + ); + const migrationService = new SpaceDataDbMigrationService( + prismaService as never, + {} as never, + { getLatestSchemaVersion: vi.fn().mockReturnValue(null) } as never, + dataDbClientManager as never, + service as never, + dataDbKnexClientFactory + ); + + try { + await migrationService.copyBaseSchemasForJob(mismatchJobId, { + workDir: path.join(rootDir, 'work-mismatch'), + jobs: 2, + timeoutMs: 30_000, + }); + await service.copySharedTables( + buildMigrationSharedTablePsqlCopyPlans({ + sourceUrl, + targetUrl, + sourceSchema: 'public', + targetSchema: targetMismatchSchema, + spaceId, + baseIds: [baseId], + tableIds: [tableId, linkedTableId], + }), + { timeoutMs: 30_000 } + ); + + const target = new Client({ connectionString: targetUrl }); + await target.connect(); + try { + await target.query( + `INSERT INTO "${baseId}"."${mainRelationName}" ("id", "__created_time", "name") + VALUES ('rec-target-extra', now(), 'Target extra row')` + ); + } finally { + await target.end(); + } + + await expect(migrationService.validateAndSwitchJob(mismatchJobId)).rejects.toMatchObject({ + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_VALIDATION_MISMATCH', + mismatches: expect.arrayContaining([ + expect.objectContaining({ + object: `base:${baseId}.${mainRelationName}`, + sourceCount: 2, + targetCount: 3, + }), + ]), + }), + }); + await expect(routingManager.dataKnexForBase(baseId)).resolves.toBe(sourceFallbackKnex); + expect(txClient.spaceDataDbBinding.upsert).not.toHaveBeenCalled(); + expect(txClient.dataDbConnection.update).not.toHaveBeenCalled(); + expect(dataDbClientManager.invalidateConnection).not.toHaveBeenCalled(); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: mismatchJobId }, + data: expect.objectContaining({ + state: 'failed', + lastError: 'Space data database migration validation failed', + validationStats: expect.objectContaining({ phase: 'validation_failed' }), + }), + }) + ); + } finally { + await Promise.all([routingManager.onModuleDestroy(), sourceFallbackKnex.destroy()]); + } + }, 60_000); + + it('waits for active computed tasks in the source data DB before draining', async () => { + const sourceUrl = pgUrl(port, sourceDatabase); + const source = new Client({ connectionString: sourceUrl }); + await source.connect(); + try { + await source.query( + `INSERT INTO "public"."computed_update_outbox" + ("id", "base_id", "status", "locked_at", "created_at", "updated_at") + VALUES + ('cuo-active-drain', $1, 'processing', now(), now(), now()), + ('cuo-active-other-base', 'bseother', 'processing', now(), now(), now()) + ON CONFLICT ("id") DO UPDATE + SET "base_id" = EXCLUDED."base_id", + "status" = EXCLUDED."status", + "locked_at" = EXCLUDED."locked_at", + "updated_at" = EXCLUDED."updated_at"`, + [baseId] + ); + } finally { + await source.end(); + } + + const updates: unknown[] = []; + const migrationJob = { + id: jobId, + spaceId, + targetInternalSchema: targetSchema, + createdBy: 'usr', + startedAt: new Date('2026-05-06T00:00:00.000Z'), + inventory: { + baseIds: [baseId], + tableIds: [tableId], + dbTableNames: [`${baseId}.sheet1`], + physicalSchemas: [], + }, + targetConnection: null, + }; + const prismaService = { + spaceDataDbMigrationJob: { + findUnique: vi.fn().mockResolvedValue(migrationJob), + findFirst: vi.fn().mockResolvedValue({ id: jobId, spaceId, state: 'copying' }), + update: vi.fn().mockImplementation(async (args: unknown) => { + updates.push(args); + return undefined; + }), + }, + }; + const dataDbClientManager = { + getDataDatabaseForSpace: vi.fn().mockResolvedValue({ + cacheKey: 'meta-fallback', + connectionId: undefined, + internalSchema: undefined, + isMetaFallback: true, + url: sourceUrl, + }), + invalidateConnection: vi.fn(), + }; + const migrationService = new SpaceDataDbMigrationService( + prismaService as never, + {} as never, + { getLatestSchemaVersion: vi.fn().mockReturnValue(null) } as never, + dataDbClientManager as never, + new SpaceDataDbCopyService(new SpaceDataDbProcessRunnerService()) as never, + dataDbKnexClientFactory + ); + + const drain = migrationService.waitForSourceComputedDrainForJob(jobId, { + timeoutMs: 2000, + pollMs: 20, + processingLeaseMs: 120_000, + }); + + await waitUntil(() => + updates.some((item) => { + const args = item as { data?: { copyStats?: { phase?: string; computedDrain?: unknown } } }; + const computedDrain = args.data?.copyStats?.computedDrain as + | { activeCount?: number } + | undefined; + return ( + args.data?.copyStats?.phase === 'computed_draining' && computedDrain?.activeCount === 1 + ); + }) + ); + + const finisher = new Client({ connectionString: sourceUrl }); + await finisher.connect(); + try { + await finisher.query( + `UPDATE "public"."computed_update_outbox" + SET "status" = 'pending', + "locked_at" = NULL, + "updated_at" = now() + WHERE "id" = 'cuo-active-drain'` + ); + } finally { + await finisher.end(); + } + + await expect(drain).resolves.toMatchObject({ + activeCount: 0, + reclaimableCount: 0, + }); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { id: jobId }, + data: expect.objectContaining({ + copyStats: expect.objectContaining({ + phase: 'computed_drained', + computedDrain: expect.objectContaining({ + activeCount: 0, + reclaimableCount: 0, + }), + }), + }), + }) + ); + + const verifier = new Client({ connectionString: sourceUrl }); + await verifier.connect(); + try { + await expect( + queryCount( + verifier, + `SELECT COUNT(*) AS count + FROM "public"."computed_update_outbox" + WHERE "id" = 'cuo-active-other-base' + AND "base_id" = 'bseother' + AND "status" = 'processing'` + ) + ).resolves.toBe(1); + } finally { + await verifier.end(); + } + }, 30_000); +}); 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 new file mode 100644 index 0000000000..d0ae1147b0 --- /dev/null +++ b/apps/nestjs-backend/src/features/space/space-data-db-copy.service.spec.ts @@ -0,0 +1,599 @@ +import { mkdtemp, readFile, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + filterPgRestoreListForForeignKeys, + PG_RESTORE_LIST_STDOUT_LIMIT, + postgresCopyToolsForStrategy, + parsePsqlInsertRowCount, + parsePsqlCopyRowCount, + REQUIRED_PGCOPYDB_COPY_TOOLS, + REQUIRED_POSTGRES_COPY_TOOLS, + SpaceDataDbCopyService, +} from './space-data-db-copy.service'; + +const sourceUrl = 'postgresql://source.example/teable'; +const targetUrl = 'postgresql://target.example/teable'; +const workDir = '/tmp/sdmjxxx'; +const psqlCommand = 'psql'; +const historyTargetArgs = ['history-target']; +const trashTargetArgs = ['trash-target']; + +describe('SpaceDataDbCopyService', () => { + const processRunner = { + run: vi.fn(), + runPipeline: vi.fn(), + }; + + beforeEach(() => { + processRunner.run.mockReset(); + processRunner.runPipeline.mockReset(); + }); + + it('parses psql COPY row counts from command output', () => { + expect(parsePsqlCopyRowCount('COPY 42\n')).toBe(42); + expect(parsePsqlCopyRowCount('notice\nCOPY 3\nCOPY 4\n')).toBe(4); + expect(parsePsqlCopyRowCount('')).toBeNull(); + }); + + it('parses psql INSERT row counts from command output', () => { + expect(parsePsqlInsertRowCount('INSERT 0 42\n')).toBe(42); + expect(parsePsqlInsertRowCount('BEGIN\nINSERT 0 3\nINSERT 0 4\nCOMMIT\n')).toBe(4); + expect(parsePsqlInsertRowCount('')).toBeNull(); + }); + + it('checks required PostgreSQL client tools before copy work starts', async () => { + processRunner.run.mockImplementation((plan: { command: string; args: string[] }) => + Promise.resolve({ + command: plan.command, + args: plan.args, + exitCode: 0, + signal: null, + stderr: '', + stdout: '', + startedAt: '2026-05-06T00:00:00.000Z', + completedAt: '2026-05-06T00:00:01.000Z', + durationMs: 1000, + }) + ); + const service = new SpaceDataDbCopyService(processRunner as never); + + await expect( + service.assertPostgresToolsAvailable('pg_dump_restore', { timeoutMs: 5000 }) + ).resolves.toHaveLength(REQUIRED_POSTGRES_COPY_TOOLS.length); + + for (const [index, command] of REQUIRED_POSTGRES_COPY_TOOLS.entries()) { + expect(processRunner.run).toHaveBeenNthCalledWith( + index + 1, + { command, args: ['--version'] }, + { timeoutMs: 5000 } + ); + } + }); + + it('requires pgcopydb only when the pgcopydb base-schema strategy is selected', () => { + expect(postgresCopyToolsForStrategy('pg_dump_restore')).toEqual([ + 'pg_dump', + 'pg_restore', + 'psql', + ]); + expect(postgresCopyToolsForStrategy('pg_dump_stream_restore')).toEqual([ + 'pg_dump', + 'pg_restore', + 'psql', + ]); + expect(postgresCopyToolsForStrategy('pgcopydb')).toEqual(REQUIRED_PGCOPYDB_COPY_TOOLS); + }); + + it('streams pg_dump into pg_restore for base schema copies by default', async () => { + processRunner.runPipeline.mockResolvedValueOnce({ + source: { command: 'pg_dump', args: [], exitCode: 0, signal: null, stderr: '', stdout: '' }, + target: { + command: 'pg_restore', + args: [], + exitCode: 0, + signal: null, + stderr: '', + stdout: '', + }, + }); + const service = new SpaceDataDbCopyService(processRunner as never); + + await expect( + service.copyBaseSchemas({ + sourceUrl, + targetUrl, + schemaNames: ['bsebbb', 'bseaaa'], + workDir, + jobs: 2, + processOptions: { timeoutMs: 10_000 }, + }) + ).resolves.toMatchObject({ + strategy: 'pg_dump_stream_restore', + stream: { + source: { command: 'pg_dump' }, + target: { command: 'pg_restore' }, + }, + }); + + expect(processRunner.run).not.toHaveBeenCalled(); + expect(processRunner.runPipeline).toHaveBeenCalledWith( + expect.objectContaining({ + source: expect.objectContaining({ + command: 'pg_dump', + args: expect.arrayContaining(['--schema', '"bseaaa"', '--schema', '"bsebbb"']), + }), + target: expect.objectContaining({ command: 'pg_restore' }), + }), + { timeoutMs: 10_000 } + ); + }); + + it('executes pg_dump before pg_restore when the dump/restore strategy is selected', async () => { + processRunner.run + .mockResolvedValueOnce({ command: 'pg_dump', exitCode: 0, args: [], stderr: '', stdout: '' }) + .mockResolvedValueOnce({ + command: 'pg_restore', + exitCode: 0, + args: [], + stderr: '', + stdout: '', + }); + const service = new SpaceDataDbCopyService(processRunner as never); + + await expect( + service.copyBaseSchemas({ + sourceUrl, + targetUrl, + schemaNames: ['bsebbb', 'bseaaa'], + workDir, + jobs: 2, + strategy: 'pg_dump_restore', + processOptions: { timeoutMs: 10_000 }, + }) + ).resolves.toMatchObject({ + strategy: 'pg_dump_restore', + dump: { command: 'pg_dump' }, + restore: { command: 'pg_restore' }, + }); + + expect(processRunner.run).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + command: 'pg_dump', + args: expect.arrayContaining(['--schema', '"bseaaa"', '--schema', '"bsebbb"']), + }), + { timeoutMs: 10_000 } + ); + expect(processRunner.run).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ command: 'pg_restore' }), + { timeoutMs: 10_000 } + ); + }); + + it('streams pg_dump into pg_restore for base schema copies when selected', async () => { + processRunner.runPipeline.mockResolvedValueOnce({ + source: { command: 'pg_dump', args: [], exitCode: 0, signal: null, stderr: '', stdout: '' }, + target: { + command: 'pg_restore', + args: [], + exitCode: 0, + signal: null, + stderr: '', + stdout: '', + }, + }); + const service = new SpaceDataDbCopyService(processRunner as never); + + await expect( + service.copyBaseSchemas({ + sourceUrl, + targetUrl, + schemaNames: ['bsebbb', 'bseaaa'], + workDir, + strategy: 'pg_dump_stream_restore', + processOptions: { timeoutMs: 10_000 }, + }) + ).resolves.toMatchObject({ + strategy: 'pg_dump_stream_restore', + stream: { + source: { command: 'pg_dump' }, + target: { command: 'pg_restore' }, + }, + }); + + expect(processRunner.run).not.toHaveBeenCalled(); + expect(processRunner.runPipeline).toHaveBeenCalledWith( + expect.objectContaining({ + source: expect.objectContaining({ + command: 'pg_dump', + args: expect.arrayContaining(['--format=custom']), + }), + target: expect.objectContaining({ command: 'pg_restore' }), + }), + { timeoutMs: 10_000 } + ); + expect(processRunner.runPipeline.mock.calls[0][0].source.args).not.toEqual( + expect.arrayContaining(['--file', '-']) + ); + }); + + it('filters out-of-space foreign keys from pg_restore list before restore', async () => { + const workDir = await mkdtemp(path.join(tmpdir(), 'teable-pg-restore-list-')); + processRunner.run + .mockResolvedValueOnce({ command: 'pg_dump', exitCode: 0, args: [], stderr: '', stdout: '' }) + .mockResolvedValueOnce({ + command: 'pg_restore', + exitCode: 0, + args: ['--list'], + stderr: '', + stdout: [ + '; archive TOC', + '181; 1259 1 TABLE bse9Jpr5JmgTTXRYHWh Biao_GeyaAMdfes85 postgres', + '182; 2606 2 FK CONSTRAINT bse9Jpr5JmgTTXRYHWh Biao_GeyaAMdfes85 fk___fk_fldP7S7LNeXsLsEFFhr postgres', + '183; 2606 3 FK CONSTRAINT bse9Jpr5JmgTTXRYHWh Biao_GeyaAMdfes85 fk_keep_in_scope postgres', + '', + ].join('\n'), + }) + .mockResolvedValueOnce({ + command: 'pg_restore', + exitCode: 0, + args: [], + stderr: '', + stdout: '', + }); + const service = new SpaceDataDbCopyService(processRunner as never); + + try { + await expect( + service.copyBaseSchemas({ + sourceUrl, + targetUrl, + schemaNames: ['bse9Jpr5JmgTTXRYHWh'], + workDir, + jobs: 2, + excludedForeignKeys: [ + { + schemaName: 'bse9Jpr5JmgTTXRYHWh', + tableName: 'Biao_GeyaAMdfes85', + constraintName: 'fk___fk_fldP7S7LNeXsLsEFFhr', + referencedSchemaName: 'bsemNMekh61Et', + referencedTableName: 'Newtable_tblXTN8jAZ9i7omvC8u', + }, + ], + processOptions: { timeoutMs: 10_000 }, + }) + ).resolves.toMatchObject({ + strategy: 'pg_dump_restore', + restoreList: { command: 'pg_restore' }, + filteredRestoreList: { + requestedForeignKeyCount: 1, + excludedEntryCount: 1, + }, + }); + + const restoreCall = processRunner.run.mock.calls[2][0]; + const restoreListOptions = processRunner.run.mock.calls[1][1]; + expect(restoreListOptions).toMatchObject({ + timeoutMs: 10_000, + stdoutLimit: PG_RESTORE_LIST_STDOUT_LIMIT, + }); + + expect(restoreCall).toEqual( + expect.objectContaining({ + command: 'pg_restore', + args: expect.arrayContaining([ + '--use-list', + path.join(workDir, 'base-schemas.restore.list'), + ]), + }) + ); + + const listFile = await readFile(path.join(workDir, 'base-schemas.restore.list'), 'utf8'); + expect(listFile).not.toContain('fk___fk_fldP7S7LNeXsLsEFFhr'); + expect(listFile).toContain('fk_keep_in_scope'); + } finally { + await rm(workDir, { recursive: true, force: true }); + } + }); + + it('filters pg_restore list entries by schema table and foreign key names', () => { + const filtered = filterPgRestoreListForForeignKeys( + [ + '182; 2606 2 FK CONSTRAINT bsexxx sheet1 fk_out_of_scope postgres', + '183; 2606 3 FK CONSTRAINT bsexxx sheet2 fk_out_of_scope postgres', + '184; 2606 4 CONSTRAINT bsexxx sheet1 fk_out_of_scope postgres', + '', + ].join('\n'), + [ + { + schemaName: 'bsexxx', + tableName: 'sheet1', + constraintName: 'fk_out_of_scope', + referencedSchemaName: 'bseyyy', + referencedTableName: 'sheet2', + }, + ] + ); + + expect(filtered.excludedEntryCount).toBe(1); + expect(filtered.content).not.toContain('182;'); + expect(filtered.content).toContain('183;'); + expect(filtered.content).toContain('184;'); + }); + + it('attaches separate progress hooks to pg_dump and pg_restore', async () => { + processRunner.run + .mockResolvedValueOnce({ command: 'pg_dump', exitCode: 0, args: [], stderr: '', stdout: '' }) + .mockResolvedValueOnce({ + command: 'pg_restore', + exitCode: 0, + args: [], + stderr: '', + stdout: '', + }); + const service = new SpaceDataDbCopyService(processRunner as never); + const onDumpProgressPoll = vi.fn(); + const onRestoreProgressPoll = vi.fn(); + const baseOnPoll = vi.fn(); + + await service.copyBaseSchemas({ + sourceUrl, + targetUrl, + schemaNames: ['bsexxx'], + workDir, + strategy: 'pg_dump_restore', + processOptions: { timeoutMs: 10_000, pollMs: 250, onPoll: baseOnPoll }, + hooks: { + onDumpProgressPoll, + onRestoreProgressPoll, + }, + }); + + const dumpOptions = processRunner.run.mock.calls[0][1]; + const restoreOptions = processRunner.run.mock.calls[1][1]; + await dumpOptions.onPoll(); + await restoreOptions.onPoll(); + + expect(baseOnPoll).toHaveBeenCalledTimes(2); + expect(onDumpProgressPoll).toHaveBeenCalledTimes(1); + expect(onRestoreProgressPoll).toHaveBeenCalledTimes(1); + expect(dumpOptions).toMatchObject({ timeoutMs: 10_000, pollMs: 250 }); + expect(restoreOptions).toMatchObject({ timeoutMs: 10_000, pollMs: 250 }); + }); + + it('does not run restore when dump fails', async () => { + processRunner.run.mockRejectedValueOnce(new Error('dump failed')); + const service = new SpaceDataDbCopyService(processRunner as never); + + await expect( + service.copyBaseSchemas({ + sourceUrl, + targetUrl, + schemaNames: ['bsexxx'], + workDir, + strategy: 'pg_dump_restore', + }) + ).rejects.toThrow('dump failed'); + + expect(processRunner.run).toHaveBeenCalledTimes(1); + expect(processRunner.runPipeline).not.toHaveBeenCalled(); + }); + + it('writes a pgcopydb filter file and runs pgcopydb when explicitly selected', async () => { + const workDir = await mkdtemp(path.join(tmpdir(), 'teable-pgcopydb-plan-')); + processRunner.run.mockResolvedValueOnce({ + command: 'pgcopydb', + exitCode: 0, + args: [], + stderr: '', + stdout: '', + }); + const service = new SpaceDataDbCopyService(processRunner as never); + + try { + await expect( + service.copyBaseSchemas({ + sourceUrl, + targetUrl, + schemaNames: ['bsebbb', 'bseaaa'], + workDir, + jobs: 2, + strategy: 'pgcopydb', + processOptions: { timeoutMs: 10_000 }, + }) + ).resolves.toMatchObject({ + strategy: 'pgcopydb', + pgcopydb: { command: 'pgcopydb' }, + }); + + expect(processRunner.run).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'pgcopydb', + args: expect.arrayContaining([ + 'copy', + 'db', + '--filters', + path.join(workDir, 'pgcopydb-base-schemas.filter.ini'), + ]), + }), + { timeoutMs: 10_000 } + ); + } finally { + await rm(workDir, { recursive: true, force: true }); + } + }); + + it('rejects pgcopydb when out-of-space foreign keys need filtered restore entries', async () => { + const service = new SpaceDataDbCopyService(processRunner as never); + + await expect( + service.copyBaseSchemas({ + sourceUrl, + targetUrl, + schemaNames: ['bsexxx'], + workDir, + strategy: 'pgcopydb', + excludedForeignKeys: [ + { + schemaName: 'bsexxx', + tableName: 'sheet1', + constraintName: 'fk_out_of_scope', + referencedSchemaName: 'bseyyy', + referencedTableName: 'sheet2', + }, + ], + }) + ).rejects.toThrow('pgcopydb base schema copy does not support filtering'); + + expect(processRunner.run).not.toHaveBeenCalled(); + }); + + it('executes shared table COPY pipelines sequentially', async () => { + processRunner.runPipeline + .mockResolvedValueOnce({ + source: { command: 'psql', args: [], exitCode: 0, signal: null, stderr: '', stdout: '' }, + target: { + command: 'psql', + args: [], + exitCode: 0, + signal: null, + stderr: '', + stdout: 'COPY 12\n', + }, + }) + .mockResolvedValueOnce({ + source: { command: 'psql', args: [], exitCode: 0, signal: null, stderr: '', stdout: '' }, + target: { + command: 'psql', + args: [], + exitCode: 0, + signal: null, + stderr: '', + stdout: 'COPY 0\n', + }, + }); + const service = new SpaceDataDbCopyService(processRunner as never); + const onTableCopied = vi.fn(); + + await expect( + service.copySharedTables( + [ + { + table: 'record_history', + sourceSql: 'COPY source history TO STDOUT', + targetSql: 'COPY target history FROM STDIN', + source: { command: psqlCommand, args: ['history-source'] }, + target: { command: psqlCommand, args: historyTargetArgs }, + }, + { + 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 }, + }, + ], + { timeoutMs: 10_000 }, + { onTableCopied } + ) + ).resolves.toEqual([ + expect.objectContaining({ table: 'record_history', copiedRows: 12 }), + expect.objectContaining({ table: 'record_trash', copiedRows: 0 }), + ]); + + expect(processRunner.runPipeline).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ table: 'record_history' }), + { timeoutMs: 10_000 } + ); + expect(processRunner.runPipeline).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ table: 'record_trash' }), + { timeoutMs: 10_000 } + ); + expect(onTableCopied).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ table: 'record_history', copiedRows: 12 }), + 0, + 2 + ); + expect(onTableCopied).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ table: 'record_trash', copiedRows: 0 }), + 1, + 2 + ); + }); + + it('executes postgres_fdw shared table inserts sequentially', async () => { + processRunner.run + .mockResolvedValueOnce({ + command: 'psql', + args: [], + exitCode: 0, + signal: null, + stderr: '', + stdout: 'INSERT 0 7\n', + }) + .mockResolvedValueOnce({ + command: 'psql', + args: [], + exitCode: 0, + signal: null, + stderr: '', + stdout: 'INSERT 0 0\n', + }); + const service = new SpaceDataDbCopyService(processRunner as never); + const onTableCopied = vi.fn(); + + await expect( + service.copySharedTablesViaPostgresFdw( + [ + { + table: 'record_history', + sql: 'fdw history', + target: { command: 'psql', args: ['history-target'] }, + }, + { + table: 'record_trash', + sql: 'fdw trash', + target: { command: 'psql', args: ['trash-target'] }, + }, + ], + { timeoutMs: 10_000 }, + { onTableCopied } + ) + ).resolves.toEqual([ + expect.objectContaining({ + strategy: 'postgres_fdw', + table: 'record_history', + copiedRows: 7, + }), + expect.objectContaining({ + strategy: 'postgres_fdw', + table: 'record_trash', + copiedRows: 0, + }), + ]); + + expect(processRunner.run).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ command: psqlCommand, args: historyTargetArgs }), + { timeoutMs: 10_000 } + ); + expect(processRunner.run).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ command: psqlCommand, args: trashTargetArgs }), + { timeoutMs: 10_000 } + ); + expect(onTableCopied).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ table: 'record_history', copiedRows: 7 }), + 0, + 2 + ); + }); +}); 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 new file mode 100644 index 0000000000..138f3bbc0b --- /dev/null +++ b/apps/nestjs-backend/src/features/space/space-data-db-copy.service.ts @@ -0,0 +1,347 @@ +import { mkdir, writeFile } from 'fs/promises'; +import path from 'path'; +import { Injectable } from '@nestjs/common'; +import { + buildBaseSchemaDumpRestorePlan, + buildBaseSchemaDumpStreamRestorePlan, + buildBaseSchemaPgcopydbPlan, + buildBaseSchemaRestoreListPlan, + buildBaseSchemaRestorePlan, + type ISpaceDataDbDumpStreamRestorePlan, + type ISharedTablePostgresFdwCopyPlan, + type ISharedTablePsqlCopyPlan, + type ISpaceDataDbDumpRestorePlan, + type ISpaceDataDbPgcopydbPlan, +} from './space-data-db-copy-plan'; +import { + SpaceDataDbProcessRunnerService, + type ISpaceDataDbProcessPipelineResult, + type ISpaceDataDbProcessRunOptions, + type ISpaceDataDbProcessRunResult, +} from './space-data-db-process-runner.service'; + +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; + +export type ISpaceDataDbBaseSchemaCopyStrategy = + | 'pg_dump_restore' + | 'pg_dump_stream_restore' + | 'pgcopydb'; +export type ISpaceDataDbSharedTableCopyStrategy = 'psql_copy' | 'postgres_fdw'; + +export type ISpaceDataDbExcludedForeignKey = { + schemaName: string; + tableName: string; + constraintName: string; + referencedSchemaName: string; + referencedTableName: string; +}; + +export type ISpaceDataDbFilteredRestoreList = { + restoreListFile: string; + requestedForeignKeyCount: number; + excludedEntryCount: number; + excludedForeignKeys: ISpaceDataDbExcludedForeignKey[]; +}; + +export type ISpaceDataDbBaseSchemaCopyResult = { + strategy: ISpaceDataDbBaseSchemaCopyStrategy; + plan: ISpaceDataDbDumpRestorePlan | ISpaceDataDbDumpStreamRestorePlan | ISpaceDataDbPgcopydbPlan; + dump?: ISpaceDataDbProcessRunResult; + stream?: ISpaceDataDbProcessPipelineResult; + restoreList?: ISpaceDataDbProcessRunResult; + filteredRestoreList?: ISpaceDataDbFilteredRestoreList; + restore?: ISpaceDataDbProcessRunResult; + pgcopydb?: ISpaceDataDbProcessRunResult; +}; + +export type ISpaceDataDbSharedTableCopyResult = ISpaceDataDbProcessPipelineResult & { + strategy?: ISpaceDataDbSharedTableCopyStrategy; + table: string; + copiedRows: number | null; +}; + +export type ISpaceDataDbPostgresFdwSharedTableCopyResult = { + strategy: 'postgres_fdw'; + table: string; + copiedRows: number | null; + target: ISpaceDataDbProcessRunResult; +}; + +type ISpaceDataDbSharedTableCopyHooks = { + onTableCopied?: ( + result: ISpaceDataDbSharedTableCopyResult, + index: number, + total: number + ) => void | Promise; +}; + +type ISpaceDataDbBaseSchemaCopyHooks = { + onDumpProgressPoll?: () => void | Promise; + onRestoreProgressPoll?: () => void | Promise; +}; + +const withProgressPoll = ( + options: ISpaceDataDbProcessRunOptions | undefined, + onPoll: (() => void | Promise) | undefined +): ISpaceDataDbProcessRunOptions | undefined => { + if (!onPoll) { + return options; + } + return { + ...options, + onPoll: async () => { + await options?.onPoll?.(); + await onPoll(); + }, + }; +}; + +const withMinimumStdoutLimit = ( + options: ISpaceDataDbProcessRunOptions | undefined, + stdoutLimit: number +): ISpaceDataDbProcessRunOptions => ({ + ...options, + stdoutLimit: Math.max(options?.stdoutLimit ?? 0, stdoutLimit), +}); + +export const parsePsqlCopyRowCount = (output: string): number | null => { + const matches = [...output.matchAll(/^COPY\s+(\d+)\s*$/gim)]; + const last = matches.at(-1)?.[1]; + if (last == null) { + return null; + } + const value = Number(last); + return Number.isSafeInteger(value) && value >= 0 ? value : null; +}; + +export const parsePsqlInsertRowCount = (output: string): number | null => { + const matches = [...output.matchAll(/^INSERT\s+\d+\s+(\d+)\s*$/gim)]; + const last = matches.at(-1)?.[1]; + if (last == null) { + return null; + } + const value = Number(last); + return Number.isSafeInteger(value) && value >= 0 ? value : null; +}; + +export const postgresCopyToolsForStrategy = (strategy: ISpaceDataDbBaseSchemaCopyStrategy) => + strategy === 'pgcopydb' ? [...REQUIRED_PGCOPYDB_COPY_TOOLS] : [...REQUIRED_POSTGRES_COPY_TOOLS]; + +export const pgRestoreListEntryMatchesForeignKey = ( + line: string, + foreignKey: ISpaceDataDbExcludedForeignKey +) => { + if (!line.includes(' FK CONSTRAINT ')) { + return false; + } + + const tokens = line.trim().split(/\s+/); + return ( + tokens.includes(foreignKey.schemaName) && + tokens.includes(foreignKey.tableName) && + tokens.includes(foreignKey.constraintName) + ); +}; + +export const filterPgRestoreListForForeignKeys = ( + content: string, + foreignKeys: ISpaceDataDbExcludedForeignKey[] +) => { + if (!foreignKeys.length) { + return { content, excludedEntryCount: 0 }; + } + + let excludedEntryCount = 0; + const filteredLines = content.split(/\r?\n/).filter((line) => { + const shouldExclude = foreignKeys.some((foreignKey) => + pgRestoreListEntryMatchesForeignKey(line, foreignKey) + ); + if (shouldExclude) { + excludedEntryCount += 1; + } + return !shouldExclude; + }); + + return { + content: filteredLines.join('\n'), + excludedEntryCount, + }; +}; + +@Injectable() +export class SpaceDataDbCopyService { + constructor(private readonly processRunner: SpaceDataDbProcessRunnerService) {} + + async assertPostgresToolsAvailable( + strategy: ISpaceDataDbBaseSchemaCopyStrategy = 'pg_dump_restore', + processOptions?: ISpaceDataDbProcessRunOptions + ): Promise { + const results: ISpaceDataDbProcessRunResult[] = []; + for (const command of postgresCopyToolsForStrategy(strategy)) { + results.push(await this.processRunner.run({ command, args: ['--version'] }, processOptions)); + } + return results; + } + + async copyBaseSchemas(input: { + sourceUrl: string; + targetUrl: string; + schemaNames: string[]; + workDir: string; + jobs?: number; + strategy?: ISpaceDataDbBaseSchemaCopyStrategy; + excludedForeignKeys?: ISpaceDataDbExcludedForeignKey[]; + processOptions?: ISpaceDataDbProcessRunOptions; + hooks?: ISpaceDataDbBaseSchemaCopyHooks; + }): Promise { + const excludedForeignKeys = input.excludedForeignKeys ?? []; + const requestedStrategy = input.strategy ?? 'pg_dump_stream_restore'; + const strategy = + requestedStrategy === 'pg_dump_stream_restore' && excludedForeignKeys.length + ? 'pg_dump_restore' + : requestedStrategy; + if (strategy === 'pgcopydb') { + if (excludedForeignKeys.length) { + throw new Error( + 'pgcopydb base schema copy does not support filtering out-of-space foreign keys; use pg_dump_restore for this migration' + ); + } + const plan = buildBaseSchemaPgcopydbPlan(input); + await mkdir(input.workDir, { recursive: true }); + await writeFile(plan.filterFile, plan.filterFileContent, 'utf8'); + const pgcopydb = await this.processRunner.run(plan.copy, input.processOptions); + return { + strategy, + plan, + pgcopydb, + }; + } + + if (strategy === 'pg_dump_stream_restore' && !excludedForeignKeys.length) { + const plan = buildBaseSchemaDumpStreamRestorePlan(input); + const onPoll = + input.hooks?.onDumpProgressPoll || input.hooks?.onRestoreProgressPoll + ? async () => { + await input.hooks?.onDumpProgressPoll?.(); + await input.hooks?.onRestoreProgressPoll?.(); + } + : undefined; + const stream = await this.processRunner.runPipeline( + plan, + withProgressPoll(input.processOptions, onPoll) + ); + return { + strategy, + plan, + stream, + }; + } + + const plan = buildBaseSchemaDumpRestorePlan(input); + const dump = await this.processRunner.run( + plan.dump, + withProgressPoll(input.processOptions, input.hooks?.onDumpProgressPoll) + ); + let restoreList: ISpaceDataDbProcessRunResult | undefined; + let filteredRestoreList: ISpaceDataDbFilteredRestoreList | undefined; + if (excludedForeignKeys.length) { + plan.restoreList = buildBaseSchemaRestoreListPlan(plan.dumpFile); + restoreList = await this.processRunner.run( + plan.restoreList, + withMinimumStdoutLimit(input.processOptions, PG_RESTORE_LIST_STDOUT_LIMIT) + ); + const restoreListFile = path.join(input.workDir, 'base-schemas.restore.list'); + const filtered = filterPgRestoreListForForeignKeys(restoreList.stdout, excludedForeignKeys); + await mkdir(input.workDir, { recursive: true }); + await writeFile(restoreListFile, filtered.content, 'utf8'); + plan.restoreListFile = restoreListFile; + plan.restore = buildBaseSchemaRestorePlan({ + targetUrl: input.targetUrl, + dumpFile: plan.dumpFile, + jobs: input.jobs, + restoreListFile, + }); + filteredRestoreList = { + restoreListFile, + requestedForeignKeyCount: excludedForeignKeys.length, + excludedEntryCount: filtered.excludedEntryCount, + excludedForeignKeys, + }; + } + const restore = await this.processRunner.run( + plan.restore, + withProgressPoll(input.processOptions, input.hooks?.onRestoreProgressPoll) + ); + + return { + strategy, + plan, + dump, + restoreList, + filteredRestoreList, + 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, + }; + } + + async copySharedTables( + plans: ISharedTablePsqlCopyPlan[], + processOptions?: ISpaceDataDbProcessRunOptions, + hooks: ISpaceDataDbSharedTableCopyHooks = {} + ): Promise { + const results: ISpaceDataDbSharedTableCopyResult[] = []; + for (const [index, plan] of plans.entries()) { + const result = await this.copySharedTable(plan, processOptions); + results.push(result); + await hooks.onTableCopied?.(result, index, plans.length); + } + return results; + } + + async copySharedTableViaPostgresFdw( + 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, + }; + } + + async copySharedTablesViaPostgresFdw( + plans: ISharedTablePostgresFdwCopyPlan[], + processOptions?: ISpaceDataDbProcessRunOptions, + hooks: { + onTableCopied?: ( + result: ISpaceDataDbPostgresFdwSharedTableCopyResult, + index: number, + total: number + ) => void | Promise; + } = {} + ): Promise { + const results: ISpaceDataDbPostgresFdwSharedTableCopyResult[] = []; + for (const [index, plan] of plans.entries()) { + const result = await this.copySharedTableViaPostgresFdw(plan, processOptions); + results.push(result); + await hooks.onTableCopied?.(result, index, plans.length); + } + return results; + } +} 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 new file mode 100644 index 0000000000..51d63ee9cc --- /dev/null +++ b/apps/nestjs-backend/src/features/space/space-data-db-migration-guard.service.spec.ts @@ -0,0 +1,211 @@ +import { HttpErrorCode } from '@teable/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SpaceDataDbMigrationGuardService } from './space-data-db-migration-guard.service'; + +describe('SpaceDataDbMigrationGuardService', () => { + const prismaService = { + spaceDataDbMigrationJob: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, + base: { + findUnique: vi.fn(), + }, + tableMeta: { + findUnique: vi.fn(), + }, + }; + + beforeEach(() => { + prismaService.spaceDataDbMigrationJob.findFirst.mockReset(); + prismaService.spaceDataDbMigrationJob.findMany.mockReset().mockResolvedValue([]); + prismaService.base.findUnique.mockReset(); + prismaService.tableMeta.findUnique.mockReset(); + }); + + it('allows writes when no active migration job exists for the space', async () => { + prismaService.spaceDataDbMigrationJob.findFirst.mockResolvedValue(null); + const service = new SpaceDataDbMigrationGuardService(prismaService as never); + + await expect(service.assertSpaceWritable('spcxxx')).resolves.toBeUndefined(); + + expect(prismaService.spaceDataDbMigrationJob.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ spaceId: 'spcxxx' }), + }) + ); + }); + + it('allows writes when the migration job table has not been deployed yet', async () => { + const missingTableError = Object.assign( + new Error( + 'The table `public.space_data_db_migration_job` does not exist in the current database.' + ), + { code: 'P2021' } + ); + prismaService.spaceDataDbMigrationJob.findFirst.mockRejectedValue(missingTableError); + const service = new SpaceDataDbMigrationGuardService(prismaService as never); + + await expect(service.assertSpaceWritable('spcxxx')).resolves.toBeUndefined(); + + expect(prismaService.spaceDataDbMigrationJob.findMany).not.toHaveBeenCalled(); + }); + + it('rejects writes for a space that has an active migration job', async () => { + prismaService.spaceDataDbMigrationJob.findFirst.mockResolvedValue({ + id: 'sdmjxxx', + state: 'freezing_writes', + }); + const service = new SpaceDataDbMigrationGuardService(prismaService as never); + + await expect(service.assertSpaceWritable('spcxxx')).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_MIGRATING', + migrationJobId: 'sdmjxxx', + }), + }); + }); + + it('does not treat test-only migration jobs as source write blockers', async () => { + prismaService.spaceDataDbMigrationJob.findFirst.mockImplementation(async (args) => { + expect(args).toMatchObject({ + where: { + spaceId: 'spcxxx', + switchOnCompletion: true, + }, + }); + return null; + }); + const service = new SpaceDataDbMigrationGuardService(prismaService as never); + + await expect(service.assertSpaceWritable('spcxxx')).resolves.toBeUndefined(); + }); + + it('allows writes when the migration job table has not been migrated yet', async () => { + prismaService.spaceDataDbMigrationJob.findFirst.mockRejectedValue( + Object.assign(new Error('The table `public.space_data_db_migration_job` does not exist'), { + code: 'P2021', + }) + ); + const service = new SpaceDataDbMigrationGuardService(prismaService as never); + + await expect(service.assertSpaceWritable('spcxxx')).resolves.toBeUndefined(); + }); + + it('rejects writes for a related space included in a grouped active migration job', async () => { + prismaService.spaceDataDbMigrationJob.findFirst.mockResolvedValue(null); + prismaService.spaceDataDbMigrationJob.findMany.mockResolvedValue([ + { + id: 'sdmjgroup', + state: 'copying', + spaceId: 'spcprimary', + inventory: { + spaceIds: ['spcprimary', 'spcrelated'], + }, + }, + ]); + const service = new SpaceDataDbMigrationGuardService(prismaService as never); + + await expect(service.assertSpaceWritable('spcrelated')).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_MIGRATING', + migrationJobId: 'sdmjgroup', + }), + }); + }); + + it('resolves base and table ids to space ids before checking the freeze', async () => { + prismaService.spaceDataDbMigrationJob.findFirst.mockResolvedValue(null); + prismaService.base.findUnique.mockResolvedValue({ spaceId: 'spcxxx' }); + prismaService.tableMeta.findUnique.mockResolvedValue({ base: { spaceId: 'spcxxx' } }); + const service = new SpaceDataDbMigrationGuardService(prismaService as never); + + await service.assertBaseWritable('bsexxx'); + await service.assertTableWritable('tblxxx'); + + expect(prismaService.base.findUnique).toHaveBeenCalledWith({ + where: { id: 'bsexxx' }, + select: { spaceId: true }, + }); + expect(prismaService.tableMeta.findUnique).toHaveBeenCalledWith({ + where: { id: 'tblxxx' }, + select: { base: { select: { spaceId: true } } }, + }); + expect(prismaService.spaceDataDbMigrationJob.findFirst).toHaveBeenCalledTimes(2); + }); + + it('uses the active transaction client for id resolution but root client for migration jobs', async () => { + const txClient = { + spaceDataDbMigrationJob: { + findFirst: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([]), + }, + base: { + findUnique: vi.fn().mockResolvedValue({ spaceId: 'spctx' }), + }, + tableMeta: { + findUnique: vi.fn().mockResolvedValue({ base: { spaceId: 'spctx' } }), + }, + }; + const rootPrisma = { + ...prismaService, + txClient: vi.fn().mockReturnValue(txClient), + }; + rootPrisma.spaceDataDbMigrationJob.findFirst.mockResolvedValue(null); + const service = new SpaceDataDbMigrationGuardService(rootPrisma as never); + + await service.assertBaseWritable('bsetx'); + await service.assertTableWritable('tbltx'); + + expect(txClient.base.findUnique).toHaveBeenCalledWith({ + where: { id: 'bsetx' }, + select: { spaceId: true }, + }); + expect(txClient.tableMeta.findUnique).toHaveBeenCalledWith({ + where: { id: 'tbltx' }, + select: { base: { select: { spaceId: true } } }, + }); + expect(rootPrisma.spaceDataDbMigrationJob.findFirst).toHaveBeenCalledTimes(2); + expect(txClient.spaceDataDbMigrationJob.findFirst).not.toHaveBeenCalled(); + expect(prismaService.base.findUnique).not.toHaveBeenCalled(); + expect(prismaService.tableMeta.findUnique).not.toHaveBeenCalled(); + }); + + it('does not poison the active transaction when the migration job table is missing', async () => { + const missingTableError = Object.assign( + new Error( + 'The table `public.space_data_db_migration_job` does not exist in the current database.' + ), + { code: 'P2021' } + ); + const txClient = { + spaceDataDbMigrationJob: { + findFirst: vi.fn().mockRejectedValue(new Error('should not use transaction client')), + findMany: vi.fn(), + }, + base: { + findUnique: vi.fn().mockResolvedValue({ spaceId: 'spctx' }), + }, + tableMeta: { + findUnique: vi.fn().mockResolvedValue({ base: { spaceId: 'spctx' } }), + }, + }; + const rootPrisma = { + ...prismaService, + txClient: vi.fn().mockReturnValue(txClient), + }; + rootPrisma.spaceDataDbMigrationJob.findFirst.mockRejectedValue(missingTableError); + const service = new SpaceDataDbMigrationGuardService(rootPrisma as never); + + await expect(service.assertTableWritable('tbltx')).resolves.toBeUndefined(); + + expect(txClient.tableMeta.findUnique).toHaveBeenCalledWith({ + where: { id: 'tbltx' }, + select: { base: { select: { spaceId: true } } }, + }); + expect(rootPrisma.spaceDataDbMigrationJob.findFirst).toHaveBeenCalledTimes(1); + expect(txClient.spaceDataDbMigrationJob.findFirst).not.toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 0000000000..8217a2b866 --- /dev/null +++ b/apps/nestjs-backend/src/features/space/space-data-db-migration-guard.service.ts @@ -0,0 +1,140 @@ +import { Injectable } from '@nestjs/common'; +import { HttpErrorCode } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import { CustomHttpException } from '../../custom.exception'; +import { + activeSpaceDataDbMigrationStates, + spaceDataDbMigratingErrorCode, +} from './space-data-db-migration.constants'; + +type IMigrationJobClient = { + spaceDataDbMigrationJob: { + findFirst(args: unknown): Promise<{ id: string; state: string } | null>; + findMany( + args: unknown + ): Promise<{ id: string; state: string; spaceId: string; inventory: unknown }[]>; + }; + base: PrismaService['base']; + tableMeta: PrismaService['tableMeta']; + txClient?: () => IMigrationJobClient; +}; + +type IMigrationJobReader = Pick; + +@Injectable() +export class SpaceDataDbMigrationGuardService { + constructor(private readonly prismaService: PrismaService) {} + + async assertSpaceWritable(spaceId: string): Promise { + const activeJob = await this.findActiveMigrationForSpace(spaceId); + + if (!activeJob) { + return; + } + + throw new CustomHttpException( + 'Space data database migration is in progress', + HttpErrorCode.CONFLICT, + { + errorCode: spaceDataDbMigratingErrorCode, + migrationJobId: activeJob.id, + migrationState: activeJob.state, + spaceId, + } + ); + } + + private async findActiveMigrationForSpace(spaceId: string) { + try { + const directJob = await this.migrationJobClient.spaceDataDbMigrationJob.findFirst({ + where: { + spaceId, + switchOnCompletion: true, + state: { in: [...activeSpaceDataDbMigrationStates] }, + }, + select: { id: true, state: true }, + }); + if (directJob) { + return directJob; + } + + const activeJobs = await this.migrationJobClient.spaceDataDbMigrationJob.findMany({ + where: { + switchOnCompletion: true, + state: { in: [...activeSpaceDataDbMigrationStates] }, + }, + select: { id: true, state: true, spaceId: true, inventory: true }, + }); + return activeJobs.find((job) => this.inventoryContainsSpace(job.inventory, spaceId)) ?? null; + } catch (error) { + if (this.isMissingMigrationJobTableError(error)) { + return null; + } + throw error; + } + } + + private inventoryContainsSpace(inventory: unknown, spaceId: string) { + if (!inventory || typeof inventory !== 'object') { + return false; + } + const candidate = inventory as { spaceIds?: unknown; relatedSpaces?: { spaces?: unknown } }; + if (Array.isArray(candidate.spaceIds) && candidate.spaceIds.includes(spaceId)) { + return true; + } + return ( + Array.isArray(candidate.relatedSpaces?.spaces) && + candidate.relatedSpaces.spaces.some( + (space) => + space && typeof space === 'object' && (space as { spaceId?: unknown }).spaceId === spaceId + ) + ); + } + + private isMissingMigrationJobTableError(error: unknown) { + const code = + typeof error === 'object' && error !== null && 'code' in error + ? String((error as { code?: unknown }).code) + : ''; + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('space_data_db_migration_job') && + (code === 'P2021' || + code === 'P2022' || + code === '42P01' || + message.includes('does not exist') || + message.includes('relation')) + ); + } + + async assertBaseWritable(baseId: string): Promise { + const base = await this.prismaClient.base.findUnique({ + where: { id: baseId }, + select: { spaceId: true }, + }); + if (!base) { + throw new CustomHttpException(`Base ${baseId} not found`, HttpErrorCode.NOT_FOUND); + } + await this.assertSpaceWritable(base.spaceId); + } + + async assertTableWritable(tableId: string): Promise { + const table = await this.prismaClient.tableMeta.findUnique({ + where: { id: tableId }, + select: { base: { select: { spaceId: true } } }, + }); + if (!table) { + throw new CustomHttpException(`Table ${tableId} not found`, HttpErrorCode.NOT_FOUND); + } + await this.assertSpaceWritable(table.base.spaceId); + } + + private get prismaClient(): IMigrationJobClient { + const client = this.prismaService as unknown as IMigrationJobClient; + return client.txClient?.() ?? client; + } + + private get migrationJobClient(): IMigrationJobReader { + return this.prismaService as unknown as IMigrationJobReader; + } +} 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 new file mode 100644 index 0000000000..4d1300c814 --- /dev/null +++ b/apps/nestjs-backend/src/features/space/space-data-db-migration-worker.service.spec.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SpaceDataDbMigrationWorkerService } from './space-data-db-migration-worker.service'; + +describe('SpaceDataDbMigrationWorkerService', () => { + const workerId = 'worker-test'; + const migrationService = { + recoverStaleActiveMigrationJobs: vi.fn(), + claimNextPendingMigrationJob: vi.fn(), + runMigrationJob: vi.fn(), + }; + + beforeEach(() => { + vi.stubEnv('BYODB_SPACE_DATA_DB_MIGRATION_WORKER_ID', workerId); + migrationService.recoverStaleActiveMigrationJobs.mockReset().mockResolvedValue([]); + migrationService.claimNextPendingMigrationJob.mockReset().mockResolvedValue(null); + migrationService.runMigrationJob.mockReset().mockResolvedValue({ state: 'succeeded' }); + }); + + it('returns null when there is no pending job', async () => { + const service = new SpaceDataDbMigrationWorkerService(migrationService as never); + + await expect(service.runOnce()).resolves.toBeNull(); + + expect(migrationService.recoverStaleActiveMigrationJobs).toHaveBeenCalledWith(workerId); + expect(migrationService.claimNextPendingMigrationJob).toHaveBeenCalledWith(workerId); + expect(migrationService.runMigrationJob).not.toHaveBeenCalled(); + }); + + it('runs a claimed migration job', async () => { + migrationService.claimNextPendingMigrationJob.mockResolvedValue({ jobId: 'sdmjxxx' }); + const service = new SpaceDataDbMigrationWorkerService(migrationService as never); + + await expect(service.runOnce()).resolves.toEqual({ + jobId: 'sdmjxxx', + status: 'succeeded', + }); + + expect(migrationService.runMigrationJob).toHaveBeenCalledWith('sdmjxxx'); + }); + + it('logs and reports a failed claimed migration job without throwing', async () => { + migrationService.claimNextPendingMigrationJob.mockResolvedValue({ jobId: 'sdmjxxx' }); + migrationService.runMigrationJob.mockRejectedValue(new Error('copy failed')); + const service = new SpaceDataDbMigrationWorkerService(migrationService as never); + + await expect(service.runOnce()).resolves.toEqual({ + jobId: 'sdmjxxx', + status: 'failed', + error: 'copy failed', + }); + }); +}); 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 new file mode 100644 index 0000000000..43cb129111 --- /dev/null +++ b/apps/nestjs-backend/src/features/space/space-data-db-migration-worker.service.ts @@ -0,0 +1,101 @@ +import { hostname } from 'os'; +import { Injectable, Logger } from '@nestjs/common'; +import { SpaceDataDbMigrationService } from './space-data-db-migration.service'; + +type ISpaceDataDbMigrationWorkerRunResult = { + jobId: string; + status: 'succeeded' | 'failed'; + error?: string; +}; + +const defaultPollMs = 5000; +const defaultErrorBackoffMs = 10000; + +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)); + +@Injectable() +export class SpaceDataDbMigrationWorkerService { + private readonly logger = new Logger(SpaceDataDbMigrationWorkerService.name); + private stopped = false; + + constructor(private readonly migrationService: SpaceDataDbMigrationService) {} + + stop() { + this.stopped = true; + } + + async runOnce(): Promise { + const workerId = this.getWorkerId(); + const recoveredJobs = await this.migrationService.recoverStaleActiveMigrationJobs(workerId); + for (const job of recoveredJobs) { + this.logger.warn( + `Recovered stale BYODB space data DB migration job ${job.jobId} from ${job.state}: ${job.lastError}` + ); + } + const claimedJob = await this.migrationService.claimNextPendingMigrationJob(workerId); + + if (!claimedJob) { + return null; + } + + try { + this.logger.log(`Running BYODB space data DB migration job ${claimedJob.jobId}`); + await this.migrationService.runMigrationJob(claimedJob.jobId); + this.logger.log(`Completed BYODB space data DB migration job ${claimedJob.jobId}`); + return { jobId: claimedJob.jobId, status: 'succeeded' }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error( + `Failed BYODB space data DB migration job ${claimedJob.jobId}: ${message}`, + error instanceof Error ? error.stack : undefined + ); + return { jobId: claimedJob.jobId, status: 'failed', error: message }; + } + } + + 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 errorBackoffMs = + options.errorBackoffMs ?? + readPositiveIntegerEnv( + 'BYODB_SPACE_DATA_DB_MIGRATION_WORKER_ERROR_BACKOFF_MS', + defaultErrorBackoffMs + ); + + this.logger.log( + `BYODB space data DB migration worker ${this.getWorkerId()} started; pollMs=${pollMs}` + ); + + while (!this.stopped) { + try { + const result = await this.runOnce(); + if (!result) { + await delay(pollMs); + } else if (result.status === 'failed') { + await delay(errorBackoffMs); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error( + `BYODB space data DB migration worker loop failed: ${message}`, + error instanceof Error ? error.stack : undefined + ); + await delay(errorBackoffMs); + } + } + + this.logger.log(`BYODB space data DB migration worker ${this.getWorkerId()} stopped`); + } + + private getWorkerId() { + return process.env.BYODB_SPACE_DATA_DB_MIGRATION_WORKER_ID ?? `${hostname()}:${process.pid}`; + } +} diff --git a/apps/nestjs-backend/src/features/space/space-data-db-migration.constants.ts b/apps/nestjs-backend/src/features/space/space-data-db-migration.constants.ts new file mode 100644 index 0000000000..7a93d10fab --- /dev/null +++ b/apps/nestjs-backend/src/features/space/space-data-db-migration.constants.ts @@ -0,0 +1,51 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +export const migrateSpaceTargetMode = 'migrate-space'; +export const spaceDataDbAdminOnlyErrorCode = 'SPACE_DATA_DB_ADMIN_ONLY'; +export const spaceDataDbAdminOnlyMessage = + 'Space data database migration is only available from the admin panel'; + +export const activeSpaceDataDbMigrationStates = [ + 'pending', + 'waiting_worker', + 'preflight', + 'freezing_writes', + 'copying', + 'validating', + 'switching', +] as const; + +export const preCopyCancelableSpaceDataDbMigrationStates = [ + 'pending', + 'waiting_worker', + 'preflight', + 'freezing_writes', +] as const; +export const cancelableSpaceDataDbMigrationStates = [ + ...preCopyCancelableSpaceDataDbMigrationStates, + 'copying', +] as const; + +export const spaceDataDbMigratingErrorCode = 'SPACE_DATA_DB_MIGRATING'; +export const spaceDataDbMigrationActiveErrorCode = 'SPACE_DATA_DB_MIGRATION_ACTIVE'; +export const spaceDataDbMigrationCanceledErrorCode = 'SPACE_DATA_DB_MIGRATION_CANCELED'; +export const spaceDataDbMigrationCancelConflictErrorCode = + 'SPACE_DATA_DB_MIGRATION_CANCEL_CONFLICT'; +export const spaceDataDbTargetConflictErrorCode = 'SPACE_DATA_DB_TARGET_CONFLICT'; +export const spaceDataDbTargetCleanupFailedErrorCode = 'SPACE_DATA_DB_TARGET_CLEANUP_FAILED'; +export const spaceDataDbTargetExtensionMissingErrorCode = 'SPACE_DATA_DB_TARGET_EXTENSION_MISSING'; +export const spaceDataDbLargeMigrationConfirmationRequiredErrorCode = + 'SPACE_DATA_DB_LARGE_MIGRATION_CONFIRMATION_REQUIRED'; +export const spaceDataDbRollbackUnsafeErrorCode = 'SPACE_DATA_DB_ROLLBACK_UNSAFE'; +export const spaceDataDbValidationMismatchErrorCode = 'SPACE_DATA_DB_VALIDATION_MISMATCH'; +export const spaceDataDbInventoryChangedErrorCode = 'SPACE_DATA_DB_INVENTORY_CHANGED'; +export const spaceDataDbRelatedSpacesRequiredErrorCode = 'SPACE_DATA_DB_RELATED_SPACES_REQUIRED'; +export const spaceDataDbTempDiskInsufficientErrorCode = 'SPACE_DATA_DB_TEMP_DISK_INSUFFICIENT'; +export const spaceDataDbTargetDiskInsufficientErrorCode = 'SPACE_DATA_DB_TARGET_DISK_INSUFFICIENT'; +export const spaceDataDbPostgresToolUnavailableErrorCode = + 'SPACE_DATA_DB_POSTGRES_TOOL_UNAVAILABLE'; +export const spaceDataDbComputedDrainTimeoutErrorCode = 'SPACE_DATA_DB_COMPUTED_DRAIN_TIMEOUT'; +export const spaceDataDbSchemaOperationDrainTimeoutErrorCode = + 'SPACE_DATA_DB_SCHEMA_OPERATION_DRAIN_TIMEOUT'; +export const spaceDataDbBackgroundWriterDrainTimeoutErrorCode = + 'SPACE_DATA_DB_BACKGROUND_WRITER_DRAIN_TIMEOUT'; +export const spaceDataDbStaleActiveJobErrorCode = 'SPACE_DATA_DB_STALE_ACTIVE_JOB'; 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 new file mode 100644 index 0000000000..6028bee347 --- /dev/null +++ b/apps/nestjs-backend/src/features/space/space-data-db-migration.service.spec.ts @@ -0,0 +1,5963 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { FieldType, HttpErrorCode } from '@teable/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fingerprintDatabaseUrl, fingerprintDataDbConnection } from './data-db-preflight.service'; +import { encryptDataDbUrl } from './data-db-url-secret'; +import { SpaceDataDbMigrationService } from './space-data-db-migration.service'; +import { + SpaceDataDbProcessCanceledError, + SpaceDataDbProcessError, + SpaceDataDbProcessPipelineError, +} from './space-data-db-process-runner.service'; + +const dataUrl = 'postgresql://teable:secret@example.com:5432/teable_data'; +const internalSchema = 'teable_meta_test'; +const schemaVersion = '20260421000000_init_data_db_baseline'; +const capabilities = { + createSchema: true, + createTable: true, + createFunction: true, + createTrigger: true, + createRole: false, + grantPrivileges: true, + inspectActivity: true, +}; + +const processResult = (command: string, stdout = '') => ({ + command, + args: [], + exitCode: 0, + signal: null, + stderr: '', + stdout, + startedAt: '2026-05-06T00:00:00.000Z', + completedAt: '2026-05-06T00:00:01.000Z', + durationMs: 1000, +}); + +const columnSignatureRows = ( + columns: { + ordinalPosition?: number; + columnName: string; + formattedType: string; + notNull?: boolean; + defaultExpression?: string | null; + }[] = [ + { columnName: '__id', formattedType: 'text', notNull: true }, + { columnName: 'fldName', formattedType: 'text' }, + ] +) => + columns.map((column, index) => ({ + ordinalPosition: column.ordinalPosition ?? index + 1, + columnName: column.columnName, + formattedType: column.formattedType, + notNull: column.notNull ?? false, + defaultExpression: column.defaultExpression ?? null, + identity: '', + generated: '', + collation: 'default', + })); + +const indexSignatureRows = ( + indexes: { + indexName: string; + isPrimary?: boolean; + isUnique?: boolean; + definition: string; + }[] = [] +) => + indexes.map((index) => ({ + indexName: index.indexName, + isPrimary: index.isPrimary ?? false, + isUnique: index.isUnique ?? false, + isValid: true, + definition: index.definition, + })); + +const constraintSignatureRows = ( + constraints: { + constraintName: string; + constraintType: string; + definition: string; + }[] = [] +) => constraints; + +const triggerSignatureRows = ( + triggers: { + triggerName: string; + enabled?: string; + definition: string; + }[] = [] +) => + triggers.map((trigger) => ({ + triggerName: trigger.triggerName, + enabled: trigger.enabled ?? 'O', + definition: trigger.definition, + })); + +describe('SpaceDataDbMigrationService', () => { + const txClient = { + dataDbConnection: { + upsert: vi.fn(), + update: vi.fn(), + }, + spaceDataDbBinding: { + upsert: vi.fn(), + }, + spaceDataDbMigrationJob: { + create: vi.fn(), + update: vi.fn(), + }, + }; + const prismaService = { + $tx: vi.fn(async (fn: (client: typeof txClient) => Promise) => fn(txClient)), + spaceDataDbBinding: { + findUnique: vi.fn(), + findMany: vi.fn(), + }, + spaceDataDbMigrationJob: { + findFirst: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + schemaOperation: { + count: vi.fn(), + findMany: vi.fn(), + }, + base: { + count: vi.fn(), + findMany: vi.fn(), + }, + space: { + findMany: vi.fn(), + }, + tableMeta: { + count: vi.fn(), + findMany: vi.fn(), + }, + field: { + count: vi.fn(), + findMany: vi.fn(), + }, + }; + const preflightService = { + preflight: vi.fn(), + }; + const baselineService = { + initialize: vi.fn(), + getLatestSchemaVersion: vi.fn(), + }; + const sourceDataPrisma = { + $queryRawUnsafe: vi.fn(), + }; + const dataDbClientManager = { + dataPrismaForSpace: vi.fn(), + getDataDatabaseForSpace: vi.fn(), + invalidateConnection: vi.fn(), + }; + const copyService = { + assertPostgresToolsAvailable: vi.fn(), + copyBaseSchemas: vi.fn(), + copySharedTables: vi.fn(), + copySharedTablesViaPostgresFdw: vi.fn(), + }; + const targetClient = { + raw: vi.fn(), + destroy: vi.fn(), + }; + const sourceClient = { + raw: vi.fn(), + destroy: vi.fn(), + }; + + beforeEach(() => { + vi.stubEnv('PRISMA_DATABASE_URL', 'postgresql://source.example/teable'); + txClient.dataDbConnection.upsert.mockReset().mockResolvedValue({ id: 'dcnxxx' }); + txClient.dataDbConnection.update.mockReset().mockResolvedValue(undefined); + txClient.spaceDataDbBinding.upsert.mockReset().mockResolvedValue(undefined); + txClient.spaceDataDbMigrationJob.create.mockReset().mockResolvedValue({ id: 'sdmjxxx' }); + txClient.spaceDataDbMigrationJob.update.mockReset().mockResolvedValue(undefined); + prismaService.$tx.mockClear(); + prismaService.spaceDataDbBinding.findUnique.mockReset().mockResolvedValue(null); + prismaService.spaceDataDbMigrationJob.findFirst.mockReset().mockResolvedValue(null); + prismaService.spaceDataDbMigrationJob.findMany.mockReset().mockResolvedValue([]); + prismaService.spaceDataDbMigrationJob.findUnique.mockReset().mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + sourceConnectionId: null, + targetConnectionId: 'dcnxxx', + switchOnCompletion: true, + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [{ schemaName: 'bsexxx', relations: [], totalBytes: 1024 }], + }, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + }); + prismaService.spaceDataDbMigrationJob.update.mockReset().mockResolvedValue(undefined); + prismaService.spaceDataDbMigrationJob.updateMany.mockReset().mockResolvedValue({ count: 0 }); + prismaService.schemaOperation.count.mockReset().mockResolvedValue(0); + prismaService.schemaOperation.findMany.mockReset().mockResolvedValue([]); + prismaService.base.count.mockReset().mockResolvedValue(0); + prismaService.base.findMany.mockReset().mockResolvedValue([ + { + id: 'bsexxx', + tables: [{ id: 'tblxxx', dbTableName: 'bsexxx.sheet1' }], + }, + ]); + prismaService.space.findMany.mockReset().mockResolvedValue([ + { + id: 'spcxxx', + name: 'Space', + baseGroup: [ + { + id: 'bsexxx', + tables: [{ id: 'tblxxx' }], + }, + ], + }, + ]); + prismaService.tableMeta.count.mockReset().mockResolvedValue(0); + prismaService.tableMeta.findMany.mockReset().mockImplementation((args?: unknown) => { + const where = (args as { where?: { baseId?: { in?: string[] }; id?: { in?: string[] } } }) + ?.where; + if (where?.baseId?.in) { + return Promise.resolve( + where.baseId.in.flatMap((baseId) => (baseId === 'bsexxx' ? [{ id: 'tblxxx' }] : [])) + ); + } + if (where?.id?.in?.includes('tblxxx')) { + return Promise.resolve([{ id: 'tblxxx', dbTableName: 'bsexxx.sheet1' }]); + } + return Promise.resolve([ + { + id: 'tblxxx', + base: { spaceId: 'spcxxx' }, + dbTableName: 'bsexxx.sheet1', + }, + ]); + }); + prismaService.field.count.mockReset().mockResolvedValue(0); + prismaService.field.findMany.mockReset().mockResolvedValue([]); + prismaService.spaceDataDbBinding.findMany.mockReset().mockResolvedValue([]); + preflightService.preflight.mockReset().mockResolvedValue({ + ok: true, + provider: 'postgres', + classification: 'empty', + capabilities, + errors: [], + }); + baselineService.initialize.mockReset().mockResolvedValue(schemaVersion); + baselineService.getLatestSchemaVersion.mockReset().mockReturnValue(schemaVersion); + sourceDataPrisma.$queryRawUnsafe.mockReset().mockResolvedValue([ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: '1024', + estimatedRows: '10', + }, + ]); + dataDbClientManager.dataPrismaForSpace.mockReset().mockResolvedValue(sourceDataPrisma); + dataDbClientManager.getDataDatabaseForSpace.mockReset().mockImplementation((_, options) => { + if ('sourceConnectionId' in (options ?? {})) { + if (options.sourceConnectionId === 'dcnsource') { + return Promise.resolve({ + cacheKey: 'dcnsource', + connectionId: 'dcnsource', + internalSchema: 'source_schema', + isMetaFallback: false, + url: 'postgresql://source.example/byodb?schema=source_schema&options=-c+search_path%3Dsource_schema', + }); + } + return Promise.resolve({ + cacheKey: 'meta-fallback', + connectionId: undefined, + internalSchema: undefined, + isMetaFallback: true, + url: 'postgresql://source.example/teable', + }); + } + if (options?.previewBinding) { + const previewSchema = options.previewBinding.internalSchema; + return Promise.resolve({ + cacheKey: 'dcnxxx', + connectionId: 'dcnxxx', + internalSchema: previewSchema, + isMetaFallback: false, + url: `${dataUrl}?schema=${previewSchema}&options=-c+search_path%3D${previewSchema}`, + }); + } + return Promise.resolve({ + cacheKey: 'meta-fallback', + connectionId: undefined, + internalSchema: undefined, + isMetaFallback: true, + url: 'postgresql://source.example/teable', + }); + }); + dataDbClientManager.invalidateConnection.mockReset(); + copyService.assertPostgresToolsAvailable + .mockReset() + .mockResolvedValue([ + processResult('pg_dump'), + processResult('pg_restore'), + processResult('psql'), + ]); + copyService.copyBaseSchemas.mockReset().mockImplementation((input?: { strategy?: string }) => { + if (input?.strategy === 'pg_dump_restore') { + return Promise.resolve({ + strategy: 'pg_dump_restore', + dump: processResult('pg_dump'), + restore: processResult('pg_restore'), + }); + } + return Promise.resolve({ + strategy: 'pg_dump_stream_restore', + stream: { + source: processResult('pg_dump'), + target: processResult('pg_restore'), + }, + }); + }); + copyService.copySharedTables.mockReset().mockResolvedValue([ + { + strategy: 'psql_copy', + table: 'record_history', + copiedRows: 5, + source: processResult('psql'), + target: processResult('psql', 'COPY 5\n'), + }, + ]); + copyService.copySharedTablesViaPostgresFdw.mockReset().mockResolvedValue([ + { + strategy: 'postgres_fdw', + table: 'record_history', + copiedRows: 5, + target: processResult('psql', 'INSERT 0 5\n'), + }, + ]); + targetClient.raw.mockReset().mockResolvedValue({ rows: [] }); + targetClient.destroy.mockReset().mockResolvedValue(undefined); + sourceClient.raw.mockReset().mockResolvedValue({ rows: [] }); + sourceClient.destroy.mockReset().mockResolvedValue(undefined); + }); + + const createService = ( + statfs?: never, + queues?: { + baseImportCsvQueue?: unknown; + baseImportJunctionCsvQueue?: unknown; + tableImportCsvChunkQueue?: unknown; + tableImportCsvQueue?: unknown; + } + ) => + new SpaceDataDbMigrationService( + prismaService as never, + preflightService as never, + baselineService as never, + dataDbClientManager as never, + copyService as never, + (url) => (url.includes('source.example') ? sourceClient : targetClient), + statfs, + queues?.baseImportCsvQueue as never, + queues?.baseImportJunctionCsvQueue as never, + queues?.tableImportCsvChunkQueue as never, + queues?.tableImportCsvQueue as never + ); + + const mockValidationClient = ( + client: typeof sourceClient, + rowCount: number, + signatures: { + columns?: ReturnType; + indexes?: ReturnType; + constraints?: ReturnType; + triggers?: ReturnType; + } = {} + ) => { + const columns = signatures.columns ?? columnSignatureRows(); + const indexes = signatures.indexes ?? indexSignatureRows(); + const constraints = signatures.constraints ?? constraintSignatureRows(); + const triggers = signatures.triggers ?? triggerSignatureRows(); + client.raw.mockImplementation((sql: string) => { + if (sql.includes('FROM pg_class c')) { + return { + rows: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + }, + ], + }; + } + if (sql.includes('FROM pg_attribute a')) { + return { rows: columns }; + } + if (sql.includes('FROM pg_index i')) { + return { rows: indexes }; + } + if (sql.includes('FROM pg_constraint con')) { + return { rows: constraints }; + } + if (sql.includes('FROM pg_trigger tg')) { + return { rows: triggers }; + } + if (sql.includes('FROM "bsexxx"."sheet1"')) { + return { rows: [{ count: String(rowCount) }] }; + } + if (sql.includes('to_regprocedure')) { + return { rows: [{ exists: true }] }; + } + if (sql.includes('__teable_data_schema_migrations')) { + return { rows: [{ exists: true }] }; + } + if (sql.includes('COUNT(*)')) { + return { rows: [{ count: '0' }] }; + } + return { rows: [] }; + }); + }; + + it('rejects concurrent active migration for the same space', async () => { + prismaService.spaceDataDbMigrationJob.findFirst.mockResolvedValue({ + id: 'sdmjactive', + state: 'copying', + }); + const service = createService(); + + await expect( + service.startMigrationForSpace('spcxxx', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_MIGRATION_ACTIVE', + migrationJobId: 'sdmjactive', + }), + }); + expect(preflightService.preflight).not.toHaveBeenCalled(); + expect(targetClient.raw).not.toHaveBeenCalled(); + expect(prismaService.$tx).not.toHaveBeenCalled(); + }); + + it('returns preflight errors when the migration target URL cannot be parsed', async () => { + preflightService.preflight.mockResolvedValue({ + ok: false, + provider: 'postgres', + classification: 'non-empty-unknown', + capabilities, + errors: [{ code: 'INVALID_DATABASE_URL', message: 'Invalid URL' }], + }); + const service = createService(); + + await expect( + service.startMigrationForSpace('spcxxx', 'usrxxx', { + url: 'not-a-postgres-url', + targetMode: 'migrate-space', + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + preflight: expect.objectContaining({ + errors: [expect.objectContaining({ code: 'INVALID_DATABASE_URL' })], + }), + }), + }); + expect(targetClient.raw).not.toHaveBeenCalled(); + expect(prismaService.$tx).not.toHaveBeenCalled(); + }); + + it('persists source physical schema inventory for DB-to-DB copy planning', async () => { + sourceDataPrisma.$queryRawUnsafe.mockResolvedValue([ + { + schemaName: 'bsexxx', + relationName: 'junction_tblxxx_fldxxx', + relationKind: 'table', + totalBytes: '2048', + estimatedRows: '3', + }, + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: '1024', + estimatedRows: '10', + }, + { + schemaName: 'bsexxx', + relationName: 'sheet1___auto_number_seq', + relationKind: 'sequence', + totalBytes: '512', + estimatedRows: null, + }, + ]); + const service = createService(); + + await service.startMigrationForSpace('spcxxx', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }); + + expect(dataDbClientManager.dataPrismaForSpace).toHaveBeenCalledWith('spcxxx'); + expect(sourceDataPrisma.$queryRawUnsafe).toHaveBeenCalledWith( + expect.stringContaining('FROM pg_class c'), + ['bsexxx'] + ); + expect(txClient.spaceDataDbMigrationJob.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + inventory: expect.objectContaining({ + sourceDataDb: { + mode: 'default', + cacheKey: 'meta-fallback', + connectionId: null, + internalSchema: null, + isMetaFallback: true, + }, + targetDataDb: { + internalSchema, + }, + physicalSchemas: [ + { + schemaName: 'bsexxx', + totalBytes: 3584, + estimatedRows: 13, + relations: [ + { + schemaName: 'bsexxx', + relationName: 'junction_tblxxx_fldxxx', + relationKind: 'table', + totalBytes: 2048, + estimatedRows: 3, + }, + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: 1024, + estimatedRows: 10, + }, + { + schemaName: 'bsexxx', + relationName: 'sheet1___auto_number_seq', + relationKind: 'sequence', + totalBytes: 512, + estimatedRows: null, + }, + ], + }, + ], + postgresExtensionDependencies: [], + outOfScopeForeignKeys: [], + estimatedTotalBytes: 3584, + estimatedTotalRows: 13, + }), + }), + select: { id: true }, + }); + }); + + it('persists out-of-space foreign keys for filtered per-space restore planning', async () => { + sourceDataPrisma.$queryRawUnsafe + .mockResolvedValueOnce([ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: '1024', + estimatedRows: '10', + }, + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + schemaName: 'bsexxx', + tableName: 'sheet1', + constraintName: 'fk_out_of_scope', + referencedSchemaName: 'bseyyy', + referencedTableName: 'sheet2', + }, + ]); + const service = createService(); + + await service.startMigrationForSpace('spcxxx', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }); + + expect(sourceDataPrisma.$queryRawUnsafe).toHaveBeenCalledWith( + expect.stringContaining('referenced_ns.nspname <> ALL($1::text[])'), + ['bsexxx'] + ); + expect(txClient.spaceDataDbMigrationJob.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + inventory: expect.objectContaining({ + outOfScopeForeignKeys: [ + { + schemaName: 'bsexxx', + tableName: 'sheet1', + constraintName: 'fk_out_of_scope', + referencedSchemaName: 'bseyyy', + referencedTableName: 'sheet2', + }, + ], + }), + }), + select: { id: true }, + }); + }); + + it('requires explicit confirmation before starting a large estimated migration', async () => { + const largeBytes = 51 * 1024 * 1024 * 1024; + sourceDataPrisma.$queryRawUnsafe.mockResolvedValue([ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: String(largeBytes), + estimatedRows: '10', + }, + ]); + const service = createService(); + + await expect( + service.startMigrationForSpace('spcxxx', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_LARGE_MIGRATION_CONFIRMATION_REQUIRED', + confirmationField: 'confirmLargeMigration', + estimatedTotalBytes: largeBytes, + estimatedTotalRows: 10, + thresholds: expect.objectContaining({ + bytes: 50 * 1024 * 1024 * 1024, + }), + }), + }); + expect(targetClient.raw).not.toHaveBeenCalled(); + expect(baselineService.initialize).not.toHaveBeenCalled(); + expect(prismaService.$tx).not.toHaveBeenCalled(); + }); + + it('starts a large estimated migration when operator confirmation is present', async () => { + const largeBytes = 51 * 1024 * 1024 * 1024; + sourceDataPrisma.$queryRawUnsafe.mockResolvedValue([ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: String(largeBytes), + estimatedRows: '10', + }, + ]); + const service = createService(); + + await expect( + service.startMigrationForSpace('spcxxx', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + confirmLargeMigration: true, + }) + ).resolves.toMatchObject({ + jobId: 'sdmjxxx', + connectionId: 'dcnxxx', + }); + expect(baselineService.initialize).toHaveBeenCalledWith(dataUrl, internalSchema); + expect(txClient.spaceDataDbMigrationJob.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + inventory: expect.objectContaining({ + estimatedTotalBytes: largeBytes, + estimatedTotalRows: 10, + }), + }), + select: { id: true }, + }); + }); + + it('rejects migrate-space when target disk capacity is known to be insufficient', async () => { + const estimatedBytes = 1024; + targetClient.raw.mockImplementation((query: string) => { + if (query.includes("current_setting('data_directory')")) { + return Promise.resolve({ rows: [{ setting: '/home/postgres/pgdata/pgroot/data' }] }); + } + if (query.includes('SELECT line FROM __teable_target_disk_capacity')) { + return Promise.resolve({ rows: [{ line: '/home/postgres/pgdata|4096|1024|3072' }] }); + } + return Promise.resolve({ rows: [] }); + }); + const service = createService(); + + await expect( + service.startMigrationForSpace('spcxxx', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_TARGET_DISK_INSUFFICIENT', + estimatedTotalBytes: estimatedBytes, + requiredBytes: 1024 * 1024 * 1024, + availableBytes: 3072, + targetDisk: expect.objectContaining({ + checked: true, + mountPath: '/home/postgres/pgdata', + }), + }), + }); + expect(baselineService.initialize).not.toHaveBeenCalled(); + expect(prismaService.$tx).not.toHaveBeenCalled(); + expect(targetClient.destroy).toHaveBeenCalled(); + }); + + it('does not block migrate-space when target disk capacity cannot be inspected', async () => { + targetClient.raw.mockImplementation((query: string) => { + if (query.includes("current_setting('data_directory')")) { + return Promise.resolve({ rows: [{ setting: '/var/lib/postgresql/data' }] }); + } + if (query.includes('COPY __teable_target_disk_capacity FROM PROGRAM')) { + return Promise.reject( + new Error('must be superuser to COPY to or from an external program') + ); + } + return Promise.resolve({ rows: [] }); + }); + const service = createService(); + + await expect( + service.startMigrationForSpace('spcxxx', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }) + ).resolves.toMatchObject({ + jobId: 'sdmjxxx', + connectionId: 'dcnxxx', + }); + expect(baselineService.initialize).toHaveBeenCalledWith(dataUrl, internalSchema); + expect(txClient.spaceDataDbMigrationJob.create).toHaveBeenCalled(); + }); + + it('cleans retryable same-job target artifacts before starting a new migration', async () => { + const matchingInventory = { + sourceDataDb: { + mode: 'default', + cacheKey: 'meta-fallback', + connectionId: null, + internalSchema: null, + isMetaFallback: true, + }, + targetDataDb: { + internalSchema, + }, + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [ + { + schemaName: 'bsexxx', + totalBytes: 1024, + estimatedRows: 10, + relations: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: 1024, + estimatedRows: 10, + }, + ], + }, + ], + estimatedTotalBytes: 1024, + estimatedTotalRows: 10, + }; + prismaService.spaceDataDbMigrationJob.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: 'sdmjold', + state: 'failed', + targetConnectionId: 'dcnxxx', + inventory: matchingInventory, + }); + targetClient.raw.mockResolvedValue({ rows: [] }); + const service = createService(); + const cleanup = vi.spyOn(service, 'cleanupTargetArtifactsForJob').mockResolvedValue({ + reason: 'retry_before_start', + baseSchemas: [], + sharedTables: [], + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + }); + + await expect( + service.startMigrationForSpace('spcxxx', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }) + ).resolves.toMatchObject({ + jobId: 'sdmjxxx', + connectionId: 'dcnxxx', + }); + + expect(cleanup).toHaveBeenCalledWith('sdmjold', 'retry_before_start', { + truncateSharedTables: true, + }); + expect(baselineService.initialize).toHaveBeenCalledWith(dataUrl, internalSchema); + }); + + it('cleans previous successful dry-run target artifacts before starting a new migration', async () => { + prismaService.spaceDataDbMigrationJob.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: 'sdmjdryrun', + spaceId: 'spcxxx', + state: 'succeeded', + switchOnCompletion: false, + targetConnectionId: 'dcnxxx', + inventory: { + targetDataDb: { internalSchema }, + baseIds: ['bseold'], + tableIds: [], + dbTableNames: [], + physicalSchemas: [{ schemaName: 'bseold', relations: [] }], + }, + }); + targetClient.raw.mockResolvedValue({ rows: [] }); + const service = createService(); + const cleanup = vi.spyOn(service, 'cleanupTargetArtifactsForJob').mockResolvedValue({ + reason: 'retry_before_start', + baseSchemas: [], + sharedTables: [], + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + }); + + await expect( + service.startMigrationForSpace('spcxxx', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }) + ).resolves.toMatchObject({ + jobId: 'sdmjxxx', + connectionId: 'dcnxxx', + }); + + expect(cleanup).toHaveBeenCalledWith('sdmjdryrun', 'retry_before_start', { + truncateSharedTables: true, + }); + expect(prismaService.spaceDataDbMigrationJob.findFirst).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + where: expect.objectContaining({ + OR: expect.arrayContaining([{ state: 'succeeded', switchOnCompletion: false }]), + }), + }) + ); + expect(txClient.spaceDataDbMigrationJob.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + switchOnCompletion: false, + state: 'waiting_worker', + }), + }) + ); + }); + + it('allows preflight to pass when conflicts belong to a previous successful dry-run', async () => { + prismaService.spaceDataDbMigrationJob.findFirst.mockResolvedValueOnce({ + id: 'sdmjdryrun', + spaceId: 'spcxxx', + state: 'succeeded', + switchOnCompletion: false, + targetConnectionId: 'dcnxxx', + inventory: { + targetDataDb: { internalSchema }, + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [{ schemaName: 'bsexxx', relations: [] }], + }, + }); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('FROM information_schema.schemata')) { + return Promise.resolve({ rows: [{ schemaName: 'bsexxx' }] }); + } + return Promise.resolve({ rows: [] }); + }); + const service = createService(); + + await expect( + service.preflightMigrationTargetForSpace('spcxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }) + ).resolves.toMatchObject({ + ok: true, + internalSchema, + }); + + expect( + targetClient.raw.mock.calls.some(([sql]) => + String(sql).includes('FROM information_schema.schemata') + ) + ).toBe(false); + expect(baselineService.initialize).not.toHaveBeenCalled(); + expect(prismaService.$tx).not.toHaveBeenCalled(); + }); + + it('does not clean retry target artifacts when the previous job inventory differs', async () => { + prismaService.spaceDataDbMigrationJob.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: 'sdmjold', + state: 'failed', + targetConnectionId: 'dcnxxx', + inventory: { + targetDataDb: { internalSchema }, + baseIds: ['bseother'], + tableIds: [], + dbTableNames: [], + physicalSchemas: [], + }, + }); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('FROM information_schema.schemata')) { + return Promise.resolve({ rows: [{ schemaName: 'bsexxx' }] }); + } + return Promise.resolve({ rows: [] }); + }); + const service = createService(); + const cleanup = vi.spyOn(service, 'cleanupTargetArtifactsForJob'); + + await expect( + service.startMigrationForSpace('spcxxx', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_TARGET_CONFLICT', + }), + }); + + expect(cleanup).not.toHaveBeenCalled(); + expect(baselineService.initialize).not.toHaveBeenCalled(); + expect(prismaService.$tx).not.toHaveBeenCalled(); + }); + + it('rejects target databases with conflicting base schemas before creating a job', async () => { + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('FROM information_schema.schemata')) { + return Promise.resolve({ rows: [{ schemaName: 'bsexxx' }] }); + } + return Promise.resolve({ rows: [] }); + }); + const service = createService(); + + await expect( + service.startMigrationForSpace('spcxxx', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_TARGET_CONFLICT', + }), + }); + expect(baselineService.initialize).not.toHaveBeenCalled(); + expect(prismaService.$tx).not.toHaveBeenCalled(); + expect(targetClient.destroy).toHaveBeenCalled(); + }); + + it('rejects target databases with conflicting shared rows before creating a job', async () => { + targetClient.raw.mockImplementation((sql: string, bindings?: unknown[]) => { + if (sql.includes('FROM information_schema.schemata')) { + return Promise.resolve({ rows: [] }); + } + if (sql.includes('SELECT to_regclass')) { + const qualifiedTable = Array.isArray(bindings) ? bindings[0] : undefined; + return Promise.resolve({ + rows: [{ exists: qualifiedTable === `"${internalSchema}"."record_history"` }], + }); + } + if (sql.includes(`FROM "${internalSchema}"."record_history"`)) { + return Promise.resolve({ rows: [{ count: '2' }] }); + } + return Promise.resolve({ rows: [] }); + }); + const service = createService(); + + await expect( + service.startMigrationForSpace('spcxxx', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_TARGET_CONFLICT', + conflicts: [{ object: `table:${internalSchema}.record_history`, count: 2 }], + }), + }); + expect(baselineService.initialize).not.toHaveBeenCalled(); + expect(prismaService.$tx).not.toHaveBeenCalled(); + expect(targetClient.destroy).toHaveBeenCalled(); + }); + + it('rejects migrate-space when the source needs pg_trgm but the target cannot create it', async () => { + sourceDataPrisma.$queryRawUnsafe.mockImplementation((query: string) => { + if (query.includes('pg_get_indexdef')) { + return Promise.resolve([ + { + extensionName: 'pg_trgm', + objectType: 'operator_class', + schemaName: 'public', + objectName: 'gin_trgm_ops', + accessMethod: 'gin', + sourceSchemaName: 'bsexxx', + sourceRelationName: 'sheet1', + sourceIndexName: 'sheet1_name_trgm_idx', + }, + ]); + } + return Promise.resolve([ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: '1024', + estimatedRows: '10', + }, + ]); + }); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('CREATE EXTENSION')) { + return Promise.reject(new Error('permission denied to create extension "pg_trgm"')); + } + if (sql.includes('FROM pg_opclass opc')) { + return Promise.resolve({ rows: [] }); + } + return Promise.resolve({ rows: [] }); + }); + const service = createService(); + + await expect( + service.startMigrationForSpace('spcxxx', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_TARGET_EXTENSION_MISSING', + errors: [ + expect.objectContaining({ + code: 'MISSING_POSTGRES_EXTENSION', + message: expect.stringContaining('permission denied to create extension "pg_trgm"'), + remediation: expect.stringContaining('CREATE EXTENSION IF NOT EXISTS pg_trgm'), + }), + ], + missingExtensions: [ + expect.objectContaining({ + extensionName: 'pg_trgm', + schemaName: 'public', + objectName: 'gin_trgm_ops', + }), + ], + }), + }); + expect(baselineService.initialize).not.toHaveBeenCalled(); + expect(prismaService.$tx).not.toHaveBeenCalled(); + expect(targetClient.raw).toHaveBeenCalledWith( + 'CREATE EXTENSION IF NOT EXISTS "pg_trgm" WITH SCHEMA "public"' + ); + expect(targetClient.destroy).toHaveBeenCalled(); + }); + + it('starts migrate-space after creating the required pg_trgm operator class on target', async () => { + sourceDataPrisma.$queryRawUnsafe.mockImplementation((query: string) => { + if (query.includes('pg_get_indexdef')) { + return Promise.resolve([ + { + extensionName: 'pg_trgm', + objectType: 'operator_class', + schemaName: 'public', + objectName: 'gin_trgm_ops', + accessMethod: 'gin', + sourceSchemaName: 'bsexxx', + sourceRelationName: 'sheet1', + sourceIndexName: 'sheet1_name_trgm_idx', + }, + ]); + } + return Promise.resolve([ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: '1024', + estimatedRows: '10', + }, + ]); + }); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('CREATE EXTENSION')) { + return { + rows: [], + }; + } + if (sql.includes('FROM pg_opclass opc')) { + return { + rows: [{ schemaName: 'public', objectName: 'gin_trgm_ops', accessMethod: 'gin' }], + }; + } + return { rows: [] }; + }); + const service = createService(); + + await expect( + service.startMigrationForSpace('spcxxx', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }) + ).resolves.toMatchObject({ + jobId: 'sdmjxxx', + connectionId: 'dcnxxx', + }); + + expect(baselineService.initialize).toHaveBeenCalledWith(dataUrl, internalSchema); + expect(targetClient.raw).toHaveBeenCalledWith( + 'CREATE EXTENSION IF NOT EXISTS "pg_trgm" WITH SCHEMA "public"' + ); + expect(txClient.spaceDataDbMigrationJob.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + inventory: expect.objectContaining({ + postgresExtensionDependencies: [ + expect.objectContaining({ + extensionName: 'pg_trgm', + schemaName: 'public', + objectName: 'gin_trgm_ops', + sourceObjects: ['bsexxx.sheet1.sheet1_name_trgm_idx'], + }), + ], + }), + }), + select: { id: true }, + }); + }); + + it('accepts pg_trgm installed in the target extensions schema when it is search-path visible', async () => { + sourceDataPrisma.$queryRawUnsafe.mockImplementation((query: string) => { + if (query.includes('pg_get_indexdef')) { + return Promise.resolve([ + { + extensionName: 'pg_trgm', + objectType: 'operator_class', + schemaName: 'public', + objectName: 'gin_trgm_ops', + accessMethod: 'gin', + sourceSchemaName: 'bsexxx', + sourceRelationName: 'sheet1', + sourceIndexName: 'sheet1_name_trgm_idx', + }, + ]); + } + return Promise.resolve([ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: '1024', + estimatedRows: '10', + }, + ]); + }); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('CREATE EXTENSION') && sql.includes('WITH SCHEMA "public"')) { + return Promise.reject(new Error('permission denied for schema public')); + } + if (sql.includes('CREATE EXTENSION') && sql.includes('WITH SCHEMA "extensions"')) { + return Promise.resolve({ rows: [] }); + } + if (sql.includes('FROM pg_opclass opc')) { + return Promise.resolve({ + rows: [{ schemaName: 'extensions', objectName: 'gin_trgm_ops', accessMethod: 'gin' }], + }); + } + return Promise.resolve({ rows: [] }); + }); + const service = createService(); + + await expect( + service.startMigrationForSpace('spcxxx', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }) + ).resolves.toMatchObject({ + jobId: 'sdmjxxx', + connectionId: 'dcnxxx', + }); + + expect(targetClient.raw).toHaveBeenCalledWith( + 'CREATE EXTENSION IF NOT EXISTS "pg_trgm" WITH SCHEMA "public"' + ); + expect(targetClient.raw).toHaveBeenCalledWith( + 'CREATE EXTENSION IF NOT EXISTS "pg_trgm" WITH SCHEMA "extensions"' + ); + expect(baselineService.initialize).toHaveBeenCalledWith(dataUrl, internalSchema); + }); + + it('preflights migrate-space pg_trgm dependencies before target schema initialization', async () => { + sourceDataPrisma.$queryRawUnsafe.mockImplementation((query: string) => { + if (query.includes('pg_get_indexdef')) { + return Promise.resolve([ + { + extensionName: 'pg_trgm', + objectType: 'operator_class', + schemaName: 'public', + objectName: 'gin_trgm_ops', + accessMethod: 'gin', + sourceSchemaName: 'bsexxx', + sourceRelationName: 'sheet1', + sourceIndexName: 'sheet1_name_trgm_idx', + }, + ]); + } + return Promise.resolve([ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: '1024', + estimatedRows: '10', + }, + ]); + }); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('CREATE EXTENSION')) { + return Promise.reject(new Error('permission denied to create extension "pg_trgm"')); + } + if (sql.includes('FROM pg_opclass opc')) { + return Promise.resolve({ rows: [] }); + } + return Promise.resolve({ rows: [] }); + }); + const service = createService(); + + await expect( + service.preflightMigrationTargetForSpace('spcxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_TARGET_EXTENSION_MISSING', + errors: [ + expect.objectContaining({ + code: 'MISSING_POSTGRES_EXTENSION', + message: expect.stringContaining('permission denied to create extension "pg_trgm"'), + }), + ], + }), + }); + + expect(preflightService.preflight).toHaveBeenCalledWith({ + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }); + expect(targetClient.raw).toHaveBeenCalledWith( + 'CREATE EXTENSION IF NOT EXISTS "pg_trgm" WITH SCHEMA "public"' + ); + expect(baselineService.initialize).not.toHaveBeenCalled(); + expect(prismaService.$tx).not.toHaveBeenCalled(); + }); + + it('creates a migration job without switching routing when preflight passes', async () => { + const service = createService(); + + await expect( + service.startMigrationForSpace('spcxxx', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }) + ).resolves.toEqual({ + jobId: 'sdmjxxx', + connectionId: 'dcnxxx', + }); + + expect(preflightService.preflight).toHaveBeenCalledWith({ + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + }); + expect(baselineService.initialize).toHaveBeenCalledWith(dataUrl, internalSchema); + expect(txClient.dataDbConnection.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + status: 'migrating', + schemaVersion, + }), + update: expect.objectContaining({ + status: 'migrating', + schemaVersion, + }), + }) + ); + expect(txClient.spaceDataDbMigrationJob.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + spaceId: 'spcxxx', + targetConnectionId: 'dcnxxx', + targetMode: 'migrate-space', + switchOnCompletion: false, + state: 'waiting_worker', + inventory: expect.objectContaining({ + sourceDataDb: { + mode: 'default', + cacheKey: 'meta-fallback', + connectionId: null, + internalSchema: null, + isMetaFallback: true, + }, + targetDataDb: { + internalSchema, + }, + relatedSpaces: expect.objectContaining({ + primarySpaceId: 'spcxxx', + hasCrossSpaceLinks: false, + }), + spaceIds: ['spcxxx'], + copySpaceIds: ['spcxxx'], + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + sharedTableIds: ['tblxxx'], + relatedSharedTableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [ + { + schemaName: 'bsexxx', + totalBytes: 1024, + estimatedRows: 10, + relations: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: 1024, + estimatedRows: 10, + }, + ], + }, + ], + postgresExtensionDependencies: [], + outOfScopeForeignKeys: [], + estimatedTotalBytes: 1024, + estimatedTotalRows: 10, + }), + createdBy: 'usrxxx', + }), + select: { id: true }, + }); + expect(dataDbClientManager.invalidateConnection).toHaveBeenCalledWith('dcnxxx'); + }); + + it('starts and switches a migration for cross-space linked spaces together', async () => { + prismaService.field.findMany.mockResolvedValue([ + { + id: 'fldlink', + type: FieldType.Link, + isLookup: null, + isConditionalLookup: false, + options: JSON.stringify({ foreignTableId: 'tblrelated' }), + lookupOptions: null, + tableId: 'tblxxx', + table: { + id: 'tblxxx', + base: { spaceId: 'spcxxx' }, + }, + }, + ]); + prismaService.tableMeta.findMany + .mockResolvedValueOnce([ + { + id: 'tblrelated', + base: { spaceId: 'spcrelated' }, + }, + ]) + .mockResolvedValueOnce([ + { id: 'tblxxx', dbTableName: 'bsexxx.sheet1' }, + { id: 'tblrelated', dbTableName: 'bserelated.sheet1' }, + ]); + prismaService.space.findMany.mockResolvedValue([ + { + id: 'spcxxx', + name: 'Primary', + baseGroup: [{ id: 'bsexxx', tables: [{ id: 'tblxxx' }] }], + }, + { + id: 'spcrelated', + name: 'Related', + baseGroup: [{ id: 'bserelated', tables: [{ id: 'tblrelated' }] }], + }, + ]); + sourceDataPrisma.$queryRawUnsafe.mockImplementation((sql: string) => { + if (sql.includes('FROM pg_class c')) { + return Promise.resolve([ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: '1024', + estimatedRows: '10', + }, + { + schemaName: 'bserelated', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: '2048', + estimatedRows: '20', + }, + ]); + } + return Promise.resolve([]); + }); + const service = createService(); + + await expect( + service.startMigrationForSpace('spcxxx', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + internalSchema, + switchOnCompletion: true, + }) + ).resolves.toMatchObject({ + jobId: 'sdmjxxx', + connectionId: 'dcnxxx', + }); + + expect(txClient.spaceDataDbMigrationJob.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + inventory: expect.objectContaining({ + spaceIds: ['spcrelated', 'spcxxx'], + copySpaceIds: ['spcrelated', 'spcxxx'], + baseIds: ['bserelated', 'bsexxx'], + tableIds: ['tblrelated', 'tblxxx'], + dbTableNames: ['bserelated.sheet1', 'bsexxx.sheet1'], + relatedSpaces: expect.objectContaining({ + primarySpaceId: 'spcxxx', + hasCrossSpaceLinks: true, + spaces: expect.arrayContaining([ + expect.objectContaining({ spaceId: 'spcxxx', isPrimary: true }), + expect.objectContaining({ spaceId: 'spcrelated', isPrimary: false }), + ]), + links: [ + expect.objectContaining({ + fromSpaceId: 'spcxxx', + toSpaceId: 'spcrelated', + fromFieldId: 'fldlink', + }), + ], + }), + }), + }), + select: { id: true }, + }); + + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + sourceConnectionId: null, + targetConnectionId: 'dcnxxx', + switchOnCompletion: true, + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + startedAt: new Date('2026-05-06T00:00:00.000Z'), + completedAt: null, + inventory: txClient.spaceDataDbMigrationJob.create.mock.calls[0][0].data.inventory, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + }); + vi.spyOn(service, 'validateCopyForJob').mockResolvedValue({ + phase: 'validation_completed', + progress: { + phase: 'validation_completed', + totalSteps: 9, + completedSteps: 8, + percent: 88.9, + estimatedTotalBytes: 3072, + completedEstimatedBytes: 3072, + estimatedTotalRows: 30, + completedEstimatedRows: 30, + startedAt: '2026-05-06T00:00:00.000Z', + updatedAt: '2026-05-06T00:01:00.000Z', + etaMs: null, + }, + targetSchemaVersion: { latest: schemaVersion, exists: true }, + routeSmoke: { + ok: true, + connectionId: 'dcnxxx', + internalSchema, + cacheKey: 'dcnxxx', + isMetaFallback: false, + }, + baseSchemas: [], + sharedTables: [], + undoFunction: { exists: true }, + completedAt: '2026-05-06T00:01:00.000Z', + }); + + await expect(service.validateAndSwitchJob('sdmjxxx')).resolves.toMatchObject({ + state: 'succeeded', + }); + + expect(txClient.spaceDataDbBinding.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { spaceId: 'spcrelated' } }) + ); + expect(txClient.spaceDataDbBinding.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { spaceId: 'spcxxx' } }) + ); + }); + + it('starts a repair migration for default spaces linked to an existing target-bound space', async () => { + const targetInternalSchema = 'spcalready'; + prismaService.field.findMany.mockResolvedValue([ + { + id: 'fldlink', + type: FieldType.Link, + isLookup: null, + isConditionalLookup: false, + options: JSON.stringify({ foreignTableId: 'tblalready' }), + lookupOptions: null, + tableId: 'tblrelated', + table: { + id: 'tblrelated', + base: { spaceId: 'spcrelated' }, + }, + }, + ]); + prismaService.tableMeta.findMany + .mockResolvedValueOnce([ + { + id: 'tblalready', + base: { spaceId: 'spcalready' }, + }, + ]) + .mockResolvedValueOnce([{ id: 'tblrelated', dbTableName: 'bserelated.sheet1' }]) + .mockResolvedValueOnce([{ id: 'tblrelated' }, { id: 'tbldeletedrelated' }]) + .mockResolvedValueOnce([ + { id: 'tblalready' }, + { id: 'tbldeletedalready' }, + { id: 'tblrelated' }, + { id: 'tbldeletedrelated' }, + ]); + prismaService.space.findMany.mockResolvedValue([ + { + id: 'spcalready', + name: 'Already', + baseGroup: [{ id: 'bsealready', tables: [{ id: 'tblalready' }] }], + }, + { + id: 'spcrelated', + name: 'Related', + baseGroup: [{ id: 'bserelated', tables: [{ id: 'tblrelated' }] }], + }, + ]); + prismaService.spaceDataDbBinding.findMany.mockResolvedValue([ + { + spaceId: 'spcalready', + mode: 'byodb', + state: 'ready', + dataDbConnection: { + id: 'dcnalready', + encryptedUrl: encryptDataDbUrl(dataUrl), + urlFingerprint: fingerprintDataDbConnection(dataUrl, targetInternalSchema), + displayHost: 'example.com:5432', + displayDatabase: 'teable_data', + internalSchema: targetInternalSchema, + }, + }, + ]); + sourceDataPrisma.$queryRawUnsafe.mockImplementation((sql: string) => { + if (sql.includes('FROM pg_class c')) { + return Promise.resolve([ + { + schemaName: 'bserelated', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: '2048', + estimatedRows: '20', + }, + ]); + } + return Promise.resolve([]); + }); + const service = createService(); + + await expect( + service.startMigrationForSpace('spcrelated', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + switchOnCompletion: true, + }) + ).resolves.toMatchObject({ + jobId: 'sdmjxxx', + connectionId: 'dcnxxx', + }); + + expect(preflightService.preflight).toHaveBeenCalledWith({ + url: dataUrl, + targetMode: 'migrate-space', + internalSchema: targetInternalSchema, + }); + expect(txClient.dataDbConnection.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { urlFingerprint: fingerprintDataDbConnection(dataUrl, targetInternalSchema) }, + }) + ); + expect(txClient.spaceDataDbMigrationJob.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + spaceId: 'spcrelated', + targetInternalSchema, + inventory: expect.objectContaining({ + spaceIds: ['spcalready', 'spcrelated'], + copySpaceIds: ['spcrelated'], + baseIds: ['bserelated'], + tableIds: ['tblrelated'], + sharedTableIds: ['tbldeletedrelated', 'tblrelated'], + relatedSharedTableIds: [ + 'tblalready', + 'tbldeletedalready', + 'tbldeletedrelated', + 'tblrelated', + ], + dbTableNames: ['bserelated.sheet1'], + relatedSpaces: expect.objectContaining({ + primarySpaceId: 'spcrelated', + hasCrossSpaceLinks: true, + spaces: expect.arrayContaining([ + expect.objectContaining({ + spaceId: 'spcalready', + dataDbMode: 'byodb', + dataDbDatabaseFingerprint: fingerprintDatabaseUrl(dataUrl), + }), + expect.objectContaining({ spaceId: 'spcrelated', dataDbMode: 'default' }), + ]), + }), + }), + }), + select: { id: true }, + }); + + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcrelated', + sourceConnectionId: null, + targetConnectionId: 'dcnxxx', + switchOnCompletion: true, + targetInternalSchema, + createdBy: 'usrxxx', + startedAt: new Date('2026-05-06T00:00:00.000Z'), + completedAt: null, + inventory: txClient.spaceDataDbMigrationJob.create.mock.calls[0][0].data.inventory, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + }); + vi.spyOn(service, 'validateCopyForJob').mockResolvedValue({ + phase: 'validation_completed', + progress: { + phase: 'validation_completed', + totalSteps: 9, + completedSteps: 8, + percent: 88.9, + estimatedTotalBytes: 2048, + completedEstimatedBytes: 2048, + estimatedTotalRows: 20, + completedEstimatedRows: 20, + startedAt: '2026-05-06T00:00:00.000Z', + updatedAt: '2026-05-06T00:01:00.000Z', + etaMs: null, + }, + targetSchemaVersion: { latest: schemaVersion, exists: true }, + routeSmoke: { + ok: true, + connectionId: 'dcnxxx', + internalSchema: targetInternalSchema, + cacheKey: 'dcnxxx', + isMetaFallback: false, + }, + baseSchemas: [], + sharedTables: [], + undoFunction: { exists: true }, + completedAt: '2026-05-06T00:01:00.000Z', + }); + + await expect(service.validateAndSwitchJob('sdmjxxx')).resolves.toMatchObject({ + state: 'succeeded', + }); + + expect(txClient.spaceDataDbBinding.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { spaceId: 'spcalready' } }) + ); + expect(txClient.spaceDataDbBinding.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { spaceId: 'spcrelated' } }) + ); + }); + + it('rejects a repair migration when linked BYODB spaces use another physical database', async () => { + const otherUrl = 'postgresql://teable:secret@other.example:5432/teable_data'; + prismaService.field.findMany.mockResolvedValue([ + { + id: 'fldlink', + type: FieldType.Link, + isLookup: null, + isConditionalLookup: false, + options: JSON.stringify({ foreignTableId: 'tblalready' }), + lookupOptions: null, + tableId: 'tblrelated', + table: { + id: 'tblrelated', + base: { spaceId: 'spcrelated' }, + }, + }, + ]); + prismaService.tableMeta.findMany.mockResolvedValueOnce([ + { + id: 'tblalready', + base: { spaceId: 'spcalready' }, + }, + ]); + prismaService.space.findMany.mockResolvedValue([ + { + id: 'spcalready', + name: 'Already', + baseGroup: [{ id: 'bsealready', tables: [{ id: 'tblalready' }] }], + }, + { + id: 'spcrelated', + name: 'Related', + baseGroup: [{ id: 'bserelated', tables: [{ id: 'tblrelated' }] }], + }, + ]); + prismaService.spaceDataDbBinding.findMany.mockResolvedValue([ + { + spaceId: 'spcalready', + mode: 'byodb', + state: 'ready', + dataDbConnection: { + id: 'dcnalready', + encryptedUrl: encryptDataDbUrl(otherUrl), + urlFingerprint: fingerprintDataDbConnection(otherUrl, 'spcalready'), + displayHost: 'other.example:5432', + displayDatabase: 'teable_data', + internalSchema: 'spcalready', + }, + }, + ]); + const service = createService(); + + await expect( + service.startMigrationForSpace('spcrelated', 'usrxxx', { + url: dataUrl, + targetMode: 'migrate-space', + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_RELATED_SPACES_REQUIRED', + mismatchedSpaceIds: ['spcalready'], + }), + }); + + expect(txClient.spaceDataDbMigrationJob.create).not.toHaveBeenCalled(); + }); + + it('claims the oldest waiting-worker migration job for a worker', async () => { + prismaService.spaceDataDbMigrationJob.findFirst.mockResolvedValue({ + id: 'sdmjwaiting', + state: 'waiting_worker', + }); + prismaService.spaceDataDbMigrationJob.updateMany.mockResolvedValue({ count: 1 }); + const service = createService(); + + await expect(service.claimNextPendingMigrationJob('worker-1')).resolves.toEqual({ + jobId: 'sdmjwaiting', + }); + + expect(prismaService.spaceDataDbMigrationJob.findFirst).toHaveBeenCalledWith({ + where: { + targetMode: 'migrate-space', + state: { in: ['waiting_worker', 'pending'] }, + }, + orderBy: [{ createdTime: 'asc' }, { id: 'asc' }], + select: { id: true, state: true }, + }); + expect(prismaService.spaceDataDbMigrationJob.updateMany).toHaveBeenCalledWith({ + where: { + id: 'sdmjwaiting', + state: 'waiting_worker', + }, + data: expect.objectContaining({ + state: 'freezing_writes', + startedAt: expect.any(Date), + lastError: null, + copyStats: expect.objectContaining({ + phase: 'worker_claimed', + worker: expect.objectContaining({ + id: 'worker-1', + previousState: 'waiting_worker', + claimedAt: expect.any(String), + }), + }), + }), + }); + }); + + it('still claims legacy pending migration jobs for a worker', async () => { + prismaService.spaceDataDbMigrationJob.findFirst.mockResolvedValue({ + id: 'sdmjpending', + state: 'pending', + }); + prismaService.spaceDataDbMigrationJob.updateMany.mockResolvedValue({ count: 1 }); + const service = createService(); + + await expect(service.claimNextPendingMigrationJob('worker-1')).resolves.toEqual({ + jobId: 'sdmjpending', + }); + + expect(prismaService.spaceDataDbMigrationJob.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: 'sdmjpending', + state: 'pending', + }, + data: expect.objectContaining({ + state: 'freezing_writes', + }), + }) + ); + }); + + it('does not claim a migration job when another worker wins the claim update', async () => { + prismaService.spaceDataDbMigrationJob.findFirst.mockResolvedValue({ + id: 'sdmjwaiting', + state: 'waiting_worker', + }); + prismaService.spaceDataDbMigrationJob.updateMany.mockResolvedValue({ count: 0 }); + const service = createService(); + + await expect(service.claimNextPendingMigrationJob('worker-1')).resolves.toBeNull(); + }); + + it('returns null when no pending migration job exists for a worker', async () => { + const service = createService(); + + await expect(service.claimNextPendingMigrationJob('worker-1')).resolves.toBeNull(); + + expect(prismaService.spaceDataDbMigrationJob.updateMany).not.toHaveBeenCalled(); + }); + + it('recovers stale active migration jobs left behind by interrupted workers', async () => { + const now = new Date('2026-05-06T01:00:00.000Z'); + prismaService.spaceDataDbMigrationJob.findMany.mockResolvedValue([ + { + id: 'sdmjstale', + spaceId: 'spcxxx', + state: 'copying', + targetInternalSchema: internalSchema, + startedAt: new Date('2026-05-06T00:00:00.000Z'), + completedAt: null, + createdBy: 'usrxxx', + lastModifiedTime: new Date('2026-05-06T00:20:00.000Z'), + inventory: { baseIds: ['bsexxx'], tableIds: ['tblxxx'] }, + copyStats: { phase: 'copying_base_schemas' }, + validationStats: null, + sourceConnectionId: null, + targetConnectionId: 'dcnxxx', + targetConnection: { encryptedUrl: encryptDataDbUrl(dataUrl) }, + }, + ]); + prismaService.spaceDataDbMigrationJob.updateMany.mockResolvedValue({ count: 1 }); + const service = createService(); + const resumeSource = vi.spyOn(service, 'resumeSourceComputedForJob').mockResolvedValue({ + deleted: 1, + } as never); + const cleanupTarget = vi.spyOn(service, 'cleanupTargetArtifactsForJob').mockResolvedValue({ + reason: 'stale_active_job', + baseSchemas: [], + sharedTables: [], + startedAt: now.toISOString(), + completedAt: now.toISOString(), + } as never); + + await expect( + service.recoverStaleActiveMigrationJobs('worker-1', { now, staleAfterMs: 30 * 60 * 1000 }) + ).resolves.toEqual([ + expect.objectContaining({ + jobId: 'sdmjstale', + state: 'copying', + lastError: expect.stringContaining('no worker progress since 2026-05-06T00:20:00.000Z'), + }), + ]); + + expect(prismaService.spaceDataDbMigrationJob.findMany).toHaveBeenCalledWith({ + where: { + targetMode: 'migrate-space', + state: { in: ['preflight', 'freezing_writes', 'copying', 'validating', 'switching'] }, + OR: [ + { lastModifiedTime: null }, + { lastModifiedTime: { lt: new Date('2026-05-06T00:30:00.000Z') } }, + ], + }, + include: { targetConnection: true }, + orderBy: [{ lastModifiedTime: 'asc' }, { createdTime: 'asc' }, { id: 'asc' }], + }); + expect(prismaService.spaceDataDbMigrationJob.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: 'sdmjstale', + state: 'copying', + }), + data: expect.objectContaining({ + state: 'failed', + completedAt: now, + lastError: expect.stringContaining( + 'Space data database migration job sdmjstale is stale' + ), + copyStats: expect.objectContaining({ + phase: 'copying_base_schemas', + staleRecovery: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_STALE_ACTIVE_JOB', + workerId: 'worker-1', + previousState: 'copying', + staleAfterMs: 30 * 60 * 1000, + }), + }), + }), + }) + ); + expect(resumeSource).toHaveBeenCalledWith('sdmjstale'); + expect(cleanupTarget).toHaveBeenCalledWith('sdmjstale', 'stale_active_job', { + truncateSharedTables: true, + }); + }); + + it('copies base schemas for an existing migration job and records copy stats', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + startedAt: new Date('2026-05-06T00:00:00.000Z'), + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [ + { + schemaName: 'bsexxx', + relations: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: 1024, + estimatedRows: 10, + }, + ], + totalBytes: 1024, + estimatedRows: 10, + }, + ], + outOfScopeForeignKeys: [ + { + schemaName: 'bsexxx', + tableName: 'sheet1', + constraintName: 'fk_out_of_scope', + referencedSchemaName: 'bseyyy', + referencedTableName: 'sheet2', + }, + ], + estimatedTotalBytes: 1024, + estimatedTotalRows: 10, + }, + }); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('FROM "bsexxx"."sheet1"')) { + return { rows: [{ count: '10' }] }; + } + return { rows: [] }; + }); + const service = createService(); + + await expect( + service.copyBaseSchemasForJob('sdmjxxx', { + workDir: '/tmp/sdmjxxx', + jobs: 2, + timeoutMs: 1000, + }) + ).resolves.toMatchObject({ + phase: 'base_schemas_completed', + baseSchemas: { + schemaNames: ['bsexxx'], + copiedRelationCount: 1, + totalCopiedRows: 10, + copiedRelations: [ + expect.objectContaining({ + schemaName: 'bsexxx', + relationName: 'sheet1', + copiedRows: 10, + estimatedRows: 10, + }), + ], + }, + }); + + expect(dataDbClientManager.getDataDatabaseForSpace).toHaveBeenCalledWith('spcxxx', { + sourceConnectionId: null, + }); + expect(copyService.copyBaseSchemas).toHaveBeenCalledWith( + expect.objectContaining({ + sourceUrl: 'postgresql://source.example/teable', + targetUrl: dataUrl, + schemaNames: ['bsexxx'], + workDir: '/tmp/sdmjxxx', + jobs: 2, + strategy: 'pg_dump_restore', + excludedForeignKeys: [ + { + schemaName: 'bsexxx', + tableName: 'sheet1', + constraintName: 'fk_out_of_scope', + referencedSchemaName: 'bseyyy', + referencedTableName: 'sheet2', + }, + ], + processOptions: expect.objectContaining({ + timeoutMs: 1000, + pollMs: 5000, + pollFailureTimeoutMs: 30000, + pollTimeoutMs: 30000, + shouldCancel: expect.any(Function), + }), + hooks: expect.objectContaining({ + onDumpProgressPoll: expect.any(Function), + onRestoreProgressPoll: expect.any(Function), + }), + }) + ); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'copying', + copyStats: expect.objectContaining({ + phase: 'copying_base_schemas', + progress: expect.objectContaining({ + estimatedTotalBytes: 1024, + completedEstimatedBytes: 0, + estimatedTotalRows: 10, + completedEstimatedRows: 0, + completedSteps: 5, + totalSteps: 9, + etaMs: expect.any(Number), + }), + }), + }), + }) + ); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'copying', + copyStats: expect.objectContaining({ + phase: 'base_schemas_completed', + progress: expect.objectContaining({ + completedEstimatedBytes: 1024, + completedEstimatedRows: 10, + completedSteps: 6, + }), + baseSchemas: expect.objectContaining({ + copiedRelationCount: 1, + totalCopiedRows: 10, + strategy: 'pg_dump_restore', + dump: expect.objectContaining({ + command: 'pg_dump', + startedAt: '2026-05-06T00:00:00.000Z', + completedAt: '2026-05-06T00:00:01.000Z', + durationMs: 1000, + }), + restore: expect.objectContaining({ + command: 'pg_restore', + durationMs: 1000, + }), + }), + }), + }), + }) + ); + }); + + it('uses the inventory source connection when the current binding already points at the target', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + sourceConnectionId: null, + startedAt: new Date('2026-05-06T00:00:00.000Z'), + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + sourceDataDb: { + mode: 'default', + cacheKey: 'meta-fallback', + connectionId: null, + internalSchema: null, + isMetaFallback: true, + }, + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [ + { + schemaName: 'bsexxx', + relations: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: 1024, + estimatedRows: 10, + }, + ], + totalBytes: 1024, + estimatedRows: 10, + }, + ], + }, + }); + dataDbClientManager.getDataDatabaseForSpace.mockImplementation((_, options) => { + if ('sourceConnectionId' in (options ?? {})) { + return Promise.resolve({ + cacheKey: 'meta-fallback', + isMetaFallback: true, + url: 'postgresql://source.example/teable', + }); + } + return Promise.resolve({ + cacheKey: 'dcnxxx', + connectionId: 'dcnxxx', + internalSchema, + isMetaFallback: false, + url: `${dataUrl}?schema=${internalSchema}&options=-c+search_path%3D${internalSchema}`, + }); + }); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('FROM "bsexxx"."sheet1"')) { + return { rows: [{ count: '10' }] }; + } + return { rows: [] }; + }); + const service = createService(); + + await expect( + service.copyBaseSchemasForJob('sdmjxxx', { + workDir: '/tmp/sdmjxxx', + jobs: 2, + }) + ).resolves.toMatchObject({ + phase: 'base_schemas_completed', + }); + + expect(dataDbClientManager.getDataDatabaseForSpace).toHaveBeenCalledWith('spcxxx', { + sourceConnectionId: null, + }); + expect(copyService.copyBaseSchemas).toHaveBeenCalledWith( + expect.objectContaining({ + sourceUrl: 'postgresql://source.example/teable', + targetUrl: dataUrl, + }) + ); + }); + + it('records active pg_stat_progress_copy samples while dumping and restoring base schemas', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + startedAt: new Date('2026-05-06T00:00:00.000Z'), + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [ + { + schemaName: 'bsexxx', + relations: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: 1024, + estimatedRows: 10, + }, + ], + totalBytes: 1024, + estimatedRows: 10, + }, + ], + estimatedTotalBytes: 1024, + estimatedTotalRows: 10, + }, + }); + sourceClient.raw.mockImplementation((sql: string) => { + if (sql.includes('pg_stat_progress_copy')) { + return { + rows: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + command: 'COPY TO', + copyType: 'PIPE', + bytesProcessed: '512', + bytesTotal: '1024', + tuplesProcessed: '4', + tuplesExcluded: '0', + }, + ], + }; + } + return { rows: [] }; + }); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('pg_stat_progress_copy')) { + return { + rows: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + command: 'COPY FROM', + copyType: 'PIPE', + bytesProcessed: '768', + bytesTotal: '1024', + tuplesProcessed: '7', + tuplesExcluded: '0', + }, + ], + }; + } + if (sql.includes('FROM "bsexxx"."sheet1"')) { + return { rows: [{ count: '10' }] }; + } + return { rows: [] }; + }); + copyService.copyBaseSchemas.mockImplementation( + async (input: { + hooks?: { + onDumpProgressPoll?: () => Promise; + onRestoreProgressPoll?: () => Promise; + }; + }) => { + await input.hooks?.onDumpProgressPoll?.(); + await input.hooks?.onRestoreProgressPoll?.(); + return { + strategy: 'pg_dump_stream_restore', + stream: { + source: processResult('pg_dump'), + target: processResult('pg_restore'), + }, + }; + } + ); + const service = createService(); + + await expect( + service.copyBaseSchemasForJob('sdmjxxx', { + workDir: '/tmp/sdmjxxx', + timeoutMs: 1000, + progressPollMs: 250, + }) + ).resolves.toMatchObject({ + phase: 'base_schemas_completed', + }); + + const progressUpdates = prismaService.spaceDataDbMigrationJob.update.mock.calls + .map(([args]) => args) + .filter((args) => args.data.copyStats?.baseSchemas?.activeCopy); + + expect(progressUpdates).toHaveLength(2); + expect(progressUpdates[0]).toMatchObject({ + data: { + copyStats: { + phase: 'copying_base_schemas', + baseSchemas: { + strategy: 'pg_dump_stream_restore', + activeCopy: { + phase: 'dump', + activeRelationCount: 1, + activeRelations: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + command: 'COPY TO', + bytesProcessed: 512, + bytesTotal: 1024, + tuplesProcessed: 4, + estimatedRows: 10, + totalBytes: 1024, + }, + ], + }, + }, + }, + }, + }); + expect(progressUpdates[1]).toMatchObject({ + data: { + copyStats: { + baseSchemas: { + activeCopy: { + phase: 'restore', + activeRelations: [ + expect.objectContaining({ + command: 'COPY FROM', + bytesProcessed: 768, + tuplesProcessed: 7, + }), + ], + }, + }, + }, + }, + }); + }); + + it('records a base schema heartbeat before pg_stat_progress_copy inspection resolves', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + startedAt: new Date('2026-05-06T00:00:00.000Z'), + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [ + { + schemaName: 'bsexxx', + relations: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: 1024, + estimatedRows: 10, + }, + ], + totalBytes: 1024, + estimatedRows: 10, + }, + ], + estimatedTotalBytes: 1024, + estimatedTotalRows: 10, + }, + }); + let resolveProgressQuery: (value: { rows: unknown[] }) => void = vi.fn(); + const progressQuery = new Promise<{ rows: unknown[] }>((resolve) => { + resolveProgressQuery = resolve; + }); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('pg_stat_progress_copy')) { + return progressQuery; + } + if (sql.includes('FROM "bsexxx"."sheet1"')) { + return { rows: [{ count: '10' }] }; + } + return { rows: [] }; + }); + copyService.copyBaseSchemas.mockImplementation( + async (input: { + hooks?: { + onRestoreProgressPoll?: () => Promise; + }; + }) => { + const pollPromise = input.hooks?.onRestoreProgressPoll?.(); + await Promise.resolve(); + expect( + prismaService.spaceDataDbMigrationJob.update.mock.calls.some(([args]) => { + const heartbeat = args.data.copyStats?.baseSchemas?.heartbeat; + return heartbeat?.stage === 'progress_poll' && heartbeat?.phase === 'restore'; + }) + ).toBe(true); + resolveProgressQuery({ rows: [] }); + await pollPromise; + return { + strategy: 'pg_dump_stream_restore', + stream: { + source: processResult('pg_dump'), + target: processResult('pg_restore'), + }, + }; + } + ); + const service = createService(); + + await expect( + service.copyBaseSchemasForJob('sdmjxxx', { + workDir: '/tmp/sdmjxxx', + progressPollMs: 250, + }) + ).resolves.toMatchObject({ + phase: 'base_schemas_completed', + }); + }); + + it('keeps base schema copy fresh while collecting post-copy row stats', async () => { + vi.useFakeTimers(); + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + startedAt: new Date('2026-05-06T00:00:00.000Z'), + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [ + { + schemaName: 'bsexxx', + relations: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: 1024, + estimatedRows: 10, + }, + ], + totalBytes: 1024, + estimatedRows: 10, + }, + ], + estimatedTotalBytes: 1024, + estimatedTotalRows: 10, + }, + }); + let resolveCount: (value: { rows: { count: string }[] }) => void = vi.fn(); + const countRows = new Promise<{ rows: { count: string }[] }>((resolve) => { + resolveCount = resolve; + }); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('FROM "bsexxx"."sheet1"')) { + return countRows; + } + return { rows: [] }; + }); + const service = createService(); + + const promise = service.copyBaseSchemasForJob('sdmjxxx', { + workDir: '/tmp/sdmjxxx', + progressPollMs: 50, + }); + await vi.advanceTimersByTimeAsync(150); + + const heartbeatCount = prismaService.spaceDataDbMigrationJob.update.mock.calls.filter( + ([args]) => args.data.copyStats?.baseSchemas?.heartbeat?.stage === 'post_copy_stats' + ).length; + expect(heartbeatCount).toBeGreaterThanOrEqual(2); + + resolveCount({ rows: [{ count: '10' }] }); + await expect(promise).resolves.toMatchObject({ + phase: 'base_schemas_completed', + }); + vi.useRealTimers(); + }); + + it('marks the migration failed when base schema copy fails', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: [], + dbTableNames: [], + physicalSchemas: [], + }, + }); + copyService.copyBaseSchemas.mockRejectedValue( + new SpaceDataDbProcessError('pg_dump failed', { + ...processResult('pg_dump'), + exitCode: 1, + stderr: 'dump stderr', + }) + ); + const service = createService(); + + await expect( + service.copyBaseSchemasForJob('sdmjxxx', { + workDir: '/tmp/sdmjxxx', + }) + ).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', + copyStats: expect.objectContaining({ + phase: 'base_schemas_failed', + baseSchemas: expect.objectContaining({ + error: 'pg_dump failed: pg_dump - exit 1 - dump stderr', + failure: expect.objectContaining({ + type: 'process', + result: expect.objectContaining({ + command: 'pg_dump', + exitCode: 1, + stderr: 'dump stderr', + durationMs: 1000, + }), + }), + }), + }), + }), + }) + ); + }); + + it('preserves canceled state when a base schema copy process is killed by cancel', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + state: 'copying', + startedAt: new Date('2026-05-06T00:00:00.000Z'), + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: [], + dbTableNames: [], + physicalSchemas: [], + }, + }); + prismaService.spaceDataDbMigrationJob.findFirst.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + state: 'canceled', + }); + copyService.copyBaseSchemas.mockRejectedValue( + new SpaceDataDbProcessCanceledError({ + ...processResult('pg_dump'), + command: 'pg_dump', + args: [], + exitCode: null, + signal: 'SIGTERM', + stderr: '', + stdout: '', + }) + ); + const service = createService(); + + await expect( + service.copyBaseSchemasForJob('sdmjxxx', { + workDir: '/tmp/sdmjxxx', + }) + ).rejects.toBeInstanceOf(SpaceDataDbProcessCanceledError); + + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenCalledTimes(1); + expect(prismaService.spaceDataDbMigrationJob.update).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + state: 'failed', + }), + }) + ); + }); + + it('copies shared rows for an existing migration job and records copy stats', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + startedAt: new Date('2026-05-06T00:00:00.000Z'), + targetInternalSchema: internalSchema, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [ + { schemaName: 'bsexxx', relations: [], totalBytes: 1024, estimatedRows: 10 }, + ], + estimatedTotalBytes: 1024, + estimatedTotalRows: 10, + }, + }); + const service = createService(); + + await expect( + service.copySharedRowsForJob('sdmjxxx', { timeoutMs: 1000 }) + ).resolves.toMatchObject({ + phase: 'shared_rows_completed', + sharedTables: { + copiedTableCount: 1, + totalCopiedRows: 5, + copiedTables: [{ table: 'record_history', copiedRows: 5 }], + }, + }); + + expect(dataDbClientManager.getDataDatabaseForSpace).toHaveBeenCalledWith('spcxxx', { + sourceConnectionId: null, + }); + expect(copyService.copySharedTables).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + table: 'record_history', + sourceSql: expect.stringContaining('FROM "public"."record_history"'), + targetSql: expect.stringContaining(`COPY "${internalSchema}"."record_history"`), + }), + expect.objectContaining({ + table: 'computed_update_outbox', + sourceSql: expect.stringContaining(`"base_id" = ANY(ARRAY['bsexxx']::text[])`), + }), + ]), + expect.objectContaining({ + timeoutMs: 1000, + onPoll: expect.any(Function), + shouldCancel: expect.any(Function), + }), + expect.objectContaining({ + onTableCopied: expect.any(Function), + }) + ); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'copying', + copyStats: expect.objectContaining({ phase: 'copying_shared_rows' }), + }), + }) + ); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'copying', + copyStats: expect.objectContaining({ + phase: 'shared_rows_completed', + progress: expect.objectContaining({ + completedSteps: 7, + completedEstimatedBytes: 1024, + completedEstimatedRows: 10, + }), + sharedTables: expect.objectContaining({ + strategy: 'psql_copy', + totalCopiedRows: 5, + copiedTables: expect.arrayContaining([ + expect.objectContaining({ + strategy: 'psql_copy', + table: 'record_history', + copiedRows: 5, + source: expect.objectContaining({ + command: 'psql', + durationMs: 1000, + }), + target: expect.objectContaining({ + command: 'psql', + stdout: 'COPY 5\n', + durationMs: 1000, + }), + }), + ]), + }), + }), + }), + }) + ); + }); + + it('copies shared rows only for the default subset during repair migrations', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcrelated', + startedAt: new Date('2026-05-06T00:00:00.000Z'), + targetInternalSchema: internalSchema, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + relatedSpaces: { + primarySpaceId: 'spcrelated', + hasCrossSpaceLinks: true, + spaces: [ + { + spaceId: 'spcalready', + name: 'Already', + isPrimary: false, + baseIds: ['bsealready'], + tableIds: ['tblalready'], + dataDbMode: 'byodb', + }, + { + spaceId: 'spcrelated', + name: 'Related', + isPrimary: true, + baseIds: ['bserelated'], + tableIds: ['tblrelated'], + dataDbMode: 'default', + }, + ], + links: [], + }, + spaceIds: ['spcalready', 'spcrelated'], + copySpaceIds: ['spcrelated'], + baseIds: ['bserelated'], + tableIds: ['tblrelated'], + sharedTableIds: ['tblrelated', 'tbldeletedrelated'], + dbTableNames: ['bserelated.sheet1'], + physicalSchemas: [ + { schemaName: 'bserelated', relations: [], totalBytes: 1024, estimatedRows: 10 }, + ], + estimatedTotalBytes: 1024, + estimatedTotalRows: 10, + }, + }); + const service = createService(); + + await expect( + service.copySharedRowsForJob('sdmjxxx', { timeoutMs: 1000 }) + ).resolves.toMatchObject({ + phase: 'shared_rows_completed', + }); + + expect(copyService.copySharedTables).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + table: 'record_history', + sourceSql: expect.stringContaining( + `"table_id" = ANY(ARRAY['tblrelated', 'tbldeletedrelated']::text[])` + ), + }), + expect.objectContaining({ + 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() + ); + const plans = copyService.copySharedTables.mock.calls.at(-1)?.[0] as Array<{ + sourceSql: string; + }>; + expect(JSON.stringify(plans)).not.toContain('spcalready'); + expect(JSON.stringify(plans)).not.toContain('tblalready'); + expect(JSON.stringify(plans)).not.toContain('bsealready'); + expect(JSON.stringify(plans)).not.toContain('tbldeletedalready'); + }); + + it('copies shared rows through postgres_fdw when explicitly selected', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + startedAt: new Date('2026-05-06T00:00:00.000Z'), + targetInternalSchema: internalSchema, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [ + { schemaName: 'bsexxx', relations: [], totalBytes: 1024, estimatedRows: 10 }, + ], + estimatedTotalBytes: 1024, + estimatedTotalRows: 10, + }, + }); + const service = createService(); + + await expect( + service.copySharedRowsForJob('sdmjxxx', { timeoutMs: 1000, strategy: 'postgres_fdw' }) + ).resolves.toMatchObject({ + phase: 'shared_rows_completed', + sharedTables: { + strategy: 'postgres_fdw', + copiedTableCount: 1, + totalCopiedRows: 5, + copiedTables: [{ strategy: 'postgres_fdw', table: 'record_history', copiedRows: 5 }], + }, + }); + + expect(copyService.copySharedTables).not.toHaveBeenCalled(); + expect(copyService.copySharedTablesViaPostgresFdw).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + table: 'record_history', + sql: expect.stringContaining('CREATE EXTENSION IF NOT EXISTS postgres_fdw'), + target: expect.objectContaining({ command: 'psql' }), + }), + expect.objectContaining({ + table: 'computed_update_outbox', + sql: expect.stringContaining(`"base_id" = ANY(ARRAY['bsexxx']::text[])`), + }), + ]), + expect.objectContaining({ + timeoutMs: 1000, + shouldCancel: expect.any(Function), + }), + expect.objectContaining({ + onTableCopied: expect.any(Function), + }) + ); + }); + + it('marks the migration failed when shared row copy fails', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetInternalSchema: internalSchema, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + sharedTableIds: ['tblxxx', 'tbldeleted'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [], + }, + }); + copyService.copySharedTables.mockImplementation( + async ( + _plans: unknown[], + _options: unknown, + hooks: { + onTableCopied?: (result: unknown, index: number, total: number) => void | Promise; + } + ) => { + await hooks.onTableCopied?.( + { + table: 'record_history', + copiedRows: 5, + source: processResult('psql'), + target: processResult('psql', 'COPY 5\n'), + }, + 0, + 8 + ); + throw new SpaceDataDbProcessPipelineError('psql copy failed', { + label: 'shared-table:record_trash', + source: { + ...processResult('psql'), + exitCode: 1, + stderr: 'source failed', + }, + target: { + ...processResult('psql'), + exitCode: null, + signal: 'SIGTERM', + }, + }); + } + ); + const service = createService(); + + 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', + copyStats: expect.objectContaining({ + phase: 'shared_rows_failed', + sharedTables: expect.objectContaining({ + copiedTableCount: 1, + totalCopiedRows: 5, + copiedTables: [ + expect.objectContaining({ + table: 'record_history', + copiedRows: 5, + }), + ], + error: 'psql copy failed', + failure: expect.objectContaining({ + type: 'pipeline', + result: expect.objectContaining({ + label: 'shared-table:record_trash', + source: expect.objectContaining({ + command: 'psql', + exitCode: 1, + stderr: 'source failed', + }), + target: expect.objectContaining({ + command: 'psql', + signal: 'SIGTERM', + }), + }), + }), + }), + }), + }), + }) + ); + }); + + it('cleans target schemas and scoped shared rows idempotently before switch', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + state: 'failed', + targetInternalSchema: internalSchema, + copyStats: { phase: 'shared_rows_failed' }, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + sharedTableIds: ['tblxxx', 'tbldeleted'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [], + }, + }); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('FROM information_schema.schemata')) { + return { rows: [{ schemaName: 'bsexxx' }] }; + } + if (sql.includes('to_regclass')) { + return { rows: [{ exists: true }] }; + } + if (sql.includes('WITH deleted')) { + return { rows: [{ count: '3' }] }; + } + return { rows: [] }; + }); + const service = createService(); + + await expect( + service.cleanupTargetArtifactsForJob('sdmjxxx', 'copy_failed') + ).resolves.toMatchObject({ + reason: 'copy_failed', + baseSchemas: [{ schemaName: 'bsexxx', dropped: true }], + sharedTables: expect.arrayContaining([ + expect.objectContaining({ table: 'record_history', deletedRows: 3 }), + ]), + }); + + expect(targetClient.raw).toHaveBeenCalledWith('DROP SCHEMA IF EXISTS "bsexxx" CASCADE'); + expect( + targetClient.raw.mock.calls.some( + ([sql, bindings]) => + typeof sql === 'string' && + sql.includes('WITH deleted') && + JSON.stringify(bindings).includes('tbldeleted') + ) + ).toBe(true); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + copyStats: expect.objectContaining({ + phase: 'shared_rows_failed', + targetCleanup: expect.objectContaining({ + reason: 'copy_failed', + completedAt: expect.any(String), + }), + }), + }), + }) + ); + }); + + it('truncates target shared tables when the target connection is unbound', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + state: 'failed', + targetInternalSchema: internalSchema, + copyStats: { phase: 'shared_rows_failed' }, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + sharedTableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [], + }, + }); + 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', 'retry_before_start', { + truncateSharedTables: true, + }) + ).resolves.toMatchObject({ + reason: 'retry_before_start', + truncateSharedTables: true, + sharedTables: expect.arrayContaining([ + expect.objectContaining({ table: 'record_history', deletedRows: null, truncated: true }), + ]), + }); + + const truncateCalls = targetClient.raw.mock.calls.filter( + ([sql]) => typeof sql === 'string' && sql.startsWith('TRUNCATE TABLE ') + ); + expect(truncateCalls).toHaveLength(1); + expect(truncateCalls[0][0]).toContain(`"${internalSchema}"."record_history"`); + expect(truncateCalls[0][0]).toContain(`"${internalSchema}"."computed_update_outbox_seed"`); + expect(truncateCalls[0][0]).toContain(`"${internalSchema}"."computed_update_outbox"`); + expect( + targetClient.raw.mock.calls.some( + ([sql]) => typeof sql === 'string' && sql.includes('WITH deleted') + ) + ).toBe(false); + }); + + it('returns a clear conflict when retry target cleanup fails', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + state: 'failed', + targetInternalSchema: internalSchema, + copyStats: { phase: 'shared_rows_failed' }, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + sharedTableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [], + }, + }); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('to_regclass')) { + return { rows: [{ exists: true }] }; + } + if (sql.startsWith('TRUNCATE TABLE ')) { + throw new Error('cannot truncate a table referenced in a foreign key constraint'); + } + return { rows: [] }; + }); + const service = createService(); + + await expect( + service.cleanupTargetArtifactsForJob('sdmjxxx', 'retry_before_start', { + truncateSharedTables: true, + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_TARGET_CLEANUP_FAILED', + targetError: expect.stringContaining('foreign key constraint'), + }), + }); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + copyStats: expect.objectContaining({ + targetCleanup: expect.objectContaining({ + failedAt: expect.any(String), + error: expect.stringContaining('foreign key constraint'), + }), + }), + }), + }) + ); + }); + + it('refuses automatic target cleanup after a successful switch', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + state: 'succeeded', + switchOnCompletion: true, + targetInternalSchema: internalSchema, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + }, + }); + const service = createService(); + + await expect(service.cleanupTargetArtifactsForJob('sdmjxxx')).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + }); + + expect(targetClient.raw).not.toHaveBeenCalled(); + }); + + it('fails validation on base table row-count mismatch and does not switch routing', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetConnectionId: 'dcnxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: [], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [{ schemaName: 'bsexxx', relations: [], totalBytes: 1024 }], + }, + }); + mockValidationClient(sourceClient, 3); + mockValidationClient(targetClient, 2); + const service = createService(); + + await expect(service.validateAndSwitchJob('sdmjxxx')).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_VALIDATION_MISMATCH', + mismatches: [ + expect.objectContaining({ + object: 'base:bsexxx.sheet1', + sourceCount: 3, + targetCount: 2, + }), + ], + }), + }); + + expect(txClient.spaceDataDbBinding.upsert).not.toHaveBeenCalled(); + expect(txClient.dataDbConnection.update).not.toHaveBeenCalled(); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'failed', + lastError: 'Space data database migration validation failed', + validationStats: expect.objectContaining({ phase: 'validation_failed' }), + }), + }) + ); + expect(sourceClient.destroy).toHaveBeenCalled(); + expect(targetClient.destroy).toHaveBeenCalled(); + }); + + it('fails validation on base table column signature mismatch and does not switch routing', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetConnectionId: 'dcnxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: [], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [{ schemaName: 'bsexxx', relations: [], totalBytes: 1024 }], + }, + }); + mockValidationClient(sourceClient, 3, { columns: columnSignatureRows() }); + mockValidationClient(targetClient, 3, { + columns: columnSignatureRows([{ columnName: '__id', formattedType: 'text', notNull: true }]), + }); + const service = createService(); + + await expect(service.validateAndSwitchJob('sdmjxxx')).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_VALIDATION_MISMATCH', + mismatches: [ + expect.objectContaining({ + object: 'base:bsexxx.sheet1', + reason: 'column_signature_mismatch', + sourceColumns: expect.arrayContaining([ + expect.objectContaining({ columnName: 'fldName', formattedType: 'text' }), + ]), + targetColumns: [expect.objectContaining({ columnName: '__id', formattedType: 'text' })], + }), + ], + }), + }); + + expect(txClient.spaceDataDbBinding.upsert).not.toHaveBeenCalled(); + expect(txClient.dataDbConnection.update).not.toHaveBeenCalled(); + }); + + it('accepts restored table columns when dropped source columns leave ordinal gaps', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + sourceConnectionId: 'dcnsource', + targetConnectionId: 'dcnxxx', + switchOnCompletion: true, + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [{ schemaName: 'bsexxx', relations: [], totalBytes: 1024 }], + }, + }); + mockValidationClient(sourceClient, 3, { + columns: columnSignatureRows([ + { columnName: '__id', formattedType: 'text', notNull: true }, + { ordinalPosition: 3, columnName: 'fldName', formattedType: 'text' }, + ]), + }); + mockValidationClient(targetClient, 3, { + columns: columnSignatureRows([ + { columnName: '__id', formattedType: 'text', notNull: true }, + { columnName: 'fldName', formattedType: 'text' }, + ]), + }); + const service = createService(); + + await expect(service.validateAndSwitchJob('sdmjxxx')).resolves.toMatchObject({ + state: 'succeeded', + validationStats: expect.objectContaining({ + phase: 'validation_completed', + baseSchemas: [ + expect.objectContaining({ + object: 'base:bsexxx.sheet1', + sourceCount: 3, + targetCount: 3, + }), + ], + }), + }); + + expect(txClient.spaceDataDbBinding.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { spaceId: 'spcxxx' }, + }) + ); + }); + + it('allows existing related target rows during repair validation', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcrelated', + sourceConnectionId: null, + targetConnectionId: 'dcnxxx', + switchOnCompletion: true, + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + relatedSpaces: { + primarySpaceId: 'spcrelated', + hasCrossSpaceLinks: true, + spaces: [ + { + spaceId: 'spcalready', + name: 'Already', + isPrimary: false, + baseIds: ['bsealready'], + tableIds: ['tblalready'], + dataDbMode: 'byodb', + }, + { + spaceId: 'spcrelated', + name: 'Related', + isPrimary: true, + baseIds: ['bserelated'], + tableIds: ['tblrelated'], + dataDbMode: 'default', + }, + ], + links: [], + }, + spaceIds: ['spcalready', 'spcrelated'], + copySpaceIds: ['spcrelated'], + baseIds: ['bserelated'], + tableIds: ['tblrelated'], + dbTableNames: ['bserelated.sheet1'], + physicalSchemas: [{ schemaName: 'bserelated', relations: [], totalBytes: 1024 }], + }, + }); + const sourceCountSqls: string[] = []; + sourceClient.raw.mockImplementation((sql: string) => { + if (sql.includes('COUNT(*)')) { + sourceCountSqls.push(sql); + return { rows: [{ count: sql.includes('tblrelated') ? '3' : '0' }] }; + } + return { rows: [] }; + }); + const targetCountCalls: Array<{ sql: string; bindings?: unknown[] }> = []; + targetClient.raw.mockImplementation((sql: string, bindings?: unknown[]) => { + if (sql.includes('to_regprocedure') || sql.includes('__teable_data_schema_migrations')) { + return { rows: [{ exists: true }] }; + } + if (sql.includes('COUNT(*)')) { + targetCountCalls.push({ sql, bindings }); + if (sql.includes('NOT (') && sql.includes('tblalready')) { + return { rows: [{ count: '0' }] }; + } + return { rows: [{ count: sql.includes('tblrelated') ? '3' : '0' }] }; + } + return { rows: [] }; + }); + const service = createService(); + + await expect(service.validateAndSwitchJob('sdmjxxx')).resolves.toMatchObject({ + state: 'succeeded', + }); + + expect(sourceCountSqls.some((sql) => sql.includes('tblalready'))).toBe(false); + expect( + targetCountCalls.some( + (call) => call.sql.includes('NOT (') && JSON.stringify(call.bindings).includes('tblalready') + ) + ).toBe(true); + expect(txClient.spaceDataDbBinding.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { spaceId: 'spcalready' } }) + ); + expect(txClient.spaceDataDbBinding.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { spaceId: 'spcrelated' } }) + ); + }); + + it('scopes base table foreign key validation to schemas inside the migrated space', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + sourceConnectionId: 'dcnsource', + targetConnectionId: 'dcnxxx', + switchOnCompletion: true, + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [{ schemaName: 'bsexxx', relations: [], totalBytes: 1024 }], + outOfScopeForeignKeys: [ + { + schemaName: 'bsexxx', + tableName: 'sheet1', + constraintName: 'fk_out_of_scope', + referencedSchemaName: 'bseyyy', + referencedTableName: 'sheet2', + }, + ], + }, + }); + mockValidationClient(sourceClient, 3); + mockValidationClient(targetClient, 3); + const service = createService(); + + await expect(service.validateAndSwitchJob('sdmjxxx')).resolves.toMatchObject({ + state: 'succeeded', + }); + + expect(sourceClient.raw).toHaveBeenCalledWith( + expect.stringContaining('referenced_ns.nspname = ANY(?::text[])'), + ['bsexxx', 'sheet1', ['bsexxx']] + ); + expect(targetClient.raw).toHaveBeenCalledWith( + expect.stringContaining('referenced_ns.nspname = ANY(?::text[])'), + ['bsexxx', 'sheet1', ['bsexxx']] + ); + }); + + it('fails validation on base table index constraint or trigger signature mismatch', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetConnectionId: 'dcnxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: [], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [{ schemaName: 'bsexxx', relations: [], totalBytes: 1024 }], + }, + }); + mockValidationClient(sourceClient, 3, { + indexes: indexSignatureRows([ + { + indexName: 'sheet1_name_idx', + definition: 'CREATE INDEX sheet1_name_idx ON bsexxx.sheet1 USING btree ("fldName")', + }, + ]), + constraints: constraintSignatureRows([ + { + constraintName: 'sheet1_name_check', + constraintType: 'c', + definition: 'CHECK (length("fldName") > 0)', + }, + ]), + triggers: triggerSignatureRows([ + { + triggerName: 'sheet1_update_trigger', + definition: 'CREATE TRIGGER sheet1_update_trigger BEFORE UPDATE ON bsexxx.sheet1', + }, + ]), + }); + mockValidationClient(targetClient, 3); + const service = createService(); + + await expect(service.validateAndSwitchJob('sdmjxxx')).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_VALIDATION_MISMATCH', + mismatches: expect.arrayContaining([ + expect.objectContaining({ + object: 'base:bsexxx.sheet1', + reason: 'index_signature_mismatch', + sourceIndexes: expect.arrayContaining([ + expect.objectContaining({ indexName: 'sheet1_name_idx' }), + ]), + targetIndexes: [], + }), + expect.objectContaining({ + object: 'base:bsexxx.sheet1', + reason: 'constraint_signature_mismatch', + sourceConstraints: expect.arrayContaining([ + expect.objectContaining({ constraintName: 'sheet1_name_check' }), + ]), + targetConstraints: [], + }), + expect.objectContaining({ + object: 'base:bsexxx.sheet1', + reason: 'trigger_signature_mismatch', + sourceTriggers: expect.arrayContaining([ + expect.objectContaining({ triggerName: 'sheet1_update_trigger' }), + ]), + targetTriggers: [], + }), + ]), + }), + }); + + expect(txClient.spaceDataDbBinding.upsert).not.toHaveBeenCalled(); + expect(txClient.dataDbConnection.update).not.toHaveBeenCalled(); + }); + + it('fails validation when the target data DB baseline schema version is not current', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetConnectionId: 'dcnxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: [], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [{ schemaName: 'bsexxx', relations: [], totalBytes: 1024 }], + }, + }); + mockValidationClient(sourceClient, 3); + mockValidationClient(targetClient, 3); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('FROM pg_class c')) { + return { + rows: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + }, + ], + }; + } + if (sql.includes('FROM pg_attribute a')) { + return { rows: columnSignatureRows() }; + } + if (sql.includes('FROM "bsexxx"."sheet1"')) { + return { rows: [{ count: '3' }] }; + } + if (sql.includes('to_regprocedure')) { + return { rows: [{ exists: true }] }; + } + if (sql.includes('__teable_data_schema_migrations')) { + return { rows: [{ exists: false }] }; + } + if (sql.includes('COUNT(*)')) { + return { rows: [{ count: '0' }] }; + } + return { rows: [] }; + }); + const service = createService(); + + await expect(service.validateAndSwitchJob('sdmjxxx')).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_VALIDATION_MISMATCH', + mismatches: [ + expect.objectContaining({ + object: `schema:${internalSchema}.__teable_data_schema_migrations`, + reason: 'target_schema_version_mismatch', + }), + ], + }), + }); + + expect(txClient.spaceDataDbBinding.upsert).not.toHaveBeenCalled(); + expect(targetClient.raw).toHaveBeenCalledWith( + expect.stringContaining(`"${internalSchema}"."__teable_data_schema_migrations"`), + [schemaVersion] + ); + }); + + it('fails validation when dry-run routing does not resolve to the target BYODB connection', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetConnectionId: 'dcnxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: [], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [{ schemaName: 'bsexxx', relations: [], totalBytes: 1024 }], + }, + }); + mockValidationClient(sourceClient, 3); + mockValidationClient(targetClient, 3); + dataDbClientManager.getDataDatabaseForSpace.mockImplementation((_, options) => { + if (options?.previewBinding) { + return Promise.resolve({ + cacheKey: 'meta-fallback', + isMetaFallback: true, + url: 'postgresql://source.example/teable', + }); + } + return Promise.resolve({ url: 'postgresql://source.example/teable' }); + }); + const service = createService(); + + await expect(service.validateAndSwitchJob('sdmjxxx')).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_VALIDATION_MISMATCH', + mismatches: [ + expect.objectContaining({ + object: 'route:spcxxx', + reason: 'target_route_smoke_failed', + }), + ], + }), + }); + + expect(txClient.spaceDataDbBinding.upsert).not.toHaveBeenCalled(); + expect(dataDbClientManager.getDataDatabaseForSpace).toHaveBeenCalledWith('spcxxx', { + previewBinding: expect.objectContaining({ + spaceId: 'spcxxx', + connectionId: 'dcnxxx', + internalSchema, + }), + }); + }); + + it('fails validation when copied target shared tables contain out-of-scope rows', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetConnectionId: 'dcnxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [{ schemaName: 'bsexxx', relations: [], totalBytes: 1024 }], + }, + }); + mockValidationClient(sourceClient, 3); + mockValidationClient(targetClient, 3); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('FROM pg_class c')) { + return { + rows: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + }, + ], + }; + } + if (sql.includes('FROM pg_attribute a')) { + return { rows: columnSignatureRows() }; + } + if (sql.includes('FROM "bsexxx"."sheet1"')) { + return { rows: [{ count: '3' }] }; + } + if (sql.includes('to_regprocedure')) { + return { rows: [{ exists: true }] }; + } + if (sql.includes('__teable_data_schema_migrations')) { + return { rows: [{ exists: true }] }; + } + if ( + sql.includes(`FROM "${internalSchema}"."record_history"`) && + sql.includes('NOT ("table_id" = ANY') + ) { + return { rows: [{ count: '1' }] }; + } + if (sql.includes('COUNT(*)')) { + return { rows: [{ count: '0' }] }; + } + return { rows: [] }; + }); + const service = createService(); + + await expect(service.validateAndSwitchJob('sdmjxxx')).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_VALIDATION_MISMATCH', + mismatches: [ + expect.objectContaining({ + object: 'shared:record_history', + reason: 'out_of_scope_target_rows', + targetCount: 1, + }), + ], + }), + }); + + expect(txClient.spaceDataDbBinding.upsert).not.toHaveBeenCalled(); + }); + + it('validates copied data without switching the space binding by default', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + sourceConnectionId: 'dcnsource', + targetConnectionId: 'dcnxxx', + switchOnCompletion: false, + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [{ schemaName: 'bsexxx', relations: [], totalBytes: 1024 }], + }, + }); + mockValidationClient(sourceClient, 3); + mockValidationClient(targetClient, 3); + const service = createService(); + + await expect(service.validateAndSwitchJob('sdmjxxx')).resolves.toMatchObject({ + state: 'succeeded', + validationStats: expect.objectContaining({ + phase: 'validation_completed', + switchOnCompletion: false, + switched: false, + }), + }); + + expect(prismaService.spaceDataDbMigrationJob.update).not.toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ state: 'switching' }), + }) + ); + 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'] + ); + expect(txClient.dataDbConnection.update).toHaveBeenCalledWith({ + where: { id: 'dcnxxx' }, + data: expect.objectContaining({ + status: 'ready', + lastError: null, + }), + }); + expect(txClient.spaceDataDbMigrationJob.update).toHaveBeenCalledWith({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'succeeded', + validationStats: expect.objectContaining({ + switchOnCompletion: false, + switched: false, + }), + lastError: null, + }), + }); + expect(dataDbClientManager.invalidateConnection).toHaveBeenCalledWith('dcnxxx'); + expect(dataDbClientManager.invalidateConnection).not.toHaveBeenCalledWith('dcnsource'); + }); + + it('validates copied data and switches the space binding to the target BYODB connection', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + sourceConnectionId: 'dcnsource', + targetConnectionId: 'dcnxxx', + switchOnCompletion: true, + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [{ schemaName: 'bsexxx', relations: [], totalBytes: 1024 }], + }, + }); + mockValidationClient(sourceClient, 3); + mockValidationClient(targetClient, 3); + const service = createService(); + + await expect(service.validateAndSwitchJob('sdmjxxx')).resolves.toMatchObject({ + state: 'succeeded', + validationStats: expect.objectContaining({ + phase: 'validation_completed', + switchOnCompletion: true, + switched: true, + switchedAt: expect.any(String), + routeSmoke: expect.objectContaining({ + ok: true, + connectionId: 'dcnxxx', + internalSchema, + }), + baseSchemas: [ + expect.objectContaining({ + object: 'base:bsexxx.sheet1', + sourceCount: 3, + targetCount: 3, + }), + ], + }), + }); + + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'validating', + validationStats: expect.objectContaining({ phase: 'validating_copy' }), + }), + }) + ); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'validating', + validationStats: expect.objectContaining({ phase: 'validation_completed' }), + }), + }) + ); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ state: 'switching' }), + }) + ); + expect(txClient.dataDbConnection.update).toHaveBeenCalledWith({ + where: { id: 'dcnxxx' }, + data: expect.objectContaining({ + status: 'ready', + lastError: null, + }), + }); + expect(txClient.spaceDataDbBinding.upsert).toHaveBeenCalledWith({ + where: { spaceId: 'spcxxx' }, + create: { + spaceId: 'spcxxx', + dataDbConnectionId: 'dcnxxx', + mode: 'byodb', + state: 'ready', + createdBy: 'usrxxx', + }, + update: { + dataDbConnectionId: 'dcnxxx', + mode: 'byodb', + state: 'ready', + }, + }); + expect(txClient.spaceDataDbMigrationJob.update).toHaveBeenCalledWith({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'succeeded', + lastError: null, + }), + }); + expect(targetClient.raw).toHaveBeenCalledWith( + expect.stringContaining(`DELETE FROM "${internalSchema}"."computed_update_pause_scope"`), + ['space', ['spcxxx'], 'space-data-db-migration:sdmjxxx'] + ); + expect(dataDbClientManager.invalidateConnection).toHaveBeenCalledWith('dcnxxx'); + expect(dataDbClientManager.invalidateConnection).toHaveBeenCalledWith('dcnsource'); + }); + + it('keeps validation fresh while row counts are running', async () => { + vi.useFakeTimers(); + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + sourceConnectionId: 'dcnsource', + targetConnectionId: 'dcnxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [{ schemaName: 'bsexxx', relations: [], totalBytes: 1024 }], + }, + }); + mockValidationClient(sourceClient, 3); + let resolveTargetCount: (value: { rows: { count: string }[] }) => void = vi.fn(); + const targetCountRows = new Promise<{ rows: { count: string }[] }>((resolve) => { + resolveTargetCount = resolve; + }); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('FROM pg_class c')) { + return { + rows: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + }, + ], + }; + } + if (sql.includes('FROM pg_attribute a')) { + return { rows: columnSignatureRows() }; + } + if (sql.includes('FROM pg_index i')) { + return { rows: [] }; + } + if (sql.includes('FROM pg_constraint con')) { + return { rows: [] }; + } + if (sql.includes('FROM pg_trigger tg')) { + return { rows: [] }; + } + if (sql.includes('FROM "bsexxx"."sheet1"')) { + return targetCountRows; + } + if (sql.includes('to_regprocedure')) { + return { rows: [{ exists: true }] }; + } + if (sql.includes('__teable_data_schema_migrations')) { + return { rows: [{ exists: true }] }; + } + if (sql.includes('COUNT(*)')) { + return { rows: [{ count: '0' }] }; + } + return { rows: [] }; + }); + const service = createService(); + + const promise = service.validateAndSwitchJob('sdmjxxx'); + await vi.advanceTimersByTimeAsync(15_000); + + const heartbeatCount = prismaService.spaceDataDbMigrationJob.update.mock.calls.filter( + ([args]) => args.data.validationStats?.heartbeat?.stage === 'validating_copy' + ).length; + expect(heartbeatCount).toBeGreaterThanOrEqual(2); + + resolveTargetCount({ rows: [{ count: '3' }] }); + await expect(promise).resolves.toMatchObject({ + state: 'succeeded', + }); + vi.useRealTimers(); + }); + + it('waits for active source computed tasks to finish or become reclaimable', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [], + }, + }); + sourceClient.raw + .mockResolvedValueOnce({ + rows: [ + { + activeCount: '1', + reclaimableCount: '0', + oldestActiveLockedAt: '2026-05-06T00:00:00.000Z', + }, + ], + }) + .mockResolvedValueOnce({ + rows: [ + { + activeCount: '0', + reclaimableCount: '1', + oldestActiveLockedAt: null, + }, + ], + }); + const service = createService(); + + await expect( + service.waitForSourceComputedDrainForJob('sdmjxxx', { + timeoutMs: 50, + pollMs: 1, + processingLeaseMs: 120_000, + }) + ).resolves.toMatchObject({ + activeCount: 0, + reclaimableCount: 1, + }); + + expect(sourceClient.raw).toHaveBeenCalledTimes(2); + expect(sourceClient.raw).toHaveBeenCalledWith( + expect.stringContaining(`FROM "public"."computed_update_outbox"`), + [expect.any(Date), expect.any(Date), expect.any(Date), ['bsexxx']] + ); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + copyStats: expect.objectContaining({ + phase: 'computed_drained', + computedDrain: expect.objectContaining({ + activeCount: 0, + reclaimableCount: 1, + }), + }), + }), + }) + ); + expect(sourceClient.destroy).toHaveBeenCalled(); + }); + + it('fails the migration when source computed tasks stay active past the drain timeout', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [], + }, + }); + sourceClient.raw.mockResolvedValue({ + rows: [ + { + activeCount: '2', + reclaimableCount: '0', + oldestActiveLockedAt: '2026-05-06T00:00:00.000Z', + }, + ], + }); + const service = createService(); + + await expect( + service.waitForSourceComputedDrainForJob('sdmjxxx', { + timeoutMs: 0, + pollMs: 1, + processingLeaseMs: 120_000, + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_COMPUTED_DRAIN_TIMEOUT', + activeCount: 2, + }), + }); + + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'failed', + lastError: expect.stringContaining('Timed out waiting for source computed tasks'), + copyStats: expect.objectContaining({ + phase: 'computed_drain_timeout', + }), + }), + }) + ); + expect(sourceClient.destroy).toHaveBeenCalled(); + }); + + it('marks the migration failed when source computed drain inspection fails', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [], + }, + }); + sourceClient.raw.mockRejectedValue(new Error('source query failed')); + const service = createService(); + + await expect( + service.waitForSourceComputedDrainForJob('sdmjxxx', { + timeoutMs: 50, + pollMs: 1, + processingLeaseMs: 120_000, + }) + ).rejects.toThrow('source query failed'); + + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'failed', + lastError: 'source query failed', + copyStats: expect.objectContaining({ + phase: 'computed_drain_failed', + }), + }), + }) + ); + expect(sourceClient.destroy).toHaveBeenCalled(); + }); + + it('waits for open schema operations for the space to finish before copy', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [], + }, + }); + prismaService.schemaOperation.count.mockResolvedValueOnce(1).mockResolvedValueOnce(0); + prismaService.schemaOperation.findMany.mockResolvedValueOnce([ + { + id: 'sgoxxx', + status: 'running', + phase: 'alter_table', + baseId: 'bsexxx', + tableId: 'tblxxx', + lockedAt: new Date('2026-05-06T00:00:00.000Z'), + lockedBy: 'worker-1', + lastModifiedTime: new Date('2026-05-06T00:00:00.000Z'), + }, + ]); + const service = createService(); + + await expect( + service.waitForSchemaOperationsForJob('sdmjxxx', { + timeoutMs: 50, + pollMs: 1, + }) + ).resolves.toMatchObject({ + openCount: 0, + }); + + expect(prismaService.schemaOperation.count).toHaveBeenCalledWith({ + where: expect.objectContaining({ + status: { in: ['pending', 'running', 'error'] }, + }), + }); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + copyStats: expect.objectContaining({ + phase: 'schema_operations_drained', + schemaOperations: expect.objectContaining({ openCount: 0 }), + }), + }), + }) + ); + }); + + it('fails the migration when schema operations remain open past the drain timeout', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [], + }, + }); + prismaService.schemaOperation.count.mockResolvedValue(2); + prismaService.schemaOperation.findMany.mockResolvedValue([ + { + id: 'sgoxxx', + status: 'running', + phase: 'alter_table', + baseId: 'bsexxx', + tableId: 'tblxxx', + lockedAt: new Date('2026-05-06T00:00:00.000Z'), + lockedBy: 'worker-1', + lastModifiedTime: new Date('2026-05-06T00:00:00.000Z'), + }, + ]); + const service = createService(); + + await expect( + service.waitForSchemaOperationsForJob('sdmjxxx', { + timeoutMs: 0, + pollMs: 1, + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_SCHEMA_OPERATION_DRAIN_TIMEOUT', + openCount: 2, + }), + }); + + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'failed', + lastError: expect.stringContaining('Timed out waiting for schema operations'), + copyStats: expect.objectContaining({ + phase: 'schema_operation_drain_timeout', + }), + }), + }) + ); + }); + + it('waits for provisioning resources and import queue jobs before copy', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [], + }, + }); + prismaService.base.count.mockResolvedValueOnce(1).mockResolvedValueOnce(0); + prismaService.base.findMany + .mockResolvedValueOnce([ + { + id: 'bsexxx', + provisionState: 'pending', + lastModifiedTime: new Date('2026-05-06T00:00:00.000Z'), + }, + ]) + .mockResolvedValueOnce([]); + const importJob = { + id: 'import-table-csv-chunk:tblxxx:abc123', + data: { + baseId: 'bsexxx', + table: { id: 'tblxxx', name: 'Sheet 1' }, + }, + timestamp: new Date('2026-05-06T00:00:00.000Z').getTime(), + getState: vi.fn().mockResolvedValue('active'), + }; + const tableImportCsvChunkQueue = { + getJobs: vi.fn().mockResolvedValueOnce([importJob]).mockResolvedValueOnce([]), + }; + const service = createService(undefined, { tableImportCsvChunkQueue }); + + await expect( + service.waitForBackgroundWritersForJob('sdmjxxx', { + timeoutMs: 50, + pollMs: 1, + }) + ).resolves.toMatchObject({ + openCount: 0, + }); + + expect(prismaService.base.count).toHaveBeenCalledWith({ + where: expect.objectContaining({ + provisionState: { in: ['pending', 'deleting', 'error'] }, + }), + }); + expect(tableImportCsvChunkQueue.getJobs).toHaveBeenCalledWith( + ['waiting', 'active', 'delayed', 'prioritized', 'waiting-children'], + 0, + 99, + false + ); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + copyStats: expect.objectContaining({ + phase: 'background_writers_drained', + backgroundWriters: expect.objectContaining({ openCount: 0 }), + }), + }), + }) + ); + }); + + it('ignores soft-deleted provisioning resources outside the migration inventory', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bseactive'], + tableIds: ['tblactive'], + dbTableNames: ['bseactive.sheet1'], + physicalSchemas: [], + }, + }); + prismaService.base.count.mockResolvedValue(0); + prismaService.base.findMany.mockResolvedValue([]); + + const service = createService(); + + await expect( + service.waitForBackgroundWritersForJob('sdmjxxx', { + timeoutMs: 0, + pollMs: 1, + }) + ).resolves.toMatchObject({ + openCount: 0, + }); + + expect(prismaService.base.count).toHaveBeenCalledWith({ + where: expect.objectContaining({ + deletedTime: null, + provisionState: { in: ['pending', 'deleting', 'error'] }, + OR: expect.arrayContaining([{ spaceId: 'spcxxx' }, { id: { in: ['bseactive'] } }]), + }), + }); + expect(prismaService.tableMeta.count).toHaveBeenCalledWith({ + where: expect.objectContaining({ + deletedTime: null, + provisionState: { in: ['pending', 'deleting', 'error'] }, + }), + }); + expect(prismaService.field.count).toHaveBeenCalledWith({ + where: expect.objectContaining({ + deletedTime: null, + tableId: { in: ['tblactive'] }, + provisionState: { in: ['pending', 'deleting', 'error'] }, + }), + }); + }); + + it('fails the migration when background writer queue inspection hangs', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [], + }, + }); + const baseImportCsvQueue = { + getJobs: vi.fn().mockImplementation(() => new Promise(() => undefined)), + }; + const service = createService(undefined, { baseImportCsvQueue }); + + await expect( + service.waitForBackgroundWritersForJob('sdmjxxx', { + timeoutMs: 1000, + pollMs: 1, + probeTimeoutMs: 1, + }) + ).rejects.toThrow('Timed out inspecting import queues after 1ms'); + + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'failed', + lastError: 'Timed out inspecting import queues after 1ms', + copyStats: expect.objectContaining({ + phase: 'background_writer_drain_failed', + }), + }), + }) + ); + }); + + it('scans open queue jobs in bounded batches while draining background writers', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [], + }, + }); + const unrelatedJob = { + id: 'import-table-csv-chunk:tblother:abc123', + data: { + baseId: 'bseother', + table: { id: 'tblother', name: 'Other' }, + }, + getState: vi.fn().mockResolvedValue('waiting'), + }; + const scopedJob = { + id: 'import-table-csv-chunk:tblxxx:def456', + data: { + baseId: 'bsexxx', + table: { id: 'tblxxx', name: 'Sheet 1' }, + }, + getState: vi.fn().mockResolvedValue('active'), + }; + const tableImportCsvChunkQueue = { + getJobs: vi.fn().mockResolvedValueOnce([unrelatedJob]).mockResolvedValueOnce([scopedJob]), + }; + const service = createService(undefined, { tableImportCsvChunkQueue }); + + await expect( + service.waitForBackgroundWritersForJob('sdmjxxx', { + timeoutMs: 0, + pollMs: 1, + queueScanBatchSize: 1, + queueScanLimit: 2, + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_BACKGROUND_WRITER_DRAIN_TIMEOUT', + openCount: 1, + queueJobCount: 1, + }), + }); + + expect(tableImportCsvChunkQueue.getJobs).toHaveBeenNthCalledWith( + 1, + ['waiting', 'active', 'delayed', 'prioritized', 'waiting-children'], + 0, + 0, + false + ); + expect(tableImportCsvChunkQueue.getJobs).toHaveBeenNthCalledWith( + 2, + ['waiting', 'active', 'delayed', 'prioritized', 'waiting-children'], + 1, + 1, + false + ); + }); + + it('fails the migration when background writers remain open past the drain timeout', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [], + }, + }); + const importJob = { + id: 'base_import_csv_job', + data: { + baseId: 'bsexxx', + tableIdMap: { + oldTableId: 'tblxxx', + }, + }, + getState: vi.fn().mockResolvedValue('waiting'), + }; + const baseImportCsvQueue = { + getJobs: vi.fn().mockResolvedValue([importJob]), + }; + const service = createService(undefined, { baseImportCsvQueue }); + + await expect( + service.waitForBackgroundWritersForJob('sdmjxxx', { + timeoutMs: 0, + pollMs: 1, + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_BACKGROUND_WRITER_DRAIN_TIMEOUT', + openCount: 1, + queueJobCount: 1, + }), + }); + + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'failed', + lastError: expect.stringContaining('Timed out waiting for background writers'), + copyStats: expect.objectContaining({ + phase: 'background_writer_drain_timeout', + }), + }), + }) + ); + }); + + it('fails before copy when the source inventory changed after freeze', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + inventory: { + sourceDataDb: { + mode: 'default', + cacheKey: 'meta-fallback', + connectionId: null, + internalSchema: null, + isMetaFallback: true, + }, + targetDataDb: { + internalSchema, + }, + baseIds: ['bsexxx'], + tableIds: ['tblold'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [ + { + schemaName: 'bsexxx', + relations: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: 1024, + estimatedRows: 10, + }, + ], + totalBytes: 1024, + estimatedRows: 10, + }, + ], + }, + }); + prismaService.space.findMany.mockResolvedValue([ + { + id: 'spcxxx', + name: 'Space', + baseGroup: [ + { + id: 'bsexxx', + tables: [{ id: 'tblnew' }], + }, + ], + }, + ]); + prismaService.tableMeta.findMany.mockResolvedValue([ + { id: 'tblnew', dbTableName: 'bsexxx.sheet2' }, + ]); + const service = createService(); + + await expect(service.assertSourceInventoryUnchangedForJob('sdmjxxx')).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_INVENTORY_CHANGED', + mismatches: expect.arrayContaining([ + expect.objectContaining({ + object: 'tableIds', + reason: 'inventory_changed', + added: ['tblnew'], + removed: ['tblold'], + }), + expect.objectContaining({ + object: 'dbTableNames', + added: ['bsexxx.sheet2'], + removed: ['bsexxx.sheet1'], + }), + ]), + }), + }); + + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'failed', + lastError: expect.stringContaining('Source inventory changed'), + copyStats: expect.objectContaining({ + phase: 'source_inventory_changed', + }), + }), + }) + ); + expect(copyService.copyBaseSchemas).not.toHaveBeenCalled(); + }); + + it('fails before copy when deleted-table shared scope changed after freeze', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + inventory: { + sourceDataDb: { + mode: 'default', + cacheKey: 'meta-fallback', + connectionId: null, + internalSchema: null, + isMetaFallback: true, + }, + targetDataDb: { + internalSchema, + }, + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + sharedTableIds: ['tblxxx'], + relatedSharedTableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [ + { + schemaName: 'bsexxx', + relations: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: 1024, + estimatedRows: 10, + }, + ], + totalBytes: 1024, + estimatedRows: 10, + }, + ], + }, + }); + prismaService.tableMeta.findMany.mockImplementation((args?: unknown) => { + const where = (args as { where?: { baseId?: { in?: string[] }; id?: { in?: string[] } } }) + ?.where; + if (where?.baseId?.in) { + return Promise.resolve([{ id: 'tblxxx' }, { id: 'tbldeleted' }]); + } + return Promise.resolve([{ id: 'tblxxx', dbTableName: 'bsexxx.sheet1' }]); + }); + const service = createService(); + + await expect(service.assertSourceInventoryUnchangedForJob('sdmjxxx')).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_INVENTORY_CHANGED', + mismatches: expect.arrayContaining([ + expect.objectContaining({ + object: 'sharedTableIds', + added: ['tbldeleted'], + }), + ]), + }), + }); + }); + + it('does not treat equivalent extension dependency objects as changed when JSON key order differs', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + inventory: { + sourceDataDb: { + isMetaFallback: true, + internalSchema: null, + connectionId: null, + cacheKey: 'meta-fallback', + mode: 'default', + }, + targetDataDb: { + internalSchema, + }, + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [ + { + estimatedRows: 10, + totalBytes: 1024, + relations: [ + { + estimatedRows: 10, + totalBytes: 1024, + relationKind: 'table', + relationName: 'sheet1', + schemaName: 'bsexxx', + }, + ], + schemaName: 'bsexxx', + }, + ], + postgresExtensionDependencies: [ + { + sourceObjects: ['bsexxx.sheet1.idx_trgm_sheet1_fldName'], + accessMethod: 'gin', + objectName: 'gin_trgm_ops', + schemaName: 'public', + objectType: 'operator_class', + extensionName: 'pg_trgm', + }, + ], + estimatedTotalBytes: 1024, + estimatedTotalRows: 10, + }, + }); + sourceDataPrisma.$queryRawUnsafe + .mockResolvedValueOnce([ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: '1024', + estimatedRows: '10', + }, + ]) + .mockResolvedValueOnce([ + { + extensionName: 'pg_trgm', + objectType: 'operator_class', + schemaName: 'public', + objectName: 'gin_trgm_ops', + accessMethod: 'gin', + sourceSchemaName: 'bsexxx', + sourceRelationName: 'sheet1', + sourceIndexName: 'idx_trgm_sheet1_fldName', + }, + ]); + const service = createService(); + + await expect(service.assertSourceInventoryUnchangedForJob('sdmjxxx')).resolves.toBeUndefined(); + + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + lastError: null, + copyStats: expect.objectContaining({ + phase: 'source_inventory_verified', + }), + }), + }) + ); + }); + + it('fails before copy when the temp work directory has insufficient free space', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [ + { + schemaName: 'bsexxx', + relations: [], + totalBytes: 2048, + estimatedRows: 10, + }, + ], + estimatedTotalBytes: 2048, + estimatedTotalRows: 10, + }, + }); + const statfs = vi.fn().mockResolvedValue({ + bavail: 3, + bfree: 3, + bsize: 1024, + }); + const service = createService(statfs as never); + + await expect( + service.assertTempWorkDirCapacityForJob('sdmjxxx', '/tmp/sdmjxxx', { + multiplier: 2, + minFreeBytes: 0, + baseSchemaCopyStrategy: 'pg_dump_restore', + }) + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_TEMP_DISK_INSUFFICIENT', + requiredBytes: 4096, + availableBytes: 3072, + }), + }); + + expect(statfs).toHaveBeenCalledWith('/tmp/sdmjxxx'); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'failed', + lastError: expect.stringContaining('Insufficient temp disk space'), + copyStats: expect.objectContaining({ + phase: 'temp_disk_insufficient', + }), + }), + }) + ); + expect(copyService.copyBaseSchemas).not.toHaveBeenCalled(); + }); + + it('does not require full dump space for streaming base-schema copy', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [], + estimatedTotalBytes: 2048, + estimatedTotalRows: 10, + }, + }); + const statfs = vi.fn().mockResolvedValue({ + bavail: 3, + bfree: 3, + bsize: 1024, + }); + const service = createService(statfs as never); + + await expect( + service.assertTempWorkDirCapacityForJob('sdmjxxx', '/tmp/sdmjxxx', { + multiplier: 2, + minFreeBytes: 1024, + baseSchemaCopyStrategy: 'pg_dump_stream_restore', + }) + ).resolves.toBeUndefined(); + + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + copyStats: expect.objectContaining({ + phase: 'temp_disk_checked', + tempDisk: expect.objectContaining({ + strategy: 'pg_dump_stream_restore', + requiresFullDumpSpace: false, + requiredBytes: 1024, + availableBytes: 3072, + }), + }), + }), + }) + ); + }); + + it('runs a migration job through copy, validation, and switch phases in order', async () => { + const service = createService(); + const pauseSource = vi.spyOn(service, 'pauseSourceComputedForJob').mockResolvedValue({ + created: true, + } as never); + const waitForSourceComputedDrain = vi + .spyOn(service, 'waitForSourceComputedDrainForJob') + .mockResolvedValue({ + activeCount: 0, + reclaimableCount: 0, + } as never); + const waitForSchemaOperations = vi + .spyOn(service, 'waitForSchemaOperationsForJob') + .mockResolvedValue({ + openCount: 0, + } as never); + const waitForBackgroundWriters = vi + .spyOn(service, 'waitForBackgroundWritersForJob') + .mockResolvedValue({ + openCount: 0, + } as never); + const assertSourceInventoryUnchanged = vi + .spyOn(service, 'assertSourceInventoryUnchangedForJob') + .mockResolvedValue(undefined); + const assertTempWorkDirCapacity = vi + .spyOn(service, 'assertTempWorkDirCapacityForJob') + .mockResolvedValue(undefined); + const resumeSource = vi.spyOn(service, 'resumeSourceComputedForJob').mockResolvedValue({ + deleted: 0, + } as never); + const copyBaseSchemas = vi.spyOn(service, 'copyBaseSchemasForJob').mockResolvedValue({ + phase: 'base_schemas_completed', + } as never); + const copySharedRows = vi.spyOn(service, 'copySharedRowsForJob').mockResolvedValue({ + phase: 'shared_rows_completed', + } as never); + const validateAndSwitch = vi.spyOn(service, 'validateAndSwitchJob').mockResolvedValue({ + state: 'succeeded', + } as never); + + await expect( + service.runMigrationJob('sdmjxxx', { + workDir: '/tmp/sdmjxxx', + jobs: 2, + timeoutMs: 1000, + }) + ).resolves.toEqual({ state: 'succeeded' }); + + expect(copyService.assertPostgresToolsAvailable).toHaveBeenCalledWith( + 'pg_dump_stream_restore', + expect.objectContaining({ + timeoutMs: 1000, + shouldCancel: expect.any(Function), + }) + ); + expect(pauseSource).toHaveBeenCalledWith('sdmjxxx'); + expect(waitForSourceComputedDrain).toHaveBeenCalledWith('sdmjxxx', { + timeoutMs: 600_000, + pollMs: 5_000, + processingLeaseMs: 120_000, + }); + expect(waitForSchemaOperations).toHaveBeenCalledWith('sdmjxxx', { + timeoutMs: 600_000, + pollMs: 5_000, + }); + expect(waitForBackgroundWriters).toHaveBeenCalledWith('sdmjxxx', { + timeoutMs: 600_000, + pollMs: 5_000, + probeTimeoutMs: 30_000, + queueScanBatchSize: 100, + queueScanLimit: 1000, + }); + expect(assertSourceInventoryUnchanged).toHaveBeenCalledWith('sdmjxxx'); + expect(assertTempWorkDirCapacity).toHaveBeenCalledWith('sdmjxxx', '/tmp/sdmjxxx', { + multiplier: 2, + minFreeBytes: 536_870_912, + baseSchemaCopyStrategy: 'pg_dump_stream_restore', + }); + expect(resumeSource).not.toHaveBeenCalled(); + expect(copyBaseSchemas).toHaveBeenCalledWith('sdmjxxx', { + workDir: '/tmp/sdmjxxx', + jobs: 2, + timeoutMs: 1000, + strategy: 'pg_dump_stream_restore', + }); + expect(copySharedRows).toHaveBeenCalledWith('sdmjxxx', { + timeoutMs: 1000, + strategy: 'psql_copy', + }); + expect(validateAndSwitch).toHaveBeenCalledWith('sdmjxxx'); + expect(copyService.assertPostgresToolsAvailable.mock.invocationCallOrder[0]).toBeLessThan( + pauseSource.mock.invocationCallOrder[0] + ); + expect(copyBaseSchemas.mock.invocationCallOrder[0]).toBeLessThan( + copySharedRows.mock.invocationCallOrder[0] + ); + expect(pauseSource.mock.invocationCallOrder[0]).toBeLessThan( + waitForSourceComputedDrain.mock.invocationCallOrder[0] + ); + expect(waitForSourceComputedDrain.mock.invocationCallOrder[0]).toBeLessThan( + waitForSchemaOperations.mock.invocationCallOrder[0] + ); + expect(waitForSchemaOperations.mock.invocationCallOrder[0]).toBeLessThan( + waitForBackgroundWriters.mock.invocationCallOrder[0] + ); + expect(waitForBackgroundWriters.mock.invocationCallOrder[0]).toBeLessThan( + assertSourceInventoryUnchanged.mock.invocationCallOrder[0] + ); + expect(assertSourceInventoryUnchanged.mock.invocationCallOrder[0]).toBeLessThan( + assertTempWorkDirCapacity.mock.invocationCallOrder[0] + ); + expect(assertTempWorkDirCapacity.mock.invocationCallOrder[0]).toBeLessThan( + copyBaseSchemas.mock.invocationCallOrder[0] + ); + expect(copySharedRows.mock.invocationCallOrder[0]).toBeLessThan( + validateAndSwitch.mock.invocationCallOrder[0] + ); + }); + + it('runs a test-only migration job without freezing source writes', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + sourceConnectionId: null, + targetConnectionId: 'dcnxxx', + switchOnCompletion: false, + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [{ schemaName: 'bsexxx', relations: [], totalBytes: 1024 }], + }, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + }); + const service = createService(); + const pauseSource = vi.spyOn(service, 'pauseSourceComputedForJob').mockResolvedValue({ + created: true, + } as never); + const waitForSourceComputedDrain = vi + .spyOn(service, 'waitForSourceComputedDrainForJob') + .mockResolvedValue({ + activeCount: 0, + reclaimableCount: 0, + } as never); + const waitForSchemaOperations = vi + .spyOn(service, 'waitForSchemaOperationsForJob') + .mockResolvedValue({ + openCount: 0, + } as never); + const waitForBackgroundWriters = vi + .spyOn(service, 'waitForBackgroundWritersForJob') + .mockResolvedValue({ + openCount: 0, + } as never); + const assertSourceInventoryUnchanged = vi + .spyOn(service, 'assertSourceInventoryUnchangedForJob') + .mockResolvedValue(undefined); + const assertTempWorkDirCapacity = vi + .spyOn(service, 'assertTempWorkDirCapacityForJob') + .mockResolvedValue(undefined); + const copyBaseSchemas = vi.spyOn(service, 'copyBaseSchemasForJob').mockResolvedValue({ + phase: 'base_schemas_completed', + } as never); + const copySharedRows = vi.spyOn(service, 'copySharedRowsForJob').mockResolvedValue({ + phase: 'shared_rows_completed', + } as never); + const validateAndSwitch = vi.spyOn(service, 'validateAndSwitchJob').mockResolvedValue({ + state: 'succeeded', + } as never); + + await expect(service.runMigrationJob('sdmjxxx', { workDir: '/tmp/sdmjxxx' })).resolves.toEqual({ + state: 'succeeded', + }); + + expect(pauseSource).not.toHaveBeenCalled(); + expect(waitForSourceComputedDrain).not.toHaveBeenCalled(); + expect(waitForSchemaOperations).not.toHaveBeenCalled(); + expect(waitForBackgroundWriters).not.toHaveBeenCalled(); + expect(assertSourceInventoryUnchanged).not.toHaveBeenCalled(); + expect(assertTempWorkDirCapacity).toHaveBeenCalledWith('sdmjxxx', '/tmp/sdmjxxx', { + multiplier: 2, + minFreeBytes: 536_870_912, + baseSchemaCopyStrategy: 'pg_dump_stream_restore', + }); + expect(copyBaseSchemas).toHaveBeenCalledWith('sdmjxxx', { + workDir: '/tmp/sdmjxxx', + jobs: 1, + timeoutMs: 86_400_000, + strategy: 'pg_dump_stream_restore', + }); + expect(copySharedRows).toHaveBeenCalledWith('sdmjxxx', { + timeoutMs: 86_400_000, + strategy: 'psql_copy', + }); + expect(validateAndSwitch).toHaveBeenCalledWith('sdmjxxx'); + }); + + it('caps copy jobs before passing them to pg_dump and pg_restore', async () => { + const service = createService(); + vi.spyOn(service, 'pauseSourceComputedForJob').mockResolvedValue({ created: false } as never); + vi.spyOn(service, 'waitForSourceComputedDrainForJob').mockResolvedValue({ + activeCount: 0, + reclaimableCount: 0, + } as never); + vi.spyOn(service, 'waitForSchemaOperationsForJob').mockResolvedValue({ + openCount: 0, + } as never); + vi.spyOn(service, 'waitForBackgroundWritersForJob').mockResolvedValue({ + openCount: 0, + } as never); + vi.spyOn(service, 'assertSourceInventoryUnchangedForJob').mockResolvedValue(undefined); + vi.spyOn(service, 'assertTempWorkDirCapacityForJob').mockResolvedValue(undefined); + const copyBaseSchemas = vi.spyOn(service, 'copyBaseSchemasForJob').mockResolvedValue({ + phase: 'base_schemas_completed', + } as never); + vi.spyOn(service, 'copySharedRowsForJob').mockResolvedValue({ + phase: 'shared_rows_completed', + } as never); + vi.spyOn(service, 'validateAndSwitchJob').mockResolvedValue({ + state: 'succeeded', + } as never); + + await expect( + service.runMigrationJob('sdmjxxx', { + workDir: '/tmp/sdmjxxx', + jobs: 20, + maxJobs: 3, + timeoutMs: 1000, + }) + ).resolves.toEqual({ state: 'succeeded' }); + + expect(copyBaseSchemas).toHaveBeenCalledWith('sdmjxxx', { + workDir: '/tmp/sdmjxxx', + jobs: 3, + timeoutMs: 1000, + strategy: 'pg_dump_stream_restore', + }); + }); + + it('checks pgcopydb and passes the selected base-schema strategy when explicitly requested', async () => { + copyService.assertPostgresToolsAvailable.mockResolvedValueOnce([ + processResult('pg_dump'), + processResult('pg_restore'), + processResult('psql'), + processResult('pgcopydb'), + ]); + const service = createService(); + vi.spyOn(service, 'pauseSourceComputedForJob').mockResolvedValue({ created: false } as never); + vi.spyOn(service, 'waitForSourceComputedDrainForJob').mockResolvedValue({ + activeCount: 0, + reclaimableCount: 0, + } as never); + vi.spyOn(service, 'waitForSchemaOperationsForJob').mockResolvedValue({ + openCount: 0, + } as never); + vi.spyOn(service, 'waitForBackgroundWritersForJob').mockResolvedValue({ + openCount: 0, + } as never); + vi.spyOn(service, 'assertSourceInventoryUnchangedForJob').mockResolvedValue(undefined); + vi.spyOn(service, 'assertTempWorkDirCapacityForJob').mockResolvedValue(undefined); + const copyBaseSchemas = vi.spyOn(service, 'copyBaseSchemasForJob').mockResolvedValue({ + phase: 'base_schemas_completed', + } as never); + const copySharedRows = vi.spyOn(service, 'copySharedRowsForJob').mockResolvedValue({ + phase: 'shared_rows_completed', + } as never); + vi.spyOn(service, 'validateAndSwitchJob').mockResolvedValue({ + state: 'succeeded', + } as never); + + await expect( + service.runMigrationJob('sdmjxxx', { + workDir: '/tmp/sdmjxxx', + jobs: 4, + baseSchemaCopyStrategy: 'pgcopydb', + sharedTableCopyStrategy: 'postgres_fdw', + timeoutMs: 1000, + }) + ).resolves.toEqual({ state: 'succeeded' }); + + expect(copyService.assertPostgresToolsAvailable).toHaveBeenCalledWith( + 'pgcopydb', + expect.objectContaining({ timeoutMs: 1000, shouldCancel: expect.any(Function) }) + ); + expect(copyBaseSchemas).toHaveBeenCalledWith('sdmjxxx', { + workDir: '/tmp/sdmjxxx', + jobs: 4, + timeoutMs: 1000, + strategy: 'pgcopydb', + }); + expect(copySharedRows).toHaveBeenCalledWith('sdmjxxx', { + timeoutMs: 1000, + strategy: 'postgres_fdw', + }); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + copyStats: expect.objectContaining({ + phase: 'postgres_tools_checked', + postgresTools: expect.objectContaining({ + requiredTools: ['pg_dump', 'pg_restore', 'psql', 'pgcopydb'], + }), + }), + }), + }) + ); + }); + + it('fails fast before pausing source computed work when PostgreSQL client tools are unavailable', async () => { + prismaService.spaceDataDbMigrationJob.findFirst + .mockResolvedValueOnce({ + id: 'sdmjxxx', + state: 'freezing_writes', + }) + .mockResolvedValueOnce({ + id: 'sdmjxxx', + state: 'freezing_writes', + }) + .mockResolvedValueOnce({ + id: 'sdmjxxx', + state: 'failed', + }); + copyService.assertPostgresToolsAvailable.mockRejectedValueOnce( + new Error('spawn pg_dump ENOENT') + ); + const service = createService(); + const pauseSource = vi.spyOn(service, 'pauseSourceComputedForJob').mockResolvedValue({ + created: true, + } as never); + + await expect( + service.runMigrationJob('sdmjxxx', { workDir: '/tmp/sdmjxxx', timeoutMs: 60_000 }) + ).rejects.toMatchObject({ + code: HttpErrorCode.VALIDATION_ERROR, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_POSTGRES_TOOL_UNAVAILABLE', + requiredTools: ['pg_dump', 'pg_restore', 'psql'], + cause: 'spawn pg_dump ENOENT', + }), + }); + + expect(pauseSource).not.toHaveBeenCalled(); + expect(copyService.copyBaseSchemas).not.toHaveBeenCalled(); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + copyStats: expect.objectContaining({ + phase: 'postgres_tools_checking', + }), + }), + }) + ); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'failed', + lastError: 'spawn pg_dump ENOENT', + copyStats: expect.objectContaining({ + phase: 'postgres_tools_unavailable', + postgresTools: expect.objectContaining({ + requiredTools: ['pg_dump', 'pg_restore', 'psql'], + error: 'spawn pg_dump ENOENT', + }), + }), + }), + }) + ); + expect(prismaService.spaceDataDbMigrationJob.updateMany).toHaveBeenCalledWith({ + where: { id: 'sdmjxxx', state: 'failed' }, + data: expect.objectContaining({ + state: 'failed', + completedAt: expect.any(Date), + lastError: 'Required PostgreSQL client tools are unavailable for space data DB migration', + }), + }); + }); + + it('cancels a pre-copy migration and resumes the migration-owned source pause', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + state: 'freezing_writes', + targetConnectionId: 'dcnxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + startedAt: new Date('2026-05-06T00:00:00.000Z'), + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [], + }, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + }); + prismaService.spaceDataDbMigrationJob.findFirst.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetMode: 'migrate-space', + state: 'canceled', + targetInternalSchema: internalSchema, + inventory: { baseIds: ['bsexxx'] }, + copyStats: { phase: 'canceled_before_copy' }, + validationStats: null, + lastError: 'Space data database migration canceled by usrxxx', + startedAt: new Date('2026-05-06T00:00:00.000Z'), + completedAt: new Date('2026-05-06T00:01:00.000Z'), + createdTime: new Date('2026-05-06T00:00:00.000Z'), + lastModifiedTime: new Date('2026-05-06T00:01:00.000Z'), + targetConnection: null, + }); + sourceClient.raw.mockResolvedValue({ rows: [{ id: 'sdmp_sdmjxxx' }] }); + const service = createService(); + + await expect( + service.cancelMigrationForSpace('spcxxx', 'sdmjxxx', 'usrxxx') + ).resolves.toMatchObject({ + jobId: 'sdmjxxx', + state: 'canceled', + }); + + expect(sourceClient.raw).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM "public"."computed_update_pause_scope"'), + ['space', ['spcxxx'], 'space-data-db-migration:sdmjxxx'] + ); + expect(txClient.dataDbConnection.update).toHaveBeenCalledWith({ + where: { id: 'dcnxxx' }, + data: expect.objectContaining({ + status: 'error', + lastError: expect.stringContaining('canceled by usrxxx'), + }), + }); + expect(txClient.spaceDataDbMigrationJob.update).toHaveBeenCalledWith({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'canceled', + lastError: expect.stringContaining('canceled by usrxxx'), + copyStats: expect.objectContaining({ + phase: 'canceled_before_copy', + canceled: expect.objectContaining({ canceledBy: 'usrxxx' }), + }), + }), + }); + }); + + it('rejects canceling a migration that has entered validation', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + state: 'validating', + targetConnectionId: 'dcnxxx', + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + inventory: { baseIds: ['bsexxx'] }, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + }); + const service = createService(); + + await expect( + service.cancelMigrationForSpace('spcxxx', 'sdmjxxx', 'usrxxx') + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_MIGRATION_CANCEL_CONFLICT', + migrationState: 'validating', + }), + }); + + expect(sourceClient.raw).not.toHaveBeenCalled(); + expect(txClient.spaceDataDbMigrationJob.update).not.toHaveBeenCalled(); + }); + + it('rolls back a completed migration after a clean post-switch proof', async () => { + const completedAt = new Date('2026-05-06T00:10:00.000Z'); + const job = { + id: 'sdmjxxx', + spaceId: 'spcxxx', + state: 'succeeded', + sourceConnectionId: null, + targetConnectionId: 'dcnxxx', + switchOnCompletion: true, + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + startedAt: new Date('2026-05-06T00:00:00.000Z'), + completedAt, + inventory: { + sourceDataDb: { + mode: 'default', + cacheKey: 'meta-fallback', + connectionId: null, + internalSchema: null, + isMetaFallback: true, + }, + targetDataDb: { internalSchema }, + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [], + }, + validationStats: { + phase: 'validation_completed', + baseSchemas: [], + sharedTables: [], + }, + copyStats: null, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + }; + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue(job); + prismaService.spaceDataDbMigrationJob.findFirst.mockResolvedValue({ + ...job, + targetMode: 'migrate-space', + state: 'rolled_back', + lastError: null, + createdTime: new Date('2026-05-06T00:00:00.000Z'), + lastModifiedTime: new Date('2026-05-06T00:11:00.000Z'), + targetConnection: null, + }); + const service = createService(); + vi.spyOn( + service as unknown as { + inspectPostSwitchRollbackProof: (job: unknown) => Promise; + }, + 'inspectPostSwitchRollbackProof' + ).mockResolvedValue({ + eligible: true, + switchedAt: completedAt.toISOString(), + checkedAt: '2026-05-06T00:11:00.000Z', + findings: [], + }); + const resumeSource = vi + .spyOn( + service as unknown as { + resumeOriginalSourceComputedPause: ( + job: unknown, + sourceDataDb: unknown + ) => Promise<{ deleted: number }>; + }, + 'resumeOriginalSourceComputedPause' + ) + .mockResolvedValue({ deleted: 1 }); + + await expect( + service.rollbackMigrationForSpace('spcxxx', 'sdmjxxx', 'usrrollback') + ).resolves.toMatchObject({ + jobId: 'sdmjxxx', + state: 'rolled_back', + }); + + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenCalledWith({ + where: { id: 'sdmjxxx' }, + data: { state: 'switching', lastError: null }, + }); + expect(txClient.spaceDataDbBinding.upsert).toHaveBeenCalledWith({ + where: { spaceId: 'spcxxx' }, + create: expect.objectContaining({ + spaceId: 'spcxxx', + dataDbConnectionId: null, + mode: 'default', + state: 'ready', + createdBy: 'usrrollback', + }), + update: { + dataDbConnectionId: null, + mode: 'default', + state: 'ready', + }, + }); + expect(txClient.spaceDataDbMigrationJob.update).toHaveBeenCalledWith({ + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'rolled_back', + lastError: null, + validationStats: expect.objectContaining({ + rollback: expect.objectContaining({ + eligible: true, + rolledBackBy: 'usrrollback', + }), + }), + }), + }); + expect(dataDbClientManager.invalidateConnection).toHaveBeenCalledWith('dcnxxx'); + expect(resumeSource).toHaveBeenCalledWith( + job, + expect.objectContaining({ cacheKey: 'meta-fallback', isMetaFallback: true }) + ); + }); + + it('rejects post-switch rollback when target writes are detected', async () => { + const completedAt = new Date('2026-05-06T00:10:00.000Z'); + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + state: 'succeeded', + sourceConnectionId: null, + targetConnectionId: 'dcnxxx', + switchOnCompletion: true, + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + startedAt: new Date('2026-05-06T00:00:00.000Z'), + completedAt, + inventory: { + sourceDataDb: { + mode: 'default', + cacheKey: 'meta-fallback', + connectionId: null, + internalSchema: null, + isMetaFallback: true, + }, + targetDataDb: { internalSchema }, + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [], + }, + validationStats: { + phase: 'validation_completed', + baseSchemas: [], + sharedTables: [], + }, + copyStats: null, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + }); + const service = createService(); + vi.spyOn( + service as unknown as { + inspectPostSwitchRollbackProof: (job: unknown) => Promise; + }, + 'inspectPostSwitchRollbackProof' + ).mockResolvedValue({ + eligible: false, + switchedAt: completedAt.toISOString(), + checkedAt: '2026-05-06T00:11:00.000Z', + findings: [{ object: 'base:bsexxx.sheet1', reason: 'row_count_changed' }], + }); + + await expect( + service.rollbackMigrationForSpace('spcxxx', 'sdmjxxx', 'usrrollback') + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_ROLLBACK_UNSAFE', + rollback: expect.objectContaining({ + eligible: false, + }), + }), + }); + + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenNthCalledWith(1, { + where: { id: 'sdmjxxx' }, + data: { state: 'switching', lastError: null }, + }); + expect(prismaService.spaceDataDbMigrationJob.update).toHaveBeenNthCalledWith(2, { + where: { id: 'sdmjxxx' }, + data: expect.objectContaining({ + state: 'succeeded', + lastError: expect.stringContaining('rollback is unsafe'), + validationStats: expect.objectContaining({ + rollback: expect.objectContaining({ eligible: false }), + }), + }), + }); + expect(txClient.spaceDataDbBinding.upsert).not.toHaveBeenCalled(); + expect(dataDbClientManager.invalidateConnection).not.toHaveBeenCalled(); + }); + + it('rejects rollback for a successful dry-run migration that did not switch routing', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + state: 'succeeded', + sourceConnectionId: null, + targetConnectionId: 'dcnxxx', + switchOnCompletion: false, + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + completedAt: new Date('2026-05-06T00:10:00.000Z'), + inventory: { + baseIds: ['bsexxx'], + tableIds: ['tblxxx'], + }, + validationStats: { + phase: 'validation_completed', + switchOnCompletion: false, + switched: false, + }, + copyStats: null, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + }); + const service = createService(); + + await expect( + service.rollbackMigrationForSpace('spcxxx', 'sdmjxxx', 'usrrollback') + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_ROLLBACK_UNSAFE', + }), + }); + + expect(prismaService.spaceDataDbMigrationJob.update).not.toHaveBeenCalledWith({ + where: { id: 'sdmjxxx' }, + data: { state: 'switching', lastError: null }, + }); + expect(txClient.spaceDataDbBinding.upsert).not.toHaveBeenCalled(); + }); + + it('rejects rollback from a BYODB source before entering the active switching state', async () => { + prismaService.spaceDataDbMigrationJob.findUnique.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + state: 'succeeded', + sourceConnectionId: 'dcnsource', + targetConnectionId: 'dcntarget', + switchOnCompletion: true, + targetInternalSchema: internalSchema, + createdBy: 'usrxxx', + completedAt: new Date('2026-05-06T00:10:00.000Z'), + inventory: { + sourceDataDb: { + mode: 'byodb', + cacheKey: 'dcnsource', + connectionId: 'dcnsource', + internalSchema, + isMetaFallback: false, + }, + targetDataDb: { internalSchema }, + baseIds: ['bsexxx'], + tableIds: [], + dbTableNames: [], + physicalSchemas: [], + }, + validationStats: null, + copyStats: null, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + }); + const service = createService(); + + await expect( + service.rollbackMigrationForSpace('spcxxx', 'sdmjxxx', 'usrrollback') + ).rejects.toMatchObject({ + code: HttpErrorCode.VALIDATION_ERROR, + }); + + expect(prismaService.spaceDataDbMigrationJob.update).not.toHaveBeenCalledWith({ + where: { id: 'sdmjxxx' }, + data: { state: 'switching', lastError: null }, + }); + }); + + it('proves a post-switch rollback is clean when target inventory, counts, and timestamps match', async () => { + const service = createService(); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('FROM pg_class c')) { + return { + rows: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + }, + ], + }; + } + if (sql.includes('FROM information_schema.columns')) { + return { rows: [{ columnName: '__last_modified_time' }] }; + } + if (sql.includes('FROM "bsexxx"."sheet1"')) { + return { rows: [{ count: sql.includes('> ?::timestamp') ? '0' : '5' }] }; + } + if (sql.includes('COUNT(*)')) { + return { rows: [{ count: '0' }] }; + } + return { rows: [] }; + }); + + await expect( + ( + service as unknown as { + inspectPostSwitchRollbackProof: (job: unknown) => Promise<{ + eligible: boolean; + findings: unknown[]; + }>; + } + ).inspectPostSwitchRollbackProof({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + completedAt: new Date('2026-05-06T00:10:00.000Z'), + targetInternalSchema: internalSchema, + inventory: { + baseIds: ['bsexxx'], + tableIds: [], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [ + { + schemaName: 'bsexxx', + totalBytes: 1024, + estimatedRows: 5, + relations: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: 1024, + estimatedRows: 5, + }, + ], + }, + ], + }, + validationStats: { + baseSchemas: [{ object: 'base:bsexxx.sheet1', sourceCount: 5, targetCount: 5 }], + sharedTables: [ + { object: 'shared:computed_update_outbox', sourceCount: 0, targetCount: 0 }, + { object: 'shared:computed_update_dead_letter', sourceCount: 0, targetCount: 0 }, + { object: 'shared:computed_update_pause_scope', sourceCount: 0, targetCount: 0 }, + { object: 'shared:__undo_log', sourceCount: 0, targetCount: 0 }, + ], + }, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + }) + ).resolves.toMatchObject({ + eligible: true, + findings: [], + }); + }); + + it('flags post-switch rollback as unsafe when target row counts or timestamps changed', async () => { + const service = createService(); + targetClient.raw.mockImplementation((sql: string) => { + if (sql.includes('FROM pg_class c')) { + return { + rows: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + }, + ], + }; + } + if (sql.includes('FROM information_schema.columns')) { + return { rows: [{ columnName: '__last_modified_time' }] }; + } + if (sql.includes('FROM "bsexxx"."sheet1"')) { + return { rows: [{ count: sql.includes('> ?::timestamp') ? '2' : '6' }] }; + } + if (sql.includes('COUNT(*)')) { + return { rows: [{ count: '0' }] }; + } + return { rows: [] }; + }); + + const proof = await ( + service as unknown as { + inspectPostSwitchRollbackProof: (job: unknown) => Promise<{ + eligible: boolean; + findings: { reason: string }[]; + }>; + } + ).inspectPostSwitchRollbackProof({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + completedAt: new Date('2026-05-06T00:10:00.000Z'), + targetInternalSchema: internalSchema, + inventory: { + baseIds: ['bsexxx'], + tableIds: [], + dbTableNames: ['bsexxx.sheet1'], + physicalSchemas: [ + { + schemaName: 'bsexxx', + totalBytes: 1024, + estimatedRows: 5, + relations: [ + { + schemaName: 'bsexxx', + relationName: 'sheet1', + relationKind: 'table', + totalBytes: 1024, + estimatedRows: 5, + }, + ], + }, + ], + }, + validationStats: { + baseSchemas: [{ object: 'base:bsexxx.sheet1', sourceCount: 5, targetCount: 5 }], + sharedTables: [ + { object: 'shared:computed_update_outbox', sourceCount: 0, targetCount: 0 }, + { object: 'shared:computed_update_dead_letter', sourceCount: 0, targetCount: 0 }, + { object: 'shared:computed_update_pause_scope', sourceCount: 0, targetCount: 0 }, + { object: 'shared:__undo_log', sourceCount: 0, targetCount: 0 }, + ], + }, + targetConnection: { + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + }); + + expect(proof.eligible).toBe(false); + expect(proof.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ reason: 'row_count_changed' }), + expect.objectContaining({ reason: 'post_switch_timestamp_rows' }), + ]) + ); + }); + + it('stops the runner before physical copy when cancellation is observed at a checkpoint', async () => { + prismaService.spaceDataDbMigrationJob.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + state: 'canceled', + }); + const service = createService(); + vi.spyOn(service, 'pauseSourceComputedForJob').mockResolvedValue({ created: true } as never); + vi.spyOn(service, 'waitForSourceComputedDrainForJob').mockResolvedValue({ + activeCount: 0, + reclaimableCount: 0, + } as never); + vi.spyOn(service, 'waitForSchemaOperationsForJob').mockResolvedValue({ + openCount: 0, + } as never); + vi.spyOn(service, 'waitForBackgroundWritersForJob').mockResolvedValue({ + openCount: 0, + } as never); + vi.spyOn(service, 'assertSourceInventoryUnchangedForJob').mockResolvedValue(undefined); + vi.spyOn(service, 'assertTempWorkDirCapacityForJob').mockResolvedValue(undefined); + const copyBaseSchemas = vi.spyOn(service, 'copyBaseSchemasForJob').mockResolvedValue({ + phase: 'base_schemas_completed', + } as never); + const resumeSource = vi.spyOn(service, 'resumeSourceComputedForJob').mockResolvedValue({ + deleted: 1, + } as never); + + await expect( + service.runMigrationJob('sdmjxxx', { workDir: '/tmp/sdmjxxx' }) + ).rejects.toMatchObject({ + code: HttpErrorCode.CONFLICT, + data: expect.objectContaining({ + errorCode: 'SPACE_DATA_DB_MIGRATION_CANCELED', + }), + }); + + expect(copyBaseSchemas).not.toHaveBeenCalled(); + expect(resumeSource).toHaveBeenCalledWith('sdmjxxx'); + }); + + it('resumes a migration-created source computed pause when copy fails before switch', async () => { + const service = createService(); + vi.spyOn(service, 'pauseSourceComputedForJob').mockResolvedValue({ created: true } as never); + vi.spyOn(service, 'waitForSourceComputedDrainForJob').mockResolvedValue({ + activeCount: 0, + reclaimableCount: 0, + } as never); + vi.spyOn(service, 'waitForSchemaOperationsForJob').mockResolvedValue({ + openCount: 0, + } as never); + vi.spyOn(service, 'waitForBackgroundWritersForJob').mockResolvedValue({ + openCount: 0, + } as never); + vi.spyOn(service, 'assertSourceInventoryUnchangedForJob').mockResolvedValue(undefined); + vi.spyOn(service, 'assertTempWorkDirCapacityForJob').mockResolvedValue(undefined); + vi.spyOn(service, 'copyBaseSchemasForJob').mockRejectedValue(new Error('copy failed')); + const copySharedRows = vi.spyOn(service, 'copySharedRowsForJob'); + const validateAndSwitch = vi.spyOn(service, 'validateAndSwitchJob'); + const resumeSource = vi.spyOn(service, 'resumeSourceComputedForJob').mockResolvedValue({ + deleted: 1, + } as never); + const cleanupTargetArtifacts = vi + .spyOn(service, 'cleanupTargetArtifactsForJob') + .mockResolvedValue({ + reason: 'pre_switch_failure', + baseSchemas: [], + sharedTables: [], + startedAt: '2026-05-06T00:00:00.000Z', + completedAt: '2026-05-06T00:00:01.000Z', + } as never); + + await expect(service.runMigrationJob('sdmjxxx', { workDir: '/tmp/sdmjxxx' })).rejects.toThrow( + 'copy failed' + ); + expect(resumeSource).toHaveBeenCalledWith('sdmjxxx'); + expect(cleanupTargetArtifacts).toHaveBeenCalledWith('sdmjxxx', 'pre_switch_failure'); + expect(copySharedRows).not.toHaveBeenCalled(); + expect(validateAndSwitch).not.toHaveBeenCalled(); + expect(txClient.spaceDataDbBinding.upsert).not.toHaveBeenCalled(); + expect(txClient.dataDbConnection.update).not.toHaveBeenCalled(); + }); + + it('returns a sanitized migration job status', async () => { + prismaService.spaceDataDbMigrationJob.findFirst.mockResolvedValue({ + id: 'sdmjxxx', + spaceId: 'spcxxx', + targetMode: 'migrate-space', + switchOnCompletion: false, + state: 'copying', + targetInternalSchema: internalSchema, + inventory: { baseIds: ['bsexxx'] }, + copyStats: { phase: 'copying_shared_rows' }, + validationStats: null, + lastError: null, + startedAt: new Date('2026-05-06T00:00:00.000Z'), + completedAt: null, + createdTime: new Date('2026-05-06T00:00:00.000Z'), + lastModifiedTime: new Date('2026-05-06T00:01:00.000Z'), + targetConnection: { + provider: 'postgres', + displayHost: 'example.com:5432', + displayDatabase: 'teable_data', + internalSchema, + schemaVersion, + lastValidatedAt: new Date('2026-05-06T00:00:00.000Z'), + lastError: null, + encryptedUrl: 'encrypted-secret', + capabilities, + }, + }); + const service = createService(); + + await expect(service.getMigrationJobStatus('spcxxx', 'sdmjxxx')).resolves.toMatchObject({ + jobId: 'sdmjxxx', + spaceId: 'spcxxx', + state: 'copying', + switchOnCompletion: false, + targetConnection: { + displayHost: 'example.com:5432', + displayDatabase: 'teable_data', + }, + copyStats: { + phase: 'copying_shared_rows', + }, + }); + const status = await service.getMigrationJobStatus('spcxxx', 'sdmjxxx'); + expect(JSON.stringify(status)).not.toContain('encrypted-secret'); + expect(prismaService.spaceDataDbMigrationJob.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'sdmjxxx' }, + }) + ); + }); +}); 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 new file mode 100644 index 0000000000..b9cef2cfc5 --- /dev/null +++ b/apps/nestjs-backend/src/features/space/space-data-db-migration.service.ts @@ -0,0 +1,6807 @@ +import { mkdir, statfs as nodeStatfs } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Inject, Injectable, Optional } from '@nestjs/common'; +import { HttpErrorCode } from '@teable/core'; +import { getMetaDatabaseUrl } from '@teable/db-data-prisma'; +import { PrismaService, ProvisionState, type Prisma } from '@teable/db-main-prisma'; +import type { + IDataDbMigrationJobStatusVo, + IDataDbPreflightRo, + IDataDbPreflightVo, +} from '@teable/openapi'; +import { Queue } from 'bullmq'; +import { CustomHttpException } from '../../custom.exception'; +import { + DataDbClientManager, + type IResolvedDataDatabase, +} from '../../global/data-db-client-manager.service'; +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 { resolveDataDbInternalSchema } from './data-db-internal-schema'; +import { + DATA_DB_PREFLIGHT_CLIENT_FACTORY, + dataDbKnexClientFactory, + fingerprintDatabaseUrl, + fingerprintDataDbConnection, + getDatabaseUrlDisplayParts, + type IDataDbPreflightClient, + type IDataDbPreflightClientFactory, + DataDbPreflightService, +} from './data-db-preflight.service'; +import { decryptDataDbUrl, encryptDataDbUrl } from './data-db-url-secret'; +import { + buildMigrationSharedTablePostgresFdwCopyPlans, + buildMigrationSharedTablePsqlCopyPlans, +} from './space-data-db-copy-plan'; +import { + postgresCopyToolsForStrategy, + SpaceDataDbCopyService, + type ISpaceDataDbBaseSchemaCopyResult, + type ISpaceDataDbBaseSchemaCopyStrategy, + type ISpaceDataDbExcludedForeignKey, + type ISpaceDataDbPostgresFdwSharedTableCopyResult, + type ISpaceDataDbSharedTableCopyStrategy, + type ISpaceDataDbSharedTableCopyResult, +} from './space-data-db-copy.service'; +import { + activeSpaceDataDbMigrationStates, + cancelableSpaceDataDbMigrationStates, + migrateSpaceTargetMode, + spaceDataDbBackgroundWriterDrainTimeoutErrorCode, + spaceDataDbComputedDrainTimeoutErrorCode, + spaceDataDbInventoryChangedErrorCode, + spaceDataDbLargeMigrationConfirmationRequiredErrorCode, + spaceDataDbMigrationCanceledErrorCode, + spaceDataDbMigrationActiveErrorCode, + spaceDataDbMigrationCancelConflictErrorCode, + spaceDataDbPostgresToolUnavailableErrorCode, + spaceDataDbRelatedSpacesRequiredErrorCode, + spaceDataDbRollbackUnsafeErrorCode, + spaceDataDbSchemaOperationDrainTimeoutErrorCode, + spaceDataDbStaleActiveJobErrorCode, + spaceDataDbTargetCleanupFailedErrorCode, + spaceDataDbTargetDiskInsufficientErrorCode, + spaceDataDbTargetExtensionMissingErrorCode, + spaceDataDbTargetConflictErrorCode, + spaceDataDbTempDiskInsufficientErrorCode, + spaceDataDbValidationMismatchErrorCode, +} from './space-data-db-migration.constants'; +import { + SpaceDataDbProcessCanceledError, + SpaceDataDbProcessError, + SpaceDataDbProcessPipelineError, + SpaceDataDbProcessPipelineCanceledError, + type ISpaceDataDbProcessRunOptions, +} from './space-data-db-process-runner.service'; +import { + resolveSpaceDataDbRelatedSpaces, + type ISpaceDataDbRelatedSpaces, +} from './space-data-db-related-spaces'; + +export const spaceDataDbStatfsToken = Symbol('SPACE_DATA_DB_STATFS'); + +export type ISpaceDataDbStatfs = typeof nodeStatfs; + +type IPreparedMigrationTarget = { + encryptedUrl: string; + urlFingerprint: string; + displayHost: string; + displayDatabase: string; + internalSchema: string; + schemaVersion: string | null; + capabilities: IDataDbPreflightVo['capabilities']; +}; + +type ISpaceDataDbPhysicalRelation = { + schemaName: string; + relationName: string; + relationKind: string; + totalBytes: number; + estimatedRows: number | null; +}; + +type ISpaceDataDbPhysicalSchema = { + schemaName: string; + relations: ISpaceDataDbPhysicalRelation[]; + totalBytes: number; + estimatedRows: number; +}; + +type ISpaceDataDbPostgresExtensionDependency = { + extensionName: string; + objectType: 'operator_class'; + schemaName: string; + objectName: string; + accessMethod: string; + sourceObjects: string[]; +}; + +type ISpaceDataDbProcessFailureResult = { + command: string; + exitCode?: number | null; + signal?: NodeJS.Signals | null; + stderr?: string; + stdout?: string; +}; + +type ISpaceDataDbProcessFailureStats = + | { + type: 'pipeline'; + message: string; + result: { + source: ISpaceDataDbProcessFailureResult; + target: ISpaceDataDbProcessFailureResult; + }; + } + | { + type: 'process'; + message: string; + result: ISpaceDataDbProcessFailureResult; + } + | { + type: 'unknown'; + message: string; + }; + +type ISpaceDataDbInventorySourceDataDb = { + mode: 'default' | 'byodb'; + cacheKey: string; + connectionId: string | null; + internalSchema: string | null; + isMetaFallback: boolean; +}; + +type ISpaceDataDbInventoryTargetDataDb = { + internalSchema: string; +}; + +type ISpaceDataDbInventoryRelatedSpaces = ISpaceDataDbRelatedSpaces; + +type ISpaceDataDbInventory = { + sourceDataDb: ISpaceDataDbInventorySourceDataDb; + targetDataDb: ISpaceDataDbInventoryTargetDataDb; + relatedSpaces: ISpaceDataDbInventoryRelatedSpaces; + spaceIds: string[]; + copySpaceIds: string[]; + baseIds: string[]; + tableIds: string[]; + sharedTableIds: string[]; + relatedSharedTableIds: string[]; + dbTableNames: string[]; + physicalSchemas: ISpaceDataDbPhysicalSchema[]; + postgresExtensionDependencies: ISpaceDataDbPostgresExtensionDependency[]; + outOfScopeForeignKeys: ISpaceDataDbExcludedForeignKey[]; + estimatedTotalBytes: number; + estimatedTotalRows: number; +}; + +type ITargetConflict = { + object: string; + count?: number; +}; + +type ITargetDiskCapacity = { + checked: boolean; + reason?: string; + dataDirectory?: string; + mountPath?: string; + availableBytes?: number; + totalBytes?: number; + usedBytes?: number; + checkedAt: string; +}; + +type IValidationMismatch = { + object: string; + reason: string; + sourceCount?: number; + targetCount?: number; + sourceKind?: string; + targetKind?: string | null; + sourceColumns?: IBaseRelationColumnSignature[]; + targetColumns?: IBaseRelationColumnSignature[]; + sourceIndexes?: IBaseRelationIndexSignature[]; + targetIndexes?: IBaseRelationIndexSignature[]; + sourceConstraints?: IBaseRelationConstraintSignature[]; + targetConstraints?: IBaseRelationConstraintSignature[]; + sourceTriggers?: IBaseRelationTriggerSignature[]; + targetTriggers?: IBaseRelationTriggerSignature[]; +}; + +type IInventoryChangedMismatch = { + object: string; + reason: 'inventory_changed'; + expectedCount?: number; + actualCount?: number; + added?: string[]; + removed?: string[]; + expected?: unknown; + actual?: unknown; +}; + +type IRowCountValidation = { + object: string; + sourceCount: number; + targetCount: number; +}; + +type IValidationStats = { + phase: 'validation_completed'; + progress: IMigrationProgressStats; + targetSchemaVersion: { latest: string | null; exists: boolean }; + routeSmoke: IRouteSmokeStats; + baseSchemas: IRowCountValidation[]; + sharedTables: IRowCountValidation[]; + undoFunction: { exists: boolean }; + switchOnCompletion?: boolean; + switched?: boolean; + switchedAt?: string; + completedAt: string; +}; + +type IComputedDrainStats = { + activeCount: number; + reclaimableCount: number; + oldestActiveLockedAt: string | null; + checkedAt: string; +}; + +type IRouteSmokeStats = { + ok: boolean; + connectionId: string | null; + internalSchema: string | null; + cacheKey: string | null; + isMetaFallback: boolean; + error?: string; +}; + +type ISharedTableCopySummary = { + strategy?: ISpaceDataDbSharedTableCopyStrategy; + table: string; + copiedRows: number | null; + source?: ISpaceDataDbSharedTableCopyResult['source']; + target: ISpaceDataDbSharedTableCopyResult['target']; +}; + +type ISharedTableCopyHeartbeat = { + stage: 'copying_shared_rows'; + tableNames: string[]; + copiedTableCount: number; + totalTables: number; + totalCopiedRows: number | null; + strategy: ISpaceDataDbSharedTableCopyStrategy; + updatedAt: string; +}; + +type IBaseSchemaRelationCopySummary = { + schemaName: string; + relationName: string; + relationKind: string; + copiedRows: number; + estimatedRows: number | null; + totalBytes: number; +}; + +type IBaseSchemaCopyProgressPhase = 'dump' | 'restore'; +type IBaseSchemaCopyHeartbeatStage = 'progress_poll' | 'post_copy_stats'; +type IValidationHeartbeatStage = 'validating_copy'; + +type IBaseSchemaActiveCopyRelation = { + schemaName: string; + relationName: string; + command: string | null; + copyType: string | null; + bytesProcessed: number | null; + bytesTotal: number | null; + tuplesProcessed: number | null; + tuplesExcluded: number | null; + estimatedRows: number | null; + totalBytes: number | null; +}; + +type IBaseSchemaActiveCopyProgress = { + phase: IBaseSchemaCopyProgressPhase; + sampledAt: string; + activeRelationCount: number; + activeRelations: IBaseSchemaActiveCopyRelation[]; + error?: string; +}; + +type IBaseSchemaCopyHeartbeat = { + stage: IBaseSchemaCopyHeartbeatStage; + phase?: IBaseSchemaCopyProgressPhase; + updatedAt: string; +}; + +type IValidationHeartbeat = { + stage: IValidationHeartbeatStage; + updatedAt: string; +}; + +type IBaseRelationColumnSignature = { + ordinalPosition: number; + columnName: string; + formattedType: string; + notNull: boolean; + defaultExpression: string | null; + identity: string; + generated: string; + collation: string | null; +}; + +type IBaseRelationIndexSignature = { + indexName: string; + isPrimary: boolean; + isUnique: boolean; + isValid: boolean; + definition: string; +}; + +type IBaseRelationConstraintSignature = { + constraintName: string; + constraintType: string; + definition: string; +}; + +type IBaseRelationTriggerSignature = { + triggerName: string; + enabled: string; + definition: string; +}; + +type ITargetArtifactCleanupStats = { + reason: string; + baseSchemas: { + schemaName: string; + dropped: boolean; + }[]; + sharedTables: { + table: string; + deletedRows: number | null; + truncated?: boolean; + }[]; + truncateSharedTables?: boolean; + startedAt: string; + completedAt?: string; + failedAt?: string; + error?: string; +}; + +type IPostSwitchWriteFinding = { + object: string; + reason: string; + count?: number; + expectedCount?: number; + actualCount?: number; + maxChangedAt?: string | null; +}; + +type IPostSwitchRollbackProof = { + eligible: boolean; + switchedAt: string; + checkedAt: string; + findings: IPostSwitchWriteFinding[]; +}; + +type IMigrationProgressStats = { + phase: string; + totalSteps: number; + completedSteps: number; + percent: number; + estimatedTotalBytes: number; + completedEstimatedBytes: number; + estimatedTotalRows: number; + completedEstimatedRows: number; + startedAt: string | null; + updatedAt: string; + etaMs: number | null; +}; + +type ISchemaOperationDrainStats = { + openCount: number; + sample: { + id: string; + status: string; + phase: string; + baseId: string | null; + tableId: string | null; + lockedAt: string | null; + lockedBy: string | null; + lastModifiedTime: string | null; + }[]; + checkedAt: string; +}; + +type IBackgroundWriterDrainStats = { + openCount: number; + provisionResourceCount: number; + queueJobCount: number; + sample: { + kind: 'provision_resource' | 'queue_job'; + resourceType?: 'base' | 'table' | 'field'; + queueName?: string; + id: string; + state: string; + baseId: string | null; + tableId: string | null; + lastModifiedTime?: string | null; + timestamp?: string | null; + }[]; + checkedAt: string; +}; + +type IMigrationJobRecord = { + id: string; + spaceId: string; + state: string; + sourceConnectionId: string | null; + targetConnectionId: string | null; + switchOnCompletion?: boolean | null; + targetInternalSchema: string; + createdBy: string; + startedAt: Date | null; + completedAt: Date | null; + inventory: unknown; + copyStats?: unknown; + validationStats?: unknown; + targetConnection: { encryptedUrl: string } | null; +}; + +type IRunMigrationJobOptions = { + workDir?: string; + jobs?: number; + maxJobs?: number; + baseSchemaCopyStrategy?: ISpaceDataDbBaseSchemaCopyStrategy; + sharedTableCopyStrategy?: ISpaceDataDbSharedTableCopyStrategy; + timeoutMs?: number; + computedDrainTimeoutMs?: number; + computedDrainPollMs?: number; + computedProcessingLeaseMs?: number; + schemaOperationDrainTimeoutMs?: number; + schemaOperationDrainPollMs?: number; + backgroundWriterDrainTimeoutMs?: number; + backgroundWriterDrainPollMs?: number; + backgroundWriterDrainProbeTimeoutMs?: number; + backgroundWriterQueueScanBatchSize?: number; + backgroundWriterQueueScanLimit?: number; + tempDiskMultiplier?: number; + tempDiskMinFreeBytes?: number; +}; + +type IResolvedRunMigrationJobOptions = Required; + +type IMigrationJobStatusRecord = { + id: string; + spaceId: string; + targetMode: string; + switchOnCompletion?: boolean | null; + state: string; + targetInternalSchema: string; + inventory: unknown; + copyStats: unknown; + validationStats: unknown; + lastError: string | null; + startedAt: Date | null; + completedAt: Date | null; + createdTime: Date; + lastModifiedTime: Date | null; + targetConnection: { + provider: 'postgres'; + displayHost: string | null; + displayDatabase: string | null; + internalSchema: string; + schemaVersion: string | null; + lastValidatedAt: Date | null; + lastError: string | null; + capabilities: unknown; + } | null; +}; + +type IRetryableMigrationJobRecord = { + id: string; + spaceId: string; + state: string; + switchOnCompletion?: boolean | null; + targetConnectionId: string | null; + inventory: unknown; +}; + +type IClaimableMigrationJobRecord = { + id: string; + state: string; +}; + +type IBaseSchemaCopyContext = { + inventory: ISpaceDataDbInventory; + schemaNames: string[]; + strategy: ISpaceDataDbBaseSchemaCopyStrategy; + progressPollMs: number; + startedAt: string; +}; + +type IBaseSchemaProgressClients = { + dump: IDataDbPreflightClient; + restore: IDataDbPreflightClient; +}; + +type IStaleMigrationJobRecord = IMigrationJobRecord & { + lastModifiedTime: Date | null; +}; + +type IMigrationJobClient = { + spaceDataDbMigrationJob: { + findFirst( + args: unknown + ): Promise<({ id: string; state: string } | IMigrationJobStatusRecord) | null>; + findMany(args: unknown): Promise; + findUnique(args: unknown): Promise; + create(args: unknown): Promise<{ id: string }>; + update(args: unknown): Promise; + updateMany(args: unknown): Promise<{ count: number }>; + }; +}; + +type IPrismaTransactionClient = { + dataDbConnection: { + upsert(args: unknown): Promise<{ id: string }>; + update(args: unknown): Promise; + }; + spaceDataDbBinding: { + upsert(args: unknown): Promise; + }; + spaceDataDbMigrationJob: { + create(args: unknown): Promise<{ id: string }>; + update(args: unknown): Promise; + }; +}; + +type IDataPrismaIntrospectionClient = { + $queryRawUnsafe(query: string, ...values: unknown[]): Promise; +}; + +const dataDbUrlRequiredError = 'Data database URL is required'; +const dataDbMigrationTable = '__teable_data_schema_migrations'; +const sharedTables = { + recordHistory: 'record_history', + tableTrash: 'table_trash', + recordTrash: 'record_trash', + computedUpdateOutbox: 'computed_update_outbox', + computedUpdateDeadLetter: 'computed_update_dead_letter', + computedUpdateOutboxSeed: 'computed_update_outbox_seed', + computedUpdatePauseScope: 'computed_update_pause_scope', + undoLog: '__undo_log', +}; +const relationKindsWithRows = new Set(['table', 'partitioned_table', 'foreign_table']); +const relationKindsWithColumns = new Set([ + 'table', + 'partitioned_table', + 'foreign_table', + 'view', + 'materialized_view', +]); +const relationKindsWithIndexSignatures = new Set([ + 'table', + 'partitioned_table', + 'foreign_table', + 'materialized_view', +]); +const relationKindsWithTableDependencySignatures = new Set([ + 'table', + 'partitioned_table', + 'foreign_table', +]); +const validationFailedMessage = 'Space data database migration validation failed'; +const metaFallbackDataDbCacheKey = 'meta-fallback'; +const defaultMigrationCopyTimeoutMs = 24 * 60 * 60 * 1000; +const defaultMigrationCopyJobs = 1; +const defaultMigrationCopyMaxJobs = 4; +const defaultComputedDrainTimeoutMs = 10 * 60 * 1000; +const defaultComputedDrainPollMs = 5 * 1000; +const defaultComputedProcessingLeaseMs = 2 * 60 * 1000; +const defaultSchemaOperationDrainTimeoutMs = 10 * 60 * 1000; +const defaultSchemaOperationDrainPollMs = 5 * 1000; +const defaultBackgroundWriterDrainTimeoutMs = 10 * 60 * 1000; +const defaultBackgroundWriterDrainPollMs = 5 * 1000; +const defaultBackgroundWriterDrainProbeTimeoutMs = 30 * 1000; +const defaultBackgroundWriterQueueScanBatchSize = 100; +const defaultBackgroundWriterQueueScanLimit = 1000; +const defaultTempDiskMultiplier = 2; +const defaultTempDiskMinFreeBytes = 512 * 1024 * 1024; +const defaultTargetDiskMultiplier = 2; +const defaultTargetDiskMinFreeBytes = 1024 * 1024 * 1024; +const defaultLargeMigrationByteThreshold = 50 * 1024 * 1024 * 1024; +const defaultLargeMigrationRowThreshold = 10_000_000; +const defaultPostgresToolCheckTimeoutMs = 5_000; +const defaultCopyProgressPollMs = 5_000; +const defaultCopyProgressPollTimeoutMs = 30_000; +const defaultStaleActiveJobTimeoutMs = 5 * 60 * 1000; +const openSchemaOperationStatuses = ['pending', 'running', 'error'] as const; +const openProvisionStates = [ + ProvisionState.pending, + ProvisionState.deleting, + ProvisionState.error, +] as const; +const openQueueJobStates = [ + 'waiting', + 'active', + 'delayed', + 'prioritized', + 'waiting-children', +] as const; +const migrationProgressTotalSteps = 9; +const migrationProgressCompletedSteps: Record = { + postgres_tools_checking: 0, + postgres_tools_checked: 0, + postgres_tools_unavailable: 0, + computed_paused: 1, + computed_draining: 1, + computed_drained: 2, + computed_drain_timeout: 1, + computed_drain_failed: 1, + schema_operations_draining: 2, + schema_operations_drained: 3, + schema_operation_drain_timeout: 2, + schema_operation_drain_failed: 2, + background_writers_draining: 3, + background_writers_drained: 4, + background_writer_drain_timeout: 3, + background_writer_drain_failed: 3, + source_inventory_verified: 4, + source_inventory_changed: 3, + temp_disk_checked: 5, + temp_disk_insufficient: 4, + copying_base_schemas: 5, + base_schemas_completed: 6, + base_schemas_failed: 5, + copying_shared_rows: 6, + shared_rows_completed: 7, + shared_rows_failed: 6, + validating_copy: 7, + validation_completed: 8, + validation_failed: 7, + switching: 8, + succeeded: 9, + canceled_before_copy: 1, +}; + +const migrationPauseReason = (jobId: string) => `space-data-db-migration:${jobId}`; + +const readPositiveIntEnv = (key: string, fallback: number) => { + const value = Number(process.env[key]); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; +}; + +const readPositiveNumberEnv = (key: string, fallback: number) => { + const value = Number(process.env[key]); + return Number.isFinite(value) && value > 0 ? value : fallback; +}; + +const readNonNegativeIntEnv = (key: string, fallback: number) => { + const value = Number(process.env[key]); + return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback; +}; + +const readBaseSchemaCopyStrategyEnv = ( + key: string, + fallback: ISpaceDataDbBaseSchemaCopyStrategy +): ISpaceDataDbBaseSchemaCopyStrategy => { + const value = process.env[key]; + return value === 'pgcopydb' || value === 'pg_dump_restore' || value === 'pg_dump_stream_restore' + ? value + : fallback; +}; + +const readSharedTableCopyStrategyEnv = ( + key: string, + fallback: ISpaceDataDbSharedTableCopyStrategy +): ISpaceDataDbSharedTableCopyStrategy => { + const value = process.env[key]; + return value === 'postgres_fdw' || value === 'psql_copy' ? value : fallback; +}; + +const optionOrPositiveIntEnv = (optionValue: number | undefined, key: string, fallback: number) => + optionValue ?? readPositiveIntEnv(key, fallback); + +const optionOrPositiveNumberEnv = ( + optionValue: number | undefined, + key: string, + fallback: number +) => optionValue ?? readPositiveNumberEnv(key, fallback); + +const normalizeRawRows = (result: { rows?: T[] } | T[]): T[] => { + if (Array.isArray(result)) { + return result; + } + return result.rows ?? []; +}; + +const sortJsonValue = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(sortJsonValue); + } + + if (!value || typeof value !== 'object') { + return value; + } + + return Object.keys(value as Record) + .sort() + .reduce>((acc, key) => { + acc[key] = sortJsonValue((value as Record)[key]); + return acc; + }, {}); +}; + +const stableJsonStringify = (value: unknown): string => JSON.stringify(sortJsonValue(value)); + +const buildPostgresExtensionDependencyKey = ( + dependency: Pick< + ISpaceDataDbPostgresExtensionDependency, + 'extensionName' | 'objectType' | 'schemaName' | 'objectName' | 'accessMethod' + > +) => + [ + dependency.extensionName, + dependency.objectType, + dependency.schemaName, + dependency.objectName, + dependency.accessMethod, + ].join(':'); + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const withTimeout = async (promise: Promise, timeoutMs: number, message: string) => { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return promise; + } + + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + timeout.unref?.(); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +}; + +const quoteIdent = (identifier: string) => `"${identifier.replace(/"/g, '""')}"`; + +const qualify = (schema: string, table: string) => `${quoteIdent(schema)}.${quoteIdent(table)}`; + +const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`; + +const buildPreflightErrorMessage = (preflight: IDataDbPreflightVo) => { + const errorCodes = preflight.errors.map((error) => error.code).join(', '); + return errorCodes + ? `Data database preflight failed: ${errorCodes}` + : `Data database preflight failed: ${preflight.classification}`; +}; + +@Injectable() +export class SpaceDataDbMigrationService { + private readonly clientFactory: IDataDbPreflightClientFactory; + + constructor( + private readonly prismaService: PrismaService, + private readonly preflightService: DataDbPreflightService, + private readonly baselineService: DataDbBaselineService, + private readonly dataDbClientManager: DataDbClientManager, + private readonly copyService: SpaceDataDbCopyService, + @Optional() + @Inject(DATA_DB_PREFLIGHT_CLIENT_FACTORY) + clientFactory?: IDataDbPreflightClientFactory, + @Optional() + @Inject(spaceDataDbStatfsToken) + private readonly statfs: ISpaceDataDbStatfs = nodeStatfs, + @Optional() + @InjectQueue(BASE_IMPORT_CSV_QUEUE) + private readonly baseImportCsvQueue?: Queue, + @Optional() + @InjectQueue(BASE_IMPORT_JUNCTION_CSV_QUEUE) + private readonly baseImportJunctionCsvQueue?: Queue, + @Optional() + @InjectQueue(TABLE_IMPORT_CSV_CHUNK_QUEUE) + private readonly tableImportCsvChunkQueue?: Queue, + @Optional() + @InjectQueue(TABLE_IMPORT_CSV_QUEUE) + private readonly tableImportCsvQueue?: Queue + ) { + this.clientFactory = clientFactory ?? dataDbKnexClientFactory; + } + + async startMigrationForSpace(spaceId: string, createdBy: string, dataDb: IDataDbPreflightRo) { + if (dataDb.targetMode !== migrateSpaceTargetMode) { + throw new CustomHttpException( + 'Only migrate-space BYODB target mode can start a space data DB migration', + HttpErrorCode.VALIDATION_ERROR + ); + } + if (!dataDb.url) { + throw new CustomHttpException(dataDbUrlRequiredError, HttpErrorCode.VALIDATION_ERROR); + } + + const relatedSpaces = await resolveSpaceDataDbRelatedSpaces(this.prismaService, spaceId); + this.assertRelatedSpacesCanMigrateTogether(spaceId, relatedSpaces); + const relatedSpaceIds = this.getRelatedSpaceIds(relatedSpaces); + await this.assertNoActiveMigrationForSpaces(relatedSpaceIds, spaceId); + + const targetDatabaseFingerprint = fingerprintDatabaseUrl(dataDb.url); + this.assertSpacesCanJoinTargetDataDb(relatedSpaces, targetDatabaseFingerprint, spaceId); + const internalSchema = await this.resolveMigrationTargetInternalSchemaOrThrow( + dataDb, + relatedSpaces, + targetDatabaseFingerprint, + spaceId + ); + const targetUrlFingerprint = fingerprintDataDbConnection(dataDb.url, internalSchema); + this.assertRelatedSpacesShareSameTarget(relatedSpaces, targetUrlFingerprint, spaceId); + const copySpaceIds = this.getRelatedSpaceIdsForCopy(relatedSpaces, targetUrlFingerprint); + this.assertRequestedSpaceIsCopySource(spaceId, copySpaceIds); + const inventory = await this.buildInventory(spaceId, internalSchema, relatedSpaces, { + copySpaceIds, + }); + this.assertLargeMigrationConfirmed(spaceId, inventory, dataDb.confirmLargeMigration === true); + await this.runTargetPreflightOperation( + dataDb, + internalSchema, + spaceId, + 'check_target_extension_dependencies', + () => this.assertTargetSupportsSourceDependencies(dataDb.url, inventory, spaceId) + ); + await this.assertTargetDiskCapacity(dataDb.url, inventory, spaceId); + await this.cleanupRetryableTargetArtifacts({ + spaceId, + targetUrlFingerprint, + targetInternalSchema: internalSchema, + inventory, + }); + await this.runTargetPreflightOperation( + dataDb, + internalSchema, + spaceId, + 'check_target_conflicts', + () => this.assertTargetHasNoSpaceConflicts(dataDb.url, internalSchema, inventory, spaceId) + ); + const prepared = await this.prepareMigrationTarget(dataDb.url, internalSchema); + + let connectionId: string | undefined; + const runTransaction = this.prismaService.$tx.bind(this.prismaService) as unknown as ( + fn: (prisma: IPrismaTransactionClient) => Promise + ) => Promise; + const job = await runTransaction(async (prisma) => { + const connection = await prisma.dataDbConnection.upsert({ + where: { urlFingerprint: prepared.urlFingerprint }, + create: { + provider: 'postgres', + encryptedUrl: prepared.encryptedUrl, + urlFingerprint: prepared.urlFingerprint, + displayHost: prepared.displayHost, + displayDatabase: prepared.displayDatabase, + internalSchema: prepared.internalSchema, + status: 'migrating', + schemaVersion: prepared.schemaVersion, + capabilities: prepared.capabilities, + lastValidatedAt: new Date(), + createdBy, + }, + update: { + encryptedUrl: prepared.encryptedUrl, + displayHost: prepared.displayHost, + displayDatabase: prepared.displayDatabase, + internalSchema: prepared.internalSchema, + status: 'migrating', + schemaVersion: prepared.schemaVersion, + capabilities: prepared.capabilities, + lastValidatedAt: new Date(), + lastError: null, + }, + select: { id: true }, + }); + connectionId = connection.id; + + return await prisma.spaceDataDbMigrationJob.create({ + data: { + spaceId, + sourceConnectionId: null, + targetConnectionId: connection.id, + targetMode: migrateSpaceTargetMode, + switchOnCompletion: dataDb.switchOnCompletion === true, + state: 'waiting_worker', + targetUrlFingerprint: prepared.urlFingerprint, + targetInternalSchema: prepared.internalSchema, + inventory, + createdBy, + }, + select: { id: true }, + }); + }); + + if (connectionId) { + await this.dataDbClientManager.invalidateConnection(connectionId); + } + + return { + jobId: job.id, + connectionId, + }; + } + + async preflightMigrationTargetForSpace(spaceId: string, dataDb: IDataDbPreflightRo) { + if (!dataDb.url) { + throw new CustomHttpException(dataDbUrlRequiredError, HttpErrorCode.VALIDATION_ERROR); + } + + const relatedSpaces = await resolveSpaceDataDbRelatedSpaces(this.prismaService, spaceId); + this.assertRelatedSpacesCanMigrateTogether(spaceId, relatedSpaces); + const targetDatabaseFingerprint = fingerprintDatabaseUrl(dataDb.url); + this.assertSpacesCanJoinTargetDataDb(relatedSpaces, targetDatabaseFingerprint, spaceId); + const internalSchema = await this.resolveMigrationTargetInternalSchemaOrThrow( + dataDb, + relatedSpaces, + targetDatabaseFingerprint, + spaceId + ); + const targetUrlFingerprint = fingerprintDataDbConnection(dataDb.url, internalSchema); + this.assertRelatedSpacesShareSameTarget(relatedSpaces, targetUrlFingerprint, spaceId); + const copySpaceIds = this.getRelatedSpaceIdsForCopy(relatedSpaces, targetUrlFingerprint); + this.assertRequestedSpaceIsCopySource(spaceId, copySpaceIds); + const inventory = await this.buildInventory(spaceId, internalSchema, relatedSpaces, { + copySpaceIds, + }); + + const preflight = await this.preflightService.preflight({ + ...dataDb, + targetMode: migrateSpaceTargetMode, + internalSchema, + }); + if (!preflight.ok) { + return preflight; + } + + await this.runTargetPreflightOperation( + dataDb, + internalSchema, + spaceId, + 'check_target_extension_dependencies', + () => this.assertTargetSupportsSourceDependencies(dataDb.url, inventory, spaceId) + ); + await this.assertTargetDiskCapacity(dataDb.url, inventory, spaceId); + const restartableTargetArtifactJob = await this.findRestartableTargetArtifactJob({ + spaceId, + targetUrlFingerprint, + targetInternalSchema: internalSchema, + }); + if ( + !restartableTargetArtifactJob || + !this.isSuccessfulDryRunJob(restartableTargetArtifactJob) + ) { + await this.runTargetPreflightOperation( + dataDb, + internalSchema, + spaceId, + 'check_target_conflicts', + () => this.assertTargetHasNoSpaceConflicts(dataDb.url, internalSchema, inventory, spaceId) + ); + } + + return { + ...preflight, + internalSchema, + }; + } + + async claimNextPendingMigrationJob(workerId: string) { + const claimableJob = (await this.migrationJobClient.spaceDataDbMigrationJob.findFirst({ + where: { + targetMode: migrateSpaceTargetMode, + state: { in: ['waiting_worker', 'pending'] }, + }, + orderBy: [{ createdTime: 'asc' }, { id: 'asc' }], + select: { id: true, state: true }, + })) as unknown as IClaimableMigrationJobRecord | null; + + if (!claimableJob) { + return null; + } + + const claimedAt = new Date(); + const claimed = await this.migrationJobClient.spaceDataDbMigrationJob.updateMany({ + where: { + id: claimableJob.id, + state: claimableJob.state, + }, + data: { + state: 'freezing_writes', + startedAt: claimedAt, + lastError: null, + copyStats: { + phase: 'worker_claimed', + worker: { + id: workerId, + previousState: claimableJob.state, + claimedAt: claimedAt.toISOString(), + }, + }, + }, + }); + + if (claimed.count !== 1) { + return null; + } + + return { jobId: claimableJob.id }; + } + + async recoverStaleActiveMigrationJobs( + workerId: string, + options: { staleAfterMs?: number; now?: Date } = {} + ) { + const staleAfterMs = Math.max( + 1, + Math.floor( + options.staleAfterMs ?? + readPositiveIntEnv( + 'BYODB_SPACE_DATA_DB_STALE_ACTIVE_JOB_TIMEOUT_MS', + defaultStaleActiveJobTimeoutMs + ) + ) + ); + const now = options.now ?? new Date(); + const staleBefore = new Date(now.getTime() - staleAfterMs); + const states = activeSpaceDataDbMigrationStates.filter( + (state) => state !== 'pending' && state !== 'waiting_worker' + ); + const jobs = (await this.migrationJobClient.spaceDataDbMigrationJob.findMany({ + where: { + targetMode: migrateSpaceTargetMode, + state: { in: states }, + OR: [{ lastModifiedTime: null }, { lastModifiedTime: { lt: staleBefore } }], + }, + include: { targetConnection: true }, + orderBy: [{ lastModifiedTime: 'asc' }, { createdTime: 'asc' }, { id: 'asc' }], + })) as unknown as IStaleMigrationJobRecord[]; + + const recovered: { jobId: string; state: string; lastError: string }[] = []; + for (const job of jobs) { + const lastError = `Space data database migration job ${job.id} is stale; no worker progress since ${ + job.lastModifiedTime?.toISOString() ?? 'never' + }`; + const marked = await this.migrationJobClient.spaceDataDbMigrationJob.updateMany({ + where: { + id: job.id, + state: job.state, + OR: [{ lastModifiedTime: null }, { lastModifiedTime: { lt: staleBefore } }], + }, + data: { + state: 'failed', + completedAt: now, + lastError, + copyStats: { + ...(this.asRecord(job.copyStats) ?? {}), + staleRecovery: { + errorCode: spaceDataDbStaleActiveJobErrorCode, + workerId, + previousState: job.state, + staleAfterMs, + staleBefore: staleBefore.toISOString(), + recoveredAt: now.toISOString(), + }, + }, + }, + }); + if (marked.count !== 1) { + continue; + } + + await this.resumeSourceComputedForJob(job.id).catch(() => undefined); + if (['copying', 'validating'].includes(job.state)) { + await this.cleanupTargetArtifactsForJob(job.id, 'stale_active_job', { + truncateSharedTables: await this.canTruncateTargetSharedTables(job.targetConnectionId), + }).catch(() => undefined); + } + + recovered.push({ jobId: job.id, state: job.state, lastError }); + } + + return recovered; + } + + private async cleanupRetryableTargetArtifacts(input: { + spaceId: string; + targetUrlFingerprint: string; + targetInternalSchema: string; + inventory: ISpaceDataDbInventory; + }) { + const retryableJob = await this.findRestartableTargetArtifactJob(input); + + if (!retryableJob) { + return; + } + + if (!this.isSuccessfulDryRunJob(retryableJob)) { + const previousInventory = this.normalizeInventory( + retryableJob.inventory, + retryableJob.spaceId ?? input.spaceId + ); + const mismatches = this.compareInventoryForCopy(previousInventory, input.inventory, { + compareSharedScope: false, + }); + if (mismatches.length) { + return; + } + } + + await this.cleanupTargetArtifactsForJob(retryableJob.id, 'retry_before_start', { + truncateSharedTables: await this.canTruncateTargetSharedTables( + retryableJob.targetConnectionId + ), + }); + } + + private async findRestartableTargetArtifactJob(input: { + spaceId: string; + targetUrlFingerprint: string; + targetInternalSchema: string; + }): Promise { + return (await this.migrationJobClient.spaceDataDbMigrationJob.findFirst({ + where: { + spaceId: input.spaceId, + targetMode: migrateSpaceTargetMode, + targetUrlFingerprint: input.targetUrlFingerprint, + targetInternalSchema: input.targetInternalSchema, + OR: [ + { state: { in: ['failed', 'canceled'] } }, + { state: 'succeeded', switchOnCompletion: false }, + ], + }, + orderBy: { createdTime: 'desc' }, + select: { + id: true, + spaceId: true, + state: true, + switchOnCompletion: true, + targetConnectionId: true, + inventory: true, + }, + })) as IRetryableMigrationJobRecord | null; + } + + private isSuccessfulDryRunJob( + job: Pick + ) { + return job.state === 'succeeded' && job.switchOnCompletion !== true; + } + + private async canTruncateTargetSharedTables(targetConnectionId: string | null) { + if (!targetConnectionId) { + return false; + } + const bindings = await this.prismaService.spaceDataDbBinding.findMany({ + where: { dataDbConnectionId: targetConnectionId }, + select: { spaceId: true }, + take: 1, + }); + return bindings.length === 0; + } + + private assertLargeMigrationConfirmed( + spaceId: string, + inventory: ISpaceDataDbInventory, + confirmed: boolean + ) { + const byteThreshold = readNonNegativeIntEnv( + 'BYODB_SPACE_DATA_DB_LARGE_MIGRATION_BYTES', + defaultLargeMigrationByteThreshold + ); + const rowThreshold = readNonNegativeIntEnv( + 'BYODB_SPACE_DATA_DB_LARGE_MIGRATION_ROWS', + defaultLargeMigrationRowThreshold + ); + const reasons = [ + byteThreshold > 0 && inventory.estimatedTotalBytes > byteThreshold + ? { + metric: 'estimatedTotalBytes', + estimated: inventory.estimatedTotalBytes, + threshold: byteThreshold, + } + : null, + rowThreshold > 0 && inventory.estimatedTotalRows > rowThreshold + ? { + metric: 'estimatedTotalRows', + estimated: inventory.estimatedTotalRows, + threshold: rowThreshold, + } + : null, + ].filter(Boolean); + + if (!reasons.length || confirmed) { + return; + } + + throw new CustomHttpException( + 'Space data database migration is above the large-space confirmation threshold', + HttpErrorCode.CONFLICT, + { + errorCode: spaceDataDbLargeMigrationConfirmationRequiredErrorCode, + confirmationField: 'confirmLargeMigration', + spaceId, + estimatedTotalBytes: inventory.estimatedTotalBytes, + estimatedTotalRows: inventory.estimatedTotalRows, + baseCount: inventory.baseIds.length, + tableCount: inventory.tableIds.length, + physicalSchemaCount: inventory.physicalSchemas.length, + thresholds: { + bytes: byteThreshold, + rows: rowThreshold, + }, + reasons, + } + ); + } + + async assertTargetDiskCapacity( + targetUrl: string, + inventory: ISpaceDataDbInventory, + spaceId: string + ) { + const multiplier = readPositiveNumberEnv( + 'BYODB_SPACE_DATA_DB_TARGET_DISK_MULTIPLIER', + defaultTargetDiskMultiplier + ); + const minFreeBytes = readNonNegativeIntEnv( + 'BYODB_SPACE_DATA_DB_TARGET_DISK_MIN_FREE_BYTES', + defaultTargetDiskMinFreeBytes + ); + const requiredBytes = Math.max( + Math.ceil(inventory.estimatedTotalBytes * multiplier), + minFreeBytes + ); + const capacity = await this.inspectTargetDiskCapacity(targetUrl); + + if ( + !capacity.checked || + capacity.availableBytes == null || + capacity.availableBytes >= requiredBytes + ) { + return; + } + + throw new CustomHttpException( + 'Target data database does not have enough disk space for migration', + HttpErrorCode.CONFLICT, + { + errorCode: spaceDataDbTargetDiskInsufficientErrorCode, + spaceId, + estimatedTotalBytes: inventory.estimatedTotalBytes, + estimatedTotalRows: inventory.estimatedTotalRows, + requiredBytes, + availableBytes: capacity.availableBytes, + multiplier, + minFreeBytes, + targetDisk: capacity, + } + ); + } + + private async inspectTargetDiskCapacity(targetUrl: string): Promise { + const checkedAt = new Date().toISOString(); + const client = this.clientFactory(targetUrl); + try { + const dataDirectoryRows = normalizeRawRows<{ setting: string }>( + await client.raw(`SELECT current_setting('data_directory') AS setting`) + ); + const dataDirectory = dataDirectoryRows[0]?.setting; + if (!dataDirectory) { + return { checked: false, reason: 'data_directory_unavailable', checkedAt }; + } + + const command = [ + 'bash', + '-lc', + [ + 'set -euo pipefail', + `target=${shellQuote(dataDirectory)}`, + 'mount=$(df -P "$target" | awk \'NR==2 {print $6}\')', + 'df -B1 -P "$mount" | awk -v mount="$mount" \'NR==2 {print mount "|" $2 "|" $3 "|" $4}\'', + ].join('; '), + ] + .map(shellQuote) + .join(' '); + await client.raw(`CREATE TEMP TABLE IF NOT EXISTS __teable_target_disk_capacity(line text)`); + await client.raw(`TRUNCATE __teable_target_disk_capacity`); + await client.raw( + `COPY __teable_target_disk_capacity FROM PROGRAM '${command.replace(/'/g, "''")}'` + ); + const rows = normalizeRawRows<{ line: string }>( + await client.raw(`SELECT line FROM __teable_target_disk_capacity LIMIT 1`) + ); + const [mountPath, totalBytes, usedBytes, availableBytes] = String(rows[0]?.line ?? '').split( + '|' + ); + const available = Number(availableBytes); + const total = Number(totalBytes); + const used = Number(usedBytes); + if (!mountPath || !Number.isFinite(available)) { + return { checked: false, reason: 'df_output_unavailable', dataDirectory, checkedAt }; + } + return { + checked: true, + dataDirectory, + mountPath, + availableBytes: available, + totalBytes: Number.isFinite(total) ? total : undefined, + usedBytes: Number.isFinite(used) ? used : undefined, + checkedAt, + }; + } catch (error) { + return { + checked: false, + reason: + error instanceof Error ? this.sanitizeTargetDiskError(error.message) : String(error), + checkedAt, + }; + } finally { + await client.destroy().catch(() => undefined); + } + } + + private sanitizeTargetDiskError(message: string) { + return message.replace(/postgresql:\/\/[^@\s]+@/g, 'postgresql://***@').slice(0, 500); + } + + async copyBaseSchemasForJob( + jobId: string, + options: { + workDir: string; + jobs?: number; + timeoutMs?: number; + strategy?: ISpaceDataDbBaseSchemaCopyStrategy; + progressPollMs?: number; + } + ) { + const job = await this.migrationJobClient.spaceDataDbMigrationJob.findUnique({ + where: { id: jobId }, + include: { targetConnection: true }, + }); + if (!job) { + throw new CustomHttpException(`Migration job ${jobId} not found`, HttpErrorCode.NOT_FOUND); + } + if (!job.targetConnection?.encryptedUrl) { + throw new CustomHttpException( + `Migration job ${jobId} has no target connection`, + HttpErrorCode.VALIDATION_ERROR + ); + } + + const context = this.buildBaseSchemaCopyContext(job, options); + const { inventory, schemaNames, strategy, progressPollMs, startedAt } = context; + const sourceDataDb = await this.getSourceDataDbForJob(job); + const targetUrl = decryptDataDbUrl(job.targetConnection.encryptedUrl); + + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'copying', + copyStats: { + phase: 'copying_base_schemas', + progress: this.buildMigrationProgress(job, inventory, 'copying_base_schemas'), + baseSchemas: { + schemaNames, + strategy, + progressPollMs, + excludedForeignKeys: inventory.outOfScopeForeignKeys, + startedAt, + }, + }, + lastError: null, + }, + }); + + let progressClients: IBaseSchemaProgressClients | null = null; + try { + await this.installTargetPublicUndoCaptureCompatibility(targetUrl); + progressClients = this.createBaseSchemaProgressClients(strategy, sourceDataDb.url, targetUrl); + const result = await this.copyService.copyBaseSchemas({ + sourceUrl: sourceDataDb.url, + targetUrl, + schemaNames, + workDir: options.workDir, + jobs: options.jobs, + strategy, + excludedForeignKeys: inventory.outOfScopeForeignKeys, + processOptions: this.buildCancelableProcessOptions( + jobId, + options.timeoutMs, + progressPollMs + ), + hooks: progressClients + ? this.buildBaseSchemaCopyHooks(job, context, progressClients) + : undefined, + }); + const copiedRelations = await this.withMigrationHeartbeat( + () => + this.updateBaseSchemaCopyHeartbeat(job, context, { + stage: 'post_copy_stats', + updatedAt: new Date().toISOString(), + }), + () => this.inspectBaseSchemaCopyRows(targetUrl, inventory), + progressPollMs + ); + const copyStats = this.buildBaseSchemaCompletedStats(job, context, result, copiedRelations); + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'copying', + copyStats, + lastError: null, + }, + }); + return copyStats; + } catch (error) { + if (await this.isProcessCancelErrorForJob(error, jobId)) { + throw error; + } + const lastError = this.buildProcessFailureMessage(error); + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'failed', + lastError, + copyStats: { + phase: 'base_schemas_failed', + progress: this.buildMigrationProgress(job, inventory, 'base_schemas_failed'), + baseSchemas: { + schemaNames, + progressPollMs, + excludedForeignKeys: inventory.outOfScopeForeignKeys, + failedAt: new Date().toISOString(), + error: lastError, + failure: this.buildProcessFailureStats(error), + }, + }, + }, + }); + throw error; + } finally { + await progressClients?.dump.destroy().catch(() => undefined); + await progressClients?.restore.destroy().catch(() => undefined); + } + } + + private buildBaseSchemaCopyContext( + job: IMigrationJobRecord, + options: { strategy?: ISpaceDataDbBaseSchemaCopyStrategy; progressPollMs?: number } + ): IBaseSchemaCopyContext { + const inventory = this.normalizeInventory(job.inventory, job.spaceId); + const requestedStrategy = options.strategy ?? 'pg_dump_stream_restore'; + return { + inventory, + schemaNames: inventory.physicalSchemas.length + ? inventory.physicalSchemas.map((schema) => schema.schemaName) + : inventory.baseIds, + strategy: + requestedStrategy === 'pg_dump_stream_restore' && inventory.outOfScopeForeignKeys.length + ? 'pg_dump_restore' + : requestedStrategy, + progressPollMs: Math.max( + 1, + Math.floor( + options.progressPollMs ?? + readPositiveIntEnv( + 'BYODB_SPACE_DATA_DB_COPY_PROGRESS_POLL_MS', + defaultCopyProgressPollMs + ) + ) + ), + startedAt: new Date().toISOString(), + }; + } + + private createBaseSchemaProgressClients( + strategy: ISpaceDataDbBaseSchemaCopyStrategy, + sourceUrl: string, + targetUrl: string + ): IBaseSchemaProgressClients | null { + return strategy === 'pg_dump_restore' || strategy === 'pg_dump_stream_restore' + ? { + dump: this.clientFactory(sourceUrl), + restore: this.clientFactory(targetUrl), + } + : null; + } + + private buildBaseSchemaCopyHooks( + job: IMigrationJobRecord, + context: IBaseSchemaCopyContext, + clients: IBaseSchemaProgressClients + ) { + return { + onDumpProgressPoll: () => + this.updateBaseSchemaCopyProgress({ + job, + ...context, + phase: 'dump', + client: clients.dump, + }), + onRestoreProgressPoll: () => + this.updateBaseSchemaCopyProgress({ + job, + ...context, + phase: 'restore', + client: clients.restore, + }), + }; + } + + private async updateBaseSchemaCopyHeartbeat( + job: IMigrationJobRecord, + context: IBaseSchemaCopyContext, + heartbeat: IBaseSchemaCopyHeartbeat + ) { + await this.migrationJobClient.spaceDataDbMigrationJob + .update({ + where: { id: job.id }, + data: { + state: 'copying', + copyStats: { + phase: 'copying_base_schemas', + progress: this.buildMigrationProgress(job, context.inventory, 'copying_base_schemas'), + baseSchemas: { + schemaNames: context.schemaNames, + strategy: context.strategy, + progressPollMs: context.progressPollMs, + excludedForeignKeys: context.inventory.outOfScopeForeignKeys, + startedAt: context.startedAt, + heartbeat, + }, + }, + lastError: null, + }, + }) + .catch(() => undefined); + } + + private buildBaseSchemaProcessStats(result: ISpaceDataDbBaseSchemaCopyResult) { + if (result.strategy === 'pgcopydb') { + return { + strategy: result.strategy, + pgcopydb: result.pgcopydb, + }; + } + if (result.strategy === 'pg_dump_stream_restore') { + return { + strategy: result.strategy, + stream: result.stream, + }; + } + return { + strategy: result.strategy, + dump: result.dump, + ...(result.restoreList ? { restoreList: result.restoreList } : {}), + ...(result.filteredRestoreList ? { filteredRestoreList: result.filteredRestoreList } : {}), + restore: result.restore, + }; + } + + private buildBaseSchemaCompletedStats( + job: IMigrationJobRecord, + context: IBaseSchemaCopyContext, + result: ISpaceDataDbBaseSchemaCopyResult, + copiedRelations: IBaseSchemaRelationCopySummary[] + ) { + return { + phase: 'base_schemas_completed', + progress: this.buildMigrationProgress(job, context.inventory, 'base_schemas_completed'), + baseSchemas: { + schemaNames: context.schemaNames, + progressPollMs: context.progressPollMs, + excludedForeignKeys: context.inventory.outOfScopeForeignKeys, + copiedRelationCount: copiedRelations.length, + totalCopiedRows: copiedRelations.reduce((sum, relation) => sum + relation.copiedRows, 0), + copiedRelations, + ...this.buildBaseSchemaProcessStats(result), + completedAt: new Date().toISOString(), + }, + }; + } + + async runMigrationJob(jobId: string, options: IRunMigrationJobOptions = {}) { + const { + workDir, + jobs, + baseSchemaCopyStrategy, + sharedTableCopyStrategy, + timeoutMs, + computedDrainTimeoutMs, + computedDrainPollMs, + computedProcessingLeaseMs, + schemaOperationDrainTimeoutMs, + schemaOperationDrainPollMs, + backgroundWriterDrainTimeoutMs, + backgroundWriterDrainPollMs, + backgroundWriterDrainProbeTimeoutMs, + backgroundWriterQueueScanBatchSize, + backgroundWriterQueueScanLimit, + tempDiskMultiplier, + tempDiskMinFreeBytes, + } = this.resolveRunMigrationJobOptions(jobId, options); + + await mkdir(workDir, { recursive: true }); + await this.assertMigrationNotCanceled(jobId); + const freezeSourceWrites = await this.shouldFreezeSourceWritesForJob(jobId); + let pause: { created: boolean } = { created: false }; + let targetArtifactsMayExist = false; + try { + await this.assertPostgresCopyToolsAvailableForJob(jobId, { + timeoutMs, + baseSchemaCopyStrategy, + }); + if (freezeSourceWrites) { + pause = await this.pauseSourceComputedForJob(jobId); + await this.waitForSourceComputedDrainForJob(jobId, { + timeoutMs: computedDrainTimeoutMs, + pollMs: computedDrainPollMs, + processingLeaseMs: computedProcessingLeaseMs, + }); + await this.waitForSchemaOperationsForJob(jobId, { + timeoutMs: schemaOperationDrainTimeoutMs, + pollMs: schemaOperationDrainPollMs, + }); + await this.waitForBackgroundWritersForJob(jobId, { + timeoutMs: backgroundWriterDrainTimeoutMs, + pollMs: backgroundWriterDrainPollMs, + probeTimeoutMs: backgroundWriterDrainProbeTimeoutMs, + queueScanBatchSize: backgroundWriterQueueScanBatchSize, + queueScanLimit: backgroundWriterQueueScanLimit, + }); + await this.assertSourceInventoryUnchangedForJob(jobId); + } + await this.assertTempWorkDirCapacityForJob(jobId, workDir, { + multiplier: tempDiskMultiplier, + minFreeBytes: tempDiskMinFreeBytes, + baseSchemaCopyStrategy, + }); + await this.assertMigrationNotCanceled(jobId); + targetArtifactsMayExist = true; + await this.copyBaseSchemasForJob(jobId, { + workDir, + jobs, + timeoutMs, + strategy: baseSchemaCopyStrategy, + }); + await this.copySharedRowsForJob(jobId, { timeoutMs, strategy: sharedTableCopyStrategy }); + return await this.validateAndSwitchJob(jobId); + } catch (error) { + if (pause.created) { + await this.resumeSourceComputedForJob(jobId).catch(() => undefined); + } + if (targetArtifactsMayExist) { + await this.cleanupTargetArtifactsForJob(jobId, 'pre_switch_failure').catch(() => undefined); + } + await this.completeFailedMigrationJob(jobId, error).catch(() => undefined); + throw error; + } + } + + private async shouldFreezeSourceWritesForJob(jobId: string) { + const job = await this.getMigrationJob(jobId); + return job.switchOnCompletion === true; + } + + async assertPostgresCopyToolsAvailableForJob( + jobId: string, + options: { + timeoutMs?: number; + baseSchemaCopyStrategy?: ISpaceDataDbBaseSchemaCopyStrategy; + } = {} + ) { + const baseSchemaCopyStrategy = options.baseSchemaCopyStrategy ?? 'pg_dump_stream_restore'; + const requiredTools = postgresCopyToolsForStrategy(baseSchemaCopyStrategy); + const startedAt = new Date().toISOString(); + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + copyStats: { + phase: 'postgres_tools_checking', + postgresTools: { + requiredTools, + startedAt, + }, + }, + lastError: null, + }, + }); + + try { + const results = await this.copyService.assertPostgresToolsAvailable( + baseSchemaCopyStrategy, + this.buildCancelableProcessOptions( + jobId, + Math.min( + options.timeoutMs ?? defaultPostgresToolCheckTimeoutMs, + defaultPostgresToolCheckTimeoutMs + ) + ) + ); + const copyStats = { + phase: 'postgres_tools_checked', + postgresTools: { + requiredTools, + results, + startedAt, + completedAt: new Date().toISOString(), + }, + }; + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + copyStats, + lastError: null, + }, + }); + return copyStats; + } catch (error) { + if (await this.isProcessCancelErrorForJob(error, jobId)) { + throw error; + } + const lastError = this.buildProcessFailureMessage(error); + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'failed', + lastError, + copyStats: { + phase: 'postgres_tools_unavailable', + postgresTools: { + requiredTools, + startedAt, + failedAt: new Date().toISOString(), + error: lastError, + failure: this.buildProcessFailureStats(error), + }, + }, + }, + }); + throw new CustomHttpException( + 'Required PostgreSQL client tools are unavailable for space data DB migration', + HttpErrorCode.VALIDATION_ERROR, + { + errorCode: spaceDataDbPostgresToolUnavailableErrorCode, + migrationJobId: jobId, + requiredTools, + cause: lastError, + } + ); + } + } + + private async updateBaseSchemaCopyProgress(input: { + job: IMigrationJobRecord; + inventory: ISpaceDataDbInventory; + schemaNames: string[]; + strategy: ISpaceDataDbBaseSchemaCopyStrategy; + progressPollMs: number; + startedAt: string; + phase: IBaseSchemaCopyProgressPhase; + client: IDataDbPreflightClient; + }) { + await this.updateBaseSchemaCopyHeartbeat(input.job, input, { + stage: 'progress_poll', + phase: input.phase, + updatedAt: new Date().toISOString(), + }); + + const activeCopy = await this.inspectBaseSchemaActiveCopyProgress( + input.client, + input.inventory, + input.schemaNames, + input.phase + ).catch((error) => ({ + phase: input.phase, + sampledAt: new Date().toISOString(), + activeRelationCount: 0, + activeRelations: [], + error: error instanceof Error ? error.message : String(error), + })); + + await this.migrationJobClient.spaceDataDbMigrationJob + .update({ + where: { id: input.job.id }, + data: { + state: 'copying', + copyStats: { + phase: 'copying_base_schemas', + progress: this.buildMigrationProgress( + input.job, + input.inventory, + 'copying_base_schemas' + ), + baseSchemas: { + schemaNames: input.schemaNames, + strategy: input.strategy, + progressPollMs: input.progressPollMs, + startedAt: input.startedAt, + activeCopy, + heartbeat: { + stage: 'progress_poll', + phase: input.phase, + updatedAt: activeCopy.sampledAt, + }, + progressUpdatedAt: activeCopy.sampledAt, + }, + }, + lastError: null, + }, + }) + .catch(() => undefined); + } + + private async inspectBaseSchemaActiveCopyProgress( + client: IDataDbPreflightClient, + inventory: ISpaceDataDbInventory, + schemaNames: string[], + phase: IBaseSchemaCopyProgressPhase + ): Promise { + const sampledAt = new Date().toISOString(); + if (!schemaNames.length) { + return { + phase, + sampledAt, + activeRelationCount: 0, + activeRelations: [], + }; + } + + const rows = normalizeRawRows<{ + schemaName: string; + relationName: string; + command: string | null; + copyType: string | null; + bytesProcessed: string | number | bigint | null; + bytesTotal: string | number | bigint | null; + tuplesProcessed: string | number | bigint | null; + tuplesExcluded: string | number | bigint | null; + }>( + await client.raw( + ` + SELECT + n.nspname AS "schemaName", + c.relname AS "relationName", + p.command AS "command", + p.type AS "copyType", + p.bytes_processed AS "bytesProcessed", + p.bytes_total AS "bytesTotal", + p.tuples_processed AS "tuplesProcessed", + p.tuples_excluded AS "tuplesExcluded" + FROM pg_stat_progress_copy p + JOIN pg_class c ON c.oid = p.relid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = ANY(?::text[]) + ORDER BY n.nspname ASC, c.relname ASC, p.pid ASC + `, + [schemaNames] + ) + ); + const relationStats = new Map( + inventory.physicalSchemas + .flatMap((schema) => schema.relations) + .map((relation) => [`${relation.schemaName}.${relation.relationName}`, relation] as const) + ); + const activeRelations = rows.map((row) => { + const relation = relationStats.get(`${row.schemaName}.${row.relationName}`); + return { + schemaName: row.schemaName, + relationName: row.relationName, + command: row.command ?? null, + copyType: row.copyType ?? null, + bytesProcessed: this.toNullableNumber(row.bytesProcessed), + bytesTotal: this.toNullableNumber(row.bytesTotal), + tuplesProcessed: this.toNullableNumber(row.tuplesProcessed), + tuplesExcluded: this.toNullableNumber(row.tuplesExcluded), + estimatedRows: relation?.estimatedRows ?? null, + totalBytes: relation?.totalBytes ?? null, + }; + }); + + return { + phase, + sampledAt, + activeRelationCount: activeRelations.length, + activeRelations, + }; + } + + private async installTargetPublicUndoCaptureCompatibility(targetUrl: string) { + const client = this.clientFactory(targetUrl); + try { + await client.raw(` + CREATE SCHEMA IF NOT EXISTS "public"; + CREATE OR REPLACE FUNCTION "public"."__teable_capture_undo_row"() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + DECLARE + batch_id text; + captured_record_id text; + captured_old_row jsonb; + captured_new_row jsonb; + BEGIN + batch_id := current_setting('teable.undo_batch_id', true); + + IF TG_OP = 'INSERT' THEN + captured_record_id := COALESCE(NEW."__id"::text, ''); + captured_new_row := to_jsonb(NEW); + ELSIF TG_OP = 'UPDATE' THEN + captured_record_id := COALESCE(NEW."__id"::text, OLD."__id"::text, ''); + captured_old_row := to_jsonb(OLD); + captured_new_row := to_jsonb(NEW); + ELSIF TG_OP = 'DELETE' THEN + captured_record_id := COALESCE(OLD."__id"::text, ''); + captured_old_row := to_jsonb(OLD); + END IF; + + IF batch_id IS NULL OR batch_id = '' THEN + RETURN NULL; + END IF; + + INSERT INTO "__undo_log" ( + "batch_id", + "operation", + "table_name", + "record_id", + "old_row", + "new_row" + ) + VALUES ( + batch_id, + TG_OP, + TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME, + captured_record_id, + captured_old_row, + captured_new_row + ); + + RETURN NULL; + END; + $$; + `); + } finally { + await client.destroy().catch(() => undefined); + } + } + + private async inspectBaseSchemaCopyRows( + targetUrl: string, + inventory: ISpaceDataDbInventory + ): Promise { + const client = this.clientFactory(targetUrl); + try { + const summaries: IBaseSchemaRelationCopySummary[] = []; + const relations = inventory.physicalSchemas.flatMap((schema) => schema.relations); + for (const relation of relations) { + if (!relationKindsWithRows.has(relation.relationKind)) { + continue; + } + summaries.push({ + schemaName: relation.schemaName, + relationName: relation.relationName, + relationKind: relation.relationKind, + copiedRows: await this.countRows(client, relation.schemaName, relation.relationName), + estimatedRows: relation.estimatedRows, + totalBytes: relation.totalBytes, + }); + } + return summaries.sort( + (left, right) => + left.schemaName.localeCompare(right.schemaName) || + left.relationName.localeCompare(right.relationName) + ); + } finally { + await client.destroy().catch(() => undefined); + } + } + + private async withMigrationHeartbeat( + heartbeat: () => Promise, + task: () => Promise, + intervalMs: number + ): Promise { + let active = true; + const heartbeatState: { inFlight: Promise | null } = { inFlight: null }; + const runHeartbeat = () => { + if (!active || heartbeatState.inFlight) { + return heartbeatState.inFlight; + } + heartbeatState.inFlight = heartbeat() + .catch(() => undefined) + .finally(() => { + heartbeatState.inFlight = null; + }); + return heartbeatState.inFlight; + }; + + await runHeartbeat(); + const timer = setInterval( + () => { + void runHeartbeat(); + }, + Math.max(1, Math.floor(intervalMs)) + ); + try { + return await task(); + } finally { + active = false; + clearInterval(timer); + const lastHeartbeat = heartbeatState.inFlight; + if (lastHeartbeat) { + await lastHeartbeat.catch(() => undefined); + } + } + } + + private resolveRunMigrationJobOptions( + jobId: string, + options: IRunMigrationJobOptions + ): IResolvedRunMigrationJobOptions { + const requestedJobs = + options.jobs ?? readPositiveIntEnv('BYODB_SPACE_DATA_DB_COPY_JOBS', defaultMigrationCopyJobs); + const maxJobs = + options.maxJobs ?? + readPositiveIntEnv('BYODB_SPACE_DATA_DB_COPY_MAX_JOBS', defaultMigrationCopyMaxJobs); + return { + workDir: options.workDir ?? path.join(tmpdir(), 'teable-space-data-db-migrations', jobId), + jobs: Math.min(Math.max(1, Math.floor(requestedJobs)), Math.max(1, Math.floor(maxJobs))), + maxJobs, + baseSchemaCopyStrategy: + options.baseSchemaCopyStrategy ?? + readBaseSchemaCopyStrategyEnv( + 'BYODB_SPACE_DATA_DB_BASE_SCHEMA_COPY_STRATEGY', + 'pg_dump_stream_restore' + ), + sharedTableCopyStrategy: + options.sharedTableCopyStrategy ?? + readSharedTableCopyStrategyEnv( + 'BYODB_SPACE_DATA_DB_SHARED_TABLE_COPY_STRATEGY', + 'psql_copy' + ), + timeoutMs: optionOrPositiveIntEnv( + options.timeoutMs, + 'BYODB_SPACE_DATA_DB_COPY_TIMEOUT_MS', + defaultMigrationCopyTimeoutMs + ), + computedDrainTimeoutMs: optionOrPositiveIntEnv( + options.computedDrainTimeoutMs, + 'BYODB_SPACE_DATA_DB_COMPUTED_DRAIN_TIMEOUT_MS', + defaultComputedDrainTimeoutMs + ), + computedDrainPollMs: optionOrPositiveIntEnv( + options.computedDrainPollMs, + 'BYODB_SPACE_DATA_DB_COMPUTED_DRAIN_POLL_MS', + defaultComputedDrainPollMs + ), + computedProcessingLeaseMs: optionOrPositiveIntEnv( + options.computedProcessingLeaseMs, + 'BYODB_SPACE_DATA_DB_COMPUTED_PROCESSING_LEASE_MS', + defaultComputedProcessingLeaseMs + ), + schemaOperationDrainTimeoutMs: optionOrPositiveIntEnv( + options.schemaOperationDrainTimeoutMs, + 'BYODB_SPACE_DATA_DB_SCHEMA_OPERATION_DRAIN_TIMEOUT_MS', + defaultSchemaOperationDrainTimeoutMs + ), + schemaOperationDrainPollMs: optionOrPositiveIntEnv( + options.schemaOperationDrainPollMs, + 'BYODB_SPACE_DATA_DB_SCHEMA_OPERATION_DRAIN_POLL_MS', + defaultSchemaOperationDrainPollMs + ), + backgroundWriterDrainTimeoutMs: optionOrPositiveIntEnv( + options.backgroundWriterDrainTimeoutMs, + 'BYODB_SPACE_DATA_DB_BACKGROUND_WRITER_DRAIN_TIMEOUT_MS', + defaultBackgroundWriterDrainTimeoutMs + ), + backgroundWriterDrainPollMs: optionOrPositiveIntEnv( + options.backgroundWriterDrainPollMs, + 'BYODB_SPACE_DATA_DB_BACKGROUND_WRITER_DRAIN_POLL_MS', + defaultBackgroundWriterDrainPollMs + ), + backgroundWriterDrainProbeTimeoutMs: optionOrPositiveIntEnv( + options.backgroundWriterDrainProbeTimeoutMs, + 'BYODB_SPACE_DATA_DB_BACKGROUND_WRITER_DRAIN_PROBE_TIMEOUT_MS', + defaultBackgroundWriterDrainProbeTimeoutMs + ), + backgroundWriterQueueScanBatchSize: optionOrPositiveIntEnv( + options.backgroundWriterQueueScanBatchSize, + 'BYODB_SPACE_DATA_DB_BACKGROUND_WRITER_QUEUE_SCAN_BATCH_SIZE', + defaultBackgroundWriterQueueScanBatchSize + ), + backgroundWriterQueueScanLimit: optionOrPositiveIntEnv( + options.backgroundWriterQueueScanLimit, + 'BYODB_SPACE_DATA_DB_BACKGROUND_WRITER_QUEUE_SCAN_LIMIT', + defaultBackgroundWriterQueueScanLimit + ), + tempDiskMultiplier: optionOrPositiveNumberEnv( + options.tempDiskMultiplier, + 'BYODB_SPACE_DATA_DB_TEMP_DISK_MULTIPLIER', + defaultTempDiskMultiplier + ), + tempDiskMinFreeBytes: optionOrPositiveIntEnv( + options.tempDiskMinFreeBytes, + 'BYODB_SPACE_DATA_DB_TEMP_DISK_MIN_FREE_BYTES', + defaultTempDiskMinFreeBytes + ), + }; + } + + async waitForSchemaOperationsForJob( + jobId: string, + options: { timeoutMs?: number; pollMs?: number } = {} + ): Promise { + const timeoutMs = Math.max( + 0, + Math.floor(options.timeoutMs ?? defaultSchemaOperationDrainTimeoutMs) + ); + const pollMs = Math.max(1, Math.floor(options.pollMs ?? defaultSchemaOperationDrainPollMs)); + const startedAt = Date.now(); + const job = await this.getMigrationJob(jobId); + const inventory = this.normalizeInventory(job.inventory, job.spaceId); + + if (!inventory.baseIds.length && !inventory.tableIds.length) { + const drained = this.buildSchemaOperationDrainStats(0, []); + await this.updateSchemaOperationDrainStats( + job, + inventory, + 'schema_operations_drained', + drained + ); + return drained; + } + + await this.updateSchemaOperationDrainStats( + job, + inventory, + 'schema_operations_draining', + this.buildSchemaOperationDrainStats(0, []) + ); + + try { + for (;;) { + await this.assertMigrationNotCanceled(jobId, job.spaceId); + const stats = await this.inspectSchemaOperationDrain(inventory); + if (stats.openCount === 0) { + await this.updateSchemaOperationDrainStats( + job, + inventory, + 'schema_operations_drained', + stats + ); + return stats; + } + + if (Date.now() - startedAt >= timeoutMs) { + const lastError = `Timed out waiting for schema operations to drain for migration job ${jobId}`; + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'failed', + lastError, + copyStats: { + phase: 'schema_operation_drain_timeout', + progress: this.buildMigrationProgress( + job, + inventory, + 'schema_operation_drain_timeout' + ), + schemaOperations: stats, + }, + }, + }); + throw new CustomHttpException(lastError, HttpErrorCode.CONFLICT, { + errorCode: spaceDataDbSchemaOperationDrainTimeoutErrorCode, + openCount: stats.openCount, + spaceId: job.spaceId, + migrationJobId: jobId, + }); + } + + await this.updateSchemaOperationDrainStats( + job, + inventory, + 'schema_operations_draining', + stats + ); + await delay(pollMs); + } + } catch (error) { + if (error instanceof CustomHttpException) { + throw error; + } + const lastError = this.buildProcessFailureMessage(error); + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'failed', + lastError, + copyStats: { + phase: 'schema_operation_drain_failed', + progress: this.buildMigrationProgress(job, inventory, 'schema_operation_drain_failed'), + schemaOperations: { + failedAt: new Date().toISOString(), + }, + }, + }, + }); + throw error; + } + } + + async waitForBackgroundWritersForJob( + jobId: string, + options: { + timeoutMs?: number; + pollMs?: number; + probeTimeoutMs?: number; + queueScanBatchSize?: number; + queueScanLimit?: number; + } = {} + ): Promise { + const timeoutMs = Math.max( + 0, + Math.floor(options.timeoutMs ?? defaultBackgroundWriterDrainTimeoutMs) + ); + const pollMs = Math.max(1, Math.floor(options.pollMs ?? defaultBackgroundWriterDrainPollMs)); + const probeTimeoutMs = Math.max( + 0, + Math.floor(options.probeTimeoutMs ?? defaultBackgroundWriterDrainProbeTimeoutMs) + ); + const queueScanBatchSize = Math.max( + 1, + Math.floor(options.queueScanBatchSize ?? defaultBackgroundWriterQueueScanBatchSize) + ); + const queueScanLimit = Math.max( + 1, + Math.floor(options.queueScanLimit ?? defaultBackgroundWriterQueueScanLimit) + ); + const startedAt = Date.now(); + const job = await this.getMigrationJob(jobId); + const inventory = this.normalizeInventory(job.inventory, job.spaceId); + + await this.updateBackgroundWriterDrainStats( + job, + inventory, + 'background_writers_draining', + this.buildBackgroundWriterDrainStats( + { openCount: 0, sample: [] }, + { openCount: 0, sample: [] } + ) + ); + + try { + for (;;) { + await this.assertMigrationNotCanceled(jobId, job.spaceId); + const stats = await this.inspectBackgroundWriterDrain(job.spaceId, inventory, { + probeTimeoutMs, + queueScanBatchSize, + queueScanLimit, + }); + if (stats.openCount === 0) { + await this.updateBackgroundWriterDrainStats( + job, + inventory, + 'background_writers_drained', + stats + ); + return stats; + } + + if (Date.now() - startedAt >= timeoutMs) { + const lastError = `Timed out waiting for background writers to drain for migration job ${jobId}`; + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'failed', + lastError, + copyStats: { + phase: 'background_writer_drain_timeout', + progress: this.buildMigrationProgress( + job, + inventory, + 'background_writer_drain_timeout' + ), + backgroundWriters: stats, + }, + }, + }); + throw new CustomHttpException(lastError, HttpErrorCode.CONFLICT, { + errorCode: spaceDataDbBackgroundWriterDrainTimeoutErrorCode, + openCount: stats.openCount, + provisionResourceCount: stats.provisionResourceCount, + queueJobCount: stats.queueJobCount, + spaceId: job.spaceId, + migrationJobId: jobId, + }); + } + + await this.updateBackgroundWriterDrainStats( + job, + inventory, + 'background_writers_draining', + stats + ); + await delay(pollMs); + } + } catch (error) { + if (error instanceof CustomHttpException) { + throw error; + } + const lastError = error instanceof Error ? error.message : String(error); + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'failed', + lastError, + copyStats: { + phase: 'background_writer_drain_failed', + progress: this.buildMigrationProgress(job, inventory, 'background_writer_drain_failed'), + backgroundWriters: { + failedAt: new Date().toISOString(), + }, + }, + }, + }); + throw error; + } + } + + async assertSourceInventoryUnchangedForJob(jobId: string): Promise { + const job = await this.getMigrationJob(jobId); + const expected = this.normalizeInventory(job.inventory, job.spaceId); + const actual = await this.buildInventory(job.spaceId, job.targetInternalSchema, undefined, { + copySpaceIds: expected.copySpaceIds, + }); + const mismatches = this.compareInventoryForCopy(expected, actual, { + compareSharedScope: true, + }); + const checkedAt = new Date().toISOString(); + + if (!mismatches.length) { + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + copyStats: { + phase: 'source_inventory_verified', + progress: this.buildMigrationProgress(job, actual, 'source_inventory_verified'), + sourceInventory: { + baseCount: actual.baseIds.length, + tableCount: actual.tableIds.length, + physicalRelationCount: this.buildPhysicalRelationKeys(actual).length, + checkedAt, + }, + }, + lastError: null, + }, + }); + return; + } + + const lastError = `Source inventory changed after migration job ${jobId} was created`; + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'failed', + lastError, + copyStats: { + phase: 'source_inventory_changed', + progress: this.buildMigrationProgress(job, expected, 'source_inventory_changed'), + sourceInventory: { + mismatches, + checkedAt, + }, + }, + }, + }); + throw new CustomHttpException(lastError, HttpErrorCode.CONFLICT, { + errorCode: spaceDataDbInventoryChangedErrorCode, + mismatches, + spaceId: job.spaceId, + migrationJobId: jobId, + }); + } + + async assertTempWorkDirCapacityForJob( + jobId: string, + workDir: string, + options: { + multiplier?: number; + minFreeBytes?: number; + baseSchemaCopyStrategy?: ISpaceDataDbBaseSchemaCopyStrategy; + } = {} + ): Promise { + const job = await this.getMigrationJob(jobId); + const inventory = this.normalizeInventory(job.inventory, job.spaceId); + const multiplier = Math.max(1, options.multiplier ?? defaultTempDiskMultiplier); + const minFreeBytes = Math.max(0, options.minFreeBytes ?? defaultTempDiskMinFreeBytes); + const strategy = options.baseSchemaCopyStrategy ?? 'pg_dump_stream_restore'; + const requiresFullDumpSpace = strategy !== 'pg_dump_stream_restore'; + const estimatedDumpBytes = requiresFullDumpSpace + ? Math.ceil(inventory.estimatedTotalBytes * multiplier) + : 0; + const requiredBytes = Math.max(estimatedDumpBytes, minFreeBytes); + const checkedAt = new Date().toISOString(); + const stats = (await this.statfs(workDir)) as { + bavail?: number | bigint; + bfree: number | bigint; + bsize: number | bigint; + }; + const availableBlocks = stats.bavail ?? stats.bfree; + const availableBytes = Number(availableBlocks) * Number(stats.bsize); + const tempDisk = { + workDir, + strategy, + estimatedTotalBytes: inventory.estimatedTotalBytes, + multiplier, + requiresFullDumpSpace, + requiredBytes, + availableBytes, + checkedAt, + }; + + if (availableBytes >= requiredBytes) { + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + copyStats: { + phase: 'temp_disk_checked', + progress: this.buildMigrationProgress(job, inventory, 'temp_disk_checked'), + tempDisk, + }, + lastError: null, + }, + }); + return; + } + + const lastError = `Insufficient temp disk space for migration job ${jobId}`; + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'failed', + lastError, + copyStats: { + phase: 'temp_disk_insufficient', + progress: this.buildMigrationProgress(job, inventory, 'temp_disk_insufficient'), + tempDisk, + }, + }, + }); + throw new CustomHttpException(lastError, HttpErrorCode.CONFLICT, { + errorCode: spaceDataDbTempDiskInsufficientErrorCode, + requiredBytes, + availableBytes, + workDir, + spaceId: job.spaceId, + migrationJobId: jobId, + }); + } + + async waitForSourceComputedDrainForJob( + jobId: string, + options: { timeoutMs?: number; pollMs?: number; processingLeaseMs?: number } = {} + ): Promise { + const timeoutMs = Math.max(0, Math.floor(options.timeoutMs ?? defaultComputedDrainTimeoutMs)); + const pollMs = Math.max(1, Math.floor(options.pollMs ?? defaultComputedDrainPollMs)); + const processingLeaseMs = Math.max( + 1, + Math.floor(options.processingLeaseMs ?? defaultComputedProcessingLeaseMs) + ); + const startedAt = Date.now(); + const job = await this.getMigrationJob(jobId); + const inventory = this.normalizeInventory(job.inventory, job.spaceId); + + if (!inventory.baseIds.length) { + const drained = this.buildComputedDrainStats({ + activeCount: 0, + reclaimableCount: 0, + oldestActiveLockedAt: null, + }); + await this.updateComputedDrainStats(job, inventory, 'computed_drained', drained); + return drained; + } + + const sourceDataDb = await this.getSourceDataDbForJob(job); + const sourceSchema = sourceDataDb.internalSchema ?? 'public'; + const client = this.clientFactory(sourceDataDb.url); + + await this.updateComputedDrainStats( + job, + inventory, + 'computed_draining', + this.buildComputedDrainStats({ + activeCount: 0, + reclaimableCount: 0, + oldestActiveLockedAt: null, + }) + ); + + try { + try { + for (;;) { + await this.assertMigrationNotCanceled(jobId, job.spaceId); + const stats = await this.inspectSourceComputedDrain( + client, + sourceSchema, + inventory.baseIds, + processingLeaseMs + ); + + if (stats.activeCount === 0) { + await this.updateComputedDrainStats(job, inventory, 'computed_drained', stats); + return stats; + } + + if (Date.now() - startedAt >= timeoutMs) { + const lastError = `Timed out waiting for source computed tasks to drain for migration job ${jobId}`; + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'failed', + lastError, + copyStats: { + phase: 'computed_drain_timeout', + progress: this.buildMigrationProgress(job, inventory, 'computed_drain_timeout'), + computedDrain: stats, + }, + }, + }); + throw new CustomHttpException(lastError, HttpErrorCode.CONFLICT, { + errorCode: spaceDataDbComputedDrainTimeoutErrorCode, + activeCount: stats.activeCount, + reclaimableCount: stats.reclaimableCount, + spaceId: job.spaceId, + migrationJobId: jobId, + }); + } + + await this.updateComputedDrainStats(job, inventory, 'computed_draining', stats); + await delay(pollMs); + } + } catch (error) { + if (error instanceof CustomHttpException) { + throw error; + } + const lastError = error instanceof Error ? error.message : String(error); + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'failed', + lastError, + copyStats: { + phase: 'computed_drain_failed', + progress: this.buildMigrationProgress(job, inventory, 'computed_drain_failed'), + computedDrain: { + failedAt: new Date().toISOString(), + }, + }, + }, + }); + throw error; + } + } finally { + await client.destroy().catch(() => undefined); + } + } + + async pauseSourceComputedForJob(jobId: string): Promise<{ created: boolean }> { + const job = await this.getMigrationJob(jobId); + const inventory = this.normalizeInventory(job.inventory, job.spaceId); + const spaceIds = this.getInventoryCopySpaceIds(inventory); + const sourceDataDb = await this.getSourceDataDbForJob(job); + const sourceSchema = sourceDataDb.internalSchema ?? 'public'; + const client = this.clientFactory(sourceDataDb.url); + + try { + const valuesSql = spaceIds + .map(() => `(?, 'space', ?, now(), ?, NULL, ?, now(), ?)`) + .join(', '); + const bindings = spaceIds.flatMap((spaceId) => [ + `sdmp_${job.id}_${spaceId}`, + spaceId, + job.createdBy, + migrationPauseReason(job.id), + job.createdBy, + ]); + const rows = normalizeRawRows<{ id: string }>( + await client.raw( + ` + INSERT INTO ${qualify(sourceSchema, sharedTables.computedUpdatePauseScope)} + ("id", "scope_type", "scope_id", "paused_at", "paused_by", "resume_at", "reason", "updated_at", "updated_by") + VALUES ${valuesSql} + ON CONFLICT ("scope_type", "scope_id") DO NOTHING + RETURNING "id" + `, + bindings + ) + ); + + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + copyStats: { + phase: 'computed_paused', + progress: this.buildMigrationProgress( + job, + this.normalizeInventory(job.inventory, job.spaceId), + 'computed_paused' + ), + computedPause: { + sourceSchema, + created: rows.length > 0, + createdCount: rows.length, + spaceIds, + reason: migrationPauseReason(job.id), + pausedAt: new Date().toISOString(), + }, + }, + lastError: null, + }, + }); + + return { created: rows.length > 0 }; + } finally { + await client.destroy().catch(() => undefined); + } + } + + async resumeSourceComputedForJob(jobId: string): Promise<{ deleted: number }> { + const job = await this.getMigrationJob(jobId); + const inventory = this.normalizeInventory(job.inventory, job.spaceId); + const sourceDataDb = await this.getSourceDataDbForJob(job); + const sourceSchema = sourceDataDb.internalSchema ?? 'public'; + const client = this.clientFactory(sourceDataDb.url); + + try { + return await this.deleteMigrationComputedPause( + client, + sourceSchema, + this.getInventoryCopySpaceIds(inventory), + job.id + ); + } finally { + await client.destroy().catch(() => undefined); + } + } + + async resumeTargetComputedForJob(jobId: string): Promise<{ deleted: number }> { + const job = await this.getMigrationJob(jobId); + if (!job.targetConnection?.encryptedUrl) { + throw new CustomHttpException( + `Migration job ${jobId} has no target connection`, + HttpErrorCode.VALIDATION_ERROR + ); + } + const client = this.clientFactory(decryptDataDbUrl(job.targetConnection.encryptedUrl)); + + try { + return await this.deleteMigrationComputedPause( + client, + job.targetInternalSchema, + this.getInventoryCopySpaceIds(this.normalizeInventory(job.inventory, job.spaceId)), + job.id + ); + } finally { + await client.destroy().catch(() => undefined); + } + } + + async getMigrationJobStatus( + spaceId: string, + jobId: string + ): Promise { + const job = (await this.migrationJobClient.spaceDataDbMigrationJob.findFirst({ + where: { id: jobId }, + include: { targetConnection: true }, + })) as IMigrationJobStatusRecord | null; + if (!job || !this.jobStatusIncludesSpace(job, spaceId)) { + throw new CustomHttpException( + `Migration job ${jobId} was not found for space ${spaceId}`, + HttpErrorCode.NOT_FOUND + ); + } + const inventory = this.normalizeInventory(job.inventory, job.spaceId); + + return { + jobId: job.id, + spaceId: job.spaceId, + targetMode: 'migrate-space', + switchOnCompletion: job.switchOnCompletion === true, + state: job.state as IDataDbMigrationJobStatusVo['state'], + targetInternalSchema: job.targetInternalSchema, + targetConnection: job.targetConnection + ? { + provider: job.targetConnection.provider, + displayHost: job.targetConnection.displayHost ?? undefined, + displayDatabase: job.targetConnection.displayDatabase ?? undefined, + internalSchema: job.targetConnection.internalSchema, + schemaVersion: job.targetConnection.schemaVersion, + lastValidatedAt: job.targetConnection.lastValidatedAt?.toISOString(), + lastError: job.targetConnection.lastError ?? undefined, + capabilities: job.targetConnection.capabilities as IDataDbPreflightVo['capabilities'], + } + : null, + relatedSpaces: inventory.relatedSpaces, + inventory: job.inventory, + copyStats: job.copyStats, + validationStats: job.validationStats, + lastError: job.lastError, + startedAt: job.startedAt?.toISOString() ?? null, + completedAt: job.completedAt?.toISOString() ?? null, + createdTime: job.createdTime.toISOString(), + lastModifiedTime: job.lastModifiedTime?.toISOString() ?? null, + }; + } + + private jobStatusIncludesSpace(job: IMigrationJobStatusRecord, spaceId: string) { + if (job.spaceId === spaceId) { + return true; + } + const inventory = this.normalizeInventory(job.inventory, job.spaceId); + return this.getInventorySpaceIds(inventory).includes(spaceId); + } + + async cancelMigrationForSpace( + spaceId: string, + jobId: string, + canceledBy: string + ): Promise { + const job = await this.getMigrationJob(jobId); + if ( + !this.getInventorySpaceIds(this.normalizeInventory(job.inventory, job.spaceId)).includes( + spaceId + ) + ) { + throw new CustomHttpException( + `Migration job ${jobId} was not found for space ${spaceId}`, + HttpErrorCode.NOT_FOUND + ); + } + + if (job.state === 'canceled') { + return await this.getMigrationJobStatus(spaceId, jobId); + } + + if (!cancelableSpaceDataDbMigrationStates.includes(job.state as never)) { + throw new CustomHttpException( + 'Only pre-validation space data database migrations can be canceled safely', + HttpErrorCode.CONFLICT, + { + errorCode: spaceDataDbMigrationCancelConflictErrorCode, + migrationJobId: jobId, + migrationState: job.state, + spaceId, + } + ); + } + + await this.resumeSourceComputedForJob(jobId); + + const lastError = `Space data database migration canceled by ${canceledBy || 'unknown user'}`; + const runTransaction = this.prismaService.$tx.bind(this.prismaService) as unknown as ( + fn: (prisma: IPrismaTransactionClient) => Promise + ) => Promise; + await runTransaction(async (prisma) => { + if (job.targetConnectionId) { + await prisma.dataDbConnection.update({ + where: { id: job.targetConnectionId }, + data: { + status: 'error', + lastError, + }, + }); + } + await prisma.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'canceled', + completedAt: new Date(), + lastError, + copyStats: { + phase: 'canceled_before_copy', + progress: this.buildMigrationProgress( + job, + this.normalizeInventory(job.inventory, job.spaceId), + 'canceled_before_copy' + ), + canceled: { + canceledBy, + canceledAt: new Date().toISOString(), + }, + }, + }, + }); + }); + + return await this.getMigrationJobStatus(spaceId, jobId); + } + + async rollbackMigrationForSpace( + spaceId: string, + jobId: string, + rolledBackBy: string + ): Promise { + const job = await this.getMigrationJob(jobId); + if ( + !this.getInventorySpaceIds(this.normalizeInventory(job.inventory, job.spaceId)).includes( + spaceId + ) + ) { + throw new CustomHttpException( + `Migration job ${jobId} was not found for space ${spaceId}`, + HttpErrorCode.NOT_FOUND + ); + } + if (job.state === 'rolled_back') { + return await this.getMigrationJobStatus(spaceId, jobId); + } + if (job.state !== 'succeeded') { + throw new CustomHttpException( + 'Only completed space data database migrations can be rolled back', + HttpErrorCode.CONFLICT, + { + errorCode: spaceDataDbMigrationCancelConflictErrorCode, + migrationJobId: jobId, + migrationState: job.state, + spaceId, + } + ); + } + if (job.switchOnCompletion !== true) { + throw new CustomHttpException( + 'Only migrations that switched the space data database can be rolled back', + HttpErrorCode.CONFLICT, + { + errorCode: spaceDataDbRollbackUnsafeErrorCode, + migrationJobId: jobId, + migrationState: job.state, + spaceId, + } + ); + } + if (!job.completedAt) { + throw new CustomHttpException( + `Migration job ${jobId} has no switch timestamp`, + HttpErrorCode.VALIDATION_ERROR + ); + } + this.assertRollbackSourceSupported(job); + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { state: 'switching', lastError: null }, + }); + + let restoredBeforeThrow = false; + let rollbackCompleted = false; + try { + const rollbackProof = await this.inspectPostSwitchRollbackProof(job); + if (!rollbackProof.eligible) { + const lastError = 'Space data database migration rollback is unsafe after target writes'; + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'succeeded', + lastError, + validationStats: this.mergeValidationStats(job.validationStats, { + rollback: rollbackProof, + }), + }, + }); + restoredBeforeThrow = true; + throw new CustomHttpException(lastError, HttpErrorCode.CONFLICT, { + errorCode: spaceDataDbRollbackUnsafeErrorCode, + migrationJobId: jobId, + spaceId, + rollback: rollbackProof, + }); + } + const sourceDataDb = this.getSourceDataDbFromInventory(job); + const spaceIds = this.getInventoryCopySpaceIds( + this.normalizeInventory(job.inventory, job.spaceId) + ); + + const runTransaction = this.prismaService.$tx.bind(this.prismaService) as unknown as ( + fn: (prisma: IPrismaTransactionClient) => Promise + ) => Promise; + await runTransaction(async (prisma) => { + for (const relatedSpaceId of spaceIds) { + await prisma.spaceDataDbBinding.upsert({ + where: { spaceId: relatedSpaceId }, + create: { + spaceId: relatedSpaceId, + dataDbConnectionId: job.sourceConnectionId, + mode: job.sourceConnectionId ? 'byodb' : 'default', + state: 'ready', + createdBy: rolledBackBy || job.createdBy, + }, + update: { + dataDbConnectionId: job.sourceConnectionId, + mode: job.sourceConnectionId ? 'byodb' : 'default', + state: 'ready', + }, + }); + } + await prisma.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'rolled_back', + completedAt: new Date(), + lastError: null, + validationStats: this.mergeValidationStats(job.validationStats, { + rollback: { + ...rollbackProof, + rolledBackBy, + rolledBackAt: new Date().toISOString(), + }, + }), + }, + }); + }); + + if (job.targetConnectionId) { + await this.dataDbClientManager.invalidateConnection(job.targetConnectionId); + } + if (job.sourceConnectionId) { + await this.dataDbClientManager.invalidateConnection(job.sourceConnectionId); + } + await this.resumeOriginalSourceComputedPause(job, sourceDataDb); + rollbackCompleted = true; + return await this.getMigrationJobStatus(spaceId, jobId); + } catch (error) { + if (!rollbackCompleted && !restoredBeforeThrow) { + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'succeeded', + lastError: error instanceof Error ? error.message : String(error), + }, + }); + } + throw error; + } + } + + private assertRollbackSourceSupported(job: IMigrationJobRecord) { + const inventory = this.normalizeInventory(job.inventory, job.spaceId); + if (inventory.sourceDataDb.mode === 'default' && !inventory.sourceDataDb.connectionId) { + return; + } + + throw new CustomHttpException( + 'Rolling back migrations from a BYODB source is not supported yet', + HttpErrorCode.VALIDATION_ERROR + ); + } + + private getSourceDataDbFromInventory(job: IMigrationJobRecord): IResolvedDataDatabase { + const inventory = this.normalizeInventory(job.inventory, job.spaceId); + if (inventory.sourceDataDb.mode === 'default' && !inventory.sourceDataDb.connectionId) { + return { + cacheKey: metaFallbackDataDbCacheKey, + url: getMetaDatabaseUrl(), + isMetaFallback: true, + }; + } + + this.assertRollbackSourceSupported(job); + throw new Error('Unsupported rollback source data database'); + } + + private async getSourceDataDbForJob(job: IMigrationJobRecord): Promise { + const inventory = this.normalizeInventory(job.inventory, job.spaceId); + return await this.dataDbClientManager.getDataDatabaseForSpace(job.spaceId, { + sourceConnectionId: inventory.sourceDataDb.connectionId, + }); + } + + private async resumeOriginalSourceComputedPause( + job: IMigrationJobRecord, + sourceDataDb: IResolvedDataDatabase + ): Promise<{ deleted: number }> { + const client = this.clientFactory(sourceDataDb.url); + try { + return await this.deleteMigrationComputedPause( + client, + sourceDataDb.internalSchema ?? 'public', + this.getInventoryCopySpaceIds(this.normalizeInventory(job.inventory, job.spaceId)), + job.id + ); + } finally { + await client.destroy().catch(() => undefined); + } + } + + private async inspectPostSwitchRollbackProof( + job: IMigrationJobRecord + ): Promise { + const switchedAt = job.completedAt?.toISOString(); + const proof: IPostSwitchRollbackProof = { + eligible: false, + switchedAt: switchedAt ?? '', + checkedAt: new Date().toISOString(), + findings: [], + }; + + if (!switchedAt) { + proof.findings.push({ + object: `migration:${job.id}`, + reason: 'missing_switch_timestamp', + }); + return proof; + } + if (!job.targetConnection?.encryptedUrl) { + proof.findings.push({ + object: `migration:${job.id}`, + reason: 'missing_target_connection', + }); + return proof; + } + + const inventory = this.normalizeInventory(job.inventory, job.spaceId); + const targetClient = this.clientFactory(decryptDataDbUrl(job.targetConnection.encryptedUrl)); + + try { + await this.collectRollbackRelationFindings(targetClient, inventory, proof.findings); + await this.collectRollbackRowCountFindings( + targetClient, + job.targetInternalSchema, + inventory, + job.spaceId, + job.validationStats, + proof.findings + ); + await this.collectRollbackTimestampFindings( + targetClient, + job.targetInternalSchema, + inventory, + job.spaceId, + switchedAt, + proof.findings + ); + } catch (error) { + proof.findings.push({ + object: `migration:${job.id}`, + reason: `proof_query_failed:${error instanceof Error ? error.message : String(error)}`, + }); + } finally { + await targetClient.destroy().catch(() => undefined); + } + + return { + ...proof, + eligible: proof.findings.length === 0, + }; + } + + private async collectRollbackRelationFindings( + targetClient: IDataDbPreflightClient, + inventory: ISpaceDataDbInventory, + findings: IPostSwitchWriteFinding[] + ) { + const expectedKeys = this.buildPhysicalRelationKeys(inventory); + const actualKeys = this.buildPhysicalRelationKeysFromRelations( + await this.inspectPhysicalSchemasWithClient(targetClient, inventory.baseIds) + ); + + if (JSON.stringify(expectedKeys) === JSON.stringify(actualKeys)) { + return; + } + + findings.push({ + object: 'base:physicalRelations', + reason: 'relation_inventory_changed', + expectedCount: expectedKeys.length, + actualCount: actualKeys.length, + }); + } + + private async collectRollbackRowCountFindings( + targetClient: IDataDbPreflightClient, + targetSchema: string, + inventory: ISpaceDataDbInventory, + spaceId: string, + validationStats: unknown, + findings: IPostSwitchWriteFinding[] + ) { + const baseRows = this.getValidationRows(validationStats, 'baseSchemas'); + const sharedRows = this.getValidationRows(validationStats, 'sharedTables'); + + if (!baseRows || !sharedRows) { + findings.push({ + object: `migration:${spaceId}`, + reason: 'missing_validation_stats', + }); + return; + } + + for (const row of baseRows) { + const relation = this.parseBaseValidationObject(row.object); + if (!relation) { + findings.push({ object: row.object, reason: 'invalid_base_validation_object' }); + continue; + } + + const actualCount = await this.countRows( + targetClient, + relation.schemaName, + relation.relationName + ); + if (actualCount !== row.targetCount) { + findings.push({ + object: row.object, + reason: 'row_count_changed', + expectedCount: row.targetCount, + actualCount, + }); + } + } + + const sharedRowsByTable = new Map( + sharedRows.map((row) => [row.object.replace(/^shared:/, ''), row]) + ); + for (const plan of this.buildSharedTableCountPlans(inventory, spaceId)) { + const row = sharedRowsByTable.get(plan.table); + if (!row) { + findings.push({ + object: `shared:${plan.table}`, + reason: 'missing_shared_validation_row', + }); + continue; + } + + const actualCount = await this.countRows( + targetClient, + targetSchema, + plan.table, + plan.whereSql(targetSchema), + plan.bindings + ); + if (actualCount !== row.targetCount) { + findings.push({ + object: row.object, + reason: 'row_count_changed', + expectedCount: row.targetCount, + actualCount, + }); + } + } + } + + private async collectRollbackTimestampFindings( + targetClient: IDataDbPreflightClient, + targetSchema: string, + inventory: ISpaceDataDbInventory, + spaceId: string, + switchedAt: string, + findings: IPostSwitchWriteFinding[] + ) { + await this.collectRollbackBaseTimestampFindings(targetClient, inventory, switchedAt, findings); + await this.collectRollbackSharedTimestampFindings( + targetClient, + targetSchema, + inventory, + spaceId, + switchedAt, + findings + ); + } + + private async collectRollbackBaseTimestampFindings( + targetClient: IDataDbPreflightClient, + inventory: ISpaceDataDbInventory, + switchedAt: string, + findings: IPostSwitchWriteFinding[] + ) { + for (const schema of inventory.physicalSchemas) { + for (const relation of schema.relations) { + if (!relationKindsWithRows.has(relation.relationKind)) { + continue; + } + + const columns = await this.getExistingColumns( + targetClient, + relation.schemaName, + relation.relationName, + ['__created_time', '__last_modified_time'] + ); + if (!columns.length) { + continue; + } + + const count = await this.countRows( + targetClient, + relation.schemaName, + relation.relationName, + columns.map((column) => `${quoteIdent(column)} > ?::timestamp`).join(' OR '), + columns.map(() => switchedAt) + ); + if (count > 0) { + findings.push({ + object: `base:${relation.schemaName}.${relation.relationName}`, + reason: 'post_switch_timestamp_rows', + count, + }); + } + } + } + } + + private async collectRollbackSharedTimestampFindings( + targetClient: IDataDbPreflightClient, + targetSchema: string, + inventory: ISpaceDataDbInventory, + spaceId: string, + switchedAt: string, + findings: IPostSwitchWriteFinding[] + ) { + const plans = this.buildPostSwitchSharedWritePlans(inventory, spaceId, switchedAt); + + for (const plan of plans) { + const count = await this.countRows( + targetClient, + targetSchema, + plan.table, + plan.whereSql, + plan.bindings + ); + if (count > 0) { + findings.push({ + object: `shared:${plan.table}`, + reason: 'post_switch_timestamp_rows', + count, + }); + } + } + } + + private buildPostSwitchSharedWritePlans( + inventory: ISpaceDataDbInventory, + spaceId: string, + switchedAt: string + ) { + const plans: { table: string; whereSql: string; bindings: unknown[] }[] = []; + const spaceIds = this.getInventoryCopySpaceIds(inventory); + const pushTableScoped = (table: string) => { + if (!inventory.tableIds.length) { + return; + } + plans.push({ + table, + whereSql: `"created_time" > ?::timestamp AND "table_id" = ANY(?::text[])`, + bindings: [switchedAt, inventory.tableIds], + }); + }; + const pushBaseScoped = (table: string) => { + if (!inventory.baseIds.length) { + return; + } + plans.push({ + table, + whereSql: [ + `("created_at" > ?::timestamp OR "updated_at" > ?::timestamp)`, + `AND "base_id" = ANY(?::text[])`, + ].join(' '), + bindings: [switchedAt, switchedAt, inventory.baseIds], + }); + }; + + pushTableScoped(sharedTables.recordHistory); + pushTableScoped(sharedTables.tableTrash); + pushTableScoped(sharedTables.recordTrash); + pushBaseScoped(sharedTables.computedUpdateOutbox); + pushBaseScoped(sharedTables.computedUpdateDeadLetter); + + plans.push({ + table: sharedTables.computedUpdatePauseScope, + whereSql: [ + `("paused_at" > ?::timestamp OR "updated_at" > ?::timestamp)`, + `(`, + `("scope_type" = 'space' AND "scope_id" = ANY(?::text[]))`, + inventory.baseIds.length + ? `OR ("scope_type" = 'base' AND "scope_id" = ANY(?::text[]))` + : '', + inventory.tableIds.length + ? `OR ("scope_type" = 'table' AND "scope_id" = ANY(?::text[]))` + : '', + `)`, + ] + .filter(Boolean) + .join(' '), + bindings: [switchedAt, switchedAt, spaceIds, inventory.baseIds, inventory.tableIds].filter( + (value) => (Array.isArray(value) ? value.length > 0 : Boolean(value)) + ), + }); + + if (inventory.baseIds.length) { + plans.push({ + table: sharedTables.undoLog, + whereSql: [ + `"created_at" > ?::timestamp`, + `AND split_part("table_name", '.', 1) = ANY(?::text[])`, + ].join(' '), + bindings: [switchedAt, inventory.baseIds], + }); + } + + return plans; + } + + private getValidationRows( + validationStats: unknown, + key: 'baseSchemas' | 'sharedTables' + ): IRowCountValidation[] | null { + const stats = this.asRecord(validationStats); + const rows = stats?.[key]; + if (!Array.isArray(rows)) { + return null; + } + + return rows + .map((row) => this.asRecord(row)) + .filter((row): row is Record => Boolean(row)) + .map((row) => ({ + object: typeof row.object === 'string' ? row.object : '', + sourceCount: Number(row.sourceCount ?? 0), + targetCount: Number(row.targetCount ?? 0), + })) + .filter((row) => row.object); + } + + private parseBaseValidationObject(object: string) { + if (!object.startsWith('base:')) { + return null; + } + + const qualified = object.slice('base:'.length); + const separatorIndex = qualified.indexOf('.'); + if (separatorIndex <= 0 || separatorIndex === qualified.length - 1) { + return null; + } + + return { + schemaName: qualified.slice(0, separatorIndex), + relationName: qualified.slice(separatorIndex + 1), + }; + } + + private async getExistingColumns( + client: IDataDbPreflightClient, + schema: string, + table: string, + columns: string[] + ) { + const rows = normalizeRawRows<{ columnName: string }>( + await client.raw( + ` + SELECT column_name AS "columnName" + FROM information_schema.columns + WHERE table_schema = ? + AND table_name = ? + AND column_name = ANY(?::text[]) + `, + [schema, table, columns] + ) + ); + return rows.map((row) => row.columnName).filter((column) => columns.includes(column)); + } + + private buildPhysicalRelationKeysFromRelations(relations: ISpaceDataDbPhysicalRelation[]) { + return relations + .map((relation) => `${relation.schemaName}.${relation.relationName}:${relation.relationKind}`) + .sort(); + } + + private mergeValidationStats(validationStats: unknown, next: Record) { + return { + ...(this.asRecord(validationStats) ?? {}), + ...next, + }; + } + + async copySharedRowsForJob( + jobId: string, + options: { timeoutMs?: number; strategy?: ISpaceDataDbSharedTableCopyStrategy } + ) { + const job = await this.migrationJobClient.spaceDataDbMigrationJob.findUnique({ + where: { id: jobId }, + include: { targetConnection: true }, + }); + if (!job) { + throw new CustomHttpException(`Migration job ${jobId} not found`, HttpErrorCode.NOT_FOUND); + } + if (!job.targetConnection?.encryptedUrl) { + throw new CustomHttpException( + `Migration job ${jobId} has no target connection`, + HttpErrorCode.VALIDATION_ERROR + ); + } + + const inventory = this.normalizeInventory(job.inventory, job.spaceId); + const sourceDataDb = await this.getSourceDataDbForJob(job); + const targetUrl = decryptDataDbUrl(job.targetConnection.encryptedUrl); + const startedAt = new Date().toISOString(); + const strategy = options.strategy ?? 'psql_copy'; + const sourceSchema = sourceDataDb.internalSchema ?? 'public'; + const sharedPlanInput = { + sourceUrl: sourceDataDb.url, + targetUrl, + sourceSchema, + targetSchema: job.targetInternalSchema, + spaceId: job.spaceId, + spaceIds: this.getInventoryCopySpaceIds(inventory), + baseIds: inventory.baseIds, + tableIds: inventory.tableIds, + sharedTableIds: inventory.sharedTableIds, + }; + const fdwNamePrefix = this.buildPostgresFdwNamePrefix(jobId); + const plans = + strategy === 'postgres_fdw' + ? buildMigrationSharedTablePostgresFdwCopyPlans({ + ...sharedPlanInput, + fdwSchemaPrefix: `${fdwNamePrefix}_schema`, + serverNamePrefix: `${fdwNamePrefix}_server`, + }) + : buildMigrationSharedTablePsqlCopyPlans(sharedPlanInput); + const tableNames = plans.map((plan) => plan.table); + + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'copying', + copyStats: { + phase: 'copying_shared_rows', + progress: this.buildMigrationProgress(job, inventory, 'copying_shared_rows'), + sharedTables: { + tableNames, + totalTables: plans.length, + copiedTableCount: 0, + totalCopiedRows: 0, + strategy, + startedAt, + }, + }, + lastError: null, + }, + }); + + const copiedTables: ISharedTableCopySummary[] = []; + try { + const onTableCopied = async ( + result: ISpaceDataDbSharedTableCopyResult | ISpaceDataDbPostgresFdwSharedTableCopyResult + ) => { + copiedTables.push(this.buildSharedTableCopySummary(result)); + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'copying', + copyStats: { + phase: 'copying_shared_rows', + progress: this.buildMigrationProgress(job, inventory, 'copying_shared_rows'), + sharedTables: { + tableNames, + totalTables: plans.length, + copiedTableCount: copiedTables.length, + totalCopiedRows: this.sumCopiedRows(copiedTables), + copiedTables, + strategy, + startedAt, + updatedAt: new Date().toISOString(), + }, + }, + lastError: null, + }, + }); + }; + const processOptions = this.buildCancelableProcessOptions(jobId, options.timeoutMs); + const processOptionsWithHeartbeat = { + ...processOptions, + onPoll: async () => { + await processOptions.onPoll?.(); + await this.updateSharedTableCopyHeartbeat(job, inventory, { + stage: 'copying_shared_rows', + tableNames, + totalTables: plans.length, + copiedTableCount: copiedTables.length, + totalCopiedRows: this.sumCopiedRows(copiedTables), + strategy, + updatedAt: new Date().toISOString(), + }); + }, + }; + const results = + strategy === 'postgres_fdw' + ? await this.copyService.copySharedTablesViaPostgresFdw( + plans as ReturnType, + processOptionsWithHeartbeat, + { onTableCopied } + ) + : await this.copyService.copySharedTables( + plans as ReturnType, + processOptionsWithHeartbeat, + { onTableCopied } + ); + const sharedTables = results.map((result) => this.buildSharedTableCopySummary(result)); + const copyStats = { + phase: 'shared_rows_completed', + progress: this.buildMigrationProgress(job, inventory, 'shared_rows_completed'), + sharedTables: { + tableNames, + totalTables: plans.length, + copiedTableCount: sharedTables.length, + totalCopiedRows: this.sumCopiedRows(sharedTables), + copiedTables: sharedTables, + strategy, + startedAt, + completedAt: new Date().toISOString(), + }, + }; + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'copying', + copyStats, + lastError: null, + }, + }); + return copyStats; + } catch (error) { + if (await this.isProcessCancelErrorForJob(error, jobId)) { + throw error; + } + const lastError = error instanceof Error ? error.message : String(error); + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'failed', + lastError, + copyStats: { + phase: 'shared_rows_failed', + progress: this.buildMigrationProgress(job, inventory, 'shared_rows_failed'), + sharedTables: { + tableNames, + totalTables: plans.length, + copiedTableCount: copiedTables.length, + totalCopiedRows: this.sumCopiedRows(copiedTables), + copiedTables, + strategy, + startedAt, + failedAt: new Date().toISOString(), + error: lastError, + failure: this.buildProcessFailureStats(error), + }, + }, + }, + }); + throw error; + } + } + + private buildSharedTableCopySummary( + result: ISpaceDataDbSharedTableCopyResult | ISpaceDataDbPostgresFdwSharedTableCopyResult + ): ISharedTableCopySummary { + return { + strategy: result.strategy, + table: result.table, + copiedRows: result.copiedRows, + ...('source' in result ? { source: result.source } : {}), + target: result.target, + }; + } + + private async updateSharedTableCopyHeartbeat( + job: IMigrationJobRecord, + inventory: ISpaceDataDbInventory, + heartbeat: ISharedTableCopyHeartbeat + ) { + await this.migrationJobClient.spaceDataDbMigrationJob + .update({ + where: { id: job.id }, + data: { + state: 'copying', + copyStats: { + phase: 'copying_shared_rows', + progress: this.buildMigrationProgress(job, inventory, 'copying_shared_rows'), + sharedTables: heartbeat, + }, + lastError: null, + }, + }) + .catch(() => undefined); + } + + private buildPostgresFdwNamePrefix(jobId: string) { + const sanitized = jobId.replace(/\W/g, '_').slice(0, 24); + return `teable_${sanitized}_fdw`; + } + + private sumCopiedRows(results: { copiedRows: number | null }[]) { + if (results.some((result) => result.copiedRows == null)) { + return null; + } + return results.reduce((sum, result) => sum + (result.copiedRows ?? 0), 0); + } + + private buildProcessFailureStats(error: unknown): ISpaceDataDbProcessFailureStats { + if (error instanceof SpaceDataDbProcessPipelineError) { + return { + type: 'pipeline', + message: error.message, + result: error.result, + }; + } + if (error instanceof SpaceDataDbProcessError) { + return { + type: 'process', + message: error.message, + result: error.result, + }; + } + return { + type: 'unknown', + message: error instanceof Error ? error.message : String(error), + }; + } + + private buildProcessFailureMessage(error: unknown) { + const baseMessage = error instanceof Error ? error.message : String(error); + const failureStats = this.buildProcessFailureStats(error); + const detail = this.getProcessFailureDetail(failureStats); + return detail ? `${baseMessage}: ${detail}` : baseMessage; + } + + private getProcessFailureDetail(failureStats: ISpaceDataDbProcessFailureStats) { + if (failureStats.type === 'process') { + return this.formatProcessFailureResult(failureStats.result); + } + + if (failureStats.type === 'pipeline') { + const source = this.formatProcessFailureResult(failureStats.result.source); + const target = this.formatProcessFailureResult(failureStats.result.target); + return [source ? `source ${source}` : '', target ? `target ${target}` : ''] + .filter(Boolean) + .join('; '); + } + + return ''; + } + + private formatProcessFailureResult(result: ISpaceDataDbProcessFailureResult) { + const exitOrSignal = + result.exitCode != null + ? `exit ${result.exitCode}` + : result.signal + ? `signal ${result.signal}` + : ''; + const output = (result.stderr || result.stdout || '').trim(); + const outputTail = output.length > 800 ? output.slice(output.length - 800) : output; + return [result.command, exitOrSignal, outputTail].filter(Boolean).join(' - '); + } + + async validateAndSwitchJob(jobId: string) { + const validationStats = await this.validateCopyForJob(jobId); + const job = await this.getMigrationJob(jobId); + const inventory = this.normalizeInventory(job.inventory, job.spaceId); + const spaceIds = this.getInventorySpaceIds(inventory); + if (!job.targetConnectionId) { + throw new CustomHttpException( + `Migration job ${jobId} has no target connection`, + HttpErrorCode.VALIDATION_ERROR + ); + } + + const switchOnCompletion = job.switchOnCompletion === true; + let completedValidationStats: IValidationStats = { + ...validationStats, + switchOnCompletion, + switched: false, + }; + try { + await this.resumeTargetComputedForJob(jobId); + const runTransaction = this.prismaService.$tx.bind(this.prismaService) as unknown as ( + fn: (prisma: IPrismaTransactionClient) => Promise + ) => Promise; + + if (!switchOnCompletion) { + await this.resumeSourceComputedForJob(jobId); + await runTransaction(async (prisma) => { + await prisma.dataDbConnection.update({ + where: { id: job.targetConnectionId }, + data: { + status: 'ready', + lastValidatedAt: new Date(), + lastError: null, + }, + }); + await prisma.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'succeeded', + validationStats: completedValidationStats, + completedAt: new Date(), + lastError: null, + }, + }); + }); + await this.dataDbClientManager.invalidateConnection(job.targetConnectionId); + return { + state: 'succeeded', + validationStats: completedValidationStats, + }; + } + + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'switching', + lastError: null, + }, + }); + + const switchedAt = new Date(); + completedValidationStats = { + ...completedValidationStats, + switched: true, + switchedAt: switchedAt.toISOString(), + }; + await runTransaction(async (prisma) => { + await prisma.dataDbConnection.update({ + where: { id: job.targetConnectionId }, + data: { + status: 'ready', + lastValidatedAt: new Date(), + lastError: null, + }, + }); + for (const spaceId of spaceIds) { + await prisma.spaceDataDbBinding.upsert({ + where: { spaceId }, + create: { + spaceId, + dataDbConnectionId: job.targetConnectionId, + mode: 'byodb', + state: 'ready', + createdBy: job.createdBy, + }, + update: { + dataDbConnectionId: job.targetConnectionId, + mode: 'byodb', + state: 'ready', + }, + }); + } + await prisma.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'succeeded', + validationStats: completedValidationStats, + completedAt: switchedAt, + lastError: null, + }, + }); + }); + } catch (error) { + const lastError = error instanceof Error ? error.message : String(error); + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'failed', + lastError, + }, + }); + throw error; + } + + await this.dataDbClientManager.invalidateConnection(job.targetConnectionId); + if (job.sourceConnectionId) { + await this.dataDbClientManager.invalidateConnection(job.sourceConnectionId); + } + return { + state: 'succeeded', + validationStats: completedValidationStats, + }; + } + + async validateCopyForJob(jobId: string): Promise { + const job = await this.getMigrationJob(jobId); + if (!job.targetConnection?.encryptedUrl) { + throw new CustomHttpException( + `Migration job ${jobId} has no target connection`, + HttpErrorCode.VALIDATION_ERROR + ); + } + + const inventory = this.normalizeInventory(job.inventory, job.spaceId); + const sourceDataDb = await this.getSourceDataDbForJob(job); + const sourceSchema = sourceDataDb.internalSchema ?? 'public'; + const targetConnection = job.targetConnection; + const targetUrl = decryptDataDbUrl(targetConnection.encryptedUrl); + const startedAt = new Date().toISOString(); + const progressPollMs = readPositiveIntEnv( + 'BYODB_SPACE_DATA_DB_COPY_PROGRESS_POLL_MS', + defaultCopyProgressPollMs + ); + + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'validating', + validationStats: { + phase: 'validating_copy', + progress: this.buildMigrationProgress(job, inventory, 'validating_copy'), + startedAt, + }, + lastError: null, + }, + }); + + const sourceClient = this.clientFactory(sourceDataDb.url); + const targetClient = this.clientFactory(targetUrl); + try { + const validation = await this.withMigrationHeartbeat( + () => + this.updateValidationHeartbeat(job, inventory, { + stage: 'validating_copy', + updatedAt: new Date().toISOString(), + }), + () => + this.validateCopiedData({ + sourceClient, + targetClient, + sourceSchema, + targetSchema: job.targetInternalSchema, + targetConnectionId: job.targetConnectionId, + targetEncryptedUrl: targetConnection.encryptedUrl, + inventory, + spaceId: job.spaceId, + }), + progressPollMs + ); + + if (validation.mismatches.length) { + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'failed', + validationStats: { + phase: 'validation_failed', + progress: this.buildMigrationProgress(job, inventory, 'validation_failed'), + mismatches: validation.mismatches, + failedAt: new Date().toISOString(), + }, + lastError: validationFailedMessage, + }, + }); + throw new CustomHttpException(validationFailedMessage, HttpErrorCode.CONFLICT, { + errorCode: spaceDataDbValidationMismatchErrorCode, + mismatches: validation.mismatches, + spaceId: job.spaceId, + }); + } + + const validationStats: IValidationStats = { + phase: 'validation_completed', + progress: this.buildMigrationProgress(job, inventory, 'validation_completed'), + targetSchemaVersion: validation.targetSchemaVersion, + routeSmoke: validation.routeSmoke, + baseSchemas: validation.baseSchemas, + sharedTables: validation.sharedTables, + undoFunction: validation.undoFunction, + completedAt: new Date().toISOString(), + }; + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'validating', + validationStats, + lastError: null, + }, + }); + return validationStats; + } catch (error) { + if (error instanceof CustomHttpException) { + throw error; + } + + const lastError = error instanceof Error ? error.message : String(error); + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + state: 'failed', + validationStats: { + phase: 'validation_failed', + progress: this.buildMigrationProgress(job, inventory, 'validation_failed'), + failedAt: new Date().toISOString(), + }, + lastError, + }, + }); + throw error; + } finally { + await Promise.all([ + sourceClient.destroy().catch(() => undefined), + targetClient.destroy().catch(() => undefined), + ]); + } + } + + private async updateValidationHeartbeat( + job: IMigrationJobRecord, + inventory: ISpaceDataDbInventory, + heartbeat: IValidationHeartbeat + ) { + await this.migrationJobClient.spaceDataDbMigrationJob + .update({ + where: { id: job.id }, + data: { + state: 'validating', + validationStats: { + phase: 'validating_copy', + progress: this.buildMigrationProgress(job, inventory, 'validating_copy'), + heartbeat, + }, + lastError: null, + }, + }) + .catch(() => undefined); + } + + private async assertNoActiveMigrationForSpaces(spaceIds: string[], requestedSpaceId: string) { + const directJob = await this.migrationJobClient.spaceDataDbMigrationJob.findFirst({ + where: { + spaceId: { in: spaceIds }, + state: { in: [...activeSpaceDataDbMigrationStates] }, + }, + select: { id: true, state: true }, + }); + const activeJob = + directJob ?? + ( + await this.migrationJobClient.spaceDataDbMigrationJob.findMany({ + where: { + state: { in: [...activeSpaceDataDbMigrationStates] }, + }, + include: { targetConnection: true }, + }) + ).find((job) => + this.getInventorySpaceIds(this.normalizeInventory(job.inventory, job.spaceId)).some( + (spaceId) => spaceIds.includes(spaceId) + ) + ); + if (!activeJob) { + return; + } + + throw new CustomHttpException( + 'A space data database migration is already active', + HttpErrorCode.CONFLICT, + { + errorCode: spaceDataDbMigrationActiveErrorCode, + migrationJobId: activeJob.id, + migrationState: activeJob.state, + spaceId: requestedSpaceId, + relatedSpaceIds: spaceIds, + } + ); + } + + private async assertMigrationNotCanceled(jobId: string, spaceId?: string) { + const job = await this.getMigrationState(jobId); + + if (job?.state !== 'canceled') { + return; + } + + throw new CustomHttpException( + `Space data database migration job ${jobId} was canceled`, + HttpErrorCode.CONFLICT, + { + errorCode: spaceDataDbMigrationCanceledErrorCode, + migrationJobId: jobId, + spaceId: spaceId ?? job.spaceId, + } + ); + } + + private buildCancelableProcessOptions( + jobId: string, + timeoutMs?: number, + pollMs?: number + ): ISpaceDataDbProcessRunOptions { + return { + timeoutMs, + pollMs, + pollTimeoutMs: pollMs + ? readPositiveIntEnv( + 'BYODB_SPACE_DATA_DB_PROCESS_POLL_TIMEOUT_MS', + Math.max(defaultCopyProgressPollTimeoutMs, pollMs * 6) + ) + : undefined, + pollFailureTimeoutMs: pollMs + ? readPositiveIntEnv( + 'BYODB_SPACE_DATA_DB_PROCESS_POLL_FAILURE_TIMEOUT_MS', + Math.max(defaultCopyProgressPollTimeoutMs, pollMs * 6) + ) + : undefined, + shouldCancel: () => this.isMigrationCanceled(jobId), + }; + } + + private async isProcessCancelErrorForJob(error: unknown, jobId: string) { + if ( + error instanceof SpaceDataDbProcessCanceledError || + error instanceof SpaceDataDbProcessPipelineCanceledError + ) { + return true; + } + return await this.isMigrationCanceled(jobId); + } + + private async isMigrationCanceled(jobId: string) { + return (await this.getMigrationState(jobId))?.state === 'canceled'; + } + + private async getMigrationState(jobId: string) { + return (await this.migrationJobClient.spaceDataDbMigrationJob.findFirst({ + where: { id: jobId }, + select: { id: true, state: true, spaceId: true }, + })) as { id: string; state: string; spaceId?: string } | null; + } + + private async completeFailedMigrationJob(jobId: string, error: unknown) { + const current = await this.getMigrationState(jobId); + if (!current || ['succeeded', 'canceled', 'rolled_back'].includes(current.state)) { + return; + } + if ( + current.state !== 'failed' && + !activeSpaceDataDbMigrationStates.includes(current.state as never) + ) { + return; + } + + await this.migrationJobClient.spaceDataDbMigrationJob.updateMany({ + where: { id: jobId, state: current.state }, + data: { + state: 'failed', + completedAt: new Date(), + lastError: error instanceof Error ? error.message : String(error), + }, + }); + } + + private assertSpacesCanJoinTargetDataDb( + relatedSpaces: ISpaceDataDbRelatedSpaces, + targetDatabaseFingerprint: string, + requestedSpaceId: string + ) { + const mismatched = relatedSpaces.spaces.filter( + (space) => + space.dataDbMode === 'byodb' && + space.dataDbDatabaseFingerprint !== targetDatabaseFingerprint + ); + if (!mismatched.length) { + return; + } + + throw new CustomHttpException( + 'Cross-space linked spaces must use the same BYODB data database URL', + HttpErrorCode.CONFLICT, + { + errorCode: spaceDataDbRelatedSpacesRequiredErrorCode, + spaceId: requestedSpaceId, + targetDatabaseFingerprint, + relatedSpaces, + mismatchedSpaceIds: mismatched.map((space) => space.spaceId), + } + ); + } + + private assertRelatedSpacesCanMigrateTogether( + spaceId: string, + relatedSpaces: ISpaceDataDbRelatedSpaces + ) { + const primary = relatedSpaces.spaces.find((space) => space.spaceId === spaceId); + if (primary) { + return; + } + + throw new CustomHttpException(`Space ${spaceId} not found`, HttpErrorCode.NOT_FOUND); + } + + private getRelatedSpaceIds(relatedSpaces: ISpaceDataDbRelatedSpaces) { + return relatedSpaces.spaces.map((space) => space.spaceId).sort(); + } + + private getInventorySpaceIds(inventory: ISpaceDataDbInventory) { + return inventory.spaceIds.length + ? inventory.spaceIds + : [inventory.relatedSpaces.primarySpaceId]; + } + + private getInventoryCopySpaceIds(inventory: ISpaceDataDbInventory) { + return inventory.copySpaceIds.length + ? inventory.copySpaceIds + : this.getInventorySpaceIds(inventory); + } + + private getRelatedSpaceIdsForCopy( + relatedSpaces: ISpaceDataDbRelatedSpaces, + targetUrlFingerprint: string + ) { + return relatedSpaces.spaces + .filter( + (space) => + space.dataDbMode !== 'byodb' || space.dataDbUrlFingerprint !== targetUrlFingerprint + ) + .map((space) => space.spaceId) + .sort(); + } + + private assertRequestedSpaceIsCopySource(spaceId: string, copySpaceIds: string[]) { + if (copySpaceIds.includes(spaceId)) { + return; + } + + throw new CustomHttpException( + 'Start migration from a related space that still uses the default data database', + HttpErrorCode.CONFLICT, + { + errorCode: spaceDataDbRelatedSpacesRequiredErrorCode, + spaceId, + copySpaceIds, + } + ); + } + + private resolveMigrationTargetInternalSchema( + dataDb: IDataDbPreflightRo, + relatedSpaces: ISpaceDataDbRelatedSpaces, + targetDatabaseFingerprint: string, + requestedSpaceId: string + ) { + const requested = resolveDataDbInternalSchema(dataDb.internalSchema, dataDb.url); + if (dataDb.internalSchema?.trim()) { + return requested; + } + + const relatedTargetSchemas = Array.from( + new Set( + relatedSpaces.spaces + .filter( + (space) => + space.dataDbMode === 'byodb' && + space.dataDbDatabaseFingerprint === targetDatabaseFingerprint && + space.dataDbInternalSchema + ) + .map((space) => space.dataDbInternalSchema as string) + ) + ).sort(); + + if (relatedTargetSchemas.length <= 1) { + return relatedTargetSchemas[0] ?? requested; + } + + throw new CustomHttpException( + 'Cross-space linked spaces must use a single BYODB internal schema before repair migration', + HttpErrorCode.CONFLICT, + { + errorCode: spaceDataDbRelatedSpacesRequiredErrorCode, + spaceId: requestedSpaceId, + targetDatabaseFingerprint, + relatedSpaces, + internalSchemas: relatedTargetSchemas, + } + ); + } + + private async resolveMigrationTargetInternalSchemaOrThrow( + dataDb: IDataDbPreflightRo, + relatedSpaces: ISpaceDataDbRelatedSpaces, + targetDatabaseFingerprint: string, + requestedSpaceId: string + ) { + try { + return this.resolveMigrationTargetInternalSchema( + dataDb, + relatedSpaces, + targetDatabaseFingerprint, + requestedSpaceId + ); + } catch (error) { + if (error instanceof CustomHttpException) { + throw error; + } + const preflight = await this.preflightService.preflight({ + ...dataDb, + targetMode: migrateSpaceTargetMode, + }); + if (!preflight.ok) { + throw this.buildMigrationPreflightException(preflight); + } + throw new CustomHttpException( + this.sanitizeTargetErrorMessage(error, dataDb.url), + HttpErrorCode.VALIDATION_ERROR, + { preflight } + ); + } + } + + private async runTargetPreflightOperation( + dataDb: IDataDbPreflightRo, + internalSchema: string, + spaceId: string, + operation: string, + action: () => Promise + ): Promise { + try { + return await action(); + } catch (error) { + if (error instanceof CustomHttpException) { + throw error; + } + const preflight = await this.preflightService.preflight({ + ...dataDb, + targetMode: migrateSpaceTargetMode, + internalSchema, + }); + if (!preflight.ok) { + throw this.buildMigrationPreflightException(preflight); + } + throw new CustomHttpException( + 'Target BYODB database operation failed before migration could be queued', + HttpErrorCode.CONFLICT, + { + errorCode: spaceDataDbTargetConflictErrorCode, + operation, + spaceId, + internalSchema, + targetError: this.sanitizeTargetErrorMessage(error, dataDb.url), + preflight, + } + ); + } + } + + private buildMigrationPreflightException(preflight: IDataDbPreflightVo) { + return new CustomHttpException(buildPreflightErrorMessage(preflight), HttpErrorCode.CONFLICT, { + preflight, + }); + } + + private sanitizeTargetErrorMessage(error: unknown, rawUrl: string) { + const message = error instanceof Error ? error.message : String(error); + return message + .replace(rawUrl, '[redacted]') + .replace(/postgresql:\/\/[^@\s]+@/g, 'postgresql://***@') + .slice(0, 1000); + } + + private assertRelatedSpacesShareSameTarget( + relatedSpaces: ISpaceDataDbRelatedSpaces, + targetUrlFingerprint: string, + requestedSpaceId: string + ) { + const mismatched = relatedSpaces.spaces.filter( + (space) => space.dataDbMode === 'byodb' && space.dataDbUrlFingerprint !== targetUrlFingerprint + ); + if (!mismatched.length) { + return; + } + + throw new CustomHttpException( + 'Cross-space linked spaces must use the same BYODB data database URL', + HttpErrorCode.CONFLICT, + { + errorCode: spaceDataDbRelatedSpacesRequiredErrorCode, + spaceId: requestedSpaceId, + targetUrlFingerprint, + relatedSpaces, + mismatchedSpaceIds: mismatched.map((space) => space.spaceId), + } + ); + } + + private async buildInventory( + spaceId: string, + targetInternalSchema: string, + relatedSpaces?: ISpaceDataDbRelatedSpaces, + options: { copySpaceIds?: string[] } = {} + ): Promise { + const resolvedRelatedSpaces = + relatedSpaces ?? (await resolveSpaceDataDbRelatedSpaces(this.prismaService, spaceId)); + this.assertRelatedSpacesCanMigrateTogether(spaceId, resolvedRelatedSpaces); + const spaceIds = this.getRelatedSpaceIds(resolvedRelatedSpaces); + const copySpaceIdSet = new Set(options.copySpaceIds?.length ? options.copySpaceIds : spaceIds); + const copySpaces = resolvedRelatedSpaces.spaces.filter((space) => + copySpaceIdSet.has(space.spaceId) + ); + const copySpaceIds = copySpaces.map((space) => space.spaceId).sort(); + const baseIds = copySpaces.flatMap((space) => space.baseIds).sort(); + const tableIds = copySpaces.flatMap((space) => space.tableIds).sort(); + const relatedBaseIds = resolvedRelatedSpaces.spaces.flatMap((space) => space.baseIds).sort(); + const [tables, sharedTableIds, relatedSharedTableIds] = await Promise.all([ + this.prismaService.tableMeta.findMany({ + where: { id: { in: tableIds }, deletedTime: null }, + select: { id: true, dbTableName: true }, + orderBy: { id: 'asc' }, + }), + this.findSharedTableIdsForBases(baseIds), + this.findSharedTableIdsForBases(relatedBaseIds), + ]); + + const sourceDataDb = await this.dataDbClientManager.getDataDatabaseForSpace(spaceId); + const dataPrisma = (await this.dataDbClientManager.dataPrismaForSpace( + spaceId + )) as IDataPrismaIntrospectionClient; + const [physicalSchemas, postgresExtensionDependencies, outOfScopeForeignKeys] = + await Promise.all([ + this.inspectSourcePhysicalSchemas(baseIds, dataPrisma), + this.inspectSourcePostgresExtensionDependencies(baseIds, dataPrisma), + this.inspectSourceOutOfScopeForeignKeys(baseIds, dataPrisma), + ]); + + return { + sourceDataDb: this.buildSourceDataDbInventory(sourceDataDb), + targetDataDb: { + internalSchema: targetInternalSchema, + }, + relatedSpaces: resolvedRelatedSpaces, + spaceIds, + copySpaceIds, + baseIds, + tableIds, + sharedTableIds, + relatedSharedTableIds, + dbTableNames: tables.map((table) => table.dbTableName).sort(), + physicalSchemas, + postgresExtensionDependencies, + outOfScopeForeignKeys, + estimatedTotalBytes: physicalSchemas.reduce((sum, schema) => sum + schema.totalBytes, 0), + estimatedTotalRows: physicalSchemas.reduce((sum, schema) => sum + schema.estimatedRows, 0), + }; + } + + private normalizeInventory( + inventory: unknown, + fallbackSpaceId: string = '' + ): ISpaceDataDbInventory { + const candidate = (inventory ?? {}) as Partial; + const sourceDataDb = this.normalizeInventorySourceDataDb(candidate.sourceDataDb); + const targetDataDb = this.normalizeInventoryTargetDataDb(candidate.targetDataDb); + const physicalSchemas = candidate.physicalSchemas ?? []; + const relatedSpaces = this.normalizeInventoryRelatedSpaces(candidate, fallbackSpaceId); + const spaceIds = this.normalizeInventorySpaceIds(candidate, relatedSpaces); + const copySpaceIds = candidate.copySpaceIds?.length ? candidate.copySpaceIds : spaceIds; + const tableIds = candidate.tableIds ?? []; + const sharedTableIds = candidate.sharedTableIds ?? tableIds; + const relatedSharedTableIds = this.normalizeInventoryRelatedSharedTableIds( + candidate, + relatedSpaces, + sharedTableIds + ); + return { + sourceDataDb: { + mode: sourceDataDb.mode ?? 'default', + cacheKey: sourceDataDb.cacheKey ?? metaFallbackDataDbCacheKey, + connectionId: sourceDataDb.connectionId ?? null, + internalSchema: sourceDataDb.internalSchema ?? null, + isMetaFallback: sourceDataDb.isMetaFallback ?? true, + }, + targetDataDb: { + internalSchema: targetDataDb.internalSchema, + }, + relatedSpaces, + spaceIds, + copySpaceIds, + baseIds: candidate.baseIds ?? [], + tableIds, + sharedTableIds, + relatedSharedTableIds, + dbTableNames: candidate.dbTableNames ?? [], + physicalSchemas, + postgresExtensionDependencies: candidate.postgresExtensionDependencies ?? [], + outOfScopeForeignKeys: candidate.outOfScopeForeignKeys ?? [], + estimatedTotalBytes: + candidate.estimatedTotalBytes ?? + physicalSchemas.reduce((sum, schema) => sum + (schema.totalBytes ?? 0), 0), + estimatedTotalRows: + candidate.estimatedTotalRows ?? + physicalSchemas.reduce((sum, schema) => sum + (schema.estimatedRows ?? 0), 0), + }; + } + + private normalizeInventorySourceDataDb(sourceDataDb: unknown): ISpaceDataDbInventorySourceDataDb { + const candidate = (sourceDataDb ?? {}) as Partial; + return { + mode: candidate.mode ?? 'default', + cacheKey: candidate.cacheKey ?? metaFallbackDataDbCacheKey, + connectionId: candidate.connectionId ?? null, + internalSchema: candidate.internalSchema ?? null, + isMetaFallback: candidate.isMetaFallback ?? true, + }; + } + + private normalizeInventoryTargetDataDb(targetDataDb: unknown): ISpaceDataDbInventoryTargetDataDb { + const candidate = (targetDataDb ?? {}) as Partial; + return { + internalSchema: candidate.internalSchema ?? '', + }; + } + + private normalizeInventoryRelatedSpaces( + candidate: Partial, + fallbackSpaceId: string + ): ISpaceDataDbInventoryRelatedSpaces { + const relatedSpaces = candidate.relatedSpaces as ISpaceDataDbInventoryRelatedSpaces | undefined; + if (relatedSpaces) { + return relatedSpaces; + } + + return { + primarySpaceId: fallbackSpaceId, + hasCrossSpaceLinks: false, + spaces: [], + links: [], + }; + } + + private normalizeInventorySpaceIds( + candidate: Partial, + relatedSpaces: ISpaceDataDbInventoryRelatedSpaces + ) { + if (candidate.spaceIds?.length) { + return candidate.spaceIds; + } + if (relatedSpaces.spaces.length) { + return relatedSpaces.spaces.map((space) => space.spaceId); + } + return relatedSpaces.primarySpaceId ? [relatedSpaces.primarySpaceId] : []; + } + + private normalizeInventoryRelatedSharedTableIds( + candidate: Partial, + relatedSpaces: ISpaceDataDbInventoryRelatedSpaces, + sharedTableIds: string[] + ) { + if (candidate.relatedSharedTableIds) { + return candidate.relatedSharedTableIds; + } + if (!relatedSpaces.spaces.length) { + return sharedTableIds; + } + return [...new Set(relatedSpaces.spaces.flatMap((space) => space.tableIds ?? []))].sort(); + } + + private buildMigrationProgress( + job: IMigrationJobRecord, + inventory: ISpaceDataDbInventory, + phase: string + ): IMigrationProgressStats { + const completedSteps = migrationProgressCompletedSteps[phase] ?? 0; + const percent = Math.round((completedSteps / migrationProgressTotalSteps) * 1000) / 10; + const baseCopyCompleted = + completedSteps >= migrationProgressCompletedSteps.base_schemas_completed; + const updatedAt = new Date(); + const startedAt = job.startedAt ?? null; + const elapsedMs = startedAt ? Math.max(0, updatedAt.getTime() - startedAt.getTime()) : null; + const etaMs = + elapsedMs != null && percent > 0 && percent < 100 + ? Math.round((elapsedMs * (100 - percent)) / percent) + : null; + + return { + phase, + totalSteps: migrationProgressTotalSteps, + completedSteps, + percent, + estimatedTotalBytes: inventory.estimatedTotalBytes, + completedEstimatedBytes: baseCopyCompleted ? inventory.estimatedTotalBytes : 0, + estimatedTotalRows: inventory.estimatedTotalRows, + completedEstimatedRows: baseCopyCompleted ? inventory.estimatedTotalRows : 0, + startedAt: this.toNullableIso(startedAt), + updatedAt: updatedAt.toISOString(), + etaMs, + }; + } + + private compareInventoryForCopy( + expected: ISpaceDataDbInventory, + actual: ISpaceDataDbInventory, + options: { compareSharedScope?: boolean } = {} + ): IInventoryChangedMismatch[] { + const mismatches: IInventoryChangedMismatch[] = []; + this.collectObjectMismatch( + mismatches, + 'sourceDataDb', + expected.sourceDataDb, + actual.sourceDataDb + ); + this.collectObjectMismatch( + mismatches, + 'targetDataDb', + expected.targetDataDb, + actual.targetDataDb + ); + this.collectListMismatch(mismatches, 'spaceIds', expected.spaceIds, actual.spaceIds); + this.collectListMismatch( + mismatches, + 'copySpaceIds', + expected.copySpaceIds, + actual.copySpaceIds + ); + if (expected.relatedSpaces.spaces.length || expected.relatedSpaces.links.length) { + this.collectObjectMismatch( + mismatches, + 'relatedSpaces', + expected.relatedSpaces, + actual.relatedSpaces + ); + } + this.collectListMismatch(mismatches, 'baseIds', expected.baseIds, actual.baseIds); + this.collectListMismatch(mismatches, 'tableIds', expected.tableIds, actual.tableIds); + if (options.compareSharedScope) { + this.collectListMismatch( + mismatches, + 'sharedTableIds', + expected.sharedTableIds, + actual.sharedTableIds + ); + this.collectListMismatch( + mismatches, + 'relatedSharedTableIds', + expected.relatedSharedTableIds, + actual.relatedSharedTableIds + ); + } + this.collectListMismatch( + mismatches, + 'dbTableNames', + expected.dbTableNames, + actual.dbTableNames + ); + this.collectListMismatch( + mismatches, + 'physicalRelations', + this.buildPhysicalRelationKeys(expected), + this.buildPhysicalRelationKeys(actual) + ); + this.collectObjectMismatch( + mismatches, + 'postgresExtensionDependencies', + expected.postgresExtensionDependencies, + actual.postgresExtensionDependencies + ); + this.collectObjectMismatch( + mismatches, + 'outOfScopeForeignKeys', + expected.outOfScopeForeignKeys, + actual.outOfScopeForeignKeys + ); + return mismatches; + } + + private collectObjectMismatch( + mismatches: IInventoryChangedMismatch[], + object: string, + expected: unknown, + actual: unknown + ) { + if (stableJsonStringify(expected) === stableJsonStringify(actual)) { + return; + } + mismatches.push({ + object, + reason: 'inventory_changed', + expected, + actual, + }); + } + + private collectListMismatch( + mismatches: IInventoryChangedMismatch[], + object: string, + expected: string[], + actual: string[] + ) { + const sortedExpected = [...expected].sort(); + const sortedActual = [...actual].sort(); + if (JSON.stringify(sortedExpected) === JSON.stringify(sortedActual)) { + return; + } + + const expectedSet = new Set(sortedExpected); + const actualSet = new Set(sortedActual); + mismatches.push({ + object, + reason: 'inventory_changed', + expectedCount: sortedExpected.length, + actualCount: sortedActual.length, + added: sortedActual.filter((value) => !expectedSet.has(value)).slice(0, 5), + removed: sortedExpected.filter((value) => !actualSet.has(value)).slice(0, 5), + }); + } + + private buildPhysicalRelationKeys(inventory: ISpaceDataDbInventory) { + return inventory.physicalSchemas + .flatMap((schema) => + schema.relations.map( + (relation) => `${relation.schemaName}.${relation.relationName}:${relation.relationKind}` + ) + ) + .sort(); + } + + private async findSharedTableIdsForBases(baseIds: string[]): Promise { + if (!baseIds.length) { + return []; + } + + const rows = await this.prismaService.tableMeta.findMany({ + where: { baseId: { in: baseIds } }, + select: { id: true }, + orderBy: { id: 'asc' }, + }); + + return [...new Set(rows.map((row) => row.id))].sort(); + } + + private buildSourceDataDbInventory( + sourceDataDb: IResolvedDataDatabase + ): ISpaceDataDbInventorySourceDataDb { + return { + mode: sourceDataDb.isMetaFallback ? 'default' : 'byodb', + cacheKey: sourceDataDb.cacheKey, + connectionId: sourceDataDb.connectionId ?? null, + internalSchema: sourceDataDb.internalSchema ?? null, + isMetaFallback: sourceDataDb.isMetaFallback, + }; + } + + private async inspectSourcePhysicalSchemas( + baseIds: string[], + dataPrisma: IDataPrismaIntrospectionClient + ): Promise { + if (!baseIds.length) { + return []; + } + + const rows = normalizeRawRows<{ + schemaName: string; + relationName: string; + relationKind: string; + totalBytes: string | number | bigint | null; + estimatedRows: string | number | bigint | null; + }>( + await dataPrisma.$queryRawUnsafe( + ` + SELECT + n.nspname AS "schemaName", + c.relname AS "relationName", + CASE c.relkind + WHEN 'r' THEN 'table' + WHEN 'p' THEN 'partitioned_table' + WHEN 'S' THEN 'sequence' + WHEN 'v' THEN 'view' + WHEN 'm' THEN 'materialized_view' + WHEN 'f' THEN 'foreign_table' + ELSE c.relkind::text + END AS "relationKind", + pg_total_relation_size(c.oid)::text AS "totalBytes", + CASE + WHEN c.relkind IN ('r', 'p', 'f') THEN GREATEST(c.reltuples, 0)::bigint::text + ELSE NULL + END AS "estimatedRows" + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = ANY($1::text[]) + AND c.relkind IN ('r', 'p', 'S', 'v', 'm', 'f') + ORDER BY n.nspname ASC, c.relname ASC + `, + baseIds + ) + ); + + const schemaMap = new Map(); + for (const row of rows) { + const schema = schemaMap.get(row.schemaName) ?? { + schemaName: row.schemaName, + relations: [], + totalBytes: 0, + estimatedRows: 0, + }; + const relation = { + schemaName: row.schemaName, + relationName: row.relationName, + relationKind: row.relationKind, + totalBytes: Number(row.totalBytes ?? 0), + estimatedRows: row.estimatedRows == null ? null : Number(row.estimatedRows), + }; + schema.relations.push(relation); + schema.totalBytes += relation.totalBytes; + schema.estimatedRows += relation.estimatedRows ?? 0; + schemaMap.set(row.schemaName, schema); + } + + return Array.from(schemaMap.values()).sort((left, right) => + left.schemaName.localeCompare(right.schemaName) + ); + } + + private async inspectSourcePostgresExtensionDependencies( + baseIds: string[], + dataPrisma: IDataPrismaIntrospectionClient + ): Promise { + if (!baseIds.length) { + return []; + } + + const rows = normalizeRawRows<{ + extensionName?: string; + objectType?: string; + schemaName?: string; + objectName?: string; + accessMethod?: string; + sourceSchemaName?: string; + sourceRelationName?: string; + sourceIndexName?: string; + }>( + await dataPrisma.$queryRawUnsafe( + ` + WITH source_indexes AS ( + SELECT + tn.nspname AS "sourceSchemaName", + tc.relname AS "sourceRelationName", + ic.relname AS "sourceIndexName", + pg_get_indexdef(i.indexrelid) AS "definition" + FROM pg_index i + JOIN pg_class tc ON tc.oid = i.indrelid + JOIN pg_namespace tn ON tn.oid = tc.relnamespace + JOIN pg_class ic ON ic.oid = i.indexrelid + WHERE tn.nspname = ANY($1::text[]) + ) + SELECT + 'pg_trgm' AS "extensionName", + 'operator_class' AS "objectType", + 'public' AS "schemaName", + 'gin_trgm_ops' AS "objectName", + 'gin' AS "accessMethod", + "sourceSchemaName", + "sourceRelationName", + "sourceIndexName" + FROM source_indexes + WHERE position('gin_trgm_ops' in "definition") > 0 + ORDER BY "sourceSchemaName" ASC, "sourceRelationName" ASC, "sourceIndexName" ASC + `, + baseIds + ) + ); + + const dependencyMap = new Map(); + for (const row of rows) { + if ( + row.extensionName !== 'pg_trgm' || + row.objectType !== 'operator_class' || + !row.schemaName || + !row.objectName || + !row.accessMethod + ) { + continue; + } + + const key = [ + row.extensionName, + row.objectType, + row.schemaName, + row.objectName, + row.accessMethod, + ].join(':'); + const dependency = dependencyMap.get(key) ?? { + extensionName: row.extensionName, + objectType: 'operator_class', + schemaName: row.schemaName, + objectName: row.objectName, + accessMethod: row.accessMethod, + sourceObjects: [], + }; + if (row.sourceSchemaName && row.sourceRelationName && row.sourceIndexName) { + dependency.sourceObjects.push( + `${row.sourceSchemaName}.${row.sourceRelationName}.${row.sourceIndexName}` + ); + } + dependencyMap.set(key, dependency); + } + + return Array.from(dependencyMap.values()) + .map((dependency) => ({ + ...dependency, + sourceObjects: [...dependency.sourceObjects].sort().slice(0, 20), + })) + .sort((left, right) => + buildPostgresExtensionDependencyKey(left).localeCompare( + buildPostgresExtensionDependencyKey(right) + ) + ); + } + + private async inspectSourceOutOfScopeForeignKeys( + baseIds: string[], + dataPrisma: IDataPrismaIntrospectionClient + ): Promise { + if (!baseIds.length) { + return []; + } + + const rows = normalizeRawRows>( + await dataPrisma.$queryRawUnsafe( + ` + SELECT + source_ns.nspname AS "schemaName", + source_rel.relname AS "tableName", + con.conname AS "constraintName", + referenced_ns.nspname AS "referencedSchemaName", + referenced_rel.relname AS "referencedTableName" + FROM pg_constraint con + JOIN pg_class source_rel ON source_rel.oid = con.conrelid + JOIN pg_namespace source_ns ON source_ns.oid = source_rel.relnamespace + JOIN pg_class referenced_rel ON referenced_rel.oid = con.confrelid + JOIN pg_namespace referenced_ns ON referenced_ns.oid = referenced_rel.relnamespace + WHERE con.contype = 'f' + AND source_ns.nspname = ANY($1::text[]) + AND referenced_ns.nspname <> ALL($1::text[]) + ORDER BY + source_ns.nspname ASC, + source_rel.relname ASC, + con.conname ASC, + referenced_ns.nspname ASC, + referenced_rel.relname ASC + `, + baseIds + ) + ); + return rows + .filter((row): row is ISpaceDataDbExcludedForeignKey => + Boolean( + row.schemaName && + row.tableName && + row.constraintName && + row.referencedSchemaName && + row.referencedTableName + ) + ) + .sort((left, right) => + [ + left.schemaName, + left.tableName, + left.constraintName, + left.referencedSchemaName, + left.referencedTableName, + ] + .join(':') + .localeCompare( + [ + right.schemaName, + right.tableName, + right.constraintName, + right.referencedSchemaName, + right.referencedTableName, + ].join(':') + ) + ); + } + + private async prepareMigrationTarget( + url: string, + internalSchema: string + ): Promise { + const preflight = await this.preflightService.preflight({ + url, + targetMode: migrateSpaceTargetMode, + internalSchema, + }); + if (!preflight.ok) { + throw new CustomHttpException(buildPreflightErrorMessage(preflight), HttpErrorCode.CONFLICT, { + preflight, + }); + } + + const schemaVersion = await this.baselineService.initialize(url, internalSchema); + const { displayHost, displayDatabase } = getDatabaseUrlDisplayParts(url); + return { + encryptedUrl: encryptDataDbUrl(url), + urlFingerprint: fingerprintDataDbConnection(url, internalSchema), + displayHost, + displayDatabase, + internalSchema, + schemaVersion, + capabilities: preflight.capabilities, + }; + } + + private async assertTargetHasNoSpaceConflicts( + url: string, + internalSchema: string, + inventory: ISpaceDataDbInventory, + spaceId: string + ) { + const client = this.clientFactory(url); + try { + const conflicts = await this.inspectTargetConflicts(client, internalSchema, inventory); + if (!conflicts.length) { + return; + } + + throw new CustomHttpException( + 'Target BYODB database already contains objects for this space migration', + HttpErrorCode.CONFLICT, + { + errorCode: spaceDataDbTargetConflictErrorCode, + conflicts, + spaceId, + } + ); + } finally { + await client.destroy().catch(() => undefined); + } + } + + private async assertTargetSupportsSourceDependencies( + url: string, + inventory: ISpaceDataDbInventory, + spaceId: string + ) { + const dependencies = inventory.postgresExtensionDependencies; + if (!dependencies.length) { + return; + } + + const client = this.clientFactory(url); + try { + const installErrors = await this.tryInstallTargetPostgresExtensionDependencies( + client, + dependencies + ); + const missingExtensions = await this.inspectMissingTargetExtensionDependencies( + client, + dependencies + ); + if (!missingExtensions.length) { + return; + } + + const errors = missingExtensions.map((dependency) => { + const installError = installErrors.find( + (error) => + error.extensionName === dependency.extensionName && + error.schemaName === dependency.schemaName + ); + const installErrorMessage = installError?.message + ? ` Automatic installation failed: ${installError.message}` + : ''; + return { + code: 'MISSING_POSTGRES_EXTENSION', + message: `Target database is missing ${dependency.schemaName}.${dependency.objectName}, required by source ${dependency.extensionName} indexes.${installErrorMessage}`, + remediation: + dependency.extensionName === 'pg_trgm' + ? `Allow the migration connection to create pg_trgm, or install it before migration: CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA ${dependency.schemaName};` + : `Install the ${dependency.extensionName} extension in the target database before migration.`, + }; + }); + + throw new CustomHttpException( + 'Target BYODB database is missing PostgreSQL extensions required by the source space', + HttpErrorCode.CONFLICT, + { + errorCode: spaceDataDbTargetExtensionMissingErrorCode, + errors, + missingExtensions, + requiredExtensions: dependencies, + spaceId, + } + ); + } finally { + await client.destroy().catch(() => undefined); + } + } + + private async tryInstallTargetPostgresExtensionDependencies( + client: IDataDbPreflightClient, + dependencies: ISpaceDataDbPostgresExtensionDependency[] + ) { + const errors: { extensionName: string; schemaName: string; message: string }[] = []; + for (const target of this.getTargetPostgresExtensionInstallTargets(dependencies)) { + for (const schemaName of target.schemaNames) { + const error = await this.tryInstallTargetPostgresExtension( + client, + target.extensionName, + schemaName + ); + if (!error) { + break; + } + errors.push(error); + } + } + + return errors; + } + + private getTargetPostgresExtensionInstallTargets( + dependencies: ISpaceDataDbPostgresExtensionDependency[] + ) { + const installTargets = new Map(); + for (const dependency of dependencies) { + if (dependency.extensionName !== 'pg_trgm') { + continue; + } + installTargets.set(dependency.extensionName, { + extensionName: dependency.extensionName, + schemaNames: [...new Set([dependency.schemaName, 'extensions'].filter(Boolean))], + }); + } + return [...installTargets.values()]; + } + + private async tryInstallTargetPostgresExtension( + client: IDataDbPreflightClient, + extensionName: string, + schemaName: string + ) { + try { + await client.raw( + `CREATE EXTENSION IF NOT EXISTS ${quoteIdent(extensionName)} WITH SCHEMA ${quoteIdent(schemaName)}` + ); + return undefined; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + extensionName, + schemaName, + message: message.length > 500 ? message.slice(message.length - 500) : message, + }; + } + } + + private async inspectMissingTargetExtensionDependencies( + client: IDataDbPreflightClient, + dependencies: ISpaceDataDbPostgresExtensionDependency[] + ) { + const operatorClassDependencies = dependencies.filter( + (dependency) => dependency.objectType === 'operator_class' + ); + if (!operatorClassDependencies.length) { + return []; + } + + const schemaNames = [ + ...new Set(operatorClassDependencies.map((dependency) => dependency.schemaName)), + ]; + const objectNames = [ + ...new Set(operatorClassDependencies.map((dependency) => dependency.objectName)), + ]; + const accessMethods = [ + ...new Set(operatorClassDependencies.map((dependency) => dependency.accessMethod)), + ]; + const rows = normalizeRawRows<{ + schemaName: string; + objectName: string; + accessMethod: string; + }>( + await client.raw( + ` + SELECT + n.nspname AS "schemaName", + opc.opcname AS "objectName", + am.amname AS "accessMethod" + FROM pg_opclass opc + JOIN pg_namespace n ON n.oid = opc.opcnamespace + JOIN pg_am am ON am.oid = opc.opcmethod + WHERE (n.nspname = ANY(?::text[]) OR n.nspname = ANY(current_schemas(true))) + AND opc.opcname = ANY(?::text[]) + AND am.amname = ANY(?::text[]) + `, + [schemaNames, objectNames, accessMethods] + ) + ); + const available = new Set( + rows.map((row) => [row.schemaName, row.objectName, row.accessMethod].join(':')) + ); + const visible = new Set(rows.map((row) => [row.objectName, row.accessMethod].join(':'))); + + return operatorClassDependencies.filter( + (dependency) => + !available.has( + [dependency.schemaName, dependency.objectName, dependency.accessMethod].join(':') + ) && !visible.has([dependency.objectName, dependency.accessMethod].join(':')) + ); + } + + private async inspectTargetConflicts( + client: IDataDbPreflightClient, + internalSchema: string, + inventory: ISpaceDataDbInventory + ): Promise { + const conflicts: ITargetConflict[] = []; + const spaceIds = this.getInventoryCopySpaceIds(inventory); + + if (inventory.baseIds.length) { + const baseSchemaRows = normalizeRawRows<{ schemaName: string }>( + await client.raw( + ` + SELECT schema_name AS "schemaName" + FROM information_schema.schemata + WHERE schema_name = ANY(?::text[]) + `, + [inventory.baseIds] + ) + ); + conflicts.push(...baseSchemaRows.map((row) => ({ object: `schema:${row.schemaName}` }))); + } + + await this.pushConflictCount( + client, + conflicts, + internalSchema, + sharedTables.recordHistory, + inventory.sharedTableIds.length ? `"table_id" = ANY(?::text[])` : '', + [inventory.sharedTableIds] + ); + await this.pushConflictCount( + client, + conflicts, + internalSchema, + sharedTables.tableTrash, + inventory.sharedTableIds.length ? `"table_id" = ANY(?::text[])` : '', + [inventory.sharedTableIds] + ); + await this.pushConflictCount( + client, + conflicts, + internalSchema, + sharedTables.recordTrash, + inventory.sharedTableIds.length ? `"table_id" = ANY(?::text[])` : '', + [inventory.sharedTableIds] + ); + await this.pushConflictCount( + client, + conflicts, + internalSchema, + sharedTables.computedUpdateOutbox, + inventory.baseIds.length ? `"base_id" = ANY(?::text[])` : '', + [inventory.baseIds] + ); + await this.pushConflictCount( + client, + conflicts, + internalSchema, + sharedTables.computedUpdateDeadLetter, + inventory.baseIds.length ? `"base_id" = ANY(?::text[])` : '', + [inventory.baseIds] + ); + await this.pushConflictCount( + client, + conflicts, + internalSchema, + sharedTables.computedUpdateOutboxSeed, + inventory.tableIds.length ? `"table_id" = ANY(?::text[])` : '', + [inventory.tableIds] + ); + await this.pushConflictCount( + client, + conflicts, + internalSchema, + sharedTables.computedUpdatePauseScope, + [ + `("scope_type" = 'space' AND "scope_id" = ANY(?::text[]))`, + inventory.baseIds.length ? `("scope_type" = 'base' AND "scope_id" = ANY(?::text[]))` : '', + inventory.sharedTableIds.length + ? `("scope_type" = 'table' AND "scope_id" = ANY(?::text[]))` + : '', + ] + .filter(Boolean) + .join(' OR '), + [spaceIds, inventory.baseIds, inventory.sharedTableIds].filter((value) => + Array.isArray(value) ? value.length > 0 : Boolean(value) + ) + ); + await this.pushConflictCount( + client, + conflicts, + internalSchema, + sharedTables.undoLog, + inventory.baseIds.length ? `split_part("table_name", '.', 1) = ANY(?::text[])` : '', + [inventory.baseIds] + ); + + return conflicts; + } + + private async pushConflictCount( + client: IDataDbPreflightClient, + conflicts: ITargetConflict[], + internalSchema: string, + table: string, + whereSql: string, + bindings: unknown[] + ) { + if (!whereSql) { + return; + } + + const qualifiedTable = qualify(internalSchema, table); + const existsRows = normalizeRawRows<{ exists: boolean }>( + await client.raw(`SELECT to_regclass(?::text) IS NOT NULL AS "exists"`, [qualifiedTable]) + ); + if (!existsRows[0]?.exists) { + return; + } + + const countRows = normalizeRawRows<{ count: string | number | bigint }>( + await client.raw( + `SELECT COUNT(*) AS "count" FROM ${qualifiedTable} WHERE ${whereSql}`, + bindings + ) + ); + const count = Number(countRows[0]?.count ?? 0); + if (count > 0) { + conflicts.push({ object: `table:${internalSchema}.${table}`, count }); + } + } + + async cleanupTargetArtifactsForJob( + jobId: string, + reason: string = 'manual_cleanup', + options: { truncateSharedTables?: boolean } = {} + ): Promise { + const job = await this.migrationJobClient.spaceDataDbMigrationJob.findUnique({ + where: { id: jobId }, + include: { targetConnection: true }, + }); + if (!job) { + throw new CustomHttpException(`Migration job ${jobId} not found`, HttpErrorCode.NOT_FOUND); + } + if (!job.targetConnection?.encryptedUrl) { + throw new CustomHttpException( + `Migration job ${jobId} has no target connection`, + HttpErrorCode.VALIDATION_ERROR + ); + } + if (job.state === 'succeeded' && job.switchOnCompletion === true) { + throw new CustomHttpException( + `Migration job ${jobId} has already switched; automatic target cleanup is unsafe`, + HttpErrorCode.CONFLICT + ); + } + + const inventory = this.normalizeInventory(job.inventory, job.spaceId); + const targetUrl = decryptDataDbUrl(job.targetConnection.encryptedUrl); + const client = this.clientFactory(targetUrl); + const stats: ITargetArtifactCleanupStats = { + reason, + baseSchemas: [], + sharedTables: [], + truncateSharedTables: options.truncateSharedTables === true, + startedAt: new Date().toISOString(), + }; + + try { + stats.sharedTables = await this.cleanupTargetSharedRows( + client, + job.targetInternalSchema, + inventory, + job.spaceId, + { truncate: options.truncateSharedTables === true } + ); + stats.baseSchemas = await this.cleanupTargetBaseSchemas(client, inventory.baseIds); + stats.completedAt = new Date().toISOString(); + await this.updateTargetCleanupStats(jobId, job.copyStats, stats); + return stats; + } catch (error) { + stats.failedAt = new Date().toISOString(); + stats.error = error instanceof Error ? error.message : String(error); + await this.updateTargetCleanupStats(jobId, job.copyStats, stats).catch(() => undefined); + if (error instanceof CustomHttpException) { + throw error; + } + throw new CustomHttpException( + 'Target BYODB retry artifact cleanup failed before migration could be queued', + HttpErrorCode.CONFLICT, + { + errorCode: spaceDataDbTargetCleanupFailedErrorCode, + jobId, + reason, + spaceId: job.spaceId, + internalSchema: job.targetInternalSchema, + targetError: this.sanitizeTargetErrorMessage(error, targetUrl), + cleanup: stats, + } + ); + } finally { + await client.destroy().catch(() => undefined); + } + } + + private async cleanupTargetBaseSchemas( + client: IDataDbPreflightClient, + baseIds: string[] + ): Promise { + if (!baseIds.length) { + return []; + } + + const rows = normalizeRawRows<{ schemaName: string }>( + await client.raw( + ` + SELECT schema_name AS "schemaName" + FROM information_schema.schemata + WHERE schema_name = ANY(?::text[]) + ORDER BY schema_name ASC + `, + [baseIds] + ) + ); + const existing = new Set(rows.map((row) => row.schemaName)); + const stats: ITargetArtifactCleanupStats['baseSchemas'] = []; + + for (const schemaName of [...baseIds].sort()) { + if (existing.has(schemaName)) { + await client.raw(`DROP SCHEMA IF EXISTS ${quoteIdent(schemaName)} CASCADE`); + } + stats.push({ + schemaName, + dropped: existing.has(schemaName), + }); + } + + return stats; + } + + private async cleanupTargetSharedRows( + client: IDataDbPreflightClient, + targetSchema: string, + inventory: ISpaceDataDbInventory, + spaceId: string, + options: { truncate?: boolean } = {} + ): Promise { + const plans = this.buildSharedTableCleanupPlans(inventory, spaceId); + const stats: ITargetArtifactCleanupStats['sharedTables'] = []; + const existingPlans: typeof plans = []; + + for (const plan of plans) { + const qualifiedTable = qualify(targetSchema, plan.table); + const existsRows = normalizeRawRows<{ exists: boolean }>( + await client.raw(`SELECT to_regclass(?::text) IS NOT NULL AS "exists"`, [qualifiedTable]) + ); + if (!existsRows[0]?.exists) { + stats.push({ table: plan.table, deletedRows: null }); + continue; + } + + if (options.truncate) { + existingPlans.push(plan); + continue; + } + + const deletedRows = normalizeRawRows<{ count: string | number | bigint }>( + await client.raw( + ` + WITH deleted AS ( + DELETE FROM ${qualifiedTable} + WHERE ${plan.whereSql(targetSchema)} + RETURNING 1 + ) + SELECT COUNT(*) AS "count" FROM deleted + `, + plan.bindings + ) + ); + stats.push({ + table: plan.table, + deletedRows: Number(deletedRows[0]?.count ?? 0), + }); + } + + if (options.truncate && existingPlans.length) { + await client.raw( + `TRUNCATE TABLE ${existingPlans.map((plan) => qualify(targetSchema, plan.table)).join(', ')}` + ); + stats.push( + ...existingPlans.map((plan) => ({ + table: plan.table, + deletedRows: null, + truncated: true, + })) + ); + } + + return stats; + } + + private buildSharedTableCleanupPlans(inventory: ISpaceDataDbInventory, spaceId: string) { + const plans = this.buildSharedTableCountPlans(inventory, spaceId); + const priority = new Map([ + [sharedTables.computedUpdateOutboxSeed, 0], + [sharedTables.recordHistory, 1], + [sharedTables.tableTrash, 2], + [sharedTables.recordTrash, 3], + [sharedTables.computedUpdateDeadLetter, 4], + [sharedTables.computedUpdateOutbox, 5], + [sharedTables.computedUpdatePauseScope, 6], + [sharedTables.undoLog, 7], + ]); + return [...plans].sort((left, right) => { + const leftPriority = priority.get(left.table) ?? Number.MAX_SAFE_INTEGER; + const rightPriority = priority.get(right.table) ?? Number.MAX_SAFE_INTEGER; + return leftPriority - rightPriority || left.table.localeCompare(right.table); + }); + } + + private async updateTargetCleanupStats( + jobId: string, + copyStats: unknown, + cleanup: ITargetArtifactCleanupStats + ) { + const current = this.asRecord(copyStats) ?? {}; + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: jobId }, + data: { + copyStats: { + ...current, + targetCleanup: cleanup, + }, + }, + }); + } + + private async getMigrationJob(jobId: string): Promise { + const job = await this.migrationJobClient.spaceDataDbMigrationJob.findUnique({ + where: { id: jobId }, + include: { targetConnection: true }, + }); + if (!job) { + throw new CustomHttpException(`Migration job ${jobId} not found`, HttpErrorCode.NOT_FOUND); + } + return job; + } + + private async validateCopiedData(input: { + sourceClient: IDataDbPreflightClient; + targetClient: IDataDbPreflightClient; + sourceSchema: string; + targetSchema: string; + targetConnectionId: string | null; + targetEncryptedUrl: string; + inventory: ISpaceDataDbInventory; + spaceId: string; + }): Promise<{ + targetSchemaVersion: { latest: string | null; exists: boolean }; + routeSmoke: IRouteSmokeStats; + baseSchemas: IRowCountValidation[]; + sharedTables: IRowCountValidation[]; + undoFunction: { exists: boolean }; + mismatches: IValidationMismatch[]; + }> { + const targetSchemaVersion = await this.validateTargetSchemaVersion( + input.targetClient, + input.targetSchema + ); + const routeSmoke = await this.validateRouteSmoke({ + spaceId: input.spaceId, + targetConnectionId: input.targetConnectionId, + targetEncryptedUrl: input.targetEncryptedUrl, + targetSchema: input.targetSchema, + }); + const baseValidation = await this.validateBaseSchemaRows( + input.sourceClient, + input.targetClient, + input.inventory + ); + const sharedValidation = await this.validateSharedTableRows(input); + const undoFunction = await this.validateUndoFunction(input.targetClient, input.targetSchema); + const mismatches = [ + ...baseValidation.mismatches, + ...sharedValidation.mismatches, + ...(targetSchemaVersion.exists + ? [] + : [ + { + object: `schema:${input.targetSchema}.${dataDbMigrationTable}`, + reason: 'target_schema_version_mismatch', + }, + ]), + ...(routeSmoke.ok + ? [] + : [ + { + object: `route:${input.spaceId}`, + reason: 'target_route_smoke_failed', + }, + ]), + ...(undoFunction.exists + ? [] + : [ + { + object: `function:${input.targetSchema}.__teable_capture_undo_row`, + reason: 'missing_target_function', + }, + ]), + ]; + + return { + targetSchemaVersion, + routeSmoke, + baseSchemas: baseValidation.validatedRows, + sharedTables: sharedValidation.validatedRows, + undoFunction, + mismatches, + }; + } + + private async validateBaseSchemaRows( + sourceClient: IDataDbPreflightClient, + targetClient: IDataDbPreflightClient, + inventory: ISpaceDataDbInventory + ): Promise<{ validatedRows: IRowCountValidation[]; mismatches: IValidationMismatch[] }> { + const baseIds = inventory.baseIds; + if (!baseIds.length) { + return { validatedRows: [], mismatches: [] }; + } + + const sourceRelations = await this.inspectPhysicalSchemasWithClient(sourceClient, baseIds); + const targetRelations = await this.inspectPhysicalSchemasWithClient(targetClient, baseIds); + const sourceMap = this.relationMap(sourceRelations); + const targetMap = this.relationMap(targetRelations); + const mismatches: IValidationMismatch[] = []; + const validatedRows: IRowCountValidation[] = []; + + for (const [key, sourceRelation] of sourceMap) { + const relationMismatch = this.buildBaseRelationMismatch( + key, + sourceRelation, + targetMap.get(key) + ); + if (relationMismatch) { + mismatches.push(relationMismatch); + continue; + } + const targetRelation = targetMap.get(key)!; + const columnMismatch = await this.buildColumnSignatureMismatch( + sourceClient, + targetClient, + key, + sourceRelation, + targetRelation + ); + if (columnMismatch) { + mismatches.push(columnMismatch); + } + mismatches.push( + ...(await this.buildRelationDependencySignatureMismatches( + sourceClient, + targetClient, + key, + sourceRelation, + targetRelation, + baseIds + )) + ); + const rowValidation = await this.buildBaseRelationRowValidation( + sourceClient, + targetClient, + key, + sourceRelation, + targetRelation + ); + if (rowValidation) { + validatedRows.push(rowValidation.validatedRow); + if (rowValidation.mismatch) { + mismatches.push(rowValidation.mismatch); + } + } + } + + for (const [key, targetRelation] of targetMap) { + if (!sourceMap.has(key)) { + mismatches.push({ + object: `base:${key}`, + reason: 'extra_target_relation', + targetKind: targetRelation.relationKind, + }); + } + } + + return { validatedRows, mismatches }; + } + + private buildBaseRelationMismatch( + key: string, + sourceRelation: ISpaceDataDbPhysicalRelation, + targetRelation: ISpaceDataDbPhysicalRelation | undefined + ): IValidationMismatch | null { + if (!targetRelation) { + return { + object: `base:${key}`, + reason: 'missing_target_relation', + sourceKind: sourceRelation.relationKind, + targetKind: null, + }; + } + if (targetRelation.relationKind !== sourceRelation.relationKind) { + return { + object: `base:${key}`, + reason: 'relation_kind_mismatch', + sourceKind: sourceRelation.relationKind, + targetKind: targetRelation.relationKind, + }; + } + return null; + } + + private async buildColumnSignatureMismatch( + sourceClient: IDataDbPreflightClient, + targetClient: IDataDbPreflightClient, + key: string, + sourceRelation: ISpaceDataDbPhysicalRelation, + targetRelation: ISpaceDataDbPhysicalRelation + ): Promise { + if (!relationKindsWithColumns.has(sourceRelation.relationKind)) { + return null; + } + + const [sourceColumns, targetColumns] = await Promise.all([ + this.inspectRelationColumnSignature( + sourceClient, + sourceRelation.schemaName, + sourceRelation.relationName + ), + this.inspectRelationColumnSignature( + targetClient, + targetRelation.schemaName, + targetRelation.relationName + ), + ]); + return this.isColumnSignatureEqual(sourceColumns, targetColumns) + ? null + : { + object: `base:${key}`, + reason: 'column_signature_mismatch', + sourceColumns, + targetColumns, + }; + } + + private async buildBaseRelationRowValidation( + sourceClient: IDataDbPreflightClient, + targetClient: IDataDbPreflightClient, + key: string, + sourceRelation: ISpaceDataDbPhysicalRelation, + targetRelation: ISpaceDataDbPhysicalRelation + ): Promise<{ validatedRow: IRowCountValidation; mismatch: IValidationMismatch | null } | null> { + if (!relationKindsWithRows.has(sourceRelation.relationKind)) { + return null; + } + + const [sourceCount, targetCount] = await Promise.all([ + this.countRows(sourceClient, sourceRelation.schemaName, sourceRelation.relationName), + this.countRows(targetClient, targetRelation.schemaName, targetRelation.relationName), + ]); + const validatedRow = { + object: `base:${key}`, + sourceCount, + targetCount, + }; + return { + validatedRow, + mismatch: + sourceCount === targetCount + ? null + : { + ...validatedRow, + reason: 'row_count_mismatch', + }, + }; + } + + private async buildRelationDependencySignatureMismatches( + sourceClient: IDataDbPreflightClient, + targetClient: IDataDbPreflightClient, + key: string, + sourceRelation: ISpaceDataDbPhysicalRelation, + targetRelation: ISpaceDataDbPhysicalRelation, + baseIds: string[] + ): Promise { + const mismatches: IValidationMismatch[] = []; + + if (relationKindsWithIndexSignatures.has(sourceRelation.relationKind)) { + const [sourceIndexes, targetIndexes] = await Promise.all([ + this.inspectRelationIndexSignature( + sourceClient, + sourceRelation.schemaName, + sourceRelation.relationName + ), + this.inspectRelationIndexSignature( + targetClient, + targetRelation.schemaName, + targetRelation.relationName + ), + ]); + if (JSON.stringify(sourceIndexes) !== JSON.stringify(targetIndexes)) { + mismatches.push({ + object: `base:${key}`, + reason: 'index_signature_mismatch', + sourceIndexes, + targetIndexes, + }); + } + } + + if (relationKindsWithTableDependencySignatures.has(sourceRelation.relationKind)) { + const [sourceConstraints, targetConstraints, sourceTriggers, targetTriggers] = + await Promise.all([ + this.inspectRelationConstraintSignature( + sourceClient, + sourceRelation.schemaName, + sourceRelation.relationName, + baseIds + ), + this.inspectRelationConstraintSignature( + targetClient, + targetRelation.schemaName, + targetRelation.relationName, + baseIds + ), + this.inspectRelationTriggerSignature( + sourceClient, + sourceRelation.schemaName, + sourceRelation.relationName + ), + this.inspectRelationTriggerSignature( + targetClient, + targetRelation.schemaName, + targetRelation.relationName + ), + ]); + if (JSON.stringify(sourceConstraints) !== JSON.stringify(targetConstraints)) { + mismatches.push({ + object: `base:${key}`, + reason: 'constraint_signature_mismatch', + sourceConstraints, + targetConstraints, + }); + } + if (JSON.stringify(sourceTriggers) !== JSON.stringify(targetTriggers)) { + mismatches.push({ + object: `base:${key}`, + reason: 'trigger_signature_mismatch', + sourceTriggers, + targetTriggers, + }); + } + } + + return mismatches; + } + + private async validateSharedTableRows(input: { + sourceClient: IDataDbPreflightClient; + targetClient: IDataDbPreflightClient; + sourceSchema: string; + targetSchema: string; + inventory: ISpaceDataDbInventory; + spaceId: string; + }): Promise<{ validatedRows: IRowCountValidation[]; mismatches: IValidationMismatch[] }> { + const countPlans = this.buildSharedTableCountPlans(input.inventory, input.spaceId); + const relatedCountPlans = this.buildRelatedSharedTableCountPlans( + input.inventory, + input.spaceId + ); + const relatedPlanByTable = new Map(relatedCountPlans.map((plan) => [plan.table, plan])); + const validatedRows: IRowCountValidation[] = []; + const mismatches: IValidationMismatch[] = []; + + for (const plan of countPlans) { + const relatedPlan = relatedPlanByTable.get(plan.table) ?? plan; + const [sourceCount, targetCount] = await Promise.all([ + this.countRows( + input.sourceClient, + input.sourceSchema, + plan.table, + plan.whereSql(input.sourceSchema), + plan.bindings + ), + this.countRows( + input.targetClient, + input.targetSchema, + plan.table, + plan.whereSql(input.targetSchema), + plan.bindings + ), + ]); + const rowValidation = { + object: `shared:${plan.table}`, + sourceCount, + targetCount, + }; + validatedRows.push(rowValidation); + if (sourceCount !== targetCount) { + mismatches.push({ + ...rowValidation, + reason: 'row_count_mismatch', + }); + } + + const outOfScopeTargetCount = await this.countRows( + input.targetClient, + input.targetSchema, + plan.table, + `NOT (${relatedPlan.whereSql(input.targetSchema)})`, + relatedPlan.bindings + ); + if (outOfScopeTargetCount > 0) { + mismatches.push({ + object: `shared:${plan.table}`, + reason: 'out_of_scope_target_rows', + targetCount: outOfScopeTargetCount, + }); + } + } + + return { validatedRows, mismatches }; + } + + private buildSharedTableCountPlans(inventory: ISpaceDataDbInventory, spaceId: string) { + return this.buildSharedTableCountPlansForScope( + inventory, + spaceId, + this.getInventoryCopySpaceIds(inventory), + inventory.baseIds, + inventory.tableIds, + inventory.sharedTableIds + ); + } + + private buildRelatedSharedTableCountPlans(inventory: ISpaceDataDbInventory, spaceId: string) { + if (!inventory.relatedSpaces.spaces.length) { + return this.buildSharedTableCountPlansForScope( + inventory, + spaceId, + this.getInventorySpaceIds(inventory), + inventory.baseIds, + inventory.tableIds, + inventory.relatedSharedTableIds + ); + } + + const relatedBaseIds = this.getRelatedSpaceIds(inventory.relatedSpaces).flatMap( + (relatedSpaceId) => + inventory.relatedSpaces.spaces.find((space) => space.spaceId === relatedSpaceId)?.baseIds ?? + [] + ); + const relatedTableIds = this.getRelatedSpaceIds(inventory.relatedSpaces).flatMap( + (relatedSpaceId) => + inventory.relatedSpaces.spaces.find((space) => space.spaceId === relatedSpaceId) + ?.tableIds ?? [] + ); + return this.buildSharedTableCountPlansForScope( + inventory, + spaceId, + this.getInventorySpaceIds(inventory), + relatedBaseIds.sort(), + relatedTableIds.sort(), + inventory.relatedSharedTableIds + ); + } + + private buildSharedTableCountPlansForScope( + inventory: ISpaceDataDbInventory, + spaceId: string, + spaceIds: string[], + baseIds: string[], + tableIds: string[], + sharedTableIds: string[] = tableIds + ) { + const plans: { table: string; whereSql: (schema: string) => string; bindings: unknown[] }[] = + []; + const pushTableScoped = (table: string) => { + if (sharedTableIds.length) { + plans.push({ + table, + whereSql: () => `"table_id" = ANY(?::text[])`, + bindings: [sharedTableIds], + }); + } + }; + const pushBaseScoped = (table: string) => { + if (baseIds.length) { + plans.push({ + table, + whereSql: () => `"base_id" = ANY(?::text[])`, + bindings: [baseIds], + }); + } + }; + + pushTableScoped(sharedTables.recordHistory); + pushTableScoped(sharedTables.tableTrash); + pushTableScoped(sharedTables.recordTrash); + pushBaseScoped(sharedTables.computedUpdateOutbox); + pushBaseScoped(sharedTables.computedUpdateDeadLetter); + + if (tableIds.length && baseIds.length) { + plans.push({ + table: sharedTables.computedUpdateOutboxSeed, + whereSql: (schema) => + [ + `"table_id" = ANY(?::text[])`, + `"task_id" IN (SELECT "id" FROM ${qualify( + schema, + sharedTables.computedUpdateOutbox + )} WHERE "base_id" = ANY(?::text[]))`, + ].join(' AND '), + bindings: [tableIds, baseIds], + }); + } + + plans.push({ + table: sharedTables.computedUpdatePauseScope, + whereSql: () => + [ + `("scope_type" = 'space' AND "scope_id" = ANY(?::text[]))`, + baseIds.length ? `("scope_type" = 'base' AND "scope_id" = ANY(?::text[]))` : '', + sharedTableIds.length ? `("scope_type" = 'table' AND "scope_id" = ANY(?::text[]))` : '', + ] + .filter(Boolean) + .join(' OR '), + bindings: [spaceIds, baseIds, sharedTableIds].filter((value) => + Array.isArray(value) ? value.length > 0 : Boolean(value) + ), + }); + + if (baseIds.length) { + plans.push({ + table: sharedTables.undoLog, + whereSql: () => `split_part("table_name", '.', 1) = ANY(?::text[])`, + bindings: [baseIds], + }); + } + + return plans; + } + + private async inspectPhysicalSchemasWithClient( + client: IDataDbPreflightClient, + baseIds: string[] + ): Promise { + if (!baseIds.length) { + return []; + } + + return normalizeRawRows( + await client.raw( + ` + SELECT + n.nspname AS "schemaName", + c.relname AS "relationName", + CASE c.relkind + WHEN 'r' THEN 'table' + WHEN 'p' THEN 'partitioned_table' + WHEN 'S' THEN 'sequence' + WHEN 'v' THEN 'view' + WHEN 'm' THEN 'materialized_view' + WHEN 'f' THEN 'foreign_table' + ELSE c.relkind::text + END AS "relationKind", + pg_total_relation_size(c.oid)::text AS "totalBytes", + CASE + WHEN c.relkind IN ('r', 'p', 'f') THEN GREATEST(c.reltuples, 0)::bigint::text + ELSE NULL + END AS "estimatedRows" + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = ANY(?::text[]) + AND c.relkind IN ('r', 'p', 'S', 'v', 'm', 'f') + ORDER BY n.nspname ASC, c.relname ASC + `, + [baseIds] + ) + ); + } + + private relationMap(relations: ISpaceDataDbPhysicalRelation[]) { + return new Map(relations.map((relation) => [this.relationKey(relation), relation])); + } + + private relationKey(relation: ISpaceDataDbPhysicalRelation) { + return `${relation.schemaName}.${relation.relationName}`; + } + + private async inspectRelationColumnSignature( + client: IDataDbPreflightClient, + schema: string, + relation: string + ): Promise { + return normalizeRawRows( + await client.raw( + ` + SELECT + a.attnum::integer AS "ordinalPosition", + a.attname AS "columnName", + format_type(a.atttypid, a.atttypmod) AS "formattedType", + a.attnotnull AS "notNull", + pg_get_expr(ad.adbin, ad.adrelid) AS "defaultExpression", + a.attidentity AS "identity", + a.attgenerated AS "generated", + coll.collname AS "collation" + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum + LEFT JOIN pg_collation coll ON coll.oid = a.attcollation + AND a.attcollation <> 0 + WHERE n.nspname = ? + AND c.relname = ? + AND a.attnum > 0 + AND NOT a.attisdropped + ORDER BY a.attnum ASC + `, + [schema, relation] + ) + ); + } + + private isColumnSignatureEqual( + sourceColumns: IBaseRelationColumnSignature[], + targetColumns: IBaseRelationColumnSignature[] + ) { + return ( + JSON.stringify(this.columnSignatureForComparison(sourceColumns)) === + JSON.stringify(this.columnSignatureForComparison(targetColumns)) + ); + } + + private columnSignatureForComparison(columns: IBaseRelationColumnSignature[]) { + return columns.map(({ ordinalPosition: _ordinalPosition, ...column }) => column); + } + + private async inspectRelationIndexSignature( + client: IDataDbPreflightClient, + schema: string, + relation: string + ): Promise { + return normalizeRawRows( + await client.raw( + ` + SELECT + ic.relname AS "indexName", + i.indisprimary AS "isPrimary", + i.indisunique AS "isUnique", + i.indisvalid AS "isValid", + pg_get_indexdef(i.indexrelid) AS "definition" + FROM pg_index i + JOIN pg_class c ON c.oid = i.indrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_class ic ON ic.oid = i.indexrelid + WHERE n.nspname = ? + AND c.relname = ? + ORDER BY i.indisprimary DESC, ic.relname ASC + `, + [schema, relation] + ) + ); + } + + private async inspectRelationConstraintSignature( + client: IDataDbPreflightClient, + schema: string, + relation: string, + includedForeignKeySchemas?: string[] + ): Promise { + const scopedForeignKeySql = includedForeignKeySchemas?.length + ? "AND (con.contype <> 'f' OR referenced_ns.nspname = ANY(?::text[]))" + : ''; + return normalizeRawRows( + await client.raw( + ` + SELECT + con.conname AS "constraintName", + con.contype AS "constraintType", + pg_get_constraintdef(con.oid, true) AS "definition" + FROM pg_constraint con + JOIN pg_class c ON c.oid = con.conrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN pg_class referenced_rel ON referenced_rel.oid = con.confrelid + LEFT JOIN pg_namespace referenced_ns ON referenced_ns.oid = referenced_rel.relnamespace + WHERE n.nspname = ? + AND c.relname = ? + ${scopedForeignKeySql} + ORDER BY con.contype ASC, con.conname ASC + `, + includedForeignKeySchemas?.length + ? [schema, relation, includedForeignKeySchemas] + : [schema, relation] + ) + ); + } + + private async inspectRelationTriggerSignature( + client: IDataDbPreflightClient, + schema: string, + relation: string + ): Promise { + return normalizeRawRows( + await client.raw( + ` + SELECT + tg.tgname AS "triggerName", + tg.tgenabled AS "enabled", + pg_get_triggerdef(tg.oid, true) AS "definition" + FROM pg_trigger tg + JOIN pg_class c ON c.oid = tg.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = ? + AND c.relname = ? + AND NOT tg.tgisinternal + ORDER BY tg.tgname ASC + `, + [schema, relation] + ) + ); + } + + private async countRows( + client: IDataDbPreflightClient, + schema: string, + table: string, + whereSql?: string, + bindings: unknown[] = [] + ) { + const rows = normalizeRawRows<{ count: string | number | bigint }>( + await client.raw( + `SELECT COUNT(*) AS "count" FROM ${qualify(schema, table)}${ + whereSql ? ` WHERE ${whereSql}` : '' + }`, + bindings + ) + ); + return Number(rows[0]?.count ?? 0); + } + + private async inspectSourceComputedDrain( + client: IDataDbPreflightClient, + schema: string, + baseIds: string[], + processingLeaseMs: number + ): Promise { + const reclaimBefore = new Date(Date.now() - processingLeaseMs); + const rows = normalizeRawRows<{ + activeCount: string | number | bigint | null; + reclaimableCount: string | number | bigint | null; + oldestActiveLockedAt: Date | string | null; + }>( + await client.raw( + ` + SELECT + COUNT(*) FILTER ( + WHERE "status" = 'processing' + AND "locked_at" IS NOT NULL + AND "locked_at" > ?::timestamp + ) AS "activeCount", + COUNT(*) FILTER ( + WHERE "status" = 'processing' + AND ("locked_at" IS NULL OR "locked_at" <= ?::timestamp) + ) AS "reclaimableCount", + MIN("locked_at") FILTER ( + WHERE "status" = 'processing' + AND "locked_at" IS NOT NULL + AND "locked_at" > ?::timestamp + ) AS "oldestActiveLockedAt" + FROM ${qualify(schema, sharedTables.computedUpdateOutbox)} + WHERE "base_id" = ANY(?::text[]) + `, + [reclaimBefore, reclaimBefore, reclaimBefore, baseIds] + ) + ); + const row = rows[0]; + return this.buildComputedDrainStats({ + activeCount: Number(row?.activeCount ?? 0), + reclaimableCount: Number(row?.reclaimableCount ?? 0), + oldestActiveLockedAt: row?.oldestActiveLockedAt ?? null, + }); + } + + private async inspectSchemaOperationDrain( + inventory: ISpaceDataDbInventory + ): Promise { + const where = this.buildSchemaOperationWhere(inventory); + const [openCount, sample] = await Promise.all([ + this.prismaService.schemaOperation.count({ where }), + this.prismaService.schemaOperation.findMany({ + where, + select: { + id: true, + status: true, + phase: true, + baseId: true, + tableId: true, + lockedAt: true, + lockedBy: true, + lastModifiedTime: true, + }, + orderBy: [{ lastModifiedTime: 'desc' }, { createdTime: 'desc' }], + take: 10, + }), + ]); + + return this.buildSchemaOperationDrainStats(openCount, sample); + } + + private buildSchemaOperationWhere( + inventory: ISpaceDataDbInventory + ): Prisma.SchemaOperationWhereInput { + const resourceIds = [...new Set([...inventory.baseIds, ...inventory.tableIds])]; + const scopeFilters: Prisma.SchemaOperationWhereInput[] = []; + if (inventory.baseIds.length) { + scopeFilters.push({ baseId: { in: inventory.baseIds } }); + } + if (inventory.tableIds.length) { + scopeFilters.push({ tableId: { in: inventory.tableIds } }); + } + if (resourceIds.length) { + scopeFilters.push({ resourceId: { in: resourceIds } }); + } + + return { + status: { in: [...openSchemaOperationStatuses] }, + OR: scopeFilters, + }; + } + + private buildSchemaOperationDrainStats( + openCount: number, + sample: { + id: string; + status: string; + phase: string; + baseId: string | null; + tableId: string | null; + lockedAt: Date | string | null; + lockedBy: string | null; + lastModifiedTime: Date | string | null; + }[] + ): ISchemaOperationDrainStats { + return { + openCount, + sample: sample.map((operation) => ({ + id: operation.id, + status: operation.status, + phase: operation.phase, + baseId: operation.baseId, + tableId: operation.tableId, + lockedAt: this.toNullableIso(operation.lockedAt), + lockedBy: operation.lockedBy, + lastModifiedTime: this.toNullableIso(operation.lastModifiedTime), + })), + checkedAt: new Date().toISOString(), + }; + } + + private async updateSchemaOperationDrainStats( + job: IMigrationJobRecord, + inventory: ISpaceDataDbInventory, + phase: 'schema_operations_draining' | 'schema_operations_drained', + stats: ISchemaOperationDrainStats + ) { + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: job.id }, + data: { + copyStats: { + phase, + progress: this.buildMigrationProgress(job, inventory, phase), + schemaOperations: stats, + }, + lastError: null, + }, + }); + } + + private async inspectBackgroundWriterDrain( + spaceId: string, + inventory: ISpaceDataDbInventory, + options: { + probeTimeoutMs: number; + queueScanBatchSize: number; + queueScanLimit: number; + } + ): Promise { + const [provisionResources, queueJobs] = await Promise.all([ + withTimeout( + this.inspectProvisionResourceDrain(spaceId, inventory), + options.probeTimeoutMs, + `Timed out inspecting provisioning resources after ${options.probeTimeoutMs}ms` + ), + withTimeout( + this.inspectImportQueueDrain(inventory, options), + options.probeTimeoutMs, + `Timed out inspecting import queues after ${options.probeTimeoutMs}ms` + ), + ]); + return this.buildBackgroundWriterDrainStats(provisionResources, queueJobs); + } + + private async inspectProvisionResourceDrain( + spaceId: string, + inventory: ISpaceDataDbInventory + ): Promise<{ openCount: number; sample: IBackgroundWriterDrainStats['sample'] }> { + const baseWhere = this.buildProvisionBaseWhere(spaceId, inventory); + const tableWhere = this.buildProvisionTableWhere(inventory); + const fieldWhere = this.buildProvisionFieldWhere(inventory); + const [baseCount, tableCount, fieldCount, baseSample, tableSample, fieldSample] = + await Promise.all([ + this.prismaService.base.count({ where: baseWhere }), + tableWhere ? this.prismaService.tableMeta.count({ where: tableWhere }) : Promise.resolve(0), + fieldWhere ? this.prismaService.field.count({ where: fieldWhere }) : Promise.resolve(0), + this.prismaService.base.findMany({ + where: baseWhere, + select: { + id: true, + provisionState: true, + lastModifiedTime: true, + }, + orderBy: [{ lastModifiedTime: 'desc' }, { createdTime: 'desc' }], + take: 10, + }), + tableWhere + ? this.prismaService.tableMeta.findMany({ + where: tableWhere, + select: { + id: true, + baseId: true, + provisionState: true, + lastModifiedTime: true, + }, + orderBy: [{ lastModifiedTime: 'desc' }, { createdTime: 'desc' }], + take: 10, + }) + : Promise.resolve([]), + fieldWhere + ? this.prismaService.field.findMany({ + where: fieldWhere, + select: { + id: true, + tableId: true, + provisionState: true, + lastModifiedTime: true, + }, + orderBy: [{ lastModifiedTime: 'desc' }, { createdTime: 'desc' }], + take: 10, + }) + : Promise.resolve([]), + ]); + + return { + openCount: baseCount + tableCount + fieldCount, + sample: [ + ...baseSample.map((resource) => ({ + kind: 'provision_resource' as const, + resourceType: 'base' as const, + id: resource.id, + state: resource.provisionState, + baseId: resource.id, + tableId: null, + lastModifiedTime: this.toNullableIso(resource.lastModifiedTime), + })), + ...tableSample.map((resource) => ({ + kind: 'provision_resource' as const, + resourceType: 'table' as const, + id: resource.id, + state: resource.provisionState, + baseId: resource.baseId, + tableId: resource.id, + lastModifiedTime: this.toNullableIso(resource.lastModifiedTime), + })), + ...fieldSample.map((resource) => ({ + kind: 'provision_resource' as const, + resourceType: 'field' as const, + id: resource.id, + state: resource.provisionState, + baseId: null, + tableId: resource.tableId, + lastModifiedTime: this.toNullableIso(resource.lastModifiedTime), + })), + ], + }; + } + + private buildProvisionBaseWhere( + spaceId: string, + inventory: ISpaceDataDbInventory + ): Prisma.BaseWhereInput { + return { + deletedTime: null, + provisionState: { in: [...openProvisionStates] }, + OR: [{ spaceId }, ...(inventory.baseIds.length ? [{ id: { in: inventory.baseIds } }] : [])], + }; + } + + private buildProvisionTableWhere( + inventory: ISpaceDataDbInventory + ): Prisma.TableMetaWhereInput | null { + const filters: Prisma.TableMetaWhereInput[] = []; + if (inventory.baseIds.length) { + filters.push({ baseId: { in: inventory.baseIds } }); + } + if (inventory.tableIds.length) { + filters.push({ id: { in: inventory.tableIds } }); + } + if (!filters.length) { + return null; + } + return { + deletedTime: null, + provisionState: { in: [...openProvisionStates] }, + OR: filters, + }; + } + + private buildProvisionFieldWhere( + inventory: ISpaceDataDbInventory + ): Prisma.FieldWhereInput | null { + if (!inventory.tableIds.length) { + return null; + } + return { + tableId: { in: inventory.tableIds }, + deletedTime: null, + provisionState: { in: [...openProvisionStates] }, + }; + } + + private async inspectImportQueueDrain( + inventory: ISpaceDataDbInventory, + options: { queueScanBatchSize: number; queueScanLimit: number } + ): Promise<{ openCount: number; sample: IBackgroundWriterDrainStats['sample'] }> { + const queues = [ + { name: BASE_IMPORT_CSV_QUEUE, queue: this.baseImportCsvQueue }, + { name: BASE_IMPORT_JUNCTION_CSV_QUEUE, queue: this.baseImportJunctionCsvQueue }, + { name: TABLE_IMPORT_CSV_CHUNK_QUEUE, queue: this.tableImportCsvChunkQueue }, + { name: TABLE_IMPORT_CSV_QUEUE, queue: this.tableImportCsvQueue }, + ]; + const samples: IBackgroundWriterDrainStats['sample'] = []; + let openCount = 0; + + for (const { name, queue } of queues) { + if (!queue) { + continue; + } + const jobs = await this.getOpenQueueJobs(queue, options); + for (const job of jobs) { + const sample = await this.buildQueueJobDrainSample(name, job, inventory); + if (sample) { + openCount++; + samples.push(sample); + } + } + } + + return { openCount, sample: samples }; + } + + private async getOpenQueueJobs( + queue: Queue, + options: { queueScanBatchSize: number; queueScanLimit: number } + ) { + const openJobs = []; + const batchSize = Math.max(1, options.queueScanBatchSize); + const scanLimit = Math.max(1, options.queueScanLimit); + let start = 0; + + while (start < scanLimit) { + const end = Math.min(start + batchSize - 1, scanLimit - 1); + const jobs = await queue.getJobs([...openQueueJobStates] as never, start, end, false); + if (!jobs.length) { + break; + } + + for (const job of jobs) { + const state = await this.getQueueJobState(job); + if (this.isOpenQueueJobState(state)) { + openJobs.push(job); + } + } + + if (jobs.length < batchSize) { + break; + } + start += batchSize; + } + + return openJobs; + } + + private async buildQueueJobDrainSample( + queueName: string, + job: { + id?: string | number; + name?: string; + data?: unknown; + timestamp?: number; + getState?: () => Promise; + }, + inventory: ISpaceDataDbInventory + ): Promise { + const data = this.asRecord(job.data); + if (!data) { + return null; + } + const scope = this.getQueueJobScope(data); + if (!this.queueJobTouchesInventory(scope, inventory)) { + return null; + } + const state = await this.getQueueJobState(job); + return { + kind: 'queue_job', + queueName, + id: String(job.id ?? job.name ?? 'unknown'), + state, + baseId: scope.baseId, + tableId: scope.tableId, + timestamp: + typeof job.timestamp === 'number' ? this.toNullableIso(new Date(job.timestamp)) : null, + }; + } + + private getQueueJobScope(data: Record) { + const table = this.asRecord(data.table); + const tableIdMap = this.asRecord(data.tableIdMap); + const tableIdMapValues = tableIdMap + ? Object.values(tableIdMap).filter((value): value is string => typeof value === 'string') + : []; + return { + baseId: typeof data.baseId === 'string' ? data.baseId : null, + tableId: + (table && typeof table.id === 'string' ? table.id : null) ?? + (typeof data.tableId === 'string' ? data.tableId : null) ?? + (tableIdMapValues.length === 1 ? tableIdMapValues[0] : null), + tableIds: tableIdMapValues, + }; + } + + private queueJobTouchesInventory( + scope: { baseId: string | null; tableId: string | null; tableIds: string[] }, + inventory: ISpaceDataDbInventory + ) { + const baseIds = new Set(inventory.baseIds); + const tableIds = new Set(inventory.tableIds); + return ( + (scope.baseId != null && baseIds.has(scope.baseId)) || + (scope.tableId != null && tableIds.has(scope.tableId)) || + scope.tableIds.some((tableId) => tableIds.has(tableId)) + ); + } + + private async getQueueJobState(job: { state?: string; getState?: () => Promise }) { + if (typeof job.getState === 'function') { + return await job.getState(); + } + return job.state ?? 'unknown'; + } + + private isOpenQueueJobState(state: string) { + return (openQueueJobStates as readonly string[]).includes(state); + } + + private asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as Record; + } + + private toNullableNumber(value: unknown): number | null { + if (value == null) { + return null; + } + const number = Number(value); + return Number.isFinite(number) ? number : null; + } + + private buildBackgroundWriterDrainStats( + provisionResources: { openCount: number; sample: IBackgroundWriterDrainStats['sample'] }, + queueJobs: { openCount: number; sample: IBackgroundWriterDrainStats['sample'] } + ): IBackgroundWriterDrainStats { + return { + openCount: provisionResources.openCount + queueJobs.openCount, + provisionResourceCount: provisionResources.openCount, + queueJobCount: queueJobs.openCount, + sample: [...provisionResources.sample, ...queueJobs.sample].slice(0, 10), + checkedAt: new Date().toISOString(), + }; + } + + private async updateBackgroundWriterDrainStats( + job: IMigrationJobRecord, + inventory: ISpaceDataDbInventory, + phase: 'background_writers_draining' | 'background_writers_drained', + stats: IBackgroundWriterDrainStats + ) { + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: job.id }, + data: { + copyStats: { + phase, + progress: this.buildMigrationProgress(job, inventory, phase), + backgroundWriters: stats, + }, + lastError: null, + }, + }); + } + + private buildComputedDrainStats(input: { + activeCount: number; + reclaimableCount: number; + oldestActiveLockedAt: Date | string | null; + }): IComputedDrainStats { + return { + activeCount: input.activeCount, + reclaimableCount: input.reclaimableCount, + oldestActiveLockedAt: this.toNullableIso(input.oldestActiveLockedAt), + checkedAt: new Date().toISOString(), + }; + } + + private async updateComputedDrainStats( + job: IMigrationJobRecord, + inventory: ISpaceDataDbInventory, + phase: 'computed_draining' | 'computed_drained', + stats: IComputedDrainStats + ) { + await this.migrationJobClient.spaceDataDbMigrationJob.update({ + where: { id: job.id }, + data: { + copyStats: { + phase, + progress: this.buildMigrationProgress(job, inventory, phase), + computedDrain: stats, + }, + lastError: null, + }, + }); + } + + private toNullableIso(value: Date | string | null): string | null { + if (!value) { + return null; + } + if (value instanceof Date) { + return value.toISOString(); + } + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? value : parsed.toISOString(); + } + + private async validateUndoFunction(client: IDataDbPreflightClient, targetSchema: string) { + const rows = normalizeRawRows<{ exists: boolean }>( + await client.raw(`SELECT to_regprocedure(?::text) IS NOT NULL AS "exists"`, [ + `${targetSchema}.__teable_capture_undo_row()`, + ]) + ); + return { exists: Boolean(rows[0]?.exists) }; + } + + private async validateTargetSchemaVersion(client: IDataDbPreflightClient, targetSchema: string) { + const latest = this.baselineService.getLatestSchemaVersion(); + if (!latest) { + return { latest, exists: true }; + } + + const rows = normalizeRawRows<{ exists: boolean }>( + await client.raw( + ` + SELECT EXISTS ( + SELECT 1 + FROM ${qualify(targetSchema, dataDbMigrationTable)} + WHERE "id" = ? + ) AS "exists" + `, + [latest] + ) + ); + return { latest, exists: Boolean(rows[0]?.exists) }; + } + + private async validateRouteSmoke(input: { + spaceId: string; + targetConnectionId: string | null; + targetEncryptedUrl: string; + targetSchema: string; + }): Promise { + if (!input.targetConnectionId) { + return { + ok: false, + connectionId: null, + internalSchema: null, + cacheKey: null, + isMetaFallback: true, + error: 'Migration job has no target connection id', + }; + } + + try { + const resolved = await this.dataDbClientManager.getDataDatabaseForSpace(input.spaceId, { + previewBinding: { + spaceId: input.spaceId, + connectionId: input.targetConnectionId, + encryptedUrl: input.targetEncryptedUrl, + internalSchema: input.targetSchema, + }, + }); + const connectionId = resolved.connectionId ?? null; + const internalSchema = resolved.internalSchema ?? null; + const ok = + !resolved.isMetaFallback && + connectionId === input.targetConnectionId && + internalSchema === input.targetSchema && + resolved.cacheKey === input.targetConnectionId; + + return { + ok, + connectionId, + internalSchema, + cacheKey: resolved.cacheKey, + isMetaFallback: resolved.isMetaFallback, + }; + } catch (error) { + return { + ok: false, + connectionId: null, + internalSchema: null, + cacheKey: null, + isMetaFallback: true, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + private async deleteMigrationComputedPause( + client: IDataDbPreflightClient, + schema: string, + spaceIds: string[], + jobId: string + ) { + const rows = normalizeRawRows<{ id: string }>( + await client.raw( + ` + DELETE FROM ${qualify(schema, sharedTables.computedUpdatePauseScope)} + WHERE "scope_type" = ? + AND "scope_id" = ANY(?::text[]) + AND "reason" = ? + RETURNING "id" + `, + ['space', spaceIds, migrationPauseReason(jobId)] + ) + ); + return { deleted: rows.length }; + } + + private get migrationJobClient(): IMigrationJobClient { + return this.prismaService as unknown as IMigrationJobClient; + } +} 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 new file mode 100644 index 0000000000..fecf1bb292 --- /dev/null +++ b/apps/nestjs-backend/src/features/space/space-data-db-process-runner.service.spec.ts @@ -0,0 +1,790 @@ +import { EventEmitter } from 'events'; +import { PassThrough } from 'stream'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + SpaceDataDbProcessCanceledError, + SpaceDataDbProcessError, + SpaceDataDbProcessPipelineCanceledError, + SpaceDataDbProcessPipelineError, + SpaceDataDbProcessRunnerService, + type ISpaceDataDbProcessSpawn, +} from './space-data-db-process-runner.service'; + +const secretUrl = 'postgresql://user:secret@example/db'; +const redactedUrl = 'postgresql://user:***@example/db'; +const invalidPercentTokenSecret = 'pa%ss$word'; +const copySourceSql = 'COPY (SELECT 1) TO STDOUT'; +const copyTargetSql = 'COPY "meta"."record_history" FROM STDIN'; +const sharedTableLabel = 'shared-table:record_history'; +const pgDumpCommand = 'pg_dump'; +const pgRestoreCommand = 'pg_restore'; +const pgCustomFormatArg = '--format=custom'; + +class FakeProcess extends EventEmitter { + readonly stdin = new PassThrough(); + readonly stderr = new PassThrough(); + readonly stdout = new PassThrough(); + readonly pid = 1234; + readonly kill = vi.fn().mockReturnValue(true); + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; +} + +const expectProcessTiming = (result: { + startedAt: string; + completedAt: string; + durationMs: number; +}) => { + expect(Date.parse(result.startedAt)).not.toBeNaN(); + expect(Date.parse(result.completedAt)).not.toBeNaN(); + expect(result.durationMs).toBeGreaterThanOrEqual(0); +}; + +describe('SpaceDataDbProcessRunnerService', () => { + let processRef: FakeProcess; + let spawnProcess: ReturnType>; + + beforeEach(() => { + processRef = new FakeProcess(); + spawnProcess = vi.fn(() => processRef); + }); + + it('runs commands with spawn arrays and shell disabled', async () => { + const service = new SpaceDataDbProcessRunnerService(spawnProcess); + const promise = service.run({ + command: pgDumpCommand, + args: ['--schema', 'bsexxx', secretUrl], + }); + + processRef.stderr.write('dump complete'); + processRef.emit('close', 0, null); + + const result = await promise; + expect(result).toMatchObject({ + command: pgDumpCommand, + args: ['--schema', 'bsexxx', redactedUrl], + exitCode: 0, + stderr: 'dump complete', + }); + expectProcessTiming(result); + expect(spawnProcess).toHaveBeenCalledWith( + pgDumpCommand, + ['--schema', 'bsexxx', secretUrl], + expect.objectContaining({ + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }) + ); + }); + + it('redacts postgres_fdw user mapping passwords from process args and output', async () => { + const service = new SpaceDataDbProcessRunnerService(spawnProcess); + const sql = `CREATE USER MAPPING FOR CURRENT_USER SERVER "srv" OPTIONS (user 'teable', password 'source_secret')`; + const promise = service.run({ + command: 'psql', + args: ['--command', sql], + }); + + processRef.stdout.write(`ran ${sql}`); + processRef.emit('close', 0, null); + + const result = await promise; + expect(result.args[1]).toContain(`password '***'`); + expect(result.stdout).toContain(`password '***'`); + }); + + it('keeps stdout and stderr limits independent', async () => { + const service = new SpaceDataDbProcessRunnerService(spawnProcess); + const promise = service.run( + { + command: pgRestoreCommand, + args: ['--list'], + }, + { stdoutLimit: 8, stderrLimit: 4 } + ); + + processRef.stdout.write('stdout-long'); + processRef.stderr.write('stderr-long'); + processRef.emit('close', 0, null); + + const result = await promise; + expect(result.stdout).toBe('out-long'); + expect(result.stderr).toBe('long'); + }); + + it('rejects non-zero exits with redacted args and stderr', async () => { + const service = new SpaceDataDbProcessRunnerService(spawnProcess); + const promise = service.run({ + command: pgRestoreCommand, + args: ['--dbname', secretUrl], + }); + + processRef.stderr.write(`failed ${secretUrl}`); + processRef.emit('close', 1, null); + + try { + await promise; + throw new Error('Expected process to fail'); + } catch (error) { + expect(error).toBeInstanceOf(SpaceDataDbProcessError); + const result = (error as SpaceDataDbProcessError).result; + expect(result).toMatchObject({ + command: pgRestoreCommand, + args: ['--dbname', redactedUrl], + exitCode: 1, + stderr: `failed ${redactedUrl}`, + }); + expectProcessTiming(result); + } + }); + + it('redacts libpq invalid percent token output from process failures', async () => { + const service = new SpaceDataDbProcessRunnerService(spawnProcess); + const promise = service.run({ + command: pgRestoreCommand, + args: ['--dbname', secretUrl], + }); + + processRef.stderr.write( + `pg_restore: error: invalid percent-encoded token: "${invalidPercentTokenSecret}"` + ); + processRef.emit('close', 1, null); + + try { + await promise; + throw new Error('Expected process to fail'); + } catch (error) { + const result = (error as SpaceDataDbProcessError).result; + expect(result.stderr).toContain('invalid percent-encoded token: "***"'); + expect(result.stderr).not.toContain(invalidPercentTokenSecret); + } + }); + + it('kills timed-out processes and rejects with a process error', async () => { + vi.useFakeTimers(); + const service = new SpaceDataDbProcessRunnerService(spawnProcess); + const promise = service.run( + { + command: pgDumpCommand, + args: ['--schema', 'bsexxx'], + }, + { timeoutMs: 100 } + ); + const assertion = expect(promise).rejects.toBeInstanceOf(SpaceDataDbProcessError); + + await vi.advanceTimersByTimeAsync(100); + + await assertion; + expect(processRef.kill).toHaveBeenCalledWith('SIGTERM'); + vi.useRealTimers(); + }); + + it('kills running commands when cancellation is requested', async () => { + const service = new SpaceDataDbProcessRunnerService(spawnProcess); + const promise = service.run( + { + command: pgDumpCommand, + args: ['--schema', 'bsexxx'], + }, + { shouldCancel: () => true } + ); + + await expect(promise).rejects.toBeInstanceOf(SpaceDataDbProcessCanceledError); + expect(processRef.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('polls progress hooks while a command is running', async () => { + vi.useFakeTimers(); + const service = new SpaceDataDbProcessRunnerService(spawnProcess); + const onPoll = vi.fn().mockResolvedValue(undefined); + const promise = service.run( + { + command: pgDumpCommand, + args: ['--schema', 'bsexxx'], + }, + { pollMs: 50, onPoll } + ); + + await vi.advanceTimersByTimeAsync(50); + processRef.emit('close', 0, null); + const result = await promise; + + expect(result.exitCode).toBe(0); + expect(onPoll).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('rejects a command when a progress poll hangs', async () => { + vi.useFakeTimers(); + const service = new SpaceDataDbProcessRunnerService(spawnProcess); + const promise = service.run( + { + command: pgDumpCommand, + args: ['--schema', 'bsexxx'], + }, + { + pollMs: 50, + pollTimeoutMs: 100, + onPoll: () => new Promise(() => undefined), + } + ); + const assertion = expect(promise).rejects.toMatchObject({ + message: expect.stringContaining('Process progress poll timed out after 100ms'), + }); + + await vi.advanceTimersByTimeAsync(100); + + await assertion; + expect(processRef.kill).toHaveBeenCalledWith('SIGTERM'); + vi.useRealTimers(); + }); + + it('rejects a command when progress polls keep failing', async () => { + vi.useFakeTimers(); + const service = new SpaceDataDbProcessRunnerService(spawnProcess); + const promise = service.run( + { + command: pgDumpCommand, + args: ['--schema', 'bsexxx'], + }, + { + pollMs: 50, + pollFailureTimeoutMs: 100, + onPoll: () => { + throw new Error('the database system is in recovery mode'); + }, + } + ); + const assertion = expect(promise).rejects.toMatchObject({ + message: expect.stringContaining('Process progress poll failed for 100ms'), + }); + + await vi.advanceTimersByTimeAsync(150); + + await assertion; + expect(processRef.kill).toHaveBeenCalledWith('SIGTERM'); + vi.useRealTimers(); + }); + + it('resolves a successful command when child exit arrives without close', async () => { + vi.useFakeTimers(); + const service = new SpaceDataDbProcessRunnerService(spawnProcess); + const promise = service.run( + { + command: pgDumpCommand, + args: ['--format=directory', secretUrl], + }, + { exitFallbackMs: 50 } + ); + + processRef.emit('exit', 0, null); + await vi.advanceTimersByTimeAsync(50); + + const result = await promise; + expect(result).toMatchObject({ + command: pgDumpCommand, + args: ['--format=directory', redactedUrl], + exitCode: 0, + }); + expectProcessTiming(result); + vi.useRealTimers(); + }); + + it('resolves a successful command when stdio closes before child close arrives', async () => { + vi.useFakeTimers(); + const service = new SpaceDataDbProcessRunnerService(spawnProcess); + const promise = service.run( + { + command: pgRestoreCommand, + args: ['--dbname', secretUrl], + }, + { exitFallbackMs: 50 } + ); + + processRef.stdout.end(); + processRef.stderr.end(); + await vi.advanceTimersByTimeAsync(50); + + const result = await promise; + expect(result).toMatchObject({ + command: pgRestoreCommand, + args: ['--dbname', redactedUrl], + exitCode: 0, + }); + expectProcessTiming(result); + vi.useRealTimers(); + }); + + it('pipes source stdout into target stdin with shell disabled', async () => { + const sourceProcess = new FakeProcess(); + const targetProcess = new FakeProcess(); + const targetChunks: Buffer[] = []; + targetProcess.stdin.on('data', (chunk: Buffer) => targetChunks.push(chunk)); + spawnProcess = vi.fn().mockReturnValueOnce(sourceProcess).mockReturnValueOnce(targetProcess); + const service = new SpaceDataDbProcessRunnerService(spawnProcess); + + const promise = service.runPipeline({ + source: { + command: 'psql', + args: ['--command', copySourceSql, secretUrl], + }, + target: { + command: 'psql', + args: ['--command', copyTargetSql, secretUrl], + }, + }); + + sourceProcess.stdout.write('row-1\n'); + sourceProcess.stdout.end('row-2\n'); + sourceProcess.emit('close', 0, null); + targetProcess.stdout.write('COPY 2\n'); + targetProcess.emit('close', 0, null); + + const result = await promise; + expect(result).toMatchObject({ + source: { command: 'psql', args: ['--command', copySourceSql, redactedUrl] }, + target: { + command: 'psql', + args: ['--command', copyTargetSql, redactedUrl], + stdout: 'COPY 2\n', + }, + }); + expectProcessTiming(result.source); + expectProcessTiming(result.target); + expect(Buffer.concat(targetChunks).toString('utf8')).toBe('row-1\nrow-2\n'); + expect(spawnProcess).toHaveBeenNthCalledWith( + 1, + 'psql', + ['--command', copySourceSql, secretUrl], + expect.objectContaining({ shell: false, stdio: ['ignore', 'pipe', 'pipe'] }) + ); + expect(spawnProcess).toHaveBeenNthCalledWith( + 2, + 'psql', + ['--command', copyTargetSql, secretUrl], + expect.objectContaining({ shell: false, stdio: ['pipe', 'pipe', 'pipe'] }) + ); + }); + + it('resolves a successful pipeline when child exit events arrive without close events', async () => { + vi.useFakeTimers(); + 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: pgDumpCommand, + args: [pgCustomFormatArg, secretUrl], + }, + target: { + command: pgRestoreCommand, + args: ['--dbname', secretUrl], + }, + }, + { exitFallbackMs: 50 } + ); + + sourceProcess.emit('exit', 0, null); + targetProcess.emit('exit', 0, null); + await vi.advanceTimersByTimeAsync(50); + + const result = await promise; + expect(result).toMatchObject({ + source: { command: pgDumpCommand, exitCode: 0 }, + target: { command: pgRestoreCommand, exitCode: 0 }, + }); + expectProcessTiming(result.source); + expectProcessTiming(result.target); + vi.useRealTimers(); + }); + + it('resolves a successful pipeline when stdio closes before child close events arrive', async () => { + vi.useFakeTimers(); + 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: pgDumpCommand, + args: [pgCustomFormatArg, secretUrl], + }, + target: { + command: pgRestoreCommand, + args: ['--dbname', secretUrl], + }, + }, + { exitFallbackMs: 50 } + ); + + sourceProcess.stdout.end(); + sourceProcess.stderr.end(); + targetProcess.stdin.end(); + targetProcess.stdout.end(); + targetProcess.stderr.end(); + await vi.advanceTimersByTimeAsync(50); + + const result = await promise; + expect(result).toMatchObject({ + source: { command: pgDumpCommand, exitCode: 0 }, + target: { command: pgRestoreCommand, exitCode: 0 }, + }); + expectProcessTiming(result.source); + expectProcessTiming(result.target); + vi.useRealTimers(); + }); + + it('ends target stdin when source exits before stdout closes', async () => { + vi.useFakeTimers(); + 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: pgDumpCommand, + args: [pgCustomFormatArg, secretUrl], + }, + target: { + command: pgRestoreCommand, + args: ['--dbname', secretUrl], + }, + }, + { exitFallbackMs: 50 } + ); + targetProcess.stdin.on('finish', () => { + targetProcess.emit('exit', 0, null); + targetProcess.emit('close', 0, null); + }); + + sourceProcess.emit('exit', 0, null); + await vi.advanceTimersByTimeAsync(50); + + const result = await promise; + expect(result).toMatchObject({ + source: { command: pgDumpCommand, exitCode: 0 }, + target: { command: pgRestoreCommand, exitCode: 0 }, + }); + expectProcessTiming(result.source); + expectProcessTiming(result.target); + vi.useRealTimers(); + }); + + it('resolves a successful pipeline when source stdio closes and target exits before source close arrives', async () => { + vi.useFakeTimers(); + 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: pgDumpCommand, + args: [pgCustomFormatArg, secretUrl], + }, + target: { + command: pgRestoreCommand, + args: ['--dbname', secretUrl], + }, + }, + { exitFallbackMs: 50 } + ); + + sourceProcess.stdout.end(); + sourceProcess.stderr.end(); + targetProcess.emit('exit', 0, null); + targetProcess.emit('close', 0, null); + await vi.advanceTimersByTimeAsync(50); + + const result = await promise; + expect(result).toMatchObject({ + source: { command: pgDumpCommand, exitCode: 0 }, + target: { command: pgRestoreCommand, exitCode: 0 }, + }); + expectProcessTiming(result.source); + expectProcessTiming(result.target); + vi.useRealTimers(); + }); + + it('resolves a successful pipeline when source exits and target stdio closes before target exit arrives', async () => { + vi.useFakeTimers(); + 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: pgDumpCommand, + args: [pgCustomFormatArg, secretUrl], + }, + target: { + command: pgRestoreCommand, + args: ['--dbname', secretUrl], + }, + }, + { exitFallbackMs: 50 } + ); + + sourceProcess.emit('exit', 0, null); + targetProcess.stdin.end(); + targetProcess.stdout.end(); + targetProcess.stderr.end(); + await vi.advanceTimersByTimeAsync(50); + + const result = await promise; + expect(result).toMatchObject({ + source: { command: pgDumpCommand, exitCode: 0 }, + target: { command: pgRestoreCommand, exitCode: 0 }, + }); + expectProcessTiming(result.source); + expectProcessTiming(result.target); + vi.useRealTimers(); + }); + + it('resolves a successful pipeline when child exit status changes without exit or close events', async () => { + vi.useFakeTimers(); + 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: pgDumpCommand, + args: [pgCustomFormatArg, secretUrl], + }, + target: { + command: pgRestoreCommand, + args: ['--dbname', secretUrl], + }, + }, + { exitFallbackMs: 50 } + ); + + sourceProcess.stdout.end(); + sourceProcess.stderr.end(); + targetProcess.stdin.end(); + targetProcess.stdout.end(); + targetProcess.stderr.end(); + sourceProcess.exitCode = 0; + targetProcess.exitCode = 0; + await vi.advanceTimersByTimeAsync(100); + + const result = await promise; + expect(result).toMatchObject({ + source: { command: pgDumpCommand, exitCode: 0 }, + target: { command: pgRestoreCommand, exitCode: 0 }, + }); + expectProcessTiming(result.source); + expectProcessTiming(result.target); + vi.useRealTimers(); + }); + + it('rejects a pipeline when a child process disappears without exit or close events', async () => { + vi.useFakeTimers(); + const sourceProcess = new FakeProcess(); + const targetProcess = new FakeProcess(); + sourceProcess.kill.mockImplementation((signal?: NodeJS.Signals | 0) => + signal === 0 ? false : true + ); + spawnProcess = vi.fn().mockReturnValueOnce(sourceProcess).mockReturnValueOnce(targetProcess); + const service = new SpaceDataDbProcessRunnerService(spawnProcess); + + const promise = service.runPipeline( + { + source: { + command: pgDumpCommand, + args: [pgCustomFormatArg, secretUrl], + }, + target: { + command: pgRestoreCommand, + args: ['--dbname', secretUrl], + }, + }, + { exitFallbackMs: 50 } + ); + const assertion = expect(promise).rejects.toMatchObject({ + message: expect.stringContaining('exited without status'), + result: expect.objectContaining({ + source: expect.objectContaining({ + exitCode: null, + signal: null, + }), + }), + }); + + await vi.advanceTimersByTimeAsync(50); + + await assertion; + expect(targetProcess.kill).toHaveBeenCalledWith('SIGTERM'); + vi.useRealTimers(); + }); + + it('rejects a pipeline when a progress poll hangs', async () => { + vi.useFakeTimers(); + 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: pgDumpCommand, + args: [pgCustomFormatArg, secretUrl], + }, + target: { + command: pgRestoreCommand, + args: ['--dbname', secretUrl], + }, + }, + { + pollMs: 50, + pollTimeoutMs: 100, + onPoll: () => new Promise(() => undefined), + } + ); + const assertion = expect(promise).rejects.toMatchObject({ + message: expect.stringContaining('Process progress poll timed out after 100ms'), + result: expect.objectContaining({ + source: expect.objectContaining({ signal: 'SIGTERM' }), + target: expect.objectContaining({ signal: 'SIGTERM' }), + }), + }); + + await vi.advanceTimersByTimeAsync(100); + + await assertion; + expect(sourceProcess.kill).toHaveBeenCalledWith('SIGTERM'); + expect(targetProcess.kill).toHaveBeenCalledWith('SIGTERM'); + vi.useRealTimers(); + }); + + it('rejects a pipeline when progress polls keep failing', async () => { + vi.useFakeTimers(); + 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: pgDumpCommand, + args: [pgCustomFormatArg, secretUrl], + }, + target: { + command: pgRestoreCommand, + args: ['--dbname', secretUrl], + }, + }, + { + pollMs: 50, + pollFailureTimeoutMs: 100, + onPoll: () => { + throw new Error('the database system is in recovery mode'); + }, + } + ); + const assertion = expect(promise).rejects.toMatchObject({ + message: expect.stringContaining('Process progress poll failed for 100ms'), + result: expect.objectContaining({ + source: expect.objectContaining({ signal: 'SIGTERM' }), + target: expect.objectContaining({ signal: 'SIGTERM' }), + }), + }); + + await vi.advanceTimersByTimeAsync(150); + + await assertion; + expect(sourceProcess.kill).toHaveBeenCalledWith('SIGTERM'); + expect(targetProcess.kill).toHaveBeenCalledWith('SIGTERM'); + vi.useRealTimers(); + }); + + it('kills the target process when the source COPY process exits non-zero', 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(`source failed ${secretUrl}`); + sourceProcess.emit('close', 1, null); + + await expect(promise).rejects.toMatchObject({ + result: expect.objectContaining({ + label: sharedTableLabel, + source: expect.objectContaining({ + exitCode: 1, + stderr: `source failed ${redactedUrl}`, + }), + }), + }); + await expect(promise).rejects.toBeInstanceOf(SpaceDataDbProcessPipelineError); + expect(targetProcess.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('redacts libpq invalid percent token output from pipeline failures', 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: pgDumpCommand, args: [pgCustomFormatArg, secretUrl] }, + target: { command: pgRestoreCommand, args: ['--dbname', secretUrl] }, + label: 'base-schemas', + }); + + targetProcess.stderr.write( + `pg_restore: error: invalid percent-encoded token: "${invalidPercentTokenSecret}"` + ); + targetProcess.emit('close', 1, null); + + await expect(promise).rejects.toMatchObject({ + result: expect.objectContaining({ + target: expect.objectContaining({ + stderr: expect.stringContaining('invalid percent-encoded token: "***"'), + }), + }), + }); + await expect(promise).rejects.toBeInstanceOf(SpaceDataDbProcessPipelineError); + }); + + it('kills both COPY processes when cancellation is requested', 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 source TO STDOUT', secretUrl] }, + target: { command: 'psql', args: ['--command', 'COPY target FROM STDIN', secretUrl] }, + label: sharedTableLabel, + }, + { shouldCancel: () => true } + ); + + await expect(promise).rejects.toBeInstanceOf(SpaceDataDbProcessPipelineCanceledError); + expect(sourceProcess.kill).toHaveBeenCalledWith('SIGTERM'); + expect(targetProcess.kill).toHaveBeenCalledWith('SIGTERM'); + }); +}); 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 new file mode 100644 index 0000000000..6498ffe715 --- /dev/null +++ b/apps/nestjs-backend/src/features/space/space-data-db-process-runner.service.ts @@ -0,0 +1,1143 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { SpawnOptions } from 'child_process'; +import { spawn as nodeSpawn } from 'child_process'; +import { Inject, Injectable, Optional } from '@nestjs/common'; +import type { + ISpaceDataDbProcessPipelinePlan, + ISpaceDataDbProcessPlan, +} from './space-data-db-copy-plan'; + +export const SPACE_DATA_DB_PROCESS_SPAWN = Symbol('SPACE_DATA_DB_PROCESS_SPAWN'); + +type IProcessLike = { + pid?: number; + stdin?: NodeJS.WritableStream | null; + stderr?: NodeJS.ReadableStream | null; + stdout?: NodeJS.ReadableStream | null; + exitCode?: number | null; + signalCode?: NodeJS.Signals | null; + kill(signal?: NodeJS.Signals | 0): boolean; + on(event: 'close', listener: (code: number | null, signal: NodeJS.Signals | null) => void): void; + on(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): void; + on(event: 'error', listener: (error: Error) => void): void; +}; + +export type ISpaceDataDbProcessSpawn = ( + command: string, + args: string[], + options: SpawnOptions +) => IProcessLike; + +export type ISpaceDataDbProcessRunOptions = { + cwd?: string; + env?: NodeJS.ProcessEnv; + timeoutMs?: number; + stderrLimit?: number; + stdoutLimit?: number; + cancelPollMs?: number; + exitFallbackMs?: number; + pollTimeoutMs?: number; + pollFailureTimeoutMs?: number; + shouldCancel?: () => boolean | Promise; + pollMs?: number; + onPoll?: () => void | Promise; +}; + +export type ISpaceDataDbProcessRunResult = { + command: string; + args: string[]; + exitCode: number; + signal: NodeJS.Signals | null; + stderr: string; + stdout: string; + startedAt: string; + completedAt: string; + durationMs: number; +}; + +type ISpaceDataDbProcessPartialResult = Omit & { + exitCode: number | null; +}; + +export type ISpaceDataDbProcessPipelineResult = { + source: ISpaceDataDbProcessRunResult; + target: ISpaceDataDbProcessRunResult; +}; + +const defaultCancelPollMs = 1_000; +const defaultExitFallbackMs = 5_000; +const defaultStderrLimit = 16 * 1024; + +const runProgressPoll = async ( + options: ISpaceDataDbProcessRunOptions, + isSettled: () => boolean +) => { + if (!options.onPoll || isSettled()) { + return; + } + await options.onPoll(); +}; + +const createProgressPollRunner = ( + options: ISpaceDataDbProcessRunOptions, + isSettled: () => boolean, + onTimeout: (error: Error) => void +) => { + let inFlight = false; + let timeout: NodeJS.Timeout | undefined; + let firstFailureAtMs: number | null = null; + + const clear = () => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + }; + + const run = () => { + if (!options.onPoll || isSettled() || inFlight) { + return; + } + + inFlight = true; + let timedOut = false; + const pollTimeoutMs = + options.pollTimeoutMs && options.pollTimeoutMs > 0 + ? Math.max(1, Math.floor(options.pollTimeoutMs)) + : undefined; + const pollFailureTimeoutMs = + options.pollFailureTimeoutMs && options.pollFailureTimeoutMs > 0 + ? Math.max(1, Math.floor(options.pollFailureTimeoutMs)) + : undefined; + + if (pollTimeoutMs) { + timeout = setTimeout(() => { + if (timedOut || isSettled()) { + return; + } + timedOut = true; + inFlight = false; + timeout = undefined; + onTimeout(new Error(`Process progress poll timed out after ${pollTimeoutMs}ms`)); + }, pollTimeoutMs); + } + + void runProgressPoll(options, isSettled) + .then(() => { + firstFailureAtMs = null; + }) + .catch((error) => { + if (timedOut || isSettled()) { + return; + } + if (!pollFailureTimeoutMs) { + return; + } + const now = Date.now(); + firstFailureAtMs ??= now; + if (now - firstFailureAtMs < pollFailureTimeoutMs) { + return; + } + const message = error instanceof Error ? error.message : String(error); + timedOut = true; + inFlight = false; + clear(); + onTimeout( + new Error(`Process progress poll failed for ${pollFailureTimeoutMs}ms: ${message}`) + ); + }) + .finally(() => { + if (timedOut) { + return; + } + inFlight = false; + clear(); + }); + }; + + return { run, clear }; +}; + +const redactSecrets = (value: string) => + value + .replace(/(postgres(?:ql)?:\/\/[^:\s/@]+:)[^@\s]+(@)/gi, '$1***$2') + .replace(/(invalid percent-encoded token:\s*["'])[^"']*(["'])/gi, '$1***$2') + .replace(/(password\s+')[^']*(')/gi, '$1***$2') + .replace(/(password=)[^'\s,)]+/gi, '$1***'); + +const appendBounded = (current: string, chunk: unknown, limit: number) => { + const next = + current + redactSecrets(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)); + if (next.length <= limit) { + return next; + } + return next.slice(next.length - limit); +}; + +const redactArgs = (args: string[]) => args.map((arg) => redactSecrets(arg)); + +export class SpaceDataDbProcessError extends Error { + constructor( + message: string, + readonly result: Omit & { + exitCode: number | null; + } + ) { + super(message); + } +} + +export class SpaceDataDbProcessPipelineError extends Error { + constructor( + message: string, + readonly result: { + label?: string; + source: ISpaceDataDbProcessPartialResult; + target: ISpaceDataDbProcessPartialResult; + } + ) { + super(message); + } +} + +export class SpaceDataDbProcessCanceledError extends SpaceDataDbProcessError { + constructor(result: SpaceDataDbProcessError['result']) { + super('Process canceled', result); + } +} + +export class SpaceDataDbProcessPipelineCanceledError extends SpaceDataDbProcessPipelineError { + constructor(result: SpaceDataDbProcessPipelineError['result']) { + super('Process pipeline canceled', result); + } +} + +type IProcessState = { + command: string; + args: string[]; + stderr: string; + stdout: string; + exitCode: number | null; + signal: NodeJS.Signals | null; + startedAt: string; + startedAtMs: number; + completedAt: string | null; + durationMs: number | null; +}; + +type IReadableState = NodeJS.ReadableStream & { + closed?: boolean; + destroyed?: boolean; + readableEnded?: boolean; +}; + +type IWritableState = NodeJS.WritableStream & { + closed?: boolean; + destroyed?: boolean; + writableEnded?: boolean; + writableFinished?: boolean; +}; + +const nowTiming = () => { + const now = new Date(); + return { + iso: now.toISOString(), + ms: now.getTime(), + }; +}; + +const durationSince = (startedAtMs: number, completedAtMs: number) => + Math.max(0, completedAtMs - startedAtMs); + +const initProcessState = (plan: ISpaceDataDbProcessPlan): IProcessState => { + const startedAt = nowTiming(); + return { + command: plan.command, + args: redactArgs(plan.args), + stderr: '', + stdout: '', + exitCode: null, + signal: null, + startedAt: startedAt.iso, + startedAtMs: startedAt.ms, + completedAt: null, + durationMs: null, + }; +}; + +const completeProcessState = (state: IProcessState) => { + if (state.completedAt) { + return; + } + const completedAt = nowTiming(); + state.completedAt = completedAt.iso; + state.durationMs = durationSince(state.startedAtMs, completedAt.ms); +}; + +const toPartialResult = (state: IProcessState): ISpaceDataDbProcessPartialResult => ({ + command: state.command, + args: state.args, + exitCode: state.exitCode, + signal: state.signal, + stderr: state.stderr, + stdout: state.stdout, + startedAt: state.startedAt, + completedAt: state.completedAt ?? new Date(state.startedAtMs).toISOString(), + durationMs: state.durationMs ?? 0, +}); + +const toRunResult = (state: IProcessState): ISpaceDataDbProcessRunResult => ({ + command: state.command, + args: state.args, + exitCode: state.exitCode ?? 0, + signal: state.signal, + stderr: state.stderr, + stdout: state.stdout, + startedAt: state.startedAt, + completedAt: state.completedAt ?? new Date(state.startedAtMs).toISOString(), + durationMs: state.durationMs ?? 0, +}); + +const isProcessGoneWithoutExitStatus = (child: IProcessLike, state: IProcessState) => { + if (state.exitCode !== null || state.signal !== null || child.pid == null) { + return false; + } + try { + return child.kill(0) === false; + } catch { + return true; + } +}; + +@Injectable() +export class SpaceDataDbProcessRunnerService { + constructor( + @Optional() + @Inject(SPACE_DATA_DB_PROCESS_SPAWN) + private readonly spawnProcess: ISpaceDataDbProcessSpawn = nodeSpawn as ISpaceDataDbProcessSpawn + ) {} + + async run( + plan: ISpaceDataDbProcessPlan, + options: ISpaceDataDbProcessRunOptions = {} + ): Promise { + const stderrLimit = options.stderrLimit ?? defaultStderrLimit; + const stdoutLimit = options.stdoutLimit ?? stderrLimit; + const redactedArgs = redactArgs(plan.args); + const startedAt = nowTiming(); + const child = this.spawnProcess(plan.command, plan.args, { + cwd: options.cwd, + env: options.env ?? process.env, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + let stdout = ''; + let cancelTimer: NodeJS.Timeout | undefined; + let pollTimer: NodeJS.Timeout | undefined; + let timeout: NodeJS.Timeout | undefined; + let exitFallbackTimer: NodeJS.Timeout | undefined; + let stdioFallbackTimer: NodeJS.Timeout | undefined; + let progressPollCleanup: (() => void) | undefined; + let exitCode: number | null = null; + let signal: NodeJS.Signals | null = null; + let exited = false; + let closed = false; + let stdoutClosed = !child.stdout; + let stderrClosed = !child.stderr; + + child.stderr?.on('data', (chunk) => { + stderr = appendBounded(stderr, chunk, stderrLimit); + }); + child.stdout?.on('data', (chunk) => { + stdout = appendBounded(stdout, chunk, stdoutLimit); + }); + + const buildResult = ( + exitCode: TExitCode, + signal: NodeJS.Signals | null + ) => { + const completedAt = nowTiming(); + return { + command: plan.command, + args: redactedArgs, + exitCode, + signal, + stderr, + stdout, + startedAt: startedAt.iso, + completedAt: completedAt.iso, + durationMs: durationSince(startedAt.ms, completedAt.ms), + }; + }; + + return await new Promise((resolve, reject) => { + let settled = false; + const settle = (fn: () => void) => { + if (settled) { + return; + } + settled = true; + if (timeout) { + clearTimeout(timeout); + } + if (cancelTimer) { + clearInterval(cancelTimer); + } + if (pollTimer) { + clearInterval(pollTimer); + } + if (exitFallbackTimer) { + clearTimeout(exitFallbackTimer); + } + if (stdioFallbackTimer) { + clearTimeout(stdioFallbackTimer); + } + progressPollCleanup?.(); + fn(); + }; + + const cancel = () => { + child.kill('SIGTERM'); + settle(() => { + reject(new SpaceDataDbProcessCanceledError(buildResult(null, 'SIGTERM'))); + }); + }; + const checkCancel = async () => { + if (!options.shouldCancel || settled) { + return; + } + if (await options.shouldCancel()) { + cancel(); + } + }; + if (options.shouldCancel) { + cancelTimer = setInterval( + () => { + void checkCancel().catch(() => undefined); + }, + Math.max(1, options.cancelPollMs ?? defaultCancelPollMs) + ); + void checkCancel().catch(() => undefined); + } + if (options.onPoll) { + const progressPoll = createProgressPollRunner( + options, + () => settled, + (error) => { + child.kill('SIGTERM'); + settle(() => { + reject( + new SpaceDataDbProcessError( + `${error.message}: ${plan.command} ${redactedArgs.join(' ')}`, + buildResult(null, 'SIGTERM') + ) + ); + }); + } + ); + progressPollCleanup = progressPoll.clear; + pollTimer = setInterval( + progressPoll.run, + Math.max(1, options.pollMs ?? defaultCancelPollMs) + ); + progressPoll.run(); + } + + if (options.timeoutMs && options.timeoutMs > 0) { + timeout = setTimeout(() => { + child.kill('SIGTERM'); + settle(() => { + reject( + new SpaceDataDbProcessError( + `Process timed out: ${plan.command} ${redactedArgs.join(' ')}`, + buildResult(null, 'SIGTERM') + ) + ); + }); + }, options.timeoutMs); + } + + child.on('error', (error) => { + settle(() => { + reject( + new SpaceDataDbProcessError( + `Process failed to start: ${plan.command} ${redactedArgs.join(' ')}`, + { + ...buildResult(null, null), + exitCode: null, + signal: null, + stderr: stderr || redactSecrets(error.message), + } + ) + ); + }); + }); + + const settleFromExitStatus = () => { + const result = buildResult(exitCode ?? 0, signal); + if (exitCode === 0 || (exitCode === null && !stderr)) { + settle(() => resolve(result)); + return; + } + settle(() => { + reject( + new SpaceDataDbProcessError( + `Process exited with code ${exitCode}: ${plan.command} ${redactedArgs.join(' ')}`, + result + ) + ); + }); + }; + const scheduleExitFallback = () => { + if (settled || exitFallbackTimer || !exited || closed) { + return; + } + exitFallbackTimer = setTimeout( + () => { + exitFallbackTimer = undefined; + if (settled || closed) { + return; + } + settleFromExitStatus(); + }, + Math.max(1, options.exitFallbackMs ?? defaultExitFallbackMs) + ); + }; + const scheduleStdioFallback = () => { + if (settled || stdioFallbackTimer || !stdoutClosed || !stderrClosed || closed) { + return; + } + stdioFallbackTimer = setTimeout( + () => { + stdioFallbackTimer = undefined; + if (settled || closed) { + return; + } + settleFromExitStatus(); + }, + Math.max(1, options.exitFallbackMs ?? defaultExitFallbackMs) + ); + }; + const markStdoutClosed = () => { + stdoutClosed = true; + scheduleStdioFallback(); + }; + const markStderrClosed = () => { + stderrClosed = true; + scheduleStdioFallback(); + }; + child.stdout?.on('end', markStdoutClosed); + child.stdout?.on('close', markStdoutClosed); + child.stderr?.on('end', markStderrClosed); + child.stderr?.on('close', markStderrClosed); + child.on('exit', (childExitCode, childSignal) => { + exited = true; + exitCode = childExitCode; + signal = childSignal; + scheduleExitFallback(); + }); + child.on('close', (exitCode, signal) => { + closed = true; + exited = true; + const result = buildResult(exitCode ?? 0, signal); + if (exitCode === 0) { + settle(() => resolve(result)); + return; + } + settle(() => { + reject( + new SpaceDataDbProcessError( + `Process exited with code ${exitCode}: ${plan.command} ${redactedArgs.join(' ')}`, + result + ) + ); + }); + }); + }); + } + + async runPipeline( + plan: ISpaceDataDbProcessPipelinePlan, + options: ISpaceDataDbProcessRunOptions = {} + ): Promise { + const stderrLimit = options.stderrLimit ?? defaultStderrLimit; + const stdoutLimit = options.stdoutLimit ?? stderrLimit; + const sourceState = initProcessState(plan.source); + const targetState = initProcessState(plan.target); + const sourceChild = this.spawnProcess(plan.source.command, plan.source.args, { + cwd: options.cwd, + env: options.env ?? process.env, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const targetChild = this.spawnProcess(plan.target.command, plan.target.args, { + cwd: options.cwd, + env: options.env ?? process.env, + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + sourceChild.stderr?.on('data', (chunk) => { + sourceState.stderr = appendBounded(sourceState.stderr, chunk, stderrLimit); + }); + targetChild.stderr?.on('data', (chunk) => { + targetState.stderr = appendBounded(targetState.stderr, chunk, stderrLimit); + }); + targetChild.stdout?.on('data', (chunk) => { + targetState.stdout = appendBounded(targetState.stdout, chunk, stdoutLimit); + }); + + return await new Promise((resolve, reject) => { + let settled = false; + let cancelTimer: NodeJS.Timeout | undefined; + let pollTimer: NodeJS.Timeout | undefined; + let timeout: NodeJS.Timeout | undefined; + let exitFallbackTimer: NodeJS.Timeout | undefined; + let stdioFallbackTimer: NodeJS.Timeout | undefined; + let successfulTargetFallbackTimer: NodeJS.Timeout | undefined; + let sourceInputFallbackTimer: NodeJS.Timeout | undefined; + let progressPollCleanup: (() => void) | undefined; + const timers: { processStatePollTimer?: NodeJS.Timeout } = {}; + let sourceClosed = false; + let targetClosed = false; + let sourceExited = false; + let targetExited = false; + let sourceStdoutClosed = false; + let sourceStderrClosed = !sourceChild.stderr; + let targetStdinClosed = false; + let targetStdoutClosed = !targetChild.stdout; + let targetStderrClosed = !targetChild.stderr; + const clearTimers = () => { + if (timeout) { + clearTimeout(timeout); + } + if (cancelTimer) { + clearInterval(cancelTimer); + } + if (pollTimer) { + clearInterval(pollTimer); + } + if (exitFallbackTimer) { + clearTimeout(exitFallbackTimer); + } + if (stdioFallbackTimer) { + clearTimeout(stdioFallbackTimer); + } + if (successfulTargetFallbackTimer) { + clearTimeout(successfulTargetFallbackTimer); + } + if (sourceInputFallbackTimer) { + clearTimeout(sourceInputFallbackTimer); + } + if (timers.processStatePollTimer) { + clearInterval(timers.processStatePollTimer); + } + progressPollCleanup?.(); + }; + const rejectPipeline = (message: string, killSource: boolean, killTarget: boolean) => { + if (settled) { + return; + } + settled = true; + clearTimers(); + if (killSource) { + sourceState.signal = sourceState.signal ?? 'SIGTERM'; + sourceChild.kill('SIGTERM'); + } + if (killTarget) { + targetState.signal = targetState.signal ?? 'SIGTERM'; + targetChild.kill('SIGTERM'); + } + completeProcessState(sourceState); + completeProcessState(targetState); + reject( + new SpaceDataDbProcessPipelineError(message, { + label: plan.label, + source: toPartialResult(sourceState), + target: toPartialResult(targetState), + }) + ); + }; + const resolveIfComplete = () => { + if (settled || sourceState.exitCode !== 0 || targetState.exitCode !== 0) { + return; + } + settled = true; + clearTimers(); + completeProcessState(sourceState); + completeProcessState(targetState); + resolve({ + source: toRunResult(sourceState), + target: toRunResult(targetState), + }); + }; + + if (!sourceChild.stdout || !targetChild.stdin) { + rejectPipeline( + `Process pipeline could not be opened: ${plan.source.command} -> ${plan.target.command}`, + true, + true + ); + return; + } + + const cancelPipeline = () => { + if (settled) { + return; + } + settled = true; + clearTimers(); + sourceState.signal = sourceState.signal ?? 'SIGTERM'; + targetState.signal = targetState.signal ?? 'SIGTERM'; + sourceChild.kill('SIGTERM'); + targetChild.kill('SIGTERM'); + completeProcessState(sourceState); + completeProcessState(targetState); + reject( + new SpaceDataDbProcessPipelineCanceledError({ + label: plan.label, + source: toPartialResult(sourceState), + target: toPartialResult(targetState), + }) + ); + }; + const checkCancel = async () => { + if (!options.shouldCancel || settled) { + return; + } + if (await options.shouldCancel()) { + cancelPipeline(); + } + }; + if (options.shouldCancel) { + cancelTimer = setInterval( + () => { + void checkCancel().catch(() => undefined); + }, + Math.max(1, options.cancelPollMs ?? defaultCancelPollMs) + ); + void checkCancel().catch(() => undefined); + } + if (options.onPoll) { + const progressPoll = createProgressPollRunner( + options, + () => settled, + (error) => { + rejectPipeline( + `${error.message}: ${plan.source.command} -> ${plan.target.command}`, + true, + true + ); + } + ); + progressPollCleanup = progressPoll.clear; + pollTimer = setInterval( + progressPoll.run, + Math.max(1, options.pollMs ?? defaultCancelPollMs) + ); + progressPoll.run(); + } + + if (options.timeoutMs && options.timeoutMs > 0) { + timeout = setTimeout(() => { + sourceState.signal = sourceState.signal ?? 'SIGTERM'; + targetState.signal = targetState.signal ?? 'SIGTERM'; + rejectPipeline( + `Process pipeline timed out: ${plan.source.command} -> ${plan.target.command}`, + true, + true + ); + }, options.timeoutMs); + } + + const scheduleExitFallback = () => { + if ( + settled || + exitFallbackTimer || + !sourceExited || + !targetExited || + (sourceClosed && targetClosed) + ) { + return; + } + + exitFallbackTimer = setTimeout( + () => { + exitFallbackTimer = undefined; + if (settled || (sourceClosed && targetClosed)) { + return; + } + if (sourceState.exitCode === 0 && targetState.exitCode === 0) { + resolveIfComplete(); + return; + } + rejectPipeline( + `Process pipeline exited before stdio closed: ${plan.source.command} -> ${plan.target.command}`, + false, + false + ); + }, + Math.max(1, options.exitFallbackMs ?? defaultExitFallbackMs) + ); + }; + const isSourceOutputSettled = () => + (sourceStdoutClosed && sourceStderrClosed) || + (sourceState.exitCode === 0 && !sourceState.stderr); + const scheduleStdioFallback = () => { + if ( + settled || + stdioFallbackTimer || + !isSourceOutputSettled() || + !targetStdinClosed || + !targetStdoutClosed || + !targetStderrClosed || + (sourceClosed && targetClosed) + ) { + return; + } + + stdioFallbackTimer = setTimeout( + () => { + stdioFallbackTimer = undefined; + if (settled || (sourceClosed && targetClosed)) { + return; + } + if ( + !isSourceOutputSettled() || + !targetStdinClosed || + !targetStdoutClosed || + !targetStderrClosed + ) { + return; + } + if ( + (sourceState.exitCode !== null && sourceState.exitCode !== 0) || + (targetState.exitCode !== null && targetState.exitCode !== 0) + ) { + rejectPipeline( + `Process pipeline stdio closed after a non-zero exit: ${plan.source.command} -> ${plan.target.command}`, + false, + false + ); + return; + } + if ( + (sourceState.exitCode === null || targetState.exitCode === null) && + (sourceState.stderr || targetState.stderr) + ) { + rejectPipeline( + `Process pipeline stdio closed without exit status and stderr output: ${plan.source.command} -> ${plan.target.command}`, + false, + false + ); + return; + } + settled = true; + clearTimers(); + completeProcessState(sourceState); + completeProcessState(targetState); + resolve({ + source: toRunResult(sourceState), + target: toRunResult(targetState), + }); + }, + Math.max(1, options.exitFallbackMs ?? defaultExitFallbackMs) + ); + }; + const scheduleSuccessfulTargetFallback = () => { + if ( + settled || + successfulTargetFallbackTimer || + targetState.exitCode !== 0 || + sourceState.exitCode !== null || + !sourceStdoutClosed || + !sourceStderrClosed || + sourceState.stderr + ) { + return; + } + + successfulTargetFallbackTimer = setTimeout( + () => { + successfulTargetFallbackTimer = undefined; + if ( + settled || + targetState.exitCode !== 0 || + sourceState.exitCode !== null || + !sourceStdoutClosed || + !sourceStderrClosed || + sourceState.stderr + ) { + return; + } + settled = true; + clearTimers(); + completeProcessState(sourceState); + completeProcessState(targetState); + resolve({ + source: toRunResult(sourceState), + target: toRunResult(targetState), + }); + }, + Math.max(1, options.exitFallbackMs ?? defaultExitFallbackMs) + ); + }; + const endTargetStdin = () => { + if (targetStdinClosed) { + return; + } + const stdin = targetChild.stdin as typeof targetChild.stdin & { + destroyed?: boolean; + writableEnded?: boolean; + }; + if (stdin.destroyed || stdin.writableEnded) { + markTargetStdinClosed(); + return; + } + stdin.end(); + }; + const scheduleSourceInputFallback = () => { + if ( + settled || + sourceInputFallbackTimer || + sourceStdoutClosed || + targetStdinClosed || + sourceState.exitCode !== 0 + ) { + return; + } + sourceInputFallbackTimer = setTimeout( + () => { + sourceInputFallbackTimer = undefined; + if (settled || sourceStdoutClosed || targetStdinClosed || sourceState.exitCode !== 0) { + return; + } + endTargetStdin(); + }, + Math.max(1, options.exitFallbackMs ?? defaultExitFallbackMs) + ); + }; + const syncProcessExitStatus = () => { + const syncOne = (child: IProcessLike, state: IProcessState, setExited: () => void) => { + if (state.exitCode !== null || state.signal !== null) { + return false; + } + const exitCode = child.exitCode; + const signalCode = child.signalCode; + if (exitCode == null && signalCode == null) { + return false; + } + state.exitCode = exitCode ?? null; + state.signal = signalCode ?? null; + completeProcessState(state); + setExited(); + return true; + }; + const sourceChanged = syncOne(sourceChild, sourceState, () => { + sourceExited = true; + }); + const targetChanged = syncOne(targetChild, targetState, () => { + targetExited = true; + }); + if (!sourceChanged && !targetChanged) { + return; + } + scheduleSourceInputFallback(); + scheduleExitFallback(); + scheduleStdioFallback(); + scheduleSuccessfulTargetFallback(); + }; + const rejectMissingExitedProcesses = () => { + if (settled) { + return; + } + const sourceMissing = isProcessGoneWithoutExitStatus(sourceChild, sourceState); + const targetMissing = isProcessGoneWithoutExitStatus(targetChild, targetState); + if (!sourceMissing && !targetMissing) { + return; + } + rejectPipeline( + `Process pipeline exited without status: ${plan.source.command} -> ${plan.target.command}`, + !sourceMissing, + !targetMissing + ); + }; + const syncStdioState = () => { + const sourceStdout = sourceChild.stdout as IReadableState; + const sourceStderr = sourceChild.stderr as IReadableState | null | undefined; + const targetStdin = targetChild.stdin as IWritableState; + const targetStdout = targetChild.stdout as IReadableState | null | undefined; + const targetStderr = targetChild.stderr as IReadableState | null | undefined; + + if (sourceStdout.closed || sourceStdout.destroyed || sourceStdout.readableEnded) { + markSourceStdoutClosed(); + } + if ( + !sourceStderr || + sourceStderr.closed || + sourceStderr.destroyed || + sourceStderr.readableEnded + ) { + markSourceStderrClosed(); + } + if ( + targetStdin.closed || + targetStdin.destroyed || + targetStdin.writableEnded || + targetStdin.writableFinished + ) { + markTargetStdinClosed(); + } + if ( + !targetStdout || + targetStdout.closed || + targetStdout.destroyed || + targetStdout.readableEnded + ) { + markTargetStdoutClosed(); + } + if ( + !targetStderr || + targetStderr.closed || + targetStderr.destroyed || + targetStderr.readableEnded + ) { + markTargetStderrClosed(); + } + }; + const markSourceStdoutClosed = () => { + sourceStdoutClosed = true; + endTargetStdin(); + scheduleStdioFallback(); + scheduleSuccessfulTargetFallback(); + }; + const markSourceStderrClosed = () => { + sourceStderrClosed = true; + scheduleStdioFallback(); + scheduleSuccessfulTargetFallback(); + }; + const markTargetStdinClosed = () => { + targetStdinClosed = true; + scheduleStdioFallback(); + }; + const markTargetStdoutClosed = () => { + targetStdoutClosed = true; + scheduleStdioFallback(); + }; + const markTargetStderrClosed = () => { + targetStderrClosed = true; + scheduleStdioFallback(); + }; + + sourceChild.stdout.on('end', markSourceStdoutClosed); + sourceChild.stdout.on('close', markSourceStdoutClosed); + sourceChild.stderr?.on('end', markSourceStderrClosed); + sourceChild.stderr?.on('close', markSourceStderrClosed); + targetChild.stdin.on('finish', markTargetStdinClosed); + targetChild.stdin.on('close', markTargetStdinClosed); + targetChild.stdout?.on('end', markTargetStdoutClosed); + targetChild.stdout?.on('close', markTargetStdoutClosed); + targetChild.stderr?.on('end', markTargetStderrClosed); + targetChild.stderr?.on('close', markTargetStderrClosed); + timers.processStatePollTimer = setInterval( + () => { + if (settled) { + return; + } + syncStdioState(); + syncProcessExitStatus(); + rejectMissingExitedProcesses(); + }, + Math.max(1, options.exitFallbackMs ?? defaultExitFallbackMs) + ); + sourceChild.stdout.on('error', (error) => { + sourceState.stderr = appendBounded(sourceState.stderr, error.message, stderrLimit); + rejectPipeline( + `Source COPY stream failed: ${plan.source.command} ${sourceState.args.join(' ')}`, + false, + true + ); + }); + targetChild.stdin.on('error', (error) => { + targetState.stderr = appendBounded(targetState.stderr, error.message, stderrLimit); + rejectPipeline( + `Target COPY stream failed: ${plan.target.command} ${targetState.args.join(' ')}`, + true, + false + ); + }); + + sourceChild.on('error', (error) => { + sourceState.stderr = sourceState.stderr || redactSecrets(error.message); + rejectPipeline( + `Source process failed to start: ${plan.source.command} ${sourceState.args.join(' ')}`, + false, + true + ); + }); + targetChild.on('error', (error) => { + targetState.stderr = targetState.stderr || redactSecrets(error.message); + rejectPipeline( + `Target process failed to start: ${plan.target.command} ${targetState.args.join(' ')}`, + true, + false + ); + }); + sourceChild.on('exit', (exitCode, signal) => { + sourceExited = true; + sourceState.exitCode = exitCode; + sourceState.signal = signal; + completeProcessState(sourceState); + scheduleSourceInputFallback(); + scheduleExitFallback(); + scheduleStdioFallback(); + }); + targetChild.on('exit', (exitCode, signal) => { + targetExited = true; + targetState.exitCode = exitCode; + targetState.signal = signal; + completeProcessState(targetState); + scheduleExitFallback(); + scheduleSuccessfulTargetFallback(); + }); + + sourceChild.on('close', (exitCode, signal) => { + sourceClosed = true; + sourceExited = true; + sourceState.exitCode = exitCode; + sourceState.signal = signal; + completeProcessState(sourceState); + if (exitCode !== 0) { + rejectPipeline( + `Source process exited with code ${exitCode}: ${plan.source.command} ${sourceState.args.join(' ')}`, + false, + true + ); + return; + } + endTargetStdin(); + resolveIfComplete(); + }); + targetChild.on('close', (exitCode, signal) => { + targetClosed = true; + targetExited = true; + targetState.exitCode = exitCode; + targetState.signal = signal; + completeProcessState(targetState); + if (exitCode !== 0) { + rejectPipeline( + `Target process exited with code ${exitCode}: ${plan.target.command} ${targetState.args.join(' ')}`, + true, + false + ); + return; + } + resolveIfComplete(); + scheduleSuccessfulTargetFallback(); + }); + + sourceChild.stdout.pipe(targetChild.stdin); + }); + } +} diff --git a/apps/nestjs-backend/src/features/space/space-data-db-related-spaces.ts b/apps/nestjs-backend/src/features/space/space-data-db-related-spaces.ts new file mode 100644 index 0000000000..e4063ae996 --- /dev/null +++ b/apps/nestjs-backend/src/features/space/space-data-db-related-spaces.ts @@ -0,0 +1,230 @@ +import { FieldType } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import type { IDataDbConnectionSummaryVo } from '@teable/openapi'; +import { extractForeignTableId } from '../base/cross-space-detection.util'; +import { decryptDataDbUrl } from './data-db-url-secret'; +import { fingerprintDatabaseUrl } from './data-db-preflight.service'; + +export type ISpaceDataDbRelatedSpaces = NonNullable; +export type ISpaceDataDbRelatedSpaceInfo = ISpaceDataDbRelatedSpaces['spaces'][number]; +export type ISpaceDataDbRelatedSpaceLink = ISpaceDataDbRelatedSpaces['links'][number]; + +type IRelatedFieldRow = { + id: string; + type: string; + isLookup: boolean | null; + isConditionalLookup: boolean | null; + options: string | null; + lookupOptions: string | null; + tableId: string; + table: { + id: string; + base: { + spaceId: string; + }; + }; +}; + +const dataDbMode = (mode: string | null | undefined): 'default' | 'byodb' => + mode === 'byodb' ? 'byodb' : 'default'; + +const dataDbBindingState = ( + state: string | null | undefined +): ISpaceDataDbRelatedSpaceInfo['dataDbState'] => { + if ( + state === 'ready' || + state === 'validating' || + state === 'initializing' || + state === 'migrating' || + state === 'error' || + state === 'disabled' + ) { + return state; + } + return undefined; +}; + +export const resolveSpaceDataDbRelatedSpaces = async ( + prismaService: PrismaService, + primarySpaceId: string +): Promise => { + const fields = (await prismaService.field.findMany({ + where: { + deletedTime: null, + table: { + deletedTime: null, + base: { + deletedTime: null, + space: { + deletedTime: null, + }, + }, + }, + OR: [ + { + type: FieldType.Link, + OR: [{ isLookup: false }, { isLookup: null }], + }, + { type: FieldType.ConditionalRollup }, + { isLookup: true, isConditionalLookup: true }, + ], + }, + select: { + id: true, + type: true, + isLookup: true, + isConditionalLookup: true, + options: true, + lookupOptions: true, + tableId: true, + table: { + select: { + id: true, + base: { + select: { + spaceId: true, + }, + }, + }, + }, + }, + })) as IRelatedFieldRow[]; + + const foreignTableIds = Array.from( + new Set(fields.map((field) => extractForeignTableId(field)).filter((id): id is string => !!id)) + ); + const foreignTables = foreignTableIds.length + ? await prismaService.tableMeta.findMany({ + where: { + id: { in: foreignTableIds }, + deletedTime: null, + base: { + deletedTime: null, + space: { + deletedTime: null, + }, + }, + }, + select: { + id: true, + base: { + select: { + spaceId: true, + }, + }, + }, + }) + : []; + const foreignSpaceByTableId = new Map( + foreignTables.map((table) => [table.id, table.base.spaceId]) + ); + + const links: ISpaceDataDbRelatedSpaceLink[] = []; + const adjacency = new Map>(); + const connect = (left: string, right: string) => { + if (left === right) return; + const leftSet = adjacency.get(left) ?? new Set(); + leftSet.add(right); + adjacency.set(left, leftSet); + const rightSet = adjacency.get(right) ?? new Set(); + rightSet.add(left); + adjacency.set(right, rightSet); + }; + + for (const field of fields) { + const foreignTableId = extractForeignTableId(field); + if (!foreignTableId) continue; + const fromSpaceId = field.table.base.spaceId; + const toSpaceId = foreignSpaceByTableId.get(foreignTableId); + if (!toSpaceId || fromSpaceId === toSpaceId) continue; + connect(fromSpaceId, toSpaceId); + links.push({ + fromSpaceId, + fromTableId: field.tableId, + fromFieldId: field.id, + toSpaceId, + toTableId: foreignTableId, + }); + } + + const relatedSpaceIds = new Set([primarySpaceId]); + const queue = [primarySpaceId]; + for (let index = 0; index < queue.length; index++) { + const current = queue[index]; + for (const next of adjacency.get(current) ?? []) { + if (relatedSpaceIds.has(next)) continue; + relatedSpaceIds.add(next); + queue.push(next); + } + } + + const [spaces, bindings] = await Promise.all([ + prismaService.space.findMany({ + where: { id: { in: [...relatedSpaceIds] }, deletedTime: null }, + select: { + id: true, + name: true, + baseGroup: { + where: { deletedTime: null }, + select: { + id: true, + tables: { + where: { deletedTime: null }, + select: { id: true }, + }, + }, + }, + }, + }), + prismaService.spaceDataDbBinding.findMany({ + where: { spaceId: { in: [...relatedSpaceIds] } }, + include: { dataDbConnection: true }, + }), + ]); + + const bindingBySpaceId = new Map(bindings.map((binding) => [binding.spaceId, binding])); + const relatedSpaces = spaces + .map((space) => { + const binding = bindingBySpaceId.get(space.id); + const connection = binding?.dataDbConnection; + return { + spaceId: space.id, + name: space.name, + isPrimary: space.id === primarySpaceId, + baseIds: space.baseGroup.map((base) => base.id).sort(), + tableIds: space.baseGroup.flatMap((base) => base.tables.map((table) => table.id)).sort(), + dataDbMode: dataDbMode(binding?.mode), + dataDbState: dataDbBindingState(binding?.state), + dataDbConnectionId: connection?.id ?? null, + dataDbUrlFingerprint: connection?.urlFingerprint ?? null, + dataDbDatabaseFingerprint: connection?.encryptedUrl + ? fingerprintDatabaseUrl(decryptDataDbUrl(connection.encryptedUrl)) + : null, + dataDbDisplayHost: connection?.displayHost ?? null, + dataDbDisplayDatabase: connection?.displayDatabase ?? null, + dataDbInternalSchema: connection?.internalSchema ?? null, + }; + }) + .sort((left, right) => + left.isPrimary === right.isPrimary + ? left.name.localeCompare(right.name) || left.spaceId.localeCompare(right.spaceId) + : left.isPrimary + ? -1 + : 1 + ); + + const componentIds = new Set(relatedSpaces.map((space) => space.spaceId)); + return { + primarySpaceId, + hasCrossSpaceLinks: relatedSpaces.length > 1, + spaces: relatedSpaces, + links: links + .filter((link) => componentIds.has(link.fromSpaceId) && componentIds.has(link.toSpaceId)) + .sort( + (left, right) => + left.fromSpaceId.localeCompare(right.fromSpaceId) || + left.toSpaceId.localeCompare(right.toSpaceId) || + left.fromFieldId.localeCompare(right.fromFieldId) + ), + }; +}; diff --git a/apps/nestjs-backend/src/features/space/space.controller.ts b/apps/nestjs-backend/src/features/space/space.controller.ts index 3c76151727..d012e7cacf 100644 --- a/apps/nestjs-backend/src/features/space/space.controller.ts +++ b/apps/nestjs-backend/src/features/space/space.controller.ts @@ -5,6 +5,9 @@ import type { ICreateSpaceVo, IUpdateSpaceVo, IGetSpaceVo, + IDataDbConnectionSummaryVo, + IDataDbMigrationJobStatusVo, + IDataDbPreflightVo, EmailInvitationVo, ListSpaceInvitationLinkVo, CreateSpaceInvitationLinkVo, @@ -17,6 +20,8 @@ import type { import { createSpaceRoSchema, ICreateSpaceRo, + dataDbPreflightRoSchema, + IDataDbPreflightRo, updateSpaceRoSchema, IUpdateSpaceRo, emailSpaceInvitationRoSchema, @@ -43,6 +48,7 @@ import { spaceSearchRoSchema, ISpaceSearchRo, } from '@teable/openapi'; +import { ClsService } from 'nestjs-cls'; import { CustomHttpException } from '../../custom.exception'; import { EmitControllerEvent } from '../../event-emitter/decorators/emit-controller-event.decorator'; import { Events } from '../../event-emitter/events'; @@ -50,15 +56,46 @@ 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 { DataDbBindingService } from './data-db-binding.service'; +import { DataDbPreflightService } from './data-db-preflight.service'; +import { + migrateSpaceTargetMode, + spaceDataDbAdminOnlyErrorCode, + spaceDataDbAdminOnlyMessage, +} from './space-data-db-migration.constants'; +import { SpaceDataDbMigrationService } from './space-data-db-migration.service'; import { SpaceService } from './space.service'; + +const rejectSpaceDataDbMigrationFromSpaceApi = () => { + throw new CustomHttpException(spaceDataDbAdminOnlyMessage, HttpErrorCode.RESTRICTED_RESOURCE, { + errorCode: spaceDataDbAdminOnlyErrorCode, + }); +}; + @Controller('api/space/') export class SpaceController { constructor( - private readonly spaceService: SpaceService, - private readonly invitationService: InvitationService, - private readonly collaboratorService: CollaboratorService + protected readonly spaceService: SpaceService, + protected readonly invitationService: InvitationService, + protected readonly collaboratorService: CollaboratorService, + protected readonly dataDbPreflightService: DataDbPreflightService, + protected readonly dataDbBindingService: DataDbBindingService, + protected readonly cls: ClsService, + protected readonly spaceDataDbMigrationService: SpaceDataDbMigrationService ) {} + @Post('data-db/preflight') + @Permissions('space|create') + async preflightDataDb( + @Body(new ZodValidationPipe(dataDbPreflightRoSchema)) + dataDbPreflightRo: IDataDbPreflightRo + ): Promise { + if (dataDbPreflightRo.targetMode === migrateSpaceTargetMode) { + rejectSpaceDataDbMigrationFromSpaceApi(); + } + return await this.dataDbPreflightService.preflight(dataDbPreflightRo); + } + @Post() @Permissions('space|create') @EmitControllerEvent(Events.SPACE_CREATE) @@ -86,6 +123,84 @@ export class SpaceController { return await this.spaceService.getSpaceById(spaceId); } + @Permissions('space|read') + @Get(':spaceId/data-db') + async getSpaceDataDb(@Param('spaceId') spaceId: string): Promise { + return await this.dataDbPreflightService.getSummary(spaceId); + } + + @Permissions('space|update') + @Patch(':spaceId/data-db') + async updateSpaceDataDb( + @Param('spaceId') spaceId: string, + @Body(new ZodValidationPipe(dataDbPreflightRoSchema)) + dataDbPreflightRo: IDataDbPreflightRo + ): Promise { + if (dataDbPreflightRo.targetMode === migrateSpaceTargetMode) { + rejectSpaceDataDbMigrationFromSpaceApi(); + } + await this.dataDbBindingService.updateBindingForSpace( + spaceId, + this.cls.get('user.id') ?? '', + dataDbPreflightRo + ); + return await this.dataDbPreflightService.getSummary(spaceId); + } + + @Permissions('space|update') + @Post(':spaceId/data-db/retest') + async retestSpaceDataDb(@Param('spaceId') spaceId: string): Promise { + await this.dataDbBindingService.retestBinding(spaceId); + return await this.dataDbPreflightService.getSummary(spaceId); + } + + @Permissions('space|update') + @Post(':spaceId/data-db/retry') + async retrySpaceDataDbMigration( + @Param('spaceId') spaceId: string + ): Promise { + await this.dataDbBindingService.retryMigrationForSpace(spaceId); + return await this.dataDbPreflightService.getSummary(spaceId); + } + + @Permissions('space|read') + @Get(':spaceId/data-db/migration/:jobId') + async getSpaceDataDbMigration( + @Param('spaceId') spaceId: string, + @Param('jobId') jobId: string + ): Promise { + rejectSpaceDataDbMigrationFromSpaceApi(); + return await this.spaceDataDbMigrationService.getMigrationJobStatus(spaceId, jobId); + } + + @Permissions('space|update') + @Post(':spaceId/data-db/migration/:jobId/cancel') + async cancelSpaceDataDbMigration( + @Param('spaceId') spaceId: string, + @Param('jobId') jobId: string + ): Promise { + rejectSpaceDataDbMigrationFromSpaceApi(); + return await this.spaceDataDbMigrationService.cancelMigrationForSpace( + spaceId, + jobId, + this.cls.get('user.id') ?? '' + ); + } + + @Permissions('space|update') + @Post(':spaceId/data-db/migration/:jobId/rollback') + async rollbackSpaceDataDbMigration( + @Param('spaceId') spaceId: string, + @Param('jobId') jobId: string + ): Promise { + rejectSpaceDataDbMigrationFromSpaceApi(); + return await this.spaceDataDbMigrationService.rollbackMigrationForSpace( + spaceId, + jobId, + this.cls.get('user.id') ?? '' + ); + } + @Permissions('space|read') @Get() async getSpaceList(): Promise { diff --git a/apps/nestjs-backend/src/features/space/space.module.ts b/apps/nestjs-backend/src/features/space/space.module.ts index 27282ac5be..eccd05da88 100644 --- a/apps/nestjs-backend/src/features/space/space.module.ts +++ b/apps/nestjs-backend/src/features/space/space.module.ts @@ -1,13 +1,23 @@ import { Module } from '@nestjs/common'; +import { EventJobModule } from '../../event-emitter/event-job/event-job.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 { DataDbBindingService } from './data-db-binding.service'; import { DataDbPreflightService } from './data-db-preflight.service'; +import { SpaceDataDbCopyService } from './space-data-db-copy.service'; +import { SpaceDataDbMigrationGuardService } from './space-data-db-migration-guard.service'; +import { SpaceDataDbMigrationWorkerService } from './space-data-db-migration-worker.service'; +import { SpaceDataDbMigrationService } from './space-data-db-migration.service'; +import { SpaceDataDbProcessRunnerService } from './space-data-db-process-runner.service'; import { SpaceController } from './space.controller'; import { SpaceService } from './space.service'; import { TemplateSpaceInitService } from './template-space-init/template-space.init.service'; @@ -20,6 +30,11 @@ import { TemplateSpaceInitService } from './template-space-init/template-space.i DataDbPreflightService, DataDbBaselineService, DataDbBindingService, + SpaceDataDbCopyService, + SpaceDataDbMigrationService, + SpaceDataDbMigrationWorkerService, + SpaceDataDbMigrationGuardService, + SpaceDataDbProcessRunnerService, ], exports: [ SpaceService, @@ -27,6 +42,11 @@ import { TemplateSpaceInitService } from './template-space-init/template-space.i DataDbPreflightService, DataDbBaselineService, DataDbBindingService, + SpaceDataDbCopyService, + SpaceDataDbMigrationService, + SpaceDataDbMigrationWorkerService, + SpaceDataDbMigrationGuardService, + SpaceDataDbProcessRunnerService, ], imports: [ SettingModule, @@ -35,6 +55,10 @@ import { TemplateSpaceInitService } from './template-space-init/template-space.i InvitationModule, BaseModule, PermissionModule, + 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), ], }) export class SpaceModule {} diff --git a/apps/nestjs-backend/src/features/space/space.service.spec.ts b/apps/nestjs-backend/src/features/space/space.service.spec.ts index 124150b9d0..fcb6bc4d47 100644 --- a/apps/nestjs-backend/src/features/space/space.service.spec.ts +++ b/apps/nestjs-backend/src/features/space/space.service.spec.ts @@ -1,5 +1,6 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; +import { Role } from '@teable/core'; import { GlobalModule } from '../../global/global.module'; import { SpaceModule } from './space.module'; import { SpaceService } from './space.service'; @@ -18,4 +19,73 @@ describe('SpaceService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('getBaseListBySpaceId', () => { + it('returns v2 status for canary-space bases even when the base is not new-base v2', async () => { + const spaceId = 'spc1'; + const createdTime = new Date('2026-06-13T00:00:00.000Z'); + const base = { + id: 'bse1', + name: 'Base', + order: 1, + spaceId, + icon: null, + createdBy: 'usr1', + lastModifiedTime: createdTime, + createdTime, + v2Enabled: false, + }; + const prismaService = { + base: { + findMany: vi.fn().mockResolvedValue([base]), + }, + user: { + findMany: vi.fn().mockResolvedValue([{ id: 'usr1', name: 'Nee', avatar: null }]), + }, + baseShare: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + const collaboratorService = { + getCurrentUserCollaboratorsBaseAndSpaceArray: vi.fn().mockResolvedValue({ + spaceIds: [spaceId], + roleMap: { [spaceId]: Role.Owner }, + }), + }; + const baseService = { + enrichBaseListV2Status: vi.fn(async (baseList: (typeof base)[]) => + baseList.map((base) => ({ + ...base, + isCanary: true, + v2Status: { useV2: true, reason: 'space_feature' as const }, + })) + ), + }; + const testService = new SpaceService( + prismaService as never, + {} as never, + baseService as never, + collaboratorService as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never + ); + + const result = await testService.getBaseListBySpaceId(spaceId); + + expect(baseService.enrichBaseListV2Status).toHaveBeenCalledWith([base]); + expect(result[0]).toMatchObject({ + id: base.id, + role: Role.Owner, + isCanary: true, + v2Status: { useV2: true, reason: 'space_feature' }, + }); + expect(result[0]).not.toHaveProperty('v2Enabled'); + }); + }); }); diff --git a/apps/nestjs-backend/src/features/space/space.service.ts b/apps/nestjs-backend/src/features/space/space.service.ts index f981f7f4cf..1dacf891e4 100644 --- a/apps/nestjs-backend/src/features/space/space.service.ts +++ b/apps/nestjs-backend/src/features/space/space.service.ts @@ -1,5 +1,5 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { Injectable } from '@nestjs/common'; +import { Injectable, Optional } from '@nestjs/common'; import type { IRole } from '@teable/core'; import { HttpErrorCode, @@ -41,6 +41,7 @@ import { SettingOpenApiService } from '../setting/open-api/setting-open-api.serv import { SettingService } from '../setting/setting.service'; import { normalizeSpaceAIIntegrationConfig } from './ai-integration-config'; import { DataDbBindingService } from './data-db-binding.service'; +import { SpaceDataDbMigrationGuardService } from './space-data-db-migration-guard.service'; @Injectable() export class SpaceService { @@ -56,9 +57,15 @@ export class SpaceService { protected readonly dataDbBindingService: DataDbBindingService, @ThresholdConfig() protected readonly thresholdConfig: IThresholdConfig, @InjectModel('CUSTOM_KNEX') protected readonly knex: Knex, - @InjectDbProvider() protected readonly dbProvider: IDbProvider + @InjectDbProvider() protected readonly dbProvider: IDbProvider, + @Optional() + protected readonly spaceDataDbMigrationGuard?: SpaceDataDbMigrationGuardService ) {} + private async assertSpaceWritable(spaceId: string) { + await this.spaceDataDbMigrationGuard?.assertSpaceWritable(spaceId); + } + protected supportsByodbSpaceCreation() { return false; } @@ -240,6 +247,7 @@ export class SpaceService { } async updateSpace(spaceId: string, updateSpaceRo: IUpdateSpaceRo) { + await this.assertSpaceWritable(spaceId); const userId = this.cls.get('user.id'); return await this.prismaService.space.update({ @@ -259,6 +267,7 @@ export class SpaceService { } async deleteSpace(spaceId: string) { + await this.assertSpaceWritable(spaceId); const userId = this.cls.get('user.id'); await this.prismaService.$tx(async () => { @@ -308,6 +317,7 @@ export class SpaceService { createdBy: true, lastModifiedTime: true, createdTime: true, + v2Enabled: true, }, where: { spaceId, @@ -333,11 +343,14 @@ export class SpaceService { const userMap = keyBy(userList, 'id'); const sharedBaseIds = new Set(sharedBaseList.map((s) => s.baseId)); - return baseList.map((base) => { + const baseListWithV2Status = await this.baseService.enrichBaseListV2Status(baseList); + + return baseListWithV2Status.map((base) => { + const { v2Enabled, ...baseInfo } = base; const role = roleMap[base.id] || roleMap[base.spaceId]; const createdUser = userMap[base.createdBy]; return { - ...base, + ...baseInfo, role, isShared: sharedBaseIds.has(base.id), lastModifiedTime: base.lastModifiedTime?.toISOString(), @@ -497,7 +510,7 @@ export class SpaceService { .filter((row) => row.type === ResourceType.Base) .map((row) => baseMap[row.base_id].spaceId) ); - const { validCreatorSet, spaceOwnerMap } = + const { spaceOwnerMap } = await this.collaboratorService.buildSpaceOwnerContext(spaceIdsForBases); const allUserIds = uniq([...userIds, ...spaceOwnerMap.values()]); @@ -509,10 +522,11 @@ export class SpaceService { const list = resultsToReturn.map((row) => { const base = baseMap[row.base_id]; - const isCreatorInSpace = validCreatorSet.has(`${base?.spaceId}:${row.created_by}`); + // Show the real base creator; only when their user record is unresolvable + // (e.g. permanently deleted) fall back to a space owner. const displayUserId = row.type === ResourceType.Base - ? isCreatorInSpace + ? userMap[row.created_by] ? row.created_by : spaceOwnerMap.get(base.spaceId) : row.created_by; @@ -546,6 +560,7 @@ export class SpaceService { } async permanentDeleteSpace(spaceId: string, ignorePermissionCheck: boolean = false) { + await this.assertSpaceWritable(spaceId); if (!ignorePermissionCheck) { const accessTokenId = this.cls.get('accessTokenId'); await this.permissionService.validPermissions(spaceId, ['space|delete'], accessTokenId, true); 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 0712e15247..0754a65c14 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 @@ -1,4 +1,4 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Inject, Injectable, Optional } from '@nestjs/common'; import { CellFormat, FieldKeyType, FieldType } from '@teable/core'; import type { IFieldRo, IFieldVo, ILinkFieldOptionsRo, IRecord } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; @@ -27,6 +27,7 @@ import type { IClsStore } from '../../../types/cls'; import { AuditScope } from '../../audit/audit-scope'; import { Audit } from '../../audit/audit.decorator'; import { FieldOpenApiService } from '../../field/open-api/field-open-api.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'; import { ViewService } from '../../view/view.service'; @@ -48,9 +49,20 @@ export class TableOpenApiV2Service { @InjectDbProvider() private readonly dbProvider: IDbProvider, private readonly tableDuplicateLegacyService: TableDuplicateService, private readonly audit: AuditScope, - private readonly cls: ClsService + private readonly cls: ClsService, + @Optional() + @Inject(SpaceDataDbMigrationGuardService) + private readonly spaceDataDbMigrationGuard?: SpaceDataDbMigrationGuardService ) {} + private async assertBaseWritable(baseId: string) { + await this.spaceDataDbMigrationGuard?.assertBaseWritable(baseId); + } + + private async assertTableWritable(tableId: string) { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + } + private async collectCrossSpaceAffectedFields( tableId: string ): Promise> { @@ -91,6 +103,7 @@ export class TableOpenApiV2Service { ro as unknown as Record, }) async createTable(baseId: string, createTableRo: ICreateTableWithDefault): Promise { + await this.assertBaseWritable(baseId); const container = await this.v2ContainerService.getContainerForBase(baseId); const commandBus = container.resolve(v2CoreTokens.commandBus); const context = await this.v2ContextFactory.createContext(container); @@ -123,6 +136,7 @@ export class TableOpenApiV2Service { tableId: string, mode: 'soft' | 'permanent' = 'soft' ): Promise { + await this.assertBaseWritable(baseId); const container = await this.v2ContainerService.getContainerForBase(baseId); const commandBus = container.resolve(v2CoreTokens.commandBus); const context = await this.v2ContextFactory.createContext(container); @@ -149,6 +163,7 @@ export class TableOpenApiV2Service { } async restoreTable(baseId: string, tableId: string): Promise { + await this.assertBaseWritable(baseId); const container = await this.v2ContainerService.getContainerForBase(baseId); const commandBus = container.resolve(v2CoreTokens.commandBus); const context = await this.v2ContextFactory.createContext(container); @@ -185,6 +200,8 @@ export class TableOpenApiV2Service { tableId: string, duplicateTableRo: IDuplicateTableRo ): Promise { + await this.assertBaseWritable(baseId); + await this.assertTableWritable(tableId); // The v2 duplicate command does not run cross-space validation when // creating fields, so a table containing any cross-space link would // silently produce another cross-space copy. Delegate to the v1 path, 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 86297db571..5f85defa9a 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 @@ -1,4 +1,4 @@ -import { NotFoundException, Injectable, Logger } from '@nestjs/common'; +import { Inject, NotFoundException, Injectable, Logger, Optional } from '@nestjs/common'; import type { FieldAction, IFieldRo, @@ -60,6 +60,7 @@ import { createFieldInstanceByVo } from '../../field/model/factory'; 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 { SpaceDataDbMigrationGuardService } from '../../space/space-data-db-migration-guard.service'; import { ViewOpenApiService } from '../../view/open-api/view-open-api.service'; import { TableDuplicateService } from '../table-duplicate.service'; import { TableService } from '../table.service'; @@ -87,12 +88,23 @@ export class TableOpenApiService { private readonly cls: ClsService, private readonly eventEmitterService: EventEmitterService, private readonly tableMutationCacheInvalidator: TableMutationCacheInvalidator, - private readonly audit: AuditScope + private readonly audit: AuditScope, + @Optional() + @Inject(SpaceDataDbMigrationGuardService) + private readonly spaceDataDbMigrationGuard?: SpaceDataDbMigrationGuardService ) {} + private async assertBaseWritable(baseId: string) { + await this.spaceDataDbMigrationGuard?.assertBaseWritable(baseId); + } + + private async assertTableWritable(tableId: string) { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + } + private async createView(tableId: string, viewRos: IViewRo[]) { const viewCreationPromises = viewRos.map(async (viewRo) => { - return this.viewOpenApiService.createView(tableId, viewRo); + return this.viewOpenApiService.createView(tableId, viewRo, { ensureRowOrder: false }); }); return await Promise.all(viewCreationPromises); } @@ -280,6 +292,7 @@ export class TableOpenApiService { } async createTable(baseId: string, tableRo: ICreateTableWithDefault): Promise { + await this.assertBaseWritable(baseId); let createdTable: ITableVo | undefined; const schema = await this.prismaService .$tx(async () => { @@ -364,6 +377,8 @@ export class TableOpenApiService { } async duplicateTable(baseId: string, tableId: string, tableRo: IDuplicateTableRo) { + await this.assertBaseWritable(baseId); + await this.assertTableWritable(tableId); return await this.tableDuplicateService.duplicateTable(baseId, tableId, tableRo); } @@ -427,6 +442,7 @@ export class TableOpenApiService { } async permanentDeleteTables(baseId: string, tableIds: string[]) { + await this.assertBaseWritable(baseId); // If the table has already been deleted, exceptions may occur // If the table hasn't been deleted and permanent deletion is executed directly, // we need to handle the deletion of associated data @@ -575,6 +591,7 @@ export class TableOpenApiService { } async deleteTable(baseId: string, tableId: string) { + await this.assertBaseWritable(baseId); try { await this.detachLink(tableId); } catch (e) { @@ -604,6 +621,7 @@ export class TableOpenApiService { } async restoreTable(baseId: string, tableId: string) { + await this.assertBaseWritable(baseId); return await this.prismaService.$tx( async (prisma) => { const { deletedTime } = await prisma.trash.findFirstOrThrow({ @@ -684,6 +702,7 @@ export class TableOpenApiService { } async updateDbTableName(baseId: string, tableId: string, dbTableNameRo: string) { + await this.assertBaseWritable(baseId); const dbTableName = this.dbProvider.joinDbTableName(baseId, dbTableNameRo); const existDbTableName = await this.prismaService.tableMeta .findFirst({ @@ -800,6 +819,7 @@ export class TableOpenApiService { } async shuffle(baseId: string) { + await this.assertBaseWritable(baseId); const tables = await this.prismaService.tableMeta.findMany({ where: { baseId, deletedTime: null, provisionState: ProvisionState.ready }, select: { id: true }, @@ -817,6 +837,7 @@ export class TableOpenApiService { } async updateOrder(baseId: string, tableId: string, orderRo: IUpdateOrderRo) { + await this.assertBaseWritable(baseId); const { anchorId, position } = orderRo; const tablesOrder = await this.prismaService.txClient().tableMeta.findMany({ diff --git a/apps/nestjs-backend/src/features/table/table-duplicate.service.spec.ts b/apps/nestjs-backend/src/features/table/table-duplicate.service.spec.ts index b51607352f..8fc2b9cf58 100644 --- a/apps/nestjs-backend/src/features/table/table-duplicate.service.spec.ts +++ b/apps/nestjs-backend/src/features/table/table-duplicate.service.spec.ts @@ -2,8 +2,100 @@ import { CellValueType, DbFieldType, FieldType, Relationship } from '@teable/cor import Knex from 'knex'; import type { Knex as KnexType } from 'knex'; import { vi } from 'vitest'; +import { DuplicateTableQueryPostgres } from '../../db-provider/duplicate-table/duplicate-query.postgres'; import { TableDuplicateService } from './table-duplicate.service'; +describe('TableDuplicateService.duplicateTableData', () => { + it('skips target-only columns when copying rows', async () => { + const dataKnex = Knex({ client: 'pg' }); + const executedSql: string[] = []; + const sourceColumnSql = 'source-column-info'; + const targetColumnSql = 'target-column-info'; + const dataPrisma = { + $queryRawUnsafe: vi.fn(async (sql: string): Promise => { + if (sql === sourceColumnSql) { + return [ + { name: '__id' }, + { name: '__version' }, + { name: '__auto_number' }, + { name: 'Name' }, + { name: 'Visible_Fields' }, + ] as T; + } + if (sql === targetColumnSql) { + return [ + { name: '__id' }, + { name: '__version' }, + { name: '__auto_number' }, + { name: 'Name' }, + { name: 'Visible_Fields' }, + { name: 'AJ_Story_Fields' }, + ] as T; + } + if (sql.includes('count(*)')) { + return [{ count: 1 }] as T; + } + return [] as T; + }), + $executeRawUnsafe: vi.fn(async (sql: string) => { + executedSql.push(sql); + return 1; + }), + }; + const fieldFindMany = vi.fn().mockResolvedValue([]); + const service = Object.create(TableDuplicateService.prototype) as TableDuplicateService; + + ( + service as unknown as { + prismaService: { + txClient: () => { + tableMeta: { findFirst: () => Promise<{ id: string }> }; + field: { findMany: typeof fieldFindMany }; + }; + }; + dbProvider: { + columnInfo: (tableName: string) => string; + duplicateTableQuery: (queryBuilder: KnexType.QueryBuilder) => DuplicateTableQueryPostgres; + }; + dataKnex: KnexType; + } + ).prismaService = { + txClient: () => ({ + tableMeta: { findFirst: async () => ({ id: 'tblTarget' }) }, + field: { findMany: fieldFindMany }, + }), + }; + ( + service as unknown as { + dbProvider: { + columnInfo: (tableName: string) => string; + duplicateTableQuery: (queryBuilder: KnexType.QueryBuilder) => DuplicateTableQueryPostgres; + }; + } + ).dbProvider = { + columnInfo: (tableName: string) => + tableName === 'bseSource.SourceTable' ? sourceColumnSql : targetColumnSql, + duplicateTableQuery: (queryBuilder) => new DuplicateTableQueryPostgres(queryBuilder), + }; + (service as unknown as { dataKnex: KnexType }).dataKnex = dataKnex; + + await service.duplicateTableData( + 'bseSource.SourceTable', + 'bseTarget.TargetTable', + {}, + {}, + [], + dataPrisma + ); + + expect(executedSql).toHaveLength(1); + expect(executedSql[0]).toContain('"Name", "Visible_Fields"'); + expect(executedSql[0]).not.toContain('AJ_Story_Fields'); + + await dataKnex.destroy(); + }); +}); + describe('TableDuplicateService.duplicateLinkJunction', () => { const createLinkFieldRaw = ({ id, diff --git a/apps/nestjs-backend/src/features/table/table-duplicate.service.ts b/apps/nestjs-backend/src/features/table/table-duplicate.service.ts index 6140c764d9..24e0f3af3e 100644 --- a/apps/nestjs-backend/src/features/table/table-duplicate.service.ts +++ b/apps/nestjs-backend/src/features/table/table-duplicate.service.ts @@ -334,6 +334,8 @@ export class TableDuplicateService { !name.startsWith('__fk_fld') && !computedSet.has(name) ); + const sourceColumnSet = new Set(allSourceColumns); + const copyableFieldColumns = newFieldColumns.filter((name) => sourceColumnSet.has(name)); const oldFkColumns = oldOriginColumns.filter((name) => name.startsWith('__fk_fld')); @@ -381,12 +383,12 @@ export class TableDuplicateService { // use new table field columns info // old table contains ghost columns or customer columns - const oldColumns = newFieldColumns + const oldColumns = copyableFieldColumns .concat(oldRowColumns) .concat(oldFkColumns) .filter((dbFieldName) => !excludeColumnsSet.has(dbFieldName)); - const newColumns = newFieldColumns + const newColumns = copyableFieldColumns .concat(newRowColumns) .concat(newFkColumns) .filter((dbFieldName) => !excludeColumnsSet.has(dbFieldName)); diff --git a/apps/nestjs-backend/src/features/table/table-freeze.service.spec.ts b/apps/nestjs-backend/src/features/table/table-freeze.service.spec.ts new file mode 100644 index 0000000000..7056a261f2 --- /dev/null +++ b/apps/nestjs-backend/src/features/table/table-freeze.service.spec.ts @@ -0,0 +1,57 @@ +import { HttpErrorCode } from '@teable/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CustomHttpException } from '../../custom.exception'; +import { TableService } from './table.service'; + +describe('TableService write freeze', () => { + const freezeError = new CustomHttpException( + 'Space data database migration is in progress', + HttpErrorCode.CONFLICT, + { + errorCode: 'SPACE_DATA_DB_MIGRATING', + migrationJobId: 'sdmjxxx', + } + ); + const prismaService = { + txClient: vi.fn(), + }; + const batchService = { + saveRawOps: vi.fn(), + }; + const migrationGuard = { + assertBaseWritable: vi.fn(), + }; + + const service = () => + new TableService( + { get: vi.fn().mockReturnValue('usrxxx') } as never, + prismaService as never, + {} as never, + batchService as never, + { driver: 'pg' } as never, + {} as never, + migrationGuard as never + ); + + beforeEach(() => { + vi.clearAllMocks(); + migrationGuard.assertBaseWritable.mockRejectedValue(freezeError); + }); + + it('rejects table creation before locking or metadata writes when the base space is migrating', async () => { + await expect( + service().createTable('bsexxx', { name: 'Blocked table', fields: [] } as never) + ).rejects.toBe(freezeError); + + expect(migrationGuard.assertBaseWritable).toHaveBeenCalledWith('bsexxx'); + expect(prismaService.txClient).not.toHaveBeenCalled(); + expect(batchService.saveRawOps).not.toHaveBeenCalled(); + }); + + it('rejects table deletion before metadata writes when the base space is migrating', async () => { + await expect(service().deleteTable('bsexxx', 'tblxxx', new Date())).rejects.toBe(freezeError); + + expect(migrationGuard.assertBaseWritable).toHaveBeenCalledWith('bsexxx'); + expect(prismaService.txClient).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/nestjs-backend/src/features/table/table.service.ts b/apps/nestjs-backend/src/features/table/table.service.ts index 1e4808a6cc..f201e60de1 100644 --- a/apps/nestjs-backend/src/features/table/table.service.ts +++ b/apps/nestjs-backend/src/features/table/table.service.ts @@ -1,5 +1,5 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; import type { IOtOperation, ISnapshotBase } from '@teable/core'; import { DriverClient, @@ -26,6 +26,7 @@ import { RawOpType } from '../../share-db/interface'; import type { IClsStore } from '../../types/cls'; import { convertNameToValidCharacter } from '../../utils/name-conversion'; import { BatchService } from '../calculation/batch.service'; +import { SpaceDataDbMigrationGuardService } from '../space/space-data-db-migration-guard.service'; type IDataPrismaExecutor = { $executeRawUnsafe(query: string, ...values: unknown[]): PromiseLike; @@ -45,9 +46,15 @@ export class TableService implements IReadonlyAdapterService { private readonly dataDbClientManager: DataDbClientManager, private readonly batchService: BatchService, @InjectDbProvider() private readonly dbProvider: IDbProvider, - @InjectModel(DATA_KNEX) private readonly knex: Knex + @InjectModel(DATA_KNEX) private readonly knex: Knex, + @Optional() + private readonly spaceDataDbMigrationGuard?: SpaceDataDbMigrationGuardService ) {} + private async assertBaseWritable(baseId: string) { + await this.spaceDataDbMigrationGuard?.assertBaseWritable(baseId); + } + generateValidName(name: string) { return convertNameToValidCharacter(name, 40); } @@ -82,6 +89,7 @@ export class TableService implements IReadonlyAdapterService { private async createDBTable(baseId: string, tableRo: ICreateTableRo, createTable = true) { const userId = this.cls.get('user.id'); + await this.assertBaseWritable(baseId); await this.lockBaseRow(baseId); const tableRaws = await this.prismaService.txClient().tableMeta.findMany({ @@ -305,6 +313,7 @@ export class TableService implements IReadonlyAdapterService { } async deleteTable(baseId: string, tableId: string, deletedTime: Date) { + await this.assertBaseWritable(baseId); const result = await this.prismaService.txClient().tableMeta.findFirst({ where: { id: tableId, baseId, deletedTime: null }, }); @@ -340,6 +349,7 @@ export class TableService implements IReadonlyAdapterService { } async restoreTable(baseId: string, tableId: string) { + await this.assertBaseWritable(baseId); const result = await this.prismaService.txClient().tableMeta.findFirst({ where: { id: tableId, baseId, deletedTime: { not: null } }, }); @@ -386,6 +396,7 @@ export class TableService implements IReadonlyAdapterService { | 'views' > ) { + await this.assertBaseWritable(baseId); const select = Object.keys(input).reduce<{ [key: string]: boolean }>((acc, key) => { acc[key] = true; return acc; 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 new file mode 100644 index 0000000000..82851be345 --- /dev/null +++ b/apps/nestjs-backend/src/features/trash/trash-freeze.service.spec.ts @@ -0,0 +1,150 @@ +import { HttpErrorCode } from '@teable/core'; +import { TableTrashType, TrashType } from '@teable/openapi'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CustomHttpException } from '../../custom.exception'; +import { TrashService } from './trash.service'; + +describe('TrashService write freeze', () => { + const freezeError = new CustomHttpException( + 'Space data database migration is in progress', + HttpErrorCode.CONFLICT, + { + errorCode: 'SPACE_DATA_DB_MIGRATING', + migrationJobId: 'sdmjxxx', + } + ); + const txClient = { + trash: { + findUniqueOrThrow: vi.fn(), + deleteMany: vi.fn(), + }, + }; + const prismaService = { + $tx: vi.fn(async (fn: (client: typeof txClient) => Promise) => fn(txClient)), + $queryRawUnsafe: vi.fn().mockResolvedValue([]), + txClient: vi.fn(() => txClient), + }; + const dataDbClientManager = { + dataPrismaForTable: vi.fn(), + }; + const spaceService = { + permanentDeleteSpace: vi.fn(), + }; + const baseService = { + permanentDeleteBase: vi.fn(), + }; + const tableOpenApiService = { + permanentDeleteTables: vi.fn(), + restoreTable: vi.fn(), + }; + const migrationGuard = { + assertSpaceWritable: vi.fn(), + assertBaseWritable: vi.fn(), + assertTableWritable: vi.fn(), + }; + + const service = () => + new TrashService( + { del: vi.fn() } as never, + prismaService as never, + { get: vi.fn().mockReturnValue('usrxxx') } as never, + {} as never, + { validPermissions: vi.fn() } as never, + spaceService as never, + baseService as never, + tableOpenApiService as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + dataDbClientManager as never, + {} as never, + {} as never, + migrationGuard as never + ); + + beforeEach(() => { + vi.clearAllMocks(); + migrationGuard.assertSpaceWritable.mockRejectedValue(freezeError); + migrationGuard.assertBaseWritable.mockRejectedValue(freezeError); + migrationGuard.assertTableWritable.mockRejectedValue(freezeError); + txClient.trash.findUniqueOrThrow.mockResolvedValue({ + id: 'trhxxx', + resourceId: 'bsexxx', + resourceType: TrashType.Base, + parentId: null, + }); + }); + + it('rejects meta trash restore before restoring or deleting trash metadata', async () => { + await expect(service().restoreTrash('trhxxx')).rejects.toBe(freezeError); + + expect(migrationGuard.assertBaseWritable).toHaveBeenCalledWith('bsexxx'); + expect(baseService.permanentDeleteBase).not.toHaveBeenCalled(); + expect(tableOpenApiService.restoreTable).not.toHaveBeenCalled(); + expect(txClient.trash.deleteMany).not.toHaveBeenCalled(); + }); + + it('rejects data-plane table trash restore before reading target table trash rows', async () => { + await expect(service().restoreTableResource('optrhxxx', 'tblxxx')).rejects.toBe(freezeError); + + expect(migrationGuard.assertTableWritable).toHaveBeenCalledWith('tblxxx'); + expect(dataDbClientManager.dataPrismaForTable).not.toHaveBeenCalled(); + }); + + it('rejects trash reset before resetting table trash rows', async () => { + await expect( + service().resetTrashItems({ resourceType: TrashType.Table, resourceId: 'tblxxx' }) + ).rejects.toBe(freezeError); + + expect(migrationGuard.assertTableWritable).toHaveBeenCalledWith('tblxxx'); + expect(dataDbClientManager.dataPrismaForTable).not.toHaveBeenCalled(); + }); + + it('rejects permanent delete before deleting base or table resources', async () => { + await expect( + service().deleteResource({ + resourceType: TrashType.Table, + resourceId: 'tblxxx', + parentId: 'bsexxx', + }) + ).rejects.toBe(freezeError); + await expect( + service().deleteResource({ resourceType: TrashType.Base, resourceId: 'bsexxx' }) + ).rejects.toBe(freezeError); + + expect(migrationGuard.assertBaseWritable).toHaveBeenCalledWith('bsexxx'); + expect(tableOpenApiService.permanentDeleteTables).not.toHaveBeenCalled(); + expect(baseService.permanentDeleteBase).not.toHaveBeenCalled(); + }); + + it('rejects restoring table-scoped trash snapshots before field, view, or record writes', async () => { + const tableTrash = { + tableId: 'tblxxx', + resourceType: TableTrashType.Field, + snapshot: JSON.stringify([{ id: 'fldxxx' }]), + createdTime: new Date(), + }; + dataDbClientManager.dataPrismaForTable.mockResolvedValue({ + tableTrash: { + findUniqueOrThrow: vi.fn().mockResolvedValue(tableTrash), + delete: vi.fn(), + }, + recordTrash: { + findMany: vi.fn(), + deleteMany: vi.fn(), + }, + }); + + await expect(service().restoreTableResource('optrhxxx', 'tblxxx')).rejects.toBe(freezeError); + + expect(migrationGuard.assertTableWritable).toHaveBeenCalledWith('tblxxx'); + expect(dataDbClientManager.dataPrismaForTable).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/nestjs-backend/src/features/trash/trash.controller.ts b/apps/nestjs-backend/src/features/trash/trash.controller.ts index b397c97031..75d29f0d8e 100644 --- a/apps/nestjs-backend/src/features/trash/trash.controller.ts +++ b/apps/nestjs-backend/src/features/trash/trash.controller.ts @@ -1,5 +1,6 @@ import { Controller, Delete, Get, Param, Post, Query, Res } from '@nestjs/common'; -import type { ITrashVo } from '@teable/openapi'; +import { IdPrefix } from '@teable/core'; +import type { IRestoreFieldTrashStreamEvent, ITrashVo, V2Feature } from '@teable/openapi'; import { ITrashRo, trashItemsRoSchema, @@ -22,7 +23,7 @@ import { TrashService } from './trash.service'; @Controller('api/trash/') export class TrashController { - protected static readonly restoreTableV2Feature = 'restoreTable'; + protected static readonly restoreTableV2Feature: V2Feature = 'restoreTable'; constructor( private readonly trashService: TrashService, @@ -49,13 +50,28 @@ export class TrashController { @Query('tableId') tableId: string | undefined, @Res({ passthrough: true }) response: Response ): Promise { - await this.prepareRestoreTableCanary(trashId, response); + await this.prepareRestoreTableCanary(trashId, tableId, response); if (this.cls.get('useV2')) { + if (trashId.startsWith(IdPrefix.Operation)) { + return await this.trashService.restoreTableResourceV2(trashId, tableId); + } return await this.trashService.restoreTrashV2(trashId); } return await this.trashService.restoreTrash(trashId, tableId); } + @Post('restore-field/:trashId/stream') + @TokenAccess() + async restoreFieldTrashStream( + @Param('trashId') trashId: string, + @Query('tableId') tableId: string | undefined, + @Res() response: Response + ): Promise { + this.prepareRestoreFieldV2Headers(response); + const stream = this.trashService.restoreFieldTableResourceV2Stream(trashId, tableId); + await this.streamRestoreResponse(response, stream); + } + @Delete('reset-items') @TokenAccess() async resetTrashItems( @@ -70,18 +86,103 @@ export class TrashController { return await this.trashService.delete(trashId); } - protected async prepareRestoreTableCanary(trashId: string, response: Response): Promise { - const decision = await this.trashService.getRestoreTableV2Decision(trashId); + protected async prepareRestoreTableCanary( + trashId: string, + tableId: string | undefined, + response: Response + ): Promise { + const decision = trashId.startsWith(IdPrefix.Operation) + ? await this.trashService.getRestoreTableResourceV2Decision(trashId, tableId) + : await this.trashService.getRestoreTableV2Decision(trashId); if (!decision) { return; } + const feature = + 'feature' in decision ? decision.feature : TrashController.restoreTableV2Feature; this.cls.set('useV2', decision.useV2); - this.cls.set('v2Feature', TrashController.restoreTableV2Feature); + this.cls.set('v2Feature', feature); this.cls.set('v2Reason', decision.reason); response.setHeader(X_TEABLE_V2_HEADER, decision.useV2 ? 'true' : 'false'); - response.setHeader(X_TEABLE_V2_FEATURE_HEADER, TrashController.restoreTableV2Feature); + response.setHeader(X_TEABLE_V2_FEATURE_HEADER, feature); response.setHeader(X_TEABLE_V2_REASON_HEADER, decision.reason); } + + protected prepareRestoreFieldV2Headers(response: Response): void { + const feature: V2Feature = 'createField'; + const reason = 'header_override'; + this.cls.set('useV2', true); + this.cls.set('v2Feature', feature); + this.cls.set('v2Reason', reason); + + response.setHeader(X_TEABLE_V2_HEADER, 'true'); + response.setHeader(X_TEABLE_V2_FEATURE_HEADER, feature); + response.setHeader(X_TEABLE_V2_REASON_HEADER, reason); + } + + protected prepareRestoreStreamResponse(response: Response) { + response.setHeader('Content-Type', 'text/event-stream'); + response.setHeader('Cache-Control', 'no-cache, no-transform'); + response.setHeader('Connection', 'keep-alive'); + response.setHeader('X-Accel-Buffering', 'no'); + response.flushHeaders(); + } + + protected isRestoreStreamClosed(response: Response) { + return response.writableEnded || response.destroyed; + } + + protected sendRestoreSseEvent(response: Response, data: IRestoreFieldTrashStreamEvent) { + if (this.isRestoreStreamClosed(response)) { + return; + } + + response.write(`data: ${JSON.stringify(data)}\n\n`); + (response as Response & { flush?: () => void }).flush?.(); + } + + protected startRestoreHeartbeat(response: Response) { + const heartbeat = setInterval(() => { + if (this.isRestoreStreamClosed(response)) { + return; + } + + response.write(': ping\n\n'); + (response as Response & { flush?: () => void }).flush?.(); + }, 15_000); + + response.on('close', () => clearInterval(heartbeat)); + return heartbeat; + } + + protected async streamRestoreResponse( + response: Response, + stream: AsyncIterable + ) { + this.prepareRestoreStreamResponse(response); + const heartbeat = this.startRestoreHeartbeat(response); + + try { + for await (const event of stream) { + if (this.isRestoreStreamClosed(response)) { + break; + } + this.sendRestoreSseEvent(response, event); + } + } catch (error) { + this.sendRestoreSseEvent(response, { + id: 'error', + phase: 'restoring', + batchIndex: -1, + totalCount: 0, + processedCount: 0, + updatedCount: 0, + message: error instanceof Error ? error.message : 'Restore stream failed', + }); + } finally { + clearInterval(heartbeat); + response.end(); + } + } } diff --git a/apps/nestjs-backend/src/features/trash/trash.service.ts b/apps/nestjs-backend/src/features/trash/trash.service.ts index 56f6056010..e04c936b6f 100644 --- a/apps/nestjs-backend/src/features/trash/trash.service.ts +++ b/apps/nestjs-backend/src/features/trash/trash.service.ts @@ -1,23 +1,32 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { Injectable } from '@nestjs/common'; +import { Injectable, Optional } from '@nestjs/common'; import type { FieldType, IFieldVo } from '@teable/core'; import { FieldKeyType, HttpErrorCode, IdPrefix, Role } from '@teable/core'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { PrismaService, type Prisma } from '@teable/db-main-prisma'; import type { + IRestoreFieldTrashStreamEvent, IResetTrashItemsRo, IResourceMapVo, ITrashItemsRo, ITrashItemVo, ITrashRo, ITrashVo, + V2Feature, } from '@teable/openapi'; -import { CollaboratorType, TableTrashType, TrashType } from '@teable/openapi'; -import { RestoreRecordsCommand, TableId, v2CoreTokens } from '@teable/v2-core'; +import { CollaboratorType, ResourceType, TableTrashType, TrashType } from '@teable/openapi'; +import { + RestoreFieldStreamCommand, + RestoreRecordsCommand, + RestoreRecordsStreamCommand, + TableId, + v2CoreTokens, +} from '@teable/v2-core'; import type { ICommandBus, + RestoreFieldStreamResult, RestoreRecordInput, RestoreRecordsResult, + RestoreRecordsStreamResult, Table, TableQueryService, } from '@teable/v2-core'; @@ -37,9 +46,13 @@ import type { IClsStore } from '../../types/cls'; import { PermissionService } from '../auth/permission.service'; import { BaseService } from '../base/base.service'; import { CanaryService, type IV2Decision } from '../canary/canary.service'; +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 { RecordService } from '../record/record.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'; @@ -52,15 +65,106 @@ import { resolveV2TrashRecordDisplayName } from './v2-trash-record-name'; type IRecordTrashSnapshot = IDeleteRecordsPayload['records'][number]; +type IRestoreProgressInput = { + phase: 'preparing' | 'restoring'; + batchIndex: number; + totalCount?: number; + processedCount?: number; + restoredCount?: number; + updatedCount?: number; +}; + +type IRestoreDoneInput = { + totalCount?: number; + restoredCount?: number; + updatedCount?: number; +}; + +type IRestoreErrorInput = { + phase: 'preparing' | 'restoring' | 'finalizing'; + message: string; + batchIndex?: number; + totalCount?: number; + processedCount?: number; + restoredCount?: number; + updatedCount?: number; + code?: string; +}; + +type IRestoreTrashStreamEvent = + | (IRestoreProgressInput & { + id: 'progress'; + resourceType: ResourceType; + totalCount: number; + processedCount: number; + restoredCount: number; + updatedCount: number; + }) + | { + id: 'done'; + resourceType: ResourceType; + totalCount: number; + restoredCount: number; + updatedCount: number; + } + | (IRestoreErrorInput & { + id: 'error'; + resourceType: ResourceType; + batchIndex: number; + totalCount: number; + processedCount: number; + restoredCount: number; + updatedCount: number; + }); + +type ITableTrashDelegate = { + findMany(args: TArgs): Promise< + Array<{ + id: string; + tableId: string; + resourceType: string; + snapshot: string; + createdBy: string; + createdTime: Date; + }> + >; + findUniqueOrThrow(args: TArgs): Promise<{ + tableId: string; + resourceType: string; + snapshot: string; + createdTime: Date; + }>; + delete(args: TArgs): Promise; + deleteMany(args: TArgs): Promise; +}; + +type IRecordTrashDelegate = { + findMany(args: TArgs): Promise< + Array<{ + id: string; + recordId: string; + snapshot: string; + createdTime: Date; + }> + >; + deleteMany(args: TArgs): Promise; +}; + type ITrashDataPrisma = { - tableTrash: DataPrismaService['tableTrash']; - recordTrash: DataPrismaService['recordTrash']; + tableTrash: ITableTrashDelegate; + recordTrash: IRecordTrashDelegate; }; type IScopedTrashDataPrisma = ITrashDataPrisma & { txClient?: () => ITrashDataPrisma; - $tx?: DataPrismaService['$tx']; - $transaction?: DataPrismaService['$transaction']; + $tx?: ( + fn: (prisma: ITrashDataPrisma) => Promise, + options?: { timeout?: number } + ) => Promise; + $transaction?: ( + fn: (prisma: ITrashDataPrisma) => Promise, + options?: { timeout?: number } + ) => Promise; }; @Injectable() @@ -76,7 +180,9 @@ export class TrashService { protected readonly tableOpenApiService: TableOpenApiService, protected readonly tableOpenApiV2Service: TableOpenApiV2Service, protected readonly fieldOpenApiService: FieldOpenApiService, + protected readonly fieldOpenApiV2Service: FieldOpenApiV2Service, protected readonly recordOpenApiService: RecordOpenApiService, + protected readonly recordOpenApiV2Service: RecordOpenApiV2Service, protected readonly recordService: RecordService, protected readonly viewService: ViewService, protected readonly v2ContainerService: V2ContainerService, @@ -84,9 +190,40 @@ export class TrashService { protected readonly canaryService: CanaryService, protected readonly dataDbClientManager: DataDbClientManager, @ThresholdConfig() protected readonly thresholdConfig: IThresholdConfig, - @InjectModel(META_KNEX) protected readonly knex: Knex + @InjectModel(META_KNEX) protected readonly knex: Knex, + @Optional() + protected readonly spaceDataDbMigrationGuard?: SpaceDataDbMigrationGuardService ) {} + private async assertSpaceWritable(spaceId: string) { + await this.spaceDataDbMigrationGuard?.assertSpaceWritable(spaceId); + } + + private async assertBaseWritable(baseId: string) { + await this.spaceDataDbMigrationGuard?.assertBaseWritable(baseId); + } + + private async assertTableWritable(tableId: string) { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + } + + private async assertTrashResourceWritable( + resourceType: TrashType, + resourceId: string, + parentId?: string | null + ) { + switch (resourceType) { + case TrashType.Space: + return await this.assertSpaceWritable(resourceId); + case TrashType.Base: + return await this.assertBaseWritable(resourceId); + case TrashType.Table: + return parentId + ? await this.assertBaseWritable(parentId) + : await this.assertTableWritable(resourceId); + } + } + private getTrashDataPrismaExecutor(prisma: IScopedTrashDataPrisma): ITrashDataPrisma { return prisma.txClient?.() ?? prisma; } @@ -760,6 +897,87 @@ export class TrashService { }; } + async getRestoreTableResourceV2Decision( + trashId: string, + routedTableId?: string + ): Promise< + | (IV2Decision & { + tableId: string; + resourceType: TableTrashType; + feature: V2Feature; + }) + | undefined + > { + if (!trashId.startsWith(IdPrefix.Operation) || !routedTableId) { + return undefined; + } + + const lookupDataPrisma = this.getTrashDataPrismaExecutor( + await this.trashDataPrismaForTable(routedTableId) + ); + const tableTrash = await lookupDataPrisma.tableTrash + .findUniqueOrThrow({ + where: { id: trashId }, + select: { + tableId: true, + resourceType: true, + }, + }) + .catch(() => undefined); + + if (!tableTrash) { + return undefined; + } + + const feature = this.getRestoreTableResourceV2Feature( + tableTrash.resourceType as TableTrashType + ); + if (!feature) { + return undefined; + } + + const table = await this.prismaService.txClient().tableMeta.findFirst({ + where: { id: tableTrash.tableId, deletedTime: null }, + select: { + base: { + select: { + spaceId: true, + v2Enabled: true, + }, + }, + }, + }); + + if (!table?.base?.spaceId) { + return { + useV2: false, + reason: 'disabled', + tableId: tableTrash.tableId, + resourceType: tableTrash.resourceType as TableTrashType, + feature, + }; + } + + const decision = await this.canaryService.shouldUseV2ForBaseWithReason(table.base, feature); + return { + ...decision, + tableId: tableTrash.tableId, + resourceType: tableTrash.resourceType as TableTrashType, + feature, + }; + } + + private getRestoreTableResourceV2Feature(resourceType: TableTrashType): V2Feature | undefined { + switch (resourceType) { + case TableTrashType.Field: + return 'createField'; + case TableTrashType.Record: + return 'createRecord'; + default: + return undefined; + } + } + async restoreTrashV2(trashId: string) { const decision = await this.getRestoreTableV2Decision(trashId); if (!decision) { @@ -770,6 +988,7 @@ export class TrashService { }); } + await this.assertBaseWritable(decision.baseId); await this.assertParentNotTrashed(decision.baseId); await this.restoreTableV2(decision.baseId, decision.tableId); } @@ -781,8 +1000,334 @@ export class TrashService { this.performanceCacheService.del(generateBaseNodeListCacheKey(baseId)); } + async restoreTableResourceV2(trashId: string, routedTableId?: string) { + const decision = await this.getRestoreTableResourceV2Decision(trashId, routedTableId); + if (!decision?.useV2) { + return await this.restoreTableResource(trashId, routedTableId); + } + + switch (decision.resourceType) { + case TableTrashType.Field: + return await this.restoreFieldTableResourceV2(trashId, routedTableId); + case TableTrashType.Record: + for await (const event of await this.restoreRecordTableResourceV2Stream( + trashId, + routedTableId + )) { + if (event.id === 'error') { + throw new CustomHttpException(event.message, HttpErrorCode.INTERNAL_SERVER_ERROR); + } + } + return; + default: + throw new CustomHttpException( + `Invalid resource type ${decision.resourceType}`, + HttpErrorCode.VALIDATION_ERROR, + { + localization: { + i18nKey: 'httpErrors.trash.invalidResourceType', + }, + } + ); + } + } + + private async restoreFieldTableResourceV2(trashId: string, routedTableId?: string) { + for await (const event of this.restoreFieldTableResourceV2Stream(trashId, routedTableId)) { + if (event.id === 'error') { + throw new CustomHttpException(event.message, HttpErrorCode.INTERNAL_SERVER_ERROR); + } + } + } + + async *restoreFieldTableResourceV2Stream( + trashId: string, + routedTableId?: string + ): AsyncGenerator { + if (!routedTableId) { + yield { + id: 'error', + phase: 'preparing', + batchIndex: -1, + totalCount: 0, + processedCount: 0, + updatedCount: 0, + message: `Table id is required to restore table trash ${trashId}`, + }; + return; + } + + const container = await this.v2ContainerService.getContainerForTable(routedTableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ExecutionContextFactory.createContext(container); + const commandResult = RestoreFieldStreamCommand.create({ + tableId: routedTableId, + trashId, + }); + if (commandResult.isErr()) { + yield { + id: 'error', + phase: 'preparing', + batchIndex: -1, + totalCount: 0, + processedCount: 0, + updatedCount: 0, + message: commandResult.error.message, + code: commandResult.error.code, + }; + return; + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + yield { + id: 'error', + phase: 'restoring', + batchIndex: -1, + totalCount: 0, + processedCount: 0, + updatedCount: 0, + message: result.error.message, + code: result.error.code, + }; + return; + } + + for await (const event of result.value) { + yield event; + } + } + + private async *restoreRecordTableResourceV2Stream( + trashId: string, + routedTableId?: string + ): AsyncGenerator { + const accessTokenId = this.cls.get('accessTokenId'); + if (!routedTableId) { + yield this.createRestoreErrorEvent(ResourceType.Record, { + phase: 'preparing', + message: `Table id is required to restore table trash ${trashId}`, + }); + return; + } + + await this.assertTableWritable(routedTableId); + const lookupDataPrisma = this.getTrashDataPrismaExecutor( + await this.trashDataPrismaForTable(routedTableId) + ); + const { + tableId, + resourceType, + snapshot: originSnapshot, + createdTime, + } = await lookupDataPrisma.tableTrash + .findUniqueOrThrow({ + where: { id: trashId }, + select: { + tableId: true, + resourceType: true, + snapshot: true, + createdTime: true, + }, + }) + .catch(() => { + throw new CustomHttpException( + `The table trash ${trashId} not found`, + HttpErrorCode.NOT_FOUND, + { + localization: { + i18nKey: 'httpErrors.trash.tableNotFound', + }, + } + ); + }); + if (tableId !== routedTableId) { + await this.assertTableWritable(tableId); + } + + if (resourceType !== TableTrashType.Record) { + yield this.createRestoreErrorEvent(ResourceType.Record, { + phase: 'preparing', + message: `Invalid resource type ${resourceType}`, + }); + return; + } + + await this.permissionService.validPermissions( + tableId, + ['table|trash_update'], + accessTokenId, + true + ); + + const recordIds = JSON.parse(originSnapshot) as string[]; + const recordTrashRows = await lookupDataPrisma.recordTrash.findMany({ + where: { tableId, recordId: { in: recordIds } }, + select: { + id: true, + recordId: true, + snapshot: true, + createdTime: true, + }, + orderBy: [{ recordId: 'asc' }, { createdTime: 'desc' }, { id: 'desc' }], + }); + const latestSnapshotsByRecordId = recordTrashRows.reduce< + Map + >((acc, row) => { + if (row.createdTime <= createdTime && !acc.has(row.recordId)) { + acc.set(row.recordId, row); + } + return acc; + }, new Map()); + const matchedRecordTrashRows = recordIds + .map((recordId) => latestSnapshotsByRecordId.get(recordId)) + .filter((row): row is (typeof recordTrashRows)[number] => row != null); + const records = matchedRecordTrashRows.map(({ snapshot }) => + this.toV2RestoreRecord(JSON.parse(snapshot)) + ); + + yield this.createRestoreProgressEvent(ResourceType.Record, { + phase: 'preparing', + batchIndex: -1, + totalCount: records.length, + }); + + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ExecutionContextFactory.createContext(container); + const commandResult = RestoreRecordsStreamCommand.create({ + tableId, + records: this.createRestoreRecordInputStream(records), + }); + if (commandResult.isErr()) { + yield this.createRestoreErrorEvent(ResourceType.Record, { + phase: 'preparing', + message: commandResult.error.message, + code: commandResult.error.code, + }); + return; + } + + const result = await commandBus.execute( + context, + commandResult.value + ); + if (result.isErr()) { + yield this.createRestoreErrorEvent(ResourceType.Record, { + phase: 'restoring', + message: result.error.message, + code: result.error.code, + }); + return; + } + + let restoredCount = 0; + for await (const event of result.value) { + if (event.id === 'progress') { + restoredCount = event.totalInserted; + yield this.createRestoreProgressEvent(ResourceType.Record, { + phase: 'restoring', + batchIndex: event.batchIndex, + totalCount: records.length, + processedCount: event.totalInserted, + restoredCount: event.totalInserted, + }); + } + if (event.id === 'error') { + yield this.createRestoreErrorEvent(ResourceType.Record, { + phase: event.phase, + batchIndex: event.batchIndex, + totalCount: records.length, + processedCount: event.totalInserted, + restoredCount: event.totalInserted, + message: event.message, + code: event.code, + }); + return; + } + if (event.id === 'done') { + restoredCount = event.restoredCount; + } + } + + await this.trashDataPrismaTransactionForTable(tableId, async (prisma) => { + await prisma.recordTrash.deleteMany({ + where: { id: { in: matchedRecordTrashRows.map(({ id }) => id) } }, + }); + await prisma.tableTrash.delete({ + where: { id: trashId }, + }); + }); + + yield this.createRestoreDoneEvent(ResourceType.Record, { + totalCount: records.length, + restoredCount, + }); + } + + private createRestoreRecordInputStream( + records: ReadonlyArray + ): AsyncIterable { + return (async function* () { + for (const record of records) { + yield record; + } + })(); + } + + private createRestoreProgressEvent( + resourceType: ResourceType, + event: IRestoreProgressInput + ): IRestoreTrashStreamEvent { + return { + id: 'progress', + phase: event.phase, + resourceType, + batchIndex: event.batchIndex, + totalCount: event.totalCount ?? 0, + processedCount: event.processedCount ?? 0, + restoredCount: event.restoredCount ?? 0, + updatedCount: event.updatedCount ?? 0, + }; + } + + private createRestoreDoneEvent( + resourceType: ResourceType, + event: IRestoreDoneInput = {} + ): IRestoreTrashStreamEvent { + return { + id: 'done', + resourceType, + totalCount: event.totalCount ?? 0, + restoredCount: event.restoredCount ?? 0, + updatedCount: event.updatedCount ?? 0, + }; + } + + private createRestoreErrorEvent( + resourceType: ResourceType, + event: IRestoreErrorInput + ): IRestoreTrashStreamEvent { + return { + id: 'error', + phase: event.phase, + resourceType, + batchIndex: event.batchIndex ?? -1, + totalCount: event.totalCount ?? 0, + processedCount: event.processedCount ?? 0, + restoredCount: event.restoredCount ?? 0, + updatedCount: event.updatedCount ?? 0, + message: event.message, + ...(event.code ? { code: event.code } : {}), + }; + } + async restoreResource(trash: { resourceType: TrashType; resourceId: string }) { const { resourceType, resourceId } = trash; + await this.assertTrashResourceWritable(resourceType, resourceId); switch (resourceType) { case TrashType.Space: return this.restoreSpace(resourceId); @@ -816,6 +1361,7 @@ export class TrashService { } ); } + await this.assertTableWritable(routedTableId); const lookupDataPrisma = this.getTrashDataPrismaExecutor( await this.trashDataPrismaForTable(routedTableId) ); @@ -846,6 +1392,9 @@ export class TrashService { } ); }); + if (tableId !== routedTableId) { + await this.assertTableWritable(tableId); + } const dataPrisma = routedTableId ? lookupDataPrisma : this.getTrashDataPrismaExecutor(await this.trashDataPrismaForTable(tableId)); @@ -874,12 +1423,7 @@ export class TrashService { ); const existingIdSet = new Set(existingSnapshots.map((s) => s.data.id)); const filteredRecords = records.filter((r) => existingIdSet.has(r.id)); - if (filteredRecords.length) { - await this.recordOpenApiService.updateRecords(tableId, { - fieldKeyType: FieldKeyType.Id, - records: filteredRecords, - }); - } + await restoreFieldRecordValues(tableId, filteredRecords, this.recordOpenApiService); } break; } @@ -923,6 +1467,14 @@ export class TrashService { 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; } @@ -991,9 +1543,9 @@ export class TrashService { return; } - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ExecutionContextFactory.createContext(); + const context = await this.v2ExecutionContextFactory.createContext(container); const commandResult = RestoreRecordsCommand.create({ tableId, @@ -1053,6 +1605,11 @@ export class TrashService { }); await this.assertParentNotTrashed(trash.parentId); + await this.assertTrashResourceWritable( + trash.resourceType as TrashType, + trash.resourceId, + trash.parentId + ); await this.restoreResource({ resourceType: trash.resourceType as TrashType, @@ -1107,6 +1664,8 @@ export class TrashService { ); } + await this.assertTrashResourceWritable(resourceType, resourceId); + if (resourceType === TrashType.Base) { await this.resetBaseTrashResource(resetTrashItemsRo); } @@ -1219,6 +1778,7 @@ export class TrashService { ignorePermissionCheck = false ): Promise { const { resourceType, resourceId, parentId } = trash; + await this.assertTrashResourceWritable(resourceType, resourceId, parentId); switch (resourceType) { case TrashType.Space: 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 b5cb59b919..441ad117f1 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 @@ -8,11 +8,16 @@ import { RecordsDeleted, TableRestored, TableTrashed, + domainError, + err, ok, type DomainError, type IEventHandler, type IExecutionContext, + type IFieldTrashRepository, type Result, + type FieldTrashSnapshot, + v2CoreTokens, } from '@teable/v2-core'; import type { DependencyContainer } from '@teable/v2-di'; import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; @@ -58,6 +63,80 @@ type ITableTrashDataDb = V1TeableDatabase & { }; /* eslint-enable @typescript-eslint/naming-convention */ +export class V2FieldTrashRepository implements IFieldTrashRepository { + constructor(private readonly db: Kysely) {} + + async getFieldTrash( + _context: IExecutionContext, + tableId: string, + trashId: string + ): Promise> { + const row = await this.db + .selectFrom('table_trash') + .where('id', '=', trashId) + .where('table_id', '=', tableId) + .select(['id', 'table_id', 'resource_type', 'snapshot']) + .executeTakeFirst(); + + if (!row) { + return err( + domainError.notFound({ + message: `The table trash ${trashId} not found`, + code: 'restore_field_stream.trash_not_found', + }) + ); + } + + if (row.resource_type !== ResourceType.Field) { + return err( + domainError.validation({ + message: `Invalid resource type ${row.resource_type}`, + code: 'restore_field_stream.invalid_resource_type', + }) + ); + } + + try { + const snapshot = JSON.parse(row.snapshot) as Partial; + if (!Array.isArray(snapshot.fields)) { + return err( + domainError.validation({ + message: 'Invalid field trash snapshot', + code: 'restore_field_stream.invalid_snapshot', + }) + ); + } + + return ok({ + trashId: row.id, + tableId: row.table_id, + fields: snapshot.fields, + records: Array.isArray(snapshot.records) ? snapshot.records : [], + }); + } catch (error) { + return err( + domainError.fromUnknown(error, { + code: 'restore_field_stream.invalid_snapshot_json', + }) + ); + } + } + + async deleteFieldTrash( + _context: IExecutionContext, + tableId: string, + trashId: string + ): Promise> { + await this.db + .deleteFrom('table_trash') + .where('id', '=', trashId) + .where('table_id', '=', tableId) + .execute(); + + return ok(undefined); + } +} + @ProjectionHandler(RecordsDeleted) export class V2RecordsDeletedTableTrashProjection implements IEventHandler { constructor(private readonly v2RecordTrashService: V2RecordTrashService) {} @@ -296,6 +375,10 @@ export class V2TableTrashService implements IV2ProjectionRegistrar { registerProjections(container: DependencyContainer): void { this.logger.log('Registering V2 trash projections'); + container.registerInstance( + v2CoreTokens.fieldTrashRepository, + new V2FieldTrashRepository(container.resolve>(v2DataDbTokens.db)) + ); container.registerInstance( V2RecordsDeletedTableTrashProjection, 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 new file mode 100644 index 0000000000..1b8aa5b32e --- /dev/null +++ b/apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo-freeze.service.spec.ts @@ -0,0 +1,117 @@ +import { HttpErrorCode } from '@teable/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CustomHttpException } from '../../../custom.exception'; +import { UndoRedoService } from './undo-redo.service'; + +describe('UndoRedoService write freeze', () => { + const freezeError = new CustomHttpException( + 'Space data database migration is in progress', + HttpErrorCode.CONFLICT, + { + errorCode: 'SPACE_DATA_DB_MIGRATING', + migrationJobId: 'sdmjxxx', + } + ); + const v2ContainerService = { + getContainerForTable: vi.fn(), + }; + const v2ContextFactory = { + createContext: vi.fn(), + }; + const cls = { + get: vi.fn(), + }; + const cacheService = { + get: vi.fn(), + }; + const undoRedoStackService = { + popUndo: vi.fn(), + popRedo: vi.fn(), + }; + const undoRedoOperationService = { + undo: vi.fn(), + redo: vi.fn(), + }; + const migrationGuard = { + assertTableWritable: vi.fn(), + }; + + const service = () => + new UndoRedoService( + v2ContainerService as never, + v2ContextFactory as never, + cls as never, + cacheService as never, + undoRedoStackService as never, + undoRedoOperationService as never, + migrationGuard as never + ); + + beforeEach(() => { + vi.clearAllMocks(); + migrationGuard.assertTableWritable.mockRejectedValue(freezeError); + }); + + it('rejects undo before reading engine preference or popping the undo stack', async () => { + await expect(service().undo('tblxxx', 'winxxx')).rejects.toBe(freezeError); + + expect(migrationGuard.assertTableWritable).toHaveBeenCalledWith('tblxxx'); + expect(cls.get).not.toHaveBeenCalled(); + expect(cacheService.get).not.toHaveBeenCalled(); + expect(undoRedoStackService.popUndo).not.toHaveBeenCalled(); + expect(v2ContainerService.getContainerForTable).not.toHaveBeenCalled(); + }); + + it('rejects redo before reading engine preference or popping the redo stack', async () => { + await expect(service().redo('tblxxx', 'winxxx')).rejects.toBe(freezeError); + + expect(migrationGuard.assertTableWritable).toHaveBeenCalledWith('tblxxx'); + expect(cls.get).not.toHaveBeenCalled(); + expect(cacheService.get).not.toHaveBeenCalled(); + expect(undoRedoStackService.popRedo).not.toHaveBeenCalled(); + expect(v2ContainerService.getContainerForTable).not.toHaveBeenCalled(); + }); + + it('rejects streaming undo and redo before starting stack replay', async () => { + const undoIterator = service().undoStream('tblxxx', 'winxxx')[Symbol.asyncIterator](); + const redoIterator = service().redoStream('tblxxx', 'winxxx')[Symbol.asyncIterator](); + + await expect(undoIterator.next()).rejects.toBe(freezeError); + await expect(redoIterator.next()).rejects.toBe(freezeError); + + expect(migrationGuard.assertTableWritable).toHaveBeenCalledTimes(2); + expect(migrationGuard.assertTableWritable).toHaveBeenCalledWith('tblxxx'); + expect(cls.get).not.toHaveBeenCalled(); + expect(cacheService.get).not.toHaveBeenCalled(); + expect(undoRedoStackService.popUndo).not.toHaveBeenCalled(); + expect(undoRedoStackService.popRedo).not.toHaveBeenCalled(); + expect(v2ContainerService.getContainerForTable).not.toHaveBeenCalled(); + }); + + it('allows non-migrating undo to continue through the normal stack path', async () => { + const push = vi.fn(); + const operation = { id: 'opxxx' }; + const reverseOperation = { id: 'opreverse' }; + + migrationGuard.assertTableWritable.mockResolvedValue(undefined); + cls.get.mockReturnValue('usrxxx'); + cacheService.get.mockResolvedValue('v1'); + undoRedoStackService.popUndo.mockResolvedValue({ + operation, + push, + }); + undoRedoOperationService.undo.mockResolvedValue(reverseOperation); + + await expect(service().undo('tblxxx', 'winxxx')).resolves.toEqual({ + body: { + status: 'fulfilled', + }, + engine: 'v1', + }); + + expect(migrationGuard.assertTableWritable).toHaveBeenCalledWith('tblxxx'); + expect(undoRedoStackService.popUndo).toHaveBeenCalledWith('tblxxx', 'winxxx'); + expect(undoRedoOperationService.undo).toHaveBeenCalledWith(operation); + expect(push).toHaveBeenCalledWith(reverseOperation); + }); +}); 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 89a48f1acf..adb6889e79 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,5 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; import type { IRedoVo, IUndoRedoStreamEvent, IUndoVo } from '@teable/openapi'; import { RedoCommand, @@ -18,6 +18,7 @@ import { ClsService } from 'nestjs-cls'; import { CacheService } from '../../../cache/cache.service'; import type { ICacheStore } from '../../../cache/types'; import type { IClsStore } from '../../../types/cls'; +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 { UndoRedoOperationService } from '../stack/undo-redo-operation.service'; @@ -93,10 +94,14 @@ export class UndoRedoService { private readonly cls: ClsService, private readonly cacheService: CacheService, private readonly undoRedoStackService: UndoRedoStackService, - private readonly undoRedoOperationService: UndoRedoOperationService + private readonly undoRedoOperationService: UndoRedoOperationService, + @Optional() + private readonly spaceDataDbMigrationGuard?: SpaceDataDbMigrationGuardService ) {} async undo(tableId: string, windowId: string): Promise> { + await this.assertTableWritable(tableId); + const preferredEngine = await this.getPreferredEngine(tableId, windowId); if (preferredEngine === 'v1') { const v1Result = await this.executeV1Undo(tableId, windowId); @@ -121,6 +126,8 @@ export class UndoRedoService { } async redo(tableId: string, windowId: string): Promise> { + await this.assertTableWritable(tableId); + const preferredEngine = await this.getPreferredEngine(tableId, windowId); if (preferredEngine === 'v1') { const v1Result = await this.executeV1Redo(tableId, windowId); @@ -145,13 +152,21 @@ export class UndoRedoService { } async *undoStream(tableId: string, windowId: string): AsyncIterable { + await this.assertTableWritable(tableId); + yield* this.executeUndoRedoStream(tableId, windowId, 'undo'); } async *redoStream(tableId: string, windowId: string): AsyncIterable { + await this.assertTableWritable(tableId); + yield* this.executeUndoRedoStream(tableId, windowId, 'redo'); } + private async assertTableWritable(tableId: string) { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + } + private async *executeUndoRedoStream( tableId: string, windowId: string, diff --git a/apps/nestjs-backend/src/features/undo-redo/operations/convert-field-v2.operation.ts b/apps/nestjs-backend/src/features/undo-redo/operations/convert-field-v2.operation.ts index 4fbe19d38b..6761ed09cb 100644 --- a/apps/nestjs-backend/src/features/undo-redo/operations/convert-field-v2.operation.ts +++ b/apps/nestjs-backend/src/features/undo-redo/operations/convert-field-v2.operation.ts @@ -110,7 +110,6 @@ export class ConvertFieldV2Operation { mode: 'undo' | 'redo' ) { await this.fieldOpenApiV2Service.convertField(tableId, fieldId, this.toConvertFieldRo(field), { - emitOperation: false, suppressWindowId: true, undoRedoMode: mode, }); diff --git a/apps/nestjs-backend/src/features/undo-redo/operations/create-fields.operation.ts b/apps/nestjs-backend/src/features/undo-redo/operations/create-fields.operation.ts index b7cae5ab26..0d6f63e524 100644 --- a/apps/nestjs-backend/src/features/undo-redo/operations/create-fields.operation.ts +++ b/apps/nestjs-backend/src/features/undo-redo/operations/create-fields.operation.ts @@ -1,7 +1,7 @@ -import { FieldKeyType } from '@teable/core'; import type { IColumnMeta, IFieldVo } from '@teable/core'; import type { ICreateFieldsOperation } from '../../../cache/types'; import { OperationName } from '../../../cache/types'; +import { restoreFieldRecordValues } from '../../field/restore-field-record-values'; import type { FieldOpenApiService } from '../../field/open-api/field-open-api.service'; import type { RecordOpenApiService } from '../../record/open-api/record-open-api.service'; @@ -55,12 +55,7 @@ export class CreateFieldsOperation { await this.fieldOpenApiService.createFields(tableId, fields); - if (records) { - await this.recordOpenApiService.updateRecords(tableId, { - fieldKeyType: FieldKeyType.Id, - records: records, - }); - } + await restoreFieldRecordValues(tableId, records, this.recordOpenApiService); return operation; } diff --git a/apps/nestjs-backend/src/features/undo-redo/operations/delete-fields.operation.ts b/apps/nestjs-backend/src/features/undo-redo/operations/delete-fields.operation.ts index c2897a3156..43bdac0a29 100644 --- a/apps/nestjs-backend/src/features/undo-redo/operations/delete-fields.operation.ts +++ b/apps/nestjs-backend/src/features/undo-redo/operations/delete-fields.operation.ts @@ -1,7 +1,7 @@ -import { FieldKeyType } from '@teable/core'; import type { DataPrismaService } from '@teable/db-data-prisma'; import type { IDeleteFieldsOperation } from '../../../cache/types'; import { OperationName } from '../../../cache/types'; +import { restoreFieldRecordValues } from '../../field/restore-field-record-values'; import type { DataDbClientManager } from '../../../global/data-db-client-manager.service'; import type { FieldOpenApiService } from '../../field/open-api/field-open-api.service'; import type { RecordOpenApiService } from '../../record/open-api/record-open-api.service'; @@ -57,12 +57,7 @@ export class DeleteFieldsOperation { restoreViewOrder: true, }); - if (records) { - await this.recordOpenApiService.updateRecords(tableId, { - fieldKeyType: FieldKeyType.Id, - records, - }); - } + await restoreFieldRecordValues(tableId, records, this.recordOpenApiService); if (operationId) { await dataPrisma.tableTrash.delete({ 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 6614e82b86..046995fa72 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 @@ -32,7 +32,7 @@ describe('trash-backed undo operations', () => { params: { tableId: 'tbl1' }, result: { fields: [{ id: 'fld1', name: 'Name' }], - records: [{ id: 'rec1' }], + records: [{ id: 'rec1', fields: { fld1: 'restored', fld2: null } }], }, operationId: 'otrash1', } as never); @@ -51,13 +51,102 @@ describe('trash-backed undo operations', () => { ); expect(recordOpenApiService.updateRecords).toHaveBeenCalledWith('tbl1', { fieldKeyType: FieldKeyType.Id, - records: [{ id: 'rec1' }], + records: [{ id: 'rec1', fields: { fld1: 'restored' } }], }); expect(dataPrismaService.tableTrash.delete).toHaveBeenCalledWith({ where: { id: 'otrash1' }, }); }); + it('DeleteFieldsOperation restores only non-empty field values from sparse v2 compat snapshots', async () => { + const fieldOpenApiService = { + createFields: vi.fn().mockResolvedValue(undefined), + }; + const recordOpenApiService = { + updateRecords: vi.fn().mockResolvedValue(undefined), + }; + const dataPrismaService = { + tableTrash: { + count: vi.fn().mockResolvedValue(1), + delete: vi.fn().mockResolvedValue(undefined), + }, + }; + const dataDbClientManager = { + dataPrismaForTable: vi.fn().mockResolvedValue(dataPrismaService), + }; + const operation = new DeleteFieldsOperation( + fieldOpenApiService as never, + recordOpenApiService as never, + dataDbClientManager as never + ); + + await operation.undo({ + name: 'DeleteFields', + params: { tableId: 'tbl1' }, + result: { + fields: [{ id: 'fld1', name: 'Name' }], + records: [ + { id: 'rec1', fields: { fld1: null } }, + { id: 'rec2', fields: { fld1: [], fld2: undefined } }, + { id: 'rec3', fields: { fld1: 'value' } }, + { id: 'rec4', fields: { fld1: 0, fld2: false, fld3: '' } }, + ], + }, + operationId: 'otrash1', + } as never); + + expect(recordOpenApiService.updateRecords).toHaveBeenCalledTimes(1); + expect(recordOpenApiService.updateRecords).toHaveBeenCalledWith('tbl1', { + fieldKeyType: FieldKeyType.Id, + records: [ + { id: 'rec3', fields: { fld1: 'value' } }, + { id: 'rec4', fields: { fld1: 0, fld2: false, fld3: '' } }, + ], + }); + }); + + it('DeleteFieldsOperation restores field values in chunks', async () => { + const fieldOpenApiService = { + createFields: vi.fn().mockResolvedValue(undefined), + }; + const recordOpenApiService = { + updateRecords: vi.fn().mockResolvedValue(undefined), + }; + const dataPrismaService = { + tableTrash: { + count: vi.fn().mockResolvedValue(1), + delete: vi.fn().mockResolvedValue(undefined), + }, + }; + const dataDbClientManager = { + dataPrismaForTable: vi.fn().mockResolvedValue(dataPrismaService), + }; + const operation = new DeleteFieldsOperation( + fieldOpenApiService as never, + recordOpenApiService as never, + dataDbClientManager as never + ); + const records = Array.from({ length: 1001 }, (_, index) => ({ + id: `rec${index}`, + fields: { fld1: `value-${index}` }, + })); + + await operation.undo({ + name: 'DeleteFields', + params: { tableId: 'tbl1' }, + result: { + fields: [{ id: 'fld1', name: 'Name' }], + records, + }, + operationId: 'otrash1', + } as never); + + expect(recordOpenApiService.updateRecords).toHaveBeenCalledTimes(3); + expect(recordOpenApiService.updateRecords.mock.calls[0][1].records).toHaveLength(500); + expect(recordOpenApiService.updateRecords.mock.calls[1][1].records).toHaveLength(500); + expect(recordOpenApiService.updateRecords.mock.calls[2][1].records).toHaveLength(1); + }); + it('DeleteRecordsOperation restores records before deleting data-db trash snapshots', async () => { const recordOpenApiService = { multipleCreateRecords: vi.fn().mockResolvedValue(undefined), diff --git a/apps/nestjs-backend/src/features/user/user.service.spec.ts b/apps/nestjs-backend/src/features/user/user.service.spec.ts index a8eaf2d610..15d0fb60a5 100644 --- a/apps/nestjs-backend/src/features/user/user.service.spec.ts +++ b/apps/nestjs-backend/src/features/user/user.service.spec.ts @@ -1,5 +1,6 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; +import { vi } from 'vitest'; import { GlobalModule } from '../../global/global.module'; import { UserModule } from './user.module'; import { UserService } from './user.service'; @@ -7,6 +8,39 @@ import { UserService } from './user.service'; describe('UserService', () => { let service: UserService; + const createNotifyMetaMergeService = (notifyMeta?: string | null) => { + const queryRaw = vi.fn().mockResolvedValue([{ notifyMeta }]); + const update = vi.fn().mockResolvedValue(undefined); + const tx = vi.fn(async (fn: () => Promise) => await fn()); + const prismaService = { + $tx: tx, + txClient: () => ({ + $queryRaw: queryRaw, + user: { + update, + }, + }), + }; + const mergeService = new UserService( + prismaService as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never + ); + + return { + mergeService, + queryRaw, + tx, + update, + }; + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [GlobalModule, UserModule], @@ -18,4 +52,49 @@ describe('UserService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + it('merges notify meta updates with existing values', async () => { + const { mergeService, queryRaw, tx, update } = createNotifyMetaMergeService( + JSON.stringify({ email: true, appBuilderChatIntroDismissed: true }) + ); + + await mergeService.updateNotifyMeta('usrTest', { email: false }); + + expect(tx).toHaveBeenCalledTimes(1); + expect(queryRaw).toHaveBeenCalledTimes(1); + expect(queryRaw.mock.calls[0][0].join('')).toContain('FOR UPDATE'); + expect(queryRaw.mock.calls[0][1]).toBe('usrTest'); + expect(update).toHaveBeenCalledWith({ + data: { + notifyMeta: JSON.stringify({ email: false, appBuilderChatIntroDismissed: true }), + }, + where: { id: 'usrTest', deletedTime: null }, + }); + }); + + it('keeps existing notify switches when dismissing app builder intro', async () => { + const { mergeService, update } = createNotifyMetaMergeService(JSON.stringify({ email: true })); + + await mergeService.updateNotifyMeta('usrTest', { appBuilderChatIntroDismissed: true }); + + expect(update).toHaveBeenCalledWith({ + data: { + notifyMeta: JSON.stringify({ email: true, appBuilderChatIntroDismissed: true }), + }, + where: { id: 'usrTest', deletedTime: null }, + }); + }); + + it('ignores malformed existing notify meta when merging updates', async () => { + const { mergeService, update } = createNotifyMetaMergeService('legacy-invalid-json'); + + await mergeService.updateNotifyMeta('usrTest', { appBuilderChatIntroDismissed: true }); + + expect(update).toHaveBeenCalledWith({ + data: { + notifyMeta: JSON.stringify({ appBuilderChatIntroDismissed: true }), + }, + where: { id: 'usrTest', deletedTime: null }, + }); + }); }); diff --git a/apps/nestjs-backend/src/features/user/user.service.ts b/apps/nestjs-backend/src/features/user/user.service.ts index 57d79cd7e1..852d7bf032 100644 --- a/apps/nestjs-backend/src/features/user/user.service.ts +++ b/apps/nestjs-backend/src/features/user/user.service.ts @@ -26,8 +26,8 @@ import type { IClsStore } from '../../types/cls'; import StorageAdapter from '../attachments/plugins/adapter'; import { InjectStorageAdapter } from '../attachments/plugins/storage'; import { getPublicFullStorageUrl } from '../attachments/plugins/utils'; -import { Audit } from '../audit/audit.decorator'; import { AuditScope } from '../audit/audit-scope'; +import { Audit } from '../audit/audit.decorator'; import { UserModel } from '../model/user'; import { SettingService } from '../setting/setting.service'; @@ -334,14 +334,39 @@ export class UserService { } async updateNotifyMeta(id: string, notifyMetaRo: IUserNotifyMeta) { - await this.prismaService.txClient().user.update({ - data: { - notifyMeta: JSON.stringify(notifyMetaRo), - }, - where: { id, deletedTime: null }, + await this.prismaService.$tx(async () => { + const [user] = await this.prismaService.txClient().$queryRaw< + Array<{ notifyMeta: string | null }> + >` + SELECT "notify_meta" AS "notifyMeta" + FROM "users" + WHERE "id" = ${id} + AND "deleted_time" IS NULL + FOR UPDATE + `; + const prevNotifyMeta = this.parseNotifyMeta(user?.notifyMeta); + + await this.prismaService.txClient().user.update({ + data: { + notifyMeta: JSON.stringify({ ...prevNotifyMeta, ...notifyMetaRo }), + }, + where: { id, deletedTime: null }, + }); }); } + private parseNotifyMeta(notifyMeta?: string | null): IUserNotifyMeta { + if (!notifyMeta) { + return {}; + } + + try { + return JSON.parse(notifyMeta) as IUserNotifyMeta; + } catch { + return {}; + } + } + async updateLang(id: string, lang: string) { await this.prismaService.txClient().user.update({ data: { 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 829204ebc8..beb18cd5cb 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 @@ -5,11 +5,13 @@ import { FieldId, FieldUpdated, RecordId, + RecordReordered, RecordsBatchCreated, RecordsBatchUpdated, RecordsDeleted, TableActionTriggerRequested, TableId, + ViewId, type IExecutionContext, type IEventHandler, } from '@teable/v2-core'; @@ -576,6 +578,61 @@ describe('V2ActionTriggerService', () => { ]); }); + it('emits setRecord with explicit empty fieldIds for record reorders', async () => { + let submitted: IPresencePayload | undefined; + + const shareDbService = { + connect: () => ({ + getPresence: () => ({ + create: () => ({ + submit: (data: IPresencePayload, cb?: (error?: unknown) => void) => { + submitted = data; + cb?.(); + }, + }), + }), + }), + } as unknown as ShareDbService; + + const registered: Array<{ instance: unknown }> = []; + const container = { + registerInstance: (_token: unknown, instance: unknown) => { + registered.push({ instance }); + return container; + }, + } as unknown as DependencyContainer; + + const service = new V2ActionTriggerService(shareDbService); + service.registerProjections(container); + + const projection = registered.find( + (item) => + (item.instance as { constructor?: { name?: string } }).constructor?.name === + 'V2RecordReorderedActionTriggerProjection' + )?.instance as IEventHandler | undefined; + + expect(projection).toBeDefined(); + + const { baseId, tableId } = createIds(); + const recordId = RecordId.create(`rec${'d'.repeat(16)}`)._unsafeUnwrap(); + const event = RecordReordered.create({ + baseId, + tableId, + viewId: ViewId.create(`viw${'e'.repeat(16)}`)._unsafeUnwrap(), + recordIds: [recordId], + ordersByRecordId: { [recordId.toString()]: 3.375 }, + previousOrdersByRecordId: { [recordId.toString()]: 3 }, + }); + + const result = await projection?.handle({} as IExecutionContext, event); + expect(result?.isOk()).toBe(true); + await waitForPresenceFlush(); + + // empty fieldIds means "no cell value changed": field-aware listeners + // (row count, aggregations) must be able to skip refreshing on reorders + expect(submitted).toEqual([{ actionKey: 'setRecord', payload: { fieldIds: [] } }]); + }); + it('emits setRecord presence payload with fieldIds for large batch updates', async () => { let channelSubmitted: string | undefined; let submitted: IPresencePayload | undefined; 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 d5f55b05bc..6db223e8b9 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 @@ -206,7 +206,10 @@ class V2RecordUpdatedActionTriggerProjection implements IEventHandler> { - emitActionTrigger(this.shareDbService, event.tableId.toString(), [{ actionKey: 'setRecord' }]); + const fieldIds = event.changes.map((c) => c.fieldId); + emitActionTrigger(this.shareDbService, event.tableId.toString(), [ + { actionKey: 'setRecord', payload: { fieldIds } }, + ]); return ok(undefined); } } @@ -247,7 +250,9 @@ class V2RecordsBatchUpdatedActionTriggerProjection implements IEventHandler> { - emitActionTrigger(this.shareDbService, event.tableId.toString(), [{ actionKey: 'setRecord' }]); + // reorder changes row order only — the explicit empty fieldIds tells + // field-aware listeners (row count, aggregations) that no cell value + // changed, so they can skip refreshing + emitActionTrigger(this.shareDbService, event.tableId.toString(), [ + { actionKey: 'setRecord', payload: { fieldIds: [] } }, + ]); return ok(undefined); } } diff --git a/apps/nestjs-backend/src/features/v2/v2-audit-log.constants.ts b/apps/nestjs-backend/src/features/v2/v2-audit-log.constants.ts deleted file mode 100644 index c6e31b160b..0000000000 --- a/apps/nestjs-backend/src/features/v2/v2-audit-log.constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { IFieldVo } from '@teable/core'; - -export const V2_FIELD_UPDATE_AUDIT_CONTEXT_KEY = '__teable_v2_field_update_audit_context'; -export const V2_RECORD_PASTE_AUDIT_CONTEXT_KEY = '__teable_v2_record_paste_audit_context'; - -export interface IV2FieldUpdateAuditContext { - tableId: string; - fieldId: string; - oldField: IFieldVo; - inputField: Record; -} 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 93aab08c5b..347943292a 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 @@ -19,6 +19,23 @@ import { V2RecordUpdatedCollaboratorNotificationProjection, } from './v2-collaborator-notification.service'; +const createScheduledContext = (actorId = 'usrActor000000001') => { + const scheduled: Array<() => Promise | void> = []; + const context = { + actorId: { toString: () => actorId }, + scheduleBackgroundTask: vi.fn((task: () => Promise | void) => { + scheduled.push(task); + }), + }; + return { context, scheduled }; +}; + +const flushScheduled = async (scheduled: Array<() => Promise | void>) => { + while (scheduled.length) { + await scheduled.shift()?.(); + } +}; + const createV2ContainerService = () => { const userFieldsQuery = { innerJoin: vi.fn().mockReturnThis(), @@ -79,11 +96,10 @@ describe('V2CollaboratorNotificationDispatcher', () => { it('sends collaborator notification for v2 created records with notified user fields', async () => { const { dispatcher, notificationService, recordService } = createDispatcher(); const projection = new V2RecordCreatedCollaboratorNotificationProjection(dispatcher); + const { context, scheduled } = createScheduledContext(); const result = await projection.handle( - { - actorId: { toString: () => 'usrActor000000001' }, - } as never, + context as never, { tableId: { toString: () => 'tblNotify00000001' }, recordId: { toString: () => 'recNotify00000001' }, @@ -97,6 +113,11 @@ describe('V2CollaboratorNotificationDispatcher', () => { ); expect(result._unsafeUnwrap()).toBeUndefined(); + expect(recordService.getRecordsHeadWithIds).not.toHaveBeenCalled(); + expect(notificationService.sendCollaboratorNotify).not.toHaveBeenCalled(); + + await flushScheduled(scheduled); + expect(recordService.getRecordsHeadWithIds).toHaveBeenCalledWith('tblNotify00000001', [ 'recNotify00000001', ]); @@ -117,11 +138,10 @@ describe('V2CollaboratorNotificationDispatcher', () => { it('sends collaborator notification for v2 updated records with notified user field changes', async () => { const { dispatcher, notificationService } = createDispatcher(); const projection = new V2RecordUpdatedCollaboratorNotificationProjection(dispatcher); + const { context, scheduled } = createScheduledContext(); const result = await projection.handle( - { - actorId: { toString: () => 'usrActor000000001' }, - } as never, + context as never, { source: 'user', tableId: { toString: () => 'tblNotify00000001' }, @@ -142,6 +162,10 @@ describe('V2CollaboratorNotificationDispatcher', () => { ); expect(result._unsafeUnwrap()).toBeUndefined(); + expect(notificationService.sendCollaboratorNotify).not.toHaveBeenCalled(); + + await flushScheduled(scheduled); + expect(notificationService.sendCollaboratorNotify).toHaveBeenCalledTimes(1); expect(notificationService.sendCollaboratorNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -154,11 +178,10 @@ describe('V2CollaboratorNotificationDispatcher', () => { it('ignores non-user v2 update events', async () => { const { dispatcher, notificationService } = createDispatcher(); const projection = new V2RecordUpdatedCollaboratorNotificationProjection(dispatcher); + const { context, scheduled } = createScheduledContext(); const result = await projection.handle( - { - actorId: { toString: () => 'usrActor000000001' }, - } as never, + context as never, { source: 'computed', tableId: { toString: () => 'tblNotify00000001' }, @@ -174,6 +197,7 @@ describe('V2CollaboratorNotificationDispatcher', () => { ); expect(result._unsafeUnwrap()).toBeUndefined(); + expect(scheduled).toHaveLength(0); expect(notificationService.sendCollaboratorNotify).not.toHaveBeenCalled(); }); }); 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 e59c4729e4..521c3b6352 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 @@ -17,6 +17,7 @@ import { RecordsBatchCreated, RecordsBatchUpdated, RecordUpdated, + scheduleExecutionContextBackgroundTask, } from '@teable/v2-core'; import type { DependencyContainer } from '@teable/v2-di'; import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; @@ -45,6 +46,26 @@ type IUserFieldOptions = { }; const maxRecordTitles = 10; +const collaboratorNotificationLogger = new Logger('V2CollaboratorNotificationProjection'); + +const scheduleCollaboratorNotificationRun = ( + context: IExecutionContext, + task: () => Promise, + eventType: string +): void => { + scheduleExecutionContextBackgroundTask(context, async () => { + try { + await task(); + } catch (error) { + collaboratorNotificationLogger.error( + `Error handling ${eventType} collaborator notification projection: ${ + error instanceof Error ? error.message : String(error) + }`, + error instanceof Error ? error.stack : undefined + ); + } + }); +}; const getNotificationDb = async ( v2ContainerService: V2ContainerService @@ -240,16 +261,21 @@ export class V2RecordCreatedCollaboratorNotificationProjection context: IExecutionContext, event: RecordCreated ): Promise> { - await this.dispatcher.notifyUserFields({ - actorId: context.actorId.toString(), - tableId: event.tableId.toString(), - records: [ - { - id: event.recordId.toString(), - fields: fieldValuesToObject(event.fieldValues), - }, - ], - }); + scheduleCollaboratorNotificationRun( + context, + () => + this.dispatcher.notifyUserFields({ + actorId: context.actorId.toString(), + tableId: event.tableId.toString(), + records: [ + { + id: event.recordId.toString(), + fields: fieldValuesToObject(event.fieldValues), + }, + ], + }), + 'record create' + ); return ok(undefined); } } @@ -264,14 +290,19 @@ export class V2RecordsBatchCreatedCollaboratorNotificationProjection context: IExecutionContext, event: RecordsBatchCreated ): Promise> { - await this.dispatcher.notifyUserFields({ - actorId: context.actorId.toString(), - tableId: event.tableId.toString(), - records: event.records.map((record: RecordValuesDTO) => ({ - id: record.recordId, - fields: fieldValuesToObject(record.fields), - })), - }); + scheduleCollaboratorNotificationRun( + context, + () => + this.dispatcher.notifyUserFields({ + actorId: context.actorId.toString(), + tableId: event.tableId.toString(), + records: event.records.map((record: RecordValuesDTO) => ({ + id: record.recordId, + fields: fieldValuesToObject(record.fields), + })), + }), + 'batch record create' + ); return ok(undefined); } } @@ -290,16 +321,21 @@ export class V2RecordUpdatedCollaboratorNotificationProjection return ok(undefined); } - await this.dispatcher.notifyUserFields({ - actorId: context.actorId.toString(), - tableId: event.tableId.toString(), - records: [ - { - id: event.recordId.toString(), - fields: changesToNewValues(event.changes), - }, - ], - }); + scheduleCollaboratorNotificationRun( + context, + () => + this.dispatcher.notifyUserFields({ + actorId: context.actorId.toString(), + tableId: event.tableId.toString(), + records: [ + { + id: event.recordId.toString(), + fields: changesToNewValues(event.changes), + }, + ], + }), + 'record update' + ); return ok(undefined); } } @@ -318,14 +354,19 @@ export class V2RecordsBatchUpdatedCollaboratorNotificationProjection return ok(undefined); } - await this.dispatcher.notifyUserFields({ - actorId: context.actorId.toString(), - tableId: event.tableId.toString(), - records: event.updates.map((update) => ({ - id: update.recordId, - fields: changesToNewValues(update.changes), - })), - }); + scheduleCollaboratorNotificationRun( + context, + () => + this.dispatcher.notifyUserFields({ + actorId: context.actorId.toString(), + tableId: event.tableId.toString(), + records: event.updates.map((update) => ({ + id: update.recordId, + fields: changesToNewValues(update.changes), + })), + }), + 'batch record update' + ); return ok(undefined); } } diff --git a/apps/nestjs-backend/src/features/v2/v2-create-table-compat.constants.ts b/apps/nestjs-backend/src/features/v2/v2-create-table-compat.constants.ts deleted file mode 100644 index 8d38c92527..0000000000 --- a/apps/nestjs-backend/src/features/v2/v2-create-table-compat.constants.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { ClsService } from 'nestjs-cls'; -import type { IClsStore } from '../../types/cls'; - -export const V2_CREATE_TABLE_LEGACY_EVENTS_CONTEXT_KEY = - '__teable_v2_create_table_legacy_events_context'; - -type IV2CreateTableLegacyEventsClsStore = IClsStore & { - [V2_CREATE_TABLE_LEGACY_EVENTS_CONTEXT_KEY]?: boolean; -}; - -export const getV2CreateTableLegacyEventsFlag = (cls: ClsService): boolean => { - return ( - (cls as ClsService).get( - V2_CREATE_TABLE_LEGACY_EVENTS_CONTEXT_KEY - ) === true - ); -}; - -export const setV2CreateTableLegacyEventsFlag = ( - cls: ClsService, - value: boolean -): void => { - (cls as ClsService).set( - V2_CREATE_TABLE_LEGACY_EVENTS_CONTEXT_KEY, - value - ); -}; diff --git a/apps/nestjs-backend/src/features/v2/v2-execution-context.factory.ts b/apps/nestjs-backend/src/features/v2/v2-execution-context.factory.ts index 803238e70c..088689dbe1 100644 --- a/apps/nestjs-backend/src/features/v2/v2-execution-context.factory.ts +++ b/apps/nestjs-backend/src/features/v2/v2-execution-context.factory.ts @@ -51,6 +51,7 @@ export class V2ExecutionContextFactory { // Get windowId from CLS for undo/redo tracking const windowId = this.cls.get('windowId'); + const scheduleBackgroundTask = this.cls.get('scheduleV2BackgroundTask'); const t: NonNullable = (key, options) => this.i18n.t(`table.${key}` as never, { args: options, @@ -62,6 +63,7 @@ export class V2ExecutionContextFactory { tracer, requestId, windowId, + scheduleBackgroundTask, config: { ...(tableLimits ? { tableLimits } : {}), }, diff --git a/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.constants.ts b/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.constants.ts deleted file mode 100644 index e288a58bef..0000000000 --- a/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.constants.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { IOtOperation } from '@teable/core'; -import type { ILegacyDeleteFieldsPayloadSnapshot } from '../field/open-api/field-open-api.service'; - -export const V2_FIELD_DELETE_COMPAT_CONTEXT_KEY = '__teable_v2_field_delete_compat_context'; - -export interface IV2FieldDeleteCompatContext { - tableId: string; - userId: string; - operationId: string; - remainingFieldIds: Set; - frozenFieldOps: Record; - legacyDeletePayload: ILegacyDeleteFieldsPayloadSnapshot; - completed?: boolean; -} diff --git a/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.spec.ts b/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.spec.ts index f311252794..55e7299a7b 100644 --- a/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.spec.ts +++ b/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.spec.ts @@ -1,15 +1,19 @@ import { ViewOpBuilder } from '@teable/core'; +import { ok } from 'neverthrow'; import { describe, expect, it, vi } from 'vitest'; -import { V2_FIELD_DELETE_COMPAT_CONTEXT_KEY } from './v2-field-delete-compat.constants'; const mockV2Tokens = vi.hoisted(() => ({ v2DataDbTokens: { db: Symbol('v2.data.db'), }, + v2MetaDbTokens: { + db: Symbol('v2.meta.db'), + }, })); vi.mock('@teable/v2-adapter-db-postgres-pg', () => ({ v2DataDbTokens: mockV2Tokens.v2DataDbTokens, + v2MetaDbTokens: mockV2Tokens.v2MetaDbTokens, })); vi.mock('./v2-container.service', () => ({ @@ -20,7 +24,12 @@ vi.mock('./v2-view-compat.service', () => ({ V2ViewCompatService: class V2ViewCompatService {}, })); -import { V2FieldDeletedCompatProjection } from './v2-field-delete-compat.service'; +import { v2CoreTokens } from '@teable/v2-core'; +import { + V2FieldDeleteCompatCompletion, + V2FieldDeleteSnapshotSink, + V2FieldDeleteCompatService, +} from './v2-field-delete-compat.service'; const createInsertDb = () => { const query = { @@ -34,121 +43,423 @@ const createInsertDb = () => { return { db, query }; }; -const createV2ContainerService = (db: unknown) => ({ - getContainer: vi.fn().mockResolvedValue({ +const createReferenceDb = ( + references: ReadonlyArray<{ from_field_id: string; to_field_id: string }> = [] +) => { + const query = { + select: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + execute: vi.fn().mockResolvedValue(references), + }; + const db = { + selectFrom: vi.fn().mockReturnValue(query), + }; + + return { db, query }; +}; + +const createV2ContainerService = (db: unknown, tableMapper: unknown) => ({ + getContainerForTable: vi.fn().mockResolvedValue({ resolve: vi.fn((token: symbol) => { - if (token !== mockV2Tokens.v2DataDbTokens.db) { - throw new Error(`Unexpected token ${String(token)}`); + if (token === mockV2Tokens.v2DataDbTokens.db) { + return db; + } + + if (token === v2CoreTokens.tableMapper) { + return tableMapper; } - return db; + throw new Error(`Unexpected token ${String(token)}`); }), }), }); -describe('V2FieldDeletedCompatProjection', () => { - it('waits until the last deleted field before running compat updates', async () => { - const { db, query } = createInsertDb(); - const projection = new V2FieldDeletedCompatProjection( - createV2ContainerService(db) as never, +const createRegistrationContainer = (input: { + tableMapper: unknown; + metaDb: unknown; + dataDb: unknown; +}) => ({ + resolve: vi.fn((token: symbol) => { + if (token === v2CoreTokens.tableMapper) { + return input.tableMapper; + } + if (token === mockV2Tokens.v2MetaDbTokens.db) { + return input.metaDb; + } + if (token === mockV2Tokens.v2DataDbTokens.db) { + return input.dataDb; + } + throw new Error(`Unexpected token ${String(token)}`); + }), + registerInstance: vi.fn(), +}); + +const createTableMapper = () => ({ + toDTO: vi.fn().mockReturnValue( + ok({ + id: 'tblCompatTable0001', + baseId: 'bseCompatBase00001', + name: 'Compat Table', + primaryFieldId: 'fldCompatA00000001', + fields: [ + { + id: 'fldCompatA00000001', + name: 'Text Field', + type: 'singleLineText', + dbFieldName: 'text_field', + dbFieldType: 'text', + isComputed: false, + }, + { + id: 'fldCompatB00000001', + name: 'Number Field', + type: 'number', + dbFieldName: 'number_field', + dbFieldType: 'number', + isComputed: false, + }, + ], + views: [ + { + id: 'viwCompat000000001', + name: 'Grid', + type: 'grid', + options: { frozenFieldId: 'fldCompatA00000001' }, + columnMeta: { + fldCompatA00000001: { order: 2, hidden: false }, + fldCompatB00000001: { order: 1, hidden: false }, + }, + }, + ], + }) + ), +}); + +const createConditionalLookupTableMapper = () => ({ + toDTO: vi.fn().mockReturnValue( + ok({ + id: 'tblCompatTable0001', + baseId: 'bseCompatBase00001', + name: 'Compat Table', + primaryFieldId: 'fldPrimary000000001', + fields: [ + { + id: 'fldCondLookup000001', + name: 'Conditional Lookup', + type: 'conditionalLookup', + dbFieldName: 'cond_lookup', + dbFieldType: 'json', + isComputed: true, + isMultipleCellValue: true, + innerType: 'number', + innerOptions: { formatting: { type: 'decimal', precision: 2 } }, + options: { + foreignTableId: 'tblForeign00000001', + lookupFieldId: 'fldForeign00000001', + condition: { + filter: { + conjunction: 'and', + filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }], + }, + sort: { fieldId: 'fldScore0000000001', order: 'desc' }, + limit: 5, + }, + }, + }, + ], + views: [ + { + id: 'viwCompat000000001', + name: 'Grid', + type: 'grid', + options: {}, + columnMeta: { + fldCondLookup000001: { order: 1, hidden: false }, + }, + }, + ], + }) + ), +}); + +const createSnapshotItem = (fieldId: string, table: unknown = { kind: 'domainTable' }) => ({ + table, + snapshot: { + field: { + id: fieldId, + name: fieldId === 'fldCompatA00000001' ? 'Text Field' : 'Number Field', + type: fieldId === 'fldCompatA00000001' ? 'singleLineText' : 'number', + isPrimary: fieldId === 'fldCompatA00000001', + }, + views: [ { - batchUpdateViewByOps: vi.fn(), - } as never - ); - const compatContext = { - tableId: 'tblCompatTable0001', - userId: 'usrCompatWriter00001', - operationId: 'opCompatDelete000001', - completed: undefined as boolean | undefined, - remainingFieldIds: new Set(['fldCompatA00000001', 'fldCompatB00000001']), - frozenFieldOps: { - viwCompat000000001: [ - ViewOpBuilder.editor.setViewProperty.build({ - key: 'options', - oldValue: { frozenFieldId: 'fldCompatA00000001' }, - newValue: { frozenFieldId: 'fldCompatB00000001' }, - }), - ], + viewId: 'viwCompat000000001', + columnMeta: + fieldId === 'fldCompatA00000001' + ? { order: 2, hidden: false } + : { order: 1, hidden: false }, }, - legacyDeletePayload: { - fields: [{ id: 'fldCompatA00000001' }], - records: [{ id: 'recCompat000000001' }], + ], + records: [{ recordId: 'recCompat000000001', value: `${fieldId}:value` }], + }, +}); + +describe('V2FieldDeleteSnapshotSink', () => { + it('prepares an explicit completion with v2 delete snapshots, frozen view ops, and references', async () => { + const tableMapper = createTableMapper(); + const { db, query } = createReferenceDb([ + { + from_field_id: 'fldCompatA00000001', + to_field_id: 'fldDependent0000001', }, + ]); + const sink = new V2FieldDeleteSnapshotSink( + tableMapper as never, + db as never, + createV2ContainerService(createInsertDb().db, tableMapper) as never, + { batchUpdateViewByOps: vi.fn() } as never + ); + const context = { + actorId: { toString: () => 'usrCompatWriter00001' }, }; - const executionContext = { - [V2_FIELD_DELETE_COMPAT_CONTEXT_KEY]: compatContext, - } as never; + const result = await sink.prepare(context as never, { + baseId: 'bseCompatBase00001', + tableId: 'tblCompatTable0001', + fieldIds: ['fldCompatA00000001', 'fldCompatB00000001'], + snapshots: [ + createSnapshotItem('fldCompatA00000001'), + createSnapshotItem('fldCompatB00000001'), + ] as never, + }); - const result = await projection.handle(executionContext, { - tableId: { toString: () => 'tblCompatTable0001' }, - fieldId: { toString: () => 'fldCompatA00000001' }, - } as never); + expect(result._unsafeUnwrap()).toBeInstanceOf(V2FieldDeleteCompatCompletion); + expect(db.selectFrom).toHaveBeenCalledWith('reference'); + expect(query.where).toHaveBeenCalledWith('from_field_id', 'in', [ + 'fldCompatA00000001', + 'fldCompatB00000001', + ]); + }); +}); - expect(result._unsafeUnwrap()).toBeUndefined(); - expect(compatContext.completed).toBeUndefined(); - expect(compatContext.remainingFieldIds.has('fldCompatB00000001')).toBe(true); +describe('V2FieldDeleteCompatService', () => { + it('registers the delete snapshot sink with the meta DB for reference reads', () => { + const tableMapper = createTableMapper(); + const metaDb = createReferenceDb().db; + const dataDb = createReferenceDb().db; + const container = createRegistrationContainer({ tableMapper, metaDb, dataDb }); + const service = new V2FieldDeleteCompatService({} as never, {} as never); + + service.registerProjections(container as never); + + expect(container.resolve).toHaveBeenCalledWith(mockV2Tokens.v2MetaDbTokens.db); + expect(container.resolve).not.toHaveBeenCalledWith(mockV2Tokens.v2DataDbTokens.db); + expect(container.registerInstance).toHaveBeenCalledWith( + v2CoreTokens.fieldDeleteSnapshotSink, + expect.any(V2FieldDeleteSnapshotSink) + ); + }); +}); + +describe('V2FieldDeleteCompatCompletion', () => { + it('keeps compat writes deferred until completion runs', () => { + const { db, query } = createInsertDb(); + const tableMapper = createTableMapper(); + const v2ContainerService = createV2ContainerService(db, tableMapper); + const v2ViewCompatService = { + batchUpdateViewByOps: vi.fn(), + }; + const completion = new V2FieldDeleteCompatCompletion( + v2ContainerService as never, + v2ViewCompatService as never, + { + tableId: 'tblCompatTable0001', + userId: 'usrCompatWriter00001', + operationId: 'opCompatDelete000001', + frozenFieldOps: {}, + snapshots: [createSnapshotItem('fldCompatA00000001')], + referencesByFieldId: new Map([['fldCompatA00000001', ['fldDependent0000001']]]), + } + ); + + expect(completion).toBeDefined(); + expect(v2ContainerService.getContainerForTable).not.toHaveBeenCalled(); + expect(v2ViewCompatService.batchUpdateViewByOps).not.toHaveBeenCalled(); expect(db.insertInto).not.toHaveBeenCalled(); expect(query.values).not.toHaveBeenCalled(); }); - it('uses v2 view compat and table_trash writes when the final field is deleted', async () => { + it('uses v2 view compat and table_trash writes when completion runs', async () => { const { db, query } = createInsertDb(); + const tableMapper = createTableMapper(); const v2ViewCompatService = { batchUpdateViewByOps: vi.fn().mockResolvedValue(undefined), }; - const projection = new V2FieldDeletedCompatProjection( - createV2ContainerService(db) as never, - v2ViewCompatService as never - ); - const compatContext = { - tableId: 'tblCompatTable0001', - userId: 'usrCompatWriter00001', - operationId: 'opCompatDelete000001', - completed: undefined as boolean | undefined, - remainingFieldIds: new Set(['fldCompatA00000001']), - frozenFieldOps: { - viwCompat000000001: [ - ViewOpBuilder.editor.setViewProperty.build({ - key: 'options', - oldValue: { frozenFieldId: 'fldCompatA00000001' }, - newValue: { frozenFieldId: 'fldCompatB00000001' }, - }), - ], - }, - legacyDeletePayload: { - fields: [{ id: 'fldCompatA00000001' }], - records: [{ id: 'recCompat000000001' }], - }, + const frozenFieldOps = { + viwCompat000000001: [ + ViewOpBuilder.editor.setViewProperty.build({ + key: 'options', + oldValue: { frozenFieldId: 'fldCompatA00000001' }, + newValue: { frozenFieldId: 'fldCompatB00000001' }, + }), + ], }; + const completion = new V2FieldDeleteCompatCompletion( + createV2ContainerService(db, tableMapper) as never, + v2ViewCompatService as never, + { + tableId: 'tblCompatTable0001', + userId: 'usrCompatWriter00001', + operationId: 'opCompatDelete000001', + frozenFieldOps, + snapshots: [createSnapshotItem('fldCompatA00000001')], + referencesByFieldId: new Map([['fldCompatA00000001', ['fldDependent0000001']]]), + } + ); - const executionContext = { - [V2_FIELD_DELETE_COMPAT_CONTEXT_KEY]: compatContext, - } as never; + const executionContext = {} as never; - const result = await projection.handle(executionContext, { - tableId: { toString: () => 'tblCompatTable0001' }, - fieldId: { toString: () => 'fldCompatA00000001' }, - } as never); + const result = await completion.complete(executionContext); expect(result._unsafeUnwrap()).toBeUndefined(); - expect(compatContext.completed).toBe(true); expect(v2ViewCompatService.batchUpdateViewByOps).toHaveBeenCalledWith( 'tblCompatTable0001', - compatContext.frozenFieldOps, + frozenFieldOps, executionContext ); expect(db.insertInto).toHaveBeenCalledWith('table_trash'); - expect(query.values).toHaveBeenCalledWith({ - id: 'opCompatDelete000001', - table_id: 'tblCompatTable0001', - created_by: 'usrCompatWriter00001', - resource_type: 'field', - snapshot: JSON.stringify({ - fields: [{ id: 'fldCompatA00000001' }], - records: [{ id: 'recCompat000000001' }], - }), + expect(query.values).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'opCompatDelete000001', + table_id: 'tblCompatTable0001', + created_by: 'usrCompatWriter00001', + resource_type: 'field', + }) + ); + const insertPayload = query.values.mock.calls[0]?.[0] as { snapshot: string }; + expect(JSON.parse(insertPayload.snapshot)).toEqual({ + fields: [ + expect.objectContaining({ + id: 'fldCompatA00000001', + name: 'Text Field', + type: 'singleLineText', + references: ['fldCompatA00000001', 'fldDependent0000001'], + columnMeta: { + viwCompat000000001: { order: 2, hidden: false }, + }, + }), + ], + records: [ + { + id: 'recCompat000000001', + fields: { + fldCompatA00000001: 'fldCompatA00000001:value', + }, + }, + ], }); expect(query.execute).toHaveBeenCalledTimes(1); }); + + it('reuses the table DTO while building legacy payloads for bulk field deletes', async () => { + const { db, query } = createInsertDb(); + const tableMapper = createTableMapper(); + const table = { kind: 'domainTable' }; + const completion = new V2FieldDeleteCompatCompletion( + createV2ContainerService(db, tableMapper) as never, + { + batchUpdateViewByOps: vi.fn(), + } as never, + { + tableId: 'tblCompatTable0001', + userId: 'usrCompatWriter00001', + operationId: 'opCompatDelete000002', + frozenFieldOps: {}, + snapshots: [ + createSnapshotItem('fldCompatA00000001', table), + createSnapshotItem('fldCompatB00000001', table), + ], + referencesByFieldId: new Map>(), + } + ); + + const result = await completion.complete({} as never); + + expect(result._unsafeUnwrap()).toBeUndefined(); + expect(tableMapper.toDTO).toHaveBeenCalledTimes(1); + + const insertPayload = query.values.mock.calls[0]?.[0] as { snapshot: string }; + const snapshot = JSON.parse(insertPayload.snapshot); + expect(snapshot.fields).toHaveLength(2); + expect(snapshot.records).toEqual([ + { + id: 'recCompat000000001', + fields: { + fldCompatA00000001: 'fldCompatA00000001:value', + fldCompatB00000001: 'fldCompatB00000001:value', + }, + }, + ]); + }); + + it('writes conditional lookup snapshots in the legacy field shape', async () => { + const { db, query } = createInsertDb(); + const tableMapper = createConditionalLookupTableMapper(); + const completion = new V2FieldDeleteCompatCompletion( + createV2ContainerService(db, tableMapper) as never, + { + batchUpdateViewByOps: vi.fn(), + } as never, + { + tableId: 'tblCompatTable0001', + userId: 'usrCompatWriter00001', + operationId: 'opCompatDelete000002', + frozenFieldOps: {}, + snapshots: [ + { + table: { kind: 'domainTable' }, + snapshot: { + field: { + id: 'fldCondLookup000001', + name: 'Conditional Lookup', + type: 'conditionalLookup', + }, + views: [ + { + viewId: 'viwCompat000000001', + columnMeta: { order: 1, hidden: false }, + }, + ], + }, + }, + ] as never, + referencesByFieldId: new Map>(), + } + ); + + const executionContext = {} as never; + + const result = await completion.complete(executionContext); + + expect(result._unsafeUnwrap()).toBeUndefined(); + const insertPayload = query.values.mock.calls[0]?.[0] as { snapshot: string }; + const snapshot = JSON.parse(insertPayload.snapshot); + expect(snapshot.fields[0]).toMatchObject({ + id: 'fldCondLookup000001', + type: 'number', + isLookup: true, + isConditionalLookup: true, + lookupOptions: { + foreignTableId: 'tblForeign00000001', + lookupFieldId: 'fldForeign00000001', + filter: { + conjunction: 'and', + filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }], + }, + sort: { fieldId: 'fldScore0000000001', order: 'desc' }, + limit: 5, + }, + options: { formatting: { type: 'decimal', precision: 2 } }, + }); + }); }); diff --git a/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.ts b/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.ts index ce135446d8..7bb644b7d0 100644 --- a/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.ts +++ b/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.ts @@ -1,14 +1,39 @@ import { Injectable, Logger } from '@nestjs/common'; +import { + CellValueType, + DbFieldType, + FieldType, + ViewOpBuilder, + ViewType, + generateOperationId, + getDbFieldType, + type IFieldVo, + type IGridColumnMeta, + type IGridViewOptions, + type IOtOperation, +} from '@teable/core'; import { ResourceType } from '@teable/openapi'; -import { v2DataDbTokens } from '@teable/v2-adapter-db-postgres-pg'; -import { FieldDeleted, ProjectionHandler, ok } from '@teable/v2-core'; -import type { DomainError, IEventHandler, IExecutionContext, Result } from '@teable/v2-core'; +import { v2DataDbTokens, v2MetaDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import { domainError, ok, v2CoreTokens } from '@teable/v2-core'; +import type { + DomainError, + FieldDeleteSnapshotSinkInput, + FieldDeleteSnapshotItem, + IExecutionContext, + IFieldDeleteSnapshotSink, + IFieldDeleteSnapshotSinkCompletion, + ITableFieldPersistenceDTO, + ITableMapper, + ITablePersistenceDTO, + Result, + UndoRedoFieldSnapshot, +} from '@teable/v2-core'; import type { DependencyContainer } from '@teable/v2-di'; import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; import type { Kysely } from 'kysely'; +import { err } from 'neverthrow'; +import { adjustFrozenField } from '../view/utils/derive-frozen-fields'; import { V2ContainerService } from './v2-container.service'; -import { V2_FIELD_DELETE_COMPAT_CONTEXT_KEY } from './v2-field-delete-compat.constants'; -import type { IV2FieldDeleteCompatContext } from './v2-field-delete-compat.constants'; import { V2ProjectionRegistrar, type IV2ProjectionRegistrar } from './v2-projection-registrar'; import { V2ViewCompatService } from './v2-view-compat.service'; @@ -24,76 +49,334 @@ type IV2FieldDeleteCompatDb = V1TeableDatabase & { }; /* eslint-enable @typescript-eslint/naming-convention */ -const getFieldDeleteCompatContext = ( - context: IExecutionContext, - event: FieldDeleted -): IV2FieldDeleteCompatContext | undefined => { - const compatContext = ( - context as IExecutionContext & { - [V2_FIELD_DELETE_COMPAT_CONTEXT_KEY]?: IV2FieldDeleteCompatContext; - } - )[V2_FIELD_DELETE_COMPAT_CONTEXT_KEY]; +type IReferenceRow = Pick; + +type ILegacyColumnMeta = NonNullable; + +type ILegacyDeletedField = Omit & { + columnMeta?: Record; + references?: string[]; +}; + +type ILegacyDeleteFieldsPayloadSnapshot = { + fields: ILegacyDeletedField[]; + records?: Array<{ id: string; fields: Record }>; +}; + +type ILegacyLookupOptions = NonNullable; +type ILegacyConditionalLookupOptions = Extract; + +type ISnapshotFieldExtra = { + isMultipleCellValue?: boolean; + isLookup?: boolean; + isConditionalLookup?: boolean; + lookupOptions?: IFieldVo['lookupOptions']; +}; + +type IFieldDtoExtra = { + cellValueType?: string; + isMultipleCellValue?: boolean; + meta?: unknown; + options?: unknown; + aiConfig?: unknown; + lookupOptions?: unknown; +}; - if (!compatContext || compatContext.completed) { - return undefined; +type IV2FieldDeleteCompatCompletionInput = { + tableId: string; + userId: string; + operationId: string; + frozenFieldOps: Record; + snapshots: ReadonlyArray; + referencesByFieldId: ReadonlyMap>; +}; + +const cellValueTypeFromFieldType = (fieldType: string, fallback?: string): CellValueType => { + if (Object.values(CellValueType).includes(fallback as CellValueType)) { + return fallback as CellValueType; } - if (compatContext.tableId !== event.tableId.toString()) { - return undefined; + switch (fieldType) { + case FieldType.Number: + case FieldType.Rating: + case FieldType.AutoNumber: + return CellValueType.Number; + case FieldType.Checkbox: + return CellValueType.Boolean; + case FieldType.Date: + case FieldType.CreatedTime: + case FieldType.LastModifiedTime: + return CellValueType.DateTime; + default: + return CellValueType.String; } +}; + +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === 'object' && !Array.isArray(value)); + +const fieldDtoExtra = (fieldDto: ITableFieldPersistenceDTO): IFieldDtoExtra => + fieldDto as IFieldDtoExtra; - return compatContext; +const snapshotFieldExtra = (snapshot: UndoRedoFieldSnapshot): ISnapshotFieldExtra => + snapshot.field as ISnapshotFieldExtra; + +const fieldDescription = (fieldDto: ITableFieldPersistenceDTO): string | undefined => + fieldDto.description ?? undefined; + +const fieldOptions = (fieldDto: ITableFieldPersistenceDTO): IFieldVo['options'] => { + const options = fieldDtoExtra(fieldDto).options; + return (isRecord(options) ? options : {}) as IFieldVo['options']; }; -@ProjectionHandler(FieldDeleted) -export class V2FieldDeletedCompatProjection implements IEventHandler { - constructor( - private readonly v2ContainerService: V2ContainerService, - private readonly v2ViewCompatService: V2ViewCompatService - ) {} +const fieldMeta = (fieldDto: ITableFieldPersistenceDTO): IFieldVo['meta'] => + fieldDtoExtra(fieldDto).meta as IFieldVo['meta']; - async handle( - context: IExecutionContext, - event: FieldDeleted - ): Promise> { - const compatContext = getFieldDeleteCompatContext(context, event); - if (!compatContext) { - return ok(undefined); +const fieldAiConfig = (fieldDto: ITableFieldPersistenceDTO): IFieldVo['aiConfig'] => + fieldDtoExtra(fieldDto).aiConfig as IFieldVo['aiConfig']; + +const fieldLookupOptions = (fieldDto: ITableFieldPersistenceDTO): IFieldVo['lookupOptions'] => { + const lookupOptions = fieldDtoExtra(fieldDto).lookupOptions; + return isRecord(lookupOptions) ? (lookupOptions as IFieldVo['lookupOptions']) : undefined; +}; + +const conditionalLookupLegacyOptions = ( + options: Record +): IFieldVo['lookupOptions'] => { + const condition = isRecord(options.condition) ? options.condition : undefined; + const lookupOptions = { + ...(typeof options.foreignTableId === 'string' + ? { foreignTableId: options.foreignTableId } + : {}), + ...(typeof options.lookupFieldId === 'string' ? { lookupFieldId: options.lookupFieldId } : {}), + ...(condition?.filter !== undefined + ? { filter: condition.filter as ILegacyConditionalLookupOptions['filter'] } + : {}), + ...(condition?.sort !== undefined + ? { sort: condition.sort as ILegacyConditionalLookupOptions['sort'] } + : {}), + ...(typeof condition?.limit === 'number' ? { limit: condition.limit } : {}), + }; + + return Object.keys(lookupOptions).length > 0 + ? (lookupOptions as ILegacyLookupOptions) + : undefined; +}; + +const legacyFieldFromDto = ( + fieldDto: ITableFieldPersistenceDTO, + snapshot: UndoRedoFieldSnapshot +): IFieldVo => { + if (fieldDto.type === 'conditionalLookup') { + const options = isRecord(fieldDto.options) ? fieldDto.options : {}; + const innerType = (fieldDto.innerType ?? FieldType.SingleLineText) as IFieldVo['type']; + return legacyFieldFromDto( + { + ...fieldDto, + type: innerType as ITableFieldPersistenceDTO['type'], + options: fieldDto.innerOptions as ITableFieldPersistenceDTO['options'], + } as ITableFieldPersistenceDTO, + { + ...snapshot, + field: { + ...snapshot.field, + type: innerType, + isLookup: true, + isConditionalLookup: true, + lookupOptions: conditionalLookupLegacyOptions(options), + } as unknown as UndoRedoFieldSnapshot['field'], + } + ); + } + + const type = fieldDto.type as FieldType; + const extra = fieldDtoExtra(fieldDto); + const snapshotExtra = snapshotFieldExtra(snapshot); + const cellValueType = cellValueTypeFromFieldType(fieldDto.type, extra.cellValueType); + const isMultipleCellValue = extra.isMultipleCellValue ?? snapshotExtra.isMultipleCellValue; + const dbFieldType = Object.values(DbFieldType).includes(fieldDto.dbFieldType as DbFieldType) + ? (fieldDto.dbFieldType as DbFieldType) + : getDbFieldType(type, cellValueType, isMultipleCellValue); + + return { + id: fieldDto.id, + name: fieldDto.name, + type, + description: fieldDescription(fieldDto), + options: fieldOptions(fieldDto), + meta: fieldMeta(fieldDto), + aiConfig: fieldAiConfig(fieldDto), + isLookup: snapshotExtra.isLookup, + isConditionalLookup: snapshotExtra.isConditionalLookup, + lookupOptions: snapshotExtra.lookupOptions ?? fieldLookupOptions(fieldDto), + notNull: fieldDto.notNull, + unique: fieldDto.unique, + isPrimary: snapshot.field.isPrimary, + isComputed: fieldDto.isComputed, + hasError: fieldDto.hasError, + cellValueType, + isMultipleCellValue, + dbFieldType, + dbFieldName: fieldDto.dbFieldName ?? fieldDto.id, + }; +}; + +const legacyRecordsFromSnapshots = ( + snapshots: ReadonlyArray +): ILegacyDeleteFieldsPayloadSnapshot['records'] => { + const recordsById = new Map }>(); + + for (const { snapshot } of snapshots) { + const fieldId = snapshot.field.id; + for (const record of snapshot.records ?? []) { + const item = recordsById.get(record.recordId) ?? { id: record.recordId, fields: {} }; + item.fields[fieldId] = record.value; + recordsById.set(record.recordId, item); + } + } + + return recordsById.size > 0 ? [...recordsById.values()] : undefined; +}; + +const legacyPayloadFromSnapshots = ( + tableMapper: ITableMapper, + snapshots: ReadonlyArray, + referencesByFieldId: ReadonlyMap> +): Result => { + const fields: ILegacyDeleteFieldsPayloadSnapshot['fields'] = []; + const fieldIds = snapshots.map(({ snapshot }) => snapshot.field.id); + const tableDtoByTable = new WeakMap(); + + for (const { table, snapshot } of snapshots) { + let tableDto = tableDtoByTable.get(table); + if (!tableDto) { + const tableDtoResult = tableMapper.toDTO(table); + if (tableDtoResult.isErr()) { + return err(tableDtoResult.error); + } + tableDto = tableDtoResult.value; + tableDtoByTable.set(table, tableDto); } - const fieldId = event.fieldId.toString(); - if (!compatContext.remainingFieldIds.has(fieldId)) { - return ok(undefined); + const fieldDto = tableDto.fields.find((candidate) => candidate.id === snapshot.field.id); + if (!fieldDto) { + return err(domainError.notFound({ message: 'Field snapshot source not found' })); } - compatContext.remainingFieldIds.delete(fieldId); - if (compatContext.remainingFieldIds.size > 0) { - return ok(undefined); + const columnMeta: Record = Object.fromEntries( + snapshot.views.flatMap((view) => + view.columnMeta == null ? [] : [[view.viewId, view.columnMeta]] + ) + ); + + fields.push({ + ...legacyFieldFromDto(fieldDto, snapshot), + ...(Object.keys(columnMeta).length > 0 ? { columnMeta } : {}), + references: [...fieldIds, ...(referencesByFieldId.get(snapshot.field.id) ?? [])], + }); + } + + return ok({ + fields, + records: legacyRecordsFromSnapshots(snapshots), + }); +}; + +const buildFrozenFieldDeleteOps = ( + tableMapper: ITableMapper, + snapshots: ReadonlyArray, + fieldIds: ReadonlyArray +): Result, DomainError> => { + const fieldIdSet = new Set(fieldIds); + const opsMap: Record = {}; + + for (const { table, snapshot } of snapshots) { + const tableDtoResult = tableMapper.toDTO(table); + if (tableDtoResult.isErr()) { + return err(tableDtoResult.error); } - compatContext.completed = true; + for (const view of tableDtoResult.value.views) { + if (view.type !== ViewType.Grid || opsMap[view.id]) { + continue; + } + + const columnMetaUpdate = Object.fromEntries( + [...fieldIdSet].map((fieldId) => [fieldId, null]) + ); + const nextOptions = adjustFrozenField( + (view.options ?? {}) as IGridViewOptions, + view.columnMeta as IGridColumnMeta, + columnMetaUpdate as unknown as IGridColumnMeta + ); + if (!nextOptions) { + continue; + } + + opsMap[view.id] = [ + ViewOpBuilder.editor.setViewProperty.build({ + key: 'options', + oldValue: view.options ?? {}, + newValue: nextOptions, + }), + ]; + } + } + + return ok(opsMap); +}; + +const getReferencesByFieldId = ( + references: ReadonlyArray +): ReadonlyMap> => { + const referencesByFieldId = new Map(); + for (const reference of references) { + const values = referencesByFieldId.get(reference.from_field_id) ?? []; + values.push(reference.to_field_id); + referencesByFieldId.set(reference.from_field_id, values); + } + return referencesByFieldId; +}; + +export class V2FieldDeleteCompatCompletion implements IFieldDeleteSnapshotSinkCompletion { + constructor( + private readonly v2ContainerService: V2ContainerService, + private readonly v2ViewCompatService: V2ViewCompatService, + private readonly input: IV2FieldDeleteCompatCompletionInput + ) {} - if (Object.keys(compatContext.frozenFieldOps).length > 0) { + async complete(context: IExecutionContext): Promise> { + if (Object.keys(this.input.frozenFieldOps).length > 0) { await this.v2ViewCompatService.batchUpdateViewByOps( - compatContext.tableId, - compatContext.frozenFieldOps, + this.input.tableId, + this.input.frozenFieldOps, context ); } - const container = await this.v2ContainerService.getContainerForTable(compatContext.tableId); + const container = await this.v2ContainerService.getContainerForTable(this.input.tableId); const db = container.resolve>(v2DataDbTokens.db); + const tableMapper = container.resolve(v2CoreTokens.tableMapper); + const legacyPayloadResult = legacyPayloadFromSnapshots( + tableMapper, + this.input.snapshots, + this.input.referencesByFieldId + ); + if (legacyPayloadResult.isErr()) { + return err(legacyPayloadResult.error); + } + const legacyPayload = legacyPayloadResult.value; await db .insertInto('table_trash') .values({ - id: compatContext.operationId, - table_id: compatContext.tableId, - created_by: compatContext.userId, + id: this.input.operationId, + table_id: this.input.tableId, + created_by: this.input.userId, resource_type: ResourceType.Field, snapshot: JSON.stringify({ - fields: compatContext.legacyDeletePayload.fields, - records: compatContext.legacyDeletePayload.records, + fields: legacyPayload.fields, + records: legacyPayload.records, }), }) .execute(); @@ -102,6 +385,45 @@ export class V2FieldDeletedCompatProjection implements IEventHandler, + private readonly v2ContainerService: V2ContainerService, + private readonly v2ViewCompatService: V2ViewCompatService + ) {} + + async prepare( + context: IExecutionContext, + input: FieldDeleteSnapshotSinkInput + ): Promise> { + const frozenFieldOpsResult = buildFrozenFieldDeleteOps( + this.tableMapper, + input.snapshots, + input.fieldIds + ); + if (frozenFieldOpsResult.isErr()) { + return err(frozenFieldOpsResult.error); + } + const references = await this.metaDb + .selectFrom('reference') + .select(['from_field_id', 'to_field_id']) + .where('from_field_id', 'in', [...input.fieldIds]) + .execute(); + + return ok( + new V2FieldDeleteCompatCompletion(this.v2ContainerService, this.v2ViewCompatService, { + tableId: input.tableId, + userId: context.actorId.toString(), + operationId: generateOperationId(), + frozenFieldOps: frozenFieldOpsResult.value, + snapshots: [...input.snapshots], + referencesByFieldId: getReferencesByFieldId(references), + }) + ); + } +} + @V2ProjectionRegistrar() @Injectable() export class V2FieldDeleteCompatService implements IV2ProjectionRegistrar { @@ -114,9 +436,16 @@ export class V2FieldDeleteCompatService implements IV2ProjectionRegistrar { registerProjections(container: DependencyContainer): void { this.logger.debug('Registering V2 field delete compatibility projections'); + const tableMapper = container.resolve(v2CoreTokens.tableMapper); + const metaDb = container.resolve>(v2MetaDbTokens.db); container.registerInstance( - V2FieldDeletedCompatProjection, - new V2FieldDeletedCompatProjection(this.v2ContainerService, this.v2ViewCompatService) + v2CoreTokens.fieldDeleteSnapshotSink, + new V2FieldDeleteSnapshotSink( + tableMapper, + metaDb, + this.v2ContainerService, + this.v2ViewCompatService + ) ); } } diff --git a/apps/nestjs-backend/src/features/v2/v2-record-history.service.spec.ts b/apps/nestjs-backend/src/features/v2/v2-record-history.service.spec.ts index ae3cc105a4..9bfa669444 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 @@ -3,6 +3,7 @@ import { v2DataDbTokens } from '@teable/v2-adapter-db-postgres-pg'; import { describe, expect, it, vi } from 'vitest'; import { Events } from '../../event-emitter/events'; import { + V2RecordsBatchCreatedHistoryProjection, V2RecordsBatchUpdatedHistoryProjection, V2RecordUpdatedHistoryProjection, } from './v2-record-history.service'; @@ -41,6 +42,23 @@ const createTable = (fields: Array>) => ({ }, }); +const createScheduledContext = (actorId: string) => { + const scheduled: Array<() => Promise | void> = []; + const context = { + actorId: { toString: () => actorId }, + scheduleBackgroundTask: vi.fn((task: () => Promise | void) => { + scheduled.push(task); + }), + }; + return { context, scheduled }; +}; + +const flushScheduled = async (scheduled: Array<() => Promise | void>) => { + while (scheduled.length) { + await scheduled.shift()?.(); + } +}; + const createV2ContainerService = () => { const query = { values: vi.fn().mockReturnThis(), @@ -64,6 +82,7 @@ const createV2ContainerService = () => { query, service: { getContainer: vi.fn().mockResolvedValue(container), + getContainerForTable: vi.fn().mockResolvedValue(container), }, }; }; @@ -71,9 +90,6 @@ const createV2ContainerService = () => { describe('V2RecordUpdatedHistoryProjection', () => { it('writes record history entries through the v2 db container', async () => { const { db, query, service: v2ContainerService } = createV2ContainerService(); - const cls = { - get: vi.fn().mockReturnValue('usrHistWriter00000001'), - }; const tableQueryService = { getById: vi .fn() @@ -84,14 +100,14 @@ describe('V2RecordUpdatedHistoryProjection', () => { }; const projection = new V2RecordUpdatedHistoryProjection( v2ContainerService as never, - cls as never, { recordHistoryDisabled: false } as never, tableQueryService as never, eventEmitterService as never ); + const { context, scheduled } = createScheduledContext('usrHistWriter00000001'); const result = await projection.handle( - {} as never, + context as never, { source: 'user', tableId: { toString: () => 'tblHistTable0000001' }, @@ -107,6 +123,10 @@ describe('V2RecordUpdatedHistoryProjection', () => { ); expect(result._unsafeUnwrap()).toBeUndefined(); + expect(db.insertInto).not.toHaveBeenCalled(); + + await flushScheduled(scheduled); + expect(db.insertInto).toHaveBeenCalledWith('record_history'); const [rows] = query.values.mock.calls[0] as [Array>]; expect(rows).toHaveLength(1); @@ -141,12 +161,84 @@ describe('V2RecordUpdatedHistoryProjection', () => { }); }); +describe('V2RecordsBatchCreatedHistoryProjection', () => { + it('writes created record history entries through the routed v2 data DB', async () => { + const { db, query, 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' }, + records: [ + { + recordId: 'recHistRecord000001', + fields: [{ fieldId: 'fldHistField0000001', value: 'created-1' }], + }, + { + recordId: 'recHistRecord000002', + fields: [{ fieldId: 'fldHistField0000001', value: 'created-2' }], + }, + ], + } as never + ); + + expect(result._unsafeUnwrap()).toBeUndefined(); + expect(db.insertInto).not.toHaveBeenCalled(); + + await flushScheduled(scheduled); + + expect(v2ContainerService.getContainerForTable).toHaveBeenCalledWith('tblHistTable0000001'); + expect(db.insertInto).toHaveBeenCalledWith('record_history'); + const [rows] = query.values.mock.calls[0] as [Array>]; + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + table_id: 'tblHistTable0000001', + record_id: 'recHistRecord000001', + field_id: 'fldHistField0000001', + created_by: 'usrBatchCreator00001', + }); + expect(JSON.parse(rows[0].before)).toEqual({ + meta: { + type: CoreFieldType.SingleLineText, + name: 'Name', + options: null, + cellValueType: 'string', + }, + data: null, + }); + expect(JSON.parse(rows[0].after)).toEqual({ + meta: { + type: CoreFieldType.SingleLineText, + name: 'Name', + options: null, + cellValueType: 'string', + }, + data: 'created-1', + }); + expect(eventEmitterService.emit).toHaveBeenCalledWith(Events.RECORD_HISTORY_CREATE, { + recordIds: ['recHistRecord000001', 'recHistRecord000002'], + }); + }); +}); + describe('V2RecordsBatchUpdatedHistoryProjection', () => { it('writes batch record history entries through the v2 db container', async () => { const { db, query, service: v2ContainerService } = createV2ContainerService(); - const cls = { - get: vi.fn().mockReturnValue('usrBatchWriter0000001'), - }; const tableQueryService = { getById: vi .fn() @@ -157,14 +249,14 @@ describe('V2RecordsBatchUpdatedHistoryProjection', () => { }; const projection = new V2RecordsBatchUpdatedHistoryProjection( v2ContainerService as never, - cls as never, { recordHistoryDisabled: false } as never, tableQueryService as never, eventEmitterService as never ); + const { context, scheduled } = createScheduledContext('usrBatchWriter0000001'); const result = await projection.handle( - {} as never, + context as never, { source: 'user', tableId: { toString: () => 'tblHistTable0000001' }, @@ -194,6 +286,10 @@ describe('V2RecordsBatchUpdatedHistoryProjection', () => { ); expect(result._unsafeUnwrap()).toBeUndefined(); + expect(db.insertInto).not.toHaveBeenCalled(); + + await flushScheduled(scheduled); + expect(db.insertInto).toHaveBeenCalledWith('record_history'); const [rows] = query.values.mock.calls[0] as [Array>]; expect(rows).toHaveLength(2); 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 577aac3b0e..6646ee6bb9 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 @@ -10,10 +10,13 @@ import { FieldValueTypeVisitor, ProjectionHandler, RecordUpdated, + RecordsBatchCreated, RecordsBatchUpdated, TableQueryService, ok, + scheduleExecutionContextBackgroundTask, v2CoreTokens, + withoutTransaction, } from '@teable/v2-core'; import type { DomainError, @@ -29,15 +32,34 @@ import type { DependencyContainer } from '@teable/v2-di'; import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; import type { ColumnType, Kysely } from 'kysely'; import { isEqual, isString } from 'lodash'; -import { ClsService } from 'nestjs-cls'; import { BaseConfig, IBaseConfig } from '../../configs/base.config'; import { EventEmitterService } from '../../event-emitter/event-emitter.service'; import { Events } from '../../event-emitter/events'; -import type { IClsStore } from '../../types/cls'; import { V2ContainerService } from './v2-container.service'; import { V2ProjectionRegistrar, type IV2ProjectionRegistrar } from './v2-projection-registrar'; const SELECT_FIELD_TYPE_SET = new Set([CoreFieldType.SingleSelect, CoreFieldType.MultipleSelect]); +const recordHistoryProjectionLogger = new Logger('V2RecordHistoryProjection'); + +const scheduleRecordHistoryRun = ( + context: IExecutionContext, + task: (backgroundContext: IExecutionContext) => Promise, + eventType: string +): void => { + const backgroundContext = withoutTransaction(context); + scheduleExecutionContextBackgroundTask(backgroundContext, async () => { + try { + await task(backgroundContext); + } catch (error) { + recordHistoryProjectionLogger.error( + `Error handling ${eventType} record history projection: ${ + error instanceof Error ? error.message : String(error) + }`, + error instanceof Error ? error.stack : undefined + ); + } + }); +}; interface IRecordHistoryEntry { id: string; @@ -243,7 +265,6 @@ const buildHistoryValue = ( export class V2RecordUpdatedHistoryProjection implements IEventHandler { constructor( private readonly v2ContainerService: V2ContainerService, - private readonly cls: ClsService, private readonly baseConfig: IBaseConfig, private readonly tableQueryService: TableQueryService, private readonly eventEmitterService: EventEmitterService @@ -253,29 +274,42 @@ export class V2RecordUpdatedHistoryProjection implements IEventHandler> { - // Check if record history is disabled if (this.baseConfig.recordHistoryDisabled) { return ok(undefined); } - // Skip computed updates - we only track user-initiated changes if (event.source === 'computed') { return ok(undefined); } - const tableIdStr = event.tableId.toString(); - const recordId = event.recordId.toString(); - const userId = this.cls.get('user.id'); - - // Get field IDs from changes if (event.changes.length === 0) { return ok(undefined); } + scheduleRecordHistoryRun( + context, + (backgroundContext) => this.writeRecordUpdatedHistory(backgroundContext, event), + 'record update' + ); + + return ok(undefined); + } + + private async writeRecordUpdatedHistory( + context: IExecutionContext, + event: RecordUpdated + ): Promise { + const tableIdStr = event.tableId.toString(); + const recordId = event.recordId.toString(); + // Use the actor captured in the event's execution snapshot, not CLS. + // CLS (AsyncLocalStorage) is read at drain time, which runs in an unrelated + // request's async context, so it would attribute history to the wrong user. + const userId = context.actorId.toString(); + // Load table from V2 domain const tableResult = await this.tableQueryService.getById(context, event.tableId); if (tableResult.isErr()) { - return ok(undefined); // Silently skip if table not found + return; // Silently skip if table not found } const table = tableResult.value; @@ -323,9 +357,117 @@ export class V2RecordUpdatedHistoryProjection implements IEventHandler { + constructor( + private readonly v2ContainerService: V2ContainerService, + private readonly baseConfig: IBaseConfig, + private readonly tableQueryService: TableQueryService, + private readonly eventEmitterService: EventEmitterService + ) {} + + async handle( + context: IExecutionContext, + event: RecordsBatchCreated + ): Promise> { + if (this.baseConfig.recordHistoryDisabled) { + return ok(undefined); + } + + const fieldIdSet = new Set(); + for (const record of event.records) { + for (const field of record.fields) { + fieldIdSet.add(field.fieldId); + } + } + + if (fieldIdSet.size === 0) { + return ok(undefined); + } + + scheduleRecordHistoryRun( + context, + (backgroundContext) => + this.writeRecordsBatchCreatedHistory(backgroundContext, event, fieldIdSet), + 'batch record create' + ); return ok(undefined); } + + private async writeRecordsBatchCreatedHistory( + context: IExecutionContext, + event: RecordsBatchCreated, + fieldIdSet: Set + ): Promise { + const tableIdStr = event.tableId.toString(); + // Use the actor captured in the event's execution snapshot, not CLS. + // CLS (AsyncLocalStorage) is read at drain time, which runs in an unrelated + // request's async context, so it would attribute history to the wrong user. + const userId = context.actorId.toString(); + + const tableResult = await this.tableQueryService.getById(context, event.tableId); + if (tableResult.isErr()) { + return; + } + const table = tableResult.value; + + const fieldMetaMap = new Map(); + for (const fieldIdStr of fieldIdSet) { + const fieldIdResult = FieldId.create(fieldIdStr); + if (fieldIdResult.isErr()) continue; + + const fieldResult = table.getField((f) => f.id().equals(fieldIdResult.value)); + if (fieldResult.isOk()) { + fieldMetaMap.set(fieldIdStr, extractFieldMeta(fieldResult.value)); + } + } + + const recordHistoryList: IRecordHistoryEntry[] = []; + const recordIds: string[] = []; + const batchSize = 5000; + + for (const record of event.records) { + recordIds.push(record.recordId); + + for (const field of record.fields) { + const value = field.value; + if (value === '' || value == null) continue; + + const meta = fieldMetaMap.get(field.fieldId); + if (!meta || meta.isComputed) continue; + + recordHistoryList.push({ + id: generateRecordHistoryId(), + table_id: tableIdStr, + record_id: record.recordId, + field_id: field.fieldId, + before: JSON.stringify(buildHistoryValue(null, meta)), + after: JSON.stringify(buildHistoryValue(value, meta)), + created_by: userId as string, + }); + } + } + + const db = await getRecordHistoryDb(this.v2ContainerService, tableIdStr); + for (let i = 0; i < recordHistoryList.length; i += batchSize) { + const batch = recordHistoryList.slice(i, i + batchSize); + await insertRecordHistoryEntries(db, batch); + } + + if (recordIds.length > 0) { + this.eventEmitterService.emit(Events.RECORD_HISTORY_CREATE, { + recordIds, + }); + } + } } /** @@ -336,7 +478,6 @@ export class V2RecordUpdatedHistoryProjection implements IEventHandler { constructor( private readonly v2ContainerService: V2ContainerService, - private readonly cls: ClsService, private readonly baseConfig: IBaseConfig, private readonly tableQueryService: TableQueryService, private readonly eventEmitterService: EventEmitterService @@ -346,20 +487,14 @@ export class V2RecordsBatchUpdatedHistoryProjection implements IEventHandler> { - // Check if record history is disabled if (this.baseConfig.recordHistoryDisabled) { return ok(undefined); } - // Skip computed updates if (event.source === 'computed') { return ok(undefined); } - const tableIdStr = event.tableId.toString(); - const userId = this.cls.get('user.id'); - - // Collect all field IDs from all updates const fieldIdSet = new Set(); for (const update of event.updates) { for (const change of update.changes) { @@ -371,10 +506,31 @@ export class V2RecordsBatchUpdatedHistoryProjection implements IEventHandler + this.writeRecordsBatchUpdatedHistory(backgroundContext, event, fieldIdSet), + 'batch record update' + ); + + return ok(undefined); + } + + private async writeRecordsBatchUpdatedHistory( + context: IExecutionContext, + event: RecordsBatchUpdated, + fieldIdSet: Set + ): Promise { + const tableIdStr = event.tableId.toString(); + // Use the actor captured in the event's execution snapshot, not CLS. + // CLS (AsyncLocalStorage) is read at drain time, which runs in an unrelated + // request's async context, so it would attribute history to the wrong user. + const userId = context.actorId.toString(); + // Load table from V2 domain const tableResult = await this.tableQueryService.getById(context, event.tableId); if (tableResult.isErr()) { - return ok(undefined); // Silently skip if table not found + return; // Silently skip if table not found } const table = tableResult.value; @@ -435,8 +591,6 @@ export class V2RecordsBatchUpdatedHistoryProjection implements IEventHandler, @BaseConfig() private readonly baseConfig: IBaseConfig, private readonly eventEmitterService: EventEmitterService ) {} @@ -470,7 +623,16 @@ export class V2RecordHistoryService implements IV2ProjectionRegistrar { V2RecordUpdatedHistoryProjection, new V2RecordUpdatedHistoryProjection( this.v2ContainerService, - this.cls, + this.baseConfig, + tableQueryService, + this.eventEmitterService + ) + ); + + container.registerInstance( + V2RecordsBatchCreatedHistoryProjection, + new V2RecordsBatchCreatedHistoryProjection( + this.v2ContainerService, this.baseConfig, tableQueryService, this.eventEmitterService @@ -481,7 +643,6 @@ export class V2RecordHistoryService implements IV2ProjectionRegistrar { V2RecordsBatchUpdatedHistoryProjection, new V2RecordsBatchUpdatedHistoryProjection( this.v2ContainerService, - this.cls, this.baseConfig, tableQueryService, this.eventEmitterService diff --git a/apps/nestjs-backend/src/features/v2/v2-undo-redo.constants.ts b/apps/nestjs-backend/src/features/v2/v2-undo-redo.constants.ts deleted file mode 100644 index 87ab0b1642..0000000000 --- a/apps/nestjs-backend/src/features/v2/v2-undo-redo.constants.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { IFieldVo } from '@teable/core'; - -export const V2_FIELD_CONVERT_UNDO_CONTEXT_KEY = '__teable_v2_field_convert_undo_context'; - -export interface IV2FieldConvertUndoContext { - tableId: string; - fieldId: string; - oldField: IFieldVo; -} diff --git a/apps/nestjs-backend/src/features/v2/v2-view-compat.service.spec.ts b/apps/nestjs-backend/src/features/v2/v2-view-compat.service.spec.ts index c3bde87f36..887820fed9 100644 --- a/apps/nestjs-backend/src/features/v2/v2-view-compat.service.spec.ts +++ b/apps/nestjs-backend/src/features/v2/v2-view-compat.service.spec.ts @@ -1,5 +1,6 @@ import { IdPrefix, ViewOpBuilder } from '@teable/core'; import { describe, expect, it, vi } from 'vitest'; +import { RawOpType } from '../../share-db/interface'; const mockV2Tokens = vi.hoisted(() => ({ v2MetaDbTokens: { @@ -69,8 +70,12 @@ const createV2ContextFactory = () => ({ }), }); +const createBatchService = () => ({ + saveRawOps: vi.fn(), +}); + describe('V2ViewCompatService', () => { - it('updates matching views through the v2 db and stores raw ops in cls state', async () => { + it('updates matching views through the v2 db and stores raw ops through the raw-op sink', async () => { const executeSelect = vi.fn().mockResolvedValue([{ id: 'viwCompat000000001', version: 3 }]); const selectQuery = { where: vi.fn().mockReturnThis(), @@ -90,23 +95,20 @@ describe('V2ViewCompatService', () => { const viewOperationPluginRunner = createViewOperationPluginRunner(); const v2ContainerService = createV2ContainerService(db, viewOperationPluginRunner); const v2ContextFactory = createV2ContextFactory(); - const clsState = new Map(); + const batchService = createBatchService(); const cls = { - getId: vi.fn().mockReturnValue('cls-request-id'), get: vi.fn((key: string) => { if (key === 'user.id') { return 'usrCompatWriter00001'; } - return clsState.get(key); - }), - set: vi.fn((key: string, value: unknown) => { - clsState.set(key, value); + return undefined; }), }; const service = new V2ViewCompatService( v2ContainerService as never, cls as never, + batchService as never, v2ContextFactory as never ); const ops = [ @@ -142,16 +144,18 @@ describe('V2ViewCompatService', () => { last_modified_by: 'usrCompatWriter00001', }); expect(executeUpdate).toHaveBeenCalledTimes(1); - - const rawOpMaps = clsState.get('tx.rawOpMaps') as Array< - Record> - >; - expect(rawOpMaps).toHaveLength(1); - expect(Object.keys(rawOpMaps[0])).toEqual([`${IdPrefix.View}_tblCompatTable0001`]); - expect(rawOpMaps[0][`${IdPrefix.View}_tblCompatTable0001`].viwCompat000000001).toMatchObject({ - op: ops, - v: 3, - }); + expect(batchService.saveRawOps).toHaveBeenCalledWith( + 'tblCompatTable0001', + RawOpType.Edit, + IdPrefix.View, + [ + { + docId: 'viwCompat000000001', + version: 3, + data: ops, + }, + ] + ); }); it('rejects view updates when the v2 view operation plugin reports a limit error', async () => { @@ -177,14 +181,14 @@ describe('V2ViewCompatService', () => { }; const viewOperationPluginRunner = createViewOperationPluginRunner(errResult(limitError)); const v2ContainerService = createV2ContainerService(db, viewOperationPluginRunner); + const batchService = createBatchService(); const cls = { - getId: vi.fn().mockReturnValue('cls-request-id'), get: vi.fn().mockReturnValue(undefined), - set: vi.fn(), }; const service = new V2ViewCompatService( v2ContainerService as never, cls as never, + batchService as never, createV2ContextFactory() as never ); const ops = [ @@ -206,5 +210,6 @@ describe('V2ViewCompatService', () => { }, }); expect(db.updateTable).not.toHaveBeenCalled(); + expect(batchService.saveRawOps).not.toHaveBeenCalled(); }); }); 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 12adcf0e1d..b1707cb66a 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 @@ -24,8 +24,9 @@ import { snakeCase } from 'lodash'; import { ClsService } from 'nestjs-cls'; import { fromZodError } from 'zod-validation-error'; import { CustomHttpException } from '../../custom.exception'; -import type { IRawOp, IRawOpMap } from '../../share-db/interface'; +import { RawOpType } from '../../share-db/interface'; 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'; @@ -54,6 +55,7 @@ export class V2ViewCompatService { constructor( private readonly v2ContainerService: V2ContainerService, private readonly cls: ClsService, + private readonly batchService: BatchService, private readonly v2ContextFactory: V2ExecutionContextFactory ) {} @@ -113,29 +115,8 @@ export class V2ViewCompatService { private saveRawOps( tableId: string, dataList: { docId: string; version: number; data?: unknown }[] - ): IRawOpMap { - const collection = `${IdPrefix.View}_${tableId}`; - const rawOpMap: IRawOpMap = { [collection]: {} }; - const baseRaw = { - src: this.cls.getId() || 'unknown', - seq: 1, - m: { - ts: Date.now(), - }, - }; - - dataList.forEach(({ docId, version, data }) => { - rawOpMap[collection][docId] = { - ...baseRaw, - op: data as IOtOperation[], - v: version, - } as IRawOp; - }); - - const prevMap = this.cls.get('tx.rawOpMaps') || []; - prevMap.push(rawOpMap); - this.cls.set('tx.rawOpMaps', prevMap); - return rawOpMap; + ) { + return this.batchService.saveRawOps(tableId, RawOpType.Edit, IdPrefix.View, dataList); } private async ensureViewOperation( diff --git a/apps/nestjs-backend/src/features/v2/v2.module.ts b/apps/nestjs-backend/src/features/v2/v2.module.ts index 0b8782e557..e204397a40 100644 --- a/apps/nestjs-backend/src/features/v2/v2.module.ts +++ b/apps/nestjs-backend/src/features/v2/v2.module.ts @@ -5,6 +5,7 @@ import type { Response } from 'express'; import { LoggerModule } from '../../logger/logger.module'; import { ShareDbModule } from '../../share-db/share-db.module'; import { AttachmentsStorageModule } from '../attachments/attachments-storage.module'; +import { CalculationModule } from '../calculation/calculation.module'; import { NotificationModule } from '../notification/notification.module'; import { RecordModule } from '../record/record.module'; import { UndoRedoStackService } from '../undo-redo/stack/undo-redo-stack.service'; @@ -102,6 +103,7 @@ const toErrorMessage = (body: unknown): string => { }), LoggerModule.register(), AttachmentsStorageModule, + CalculationModule, ShareDbModule, NotificationModule, RecordModule, 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 6dc9fff327..5aaac52acb 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 @@ -1,5 +1,5 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; import type { IOtOperation, IViewRo, @@ -20,6 +20,7 @@ import type { import { ViewType, IManualSortRo, + RecordOpBuilder, ViewOpBuilder, generateShareId, VIEW_JSON_KEYS, @@ -33,19 +34,19 @@ import { HttpErrorCode, } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import { PluginPosition, PluginStatus } from '@teable/openapi'; +import { PluginPosition, PluginStatus, IViewShareMetaRo } from '@teable/openapi'; import type { IViewPluginUpdateStorageRo, IGetViewFilterLinkRecordsVo, IUpdateOrderRo, IUpdateRecordOrdersRo, IViewInstallPluginRo, - IViewShareMetaRo, } from '@teable/openapi'; import { Knex } from 'knex'; import { keyBy, pick } from 'lodash'; import { InjectModel } from 'nest-knexjs'; import { ClsService } from 'nestjs-cls'; +import type { EditOp } from 'sharedb'; import { IThresholdConfig, ThresholdConfig } from '../../../configs/threshold.config'; import { CustomHttpException } from '../../../custom.exception'; import { InjectDbProvider } from '../../../db-provider/db.provider'; @@ -54,14 +55,19 @@ import { EventEmitterService } from '../../../event-emitter/event-emitter.servic import { Events } from '../../../event-emitter/events'; import { DatabaseRouter } from '../../../global/database-router.service'; import { DATA_KNEX } from '../../../global/knex/knex.module'; +import { ShareDbService } from '../../../share-db/share-db.service'; import type { IClsStore } from '../../../types/cls'; import { Timing } from '../../../utils/timing'; import { updateMultipleOrders, updateOrder } from '../../../utils/update-order'; +import { AuditScope } from '../../audit/audit-scope'; +import { Audit } from '../../audit/audit.decorator'; import { FieldViewSyncService } from '../../field/field-calculate/field-view-sync.service'; import { FieldService } from '../../field/field.service'; import type { IFieldInstance } from '../../field/model/factory'; import { createFieldInstanceByRaw, createFieldInstanceByVo } from '../../field/model/factory'; import { RecordService } from '../../record/record.service'; +import { SpaceDataDbMigrationGuardService } from '../../space/space-data-db-migration-guard.service'; +import { ROW_ORDER_FIELD_PREFIX } from '../constant'; import { createViewInstanceByRaw } from '../model/factory'; import { ViewService } from '../view.service'; @@ -77,13 +83,45 @@ export class ViewOpenApiService { private readonly fieldService: FieldService, private readonly fieldViewSyncService: FieldViewSyncService, private readonly eventEmitterService: EventEmitterService, + private readonly shareDbService: ShareDbService, private readonly cls: ClsService, + private readonly audit: AuditScope, @InjectDbProvider() private readonly dbProvider: IDbProvider, @InjectModel(DATA_KNEX) private readonly knex: Knex, - @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig + @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig, + @Optional() + private readonly spaceDataDbMigrationGuard?: SpaceDataDbMigrationGuardService ) {} - async createView(tableId: string, viewRo: IViewRo) { + private async assertTableWritable(tableId: string) { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + } + + private async assertViewWritable(viewId: string) { + const view = await this.prismaService.view.findUnique({ + where: { id: viewId }, + select: { tableId: true }, + }); + if (view) { + await this.assertTableWritable(view.tableId); + } + } + + private async ensureGridViewRowOrderColumn(tableId: string, view: IViewVo) { + if (view.type !== ViewType.Grid) { + return; + } + + const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({ + where: { id: tableId }, + select: { dbTableName: true }, + }); + + await this.viewService.getOrCreateViewIndexFieldForTable(tableId, dbTableName, view.id); + } + + async createView(tableId: string, viewRo: IViewRo, options: { ensureRowOrder?: boolean } = {}) { + await this.assertTableWritable(tableId); if (viewRo.type === ViewType.Plugin) { const res = await this.pluginInstall(tableId, { name: viewRo.name, @@ -94,12 +132,19 @@ export class ViewOpenApiService { }); return this.viewService.getViewById(res.viewId); } - return await this.prismaService.$tx(async () => { + const view = await this.prismaService.$tx(async () => { return this.createViewInner(tableId, viewRo); }); + + if (options.ensureRowOrder !== false) { + await this.ensureGridViewRowOrderColumn(tableId, view); + } + + return view; } async deleteView(tableId: string, viewId: string, windowId?: string) { + await this.assertTableWritable(tableId); const result = await this.prismaService.$tx(async () => { await this.fieldViewSyncService.deleteLinkOptionsDependenciesByViewId(tableId, viewId); return await this.deleteViewInner(tableId, viewId); @@ -194,6 +239,7 @@ export class ViewOpenApiService { columnMetaRo: IColumnMetaRo, windowId?: string ) { + await this.assertTableWritable(tableId); const view = await this.prismaService.view .findFirstOrThrow({ where: { tableId, id: viewId }, @@ -289,6 +335,21 @@ export class ViewOpenApiService { } } + @Audit({ + action: Events.SHARED_VIEW_UPDATE, + resourceId: (_tableId: string, viewId: string) => viewId, + params: (tableId: string, viewId: string, viewShareMetaRo: IViewShareMetaRo) => ({ + tableId, + viewId, + // Mirror base-share masking: never log the plaintext share password — record only whether + // one was set. (IViewShareMetaRo.password is optional and was previously stored verbatim.) + shareMeta: { + ...viewShareMetaRo, + password: viewShareMetaRo.password !== undefined ? '[set]' : undefined, + }, + }), + emit: true, + }) async updateShareMeta(tableId: string, viewId: string, viewShareMetaRo: IViewShareMetaRo) { return this.setViewProperty(tableId, viewId, 'shareMeta', viewShareMetaRo); } @@ -394,6 +455,7 @@ export class ViewOpenApiService { newValue: unknown, windowId?: string ) { + await this.assertTableWritable(tableId); const curView = await this.prismaService.view .findFirstOrThrow({ select: { [key]: true }, @@ -451,6 +513,7 @@ export class ViewOpenApiService { } async updateViewByOps(tableId: string, viewId: string, ops: IOtOperation[]) { + await this.assertTableWritable(tableId); return await this.prismaService.$tx(async () => { return await this.viewService.updateViewByOps(tableId, viewId, ops); }); @@ -462,6 +525,7 @@ export class ViewOpenApiService { viewOptions: IViewOptions, windowId?: string ) { + await this.assertTableWritable(tableId); const curView = await this.prismaService.view .findFirstOrThrow({ select: { options: true, type: true }, @@ -521,6 +585,7 @@ export class ViewOpenApiService { * shuffle view order */ async shuffle(tableId: string) { + await this.assertTableWritable(tableId); const views = await this.prismaService.view.findMany({ where: { tableId, deletedTime: null }, select: { id: true, order: true }, @@ -551,6 +616,7 @@ export class ViewOpenApiService { orderRo: IUpdateOrderRo, windowId?: string ) { + await this.assertTableWritable(tableId); const { anchorId, position } = orderRo; const view = await this.prismaService.view @@ -736,12 +802,41 @@ export class ViewOpenApiService { order?: Record; }[] ) { + await this.assertTableWritable(tableId); await this.recordService.updateRecordIndexes(tableId, recordsWithOrder); - const ops = ViewOpBuilder.editor.setViewProperty.build({ - key: 'lastModifiedTime', - newValue: new Date().toISOString(), + await this.publishRowOrderChange(tableId, viewId); + } + + /** + * Manual reorder only rewrites the hidden __row_ column, so no + * record op exists to wake record query subscriptions. Publish a synthetic + * op carrying the row order pseudo column so the adapter's skipPoll can + * scope polling to subscriptions on this view. The op intentionally has no + * doc id (d): QueryEmitter then only triggers polling and never relays the + * op to doc subscribers, which would choke on its fake version. + */ + private async publishRowOrderChange(tableId: string, viewId: string) { + // The doc-ids query cache key includes table lastModifiedTime (previously + // bumped as a side effect of the view lastModifiedTime op). Bump it before + // publishing so the poll triggered by this op misses the stale cache. + await this.prismaService.tableMeta.update({ + where: { id: tableId }, + data: { lastModifiedTime: new Date().toISOString() }, }); - await this.viewService.updateViewByOps(tableId, viewId, [ops]); + const rawOp = { + src: generateOperationId(), + seq: 1, + v: 0, + m: { ts: Date.now() }, + op: [ + RecordOpBuilder.editor.setRecord.build({ + fieldId: `${ROW_ORDER_FIELD_PREFIX}_${viewId}`, + newCellValue: null, + oldCellValue: null, + }), + ], + } as EditOp; + this.shareDbService.publishRecordChannel(tableId, rawOp); } async updateRecordOrders( @@ -750,6 +845,7 @@ export class ViewOpenApiService { orderRo: IUpdateRecordOrdersRo, windowId?: string ) { + await this.assertTableWritable(table.id); const recordIds = orderRo.recordIds; const dbTableName = table.dbTableName; const orderIndexesBefore = windowId @@ -781,11 +877,7 @@ export class ViewOpenApiService { await prisma.$executeRawUnsafe(updateRecordSql); } }); - const ops = ViewOpBuilder.editor.setViewProperty.build({ - key: 'lastModifiedTime', - newValue: new Date().toISOString(), - }); - await this.viewService.updateViewByOps(table.id, viewId, [ops]); + await this.publishRowOrderChange(table.id, viewId); }, }); @@ -803,6 +895,12 @@ export class ViewOpenApiService { } } + @Audit({ + action: Events.SHARED_VIEW_REFRESH, + resourceId: (_tableId: string, viewId: string) => viewId, + params: (tableId: string, viewId: string) => ({ tableId, viewId }), + emit: (result: { shareId: string }) => ({ shareId: result.shareId }), + }) async refreshShareId(tableId: string, viewId: string) { const view = await this.prismaService.view.findUnique({ where: { id: viewId, tableId, deletedTime: null }, @@ -841,6 +939,12 @@ export class ViewOpenApiService { return { shareId: newShareId }; } + @Audit({ + action: Events.SHARED_VIEW_CREATE, + resourceId: (_tableId: string, viewId: string) => viewId, + params: (tableId: string, viewId: string) => ({ tableId, viewId, enabled: true }), + emit: (result: { shareId: string }) => ({ shareId: result.shareId }), + }) async enableShare(tableId: string, viewId: string) { const view = await this.prismaService.view.findUnique({ where: { id: viewId, tableId, deletedTime: null }, @@ -894,6 +998,12 @@ export class ViewOpenApiService { return { shareId: newShareId }; } + @Audit({ + action: Events.SHARED_VIEW_DELETE, + resourceId: (_tableId: string, viewId: string) => viewId, + params: (tableId: string, viewId: string) => ({ tableId, viewId, enabled: false }), + emit: true, + }) async disableShare(tableId: string, viewId: string) { const view = await this.prismaService.view.findUnique({ where: { id: viewId, tableId, deletedTime: null }, @@ -1064,6 +1174,7 @@ export class ViewOpenApiService { enableShare?: boolean; } ) { + await this.assertTableWritable(tableId); const userId = this.cls.get('user.id'); const { name, pluginId, shareId, shareMeta, enableShare } = ro; const plugin = await this.prismaService.txClient().plugin.findUnique({ @@ -1143,6 +1254,7 @@ export class ViewOpenApiService { } async updatePluginStorage(viewId: string, storage: IViewPluginUpdateStorageRo['storage']) { + await this.assertViewWritable(viewId); const pluginInstall = await this.prismaService.pluginInstall.findFirst({ where: { positionId: viewId, position: PluginPosition.View }, select: { id: true }, @@ -1203,6 +1315,7 @@ export class ViewOpenApiService { } async duplicateView(tableId: string, viewId: string) { + await this.assertTableWritable(tableId); const view = await this.viewService.getViewById(viewId); const { options: optionsRaw } = await this.prismaService.txClient().view.findUniqueOrThrow({ where: { id: viewId, deletedTime: null }, diff --git a/apps/nestjs-backend/src/features/view/view.service.ts b/apps/nestjs-backend/src/features/view/view.service.ts index 6517832f37..51d8419b67 100644 --- a/apps/nestjs-backend/src/features/view/view.service.ts +++ b/apps/nestjs-backend/src/features/view/view.service.ts @@ -1,5 +1,5 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { Injectable } from '@nestjs/common'; +import { Injectable, Optional } from '@nestjs/common'; import type { ISnapshotBase, IViewRo, @@ -32,7 +32,7 @@ import { CellValueType, HttpErrorCode, } from '@teable/core'; -import type { Prisma } from '@teable/db-main-prisma'; +import type { Prisma, View } from '@teable/db-main-prisma'; import { PrismaService } from '@teable/db-main-prisma'; import { Knex } from 'knex'; import { isEmpty, isNull, isString, merge, snakeCase, uniq } from 'lodash'; @@ -50,6 +50,7 @@ import { RawOpType } from '../../share-db/interface'; import type { IClsStore } from '../../types/cls'; import { convertViewVoAttachmentUrl } from '../../utils/convert-view-vo-attachment-url'; import { BatchService } from '../calculation/batch.service'; +import { SpaceDataDbMigrationGuardService } from '../space/space-data-db-migration-guard.service'; import { ROW_ORDER_FIELD_PREFIX } from './constant'; import { createViewInstanceByRaw, createViewVoByRaw } from './model/factory'; import { adjustFrozenField } from './utils/derive-frozen-fields'; @@ -67,9 +68,15 @@ export class ViewService implements IReadonlyAdapterService { @InjectModel(CUSTOM_KNEX) private readonly knex: Knex, @InjectModel(DATA_KNEX) private readonly dataKnex: Knex, @InjectDbProvider() private readonly dbProvider: IDbProvider, - private readonly viewDataSafetyLimitService: ViewDataSafetyLimitService + private readonly viewDataSafetyLimitService: ViewDataSafetyLimitService, + @Optional() + private readonly spaceDataDbMigrationGuard?: SpaceDataDbMigrationGuardService ) {} + private async assertTableWritable(tableId: string) { + await this.spaceDataDbMigrationGuard?.assertTableWritable(tableId); + } + getRowIndexFieldName(viewId: string) { return `${ROW_ORDER_FIELD_PREFIX}_${viewId}`; } @@ -253,6 +260,7 @@ export class ViewService implements IReadonlyAdapterService { } async restoreView(tableId: string, viewId: string) { + await this.assertTableWritable(tableId); await this.prismaService.$tx(async () => { await this.prismaService.txClient().view.update({ where: { id: viewId }, @@ -270,6 +278,7 @@ export class ViewService implements IReadonlyAdapterService { async createDbView(tableId: string, viewRo: IViewRo) { const userId = this.cls.get('user.id'); + await this.assertTableWritable(tableId); await this.viewDataSafetyLimitService.ensureCanCreateView(tableId); const createViewRo = await this.viewDataCompensation(tableId, viewRo); @@ -300,9 +309,13 @@ export class ViewService implements IReadonlyAdapterService { const viewId = generateViewId(); const prisma = this.prismaService.txClient(); + const activeFieldIds = await this.getActiveFieldIdSet(tableId); const orderColumnMeta = await this.generateViewOrderColumnMeta(tableId); - const mergedColumnMeta = merge(orderColumnMeta, columnMeta); + const mergedColumnMeta = this.filterColumnMetaByFieldIds( + merge(orderColumnMeta, columnMeta), + activeFieldIds + ); const data: Prisma.ViewCreateInput = { id: viewId, @@ -335,8 +348,10 @@ export class ViewService implements IReadonlyAdapterService { const viewRaw = await this.prismaService.txClient().view.findUniqueOrThrow({ where: { id: viewId, deletedTime: null }, }); + const activeFieldIds = await this.getActiveFieldIdSet(viewRaw.tableId); + const sanitizedViewRaw = this.sanitizeViewRawColumnMeta(viewRaw, activeFieldIds); - return convertViewVoAttachmentUrl(createViewInstanceByRaw(viewRaw) as IViewVo); + return convertViewVoAttachmentUrl(createViewInstanceByRaw(sanitizedViewRaw) as IViewVo); } async getViews(tableId: string, ids?: string[]): Promise { @@ -349,7 +364,13 @@ export class ViewService implements IReadonlyAdapterService { orderBy: { order: 'asc' }, }); - return viewRaws.map((viewRaw) => convertViewVoAttachmentUrl(createViewVoByRaw(viewRaw))); + const activeFieldIds = await this.getActiveFieldIdSet(tableId); + + return viewRaws.map((viewRaw) => + convertViewVoAttachmentUrl( + createViewVoByRaw(this.sanitizeViewRawColumnMeta(viewRaw, activeFieldIds)) + ) + ); } async createView(tableId: string, viewRo: IViewRo): Promise { @@ -359,10 +380,14 @@ export class ViewService implements IReadonlyAdapterService { { docId: viewRaw.id, version: 0, data: viewRaw }, ]); - return convertViewVoAttachmentUrl(createViewVoByRaw(viewRaw)); + const activeFieldIds = await this.getActiveFieldIdSet(tableId); + return convertViewVoAttachmentUrl( + createViewVoByRaw(this.sanitizeViewRawColumnMeta(viewRaw, activeFieldIds)) + ); } async deleteView(tableId: string, viewId: string) { + await this.assertTableWritable(tableId); // Use SELECT FOR UPDATE to lock all views in the table to prevent concurrent deletion // This ensures that when checking if this is the last view, no other transaction // can delete views simultaneously @@ -408,6 +433,7 @@ export class ViewService implements IReadonlyAdapterService { } async updateViewSort(tableId: string, viewId: string, sort: ISort) { + await this.assertTableWritable(tableId); this.viewDataSafetyLimitService.ensureSort(sort); const viewRaw = await this.prismaService .txClient() @@ -465,6 +491,7 @@ export class ViewService implements IReadonlyAdapterService { } async batchUpdateViewByOps(tableId: string, opsMap: { [viewId: string]: IOtOperation[] }) { + await this.assertTableWritable(tableId); const { updateViewMap, updateViewKeySet } = this.getBatchUpdateViewContext(opsMap); if (updateViewKeySet.size === 0) { return; @@ -568,6 +595,7 @@ export class ViewService implements IReadonlyAdapterService { } async del(_version: number, _tableId: string, viewId: string) { + await this.assertTableWritable(_tableId); await this.prismaService.txClient().view.update({ where: { id: viewId }, data: { @@ -792,18 +820,60 @@ export class ViewService implements IReadonlyAdapterService { ); } + const activeFieldIds = await this.getActiveFieldIdSet(tableId); + return views .map((view) => { + const sanitizedView = this.sanitizeViewRawColumnMeta(view, activeFieldIds); return { id: view.id, v: view.version, type: 'json0', - data: convertViewVoAttachmentUrl(createViewVoByRaw(view)), + data: convertViewVoAttachmentUrl(createViewVoByRaw(sanitizedView)), }; }) .sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); } + private async getActiveFieldIdSet(tableId: string) { + const fields = await this.prismaService.txClient().field.findMany({ + where: { tableId, deletedTime: null }, + select: { id: true }, + }); + + return new Set(fields.map(({ id }) => id)); + } + + private filterColumnMetaByFieldIds( + columnMeta: IColumnMeta | null | undefined, + fieldIds: Set + ) { + if (!columnMeta) { + return columnMeta; + } + + return Object.entries(columnMeta).reduce((acc, [fieldId, meta]) => { + if (fieldIds.has(fieldId)) { + acc[fieldId] = meta; + } + return acc; + }, {}); + } + + private sanitizeViewRawColumnMeta(viewRaw: T, fieldIds: Set): T { + const columnMeta = JSON.parse(viewRaw.columnMeta as string) as IColumnMeta | null | undefined; + const filteredColumnMeta = this.filterColumnMetaByFieldIds(columnMeta, fieldIds); + + if (Object.keys(filteredColumnMeta ?? {}).length === Object.keys(columnMeta ?? {}).length) { + return viewRaw; + } + + return { + ...viewRaw, + columnMeta: JSON.stringify(filteredColumnMeta ?? {}), + }; + } + async getDocIdsByQuery(tableId: string, query?: { includeIds: string[] }) { const views = await this.prismaService.txClient().view.findMany({ where: { tableId, deletedTime: null, id: { in: query?.includeIds } }, 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 0f126c83b9..4c819c9c9d 100644 --- a/apps/nestjs-backend/src/filter/global-exception.filter.spec.ts +++ b/apps/nestjs-backend/src/filter/global-exception.filter.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Logger } from '@nestjs/common'; +import { BadRequestException, Logger, RequestTimeoutException } from '@nestjs/common'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { GlobalExceptionFilter } from './global-exception.filter'; @@ -67,9 +67,11 @@ describe('GlobalExceptionFilter', () => { const response: { status: ReturnType; json: ReturnType; + setHeader: ReturnType; } = { status: vi.fn(), json: vi.fn(), + setHeader: vi.fn(), }; response.status.mockReturnValue(response); @@ -113,6 +115,73 @@ describe('GlobalExceptionFilter', () => { }); }); + it('captures unexpected exceptions with V2 attribution context', () => { + const cls = { + get: vi.fn((key: string) => { + const values = new Map([ + ['spaceId', spaceId], + ['useV2', true], + ['v2Reason', 'space_feature'], + ['v2Feature', 'deleteField'], + ]); + return values.get(key); + }), + }; + const exception = new Error('delete failed'); + const filter = new GlobalExceptionFilter(configService as never, cls as never); + + filter.catch(exception, host as never); + + expect(response.setHeader).toHaveBeenCalledWith('x-teable-v2', 'true'); + expect(response.setHeader).toHaveBeenCalledWith('x-teable-v2-reason', 'space_feature'); + expect(response.setHeader).toHaveBeenCalledWith('x-teable-v2-feature', 'deleteField'); + expect(sentryScope.setTag).toHaveBeenCalledWith('teable.version', 'v2'); + expect(sentryScope.setTag).toHaveBeenCalledWith('teable.v2.enabled', 'true'); + expect(sentryScope.setTag).toHaveBeenCalledWith('teable.v2.reason', 'space_feature'); + expect(sentryScope.setTag).toHaveBeenCalledWith('teable.v2.feature', 'deleteField'); + expect(sentryScope.setTag).toHaveBeenCalledWith('teable.request.attribution', 'v2'); + expect(activeSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'teable.request.attribution': 'v2', + 'teable.v2.enabled': true, + 'teable.v2.reason': 'space_feature', + 'teable.v2.feature': 'deleteField', + }) + ); + }); + + it('logs V2 attribution for request timeout HTTP exceptions that are not captured', () => { + const loggerError = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + const cls = { + get: vi.fn((key: string) => { + const values = new Map([ + ['useV2', false], + ['v2Reason', 'disabled'], + ['v2Feature', 'deleteField'], + ]); + return values.get(key); + }), + }; + const filter = new GlobalExceptionFilter(configService as never, cls as never); + + filter.catch(new RequestTimeoutException('timeout'), host as never); + + expect(withScope).not.toHaveBeenCalled(); + expect(response.setHeader).toHaveBeenCalledWith('x-teable-v2', 'false'); + expect(loggerError).toHaveBeenCalledWith( + expect.objectContaining({ + url: '/api/test', + v2: { + attribution: 'v1', + enabled: false, + reason: 'disabled', + feature: 'deleteField', + }, + }), + expect.any(String) + ); + }); + it('does not capture expected Nest HTTP exceptions', () => { const filter = new GlobalExceptionFilter(configService as never); diff --git a/apps/nestjs-backend/src/filter/global-exception.filter.ts b/apps/nestjs-backend/src/filter/global-exception.filter.ts index fdbf4e5520..83fc426a09 100644 --- a/apps/nestjs-backend/src/filter/global-exception.filter.ts +++ b/apps/nestjs-backend/src/filter/global-exception.filter.ts @@ -18,6 +18,13 @@ import type { Request, Response } from 'express'; import { ClsService } from 'nestjs-cls'; import type { ILoggerConfig } from '../configs/logger.config'; import { TemplateAppTokenNotAllowedException } from '../custom.exception'; +import { + getV2Attribution, + getV2AttributionLogContext, + getV2AttributionSpanAttributes, + setV2AttributionHeaders, + setV2AttributionOnSentryScope, +} from '../features/canary/v2-attribution'; import { classifyDataDbRuntimeError } from '../global/data-db-runtime-error'; import type { IDataDbRuntimeErrorClassification } from '../global/data-db-runtime-error'; import type { IClsStore } from '../types/cls'; @@ -58,6 +65,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { const dataDbContext = this.getDataDbContext(); const dataDbError = dataDbContext ? classifyDataDbRuntimeError(exception) : null; + setV2AttributionHeaders(response, getV2Attribution(this.cls)); this.annotateActiveSpan(dataDbError); this.recordDataDbMetric(dataDbError); this.captureException(exception, dataDbError); @@ -139,6 +147,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { if (spaceId) { scope.setTag('space.id', spaceId); } + setV2AttributionOnSentryScope(scope, getV2Attribution(this.cls)); } catch { // CLS may not be active (e.g., non-HTTP contexts) } @@ -167,12 +176,17 @@ export class GlobalExceptionFilter implements ExceptionFilter { } private annotateActiveSpan(dataDbError?: IDataDbRuntimeErrorClassification | null) { - const dataDbContext = this.getDataDbContext(); - if (!dataDbContext || !dataDbError) return; - const span = trace.getActiveSpan(); if (!span) return; + const v2Attributes = getV2AttributionSpanAttributes(getV2Attribution(this.cls)); + if (Object.keys(v2Attributes).length) { + span.setAttributes(v2Attributes); + } + + const dataDbContext = this.getDataDbContext(); + if (!dataDbContext || !dataDbError) return; + span.setAttributes({ [dataDbOtelAttribute.mode]: dataDbContext.mode, [dataDbOtelAttribute.errorCode]: dataDbError.code, @@ -216,10 +230,12 @@ export class GlobalExceptionFilter implements ExceptionFilter { protected logError(exception: Error, request: Request) { const dataDbContext = this.getDataDbContext(); const dataDbError = dataDbContext ? classifyDataDbRuntimeError(exception) : null; + const v2 = getV2AttributionLogContext(getV2Attribution(this.cls)); this.logger.error( { url: request?.url, message: exception.message, + v2, dataDb: dataDbError ? { code: dataDbError.code, diff --git a/apps/nestjs-backend/src/global/data-db-client-manager.service.spec.ts b/apps/nestjs-backend/src/global/data-db-client-manager.service.spec.ts index 585d1fb929..5a4b97006b 100644 --- a/apps/nestjs-backend/src/global/data-db-client-manager.service.spec.ts +++ b/apps/nestjs-backend/src/global/data-db-client-manager.service.spec.ts @@ -8,6 +8,13 @@ const withTxClient = (txClient: T) => ({ txClient: vi.fn(() => txClient), }); +const dataUrl = 'postgresql://teable:secret@example.com:5432/teable_data'; +const internalSchema = 'teable_meta_test'; +const connectionId = 'dcnxxx'; +const displayHost = 'example.com'; +const displayDatabase = 'teable_data'; +const urlFingerprint = 'fp_xxx'; + describe('DataDbClientManager', () => { it('falls back to the meta DB clients when a space has no BYODB binding', async () => { const prismaService = withTxClient({ @@ -156,8 +163,6 @@ describe('DataDbClientManager', () => { }); it('resolves BYODB connection details from a ready space binding', async () => { - const dataUrl = 'postgresql://teable:secret@example.com:5432/teable_data'; - const internalSchema = 'teable_meta_test'; const cls = { isActive: vi.fn().mockReturnValue(true), set: vi.fn(), @@ -168,12 +173,12 @@ describe('DataDbClientManager', () => { mode: 'byodb', state: 'ready', dataDbConnection: { - id: 'dcnxxx', + id: connectionId, status: 'ready', internalSchema, - displayHost: 'example.com', - displayDatabase: 'teable_data', - urlFingerprint: 'fp_xxx', + displayHost, + displayDatabase, + urlFingerprint, encryptedUrl: encryptDataDbUrl(dataUrl), }, }), @@ -194,27 +199,134 @@ describe('DataDbClientManager', () => { `${dataUrl}?schema=${internalSchema}&options=-c+search_path%3D${internalSchema}` ); await expect(manager.getDataDatabaseForSpace('spcxxx')).resolves.toMatchObject({ - cacheKey: 'dcnxxx', - connectionId: 'dcnxxx', + cacheKey: connectionId, + connectionId, isMetaFallback: false, url: `${dataUrl}?schema=${internalSchema}&options=-c+search_path%3D${internalSchema}`, }); expect(cls.set).toHaveBeenCalledWith('dataDb', { mode: 'byodb', spaceId: 'spcxxx', - connectionId: 'dcnxxx', - urlFingerprint: 'fp_xxx', - displayHost: 'example.com', - displayDatabase: 'teable_data', + connectionId, + urlFingerprint, + displayHost, + displayDatabase, internalSchema, }); await expect(manager.dataKnexForSpace('spcxxx')).resolves.not.toBe(metaFallbackDataKnex); await manager.onModuleDestroy(); }); + it('can preview a BYODB route for a space without writing a binding', async () => { + const prismaService = withTxClient({ + spaceDataDbBinding: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }); + const manager = new DataDbClientManager( + prismaService as never, + {} as never, + {} as never, + new DataDbRuntimeCacheService() + ); + + await expect( + manager.getDataDatabaseForSpace('spcxxx', { + previewBinding: { + spaceId: 'spcxxx', + connectionId, + encryptedUrl: encryptDataDbUrl(dataUrl), + internalSchema, + urlFingerprint, + displayHost, + displayDatabase, + }, + }) + ).resolves.toMatchObject({ + cacheKey: connectionId, + connectionId, + internalSchema, + isMetaFallback: false, + url: `${dataUrl}?schema=${internalSchema}&options=-c+search_path%3D${internalSchema}`, + }); + expect(prismaService.spaceDataDbBinding.findUnique).not.toHaveBeenCalled(); + }); + + it('can force the original source route to meta fallback during migration', async () => { + vi.stubEnv('PRISMA_DATABASE_URL', 'postgresql://meta.example/teable'); + const prismaService = withTxClient({ + dataDbConnection: { + findUnique: vi.fn(), + }, + spaceDataDbBinding: { + findUnique: vi.fn().mockResolvedValue({ + mode: 'byodb', + state: 'migrating', + dataDbConnection: { + id: connectionId, + status: 'migrating', + internalSchema, + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + }), + }, + }); + const manager = new DataDbClientManager( + prismaService as never, + {} as never, + {} as never, + new DataDbRuntimeCacheService() + ); + + await expect( + manager.getDataDatabaseForSpace('spcxxx', { sourceConnectionId: null }) + ).resolves.toMatchObject({ + cacheKey: 'meta-fallback', + isMetaFallback: true, + }); + expect(prismaService.spaceDataDbBinding.findUnique).not.toHaveBeenCalled(); + expect(prismaService.dataDbConnection.findUnique).not.toHaveBeenCalled(); + }); + + it('can resolve the original source BYODB connection during migration', async () => { + const sourceConnectionId = 'dcnsource'; + const sourceSchema = 'source_schema'; + const sourceUrl = 'postgresql://teable:secret@source.example.com:5432/teable_data'; + const prismaService = withTxClient({ + dataDbConnection: { + findUnique: vi.fn().mockResolvedValue({ + id: sourceConnectionId, + internalSchema: sourceSchema, + encryptedUrl: encryptDataDbUrl(sourceUrl), + }), + }, + spaceDataDbBinding: { + findUnique: vi.fn(), + }, + }); + const manager = new DataDbClientManager( + prismaService as never, + {} as never, + {} as never, + new DataDbRuntimeCacheService() + ); + + await expect( + manager.getDataDatabaseForSpace('spcxxx', { sourceConnectionId }) + ).resolves.toMatchObject({ + cacheKey: sourceConnectionId, + connectionId: sourceConnectionId, + internalSchema: sourceSchema, + isMetaFallback: false, + url: `${sourceUrl}?schema=${sourceSchema}&options=-c+search_path%3D${sourceSchema}`, + }); + expect(prismaService.dataDbConnection.findUnique).toHaveBeenCalledWith({ + where: { id: sourceConnectionId }, + }); + expect(prismaService.spaceDataDbBinding.findUnique).not.toHaveBeenCalled(); + }); + it('resolves BYODB connection details when no CLS context is active', async () => { - const dataUrl = 'postgresql://teable:secret@example.com:5432/teable_data'; - const internalSchema = 'teable_meta_test'; const cls = { isActive: vi.fn().mockReturnValue(false), set: vi.fn(() => { @@ -227,12 +339,12 @@ describe('DataDbClientManager', () => { mode: 'byodb', state: 'ready', dataDbConnection: { - id: 'dcnxxx', + id: connectionId, status: 'ready', internalSchema, - displayHost: 'example.com', - displayDatabase: 'teable_data', - urlFingerprint: 'fp_xxx', + displayHost, + displayDatabase, + urlFingerprint, encryptedUrl: encryptDataDbUrl(dataUrl), }, }), @@ -254,8 +366,6 @@ describe('DataDbClientManager', () => { }); it('ensures the BYODB internal schema is migrated before returning a scoped URL', async () => { - const dataUrl = 'postgresql://teable:secret@example.com:5432/teable_data'; - const internalSchema = 'teable_meta_test'; const dataDbMigrationService = { ensureConnectionMigrated: vi.fn().mockResolvedValue([]), }; @@ -265,7 +375,7 @@ describe('DataDbClientManager', () => { mode: 'byodb', state: 'migrating', dataDbConnection: { - id: 'dcnxxx', + id: connectionId, status: 'migrating', internalSchema, encryptedUrl: encryptDataDbUrl(dataUrl), @@ -282,13 +392,13 @@ describe('DataDbClientManager', () => { ); await expect(manager.getDataDatabaseForSpace('spcxxx')).resolves.toMatchObject({ - cacheKey: 'dcnxxx', - connectionId: 'dcnxxx', + cacheKey: connectionId, + connectionId, internalSchema, isMetaFallback: false, }); expect(dataDbMigrationService.ensureConnectionMigrated).toHaveBeenCalledWith({ - connectionId: 'dcnxxx', + connectionId, internalSchema, url: dataUrl, }); diff --git a/apps/nestjs-backend/src/global/data-db-client-manager.service.ts b/apps/nestjs-backend/src/global/data-db-client-manager.service.ts index d569b0db5d..4c0404cdce 100644 --- a/apps/nestjs-backend/src/global/data-db-client-manager.service.ts +++ b/apps/nestjs-backend/src/global/data-db-client-manager.service.ts @@ -27,12 +27,28 @@ export interface IResolvedDataDatabase { internalSchema?: string; } +export interface IDataDbPreviewBinding { + spaceId: string; + connectionId: string; + encryptedUrl: string; + internalSchema: string; + urlFingerprint?: string | null; + displayHost?: string | null; + displayDatabase?: string | null; +} + export interface IDataDbRoutingOptions { useTransaction?: boolean; + previewBinding?: IDataDbPreviewBinding; + sourceConnectionId?: string | null; } type IMetaRoutingClient = PrismaService | NonNullable; +type IResolvedSpaceDataDbRoute = + | { isMetaFallback: true } + | { connectionId: string; internalSchema: string; isMetaFallback: false; url: string }; + @Injectable() export class DataDbClientManager { constructor( @@ -205,10 +221,15 @@ export class DataDbClientManager { private async resolveSpaceDataDb( spaceId: string, options?: IDataDbRoutingOptions - ): Promise< - | { isMetaFallback: true } - | { connectionId: string; internalSchema: string; isMetaFallback: false; url: string } - > { + ): Promise { + if ('sourceConnectionId' in (options ?? {})) { + return await this.resolveSourceSpaceDataDb(options); + } + + if (options?.previewBinding?.spaceId === spaceId) { + return this.resolvePreviewSpaceDataDb(spaceId, options.previewBinding); + } + const binding = await this.getMetaRoutingClient(options).spaceDataDbBinding.findUnique({ where: { spaceId }, include: { dataDbConnection: true }, @@ -266,6 +287,53 @@ export class DataDbClientManager { }; } + private resolvePreviewSpaceDataDb( + spaceId: string, + preview: IDataDbPreviewBinding + ): IResolvedSpaceDataDbRoute { + if (this.cls?.isActive()) { + this.cls.set('dataDb', { + mode: 'byodb', + spaceId, + connectionId: preview.connectionId, + urlFingerprint: preview.urlFingerprint ?? null, + displayHost: preview.displayHost ?? null, + displayDatabase: preview.displayDatabase ?? null, + internalSchema: preview.internalSchema, + }); + } + + return { + connectionId: preview.connectionId, + internalSchema: preview.internalSchema, + isMetaFallback: false, + url: decryptDataDbUrl(preview.encryptedUrl), + }; + } + + private async resolveSourceSpaceDataDb( + options?: IDataDbRoutingOptions + ): Promise { + const sourceConnectionId = options?.sourceConnectionId ?? null; + if (!sourceConnectionId) { + return { isMetaFallback: true }; + } + + const connection = await this.getMetaRoutingClient(options).dataDbConnection.findUnique({ + where: { id: sourceConnectionId }, + }); + if (!connection?.encryptedUrl) { + throw new Error(`Data database source connection ${sourceConnectionId} was not found`); + } + + return { + connectionId: connection.id, + internalSchema: connection.internalSchema, + isMetaFallback: false, + url: decryptDataDbUrl(connection.encryptedUrl), + }; + } + async onModuleDestroy() { await Promise.all([ this.runtimeCache.deleteByNamespace(DATA_DB_KNEX_CACHE_NAMESPACE), diff --git a/apps/nestjs-backend/src/global/database-router.service.spec.ts b/apps/nestjs-backend/src/global/database-router.service.spec.ts index 788cddab35..a37c2eacaa 100644 --- a/apps/nestjs-backend/src/global/database-router.service.spec.ts +++ b/apps/nestjs-backend/src/global/database-router.service.spec.ts @@ -2,6 +2,29 @@ import { describe, expect, it, vi } from 'vitest'; import { DatabaseRouter } from './database-router.service'; describe('DatabaseRouter', () => { + it('exposes resolved base data DB routing metadata', async () => { + const resolved = { + cacheKey: 'dcnxxx', + connectionId: 'dcnxxx', + internalSchema: 'teable_meta_x', + isMetaFallback: false, + url: 'postgresql://readonly:secret@byodb.example.com:5432/teable?schema=teable_meta_x', + }; + const dataDbClientManager = { + getDataDatabaseForBase: vi.fn().mockResolvedValue(resolved), + }; + const router = new DatabaseRouter( + {} as never, + {} as never, + {} as never, + {} as never, + dataDbClientManager as never + ); + + await expect(router.getDataDatabaseForBase('bsexxx')).resolves.toBe(resolved); + expect(dataDbClientManager.getDataDatabaseForBase).toHaveBeenCalledWith('bsexxx', undefined); + }); + it('executes table scoped raw queries through the scoped table client', async () => { const queryRaw = vi.fn().mockResolvedValue([{ count: 1 }]); const executeRaw = vi.fn().mockResolvedValue(1); @@ -61,6 +84,74 @@ describe('DatabaseRouter', () => { expect(queryRaw).toHaveBeenCalledWith('select * from x where id = $1', 'recxxx'); }); + it('clears cached query plans and retries once after schema result type changes', async () => { + const cachedPlanError = { + code: 'P2010', + meta: { + code: '0A000', + message: 'ERROR: cached plan must not change result type', + }, + }; + const queryRaw = vi + .fn() + .mockRejectedValueOnce(cachedPlanError) + .mockResolvedValueOnce([{ id: 'recxxx' }]); + const executeRaw = vi.fn().mockResolvedValue(0); + const dataDbClientManager = { + dataPrismaForTable: vi.fn().mockResolvedValue({ + txClient: () => ({ + $queryRawUnsafe: queryRaw, + $executeRawUnsafe: executeRaw, + }), + }), + }; + const router = new DatabaseRouter( + {} as never, + {} as never, + {} as never, + {} as never, + dataDbClientManager as never + ); + + await expect(router.queryDataPrismaForTable('tblxxx', 'select * from x')).resolves.toEqual([ + { id: 'recxxx' }, + ]); + expect(executeRaw).toHaveBeenCalledWith('DISCARD PLANS'); + expect(queryRaw).toHaveBeenCalledTimes(2); + }); + + it('does not clear cached query plans while using an explicit transaction client', async () => { + const cachedPlanError = { + code: 'P2010', + meta: { + code: '0A000', + message: 'ERROR: cached plan must not change result type', + }, + }; + const queryRaw = vi.fn().mockRejectedValue(cachedPlanError); + const executeRaw = vi.fn(); + const dataDbClientManager = { + dataPrismaForTable: vi.fn().mockResolvedValue({ + txClient: () => ({ + $queryRawUnsafe: queryRaw, + $executeRawUnsafe: executeRaw, + }), + }), + }; + const router = new DatabaseRouter( + {} as never, + {} as never, + {} as never, + {} as never, + dataDbClientManager as never + ); + + await expect( + router.queryDataPrismaForTable('tblxxx', 'select * from x', { useTransaction: true }) + ).rejects.toBe(cachedPlanError); + expect(executeRaw).not.toHaveBeenCalled(); + }); + it('executes table scoped transactions with PrismaClient transaction fallback', async () => { const executeRaw = vi.fn().mockResolvedValue(1); const transaction = vi.fn(async (fn) => await fn({ $executeRawUnsafe: executeRaw })); diff --git a/apps/nestjs-backend/src/global/database-router.service.ts b/apps/nestjs-backend/src/global/database-router.service.ts index 3ffacbb7e6..ec3ee480d0 100644 --- a/apps/nestjs-backend/src/global/database-router.service.ts +++ b/apps/nestjs-backend/src/global/database-router.service.ts @@ -32,6 +32,24 @@ type IDataPrismaScopedClient = IDataPrismaQueryExecutor & { ) => Promise; }; +const isCachedPlanResultTypeError = (error: unknown): boolean => { + const err = error as { + code?: unknown; + meta?: { code?: unknown; message?: unknown }; + message?: unknown; + }; + const pgCode = typeof err.meta?.code === 'string' ? err.meta.code : undefined; + const messageParts = [err.message, err.meta?.message].filter( + (part): part is string => typeof part === 'string' + ); + + return ( + err.code === 'P2010' && + pgCode === '0A000' && + messageParts.some((message) => message.includes('cached plan must not change result type')) + ); +}; + @Injectable() export class DatabaseRouter { constructor( @@ -74,6 +92,10 @@ export class DatabaseRouter { return await this.dataDbClientManager.getDataDatabaseUrlForBase(baseId, options); } + async getDataDatabaseForBase(baseId: string, options?: IDataDbRoutingOptions) { + return await this.dataDbClientManager.getDataDatabaseForBase(baseId, options); + } + async dataKnexForSpace(spaceId: string, options?: IDataDbRoutingOptions) { return await this.dataDbClientManager.dataKnexForSpace(spaceId, options); } @@ -102,6 +124,24 @@ export class DatabaseRouter { return prisma.txClient?.() ?? prisma; } + private async queryWithCachedPlanRetry( + prisma: IDataPrismaQueryExecutor, + query: string, + queryValues: unknown[], + shouldRetry: boolean + ): Promise { + try { + return await prisma.$queryRawUnsafe(query, ...queryValues); + } catch (error) { + if (!shouldRetry || !isCachedPlanResultTypeError(error)) { + throw error; + } + + await prisma.$executeRawUnsafe('DISCARD PLANS'); + return await prisma.$queryRawUnsafe(query, ...queryValues); + } + } + async dataPrismaExecutorForTable( tableId: string, options?: IDataDbRoutingOptions @@ -126,7 +166,12 @@ export class DatabaseRouter { ): Promise { const { options, queryValues } = this.normalizeRoutingOptions(optionsOrFirstValue, values); const prisma = await this.dataPrismaExecutorForTable(tableId, options); - return await prisma.$queryRawUnsafe(query, ...queryValues); + return await this.queryWithCachedPlanRetry( + prisma, + query, + queryValues, + !options?.useTransaction + ); } async executeDataPrismaForTable( @@ -148,7 +193,12 @@ export class DatabaseRouter { ): Promise { const { options, queryValues } = this.normalizeRoutingOptions(optionsOrFirstValue, values); const prisma = await this.dataPrismaExecutorForBase(baseId, options); - return await prisma.$queryRawUnsafe(query, ...queryValues); + return await this.queryWithCachedPlanRetry( + prisma, + query, + queryValues, + !options?.useTransaction + ); } async executeDataPrismaForBase( @@ -215,11 +265,12 @@ export class DatabaseRouter { } private isRoutingOptions(value: unknown): value is IDataDbRoutingOptions { + const routingOptionKeys = new Set(['useTransaction', 'previewBinding']); return ( Boolean(value) && typeof value === 'object' && Object.keys(value as Record).length > 0 && - Object.keys(value as Record).every((key) => key === 'useTransaction') + Object.keys(value as Record).every((key) => routingOptionKeys.has(key)) ); } diff --git a/apps/nestjs-backend/src/global/global.module.ts b/apps/nestjs-backend/src/global/global.module.ts index e269ebd3a9..e4321aca15 100644 --- a/apps/nestjs-backend/src/global/global.module.ts +++ b/apps/nestjs-backend/src/global/global.module.ts @@ -26,6 +26,7 @@ import { PermissionModule } from '../features/auth/permission.module'; import { DataLoaderModule } from '../features/data-loader/data-loader.module'; import { ModelModule } from '../features/model/model.module'; import { DataDbMigrationService } from '../features/space/data-db-migration.service'; +import { SpaceDataDbMigrationGuardService } from '../features/space/space-data-db-migration-guard.service'; import { RequestInfoMiddleware } from '../middleware/request-info.middleware'; import { SessionCsrfMiddleware } from '../middleware/session-csrf.middleware'; import { PerformanceCacheModule } from '../performance-cache'; @@ -100,6 +101,7 @@ const globalModules = { DataDbRuntimeCacheService, DataDbClientManager, DataDbMigrationService, + SpaceDataDbMigrationGuardService, DatabaseRouter, RequestInfoMiddleware, SessionCsrfMiddleware, @@ -121,6 +123,7 @@ const globalModules = { DataDbRuntimeCacheService, DataDbClientManager, DataDbMigrationService, + SpaceDataDbMigrationGuardService, DatabaseRouter, KnexModule, PrismaModule, diff --git a/apps/nestjs-backend/src/middleware/request-info.middleware.spec.ts b/apps/nestjs-backend/src/middleware/request-info.middleware.spec.ts new file mode 100644 index 0000000000..2dbc1ed1c5 --- /dev/null +++ b/apps/nestjs-backend/src/middleware/request-info.middleware.spec.ts @@ -0,0 +1,128 @@ +import type { Request, Response } from 'express'; +import { describe, expect, it, vi } from 'vitest'; +import type { ClsService } from 'nestjs-cls'; +import type { IClsStore } from '../types/cls'; +import { RequestInfoMiddleware } from './request-info.middleware'; + +const createRequest = (overrides: Partial = {}): Request => + ({ + headers: {}, + ip: '127.0.0.1', + socket: { remoteAddress: '127.0.0.1' }, + ...overrides, + }) as Request; + +describe('RequestInfoMiddleware', () => { + 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; + + 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 next = vi.fn(); + 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()?.(); + + expect(task).toHaveBeenCalledTimes(1); + } finally { + globalWithTimeout.setTimeout = originalSetTimeout; + } + }); + + 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; + + try { + const clsValues = new Map(); + const scheduledStore = { + audit: { + rootAction: 'table.duplicate', + operationId: 'op_1', + }, + } as IClsStore; + const cls = { + get: vi.fn(() => scheduledStore), + 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); + + middleware.use(createRequest(), res, vi.fn()); + + const schedule = clsValues.get('scheduleV2BackgroundTask') as NonNullable< + IClsStore['scheduleV2BackgroundTask'] + >; + const task = vi.fn(); + + schedule(task); + listeners.get('finish')?.(); + timers.shift()?.(); + + expect(cls.runWith).toHaveBeenCalledWith(scheduledStore, expect.any(Function)); + expect(task).toHaveBeenCalledTimes(1); + } finally { + globalWithTimeout.setTimeout = originalSetTimeout; + } + }); +}); diff --git a/apps/nestjs-backend/src/middleware/request-info.middleware.ts b/apps/nestjs-backend/src/middleware/request-info.middleware.ts index f2944d3846..19605d18c2 100644 --- a/apps/nestjs-backend/src/middleware/request-info.middleware.ts +++ b/apps/nestjs-backend/src/middleware/request-info.middleware.ts @@ -6,6 +6,57 @@ import { ClsService } from 'nestjs-cls'; import type { IClsStore } from '../types/cls'; const automationRobotUserId = 'automationRobot'; +const fallbackScheduleV2BackgroundTask: NonNullable = ( + task +) => { + const handle = setTimeout(() => void task(), 0); + handle.unref?.(); +}; + +const createAfterResponseScheduler = ( + cls: ClsService, + res: Response +): NonNullable => { + const pendingTasks: Array<() => Promise | void> = []; + let responseFinished = res.writableEnded || res.destroyed; + let flushScheduled = false; + + const scheduleFlush = () => { + if (flushScheduled) { + return; + } + flushScheduled = true; + const handle = setTimeout(() => { + flushScheduled = false; + const tasks = pendingTasks.splice(0); + for (const task of tasks) { + void task(); + } + }, 0); + handle.unref?.(); + }; + + const markResponseFinished = () => { + responseFinished = true; + scheduleFlush(); + }; + + res.once('finish', markResponseFinished); + res.once('close', markResponseFinished); + + return (task) => { + const store = cls.get(); + pendingTasks.push(() => { + if (store) { + return cls.runWith(store, task); + } + return task(); + }); + if (responseFinished) { + scheduleFlush(); + } + }; +}; @Injectable() export class RequestInfoMiddleware implements NestMiddleware { @@ -51,6 +102,11 @@ export class RequestInfoMiddleware implements NestMiddleware { this.cls.set('canaryHeader', canaryHeader); } + this.cls.set( + 'scheduleV2BackgroundTask', + res ? createAfterResponseScheduler(this.cls, res) : fallbackScheduleV2BackgroundTask + ); + next(); } } diff --git a/apps/nestjs-backend/src/share-db/interface.ts b/apps/nestjs-backend/src/share-db/interface.ts index 6acd25e980..69e029b430 100644 --- a/apps/nestjs-backend/src/share-db/interface.ts +++ b/apps/nestjs-backend/src/share-db/interface.ts @@ -1,4 +1,4 @@ -import type { ISnapshotBase } from '@teable/core'; +import type { ISnapshotBase, IOtOperation } from '@teable/core'; import type { CreateOp, DB, DeleteOp, EditOp } from 'sharedb'; export interface IReadonlyAdapterService { @@ -62,3 +62,12 @@ export interface IRawOpMap { [docId: string]: IRawOp; }; } + +/** + * Per-subscription-type policy deciding whether an op can possibly affect a + * query subscription's results; see query-poll-skip.ts for the dispatch. + */ +export interface IQueryPollSkipStrategy { + /** return true when polling can be safely skipped for this op */ + shouldSkip(collection: string, id: string, ops: IOtOperation[], query: unknown): boolean; +} diff --git a/apps/nestjs-backend/src/share-db/metrics/query-poll-skip-metrics.ts b/apps/nestjs-backend/src/share-db/metrics/query-poll-skip-metrics.ts new file mode 100644 index 0000000000..d10f137fae --- /dev/null +++ b/apps/nestjs-backend/src/share-db/metrics/query-poll-skip-metrics.ts @@ -0,0 +1,39 @@ +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: skipPoll's dispatch in query-poll-skip/ is a +// pure hot path, not a Nest provider, so it cannot inject the service. +// +// skipPoll fires per (op x subscription) — hundreds of thousands of times a +// day — so decisions are observed via aggregated counters only; per-event +// logging at this rate is not acceptable. +const meter = metrics.getMeter('teable-observability'); + +const decisionsTotal = meter.createCounter('realtime.query_poll.decisions.total', { + description: 'ShareDB query poll skip decisions, labeled by decision and reason', +}); + +export type IQueryPollDecisionReason = + // entry-level guards + | 'create_or_delete' + | 'no_component_ops' + | 'no_strategy' + // record strategy reasons + | 'unanalyzable_op' + | 'row_order_same_view' + | 'row_order_other_view_only' + | 'view_bound_query' + | 'unbounded_query' + | 'fields_relevant' + | 'fields_irrelevant' + | 'field_options_relevant' + | 'field_options_irrelevant'; + +export const recordQueryPollDecision = ( + skip: boolean, + reason: IQueryPollDecisionReason +): boolean => { + decisionsTotal.add(1, { decision: skip ? 'skip' : 'poll', reason }); + return skip; +}; diff --git a/apps/nestjs-backend/src/share-db/query-poll-skip/index.ts b/apps/nestjs-backend/src/share-db/query-poll-skip/index.ts new file mode 100644 index 0000000000..290feb0397 --- /dev/null +++ b/apps/nestjs-backend/src/share-db/query-poll-skip/index.ts @@ -0,0 +1,31 @@ +import { IdPrefix } from '@teable/core'; +import type { CreateOp, DeleteOp, EditOp } from 'sharedb'; +import type { IQueryPollSkipStrategy } from '../interface'; +import { recordQueryPollDecision } from '../metrics/query-poll-skip-metrics'; +import { RecordQueryPollSkipStrategy } from './record-query-poll-skip.strategy'; + +// one strategy per subscribed doc type; a subscription type without a +// strategy always polls +const strategies: Partial> = { + [IdPrefix.Record]: new RecordQueryPollSkipStrategy(), +}; + +/** + * Entry point used by ShareDbAdapter.skipPoll. Common guards live here; + * doc-type specific reasoning is delegated to the matching strategy. + * Decisions are observed via aggregated otel counters (each decision is + * counted exactly once, either here or inside the strategy). + */ +export const shouldSkipQueryPoll = ( + collection: string, + id: string, + op: CreateOp | DeleteOp | EditOp, + query: unknown +): boolean => { + if (op.create || op.del) return recordQueryPollDecision(false, 'create_or_delete'); + if (!op.op) return recordQueryPollDecision(true, 'no_component_ops'); + const [docType] = collection.split('_'); + const strategy = strategies[docType as IdPrefix]; + if (!strategy) return recordQueryPollDecision(false, 'no_strategy'); + return strategy.shouldSkip(collection, id, op.op, query); +}; diff --git a/apps/nestjs-backend/src/share-db/query-poll-skip/query-poll-skip.spec.ts b/apps/nestjs-backend/src/share-db/query-poll-skip/query-poll-skip.spec.ts new file mode 100644 index 0000000000..59fb0879f1 --- /dev/null +++ b/apps/nestjs-backend/src/share-db/query-poll-skip/query-poll-skip.spec.ts @@ -0,0 +1,128 @@ +import type { EditOp } from 'sharedb'; +import { describe, expect, it } from 'vitest'; +import { shouldSkipQueryPoll } from '.'; + +const editOp = (subOps: unknown[]) => ({ op: subOps }) as unknown as EditOp; + +const collection = 'rec_tblTest0000000001'; + +const filteredQuery = { + ignoreViewQuery: true, + filter: { + conjunction: 'and', + filterSet: [{ fieldId: 'fldFiltered00000001', operator: 'is', value: '1' }], + }, +}; + +describe('shouldSkipQueryPoll', () => { + it('always polls for create and delete ops', () => { + expect(shouldSkipQueryPoll(collection, 'rec1', { create: {} } as never, filteredQuery)).toBe( + false + ); + expect(shouldSkipQueryPoll(collection, 'rec1', { del: true } as never, filteredQuery)).toBe( + false + ); + }); + + it('always polls for subscription types without a strategy', () => { + const op = editOp([{ p: ['fields', 'fldUnrelated0000001'], oi: 'x' }]); + expect(shouldSkipQueryPoll('viw_tblTest0000000001', 'viw1', op, {})).toBe(false); + }); + + it('polls row order changes only for subscriptions on the same view', () => { + const op = editOp([{ p: ['fields', '__row_viwaTuM2nkzlPWclDH9'], oi: 3.375, od: 3 }]); + // same view rides the manual order, inlined or not + expect(shouldSkipQueryPoll(collection, 'rec1', op, { viewId: 'viwaTuM2nkzlPWclDH9' })).toBe( + false + ); + expect( + shouldSkipQueryPoll(collection, 'rec1', op, { + ...filteredQuery, + viewId: 'viwaTuM2nkzlPWclDH9', + }) + ).toBe(false); + // other views and view-less queries never read this pseudo column + expect(shouldSkipQueryPoll(collection, 'rec1', op, { viewId: 'viwOther00000000001' })).toBe( + true + ); + expect(shouldSkipQueryPoll(collection, 'rec1', op, filteredQuery)).toBe(true); + }); + + it('falls back to field analysis when an op mixes field values with row order columns', () => { + const op = editOp([ + { p: ['fields', 'fldUnrelated0000001'], oi: 'x' }, + { p: ['fields', '__row_viwaTuM2nkzlPWclDH9'], oi: 3.375, od: 3 }, + ]); + expect(shouldSkipQueryPoll(collection, 'rec1', op, filteredQuery)).toBe(true); + const filteredOp = editOp([ + { p: ['fields', 'fldFiltered00000001'], oi: 'x' }, + { p: ['fields', '__row_viwaTuM2nkzlPWclDH9'], oi: 3.375, od: 3 }, + ]); + expect(shouldSkipQueryPoll(collection, 'rec1', filteredOp, filteredQuery)).toBe(false); + }); + + it('polls when an op touches an unknown pseudo column', () => { + const op = editOp([{ p: ['fields', '__unknown_column'], oi: 'x' }]); + expect(shouldSkipQueryPoll(collection, 'rec1', op, filteredQuery)).toBe(false); + }); + + it('skips polling when modified fields do not affect the query', () => { + const op = editOp([{ p: ['fields', 'fldUnrelated0000001'], oi: 'x' }]); + expect(shouldSkipQueryPoll(collection, 'rec1', op, filteredQuery)).toBe(true); + }); + + it('polls when a modified field is referenced by the query filter', () => { + const op = editOp([{ p: ['fields', 'fldFiltered00000001'], oi: 'x' }]); + expect(shouldSkipQueryPoll(collection, 'rec1', op, filteredQuery)).toBe(false); + }); + + it('polls when a modified field is referenced by the record read filter', () => { + const op = editOp([{ p: ['fields', 'fldAuthority0000001'], oi: 'x' }]); + const query = { + ...filteredQuery, + recordReadFilter: { + conjunction: 'and', + filterSet: [{ fieldId: 'fldAuthority0000001', operator: 'is', value: 'me' }], + }, + }; + expect(shouldSkipQueryPoll(collection, 'rec1', op, query)).toBe(false); + }); + + it('always polls for plain viewId queries whose view config lives server side', () => { + const op = editOp([{ p: ['fields', 'fldUnrelated0000001'], oi: 'x' }]); + expect(shouldSkipQueryPoll(collection, 'rec1', op, { viewId: 'viwaTuM2nkzlPWclDH9' })).toBe( + false + ); + }); + + it('polls for a global filtering search (unbounded field scope)', () => { + const op = editOp([{ p: ['fields', 'fldUnrelated0000001'], oi: 'x' }]); + const query = { ignoreViewQuery: true, search: ['hello', '', true] }; + expect(shouldSkipQueryPoll(collection, 'rec1', op, query)).toBe(false); + }); + + describe('forwarded field options ops', () => { + const fieldId = 'fldSelect0000000001'; + const fieldOp = editOp([{ p: ['options'], oi: { choices: [] }, od: { choices: [] } }]); + + it('polls plain viewId subscriptions whose conditions live server side', () => { + expect( + shouldSkipQueryPoll(collection, fieldId, fieldOp, { viewId: 'viwaTuM2nkzlPWclDH9' }) + ).toBe(false); + }); + + it('polls inlined subscriptions referencing the field', () => { + const query = { ignoreViewQuery: true, orderBy: [{ fieldId, order: 'asc' }] }; + expect(shouldSkipQueryPoll(collection, fieldId, fieldOp, query)).toBe(false); + }); + + it('skips inlined subscriptions not referencing the field', () => { + expect(shouldSkipQueryPoll(collection, fieldId, fieldOp, filteredQuery)).toBe(true); + }); + + it('polls for a global filtering search (unbounded field scope)', () => { + const query = { ignoreViewQuery: true, search: ['hello', '', true] }; + expect(shouldSkipQueryPoll(collection, fieldId, fieldOp, query)).toBe(false); + }); + }); +}); diff --git a/apps/nestjs-backend/src/share-db/query-poll-skip/record-query-poll-skip.strategy.ts b/apps/nestjs-backend/src/share-db/query-poll-skip/record-query-poll-skip.strategy.ts new file mode 100644 index 0000000000..d856c13760 --- /dev/null +++ b/apps/nestjs-backend/src/share-db/query-poll-skip/record-query-poll-skip.strategy.ts @@ -0,0 +1,120 @@ +import type { IFilter, IOtOperation } from '@teable/core'; +import { + collectQueryFieldIds, + extractFieldIdsFromFilter, + IdPrefix, + RecordOpBuilder, +} from '@teable/core'; +import type { IGetRecordsRo } from '@teable/openapi'; +import { ROW_ORDER_FIELD_PREFIX } from '../../features/view/constant'; +import type { IQueryPollSkipStrategy } from '../interface'; +import { recordQueryPollDecision } from '../metrics/query-poll-skip-metrics'; + +type ISubscriptionRecordQuery = IGetRecordsRo & { recordReadFilter?: IFilter }; + +/** + * Record query subscriptions receive, besides record ops, field options ops + * forwarded by ShareDbService.forwardToRecordChannel; those carry the source + * doc id in op.d (passed here as id), which narrows the affected + * subscriptions. + */ +export class RecordQueryPollSkipStrategy implements IQueryPollSkipStrategy { + shouldSkip(collection: string, id: string, ops: IOtOperation[], query: unknown): boolean { + const recordQuery = query as ISubscriptionRecordQuery | null | undefined; + if (!recordQuery) return recordQueryPollDecision(false, 'unbounded_query'); + + if (typeof id === 'string' && id.startsWith(IdPrefix.Field)) { + return this.skipForwardedFieldOp(id, recordQuery); + } + return this.skipRecordOp(ops, recordQuery); + } + + // forwarded field options op: options shape result semantics (e.g. select + // choice order drives sorting), so only queries referencing the field care + private skipForwardedFieldOp(fieldId: string, recordQuery: ISubscriptionRecordQuery): boolean { + if (recordQuery.viewId && !recordQuery.ignoreViewQuery) { + return recordQueryPollDecision(false, 'view_bound_query'); + } + const queryFieldIds = this.collectSubscriptionFieldIds(recordQuery); + if (!queryFieldIds) return recordQueryPollDecision(false, 'unbounded_query'); + return queryFieldIds.has(fieldId) + ? recordQueryPollDecision(false, 'field_options_relevant') + : recordQueryPollDecision(true, 'field_options_irrelevant'); + } + + private skipRecordOp(ops: IOtOperation[], recordQuery: ISubscriptionRecordQuery): boolean { + const modified = this.extractModifiedFields(ops); + if (!modified) return recordQueryPollDecision(false, 'unanalyzable_op'); + const { fieldIds, rowOrderViewIds } = modified; + + // the row order pseudo column only feeds the base ordering of queries on + // the same view (inlined or not — both ride the view's manual order) + if (recordQuery.viewId && rowOrderViewIds.has(recordQuery.viewId)) { + return recordQueryPollDecision(false, 'row_order_same_view'); + } + if (fieldIds.size === 0) { + // an op with no analyzable path must conservatively poll + return rowOrderViewIds.size > 0 + ? recordQueryPollDecision(true, 'row_order_other_view_only') + : recordQueryPollDecision(false, 'unanalyzable_op'); + } + + // a viewId query depends on the view's server-side filter/sort, whose + // fields are not visible here, so it must always poll. This guard must stay + // AFTER the row-order checks above: hoisting it (or merging it with the + // identical guard in skipForwardedFieldOp) would turn the + // 'row_order_other_view_only' skip into a poll for every view-bound query + if (recordQuery.viewId && !recordQuery.ignoreViewQuery) { + return recordQueryPollDecision(false, 'view_bound_query'); + } + + const queryFieldIds = this.collectSubscriptionFieldIds(recordQuery); + if (!queryFieldIds) return recordQueryPollDecision(false, 'unbounded_query'); + + for (const fieldId of fieldIds) { + if (queryFieldIds.has(fieldId)) { + return recordQueryPollDecision(false, 'fields_relevant'); + } + } + return recordQueryPollDecision(true, 'fields_irrelevant'); + } + + // subscribers may attach their authority-matrix read filter; rows enter or + // leave their visible set when its fields change, so those fields are + // relevant too. Advisory only: read enforcement never relies on it + private collectSubscriptionFieldIds(recordQuery: ISubscriptionRecordQuery): Set | null { + const queryFieldIds = collectQueryFieldIds(recordQuery); + if (!queryFieldIds) return null; + for (const fieldId of extractFieldIdsFromFilter(recordQuery.recordReadFilter, true)) { + queryFieldIds.add(fieldId); + } + return queryFieldIds; + } + + // Returns null when the op touches a path that is neither a plain field + // value nor a row order pseudo column (fields.__row_), meaning the + // op may affect query order/membership in ways field analysis cannot see. + private extractModifiedFields( + ops: IOtOperation[] + ): { fieldIds: Set; rowOrderViewIds: Set } | null { + const fieldIds = new Set(); + const rowOrderViewIds = new Set(); + const rowOrderPrefix = `${ROW_ORDER_FIELD_PREFIX}_`; + for (const subOp of ops) { + // any non-field path may affect membership/order in ways field analysis + // cannot see, so bail to a conservative poll rather than ignoring it + if (subOp.p?.[0] !== 'fields') return null; + // core's SetRecordBuilder owns the ['fields', fieldId] op path schema + const fieldId = RecordOpBuilder.editor.setRecord.detect(subOp)?.fieldId; + if (typeof fieldId !== 'string') return null; + if (fieldId.startsWith(IdPrefix.Field)) { + fieldIds.add(fieldId); + } else if (fieldId.startsWith(rowOrderPrefix)) { + rowOrderViewIds.add(fieldId.slice(rowOrderPrefix.length)); + } else { + return null; + } + } + return { fieldIds, rowOrderViewIds }; + } +} diff --git a/apps/nestjs-backend/src/share-db/share-db.adapter.ts b/apps/nestjs-backend/src/share-db/share-db.adapter.ts index 3e2519a5cb..d66c230716 100644 --- a/apps/nestjs-backend/src/share-db/share-db.adapter.ts +++ b/apps/nestjs-backend/src/share-db/share-db.adapter.ts @@ -36,6 +36,7 @@ import { type IEditOp, type IShareDbReadonlyAdapterService, } from './interface'; +import { shouldSkipQueryPoll } from './query-poll-skip'; import { FieldReadonlyServiceAdapter } from './readonly/field-readonly.service'; import { RecordReadonlyServiceAdapter } from './readonly/record-readonly.service'; import { TableReadonlyServiceAdapter } from './readonly/table-readonly.service'; @@ -53,6 +54,11 @@ type IProjection = { [fieldNameOrId: string]: boolean }; export class ShareDbAdapter extends ShareDb.DB { private logger = new Logger(ShareDbAdapter.name); + // Read by sharedb QueryEmitter (lib/query-emitter.js): ops arriving while a + // poll is in flight or within this window are coalesced into a single + // trailing poll, instead of one poll per op. + pollDebounce = Number(process.env.SHAREDB_QUERY_POLL_DEBOUNCE_MS ?? 200); + closed: boolean; constructor( @@ -82,6 +88,20 @@ export class ShareDbAdapter extends ShareDb.DB { throw new Error(`QueryType: ${type} has no readonly adapter service implementation`); } + // Translate the query's field-id projection (string[]) into the snapshot + // projection shape ({ [fieldId]: true }). Returns undefined when the query + // carries no projection, leaving the ShareDB native projection in place. + private queryProjection(query: unknown): IProjection | undefined { + const projection = (query as { projection?: string[] } | undefined)?.projection; + if (!Array.isArray(projection) || projection.length === 0) { + return undefined; + } + return projection.reduce((acc, fieldId) => { + acc[fieldId] = true; + return acc; + }, {}); + } + query = async ( collection: string, query: unknown, @@ -101,7 +121,12 @@ export class ShareDbAdapter extends ShareDb.DB { this.getSnapshotBulk( collection, results as string[], - projection, + // ShareDB's native projection arg is only populated for registered + // projection collections (we register none), so it is always empty. + // The field selection the client cares about rides inside the query + // (e.g. a view's visible field ids) — forward it so the bulk snapshot + // only carries those fields. + this.queryProjection(query) ?? projection, options, (error, snapshots) => { if (error) { @@ -163,19 +188,25 @@ export class ShareDbAdapter extends ShareDb.DB { } // Return true to avoid polling if there is no possibility that an op could - // affect a query's results + // affect a query's results; the decision logic lives in query-poll-skip/ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore skipPoll( - _collection: string, + collection: string, _id: string, op: CreateOp | DeleteOp | EditOp, - _query: unknown + query: unknown ): boolean { - // ShareDB is in charge of doing the validation of ops, so at this point we - // should be able to assume that the op is structured validly - if (op.create || op.del) return false; - return !op.op; + // decision counts are observed via otel metrics inside query-poll-skip; + // per-event logging is debug-only — this fires hundreds of thousands of + // times a day in production + const isShouldSkipQueryPoll = shouldSkipQueryPoll(collection, _id, op, query); + if (isShouldSkipQueryPoll) { + this.logger.debug( + `skipping poll for op on ${collection} ${_id} because modified fields do not affect the query` + ); + } + return isShouldSkipQueryPoll; } close(callback: () => void) { @@ -223,7 +254,6 @@ export class ShareDbAdapter extends ShareDb.DB { ) { try { const [docType, collectionId] = collection.split('_'); - let authHeaders; try { authHeaders = this.getAuthHeaders(options); 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 7c56608405..8e48cc9301 100644 --- a/apps/nestjs-backend/src/share-db/share-db.service.ts +++ b/apps/nestjs-backend/src/share-db/share-db.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger, Optional } from '@nestjs/common'; import { context as otelContext, trace as otelTrace } from '@opentelemetry/api'; -import { FieldOpBuilder, IdPrefix, ViewOpBuilder } from '@teable/core'; +import { FieldOpBuilder, IdPrefix } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import { noop } from 'lodash'; import { ClsService } from 'nestjs-cls'; @@ -152,9 +152,9 @@ export class ShareDbService extends ShareDBClass { this.pubsub.publish(channels, repairedOp, noop); publishCount++; - if (this.shouldPublishAction(repairedOp)) { + if (this.shouldForwardToRecordChannel(repairedOp)) { const tableId = collection.split('_')[1]; - this.publishRelatedChannels(tableId, repairedOp); + this.forwardToRecordChannel(tableId, repairedOp); } } } @@ -164,28 +164,28 @@ export class ShareDbService extends ShareDBClass { } } - // for update record when import + // synthetic ops that only wake record query polling, never doc subscribers + // (no doc id): import progress and manual row reorder publishRecordChannel(tableId: string, rawOp: EditOp | CreateOp | DeleteOp) { this.pubsub.publish([`${IdPrefix.Record}_${tableId}`], rawOp, noop); } - private shouldPublishAction(rawOp: EditOp | CreateOp | DeleteOp) { - const viewKeys = ['filter', 'sort', 'group', 'lastModifiedTime']; + // field options shape record query result semantics (e.g. select choice + // order drives sorting) without emitting record ops, so their changes must + // wake record query subscriptions; the adapter's skipPoll narrows the + // fan-out to subscriptions referencing the field. View condition changes + // are not forwarded: clients inline view conditions into the query and + // resubscribe on change. Manual row reorder emits a synthetic record op + // itself (see ViewOpenApiService.publishRowOrderChange) + private shouldForwardToRecordChannel(rawOp: EditOp | CreateOp | DeleteOp) { const fieldKeys = ['options']; - return rawOp.op?.some( - (op) => - viewKeys.includes(ViewOpBuilder.editor.setViewProperty.detect(op)?.key as string) || - fieldKeys.includes(FieldOpBuilder.editor.setFieldProperty.detect(op)?.key as string) + return rawOp.op?.some((op) => + fieldKeys.includes(FieldOpBuilder.editor.setFieldProperty.detect(op)?.key as string) ); } - /** - * this is for some special scenarios like manual sort - * which only send view ops but update record too - */ - private publishRelatedChannels(tableId: string, rawOp: EditOp | CreateOp | DeleteOp) { + private forwardToRecordChannel(tableId: string, rawOp: EditOp | CreateOp | DeleteOp) { this.pubsub.publish([`${IdPrefix.Record}_${tableId}`], rawOp, noop); - this.pubsub.publish([`${IdPrefix.Field}_${tableId}`], rawOp, noop); } private onSubmit = ( diff --git a/apps/nestjs-backend/src/share-db/sharedb-redis.pubsub.ts b/apps/nestjs-backend/src/share-db/sharedb-redis.pubsub.ts index 95cccfaea9..6d4f97f9e3 100644 --- a/apps/nestjs-backend/src/share-db/sharedb-redis.pubsub.ts +++ b/apps/nestjs-backend/src/share-db/sharedb-redis.pubsub.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import { context as otelContext, ROOT_CONTEXT } from '@opentelemetry/api'; import Redis from 'ioredis'; import type { Error as ShareDBError } from 'sharedb'; import { PubSub } from 'sharedb'; @@ -122,7 +123,13 @@ export class RedisPubSub extends PubSub { } handleMessage(channel: string, message: string) { - this._emit(channel, JSON.parse(message)); + // The message listener was registered during app bootstrap, so without + // detaching, every pubsub-triggered query poll (and its loopback HTTP + // call) inherits the bootstrap trace and piles up in one giant trace for + // the pod's whole lifetime. ROOT_CONTEXT lets each poll start its own. + otelContext.with(ROOT_CONTEXT, () => { + this._emit(channel, JSON.parse(message)); + }); } _unsubscribe(channel: string, callback: (err: ShareDBError | null) => void): void { diff --git a/apps/nestjs-backend/src/tracing.ts b/apps/nestjs-backend/src/tracing.ts index c323c5431e..bacc2013ca 100644 --- a/apps/nestjs-backend/src/tracing.ts +++ b/apps/nestjs-backend/src/tracing.ts @@ -16,6 +16,10 @@ * | OTEL_SERVICE_NAME | Service name for tracing | teable | teable | * | OTEL_EXPORT_RATIO | Export ratio (0.0-1.0) | 1.0 (100%) | 0.1 (10%) | * | OTEL_EXPORT_LATENCY_THRESHOLD_MS | Slow request threshold (ms) | 1500 | 1500 | + * | OTEL_BSP_MAX_QUEUE_SIZE | Trace BSP max queue size | 2048 | 2048 | + * | OTEL_BSP_MAX_EXPORT_BATCH_SIZE | Trace BSP max export batch | 512 | 512 | + * | OTEL_BSP_SCHEDULE_DELAY | Trace BSP delay (ms) | 5000 | 5000 | + * | OTEL_BSP_EXPORT_TIMEOUT | Trace BSP export timeout (ms) | 30000 | 30000 | * | OTEL_METRIC_EXPORT_INTERVAL_MS | Metrics export interval (ms) | 10000 | 60000 | * | BACKEND_SENTRY_DSN | Sentry DSN for error tracking | (disabled) | (disabled) | * | BUILD_VERSION | Build version for resource | (none) | (none) | @@ -42,7 +46,11 @@ import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino'; import { RuntimeNodeInstrumentation } from '@opentelemetry/instrumentation-runtime-node'; import { resourceFromAttributes } from '@opentelemetry/resources'; import * as opentelemetry from '@opentelemetry/sdk-node'; -import { BatchSpanProcessor, NoopSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { + BatchSpanProcessor, + NoopSpanProcessor, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; import type { SpanProcessor } from '@opentelemetry/sdk-trace-base'; import { ATTR_HTTP_RESPONSE_STATUS_CODE, @@ -65,7 +73,8 @@ const nativeRequire: NodeRequire = typeof __non_webpack_require__ !== 'undefined' ? __non_webpack_require__ : require; const { BatchLogRecordProcessor } = opentelemetry.logs; -const { PeriodicExportingMetricReader, AggregationType } = opentelemetry.metrics; +const { PeriodicExportingMetricReader, AggregationType, createAllowListAttributesProcessor } = + opentelemetry.metrics; const { AlwaysOnSampler } = opentelemetry.node; const otelLogger = new Logger('OpenTelemetry'); @@ -83,6 +92,10 @@ const ENV_DEFAULTS = { OTEL_SERVICE_NAME: 'teable', OTEL_EXPORT_RATIO: '1.0', OTEL_EXPORT_LATENCY_THRESHOLD_MS: '1500', + OTEL_BSP_MAX_QUEUE_SIZE: '2048', + OTEL_BSP_MAX_EXPORT_BATCH_SIZE: '512', + OTEL_BSP_SCHEDULE_DELAY: '5000', + OTEL_BSP_EXPORT_TIMEOUT: '30000', OTEL_METRIC_EXPORT_INTERVAL_MS: '10000', }, production: { @@ -92,6 +105,10 @@ const ENV_DEFAULTS = { OTEL_SERVICE_NAME: 'teable', OTEL_EXPORT_RATIO: '0.1', OTEL_EXPORT_LATENCY_THRESHOLD_MS: '1500', + OTEL_BSP_MAX_QUEUE_SIZE: '2048', + OTEL_BSP_MAX_EXPORT_BATCH_SIZE: '512', + OTEL_BSP_SCHEDULE_DELAY: '5000', + OTEL_BSP_EXPORT_TIMEOUT: '30000', OTEL_METRIC_EXPORT_INTERVAL_MS: '60000', }, } as const; @@ -130,6 +147,11 @@ const parseNumber = (value: string | undefined, defaultValue: number): number => return Number.isFinite(parsed) ? parsed : defaultValue; }; +const parseIntegerConfig = (key: EnvConfigKey, defaultValue: number, minValue: number): number => { + const parsed = Math.floor(parseNumber(getConfig(key), defaultValue)); + return Math.max(minValue, parsed); +}; + // Configuration const headers = parseHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS); const traceEndpoint = getConfig('OTEL_EXPORTER_OTLP_ENDPOINT'); @@ -141,11 +163,17 @@ const latencyThresholdMs = Math.max( 0, parseNumber(getConfig('OTEL_EXPORT_LATENCY_THRESHOLD_MS'), 1500) ); +const traceBatchMaxQueueSize = parseIntegerConfig('OTEL_BSP_MAX_QUEUE_SIZE', 2048, 1); +const traceBatchMaxExportBatchSize = Math.min( + traceBatchMaxQueueSize, + parseIntegerConfig('OTEL_BSP_MAX_EXPORT_BATCH_SIZE', 512, 1) +); +const traceBatchScheduledDelayMillis = parseIntegerConfig('OTEL_BSP_SCHEDULE_DELAY', 5000, 0); +const traceBatchExportTimeoutMillis = parseIntegerConfig('OTEL_BSP_EXPORT_TIMEOUT', 30000, 1); const metricExportIntervalMs = Math.max( 1000, parseNumber(getConfig('OTEL_METRIC_EXPORT_INTERVAL_MS'), 60000) ); - // Exporters const createExporterOptions = (url?: string) => ({ url, @@ -164,7 +192,9 @@ const metricsExporter = metricsEndpoint // Strip high-cardinality resource attributes from metrics only. // Traces and logs keep these for debugging; metrics drop them to prevent -// cardinality explosion in ephemeral containers (each restart = new host.name + pid). +// cardinality explosion (each restart = new host.name + pid; each deploy = +// new service.version build tag, so the unique metric series count would grow +// unbounded over time as releases accumulate). if (metricsExporter) { const dropFromMetricResource = new Set([ 'host.name', @@ -178,6 +208,7 @@ if (metricsExporter) { 'process.executable.path', 'process.owner', 'service.instance.id', + 'service.version', ]); const origExport = metricsExporter.export.bind(metricsExporter); metricsExporter.export = (metrics, cb) => { @@ -200,9 +231,31 @@ const getTraceDecision = (traceId: string): boolean => { return hash % 10000 < exportRatio * 10000; }; -const shouldExportSpan = (span: opentelemetry.tracing.ReadableSpan): boolean => { - if (exportRatio >= 1.0) return true; +// Prisma emits ~11 spans per query (operation, client:middleware/serialize, +// engine:query/connection/db_query/serialize/...). Keep only the spine +// (operation -> engine:query -> db_query) and drop the rest. The full spine is kept +// so the surviving db_query is never orphaned (which renders as a "Missing Span"). +const PRISMA_SPINE_SPANS = new Set([ + 'prisma:client:operation', + 'prisma:engine:query', + 'prisma:engine:db_query', +]); + +const isDroppedPrismaSpan = (span: opentelemetry.tracing.ReadableSpan): boolean => + span.name.startsWith('prisma:') && !PRISMA_SPINE_SPANS.has(span.name); + +const isPriorityTraceSpan = (span: opentelemetry.tracing.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') + ); +}; +const shouldExportSpan = (span: opentelemetry.tracing.ReadableSpan): boolean => { // Always export errors if (span.status.code === SpanStatusCode.ERROR) return true; @@ -214,26 +267,44 @@ const shouldExportSpan = (span: opentelemetry.tracing.ReadableSpan): boolean => const durationMs = span.duration[0] * 1000 + span.duration[1] / 1_000_000; if (durationMs > latencyThresholdMs) return true; + if (isDroppedPrismaSpan(span)) return false; + + if (exportRatio >= 1.0) return true; + // Consistent export decision based on traceId - all spans in same trace have same fate return getTraceDecision(span.spanContext().traceId); }; -const createSmartBatchProcessor = (exporter: OTLPTraceExporter): SpanProcessor => { - const batchProcessor = new BatchSpanProcessor(exporter, { - maxQueueSize: 2048, - maxExportBatchSize: 512, - scheduledDelayMillis: 5000, - exportTimeoutMillis: 30000, +const createSmartSpanProcessor = ( + batchExporter: OTLPTraceExporter, + priorityExporter: OTLPTraceExporter +): SpanProcessor => { + const batchProcessor = new BatchSpanProcessor(batchExporter, { + maxQueueSize: traceBatchMaxQueueSize, + maxExportBatchSize: traceBatchMaxExportBatchSize, + scheduledDelayMillis: traceBatchScheduledDelayMillis, + exportTimeoutMillis: traceBatchExportTimeoutMillis, }); - if (exportRatio >= 1.0) return batchProcessor; + const priorityProcessor = new SimpleSpanProcessor(priorityExporter); return { - onStart: batchProcessor.onStart.bind(batchProcessor), + onStart: (span, parentContext) => { + priorityProcessor.onStart(span, parentContext); + batchProcessor.onStart(span, parentContext); + }, onEnd: (span: opentelemetry.tracing.ReadableSpan) => { + if (isPriorityTraceSpan(span)) { + priorityProcessor.onEnd(span); + return; + } if (shouldExportSpan(span)) batchProcessor.onEnd(span); }, - shutdown: batchProcessor.shutdown.bind(batchProcessor), - forceFlush: batchProcessor.forceFlush.bind(batchProcessor), + shutdown: () => + Promise.all([priorityProcessor.shutdown(), batchProcessor.shutdown()]).then(() => undefined), + forceFlush: () => + Promise.all([priorityProcessor.forceFlush(), batchProcessor.forceFlush()]).then( + () => undefined + ), }; }; @@ -289,7 +360,14 @@ const teableDbSpanAttributeProcessor: SpanProcessor = { // even when no exporter is configured (needed for trace ID in logs) const spanProcessors = [ ...(hasSentry ? [new SentrySpanProcessor()] : []), - ...(traceExporter ? [createSmartBatchProcessor(traceExporter)] : [new NoopSpanProcessor()]), + ...(traceExporter + ? [ + createSmartSpanProcessor( + traceExporter, + new OTLPTraceExporter(createExporterOptions(traceEndpoint)) + ), + ] + : [new NoopSpanProcessor()]), httpClientActiveRequestsProcessor, teableDbSpanAttributeProcessor, ]; @@ -318,9 +396,9 @@ const ignorePaths = [ // ───────────────────────────────────────────────────────────────────────────── const drop = { type: AggregationType.DROP } as const; const buckets = (boundaries: number[]) => - ({ type: AggregationType.EXPLICIT_BUCKET_HISTOGRAM, boundaries }) as const; + ({ type: AggregationType.EXPLICIT_BUCKET_HISTOGRAM, options: { boundaries } }) as const; -const metricViews = [ +const metricViews: opentelemetry.metrics.ViewOptions[] = [ // Drop inbound HTTP metrics — 200+ routes cause cardinality explosion; // traces already provide per-request latency/status via SigNoz APM. { instrumentName: 'http.server.duration', aggregation: drop }, @@ -329,19 +407,22 @@ const metricViews = [ // Outbound HTTP — keep but drop server.address to cap cardinality // (50+ webhook hosts and growing). Only method + status remain. + // Boundaries are in seconds (instrumentation-http records seconds). { instrumentName: 'http.client.request.duration', aggregation: buckets([0.05, 0.25, 1, 5, 30]), - attributeKeys: ['http.request.method', 'http.response.status_code'], + attributesProcessors: [ + createAllowListAttributesProcessor(['http.request.method', 'http.response.status_code']), + ], }, - // Reduce high-cardinality auto-instrumented histograms from 14 → 5 buckets - // 1ms=cached, 5ms=indexed, 25ms=scan, 100ms=slow, 1s=very-slow - // Keep only operation name + system; drop db.namespace, server.address/port, etc. + // 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. { instrumentName: 'db.client.operation.duration', aggregation: buckets([0.001, 0.005, 0.025, 0.1, 1]), - attributeKeys: ['db.operation.name', 'db.system'], + attributesProcessors: [createAllowListAttributesProcessor(['db.operation.name', 'db.system'])], }, ]; @@ -391,6 +472,8 @@ otelLogger.log( `Initialized: service=${serviceName}, env=${isDevelopment ? 'dev' : 'prod'}, ` + `exportRatio=${exportRatio * 100}%, latencyThreshold=${latencyThresholdMs}ms, ` + `exporters=[traces:${!!traceEndpoint}, logs:${!!logEndpoint}, metrics:${!!metricsEndpoint}], ` + + `traceBatch=[queue:${traceBatchMaxQueueSize}, batch:${traceBatchMaxExportBatchSize}, ` + + `delay:${traceBatchScheduledDelayMillis}ms, timeout:${traceBatchExportTimeoutMillis}ms], ` + `metricsInterval=${metricExportIntervalMs}ms, ` + `sentry=${hasSentry}` ); diff --git a/apps/nestjs-backend/src/types/cls.ts b/apps/nestjs-backend/src/types/cls.ts index da8c6f11c1..f2dbcd76e4 100644 --- a/apps/nestjs-backend/src/types/cls.ts +++ b/apps/nestjs-backend/src/types/cls.ts @@ -1,6 +1,7 @@ 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 { ClsStore } from 'nestjs-cls'; import type { IAuditOperation } from '../features/audit/audit-scope'; import type { IWorkflowContext } from '../features/auth/strategies/types'; @@ -101,11 +102,11 @@ export interface IClsStore extends ClsStore { dataLoaderCache?: IDataLoaderCache; clearCacheKeys?: (keyof IPerformanceCacheStore)[]; canaryHeader?: string; // x-canary header value for canary release override + scheduleV2BackgroundTask?: ExecutionContextBackgroundTaskScheduler; useV2?: boolean; // Flag to indicate if V2 implementation should be used (set by V2FeatureGuard) 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 - skipFieldComputation?: boolean; // Skip computed field evaluation during bulk structure creation (import/duplicate) // 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 5b53740884..e017ad5f69 100644 --- a/apps/nestjs-backend/src/types/i18n.generated.ts +++ b/apps/nestjs-backend/src/types/i18n.generated.ts @@ -257,6 +257,7 @@ export type I18nTranslations = { "switchBase": string; "getMore": string; "copySuccess": string; + "openLink": string; "share": string; "clear": string; "retry": string; @@ -697,14 +698,16 @@ export type I18nTranslations = { }; "addOrgCollaborator": { "title": string; + "button": string; "placeholder": string; }; - "sendInvitationSuccess": string; "table": { "collaborator": string; "accessPermission": string; "joinAt": string; + "lastLogin": string; }; + "sendInvitationSuccess": string; "authority": { "title": string; "description": string; @@ -736,6 +739,9 @@ export type I18nTranslations = { "billable": string; "billableByAuthorityMatrix": string; "licenseExpiredGracePeriod": string; + "licenseAutoFetchFailed": string; + "licenseAutoFetchRetryFailed": string; + "licenseExpiredGracePeriodDays": string; "spaceSubscriptionModal": { "title": string; "description": string; @@ -900,6 +906,10 @@ export type I18nTranslations = { "isOpenRouter": string; "customModel": string; "customModelDescription": string; + "modelsSelectedCount": string; + "clearAllModels": string; + "customModelPlaceholder": string; + "addModelFill": string; "aiAbilitySettings": string; "aiAbilitySettingsDescription": string; "imageModelAbility": { @@ -1203,7 +1213,7 @@ export type I18nTranslations = { "appBuilderApiProxy": { "title": string; }; - "sandboxVercel": { + "sandboxOpenSandbox": { "title": string; "description": string; }; @@ -1247,6 +1257,10 @@ export type I18nTranslations = { "successText": string; "failedText": string; }; + "importantNotice": { + "title": string; + "acknowledge": string; + }; "noAttention": string; "noSeverity": string; "sections": { @@ -1334,6 +1348,7 @@ export type I18nTranslations = { "unsubscribeTime": string; "source": string; "sourceAutomationDeleted": string; + "sourceApiSend": string; "processing": string; "unsubscribeH1": string; "unsubscribeH2": string; @@ -1698,12 +1713,109 @@ export type I18nTranslations = { "unknown": string; }; }; + "skills": { + "title": string; + "tabs": { + "mySkills": string; + }; + "actions": { + "import": string; + "sync": string; + "delete": string; + "enable": string; + "disable": string; + "edit": string; + "tryInChat": string; + "replace": string; + "download": string; + "uninstall": string; + "copyToBase": string; + "copyToPersonal": string; + "copyToApp": string; + }; + "import": { + "title": string; + "description": string; + "githubTab": string; + "fileTab": string; + "githubPlaceholder": string; + "fileHint": string; + "or": string; + "selectFile": string; + "replaceWarning": string; + "importing": string; + "submit": string; + "success": string; + "error": string; + }; + "detail": { + "slug": string; + "description": string; + "source": string; + "skillLabel": string; + "syncStatus": string; + "preview": string; + "addedBy": string; + }; + "scope": { + "label": string; + "system": string; + "base": string; + "user": string; + "userDescription": string; + "baseDescription": string; + "app": string; + "appDescription": string; + "cuppyclaw": string; + "cuppyclawDescription": string; + }; + "source": { + "github": string; + "file": string; + "manual": string; + }; + "status": { + "enabled": string; + "disabled": string; + "synced": string; + "failed": string; + }; + "empty": { + "mySkills": string; + }; + "confirm": { + "deleteTitle": string; + "deleteDescription": string; + "copyTitle": string; + "copyDescription": string; + "copyOverwriteWarning": string; + }; + "toast": { + "syncing": string; + "syncSuccess": string; + "syncError": string; + "deleting": string; + "deleteSuccess": string; + "deleteError": string; + "updateSuccess": string; + "copying": string; + "copySuccess": string; + "copyError": string; + }; + }; "changelog": { "newUpdate": string; "title": string; "url": string; "id": string; }; + "resourceDescription": { + "addDescription": string; + "nodeDescription": string; + "descriptionSaving": string; + "descriptionSaveFailed": string; + "descriptionPlaceholder": string; + }; "noPermissionToCreateBase": string; "chat": { "responseInterrupted": string; @@ -1719,9 +1831,12 @@ export type I18nTranslations = { "sandboxCapacityFull": string; "sandboxTransient": string; "sandboxSnapshotNotFound": string; + "sandboxProviderError": string; + "sandboxProviderErrorDescription": string; "agentStartFailed": string; "idleTimeout": string; "danglingToolUse": string; + "contextImportFailed": string; }; "clickToCopyTooltip": string; "copiedTooltip": string; @@ -1977,6 +2092,7 @@ export type I18nTranslations = { "untitled": string; "cancel": string; "confirm": string; + "clear": string; "back": string; "done": string; "create": string; @@ -2264,6 +2380,13 @@ export type I18nTranslations = { "createdBy": string; "before": string; "after": string; + "buttonClicked": string; + "filterField": string; + "filterCreatedBy": string; + "filterTime": string; + "allFields": string; + "allUsers": string; + "clearFilter": string; "viewRecord": string; }; "showHiddenFields": string; @@ -2930,6 +3053,11 @@ export type I18nTranslations = { "nameMaxLength": string; "descriptionMaxLength": string; }; + "validation": { + "field": { + "unique": string; + }; + }; "custom": { "fieldValueNotNull": string; "fieldValueDuplicate": string; @@ -3317,6 +3445,43 @@ export type I18nTranslations = { "gatewayApiKeyNotSet": string; "geminiImageNotSupportedViaGateway": string; }; + "skill": { + "notFound": string; + "onlyGithubCanSync": string; + "syncFailed": string; + "skillMdNotFoundAtSource": string; + "slugChangedUpstream": string; + "skillMdNotFoundAtPath": string; + "invalidSkillFile": string; + "tooManyResourceFiles": string; + "noSkillMdInPackage": string; + "multipleSkillMdInPackage": string; + "invalidResourcePaths": string; + "nameFieldRequired": string; + "invalidSlugFormat": string; + "filesExceedSizeLimit": string; + "binaryFilesNotAllowed": string; + "totalSizeExceedsLimit": string; + "invalidGithubUrl": string; + "directoryTooDeep": string; + "importFailed": string; + "alreadyInScope": string; + }; + "sandbox": { + "fileNotFound": string; + "onlyRegularFiles": string; + "fileTooLarge": string; + "cannotDeleteRoot": string; + "deleteFailed": string; + "uploadFailed": string; + "fileEndpointUnavailable": string; + "invalidPath": string; + "tooManyDownloads": string; + "dailyLimitExceeded": string; + "storageFull": string; + "tooManyFiles": string; + "noActiveSandbox": string; + }; "role": { "notFound": string; }; @@ -3531,6 +3696,7 @@ export type I18nTranslations = { "createBase": string; "createSpace": string; "invite": string; + "startFromScratch": string; }; "allSpaces": string; "emptySpaceTitle": string; @@ -3688,7 +3854,7 @@ export type I18nTranslations = { }; "baseList": { "allBases": string; - "owner": string; + "creator": string; "createdTime": string; "lastOpened": string; "enter": string; @@ -3762,6 +3928,68 @@ export type I18nTranslations = { "unnamedApp": string; }; }; + "airtableImport": { + "title": string; + "close": string; + "pickBase": string; + "noBases": string; + "optionRecords": string; + "optionAttachments": string; + "import": string; + "done": string; + "failed": string; + "openBase": string; + "issuesSummary": string; + "issuesMore": string; + "issue": { + "fieldDegraded": string; + "fieldSkipped": string; + "viewSkipped": string; + "valuesDropped": string; + "viewConfigDegraded": string; + }; + "phase": { + "fetchingSchema": string; + "creatingBase": string; + "creatingTable": string; + "creatingLinks": string; + "fillingLinks": string; + "done": string; + "applyingViewConfig": string; + }; + "searchBases": string; + "noSearchResults": string; + "detecting": string; + "connectWithAirtable": string; + "waitingOAuth": string; + "connectedAs": string; + "permission": { + "create": string; + "edit": string; + "comment": string; + "read": string; + }; + "integrationRequired": string; + "optionViewConfig": string; + "viewConfig": { + "help": string; + "helpStep1": string; + "helpStep2": string; + "helpStep3": string; + "linkPlaceholder": string; + "mismatch": string; + "disableReminder": string; + }; + }; + "createBaseDialog": { + "title": string; + }; + "importBaseDialog": { + "title": string; + "fromFile": string; + "fromFileDesc": string; + "fromAirtableDesc": string; + }; "collaborators": string; "more": string; "export": { @@ -4309,6 +4537,9 @@ export type I18nTranslations = { "medium": string; "high": string; }; + "tip": { + "gptImageResolution": string; + }; "autoFill": { "title": string; "tip": string; @@ -4497,6 +4728,29 @@ export type I18nTranslations = { "finalizing": string; }; }; + "restoreFieldStream": { + "confirmTitle": string; + "confirmDescription": string; + "preparing": string; + "restoring": string; + "restoringRecords": string; + "restoreFailed": string; + "descriptionWithIssues": string; + "completedWithIssues": string; + "issuesBadge": string; + "chunkFailureTitle": string; + "chunkFailureSummary": string; + "chunkLabel": string; + "rowsLabel": string; + "partialFailureDescription": string; + "phaseLabel": { + "preparing": string; + "guarding": string; + "processing": string; + "publishing": string; + "finalizing": string; + }; + }; "pasing": string; }; "graph": { @@ -4803,6 +5057,7 @@ export type I18nTranslations = { "menu": { "addFromOtherSource": string; "excelFile": string; + "airtable": string; "csvFile": string; "importCsvData": string; "importExcelData": string; @@ -4979,6 +5234,7 @@ export type I18nTranslations = { "noPermission": string; "connectionCountTip": string; "createFailed": string; + "readonlyUnavailable": string; "helpLink": string; }; "view": { @@ -5133,6 +5389,7 @@ export type I18nTranslations = { "contextCompaction": { "auto": string; "manual": string; + "compacting": string; }; "taskProgress": { "title": string; @@ -5202,9 +5459,19 @@ export type I18nTranslations = { "folders": string; "envs": string; }; + "slashCommand": { + "skills": string; + "searchEmpty": string; + }; "inputPlaceholder": string; "inputPlaceholderFiles": string; "thought": string; + "stage": { + "initializing": string; + "working": string; + "committing": string; + "recovering": string; + }; "meta": { "input": string; "output": string; @@ -5213,6 +5480,8 @@ export type I18nTranslations = { "attachment": { "pastedTextFileName": string; "imageNotSupported": string; + "tooManyFiles": string; + "storageFull": string; }; "suggestions": { "title": string; @@ -5273,7 +5542,9 @@ export type I18nTranslations = { "queue": { "nQueued": string; "edit": string; - "forceSend": string; + "steerNow": string; + "steerFailed": string; + "conversationInputMarker": string; "removeFromQueue": string; }; "partTool": { @@ -5282,15 +5553,26 @@ export type I18nTranslations = { "write": string; "edit": string; "glob": string; + "find": string; "grep": string; + "ls": string; "webSearch": string; "skill": string; "foundFiles": string; + "listedEntries": string; "moreTools": string; "moreItems": string; "fallbackName": string; "questionCount_one": string; "questionCount_other": string; + "presentFiles": string; + "download": string; + "installSkill": string; + "installSkillTitle": string; + "installSkillDescription": string; + "skillInstalled": string; + "installSkillFailed": string; + "installSkillReplaceWarning": string; }; "retry": { "interrupted": string; @@ -5364,6 +5646,19 @@ export type I18nTranslations = { "groupByRowTip": string; }; }; + "baseNode": { + "info": { + "menu": string; + "createdBy": string; + "createdTime": string; + "lastModifiedBy": string; + "lastModifiedTime": string; + "folderId": string; + "tableId": string; + "automationId": string; + "appId": string; + }; + }; "plugin": { "recent": string; "more": string; diff --git a/apps/nestjs-backend/src/utils/major-field-keys-changed.spec.ts b/apps/nestjs-backend/src/utils/major-field-keys-changed.spec.ts index 149cd03715..874baa79fc 100644 --- a/apps/nestjs-backend/src/utils/major-field-keys-changed.spec.ts +++ b/apps/nestjs-backend/src/utils/major-field-keys-changed.spec.ts @@ -73,6 +73,28 @@ describe('majorFieldKeysChanged', () => { ).toBe(false); }); + it('should return false if only defaultValue has changed', () => { + const singleSelectField = { + type: FieldType.SingleSelect, + name: 'Status', + dbFieldName: 'status', + options: { + choices: [{ id: 'cho1', name: 'Todo', color: 'blue' }], + defaultValue: 'Todo', + }, + } as IFieldVo; + + expect( + majorFieldKeysChanged(singleSelectField, { + ...singleSelectField, + options: { + ...singleSelectField.options, + defaultValue: undefined, + }, + }) + ).toBe(false); + }); + it('should return true if major options like expression have changed', () => { expect( majorFieldKeysChanged(formulaField, { diff --git a/apps/nestjs-backend/src/utils/major-field-keys-changed.ts b/apps/nestjs-backend/src/utils/major-field-keys-changed.ts index 359a1aa8b5..151794f9f2 100644 --- a/apps/nestjs-backend/src/utils/major-field-keys-changed.ts +++ b/apps/nestjs-backend/src/utils/major-field-keys-changed.ts @@ -11,6 +11,7 @@ export const NON_INFECT_OPTION_KEYS = new Set([ 'filter', 'sort', 'limit', + 'defaultValue', ]); export const majorOptionsKeyChanged = ( diff --git a/apps/nestjs-backend/test/action-trigger-field-conversion.e2e-spec.ts b/apps/nestjs-backend/test/action-trigger-field-conversion.e2e-spec.ts new file mode 100644 index 0000000000..96f41d667f --- /dev/null +++ b/apps/nestjs-backend/test/action-trigger-field-conversion.e2e-spec.ts @@ -0,0 +1,76 @@ +import type { INestApplication } from '@nestjs/common'; +import { FieldType } from '@teable/core'; +import { axios } from '@teable/openapi'; +import { ShareDbService } from '../src/share-db/share-db.service'; +import { collectActionTriggers } from './utils/action-trigger'; +import { createField, createTable, initApp, permanentDeleteTable } from './utils/init-app'; + +describe('Action trigger field conversion presence (e2e)', () => { + let app: INestApplication; + let cookie: string; + let port: string; + let shareDbService: ShareDbService; + const tableIds = new Set(); + const baseId = globalThis.testConfig.baseId; + + beforeAll(async () => { + const appCtx = await initApp(); + app = appCtx.app; + cookie = appCtx.cookie; + port = process.env.PORT!; + shareDbService = app.get(ShareDbService); + }); + + afterAll(async () => { + for (const tableId of [...tableIds].reverse()) { + await permanentDeleteTable(baseId, tableId); + } + await app.close(); + }); + + it('emits schema-refresh setField when converting a text-valued formula field to text in v1', async () => { + const table = await createTable(baseId, { + name: 'action-trigger-formula-to-text-v1', + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + }); + tableIds.add(table.id); + + const formulaField = await createField(table.id, { + name: 'Formula Amount', + type: FieldType.Formula, + options: { + expression: "'ready'", + }, + }); + + const actions = await collectActionTriggers({ + shareDbService, + cookie, + port, + tableId: table.id, + act: async () => { + const response = await axios.put(`/table/${table.id}/field/${formulaField.id}/convert`, { + name: formulaField.name, + type: FieldType.SingleLineText, + }); + + expect(response.status).toBe(200); + expect(response.headers['x-teable-v2']).not.toBe('true'); + }, + }); + + expect(actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + actionKey: 'setField', + payload: expect.objectContaining({ + tableId: table.id, + field: expect.objectContaining({ + id: formulaField.id, + }), + }), + }), + ]) + ); + }); +}); diff --git a/apps/nestjs-backend/test/action-trigger-set-record.e2e-spec.ts b/apps/nestjs-backend/test/action-trigger-set-record.e2e-spec.ts new file mode 100644 index 0000000000..531186dc3e --- /dev/null +++ b/apps/nestjs-backend/test/action-trigger-set-record.e2e-spec.ts @@ -0,0 +1,161 @@ +import type { INestApplication } from '@nestjs/common'; +import { FieldKeyType, FieldType } from '@teable/core'; +import { axios, X_CANARY_HEADER } from '@teable/openapi'; +import { ShareDbService } from '../src/share-db/share-db.service'; +import type { IActionTrigger } from './utils/action-trigger'; +import { collectActionTriggers } from './utils/action-trigger'; +import { createRecords, createTable, initApp, permanentDeleteTable } from './utils/init-app'; + +const v2ResponseHeader = 'x-teable-v2'; + +// field-aware listeners (use-field-aware-table-listener) and the sharedb +// skipPoll contract rely on every setRecord presence event carrying the +// changed cell fieldIds, for both the v1 listener and the v2 projection +describe('Action trigger setRecord presence (e2e)', () => { + let app: INestApplication; + let cookie: string; + let port: string; + let shareDbService: ShareDbService; + const tableIds = new Set(); + const baseId = globalThis.testConfig.baseId; + + beforeAll(async () => { + const appCtx = await initApp(); + app = appCtx.app; + cookie = appCtx.cookie; + port = process.env.PORT!; + shareDbService = app.get(ShareDbService); + }); + + afterAll(async () => { + for (const tableId of [...tableIds].reverse()) { + await permanentDeleteTable(baseId, tableId); + } + await app.close(); + }); + + const setRecordFieldIds = (actions: ReadonlyArray): string[] => + actions + .filter( + (action) => action.actionKey === 'setRecord' && Array.isArray(action.payload?.fieldIds) + ) + .flatMap((action) => action.payload?.fieldIds as string[]); + + const prepareTable = async (name: string) => { + const table = await createTable(baseId, { + name, + fields: [ + { name: 'Title', type: FieldType.SingleLineText }, + { name: 'Note', type: FieldType.SingleLineText }, + ], + }); + tableIds.add(table.id); + + const titleFieldId = table.fields[0].id; + const noteFieldId = table.fields[1].id; + const { records } = await createRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + records: [{ fields: {} }, { fields: {} }], + }); + return { table, titleFieldId, noteFieldId, records }; + }; + + const updateSingleRecord = async (params: { + tableId: string; + recordId: string; + fieldId: string; + value: string; + useV2: boolean; + }) => { + const { tableId, recordId, fieldId, value, useV2 } = params; + const response = await axios.patch( + `/table/${tableId}/record/${recordId}`, + { record: { fields: { [fieldId]: value } }, fieldKeyType: FieldKeyType.Id }, + { headers: useV2 ? { [X_CANARY_HEADER]: 'true' } : {} } + ); + expect(response.status).toBe(200); + if (useV2) { + expect(response.headers[v2ResponseHeader]).toBe('true'); + } else { + expect(response.headers[v2ResponseHeader]).not.toBe('true'); + } + }; + + const updateRecordsBatch = async (params: { + tableId: string; + updates: { id: string; fields: Record }[]; + useV2: boolean; + }) => { + const { tableId, updates, useV2 } = params; + const response = await axios.patch( + `/table/${tableId}/record`, + { records: updates, fieldKeyType: FieldKeyType.Id }, + { headers: useV2 ? { [X_CANARY_HEADER]: 'true' } : {} } + ); + expect(response.status).toBe(200); + if (useV2) { + expect(response.headers[v2ResponseHeader]).toBe('true'); + } else { + expect(response.headers[v2ResponseHeader]).not.toBe('true'); + } + }; + + describe.each([ + { engine: 'v1', useV2: false }, + { engine: 'v2', useV2: true }, + ])('$engine engine', ({ engine, useV2 }) => { + it('emits setRecord with the changed fieldIds for a single record update', async () => { + const { table, titleFieldId, noteFieldId, records } = await prepareTable( + `set-record-presence-single-${engine}` + ); + + const actions = await collectActionTriggers({ + shareDbService, + cookie, + port, + tableId: table.id, + until: (received) => setRecordFieldIds(received).includes(noteFieldId), + act: () => + updateSingleRecord({ + tableId: table.id, + recordId: records[0].id, + fieldId: noteFieldId, + value: `${engine} single`, + useV2, + }), + }); + + expect(setRecordFieldIds(actions)).toEqual([noteFieldId]); + expect(setRecordFieldIds(actions)).not.toContain(titleFieldId); + }); + + it('emits setRecord with the union of changed fieldIds for a batch update', async () => { + const { table, titleFieldId, noteFieldId, records } = await prepareTable( + `set-record-presence-batch-${engine}` + ); + + const expected = new Set([titleFieldId, noteFieldId]); + const actions = await collectActionTriggers({ + shareDbService, + cookie, + port, + tableId: table.id, + until: (received) => { + const ids = new Set(setRecordFieldIds(received)); + return [...expected].every((id) => ids.has(id)); + }, + act: () => + updateRecordsBatch({ + tableId: table.id, + updates: [ + { id: records[0].id, fields: { [titleFieldId]: `${engine} batch title` } }, + { id: records[1].id, fields: { [noteFieldId]: `${engine} batch note` } }, + ], + useV2, + }), + }); + + expect(new Set(setRecordFieldIds(actions))).toEqual(expected); + }); + }); +}); diff --git a/apps/nestjs-backend/test/aggregation.e2e-spec.ts b/apps/nestjs-backend/test/aggregation.e2e-spec.ts index cd0378bfcc..3188c65bc9 100644 --- a/apps/nestjs-backend/test/aggregation.e2e-spec.ts +++ b/apps/nestjs-backend/test/aggregation.e2e-spec.ts @@ -1466,6 +1466,48 @@ describe('OpenAPI AggregationController (e2e)', () => { }); }); + describe('row count with search projection', () => { + let table: ITableFullVo; + + beforeAll(async () => { + table = await createTable(baseId, { + name: 'agg_row_count_projection', + fields: [ + { name: 'Title', type: FieldType.SingleLineText }, + { name: 'Note', type: FieldType.SingleLineText }, + ], + records: [ + { fields: { Title: 'apple', Note: 'banana' } }, + { fields: { Title: 'banana', Note: 'cherry' } }, + ], + }); + }); + + afterAll(async () => { + await permanentDeleteTable(baseId, table.id); + }); + + it('should exclude search hits on fields outside the projection', async () => { + const viewId = table.views[0].id; + const search: [string, string?, boolean?] = ['banana', '', true]; + + const { rowCount: withoutProjection } = (await getRowCount(table.id, { viewId, search })) + .data; + expect(withoutProjection).toEqual(2); + + // simulate a personal view that hides the Note field: only Title is searched + const { rowCount } = ( + await getRowCount(table.id, { + viewId, + ignoreViewQuery: true, + search, + projection: [table.fields[0].id], + }) + ).data; + expect(rowCount).toEqual(1); + }); + }); + describe('attachment total size aggregation with groupBy', () => { let tableId: string; let groupFieldId: string; @@ -1475,6 +1517,28 @@ describe('OpenAPI AggregationController (e2e)', () => { let recordB1Id: string; let file10Path: string; let file20Path: string; + const uploadAttachmentWithRetry = async (recordId: string, filePath: string) => { + const retryable404 = (error: unknown) => { + const err = error as { status?: number; code?: string; message?: string }; + return err.status === 404 || err.code === 'not_found' || err.message === 'Table not found'; + }; + + for (let attempt = 0; attempt < 5; attempt++) { + try { + return await uploadAttachment( + tableId, + recordId, + attachmentFieldId, + fs.createReadStream(filePath) + ); + } catch (error) { + if (attempt === 4 || !retryable404(error)) { + throw error; + } + await sleep(500); + } + } + }; beforeAll(async () => { file10Path = path.join(StorageAdapter.TEMPORARY_DIR, 'agg-10b.bin'); @@ -1517,24 +1581,9 @@ describe('OpenAPI AggregationController (e2e)', () => { recordA2Id = created.records[1].id; recordB1Id = created.records[2].id; - await uploadAttachment( - tableId, - recordA1Id, - attachmentFieldId, - fs.createReadStream(file10Path) - ); - await uploadAttachment( - tableId, - recordA2Id, - attachmentFieldId, - fs.createReadStream(file20Path) - ); - await uploadAttachment( - tableId, - recordB1Id, - attachmentFieldId, - fs.createReadStream(file20Path) - ); + await uploadAttachmentWithRetry(recordA1Id, file10Path); + await uploadAttachmentWithRetry(recordA2Id, file20Path); + await uploadAttachmentWithRetry(recordB1Id, file20Path); }); afterAll(async () => { diff --git a/apps/nestjs-backend/test/airtable-import.e2e-spec.ts b/apps/nestjs-backend/test/airtable-import.e2e-spec.ts new file mode 100644 index 0000000000..54de80667b --- /dev/null +++ b/apps/nestjs-backend/test/airtable-import.e2e-spec.ts @@ -0,0 +1,192 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { INestApplication } from '@nestjs/common'; +import { FieldKeyType, FieldType, Relationship } from '@teable/core'; +import { getFields, getTableList, IMPORT_AIRTABLE_STREAM } from '@teable/openapi'; +import { initApp, getRecords, permanentDeleteBase } from './utils/init-app'; + +/** + * Real-path import e2e: drives the importer end-to-end against a fixed Airtable + * base using a PAT (no OAuth — the RO's `accessToken` replaces the integration). + * + * Gated on env, so CI without credentials skips it: + * AIRTABLE_TEST_PAT=pat… a token that can read the test base + * AIRTABLE_TEST_BASE=app… base built by test-scripts/airtable-build-test-base.mjs + * + * Asserts the API-creatable surface of that base. Computed fields, single-link + * cardinality, and views are added manually in the UI; extend the assertions + * once they're present. + */ +const pat = process.env.AIRTABLE_TEST_PAT; +const testBase = process.env.AIRTABLE_TEST_BASE; + +interface ISseResult { + done: { type: 'done'; data: any } | null; + error: { type: 'error'; message: string } | null; +} + +function consumeSseLine(line: string, out: ISseResult): void { + if (!line.startsWith('data: ')) return; + const json = line.slice(6).trim(); + if (!json) return; + const event = JSON.parse(json); + if (event.type === 'done') out.done = event; + else if (event.type === 'error') out.error = event; +} + +async function importViaSse(appUrl: string, cookie: string, body: unknown): Promise { + const response = await fetch(`${appUrl}/api${IMPORT_AIRTABLE_STREAM}`, { + method: 'POST', + // eslint-disable-next-line @typescript-eslint/naming-convention + headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', Cookie: cookie }, + body: JSON.stringify(body), + }); + expect(response.ok).toBe(true); + + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + const out: ISseResult = { done: null, error: null }; + let buffer = ''; + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) consumeSseLine(line, out); + } + return out; +} + +// Skip unless a real Airtable PAT is set (all PATs start with "pat"), so an +// empty or placeholder secret skips the suite instead of failing CI. +describe.skipIf(!pat?.startsWith('pat') || !testBase)( + 'Airtable import (e2e, needs AIRTABLE_PAT + AIRTABLE_TEST_BASE)', + () => { + let app: INestApplication; + const spaceId = globalThis.testConfig.spaceId; + let result: any; + let tables: { id: string; name: string }[]; + let allFieldsId: string; + let linkedId: string; + let allFields: any[]; + + const byName = (fields: any[], name: string) => fields.find((f) => f.name === name); + + beforeAll(async () => { + const ctx = await initApp(); + app = ctx.app; + + const { done, error } = await importViaSse(ctx.appUrl, ctx.cookie, { + accessToken: pat, + airtableBaseId: testBase, + spaceId, + baseName: 'E2E Airtable Import', + importRecords: true, + importAttachments: true, + }); + expect(error).toBeNull(); + expect(done).toBeTruthy(); + result = done!.data; + + tables = (await getTableList(result.base.id)).data; + allFieldsId = tables.find((t) => t.name === 'All Fields')!.id; + linkedId = tables.find((t) => t.name === 'Linked')!.id; + allFields = (await getFields(allFieldsId)).data; + }, 180_000); + + afterAll(async () => { + if (result?.base?.id) await permanentDeleteBase(result.base.id).catch(() => undefined); + await app.close(); + }); + + it('creates the base in the target space with its three tables', () => { + expect(result.base.spaceId).toBe(spaceId); + expect(tables.map((t) => t.name).sort()).toEqual(['All Fields', 'Linked', 'Self']); + }); + + it('maps scalar field types (text/number/date/rating/checkbox/user/attachment)', () => { + expect(byName(allFields, 'Long text')?.type).toBe(FieldType.LongText); + expect(byName(allFields, 'Email')?.type).toBe(FieldType.SingleLineText); + expect(byName(allFields, 'Int')?.type).toBe(FieldType.Number); + expect(byName(allFields, 'Currency USD')?.type).toBe(FieldType.Number); + expect(byName(allFields, 'Rate star 5')?.type).toBe(FieldType.Rating); + expect(byName(allFields, 'Check green')?.type).toBe(FieldType.Checkbox); + expect(byName(allFields, 'Date iso')?.type).toBe(FieldType.Date); + expect(byName(allFields, 'DT utc 24')?.type).toBe(FieldType.Date); + expect(byName(allFields, 'Single collaborator')?.type).toBe(FieldType.User); + expect(byName(allFields, 'Attachments')?.type).toBe(FieldType.Attachment); + }); + + it('maps single/multi select with their choices', () => { + const ss = byName(allFields, 'Single select'); + expect(ss?.type).toBe(FieldType.SingleSelect); + expect(ss?.options?.choices?.length).toBe(5); + expect(byName(allFields, 'Multi select')?.type).toBe(FieldType.MultipleSelect); + }); + + it('degrades duration to a number and reports it as an issue', () => { + expect(byName(allFields, 'Dur h:mm')?.type).toBe(FieldType.Number); + expect(result.issues.some((i: any) => i.fieldName === 'Dur h:mm')).toBe(true); + }); + + it('creates link fields pointing at the right tables', () => { + const toB = byName(allFields, 'Link to B'); + expect(toB?.type).toBe(FieldType.Link); + expect(toB?.options?.foreignTableId).toBe(linkedId); + expect([Relationship.ManyMany, Relationship.ManyOne]).toContain(toB?.options?.relationship); + + const self = byName(allFields, 'Self link'); + expect(self?.type).toBe(FieldType.Link); + expect(self?.options?.foreignTableId).toBe(allFieldsId); + }); + + it('relaxes an over-capacity single link to many-to-many instead of truncating', () => { + // The API can only create multi links, so the kitchen-sink base has no + // single links — add a single-link field whose data holds several links + // in the Airtable UI to exercise this. Airtable's single-link is a soft + // per-cell limit, so its data can hold multiple; the importer must keep + // them all by relaxing the field to many-to-many, never truncating. + const relaxed = result.issues.filter((i: any) => i.toType === 'many-to-many link'); + for (const issue of relaxed) { + // a relaxed field must NOT also be reported as truncated (kept-first) + expect( + result.issues.some( + (i: any) => + i.code === 'valuesDropped' && + i.fieldName === issue.fieldName && + /single-link/.test(i.reason ?? '') + ) + ).toBe(false); + // and if it lives in All Fields, it must be a many-to-many link now + const field = byName(allFields, issue.fieldName); + if (field) expect(field.options?.relationship).toBe(Relationship.ManyMany); + } + }); + + it('keeps fields in the Airtable field order (primary first, links after scalars)', () => { + const names = allFields.map((f) => f.name); + const idx = (name: string) => names.indexOf(name); + const selectIdx = idx('Single select'); + expect(names[0]).toBe('Name'); // primary first + // scalar columns keep their source order + expect(idx('Long text')).toBeLessThan(idx('Barcode')); + expect(idx('Barcode')).toBeLessThan(idx('Int')); + expect(idx('Int')).toBeLessThan(selectIdx); + // links/derived fields land at their Airtable position, not dumped at the end + expect(selectIdx).toBeLessThan(idx('Link to B')); + }); + + it('imports the records of both tables in their Airtable order', async () => { + const nameId = byName(allFields, 'Name')?.id; + const af = (await getRecords(allFieldsId, { fieldKeyType: FieldKeyType.Id })).records; + expect(af.length).toBeGreaterThanOrEqual(19); + const linked = (await getRecords(linkedId, { fieldKeyType: FieldKeyType.Id })).records; + expect(linked.length).toBe(8); + // records keep the Airtable order — Teable's create would otherwise reverse them + const names = af.map((r: any) => r.fields[nameId!]); + expect(names.indexOf('Row 1')).toBeLessThan(names.indexOf('Row 2')); + expect(names.indexOf('Row 2')).toBeLessThan(names.indexOf('Row 3')); + }); + } +); diff --git a/apps/nestjs-backend/test/base-duplicate.e2e-spec.ts b/apps/nestjs-backend/test/base-duplicate.e2e-spec.ts index ebaf7841ab..0ca6d07174 100644 --- a/apps/nestjs-backend/test/base-duplicate.e2e-spec.ts +++ b/apps/nestjs-backend/test/base-duplicate.e2e-spec.ts @@ -35,6 +35,7 @@ import { getPluginPanel, getPluginPanelPlugin, getTableList, + getUserLastVisitListBase, getViewList, installPlugin, installPluginPanel, @@ -228,6 +229,24 @@ describe('OpenAPI Base Duplicate (e2e)', () => { await deleteBase(dupResult.data.id); }); + it('seeds last-visit so a freshly duplicated base tops the recent list', async () => { + const dupResult = await duplicateBase({ + fromBaseId: base.id, + spaceId, + name: 'last-visit seed copy', + }); + expect(dupResult.status).toBe(201); + duplicateBaseId = dupResult.data.id; + + // The recent-bases query INNER JOINs user_last_visit, so without the seed the + // new base is absent entirely (not merely sorted last) — looking like a failed copy. + const listRes = await getUserLastVisitListBase(); + const listedIds = listRes.data.list.map((item) => item.resource.id); + expect(listedIds).toContain(duplicateBaseId); + // Seeded with lastVisitTime = now, so it sorts to the front. + expect(listRes.data.list[0].resource.id).toBe(duplicateBaseId); + }); + it('duplicate with records', async () => { const table1 = await createTable(base.id, { name: 'table1' }); const preRecords = await getRecords(table1.id); @@ -912,6 +931,157 @@ describe('OpenAPI Base Duplicate (e2e)', () => { ); }); + 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: [] }); + const linkField = ( + await createField(sourceTable.id, { + name: 'Links', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: linkedTable.id, + }, + }) + ).data; + const symmetricField = ( + await getField( + linkedTable.id, + (linkField.options as ILinkFieldOptions).symmetricFieldId as string + ) + ).data; + const linkedRecords = await createRecords(linkedTable.id, { + fieldKeyType: FieldKeyType.Id, + records: [{ fields: {} }, { fields: {} }], + }); + await createRecords(sourceTable.id, { + fieldKeyType: FieldKeyType.Name, + records: [ + { fields: { [linkField.name]: [{ id: linkedRecords.records[0].id }] } }, + { fields: { [linkField.name]: [{ id: linkedRecords.records[1].id }] } }, + ], + }); + + const dupResult = await duplicateBase({ + fromBaseId: base.id, + spaceId, + name: 'v2 stream link copy', + withRecords: true, + }); + duplicateBaseId = dupResult.data.id; + + const duplicatedTables = await getTableList(duplicateBaseId).then((res) => res.data); + const duplicatedSourceTable = duplicatedTables.find(({ name }) => name === sourceTable.name)!; + const duplicatedLinkedTable = duplicatedTables.find(({ name }) => name === linkedTable.name)!; + const duplicatedSourceRecords = await getRecords(duplicatedSourceTable.id); + const duplicatedLinkedRecords = await getRecords(duplicatedLinkedTable.id); + + expect(duplicatedSourceRecords.records[0].fields[linkField.name]).toMatchObject([ + { id: duplicatedLinkedRecords.records[0].id }, + ]); + expect(duplicatedSourceRecords.records[1].fields[linkField.name]).toMatchObject([ + { id: duplicatedLinkedRecords.records[1].id }, + ]); + expect(duplicatedLinkedRecords.records[0].fields[symmetricField.name]).toMatchObject([ + { id: duplicatedSourceRecords.records[0].id }, + ]); + expect(duplicatedLinkedRecords.records[1].fields[symmetricField.name]).toMatchObject([ + { id: duplicatedSourceRecords.records[1].id }, + ]); + }); + + it('downgrades cross-space cross-base link records through v2 stream copy', async () => { + const externalBase = (await createBase({ spaceId, name: 'V2 External Base' })).data; + const destSpace = (await createSpace({ name: 'v2 duplicate dest space' })).data; + + try { + const externalTable = await createTable(externalBase.id, { + name: 'V2 Vendors', + records: [], + }); + const externalPrimaryField = externalTable.fields.find(({ isPrimary }) => isPrimary)!; + const vendorRecord = ( + await createRecords(externalTable.id, { + fieldKeyType: FieldKeyType.Id, + records: [{ fields: { [externalPrimaryField.id]: 'Vendor A' } }], + }) + ).records[0]; + + const sourceTable = await createTable(base.id, { name: 'V2 Orders', records: [] }); + const sourcePrimaryField = sourceTable.fields.find(({ isPrimary }) => isPrimary)!; + const vendorLinkField = ( + await createField(sourceTable.id, { + name: 'Vendor', + type: FieldType.Link, + options: { + baseId: externalBase.id, + relationship: Relationship.ManyMany, + foreignTableId: externalTable.id, + }, + }) + ).data; + const vendorLookupField = ( + await createField(sourceTable.id, { + name: 'Vendor Name', + type: FieldType.SingleLineText, + isLookup: true, + lookupOptions: { + foreignTableId: externalTable.id, + linkFieldId: vendorLinkField.id, + lookupFieldId: externalPrimaryField.id, + }, + }) + ).data; + + await createRecords(sourceTable.id, { + fieldKeyType: FieldKeyType.Id, + records: [ + { + fields: { + [sourcePrimaryField.id]: 'Order 1', + [vendorLinkField.id]: [{ id: vendorRecord.id }], + }, + }, + ], + }); + + const dupResult = await duplicateBase({ + fromBaseId: base.id, + spaceId: destSpace.id, + name: 'v2 cross base link downgrade', + withRecords: true, + }); + duplicateBaseId = dupResult.data.id; + + const duplicatedTables = await getTableList(duplicateBaseId).then((res) => res.data); + const duplicatedSourceTable = duplicatedTables.find( + ({ name }) => name === sourceTable.name + )!; + const duplicatedFields = (await getFields(duplicatedSourceTable.id)).data; + const duplicatedVendorLinkField = duplicatedFields.find( + ({ name }) => name === vendorLinkField.name + )!; + const duplicatedVendorLookupField = duplicatedFields.find( + ({ name }) => name === vendorLookupField.name + )!; + expect(duplicatedVendorLinkField.type).toBe(FieldType.SingleLineText); + expect(duplicatedVendorLookupField.type).toBe(FieldType.SingleLineText); + expect(duplicatedVendorLookupField.isLookup).toBeFalsy(); + + const duplicatedRecords = await getRecords(duplicatedSourceTable.id); + const duplicatedRow = duplicatedRecords.records[0]; + expect(duplicatedRow.fields[duplicatedVendorLinkField.name]).toBe('Vendor A'); + expect(duplicatedRow.fields[duplicatedVendorLookupField.name]).toContain('Vendor A'); + } finally { + await permanentDeleteBase(externalBase.id); + if (duplicateBaseId) { + await permanentDeleteBase(duplicateBaseId); + duplicateBaseId = undefined; + } + await permanentDeleteSpace(destSpace.id); + } + }); + it('duplicates formula, link, lookup, rollup, bidirectional link, and ai field config through v2', async () => { const peopleTable = await createTable(base.id, { name: 'People' }); const taskTable = await createTable(base.id, { name: 'Tasks' }); diff --git a/apps/nestjs-backend/test/base-node.e2e-spec.ts b/apps/nestjs-backend/test/base-node.e2e-spec.ts index 90c4d0040c..1d9f255bd9 100644 --- a/apps/nestjs-backend/test/base-node.e2e-spec.ts +++ b/apps/nestjs-backend/test/base-node.e2e-spec.ts @@ -1,10 +1,16 @@ /* eslint-disable sonarjs/no-duplicate-string */ import type { INestApplication } from '@nestjs/common'; import { FieldType, Relationship, Role, ViewType } from '@teable/core'; -import type { IBaseNodeTableResourceMeta, IBaseNodeVo } from '@teable/openapi'; +import type { + IBaseNodeResourceMeta, + IBaseNodeTableResourceMeta, + IBaseNodeVo, + ICreateBaseNodeRo, +} from '@teable/openapi'; import { axios, createBaseNode, + getBaseNodeList, getBaseNodeTree, getBaseNode, updateBaseNode, @@ -40,6 +46,42 @@ const updatedName = 'Updated Name'; const testTableName = 'Test Table'; const windowIdHeader = 'x-window-id'; +const nodeInfoCases: ICreateBaseNodeRo[] = [ + { + resourceType: BaseNodeResourceType.Folder, + name: 'Info Folder', + }, + { + resourceType: BaseNodeResourceType.Table, + name: 'Info Table', + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }, + { + resourceType: BaseNodeResourceType.Dashboard, + name: 'Info Dashboard', + }, +]; + +const expectResourceAuditInfo = ( + resourceMeta: IBaseNodeResourceMeta, + expected: { name?: string } +) => { + if (expected.name) { + expect(resourceMeta.name).toBe(expected.name); + } + expect(resourceMeta.createdByUser).toMatchObject({ + id: globalThis.testConfig.userId, + name: globalThis.testConfig.userName, + email: globalThis.testConfig.email, + }); + expect(Date.parse(resourceMeta.createdTime!)).not.toBeNaN(); + expect(resourceMeta).not.toHaveProperty('createdBy'); + expect(resourceMeta).not.toHaveProperty('lastModifiedBy'); + expect(resourceMeta).toHaveProperty('lastModifiedByUser'); + expect(resourceMeta).toHaveProperty('lastModifiedTime'); +}; + describe('BaseNodeController (e2e) /api/base/:baseId/node', () => { let app: INestApplication; let baseId: string; @@ -125,6 +167,49 @@ describe('BaseNodeController (e2e) /api/base/:baseId/node', () => { }); }); + describe('GET /api/base/:baseId/node - Get node resource audit info', () => { + const nodesToCleanup: string[] = []; + + afterEach(async () => { + for (const nodeId of [...nodesToCleanup].reverse()) { + await deleteBaseNode(baseId, nodeId); + } + nodesToCleanup.length = 0; + }); + + it.each(nodeInfoCases)( + 'should return $resourceType ($name) resource audit info in resourceMeta', + async (ro) => { + const node = await createBaseNode(baseId, ro).then((res) => res.data); + nodesToCleanup.push(node.id); + + const singleNode = await getBaseNode(baseId, node.id).then((res) => res.data); + const list = await getBaseNodeList(baseId).then((res) => res.data); + const listNode = list.find((item) => item.id === node.id); + const tree = await getBaseNodeTree(baseId).then((res) => res.data); + const treeNode = tree.nodes.find((item) => item.id === node.id); + + expect(singleNode.resourceType).toBe(ro.resourceType); + expect(singleNode.resourceId).toBe(node.resourceId); + expectResourceAuditInfo(singleNode.resourceMeta, { name: ro.name }); + expect(listNode).toBeTruthy(); + if (!listNode) { + return; + } + expect(listNode.resourceType).toBe(ro.resourceType); + expect(listNode.resourceId).toBe(node.resourceId); + expectResourceAuditInfo(listNode.resourceMeta, { name: ro.name }); + expect(treeNode).toBeTruthy(); + if (!treeNode) { + return; + } + expect(treeNode.resourceType).toBe(ro.resourceType); + expect(treeNode.resourceId).toBe(node.resourceId); + expectResourceAuditInfo(treeNode.resourceMeta, { name: ro.name }); + } + ); + }); + describe('POST /api/base/:baseId/node - Create node', () => { const nodesToCleanup: string[] = []; diff --git a/apps/nestjs-backend/test/base.e2e-spec.ts b/apps/nestjs-backend/test/base.e2e-spec.ts index 6045ad3e24..f70de4a4bc 100644 --- a/apps/nestjs-backend/test/base.e2e-spec.ts +++ b/apps/nestjs-backend/test/base.e2e-spec.ts @@ -507,12 +507,11 @@ describe('OpenAPI BaseController (e2e)', () => { expect(res.data.find((v) => v.id === baseId2)).toBeDefined(); }); - describe('Base owner display after member removal', () => { + describe('Base creator display after member removal', () => { const userAEmail = 'userA-t1606@example.com'; const userBEmail = 'userB-t1606@example.com'; let userARequest: AxiosInstance; let userBRequest: AxiosInstance; - let userAId: string; let userBId: string; let spaceId: string; let baseId: string; @@ -528,10 +527,6 @@ describe('OpenAPI BaseController (e2e)', () => { password: '12345678', }); - // Get user A's ID (space owner) - const userAInfo = await userARequest.get(USER_ME); - userAId = userAInfo.data.id; - // Get user B's ID const userBInfo = await userBRequest.get(USER_ME); userBId = userBInfo.data.id; @@ -562,7 +557,7 @@ describe('OpenAPI BaseController (e2e)', () => { await userARequest.delete(urlBuilder(DELETE_SPACE, { spaceId })); }); - it('should fallback to space owner when creator is removed from space', async () => { + it('should still show the creator after they are removed from the space', async () => { // Verify user B is the creator before removal (via getBaseAll) const beforeRemoval = await userARequest.get(GET_BASE_ALL); const baseBefore = beforeRemoval.data.find((b: { id: string }) => b.id === baseId); @@ -575,13 +570,14 @@ describe('OpenAPI BaseController (e2e)', () => { params: { principalId: userBId, principalType: PrincipalType.User }, }); - // Verify createdUser is now the space owner (user A) after removal + // The real creator (user B) is still resolvable, so createdUser stays user B + // even though they are no longer a space collaborator (T5261). The space-owner + // fallback only applies when the creator's user record cannot be resolved. const afterRemoval = await userARequest.get(GET_BASE_ALL); const baseAfter = afterRemoval.data.find((b: { id: string }) => b.id === baseId); expect(baseAfter).toBeDefined(); - // The createdUser should fallback to space owner (user A) since user B is no longer in the space expect(baseAfter.createdUser).toBeDefined(); - expect(baseAfter.createdUser.id).toBe(userAId); + expect(baseAfter.createdUser.id).toBe(userBId); }); }); 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 de51fd52c4..720fdd7181 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 @@ -1,4 +1,6 @@ /* eslint-disable sonarjs/no-duplicate-string */ +/* eslint-disable @typescript-eslint/naming-convention */ +import { spawnSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import type { INestApplication } from '@nestjs/common'; @@ -14,8 +16,10 @@ import { getGroupPoints, getImportStatus as apiGetImportStatus, getRecordHistory, + getTrashItems, getRowCount, getSignature as apiGetSignature, + getSpaceDataDbMigration, getTableList, getTableActivatedIndex, GroupPointType, @@ -25,6 +29,7 @@ import { notify as apiNotify, redo, ResourceType, + restoreTrash, SettingKey, SUPPORTEDTYPE, TableIndex, @@ -37,6 +42,7 @@ import { uploadFile as apiUploadFile, X_CANARY_HEADER, type ITableFullVo, + type IDataDbMigrationJobStatusVo, } from '@teable/openapi'; import Knex from 'knex'; import type { Knex as KnexType } from 'knex'; @@ -47,11 +53,14 @@ import { baseConfig } from '../src/configs/base.config'; import { EventEmitterService } from '../src/event-emitter/event-emitter.service'; import { Events } from '../src/event-emitter/events'; import StorageAdapter from '../src/features/attachments/plugins/adapter'; +import { BaseSqlExecutorService } from '../src/features/base-sql-executor/base-sql-executor.service'; import { X_TEABLE_V2_HEADER, X_TEABLE_V2_REASON_HEADER, } from '../src/features/canary/interceptors/v2-indicator.interceptor'; import { CsvImporter } from '../src/features/import/open-api/import.class'; +import { SpaceDataDbMigrationWorkerService } from '../src/features/space/space-data-db-migration-worker.service'; +import { SpaceDataDbMigrationService } from '../src/features/space/space-data-db-migration.service'; import { createAwaitWithEventWithResult } from './utils/event-promise'; import { createBase, @@ -84,12 +93,18 @@ const metaDatabaseUrl = process.env.PRISMA_META_DATABASE_URL ?? process.env.PRISMA_DATABASE_URL ?? process.env.DATABASE_URL; +const defaultDataDatabaseUrl = + process.env.PRISMA_DATABASE_URL ?? process.env.DATABASE_URL ?? metaDatabaseUrl; const byodbDataDatabaseUrl = process.env.BYODB_E2E_DATA_DATABASE_URL; const isIndependentByodbDataDb = databaseIdentity(metaDatabaseUrl) != null && databaseIdentity(byodbDataDatabaseUrl) != null && databaseIdentity(metaDatabaseUrl) !== databaseIdentity(byodbDataDatabaseUrl); const describeByodbStorage = isIndependentByodbDataDb ? describe : describe.skip; +const hasPostgresMigrationTools = ['pg_dump', 'pg_restore', 'psql'].every( + (command) => spawnSync('which', [command], { stdio: 'ignore' }).status === 0 +); +const itWithMigrationTools = hasPostgresMigrationTools ? it : it.skip; const dataPlaneSystemTables = [ '__teable_data_schema_migrations', @@ -100,6 +115,8 @@ const dataPlaneSystemTables = [ 'record_history', 'table_trash', 'record_trash', + 'attachments', + 'attachments_table', '__undo_log', ]; @@ -175,6 +192,29 @@ const tableExists = async (client: KnexType, dbTableName: string) => { return relationExists(client, schemaName, tableName); }; +const undoCaptureTriggerFunctionSchema = async (client: KnexType, dbTableName: string) => { + const { schemaName, tableName } = parseDbTableName(dbTableName); + const rows = await rawRows<{ function_schema: string | null }>( + client, + ` + SELECT pn.nspname AS function_schema + FROM pg_trigger tg + JOIN pg_class c ON c.oid = tg.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_proc p ON p.oid = tg.tgfoid + JOIN pg_namespace pn ON pn.oid = p.pronamespace + WHERE NOT tg.tgisinternal + AND tg.tgname = '__teable_undo_capture' + AND n.nspname = ? + AND c.relname = ? + LIMIT 1 + `, + [schemaName, tableName] + ); + + return rows[0]?.function_schema ?? null; +}; + const columnExists = async (client: KnexType, dbTableName: string, columnName: string) => { const { schemaName, tableName } = parseDbTableName(dbTableName); const rows = await rawRows<{ exists: boolean }>( @@ -332,6 +372,33 @@ const waitForImportCompleted = async (tableId: string, expectedSuccessCount: num throw new Error(`BYODB import timed out with latest status: ${data.status}`); }; +type IGetMigrationStatus = (spaceId: string, jobId: string) => Promise; + +const waitForMigrationSucceeded = async ( + spaceId: string, + jobId: string, + getStatus: IGetMigrationStatus = async (statusSpaceId, statusJobId) => + (await getSpaceDataDbMigration(statusSpaceId, statusJobId)).data +) => { + const maxRetries = 240; + let latest: IDataDbMigrationJobStatusVo | undefined; + + for (let i = 0; i < maxRetries; i++) { + latest = await getStatus(spaceId, jobId); + if (latest.state === 'succeeded') { + return latest; + } + if (['failed', 'canceled', 'rolled_back'].includes(latest.state)) { + throw new Error( + `BYODB migration ${jobId} ended as ${latest.state}: ${latest.lastError ?? 'unknown error'}` + ); + } + await sleep(500); + } + + throw new Error(`BYODB migration ${jobId} timed out at state: ${latest?.state ?? 'unknown'}`); +}; + const createPgClient = (url: string) => Knex({ client: 'pg', @@ -373,6 +440,18 @@ const uploadImportCsv = async () => { } }; +const expectRequestStatus = async (request: () => Promise, status: number) => { + try { + await request(); + throw new Error(`Expected request to fail with ${status}`); + } catch (error) { + const actualStatus = + (error as { status?: number; response?: { status?: number } }).status ?? + (error as { response?: { status?: number } }).response?.status; + expect(actualStatus).toBe(status); + } +}; + const safeDropSchema = async (client: KnexType | undefined, schemaName: string | undefined) => { if (!client || !schemaName) { return; @@ -386,22 +465,57 @@ const safeDropSchema = async (client: KnexType | undefined, schemaName: string | describeByodbStorage('BYODB space storage placement (e2e)', () => { let app: INestApplication; let metaDb: KnexType; + let defaultDataDb: KnexType; let dataDb: KnexType; let baseConfigService: IBaseConfig; + let baseSqlExecutorService: BaseSqlExecutorService; + let migrationService: SpaceDataDbMigrationService; + let migrationWorkerService: SpaceDataDbMigrationWorkerService; let recordHistoryDisabled: boolean | undefined; let spaceId: string | undefined; let baseId: string | undefined; const userId = globalThis.testConfig.userId; const internalSchema = `byodb_e2e_${Date.now().toString(36)}`; + const waitForTrashId = async (resourceId: string, resourceType: ResourceType) => { + const maxRetries = 60; + + for (let i = 0; i < maxRetries; i++) { + const trashRows = await rawRows<{ id: string }>( + metaDb, + ` + SELECT ${quoteIdent('id')} + FROM ${quoteIdent('trash')} + WHERE ${quoteIdent('resource_id')} = ? + AND ${quoteIdent('resource_type')} = ? + ORDER BY ${quoteIdent('deleted_time')} DESC + LIMIT 1 + `, + [resourceId, resourceType] + ); + const trashId = trashRows[0]?.id; + + if (trashId) { + return trashId; + } + + await sleep(100); + } + + throw new Error(`Timed out waiting for trash item ${resourceType}:${resourceId}`); + }; beforeAll(async () => { metaDb = createPgClient(metaDatabaseUrl!); + defaultDataDb = createPgClient(defaultDataDatabaseUrl!); dataDb = createPgClient(byodbDataDatabaseUrl!); const appCtx = await initApp(); app = appCtx.app; baseConfigService = app.get(baseConfig.KEY) as IBaseConfig; + baseSqlExecutorService = app.get(BaseSqlExecutorService); + migrationService = app.get(SpaceDataDbMigrationService); + migrationWorkerService = app.get(SpaceDataDbMigrationWorkerService); recordHistoryDisabled = baseConfigService.recordHistoryDisabled; baseConfigService.recordHistoryDisabled = false; ensureUndoRedoWindowIdHeader(`win_byodb_storage_${Date.now()}`); @@ -425,6 +539,7 @@ describeByodbStorage('BYODB space storage placement (e2e)', () => { } await dataDb?.destroy().catch(() => undefined); + await defaultDataDb?.destroy().catch(() => undefined); await metaDb?.destroy().catch(() => undefined); await app?.close(); }, 60_000); @@ -574,6 +689,12 @@ describeByodbStorage('BYODB space storage placement (e2e)', () => { expect(initialRowCount.data.rowCount).toBe(3); await expect(countDbTableRows(dataDb, mainTable.dbTableName)).resolves.toBe(3); await expect(countDbTableRows(metaDb, mainTable.dbTableName)).resolves.toBe(0); + const { schemaName, tableName } = parseDbTableName(mainTable.dbTableName); + const sqlQueryRows = await baseSqlExecutorService.executeQuerySql<{ count: number }[]>( + base.id, + `SELECT COUNT(*)::int AS count FROM ${quoteIdent(schemaName)}.${quoteIdent(tableName)}` + ); + expect(sqlQueryRows[0]?.count).toBe(3); const mainRecords = await createRecords(mainTable.id, { fieldKeyType: FieldKeyType.Id, @@ -743,6 +864,499 @@ describeByodbStorage('BYODB space storage placement (e2e)', () => { await assertComputedSideEffectsStayOutOfMetaDb(base.id, mainTable.id, recordId); }, 240_000); + itWithMigrationTools( + 'migrates an existing default space and routes post-switch writes to BYODB only', + async () => { + const migrationInternalSchema = `byodb_existing_migrate_${Date.now().toString(36)}`; + let migrationSpaceId: string | undefined; + let migrationBaseId: string | undefined; + let migratedTable: ITableFullVo | undefined; + let otherSpaceId: string | undefined; + let otherBaseId: string | undefined; + let otherTable: ITableFullVo | undefined; + + try { + const space = await createSpace({ name: 'BYODB existing-space migration e2e' }); + migrationSpaceId = space.id; + const base = await createBase({ + spaceId: space.id, + name: 'BYODB existing-space migration base', + }); + migrationBaseId = base.id; + migratedTable = await createTable(base.id, { + name: 'BYODB existing-space migration table', + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + records: [{ fields: { Name: 'Before migration' } }], + }); + const primaryFieldId = migratedTable.fields.find((field) => field.isPrimary)?.id; + const sourceRecordId = migratedTable.records[0]?.id; + expect(primaryFieldId).toBeTruthy(); + expect(sourceRecordId).toBeTruthy(); + + const otherSpace = await createSpace({ + name: 'BYODB other default-space during migration e2e', + }); + otherSpaceId = otherSpace.id; + const otherBase = await createBase({ + spaceId: otherSpace.id, + name: 'BYODB other default-space migration base', + }); + otherBaseId = otherBase.id; + otherTable = await createTable(otherBase.id, { + name: 'BYODB other default-space migration table', + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + records: [{ fields: { Name: 'Other before migration' } }], + }); + const otherPrimaryFieldId = otherTable.fields.find((field) => field.isPrimary)?.id; + expect(otherPrimaryFieldId).toBeTruthy(); + + await updateRecord(migratedTable.id, sourceRecordId!, { + fieldKeyType: FieldKeyType.Id, + record: { + fields: { + [primaryFieldId!]: 'Updated before migration', + }, + }, + }); + + await expect(tableExists(defaultDataDb, migratedTable.dbTableName)).resolves.toBe(true); + await expect(tableExists(dataDb, migratedTable.dbTableName)).resolves.toBe(false); + await expect( + waitForAtLeast( + () => + countRows( + defaultDataDb, + 'public', + 'record_history', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('record_id')} = ?`, + [migratedTable!.id, sourceRecordId] + ), + 1 + ) + ).resolves.toBeGreaterThan(0); + + const migration = await migrationService.startMigrationForSpace(space.id, userId, { + url: byodbDataDatabaseUrl!, + targetMode: 'migrate-space', + internalSchema: migrationInternalSchema, + confirmLargeMigration: true, + switchOnCompletion: true, + }); + const jobId = migration.jobId; + expect(jobId).toBeTruthy(); + + const otherDuringMigrationRecords = await createRecords(otherTable.id, { + fieldKeyType: FieldKeyType.Id, + records: [{ fields: { [otherPrimaryFieldId!]: 'Other during migration' } }], + }); + const otherDuringMigrationRecordId = otherDuringMigrationRecords.records[0].id; + await expect( + countDbTableRowsWhere( + defaultDataDb, + otherTable.dbTableName, + `${quoteIdent('__id')} = ?`, + [otherDuringMigrationRecordId] + ) + ).resolves.toBe(1); + await expect(tableExists(dataDb, otherTable.dbTableName)).resolves.toBe(false); + + const workerResult = await migrationWorkerService.runOnce(); + expect(workerResult?.jobId).toBe(jobId); + expect(workerResult?.status).toBe('succeeded'); + + const status = await waitForMigrationSucceeded( + space.id, + jobId, + (statusSpaceId, statusJobId) => + migrationService.getMigrationJobStatus(statusSpaceId, statusJobId) + ); + expect(status.targetInternalSchema).toBe(migrationInternalSchema); + await assertDataPlaneBaseline(migrationInternalSchema); + await expect(tableExists(defaultDataDb, migratedTable.dbTableName)).resolves.toBe(true); + await expect(tableExists(dataDb, migratedTable.dbTableName)).resolves.toBe(true); + await expect( + countDbTableRowsWhere(dataDb, migratedTable.dbTableName, `${quoteIdent('__id')} = ?`, [ + sourceRecordId, + ]) + ).resolves.toBe(1); + + const postSwitchRecords = await createRecords(migratedTable.id, { + fieldKeyType: FieldKeyType.Id, + records: [{ fields: { [primaryFieldId!]: 'After migration' } }], + }); + const postSwitchRecordId = postSwitchRecords.records[0].id; + await expect( + countDbTableRowsWhere(dataDb, migratedTable.dbTableName, `${quoteIdent('__id')} = ?`, [ + postSwitchRecordId, + ]) + ).resolves.toBe(1); + await expect( + countDbTableRowsWhere( + defaultDataDb, + migratedTable.dbTableName, + `${quoteIdent('__id')} = ?`, + [postSwitchRecordId] + ) + ).resolves.toBe(0); + + await updateRecord(migratedTable.id, postSwitchRecordId, { + fieldKeyType: FieldKeyType.Id, + record: { + fields: { + [primaryFieldId!]: 'Updated after migration', + }, + }, + }); + await expect( + waitForAtLeast( + () => + countRows( + dataDb, + migrationInternalSchema, + 'record_history', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('record_id')} = ?`, + [migratedTable!.id, postSwitchRecordId] + ), + 1 + ) + ).resolves.toBeGreaterThan(0); + await expect( + countRows( + defaultDataDb, + 'public', + 'record_history', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('record_id')} = ?`, + [migratedTable.id, postSwitchRecordId] + ) + ).resolves.toBe(0); + await expect( + undoCaptureTriggerFunctionSchema(dataDb, migratedTable.dbTableName) + ).resolves.toBe(migrationInternalSchema); + + await deleteRecord(migratedTable.id, postSwitchRecordId); + await expect( + waitForCount( + () => + countRows( + dataDb, + migrationInternalSchema, + 'record_trash', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('record_id')} = ?`, + [migratedTable!.id, postSwitchRecordId] + ), + 1 + ) + ).resolves.toBe(1); + await expect( + countRows( + defaultDataDb, + 'public', + 'record_trash', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('record_id')} = ?`, + [migratedTable.id, postSwitchRecordId] + ) + ).resolves.toBe(0); + + const undoResult = await undo(migratedTable.id); + expect(undoResult.data.status).toBe('fulfilled'); + await expect( + waitForCount( + () => + countRows( + dataDb, + migrationInternalSchema, + 'record_trash', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('record_id')} = ?`, + [migratedTable!.id, postSwitchRecordId] + ), + 0 + ) + ).resolves.toBe(0); + await expect( + countRows( + defaultDataDb, + 'public', + 'record_trash', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('record_id')} = ?`, + [migratedTable.id, postSwitchRecordId] + ) + ).resolves.toBe(0); + } finally { + if (migrationBaseId) { + await permanentDeleteBase(migrationBaseId).catch(() => undefined); + } + if (otherBaseId) { + await permanentDeleteBase(otherBaseId).catch(() => undefined); + } + if (migrationSpaceId) { + await permanentDeleteSpace(migrationSpaceId).catch(() => undefined); + } + if (otherSpaceId) { + await permanentDeleteSpace(otherSpaceId).catch(() => undefined); + } + await safeDropSchema(dataDb, migrationBaseId); + await safeDropSchema(defaultDataDb, migrationBaseId); + await safeDropSchema(metaDb, migrationBaseId); + await safeDropSchema(dataDb, otherBaseId); + await safeDropSchema(defaultDataDb, otherBaseId); + await safeDropSchema(metaDb, otherBaseId); + await safeDropSchema(dataDb, migrationInternalSchema); + await safeDropSchema(metaDb, migrationInternalSchema); + } + }, + 240_000 + ); + + it('rejects import trash and schema restore writes while a space migration is active', async () => { + const freezeInternalSchema = `byodb_freeze_${Date.now().toString(36)}`; + const migrationJobId = `sdmjfreeze${Date.now().toString(36)}`; + let freezeSpaceId: string | undefined; + let freezeBaseId: string | undefined; + let restoreTableId: string | undefined; + + try { + const freezeSpace = await createSpace({ name: 'BYODB migration freeze e2e' }); + freezeSpaceId = freezeSpace.id; + const freezeBase = await createBase({ + spaceId: freezeSpace.id, + name: 'BYODB migration freeze base', + }); + freezeBaseId = freezeBase.id; + const activeTable = await createTable(freezeBase.id, { + name: 'BYODB migration freeze active table', + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + records: [{ fields: { Name: 'Active row' } }], + }); + const restoreTable = await createTable(freezeBase.id, { + name: 'BYODB migration freeze restore table', + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + records: [{ fields: { Name: 'Restore row' } }], + }); + restoreTableId = restoreTable.id; + + const attachmentUrl = await uploadImportCsv(); + const { + data: { worksheets }, + } = await apiAnalyzeFile({ + attachmentUrl, + fileType: SUPPORTEDTYPE.CSV, + }); + const columns = worksheets[CsvImporter.DEFAULT_SHEETKEY].columns.map((column, index) => ({ + ...column, + sourceColumnIndex: index, + })); + + await deleteTable(freezeBase.id, restoreTable.id, 200); + const trashId = await waitForTrashId(restoreTable.id, ResourceType.Table); + + await metaDb('space_data_db_migration_job').insert({ + id: migrationJobId, + space_id: freezeSpace.id, + target_mode: 'migrate-space', + switch_on_completion: true, + state: 'copying', + target_url_fingerprint: `dbfp_${migrationJobId}`, + target_internal_schema: freezeInternalSchema, + inventory: { + baseIds: [freezeBase.id], + tableIds: [activeTable.id, restoreTable.id], + dbTableNames: [activeTable.dbTableName, restoreTable.dbTableName], + }, + started_at: new Date(), + created_by: userId, + }); + + await expectRequestStatus( + () => + apiImportTableFromFile(freezeBase.id, { + attachmentUrl, + fileType: SUPPORTEDTYPE.CSV, + worksheets: { + [CsvImporter.DEFAULT_SHEETKEY]: { + name: 'BYODB migration freeze imported table', + columns, + useFirstRowAsHeader: true, + importData: true, + }, + }, + tz: 'Asia/Shanghai', + }), + 409 + ); + await deleteTable(freezeBase.id, activeTable.id, 409); + await expectRequestStatus(() => restoreTrash(trashId!), 409); + await permanentDeleteTable(freezeBase.id, restoreTable.id, 409); + + await expect(tableExists(defaultDataDb, activeTable.dbTableName)).resolves.toBe(true); + await expect( + countRows(metaDb, 'public', 'trash', `${quoteIdent('id')} = ?`, [trashId]) + ).resolves.toBe(1); + } finally { + await metaDb('space_data_db_migration_job') + .where({ id: migrationJobId }) + .delete() + .catch(() => undefined); + if (freezeBaseId && restoreTableId) { + await permanentDeleteTable(freezeBaseId, restoreTableId).catch(() => undefined); + } + if (freezeBaseId) { + await permanentDeleteBase(freezeBaseId).catch(() => undefined); + } + if (freezeSpaceId) { + await permanentDeleteSpace(freezeSpaceId).catch(() => undefined); + } + await safeDropSchema(dataDb, freezeBaseId); + await safeDropSchema(defaultDataDb, freezeBaseId); + await safeDropSchema(metaDb, freezeBaseId); + await safeDropSchema(dataDb, freezeInternalSchema); + await safeDropSchema(metaDb, freezeInternalSchema); + } + }, 180_000); + + it('restores canary V2 record trash from a BYODB data DB', async () => { + const restoreInternalSchema = `byodb_restore_v2_${Date.now().toString(36)}`; + const previousEnableCanaryFeature = process.env.ENABLE_CANARY_FEATURE; + process.env.ENABLE_CANARY_FEATURE = 'true'; + let restoreSpaceId: string | undefined; + let restoreBaseId: string | undefined; + let restoreTableId: string | undefined; + + try { + const restoreSpace = await createSpace({ + name: 'BYODB V2 record trash restore e2e', + dataDb: { + mode: 'byodb', + url: byodbDataDatabaseUrl!, + targetMode: 'initialize-empty', + internalSchema: restoreInternalSchema, + }, + }); + restoreSpaceId = restoreSpace.id; + await updateSetting({ + [SettingKey.CANARY_CONFIG]: { + enabled: true, + spaceIds: [restoreSpace.id], + }, + }); + + const restoreBase = await createBase({ + spaceId: restoreSpace.id, + name: 'BYODB V2 record trash restore base', + }); + restoreBaseId = restoreBase.id; + const restoreTable = await createTable(restoreBase.id, { + name: 'BYODB V2 record trash restore table', + fields: [{ name: 'Name', type: FieldType.SingleLineText, isPrimary: true }], + records: [{ fields: { Name: 'Restore row' } }], + }); + restoreTableId = restoreTable.id; + const recordId = restoreTable.records[0]!.id; + + await deleteRecord(restoreTable.id, recordId); + await expect( + waitForCount( + () => + countRows( + dataDb, + restoreInternalSchema, + 'table_trash', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('resource_type')} = ?`, + [restoreTable.id, ResourceType.Record] + ), + 1 + ) + ).resolves.toBe(1); + await expect( + waitForCount( + () => + countRows( + dataDb, + restoreInternalSchema, + 'record_trash', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('record_id')} = ?`, + [restoreTable.id, recordId] + ), + 1 + ) + ).resolves.toBe(1); + await expect( + countRows( + metaDb, + 'public', + 'record_trash', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('record_id')} = ?`, + [restoreTable.id, recordId] + ) + ).resolves.toBe(0); + + const trash = await getTrashItems({ + resourceId: restoreTable.id, + resourceType: ResourceType.Table, + }); + const recordTrashItem = trash.data.trashItems.find( + (item) => + item.resourceType === ResourceType.Record && + 'resourceIds' in item && + item.resourceIds.includes(recordId) + ); + expect(recordTrashItem).toBeDefined(); + + const restored = await restoreTrash(recordTrashItem!.id, restoreTable.id); + expect(restored.status).toBe(201); + expect(restored.headers[X_TEABLE_V2_HEADER]).toBe('true'); + + await expect( + waitForCount( + () => + countRows( + dataDb, + restoreInternalSchema, + 'record_trash', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('record_id')} = ?`, + [restoreTable.id, recordId] + ), + 0 + ) + ).resolves.toBe(0); + await expect( + countDbTableRowsWhere(dataDb, restoreTable.dbTableName, `${quoteIdent('__id')} = ?`, [ + recordId, + ]) + ).resolves.toBe(1); + await expect( + countDbTableRowsWhere(metaDb, restoreTable.dbTableName, `${quoteIdent('__id')} = ?`, [ + recordId, + ]) + ).resolves.toBe(0); + } finally { + if (previousEnableCanaryFeature === undefined) { + delete process.env.ENABLE_CANARY_FEATURE; + } else { + process.env.ENABLE_CANARY_FEATURE = previousEnableCanaryFeature; + } + await updateSetting({ + [SettingKey.CANARY_CONFIG]: { + enabled: false, + spaceIds: [], + }, + }).catch(() => undefined); + if (restoreBaseId && restoreTableId) { + await permanentDeleteTable(restoreBaseId, restoreTableId).catch(() => undefined); + } + if (restoreBaseId) { + await permanentDeleteBase(restoreBaseId).catch(() => undefined); + } + if (restoreSpaceId) { + await permanentDeleteSpace(restoreSpaceId).catch(() => undefined); + } + await safeDropSchema(dataDb, restoreBaseId); + await safeDropSchema(defaultDataDb, restoreBaseId); + await safeDropSchema(metaDb, restoreBaseId); + await safeDropSchema(dataDb, restoreInternalSchema); + await safeDropSchema(metaDb, restoreInternalSchema); + } + }); + const assertMetaPlaneRows = async ( targetSpaceId: string, targetBaseId: string, @@ -1134,7 +1748,10 @@ describeByodbStorage('BYODB space storage placement (e2e)', () => { { name: 'order_count', type: FieldType.Number }, ]); - await waitForImportCompleted(importedTable.id, 2); + const isV2Import = importResult.headers[X_TEABLE_V2_HEADER] === 'true'; + if (!isV2Import) { + await waitForImportCompleted(importedTable.id, 2); + } const importedRecords = await getRecords(importedTable.id, { fieldKeyType: FieldKeyType.Name, @@ -1180,6 +1797,7 @@ describeByodbStorage('BYODB space storage placement (e2e)', () => { await expect(countDbTableRows(dataDb, importedTable.dbTableName)).resolves.toBe(2); await expect(countDbTableRows(metaDb, importedTable.dbTableName)).resolves.toBe(0); + const expectedSchemaOperationType = isV2Import ? 'table.import' : 'table.create'; await expect( countRows( metaDb, @@ -1188,7 +1806,7 @@ describeByodbStorage('BYODB space storage placement (e2e)', () => { `${quoteIdent('base_id')} = ? AND ${quoteIdent('table_id')} = ? AND ${quoteIdent( 'type' )} = ? AND ${quoteIdent('status')} = ?`, - [targetBaseId, importedTable.id, 'table.create', 'ready'] + [targetBaseId, importedTable.id, expectedSchemaOperationType, 'ready'] ) ).resolves.toBe(1); await expect( diff --git a/apps/nestjs-backend/test/cls-attribution-crossing.e2e-spec.ts b/apps/nestjs-backend/test/cls-attribution-crossing.e2e-spec.ts new file mode 100644 index 0000000000..f1e35fc447 --- /dev/null +++ b/apps/nestjs-backend/test/cls-attribution-crossing.e2e-spec.ts @@ -0,0 +1,132 @@ +import type { INestApplication } from '@nestjs/common'; +import { FieldKeyType, FieldType, Role } from '@teable/core'; +import { + deleteSpaceCollaborator, + emailSpaceInvitation, + getRecordHistory, + PrincipalType, + UPDATE_RECORD, + urlBuilder, + USER_ME, +} from '@teable/openapi'; +import type { IUserMeVo } from '@teable/openapi'; +import type { AxiosInstance } from 'axios'; +import type { IBaseConfig } from '../src/configs/base.config'; +import { baseConfig } from '../src/configs/base.config'; +import { createNewUserAxios } from './utils/axios-instance/new-user'; +import { + createField, + createRecords, + createTable, + initApp, + permanentDeleteTable, + updateRecord, +} from './utils/init-app'; + +/** + * Regression for T4966: v2 record-history projections must attribute history to the + * user who actually made the change, not to whoever happened to trigger the async + * drain. v2 record events are drained outside the originating request (setImmediate + * on a base-shared queue), so a concurrent request's drain ran in an unrelated user's + * CLS context and stamped `record_history.created_by` with the wrong user. The fix + * reads `context.actorId` (snapshotted per event at publish time) instead of CLS at + * drain time. + */ +describe('Record history actor attribution under concurrency (e2e)', () => { + let app: INestApplication; + let secondUserAxios: AxiosInstance; + let secondUser: IUserMeVo; + + const baseId = globalThis.testConfig.baseId; + const spaceId = globalThis.testConfig.spaceId; + const firstUserId = globalThis.testConfig.userId; + const parallelWriters = 8; + + beforeAll(async () => { + const appCtx = await initApp(); + app = appCtx.app; + + const baseConfigService = app.get(baseConfig.KEY) as IBaseConfig; + baseConfigService.recordHistoryDisabled = false; + + const secondUserEmail = `cls-attribution-${Date.now()}@example.com`; + secondUserAxios = await createNewUserAxios({ email: secondUserEmail, password: '12345678' }); + secondUser = (await secondUserAxios.get(USER_ME)).data; + await emailSpaceInvitation({ + spaceId, + emailSpaceInvitationRo: { role: Role.Editor, emails: [secondUserEmail] }, + }); + }); + + afterAll(async () => { + await deleteSpaceCollaborator({ + spaceId, + deleteSpaceCollaboratorRo: { + principalId: secondUser.id, + principalType: PrincipalType.User, + }, + }).catch(() => undefined); + await app.close(); + }); + + const waitForHistoryCreatedBy = async ( + tableId: string, + recordId: string + ): Promise => { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + const { data } = await getRecordHistory(tableId, recordId, {}); + if (data.historyList.length > 0) { + return data.historyList[0].createdBy; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return null; + }; + + it('attributes each record history to its acting user, not the drain-triggering user', async () => { + const table = await createTable(baseId, { name: 'cls attribution crossing' }); + try { + const noteField = await createField(table.id, { type: FieldType.SingleLineText }); + + const { records } = await createRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + records: Array.from({ length: parallelWriters * 2 }, () => ({ fields: {} })), + }); + + // Alternate ownership so the two users' RecordUpdated events interleave inside + // the same shared async drain batch. + const firstUserRecordIds = records.filter((_, i) => i % 2 === 0).map((r) => r.id); + const secondUserRecordIds = records.filter((_, i) => i % 2 === 1).map((r) => r.id); + + // Fire all updates concurrently: first user via the global owner session, + // second user via their own session. Distinct values guarantee a real change. + await Promise.all([ + ...firstUserRecordIds.map((recordId, i) => + updateRecord(table.id, recordId, { + record: { fields: { [noteField.id]: `first-${i}` } }, + fieldKeyType: FieldKeyType.Id, + }) + ), + ...secondUserRecordIds.map((recordId, i) => + secondUserAxios.patch(urlBuilder(UPDATE_RECORD, { tableId: table.id, recordId }), { + record: { fields: { [noteField.id]: `second-${i}` } }, + fieldKeyType: FieldKeyType.Id, + }) + ), + ]); + + const firstUserAttribution = await Promise.all( + firstUserRecordIds.map((id) => waitForHistoryCreatedBy(table.id, id)) + ); + const secondUserAttribution = await Promise.all( + secondUserRecordIds.map((id) => waitForHistoryCreatedBy(table.id, id)) + ); + + expect(firstUserAttribution).toEqual(firstUserRecordIds.map(() => firstUserId)); + expect(secondUserAttribution).toEqual(secondUserRecordIds.map(() => secondUser.id)); + } finally { + await permanentDeleteTable(baseId, table.id); + } + }); +}); diff --git a/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts b/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts index 5d15663950..fbe624e122 100644 --- a/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts +++ b/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts @@ -4,6 +4,7 @@ import { FieldKeyType, FieldType, Relationship, Role } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import { deleteSpaceCollaborator, + deleteBaseCollaborator, emailSpaceInvitation, getRecord, getRecords, @@ -30,8 +31,10 @@ import { createField, createRecords, createTable, + convertField, deleteBase, deleteField, + getField, initApp, permanentDeleteTable, } from './utils/init-app'; @@ -39,6 +42,7 @@ import { describe('Computed user field (e2e)', () => { let app: INestApplication; let v2ContainerService: V2ContainerService; + let eventEmitterService: EventEmitterService; let prisma: PrismaService; const spaceId = globalThis.testConfig.spaceId; const userName = globalThis.testConfig.userName; @@ -49,6 +53,7 @@ describe('Computed user field (e2e)', () => { const appCtx = await initApp(); app = appCtx.app; v2ContainerService = app.get(V2ContainerService); + eventEmitterService = app.get(EventEmitterService); prisma = app.get(PrismaService); const base = await createBase({ name: 'base1', spaceId }); baseId = base.id; @@ -326,14 +331,7 @@ describe('Computed user field (e2e)', () => { }); let record = await getRecord(table1.id, recordId, { fieldKeyType: FieldKeyType.Id }); - if (isV2Mode) { - expect(record.data.fields[lastModifiedByField.id]).toMatchObject({ - id: globalThis.testConfig.userId, - title: globalThis.testConfig.userName, - }); - } else { - expect(record.data.fields[lastModifiedByField.id]).toBeUndefined(); - } + expect(record.data.fields[lastModifiedByField.id]).toBeUndefined(); await updateRecord(table1.id, recordId, { record: { @@ -380,14 +378,7 @@ describe('Computed user field (e2e)', () => { }); let record = await getRecord(table1.id, recordId, { fieldKeyType: FieldKeyType.Id }); - if (isV2Mode) { - expect(record.data.fields[lastModifiedByField.id]).toMatchObject({ - id: globalThis.testConfig.userId, - title: globalThis.testConfig.userName, - }); - } else { - expect(record.data.fields[lastModifiedByField.id]).toBeUndefined(); - } + expect(record.data.fields[lastModifiedByField.id]).toBeUndefined(); await deleteField(table1.id, textField.id); @@ -446,6 +437,314 @@ describe('Computed user field (e2e)', () => { ]); expect(updatedRecord.data.fields[formulaField.id]).toContain(globalThis.testConfig.userName); }); + + it('should refresh linked lookup user field after source multi-user field changes', async () => { + const hostTable = await createTable(baseId, { name: 'lookup-user-host' }); + const secondaryUserEmail = `lookup-refresh-user-${Date.now()}@example.com`; + const secondaryUserRequest = await createNewUserAxios({ + email: secondaryUserEmail, + password: '12345678', + }); + const secondaryUser = (await secondaryUserRequest.get(USER_ME)).data; + await emailBaseInvitation({ + baseId, + emailBaseInvitationRo: { role: Role.Creator, emails: [secondaryUserEmail] }, + }); + + try { + const sourceUserField = await createField(table1.id, { + name: 'Members', + type: FieldType.User, + options: { + isMultiple: true, + shouldNotify: false, + }, + }); + + const linkField = await createField(hostTable.id, { + name: 'Source', + type: FieldType.Link, + options: { + relationship: Relationship.ManyOne, + foreignTableId: table1.id, + lookupFieldId: table1.fields[0].id, + } as ILinkFieldOptionsRo, + }); + + const lookupField = await createField(hostTable.id, { + name: 'Lookup Members', + type: FieldType.User, + isLookup: true, + lookupOptions: { + linkFieldId: linkField.id, + foreignTableId: table1.id, + lookupFieldId: sourceUserField.id, + } as ILookupOptionsRo, + }); + + const sourceRecordId = table1.records[0].id; + const hostRecordId = hostTable.records[0].id; + + await updateRecord(table1.id, sourceRecordId, { + record: { + fields: { + [sourceUserField.id]: [globalThis.testConfig.userId], + }, + }, + fieldKeyType: FieldKeyType.Id, + typecast: true, + }); + await updateRecord(hostTable.id, hostRecordId, { + record: { + fields: { + [linkField.id]: { id: sourceRecordId }, + }, + }, + fieldKeyType: FieldKeyType.Id, + }); + await processV2Outbox(); + + const initialHostRecord = await getRecord(hostTable.id, hostRecordId, { + fieldKeyType: FieldKeyType.Id, + }); + expect(initialHostRecord.data.fields[lookupField.id]).toEqual([ + expect.objectContaining({ + id: globalThis.testConfig.userId, + title: globalThis.testConfig.userName, + }), + ]); + + await updateRecord(table1.id, sourceRecordId, { + record: { + fields: { + [sourceUserField.id]: [globalThis.testConfig.userId, secondaryUser.id], + }, + }, + fieldKeyType: FieldKeyType.Id, + typecast: true, + }); + await processV2Outbox(); + + const refreshedHostRecord = await getRecord(hostTable.id, hostRecordId, { + fieldKeyType: FieldKeyType.Id, + }); + expect(refreshedHostRecord.data.fields[lookupField.id]).toEqual([ + expect.objectContaining({ + id: globalThis.testConfig.userId, + title: globalThis.testConfig.userName, + }), + expect.objectContaining({ id: secondaryUser.id, title: secondaryUser.name }), + ]); + } finally { + await deleteTable(baseId, hostTable.id); + await deleteBaseCollaborator({ + baseId, + deleteBaseCollaboratorRo: { + principalId: secondaryUser.id, + principalType: PrincipalType.User, + }, + }); + } + }); + + it('should emit a legacy host lookup update event when linked multi-user source changes', async () => { + const v1Base = await createBase({ name: 'lookup-user-v1-base', spaceId }); + await prisma.base.update({ where: { id: v1Base.id }, data: { v2Enabled: false } }); + const sourceTable = await createTable(v1Base.id, { name: 'lookup-user-source-v1' }); + const hostTable = await createTable(v1Base.id, { name: 'lookup-user-host-v1' }); + const secondaryUserEmail = `lookup-refresh-v1-user-${Date.now()}@example.com`; + const secondaryUserRequest = await createNewUserAxios({ + email: secondaryUserEmail, + password: '12345678', + }); + const secondaryUser = (await secondaryUserRequest.get(USER_ME)).data; + await emailBaseInvitation({ + baseId: v1Base.id, + emailBaseInvitationRo: { role: Role.Creator, emails: [secondaryUserEmail] }, + }); + + try { + const sourceUserField = await createField(sourceTable.id, { + name: 'Members', + type: FieldType.User, + options: { + isMultiple: true, + shouldNotify: false, + }, + }); + + const linkField = await createField(hostTable.id, { + name: 'Source', + type: FieldType.Link, + options: { + relationship: Relationship.ManyOne, + foreignTableId: sourceTable.id, + lookupFieldId: sourceTable.fields[0].id, + } as ILinkFieldOptionsRo, + }); + + const lookupField = await createField(hostTable.id, { + name: 'Lookup Members', + type: FieldType.User, + isLookup: true, + lookupOptions: { + linkFieldId: linkField.id, + foreignTableId: sourceTable.id, + lookupFieldId: sourceUserField.id, + } as ILookupOptionsRo, + }); + + const sourceRecordId = sourceTable.records[0].id; + const hostRecordId = hostTable.records[0].id; + + await updateRecord(sourceTable.id, sourceRecordId, { + record: { + fields: { + [sourceUserField.id]: [globalThis.testConfig.userId], + }, + }, + fieldKeyType: FieldKeyType.Id, + typecast: true, + }); + await updateRecord(hostTable.id, hostRecordId, { + record: { + fields: { + [linkField.id]: { id: sourceRecordId }, + }, + }, + fieldKeyType: FieldKeyType.Id, + }); + + const events: unknown[] = []; + const handler = (event: unknown) => events.push(event); + eventEmitterService.eventEmitter.on(Events.TABLE_RECORD_UPDATE, handler); + try { + await updateRecord(sourceTable.id, sourceRecordId, { + record: { + fields: { + [sourceUserField.id]: [globalThis.testConfig.userId, secondaryUser.id], + }, + }, + fieldKeyType: FieldKeyType.Id, + typecast: true, + }); + + const deadline = Date.now() + 2000; + while ( + Date.now() < deadline && + !events.some( + (event) => + (event as { payload?: { tableId?: string } }).payload?.tableId === hostTable.id + ) + ) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } finally { + eventEmitterService.eventEmitter.off(Events.TABLE_RECORD_UPDATE, handler); + } + + const refreshedHostRecord = await getRecord(hostTable.id, hostRecordId, { + fieldKeyType: FieldKeyType.Id, + }); + expect(refreshedHostRecord.data.fields[lookupField.id]).toEqual([ + expect.objectContaining({ + id: globalThis.testConfig.userId, + title: globalThis.testConfig.userName, + }), + expect.objectContaining({ id: secondaryUser.id, title: secondaryUser.name }), + ]); + + const hostEvent = events.find( + (event) => (event as { payload?: { tableId?: string } }).payload?.tableId === hostTable.id + ) as { payload?: { record?: { fields?: Record } } }; + expect(hostEvent?.payload?.record?.fields?.[lookupField.id]?.newValue).toEqual([ + expect.objectContaining({ + id: globalThis.testConfig.userId, + title: globalThis.testConfig.userName, + }), + expect.objectContaining({ id: secondaryUser.id, title: secondaryUser.name }), + ]); + } finally { + await deleteTable(v1Base.id, hostTable.id); + await deleteTable(v1Base.id, sourceTable.id); + await deleteBaseCollaborator({ + baseId: v1Base.id, + deleteBaseCollaboratorRo: { + principalId: secondaryUser.id, + principalType: PrincipalType.User, + }, + }); + await deleteBase(v1Base.id); + } + }); + + it('should refresh dependent formula after converting a user field to multiple users', async () => { + if (!isV2Mode) { + return; + } + + const userField = await createField(table1.id, { + name: 'single-user-source', + type: FieldType.User, + options: { + isMultiple: false, + shouldNotify: false, + }, + }); + + const formulaField = await createField(table1.id, { + name: 'user-formula', + type: FieldType.Formula, + options: { + expression: `{${userField.id}}`, + }, + }); + + expect(formulaField.isMultipleCellValue).not.toBe(true); + + const recordId = table1.records[0].id; + await updateRecord(table1.id, recordId, { + record: { + fields: { + [userField.id]: globalThis.testConfig.userId, + }, + }, + fieldKeyType: FieldKeyType.Id, + typecast: true, + }); + await processV2Outbox(); + + const singleUserRecord = await getRecord(table1.id, recordId, { + fieldKeyType: FieldKeyType.Id, + }); + expect(singleUserRecord.data.fields[userField.id]).toMatchObject({ + title: globalThis.testConfig.userName, + }); + expect(singleUserRecord.data.fields[formulaField.id]).toBe(globalThis.testConfig.userName); + + await convertField(table1.id, userField.id, { + name: userField.name, + type: FieldType.User, + options: { + isMultiple: true, + shouldNotify: false, + }, + }); + await processV2Outbox(); + + const refreshedFormulaField = await getField(table1.id, formulaField.id); + expect(refreshedFormulaField.isMultipleCellValue).toBe(true); + + const multipleUserRecord = await getRecord(table1.id, recordId, { + fieldKeyType: FieldKeyType.Id, + }); + expect(multipleUserRecord.data.fields[userField.id]).toEqual([ + expect.objectContaining({ title: globalThis.testConfig.userName }), + ]); + expect(multipleUserRecord.data.fields[formulaField.id]).toEqual([ + globalThis.testConfig.userName, + ]); + }); }); describe('rename', () => { diff --git a/apps/nestjs-backend/test/delete-field.e2e-spec.ts b/apps/nestjs-backend/test/delete-field.e2e-spec.ts index 47d6633fd3..da3f58b95c 100644 --- a/apps/nestjs-backend/test/delete-field.e2e-spec.ts +++ b/apps/nestjs-backend/test/delete-field.e2e-spec.ts @@ -6,9 +6,12 @@ import type { INestApplication } from '@nestjs/common'; import { FieldKeyType, FieldType } from '@teable/core'; -import { PrismaService } from '@teable/db-main-prisma'; +import { DataPrismaService } from '@teable/db-data-prisma'; +import { PrismaService, ProvisionState } from '@teable/db-main-prisma'; import type { ITableFullVo } from '@teable/openapi'; import { convertField } from '@teable/openapi'; +import { type ITableSchemaRepository, v2CoreTokens } from '@teable/v2-core'; +import { V2ContainerService } from '../src/features/v2/v2-container.service'; import { createField, createTable, @@ -18,15 +21,34 @@ import { permanentDeleteTable, } from './utils/init-app'; +const isForceV2 = process.env.FORCE_V2_ALL === 'true'; +const describeV2 = isForceV2 ? describe : describe.skip; + +const quoteIdent = (identifier: string) => `"${identifier.replace(/"/g, '""')}"`; + +const parseDbTableName = (dbTableName: string) => { + const [schemaName, tableName] = dbTableName.split('.'); + + if (!schemaName || !tableName) { + throw new Error(`Invalid dbTableName: ${dbTableName}`); + } + + return { schemaName, tableName }; +}; + describe('OpenAPI delete field (e2e)', () => { let app: INestApplication; const baseId = globalThis.testConfig.baseId; let prisma: PrismaService; + let dataPrisma: DataPrismaService; + let v2ContainerService: V2ContainerService; beforeAll(async () => { const appCtx = await initApp(); app = appCtx.app; prisma = app.get(PrismaService); + dataPrisma = app.get(DataPrismaService); + v2ContainerService = app.get(V2ContainerService); }); afterAll(async () => { @@ -124,6 +146,87 @@ describe('OpenAPI delete field (e2e)', () => { }); }); + describeV2('V2 delete field schema transition', () => { + let table: ITableFullVo; + + beforeEach(async () => { + table = await createTable(baseId, { + name: 'Delete Field Pending Schema Test Table', + fields: [ + { + name: 'Primary Field', + type: FieldType.SingleLineText, + }, + { + name: 'Column To Delete', + type: FieldType.SingleLineText, + }, + ], + records: [ + { + fields: { + 'Primary Field': 'Record 1', + 'Column To Delete': 'value 1', + }, + }, + ], + }); + }); + + afterEach(async () => { + if (table?.id) { + await permanentDeleteTable(baseId, table.id); + } + }); + + it('should hide the table from reads before dropping a physical column', async () => { + const field = table.fields.find((f) => f.name === 'Column To Delete')!; + const dbFieldName = field.dbFieldName!; + const container = await v2ContainerService.getContainerForTable(table.id); + const tableSchemaRepository = container.resolve( + v2CoreTokens.tableSchemaRepository + ); + const originalUpdate = tableSchemaRepository.update.bind(tableSchemaRepository); + const { schemaName, tableName } = parseDbTableName(table.dbTableName); + let observedProvisionState: ProvisionState | undefined; + let readDuringSchemaUpdateError: unknown; + + tableSchemaRepository.update = (async (...args: Parameters) => { + await dataPrisma.$executeRawUnsafe( + `ALTER TABLE ${quoteIdent(schemaName)}.${quoteIdent(tableName)} DROP COLUMN IF EXISTS ${quoteIdent(dbFieldName)} CASCADE` + ); + const tableRaw = await prisma.tableMeta.findUniqueOrThrow({ + where: { id: table.id }, + select: { provisionState: true }, + }); + observedProvisionState = tableRaw.provisionState; + + try { + await getRecords(table.id, { fieldKeyType: FieldKeyType.Id }); + } catch (error) { + readDuringSchemaUpdateError = error; + } + + return originalUpdate(...args); + }) as typeof tableSchemaRepository.update; + + try { + await deleteField(table.id, field.id); + } finally { + tableSchemaRepository.update = originalUpdate; + } + + const readErrorMessage = JSON.stringify(readDuringSchemaUpdateError); + expect(observedProvisionState).toBe(ProvisionState.pending); + expect(readDuringSchemaUpdateError).toMatchObject({ status: 404 }); + expect(readErrorMessage).not.toContain(dbFieldName); + + const records = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id }); + expect(records.records).toHaveLength(1); + expect(records.records[0].fields[field.id]).toBeUndefined(); + }); + }); + describe('delete field with formula dependencies', () => { let table: ITableFullVo; diff --git a/apps/nestjs-backend/test/field-converting.e2e-spec.ts b/apps/nestjs-backend/test/field-converting.e2e-spec.ts index 7916738bba..a6abdf03fa 100644 --- a/apps/nestjs-backend/test/field-converting.e2e-spec.ts +++ b/apps/nestjs-backend/test/field-converting.e2e-spec.ts @@ -47,9 +47,12 @@ import { X_CANARY_HEADER, } from '@teable/openapi'; import type { Knex } from 'knex'; +import type { MockInstance } from 'vitest'; +import { vi } from 'vitest'; import { DB_PROVIDER_SYMBOL } from '../src/db-provider/db.provider'; import type { IDbProvider } from '../src/db-provider/db.provider.interface'; import { FieldService } from '../src/features/field/field.service'; +import { ComputedOrchestratorService } from '../src/features/record/computed/services/computed-orchestrator.service'; import { createNewUserAxios } from './utils/axios-instance/new-user'; import { getRecords, @@ -78,6 +81,7 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => { let prisma: PrismaService; let fieldService: FieldService; let knex: Knex; + let computedOrchestrator: ComputedOrchestratorService; beforeAll(async () => { const appCtx = await initApp(); @@ -86,6 +90,7 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => { prisma = appCtx.app.get(PrismaService); fieldService = appCtx.app.get(FieldService); knex = appCtx.app.get('CUSTOM_KNEX'); + computedOrchestrator = appCtx.app.get(ComputedOrchestratorService); }); afterAll(async () => { @@ -262,6 +267,57 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => { ); }); + it('should not recompute dependent fields when only defaultValue changes', async () => { + const statusField = await createField(table1.id, { + name: 'Status', + type: FieldType.SingleSelect, + options: { + choices: [ + { id: 'choTodo', name: 'Todo', color: Colors.Cyan }, + { id: 'choDone', name: 'Done', color: Colors.Blue }, + ], + defaultValue: 'Todo', + }, + }); + + const formulaField = await createField(table1.id, { + name: 'Status formula', + type: FieldType.Formula, + options: { + expression: `{${statusField.id}}`, + }, + }); + + await updateRecordByApi(table1.id, table1.records[0].id, statusField.id, 'Todo'); + + const computeSpy: MockInstance = vi.spyOn( + computedOrchestrator, + 'computeCellChangesForFields' + ); + + try { + const updatedField = await convertField(table1.id, statusField.id, { + type: FieldType.SingleSelect, + options: { + choices: [ + { id: 'choTodo', name: 'Todo', color: Colors.Cyan }, + { id: 'choDone', name: 'Done', color: Colors.Blue }, + ], + defaultValue: 'Done', + }, + }); + + expect((updatedField.options as ISelectFieldOptions).defaultValue).toEqual('Done'); + expect(computeSpy).not.toHaveBeenCalled(); + + const record = await getRecord(table1.id, table1.records[0].id); + expect(record.fields[statusField.id]).toEqual('Todo'); + expect(record.fields[formulaField.id]).toEqual('Todo'); + } finally { + computeSpy.mockRestore(); + } + }); + it('should modify field validation', async () => { const sourceFieldRo: IFieldRo = { name: 'TextField', @@ -5096,6 +5152,56 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => { expect(matchedIndexes2).toHaveLength(0); }); + it('should drop stale standalone unique index when unique is disabled', async () => { + const sourceField = await createField(table1.id, { + name: 'Stale unique text', + type: FieldType.SingleLineText, + }); + const { records } = await createRecords(table1.id, { + records: [ + { fields: { [sourceField.id]: 'alpha' } }, + { fields: { [sourceField.id]: 'beta' } }, + ], + }); + const uniqueField = await convertField(table1.id, sourceField.id, { + ...sourceField, + unique: true, + }); + const [schemaName, physicalTableName] = dbProvider.splitTableName(table1.dbTableName); + const staleIndexName = `${physicalTableName}_${uniqueField.dbFieldName}_unique`; + const staleIndexSql = knex + .raw('CREATE UNIQUE INDEX ?? ON ??.?? (??)', [ + staleIndexName, + schemaName, + physicalTableName, + uniqueField.dbFieldName, + ]) + .toQuery(); + + await prisma.txClient().$executeRawUnsafe(staleIndexSql); + + const matchedIndexes1 = await fieldService.findUniqueIndexesForField( + table1.dbTableName, + uniqueField.dbFieldName + ); + expect(matchedIndexes1).toEqual(expect.arrayContaining([staleIndexName])); + expect(matchedIndexes1.length).toBeGreaterThanOrEqual(2); + + const dropUniqueField = await convertField(table1.id, uniqueField.id, { + ...uniqueField, + unique: false, + }); + expect(dropUniqueField.unique).toEqual(false); + + const matchedIndexes2 = await fieldService.findUniqueIndexesForField( + table1.dbTableName, + dropUniqueField.dbFieldName + ); + expect(matchedIndexes2).toHaveLength(0); + + await updateRecordByApi(table1.id, records[1].id, dropUniqueField.id, 'alpha'); + }); + it('should modify old unique property', async () => { const field = table1.fields[0]; const matchedIndexes = await fieldService.findUniqueIndexesForField( diff --git a/apps/nestjs-backend/test/field-view-sync.e2e-spec.ts b/apps/nestjs-backend/test/field-view-sync.e2e-spec.ts index fa9f4a7db6..03cec717e7 100644 --- a/apps/nestjs-backend/test/field-view-sync.e2e-spec.ts +++ b/apps/nestjs-backend/test/field-view-sync.e2e-spec.ts @@ -132,6 +132,8 @@ describe('OpenAPI FieldController (e2e)', () => { ], }); + expect(gridViewAfterDelete?.columnMeta).not.haveOwnProperty(numberField.id); + expect(kanbanViewAfterDelete).toEqual({ ...kanbanViewAfterDelete, filter: { @@ -146,6 +148,7 @@ describe('OpenAPI FieldController (e2e)', () => { ], }); + expect(kanbanViewAfterDelete?.columnMeta).not.haveOwnProperty(numberField.id); expect(formViewAfterDelete?.columnMeta).not.haveOwnProperty(numberField.id); }); diff --git a/apps/nestjs-backend/test/field.e2e-spec.ts b/apps/nestjs-backend/test/field.e2e-spec.ts index 6484712511..aa7a555ac7 100644 --- a/apps/nestjs-backend/test/field.e2e-spec.ts +++ b/apps/nestjs-backend/test/field.e2e-spec.ts @@ -30,9 +30,11 @@ import { convertField, deleteField, permanentDeleteTable, + getField, getFields, getRecord, initApp, + updateField, updateRecordByApi, createRecords, getRecords, @@ -141,6 +143,42 @@ describe('OpenAPI FieldController (e2e)', () => { expect(field).toBeDefined(); expect(field.type).toBe(FieldType.Date); }); + + it('preserves symmetric link field when PATCH only renames a v2 link field', async () => { + await withForceV2All(async () => { + const linkedTable = await createTable(baseId, { name: 'patch-rename-linked' }); + try { + const linkField = await createField(table1.id, { + name: 'Linked table', + type: FieldType.Link, + options: { + foreignTableId: linkedTable.id, + relationship: Relationship.ManyMany, + isOneWay: false, + } as ILinkFieldOptionsRo, + }); + const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId; + expect(symmetricFieldId).toBeDefined(); + const symmetricFieldBeforeUpdate = await getField(linkedTable.id, symmetricFieldId!); + expect(symmetricFieldBeforeUpdate.id).toBe(symmetricFieldId); + + await updateField(table1.id, linkField.id, { + name: 'Renamed linked table', + }); + const updatedField = await getField(table1.id, linkField.id); + + expect(updatedField.name).toBe('Renamed linked table'); + const symmetricField = await getField(linkedTable.id, symmetricFieldId!); + expect(symmetricField.id).toBe(symmetricFieldId); + expect(symmetricField.type).toBe(FieldType.Link); + expect((updatedField.options as ILinkFieldOptions).symmetricFieldId).toBe( + symmetricFieldId + ); + } finally { + await permanentDeleteTable(baseId, linkedTable.id); + } + }); + }); }); describe('should generate default name and options for field', () => { diff --git a/apps/nestjs-backend/test/formula-inline-computed-update.e2e-spec.ts b/apps/nestjs-backend/test/formula-inline-computed-update.e2e-spec.ts new file mode 100644 index 0000000000..cc41eadbbf --- /dev/null +++ b/apps/nestjs-backend/test/formula-inline-computed-update.e2e-spec.ts @@ -0,0 +1,104 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { INestApplication } from '@nestjs/common'; +import type { IFieldRo, ISelectFieldOptionsRo } from '@teable/core'; +import { Colors, FieldKeyType, FieldType } from '@teable/core'; +import { updateRecord as apiUpdateRecord } from '@teable/openapi'; +import { + createField, + createTable, + getRecord, + initApp, + permanentDeleteTable, +} from './utils/init-app'; +import { X_TEABLE_V2_HEADER } from '../src/features/canary/interceptors/v2-indicator.interceptor'; + +const isForceV2 = process.env.FORCE_V2_ALL === 'true'; +const describeV2 = isForceV2 ? describe : describe.skip; + +describeV2('Formula inline computed updates (v2 e2e)', () => { + let app: INestApplication; + const baseId = globalThis.testConfig.baseId; + + beforeAll(async () => { + const appCtx = await initApp(); + app = appCtx.app; + }); + + afterAll(async () => { + await app.close(); + }); + + it('returns the updated same-record formula value in the PATCH response', async () => { + const table = await createTable(baseId, { + name: `formula_inline_update_${Date.now()}`, + fields: [ + { + name: 'Commission Valid', + type: FieldType.SingleSelect, + options: { + choices: [ + { name: 'Yes', color: Colors.Green }, + { name: 'No', color: Colors.Red }, + ], + } as ISelectFieldOptionsRo, + }, + { name: 'Price', type: FieldType.Number }, + { + name: 'Order Type', + type: FieldType.SingleSelect, + options: { + choices: [ + { name: 'New', color: Colors.Blue }, + { name: 'Renewal', color: Colors.Yellow }, + ], + } as ISelectFieldOptionsRo, + }, + ] as IFieldRo[], + records: [ + { + fields: { + 'Commission Valid': 'Yes', + Price: 480, + 'Order Type': 'New', + }, + }, + ], + }); + + try { + const commissionValidFieldId = table.fields.find( + (field) => field.name === 'Commission Valid' + )!.id; + const priceFieldId = table.fields.find((field) => field.name === 'Price')!.id; + const orderTypeFieldId = table.fields.find((field) => field.name === 'Order Type')!.id; + + const commissionField = await createField(table.id, { + name: 'Commission', + type: FieldType.Formula, + options: { + expression: `IF({${commissionValidFieldId}} = "No", 0, IF({${priceFieldId}} > 0, ROUND(IF({${orderTypeFieldId}} = "New", {${priceFieldId}} * 0.15, {${priceFieldId}} * 0.10), 2), 0))`, + }, + } as IFieldRo); + + const recordId = table.records[0].id; + const initialRecord = await getRecord(table.id, recordId); + expect(initialRecord.fields[commissionField.id]).toBe(72); + + const response = await apiUpdateRecord(table.id, recordId, { + fieldKeyType: FieldKeyType.Id, + record: { + fields: { + [commissionValidFieldId]: 'No', + }, + }, + }); + + expect(response.status).toBe(200); + expect(response.headers[X_TEABLE_V2_HEADER]).toBe('true'); + expect(response.data.fields[commissionValidFieldId]).toBe('No'); + expect(response.data.fields[commissionField.id]).toBe(0); + } finally { + await permanentDeleteTable(baseId, table.id); + } + }); +}); diff --git a/apps/nestjs-backend/test/import-base.e2e-spec.ts b/apps/nestjs-backend/test/import-base.e2e-spec.ts index 372860f49f..3d951ebee6 100644 --- a/apps/nestjs-backend/test/import-base.e2e-spec.ts +++ b/apps/nestjs-backend/test/import-base.e2e-spec.ts @@ -715,97 +715,107 @@ describe('OpenAPI BaseController for base import (e2e)', () => { } }); - it('imports base with conditional rollup without circular dependency', async () => { - const { previewUrl } = await awaitConditionalExport(async () => { - await exportBase(conditionalBaseId); - }); - - const attachmentService = getAttachmentService(app); - const clsService = app.get(ClsService); - - const notify = await clsService.runWith>( - { - user: { - id: userId, - name: 'Test User', - email: 'test@example.com', - isAdmin: null, - }, - } as unknown as ClsStore, - async () => { - return await attachmentService.uploadFromUrl(appUrl + previewUrl); - } - ); - - const { base: importedBase } = ( - await importBase({ - notify: notify as unknown as INotifyVo, - spaceId, - }) - ).data; + it( + 'imports base with conditional rollup without circular dependency', + { timeout: 180_000 }, + async () => { + const { previewUrl } = await awaitConditionalExport(async () => { + await exportBase(conditionalBaseId); + }); - importedBaseId = importedBase.id; + const attachmentService = getAttachmentService(app); + const clsService = app.get(ClsService); - const tableList = (await getTableList(importedBase.id)).data; - expect(tableList.map(({ name }) => name).sort()).toEqual( - [hostTable.name, foreignTable.name].sort() - ); + const notify = await clsService.runWith>( + { + user: { + id: userId, + name: 'Test User', + email: 'test@example.com', + isAdmin: null, + }, + } as unknown as ClsStore, + async () => { + return await attachmentService.uploadFromUrl(appUrl + previewUrl); + } + ); - const importedHostMeta = tableList.find((tableMeta) => tableMeta.name === hostTable.name)!; - const importedHost = await getTable(importedBase.id, importedHostMeta.id, { - includeContent: true, - }); + const { base: importedBase } = ( + await importBase({ + notify: notify as unknown as INotifyVo, + spaceId, + }) + ).data; - const importedFields = importedHost.fields ?? []; - const importedRollupField = importedFields.find((field) => field.name === 'Status Rollup')!; - expect(importedRollupField.type).toBe(FieldType.ConditionalRollup); - expect(importedRollupField.hasError).toBeFalsy(); + importedBaseId = importedBase.id; - const importedLookupField = importedFields.find((field) => field.name === 'Status Lookup')!; - expect(importedLookupField.isLookup).toBeTruthy(); - expect(importedLookupField.isConditionalLookup).toBeTruthy(); - expect(importedLookupField.hasError).toBeFalsy(); - const lookupOptions = - typeof importedLookupField.lookupOptions === 'string' - ? (JSON.parse(importedLookupField.lookupOptions) as { - sort?: { fieldId: string; order?: SortFunc }; - }) - : (importedLookupField.lookupOptions as - | { sort?: { fieldId: string; order?: SortFunc } } - | undefined); - expect(lookupOptions?.sort?.order).toBe(SortFunc.Asc); + const tableList = (await getTableList(importedBase.id)).data; + expect(tableList.map(({ name }) => name).sort()).toEqual( + [hostTable.name, foreignTable.name].sort() + ); - const importedStatusFilter = importedFields.find((field) => field.name === 'StatusFilter')!; + const importedHostMeta = tableList.find((tableMeta) => tableMeta.name === hostTable.name)!; + const importedHost = await getTable(importedBase.id, importedHostMeta.id, { + includeContent: true, + }); - const activeRecordMeta = await waitForRecordWithFieldValue( - importedHostMeta.id, - importedStatusFilter.id, - 'Active' - ); - const inactiveRecordMeta = await waitForRecordWithFieldValue( - importedHostMeta.id, - importedStatusFilter.id, - 'Inactive' - ); + const importedFields = importedHost.fields ?? []; + const importedRollupField = importedFields.find((field) => field.name === 'Status Rollup')!; + expect(importedRollupField.type).toBe(FieldType.ConditionalRollup); + expect(importedRollupField.hasError).toBeFalsy(); + + const importedLookupField = importedFields.find((field) => field.name === 'Status Lookup')!; + expect(importedLookupField.isLookup).toBeTruthy(); + expect(importedLookupField.isConditionalLookup).toBeTruthy(); + expect(importedLookupField.hasError).toBeFalsy(); + const lookupOptions = + typeof importedLookupField.lookupOptions === 'string' + ? (JSON.parse(importedLookupField.lookupOptions) as { + sort?: { fieldId: string; order?: SortFunc }; + }) + : (importedLookupField.lookupOptions as + | { sort?: { fieldId: string; order?: SortFunc } } + | undefined); + expect(lookupOptions?.sort?.order).toBe(SortFunc.Asc); + + const importedStatusFilter = importedFields.find((field) => field.name === 'StatusFilter')!; + const importedRecordTimeoutMs = 30_000; + + const activeRecordMeta = await waitForRecordWithFieldValue( + importedHostMeta.id, + importedStatusFilter.id, + 'Active', + importedRecordTimeoutMs + ); + const inactiveRecordMeta = await waitForRecordWithFieldValue( + importedHostMeta.id, + importedStatusFilter.id, + 'Inactive', + importedRecordTimeoutMs + ); - expect(activeRecordMeta).toBeDefined(); - expect(inactiveRecordMeta).toBeDefined(); + expect(activeRecordMeta).toBeDefined(); + expect(inactiveRecordMeta).toBeDefined(); - const activeRecord = await waitForComputedRecord(importedHostMeta.id, activeRecordMeta!.id, [ - importedRollupField.id, - importedLookupField.id, - ]); - const inactiveRecord = await waitForComputedRecord( - importedHostMeta.id, - inactiveRecordMeta!.id, - [importedRollupField.id, importedLookupField.id] - ); + const activeRecord = await waitForComputedRecord( + importedHostMeta.id, + activeRecordMeta!.id, + [importedRollupField.id, importedLookupField.id], + importedRecordTimeoutMs + ); + const inactiveRecord = await waitForComputedRecord( + importedHostMeta.id, + inactiveRecordMeta!.id, + [importedRollupField.id, importedLookupField.id], + importedRecordTimeoutMs + ); - expect(activeRecord.fields?.[importedRollupField.id]).toBe('Alpha'); - expect(inactiveRecord.fields?.[importedRollupField.id]).toBe('Beta'); - expect(activeRecord.fields?.[importedLookupField.id]).toEqual(['Alpha']); - expect(inactiveRecord.fields?.[importedLookupField.id]).toEqual(['Beta']); - }); + expect(activeRecord.fields?.[importedRollupField.id]).toBe('Alpha'); + expect(inactiveRecord.fields?.[importedRollupField.id]).toBe('Beta'); + expect(activeRecord.fields?.[importedLookupField.id]).toEqual(['Alpha']); + expect(inactiveRecord.fields?.[importedLookupField.id]).toEqual(['Beta']); + } + ); }); describe('primary formula import', () => { diff --git a/apps/nestjs-backend/test/oauth-server.e2e-spec.ts b/apps/nestjs-backend/test/oauth-server.e2e-spec.ts index dab71db9ab..9aa1c390b6 100644 --- a/apps/nestjs-backend/test/oauth-server.e2e-spec.ts +++ b/apps/nestjs-backend/test/oauth-server.e2e-spec.ts @@ -1165,6 +1165,13 @@ describe('OpenAPI OAuthController (e2e)', () => { }); it('/api/oauth/client/:clientId/revoke-token (POST)', async () => { + const userRes = await anonymousAxios.get(`/auth/user`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + expect(userRes.status).toBe(200); + const revokeRes = await axios.post( urlBuilder(REVOKE_TOKEN, { clientId: oauth.clientId }) ); diff --git a/apps/nestjs-backend/test/record-query-builder.e2e-spec.ts b/apps/nestjs-backend/test/record-query-builder.e2e-spec.ts index eba323db6e..875627ddd1 100644 --- a/apps/nestjs-backend/test/record-query-builder.e2e-spec.ts +++ b/apps/nestjs-backend/test/record-query-builder.e2e-spec.ts @@ -1,7 +1,7 @@ /* eslint-disable sonarjs/no-duplicate-string */ import type { INestApplication } from '@nestjs/common'; import type { IFieldRo, IFieldVo, ILinkFieldOptionsRo, ILookupOptionsRo } from '@teable/core'; -import { FieldType as FT, Relationship, StatisticsFunc } from '@teable/core'; +import { FieldType as FT, Relationship, SortFunc, StatisticsFunc } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import { format as formatSql } from 'sql-formatter'; import type { IRecordQueryBuilder } from '../src/features/record/query-builder'; @@ -183,6 +183,105 @@ describe('RecordQueryBuilder (e2e)', () => { expect(formatted).toMatch(/from\s+"BASE_TBL_ALIAS"\s+as\s+"TBL_ALIAS"/i); }); + it('limits aggregate field selections to the requested projection', async () => { + const { selectionMap } = await rqb.createRecordAggregateBuilder(dbTableName, { + tableId: table.id, + aggregationFields: [ + { + fieldId: '*', + statisticFunc: StatisticsFunc.Count, + alias: 'row_count', + }, + ], + groupBy: [{ fieldId: f1.id, order: SortFunc.Asc }], + projection: [f1.id], + }); + + expect(Array.from(selectionMap.keys())).toEqual([f1.id]); + }); + + it('builds CreatedBy/LastModifiedBy SQL from data-table snapshots without users joins', async () => { + let createdByField: IFieldVo | undefined; + let lastModifiedByField: IFieldVo | undefined; + + try { + createdByField = await createField(table.id, { name: 'RQ Created By', type: FT.CreatedBy }); + lastModifiedByField = await createField(table.id, { + name: 'RQ Last Modified By', + type: FT.LastModifiedBy, + }); + + const { qb, alias } = await rqb.createRecordQueryBuilder(dbTableName, { + tableId: table.id, + projection: [createdByField.id, lastModifiedByField.id], + filter: { + conjunction: 'and', + filterSet: [{ fieldId: lastModifiedByField.id, operator: 'is', value: 'usrAudit' }], + }, + sort: [{ fieldId: createdByField.id, order: SortFunc.Asc }], + }); + + qb.from({ [alias]: 'db_table' }); + const sql = qb.limit(1).toQuery(); + + expect(sql).not.toContain('users'); + expect(sql).not.toContain('public.users'); + expect(sql).toContain(`"${alias}"."${createdByField.dbFieldName}"`); + expect(sql).toContain(`"${alias}"."__created_by"`); + expect(sql).toContain(`"${alias}"."${lastModifiedByField.dbFieldName}"`); + expect(sql).toContain(`"${alias}"."__last_modified_by"`); + expect(sql).toContain('jsonb_extract_path_text'); + expect(sql).toContain("->>'title'"); + } finally { + if (lastModifiedByField) { + await deleteField(table.id, lastModifiedByField.id); + } + if (createdByField) { + await deleteField(table.id, createdByField.id); + } + } + }); + + it('builds formulas referencing audit user fields without users joins', async () => { + let createdByField: IFieldVo | undefined; + let formulaField: IFieldVo | undefined; + + try { + createdByField = await createField(table.id, { + name: 'Formula Created By', + type: FT.CreatedBy, + }); + formulaField = await createField(table.id, { + name: 'Formula Created By Name', + type: FT.Formula, + options: { + expression: `{${createdByField.id}}`, + }, + } as IFieldRo); + + const { qb, alias } = await rqb.createRecordQueryBuilder(dbTableName, { + tableId: table.id, + projection: [formulaField.id], + }); + + qb.from({ [alias]: 'db_table' }); + const sql = qb.limit(1).toQuery(); + + expect(sql).not.toContain('users'); + expect(sql).not.toContain('public.users'); + expect(sql).toContain(`"${alias}"."${createdByField.dbFieldName}"`); + expect(sql).toContain(`"${alias}"."__created_by"`); + expect(sql).toContain("->>'title'"); + } finally { + if (formulaField) { + await deleteField(table.id, formulaField.id); + } + if (createdByField) { + await deleteField(table.id, createdByField.id); + } + } + }); + it('qualifies system columns inside lookup CTE formulas', async () => { const foreignTable = await createTable(baseId, { name: 'rqb_lookup_src' }); const foreignFormulaRo: IFieldRo = { @@ -280,6 +379,7 @@ describe('RecordQueryBuilder (e2e)', () => { const { qb, alias } = await rqb.createRecordQueryBuilder(dbTableName, { tableId: table.id, projection: [conditionalRollup.id], + preferStoredLookupFields: false, }); qb.from({ [alias]: 'db_table' }); @@ -363,6 +463,7 @@ describe('RecordQueryBuilder (e2e)', () => { const { qb, alias } = await rqb.createRecordQueryBuilder(dbTableName, { tableId: table.id, projection: [conditionalRollup.id], + preferStoredLookupFields: false, }); qb.from({ [alias]: 'db_table' }); @@ -451,6 +552,7 @@ describe('RecordQueryBuilder (e2e)', () => { const { qb, alias } = await rqb.createRecordQueryBuilder(dbTableName, { tableId: table.id, projection: [conditionalRollup.id], + preferStoredLookupFields: false, }); qb.from({ [alias]: 'db_table' }); @@ -511,16 +613,52 @@ describe('RecordQueryBuilder (e2e)', () => { } as ILookupOptionsRo, } as IFieldRo); - const { qb, alias } = await rqb.createRecordQueryBuilder(dbTableName, { + const { qb, alias, selectionMap } = await rqb.createRecordQueryBuilder(dbTableName, { tableId: table.id, projection: [conditionalLookup.id], }); qb.from({ [alias]: 'db_table' }); const sql = qb.limit(1).toQuery(); - expect(sql).toContain(`__cl_${conditionalLookup.id}`); - expect(sql).toContain('ROW_NUMBER() OVER (PARTITION BY'); - expect(sql).toContain('jsonb_extract_path_text'); + expect(sql).not.toContain(`CTE_CONDITIONAL_LOOKUP_${conditionalLookup.id}`); + expect(selectionMap.get(conditionalLookup.id)?.toString()).toContain( + `"${conditionalLookup.dbFieldName}"` + ); + + const { qb: computedQb, alias: computedAlias } = await rqb.createRecordQueryBuilder( + dbTableName, + { + tableId: table.id, + projection: [conditionalLookup.id], + preferStoredLookupFields: false, + } + ); + computedQb.from({ [computedAlias]: 'db_table' }); + + const computedSql = computedQb.limit(1).toQuery(); + expect(computedSql).toContain(`__cl_${conditionalLookup.id}`); + expect(computedSql).toContain('ROW_NUMBER() OVER (PARTITION BY'); + expect(computedSql).toContain('jsonb_extract_path_text'); + + const { qb: aggregateQb, selectionMap: aggregateSelectionMap } = + await rqb.createRecordAggregateBuilder(dbTableName, { + tableId: table.id, + aggregationFields: [ + { + fieldId: '*', + statisticFunc: StatisticsFunc.Count, + alias: 'row_count', + }, + ], + groupBy: [{ fieldId: conditionalLookup.id, order: SortFunc.Asc }], + projection: [conditionalLookup.id], + }); + + const aggregateSql = aggregateQb.toQuery(); + expect(aggregateSql).not.toContain(`CTE_CONDITIONAL_LOOKUP_${conditionalLookup.id}`); + expect(aggregateSelectionMap.get(conditionalLookup.id)?.toString()).toContain( + `"${conditionalLookup.dbFieldName}"` + ); } finally { if (conditionalLookup) { await deleteField(table.id, conditionalLookup.id); diff --git a/apps/nestjs-backend/test/search-consistency.e2e-spec.ts b/apps/nestjs-backend/test/search-consistency.e2e-spec.ts new file mode 100644 index 0000000000..ef2e409542 --- /dev/null +++ b/apps/nestjs-backend/test/search-consistency.e2e-spec.ts @@ -0,0 +1,290 @@ +import fs from 'fs'; +import path from 'path'; +import type { INestApplication } from '@nestjs/common'; +import { + Colors, + computeSearchHitIndex, + DateFormattingPreset, + FieldKeyType, + FieldType, + NumberFormattingType, + RatingIcon, + Relationship, + TimeFormatting, +} from '@teable/core'; +import type { ITableFullVo } from '@teable/openapi'; +import { + createField, + createRecords, + getRecords as apiGetRecords, + getSearchIndex, + uploadAttachment, +} from '@teable/openapi'; +import StorageAdapter from '../src/features/attachments/plugins/adapter'; +import { createFieldInstanceByVo } from '../src/features/field/model/factory'; +import { + createBase, + createSpace, + createTable, + permanentDeleteSpace, + initApp, + getFields, +} from './utils/init-app'; + +/** + * Reconciliation suite: for the same data set, server-side search hits + * (aggregation/search-index, the same SQL semantics that filter rows) must + * agree with client-side hits (computeSearchHitIndex from @teable/core, the + * same code that highlights cells). Field eligibility is structurally shared + * via FieldCore.isSearchable; this locks the value-level matching semantics. + */ +describe('Search consistency between server and client (e2e)', () => { + let app: INestApplication; + let table: ITableFullVo; + let subTable: ITableFullVo; + let viewId: string; + let spaceId: string | undefined; + let baseId: string | undefined; + + const fieldId = (name: string) => { + const field = table.fields.find((f) => f.name === name); + if (!field) throw new Error(`field ${name} not found`); + return field.id; + }; + + beforeAll(async () => { + const appCtx = await initApp(); + app = appCtx.app; + + const space = await createSpace({ name: 'search-consistency-space' }); + spaceId = space.id; + const base = await createBase({ name: 'search-consistency-base', spaceId }); + baseId = base.id; + + subTable = await createTable(baseId, { + name: 'search-consistency-sub', + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + records: [{ fields: { Name: 'Sub Record A' } }, { fields: { Name: 'Sub Record B' } }], + fieldKeyType: FieldKeyType.Name, + }); + + table = await createTable(baseId, { + name: 'search-consistency-main', + fields: [ + { name: 'Title', type: FieldType.SingleLineText }, + { + name: 'Amount', + type: FieldType.Number, + options: { formatting: { type: NumberFormattingType.Decimal, precision: 2 } }, + }, + { + name: 'Stars', + type: FieldType.Rating, + options: { icon: RatingIcon.Star, color: Colors.YellowBright, max: 5 }, + }, + { + name: 'Due', + type: FieldType.Date, + options: { + formatting: { + date: DateFormattingPreset.ISO, + time: TimeFormatting.None, + timeZone: 'Asia/Singapore', + }, + }, + }, + { + name: 'Status', + type: FieldType.SingleSelect, + options: { + choices: [ + { name: 'Open', color: Colors.Cyan }, + { name: 'Closed', color: Colors.Gray }, + ], + }, + }, + { + name: 'Tags', + type: FieldType.MultipleSelect, + options: { + choices: [ + { name: 'alpha', color: Colors.Cyan }, + { name: 'beta', color: Colors.Blue }, + { name: 'gamma', color: Colors.Gray }, + ], + }, + }, + { name: 'Done', type: FieldType.Checkbox }, + { name: 'Files', type: FieldType.Attachment }, + ], + records: [], + }); + viewId = table.views[0].id; + + const titleId = fieldId('Title'); + const amountId = fieldId('Amount'); + const dueId = fieldId('Due'); + + await createField(table.id, { + name: 'Link', + type: FieldType.Link, + options: { relationship: Relationship.ManyMany, foreignTableId: subTable.id, isOneWay: true }, + }); + await createField(table.id, { + name: 'TextFormula', + type: FieldType.Formula, + options: { expression: `{${titleId}}` }, + }); + await createField(table.id, { + name: 'NumberFormula', + type: FieldType.Formula, + options: { expression: `{${amountId}} * 2` }, + }); + await createField(table.id, { + name: 'DateFormula', + type: FieldType.Formula, + options: { expression: `{${dueId}}` }, + }); + await createField(table.id, { + name: 'BoolFormula', + type: FieldType.Formula, + options: { expression: `{${amountId}} > 1` }, + }); + table.fields = await getFields(table.id); + + const { records: createdRecords } = ( + await createRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + records: [ + { + fields: { + [fieldId('Title')]: 'Apple Pie', + [fieldId('Amount')]: 1.5, + [fieldId('Stars')]: 3, + [fieldId('Due')]: '2026-01-15T00:00:00.000Z', + [fieldId('Status')]: 'Open', + [fieldId('Tags')]: ['alpha', 'beta'], + [fieldId('Done')]: true, + [fieldId('Link')]: [{ id: subTable.records[0].id }], + }, + }, + { + fields: { + [fieldId('Title')]: 'banana split', + [fieldId('Amount')]: 100, + [fieldId('Tags')]: ['gamma'], + }, + }, + { + fields: { + [fieldId('Title')]: '50% off_sale', + [fieldId('Stars')]: 5, + }, + }, + { fields: { [fieldId('Title')]: 'TRUE Story' } }, + { fields: {} }, + ], + }) + ).data; + + const tmpPath = path.resolve( + path.join(StorageAdapter.TEMPORARY_DIR, 'search-consistency-invoice-report.txt') + ); + fs.writeFileSync(tmpPath, 'attachment content'); + try { + await uploadAttachment( + table.id, + createdRecords[0].id, + fieldId('Files'), + fs.createReadStream(tmpPath) + ); + } finally { + fs.unlinkSync(tmpPath); + } + }); + + afterAll(async () => { + if (spaceId) { + await permanentDeleteSpace(spaceId); + } + await app?.close(); + }); + + const toKey = (hit: { recordId: string; fieldId: string }) => `${hit.recordId}:${hit.fieldId}`; + + const fetchServerHits = async (search: [string, string, boolean]) => { + const { data } = await getSearchIndex(table.id, { viewId, take: 1000, search }); + // an empty hit response has no body, so data may be '' instead of null + return new Set(Array.isArray(data) ? data.map(toKey) : []); + }; + + const computeClientHits = async (search: [string, string, boolean]) => { + const { records } = ( + await apiGetRecords(table.id, { viewId, fieldKeyType: FieldKeyType.Id, take: 1000 }) + ).data; + const fields = table.fields.map(createFieldInstanceByVo); + const hits = computeSearchHitIndex( + records.map((r) => ({ id: r.id, fields: r.fields })), + fields, + search + ); + return new Set((hits ?? []).map(toKey)); + }; + + type ICase = { name: string; keyword: string; scope?: () => string }; + const cases: ICase[] = [ + { name: 'plain text, all fields', keyword: 'apple' }, + { name: 'case-insensitive text, all fields', keyword: 'APPLE' }, + { name: 'numeric keyword, all fields', keyword: '1.5' }, + { name: 'numeric fragment, all fields', keyword: '.5' }, + { name: 'integer keyword, all fields', keyword: '100' }, + { name: 'date-like keyword, all fields (datetime excluded)', keyword: '2026' }, + { name: 'like wildcard percent, all fields', keyword: '%' }, + { name: 'like wildcard underscore, all fields', keyword: '_' }, + { name: 'multi select option, all fields', keyword: 'alpha' }, + { name: 'single select lowercase, all fields', keyword: 'open' }, + { name: 'boolean-like keyword, all fields (boolean excluded)', keyword: 'true' }, + { name: 'multi-value join boundary, all fields', keyword: 'a, b' }, + { name: 'link title, all fields', keyword: 'sub record' }, + { name: 'date scoped to date field', keyword: '2026', scope: () => fieldId('Due') }, + { name: 'text scoped to text field', keyword: 'apple', scope: () => fieldId('Title') }, + { + name: 'numeric scoped to several fields', + keyword: '1.5', + scope: () => `${fieldId('Amount')},${fieldId('Stars')}`, + }, + { name: 'link scoped to link field', keyword: 'sub record', scope: () => fieldId('Link') }, + ]; + + it.each(cases)('agrees on $name', async ({ keyword, scope }) => { + const search: [string, string, boolean] = [keyword, scope?.() ?? '', false]; + const serverHits = await fetchServerHits(search); + const clientHits = await computeClientHits(search); + expect([...clientHits].sort()).toEqual([...serverHits].sort()); + }); + // attachments are excluded from search on both sides (AttachmentFieldCore + // isSearchable returns false; the server consumes the same predicate): + // server-side matching could only run over the stored JSON text, which + // produced noise hits for mimetype/path keywords like "png" or "pdf" + it('never matches attachment fields on either side', async () => { + const { records } = ( + await apiGetRecords(table.id, { viewId, fieldKeyType: FieldKeyType.Id, take: 1000 }) + ).data; + const attach = (records[0].fields[fieldId('Files')] as { name: string; token: string }[])[0]; + + const keywordsByScope: [string, string][] = [ + ['invoice', ''], + ['invoice', fieldId('Files')], + [attach.token, ''], + ['text/plain', ''], + ['mimetype', ''], + ]; + for (const [keyword, scope] of keywordsByScope) { + const search: [string, string, boolean] = [keyword, scope, false]; + const serverHits = await fetchServerHits(search); + const clientHits = await computeClientHits(search); + expect(serverHits.size).toBe(0); + expect(clientHits.size).toBe(0); + } + }); +}); diff --git a/apps/nestjs-backend/test/selection.e2e-spec.ts b/apps/nestjs-backend/test/selection.e2e-spec.ts index 78cc74e21a..d9a167434a 100644 --- a/apps/nestjs-backend/test/selection.e2e-spec.ts +++ b/apps/nestjs-backend/test/selection.e2e-spec.ts @@ -6,6 +6,7 @@ import { Colors, FieldKeyType, FieldType, + Me, MultiNumberDisplayType, Relationship, Role, @@ -13,17 +14,31 @@ import { defaultNumberFormatting, } from '@teable/core'; import type { IFieldRo, IUserCellValue } from '@teable/core'; -import type { IRecordsVo, IPasteRo, IPasteVo, ITableFullVo, IUserMeVo } from '@teable/openapi'; +import type { + IRecordsVo, + IGetRecordsRo, + IPasteByIdVo, + IPasteRo, + IPasteVo, + ITableFullVo, + IUserMeVo, +} from '@teable/openapi'; import { RangeType, IdReturnType, CLEAR_URL, + CLEAR_BY_ID_URL, + CLEAR_BY_ID_STREAM_URL, CLEAR_STREAM_URL, + DELETE_BY_ID_URL, + DELETE_BY_ID_STREAM_URL, DELETE_STREAM_URL, DUPLICATE_STREAM_URL, DELETE_URL, GET_RECORDS_URL, PASTE_URL, + PASTE_BY_ID_URL, + PASTE_BY_ID_STREAM_URL, PASTE_STREAM_URL, X_CANARY_HEADER, axios, @@ -33,7 +48,10 @@ import { getFields, deleteSelection, clear, + updateRecordOrders, + updateViewColumnMeta, updateViewFilter, + updateViewGroup, updateViewSort, USER_ME, UPDATE_USER_NAME, @@ -52,6 +70,7 @@ import { initApp, createTable, createRecords, + convertField, permanentDeleteTable, permanentDeleteSpace, updateRecordByApi, @@ -157,6 +176,48 @@ describe('OpenAPI SelectionController (e2e)', () => { ); }; + const pasteByIdWithCanary = async (tableId: string, pasteRo: unknown, useV2: boolean) => { + return axios.patch( + urlBuilder(PASTE_BY_ID_URL, { + tableId, + }), + pasteRo, + { + headers: { + [X_CANARY_HEADER]: useV2 ? 'true' : 'false', + }, + } + ); + }; + + const clearByIdWithCanary = async (tableId: string, clearRo: unknown, useV2: boolean) => { + return axios.patch( + urlBuilder(CLEAR_BY_ID_URL, { + tableId, + }), + clearRo, + { + headers: { + [X_CANARY_HEADER]: useV2 ? 'true' : 'false', + }, + } + ); + }; + + const deleteByIdWithCanary = async (tableId: string, deleteRo: unknown, useV2: boolean) => { + return axios.post<{ ids: string[] }>( + urlBuilder(DELETE_BY_ID_URL, { + tableId, + }), + deleteRo, + { + headers: { + [X_CANARY_HEADER]: useV2 ? 'true' : 'false', + }, + } + ); + }; + const deleteStreamWithCanary = async ( tableId: string, rangesRo: { @@ -593,6 +654,73 @@ describe('OpenAPI SelectionController (e2e)', () => { }; }; + const patchSelectionByIdStreamWithCanary = async ( + url: string, + tableId: string, + body: TBody, + useV2: boolean + ) => { + const streamUrl = axios.getUri({ + baseURL: axios.defaults.baseURL, + url: urlBuilder(url, { + tableId, + }), + }); + + const response = await fetch(streamUrl, { + method: 'PATCH', + headers: { + Accept: 'text/event-stream', + 'Content-Type': 'application/json', + Cookie: cookie, + [X_CANARY_HEADER]: useV2 ? 'true' : 'false', + }, + body: JSON.stringify(body), + }); + + expect(response.ok).toBe(true); + expect(response.headers.get('content-type')).toContain('text/event-stream'); + + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + const progressEvents: Array> = []; + let doneEvent: Record | undefined; + const errorEvents: Array> = []; + + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const jsonStr = line.slice(5).trim(); + if (!jsonStr || jsonStr === '[DONE]') continue; + const event = JSON.parse(jsonStr) as Record; + if (event.id === 'progress') { + progressEvents.push(event); + } + if (event.id === 'done') { + doneEvent = event; + } + if (event.id === 'error') { + errorEvents.push(event); + } + } + } + + return { + progressEvents, + doneEvent, + errorEvents, + }; + }; + describe('getIdsFromRanges', () => { it('should return all ids for cell range ', async () => { const viewId = table.views[0].id; @@ -628,6 +756,33 @@ describe('OpenAPI SelectionController (e2e)', () => { expect(data.fieldIds).toHaveLength(table.fields.length); }); + it('T5266: should batch row range ids past the search index page limit', async () => { + const bigTable = await createTable(baseId, { + name: 'range-to-id-big-copy', + fields: [{ name: 'name', type: FieldType.SingleLineText }], + records: Array.from({ length: 1005 }, (_, index) => ({ + fields: { name: `row-${index}` }, + })), + }); + + try { + const data = ( + await apiGetIdsFromRanges(bigTable.id, { + viewId: bigTable.views[0].id, + ranges: [[0, 1004]], + type: RangeType.Rows, + returnType: IdReturnType.RecordId, + }) + ).data; + + expect(data.recordIds).toHaveLength(1005); + expect(data.recordIds?.[0]).toBe(bigTable.records[0].id); + expect(data.recordIds?.[1004]).toBe(bigTable.records[1004].id); + } finally { + await permanentDeleteTable(baseId, bigTable.id); + } + }); + it('should return all ids for column range', async () => { const viewId = table.views[0].id; @@ -1216,6 +1371,7 @@ describe('OpenAPI SelectionController (e2e)', () => { groupBy: [...groupBy], orderBy: [...orderBy], fieldKeyType: FieldKeyType.Id, + includeQueryExtra: true, }); const firstGroupHeader = groupedResult.data.extra?.groupPoints?.find( (point) => point.type === 0 && 'id' in point @@ -2119,6 +2275,7 @@ describe('OpenAPI SelectionController (e2e)', () => { groupBy: [...groupBy], orderBy: [...orderBy], fieldKeyType: FieldKeyType.Id, + includeQueryExtra: true, }); const firstGroupHeader = groupedResult.data.extra?.groupPoints?.find( (point) => point.type === 0 && 'id' in point @@ -2170,78 +2327,1650 @@ describe('OpenAPI SelectionController (e2e)', () => { ); }); - describe('api/table/:tableId/selection/delete-stream (SSE)', () => { - it('should stream v2 delete progress and return the deleted ids', async () => { - const streamTable = await createTable(baseId, { - name: 'delete-stream', + describe('api/table/:tableId/selection id-based mutations', () => { + const modes = isForceV2 + ? [{ label: 'v2-forced', useV2: true }] + : [ + { label: 'v1', useV2: false }, + { label: 'v2', useV2: true }, + ]; + + it.each(modes)( + 'paste-by-id updates explicit record ids in request order ($label)', + async ({ useV2 }) => { + const idTable = await createTable(baseId, { + name: 'paste-by-id', + fields: [ + { name: 'Name', type: FieldType.SingleLineText }, + { name: 'Status', type: FieldType.SingleLineText }, + ], + records: [ + { fields: { Name: 'A', Status: 'old-a' } }, + { fields: { Name: 'B', Status: 'old-b' } }, + { fields: { Name: 'C', Status: 'old-c' } }, + ], + }); + + try { + const statusFieldId = idTable.fields.find((field) => field.name === 'Status')!.id; + await pasteByIdWithCanary( + idTable.id, + { + viewId: idTable.views[0].id, + selection: { + recordIds: [idTable.records[2].id, idTable.records[0].id], + fieldIds: [statusFieldId], + }, + content: 'new-c\nnew-a', + }, + useV2 + ); + + const { data } = await getRecordsWithCanary( + idTable.id, + { viewId: idTable.views[0].id, fieldKeyType: FieldKeyType.Id }, + useV2 + ); + const fieldsById = Object.fromEntries( + data.records.map((record) => [record.id, record.fields[statusFieldId]]) + ); + expect(fieldsById[idTable.records[0].id]).toBe('new-a'); + expect(fieldsById[idTable.records[1].id]).toBe('old-b'); + expect(fieldsById[idTable.records[2].id]).toBe('new-c'); + } finally { + await permanentDeleteTable(baseId, idTable.id); + } + } + ); + + it.each(modes)( + 'paste-by-id expands one copied cell across selected records and fields with header ($label)', + async ({ useV2 }) => { + const idTable = await createTable(baseId, { + name: 'paste-by-id-expand-one-cell', + fields: [ + { name: 'Name', type: FieldType.SingleLineText }, + { name: 'Status', type: FieldType.SingleLineText }, + ], + records: [ + { fields: { Name: 'A', Status: 'old-a' } }, + { fields: { Name: 'B', Status: 'old-b' } }, + { fields: { Name: 'C', Status: 'old-c' } }, + ], + }); + + try { + const [nameField, statusField] = idTable.fields; + await pasteByIdWithCanary( + idTable.id, + { + viewId: idTable.views[0].id, + selection: { + recordIds: idTable.records.map((record) => record.id), + fieldIds: [nameField.id, statusField.id], + }, + content: [['1']], + header: [nameField], + }, + useV2 + ); + + const { data } = await getRecordsWithCanary( + idTable.id, + { viewId: idTable.views[0].id, fieldKeyType: FieldKeyType.Id }, + useV2 + ); + data.records.forEach((record) => { + expect(record.fields[nameField.id]).toBe('1'); + expect(record.fields[statusField.id]).toBe('1'); + }); + } finally { + await permanentDeleteTable(baseId, idTable.id); + } + } + ); + + it.each(modes)( + 'paste-by-id uses explicit target fields for multi-column content and creates extra rows ($label)', + async ({ useV2 }) => { + const idTable = await createTable(baseId, { + name: 'paste-by-id-expand-anchor', + fields: [ + { name: 'Name', type: FieldType.SingleLineText }, + { name: 'Score', type: FieldType.Number }, + { + name: 'Status', + type: FieldType.SingleSelect, + options: { + choices: [ + { name: 'Todo', color: Colors.Orange }, + { name: 'Done', color: Colors.Green }, + ], + }, + }, + ], + records: [{ fields: { Name: 'A', Score: 0, Status: 'Todo' } }], + }); + + try { + const [nameField, scoreField, statusField] = idTable.fields; + const result = await pasteByIdWithCanary( + idTable.id, + { + viewId: idTable.views[0].id, + selection: { + recordIds: [idTable.records[0].id], + fieldIds: [nameField.id, scoreField.id, statusField.id], + }, + content: [ + ['row-1', 1, 'Done'], + ['row-2', 2, 'Todo'], + ['row-3', 3, 'Done'], + ], + header: [nameField, scoreField, statusField], + }, + useV2 + ); + + expect(result.data.selection.fieldIds).toEqual([ + nameField.id, + scoreField.id, + statusField.id, + ]); + expect(result.data.selection.recordIds).toHaveLength(3); + expect(result.data.selection.recordIds[0]).toBe(idTable.records[0].id); + expect(result.data.createdRecordIds).toHaveLength(2); + expect(result.data.pastedRecordIds).toEqual(result.data.selection.recordIds); + expect(result.data.pastedFieldIds).toEqual(result.data.selection.fieldIds); + expect(result.data.createdFieldIds).toBeUndefined(); + expect(result.data.skippedAttachments).toEqual([]); + + const { data } = await getRecordsWithCanary( + idTable.id, + { viewId: idTable.views[0].id, fieldKeyType: FieldKeyType.Id }, + useV2 + ); + const fieldsById = Object.fromEntries( + data.records.map((record) => [record.id, record.fields]) + ); + const [firstRecordId, secondRecordId, thirdRecordId] = result.data.selection + .recordIds as [string, string, string]; + + expect(fieldsById[firstRecordId][nameField.id]).toBe('row-1'); + expect(fieldsById[firstRecordId][scoreField.id]).toBe(1); + expect(fieldsById[firstRecordId][statusField.id]).toBe('Done'); + expect(fieldsById[secondRecordId][nameField.id]).toBe('row-2'); + expect(fieldsById[secondRecordId][scoreField.id]).toBe(2); + expect(fieldsById[secondRecordId][statusField.id]).toBe('Todo'); + expect(fieldsById[thirdRecordId][nameField.id]).toBe('row-3'); + expect(fieldsById[thirdRecordId][scoreField.id]).toBe(3); + expect(fieldsById[thirdRecordId][statusField.id]).toBe('Done'); + } finally { + await permanentDeleteTable(baseId, idTable.id); + } + } + ); + + it.each(modes)( + 'paste-by-id creates overflowing target fields and returns pasted field ids ($label)', + async ({ useV2 }) => { + const sourceTable = await createTable(baseId, { + name: 'paste-by-id-source-fields', + fields: [ + { name: 'Name', type: FieldType.SingleLineText }, + { name: 'Score', type: FieldType.Number }, + { name: 'Note', type: FieldType.SingleLineText }, + ], + records: [], + }); + const targetTable = await createTable(baseId, { + name: 'paste-by-id-create-fields', + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + records: [], + }); + + try { + const [targetNameField] = targetTable.fields; + const result = await pasteByIdWithCanary( + targetTable.id, + { + viewId: targetTable.views[0].id, + selection: { + recordIds: [], + fieldIds: [targetNameField.id], + }, + content: [ + ['row-1', 10, 'note-1'], + ['row-2', 20, 'note-2'], + ], + header: sourceTable.fields, + }, + useV2 + ); + + expect(result.data.createdRecordIds).toHaveLength(2); + expect(result.data.createdFieldIds).toHaveLength(2); + expect(result.data.pastedRecordIds).toEqual(result.data.createdRecordIds); + expect(result.data.pastedFieldIds).toEqual([ + targetNameField.id, + ...(result.data.createdFieldIds ?? []), + ]); + + const fieldsAfter = (await getFields(targetTable.id, { viewId: targetTable.views[0].id })) + .data; + const createdFieldsById = Object.fromEntries( + fieldsAfter + .filter((field) => result.data.createdFieldIds?.includes(field.id)) + .map((field) => [field.id, field]) + ); + const [scoreFieldId, noteFieldId] = result.data.createdFieldIds as [string, string]; + expect(createdFieldsById[scoreFieldId].name).toBe('Score'); + expect(createdFieldsById[scoreFieldId].type).toBe(FieldType.Number); + expect(createdFieldsById[noteFieldId].name).toBe('Note'); + + const { data } = await getRecordsWithCanary( + targetTable.id, + { viewId: targetTable.views[0].id, fieldKeyType: FieldKeyType.Id }, + useV2 + ); + const createdRows = data.records.filter((record) => + result.data.createdRecordIds?.includes(record.id) + ); + expect(createdRows.map((record) => record.fields[targetNameField.id])).toEqual([ + 'row-1', + 'row-2', + ]); + expect(createdRows.map((record) => record.fields[scoreFieldId])).toEqual([10, 20]); + expect(createdRows.map((record) => record.fields[noteFieldId])).toEqual([ + 'note-1', + 'note-2', + ]); + } finally { + await permanentDeleteTable(baseId, targetTable.id); + await permanentDeleteTable(baseId, sourceTable.id); + } + } + ); + + it.each(modes)('paste-by-id returns created select choice ids ($label)', async ({ useV2 }) => { + const idTable = await createTable(baseId, { + name: 'paste-by-id-created-choice', fields: [ - { name: 'name', type: FieldType.SingleLineText }, - { name: 'number', type: FieldType.Number }, - ], - records: [ - { fields: { name: 'stream-1', number: 1 } }, - { fields: { name: 'stream-2', number: 2 } }, - { fields: { name: 'stream-3', number: 3 } }, + { name: 'Name', type: FieldType.SingleLineText }, + { + name: 'Status', + type: FieldType.SingleSelect, + options: { + choices: [{ name: 'Todo', color: Colors.Orange }], + }, + }, ], + records: [{ fields: { Name: 'A', Status: 'Todo' } }], }); try { - const { progressEvents, doneEvent, errorEvents } = await deleteStreamWithCanary( - streamTable.id, + const statusField = idTable.fields.find((field) => field.name === 'Status')!; + const result = await pasteByIdWithCanary( + idTable.id, { - viewId: streamTable.views[0].id, - type: RangeType.Rows, - ranges: [[0, 2]], + viewId: idTable.views[0].id, + selection: { + recordIds: [idTable.records[0].id], + fieldIds: [statusField.id], + }, + content: [['In Progress']], }, - true + useV2 ); - expect(errorEvents).toHaveLength(0); - expect(doneEvent?.data.deletedRecordIds).toEqual( - streamTable.records.map((record) => record.id) - ); - expect(progressEvents.some((event) => event.totalCount === 3)).toBe(true); - expect(progressEvents.some((event) => event.deletedCount > 0)).toBe(true); + expect(result.data.createdChoiceIdsByFieldId?.[statusField.id]).toHaveLength(1); - const recordsAfter = await getRecords(streamTable.id, { - fieldKeyType: FieldKeyType.Id, - }); - expect(recordsAfter.data.records).toHaveLength(0); + const fieldsAfter = (await getFields(idTable.id, { viewId: idTable.views[0].id })).data; + const statusFieldAfter = fieldsAfter.find((field) => field.id === statusField.id)!; + const choices = + (statusFieldAfter.options as { choices?: Array<{ id: string; name: string }> }).choices ?? + []; + const createdChoiceId = result.data.createdChoiceIdsByFieldId?.[statusField.id]?.[0]; + expect(choices.some((choice) => choice.id === createdChoiceId)).toBe(true); + expect(choices.some((choice) => choice.name === 'In Progress')).toBe(true); } finally { - await permanentDeleteTable(baseId, streamTable.id); + await permanentDeleteTable(baseId, idTable.id); } }); - it('should expose stream response headers for v2 delete', async () => { - const streamTable = await createTable(baseId, { - name: 'delete-stream-headers', - fields: [{ name: 'name', type: FieldType.SingleLineText }], - records: [{ fields: { name: 'stream-headers-1' } }], - }); + it.each(modes)( + 'paste-by-id create-only keeps Teable paste behavior without filter defaults ($label)', + async ({ useV2 }) => { + const idTable = await createTable(baseId, { + name: 'paste-by-id-no-filter-defaults', + fields: [ + { name: 'Name', type: FieldType.SingleLineText }, + { name: 'Status', type: FieldType.SingleLineText }, + ], + records: [], + }); - try { - const { headers } = await deleteStreamWithCanary( + try { + const [nameField, statusField] = idTable.fields; + await updateViewFilter(idTable.id, idTable.views[0].id, { + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: statusField.id, + operator: 'is', + value: 'Default Status', + }, + ], + }, + }); + + const result = await pasteByIdWithCanary( + idTable.id, + { + viewId: idTable.views[0].id, + selection: { + recordIds: [], + fieldIds: [nameField.id], + }, + content: [['created-without-default']], + header: [nameField], + }, + useV2 + ); + + expect(result.data.createdRecordIds).toHaveLength(1); + + const recordsAfter = await getRecordsWithCanary( + idTable.id, + { fieldKeyType: FieldKeyType.Id, ignoreViewQuery: true }, + useV2 + ); + const createdRecord = recordsAfter.data.records.find( + (record) => record.id === result.data.createdRecordIds?.[0] + ); + expect(createdRecord?.fields[nameField.id]).toBe('created-without-default'); + expect(createdRecord?.fields[statusField.id] ?? null).toBeNull(); + } finally { + await permanentDeleteTable(baseId, idTable.id); + } + } + ); + + it.each(modes)( + 'paste-by-id create-only keeps Teable field default values ($label)', + async ({ useV2 }) => { + const idTable = await createTable(baseId, { + name: 'paste-by-id-field-defaults', + fields: [ + { name: 'Name', type: FieldType.SingleLineText }, + { + name: 'Status', + type: FieldType.SingleLineText, + options: { defaultValue: 'Default Status' }, + }, + ], + records: [], + }); + + try { + const [nameField, statusField] = idTable.fields; + const result = await pasteByIdWithCanary( + idTable.id, + { + viewId: idTable.views[0].id, + selection: { + recordIds: [], + fieldIds: [nameField.id], + }, + content: [['created-with-field-default']], + header: [nameField], + }, + useV2 + ); + + expect(result.data.createdRecordIds).toHaveLength(1); + + const recordsAfter = await getRecordsWithCanary( + idTable.id, + { fieldKeyType: FieldKeyType.Id }, + useV2 + ); + const createdRecord = recordsAfter.data.records.find( + (record) => record.id === result.data.createdRecordIds?.[0] + ); + expect(createdRecord?.fields[nameField.id]).toBe('created-with-field-default'); + expect(createdRecord?.fields[statusField.id]).toBe('Default Status'); + } finally { + await permanentDeleteTable(baseId, idTable.id); + } + } + ); + + it.each(modes)( + 'paste-by-id treats empty record ids as create-only target ($label)', + async ({ useV2 }) => { + const idTable = await createTable(baseId, { + name: 'paste-by-id-empty-record-ids', + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + records: [{ fields: { Name: 'A' } }], + }); + + try { + const [nameField] = idTable.fields; + const result = await pasteByIdWithCanary( + idTable.id, + { + viewId: idTable.views[0].id, + selection: { + recordIds: [], + fieldIds: [nameField.id], + }, + content: [['row-1'], ['row-2']], + header: [nameField], + }, + useV2 + ); + + expect(result.data.createdRecordIds).toHaveLength(2); + expect(result.data.selection.recordIds).toEqual(result.data.createdRecordIds); + + const { data } = await getRecordsWithCanary( + idTable.id, + { viewId: idTable.views[0].id, fieldKeyType: FieldKeyType.Id }, + useV2 + ); + expect(data.records.some((record) => record.fields[nameField.id] === 'A')).toBe(true); + expect(data.records.some((record) => record.fields[nameField.id] === 'row-1')).toBe(true); + expect(data.records.some((record) => record.fields[nameField.id] === 'row-2')).toBe(true); + } finally { + await permanentDeleteTable(baseId, idTable.id); + } + } + ); + + it.each(modes)( + 'paste-by-id pastes into a whole selected column with query scope and exclude ids ($label)', + async ({ useV2 }) => { + const idTable = await createTable(baseId, { + name: 'paste-by-id-column', + fields: [ + { name: 'Name', type: FieldType.SingleLineText }, + { name: 'Status', type: FieldType.SingleLineText }, + ], + records: [ + { fields: { Name: 'A', Status: 'old-a' } }, + { fields: { Name: 'B', Status: 'keep-b' } }, + { fields: { Name: 'C', Status: 'old-c' } }, + ], + }); + + try { + const statusFieldId = idTable.fields.find((field) => field.name === 'Status')!.id; + await pasteByIdWithCanary( + idTable.id, + { + viewId: idTable.views[0].id, + selection: { + fieldIds: [statusFieldId], + excludeRecordIds: [idTable.records[1].id], + }, + content: 'new-a\nnew-c', + }, + useV2 + ); + + const { data } = await getRecordsWithCanary( + idTable.id, + { viewId: idTable.views[0].id, fieldKeyType: FieldKeyType.Id }, + useV2 + ); + const fieldsById = Object.fromEntries( + data.records.map((record) => [record.id, record.fields[statusFieldId]]) + ); + expect(fieldsById[idTable.records[0].id]).toBe('new-a'); + expect(fieldsById[idTable.records[1].id]).toBe('keep-b'); + expect(fieldsById[idTable.records[2].id]).toBe('new-c'); + } finally { + await permanentDeleteTable(baseId, idTable.id); + } + } + ); + + it.each(modes)( + 'clear-by-id clears a whole selected column with query scope and exclude ids ($label)', + async ({ useV2 }) => { + const idTable = await createTable(baseId, { + name: 'clear-by-id', + fields: [ + { name: 'Name', type: FieldType.SingleLineText }, + { name: 'Status', type: FieldType.SingleLineText }, + ], + records: [ + { fields: { Name: 'A', Status: 'clear-a' } }, + { fields: { Name: 'B', Status: 'keep-b' } }, + { fields: { Name: 'C', Status: 'clear-c' } }, + ], + }); + + try { + const statusFieldId = idTable.fields.find((field) => field.name === 'Status')!.id; + await clearByIdWithCanary( + idTable.id, + { + viewId: idTable.views[0].id, + selection: { + fieldIds: [statusFieldId], + excludeRecordIds: [idTable.records[1].id], + }, + }, + useV2 + ); + + const { data } = await getRecordsWithCanary( + idTable.id, + { viewId: idTable.views[0].id, fieldKeyType: FieldKeyType.Id }, + useV2 + ); + const fieldsById = Object.fromEntries( + data.records.map((record) => [record.id, record.fields[statusFieldId] ?? null]) + ); + expect(fieldsById[idTable.records[0].id]).toBeNull(); + expect(fieldsById[idTable.records[1].id]).toBe('keep-b'); + expect(fieldsById[idTable.records[2].id]).toBeNull(); + } finally { + await permanentDeleteTable(baseId, idTable.id); + } + } + ); + + it.each(modes)( + 'clear-by-id clears whole selected rows with query field scope ($label)', + async ({ useV2 }) => { + const idTable = await createTable(baseId, { + name: 'clear-by-id-row', + fields: [ + { name: 'Name', type: FieldType.SingleLineText }, + { name: 'Status', type: FieldType.SingleLineText }, + { name: 'Note', type: FieldType.SingleLineText }, + ], + records: [ + { fields: { Name: 'A', Status: 'clear-a', Note: 'note-a' } }, + { fields: { Name: 'B', Status: 'keep-b', Note: 'keep-note-b' } }, + { fields: { Name: 'C', Status: 'clear-c', Note: 'note-c' } }, + ], + }); + + try { + const statusFieldId = idTable.fields.find((field) => field.name === 'Status')!.id; + const noteFieldId = idTable.fields.find((field) => field.name === 'Note')!.id; + await clearByIdWithCanary( + idTable.id, + { + viewId: idTable.views[0].id, + selection: { + recordIds: [idTable.records[0].id, idTable.records[2].id], + }, + }, + useV2 + ); + + const { data } = await getRecordsWithCanary( + idTable.id, + { viewId: idTable.views[0].id, fieldKeyType: FieldKeyType.Id }, + useV2 + ); + const fieldsById = Object.fromEntries( + data.records.map((record) => [record.id, record.fields]) + ); + expect(fieldsById[idTable.records[0].id][statusFieldId] ?? null).toBeNull(); + expect(fieldsById[idTable.records[0].id][noteFieldId] ?? null).toBeNull(); + expect(fieldsById[idTable.records[1].id][statusFieldId]).toBe('keep-b'); + expect(fieldsById[idTable.records[1].id][noteFieldId]).toBe('keep-note-b'); + expect(fieldsById[idTable.records[2].id][statusFieldId] ?? null).toBeNull(); + expect(fieldsById[idTable.records[2].id][noteFieldId] ?? null).toBeNull(); + } finally { + await permanentDeleteTable(baseId, idTable.id); + } + } + ); + + it.each(modes)( + 'delete-by-id deletes query-selected rows except excluded ids ($label)', + async ({ useV2 }) => { + const idTable = await createTable(baseId, { + name: 'delete-by-id', + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + records: [ + { fields: { Name: 'A' } }, + { fields: { Name: 'B' } }, + { fields: { Name: 'C' } }, + ], + }); + + try { + const result = await deleteByIdWithCanary( + idTable.id, + { + viewId: idTable.views[0].id, + selection: { + excludeRecordIds: [idTable.records[1].id], + }, + }, + useV2 + ); + + expect(result.data.ids).toEqual([idTable.records[0].id, idTable.records[2].id]); + const { data } = await getRecordsWithCanary( + idTable.id, + { viewId: idTable.views[0].id, fieldKeyType: FieldKeyType.Id }, + useV2 + ); + expect(data.records.map((record) => record.id)).toEqual([idTable.records[1].id]); + } finally { + await permanentDeleteTable(baseId, idTable.id); + } + } + ); + + it.each(modes)( + 'by-id mutations follow saved view filter and sort for query-scoped rows ($label)', + async ({ useV2 }) => { + const idTable = await createTable(baseId, { + name: 'by-id-saved-view-filter-sort', + fields: [ + { name: 'Name', type: FieldType.SingleLineText }, + { name: 'Value', type: FieldType.Number }, + { name: 'Marker', type: FieldType.SingleLineText }, + { name: 'Note', type: FieldType.SingleLineText }, + ], + records: [ + { fields: { Name: 'RecordA', Value: 100, Marker: 'old-a', Note: 'note-a' } }, + { fields: { Name: 'RecordB', Value: 200, Marker: 'old-b', Note: 'note-b' } }, + { fields: { Name: 'RecordC', Value: 300, Marker: 'old-c', Note: 'note-c' } }, + { fields: { Name: 'RecordD', Value: 400, Marker: 'old-d', Note: 'note-d' } }, + { fields: { Name: 'RecordE', Value: 500, Marker: 'old-e', Note: 'note-e' } }, + ], + }); + + try { + const viewId = idTable.views[0].id; + const nameField = idTable.fields.find((field) => field.name === 'Name')!; + const valueField = idTable.fields.find((field) => field.name === 'Value')!; + const markerField = idTable.fields.find((field) => field.name === 'Marker')!; + const noteField = idTable.fields.find((field) => field.name === 'Note')!; + + await updateViewSort(idTable.id, viewId, { + sort: { + sortObjs: [{ fieldId: valueField.id, order: SortFunc.Desc }], + manualSort: false, + }, + }); + await updateViewFilter(idTable.id, viewId, { + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: valueField.id, + operator: 'isGreaterEqual', + value: 200, + }, + ], + }, + }); + + const visibleBefore = await getRecordsWithCanary( + idTable.id, + { viewId, fieldKeyType: FieldKeyType.Id }, + useV2 + ); + expect(visibleBefore.data.records.map((record) => record.fields[nameField.id])).toEqual([ + 'RecordE', + 'RecordD', + 'RecordC', + 'RecordB', + ]); + + const pasteResult = await pasteByIdWithCanary( + idTable.id, + { + viewId, + selection: { + fieldIds: [markerField.id], + }, + content: 'paste-e\npaste-d\npaste-c\npaste-b', + }, + useV2 + ); + expect(pasteResult.data.selection.recordIds).toEqual( + visibleBefore.data.records.map((record) => record.id) + ); + + await clearByIdWithCanary( + idTable.id, + { + viewId, + selection: { + fieldIds: [noteField.id], + }, + }, + useV2 + ); + + const recordDId = idTable.records.find((record) => record.fields.Name === 'RecordD')!.id; + const deleteResult = await deleteByIdWithCanary( + idTable.id, + { + viewId, + selection: { + excludeRecordIds: [recordDId], + }, + }, + useV2 + ); + expect(deleteResult.data.ids).toEqual( + visibleBefore.data.records + .filter((record) => record.id !== recordDId) + .map((record) => record.id) + ); + + const recordsAfter = await getRecordsWithCanary( + idTable.id, + { fieldKeyType: FieldKeyType.Id, ignoreViewQuery: true }, + useV2 + ); + const fieldsByName = Object.fromEntries( + recordsAfter.data.records.map((record) => [record.fields[nameField.id], record.fields]) + ); + expect(Object.keys(fieldsByName)).toEqual(['RecordA', 'RecordD']); + expect(fieldsByName.RecordA[markerField.id]).toBe('old-a'); + expect(fieldsByName.RecordA[noteField.id]).toBe('note-a'); + expect(fieldsByName.RecordD[markerField.id]).toBe('paste-d'); + expect(fieldsByName.RecordD[noteField.id] ?? null).toBeNull(); + } finally { + await permanentDeleteTable(baseId, idTable.id); + } + } + ); + + it.each(modes)( + 'by-id mutations respect search hide-not-match query scope ($label)', + async ({ useV2 }) => { + const idTable = await createTable(baseId, { + name: 'by-id-search-hide-not-match', + fields: [ + { name: 'Name', type: FieldType.SingleLineText }, + { name: 'Marker', type: FieldType.SingleLineText }, + { name: 'Note', type: FieldType.SingleLineText }, + ], + records: [ + { fields: { Name: 'Alpha', Marker: 'old-alpha', Note: 'note-alpha' } }, + { fields: { Name: 'target-one', Marker: 'old-one', Note: 'note-one' } }, + { fields: { Name: 'Bravo', Marker: 'old-bravo', Note: 'note-bravo' } }, + { fields: { Name: 'target-two', Marker: 'old-two', Note: 'note-two' } }, + { fields: { Name: 'Charlie', Marker: 'old-charlie', Note: 'note-charlie' } }, + ], + }); + + try { + const viewId = idTable.views[0].id; + const nameField = idTable.fields.find((field) => field.name === 'Name')!; + const markerField = idTable.fields.find((field) => field.name === 'Marker')!; + const noteField = idTable.fields.find((field) => field.name === 'Note')!; + const targetTwoId = idTable.records.find((record) => { + return record.fields.Name === 'target-two'; + })!.id; + + const pasteResult = await pasteByIdWithCanary( + idTable.id, + { + viewId, + search: ['target', '', true], + selection: { + fieldIds: [markerField.id], + }, + content: 'paste-one\npaste-two', + }, + useV2 + ); + expect(pasteResult.data.selection.recordIds).toEqual([ + idTable.records[1].id, + idTable.records[3].id, + ]); + + await clearByIdWithCanary( + idTable.id, + { + viewId, + search: ['target', '', true], + selection: { + fieldIds: [noteField.id], + }, + }, + useV2 + ); + + const deleteResult = await deleteByIdWithCanary( + idTable.id, + { + viewId, + search: ['target', '', true], + selection: { + excludeRecordIds: [targetTwoId], + }, + }, + useV2 + ); + expect(deleteResult.data.ids).toEqual([idTable.records[1].id]); + + const recordsAfter = await getRecordsWithCanary( + idTable.id, + { fieldKeyType: FieldKeyType.Id, ignoreViewQuery: true }, + useV2 + ); + const fieldsByName = Object.fromEntries( + recordsAfter.data.records.map((record) => [record.fields[nameField.id], record.fields]) + ); + expect(Object.keys(fieldsByName)).toEqual(['Alpha', 'Bravo', 'target-two', 'Charlie']); + expect(fieldsByName.Alpha[markerField.id]).toBe('old-alpha'); + expect(fieldsByName.Alpha[noteField.id]).toBe('note-alpha'); + expect(fieldsByName.Bravo[markerField.id]).toBe('old-bravo'); + expect(fieldsByName.Bravo[noteField.id]).toBe('note-bravo'); + expect(fieldsByName['target-two'][markerField.id]).toBe('paste-two'); + expect(fieldsByName['target-two'][noteField.id] ?? null).toBeNull(); + expect(fieldsByName.Charlie[markerField.id]).toBe('old-charlie'); + expect(fieldsByName.Charlie[noteField.id]).toBe('note-charlie'); + } finally { + await permanentDeleteTable(baseId, idTable.id); + } + } + ); + + it.each(modes)( + 'paste-by-id follows saved grouped and sorted view order for query-scoped rows ($label)', + async ({ useV2 }) => { + const idTable = await createTable(baseId, { + name: 'paste-by-id-saved-view-matrix', + fields: [ + { name: 'Title', type: FieldType.SingleLineText }, + { + name: 'Status', + type: FieldType.SingleSelect, + options: { + choices: [ + { name: 'GroupA', color: Colors.Blue }, + { name: 'GroupB', color: Colors.Green }, + ], + }, + }, + { name: 'Marker', type: FieldType.SingleLineText }, + ], + records: [ + { fields: { Title: 'A-01', Status: 'GroupA', Marker: 'old-a01' } }, + { fields: { Title: 'A-02', Status: 'GroupA', Marker: 'old-a02' } }, + { fields: { Title: 'B-01', Status: 'GroupB', Marker: 'old-b01' } }, + { fields: { Title: 'B-02', Status: 'GroupB', Marker: 'old-b02' } }, + ], + }); + + try { + const viewId = idTable.views[0].id; + const titleField = idTable.fields.find((field) => field.name === 'Title')!; + const statusField = idTable.fields.find((field) => field.name === 'Status')!; + const markerField = idTable.fields.find((field) => field.name === 'Marker')!; + + await updateViewGroup(idTable.id, viewId, { + group: [{ fieldId: statusField.id, order: SortFunc.Asc }], + }); + await updateViewSort(idTable.id, viewId, { + sort: { + sortObjs: [{ fieldId: titleField.id, order: SortFunc.Desc }], + manualSort: false, + }, + }); + + const visibleBefore = await getRecordsWithCanary( + idTable.id, + { viewId, fieldKeyType: FieldKeyType.Id }, + useV2 + ); + const visibleTitles = visibleBefore.data.records.map( + (record) => record.fields[titleField.id] as string + ); + expect(visibleTitles).toEqual(expect.arrayContaining(['A-01', 'A-02', 'B-01', 'B-02'])); + expect(visibleTitles).toHaveLength(4); + + const result = await pasteByIdWithCanary( + idTable.id, + { + viewId, + selection: { + fieldIds: [markerField.id], + }, + content: visibleTitles.map((title) => `paste-${title.toLowerCase()}`).join('\n'), + }, + useV2 + ); + + expect(result.data.selection.recordIds).toEqual( + visibleBefore.data.records.map((record) => record.id) + ); + expect(result.data.selection.fieldIds).toEqual([markerField.id]); + + const allRecords = await getRecordsWithCanary( + idTable.id, + { fieldKeyType: FieldKeyType.Id, ignoreViewQuery: true }, + useV2 + ); + const markerByTitle = Object.fromEntries( + allRecords.data.records.map((record) => [ + record.fields[titleField.id], + record.fields[markerField.id], + ]) + ); + visibleTitles.forEach((title) => { + expect(markerByTitle[title]).toBe(`paste-${title.toLowerCase()}`); + }); + } finally { + await permanentDeleteTable(baseId, idTable.id); + } + } + ); + + it.each(modes)( + 'paste-by-id follows personal grouped query and collapsed groups for query-scoped rows ($label)', + async ({ useV2 }) => { + const idTable = await createTable(baseId, { + name: 'paste-by-id-personal-view-matrix', + fields: [ + { name: 'Title', type: FieldType.SingleLineText }, + { + name: 'Status', + type: FieldType.SingleSelect, + options: { + choices: [ + { name: 'GroupA', color: Colors.Blue }, + { name: 'GroupB', color: Colors.Green }, + ], + }, + }, + { name: 'Marker', type: FieldType.SingleLineText }, + ], + records: [ + { fields: { Title: 'A-01', Status: 'GroupA', Marker: 'old-a01' } }, + { fields: { Title: 'A-02', Status: 'GroupA', Marker: 'old-a02' } }, + { fields: { Title: 'B-01', Status: 'GroupB', Marker: 'old-b01' } }, + { fields: { Title: 'B-02', Status: 'GroupB', Marker: 'old-b02' } }, + ], + }); + + try { + const viewId = idTable.views[0].id; + const titleField = idTable.fields.find((field) => field.name === 'Title')!; + const statusField = idTable.fields.find((field) => field.name === 'Status')!; + const markerField = idTable.fields.find((field) => field.name === 'Marker')!; + + await updateViewSort(idTable.id, viewId, { + sort: { + sortObjs: [{ fieldId: titleField.id, order: SortFunc.Desc }], + manualSort: false, + }, + }); + await updateViewFilter(idTable.id, viewId, { + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: statusField.id, + operator: 'is', + value: 'GroupA', + }, + ], + }, + }); + + const personalFilter: NonNullable = { + conjunction: 'and', + filterSet: [ + { + fieldId: statusField.id, + operator: 'isAnyOf', + value: ['GroupA', 'GroupB'], + }, + ], + }; + const personalGroupBy = [{ fieldId: statusField.id, order: SortFunc.Asc }] as const; + const personalOrderBy = [{ fieldId: titleField.id, order: SortFunc.Asc }] as const; + + const groupedResult = await getRecordsWithCanary( + idTable.id, + { + viewId, + ignoreViewQuery: true, + filter: personalFilter, + groupBy: [...personalGroupBy], + orderBy: [...personalOrderBy], + fieldKeyType: FieldKeyType.Id, + includeQueryExtra: true, + }, + useV2 + ); + expect(groupedResult.data.records.map((record) => record.fields[titleField.id])).toEqual([ + 'A-01', + 'A-02', + 'B-01', + 'B-02', + ]); + const firstGroupHeader = groupedResult.data.extra?.groupPoints?.find( + (point) => point.type === 0 && 'id' in point + ); + expect(firstGroupHeader).toBeDefined(); + const collapsedGroupIds = [(firstGroupHeader as { id: string }).id]; + + const result = await pasteByIdWithCanary( + idTable.id, + { + viewId, + ignoreViewQuery: true, + filter: personalFilter, + groupBy: [...personalGroupBy], + orderBy: [...personalOrderBy], + collapsedGroupIds, + selection: { + fieldIds: [markerField.id], + }, + content: 'paste-b01\npaste-b02', + }, + useV2 + ); + + const allRecords = await getRecordsWithCanary( + idTable.id, + { fieldKeyType: FieldKeyType.Id, ignoreViewQuery: true }, + useV2 + ); + const markerByTitle = Object.fromEntries( + allRecords.data.records.map((record) => [ + record.fields[titleField.id], + record.fields[markerField.id], + ]) + ); + expect(result.data.selection.recordIds).toHaveLength(2); + expect(markerByTitle).toMatchObject({ + 'A-01': 'old-a01', + 'A-02': 'old-a02', + 'B-01': 'paste-b01', + 'B-02': 'paste-b02', + }); + } finally { + await permanentDeleteTable(baseId, idTable.id); + } + } + ); + + it.each(modes)( + 'clear-by-id uses personal projection over saved hidden fields for query-scoped fields ($label)', + async ({ useV2 }) => { + const idTable = await createTable(baseId, { + name: 'clear-by-id-personal-projection-matrix', + fields: [ + { name: 'Title', type: FieldType.SingleLineText }, + { + name: 'Status', + type: FieldType.SingleSelect, + options: { + choices: [ + { name: 'GroupA', color: Colors.Blue }, + { name: 'GroupB', color: Colors.Green }, + ], + }, + }, + { name: 'Marker', type: FieldType.SingleLineText }, + { name: 'PersonalOnly', type: FieldType.SingleLineText }, + ], + records: [ + { + fields: { + Title: 'A-01', + Status: 'GroupA', + Marker: 'marker-a01', + PersonalOnly: 'personal-a01', + }, + }, + { + fields: { + Title: 'A-02', + Status: 'GroupA', + Marker: 'marker-a02', + PersonalOnly: 'personal-a02', + }, + }, + { + fields: { + Title: 'B-01', + Status: 'GroupB', + Marker: 'marker-b01', + PersonalOnly: 'personal-b01', + }, + }, + { + fields: { + Title: 'B-02', + Status: 'GroupB', + Marker: 'marker-b02', + PersonalOnly: 'personal-b02', + }, + }, + ], + }); + + try { + const viewId = idTable.views[0].id; + const titleField = idTable.fields.find((field) => field.name === 'Title')!; + const statusField = idTable.fields.find((field) => field.name === 'Status')!; + const markerField = idTable.fields.find((field) => field.name === 'Marker')!; + const personalField = idTable.fields.find((field) => field.name === 'PersonalOnly')!; + + await updateViewColumnMeta(idTable.id, viewId, [ + { + fieldId: personalField.id, + columnMeta: { + hidden: true, + }, + }, + ]); + await updateViewFilter(idTable.id, viewId, { + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: statusField.id, + operator: 'is', + value: 'GroupA', + }, + ], + }, + }); + + const personalFilter: NonNullable = { + conjunction: 'and', + filterSet: [ + { + fieldId: statusField.id, + operator: 'isAnyOf', + value: ['GroupA', 'GroupB'], + }, + ], + }; + const personalGroupBy = [{ fieldId: statusField.id, order: SortFunc.Asc }] as const; + const personalOrderBy = [{ fieldId: titleField.id, order: SortFunc.Asc }] as const; + const personalProjection = [personalField.id, markerField.id]; + + const groupedResult = await getRecordsWithCanary( + idTable.id, + { + viewId, + ignoreViewQuery: true, + filter: personalFilter, + groupBy: [...personalGroupBy], + orderBy: [...personalOrderBy], + projection: personalProjection, + fieldKeyType: FieldKeyType.Id, + includeQueryExtra: true, + }, + useV2 + ); + const firstGroupHeader = groupedResult.data.extra?.groupPoints?.find( + (point) => point.type === 0 && 'id' in point + ); + expect(firstGroupHeader).toBeDefined(); + const collapsedGroupIds = [(firstGroupHeader as { id: string }).id]; + + await clearByIdWithCanary( + idTable.id, + { + viewId, + ignoreViewQuery: true, + filter: personalFilter, + groupBy: [...personalGroupBy], + orderBy: [...personalOrderBy], + projection: personalProjection, + collapsedGroupIds, + selection: {}, + }, + useV2 + ); + + const allRecords = await getRecordsWithCanary( + idTable.id, + { fieldKeyType: FieldKeyType.Id, ignoreViewQuery: true }, + useV2 + ); + const fieldsByTitle = Object.fromEntries( + allRecords.data.records.map((record) => [record.fields[titleField.id], record.fields]) + ); + expect(fieldsByTitle['A-01'][statusField.id]).toBe('GroupA'); + expect(fieldsByTitle['A-01'][markerField.id]).toBe('marker-a01'); + expect(fieldsByTitle['A-01'][personalField.id]).toBe('personal-a01'); + expect(fieldsByTitle['A-02'][markerField.id]).toBe('marker-a02'); + expect(fieldsByTitle['A-02'][personalField.id]).toBe('personal-a02'); + expect(fieldsByTitle['B-01'][statusField.id]).toBe('GroupB'); + expect(fieldsByTitle['B-01'][markerField.id] ?? null).toBeNull(); + expect(fieldsByTitle['B-01'][personalField.id] ?? null).toBeNull(); + expect(fieldsByTitle['B-02'][markerField.id] ?? null).toBeNull(); + expect(fieldsByTitle['B-02'][personalField.id] ?? null).toBeNull(); + } finally { + await permanentDeleteTable(baseId, idTable.id); + } + } + ); + + it.each(modes)( + 'delete-by-id uses personal grouped query and collapsed groups for query-scoped rows ($label)', + async ({ useV2 }) => { + const idTable = await createTable(baseId, { + name: 'delete-by-id-personal-view-matrix', + fields: [ + { name: 'Title', type: FieldType.SingleLineText }, + { + name: 'Status', + type: FieldType.SingleSelect, + options: { + choices: [ + { name: 'GroupA', color: Colors.Blue }, + { name: 'GroupB', color: Colors.Green }, + ], + }, + }, + ], + records: [ + { fields: { Title: 'A-01', Status: 'GroupA' } }, + { fields: { Title: 'A-02', Status: 'GroupA' } }, + { fields: { Title: 'B-01', Status: 'GroupB' } }, + { fields: { Title: 'B-02', Status: 'GroupB' } }, + ], + }); + + try { + const viewId = idTable.views[0].id; + const titleField = idTable.fields.find((field) => field.name === 'Title')!; + const statusField = idTable.fields.find((field) => field.name === 'Status')!; + + await updateViewFilter(idTable.id, viewId, { + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: statusField.id, + operator: 'is', + value: 'GroupA', + }, + ], + }, + }); + + const personalFilter: NonNullable = { + conjunction: 'and', + filterSet: [ + { + fieldId: statusField.id, + operator: 'isAnyOf', + value: ['GroupA', 'GroupB'], + }, + ], + }; + const personalGroupBy = [{ fieldId: statusField.id, order: SortFunc.Asc }] as const; + const personalOrderBy = [{ fieldId: titleField.id, order: SortFunc.Asc }] as const; + const groupedResult = await getRecordsWithCanary( + idTable.id, + { + viewId, + ignoreViewQuery: true, + filter: personalFilter, + groupBy: [...personalGroupBy], + orderBy: [...personalOrderBy], + fieldKeyType: FieldKeyType.Id, + includeQueryExtra: true, + }, + useV2 + ); + const firstGroupHeader = groupedResult.data.extra?.groupPoints?.find( + (point) => point.type === 0 && 'id' in point + ); + expect(firstGroupHeader).toBeDefined(); + const collapsedGroupIds = [(firstGroupHeader as { id: string }).id]; + const b02RecordId = idTable.records.find((record) => { + return record.fields.Title === 'B-02'; + })!.id; + + const result = await deleteByIdWithCanary( + idTable.id, + { + viewId, + ignoreViewQuery: true, + filter: personalFilter, + groupBy: [...personalGroupBy], + orderBy: [...personalOrderBy], + collapsedGroupIds, + selection: { + excludeRecordIds: [b02RecordId], + }, + }, + useV2 + ); + + expect(result.data.ids).toHaveLength(1); + const recordsAfter = await getRecordsWithCanary( + idTable.id, + { fieldKeyType: FieldKeyType.Id, ignoreViewQuery: true }, + useV2 + ); + expect(recordsAfter.data.records.map((record) => record.fields[titleField.id])).toEqual([ + 'A-01', + 'A-02', + 'B-02', + ]); + } finally { + await permanentDeleteTable(baseId, idTable.id); + } + } + ); + + it.each(modes)( + 'delete-by-id deletes the whole table when using query scope without exclusions ($label)', + async ({ useV2 }) => { + const idTable = await createTable(baseId, { + name: 'delete-by-id-all', + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + records: [ + { fields: { Name: 'A' } }, + { fields: { Name: 'B' } }, + { fields: { Name: 'C' } }, + ], + }); + + try { + const result = await deleteByIdWithCanary( + idTable.id, + { + viewId: idTable.views[0].id, + selection: {}, + }, + useV2 + ); + + expect(result.data.ids).toEqual(idTable.records.map((record) => record.id)); + const { data } = await getRecordsWithCanary( + idTable.id, + { viewId: idTable.views[0].id, fieldKeyType: FieldKeyType.Id }, + useV2 + ); + expect(data.records).toHaveLength(0); + } finally { + await permanentDeleteTable(baseId, idTable.id); + } + } + ); + }); + + describe('api/table/:tableId/selection/delete-stream (SSE)', () => { + it('should stream v2 delete progress and return the deleted ids', async () => { + const streamTable = await createTable(baseId, { + name: 'delete-stream', + fields: [ + { name: 'name', type: FieldType.SingleLineText }, + { name: 'number', type: FieldType.Number }, + ], + records: [ + { fields: { name: 'stream-1', number: 1 } }, + { fields: { name: 'stream-2', number: 2 } }, + { fields: { name: 'stream-3', number: 3 } }, + ], + }); + + try { + const { progressEvents, doneEvent, errorEvents } = await deleteStreamWithCanary( + streamTable.id, + { + viewId: streamTable.views[0].id, + type: RangeType.Rows, + ranges: [[0, 2]], + }, + true + ); + + expect(errorEvents).toHaveLength(0); + expect(doneEvent?.data.deletedRecordIds).toEqual( + streamTable.records.map((record) => record.id) + ); + expect(progressEvents.some((event) => event.totalCount === 3)).toBe(true); + expect(progressEvents.some((event) => event.deletedCount > 0)).toBe(true); + + const recordsAfter = await getRecords(streamTable.id, { + fieldKeyType: FieldKeyType.Id, + }); + expect(recordsAfter.data.records).toHaveLength(0); + } finally { + await permanentDeleteTable(baseId, streamTable.id); + } + }); + + it('should expose stream response headers for v2 delete', async () => { + const streamTable = await createTable(baseId, { + name: 'delete-stream-headers', + fields: [{ name: 'name', type: FieldType.SingleLineText }], + records: [{ fields: { name: 'stream-headers-1' } }], + }); + + try { + const { headers } = await deleteStreamWithCanary( + streamTable.id, + { + viewId: streamTable.views[0].id, + type: RangeType.Rows, + ranges: [[0, 0]], + }, + true + ); + + expect(headers.contentType).toContain('text/event-stream'); + expect(headers.xAccelBuffering).toBe('no'); + expect(headers.xTeableV2).toBe('true'); + expect(headers.xTeableV2Feature).toBe('deleteRecord'); + } finally { + await permanentDeleteTable(baseId, streamTable.id); + } + }); + + it('should allow delete-stream when v2 canary is disabled and fall back to v1 synchronous delete', async () => { + const streamTable = await createTable(baseId, { + name: 'delete-stream-v1-fallback', + fields: [ + { name: 'name', type: FieldType.SingleLineText }, + { name: 'number', type: FieldType.Number }, + ], + records: [ + { fields: { name: 'stream-v1-1', number: 1 } }, + { fields: { name: 'stream-v1-2', number: 2 } }, + { fields: { name: 'stream-v1-3', number: 3 } }, + ], + }); + + try { + const { progressEvents, doneEvent, errorEvents } = await deleteStreamWithCanary( + streamTable.id, + { + viewId: streamTable.views[0].id, + type: RangeType.Rows, + ranges: [[0, 2]], + }, + false + ); + + expect(errorEvents).toHaveLength(0); + expect(progressEvents.length).toBeGreaterThan(0); + expect(progressEvents[0]).toMatchObject({ + phase: 'preparing', + deletedCount: 0, + }); + expect(progressEvents.at(-1)).toMatchObject({ + totalCount: 3, + }); + expect(doneEvent?.data.deletedRecordIds).toEqual( + streamTable.records.map((record) => record.id) + ); + expect(doneEvent?.deletedCount).toBe(3); + + const recordsAfter = await getRecords(streamTable.id, { + fieldKeyType: FieldKeyType.Id, + }); + expect(recordsAfter.data.records).toHaveLength(0); + } finally { + await permanentDeleteTable(baseId, streamTable.id); + } + }); + + it('should keep streaming after chunk error events and still deliver the final done event', async () => { + const streamTable = await createTable(baseId, { + name: 'delete-stream-partial-error', + fields: [ + { name: 'name', type: FieldType.SingleLineText }, + { name: 'number', type: FieldType.Number }, + ], + records: [ + { fields: { name: 'stream-error-1', number: 1 } }, + { fields: { name: 'stream-error-2', number: 2 } }, + { fields: { name: 'stream-error-3', number: 3 } }, + ], + }); + + const recordOpenApiV2Service = app.get(RecordOpenApiV2Service); + const deleteByRangeStreamSpy = vi + .spyOn(recordOpenApiV2Service, 'deleteByRangeStream') + .mockImplementation(async () => { + return (async function* () { + yield { + id: 'progress', + phase: 'deleting', + batchIndex: 0, + totalCount: 3, + deletedCount: 1, + batchDeletedCount: 1, + }; + yield { + id: 'error', + phase: 'deleting', + batchIndex: 1, + totalCount: 3, + deletedCount: 1, + recordIds: [streamTable.records[1]!.id], + message: 'chunk 2 failed', + code: 'unexpected', + }; + yield { + id: 'done', + totalCount: 3, + deletedCount: 2, + data: { + deletedCount: 2, + deletedRecordIds: [streamTable.records[0]!.id, streamTable.records[2]!.id], + }, + }; + })(); + }); + + try { + const { progressEvents, doneEvent, errorEvents } = await deleteStreamWithCanary( + streamTable.id, + { + viewId: streamTable.views[0].id, + type: RangeType.Rows, + ranges: [[0, 2]], + }, + true + ); + + expect(progressEvents).toHaveLength(1); + expect(errorEvents).toEqual([ + { + id: 'error', + message: 'chunk 2 failed', + batchIndex: 1, + phase: 'deleting', + recordIds: [streamTable.records[1]!.id], + }, + ]); + expect(doneEvent).toMatchObject({ + id: 'done', + deletedCount: 2, + data: { + deletedRecordIds: [streamTable.records[0]!.id, streamTable.records[2]!.id], + }, + }); + } finally { + deleteByRangeStreamSpy.mockRestore(); + await permanentDeleteTable(baseId, streamTable.id); + } + }); + }); + + describe('api/table/:tableId/selection/clear-stream (SSE)', () => { + it('should stream v2 clear progress and clear the selected cells', async () => { + const streamTable = await createTable(baseId, { + name: 'clear-stream', + fields: [ + { name: 'name', type: FieldType.SingleLineText }, + { name: 'number', type: FieldType.Number }, + ], + records: [ + { fields: { name: 'stream-1', number: 1 } }, + { fields: { name: 'stream-2', number: 2 } }, + { fields: { name: 'stream-3', number: 3 } }, + ], + }); + + try { + const nameFieldId = streamTable.fields.find((field) => field.name === 'name')!.id; + const numberFieldId = streamTable.fields.find((field) => field.name === 'number')!.id; + + const { progressEvents, doneEvent, errorEvents } = await clearStreamWithCanary( streamTable.id, { viewId: streamTable.views[0].id, type: RangeType.Rows, - ranges: [[0, 0]], + ranges: [[0, 2]], }, true ); - expect(headers.contentType).toContain('text/event-stream'); - expect(headers.xAccelBuffering).toBe('no'); - expect(headers.xTeableV2).toBe('true'); - expect(headers.xTeableV2Feature).toBe('deleteRecord'); + expect(errorEvents).toHaveLength(0); + expect(doneEvent?.processedCount).toBe(3); + expect(doneEvent?.clearedCount).toBe(3); + expect(progressEvents.some((event) => event.totalCount === 3)).toBe(true); + expect(progressEvents.some((event) => event.clearedCount > 0)).toBe(true); + + const recordsAfter = await getRecords(streamTable.id, { + fieldKeyType: FieldKeyType.Id, + }); + expect(recordsAfter.data.records).toHaveLength(3); + expect(recordsAfter.data.records.map((record) => record.fields[nameFieldId])).toEqual([ + undefined, + undefined, + undefined, + ]); + expect(recordsAfter.data.records.map((record) => record.fields[numberFieldId])).toEqual([ + undefined, + undefined, + undefined, + ]); } finally { await permanentDeleteTable(baseId, streamTable.id); } }); - it('should allow delete-stream when v2 canary is disabled and fall back to v1 synchronous delete', async () => { + it('should allow clear-stream when v2 canary is disabled and fall back to v1 clear', async () => { const streamTable = await createTable(baseId, { - name: 'delete-stream-v1-fallback', + name: 'clear-stream-v1-fallback', fields: [ { name: 'name', type: FieldType.SingleLineText }, { name: 'number', type: FieldType.Number }, @@ -2249,502 +3978,586 @@ describe('OpenAPI SelectionController (e2e)', () => { records: [ { fields: { name: 'stream-v1-1', number: 1 } }, { fields: { name: 'stream-v1-2', number: 2 } }, - { fields: { name: 'stream-v1-3', number: 3 } }, ], }); try { - const { progressEvents, doneEvent, errorEvents } = await deleteStreamWithCanary( + const nameFieldId = streamTable.fields.find((field) => field.name === 'name')!.id; + + const { progressEvents, doneEvent, errorEvents } = await clearStreamWithCanary( streamTable.id, { viewId: streamTable.views[0].id, type: RangeType.Rows, - ranges: [[0, 2]], + ranges: [[0, 1]], + }, + false + ); + + expect(errorEvents).toHaveLength(0); + expect(progressEvents[0]).toMatchObject({ + phase: 'preparing', + processedCount: 0, + }); + expect(doneEvent?.processedCount).toBe(2); + expect(doneEvent?.clearedCount).toBe(2); + + const recordsAfter = await getRecords(streamTable.id, { + fieldKeyType: FieldKeyType.Id, + }); + expect(recordsAfter.data.records.map((record) => record.fields[nameFieldId])).toEqual([ + undefined, + undefined, + ]); + } finally { + await permanentDeleteTable(baseId, streamTable.id); + } + }); + }); + + describe('api/table/:tableId/selection/duplicate-stream (SSE)', () => { + it('should stream v2 duplicate progress and return the duplicated ids', async () => { + const streamTable = await createTable(baseId, { + name: 'duplicate-stream', + fields: [ + { name: 'name', type: FieldType.SingleLineText }, + { name: 'number', type: FieldType.Number }, + ], + records: [ + { fields: { name: 'stream-1', number: 1 } }, + { fields: { name: 'stream-2', number: 2 } }, + ], + }); + + try { + const { progressEvents, doneEvent, errorEvents } = await duplicateStreamWithCanary( + streamTable.id, + { + viewId: streamTable.views[0].id, + type: RangeType.Rows, + ranges: [[0, 1]], + }, + true + ); + + expect(errorEvents).toHaveLength(0); + expect(doneEvent?.duplicatedCount).toBe(2); + expect(doneEvent?.data.duplicatedRecordIds).toHaveLength(2); + expect(progressEvents.some((event) => event.totalCount === 2)).toBe(true); + expect(progressEvents.some((event) => event.duplicatedCount > 0)).toBe(true); + + const recordsAfter = await getRecords(streamTable.id, { + fieldKeyType: FieldKeyType.Id, + }); + expect(recordsAfter.data.records).toHaveLength(4); + } finally { + await permanentDeleteTable(baseId, streamTable.id); + } + }); + + it('should allow duplicate-stream when v2 canary is disabled and fall back to v1 duplication', async () => { + const streamTable = await createTable(baseId, { + name: 'duplicate-stream-v1-fallback', + fields: [ + { name: 'name', type: FieldType.SingleLineText }, + { name: 'number', type: FieldType.Number }, + ], + records: [ + { fields: { name: 'stream-v1-1', number: 1 } }, + { fields: { name: 'stream-v1-2', number: 2 } }, + ], + }); + + try { + const { progressEvents, doneEvent, errorEvents } = await duplicateStreamWithCanary( + streamTable.id, + { + viewId: streamTable.views[0].id, + type: RangeType.Rows, + ranges: [[0, 1]], + }, + false + ); + + expect(errorEvents).toHaveLength(0); + expect(progressEvents.length).toBeGreaterThan(0); + expect(progressEvents[0]).toMatchObject({ + phase: 'preparing', + duplicatedCount: 0, + }); + expect(doneEvent?.duplicatedCount).toBe(2); + + const recordsAfter = await getRecords(streamTable.id, { + fieldKeyType: FieldKeyType.Id, + }); + expect(recordsAfter.data.records).toHaveLength(4); + } finally { + await permanentDeleteTable(baseId, streamTable.id); + } + }); + }); + + describe('api/table/:tableId/selection/paste-stream (SSE)', () => { + it('should stream v2 paste progress and return the created ids', async () => { + const streamTable = await createTable(baseId, { + name: 'paste-stream', + fields: [ + { name: 'name', type: FieldType.SingleLineText }, + { name: 'number', type: FieldType.Number }, + ], + records: [ + { fields: { name: 'stream-1', number: 1 } }, + { fields: { name: 'stream-2', number: 2 } }, + ], + }); + + try { + const nameFieldId = streamTable.fields.find((field) => field.name === 'name')!.id; + + const { progressEvents, doneEvent, errorEvents } = await pasteStreamWithCanary( + streamTable.id, + { + viewId: streamTable.views[0].id, + ranges: [ + [0, 0], + [1, 2], + ], + content: [ + ['updated-1', 11], + ['updated-2', 22], + ['created-3', 33], + ], + }, + true + ); + + expect(errorEvents).toHaveLength(0); + expect(doneEvent?.processedCount).toBe(3); + expect(doneEvent?.updatedCount).toBe(2); + expect(doneEvent?.createdCount).toBe(1); + expect(doneEvent?.data.createdRecordIds).toHaveLength(1); + expect(progressEvents.some((event) => event.totalCount === 3)).toBe(true); + expect(progressEvents.some((event) => event.processedCount > 0)).toBe(true); + + const recordsAfter = await getRecords(streamTable.id, { + fieldKeyType: FieldKeyType.Id, + }); + expect(recordsAfter.data.records).toHaveLength(3); + expect(recordsAfter.data.records.map((record) => record.fields[nameFieldId])).toEqual([ + 'updated-1', + 'updated-2', + 'created-3', + ]); + } finally { + await permanentDeleteTable(baseId, streamTable.id); + } + }); + + it('T3238 should not create rows when v2 paste-stream updates records out of the filtered view', async () => { + const rowCount = 600; + const createBatchSize = 200; + const streamTable = await createTable(baseId, { + name: 'T3238 paste-stream filtered empty rows', + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + records: [], + }); + + try { + const viewId = streamTable.views[0].id; + const nameFieldId = streamTable.fields.find((field) => field.name === 'Name')!.id; + + for (let offset = 0; offset < rowCount; offset += createBatchSize) { + await createRecords(streamTable.id, { + records: Array.from({ length: Math.min(createBatchSize, rowCount - offset) }, () => ({ + fields: {}, + })), + }); + } + + await updateViewFilter(streamTable.id, viewId, { + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: nameFieldId, + operator: 'isEmpty', + value: null, + }, + ], + }, + }); + + const filteredRecordsBefore = await getRecords(streamTable.id, { + viewId, + fieldKeyType: FieldKeyType.Id, + take: rowCount, + }); + expect(filteredRecordsBefore.data.records).toHaveLength(rowCount); + + const { progressEvents, doneEvent, errorEvents } = await pasteStreamWithCanary( + streamTable.id, + { + viewId, + ranges: [ + [0, 0], + [0, rowCount - 1], + ], + content: Array.from({ length: rowCount }, (_, index) => [`T3238-${index}`]), }, - false + true ); expect(errorEvents).toHaveLength(0); - expect(progressEvents.length).toBeGreaterThan(0); - expect(progressEvents[0]).toMatchObject({ - phase: 'preparing', - deletedCount: 0, - }); - expect(progressEvents.at(-1)).toMatchObject({ - totalCount: 3, + expect(doneEvent?.processedCount).toBe(rowCount); + expect(doneEvent?.updatedCount).toBe(rowCount); + expect(doneEvent?.createdCount).toBe(0); + expect(doneEvent?.data.createdRecordIds).toHaveLength(0); + expect(progressEvents.some((event) => event.totalCount === rowCount)).toBe(true); + + const allRecordsAfter = await getRecords(streamTable.id, { + fieldKeyType: FieldKeyType.Id, + take: rowCount, }); - expect(doneEvent?.data.deletedRecordIds).toEqual( - streamTable.records.map((record) => record.id) - ); - expect(doneEvent?.deletedCount).toBe(3); + expect(allRecordsAfter.data.records).toHaveLength(rowCount); + expect( + new Set(allRecordsAfter.data.records.map((record) => record.fields[nameFieldId])) + ).toEqual(new Set(Array.from({ length: rowCount }, (_, index) => `T3238-${index}`))); - const recordsAfter = await getRecords(streamTable.id, { + const filteredRecordsAfter = await getRecords(streamTable.id, { + viewId, fieldKeyType: FieldKeyType.Id, + take: 1, }); - expect(recordsAfter.data.records).toHaveLength(0); + expect(filteredRecordsAfter.data.records).toHaveLength(0); } finally { await permanentDeleteTable(baseId, streamTable.id); } }); - it('should keep streaming after chunk error events and still deliver the final done event', async () => { - const streamTable = await createTable(baseId, { - name: 'delete-stream-partial-error', - fields: [ - { name: 'name', type: FieldType.SingleLineText }, - { name: 'number', type: FieldType.Number }, - ], - records: [ - { fields: { name: 'stream-error-1', number: 1 } }, - { fields: { name: 'stream-error-2', number: 2 } }, - { fields: { name: 'stream-error-3', number: 3 } }, - ], - }); + it.skipIf(isForceV2)( + 'should allow paste-stream when v2 canary is disabled and fall back to v1 paste', + async () => { + const streamTable = await createTable(baseId, { + name: 'paste-stream-v1-fallback', + fields: [ + { name: 'name', type: FieldType.SingleLineText }, + { name: 'number', type: FieldType.Number }, + ], + records: [{ fields: { name: 'stream-v1-1', number: 1 } }], + }); - const recordOpenApiV2Service = app.get(RecordOpenApiV2Service); - const deleteByRangeStreamSpy = vi - .spyOn(recordOpenApiV2Service, 'deleteByRangeStream') - .mockImplementation(async function* () { - yield { - id: 'progress', - phase: 'deleting', - batchIndex: 0, - totalCount: 3, - deletedCount: 1, - batchDeletedCount: 1, - }; - yield { - id: 'error', - phase: 'deleting', - batchIndex: 1, - totalCount: 3, - deletedCount: 1, - recordIds: [streamTable.records[1]!.id], - message: 'chunk 2 failed', - code: 'unexpected', - }; - yield { - id: 'done', - totalCount: 3, - deletedCount: 2, - data: { - deletedCount: 2, - deletedRecordIds: [streamTable.records[0]!.id, streamTable.records[2]!.id], + try { + const nameFieldId = streamTable.fields.find((field) => field.name === 'name')!.id; + + const { progressEvents, doneEvent, errorEvents } = await pasteStreamWithCanary( + streamTable.id, + { + viewId: streamTable.views[0].id, + ranges: [ + [0, 0], + [1, 1], + ], + content: [ + ['fallback-1', 11], + ['fallback-2', 22], + ], }, - }; - }); + false + ); - try { - const { progressEvents, doneEvent, errorEvents } = await deleteStreamWithCanary( - streamTable.id, - { - viewId: streamTable.views[0].id, - type: RangeType.Rows, - ranges: [[0, 2]], - }, - true - ); + expect(errorEvents).toHaveLength(0); + expect(progressEvents.length).toBeGreaterThan(0); + expect(progressEvents[0]).toMatchObject({ + phase: 'preparing', + processedCount: 0, + }); + expect(doneEvent?.processedCount).toBe(2); + expect(doneEvent?.data.ranges).toEqual([ + [0, 0], + [1, 1], + ]); - expect(progressEvents).toHaveLength(1); - expect(errorEvents).toEqual([ - { - id: 'error', - message: 'chunk 2 failed', - batchIndex: 1, - phase: 'deleting', - recordIds: [streamTable.records[1]!.id], - }, - ]); - expect(doneEvent).toMatchObject({ - id: 'done', - deletedCount: 2, - data: { - deletedRecordIds: [streamTable.records[0]!.id, streamTable.records[2]!.id], - }, - }); - } finally { - deleteByRangeStreamSpy.mockRestore(); - await permanentDeleteTable(baseId, streamTable.id); + const recordsAfter = await getRecords(streamTable.id, { + fieldKeyType: FieldKeyType.Id, + }); + expect(recordsAfter.data.records).toHaveLength(2); + expect(recordsAfter.data.records.map((record) => record.fields[nameFieldId])).toEqual([ + 'fallback-1', + 'fallback-2', + ]); + } finally { + await permanentDeleteTable(baseId, streamTable.id); + } } - }); + ); }); - describe('api/table/:tableId/selection/clear-stream (SSE)', () => { - it('should stream v2 clear progress and clear the selected cells', async () => { + describe('api/table/:tableId/selection/*-by-id-stream (SSE)', () => { + it('T5268: should preserve leading empty paste rows in by-id stream content', async () => { const streamTable = await createTable(baseId, { - name: 'clear-stream', - fields: [ - { name: 'name', type: FieldType.SingleLineText }, - { name: 'number', type: FieldType.Number }, - ], - records: [ - { fields: { name: 'stream-1', number: 1 } }, - { fields: { name: 'stream-2', number: 2 } }, - { fields: { name: 'stream-3', number: 3 } }, - ], + name: 'paste-leading-empty-row', + fields: [{ name: 'name', type: FieldType.SingleLineText }], + records: [{ fields: { name: 'old-1' } }, { fields: { name: 'old-2' } }], }); try { const nameFieldId = streamTable.fields.find((field) => field.name === 'name')!.id; - const numberFieldId = streamTable.fields.find((field) => field.name === 'number')!.id; - const { progressEvents, doneEvent, errorEvents } = await clearStreamWithCanary( + const { doneEvent, errorEvents } = await patchSelectionByIdStreamWithCanary( + PASTE_BY_ID_STREAM_URL, streamTable.id, { viewId: streamTable.views[0].id, - type: RangeType.Rows, - ranges: [[0, 2]], + selection: { + recordIds: [streamTable.records[0]!.id, streamTable.records[1]!.id], + fieldIds: [nameFieldId], + }, + content: '\nnew-2', }, true ); expect(errorEvents).toHaveLength(0); - expect(doneEvent?.processedCount).toBe(3); - expect(doneEvent?.clearedCount).toBe(3); - expect(progressEvents.some((event) => event.totalCount === 3)).toBe(true); - expect(progressEvents.some((event) => event.clearedCount > 0)).toBe(true); + expect(doneEvent).toMatchObject({ processedCount: 2, updatedCount: 2 }); const recordsAfter = await getRecords(streamTable.id, { fieldKeyType: FieldKeyType.Id, }); - expect(recordsAfter.data.records).toHaveLength(3); expect(recordsAfter.data.records.map((record) => record.fields[nameFieldId])).toEqual([ - undefined, - undefined, - undefined, - ]); - expect(recordsAfter.data.records.map((record) => record.fields[numberFieldId])).toEqual([ - undefined, - undefined, - undefined, + '', + 'new-2', ]); } finally { await permanentDeleteTable(baseId, streamTable.id); } }); - it('should allow clear-stream when v2 canary is disabled and fall back to v1 clear', async () => { + it('T5265: should clear large by-id stream selections without falling back to clear-by-id', async () => { const streamTable = await createTable(baseId, { - name: 'clear-stream-v1-fallback', - fields: [ - { name: 'name', type: FieldType.SingleLineText }, - { name: 'number', type: FieldType.Number }, - ], - records: [ - { fields: { name: 'stream-v1-1', number: 1 } }, - { fields: { name: 'stream-v1-2', number: 2 } }, - ], + name: 'clear-by-id-stream-large', + fields: [{ name: 'name', type: FieldType.SingleLineText }], + records: Array.from({ length: 205 }, (_, index) => ({ + fields: { name: `row-${index}` }, + })), }); try { const nameFieldId = streamTable.fields.find((field) => field.name === 'name')!.id; - const { progressEvents, doneEvent, errorEvents } = await clearStreamWithCanary( + const { progressEvents, doneEvent, errorEvents } = await patchSelectionByIdStreamWithCanary( + CLEAR_BY_ID_STREAM_URL, streamTable.id, { viewId: streamTable.views[0].id, - type: RangeType.Rows, - ranges: [[0, 1]], + selection: { + recordIds: streamTable.records.map((record) => record.id), + fieldIds: [nameFieldId], + }, }, - false + true ); expect(errorEvents).toHaveLength(0); - expect(progressEvents[0]).toMatchObject({ - phase: 'preparing', - processedCount: 0, - }); - expect(doneEvent?.processedCount).toBe(2); - expect(doneEvent?.clearedCount).toBe(2); + expect(doneEvent).toMatchObject({ processedCount: 205, clearedCount: 205 }); + expect(progressEvents.some((event) => event.totalCount === 205)).toBe(true); const recordsAfter = await getRecords(streamTable.id, { fieldKeyType: FieldKeyType.Id, }); - expect(recordsAfter.data.records.map((record) => record.fields[nameFieldId])).toEqual([ - undefined, - undefined, - ]); + expect( + recordsAfter.data.records.every((record) => record.fields[nameFieldId] == null) + ).toBe(true); } finally { await permanentDeleteTable(baseId, streamTable.id); } }); - }); - describe('api/table/:tableId/selection/duplicate-stream (SSE)', () => { - it('should stream v2 duplicate progress and return the duplicated ids', async () => { + it('T5267: should delete stream selections after resolving unloaded row ranges to ids', async () => { const streamTable = await createTable(baseId, { - name: 'duplicate-stream', - fields: [ - { name: 'name', type: FieldType.SingleLineText }, - { name: 'number', type: FieldType.Number }, - ], - records: [ - { fields: { name: 'stream-1', number: 1 } }, - { fields: { name: 'stream-2', number: 2 } }, - ], + name: 'delete-unloaded-range-stream', + fields: [{ name: 'name', type: FieldType.SingleLineText }], + records: Array.from({ length: 205 }, (_, index) => ({ + fields: { name: `row-${index}` }, + })), }); try { - const { progressEvents, doneEvent, errorEvents } = await duplicateStreamWithCanary( + const idsData = ( + await apiGetIdsFromRanges(streamTable.id, { + viewId: streamTable.views[0].id, + ranges: [[0, 204]], + type: RangeType.Rows, + returnType: IdReturnType.RecordId, + }) + ).data; + + const { doneEvent, errorEvents } = await patchSelectionByIdStreamWithCanary( + DELETE_BY_ID_STREAM_URL, streamTable.id, { viewId: streamTable.views[0].id, - type: RangeType.Rows, - ranges: [[0, 1]], + selection: { + recordIds: idsData.recordIds, + }, }, true ); expect(errorEvents).toHaveLength(0); - expect(doneEvent?.duplicatedCount).toBe(2); - expect(doneEvent?.data.duplicatedRecordIds).toHaveLength(2); - expect(progressEvents.some((event) => event.totalCount === 2)).toBe(true); - expect(progressEvents.some((event) => event.duplicatedCount > 0)).toBe(true); + expect(doneEvent).toMatchObject({ totalCount: 205, deletedCount: 205 }); const recordsAfter = await getRecords(streamTable.id, { fieldKeyType: FieldKeyType.Id, }); - expect(recordsAfter.data.records).toHaveLength(4); + expect(recordsAfter.data.records).toHaveLength(0); } finally { await permanentDeleteTable(baseId, streamTable.id); } }); - it('should allow duplicate-stream when v2 canary is disabled and fall back to v1 duplication', async () => { + it('should clear only selected cells by record ids and field ids in v2', async () => { const streamTable = await createTable(baseId, { - name: 'duplicate-stream-v1-fallback', + name: 'clear-by-id-stream', fields: [ { name: 'name', type: FieldType.SingleLineText }, { name: 'number', type: FieldType.Number }, ], records: [ - { fields: { name: 'stream-v1-1', number: 1 } }, - { fields: { name: 'stream-v1-2', number: 2 } }, + { fields: { name: 'keep-name-1', number: 1 } }, + { fields: { name: 'clear-name-2', number: 2 } }, + { fields: { name: 'keep-name-3', number: 3 } }, ], }); try { - const { progressEvents, doneEvent, errorEvents } = await duplicateStreamWithCanary( + const nameFieldId = streamTable.fields.find((field) => field.name === 'name')!.id; + const numberFieldId = streamTable.fields.find((field) => field.name === 'number')!.id; + const targetRecordId = streamTable.records[1]!.id; + + const { progressEvents, doneEvent, errorEvents } = await patchSelectionByIdStreamWithCanary( + CLEAR_BY_ID_STREAM_URL, streamTable.id, { viewId: streamTable.views[0].id, - type: RangeType.Rows, - ranges: [[0, 1]], + selection: { + recordIds: [targetRecordId], + fieldIds: [nameFieldId], + }, }, - false + true ); - expect(errorEvents).toHaveLength(0); - expect(progressEvents.length).toBeGreaterThan(0); - expect(progressEvents[0]).toMatchObject({ - phase: 'preparing', - duplicatedCount: 0, - }); - expect(doneEvent?.duplicatedCount).toBe(2); - + expect(errorEvents).toHaveLength(0); + expect(doneEvent).toMatchObject({ processedCount: 1, clearedCount: 1 }); + expect(progressEvents.some((event) => event.totalCount === 1)).toBe(true); + const recordsAfter = await getRecords(streamTable.id, { fieldKeyType: FieldKeyType.Id, }); - expect(recordsAfter.data.records).toHaveLength(4); + expect(recordsAfter.data.records.map((record) => record.fields[nameFieldId])).toEqual([ + 'keep-name-1', + undefined, + 'keep-name-3', + ]); + expect(recordsAfter.data.records.map((record) => record.fields[numberFieldId])).toEqual([ + 1, 2, 3, + ]); } finally { await permanentDeleteTable(baseId, streamTable.id); } }); - }); - describe('api/table/:tableId/selection/paste-stream (SSE)', () => { - it('should stream v2 paste progress and return the created ids', async () => { + it('should paste multi-column content by explicit target ids and expand rows in v2', async () => { const streamTable = await createTable(baseId, { - name: 'paste-stream', + name: 'paste-by-id-stream', fields: [ { name: 'name', type: FieldType.SingleLineText }, { name: 'number', type: FieldType.Number }, ], - records: [ - { fields: { name: 'stream-1', number: 1 } }, - { fields: { name: 'stream-2', number: 2 } }, - ], + records: [{ fields: { name: 'old-1', number: 1 } }], }); try { const nameFieldId = streamTable.fields.find((field) => field.name === 'name')!.id; + const numberFieldId = streamTable.fields.find((field) => field.name === 'number')!.id; - const { progressEvents, doneEvent, errorEvents } = await pasteStreamWithCanary( + const { progressEvents, doneEvent, errorEvents } = await patchSelectionByIdStreamWithCanary( + PASTE_BY_ID_STREAM_URL, streamTable.id, { viewId: streamTable.views[0].id, - ranges: [ - [0, 0], - [1, 2], - ], + selection: { + recordIds: [streamTable.records[0]!.id], + fieldIds: [nameFieldId, numberFieldId], + }, content: [ - ['updated-1', 11], - ['updated-2', 22], - ['created-3', 33], + ['new-1', 11], + ['new-2', 22], + ['new-3', 33], ], }, true ); expect(errorEvents).toHaveLength(0); - expect(doneEvent?.processedCount).toBe(3); - expect(doneEvent?.updatedCount).toBe(2); - expect(doneEvent?.createdCount).toBe(1); - expect(doneEvent?.data.createdRecordIds).toHaveLength(1); + expect(doneEvent).toMatchObject({ processedCount: 3, updatedCount: 1, createdCount: 2 }); expect(progressEvents.some((event) => event.totalCount === 3)).toBe(true); - expect(progressEvents.some((event) => event.processedCount > 0)).toBe(true); const recordsAfter = await getRecords(streamTable.id, { fieldKeyType: FieldKeyType.Id, }); - expect(recordsAfter.data.records).toHaveLength(3); expect(recordsAfter.data.records.map((record) => record.fields[nameFieldId])).toEqual([ - 'updated-1', - 'updated-2', - 'created-3', + 'new-1', + 'new-2', + 'new-3', + ]); + expect(recordsAfter.data.records.map((record) => record.fields[numberFieldId])).toEqual([ + 11, 22, 33, ]); } finally { await permanentDeleteTable(baseId, streamTable.id); } }); - it('T3238 should not create rows when v2 paste-stream updates records out of the filtered view', async () => { - const rowCount = 600; - const createBatchSize = 200; + it('should delete all records except excluded ids without sending all row ids in v2', async () => { const streamTable = await createTable(baseId, { - name: 'T3238 paste-stream filtered empty rows', - fields: [{ name: 'Name', type: FieldType.SingleLineText }], - records: [], + name: 'delete-by-id-stream', + fields: [{ name: 'name', type: FieldType.SingleLineText }], + records: [ + { fields: { name: 'delete-1' } }, + { fields: { name: 'keep-2' } }, + { fields: { name: 'delete-3' } }, + ], }); try { - const viewId = streamTable.views[0].id; - const nameFieldId = streamTable.fields.find((field) => field.name === 'Name')!.id; - - for (let offset = 0; offset < rowCount; offset += createBatchSize) { - await createRecords(streamTable.id, { - records: Array.from({ length: Math.min(createBatchSize, rowCount - offset) }, () => ({ - fields: {}, - })), - }); - } - - await updateViewFilter(streamTable.id, viewId, { - filter: { - conjunction: 'and', - filterSet: [ - { - fieldId: nameFieldId, - operator: 'isEmpty', - value: null, - }, - ], - }, - }); - - const filteredRecordsBefore = await getRecords(streamTable.id, { - viewId, - fieldKeyType: FieldKeyType.Id, - take: rowCount, - }); - expect(filteredRecordsBefore.data.records).toHaveLength(rowCount); + const keepRecordId = streamTable.records[1]!.id; - const { progressEvents, doneEvent, errorEvents } = await pasteStreamWithCanary( + const { progressEvents, doneEvent, errorEvents } = await patchSelectionByIdStreamWithCanary( + DELETE_BY_ID_STREAM_URL, streamTable.id, { - viewId, - ranges: [ - [0, 0], - [0, rowCount - 1], - ], - content: Array.from({ length: rowCount }, (_, index) => [`T3238-${index}`]), + viewId: streamTable.views[0].id, + selection: { + allRecords: true, + excludedRecordIds: [keepRecordId], + }, }, true ); expect(errorEvents).toHaveLength(0); - expect(doneEvent?.processedCount).toBe(rowCount); - expect(doneEvent?.updatedCount).toBe(rowCount); - expect(doneEvent?.createdCount).toBe(0); - expect(doneEvent?.data.createdRecordIds).toHaveLength(0); - expect(progressEvents.some((event) => event.totalCount === rowCount)).toBe(true); - - const allRecordsAfter = await getRecords(streamTable.id, { - fieldKeyType: FieldKeyType.Id, - take: rowCount, - }); - expect(allRecordsAfter.data.records).toHaveLength(rowCount); - expect( - new Set(allRecordsAfter.data.records.map((record) => record.fields[nameFieldId])) - ).toEqual(new Set(Array.from({ length: rowCount }, (_, index) => `T3238-${index}`))); + expect(doneEvent).toMatchObject({ totalCount: 2, deletedCount: 2 }); + expect(progressEvents.some((event) => event.totalCount === 2)).toBe(true); - const filteredRecordsAfter = await getRecords(streamTable.id, { - viewId, + const recordsAfter = await getRecords(streamTable.id, { fieldKeyType: FieldKeyType.Id, - take: 1, }); - expect(filteredRecordsAfter.data.records).toHaveLength(0); + expect(recordsAfter.data.records.map((record) => record.id)).toEqual([keepRecordId]); } finally { await permanentDeleteTable(baseId, streamTable.id); } }); - - it.skipIf(isForceV2)( - 'should allow paste-stream when v2 canary is disabled and fall back to v1 paste', - async () => { - const streamTable = await createTable(baseId, { - name: 'paste-stream-v1-fallback', - fields: [ - { name: 'name', type: FieldType.SingleLineText }, - { name: 'number', type: FieldType.Number }, - ], - records: [{ fields: { name: 'stream-v1-1', number: 1 } }], - }); - - try { - const nameFieldId = streamTable.fields.find((field) => field.name === 'name')!.id; - - const { progressEvents, doneEvent, errorEvents } = await pasteStreamWithCanary( - streamTable.id, - { - viewId: streamTable.views[0].id, - ranges: [ - [0, 0], - [1, 1], - ], - content: [ - ['fallback-1', 11], - ['fallback-2', 22], - ], - }, - false - ); - - expect(errorEvents).toHaveLength(0); - expect(progressEvents.length).toBeGreaterThan(0); - expect(progressEvents[0]).toMatchObject({ - phase: 'preparing', - processedCount: 0, - }); - expect(doneEvent?.processedCount).toBe(2); - expect(doneEvent?.data.ranges).toEqual([ - [0, 0], - [1, 1], - ]); - - const recordsAfter = await getRecords(streamTable.id, { - fieldKeyType: FieldKeyType.Id, - }); - expect(recordsAfter.data.records).toHaveLength(2); - expect(recordsAfter.data.records.map((record) => record.fields[nameFieldId])).toEqual([ - 'fallback-1', - 'fallback-2', - ]); - } finally { - await permanentDeleteTable(baseId, streamTable.id); - } - } - ); }); describe('paste user', () => { @@ -3188,6 +5001,98 @@ describe('OpenAPI SelectionController (e2e)', () => { }); }); + describe('paste with sort ties and manual row order (personal view)', () => { + /** + * Repro for paste row misalignment under personal views: + * the grid displays rows via the v1 read path, which always breaks sort + * ties by the view's manual row order (__row_{viewId}), while v2 range + * commands broke ties by __auto_number. With duplicate sort values and a + * manually reordered row, paste targeted a different record than the one + * displayed at the same offset. + */ + let tieTable: ITableFullVo; + + beforeEach(async () => { + // Creation order (auto number): A, B, C tie in group 1; D, E in group 2 + tieTable = await createTable(baseId, { + name: 'tie-paste-table', + fields: [ + { name: 'Name', type: FieldType.SingleLineText }, + { name: 'Group', type: FieldType.Number }, + ], + records: [ + { fields: { Name: 'RecordA', Group: 1 } }, + { fields: { Name: 'RecordB', Group: 1 } }, + { fields: { Name: 'RecordC', Group: 1 } }, + { fields: { Name: 'RecordD', Group: 2 } }, + { fields: { Name: 'RecordE', Group: 2 } }, + ], + }); + // Make the manual row order differ from creation order within the tied + // group: move C before A -> view row order: C, A, B, D, E + await updateRecordOrders(tieTable.id, tieTable.views[0].id, { + anchorId: tieTable.records[0].id, + position: 'before', + recordIds: [tieTable.records[2].id], + }); + }); + + afterEach(async () => { + await permanentDeleteTable(baseId, tieTable.id); + }); + + it.each( + isForceV2 + ? [{ label: 'v2-forced', useV2: true }] + : [ + { label: 'v1', useV2: false }, + { label: 'v2', useV2: true }, + ] + )('should paste into the displayed row when the sort has ties ($label)', async ({ useV2 }) => { + const nameField = tieTable.fields.find((f) => f.name === 'Name')!; + const groupField = tieTable.fields.find((f) => f.name === 'Group')!; + // Mimic a personal view: the client sends its own sort with + // ignoreViewQuery instead of relying on the saved view config. + const personalViewQuery = { + viewId: tieTable.views[0].id, + ignoreViewQuery: true, + orderBy: [{ fieldId: groupField.id, order: SortFunc.Asc }], + }; + + // Ground truth: the row order the grid displays (v1 read path breaks + // the Group=1 tie by manual row order, so RecordC is the first row). + const displayed = await getRecordsWithCanary( + tieTable.id, + { ...personalViewQuery, fieldKeyType: FieldKeyType.Id }, + false + ); + expect(displayed.data.records[0].fields[nameField.id]).toBe('RecordC'); + + // Paste a single cell into the first displayed row (Name column). + await pasteWithCanary( + tieTable.id, + { + ...personalViewQuery, + content: 'PastedTop', + ranges: [ + [0, 0], + [0, 0], + ], + }, + useV2 + ); + + const allRecords = await getRecords(tieTable.id, { fieldKeyType: FieldKeyType.Id }); + const recordC = allRecords.data.records.find((r) => r.id === tieTable.records[2].id); + const recordA = allRecords.data.records.find((r) => r.id === tieTable.records[0].id); + + // The displayed first row (RecordC) must receive the pasted value; + // RecordA (first by auto number within the tie) must stay unchanged. + expect(recordC?.fields[nameField.id]).toBe('PastedTop'); + expect(recordA?.fields[nameField.id]).toBe('RecordA'); + }); + }); + describe('paste with view-level sort and filter (no client orderBy)', () => { /** * Regression test: when the view has a saved sort/filter but the client @@ -3812,6 +5717,7 @@ describe('OpenAPI SelectionController (e2e)', () => { groupBy: [...groupBy], orderBy: [...orderBy], fieldKeyType: FieldKeyType.Id, + includeQueryExtra: true, }); const firstGroupHeader = groupedResult.data.extra?.groupPoints?.find( @@ -3872,5 +5778,125 @@ describe('OpenAPI SelectionController (e2e)', () => { ).toBe(false); } ); + + it('T4992 should paste grouped tail rows in v2 when the view filter references Me', async () => { + const assigneeValue = { + id: globalThis.testConfig.userId, + title: globalThis.testConfig.userName, + email: globalThis.testConfig.email, + }; + const requiredTable = await createTable(baseId, { + name: 'T4992 grouped paste required field', + fields: [ + { name: 'Title', type: FieldType.SingleLineText }, + { + name: 'Status', + type: FieldType.SingleSelect, + options: { + choices: [ + { name: 'Need more information', color: Colors.Red }, + { name: 'Added to backlog', color: Colors.Teal }, + { name: 'Entered development workflow', color: Colors.Purple }, + { name: 'Launched', color: Colors.Green }, + { name: 'Closed as completed', color: Colors.Green }, + { name: 'Closed as not planned', color: Colors.Red }, + ], + }, + }, + { name: 'Assignee', type: FieldType.User }, + { name: 'Email', type: FieldType.SingleLineText }, + ], + records: [ + ...Array.from({ length: 14 }, (_, index) => ({ + fields: { + Title: `Blank-${index + 1}`, + Assignee: assigneeValue, + Email: `blank-${index + 1}@example.com`, + }, + })), + ...Array.from({ length: 3 }, (_, index) => ({ + fields: { + Title: `Need-${index + 1}`, + Status: 'Need more information', + Assignee: assigneeValue, + Email: `need-${index + 1}@example.com`, + }, + })), + ...Array.from({ length: 7 }, (_, index) => ({ + fields: { + Title: `Backlog-${index + 1}`, + Status: 'Added to backlog', + Assignee: assigneeValue, + Email: `backlog-${index + 1}@example.com`, + }, + })), + ...Array.from({ length: 5 }, (_, index) => ({ + fields: { + Title: `Workflow-${index + 1}`, + Status: 'Entered development workflow', + Assignee: assigneeValue, + Email: `workflow-${index + 1}@example.com`, + }, + })), + ], + }); + + try { + const viewId = requiredTable.views[0].id; + const statusField = requiredTable.fields.find((field) => field.name === 'Status')!; + const assigneeField = requiredTable.fields.find((field) => field.name === 'Assignee')!; + const emailField = requiredTable.fields.find((field) => field.name === 'Email')!; + await convertField(requiredTable.id, emailField.id, { ...emailField, notNull: true }); + await updateViewFilter(requiredTable.id, viewId, { + filter: { + conjunction: 'and', + filterSet: [ + { fieldId: assigneeField.id, operator: 'is', value: Me }, + { + fieldId: statusField.id, + operator: 'isNoneOf', + value: ['Closed as not planned', 'Closed as completed', 'Launched'], + }, + ], + }, + }); + const groupBy = [{ fieldId: statusField.id, order: SortFunc.Asc }] as const; + await updateViewGroup(requiredTable.id, viewId, { group: [...groupBy] }); + + const pasteRes = await pasteWithCanary( + requiredTable.id, + { + viewId, + content: [['Launched']], + ranges: [ + [1, 24], + [1, 28], + ], + header: [statusField], + groupBy: [...groupBy], + }, + true + ); + + expect(pasteRes.status).toBe(200); + expect(pasteRes.headers['x-teable-v2']).toBe('true'); + + const allRecords = await getRecords(requiredTable.id, { + fieldKeyType: FieldKeyType.Id, + take: 100, + }); + + expect(allRecords.data.records).toHaveLength(29); + const workflowRecords = allRecords.data.records.filter((record) => + String(record.fields[requiredTable.fields[0].id] ?? '').startsWith('Workflow-') + ); + expect(workflowRecords).toHaveLength(5); + expect( + workflowRecords.every((record) => record.fields[statusField.id] === 'Launched') + ).toBe(true); + } finally { + await permanentDeleteTable(baseId, requiredTable.id); + } + }); }); }); diff --git a/apps/nestjs-backend/test/share-db-poll-debounce.e2e-spec.ts b/apps/nestjs-backend/test/share-db-poll-debounce.e2e-spec.ts new file mode 100644 index 0000000000..e2ab913710 --- /dev/null +++ b/apps/nestjs-backend/test/share-db-poll-debounce.e2e-spec.ts @@ -0,0 +1,111 @@ +import type { INestApplication } from '@nestjs/common'; +import { FieldKeyType, IdPrefix, ViewType } from '@teable/core'; +import { createRecords as apiCreateRecords } from '@teable/openapi'; +import type { Connection, Query } from 'sharedb/lib/client'; +import { vi } from 'vitest'; +import { ShareDbAdapter } from '../src/share-db/share-db.adapter'; +import { ShareDbService } from '../src/share-db/share-db.service'; +import { initApp, createTable, permanentDeleteTable } from './utils/init-app'; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const waitForQueryReady = (query: Query, timeout = 5000): Promise => { + return new Promise((resolve, reject) => { + if (query.ready) { + resolve(); + return; + } + const timer = setTimeout(() => reject(new Error('Query ready timeout')), timeout); + query.once('ready', () => { + clearTimeout(timer); + resolve(); + }); + }); +}; + +describe('ShareDB query pollDebounce (e2e)', () => { + let app: INestApplication; + let tableId: string; + let cookie: string; + let port: string; + let shareDbService: ShareDbService; + let adapter: ShareDbAdapter; + let originalPollDebounce: number; + const baseId = globalThis.testConfig.baseId; + + const burstSize = 8; + + beforeAll(async () => { + const appCtx = await initApp(); + app = appCtx.app; + cookie = appCtx.cookie; + port = process.env.PORT!; + shareDbService = app.get(ShareDbService); + adapter = app.get(ShareDbAdapter); + originalPollDebounce = adapter.pollDebounce; + + const table = await createTable(baseId, { + name: 'poll-debounce-test-table', + views: [{ type: ViewType.Grid, name: 'default-view' }], + }); + tableId = table.id; + }); + + afterAll(async () => { + adapter.pollDebounce = originalPollDebounce; + await permanentDeleteTable(baseId, tableId); + await app.close(); + }); + + it('exposes pollDebounce on the backend db read by QueryEmitter', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((shareDbService as any).db).toBe(adapter); + expect(typeof adapter.pollDebounce).toBe('number'); + expect(adapter.pollDebounce).toBeGreaterThan(0); + }); + + // Subscribes a fresh query (QueryEmitter snapshots pollDebounce at subscribe + // time), fires burstSize sequential record creations, then counts how many + // times the adapter polled this collection. + const countPollsForBurst = async (pollDebounceMs: number): Promise => { + adapter.pollDebounce = pollDebounceMs; + const connection: Connection = shareDbService.connect(undefined, { + url: `ws://localhost:${port}/socket`, + headers: { cookie }, + }); + const collection = `${IdPrefix.Record}_${tableId}`; + const query = connection.createSubscribeQuery(collection, {}); + + try { + await waitForQueryReady(query); + const spy = vi.spyOn(adapter, 'queryPoll'); + + for (let i = 0; i < burstSize; i++) { + await apiCreateRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + records: [{ fields: {} }], + }); + } + // Wait past the debounce window so the trailing poll lands + await sleep(pollDebounceMs * 1.5 + 1500); + + const polls = spy.mock.calls.filter(([c]) => c === collection).length; + spy.mockRestore(); + return polls; + } finally { + query.destroy(); + connection.close(); + } + }; + + it('coalesces an op burst into fewer polls when pollDebounce is on', async () => { + const pollsWithoutDebounce = await countPollsForBurst(0); + const pollsWithDebounce = await countPollsForBurst(3000); + + // Without debounce each create op polls (minus natural in-flight coalescing) + expect(pollsWithoutDebounce).toBeGreaterThanOrEqual(4); + // With debounce: one leading poll + coalesced trailing polls + expect(pollsWithDebounce).toBeLessThanOrEqual(3); + expect(pollsWithDebounce).toBeLessThan(pollsWithoutDebounce); + }, 60000); +}); diff --git a/apps/nestjs-backend/test/share.e2e-spec.ts b/apps/nestjs-backend/test/share.e2e-spec.ts index 498e52f72b..0be6ac6f5b 100644 --- a/apps/nestjs-backend/test/share.e2e-spec.ts +++ b/apps/nestjs-backend/test/share.e2e-spec.ts @@ -9,11 +9,13 @@ import type { } from '@teable/core'; import { ANONYMOUS_USER_ID, + DateFormattingPreset, FieldKeyType, FieldType, is, Relationship, SortFunc, + TimeFormatting, ViewType, } from '@teable/core'; import { @@ -27,6 +29,7 @@ import { getShareViewLinkRecords as apiGetShareViewLinkRecords, getShareViewCollaborators as apiGetShareViewCollaborators, getShareViewRecords as apiGetShareViewRecords, + getShareViewCalendarDailyCollection as apiGetShareViewCalendarDailyCollection, getBaseCollaboratorList as apiGetBaseCollaboratorList, updateViewColumnMeta as apiUpdateViewColumnMeta, updateViewShareMeta as apiUpdateViewShareMeta, @@ -66,6 +69,7 @@ import { getField, deleteField, convertField, + updateRecordByApi, permanentDeleteBase, } from './utils/init-app'; @@ -90,7 +94,6 @@ describe('OpenAPI ShareController (e2e)', () => { const spaceId = globalThis.testConfig.spaceId; const userId = globalThis.testConfig.userId; const userName = globalThis.testConfig.userName; - const userEmail = globalThis.testConfig.email; let fieldIds: string[] = []; let anonymousUser: ReturnType; @@ -237,7 +240,6 @@ describe('OpenAPI ShareController (e2e)', () => { await updateViewShareMeta(tableId, formViewId, { submit: { requireLogin: true, - allow: true, }, }); const error = await getError(() => @@ -391,6 +393,94 @@ describe('OpenAPI ShareController (e2e)', () => { }); }); + // 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 + // the full-record calendar payload bypass it because the share context carries + // no authority matrix to restrict columns server side. + describe('api/:shareId/view hidden field read protection', () => { + let leakTableId: string; + let leakViewId: string; + let leakShareId: string; + let dueFieldId: string; + let secretFieldId: string; + const secretValue = 'top-secret-value'; + + beforeAll(async () => { + const table = await createTable(baseId, { + name: 'hidden-read-leak', + fields: [ + { name: 'Name', type: FieldType.SingleLineText }, + { + name: '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 } }, + ], + }); + leakTableId = table.id; + leakViewId = table.defaultViewId!; + dueFieldId = table.fields[1].id; + secretFieldId = table.fields[2].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: secretFieldId, columnMeta: { hidden: true } }, + ]); + }); + + afterAll(async () => { + await permanentDeleteTable(baseId, leakTableId); + }); + + 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[0].fields).not.toHaveProperty(secretFieldId); + }); + + it('must not return a hidden column even when the client requests it via projection', async () => { + const result = await apiGetShareViewRecords(leakShareId, { + take: 10, + projection: [secretFieldId], + }); + + const leaked = result.data.records.some( + (record) => record.fields[secretFieldId] === secretValue + ); + expect(leaked).toBe(false); + }); + + it('must not return hidden columns in the calendar daily collection records', async () => { + 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.records.length).toBeGreaterThan(0); + const leaked = result.data.records.some((record) => + Object.prototype.hasOwnProperty.call(record.fields, secretFieldId) + ); + expect(leaked).toBe(false); + }); + }); + describe('share view allowEdit permission scope', () => { let editTable: ITableFullVo; let editShareId: string; @@ -908,12 +998,10 @@ describe('OpenAPI ShareController (e2e)', () => { const mulResult = await apiGetShareViewCollaborators(gridViewShareId, { fieldId: multipleUserFieldId, }); - expect(result.data).toEqual([ - { userId, userName, email: userEmail, avatar: expect.any(String) }, - ]); - expect(mulResult.data).toEqual([ - { userId, userName, email: userEmail, avatar: expect.any(String) }, - ]); + // 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) }]); await apiDeleteRecords( userTableRes.id, @@ -967,6 +1055,24 @@ describe('OpenAPI ShareController (e2e)', () => { }, ]); }); + it('should search collaborators by name but not by email', async () => { + await apiUpdateViewColumnMeta(userTableRes.id, formViewId, [ + { fieldId: userFieldId, columnMeta: { visible: true } }, + ]); + const byName = await apiGetShareViewCollaborators(fromViewShareId, { + search: userName, + }); + expect(byName.data.map((user) => user.userId)).toContain(userId); + // Email is neither returned nor searchable for anonymous shares, so a + // full email must not surface a collaborator (membership oracle closed). + const byEmail = await apiGetShareViewCollaborators(fromViewShareId, { + search: globalThis.testConfig.email, + }); + expect(byEmail.data).toEqual([]); + await apiUpdateViewColumnMeta(userTableRes.id, formViewId, [ + { fieldId: userFieldId, columnMeta: { visible: false } }, + ]); + }); }); }); @@ -1127,6 +1233,78 @@ describe('OpenAPI ShareController (e2e)', () => { }); expect(unmatched.data.records).toHaveLength(0); }); + + // T4864: a record linked before (or outside of) the link field's filterByViewId + // scope must still appear in the selected list / detail panel. The view scope only + // limits which records can be newly linked (candidate list), not the existing links. + it('selected list ignores the link field filterByViewId scope', async () => { + const primary = table2.fields[0]; + + const foreignRecords = await apiCreateRecords(table2.id, { + fieldKeyType: FieldKeyType.Id, + records: [ + { fields: { [primary.id]: 'in view' } }, + { fields: { [primary.id]: 'out of view' } }, + ], + }); + const inViewId = foreignRecords.data.records[0].id; + const outOfViewId = foreignRecords.data.records[1].id; + + // scope the foreign default view so only the "in view" record is visible + await updateViewFilter(table2.id, table2.defaultViewId!, { + filter: { + conjunction: 'and', + filterSet: [{ fieldId: primary.id, operator: is.value, value: 'in view' }], + }, + }); + + const linkField = await createField(table1.id, { + name: 'scoped link', + type: FieldType.Link, + options: { + relationship: Relationship.ManyOne, + foreignTableId: table2.id, + filterByViewId: table2.defaultViewId, + }, + }); + + const hostRecords = await apiCreateRecords(table1.id, { + fieldKeyType: FieldKeyType.Id, + records: [{ fields: {} }], + }); + const hostId = hostRecords.data.records[0].id; + + // link the host record to the record that is NOT in the configured view + await updateRecordByApi(table1.id, hostId, linkField.data.id, { + id: outOfViewId, + }); + + const selectedQuery = { + filterLinkCellSelected: [linkField.data.id, hostId] as [string, string], + }; + + // the already-linked record must be visible even though it fails the view filter + const selected = await apiGetShareViewRecords(linkField.data.id, selectedQuery); + expect(selected.data.records.map((r) => r.id)).toEqual([outOfViewId]); + + const selectedRowCount = await getShareViewRowCount(linkField.data.id, selectedQuery); + expect(selectedRowCount.data.rowCount).toEqual(1); + + // the expand-record card loads already-linked records by selectedRecordIds and + // must also bypass the view scope + const byIds = await apiGetShareViewRecords(linkField.data.id, { + selectedRecordIds: [outOfViewId], + }); + expect(byIds.data.records.map((r) => r.id)).toEqual([outOfViewId]); + + // the candidate list must still respect the view filter + const candidate = await apiGetShareViewRecords(linkField.data.id, { + filterLinkCellCandidate: [linkField.data.id, hostId], + }); + const candidateIds = candidate.data.records.map((r) => r.id); + expect(candidateIds).toContain(inViewId); + expect(candidateIds).not.toContain(outOfViewId); + }); }); describe('link view limit', () => { diff --git a/apps/nestjs-backend/test/sort.e2e-spec.ts b/apps/nestjs-backend/test/sort.e2e-spec.ts index b0754dce78..80f83981db 100644 --- a/apps/nestjs-backend/test/sort.e2e-spec.ts +++ b/apps/nestjs-backend/test/sort.e2e-spec.ts @@ -170,6 +170,25 @@ describe('OpenAPI ViewController view order sort (e2e)', () => { expect(viewSort).toEqual(assertSort.sort); }); + it('/api/table/{tableId}/view/{viewId}/sort clears view sort with null (PUT)', async () => { + await apiSetViewSort(tableId, viewId, { + sort: { + sortObjs: [ + { + fieldId: fields[0].id as string, + order: SortFunc.Asc, + }, + ], + manualSort: false, + }, + }); + + await apiSetViewSort(tableId, viewId, { sort: null }); + + const updatedView = await getView(tableId, viewId); + expect(updatedView.sort).toBeUndefined(); + }); + it('sort date should always use a second precision when formatting time is not none', async () => { await createRecords(tableId, { records: [ diff --git a/apps/nestjs-backend/test/table-import.e2e-spec.ts b/apps/nestjs-backend/test/table-import.e2e-spec.ts index 44e2e1ff77..234adb4a38 100644 --- a/apps/nestjs-backend/test/table-import.e2e-spec.ts +++ b/apps/nestjs-backend/test/table-import.e2e-spec.ts @@ -31,6 +31,8 @@ import { initApp, permanentDeleteTable, getTable as apiGetTableById } from './ut extend(timezone); +const importTimeZone = 'Asia/Shanghai'; + enum TestFileFormat { 'CSV' = 'csv', 'TSV' = 'tsv', @@ -39,6 +41,7 @@ enum TestFileFormat { } const defaultTestSheetKey = 'Sheet1'; +const xTeableV2Header = 'x-teable-v2'; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const testSupportTypeMap = { @@ -272,7 +275,7 @@ describe('OpenAPI ImportController (e2e)', () => { importData: true, }, }, - tz: 'Asia/Shanghai', + tz: importTimeZone, }); const { fields, id } = table.data[0]; @@ -282,9 +285,11 @@ describe('OpenAPI ImportController (e2e)', () => { name: field.name, })); - await awaitWithEvent(async () => { - noop(); - }); + if (table.headers[xTeableV2Header] !== 'true') { + await awaitWithEvent(async () => { + noop(); + }); + } const { records } = await apiGetTableById(baseId, table.data[0].id, { includeContent: true, @@ -297,13 +302,80 @@ describe('OpenAPI ImportController (e2e)', () => { } ); + it('should route CSV new-table import through V2 when V2 is forced', async () => { + const previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + + try { + const spaceRes = await apiCreateSpace({ name: 'v2-import-csv' }); + const spaceId = spaceRes?.data?.id; + const baseRes = await apiCreateBase({ spaceId }); + const baseId = baseRes.data.id; + + const format = TestFileFormat.CSV; + const fileType = testSupportTypeMap[format].fileType; + const attachmentUrl = testFiles[format].url; + const sheetKey = testSupportTypeMap[format].defaultSheetKey; + + const { + data: { worksheets }, + } = await apiAnalyzeFile({ + attachmentUrl, + fileType, + }); + const columns = worksheets[sheetKey].columns.map((column, index) => ({ + ...column, + sourceColumnIndex: index, + })); + + const importRes = await apiImportTableFromFile(baseId, { + attachmentUrl, + fileType, + worksheets: { + [sheetKey]: { + name: sheetKey, + columns, + useFirstRowAsHeader: true, + importData: true, + }, + }, + tz: importTimeZone, + }); + + expect(importRes.headers[xTeableV2Header]).toBe('true'); + expect(importRes.headers['x-teable-v2-feature']).toBe('importCsv'); + expect(['env_force_v2_all', 'new_base']).toContain(importRes.headers['x-teable-v2-reason']); + + const { fields, id } = importRes.data[0]; + const createdFields = fields.map((field) => ({ + type: field.type, + name: field.name, + })); + + const { records } = await apiGetTableById(baseId, id, { + includeContent: true, + }); + + bases.push([baseId, id]); + + expect(records?.length).toBe(2); + expect(createdFields).toEqual(assertHeaders); + } finally { + if (previousForceV2All === undefined) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + } + }); + it('should query import status until completed for imported table', async () => { const spaceRes = await apiCreateSpace({ name: 'status-check' }); const spaceId = spaceRes?.data?.id; const baseRes = await apiCreateBase({ spaceId }); const baseId = baseRes.data.id; - const format = TestFileFormat.CSV; + const format = TestFileFormat.XLSX; const fileType = testSupportTypeMap[format].fileType; const attachmentUrl = testFiles[format].url; const sheetKey = testSupportTypeMap[format].defaultSheetKey; @@ -330,7 +402,7 @@ describe('OpenAPI ImportController (e2e)', () => { importData: true, }, }, - tz: 'Asia/Shanghai', + tz: importTimeZone, }); const tableId = importRes.data[0].id; @@ -422,17 +494,20 @@ describe('OpenAPI ImportController (e2e)', () => { }); // import data into table - await awaitWithEvent(async () => { - await apiInplaceImportTableFromFile(baseId, tableId, { - attachmentUrl, - fileType, - insertConfig: { - sourceWorkSheetKey: CsvImporter.DEFAULT_SHEETKEY, - excludeFirstRow: true, - sourceColumnMap, - }, - }); + const importRes = await apiInplaceImportTableFromFile(baseId, tableId, { + attachmentUrl, + fileType, + insertConfig: { + sourceWorkSheetKey: CsvImporter.DEFAULT_SHEETKEY, + excludeFirstRow: true, + sourceColumnMap, + }, }); + if (importRes.headers[xTeableV2Header] !== 'true') { + await awaitWithEvent(async () => { + noop(); + }); + } const { records } = await apiGetTableById(baseId, tableId, { includeContent: true, diff --git a/apps/nestjs-backend/test/table-trash.e2e-spec.ts b/apps/nestjs-backend/test/table-trash.e2e-spec.ts index 3724915483..0b73ea1dd2 100644 --- a/apps/nestjs-backend/test/table-trash.e2e-spec.ts +++ b/apps/nestjs-backend/test/table-trash.e2e-spec.ts @@ -1,10 +1,18 @@ /* eslint-disable sonarjs/no-duplicate-string */ import { faker } from '@faker-js/faker'; import type { INestApplication } from '@nestjs/common'; -import { FieldKeyType, FieldType, ViewType, generateRecordTrashId } from '@teable/core'; +import type { ILinkFieldOptions } from '@teable/core'; +import { + FieldKeyType, + FieldType, + Relationship, + ViewType, + generateRecordTrashId, +} from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import type { ITableTrashItemVo } from '@teable/openapi'; import { + axios, RangeType, SettingKey, createRecords, @@ -16,7 +24,9 @@ import { resetTrashItems, ResourceType, restoreTrash, + updateRecords, updateSetting, + urlBuilder, } from '@teable/openapi'; import { vi } from 'vitest'; import { EventEmitterService } from '../src/event-emitter/event-emitter.service'; @@ -69,6 +79,60 @@ const tableVo = { const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +type IRestoreTrashStreamEvent = + | { + id: 'progress'; + phase: 'preparing' | 'restoring'; + batchIndex: number; + totalCount: number; + processedCount: number; + updatedCount: number; + } + | { + id: 'done'; + totalCount: number; + updatedCount: number; + } + | { + id: 'error'; + phase: 'preparing' | 'restoring' | 'finalizing'; + batchIndex: number; + totalCount: number; + processedCount: number; + updatedCount: number; + message: string; + code?: string; + }; + +const readRestoreTrashStream = async (response: Response) => { + expect(response.ok).toBe(true); + expect(response.headers.get('content-type')).toContain('text/event-stream'); + + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + const events: IRestoreTrashStreamEvent[] = []; + + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const jsonStr = line.slice(5).trim(); + if (!jsonStr || jsonStr === '[DONE]') continue; + events.push(JSON.parse(jsonStr) as IRestoreTrashStreamEvent); + } + } + + return events; +}; + 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 }); @@ -87,6 +151,7 @@ describe('Trash (e2e)', () => { let prisma: PrismaService; let eventEmitterService: EventEmitterService; let recordOpenApiService: RecordOpenApiService; + let cookie: string; const baseId = globalThis.testConfig.baseId; @@ -99,6 +164,7 @@ describe('Trash (e2e)', () => { const appCtx = await initApp(); app = appCtx.app; + cookie = appCtx.cookie; prisma = app.get(PrismaService); eventEmitterService = app.get(EventEmitterService); recordOpenApiService = app.get(RecordOpenApiService); @@ -499,6 +565,8 @@ describe('Trash (e2e)', () => { const restored = await restoreTrash(recordTrashItem!.id, tableId); expect(restored.status).toEqual(201); + expect(restored.headers['x-teable-v2']).toBe('true'); + expect(restored.headers['x-teable-v2-feature']).toBe('createRecord'); expect(legacyRestoreSpy).not.toHaveBeenCalled(); const recordsAfterRestore = await getRecords(tableId, { @@ -526,6 +594,150 @@ describe('Trash (e2e)', () => { } }); + it('should restore V2 field trash values from a sparse snapshot', async () => { + await updateSetting({ + [SettingKey.CANARY_CONFIG]: { + enabled: true, + spaceIds: [globalThis.testConfig.spaceId], + }, + }); + + try { + const field = await createField(tableId, { + name: `restore-v2-sparse-${Date.now()}`, + type: FieldType.SingleLineText, + }); + const created = await createRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + records: Array.from({ length: 501 }, (_, index) => ({ + fields: { + [field.id]: `restore-value-${index}`, + }, + })), + }); + expect(created.headers['x-teable-v2']).toBe('true'); + const recordIds = created.data.records.map((record) => record.id); + + const deleteRes = await deleteFields(tableId, [field.id]); + expect(deleteRes.headers['x-teable-v2']).toBe('true'); + + const itemsRes = await waitForTableTrashItems(tableId, 1); + const fieldTrashItem = itemsRes.data.trashItems.find( + (t) => (t as ITableTrashItemVo).resourceType === ResourceType.Field + ) as ITableTrashItemVo | undefined; + + expect(fieldTrashItem).toBeTruthy(); + + const restored = await restoreTrash(fieldTrashItem!.id, tableId); + expect(restored.status).toEqual(201); + expect(restored.headers['x-teable-v2']).toBe('true'); + expect(restored.headers['x-teable-v2-feature']).toBe('createField'); + + const recordsAfterRestore = await getRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + }); + const restoredRecord = recordsAfterRestore.records.find( + (record) => record.id === recordIds[0] + ); + expect(restoredRecord?.fields[field.id]).toBe('restore-value-0'); + } finally { + await updateSetting({ + [SettingKey.CANARY_CONFIG]: { + enabled: false, + spaceIds: [], + }, + }); + } + }); + + it('should stream V2 field trash restore progress while restoring record values', async () => { + await updateSetting({ + [SettingKey.CANARY_CONFIG]: { + enabled: true, + spaceIds: [globalThis.testConfig.spaceId], + }, + }); + + const legacyRecordUpdateSpy = vi.spyOn(recordOpenApiService, 'updateRecords'); + try { + const field = await createField(tableId, { + name: `restore-v2-stream-${Date.now()}`, + type: FieldType.SingleLineText, + }); + const recordsBeforeUpdate = await getRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + }); + const recordIds = recordsBeforeUpdate.records.slice(0, 3).map((record) => record.id); + await updateRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + records: recordIds.map((recordId, index) => ({ + id: recordId, + fields: { + [field.id]: `stream-restore-value-${index}`, + }, + })), + }); + + const deleteRes = await deleteFields(tableId, [field.id]); + expect(deleteRes.headers['x-teable-v2']).toBe('true'); + + const itemsRes = await waitForTableTrashItems(tableId, 1); + const fieldTrashItem = itemsRes.data.trashItems.find( + (item) => (item as ITableTrashItemVo).resourceType === ResourceType.Field + ) as ITableTrashItemVo | undefined; + expect(fieldTrashItem).toBeTruthy(); + + const response = await fetch( + axios.getUri({ + baseURL: axios.defaults.baseURL, + url: urlBuilder('/trash/restore-field/{trashId}/stream', { + trashId: fieldTrashItem!.id, + }), + params: { tableId }, + }), + { + method: 'POST', + headers: { + Accept: 'text/event-stream', + Cookie: cookie, + }, + } + ); + + const events = await readRestoreTrashStream(response); + const progressEvents = events.filter((event) => event.id === 'progress'); + const doneEvent = events.find((event) => event.id === 'done'); + + expect(progressEvents.some((event) => event.updatedCount > 0)).toBe(true); + expect(doneEvent).toMatchObject({ + id: 'done', + updatedCount: 3, + }); + expect(doneEvent).not.toHaveProperty('resourceType'); + expect(response.headers.get('x-teable-v2')).toBe('true'); + expect(response.headers.get('x-teable-v2-feature')).toBe('createField'); + expect(legacyRecordUpdateSpy).not.toHaveBeenCalled(); + + const recordsAfterRestore = await getRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + }); + for (const [index, recordId] of recordIds.entries()) { + const restoredRecord = recordsAfterRestore.records.find( + (record) => record.id === recordId + ); + expect(restoredRecord?.fields[field.id]).toBe(`stream-restore-value-${index}`); + } + } finally { + await updateSetting({ + [SettingKey.CANARY_CONFIG]: { + enabled: false, + spaceIds: [], + }, + }); + legacyRecordUpdateSpy.mockRestore(); + } + }); + it('should restore field when some records were deleted after field deletion', async () => { const field = await createField(tableId, { name: 'restore field', @@ -565,6 +777,93 @@ describe('Trash (e2e)', () => { expect(afterFields.find((f) => f.id === field.id)).toBeTruthy(); }); + it('should restore a two-way link field together with its deleted symmetric field', async () => { + await updateSetting({ + [SettingKey.CANARY_CONFIG]: { + enabled: true, + spaceIds: [globalThis.testConfig.spaceId], + }, + }); + + const foreignTable = await createTable(baseId, { + name: `restore-link-target-${Date.now()}`, + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + records: [{ fields: { Name: 'target' } }], + }); + + try { + const linkField = await createField(tableId, { + name: 'restore link', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: foreignTable.id, + }, + }); + const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId; + expect(symmetricFieldId).toBeTruthy(); + + const sourceRecord = (await getRecords(tableId, { fieldKeyType: FieldKeyType.Id })) + .records[0]; + const targetRecord = (await getRecords(foreignTable.id, { fieldKeyType: FieldKeyType.Id })) + .records[0]; + + await updateRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + records: [ + { + id: sourceRecord.id, + fields: { + [linkField.id]: [{ id: targetRecord.id }], + }, + }, + ], + }); + + const deleteRes = await deleteFields(tableId, [linkField.id]); + expect(deleteRes.headers['x-teable-v2']).toBe('true'); + + const deletedMain = await prisma.field.findUnique({ where: { id: linkField.id } }); + const deletedSymmetric = await prisma.field.findUnique({ + where: { id: symmetricFieldId! }, + }); + expect(deletedMain?.deletedTime).toBeTruthy(); + expect(deletedSymmetric?.deletedTime).toBeTruthy(); + + const itemsRes = await waitForTableTrashItems(tableId, 1); + const fieldTrashItem = itemsRes.data.trashItems.find( + (t) => (t as ITableTrashItemVo).resourceType === ResourceType.Field + ) as ITableTrashItemVo | undefined; + + expect(fieldTrashItem).toBeTruthy(); + + const restored = await restoreTrash(fieldTrashItem!.id, tableId); + expect(restored.status).toEqual(201); + expect(restored.headers['x-teable-v2']).toBe('true'); + expect(restored.headers['x-teable-v2-feature']).toBe('createField'); + + const afterSourceFields = await getFields(tableId); + const afterTargetFields = await getFields(foreignTable.id); + expect(afterSourceFields.find((f) => f.id === linkField.id)).toBeTruthy(); + expect(afterTargetFields.find((f) => f.id === symmetricFieldId)).toBeTruthy(); + + const sourceAfterRestore = ( + await getRecords(tableId, { fieldKeyType: FieldKeyType.Id }) + ).records.find((record) => record.id === sourceRecord.id); + expect(sourceAfterRestore?.fields[linkField.id]).toEqual([ + expect.objectContaining({ id: targetRecord.id }), + ]); + } finally { + await updateSetting({ + [SettingKey.CANARY_CONFIG]: { + enabled: false, + spaceIds: [], + }, + }); + await permanentDeleteTable(baseId, foreignTable.id); + } + }); + it('should restore fields successfully', async () => { const recordsData = await getRecords(tableId); const deletedRecordIds = recordsData.records.map((r) => r.id); diff --git a/apps/nestjs-backend/test/undo-redo.e2e-spec.ts b/apps/nestjs-backend/test/undo-redo.e2e-spec.ts index 245856727b..c34cd07ab0 100644 --- a/apps/nestjs-backend/test/undo-redo.e2e-spec.ts +++ b/apps/nestjs-backend/test/undo-redo.e2e-spec.ts @@ -1271,7 +1271,7 @@ describe('Undo Redo (e2e)', () => { await undo(table.id); - expect(await waitForViewVisibility(table.id, view.id, true)).toMatchObject({ + expect(await waitForViewVisibility(table.id, view.id, true, 300)).toMatchObject({ id: view.id, name: view.name, type: view.type, @@ -1279,7 +1279,7 @@ describe('Undo Redo (e2e)', () => { await redo(table.id); - expect(await waitForViewVisibility(table.id, view.id, false)).toBeUndefined(); + expect(await waitForViewVisibility(table.id, view.id, false, 300)).toBeUndefined(); }); it('should undo / redo update view property', async () => { diff --git a/apps/nestjs-backend/test/utils/action-trigger.ts b/apps/nestjs-backend/test/utils/action-trigger.ts new file mode 100644 index 0000000000..df6b26d761 --- /dev/null +++ b/apps/nestjs-backend/test/utils/action-trigger.ts @@ -0,0 +1,123 @@ +import { getActionTriggerChannel } from '@teable/core'; +import type { Connection } from 'sharedb/lib/client'; +import type { ShareDbService } from '../../src/share-db/share-db.service'; + +export interface IActionTrigger { + actionKey: string; + payload?: Record; +} + +const createConnection = ( + shareDbService: ShareDbService, + cookie: string, + port: string +): Connection => { + return shareDbService.connect(undefined, { + url: `ws://localhost:${port}/socket`, + headers: { cookie }, + }); +}; + +/** + * Subscribe to a table's action-trigger presence channel, run `act`, and + * resolve with every action received until the `until` predicate matches or + * the channel stays idle for `idleMs` after `act` completed. + */ +export const collectActionTriggers = async (params: { + shareDbService: ShareDbService; + cookie: string; + port: string; + tableId: string; + act: () => Promise; + idleMs?: number; + timeoutMs?: number; + until?: (actions: ReadonlyArray) => boolean; +}): Promise => { + const { + shareDbService, + cookie, + port, + tableId, + act, + idleMs = 300, + timeoutMs = 5000, + until, + } = params; + + return new Promise((resolve, reject) => { + const connection = createConnection(shareDbService, cookie, port); + const presence = connection.getPresence(getActionTriggerChannel(tableId)); + const received: IActionTrigger[] = []; + let capture = false; + let settled = false; + let actCompleted = false; + let idleTimer: NodeJS.Timeout | undefined; + + const cleanup = () => { + clearTimeout(timeout); + if (idleTimer) clearTimeout(idleTimer); + presence.removeListener('receive', onReceive); + try { + presence.unsubscribe(); + presence.destroy(); + } catch { + void 0; + } + connection.close(); + }; + + const finish = (error?: unknown) => { + if (settled) return; + settled = true; + cleanup(); + if (error) { + reject(error instanceof Error ? error : new Error(String(error))); + return; + } + resolve(received); + }; + + const onReceive = (_id: string, batch: IActionTrigger[]) => { + if (!capture) { + return; + } + received.push(...batch); + if (until?.(received)) { + finish(); + return; + } + if (!actCompleted) { + return; + } + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => finish(), idleMs); + }; + + const timeout = setTimeout(() => { + finish(new Error('Action trigger timeout')); + }, timeoutMs); + + presence.subscribe(async (error: unknown) => { + if (error) { + finish(error); + return; + } + + presence.on('receive', onReceive); + + try { + capture = true; + await act(); + actCompleted = true; + if (until?.(received)) { + finish(); + return; + } + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => finish(), idleMs); + } catch (actError) { + finish(actError); + } + }); + }); +}; diff --git a/apps/nestjs-backend/test/utils/init-app.ts b/apps/nestjs-backend/test/utils/init-app.ts index 8c2cf7eb86..c01d4269b1 100644 --- a/apps/nestjs-backend/test/utils/init-app.ts +++ b/apps/nestjs-backend/test/utils/init-app.ts @@ -17,6 +17,7 @@ import type { IViewRo, IConditionalRollupFieldOptions, IFilter, + IUpdateFieldRo, } from '@teable/core'; import { FieldKeyType, FieldType } from '@teable/core'; import type { @@ -41,6 +42,7 @@ import { getRecords as apiGetRecords, createRecords as apiCreateRecords, createField as apiCreateField, + updateField as apiUpdateField, deleteField as apiDeleteField, convertField as apiConvertField, duplicateRecord as apiDuplicateRecord, @@ -476,6 +478,25 @@ export async function createField( } } +export async function updateField( + tableId: string, + fieldId: string, + fieldRo: IUpdateFieldRo, + expectStatus = 200 +): Promise { + try { + const res = await apiUpdateField(tableId, fieldId, fieldRo); + + expect(res.status).toEqual(expectStatus); + return res.data; + } catch (e: unknown) { + if ((e as HttpError).status !== expectStatus) { + throw e; + } + return {} as IFieldVo; + } +} + export async function createFields( tableId: string, fieldRos: IFieldRo[], diff --git a/apps/nestjs-backend/test/v2-action-trigger-field-conversion.e2e-spec.ts b/apps/nestjs-backend/test/v2-action-trigger-field-conversion.e2e-spec.ts index 9a1913c815..d69e9543b1 100644 --- a/apps/nestjs-backend/test/v2-action-trigger-field-conversion.e2e-spec.ts +++ b/apps/nestjs-backend/test/v2-action-trigger-field-conversion.e2e-spec.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { INestApplication } from '@nestjs/common'; -import { FieldKeyType, FieldType, getActionTriggerChannel } from '@teable/core'; +import { FieldKeyType, FieldType } from '@teable/core'; import { axios, X_CANARY_HEADER } from '@teable/openapi'; -import type { Connection } from 'sharedb/lib/client'; import { ShareDbService } from '../src/share-db/share-db.service'; +import { collectActionTriggers } from './utils/action-trigger'; import { createField, createRecords, @@ -12,12 +12,8 @@ import { permanentDeleteTable, } from './utils/init-app'; -interface IActionTrigger { - actionKey: string; - payload?: Record; -} - const amountTextFieldName = 'Amount Text'; +const v2ResponseHeader = 'x-teable-v2'; let fieldIdCounter = 0; @@ -27,116 +23,6 @@ const createFieldId = () => { return `fld${suffix}`; }; -const createConnection = ( - shareDbService: ShareDbService, - cookie: string, - port: string -): Connection => { - return shareDbService.connect(undefined, { - url: `ws://localhost:${port}/socket`, - headers: { cookie }, - }); -}; - -const collectActionTriggers = async (params: { - shareDbService: ShareDbService; - cookie: string; - port: string; - tableId: string; - act: () => Promise; - idleMs?: number; - timeoutMs?: number; - until?: (actions: ReadonlyArray) => boolean; -}): Promise => { - const { - shareDbService, - cookie, - port, - tableId, - act, - idleMs = 300, - timeoutMs = 5000, - until, - } = params; - - return new Promise((resolve, reject) => { - const connection = createConnection(shareDbService, cookie, port); - const presence = connection.getPresence(getActionTriggerChannel(tableId)); - const received: IActionTrigger[] = []; - let capture = false; - let settled = false; - let actCompleted = false; - let idleTimer: NodeJS.Timeout | undefined; - - const cleanup = () => { - clearTimeout(timeout); - if (idleTimer) clearTimeout(idleTimer); - presence.removeListener('receive', onReceive); - try { - presence.unsubscribe(); - presence.destroy(); - } catch { - void 0; - } - connection.close(); - }; - - const finish = (error?: unknown) => { - if (settled) return; - settled = true; - cleanup(); - if (error) { - reject(error instanceof Error ? error : new Error(String(error))); - return; - } - resolve(received); - }; - - const onReceive = (_id: string, batch: IActionTrigger[]) => { - if (!capture) { - return; - } - received.push(...batch); - if (until?.(received)) { - finish(); - return; - } - if (!actCompleted) { - return; - } - if (idleTimer) clearTimeout(idleTimer); - idleTimer = setTimeout(() => finish(), idleMs); - }; - - const timeout = setTimeout(() => { - finish(new Error('Action trigger timeout')); - }, timeoutMs); - - presence.subscribe(async (error: unknown) => { - if (error) { - finish(error); - return; - } - - presence.on('receive', onReceive); - - try { - capture = true; - await act(); - actCompleted = true; - if (until?.(received)) { - finish(); - return; - } - if (idleTimer) clearTimeout(idleTimer); - idleTimer = setTimeout(() => finish(), idleMs); - } catch (actError) { - finish(actError); - } - }); - }); -}; - describe('V2 action trigger field conversion (e2e)', () => { let app: INestApplication; let cookie: string; @@ -164,7 +50,7 @@ describe('V2 action trigger field conversion (e2e)', () => { const table = await createTable(baseId, { name: 'v2-action-trigger-field-conversion', fields: [ - { name: 'Name', type: FieldType.SingleLineText, isPrimary: true }, + { name: 'Name', type: FieldType.SingleLineText }, { name: amountTextFieldName, type: FieldType.SingleLineText }, ], }); @@ -212,7 +98,7 @@ describe('V2 action trigger field conversion (e2e)', () => { ); expect(response.status).toBe(200); - expect(response.headers['x-teable-v2']).toBe('true'); + expect(response.headers[v2ResponseHeader]).toBe('true'); }, }); @@ -253,7 +139,7 @@ describe('V2 action trigger field conversion (e2e)', () => { const table = await createTable(baseId, { name: 'v2-action-trigger-field-conversion-formula', fields: [ - { name: 'Name', type: FieldType.SingleLineText, isPrimary: true }, + { name: 'Name', type: FieldType.SingleLineText }, { name: amountTextFieldName, type: FieldType.SingleLineText }, ], }); @@ -304,7 +190,7 @@ describe('V2 action trigger field conversion (e2e)', () => { ); expect(response.status).toBe(200); - expect(response.headers['x-teable-v2']).toBe('true'); + expect(response.headers[v2ResponseHeader]).toBe('true'); }, }); @@ -330,7 +216,7 @@ describe('V2 action trigger field conversion (e2e)', () => { const foreignTable = await createTable(baseId, { name: 'v2-action-trigger-foreign-schema-source', fields: [ - { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Name', type: 'singleLineText' }, { name: 'Status', type: 'singleSelect', @@ -356,7 +242,6 @@ describe('V2 action trigger field conversion (e2e)', () => { id: hostPrimaryFieldId, name: 'Name', type: 'singleLineText', - isPrimary: true, }, { id: linkFieldId, @@ -436,7 +321,7 @@ describe('V2 action trigger field conversion (e2e)', () => { ); expect(response.status).toBe(200); - expect(response.headers['x-teable-v2']).toBe('true'); + expect(response.headers[v2ResponseHeader]).toBe('true'); }, }); @@ -461,7 +346,7 @@ describe('V2 action trigger field conversion (e2e)', () => { const table = await createTable(baseId, { name: 'v2-action-trigger-create-formula-field', fields: [ - { name: 'Name', type: FieldType.SingleLineText, isPrimary: true }, + { name: 'Name', type: FieldType.SingleLineText }, { id: sourceFieldId, name: amountTextFieldName, type: FieldType.Number }, ], }); @@ -499,7 +384,7 @@ describe('V2 action trigger field conversion (e2e)', () => { ); expect(response.status).toBe(201); - expect(response.headers['x-teable-v2']).toBe('true'); + expect(response.headers[v2ResponseHeader]).toBe('true'); }, }); diff --git a/apps/nestjs-backend/test/view.e2e-spec.ts b/apps/nestjs-backend/test/view.e2e-spec.ts index 7d39d83e89..8fe3bca5bb 100644 --- a/apps/nestjs-backend/test/view.e2e-spec.ts +++ b/apps/nestjs-backend/test/view.e2e-spec.ts @@ -48,6 +48,7 @@ import { createView, permanentDeleteTable, createTable, + deleteField, getViews, getView, getTable, @@ -98,7 +99,18 @@ describe('OpenAPI ViewController (e2e)', () => { type: ViewType.Grid, }; - await createView(table.id, viewRo); + 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([ @@ -232,6 +244,47 @@ describe('OpenAPI ViewController (e2e)', () => { 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', diff --git a/apps/nestjs-backend/vitest-e2e.config.ts b/apps/nestjs-backend/vitest-e2e.config.ts index cbc8f9ee06..cf3f329cc9 100644 --- a/apps/nestjs-backend/vitest-e2e.config.ts +++ b/apps/nestjs-backend/vitest-e2e.config.ts @@ -19,6 +19,9 @@ const testFiles = ['**/test/**/*.{e2e-test,e2e-spec}.{js,ts}']; export default defineConfig({ resolve: { + alias: { + buffer: 'node:buffer', + }, conditions: ['@teable/source'], }, ssr: { diff --git a/apps/nestjs-backend/vitest-e2e.setup.ts b/apps/nestjs-backend/vitest-e2e.setup.ts index 541ed8f203..453d499d82 100644 --- a/apps/nestjs-backend/vitest-e2e.setup.ts +++ b/apps/nestjs-backend/vitest-e2e.setup.ts @@ -1,9 +1,15 @@ import path from 'path'; +import { Buffer as NodeBuffer } from 'node:buffer'; +import { createRequire } from 'node:module'; import type { INestApplication } from '@nestjs/common'; import { DriverClient, parseDsn } from '@teable/core'; import dotenv from 'dotenv-flow'; import { buildSync } from 'esbuild'; +const require = createRequire(import.meta.url); +const bufferModule = require('buffer') as { Buffer: typeof NodeBuffer; SlowBuffer?: unknown }; +bufferModule.SlowBuffer ??= bufferModule.Buffer ?? NodeBuffer; + // Handle ConditionalModule timeout errors that occur sporadically in CI // These errors are thrown from setTimeout callbacks and cannot be caught normally // See: @nestjs/config ConditionalModule.registerWhen diff --git a/apps/nestjs-backend/vitest.config.ts b/apps/nestjs-backend/vitest.config.ts index 01e5de3229..9791020893 100644 --- a/apps/nestjs-backend/vitest.config.ts +++ b/apps/nestjs-backend/vitest.config.ts @@ -6,6 +6,9 @@ const testFiles = ['**/src/**/*.{test,spec}.{js,ts}']; export default defineConfig({ resolve: { + alias: { + buffer: 'node:buffer', + }, conditions: ['@teable/source'], }, ssr: { @@ -26,6 +29,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', + setupFiles: './vitest.setup.ts', passWithNoTests: true, pool: 'forks', coverage: { diff --git a/apps/nestjs-backend/vitest.setup.ts b/apps/nestjs-backend/vitest.setup.ts new file mode 100644 index 0000000000..c6f2773240 --- /dev/null +++ b/apps/nestjs-backend/vitest.setup.ts @@ -0,0 +1,6 @@ +import { Buffer as NodeBuffer } from 'node:buffer'; +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; diff --git a/apps/nextjs-app/.env.development b/apps/nextjs-app/.env.development index 806a5b602a..ac21905a29 100644 --- a/apps/nextjs-app/.env.development +++ b/apps/nextjs-app/.env.development @@ -19,10 +19,10 @@ PUBLIC_ORIGIN=http://localhost:3000 I18N_TYPES_OUTPUT_PATH=./src/types/i18n.generated.ts # DATABASE_URL # @see https://www.prisma.io/docs/reference/database-reference/connection-urls#examples -PRISMA_DATABASE_URL=postgresql://teable:teable@127.0.0.1:5432/teable?schema=public&statement_cache_size=1 +PRISMA_DATABASE_URL=postgresql://teable:teable@127.0.0.1:5432/teable?schema=public&statement_cache_size=0 # Optional split-db override: -# PRISMA_META_DATABASE_URL=postgresql://teable:teable@127.0.0.1:5432/teable-meta?schema=public&statement_cache_size=1 -# PRISMA_DATA_DATABASE_URL=postgresql://teable:teable@127.0.0.1:5432/teable-data?schema=public&statement_cache_size=1 +# PRISMA_META_DATABASE_URL=postgresql://teable:teable@127.0.0.1:5432/teable-meta?schema=public&statement_cache_size=0 +# PRISMA_DATA_DATABASE_URL=postgresql://teable:teable@127.0.0.1:5432/teable-data?schema=public&statement_cache_size=0 PUBLIC_DATABASE_PROXY=127.0.0.1:5432 API_DOC_DISENABLED=false diff --git a/apps/nextjs-app/.env.example b/apps/nextjs-app/.env.example index 552a1ac41e..7e29d60b3e 100644 --- a/apps/nextjs-app/.env.example +++ b/apps/nextjs-app/.env.example @@ -51,6 +51,10 @@ UMAMI_URL=https://umami.example.com/script.js GA_ID=your-google-analytics-id +# set PostHog project API key and host to enable product analytics (leave empty to disable) +# POSTHOG_KEY=phc_xxx +# POSTHOG_HOST=https://us.i.posthog.com + # The spaceId where your template base is located, it is the basic info of template center operation TEMPLATE_SPACE_ID=your-template-space-id # template site link, you need to set the current value to enable create from template diff --git a/apps/nextjs-app/.env.test b/apps/nextjs-app/.env.test index 6c6748816f..93d1274fb8 100644 --- a/apps/nextjs-app/.env.test +++ b/apps/nextjs-app/.env.test @@ -16,7 +16,7 @@ STORAGE_PREFIX=http://127.0.0.1:3000 # DATABASE_URL # @see https://www.prisma.io/docs/reference/database-reference/connection-urls#examples -PRISMA_DATABASE_URL=postgresql://teable:teable@127.0.0.1:5432/teable?schema=public&statement_cache_size=1 +PRISMA_DATABASE_URL=postgresql://teable:teable@127.0.0.1:5432/teable?schema=public&statement_cache_size=0 PUBLIC_DATABASE_PROXY=127.0.0.1:5432 BACKEND_CACHE_PROVIDER=memory diff --git a/apps/nextjs-app/next.config.js b/apps/nextjs-app/next.config.js index a5cde655f2..fac2356482 100644 --- a/apps/nextjs-app/next.config.js +++ b/apps/nextjs-app/next.config.js @@ -83,6 +83,7 @@ const secureHeaders = createSecureHeaders({ "'unsafe-eval'", "'unsafe-inline'", 'https://www.clarity.ms', + 'https://*.posthog.com', 'https://*.teable.io', 'https://*.teable.ai', 'https://*.teable.cn', @@ -95,6 +96,7 @@ const secureHeaders = createSecureHeaders({ 'https://*.teable.ai', 'https://*.teable.cn', 'https://*.clarity.ms', + 'https://*.posthog.com', ], mediaSrc: ["'self'", 'https:', 'http:', 'data:'], imgSrc: ["'self'", 'https:', 'http:', 'data:'], @@ -243,7 +245,11 @@ const nextConfig = { source: '/:path((?!api|streamsaver).*)*', headers: [ ...secureHeaders, - { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' }, + // allow-popups (not same-origin) so OAuth connect popups keep their + // opener link across the cross-origin provider hop — otherwise the + // opener can't close the popup and window.close() inside it is blocked, + // leaving a stranded "Connected" window. (streamsaver uses the same.) + { key: 'Cross-Origin-Opener-Policy', value: 'same-origin-allow-popups' }, { key: 'Cross-Origin-Embedder-Policy', value: 'same-origin' }, ], }, diff --git a/apps/nextjs-app/public/images/layout/app-builder-dark.png b/apps/nextjs-app/public/images/layout/app-builder-dark.png new file mode 100644 index 0000000000..e6b8bd3bd7 Binary files /dev/null and b/apps/nextjs-app/public/images/layout/app-builder-dark.png differ diff --git a/apps/nextjs-app/public/images/layout/app-builder-light.png b/apps/nextjs-app/public/images/layout/app-builder-light.png new file mode 100644 index 0000000000..2478012861 Binary files /dev/null and b/apps/nextjs-app/public/images/layout/app-builder-light.png differ diff --git a/apps/nextjs-app/public/images/layout/chat-dark.png b/apps/nextjs-app/public/images/layout/chat-dark.png new file mode 100644 index 0000000000..4b01aaaf79 Binary files /dev/null and b/apps/nextjs-app/public/images/layout/chat-dark.png differ diff --git a/apps/nextjs-app/public/images/layout/chat-light.png b/apps/nextjs-app/public/images/layout/chat-light.png new file mode 100644 index 0000000000..5f34050d40 Binary files /dev/null and b/apps/nextjs-app/public/images/layout/chat-light.png differ diff --git a/apps/nextjs-app/src/components/Metrics.tsx b/apps/nextjs-app/src/components/Metrics.tsx index 1eb6a665bd..d7b1f0f860 100644 --- a/apps/nextjs-app/src/components/Metrics.tsx +++ b/apps/nextjs-app/src/components/Metrics.tsx @@ -4,10 +4,21 @@ import { syncMarketingAttributionFromUrl } from '@/lib/marketing-attribution'; const GOOGLE_LINKER_DOMAINS = ['teable.ai', 'app.teable.ai']; +const POSTHOG_DEFAULT_HOST = 'https://us.i.posthog.com'; +const INTERNAL_EMAIL_REGEX = /@teable\.(?:io|ai|cn)$/i; + +interface IPostHog { + init: (key: string, config: Record) => void; + capture: (event: string, properties?: Record) => void; + identify: (distinctId: string, properties?: Record) => void; + reset: () => void; +} + declare global { interface Window { gtag?: (command: string, targetId: string | Date, config?: Record) => void; dataLayer?: unknown[]; + posthog?: IPostHog; } } @@ -172,3 +183,67 @@ export const GoogleAnalytics = ({ ); }; + +export const PostHog = ({ + posthogKey, + posthogHost, + user, +}: { + posthogKey?: string; + posthogHost?: string; + user?: { + id?: string; + name?: string; + email?: string; + }; +}) => { + const apiHost = posthogHost || POSTHOG_DEFAULT_HOST; + const userId = user?.id; + const userEmail = user?.email; + const userName = user?.name; + const [isReady, setIsReady] = useState(false); + + // Tie events to a stable person (user.id) once identified; reset on logged-out / + // anonymous surfaces (e.g. share pages) so a previous identity doesn't leak. + useEffect(() => { + if (!isReady || !window.posthog) { + return; + } + if (userId) { + window.posthog.identify(userId, { + email: userEmail, + name: userName, + is_employee: userEmail ? INTERNAL_EMAIL_REGEX.test(userEmail) : false, + }); + } else { + window.posthog.reset(); + } + }, [isReady, userId, userEmail, userName]); + + if (!posthogKey) { + return null; + } + + return ( +