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 ``;
- }
-
- 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\n
\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