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/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/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/cache/types.ts b/apps/nestjs-backend/src/cache/types.ts
index 942cc59623..dce4141198 100644
--- a/apps/nestjs-backend/src/cache/types.ts
+++ b/apps/nestjs-backend/src/cache/types.ts
@@ -34,7 +34,7 @@ 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;
// Distributed lock keys
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/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/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..36aa9569ef 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
@@ -9,6 +9,7 @@ import { getV2CreateTableLegacyEventsFlag } from '../../features/v2/v2-create-ta
import { ShareDbService } from '../../share-db/share-db.service';
import type { IClsStore } from '../../types/cls';
import type {
+ IChangeRecord,
RecordCreateEvent,
RecordDeleteEvent,
RecordUpdateEvent,
@@ -19,6 +20,17 @@ 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];
+};
+
type IViewEvent = ViewUpdateEvent;
type IRecordEvent = RecordCreateEvent | RecordDeleteEvent | RecordUpdateEvent;
type IListenerEvent =
@@ -141,17 +153,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);
}
}
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/auth/decorators/permissions.decorator.ts b/apps/nestjs-backend/src/features/auth/decorators/permissions.decorator.ts
index 8ade9c4780..14743f3633 100644
--- a/apps/nestjs-backend/src/features/auth/decorators/permissions.decorator.ts
+++ b/apps/nestjs-backend/src/features/auth/decorators/permissions.decorator.ts
@@ -2,6 +2,11 @@ import { SetMetadata } from '@nestjs/common';
import type { Action } from '@teable/core';
export const PERMISSIONS_KEY = 'permissions';
+export const ANY_PERMISSIONS_KEY = 'anyPermissions';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const Permissions = (...permissions: Action[]) => SetMetadata(PERMISSIONS_KEY, permissions);
+
+// eslint-disable-next-line @typescript-eslint/naming-convention
+export const AnyPermissions = (...permissionGroups: Action[][]) =>
+ SetMetadata(ANY_PERMISSIONS_KEY, permissionGroups);
diff --git a/apps/nestjs-backend/src/features/auth/guard/permission.guard.spec.ts b/apps/nestjs-backend/src/features/auth/guard/permission.guard.spec.ts
new file mode 100644
index 0000000000..9c13202fb8
--- /dev/null
+++ b/apps/nestjs-backend/src/features/auth/guard/permission.guard.spec.ts
@@ -0,0 +1,125 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import type { ExecutionContext } from '@nestjs/common';
+import type { Reflector } from '@nestjs/core';
+import { HttpErrorCode, type Action } from '@teable/core';
+import type { ClsService } from 'nestjs-cls';
+import { CustomHttpException } from '../../../custom.exception';
+import type { IClsStore } from '../../../types/cls';
+import type { PermissionService } from '../permission.service';
+import { PermissionGuard } from './permission.guard';
+
+vi.mock('../permission.service', () => ({
+ PermissionService: class PermissionService {},
+}));
+
+const tableId = 'tblxxxxxxxxxxxx';
+const tableUpdatePermissions: Action[] = ['table|update'];
+const instanceUpdatePermissions: Action[] = ['instance|update'];
+
+const createContext = (): ExecutionContext =>
+ ({
+ getHandler: vi.fn(),
+ getClass: vi.fn(),
+ switchToHttp: () => ({
+ getRequest: () => ({
+ params: { tableId },
+ headers: {},
+ }),
+ }),
+ }) as unknown as ExecutionContext;
+
+const createForbiddenError = (permissions: Action[]) =>
+ new CustomHttpException(
+ `not allowed to operate ${permissions.join(', ')} on ${tableId}`,
+ HttpErrorCode.RESTRICTED_RESOURCE
+ );
+
+describe('PermissionGuard', () => {
+ const createGuard = ({
+ primaryPermissions,
+ anyPermissions,
+ isAdmin = false,
+ validPermissions,
+ }: {
+ primaryPermissions: Action[];
+ anyPermissions?: Action[][];
+ isAdmin?: boolean;
+ validPermissions: PermissionService['validPermissions'];
+ }) => {
+ const reflector = {
+ getAllAndOverride: vi.fn((key: string) => {
+ if (key === 'permissions') {
+ return primaryPermissions;
+ }
+ if (key === 'anyPermissions') {
+ return anyPermissions;
+ }
+ return undefined;
+ }),
+ } as unknown as Reflector;
+ const cls = {
+ get: vi.fn((key: string) => {
+ if (key === 'user.id') {
+ return 'usrxxxxxxxxxxxx';
+ }
+ if (key === 'user.isAdmin') {
+ return isAdmin;
+ }
+ return undefined;
+ }),
+ set: vi.fn(),
+ } as unknown as ClsService;
+ const permissionService = {
+ validPermissions,
+ } as unknown as PermissionService;
+
+ return {
+ guard: new PermissionGuard(reflector, cls, permissionService),
+ cls,
+ permissionService,
+ };
+ };
+
+ it('keeps table update as the primary permission for alternative permission routes', async () => {
+ const validPermissions = vi.fn().mockResolvedValue(tableUpdatePermissions);
+ const { guard, cls } = createGuard({
+ primaryPermissions: tableUpdatePermissions,
+ anyPermissions: [instanceUpdatePermissions],
+ validPermissions,
+ });
+
+ await expect(guard.canActivate(createContext())).resolves.toBe(true);
+
+ expect(validPermissions).toHaveBeenCalledWith(tableId, tableUpdatePermissions, undefined);
+ expect(cls.get).not.toHaveBeenCalledWith('user.isAdmin');
+ });
+
+ it('allows instance admins through alternative permissions when table update is unavailable', async () => {
+ const validPermissions = vi
+ .fn()
+ .mockRejectedValue(createForbiddenError(tableUpdatePermissions));
+ const { guard } = createGuard({
+ primaryPermissions: tableUpdatePermissions,
+ anyPermissions: [instanceUpdatePermissions],
+ isAdmin: true,
+ validPermissions,
+ });
+
+ await expect(guard.canActivate(createContext())).resolves.toBe(true);
+ });
+
+ it('still rejects users without table update or instance update', async () => {
+ const validPermissions = vi
+ .fn()
+ .mockRejectedValue(createForbiddenError(tableUpdatePermissions));
+ const { guard } = createGuard({
+ primaryPermissions: tableUpdatePermissions,
+ anyPermissions: [instanceUpdatePermissions],
+ validPermissions,
+ });
+
+ await expect(guard.canActivate(createContext())).rejects.toThrow(
+ `not allowed to operate table|update on ${tableId}`
+ );
+ });
+});
diff --git a/apps/nestjs-backend/src/features/auth/guard/permission.guard.ts b/apps/nestjs-backend/src/features/auth/guard/permission.guard.ts
index c5f52475f8..42cb61f1ab 100644
--- a/apps/nestjs-backend/src/features/auth/guard/permission.guard.ts
+++ b/apps/nestjs-backend/src/features/auth/guard/permission.guard.ts
@@ -8,7 +8,7 @@ import { CustomHttpException } from '../../../custom.exception';
import type { IClsStore } from '../../../types/cls';
import { AllowAnonymousType, IS_ALLOW_ANONYMOUS } from '../decorators/allow-anonymous.decorator';
import { IS_DISABLED_PERMISSION } from '../decorators/disabled-permission.decorator';
-import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
+import { ANY_PERMISSIONS_KEY, PERMISSIONS_KEY } from '../decorators/permissions.decorator';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import type { IResourceMeta } from '../decorators/resource_meta.decorator';
import { RESOURCE_META } from '../decorators/resource_meta.decorator';
@@ -323,6 +323,10 @@ export class PermissionGuard {
context.getHandler(),
context.getClass(),
]);
+ const anyPermissions = this.reflector.getAllAndOverride(
+ ANY_PERMISSIONS_KEY,
+ [context.getHandler(), context.getClass()]
+ );
const resourceId = this.getResourceId(context) || this.defaultResourceId(context);
const accessTokenId = this.cls.get('accessTokenId');
if (accessTokenId && !permissions?.length) {
@@ -337,6 +341,24 @@ export class PermissionGuard {
if (!permissions?.length) {
return true;
}
+ if (anyPermissions?.length) {
+ try {
+ return await this.checkPermissions(resourceId, permissions);
+ } catch (error) {
+ for (const permissionGroup of anyPermissions) {
+ try {
+ return await this.checkPermissions(resourceId, permissionGroup);
+ } catch {
+ // Try the next alternative and preserve the primary error if all alternatives fail.
+ }
+ }
+ throw error;
+ }
+ }
+ return await this.checkPermissions(resourceId, permissions);
+ }
+
+ private async checkPermissions(resourceId: string | undefined, permissions: Action[]) {
// instance permission check
if (permissions?.includes('instance|update')) {
return this.instancePermissionChecker('instance|update');
diff --git a/apps/nestjs-backend/src/features/base-share/base-share.service.ts b/apps/nestjs-backend/src/features/base-share/base-share.service.ts
index 2d49402469..bb623580b8 100644
--- a/apps/nestjs-backend/src/features/base-share/base-share.service.ts
+++ b/apps/nestjs-backend/src/features/base-share/base-share.service.ts
@@ -5,9 +5,12 @@ import type { ICreateBaseShareRo, IUpdateBaseShareRo, IBaseShareVo } from '@teab
import { BaseNodeResourceType } from '@teable/openapi';
import { ClsService } from 'nestjs-cls';
import { CustomHttpException } from '../../custom.exception';
+import { Events } from '../../event-emitter/events';
import { PerformanceCache, PerformanceCacheService } from '../../performance-cache';
import { generateBaseShareListCacheKey } from '../../performance-cache/generate-keys';
import type { IClsStore } from '../../types/cls';
+import { AuditScope } from '../audit/audit-scope';
+import { Audit } from '../audit/audit.decorator';
const baseShareNotFoundMessage = 'Base share not found';
const baseShareNotFoundKey = 'httpErrors.baseShare.notFound';
@@ -19,7 +22,8 @@ export class BaseShareService {
constructor(
private readonly prismaService: PrismaService,
private readonly cls: ClsService,
- private readonly performanceCacheService: PerformanceCacheService
+ private readonly performanceCacheService: PerformanceCacheService,
+ private readonly audit: AuditScope
) {}
private async invalidateBaseShareListCache(baseId: string): Promise {
@@ -75,6 +79,16 @@ export class BaseShareService {
};
}
+ @Audit({
+ action: Events.BASE_SHARE_CREATE,
+ resourceId: (baseId: string) => baseId,
+ params: (baseId: string, data: ICreateBaseShareRo) => ({
+ baseId,
+ nodeId: data.nodeId ?? null,
+ type: data.nodeId ? 'node' : 'base',
+ }),
+ emit: (result: IBaseShareVo) => ({ shareId: result.shareId, enabled: result.enabled }),
+ })
async createBaseShare(baseId: string, data: ICreateBaseShareRo): Promise {
const nodeId = data.nodeId ?? null;
@@ -154,6 +168,22 @@ export class BaseShareService {
return this.formatBaseShareVo(share);
}
+ @Audit({
+ action: Events.BASE_SHARE_UPDATE,
+ resourceId: (_baseId: string, shareId: string) => shareId,
+ params: (baseId: string, shareId: string, data: IUpdateBaseShareRo) => ({
+ baseId,
+ shareId,
+ // First-pass: store raw request body so any field change is preserved.
+ // Password value itself is never logged — controller-layer secrets stay opaque.
+ changes: { ...data, password: data.password !== undefined ? '[set]' : undefined },
+ }),
+ emit: (result: IBaseShareVo) => ({
+ nodeId: result.nodeId,
+ type: result.nodeId ? 'node' : 'base',
+ enabled: result.enabled,
+ }),
+ })
async updateBaseShare(
baseId: string,
shareId: string,
@@ -199,6 +229,12 @@ export class BaseShareService {
return this.formatBaseShareVo(updated);
}
+ @Audit({
+ action: Events.BASE_SHARE_DELETE,
+ resourceId: (_baseId: string, shareId: string) => shareId,
+ params: (baseId: string, shareId: string) => ({ baseId, shareId }),
+ emit: true,
+ })
async deleteBaseShare(baseId: string, shareId: string): Promise {
const share = await this.prismaService.baseShare.findFirst({
where: { baseId, shareId, enabled: true },
@@ -222,6 +258,12 @@ export class BaseShareService {
await this.invalidateBaseShareListCache(baseId);
}
+ @Audit({
+ action: Events.BASE_SHARE_REFRESH,
+ resourceId: (_baseId: string, shareId: string) => shareId,
+ params: (baseId: string, shareId: string) => ({ baseId, oldShareId: shareId }),
+ emit: (result: IBaseShareVo) => ({ newShareId: result.shareId }),
+ })
async refreshBaseShareId(baseId: string, shareId: string): Promise {
const share = await this.prismaService.baseShare.findFirst({
where: { baseId, shareId, enabled: true },
diff --git a/apps/nestjs-backend/src/features/base-sql-executor/allowed-functions.ts b/apps/nestjs-backend/src/features/base-sql-executor/allowed-functions.ts
new file mode 100644
index 0000000000..c965202d92
--- /dev/null
+++ b/apps/nestjs-backend/src/features/base-sql-executor/allowed-functions.ts
@@ -0,0 +1,144 @@
+/**
+ * Whitelist of PostgreSQL functions allowed in user-facing sql-query API.
+ * Any function NOT in this set will be rejected at the AST validation layer.
+ *
+ * Criteria for inclusion:
+ * - IMMUTABLE or STABLE, or VOLATILE but side-effect-free (e.g. now(), timezone())
+ * - Cannot execute embedded SQL (excludes query_to_xml, ts_stat, ts_rewrite, etc.)
+ * - Cannot modify database state (excludes setval, lo_create, set_config, etc.)
+ * - Cannot acquire locks or send notifications (excludes pg_advisory_lock, pg_notify, etc.)
+ * - Cannot generate unbounded rows from nothing (excludes generate_series)
+ */
+export const allowedFunctions = new Set([
+ // ── Aggregation ──
+ 'avg',
+ 'bool_and',
+ 'bool_or',
+ 'count',
+ 'every',
+ 'json_agg',
+ 'jsonb_agg',
+ 'max',
+ 'min',
+ 'string_agg',
+ 'sum',
+ 'array_agg',
+
+ // ── Window ──
+ 'cume_dist',
+ 'dense_rank',
+ 'first_value',
+ 'lag',
+ 'last_value',
+ 'lead',
+ 'nth_value',
+ 'ntile',
+ 'percent_rank',
+ 'rank',
+ 'row_number',
+
+ // ── Math ──
+ 'abs',
+ 'ceil',
+ 'ceiling',
+ 'div',
+ 'floor',
+ 'greatest',
+ 'least',
+ 'mod',
+ 'power',
+ 'pow',
+ 'round',
+ 'sign',
+ 'sqrt',
+ 'trunc',
+
+ // ── String ──
+ 'ascii',
+ 'char_length',
+ 'chr',
+ 'concat',
+ 'concat_ws',
+ 'initcap',
+ 'left',
+ 'length',
+ 'lower',
+ 'lpad',
+ 'ltrim',
+ 'md5',
+ 'position',
+ 'regexp_match',
+ 'regexp_matches',
+ 'regexp_replace',
+ 'repeat',
+ 'replace',
+ 'reverse',
+ 'right',
+ 'rpad',
+ 'rtrim',
+ 'split_part',
+ 'starts_with',
+ 'strpos',
+ 'substr',
+ 'substring',
+ 'translate',
+ 'trim',
+ 'upper',
+
+ // ── Date / Time ──
+ 'age',
+ 'date_part',
+ 'date_trunc',
+ 'extract',
+ 'make_date',
+ 'make_timestamp',
+ 'now',
+ 'to_char',
+ 'to_date',
+ 'to_number',
+ 'to_timestamp',
+ 'timezone',
+
+ // ── JSON / JSONB ──
+ 'json_array_elements',
+ 'json_array_elements_text',
+ 'json_array_length',
+ 'json_build_array',
+ 'json_build_object',
+ 'json_extract_path',
+ 'json_extract_path_text',
+ 'json_typeof',
+ 'jsonb_array_elements',
+ 'jsonb_array_elements_text',
+ 'jsonb_array_length',
+ 'jsonb_build_array',
+ 'jsonb_build_object',
+ 'jsonb_each',
+ 'jsonb_each_text',
+ 'jsonb_extract_path',
+ 'jsonb_extract_path_text',
+ 'jsonb_object_keys',
+ 'jsonb_pretty',
+ 'jsonb_set',
+ 'jsonb_strip_nulls',
+ 'jsonb_typeof',
+ 'to_json',
+ 'to_jsonb',
+
+ // ── Array ──
+ 'array_append',
+ 'array_cat',
+ 'array_length',
+ 'array_position',
+ 'array_remove',
+ 'array_to_string',
+ 'string_to_array',
+ 'unnest',
+
+ // ── Conditional ──
+ 'coalesce',
+ 'nullif',
+
+ // ── Type conversion ──
+ 'cast',
+]);
diff --git a/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts b/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts
index d8f21106e4..39765390e9 100644
--- a/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts
+++ b/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts
@@ -5,6 +5,7 @@ import { DriverClient, HttpErrorCode, parseDsn } from '@teable/core';
import { Prisma, PrismaService, getDatabaseUrl } from '@teable/db-main-prisma';
import { Knex } from 'knex';
import { InjectModel } from 'nest-knexjs';
+import { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config';
import { CustomHttpException } from '../../custom.exception';
import { DatabaseRouter } from '../../global/database-router.service';
import { DATA_KNEX } from '../../global/knex';
@@ -21,7 +22,8 @@ export class BaseSqlExecutorService {
private readonly prismaService: PrismaService,
private readonly databaseRouter: DatabaseRouter,
private readonly configService: ConfigService,
- @InjectModel(DATA_KNEX) private readonly knex: Knex
+ @InjectModel(DATA_KNEX) private readonly knex: Knex,
+ @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig
) {
this.dsn = parseDsn(this.getDatabaseUrl());
this.driver = this.dsn.driver as DriverClient;
@@ -144,23 +146,27 @@ export class BaseSqlExecutorService {
}
}
- private async setRole(
+ private async setLocalRole(
prisma: { $executeRawUnsafe(query: string): Promise },
baseId: string
) {
const roleName = this.getReadOnlyRoleName(baseId);
- await prisma.$executeRawUnsafe(this.knex.raw(`SET ROLE ??`, [roleName]).toQuery());
+ await prisma.$executeRawUnsafe(this.knex.raw(`SET LOCAL ROLE ??`, [roleName]).toQuery());
}
- private async resetRole(prisma: { $executeRawUnsafe(query: string): Promise }) {
- await prisma.$executeRawUnsafe(this.knex.raw(`RESET ROLE`).toQuery());
+ private async setTransactionReadOnly(prisma: {
+ $executeRawUnsafe(query: string): Promise;
+ }) {
+ await prisma.$executeRawUnsafe('SET TRANSACTION READ ONLY');
}
- private async readonlyExecuteSql(baseId: string, sql: string) {
- return this.databaseRouter.dataPrismaTransactionForBase(baseId, async (prisma) => {
- await prisma.$executeRawUnsafe('SET TRANSACTION READ ONLY');
- return await prisma.$queryRawUnsafe(sql);
- });
+ private async setLocalStatementTimeout(prisma: {
+ $executeRawUnsafe(query: string): Promise;
+ }) {
+ const timeoutMs = this.thresholdConfig.searchTimeout;
+ await prisma.$executeRawUnsafe(
+ this.knex.raw(`SET LOCAL statement_timeout = ?`, [timeoutMs]).toQuery()
+ );
}
/**
@@ -197,23 +203,7 @@ export class BaseSqlExecutorService {
database: this.driver,
});
// 3. read only role check table access, only pg and pg version > 14 support
- try {
- await this.readonlyExecuteSql(baseId, sql);
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- } catch (error: any) {
- throw new CustomHttpException(
- `read only check failed: ${error?.meta?.message || error?.message}`,
- HttpErrorCode.VALIDATION_ERROR,
- {
- localization: {
- i18nKey: 'httpErrors.baseSqlExecutor.readOnlyCheckFailed',
- context: {
- message: error?.meta?.message || error?.message,
- },
- },
- }
- );
- }
+ // TODO: need read only db connection for better security
}
async executeQuerySql(
@@ -228,7 +218,9 @@ export class BaseSqlExecutorService {
await this.roleCheckAndCreate(baseId);
return this.databaseRouter.dataPrismaTransactionForBase(baseId, async (prisma) => {
try {
- await this.setRole(prisma, baseId);
+ await this.setLocalStatementTimeout(prisma);
+ await this.setTransactionReadOnly(prisma);
+ await this.setLocalRole(prisma, baseId);
return await prisma.$queryRawUnsafe(sql);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
@@ -244,10 +236,6 @@ export class BaseSqlExecutorService {
},
}
);
- } finally {
- await this.resetRole(prisma).catch((error) => {
- console.log('resetRole error', error);
- });
}
});
}
diff --git a/apps/nestjs-backend/src/features/base-sql-executor/utils.spec.ts b/apps/nestjs-backend/src/features/base-sql-executor/utils.spec.ts
index dc37594fc4..e618634237 100644
--- a/apps/nestjs-backend/src/features/base-sql-executor/utils.spec.ts
+++ b/apps/nestjs-backend/src/features/base-sql-executor/utils.spec.ts
@@ -13,6 +13,14 @@ describe('base sql executor utils', () => {
expect(() => validateRoleOperations('set role xxx;')).toThrow();
});
+ it('should throw an error if the sql contains set local role', () => {
+ expect(() => validateRoleOperations('set local role xxx')).toThrow();
+ });
+
+ it('should throw an error if the sql contains set session role', () => {
+ expect(() => validateRoleOperations('set session role xxx')).toThrow();
+ });
+
it('should throw an error if the sql contains set role with line break', () => {
expect(() =>
validateRoleOperations(`set
@@ -136,5 +144,74 @@ describe('base sql executor utils', () => {
})
).not.toThrow();
});
+
+ it.each([
+ 'SELECT count(*) FROM "bseXXX"."tblYYY"',
+ 'SELECT sum("amount") FROM "bseXXX"."tblYYY"',
+ 'SELECT abs("amount") FROM "bseXXX"."tblYYY"',
+ 'SELECT round("amount", 2) FROM "bseXXX"."tblYYY"',
+ 'SELECT floor("amount") FROM "bseXXX"."tblYYY"',
+ 'SELECT ceil("amount") FROM "bseXXX"."tblYYY"',
+ 'SELECT ceiling("amount") FROM "bseXXX"."tblYYY"',
+ 'SELECT lower("name") FROM "bseXXX"."tblYYY"',
+ 'SELECT replace("name", \'a\', \'b\') FROM "bseXXX"."tblYYY"',
+ 'SELECT regexp_replace("name", \'a\', \'b\') FROM "bseXXX"."tblYYY"',
+ 'SELECT substring("name" from 1 for 3) FROM "bseXXX"."tblYYY"',
+ 'SELECT substr("name", 1, 3) FROM "bseXXX"."tblYYY"',
+ 'SELECT concat("first_name", "last_name") FROM "bseXXX"."tblYYY"',
+ 'SELECT concat_ws(\' \', "first_name", "last_name") FROM "bseXXX"."tblYYY"',
+ 'SELECT split_part("name", \'/\', 1) FROM "bseXXX"."tblYYY"',
+ 'SELECT coalesce("name", \'unknown\') FROM "bseXXX"."tblYYY"',
+ 'SELECT to_char("created_time", \'YYYY-MM-DD\') FROM "bseXXX"."tblYYY"',
+ 'SELECT extract(year from "created_time") FROM "bseXXX"."tblYYY"',
+ 'SELECT json_extract_path_text("payload"::json, \'name\') FROM "bseXXX"."tblYYY"',
+ ])('allows explicitly permitted function in %s', (sql) => {
+ expect(() =>
+ checkTableAccess(sql, {
+ tableNames: ['bseXXX.tblYYY'],
+ database: DriverClient.Pg,
+ })
+ ).not.toThrow();
+ });
+
+ it.each([
+ [
+ "SELECT query_to_xml('SELECT rolname FROM pg_catalog.pg_roles', true, false, '')",
+ 'query_to_xml',
+ ],
+ [
+ "SELECT query_to_xmlschema('SELECT relname FROM pg_catalog.pg_class', true, false, '')",
+ 'query_to_xmlschema',
+ ],
+ [
+ "SELECT query_to_xml_and_xmlschema('SELECT table_name FROM information_schema.tables', true, false, '')",
+ 'query_to_xml_and_xmlschema',
+ ],
+ ["SELECT ts_stat('SELECT to_tsvector(''simple'', ''abc'')')", 'ts_stat'],
+ ["SELECT set_config('statement_timeout','0',true)", 'set_config'],
+ ['SELECT lo_create(0)', 'lo_create'],
+ ["SELECT lo_from_bytea(0, decode('414243','hex'))", 'lo_from_bytea'],
+ ['SELECT pg_try_advisory_lock(123456789)', 'pg_try_advisory_lock'],
+ ['SELECT pg_advisory_unlock_all()', 'pg_advisory_unlock_all'],
+ [
+ "WITH n AS (SELECT pg_notify('cuppy_probe', 'blackbox')) SELECT 'ok' AS result FROM n",
+ 'pg_notify',
+ ],
+ ["SELECT pg_logical_emit_message(false, 'cuppy_probe', 'hello')", 'pg_logical_emit_message'],
+ ['SELECT pg_export_snapshot()', 'pg_export_snapshot'],
+ ['SELECT pg_lock_status()', 'pg_lock_status'],
+ ['SELECT pg_control_system()', 'pg_control_system'],
+ ['SELECT pg_current_wal_lsn()', 'pg_current_wal_lsn'],
+ ['SELECT pg_database_size(current_database())', 'pg_database_size'],
+ ['SELECT version()', 'version'],
+ ["SELECT pg_catalog.pg_notify('cuppy_probe', 'blackbox')", 'pg_notify'],
+ ])('blocks unapproved function %s', (sql, functionName) => {
+ expect(() =>
+ checkTableAccess(sql, {
+ tableNames: [],
+ database: DriverClient.Pg,
+ })
+ ).toThrow(new RegExp(`function ${functionName}`));
+ });
});
});
diff --git a/apps/nestjs-backend/src/features/base-sql-executor/utils.ts b/apps/nestjs-backend/src/features/base-sql-executor/utils.ts
index a56e1922e1..0474f82dc9 100644
--- a/apps/nestjs-backend/src/features/base-sql-executor/utils.ts
+++ b/apps/nestjs-backend/src/features/base-sql-executor/utils.ts
@@ -2,6 +2,9 @@ import { DriverClient, HttpErrorCode } from '@teable/core';
import type { AST } from 'node-sql-parser';
import { Parser } from 'node-sql-parser';
import { CustomHttpException } from '../../custom.exception';
+import { allowedFunctions } from './allowed-functions';
+
+const whiteListCheckErrorKey = 'httpErrors.baseSqlExecutor.whiteListCheckError';
export const validateRoleOperations = (sql: string) => {
const removeQuotedContent = (sql: string) => {
@@ -11,7 +14,11 @@ export const validateRoleOperations = (sql: string) => {
const normalizedSql = sql.toLowerCase().replace(/\s+/g, ' ');
const sqlWithoutQuotes = removeQuotedContent(normalizedSql);
- const roleOperationPatterns = [/set\s+role/, /reset\s+role/, /set\s+session/];
+ const roleOperationPatterns = [
+ /set\s+(?:local\s+|session\s+)?role/,
+ /reset\s+role/,
+ /set\s+session/,
+ ];
for (const pattern of roleOperationPatterns) {
if (pattern.test(sqlWithoutQuotes)) {
@@ -35,6 +42,79 @@ const databaseTypeMap = {
[DriverClient.Pg]: 'postgresql',
};
+const getFunctionName = (node: unknown): string | null => {
+ const functionNode = node as {
+ type?: unknown;
+ name?: { name?: Array<{ value?: unknown }> };
+ };
+ if (functionNode.type !== 'function') {
+ return null;
+ }
+
+ const nameParts = functionNode.name?.name;
+ const lastNamePart = nameParts?.[nameParts.length - 1]?.value;
+ return typeof lastNamePart === 'string' ? lastNamePart.toLowerCase() : null;
+};
+
+const findUnallowedFunctionInArray = (values: unknown[]): string | null => {
+ for (const value of values) {
+ const unallowed = findUnallowedFunction(value);
+ if (unallowed) {
+ return unallowed;
+ }
+ }
+
+ return null;
+};
+
+const findUnallowedFunctionInValue = (value: unknown): string | null => {
+ if (Array.isArray(value)) {
+ return findUnallowedFunctionInArray(value);
+ }
+
+ return findUnallowedFunction(value);
+};
+
+function findUnallowedFunction(node: unknown): string | null {
+ if (!node || typeof node !== 'object') {
+ return null;
+ }
+
+ const functionName = getFunctionName(node);
+ if (functionName && !allowedFunctions.has(functionName)) {
+ return functionName;
+ }
+
+ for (const value of Object.values(node)) {
+ const unallowed = findUnallowedFunctionInValue(value);
+ if (unallowed) {
+ return unallowed;
+ }
+ }
+
+ return null;
+}
+
+const validateFunctionCalls = (ast: AST | AST[]) => {
+ const unallowed = findUnallowedFunction(ast);
+ if (!unallowed) {
+ return;
+ }
+
+ throw new CustomHttpException(
+ `not allowed to execute sql with function ${unallowed}`,
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: whiteListCheckErrorKey,
+ context: {
+ function: unallowed,
+ },
+ },
+ }
+ );
+};
+
const collectWithNames = (ast?: AST) => {
if (!ast) {
return [];
@@ -79,6 +159,7 @@ export const checkTableAccess = (
);
}
})();
+ validateFunctionCalls(ast);
const withNames = Array.isArray(ast) ? ast.flatMap(collectWithNames) : collectWithNames(ast);
const allowedTables = new Set([...withNames, ...tableNames]);
const whiteColumnList = Array.from(allowedTables).map((table) => {
@@ -95,6 +176,18 @@ export const checkTableAccess = (
}
const sqlTableList = parser.tableList(sql, opt);
+
+ if (!sqlTableList.length) {
+ throw new CustomHttpException(
+ 'SQL syntax error or no table accessed, please check your query',
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.baseSqlExecutor.sqlSyntaxError',
+ },
+ }
+ );
+ }
const invalidTableNames = sqlTableList
.filter((t: string) => !whiteColumnList.includes(t))
.map((t: string) => t.split('::').pop()!);
@@ -111,7 +204,7 @@ export const checkTableAccess = (
HttpErrorCode.VALIDATION_ERROR,
{
localization: {
- i18nKey: 'httpErrors.baseSqlExecutor.whiteListCheckError',
+ i18nKey: whiteListCheckErrorKey,
context: {
message,
},
diff --git a/apps/nestjs-backend/src/features/base/base-duplicate.service.spec.ts b/apps/nestjs-backend/src/features/base/base-duplicate.service.spec.ts
index fc0f545ebb..cbe60640fd 100644
--- a/apps/nestjs-backend/src/features/base/base-duplicate.service.spec.ts
+++ b/apps/nestjs-backend/src/features/base/base-duplicate.service.spec.ts
@@ -2,11 +2,10 @@ import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import { FieldType, Relationship } from '@teable/core';
import { BaseDuplicateMode } from '@teable/openapi';
-import { v2RecordRepositoryPostgresTokens } from '@teable/v2-adapter-table-repository-postgres';
-import { TableByIdSpec, v2CoreTokens } from '@teable/v2-core';
+import { v2CoreTokens, type DuplicateBaseRecordReadOptions } from '@teable/v2-core';
import { GlobalModule } from '../../global/global.module';
import { BaseDuplicateService } from './base-duplicate.service';
-import type { BaseImportProgressCallback, IBaseImportProgress } from './base-import.service';
+import type { IBaseImportProgress } from './base-import.service';
import { BaseModule } from './base.module';
import type { ILinkFieldTableMap } from './utils';
@@ -150,7 +149,6 @@ describe('BaseDuplicateService normalizeDuplicateStructureForV2', () => {
});
describe('BaseDuplicateService duplicateBaseV2', () => {
- const okResult = (value: T) => ({ isErr: () => false, value });
type IServiceArgs = ConstructorParameters;
const duplicateBaseName = 'Duplicated base';
const sourceTableName = 'Source table';
@@ -160,15 +158,19 @@ describe('BaseDuplicateService duplicateBaseV2', () => {
buildDuplicateStructureConfig: (...args: unknown[]) => Promise;
getCrossBaseLinkFieldTableMap: (...args: unknown[]) => Promise;
getDisconnectedLinkFieldTableMap: (...args: unknown[]) => Promise;
- getDisconnectedLinkFieldIds: (...args: unknown[]) => Promise;
+ getV2CrossBaseLinkFieldTableMap: (...args: unknown[]) => Promise;
+ getV2DisconnectedLinkFieldTableMap: (...args: unknown[]) => Promise;
+ getV2InternalLinkRelationTableMap: (...args: unknown[]) => Promise>;
normalizeDuplicateStructureForV2: (structure: unknown) => unknown;
createDuplicateBaseSource: (...args: unknown[]) => {
- records(tableId: string): AsyncIterable<{ fields: Record }>;
+ records(
+ tableId: string,
+ options?: DuplicateBaseRecordReadOptions
+ ): AsyncIterable<{ fields: Record }>;
};
duplicateTableData: (...args: unknown[]) => Promise;
duplicateAttachments: (...args: unknown[]) => Promise;
duplicateLinkJunction: (...args: unknown[]) => Promise;
- backfillDuplicatedBaseComputedFields: (...args: unknown[]) => Promise;
};
it('should create the v2 execution context from the space data container', async () => {
@@ -231,9 +233,9 @@ describe('BaseDuplicateService duplicateBaseV2', () => {
{} as IServiceArgs[6],
{ get: vi.fn().mockReturnValue('usrTest') } as unknown as IServiceArgs[7],
{} as IServiceArgs[8],
- {} as IServiceArgs[9],
- v2ContainerService as unknown as IServiceArgs[10],
- v2ContextFactory as unknown as IServiceArgs[11]
+ v2ContainerService as unknown as IServiceArgs[9],
+ v2ContextFactory as unknown as IServiceArgs[10],
+ {} as IServiceArgs[11]
);
const internals = service as unknown as IDuplicateServiceInternals;
@@ -243,9 +245,9 @@ describe('BaseDuplicateService duplicateBaseV2', () => {
structure,
sourceDbTableNameByTableId,
});
- vi.spyOn(internals, 'getCrossBaseLinkFieldTableMap').mockResolvedValue({});
- vi.spyOn(internals, 'getDisconnectedLinkFieldTableMap').mockResolvedValue({});
- vi.spyOn(internals, 'getDisconnectedLinkFieldIds').mockResolvedValue([]);
+ vi.spyOn(internals, 'getV2CrossBaseLinkFieldTableMap').mockResolvedValue({});
+ vi.spyOn(internals, 'getV2DisconnectedLinkFieldTableMap').mockResolvedValue({});
+ vi.spyOn(internals, 'getV2InternalLinkRelationTableMap').mockResolvedValue({});
vi.spyOn(internals, 'normalizeDuplicateStructureForV2').mockReturnValue(structure);
vi.spyOn(internals, 'createDuplicateBaseSource').mockReturnValue(source);
@@ -262,12 +264,13 @@ describe('BaseDuplicateService duplicateBaseV2', () => {
'bseSource',
structure,
{},
- sourceDbTableNameByTableId
+ sourceDbTableNameByTableId,
+ {}
);
expect(commandBus.execute).toHaveBeenCalledWith(context, expect.any(Object));
});
- it('should create v2 structure first and copy records with raw table duplication', async () => {
+ it('should copy records through the v2 duplicate command stream', async () => {
const spaceId = 'spcTarget';
const targetBaseId = 'bseTarget';
const tableIdMap = { tblSource: 'tblTarget' };
@@ -296,7 +299,7 @@ describe('BaseDuplicateService duplicateBaseV2', () => {
tableIdMap,
fieldIdMap,
viewIdMap,
- recordsLength: 0,
+ recordsLength: 12,
};
})(),
}),
@@ -335,10 +338,10 @@ describe('BaseDuplicateService duplicateBaseV2', () => {
{} as IServiceArgs[5],
persistedComputedBackfillService as unknown as IServiceArgs[6],
{ get: vi.fn().mockReturnValue('usrTest') } as unknown as IServiceArgs[7],
- {} as IServiceArgs[8],
- dataDbClientManager as unknown as IServiceArgs[9],
- v2ContainerService as unknown as IServiceArgs[10],
- v2ContextFactory as unknown as IServiceArgs[11]
+ dataDbClientManager as unknown as IServiceArgs[8],
+ v2ContainerService as unknown as IServiceArgs[9],
+ v2ContextFactory as unknown as IServiceArgs[10],
+ {} as IServiceArgs[11]
);
const internals = service as unknown as IDuplicateServiceInternals;
const mergedLinkFieldTableMap = {
@@ -349,17 +352,16 @@ describe('BaseDuplicateService duplicateBaseV2', () => {
structure,
sourceDbTableNameByTableId: { tblSource: sourceDbTableName },
});
- vi.spyOn(internals, 'getCrossBaseLinkFieldTableMap').mockResolvedValue({});
- vi.spyOn(internals, 'getDisconnectedLinkFieldTableMap').mockResolvedValue(
+ vi.spyOn(internals, 'getV2CrossBaseLinkFieldTableMap').mockResolvedValue({});
+ vi.spyOn(internals, 'getV2DisconnectedLinkFieldTableMap').mockResolvedValue(
mergedLinkFieldTableMap
);
- vi.spyOn(internals, 'getDisconnectedLinkFieldIds').mockResolvedValue(['fldDisconnected']);
+ vi.spyOn(internals, 'getV2InternalLinkRelationTableMap').mockResolvedValue({});
vi.spyOn(internals, 'normalizeDuplicateStructureForV2').mockReturnValue(structure);
vi.spyOn(internals, 'createDuplicateBaseSource').mockReturnValue(source);
vi.spyOn(internals, 'duplicateTableData').mockResolvedValue(12);
vi.spyOn(internals, 'duplicateAttachments').mockResolvedValue(undefined);
vi.spyOn(internals, 'duplicateLinkJunction').mockResolvedValue(undefined);
- vi.spyOn(internals, 'backfillDuplicatedBaseComputedFields').mockResolvedValue(undefined);
const result = await service.duplicateBaseV2({
fromBaseId: 'bseSource',
@@ -368,88 +370,23 @@ describe('BaseDuplicateService duplicateBaseV2', () => {
withRecords: true,
});
- const executedCommand = commandBus.execute.mock.calls[0]?.[1] as { withRecords: boolean };
- expect(executedCommand.withRecords).toBe(false);
- expect(internals.duplicateTableData).toHaveBeenCalledWith(
- targetBaseId,
- tableIdMap,
- fieldIdMap,
- viewIdMap,
- mergedLinkFieldTableMap,
- undefined
- );
- expect(internals.duplicateLinkJunction).toHaveBeenCalledWith(
+ const executedCommand = commandBus.execute.mock.calls[0]?.[1] as {
+ withRecords: boolean;
+ batchSize: number;
+ };
+ expect(executedCommand.withRecords).toBe(true);
+ expect(executedCommand.batchSize).toBe(500);
+ expect(internals.duplicateTableData).not.toHaveBeenCalled();
+ expect(internals.duplicateLinkJunction).not.toHaveBeenCalled();
+ expect(persistedComputedBackfillService.recomputeForTables).not.toHaveBeenCalled();
+ expect(internals.duplicateAttachments).toHaveBeenCalledWith(
targetBaseId,
tableIdMap,
- fieldIdMap,
- true,
- ['fldDisconnected']
- );
- expect(persistedComputedBackfillService.recomputeForTables).toHaveBeenCalledWith(['tblTarget']);
- expect(internals.backfillDuplicatedBaseComputedFields).toHaveBeenCalledWith(
- container,
- context,
- ['tblTarget']
+ fieldIdMap
);
expect(result.recordsLength).toBe(12);
});
- it('should synchronously backfill v2 computed and link fields after raw record copy', async () => {
- const targetTableId = 'tblaaaaaaaaaaaaaaaa';
- const context = { requestId: 'ctx' };
- const fields = [{ name: 'Owner' }];
- const table = {
- getFields: vi.fn().mockReturnValue(fields),
- };
- const tableRepository = {
- findOne: vi.fn().mockResolvedValue(okResult(table)),
- };
- const backfillService = {
- executeSyncMany: vi.fn().mockResolvedValue(okResult(undefined)),
- };
- const container = {
- resolve: vi.fn((token: symbol) => {
- if (token === v2CoreTokens.tableRepository) {
- return tableRepository;
- }
- if (token === v2RecordRepositoryPostgresTokens.computedFieldBackfillService) {
- return backfillService;
- }
- throw new Error('Unexpected token');
- }),
- };
-
- const service = new BaseDuplicateService(
- {} as IServiceArgs[0],
- {} as IServiceArgs[1],
- {} as IServiceArgs[2],
- {} as IServiceArgs[3],
- {} as IServiceArgs[4],
- {} as IServiceArgs[5],
- {} as IServiceArgs[6],
- {} as IServiceArgs[7],
- {} as IServiceArgs[8],
- {} as IServiceArgs[9],
- {} as IServiceArgs[10],
- {} as IServiceArgs[11]
- );
- const internals = service as unknown as IDuplicateServiceInternals;
-
- await internals.backfillDuplicatedBaseComputedFields(container, context, [targetTableId]);
-
- expect(container.resolve).toHaveBeenCalledWith(v2CoreTokens.tableRepository);
- expect(container.resolve).toHaveBeenCalledWith(
- v2RecordRepositoryPostgresTokens.computedFieldBackfillService
- );
- expect(tableRepository.findOne).toHaveBeenCalledWith(context, expect.any(TableByIdSpec));
- expect(backfillService.executeSyncMany).toHaveBeenCalledWith(context, {
- table,
- fields,
- skipDistinctFilter: true,
- includeOneManyTwoWay: true,
- });
- });
-
it('should forward real row totals through duplicateBaseV2 progress events', async () => {
const spaceId = 'spcTarget';
const targetBaseId = 'bseTarget';
@@ -473,13 +410,35 @@ describe('BaseDuplicateService duplicateBaseV2', () => {
execute: vi.fn().mockResolvedValue({
isErr: () => false,
value: (async function* () {
+ yield {
+ id: 'progress',
+ phase: 'table_data_start',
+ processedRows: 0,
+ totalRows: 12,
+ };
+ yield {
+ id: 'progress',
+ phase: 'table_data_progress',
+ tableId: 'tblTarget',
+ tableName: sourceTableName,
+ processedRows: 5,
+ batchProcessedRows: 5,
+ currentBatch: 1,
+ totalRows: 12,
+ };
+ yield {
+ id: 'progress',
+ phase: 'table_data_done',
+ processedRows: 12,
+ totalRows: 12,
+ };
yield {
id: 'done',
baseId: targetBaseId,
tableIdMap,
fieldIdMap,
viewIdMap,
- recordsLength: 0,
+ recordsLength: 12,
};
})(),
}),
@@ -517,10 +476,10 @@ describe('BaseDuplicateService duplicateBaseV2', () => {
{} as IServiceArgs[5],
persistedComputedBackfillService as unknown as IServiceArgs[6],
{ get: vi.fn().mockReturnValue('usrTest') } as unknown as IServiceArgs[7],
- {} as IServiceArgs[8],
- dataDbClientManager as unknown as IServiceArgs[9],
- v2ContainerService as unknown as IServiceArgs[10],
- v2ContextFactory as unknown as IServiceArgs[11]
+ dataDbClientManager as unknown as IServiceArgs[8],
+ v2ContainerService as unknown as IServiceArgs[9],
+ v2ContextFactory as unknown as IServiceArgs[10],
+ {} as IServiceArgs[11]
);
const internals = service as unknown as IDuplicateServiceInternals;
const progressEvents: unknown[] = [];
@@ -529,29 +488,14 @@ describe('BaseDuplicateService duplicateBaseV2', () => {
structure,
sourceDbTableNameByTableId: { tblSource: sourceDbTableName },
});
- vi.spyOn(internals, 'getCrossBaseLinkFieldTableMap').mockResolvedValue({});
- vi.spyOn(internals, 'getDisconnectedLinkFieldTableMap').mockResolvedValue({});
- vi.spyOn(internals, 'getDisconnectedLinkFieldIds').mockResolvedValue([]);
+ vi.spyOn(internals, 'getV2CrossBaseLinkFieldTableMap').mockResolvedValue({});
+ vi.spyOn(internals, 'getV2DisconnectedLinkFieldTableMap').mockResolvedValue({});
+ vi.spyOn(internals, 'getV2InternalLinkRelationTableMap').mockResolvedValue({});
vi.spyOn(internals, 'normalizeDuplicateStructureForV2').mockReturnValue(structure);
vi.spyOn(internals, 'createDuplicateBaseSource').mockReturnValue(source);
- vi.spyOn(internals, 'duplicateTableData').mockImplementation(async (...args: unknown[]) => {
- const onProgress = args[5] as BaseImportProgressCallback | undefined;
- onProgress?.({ phase: 'table_data_start', processedRows: 0, totalRows: 12 });
- onProgress?.({
- phase: 'table_data_progress',
- tableId: 'tblTarget',
- tableName: sourceTableName,
- processedRows: 5,
- batchProcessedRows: 5,
- currentBatch: 1,
- totalRows: 12,
- });
- onProgress?.({ phase: 'table_data_done', processedRows: 12, totalRows: 12 });
- return 12;
- });
+ vi.spyOn(internals, 'duplicateTableData').mockResolvedValue(12);
vi.spyOn(internals, 'duplicateAttachments').mockResolvedValue(undefined);
vi.spyOn(internals, 'duplicateLinkJunction').mockResolvedValue(undefined);
- vi.spyOn(internals, 'backfillDuplicatedBaseComputedFields').mockResolvedValue(undefined);
const result = await service.duplicateBaseV2(
{
@@ -565,14 +509,7 @@ describe('BaseDuplicateService duplicateBaseV2', () => {
(event: string | IBaseImportProgress) => progressEvents.push(event)
);
- expect(internals.duplicateTableData).toHaveBeenCalledWith(
- targetBaseId,
- tableIdMap,
- fieldIdMap,
- viewIdMap,
- {},
- expect.any(Function)
- );
+ expect(internals.duplicateTableData).not.toHaveBeenCalled();
expect(progressEvents).toEqual(
expect.arrayContaining([
expect.objectContaining({ phase: 'table_data_start', processedRows: 0, totalRows: 12 }),
@@ -661,10 +598,10 @@ describe('BaseDuplicateService duplicateBaseV2', () => {
knex as unknown as IServiceArgs[5],
{} as IServiceArgs[6],
{} as IServiceArgs[7],
- {} as IServiceArgs[8],
{
dataPrismaForBase: vi.fn().mockResolvedValue(dataPrisma),
- } as unknown as IServiceArgs[9],
+ } as unknown as IServiceArgs[8],
+ {} as IServiceArgs[9],
{} as IServiceArgs[10],
{} as IServiceArgs[11]
);
@@ -724,10 +661,10 @@ describe('BaseDuplicateService duplicateBaseV2', () => {
{} as IServiceArgs[5],
{} as IServiceArgs[6],
{} as IServiceArgs[7],
- {} as IServiceArgs[8],
{
dataKnexForBase: vi.fn().mockResolvedValue(dataKnex),
- } as unknown as IServiceArgs[9],
+ } as unknown as IServiceArgs[8],
+ {} as IServiceArgs[9],
{} as IServiceArgs[10],
{} as IServiceArgs[11]
);
@@ -768,7 +705,503 @@ describe('BaseDuplicateService duplicateBaseV2', () => {
recordId: 'recSource',
fields: { fldText: 'A' },
autoNumber: 1,
+ lastModifiedTime: null,
+ lastModifiedBy: null,
},
]);
});
+
+ it('should skip internal v2 link relation reads during insert phase', async () => {
+ const sourceTableQuery = {
+ select: vi.fn().mockReturnThis(),
+ where: vi.fn().mockReturnThis(),
+ orderBy: vi.fn().mockReturnThis(),
+ limit: vi
+ .fn()
+ .mockResolvedValueOnce([
+ {
+ __id: 'recSource',
+ __auto_number: 1,
+ fldStory: [{ id: 'recStale', title: 'Deleted story' }],
+ },
+ ])
+ .mockResolvedValueOnce([]),
+ };
+ const dataKnex = vi.fn((tableName: string) => {
+ if (tableName === 'bseSource.tblSource') return sourceTableQuery;
+ throw new Error(`unexpected table ${tableName}`);
+ });
+ const service = new BaseDuplicateService(
+ {} as IServiceArgs[0],
+ {} as IServiceArgs[1],
+ {} as IServiceArgs[2],
+ {} as IServiceArgs[3],
+ {} as IServiceArgs[4],
+ {} as IServiceArgs[5],
+ {} as IServiceArgs[6],
+ {} as IServiceArgs[7],
+ { dataKnexForBase: vi.fn().mockResolvedValue(dataKnex) } as unknown as IServiceArgs[8],
+ {} as IServiceArgs[9],
+ {} as IServiceArgs[10],
+ {} as IServiceArgs[11]
+ );
+ const internals = service as unknown as IDuplicateServiceInternals;
+ const source = internals.createDuplicateBaseSource(
+ 'bseSource',
+ {
+ name: duplicateBaseName,
+ tables: [
+ {
+ id: 'tblSource',
+ name: sourceTableName,
+ dbTableName: 'tblShortName',
+ fields: [
+ {
+ id: 'fldStory',
+ name: 'Story',
+ dbFieldName: 'fldStory',
+ type: FieldType.Link,
+ },
+ ],
+ views: [],
+ },
+ ],
+ },
+ {},
+ { tblSource: 'bseSource.tblSource' },
+ {
+ tblSource: [
+ {
+ fieldId: 'fldStory',
+ dbFieldName: 'fldStory',
+ foreignTableId: 'tblStory',
+ lookupFieldId: 'fldStoryName',
+ relationship: 'manyMany',
+ fkHostTableName: 'bseSource.junction_fldStory',
+ selfKeyName: '__fk_self',
+ foreignKeyName: '__fk_foreign',
+ isOneWay: false,
+ isMultipleCellValue: true,
+ orderColumnName: '__order',
+ },
+ ],
+ }
+ );
+
+ const records = [];
+ for await (const record of source.records('tblSource', { phase: 'insert' })) {
+ records.push(record);
+ }
+
+ expect(dataKnex).toHaveBeenCalledWith('bseSource.tblSource');
+ expect(dataKnex).not.toHaveBeenCalledWith('bseSource.junction_fldStory');
+ expect(records[0].fields.fldStory).toEqual([{ id: 'recStale', title: 'Deleted story' }]);
+ });
+
+ it('should rebuild internal v2 link values from relation storage and ignore stale cache ids', async () => {
+ const sourceTableQuery = {
+ select: vi.fn().mockReturnThis(),
+ where: vi.fn().mockReturnThis(),
+ orderBy: vi.fn().mockReturnThis(),
+ limit: vi
+ .fn()
+ .mockResolvedValueOnce([
+ {
+ __id: 'recSource',
+ __auto_number: 1,
+ fldStory: [
+ { id: 'recExisting', title: 'Existing story' },
+ { id: 'recStale', title: 'Deleted story' },
+ ],
+ },
+ ])
+ .mockResolvedValueOnce([]),
+ };
+ const junctionQuery = {
+ select: vi.fn().mockReturnThis(),
+ whereIn: vi.fn().mockReturnThis(),
+ orderBy: vi.fn().mockResolvedValue([
+ {
+ sourceRecordId: 'recSource',
+ foreignRecordId: 'recExisting',
+ },
+ ]),
+ };
+ const dataKnex = vi.fn((tableName: string) => {
+ if (tableName === 'bseSource.tblSource') return sourceTableQuery;
+ if (tableName === 'bseSource.junction_fldStory') return junctionQuery;
+ throw new Error(`unexpected table ${tableName}`);
+ });
+ const service = new BaseDuplicateService(
+ {} as IServiceArgs[0],
+ {} as IServiceArgs[1],
+ {} as IServiceArgs[2],
+ {} as IServiceArgs[3],
+ {} as IServiceArgs[4],
+ {} as IServiceArgs[5],
+ {} as IServiceArgs[6],
+ {} as IServiceArgs[7],
+ { dataKnexForBase: vi.fn().mockResolvedValue(dataKnex) } as unknown as IServiceArgs[8],
+ {} as IServiceArgs[9],
+ {} as IServiceArgs[10],
+ {} as IServiceArgs[11]
+ );
+ const internals = service as unknown as IDuplicateServiceInternals;
+ const source = internals.createDuplicateBaseSource(
+ 'bseSource',
+ {
+ name: duplicateBaseName,
+ tables: [
+ {
+ id: 'tblSource',
+ name: sourceTableName,
+ dbTableName: 'tblShortName',
+ fields: [
+ {
+ id: 'fldStory',
+ name: 'Story',
+ dbFieldName: 'fldStory',
+ type: FieldType.Link,
+ },
+ ],
+ views: [],
+ },
+ ],
+ },
+ {},
+ { tblSource: 'bseSource.tblSource' },
+ {
+ tblSource: [
+ {
+ fieldId: 'fldStory',
+ dbFieldName: 'fldStory',
+ foreignTableId: 'tblStory',
+ lookupFieldId: 'fldStoryName',
+ relationship: 'manyMany',
+ fkHostTableName: 'bseSource.junction_fldStory',
+ selfKeyName: '__fk_self',
+ foreignKeyName: '__fk_foreign',
+ isOneWay: false,
+ isMultipleCellValue: true,
+ orderColumnName: '__order',
+ },
+ ],
+ }
+ );
+
+ const records = [];
+ for await (const record of source.records('tblSource', { phase: 'linkRestore' })) {
+ records.push(record);
+ }
+
+ expect(dataKnex).toHaveBeenCalledWith('bseSource.tblSource');
+ expect(dataKnex).toHaveBeenCalledWith('bseSource.junction_fldStory');
+ expect(junctionQuery.whereIn).toHaveBeenCalledWith('__fk_self', ['recSource']);
+ expect(records[0].fields.fldStory).toEqual([{ id: 'recExisting' }]);
+ });
+
+ it('should rebuild many-one internal v2 link values from current table FK storage', async () => {
+ const sourceTableQuery = {
+ select: vi.fn().mockReturnThis(),
+ where: vi.fn().mockReturnThis(),
+ orderBy: vi.fn().mockReturnThis(),
+ limit: vi
+ .fn()
+ .mockResolvedValueOnce([
+ {
+ __id: 'recChild',
+ __auto_number: 1,
+ fldParent: { id: 'recStale', title: 'Deleted parent' },
+ },
+ ])
+ .mockResolvedValueOnce([]),
+ whereIn: vi.fn().mockReturnThis(),
+ whereNotNull: vi.fn().mockResolvedValue([
+ {
+ sourceRecordId: 'recChild',
+ foreignRecordId: 'recParent',
+ },
+ ]),
+ };
+ const dataKnex = vi.fn((tableName: string) => {
+ if (tableName === 'bseSource.childTable') return sourceTableQuery;
+ throw new Error(`unexpected table ${tableName}`);
+ });
+ const service = new BaseDuplicateService(
+ {} as IServiceArgs[0],
+ {} as IServiceArgs[1],
+ {} as IServiceArgs[2],
+ {} as IServiceArgs[3],
+ {} as IServiceArgs[4],
+ {} as IServiceArgs[5],
+ {} as IServiceArgs[6],
+ {} as IServiceArgs[7],
+ { dataKnexForBase: vi.fn().mockResolvedValue(dataKnex) } as unknown as IServiceArgs[8],
+ {} as IServiceArgs[9],
+ {} as IServiceArgs[10],
+ {} as IServiceArgs[11]
+ );
+ const internals = service as unknown as IDuplicateServiceInternals;
+ const source = internals.createDuplicateBaseSource(
+ 'bseSource',
+ {
+ name: duplicateBaseName,
+ tables: [
+ {
+ id: 'tblChild',
+ name: sourceTableName,
+ dbTableName: 'childTable',
+ fields: [
+ {
+ id: 'fldParent',
+ name: 'Parent',
+ dbFieldName: 'fldParent',
+ type: FieldType.Link,
+ },
+ ],
+ views: [],
+ },
+ ],
+ },
+ {},
+ { tblChild: 'bseSource.childTable' },
+ {
+ tblChild: [
+ {
+ fieldId: 'fldParent',
+ dbFieldName: 'fldParent',
+ foreignTableId: 'tblParent',
+ lookupFieldId: 'fldParentName',
+ relationship: 'manyOne',
+ fkHostTableName: 'bseSource.childTable',
+ selfKeyName: '__id',
+ foreignKeyName: '__fk_parent',
+ isOneWay: false,
+ isMultipleCellValue: false,
+ orderColumnName: '__fk_parent_order',
+ },
+ ],
+ }
+ );
+
+ const records = [];
+ for await (const record of source.records('tblChild', { phase: 'linkRestore' })) {
+ records.push(record);
+ }
+
+ expect(dataKnex).toHaveBeenCalledWith('bseSource.childTable');
+ expect(sourceTableQuery.whereIn).toHaveBeenCalledWith('__id', ['recChild']);
+ expect(sourceTableQuery.whereNotNull).toHaveBeenCalledWith('__fk_parent');
+ expect(records[0].fields.fldParent).toEqual({ id: 'recParent' });
+ });
+
+ it('should rebuild two-way one-many internal v2 link values from foreign table FK storage', async () => {
+ const sourceTableQuery = {
+ select: vi.fn().mockReturnThis(),
+ where: vi.fn().mockReturnThis(),
+ orderBy: vi.fn().mockReturnThis(),
+ limit: vi
+ .fn()
+ .mockResolvedValueOnce([
+ {
+ __id: 'recParent',
+ __auto_number: 1,
+ fldChildren: [
+ { id: 'recChildExisting', title: 'Existing child' },
+ { id: 'recChildStale', title: 'Deleted child' },
+ ],
+ },
+ ])
+ .mockResolvedValueOnce([]),
+ };
+ const foreignTableQuery = {
+ select: vi.fn().mockReturnThis(),
+ whereIn: vi.fn().mockReturnThis(),
+ orderBy: vi.fn().mockResolvedValue([
+ {
+ sourceRecordId: 'recParent',
+ foreignRecordId: 'recChildExisting',
+ },
+ ]),
+ };
+ const dataKnex = vi.fn((tableName: string) => {
+ if (tableName === 'bseSource.parentTable') return sourceTableQuery;
+ if (tableName === 'bseSource.childTable') return foreignTableQuery;
+ throw new Error(`unexpected table ${tableName}`);
+ });
+ const service = new BaseDuplicateService(
+ {} as IServiceArgs[0],
+ {} as IServiceArgs[1],
+ {} as IServiceArgs[2],
+ {} as IServiceArgs[3],
+ {} as IServiceArgs[4],
+ {} as IServiceArgs[5],
+ {} as IServiceArgs[6],
+ {} as IServiceArgs[7],
+ { dataKnexForBase: vi.fn().mockResolvedValue(dataKnex) } as unknown as IServiceArgs[8],
+ {} as IServiceArgs[9],
+ {} as IServiceArgs[10],
+ {} as IServiceArgs[11]
+ );
+ const internals = service as unknown as IDuplicateServiceInternals;
+ const source = internals.createDuplicateBaseSource(
+ 'bseSource',
+ {
+ name: duplicateBaseName,
+ tables: [
+ {
+ id: 'tblParent',
+ name: sourceTableName,
+ dbTableName: 'parentTable',
+ fields: [
+ {
+ id: 'fldChildren',
+ name: 'Children',
+ dbFieldName: 'fldChildren',
+ type: FieldType.Link,
+ },
+ ],
+ views: [],
+ },
+ ],
+ },
+ {},
+ { tblParent: 'bseSource.parentTable' },
+ {
+ tblParent: [
+ {
+ fieldId: 'fldChildren',
+ dbFieldName: 'fldChildren',
+ foreignTableId: 'tblChild',
+ lookupFieldId: 'fldChildName',
+ relationship: 'oneMany',
+ fkHostTableName: 'bseSource.childTable',
+ selfKeyName: '__fk_parent',
+ foreignKeyName: '__id',
+ isOneWay: false,
+ isMultipleCellValue: true,
+ orderColumnName: '__fk_parent_order',
+ },
+ ],
+ }
+ );
+
+ const records = [];
+ for await (const record of source.records('tblParent', { phase: 'linkRestore' })) {
+ records.push(record);
+ }
+
+ expect(dataKnex).toHaveBeenCalledWith('bseSource.childTable');
+ expect(foreignTableQuery.whereIn).toHaveBeenCalledWith('__fk_parent', ['recParent']);
+ expect(foreignTableQuery.orderBy).toHaveBeenCalledWith('__fk_parent_order', 'asc');
+ expect(records[0].fields.fldChildren).toEqual([{ id: 'recChildExisting' }]);
+ });
+
+ it('should normalize postgres array literal link values when downgrading v2 cross-base links', async () => {
+ const query = {
+ select: vi.fn().mockReturnThis(),
+ where: vi.fn().mockReturnThis(),
+ orderBy: vi.fn().mockReturnThis(),
+ limit: vi
+ .fn()
+ .mockResolvedValueOnce([
+ {
+ __id: 'recSource',
+ __auto_number: 1,
+ fldVendor: '{"{\\"id\\":\\"recVendor\\",\\"title\\":\\"Vendor A\\"}"}',
+ },
+ ])
+ .mockResolvedValueOnce([]),
+ };
+ const dataKnex = vi.fn().mockReturnValue(query);
+ const service = new BaseDuplicateService(
+ {} as IServiceArgs[0],
+ {} as IServiceArgs[1],
+ {} as IServiceArgs[2],
+ {} as IServiceArgs[3],
+ {} as IServiceArgs[4],
+ {} as IServiceArgs[5],
+ {} as IServiceArgs[6],
+ {} as IServiceArgs[7],
+ {
+ dataKnexForBase: vi.fn().mockResolvedValue(dataKnex),
+ } as unknown as IServiceArgs[8],
+ {} as IServiceArgs[9],
+ {} as IServiceArgs[10],
+ {} as IServiceArgs[11]
+ );
+ const internals = service as unknown as IDuplicateServiceInternals;
+ const source = internals.createDuplicateBaseSource(
+ 'bseSource',
+ {
+ name: duplicateBaseName,
+ tables: [
+ {
+ id: 'tblSource',
+ name: sourceTableName,
+ dbTableName: 'tblSource',
+ fields: [
+ {
+ id: 'fldVendor',
+ name: 'Vendor',
+ dbFieldName: 'fldVendor',
+ type: FieldType.SingleLineText,
+ },
+ ],
+ views: [],
+ },
+ ],
+ },
+ {
+ tblSource: [
+ {
+ dbFieldName: 'fldVendor',
+ selfKeyName: 'fk_fld_vendor',
+ isMultipleCellValue: true,
+ },
+ ],
+ },
+ { tblSource: 'bseSource.tblSource' }
+ );
+
+ const records = [];
+ for await (const record of source.records('tblSource')) {
+ records.push(record);
+ }
+
+ expect(records[0].fields.fldVendor).toBe('Vendor A');
+ });
+
+ it('should include nullable isLookup host link fields when building v2 cross-base maps', async () => {
+ const fieldFindMany = vi.fn().mockResolvedValue([]);
+ const service = new BaseDuplicateService(
+ {
+ txClient: vi.fn().mockReturnValue({
+ field: { findMany: fieldFindMany },
+ }),
+ } as unknown as IServiceArgs[0],
+ {} as IServiceArgs[1],
+ {} as IServiceArgs[2],
+ {} as IServiceArgs[3],
+ {} as IServiceArgs[4],
+ {} as IServiceArgs[5],
+ {} as IServiceArgs[6],
+ {} as IServiceArgs[7],
+ {} as IServiceArgs[8],
+ {} as IServiceArgs[9],
+ {} as IServiceArgs[10],
+ {} as IServiceArgs[11]
+ );
+ const internals = service as unknown as IDuplicateServiceInternals;
+
+ await internals.getV2CrossBaseLinkFieldTableMap({ tblSource: 'tblSource' });
+
+ expect(fieldFindMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ OR: [{ isLookup: false }, { isLookup: null }],
+ }),
+ })
+ );
+ });
});
diff --git a/apps/nestjs-backend/src/features/base/base-duplicate.service.ts b/apps/nestjs-backend/src/features/base/base-duplicate.service.ts
index 8a5f0202a8..f021ef8f36 100644
--- a/apps/nestjs-backend/src/features/base/base-duplicate.service.ts
+++ b/apps/nestjs-backend/src/features/base/base-duplicate.service.ts
@@ -10,26 +10,18 @@ import {
type IDuplicateBaseRo,
} from '@teable/openapi';
import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg';
-import {
- v2RecordRepositoryPostgresTokens,
- type ComputedFieldBackfillService,
-} from '@teable/v2-adapter-table-repository-postgres';
import {
DuplicateBaseCommand,
- TableByIdSpec,
- TableId,
v2CoreTokens,
+ type DuplicateBaseRecordReadOptions,
type DotTeaFieldInput,
type DuplicateBaseResult,
type DuplicateBaseSource,
type ICommandBus,
- type IExecutionContext,
- type ITableRepository,
type NormalizedDotTeaField,
type NormalizedDotTeaStructure,
} from '@teable/v2-core';
-import type { DependencyContainer } from '@teable/v2-di';
-import { normalizeField } from '@teable/v2-dottea';
+import { normalizeFields } from '@teable/v2-dottea';
import { Knex } from 'knex';
import type { Kysely } from 'kysely';
import { groupBy, omit } from 'lodash';
@@ -60,6 +52,8 @@ import type { ILinkFieldTableInfo, ILinkFieldTableMap } from './utils';
type DuplicatedBase = Awaited>['base'];
const v2DuplicateReadBatchSize = 500;
const v2DuplicateCopyBatchSize = 500;
+const v2DuplicateLinkFieldBatchSize = 500;
+const v2DuplicateTableIdQueryChunkSize = 100;
type DuplicateStructureConfig = Awaited>;
type DuplicateV2FieldConfig = Omit<
DuplicateStructureConfig['tables'][number]['fields'][number],
@@ -77,6 +71,33 @@ type DuplicateStructureConfigResult = {
structure: DuplicateStructureConfig;
sourceDbTableNameByTableId: Record;
};
+type DuplicateBaseStructConfigInput = Parameters<
+ BaseExportService['generateBaseStructConfig']
+>[0] & {
+ includeWorkflowRuntimeState?: boolean;
+};
+type DuplicateLinkFieldRaw = {
+ id: string;
+ tableId: string;
+ dbFieldName: string;
+ isMultipleCellValue: boolean | null;
+ meta: string | null;
+ options: string | null;
+};
+type V2InternalLinkFieldTableInfo = {
+ fieldId: string;
+ dbFieldName: string;
+ foreignTableId: string;
+ lookupFieldId: string;
+ relationship: string;
+ fkHostTableName: string;
+ selfKeyName: string;
+ foreignKeyName: string;
+ isOneWay: boolean;
+ isMultipleCellValue: boolean;
+ orderColumnName?: string;
+};
+type V2LinkCellItem = { id: string; title?: string };
type IDataPrismaExecutor = {
$executeRawUnsafe(query: string, ...values: unknown[]): Promise;
@@ -269,9 +290,9 @@ export class BaseDuplicateService {
// allowCrossBase=true keeps same-space cross-base links intact. Mirrors
// the v1 branch in duplicateBase above.
const crossBaseLinkFieldTableMap: ILinkFieldTableMap = allowCrossBase
- ? await this.getCrossBaseLinkFieldTableMap(sourceTableIdMap, spaceId)
- : await this.getCrossBaseLinkFieldTableMap(sourceTableIdMap);
- const disconnectedLinkFieldTableMap = await this.getDisconnectedLinkFieldTableMap(
+ ? await this.getV2CrossBaseLinkFieldTableMap(sourceTableIdMap, spaceId)
+ : await this.getV2CrossBaseLinkFieldTableMap(sourceTableIdMap);
+ const disconnectedLinkFieldTableMap = await this.getV2DisconnectedLinkFieldTableMap(
sourceTableIdMap,
fromBaseId,
nodes,
@@ -281,12 +302,8 @@ export class BaseDuplicateService {
crossBaseLinkFieldTableMap,
disconnectedLinkFieldTableMap
);
- const disconnectedLinkFieldIds = await this.getDisconnectedLinkFieldIds(
- sourceTableIdMap,
- fromBaseId,
- nodes,
- skipParentNodes
- );
+ const internalLinkRelationTableMap =
+ await this.getV2InternalLinkRelationTableMap(sourceTableIdMap);
const container = await this.v2ContainerService.getContainerForSpace(spaceId);
const commandBus = container.resolve(v2CoreTokens.commandBus);
const db = container.resolve>(v2PostgresDbTokens.db);
@@ -315,12 +332,14 @@ export class BaseDuplicateService {
fromBaseId,
normalizedStructure,
mergedLinkFieldTableMap,
- sourceDbTableNameByTableId
+ sourceDbTableNameByTableId,
+ internalLinkRelationTableMap
);
const commandResult = DuplicateBaseCommand.createFromSource({
baseId: base.id,
source,
- withRecords: false,
+ withRecords: Boolean(withRecords),
+ batchSize: v2DuplicateCopyBatchSize,
});
if (commandResult.isErr()) {
throw new Error(commandResult.error.message);
@@ -360,37 +379,15 @@ export class BaseDuplicateService {
structure,
{ tableIdMap, fieldIdMap, viewIdMap },
duplicateMode,
- onProgress,
- { restoreEeResources: true }
+ onProgress
);
if (withRecords) {
- recordsLength = await this.duplicateTableData(
- base.id,
- tableIdMap,
- fieldIdMap,
- viewIdMap,
- mergedLinkFieldTableMap,
- onProgress
- );
onProgress?.({
phase: 'attachments_copying',
processedRows: recordsLength,
totalRows: recordsLength,
});
await this.duplicateAttachments(base.id, tableIdMap, fieldIdMap);
- await this.duplicateLinkJunction(
- base.id,
- tableIdMap,
- fieldIdMap,
- allowCrossBase,
- disconnectedLinkFieldIds
- );
- await this.persistedComputedBackfillService.recomputeForTables(Object.values(tableIdMap));
- await this.backfillDuplicatedBaseComputedFields(
- container,
- context,
- Object.values(tableIdMap)
- );
await prisma.base.update({
where: { id: base.id },
data: {
@@ -509,23 +506,10 @@ export class BaseDuplicateService {
},
});
const tableIds = tableRaws.map(({ id }) => id);
- const fieldRaws = await prisma.field.findMany({
- where: {
- tableId: { in: tableIds },
- deletedTime: null,
- },
- });
- const viewRaws = await prisma.view.findMany({
- where: {
- tableId: { in: tableIds },
- deletedTime: null,
- },
- orderBy: {
- order: 'asc',
- },
- });
+ const fieldRaws = await this.baseExportService.findFieldsByTableIds(tableIds);
+ const viewRaws = await this.baseExportService.findViewsByTableIds(tableIds);
- const structure = await this.baseExportService.generateBaseStructConfig({
+ const structureInput: DuplicateBaseStructConfigInput = {
baseRaw,
tableRaws,
fieldRaws,
@@ -539,7 +523,9 @@ export class BaseDuplicateService {
excludedTableIds,
rootNodeIds,
destSpaceId,
- });
+ includeWorkflowRuntimeState: false,
+ };
+ const structure = await this.baseExportService.generateBaseStructConfig(structureInput);
return {
structure,
@@ -555,26 +541,38 @@ export class BaseDuplicateService {
sourceBaseId: string,
structure: DuplicateV2StructureConfig,
disconnectedLinkFieldTableMap: ILinkFieldTableMap,
- sourceDbTableNameByTableId: Record = {}
+ sourceDbTableNameByTableId: Record = {},
+ internalLinkRelationTableMap: Record = {}
): DuplicateBaseSource {
const tableById = new Map(structure.tables.map((table) => [table.id, table]));
- const readRows = (sourceDbTableName: string, crossBaseLinkInfo: ILinkFieldTableInfo[]) =>
- this.createSourceTableRecordRows(sourceBaseId, sourceDbTableName, crossBaseLinkInfo);
+ const readRows = (
+ sourceDbTableName: string,
+ crossBaseLinkInfo: ILinkFieldTableInfo[],
+ internalLinkInfo: V2InternalLinkFieldTableInfo[]
+ ) =>
+ this.createSourceTableRecordRows(
+ sourceBaseId,
+ sourceDbTableName,
+ crossBaseLinkInfo,
+ internalLinkInfo
+ );
const toRecordInput = (table: DuplicateV2TableConfig, row: Record) =>
this.toDuplicateBaseRecordInput(table, row);
return {
structure,
- async *records(tableId: string) {
+ async *records(tableId: string, options?: DuplicateBaseRecordReadOptions) {
const table = tableById.get(tableId);
const sourceDbTableName = sourceDbTableNameByTableId[tableId] ?? table?.dbTableName;
if (!table || !sourceDbTableName) {
return;
}
+ const shouldReadInternalLinks = options?.phase !== 'insert';
for await (const row of readRows(
sourceDbTableName,
- disconnectedLinkFieldTableMap[tableId] || []
+ disconnectedLinkFieldTableMap[tableId] || [],
+ shouldReadInternalLinks ? internalLinkRelationTableMap[tableId] || [] : []
)) {
yield toRecordInput(table, row);
}
@@ -616,29 +614,13 @@ export class BaseDuplicateService {
return {
...structure,
tables: structure.tables.map((table) => {
- const tableFieldTypesById = new Map(
- table.fields
- .filter((field) => field.id)
- .map(
- (field) =>
- [
- field.id!,
- field.isConditionalLookup
- ? 'conditionalLookup'
- : field.isLookup
- ? 'lookup'
- : field.type,
- ] as const
- )
- );
-
return {
...table,
- fields: table.fields.map((field) => {
- const normalized = normalizeField(field as DotTeaFieldInput, tableFieldTypesById, {
- availableTableIds,
- fieldIdsByTableId,
- });
+ fields: normalizeFields(table.fields as ReadonlyArray, {
+ availableTableIds,
+ fieldIdsByTableId,
+ }).map((normalized, index) => {
+ const field = table.fields[index]!;
const normalizedField = { ...field, ...normalized };
if (
@@ -723,12 +705,8 @@ export class BaseDuplicateService {
...(row.__auto_number ? { autoNumber: Number(row.__auto_number) } : {}),
...(row.__created_time ? { createdTime: this.toRestoreString(row.__created_time) } : {}),
...(row.__created_by ? { createdBy: this.toRestoreString(row.__created_by) } : {}),
- ...(row.__last_modified_time
- ? { lastModifiedTime: this.toRestoreString(row.__last_modified_time) }
- : {}),
- ...(row.__last_modified_by
- ? { lastModifiedBy: this.toRestoreString(row.__last_modified_by) }
- : {}),
+ lastModifiedTime: null,
+ lastModifiedBy: null,
};
}
@@ -1013,6 +991,211 @@ export class BaseDuplicateService {
return tableId2DbFieldNameMap;
}
+ private async findV2DuplicateLinkFields(tableIds: string[]) {
+ const prisma = this.prismaService.txClient();
+ const fields: DuplicateLinkFieldRaw[] = [];
+
+ for (let index = 0; index < tableIds.length; index += v2DuplicateTableIdQueryChunkSize) {
+ const tableIdChunk = tableIds.slice(index, index + v2DuplicateTableIdQueryChunkSize);
+ let cursor: string | undefined;
+ let hasMore = true;
+
+ while (hasMore) {
+ const page = await prisma.field.findMany({
+ where: {
+ tableId: { in: tableIdChunk },
+ type: FieldType.Link,
+ OR: [{ isLookup: false }, { isLookup: null }],
+ deletedTime: null,
+ },
+ ...(cursor
+ ? {
+ cursor: {
+ id: cursor,
+ },
+ skip: 1,
+ }
+ : {}),
+ select: {
+ id: true,
+ tableId: true,
+ dbFieldName: true,
+ isMultipleCellValue: true,
+ meta: true,
+ options: true,
+ },
+ orderBy: [{ tableId: 'asc' }, { id: 'asc' }],
+ take: v2DuplicateLinkFieldBatchSize,
+ });
+
+ fields.push(...page);
+
+ hasMore = page.length === v2DuplicateLinkFieldBatchSize;
+ if (hasMore) {
+ cursor = page[page.length - 1].id;
+ }
+ }
+ }
+
+ return fields;
+ }
+
+ private parseLinkFieldOptions(options: string | null): ILinkFieldOptions | undefined {
+ if (!options) {
+ return undefined;
+ }
+
+ try {
+ return JSON.parse(options) as ILinkFieldOptions;
+ } catch {
+ return undefined;
+ }
+ }
+
+ private resolveV2LinkOrderColumnName(options: ILinkFieldOptions): string | undefined {
+ switch (options.relationship) {
+ case 'manyMany':
+ return '__order';
+ case 'oneMany':
+ return options.isOneWay ? '__order' : `${options.selfKeyName}_order`;
+ case 'manyOne':
+ case 'oneOne':
+ return `${options.foreignKeyName}_order`;
+ default:
+ return undefined;
+ }
+ }
+
+ private hasV2LinkOrderColumn(meta: string | null): boolean {
+ if (!meta) {
+ return false;
+ }
+
+ try {
+ return Boolean((JSON.parse(meta) as { hasOrderColumn?: unknown }).hasOrderColumn);
+ } catch {
+ return false;
+ }
+ }
+
+ private buildLinkFieldTableMap(
+ tableIdMap: Record,
+ fields: DuplicateLinkFieldRaw[]
+ ): ILinkFieldTableMap {
+ const tableId2DbFieldNameMap: ILinkFieldTableMap = {};
+
+ Object.entries(groupBy(fields, 'tableId')).forEach(([tableId, fields]) => {
+ const info = fields.flatMap(({ dbFieldName, isMultipleCellValue, options }) => {
+ const parsedOptions = this.parseLinkFieldOptions(options);
+ if (!parsedOptions?.selfKeyName) {
+ return [];
+ }
+ return [
+ {
+ dbFieldName,
+ selfKeyName: parsedOptions.selfKeyName,
+ isMultipleCellValue: !!isMultipleCellValue,
+ },
+ ];
+ });
+
+ if (info.length) {
+ tableId2DbFieldNameMap[tableId] = info;
+ tableId2DbFieldNameMap[tableIdMap[tableId]] = info;
+ }
+ });
+
+ return tableId2DbFieldNameMap;
+ }
+
+ private async getV2InternalLinkRelationTableMap(
+ tableIdMap: Record
+ ): Promise> {
+ const tableIds = Object.keys(tableIdMap);
+ const fields = (await this.findV2DuplicateLinkFields(tableIds)).filter((field) => {
+ const options = this.parseLinkFieldOptions(field.options);
+ return (
+ options?.foreignTableId &&
+ !options.baseId &&
+ tableIdMap[options.foreignTableId] &&
+ options.fkHostTableName &&
+ options.selfKeyName &&
+ options.foreignKeyName
+ );
+ });
+
+ if (!fields.length) {
+ return {};
+ }
+
+ const tableId2Info: Record = {};
+ Object.entries(groupBy(fields, 'tableId')).forEach(([tableId, tableFields]) => {
+ const info = tableFields.flatMap((field) => {
+ const options = this.parseLinkFieldOptions(field.options);
+ const foreignTableId = options?.foreignTableId;
+ const lookupFieldId = options?.lookupFieldId;
+ if (
+ !foreignTableId ||
+ !lookupFieldId ||
+ !options?.relationship ||
+ !options.fkHostTableName ||
+ !options.selfKeyName ||
+ !options.foreignKeyName
+ ) {
+ return [];
+ }
+
+ return [
+ {
+ fieldId: field.id,
+ dbFieldName: field.dbFieldName,
+ foreignTableId,
+ lookupFieldId,
+ relationship: options.relationship,
+ fkHostTableName: options.fkHostTableName,
+ selfKeyName: options.selfKeyName,
+ foreignKeyName: options.foreignKeyName,
+ isOneWay: Boolean(options.isOneWay),
+ isMultipleCellValue: !!field.isMultipleCellValue,
+ orderColumnName: this.hasV2LinkOrderColumn(field.meta)
+ ? this.resolveV2LinkOrderColumnName(options)
+ : undefined,
+ },
+ ];
+ });
+
+ if (info.length) {
+ tableId2Info[tableId] = info;
+ tableId2Info[tableIdMap[tableId]] = info;
+ }
+ });
+
+ return tableId2Info;
+ }
+
+ private async getV2DisconnectedLinkFieldTableMap(
+ tableIdMap: Record,
+ fromBaseId: string,
+ nodes?: string[],
+ skipParentNodes: boolean = false
+ ): Promise {
+ const { excludedTableIds } = await this.collectNodesAndResourceIds(
+ fromBaseId,
+ nodes,
+ skipParentNodes
+ );
+
+ if (!nodes?.length || !excludedTableIds?.length) {
+ return {};
+ }
+
+ const fields = (await this.findV2DuplicateLinkFields(Object.keys(tableIdMap))).filter((field) =>
+ excludedTableIds.includes(this.parseLinkFieldOptions(field.options)?.foreignTableId ?? '')
+ );
+
+ return this.buildLinkFieldTableMap(tableIdMap, fields);
+ }
+
async previewCrossSpaceAffectedFields(
fromBaseId: string,
destSpaceId: string
@@ -1127,6 +1310,42 @@ export class BaseDuplicateService {
return tableId2DbFieldNameMap;
}
+ private async getV2CrossBaseLinkFieldTableMap(
+ tableIdMap: Record,
+ destSpaceId?: string
+ ): Promise {
+ const linkFields = (await this.findV2DuplicateLinkFields(Object.keys(tableIdMap))).filter(
+ (field) => this.parseLinkFieldOptions(field.options)?.baseId
+ );
+
+ let crossBaseLinkFields = linkFields;
+ if (destSpaceId) {
+ const prisma = this.prismaService.txClient();
+ const foreignBaseIds = Array.from(
+ new Set(
+ linkFields
+ .map((field) => this.parseLinkFieldOptions(field.options)?.baseId)
+ .filter((baseId): baseId is string => Boolean(baseId))
+ )
+ );
+ const bases = foreignBaseIds.length
+ ? await prisma.base.findMany({
+ where: { id: { in: foreignBaseIds }, deletedTime: null },
+ select: { id: true, spaceId: true },
+ })
+ : [];
+ const crossSpaceBaseIds = new Set(
+ bases.filter((base) => base.spaceId !== destSpaceId).map((base) => base.id)
+ );
+ crossBaseLinkFields = linkFields.filter((field) => {
+ const baseId = this.parseLinkFieldOptions(field.options)?.baseId;
+ return baseId && crossSpaceBaseIds.has(baseId);
+ });
+ }
+
+ return this.buildLinkFieldTableMap(tableIdMap, crossBaseLinkFields);
+ }
+
private async duplicateTableData(
targetBaseId: string,
tableIdMap: Record,
@@ -1396,10 +1615,64 @@ export class BaseDuplicateService {
return String(value);
}
+ private parsePostgresTextArrayLiteral(value: string): string[] | undefined {
+ if (!value.startsWith('{') || !value.endsWith('}')) {
+ return undefined;
+ }
+
+ const inner = value.slice(1, -1);
+ if (!inner) {
+ return [];
+ }
+
+ const items: string[] = [];
+ let item = '';
+ let inQuotes = false;
+
+ for (let index = 0; index < inner.length; index += 1) {
+ const char = inner[index];
+
+ if (inQuotes) {
+ if (char === '\\') {
+ index += 1;
+ item += inner[index] ?? '';
+ continue;
+ }
+ if (char === '"') {
+ inQuotes = false;
+ continue;
+ }
+ item += char;
+ continue;
+ }
+
+ if (char === '"') {
+ inQuotes = true;
+ continue;
+ }
+
+ if (char === ',') {
+ items.push(item);
+ item = '';
+ continue;
+ }
+
+ item += char;
+ }
+
+ if (inQuotes) {
+ return undefined;
+ }
+
+ items.push(item);
+ return items;
+ }
+
private async *createSourceTableRecordRows(
sourceBaseId: string,
sourceDbTableName: string,
- crossBaseLinkInfo: ILinkFieldTableInfo[]
+ crossBaseLinkInfo: ILinkFieldTableInfo[],
+ internalLinkInfo: V2InternalLinkFieldTableInfo[] = []
): AsyncGenerator> {
const dataKnex = await this.dataDbClientManager.dataKnexForBase(sourceBaseId, {
useTransaction: true,
@@ -1416,8 +1689,18 @@ export class BaseDuplicateService {
return;
}
- for (const row of rows) {
- yield this.normalizeCrossBaseLinkColumns(row, crossBaseLinkInfo);
+ const sourceRecordIds = rows.flatMap((row) =>
+ typeof row.__id === 'string' ? [row.__id] : []
+ );
+ const normalizedRows = await this.normalizeInternalLinkColumns(
+ dataKnex,
+ rows.map((row) => this.normalizeCrossBaseLinkColumns(row, crossBaseLinkInfo)),
+ sourceRecordIds,
+ internalLinkInfo
+ );
+
+ for (const row of normalizedRows) {
+ yield row;
}
lastAutoNumber = Number(rows[rows.length - 1]?.__auto_number ?? lastAutoNumber);
@@ -1444,6 +1727,138 @@ export class BaseDuplicateService {
return nextRow;
}
+ private async normalizeInternalLinkColumns(
+ dataKnex: Knex,
+ rows: Record[],
+ sourceRecordIds: string[],
+ internalLinkInfo: V2InternalLinkFieldTableInfo[]
+ ) {
+ if (!rows.length || !internalLinkInfo.length) {
+ return rows;
+ }
+
+ const rowsById = new Map(
+ rows.flatMap((row) => (typeof row.__id === 'string' ? ([[row.__id, row]] as const) : []))
+ );
+ for (const row of rows) {
+ for (const { dbFieldName } of internalLinkInfo) {
+ delete row[dbFieldName];
+ }
+ }
+
+ if (!sourceRecordIds.length) {
+ return rows;
+ }
+
+ for (const info of internalLinkInfo) {
+ const relations = await this.findV2InternalLinkRelations(dataKnex, sourceRecordIds, info);
+ for (const [sourceRecordId, linkItems] of relations) {
+ const row = rowsById.get(sourceRecordId);
+ if (!row) continue;
+ if (info.isMultipleCellValue) {
+ row[info.dbFieldName] = linkItems;
+ } else if (linkItems[0]) {
+ row[info.dbFieldName] = linkItems[0];
+ }
+ }
+ }
+
+ return rows;
+ }
+
+ private async findV2InternalLinkRelations(
+ dataKnex: Knex,
+ sourceRecordIds: string[],
+ info: V2InternalLinkFieldTableInfo
+ ): Promise