From 64b1a7dcff01622c1c30c296093622f4144f2352 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 05:16:52 +0000 Subject: [PATCH] [sync] T3475 fix stale ShareDB snapshots for record query cache Synced from teableio/teable-ee@4ea176b Co-authored-by: Aries X Co-authored-by: Bieber Co-authored-by: Boris Co-authored-by: Jocky-Teable Co-authored-by: Jun Lu Co-authored-by: Uno Co-authored-by: nichenqin --- apps/nestjs-backend/src/cache/types.ts | 1 + .../__tests__/invalid-filter-skip.spec.ts | 306 ++++++ .../filter-query/filter-query.abstract.ts | 210 ++-- .../db-provider/formula-default-unit.spec.ts | 32 + .../search-query/date-search-range.util.ts | 80 ++ .../search-index-builder.postgres.spec.ts | 33 +- .../search-index-builder.postgres.ts | 70 +- .../search-query.postgres.spec.ts | 55 + .../search-query/search-query.postgres.ts | 45 +- .../attachments/attachments-crop.processor.ts | 19 +- .../auth/guard/base-node-permission.guard.ts | 27 +- .../base-node/base-node.service.spec.ts | 39 + .../features/base-node/base-node.service.ts | 16 +- .../src/features/base/base-import.service.ts | 1 + .../src/features/base/base.service.ts | 1 + .../features/canary/canary.service.spec.ts | 86 ++ .../src/features/canary/canary.service.ts | 28 +- .../canary/guards/v2-feature.guard.ts | 55 +- .../field-converting.service.ts | 29 +- .../field-supplement.service.ts | 65 +- .../integrity/integrity-v2.service.spec.ts | 253 +++++ .../integrity/integrity-v2.service.ts | 387 ++++++- .../features/integrity/integrity.module.ts | 3 +- .../integrity/link-integrity.service.ts | 288 +++++ .../query-builder/sql-conversion.visitor.ts | 25 +- .../src/features/record/record.service.ts | 3 + .../open-api/admin-open-api.service.ts | 34 +- .../table/open-api/table-open-api.service.ts | 12 +- .../src/features/table/table-index.service.ts | 11 +- .../src/features/trash/trash.service.ts | 4 +- .../v2/v2-base-node-compat.service.ts | 14 +- .../view/open-api/view-open-api.service.ts | 26 +- .../src/share-db/share-db.adapter.ts | 43 +- .../src/share-db/share-db.spec.ts | 53 + apps/nestjs-backend/src/types/cls.ts | 3 + .../src/types/i18n.generated.ts | 36 +- .../nestjs-backend/test/base-node.e2e-spec.ts | 183 +++- .../test/computed-orchestrator.e2e-spec.ts | 185 ++-- .../test/computed-user-field.e2e-spec.ts | 21 +- .../test/field-converting.e2e-spec.ts | 26 + .../test/field-duplicate.e2e-spec.ts | 152 +++ apps/nestjs-backend/test/formula.e2e-spec.ts | 71 +- .../nestjs-backend/test/integrity.e2e-spec.ts | 168 +++ .../test/record-filter-query.e2e-spec.ts | 12 +- .../test/record-search-query.e2e-spec.ts | 29 - .../test/record-typecast.e2e-spec.ts | 3 +- apps/nestjs-backend/test/table.e2e-spec.ts | 14 + apps/nestjs-backend/test/trash.e2e-spec.ts | 51 +- .../nestjs-backend/test/undo-redo.e2e-spec.ts | 23 +- .../changelog/ChangelogNotification.tsx | 6 +- .../app/blocks/admin/setting/SettingPage.tsx | 5 +- .../base/base-side-bar/BaseNodeTree.tsx | 11 +- .../base-side-bar/BaseSidebarHeaderLeft.tsx | 18 +- .../blocks/base/base-side-bar/QuickAction.tsx | 6 +- .../components/IntegrityV2Components.tsx | 392 ++++++- .../design/components/IntegrityV2Dialog.tsx | 54 + .../design/components/integrityV2Utils.ts | 8 +- .../view/component/grid/GridViewBase.tsx | 8 +- .../space-setting/SpaceInnerSettingModal.tsx | 87 +- .../space-setting/general/GeneralPage.tsx | 7 +- .../app/blocks/space-setting/types.ts | 5 + .../blocks/view/grid/GridViewBaseInner.tsx | 9 +- .../blocks/view/grid/useGridSearchStore.ts | 11 + .../tool-bar/components/GridViewOperators.tsx | 68 +- .../features/app/components/SideBarFooter.tsx | 6 +- .../download-attachments/DownloadContent.tsx | 61 +- .../useDownloadAttachmentsStore.ts | 8 +- .../field-setting/FieldSetting.spec.tsx | 14 + .../components/field-setting/FieldSetting.tsx | 39 +- .../app/components/setting/Account.tsx | 4 +- .../app/components/setting/SettingDialog.tsx | 150 +-- .../components/setting/SettingTabShell.tsx | 25 +- .../app/components/setting/System.tsx | 18 +- .../setting/UnifiedSettingDialogContent.tsx | 304 ++++++ .../setting/integration/Integration.tsx | 2 +- .../app/components/setting/useSettingStore.ts | 12 +- .../src/features/app/hooks/useBaseResource.ts | 11 + .../app/utils/download-all-attachments.ts | 26 +- apps/nextjs-app/src/styles/global.css | 22 +- .../common-i18n/src/locales/de/common.json | 15 +- packages/common-i18n/src/locales/de/sdk.json | 11 +- .../common-i18n/src/locales/de/table.json | 7 + .../common-i18n/src/locales/en/common.json | 15 +- packages/common-i18n/src/locales/en/sdk.json | 11 +- .../common-i18n/src/locales/en/table.json | 25 +- .../common-i18n/src/locales/es/common.json | 15 +- packages/common-i18n/src/locales/es/sdk.json | 11 +- .../common-i18n/src/locales/es/table.json | 7 + .../common-i18n/src/locales/fr/common.json | 15 +- packages/common-i18n/src/locales/fr/sdk.json | 11 +- .../common-i18n/src/locales/fr/table.json | 7 + .../common-i18n/src/locales/it/common.json | 15 +- packages/common-i18n/src/locales/it/sdk.json | 11 +- .../common-i18n/src/locales/it/table.json | 7 + .../common-i18n/src/locales/ja/common.json | 15 +- packages/common-i18n/src/locales/ja/sdk.json | 11 +- .../common-i18n/src/locales/ja/table.json | 7 + .../common-i18n/src/locales/ru/common.json | 15 +- packages/common-i18n/src/locales/ru/sdk.json | 11 +- .../common-i18n/src/locales/ru/table.json | 7 + .../common-i18n/src/locales/tr/common.json | 15 +- packages/common-i18n/src/locales/tr/sdk.json | 11 +- .../common-i18n/src/locales/tr/table.json | 7 + .../common-i18n/src/locales/uk/common.json | 15 +- packages/common-i18n/src/locales/uk/sdk.json | 11 +- .../common-i18n/src/locales/uk/table.json | 7 + .../common-i18n/src/locales/zh/common.json | 15 +- packages/common-i18n/src/locales/zh/sdk.json | 11 +- .../common-i18n/src/locales/zh/table.json | 25 +- .../field/derivate/formula.field.spec.ts | 13 + .../src/models/view/filter/filter.spec.ts | 227 +++- .../core/src/models/view/filter/filter.ts | 156 ++- .../core/src/models/view/filter/operator.ts | 7 +- .../migration.sql | 1 + .../prisma/postgres/schema.prisma | 1 + .../db-main-prisma/prisma/template.prisma | 1 + .../icons/src/components/CircleDollarSign.tsx | 18 + packages/icons/src/components/IDCard.tsx | 34 + packages/icons/src/components/Info.tsx | 12 +- packages/icons/src/index.ts | 2 + packages/openapi/src/integrity/link-check.ts | 3 + packages/openapi/src/integrity/schema-v2.ts | 10 + .../upload-attachment/AttachmentItem.tsx | 47 +- .../src/components/editor/attachment/utils.ts | 4 +- .../expand-record/RecordEditorItem.tsx | 6 +- .../condition-item/ConditionItem.tsx | 33 +- .../filter/view-filter/ViewFilter.tsx | 71 +- .../filter/view-filter/hooks/index.ts | 1 + .../filter/view-filter/hooks/useFilterNode.ts | 19 +- .../hooks/useFilterValidationContext.ts | 23 + .../hooks/use-grid-columns.tsx | 39 +- .../grid/managers/sprite-manager/sprites.tsx | 4 +- .../cell-renderer/buttonCellRenderer.ts | 6 +- .../src/components/hide-fields/HideFields.tsx | 4 +- .../components/hide-fields/HideFieldsBase.tsx | 22 +- .../src/components/table/InfiniteTable.tsx | 27 +- .../PostgresTableRepository.helpers.spec.ts | 6 +- .../repositories/PostgresTableRepository.ts | 22 +- .../commands/RecordSearchExplain.db.spec.ts | 237 +++++ .../src/meta/MetaChecker.spec.ts | 33 + .../src/meta/MetaChecker.ts | 10 + .../src/meta/MetaRepairer.spec.ts | 144 +++ .../src/meta/MetaRepairer.ts | 232 ++++ .../src/meta/index.ts | 14 + .../attachments/attachmentTableMutations.ts | 94 ++ .../record/computed/ComputedFieldUpdater.ts | 37 +- .../__tests__/ComputedFieldUpdater.spec.ts | 18 +- .../ComputedFieldSelectExpressionVisitor.ts | 8 +- .../ComputedTableRecordQueryBuilder.spec.ts | 97 ++ .../computed/FieldReferenceSqlVisitor.spec.ts | 26 + .../computed/FieldReferenceSqlVisitor.ts | 32 +- .../SameTableBatchQueryBuilder.spec.ts | 96 ++ .../computed/SameTableBatchQueryBuilder.ts | 41 +- .../FieldReferenceSqlVisitor.spec.ts.snap | 43 +- .../insert/RecordInsertBuilder.ts | 18 + .../PostgresTableRecordRepository.ts | 4 +- .../RecordSearchWhereBuilder.pglite.spec.ts | 50 + .../repository/RecordSearchWhereBuilder.ts | 36 +- .../src/record/repository/dateSearchRange.ts | 76 ++ .../record/visitors/CellValueMutateVisitor.ts | 25 +- .../schema/rules/core/RuleRepairMetadata.ts | 4 + .../schema/rules/field/ColumnExistsRule.ts | 18 + .../src/schema/rules/field/FieldMetaRule.ts | 34 +- .../rules/field/FieldSchemaRulesFactory.ts | 5 +- .../src/schema/rules/field/FkColumnRule.ts | 18 + .../rules/field/GeneratedColumnMetaRule.ts | 18 + .../schema/rules/field/GeneratedColumnRule.ts | 18 + .../schema/rules/field/JunctionTableRule.ts | 19 +- .../schema/rules/field/LinkValueColumnRule.ts | 18 + .../src/schema/rules/field/OrderColumnRule.ts | 18 + .../rules/field/SchemaRules.pglite.spec.ts | 995 +++++++++++++++++- .../rules/field/SelectOptionsMetaRule.ts | 312 ++++++ .../src/schema/rules/field/index.ts | 1 + .../rules/repairer/SchemaRepairResult.ts | 6 + .../schema/rules/repairer/SchemaRepairer.ts | 23 +- .../visitors/TableSchemaUpdateVisitor.ts | 31 +- .../TableSchemaUpdateVisitor.spec.ts | 26 +- .../src/commands/UpdateRecordHandler.spec.ts | 57 +- .../core/src/commands/UpdateRecordHandler.ts | 13 +- .../fields/types/FieldValueObjects.spec.ts | 32 +- .../src/domain/table/fields/types/TimeZone.ts | 26 +- .../core/src/ports/TableRecordRepository.ts | 8 + .../defaults/DefaultTableMapper.spec.ts | 56 + .../mappers/defaults/DefaultTableMapper.ts | 32 +- .../queries/ListTableRecordsHandler.spec.ts | 76 ++ .../src/queries/ListTableRecordsHandler.ts | 71 +- .../core/src/schemas/field/common.schema.ts | 6 +- .../src/schemas/field/tableField.schema.ts | 8 +- .../schemas/field/time-zone.schema.spec.ts | 52 + packages/v2/dottea/src/index.ts | 43 +- .../normalizer/DotTeaFieldNormalizer.spec.ts | 74 ++ .../src/normalizer/DotTeaFieldNormalizer.ts | 100 +- packages/v2/e2e/src/date-time.e2e.spec.ts | 53 +- .../v2/e2e/src/record-http-compat.e2e.spec.ts | 101 +- .../computed/force-v2-all-regressions.spec.ts | 138 +++ 195 files changed, 8544 insertions(+), 1250 deletions(-) create mode 100644 apps/nestjs-backend/src/db-provider/filter-query/__tests__/invalid-filter-skip.spec.ts create mode 100644 apps/nestjs-backend/src/db-provider/search-query/date-search-range.util.ts create mode 100644 apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.spec.ts create mode 100644 apps/nestjs-backend/src/features/canary/canary.service.spec.ts create mode 100644 apps/nestjs-backend/src/features/integrity/integrity-v2.service.spec.ts create mode 100644 apps/nextjs-app/src/features/app/blocks/space-setting/types.ts create mode 100644 apps/nextjs-app/src/features/app/components/setting/UnifiedSettingDialogContent.tsx create mode 100644 packages/db-main-prisma/prisma/postgres/migrations/20260424000000_add_base_v2_enabled/migration.sql create mode 100644 packages/icons/src/components/CircleDollarSign.tsx create mode 100644 packages/icons/src/components/IDCard.tsx create mode 100644 packages/sdk/src/components/filter/view-filter/hooks/useFilterValidationContext.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/integration/commands/RecordSearchExplain.db.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/meta/MetaRepairer.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/meta/MetaRepairer.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/attachments/attachmentTableMutations.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/repository/dateSearchRange.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/field/SelectOptionsMetaRule.ts create mode 100644 packages/v2/core/src/schemas/field/time-zone.schema.spec.ts create mode 100644 packages/v2/dottea/src/normalizer/DotTeaFieldNormalizer.spec.ts diff --git a/apps/nestjs-backend/src/cache/types.ts b/apps/nestjs-backend/src/cache/types.ts index c200ccd5cd..942cc59623 100644 --- a/apps/nestjs-backend/src/cache/types.ts +++ b/apps/nestjs-backend/src/cache/types.ts @@ -36,6 +36,7 @@ export interface ICacheStore { [key: `oauth:token-rate:${string}:${string}`]: number; [key: `automation:email:rate:${string}:${number}`]: number; [key: `automation:email-att:${string}`]: string[]; + [key: `automation:fail-notify-count:${string}`]: number; // Distributed lock keys [key: `lock:${string}`]: string; [key: `import:result:manifest:${string}`]: { diff --git a/apps/nestjs-backend/src/db-provider/filter-query/__tests__/invalid-filter-skip.spec.ts b/apps/nestjs-backend/src/db-provider/filter-query/__tests__/invalid-filter-skip.spec.ts new file mode 100644 index 0000000000..b058bbdc73 --- /dev/null +++ b/apps/nestjs-backend/src/db-provider/filter-query/__tests__/invalid-filter-skip.spec.ts @@ -0,0 +1,306 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { + CellValueType, + DateFieldCore, + DateFormattingPreset, + DriverClient, + FieldType, + NumberFieldCore, + SingleLineTextFieldCore, + TimeFormatting, +} from '@teable/core'; +import type { FieldCore, IFilter } from '@teable/core'; +import knex from 'knex'; +import type { IRecordQueryFilterContext } from '../../../features/record/query-builder/record-query-builder.interface'; +import type { IDbProvider } from '../../db.provider.interface'; +import type { AbstractCellValueFilter } from '../cell-value-filter.abstract'; +import { AbstractFilterQuery } from '../filter-query.abstract'; +import { FilterQueryPostgres } from '../postgres/filter-query.postgres'; + +const knexBuilder = knex({ client: 'pg' }); +const dbProviderStub = { driver: DriverClient.Pg } as unknown as IDbProvider; +const mainTableAlias = 'main_table as main'; + +function assignBaseField( + field: T, + params: { + id: string; + name?: string; + dbFieldName: string; + type: FieldType; + cellValueType: CellValueType; + options: T['options']; + } +): T { + field.id = params.id; + field.name = params.name ?? params.id; + field.dbFieldName = params.dbFieldName; + field.type = params.type; + field.options = params.options; + field.cellValueType = params.cellValueType; + field.isMultipleCellValue = false; + field.isLookup = false; + field.updateDbFieldType(); + return field; +} + +function createNumberField(id: string, dbFieldName: string): NumberFieldCore { + return assignBaseField(new NumberFieldCore(), { + id, + dbFieldName, + type: FieldType.Number, + cellValueType: CellValueType.Number, + options: NumberFieldCore.defaultOptions(), + }); +} + +function createTextField(id: string, dbFieldName: string, name?: string): SingleLineTextFieldCore { + return assignBaseField(new SingleLineTextFieldCore(), { + id, + name, + dbFieldName, + type: FieldType.SingleLineText, + cellValueType: CellValueType.String, + options: SingleLineTextFieldCore.defaultOptions(), + }); +} + +function createDateField(id: string, dbFieldName: string): DateFieldCore { + const options = DateFieldCore.defaultOptions(); + options.formatting = { + date: DateFormattingPreset.ISO, + time: TimeFormatting.None, + timeZone: 'UTC', + }; + return assignBaseField(new DateFieldCore(), { + id, + dbFieldName, + type: FieldType.Date, + cellValueType: CellValueType.DateTime, + options, + }); +} + +class ThrowingFilterQuery extends AbstractFilterQuery { + private createThrowingFilter(): AbstractCellValueFilter { + return { + compiler: () => { + throw new Error('unexpected adapter failure'); + }, + } as unknown as AbstractCellValueFilter; + } + + booleanFilter(_field: FieldCore, _context?: IRecordQueryFilterContext): AbstractCellValueFilter { + return this.createThrowingFilter(); + } + + numberFilter(_field: FieldCore, _context?: IRecordQueryFilterContext): AbstractCellValueFilter { + return this.createThrowingFilter(); + } + + dateTimeFilter(_field: FieldCore, _context?: IRecordQueryFilterContext): AbstractCellValueFilter { + return this.createThrowingFilter(); + } + + stringFilter(_field: FieldCore, _context?: IRecordQueryFilterContext): AbstractCellValueFilter { + return this.createThrowingFilter(); + } + + jsonFilter(_field: FieldCore, _context?: IRecordQueryFilterContext): AbstractCellValueFilter { + return this.createThrowingFilter(); + } +} + +describe('filter-query invalid filter skip', () => { + it('skips filter item with invalid operator instead of throwing', () => { + const numberField = createNumberField('fld_num', 'num_col'); + const filter = { + conjunction: 'and', + filterSet: [ + { + fieldId: numberField.id, + operator: 'contains', + value: 'whatever', + }, + ], + } as unknown as IFilter; + + const qb = knexBuilder(mainTableAlias); + const filterQuery = new FilterQueryPostgres( + qb, + { [numberField.id]: numberField }, + filter, + undefined, + dbProviderStub + ); + + expect(() => filterQuery.appendQueryBuilder()).not.toThrow(); + expect(qb.toQuery()).not.toContain('num_col'); + }); + + it('preserves valid filter items alongside skipped invalid ones', () => { + const numberField = createNumberField('fld_num', 'num_col'); + const textField = createTextField('fld_text', 'text_col'); + const filter = { + conjunction: 'and', + filterSet: [ + { + fieldId: numberField.id, + operator: 'contains', + value: 'whatever', + }, + { + fieldId: textField.id, + operator: 'contains', + value: 'hello', + }, + ], + } as unknown as IFilter; + + const qb = knexBuilder(mainTableAlias); + const filterQuery = new FilterQueryPostgres( + qb, + { [numberField.id]: numberField, [textField.id]: textField }, + filter, + undefined, + dbProviderStub + ); + + expect(() => filterQuery.appendQueryBuilder()).not.toThrow(); + const sql = qb.toQuery(); + expect(sql).toContain('text_col'); + expect(sql).not.toContain('num_col'); + }); + + it('keeps filter items keyed by field name when fields map supports name keys', () => { + const textField = createTextField('fld_text_name', 'text_name_col', 'Display Name'); + const filter = { + conjunction: 'and', + filterSet: [ + { + fieldId: textField.name, + operator: 'contains', + value: 'hello', + }, + ], + } as unknown as IFilter; + + const qb = knexBuilder(mainTableAlias); + const filterQuery = new FilterQueryPostgres( + qb, + { [textField.id]: textField, [textField.name]: textField }, + filter, + undefined, + dbProviderStub + ); + + expect(() => filterQuery.appendQueryBuilder()).not.toThrow(); + expect(qb.toQuery()).toContain('text_name_col'); + }); + + it('skips filter item with invalid sub-operator mode instead of throwing', () => { + const dateField = createDateField('fld_date', 'date_col'); + const filter = { + conjunction: 'and', + filterSet: [ + { + fieldId: dateField.id, + operator: 'isWithIn', + value: { mode: 'invalidMode', exactDate: null, timeZone: 'UTC' }, + }, + ], + } as unknown as IFilter; + + const qb = knexBuilder(mainTableAlias); + const filterQuery = new FilterQueryPostgres( + qb, + { [dateField.id]: dateField }, + filter, + undefined, + dbProviderStub + ); + + expect(() => filterQuery.appendQueryBuilder()).not.toThrow(); + expect(qb.toQuery()).not.toContain('date_col'); + }); + + it('skips filter item whose value shape fails inside the adapter compiler', () => { + const dateField = createDateField('fld_date_shape', 'date_shape_col'); + // value is a string, but isWithIn requires an object { mode, ... } + const filter = { + conjunction: 'and', + filterSet: [ + { + fieldId: dateField.id, + operator: 'isWithIn', + value: 'today', + }, + ], + } as unknown as IFilter; + + const qb = knexBuilder(mainTableAlias).select('id'); + const filterQuery = new FilterQueryPostgres( + qb, + { [dateField.id]: dateField }, + filter, + undefined, + dbProviderStub + ); + + expect(() => filterQuery.appendQueryBuilder()).not.toThrow(); + expect(() => qb.toQuery()).not.toThrow(); + }); + + it('rethrows non-user compiler errors instead of swallowing them', () => { + const numberField = createNumberField('fld_num_system', 'num_system_col'); + const filter = { + conjunction: 'and', + filterSet: [ + { + fieldId: numberField.id, + operator: 'is', + value: 1, + }, + ], + } as unknown as IFilter; + + const qb = knexBuilder(mainTableAlias).select('id'); + const filterQuery = new ThrowingFilterQuery( + qb, + { [numberField.id]: numberField }, + filter, + undefined, + dbProviderStub + ); + + expect(() => filterQuery.appendQueryBuilder()).not.toThrow(); + expect(() => qb.toQuery()).toThrow(); + }); + + it('rethrows field-reference context errors instead of skipping them', () => { + const textField = createTextField('fld_text_ref_context', 'text_ref_context_col'); + const refField = createTextField('fld_ref_context', 'ref_context_col'); + const filter = { + conjunction: 'and', + filterSet: [ + { + fieldId: textField.id, + operator: 'is', + value: { type: 'field', fieldId: refField.id }, + }, + ], + } as unknown as IFilter; + + const qb = knexBuilder(mainTableAlias).select('id'); + const filterQuery = new FilterQueryPostgres( + qb, + { [textField.id]: textField, [refField.id]: refField }, + filter, + undefined, + dbProviderStub + ); + + expect(() => filterQuery.appendQueryBuilder()).not.toThrow(); + expect(() => qb.toQuery()).toThrow('not available for reference comparisons'); + }); +}); diff --git a/apps/nestjs-backend/src/db-provider/filter-query/filter-query.abstract.ts b/apps/nestjs-backend/src/db-provider/filter-query/filter-query.abstract.ts index 6910423874..d05f322b32 100644 --- a/apps/nestjs-backend/src/db-provider/filter-query/filter-query.abstract.ts +++ b/apps/nestjs-backend/src/db-provider/filter-query/filter-query.abstract.ts @@ -1,8 +1,8 @@ -import { Logger } from '@nestjs/common'; +import { BadRequestException, Logger } from '@nestjs/common'; import type { FieldCore, IConjunction, - IDateTimeFieldOperator, + IFilterValidationError, IFilter, IFilterItem, IFilterOperator, @@ -14,17 +14,16 @@ import { CellValueType, DbFieldType, FieldType, + analyzeFilterValidationIssues, getFilterOperatorMapping, - getValidFilterSubOperators, - HttpErrorCode, isEmpty, isMeTag, isNotEmpty, isFieldReferenceValue, } from '@teable/core'; import type { Knex } from 'knex'; -import { includes, invert, isObject } from 'lodash'; -import { CustomHttpException } from '../../custom.exception'; +import { includes, invert } from 'lodash'; +import { ZodError } from 'zod'; import type { IRecordQueryFilterContext } from '../../features/record/query-builder/record-query-builder.interface'; import type { IDbProvider, IFilterQueryExtra } from '../db.provider.interface'; import type { AbstractCellValueFilter } from './cell-value-filter.abstract'; @@ -33,6 +32,7 @@ import type { IFilterQueryInterface } from './filter-query.interface'; export abstract class AbstractFilterQuery implements IFilterQueryInterface { private logger = new Logger(AbstractFilterQuery.name); + private filterValidationIssueMap = new Map(); constructor( protected readonly originQueryBuilder: Knex.QueryBuilder, @@ -45,6 +45,7 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface { appendQueryBuilder(): Knex.QueryBuilder { this.preProcessRemoveNullAndReplaceMe(this.filter); + this.filterValidationIssueMap = this.collectFilterValidationIssues(this.filter); return this.parseFilters(this.originQueryBuilder, this.filter); } @@ -52,20 +53,22 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface { private parseFilters( queryBuilder: Knex.QueryBuilder, filter?: IFilter, - parentConjunction?: IConjunction + parentConjunction?: IConjunction, + path: number[] = [] ): Knex.QueryBuilder { if (!filter || !filter.filterSet) { return queryBuilder; } const { filterSet, conjunction } = filter; queryBuilder.where((filterBuilder) => { - filterSet.forEach((filterItem) => { + filterSet.forEach((filterItem, index) => { + const itemPath = [...path, index]; if ('fieldId' in filterItem) { - this.parseFilter(filterBuilder, filterItem as IFilterItem, conjunction); + this.parseFilter(filterBuilder, filterItem as IFilterItem, conjunction, itemPath); } else { filterBuilder = filterBuilder[parentConjunction || conjunction]; filterBuilder.where((builder) => { - this.parseFilters(builder, filterItem as IFilterSet, conjunction); + this.parseFilters(builder, filterItem as IFilterSet, conjunction, itemPath); }); } }); @@ -77,7 +80,8 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface { private parseFilter( queryBuilder: Knex.QueryBuilder, filterMeta: IFilterItem, - conjunction: IConjunction + conjunction: IConjunction, + path: number[] ) { const { fieldId, operator, value, isSymbol } = filterMeta; @@ -86,71 +90,153 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface { return queryBuilder; } - let convertOperator = operator; - const filterOperatorMapping = getFilterOperatorMapping(field); - const validFilterOperators = Object.keys(filterOperatorMapping); - if (isSymbol) { - convertOperator = invert(filterOperatorMapping)[operator] as IFilterOperator; + if (this.shouldSkipInvalidFilterItem(field, filterMeta, path)) { + return queryBuilder; } + const convertOperator = this.getConvertedOperator(field, operator, isSymbol); + const validFilterOperators = Object.keys(getFilterOperatorMapping(field)); + if (!includes(validFilterOperators, convertOperator)) { - let referenceFieldId: string | undefined; - if (isFieldReferenceValue(value)) { - referenceFieldId = value.fieldId; - } else if (Array.isArray(value)) { - referenceFieldId = ( - value.find((entry) => isFieldReferenceValue(entry)) as IFieldReferenceValue | undefined - )?.fieldId; - } + this.throwIfFilterReferencesInvalidOperator(field, value); + this.logger.warn( + `Skip filter item: field=${field.id}(${field.name}) operator='${convertOperator}' not in [${validFilterOperators.join(',')}]` + ); + return queryBuilder; + } - if (referenceFieldId) { - const referenceName = this.fields?.[referenceFieldId]?.name ?? referenceFieldId; - const sourceName = field.name ?? field.id; - throw new FieldReferenceCompatibilityException(sourceName, referenceName); - } + queryBuilder = queryBuilder[conjunction]; - throw new CustomHttpException( - `The '${convertOperator}' operation provided for the '${field.name}' filter is invalid. Only the following types are allowed: [${validFilterOperators}]`, - HttpErrorCode.VALIDATION_ERROR, - { - localization: { - i18nKey: 'httpErrors.view.filterInvalidOperator', - }, - } + try { + this.getFilterAdapter(field).compiler( + queryBuilder, + convertOperator as IFilterOperator, + value, + this.dbProvider! ); + } catch (error) { + this.handleCompilerError(error, field, convertOperator, value); + } + return queryBuilder; + } + + private shouldSkipInvalidFilterItem(field: FieldCore, filterMeta: IFilterItem, path: number[]) { + const validationIssues = this.getFilterItemValidationIssues(path); + if (validationIssues.length === 0) { + return false; } - const validFilterSubOperators = getValidFilterSubOperators( - field.type, - convertOperator as IDateTimeFieldOperator + const hasInvalidOperator = validationIssues.some( + (issue) => issue.code === 'OPERATOR_NOT_ALLOWED' ); + if (hasInvalidOperator) { + this.throwIfFilterReferencesInvalidOperator(field, filterMeta.value); + } - if ( - validFilterSubOperators && - isObject(value) && - 'mode' in value && - !includes(validFilterSubOperators, value.mode) - ) { - throw new CustomHttpException( - `The '${convertOperator}' operation provided for the '${field.name}' filter is invalid. Only the following subtypes are allowed: [${validFilterSubOperators}]`, - HttpErrorCode.VALIDATION_ERROR, - { - localization: { - i18nKey: 'httpErrors.view.filterInvalidOperatorMode', - }, - } - ); + this.logger.warn( + `Skip filter item: field=${field.id}(${field.name}) path=${path.join('.')} issues=[${validationIssues + .map((issue) => issue.code) + .join(',')}]` + ); + return true; + } + + private getConvertedOperator(field: FieldCore, operator: string, isSymbol?: boolean) { + if (!isSymbol) { + return operator as IFilterOperator; } - queryBuilder = queryBuilder[conjunction]; + return invert(getFilterOperatorMapping(field))[operator] as IFilterOperator; + } - this.getFilterAdapter(field).compiler( - queryBuilder, - convertOperator as IFilterOperator, - value, - this.dbProvider! + private throwIfFilterReferencesInvalidOperator(field: FieldCore, value: unknown) { + const referenceFieldId = this.extractFieldReferenceFieldId(value); + if (!referenceFieldId) { + return; + } + + const referenceName = this.fields?.[referenceFieldId]?.name ?? referenceFieldId; + const sourceName = field.name ?? field.id; + throw new FieldReferenceCompatibilityException(sourceName, referenceName); + } + + private handleCompilerError( + error: unknown, + field: FieldCore, + convertOperator: IFilterOperator, + value: unknown + ) { + if (error instanceof FieldReferenceCompatibilityException) { + throw error; + } + if (this.extractFieldReferenceFieldId(value)) { + throw error; + } + if (!this.isSkippableCompilerError(error)) { + throw error; + } + const reason = error instanceof Error ? error.message : String(error); + this.logger.warn( + `Skip filter item: field=${field.id}(${field.name}) operator='${convertOperator}' ` + + `value=${JSON.stringify(value)} compile error: ${reason}` ); - return queryBuilder; + } + + private collectFilterValidationIssues(filter?: IFilter) { + const issueMap = new Map(); + if (!filter || !this.fields) { + return issueMap; + } + + const fieldMetaMap = Object.entries(this.fields).reduce( + (acc, [fieldKey, field]) => { + const fieldMeta = { + type: field.type, + cellValueType: field.cellValueType, + isMultipleCellValue: Boolean(field.isMultipleCellValue), + }; + acc[fieldKey] = fieldMeta; + acc[field.id] = fieldMeta; + return acc; + }, + {} as Record< + string, + { + type: FieldType; + cellValueType: CellValueType; + isMultipleCellValue: boolean; + } + > + ); + + const issues = analyzeFilterValidationIssues(filter, fieldMetaMap); + issues.forEach((issue) => { + const key = issue.path.join('.'); + const issueList = issueMap.get(key) ?? []; + issueList.push(issue); + issueMap.set(key, issueList); + }); + return issueMap; + } + + private getFilterItemValidationIssues(path: number[]) { + return this.filterValidationIssueMap.get(path.join('.')) ?? []; + } + + private extractFieldReferenceFieldId(value: unknown): string | undefined { + if (isFieldReferenceValue(value)) { + return value.fieldId; + } + if (Array.isArray(value)) { + return ( + value.find((entry) => isFieldReferenceValue(entry)) as IFieldReferenceValue | undefined + )?.fieldId; + } + return undefined; + } + + private isSkippableCompilerError(error: unknown) { + return error instanceof BadRequestException || error instanceof ZodError; } private getFilterAdapter(field: FieldCore): AbstractCellValueFilter { diff --git a/apps/nestjs-backend/src/db-provider/formula-default-unit.spec.ts b/apps/nestjs-backend/src/db-provider/formula-default-unit.spec.ts index 1909991cf4..dd2c9259a2 100644 --- a/apps/nestjs-backend/src/db-provider/formula-default-unit.spec.ts +++ b/apps/nestjs-backend/src/db-provider/formula-default-unit.spec.ts @@ -69,3 +69,35 @@ describe('convertFormulaToGeneratedColumn blank numeric comparisons', () => { expect(result.sql).toContain("= ''"); }); }); + +describe('convertFormulaToSelectQuery blank numeric comparisons', () => { + it('keeps spaced BLANK() as a blank operand when comparing number fields', () => { + const numberField = createFieldInstanceByVo({ + id: 'fldWeight', + dbFieldName: 'weight', + name: 'Weight', + type: FieldType.Number, + cellValueType: CellValueType.Number, + dbFieldType: DbFieldType.Real, + }); + const table = new TableDomain({ + id: 'tblFormulaUnit', + name: 'Formula Unit', + dbTableName: 'public.tbl_formula_unit', + lastModifiedTime: '2026-04-08T00:00:00.000Z', + fields: [numberField], + }); + const provider = new PostgresProvider(knex({ client: 'pg' })); + const sql = toSql( + provider.convertFormulaToSelectQuery('{fldWeight} != BLANK()', { + ...context, + table, + }) + ); + + expect(sql).toContain('COALESCE(NULLIF'); + expect(sql).toContain('"weight"'); + expect(sql).not.toContain('::numeric'); + expect(sql).toContain("<> ''"); + }); +}); diff --git a/apps/nestjs-backend/src/db-provider/search-query/date-search-range.util.ts b/apps/nestjs-backend/src/db-provider/search-query/date-search-range.util.ts new file mode 100644 index 0000000000..3e85cf721f --- /dev/null +++ b/apps/nestjs-backend/src/db-provider/search-query/date-search-range.util.ts @@ -0,0 +1,80 @@ +import { DateFormattingPreset, type IDateFieldOptions, TimeFormatting } from '@teable/core'; +import dayjs from 'dayjs'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(customParseFormat); + +type IDateSearchUnit = 'year' | 'month' | 'day' | 'minute'; + +export interface IDateSearchRange { + start: string; + end: string; +} + +const dateSearchPatterns: Array<{ pattern: RegExp; format: string; unit: IDateSearchUnit }> = [ + { pattern: /^\d{4}$/, format: 'YYYY', unit: 'year' }, + { pattern: /^\d{4}-\d{2}$/, format: 'YYYY-MM', unit: 'month' }, + { pattern: /^\d{4}-\d{2}-\d{2}$/, format: 'YYYY-MM-DD', unit: 'day' }, + { pattern: /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}$/, format: 'YYYY-MM-DD HH:mm', unit: 'minute' }, +]; + +const isUnitAllowed = ( + unit: IDateSearchUnit, + formatting: IDateFieldOptions['formatting'] +): boolean => { + const dateFormat = formatting.date ?? DateFormattingPreset.ISO; + const hasTime = formatting.time != null && formatting.time !== TimeFormatting.None; + + switch (unit) { + case 'year': + return true; + case 'month': + return dateFormat !== DateFormattingPreset.Y; + case 'day': + return dateFormat !== DateFormattingPreset.Y && dateFormat !== DateFormattingPreset.YM; + case 'minute': + return hasTime; + default: + return false; + } +}; + +export const getDateSearchRange = ( + rawSearchValue: string, + dateFieldOptions: IDateFieldOptions +): IDateSearchRange | null => { + const searchValue = rawSearchValue.trim(); + if (!searchValue) { + return null; + } + + const formatting = dateFieldOptions.formatting; + const timeZone = formatting.timeZone; + + for (const candidate of dateSearchPatterns) { + if (!candidate.pattern.test(searchValue) || !isUnitAllowed(candidate.unit, formatting)) { + continue; + } + + const normalizedSearchValue = + candidate.unit === 'minute' ? searchValue.replace('T', ' ') : searchValue; + const parsed = dayjs.tz(normalizedSearchValue, candidate.format, timeZone); + if (!parsed.isValid() || parsed.format(candidate.format) !== normalizedSearchValue) { + continue; + } + + const start = parsed.startOf(candidate.unit); + const end = start.add(1, candidate.unit); + + return { + start: start.toISOString(), + end: end.toISOString(), + }; + } + + return null; +}; diff --git a/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.spec.ts b/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.spec.ts index e8544e1a62..e4756350b4 100644 --- a/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.spec.ts +++ b/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.spec.ts @@ -1,9 +1,9 @@ import { CellValueType, FieldType } from '@teable/core'; import type { IFieldInstance } from '../../features/field/model/factory'; -import { FieldFormatter } from './search-index-builder.postgres'; +import { FieldFormatter, IndexBuilderPostgres } from './search-index-builder.postgres'; describe('FieldFormatter', () => { - it('formats date fields for search without creating a trigram index expression', () => { + it('does not expose trigram search expressions for date fields, but still builds a btree index spec', () => { const field = { cellValueType: CellValueType.DateTime, dbFieldName: 'Due_Date', @@ -17,9 +17,32 @@ describe('FieldFormatter', () => { type: FieldType.Date, } as IFieldInstance; - expect(FieldFormatter.getSearchableExpression(field)).toBe( - "TO_CHAR(TIMEZONE('Asia/Singapore', \"Due_Date\"), 'YYYY-MM-DD HH24:MI')" + expect(FieldFormatter.getSearchableExpression(field)).toBeNull(); + expect(FieldFormatter.getIndexSpec(field)).toEqual({ + kind: 'btree', + expression: '"Due_Date"', + }); + }); + + it('creates a btree index sql for single datetime fields', () => { + const builder = new IndexBuilderPostgres(); + const field = { + id: 'fldDateField000001', + cellValueType: CellValueType.DateTime, + dbFieldName: 'Due_Date', + isMultipleCellValue: false, + isStructuredCellValue: false, + options: { + formatting: { + timeZone: 'Asia/Singapore', + }, + }, + type: FieldType.Date, + } as IFieldInstance; + + expect(builder.createSingleIndexSql('base_table.records', field)).toContain( + 'ON "base_table"."records" USING btree ("Due_Date")' ); - expect(FieldFormatter.getIndexExpression(field)).toBeNull(); + expect(builder.createSingleIndexSql('base_table.records', field)).not.toContain('gin_trgm_ops'); }); }); diff --git a/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.ts b/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.ts index a8a522ae10..c6f852136c 100644 --- a/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.ts +++ b/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.ts @@ -1,7 +1,6 @@ /* eslint-disable regexp/no-unused-capturing-group */ /* eslint-disable sonarjs/no-duplicate-string */ import { assertNever, CellValueType, FieldType } from '@teable/core'; -import type { IDateFieldOptions } from '@teable/core'; import type { IFieldInstance } from '../../features/field/model/factory'; import { IndexBuilderAbstract } from '../index-query/index-abstract-builder'; @@ -14,7 +13,17 @@ interface IPgIndex { indexdef: string; } -const unSupportCellValueType = [CellValueType.DateTime, CellValueType.Boolean]; +const unSupportCellValueType = [CellValueType.Boolean]; + +type ISearchIndexSpec = + | { + kind: 'btree'; + expression: string; + } + | { + kind: 'trgm'; + expression: string; + }; export class FieldFormatter { static getSearchableExpression(field: IFieldInstance, isArray = false): string | null { @@ -29,8 +38,8 @@ export class FieldFormatter { return `ROUND(value::numeric, ${precision})::text`; } case CellValueType.DateTime: { - const timeZone = (options as IDateFieldOptions).formatting.timeZone.replace(/'/g, "''"); - return `TO_CHAR(TIMEZONE('${timeZone}', value), 'YYYY-MM-DD HH24:MI')`; + // date type not support full text search + return null; } case CellValueType.Boolean: { // date type not support full text search @@ -67,11 +76,27 @@ export class FieldFormatter { } // expression for generating index - static getIndexExpression(field: IFieldInstance): string | null { + static getIndexSpec(field: IFieldInstance): ISearchIndexSpec | null { if (field.cellValueType === CellValueType.DateTime) { + if (field.isMultipleCellValue) { + return null; + } + + return { + kind: 'btree', + expression: `"${field.dbFieldName}"`, + }; + } + + const expression = this.getSearchableExpression(field, field.isMultipleCellValue); + if (!expression) { return null; } - return this.getSearchableExpression(field, field.isMultipleCellValue); + + return { + kind: 'trgm', + expression, + }; } } @@ -112,12 +137,16 @@ export class IndexBuilderPostgres extends IndexBuilderAbstract { createSingleIndexSql(dbTableName: string, field: IFieldInstance): string | null { const [schema, table] = dbTableName.split('.'); const indexName = this.getIndexName(table, field); - const expression = FieldFormatter.getIndexExpression(field); - if (expression === null) { + const indexSpec = FieldFormatter.getIndexSpec(field); + if (indexSpec === null) { return null; } - return `CREATE INDEX IF NOT EXISTS "${indexName}" ON "${schema}"."${table}" USING gin ((${expression}) gin_trgm_ops)`; + if (indexSpec.kind === 'btree') { + return `CREATE INDEX IF NOT EXISTS "${indexName}" ON "${schema}"."${table}" USING btree (${indexSpec.expression})`; + } + + return `CREATE INDEX IF NOT EXISTS "${indexName}" ON "${schema}"."${table}" USING gin ((${indexSpec.expression}) gin_trgm_ops)`; } getDropIndexSql(dbTableName: string): string { @@ -145,8 +174,7 @@ export class IndexBuilderPostgres extends IndexBuilderAbstract { const fieldSql = searchFields .filter(({ cellValueType }) => !unSupportCellValueType.includes(cellValueType)) .map((field) => { - const expression = FieldFormatter.getIndexExpression(field); - return expression ? this.createSingleIndexSql(dbTableName, field) : null; + return this.createSingleIndexSql(dbTableName, field); }) .filter((sql): sql is string => sql !== null); @@ -202,9 +230,8 @@ export class IndexBuilderPostgres extends IndexBuilderAbstract { const [, table] = dbTableName.split('.'); const expectExistIndex = fields .filter(({ cellValueType }) => !unSupportCellValueType.includes(cellValueType)) - .map((field) => { - return this.getIndexName(table, field); - }); + .filter((field) => this.createSingleIndexSql(dbTableName, field) !== null) + .map((field) => this.getIndexName(table, field)); // 1: find the lack or redundant index const lackingIndex = expectExistIndex.filter( @@ -223,11 +250,16 @@ export class IndexBuilderPostgres extends IndexBuilderAbstract { // 2: find the abnormal index definition const expectIndexDef = fields .filter(({ cellValueType }) => !unSupportCellValueType.includes(cellValueType)) - .map((f) => { - return { - indexName: this.getIndexName(table, f), - indexDef: this.createSingleIndexSql(dbTableName, f) as string, - }; + .flatMap((f) => { + const indexDef = this.createSingleIndexSql(dbTableName, f); + return indexDef + ? [ + { + indexName: this.getIndexName(table, f), + indexDef, + }, + ] + : []; }); return expectIndexDef 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 new file mode 100644 index 0000000000..069a3cf712 --- /dev/null +++ b/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.spec.ts @@ -0,0 +1,55 @@ +import { CellValueType, DateFormattingPreset, FieldType, TimeFormatting } from '@teable/core'; +import { TableIndex } from '@teable/openapi'; +import knex from 'knex'; +import { describe, expect, it } from 'vitest'; +import type { IFieldInstance } from '../../features/field/model/factory'; +import { SearchQueryPostgres } from './search-query.postgres'; + +const buildDateField = (): IFieldInstance => + ({ + id: 'fldDateSearch00001', + dbFieldName: 'Due_Date', + cellValueType: CellValueType.DateTime, + isMultipleCellValue: false, + isStructuredCellValue: false, + type: FieldType.Date, + options: { + formatting: { + date: DateFormattingPreset.ISO, + time: TimeFormatting.None, + timeZone: 'Asia/Shanghai', + }, + }, + }) as IFieldInstance; + +describe('SearchQueryPostgres', () => { + const db = knex({ client: 'pg' }); + + it('uses a datetime range for date-like search values when search index is enabled', () => { + const field = buildDateField(); + const builder = new SearchQueryPostgres( + db.queryBuilder(), + field, + ['2022-03-02', '', true], + [TableIndex.search] + ); + + const compiled = builder.getQuery()?.toSQL(); + expect(compiled?.sql).toContain('"Due_Date" >= ?::timestamptz AND "Due_Date" < ?::timestamptz'); + expect(compiled?.sql).not.toContain('TO_CHAR'); + expect(compiled?.bindings).toEqual(['2022-03-01T16:00:00.000Z', '2022-03-02T16:00:00.000Z']); + }); + + it('skips date-field scans for non-date-like search values', () => { + const field = buildDateField(); + const builder = new SearchQueryPostgres( + db.queryBuilder(), + field, + ['not-a-date', '', true], + [TableIndex.search] + ); + + const compiled = builder.getQuery()?.toSQL(); + expect(compiled?.sql).toBe('FALSE'); + }); +}); 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 aba34644a7..53283be572 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 @@ -9,6 +9,7 @@ import type { IRecordQueryFilterContext } from '../../features/record/query-buil import { escapePostgresRegex } from '../../utils/postgres-regex-escape'; import { escapeLikeWildcards } from '../../utils/sql-like-escape'; import { SearchQueryAbstract } from './abstract'; +import { getDateSearchRange } from './date-search-range.util'; import { FieldFormatter } from './search-index-builder.postgres'; import type { ISearchCellValueType } from './types'; @@ -53,6 +54,9 @@ export class SearchQueryPostgres extends SearchQueryAbstract { const { isMultipleCellValue } = field; const isSearchAllFields = !search[1]; if (isSearchAllFields) { + if (field.cellValueType === CellValueType.DateTime) { + return isMultipleCellValue ? this.multipleDate() : this.date(); + } const searchValue = search[0]; const escapedSearchValue = escapeLikeWildcards(searchValue); const expression = FieldFormatter.getSearchableExpression(field, isMultipleCellValue); @@ -136,17 +140,15 @@ export class SearchQueryPostgres extends SearchQueryAbstract { } protected date() { - const { - search, - knex, - field: { options }, - } = this; - const searchValue = search[0]; - const escapedSearchValue = escapeLikeWildcards(searchValue); - const timeZone = (options as IDateFieldOptions).formatting.timeZone; + const { search, knex } = this; + const range = getDateSearchRange(search[0], this.field.options as IDateFieldOptions); + if (!range) { + return knex.raw('FALSE'); + } + return knex.raw( - `TO_CHAR(TIMEZONE(?, ${this.fieldName}), 'YYYY-MM-DD HH24:MI') ILIKE ? ESCAPE '\\'`, - [timeZone, `%${escapedSearchValue}%`] + `(${this.fieldName} >= ?::timestamptz AND ${this.fieldName} < ?::timestamptz)`, + [range.start, range.end] ); } @@ -199,20 +201,24 @@ export class SearchQueryPostgres extends SearchQueryAbstract { protected multipleDate() { const { search, knex } = this; - const searchValue = search[0]; - const escapedSearchValue = escapeLikeWildcards(searchValue); - const timeZone = (this.field.options as IDateFieldOptions).formatting.timeZone; + const range = getDateSearchRange(search[0], this.field.options as IDateFieldOptions); + if (!range) { + return knex.raw('FALSE'); + } + return knex.raw( ` EXISTS ( SELECT 1 FROM ( - SELECT string_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), 'YYYY-MM-DD HH24:MI'), ', ') as aggregated + SELECT 1 FROM jsonb_array_elements_text(${this.fieldName}::jsonb) as elem + WHERE CAST(elem AS timestamp with time zone) >= ?::timestamptz + AND CAST(elem AS timestamp with time zone) < ?::timestamptz + LIMIT 1 ) as sub - WHERE sub.aggregated ILIKE ? ESCAPE '\\' ) `, - [timeZone, `%${escapedSearchValue}%`] + [range.start, range.end] ); } @@ -298,8 +304,11 @@ export class SearchQueryPostgresBuilder { return conditions .filter(({ field }) => { - // global search does not support checkbox - if (isSearchAllFields && field.cellValueType === CellValueType.Boolean) { + // global search does not support date time and checkbox + if ( + isSearchAllFields && + [CellValueType.DateTime, CellValueType.Boolean].includes(field.cellValueType) + ) { return false; } return true; diff --git a/apps/nestjs-backend/src/features/attachments/attachments-crop.processor.ts b/apps/nestjs-backend/src/features/attachments/attachments-crop.processor.ts index 0783666b7b..0b93b38a0c 100644 --- a/apps/nestjs-backend/src/features/attachments/attachments-crop.processor.ts +++ b/apps/nestjs-backend/src/features/attachments/attachments-crop.processor.ts @@ -5,6 +5,7 @@ import { isImage, isPdf } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import { Queue } from 'bullmq'; import type { Job } from 'bullmq'; +import sharp from 'sharp'; import { EventEmitterService } from '../../event-emitter/event-emitter.service'; import { Events } from '../../event-emitter/events'; import { AttachmentsStorageService } from '../attachments/attachments-storage.service'; @@ -59,7 +60,11 @@ export class AttachmentsCropQueueProcessor extends WorkerHost { where: { token }, select: { thumbnailPath: true }, }); - if (existing?.thumbnailPath) { + if (!existing) { + this.logger.log(`Attachment with token(${token}) does not exist.`); + return; + } + if (existing.thumbnailPath) { this.logger.log(`path(${path}) already has thumbnail`); return; } @@ -83,6 +88,12 @@ export class AttachmentsCropQueueProcessor extends WorkerHost { const pdfBuffer = Buffer.concat(chunks); const { buffer, height: imgHeight } = await renderPdfFirstPageAsImage(pdfBuffer); + const isBlank = await this.isBlankImage(buffer); + if (isBlank) { + this.logger.warn(`PDF thumbnail for ${path} is blank, skipping storage`); + return; + } + ({ lgThumbnailPath, smThumbnailPath } = await this.attachmentsStorageService.uploadTableImageThumbnailsFromBuffer( bucket, @@ -91,7 +102,6 @@ export class AttachmentsCropQueueProcessor extends WorkerHost { imgHeight )); } catch (error) { - console.error(`Failed to render PDF thumbnail for ${path}`, error); this.logger.error(`PDF thumbnail failed for ${path}`, error); // Non-fatal: frontend falls back to PDF icon return; @@ -109,4 +119,9 @@ export class AttachmentsCropQueueProcessor extends WorkerHost { }); this.logger.log(`path(${path}) crop thumbnails success`); } + + private async isBlankImage(pngBuffer: Buffer): Promise { + const { channels } = await sharp(pngBuffer).stats(); + return channels.slice(0, 3).every((ch) => ch.min >= 250); + } } diff --git a/apps/nestjs-backend/src/features/auth/guard/base-node-permission.guard.ts b/apps/nestjs-backend/src/features/auth/guard/base-node-permission.guard.ts index 5af107d066..e43d519cf6 100644 --- a/apps/nestjs-backend/src/features/auth/guard/base-node-permission.guard.ts +++ b/apps/nestjs-backend/src/features/auth/guard/base-node-permission.guard.ts @@ -1,7 +1,7 @@ import type { ExecutionContext } from '@nestjs/common'; import { Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { HttpErrorCode } from '@teable/core'; +import { HttpErrorCode, IdPrefix, identify } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import type { BaseNodeResourceType } from '@teable/openapi'; import { ClsService } from 'nestjs-cls'; @@ -53,6 +53,7 @@ export class BaseNodePermissionGuard extends PermissionGuard { }, }); } + await this.resolveRequestNodeIds(context, baseId); const permissionContext = await this.getPermissionContext(); return this.checkActivate(context, baseId, permissionContext); } @@ -147,6 +148,30 @@ export class BaseNodePermissionGuard extends PermissionGuard { } } + async resolveRequestNodeIds(context: ExecutionContext, baseId: string) { + const req = context.switchToHttp().getRequest(); + const needsResolve = (id?: string): id is string => + typeof id === 'string' && identify(id) !== IdPrefix.BaseNode; + + const { nodeId } = req.params; + const { parentId, anchorId } = req.body ?? {}; + + const resourceIds = [nodeId, parentId, anchorId].filter(needsResolve); + if (!resourceIds.length) { + return; + } + + const nodes = await this.prismaService.baseNode.findMany({ + where: { baseId, resourceId: { in: [...new Set(resourceIds)] } }, + select: { id: true, resourceId: true }, + }); + const resolved = new Map(nodes.map((n) => [n.resourceId, n.id])); + + if (resolved.has(nodeId)) req.params.nodeId = resolved.get(nodeId)!; + if (resolved.has(parentId)) req.body.parentId = resolved.get(parentId)!; + if (resolved.has(anchorId)) req.body.anchorId = resolved.get(anchorId)!; + } + private async getPermissionContext() { const permissions = this.clsInner.get('permissions'); const permissionSet = new Set(permissions); diff --git a/apps/nestjs-backend/src/features/base-node/base-node.service.spec.ts b/apps/nestjs-backend/src/features/base-node/base-node.service.spec.ts index 9bbb40858b..3aaaa70932 100644 --- a/apps/nestjs-backend/src/features/base-node/base-node.service.spec.ts +++ b/apps/nestjs-backend/src/features/base-node/base-node.service.spec.ts @@ -240,4 +240,43 @@ describe('BaseNodeService', () => { }); }); }); + + describe('getCreateTableV2Decision', () => { + it('uses the base v2 marker when deciding table creation routing', async () => { + const canaryService = { + shouldUseV2ForBaseWithReason: vi + .fn() + .mockResolvedValue({ useV2: true, reason: 'new_base' }), + }; + const prismaService = { + txClient: vi.fn(() => ({ + base: { + findUnique: vi.fn().mockResolvedValue({ spaceId: 'spc1', v2Enabled: true }), + }, + })), + }; + const routingService = new BaseNodeService( + {} as never, + {} as never, + prismaService as never, + {} as never, + {} as never, + { get: vi.fn(), set: vi.fn() } as never, + {} as never, + canaryService as never, + {} as never, + {} as never, + {} as never, + {} as never + ); + + const decision = await routingService.getCreateTableV2Decision(baseId); + + expect(canaryService.shouldUseV2ForBaseWithReason).toHaveBeenCalledWith( + { spaceId: 'spc1', v2Enabled: true }, + 'createTable' + ); + expect(decision).toEqual({ useV2: true, reason: 'new_base' }); + }); + }); }); diff --git a/apps/nestjs-backend/src/features/base-node/base-node.service.ts b/apps/nestjs-backend/src/features/base-node/base-node.service.ts index 551c9c5ba0..4f22ad7a09 100644 --- a/apps/nestjs-backend/src/features/base-node/base-node.service.ts +++ b/apps/nestjs-backend/src/features/base-node/base-node.service.ts @@ -141,14 +141,10 @@ export class BaseNodeService { const base = await this.prismaService.txClient().base.findUnique({ where: { id: baseId, deletedTime: null }, - select: { spaceId: true }, + select: { spaceId: true, v2Enabled: true }, }); - if (!base?.spaceId) { - return { useV2: false, reason: 'disabled' }; - } - - return this.canaryService.shouldUseV2WithReason(base.spaceId, feature); + return this.canaryService.shouldUseV2ForBaseWithReason(base, feature); } async getDeleteTableV2Decision(baseId: string, nodeId: string): Promise { @@ -158,14 +154,10 @@ export class BaseNodeService { async getCreateTableV2Decision(baseId: string): Promise { const base = await this.prismaService.txClient().base.findUnique({ where: { id: baseId, deletedTime: null }, - select: { spaceId: true }, + select: { spaceId: true, v2Enabled: true }, }); - if (!base?.spaceId) { - return { useV2: false, reason: 'disabled' }; - } - - return this.canaryService.shouldUseV2WithReason(base.spaceId, 'createTable'); + return this.canaryService.shouldUseV2ForBaseWithReason(base, 'createTable'); } private generateDefaultUrl( diff --git a/apps/nestjs-backend/src/features/base/base-import.service.ts b/apps/nestjs-backend/src/features/base/base-import.service.ts index 2601cf993a..f6cc7207e8 100644 --- a/apps/nestjs-backend/src/features/base/base-import.service.ts +++ b/apps/nestjs-backend/src/features/base/base-import.service.ts @@ -87,6 +87,7 @@ export class BaseImportService { spaceId, order, icon, + v2Enabled: true, createdBy: userId, }, select: { diff --git a/apps/nestjs-backend/src/features/base/base.service.ts b/apps/nestjs-backend/src/features/base/base.service.ts index 22fef1b101..47ecc0b34c 100644 --- a/apps/nestjs-backend/src/features/base/base.service.ts +++ b/apps/nestjs-backend/src/features/base/base.service.ts @@ -227,6 +227,7 @@ export class BaseService { spaceId, order, icon, + v2Enabled: true, createdBy: userId, }, select: { diff --git a/apps/nestjs-backend/src/features/canary/canary.service.spec.ts b/apps/nestjs-backend/src/features/canary/canary.service.spec.ts new file mode 100644 index 0000000000..ae2cf3207d --- /dev/null +++ b/apps/nestjs-backend/src/features/canary/canary.service.spec.ts @@ -0,0 +1,86 @@ +import { SettingKey } from '@teable/openapi'; +import { CanaryService } from './canary.service'; + +describe('CanaryService', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + const createService = (params?: { + canaryHeader?: string; + config?: { enabled: boolean; spaceIds?: string[]; forceV2All?: boolean }; + }) => { + const settingService = { + getSetting: vi.fn().mockResolvedValue({ + [SettingKey.CANARY_CONFIG]: params?.config ?? null, + }), + }; + const cls = { + get: vi.fn((key: string) => (key === 'canaryHeader' ? params?.canaryHeader : undefined)), + }; + + return { + service: new CanaryService(settingService as never, cls as never), + settingService, + cls, + }; + }; + + it('forces v2 for a marked new base before disabled canary, config, or header decisions', async () => { + process.env.ENABLE_CANARY_FEATURE = 'false'; + process.env.FORCE_V2_ALL = 'false'; + const { service, settingService, cls } = createService({ + canaryHeader: 'false', + config: { enabled: false, spaceIds: [], forceV2All: false }, + }); + + const decision = await service.shouldUseV2ForBaseWithReason( + { spaceId: 'spc1', v2Enabled: true }, + 'createRecord' + ); + + expect(decision).toEqual({ useV2: true, reason: 'new_base' }); + expect(settingService.getSetting).not.toHaveBeenCalled(); + expect(cls.get).not.toHaveBeenCalled(); + }); + + it('reports new_base for a marked new base even when force v2 all is enabled', async () => { + process.env.ENABLE_CANARY_FEATURE = 'true'; + process.env.FORCE_V2_ALL = 'true'; + const { service, settingService, cls } = createService({ + canaryHeader: 'false', + config: { enabled: true, spaceIds: ['spc1'], forceV2All: true }, + }); + + const decision = await service.shouldUseV2ForBaseWithReason( + { spaceId: 'spc1', v2Enabled: true }, + 'createRecord' + ); + + expect(decision).toEqual({ useV2: true, reason: 'new_base' }); + expect(settingService.getSetting).not.toHaveBeenCalled(); + expect(cls.get).not.toHaveBeenCalled(); + }); + + it('falls back to rollout decisions for unmarked bases', async () => { + process.env.ENABLE_CANARY_FEATURE = 'true'; + process.env.FORCE_V2_ALL = 'false'; + const { service } = createService({ + config: { enabled: true, spaceIds: ['spc1'], forceV2All: false }, + }); + + const decision = await service.shouldUseV2ForBaseWithReason( + { spaceId: 'spc1', v2Enabled: false }, + 'createRecord' + ); + + expect(decision).toEqual({ useV2: true, reason: 'space_feature' }); + }); +}); diff --git a/apps/nestjs-backend/src/features/canary/canary.service.ts b/apps/nestjs-backend/src/features/canary/canary.service.ts index fdbf9dbd20..9ef17a6a1b 100644 --- a/apps/nestjs-backend/src/features/canary/canary.service.ts +++ b/apps/nestjs-backend/src/features/canary/canary.service.ts @@ -10,6 +10,11 @@ export interface IV2Decision { reason: V2Reason; } +export interface IBaseV2DecisionContext { + spaceId?: string | null; + v2Enabled?: boolean | null; +} + @Injectable() export class CanaryService { constructor( @@ -34,7 +39,7 @@ export class CanaryService { /** * Check if V2 is forced globally via environment variable (FORCE_V2_ALL=true) - * This has the highest priority over all other settings + * This is the fallback priority for bases that are not explicitly marked as new-base V2. */ isForceV2AllEnabled(): boolean { return process.env.FORCE_V2_ALL === 'true'; @@ -86,7 +91,7 @@ export class CanaryService { /** * Determine if V2 implementation should be used for a specific feature * Priority: - * 1. FORCE_V2_ALL env var (highest priority, bypasses all checks) + * 1. FORCE_V2_ALL env var (highest priority for space-only checks) * 2. If canary feature is disabled globally, return false * 3. forceV2All in config (database setting) * 4. x-canary header override @@ -127,12 +132,29 @@ export class CanaryService { return config.spaceIds?.includes(spaceId) ?? false; } + /** + * New bases are V2-first regardless of canary, request headers, or rollout config. + * Unsupported features still do not call this path because they have no @UseV2Feature marker. + */ + async shouldUseV2ForBaseWithReason( + base: IBaseV2DecisionContext | null | undefined, + feature: V2Feature + ): Promise { + if (base?.v2Enabled) { + return { useV2: true, reason: 'new_base' }; + } + + return base?.spaceId + ? this.shouldUseV2WithReason(base.spaceId, feature) + : this.shouldUseV2WithReason('', feature); + } + /** * Determine if V2 implementation should be used for a specific feature, * with detailed reason information. * * Priority: - * 1. FORCE_V2_ALL env var (highest priority, bypasses all checks) + * 1. FORCE_V2_ALL env var (highest priority for space-only checks) * 2. If canary feature is disabled globally, return false * 3. forceV2All in config (database setting) * 4. x-canary header override diff --git a/apps/nestjs-backend/src/features/canary/guards/v2-feature.guard.ts b/apps/nestjs-backend/src/features/canary/guards/v2-feature.guard.ts index 5669ba2b6e..f3fe88b325 100644 --- a/apps/nestjs-backend/src/features/canary/guards/v2-feature.guard.ts +++ b/apps/nestjs-backend/src/features/canary/guards/v2-feature.guard.ts @@ -5,7 +5,7 @@ import { PrismaService } from '@teable/db-main-prisma'; import type { V2Feature } from '@teable/openapi'; import { ClsService } from 'nestjs-cls'; import type { IClsStore } from '../../../types/cls'; -import { CanaryService } from '../canary.service'; +import { CanaryService, type IBaseV2DecisionContext } from '../canary.service'; import { USE_V2_FEATURE_KEY } from '../decorators/use-v2-feature.decorator'; /** @@ -65,26 +65,16 @@ export class V2FeatureGuard implements CanActivate { return true; } - // 2. Check FORCE_V2_ALL first (highest priority) - if (this.canaryService.isForceV2AllEnabled()) { - this.cls.set('useV2', true); - this.cls.set('v2Feature', feature); - this.cls.set('v2Reason', 'env_force_v2_all'); - return true; - } - - // 3. Get spaceId from request context - const spaceId = await this.getSpaceIdFromContext(context); - - if (!spaceId) { + if (this.isUnsupportedV2Payload(req.body, feature)) { this.cls.set('useV2', false); this.cls.set('v2Feature', feature); - this.cls.set('v2Reason', 'disabled'); + this.cls.set('v2Reason', 'unsupported_feature'); return true; } - // 4. Determine if V2 should be used with reason - const decision = await this.canaryService.shouldUseV2WithReason(spaceId, feature); + // 2. Resolve base context when possible. Marked new bases are V2-first and bypass rollout config. + const base = await this.getBaseV2DecisionContext(context); + const decision = await this.canaryService.shouldUseV2ForBaseWithReason(base, feature); this.cls.set('useV2', decision.useV2); this.cls.set('v2Feature', feature); this.cls.set('v2Reason', decision.reason); @@ -92,11 +82,30 @@ export class V2FeatureGuard implements CanActivate { return true; } + private isUnsupportedV2Payload(body: unknown, feature: V2Feature): boolean { + if (feature !== 'updateRecord') { + return false; + } + + const updateRecordBody = body as + | { + record?: { fields?: Record | null } | null; + order?: unknown; + } + | undefined; + const fields = updateRecordBody?.record?.fields ?? {}; + + // V2 does not yet support updateRecord calls that only reorder a record. + return Boolean(updateRecordBody?.order) && Object.keys(fields).length === 0; + } + /** - * Extract spaceId from request context. + * Extract base V2 decision context from request context. * Supports: spaceId (direct), baseId (lookup), tableId (lookup via base) */ - private async getSpaceIdFromContext(context: ExecutionContext): Promise { + private async getBaseV2DecisionContext( + context: ExecutionContext + ): Promise { const req = context.switchToHttp().getRequest(); const resourceId = req.params.spaceId || req.params.baseId || req.params.tableId; @@ -106,16 +115,16 @@ export class V2FeatureGuard implements CanActivate { // Direct spaceId if (resourceId.startsWith(IdPrefix.Space)) { - return resourceId; + return { spaceId: resourceId }; } // BaseId -> lookup spaceId if (resourceId.startsWith(IdPrefix.Base)) { const base = await this.prismaService.txClient().base.findUnique({ where: { id: resourceId, deletedTime: null }, - select: { spaceId: true }, + select: { spaceId: true, v2Enabled: true }, }); - return base?.spaceId; + return base ?? undefined; } // TableId -> lookup baseId -> lookup spaceId @@ -129,9 +138,9 @@ export class V2FeatureGuard implements CanActivate { const base = await this.prismaService.txClient().base.findUnique({ where: { id: table.baseId, deletedTime: null }, - select: { spaceId: true }, + select: { spaceId: true, v2Enabled: true }, }); - return base?.spaceId; + return base ?? undefined; } return undefined; diff --git a/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts b/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts index cf6f63ed64..99e51fe35e 100644 --- a/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts +++ b/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts @@ -7,10 +7,8 @@ import type { IConvertFieldRo, ILinkFieldOptions, FieldCore, - LinkFieldCore, } from '@teable/core'; import { - CellValueType, ColorUtils, DbFieldType, FIELD_VO_PROPERTIES, @@ -1488,10 +1486,7 @@ export class FieldConvertingService { select: { dbTableName: true, name: true }, }); - // index do not support date cell value type - if (newField.cellValueType !== CellValueType.DateTime) { - await this.tableIndexService.createSearchFieldSingleIndex(tableId, newField); - } + await this.tableIndexService.createSearchFieldSingleIndex(tableId, newField); if (!this.needTempleCloseFieldConstraint(newField, oldField)) { return; @@ -1594,6 +1589,28 @@ export class FieldConvertingService { ); } + // Primary fields must stay as regular (editable) fields. Converting a primary to a + // lookup / conditional-lookup produces a computed primary whose cell value is derived + // from a link, which in turn breaks base duplication (findFirstOrThrow for the foreign + // table's primary can't locate a valid static primary). See T3367. + // lookupOptions is included for symmetry with the createField guard — leaving stray + // lookupOptions on a primary is the same semantic corruption even without isLookup=true. + if ( + oldField.isPrimary && + (updateFieldRo.isLookup || updateFieldRo.isConditionalLookup || updateFieldRo.lookupOptions) + ) { + throw new CustomHttpException( + 'Primary field cannot be configured as a lookup field', + HttpErrorCode.VALIDATION_ERROR, + { + localization: { + i18nKey: 'httpErrors.field.primaryCannotBeLookup', + context: {}, + }, + } + ); + } + const newFieldVo = await this.fieldSupplementService.prepareUpdateField( tableId, updateFieldRo, diff --git a/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts b/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts index c75b7d158f..f4a3382325 100644 --- a/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts +++ b/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts @@ -29,6 +29,7 @@ import { LastModifiedTimeFieldCore, LongTextFieldCore, NumberFieldCore, + PRIMARY_SUPPORTED_TYPES, RatingFieldCore, Relationship, RelationshipRevert, @@ -1764,10 +1765,70 @@ export class FieldSupplementService { this.validateFormattingShowAs(fieldVo); this.validateAiConfig(fieldVo); + await this.validatePrimaryConfigurations(tableId, [fieldVo]); return fieldVo; } + // Primary fields must be a static, supported type with no lookup configuration, and a table + // can have at most one primary. Bulk paths (table/base/field duplicate, .tea import, AI tools) + // historically passed isPrimary=true on raw field VOs, allowing link/checkbox/attachment/rollup + // primaries to slip in and tables to end up with multiple primaries. See T3367 follow-up. + // Aligns with v2's CreateFieldCommand guard (community/packages/v2/.../CreateFieldCommand.ts:52). + private async validatePrimaryConfigurations(tableId: string, fieldVos: IFieldVo[]) { + const newPrimaries = fieldVos.filter((f) => f.isPrimary); + if (newPrimaries.length === 0) return; + + if (newPrimaries.length > 1) { + throw new CustomHttpException( + 'Cannot create more than one primary field in a single batch', + HttpErrorCode.VALIDATION_ERROR, + { + localization: { i18nKey: 'httpErrors.field.primaryFieldAlreadyExists', context: {} }, + } + ); + } + + for (const fieldVo of newPrimaries) { + if (!PRIMARY_SUPPORTED_TYPES.has(fieldVo.type)) { + throw new CustomHttpException( + `Field type ${fieldVo.type} is not supported as primary field`, + HttpErrorCode.VALIDATION_ERROR, + { + localization: { + i18nKey: 'httpErrors.field.unsupportedPrimaryFieldType', + context: { type: fieldVo.type }, + }, + } + ); + } + + if (fieldVo.isLookup || fieldVo.isConditionalLookup || fieldVo.lookupOptions) { + throw new CustomHttpException( + 'Primary field cannot be configured as a lookup field', + HttpErrorCode.VALIDATION_ERROR, + { + localization: { i18nKey: 'httpErrors.field.primaryCannotBeLookup', context: {} }, + } + ); + } + } + + const existing = await this.prismaService.txClient().field.findFirst({ + where: { tableId, isPrimary: true, deletedTime: null }, + select: { id: true }, + }); + if (existing) { + throw new CustomHttpException( + 'Table already has a primary field', + HttpErrorCode.VALIDATION_ERROR, + { + localization: { i18nKey: 'httpErrors.field.primaryFieldAlreadyExists', context: {} }, + } + ); + } + } + async prepareCreateFields(tableId: string, fieldRos: IFieldRo[], batchFieldVos?: IFieldVo[]) { // throw error when dbFieldName is duplicated const fieldRoDbFieldNames = fieldRos @@ -1807,7 +1868,7 @@ export class FieldSupplementService { const dbFieldNames = await this.fieldService.generateDbFieldNames(tableId, uniqFieldNames); - return fieldRos.map((fieldRo, index) => { + const fieldVos = fieldRos.map((fieldRo, index) => { const field = fields[index]; const fieldId = field.id || generateFieldId(); const fieldName = uniqFieldNames[index]; @@ -1823,6 +1884,8 @@ export class FieldSupplementService { this.validateAiConfig(fieldVo); return fieldVo; }); + await this.validatePrimaryConfigurations(tableId, fieldVos); + return fieldVos; } async prepareUpdateField( diff --git a/apps/nestjs-backend/src/features/integrity/integrity-v2.service.spec.ts b/apps/nestjs-backend/src/features/integrity/integrity-v2.service.spec.ts new file mode 100644 index 0000000000..6165ee5650 --- /dev/null +++ b/apps/nestjs-backend/src/features/integrity/integrity-v2.service.spec.ts @@ -0,0 +1,253 @@ +import * as Sentry from '@sentry/nestjs'; +import type * as V2AdapterTableRepositoryPostgres from '@teable/v2-adapter-table-repository-postgres'; +import type { ITracer, Table } from '@teable/v2-core'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { IntegrityV2Service } from './integrity-v2.service'; + +type IMetaValidationIssue = V2AdapterTableRepositoryPostgres.MetaValidationIssue; +type ISchemaRepairResult = V2AdapterTableRepositoryPostgres.SchemaRepairResult; + +const schemaIntegrityRepairFeatureTag = 'schema-integrity-repair'; +const repairRuleId = 'column:fldIntegrity0001'; +const metaFieldId = 'fldIntegrity0001'; +const metaReferenceRuleId = 'meta:reference'; +const repairFailureSpanName = 'teable.IntegrityV2Service.reportRepairFailure'; +const integrityFailureKindAttribute = 'teable.integrity.failure_kind'; +const integrityRuleIdAttribute = 'teable.integrity.rule_id'; +const integrityScopeAttribute = 'teable.integrity.scope'; +const integrityTargetIdAttribute = 'teable.integrity.target_id'; +const repairStreamCrashedMessage = 'repair stream crashed'; +const createThrowingRepairStream = (message: string): AsyncGenerator => + ({ + async next() { + throw new Error(message); + }, + async return() { + return { + done: true, + value: undefined, + }; + }, + async throw(error) { + throw error; + }, + [Symbol.asyncIterator]() { + return this; + }, + }) as AsyncGenerator; + +class FakeSpan { + name: string; + attributes?: Record; + errors: string[] = []; + ended = false; + + constructor(name: string, attributes?: Record) { + this.name = name; + this.attributes = attributes; + } + + setAttribute(key: string, value: string | number | boolean): void { + this.attributes = { + ...(this.attributes ?? {}), + [key]: value, + }; + } + + setAttributes(attributes: Record): void { + this.attributes = { + ...(this.attributes ?? {}), + ...attributes, + }; + } + + recordError(message: string): void { + this.errors.push(message); + } + + end(): void { + this.ended = true; + } +} + +class FakeTracer implements ITracer { + spans: FakeSpan[] = []; + + startSpan(name: string, attributes?: Record) { + const span = new FakeSpan(name, attributes); + this.spans.push(span); + return span; + } + + async withSpan(_span: FakeSpan, callback: () => Promise): Promise { + return await callback(); + } + + getActiveSpan(): FakeSpan | undefined { + return this.spans.at(-1); + } +} + +const sentryScope = { + setLevel: vi.fn(), + setTag: vi.fn(), + setContext: vi.fn(), +}; + +vi.mock('@sentry/nestjs', () => ({ + withScope: (callback: (scope: typeof sentryScope) => void) => callback(sentryScope), + captureException: vi.fn(), +})); + +const createTable = (fields: unknown[] = []): Table => + ({ + id: () => ({ toString: () => 'tblIntegrity000001' }), + name: () => ({ toString: () => 'Tasks' }), + baseId: () => ({ toString: () => 'baseIntegrity0001' }), + getFields: () => fields, + }) as unknown as Table; + +const createMetaIssue = (): IMetaValidationIssue => ({ + fieldId: metaFieldId, + fieldName: 'Status', + fieldType: 'lookup', + category: 'reference', + severity: 'error', + message: 'Link field not found: fldMissing', + details: { + relatedFieldId: 'fldMissing', + }, +}); + +const createMetaIssueStream = (issue: IMetaValidationIssue): AsyncGenerator => + (async function* () { + yield issue; + })(); + +const createRepairResult = (): ISchemaRepairResult => ({ + id: 'tblIntegrity000001:rule:error', + fieldId: metaFieldId, + fieldName: 'Status', + ruleId: repairRuleId, + ruleDescription: 'Physical column "Status" (text)', + status: 'error', + outcome: 'unchanged', + message: 'Schema repair failed', + required: true, + timestamp: Date.now(), + dependencies: [], + depth: 0, + details: { + statementCount: 1, + }, +}); + +const collect = async (stream: AsyncGenerator): Promise => { + const results: T[] = []; + for await (const item of stream) { + results.push(item); + } + return results; +}; + +describe('IntegrityV2Service repair telemetry', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('marks metadata reference check results as auto repairable', async () => { + const service = new IntegrityV2Service({} as never, {} as never); + const table = createTable(); + const issue = createMetaIssue(); + + const stream = service['decorateMetaCheckStream']( + table, + createMetaIssueStream(issue), + undefined + ); + + const results = await collect(stream); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + fieldId: issue.fieldId, + ruleId: metaReferenceRuleId, + status: 'error', + repair: { + available: true, + mode: 'auto', + }, + }); + }); + + it('captures result-level repair failures to sentry and trace spans', async () => { + const service = new IntegrityV2Service({} as never, {} as never); + const tracer = new FakeTracer(); + const table = createTable(); + const result = createRepairResult(); + + const stream = service['decorateRepairStream']( + table, + (async function* () { + yield result; + })(), + undefined, + { + tracer, + scope: 'table', + targetId: table.id().toString(), + } + ); + + const serialized = await collect(stream); + + expect(serialized).toHaveLength(1); + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(sentryScope.setTag).toHaveBeenCalledWith('feature', schemaIntegrityRepairFeatureTag); + expect(sentryScope.setTag).toHaveBeenCalledWith('integrity.failure_kind', 'result_error'); + expect(sentryScope.setContext).toHaveBeenCalledWith( + 'schema-integrity-repair', + expect.objectContaining({ + tableId: 'tblIntegrity000001', + ruleId: repairRuleId, + failureKind: 'result_error', + }) + ); + expect(tracer.spans[0]?.name).toBe(repairFailureSpanName); + expect(tracer.spans[0]?.attributes).toMatchObject({ + [integrityFailureKindAttribute]: 'result_error', + [integrityRuleIdAttribute]: repairRuleId, + [integrityScopeAttribute]: 'table', + }); + expect(tracer.spans[0]?.errors).toContain('Schema repair failed'); + }); + + it('captures thrown repair stream exceptions to sentry and trace spans', async () => { + const service = new IntegrityV2Service({} as never, {} as never); + const tracer = new FakeTracer(); + const table = createTable(); + + const stream = service['decorateRepairStream']( + table, + createThrowingRepairStream(repairStreamCrashedMessage), + undefined, + { + tracer, + scope: 'base', + targetId: table.baseId().toString(), + } + ); + + await expect(collect(stream)).rejects.toThrow(repairStreamCrashedMessage); + + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(sentryScope.setTag).toHaveBeenCalledWith('integrity.failure_kind', 'stream_exception'); + expect(tracer.spans[0]?.attributes).toMatchObject({ + [integrityFailureKindAttribute]: 'stream_exception', + [integrityScopeAttribute]: 'base', + [integrityTargetIdAttribute]: 'baseIntegrity0001', + }); + expect(tracer.spans[0]?.errors).toContain(repairStreamCrashedMessage); + }); +}); diff --git a/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts b/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts index 2f8fa2b09a..498cd18bda 100644 --- a/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts +++ b/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts @@ -1,4 +1,5 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; import type { IV2BaseSchemaIntegrityRepairRo, IV2SchemaIntegrityFilterStatus, @@ -12,20 +13,30 @@ import type { } from '@teable/openapi'; import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg'; import { + checkTableMetaWithTables, + createMetaRepairer, createSchemaChecker, createSchemaRepairer, + getMetaIssueDetails, + getMetaRepairHint, + getMetaRuleId, + isMetaRuleId, + metaRuleDescription, PostgresSchemaIntrospector, + type MetaValidationIssue, type SchemaCheckResult, type SchemaRepairResult, type SchemaRuleRepairHint, } from '@teable/v2-adapter-table-repository-postgres'; import { BaseId, + TeableSpanAttributes, TableByBaseIdSpec, TableByIdSpec, TableId, v2CoreTokens, type IBaseRepository, + type ITracer, type ITableRepository, type Table, } from '@teable/v2-core'; @@ -33,6 +44,17 @@ import { V2ContainerService } from '../v2/v2-container.service'; import { V2ExecutionContextFactory } from '../v2/v2-execution-context.factory'; type ISchemaIntegrityDb = Parameters[0]['db']; +type IRepairTelemetryScope = 'table' | 'base'; +type IRepairTelemetryKind = 'result_error' | 'stream_exception'; + +const schemaIntegrityRepairFeatureTag = 'schema-integrity-repair'; +const teableBaseIdAttribute = 'teable.base_id'; +const integrityScopeAttribute = 'teable.integrity.scope'; +const integrityTargetIdAttribute = 'teable.integrity.target_id'; +const integrityFailureKindAttribute = 'teable.integrity.failure_kind'; +const integrityRuleIdAttribute = 'teable.integrity.rule_id'; +const integrityOutcomeAttribute = 'teable.integrity.outcome'; +const integrityRequiredAttribute = 'teable.integrity.required'; @Injectable() export class IntegrityV2Service { @@ -45,27 +67,48 @@ export class IntegrityV2Service { tableId: string, statuses?: IV2SchemaIntegrityFilterStatus[] ): Promise> { - const { table, db, schema } = await this.resolveSchemaTarget(tableId); + const { table, tables, db, schema } = await this.resolveSchemaTarget(tableId, { + includeBaseTables: true, + }); const checker = createSchemaChecker({ db, introspector: new PostgresSchemaIntrospector(db), schema, }); - return this.decorateCheckStream(table, checker.checkTable(table), statuses); + return this.streamTableChecks(table, tables, checker, statuses); } async createRepairStream( tableId: string, repairRo: IV2SchemaIntegrityRepairRo ): Promise> { - const { table, db, schema } = await this.resolveSchemaTarget(tableId); + const { table, tables, db, schema, context } = await this.resolveSchemaTarget(tableId, { + includeBaseTables: true, + }); const repairer = createSchemaRepairer({ db, introspector: new PostgresSchemaIntrospector(db), schema, }); + const metaRepairer = createMetaRepairer({ db }); + + if (repairRo.fieldId && isMetaRuleId(repairRo.ruleId)) { + return this.decorateRepairStream( + table, + metaRepairer.repairRule(table, tables, repairRo.fieldId, repairRo.ruleId, { + dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, + }), + repairRo.statuses, + { + tracer: context.tracer, + scope: 'table', + targetId: tableId, + } + ); + } if (repairRo.fieldId && repairRo.ruleId) { return this.decorateRepairStream( @@ -75,28 +118,55 @@ export class IntegrityV2Service { manualRepairValues: repairRo.manualRepairValues, targetStatuses: repairRo.targetStatuses, }), - repairRo.statuses + repairRo.statuses, + { + tracer: context.tracer, + scope: 'table', + targetId: tableId, + } ); } if (repairRo.fieldId) { return this.decorateRepairStream( table, - repairer.repairField(table, repairRo.fieldId, { - dryRun: repairRo.dryRun, - targetStatuses: repairRo.targetStatuses, - }), - repairRo.statuses + this.combineRepairStreams( + repairer.repairField(table, repairRo.fieldId, { + dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, + }), + metaRepairer.repairField(table, tables, repairRo.fieldId, { + dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, + }) + ), + repairRo.statuses, + { + tracer: context.tracer, + scope: 'table', + targetId: tableId, + } ); } return this.decorateRepairStream( table, - repairer.repairTable(table, { - dryRun: repairRo.dryRun, - targetStatuses: repairRo.targetStatuses, - }), - repairRo.statuses + this.combineRepairStreams( + repairer.repairTable(table, { + dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, + }), + metaRepairer.repairTable(table, tables, { + dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, + }) + ), + repairRo.statuses, + { + tracer: context.tracer, + scope: 'table', + targetId: tableId, + } ); } @@ -118,17 +188,27 @@ export class IntegrityV2Service { baseId: string, repairRo: IV2BaseSchemaIntegrityRepairRo ): Promise> { - const { tables, db, schema } = await this.resolveBaseTarget(baseId); + const { tables, db, schema, context } = await this.resolveBaseTarget(baseId); const repairer = createSchemaRepairer({ db, introspector: new PostgresSchemaIntrospector(db), schema, }); + const metaRepairer = createMetaRepairer({ db }); - return this.streamBaseRepairs(tables, repairer, repairRo); + return this.streamBaseRepairs(tables, repairer, metaRepairer, repairRo, { + tracer: context.tracer, + scope: 'base', + targetId: baseId, + }); } - private async resolveSchemaTarget(tableId: string) { + private async resolveSchemaTarget( + tableId: string, + options?: { + includeBaseTables?: boolean; + } + ) { const parsedTableId = TableId.create(tableId); if (parsedTableId.isErr()) { throw new HttpException(parsedTableId.error.message, HttpStatus.BAD_REQUEST); @@ -148,11 +228,27 @@ export class IntegrityV2Service { const db = container.resolve(v2PostgresDbTokens.db); const table = tableResult.value; + let tables: ReadonlyArray = [table]; + + if (options?.includeBaseTables) { + const tablesResult = await tableRepository.find( + context, + TableByBaseIdSpec.create(table.baseId()) + ); + + if (tablesResult.isErr()) { + throw new HttpException(tablesResult.error.message, HttpStatus.INTERNAL_SERVER_ERROR); + } + + tables = tablesResult.value; + } return { table, + tables, db, schema: table.baseId().toString(), + context, }; } @@ -194,32 +290,60 @@ export class IntegrityV2Service { tables, db, schema: parsedBaseId.value.toString(), + context, }; } + private async *streamTableChecks( + table: Table, + allTables: ReadonlyArray
, + checker: ReturnType, + statuses?: IV2SchemaIntegrityFilterStatus[] + ): AsyncGenerator { + yield* this.decorateCheckStream(table, checker.checkTable(table), statuses); + yield* this.decorateMetaCheckStream( + table, + checkTableMetaWithTables(table, table.baseId(), allTables), + statuses + ); + } + private async *streamBaseChecks( tables: ReadonlyArray
, checker: ReturnType, statuses?: IV2SchemaIntegrityFilterStatus[] ): AsyncGenerator { for (const table of tables) { - yield* this.decorateCheckStream(table, checker.checkTable(table), statuses); + yield* this.streamTableChecks(table, tables, checker, statuses); } } private async *streamBaseRepairs( tables: ReadonlyArray
, repairer: ReturnType, - repairRo: IV2BaseSchemaIntegrityRepairRo + metaRepairer: ReturnType, + repairRo: IV2BaseSchemaIntegrityRepairRo, + telemetry: { + tracer?: ITracer; + scope: IRepairTelemetryScope; + targetId: string; + } ): AsyncGenerator { for (const table of tables) { yield* this.decorateRepairStream( table, - repairer.repairTable(table, { - dryRun: repairRo.dryRun, - targetStatuses: repairRo.targetStatuses, - }), - repairRo.statuses + this.combineRepairStreams( + repairer.repairTable(table, { + dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, + }), + metaRepairer.repairTable(table, tables, { + dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, + }) + ), + repairRo.statuses, + telemetry ); } } @@ -243,16 +367,176 @@ export class IntegrityV2Service { private async *decorateRepairStream( table: Table, stream: AsyncGenerator, - statuses?: IV2SchemaIntegrityFilterStatus[] + statuses?: IV2SchemaIntegrityFilterStatus[], + telemetry?: { + tracer?: ITracer; + scope: IRepairTelemetryScope; + targetId: string; + } ): AsyncGenerator { const statusFilter = this.createStatusFilterSet(statuses); - for await (const result of stream) { - const serialized = this.serializeRepairResult(table, result); - if (!this.shouldIncludeResult(serialized.status, statusFilter)) { + try { + for await (const result of stream) { + const serialized = this.serializeRepairResult(table, result); + if (serialized.status === 'error' && telemetry) { + await this.captureRepairFailure( + table, + result, + new Error(result.message), + telemetry, + 'result_error' + ); + } + + if (!this.shouldIncludeResult(serialized.status, statusFilter)) { + continue; + } + + yield serialized; + } + } catch (error) { + if (telemetry) { + await this.captureRepairFailure(table, undefined, error, telemetry, 'stream_exception'); + } + throw error; + } + } + + private async *combineRepairStreams( + ...streams: ReadonlyArray> + ): AsyncGenerator { + for (const stream of streams) { + yield* stream; + } + } + + private async captureRepairFailure( + table: Table, + result: SchemaRepairResult | undefined, + error: unknown, + telemetry: { + tracer?: ITracer; + scope: IRepairTelemetryScope; + targetId: string; + }, + kind: IRepairTelemetryKind + ): Promise { + const err = error instanceof Error ? error : new Error(String(error)); + const tableId = table.id().toString(); + const baseId = table.baseId().toString(); + const spanAttributes: Record = { + [TeableSpanAttributes.VERSION]: 'v2', + [TeableSpanAttributes.COMPONENT]: 'service', + [TeableSpanAttributes.OPERATION]: 'integrity.repair.failure', + [TeableSpanAttributes.TABLE_ID]: tableId, + [teableBaseIdAttribute]: baseId, + [integrityScopeAttribute]: telemetry.scope, + [integrityTargetIdAttribute]: telemetry.targetId, + [integrityFailureKindAttribute]: kind, + }; + + if (result?.fieldId && result.fieldId !== '__system__') { + spanAttributes[TeableSpanAttributes.FIELD_ID] = result.fieldId; + } + if (result?.ruleId) { + spanAttributes[integrityRuleIdAttribute] = result.ruleId; + } + if (result?.outcome) { + spanAttributes[integrityOutcomeAttribute] = result.outcome; + } + if (result?.required != null) { + spanAttributes[integrityRequiredAttribute] = result.required; + } + + const reportToSentry = () => { + Sentry.withScope((scope) => { + scope.setLevel?.('error'); + scope.setTag('feature', schemaIntegrityRepairFeatureTag); + scope.setTag('integrity.scope', telemetry.scope); + scope.setTag('integrity.target_id', telemetry.targetId); + scope.setTag('integrity.failure_kind', kind); + scope.setTag('base.id', baseId); + scope.setTag('table.id', tableId); + + if (result?.ruleId) { + scope.setTag('integrity.rule_id', result.ruleId); + } + if (result?.fieldId && result.fieldId !== '__system__') { + scope.setTag('field.id', result.fieldId); + } + + scope.setContext('schema-integrity-repair', { + baseId, + tableId, + tableName: table.name().toString(), + scope: telemetry.scope, + targetId: telemetry.targetId, + failureKind: kind, + resultId: result?.id, + fieldId: result?.fieldId, + fieldName: result?.fieldName, + ruleId: result?.ruleId, + ruleDescription: result?.ruleDescription, + outcome: result?.outcome, + required: result?.required, + details: result?.details, + }); + + Sentry.captureException(err); + }); + }; + + const tracer = telemetry.tracer; + if (!tracer) { + reportToSentry(); + return; + } + + const span = tracer.startSpan('teable.IntegrityV2Service.reportRepairFailure', spanAttributes); + try { + span.recordError(err.message); + await tracer.withSpan(span, async () => { + reportToSentry(); + }); + } finally { + span.end(); + } + } + + private async *decorateMetaCheckStream( + table: Table, + stream: AsyncGenerator, + statuses?: IV2SchemaIntegrityFilterStatus[] + ): AsyncGenerator { + const statusFilter = this.createStatusFilterSet(statuses); + + for await (const issue of stream) { + const status = this.toMetaCheckStatus(issue.severity); + if (!status || !this.shouldIncludeResult(status, statusFilter)) { continue; } - yield serialized; + yield { + id: this.createScopedResultId(table, `${issue.fieldId}:${getMetaRuleId(issue)}`), + baseId: table.baseId().toString(), + tableId: table.id().toString(), + tableName: table.name().toString(), + fieldId: issue.fieldId, + fieldName: issue.fieldName, + ruleId: getMetaRuleId(issue), + ruleDescription: metaRuleDescription, + status, + message: issue.message, + details: this.toMutableDetails(getMetaIssueDetails(issue)), + repair: + status === 'error' || status === 'warn' + ? this.toMutableRepairHint(getMetaRepairHint(issue)) + : undefined, + required: true, + timestamp: Date.now(), + dependencies: [], + depth: 0, + }; } } @@ -262,6 +546,7 @@ export class IntegrityV2Service { ): IV2SchemaIntegrityCheckResult { return { id: this.createScopedResultId(table, result.id), + baseId: table.baseId().toString(), tableId: table.id().toString(), tableName: table.name().toString(), fieldId: result.fieldId, @@ -270,14 +555,7 @@ export class IntegrityV2Service { ruleDescription: result.ruleDescription, status: result.status, message: result.message, - details: result.details - ? { - missing: this.toMutableArray(result.details.missing), - missingItems: this.toMutableDetailItems(result.details.missingItems), - extra: this.toMutableArray(result.details.extra), - extraItems: this.toMutableDetailItems(result.details.extraItems), - } - : undefined, + details: this.toMutableDetails(result.details), repair: result.repair ? this.toMutableRepairHint(result.repair) : undefined, required: result.required, timestamp: result.timestamp, @@ -292,6 +570,7 @@ export class IntegrityV2Service { ): IV2SchemaIntegrityRepairResult { return { id: this.createScopedResultId(table, result.id), + baseId: table.baseId().toString(), tableId: table.id().toString(), tableName: table.name().toString(), fieldId: result.fieldId, @@ -301,15 +580,7 @@ export class IntegrityV2Service { status: result.status, outcome: result.outcome, message: result.message, - details: result.details - ? { - missing: this.toMutableArray(result.details.missing), - missingItems: this.toMutableDetailItems(result.details.missingItems), - extra: this.toMutableArray(result.details.extra), - extraItems: this.toMutableDetailItems(result.details.extraItems), - statementCount: result.details.statementCount, - } - : undefined, + details: this.toMutableDetails(result.details), repair: result.repair ? this.toMutableRepairHint(result.repair) : undefined, required: result.required, timestamp: result.timestamp, @@ -322,6 +593,22 @@ export class IntegrityV2Service { return `${table.id().toString()}:${id}`; } + private toMutableDetails(details?: SchemaRepairResult['details']) { + return details + ? { + missing: this.toMutableArray(details.missing), + missingItems: this.toMutableDetailItems(details.missingItems), + extra: this.toMutableArray(details.extra), + extraItems: this.toMutableDetailItems(details.extraItems), + statementCount: details.statementCount, + statements: details.statements?.map((statement) => ({ + sql: statement.sql, + parameters: [...statement.parameters], + })), + } + : undefined; + } + private toMutableArray(values?: ReadonlyArray): string[] | undefined { return values ? [...values] : undefined; } @@ -461,4 +748,12 @@ export class IntegrityV2Service { return statusFilter.has(status as IV2SchemaIntegrityFilterStatus); } + + private toMetaCheckStatus( + severity: MetaValidationIssue['severity'] + ): IV2SchemaIntegrityCheckResult['status'] | undefined { + if (severity === 'error') return 'error'; + if (severity === 'warning') return 'warn'; + return undefined; + } } diff --git a/apps/nestjs-backend/src/features/integrity/integrity.module.ts b/apps/nestjs-backend/src/features/integrity/integrity.module.ts index aa045bd587..5eff36fec6 100644 --- a/apps/nestjs-backend/src/features/integrity/integrity.module.ts +++ b/apps/nestjs-backend/src/features/integrity/integrity.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { CanaryModule } from '../canary/canary.module'; +import { FieldOpenApiModule } from '../field/open-api/field-open-api.module'; import { FieldModule } from '../field/field.module'; import { TableDomainQueryModule } from '../table-domain'; import { V2Module } from '../v2/v2.module'; @@ -12,7 +13,7 @@ import { LinkIntegrityService } from './link-integrity.service'; import { UniqueIndexService } from './unique-index.service'; @Module({ - imports: [FieldModule, TableDomainQueryModule, V2Module, CanaryModule], + imports: [FieldModule, FieldOpenApiModule, TableDomainQueryModule, V2Module, CanaryModule], controllers: [IntegrityController, IntegrityV2Controller], providers: [ ForeignKeyIntegrityService, diff --git a/apps/nestjs-backend/src/features/integrity/link-integrity.service.ts b/apps/nestjs-backend/src/features/integrity/link-integrity.service.ts index f86ae63fbd..967849cd50 100644 --- a/apps/nestjs-backend/src/features/integrity/link-integrity.service.ts +++ b/apps/nestjs-backend/src/features/integrity/link-integrity.service.ts @@ -4,6 +4,7 @@ import { FieldType, CellValueType, DbFieldType, + PRIMARY_SUPPORTED_TYPES, Relationship, DriverClient, getValidFilterOperators, @@ -27,6 +28,7 @@ import { LinkFieldQueryService } from '../field/field-calculate/link-field-query import { FieldService } from '../field/field.service'; import { createFieldInstanceByRaw } from '../field/model/factory'; import type { LinkFieldDto } from '../field/model/field-dto/link-field.dto'; +import { FieldOpenApiService } from '../field/open-api/field-open-api.service'; import { TableDomainQueryService } from '../table-domain'; import { ForeignKeyIntegrityService } from './foreign-key.service'; import { LinkFieldIntegrityService } from './link-field.service'; @@ -44,6 +46,7 @@ export class LinkIntegrityService { private readonly tableDomainQueryService: TableDomainQueryService, private readonly linkFieldQueryService: LinkFieldQueryService, private readonly fieldService: FieldService, + private readonly fieldOpenApiService: FieldOpenApiService, @InjectDbProvider() private readonly dbProvider: IDbProvider, @InjectModel('CUSTOM_KNEX') private readonly knex: Knex ) {} @@ -161,12 +164,102 @@ export class LinkIntegrityService { }); } + const invalidPrimaryIssues = await this.checkInvalidPrimary(baseId); + if (invalidPrimaryIssues.length > 0) { + linkFieldIssues.push({ + baseId: mainBase.id, + baseName: mainBase.name, + issues: invalidPrimaryIssues, + }); + } + + const missingPrimaryIssues = await this.checkMissingPrimary(baseId); + if (missingPrimaryIssues.length > 0) { + linkFieldIssues.push({ + baseId: mainBase.id, + baseName: mainBase.name, + issues: missingPrimaryIssues, + }); + } + return { hasIssues: linkFieldIssues.length > 0, linkFieldIssues, }; } + // Detect primary fields that break base duplication / symmetric link generation: + // 1. Lookup-ish primaries — isLookup, isConditionalLookup, or stray lookupOptions. + // Origin: convertField path before T3367 (e.g. AI flipped Employee→lookup). + // 2. Unsupported-type primaries — link/checkbox/attachment/rollup as primary. + // Origin: bulk createFieldsByRo (duplicate/import/AI), now blocked at the source. + // Both states make `findFirstOrThrow({tableId, isPrimary: true})` return a field that can't + // serve as a static lookupFieldId for symmetric links. + private async checkInvalidPrimary(baseId: string): Promise { + const fields = await this.prismaService.field.findMany({ + where: { + deletedTime: null, + isPrimary: true, + table: { baseId, deletedTime: null }, + OR: [ + { isLookup: true }, + { isConditionalLookup: true }, + { lookupOptions: { not: null } }, + { type: { notIn: Array.from(PRIMARY_SUPPORTED_TYPES) } }, + ], + }, + select: { + id: true, + name: true, + type: true, + isLookup: true, + isConditionalLookup: true, + lookupOptions: true, + tableId: true, + table: { select: { name: true } }, + }, + }); + + return fields.map((f) => { + const isLookupish = f.isLookup || f.isConditionalLookup || f.lookupOptions !== null; + const type = isLookupish + ? IntegrityIssueType.InvalidPrimaryLookup + : IntegrityIssueType.InvalidPrimaryType; + const reason = isLookupish + ? 'is incorrectly configured as a lookup field' + : `has unsupported type "${f.type}"`; + return { + fieldId: f.id, + tableId: f.tableId, + type, + message: `Primary field "${f.name}" in table "${f.table.name}" ${reason}, which breaks base duplication. Fixing will demote it and promote an existing eligible field as primary; if no candidate qualifies, a new formula field mirroring the current value is added and the bad primary is renamed with a "(before-fix)" suffix.`, + }; + }); + } + + // Detect tables that have no primary field at all. `field-supplement.generateSymmetricField` + // does `findFirstOrThrow({tableId, isPrimary: true})` when creating link fields during base + // duplication; a missing primary makes that throw. + private async checkMissingPrimary(baseId: string): Promise { + const tables = await this.prismaService.tableMeta.findMany({ + where: { + baseId, + deletedTime: null, + fields: { none: { isPrimary: true, deletedTime: null } }, + }, + select: { id: true, name: true }, + }); + + return tables.map((t) => ({ + // fieldId is required by the schema; use tableId as a stable placeholder so the fix + // dispatcher can locate the table without a real field reference. + fieldId: t.id, + tableId: t.id, + type: IntegrityIssueType.MissingPrimary, + message: `Table "${t.name}" has no primary field, which breaks base duplication. Fixing will promote the first existing eligible field as primary, or add a new "Name" text field if none qualifies.`, + })); + } + private async checkReferenceField(baseId: string): Promise { const tables = await this.prismaService.tableMeta.findMany({ where: { baseId, deletedTime: null }, @@ -859,6 +952,18 @@ export class LinkIntegrityService { result && fixResults.push(result); break; } + case IntegrityIssueType.InvalidPrimaryLookup: + case IntegrityIssueType.InvalidPrimaryType: { + const result = await this.fixInvalidPrimary(issue.fieldId, issue.type); + result && fixResults.push(result); + break; + } + case IntegrityIssueType.MissingPrimary: { + // For missing-primary issues fieldId carries the tableId (see checkMissingPrimary). + const result = await this.fixMissingPrimary(issue.fieldId); + result && fixResults.push(result); + break; + } default: break; } @@ -886,6 +991,189 @@ export class LinkIntegrityService { }; } + async fixInvalidPrimary( + fieldId: string, + issueType: IntegrityIssueType + ): Promise { + const oldField = await this.prismaService.field.findFirst({ + where: { + id: fieldId, + deletedTime: null, + isPrimary: true, + }, + select: { id: true, name: true, tableId: true }, + }); + if (!oldField) return; + + // Strategy: atomic via outer $tx — inner $tx calls from updateField / createField reuse + // the same transaction, so any failure rolls back all partial mutations. + // 1. Demote the bad primary (direct DB — no service for primary toggle). + // 2. If the table still has a separate valid primary → keep it as-is (defensive). + // 3. Else if any field qualifies as primary → promote it directly. + // 4. Else fall back: rename the bad primary to "(before-fix)" so the original name + // is free, then create + promote a formula field mirroring the old value. + // The old bad primary is always preserved so existing references (link preview + // `options.lookupFieldId`, downstream lookups/rollups/formulas) keep working. + + const primaryFieldFilter = { + deletedTime: null, + isLookup: null, + isConditionalLookup: null, + lookupOptions: null, + type: { in: Array.from(PRIMARY_SUPPORTED_TYPES) }, + }; + + const result = await this.prismaService.$tx(async (prisma) => { + // Demote the bad primary first. Rename is deferred — only the formula fallback path + // needs to free up the original name for the new field. + await prisma.field.update({ + where: { id: oldField.id }, + data: { isPrimary: null }, + }); + + // Defensive: if a separate valid primary already exists in the table, leave it alone. + // Avoids leaving the table with multiple primaries (which the integrity check doesn't + // detect today). Production has zero such tables and validatePrimaryConfigurations + // blocks new ones, but this guards races / direct SQL writes / future regressions. + const existingValidPrimary = await prisma.field.findFirst({ + where: { tableId: oldField.tableId, isPrimary: true, ...primaryFieldFilter }, + select: { id: true, name: true }, + }); + + if (existingValidPrimary) { + return { kind: 'kept' as const, field: existingValidPrimary }; + } + + // Prefer promoting an existing eligible candidate over creating a new formula field. + // Mirrors fixMissingPrimary's behavior — fewer artifact fields, simpler table shape. + // The promoted field's displayed value will replace the bad primary's value; the bad + // primary itself stays untouched (no rename needed since no name collision). + const candidate = await prisma.field.findFirst({ + where: { tableId: oldField.tableId, ...primaryFieldFilter }, + orderBy: { order: 'asc' }, + select: { id: true, name: true }, + }); + + if (candidate) { + await prisma.field.update({ + where: { id: candidate.id }, + data: { isPrimary: true }, + }); + return { kind: 'promoted' as const, field: candidate }; + } + + // Fallback: no eligible candidate exists. Rename the bad primary so the new formula + // field can take its name, then create the formula mirroring the original value. + const legacyName = `${oldField.name} (before-fix)`; + await this.fieldOpenApiService.updateField(oldField.tableId, oldField.id, { + name: legacyName, + }); + const newField = await this.fieldOpenApiService.createField(oldField.tableId, { + type: FieldType.Formula, + name: oldField.name, + options: { + expression: `{${oldField.id}}`, + }, + }); + + await prisma.field.update({ + where: { id: newField.id }, + data: { isPrimary: true }, + }); + + return { + kind: 'created' as const, + field: { id: newField.id, name: oldField.name }, + legacyName, + }; + }); + + const baseMsg = `Demoted invalid primary "${oldField.name}" (id ${oldField.id}).`; + if (result.kind === 'kept') { + return { + type: issueType, + fieldId, + message: `${baseMsg} Existing valid primary "${result.field.name}" (${result.field.id}) preserved.`, + }; + } + if (result.kind === 'promoted') { + return { + type: issueType, + fieldId, + message: `${baseMsg} Promoted existing field "${result.field.name}" (${result.field.id}) to primary.`, + }; + } + return { + type: issueType, + fieldId, + message: `Demoted invalid primary "${oldField.name}" (renamed to "${result.legacyName}", id ${oldField.id}). Added new formula field "${result.field.name}" (${result.field.id}) as primary, mirroring the original value.`, + }; + } + + async fixMissingPrimary(tableId: string): Promise { + const table = await this.prismaService.tableMeta.findFirst({ + where: { id: tableId, deletedTime: null }, + select: { id: true, name: true }, + }); + if (!table) return; + + // Re-check inside the transaction to avoid racing with a concurrent promotion. + return this.prismaService.$tx(async () => { + const prisma = this.prismaService.txClient(); + const existing = await prisma.field.findFirst({ + where: { tableId, isPrimary: true, deletedTime: null }, + select: { id: true }, + }); + if (existing) return undefined; + + // Prefer promoting an existing valid candidate. Avoids leaving a stray "Name 2" + // alongside the user's existing fields and matches the natural intuition that + // the first usable column should be primary. + const candidate = await prisma.field.findFirst({ + where: { + tableId, + deletedTime: null, + isLookup: null, + isConditionalLookup: null, + lookupOptions: null, + type: { in: Array.from(PRIMARY_SUPPORTED_TYPES) }, + }, + orderBy: { order: 'asc' }, + select: { id: true, name: true }, + }); + + if (candidate) { + await prisma.field.update({ + where: { id: candidate.id }, + data: { isPrimary: true }, + }); + return { + type: IntegrityIssueType.MissingPrimary, + fieldId: candidate.id, + tableId, + message: `Promoted existing field "${candidate.name}" (${candidate.id}) to primary in table "${table.name}".`, + }; + } + + // Fallback: no usable candidate (every field is link / checkbox / attachment / + // rollup / lookup-ish). Create a new "Name" text field as primary. + const newField = await this.fieldOpenApiService.createField(tableId, { + type: FieldType.SingleLineText, + name: 'Name', + }); + await prisma.field.update({ + where: { id: newField.id }, + data: { isPrimary: true }, + }); + return { + type: IntegrityIssueType.MissingPrimary, + fieldId: newField.id, + tableId, + message: `Added "Name" text field (${newField.id}) as primary in table "${table.name}".`, + }; + }); + } + async fixOneWayLinkField(fieldId: string): Promise { const field = await this.prismaService.field.findFirstOrThrow({ where: { id: fieldId, deletedTime: null }, diff --git a/apps/nestjs-backend/src/features/record/query-builder/sql-conversion.visitor.ts b/apps/nestjs-backend/src/features/record/query-builder/sql-conversion.visitor.ts index 5d40cc615d..7d38463274 100644 --- a/apps/nestjs-backend/src/features/record/query-builder/sql-conversion.visitor.ts +++ b/apps/nestjs-backend/src/features/record/query-builder/sql-conversion.visitor.ts @@ -1621,9 +1621,26 @@ abstract class BaseSqlConversionVisitor< return `((${valueSql}))::boolean`; } + private unwrapExpression(ctx: ExprContext): ExprContext { + if (ctx instanceof BracketsContext) { + return this.unwrapExpression(ctx.expr()); + } + + if ( + ctx instanceof LeftWhitespaceOrCommentsContext || + ctx instanceof RightWhitespaceOrCommentsContext + ) { + return this.unwrapExpression(ctx.expr()); + } + + return ctx; + } + private isBlankLikeExpression(ctx: ExprContext): boolean { - if (ctx instanceof StringLiteralContext) { - const raw = ctx.text; + const normalizedCtx = this.unwrapExpression(ctx); + + if (normalizedCtx instanceof StringLiteralContext) { + const raw = normalizedCtx.text; if (raw.startsWith("'") && raw.endsWith("'")) { const unescaped = unescapeString(raw.slice(1, -1)); return unescaped === ''; @@ -1631,8 +1648,8 @@ abstract class BaseSqlConversionVisitor< return false; } - if (ctx instanceof FunctionCallContext) { - const rawName = ctx.func_name().text.toUpperCase(); + if (normalizedCtx instanceof FunctionCallContext) { + const rawName = normalizedCtx.func_name().text.toUpperCase(); const fnName = normalizeFunctionNameAlias(rawName) as FunctionName; return fnName === FunctionName.Blank; } diff --git a/apps/nestjs-backend/src/features/record/record.service.ts b/apps/nestjs-backend/src/features/record/record.service.ts index deb96b682f..15d623b81f 100644 --- a/apps/nestjs-backend/src/features/record/record.service.ts +++ b/apps/nestjs-backend/src/features/record/record.service.ts @@ -2140,6 +2140,9 @@ export class RecordService { return false; } if (isSearchAllFields) { + if (field.cellValueType === CellValueType.DateTime) { + return false; + } if (field.cellValueType === CellValueType.Number && isNaN(Number(search[0]))) { return false; } diff --git a/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.service.ts b/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.service.ts index 3a74d1f632..6c0f57752f 100644 --- a/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.service.ts +++ b/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable sonarjs/no-duplicate-string */ import { Session } from 'node:inspector'; import { Readable } from 'node:stream'; import { @@ -41,11 +42,11 @@ export class AdminOpenApiService { } async repairTableAttachmentThumbnail() { - // once handle 1000 attachments const take = 1000; let total = 0; - for (let skip = 0; ; skip += take) { - const sqlNative = this.knex('attachments_table') + let lastToken: string | null = null; + for (;;) { + const query = this.knex('attachments_table') .select( 'attachments.token', 'attachments.height', @@ -53,13 +54,29 @@ export class AdminOpenApiService { 'attachments.path' ) .leftJoin('attachments', 'attachments_table.token', 'attachments.token') - .whereNotNull('attachments.height') + .where((qb) => + qb + .where((image) => + image + .where('attachments.mimetype', 'like', 'image/%') + .whereNotNull('attachments.height') + ) + .orWhereIn('attachments.mimetype', ['application/pdf', 'application/x-pdf']) + ) .whereNull('attachments.deleted_time') .whereNull('attachments.thumbnail_path') - .limit(take) - .offset(skip) - .toSQL() - .toNative(); + .groupBy( + 'attachments.token', + 'attachments.height', + 'attachments.mimetype', + 'attachments.path' + ) + .orderBy('attachments.token') + .limit(take); + if (lastToken) { + query.where('attachments.token', '>', lastToken); + } + const sqlNative = query.toSQL().toNative(); const attachments = await this.prismaService.$queryRawUnsafe< { token: string; height?: number; mimetype: string; path: string }[] >(sqlNative.sql, ...sqlNative.bindings); @@ -67,6 +84,7 @@ export class AdminOpenApiService { if (attachments.length === 0) { break; } + lastToken = attachments[attachments.length - 1].token; total += attachments.length; await this.attachmentsCropQueueProcessor.queue.addBulk( attachments.map((attachment) => ({ diff --git a/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts b/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts index c0b9a799fc..9233684794 100644 --- a/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts +++ b/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts @@ -199,13 +199,15 @@ export class TableOpenApiService { const tableVo = await this.createTableMeta(baseId, tableRo); const tableId = tableVo.id; - const preparedFields = await this.prepareFields(tableId, tableRo.fields); - - // set the first field to be the primary field if not set - if (!preparedFields.find((field) => field.isPrimary)) { - preparedFields[0].isPrimary = true; + // Mark the first field as primary BEFORE prepareFields so the validation in + // prepareCreateFields catches bad-type / lookup-ish primaries from internal callers + // (template/import/AI) that don't go through the prepareCreateTableRo pipe. + if (tableRo.fields.length && !tableRo.fields.find((field) => (field as IFieldVo).isPrimary)) { + (tableRo.fields[0] as IFieldVo).isPrimary = true; } + const preparedFields = await this.prepareFields(tableId, tableRo.fields); + // create teable should not set computed field isPending, because noting need to calculate when create preparedFields.forEach((field) => delete field.isPending); await this.createFields(tableId, preparedFields); diff --git a/apps/nestjs-backend/src/features/table/table-index.service.ts b/apps/nestjs-backend/src/features/table/table-index.service.ts index 2c4a79e1b2..fe7f4bcdd7 100644 --- a/apps/nestjs-backend/src/features/table/table-index.service.ts +++ b/apps/nestjs-backend/src/features/table/table-index.service.ts @@ -1,6 +1,6 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; -import { CellValueType, FieldType, HttpErrorCode } from '@teable/core'; +import { Injectable, Logger } from '@nestjs/common'; +import { FieldType, HttpErrorCode } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import { TableIndex } from '@teable/openapi'; import type { IGetAbnormalVo, ITableIndexType, IToggleIndexRo } from '@teable/openapi'; @@ -15,8 +15,6 @@ import type { IClsStore } from '../../types/cls'; import type { IFieldInstance } from '../field/model/factory'; import { createFieldInstanceByRaw } from '../field/model/factory'; -const unSupportTableIndex = 'Unsupport table index type'; - @Injectable() export class TableIndexService { private logger = new Logger(TableIndexService.name); @@ -175,10 +173,7 @@ export class TableIndexService { } async createSearchFieldSingleIndex(tableId: string, fieldInstance: IFieldInstance) { - if ( - fieldInstance.cellValueType === CellValueType.DateTime || - fieldInstance.type === FieldType.Button - ) { + if (fieldInstance.type === FieldType.Button) { return; } const tableRaw = await this.prismaService.txClient().tableMeta.findFirstOrThrow({ diff --git a/apps/nestjs-backend/src/features/trash/trash.service.ts b/apps/nestjs-backend/src/features/trash/trash.service.ts index 323bbc4613..ab456e179b 100644 --- a/apps/nestjs-backend/src/features/trash/trash.service.ts +++ b/apps/nestjs-backend/src/features/trash/trash.service.ts @@ -686,14 +686,14 @@ export class TrashService { const base = await this.prismaService.txClient().base.findUnique({ where: { id: baseId, deletedTime: null }, - select: { spaceId: true }, + select: { spaceId: true, v2Enabled: true }, }); if (!base?.spaceId) { return { useV2: false, reason: 'disabled', baseId, tableId: trash.resourceId }; } - const decision = await this.canaryService.shouldUseV2WithReason(base.spaceId, 'restoreTable'); + const decision = await this.canaryService.shouldUseV2ForBaseWithReason(base, 'restoreTable'); return { ...decision, baseId, diff --git a/apps/nestjs-backend/src/features/v2/v2-base-node-compat.service.ts b/apps/nestjs-backend/src/features/v2/v2-base-node-compat.service.ts index 6f496a594f..2828bc815f 100644 --- a/apps/nestjs-backend/src/features/v2/v2-base-node-compat.service.ts +++ b/apps/nestjs-backend/src/features/v2/v2-base-node-compat.service.ts @@ -13,9 +13,11 @@ import { type Result, } from '@teable/v2-core'; import type { DependencyContainer } from '@teable/v2-di'; +import { ClsService } from 'nestjs-cls'; import { PerformanceCacheService } from '../../performance-cache'; import { generateBaseNodeListCacheKey } from '../../performance-cache/generate-keys'; import { ShareDbService } from '../../share-db/share-db.service'; +import type { IClsStore } from '../../types/cls'; import { presenceHandler } from '../base-node/helper'; import { V2ProjectionRegistrar, type IV2ProjectionRegistrar } from './v2-projection-registrar'; @@ -28,13 +30,18 @@ export class V2TableBaseNodeProjection { constructor( private readonly performanceCacheService: PerformanceCacheService, - private readonly shareDbService: ShareDbService + private readonly shareDbService: ShareDbService, + private readonly cls: ClsService ) {} async handle( _context: IExecutionContext, event: TableCreated | TableTrashed | TableDeleted | TableRestored ): Promise> { + const ignoreBaseNodeListener = this.cls.get('ignoreBaseNodeListener'); + if (ignoreBaseNodeListener) { + return ok(undefined); + } const baseId = event.baseId.toString(); this.performanceCacheService.del(generateBaseNodeListCacheKey(baseId)); @@ -59,7 +66,8 @@ export class V2BaseNodeCompatService implements IV2ProjectionRegistrar { constructor( private readonly performanceCacheService: PerformanceCacheService, - private readonly shareDbService: ShareDbService + private readonly shareDbService: ShareDbService, + private readonly cls: ClsService ) {} registerProjections(container: DependencyContainer): void { @@ -67,7 +75,7 @@ export class V2BaseNodeCompatService implements IV2ProjectionRegistrar { container.registerInstance( V2TableBaseNodeProjection, - new V2TableBaseNodeProjection(this.performanceCacheService, this.shareDbService) + new V2TableBaseNodeProjection(this.performanceCacheService, this.shareDbService, this.cls) ); } } diff --git a/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts b/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts index b7feaf5f49..c6dd4f8ee6 100644 --- a/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts +++ b/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts @@ -12,6 +12,7 @@ import type { ILinkFieldOptions, IPluginViewOptions, IViewPropertyKeys, + CellValueType, ISort, IGroup, TableDomain, @@ -28,7 +29,7 @@ import { generatePluginInstallId, generateOperationId, extractFieldIdsFromFilter, - validateFilterOperatorModeCompatibility, + analyzeFilterValidationIssues, HttpErrorCode, } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; @@ -289,7 +290,7 @@ export class ViewOpenApiService { if (fieldIds.length > 0) { const fields = await this.prismaService.field.findMany({ where: { tableId, id: { in: fieldIds } }, - select: { id: true, type: true }, + select: { id: true, type: true, cellValueType: true, isMultipleCellValue: true }, }); // Check for unsupported Button type fields @@ -306,15 +307,26 @@ export class ViewOpenApiService { ); } - // Validate operator + mode compatibility for date fields - const fieldTypeMap = fields.reduce( + // Validate filter compatibility with the same shared analyzer used by SDK/query execution. + const fieldMetaMap = fields.reduce( (acc, f) => { - acc[f.id] = f.type as FieldType; + acc[f.id] = { + type: f.type as FieldType, + cellValueType: f.cellValueType as CellValueType, + isMultipleCellValue: Boolean(f.isMultipleCellValue), + }; return acc; }, - {} as Record + {} as Record< + string, + { + type: FieldType; + cellValueType: CellValueType; + isMultipleCellValue: boolean; + } + > ); - const validationErrors = validateFilterOperatorModeCompatibility(filter, fieldTypeMap); + const validationErrors = analyzeFilterValidationIssues(filter, fieldMetaMap); if (validationErrors.length > 0) { throw new CustomHttpException(validationErrors[0].message, HttpErrorCode.VALIDATION_ERROR, { localization: { diff --git a/apps/nestjs-backend/src/share-db/share-db.adapter.ts b/apps/nestjs-backend/src/share-db/share-db.adapter.ts index 1f66f51f57..3e2519a5cb 100644 --- a/apps/nestjs-backend/src/share-db/share-db.adapter.ts +++ b/apps/nestjs-backend/src/share-db/share-db.adapter.ts @@ -195,6 +195,21 @@ export class ShareDbAdapter extends ShareDb.DB { }, {}); } + private snapshots2MapWithMissing( + ids: string[], + snapshotData: ISnapshotBase[] + ): Record { + const snapshotDataMap = new Map(snapshotData.map((snapshot) => [snapshot.id, snapshot])); + const snapshots = ids.map((id) => { + const snapshot = snapshotDataMap.get(id); + if (!snapshot) { + return new Snapshot(id, 0, null, undefined, null); + } + return new Snapshot(snapshot.id, snapshot.v, snapshot.type, snapshot.data, null); + }); + return this.snapshots2Map(snapshots); + } + // Get the named document from the database. The callback is called with (err, // snapshot). A snapshot with a version of zero is returned if the document // has never been created in the database. @@ -216,16 +231,7 @@ export class ShareDbAdapter extends ShareDb.DB { // For internal (server-side) connections without auth, resolve field docs directly if (docType === IdPrefix.Field && this.fieldServiceInner) { const snapshotData = await this.fieldServiceInner.getSnapshotBulk(collectionId, ids); - if (snapshotData.length) { - const snapshots = snapshotData.map( - (snapshot) => - new Snapshot(snapshot.id, snapshot.v, snapshot.type, snapshot.data, null) - ); - callback(null, this.snapshots2Map(snapshots)); - } else { - const snapshots = ids.map((id) => new Snapshot(id, 0, null, undefined, null)); - callback(null, this.snapshots2Map(snapshots)); - } + callback(null, this.snapshots2MapWithMissing(ids, snapshotData)); return; } throw new UnauthorizedException('Unauthorized request not authorized'); @@ -243,22 +249,7 @@ export class ShareDbAdapter extends ShareDb.DB { ); } ); - if (snapshotData.length) { - const snapshots = snapshotData.map( - (snapshot) => - new Snapshot( - snapshot.id, - snapshot.v, - snapshot.type, - snapshot.data, - null // TODO: metadata - ) - ); - callback(null, this.snapshots2Map(snapshots)); - } else { - const snapshots = ids.map((id) => new Snapshot(id, 0, null, undefined, null)); - callback(null, this.snapshots2Map(snapshots)); - } + callback(null, this.snapshots2MapWithMissing(ids, snapshotData)); } catch (err) { this.logger.error(err); callback(exceptionParse(err as Error)); diff --git a/apps/nestjs-backend/src/share-db/share-db.spec.ts b/apps/nestjs-backend/src/share-db/share-db.spec.ts index 649cd2fb41..1820036315 100644 --- a/apps/nestjs-backend/src/share-db/share-db.spec.ts +++ b/apps/nestjs-backend/src/share-db/share-db.spec.ts @@ -1,6 +1,9 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; +import { IdPrefix } from '@teable/core'; +import { vi } from 'vitest'; import { GlobalModule } from '../global/global.module'; +import { ShareDbAdapter } from './share-db.adapter'; import { ShareDbModule } from './share-db.module'; import { ShareDbService } from './share-db.service'; @@ -19,6 +22,56 @@ describe('ShareDb', () => { expect(provider).toBeDefined(); }); + it('returns empty snapshots for stale query ids missing from snapshot bulk', async () => { + const cls = { + get: vi.fn(() => undefined), + runWith: vi.fn((_store, fn) => fn()), + }; + const recordService = { + getSnapshotBulk: vi.fn().mockResolvedValue([ + { + id: 'recExisting', + v: 2, + type: 'json0', + data: { id: 'recExisting', fields: {} }, + }, + ]), + }; + const adapter = new ShareDbAdapter( + cls as never, + {} as never, + recordService as never, + {} as never, + {} as never, + {} as never + ); + + const snapshots = await new Promise< + Record + >((resolve, reject) => { + adapter.getSnapshotBulk( + `${IdPrefix.Record}_tblTest`, + ['recExisting', 'recDeleted'], + undefined, + { cookie: 'teable-session=test' }, + (error, data) => { + if (error) { + reject(error); + return; + } + resolve(data as Record); + } + ); + }); + + expect(snapshots.recExisting.v).toBe(2); + expect(snapshots.recDeleted).toMatchObject({ + v: 0, + type: null, + data: undefined, + }); + }); + // it('create simple document', (done) => { // const randomTitle = `B:${Math.floor(Math.random() * 1000)}`; // const doc = provider.connect().get('books', randomTitle); diff --git a/apps/nestjs-backend/src/types/cls.ts b/apps/nestjs-backend/src/types/cls.ts index 788968b86e..40f6dfa245 100644 --- a/apps/nestjs-backend/src/types/cls.ts +++ b/apps/nestjs-backend/src/types/cls.ts @@ -10,8 +10,10 @@ import type { IDataLoaderCache } from './data-loader'; export type V2Reason = | 'env_force_v2_all' | 'config_force_v2_all' + | 'new_base' | 'header_override' | 'space_feature' + | 'unsupported_feature' | 'disabled' | 'feature_not_enabled' | 'no_feature'; @@ -51,6 +53,7 @@ export interface IClsStore extends ClsStore { rawOpMaps?: IRawOpMap[]; }; shareViewId?: string; + baseShareId?: string; permissions: Action[]; // this is used to check if the user is in the space when the user operate in a space spaceId?: string; diff --git a/apps/nestjs-backend/src/types/i18n.generated.ts b/apps/nestjs-backend/src/types/i18n.generated.ts index d990617ec4..fdb3eea59c 100644 --- a/apps/nestjs-backend/src/types/i18n.generated.ts +++ b/apps/nestjs-backend/src/types/i18n.generated.ts @@ -358,6 +358,7 @@ export type I18nTranslations = { }; "settings": { "title": string; + "allSetting": string; "personal": { "title": string; }; @@ -1414,6 +1415,10 @@ export type I18nTranslations = { "title": string; "message": string; }; + "failedSummary": { + "title": string; + "message": string; + }; }; "billing": { "title": string; @@ -2087,6 +2092,7 @@ export type I18nTranslations = { }; "invalidateSelected": string; "invalidateSelectedTips": string; + "invalidConditionTip": string; "default": { "empty": string; "placeholder": string; @@ -2195,6 +2201,7 @@ export type I18nTranslations = { "showAll": string; "hideAll": string; "primaryKey": string; + "notInCurrentView": string; }; "expandRecord": { "copy": string; @@ -3163,6 +3170,8 @@ export type I18nTranslations = { "clickCountReachedMaxCount": string; "notSupportReset": string; }; + "primaryCannotBeLookup": string; + "primaryFieldAlreadyExists": string; }; "view": { "notFound": string; @@ -3361,6 +3370,7 @@ export type I18nTranslations = { "zipFileTooLarge": string; "invalidZip": string; "domainAlreadyInUse": string; + "domainReserved": string; }; "reward": { "notFound": string; @@ -3384,6 +3394,7 @@ export type I18nTranslations = { "linkedInAuthorNotFound": string; "fetchLinkedInUserFailed": string; "domainAlreadyInUse": string; + "domainReserved": string; }; }; "aiError": { @@ -3982,6 +3993,7 @@ export type I18nTranslations = { "sortMissingWarningTitle": string; "sortMissingWarningDescription": string; }; + "fieldUnavailable": string; "lastModifiedScope": string; "lastModifiedAll": string; "lastModifiedSpecific": string; @@ -4432,6 +4444,7 @@ export type I18nTranslations = { "foreignKeyOrphanRows": string; "junctionForeignKeyTargetTableMissing": string; "junctionForeignKeyOrphanRows": string; + "autoRule": string; }; "manual": { "apply": string; @@ -4450,10 +4463,22 @@ export type I18nTranslations = { }; "manualRepairPreview": string; "manualRepairPreviewTip": string; + "repairPreviewTitle": string; + "repairPreviewDescription": string; + "repairPreviewWhat": string; + "repairPreviewTarget": string; + "repairPreviewPrinciple": string; + "repairPreviewNoPrinciple": string; + "repairPreviewSql": string; + "repairPreviewNoSql": string; + "repairPreviewCannotConfirm": string; + "repairPreviewParameters": string; + "repairPreviewConfirm": string; }; - "type": string; - "message": string; "errorType": { + "InvalidPrimaryLookup": string; + "InvalidPrimaryType": string; + "MissingPrimary": string; "ForeignTableNotFound": string; "ForeignKeyNotFound": string; "SelfKeyNotFound": string; @@ -4466,6 +4491,8 @@ export type I18nTranslations = { "EmptyString": string; "InvalidFilterOperator": string; }; + "type": string; + "message": string; }; "index": { "description": string; @@ -4959,6 +4986,9 @@ export type I18nTranslations = { "retry": { "interrupted": string; "button": string; + "offline": string; + "pausedHidden": string; + "maxAttemptsReached": string; }; "guide": { "goToScenario": string; @@ -4994,6 +5024,8 @@ export type I18nTranslations = { "advancedOptions": string; "namingFieldLabel": string; "selectField": string; + "noPrefixOption": string; + "noPrefixOptionDesc": string; "groupByRow": string; "groupByRowTip": string; }; diff --git a/apps/nestjs-backend/test/base-node.e2e-spec.ts b/apps/nestjs-backend/test/base-node.e2e-spec.ts index 70c30673ab..3b37c0ec8e 100644 --- a/apps/nestjs-backend/test/base-node.e2e-spec.ts +++ b/apps/nestjs-backend/test/base-node.e2e-spec.ts @@ -39,7 +39,6 @@ const testFolder = 'Test Folder'; const updatedName = 'Updated Name'; const testTableName = 'Test Table'; const windowIdHeader = 'x-window-id'; -const isForceV2 = process.env.FORCE_V2_ALL === 'true'; describe('BaseNodeController (e2e) /api/base/:baseId/node', () => { let app: INestApplication; @@ -185,9 +184,9 @@ describe('BaseNodeController (e2e) /api/base/:baseId/node', () => { ); expect(response.status).toBe(201); - expect(response.headers['x-teable-v2']).toBe(isForceV2 ? 'true' : 'false'); + expect(response.headers['x-teable-v2']).toBe('true'); expect(response.headers['x-teable-v2-feature']).toBe('createTable'); - expect(response.headers['x-teable-v2-reason']).toBeTruthy(); + expect(response.headers['x-teable-v2-reason']).toBe('new_base'); nodesToCleanup.push(response.data.id); }); @@ -384,9 +383,9 @@ describe('BaseNodeController (e2e) /api/base/:baseId/node', () => { ); expect(response.status).toBe(201); - expect(response.headers['x-teable-v2']).toBe(isForceV2 ? 'true' : 'false'); + expect(response.headers['x-teable-v2']).toBe('true'); expect(response.headers['x-teable-v2-feature']).toBe('createTable'); - expect(response.headers['x-teable-v2-reason']).toBeTruthy(); + expect(response.headers['x-teable-v2-reason']).toBe('new_base'); nodesToCleanup.push(response.data.id); @@ -673,9 +672,9 @@ describe('BaseNodeController (e2e) /api/base/:baseId/node', () => { ); expect(response.status).toBe(200); - expect(response.headers['x-teable-v2']).toBe(isForceV2 ? 'true' : 'false'); + expect(response.headers['x-teable-v2']).toBe('true'); expect(response.headers['x-teable-v2-feature']).toBe('deleteTable'); - expect(response.headers['x-teable-v2-reason']).toBeTruthy(); + expect(response.headers['x-teable-v2-reason']).toBe('new_base'); const error = await getError(() => getBaseNode(baseId, table.data.id)); expect(error?.status).toBeGreaterThanOrEqual(400); @@ -1032,9 +1031,9 @@ describe('BaseNodeController (e2e) /api/base/:baseId/node', () => { ); expect(response.status).toBe(201); - expect(response.headers['x-teable-v2']).toBe(isForceV2 ? 'true' : 'false'); + expect(response.headers['x-teable-v2']).toBe('true'); expect(response.headers['x-teable-v2-feature']).toBe('duplicateTable'); - expect(response.headers['x-teable-v2-reason']).toBeTruthy(); + expect(response.headers['x-teable-v2-reason']).toBe('new_base'); nodesToCleanup.push(response.data.id); expect(response.data.resourceMeta?.name).toBe('Duplicated Table Via Node Route'); @@ -1907,4 +1906,170 @@ describe('BaseNodeController (e2e) /api/base/:baseId/node', () => { }); }); }); + + describe('Resource ID resolution (using resourceId instead of nodeId)', () => { + const nodesToCleanup: string[] = []; + + afterEach(async () => { + for (const nodeId of [...nodesToCleanup].reverse()) { + await deleteBaseNode(baseId, nodeId); + } + nodesToCleanup.length = 0; + }); + + it('should get node by resourceId (tableId)', async () => { + const node = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Resolve Get Test', + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + nodesToCleanup.push(node.data.id); + + const response = await getBaseNode(baseId, node.data.resourceId); + + expect(response.data.id).toBe(node.data.id); + expect(response.data.resourceId).toBe(node.data.resourceId); + expect(response.data.resourceMeta?.name).toBe('Resolve Get Test'); + }); + + it('should update node by resourceId', async () => { + const node = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Resolve Update Test', + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + nodesToCleanup.push(node.data.id); + + const response = await updateBaseNode(baseId, node.data.resourceId, { + name: 'Resolve Updated', + }); + + expect(response.data.id).toBe(node.data.id); + expect(response.data.resourceMeta?.name).toBe('Resolve Updated'); + }); + + it('should duplicate node by resourceId', async () => { + const node = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Resolve Duplicate Test', + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + nodesToCleanup.push(node.data.id); + + const duplicate = await duplicateBaseNode(baseId, node.data.resourceId, { + name: 'Resolve Duplicated', + }); + nodesToCleanup.push(duplicate.data.id); + + expect(duplicate.data.id).not.toBe(node.data.id); + expect(duplicate.data.resourceMeta?.name).toBe('Resolve Duplicated'); + }); + + it('should move node by resourceId', async () => { + const folder = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Folder, + name: 'Resolve Move Folder', + }); + nodesToCleanup.push(folder.data.id); + + const node = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Resolve Move Test', + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + nodesToCleanup.push(node.data.id); + + const response = await moveBaseNode(baseId, node.data.resourceId, { + parentId: folder.data.id, + }); + + expect(response.data.id).toBe(node.data.id); + expect(response.data.parentId).toBe(folder.data.id); + }); + + it('should delete node by resourceId', async () => { + const node = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Resolve Delete Test', + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + + await deleteBaseNode(baseId, node.data.resourceId); + + const error = await getError(() => getBaseNode(baseId, node.data.id)); + expect(error?.status).toBeGreaterThanOrEqual(400); + }); + + it('should move node with resourceId as parentId', async () => { + const folder = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Folder, + name: 'Resolve Parent Folder', + }); + nodesToCleanup.push(folder.data.id); + + const node = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Resolve Parent Move Test', + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + nodesToCleanup.push(node.data.id); + + const response = await moveBaseNode(baseId, node.data.id, { + parentId: folder.data.resourceId, + }); + + expect(response.data.id).toBe(node.data.id); + expect(response.data.parentId).toBe(folder.data.id); + }); + + it('should create node with resourceId as parentId', async () => { + const folder = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Folder, + name: 'Resolve Create Parent Folder', + }); + nodesToCleanup.push(folder.data.id); + + const node = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Resolve Create In Folder Test', + parentId: folder.data.resourceId, + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + nodesToCleanup.push(node.data.id); + + expect(node.data.parentId).toBe(folder.data.id); + }); + + it('should move node with resourceId as anchorId', async () => { + const anchor = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Anchor Table', + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + nodesToCleanup.push(anchor.data.id); + + const node = await createBaseNode(baseId, { + resourceType: BaseNodeResourceType.Table, + name: 'Movable Table', + fields: [{ name: 'Field1', type: FieldType.SingleLineText }], + views: [{ name: 'Grid view', type: ViewType.Grid }], + }); + nodesToCleanup.push(node.data.id); + + const response = await moveBaseNode(baseId, node.data.id, { + anchorId: anchor.data.resourceId, + position: 'before', + }); + + expect(response.data.id).toBe(node.data.id); + }); + }); }); diff --git a/apps/nestjs-backend/test/computed-orchestrator.e2e-spec.ts b/apps/nestjs-backend/test/computed-orchestrator.e2e-spec.ts index 422a6081f0..efdad47b33 100644 --- a/apps/nestjs-backend/test/computed-orchestrator.e2e-spec.ts +++ b/apps/nestjs-backend/test/computed-orchestrator.e2e-spec.ts @@ -56,6 +56,7 @@ dayjs.extend(utc); dayjs.extend(timezone); const isForceV2 = process.env.FORCE_V2_ALL === 'true'; +let isV2Mode = isForceV2; describe('Computed Orchestrator (e2e)', () => { let app: INestApplication; @@ -78,6 +79,11 @@ describe('Computed Orchestrator (e2e)', () => { tableDomainQueryService = app.get(TableDomainQueryService); recordDialect = app.get(RECORD_QUERY_DIALECT_SYMBOL as any); v2ContainerService = app.get(V2ContainerService); + const base = await prisma.base.findUnique({ + where: { id: baseId }, + select: { v2Enabled: true }, + }); + isV2Mode = isForceV2 || Boolean(base?.v2Enabled); }); afterAll(async () => { @@ -89,7 +95,7 @@ describe('Computed Orchestrator (e2e)', () => { * This ensures all async computed updates are completed before assertions. */ async function processV2Outbox(times = 1): Promise { - if (!isForceV2) return; + if (!isV2Mode) return; const container = await v2ContainerService.getContainer(); const drainService = container.resolve( @@ -132,7 +138,7 @@ describe('Computed Orchestrator (e2e)', () => { _count: number = 1 ) { return async function fn(fn: () => Promise) { - if (isForceV2) { + if (isV2Mode) { // In v2 mode, execute and process outbox to ensure async updates complete const result = await fn(); await processV2Outbox(); @@ -143,11 +149,17 @@ describe('Computed Orchestrator (e2e)', () => { }; } - async function runAndCaptureRecordUpdates(fn: () => Promise): Promise<{ + async function runAndCaptureRecordUpdates( + fn: () => Promise, + options?: { + isComplete?: (events: any[]) => boolean; + timeoutMs?: number; + } + ): Promise<{ result: T; events: any[]; }> { - if (isForceV2) { + if (isV2Mode) { // In v2 mode, execute and process outbox to ensure async updates complete // Events are not emitted in V2 mode, so we return an empty array const result = await fn(); @@ -160,8 +172,31 @@ describe('Computed Orchestrator (e2e)', () => { eventEmitterService.eventEmitter.on(Events.TABLE_RECORD_UPDATE, handler); try { const result = await fn(); - // allow async emission to flush - await new Promise((r) => setTimeout(r, 50)); + // Computed updates may emit a short burst of async record.update events after + // the originating mutation resolves. Keep listening until the stream settles. + const stableWindowMs = 100; + const pollIntervalMs = 25; + const deadline = Date.now() + (options?.timeoutMs ?? 2000); + let stableSince = Date.now(); + let lastCount = events.length; + + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, pollIntervalMs)); + + if (events.length !== lastCount) { + lastCount = events.length; + stableSince = Date.now(); + continue; + } + + if ( + Date.now() - stableSince >= stableWindowMs && + (!options?.isComplete || options.isComplete(events)) + ) { + break; + } + } + return { result, events }; } finally { eventEmitterService.eventEmitter.off(Events.TABLE_RECORD_UPDATE, handler); @@ -279,7 +314,7 @@ describe('Computed Orchestrator (e2e)', () => { })) as any; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const event = payloads[0] as any; // RecordUpdateEvent expect(event.payload.tableId).toBe(table.id); const changes = event.payload.record.fields as Record< @@ -498,7 +533,7 @@ IF( })) as any; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const event = payloads[0] as any; const recs = Array.isArray(event.payload.record) ? event.payload.record @@ -556,7 +591,7 @@ IF( })) as any; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const event = payloads[0] as any; expect(event.payload.tableId).toBe(table.id); const rec = Array.isArray(event.payload.record) @@ -976,7 +1011,7 @@ IF( }); // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const changes = findLatestRecordChangeMap(events, t2.id, t2.records[0].id); const lkpChange = assertChange(changes[lkp.id]); expectNoOldValue(lkpChange); @@ -1039,7 +1074,7 @@ IF( const symmetricFieldId = symmetric.id; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const evtOnT2 = events.find((e) => e.payload?.tableId === t2.id); expect(evtOnT2).toBeDefined(); const recT2 = Array.isArray(evtOnT2!.payload.record) @@ -1105,7 +1140,7 @@ IF( }); // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const changes = findLatestRecordChangeMap(events, t1.id, t1.records[0].id); const lkpChange = assertChange(changes[lkp.id]); expectNoOldValue(lkpChange); @@ -1160,7 +1195,7 @@ IF( }); // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const changes = findLatestRecordChangeMap(events, t1.id, t1.records[0].id); const lkpChange = assertChange(changes[lkp.id]); expectNoOldValue(lkpChange); @@ -1212,7 +1247,7 @@ IF( }); // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const changes = findLatestRecordChangeMap(events, t2.id, t2.records[0].id); const lkpChange = assertChange(changes[lkp.id]); expectNoOldValue(lkpChange); @@ -1270,7 +1305,7 @@ IF( })) as any; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { // Find T2 event const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const changes = t2Event.payload.record.fields as Record< @@ -1337,7 +1372,7 @@ IF( }); // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const t2Event = [...events] .reverse() .find((event) => event.payload.tableId === t2.id && toChangeMap(event)[roll2.id])!; @@ -1425,7 +1460,7 @@ IF( })) as any; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { // T1 const t1Event = (payloads as any[]).find((e) => e.payload.tableId === t1.id)!; const t1Changes = ( @@ -1554,7 +1589,7 @@ IF( })) as any; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const t2Changes = ( Array.isArray(t2Event.payload.record) ? t2Event.payload.record[0] : t2Event.payload.record @@ -1667,7 +1702,7 @@ IF( })) as any; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const t2Changes = ( Array.isArray(t2Event.payload.record) ? t2Event.payload.record[0] : t2Event.payload.record @@ -1762,7 +1797,7 @@ IF( } as IFieldRo); }); - if (!isForceV2) { + if (!isV2Mode) { const hostCreateEvent = creationEvents.find((e) => e.payload.tableId === host.id); expect(hostCreateEvent).toBeDefined(); const createRecordPayload = Array.isArray(hostCreateEvent!.payload.record) @@ -1804,7 +1839,7 @@ IF( (await getRow(hostDbTable, host.records[0].id))[hostFieldVo.dbFieldName] ); expect(valueAfterStatus).toEqual(2); - if (!isForceV2) { + if (!isV2Mode) { const hostFilterEvent = filterEvents.find((e) => e.payload.tableId === host.id); expect(hostFilterEvent).toBeDefined(); const filterRecordPayload = Array.isArray(hostFilterEvent!.payload.record) @@ -1825,7 +1860,7 @@ IF( (await getRow(hostDbTable, host.records[0].id))[hostFieldVo.dbFieldName] ); expect(valueAfterLookupColumnChange).toEqual(1); - if (!isForceV2) { + if (!isV2Mode) { const hostLookupEvent = lookupColumnEvents.find((e) => e.payload.tableId === host.id); expect(hostLookupEvent).toBeDefined(); const lookupRecordPayload = Array.isArray(hostLookupEvent!.payload.record) @@ -1901,21 +1936,31 @@ IF( filterSet.push(...additionalFilterItems); } - const { result: rollupField, events } = await runAndCaptureRecordUpdates(async () => { - return await createField(host.id, { - name: `Equality ${expression}`, - type: FieldType.ConditionalRollup, - options: { - foreignTableId: foreign.id, - lookupFieldId: foreignAmountId, - expression, - filter: { - conjunction: 'and', - filterSet, + const { result: rollupField, events } = await runAndCaptureRecordUpdates( + async () => { + return await createField(host.id, { + name: `Equality ${expression}`, + type: FieldType.ConditionalRollup, + options: { + foreignTableId: foreign.id, + lookupFieldId: foreignAmountId, + expression, + filter: { + conjunction: 'and', + filterSet, + }, }, - }, - } as IFieldRo); - }); + } as IFieldRo); + }, + { + isComplete: (events) => + Boolean( + findRecordChangeMap(events, host.id, aliceRecordId) && + findRecordChangeMap(events, host.id, nobodyRecordId) + ), + timeoutMs: 5000, + } + ); const hostDbTable = await getDbTableName(host.id); const hostFieldVo = (await getFields(host.id)).find((f) => f.id === rollupField.id)! as any; @@ -2106,7 +2151,7 @@ IF( const ctx = await setupEqualityConditionalRollup(expression); const { cleanup } = ctx; try { - if (!isForceV2) { + if (!isV2Mode) { const createAliceChange = findRecordChangeMap( ctx.creationEvents, ctx.host.id, @@ -2146,7 +2191,7 @@ IF( await update(ctx); }); - if (!isForceV2) { + if (!isV2Mode) { const updateAliceChange = findRecordChangeMap( updateEvents, ctx.host.id, @@ -2192,7 +2237,7 @@ IF( }); const { cleanup } = ctx; try { - if (!isForceV2) { + if (!isV2Mode) { const createAliceChange = findRecordChangeMap( ctx.creationEvents, ctx.host.id, @@ -2233,7 +2278,7 @@ IF( }); }); - if (!isForceV2) { + if (!isV2Mode) { const updateAliceChange = findRecordChangeMap( updateEvents, ctx.host.id, @@ -2310,7 +2355,7 @@ IF( } ); - if (!isForceV2) { + if (!isV2Mode) { const createAliceChange = findRecordChangeMap(creationEvents, host.id, aliceId); expect(createAliceChange).toBeDefined(); expect(createAliceChange?.[rollupField.id]?.newValue).toEqual(30); @@ -2327,7 +2372,7 @@ IF( const { events: updateEvents } = await runAndCaptureRecordUpdates(async () => { await updateRecordByApi(foreign.id, foreign.records[0].id, foreignAmountId, 15); }); - if (!isForceV2) { + if (!isV2Mode) { const updateAliceChange = findRecordChangeMap(updateEvents, host.id, aliceId); expect(updateAliceChange).toBeDefined(); expect(updateAliceChange?.[rollupField.id]?.newValue).toEqual(35); @@ -2422,7 +2467,7 @@ IF( } ); - if (!isForceV2) { + if (!isV2Mode) { const createAChange = findRecordChangeMap(creationEvents, host.id, hostAId); expect(createAChange).toBeDefined(); expect(createAChange?.[rollupField.id]?.newValue).toEqual(15); @@ -2571,7 +2616,7 @@ IF( } as IFieldRo); }); - if (!isForceV2) { + if (!isV2Mode) { const createChange = findRecordChangeMap(creationEvents, host.id, hostRecordId); expect(createChange).toBeDefined(); expect(createChange?.[conditionalRollupField.id]?.newValue).toEqual(1); @@ -2588,7 +2633,7 @@ IF( const { events: hostFieldChangeEvents } = await runAndCaptureRecordUpdates(async () => { await updateRecordByApi(host.id, hostRecordId, targetFieldId, 'B'); }); - if (!isForceV2) { + if (!isV2Mode) { const hostFieldChange = findRecordChangeMap(hostFieldChangeEvents, host.id, hostRecordId); expect(hostFieldChange).toBeDefined(); const hostFieldLookupChange = assertChange(hostFieldChange?.[conditionalRollupField.id]); @@ -2603,7 +2648,7 @@ IF( const { events: foreignFieldChangeEvents } = await runAndCaptureRecordUpdates(async () => { await updateRecordByApi(foreign.id, foreign.records[1].id, statusId, 'B'); }); - if (!isForceV2) { + if (!isV2Mode) { const foreignDrivenChange = findRecordChangeMap( foreignFieldChangeEvents, host.id, @@ -2687,7 +2732,7 @@ IF( (f) => f.id === conditionalRollupField.id )! as any; - if (!isForceV2) { + if (!isV2Mode) { const createChangeA = findRecordChangeMap(createEvents, host.id, hostRecordAId); expect(createChangeA).toBeDefined(); expect(createChangeA?.[conditionalRollupField.id]?.newValue).toEqual(1); @@ -2729,7 +2774,7 @@ IF( } as IFieldRo); }); - if (!isForceV2) { + if (!isV2Mode) { const updatedChangeA = findRecordChangeMap(filterChangeEvents, host.id, hostRecordAId); if (updatedChangeA?.[conditionalRollupField.id]) { const change = assertChange(updatedChangeA[conditionalRollupField.id]); @@ -2870,7 +2915,7 @@ IF( })) as any; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const event = payloads[0] as any; expect(event.payload.tableId).toBe(table.id); const rec = Array.isArray(event.payload.record) @@ -2925,7 +2970,7 @@ IF( })) as any; // Event payload verification only in v1 mode - if (!isForceV2) { + if (!isV2Mode) { const evt = payloads[0]; const rec = Array.isArray(evt.payload.record) ? evt.payload.record[0] : evt.payload.record; const changes = rec.fields as FieldChangeMap; @@ -3010,7 +3055,7 @@ IF( await deleteField(t1.id, aId); })) as any; - if (!isForceV2) { + if (!isV2Mode) { // T2 const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const t2Changes = ( @@ -3086,7 +3131,7 @@ IF( await deleteField(t1.id, aId); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const changes = ( Array.isArray(t2Event.payload.record) ? t2Event.payload.record[0] : t2Event.payload.record @@ -3179,7 +3224,7 @@ IF( const { events } = await runAndCaptureRecordUpdates(async () => { await createField(table.id, { name: 'B', type: FieldType.SingleLineText } as IFieldRo); }); - if (!isForceV2) { + if (!isV2Mode) { expect(events.length).toBe(1); const baseField = (await getFields(table.id)).find((f) => f.name === 'B')!; const changeMap = toChangeMap(events[0]); @@ -3199,7 +3244,7 @@ IF( } as IFieldRo); }); const fId = (await getFields(table.id)).find((f) => f.name === 'F')!.id; - if (!isForceV2) { + if (!isV2Mode) { expect(events.length).toBe(1); const changeMap = toChangeMap(events[0]); const fChange = assertChange(changeMap[fId]); @@ -3254,7 +3299,7 @@ IF( } as any); }); const lkpField = (await getFields(t2.id)).find((f) => f.name === 'LK')!; - if (!isForceV2) { + if (!isV2Mode) { expect(events.length).toBe(1); const changeMap = toChangeMap(events[0]); const lkpChange = assertChange(changeMap[lkpField.id]); @@ -3285,7 +3330,7 @@ IF( } as any); }); const rId = (await getFields(t2.id)).find((f) => f.name === 'R')!.id; - if (!isForceV2) { + if (!isV2Mode) { expect(events.length).toBe(1); const changeMap = toChangeMap(events[0]); const rChange = assertChange(changeMap[rId]); @@ -3327,7 +3372,7 @@ IF( options: { expression: `{${aId}} + 5` }, } as any); }); - if (!isForceV2) { + if (!isV2Mode) { expect(events.length).toBe(1); const changeMap = toChangeMap(events[0]); const fChange = assertChange(changeMap[f.id]); @@ -3362,7 +3407,7 @@ IF( const { events } = await runAndCaptureRecordUpdates(async () => { await duplicateField(table.id, textField.id, { name: 'Text_copy' }); }); - if (!isForceV2) { + if (!isV2Mode) { expect(events.length).toBe(1); const textCopyField = (await getFields(table.id)).find((f) => f.name === 'Text_copy')!; const changeMap = toChangeMap(events[0]); @@ -3383,7 +3428,7 @@ IF( await duplicateField(table.id, f.id, { name: 'F_copy' }); }); const fCopyId = (await getFields(table.id)).find((x) => x.name === 'F_copy')!.id; - if (!isForceV2) { + if (!isV2Mode) { expect(events.length).toBe(1); const changeMap = toChangeMap(events[0]); const fCopyChange = assertChange(changeMap[fCopyId]); @@ -3437,7 +3482,7 @@ IF( await updateRecordByApi(t1.id, t1.records[0].id, titleId, 'Bar'); })) as any; - if (!isForceV2) { + if (!isV2Mode) { // Find T2 event const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const changes = t2Event.payload.record.fields as FieldChangeMap; @@ -3581,7 +3626,7 @@ IF( .map((x: any) => x?.id) .filter(Boolean); - if (!isForceV2) { + if (!isV2Mode) { // Expect: one event on T1[1-1] and one symmetric event on T2[2-1] const t1Event = (payloads as any[]).find((e) => e.payload.tableId === t1.id)!; const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; @@ -3679,7 +3724,7 @@ IF( await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB1 }]); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const change = assertChange(getChangeFromEvent(t2Event, linkOnT2.id, rB1)); expectNoOldValue(change); @@ -3697,7 +3742,7 @@ IF( await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB1 }, { id: rB2 }]); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const change = assertChange(getChangeFromEvent(t2Event, linkOnT2.id, rB2)); expectNoOldValue(change); @@ -3715,7 +3760,7 @@ IF( await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB2 }]); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const change = assertChange( getChangeFromEvent(t2Event, linkOnT2.id, rB1) || @@ -3791,7 +3836,7 @@ IF( )(async () => { await updateRecordByApi(t1.id, rA1, linkOnT1.id, { id: rB1 }); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const recs = Array.isArray(t2Event.payload.record) ? t2Event.payload.record @@ -3814,7 +3859,7 @@ IF( )(async () => { await updateRecordByApi(t1.id, rA1, linkOnT1.id, { id: rB2 }); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const recs = Array.isArray(t2Event.payload.record) ? t2Event.payload.record @@ -3892,7 +3937,7 @@ IF( )(async () => { await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB1 }]); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const recs = Array.isArray(t2Event.payload.record) ? t2Event.payload.record @@ -3915,7 +3960,7 @@ IF( )(async () => { await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB1 }, { id: rB2 }]); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const recs = Array.isArray(t2Event.payload.record) ? t2Event.payload.record @@ -3938,7 +3983,7 @@ IF( )(async () => { await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB2 }]); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; const recs = Array.isArray(t2Event.payload.record) ? t2Event.payload.record @@ -4037,7 +4082,7 @@ IF( await updateRecordByApi(t2.id, r2_1, linkOnT2.id, [{ id: r1_1 }]); })) as any; - if (!isForceV2) { + if (!isV2Mode) { const t1Event = (payloads as any[]).find((e) => e.payload.tableId === t1.id)!; const recs = Array.isArray(t1Event.payload.record) ? t1Event.payload.record diff --git a/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts b/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts index b917f242c9..5d15663950 100644 --- a/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts +++ b/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts @@ -1,6 +1,7 @@ import type { INestApplication } from '@nestjs/common'; import type { IFieldRo, IFieldVo, ILinkFieldOptionsRo, ILookupOptionsRo } from '@teable/core'; import { FieldKeyType, FieldType, Relationship, Role } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; import { deleteSpaceCollaborator, emailSpaceInvitation, @@ -38,16 +39,24 @@ import { describe('Computed user field (e2e)', () => { let app: INestApplication; let v2ContainerService: V2ContainerService; + let prisma: PrismaService; const spaceId = globalThis.testConfig.spaceId; const userName = globalThis.testConfig.userName; const isForceV2 = process.env.FORCE_V2_ALL === 'true'; + let isV2Mode = isForceV2; let baseId: string; beforeAll(async () => { const appCtx = await initApp(); app = appCtx.app; v2ContainerService = app.get(V2ContainerService); + prisma = app.get(PrismaService); const base = await createBase({ name: 'base1', spaceId }); baseId = base.id; + const baseMeta = await prisma.base.findUnique({ + where: { id: baseId }, + select: { v2Enabled: true }, + }); + isV2Mode = isForceV2 || Boolean(baseMeta?.v2Enabled); }); afterAll(async () => { @@ -56,7 +65,7 @@ describe('Computed user field (e2e)', () => { }); async function processV2Outbox(): Promise { - if (!isForceV2) return; + if (!isV2Mode) return; const container = await v2ContainerService.getContainer(); const drainService = container.resolve( @@ -132,7 +141,7 @@ describe('Computed user field (e2e)', () => { title: userName, }); - if (isForceV2) { + if (isV2Mode) { expect(records.data.records[1].fields[lastModifiedByField.id]).toMatchObject({ title: userName, }); @@ -191,7 +200,7 @@ describe('Computed user field (e2e)', () => { expect(records.data.records[0].fields[formulaField.id]).toEqual(userName); - if (isForceV2) { + if (isV2Mode) { expect(records.data.records[1].fields[lastModifiedByField.id]).toMatchObject({ title: userName, }); @@ -255,7 +264,7 @@ describe('Computed user field (e2e)', () => { records.data.records[0].lastModifiedTime ); - if (isForceV2) { + if (isV2Mode) { expect(records.data.records[1].fields[lastModifiedTimeField.id]).toEqual( records.data.records[1].lastModifiedTime ); @@ -317,7 +326,7 @@ describe('Computed user field (e2e)', () => { }); let record = await getRecord(table1.id, recordId, { fieldKeyType: FieldKeyType.Id }); - if (isForceV2) { + if (isV2Mode) { expect(record.data.fields[lastModifiedByField.id]).toMatchObject({ id: globalThis.testConfig.userId, title: globalThis.testConfig.userName, @@ -371,7 +380,7 @@ describe('Computed user field (e2e)', () => { }); let record = await getRecord(table1.id, recordId, { fieldKeyType: FieldKeyType.Id }); - if (isForceV2) { + if (isV2Mode) { expect(record.data.fields[lastModifiedByField.id]).toMatchObject({ id: globalThis.testConfig.userId, title: globalThis.testConfig.userName, diff --git a/apps/nestjs-backend/test/field-converting.e2e-spec.ts b/apps/nestjs-backend/test/field-converting.e2e-spec.ts index a5990a4d75..7916738bba 100644 --- a/apps/nestjs-backend/test/field-converting.e2e-spec.ts +++ b/apps/nestjs-backend/test/field-converting.e2e-spec.ts @@ -848,6 +848,32 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => { await expect(convertField(table1.id, table1.fields[0].id, newFieldRo)).rejects.toThrow(); }); + it('should not convert primary field to a lookup field (T3367)', async () => { + const linkFieldRo: IFieldRo = { + name: 'link', + type: FieldType.Link, + options: { + relationship: Relationship.ManyOne, + foreignTableId: table2.id, + }, + }; + const linkField = await createField(table1.id, linkFieldRo); + + const toLookupRo: IFieldRo = { + type: FieldType.SingleLineText, + isLookup: true, + lookupOptions: { + foreignTableId: table2.id, + lookupFieldId: table2.fields[0].id, + linkFieldId: linkField.id, + }, + }; + + await expect(convertField(table1.id, table1.fields[0].id, toLookupRo)).rejects.toThrow( + /primary/i + ); + }); + it('should convert text to date', async () => { const newFieldRo: IFieldRo = { type: FieldType.Date, diff --git a/apps/nestjs-backend/test/field-duplicate.e2e-spec.ts b/apps/nestjs-backend/test/field-duplicate.e2e-spec.ts index d501b5434b..30673deb5f 100644 --- a/apps/nestjs-backend/test/field-duplicate.e2e-spec.ts +++ b/apps/nestjs-backend/test/field-duplicate.e2e-spec.ts @@ -905,6 +905,158 @@ describe('OpenAPI FieldOpenApiController for duplicate field (e2e)', () => { }); }); + describe('duplicate filtered lookup fields that target conditional lookups', () => { + let issuesTable: ITableFullVo; + let releasesTable: ITableFullVo; + let launchesTable: ITableFullVo; + let originalForceV2All: string | undefined; + let issueTitleFieldId: string; + let issuePrFieldId: string; + let releasePrFieldId: string; + let releaseEditionFieldId: string; + let issuesTitleLookupFieldId: string; + let relatedReleasesFieldId: string; + let releaseIssuesFieldId: string; + + beforeAll(async () => { + originalForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'false'; + + issuesTable = await createTable(baseId, { + name: 'duplicate_nested_lookup_issues', + fields: [ + { name: 'Title', type: FieldType.SingleLineText }, + { name: 'PR', type: FieldType.SingleLineText }, + ], + }); + + const issueFields = (await getFields(issuesTable.id)).data; + issueTitleFieldId = issueFields.find((field) => field.name === 'Title')!.id; + issuePrFieldId = issueFields.find((field) => field.name === 'PR')!.id; + + releasesTable = await createTable(baseId, { + name: 'duplicate_nested_lookup_releases', + fields: [ + { name: 'Tag', type: FieldType.SingleLineText }, + { name: 'PR', type: FieldType.SingleLineText }, + { + name: 'Edition', + type: FieldType.SingleSelect, + options: { + choices: [{ name: 'cloud' }, { name: 'ee' }], + }, + }, + ], + }); + + const releaseFields = (await getFields(releasesTable.id)).data; + releasePrFieldId = releaseFields.find((field) => field.name === 'PR')!.id; + releaseEditionFieldId = releaseFields.find((field) => field.name === 'Edition')!.id; + + issuesTitleLookupFieldId = ( + await createField(releasesTable.id, { + name: 'Issues title', + type: FieldType.SingleLineText, + isLookup: true, + isConditionalLookup: true, + lookupOptions: { + foreignTableId: issuesTable.id, + lookupFieldId: issueTitleFieldId, + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: issuePrFieldId, + operator: 'is', + value: { + type: 'field', + fieldId: releasePrFieldId, + tableId: releasesTable.id, + }, + }, + ], + }, + }, + }) + ).data.id; + + launchesTable = await createTable(baseId, { + name: 'duplicate_nested_lookup_launches', + fields: [{ name: 'Launch', type: FieldType.SingleLineText }], + }); + + relatedReleasesFieldId = ( + await createField(launchesTable.id, { + name: 'Related Releases', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: releasesTable.id, + isOneWay: false, + }, + }) + ).data.id; + + releaseIssuesFieldId = ( + await createField(launchesTable.id, { + name: 'Release Issues', + type: FieldType.SingleLineText, + isLookup: true, + lookupOptions: { + foreignTableId: releasesTable.id, + linkFieldId: relatedReleasesFieldId, + lookupFieldId: issuesTitleLookupFieldId, + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: releaseEditionFieldId, + operator: 'is', + value: 'cloud', + }, + ], + }, + }, + }) + ).data.id; + + process.env.FORCE_V2_ALL = originalForceV2All; + }); + + afterAll(async () => { + process.env.FORCE_V2_ALL = originalForceV2All; + await permanentDeleteTable(baseId, launchesTable.id); + await permanentDeleteTable(baseId, releasesTable.id); + await permanentDeleteTable(baseId, issuesTable.id); + }); + + it('should duplicate filtered lookups whose source field is conditional lookup', async () => { + const duplicated = ( + await duplicateField(launchesTable.id, releaseIssuesFieldId, { + name: 'Release Issues Copy', + }) + ).data; + + expect(duplicated.isLookup).toBe(true); + expect(duplicated.isConditionalLookup).not.toBe(true); + expect(duplicated.lookupOptions).toMatchObject({ + foreignTableId: releasesTable.id, + linkFieldId: relatedReleasesFieldId, + lookupFieldId: issuesTitleLookupFieldId, + filter: { + conjunction: 'and', + filterSet: [ + expect.objectContaining({ + fieldId: releaseEditionFieldId, + operator: 'is', + value: 'cloud', + }), + ], + }, + }); + }); + }); + describe('duplicate rollup fields', () => { let table: ITableFullVo; let subTable: ITableFullVo; diff --git a/apps/nestjs-backend/test/formula.e2e-spec.ts b/apps/nestjs-backend/test/formula.e2e-spec.ts index e8eed84a34..26ac3825b9 100644 --- a/apps/nestjs-backend/test/formula.e2e-spec.ts +++ b/apps/nestjs-backend/test/formula.e2e-spec.ts @@ -19,7 +19,13 @@ import { Relationship, TimeFormatting, } from '@teable/core'; -import { getRecord, updateRecords, type ITableFullVo } from '@teable/openapi'; +import { + X_CANARY_HEADER, + axios, + getRecord, + updateRecords, + type ITableFullVo, +} from '@teable/openapi'; import { createField, createFields, @@ -692,6 +698,69 @@ describe('OpenAPI formula (e2e)', () => { expect(clearedRecord.fields[equalsEmptyField.name]).toEqual(1); }); + const expectBlankSpacingComparison = async (canaryHeader: 'true' | 'false') => { + const previousCanaryHeader = axios.defaults.headers.common[X_CANARY_HEADER]; + axios.defaults.headers.common[X_CANARY_HEADER] = canaryHeader; + + try { + const localizedNumberField = await createField(table1Id, { + id: generateFieldId(), + name: 'å…„čŒä½“é‡(kg)', + type: FieldType.Number, + options: { + formatting: { type: NumberFormattingType.Decimal, precision: 0 }, + }, + }); + + const compactBlankField = await createField(table1Id, { + id: generateFieldId(), + name: 'blank-compact', + type: FieldType.Formula, + options: { + expression: `{${localizedNumberField.id}} !=BLANK()`, + }, + }); + + const spacedBlankField = await createField(table1Id, { + id: generateFieldId(), + name: 'blank-spaced', + type: FieldType.Formula, + options: { + expression: `{${localizedNumberField.id}} != BLANK()`, + }, + }); + + const { records } = await createRecords(table1Id, { + fieldKeyType: FieldKeyType.Name, + records: [ + { + fields: { + [localizedNumberField.name]: 70, + }, + }, + ], + }); + + const { data: record } = await getRecord(table1Id, records[0].id); + expect(record.fields?.[compactBlankField.name]).toBe(true); + expect(record.fields?.[spacedBlankField.name]).toBe(true); + } finally { + if (previousCanaryHeader == null) { + delete axios.defaults.headers.common[X_CANARY_HEADER]; + } else { + axios.defaults.headers.common[X_CANARY_HEADER] = previousCanaryHeader; + } + } + }; + + it('should keep BLANK() comparisons stable with spaced function calls in v1 mode', async () => { + await expectBlankSpacingComparison('false'); + }); + + it('should keep BLANK() comparisons stable with spaced function calls in canary mode', async () => { + await expectBlankSpacingComparison('true'); + }); + it('should calculate formula containing question mark literal', async () => { const urlFormulaField = await createField(table1Id, { name: 'url formula', diff --git a/apps/nestjs-backend/test/integrity.e2e-spec.ts b/apps/nestjs-backend/test/integrity.e2e-spec.ts index 1461a678eb..c8e96ba8a2 100644 --- a/apps/nestjs-backend/test/integrity.e2e-spec.ts +++ b/apps/nestjs-backend/test/integrity.e2e-spec.ts @@ -1079,4 +1079,172 @@ describe('OpenAPI integrity (e2e)', () => { expect(integrity3.data.hasIssues).toEqual(false); }); }); + + describe('fix invalid primary', () => { + let baseId1: string; + let base1table: ITableFullVo; + + beforeEach(async () => { + baseId1 = (await createBase({ spaceId, name: 'base1' })).data.id; + base1table = await createTable(baseId1, { name: 'base1table' }); + }); + + afterEach(async () => { + await permanentDeleteTable(baseId1, base1table.id); + await deleteBase(baseId1); + }); + + it('detects and fixes a primary field with unsupported type by promoting existing candidate', async () => { + // Inject the corrupt state directly: bulk paths historically allowed isPrimary=true on a + // checkbox field. Real-world examples in prod were created by AI / .tea import bypassing + // the source guards we added. + const originalPrimaryId = base1table.fields.find((f) => f.isPrimary)!.id; + await prisma.txClient().field.update({ + where: { id: originalPrimaryId }, + data: { isPrimary: null }, + }); + const checkboxField = await createField(base1table.id, { + name: 'broken primary', + type: FieldType.Checkbox, + }); + await prisma.txClient().field.update({ + where: { id: checkboxField.id }, + data: { isPrimary: true }, + }); + + const integrity = await checkBaseIntegrity(baseId1, base1table.id); + const issues = integrity.data.linkFieldIssues.flatMap((i) => i.issues); + expect(issues.some((i) => i.type === IntegrityIssueType.InvalidPrimaryType)).toEqual(true); + + await fixBaseIntegrity(baseId1, base1table.id); + + const integrity2 = await checkBaseIntegrity(baseId1, base1table.id); + expect(integrity2.data.hasIssues).toEqual(false); + + // Promote-existing path: the demoted original SingleLineText is the first eligible + // candidate by order, so it gets re-promoted. No new formula field is created. + const primaries = await prisma.txClient().field.findMany({ + where: { tableId: base1table.id, isPrimary: true, deletedTime: null }, + select: { id: true }, + }); + expect(primaries).toHaveLength(1); + expect(primaries[0].id).toEqual(originalPrimaryId); + + // Bad checkbox is demoted but NOT renamed — rename only happens in the formula + // fallback path where the new field needs to take the original name. + const checkboxAfter = await prisma.txClient().field.findFirst({ + where: { id: checkboxField.id }, + select: { isPrimary: true, name: true }, + }); + expect(checkboxAfter?.isPrimary).toBeNull(); + expect(checkboxAfter?.name).toEqual('broken primary'); + }); + + it('falls back to a new formula primary when no eligible candidate exists', async () => { + // Make every non-bad field ineligible so promote-existing has nothing to promote, then + // verify the formula fallback path runs and mirrors the bad primary's value. + const originalPrimary = base1table.fields.find((f) => f.isPrimary)!; + const otherFields = base1table.fields.filter((f) => !f.isPrimary); + + // Mutate every non-primary field to attachment (not in PRIMARY_SUPPORTED_TYPES) so + // they can't be promoted. + for (const field of otherFields) { + await prisma.txClient().field.update({ + where: { id: field.id }, + data: { type: FieldType.Attachment }, + }); + } + + // Replace the original SingleLineText primary with a checkbox bad primary. + await prisma.txClient().field.update({ + where: { id: originalPrimary.id }, + data: { isPrimary: null, type: FieldType.Attachment }, + }); + const checkboxField = await createField(base1table.id, { + name: 'broken primary', + type: FieldType.Checkbox, + }); + await prisma.txClient().field.update({ + where: { id: checkboxField.id }, + data: { isPrimary: true }, + }); + + await fixBaseIntegrity(baseId1, base1table.id); + + const primaries = await prisma.txClient().field.findMany({ + where: { tableId: base1table.id, isPrimary: true, deletedTime: null }, + select: { id: true, type: true, name: true, options: true }, + }); + expect(primaries).toHaveLength(1); + expect(primaries[0].type).toEqual(FieldType.Formula); + expect(primaries[0].name).toEqual('broken primary'); + // Formula expression references the old field id so the displayed value stays continuous. + expect(primaries[0].options).toContain(checkboxField.id); + }); + + it('preserves existing valid primary when an extra invalid primary is fixed', async () => { + // Defensive scenario: a valid primary already exists, but somehow a second isPrimary=true + // field with bad type sneaks in (race / direct SQL / future regression). + // Fix should demote the invalid one and keep the valid primary as-is, without + // creating a third "formula" primary. + const validPrimaryId = base1table.fields.find((f) => f.isPrimary)!.id; + + const checkboxField = await createField(base1table.id, { + name: 'rogue checkbox primary', + type: FieldType.Checkbox, + }); + await prisma.txClient().field.update({ + where: { id: checkboxField.id }, + data: { isPrimary: true }, + }); + + const before = await prisma.txClient().field.findMany({ + where: { tableId: base1table.id, isPrimary: true, deletedTime: null }, + }); + expect(before).toHaveLength(2); + + await fixBaseIntegrity(baseId1, base1table.id); + + const after = await prisma.txClient().field.findMany({ + where: { tableId: base1table.id, isPrimary: true, deletedTime: null }, + select: { id: true, type: true }, + }); + expect(after).toHaveLength(1); + expect(after[0].id).toEqual(validPrimaryId); + + const checkboxAfter = await prisma.txClient().field.findFirst({ + where: { id: checkboxField.id }, + select: { isPrimary: true, name: true }, + }); + expect(checkboxAfter?.isPrimary).toBeNull(); + // Kept-existing path: bad primary is demoted but NOT renamed. + expect(checkboxAfter?.name).toEqual('rogue checkbox primary'); + }); + + it('detects and fixes a table missing its primary field by promoting existing candidate', async () => { + const primary = base1table.fields.find((f) => f.isPrimary)!; + await prisma.txClient().field.update({ + where: { id: primary.id }, + data: { isPrimary: null }, + }); + + const integrity = await checkBaseIntegrity(baseId1, base1table.id); + const issues = integrity.data.linkFieldIssues.flatMap((i) => i.issues); + expect(issues.some((i) => i.type === IntegrityIssueType.MissingPrimary)).toEqual(true); + + await fixBaseIntegrity(baseId1, base1table.id); + + const integrity2 = await checkBaseIntegrity(baseId1, base1table.id); + expect(integrity2.data.hasIssues).toEqual(false); + + // Should re-promote the existing primary, not create a duplicate "Name 2". + const primaries = await prisma.txClient().field.findMany({ + where: { tableId: base1table.id, isPrimary: true, deletedTime: null }, + select: { id: true, name: true }, + }); + expect(primaries).toHaveLength(1); + expect(primaries[0].id).toEqual(primary.id); + expect(primaries[0].name).toEqual(primary.name); + }); + }); }); diff --git a/apps/nestjs-backend/test/record-filter-query.e2e-spec.ts b/apps/nestjs-backend/test/record-filter-query.e2e-spec.ts index 7748f50604..21ff2a5894 100644 --- a/apps/nestjs-backend/test/record-filter-query.e2e-spec.ts +++ b/apps/nestjs-backend/test/record-filter-query.e2e-spec.ts @@ -157,8 +157,8 @@ describe('OpenAPI Record-Filter-Query (e2e)', () => { test.each(MULTIPLE_SELECT_FIELD_CASES)(testDesc, async (param) => doTest(table, param)); }); - describe('dateRange filter error cases', () => { - it('should throw error when start > end (invalid range)', async () => { + describe('dateRange invalid filters are skipped instead of crashing the query', () => { + it('skips when start > end (compiler-level validation)', async () => { const { fieldIndex, operator, queryValue } = DATE_RANGE_ERROR_CASES.invalidRange; const filter: IFilter = { filterSet: [ @@ -170,10 +170,11 @@ describe('OpenAPI Record-Filter-Query (e2e)', () => { ], conjunction: and.value, }; - await expect(getFilterRecord(table.id, table.views[0].id, filter)).rejects.toThrow(); + const result = await getFilterRecord(table.id, table.views[0].id, filter); + expect(result.records.length).toBeGreaterThan(0); }); - it('should throw error when dateRange is used with isNot operator', async () => { + it('skips when dateRange is used with isNot operator (analyzer-level validation)', async () => { const { fieldIndex, operator, queryValue } = DATE_RANGE_ERROR_CASES.invalidOperator; const filter: IFilter = { filterSet: [ @@ -185,7 +186,8 @@ describe('OpenAPI Record-Filter-Query (e2e)', () => { ], conjunction: and.value, }; - await expect(getFilterRecord(table.id, table.views[0].id, filter)).rejects.toThrow(); + const result = await getFilterRecord(table.id, table.views[0].id, filter); + expect(result.records.length).toBeGreaterThan(0); }); }); }); diff --git a/apps/nestjs-backend/test/record-search-query.e2e-spec.ts b/apps/nestjs-backend/test/record-search-query.e2e-spec.ts index 62918c88fd..a0f65a530a 100644 --- a/apps/nestjs-backend/test/record-search-query.e2e-spec.ts +++ b/apps/nestjs-backend/test/record-search-query.e2e-spec.ts @@ -927,35 +927,6 @@ describe('OpenAPI Record-Search-Query (e2e)', async () => { }); }); - it('should search date fields globally when search index is enabled', async () => { - await toggleTableIndex(baseId, table.id, { type: TableIndex.search }); - - const dateField = table.fields.find( - (f) => f.cellValueType === CellValueType.DateTime - )! as IFieldInstance; - - const { records } = ( - await apiGetRecords(table.id, { - fieldKeyType: FieldKeyType.Id, - viewId: table.views[0].id, - search: ['2022-03-02', '', true], - }) - ).data; - - expect(records.length).toBe(1); - expect(records[0].fields[dateField.id]).toBe('2022-03-01T16:00:00.000Z'); - - const searchIndex = await getSearchIndex(table.id, { - viewId: table.views[0].id, - take: 10, - search: ['2022-03-02', '', true], - }); - - expect(searchIndex.data).toEqual( - expect.arrayContaining([expect.objectContaining({ fieldId: dateField.id })]) - ); - }); - it('should repair abnormal index', async () => { const textfield = table.fields.find( (f) => f.cellValueType === CellValueType.String diff --git a/apps/nestjs-backend/test/record-typecast.e2e-spec.ts b/apps/nestjs-backend/test/record-typecast.e2e-spec.ts index accdf62ec8..6c900ace3b 100644 --- a/apps/nestjs-backend/test/record-typecast.e2e-spec.ts +++ b/apps/nestjs-backend/test/record-typecast.e2e-spec.ts @@ -279,7 +279,8 @@ describe('Record Typecast', () => { typecast: true, }).then((res) => res.data); - expect(record.fields[table.fields[1].id]).toBeUndefined(); + const emptySelectValue = record.fields[table.fields[1].id]; + expect(emptySelectValue === null || emptySelectValue === undefined).toBe(true); }); }); }); diff --git a/apps/nestjs-backend/test/table.e2e-spec.ts b/apps/nestjs-backend/test/table.e2e-spec.ts index 019afa2b2c..5388ef0d74 100644 --- a/apps/nestjs-backend/test/table.e2e-spec.ts +++ b/apps/nestjs-backend/test/table.e2e-spec.ts @@ -375,6 +375,20 @@ describe('OpenAPI TableController (e2e)', () => { expect(fields[2].type).toEqual(FieldType.LongText); }); + it('should reject createTable when first field has unsupported primary type', async () => { + // Without the fix, the service would auto-promote a checkbox first field to primary + // (bypassing prepareCreateFields validation), persisting a bad-type primary. + await expect( + createTable(baseId, { + name: 'bad primary table', + fields: [ + { name: 'Done', type: FieldType.Checkbox }, + { name: 'Note', type: FieldType.SingleLineText }, + ], + }) + ).rejects.toThrow(/primary/i); + }); + it('should update table simple properties', async () => { const result = await createTable(baseId, { name: 'table', diff --git a/apps/nestjs-backend/test/trash.e2e-spec.ts b/apps/nestjs-backend/test/trash.e2e-spec.ts index 1638299e70..72ecf990ee 100644 --- a/apps/nestjs-backend/test/trash.e2e-spec.ts +++ b/apps/nestjs-backend/test/trash.e2e-spec.ts @@ -1,6 +1,7 @@ /* eslint-disable sonarjs/no-duplicate-string */ import type { INestApplication } from '@nestjs/common'; import { FieldType, Relationship } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; import type { ITrashItemVo } from '@teable/openapi'; import { getTrash, @@ -43,18 +44,32 @@ const waitForBaseTrashItems = async (baseId: string, expectedCount = 1, maxRetri describe('Trash (e2e)', () => { let app: INestApplication; let eventEmitterService: EventEmitterService; + let prisma: PrismaService; let awaitWithSpaceEvent: (fn: () => Promise) => Promise; let awaitWithBaseEvent: (fn: () => Promise) => Promise; let awaitWithTableEvent: (fn: () => Promise) => Promise; - const awaitWithTableDeleteSync = async (fn: () => Promise) => - isForceV2 ? await fn() : awaitWithTableEvent(fn); + const isBaseV2Mode = async (baseId: string) => { + if (isForceV2) { + return true; + } + + const base = await prisma.base.findUnique({ + where: { id: baseId }, + select: { v2Enabled: true }, + }); + return Boolean(base?.v2Enabled); + }; + + const awaitWithTableDeleteSync = async (baseId: string, fn: () => Promise) => + (await isBaseV2Mode(baseId)) ? await fn() : awaitWithTableEvent(fn); beforeAll(async () => { const appCtx = await initApp(); app = appCtx.app; eventEmitterService = app.get(EventEmitterService); + prisma = app.get(PrismaService); awaitWithSpaceEvent = createAwaitWithEvent(eventEmitterService, Events.SPACE_DELETE); awaitWithBaseEvent = createAwaitWithEvent(eventEmitterService, Events.BASE_DELETE); @@ -100,7 +115,7 @@ describe('Trash (e2e)', () => { it('should retrieve trash items for base when a table is deleted', async () => { const tableId = (await createTable(baseId, {})).id; - await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId)); + await awaitWithTableDeleteSync(baseId, () => deleteTable(baseId, tableId)); const res = await waitForBaseTrashItems(baseId, 1); @@ -120,7 +135,7 @@ describe('Trash (e2e)', () => { }, }); - await awaitWithTableDeleteSync(() => deleteTable(baseId, foreignTableId)); + await awaitWithTableDeleteSync(baseId, () => deleteTable(baseId, foreignTableId)); const res = await waitForBaseTrashItems(baseId, 1); @@ -167,7 +182,7 @@ describe('Trash (e2e)', () => { }); it('should restore table successfully', async () => { - await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId)); + await awaitWithTableDeleteSync(baseId, () => deleteTable(baseId, tableId)); const trash = (await waitForBaseTrashItems(baseId, 1)).data; const restored = await restoreTrash(trash.trashItems[0].id); @@ -176,15 +191,27 @@ describe('Trash (e2e)', () => { }); it('should expose restore-table canary headers when restoring a table trash item', async () => { - await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId)); + await awaitWithTableDeleteSync(baseId, () => deleteTable(baseId, tableId)); const trash = (await waitForBaseTrashItems(baseId, 1)).data; - const restored = await restoreTrash(trash.trashItems[0].id); + const previousForceV2All = process.env.FORCE_V2_ALL; + const restored = await (async () => { + process.env.FORCE_V2_ALL = 'true'; + try { + return await restoreTrash(trash.trashItems[0].id); + } finally { + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + } + })(); expect(restored.status).toEqual(201); - expect(restored.headers['x-teable-v2']).toBe(isForceV2 ? 'true' : 'false'); + expect(restored.headers['x-teable-v2']).toBe('true'); expect(restored.headers['x-teable-v2-feature']).toBe('restoreTable'); - expect(restored.headers['x-teable-v2-reason']).toBeTruthy(); + expect(restored.headers['x-teable-v2-reason']).toBe('new_base'); }); }); @@ -210,9 +237,9 @@ describe('Trash (e2e)', () => { const tableId2 = (await createTable(baseId, {})).id; const tableId3 = (await createTable(baseId, {})).id; - await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId1)); - await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId2)); - await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId3)); + await awaitWithTableDeleteSync(baseId, () => deleteTable(baseId, tableId1)); + await awaitWithTableDeleteSync(baseId, () => deleteTable(baseId, tableId2)); + await awaitWithTableDeleteSync(baseId, () => deleteTable(baseId, tableId3)); const trash = (await waitForBaseTrashItems(baseId, 3)).data; diff --git a/apps/nestjs-backend/test/undo-redo.e2e-spec.ts b/apps/nestjs-backend/test/undo-redo.e2e-spec.ts index 5f7dc12ebb..245856727b 100644 --- a/apps/nestjs-backend/test/undo-redo.e2e-spec.ts +++ b/apps/nestjs-backend/test/undo-redo.e2e-spec.ts @@ -72,6 +72,23 @@ const waitForTableTrashCount = async (tableId: string, expectedCount: number, ma return await getTrashItems({ resourceId: tableId, resourceType: ResourceType.Table }); }; +const waitForViewVisibility = async ( + tableId: string, + viewId: string, + visible: boolean, + maxRetries = 100 +) => { + for (let i = 0; i < maxRetries; i++) { + const views = (await getViewList(tableId)).data; + const view = views.find((v) => v.id === viewId); + if (Boolean(view) === visible) { + return view; + } + await sleep(100); + } + + return (await getViewList(tableId)).data.find((v) => v.id === viewId); +}; describe('Undo Redo (e2e)', () => { let app: INestApplication; @@ -1254,8 +1271,7 @@ describe('Undo Redo (e2e)', () => { await undo(table.id); - const viewsAfterUndo = (await getViewList(table.id)).data; - expect(viewsAfterUndo.find((v) => v.id === view.id)).toMatchObject({ + expect(await waitForViewVisibility(table.id, view.id, true)).toMatchObject({ id: view.id, name: view.name, type: view.type, @@ -1263,8 +1279,7 @@ describe('Undo Redo (e2e)', () => { await redo(table.id); - const viewsAfterRedo = (await getViewList(table.id)).data; - expect(viewsAfterRedo.find((v) => v.id === view.id)).toBeUndefined(); + expect(await waitForViewVisibility(table.id, view.id, false)).toBeUndefined(); }); it('should undo / redo update view property', async () => { diff --git a/apps/nextjs-app/src/components/changelog/ChangelogNotification.tsx b/apps/nextjs-app/src/components/changelog/ChangelogNotification.tsx index 453b1c105e..6d32e10e7f 100644 --- a/apps/nextjs-app/src/components/changelog/ChangelogNotification.tsx +++ b/apps/nextjs-app/src/components/changelog/ChangelogNotification.tsx @@ -1,6 +1,6 @@ import { ArrowUpRight, X } from '@teable/icons'; import { LocalStorageKeys } from '@teable/sdk/config'; -import { useIsHydrated, useShareId } from '@teable/sdk/hooks'; +import { useIsHydrated, useIsReadOnlyPreview } from '@teable/sdk/hooks'; import { Button } from '@teable/ui-lib/shadcn'; import { Rocket } from 'lucide-react'; import { useTranslation } from 'next-i18next'; @@ -11,7 +11,7 @@ export const ChangelogNotification = () => { const { t } = useTranslation('common'); const isHydrated = useIsHydrated(); const isCloud = useIsCloud(); - const shareId = useShareId(); + const isReadOnlyPreview = useIsReadOnlyPreview(); const [visible, setVisible] = useState(false); const changelogId = t('changelog.id'); @@ -39,7 +39,7 @@ export const ChangelogNotification = () => { } }, [changelogId]); - if (!isCloud || !isHydrated || !visible || shareId) { + if (!isCloud || !isHydrated || !visible || isReadOnlyPreview) { return null; } diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx index e18804e356..91a3b49afa 100644 --- a/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx @@ -30,10 +30,11 @@ import { scrollToTarget } from './utils'; export interface ISettingPageProps { settingServerData?: ISettingVo; rewardManage?: React.ReactNode; + canarySettings?: React.ReactNode; } export const SettingPage = (props: ISettingPageProps) => { - const { settingServerData, rewardManage } = props; + const { settingServerData, rewardManage, canarySettings } = props; const queryClient = useQueryClient(); const { t } = useTranslation('common'); @@ -283,7 +284,7 @@ export const SettingPage = (props: ISettingPageProps) => { {rewardManage} - + {canarySettings ?? } {/* email config */}
diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx index b3d681406e..40d65de775 100644 --- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx +++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx @@ -186,6 +186,7 @@ export const BaseNodeTree = (props: IBaseNodeTreeProps) => { const draggedItemsRef = useRef[]>([]); const treeItemsRef = useRef(treeItems); const viewportRef = useRef(null); + const focusedNodeIdRef = useRef(null); const [selectedItems, setSelectedItems] = useState([]); const [expandedItemsMap, setExpandedItemsMap] = useLocalStorage>( LocalStorageKeys.BaseNodeTreeExpandedItems, @@ -474,12 +475,18 @@ export const BaseNodeTree = (props: IBaseNodeTreeProps) => { ]); useEffect(() => { - if (selectedItems.length === 0) return; if (Object.keys(treeItems).length === 0) return; - const focusItem = tree.getItemInstance(selectedItems[0]); + if (selectedItems.length === 0) { + focusedNodeIdRef.current = null; + return; + } + const currentId = selectedItems[0]; + if (focusedNodeIdRef.current === currentId) return; + const focusItem = tree.getItemInstance(currentId); if (focusItem) { focusItem.setFocused(); focusItem.scrollTo({ block: 'nearest', inline: 'nearest' }); + focusedNodeIdRef.current = currentId; } }, [selectedItems, tree, treeItems]); diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSidebarHeaderLeft.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSidebarHeaderLeft.tsx index 6583b06246..79411c92f1 100644 --- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSidebarHeaderLeft.tsx +++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSidebarHeaderLeft.tsx @@ -6,6 +6,7 @@ import { ReactQueryKeys } from '@teable/sdk/config'; import { useBase } from '@teable/sdk/hooks'; import { useIsReadOnlyPreview } from '@teable/sdk/hooks/use-is-readonly-preview'; import { + Badge, cn, DropdownMenu, DropdownMenuItem, @@ -40,6 +41,7 @@ const BaseDropdownMenu = ({ collaboratorType, currentBaseId, baseName, + isCanary, isBaseShared, disabled, }: { @@ -52,6 +54,7 @@ const BaseDropdownMenu = ({ collaboratorType?: CollaboratorType; currentBaseId: string; baseName: string; + isCanary?: boolean; isBaseShared: boolean; disabled?: boolean; }) => { @@ -82,14 +85,22 @@ const BaseDropdownMenu = ({ {children} e.stopPropagation()} > + {!isCanary && ( + + v1 + + )} -
+
{t('common:actions.backToSpace')}
@@ -303,12 +314,13 @@ export const BaseSidebarHeaderLeft = ({ creditUsage }: { creditUsage?: React.Rea collaboratorType={base.collaboratorType} currentBaseId={base.id} baseName={base.name} + isCanary={base.isCanary} isBaseShared={isBaseShared} disabled={isReadOnlyPreview} >
{ setOpen(false); setting.setOpen(true); }} - value={t('common:settings.personal.title')} - keywords={[t('common:settings.personal.title')]} + value={t('common:settings.allSetting')} + keywords={[t('common:settings.allSetting')]} >
- {t('common:settings.personal.title')} + {t('common:settings.allSetting')} )} diff --git a/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Components.tsx b/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Components.tsx index 18f517c5c9..9700e029b7 100644 --- a/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Components.tsx +++ b/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Components.tsx @@ -37,6 +37,7 @@ import { CheckCircle2, Clock, Columns3, + ExternalLink, Info, Loader2, RefreshCcw, @@ -44,6 +45,7 @@ import { Wrench, XCircle, } from 'lucide-react'; +import Link from 'next/link'; import { useTranslation } from 'next-i18next'; import { useEffect, useMemo, useState, type ComponentType } from 'react'; import { @@ -227,6 +229,14 @@ const inferFieldTypeFromGroup = (group: ResultGroup) => { type ManualRepairSchema = NonNullable['manualRepairSchema']>; type ManualRepairProperty = ManualRepairSchema['properties'][string]; type ManualRepairValues = Record; +type RepairRuleHandler = ( + result: IntegrityResult, + manualRepairValues?: ManualRepairValues +) => Promise; +type RepairRulePreviewHandler = ( + result: IntegrityResult, + manualRepairValues?: ManualRepairValues +) => Promise; const getManualRepairDefaultValues = (manualRepairSchema?: ManualRepairSchema) => { return Object.fromEntries( @@ -436,49 +446,301 @@ const ManualRepairDialog = ({ ); }; +const getPreviewResults = (results: IntegrityResult[]) => + results.filter((result) => result.status !== 'running' && result.status !== 'pending'); + +const canPreviewRepairResult = (result: IntegrityResult) => + result.status === 'error' || result.status === 'warn' || result.status === 'skipped'; + +const getRuleRepairTooltipText = ( + t: Translate, + result: IntegrityResult, + reason?: string, + description?: string +) => + reason || + description || + (result.repair + ? t('table:table.integrity.v2.repairPreviewDescription') + : t('table:table.integrity.v2.repairUnavailable')); + +const formatRepairParameters = (parameters?: ReadonlyArray) => { + if (!parameters?.length) { + return undefined; + } + + return JSON.stringify(parameters, null, 2); +}; + +const RepairRulePreviewDialog = ({ + result, + open, + dryRunResults, + isSubmitting, + canConfirm, + onOpenChange, + onConfirm, +}: { + result: IntegrityResult; + open: boolean; + dryRunResults: IntegrityResult[]; + isSubmitting: boolean; + canConfirm: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}) => { + const { t } = useTranslation(['table', 'common']); + const previewResults = getPreviewResults(dryRunResults); + const displayResults = previewResults.length ? previewResults : [result]; + const statements = displayResults.flatMap( + (previewResult) => previewResult.details?.statements || [] + ); + const canExecute = canConfirm && statements.length > 0; + + return ( + + + + {t('table:table.integrity.v2.repairPreviewTitle')} + + {t('table:table.integrity.v2.repairPreviewDescription')} + + + +
+ {displayResults.map((previewResult) => { + const localizedMessage = getLocalizedResultMessage(t as Translate, previewResult); + const localizedRuleName = getLocalizedRuleDescription(t as Translate, previewResult); + const repairReason = getLocalizedRepairReason(t as Translate, previewResult); + const repairDescription = getLocalizedRepairDescription(t as Translate, previewResult); + const localizedMissing = getLocalizedDetailItems( + t as Translate, + previewResult.details?.missingItems || previewResult.details?.missing + ); + const localizedExtra = getLocalizedDetailItems( + t as Translate, + previewResult.details?.extraItems || previewResult.details?.extra + ); + + return ( +
+
+ + {localizedRuleName} + +
+ +
+
+
+ {t('table:table.integrity.v2.repairPreviewWhat')} +
+
+ {t('table:table.integrity.v2.repairPreviewTarget', { + fieldName: previewResult.fieldName, + ruleName: localizedRuleName, + })} +
+ {localizedMessage ? ( +
{localizedMessage}
+ ) : null} + {localizedMissing?.length ? ( +
+ {t('table:table.integrity.v2.detailsMissing', { + details: localizedMissing.join(', '), + })} +
+ ) : null} + {localizedExtra?.length ? ( +
+ {t('table:table.integrity.v2.detailsExtra', { + details: localizedExtra.join(', '), + })} +
+ ) : null} +
+ +
+
+ {t('table:table.integrity.v2.repairPreviewPrinciple')} +
+
+ {repairDescription || + repairReason || + t('table:table.integrity.v2.repairPreviewNoPrinciple')} +
+
+
+
+ ); + })} + +
+
+ {t('table:table.integrity.v2.repairPreviewSql')} +
+ {statements.length ? ( +
+ {statements.map((statement, index) => { + const parameters = formatRepairParameters(statement.parameters); + + return ( +
+
+                        {statement.sql}
+                      
+ {parameters ? ( +
+                          
+                            {t('table:table.integrity.v2.repairPreviewParameters')}
+                            {': '}
+                            {parameters}
+                          
+                        
+ ) : null} +
+ ); + })} +
+ ) : ( +
+ {t('table:table.integrity.v2.repairPreviewNoSql')} +
+ )} +
+ + {!canExecute ? ( + + + {statements.length + ? t('table:table.integrity.v2.repairPreviewCannotConfirm') + : t('table:table.integrity.v2.repairPreviewNoSql')} + + + ) : null} +
+ + + + + +
+
+ ); +}; + +const ManualRuleRepairAction = ({ + result, + reason, + description, + onRepairRule, +}: { + result: IntegrityResult; + reason?: string; + description?: string; + onRepairRule: RepairRuleHandler; +}) => { + const { t } = useTranslation(['table']); + + return ( + + + + + + + + +
{reason || t('table:table.integrity.v2.manualRepairNotice')}
+ {description ?
{description}
: null} +
+
+
+ ); +}; + const RuleRepairAction = ({ result, isRunning, isActive, onRepairRule, + onPreviewRepairRule, }: { result: IntegrityResult; isRunning: boolean; isActive: boolean; - onRepairRule?: ( - result: IntegrityResult, - manualRepairValues?: ManualRepairValues - ) => Promise; + onRepairRule?: RepairRuleHandler; + onPreviewRepairRule?: RepairRulePreviewHandler; }) => { const { t } = useTranslation(['table']); - const canRepair = Boolean(result.repair?.available && result.tableId && result.fieldId); + const hasRepairTarget = Boolean(result.tableId && result.fieldId && result.ruleId); + const canShowPreview = Boolean(onPreviewRepairRule && hasRepairTarget); + const canRepair = Boolean(result.repair?.available && hasRepairTarget && onRepairRule); const reason = getLocalizedRepairReason(t as Translate, result); const description = getLocalizedRepairDescription(t as Translate, result); + const tooltipReason = getRuleRepairTooltipText(t as Translate, result, reason, description); + const [previewOpen, setPreviewOpen] = useState(false); + const [dryRunResults, setDryRunResults] = useState([]); + const [isPreviewLoading, setIsPreviewLoading] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); - if (!result.repair) { + if (!canPreviewRepairResult(result) || (!onRepairRule && !onPreviewRepairRule)) { return null; } + const handlePreview = async () => { + if (!onPreviewRepairRule) { + void onRepairRule?.(result); + return; + } + + setIsPreviewLoading(true); + try { + const nextDryRunResults = await onPreviewRepairRule(result); + setDryRunResults(nextDryRunResults); + setPreviewOpen(true); + } finally { + setIsPreviewLoading(false); + } + }; + + const handleConfirm = async () => { + if (!canRepair || !onRepairRule) { + return; + } + + setIsConfirming(true); + try { + const repaired = await onRepairRule(result); + if (repaired) { + setPreviewOpen(false); + } + } finally { + setIsConfirming(false); + } + }; + return (
- {result.repair.mode === 'manual' ? ( - - - - - - - - -
{reason || t('table:table.integrity.v2.manualRepairNotice')}
- {description ?
{description}
: null} -
-
-
+ {result.repair?.mode === 'manual' && onRepairRule ? ( + ) : null} @@ -488,10 +750,10 @@ const RuleRepairAction = ({ size="xs" variant="outline" className="h-7 px-2 text-xs" - disabled={!canRepair || isRunning} - onClick={() => void onRepairRule?.(result)} + disabled={!canShowPreview || isRunning || isPreviewLoading} + onClick={() => void handlePreview()} > - {isRunning && isActive ? ( + {(isRunning && isActive) || isPreviewLoading ? ( ) : ( @@ -500,14 +762,21 @@ const RuleRepairAction = ({ - {!canRepair || reason ? ( - -
{reason || t('table:table.integrity.v2.repairUnavailable')}
- {description ?
{description}
: null} -
- ) : null} + +
{tooltipReason}
+ {reason && description ?
{description}
: null} +
+ void handleConfirm()} + />
); }; @@ -559,14 +828,13 @@ const RuleResultItem = ({ isRunning, isActive, onRepairRule, + onPreviewRepairRule, }: { result: IntegrityResult; isRunning: boolean; isActive: boolean; - onRepairRule?: ( - result: IntegrityResult, - manualRepairValues?: ManualRepairValues - ) => Promise; + onRepairRule?: RepairRuleHandler; + onPreviewRepairRule?: RepairRulePreviewHandler; }) => { const { t } = useTranslation(['table']); const localizedMessage = getLocalizedResultMessage(t as Translate, result); @@ -609,6 +877,7 @@ const RuleResultItem = ({ isRunning={isRunning} isActive={isActive} onRepairRule={onRepairRule} + onPreviewRepairRule={onPreviewRepairRule} />
{shouldShowMessage ? ( @@ -885,15 +1154,14 @@ const IntegrityGroupCard = ({ isRunning, activeRepairResultId, onRepairRule, + onPreviewRepairRule, nested = false, }: { group: ResultGroup; isRunning: boolean; activeRepairResultId?: string | null; - onRepairRule?: ( - result: IntegrityResult, - manualRepairValues?: ManualRepairValues - ) => Promise; + onRepairRule?: RepairRuleHandler; + onPreviewRepairRule?: RepairRulePreviewHandler; nested?: boolean; }) => { const { t } = useTranslation(['table']); @@ -943,6 +1211,7 @@ const IntegrityGroupCard = ({ isRunning={isRunning} isActive={activeRepairResultId === result.id} onRepairRule={onRepairRule} + onPreviewRepairRule={onPreviewRepairRule} /> ))}
@@ -952,19 +1221,25 @@ const IntegrityGroupCard = ({ const IntegrityTableCard = ({ group, + baseId, isRunning, activeRepairResultId, onRepairRule, + onPreviewRepairRule, }: { group: TableResultGroup; + baseId?: string; isRunning: boolean; activeRepairResultId?: string | null; - onRepairRule?: ( - result: IntegrityResult, - manualRepairValues?: ManualRepairValues - ) => Promise; + onRepairRule?: RepairRuleHandler; + onPreviewRepairRule?: RepairRulePreviewHandler; }) => { const displayState = getGroupDisplayState(group.results); + const tableHref = + (group.baseId || baseId) && group.tableId + ? `/base/${group.baseId || baseId}/table/${group.tableId}` + : undefined; + const tableLabel = group.tableName || group.tableId; return (
@@ -972,9 +1247,17 @@ const IntegrityTableCard = ({
-
- {group.tableName || group.tableId} -
+ {tableHref ? ( + + {tableLabel} + + + ) : ( +
{tableLabel}
+ )} {group.tableId ? (
{group.tableId}
) : null} @@ -991,6 +1274,7 @@ const IntegrityTableCard = ({ isRunning={isRunning} activeRepairResultId={activeRepairResultId} onRepairRule={onRepairRule} + onPreviewRepairRule={onPreviewRepairRule} nested /> ))} @@ -1001,6 +1285,7 @@ const IntegrityTableCard = ({ export const IntegrityResultsPanel = ({ scope, + baseId, tableGroups, groupedResults, hasRun, @@ -1010,8 +1295,10 @@ export const IntegrityResultsPanel = ({ hasFilteredOutAll, activeRepairResultId, onRepairRule, + onPreviewRepairRule, }: { scope: IntegrityScope; + baseId?: string; tableGroups: TableResultGroup[]; groupedResults: ResultGroup[]; hasRun: boolean; @@ -1020,10 +1307,8 @@ export const IntegrityResultsPanel = ({ hasTarget: boolean; hasFilteredOutAll: boolean; activeRepairResultId?: string | null; - onRepairRule?: ( - result: IntegrityResult, - manualRepairValues?: ManualRepairValues - ) => Promise; + onRepairRule?: RepairRuleHandler; + onPreviewRepairRule?: RepairRulePreviewHandler; }) => { const { t } = useTranslation(['table']); const runningText = getPhaseText(t as Translate, phase, 'running'); @@ -1074,9 +1359,11 @@ export const IntegrityResultsPanel = ({ ))}
@@ -1095,6 +1382,7 @@ export const IntegrityResultsPanel = ({ isRunning={isRunning} activeRepairResultId={activeRepairResultId} onRepairRule={onRepairRule} + onPreviewRepairRule={onPreviewRepairRule} nested /> ))} diff --git a/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Dialog.tsx b/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Dialog.tsx index fed4e5c00b..a50a9fcf8a 100644 --- a/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Dialog.tsx +++ b/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Dialog.tsx @@ -234,6 +234,58 @@ export const IntegrityV2Dialog = ({ [stopStream, t] ); + const runRuleRepairDryRun = useCallback( + async (result: IntegrityResult, manualRepairValues?: ManualRepairValues) => { + if (!result.tableId || !result.fieldId) { + return []; + } + + stopStream(); + const controller = new AbortController(); + abortRef.current = controller; + + setIsRunning(true); + setActiveRepairResultId(result.id); + setStreamError(null); + + const dryRunResults: IntegrityResult[] = []; + + try { + await streamV2TableSchemaIntegrityRepair( + result.tableId, + { + fieldId: result.fieldId, + ruleId: result.ruleId, + dryRun: true, + targetStatuses: ['warn', 'error'], + manualRepairValues, + }, + { + signal: controller.signal, + onResult: (nextResult) => { + dryRunResults.push(nextResult); + }, + } + ); + } catch (error) { + if (!controller.signal.aborted) { + const message = getErrorMessage(error, t('table:table.integrity.v2.streamError')); + setStreamError(message); + toast.error(message); + } + } finally { + if (abortRef.current === controller) { + abortRef.current = null; + setIsRunning(false); + setActiveRepairResultId(null); + } + } + + return dryRunResults; + }, + [stopStream, t] + ); + useEffect(() => { if (!open) { stopStream(); @@ -337,6 +389,7 @@ export const IntegrityV2Dialog = ({ diff --git a/apps/nextjs-app/src/features/app/blocks/design/components/integrityV2Utils.ts b/apps/nextjs-app/src/features/app/blocks/design/components/integrityV2Utils.ts index 37d7a16924..e0568d2b2c 100644 --- a/apps/nextjs-app/src/features/app/blocks/design/components/integrityV2Utils.ts +++ b/apps/nextjs-app/src/features/app/blocks/design/components/integrityV2Utils.ts @@ -30,6 +30,7 @@ export type ResultGroup = { }; export type TableResultGroup = { + baseId: string; tableId: string; tableName: string; results: IntegrityResult[]; @@ -190,7 +191,7 @@ export const groupResultsByTable = (results: IntegrityResult[]): TableResultGrou const groups = new Map(); for (const result of results) { - const key = result.tableId || '__unknown_table__'; + const key = `${result.baseId || '__unknown_base__'}:${result.tableId || '__unknown_table__'}`; const existing = groups.get(key); if (existing) { @@ -201,8 +202,9 @@ export const groupResultsByTable = (results: IntegrityResult[]): TableResultGrou groups.set(key, [result]); } - return Array.from(groups.entries()).map(([tableId, tableResults]) => ({ - tableId: tableId === '__unknown_table__' ? '' : tableId, + return Array.from(groups.values()).map((tableResults) => ({ + baseId: tableResults[0]?.baseId || '', + tableId: tableResults[0]?.tableId || '', tableName: tableResults[0]?.tableName || '', results: tableResults, groups: groupResults(tableResults), diff --git a/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridViewBase.tsx b/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridViewBase.tsx index 966678c501..e740f3ba55 100644 --- a/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridViewBase.tsx +++ b/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridViewBase.tsx @@ -88,7 +88,12 @@ export const GridViewBase = (props: IGridViewProps) => { const isTouchDevice = useIsTouchDevice(); const { setSelection, openStatisticMenu, openGroupHeaderMenu, openHeaderMenu } = useGridViewStore(); - const { columns: originalColumns, cellValue2GridDisplay } = useGridColumns(); + const { setGridRef, searchCursor, highlightedFieldId } = useGridSearchStore(); + const { columns: originalColumns, cellValue2GridDisplay } = useGridColumns( + undefined, + undefined, + highlightedFieldId + ); const { columns, onColumnResize } = useGridColumnResize(originalColumns); const { columnStatistics } = useGridColumnStatistics(columns); const { onColumnOrdered } = useGridColumnOrder(); @@ -97,7 +102,6 @@ export const GridViewBase = (props: IGridViewProps) => { const allFields = useFields({ withHidden: true }); const customIcons = useGridIcons(); const { openTooltip, closeTooltip } = useGridTooltipStore(); - const { setGridRef, searchCursor } = useGridSearchStore(); const buttonClickStatusHook = useButtonClickStatus(tableId, shareId); const prepare = isHydrated && view && columns.length; diff --git a/apps/nextjs-app/src/features/app/blocks/space-setting/SpaceInnerSettingModal.tsx b/apps/nextjs-app/src/features/app/blocks/space-setting/SpaceInnerSettingModal.tsx index f609e72203..9099779632 100644 --- a/apps/nextjs-app/src/features/app/blocks/space-setting/SpaceInnerSettingModal.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space-setting/SpaceInnerSettingModal.tsx @@ -1,42 +1,28 @@ +import { Dialog, DialogContent, DialogTrigger } from '@teable/ui-lib/shadcn'; +import { useCallback, useEffect, useState } from 'react'; import { - Dialog, - DialogContent, - DialogTrigger, - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from '@teable/ui-lib/shadcn'; -import { Settings, Users } from 'lucide-react'; -import { useTranslation } from 'next-i18next'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { spaceConfig } from '@/features/i18n/space.config'; -import { CollaboratorPage } from './collaborator'; -import { GeneralPage } from './general'; + UnifiedSettingDialogContent, + type UnifiedSettingTab, +} from '@/features/app/components/setting/UnifiedSettingDialogContent'; +import { SpaceSettingTab } from './types'; interface ISpaceInnerSettingModalProps { open?: boolean; setOpen?: (open: boolean) => void; - defaultTab?: SettingTab; + defaultTab?: SpaceSettingTab; children: React.ReactNode; } -export enum SettingTab { - General = 'general', - Collaborator = 'collaborator', - Plan = 'plan', -} +export { SpaceSettingTab as SettingTab }; export const SpaceInnerSettingModal = (props: ISpaceInnerSettingModalProps) => { const { children, open: controlledOpen, setOpen: controlledSetOpen, - defaultTab = SettingTab.General, + defaultTab = SpaceSettingTab.General, } = props; - const { t } = useTranslation(spaceConfig.i18nNamespaces); - const [internalOpen, setInternalOpen] = useState(false); const isControlled = controlledOpen !== undefined; const open = isControlled ? controlledOpen : internalOpen; @@ -52,58 +38,14 @@ export const SpaceInnerSettingModal = (props: ISpaceInnerSettingModalProps) => { [controlledSetOpen, isControlled, setInternalOpen] ); - const [tab, setTab] = useState(defaultTab); + const [tab, setTab] = useState(defaultTab); + useEffect(() => { if (open) { setTab(defaultTab); } }, [open, defaultTab]); - const tabList = useMemo(() => { - return [ - { - key: SettingTab.General, - name: t('space:spaceSetting.general'), - Icon: Settings, - }, - { - key: SettingTab.Collaborator, - name: t('space:spaceSetting.collaborators'), - Icon: Users, - }, - ]; - }, [t]); - - const content = ( - setTab(value as SettingTab)} - className="flex h-full gap-0 overflow-hidden" - > - - {tabList.map(({ key, name, Icon }) => { - return ( - - - {name} - - ); - })} - - - - - - - - - ); - return ( {children} @@ -111,7 +53,12 @@ export const SpaceInnerSettingModal = (props: ISpaceInnerSettingModalProps) => { className="flex h-[85%] max-h-[85%] max-w-[80%] flex-col gap-0 p-0 transition-[max-width] duration-300" onOpenAutoFocus={(e) => e.preventDefault()} > - {content} + ); diff --git a/apps/nextjs-app/src/features/app/blocks/space-setting/general/GeneralPage.tsx b/apps/nextjs-app/src/features/app/blocks/space-setting/general/GeneralPage.tsx index 3731c19dc4..87ebe40072 100644 --- a/apps/nextjs-app/src/features/app/blocks/space-setting/general/GeneralPage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space-setting/general/GeneralPage.tsx @@ -88,7 +88,7 @@ export const GeneralPage = () => {
{/* Space name */} -
+
{isEditing ? ( { onBlur={onBlur} onKeyDown={onKeydown} autoFocus - size="lg" className="px-3" /> ) : ( @@ -104,21 +103,19 @@ export const GeneralPage = () => { value={space.name} readOnly onClick={() => hasPermission(space.role, 'space|update') && setIsEditing(true)} - size="lg" className={`px-3 ${hasPermission(space.role, 'space|update') ? 'cursor-pointer' : 'cursor-default'}`} /> )}
{/* Space ID */} -
+
= ( const taskStatusCollection = useContext(TaskStatusCollectionContext); const { shareId } = useShareContext(); const buttonClickStatusHook = useButtonClickStatus(tableId, shareId); - const { columns: originalColumns, cellValue2GridDisplay } = useGridColumns(); + const { setGridRef, searchCursor, highlightedFieldId, setRecordMap, setFields } = + useGridSearchStore(); + const { columns: originalColumns, cellValue2GridDisplay } = useGridColumns( + undefined, + undefined, + highlightedFieldId + ); const { columns, onColumnResize } = useGridColumnResize(originalColumns); const { columnStatistics } = useGridColumnStatistics(columns); const { onColumnOrdered } = useGridColumnOrder(); @@ -232,7 +238,6 @@ export const GridViewBaseInner: React.FC = ( const realRowCount = rowCount ?? ssrRecords?.length ?? 0; const fieldEditable = permission['field|update']; const { undo, redo } = useUndoRedo(); - const { setGridRef, searchCursor, setRecordMap, setFields } = useGridSearchStore(); const [expandRecord, setExpandRecord] = useState<{ tableId: string; recordId: string }>(); const [newRecords, setNewRecords] = useState(); const [cellErrors, setCellErrors] = useState([]); diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/useGridSearchStore.ts b/apps/nextjs-app/src/features/app/blocks/view/grid/useGridSearchStore.ts index 0fef8f931c..84ef8dda8c 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/useGridSearchStore.ts +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/useGridSearchStore.ts @@ -33,6 +33,8 @@ interface IGridRefState { setGridRef: (ref: React.RefObject) => void; searchCursor: [number, number] | null; setSearchCursor: (cell: [number, number] | null) => void; + highlightedFieldId: string | null; + setHighlightedFieldId: (fieldId: string | null) => void; resetSearchHandler: () => void; setResetSearchHandler: (fn: () => void) => void; recordMap: IRecordIndexMap | null; @@ -48,6 +50,7 @@ interface IGridRefState { export const useGridSearchStore = create((set) => ({ gridRef: null, searchCursor: null, + highlightedFieldId: null, recordMap: null, fields: null, highlightedTableId: null, @@ -77,6 +80,14 @@ export const useGridSearchStore = create((set) => ({ }; }); }, + setHighlightedFieldId: (fieldId: string | null) => { + set((state) => { + return { + ...state, + highlightedFieldId: fieldId, + }; + }); + }, setRecordMap: (recordMap: IRecordIndexMap | null) => { set((state) => { // Notify listeners when recordMap changes diff --git a/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/GridViewOperators.tsx b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/GridViewOperators.tsx index 92aaaf880d..a2f0f9a34f 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/GridViewOperators.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/GridViewOperators.tsx @@ -8,11 +8,14 @@ import { AlertTriangle, } from '@teable/icons'; import { HideFields, RowHeight, Sort, Group, ViewFilter } from '@teable/sdk'; +import { useFields } from '@teable/sdk/hooks'; import { useView } from '@teable/sdk/hooks/use-view'; import { cn } from '@teable/ui-lib/shadcn'; +import { toast } from '@teable/ui-lib/shadcn/ui/sonner'; import { useTranslation } from 'next-i18next'; import { useEffect, useRef } from 'react'; import { tableConfig } from '@/features/i18n/table.config'; +import { useGridSearchStore } from '../../grid/useGridSearchStore'; import { useToolbarChange } from '../../hooks/useToolbarChange'; import { ToolBarButton } from '../ToolBarButton'; import { useToolBarStore } from './useToolBarStore'; @@ -20,6 +23,9 @@ import { useToolBarStore } from './useToolBarStore'; export const GridViewOperators: React.FC<{ disabled?: boolean }> = (props) => { const { disabled } = props; const view = useView(); + const fields = useFields(); + const allFields = useFields({ withHidden: true, withDenied: true }); + const { gridRef, setHighlightedFieldId } = useGridSearchStore(); const { onFilterChange, onRowHeightChange, @@ -32,6 +38,7 @@ export const GridViewOperators: React.FC<{ disabled?: boolean }> = (props) => { const filterRef = useRef(null); const sortRef = useRef(null); const groupRef = useRef(null); + const highlightTimeoutRef = useRef | null>(null); useEffect(() => { setFilterRef(filterRef); @@ -39,12 +46,39 @@ export const GridViewOperators: React.FC<{ disabled?: boolean }> = (props) => { setGroupRef(groupRef); }, [setFilterRef, setGroupRef, setSortRef]); + useEffect(() => { + return () => { + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current); + } + setHighlightedFieldId(null); + }; + }, [setHighlightedFieldId]); + if (!view) { return
; } return (
- + { + const columnIndex = fields.findIndex(({ id }) => id === field.id); + if (columnIndex === -1) { + const fieldName = allFields.find(({ id }) => id === field.id)?.name ?? field.name; + toast.warning(t('sdk:hidden.notInCurrentView', { fieldName })); + return; + } + gridRef?.current?.scrollToItem([columnIndex, 0]); + setHighlightedFieldId(field.id); + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current); + } + highlightTimeoutRef.current = setTimeout(() => { + setHighlightedFieldId(null); + highlightTimeoutRef.current = null; + }, 1000); + }} + > {(text, isActive) => ( = (props) => { )} - {/* - - - { - // disabled doesn't trigger the tooltip, so wrap div - } -
- - {(text: string, isActive) => ( - - - - )} - -
-
- -

{t('table:toolbar.comingSoon')}

-
-
-
*/} { const { user } = useSession(); + const { base } = useContext(BaseContext); const isAnonymous = useIsAnonymous(); const isReadOnlyPreview = useIsReadOnlyPreview(); const { brandName } = useBrand(); @@ -61,7 +63,7 @@ export const SideBarFooter: React.FC = () => {

- + diff --git a/apps/nextjs-app/src/features/app/components/download-attachments/DownloadContent.tsx b/apps/nextjs-app/src/features/app/components/download-attachments/DownloadContent.tsx index e9b21338f5..b09329277b 100644 --- a/apps/nextjs-app/src/features/app/components/download-attachments/DownloadContent.tsx +++ b/apps/nextjs-app/src/features/app/components/download-attachments/DownloadContent.tsx @@ -15,6 +15,7 @@ import { CommandInput, CommandItem, CommandList, + CommandSeparator, Label, Popover, PopoverContent, @@ -64,7 +65,7 @@ export const DownloadContent = ({ const [downloading, setDownloading] = useState(false); const abortControllerRef = useRef(null); - const { namingFieldId, setNamingFieldId, groupByRow, setGroupByRow } = + const { namingFieldId, setNamingFieldId, noPrefix, setNoPrefix, groupByRow, setGroupByRow } = useColumnDownloadDialogStore(); const allFields = useFields({ withHidden: true, withDenied: true }); const fieldStaticGetter = useFieldStaticGetter(); @@ -96,6 +97,12 @@ export const DownloadContent = ({ [namingFieldId, setNamingFieldId] ); + // Handle "no prefix" option - toggle on/off + const handleNoPrefixSelect = useCallback(() => { + setSelectorOpen(false); + setNoPrefix(!noPrefix); + }, [noPrefix, setNoPrefix]); + // Load preview on mount useEffect(() => { const loadPreview = async () => { @@ -184,6 +191,7 @@ export const DownloadContent = ({ shareId, personalViewCommonQuery, namingField, + noPrefix, groupByRow, abortController, onProgress: updateProgress, @@ -218,6 +226,7 @@ export const DownloadContent = ({ viewId, shareId, namingField, + noPrefix, groupByRow, personalViewCommonQuery, onClose, @@ -299,8 +308,15 @@ export const DownloadContent = ({ className="w-full justify-between dark:bg-[color-mix(in_oklab,white_10%,hsl(var(--background)))]" >
- {namingFieldId ? ( - (() => { + {(() => { + if (noPrefix) { + return ( + + {t('table:download.allAttachments.noPrefixOption')} + + ); + } + if (namingFieldId) { const selectedField = namingFields.find((f) => f.id === namingFieldId); if (!selectedField) return null; const { Icon } = fieldStaticGetter(selectedField.type, { @@ -315,12 +331,13 @@ export const DownloadContent = ({ {selectedField.name} ); - })() - ) : ( - - {t('table:download.allAttachments.selectField')} - - )} + } + return ( + + {t('table:download.allAttachments.selectField')} + + ); + })()}
@@ -330,6 +347,32 @@ export const DownloadContent = ({ {t('common:noResult')} + + +
+ + {t('table:download.allAttachments.noPrefixOption')} + + + {t('table:download.allAttachments.noPrefixOptionDesc')} + +
+ +
+
+ {namingFields.map((field) => { const { Icon } = fieldStaticGetter(field.type, { diff --git a/apps/nextjs-app/src/features/app/components/download-attachments/useDownloadAttachmentsStore.ts b/apps/nextjs-app/src/features/app/components/download-attachments/useDownloadAttachmentsStore.ts index e4a1d5561a..ab65ef8e1f 100644 --- a/apps/nextjs-app/src/features/app/components/download-attachments/useDownloadAttachmentsStore.ts +++ b/apps/nextjs-app/src/features/app/components/download-attachments/useDownloadAttachmentsStore.ts @@ -13,6 +13,7 @@ interface IColumnDownloadDialogState { shareId?: string; personalViewCommonQuery?: IGetRecordsRo; namingFieldId?: string; + noPrefix: boolean; groupByRow: boolean; openDialog: (params: { @@ -25,17 +26,20 @@ interface IColumnDownloadDialogState { }) => void; closeDialog: () => void; setNamingFieldId: (namingFieldId?: string) => void; + setNoPrefix: (noPrefix: boolean) => void; setGroupByRow: (groupByRow: boolean) => void; } export const useColumnDownloadDialogStore = create((set) => ({ open: false, + noPrefix: false, groupByRow: false, openDialog: (params) => set({ open: true, namingFieldId: undefined, // Reset naming field when opening dialog + noPrefix: false, // Reset no-prefix when opening dialog groupByRow: false, // Reset group by row when opening dialog ...params, }), @@ -49,8 +53,10 @@ export const useColumnDownloadDialogStore = create(( shareId: undefined, personalViewCommonQuery: undefined, namingFieldId: undefined, + noPrefix: false, groupByRow: false, }), - setNamingFieldId: (namingFieldId) => set({ namingFieldId }), + setNamingFieldId: (namingFieldId) => set({ namingFieldId, noPrefix: false }), + setNoPrefix: (noPrefix) => set({ noPrefix, namingFieldId: undefined }), setGroupByRow: (groupByRow) => set({ groupByRow }), })); diff --git a/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.spec.tsx b/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.spec.tsx index 6e13791fb6..5915507285 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.spec.tsx +++ b/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.spec.tsx @@ -18,6 +18,20 @@ vi.mock('./DynamicFieldEditor', () => ({ })); describe('FieldSettingBase', () => { + it('disables save when editing target field is not available yet', () => { + render( + undefined} + onConfirm={() => undefined} + /> + ); + + expect(screen.getByRole('button', { name: 'common:actions.save' })).toBeDisabled(); + }); + it('hydrates local editor state when originField arrives after initial fallback render', () => { const lookupField = { id: 'fldLookup0000000001', diff --git a/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.tsx b/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.tsx index 5469b277db..989e9b22b8 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.tsx +++ b/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.tsx @@ -139,6 +139,18 @@ export const FieldSetting = (props: IFieldSetting) => { const autoFillModeRef = useRef(null); const { t } = useTranslation(tableConfig.i18nNamespaces); + const getEditFieldId = () => { + if (operator !== FieldOperator.Edit) { + return undefined; + } + + return props.field?.id; + }; + + const notifyMissingEditField = () => { + toast.error(t('table:field.editor.fieldUnavailable')); + }; + // Fetch field stats (empty/filled count) for AI field dialog const fetchFieldStats = async (fieldId: string) => { if (!tableId) return; @@ -242,9 +254,12 @@ export const FieldSetting = (props: IFieldSetting) => { } if (operator === FieldOperator.Edit) { - const fieldId = props.field?.id; + const fieldId = getEditFieldId(); if (tableId && fieldId) { result = await convertField({ tableId, fieldId, fieldRo: field }); + } else { + notifyMissingEditField(); + return; } } @@ -264,7 +279,13 @@ export const FieldSetting = (props: IFieldSetting) => { const getPlan = async (fieldRo: IFieldRo) => { if (operator === FieldOperator.Edit) { - return await planFieldConvert({ tableId, fieldId: props.field?.id as string, fieldRo }); + const fieldId = getEditFieldId(); + if (!fieldId) { + notifyMissingEditField(); + return; + } + + return await planFieldConvert({ tableId, fieldId, fieldRo }); } return await planFieldCreate({ tableId, fieldRo }); }; @@ -298,6 +319,9 @@ export const FieldSetting = (props: IFieldSetting) => { } const plan = await getPlan(fieldRo); + if (!plan) { + return; + } setFieldRo(fieldRo); setPlan(plan); const estimateTime = plan?.estimateTime || 0; @@ -314,6 +338,9 @@ export const FieldSetting = (props: IFieldSetting) => { autoFillModeRef.current = mode; const plan = await getPlan(fieldRo); + if (!plan) { + return; + } setPlan(plan); const estimateTime = plan?.estimateTime || 0; const linkFieldCount = plan?.linkFieldCount || 0; @@ -392,6 +419,7 @@ export const FieldSettingBase = (props: IFieldSettingBase) => { const [alertVisible, setAlertVisible] = useState(false); const [updateCount, setUpdateCount] = useState(0); const [isSaving, setIsSaving] = useState(false); + const isMissingEditField = operator === FieldOperator.Edit && !originField?.id; useEffect(() => { if (updateCount > 0) { @@ -428,6 +456,11 @@ export const FieldSettingBase = (props: IFieldSettingBase) => { }; const onSave = async () => { + if (isMissingEditField) { + toast.error(t('table:field.editor.fieldUnavailable')); + return; + } + if (operator === FieldOperator.Edit && !updateCount) { onConfirm?.(); return; @@ -523,7 +556,7 @@ export const FieldSettingBase = (props: IFieldSettingBase) => { -
diff --git a/apps/nextjs-app/src/features/app/components/setting/Account.tsx b/apps/nextjs-app/src/features/app/components/setting/Account.tsx index 46880bb6b7..c971d48a97 100644 --- a/apps/nextjs-app/src/features/app/components/setting/Account.tsx +++ b/apps/nextjs-app/src/features/app/components/setting/Account.tsx @@ -104,9 +104,9 @@ export const Account: React.FC = () => { )} -
+
toggleRenameUser(e)} /> diff --git a/apps/nextjs-app/src/features/app/components/setting/SettingDialog.tsx b/apps/nextjs-app/src/features/app/components/setting/SettingDialog.tsx index 522980060f..804cf91cfe 100644 --- a/apps/nextjs-app/src/features/app/components/setting/SettingDialog.tsx +++ b/apps/nextjs-app/src/features/app/components/setting/SettingDialog.tsx @@ -1,131 +1,17 @@ -import { Bell, Key, Link, Lock, Settings, User } from '@teable/icons'; import { useIsTouchDevice } from '@teable/sdk/hooks'; -import { - Dialog, - DialogContent, - Sheet, - SheetContent, - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from '@teable/ui-lib/shadcn'; -import { useTranslation } from 'next-i18next'; -import { useMemo } from 'react'; -import { System } from '@/features/app/components/setting/System'; -import { settingConfig } from '@/features/i18n/setting.config'; -import { Account } from './Account'; -import { Integration } from './integration/Integration'; -import { Notifications } from './Notifications'; -import { OAuthAppSection } from './oauth-app'; -import { PersonalAccessTokenSection } from './personal-access-token'; +import { Dialog, DialogContent, Sheet, SheetContent } from '@teable/ui-lib/shadcn'; +import { UnifiedSettingDialogContent } from './UnifiedSettingDialogContent'; import { SettingTab, useSettingStore } from './useSettingStore'; -export const SettingDialog = () => { - const { t } = useTranslation(settingConfig.i18nNamespaces); +export interface ISettingDialogProps { + spaceId?: string; + includeSpaceSettings?: boolean; +} + +export const SettingDialog = ({ spaceId, includeSpaceSettings = true }: ISettingDialogProps) => { const isTouchDevice = useIsTouchDevice(); const { open, setOpen, tab, setTab } = useSettingStore(); - - const tabList = useMemo(() => { - return [ - { - key: SettingTab.Profile, - name: t('settings.account.tab'), - Icon: User, - }, - { - key: SettingTab.System, - name: t('settings.setting.title'), - Icon: Settings, - }, - { - key: SettingTab.Notifications, - name: t('settings.notify.title'), - Icon: Bell, - }, - { - key: SettingTab.Integration, - name: t('settings.integration.title'), - Icon: Link, - }, - { - key: SettingTab.PersonalAccessToken, - name: t('setting:personalAccessToken'), - Icon: Key, - }, - { - key: SettingTab.OAuthApp, - name: t('setting:oauthApps'), - Icon: Lock, - }, - ]; - }, [t]); - - const content = ( - setTab(value as SettingTab)} - className="flex h-full gap-0 overflow-hidden" - > - - {tabList.map(({ key, name, Icon }) => { - return ( - - - {name} - - ); - })} - - - - - - - - - - - - - - - - - - - - - ); + const activeTab = tab ?? SettingTab.Profile; return ( <> @@ -135,7 +21,14 @@ export const SettingDialog = () => { className="h-5/6 rounded-t-lg px-1 pb-0 pt-4 [&>button]:right-4 [&>button]:top-4 " side="bottom" > - {content} + ) : ( @@ -144,7 +37,14 @@ export const SettingDialog = () => { className="h-4/5 max-h-[80vh] max-w-6xl overflow-hidden p-0 [&>button]:right-4 [&>button]:top-4 " onOpenAutoFocus={(e) => e.preventDefault()} > - {content} + )} diff --git a/apps/nextjs-app/src/features/app/components/setting/SettingTabShell.tsx b/apps/nextjs-app/src/features/app/components/setting/SettingTabShell.tsx index 8ecabe5457..4f26b2d2b7 100644 --- a/apps/nextjs-app/src/features/app/components/setting/SettingTabShell.tsx +++ b/apps/nextjs-app/src/features/app/components/setting/SettingTabShell.tsx @@ -34,17 +34,17 @@ export const SettingTabHeader = ({ return (
-
+
{leading} -
-
{title}
+
+
{title}
{description && ( -
+
{description}
)} @@ -67,16 +67,11 @@ export const SettingTabShell = ({ footerClassName, }: SettingTabShellProps) => { return ( -
+
{header && (
@@ -85,13 +80,15 @@ export const SettingTabShell = ({ )}
{children}
- {footer &&
{footer}
} + {footer && ( +
{footer}
+ )}
); }; diff --git a/apps/nextjs-app/src/features/app/components/setting/System.tsx b/apps/nextjs-app/src/features/app/components/setting/System.tsx index 257faf8c88..038bec451c 100644 --- a/apps/nextjs-app/src/features/app/components/setting/System.tsx +++ b/apps/nextjs-app/src/features/app/components/setting/System.tsx @@ -37,57 +37,57 @@ export const System: React.FC = () => { setTheme(value); }} > -
+
- + {t('settings.setting.light')}
-
+
- + {t('settings.setting.dark')}
-
+
- + {t('settings.setting.system')}
diff --git a/apps/nextjs-app/src/features/app/components/setting/UnifiedSettingDialogContent.tsx b/apps/nextjs-app/src/features/app/components/setting/UnifiedSettingDialogContent.tsx new file mode 100644 index 0000000000..13b726ede5 --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/setting/UnifiedSettingDialogContent.tsx @@ -0,0 +1,304 @@ +import { useQuery } from '@tanstack/react-query'; +import { Role } from '@teable/core'; +import { Bell, Key, Link, Lock, Settings, User } from '@teable/icons'; +import { getSpaceById } from '@teable/openapi'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import { useBase, useSession } from '@teable/sdk/hooks'; +import { Tabs, TabsContent, TabsList, TabsTrigger, cn } from '@teable/ui-lib/shadcn'; +import { uniq } from 'lodash'; +import { Settings2, Users } from 'lucide-react'; +import { useParams } from 'next/navigation'; +import { useTranslation } from 'next-i18next'; +import type { ElementType, ReactElement, ReactNode } from 'react'; +import { useEffect, useMemo } from 'react'; +import { CollaboratorPage } from '@/features/app/blocks/space-setting/collaborator'; +import { GeneralPage } from '@/features/app/blocks/space-setting/general'; +import { SpaceSettingTab } from '@/features/app/blocks/space-setting/types'; +import { Account } from '@/features/app/components/setting/Account'; +import { Integration } from '@/features/app/components/setting/integration/Integration'; +import { Notifications } from '@/features/app/components/setting/Notifications'; +import { OAuthAppSection } from '@/features/app/components/setting/oauth-app'; +import { PersonalAccessTokenSection } from '@/features/app/components/setting/personal-access-token'; +import { System } from '@/features/app/components/setting/System'; +import { SettingTab as PersonalSettingTab } from '@/features/app/components/setting/useSettingStore'; +import { SpaceAvatar } from '@/features/app/components/space/SpaceAvatar'; +import { UserAvatar } from '@/features/app/components/user/UserAvatar'; +import { settingConfig } from '@/features/i18n/setting.config'; +import { spaceConfig } from '@/features/i18n/space.config'; + +export type UnifiedSettingKnownTab = PersonalSettingTab | SpaceSettingTab; +export type UnifiedSettingTab = string; + +export interface IUnifiedSettingListItem { + key: UnifiedSettingTab; + name: string; + Icon: ElementType; + badge?: ReactNode; + disabled?: boolean; + content: ReactNode | ((ctx: IUnifiedSettingRenderContext) => ReactNode); + contentClassName?: string; +} + +export interface IUnifiedSettingRenderContext { + onTabChange: (tab: UnifiedSettingTab) => void; + resolvedSpaceId?: string; + showSidebar: boolean; +} + +export interface IUnifiedSettingTriggerOverrides { + badge?: ReactNode; + disabled?: boolean; +} + +interface IUnifiedSettingGroup { + key: 'personal' | 'space'; + title: string; + entity: ReactNode; + tabs: IUnifiedSettingListItem[]; +} + +export interface IUnifiedSettingDialogContentProps { + tab: UnifiedSettingTab; + onTabChange: (tab: UnifiedSettingTab) => void; + entry: 'personal' | 'space'; + defaultTab: UnifiedSettingTab; + contentOnly?: boolean; + spaceId?: string; + includeSpaceSettings?: boolean; + extraPersonalTabs?: IUnifiedSettingListItem[]; + extraSpaceTabs?: IUnifiedSettingListItem[]; + renderTabTrigger?: ( + item: IUnifiedSettingListItem, + ctx: IUnifiedSettingRenderContext, + renderDefaultTrigger: (overrides?: IUnifiedSettingTriggerOverrides) => ReactElement + ) => ReactElement; +} + +export const UnifiedSettingDialogContent = ({ + tab, + onTabChange, + entry, + defaultTab, + contentOnly = false, + spaceId: spaceIdProp, + includeSpaceSettings = true, + extraPersonalTabs, + extraSpaceTabs, + renderTabTrigger, +}: IUnifiedSettingDialogContentProps) => { + const { t } = useTranslation( + uniq([...settingConfig.i18nNamespaces, ...spaceConfig.i18nNamespaces]) + ); + const { user } = useSession(); + const routeParams = useParams<{ spaceId?: string }>(); + const base = useBase() as { spaceId?: string } | undefined; + const resolvedSpaceId = includeSpaceSettings + ? spaceIdProp ?? routeParams?.spaceId ?? base?.spaceId + : undefined; + + const { data: space } = useQuery({ + queryKey: ReactQueryKeys.space(resolvedSpaceId as string), + queryFn: ({ queryKey }) => getSpaceById(queryKey[1] as string).then((res) => res.data), + enabled: Boolean(resolvedSpaceId), + }); + + const canAccessSpaceSettings = includeSpaceSettings && space?.role === Role.Owner; + + const personalTabs = useMemo( + () => [ + { + key: PersonalSettingTab.Profile, + name: t('settings.account.tab'), + Icon: User, + content: , + }, + { + key: PersonalSettingTab.System, + name: t('settings.setting.title'), + Icon: Settings, + content: , + }, + { + key: PersonalSettingTab.Notifications, + name: t('settings.notify.title'), + Icon: Bell, + content: , + }, + { + key: PersonalSettingTab.Integration, + name: t('settings.integration.title'), + Icon: Link, + content: , + }, + { + key: PersonalSettingTab.PersonalAccessToken, + name: t('setting:personalAccessToken'), + Icon: Key, + content: , + }, + { + key: PersonalSettingTab.OAuthApp, + name: t('setting:oauthApps'), + Icon: Lock, + content: , + }, + ...(extraPersonalTabs ?? []), + ], + [extraPersonalTabs, t] + ); + + const spaceTabs = useMemo(() => { + if (!resolvedSpaceId || !canAccessSpaceSettings) { + return []; + } + + return [ + { + key: SpaceSettingTab.General, + name: t('space:spaceSetting.general'), + Icon: Settings2, + content: , + }, + { + key: SpaceSettingTab.Collaborator, + name: t('space:spaceSetting.collaborators'), + Icon: Users, + content: , + }, + ...(extraSpaceTabs ?? []), + ]; + }, [canAccessSpaceSettings, extraSpaceTabs, resolvedSpaceId, t]); + + const orderedGroups = useMemo(() => { + const groups: IUnifiedSettingGroup[] = [ + { + key: 'personal' as const, + title: t('common:settings.personal.title'), + entity: user ? ( +
+ + + {user.name} + +
+ ) : null, + tabs: personalTabs, + }, + { + key: 'space' as const, + title: t('common:noun.space'), + entity: + resolvedSpaceId && space ? ( +
+ + + {space.name} + +
+ ) : null, + tabs: spaceTabs, + }, + ].filter((group) => group.tabs.length > 0); + + if (entry === 'space') { + return groups.sort((a, b) => (a.key === 'space' ? -1 : b.key === 'space' ? 1 : 0)); + } + + return groups.sort((a, b) => (a.key === 'personal' ? -1 : b.key === 'personal' ? 1 : 0)); + }, [entry, personalTabs, resolvedSpaceId, space, spaceTabs, t, user]); + + const showSidebar = !contentOnly && orderedGroups.length > 0; + const availableTabs = useMemo( + () => orderedGroups.flatMap((group) => group.tabs.map(({ key }) => key)), + [orderedGroups] + ); + + useEffect(() => { + if (availableTabs.includes(tab)) { + return; + } + + const fallbackTab = availableTabs.includes(defaultTab) ? defaultTab : availableTabs[0]; + + if (fallbackTab && fallbackTab !== tab) { + onTabChange(fallbackTab); + } + }, [availableTabs, defaultTab, onTabChange, tab]); + + const renderContext = useMemo( + () => ({ onTabChange, resolvedSpaceId, showSidebar }), + [onTabChange, resolvedSpaceId, showSidebar] + ); + + const allTabs = useMemo(() => orderedGroups.flatMap((group) => group.tabs), [orderedGroups]); + + return ( + + {showSidebar && ( + + {orderedGroups.map((group) => ( +
+
+

+ {group.title} +

+ {group.entity} +
+
+ {group.tabs.map((item) => { + const renderDefaultTrigger = ( + overrides?: IUnifiedSettingTriggerOverrides + ): ReactElement => ( + +
+
+ + {item.name} +
+ + {overrides?.badge ?? item.badge} + +
+
+ ); + + return renderTabTrigger + ? renderTabTrigger(item, renderContext, renderDefaultTrigger) + : renderDefaultTrigger(); + })} +
+
+ ))} +
+ )} + + {allTabs.map((item) => ( + spaceTab.key === item.key) + ? cn('mt-0 min-w-0 flex-1 focus-visible:outline-none', { + 'overflow-y-auto overflow-x-hidden': showSidebar, + }) + : 'mt-0 size-full overflow-y-auto overflow-x-hidden') + } + > + {typeof item.content === 'function' ? item.content(renderContext) : item.content} + + ))} +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/components/setting/integration/Integration.tsx b/apps/nextjs-app/src/features/app/components/setting/integration/Integration.tsx index b80a5c9381..7e371445fb 100644 --- a/apps/nextjs-app/src/features/app/components/setting/integration/Integration.tsx +++ b/apps/nextjs-app/src/features/app/components/setting/integration/Integration.tsx @@ -36,7 +36,7 @@ export const Integration = () => { contentClassName="px-0 py-0" > setTab(value as 'user' | 'third-party')} > diff --git a/apps/nextjs-app/src/features/app/components/setting/useSettingStore.ts b/apps/nextjs-app/src/features/app/components/setting/useSettingStore.ts index b51e40439e..9994b09fcb 100644 --- a/apps/nextjs-app/src/features/app/components/setting/useSettingStore.ts +++ b/apps/nextjs-app/src/features/app/components/setting/useSettingStore.ts @@ -11,16 +11,18 @@ export enum SettingTab { LicensePlan = 'license-plan', } +export type SettingDialogTab = string; + interface ISettingState { - tab?: SettingTab; - setTab: (tab: SettingTab) => void; + tab?: SettingDialogTab; + setTab: (tab: SettingDialogTab) => void; open: boolean; - setOpen: (open: boolean, tab?: SettingTab) => void; + setOpen: (open: boolean, tab?: SettingDialogTab) => void; } export const useSettingStore = create((set) => ({ open: false, - setOpen: (open: boolean, tab?: SettingTab) => { + setOpen: (open: boolean, tab?: SettingDialogTab) => { set((state) => { return { ...state, @@ -29,7 +31,7 @@ export const useSettingStore = create((set) => ({ }; }); }, - setTab: (tab: SettingTab) => { + setTab: (tab: SettingDialogTab) => { set((state) => { return { ...state, diff --git a/apps/nextjs-app/src/features/app/hooks/useBaseResource.ts b/apps/nextjs-app/src/features/app/hooks/useBaseResource.ts index d678622545..dd7726e109 100644 --- a/apps/nextjs-app/src/features/app/hooks/useBaseResource.ts +++ b/apps/nextjs-app/src/features/app/hooks/useBaseResource.ts @@ -103,3 +103,14 @@ export function useBaseResource(): IBaseResource { }; }, [baseId, slug]); } + +export function useBaseNodeId(): string | undefined { + const router = useRouter(); + const { slug } = router.query; + return useMemo(() => { + if (!slug || slug.length === 0) { + return; + } + return slug?.[1]; + }, [slug]); +} diff --git a/apps/nextjs-app/src/features/app/utils/download-all-attachments.ts b/apps/nextjs-app/src/features/app/utils/download-all-attachments.ts index bb539019f7..69e76496ba 100644 --- a/apps/nextjs-app/src/features/app/utils/download-all-attachments.ts +++ b/apps/nextjs-app/src/features/app/utils/download-all-attachments.ts @@ -40,6 +40,8 @@ export interface IDownloadAllAttachmentsOptions { shareId?: string; personalViewCommonQuery?: IGetRecordsRo; namingField?: IFieldInstance; + /** When true, keep the original filename without any prefix (collisions resolved via _N suffix) */ + noPrefix?: boolean; groupByRow?: boolean; onProgress?: (progress: IDownloadProgress) => void; abortController?: AbortController; @@ -333,10 +335,20 @@ function generateZipFileName( rowAttachmentCount: number, namingValue?: string, isNamingValueDuplicated?: boolean, - groupByRow?: boolean + groupByRow?: boolean, + noPrefix?: boolean ): string { const hasMultipleInRow = rowAttachmentCount > 1; + // No-prefix mode: keep original filename. groupByRow still wraps multi-attachment rows + // in a row-numbered folder so files within the same row stay together. + if (noPrefix) { + if (groupByRow && hasMultipleInRow) { + return `${getPaddedRowNumber(rowIndex, totalRows)}/${fileName}`; + } + return fileName; + } + // When groupByRow is enabled and row has multiple attachments, use folder structure if (groupByRow && hasMultipleInRow) { const folderName = generateFolderName( @@ -385,6 +397,7 @@ export async function downloadAllAttachments( shareId, personalViewCommonQuery, namingField, + noPrefix, groupByRow, onProgress, abortController, @@ -451,6 +464,9 @@ export async function downloadAllAttachments( let downloadedBytes = 0; let processedFiles = 0; + // Track final zip entry names for noPrefix mode to dedupe cross-row collisions. + const usedZipPaths = new Map(); + // 6. Create zip stream const zip = new Zip((err, chunk, final) => { if (err) { @@ -477,7 +493,7 @@ export async function downloadAllAttachments( } const isNamingValueDuplicated = namingValue ? duplicatedNamingValues.has(namingValue) : false; - const fileName = generateZipFileName( + let fileName = generateZipFileName( rowIndex, attachmentIndex, attachment.name, @@ -485,8 +501,12 @@ export async function downloadAllAttachments( attachmentCountInRow, namingValue, isNamingValueDuplicated, - groupByRow + groupByRow, + noPrefix ); + if (noPrefix) { + fileName = generateUniqueFileName(fileName, usedZipPaths); + } // Skip attachments without valid presignedUrl if (!attachment.presignedUrl) { diff --git a/apps/nextjs-app/src/styles/global.css b/apps/nextjs-app/src/styles/global.css index ad205d5e7a..5f66617d7b 100644 --- a/apps/nextjs-app/src/styles/global.css +++ b/apps/nextjs-app/src/styles/global.css @@ -40,11 +40,11 @@ body { background-color: hsl(var(--muted-foreground)) !important; } -.fc-scrollgrid-section > td { +.fc-scrollgrid-section>td { border-radius: 0px 0px 8px 8px !important; } -.fc-scrollgrid-section > th { +.fc-scrollgrid-section>th { border-radius: 0px 8px 0px 0px !important; } @@ -167,3 +167,21 @@ body { .react-flow__controls-button svg { fill: currentColor !important; } + +@keyframes ui-attention-pulse { + 0% { + box-shadow: inset 0 0 0 0 hsl(var(--primary) / 0); + } + + 35% { + box-shadow: inset 0 0 0 5px hsl(var(--primary)/ 0.15); + } + + 100% { + box-shadow: inset 0 0 0 0 hsl(var(--primary) / 0); + } +} + +.ui-attention-pulse { + animation: ui-attention-pulse 700ms ease-in-out 2; +} \ No newline at end of file diff --git a/packages/common-i18n/src/locales/de/common.json b/packages/common-i18n/src/locales/de/common.json index 18edf2518e..18c538300c 100644 --- a/packages/common-i18n/src/locales/de/common.json +++ b/packages/common-i18n/src/locales/de/common.json @@ -164,8 +164,9 @@ }, "settings": { "title": "Instanzeinstellungen", + "allSetting": "Space- & persƶnliche Einstellungen", "personal": { - "title": "Persƶnliche Einstellungen" + "title": "Persƶnlich" }, "back": "Zurück zum Start", "account": { @@ -885,6 +886,10 @@ "runQuotaExceeded": { "title": "Automatisierung {{name}} hat die maximale monatliche Ausführungsanzahl erreicht", "message": "Die monatlichen Ausführungen für Automatisierung {{name}} sind aufgebraucht. Die Ausführung ist vorübergehend nicht mƶglich. Bitte upgraden Sie Ihr Abonnement oder kaufen Sie zusƤtzliche Ausführungen." + }, + "failedSummary": { + "title": "Automatisierung {{name}} ist {{failCount}} Mal fehlgeschlagen", + "message": "Ihre Automatisierung {{name}} hat {{failCount}} aufeinanderfolgende Fehler angesammelt. Ɩffnen Sie den Ausführungsverlauf, um Details anzuzeigen." } }, "billing": { @@ -1100,9 +1105,9 @@ } }, "changelog": { - "newUpdate": "17. APR UPDATE", - "title": "Claude Opus 4.7 ist jetzt in Teable verfügbar", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "UPDATE VOM 23. APRIL", + "title": "Externe Bearbeitung ohne Zusatzkosten", + "url": "https://help.teable.ai/en/changelog#apr-23-2026", + "id": "changelog-2026-04-23-external-editing-with-no-extra-cost" } } diff --git a/packages/common-i18n/src/locales/de/sdk.json b/packages/common-i18n/src/locales/de/sdk.json index 54dd297b79..51c24a3cbc 100644 --- a/packages/common-i18n/src/locales/de/sdk.json +++ b/packages/common-i18n/src/locales/de/sdk.json @@ -158,6 +158,7 @@ }, "invalidateSelected": "UnzulƤssiger Wert", "invalidateSelectedTips": "Der ausgewƤhlte Wert wurde gelƶscht, bitte wƤhlen Sie erneut", + "invalidConditionTip": "Diese Filterbedingung ist ungültig und wird ignoriert. Bitte passen Sie den Wert an.", "default": { "empty": "Es werden keine Filterbedingungen angewendet", "placeholder": "Einen Wert eingeben" @@ -264,7 +265,8 @@ "configLabel_other_visible": "{{count}} sichtbare Felder", "showAll": "Alle zeigen", "hideAll": "Alle verstecken", - "primaryKey": "PrimƤres Feld: Identifiziert DatensƤtze\nKann nicht ausgeblendet oder gelƶscht werden, sichtbar in verknüpften DatensƤtzen." + "primaryKey": "PrimƤres Feld: Identifiziert DatensƤtze\nKann nicht ausgeblendet oder gelƶscht werden, sichtbar in verknüpften DatensƤtzen.", + "notInCurrentView": "Feld ā€ž{{fieldName}}ā€œ ist in der aktuellen Ansicht nicht sichtbar und kann nicht angesteuert werden" }, "expandRecord": { "copy": "In die Zwischenablage kopieren", @@ -1223,7 +1225,9 @@ "button": { "clickCountReachedMaxCount": "Anzahl der SchaltflƤchenklicks hat das Maximum erreicht", "notSupportReset": "SchaltflƤchenfeld unterstützt kein Zurücksetzen" - } + }, + "primaryCannotBeLookup": "PrimƤrfeld kann nicht als Lookup-Feld konfiguriert werden", + "primaryFieldAlreadyExists": "Tabelle hat bereits ein PrimƤrfeld" }, "view": { "notFound": "Ansicht nicht gefunden", @@ -1438,7 +1442,8 @@ "linkedInPostNotFound": "LinkedIn-Beitrag nicht gefunden: {{postId}}", "linkedInAuthorNotFound": "LinkedIn-Autor nicht gefunden: {{postId}}", "fetchLinkedInUserFailed": "Abrufen des LinkedIn-Benutzers fehlgeschlagen: {{error}}", - "domainAlreadyInUse": "Diese Domain ist bereits an eine andere App gebunden" + "domainAlreadyInUse": "Diese Domain ist bereits an eine andere App gebunden", + "domainReserved": "Subdomain ist reserviert" } } } diff --git a/packages/common-i18n/src/locales/de/table.json b/packages/common-i18n/src/locales/de/table.json index e04c635a26..2a451e7c0a 100644 --- a/packages/common-i18n/src/locales/de/table.json +++ b/packages/common-i18n/src/locales/de/table.json @@ -641,6 +641,11 @@ } } } + }, + "errorType": { + "InvalidPrimaryLookup": "PrimƤrfeld fƤlschlich als Lookup konfiguriert", + "InvalidPrimaryType": "PrimƤrfeld hat einen nicht unterstützten Typ", + "MissingPrimary": "Tabelle hat kein PrimƤrfeld" } }, "index": { @@ -1054,6 +1059,8 @@ "advancedOptions": "Erweiterte Optionen", "namingFieldLabel": "PrƤfix für Anhangsname", "selectField": "Standard: Anhangsindex", + "noPrefixOption": "Kein PrƤfix", + "noPrefixOptionDesc": "Originaldateiname beibehalten", "groupByRow": "In Ordner archivieren", "groupByRowTip": "Wenn eine Zeile mehrere AnhƤnge hat, werden sie im selben Ordner abgelegt; Zeilen mit nur einem Anhang erstellen keinen Ordner." } diff --git a/packages/common-i18n/src/locales/en/common.json b/packages/common-i18n/src/locales/en/common.json index 0ad5a3be4d..71f93728ee 100644 --- a/packages/common-i18n/src/locales/en/common.json +++ b/packages/common-i18n/src/locales/en/common.json @@ -170,8 +170,9 @@ }, "settings": { "title": "Instance settings", + "allSetting": "Space & personal settings", "personal": { - "title": "Personal settings" + "title": "Personal" }, "templateAdmin": { "title": "Template admin", @@ -1204,6 +1205,10 @@ "runQuotaExceeded": { "title": "Automation {{name}} run failed due to run quota limit", "message": "Your automation {{name}} reached the monthly automation run quota. Please upgrade your plan or purchase additional automation runs." + }, + "failedSummary": { + "title": "Automation {{name}} has failed {{failCount}} times", + "message": "Your automation {{name}} has accumulated {{failCount}} consecutive failures. Open run history to view details." } }, "billing": { @@ -1452,9 +1457,9 @@ } }, "changelog": { - "newUpdate": "APR 17 UPDATE", - "title": "Claude Opus 4.7 is now in Teable", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "APRIL 23 UPDATE", + "title": "External Editing with No Extra Cost", + "url": "https://help.teable.ai/en/changelog#apr-23-2026", + "id": "changelog-2026-04-23-external-editing-with-no-extra-cost" } } diff --git a/packages/common-i18n/src/locales/en/sdk.json b/packages/common-i18n/src/locales/en/sdk.json index d9d4cca364..71c677966f 100644 --- a/packages/common-i18n/src/locales/en/sdk.json +++ b/packages/common-i18n/src/locales/en/sdk.json @@ -174,6 +174,7 @@ }, "invalidateSelected": "Invalid value", "invalidateSelectedTips": "The selected value has been deleted, please select again", + "invalidConditionTip": "This filter condition is invalid and will be ignored. Please adjust the value.", "default": { "empty": "No filter conditions are applied", "placeholder": "Enter a value" @@ -281,7 +282,8 @@ "configLabel_other_visible": "{{count}} visible fields", "showAll": "Show all", "hideAll": "Hide all", - "primaryKey": "Primary field: Identifies records\nCannot be hidden or deleted, visible in linked records." + "primaryKey": "Primary field: Identifies records\nCannot be hidden or deleted, visible in linked records.", + "notInCurrentView": "Field \"{{fieldName}}\" is not visible in the current view and can't be jumped to" }, "expandRecord": { "copy": "Copy to clipboard", @@ -1249,7 +1251,9 @@ "button": { "clickCountReachedMaxCount": "Button click count has reached the maximum limit", "notSupportReset": "Button field does not support reset" - } + }, + "primaryCannotBeLookup": "Primary field cannot be configured as a lookup field", + "primaryFieldAlreadyExists": "Table already has a primary field" }, "view": { "notFound": "View not found", @@ -1447,7 +1451,8 @@ "noFilesInZip": "No files found in ZIP", "zipFileTooLarge": "ZIP file size exceeds 5MB limit", "invalidZip": "Invalid ZIP file", - "domainAlreadyInUse": "This domain is already bound to another app" + "domainAlreadyInUse": "This domain is already bound to another app", + "domainReserved": "Subdomain is reserved" }, "reward": { "notFound": "Reward not found", diff --git a/packages/common-i18n/src/locales/en/table.json b/packages/common-i18n/src/locales/en/table.json index d7fef7adf3..97b63310a1 100644 --- a/packages/common-i18n/src/locales/en/table.json +++ b/packages/common-i18n/src/locales/en/table.json @@ -256,6 +256,7 @@ "reset": "Reset", "fieldUpdated": "Field has been updated", "fieldCreated": "Field has been created", + "fieldUnavailable": "This field is unavailable. Refresh the table and try again.", "confirmFieldChange": "Confirm Field Change", "areYouSurePerformIt": "Are you sure you want to perform it?", "addDescription": "Add description", @@ -661,6 +662,17 @@ "manualRepairDialogClose": "Close", "manualRepairPreview": "Manual repair setup", "manualRepairPreviewTip": "Manual repair is not submitted yet. This preview shows the options the rule asks the user to choose from.", + "repairPreviewTitle": "Confirm repair details", + "repairPreviewDescription": "These details come from the repair dry run. The repair will only execute after you confirm.", + "repairPreviewWhat": "What will be repaired", + "repairPreviewTarget": "The \"{{ruleName}}\" rule for field \"{{fieldName}}\"", + "repairPreviewPrinciple": "How the repair works", + "repairPreviewNoPrinciple": "This rule did not return additional repair guidance.", + "repairPreviewSql": "SQL to execute", + "repairPreviewNoSql": "The dry run did not return executable SQL, so automatic repair cannot be run directly.", + "repairPreviewCannotConfirm": "The dry run returned SQL, but this rule cannot be confirmed for automatic repair right now. You can still review the repair plan.", + "repairPreviewParameters": "Parameters", + "repairPreviewConfirm": "Run repair", "checking": "Checking schema...", "repairing": "Repairing schema...", "streamError": "Failed to stream schema integrity results.", @@ -770,6 +782,7 @@ "junctionForeignKeyOrphanRows": "Automatic repair is unavailable because invalid junction rows still exist" }, "description": { + "autoRule": "The repair will execute the schema statements generated by this rule, then re-check the rule to confirm the schema is valid.", "symmetricFieldConflict": "More than one field points at the same symmetric link target. Pick which field should own the two-way relationship before repairing.", "foreignKeyTargetTableMissing": "Check whether the linked table {{targetPhysicalTableName}} was deleted or renamed. Recreate the table, or update/remove the link configuration for \"{{fieldName}}\", then run the check again.", "foreignKeyOrphanRows": "Clean up the invalid linked rows for \"{{fieldName}}\" before adding the foreign key constraint again.", @@ -831,7 +844,10 @@ "ReferenceFieldNotFound": "ReferenceFieldNotFound", "UniqueIndexNotFound": "UniqueIndexNotFound", "EmptyString": "EmptyString", - "InvalidFilterOperator": "InvalidFilterOperator" + "InvalidFilterOperator": "InvalidFilterOperator", + "InvalidPrimaryLookup": "Primary field misconfigured as lookup", + "InvalidPrimaryType": "Primary field has unsupported type", + "MissingPrimary": "Table has no primary field" } }, "index": { @@ -945,6 +961,8 @@ "advancedOptions": "Advanced options", "namingFieldLabel": "Attachment name prefix", "selectField": "Default: attachment index", + "noPrefixOption": "No prefix", + "noPrefixOptionDesc": "Keep the original filename", "groupByRow": "Archive into folders", "groupByRowTip": "When a row has multiple attachments, they will be placed in the same folder; rows with only one attachment will not create a folder." } @@ -1359,7 +1377,10 @@ }, "retry": { "interrupted": "Response interrupted", - "button": "Retry" + "button": "Retry", + "offline": "You're offline. We'll retry once your connection is back.", + "pausedHidden": "Retry paused — open this tab to reconnect.", + "maxAttemptsReached": "Couldn't reconnect automatically. Please refresh the page manually." }, "guide": { "goToScenario": "Go to scenario {{index}}" diff --git a/packages/common-i18n/src/locales/es/common.json b/packages/common-i18n/src/locales/es/common.json index cb3bb6d93f..e8235dded4 100644 --- a/packages/common-i18n/src/locales/es/common.json +++ b/packages/common-i18n/src/locales/es/common.json @@ -164,8 +164,9 @@ }, "settings": { "title": "Configuración de instancia", + "allSetting": "Configuración del espacio y personal", "personal": { - "title": "Configuración personal" + "title": "Personal" }, "back": "Volver al inicio", "account": { @@ -888,6 +889,10 @@ "runQuotaExceeded": { "title": "La automatización {{name}} alcanzó el mĆ”ximo mensual de ejecuciones", "message": "Las ejecuciones mensuales de la automatización {{name}} se han agotado y no puede ejecutarse temporalmente. Actualiza tu suscripción o compra ejecuciones adicionales." + }, + "failedSummary": { + "title": "La automatización {{name}} ha fallado {{failCount}} veces", + "message": "Tu automatización {{name}} ha acumulado {{failCount}} fallos consecutivos. Abre el historial de ejecuciones para ver los detalles." } }, "billing": { @@ -1103,9 +1108,9 @@ } }, "changelog": { - "newUpdate": "ACTUALIZACIƓN 17 ABR", - "title": "Claude Opus 4.7 ya estĆ” disponible en Teable", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "ACTUALIZACIƓN DEL 23 DE ABRIL", + "title": "Edición externa sin coste adicional", + "url": "https://help.teable.ai/en/changelog#apr-23-2026", + "id": "changelog-2026-04-23-external-editing-with-no-extra-cost" } } diff --git a/packages/common-i18n/src/locales/es/sdk.json b/packages/common-i18n/src/locales/es/sdk.json index 38952db515..2c8fda1006 100644 --- a/packages/common-i18n/src/locales/es/sdk.json +++ b/packages/common-i18n/src/locales/es/sdk.json @@ -158,6 +158,7 @@ }, "invalidateSelected": "Valor no vĆ”lido", "invalidateSelectedTips": "Se ha eliminado el valor seleccionado, seleccione nuevamente", + "invalidConditionTip": "Esta condición de filtro no es vĆ”lida y se ignorarĆ”. Ajuste el valor.", "default": { "empty": "No se aplican condiciones de filtro", "placeholder": "Ingrese un valor" @@ -264,7 +265,8 @@ "configLabel_other_visible": "{{count}} visible fields", "showAll": "Mostrar todo", "hideAll": "Esconderse", - "primaryKey": "Campo primario: identifica registros\n" + "primaryKey": "Campo primario: identifica registros\n", + "notInCurrentView": "El campo \"{{fieldName}}\" no estĆ” visible en la vista actual y no se puede localizar" }, "expandRecord": { "copy": "Copiar al portapapeles", @@ -1221,7 +1223,9 @@ "button": { "clickCountReachedMaxCount": "El conteo de clics del botón ha alcanzado el lĆ­mite mĆ”ximo", "notSupportReset": "El campo de botón no admite restablecimiento" - } + }, + "primaryCannotBeLookup": "El campo principal no puede configurarse como un campo Lookup", + "primaryFieldAlreadyExists": "La tabla ya tiene un campo principal" }, "view": { "notFound": "Vista no encontrada", @@ -1432,7 +1436,8 @@ "linkedInPostNotFound": "Publicación de LinkedIn no encontrada: {{postId}}", "linkedInAuthorNotFound": "Autor de LinkedIn no encontrado: {{postId}}", "fetchLinkedInUserFailed": "Error al obtener el usuario de LinkedIn: {{error}}", - "domainAlreadyInUse": "Este dominio ya estĆ” vinculado a otra aplicación" + "domainAlreadyInUse": "Este dominio ya estĆ” vinculado a otra aplicación", + "domainReserved": "Subdominio reservado" } } } diff --git a/packages/common-i18n/src/locales/es/table.json b/packages/common-i18n/src/locales/es/table.json index 81ef28ee15..e52546cb91 100644 --- a/packages/common-i18n/src/locales/es/table.json +++ b/packages/common-i18n/src/locales/es/table.json @@ -638,6 +638,11 @@ } } } + }, + "errorType": { + "InvalidPrimaryLookup": "Campo principal mal configurado como Lookup", + "InvalidPrimaryType": "El campo principal tiene un tipo no admitido", + "MissingPrimary": "La tabla no tiene campo principal" } }, "index": { @@ -1047,6 +1052,8 @@ "advancedOptions": "Opciones avanzadas", "namingFieldLabel": "Prefijo del nombre del adjunto", "selectField": "Por defecto: Ć­ndice de adjunto", + "noPrefixOption": "Sin prefijo", + "noPrefixOptionDesc": "Mantener el nombre original", "groupByRow": "Archivar en carpetas", "groupByRowTip": "Cuando una fila tiene mĆŗltiples adjuntos, se colocarĆ”n en la misma carpeta; las filas con solo un adjunto no crearĆ”n una carpeta." } diff --git a/packages/common-i18n/src/locales/fr/common.json b/packages/common-i18n/src/locales/fr/common.json index 2b0f19db09..9a8f5c5d9e 100644 --- a/packages/common-i18n/src/locales/fr/common.json +++ b/packages/common-i18n/src/locales/fr/common.json @@ -164,8 +164,9 @@ }, "settings": { "title": "ParamĆØtres d'instance", + "allSetting": "ParamĆØtres de l'espace et personnels", "personal": { - "title": "ParamĆØtres personnels" + "title": "Personnel" }, "back": "Retour Ć  l'accueil", "account": { @@ -890,6 +891,10 @@ "runQuotaExceeded": { "title": "L'automatisation {{name}} a atteint le nombre maximal d'exĆ©cutions mensuelles", "message": "Le quota mensuel d'exĆ©cutions de l'automatisation {{name}} est Ć©puisĆ©. L'exĆ©cution est temporairement indisponible. Veuillez mettre Ć  niveau votre abonnement ou acheter des exĆ©cutions supplĆ©mentaires." + }, + "failedSummary": { + "title": "L'automatisation {{name}} a Ć©chouĆ© {{failCount}} fois", + "message": "Votre automatisation {{name}} a cumulĆ© {{failCount}} Ć©checs consĆ©cutifs. Ouvrez l'historique des exĆ©cutions pour voir les dĆ©tails." } }, "billing": { @@ -1105,9 +1110,9 @@ } }, "changelog": { - "newUpdate": "MISE ƀ JOUR DU 17 AVR", - "title": "Claude Opus 4.7 est dĆ©sormais disponible dans Teable", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "MISE ƀ JOUR DU 23 AVRIL", + "title": "Ɖdition externe sans coĆ»t supplĆ©mentaire", + "url": "https://help.teable.ai/en/changelog#apr-23-2026", + "id": "changelog-2026-04-23-external-editing-with-no-extra-cost" } } diff --git a/packages/common-i18n/src/locales/fr/sdk.json b/packages/common-i18n/src/locales/fr/sdk.json index a78b3ae227..5657dcb67d 100644 --- a/packages/common-i18n/src/locales/fr/sdk.json +++ b/packages/common-i18n/src/locales/fr/sdk.json @@ -158,6 +158,7 @@ }, "invalidateSelected": "Invalid value", "invalidateSelectedTips": "The selected value has been deleted, please select again", + "invalidConditionTip": "Cette condition de filtre est invalide et sera ignorĆ©e. Veuillez ajuster la valeur.", "default": { "empty": "Aucune condition de filtrage appliquĆ©e", "placeholder": "Entrez une valeur" @@ -264,7 +265,8 @@ "configLabel_other_visible": "{{count}} champs visibles", "showAll": "Tout afficher", "hideAll": "Tout masquer", - "primaryKey": "Champ principal : utilisĆ© pour identifier les enregistrements, ne peut pas ĆŖtre cachĆ© ou supprimĆ©" + "primaryKey": "Champ principal : utilisĆ© pour identifier les enregistrements, ne peut pas ĆŖtre cachĆ© ou supprimĆ©", + "notInCurrentView": "Le champ Ā« {{fieldName}} Ā» n'est pas visible dans la vue actuelle et ne peut pas ĆŖtre atteint" }, "expandRecord": { "copy": "Copier dans le presse-papiers", @@ -1221,7 +1223,9 @@ "button": { "clickCountReachedMaxCount": "Le nombre de clics sur le bouton a atteint la limite maximale", "notSupportReset": "Le champ de bouton ne prend pas en charge la rĆ©initialisation" - } + }, + "primaryCannotBeLookup": "Le champ principal ne peut pas ĆŖtre configurĆ© comme un champ Lookup", + "primaryFieldAlreadyExists": "La table a dĆ©jĆ  un champ principal" }, "view": { "notFound": "Vue introuvable", @@ -1432,7 +1436,8 @@ "linkedInPostNotFound": "Publication LinkedIn non trouvĆ©e : {{postId}}", "linkedInAuthorNotFound": "Auteur LinkedIn non trouvĆ© : {{postId}}", "fetchLinkedInUserFailed": "Ɖchec de la rĆ©cupĆ©ration de l'utilisateur LinkedIn : {{error}}", - "domainAlreadyInUse": "Ce domaine est dĆ©jĆ  liĆ© Ć  une autre application" + "domainAlreadyInUse": "Ce domaine est dĆ©jĆ  liĆ© Ć  une autre application", + "domainReserved": "Sous-domaine rĆ©servĆ©" } } } diff --git a/packages/common-i18n/src/locales/fr/table.json b/packages/common-i18n/src/locales/fr/table.json index 6bea471b1c..fcee272e3c 100644 --- a/packages/common-i18n/src/locales/fr/table.json +++ b/packages/common-i18n/src/locales/fr/table.json @@ -632,6 +632,11 @@ } } } + }, + "errorType": { + "InvalidPrimaryLookup": "Champ principal mal configurĆ© comme Lookup", + "InvalidPrimaryType": "Le champ principal a un type non pris en charge", + "MissingPrimary": "La table n'a pas de champ principal" } }, "index": { @@ -1033,6 +1038,8 @@ "advancedOptions": "Options avancĆ©es", "namingFieldLabel": "PrĆ©fixe du nom de piĆØce jointe", "selectField": "Par dĆ©faut: index de piĆØce jointe", + "noPrefixOption": "Sans prĆ©fixe", + "noPrefixOptionDesc": "Conserver le nom de fichier original", "groupByRow": "Archiver dans des dossiers", "groupByRowTip": "Lorsqu'une ligne contient plusieurs piĆØces jointes, elles seront placĆ©es dans le mĆŖme dossier ; les lignes avec une seule piĆØce jointe ne crĆ©eront pas de dossier." } diff --git a/packages/common-i18n/src/locales/it/common.json b/packages/common-i18n/src/locales/it/common.json index 0a1cbf8dd5..d4f134e3e8 100644 --- a/packages/common-i18n/src/locales/it/common.json +++ b/packages/common-i18n/src/locales/it/common.json @@ -164,8 +164,9 @@ }, "settings": { "title": "Impostazioni dell'istanza", + "allSetting": "Impostazioni dello spazio e personali", "personal": { - "title": "Impostazioni personali" + "title": "Personale" }, "templateAdmin": { "title": "Gestione modelli", @@ -890,6 +891,10 @@ "runQuotaExceeded": { "title": "L'automazione {{name}} ha raggiunto il numero massimo mensile di esecuzioni", "message": "Le esecuzioni mensili dell'automazione {{name}} sono esaurite e l'esecuzione ĆØ temporaneamente non disponibile. Aggiorna l'abbonamento o acquista esecuzioni aggiuntive." + }, + "failedSummary": { + "title": "L'automazione {{name}} ha fallito {{failCount}} volte", + "message": "La tua automazione {{name}} ha accumulato {{failCount}} errori consecutivi. Apri la cronologia delle esecuzioni per visualizzare i dettagli." } }, "billing": { @@ -1105,9 +1110,9 @@ } }, "changelog": { - "newUpdate": "AGGIORNAMENTO 17 APR", - "title": "Claude Opus 4.7 ĆØ ora disponibile in Teable", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "AGGIORNAMENTO DEL 23 APRILE", + "title": "Modifica esterna senza costi aggiuntivi", + "url": "https://help.teable.ai/en/changelog#apr-23-2026", + "id": "changelog-2026-04-23-external-editing-with-no-extra-cost" } } diff --git a/packages/common-i18n/src/locales/it/sdk.json b/packages/common-i18n/src/locales/it/sdk.json index f7128f8ddd..acc5fa94d8 100644 --- a/packages/common-i18n/src/locales/it/sdk.json +++ b/packages/common-i18n/src/locales/it/sdk.json @@ -158,6 +158,7 @@ }, "invalidateSelected": "Valore non valido", "invalidateSelectedTips": "Il valore selezionato ĆØ stato eliminato, per favore seleziona di nuovo", + "invalidConditionTip": "Questa condizione di filtro non ĆØ valida e verrĆ  ignorata. Regola il valore.", "default": { "empty": "Nessuna condizione di filtro applicata", "placeholder": "Inserisci un valore" @@ -264,7 +265,8 @@ "configLabel_other_visible": "{{count}} campi visibili", "showAll": "Mostra tutto", "hideAll": "Nascondi tutto", - "primaryKey": "Campo principale: Identifica i record\nnon può essere nascosto o eliminato, visibile nei record collegati." + "primaryKey": "Campo principale: Identifica i record\nnon può essere nascosto o eliminato, visibile nei record collegati.", + "notInCurrentView": "Il campo \"{{fieldName}}\" non ĆØ visibile nella vista corrente e non può essere raggiunto" }, "expandRecord": { "copy": "Copia negli appunti", @@ -1221,7 +1223,9 @@ "button": { "clickCountReachedMaxCount": "Il conteggio dei clic sul pulsante ha raggiunto il limite massimo", "notSupportReset": "Il campo pulsante non supporta il ripristino" - } + }, + "primaryCannotBeLookup": "Il campo primario non può essere configurato come campo Lookup", + "primaryFieldAlreadyExists": "La tabella ha giĆ  un campo primario" }, "view": { "notFound": "Vista non trovata", @@ -1432,7 +1436,8 @@ "linkedInPostNotFound": "Post LinkedIn non trovato: {{postId}}", "linkedInAuthorNotFound": "Autore LinkedIn non trovato: {{postId}}", "fetchLinkedInUserFailed": "Impossibile recuperare l'utente LinkedIn: {{error}}", - "domainAlreadyInUse": "Questo dominio ĆØ giĆ  associato a un'altra app" + "domainAlreadyInUse": "Questo dominio ĆØ giĆ  associato a un'altra app", + "domainReserved": "Sottodominio riservato" } } } diff --git a/packages/common-i18n/src/locales/it/table.json b/packages/common-i18n/src/locales/it/table.json index f99cd1d33a..6c4624459c 100644 --- a/packages/common-i18n/src/locales/it/table.json +++ b/packages/common-i18n/src/locales/it/table.json @@ -641,6 +641,11 @@ } } } + }, + "errorType": { + "InvalidPrimaryLookup": "Campo primario configurato erroneamente come Lookup", + "InvalidPrimaryType": "Il campo primario ha un tipo non supportato", + "MissingPrimary": "La tabella non ha un campo primario" } }, "index": { @@ -1058,6 +1063,8 @@ "advancedOptions": "Opzioni avanzate", "namingFieldLabel": "Prefisso nome allegato", "selectField": "Predefinito: indice allegato", + "noPrefixOption": "Nessun prefisso", + "noPrefixOptionDesc": "Mantieni il nome file originale", "groupByRow": "Archivia in cartelle", "groupByRowTip": "Quando una riga ha più allegati, verranno inseriti nella stessa cartella; le righe con un solo allegato non creeranno una cartella." } diff --git a/packages/common-i18n/src/locales/ja/common.json b/packages/common-i18n/src/locales/ja/common.json index 158a0201b0..5bb45c7412 100644 --- a/packages/common-i18n/src/locales/ja/common.json +++ b/packages/common-i18n/src/locales/ja/common.json @@ -164,8 +164,9 @@ }, "settings": { "title": "ć‚¤ćƒ³ć‚¹ć‚æćƒ³ć‚¹čØ­å®š", + "allSetting": "ć‚¹ćƒšćƒ¼ć‚¹ćØå€‹äŗŗčØ­å®š", "personal": { - "title": "å€‹äŗŗčØ­å®š" + "title": "個人" }, "templateAdmin": { "title": "ćƒ†ćƒ³ćƒ—ćƒ¬ćƒ¼ćƒˆē®”ē†", @@ -892,6 +893,10 @@ "runQuotaExceeded": { "title": "č‡Ŗå‹•åŒ–{{name}}ćÆęœˆé–“å®Ÿč”Œå›žę•°ć®äøŠé™ć«é”ć—ć¾ć—ćŸ", "message": "č‡Ŗå‹•åŒ–{{name}}ć®ä»Šęœˆć®å®Ÿč”Œå›žę•°ć‚’ä½æć„åˆ‡ć£ćŸćŸć‚ć€ē¾åœØćÆå®Ÿč”Œć§ćć¾ć›ć‚“ć€‚ćƒ—ćƒ©ćƒ³ć‚’ć‚¢ćƒƒćƒ—ć‚°ćƒ¬ćƒ¼ćƒ‰ć™ć‚‹ć‹ć€čæ½åŠ å®Ÿč”Œå›žę•°ć‚’č³¼å…„ć—ć¦ćć ć•ć„ć€‚" + }, + "failedSummary": { + "title": "č‡Ŗå‹•åŒ–{{name}}が{{failCount}}å›žé€£ē¶šć§å¤±ę•—ć—ć¾ć—ćŸ", + "message": "č‡Ŗå‹•åŒ–{{name}}が{{failCount}}å›žé€£ē¶šć§å¤±ę•—ć—ć¦ć„ć¾ć™ć€‚å®Ÿč”Œå±„ę­“ć‚’é–‹ć„ć¦č©³ē“°ć‚’ć”ē¢ŗčŖćć ć•ć„ć€‚" } }, "billing": { @@ -1107,9 +1112,9 @@ } }, "changelog": { - "newUpdate": "4月17ę—„ ć‚¢ćƒƒćƒ—ćƒ‡ćƒ¼ćƒˆ", - "title": "Claude Opus 4.7 が Teable ć§åˆ©ē”ØåÆčƒ½ć«", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "4月23ę—„ć®ć‚¢ćƒƒćƒ—ćƒ‡ćƒ¼ćƒˆ", + "title": "čæ½åŠ ę–™é‡‘ćŖć—ć®å¤–éƒØē·Øé›†", + "url": "https://help.teable.ai/en/changelog#apr-23-2026", + "id": "changelog-2026-04-23-external-editing-with-no-extra-cost" } } diff --git a/packages/common-i18n/src/locales/ja/sdk.json b/packages/common-i18n/src/locales/ja/sdk.json index c954b711e9..1ecbb57373 100644 --- a/packages/common-i18n/src/locales/ja/sdk.json +++ b/packages/common-i18n/src/locales/ja/sdk.json @@ -158,6 +158,7 @@ }, "invalidateSelected": "ē„”åŠ¹ćŖå€¤", "invalidateSelectedTips": "éøęŠžć—ćŸå€¤ćÆå‰Šé™¤ć•ć‚Œć¾ć—ćŸć€‚ć‚‚ć†äø€åŗ¦éøęŠžć—ć¦ćć ć•ć„", + "invalidConditionTip": "ć“ć®ćƒ•ć‚£ćƒ«ć‚æćƒ¼ę”ä»¶ćÆē„”åŠ¹ćŖćŸć‚ē„”č¦–ć•ć‚Œć¾ć™ć€‚å€¤ć‚’čŖæę•“ć—ć¦ćć ć•ć„ć€‚", "default": { "empty": "ćƒ•ć‚£ćƒ«ć‚æćƒ¼ę”ä»¶ćÆé©ē”Øć•ć‚Œć¾ć›ć‚“", "placeholder": "å€¤ć‚’å…„åŠ›ć—ć¦ćć ć•ć„" @@ -264,7 +265,8 @@ "configLabel_other_visible": "{{count}}č”Øē¤ŗćƒ•ć‚£ćƒ¼ćƒ«ćƒ‰", "showAll": "すべて蔨示", "hideAll": "ć™ć¹ć¦éžč”Øē¤ŗ", - "primaryKey": "ćƒ—ćƒ©ć‚¤ćƒžćƒŖćƒ•ć‚£ćƒ¼ćƒ«ćƒ‰: ćƒ¬ć‚³ćƒ¼ćƒ‰ć‚’č­˜åˆ„ć™ć‚‹ćŸć‚ć«ä½æē”Øć•ć‚Œć€éžč”Øē¤ŗć«ć—ćŸć‚Šå‰Šé™¤ć—ćŸć‚Šć™ć‚‹ć“ćØćÆć§ćć¾ć›ć‚“" + "primaryKey": "ćƒ—ćƒ©ć‚¤ćƒžćƒŖćƒ•ć‚£ćƒ¼ćƒ«ćƒ‰: ćƒ¬ć‚³ćƒ¼ćƒ‰ć‚’č­˜åˆ„ć™ć‚‹ćŸć‚ć«ä½æē”Øć•ć‚Œć€éžč”Øē¤ŗć«ć—ćŸć‚Šå‰Šé™¤ć—ćŸć‚Šć™ć‚‹ć“ćØćÆć§ćć¾ć›ć‚“", + "notInCurrentView": "ćƒ•ć‚£ćƒ¼ćƒ«ćƒ‰ć€Œ{{fieldName}}ć€ćÆē¾åœØć®ćƒ“ćƒ„ćƒ¼ć«č”Øē¤ŗć•ć‚Œć¦ć„ćŖć„ćŸć‚ć€ē§»å‹•ć§ćć¾ć›ć‚“" }, "expandRecord": { "copy": "ć‚ÆćƒŖćƒƒćƒ—ćƒœćƒ¼ćƒ‰ć«ć‚³ćƒ”ćƒ¼", @@ -1221,7 +1223,9 @@ "button": { "clickCountReachedMaxCount": "ćƒœć‚æćƒ³ć®ć‚ÆćƒŖćƒƒć‚Æę•°ćŒęœ€å¤§åˆ¶é™ć«é”ć—ć¾ć—ćŸ", "notSupportReset": "ćƒœć‚æćƒ³ćÆćƒŖć‚»ćƒƒćƒˆć‚’ć‚µćƒćƒ¼ćƒˆć—ć¦ć„ć¾ć›ć‚“" - } + }, + "primaryCannotBeLookup": "äø»ćƒ•ć‚£ćƒ¼ćƒ«ćƒ‰ć‚’ Lookup ćƒ•ć‚£ćƒ¼ćƒ«ćƒ‰ćØć—ć¦čØ­å®šć™ć‚‹ć“ćØćÆć§ćć¾ć›ć‚“", + "primaryFieldAlreadyExists": "ćƒ†ćƒ¼ćƒ–ćƒ«ć«ćÆę—¢ć«äø»ćƒ•ć‚£ćƒ¼ćƒ«ćƒ‰ćŒć‚ć‚Šć¾ć™" }, "view": { "notFound": "ćƒ“ćƒ„ćƒ¼ćŒč¦‹ć¤ć‹ć‚Šć¾ć›ć‚“", @@ -1432,7 +1436,8 @@ "linkedInPostNotFound": "LinkedInęŠ•ēØæćŒč¦‹ć¤ć‹ć‚Šć¾ć›ć‚“: {{postId}}", "linkedInAuthorNotFound": "LinkedInęŠ•ēØæč€…ćŒč¦‹ć¤ć‹ć‚Šć¾ć›ć‚“: {{postId}}", "fetchLinkedInUserFailed": "LinkedInćƒ¦ćƒ¼ć‚¶ćƒ¼ć®å–å¾—ć«å¤±ę•—ć—ć¾ć—ćŸ: {{error}}", - "domainAlreadyInUse": "ć“ć®ćƒ‰ćƒ”ć‚¤ćƒ³ćÆć™ć§ć«åˆ„ć®ć‚¢ćƒ—ćƒŖć«ē“ä»˜ć‘ć‚‰ć‚Œć¦ć„ć¾ć™" + "domainAlreadyInUse": "ć“ć®ćƒ‰ćƒ”ć‚¤ćƒ³ćÆć™ć§ć«åˆ„ć®ć‚¢ćƒ—ćƒŖć«ē“ä»˜ć‘ć‚‰ć‚Œć¦ć„ć¾ć™", + "domainReserved": "ć‚µćƒ–ćƒ‰ćƒ”ć‚¤ćƒ³ćÆäŗˆē“„ęøˆćæć§ć™" } } } diff --git a/packages/common-i18n/src/locales/ja/table.json b/packages/common-i18n/src/locales/ja/table.json index acf3f2e5aa..f743126e98 100644 --- a/packages/common-i18n/src/locales/ja/table.json +++ b/packages/common-i18n/src/locales/ja/table.json @@ -632,6 +632,11 @@ } } } + }, + "errorType": { + "InvalidPrimaryLookup": "äø»ćƒ•ć‚£ćƒ¼ćƒ«ćƒ‰ćŒ Lookup ćØć—ć¦čŖ¤čØ­å®š", + "InvalidPrimaryType": "äø»ćƒ•ć‚£ćƒ¼ćƒ«ćƒ‰ć®ć‚æć‚¤ćƒ—ćŒéžåÆ¾åæœ", + "MissingPrimary": "ćƒ†ćƒ¼ćƒ–ćƒ«ć«äø»ćƒ•ć‚£ćƒ¼ćƒ«ćƒ‰ćŒć‚ć‚Šć¾ć›ć‚“" } }, "index": { @@ -1032,6 +1037,8 @@ "advancedOptions": "č©³ē“°ć‚Ŗćƒ—ć‚·ćƒ§ćƒ³", "namingFieldLabel": "ę·»ä»˜ćƒ•ć‚”ć‚¤ćƒ«åć®ćƒ—ćƒ¬ćƒ•ć‚£ćƒƒć‚Æć‚¹", "selectField": "ćƒ‡ćƒ•ć‚©ćƒ«ćƒˆ: ę·»ä»˜ćƒ•ć‚”ć‚¤ćƒ«ē•Ŗå·", + "noPrefixOption": "ćƒ—ćƒ¬ćƒ•ć‚£ćƒƒć‚Æć‚¹ćŖć—", + "noPrefixOptionDesc": "å…ƒć®ćƒ•ć‚”ć‚¤ćƒ«åć‚’äæęŒ", "groupByRow": "ćƒ•ć‚©ćƒ«ćƒ€ć«ć‚¢ćƒ¼ć‚«ć‚¤ćƒ–", "groupByRowTip": "č”Œć«č¤‡ę•°ć®ę·»ä»˜ćƒ•ć‚”ć‚¤ćƒ«ćŒć‚ć‚‹å “åˆć€åŒć˜ćƒ•ć‚©ćƒ«ćƒ€ć«é…ē½®ć•ć‚Œć¾ć™ć€‚ę·»ä»˜ćƒ•ć‚”ć‚¤ćƒ«ćŒ1ć¤ć ć‘ć®č”ŒćÆćƒ•ć‚©ćƒ«ćƒ€ć‚’ä½œęˆć—ć¾ć›ć‚“ć€‚" } diff --git a/packages/common-i18n/src/locales/ru/common.json b/packages/common-i18n/src/locales/ru/common.json index 39ccb67d8f..14cfebe76a 100644 --- a/packages/common-i18n/src/locales/ru/common.json +++ b/packages/common-i18n/src/locales/ru/common.json @@ -164,8 +164,9 @@ }, "settings": { "title": "ŠŠ°ŃŃ‚Ń€Š¾Š¹ŠŗŠø ŃŠŗŠ·ŠµŠ¼ŠæŠ»ŃŃ€Š°", + "allSetting": "ŠŠ°ŃŃ‚Ń€Š¾Š¹ŠŗŠø пространства Šø личные настройки", "personal": { - "title": "Личные настройки" + "title": "Личное" }, "templateAdmin": { "title": "Управление шаблонами", @@ -848,6 +849,10 @@ "runQuotaExceeded": { "title": "ŠŠ²Ń‚Š¾Š¼Š°Ń‚ŠøŠ·Š°Ń†ŠøŃ {{name}} Гостигла максимального Š¼ŠµŃŃŃ‡Š½Š¾Š³Š¾ лимита запусков", "message": "ŠœŠµŃŃŃ‡Š½Ń‹Š¹ лимит запусков Š“Š»Ń автоматизации {{name}} исчерпан, ŠæŠ¾ŃŃ‚Š¾Š¼Ńƒ сейчас запуск Š½ŠµŠ“Š¾ŃŃ‚ŃƒŠæŠµŠ½. ŠžŠ±Š½Š¾Š²ŠøŃ‚Šµ поГписку или приобретите Š“Š¾ŠæŠ¾Š»Š½ŠøŃ‚ŠµŠ»ŃŒŠ½Ń‹Šµ запуски." + }, + "failedSummary": { + "title": "ŠŠ²Ń‚Š¾Š¼Š°Ń‚ŠøŠ·Š°Ń†ŠøŃ {{name}} Š·Š°Š²ŠµŃ€ŃˆŠøŠ»Š°ŃŃŒ ошибкой {{failCount}} раз", + "message": "Š’Š°ŃˆŠ° Š°Š²Ń‚Š¾Š¼Š°Ń‚ŠøŠ·Š°Ń†ŠøŃ {{name}} накопила {{failCount}} ŠæŠ¾ŃŠ»ŠµŠ“Š¾Š²Š°Ń‚ŠµŠ»ŃŒŠ½Ń‹Ń… ошибок. ŠžŃ‚ŠŗŃ€Š¾Š¹Ń‚Šµ ŠøŃŃ‚Š¾Ń€ŠøŃŽ запусков Š“Š»Ń просмотра Геталей." } }, "billing": { @@ -1063,9 +1068,9 @@ } }, "changelog": { - "newUpdate": "ŠžŠ‘ŠŠžŠ’Š›Š•ŠŠ˜Š• 17 АПР", - "title": "Claude Opus 4.7 Ń‚ŠµŠæŠµŃ€ŃŒ Š“Š¾ŃŃ‚ŃƒŠæŠµŠ½ в Teable", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "ŠžŠ‘ŠŠžŠ’Š›Š•ŠŠ˜Š• ŠžŠ¢ 23 ŠŠŸŠ Š•Š›ŠÆ", + "title": "Š’Š½ŠµŃˆŠ½ŠµŠµ реГактирование без Š“Š¾ŠæŠ¾Š»Š½ŠøŃ‚ŠµŠ»ŃŒŠ½Ń‹Ń… затрат", + "url": "https://help.teable.ai/en/changelog#apr-23-2026", + "id": "changelog-2026-04-23-external-editing-with-no-extra-cost" } } diff --git a/packages/common-i18n/src/locales/ru/sdk.json b/packages/common-i18n/src/locales/ru/sdk.json index 276ab5a3e9..4eaab6d72a 100644 --- a/packages/common-i18n/src/locales/ru/sdk.json +++ b/packages/common-i18n/src/locales/ru/sdk.json @@ -158,6 +158,7 @@ }, "invalidateSelected": "ŠŠµŠ²ŠµŃ€Š½Š¾Šµ значение", "invalidateSelectedTips": "Выбранное значение было уГалено, выберите Š“Ń€ŃƒŠ³Š¾Šµ", + "invalidConditionTip": "Это условие Ń„ŠøŠ»ŃŒŃ‚Ń€Š° Š½ŠµŠ“Š¾ŠæŃƒŃŃ‚ŠøŠ¼Š¾ Šø Š±ŃƒŠ“ŠµŃ‚ проигнорировано. Š˜Š·Š¼ŠµŠ½ŠøŃ‚Šµ значение.", "default": { "empty": "Š¤ŠøŠ»ŃŒŃ‚Ń€Ń‹ не применены", "placeholder": "ВвеГите значение" @@ -264,7 +265,8 @@ "configLabel_other_visible": "{{count}} виГимых ŠæŠ¾Š»Ń", "showAll": "ŠŸŠ¾ŠŗŠ°Š·Š°Ń‚ŃŒ все", "hideAll": "Š”ŠŗŃ€Ń‹Ń‚ŃŒ все", - "primaryKey": "ŠžŃŠ½Š¾Š²Š½Š¾Šµ поле: Š˜Š“ŠµŠ½Ń‚ŠøŃ„ŠøŃ†ŠøŃ€ŃƒŠµŃ‚ записи\nŠ½ŠµŠ»ŃŒŠ·Ń ŃŠŗŃ€Ń‹Ń‚ŃŒ или ŃƒŠ“Š°Š»ŠøŃ‚ŃŒ, виГно в ŃŠ²ŃŠ·Š°Š½Š½Ń‹Ń… Š·Š°ŠæŠøŃŃŃ…." + "primaryKey": "ŠžŃŠ½Š¾Š²Š½Š¾Šµ поле: Š˜Š“ŠµŠ½Ń‚ŠøŃ„ŠøŃ†ŠøŃ€ŃƒŠµŃ‚ записи\nŠ½ŠµŠ»ŃŒŠ·Ń ŃŠŗŃ€Ń‹Ń‚ŃŒ или ŃƒŠ“Š°Š»ŠøŃ‚ŃŒ, виГно в ŃŠ²ŃŠ·Š°Š½Š½Ń‹Ń… Š·Š°ŠæŠøŃŃŃ….", + "notInCurrentView": "Поле Ā«{{fieldName}}Ā» не Š¾Ń‚Š¾Š±Ń€Š°Š¶Š°ŠµŃ‚ŃŃ в Ń‚ŠµŠŗŃƒŃ‰ŠµŠ¼ преГставлении, ŠæŠ¾ŃŃ‚Š¾Š¼Ńƒ перейти Šŗ нему Š½ŠµŠ»ŃŒŠ·Ń" }, "expandRecord": { "copy": "ŠšŠ¾ŠæŠøŃ€Š¾Š²Š°Ń‚ŃŒ в Š±ŃƒŃ„ер обмена", @@ -1221,7 +1223,9 @@ "button": { "clickCountReachedMaxCount": "ŠšŠ¾Š»ŠøŃ‡ŠµŃŃ‚Š²Š¾ нажатий кнопки Гостигло максимального преГела", "notSupportReset": "Кнопка не поГГерживает сброс" - } + }, + "primaryCannotBeLookup": "ŠžŃŠ½Š¾Š²Š½Š¾Šµ поле не может Š±Ń‹Ń‚ŃŒ настроено как поле Lookup", + "primaryFieldAlreadyExists": "Š’ таблице уже ŠµŃŃ‚ŃŒ основное поле" }, "view": { "notFound": "ŠŸŃ€ŠµŠ“ŃŃ‚Š°Š²Š»ŠµŠ½ŠøŠµ не найГено", @@ -1432,7 +1436,8 @@ "linkedInPostNotFound": "ŠŸŃƒŠ±Š»ŠøŠŗŠ°Ń†ŠøŃ LinkedIn не найГена: {{postId}}", "linkedInAuthorNotFound": "Автор LinkedIn не найГен: {{postId}}", "fetchLinkedInUserFailed": "ŠŠµ уГалось ŠæŠ¾Š»ŃƒŃ‡ŠøŃ‚ŃŒ ŠæŠ¾Š»ŃŒŠ·Š¾Š²Š°Ń‚ŠµŠ»Ń LinkedIn: {{error}}", - "domainAlreadyInUse": "Этот Гомен уже ŠæŃ€ŠøŠ²ŃŠ·Š°Š½ Šŗ Š“Ń€ŃƒŠ³Š¾Š¼Ńƒ ŠæŃ€ŠøŠ»Š¾Š¶ŠµŠ½ŠøŃŽ" + "domainAlreadyInUse": "Этот Гомен уже ŠæŃ€ŠøŠ²ŃŠ·Š°Š½ Šŗ Š“Ń€ŃƒŠ³Š¾Š¼Ńƒ ŠæŃ€ŠøŠ»Š¾Š¶ŠµŠ½ŠøŃŽ", + "domainReserved": "ПоГГомен зарезервирован" } } } diff --git a/packages/common-i18n/src/locales/ru/table.json b/packages/common-i18n/src/locales/ru/table.json index a4a51abbc7..14c71c1ec8 100644 --- a/packages/common-i18n/src/locales/ru/table.json +++ b/packages/common-i18n/src/locales/ru/table.json @@ -632,6 +632,11 @@ } } } + }, + "errorType": { + "InvalidPrimaryLookup": "ŠžŃŠ½Š¾Š²Š½Š¾Šµ поле Š½ŠµŠæŃ€Š°Š²ŠøŠ»ŃŒŠ½Š¾ настроено как Lookup", + "InvalidPrimaryType": "ŠžŃŠ½Š¾Š²Š½Š¾Šµ поле имеет непоГГерживаемый тип", + "MissingPrimary": "Š’ таблице Š¾Ń‚ŃŃƒŃ‚ŃŃ‚Š²ŃƒŠµŃ‚ основное поле" } }, "index": { @@ -1033,6 +1038,8 @@ "advancedOptions": "Š”Š¾ŠæŠ¾Š»Š½ŠøŃ‚ŠµŠ»ŃŒŠ½Ń‹Šµ параметры", "namingFieldLabel": "ŠŸŃ€ŠµŃ„ŠøŠŗŃ имени Š²Š»Š¾Š¶ŠµŠ½ŠøŃ", "selectField": "По ŃƒŠ¼Š¾Š»Ń‡Š°Š½ŠøŃŽ: инГекс Š²Š»Š¾Š¶ŠµŠ½ŠøŃ", + "noPrefixOption": "Без префикса", + "noPrefixOptionDesc": "Š”Š¾Ń…Ń€Š°Š½ŠøŃ‚ŃŒ исхоГное ŠøŠ¼Ń файла", "groupByRow": "ŠŃ€Ń…ŠøŠ²ŠøŃ€Š¾Š²Š°Ń‚ŃŒ в папки", "groupByRowTip": "КогГа в строке несколько вложений, они Š±ŃƒŠ“ŃƒŃ‚ помещены в оГну папку; строки с оГним вложением не ŃŠ¾Š·Š“Š°Š“ŃƒŃ‚ папку." } diff --git a/packages/common-i18n/src/locales/tr/common.json b/packages/common-i18n/src/locales/tr/common.json index 51af08a2bf..029ef5b607 100644 --- a/packages/common-i18n/src/locales/tr/common.json +++ b/packages/common-i18n/src/locales/tr/common.json @@ -164,8 +164,9 @@ }, "settings": { "title": "Ɩrnek Ayarları", + "allSetting": "Alan ve kişisel ayarlar", "personal": { - "title": "Kişisel ayarlar" + "title": "Kişisel" }, "templateAdmin": { "title": "Şablon yƶnetimi", @@ -849,6 +850,10 @@ "runQuotaExceeded": { "title": "Otomasyon {{name}} aylık maksimum Ƨalıştırma sayısına ulaştı", "message": "Otomasyon {{name}} iƧin aylık Ƨalıştırma hakkı tükendiği iƧin şu anda Ƨalıştırılamıyor. Aboneliğinizi yükseltin veya ek Ƨalıştırma satın alın." + }, + "failedSummary": { + "title": "Otomasyon {{name}} {{failCount}} kez başarısız oldu", + "message": "Otomasyonunuz {{name}} art arda {{failCount}} kez başarısız oldu. Ayrıntıları gƶrmek iƧin Ƨalıştırma geƧmişini aƧın." } }, "billing": { @@ -1095,9 +1100,9 @@ } }, "changelog": { - "newUpdate": "17 NİS GÜNCELLEMESİ", - "title": "Claude Opus 4.7 artık Teable'da", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "23 NİSAN GÜNCELLEMESİ", + "title": "Ek Ücret Olmadan Harici Düzenleme", + "url": "https://help.teable.ai/en/changelog#apr-23-2026", + "id": "changelog-2026-04-23-external-editing-with-no-extra-cost" } } diff --git a/packages/common-i18n/src/locales/tr/sdk.json b/packages/common-i18n/src/locales/tr/sdk.json index 690689a9c0..8ccf6a168a 100644 --- a/packages/common-i18n/src/locales/tr/sdk.json +++ b/packages/common-i18n/src/locales/tr/sdk.json @@ -158,6 +158,7 @@ }, "invalidateSelected": "GeƧersiz değer", "invalidateSelectedTips": "SeƧilen değer silindi, lütfen tekrar seƧin", + "invalidConditionTip": "Bu filtre koşulu geƧersiz ve yok sayılacak. Lütfen değeri ayarlayın.", "default": { "empty": "Filtre koşulu uygulanmadı", "placeholder": "Bir değer girin" @@ -264,7 +265,8 @@ "configLabel_other_visible": "{{count}} gƶrünür alan", "showAll": "Tümünü Gƶster", "hideAll": "Tümünü Gizle", - "primaryKey": "Birincil alan: Kayıtları tanımlar\ngizlenemez veya silinemez, bağlantılı kayıtlarda gƶrünür." + "primaryKey": "Birincil alan: Kayıtları tanımlar\ngizlenemez veya silinemez, bağlantılı kayıtlarda gƶrünür.", + "notInCurrentView": "\"{{fieldName}}\" alanı mevcut gƶrünümde gƶrünmüyor, bu yüzden alana gidilemiyor" }, "expandRecord": { "copy": "Panoya kopyala", @@ -1221,7 +1223,9 @@ "button": { "clickCountReachedMaxCount": "Düğme tıklama sayısı maksimum sınıra ulaştı", "notSupportReset": "Düğme sıfırlamayı desteklemiyor" - } + }, + "primaryCannotBeLookup": "Birincil alan Lookup alanı olarak yapılandırılamaz", + "primaryFieldAlreadyExists": "Tablonun zaten bir birincil alanı var" }, "view": { "notFound": "Gƶrünüm bulunamadı", @@ -1432,7 +1436,8 @@ "linkedInPostNotFound": "LinkedIn gƶnderisi bulunamadı: {{postId}}", "linkedInAuthorNotFound": "LinkedIn yazarı bulunamadı: {{postId}}", "fetchLinkedInUserFailed": "LinkedIn kullanıcısı alınamadı: {{error}}", - "domainAlreadyInUse": "Bu alan adı zaten başka bir uygulamaya bağlı" + "domainAlreadyInUse": "Bu alan adı zaten başka bir uygulamaya bağlı", + "domainReserved": "Alt alan adı ayrılmış" } } } diff --git a/packages/common-i18n/src/locales/tr/table.json b/packages/common-i18n/src/locales/tr/table.json index 9e9f81204f..a9568fe4fc 100644 --- a/packages/common-i18n/src/locales/tr/table.json +++ b/packages/common-i18n/src/locales/tr/table.json @@ -640,6 +640,11 @@ } } } + }, + "errorType": { + "InvalidPrimaryLookup": "Birincil alan Lookup olarak yanlış yapılandırıldı", + "InvalidPrimaryType": "Birincil alanın türü desteklenmiyor", + "MissingPrimary": "Tabloda birincil alan yok" } } }, @@ -878,6 +883,8 @@ "advancedOptions": "Gelişmiş seƧenekler", "namingFieldLabel": "Ek adı ƶneki", "selectField": "Varsayılan: ek dizini", + "noPrefixOption": "Ɩnek yok", + "noPrefixOptionDesc": "Orijinal dosya adını koru", "groupByRow": "Klasƶrlere arşivle", "groupByRowTip": "Bir satırda birden fazla ek varsa, aynı klasƶre yerleştirilir; yalnızca bir eki olan satırlar klasƶr oluşturmaz." } diff --git a/packages/common-i18n/src/locales/uk/common.json b/packages/common-i18n/src/locales/uk/common.json index 40eefbb58f..ade8b538b6 100644 --- a/packages/common-i18n/src/locales/uk/common.json +++ b/packages/common-i18n/src/locales/uk/common.json @@ -151,8 +151,9 @@ }, "settings": { "title": "ŠŸŃ€ŠøŠŗŠ»Š°Š“ Š½Š°Š»Š°ŃˆŃ‚ŃƒŠ²Š°Š½ŃŒ", + "allSetting": "ŠŠ°Š»Š°ŃˆŃ‚ŃƒŠ²Š°Š½Š½Ń ŠæŃ€Š¾ŃŃ‚Š¾Ń€Ńƒ й особисті", "personal": { - "title": "ŠžŃŠ¾Š±ŠøŃŃ‚Ń– Š½Š°Š»Š°ŃˆŃ‚ŃƒŠ²Š°Š½Š½Ń" + "title": "ŠžŃŠ¾Š±ŠøŃŃ‚Šµ" }, "templateAdmin": { "title": "ŠšŠµŃ€ŃƒŠ²Š°Š½Š½Ń шаблонами", @@ -838,6 +839,10 @@ "runQuotaExceeded": { "title": "ŠŠ²Ń‚Š¾Š¼Š°Ń‚ŠøŠ·Š°Ń†Ń–Ń {{name}} Š“Š¾ŃŃŠ³Š»Š° максимального Š¼Ń–ŃŃŃ‡Š½Š¾Š³Š¾ Š»Ń–Š¼Ń–Ń‚Ńƒ Š·Š°ŠæŃƒŃŠŗŃ–Š²", "message": "ŠœŃ–ŃŃŃ‡Š½ŠøŠ¹ ліміт Š·Š°ŠæŃƒŃŠŗŃ–Š² Š“Š»Ń автоматизації {{name}} вичерпано, Ń‚Š¾Š¼Ńƒ зараз запуск Š½ŠµŠ“Š¾ŃŃ‚ŃƒŠæŠ½ŠøŠ¹. ŠžŠ½Š¾Š²Ń–Ń‚ŃŒ ŠæŃ–Š“ŠæŠøŃŠŗŃƒ або приГбайте ГоГаткові запуски." + }, + "failedSummary": { + "title": "ŠŠ²Ń‚Š¾Š¼Š°Ń‚ŠøŠ·Š°Ń†Ń–Ń {{name}} Š·Š°Š²ŠµŃ€ŃˆŠøŠ»Š°ŃŃ ŠæŠ¾Š¼ŠøŠ»ŠŗŠ¾ŃŽ {{failCount}} разів", + "message": "Š’Š°ŃˆŠ° Š°Š²Ń‚Š¾Š¼Š°Ń‚ŠøŠ·Š°Ń†Ń–Ń {{name}} накопичила {{failCount}} посліГовних помилок. ВіГкрийте Ń–ŃŃ‚Š¾Ń€Ń–ŃŽ Š·Š°ŠæŃƒŃŠŗŃ–Š², щоб ŠæŠµŃ€ŠµŠ³Š»ŃŠ½ŃƒŃ‚Šø Геталі." } }, "billing": { @@ -1084,9 +1089,9 @@ } }, "changelog": { - "newUpdate": "ŠžŠŠžŠ’Š›Š•ŠŠŠÆ 17 ŠšŠ’Š†Š¢", - "title": "Claude Opus 4.7 тепер Š“Š¾ŃŃ‚ŃƒŠæŠ½ŠøŠ¹ у Teable", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "ŠžŠŠžŠ’Š›Š•ŠŠŠÆ ВІД 23 ŠšŠ’Š†Š¢ŠŠÆ", + "title": "Š—Š¾Š²Š½Ń–ŃˆŠ½Ń” Ń€ŠµŠ“Š°Š³ŃƒŠ²Š°Š½Š½Ń без ГоГаткових витрат", + "url": "https://help.teable.ai/en/changelog#apr-23-2026", + "id": "changelog-2026-04-23-external-editing-with-no-extra-cost" } } diff --git a/packages/common-i18n/src/locales/uk/sdk.json b/packages/common-i18n/src/locales/uk/sdk.json index 26aff93b15..ffd39041b1 100644 --- a/packages/common-i18n/src/locales/uk/sdk.json +++ b/packages/common-i18n/src/locales/uk/sdk.json @@ -158,6 +158,7 @@ }, "invalidateSelected": "ŠŠµŠ“Ń–Š¹ŃŠ½Šµ Š·Š½Š°Ń‡ŠµŠ½Š½Ń", "invalidateSelectedTips": "Вибране Š·Š½Š°Ń‡ŠµŠ½Š½Ń виГалено, Š²ŠøŠ±ŠµŃ€Ń–Ń‚ŃŒ ще раз", + "invalidConditionTip": "Š¦Ń умова Ń„Ń–Š»ŃŒŃ‚Ń€Š° неГійсна і буГе проігнорована. Š—Š¼Ń–Š½Ń–Ń‚ŃŒ Š·Š½Š°Ń‡ŠµŠ½Š½Ń.", "default": { "empty": "Умови Ń„Ń–Š»ŃŒŃ‚Ń€ŃƒŠ²Š°Š½Š½Ń не Š·Š°ŃŃ‚Š¾ŃŠ¾Š²ŃƒŃŽŃ‚ŃŒŃŃ", "placeholder": "Š’Š²ŠµŠ“Ń–Ń‚ŃŒ Š·Š½Š°Ń‡ŠµŠ½Š½Ń" @@ -264,7 +265,8 @@ "configLabel_other_visible": "{{count}} виГимих ​​полів", "showAll": "ŠŸŠ¾ŠŗŠ°Š·Š°Ń‚Šø все", "hideAll": "ŠŸŃ€ŠøŃ…Š¾Š²Š°Ń‚Šø все", - "primaryKey": "ŠžŃŠ½Š¾Š²Š½Šµ поле: Š†Š“ŠµŠ½Ń‚ŠøŃ„Ń–ŠŗŃƒŃ” записи\nне можна приховати або виГалити, виГно у зв'ŃŠ·Š°Š½ŠøŃ… записах." + "primaryKey": "ŠžŃŠ½Š¾Š²Š½Šµ поле: Š†Š“ŠµŠ½Ń‚ŠøŃ„Ń–ŠŗŃƒŃ” записи\nне можна приховати або виГалити, виГно у зв'ŃŠ·Š°Š½ŠøŃ… записах.", + "notInCurrentView": "Поле Ā«{{fieldName}}Ā» не Š²Ń–Š“Š¾Š±Ń€Š°Š¶Š°Ń”Ń‚ŃŒŃŃ в ŠæŠ¾Ń‚Š¾Ń‡Š½Š¾Š¼Ńƒ поГанні, Ń‚Š¾Š¼Ńƒ перейти Го нього не можна" }, "expandRecord": { "copy": "ŠšŠ¾ŠæŃ–ŃŽŠ²Š°Ń‚Šø в Š±ŃƒŃ„ер Š¾Š±Š¼Ń–Š½Ńƒ", @@ -1221,7 +1223,9 @@ "button": { "clickCountReachedMaxCount": "ŠšŃ–Š»ŃŒŠŗŃ–ŃŃ‚ŃŒ Š½Š°Ń‚ŠøŃŠŗŠ°Š½ŃŒ кнопки Š“Š¾ŃŃŠ³Š»Š° Š¼Š°ŠŗŃŠøŠ¼Š°Š»ŃŒŠ½Š¾Ń— межі", "notSupportReset": "Кнопка не ŠæŃ–Š“Ń‚Ń€ŠøŠ¼ŃƒŃ” ŃŠŗŠøŠ“Š°Š½Š½Ń" - } + }, + "primaryCannotBeLookup": "ŠŸŠµŃ€Š²ŠøŠ½Š½Šµ поле не може Š±ŃƒŃ‚Šø Š½Š°Š»Š°ŃˆŃ‚Š¾Š²Š°Š½Š¾ ŃŠŗ поле Lookup", + "primaryFieldAlreadyExists": "Š£ таблиці вже є первинне поле" }, "view": { "notFound": "ŠŸŃ€ŠµŠ“ŃŃ‚Š°Š²Š»ŠµŠ½Š½Ń не знайГено", @@ -1432,7 +1436,8 @@ "linkedInPostNotFound": "ŠŸŃƒŠ±Š»Ń–ŠŗŠ°Ń†Ń–ŃŽ LinkedIn не знайГено: {{postId}}", "linkedInAuthorNotFound": "Автора LinkedIn не знайГено: {{postId}}", "fetchLinkedInUserFailed": "ŠŠµ Š²Š“Š°Š»Š¾ŃŃ отримати ŠŗŠ¾Ń€ŠøŃŃ‚ŃƒŠ²Š°Ń‡Š° LinkedIn: {{error}}", - "domainAlreadyInUse": "Цей Гомен вже прив'ŃŠ·Š°Š½ŠøŠ¹ Го Ń–Š½ŃˆŠ¾Š³Š¾ ГоГатка" + "domainAlreadyInUse": "Цей Гомен вже прив'ŃŠ·Š°Š½ŠøŠ¹ Го Ń–Š½ŃˆŠ¾Š³Š¾ ГоГатка", + "domainReserved": "ŠŸŃ–Š“Š“Š¾Š¼ŠµŠ½ зарезервований" } } } diff --git a/packages/common-i18n/src/locales/uk/table.json b/packages/common-i18n/src/locales/uk/table.json index 49fd56f44b..1f9dee6800 100644 --- a/packages/common-i18n/src/locales/uk/table.json +++ b/packages/common-i18n/src/locales/uk/table.json @@ -641,6 +641,11 @@ } } } + }, + "errorType": { + "InvalidPrimaryLookup": "ŠŸŠµŃ€Š²ŠøŠ½Š½Šµ поле Š½ŠµŠæŃ€Š°Š²ŠøŠ»ŃŒŠ½Š¾ Š½Š°Š»Š°ŃˆŃ‚Š¾Š²Š°Š½Š¾ ŃŠŗ Lookup", + "InvalidPrimaryType": "ŠŸŠµŃ€Š²ŠøŠ½Š½Šµ поле має Š½ŠµŠæŃ–Š“Ń‚Ń€ŠøŠ¼ŃƒŠ²Š°Š½ŠøŠ¹ тип", + "MissingPrimary": "Š£ таблиці Š²Ń–Š“ŃŃƒŃ‚Š½Ń” первинне поле" } }, "index": { @@ -1052,6 +1057,8 @@ "advancedOptions": "ДоГаткові параметри", "namingFieldLabel": "ŠŸŃ€ŠµŃ„Ń–ŠŗŃ імені Š²ŠŗŠ»Š°Š“ŠµŠ½Š½Ń", "selectField": "За Š·Š°Š¼Š¾Š²Ń‡ŃƒŠ²Š°Š½Š½ŃŠ¼: інГекс Š²ŠŗŠ»Š°Š“ŠµŠ½Š½Ń", + "noPrefixOption": "Без префікса", + "noPrefixOptionDesc": "Зберегти Š¾Ń€ŠøŠ³Ń–Š½Š°Š»ŃŒŠ½Ńƒ назву Ń„Š°Š¹Š»Ńƒ", "groupByRow": "ŠŃ€Ń…Ń–Š²ŃƒŠ²Š°Ń‚Šø в папки", "groupByRowTip": "Коли Ń€ŃŠ“Š¾Šŗ має ŠŗŃ–Š»ŃŒŠŗŠ° вклаГень, вони Š±ŃƒŠ“ŃƒŃ‚ŃŒ розміщені в оГній папці; Ń€ŃŠ“ŠŗŠø Š· оГним Š²ŠŗŠ»Š°Š“ŠµŠ½Š½ŃŠ¼ не ŃŃ‚Š²Š¾Ń€ŃŽŃŽŃ‚ŃŒ папку." } diff --git a/packages/common-i18n/src/locales/zh/common.json b/packages/common-i18n/src/locales/zh/common.json index e39c6e2afb..7baac653b4 100644 --- a/packages/common-i18n/src/locales/zh/common.json +++ b/packages/common-i18n/src/locales/zh/common.json @@ -174,8 +174,9 @@ }, "settings": { "title": "å®žä¾‹č®¾ē½®", + "allSetting": "äøŖäŗŗ & 空闓设置", "personal": { - "title": "个人设置" + "title": "äøŖäŗŗ" }, "templateAdmin": { "title": "ęØ”ęæē®”ē†", @@ -1201,6 +1202,10 @@ "runQuotaExceeded": { "title": "č‡ŖåŠØåŒ– {{name}} å·²č§¦åŠęÆęœˆęœ€å¤§čæč”Œę¬”ę•°", "message": "č‡ŖåŠØåŒ– {{name}} ęœ¬ęœˆåÆčæč”Œę¬”ę•°å·²ē”Øå®Œļ¼Œęš‚ę—¶ę— ę³•ē»§ē»­čæč”Œć€‚čÆ·å‡ēŗ§č®¢é˜…ęˆ–č“­ä¹°é¢å¤–čæč”Œę¬”ę•°ć€‚" + }, + "failedSummary": { + "title": "č‡ŖåŠØåŒ– {{name}} å·²čæžē»­å¤±č“„ {{failCount}} ꬔ", + "message": "č‡ŖåŠØåŒ– {{name}} å·²ē“Æč®”čæžē»­å¤±č“„ {{failCount}} ę¬”ļ¼ŒčÆ·å‰å¾€čæč”ŒåŽ†å²ęŸ„ēœ‹čÆ¦ęƒ…ć€‚" } }, "billing": { @@ -1449,9 +1454,9 @@ } }, "changelog": { - "newUpdate": "4月17ę—„ ꛓꖰ", - "title": "Claude Opus 4.7 ēŽ°å·²ęŽ„å…„ Teable", - "url": "https://help.teable.ai/en/changelog#apr-17-2026", - "id": "changelog-2026-04-17-claude-opus-4-7-is-now-in-teable" + "newUpdate": "4月23ꗄꛓꖰ", + "title": "é›¶é¢å¤–ęˆęœ¬ēš„å¤–éƒØē¼–č¾‘", + "url": "https://help.teable.ai/en/changelog#apr-23-2026", + "id": "changelog-2026-04-23-external-editing-with-no-extra-cost" } } diff --git a/packages/common-i18n/src/locales/zh/sdk.json b/packages/common-i18n/src/locales/zh/sdk.json index 108cd5bba5..886cd73d37 100644 --- a/packages/common-i18n/src/locales/zh/sdk.json +++ b/packages/common-i18n/src/locales/zh/sdk.json @@ -191,6 +191,7 @@ }, "invalidateSelected": "已删除选锹", "invalidateSelectedTips": "čÆ„é€‰é”¹å·²č¢«åˆ é™¤ļ¼ŒčÆ·é‡ę–°é€‰ę‹©", + "invalidConditionTip": "ę­¤ē­›é€‰ę”ä»¶ę— ę•ˆļ¼Œå°†č¢«åæ½ē•„ć€‚čÆ·č°ƒę•“åŽé‡čÆ•ć€‚", "default": { "empty": "å½“å‰ę²”ęœ‰åŗ”ē”Øä»»ä½•ē­›é€‰ę”ä»¶", "placeholder": "请输兄" @@ -298,7 +299,8 @@ "configLabel_other_visible": "å­—ę®µé…ē½®({{count}} 显示)", "showAll": "ę˜¾ē¤ŗå…ØéƒØ", "hideAll": "éšč—å…ØéƒØ", - "primaryKey": "äø»å­—ę®µļ¼šē”ØäŗŽę ‡čÆ†č®°å½•ļ¼ŒäøåÆéšč—ęˆ–åˆ é™¤\nå…¶å€¼ä¼šåœØå…³č”č®°å½•äø­ę˜¾ē¤ŗ" + "primaryKey": "äø»å­—ę®µļ¼šē”ØäŗŽę ‡čÆ†č®°å½•ļ¼ŒäøåÆéšč—ęˆ–åˆ é™¤\nå…¶å€¼ä¼šåœØå…³č”č®°å½•äø­ę˜¾ē¤ŗ", + "notInCurrentView": "å­—ę®µā€œ{{fieldName}}ā€äøåœØå½“å‰č§†å›¾äø­ę˜¾ē¤ŗļ¼Œę— ę³•č·³č½¬" }, "expandRecord": { "copy": "å¤åˆ¶åˆ°å‰Ŗč““ęæ", @@ -1241,7 +1243,9 @@ "button": { "clickCountReachedMaxCount": "ęŒ‰é’®ē‚¹å‡»ę¬”ę•°å·²č¾¾åˆ°ęœ€å¤§é™åˆ¶", "notSupportReset": "ęŒ‰é’®äøę”ÆęŒé‡ē½®" - } + }, + "primaryCannotBeLookup": "äø»å­—ę®µäøčƒ½é…ē½®äøŗ Lookup 字段", + "primaryFieldAlreadyExists": "č”Øå·²ē»ęœ‰äø»å­—ę®µ" }, "view": { "notFound": "č§†å›¾äøå­˜åœØ", @@ -1439,7 +1443,8 @@ "noFilesInZip": "ZIP ę–‡ä»¶äø­ę²”ęœ‰ę‰¾åˆ°ę–‡ä»¶", "zipFileTooLarge": "ZIP ę–‡ä»¶å¤§å°č¶…čæ‡ 5MB 限制", "invalidZip": "ę— ę•ˆēš„ ZIP ꖇ件", - "domainAlreadyInUse": "čÆ„åŸŸåå·²č¢«å…¶ä»–åŗ”ē”Øē»‘å®š" + "domainAlreadyInUse": "čÆ„åŸŸåå·²č¢«å…¶ä»–åŗ”ē”Øē»‘å®š", + "domainReserved": "å­åŸŸåå·²č¢«äæē•™" }, "reward": { "notFound": "å„–åŠ±č®°å½•äøå­˜åœØ", diff --git a/packages/common-i18n/src/locales/zh/table.json b/packages/common-i18n/src/locales/zh/table.json index 93aa89801f..3bc62cb008 100644 --- a/packages/common-i18n/src/locales/zh/table.json +++ b/packages/common-i18n/src/locales/zh/table.json @@ -256,6 +256,7 @@ "reset": "清空", "fieldUpdated": "字段已曓新", "fieldCreated": "å­—ę®µå·²åˆ›å»ŗ", + "fieldUnavailable": "å­—ę®µå½“å‰äøåÆē”Øļ¼ŒčÆ·åˆ·ę–°č”Øę ¼åŽé‡čÆ•ć€‚", "confirmFieldChange": "ē”®č®¤å­—ę®µå˜ę›“", "areYouSurePerformIt": "ē”®å®šč¦ę‰§č”Œę­¤ę“ä½œå—ļ¼Ÿ", "addDescription": "ę·»åŠ ęčæ°", @@ -662,6 +663,17 @@ "manualRepairDialogClose": "关闭", "manualRepairPreview": "ę‰‹åŠØäæ®å¤é…ē½®", "manualRepairPreviewTip": "å½“å‰ä»…å±•ē¤ŗč§„åˆ™č¦ę±‚ē”Øęˆ·ęä¾›ēš„äæ®å¤é€‰é”¹ļ¼Œå°šęœŖēœŸę­£ęäŗ¤ę‰§č”Œć€‚", + "repairPreviewTitle": "ē”®č®¤äæ®å¤å†…å®¹", + "repairPreviewDescription": "äø‹é¢ę˜Æ dry-run čæ”å›žēš„äæ®å¤čÆ“ę˜Žå’Œå°†č¦ę‰§č”Œēš„ SQLć€‚ē”®č®¤åŽę‰ä¼šēœŸę­£ę‰§č”Œäæ®å¤ć€‚", + "repairPreviewWhat": "å°†äæ®å¤ä»€ä¹ˆ", + "repairPreviewTarget": "å­—ę®µć€Œ{{fieldName}}ć€ēš„ć€Œ{{ruleName}}ć€č§„åˆ™", + "repairPreviewPrinciple": "äæ®å¤åŽŸē†", + "repairPreviewNoPrinciple": "čÆ„č§„åˆ™ęœŖčæ”å›žé¢å¤–ēš„äæ®å¤åŽŸē†čÆ“ę˜Žć€‚", + "repairPreviewSql": "å°†ę‰§č”Œēš„ SQL", + "repairPreviewNoSql": "dry-run ę²”ęœ‰čæ”å›žåÆę‰§č”Œ SQLļ¼Œå½“å‰äøčƒ½ē›“ęŽ„ę‰§č”Œč‡ŖåŠØäæ®å¤ć€‚", + "repairPreviewCannotConfirm": "dry-run čæ”å›žäŗ† SQLļ¼Œä½†čÆ„č§„åˆ™å½“å‰äøę”ÆęŒč‡ŖåŠØäæ®å¤ē”®č®¤ļ¼ŒåŖčƒ½ęŸ„ēœ‹äæ®å¤č®”åˆ’ć€‚", + "repairPreviewParameters": "å‚ę•°", + "repairPreviewConfirm": "ē”®č®¤ę‰§č”Œäæ®å¤", "checking": "ę­£åœØę£€ęŸ„ schema...", "repairing": "ę­£åœØäæ®å¤ schema...", "streamError": "Schema å®Œę•“ę€§ęµå¼čÆ·ę±‚å¤±č“„ć€‚", @@ -771,6 +783,7 @@ "junctionForeignKeyOrphanRows": "å½“å‰ę— ę³•č‡ŖåŠØäæ®å¤ļ¼šäø­é—“č”Øä»å­˜åœØę— ę•ˆå…³ē³»č”Œ" }, "description": { + "autoRule": "äæ®å¤ä¼šę‰§č”ŒčÆ„č§„åˆ™ē”Ÿęˆēš„ schema čÆ­å„ļ¼Œē„¶åŽé‡ę–°ę ”éŖŒč§„åˆ™ļ¼Œē”®č®¤ schema å·²ę¢å¤äøŗę­£ē”®ēŠ¶ę€ć€‚", "symmetricFieldConflict": "ęœ‰å¤šäøŖå­—ę®µåŒę—¶ęŒ‡å‘åŒäø€äøŖåÆ¹ē§°å…³č”ē›®ę ‡ć€‚éœ€č¦å…ˆå†³å®šå“Ŗäø€äøŖå­—ę®µåŗ”čÆ„äæē•™åŒå‘å…³č”å…³ē³»ļ¼Œå†ē»§ē»­äæ®å¤ć€‚", "foreignKeyTargetTableMissing": "čÆ·å…ˆē”®č®¤å…³č”ē›®ę ‡č”Ø {{targetPhysicalTableName}} ę˜Æå¦å·²č¢«åˆ é™¤ęˆ–é‡å‘½åć€‚ę¢å¤ē›®ę ‡č”ØåŽåÆé‡ę–°ę‰§č”Œäæ®å¤ļ¼›å¦‚ęžœčÆ„å…³č”å·²ē»äøå†éœ€č¦ļ¼ŒčÆ·äæ®ę”¹ęˆ–åˆ é™¤å­—ę®µć€Œ{{fieldName}}ć€ēš„å…³č”é…ē½®ć€‚", "foreignKeyOrphanRows": "čÆ·å…ˆęø…ē†å­—ę®µć€Œ{{fieldName}}ć€äø­ęŒ‡å‘äøå­˜åœØč®°å½•ēš„ę— ę•ˆå…³č”ę•°ę®ļ¼Œē„¶åŽå†é‡ę–°ę‰§č”Œäæ®å¤ć€‚", @@ -832,7 +845,10 @@ "ReferenceFieldNotFound": "å¼•ē”Øå­—ę®µäøå­˜åœØ", "UniqueIndexNotFound": "å”Æäø€ē“¢å¼•äøå­˜åœØ", "EmptyString": "å­˜åœØē©ŗå­—ē¬¦äø²å•å…ƒę ¼", - "InvalidFilterOperator": "ę— ę•ˆēš„ē­›é€‰ę“ä½œē¬¦" + "InvalidFilterOperator": "ę— ę•ˆēš„ē­›é€‰ę“ä½œē¬¦", + "InvalidPrimaryLookup": "äø»å­—ę®µč¢«é”™čÆÆé…ē½®äøŗ Lookup", + "InvalidPrimaryType": "äø»å­—ę®µē±»åž‹äøę”ÆęŒ", + "MissingPrimary": "蔨缺少主字段" } }, "index": { @@ -947,6 +963,8 @@ "advancedOptions": "é«˜ēŗ§é€‰é”¹", "namingFieldLabel": "é™„ä»¶åå‰ē¼€", "selectField": "é»˜č®¤é™„ä»¶åŗå·", + "noPrefixOption": "ę— å‰ē¼€", + "noPrefixOptionDesc": "äæē•™åŽŸå§‹ę–‡ä»¶å", "groupByRow": "å½’ę”£åˆ°ę–‡ä»¶å¤¹", "groupByRowTip": "åŒäø€č”Œęœ‰å¤šäøŖé™„ä»¶ę—¶ļ¼Œä¼šę”¾å…„åŒäø€äøŖę–‡ä»¶å¤¹äø­ļ¼›åŖęœ‰äø€äøŖé™„ä»¶ēš„č”Œäøä¼šåˆ›å»ŗę–‡ä»¶å¤¹ć€‚" } @@ -1353,7 +1371,10 @@ }, "retry": { "interrupted": "å“åŗ”å·²äø­ę–­", - "button": "é‡čÆ•" + "button": "é‡čÆ•", + "offline": "å½“å‰ę— ē½‘ē»œčæžęŽ„ļ¼Œę¢å¤åŽå°†č‡ŖåŠØé‡čÆ•ć€‚", + "pausedHidden": "å·²ęš‚åœé‡čÆ•ļ¼ŒčÆ·åˆ‡å›žę­¤ę ‡ē­¾é”µä»„é‡ę–°čæžęŽ„ć€‚", + "maxAttemptsReached": "č‡ŖåŠØé‡čÆ•ęœŖęˆåŠŸļ¼ŒčÆ·ę‰‹åŠØåˆ·ę–°é”µé¢ć€‚" }, "guide": { "goToScenario": "č·³č½¬åˆ°åœŗę™Æ {{index}}" diff --git a/packages/core/src/models/field/derivate/formula.field.spec.ts b/packages/core/src/models/field/derivate/formula.field.spec.ts index a8e198d220..0b4d367bec 100644 --- a/packages/core/src/models/field/derivate/formula.field.spec.ts +++ b/packages/core/src/models/field/derivate/formula.field.spec.ts @@ -238,6 +238,19 @@ describe('FormulaFieldCore', () => { expect(converted).toBe('{fld123} + 1'); }); + it('should convert localized BLANK comparisons with spaced function calls', () => { + const dependFieldMap = { + fldWeight: { name: 'å…„čŒä½“é‡(kg)' }, + }; + + expect( + FormulaFieldCore.convertExpressionNameToId('{å…„čŒä½“é‡(kg)} !=BLANK()', dependFieldMap) + ).toBe('{fldWeight} !=BLANK()'); + expect( + FormulaFieldCore.convertExpressionNameToId('{å…„čŒä½“é‡(kg)} != BLANK()', dependFieldMap) + ).toBe('{fldWeight} != BLANK()'); + }); + it('should return current typed value with field context', () => { expect(FormulaFieldCore.getParsedValueType('2 + 2', {})).toEqual({ cellValueType: CellValueType.Number, diff --git a/packages/core/src/models/view/filter/filter.spec.ts b/packages/core/src/models/view/filter/filter.spec.ts index 0eb837db5f..906eee2192 100644 --- a/packages/core/src/models/view/filter/filter.spec.ts +++ b/packages/core/src/models/view/filter/filter.spec.ts @@ -1,5 +1,6 @@ +import { CellValueType, FieldType } from '../../field/constant'; import type { IFilter } from './filter'; -import { filterSchema } from './filter'; +import { analyzeFilterValidationIssues, filterSchema } from './filter'; describe('Filter Parse', () => { it('should parse single filter', async () => { @@ -115,3 +116,227 @@ describe('Filter Parse', () => { }); }); }); + +describe('analyzeFilterValidationIssues', () => { + const dateFieldId = 'fldDate0000000000'; + const dateReferenceFieldId = 'fldDateRef0000000'; + const numberFieldId = 'fldNumber00000000'; + + const fieldMetaMap = { + [dateFieldId]: { + type: FieldType.Date, + cellValueType: CellValueType.DateTime, + }, + [dateReferenceFieldId]: { + type: FieldType.Date, + cellValueType: CellValueType.DateTime, + }, + [numberFieldId]: { + type: FieldType.Number, + cellValueType: CellValueType.Number, + }, + }; + + it('reports invalid operator for the field type', () => { + const filter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: numberFieldId, + operator: 'contains', + value: 'abc', + }, + ], + }; + + const errors = analyzeFilterValidationIssues(filter, fieldMetaMap); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: 'OPERATOR_NOT_ALLOWED', + fieldId: numberFieldId, + operator: 'contains', + path: [0], + }); + }); + + it('reports nested path for invalid sub-operator mode', () => { + const filter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: numberFieldId, + operator: 'is', + value: 1, + }, + { + conjunction: 'or', + filterSet: [ + { + fieldId: dateFieldId, + operator: 'isWithIn', + value: { mode: 'notAMode', exactDate: null, timeZone: 'UTC' } as never, + }, + ], + }, + ], + }; + + const errors = analyzeFilterValidationIssues(filter, fieldMetaMap); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: 'MODE_NOT_ALLOWED', + fieldId: dateFieldId, + mode: 'notAMode', + path: [1, 0], + }); + }); + + it('reports shape mismatch when isWithIn value is a primitive', () => { + const filter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: dateFieldId, + operator: 'isWithIn', + value: 'today' as never, + }, + ], + }; + + const errors = analyzeFilterValidationIssues(filter, fieldMetaMap); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: 'VALUE_SHAPE_INVALID', + fieldId: dateFieldId, + operator: 'isWithIn', + path: [0], + }); + expect(errors[0].message).toContain('Valid modes:'); + expect(errors[0].message).toContain('pastWeek'); + }); + + it('reports shape mismatch when isBefore value is a plain date string', () => { + const filter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: dateFieldId, + operator: 'isBefore', + value: '2026-04-27T00:00:00.000Z' as never, + }, + ], + }; + + const errors = analyzeFilterValidationIssues(filter, fieldMetaMap); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: 'VALUE_SHAPE_INVALID', + fieldId: dateFieldId, + operator: 'isBefore', + path: [0], + }); + expect(errors[0].message).toContain('Valid modes:'); + expect(errors[0].message).toContain('today'); + }); + + it('reports invalid mode when value is an object with unknown mode', () => { + const filter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: dateFieldId, + operator: 'isWithIn', + value: { mode: 'notAMode', exactDate: null, timeZone: 'UTC' } as never, + }, + ], + }; + + const errors = analyzeFilterValidationIssues(filter, fieldMetaMap); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: 'MODE_NOT_ALLOWED', + fieldId: dateFieldId, + mode: 'notAMode', + path: [0], + }); + }); + + it('treats null value as in-progress, not an error', () => { + const filter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: dateFieldId, + operator: 'isWithIn', + value: null, + }, + ], + }; + + const errors = analyzeFilterValidationIssues(filter, fieldMetaMap); + expect(errors).toEqual([]); + }); + + it('allows date field reference comparisons without requiring mode', () => { + const filter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: dateFieldId, + operator: 'is', + value: { + type: 'field', + fieldId: dateReferenceFieldId, + }, + }, + ], + }; + + const errors = analyzeFilterValidationIssues(filter, fieldMetaMap); + expect(errors).toEqual([]); + }); + + it('reports date field reference arrays as invalid date value shape', () => { + const filter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: dateFieldId, + operator: 'is', + value: [ + { + type: 'field', + fieldId: dateReferenceFieldId, + }, + ] as never, + }, + ], + }; + + const errors = analyzeFilterValidationIssues(filter, fieldMetaMap); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: 'VALUE_SHAPE_INVALID', + fieldId: dateFieldId, + operator: 'is', + path: [0], + }); + }); + + it('treats symbol operator as compatible when mapping exists', () => { + const filter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: numberFieldId, + isSymbol: true, + operator: '=', + value: 3, + }, + ], + }; + + const errors = analyzeFilterValidationIssues(filter, fieldMetaMap); + expect(errors).toEqual([]); + }); +}); diff --git a/packages/core/src/models/view/filter/filter.ts b/packages/core/src/models/view/filter/filter.ts index 39679803e2..47d181292b 100644 --- a/packages/core/src/models/view/filter/filter.ts +++ b/packages/core/src/models/view/filter/filter.ts @@ -1,11 +1,15 @@ import { z } from 'zod'; -import { FieldType } from '../../field/constant'; +import type { CellValueType, FieldType } from '../../field/constant'; import type { IConjunction } from './conjunction'; import { and, conjunctionSchema } from './conjunction'; -import type { IFilterItem } from './filter-item'; -import { filterItemSchema, isFieldReferenceValue } from './filter-item'; -import type { IDateTimeFieldOperator } from './operator'; -import { getValidFilterSubOperators, isWithIn } from './operator'; +import { filterItemSchema, isFieldReferenceValue, type IFilterItem } from './filter-item'; +import type { IDateTimeFieldOperator, IOperator } from './operator'; +import { + getFilterOperatorMapping, + getValidFilterOperators, + getValidFilterSubOperators, + isWithIn, +} from './operator'; export const baseFilterSetSchema = z.object({ conjunction: conjunctionSchema, @@ -129,54 +133,130 @@ export const extractFieldIdsFromFilter = ( }; export interface IFilterValidationError { + code: 'FIELD_NOT_FOUND' | 'OPERATOR_NOT_ALLOWED' | 'MODE_NOT_ALLOWED' | 'VALUE_SHAPE_INVALID'; + path: number[]; fieldId: string; operator: string; mode?: string; message: string; } -/** - * Validate filter operator and mode compatibility - * Returns an array of validation errors if any, empty array if valid - * @param filter - The filter to validate - * @param fieldTypeMap - A map of fieldId to FieldType - */ -export const validateFilterOperatorModeCompatibility = ( +export interface IFilterValidationFieldMeta { + type: FieldType; + cellValueType: CellValueType; + isMultipleCellValue?: boolean; +} + +const normalizeFilterOperator = ( + operator: string, + isSymbol: boolean | undefined, + fieldMeta: IFilterValidationFieldMeta +): IOperator | undefined => { + if (!isSymbol) { + return operator as IOperator; + } + + const operatorMapping = getFilterOperatorMapping(fieldMeta); + return (Object.entries(operatorMapping).find(([, symbol]) => symbol === operator)?.[0] ?? + undefined) as IOperator | undefined; +}; + +const analyzeFilterItemValidationIssues = ( + filterItem: IFilterItem, + path: number[], + fieldMetaMap: Record +): IFilterValidationError[] => { + const { fieldId, operator, value, isSymbol } = filterItem; + const fieldMeta = fieldMetaMap[fieldId]; + if (!fieldMeta) { + return [ + { + code: 'FIELD_NOT_FOUND', + path, + fieldId, + operator, + message: `The field '${fieldId}' was not found and this filter condition will be ignored.`, + }, + ]; + } + + const normalizedOperator = normalizeFilterOperator(operator, isSymbol, fieldMeta); + const validFilterOperators = getValidFilterOperators(fieldMeta); + if (!normalizedOperator || !validFilterOperators.includes(normalizedOperator)) { + return [ + { + code: 'OPERATOR_NOT_ALLOWED', + path, + fieldId, + operator, + message: `The '${operator}' operation provided for '${fieldId}' is invalid. Allowed operators: [${validFilterOperators.join(',')}].`, + }, + ]; + } + + const validFilterSubOperators = getValidFilterSubOperators( + fieldMeta.type, + normalizedOperator as IDateTimeFieldOperator + ); + // Operator without sub-operators (isEmpty / isNotEmpty / ...) has no mode to check. + if (!validFilterSubOperators) return []; + + // null/undefined is treated as "in-progress" — backend drops these silently. + if (value == null) return []; + + // Date operators support comparing against another field directly. + if (isFieldReferenceValue(value)) return []; + + const operatorName = normalizedOperator === isWithIn.value ? 'isWithIn' : normalizedOperator; + // Shape mismatch: operator expects { mode, ... } but value is a primitive/array. + if (typeof value !== 'object' || Array.isArray(value) || !('mode' in (value as object))) { + return [ + { + code: 'VALUE_SHAPE_INVALID', + path, + fieldId, + operator: normalizedOperator, + message: `The '${operatorName}' operation requires an object value with a 'mode' field. Valid modes: [${validFilterSubOperators.join(',')}]. Example: { mode: "${validFilterSubOperators[0]}", timeZone: "UTC" }`, + }, + ]; + } + + const mode = String((value as { mode: unknown }).mode); + if (!validFilterSubOperators.includes(mode as never)) { + return [ + { + code: 'MODE_NOT_ALLOWED', + path, + fieldId, + operator: normalizedOperator, + mode, + message: `The '${operatorName}' operation with mode '${mode}' is invalid. Allowed modes: [${validFilterSubOperators.join(',')}].`, + }, + ]; + } + + return []; +}; + +export const analyzeFilterValidationIssues = ( filter: IFilter | null | undefined, - fieldTypeMap: Record + fieldMetaMap: Record ): IFilterValidationError[] => { if (!filter) return []; const errors: IFilterValidationError[] = []; - const traverse = (filterItem: IFilter | IFilterItem) => { + const traverse = (filterItem: IFilter | IFilterItem, path: number[]) => { if (filterItem && 'fieldId' in filterItem) { - const { fieldId, operator, value } = filterItem; - const fieldType = fieldTypeMap[fieldId]; - - // Only validate date fields with date filter value - if (fieldType === FieldType.Date && value && typeof value === 'object' && 'mode' in value) { - const dateValue = value as { mode: string }; - const validSubOperators = getValidFilterSubOperators( - fieldType, - operator as IDateTimeFieldOperator - ); - - if (validSubOperators && !validSubOperators.includes(dateValue.mode as never)) { - const operatorName = operator === isWithIn.value ? 'isWithIn' : operator; - errors.push({ - fieldId, - operator: operator as string, - mode: dateValue.mode, - message: `The '${operatorName}' operation with mode '${dateValue.mode}' is invalid. Allowed modes: [${validSubOperators.join(',')}]`, - }); - } - } - } else if (filterItem && 'filterSet' in filterItem) { - filterItem.filterSet.forEach((item) => traverse(item)); + errors.push(...analyzeFilterItemValidationIssues(filterItem, path, fieldMetaMap)); + return; + } + + if (filterItem && 'filterSet' in filterItem) { + filterItem.filterSet.forEach((item, index) => traverse(item, [...path, index])); } }; - traverse(filter); + traverse(filter, []); return errors; }; diff --git a/packages/core/src/models/view/filter/operator.ts b/packages/core/src/models/view/filter/operator.ts index 926309c7b8..8c966d3961 100644 --- a/packages/core/src/models/view/filter/operator.ts +++ b/packages/core/src/models/view/filter/operator.ts @@ -2,7 +2,6 @@ import { pick, pullAll, uniq } from 'lodash'; import { z } from 'zod'; import { CellValueType, FieldType } from '../../field/constant'; -import type { FieldCore } from '../../field/field'; export const is = z.literal('is'); export const isNot = z.literal('isNot'); @@ -371,7 +370,11 @@ export const dateTimeFieldValidSubOperatorsByIsWithin = [ nextNumberOfDays.value, ]; -export function getFilterOperatorMapping(field: FieldCore) { +export function getFilterOperatorMapping(field: { + cellValueType: CellValueType; + type: FieldType; + isMultipleCellValue?: boolean; +}) { const validFilterOperators = getValidFilterOperators(field); return pick(mappingOperatorSymbol, validFilterOperators); diff --git a/packages/db-main-prisma/prisma/postgres/migrations/20260424000000_add_base_v2_enabled/migration.sql b/packages/db-main-prisma/prisma/postgres/migrations/20260424000000_add_base_v2_enabled/migration.sql new file mode 100644 index 0000000000..f5c1aa62f5 --- /dev/null +++ b/packages/db-main-prisma/prisma/postgres/migrations/20260424000000_add_base_v2_enabled/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "base" ADD COLUMN "v2_enabled" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/db-main-prisma/prisma/postgres/schema.prisma b/packages/db-main-prisma/prisma/postgres/schema.prisma index 7434c5def6..98a1038267 100644 --- a/packages/db-main-prisma/prisma/postgres/schema.prisma +++ b/packages/db-main-prisma/prisma/postgres/schema.prisma @@ -44,6 +44,7 @@ model Base { order Float icon String? schemaPass String? @map("schema_pass") + v2Enabled Boolean @default(false) @map("v2_enabled") deletedTime DateTime? @map("deleted_time") createdTime DateTime @default(now()) @map("created_time") createdBy String @map("created_by") diff --git a/packages/db-main-prisma/prisma/template.prisma b/packages/db-main-prisma/prisma/template.prisma index 090167512b..d51df76b14 100644 --- a/packages/db-main-prisma/prisma/template.prisma +++ b/packages/db-main-prisma/prisma/template.prisma @@ -44,6 +44,7 @@ model Base { order Float icon String? schemaPass String? @map("schema_pass") + v2Enabled Boolean @default(false) @map("v2_enabled") deletedTime DateTime? @map("deleted_time") createdTime DateTime @default(now()) @map("created_time") createdBy String @map("created_by") diff --git a/packages/icons/src/components/CircleDollarSign.tsx b/packages/icons/src/components/CircleDollarSign.tsx new file mode 100644 index 0000000000..259a358055 --- /dev/null +++ b/packages/icons/src/components/CircleDollarSign.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +const CircleDollarSign = (props: SVGProps) => ( + + + +); +export default CircleDollarSign; diff --git a/packages/icons/src/components/IDCard.tsx b/packages/icons/src/components/IDCard.tsx new file mode 100644 index 0000000000..5d3b3afff0 --- /dev/null +++ b/packages/icons/src/components/IDCard.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +const IDCard = (props: SVGProps) => ( + + + + + + +); +export default IDCard; diff --git a/packages/icons/src/components/Info.tsx b/packages/icons/src/components/Info.tsx index 58e625e969..c51f121241 100644 --- a/packages/icons/src/components/Info.tsx +++ b/packages/icons/src/components/Info.tsx @@ -10,7 +10,17 @@ const Info = (props: SVGProps) => ( {...props} > + + diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index e83f5cb73d..5a495080af 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -112,8 +112,10 @@ export { default as GoogleLogo } from './components/GoogleLogo'; export { default as Hash } from './components/Hash'; export { default as Heart } from './components/Heart'; export { default as HelpCircle } from './components/HelpCircle'; +export { default as CircleDollarSign } from './components/CircleDollarSign'; export { default as History } from './components/History'; export { default as Home } from './components/Home'; +export { default as IDCard } from './components/IDCard'; export { default as Info } from './components/Info'; export { default as Image } from './components/Image'; export { default as ImageGeneration } from './components/ImageGeneration'; diff --git a/packages/openapi/src/integrity/link-check.ts b/packages/openapi/src/integrity/link-check.ts index 5145d48b2b..32abc79861 100644 --- a/packages/openapi/src/integrity/link-check.ts +++ b/packages/openapi/src/integrity/link-check.ts @@ -18,6 +18,9 @@ export enum IntegrityIssueType { UniqueIndexNotFound = 'UniqueIndexNotFound', EmptyString = 'EmptyString', InvalidFilterOperator = 'InvalidFilterOperator', + InvalidPrimaryLookup = 'InvalidPrimaryLookup', + InvalidPrimaryType = 'InvalidPrimaryType', + MissingPrimary = 'MissingPrimary', } // Define the schema for a single issue diff --git a/packages/openapi/src/integrity/schema-v2.ts b/packages/openapi/src/integrity/schema-v2.ts index f4eaa439db..b7dea4ba47 100644 --- a/packages/openapi/src/integrity/schema-v2.ts +++ b/packages/openapi/src/integrity/schema-v2.ts @@ -64,6 +64,14 @@ export const v2SchemaIntegrityDetailsSchema = z.object({ ) .optional(), statementCount: z.number().optional(), + statements: z + .array( + z.object({ + sql: z.string(), + parameters: z.array(z.unknown()).optional(), + }) + ) + .optional(), }); export const v2SchemaIntegrityI18nMessageSchema = z.object({ @@ -138,6 +146,7 @@ export const v2SchemaIntegrityCheckStatusSchema = z.enum([ export const v2SchemaIntegrityCheckResultSchema = z.object({ id: z.string(), + baseId: z.string().optional(), tableId: z.string().optional(), tableName: z.string().optional(), fieldId: z.string(), @@ -174,6 +183,7 @@ export const v2SchemaIntegrityRepairOutcomeSchema = z.enum([ export const v2SchemaIntegrityRepairResultSchema = z.object({ id: z.string(), + baseId: z.string().optional(), tableId: z.string().optional(), tableName: z.string().optional(), fieldId: z.string(), diff --git a/packages/sdk/src/components/editor/attachment/upload-attachment/AttachmentItem.tsx b/packages/sdk/src/components/editor/attachment/upload-attachment/AttachmentItem.tsx index adea37e567..fbf731baf4 100644 --- a/packages/sdk/src/components/editor/attachment/upload-attachment/AttachmentItem.tsx +++ b/packages/sdk/src/components/editor/attachment/upload-attachment/AttachmentItem.tsx @@ -1,7 +1,7 @@ import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import type { IAttachmentItem } from '@teable/core'; -import { Download, Pencil, X } from '@teable/icons'; +import { Download, X } from '@teable/icons'; import { Button, cn, FilePreviewItem, isImage } from '@teable/ui-lib'; import { useCallback, useEffect, useRef, useState } from 'react'; import { EllipsisFileName } from '../../../upload/EllipsisFileName'; @@ -99,46 +99,35 @@ function AttachmentItem(props: IUploadAttachment) { /> )} - {!readonly && ( +
+ + {formatFileSize(attachment.size)} + - )} -
-
- {!readonly && ( - - )} + {!readonly && ( -
- - {formatFileSize(attachment.size)} - + )}
{isEditing ? ( @@ -161,8 +150,8 @@ function AttachmentItem(props: IUploadAttachment) { ) : (
diff --git a/packages/sdk/src/components/filter/condition/condition-item/ConditionItem.tsx b/packages/sdk/src/components/filter/condition/condition-item/ConditionItem.tsx index fd89759300..45aae02f1b 100644 --- a/packages/sdk/src/components/filter/condition/condition-item/ConditionItem.tsx +++ b/packages/sdk/src/components/filter/condition/condition-item/ConditionItem.tsx @@ -1,11 +1,20 @@ -import { Trash } from '@teable/icons'; -import { Button } from '@teable/ui-lib'; +import { AlertTriangle, Trash } from '@teable/icons'; +import { + Button, + Tooltip, + TooltipContent, + TooltipPortal, + TooltipProvider, + TooltipTrigger, +} from '@teable/ui-lib'; +import { useTranslation } from '../../../../context/app/i18n'; import { useCrud } from '../../hooks'; import type { IConditionItemProperty, IBaseConditionProps, IBaseFilterComponentProps, } from '../../types'; +import { useFilterItemError } from '../../view-filter/hooks'; import { FieldSelect } from './base-component/FieldSelect'; import { FieldValue } from './base-component/FieldValue'; import { OperatorSelect } from './base-component/OperatorSelect'; @@ -19,9 +28,11 @@ interface IConditionItemProps(props: IConditionItemProps) => { const { path, value, index } = props; const { onDelete } = useCrud(); + const { t } = useTranslation(); + const itemError = useFilterItemError(path); return ( -
+
@@ -36,6 +47,22 @@ export const ConditionItem = (props: IConditio > + {itemError && ( + + + + + + + + + + {t('filter.invalidConditionTip')} + + + + + )}
); }; diff --git a/packages/sdk/src/components/filter/view-filter/ViewFilter.tsx b/packages/sdk/src/components/filter/view-filter/ViewFilter.tsx index 69f59275b1..be2faf9399 100644 --- a/packages/sdk/src/components/filter/view-filter/ViewFilter.tsx +++ b/packages/sdk/src/components/filter/view-filter/ViewFilter.tsx @@ -1,13 +1,18 @@ -import { FieldType, type IFilter } from '@teable/core'; +import { + FieldType, + analyzeFilterValidationIssues, + type IFilter, + type IFilterValidationFieldMeta, +} from '@teable/core'; import { Popover, PopoverTrigger, PopoverContent, cn } from '@teable/ui-lib'; import { isEqual } from 'lodash'; -import { useRef, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { useDebounce, useUpdateEffect } from 'react-use'; import { useFields, useTableId, useViewId } from '../../../hooks'; import { ReadOnlyTip } from '../../ReadOnlyTip'; import type { IFilterBaseComponent } from '../types'; import { BaseViewFilter } from './BaseViewFilter'; -import { useFilterNode, useViewFilterLinkContext } from './hooks'; +import { FilterValidationContext, useFilterNode, useViewFilterLinkContext } from './hooks'; import type { IViewFilterConditionItem, IViewFilterLinkContext } from './types'; export interface IViewFilterProps { @@ -25,6 +30,20 @@ export const ViewFilter = (props: IViewFilterProps) => { const fields = defaultFields.filter((f) => f.type !== FieldType.Button); const { text, isActive, hasWarning } = useFilterNode(filters, fields); const [filter, setFilter] = useState(filters); + + // Validation errors against the local (editing) filter — lets the popover highlight + // invalid rows in real time as the user fixes them. + const validationErrors = useMemo(() => { + const fieldMetaMap = fields.reduce>((acc, f) => { + acc[f.id] = { + type: f.type as FieldType, + cellValueType: f.cellValueType, + isMultipleCellValue: f.isMultipleCellValue, + }; + return acc; + }, {}); + return analyzeFilterValidationIssues(filter, fieldMetaMap); + }, [filter, fields]); const [popoverOpen, setPopoverOpen] = useState(false); // Track local edit version to prevent stale server responses from overwriting local state @@ -69,27 +88,29 @@ export const ViewFilter = (props: IViewFilterProps) => { ); return ( - - - {children?.(text, isActive || popoverOpen, hasWarning)} - - - - {contentHeader} - - fields={fields} - value={filter} - onChange={onChangeHandler} - customValueComponent={props.customValueComponent} - viewFilterLinkContext={finalViewFilterLinkContext} - /> - - + + + + {children?.(text, isActive || popoverOpen, hasWarning)} + + + + {contentHeader} + + fields={fields} + value={filter} + onChange={onChangeHandler} + customValueComponent={props.customValueComponent} + viewFilterLinkContext={finalViewFilterLinkContext} + /> + + + ); }; diff --git a/packages/sdk/src/components/filter/view-filter/hooks/index.ts b/packages/sdk/src/components/filter/view-filter/hooks/index.ts index 03035a2552..6987abba42 100644 --- a/packages/sdk/src/components/filter/view-filter/hooks/index.ts +++ b/packages/sdk/src/components/filter/view-filter/hooks/index.ts @@ -5,3 +5,4 @@ export * from './useDateI18nMap'; export * from './useOperatorI18nMap'; export * from './useViewFilterContext'; export * from './useFieldFilterLinkContext'; +export * from './useFilterValidationContext'; diff --git a/packages/sdk/src/components/filter/view-filter/hooks/useFilterNode.ts b/packages/sdk/src/components/filter/view-filter/hooks/useFilterNode.ts index 74ac1c880c..e7b1842dc9 100644 --- a/packages/sdk/src/components/filter/view-filter/hooks/useFilterNode.ts +++ b/packages/sdk/src/components/filter/view-filter/hooks/useFilterNode.ts @@ -1,5 +1,5 @@ -import type { FieldType, IFilter } from '@teable/core'; -import { validateFilterOperatorModeCompatibility } from '@teable/core'; +import type { FieldType, IFilter, IFilterValidationFieldMeta } from '@teable/core'; +import { analyzeFilterValidationIssues } from '@teable/core'; import { Filter as FilterIcon } from '@teable/icons'; import { keyBy } from 'lodash'; import { useCallback, useMemo } from 'react'; @@ -44,20 +44,23 @@ export const useFilterNode = (filters: IFilter | null | undefined, fields: IFiel return generateFilterButtonText(filteredIds, fields); }, [fields, filters, generateFilterButtonText]); - // Check if filter has any validation errors (e.g., invalid operator+mode combination) + // Show a compact warning state when stored filters contain ignored conditions. const hasWarning = useMemo(() => { if (!filters || !fields.length) return false; - const fieldTypeMap = fields.reduce( + const fieldMetaMap = fields.reduce( (acc, field) => { - acc[field.id] = field.type as FieldType; + acc[field.id] = { + type: field.type as FieldType, + cellValueType: field.cellValueType, + isMultipleCellValue: field.isMultipleCellValue, + }; return acc; }, - {} as Record + {} as Record ); - const errors = validateFilterOperatorModeCompatibility(filters, fieldTypeMap); - return errors.length > 0; + return analyzeFilterValidationIssues(filters, fieldMetaMap).length > 0; }, [filters, fields]); return { diff --git a/packages/sdk/src/components/filter/view-filter/hooks/useFilterValidationContext.ts b/packages/sdk/src/components/filter/view-filter/hooks/useFilterValidationContext.ts new file mode 100644 index 0000000000..dd6531efff --- /dev/null +++ b/packages/sdk/src/components/filter/view-filter/hooks/useFilterValidationContext.ts @@ -0,0 +1,23 @@ +import type { IFilterValidationError } from '@teable/core'; +import { createContext, useContext } from 'react'; +import type { IFilterPath } from '../../types'; + +export const FilterValidationContext = createContext([]); + +const normalizeFilterPath = (path: IFilterPath | number[]): number[] => { + return path.reduce((acc, segment) => { + if (typeof segment === 'number') { + acc.push(segment); + } + return acc; + }, []); +}; + +const isSamePath = (path1: number[], path2: number[]) => + path1.length === path2.length && path1.every((segment, index) => segment === path2[index]); + +export const useFilterItemError = (path: IFilterPath) => { + const errors = useContext(FilterValidationContext); + const normalizedPath = normalizeFilterPath(path); + return errors.find((error) => isSamePath(error.path, normalizedPath)); +}; diff --git a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-columns.tsx b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-columns.tsx index c840bbc443..ad8dba0bc4 100644 --- a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-columns.tsx +++ b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-columns.tsx @@ -61,6 +61,7 @@ interface IGenerateColumnsProps { sortFieldIds?: Set; groupFieldIds?: Set; filterFieldIds?: Set; + highlightedFieldId?: string | null; } const getColumnThemeByField = ({ @@ -69,7 +70,11 @@ const getColumnThemeByField = ({ sortFieldIds, groupFieldIds, filterFieldIds, -}: Pick & { + highlightedFieldId, +}: Pick< + IGenerateColumnsProps, + 'theme' | 'sortFieldIds' | 'groupFieldIds' | 'filterFieldIds' | 'highlightedFieldId' +> & { field: IFieldInstance; }) => { const { id, isPending, hasError } = field; @@ -116,6 +121,13 @@ const getColumnThemeByField = ({ }; } + if (highlightedFieldId === id) { + customTheme = { + ...customTheme, + ...getHighlightedColumnTheme(theme), + }; + } + if (hasError || isPending) { const c = hasError ? { light: [rose[100], rose[200]] as const, dark: [rose[500], rose[400]] as const } @@ -132,6 +144,21 @@ const getColumnThemeByField = ({ return customTheme; }; +const getHighlightedColumnTheme = (theme: string | undefined) => { + const isDark = theme === 'dark'; + const { blue } = colors; + + return isDark + ? { + cellBg: hexToRGBA(blue[500], 0.1), + columnHeaderBg: hexToRGBA(blue[500], 0.1), + } + : { + cellBg: blue[50], + columnHeaderBg: blue[50], + }; +}; + const useGenerateColumns = () => { const { t } = useTranslation(); return useCallback( @@ -143,6 +170,7 @@ const useGenerateColumns = () => { sortFieldIds, groupFieldIds, filterFieldIds, + highlightedFieldId, }: IGenerateColumnsProps): (IGridColumn & { id: string })[] => { return fields .map((field, i) => { @@ -156,6 +184,7 @@ const useGenerateColumns = () => { sortFieldIds, groupFieldIds, filterFieldIds, + highlightedFieldId, }); return { @@ -562,7 +591,11 @@ export const useCreateCellValue2GridDisplay = ( ); }; -export function useGridColumns(hasMenu?: boolean, hiddenFieldIds?: string[]) { +export function useGridColumns( + hasMenu?: boolean, + hiddenFieldIds?: string[], + highlightedFieldId?: string | null +) { const view = useView() as GridView | undefined; const originFields = useFields(); const totalFields = useFields({ withHidden: true, withDenied: true }); @@ -616,6 +649,7 @@ export function useGridColumns(hasMenu?: boolean, hiddenFieldIds?: string[]) { sortFieldIds, groupFieldIds, filterFieldIds, + highlightedFieldId, }), cellValue2GridDisplay: createCellValue2GridDisplay(fields), }), @@ -628,6 +662,7 @@ export function useGridColumns(hasMenu?: boolean, hiddenFieldIds?: string[]) { sortFieldIds, groupFieldIds, filterFieldIds, + highlightedFieldId, createCellValue2GridDisplay, ] ); diff --git a/packages/sdk/src/components/grid/managers/sprite-manager/sprites.tsx b/packages/sdk/src/components/grid/managers/sprite-manager/sprites.tsx index d8aa4e7699..a5d8b8135b 100644 --- a/packages/sdk/src/components/grid/managers/sprite-manager/sprites.tsx +++ b/packages/sdk/src/components/grid/managers/sprite-manager/sprites.tsx @@ -1,5 +1,4 @@ import { - AlertCircle, DraggableHandle, Maximize2, Plus, @@ -8,6 +7,7 @@ import { ChevronRight, Lock, EyeOff, + Info, } from '@teable/icons'; import { renderToString } from 'react-dom/server'; @@ -33,7 +33,7 @@ const add = (props: ISpriteProps) => { const description = (props: ISpriteProps) => { const { fgColor } = props; - return renderToString(); + return renderToString(); }; const close = (props: ISpriteProps) => { diff --git a/packages/sdk/src/components/grid/renderers/cell-renderer/buttonCellRenderer.ts b/packages/sdk/src/components/grid/renderers/cell-renderer/buttonCellRenderer.ts index 0c707dcb52..d8710c4805 100644 --- a/packages/sdk/src/components/grid/renderers/cell-renderer/buttonCellRenderer.ts +++ b/packages/sdk/src/components/grid/renderers/cell-renderer/buttonCellRenderer.ts @@ -100,7 +100,7 @@ const calcPosition = ( const { fieldOptions } = data; const { width, ctx, theme, height } = props; const { fontSizeXS, fontFamily } = theme; - const cacheKey = `${fieldOptions.label}-${width}`; + const cacheKey = `${fieldOptions.label}-${width}-${height}`; if (!flush) { const cachedRect = positionCache.get(cacheKey); if (cachedRect) return cachedRect; @@ -187,10 +187,10 @@ export const buttonCellRenderer: IInternalCellRenderer = { checkRegion: (cell: IButtonCell, props: ICellClickProps, _shouldCalculate?: boolean) => { const { data } = cell; const { fieldOptions } = data; - const { hoverCellPosition, width } = props; + const { hoverCellPosition, width, height } = props; const [x, y] = hoverCellPosition; - const cacheKey = `${fieldOptions.label}-${width}`; + const cacheKey = `${fieldOptions.label}-${width}-${height}`; const rect = positionCache.get(cacheKey); if ( rect && diff --git a/packages/sdk/src/components/hide-fields/HideFields.tsx b/packages/sdk/src/components/hide-fields/HideFields.tsx index 17e7b8857e..99fc55a3cb 100644 --- a/packages/sdk/src/components/hide-fields/HideFields.tsx +++ b/packages/sdk/src/components/hide-fields/HideFields.tsx @@ -9,8 +9,9 @@ import { HideFieldsBase } from './HideFieldsBase'; export const HideFields: React.FC<{ footer?: React.ReactNode; + onFieldClick?: (field: IFieldInstance) => void; children: (text: string, isActive: boolean) => React.ReactNode; -}> = ({ footer, children }) => { +}> = ({ footer, onFieldClick, children }) => { const activeViewId = useViewId(); const fields = useFields({ withHidden: true, withDenied: true }); const view = useView() as GridView | undefined; @@ -79,6 +80,7 @@ export const HideFields: React.FC<{ hidden={hiddenFieldIds} onChange={onChange} onOrderChange={onOrderChange} + onFieldClick={onFieldClick} > {children( hiddenCount ? t('hidden.configLabel_other', { count: hiddenCount }) : t('hidden.label'), diff --git a/packages/sdk/src/components/hide-fields/HideFieldsBase.tsx b/packages/sdk/src/components/hide-fields/HideFieldsBase.tsx index 119dd489e8..af2a41fe4e 100644 --- a/packages/sdk/src/components/hide-fields/HideFieldsBase.tsx +++ b/packages/sdk/src/components/hide-fields/HideFieldsBase.tsx @@ -34,10 +34,11 @@ interface IHideFieldsBaseProps { children: React.ReactNode; onChange: (hidden: string[]) => void; onOrderChange?: (fieldId: string, fromIndex: number, toIndex: number) => void; + onFieldClick?: (field: IFieldInstance) => void; } export const HideFieldsBase = (props: IHideFieldsBaseProps) => { - const { fields, hidden, footer, children, onChange, onOrderChange } = props; + const { fields, hidden, footer, children, onChange, onOrderChange, onFieldClick } = props; const { t } = useTranslation(); const fieldStaticGetter = useFieldStaticGetter(); @@ -129,6 +130,15 @@ export const HideFieldsBase = (props: IHideFieldsBaseProps) => { hasAiConfig: Boolean(aiConfig), deniedReadRecord: !canReadFieldRecord, }); + const handleFieldClick = () => { + if (onFieldClick) { + onFieldClick(field); + return; + } + if (!isPrimary) { + switchChange(id, !statusMap[id]); + } + }; return ( {({ setNodeRef, listeners, attributes, style, isDragging }) => ( @@ -150,7 +160,7 @@ export const HideFieldsBase = (props: IHideFieldsBaseProps) => {
+ {/* forbid drag when search */} {dragEnabled && (
diff --git a/packages/sdk/src/components/table/InfiniteTable.tsx b/packages/sdk/src/components/table/InfiniteTable.tsx index 73ac6568fb..807f18d971 100644 --- a/packages/sdk/src/components/table/InfiniteTable.tsx +++ b/packages/sdk/src/components/table/InfiniteTable.tsx @@ -12,12 +12,22 @@ interface IInfiniteTableProps { sorting?: SortingState; onSortingChange?: OnChangeFn; emptyText?: string; + density?: 'default' | 'compact'; } export const InfiniteTable = ( props: IInfiniteTableProps ) => { - const { rows, columns, className, fetchNextPage, sorting, onSortingChange, emptyText } = props; + const { + rows, + columns, + className, + fetchNextPage, + sorting, + onSortingChange, + emptyText, + density = 'default', + } = props; const { t } = useTranslation(); const listRef = useRef(null); @@ -51,13 +61,15 @@ export const InfiniteTable = ( fetchMoreOnBottomReached(listRef.current); }, [fetchMoreOnBottomReached]); + const cellPaddingClass = density === 'compact' ? 'px-2.5' : 'px-4'; + return (
fetchMoreOnBottomReached(e.target as HTMLDivElement)} > -
+
{table.getHeaderGroups().map((headerGroup) => ( ( return ( ( { expect(legacy.choices[0].name).toBe('Todo'); const modern = repo.normalizeSelectOptions({ - choices: [{ id: '', name: 'Ready', color: 'invalid' }], + choices: [ + { id: '', name: 'Ready', color: 'invalid' }, + { id: 'choReadyTrimmed', name: ' Ready ', color: 'blue' }, + ], defaultValue: 'ready', preventAutoNewOptions: true, }); expect(modern.defaultValue).toBe('ready'); expect(modern.preventAutoNewOptions).toBe(true); + expect(modern.choices).toHaveLength(1); expect(modern.choices[0].id).toMatch(/^cho/); expect(repo.resolveSortColumn({ toString: () => 'name' })).toBe('name'); diff --git a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.ts b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.ts index c2c5b004f8..a0af079074 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.ts @@ -35,6 +35,22 @@ const formatSpecDetails = (specInfo: TableWhereSpecInfo): string => { return parts.join(' '); }; +type SelectChoiceDto = { id: string; name: string; color: string }; + +const deduplicateSelectChoices = ( + choices: ReadonlyArray +): ReadonlyArray => { + const seen = new Set(); + const deduped: SelectChoiceDto[] = []; + for (const choice of choices) { + const normalizedName = choice.name.trim(); + if (seen.has(normalizedName)) continue; + seen.add(normalizedName); + deduped.push(choice); + } + return deduped; +}; + const v1SymbolOperatorMap: Record = { '=': 'is', '!=': 'isNot', @@ -1904,7 +1920,7 @@ export class PostgresTableRepository implements core.ITableRepository { } private normalizeSelectOptions(raw: Record): { - choices: ReadonlyArray<{ id: string; name: string; color: string }>; + choices: ReadonlyArray; defaultValue?: string | ReadonlyArray; preventAutoNewOptions?: boolean; } { @@ -1921,7 +1937,7 @@ export class PostgresTableRepository implements core.ITableRepository { name: String(name), color: normalizeColor(undefined, index), })); - return { choices }; + return { choices: deduplicateSelectChoices(choices) }; } const choices = Array.isArray(raw.choices) @@ -1943,7 +1959,7 @@ export class PostgresTableRepository implements core.ITableRepository { typeof raw.preventAutoNewOptions === 'boolean' ? raw.preventAutoNewOptions : undefined; return { - choices: choices as ReadonlyArray<{ id: string; name: string; color: string }>, + choices: deduplicateSelectChoices(choices), ...(defaultValue !== undefined ? { defaultValue: defaultValue as string | string[] } : {}), ...(preventAutoNewOptions !== undefined ? { preventAutoNewOptions } : {}), }; diff --git a/packages/v2/adapter-table-repository-postgres/src/integration/commands/RecordSearchExplain.db.spec.ts b/packages/v2/adapter-table-repository-postgres/src/integration/commands/RecordSearchExplain.db.spec.ts new file mode 100644 index 0000000000..dd17a65f7d --- /dev/null +++ b/packages/v2/adapter-table-repository-postgres/src/integration/commands/RecordSearchExplain.db.spec.ts @@ -0,0 +1,237 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import { createV2NodeTestContainer } from '@teable/v2-container-node-test'; +import { + ActorId, + CreateFieldCommand, + CreateRecordCommand, + CreateTableCommand, + type CreateFieldResult, + type CreateRecordResult, + type CreateTableResult, + type Field, + type ICommandBus, + RecordSearch, + type Table, + v2CoreTokens, +} from '@teable/v2-core'; +import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; +import type { CompiledQuery, Expression, Kysely, SqlBool } from 'kysely'; +import { sql } from 'kysely'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { buildRecordSearchWhereClause } from '../../record/repository/RecordSearchWhereBuilder'; +import { getV2NodeTestContainer, setV2NodeTestContainer } from '../testkit/v2NodeTestContainer'; + +type ExplainNode = { + 'Node Type'?: string; + Plans?: ExplainNode[]; +}; + +type ExplainOutput = { + Plan: ExplainNode; +}; + +const createContext = () => { + const actorIdResult = ActorId.create('system'); + return { actorId: actorIdResult._unsafeUnwrap() }; +}; + +const getDbTableLocation = (table: Table, defaultSchema: string) => { + const dbTableName = table.dbTableName()._unsafeUnwrap().split({ defaultSchema })._unsafeUnwrap(); + return { + schemaName: dbTableName.schema ?? defaultSchema, + tableName: dbTableName.tableName, + fullTableName: `${dbTableName.schema ?? defaultSchema}.${dbTableName.tableName}`, + }; +}; + +const getFieldByName = (table: Table, name: string): Field => { + return table.getField((candidate) => candidate.name().toString() === name)._unsafeUnwrap(); +}; + +const compileDateSearchQuery = ({ + db, + table, + fullTableName, + fieldId, + value, +}: { + db: Kysely; + table: Table; + fullTableName: string; + fieldId: string; + value: string; +}): CompiledQuery => { + const whereClause = buildRecordSearchWhereClause( + table, + { + search: RecordSearch.fromTuple([value, fieldId, true]), + }, + { + tableAlias: 't', + } + )._unsafeUnwrap(); + + let query = db.selectFrom(`${fullTableName} as t`).select('t.__id as id'); + if (whereClause != null) { + query = query.where(whereClause as Expression); + } + + return query.compile(); +}; + +const findMatchingRecordIds = async ({ + db, + compiled, +}: { + db: Kysely; + compiled: CompiledQuery; +}) => { + const rows = await db.executeQuery<{ id: string }>({ + ...compiled, + parameters: [...compiled.parameters], + }); + + return rows.rows.map((row) => row.id); +}; + +const explainQueryPlan = async ({ + db, + compiled, +}: { + db: Kysely; + compiled: CompiledQuery; +}): Promise => { + const rows = await db.transaction().execute(async (trx) => { + await trx.executeQuery(sql.raw('SET LOCAL enable_seqscan = off').compile(trx)); + const explainQuery = sql`EXPLAIN (FORMAT JSON) ${sql.raw(compiled.sql)}`.compile(trx); + return trx.executeQuery<{ 'QUERY PLAN': string | object }>({ + ...explainQuery, + parameters: [...compiled.parameters], + }); + }); + + const rawPlan = rows.rows[0]?.['QUERY PLAN']; + if (rawPlan == null) { + throw new Error('Missing EXPLAIN output'); + } + + if (typeof rawPlan === 'object') { + return (Array.isArray(rawPlan) ? rawPlan[0] : rawPlan) as ExplainOutput; + } + + return (JSON.parse(rawPlan) as ExplainOutput[])[0] as ExplainOutput; +}; + +const flattenNodeTypes = (node: ExplainNode): string[] => { + const nodeType = node['Node Type'] ? [node['Node Type']] : []; + const childTypes = node.Plans?.flatMap(flattenNodeTypes) ?? []; + return [...nodeType, ...childTypes]; +}; + +describe('RecordSearch EXPLAIN (db)', () => { + beforeEach(async () => { + setV2NodeTestContainer(await createV2NodeTestContainer()); + }); + + it('uses the datetime search index for field-specific date search', async () => { + const { container, baseId } = getV2NodeTestContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const db = container.resolve>(v2PostgresDbTokens.db); + const context = createContext(); + + const createTableResult = CreateTableCommand.create({ + baseId: baseId.toString(), + name: 'Search Explain', + fields: [{ type: 'singleLineText', name: 'Title', isPrimary: true }], + views: [{ type: 'grid' }], + })._unsafeUnwrap(); + + const createdTableResult = await commandBus.execute( + context, + createTableResult + ); + const createdTable = createdTableResult._unsafeUnwrap().table; + const titleField = getFieldByName(createdTable, 'Title'); + const titleDbFieldName = titleField.dbFieldName()._unsafeUnwrap().value()._unsafeUnwrap(); + const { schemaName, tableName, fullTableName } = getDbTableLocation( + createdTable, + baseId.toString() + ); + + await sql + .raw( + `CREATE INDEX "idx_trgm_bootstrap_date_search" ON "${schemaName}"."${tableName}" USING btree ("${titleDbFieldName}")` + ) + .execute(db); + + const createFieldResult = CreateFieldCommand.create({ + baseId: baseId.toString(), + tableId: createdTable.id().toString(), + field: { + type: 'date', + name: 'Due', + }, + })._unsafeUnwrap(); + + const updatedTableResult = await commandBus.execute( + context, + createFieldResult + ); + const updatedTable = updatedTableResult._unsafeUnwrap().table; + const dueField = getFieldByName(updatedTable, 'Due'); + const dueFieldId = dueField.id().toString(); + const dueDbFieldName = dueField.dbFieldName()._unsafeUnwrap().value()._unsafeUnwrap(); + + const dateIndexRows = await sql<{ indexdef: string }>` + SELECT indexdef + FROM pg_indexes + WHERE schemaname = ${schemaName} + AND tablename = ${tableName} + AND indexname LIKE 'idx_trgm%' + AND indexdef LIKE ${`%USING btree ("${dueDbFieldName}")%`} + `.execute(db); + + expect(dateIndexRows.rows).toHaveLength(1); + + const records = [ + { title: 'Alpha', due: '2026-02-23T00:00:00.000Z' }, + { title: 'Bravo', due: '2026-02-24T00:00:00.000Z' }, + { title: 'Charlie', due: '2026-02-25T00:00:00.000Z' }, + ]; + + for (const record of records) { + const createRecordResult = CreateRecordCommand.create({ + tableId: updatedTable.id().toString(), + fields: { + [titleField.id().toString()]: record.title, + [dueFieldId]: record.due, + }, + })._unsafeUnwrap(); + + const execResult = await commandBus.execute( + context, + createRecordResult + ); + execResult._unsafeUnwrap(); + } + + const compiled = compileDateSearchQuery({ + db, + table: updatedTable, + fullTableName, + fieldId: dueFieldId, + value: '2026-02-24', + }); + + const matchingIds = await findMatchingRecordIds({ db, compiled }); + expect(matchingIds).toHaveLength(1); + + const explain = await explainQueryPlan({ db, compiled }); + const nodeTypes = flattenNodeTypes(explain.Plan); + + expect(nodeTypes.some((nodeType) => nodeType.includes('Index'))).toBe(true); + expect(nodeTypes.every((nodeType) => nodeType !== 'Seq Scan')).toBe(true); + }); +}); diff --git a/packages/v2/adapter-table-repository-postgres/src/meta/MetaChecker.spec.ts b/packages/v2/adapter-table-repository-postgres/src/meta/MetaChecker.spec.ts index db274bf34a..91ead5ed83 100644 --- a/packages/v2/adapter-table-repository-postgres/src/meta/MetaChecker.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/meta/MetaChecker.spec.ts @@ -25,10 +25,12 @@ const createField = (params: { name: string; type: string; accept: () => ReturnType | ReturnType; + hasError?: boolean; }) => ({ id: () => asId(params.id), name: () => asId(params.name), type: () => asId(params.type), + hasError: () => ({ isError: () => params.hasError === true }), accept: params.accept, }); @@ -188,4 +190,35 @@ describe('MetaChecker', () => { expect(standaloneIssues).toEqual([{ fieldId: 'fld9', message: 'healthy' }]); expect(standaloneWithTablesIssues).toEqual([{ fieldId: 'fld9', message: 'healthy' }]); }); + + it('skips fields already marked hasError in standalone table checks', async () => { + const { checkTableMetaWithTables } = await loadMetaCheckerModule(); + const accept = vi.fn(() => ok([{ fieldId: 'fldBroken', message: 'should not emit' }])); + const table = createTable({ + id: 'tblHasError', + name: 'Stories', + fields: [ + createField({ + id: 'fldBroken', + name: 'Broken Lookup', + type: 'lookup', + hasError: true, + accept, + }), + ], + }); + + mocks.createMetaValidationContextFromTables.mockReturnValue({ table }); + + const issues = await collect( + checkTableMetaWithTables( + table as never, + BaseId.create(`bse${'f'.repeat(16)}`)._unsafeUnwrap(), + [table as never] + ) + ); + + expect(issues).toEqual([]); + expect(accept).not.toHaveBeenCalled(); + }); }); diff --git a/packages/v2/adapter-table-repository-postgres/src/meta/MetaChecker.ts b/packages/v2/adapter-table-repository-postgres/src/meta/MetaChecker.ts index 2145687d35..1d26a9aa87 100644 --- a/packages/v2/adapter-table-repository-postgres/src/meta/MetaChecker.ts +++ b/packages/v2/adapter-table-repository-postgres/src/meta/MetaChecker.ts @@ -97,6 +97,11 @@ export class MetaChecker { const visitor = new MetaValidationVisitor(ctx); for (const field of table.getFields()) { + const hasError = typeof field.hasError === 'function' ? field.hasError().isError() : false; + if (hasError) { + continue; + } + try { const issuesResult = field.accept(visitor); @@ -224,6 +229,11 @@ export async function* checkTableMetaWithTables( const visitor = new MetaValidationVisitor(ctx); for (const field of table.getFields()) { + const hasError = typeof field.hasError === 'function' ? field.hasError().isError() : false; + if (hasError) { + continue; + } + try { const issuesResult = field.accept(visitor); if (issuesResult.isErr()) { diff --git a/packages/v2/adapter-table-repository-postgres/src/meta/MetaRepairer.spec.ts b/packages/v2/adapter-table-repository-postgres/src/meta/MetaRepairer.spec.ts new file mode 100644 index 0000000000..20cfeb19da --- /dev/null +++ b/packages/v2/adapter-table-repository-postgres/src/meta/MetaRepairer.spec.ts @@ -0,0 +1,144 @@ +import { ok } from 'neverthrow'; +import { describe, expect, it, vi } from 'vitest'; + +import { createMetaRepairer, getMetaRuleId, type MetaValidationIssue } from './index'; + +const metaFieldId = 'fldMetaRepair0001'; + +const asId = (value: string) => ({ + toString: () => value, +}); + +const createMetaIssue = (): MetaValidationIssue => ({ + fieldId: metaFieldId, + fieldName: 'Status', + fieldType: 'lookup', + category: 'reference', + severity: 'error', + message: 'Link field not found: fldMissing', + details: { + relatedFieldId: 'fldMissing', + }, +}); + +const createField = (issue: MetaValidationIssue) => { + let hasError = false; + + return { + id: () => asId(issue.fieldId), + name: () => asId(issue.fieldName), + type: () => asId(issue.fieldType), + hasError: () => ({ isError: () => hasError }), + setHasError: () => { + hasError = true; + }, + getHasError: () => hasError, + accept: () => ok([issue]), + }; +}; + +const createTable = (fields: ReturnType[]) => + ({ + id: () => asId('tblMetaRepair0001'), + name: () => asId('Tasks'), + baseId: () => asId('bseMetaRepair0001'), + getFields: () => fields, + }) as never; + +const createFakeDb = () => { + const execute = vi.fn().mockResolvedValue([]); + const compile = vi.fn(() => ({ + sql: 'update "field" set "has_error" = $1 where "id" = $2', + parameters: [true, metaFieldId], + })); + const where = vi.fn(() => ({ compile, execute })); + const set = vi.fn(() => ({ where })); + const updateTable = vi.fn(() => ({ set })); + + return { + compile, + db: { updateTable }, + execute, + set, + updateTable, + where, + }; +}; + +const collect = async (stream: AsyncGenerator): Promise => { + const results: T[] = []; + for await (const result of stream) { + results.push(result); + } + return results; +}; + +describe('MetaRepairer', () => { + it('repairs metadata reference issues by setting field has_error', async () => { + const issue = createMetaIssue(); + const field = createField(issue); + const table = createTable([field]); + const fakeDb = createFakeDb(); + const repairer = createMetaRepairer({ db: fakeDb.db as never }); + + const results = await collect( + repairer.repairRule(table, [table], issue.fieldId, getMetaRuleId(issue), { + targetStatuses: ['error'], + }) + ); + + expect(results.map((result) => result.status)).toEqual(['running', 'success']); + expect(results[0]).toMatchObject({ + fieldId: issue.fieldId, + ruleId: 'meta:reference', + repair: { + available: true, + mode: 'auto', + }, + }); + expect(results[1]).toMatchObject({ + fieldId: issue.fieldId, + ruleId: 'meta:reference', + outcome: 'repaired', + message: 'Field marked hasError', + details: { + missing: ['fldMissing'], + }, + }); + expect(fakeDb.updateTable).toHaveBeenCalledWith('field'); + expect(fakeDb.set).toHaveBeenCalledWith({ has_error: true }); + expect(fakeDb.where).toHaveBeenCalledWith('id', '=', issue.fieldId); + expect(field.getHasError()).toBe(true); + }); + + it('supports dry run without mutating field has_error', async () => { + const issue = createMetaIssue(); + const field = createField(issue); + const table = createTable([field]); + const fakeDb = createFakeDb(); + const repairer = createMetaRepairer({ db: fakeDb.db as never }); + + const results = await collect( + repairer.repairField(table, [table], issue.fieldId, { + dryRun: true, + }) + ); + + expect(results.map((result) => result.status)).toEqual(['running', 'success']); + expect(results[1]).toMatchObject({ + outcome: 'repaired', + message: 'Dry run: field will be marked hasError', + details: { + statementCount: 1, + statements: [ + { + sql: 'update "field" set "has_error" = $1 where "id" = $2', + parameters: [true, metaFieldId], + }, + ], + }, + }); + expect(fakeDb.execute).not.toHaveBeenCalled(); + expect(field.getHasError()).toBe(false); + }); +}); diff --git a/packages/v2/adapter-table-repository-postgres/src/meta/MetaRepairer.ts b/packages/v2/adapter-table-repository-postgres/src/meta/MetaRepairer.ts new file mode 100644 index 0000000000..b8b167035a --- /dev/null +++ b/packages/v2/adapter-table-repository-postgres/src/meta/MetaRepairer.ts @@ -0,0 +1,232 @@ +import { FieldHasError, type Table } from '@teable/v2-core'; +import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; +import type { Kysely } from 'kysely'; + +import type { SchemaRuleRepairHint } from '../schema/rules/core/ISchemaRule'; +import type { SchemaRepairOptions } from '../schema/rules/repairer/SchemaRepairer'; +import { + errorResult, + pendingResult, + runningResult, + skippedResult, + successResult, + type SchemaRepairDetails, + type SchemaRepairResult, +} from '../schema/rules/repairer/SchemaRepairResult'; +import { checkTableMetaWithTables } from './MetaChecker'; +import type { MetaValidationIssue, MetaValidationSeverity } from './MetaValidationResult'; + +export const metaRuleDescription = 'Metadata reference validation'; +export const metaRuleIdPrefix = 'meta:'; + +export interface MetaRepairerParams { + readonly db: Kysely; +} + +export interface MetaRepairTarget { + readonly fieldId?: string; + readonly ruleId?: string; +} + +export type MetaRepairOptions = Pick; + +export const getMetaRuleId = (issue: Pick): string => + `${metaRuleIdPrefix}${issue.category}`; + +export const isMetaRuleId = (ruleId: string | undefined): ruleId is string => + Boolean(ruleId?.startsWith(metaRuleIdPrefix)); + +export const getMetaIssueDetails = ( + issue: MetaValidationIssue +): SchemaRepairDetails | undefined => { + const missing = [ + issue.details?.relatedTableId, + issue.details?.relatedFieldId, + issue.details?.path, + ].filter((value): value is string => Boolean(value)); + + return missing.length ? { missing } : undefined; +}; + +export const getMetaRepairHint = (issue: MetaValidationIssue): SchemaRuleRepairHint => ({ + available: true, + mode: 'auto', + reason: { + fallback: `Automatic repair will mark "${issue.fieldName}" as hasError.`, + }, + description: { + fallback: + 'This keeps the broken computed field from participating in computed SQL until its metadata references are fixed manually.', + }, +}); + +export class MetaRepairer { + constructor(private readonly params: MetaRepairerParams) {} + + async *repairTable( + table: Table, + allTables: ReadonlyArray
, + options: MetaRepairOptions = {} + ): AsyncGenerator { + yield* this.repairInternal(table, allTables, {}, options); + } + + async *repairField( + table: Table, + allTables: ReadonlyArray
, + fieldId: string, + options: MetaRepairOptions = {} + ): AsyncGenerator { + yield* this.repairInternal(table, allTables, { fieldId }, options); + } + + async *repairRule( + table: Table, + allTables: ReadonlyArray
, + fieldId: string, + ruleId: string, + options: MetaRepairOptions = {} + ): AsyncGenerator { + yield* this.repairInternal(table, allTables, { fieldId, ruleId }, options); + } + + private async *repairInternal( + table: Table, + allTables: ReadonlyArray
, + target: MetaRepairTarget, + options: MetaRepairOptions + ): AsyncGenerator { + const repairedFieldIds = new Set(); + + for await (const issue of checkTableMetaWithTables(table, table.baseId(), allTables)) { + const currentStatus = toRepairableMetaStatus(issue.severity); + if (!currentStatus || !matchesMetaRepairTarget(issue, target)) { + continue; + } + + const pending = createMetaRepairPending(issue); + const details = getMetaIssueDetails(issue); + const repair = getMetaRepairHint(issue); + + yield withRepair(runningResult(pending), repair); + + if (shouldSkipRepairStatus(currentStatus, options.targetStatuses)) { + yield withRepair( + skippedResult(pending, 'Skipped: status not selected for repair', details), + repair + ); + continue; + } + + if (repairedFieldIds.has(issue.fieldId)) { + yield withRepair( + successResult(pending, 'Field already marked hasError', 'unchanged', details), + repair + ); + continue; + } + + if (options.dryRun) { + repairedFieldIds.add(issue.fieldId); + const compiled = this.createMarkFieldHasErrorQuery(issue).compile(); + yield withRepair( + successResult(pending, 'Dry run: field will be marked hasError', 'repaired', { + ...details, + statementCount: 1, + statements: [ + { + sql: compiled.sql, + parameters: compiled.parameters, + }, + ], + }), + repair + ); + continue; + } + + const repairResult = await this.markFieldHasError(table, issue, pending, details, repair); + yield repairResult; + if (repairResult.status === 'success') { + repairedFieldIds.add(issue.fieldId); + } + } + } + + private async markFieldHasError( + table: Table, + issue: MetaValidationIssue, + pending: SchemaRepairResult, + details: SchemaRepairDetails | undefined, + repair: SchemaRuleRepairHint + ): Promise { + try { + await this.createMarkFieldHasErrorQuery(issue).execute(); + + table + .getFields() + .find((field) => field.id().toString() === issue.fieldId) + ?.setHasError(FieldHasError.error()); + + return withRepair( + successResult(pending, 'Field marked hasError', 'repaired', { + ...details, + statementCount: 1, + }), + repair + ); + } catch (error) { + return withRepair( + errorResult( + pending, + error instanceof Error ? error.message : 'Unknown error during meta repair', + details + ), + repair + ); + } + } + + private createMarkFieldHasErrorQuery(issue: MetaValidationIssue) { + return this.params.db + .updateTable('field') + .set({ has_error: true }) + .where('id', '=', issue.fieldId); + } +} + +export const createMetaRepairer = (params: MetaRepairerParams): MetaRepairer => + new MetaRepairer(params); + +const createMetaRepairPending = (issue: MetaValidationIssue): SchemaRepairResult => + pendingResult(issue.fieldId, issue.fieldName, getMetaRuleId(issue), metaRuleDescription, true); + +const toRepairableMetaStatus = (severity: MetaValidationSeverity): 'warn' | 'error' | undefined => { + if (severity === 'error') { + return 'error'; + } + if (severity === 'warning') { + return 'warn'; + } +}; + +const matchesMetaRepairTarget = (issue: MetaValidationIssue, target: MetaRepairTarget): boolean => { + if (target.fieldId && issue.fieldId !== target.fieldId) { + return false; + } + + return !target.ruleId || getMetaRuleId(issue) === target.ruleId; +}; + +const shouldSkipRepairStatus = ( + status: 'warn' | 'error', + targetStatuses?: ReadonlyArray<'warn' | 'error'> +): boolean => Boolean(targetStatuses?.length && !targetStatuses.includes(status)); + +const withRepair = ( + result: SchemaRepairResult, + repair: SchemaRuleRepairHint +): SchemaRepairResult => ({ + ...result, + repair, +}); diff --git a/packages/v2/adapter-table-repository-postgres/src/meta/index.ts b/packages/v2/adapter-table-repository-postgres/src/meta/index.ts index b0779e6814..129655c3c2 100644 --- a/packages/v2/adapter-table-repository-postgres/src/meta/index.ts +++ b/packages/v2/adapter-table-repository-postgres/src/meta/index.ts @@ -11,6 +11,20 @@ export { type IMetaValidationContext, } from './MetaValidationContext'; +export { + createMetaRepairer, + getMetaIssueDetails, + getMetaRepairHint, + getMetaRuleId, + isMetaRuleId, + MetaRepairer, + metaRuleDescription, + metaRuleIdPrefix, + type MetaRepairerParams, + type MetaRepairOptions, + type MetaRepairTarget, +} from './MetaRepairer'; + export type { MetaValidationCategory, MetaValidationSeverity, diff --git a/packages/v2/adapter-table-repository-postgres/src/record/attachments/attachmentTableMutations.ts b/packages/v2/adapter-table-repository-postgres/src/record/attachments/attachmentTableMutations.ts new file mode 100644 index 0000000000..1ef287bd19 --- /dev/null +++ b/packages/v2/adapter-table-repository-postgres/src/record/attachments/attachmentTableMutations.ts @@ -0,0 +1,94 @@ +import { generatePrefixedId } from '@teable/v2-core'; +import type { CompiledQuery, Kysely } from 'kysely'; + +import type { DynamicDB } from '../query-builder'; + +const ATTACHMENT_TABLE_ROW_ID_PREFIX = 'attt'; +const ATTACHMENT_TABLE_ROW_ID_LENGTH = 16; + +type AttachmentItemLike = { + id?: string; + token?: string; + name?: string; +}; + +const normalizeAttachmentItems = (value: unknown): AttachmentItemLike[] => { + if (Array.isArray(value)) { + return value as AttachmentItemLike[]; + } + if (value && typeof value === 'object') { + return [value as AttachmentItemLike]; + } + return []; +}; + +const toAttachmentTableRows = (params: { + actorId: string; + tableId: string; + recordId: string; + fieldId: string; + value: unknown; +}) => { + const { actorId, tableId, recordId, fieldId, value } = params; + + return normalizeAttachmentItems(value) + .map((item) => { + const attachmentId = item.id ? String(item.id) : ''; + const token = item.token ? String(item.token) : ''; + if (!attachmentId || !token) { + return null; + } + + return { + id: generatePrefixedId(ATTACHMENT_TABLE_ROW_ID_PREFIX, ATTACHMENT_TABLE_ROW_ID_LENGTH), + attachment_id: attachmentId, + token, + name: item.name ? String(item.name) : '', + table_id: tableId, + record_id: recordId, + field_id: fieldId, + created_by: actorId, + }; + }) + .filter((row): row is NonNullable => row !== null); +}; + +export const buildAttachmentTableInsertQuery = ( + db: Kysely, + params: { + actorId: string; + tableId: string; + recordId: string; + fieldId: string; + value: unknown; + } +): CompiledQuery | undefined => { + const rows = toAttachmentTableRows(params); + if (rows.length === 0) { + return undefined; + } + + return db.insertInto('attachments_table').values(rows).compile(); +}; + +export const buildAttachmentTableReplaceQueries = ( + db: Kysely, + params: { + actorId: string; + tableId: string; + recordId: string; + fieldId: string; + value: unknown; + } +): CompiledQuery[] => { + const deleteQuery = db + .deleteFrom('attachments_table') + .where('table_id', '=', params.tableId) + .where('record_id', '=', params.recordId) + .where('field_id', '=', params.fieldId) + .compile(); + + const insertQuery = buildAttachmentTableInsertQuery(db, params); + + return insertQuery ? [deleteQuery, insertQuery] : [deleteQuery]; +}; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedFieldUpdater.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedFieldUpdater.ts index be573ce775..4828a8ff99 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedFieldUpdater.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedFieldUpdater.ts @@ -1799,6 +1799,14 @@ type PreparedPropagationSelect = { const propagationQueryKey = (compiled: ReturnType): string => `${compiled.sql}::${JSON.stringify(compiled.parameters)}`; +const toAffectedRowCount = (value: unknown): number | undefined => { + if (typeof value === 'bigint') { + return value > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(value); + } + if (typeof value === 'number') return value; + return undefined; +}; + const propagateDirtyRecords = async ( db: Kysely, edges: ReadonlyArray, @@ -1806,14 +1814,6 @@ const propagateDirtyRecords = async ( context?: IExecutionContext ): Promise> => { try { - const countDirtyRecords = async (): Promise => { - const result = await db - .selectFrom(DIRTY_TABLE) - .select(sql`count(*)`.as('count')) - .executeTakeFirst(); - return result ? Number(result.count) : 0; - }; - // Build trace info for all edges once const edgeTraceInfos = edges.map((edge, i) => buildEdgeTraceInfo(edge, i, tableById)); const plannedAllTargetReasonCounts = countPlannedAllTargetReasonCounts(edges); @@ -1852,7 +1852,6 @@ const propagateDirtyRecords = async ( const selectQueries = [...preparedQueries.values()]; const maxPasses = Math.max(selectQueries.length, 1); - let previousCount = await countDirtyRecords(); for (let pass = 0; pass < maxPasses; pass += 1) { if (selectQueries.length === 0) { @@ -1902,7 +1901,7 @@ const propagateDirtyRecords = async ( ); } - const executeBatchWork = async (): Promise => { + const executeBatchWork = async (): Promise => { // Build UNION ALL query from all SELECT queries if (selectQueries.length === 1) { // Single edge - no need for UNION ALL @@ -1914,7 +1913,8 @@ const propagateDirtyRecords = async ( .compile(); batchSpan?.setAttribute('batch.sql', compiled.sql); - await db.executeQuery(compiled); + const result = await db.executeQuery(compiled); + return toAffectedRowCount(result.numAffectedRows); } else { // Multiple edges - use UNION ALL // Start with first query, then chain unionAll for the rest @@ -1931,26 +1931,29 @@ const propagateDirtyRecords = async ( .compile(); batchSpan?.setAttribute('batch.sql', compiled.sql); - await db.executeQuery(compiled); + const result = await db.executeQuery(compiled); + return toAffectedRowCount(result.numAffectedRows); } }; + let insertedRowCount: number | undefined; try { // Use withSpan to set batchSpan as active context so pg queries become children if (batchSpan && context?.tracer) { - await context.tracer.withSpan(batchSpan, executeBatchWork); + insertedRowCount = await context.tracer.withSpan(batchSpan, executeBatchWork); } else { - await executeBatchWork(); + insertedRowCount = await executeBatchWork(); } } finally { + if (insertedRowCount !== undefined) { + batchSpan?.setAttribute('batch.insertedRowCount', insertedRowCount); + } batchSpan?.end(); } - const nextCount = await countDirtyRecords(); - if (nextCount === previousCount) { + if (insertedRowCount === 0) { break; } - previousCount = nextCount; } return ok({ plannedAllTargetReasonCounts, diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/ComputedFieldUpdater.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/ComputedFieldUpdater.spec.ts index 8240acabd7..96edb79bcf 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/ComputedFieldUpdater.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/ComputedFieldUpdater.spec.ts @@ -49,7 +49,7 @@ class RecordingConnection implements DatabaseConnection { async executeQuery(compiledQuery: CompiledQuery): Promise> { this.queries.push(compiledQuery); - return { rows: [] }; + return { rows: [], numAffectedRows: BigInt(0) }; } async *streamQuery(): AsyncIterableIterator> { @@ -717,20 +717,12 @@ describe('ComputedFieldUpdater', () => { ], "sql": "insert into "tmp_computed_dirty" ("table_id", "record_id") values ($1, $2) on conflict ("table_id", "record_id") do nothing", }, - { - "parameters": [], - "sql": "select count(*) as "count" from "tmp_computed_dirty"", - }, { "parameters": [ "tblcccccccccccccccc", ], "sql": "insert into "tmp_computed_dirty" ("table_id", "record_id") select distinct 'tblbbbbbbbbbbbbbbbb' as "table_id", "j"."__fk_fldffffffffffffffff" as "record_id" from "bseaaaaaaaaaaaaaaaa"."junction_fldeeeeeeeeeeeeeeee_fldffffffffffffffff" as "j" inner join "tmp_computed_dirty" as "d" on "d"."record_id" = "j"."__fk_fldeeeeeeeeeeeeeeee" where "d"."table_id" = $1 on conflict ("table_id", "record_id") do nothing", }, - { - "parameters": [], - "sql": "select count(*) as "count" from "tmp_computed_dirty"", - }, { "parameters": [], "sql": "select "table_id" as "tableId", count(*) as "recordCount" from "tmp_computed_dirty" group by "table_id"", @@ -1122,10 +1114,6 @@ describe('ComputedFieldUpdater', () => { ], "sql": "insert into "tmp_computed_dirty" ("table_id", "record_id") values ($1, $2) on conflict ("table_id", "record_id") do nothing", }, - { - "parameters": [], - "sql": "select count(*) as "count" from "tmp_computed_dirty"", - }, { "parameters": [ "tblkkkkkkkkkkkkkkkk", @@ -1133,10 +1121,6 @@ describe('ComputedFieldUpdater', () => { ], "sql": "insert into "tmp_computed_dirty" ("table_id", "record_id") select distinct 'tblllllllllllllllll' as "table_id", "t"."__id" as "record_id" from "bseaaaaaaaaaaaaaaaa"."tblllllllllllllllll" as "t" inner join "tmp_computed_dirty" as "d" on "d"."record_id" = "t"."__fk_fldpppppppppppppppp" where "d"."table_id" = $1 union all select distinct 'tblmmmmmmmmmmmmmmmm' as "table_id", "t"."__id" as "record_id" from "bseaaaaaaaaaaaaaaaa"."tblmmmmmmmmmmmmmmmm" as "t" inner join "tmp_computed_dirty" as "d" on "d"."record_id" = "t"."__fk_fldtttttttttttttttt" where "d"."table_id" = $2 on conflict ("table_id", "record_id") do nothing", }, - { - "parameters": [], - "sql": "select count(*) as "count" from "tmp_computed_dirty"", - }, { "parameters": [], "sql": "select "table_id" as "tableId", count(*) as "recordCount" from "tmp_computed_dirty" group by "table_id"", diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedFieldSelectExpressionVisitor.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedFieldSelectExpressionVisitor.ts index f5a72ac6a4..d848772261 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedFieldSelectExpressionVisitor.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedFieldSelectExpressionVisitor.ts @@ -549,7 +549,9 @@ export class ComputedFieldSelectExpressionVisitor return ok(sql.raw('NULL::jsonb').as(colAlias)); } const linkFieldResult = field.linkField(this.table); - if (linkFieldResult.isErr()) return err(linkFieldResult.error); + if (linkFieldResult.isErr()) { + return ok(sql.raw('NULL::jsonb').as(colAlias)); + } const linkField = linkFieldResult.value; if (linkField.foreignTableId().toString() !== field.foreignTableId().toString()) { return ok(sql.raw('NULL::jsonb').as(colAlias)); @@ -589,7 +591,9 @@ export class ComputedFieldSelectExpressionVisitor } const expression = field.expression().toString(); const linkFieldResult = field.linkField(this.table); - if (linkFieldResult.isErr()) return err(linkFieldResult.error); + if (linkFieldResult.isErr()) { + return this.typedNullColumn(field, colAlias); + } const linkField = linkFieldResult.value; if (linkField.foreignTableId().toString() !== field.foreignTableId().toString()) { return ok(sql.raw('NULL').as(colAlias)); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.spec.ts index cc0a24b7e6..5145b831dc 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.spec.ts @@ -1318,6 +1318,82 @@ describe('ComputedTableRecordQueryBuilder', () => { expect(sql).not.toContain('jsonb_array_elements'); }); + test('formula reference to link uses stored link title snapshot', () => { + const baseId = BaseId.create(BASE_ID)._unsafeUnwrap(); + const mainTableId = TableId.create(MAIN_TABLE_ID)._unsafeUnwrap(); + const foreignTableId = TableId.create(FOREIGN_TABLE_ID)._unsafeUnwrap(); + const linkFieldId = FieldId.create(LINK_FIELD_ID)._unsafeUnwrap(); + const foreignTitleFieldId = FieldId.create(`fld${'p'.repeat(16)}`)._unsafeUnwrap(); + const formulaFieldId = FieldId.create(`fld${'z'.repeat(16)}`)._unsafeUnwrap(); + + const foreignBuilder = Table.builder() + .withId(foreignTableId) + .withBaseId(baseId) + .withName(TableName.create('ForeignTable')._unsafeUnwrap()); + foreignBuilder + .field() + .singleLineText() + .withId(foreignTitleFieldId) + .withName(FieldName.create('Title')._unsafeUnwrap()) + .done(); + foreignBuilder.view().defaultGrid().done(); + + const foreignTable = foreignBuilder.build()._unsafeUnwrap(); + foreignTable + .getFields()[0] + .setDbFieldName(DbFieldName.rehydrate('col_title')._unsafeUnwrap()) + ._unsafeUnwrap(); + + const linkConfig = LinkFieldConfig.create({ + relationship: 'manyOne', + foreignTableId: foreignTableId.toString(), + lookupFieldId: foreignTitleFieldId.toString(), + symmetricFieldId: SYMMETRIC_FIELD_ID, + })._unsafeUnwrap(); + + const mainBuilder = Table.builder() + .withId(mainTableId) + .withBaseId(baseId) + .withName(TableName.create('MainTable')._unsafeUnwrap()); + mainBuilder + .field() + .link() + .withId(linkFieldId) + .withName(FieldName.create('Link')._unsafeUnwrap()) + .withConfig(linkConfig) + .done(); + mainBuilder + .field() + .formula() + .withId(formulaFieldId) + .withName(FieldName.create('LinkTitleFormula')._unsafeUnwrap()) + .withExpression(FormulaExpression.create(`{${linkFieldId.toString()}}`)._unsafeUnwrap()) + .done(); + mainBuilder.view().defaultGrid().done(); + + const mainTable = mainBuilder.build({ foreignTables: [foreignTable] })._unsafeUnwrap(); + mainTable + .getFields()[0] + .setDbFieldName(DbFieldName.rehydrate('col_link')._unsafeUnwrap()) + ._unsafeUnwrap(); + mainTable + .getFields()[1] + .setDbFieldName(DbFieldName.rehydrate('col_link_title_formula')._unsafeUnwrap()) + ._unsafeUnwrap(); + + const db = createTestDb(); + const foreignTables = new Map([[foreignTableId.toString(), foreignTable]]); + const { sql } = compileQuery( + db, + new ComputedTableRecordQueryBuilder(db, { foreignTables, typeValidationStrategy }) + .from(mainTable) + .select([formulaFieldId]) + ); + + expect(sql).toContain(`COALESCE(("t"."col_link")::jsonb->>'title'`); + expect(sql).not.toContain('"f"."col_title"'); + }); + test('shares LATERAL JOIN between link and lookup on same link', () => { const db = createTestDb(); const { mainTable, foreignTable, foreignTableId } = createLookupTable(); @@ -1394,6 +1470,27 @@ describe('ComputedTableRecordQueryBuilder', () => { expect(sql).not.toContain('inner join lateral'); }); + test('returns NULL lookup when lookup link field is missing', () => { + const db = createTestDb(); + const { mainTable, foreignTable, foreignTableId } = createLookupTable(); + const lookupField = mainTable.getFields()[2]; + const tableWithoutLink = Object.create(mainTable) as Table; + (tableWithoutLink as unknown as { getField: () => { isErr(): true } }).getField = () => ({ + isErr: () => true, + }); + + const foreignTables = new Map([[foreignTableId.toString(), foreignTable]]); + const { sql } = compileQuery( + db, + new ComputedTableRecordQueryBuilder(db, { foreignTables, typeValidationStrategy }) + .from(tableWithoutLink) + .select([lookupField.id()]) + ); + + expect(sql).toContain('NULL::jsonb as "col_lookup"'); + expect(sql).not.toContain('inner join lateral'); + }); + test('uses single-level flattening for lookup-of-lookup chains without inner filters', () => { const db = createTestDb(); const { hostTable, middleTable, middleTableId } = createNestedLookupChain(); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/FieldReferenceSqlVisitor.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/FieldReferenceSqlVisitor.spec.ts index 5e2bb9d9ec..0c62a36e4d 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/FieldReferenceSqlVisitor.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/FieldReferenceSqlVisitor.spec.ts @@ -620,6 +620,32 @@ describe('FieldReferenceSqlVisitor', () => { expect(mockLateral.calls).toEqual([]); }); + it('should short-circuit lookup fields with missing link field without registering laterals', () => { + const { table, lookupField } = createLookupErrorTable(); + const tableWithoutLink = Object.create(table) as Table; + (tableWithoutLink as unknown as { getField: () => { isErr(): true } }).getField = () => ({ + isErr: () => true, + }); + lookupField.setHasError(FieldHasError.ok()); + mockLateral.clear(); + const visitor = new FieldReferenceSqlVisitor({ + table: tableWithoutLink, + tableAlias: 't', + lateral: mockLateral, + }); + + const result = lookupField.accept(visitor); + + expect(result.isOk()).toBe(true); + const expr = result._unsafeUnwrap(); + + expect(expr.valueSql).toBe('NULL::text'); + expect(expr.valueType).toBe('string'); + expect(expr.errorConditionSql).toBe('TRUE'); + expect(expr.errorMessageSql).toContain('#ERROR:REF:errored_field'); + expect(mockLateral.calls).toEqual([]); + }); + it('should short-circuit errored conditional lookup fields without registering laterals', () => { const { table, conditionalLookupField } = createConditionalLookupErrorTable(); mockLateral.clear(); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/FieldReferenceSqlVisitor.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/FieldReferenceSqlVisitor.ts index 20d989994b..5f4a9c826e 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/FieldReferenceSqlVisitor.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/FieldReferenceSqlVisitor.ts @@ -385,25 +385,13 @@ export class FieldReferenceSqlVisitor implements IFieldVisitor { if (field.hasError().isError()) { return this.erroredFieldExpr(field, { isArray: isMultiValue, storageKind: 'json' }); } - const orderByResult = this.getLinkOrderBy(field); - if (orderByResult.isErr()) return err(orderByResult.error); - const lateralAlias = this.lateral.addColumn( - field.id(), - field.foreignTableId().toString(), - colAlias, - { - type: 'link', - lookupFieldId: field.lookupFieldId(), - isMultiValue, - orderBy: orderByResult.value, - } - ); - // Return the raw lateral reference - JSON extraction handled by visitFormulaField + // Use the stored link snapshot for formula references. Recomputing the link through the + // foreign display field can drop title when the foreign primary is itself stale/empty. return ok( makeExpr( - this.qualify(lateralAlias, colAlias), + this.qualify(this.tableAlias, colAlias), 'unknown', - false, + isMultiValue, undefined, undefined, field, @@ -430,7 +418,7 @@ export class FieldReferenceSqlVisitor implements IFieldVisitor { } const condition = field.lookupOptions().condition(); const linkFieldResult = field.linkField(this.table); - if (linkFieldResult.isErr()) return err(linkFieldResult.error); + if (linkFieldResult.isErr()) return this.erroredFieldExpr(field, exprOptions); const linkField = linkFieldResult.value; const orderByResult = this.getLinkOrderBy(linkField); if (orderByResult.isErr()) return err(orderByResult.error); @@ -472,10 +460,10 @@ export class FieldReferenceSqlVisitor implements IFieldVisitor { return this.erroredFieldExpr(field); } const expression = field.expression().toString(); - const linkFieldResult = field - .linkField(this.table) - .andThen((linkField) => this.getLinkOrderBy(linkField)); - if (linkFieldResult.isErr()) return err(linkFieldResult.error); + const linkFieldResult = field.linkField(this.table); + if (linkFieldResult.isErr()) return this.erroredFieldExpr(field); + const orderByResult = this.getLinkOrderBy(linkFieldResult.value); + if (orderByResult.isErr()) return err(orderByResult.error); const lateralAlias = this.lateral.addColumn( field.linkFieldId(), field.foreignTableId().toString(), @@ -484,7 +472,7 @@ export class FieldReferenceSqlVisitor implements IFieldVisitor { type: 'rollup', foreignFieldId: field.lookupFieldId(), expression, - orderBy: linkFieldResult.value, + orderBy: orderByResult.value, } ); return ok(makeExpr(this.qualify(lateralAlias, colAlias), 'unknown', false)); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.spec.ts index 0cc947a939..92b498ce96 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.spec.ts @@ -6,6 +6,7 @@ import { FieldId, FieldName, FormulaExpression, + LinkFieldConfig, Table, TableId, TableName, @@ -209,6 +210,77 @@ const createChainedFormulaTable = () => { return { table, plusOneId, plusOneDoubleId }; }; +const createLinkFormulaTable = () => { + const baseId = BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap(); + const mainTableId = TableId.create(`tbl${'l'.repeat(16)}`)._unsafeUnwrap(); + const foreignTableId = TableId.create(`tbl${'r'.repeat(16)}`)._unsafeUnwrap(); + const lookupFieldId = createFieldId(`fld${'p'.repeat(16)}`); + const linkFieldId = createFieldId(`fld${'q'.repeat(16)}`); + const formulaFieldId = createFieldId(`fld${'r'.repeat(16)}`); + + const foreignBuilder = Table.builder() + .withId(foreignTableId) + .withBaseId(baseId) + .withName(TableName.create('ForeignTable')._unsafeUnwrap()); + foreignBuilder + .field() + .singleLineText() + .withId(lookupFieldId) + .withName(createFieldName('Title')) + .done(); + foreignBuilder.view().defaultGrid().done(); + + const foreignTable = foreignBuilder.build()._unsafeUnwrap(); + foreignTable + .getFields()[0] + .setDbFieldName(DbFieldName.rehydrate('Title')._unsafeUnwrap()) + ._unsafeUnwrap(); + + const linkConfig = LinkFieldConfig.create({ + relationship: 'manyOne', + foreignTableId: foreignTableId.toString(), + lookupFieldId: lookupFieldId.toString(), + symmetricFieldId: `fld${'s'.repeat(16)}`, + })._unsafeUnwrap(); + + const mainBuilder = Table.builder() + .withId(mainTableId) + .withBaseId(baseId) + .withName(TableName.create('MainTable')._unsafeUnwrap()); + mainBuilder.field().singleLineText().withName(createFieldName('Name')).done(); + mainBuilder + .field() + .link() + .withId(linkFieldId) + .withName(createFieldName('Link')) + .withConfig(linkConfig) + .done(); + mainBuilder + .field() + .formula() + .withId(formulaFieldId) + .withName(createFieldName('LinkTitleFormula')) + .withExpression(FormulaExpression.create(`{${linkFieldId.toString()}}`)._unsafeUnwrap()) + .done(); + mainBuilder.view().defaultGrid().done(); + + const table = mainBuilder.build({ foreignTables: [foreignTable] })._unsafeUnwrap(); + table + .getFields()[0] + .setDbFieldName(DbFieldName.rehydrate('Name')._unsafeUnwrap()) + ._unsafeUnwrap(); + table + .getFields()[1] + .setDbFieldName(DbFieldName.rehydrate('Link')._unsafeUnwrap()) + ._unsafeUnwrap(); + table + .getFields()[2] + .setDbFieldName(DbFieldName.rehydrate('LinkTitleFormula')._unsafeUnwrap()) + ._unsafeUnwrap(); + + return { table, formulaFieldId }; +}; + const createIsErrorFormulaChainTable = () => { const baseId = BaseId.create(`bse${'e'.repeat(16)}`)._unsafeUnwrap(); const tableId = TableId.create(`tbl${'e'.repeat(16)}`)._unsafeUnwrap(); @@ -493,6 +565,30 @@ describe('SameTableBatchQueryBuilder', () => { expect(sqlText).toContain(`"level_0"."AlwaysError" LIKE '#ERROR:%'`); expect(sqlText).toContain('TRUE OR'); }); + + it('extracts link title when a same-table formula directly references a link field', () => { + const db = createMockKysely(); + const builder = new SameTableBatchQueryBuilder(db, typeValidationStrategy); + const { table, formulaFieldId } = createLinkFormulaTable(); + + const result = builder.build({ + table, + fieldLevels: [{ level: 0, fieldIds: [formulaFieldId] }], + }); + + expect(result.isOk()).toBe(true); + const updateBuilder = new UpdateFromSelectBuilder(db); + const compiled = updateBuilder.build({ + table, + fieldIds: [formulaFieldId], + selectQuery: result._unsafeUnwrap().selectQuery, + }); + expect(compiled.isOk()).toBe(true); + + const sqlText = compiled._unsafeUnwrap().sql; + expect(sqlText).toContain(`COALESCE(("t"."Link")::jsonb->>'title'`); + expect(sqlText).not.toContain(`"t"."Link" as "LinkTitleFormula"`); + }); }); describe('field mappings', () => { diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.ts index 0f21f2b437..a73ef64a19 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.ts @@ -9,9 +9,11 @@ import type { Table, } from '@teable/v2-core'; import { + extractJsonScalarText, FormulaSqlPgTranslator, guardValueSql, makeExpr, + normalizeToJsonArrayWithStrategy, type IPgTypeValidationStrategy, type SqlExpr, type SqlValueType, @@ -248,10 +250,47 @@ export class SameTableBatchQueryBuilder { } const expr = translated.value; - const typedSql = guardValueSql(expr.valueSql, expr.errorConditionSql); + const valueSql = this.normalizeFormulaValueSql(formulaField, expr); + const typedSql = guardValueSql(valueSql, expr.errorConditionSql); return ok(sql.raw(typedSql)); } + private normalizeFormulaValueSql(formulaField: FormulaField, expr: SqlExpr): string { + if (expr.storageKind !== 'json' || !this.shouldExtractJsonDisplay(expr)) { + return expr.valueSql; + } + + const formulaIsMultiple = formulaField + .isMultipleCellValue() + .map((multiplicity) => multiplicity.isMultiple()) + .unwrapOr(false); + + if (formulaIsMultiple || expr.isArray) { + const normalized = normalizeToJsonArrayWithStrategy( + expr.valueSql, + this.typeValidationStrategy + ); + return `( + SELECT jsonb_agg(to_jsonb(${extractJsonScalarText('elem')}) ORDER BY ord) + FROM jsonb_array_elements(${normalized}) WITH ORDINALITY AS _jae(elem, ord) + )`; + } + + return extractJsonScalarText(`(${expr.valueSql})::jsonb`); + } + + private shouldExtractJsonDisplay(expr: SqlExpr): boolean { + const referenced = expr.field; + if (!referenced) return false; + + const type = referenced.type(); + return ( + type.equals(FieldType.link()) || + type.equals(FieldType.button()) || + type.equals(FieldType.user()) + ); + } + /** * Resolve a field reference to SQL, checking if it should come from a previous CTE. */ diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/__snapshots__/FieldReferenceSqlVisitor.spec.ts.snap b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/__snapshots__/FieldReferenceSqlVisitor.spec.ts.snap index 46bf77a48a..52ec0cd430 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/__snapshots__/FieldReferenceSqlVisitor.spec.ts.snap +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/__snapshots__/FieldReferenceSqlVisitor.spec.ts.snap @@ -38,28 +38,10 @@ exports[`FieldReferenceSqlVisitor > JSON fields with display value extraction > exports[`FieldReferenceSqlVisitor > link fields > should aggregate titles from multi-value link field 1`] = ` { - "isArray": false, - "lateralCalls": [ - { - "columnType": { - "isMultiValue": true, - "lookupFieldId": FieldId { - "value": "fldtttttttttttttttt", - }, - "orderBy": { - "column": "__fk_fldyyyyyyyyyyyyyyyy_order", - "source": "foreign", - }, - "type": "link", - }, - "foreignTableId": "tblffffffffffffffff", - "linkFieldId": "fldllllllllllllllll", - "outputAlias": "col_link_multiple", - "type": "addColumn", - }, - ], + "isArray": true, + "lateralCalls": [], "storageKind": "json", - "valueSql": ""lat_0"."col_link_multiple"", + "valueSql": ""t"."col_link_multiple"", "valueType": "unknown", } `; @@ -67,24 +49,9 @@ exports[`FieldReferenceSqlVisitor > link fields > should aggregate titles from m exports[`FieldReferenceSqlVisitor > link fields > should extract title from single-value link field 1`] = ` { "isArray": false, - "lateralCalls": [ - { - "columnType": { - "isMultiValue": false, - "lookupFieldId": FieldId { - "value": "fldtttttttttttttttt", - }, - "orderBy": undefined, - "type": "link", - }, - "foreignTableId": "tblffffffffffffffff", - "linkFieldId": "fldkkkkkkkkkkkkkkkk", - "outputAlias": "col_link_single", - "type": "addColumn", - }, - ], + "lateralCalls": [], "storageKind": "json", - "valueSql": ""lat_0"."col_link_single"", + "valueSql": ""t"."col_link_single"", "valueType": "unknown", } `; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/insert/RecordInsertBuilder.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/insert/RecordInsertBuilder.ts index ad9acf0f6c..4e992b25e9 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/insert/RecordInsertBuilder.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/insert/RecordInsertBuilder.ts @@ -9,6 +9,7 @@ import type { Result } from 'neverthrow'; import { buildFilledLinkValueExpression } from '../../buildFilledLinkValueExpression'; import { isPersistedAsGeneratedColumn } from '../../computed/isPersistedAsGeneratedColumn'; import { normalizeStoredLinkItems } from '../../normalizeLinkItems'; +import { buildAttachmentTableInsertQuery } from '../../attachments/attachmentTableMutations'; import { FieldInsertValueVisitor, type FieldInsertResult } from '../../visitors'; import type { DynamicDB } from '../ITableRecordQueryBuilder'; @@ -379,6 +380,23 @@ export class RecordInsertBuilder { } } } + + if (field.type().equals(FieldType.attachment())) { + const insertQuery = buildAttachmentTableInsertQuery(builder.db, { + actorId: context.actorId, + tableId: table.id().toString(), + recordId: context.recordId, + fieldId: fieldIdStr, + value: rawValue, + }); + + if (insertQuery) { + additionalStatements.push({ + description: `Insert attachment index rows for field ${fieldIdStr}`, + compiled: insertQuery, + }); + } + } } else { // Fallback: just use raw value values[dbFieldName] = rawValue; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.ts b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.ts index 3935126dc1..d2405b2fe5 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.ts @@ -1720,7 +1720,7 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor if (changedFieldColumns.length > 0 && !updatedRow) { await snapshotCaptureSession.abort(); snapshotCaptureSession = undefined; - return ok({}); + return ok({ mutationApplied: false }); } // Acquire advisory locks for linked records to prevent deadlocks @@ -1765,7 +1765,7 @@ export class PostgresTableRecordRepository implements core.ITableRecordRepositor await this.touchTableMeta(db, table.id().toString(), actorId); const computedChanges = extractChangesForRecord(computedResult, recordIdStr); const changedFields = toChangedFieldsMap(updatedRow, changedFieldColumns); - return ok({ changedFields, computedChanges, updateSnapshot }); + return ok({ mutationApplied: true, changedFields, computedChanges, updateSnapshot }); } catch (error) { await snapshotCaptureSession?.abort(); return err( diff --git a/packages/v2/adapter-table-repository-postgres/src/record/repository/RecordSearchWhereBuilder.pglite.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/repository/RecordSearchWhereBuilder.pglite.spec.ts index ddaa973e9a..fd0b3619e3 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/repository/RecordSearchWhereBuilder.pglite.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/repository/RecordSearchWhereBuilder.pglite.spec.ts @@ -36,6 +36,10 @@ class PGliteDialect implements Dialect { }; }, streamQuery: async function* () { + // eslint-disable-next-line no-constant-condition + if (false) { + yield undefined as never; + } throw new Error('PGlite does not support streaming'); }, }), @@ -292,6 +296,38 @@ const findMatchingRecordIds = async ({ return rows.map((row) => row.id as string); }; +const compileSearchQuery = ({ + db, + table, + fullTableName, + search, + visibleFieldIds, +}: { + db: Kysely; + table: Table; + fullTableName: string; + search: RecordSearch; + visibleFieldIds?: ReadonlyArray; +}) => { + const whereClause = buildRecordSearchWhereClause( + table, + { + search, + visibleFieldIds, + }, + { + tableAlias: 't', + } + )._unsafeUnwrap(); + + let query = db.selectFrom(`${fullTableName} as t`).select('t.__id as id'); + if (whereClause != null) { + query = query.where(whereClause); + } + + return query.compile(); +}; + describe('RecordSearchWhereBuilder (pglite)', () => { let client: PGlite; let db: Kysely; @@ -411,6 +447,20 @@ describe('RecordSearchWhereBuilder (pglite)', () => { ).resolves.toEqual([fixture.recordIds.alpha]); }); + it('compiles date-like searches to range predicates instead of TO_CHAR matches', async () => { + const fixture = await setupSearchFixture({ db, createdSchemas, seed: 'date-range-sql' }); + + const compiled = compileSearchQuery({ + db, + table: fixture.table, + fullTableName: fixture.fullTableName, + search: RecordSearch.fromTuple(['2026-02-24', fixture.fieldIds.due.toString(), true]), + }); + + expect(compiled.sql.toLowerCase()).toContain('"t"."col_due" >='); + expect(compiled.sql.toLowerCase()).toContain('"t"."col_due" <'); + expect(compiled.sql.toLowerCase()).not.toContain('to_char('); + }); it('does not filter rows for checkbox field-specific visible-row search', async () => { const fixture = await setupSearchFixture({ db, createdSchemas, seed: 'checkbox' }); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/repository/RecordSearchWhereBuilder.ts b/packages/v2/adapter-table-repository-postgres/src/record/repository/RecordSearchWhereBuilder.ts index 1a2bcee1a9..55e8fd4595 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/repository/RecordSearchWhereBuilder.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/repository/RecordSearchWhereBuilder.ts @@ -21,10 +21,9 @@ import { import { sql, type Expression, type SqlBool } from 'kysely'; import { ok, safeTry } from 'neverthrow'; import type { Result } from 'neverthrow'; +import { getDateSearchRange } from './dateSearchRange'; const fieldValueTypeVisitor = new FieldValueTypeVisitor(); -const DEFAULT_DATE_TIME_FORMAT = 'YYYY-MM-DD HH24:MI'; - const escapeLikeWildcards = (input: string): string => { return input.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_'); }; @@ -106,20 +105,20 @@ const buildNumberMultipleCondition = ( const buildDateMultipleCondition = ( columnRef: Expression, searchValue: string, - timeZone: string + formatting?: DateTimeFormatting ) => { + const range = getDateSearchRange(searchValue, formatting); + if (!range) { + return sql`false`; + } + const arrayExpr = normalizeToJsonArray(columnRef); return sql` EXISTS ( SELECT 1 - FROM ( - SELECT string_agg( - TO_CHAR(TIMEZONE(${timeZone}, CAST(elem.value AS timestamp with time zone)), ${DEFAULT_DATE_TIME_FORMAT}), - ', ' - ) AS aggregated - FROM jsonb_array_elements_text(${arrayExpr}) AS elem(value) - ) AS sub - WHERE sub.aggregated ILIKE ${`%${escapeLikeWildcards(searchValue)}%`} ESCAPE '\\' + FROM jsonb_array_elements_text(${arrayExpr}) AS elem(value) + WHERE CAST(elem.value AS timestamp with time zone) >= ${range.start} + AND CAST(elem.value AS timestamp with time zone) < ${range.end} ) `; }; @@ -231,10 +230,6 @@ const resolveNumberPrecision = (field: Field): number => { return resolveNumberFormatting(field)?.precision().toNumber() ?? 0; }; -const resolveSearchTimeZone = (field: Field): string => { - return resolveDateTimeFormatting(field)?.timeZone().toString() ?? 'UTC'; -}; - const resolveColumnRef = ( field: Field, tableAlias: string @@ -282,11 +277,16 @@ const buildFieldSearchCondition = ( } if (cellValueType.equals(CellValueType.dateTime())) { - const timeZone = resolveSearchTimeZone(field); + const formatting = resolveDateTimeFormatting(field); + const range = getDateSearchRange(search.value, formatting); + if (!range) { + return ok(sql`false`); + } + return ok( isMultiple - ? buildDateMultipleCondition(columnRef, search.value, timeZone) - : sql`TO_CHAR(TIMEZONE(${timeZone}, ${columnRef}), ${DEFAULT_DATE_TIME_FORMAT}) ILIKE ${`%${escapeLikeWildcards(search.value)}%`} ESCAPE '\\'` + ? buildDateMultipleCondition(columnRef, search.value, formatting) + : sql`${columnRef} >= ${range.start} AND ${columnRef} < ${range.end}` ); } diff --git a/packages/v2/adapter-table-repository-postgres/src/record/repository/dateSearchRange.ts b/packages/v2/adapter-table-repository-postgres/src/record/repository/dateSearchRange.ts new file mode 100644 index 0000000000..ac578990e8 --- /dev/null +++ b/packages/v2/adapter-table-repository-postgres/src/record/repository/dateSearchRange.ts @@ -0,0 +1,76 @@ +import { DateFormattingPreset, type DateTimeFormatting, TimeFormatting } from '@teable/v2-core'; +import dayjs from 'dayjs'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(customParseFormat); + +type IDateSearchUnit = 'year' | 'month' | 'day' | 'minute'; + +export interface IDateSearchRange { + start: string; + end: string; +} + +const dateSearchPatterns: Array<{ pattern: RegExp; format: string; unit: IDateSearchUnit }> = [ + { pattern: /^\d{4}$/, format: 'YYYY', unit: 'year' }, + { pattern: /^\d{4}-\d{2}$/, format: 'YYYY-MM', unit: 'month' }, + { pattern: /^\d{4}-\d{2}-\d{2}$/, format: 'YYYY-MM-DD', unit: 'day' }, + { pattern: /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}$/, format: 'YYYY-MM-DD HH:mm', unit: 'minute' }, +]; + +const isUnitAllowed = (unit: IDateSearchUnit, formatting?: DateTimeFormatting): boolean => { + const dateFormat = formatting?.date() ?? DateFormattingPreset.ISO; + const hasTime = formatting != null && formatting.time() !== TimeFormatting.None; + + switch (unit) { + case 'year': + return true; + case 'month': + return dateFormat !== DateFormattingPreset.Y; + case 'day': + return dateFormat !== DateFormattingPreset.Y && dateFormat !== DateFormattingPreset.YM; + case 'minute': + return hasTime; + default: + return false; + } +}; + +export const getDateSearchRange = ( + rawSearchValue: string, + formatting?: DateTimeFormatting +): IDateSearchRange | null => { + const searchValue = rawSearchValue.trim(); + if (!searchValue) { + return null; + } + + const timeZone = formatting?.timeZone().toString() ?? 'UTC'; + + for (const candidate of dateSearchPatterns) { + if (!candidate.pattern.test(searchValue) || !isUnitAllowed(candidate.unit, formatting)) { + continue; + } + + const normalizedSearchValue = + candidate.unit === 'minute' ? searchValue.replace('T', ' ') : searchValue; + const parsed = dayjs.tz(normalizedSearchValue, candidate.format, timeZone); + if (!parsed.isValid() || parsed.format(candidate.format) !== normalizedSearchValue) { + continue; + } + + const start = parsed.startOf(candidate.unit); + const end = start.add(1, candidate.unit); + + return { + start: start.toISOString(), + end: end.toISOString(), + }; + } + + return null; +}; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/visitors/CellValueMutateVisitor.ts b/packages/v2/adapter-table-repository-postgres/src/record/visitors/CellValueMutateVisitor.ts index 50ad750c9e..cb3b156254 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/visitors/CellValueMutateVisitor.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/visitors/CellValueMutateVisitor.ts @@ -8,7 +8,6 @@ import type { LastModifiedTimeField, LinkField, MultipleSelectField, - SetAttachmentValueSpec, SetCheckboxValueSpec, SetDateValueSpec, SetLinkValueByTitleSpec, @@ -31,6 +30,7 @@ import { FieldId, FieldType, ok, + SetAttachmentValueSpec, SetLinkValueSpec as SetLinkValueSpecClass, } from '@teable/v2-core'; import type { CompiledQuery, Kysely } from 'kysely'; @@ -38,6 +38,7 @@ import { sql } from 'kysely'; import { err, safeTry } from 'neverthrow'; import type { Result } from 'neverthrow'; +import { buildAttachmentTableReplaceQueries } from '../attachments/attachmentTableMutations'; import { buildFilledLinkValueExpression } from '../buildFilledLinkValueExpression'; import { isPersistedAsGeneratedColumn } from '../computed/isPersistedAsGeneratedColumn'; import { normalizeStoredLinkItems } from '../normalizeLinkItems'; @@ -503,7 +504,22 @@ export class CellValueMutateVisitor implements ICellValueSpecVisitor { } visitSetAttachmentValue(spec: SetAttachmentValueSpec): Result { - return this.addJsonValue(spec.fieldId, spec.value.toValue()); + const addResult = this.addJsonValue(spec.fieldId, spec.value.toValue()); + if (addResult.isErr()) { + return addResult; + } + + this.additionalStatements.push( + ...buildAttachmentTableReplaceQueries(this.db, { + actorId: this.ctx.actorId, + tableId: this.table.id().toString(), + recordId: this.ctx.recordId, + fieldId: spec.fieldId.toString(), + value: spec.value.toValue(), + }) + ); + + return ok(undefined); } visitSetUserValue(spec: SetUserValueSpec): Result { @@ -536,6 +552,11 @@ export class CellValueMutateVisitor implements ICellValueSpecVisitor { return this.visitSetLinkValue(nullSpec); } + if (field.type().equals(FieldType.attachment())) { + const nullSpec = new SetAttachmentValueSpec(field.id(), CellValue.null()); + return this.visitSetAttachmentValue(nullSpec); + } + // Non-link fields: directly SET col = NULL return this.addSimpleValue(field.id(), null); } diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/core/RuleRepairMetadata.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/core/RuleRepairMetadata.ts index 6645a7bfff..685a3cdce6 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/core/RuleRepairMetadata.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/core/RuleRepairMetadata.ts @@ -97,5 +97,9 @@ export const getRuleRepairHint = ( return ok({ available: true, mode: 'auto', + description: createMessage( + 'table:table.integrity.v2.repairMeta.description.autoRule', + 'The repair will execute the schema statements generated by this rule, then re-check the rule to confirm the schema is valid.' + ), }); }; diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ColumnExistsRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ColumnExistsRule.ts index 9129013afe..06ff4fc5db 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ColumnExistsRule.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ColumnExistsRule.ts @@ -14,6 +14,7 @@ import { import type { SchemaRuleContext } from '../context/SchemaRuleContext'; import type { ISchemaRule, + SchemaRuleRepairHint, SchemaRuleValidationResult, TableSchemaStatementBuilder, } from '../core/ISchemaRule'; @@ -122,6 +123,23 @@ export class ColumnExistsRule implements ISchemaRule { }); } + getRepairHint( + _ctx: SchemaRuleContext, + _validation: SchemaRuleValidationResult + ): Result { + return ok({ + available: true, + mode: 'auto', + reason: { + fallback: `Automatic repair will recreate the missing physical column for "${this.field.name().toString()}".`, + }, + description: { + fallback: + 'This repair only recreates the missing column structure. It does not recover historical cell values from another source, so affected records may still display empty values until data is backfilled separately.', + }, + }); + } + up(ctx: SchemaRuleContext): Result, DomainError> { return safeTry, DomainError>(function* () { const columnName = yield* resolveColumnName(ctx.field); diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FieldMetaRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FieldMetaRule.ts index f7256c7175..bab19ff72c 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FieldMetaRule.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FieldMetaRule.ts @@ -1,10 +1,12 @@ import type { DomainError, Field } from '@teable/v2-core'; +import { sql } from 'kysely'; import { ok } from 'neverthrow'; import type { Result } from 'neverthrow'; import type { SchemaRuleContext } from '../context/SchemaRuleContext'; import type { ISchemaRule, + SchemaRuleRepairHint, SchemaRuleValidationResult, TableSchemaStatementBuilder, } from '../core/ISchemaRule'; @@ -108,11 +110,31 @@ export class FieldMetaRule implements ISchemaRule { return ok({ valid: true }); } + getRepairHint( + _ctx: SchemaRuleContext, + _validation: SchemaRuleValidationResult + ): Result { + return ok({ + available: true, + mode: 'auto', + reason: { + fallback: `Automatic repair will update metadata for "${this.field.name().toString()}".`, + }, + description: { + fallback: + 'This repair changes field metadata only and preserves unrelated meta keys. Record cell values are not rewritten, but field behavior or display can change immediately after the metadata update takes effect.', + }, + }); + } + up(ctx: SchemaRuleContext): Result, DomainError> { const fieldId = this.field.id().toString(); + const patch = JSON.stringify(this.meta); const updateMeta = ctx.db .updateTable('field') - .set({ meta: JSON.stringify(this.meta) }) + .set({ + meta: sql`(coalesce(meta::jsonb, '{}'::jsonb) || ${patch}::jsonb)::text`, + }) .where('id', '=', fieldId); return ok([updateMeta]); @@ -120,10 +142,16 @@ export class FieldMetaRule implements ISchemaRule { down(ctx: SchemaRuleContext): Result, DomainError> { const fieldId = this.field.id().toString(); - // Clear metadata on down (set to empty object) + const keys = Object.keys(this.meta); + const quotedKeys = keys.map((key) => `'${key.replaceAll("'", "''")}'`).join(', '); const updateMeta = ctx.db .updateTable('field') - .set({ meta: JSON.stringify({}) }) + .set({ + meta: + keys.length === 0 + ? sql`coalesce(meta::jsonb, '{}'::jsonb)::text` + : sql.raw(`(coalesce(meta::jsonb, '{}'::jsonb) - array[${quotedKeys}])::text`), + }) .where('id', '=', fieldId); return ok([updateMeta]); diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FieldSchemaRulesFactory.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FieldSchemaRulesFactory.ts index 7e1b376f03..515f55b637 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FieldSchemaRulesFactory.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FieldSchemaRulesFactory.ts @@ -44,6 +44,7 @@ import { LinkValueColumnRule } from './LinkValueColumnRule'; import { NotNullConstraintRule } from './NotNullConstraintRule'; import { OrderColumnRule } from './OrderColumnRule'; import { ReferenceRule } from './ReferenceRule'; +import { SelectOptionsMetaRule } from './SelectOptionsMetaRule'; import { UniqueIndexRule } from './UniqueIndexRule'; /** @@ -156,13 +157,13 @@ export class FieldSchemaRulesVisitor extends AbstractFieldVisitor, DomainError> { - return ok(ColumnExistsRule.createRulesFromField(field)); + return ok([...ColumnExistsRule.createRulesFromField(field), new SelectOptionsMetaRule(field)]); } visitMultipleSelectField( field: MultipleSelectField ): Result, DomainError> { - return ok(ColumnExistsRule.createRulesFromField(field)); + return ok([...ColumnExistsRule.createRulesFromField(field), new SelectOptionsMetaRule(field)]); } visitCheckboxField(field: CheckboxField): Result, DomainError> { diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FkColumnRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FkColumnRule.ts index 61e2ba8347..af1917753a 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FkColumnRule.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FkColumnRule.ts @@ -6,6 +6,7 @@ import { resolveColumnName } from '../../visitors/PostgresTableSchemaFieldColumn import type { SchemaRuleContext } from '../context/SchemaRuleContext'; import type { ISchemaRule, + SchemaRuleRepairHint, SchemaRuleValidationResult, TableSchemaStatementBuilder, } from '../core/ISchemaRule'; @@ -82,6 +83,23 @@ export class FkColumnRule implements ISchemaRule { }); } + getRepairHint( + _ctx: SchemaRuleContext, + _validation: SchemaRuleValidationResult + ): Result { + return ok({ + available: true, + mode: 'auto', + reason: { + fallback: `Automatic repair will recreate the FK helper column for "${this.field.name().toString()}".`, + }, + description: { + fallback: + 'This repair treats the current link-value column in the underlying table as the recovery source and only backfills rows where the FK helper column is still empty. Existing FK values are preserved. If the stored link values are already missing or stale, the missing relations cannot be fully reconstructed and linked displays may remain incomplete.', + }, + }); + } + up(ctx: SchemaRuleContext): Result, DomainError> { const self = this; return safeTry, DomainError>(function* () { diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/GeneratedColumnMetaRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/GeneratedColumnMetaRule.ts index 07136ae440..3962860ca9 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/GeneratedColumnMetaRule.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/GeneratedColumnMetaRule.ts @@ -9,6 +9,7 @@ import { import type { SchemaRuleContext } from '../context/SchemaRuleContext'; import type { ISchemaRule, + SchemaRuleRepairHint, SchemaRuleValidationResult, TableSchemaStatementBuilder, } from '../core/ISchemaRule'; @@ -64,6 +65,23 @@ export class GeneratedColumnMetaRule implements ISchemaRule { }); } + getRepairHint( + _ctx: SchemaRuleContext, + _validation: SchemaRuleValidationResult + ): Result { + return ok({ + available: true, + mode: 'auto', + reason: { + fallback: `Automatic repair will convert "${this.field.name().toString()}" back to a normal stored column.`, + }, + description: { + fallback: + 'This repair drops the current generated column and recreates a plain stored column to match field metadata. The old generated display values in that physical column are discarded, so the recreated column starts empty until another process repopulates it.', + }, + }); + } + up(ctx: SchemaRuleContext): Result, DomainError> { return safeTry, DomainError>(function* () { const columnName = yield* resolveColumnName(ctx.field); diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/GeneratedColumnRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/GeneratedColumnRule.ts index 88e5e21e6d..6ce9c8ebc3 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/GeneratedColumnRule.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/GeneratedColumnRule.ts @@ -7,6 +7,7 @@ import { resolveColumnName } from '../../visitors/PostgresTableSchemaFieldColumn import type { SchemaRuleContext } from '../context/SchemaRuleContext'; import type { ISchemaRule, + SchemaRuleRepairHint, SchemaRuleValidationResult, TableSchemaStatementBuilder, } from '../core/ISchemaRule'; @@ -106,6 +107,23 @@ export class GeneratedColumnRule implements ISchemaRule { }); } + getRepairHint( + _ctx: SchemaRuleContext, + _validation: SchemaRuleValidationResult + ): Result { + return ok({ + available: true, + mode: 'auto', + reason: { + fallback: `Automatic repair will rebuild the generated column for "${this.field.name().toString()}".`, + }, + description: { + fallback: + 'This repair drops and recreates the physical column from its system source column. User-visible values are recalculated from underlying system data, so ad-hoc values that had drifted in the physical column will be discarded.', + }, + }); + } + up(ctx: SchemaRuleContext): Result, DomainError> { const sourceColumn = this.sourceColumn; const columnType = this.columnType; diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/JunctionTableRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/JunctionTableRule.ts index 04b0de4f16..b2886a04af 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/JunctionTableRule.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/JunctionTableRule.ts @@ -1,7 +1,7 @@ import { domainError, type DomainError, type LinkField } from '@teable/v2-core'; +import { sql } from 'kysely'; import { err, ok, safeTry } from 'neverthrow'; import type { Result } from 'neverthrow'; -import { sql } from 'kysely'; import { resolveColumnName } from '../../visitors/PostgresTableSchemaFieldColumn'; import type { SchemaRuleContext } from '../context/SchemaRuleContext'; @@ -227,6 +227,23 @@ export class JunctionTableExistsRule implements ISchemaRule { }); } + getRepairHint( + _ctx: SchemaRuleContext, + _validation: SchemaRuleValidationResult + ): Result { + return ok({ + available: true, + mode: 'auto', + reason: { + fallback: `Automatic repair will recreate the junction table for "${this.field.name().toString()}".`, + }, + description: { + fallback: + 'This repair treats the current link-value column in the underlying source table as the recovery source. It recreates only the relation rows that can still be derived from those stored link values. Missing historical links cannot be recovered, and rebuilt ordering/display may follow the stored link-value order.', + }, + }); + } + up(ctx: SchemaRuleContext): Result, DomainError> { const self = this; return safeTry, DomainError>(function* () { diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/LinkValueColumnRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/LinkValueColumnRule.ts index 19d9e47f45..3fbc1782e3 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/LinkValueColumnRule.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/LinkValueColumnRule.ts @@ -6,6 +6,7 @@ import { resolveColumnName } from '../../visitors/PostgresTableSchemaFieldColumn import type { SchemaRuleContext } from '../context/SchemaRuleContext'; import type { ISchemaRule, + SchemaRuleRepairHint, SchemaRuleValidationResult, TableSchemaStatementBuilder, } from '../core/ISchemaRule'; @@ -57,6 +58,23 @@ export class LinkValueColumnRule implements ISchemaRule { }); } + getRepairHint( + _ctx: SchemaRuleContext, + _validation: SchemaRuleValidationResult + ): Result { + return ok({ + available: true, + mode: 'auto', + reason: { + fallback: `Automatic repair will recreate the stored link-value column for "${this.field.name().toString()}".`, + }, + description: { + fallback: + 'This repair restores the JSONB display-value column only. Underlying relation ids in FK or junction storage are not rewritten, but linked cells may still display empty or stale values until those display payloads are rebuilt.', + }, + }); + } + up(ctx: SchemaRuleContext): Result, DomainError> { return safeTry, DomainError>(function* () { const columnName = yield* resolveColumnName(ctx.field); diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/OrderColumnRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/OrderColumnRule.ts index 9c74432624..47e7f760f8 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/OrderColumnRule.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/OrderColumnRule.ts @@ -5,6 +5,7 @@ import type { Result } from 'neverthrow'; import type { SchemaRuleContext } from '../context/SchemaRuleContext'; import type { ISchemaRule, + SchemaRuleRepairHint, SchemaRuleValidationResult, TableSchemaStatementBuilder, } from '../core/ISchemaRule'; @@ -69,6 +70,23 @@ export class OrderColumnRule implements ISchemaRule { }); } + getRepairHint( + _ctx: SchemaRuleContext, + _validation: SchemaRuleValidationResult + ): Result { + return ok({ + available: true, + mode: 'auto', + reason: { + fallback: `Automatic repair will recreate the order column for "${this.field.name().toString()}".`, + }, + description: { + fallback: + 'This repair restores only the helper column that stores link display order. Relation data stays intact, but custom ordering may be lost until order values are repopulated.', + }, + }); + } + up(ctx: SchemaRuleContext): Result, DomainError> { const columnName = this.columnName; const targetTable = this.targetTable; diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/SchemaRules.pglite.spec.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/SchemaRules.pglite.spec.ts index 51ce1b6059..69240dc470 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/SchemaRules.pglite.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/SchemaRules.pglite.spec.ts @@ -13,7 +13,9 @@ import { createConditionalLookupFieldPending, createCreatedTimeField, createLinkField, + createMultipleSelectField, createLookupFieldPending, + createSingleSelectField, createSingleLineTextField, ConditionalLookupOptions, DbFieldName, @@ -26,10 +28,12 @@ import { FieldUnique, GeneratedColumnMeta, LookupOptions, + SelectOption, Table, TableId, TableName, } from '@teable/v2-core'; +import { Pg16TypeValidationStrategy } from '@teable/v2-formula-sql-pg'; import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; import type { Dialect, QueryResult } from 'kysely'; import { @@ -44,6 +48,7 @@ import { err, ok } from 'neverthrow'; import type { Result } from 'neverthrow'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { ComputedFieldBackfillService } from '../../../record/computed/ComputedFieldBackfillService'; import { createSchemaChecker } from '../checker/SchemaChecker'; import type { SchemaCheckResult } from '../checker/SchemaCheckResult'; import { PostgresSchemaIntrospector } from '../context/PostgresSchemaIntrospector'; @@ -73,6 +78,7 @@ import { LinkValueColumnRule } from './LinkValueColumnRule'; import { NotNullConstraintRule } from './NotNullConstraintRule'; import { OrderColumnRule } from './OrderColumnRule'; import { ReferenceRule } from './ReferenceRule'; +import { SelectOptionsMetaRule } from './SelectOptionsMetaRule'; import { UniqueIndexRule } from './UniqueIndexRule'; const TEST_SCHEMA = 'test_schema'; @@ -202,6 +208,35 @@ const createRealField = ( return fieldResult; }; +const createRealFieldWithFieldId = ( + id: string, + name: string, + dbFieldName: string +): Result => { + const fieldIdResult = FieldId.create(id); + if (fieldIdResult.isErr()) return err(fieldIdResult.error); + + const fieldNameResult = FieldName.create(name); + if (fieldNameResult.isErr()) return err(fieldNameResult.error); + + const dbFieldResult = DbFieldName.rehydrate(dbFieldName); + if (dbFieldResult.isErr()) return err(dbFieldResult.error); + + const fieldResult = createSingleLineTextField({ + id: fieldIdResult.value, + name: fieldNameResult.value, + notNull: FieldNotNull.optional(), + unique: FieldUnique.disabled(), + }); + + if (fieldResult.isErr()) return err(fieldResult.error); + + const setResult = fieldResult.value.setDbFieldName(dbFieldResult.value); + if (setResult.isErr()) return err(setResult.error); + + return fieldResult; +}; + const createCreatedTimeFieldWithGeneratedMeta = ( id: string, name: string, @@ -233,6 +268,84 @@ const createCreatedTimeFieldWithGeneratedMeta = ( return fieldResult; }; +const createSelectOptions = ( + choices: ReadonlyArray<{ id: string; name: string; color: string }> +): Result, DomainError> => { + const options: SelectOption[] = []; + + for (const choice of choices) { + const optionResult = SelectOption.create(choice); + if (optionResult.isErr()) { + return err(optionResult.error); + } + options.push(optionResult.value); + } + + return ok(options); +}; + +const createRealSingleSelectField = (params: { + id: string; + name: string; + dbFieldName: string; + choices: ReadonlyArray<{ id: string; name: string; color: string }>; +}): Result => { + const fieldIdResult = FieldId.create(createValidFieldId(params.id)); + if (fieldIdResult.isErr()) return err(fieldIdResult.error); + + const fieldNameResult = FieldName.create(params.name); + if (fieldNameResult.isErr()) return err(fieldNameResult.error); + + const dbFieldResult = DbFieldName.rehydrate(params.dbFieldName); + if (dbFieldResult.isErr()) return err(dbFieldResult.error); + + const optionsResult = createSelectOptions(params.choices); + if (optionsResult.isErr()) return err(optionsResult.error); + + const fieldResult = createSingleSelectField({ + id: fieldIdResult.value, + name: fieldNameResult.value, + options: optionsResult.value, + }); + if (fieldResult.isErr()) return err(fieldResult.error); + + const setResult = fieldResult.value.setDbFieldName(dbFieldResult.value); + if (setResult.isErr()) return err(setResult.error); + + return fieldResult; +}; + +const createRealMultipleSelectField = (params: { + id: string; + name: string; + dbFieldName: string; + choices: ReadonlyArray<{ id: string; name: string; color: string }>; +}): Result => { + const fieldIdResult = FieldId.create(createValidFieldId(params.id)); + if (fieldIdResult.isErr()) return err(fieldIdResult.error); + + const fieldNameResult = FieldName.create(params.name); + if (fieldNameResult.isErr()) return err(fieldNameResult.error); + + const dbFieldResult = DbFieldName.rehydrate(params.dbFieldName); + if (dbFieldResult.isErr()) return err(dbFieldResult.error); + + const optionsResult = createSelectOptions(params.choices); + if (optionsResult.isErr()) return err(optionsResult.error); + + const fieldResult = createMultipleSelectField({ + id: fieldIdResult.value, + name: fieldNameResult.value, + options: optionsResult.value, + }); + if (fieldResult.isErr()) return err(fieldResult.error); + + const setResult = fieldResult.value.setDbFieldName(dbFieldResult.value); + if (setResult.isErr()) return err(setResult.error); + + return fieldResult; +}; + const createLookupField = ( id: string, name: string, @@ -446,10 +559,10 @@ describe('Schema Rules Unit Tests with PGlite', () => { name TEXT, description TEXT, type TEXT, - options JSONB, + options TEXT, table_id TEXT, tableId TEXT, - meta JSONB + meta TEXT )`.execute(db); await sql`CREATE TABLE IF NOT EXISTS table_meta ( @@ -551,6 +664,73 @@ describe('Schema Rules Unit Tests with PGlite', () => { return tableResult.value; }; + const createTableAggregateWithId = ( + tableId: string, + tableName: string, + fields: ReadonlyArray, + primaryFieldId = fields[0]?.id() + ): Table => { + const tableIdResult = TableId.create(tableId); + if (tableIdResult.isErr()) { + throw new Error(tableIdResult.error.message); + } + + const baseIdSeed = sanitizeIdSeed(tableName).padEnd(16, '0').slice(0, 16); + const baseIdResult = BaseId.create(`bse${baseIdSeed}`); + if (baseIdResult.isErr()) { + throw new Error(baseIdResult.error.message); + } + + const tableNameResult = TableName.create(tableName); + if (tableNameResult.isErr()) { + throw new Error(tableNameResult.error.message); + } + + const dbTableNameResult = DbTableName.rehydrate(`${TEST_SCHEMA}.${tableName}`); + if (dbTableNameResult.isErr()) { + throw new Error(dbTableNameResult.error.message); + } + + if (!primaryFieldId) { + throw new Error('primaryFieldId is required'); + } + + const tableResult = Table.rehydrate({ + id: tableIdResult.value, + baseId: baseIdResult.value, + name: tableNameResult.value, + fields, + views: [], + primaryFieldId, + dbTableName: dbTableNameResult.value, + }); + + if (tableResult.isErr()) { + throw new Error(tableResult.error.message); + } + + return tableResult.value; + }; + + const createComputedBackfillService = (foreignTables: ReadonlyArray
) => + new ComputedFieldBackfillService( + { + find: async () => ok(foreignTables), + findOne: async () => ok(foreignTables[0]), + } as never, + { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as never, + { hash: () => 'hash' } as never, + db, + { enqueueFieldBackfill: async () => ok({ taskId: 'task' }) } as never, + { mode: 'sync', hybridThreshold: 5000 }, + new Pg16TypeValidationStrategy() + ); + const collectFinalResults = async ( generator: AsyncGenerator ): Promise => { @@ -2174,6 +2354,401 @@ describe('Schema Rules Unit Tests with PGlite', () => { // After down, meta is set to {} which doesn't have hasOrderColumn expect((await rule.isValid(ctx))._unsafeUnwrap().valid).toBe(false); }); + + it('should merge metadata updates instead of overwriting unrelated keys', async () => { + await createTestTable(TABLE_NAME); + + const fieldResult = createRealField('fmr004', 'Link', 'link_col'); + const field = fieldResult._unsafeUnwrap(); + + await sql`INSERT INTO field (id, name, meta) VALUES (${field.id().toString()}, 'Link', '{"foo":"bar","nested":{"keep":true}}') ON CONFLICT (id) DO NOTHING`.execute( + db + ); + + const rule = FieldMetaRule.forOrderColumn(field); + const ctx = createContext(TABLE_NAME, field); + + for (const stmt of rule.up(ctx)._unsafeUnwrap()) { + await db.executeQuery(stmt.compile(db)); + } + + const row = await db + .selectFrom('field') + .select('meta') + .where('id', '=', field.id().toString()) + .executeTakeFirstOrThrow(); + const meta = + typeof row.meta === 'string' ? JSON.parse(row.meta) : (row.meta as Record); + + expect(meta).toMatchObject({ + foo: 'bar', + nested: { keep: true }, + hasOrderColumn: true, + }); + + for (const stmt of rule.down(ctx)._unsafeUnwrap()) { + await db.executeQuery(stmt.compile(db)); + } + + const revertedRow = await db + .selectFrom('field') + .select('meta') + .where('id', '=', field.id().toString()) + .executeTakeFirstOrThrow(); + const revertedMeta = + typeof revertedRow.meta === 'string' + ? JSON.parse(revertedRow.meta) + : (revertedRow.meta as Record); + + expect(revertedMeta).toMatchObject({ + foo: 'bar', + nested: { keep: true }, + }); + expect(revertedMeta).not.toHaveProperty('hasOrderColumn'); + }); + + it('should repair text-backed field meta columns', async () => { + await createTestTable(TABLE_NAME); + + const fieldResult = createRealField('fmr005', 'Link', 'link_col'); + const field = fieldResult._unsafeUnwrap(); + + try { + await sql`INSERT INTO field (id, name, meta) VALUES (${field.id().toString()}, 'Link', ${JSON.stringify({ foo: 'bar' })})`.execute( + db + ); + + const rule = FieldMetaRule.forOrderColumn(field); + const ctx = createContext(TABLE_NAME, field); + + for (const stmt of rule.up(ctx)._unsafeUnwrap()) { + await db.executeQuery(stmt.compile(db)); + } + + const row = await db + .selectFrom('field') + .select('meta') + .where('id', '=', field.id().toString()) + .executeTakeFirstOrThrow(); + + expect(typeof row.meta).toBe('string'); + expect(JSON.parse(row.meta as string)).toMatchObject({ + foo: 'bar', + hasOrderColumn: true, + }); + } finally { + await sql`DELETE FROM field WHERE id = ${field.id().toString()}`.execute(db); + } + }); + }); + + describe('SelectOptionsMetaRule', () => { + const TABLE_NAME = 'test_select_options_rule'; + const expectedChoices = [ + { id: 'choKeep', name: 'Keep', color: 'blueBright' }, + { id: 'choDone', name: 'Done', color: 'greenBright' }, + ] as const; + + it('should validate and repair select option choices without rewriting stored record values', async () => { + await createTestTable(TABLE_NAME, ['status_col TEXT']); + + const fieldResult = createRealSingleSelectField({ + id: 'som001', + name: 'Status', + dbFieldName: 'status_col', + choices: expectedChoices, + }); + expect(fieldResult.isOk()).toBe(true); + const field = fieldResult._unsafeUnwrap(); + + await sql + .raw( + `INSERT INTO ${TEST_SCHEMA}.${TABLE_NAME} (__id, status_col) VALUES ('rec_status_1', 'choKeep')` + ) + .execute(db); + await sql`INSERT INTO field (id, name, type, options, table_id) + VALUES ( + ${field.id().toString()}, + 'Status', + 'singleSelect', + ${JSON.stringify({ + choices: [ + { id: 'choDup', name: 'Legacy', color: 'redBright' }, + { id: 'choDup', name: 'Legacy Duplicate', color: 'yellowBright' }, + ], + defaultValue: 'choKeep', + preventAutoNewOptions: true, + })}, + ${TABLE_NAME} + )`.execute(db); + + const rule = new SelectOptionsMetaRule(field); + const ctx = createContext(TABLE_NAME, field); + + const invalidResult = await rule.isValid(ctx); + expect(invalidResult.isOk()).toBe(true); + expect(invalidResult._unsafeUnwrap().valid).toBe(false); + expect(invalidResult._unsafeUnwrap().missing).toContain( + 'options.choices does not match the field definition' + ); + + for (const stmt of rule.up(ctx)._unsafeUnwrap()) { + await db.executeQuery(stmt.compile(db)); + } + + expect((await rule.isValid(ctx))._unsafeUnwrap().valid).toBe(true); + + const fieldRow = await db + .selectFrom('field') + .select('options') + .where('id', '=', field.id().toString()) + .executeTakeFirstOrThrow(); + const options = + typeof fieldRow.options === 'string' + ? JSON.parse(fieldRow.options) + : (fieldRow.options as Record); + + expect(options).toMatchObject({ + choices: expectedChoices, + defaultValue: 'choKeep', + preventAutoNewOptions: true, + }); + + const recordRow = await sql<{ status_col: string }>` + SELECT status_col + FROM ${sql.id(TEST_SCHEMA)}.${sql.id(TABLE_NAME)} + WHERE __id = 'rec_status_1' + `.execute(db); + expect(recordRow.rows[0]?.status_col).toBe('choKeep'); + }); + + it('should remap single-select values that point to removed duplicate choices', async () => { + await createTestTable(TABLE_NAME, ['status_col TEXT']); + + const fieldResult = createRealSingleSelectField({ + id: 'som006', + name: 'Status Duplicate Choices', + dbFieldName: 'status_col', + choices: [ + { id: 'choDup', name: 'Legacy', color: 'blueBright' }, + { id: 'choDup', name: 'Legacy Duplicate', color: 'yellowBright' }, + { id: 'choKeep', name: 'Keep', color: 'greenBright' }, + ], + }); + expect(fieldResult.isOk()).toBe(true); + const field = fieldResult._unsafeUnwrap(); + + await sql + .raw( + `INSERT INTO ${TEST_SCHEMA}.${TABLE_NAME} (__id, status_col) VALUES + ('rec_duplicate_name', 'Legacy Duplicate'), + ('rec_canonical_name', 'Legacy'), + ('rec_keep', 'Keep')` + ) + .execute(db); + await sql`INSERT INTO field (id, name, type, options, table_id) + VALUES ( + ${field.id().toString()}, + 'Status Duplicate Choices', + 'singleSelect', + ${JSON.stringify({ + choices: [ + { id: 'choDup', name: 'Legacy', color: 'blueBright' }, + { id: 'choDup', name: 'Legacy Duplicate', color: 'yellowBright' }, + { id: 'choKeep', name: 'Keep', color: 'greenBright' }, + ], + })}, + ${TABLE_NAME} + )`.execute(db); + + const rule = new SelectOptionsMetaRule(field); + const ctx = createContext(TABLE_NAME, field); + + for (const stmt of rule.up(ctx)._unsafeUnwrap()) { + await db.executeQuery(stmt.compile(db)); + } + + const rows = await sql<{ __id: string; status_col: string }>` + SELECT __id, status_col + FROM ${sql.id(TEST_SCHEMA)}.${sql.id(TABLE_NAME)} + ORDER BY __id + `.execute(db); + expect(rows.rows).toEqual([ + { __id: 'rec_canonical_name', status_col: 'Legacy' }, + { __id: 'rec_duplicate_name', status_col: 'Legacy' }, + { __id: 'rec_keep', status_col: 'Keep' }, + ]); + + const fieldRow = await db + .selectFrom('field') + .select('options') + .where('id', '=', field.id().toString()) + .executeTakeFirstOrThrow(); + const options = JSON.parse(fieldRow.options as string) as { + choices: ReadonlyArray<{ id: string; name: string; color: string }>; + }; + expect(options.choices).toEqual([ + { id: 'choDup', name: 'Legacy', color: 'blueBright' }, + { id: 'choKeep', name: 'Keep', color: 'greenBright' }, + ]); + }); + + it('should remap and dedupe multiple-select values that point to removed duplicate choices', async () => { + await createTestTable(TABLE_NAME, ['tags_col JSONB']); + + const fieldResult = createRealMultipleSelectField({ + id: 'som007', + name: 'Tags Duplicate Choices', + dbFieldName: 'tags_col', + choices: [ + { id: 'choDup', name: 'Legacy', color: 'blueBright' }, + { id: 'choDup', name: 'Legacy Duplicate', color: 'yellowBright' }, + { id: 'choKeep', name: 'Keep', color: 'greenBright' }, + ], + }); + expect(fieldResult.isOk()).toBe(true); + const field = fieldResult._unsafeUnwrap(); + + await sql + .raw( + `INSERT INTO ${TEST_SCHEMA}.${TABLE_NAME} (__id, tags_col) VALUES + ('rec_tags', '["Legacy Duplicate","Legacy","Keep"]'::jsonb)` + ) + .execute(db); + await sql`INSERT INTO field (id, name, type, options, table_id) + VALUES ( + ${field.id().toString()}, + 'Tags Duplicate Choices', + 'multipleSelect', + ${JSON.stringify({ + choices: [ + { id: 'choDup', name: 'Legacy', color: 'blueBright' }, + { id: 'choDup', name: 'Legacy Duplicate', color: 'yellowBright' }, + { id: 'choKeep', name: 'Keep', color: 'greenBright' }, + ], + })}, + ${TABLE_NAME} + )`.execute(db); + + const rule = new SelectOptionsMetaRule(field); + const ctx = createContext(TABLE_NAME, field); + + for (const stmt of rule.up(ctx)._unsafeUnwrap()) { + await db.executeQuery(stmt.compile(db)); + } + + const recordRow = await sql<{ tags_col: unknown }>` + SELECT tags_col + FROM ${sql.id(TEST_SCHEMA)}.${sql.id(TABLE_NAME)} + WHERE __id = 'rec_tags' + `.execute(db); + expect(recordRow.rows[0]?.tags_col).toEqual(['Legacy', 'Keep']); + }); + + it('should repair text-backed field options columns', async () => { + await createTestTable(TABLE_NAME, ['status_col TEXT']); + + const fieldResult = createRealSingleSelectField({ + id: 'som005', + name: 'Status Text Options', + dbFieldName: 'status_col', + choices: expectedChoices, + }); + expect(fieldResult.isOk()).toBe(true); + const field = fieldResult._unsafeUnwrap(); + + try { + await sql`INSERT INTO field (id, name, type, options, table_id) + VALUES ( + ${field.id().toString()}, + 'Status Text Options', + 'singleSelect', + ${JSON.stringify({ + choices: [{ id: 'choLegacy', name: 'Legacy', color: 'redBright' }], + defaultValue: 'choKeep', + })}, + ${TABLE_NAME} + )`.execute(db); + + const rule = new SelectOptionsMetaRule(field); + const ctx = createContext(TABLE_NAME, field); + + for (const stmt of rule.up(ctx)._unsafeUnwrap()) { + await db.executeQuery(stmt.compile(db)); + } + + const row = await db + .selectFrom('field') + .select('options') + .where('id', '=', field.id().toString()) + .executeTakeFirstOrThrow(); + + expect(typeof row.options).toBe('string'); + expect(JSON.parse(row.options as string)).toMatchObject({ + choices: expectedChoices, + defaultValue: 'choKeep', + }); + } finally { + await sql`DELETE FROM field WHERE id = ${field.id().toString()}`.execute(db); + } + }); + + it('should surface display impact in the repair hint', () => { + const field = createRealSingleSelectField({ + id: 'som002', + name: 'Status', + dbFieldName: 'status_col', + choices: expectedChoices, + })._unsafeUnwrap(); + const rule = new SelectOptionsMetaRule(field); + + const repairHint = rule.getRepairHint({} as SchemaRuleContext, { valid: false }); + + expect(repairHint.isOk()).toBe(true); + expect(repairHint._unsafeUnwrap()).toMatchObject({ + available: true, + mode: 'auto', + description: { + fallback: expect.stringContaining( + 'migrates cells that point at removed duplicate choice' + ), + }, + }); + expect(repairHint._unsafeUnwrap()?.description?.fallback).toContain('display'); + }); + + it('should register the select options rule for both single and multiple select fields', () => { + const singleField = createRealSingleSelectField({ + id: 'som003', + name: 'Single Status', + dbFieldName: 'single_status_col', + choices: expectedChoices, + })._unsafeUnwrap(); + const multipleField = createRealMultipleSelectField({ + id: 'som004', + name: 'Multiple Status', + dbFieldName: 'multiple_status_col', + choices: expectedChoices, + })._unsafeUnwrap(); + + const singleRules = createFieldSchemaRules(singleField, { + schema: TEST_SCHEMA, + tableName: TABLE_NAME, + tableId: TABLE_NAME, + })._unsafeUnwrap(); + const multipleRules = createFieldSchemaRules(multipleField, { + schema: TEST_SCHEMA, + tableName: TABLE_NAME, + tableId: TABLE_NAME, + })._unsafeUnwrap(); + + expect( + singleRules.some((rule) => rule.id === `select_options:${singleField.id().toString()}`) + ).toBe(true); + expect( + multipleRules.some((rule) => rule.id === `select_options:${multipleField.id().toString()}`) + ).toBe(true); + }); }); describe('ReferenceRule', () => { @@ -2381,7 +2956,7 @@ describe('Schema Rules Unit Tests with PGlite', () => { ${symmetricFieldId!}, 'Back Link', 'link', - ${JSON.stringify({ symmetricFieldId: field.id().toString() })}::jsonb, + ${JSON.stringify({ symmetricFieldId: field.id().toString() })}, ${FOREIGN_TABLE_NAME} ) `.execute(db); @@ -2391,7 +2966,7 @@ describe('Schema Rules Unit Tests with PGlite', () => { ${createValidFieldId('symdup01')}, 'Legacy One Way', 'link', - ${JSON.stringify({ symmetricFieldId, isOneWay: true })}::jsonb, + ${JSON.stringify({ symmetricFieldId, isOneWay: true })}, ${TABLE_NAME} ) `.execute(db); @@ -2431,7 +3006,7 @@ describe('Schema Rules Unit Tests with PGlite', () => { ${symmetricFieldId!}, 'Back Link', 'link', - ${JSON.stringify({ symmetricFieldId: field.id().toString() })}::jsonb, + ${JSON.stringify({ symmetricFieldId: field.id().toString() })}, ${FOREIGN_TABLE_NAME} ) `.execute(db); @@ -2441,7 +3016,7 @@ describe('Schema Rules Unit Tests with PGlite', () => { ${createValidFieldId('symdup02')}, 'Competing Link', 'link', - ${JSON.stringify({ symmetricFieldId })}::jsonb, + ${JSON.stringify({ symmetricFieldId })}, ${TABLE_NAME} ) `.execute(db); @@ -2462,6 +3037,95 @@ describe('Schema Rules Unit Tests with PGlite', () => { }); }); + describe('repair hints for high-risk rules', () => { + it('explains fk-column repair uses the stored link value column as recovery source', () => { + const field = createRealField('fkhint1', 'Link', 'link_col')._unsafeUnwrap(); + const rule = FkColumnRule.forField(field, 'fk_link_col', 'target_table'); + + const repairHint = rule.getRepairHint({} as SchemaRuleContext, { valid: false }); + + expect(repairHint.isOk()).toBe(true); + expect(repairHint._unsafeUnwrap()).toMatchObject({ + available: true, + mode: 'auto', + description: { + fallback: expect.stringContaining('underlying table as the recovery source'), + }, + }); + }); + + it('explains junction-table repair only reconstructs relations that still exist in stored link values', () => { + const field = createRealLinkField({ + id: 'jhint01', + name: 'Projects', + dbFieldName: 'projects_link', + relationship: 'manyMany', + foreignTableId: createValidTableId('projects'), + fkHostTableName: 'projects_junction', + selfKeyName: 'task_id', + foreignKeyName: 'project_id', + })._unsafeUnwrap() as LinkField; + + const rule = new JunctionTableExistsRule(field, { + junctionTable: { schema: TEST_SCHEMA, tableName: 'projects_junction' }, + selfKeyName: 'task_id', + foreignKeyName: 'project_id', + sourceTable: { schema: TEST_SCHEMA, tableName: 'tasks' }, + foreignTable: { schema: TEST_SCHEMA, tableName: 'projects' }, + withIndexes: true, + }); + + const repairHint = rule.getRepairHint({} as SchemaRuleContext, { valid: false }); + + expect(repairHint.isOk()).toBe(true); + expect(repairHint._unsafeUnwrap()).toMatchObject({ + available: true, + mode: 'auto', + description: { + fallback: expect.stringContaining('Missing historical links cannot be recovered'), + }, + }); + }); + + it('explains link-value-column repair may still leave display values empty or stale', () => { + const field = createRealField('lvhint1', 'Link Display', 'link_display_col')._unsafeUnwrap(); + const rule = LinkValueColumnRule.forField(field, 'twoWay'); + + const repairHint = rule.getRepairHint({} as SchemaRuleContext, { valid: false }); + + expect(repairHint.isOk()).toBe(true); + expect(repairHint._unsafeUnwrap()).toMatchObject({ + available: true, + mode: 'auto', + description: { + fallback: expect.stringContaining('display empty or stale values'), + }, + }); + }); + + it('explains generated-column-meta repair discards old generated display values when recreating a stored column', () => { + const field = createCreatedTimeFieldWithGeneratedMeta( + 'gchint1', + 'Created At', + 'created_at_copy', + true + )._unsafeUnwrap(); + const generatedRule = GeneratedColumnRule.forCreatedTime(field); + const rule = new GeneratedColumnMetaRule(field, generatedRule, generatedRule); + + const repairHint = rule.getRepairHint({} as SchemaRuleContext, { valid: false }); + + expect(repairHint.isOk()).toBe(true); + expect(repairHint._unsafeUnwrap()).toMatchObject({ + available: true, + mode: 'auto', + description: { + fallback: expect.stringContaining('old generated display values'), + }, + }); + }); + }); + describe('SchemaRepairer', () => { const expectRuleRepairLifecycle = async (params: { table: Table; @@ -2533,7 +3197,7 @@ describe('Schema Rules Unit Tests with PGlite', () => { ${symmetricFieldId!}, 'Back Link', 'link', - ${JSON.stringify({ symmetricFieldId: field.id().toString() })}::jsonb, + ${JSON.stringify({ symmetricFieldId: field.id().toString() })}, ${foreignTableName} ) `.execute(db); @@ -2543,7 +3207,7 @@ describe('Schema Rules Unit Tests with PGlite', () => { ${createValidFieldId('symmreq3')}, 'Competing Link', 'link', - ${JSON.stringify({ symmetricFieldId })}::jsonb, + ${JSON.stringify({ symmetricFieldId })}, ${table.id().toString()} ) `.execute(db); @@ -2584,7 +3248,7 @@ describe('Schema Rules Unit Tests with PGlite', () => { ${symmetricFieldId!}, 'Back Link', 'link', - ${JSON.stringify({ symmetricFieldId: field.id().toString() })}::jsonb, + ${JSON.stringify({ symmetricFieldId: field.id().toString() })}, ${foreignTableName} ) `.execute(db); @@ -2594,7 +3258,7 @@ describe('Schema Rules Unit Tests with PGlite', () => { ${duplicateFieldId}, 'Competing Link', 'link', - ${JSON.stringify({ symmetricFieldId })}::jsonb, + ${JSON.stringify({ symmetricFieldId })}, ${table.id().toString()} ) `.execute(db); @@ -2821,9 +3485,12 @@ describe('Schema Rules Unit Tests with PGlite', () => { ); expect(columnRule?.status).toBe('error'); - expect(columnRule?.repair).toEqual({ + expect(columnRule?.repair).toMatchObject({ available: true, mode: 'auto', + reason: { + fallback: 'Automatic repair will recreate the missing physical column for "Name".', + }, }); }); @@ -3304,6 +3971,23 @@ describe('Schema Rules Unit Tests with PGlite', () => { { record_id: 'rec_source_b', fk_value: 'rec_target_b' }, ]); + const sourceRows = await sql<{ record_id: string; link_value: unknown }>` + SELECT __id AS record_id, link_value + FROM ${sql.id(TEST_SCHEMA)}.${sql.id(sourceTableName)} + ORDER BY __id + `.execute(db); + + expect(sourceRows.rows).toEqual([ + { + record_id: 'rec_source_a', + link_value: { id: 'rec_target_a', title: 'Target A' }, + }, + { + record_id: 'rec_source_b', + link_value: { id: 'rec_target_b', title: 'Target B' }, + }, + ]); + const checker = createSchemaChecker({ db, introspector, schema: TEST_SCHEMA }); const checkResults = await collectFinalResults( checker.checkField(table, field.id().toString()) @@ -3400,6 +4084,26 @@ describe('Schema Rules Unit Tests with PGlite', () => { { record_id: 'rec_target_c', fk_value: 'rec_source_b' }, ]); + const sourceRows = await sql<{ record_id: string; link_value: unknown }>` + SELECT __id AS record_id, link_value + FROM ${sql.id(TEST_SCHEMA)}.${sql.id(sourceTableName)} + ORDER BY __id + `.execute(db); + + expect(sourceRows.rows).toEqual([ + { + record_id: 'rec_source_a', + link_value: [ + { id: 'rec_target_a', title: 'Target A' }, + { id: 'rec_target_b', title: 'Target B' }, + ], + }, + { + record_id: 'rec_source_b', + link_value: [{ id: 'rec_target_c', title: 'Target C' }], + }, + ]); + const checker = createSchemaChecker({ db, introspector, schema: TEST_SCHEMA }); const checkResults = await collectFinalResults( checker.checkField(table, field.id().toString()) @@ -3493,6 +4197,17 @@ describe('Schema Rules Unit Tests with PGlite', () => { { self_id: 'rec_source_a', foreign_id: 'rec_target_a' }, { self_id: 'rec_source_a', foreign_id: 'rec_target_b' }, ]); + + const sourceRows = await sql<{ link_value: unknown }>` + SELECT link_value + FROM ${sql.id(TEST_SCHEMA)}.${sql.id(sourceTableName)} + WHERE __id = 'rec_source_a' + `.execute(db); + + expect(sourceRows.rows[0]?.link_value).toEqual([ + { id: 'rec_target_a', title: 'Target A' }, + { id: 'rec_target_b', title: 'Target B' }, + ]); }); it('should repair a dropped junction table and backfill manyMany link rows with order', async () => { @@ -3575,6 +4290,254 @@ describe('Schema Rules Unit Tests with PGlite', () => { { self_id: 'rec_source_a', foreign_id: 'rec_target_b', order_value: 1 }, { self_id: 'rec_source_a', foreign_id: 'rec_target_a', order_value: 2 }, ]); + + const sourceRows = await sql<{ link_value: unknown }>` + SELECT link_value + FROM ${sql.id(TEST_SCHEMA)}.${sql.id(sourceTableName)} + WHERE __id = 'rec_source_a' + `.execute(db); + + expect(sourceRows.rows[0]?.link_value).toEqual([ + { id: 'rec_target_b', title: 'Target B' }, + { id: 'rec_target_a', title: 'Target A' }, + ]); + }); + + it('should preserve FK-backed link values after repair and computed backfill', async () => { + const sourceTableName = createValidTableId('src_fk_recompute'); + const targetTableName = createValidTableId('tgt_fk_recompute'); + const fkColumnName = '__fk_recompute_link'; + const fieldSeed = 'rpfkrecompute'; + const lookupFieldId = createValidFieldId(`lookup_${fieldSeed}`); + + await createTestTable(targetTableName, [ + '__version INTEGER DEFAULT 1', + '__auto_number INTEGER', + 'title_col TEXT', + ]); + await createTestTable(sourceTableName, [ + '__version INTEGER DEFAULT 1', + 'link_value JSONB', + `"${fkColumnName}" TEXT`, + ]); + + await sql + .raw( + ` + INSERT INTO ${TEST_SCHEMA}.${targetTableName} (__id, __auto_number, title_col) + VALUES ('rec_target_a', 1, 'Target A'), ('rec_target_b', 2, 'Target B') + ` + ) + .execute(db); + await sql + .raw( + ` + INSERT INTO ${TEST_SCHEMA}.${sourceTableName} (__id, link_value, "${fkColumnName}") + VALUES + ('rec_source_a', '{"id":"rec_target_a","title":"Target A"}'::jsonb, 'rec_target_a'), + ('rec_source_b', '{"id":"rec_target_b","title":"Target B"}'::jsonb, 'rec_target_b') + ` + ) + .execute(db); + + const field = createRealLinkField({ + id: fieldSeed, + name: 'FK Recompute Link', + dbFieldName: 'link_value', + relationship: 'manyOne', + foreignTableId: targetTableName, + fkHostTableName: `${TEST_SCHEMA}.${sourceTableName}`, + selfKeyName: '__id', + foreignKeyName: fkColumnName, + hasOrderColumn: false, + })._unsafeUnwrap(); + const titleField = createRealFieldWithFieldId( + lookupFieldId, + 'Title', + 'title_col' + )._unsafeUnwrap(); + const sourceTable = createTableAggregateWithId(sourceTableName, sourceTableName, [field]); + const targetTable = createTableAggregateWithId( + targetTableName, + targetTableName, + [titleField], + titleField.id() + ); + + await sql + .raw(`ALTER TABLE ${TEST_SCHEMA}.${sourceTableName} DROP COLUMN "${fkColumnName}" CASCADE`) + .execute(db); + + const repairer = createSchemaRepairer({ db, introspector, schema: TEST_SCHEMA }); + const repairResults = await collectFinalRepairResults( + repairer.repairField(sourceTable, field.id().toString()) + ); + + expect( + repairResults.find((result) => result.ruleId === `fk_column:${field.id().toString()}`) + ?.outcome + ).toBe('repaired'); + + const backfillService = createComputedBackfillService([targetTable]); + const backfillResult = await backfillService.backfillMany({} as never, { + table: sourceTable, + fields: [field], + }); + + expect(backfillResult.isOk()).toBe(true); + + const sourceRows = await sql<{ record_id: string; link_value: unknown }>` + SELECT __id AS record_id, link_value + FROM ${sql.id(TEST_SCHEMA)}.${sql.id(sourceTableName)} + ORDER BY __id + `.execute(db); + + expect(sourceRows.rows).toEqual([ + { + record_id: 'rec_source_a', + link_value: { id: 'rec_target_a', title: 'Target A' }, + }, + { + record_id: 'rec_source_b', + link_value: { id: 'rec_target_b', title: 'Target B' }, + }, + ]); + + const checker = createSchemaChecker({ db, introspector, schema: TEST_SCHEMA }); + const checkResults = await collectFinalResults( + checker.checkField(sourceTable, field.id().toString()) + ); + expect( + checkResults.find((result) => result.ruleId === `fk_column:${field.id().toString()}`) + ?.status + ).toBe('success'); + expect( + checkResults.find( + (result) => result.ruleId === `fk:${field.id().toString()}:${fkColumnName}` + )?.status + ).toBe('success'); + }); + + it('should preserve junction-backed link values after repair and computed backfill', async () => { + const sourceTableName = createValidTableId('src_junction_recompute'); + const targetTableName = createValidTableId('tgt_junction_recompute'); + const junctionTableName = 'junction_recompute_link'; + const selfKeyName = '__fk_recompute_self'; + const foreignKeyName = '__fk_recompute_foreign'; + const fieldSeed = 'rpjctrecompute'; + const lookupFieldId = createValidFieldId(`lookup_${fieldSeed}`); + + await createTestTable(sourceTableName, ['__version INTEGER DEFAULT 1', 'link_value JSONB']); + await createTestTable(targetTableName, [ + '__version INTEGER DEFAULT 1', + '__auto_number INTEGER', + 'title_col TEXT', + ]); + await createExplicitTestTable(junctionTableName, [ + '__id SERIAL PRIMARY KEY', + `"${selfKeyName}" TEXT`, + `"${foreignKeyName}" TEXT`, + '__order DOUBLE PRECISION', + ]); + + await sql + .raw( + ` + INSERT INTO ${TEST_SCHEMA}.${sourceTableName} (__id, link_value) + VALUES + ('rec_source_a', '[{"id":"rec_target_b","title":"Target B"},{"id":"rec_target_a","title":"Target A"}]'::jsonb) + ` + ) + .execute(db); + await sql + .raw( + ` + INSERT INTO ${TEST_SCHEMA}.${targetTableName} (__id, __auto_number, title_col) + VALUES ('rec_target_a', 1, 'Target A'), ('rec_target_b', 2, 'Target B') + ` + ) + .execute(db); + await sql + .raw( + ` + INSERT INTO ${TEST_SCHEMA}.${junctionTableName} ("${selfKeyName}", "${foreignKeyName}", "__order") + VALUES ('rec_source_a', 'rec_target_b', 1), ('rec_source_a', 'rec_target_a', 2) + ` + ) + .execute(db); + + const field = createRealLinkField({ + id: fieldSeed, + name: 'Junction Recompute Link', + dbFieldName: 'link_value', + relationship: 'manyMany', + foreignTableId: targetTableName, + fkHostTableName: `${TEST_SCHEMA}.${junctionTableName}`, + selfKeyName, + foreignKeyName, + hasOrderColumn: true, + })._unsafeUnwrap(); + const titleField = createRealFieldWithFieldId( + lookupFieldId, + 'Title', + 'title_col' + )._unsafeUnwrap(); + const sourceTable = createTableAggregateWithId(sourceTableName, sourceTableName, [field]); + const targetTable = createTableAggregateWithId( + targetTableName, + targetTableName, + [titleField], + titleField.id() + ); + + await sql.raw(`DROP TABLE ${TEST_SCHEMA}.${junctionTableName}`).execute(db); + + const repairer = createSchemaRepairer({ db, introspector, schema: TEST_SCHEMA }); + const repairResults = await collectFinalRepairResults( + repairer.repairField(sourceTable, field.id().toString()) + ); + + expect( + repairResults.find((result) => result.ruleId === `junction_table:${field.id().toString()}`) + ?.outcome + ).toBe('repaired'); + + const backfillService = createComputedBackfillService([targetTable]); + const backfillResult = await backfillService.backfillMany({} as never, { + table: sourceTable, + fields: [field], + }); + + expect(backfillResult.isOk()).toBe(true); + + const sourceRows = await sql<{ link_value: unknown }>` + SELECT link_value + FROM ${sql.id(TEST_SCHEMA)}.${sql.id(sourceTableName)} + WHERE __id = 'rec_source_a' + `.execute(db); + + expect(sourceRows.rows[0]?.link_value).toEqual([ + { id: 'rec_target_b', title: 'Target B' }, + { id: 'rec_target_a', title: 'Target A' }, + ]); + + const checker = createSchemaChecker({ db, introspector, schema: TEST_SCHEMA }); + const checkResults = await collectFinalResults( + checker.checkField(sourceTable, field.id().toString()) + ); + expect( + checkResults.find((result) => result.ruleId === `junction_table:${field.id().toString()}`) + ?.status + ).toBe('success'); + expect( + checkResults.find((result) => result.ruleId === `junction_fk:${field.id().toString()}:self`) + ?.status + ).toBe('success'); + expect( + checkResults.find( + (result) => result.ruleId === `junction_fk:${field.id().toString()}:foreign` + )?.status + ).toBe('success'); }); it('should repair a single reference rule using a checker-provided rule id', async () => { @@ -3979,7 +4942,7 @@ describe('Schema Rules Unit Tests with PGlite', () => { })._unsafeUnwrap(); const table = createTableAggregate(sourceTableName, field); - await sql`INSERT INTO field (id, name, meta) VALUES (${field.id().toString()}, 'Field Meta Rule', '{}') ON CONFLICT (id) DO UPDATE SET meta = '{}'::jsonb`.execute( + await sql`INSERT INTO field (id, name, meta) VALUES (${field.id().toString()}, 'Field Meta Rule', '{}') ON CONFLICT (id) DO UPDATE SET meta = '{}'`.execute( db ); @@ -3988,13 +4951,17 @@ describe('Schema Rules Unit Tests with PGlite', () => { fieldId: field.id().toString(), ruleId: `field_meta:${field.id().toString()}`, verifyAfterRepair: async () => { - const record = await sql<{ meta: Record }>` + const record = await sql<{ meta: string | Record }>` SELECT meta FROM field WHERE id = ${field.id().toString()} `.execute(db); + const meta = + typeof record.rows[0]?.meta === 'string' + ? JSON.parse(record.rows[0].meta) + : record.rows[0]?.meta; - expect(record.rows[0]?.meta).toMatchObject({ hasOrderColumn: true }); + expect(meta).toMatchObject({ hasOrderColumn: true }); }, }); }); diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/SelectOptionsMetaRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/SelectOptionsMetaRule.ts new file mode 100644 index 0000000000..9a6417a755 --- /dev/null +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/SelectOptionsMetaRule.ts @@ -0,0 +1,312 @@ +import type { DomainError, MultipleSelectField, SingleSelectField } from '@teable/v2-core'; +import { sql } from 'kysely'; +import { ok, safeTry } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { resolveColumnName } from '../../visitors/PostgresTableSchemaFieldColumn'; +import type { SchemaRuleContext } from '../context/SchemaRuleContext'; +import type { + ISchemaRule, + SchemaRuleRepairHint, + SchemaRuleValidationResult, + TableSchemaStatementBuilder, +} from '../core/ISchemaRule'; +import { + compressSql, + quoteIdentifier, + quoteLiteral, + quoteTableIdentifier, +} from '../helpers/StatementBuilders'; + +type SelectField = SingleSelectField | MultipleSelectField; +type SelectChoiceDto = { + id: string; + name: string; + color: string; +}; + +const normalizeSelectChoice = (value: unknown): SelectChoiceDto | undefined => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + const candidate = value as Partial; + if ( + typeof candidate.id !== 'string' || + typeof candidate.name !== 'string' || + typeof candidate.color !== 'string' + ) { + return undefined; + } + + return { + id: candidate.id, + name: candidate.name, + color: candidate.color, + }; +}; + +export class SelectOptionsMetaRule implements ISchemaRule { + readonly id: string; + readonly description: string; + readonly dependencies: ReadonlyArray; + readonly required = true; + + constructor( + private readonly field: SelectField, + dependsOnRuleId?: string + ) { + this.id = `select_options:${field.id().toString()}`; + this.description = `Select options metadata for "${field.name().toString()}"`; + this.dependencies = dependsOnRuleId ? [dependsOnRuleId] : []; + } + + private expectedChoices(): ReadonlyArray { + const choices: SelectChoiceDto[] = []; + const seenIds = new Set(); + const seenNames = new Set(); + + for (const option of this.field.selectOptions()) { + const dto = option.toDto(); + if (seenIds.has(dto.id) || seenNames.has(dto.name)) { + continue; + } + seenIds.add(dto.id); + seenNames.add(dto.name); + choices.push(dto); + } + + return choices; + } + + private parseOptions(raw: unknown): Record | undefined { + if (raw == null) { + return {}; + } + + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw) as unknown; + return this.parseOptions(parsed); + } catch { + return undefined; + } + } + + if (typeof raw !== 'object' || Array.isArray(raw)) { + return undefined; + } + + return raw as Record; + } + + private normalizeChoices(value: unknown): ReadonlyArray | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const normalized: SelectChoiceDto[] = []; + for (const item of value) { + const choice = normalizeSelectChoice(item); + if (!choice) { + return undefined; + } + normalized.push(choice); + } + + return normalized; + } + + async isValid(ctx: SchemaRuleContext): Promise> { + const fieldId = this.field.id().toString(); + const result = await ctx.db + .selectFrom('field') + .select('options') + .where('id', '=', fieldId) + .executeTakeFirst(); + + if (!result) { + return ok({ + valid: false, + missing: [`field record with id "${fieldId}" not found in field table`], + }); + } + + const currentOptions = this.parseOptions(result.options); + if (!currentOptions) { + return ok({ + valid: false, + missing: [`field "${fieldId}" has invalid JSON in options column`], + }); + } + + const currentChoices = this.normalizeChoices(currentOptions.choices); + if (!currentChoices) { + return ok({ + valid: false, + missing: ['options.choices is missing or invalid'], + }); + } + + if (JSON.stringify(currentChoices) !== JSON.stringify(this.expectedChoices())) { + return ok({ + valid: false, + missing: ['options.choices does not match the field definition'], + }); + } + + return ok({ valid: true }); + } + + getRepairHint( + _ctx: SchemaRuleContext, + _validation: SchemaRuleValidationResult + ): Result { + return ok({ + available: true, + mode: 'auto', + reason: { + fallback: `Automatic repair will reconcile select option metadata for "${this.field.name().toString()}".`, + }, + description: { + fallback: + 'This repair rewrites field.options.choices to match the field definition, migrates cells that point at removed duplicate choice IDs or names to the retained choice, and preserves unrelated option keys. Labels, colors, and option order can change immediately in the UI. Cells that point at choices with no retained ID or name match may still display empty or unknown values until those stored values are corrected separately.', + }, + }); + } + + up(ctx: SchemaRuleContext): Result, DomainError> { + const rule = this; + return safeTry, DomainError>(function* () { + const fieldId = rule.field.id().toString(); + const columnName = yield* resolveColumnName(ctx.field); + const patch = JSON.stringify({ choices: rule.expectedChoices() }); + const updateOptions = ctx.db + .updateTable('field') + .set({ + options: sql`(coalesce(options::jsonb, '{}'::jsonb) || ${patch}::jsonb)::text`, + }) + .where('id', '=', fieldId); + + return ok([rule.repairStoredChoiceValues(ctx, columnName), updateOptions]); + }); + } + + down(ctx: SchemaRuleContext): Result, DomainError> { + const fieldId = this.field.id().toString(); + const updateOptions = ctx.db + .updateTable('field') + .set({ + options: sql`(coalesce(options::jsonb, '{}'::jsonb) - 'choices')::text`, + }) + .where('id', '=', fieldId); + + return ok([updateOptions]); + } + + private buildChoiceTokenMapSql(): string { + return compressSql(` + current_choices AS ( + SELECT + choice->>'id' AS old_id, + choice->>'name' AS old_name + FROM field f + CROSS JOIN LATERAL jsonb_array_elements( + COALESCE(f.options::jsonb->'choices', '[]'::jsonb) + ) AS choice + WHERE f.id = ${quoteLiteral(this.field.id().toString())} + ), + canonical_choices AS ( + SELECT + choice->>'id' AS id, + choice->>'name' AS name + FROM jsonb_array_elements(${quoteLiteral(JSON.stringify(this.expectedChoices()))}::jsonb) AS choice + ), + choice_token_map AS ( + SELECT DISTINCT ON (token) + token, + canonical_name + FROM ( + SELECT + CASE + WHEN c.old_id IS NOT NULL AND c.old_id <> canonical.id THEN c.old_id + END AS token, + canonical.name AS canonical_name + FROM current_choices c + CROSS JOIN LATERAL ( + SELECT e.id, e.name + FROM canonical_choices e + WHERE e.id = c.old_id OR e.name = c.old_name + ORDER BY CASE WHEN e.id = c.old_id THEN 0 ELSE 1 END + LIMIT 1 + ) canonical + UNION ALL + SELECT + CASE + WHEN c.old_name IS NOT NULL AND c.old_name <> canonical.name THEN c.old_name + END AS token, + canonical.name AS canonical_name + FROM current_choices c + CROSS JOIN LATERAL ( + SELECT e.id, e.name + FROM canonical_choices e + WHERE e.id = c.old_id OR e.name = c.old_name + ORDER BY CASE WHEN e.id = c.old_id THEN 0 ELSE 1 END + LIMIT 1 + ) canonical + ) mapped + WHERE token IS NOT NULL AND token <> '' + ORDER BY token + ) + `); + } + + private repairStoredChoiceValues( + ctx: SchemaRuleContext, + columnName: string + ): TableSchemaStatementBuilder { + const tableName = quoteTableIdentifier({ schema: ctx.schema, tableName: ctx.tableName }); + const column = quoteIdentifier(columnName); + + if (this.field.type().toString() === 'multipleSelect') { + return sql.raw( + compressSql(` + WITH ${this.buildChoiceTokenMapSql()} + UPDATE ${tableName} AS t + SET ${column} = COALESCE( + ( + SELECT jsonb_agg(d.mapped_value ORDER BY d.ord) + FROM ( + SELECT mapped_value, MIN(ord) AS ord + FROM ( + SELECT + COALESCE(m.canonical_name, elem.value #>> '{}') AS mapped_value, + elem.ord + FROM jsonb_array_elements(t.${column}) WITH ORDINALITY AS elem(value, ord) + LEFT JOIN choice_token_map m + ON jsonb_typeof(elem.value) = 'string' + AND elem.value #>> '{}' = m.token + ) expanded + GROUP BY mapped_value + ) d + ), + '[]'::jsonb + ) + WHERE t.${column} IS NOT NULL + AND jsonb_typeof(t.${column}) = 'array' + AND EXISTS (SELECT 1 FROM choice_token_map) + `) + ); + } + + return sql.raw( + compressSql(` + WITH ${this.buildChoiceTokenMapSql()} + UPDATE ${tableName} AS t + SET ${column} = m.canonical_name + FROM choice_token_map m + WHERE t.${column} = m.token + `) + ); + } +} diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/index.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/index.ts index 9a39b8e985..c275a2a349 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/index.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/index.ts @@ -23,4 +23,5 @@ export { LinkSymmetricFieldRule } from './LinkSymmetricFieldRule'; export { LinkValueColumnRule } from './LinkValueColumnRule'; export { OrderColumnRule } from './OrderColumnRule'; export { ReferenceRule, type ReferenceEntry } from './ReferenceRule'; +export { SelectOptionsMetaRule } from './SelectOptionsMetaRule'; export { UniqueIndexRule } from './UniqueIndexRule'; diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/repairer/SchemaRepairResult.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/repairer/SchemaRepairResult.ts index 3e625568cc..8b2645a6f6 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/repairer/SchemaRepairResult.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/repairer/SchemaRepairResult.ts @@ -10,6 +10,12 @@ export interface SchemaRepairDetails { extra?: ReadonlyArray; extraItems?: ReadonlyArray; statementCount?: number; + statements?: ReadonlyArray; +} + +export interface SchemaRepairSqlStatement { + sql: string; + parameters: ReadonlyArray; } export interface SchemaRepairResult { diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/repairer/SchemaRepairer.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/repairer/SchemaRepairer.ts index f725038ed4..42ad1aad1f 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/repairer/SchemaRepairer.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/repairer/SchemaRepairer.ts @@ -1,7 +1,7 @@ import type { Table } from '@teable/v2-core'; import { executeTableSchemaStatements } from '../../../shared/db'; -import type { SchemaRuleManualRepairValues } from '../core'; +import type { SchemaRuleManualRepairValues, TableSchemaStatementBuilder } from '../core'; import { getRuleRepairHint } from '../core/RuleRepairMetadata'; import { createSchemaRulePlanner, @@ -10,6 +10,7 @@ import { } from '../planner/SchemaRulePlanner'; import { type SchemaRepairResult, + type SchemaRepairSqlStatement, pendingResult, runningResult, successResult, @@ -26,6 +27,19 @@ export interface SchemaRepairOptions { readonly targetStatuses?: ReadonlyArray<'warn' | 'error'>; } +const compileRepairStatements = ( + db: SchemaRepairerParams['db'], + statements: ReadonlyArray +): ReadonlyArray => + statements.map((statement) => { + const compiled = statement.compile(db); + + return { + sql: compiled.sql, + parameters: compiled.parameters ?? [], + }; + }); + export class SchemaRepairer { constructor(private readonly params: SchemaRepairerParams) {} @@ -248,12 +262,17 @@ export class SchemaRepairer { } if (options?.dryRun) { + const compiledStatements = compileRepairStatements(ctx.db, statements); yield { ...successResult( pending, `Dry run: ${statements.length} statements ready`, 'repaired', - { ...details, statementCount: statements.length } + { + ...details, + statementCount: statements.length, + statements: compiledStatements, + } ), repair, }; diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableSchemaUpdateVisitor.ts b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableSchemaUpdateVisitor.ts index a030370946..32eb8b4978 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableSchemaUpdateVisitor.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableSchemaUpdateVisitor.ts @@ -133,17 +133,11 @@ export class TableSchemaUpdateVisitor return `${prefix}_${tableName.slice(0, tableDbNameLen)}_${abbDbFieldName}_${fieldId}`; } - /** Field types whose cellValueType is Boolean or DateTime (unsupported by GIN trigram). */ - private static readonly SEARCH_INDEX_UNSUPPORTED_TYPES = new Set([ - 'checkbox', - 'date', - 'createdTime', - 'lastModifiedTime', - 'button', - ]); + /** Field types that should never participate in search indexing. */ + private static readonly SEARCH_INDEX_UNSUPPORTED_TYPES = new Set(['checkbox', 'button']); /** - * Whether a field type supports GIN trigram search indexes. + * Whether a field type supports search indexes. */ private static fieldSupportsSearchIndex(fieldType: string): boolean { return !TableSchemaUpdateVisitor.SEARCH_INDEX_UNSUPPORTED_TYPES.has(fieldType); @@ -165,7 +159,7 @@ export class TableSchemaUpdateVisitor } /** - * Build a conditional CREATE INDEX statement for a field's GIN trigram search index. + * Build a conditional CREATE INDEX statement for a field's search index. * Only creates the index if any idx_trgm indexes already exist on the table * (i.e., the table has search indexing enabled). */ @@ -179,11 +173,19 @@ export class TableSchemaUpdateVisitor } const valueTypeResult = field.accept(new FieldValueTypeVisitor()); + let useBtree = false; if (valueTypeResult.isOk()) { const cellValueType = valueTypeResult.value.cellValueType.toString(); - if (cellValueType === 'boolean' || cellValueType === 'dateTime') { + const isMultiple = valueTypeResult.value.isMultipleCellValue.isMultiple(); + if (cellValueType === 'boolean') { return null; } + if (cellValueType === 'dateTime') { + if (isMultiple) { + return null; + } + useBtree = true; + } } const { db, schema, tableName } = this.params; @@ -196,8 +198,9 @@ export class TableSchemaUpdateVisitor const isMultipleResult = field.isMultipleCellValue(); const isMultiple = isMultipleResult.isOk() && isMultipleResult.value.toBoolean(); let expression = `"${dbFieldName}"::text`; - - if (!isMultiple && fieldType === 'longText') { + if (useBtree) { + expression = `"${dbFieldName}"`; + } else if (!isMultiple && fieldType === 'longText') { expression = `REPLACE(REPLACE(REPLACE("${dbFieldName}"::text, CHR(13), ' '::text), CHR(10), ' '::text), CHR(9), ' '::text)`; } @@ -211,7 +214,7 @@ export class TableSchemaUpdateVisitor AND tablename = '${tableName}' AND indexname LIKE 'idx_trgm%' ) THEN - EXECUTE 'CREATE INDEX IF NOT EXISTS "${indexName}" ON "${pgSchema}"."${tableName}" USING gin ((${expression.replace(/'/g, "''")}) gin_trgm_ops)'; + EXECUTE 'CREATE INDEX IF NOT EXISTS "${indexName}" ON "${pgSchema}"."${tableName}" ${useBtree ? `USING btree (${expression.replace(/'/g, "''")})` : `USING gin ((${expression.replace(/'/g, "''")}) gin_trgm_ops)`}'; END IF; END $$; diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/__tests__/TableSchemaUpdateVisitor.spec.ts b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/__tests__/TableSchemaUpdateVisitor.spec.ts index bda1dfbaa1..f089e1c292 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/__tests__/TableSchemaUpdateVisitor.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/__tests__/TableSchemaUpdateVisitor.spec.ts @@ -24,6 +24,7 @@ import { describe, expect, it } from 'vitest'; import { TableSchemaUpdateVisitor } from '../TableSchemaUpdateVisitor'; import { createTestDb } from './helpers/createTestDb'; +import { createDtField } from './helpers/fieldFactories'; describe('TableSchemaUpdateVisitor', () => { describe('visitTableUpdateFieldConstraints', () => { @@ -982,7 +983,15 @@ describe('TableSchemaUpdateVisitor', () => { table: createTable(), }); - const createField = (params: { id: string; kind: 'singleLineText' | 'checkbox' }) => { + const createField = (params: { id: string; kind: 'singleLineText' | 'checkbox' | 'date' }) => { + if (params.kind === 'date') { + return createDtField( + params.id, + `Field ${params.id.slice(-4)}`, + `fld_${params.id.slice(-8)}` + )._unsafeUnwrap(); + } + const table = Table.builder() .withBaseId(BaseId.create(SCHEMA)._unsafeUnwrap()) .withName(TableName.create('Field Source')._unsafeUnwrap()); @@ -1019,6 +1028,21 @@ describe('TableSchemaUpdateVisitor', () => { expect(sqls.some((text) => text.includes('CREATE INDEX IF NOT EXISTS'))).toBe(true); }); + it('should append a btree search index statement for date fields', () => { + const field = createField({ id: 'fldDateField000001', kind: 'date' }); + const spec = TableAddFieldSpec.create(field); + const visitor = createVisitor(); + + const result = visitor.visitTableAddField(spec); + expect(result.isOk()).toBe(true); + + const sqls = result._unsafeUnwrap().map((statement) => statement.compile(db).sql); + expect(sqls.some((text) => text.includes("indexname LIKE 'idx_trgm%'"))).toBe(true); + expect(sqls.some((text) => text.includes('CREATE INDEX IF NOT EXISTS'))).toBe(true); + expect(sqls.some((text) => text.includes('USING btree'))).toBe(true); + expect(sqls.some((text) => text.includes('gin_trgm_ops'))).toBe(false); + }); + it('should not append search index statement for unsupported field types', () => { const field = createField({ id: 'fldCheckboxField001', kind: 'checkbox' }); const spec = TableAddFieldSpec.create(field); diff --git a/packages/v2/core/src/commands/UpdateRecordHandler.spec.ts b/packages/v2/core/src/commands/UpdateRecordHandler.spec.ts index 11f44f6c89..5d2080aecf 100644 --- a/packages/v2/core/src/commands/UpdateRecordHandler.spec.ts +++ b/packages/v2/core/src/commands/UpdateRecordHandler.spec.ts @@ -229,6 +229,7 @@ class FakeTableRecordRepository implements ITableRecordRepository { lastRecordId: RecordId | undefined; lastMutateSpec: ICellValueSpec | undefined; omitUpdateSnapshot = false; + mutationApplied: boolean | undefined = true; async insert( _: IExecutionContext, @@ -265,8 +266,11 @@ class FakeTableRecordRepository implements ITableRecordRepository { this.lastMutateSpec = mutateSpec; return ok( this.omitUpdateSnapshot - ? {} + ? { + mutationApplied: this.mutationApplied, + } : { + mutationApplied: this.mutationApplied, updateSnapshot: { previous: { recordId: recordId.toString(), @@ -876,6 +880,57 @@ describe('UpdateRecordHandler', () => { expect(result._unsafeUnwrapErr().code).toBe('record.update_snapshot.unavailable'); }); + it('does not require an update snapshot when a stale no-op update was not persisted', async () => { + const { table, tableId, textFieldId } = buildTable(); + const recordResult = table + .createRecord(new Map([[textFieldId.toString(), 'Old Title']])) + ._unsafeUnwrap(); + + const tableRepository = new FakeTableRepository(); + tableRepository.tables.push(table); + const tableQueryService = new TableQueryService(tableRepository); + + const recordRepository = new FakeTableRecordRepository(); + recordRepository.omitUpdateSnapshot = true; + recordRepository.mutationApplied = false; + const recordQueryRepository = new FakeTableRecordQueryRepository(); + recordQueryRepository.record = { + id: recordResult.record.id().toString(), + fields: { [textFieldId.toString()]: 'Old Title' }, + version: 1, + }; + + const eventBus = new FakeEventBus(); + const undoRedoService = new FakeUndoRedoService(); + const handler = new UpdateRecordHandler( + tableQueryService, + recordRepository, + recordQueryRepository, + new FakeRecordOrderCalculator(), + new FakeRecordMutationSpecResolverService() as unknown as RecordMutationSpecResolverService, + noopRecordChangedValueDecoratorService, + createRecordWritePluginRunner(), + new RecordWriteSideEffectService(), + noopRecordWriteUndoRedoPlanService, + createTableUpdateFlow(tableRepository, eventBus, new FakeUnitOfWork()), + eventBus, + undoRedoService as unknown as UndoRedoStackService, + new FakeUnitOfWork() + ); + + const command = UpdateRecordCommand.create({ + tableId: tableId.toString(), + recordId: recordResult.record.id().toString(), + fields: { [textFieldId.toString()]: 'New Title' }, + })._unsafeUnwrap(); + + const result = await handler.handle(createContext(), command); + + expect(result.isOk()).toBe(true); + expect(eventBus.published.some(isRecordUpdatedEvent)).toBe(false); + expect(undoRedoService.calls).toHaveLength(0); + }); + it('event changes contain resolved values after typecast user field resolution', async () => { const { table, tableId, textFieldId, userFieldId } = buildTable(); const recordResult = table diff --git a/packages/v2/core/src/commands/UpdateRecordHandler.ts b/packages/v2/core/src/commands/UpdateRecordHandler.ts index b767cb0865..1957c7fa8e 100644 --- a/packages/v2/core/src/commands/UpdateRecordHandler.ts +++ b/packages/v2/core/src/commands/UpdateRecordHandler.ts @@ -316,14 +316,17 @@ export class UpdateRecordHandler // 2. Build changes array with old/new values (need to resolve field keys to IDs for event) const changes: RecordFieldChangeDTO[] = []; + const mutationApplied = mutationResult.mutation.mutationApplied !== false; const changedFieldValues = new Map( mutationResult.mutation.changedFields ?? [] ); - for (const entry of updatedRecord.fields().entries()) { - const fieldId = entry.fieldId.toString(); - const newValue = entry.value.toValue(); - if (!areFieldValuesEqual(currentRecord.fields[fieldId], newValue)) { - changedFieldValues.set(fieldId, newValue); + if (mutationApplied) { + for (const entry of updatedRecord.fields().entries()) { + const fieldId = entry.fieldId.toString(); + const newValue = entry.value.toValue(); + if (!areFieldValuesEqual(currentRecord.fields[fieldId], newValue)) { + changedFieldValues.set(fieldId, newValue); + } } } const updatedFieldValues = diff --git a/packages/v2/core/src/domain/table/fields/types/FieldValueObjects.spec.ts b/packages/v2/core/src/domain/table/fields/types/FieldValueObjects.spec.ts index a1b69d9c7c..3ea82d2c90 100644 --- a/packages/v2/core/src/domain/table/fields/types/FieldValueObjects.spec.ts +++ b/packages/v2/core/src/domain/table/fields/types/FieldValueObjects.spec.ts @@ -24,7 +24,7 @@ import { SelectOptionId } from './SelectOptionId'; import { SelectOptionName } from './SelectOptionName'; import { validateSelectOptions } from './SelectOptions'; import { TextDefaultValue } from './TextDefaultValue'; -import { TimeZone } from './TimeZone'; +import { TIME_ZONE_LIST, TimeZone, timeZoneValueSchema } from './TimeZone'; import { UserDefaultValue } from './UserDefaultValue'; import { UserId } from './UserId'; import { UserMultiplicity } from './UserMultiplicity'; @@ -349,6 +349,36 @@ describe('TimeZone', () => { expect(TimeZone.create('Etc/UTC')._unsafeUnwrap().toString()).toBe('Etc/UTC'); }); + it('accepts known IANA aliases supported by Intl', () => { + const aliases = [ + 'Africa/Asmera', + 'America/Buenos_Aires', + 'America/Catamarca', + 'America/Ciudad_Juarez', + 'America/Coral_Harbour', + 'America/Cordoba', + 'America/Coyhaique', + 'America/Indianapolis', + 'America/Jujuy', + 'America/Louisville', + 'America/Mendoza', + 'Asia/Calcutta', + 'Asia/Katmandu', + 'Asia/Rangoon', + 'Asia/Saigon', + 'Atlantic/Faeroe', + 'Europe/Kiev', + 'Pacific/Ponape', + 'Pacific/Truk', + ]; + + for (const alias of aliases) { + expect(TIME_ZONE_LIST).toContain(alias); + expect(TimeZone.create(alias)._unsafeUnwrap().toString()).toBe(alias); + expect(timeZoneValueSchema.parse(alias)).toBe(alias); + } + }); + it('provides detailed error message for invalid timezone', () => { const result = TimeZone.create('Invalid/Zone'); expect(result.isErr()).toBe(true); diff --git a/packages/v2/core/src/domain/table/fields/types/TimeZone.ts b/packages/v2/core/src/domain/table/fields/types/TimeZone.ts index 1c31bcba64..2af6e0b73a 100644 --- a/packages/v2/core/src/domain/table/fields/types/TimeZone.ts +++ b/packages/v2/core/src/domain/table/fields/types/TimeZone.ts @@ -12,6 +12,7 @@ export const TIME_ZONE_LIST = [ 'Africa/Addis_Ababa', 'Africa/Algiers', 'Africa/Asmara', + 'Africa/Asmera', 'Africa/Bamako', 'Africa/Bangui', 'Africa/Banjul', @@ -88,15 +89,21 @@ export const TIME_ZONE_LIST = [ 'America/Boa_Vista', 'America/Bogota', 'America/Boise', + 'America/Buenos_Aires', 'America/Cambridge_Bay', 'America/Campo_Grande', 'America/Cancun', 'America/Caracas', + 'America/Catamarca', 'America/Cayenne', 'America/Cayman', 'America/Chicago', 'America/Chihuahua', + 'America/Ciudad_Juarez', + 'America/Coral_Harbour', + 'America/Cordoba', 'America/Costa_Rica', + 'America/Coyhaique', 'America/Creston', 'America/Cuiaba', 'America/Curacao', @@ -131,9 +138,11 @@ export const TIME_ZONE_LIST = [ 'America/Indiana/Vevay', 'America/Indiana/Vincennes', 'America/Indiana/Winamac', + 'America/Indianapolis', 'America/Inuvik', 'America/Iqaluit', 'America/Jamaica', + 'America/Jujuy', 'America/Juneau', 'America/Kentucky/Louisville', 'America/Kentucky/Monticello', @@ -141,6 +150,7 @@ export const TIME_ZONE_LIST = [ 'America/La_Paz', 'America/Lima', 'America/Los_Angeles', + 'America/Louisville', 'America/Lower_Princes', 'America/Maceio', 'America/Managua', @@ -149,6 +159,7 @@ export const TIME_ZONE_LIST = [ 'America/Martinique', 'America/Matamoros', 'America/Mazatlan', + 'America/Mendoza', 'America/Menominee', 'America/Merida', 'America/Metlakatla', @@ -235,6 +246,7 @@ export const TIME_ZONE_LIST = [ 'Asia/Beirut', 'Asia/Bishkek', 'Asia/Brunei', + 'Asia/Calcutta', 'Asia/Chita', 'Asia/Choibalsan', 'Asia/Colombo', @@ -257,6 +269,7 @@ export const TIME_ZONE_LIST = [ 'Asia/Kamchatka', 'Asia/Karachi', 'Asia/Kathmandu', + 'Asia/Katmandu', 'Asia/Khandyga', 'Asia/Kolkata', 'Asia/Krasnoyarsk', @@ -279,7 +292,9 @@ export const TIME_ZONE_LIST = [ 'Asia/Qatar', 'Asia/Qostanay', 'Asia/Qyzylorda', + 'Asia/Rangoon', 'Asia/Riyadh', + 'Asia/Saigon', 'Asia/Sakhalin', 'Asia/Samarkand', 'Asia/Seoul', @@ -306,6 +321,7 @@ export const TIME_ZONE_LIST = [ 'Atlantic/Bermuda', 'Atlantic/Canary', 'Atlantic/Cape_Verde', + 'Atlantic/Faeroe', 'Atlantic/Faroe', 'Atlantic/Madeira', 'Atlantic/Reykjavik', @@ -343,6 +359,7 @@ export const TIME_ZONE_LIST = [ 'Europe/Isle_of_Man', 'Europe/Istanbul', 'Europe/Jersey', + 'Europe/Kiev', 'Europe/Kaliningrad', 'Europe/Kirov', 'Europe/Lisbon', @@ -424,12 +441,14 @@ export const TIME_ZONE_LIST = [ 'Pacific/Palau', 'Pacific/Pitcairn', 'Pacific/Pohnpei', + 'Pacific/Ponape', 'Pacific/Port_Moresby', 'Pacific/Rarotonga', 'Pacific/Saipan', 'Pacific/Tahiti', 'Pacific/Tarawa', 'Pacific/Tongatapu', + 'Pacific/Truk', 'Pacific/Wake', 'Pacific/Wallis', 'Etc/GMT', @@ -464,8 +483,9 @@ export const TIME_ZONE_LIST = [ 'Etc/UTC', ] as const; -const timeZoneSchema = z.enum(TIME_ZONE_LIST); -export type TimeZoneValue = z.infer; +export const timeZoneValueSchema = z.enum(TIME_ZONE_LIST); + +export type TimeZoneValue = z.infer; export class TimeZone extends ValueObject { private constructor(private readonly value: TimeZoneValue) { @@ -475,7 +495,7 @@ export class TimeZone extends ValueObject { static create(raw: unknown): Result { // Handle case-insensitive 'UTC' for backwards compatibility const normalized = typeof raw === 'string' && raw.toUpperCase() === 'UTC' ? 'utc' : raw; - const parsed = timeZoneSchema.safeParse(normalized); + const parsed = timeZoneValueSchema.safeParse(normalized); if (!parsed.success) return err( domainError.validation({ diff --git a/packages/v2/core/src/ports/TableRecordRepository.ts b/packages/v2/core/src/ports/TableRecordRepository.ts index a90136e553..0f05223554 100644 --- a/packages/v2/core/src/ports/TableRecordRepository.ts +++ b/packages/v2/core/src/ports/TableRecordRepository.ts @@ -41,6 +41,14 @@ export interface RecordUpdateSnapshot { * Contains computed field values that were updated as part of the operation. */ export interface RecordMutationResult { + /** + * Whether the repository actually persisted a storage mutation. + * + * `false` is distinct from a missing snapshot: it means the requested mutation + * was skipped because storage already matched the desired state. + */ + mutationApplied?: boolean; + /** * Final stored values for fields changed by this operation. * Map of fieldId -> persisted newValue. diff --git a/packages/v2/core/src/ports/mappers/defaults/DefaultTableMapper.spec.ts b/packages/v2/core/src/ports/mappers/defaults/DefaultTableMapper.spec.ts index 9f8891eb89..698e472604 100644 --- a/packages/v2/core/src/ports/mappers/defaults/DefaultTableMapper.spec.ts +++ b/packages/v2/core/src/ports/mappers/defaults/DefaultTableMapper.spec.ts @@ -373,6 +373,62 @@ describe('DefaultTableMapper', () => { fieldDbNameResult?._unsafeUnwrap(); }); + it('deduplicates select choices by name when rehydrating persistence dto', () => { + const mapper = new DefaultTableMapper(); + const dto = mapper.toDTO(buildTable())._unsafeUnwrap(); + + const withDuplicateSelectChoices = { + ...dto, + fields: dto.fields.map((field) => { + if (field.type === 'singleSelect') { + return { + ...field, + options: { + ...field.options, + choices: [ + ...field.options.choices, + { id: `cho${'c'.repeat(16)}`, name: 'Todo', color: 'red' }, + { id: `cho${'e'.repeat(16)}`, name: ' Todo ', color: 'blue' }, + ], + }, + }; + } + + if (field.type === 'multipleSelect') { + return { + ...field, + options: { + ...field.options, + choices: [ + ...field.options.choices, + { id: `cho${'d'.repeat(16)}`, name: 'Done', color: 'yellow' }, + ], + }, + }; + } + + return field; + }), + }; + + const mapped = mapper.toDomain(withDuplicateSelectChoices)._unsafeUnwrap(); + const statusField = mapped.getFields().find((field) => field.name().toString() === 'Status') as + | SingleSelectField + | undefined; + const tagsField = mapped.getFields().find((field) => field.name().toString() === 'Tags') as + | MultipleSelectField + | undefined; + + expect(statusField?.selectOptions().map((option) => option.name().toString())).toEqual([ + 'Todo', + 'Done', + ]); + expect(tagsField?.selectOptions().map((option) => option.name().toString())).toEqual([ + 'Todo', + 'Done', + ]); + }); + it('preserves dbTableName when mapping builder-backed tables to dto', () => { const baseId = BaseId.create(`bse${'z'.repeat(16)}`)._unsafeUnwrap(); const tableId = TableId.create(`tbl${'z'.repeat(16)}`)._unsafeUnwrap(); diff --git a/packages/v2/core/src/ports/mappers/defaults/DefaultTableMapper.ts b/packages/v2/core/src/ports/mappers/defaults/DefaultTableMapper.ts index 6e63317533..d731dacbcd 100644 --- a/packages/v2/core/src/ports/mappers/defaults/DefaultTableMapper.ts +++ b/packages/v2/core/src/ports/mappers/defaults/DefaultTableMapper.ts @@ -114,6 +114,7 @@ import type { IRatingFieldOptionsDTO, IRollupFieldConfigDTO, IRollupFieldOptionsDTO, + ISelectFieldChoiceDTO, ISelectFieldOptionsDTO, ISingleLineTextFieldOptionsDTO, IUserFieldOptionsDTO, @@ -139,6 +140,20 @@ const optional = ( return parser(raw).map((value) => value); }; +const deduplicateSelectChoiceDtos = ( + choices: ReadonlyArray +): ReadonlyArray => { + const seen = new Set(); + const deduped: ISelectFieldChoiceDTO[] = []; + for (const choice of choices) { + const name = choice.name.trim(); + if (seen.has(name)) continue; + seen.add(name); + deduped.push(choice); + } + return deduped; +}; + const parseFormulaFormatting = ( raw: unknown ): Result => { @@ -695,12 +710,23 @@ class FieldToPersistenceVisitor implements IFieldVisitor; return { - ...innerDto, + ...innerShape, ...baseDto, id: field.id().toString(), name: field.name().toString(), + type: unwrappedInner.innerType ?? innerDto.type, isLookup: true, isConditionalLookup, lookupOptions: lookupOptionsWithRelationship, @@ -1148,7 +1174,7 @@ export class DefaultTableMapper implements ITableMapper { }) .with({ type: 'singleSelect' }, (dto) => { const optionsDto = dto.options ?? { choices: [] }; - const choices = optionsDto.choices ?? []; + const choices = deduplicateSelectChoiceDtos(optionsDto.choices ?? []); return sequenceResults(choices.map((choice) => SelectOption.create(choice))).andThen( (options) => optional(optionsDto.defaultValue, SelectDefaultValue.create).andThen( @@ -1170,7 +1196,7 @@ export class DefaultTableMapper implements ITableMapper { }) .with({ type: 'multipleSelect' }, (dto) => { const optionsDto = dto.options ?? { choices: [] }; - const choices = optionsDto.choices ?? []; + const choices = deduplicateSelectChoiceDtos(optionsDto.choices ?? []); return sequenceResults(choices.map((choice) => SelectOption.create(choice))).andThen( (options) => optional(optionsDto.defaultValue, SelectDefaultValue.create).andThen( diff --git a/packages/v2/core/src/queries/ListTableRecordsHandler.spec.ts b/packages/v2/core/src/queries/ListTableRecordsHandler.spec.ts index 6fa58ed106..415b413ec1 100644 --- a/packages/v2/core/src/queries/ListTableRecordsHandler.spec.ts +++ b/packages/v2/core/src/queries/ListTableRecordsHandler.spec.ts @@ -12,6 +12,7 @@ import { LinkFieldConfig } from '../domain/table/fields/types/LinkFieldConfig'; import { SelectOption } from '../domain/table/fields/types/SelectOption'; import { RecordId } from '../domain/table/records/RecordId'; import { NoopRecordConditionSpecVisitor } from '../domain/table/records/specs/visitors/NoopRecordConditionSpecVisitor'; +import type { UserConditionSpec } from '../domain/table/records/specs/UserConditionSpec'; import { TableUpdateViewColumnMetaSpec } from '../domain/table/specs/TableUpdateViewColumnMetaSpec'; import { TableUpdateViewQueryDefaultsSpec } from '../domain/table/specs/TableUpdateViewQueryDefaultsSpec'; import { Table } from '../domain/table/Table'; @@ -53,6 +54,16 @@ const buildTable = () => { return builder.build()._unsafeUnwrap(); }; +const buildUserFilterTable = () => { + const builder = Table.builder() + .withBaseId(createBaseId('u')) + .withName(TableName.create('User Filter Records')._unsafeUnwrap()); + builder.field().singleLineText().withName(FieldName.create('Title')._unsafeUnwrap()).done(); + builder.field().user().withName(FieldName.create('Assignee')._unsafeUnwrap()).done(); + builder.view().defaultGrid().done(); + return builder.build()._unsafeUnwrap(); +}; + const buildHostTableReferencing = ( foreignTable: Table, relationship: 'manyMany' | 'oneMany' = 'oneMany', @@ -90,6 +101,7 @@ class RecordingSpecVisitor extends NoopRecordConditionSpecVisitor { readonly visited: string[] = []; readonly incomingLinkSelectedModes: string[] = []; readonly incomingLinkCandidateModes: string[] = []; + readonly userValues: unknown[] = []; override visitIncomingLinkSelected( ...args: Parameters @@ -113,6 +125,13 @@ class RecordingSpecVisitor extends NoopRecordConditionSpecVisitor { this.visited.push(`recordByIds:${args[0].recordIds().length}`); return super.visitRecordByIds(...args); } + + override visitUserIs(spec: UserConditionSpec) { + this.visited.push('userIs'); + const value = spec.value(); + this.userValues.push(value && 'toValue' in value ? value.toValue() : value); + return super.visitUserIs(spec); + } } describe('ListTableRecordsHandler', () => { @@ -179,6 +198,63 @@ describe('ListTableRecordsHandler', () => { expect(captured.spec).toBeDefined(); }); + it('replaces Me in view filters with the current actor id', async () => { + const table = buildUserFilterTable(); + const assigneeField = table + .getField((field) => field.name().toString() === 'Assignee') + ._unsafeUnwrap(); + const view = table.views()[0]!; + const tableWithViewFilter = TableUpdateViewQueryDefaultsSpec.create([ + { + viewId: view.id(), + queryDefaults: ViewQueryDefaults.create({ + filter: { + conjunction: 'and', + items: [ + { + fieldId: assigneeField.id().toString(), + operator: 'is', + value: 'Me', + }, + ], + }, + })._unsafeUnwrap(), + }, + ]) + .mutate(table) + ._unsafeUnwrap(); + const tableRepository = new MemoryTableRepository(); + const context = createContext(); + await tableRepository.insert(context, tableWithViewFilter); + + const captured: { spec?: unknown } = {}; + const recordQueryRepo: ITableRecordQueryRepository = { + find: async (_context, _table, spec) => { + captured.spec = spec; + return ok({ records: [], total: 0 }); + }, + findOne: async () => err(domainError.notFound({ message: 'Not found' })), + async *findStream() {}, + }; + + const queryResult = ListTableRecordsQuery.create({ + tableId: table.id().toString(), + viewId: view.id().toString(), + }); + const handler = new ListTableRecordsHandler(tableRepository, recordQueryRepo, new NoopLogger()); + const result = await handler.handle(context, queryResult._unsafeUnwrap()); + + expect(result.isOk()).toBe(true); + const visitor = new RecordingSpecVisitor(); + const acceptResult = ( + captured.spec as { + accept: (visitor: RecordingSpecVisitor) => ReturnType; + } + ).accept(visitor); + expect(acceptResult.isOk()).toBe(true); + expect(visitor.userValues).toEqual([context.actorId.toString()]); + }); + it('drops filters for disabled fields from the permission read source', async () => { const table = buildTable(); const tableRepository = new MemoryTableRepository(); diff --git a/packages/v2/core/src/queries/ListTableRecordsHandler.ts b/packages/v2/core/src/queries/ListTableRecordsHandler.ts index 8102eda10f..9472419b72 100644 --- a/packages/v2/core/src/queries/ListTableRecordsHandler.ts +++ b/packages/v2/core/src/queries/ListTableRecordsHandler.ts @@ -6,6 +6,7 @@ import { FieldKeyResolverService } from '../application/services/FieldKeyResolve import { mergeOrderBy, resolveOrderBy as resolveQueryOrderBy } from '../commands/shared/orderBy'; import { domainError, isNotFoundError, type DomainError } from '../domain/shared/DomainError'; import { type ISpecification } from '../domain/shared/specification/ISpecification'; +import { FieldType } from '../domain/table/fields/FieldType'; import { FieldId } from '../domain/table/fields/FieldId'; import { FieldKeyType } from '../domain/table/fields/FieldKeyType'; import { FieldCondition } from '../domain/table/fields/types/FieldCondition'; @@ -36,10 +37,13 @@ import { type RecordFilter, type RecordFilterCondition, type RecordFilterNode, + type RecordFilterValue, } from './RecordFilterDto'; import { buildRecordConditionSpec, sanitizeRecordFilter } from './RecordFilterMapper'; import { RecordSearch, resolveVisibleRowSearch } from './RecordSearch'; +const currentUserFilterValue = 'Me'; + export class ListTableRecordsResult { private constructor( readonly records: ReadonlyArray, @@ -154,6 +158,60 @@ function resolveFilterNodeFieldKeys( return ok(node); } +function isUserLikeFieldType(type: FieldType): boolean { + return ( + type.equals(FieldType.user()) || + type.equals(FieldType.createdBy()) || + type.equals(FieldType.lastModifiedBy()) + ); +} + +function replaceCurrentUserTagInFilter( + table: Table, + filter: RecordFilter | null | undefined, + actorId: string +): RecordFilter | null | undefined { + if (!filter) { + return filter; + } + + const replaceNode = (node: RecordFilterNode): RecordFilterNode => { + if (isRecordFilterNot(node)) { + return { not: replaceNode(node.not) }; + } + + if (isRecordFilterGroup(node)) { + return { + ...node, + items: node.items.map((item) => replaceNode(item)), + }; + } + + if (!isRecordFilterCondition(node)) { + return node; + } + + const fieldResult = table.getField((field) => field.id().toString() === node.fieldId); + if (fieldResult.isErr() || !isUserLikeFieldType(fieldResult.value.type())) { + return node; + } + + const replaceValue = (value: RecordFilterValue): RecordFilterValue => { + if (Array.isArray(value)) { + return value.map((item) => (item === currentUserFilterValue ? actorId : item)); + } + return value === currentUserFilterValue ? actorId : value; + }; + + return { + ...node, + value: replaceValue(node.value), + }; + }; + + return replaceNode(filter); +} + type IRecordReadQuerySource = { enabledFieldIds?: ReadonlyArray; }; @@ -356,6 +414,11 @@ export class ListTableRecordsHandler const resolvedFilter = query.filter ? yield* resolveFilterFieldKeys(table, query.filter, query.fieldKeyType) : undefined; + const actorResolvedFilter = replaceCurrentUserTagInFilter( + table, + resolvedFilter, + context.actorId.toString() + ); // Pre-resolve link candidate plan so filterByViewId can inform effectiveView. const linkCandidatePlan = query.filterLinkCellCandidate @@ -387,12 +450,14 @@ export class ListTableRecordsHandler const effectiveQueryDefaults = effectiveView ? yield* effectiveView.queryDefaults() : undefined; - const sanitizedDefaultFilter = yield* sanitizeRecordFilter( + const defaultFilter = replaceCurrentUserTagInFilter( table, - effectiveQueryDefaults?.filter() + effectiveQueryDefaults?.filter(), + context.actorId.toString() ); + const sanitizedDefaultFilter = yield* sanitizeRecordFilter(table, defaultFilter); const effectiveFilter = sanitizeFilterByEnabledFieldIds( - mergeFilterWithViewDefaults(sanitizedDefaultFilter, resolvedFilter), + mergeFilterWithViewDefaults(sanitizedDefaultFilter, actorResolvedFilter), enabledFieldIds ); const effectiveSort = mergeSortWithViewDefaults( diff --git a/packages/v2/core/src/schemas/field/common.schema.ts b/packages/v2/core/src/schemas/field/common.schema.ts index 3042c91ab2..a6d5c7353b 100644 --- a/packages/v2/core/src/schemas/field/common.schema.ts +++ b/packages/v2/core/src/schemas/field/common.schema.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { TimeFormatting } from '../../domain/table/fields/types/DateTimeFormatting'; import { fieldColorSchema } from '../../domain/table/fields/types/FieldColor'; +import { longTextShowAsValues } from '../../domain/table/fields/types/LongTextShowAs'; import { NumberFormattingType } from '../../domain/table/fields/types/NumberFormatting'; import { MultiNumberDisplayType, @@ -9,9 +10,8 @@ import { } from '../../domain/table/fields/types/NumberShowAs'; import { ratingColorValues } from '../../domain/table/fields/types/RatingColor'; import { ratingIconValues } from '../../domain/table/fields/types/RatingIcon'; -import { longTextShowAsValues } from '../../domain/table/fields/types/LongTextShowAs'; import { singleLineTextShowAsValues } from '../../domain/table/fields/types/SingleLineTextShowAs'; -import { TIME_ZONE_LIST } from '../../domain/table/fields/types/TimeZone'; +import { timeZoneValueSchema } from '../../domain/table/fields/types/TimeZone'; // Basic enum schemas (re-export or define locally) export const ratingIconSchema = z.enum(ratingIconValues); @@ -61,7 +61,7 @@ export const numberShowAsSchema = z.union([singleNumberShowAsSchema, multiNumber export const dateFormattingSchema = z.object({ date: z.string(), time: z.enum([TimeFormatting.Hour24, TimeFormatting.Hour12, TimeFormatting.None]), - timeZone: z.enum(TIME_ZONE_LIST), + timeZone: timeZoneValueSchema, }); // Formula formatting (union of number and date) diff --git a/packages/v2/core/src/schemas/field/tableField.schema.ts b/packages/v2/core/src/schemas/field/tableField.schema.ts index 9f7a9a7318..3499640406 100644 --- a/packages/v2/core/src/schemas/field/tableField.schema.ts +++ b/packages/v2/core/src/schemas/field/tableField.schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { fieldColorSchema, fieldColorValues } from '../../domain/table/fields/types/FieldColor'; -import { TIME_ZONE_LIST } from '../../domain/table/fields/types/TimeZone'; +import { timeZoneValueSchema } from '../../domain/table/fields/types/TimeZone'; import { cellValueTypeSchema, dateFormattingSchema, @@ -129,7 +129,7 @@ export const buttonOptionsSchema = z.object({ export const formulaOptionsSchema = z.object({ expression: z.string(), - timeZone: z.enum(TIME_ZONE_LIST).optional(), + timeZone: timeZoneValueSchema.optional(), formatting: formulaFormattingSchema.optional(), showAs: formulaShowAsSchema.optional(), }); @@ -154,7 +154,7 @@ export const linkOptionsSchema = z export const rollupOptionsSchema = z .object({ expression: z.string(), - timeZone: z.enum(TIME_ZONE_LIST).optional(), + timeZone: timeZoneValueSchema.optional(), formatting: formulaFormattingSchema.optional(), showAs: formulaShowAsSchema.optional(), }) @@ -205,7 +205,7 @@ export const conditionalRollupConfigSchema = z export const conditionalRollupOptionsSchema = z .object({ expression: z.string(), - timeZone: z.enum(TIME_ZONE_LIST).optional(), + timeZone: timeZoneValueSchema.optional(), formatting: formulaFormattingSchema.optional(), showAs: formulaShowAsSchema.optional(), }) diff --git a/packages/v2/core/src/schemas/field/time-zone.schema.spec.ts b/packages/v2/core/src/schemas/field/time-zone.schema.spec.ts new file mode 100644 index 0000000000..57c9076f74 --- /dev/null +++ b/packages/v2/core/src/schemas/field/time-zone.schema.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { TimeFormatting } from '../../domain/table/fields/types/DateTimeFormatting'; +import { dateFormattingSchema } from './common.schema'; +import { + conditionalRollupOptionsSchema, + formulaOptionsSchema, + rollupOptionsSchema, +} from './tableField.schema'; + +describe('field timezone schemas', () => { + it('accepts known IANA aliases in field option schemas', () => { + expect( + dateFormattingSchema.parse({ + date: 'YYYY-MM-DD', + time: TimeFormatting.None, + timeZone: 'Asia/Saigon', + }).timeZone + ).toBe('Asia/Saigon'); + + expect( + formulaOptionsSchema.parse({ + expression: 'NOW()', + timeZone: 'Asia/Saigon', + }).timeZone + ).toBe('Asia/Saigon'); + + expect( + rollupOptionsSchema.parse({ + expression: 'COUNTALL(values)', + timeZone: 'Asia/Saigon', + }).timeZone + ).toBe('Asia/Saigon'); + + expect( + conditionalRollupOptionsSchema.parse({ + expression: 'COUNTALL(values)', + timeZone: 'Asia/Saigon', + }).timeZone + ).toBe('Asia/Saigon'); + }); + + it('still rejects invalid timezone values', () => { + expect(() => + dateFormattingSchema.parse({ + date: 'YYYY-MM-DD', + time: TimeFormatting.None, + timeZone: 'Invalid/Zone', + }) + ).toThrow(); + }); +}); diff --git a/packages/v2/dottea/src/index.ts b/packages/v2/dottea/src/index.ts index 55ced87fa6..8854e3fdd9 100644 --- a/packages/v2/dottea/src/index.ts +++ b/packages/v2/dottea/src/index.ts @@ -109,29 +109,26 @@ export class DotTeaParser implements IDotTeaParser { const structure = structureResult.value; - // Build a map of field IDs to field types for dependency detection - const fieldTypesById = new Map(); - for (const table of structure.tables) { - for (const field of table.fields) { - if (field.id) { - fieldTypesById.set(field.id, field.type); - } - } - } - - // Normalize all tables and their fields - const normalizedTables = structure.tables.map((table) => ({ - ...(table.id ? { id: table.id } : {}), - name: table.name, - fields: table.fields.map((field) => normalizeField(field, fieldTypesById)), - views: table.views - ?.filter((view) => (view.type ? allowedViewTypes.has(view.type) : true)) - .map((view) => ({ - ...(view.id ? { id: view.id } : {}), - name: view.name, - type: view.type, - })), - })); + // Normalize all tables and their fields using table-local field IDs so + // legacy formulas that reference deleted fields can be downgraded safely. + const normalizedTables = structure.tables.map((table) => { + const tableFieldTypesById = new Map( + table.fields.filter((field) => field.id).map((field) => [field.id!, field.type] as const) + ); + + return { + ...(table.id ? { id: table.id } : {}), + name: table.name, + fields: table.fields.map((field) => normalizeField(field, tableFieldTypesById)), + views: table.views + ?.filter((view) => (view.type ? allowedViewTypes.has(view.type) : true)) + .map((view) => ({ + ...(view.id ? { id: view.id } : {}), + name: view.name, + type: view.type, + })), + }; + }); return ok({ tables: normalizedTables }); } diff --git a/packages/v2/dottea/src/normalizer/DotTeaFieldNormalizer.spec.ts b/packages/v2/dottea/src/normalizer/DotTeaFieldNormalizer.spec.ts new file mode 100644 index 0000000000..b4b142e747 --- /dev/null +++ b/packages/v2/dottea/src/normalizer/DotTeaFieldNormalizer.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTableFromInput } from '../../../core/src/commands/TableInputParser'; +import { normalizeField } from './DotTeaFieldNormalizer'; + +describe('DotTeaFieldNormalizer', () => { + it('deduplicates select choices that only differ by surrounding whitespace', () => { + const normalized = normalizeField( + { + id: `fld${'s'.repeat(16)}`, + type: 'singleSelect', + name: 'Tꬔ', + options: { + choices: [ + { id: 'chom4XbfXuh', name: 'T1', color: 'purple' }, + { id: 'cho0eYy9LIM', name: ' T1', color: 'purpleLight2' }, + { id: 'chojvEzfz4d', name: 'T2', color: 'blueLight2' }, + ], + defaultValue: ' T1', + }, + }, + new Map() + ); + + expect(normalized.options).toEqual({ + choices: [ + { id: 'chom4XbfXuh', name: 'T1', color: 'purple' }, + { id: 'chojvEzfz4d', name: 'T2', color: 'blueLight2' }, + ], + defaultValue: 'T1', + }); + + const result = buildTableFromInput({ + baseId: `bse${'a'.repeat(16)}`, + tableId: `tbl${'b'.repeat(16)}`, + name: 'Import Test', + fields: [ + { + id: `fld${'p'.repeat(16)}`, + type: 'singleLineText', + name: 'Name', + isPrimary: true, + }, + { + id: normalized.id, + type: normalized.type, + name: normalized.name, + options: normalized.options, + }, + ], + }); + + expect(result.isOk()).toBe(true); + }); + + it('downgrades formulas that reference missing fields to singleLineText', () => { + const normalized = normalizeField( + { + id: `fld${'f'.repeat(16)}`, + type: 'formula', + name: 'Broken Formula', + options: { + expression: 'SUM({fldaaaaaaaaaaaaaaaa},{fldbbbbbbbbbbbbbbbb})', + }, + }, + new Map([['fldaaaaaaaaaaaaaaaa', 'number']]) + ); + + expect(normalized.type).toBe('singleLineText'); + expect(normalized.options).toEqual({ + expression: 'SUM({fldaaaaaaaaaaaaaaaa},{fldbbbbbbbbbbbbbbbb})', + }); + }); +}); diff --git a/packages/v2/dottea/src/normalizer/DotTeaFieldNormalizer.ts b/packages/v2/dottea/src/normalizer/DotTeaFieldNormalizer.ts index 80bfd74a9e..0933d0a72f 100644 --- a/packages/v2/dottea/src/normalizer/DotTeaFieldNormalizer.ts +++ b/packages/v2/dottea/src/normalizer/DotTeaFieldNormalizer.ts @@ -6,6 +6,53 @@ const asRecord = (value: unknown): Record | undefined => const readString = (value: Record | undefined, key: string): string | undefined => typeof value?.[key] === 'string' ? (value[key] as string) : undefined; +const normalizeSelectOptionName = (name: unknown): unknown => + typeof name === 'string' ? name.trim() : name; + +const normalizeSelectDefaultValue = (value: unknown): unknown => { + if (typeof value === 'string') { + return value.trim(); + } + if (Array.isArray(value)) { + return value.map((item) => (typeof item === 'string' ? item.trim() : item)); + } + return value; +}; + +const normalizeSelectChoices = ( + options: Record | undefined +): Record | undefined => { + if (!options || !Array.isArray(options.choices)) { + return options; + } + + const seen = new Set(); + const choices = options.choices.flatMap((choice) => { + if (!choice || typeof choice !== 'object' || Array.isArray(choice)) { + return [choice]; + } + + const rawChoice = choice as Record; + const normalizedName = normalizeSelectOptionName(rawChoice.name); + if (typeof normalizedName === 'string') { + if (seen.has(normalizedName)) { + return []; + } + seen.add(normalizedName); + } + + return [{ ...rawChoice, name: normalizedName }]; + }); + + return { + ...options, + choices, + ...(options.defaultValue !== undefined + ? { defaultValue: normalizeSelectDefaultValue(options.defaultValue) } + : {}), + }; +}; + /** * Extract field IDs from a formula expression. */ @@ -116,11 +163,17 @@ export const normalizeFieldOptions = ( fieldTypesById: ReadonlyMap ): NormalizedFieldOptions => { const rawOptions = asRecord(field.options); + const normalizedSelectOptions = + field.type === 'singleSelect' || field.type === 'multipleSelect' + ? normalizeSelectChoices(rawOptions) + : rawOptions; const rawLookupOptions = asRecord(field.lookupOptions); if (field.type === 'link') { - const options = normalizeLinkOptions(rawOptions); - return options ? { type: 'link', options } : { type: 'singleLineText', options: rawOptions }; + const options = normalizeLinkOptions(normalizedSelectOptions); + return options + ? { type: 'link', options } + : { type: 'singleLineText', options: normalizedSelectOptions }; } const lookupOptions = normalizeLookupOptions(rawLookupOptions); @@ -129,10 +182,10 @@ export const normalizeFieldOptions = ( } if (field.type === 'rollup') { - const options = normalizeFormulaOptions(rawOptions, 'countall({values})'); + const options = normalizeFormulaOptions(normalizedSelectOptions, 'countall({values})'); return options && lookupOptions ? { type: 'rollup', options, config: lookupOptions } - : { type: 'singleLineText', options: rawOptions }; + : { type: 'singleLineText', options: normalizedSelectOptions }; } // Check conditionalLookup BEFORE formula, because v1 dottea stores conditional lookups @@ -141,21 +194,24 @@ export const normalizeFieldOptions = ( if (field.type === 'conditionalLookup' || field.isConditionalLookup) { // Config can be in rawOptions or rawLookupOptions depending on v1 export format const foreignTableId = - readString(rawLookupOptions, 'foreignTableId') ?? readString(rawOptions, 'foreignTableId'); + readString(rawLookupOptions, 'foreignTableId') ?? + readString(normalizedSelectOptions, 'foreignTableId'); const lookupFieldId = - readString(rawLookupOptions, 'lookupFieldId') ?? readString(rawOptions, 'lookupFieldId'); - const condition = normalizeCondition(rawLookupOptions) ?? normalizeCondition(rawOptions); + readString(rawLookupOptions, 'lookupFieldId') ?? + readString(normalizedSelectOptions, 'lookupFieldId'); + const condition = + normalizeCondition(rawLookupOptions) ?? normalizeCondition(normalizedSelectOptions); if (foreignTableId && lookupFieldId && condition) { return { type: 'conditionalLookup', options: { foreignTableId, lookupFieldId, condition } }; } - return { type: 'singleLineText', options: rawOptions }; + return { type: 'singleLineText', options: normalizedSelectOptions }; } if (field.type === 'conditionalRollup') { - const options = normalizeFormulaOptions(rawOptions, 'countall({values})'); - const foreignTableId = readString(rawOptions, 'foreignTableId'); - const lookupFieldId = readString(rawOptions, 'lookupFieldId'); - const condition = normalizeCondition(rawOptions); + const options = normalizeFormulaOptions(normalizedSelectOptions, 'countall({values})'); + const foreignTableId = readString(normalizedSelectOptions, 'foreignTableId'); + const lookupFieldId = readString(normalizedSelectOptions, 'lookupFieldId'); + const condition = normalizeCondition(normalizedSelectOptions); if (options && foreignTableId && lookupFieldId && condition) { return { type: 'conditionalRollup', @@ -163,24 +219,30 @@ export const normalizeFieldOptions = ( config: { foreignTableId, lookupFieldId, condition }, }; } - return { type: 'singleLineText', options: rawOptions }; + return { type: 'singleLineText', options: normalizedSelectOptions }; } if (field.type === 'formula') { - const expression = typeof rawOptions?.expression === 'string' ? rawOptions.expression : ''; + const expression = + typeof normalizedSelectOptions?.expression === 'string' + ? normalizedSelectOptions.expression + : ''; const refs = expression ? extractFieldReferences(expression) : []; + const hasMissingDependency = refs.some((ref) => !fieldTypesById.has(ref)); const hasComputedDependency = refs.some((ref) => { const type = fieldTypesById.get(ref); return type === 'rollup' || type === 'conditionalRollup'; }); - if (hasComputedDependency) { - return { type: 'singleLineText', options: rawOptions }; + if (hasMissingDependency || hasComputedDependency) { + return { type: 'singleLineText', options: normalizedSelectOptions }; } - const options = normalizeFormulaOptions(rawOptions, '0'); - return options ? { type: 'formula', options } : { type: 'singleLineText', options: rawOptions }; + const options = normalizeFormulaOptions(normalizedSelectOptions, '0'); + return options + ? { type: 'formula', options } + : { type: 'singleLineText', options: normalizedSelectOptions }; } - return { options: rawOptions }; + return { options: normalizedSelectOptions }; }; /** diff --git a/packages/v2/e2e/src/date-time.e2e.spec.ts b/packages/v2/e2e/src/date-time.e2e.spec.ts index f203be3412..d2b7c33315 100644 --- a/packages/v2/e2e/src/date-time.e2e.spec.ts +++ b/packages/v2/e2e/src/date-time.e2e.spec.ts @@ -5,7 +5,7 @@ import { listTableRecordsOkResponseSchema, updateRecordOkResponseSchema, } from '@teable/v2-contract-http'; -import { FieldKeyType, type RecordFilter, type RecordSearchInput } from '@teable/v2-core'; +import { FieldKeyType, type RecordFilter } from '@teable/v2-core'; import { beforeAll, describe, expect, it } from 'vitest'; import { getSharedTestContext, type SharedTestContext } from './shared/globalTestContext'; @@ -133,7 +133,7 @@ describe('v2 http date time parsing (e2e)', () => { const listRecords = async ( tableId: string, - options?: { filter?: RecordFilter; groupBy?: string[]; search?: RecordSearchInput } + options?: { filter?: RecordFilter; groupBy?: string[] } ) => { const params = new URLSearchParams({ tableId, @@ -148,10 +148,6 @@ describe('v2 http date time parsing (e2e)', () => { params.set('groupBy', JSON.stringify(options.groupBy)); } - if (options?.search) { - params.set('search', JSON.stringify(options.search)); - } - const response = await fetch(`${ctx.baseUrl}/tables/listRecords?${params.toString()}`, { method: 'GET', headers: { 'content-type': 'application/json' }, @@ -209,51 +205,6 @@ describe('v2 http date time parsing (e2e)', () => { expect(updatedRecord.fields[dateFieldId]).toBe(dateCase.update.expected); }); - it('searches date fields globally', async () => { - const table = await createTable({ - baseId: ctx.baseId, - name: uniqueName('global-date-search'), - fields: [ - { type: 'singleLineText', name: 'Title', isPrimary: true }, - { - type: 'date', - name: 'Question Date', - options: { - formatting: { - date: 'YYYY-MM-DD', - time: 'None', - timeZone: 'utc', - }, - }, - }, - ], - views: [{ type: 'grid' }], - }); - - const primaryFieldId = table.fields.find((field) => field.isPrimary)?.id ?? ''; - const dateFieldId = table.fields.find((field) => field.name === 'Question Date')?.id ?? ''; - - await createRecords(table.id, [ - { - fields: { - [primaryFieldId]: 'Target', - [dateFieldId]: '2026-02-24', - }, - }, - { - fields: { - [primaryFieldId]: 'Other', - [dateFieldId]: '2026-02-25', - }, - }, - ]); - - const records = await listRecords(table.id, { search: ['2026-02-24', '', true] }); - - expect(records).toHaveLength(1); - expect(records[0]?.fields[primaryFieldId]).toBe('Target'); - }); - it('filters exact datetime groups to a single timestamp', async () => { const table = await createTable({ baseId: ctx.baseId, diff --git a/packages/v2/e2e/src/record-http-compat.e2e.spec.ts b/packages/v2/e2e/src/record-http-compat.e2e.spec.ts index 4f12cb7e3d..754a16a667 100644 --- a/packages/v2/e2e/src/record-http-compat.e2e.spec.ts +++ b/packages/v2/e2e/src/record-http-compat.e2e.spec.ts @@ -24,6 +24,7 @@ describe('v2 http record compatibility (P0)', () => { let attachmentPath = ''; let attachmentSize = 0; let attachmentMimetype = ''; + let attachmentIdCounter = 0; const uniqueTableName = (prefix: string) => { tableCounter += 1; @@ -210,18 +211,36 @@ describe('v2 http record compatibility (P0)', () => { }; const seedAttachment = async () => { - attachmentToken = `tok_${Date.now()}`; - attachmentPath = 'table/DXR3aPmms8EI'; - attachmentSize = 4; - attachmentMimetype = 'text/plain'; + attachmentIdCounter += 1; + const token = `tok_${Date.now()}_${attachmentIdCounter}`; + const path = `table/DXR3aPmms8EI_${attachmentIdCounter}`; + const size = 4 + attachmentIdCounter; + const mimetype = 'text/plain'; await sql .raw( `insert into attachments (id, token, hash, size, mimetype, path, created_by) - values ('att_${Date.now()}', '${attachmentToken}', 'hash', ${attachmentSize}, '${attachmentMimetype}', '${attachmentPath}', '${ctx.testUser.id}') + values ('att_${Date.now()}_${attachmentIdCounter}', '${token}', 'hash', ${size}, '${mimetype}', '${path}', '${ctx.testUser.id}') on conflict (token) do nothing` ) .execute(ctx.testContainer.db); + + return { + token, + path, + size, + mimetype, + }; + }; + + const listAttachmentRows = async (recordId: string, fieldId: string) => { + return await ctx.testContainer.db + .selectFrom('attachments_table') + .select(['attachment_id', 'token', 'name', 'table_id', 'record_id', 'field_id']) + .where('record_id', '=', recordId) + .where('field_id', '=', fieldId) + .orderBy('token') + .execute(); }; beforeAll(async () => { @@ -229,7 +248,11 @@ describe('v2 http record compatibility (P0)', () => { // Test user is already inserted by globalTestContext await ensureAttachmentTables(); - await seedAttachment(); + const seeded = await seedAttachment(); + attachmentToken = seeded.token; + attachmentPath = seeded.path; + attachmentSize = seeded.size; + attachmentMimetype = seeded.mimetype; }); describe('audit user fields', () => { @@ -464,6 +487,72 @@ describe('v2 http record compatibility (P0)', () => { ]); }); + it('persists attachment rows into attachments_table on create and update', async () => { + const initialAttachment = await seedAttachment(); + const replacementAttachment = await seedAttachment(); + const table = await createTable({ + baseId: ctx.baseId, + name: uniqueTableName('attachment-sync'), + fields: [ + { type: 'singleLineText', name: 'title', isPrimary: true }, + { type: 'attachment', name: 'attachment' }, + ], + }); + const titleFieldId = table.fields.find((field) => field.name === 'title')?.id ?? ''; + const attachmentFieldId = table.fields.find((field) => field.name === 'attachment')?.id ?? ''; + + const createdRecord = await createRecord(table.id, { + [titleFieldId]: 'attachment row sync', + [attachmentFieldId]: [ + { + id: 'act_sync_create_1', + name: 'created.txt', + token: initialAttachment.token, + path: 'ignored/on/write', + size: 1, + mimetype: 'text/plain', + }, + ], + }); + + const createdRows = await listAttachmentRows(createdRecord.id, attachmentFieldId); + expect(createdRows).toEqual([ + { + attachment_id: 'act_sync_create_1', + token: initialAttachment.token, + name: 'created.txt', + table_id: table.id, + record_id: createdRecord.id, + field_id: attachmentFieldId, + }, + ]); + + await updateRecord(table.id, createdRecord.id, { + [attachmentFieldId]: [ + { + id: 'act_sync_update_1', + name: 'updated.txt', + token: replacementAttachment.token, + path: 'ignored/on/update', + size: 2, + mimetype: 'text/plain', + }, + ], + }); + + const updatedRows = await listAttachmentRows(createdRecord.id, attachmentFieldId); + expect(updatedRows).toEqual([ + { + attachment_id: 'act_sync_update_1', + token: replacementAttachment.token, + name: 'updated.txt', + table_id: table.id, + record_id: createdRecord.id, + field_id: attachmentFieldId, + }, + ]); + }); + it('errors when attachment token not exist', async () => { const table = await createTable({ baseId: ctx.baseId, diff --git a/packages/v2/e2e/src/update-field/computed/force-v2-all-regressions.spec.ts b/packages/v2/e2e/src/update-field/computed/force-v2-all-regressions.spec.ts index 74501ecade..a5f99712a7 100644 --- a/packages/v2/e2e/src/update-field/computed/force-v2-all-regressions.spec.ts +++ b/packages/v2/e2e/src/update-field/computed/force-v2-all-regressions.spec.ts @@ -10,6 +10,36 @@ const createFieldId = () => { return `fld${suffix}`; }; +const getDbTableName = async (ctx: SharedTestContext, tableId: string) => { + const tableMeta = await ctx.testContainer.db + .selectFrom('table_meta') + .select('db_table_name') + .where('id', '=', tableId) + .executeTakeFirst(); + + const dbTableName = tableMeta?.db_table_name; + if (!dbTableName) { + throw new Error(`Missing db_table_name for table ${tableId}`); + } + + return dbTableName; +}; + +const getDbFieldName = async (ctx: SharedTestContext, fieldId: string) => { + const fieldMeta = await ctx.testContainer.db + .selectFrom('field') + .select('db_field_name') + .where('id', '=', fieldId) + .executeTakeFirst(); + + const dbFieldName = fieldMeta?.db_field_name; + if (!dbFieldName) { + throw new Error(`Missing db_field_name for field ${fieldId}`); + } + + return dbFieldName; +}; + describe('update-field: FORCE_V2_ALL regressions', () => { let ctx: SharedTestContext; let nameCounter = 0; @@ -203,6 +233,114 @@ describe('update-field: FORCE_V2_ALL regressions', () => { } ); + test( + 'converts text to formula using stored link title snapshot when foreign display is blank', + { timeout: 120_000 }, + async () => { + let hostTableId: string | undefined; + let foreignTableId: string | undefined; + + try { + const expectedTitle = 'Snapshot Title'; + const foreignNameFieldId = createFieldId(); + const linkFieldId = createFieldId(); + const formulaFieldId = createFieldId(); + + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: nextName('v1p-link-formula-foreign'), + fields: [ + { + type: 'singleLineText', + id: foreignNameFieldId, + name: 'Name', + isPrimary: true, + }, + ], + }); + foreignTableId = foreignTable.id; + + const foreignRecord = await ctx.createRecord(foreignTable.id, { + [foreignNameFieldId]: expectedTitle, + }); + + const hostTable = await ctx.createTable({ + baseId: ctx.baseId, + name: nextName('v1p-link-formula-host'), + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', id: formulaFieldId, name: 'Formula Target' }, + ], + }); + hostTableId = hostTable.id; + + const hostRecord = await ctx.createRecord(hostTable.id, { + Name: 'Host Row', + [formulaFieldId]: 'will convert', + }); + + await ctx.createField({ + baseId: ctx.baseId, + tableId: hostTable.id, + field: { + type: 'link', + id: linkFieldId, + name: 'Linked Record', + options: { + relationship: 'manyOne', + foreignTableId: foreignTable.id, + lookupFieldId: foreignNameFieldId, + }, + }, + }); + + await ctx.updateRecord(hostTable.id, hostRecord.id, { + [linkFieldId]: { id: foreignRecord.id }, + }); + await ctx.drainOutbox(); + + const linkedRows = await ctx.listRecords(hostTable.id, { limit: 10, offset: 0 }); + const linkedRow = linkedRows.find((record) => record.id === hostRecord.id); + expect(linkedRow?.fields[linkFieldId]).toEqual({ + id: foreignRecord.id, + title: expectedTitle, + }); + + const foreignDbTableName = await getDbTableName(ctx, foreignTable.id); + const foreignNameDbFieldName = await getDbFieldName(ctx, foreignNameFieldId); + await sql` + UPDATE ${sql.table(foreignDbTableName)} + SET ${sql.ref(foreignNameDbFieldName)} = ${''} + WHERE "__id" = ${foreignRecord.id} + `.execute(ctx.testContainer.db); + + await ctx.updateField({ + tableId: hostTable.id, + fieldId: formulaFieldId, + field: { + type: 'formula', + name: 'Formula Target', + options: { + expression: `{${linkFieldId}}`, + }, + }, + }); + await ctx.drainOutbox(); + + const records = await ctx.listRecords(hostTable.id, { limit: 10, offset: 0 }); + const record = records.find((item) => item.id === hostRecord.id); + expect(record?.fields[formulaFieldId]).toBe(expectedTitle); + } finally { + if (hostTableId) { + await ctx.deleteTable(hostTableId).catch(() => undefined); + } + if (foreignTableId) { + await ctx.deleteTable(foreignTableId).catch(() => undefined); + } + } + } + ); + test( 'ignores malformed numeric text in conditional rollup backfill instead of throwing pg cast errors', { timeout: 120_000 },