From e653859f228f483a99401a7df896570f53cf12ec Mon Sep 17 00:00:00 2001 From: teable-bot Date: Fri, 6 Feb 2026 08:22:12 +0000 Subject: [PATCH] [sync] fix: warning log when role create concurrently (#1180) Synced from teableio/teable-ee@aa688e3 --- .dockerignore | 4 +- .github/workflows/integration-tests.yml | 26 +- .github/workflows/issue-id-check.yml | 62 +- .github/workflows/v2-benchmark-tests.yml | 72 + .github/workflows/v2-core-tests.yml | 143 + .gitignore | 7 +- .prettierignore | 1 + .vscode/settings.json | 10 +- agents.md | 73 + apps/nestjs-backend/.eslintrc.js | 8 + apps/nestjs-backend/package.json | 17 + apps/nestjs-backend/src/app.module.ts | 11 +- apps/nestjs-backend/src/bootstrap.ts | 4 +- .../boolean-cell-value-filter.adapter.ts | 3 +- .../boolean-cell-value-filter.adapter.ts | 3 +- .../src/db-provider/postgres.provider.ts | 14 +- .../src/db-provider/sqlite.provider.ts | 11 +- .../default-datetime-parse-pattern.spec.ts | 16 + .../utils/default-datetime-parse-pattern.ts | 3 +- .../src/features/auth/auth.module.ts | 14 +- .../src/features/auth/social/social.module.ts | 33 +- .../base-sql-executor.service.ts | 16 +- .../builtin-assets-init.service.ts | 9 + .../features/calculation/reference.service.ts | 6 +- .../src/features/canary/canary.module.ts | 6 +- .../src/features/canary/canary.service.ts | 113 +- .../decorators/use-v2-feature.decorator.ts | 20 + .../canary/guards/v2-feature.guard.ts | 139 + .../src/features/canary/index.ts | 3 + .../interceptors/v2-indicator.interceptor.ts | 83 + .../field-supplement.service.ts | 9 +- .../model/field-dto/created-time-field.dto.ts | 27 +- .../field-dto/last-modified-time-field.dto.ts | 27 +- .../open-api/import-open-api-v2.service.ts | 204 + .../open-api/import-open-api.controller.ts | 35 +- .../import/open-api/import-open-api.module.ts | 9 +- .../features/invitation/invitation.service.ts | 2 - .../mail-sender/mail-sender.service.ts | 41 +- .../open-api/mail-sender.merge.processor.ts | 4 +- .../mail-sender/templates/partials/header.hbs | 3 +- .../open-api/record-open-api-v2.service.ts | 947 + .../open-api/record-open-api.controller.ts | 96 +- .../record/open-api/record-open-api.module.ts | 17 +- .../query-builder/sql-conversion.visitor.ts | 2 +- .../src/features/record/record.service.ts | 13 +- .../selection/selection.controller.ts | 45 +- .../features/selection/selection.module.ts | 6 +- .../features/selection/selection.service.ts | 5 +- .../open-api/setting-open-api.service.ts | 4 +- .../table/open-api/table-open-api.service.ts | 13 +- .../trash/listener/table-trash.listener.ts | 2 +- .../undo-redo/stack/undo-redo-stack.module.ts | 6 +- .../stack/undo-redo-stack.service.ts | 22 + .../features/v2/v2-action-trigger.service.ts | 166 + .../v2/v2-command-bus-tracing.middleware.ts | 84 + .../src/features/v2/v2-container.service.ts | 87 + .../v2/v2-execution-context.factory.ts | 60 + .../src/features/v2/v2-logger.adapter.ts | 47 + .../src/features/v2/v2-openapi.controller.ts | 84 + .../features/v2/v2-projection-registrar.ts | 10 + .../v2/v2-query-bus-tracing.middleware.ts | 43 + .../features/v2/v2-record-history.service.ts | 483 + .../src/features/v2/v2-tracer.adapter.ts | 47 + .../src/features/v2/v2-undo-redo.service.ts | 392 + .../src/features/v2/v2.controller.ts | 81 + .../src/features/v2/v2.module.ts | 100 + .../view/open-api/view-open-api-v2.service.ts | 66 + .../view/open-api/view-open-api.controller.ts | 20 +- .../view/open-api/view-open-api.module.ts | 9 +- .../src/global/global.module.ts | 2 +- apps/nestjs-backend/src/tracing.ts | 39 + .../src/tracing/route-tracing.interceptor.ts | 34 +- apps/nestjs-backend/src/types/cls.ts | 14 + .../src/types/i18n.generated.ts | 44 +- .../static/system/email-logo.png | Bin 0 -> 3463 bytes .../test/attachment.e2e-spec.ts | 7 +- .../test/auto-number.e2e-spec.ts | 24 +- .../test/collaboration.e2e-spec.ts | 13 +- .../test/computed-orchestrator.e2e-spec.ts | 1227 +- .../computed-version-regression.e2e-spec.ts | 97 +- .../test/conditional-lookup.e2e-spec.ts | 87 + .../formula-datetime-parse-update.e2e-spec.ts | 285 + apps/nestjs-backend/test/formula.e2e-spec.ts | 83 +- apps/nestjs-backend/test/link-api.e2e-spec.ts | 68 + .../test/link-events.e2e-spec.ts | 8 +- ...ink-formula-if-boolean-context.e2e-spec.ts | 14 +- .../test/oauth-server.e2e-spec.ts | 48 +- .../nestjs-backend/test/selection.e2e-spec.ts | 142 + .../test/table-import.e2e-spec.ts | 20 +- apps/nestjs-backend/test/utils/init-app.ts | 4 + apps/nestjs-backend/tsconfig.eslint.json | 5 +- apps/nestjs-backend/tsconfig.json | 13 +- apps/nestjs-backend/vitest-bench.config.ts | 4 +- apps/nestjs-backend/vitest-e2e.config.ts | 4 + apps/nestjs-backend/vitest-e2e.setup.ts | 27 + apps/nestjs-backend/webpack.swc.js | 17 +- .../config/tests/I18nextTestStubProvider.tsx | 2 +- .../e2e/pages/index/index-chinese.spec.ts | 4 +- apps/nextjs-app/e2e/pages/index/index.spec.ts | 4 +- apps/nextjs-app/e2e/pages/system/404.spec.ts | 4 +- apps/nextjs-app/src/AppProviders.tsx | 6 +- .../tool-bar/components/CoverFieldSelect.tsx | 8 +- .../components/GalleryViewOperators.tsx | 5 +- .../components/KanbanViewOperators.tsx | 5 +- .../download-attachments/DownloadContent.tsx | 178 +- .../components/FloatPlugin.tsx | 2 +- .../src/features/i18n/system.config.ts | 4 +- .../src/features/system/pages/ErrorPage.tsx | 9 +- .../features/system/pages/ForbiddenPage.tsx | 9 +- .../features/system/pages/HttpErrorPage.tsx | 8 +- .../features/system/pages/NotFoundPage.tsx | 9 +- .../features/system/pages/PaymentRequired.tsx | 9 +- .../src/lib/i18n/staticPageLocale.ts | 28 +- apps/nextjs-app/src/lib/withAuthSSR.ts | 3 - apps/nextjs-app/src/pages/404.tsx | 10 +- apps/nextjs-app/src/pages/_error.tsx | 14 +- .../pages/base/[baseId]/authority-matrix.tsx | 3 +- .../src/pages/base/[baseId]/design.tsx | 5 +- .../src/pages/base/[baseId]/trash.tsx | 4 +- apps/nextjs-app/src/pages/waitlist/index.tsx | 7 +- apps/nextjs-app/tsconfig.json | 3 +- apps/playground/.cta.json | 12 + apps/playground/.cursorrules | 7 + apps/playground/.env.development | 13 + apps/playground/.gitignore | 13 + apps/playground/.vscode/settings.json | 11 + apps/playground/README.md | 297 + apps/playground/components.json | 21 + apps/playground/package.json | 120 + apps/playground/public/favicon.ico | Bin 0 -> 1150 bytes apps/playground/public/favicon.svg | 1 + .../playground/ComputedTasksPanel.tsx | 520 + .../playground/CreateTableDropdown.tsx | 297 + .../playground/ExplainResultPanel.tsx | 1098 + .../playground/FieldCreateDialog.tsx | 51 + .../src/components/playground/FieldForm.tsx | 388 + .../playground/FieldFormOptions.tsx | 180 + .../components/playground/ImportCsvDialog.tsx | 388 + .../components/playground/LinkFieldLabel.tsx | 87 + .../src/components/playground/LogPanel.tsx | 428 + .../components/playground/MetaCheckPanel.tsx | 484 + .../playground/PlaygroundRecordRoute.tsx | 530 + .../components/playground/PlaygroundShell.tsx | 1177 + .../playground/PlaygroundTableRoute.tsx | 552 + .../playground/RecordCreateDialog.tsx | 293 + .../playground/RecordDeleteDialog.tsx | 171 + .../playground/RecordUpdateDialog.tsx | 424 + .../playground/SchemaCheckPanel.tsx | 465 + .../components/playground/TableMetaPage.tsx | 1943 ++ .../playground/UnderlyingDataPanel.tsx | 503 + .../field-inputs/CheckboxFieldInput.tsx | 22 + .../field-inputs/DateFieldInput.tsx | 24 + .../field-inputs/DisabledFieldInput.tsx | 36 + .../field-inputs/LinkFieldInput.tsx | 343 + .../field-inputs/NumberFieldInput.tsx | 22 + .../field-inputs/RatingFieldInput.tsx | 46 + .../field-inputs/SelectFieldInput.tsx | 230 + .../field-inputs/TextFieldInput.tsx | 36 + .../playground/field-inputs/index.tsx | 80 + .../playground/field-inputs/types.ts | 14 + .../field-options/ButtonOptions.tsx | 103 + .../field-options/CheckboxOptions.tsx | 28 + .../field-options/ConditionBuilder.tsx | 474 + .../ConditionalLookupOptions.tsx | 145 + .../ConditionalRollupOptions.tsx | 199 + .../playground/field-options/DateOptions.tsx | 116 + .../field-options/FormulaOptions.tsx | 66 + .../playground/field-options/LinkOptions.tsx | 171 + .../field-options/LookupOptions.tsx | 143 + .../field-options/NumberOptions.tsx | 106 + .../field-options/RatingOptions.tsx | 91 + .../field-options/RollupOptions.tsx | 198 + .../field-options/SelectOptions.tsx | 123 + .../field-options/SingleLineTextOptions.tsx | 62 + .../playground/field-options/UserOptions.tsx | 47 + .../playground/fieldOptionsVisitor.tsx | 356 + .../playground/recordValueVisitor.tsx | 654 + .../src/components/ui/alert-dialog.tsx | 115 + apps/playground/src/components/ui/badge.tsx | 39 + apps/playground/src/components/ui/button.tsx | 60 + .../playground/src/components/ui/calendar.tsx | 71 + apps/playground/src/components/ui/card.tsx | 75 + .../playground/src/components/ui/checkbox.tsx | 29 + apps/playground/src/components/ui/command.tsx | 161 + .../src/components/ui/context-menu.tsx | 189 + .../src/components/ui/data-table.tsx | 351 + .../src/components/ui/date-picker.tsx | 50 + apps/playground/src/components/ui/dialog.tsx | 127 + .../src/components/ui/dropdown-menu.tsx | 199 + apps/playground/src/components/ui/form.tsx | 150 + apps/playground/src/components/ui/input.tsx | 21 + apps/playground/src/components/ui/label.tsx | 21 + apps/playground/src/components/ui/popover.tsx | 40 + .../src/components/ui/radio-group.tsx | 43 + .../src/components/ui/scroll-area.tsx | 62 + apps/playground/src/components/ui/select.tsx | 173 + .../src/components/ui/separator.tsx | 28 + apps/playground/src/components/ui/sheet.tsx | 128 + apps/playground/src/components/ui/sidebar.tsx | 772 + .../playground/src/components/ui/skeleton.tsx | 9 + apps/playground/src/components/ui/slider.tsx | 58 + apps/playground/src/components/ui/switch.tsx | 28 + apps/playground/src/components/ui/table.tsx | 90 + apps/playground/src/components/ui/tabs.tsx | 53 + .../playground/src/components/ui/textarea.tsx | 18 + apps/playground/src/components/ui/tooltip.tsx | 55 + apps/playground/src/hooks/use-mobile.ts | 19 + apps/playground/src/hooks/useLogStream.ts | 212 + apps/playground/src/hooks/useRecord.ts | 41 + apps/playground/src/hooks/useRecords.ts | 52 + .../src/integrations/otel/client.ts | 79 + .../integrations/tanstack-query/devtools.tsx | 6 + .../tanstack-query/root-provider.tsx | 18 + apps/playground/src/lib/broadcastChannel.ts | 158 + apps/playground/src/lib/fieldTypeIcons.ts | 59 + .../src/lib/nuqs/tanstackRouterAdapter.tsx | 62 + .../src/lib/orpc/OrpcClientContext.tsx | 26 + .../src/lib/orpc/RemoteOrpcProvider.tsx | 10 + .../src/lib/orpc/SandboxOrpcProvider.tsx | 10 + apps/playground/src/lib/orpcClient.ts | 50 + .../src/lib/playground/constants.ts | 14 + .../src/lib/playground/databaseUrl.ts | 183 + .../src/lib/playground/environment.ts | 76 + apps/playground/src/lib/sandboxContainer.ts | 103 + apps/playground/src/lib/sandboxOrpcClient.ts | 172 + apps/playground/src/lib/shareDb.ts | 395 + apps/playground/src/lib/utils.ts | 6 + apps/playground/src/logo.svg | 12 + apps/playground/src/polyfill.ts | 21 + apps/playground/src/router.tsx | 76 + .../src/routes/$baseId.$tableId.$recordId.tsx | 17 + .../src/routes/$baseId.$tableId.tsx | 21 + apps/playground/src/routes/$baseId.tsx | 637 + apps/playground/src/routes/__root.tsx | 84 + .../api.computed-tasks.$taskId.retry-now.ts | 62 + ...puted-tasks.dead-letters.$taskId.replay.ts | 81 + ...api.computed-tasks.dead-letters.$taskId.ts | 42 + .../routes/api.computed-tasks.dead-letters.ts | 77 + .../src/routes/api.computed-tasks.outbox.ts | 59 + apps/playground/src/routes/api.db.check.ts | 64 + apps/playground/src/routes/api.logs.stream.ts | 69 + .../routes/api.meta.$tableId.check.stream.ts | 203 + apps/playground/src/routes/api.rpc.$.ts | 99 + .../api.schema.$tableId.check.stream.ts | 192 + .../src/routes/api.underlying.$tableId.ts | 85 + apps/playground/src/routes/computed-tasks.tsx | 41 + apps/playground/src/routes/index.tsx | 137 + .../sandbox/$baseId.$tableId.$recordId.tsx | 18 + .../src/routes/sandbox/$baseId.$tableId.tsx | 22 + .../playground/src/routes/sandbox/$baseId.tsx | 18 + apps/playground/src/routes/sandbox/index.tsx | 8 + apps/playground/src/server.ts | 56 + apps/playground/src/server/otel.ts | 156 + .../src/server/playgroundContainer.ts | 110 + .../src/server/playgroundDbContext.ts | 21 + .../playground/src/server/playgroundLogger.ts | 62 + apps/playground/src/server/shareDbServer.ts | 79 + apps/playground/src/server/traceContext.ts | 9 + .../src/server/traceResponseHeaders.ts | 42 + apps/playground/src/server/v2OrpcRouter.ts | 39 + apps/playground/src/styles.css | 385 + apps/playground/src/types/sharedb-pubsub.d.ts | 11 + apps/playground/tsconfig.json | 33 + apps/playground/vite.config.ts | 196 + commitlint.config.js | 4 +- dockers/teable/Dockerfile | 2 +- dottea/.gitignore | 1 + package.json | 14 + packages/common-i18n/lint-staged.config.js | 11 +- packages/common-i18n/src/I18nNamespaces.ts | 2 - .../common-i18n/src/locales/de/common.json | 21 + .../common-i18n/src/locales/de/system.json | 21 - .../common-i18n/src/locales/de/table.json | 2 + .../common-i18n/src/locales/en/common.json | 21 + .../common-i18n/src/locales/en/system.json | 21 - .../common-i18n/src/locales/en/table.json | 2 + .../common-i18n/src/locales/es/common.json | 21 + .../common-i18n/src/locales/es/system.json | 21 - .../common-i18n/src/locales/es/table.json | 2 + .../common-i18n/src/locales/fr/common.json | 21 + .../common-i18n/src/locales/fr/system.json | 21 - .../common-i18n/src/locales/fr/table.json | 2 + .../common-i18n/src/locales/it/common.json | 21 + .../common-i18n/src/locales/it/system.json | 21 - .../common-i18n/src/locales/it/table.json | 2 + .../common-i18n/src/locales/ja/common.json | 21 + .../common-i18n/src/locales/ja/system.json | 21 - .../common-i18n/src/locales/ja/table.json | 2 + .../common-i18n/src/locales/ru/common.json | 21 + .../common-i18n/src/locales/ru/system.json | 21 - .../common-i18n/src/locales/ru/table.json | 2 + .../common-i18n/src/locales/tr/common.json | 23 +- .../common-i18n/src/locales/tr/system.json | 21 - .../common-i18n/src/locales/tr/table.json | 2 + .../common-i18n/src/locales/uk/common.json | 23 +- .../common-i18n/src/locales/uk/system.json | 21 - .../common-i18n/src/locales/uk/table.json | 2 + .../common-i18n/src/locales/zh/common.json | 21 + .../common-i18n/src/locales/zh/system.json | 21 - .../common-i18n/src/locales/zh/table.json | 2 + packages/core/package.json | 2 +- packages/core/src/formula/evaluate.ts | 4 +- packages/core/src/formula/index.ts | 12 +- packages/core/src/formula/parser/Formula.ts | 1169 - .../core/src/formula/parser/FormulaLexer.ts | 509 - .../core/src/formula/parser/FormulaVisitor.ts | 171 - packages/core/src/formula/visitor.ts | 26 +- .../abstract/formula.field.abstract.ts | 6 +- .../models/field/derivate/formula.field.ts | 4 +- packages/core/tsconfig.build.json | 5 + packages/core/tsconfig.json | 9 +- packages/db-main-prisma/package.json | 3 +- .../migration.sql | 90 + .../migration.sql | 13 + .../migration.sql | 35 + .../migration.sql | 17 + .../migration.sql | 6 + .../migration.sql | 1 + .../prisma/postgres/schema.prisma | 85 + .../migration.sql | 81 + .../migration.sql | 22 + .../migration.sql | 1 + .../prisma/sqlite/schema.prisma | 75 + .../db-main-prisma/prisma/template.prisma | 85 + packages/formula/.eslintrc.cjs | 31 + packages/formula/.gitignore | 12 + packages/formula/package.json | 62 + .../src}/conversion.visitor.spec.ts | 0 .../src}/conversion.visitor.ts | 4 +- .../formula => formula/src}/error.listener.ts | 0 .../src}/field-reference.util.ts | 0 .../src}/field-reference.visitor.spec.ts | 0 .../src}/field-reference.visitor.ts | 2 +- .../function-call-collector.visitor.spec.ts | 0 .../src}/function-call-collector.visitor.ts | 2 +- packages/formula/src/index.ts | 15 + .../formula => formula/src}/parse-formula.ts | 0 .../formula => formula/src}/parser/Formula.g4 | 0 .../src}/parser/Formula.interp | 0 .../src}/parser/Formula.tokens | 0 packages/formula/src/parser/Formula.ts | 1431 ++ .../src}/parser/FormulaLexer.g4 | 0 .../src}/parser/FormulaLexer.interp | 0 .../src}/parser/FormulaLexer.tokens | 0 packages/formula/src/parser/FormulaLexer.ts | 757 + packages/formula/src/parser/FormulaVisitor.ts | 168 + .../formula => formula/src}/parser/README.md | 0 packages/formula/tsconfig.build.json | 5 + packages/formula/tsconfig.eslint.json | 20 + packages/formula/tsconfig.json | 24 + packages/formula/tsdown.config.ts | 23 + packages/formula/vitest.config.ts | 41 + packages/formula/vitest.setup.js | 1 + packages/i18n-keys/package.json | 52 + packages/i18n-keys/src/index.ts | 94 + packages/i18n-keys/tsconfig.build.json | 5 + packages/i18n-keys/tsconfig.json | 23 + packages/i18n-keys/tsdown.config.ts | 4 + packages/openapi/src/admin/setting/update.ts | 23 + .../openapi/src/record/upload-attachment.ts | 2 + packages/sdk/lint-staged.config.js | 11 +- packages/sdk/package.json | 1 + .../src/components/cell-value/CellValue.tsx | 364 +- .../comment/comment-editor/Editor.tsx | 53 +- .../src/components/editor/button/Editor.tsx | 6 +- .../src/components/editor/formula/Editor.tsx | 3 +- .../components/editor/formula/constants.ts | 3 +- .../editor/formula/extensions/token.ts | 2 +- .../src/components/editor/formula/visitor.ts | 4 +- .../filterDatePicker/DateRangePicker.tsx | 9 +- .../filterDatePicker/FilterDatePicker.tsx | 6 +- .../markdown-editor/EditorContainer.tsx | 53 +- .../src/components/plate/ui/image-element.tsx | 6 +- .../src/components/plate/ui/image-preview.tsx | 2 +- .../components/plate/ui/inline-combobox.tsx | 2 +- .../plate/ui/link-floating-toolbar.tsx | 5 +- .../plate/ui/media-placeholder-element.tsx | 6 +- .../components/plate/ui/paragraph-element.tsx | 6 +- packages/sdk/src/model/record/record.ts | 11 +- packages/sdk/tsconfig.json | 10 +- packages/ui-lib/lint-staged.config.js | 11 +- packages/ui-lib/src/base/dnd-kit/index.tsx | 7 +- packages/ui-lib/src/shadcn/ui/chart.tsx | 270 +- packages/ui-lib/src/shadcn/ui/command.tsx | 41 +- packages/ui-lib/src/shadcn/ui/form.tsx | 2 +- packages/ui-lib/src/shadcn/ui/input-otp.tsx | 105 +- packages/ui-lib/src/shadcn/ui/resizable.tsx | 13 +- packages/v2/adapter-bullmq/package.json | 10 + packages/v2/adapter-bullmq/src/index.ts | 1 + .../adapter-csv-parser-papaparse/package.json | 51 + .../src/PapaparseCsvParser.ts | 340 + .../adapter-csv-parser-papaparse/src/index.ts | 1 + .../tsconfig.build.json | 10 + .../tsconfig.eslint.json | 20 + .../tsconfig.json | 29 + .../tsdown.config.ts | 4 + .../adapter-db-postgres-bun-sql/.eslintrc.cjs | 29 + .../v2/adapter-db-postgres-bun-sql/.gitignore | 12 + .../ARCHITECTURE.md | 19 + .../adapter-db-postgres-bun-sql/package.json | 48 + .../src/createDb.ts | 22 + .../src/di/register.ts | 27 + .../adapter-db-postgres-bun-sql/src/index.ts | 12 + .../tsconfig.build.json | 10 + .../tsconfig.eslint.json | 20 + .../adapter-db-postgres-bun-sql/tsconfig.json | 31 + .../tsdown.config.ts | 4 + .../v2/adapter-db-postgres-pg/.eslintrc.cjs | 29 + packages/v2/adapter-db-postgres-pg/.gitignore | 13 + .../v2/adapter-db-postgres-pg/ARCHITECTURE.md | 20 + .../v2/adapter-db-postgres-pg/package.json | 48 + .../v2/adapter-db-postgres-pg/src/config.ts | 2 + .../v2/adapter-db-postgres-pg/src/createDb.ts | 93 + .../adapter-db-postgres-pg/src/di/register.ts | 25 + .../adapter-db-postgres-pg/src/di/tokens.ts | 1 + .../v2/adapter-db-postgres-pg/src/index.ts | 5 + .../adapter-db-postgres-pg/src/unitOfWork.ts | 6 + .../tsconfig.build.json | 10 + .../tsconfig.eslint.json | 20 + .../v2/adapter-db-postgres-pg/tsconfig.json | 30 + .../adapter-db-postgres-pg/tsdown.config.ts | 4 + .../adapter-db-postgres-pglite/.eslintrc.cjs | 28 + .../ARCHITECTURE.md | 23 + .../adapter-db-postgres-pglite/package.json | 48 + .../src/createDb.ts | 20 + .../src/di/register.ts | 27 + .../adapter-db-postgres-pglite/src/index.ts | 12 + .../src/kyselyPgliteBrowser.ts | 108 + .../tsconfig.build.json | 10 + .../tsconfig.eslint.json | 20 + .../adapter-db-postgres-pglite/tsconfig.json | 30 + .../tsdown.config.ts | 4 + .../.eslintrc.cjs | 29 + .../adapter-db-postgres-postgresjs/.gitignore | 12 + .../ARCHITECTURE.md | 19 + .../package.json | 48 + .../src/createDb.ts | 27 + .../src/di/register.ts | 27 + .../src/index.ts | 12 + .../tsconfig.build.json | 10 + .../tsconfig.eslint.json | 20 + .../tsconfig.json | 30 + .../tsdown.config.ts | 4 + .../adapter-db-postgres-shared/.eslintrc.cjs | 29 + .../ARCHITECTURE.md | 20 + .../adapter-db-postgres-shared/package.json | 48 + .../adapter-db-postgres-shared/src/config.ts | 18 + .../src/di/tokens.ts | 4 + .../adapter-db-postgres-shared/src/index.ts | 3 + .../src/unitOfWork.ts | 136 + .../tsconfig.build.json | 10 + .../tsconfig.eslint.json | 20 + .../adapter-db-postgres-shared/tsconfig.json | 37 + .../tsdown.config.ts | 4 + .../v2/adapter-logger-console/.eslintrc.cjs | 29 + packages/v2/adapter-logger-console/.gitignore | 12 + .../v2/adapter-logger-console/package.json | 48 + .../src/ConsoleLogger.ts | 39 + .../v2/adapter-logger-console/src/index.ts | 1 + .../tsconfig.build.json | 10 + .../tsconfig.eslint.json | 20 + .../v2/adapter-logger-console/tsconfig.json | 29 + .../adapter-logger-console/tsdown.config.ts | 4 + .../adapter-logger-console/vitest.config.ts | 29 + packages/v2/adapter-logger-pino/.eslintrc.cjs | 29 + packages/v2/adapter-logger-pino/.gitignore | 12 + .../v2/adapter-logger-pino/ARCHITECTURE.md | 20 + packages/v2/adapter-logger-pino/package.json | 48 + .../src/BroadcastLogger.ts | 141 + .../src/PinoLoggerAdapter.ts | 47 + packages/v2/adapter-logger-pino/src/index.ts | 3 + packages/v2/adapter-logger-pino/src/pino.ts | 18 + .../adapter-logger-pino/tsconfig.build.json | 10 + .../adapter-logger-pino/tsconfig.eslint.json | 20 + packages/v2/adapter-logger-pino/tsconfig.json | 29 + .../v2/adapter-logger-pino/tsdown.config.ts | 4 + .../v2/adapter-logger-pino/vitest.config.ts | 29 + .../.eslintrc.cjs | 30 + .../ARCHITECTURE.md | 18 + .../package.json | 49 + .../src/BroadcastChannelRealtimeEngine.ts | 45 + .../src/BroadcastChannelRealtimeHub.ts | 364 + .../src/di/register.ts | 79 + .../src/di/tokens.ts | 3 + .../src/index.ts | 4 + .../tsconfig.build.json | 10 + .../tsconfig.eslint.json | 20 + .../tsconfig.json | 30 + .../tsdown.config.ts | 4 + .../vitest.config.ts | 9 + .../v2/adapter-realtime-sharedb/.eslintrc.cjs | 30 + .../adapter-realtime-sharedb/ARCHITECTURE.md | 21 + .../v2/adapter-realtime-sharedb/package.json | 54 + .../src/ShareDbBackendPublisher.ts | 122 + .../src/ShareDbPubSubPublisher.ts | 32 + .../src/ShareDbPublisher.ts | 9 + .../src/ShareDbRealtimeEngine.spec.ts | 404 + .../src/ShareDbRealtimeEngine.ts | 141 + .../src/ShareDbWebSocketServer.ts | 39 + .../src/di/register.ts | 76 + .../adapter-realtime-sharedb/src/di/tokens.ts | 3 + .../v2/adapter-realtime-sharedb/src/index.ts | 8 + .../src/websocket-json-stream.d.ts | 4 + .../tsconfig.build.json | 10 + .../tsconfig.eslint.json | 20 + .../v2/adapter-realtime-sharedb/tsconfig.json | 30 + .../adapter-realtime-sharedb/tsdown.config.ts | 4 + .../adapter-realtime-sharedb/vitest.config.ts | 38 + .../v2/adapter-realtime-yjs/.eslintrc.cjs | 29 + .../v2/adapter-realtime-yjs/ARCHITECTURE.md | 15 + packages/v2/adapter-realtime-yjs/package.json | 48 + .../src/YjsRealtimeEngine.ts | 36 + packages/v2/adapter-realtime-yjs/src/index.ts | 1 + .../adapter-realtime-yjs/tsconfig.build.json | 10 + .../adapter-realtime-yjs/tsconfig.eslint.json | 20 + .../v2/adapter-realtime-yjs/tsconfig.json | 30 + .../v2/adapter-realtime-yjs/tsdown.config.ts | 4 + .../v2/adapter-realtime-yjs/vitest.config.ts | 29 + .../adapter-repository-postgres/.eslintrc.cjs | 31 + .../v2/adapter-repository-postgres/.gitignore | 13 + .../adapter-repository-postgres/package.json | 60 + .../adapter-repository-postgres/src/config.ts | 38 + .../src/db/schema.ts | 338 + .../src/db/tableDbMeta.ts | 12 + .../src/di/register.ts | 74 + .../src/di/tokens.ts | 6 + .../adapter-repository-postgres/src/index.ts | 13 + .../adapter-repository-postgres/src/naming.ts | 43 + .../repositories/PostgresBaseRepository.ts | 195 + .../PostgresTableRepository.spec.ts | 1508 ++ .../repositories/PostgresTableRepository.ts | 1581 ++ .../PostgresTableRowLimitService.ts | 73 + .../TableFieldPersistenceBuilder.ts | 399 + .../visitors/FieldStorageTypeVisitor.spec.ts | 105 + .../visitors/FieldStorageTypeVisitor.ts | 216 + .../visitors/TableMetaUpdateVisitor.ts | 235 + .../TableRecordConditionWhereVisitor.spec.ts | 545 + .../TableRecordConditionWhereVisitor.ts | 1510 ++ .../TableRecordSelectColumnsVisitor.ts | 172 + .../visitors/TableWhereVisitor.ts | 151 + ...leRecordConditionWhereVisitor.spec.ts.snap | 1188 + .../tsconfig.build.json | 10 + .../tsconfig.eslint.json | 20 + .../adapter-repository-postgres/tsconfig.json | 39 + .../tsdown.config.ts | 4 + .../vitest.config.ts | 41 + .../.eslintrc.cjs | 39 + .../package.json | 60 + .../src/di/index.ts | 2 + .../src/di/register.ts | 249 + .../src/di/tokens.ts | 3 + .../src/index.ts | 24 + .../src/meta/MetaChecker.ts | 265 + .../src/meta/MetaValidationContext.ts | 315 + .../src/meta/MetaValidationResult.ts | 211 + .../src/meta/MetaValidationVisitor.ts | 784 + .../src/meta/index.ts | 32 + .../src/record/ARCHITECTURE.md | 23 + .../src/record/computed/ARCHITECTURE.md | 343 + .../computed/ComputedFieldBackfillService.ts | 437 + .../record/computed/ComputedFieldUpdater.ts | 1825 ++ .../src/record/computed/ComputedUpdateLock.ts | 322 + .../record/computed/ComputedUpdatePlanner.ts | 1296 + .../src/record/computed/ComputedUpdateRun.ts | 64 + .../record/computed/FieldDependencyGraph.ts | 1262 + .../computed/UpdateFromSelectBuilder.ts | 565 + .../__tests__/ComputedFieldUpdater.spec.ts | 680 + .../__tests__/ComputedUpdateLock.spec.ts | 90 + .../__tests__/ComputedUpdatePlanner.spec.ts | 795 + .../FieldDependencyGraph.pglite.spec.ts | 329 + .../computed/__tests__/SameTableBatch.spec.ts | 301 + .../UpdateFromSelectBuilder.lookup.spec.ts | 443 + .../__tests__/UpdateFromSelectBuilder.spec.ts | 248 + .../__tests__/UserFields.pglite.spec.ts | 315 + .../src/record/computed/index.ts | 16 + .../computed/isPersistedAsGeneratedColumn.ts | 95 + .../outbox/ComputedUpdateOutbox.spec.ts | 418 + .../computed/outbox/ComputedUpdateOutbox.ts | 1407 ++ .../ComputedUpdateOutboxPayload.spec.ts | 74 + .../outbox/ComputedUpdateOutboxPayload.ts | 359 + .../outbox/ComputedUpdateSeedPayload.ts | 350 + .../outbox/FieldBackfillOutboxPayload.ts | 143 + .../computed/outbox/IComputedUpdateOutbox.ts | 123 + ...mputedUpdateOutbox.deadlock.pglite.spec.ts | 231 + .../strategies/AsyncWithRetryStrategy.ts | 37 + .../HybridWithOutboxStrategy.spec.ts | 392 + .../strategies/HybridWithOutboxStrategy.ts | 636 + .../computed/strategies/IUpdateStrategy.ts | 31 + .../strategies/SyncInTransactionStrategy.ts | 176 + .../src/record/computed/strategies/index.ts | 4 + .../record/computed/types/UpdateTrigger.ts | 156 + .../src/record/computed/types/index.ts | 1 + .../worker/ComputedUpdatePollingService.ts | 248 + .../worker/ComputedUpdateWorker.spec.ts | 473 + .../computed/worker/ComputedUpdateWorker.ts | 883 + .../src/record/di/index.ts | 2 + .../src/record/di/register.ts | 171 + .../src/record/di/tokens.ts | 29 + .../src/record/index.ts | 14 + .../query-builder/FieldOutputColumnVisitor.ts | 166 + .../query-builder/ITableRecordQueryBuilder.ts | 132 + .../TableRecordQueryBuilderManager.ts | 115 + .../ComputedFieldSelectExpressionVisitor.ts | 654 + ...SelectExpressionVisitor.userFields.spec.ts | 156 + .../ComputedTableRecordQueryBuilder.spec.ts | 1907 ++ .../ComputedTableRecordQueryBuilder.ts | 1346 + .../computed/FieldReferenceSqlVisitor.spec.ts | 398 + .../computed/FieldReferenceSqlVisitor.ts | 497 + .../SameTableBatchQueryBuilder.spec.ts | 248 + .../computed/SameTableBatchQueryBuilder.ts | 455 + ...mputedTableRecordQueryBuilder.spec.ts.snap | 53 + .../FieldReferenceSqlVisitor.spec.ts.snap | 166 + .../record/query-builder/computed/index.ts | 2 + .../src/record/query-builder/index.ts | 20 + .../insert/RecordInsertBuilder.spec.ts | 172 + .../insert/RecordInsertBuilder.ts | 638 + .../RecordInsertBuilder.userFields.spec.ts | 181 + .../src/record/query-builder/insert/index.ts | 10 + .../stored/StoredFieldSelectVisitor.ts | 159 + .../StoredTableRecordQueryBuilder.spec.ts | 367 + .../stored/StoredTableRecordQueryBuilder.ts | 225 + .../src/record/query-builder/stored/index.ts | 2 + .../update/BatchRecordUpdateBuilder.ts | 367 + .../update/BatchUpdateSqlBuilder.spec.ts | 416 + .../update/BatchUpdateSqlBuilder.ts | 290 + .../update/RecordUpdateBuilder.ts | 558 + .../BatchUpdateSqlBuilder.spec.ts.snap | 103 + .../src/record/query-builder/update/index.ts | 5 + .../PostgresAttachmentLookupService.ts | 102 + ...stgresRecordOrderCalculator.pglite.spec.ts | 260 + .../PostgresRecordOrderCalculator.ts | 179 + .../PostgresTableRecordQueryRepository.ts | 475 + ...stgresTableRecordRepository.delete.spec.ts | 449 + ...sTableRecordRepository.exclusivity.spec.ts | 312 + .../PostgresTableRecordRepository.ts | 2457 ++ ...stgresTableRecordRepository.update.spec.ts | 638 + .../repository/PostgresUserLookupService.ts | 55 + .../src/record/repository/index.ts | 5 + .../record/visitors/CellValueMutateVisitor.ts | 861 + .../visitors/FieldDatabaseValueVisitor.ts | 210 + .../visitors/FieldDeleteValueVisitor.ts | 235 + .../visitors/FieldInsertValueVisitor.ts | 390 + .../visitors/FieldSqlLiteralVisitor.spec.ts | 698 + .../record/visitors/FieldSqlLiteralVisitor.ts | 243 + .../visitors/LinkChangeCollectorVisitor.ts | 387 + .../LinkExclusivityConstraintCollector.ts | 300 + .../TableRecordConditionWhereVisitor.ts | 1611 ++ .../src/record/visitors/index.ts | 36 + .../src/schema/config.ts | 28 + .../src/schema/di/register.ts | 57 + .../src/schema/di/tokens.ts | 4 + .../src/schema/index.ts | 8 + .../src/schema/naming.ts | 43 + .../PostgresTableSchemaRepository.spec.ts | 203 + .../PostgresTableSchemaRepository.ts | 314 + .../src/schema/repositories/index.ts | 1 + .../src/schema/rules/ARCHITECTURE.md | 158 + .../schema/rules/checker/SchemaCheckResult.ts | 166 + .../src/schema/rules/checker/SchemaChecker.ts | 421 + .../src/schema/rules/checker/index.ts | 2 + .../context/PostgresSchemaIntrospector.ts | 309 + .../rules/context/SchemaIntrospector.ts | 112 + .../schema/rules/context/SchemaRuleContext.ts | 55 + ...chemaIntrospector.integration.spec.ts.snap | 115 + .../src/schema/rules/context/index.ts | 9 + .../src/schema/rules/core/ISchemaRule.ts | 92 + .../src/schema/rules/core/index.ts | 5 + .../schema/rules/field/ColumnExistsRule.ts | 148 + .../rules/field/ColumnUniqueConstraintRule.ts | 118 + .../src/schema/rules/field/FieldMetaRule.ts | 131 + .../rules/field/FieldSchemaRulesFactory.ts | 374 + .../src/schema/rules/field/FkColumnRule.ts | 96 + .../src/schema/rules/field/ForeignKeyRule.ts | 106 + .../schema/rules/field/GeneratedColumnRule.ts | 133 + .../src/schema/rules/field/IndexRule.ts | 77 + .../schema/rules/field/JunctionTableRule.ts | 441 + .../rules/field/LinkSymmetricFieldRule.ts | 155 + .../schema/rules/field/LinkValueColumnRule.ts | 81 + .../rules/field/NotNullConstraintRule.ts | 100 + .../src/schema/rules/field/OrderColumnRule.ts | 90 + .../src/schema/rules/field/ReferenceRule.ts | 160 + .../rules/field/SchemaRules.pglite.spec.ts | 1742 ++ .../src/schema/rules/field/UniqueIndexRule.ts | 97 + .../src/schema/rules/field/index.ts | 25 + .../schema/rules/helpers/StatementBuilders.ts | 136 + .../src/schema/rules/helpers/index.ts | 13 + .../src/schema/rules/index.ts | 72 + .../rules/resolver/SchemaRuleResolver.spec.ts | 120 + .../rules/resolver/SchemaRuleResolver.ts | 186 + .../src/schema/rules/resolver/index.ts | 6 + .../src/schema/visitors/ARCHITECTURE.md | 41 + .../visitors/FieldValueDuplicateVisitor.ts | 220 + .../LinkFieldValueDuplicateVisitor.ts | 127 + .../PostgresTableSchemaFieldColumn.ts | 228 + .../PostgresTableSchemaFieldCreateVisitor.ts | 307 + .../PostgresTableSchemaFieldDeleteVisitor.ts | 238 + .../visitors/TableAddFieldCollectorVisitor.ts | 76 + .../visitors/TableSchemaUpdateVisitor.ts | 194 + .../__tests__/LookupColumnType.pglite.spec.ts | 748 + .../src/schema/visitors/index.ts | 7 + .../src/shared/db.ts | 31 + .../src/shared/errors.spec.ts | 240 + .../src/shared/errors.ts | 125 + .../src/shared/index.ts | 2 + .../src/utils/detectPgCapability.ts | 69 + .../src/utils/index.ts | 1 + .../tsconfig.build.json | 9 + .../tsconfig.eslint.json | 5 + .../tsconfig.json | 54 + .../tsdown.config.ts | 6 + .../vitest.config.ts | 48 + packages/v2/benchmark-bun/.eslintrc.cjs | 27 + packages/v2/benchmark-bun/.gitignore | 13 + packages/v2/benchmark-bun/package.json | 65 + .../v2/benchmark-bun/src/bench-context.ts | 233 + .../src/computed-cte-batch.bench.ts | 220 + .../benchmark-bun/src/create-record.bench.ts | 180 + .../benchmark-bun/src/create-table.bench.ts | 75 + .../v2/benchmark-bun/src/db-adapter.bench.ts | 187 + .../src/get-table-by-id.bench.ts | 95 + packages/v2/benchmark-bun/src/index.ts | 4 + packages/v2/benchmark-bun/src/rpc-client.ts | 22 + packages/v2/benchmark-bun/src/run.ts | 39 + packages/v2/benchmark-bun/tsconfig.build.json | 10 + .../v2/benchmark-bun/tsconfig.eslint.json | 20 + packages/v2/benchmark-bun/tsconfig.json | 88 + packages/v2/benchmark-bun/tsdown.config.ts | 4 + packages/v2/benchmark-node/.eslintrc.cjs | 29 + packages/v2/benchmark-node/.gitignore | 1 + packages/v2/benchmark-node/package.json | 59 + .../src/computed-cte-batch.bench.ts | 334 + .../src/computed-fanout.bench.ts | 368 + .../benchmark-node/src/create-record.bench.ts | 310 + .../benchmark-node/src/create-table.bench.ts | 219 + .../v2/benchmark-node/src/db-adapter.bench.ts | 222 + .../src/get-table-by-id.bench.ts | 227 + packages/v2/benchmark-node/src/index.ts | 1 + .../v2/benchmark-node/tsconfig.build.json | 10 + .../v2/benchmark-node/tsconfig.eslint.json | 20 + packages/v2/benchmark-node/tsconfig.json | 70 + packages/v2/benchmark-node/tsdown.config.ts | 4 + packages/v2/benchmark-node/vitest.config.ts | 39 + packages/v2/command-explain/.eslintrc.cjs | 47 + packages/v2/command-explain/package.json | 55 + .../src/analyzers/CreateRecordAnalyzer.ts | 714 + .../src/analyzers/DeleteRecordsAnalyzer.ts | 792 + .../src/analyzers/ICommandAnalyzer.ts | 23 + .../src/analyzers/PasteCommandAnalyzer.ts | 575 + .../src/analyzers/UpdateRecordAnalyzer.ts | 792 + .../v2/command-explain/src/analyzers/index.ts | 5 + packages/v2/command-explain/src/di/index.ts | 2 + .../v2/command-explain/src/di/register.ts | 43 + packages/v2/command-explain/src/di/tokens.ts | 12 + packages/v2/command-explain/src/index.ts | 14 + .../src/service/ExplainService.ts | 89 + .../v2/command-explain/src/service/index.ts | 1 + .../src/types/ComplexityAssessment.ts | 28 + .../src/types/ExplainOptions.ts | 38 + .../src/types/ExplainResult.ts | 324 + .../v2/command-explain/src/types/index.ts | 3 + .../src/utils/ComplexityCalculator.ts | 208 + .../utils/ComputedUpdateLockInfoBuilder.ts | 95 + .../src/utils/ComputedUpdateReasonBuilder.ts | 202 + .../src/utils/DirtyTableSetupBuilder.ts | 65 + .../src/utils/LinkRecordLockInfoBuilder.ts | 78 + .../src/utils/SqlExplainRunner.ts | 407 + .../v2/command-explain/src/utils/index.ts | 3 + .../v2/command-explain/tsconfig.build.json | 10 + .../v2/command-explain/tsconfig.eslint.json | 20 + packages/v2/command-explain/tsconfig.json | 53 + packages/v2/command-explain/tsdown.config.ts | 6 + packages/v2/command-explain/vitest.config.ts | 52 + packages/v2/container-browser/.eslintrc.cjs | 27 + packages/v2/container-browser/.gitignore | 13 + packages/v2/container-browser/ARCHITECTURE.md | 21 + packages/v2/container-browser/package.json | 52 + packages/v2/container-browser/src/index.ts | 215 + .../v2/container-browser/tsconfig.build.json | 10 + .../v2/container-browser/tsconfig.eslint.json | 20 + packages/v2/container-browser/tsconfig.json | 46 + .../v2/container-browser/tsdown.config.ts | 4 + .../v2/container-browser/vitest.config.ts | 29 + packages/v2/container-bun-test/.eslintrc.cjs | 27 + packages/v2/container-bun-test/.gitignore | 12 + packages/v2/container-bun-test/package.json | 57 + packages/v2/container-bun-test/src/index.ts | 184 + .../v2/container-bun-test/tsconfig.build.json | 10 + .../container-bun-test/tsconfig.eslint.json | 20 + packages/v2/container-bun-test/tsconfig.json | 49 + .../v2/container-bun-test/tsdown.config.ts | 4 + .../v2/container-bun-test/vitest.config.ts | 29 + packages/v2/container-bun/.eslintrc.cjs | 27 + packages/v2/container-bun/.gitignore | 13 + packages/v2/container-bun/package.json | 51 + packages/v2/container-bun/src/index.ts | 166 + packages/v2/container-bun/tsconfig.build.json | 10 + .../v2/container-bun/tsconfig.eslint.json | 20 + packages/v2/container-bun/tsconfig.json | 40 + packages/v2/container-bun/tsdown.config.ts | 4 + packages/v2/container-bun/vitest.config.ts | 29 + packages/v2/container-node-test/.eslintrc.cjs | 26 + packages/v2/container-node-test/.gitignore | 13 + packages/v2/container-node-test/package.json | 60 + .../src/ComputedPlanSnapshot.ts | 155 + .../v2/container-node-test/src/SpyLogger.ts | 169 + packages/v2/container-node-test/src/index.ts | 412 + .../container-node-test/tsconfig.build.json | 10 + .../container-node-test/tsconfig.eslint.json | 20 + packages/v2/container-node-test/tsconfig.json | 48 + .../v2/container-node-test/tsdown.config.ts | 4 + .../v2/container-node-test/vitest.config.ts | 36 + packages/v2/container-node/.eslintrc.cjs | 27 + packages/v2/container-node/.gitignore | 13 + packages/v2/container-node/package.json | 54 + packages/v2/container-node/src/index.ts | 178 + .../v2/container-node/tsconfig.build.json | 10 + .../v2/container-node/tsconfig.eslint.json | 20 + packages/v2/container-node/tsconfig.json | 44 + packages/v2/container-node/tsdown.config.ts | 4 + packages/v2/container-node/vitest.config.ts | 29 + .../v2/contract-http-client/.eslintrc.cjs | 27 + packages/v2/contract-http-client/.gitignore | 13 + packages/v2/contract-http-client/package.json | 50 + packages/v2/contract-http-client/src/index.ts | 91 + .../contract-http-client/tsconfig.build.json | 10 + .../contract-http-client/tsconfig.eslint.json | 20 + .../v2/contract-http-client/tsconfig.json | 31 + .../v2/contract-http-client/tsdown.config.ts | 4 + .../v2/contract-http-client/vitest.config.ts | 29 + .../v2/contract-http-express/.eslintrc.cjs | 27 + packages/v2/contract-http-express/.gitignore | 12 + .../v2/contract-http-express/package.json | 52 + .../v2/contract-http-express/src/index.ts | 27 + .../contract-http-express/tsconfig.build.json | 10 + .../tsconfig.eslint.json | 20 + .../v2/contract-http-express/tsconfig.json | 48 + .../v2/contract-http-express/tsdown.config.ts | 4 + .../v2/contract-http-express/vitest.config.ts | 29 + .../v2/contract-http-fastify/.eslintrc.cjs | 27 + packages/v2/contract-http-fastify/.gitignore | 13 + .../v2/contract-http-fastify/package.json | 50 + .../v2/contract-http-fastify/src/index.ts | 38 + .../contract-http-fastify/tsconfig.build.json | 10 + .../tsconfig.eslint.json | 20 + .../v2/contract-http-fastify/tsconfig.json | 48 + .../v2/contract-http-fastify/tsdown.config.ts | 4 + .../v2/contract-http-fastify/vitest.config.ts | 29 + packages/v2/contract-http-hono/.eslintrc.cjs | 27 + packages/v2/contract-http-hono/.gitignore | 1 + packages/v2/contract-http-hono/package.json | 50 + packages/v2/contract-http-hono/src/index.ts | 31 + .../v2/contract-http-hono/tsconfig.build.json | 10 + .../contract-http-hono/tsconfig.eslint.json | 20 + packages/v2/contract-http-hono/tsconfig.json | 48 + .../v2/contract-http-hono/tsdown.config.ts | 4 + .../v2/contract-http-hono/vitest.config.ts | 29 + .../.eslintrc.cjs | 27 + .../contract-http-implementation/.gitignore | 13 + .../contract-http-implementation/package.json | 58 + .../src/handlers/bases/createBase.ts | 52 + .../src/handlers/bases/index.ts | 2 + .../src/handlers/bases/listBases.ts | 52 + .../src/handlers/index.ts | 2 + .../src/handlers/tables/clear.ts | 49 + .../src/handlers/tables/createField.ts | 52 + .../src/handlers/tables/createRecord.ts | 54 + .../src/handlers/tables/createRecords.ts | 52 + .../src/handlers/tables/createTable.ts | 52 + .../src/handlers/tables/createTables.ts | 52 + .../src/handlers/tables/deleteByRange.ts | 52 + .../src/handlers/tables/deleteField.ts | 52 + .../src/handlers/tables/deleteRecords.ts | 52 + .../src/handlers/tables/deleteTable.ts | 52 + .../src/handlers/tables/duplicateRecord.ts | 54 + .../src/handlers/tables/explainCommand.ts | 200 + .../src/handlers/tables/getRecordById.ts | 52 + .../src/handlers/tables/getTableById.ts | 52 + .../src/handlers/tables/importCsv.ts | 52 + .../src/handlers/tables/importRecords.ts | 78 + .../src/handlers/tables/index.ts | 22 + .../src/handlers/tables/listTableRecords.ts | 52 + .../src/handlers/tables/listTables.ts | 52 + .../src/handlers/tables/paste.ts | 49 + .../src/handlers/tables/renameTable.ts | 52 + .../src/handlers/tables/reorderRecords.ts | 52 + .../src/handlers/tables/updateRecord.ts | 52 + .../contract-http-implementation/src/index.ts | 1 + .../src/router.ts | 878 + .../tsconfig.build.json | 10 + .../tsconfig.eslint.json | 20 + .../tsconfig.json | 71 + .../tsdown.config.ts | 7 + .../vitest.config.ts | 29 + .../v2/contract-http-openapi/.eslintrc.cjs | 27 + packages/v2/contract-http-openapi/.gitignore | 12 + .../v2/contract-http-openapi/package.json | 50 + .../v2/contract-http-openapi/src/generate.ts | 28 + .../v2/contract-http-openapi/src/index.ts | 2 + .../v2/contract-http-openapi/src/openapi.ts | 124 + .../contract-http-openapi/tsconfig.build.json | 10 + .../tsconfig.eslint.json | 20 + .../v2/contract-http-openapi/tsconfig.json | 31 + .../v2/contract-http-openapi/tsdown.config.ts | 4 + .../v2/contract-http-openapi/vitest.config.ts | 29 + packages/v2/contract-http/.eslintrc.cjs | 27 + packages/v2/contract-http/.gitignore | 12 + packages/v2/contract-http/package.json | 51 + .../v2/contract-http/src/base/createBase.ts | 50 + packages/v2/contract-http/src/base/dto.ts | 18 + .../v2/contract-http/src/base/listBases.ts | 64 + packages/v2/contract-http/src/contract.ts | 357 + packages/v2/contract-http/src/index.ts | 32 + .../v2/contract-http/src/shared/container.ts | 6 + .../contract-http/src/shared/domainEvent.ts | 14 + packages/v2/contract-http/src/shared/http.ts | 73 + .../v2/contract-http/src/shared/neverthrow.ts | 11 + packages/v2/contract-http/src/table/clear.ts | 45 + .../v2/contract-http/src/table/createField.ts | 50 + .../contract-http/src/table/createRecord.ts | 79 + .../contract-http/src/table/createRecords.ts | 92 + .../v2/contract-http/src/table/createTable.ts | 50 + .../contract-http/src/table/createTables.ts | 59 + .../contract-http/src/table/deleteByRange.ts | 57 + .../v2/contract-http/src/table/deleteField.ts | 50 + .../contract-http/src/table/deleteRecords.ts | 51 + .../v2/contract-http/src/table/deleteTable.ts | 50 + packages/v2/contract-http/src/table/dto.ts | 879 + .../src/table/duplicateRecord.ts | 85 + .../contract-http/src/table/explainCommand.ts | 352 + .../contract-http/src/table/getRecordById.ts | 43 + .../contract-http/src/table/getTableById.ts | 43 + .../v2/contract-http/src/table/importCsv.ts | 47 + .../contract-http/src/table/importRecords.ts | 44 + .../src/table/listTableRecords.ts | 80 + .../v2/contract-http/src/table/listTables.ts | 46 + .../src/table/mapTableDtoToDomain.ts | 485 + packages/v2/contract-http/src/table/paste.ts | 53 + .../v2/contract-http/src/table/recordDto.ts | 23 + .../v2/contract-http/src/table/renameTable.ts | 50 + .../contract-http/src/table/reorderRecords.ts | 50 + .../contract-http/src/table/updateRecord.ts | 85 + packages/v2/contract-http/tsconfig.build.json | 10 + .../v2/contract-http/tsconfig.eslint.json | 20 + packages/v2/contract-http/tsconfig.json | 30 + packages/v2/contract-http/tsdown.config.ts | 4 + packages/v2/contract-http/vitest.config.ts | 29 + packages/v2/core/.eslintrc.cjs | 37 + packages/v2/core/.gitignore | 13 + .../core/docs/CREATE_RECORD_ARCHITECTURE.md | 638 + packages/v2/core/package.json | 55 + packages/v2/core/src/ARCHITECTURE.md | 23 + .../v2/core/src/application/ARCHITECTURE.md | 22 + .../application/projections/ARCHITECTURE.md | 19 + .../FieldCreatedRealtimeProjection.ts | 72 + .../FieldDeletedRealtimeProjection.ts | 40 + .../FieldOptionsAddedRealtimeProjection.ts | 65 + .../src/application/projections/Projection.ts | 7 + .../projections/RealtimeProjection.ts | 4 + .../projections/RealtimeProjections.spec.ts | 595 + .../RecordCreatedRealtimeProjection.ts | 52 + .../RecordUpdatedRealtimeProjection.ts | 57 + .../RecordsBatchCreatedRealtimeProjection.ts | 52 + .../RecordsBatchUpdatedRealtimeProjection.ts | 70 + .../RecordsDeletedRealtimeProjection.ts | 41 + .../TableCreatedRealtimeProjection.ts | 59 + .../projections/TableRecordRealtimeDTO.ts | 16 + ...ViewColumnMetaUpdatedRealtimeProjection.ts | 67 + .../src/application/services/ARCHITECTURE.md | 22 + .../AttachmentValueResolverService.spec.ts | 170 + .../AttachmentValueResolverService.ts | 197 + .../FieldCreationSideEffectService.ts | 89 + .../FieldDeletionSideEffectService.ts | 73 + .../services/FieldKeyResolverService.ts | 233 + .../ForeignTableLoaderService.spec.ts | 77 + .../services/ForeignTableLoaderService.ts | 52 + .../services/LinkTitleResolverService.spec.ts | 202 + .../services/LinkTitleResolverService.ts | 513 + .../RecordCreateConstraintService.spec.ts | 76 + .../services/RecordCreateConstraintService.ts | 36 + .../RecordMutationSpecResolverService.spec.ts | 327 + .../RecordMutationSpecResolverService.ts | 283 + .../services/RecordWriteSideEffectService.ts | 40 + .../src/application/services/SpecResolver.ts | 13 + .../services/TableCreationService.spec.ts | 164 + .../services/TableCreationService.ts | 230 + .../services/TableQueryService.spec.ts | 105 + .../application/services/TableQueryService.ts | 192 + .../application/services/TableUpdateFlow.ts | 151 + .../services/UndoRedoService.spec.ts | 133 + .../application/services/UndoRedoService.ts | 258 + .../services/UserValueResolverService.spec.ts | 96 + .../services/UserValueResolverService.ts | 206 + packages/v2/core/src/commands/ARCHITECTURE.md | 46 + .../v2/core/src/commands/ClearCommand.spec.ts | 228 + packages/v2/core/src/commands/ClearCommand.ts | 169 + .../v2/core/src/commands/ClearHandler.spec.ts | 357 + packages/v2/core/src/commands/ClearHandler.ts | 282 + .../v2/core/src/commands/CommandHandler.ts | 41 + .../src/commands/CreateBaseCommand.spec.ts | 21 + .../v2/core/src/commands/CreateBaseCommand.ts | 40 + .../src/commands/CreateBaseHandler.spec.ts | 100 + .../v2/core/src/commands/CreateBaseHandler.ts | 59 + .../src/commands/CreateFieldCommand.spec.ts | 528 + .../core/src/commands/CreateFieldCommand.ts | 63 + .../src/commands/CreateFieldHandler.spec.ts | 316 + .../core/src/commands/CreateFieldHandler.ts | 96 + .../src/commands/CreateRecordCommand.spec.ts | 83 + .../core/src/commands/CreateRecordCommand.ts | 75 + .../src/commands/CreateRecordHandler.spec.ts | 811 + .../core/src/commands/CreateRecordHandler.ts | 204 + .../src/commands/CreateRecordsCommand.spec.ts | 200 + .../core/src/commands/CreateRecordsCommand.ts | 79 + .../src/commands/CreateRecordsHandler.spec.ts | 1223 + .../core/src/commands/CreateRecordsHandler.ts | 256 + .../CreateRecordsStreamCommand.spec.ts | 56 + .../commands/CreateRecordsStreamCommand.ts | 96 + .../CreateRecordsStreamHandler.spec.ts | 272 + .../commands/CreateRecordsStreamHandler.ts | 98 + .../src/commands/CreateTableCommand.spec.ts | 428 + .../core/src/commands/CreateTableCommand.ts | 279 + .../src/commands/CreateTableHandler.spec.ts | 624 + .../core/src/commands/CreateTableHandler.ts | 137 + .../src/commands/CreateTablesCommand.spec.ts | 33 + .../core/src/commands/CreateTablesCommand.ts | 97 + .../src/commands/CreateTablesHandler.spec.ts | 438 + .../core/src/commands/CreateTablesHandler.ts | 379 + .../src/commands/DeleteByRangeCommand.spec.ts | 250 + .../core/src/commands/DeleteByRangeCommand.ts | 130 + .../src/commands/DeleteByRangeHandler.spec.ts | 454 + .../core/src/commands/DeleteByRangeHandler.ts | 323 + .../src/commands/DeleteFieldCommand.spec.ts | 33 + .../core/src/commands/DeleteFieldCommand.ts | 46 + .../src/commands/DeleteFieldHandler.spec.ts | 278 + .../core/src/commands/DeleteFieldHandler.ts | 108 + .../src/commands/DeleteRecordsCommand.spec.ts | 34 + .../core/src/commands/DeleteRecordsCommand.ts | 62 + .../src/commands/DeleteRecordsHandler.spec.ts | 346 + .../core/src/commands/DeleteRecordsHandler.ts | 152 + .../src/commands/DeleteTableCommand.spec.ts | 28 + .../core/src/commands/DeleteTableCommand.ts | 31 + .../src/commands/DeleteTableHandler.spec.ts | 265 + .../core/src/commands/DeleteTableHandler.ts | 94 + .../commands/DuplicateFieldCommand.spec.ts | 120 + .../src/commands/DuplicateFieldCommand.ts | 64 + .../src/commands/DuplicateFieldHandler.ts | 145 + .../commands/DuplicateRecordCommand.spec.ts | 59 + .../src/commands/DuplicateRecordCommand.ts | 52 + .../commands/DuplicateRecordHandler.spec.ts | 676 + .../src/commands/DuplicateRecordHandler.ts | 222 + .../v2/core/src/commands/FieldValidation.ts | 56 + .../src/commands/ImportCsvCommand.spec.ts | 54 + .../v2/core/src/commands/ImportCsvCommand.ts | 277 + .../src/commands/ImportCsvHandler.spec.ts | 373 + .../v2/core/src/commands/ImportCsvHandler.ts | 317 + .../ImportDotTeaStructureCommand.spec.ts | 28 + .../commands/ImportDotTeaStructureCommand.ts | 76 + .../ImportDotTeaStructureHandler.spec.ts | 169 + .../commands/ImportDotTeaStructureHandler.ts | 243 + .../core/src/commands/ImportRecordsCommand.ts | 147 + .../core/src/commands/ImportRecordsHandler.ts | 433 + .../v2/core/src/commands/PasteCommand.spec.ts | 250 + packages/v2/core/src/commands/PasteCommand.ts | 368 + .../v2/core/src/commands/PasteHandler.spec.ts | 624 + packages/v2/core/src/commands/PasteHandler.ts | 1327 + .../v2/core/src/commands/RangeUtils.spec.ts | 190 + packages/v2/core/src/commands/RangeUtils.ts | 134 + packages/v2/core/src/commands/RedoCommand.ts | 36 + packages/v2/core/src/commands/RedoHandler.ts | 43 + .../src/commands/RenameTableCommand.spec.ts | 29 + .../core/src/commands/RenameTableCommand.ts | 41 + .../src/commands/RenameTableHandler.spec.ts | 208 + .../core/src/commands/RenameTableHandler.ts | 47 + .../src/commands/ReorderRecordsCommand.ts | 70 + .../src/commands/ReorderRecordsHandler.ts | 171 + .../src/commands/RestoreRecordsCommand.ts | 51 + .../src/commands/RestoreRecordsHandler.ts | 146 + .../core/src/commands/TableFieldSpecs.spec.ts | 322 + .../v2/core/src/commands/TableFieldSpecs.ts | 2432 ++ .../src/commands/TableInputParser.spec.ts | 106 + .../v2/core/src/commands/TableInputParser.ts | 253 + .../core/src/commands/TableUpdateCommand.ts | 13 + packages/v2/core/src/commands/UndoCommand.ts | 36 + packages/v2/core/src/commands/UndoHandler.ts | 43 + .../src/commands/UpdateRecordCommand.spec.ts | 67 + .../core/src/commands/UpdateRecordCommand.ts | 54 + .../src/commands/UpdateRecordHandler.spec.ts | 680 + .../core/src/commands/UpdateRecordHandler.ts | 221 + .../core/src/commands/shared/orderBy.spec.ts | 108 + .../v2/core/src/commands/shared/orderBy.ts | 81 + packages/v2/core/src/di/ARCHITECTURE.md | 34 + packages/v2/core/src/di/index.ts | 1 + .../v2/core/src/di/registerCoreServices.ts | 218 + packages/v2/core/src/domain/ARCHITECTURE.md | 24 + .../v2/core/src/domain/base/ARCHITECTURE.md | 18 + packages/v2/core/src/domain/base/Base.ts | 43 + .../v2/core/src/domain/base/BaseBuilder.ts | 50 + packages/v2/core/src/domain/base/BaseId.ts | 39 + packages/v2/core/src/domain/base/BaseName.ts | 28 + .../src/domain/base/events/BaseCreated.ts | 20 + .../v2/core/src/domain/base/events/index.ts | 1 + packages/v2/core/src/domain/base/index.ts | 6 + .../src/domain/base/specs/BaseByIdSpec.ts | 34 + .../src/domain/base/specs/IBaseSpecVisitor.ts | 9 + .../v2/core/src/domain/base/specs/index.ts | 2 + .../core/src/domain/formula/ARCHITECTURE.md | 23 + .../core/src/domain/formula/CellValueType.ts | 6 + .../src/domain/formula/FormulaBasics.spec.ts | 241 + .../domain/formula/FormulaFieldReference.ts | 7 + .../src/domain/formula/function-aliases.ts | 22 + .../functions/FormulaFunctions.spec.ts | 465 + .../src/domain/formula/functions/array.ts | 204 + .../src/domain/formula/functions/common.ts | 143 + .../src/domain/formula/functions/date-time.ts | 485 + .../src/domain/formula/functions/factory.ts | 177 + .../src/domain/formula/functions/logical.ts | 251 + .../src/domain/formula/functions/numeric.ts | 405 + .../src/domain/formula/functions/system.ts | 66 + .../core/src/domain/formula/functions/text.ts | 357 + packages/v2/core/src/domain/formula/index.ts | 5 + .../domain/formula/typed-value-converter.ts | 48 + .../v2/core/src/domain/formula/typed-value.ts | 18 + .../v2/core/src/domain/formula/visitor.ts | 198 + .../v2/core/src/domain/shared/ARCHITECTURE.md | 36 + packages/v2/core/src/domain/shared/ActorId.ts | 28 + .../core/src/domain/shared/AggregateRoot.ts | 25 + .../src/domain/shared/DomainBasics.spec.ts | 148 + .../v2/core/src/domain/shared/DomainError.ts | 318 + .../v2/core/src/domain/shared/DomainEvent.ts | 12 + .../core/src/domain/shared/DomainEventName.ts | 88 + packages/v2/core/src/domain/shared/Entity.ts | 7 + .../src/domain/shared/IdGenerator.spec.ts | 25 + .../v2/core/src/domain/shared/IdGenerator.ts | 41 + .../v2/core/src/domain/shared/OccurredAt.ts | 32 + .../domain/shared/RehydratedValueObject.ts | 31 + .../v2/core/src/domain/shared/ValueObject.ts | 3 + .../src/domain/shared/graph/ARCHITECTURE.md | 17 + .../shared/graph/topologicalSort.spec.ts | 57 + .../domain/shared/graph/topologicalSort.ts | 149 + .../domain/shared/pagination/ARCHITECTURE.md | 20 + .../shared/pagination/OffsetPagination.ts | 21 + .../src/domain/shared/pagination/PageLimit.ts | 28 + .../domain/shared/pagination/PageOffset.ts | 32 + .../shared/pagination/Pagination.spec.ts | 53 + .../src/domain/shared/sort/ARCHITECTURE.md | 19 + .../core/src/domain/shared/sort/Sort.spec.ts | 48 + .../v2/core/src/domain/shared/sort/Sort.ts | 33 + .../src/domain/shared/sort/SortDirection.ts | 42 + .../shared/specification/ARCHITECTURE.md | 26 + .../domain/shared/specification/AndSpec.ts | 42 + .../shared/specification/ISpecVisitor.ts | 9 + .../shared/specification/ISpecification.ts | 11 + .../shared/specification/MutateOnlySpec.ts | 16 + .../domain/shared/specification/NotSpec.ts | 43 + .../src/domain/shared/specification/OrSpec.ts | 58 + .../shared/specification/SpecBasics.spec.ts | 307 + .../shared/specification/SpecBuilder.ts | 76 + .../specification/visitors/ARCHITECTURE.md | 19 + .../visitors/AbstractSpecFilterVisitor.ts | 40 + .../visitors/ISpecFilterVisitor.ts | 24 + .../specification/visitors/NoopSpecVisitor.ts | 12 + .../v2/core/src/domain/table/ARCHITECTURE.md | 49 + .../core/src/domain/table/DbTableName.spec.ts | 31 + .../v2/core/src/domain/table/DbTableName.ts | 44 + .../v2/core/src/domain/table/ForeignTable.ts | 43 + .../src/domain/table/IdValueObjects.spec.ts | 71 + .../src/domain/table/NoopVisitors.spec.ts | 94 + .../Table.createRecordInputSchema.spec.ts | 997 + .../v2/core/src/domain/table/Table.spec.ts | 1124 + packages/v2/core/src/domain/table/Table.ts | 824 + .../src/domain/table/TableBuilder.spec.ts | 876 + .../v2/core/src/domain/table/TableBuilder.ts | 2423 ++ packages/v2/core/src/domain/table/TableId.ts | 39 + .../v2/core/src/domain/table/TableMutator.ts | 248 + .../v2/core/src/domain/table/TableName.ts | 28 + .../src/domain/table/TableSortKey.spec.ts | 29 + .../v2/core/src/domain/table/TableSortKey.ts | 50 + .../src/domain/table/events/ARCHITECTURE.md | 26 + .../table/events/AbstractTableUpdatedEvent.ts | 24 + .../src/domain/table/events/FieldCreated.ts | 23 + .../src/domain/table/events/FieldDeleted.ts | 23 + .../domain/table/events/FieldDuplicated.ts | 37 + .../domain/table/events/FieldOptionsAdded.ts | 37 + .../src/domain/table/events/RecordCreated.ts | 30 + .../table/events/RecordFieldValuesDTO.ts | 36 + .../domain/table/events/RecordReordered.ts | 41 + .../src/domain/table/events/RecordUpdated.ts | 44 + .../table/events/RecordsBatchCreated.ts | 27 + .../table/events/RecordsBatchUpdated.ts | 29 + .../src/domain/table/events/RecordsDeleted.ts | 50 + .../src/domain/table/events/TableCreated.ts | 37 + .../src/domain/table/events/TableDeleted.ts | 37 + .../src/domain/table/events/TableRenamed.ts | 29 + .../table/events/ViewColumnMetaUpdated.ts | 30 + .../src/domain/table/fields/ARCHITECTURE.md | 40 + .../src/domain/table/fields/DbFieldName.ts | 28 + .../src/domain/table/fields/DbFieldType.ts | 28 + .../v2/core/src/domain/table/fields/Field.ts | 203 + .../domain/table/fields/FieldBasics.spec.ts | 130 + .../domain/table/fields/FieldFactory.spec.ts | 367 + .../src/domain/table/fields/FieldFactory.ts | 383 + .../core/src/domain/table/fields/FieldId.ts | 50 + .../src/domain/table/fields/FieldKeyType.ts | 27 + .../core/src/domain/table/fields/FieldName.ts | 28 + .../core/src/domain/table/fields/FieldType.ts | 142 + .../table/fields/ForeignTableRelatedField.ts | 47 + .../fields/ForeignTableValidation.spec.ts | 183 + .../domain/table/fields/fieldPredicates.ts | 53 + .../fields/selectOptionAutoCreate.spec.ts | 121 + .../domain/table/fields/specs/ARCHITECTURE.md | 42 + .../table/fields/specs/FieldByIdSpec.ts | 32 + .../table/fields/specs/FieldByKeySpec.ts | 35 + .../table/fields/specs/FieldByNameSpec.ts | 32 + .../fields/specs/FieldIsAttachmentSpec.ts | 28 + .../fields/specs/FieldIsBooleanValueSpec.ts | 31 + .../table/fields/specs/FieldIsButtonSpec.ts | 28 + .../table/fields/specs/FieldIsCheckboxSpec.ts | 28 + .../table/fields/specs/FieldIsComputedSpec.ts | 27 + .../table/fields/specs/FieldIsDateLikeSpec.ts | 31 + .../table/fields/specs/FieldIsDateSpec.ts | 28 + .../fields/specs/FieldIsDateTimeValueSpec.ts | 31 + .../table/fields/specs/FieldIsFormulaSpec.ts | 28 + .../table/fields/specs/FieldIsJsonSpec.ts | 48 + .../table/fields/specs/FieldIsLinkSpec.ts | 28 + .../table/fields/specs/FieldIsLongTextSpec.ts | 28 + .../table/fields/specs/FieldIsLookupSpec.ts | 28 + .../fields/specs/FieldIsMultipleSelectSpec.ts | 28 + .../fields/specs/FieldIsNumberFieldSpec.ts | 28 + .../fields/specs/FieldIsNumberLikeSpec.ts | 31 + .../table/fields/specs/FieldIsNumberSpec.ts | 28 + .../fields/specs/FieldIsNumberValueSpec.ts | 31 + .../table/fields/specs/FieldIsPrimarySpec.ts | 32 + .../table/fields/specs/FieldIsRatingSpec.ts | 28 + .../table/fields/specs/FieldIsRollupSpec.ts | 28 + .../fields/specs/FieldIsSingleSelectSpec.ts | 28 + .../fields/specs/FieldIsSingleTextSpec.ts | 28 + .../fields/specs/FieldIsStringValueSpec.ts | 31 + .../table/fields/specs/FieldIsUserSpec.ts | 33 + .../table/fields/specs/FieldSpecBuilder.ts | 216 + .../table/fields/specs/FieldSpecs.spec.ts | 463 + .../domain/table/fields/types/ARCHITECTURE.md | 83 + .../table/fields/types/AttachmentField.ts | 28 + .../table/fields/types/AutoNumberField.ts | 62 + .../domain/table/fields/types/ButtonField.ts | 87 + .../domain/table/fields/types/ButtonLabel.ts | 32 + .../table/fields/types/ButtonMaxCount.ts | 28 + .../table/fields/types/ButtonResetCount.ts | 29 + .../table/fields/types/ButtonWorkflow.ts | 39 + .../fields/types/CellValueMultiplicity.ts | 41 + .../table/fields/types/CellValueType.ts | 45 + .../fields/types/CheckboxDefaultValue.ts | 29 + .../table/fields/types/CheckboxField.ts | 45 + .../fields/types/ConditionalLookupField.ts | 320 + .../fields/types/ConditionalLookupOptions.ts | 119 + .../fields/types/ConditionalRollupConfig.ts | 116 + .../fields/types/ConditionalRollupField.ts | 507 + .../table/fields/types/CreatedByField.ts | 52 + .../table/fields/types/CreatedTimeField.ts | 74 + .../table/fields/types/DateDefaultValue.ts | 30 + .../domain/table/fields/types/DateField.ts | 60 + .../table/fields/types/DateFormat.spec.ts | 28 + .../domain/table/fields/types/DateFormat.ts | 37 + .../fields/types/DateTimeFormatting.spec.ts | 44 + .../table/fields/types/DateTimeFormatting.ts | 97 + .../domain/table/fields/types/FieldColor.ts | 86 + .../table/fields/types/FieldComputed.ts | 36 + .../table/fields/types/FieldCondition.ts | 488 + .../table/fields/types/FieldHasError.ts | 44 + .../domain/table/fields/types/FieldNotNull.ts | 36 + .../table/fields/types/FieldTypes.spec.ts | 562 + .../domain/table/fields/types/FieldUnique.ts | 36 + .../fields/types/FieldValueObjects.spec.ts | 359 + .../fields/types/FormulaExpression.spec.ts | 81 + .../table/fields/types/FormulaExpression.ts | 173 + .../table/fields/types/FormulaField.spec.ts | 240 + .../domain/table/fields/types/FormulaField.ts | 262 + .../table/fields/types/FormulaMeta.spec.ts | 34 + .../domain/table/fields/types/FormulaMeta.ts | 50 + .../table/fields/types/GeneratedColumnMeta.ts | 51 + .../table/fields/types/LastModifiedByField.ts | 68 + .../fields/types/LastModifiedTimeField.ts | 93 + .../table/fields/types/LinkField.spec.ts | 678 + .../domain/table/fields/types/LinkField.ts | 419 + .../fields/types/LinkFieldConfig.spec.ts | 276 + .../table/fields/types/LinkFieldConfig.ts | 432 + .../table/fields/types/LinkFieldMeta.ts | 37 + .../table/fields/types/LinkRelationship.ts | 77 + .../table/fields/types/LongTextField.ts | 45 + .../table/fields/types/LookupField.spec.ts | 913 + .../domain/table/fields/types/LookupField.ts | 320 + .../table/fields/types/LookupOptions.spec.ts | 159 + .../table/fields/types/LookupOptions.ts | 135 + .../table/fields/types/MultipleSelectField.ts | 70 + .../table/fields/types/NumberDefaultValue.ts | 29 + .../domain/table/fields/types/NumberField.ts | 69 + .../fields/types/NumberFormatting.spec.ts | 61 + .../table/fields/types/NumberFormatting.ts | 95 + .../table/fields/types/NumberShowAs.spec.ts | 46 + .../domain/table/fields/types/NumberShowAs.ts | 69 + .../fields/types/NumericPrecision.spec.ts | 29 + .../table/fields/types/NumericPrecision.ts | 37 + .../domain/table/fields/types/RatingColor.ts | 36 + .../domain/table/fields/types/RatingField.ts | 69 + .../domain/table/fields/types/RatingIcon.ts | 44 + .../domain/table/fields/types/RatingMax.ts | 32 + .../table/fields/types/RollupExpression.ts | 193 + .../domain/table/fields/types/RollupField.ts | 474 + .../table/fields/types/RollupFieldConfig.ts | 75 + .../fields/types/SelectAutoNewOptions.ts | 37 + .../table/fields/types/SelectDefaultValue.ts | 59 + .../domain/table/fields/types/SelectOption.ts | 67 + .../table/fields/types/SelectOptionId.ts | 42 + .../table/fields/types/SelectOptionName.ts | 37 + .../table/fields/types/SelectOptions.ts | 44 + .../table/fields/types/SingleLineTextField.ts | 53 + .../fields/types/SingleLineTextShowAs.spec.ts | 25 + .../fields/types/SingleLineTextShowAs.ts | 39 + .../table/fields/types/SingleSelectField.ts | 70 + .../table/fields/types/TextDefaultValue.ts | 31 + .../src/domain/table/fields/types/TimeZone.ts | 509 + .../table/fields/types/UserDefaultValue.ts | 59 + .../domain/table/fields/types/UserField.ts | 69 + .../src/domain/table/fields/types/UserId.ts | 32 + .../table/fields/types/UserMultiplicity.ts | 37 + .../table/fields/types/UserNotification.ts | 37 + .../table/fields/visitors/ARCHITECTURE.md | 27 + .../fields/visitors/AbstractFieldVisitor.ts | 85 + .../FieldCellValueSchemaVisitor.spec.ts | 156 + .../visitors/FieldCellValueSchemaVisitor.ts | 265 + .../FieldCreationSideEffectVisitor.spec.ts | 322 + .../FieldCreationSideEffectVisitor.ts | 198 + .../visitors/FieldDefaultValueVisitor.spec.ts | 258 + .../visitors/FieldDefaultValueVisitor.ts | 208 + .../FieldDeletionSideEffectVisitor.spec.ts | 188 + .../FieldDeletionSideEffectVisitor.ts | 191 + .../FieldFormVisibilityVisitor.spec.ts | 37 + .../visitors/FieldFormVisibilityVisitor.ts | 120 + .../visitors/FieldToSpecVisitor.spec.ts | 215 + .../fields/visitors/FieldToSpecVisitor.ts | 710 + .../visitors/FieldValueTypeVisitor.spec.ts | 55 + .../fields/visitors/FieldValueTypeVisitor.ts | 240 + .../table/fields/visitors/IFieldVisitor.ts | 50 + .../LinkForeignTableReferenceVisitor.spec.ts | 105 + .../LinkForeignTableReferenceVisitor.ts | 206 + .../fields/visitors/NoopFieldVisitor.spec.ts | 42 + .../table/fields/visitors/NoopFieldVisitor.ts | 117 + .../visitors/RecordWriteSideEffectVisitor.ts | 272 + .../SetFieldValueSpecFactoryVisitor.spec.ts | 27 + .../SetFieldValueSpecFactoryVisitor.ts | 196 + .../table/fields/visitors/dateValueParser.ts | 97 + .../src/domain/table/methods/ARCHITECTURE.md | 18 + .../methods/getOrderedVisibleFieldIds.ts | 104 + .../table/methods/records/ARCHITECTURE.md | 19 + .../methods/records/calculateBatchSize.ts | 62 + .../table/methods/records/createRecord.ts | 13 + .../table/methods/records/createRecords.ts | 53 + .../methods/records/createRecordsStream.ts | 43 + .../records/createRecordsStreamAsync.ts | 37 + .../src/domain/table/methods/records/index.ts | 6 + .../table/methods/records/recordBuilders.ts | 145 + .../table/methods/records/updateRecord.ts | 73 + .../methods/records/updateRecordsStream.ts | 55 + .../core/src/domain/table/methods/rename.ts | 30 + .../src/domain/table/records/ARCHITECTURE.md | 35 + .../table/records/RecordCreateResult.ts | 36 + .../core/src/domain/table/records/RecordId.ts | 39 + .../domain/table/records/RecordInsertOrder.ts | 69 + .../records/RecordMutationSpecBuilder.spec.ts | 183 + .../records/RecordMutationSpecBuilder.ts | 209 + .../table/records/RecordUpdateResult.ts | 34 + .../src/domain/table/records/TableRecord.ts | 60 + .../domain/table/records/TableRecordFields.ts | 97 + .../table/records/recordToFieldValues.ts | 15 + .../table/records/specs/ARCHITECTURE.md | 92 + .../records/specs/AttachmentConditionSpec.ts | 34 + .../records/specs/ButtonConditionSpec.ts | 34 + .../records/specs/CheckboxConditionSpec.ts | 33 + .../specs/ConditionalLookupConditionSpec.ts | 69 + .../specs/ConditionalRollupConditionSpec.ts | 65 + .../table/records/specs/DateConditionSpec.ts | 37 + .../specs/FieldConditionSpecBuilder.ts | 261 + .../records/specs/FormulaConditionSpec.ts | 54 + .../specs/ITableRecordConditionSpecVisitor.ts | 259 + .../table/records/specs/LinkConditionSpec.ts | 41 + .../records/specs/LongTextConditionSpec.ts | 34 + .../specs/MultipleSelectConditionSpec.ts | 39 + .../records/specs/NumberConditionSpec.ts | 40 + .../records/specs/RatingConditionSpec.ts | 40 + .../table/records/specs/RecordByIdSpec.ts | 35 + .../table/records/specs/RecordByIdsSpec.ts | 35 + .../records/specs/RecordConditionOperators.ts | 257 + .../records/specs/RecordConditionSpec.ts | 241 + .../specs/RecordConditionSpecAccept.spec.ts | 245 + .../specs/RecordConditionSpecBuilder.spec.ts | 664 + .../specs/RecordConditionSpecBuilder.ts | 81 + .../RecordConditionSpecEvaluation.spec.ts | 319 + .../specs/RecordConditionSpecFactory.ts | 20 + .../specs/RecordConditionSpecs.spec.ts | 115 + .../specs/RecordConditionValues.spec.ts | 114 + .../records/specs/RecordConditionValues.ts | 196 + .../records/specs/RollupConditionSpec.ts | 54 + .../specs/SingleLineTextConditionSpec.ts | 38 + .../specs/SingleSelectConditionSpec.ts | 38 + .../table/records/specs/UserConditionSpec.ts | 39 + .../records/specs/values/ARCHITECTURE.md | 48 + .../specs/values/ICellValueSpecVisitor.ts | 69 + .../records/specs/values/NoopCellValueSpec.ts | 31 + .../specs/values/SetAttachmentValueSpec.ts | 44 + .../specs/values/SetCheckboxValueSpec.ts | 29 + .../records/specs/values/SetDateValueSpec.ts | 29 + .../values/SetFieldValueSpecFactory.spec.ts | 32 + .../specs/values/SetFieldValueSpecFactory.ts | 84 + .../specs/values/SetLinkValueByTitleSpec.ts | 57 + .../records/specs/values/SetLinkValueSpec.ts | 37 + .../specs/values/SetLongTextValueSpec.ts | 28 + .../values/SetMultipleSelectValueSpec.ts | 29 + .../specs/values/SetNumberValueSpec.ts | 28 + .../specs/values/SetRatingValueSpec.ts | 29 + .../specs/values/SetRowOrderValueSpec.ts | 31 + .../values/SetSingleLineTextValueSpec.ts | 28 + .../specs/values/SetSingleSelectValueSpec.ts | 29 + .../values/SetUserValueByIdentifierSpec.ts | 37 + .../records/specs/values/SetUserValueSpec.ts | 39 + .../records/specs/visitors/ARCHITECTURE.md | 16 + .../NoopRecordConditionSpecVisitor.spec.ts | 21 + .../NoopRecordConditionSpecVisitor.ts | 551 + .../table/records/values/ARCHITECTURE.md | 20 + .../table/records/values/CellValue.spec.ts | 84 + .../domain/table/records/values/CellValue.ts | 67 + .../domain/table/resolveFormulaFields.spec.ts | 281 + .../src/domain/table/resolveFormulaFields.ts | 134 + .../src/domain/table/specs/ARCHITECTURE.md | 30 + .../domain/table/specs/ITableSpecVisitor.ts | 29 + .../domain/table/specs/TableAddFieldSpec.ts | 31 + .../table/specs/TableAddSelectOptionsSpec.ts | 39 + .../domain/table/specs/TableByBaseIdSpec.ts | 34 + .../src/domain/table/specs/TableByIdSpec.ts | 34 + .../src/domain/table/specs/TableByIdsSpec.ts | 35 + .../domain/table/specs/TableByNameLikeSpec.ts | 34 + .../src/domain/table/specs/TableByNameSpec.ts | 33 + .../table/specs/TableDuplicateFieldSpec.ts | 57 + .../table/specs/TableRemoveFieldSpec.ts | 31 + .../src/domain/table/specs/TableRenameSpec.ts | 38 + .../table/specs/TableSpecBuilder.spec.ts | 156 + .../domain/table/specs/TableSpecBuilder.ts | 95 + .../src/domain/table/specs/TableSpecs.spec.ts | 196 + .../specs/TableUpdateViewColumnMetaSpec.ts | 53 + .../TableEventGeneratingSpecVisitor.ts | 140 + .../specs/visitors/TableSpecEventVisitor.ts | 182 + .../src/domain/table/views/ARCHITECTURE.md | 30 + .../v2/core/src/domain/table/views/View.ts | 64 + .../src/domain/table/views/ViewBasics.spec.ts | 142 + .../src/domain/table/views/ViewColumnMeta.ts | 167 + .../src/domain/table/views/ViewFactory.ts | 38 + .../v2/core/src/domain/table/views/ViewId.ts | 48 + .../core/src/domain/table/views/ViewName.ts | 28 + .../table/views/ViewQueryDefaults.spec.ts | 86 + .../domain/table/views/ViewQueryDefaults.ts | 166 + .../src/domain/table/views/ViewType.spec.ts | 25 + .../core/src/domain/table/views/ViewType.ts | 53 + .../domain/table/views/types/ARCHITECTURE.md | 22 + .../domain/table/views/types/CalendarView.ts | 23 + .../src/domain/table/views/types/FormView.ts | 23 + .../domain/table/views/types/GalleryView.ts | 23 + .../src/domain/table/views/types/GridView.ts | 23 + .../domain/table/views/types/KanbanView.ts | 23 + .../domain/table/views/types/PluginView.ts | 23 + .../table/views/visitors/ARCHITECTURE.md | 19 + .../table/views/visitors/CloneViewVisitor.ts | 45 + .../table/views/visitors/IViewVisitor.ts | 18 + .../table/views/visitors/NoopViewVisitor.ts | 37 + packages/v2/core/src/index.spec.ts | 15 + packages/v2/core/src/index.ts | 395 + packages/v2/core/src/ports/ARCHITECTURE.md | 49 + .../core/src/ports/AttachmentLookupService.ts | 22 + packages/v2/core/src/ports/BaseRepository.ts | 21 + packages/v2/core/src/ports/CommandBus.ts | 24 + packages/v2/core/src/ports/CsvParser.ts | 88 + packages/v2/core/src/ports/DotTeaParser.ts | 93 + packages/v2/core/src/ports/EventBus.ts | 13 + packages/v2/core/src/ports/EventHandler.ts | 49 + .../v2/core/src/ports/ExecutionContext.ts | 17 + packages/v2/core/src/ports/HandlerResolver.ts | 5 + packages/v2/core/src/ports/Hasher.ts | 15 + packages/v2/core/src/ports/Logger.ts | 83 + packages/v2/core/src/ports/QueryBus.ts | 24 + packages/v2/core/src/ports/RealtimeChange.ts | 7 + .../v2/core/src/ports/RealtimeDocId.spec.ts | 31 + packages/v2/core/src/ports/RealtimeDocId.ts | 51 + packages/v2/core/src/ports/RealtimeEngine.ts | 31 + .../ports/RecordCreateConstraintService.ts | 23 + .../core/src/ports/RecordOrderCalculator.ts | 24 + packages/v2/core/src/ports/RepositoryQuery.ts | 7 + .../src/ports/TableRecordQueryRepository.ts | 186 + .../v2/core/src/ports/TableRecordReadModel.ts | 24 + .../core/src/ports/TableRecordRepository.ts | 212 + packages/v2/core/src/ports/TableRepository.ts | 33 + .../core/src/ports/TableSchemaRepository.ts | 21 + packages/v2/core/src/ports/TraceSpan.spec.ts | 178 + packages/v2/core/src/ports/TraceSpan.ts | 212 + packages/v2/core/src/ports/Tracer.ts | 109 + packages/v2/core/src/ports/UndoRedoStore.ts | 100 + packages/v2/core/src/ports/UnitOfWork.ts | 16 + .../v2/core/src/ports/UserLookupService.ts | 15 + .../core/src/ports/defaults/ARCHITECTURE.md | 25 + .../core/src/ports/defaults/NoopCsvParser.ts | 19 + .../core/src/ports/defaults/NoopEventBus.ts | 20 + .../v2/core/src/ports/defaults/NoopHasher.ts | 19 + .../v2/core/src/ports/defaults/NoopLogger.ts | 21 + .../core/src/ports/defaults/NoopPorts.spec.ts | 259 + .../src/ports/defaults/NoopRealtimeEngine.ts | 32 + .../defaults/NoopRecordOrderCalculator.ts | 27 + .../NoopTableRecordQueryRepository.ts | 46 + .../defaults/NoopTableRecordRepository.ts | 106 + .../src/ports/defaults/NoopTableRepository.ts | 51 + .../defaults/NoopTableSchemaRepository.ts | 35 + .../v2/core/src/ports/defaults/NoopTracer.ts | 23 + .../src/ports/defaults/NoopUndoRedoStore.ts | 26 + .../core/src/ports/defaults/NoopUnitOfWork.ts | 38 + .../v2/core/src/ports/defaults/index.spec.ts | 13 + packages/v2/core/src/ports/defaults/index.ts | 13 + .../v2/core/src/ports/import/IImportSource.ts | 75 + .../src/ports/import/IImportSourceAdapter.ts | 44 + .../src/ports/import/IImportSourceRegistry.ts | 35 + packages/v2/core/src/ports/import/index.ts | 3 + .../v2/core/src/ports/mappers/ARCHITECTURE.md | 17 + .../v2/core/src/ports/mappers/TableMapper.ts | 344 + .../ports/mappers/defaults/ARCHITECTURE.md | 20 + .../defaults/DefaultTableMapper.spec.ts | 349 + .../mappers/defaults/DefaultTableMapper.ts | 1194 + .../src/ports/mappers/defaults/index.spec.ts | 9 + .../core/src/ports/mappers/defaults/index.ts | 1 + .../v2/core/src/ports/memory/ARCHITECTURE.md | 21 + .../src/ports/memory/AsyncMemoryEventBus.ts | 174 + .../core/src/ports/memory/MemoryCommandBus.ts | 69 + .../core/src/ports/memory/MemoryEventBus.ts | 73 + .../core/src/ports/memory/MemoryPorts.spec.ts | 434 + .../core/src/ports/memory/MemoryQueryBus.ts | 69 + .../src/ports/memory/MemoryTableRepository.ts | 133 + .../ports/memory/MemoryUndoRedoStore.spec.ts | 85 + .../src/ports/memory/MemoryUndoRedoStore.ts | 67 + .../v2/core/src/ports/memory/index.spec.ts | 14 + packages/v2/core/src/ports/memory/index.ts | 6 + packages/v2/core/src/ports/tokens.spec.ts | 12 + packages/v2/core/src/ports/tokens.ts | 37 + packages/v2/core/src/queries/ARCHITECTURE.md | 27 + .../core/src/queries/GetRecordByIdHandler.ts | 82 + .../v2/core/src/queries/GetRecordByIdQuery.ts | 33 + .../src/queries/GetTableByIdHandler.spec.ts | 88 + .../core/src/queries/GetTableByIdHandler.ts | 59 + .../src/queries/GetTableByIdQuery.spec.ts | 29 + .../v2/core/src/queries/GetTableByIdQuery.ts | 31 + .../core/src/queries/ListBasesHandler.spec.ts | 79 + .../v2/core/src/queries/ListBasesHandler.ts | 56 + .../v2/core/src/queries/ListBasesQuery.ts | 36 + .../queries/ListTableRecordsHandler.spec.ts | 177 + .../src/queries/ListTableRecordsHandler.ts | 291 + .../src/queries/ListTableRecordsQuery.spec.ts | 39 + .../core/src/queries/ListTableRecordsQuery.ts | 106 + .../src/queries/ListTablesHandler.spec.ts | 99 + .../v2/core/src/queries/ListTablesHandler.ts | 50 + .../core/src/queries/ListTablesQuery.spec.ts | 54 + .../v2/core/src/queries/ListTablesQuery.ts | 86 + packages/v2/core/src/queries/QueryHandler.ts | 38 + .../core/src/queries/RecordFilterDto.spec.ts | 115 + .../v2/core/src/queries/RecordFilterDto.ts | 223 + .../src/queries/RecordFilterMapper.spec.ts | 176 + .../v2/core/src/queries/RecordFilterMapper.ts | 110 + .../core/src/schemas/field/common.schema.ts | 110 + packages/v2/core/src/schemas/field/index.ts | 51 + .../src/schemas/field/tableField.schema.ts | 424 + packages/v2/core/src/schemas/index.ts | 5 + .../src/schemas/table/createTable.schema.ts | 21 + packages/v2/core/src/schemas/table/index.ts | 5 + .../v2/core/src/schemas/table/view.schema.ts | 10 + packages/v2/core/tsconfig.build.json | 10 + packages/v2/core/tsconfig.eslint.json | 20 + packages/v2/core/tsconfig.json | 37 + packages/v2/core/tsdown.config.ts | 4 + packages/v2/core/vitest.config.ts | 38 + packages/v2/debug-data/.eslintrc.cjs | 30 + packages/v2/debug-data/ARCHITECTURE.md | 26 + packages/v2/debug-data/package.json | 53 + .../postgres/PostgresDebugMetaStore.ts | 400 + .../postgres/PostgresDebugRecordStore.ts | 115 + .../postgres/PostgresFieldRelationGraph.ts | 414 + packages/v2/debug-data/src/di/register.ts | 54 + packages/v2/debug-data/src/di/tokens.ts | 13 + packages/v2/debug-data/src/index.ts | 18 + .../v2/debug-data/src/ports/DebugMetaStore.ts | 23 + .../debug-data/src/ports/DebugRecordStore.ts | 20 + .../src/ports/FieldRelationGraph.ts | 21 + .../src/service/DebugDataService.ts | 349 + packages/v2/debug-data/src/types.ts | 154 + packages/v2/debug-data/tsconfig.build.json | 10 + packages/v2/debug-data/tsconfig.eslint.json | 20 + packages/v2/debug-data/tsconfig.json | 49 + packages/v2/debug-data/tsdown.config.ts | 4 + packages/v2/devtools/.eslintrc.cjs | 27 + packages/v2/devtools/.gitignore | 2 + packages/v2/devtools/bin/run.ts | 32 + packages/v2/devtools/package.json | 69 + .../v2/devtools/src/commands/dottea/import.ts | 75 + .../v2/devtools/src/commands/dottea/index.ts | 8 + .../devtools/src/commands/explain/create.ts | 65 + .../devtools/src/commands/explain/delete.ts | 52 + .../v2/devtools/src/commands/explain/index.ts | 4 + .../v2/devtools/src/commands/explain/paste.ts | 169 + .../devtools/src/commands/explain/update.ts | 69 + packages/v2/devtools/src/commands/index.ts | 67 + .../v2/devtools/src/commands/mock/generate.ts | 86 + .../v2/devtools/src/commands/mock/index.ts | 1 + .../devtools/src/commands/records/create.ts | 70 + .../devtools/src/commands/records/delete.ts | 49 + .../v2/devtools/src/commands/records/get.ts | 54 + .../v2/devtools/src/commands/records/index.ts | 5 + .../v2/devtools/src/commands/records/list.ts | 63 + .../devtools/src/commands/records/update.ts | 72 + .../v2/devtools/src/commands/relations.ts | 78 + .../v2/devtools/src/commands/schema/field.ts | 39 + .../v2/devtools/src/commands/schema/index.ts | 2 + .../v2/devtools/src/commands/schema/table.ts | 34 + packages/v2/devtools/src/commands/shared.ts | 76 + .../v2/devtools/src/commands/tables/create.ts | 80 + .../src/commands/tables/describe-schema.ts | 747 + .../v2/devtools/src/commands/tables/index.ts | 2 + .../devtools/src/commands/underlying/field.ts | 47 + .../src/commands/underlying/fields.ts | 43 + .../devtools/src/commands/underlying/index.ts | 6 + .../src/commands/underlying/record.ts | 51 + .../src/commands/underlying/records.ts | 59 + .../devtools/src/commands/underlying/table.ts | 43 + .../src/commands/underlying/tables.ts | 43 + packages/v2/devtools/src/errors/CliError.ts | 38 + packages/v2/devtools/src/errors/index.ts | 1 + packages/v2/devtools/src/index.ts | 14 + packages/v2/devtools/src/layers/AppLayer.ts | 84 + .../devtools/src/layers/CommandExplainLive.ts | 166 + .../v2/devtools/src/layers/DatabaseLive.ts | 66 + .../devtools/src/layers/DatabasePgliteLive.ts | 173 + .../v2/devtools/src/layers/DebugDataLive.ts | 225 + .../devtools/src/layers/DotTeaImporterLive.ts | 79 + .../v2/devtools/src/layers/MockRecordsLive.ts | 171 + .../devtools/src/layers/NodeCryptoHasher.ts | 11 + packages/v2/devtools/src/layers/OutputLive.ts | 40 + .../devtools/src/layers/RecordMutationLive.ts | 156 + .../devtools/src/layers/SchemaCheckerLive.ts | 165 + .../devtools/src/layers/TableCreatorLive.ts | 67 + packages/v2/devtools/src/layers/index.ts | 11 + .../devtools/src/services/CommandExplain.ts | 38 + packages/v2/devtools/src/services/Database.ts | 21 + .../v2/devtools/src/services/DebugData.ts | 80 + .../devtools/src/services/DotTeaImporter.ts | 25 + .../v2/devtools/src/services/MockRecords.ts | 28 + packages/v2/devtools/src/services/Output.ts | 35 + .../devtools/src/services/RecordMutation.ts | 51 + .../v2/devtools/src/services/SchemaChecker.ts | 37 + .../v2/devtools/src/services/TableCreator.ts | 36 + packages/v2/devtools/src/services/index.ts | 9 + packages/v2/devtools/src/utils/connection.ts | 64 + packages/v2/devtools/src/utils/index.ts | 1 + packages/v2/devtools/tsconfig.build.json | 10 + packages/v2/devtools/tsconfig.eslint.json | 20 + packages/v2/devtools/tsconfig.json | 52 + packages/v2/devtools/tsdown.config.ts | 4 + packages/v2/di/.eslintrc.cjs | 27 + packages/v2/di/.gitignore | 13 + packages/v2/di/package.json | 50 + packages/v2/di/src/index.ts | 38 + packages/v2/di/tsconfig.build.json | 5 + packages/v2/di/tsconfig.eslint.json | 20 + packages/v2/di/tsconfig.json | 24 + packages/v2/di/tsdown.config.ts | 4 + packages/v2/di/vitest.config.ts | 29 + packages/v2/dottea/.eslintrc.cjs | 30 + packages/v2/dottea/ARCHITECTURE.md | 12 + packages/v2/dottea/package.json | 49 + packages/v2/dottea/src/index.ts | 153 + .../src/normalizer/DotTeaFieldNormalizer.ts | 221 + packages/v2/dottea/src/normalizer/index.ts | 5 + packages/v2/dottea/tsconfig.build.json | 10 + packages/v2/dottea/tsconfig.eslint.json | 20 + packages/v2/dottea/tsconfig.json | 37 + packages/v2/dottea/tsdown.config.ts | 4 + packages/v2/e2e/.eslintrc.cjs | 28 + packages/v2/e2e/.gitignore | 13 + packages/v2/e2e/package.json | 64 + packages/v2/e2e/src/clear.e2e.spec.ts | 869 + .../src/computed-matrix/chain.matrix.spec.ts | 574 + .../conditional.matrix.spec.ts | 492 + .../computed-matrix/formula.matrix.spec.ts | 360 + .../computed-matrix/link-ops.matrix.spec.ts | 531 + .../src/computed-matrix/lookup.matrix.spec.ts | 423 + .../src/computed-matrix/rollup.matrix.spec.ts | 450 + .../computed-matrix/self-ref.matrix.spec.ts | 418 + .../src/computed-matrix/shared/generators.ts | 217 + .../e2e/src/computed-matrix/shared/index.ts | 8 + .../e2e/src/computed-matrix/shared/setup.ts | 20 + .../e2e/src/computed-matrix/shared/types.ts | 135 + .../src/computed-matrix/shared/validators.ts | 351 + packages/v2/e2e/src/computed.e2e.spec.ts | 11920 +++++++++ .../v2/e2e/src/conditional-lookup.e2e.spec.ts | 1029 + ...nditionalFieldDirtyPropagation.e2e.spec.ts | 636 + .../v2/e2e/src/conditionalFields.e2e.spec.ts | 853 + packages/v2/e2e/src/createField.e2e.spec.ts | 1981 ++ packages/v2/e2e/src/createRecord.e2e.spec.ts | 885 + .../v2/e2e/src/createRecordLink.e2e.spec.ts | 1015 + packages/v2/e2e/src/createRecords.e2e.spec.ts | 1119 + packages/v2/e2e/src/createTable.e2e.spec.ts | 483 + packages/v2/e2e/src/credit-limit.e2e.spec.ts | 133 + packages/v2/e2e/src/date-time.e2e.spec.ts | 175 + packages/v2/e2e/src/deleteByRange.e2e.spec.ts | 736 + packages/v2/e2e/src/deleteField.e2e.spec.ts | 221 + .../src/deleteRecords-with-links.e2e.spec.ts | 742 + packages/v2/e2e/src/deleteRecords.e2e.spec.ts | 74 + packages/v2/e2e/src/deleteTable.e2e.spec.ts | 71 + .../v2/e2e/src/duplicateField.e2e.spec.ts | 291 + .../v2/e2e/src/duplicateRecord.e2e.spec.ts | 189 + .../src/field-condition-isSymbol.e2e.spec.ts | 214 + .../src/formula-datetime-format.e2e.spec.ts | 247 + .../formula-lookup-empty-text-if.e2e.spec.ts | 130 + packages/v2/e2e/src/formula.e2e.spec.ts | 16524 +++++++++++++ packages/v2/e2e/src/getTableById.e2e.spec.ts | 132 + packages/v2/e2e/src/importCsv.e2e.spec.ts | 208 + packages/v2/e2e/src/importRecords.e2e.spec.ts | 327 + packages/v2/e2e/src/index.ts | 1 + .../v2/e2e/src/link-advisory-lock.e2e.spec.ts | 264 + .../link-exclusivity-constraints.e2e.spec.ts | 934 + .../link-formula-lookup-single.e2e.spec.ts | 352 + .../src/link-one-one-duplicate.e2e.spec.ts | 411 + packages/v2/e2e/src/link-order.e2e.spec.ts | 202 + packages/v2/e2e/src/link-ordering.e2e.spec.ts | 359 + .../link-title-multivalue-lookup.e2e.spec.ts | 171 + packages/v2/e2e/src/listTables.e2e.spec.ts | 104 + .../v2/e2e/src/lookup-cross-base.e2e.spec.ts | 620 + .../v2/e2e/src/numeric-coercion.e2e.spec.ts | 689 + packages/v2/e2e/src/paste.e2e.spec.ts | 2341 ++ .../v2/e2e/src/realtimeShareDb.e2e.spec.ts | 900 + .../record-constraint-violations.e2e.spec.ts | 336 + .../record-create-update-chain.e2e.spec.ts | 294 + .../v2/e2e/src/record-cycle-ops.e2e.spec.ts | 238 + .../v2/e2e/src/record-field-key.e2e.spec.ts | 155 + .../v2/e2e/src/record-http-compat.e2e.spec.ts | 628 + .../v2/e2e/src/record-ordering.e2e.spec.ts | 739 + packages/v2/e2e/src/renameTable.e2e.spec.ts | 73 + .../v2/e2e/src/shared/globalTestContext.ts | 743 + packages/v2/e2e/src/shared/vitest.setup.ts | 14 + .../v2/e2e/src/undoRedoComplex.e2e.spec.ts | 239 + packages/v2/e2e/src/updateRecord.e2e.spec.ts | 1067 + .../e2e/src/updateRecordUndoRedo.e2e.spec.ts | 78 + packages/v2/e2e/tsconfig.build.json | 10 + packages/v2/e2e/tsconfig.eslint.json | 20 + packages/v2/e2e/tsconfig.json | 90 + packages/v2/e2e/tsdown.config.ts | 4 + packages/v2/e2e/vitest.config.ts | 86 + .../v2/field-dependency-core/package.json | 47 + .../src/edge-builder.spec.ts | 397 + .../field-dependency-core/src/edge-builder.ts | 214 + .../v2/field-dependency-core/src/index.ts | 36 + .../field-dependency-core/src/parsers.spec.ts | 285 + .../v2/field-dependency-core/src/parsers.ts | 231 + .../v2/field-dependency-core/src/types.ts | 149 + .../field-dependency-core/tsconfig.build.json | 10 + .../v2/field-dependency-core/tsconfig.json | 34 + .../v2/field-dependency-core/tsdown.config.ts | 4 + .../v2/field-dependency-core/vitest.config.ts | 39 + packages/v2/formula-sql-pg/.eslintrc.cjs | 37 + packages/v2/formula-sql-pg/.gitignore | 12 + packages/v2/formula-sql-pg/ARCHITECTURE.md | 28 + packages/v2/formula-sql-pg/package.json | 53 + .../formula-sql-pg/src/ArrayFunctions.spec.ts | 92 + .../src/BinaryOperators.spec.ts | 143 + .../formula-sql-pg/src/DateFunctions.spec.ts | 168 + .../src/DirectFieldReferences.spec.ts | 81 + .../v2/formula-sql-pg/src/EdgeCases.spec.ts | 150 + .../formula-sql-pg/src/ErrorHandling.spec.ts | 193 + .../src/FieldFormattingSql.spec.ts | 197 + .../formula-sql-pg/src/FieldFormattingSql.ts | 147 + .../src/FieldSqlCoercionVisitor.ts | 161 + .../src/FormattingMatrix.spec.ts | 108 + .../src/FormulaSqlPgExpressionBuilder.ts | 1950 ++ .../src/FormulaSqlPgFunctions.ts | 2140 ++ .../src/FormulaSqlPgTranslator.ts | 350 + .../formula-sql-pg/src/FormulaSqlPgVisitor.ts | 219 + .../src/IfBranchNormalization.spec.ts | 54 + .../src/LogicalFunctions.spec.ts | 91 + .../src/LookupArrayNormalization.spec.ts | 184 + .../src/NumericFunctions.spec.ts | 120 + .../src/ParameterlessFunctions.spec.ts | 91 + .../v2/formula-sql-pg/src/PgSqlHelpers.ts | 181 + .../PgTypeValidation.pg-integration.spec.ts | 68 + .../src/PgTypeValidation.pg-smoke.spec.ts | 53 + .../src/PgTypeValidationStrategy.ts | 25 + .../v2/formula-sql-pg/src/SqlExpression.ts | 73 + .../formula-sql-pg/src/TextFunctions.spec.ts | 102 + .../src/TimeZonePgMapping.spec.ts | 26 + .../formula-sql-pg/src/TimeZonePgMapping.ts | 14 + .../src/TranslatorEdgeCases.spec.ts | 154 + .../src/TypeConsistency.spec.ts | 76 + .../src/VisitorEdgeCases.spec.ts | 235 + .../__snapshots__/ArrayFunctions.spec.ts.snap | 4915 ++++ .../BinaryOperators.spec.ts.snap | 16063 ++++++++++++ .../__snapshots__/DateFunctions.spec.ts.snap | 20203 ++++++++++++++++ .../DirectFieldReferences.spec.ts.snap | 429 + .../src/__snapshots__/EdgeCases.spec.ts.snap | 222 + .../__snapshots__/ErrorHandling.spec.ts.snap | 545 + .../IfBranchNormalization.spec.ts.snap | 111 + .../LogicalFunctions.spec.ts.snap | 4438 ++++ .../NumericFunctions.spec.ts.snap | 14091 +++++++++++ .../ParameterlessFunctions.spec.ts.snap | 55 + .../__snapshots__/TextFunctions.spec.ts.snap | 8173 +++++++ .../src/__tests__/PgSqlHelpers.spec.ts | 193 + .../__tests__/datetime-format.util.spec.ts | 195 + .../PgTypeValidationStrategy.spec.ts | 101 + packages/v2/formula-sql-pg/src/index.ts | 27 + .../strategies/Pg16TypeValidationStrategy.ts | 13 + .../PgLegacyTypeValidationStrategy.ts | 21 + .../v2/formula-sql-pg/src/strategies/index.ts | 2 + .../src/testkit/FormulaSqlPgTestkit.ts | 1133 + .../src/testkit/vitest.setup.ts | 12 + packages/v2/formula-sql-pg/src/tokens.ts | 10 + .../src/utils/datetime-format.util.ts | 96 + .../v2/formula-sql-pg/tsconfig.build.json | 10 + .../v2/formula-sql-pg/tsconfig.eslint.json | 20 + packages/v2/formula-sql-pg/tsconfig.json | 40 + packages/v2/formula-sql-pg/tsdown.config.ts | 4 + packages/v2/formula-sql-pg/vitest.config.ts | 68 + packages/v2/import/package.json | 53 + .../src/adapters/CsvImportAdapter.spec.ts | 191 + .../import/src/adapters/CsvImportAdapter.ts | 202 + .../src/adapters/ExcelImportAdapter.spec.ts | 47 + .../import/src/adapters/ExcelImportAdapter.ts | 194 + packages/v2/import/src/adapters/index.ts | 2 + packages/v2/import/src/di/index.ts | 1 + .../import/src/di/registerImportServices.ts | 62 + packages/v2/import/src/index.ts | 8 + .../src/ports/ImportSourceRegistry.spec.ts | 94 + .../import/src/ports/ImportSourceRegistry.ts | 47 + packages/v2/import/src/ports/index.ts | 14 + packages/v2/import/tsconfig.build.json | 13 + packages/v2/import/tsconfig.json | 30 + packages/v2/import/vitest.config.ts | 39 + packages/v2/mock-records/.eslintrc.cjs | 30 + packages/v2/mock-records/package.json | 52 + .../mock-records/src/MockRecordGenerator.ts | 218 + .../src/TableDependencyAnalyzer.ts | 97 + packages/v2/mock-records/src/index.ts | 16 + packages/v2/mock-records/src/types.ts | 39 + .../src/visitors/FieldMockValueVisitor.ts | 221 + packages/v2/mock-records/tsconfig.build.json | 10 + packages/v2/mock-records/tsconfig.eslint.json | 20 + packages/v2/mock-records/tsconfig.json | 38 + packages/v2/mock-records/tsdown.config.ts | 4 + packages/v2/postgres-schema/.eslintrc.cjs | 29 + packages/v2/postgres-schema/.gitignore | 12 + packages/v2/postgres-schema/package.json | 45 + packages/v2/postgres-schema/src/index.ts | 12 + packages/v2/postgres-schema/src/v1/types.ts | 197 + .../v2/postgres-schema/tsconfig.build.json | 10 + .../v2/postgres-schema/tsconfig.eslint.json | 20 + packages/v2/postgres-schema/tsconfig.json | 23 + packages/v2/postgres-schema/tsdown.config.ts | 4 + packages/v2/table-templates/.eslintrc.cjs | 29 + packages/v2/table-templates/.gitignore | 13 + packages/v2/table-templates/ARCHITECTURE.md | 17 + packages/v2/table-templates/package.json | 48 + packages/v2/table-templates/src/index.ts | 84 + .../src/templates/all-base-fields.ts | 111 + .../src/templates/all-field-types.ts | 408 + .../src/templates/bug-triage.ts | 445 + .../src/templates/content-calendar.ts | 106 + .../v2/table-templates/src/templates/crm.ts | 757 + .../src/templates/hr-management.ts | 764 + .../v2/table-templates/src/templates/index.ts | 13 + .../src/templates/personal-finance.ts | 137 + .../src/templates/project-tracker.ts | 109 + .../table-templates/src/templates/simple.ts | 52 + .../v2/table-templates/src/templates/todo.ts | 115 + packages/v2/table-templates/src/types.ts | 60 + packages/v2/table-templates/src/utils.ts | 175 + .../v2/table-templates/tsconfig.build.json | 10 + .../v2/table-templates/tsconfig.eslint.json | 20 + packages/v2/table-templates/tsconfig.json | 30 + packages/v2/table-templates/tsdown.config.ts | 4 + packages/v2/table-templates/vitest.config.ts | 29 + packages/v2/test-node/.eslintrc.cjs | 27 + packages/v2/test-node/.gitignore | 13 + packages/v2/test-node/package.json | 54 + .../commands/CreateFieldHandler.db.spec.ts | 943 + .../src/commands/CreateFieldHandler.spec.ts | 409 + .../commands/CreateRecordHandler.db.spec.ts | 211 + .../commands/CreateRecordsHandler.db.spec.ts | 411 + .../CreateRecordsStream.bench.spec.ts | 457 + .../src/commands/CreateTableCommand.spec.ts | 49 + .../commands/CreateTableHandler.db.spec.ts | 130 + .../src/commands/CreateTableHandler.spec.ts | 731 + .../src/commands/DeleteFieldHandler.spec.ts | 187 + .../src/commands/RenameTableHandler.spec.ts | 65 + .../commands/UpdateRecordHandler.db.spec.ts | 181 + .../commands/UpdateRecordUndoRedo.db.spec.ts | 179 + .../ComputedFieldVersionIncrement.db.spec.ts | 493 + ...omputedFormulaTypeCompatibility.db.spec.ts | 306 + .../computed/ComputedOptimization.e2e.spec.ts | 821 + .../computed/ComputedUpdatePlanner.db.spec.ts | 851 + .../FieldDependencyChain.integration.spec.ts | 1328 + .../computed/FieldDependencyGraph.db.spec.ts | 433 + packages/v2/test-node/src/index.ts | 1 + .../src/queries/GetTableByIdHandler.spec.ts | 121 + .../src/queries/GetTableByIdQuery.spec.ts | 17 + .../src/queries/ListTablesHandler.spec.ts | 129 + .../src/testkit/createV2NodeTestContainer.ts | 58 + .../src/testkit/v2NodeTestContainer.ts | 28 + packages/v2/test-node/tsconfig.build.json | 10 + packages/v2/test-node/tsconfig.eslint.json | 20 + packages/v2/test-node/tsconfig.json | 68 + packages/v2/test-node/tsdown.config.ts | 4 + packages/v2/test-node/vitest.config.ts | 42 + packages/v2/test-node/vitest.setup.ts | 18 + packages/v2/tsdown-config/package.json | 22 + packages/v2/tsdown-config/src/index.cjs | 24 + packages/v2/tsdown-config/src/index.d.ts | 4 + packages/v2/tsdown-config/src/index.js | 23 + packages/v2/utils/.eslintrc.cjs | 29 + packages/v2/utils/.gitignore | 12 + packages/v2/utils/ARCHITECTURE.md | 11 + packages/v2/utils/package.json | 48 + packages/v2/utils/src/index.ts | 1 + packages/v2/utils/src/printTable.spec.ts | 98 + packages/v2/utils/src/printTable.ts | 161 + packages/v2/utils/tsconfig.build.json | 10 + packages/v2/utils/tsconfig.eslint.json | 20 + packages/v2/utils/tsconfig.json | 29 + packages/v2/utils/tsdown.config.ts | 4 + packages/v2/utils/vitest.config.ts | 29 + pnpm-workspace.yaml | 1 + 1934 files changed, 349459 insertions(+), 3487 deletions(-) create mode 100644 .github/workflows/v2-benchmark-tests.yml create mode 100644 .github/workflows/v2-core-tests.yml create mode 100644 agents.md create mode 100644 apps/nestjs-backend/src/features/canary/decorators/use-v2-feature.decorator.ts create mode 100644 apps/nestjs-backend/src/features/canary/guards/v2-feature.guard.ts create mode 100644 apps/nestjs-backend/src/features/canary/interceptors/v2-indicator.interceptor.ts create mode 100644 apps/nestjs-backend/src/features/import/open-api/import-open-api-v2.service.ts create mode 100644 apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.ts create mode 100644 apps/nestjs-backend/src/features/v2/v2-action-trigger.service.ts create mode 100644 apps/nestjs-backend/src/features/v2/v2-command-bus-tracing.middleware.ts create mode 100644 apps/nestjs-backend/src/features/v2/v2-container.service.ts create mode 100644 apps/nestjs-backend/src/features/v2/v2-execution-context.factory.ts create mode 100644 apps/nestjs-backend/src/features/v2/v2-logger.adapter.ts create mode 100644 apps/nestjs-backend/src/features/v2/v2-openapi.controller.ts create mode 100644 apps/nestjs-backend/src/features/v2/v2-projection-registrar.ts create mode 100644 apps/nestjs-backend/src/features/v2/v2-query-bus-tracing.middleware.ts create mode 100644 apps/nestjs-backend/src/features/v2/v2-record-history.service.ts create mode 100644 apps/nestjs-backend/src/features/v2/v2-tracer.adapter.ts create mode 100644 apps/nestjs-backend/src/features/v2/v2-undo-redo.service.ts create mode 100644 apps/nestjs-backend/src/features/v2/v2.controller.ts create mode 100644 apps/nestjs-backend/src/features/v2/v2.module.ts create mode 100644 apps/nestjs-backend/src/features/view/open-api/view-open-api-v2.service.ts create mode 100755 apps/nestjs-backend/static/system/email-logo.png create mode 100644 apps/nestjs-backend/test/formula-datetime-parse-update.e2e-spec.ts create mode 100644 apps/playground/.cta.json create mode 100644 apps/playground/.cursorrules create mode 100644 apps/playground/.env.development create mode 100644 apps/playground/.gitignore create mode 100644 apps/playground/.vscode/settings.json create mode 100644 apps/playground/README.md create mode 100644 apps/playground/components.json create mode 100644 apps/playground/package.json create mode 100644 apps/playground/public/favicon.ico create mode 100644 apps/playground/public/favicon.svg create mode 100644 apps/playground/src/components/playground/ComputedTasksPanel.tsx create mode 100644 apps/playground/src/components/playground/CreateTableDropdown.tsx create mode 100644 apps/playground/src/components/playground/ExplainResultPanel.tsx create mode 100644 apps/playground/src/components/playground/FieldCreateDialog.tsx create mode 100644 apps/playground/src/components/playground/FieldForm.tsx create mode 100644 apps/playground/src/components/playground/FieldFormOptions.tsx create mode 100644 apps/playground/src/components/playground/ImportCsvDialog.tsx create mode 100644 apps/playground/src/components/playground/LinkFieldLabel.tsx create mode 100644 apps/playground/src/components/playground/LogPanel.tsx create mode 100644 apps/playground/src/components/playground/MetaCheckPanel.tsx create mode 100644 apps/playground/src/components/playground/PlaygroundRecordRoute.tsx create mode 100644 apps/playground/src/components/playground/PlaygroundShell.tsx create mode 100644 apps/playground/src/components/playground/PlaygroundTableRoute.tsx create mode 100644 apps/playground/src/components/playground/RecordCreateDialog.tsx create mode 100644 apps/playground/src/components/playground/RecordDeleteDialog.tsx create mode 100644 apps/playground/src/components/playground/RecordUpdateDialog.tsx create mode 100644 apps/playground/src/components/playground/SchemaCheckPanel.tsx create mode 100644 apps/playground/src/components/playground/TableMetaPage.tsx create mode 100644 apps/playground/src/components/playground/UnderlyingDataPanel.tsx create mode 100644 apps/playground/src/components/playground/field-inputs/CheckboxFieldInput.tsx create mode 100644 apps/playground/src/components/playground/field-inputs/DateFieldInput.tsx create mode 100644 apps/playground/src/components/playground/field-inputs/DisabledFieldInput.tsx create mode 100644 apps/playground/src/components/playground/field-inputs/LinkFieldInput.tsx create mode 100644 apps/playground/src/components/playground/field-inputs/NumberFieldInput.tsx create mode 100644 apps/playground/src/components/playground/field-inputs/RatingFieldInput.tsx create mode 100644 apps/playground/src/components/playground/field-inputs/SelectFieldInput.tsx create mode 100644 apps/playground/src/components/playground/field-inputs/TextFieldInput.tsx create mode 100644 apps/playground/src/components/playground/field-inputs/index.tsx create mode 100644 apps/playground/src/components/playground/field-inputs/types.ts create mode 100644 apps/playground/src/components/playground/field-options/ButtonOptions.tsx create mode 100644 apps/playground/src/components/playground/field-options/CheckboxOptions.tsx create mode 100644 apps/playground/src/components/playground/field-options/ConditionBuilder.tsx create mode 100644 apps/playground/src/components/playground/field-options/ConditionalLookupOptions.tsx create mode 100644 apps/playground/src/components/playground/field-options/ConditionalRollupOptions.tsx create mode 100644 apps/playground/src/components/playground/field-options/DateOptions.tsx create mode 100644 apps/playground/src/components/playground/field-options/FormulaOptions.tsx create mode 100644 apps/playground/src/components/playground/field-options/LinkOptions.tsx create mode 100644 apps/playground/src/components/playground/field-options/LookupOptions.tsx create mode 100644 apps/playground/src/components/playground/field-options/NumberOptions.tsx create mode 100644 apps/playground/src/components/playground/field-options/RatingOptions.tsx create mode 100644 apps/playground/src/components/playground/field-options/RollupOptions.tsx create mode 100644 apps/playground/src/components/playground/field-options/SelectOptions.tsx create mode 100644 apps/playground/src/components/playground/field-options/SingleLineTextOptions.tsx create mode 100644 apps/playground/src/components/playground/field-options/UserOptions.tsx create mode 100644 apps/playground/src/components/playground/fieldOptionsVisitor.tsx create mode 100644 apps/playground/src/components/playground/recordValueVisitor.tsx create mode 100644 apps/playground/src/components/ui/alert-dialog.tsx create mode 100644 apps/playground/src/components/ui/badge.tsx create mode 100644 apps/playground/src/components/ui/button.tsx create mode 100644 apps/playground/src/components/ui/calendar.tsx create mode 100644 apps/playground/src/components/ui/card.tsx create mode 100644 apps/playground/src/components/ui/checkbox.tsx create mode 100644 apps/playground/src/components/ui/command.tsx create mode 100644 apps/playground/src/components/ui/context-menu.tsx create mode 100644 apps/playground/src/components/ui/data-table.tsx create mode 100644 apps/playground/src/components/ui/date-picker.tsx create mode 100644 apps/playground/src/components/ui/dialog.tsx create mode 100644 apps/playground/src/components/ui/dropdown-menu.tsx create mode 100644 apps/playground/src/components/ui/form.tsx create mode 100644 apps/playground/src/components/ui/input.tsx create mode 100644 apps/playground/src/components/ui/label.tsx create mode 100644 apps/playground/src/components/ui/popover.tsx create mode 100644 apps/playground/src/components/ui/radio-group.tsx create mode 100644 apps/playground/src/components/ui/scroll-area.tsx create mode 100644 apps/playground/src/components/ui/select.tsx create mode 100644 apps/playground/src/components/ui/separator.tsx create mode 100644 apps/playground/src/components/ui/sheet.tsx create mode 100644 apps/playground/src/components/ui/sidebar.tsx create mode 100644 apps/playground/src/components/ui/skeleton.tsx create mode 100644 apps/playground/src/components/ui/slider.tsx create mode 100644 apps/playground/src/components/ui/switch.tsx create mode 100644 apps/playground/src/components/ui/table.tsx create mode 100644 apps/playground/src/components/ui/tabs.tsx create mode 100644 apps/playground/src/components/ui/textarea.tsx create mode 100644 apps/playground/src/components/ui/tooltip.tsx create mode 100644 apps/playground/src/hooks/use-mobile.ts create mode 100644 apps/playground/src/hooks/useLogStream.ts create mode 100644 apps/playground/src/hooks/useRecord.ts create mode 100644 apps/playground/src/hooks/useRecords.ts create mode 100644 apps/playground/src/integrations/otel/client.ts create mode 100644 apps/playground/src/integrations/tanstack-query/devtools.tsx create mode 100644 apps/playground/src/integrations/tanstack-query/root-provider.tsx create mode 100644 apps/playground/src/lib/broadcastChannel.ts create mode 100644 apps/playground/src/lib/fieldTypeIcons.ts create mode 100644 apps/playground/src/lib/nuqs/tanstackRouterAdapter.tsx create mode 100644 apps/playground/src/lib/orpc/OrpcClientContext.tsx create mode 100644 apps/playground/src/lib/orpc/RemoteOrpcProvider.tsx create mode 100644 apps/playground/src/lib/orpc/SandboxOrpcProvider.tsx create mode 100644 apps/playground/src/lib/orpcClient.ts create mode 100644 apps/playground/src/lib/playground/constants.ts create mode 100644 apps/playground/src/lib/playground/databaseUrl.ts create mode 100644 apps/playground/src/lib/playground/environment.ts create mode 100644 apps/playground/src/lib/sandboxContainer.ts create mode 100644 apps/playground/src/lib/sandboxOrpcClient.ts create mode 100644 apps/playground/src/lib/shareDb.ts create mode 100644 apps/playground/src/lib/utils.ts create mode 100644 apps/playground/src/logo.svg create mode 100644 apps/playground/src/polyfill.ts create mode 100644 apps/playground/src/router.tsx create mode 100644 apps/playground/src/routes/$baseId.$tableId.$recordId.tsx create mode 100644 apps/playground/src/routes/$baseId.$tableId.tsx create mode 100644 apps/playground/src/routes/$baseId.tsx create mode 100644 apps/playground/src/routes/__root.tsx create mode 100644 apps/playground/src/routes/api.computed-tasks.$taskId.retry-now.ts create mode 100644 apps/playground/src/routes/api.computed-tasks.dead-letters.$taskId.replay.ts create mode 100644 apps/playground/src/routes/api.computed-tasks.dead-letters.$taskId.ts create mode 100644 apps/playground/src/routes/api.computed-tasks.dead-letters.ts create mode 100644 apps/playground/src/routes/api.computed-tasks.outbox.ts create mode 100644 apps/playground/src/routes/api.db.check.ts create mode 100644 apps/playground/src/routes/api.logs.stream.ts create mode 100644 apps/playground/src/routes/api.meta.$tableId.check.stream.ts create mode 100644 apps/playground/src/routes/api.rpc.$.ts create mode 100644 apps/playground/src/routes/api.schema.$tableId.check.stream.ts create mode 100644 apps/playground/src/routes/api.underlying.$tableId.ts create mode 100644 apps/playground/src/routes/computed-tasks.tsx create mode 100644 apps/playground/src/routes/index.tsx create mode 100644 apps/playground/src/routes/sandbox/$baseId.$tableId.$recordId.tsx create mode 100644 apps/playground/src/routes/sandbox/$baseId.$tableId.tsx create mode 100644 apps/playground/src/routes/sandbox/$baseId.tsx create mode 100644 apps/playground/src/routes/sandbox/index.tsx create mode 100644 apps/playground/src/server.ts create mode 100644 apps/playground/src/server/otel.ts create mode 100644 apps/playground/src/server/playgroundContainer.ts create mode 100644 apps/playground/src/server/playgroundDbContext.ts create mode 100644 apps/playground/src/server/playgroundLogger.ts create mode 100644 apps/playground/src/server/shareDbServer.ts create mode 100644 apps/playground/src/server/traceContext.ts create mode 100644 apps/playground/src/server/traceResponseHeaders.ts create mode 100644 apps/playground/src/server/v2OrpcRouter.ts create mode 100644 apps/playground/src/styles.css create mode 100644 apps/playground/src/types/sharedb-pubsub.d.ts create mode 100644 apps/playground/tsconfig.json create mode 100644 apps/playground/vite.config.ts create mode 100644 dottea/.gitignore delete mode 100644 packages/common-i18n/src/locales/de/system.json delete mode 100644 packages/common-i18n/src/locales/en/system.json delete mode 100644 packages/common-i18n/src/locales/es/system.json delete mode 100644 packages/common-i18n/src/locales/fr/system.json delete mode 100644 packages/common-i18n/src/locales/it/system.json delete mode 100644 packages/common-i18n/src/locales/ja/system.json delete mode 100644 packages/common-i18n/src/locales/ru/system.json delete mode 100644 packages/common-i18n/src/locales/tr/system.json delete mode 100644 packages/common-i18n/src/locales/uk/system.json delete mode 100644 packages/common-i18n/src/locales/zh/system.json delete mode 100644 packages/core/src/formula/parser/Formula.ts delete mode 100644 packages/core/src/formula/parser/FormulaLexer.ts delete mode 100644 packages/core/src/formula/parser/FormulaVisitor.ts create mode 100644 packages/db-main-prisma/prisma/postgres/migrations/20260104151713_add_computed_update_outbox_tables/migration.sql create mode 100644 packages/db-main-prisma/prisma/postgres/migrations/20260104190000_add_outbox_seed_table_id/migration.sql create mode 100644 packages/db-main-prisma/prisma/postgres/migrations/20260105123000_add_computed_update_run_tracking/migration.sql create mode 100644 packages/db-main-prisma/prisma/postgres/migrations/20260114000000_add_field_json_indexes/migration.sql create mode 100644 packages/db-main-prisma/prisma/postgres/migrations/20260118000000_add_symmetric_field_id_index/migration.sql create mode 100644 packages/db-main-prisma/prisma/postgres/migrations/20260129203000_add_outbox_pending_unique_index/migration.sql create mode 100644 packages/db-main-prisma/prisma/sqlite/migrations/20260104151713_add_computed_update_outbox_tables/migration.sql create mode 100644 packages/db-main-prisma/prisma/sqlite/migrations/20260104190000_add_outbox_seed_table_id/migration.sql create mode 100644 packages/db-main-prisma/prisma/sqlite/migrations/20260129203000_add_outbox_pending_unique_index/migration.sql create mode 100644 packages/formula/.eslintrc.cjs create mode 100644 packages/formula/.gitignore create mode 100644 packages/formula/package.json rename packages/{core/src/formula => formula/src}/conversion.visitor.spec.ts (100%) rename packages/{core/src/formula => formula/src}/conversion.visitor.ts (93%) rename packages/{core/src/formula => formula/src}/error.listener.ts (100%) rename packages/{core/src/formula => formula/src}/field-reference.util.ts (100%) rename packages/{core/src/formula => formula/src}/field-reference.visitor.spec.ts (100%) rename packages/{core/src/formula => formula/src}/field-reference.visitor.ts (96%) rename packages/{core/src/formula => formula/src}/function-call-collector.visitor.spec.ts (100%) rename packages/{core/src/formula => formula/src}/function-call-collector.visitor.ts (99%) create mode 100644 packages/formula/src/index.ts rename packages/{core/src/formula => formula/src}/parse-formula.ts (100%) rename packages/{core/src/formula => formula/src}/parser/Formula.g4 (100%) rename packages/{core/src/formula => formula/src}/parser/Formula.interp (100%) rename packages/{core/src/formula => formula/src}/parser/Formula.tokens (100%) create mode 100644 packages/formula/src/parser/Formula.ts rename packages/{core/src/formula => formula/src}/parser/FormulaLexer.g4 (100%) rename packages/{core/src/formula => formula/src}/parser/FormulaLexer.interp (100%) rename packages/{core/src/formula => formula/src}/parser/FormulaLexer.tokens (100%) create mode 100644 packages/formula/src/parser/FormulaLexer.ts create mode 100644 packages/formula/src/parser/FormulaVisitor.ts rename packages/{core/src/formula => formula/src}/parser/README.md (100%) create mode 100644 packages/formula/tsconfig.build.json create mode 100644 packages/formula/tsconfig.eslint.json create mode 100644 packages/formula/tsconfig.json create mode 100644 packages/formula/tsdown.config.ts create mode 100644 packages/formula/vitest.config.ts create mode 100644 packages/formula/vitest.setup.js create mode 100644 packages/i18n-keys/package.json create mode 100644 packages/i18n-keys/src/index.ts create mode 100644 packages/i18n-keys/tsconfig.build.json create mode 100644 packages/i18n-keys/tsconfig.json create mode 100644 packages/i18n-keys/tsdown.config.ts create mode 100644 packages/v2/adapter-bullmq/package.json create mode 100644 packages/v2/adapter-bullmq/src/index.ts create mode 100644 packages/v2/adapter-csv-parser-papaparse/package.json create mode 100644 packages/v2/adapter-csv-parser-papaparse/src/PapaparseCsvParser.ts create mode 100644 packages/v2/adapter-csv-parser-papaparse/src/index.ts create mode 100644 packages/v2/adapter-csv-parser-papaparse/tsconfig.build.json create mode 100644 packages/v2/adapter-csv-parser-papaparse/tsconfig.eslint.json create mode 100644 packages/v2/adapter-csv-parser-papaparse/tsconfig.json create mode 100644 packages/v2/adapter-csv-parser-papaparse/tsdown.config.ts create mode 100644 packages/v2/adapter-db-postgres-bun-sql/.eslintrc.cjs create mode 100644 packages/v2/adapter-db-postgres-bun-sql/.gitignore create mode 100644 packages/v2/adapter-db-postgres-bun-sql/ARCHITECTURE.md create mode 100644 packages/v2/adapter-db-postgres-bun-sql/package.json create mode 100644 packages/v2/adapter-db-postgres-bun-sql/src/createDb.ts create mode 100644 packages/v2/adapter-db-postgres-bun-sql/src/di/register.ts create mode 100644 packages/v2/adapter-db-postgres-bun-sql/src/index.ts create mode 100644 packages/v2/adapter-db-postgres-bun-sql/tsconfig.build.json create mode 100644 packages/v2/adapter-db-postgres-bun-sql/tsconfig.eslint.json create mode 100644 packages/v2/adapter-db-postgres-bun-sql/tsconfig.json create mode 100644 packages/v2/adapter-db-postgres-bun-sql/tsdown.config.ts create mode 100644 packages/v2/adapter-db-postgres-pg/.eslintrc.cjs create mode 100644 packages/v2/adapter-db-postgres-pg/.gitignore create mode 100644 packages/v2/adapter-db-postgres-pg/ARCHITECTURE.md create mode 100644 packages/v2/adapter-db-postgres-pg/package.json create mode 100644 packages/v2/adapter-db-postgres-pg/src/config.ts create mode 100644 packages/v2/adapter-db-postgres-pg/src/createDb.ts create mode 100644 packages/v2/adapter-db-postgres-pg/src/di/register.ts create mode 100644 packages/v2/adapter-db-postgres-pg/src/di/tokens.ts create mode 100644 packages/v2/adapter-db-postgres-pg/src/index.ts create mode 100644 packages/v2/adapter-db-postgres-pg/src/unitOfWork.ts create mode 100644 packages/v2/adapter-db-postgres-pg/tsconfig.build.json create mode 100644 packages/v2/adapter-db-postgres-pg/tsconfig.eslint.json create mode 100644 packages/v2/adapter-db-postgres-pg/tsconfig.json create mode 100644 packages/v2/adapter-db-postgres-pg/tsdown.config.ts create mode 100644 packages/v2/adapter-db-postgres-pglite/.eslintrc.cjs create mode 100644 packages/v2/adapter-db-postgres-pglite/ARCHITECTURE.md create mode 100644 packages/v2/adapter-db-postgres-pglite/package.json create mode 100644 packages/v2/adapter-db-postgres-pglite/src/createDb.ts create mode 100644 packages/v2/adapter-db-postgres-pglite/src/di/register.ts create mode 100644 packages/v2/adapter-db-postgres-pglite/src/index.ts create mode 100644 packages/v2/adapter-db-postgres-pglite/src/kyselyPgliteBrowser.ts create mode 100644 packages/v2/adapter-db-postgres-pglite/tsconfig.build.json create mode 100644 packages/v2/adapter-db-postgres-pglite/tsconfig.eslint.json create mode 100644 packages/v2/adapter-db-postgres-pglite/tsconfig.json create mode 100644 packages/v2/adapter-db-postgres-pglite/tsdown.config.ts create mode 100644 packages/v2/adapter-db-postgres-postgresjs/.eslintrc.cjs create mode 100644 packages/v2/adapter-db-postgres-postgresjs/.gitignore create mode 100644 packages/v2/adapter-db-postgres-postgresjs/ARCHITECTURE.md create mode 100644 packages/v2/adapter-db-postgres-postgresjs/package.json create mode 100644 packages/v2/adapter-db-postgres-postgresjs/src/createDb.ts create mode 100644 packages/v2/adapter-db-postgres-postgresjs/src/di/register.ts create mode 100644 packages/v2/adapter-db-postgres-postgresjs/src/index.ts create mode 100644 packages/v2/adapter-db-postgres-postgresjs/tsconfig.build.json create mode 100644 packages/v2/adapter-db-postgres-postgresjs/tsconfig.eslint.json create mode 100644 packages/v2/adapter-db-postgres-postgresjs/tsconfig.json create mode 100644 packages/v2/adapter-db-postgres-postgresjs/tsdown.config.ts create mode 100644 packages/v2/adapter-db-postgres-shared/.eslintrc.cjs create mode 100644 packages/v2/adapter-db-postgres-shared/ARCHITECTURE.md create mode 100644 packages/v2/adapter-db-postgres-shared/package.json create mode 100644 packages/v2/adapter-db-postgres-shared/src/config.ts create mode 100644 packages/v2/adapter-db-postgres-shared/src/di/tokens.ts create mode 100644 packages/v2/adapter-db-postgres-shared/src/index.ts create mode 100644 packages/v2/adapter-db-postgres-shared/src/unitOfWork.ts create mode 100644 packages/v2/adapter-db-postgres-shared/tsconfig.build.json create mode 100644 packages/v2/adapter-db-postgres-shared/tsconfig.eslint.json create mode 100644 packages/v2/adapter-db-postgres-shared/tsconfig.json create mode 100644 packages/v2/adapter-db-postgres-shared/tsdown.config.ts create mode 100644 packages/v2/adapter-logger-console/.eslintrc.cjs create mode 100644 packages/v2/adapter-logger-console/.gitignore create mode 100644 packages/v2/adapter-logger-console/package.json create mode 100644 packages/v2/adapter-logger-console/src/ConsoleLogger.ts create mode 100644 packages/v2/adapter-logger-console/src/index.ts create mode 100644 packages/v2/adapter-logger-console/tsconfig.build.json create mode 100644 packages/v2/adapter-logger-console/tsconfig.eslint.json create mode 100644 packages/v2/adapter-logger-console/tsconfig.json create mode 100644 packages/v2/adapter-logger-console/tsdown.config.ts create mode 100644 packages/v2/adapter-logger-console/vitest.config.ts create mode 100644 packages/v2/adapter-logger-pino/.eslintrc.cjs create mode 100644 packages/v2/adapter-logger-pino/.gitignore create mode 100644 packages/v2/adapter-logger-pino/ARCHITECTURE.md create mode 100644 packages/v2/adapter-logger-pino/package.json create mode 100644 packages/v2/adapter-logger-pino/src/BroadcastLogger.ts create mode 100644 packages/v2/adapter-logger-pino/src/PinoLoggerAdapter.ts create mode 100644 packages/v2/adapter-logger-pino/src/index.ts create mode 100644 packages/v2/adapter-logger-pino/src/pino.ts create mode 100644 packages/v2/adapter-logger-pino/tsconfig.build.json create mode 100644 packages/v2/adapter-logger-pino/tsconfig.eslint.json create mode 100644 packages/v2/adapter-logger-pino/tsconfig.json create mode 100644 packages/v2/adapter-logger-pino/tsdown.config.ts create mode 100644 packages/v2/adapter-logger-pino/vitest.config.ts create mode 100644 packages/v2/adapter-realtime-broadcastchannel/.eslintrc.cjs create mode 100644 packages/v2/adapter-realtime-broadcastchannel/ARCHITECTURE.md create mode 100644 packages/v2/adapter-realtime-broadcastchannel/package.json create mode 100644 packages/v2/adapter-realtime-broadcastchannel/src/BroadcastChannelRealtimeEngine.ts create mode 100644 packages/v2/adapter-realtime-broadcastchannel/src/BroadcastChannelRealtimeHub.ts create mode 100644 packages/v2/adapter-realtime-broadcastchannel/src/di/register.ts create mode 100644 packages/v2/adapter-realtime-broadcastchannel/src/di/tokens.ts create mode 100644 packages/v2/adapter-realtime-broadcastchannel/src/index.ts create mode 100644 packages/v2/adapter-realtime-broadcastchannel/tsconfig.build.json create mode 100644 packages/v2/adapter-realtime-broadcastchannel/tsconfig.eslint.json create mode 100644 packages/v2/adapter-realtime-broadcastchannel/tsconfig.json create mode 100644 packages/v2/adapter-realtime-broadcastchannel/tsdown.config.ts create mode 100644 packages/v2/adapter-realtime-broadcastchannel/vitest.config.ts create mode 100644 packages/v2/adapter-realtime-sharedb/.eslintrc.cjs create mode 100644 packages/v2/adapter-realtime-sharedb/ARCHITECTURE.md create mode 100644 packages/v2/adapter-realtime-sharedb/package.json create mode 100644 packages/v2/adapter-realtime-sharedb/src/ShareDbBackendPublisher.ts create mode 100644 packages/v2/adapter-realtime-sharedb/src/ShareDbPubSubPublisher.ts create mode 100644 packages/v2/adapter-realtime-sharedb/src/ShareDbPublisher.ts create mode 100644 packages/v2/adapter-realtime-sharedb/src/ShareDbRealtimeEngine.spec.ts create mode 100644 packages/v2/adapter-realtime-sharedb/src/ShareDbRealtimeEngine.ts create mode 100644 packages/v2/adapter-realtime-sharedb/src/ShareDbWebSocketServer.ts create mode 100644 packages/v2/adapter-realtime-sharedb/src/di/register.ts create mode 100644 packages/v2/adapter-realtime-sharedb/src/di/tokens.ts create mode 100644 packages/v2/adapter-realtime-sharedb/src/index.ts create mode 100644 packages/v2/adapter-realtime-sharedb/src/websocket-json-stream.d.ts create mode 100644 packages/v2/adapter-realtime-sharedb/tsconfig.build.json create mode 100644 packages/v2/adapter-realtime-sharedb/tsconfig.eslint.json create mode 100644 packages/v2/adapter-realtime-sharedb/tsconfig.json create mode 100644 packages/v2/adapter-realtime-sharedb/tsdown.config.ts create mode 100644 packages/v2/adapter-realtime-sharedb/vitest.config.ts create mode 100644 packages/v2/adapter-realtime-yjs/.eslintrc.cjs create mode 100644 packages/v2/adapter-realtime-yjs/ARCHITECTURE.md create mode 100644 packages/v2/adapter-realtime-yjs/package.json create mode 100644 packages/v2/adapter-realtime-yjs/src/YjsRealtimeEngine.ts create mode 100644 packages/v2/adapter-realtime-yjs/src/index.ts create mode 100644 packages/v2/adapter-realtime-yjs/tsconfig.build.json create mode 100644 packages/v2/adapter-realtime-yjs/tsconfig.eslint.json create mode 100644 packages/v2/adapter-realtime-yjs/tsconfig.json create mode 100644 packages/v2/adapter-realtime-yjs/tsdown.config.ts create mode 100644 packages/v2/adapter-realtime-yjs/vitest.config.ts create mode 100644 packages/v2/adapter-repository-postgres/.eslintrc.cjs create mode 100644 packages/v2/adapter-repository-postgres/.gitignore create mode 100644 packages/v2/adapter-repository-postgres/package.json create mode 100644 packages/v2/adapter-repository-postgres/src/config.ts create mode 100644 packages/v2/adapter-repository-postgres/src/db/schema.ts create mode 100644 packages/v2/adapter-repository-postgres/src/db/tableDbMeta.ts create mode 100644 packages/v2/adapter-repository-postgres/src/di/register.ts create mode 100644 packages/v2/adapter-repository-postgres/src/di/tokens.ts create mode 100644 packages/v2/adapter-repository-postgres/src/index.ts create mode 100644 packages/v2/adapter-repository-postgres/src/naming.ts create mode 100644 packages/v2/adapter-repository-postgres/src/repositories/PostgresBaseRepository.ts create mode 100644 packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.spec.ts create mode 100644 packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.ts create mode 100644 packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRowLimitService.ts create mode 100644 packages/v2/adapter-repository-postgres/src/repositories/TableFieldPersistenceBuilder.ts create mode 100644 packages/v2/adapter-repository-postgres/src/repositories/visitors/FieldStorageTypeVisitor.spec.ts create mode 100644 packages/v2/adapter-repository-postgres/src/repositories/visitors/FieldStorageTypeVisitor.ts create mode 100644 packages/v2/adapter-repository-postgres/src/repositories/visitors/TableMetaUpdateVisitor.ts create mode 100644 packages/v2/adapter-repository-postgres/src/repositories/visitors/TableRecordConditionWhereVisitor.spec.ts create mode 100644 packages/v2/adapter-repository-postgres/src/repositories/visitors/TableRecordConditionWhereVisitor.ts create mode 100644 packages/v2/adapter-repository-postgres/src/repositories/visitors/TableRecordSelectColumnsVisitor.ts create mode 100644 packages/v2/adapter-repository-postgres/src/repositories/visitors/TableWhereVisitor.ts create mode 100644 packages/v2/adapter-repository-postgres/src/repositories/visitors/__snapshots__/TableRecordConditionWhereVisitor.spec.ts.snap create mode 100644 packages/v2/adapter-repository-postgres/tsconfig.build.json create mode 100644 packages/v2/adapter-repository-postgres/tsconfig.eslint.json create mode 100644 packages/v2/adapter-repository-postgres/tsconfig.json create mode 100644 packages/v2/adapter-repository-postgres/tsdown.config.ts create mode 100644 packages/v2/adapter-repository-postgres/vitest.config.ts create mode 100644 packages/v2/adapter-table-repository-postgres/.eslintrc.cjs create mode 100644 packages/v2/adapter-table-repository-postgres/package.json create mode 100644 packages/v2/adapter-table-repository-postgres/src/di/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/di/register.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/di/tokens.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/meta/MetaChecker.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/meta/MetaValidationContext.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/meta/MetaValidationResult.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/meta/MetaValidationVisitor.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/meta/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/ARCHITECTURE.md create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/ARCHITECTURE.md create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedFieldBackfillService.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedFieldUpdater.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedUpdateLock.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedUpdatePlanner.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedUpdateRun.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/FieldDependencyGraph.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/UpdateFromSelectBuilder.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/ComputedFieldUpdater.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/ComputedUpdateLock.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/ComputedUpdatePlanner.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/FieldDependencyGraph.pglite.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/SameTableBatch.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UpdateFromSelectBuilder.lookup.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UpdateFromSelectBuilder.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UserFields.pglite.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/isPersistedAsGeneratedColumn.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedUpdateOutbox.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedUpdateOutbox.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedUpdateOutboxPayload.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedUpdateOutboxPayload.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/ComputedUpdateSeedPayload.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/FieldBackfillOutboxPayload.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/IComputedUpdateOutbox.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/outbox/__tests__/ComputedUpdateOutbox.deadlock.pglite.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/strategies/AsyncWithRetryStrategy.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/strategies/HybridWithOutboxStrategy.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/strategies/HybridWithOutboxStrategy.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/strategies/IUpdateStrategy.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/strategies/SyncInTransactionStrategy.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/strategies/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/types/UpdateTrigger.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/types/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdatePollingService.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdateWorker.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdateWorker.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/di/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/di/register.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/di/tokens.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/FieldOutputColumnVisitor.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/ITableRecordQueryBuilder.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/TableRecordQueryBuilderManager.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedFieldSelectExpressionVisitor.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedFieldSelectExpressionVisitor.userFields.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/FieldReferenceSqlVisitor.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/FieldReferenceSqlVisitor.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/__snapshots__/ComputedTableRecordQueryBuilder.spec.ts.snap create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/__snapshots__/FieldReferenceSqlVisitor.spec.ts.snap create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/insert/RecordInsertBuilder.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/insert/RecordInsertBuilder.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/insert/RecordInsertBuilder.userFields.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/insert/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/StoredFieldSelectVisitor.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/StoredTableRecordQueryBuilder.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/StoredTableRecordQueryBuilder.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/stored/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/update/BatchRecordUpdateBuilder.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/update/BatchUpdateSqlBuilder.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/update/BatchUpdateSqlBuilder.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/update/RecordUpdateBuilder.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/update/__snapshots__/BatchUpdateSqlBuilder.spec.ts.snap create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/query-builder/update/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresAttachmentLookupService.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresRecordOrderCalculator.pglite.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresRecordOrderCalculator.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordQueryRepository.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.delete.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.exclusivity.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.update.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresUserLookupService.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/repository/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/visitors/CellValueMutateVisitor.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/visitors/FieldDatabaseValueVisitor.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/visitors/FieldDeleteValueVisitor.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/visitors/FieldInsertValueVisitor.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/visitors/FieldSqlLiteralVisitor.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/visitors/FieldSqlLiteralVisitor.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/visitors/LinkChangeCollectorVisitor.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/visitors/LinkExclusivityConstraintCollector.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/visitors/TableRecordConditionWhereVisitor.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/record/visitors/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/config.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/di/register.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/di/tokens.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/naming.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/repositories/PostgresTableSchemaRepository.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/repositories/PostgresTableSchemaRepository.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/repositories/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/ARCHITECTURE.md create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/checker/SchemaCheckResult.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/checker/SchemaChecker.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/checker/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/context/PostgresSchemaIntrospector.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/context/SchemaIntrospector.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/context/SchemaRuleContext.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/context/__snapshots__/PostgresSchemaIntrospector.integration.spec.ts.snap create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/context/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/core/ISchemaRule.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/core/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ColumnExistsRule.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ColumnUniqueConstraintRule.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FieldMetaRule.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FieldSchemaRulesFactory.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FkColumnRule.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ForeignKeyRule.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/field/GeneratedColumnRule.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/field/IndexRule.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/field/JunctionTableRule.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/field/LinkSymmetricFieldRule.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/field/LinkValueColumnRule.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/field/NotNullConstraintRule.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/field/OrderColumnRule.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ReferenceRule.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/field/SchemaRules.pglite.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/field/UniqueIndexRule.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/field/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/helpers/StatementBuilders.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/helpers/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/resolver/SchemaRuleResolver.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/resolver/SchemaRuleResolver.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/resolver/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/visitors/ARCHITECTURE.md create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/visitors/FieldValueDuplicateVisitor.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/visitors/LinkFieldValueDuplicateVisitor.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/visitors/PostgresTableSchemaFieldColumn.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/visitors/PostgresTableSchemaFieldCreateVisitor.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/visitors/PostgresTableSchemaFieldDeleteVisitor.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableAddFieldCollectorVisitor.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableSchemaUpdateVisitor.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/visitors/__tests__/LookupColumnType.pglite.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/visitors/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/shared/db.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/shared/errors.spec.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/shared/errors.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/shared/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/utils/detectPgCapability.ts create mode 100644 packages/v2/adapter-table-repository-postgres/src/utils/index.ts create mode 100644 packages/v2/adapter-table-repository-postgres/tsconfig.build.json create mode 100644 packages/v2/adapter-table-repository-postgres/tsconfig.eslint.json create mode 100644 packages/v2/adapter-table-repository-postgres/tsconfig.json create mode 100644 packages/v2/adapter-table-repository-postgres/tsdown.config.ts create mode 100644 packages/v2/adapter-table-repository-postgres/vitest.config.ts create mode 100644 packages/v2/benchmark-bun/.eslintrc.cjs create mode 100644 packages/v2/benchmark-bun/.gitignore create mode 100644 packages/v2/benchmark-bun/package.json create mode 100644 packages/v2/benchmark-bun/src/bench-context.ts create mode 100644 packages/v2/benchmark-bun/src/computed-cte-batch.bench.ts create mode 100644 packages/v2/benchmark-bun/src/create-record.bench.ts create mode 100644 packages/v2/benchmark-bun/src/create-table.bench.ts create mode 100644 packages/v2/benchmark-bun/src/db-adapter.bench.ts create mode 100644 packages/v2/benchmark-bun/src/get-table-by-id.bench.ts create mode 100644 packages/v2/benchmark-bun/src/index.ts create mode 100644 packages/v2/benchmark-bun/src/rpc-client.ts create mode 100644 packages/v2/benchmark-bun/src/run.ts create mode 100644 packages/v2/benchmark-bun/tsconfig.build.json create mode 100644 packages/v2/benchmark-bun/tsconfig.eslint.json create mode 100644 packages/v2/benchmark-bun/tsconfig.json create mode 100644 packages/v2/benchmark-bun/tsdown.config.ts create mode 100644 packages/v2/benchmark-node/.eslintrc.cjs create mode 100644 packages/v2/benchmark-node/.gitignore create mode 100644 packages/v2/benchmark-node/package.json create mode 100644 packages/v2/benchmark-node/src/computed-cte-batch.bench.ts create mode 100644 packages/v2/benchmark-node/src/computed-fanout.bench.ts create mode 100644 packages/v2/benchmark-node/src/create-record.bench.ts create mode 100644 packages/v2/benchmark-node/src/create-table.bench.ts create mode 100644 packages/v2/benchmark-node/src/db-adapter.bench.ts create mode 100644 packages/v2/benchmark-node/src/get-table-by-id.bench.ts create mode 100644 packages/v2/benchmark-node/src/index.ts create mode 100644 packages/v2/benchmark-node/tsconfig.build.json create mode 100644 packages/v2/benchmark-node/tsconfig.eslint.json create mode 100644 packages/v2/benchmark-node/tsconfig.json create mode 100644 packages/v2/benchmark-node/tsdown.config.ts create mode 100644 packages/v2/benchmark-node/vitest.config.ts create mode 100644 packages/v2/command-explain/.eslintrc.cjs create mode 100644 packages/v2/command-explain/package.json create mode 100644 packages/v2/command-explain/src/analyzers/CreateRecordAnalyzer.ts create mode 100644 packages/v2/command-explain/src/analyzers/DeleteRecordsAnalyzer.ts create mode 100644 packages/v2/command-explain/src/analyzers/ICommandAnalyzer.ts create mode 100644 packages/v2/command-explain/src/analyzers/PasteCommandAnalyzer.ts create mode 100644 packages/v2/command-explain/src/analyzers/UpdateRecordAnalyzer.ts create mode 100644 packages/v2/command-explain/src/analyzers/index.ts create mode 100644 packages/v2/command-explain/src/di/index.ts create mode 100644 packages/v2/command-explain/src/di/register.ts create mode 100644 packages/v2/command-explain/src/di/tokens.ts create mode 100644 packages/v2/command-explain/src/index.ts create mode 100644 packages/v2/command-explain/src/service/ExplainService.ts create mode 100644 packages/v2/command-explain/src/service/index.ts create mode 100644 packages/v2/command-explain/src/types/ComplexityAssessment.ts create mode 100644 packages/v2/command-explain/src/types/ExplainOptions.ts create mode 100644 packages/v2/command-explain/src/types/ExplainResult.ts create mode 100644 packages/v2/command-explain/src/types/index.ts create mode 100644 packages/v2/command-explain/src/utils/ComplexityCalculator.ts create mode 100644 packages/v2/command-explain/src/utils/ComputedUpdateLockInfoBuilder.ts create mode 100644 packages/v2/command-explain/src/utils/ComputedUpdateReasonBuilder.ts create mode 100644 packages/v2/command-explain/src/utils/DirtyTableSetupBuilder.ts create mode 100644 packages/v2/command-explain/src/utils/LinkRecordLockInfoBuilder.ts create mode 100644 packages/v2/command-explain/src/utils/SqlExplainRunner.ts create mode 100644 packages/v2/command-explain/src/utils/index.ts create mode 100644 packages/v2/command-explain/tsconfig.build.json create mode 100644 packages/v2/command-explain/tsconfig.eslint.json create mode 100644 packages/v2/command-explain/tsconfig.json create mode 100644 packages/v2/command-explain/tsdown.config.ts create mode 100644 packages/v2/command-explain/vitest.config.ts create mode 100644 packages/v2/container-browser/.eslintrc.cjs create mode 100644 packages/v2/container-browser/.gitignore create mode 100644 packages/v2/container-browser/ARCHITECTURE.md create mode 100644 packages/v2/container-browser/package.json create mode 100644 packages/v2/container-browser/src/index.ts create mode 100644 packages/v2/container-browser/tsconfig.build.json create mode 100644 packages/v2/container-browser/tsconfig.eslint.json create mode 100644 packages/v2/container-browser/tsconfig.json create mode 100644 packages/v2/container-browser/tsdown.config.ts create mode 100644 packages/v2/container-browser/vitest.config.ts create mode 100644 packages/v2/container-bun-test/.eslintrc.cjs create mode 100644 packages/v2/container-bun-test/.gitignore create mode 100644 packages/v2/container-bun-test/package.json create mode 100644 packages/v2/container-bun-test/src/index.ts create mode 100644 packages/v2/container-bun-test/tsconfig.build.json create mode 100644 packages/v2/container-bun-test/tsconfig.eslint.json create mode 100644 packages/v2/container-bun-test/tsconfig.json create mode 100644 packages/v2/container-bun-test/tsdown.config.ts create mode 100644 packages/v2/container-bun-test/vitest.config.ts create mode 100644 packages/v2/container-bun/.eslintrc.cjs create mode 100644 packages/v2/container-bun/.gitignore create mode 100644 packages/v2/container-bun/package.json create mode 100644 packages/v2/container-bun/src/index.ts create mode 100644 packages/v2/container-bun/tsconfig.build.json create mode 100644 packages/v2/container-bun/tsconfig.eslint.json create mode 100644 packages/v2/container-bun/tsconfig.json create mode 100644 packages/v2/container-bun/tsdown.config.ts create mode 100644 packages/v2/container-bun/vitest.config.ts create mode 100644 packages/v2/container-node-test/.eslintrc.cjs create mode 100644 packages/v2/container-node-test/.gitignore create mode 100644 packages/v2/container-node-test/package.json create mode 100644 packages/v2/container-node-test/src/ComputedPlanSnapshot.ts create mode 100644 packages/v2/container-node-test/src/SpyLogger.ts create mode 100644 packages/v2/container-node-test/src/index.ts create mode 100644 packages/v2/container-node-test/tsconfig.build.json create mode 100644 packages/v2/container-node-test/tsconfig.eslint.json create mode 100644 packages/v2/container-node-test/tsconfig.json create mode 100644 packages/v2/container-node-test/tsdown.config.ts create mode 100644 packages/v2/container-node-test/vitest.config.ts create mode 100644 packages/v2/container-node/.eslintrc.cjs create mode 100644 packages/v2/container-node/.gitignore create mode 100644 packages/v2/container-node/package.json create mode 100644 packages/v2/container-node/src/index.ts create mode 100644 packages/v2/container-node/tsconfig.build.json create mode 100644 packages/v2/container-node/tsconfig.eslint.json create mode 100644 packages/v2/container-node/tsconfig.json create mode 100644 packages/v2/container-node/tsdown.config.ts create mode 100644 packages/v2/container-node/vitest.config.ts create mode 100644 packages/v2/contract-http-client/.eslintrc.cjs create mode 100644 packages/v2/contract-http-client/.gitignore create mode 100644 packages/v2/contract-http-client/package.json create mode 100644 packages/v2/contract-http-client/src/index.ts create mode 100644 packages/v2/contract-http-client/tsconfig.build.json create mode 100644 packages/v2/contract-http-client/tsconfig.eslint.json create mode 100644 packages/v2/contract-http-client/tsconfig.json create mode 100644 packages/v2/contract-http-client/tsdown.config.ts create mode 100644 packages/v2/contract-http-client/vitest.config.ts create mode 100644 packages/v2/contract-http-express/.eslintrc.cjs create mode 100644 packages/v2/contract-http-express/.gitignore create mode 100644 packages/v2/contract-http-express/package.json create mode 100644 packages/v2/contract-http-express/src/index.ts create mode 100644 packages/v2/contract-http-express/tsconfig.build.json create mode 100644 packages/v2/contract-http-express/tsconfig.eslint.json create mode 100644 packages/v2/contract-http-express/tsconfig.json create mode 100644 packages/v2/contract-http-express/tsdown.config.ts create mode 100644 packages/v2/contract-http-express/vitest.config.ts create mode 100644 packages/v2/contract-http-fastify/.eslintrc.cjs create mode 100644 packages/v2/contract-http-fastify/.gitignore create mode 100644 packages/v2/contract-http-fastify/package.json create mode 100644 packages/v2/contract-http-fastify/src/index.ts create mode 100644 packages/v2/contract-http-fastify/tsconfig.build.json create mode 100644 packages/v2/contract-http-fastify/tsconfig.eslint.json create mode 100644 packages/v2/contract-http-fastify/tsconfig.json create mode 100644 packages/v2/contract-http-fastify/tsdown.config.ts create mode 100644 packages/v2/contract-http-fastify/vitest.config.ts create mode 100644 packages/v2/contract-http-hono/.eslintrc.cjs create mode 100644 packages/v2/contract-http-hono/.gitignore create mode 100644 packages/v2/contract-http-hono/package.json create mode 100644 packages/v2/contract-http-hono/src/index.ts create mode 100644 packages/v2/contract-http-hono/tsconfig.build.json create mode 100644 packages/v2/contract-http-hono/tsconfig.eslint.json create mode 100644 packages/v2/contract-http-hono/tsconfig.json create mode 100644 packages/v2/contract-http-hono/tsdown.config.ts create mode 100644 packages/v2/contract-http-hono/vitest.config.ts create mode 100644 packages/v2/contract-http-implementation/.eslintrc.cjs create mode 100644 packages/v2/contract-http-implementation/.gitignore create mode 100644 packages/v2/contract-http-implementation/package.json create mode 100644 packages/v2/contract-http-implementation/src/handlers/bases/createBase.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/bases/index.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/bases/listBases.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/index.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/clear.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/createField.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/createRecord.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/createRecords.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/createTable.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/createTables.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/deleteByRange.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/deleteField.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/deleteRecords.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/deleteTable.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/duplicateRecord.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/explainCommand.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/getRecordById.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/getTableById.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/importCsv.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/importRecords.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/index.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/listTableRecords.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/listTables.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/paste.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/renameTable.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/reorderRecords.ts create mode 100644 packages/v2/contract-http-implementation/src/handlers/tables/updateRecord.ts create mode 100644 packages/v2/contract-http-implementation/src/index.ts create mode 100644 packages/v2/contract-http-implementation/src/router.ts create mode 100644 packages/v2/contract-http-implementation/tsconfig.build.json create mode 100644 packages/v2/contract-http-implementation/tsconfig.eslint.json create mode 100644 packages/v2/contract-http-implementation/tsconfig.json create mode 100644 packages/v2/contract-http-implementation/tsdown.config.ts create mode 100644 packages/v2/contract-http-implementation/vitest.config.ts create mode 100644 packages/v2/contract-http-openapi/.eslintrc.cjs create mode 100644 packages/v2/contract-http-openapi/.gitignore create mode 100644 packages/v2/contract-http-openapi/package.json create mode 100644 packages/v2/contract-http-openapi/src/generate.ts create mode 100644 packages/v2/contract-http-openapi/src/index.ts create mode 100644 packages/v2/contract-http-openapi/src/openapi.ts create mode 100644 packages/v2/contract-http-openapi/tsconfig.build.json create mode 100644 packages/v2/contract-http-openapi/tsconfig.eslint.json create mode 100644 packages/v2/contract-http-openapi/tsconfig.json create mode 100644 packages/v2/contract-http-openapi/tsdown.config.ts create mode 100644 packages/v2/contract-http-openapi/vitest.config.ts create mode 100644 packages/v2/contract-http/.eslintrc.cjs create mode 100644 packages/v2/contract-http/.gitignore create mode 100644 packages/v2/contract-http/package.json create mode 100644 packages/v2/contract-http/src/base/createBase.ts create mode 100644 packages/v2/contract-http/src/base/dto.ts create mode 100644 packages/v2/contract-http/src/base/listBases.ts create mode 100644 packages/v2/contract-http/src/contract.ts create mode 100644 packages/v2/contract-http/src/index.ts create mode 100644 packages/v2/contract-http/src/shared/container.ts create mode 100644 packages/v2/contract-http/src/shared/domainEvent.ts create mode 100644 packages/v2/contract-http/src/shared/http.ts create mode 100644 packages/v2/contract-http/src/shared/neverthrow.ts create mode 100644 packages/v2/contract-http/src/table/clear.ts create mode 100644 packages/v2/contract-http/src/table/createField.ts create mode 100644 packages/v2/contract-http/src/table/createRecord.ts create mode 100644 packages/v2/contract-http/src/table/createRecords.ts create mode 100644 packages/v2/contract-http/src/table/createTable.ts create mode 100644 packages/v2/contract-http/src/table/createTables.ts create mode 100644 packages/v2/contract-http/src/table/deleteByRange.ts create mode 100644 packages/v2/contract-http/src/table/deleteField.ts create mode 100644 packages/v2/contract-http/src/table/deleteRecords.ts create mode 100644 packages/v2/contract-http/src/table/deleteTable.ts create mode 100644 packages/v2/contract-http/src/table/dto.ts create mode 100644 packages/v2/contract-http/src/table/duplicateRecord.ts create mode 100644 packages/v2/contract-http/src/table/explainCommand.ts create mode 100644 packages/v2/contract-http/src/table/getRecordById.ts create mode 100644 packages/v2/contract-http/src/table/getTableById.ts create mode 100644 packages/v2/contract-http/src/table/importCsv.ts create mode 100644 packages/v2/contract-http/src/table/importRecords.ts create mode 100644 packages/v2/contract-http/src/table/listTableRecords.ts create mode 100644 packages/v2/contract-http/src/table/listTables.ts create mode 100644 packages/v2/contract-http/src/table/mapTableDtoToDomain.ts create mode 100644 packages/v2/contract-http/src/table/paste.ts create mode 100644 packages/v2/contract-http/src/table/recordDto.ts create mode 100644 packages/v2/contract-http/src/table/renameTable.ts create mode 100644 packages/v2/contract-http/src/table/reorderRecords.ts create mode 100644 packages/v2/contract-http/src/table/updateRecord.ts create mode 100644 packages/v2/contract-http/tsconfig.build.json create mode 100644 packages/v2/contract-http/tsconfig.eslint.json create mode 100644 packages/v2/contract-http/tsconfig.json create mode 100644 packages/v2/contract-http/tsdown.config.ts create mode 100644 packages/v2/contract-http/vitest.config.ts create mode 100644 packages/v2/core/.eslintrc.cjs create mode 100644 packages/v2/core/.gitignore create mode 100644 packages/v2/core/docs/CREATE_RECORD_ARCHITECTURE.md create mode 100644 packages/v2/core/package.json create mode 100644 packages/v2/core/src/ARCHITECTURE.md create mode 100644 packages/v2/core/src/application/ARCHITECTURE.md create mode 100644 packages/v2/core/src/application/projections/ARCHITECTURE.md create mode 100644 packages/v2/core/src/application/projections/FieldCreatedRealtimeProjection.ts create mode 100644 packages/v2/core/src/application/projections/FieldDeletedRealtimeProjection.ts create mode 100644 packages/v2/core/src/application/projections/FieldOptionsAddedRealtimeProjection.ts create mode 100644 packages/v2/core/src/application/projections/Projection.ts create mode 100644 packages/v2/core/src/application/projections/RealtimeProjection.ts create mode 100644 packages/v2/core/src/application/projections/RealtimeProjections.spec.ts create mode 100644 packages/v2/core/src/application/projections/RecordCreatedRealtimeProjection.ts create mode 100644 packages/v2/core/src/application/projections/RecordUpdatedRealtimeProjection.ts create mode 100644 packages/v2/core/src/application/projections/RecordsBatchCreatedRealtimeProjection.ts create mode 100644 packages/v2/core/src/application/projections/RecordsBatchUpdatedRealtimeProjection.ts create mode 100644 packages/v2/core/src/application/projections/RecordsDeletedRealtimeProjection.ts create mode 100644 packages/v2/core/src/application/projections/TableCreatedRealtimeProjection.ts create mode 100644 packages/v2/core/src/application/projections/TableRecordRealtimeDTO.ts create mode 100644 packages/v2/core/src/application/projections/ViewColumnMetaUpdatedRealtimeProjection.ts create mode 100644 packages/v2/core/src/application/services/ARCHITECTURE.md create mode 100644 packages/v2/core/src/application/services/AttachmentValueResolverService.spec.ts create mode 100644 packages/v2/core/src/application/services/AttachmentValueResolverService.ts create mode 100644 packages/v2/core/src/application/services/FieldCreationSideEffectService.ts create mode 100644 packages/v2/core/src/application/services/FieldDeletionSideEffectService.ts create mode 100644 packages/v2/core/src/application/services/FieldKeyResolverService.ts create mode 100644 packages/v2/core/src/application/services/ForeignTableLoaderService.spec.ts create mode 100644 packages/v2/core/src/application/services/ForeignTableLoaderService.ts create mode 100644 packages/v2/core/src/application/services/LinkTitleResolverService.spec.ts create mode 100644 packages/v2/core/src/application/services/LinkTitleResolverService.ts create mode 100644 packages/v2/core/src/application/services/RecordCreateConstraintService.spec.ts create mode 100644 packages/v2/core/src/application/services/RecordCreateConstraintService.ts create mode 100644 packages/v2/core/src/application/services/RecordMutationSpecResolverService.spec.ts create mode 100644 packages/v2/core/src/application/services/RecordMutationSpecResolverService.ts create mode 100644 packages/v2/core/src/application/services/RecordWriteSideEffectService.ts create mode 100644 packages/v2/core/src/application/services/SpecResolver.ts create mode 100644 packages/v2/core/src/application/services/TableCreationService.spec.ts create mode 100644 packages/v2/core/src/application/services/TableCreationService.ts create mode 100644 packages/v2/core/src/application/services/TableQueryService.spec.ts create mode 100644 packages/v2/core/src/application/services/TableQueryService.ts create mode 100644 packages/v2/core/src/application/services/TableUpdateFlow.ts create mode 100644 packages/v2/core/src/application/services/UndoRedoService.spec.ts create mode 100644 packages/v2/core/src/application/services/UndoRedoService.ts create mode 100644 packages/v2/core/src/application/services/UserValueResolverService.spec.ts create mode 100644 packages/v2/core/src/application/services/UserValueResolverService.ts create mode 100644 packages/v2/core/src/commands/ARCHITECTURE.md create mode 100644 packages/v2/core/src/commands/ClearCommand.spec.ts create mode 100644 packages/v2/core/src/commands/ClearCommand.ts create mode 100644 packages/v2/core/src/commands/ClearHandler.spec.ts create mode 100644 packages/v2/core/src/commands/ClearHandler.ts create mode 100644 packages/v2/core/src/commands/CommandHandler.ts create mode 100644 packages/v2/core/src/commands/CreateBaseCommand.spec.ts create mode 100644 packages/v2/core/src/commands/CreateBaseCommand.ts create mode 100644 packages/v2/core/src/commands/CreateBaseHandler.spec.ts create mode 100644 packages/v2/core/src/commands/CreateBaseHandler.ts create mode 100644 packages/v2/core/src/commands/CreateFieldCommand.spec.ts create mode 100644 packages/v2/core/src/commands/CreateFieldCommand.ts create mode 100644 packages/v2/core/src/commands/CreateFieldHandler.spec.ts create mode 100644 packages/v2/core/src/commands/CreateFieldHandler.ts create mode 100644 packages/v2/core/src/commands/CreateRecordCommand.spec.ts create mode 100644 packages/v2/core/src/commands/CreateRecordCommand.ts create mode 100644 packages/v2/core/src/commands/CreateRecordHandler.spec.ts create mode 100644 packages/v2/core/src/commands/CreateRecordHandler.ts create mode 100644 packages/v2/core/src/commands/CreateRecordsCommand.spec.ts create mode 100644 packages/v2/core/src/commands/CreateRecordsCommand.ts create mode 100644 packages/v2/core/src/commands/CreateRecordsHandler.spec.ts create mode 100644 packages/v2/core/src/commands/CreateRecordsHandler.ts create mode 100644 packages/v2/core/src/commands/CreateRecordsStreamCommand.spec.ts create mode 100644 packages/v2/core/src/commands/CreateRecordsStreamCommand.ts create mode 100644 packages/v2/core/src/commands/CreateRecordsStreamHandler.spec.ts create mode 100644 packages/v2/core/src/commands/CreateRecordsStreamHandler.ts create mode 100644 packages/v2/core/src/commands/CreateTableCommand.spec.ts create mode 100644 packages/v2/core/src/commands/CreateTableCommand.ts create mode 100644 packages/v2/core/src/commands/CreateTableHandler.spec.ts create mode 100644 packages/v2/core/src/commands/CreateTableHandler.ts create mode 100644 packages/v2/core/src/commands/CreateTablesCommand.spec.ts create mode 100644 packages/v2/core/src/commands/CreateTablesCommand.ts create mode 100644 packages/v2/core/src/commands/CreateTablesHandler.spec.ts create mode 100644 packages/v2/core/src/commands/CreateTablesHandler.ts create mode 100644 packages/v2/core/src/commands/DeleteByRangeCommand.spec.ts create mode 100644 packages/v2/core/src/commands/DeleteByRangeCommand.ts create mode 100644 packages/v2/core/src/commands/DeleteByRangeHandler.spec.ts create mode 100644 packages/v2/core/src/commands/DeleteByRangeHandler.ts create mode 100644 packages/v2/core/src/commands/DeleteFieldCommand.spec.ts create mode 100644 packages/v2/core/src/commands/DeleteFieldCommand.ts create mode 100644 packages/v2/core/src/commands/DeleteFieldHandler.spec.ts create mode 100644 packages/v2/core/src/commands/DeleteFieldHandler.ts create mode 100644 packages/v2/core/src/commands/DeleteRecordsCommand.spec.ts create mode 100644 packages/v2/core/src/commands/DeleteRecordsCommand.ts create mode 100644 packages/v2/core/src/commands/DeleteRecordsHandler.spec.ts create mode 100644 packages/v2/core/src/commands/DeleteRecordsHandler.ts create mode 100644 packages/v2/core/src/commands/DeleteTableCommand.spec.ts create mode 100644 packages/v2/core/src/commands/DeleteTableCommand.ts create mode 100644 packages/v2/core/src/commands/DeleteTableHandler.spec.ts create mode 100644 packages/v2/core/src/commands/DeleteTableHandler.ts create mode 100644 packages/v2/core/src/commands/DuplicateFieldCommand.spec.ts create mode 100644 packages/v2/core/src/commands/DuplicateFieldCommand.ts create mode 100644 packages/v2/core/src/commands/DuplicateFieldHandler.ts create mode 100644 packages/v2/core/src/commands/DuplicateRecordCommand.spec.ts create mode 100644 packages/v2/core/src/commands/DuplicateRecordCommand.ts create mode 100644 packages/v2/core/src/commands/DuplicateRecordHandler.spec.ts create mode 100644 packages/v2/core/src/commands/DuplicateRecordHandler.ts create mode 100644 packages/v2/core/src/commands/FieldValidation.ts create mode 100644 packages/v2/core/src/commands/ImportCsvCommand.spec.ts create mode 100644 packages/v2/core/src/commands/ImportCsvCommand.ts create mode 100644 packages/v2/core/src/commands/ImportCsvHandler.spec.ts create mode 100644 packages/v2/core/src/commands/ImportCsvHandler.ts create mode 100644 packages/v2/core/src/commands/ImportDotTeaStructureCommand.spec.ts create mode 100644 packages/v2/core/src/commands/ImportDotTeaStructureCommand.ts create mode 100644 packages/v2/core/src/commands/ImportDotTeaStructureHandler.spec.ts create mode 100644 packages/v2/core/src/commands/ImportDotTeaStructureHandler.ts create mode 100644 packages/v2/core/src/commands/ImportRecordsCommand.ts create mode 100644 packages/v2/core/src/commands/ImportRecordsHandler.ts create mode 100644 packages/v2/core/src/commands/PasteCommand.spec.ts create mode 100644 packages/v2/core/src/commands/PasteCommand.ts create mode 100644 packages/v2/core/src/commands/PasteHandler.spec.ts create mode 100644 packages/v2/core/src/commands/PasteHandler.ts create mode 100644 packages/v2/core/src/commands/RangeUtils.spec.ts create mode 100644 packages/v2/core/src/commands/RangeUtils.ts create mode 100644 packages/v2/core/src/commands/RedoCommand.ts create mode 100644 packages/v2/core/src/commands/RedoHandler.ts create mode 100644 packages/v2/core/src/commands/RenameTableCommand.spec.ts create mode 100644 packages/v2/core/src/commands/RenameTableCommand.ts create mode 100644 packages/v2/core/src/commands/RenameTableHandler.spec.ts create mode 100644 packages/v2/core/src/commands/RenameTableHandler.ts create mode 100644 packages/v2/core/src/commands/ReorderRecordsCommand.ts create mode 100644 packages/v2/core/src/commands/ReorderRecordsHandler.ts create mode 100644 packages/v2/core/src/commands/RestoreRecordsCommand.ts create mode 100644 packages/v2/core/src/commands/RestoreRecordsHandler.ts create mode 100644 packages/v2/core/src/commands/TableFieldSpecs.spec.ts create mode 100644 packages/v2/core/src/commands/TableFieldSpecs.ts create mode 100644 packages/v2/core/src/commands/TableInputParser.spec.ts create mode 100644 packages/v2/core/src/commands/TableInputParser.ts create mode 100644 packages/v2/core/src/commands/TableUpdateCommand.ts create mode 100644 packages/v2/core/src/commands/UndoCommand.ts create mode 100644 packages/v2/core/src/commands/UndoHandler.ts create mode 100644 packages/v2/core/src/commands/UpdateRecordCommand.spec.ts create mode 100644 packages/v2/core/src/commands/UpdateRecordCommand.ts create mode 100644 packages/v2/core/src/commands/UpdateRecordHandler.spec.ts create mode 100644 packages/v2/core/src/commands/UpdateRecordHandler.ts create mode 100644 packages/v2/core/src/commands/shared/orderBy.spec.ts create mode 100644 packages/v2/core/src/commands/shared/orderBy.ts create mode 100644 packages/v2/core/src/di/ARCHITECTURE.md create mode 100644 packages/v2/core/src/di/index.ts create mode 100644 packages/v2/core/src/di/registerCoreServices.ts create mode 100644 packages/v2/core/src/domain/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/base/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/base/Base.ts create mode 100644 packages/v2/core/src/domain/base/BaseBuilder.ts create mode 100644 packages/v2/core/src/domain/base/BaseId.ts create mode 100644 packages/v2/core/src/domain/base/BaseName.ts create mode 100644 packages/v2/core/src/domain/base/events/BaseCreated.ts create mode 100644 packages/v2/core/src/domain/base/events/index.ts create mode 100644 packages/v2/core/src/domain/base/index.ts create mode 100644 packages/v2/core/src/domain/base/specs/BaseByIdSpec.ts create mode 100644 packages/v2/core/src/domain/base/specs/IBaseSpecVisitor.ts create mode 100644 packages/v2/core/src/domain/base/specs/index.ts create mode 100644 packages/v2/core/src/domain/formula/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/formula/CellValueType.ts create mode 100644 packages/v2/core/src/domain/formula/FormulaBasics.spec.ts create mode 100644 packages/v2/core/src/domain/formula/FormulaFieldReference.ts create mode 100644 packages/v2/core/src/domain/formula/function-aliases.ts create mode 100644 packages/v2/core/src/domain/formula/functions/FormulaFunctions.spec.ts create mode 100644 packages/v2/core/src/domain/formula/functions/array.ts create mode 100644 packages/v2/core/src/domain/formula/functions/common.ts create mode 100644 packages/v2/core/src/domain/formula/functions/date-time.ts create mode 100644 packages/v2/core/src/domain/formula/functions/factory.ts create mode 100644 packages/v2/core/src/domain/formula/functions/logical.ts create mode 100644 packages/v2/core/src/domain/formula/functions/numeric.ts create mode 100644 packages/v2/core/src/domain/formula/functions/system.ts create mode 100644 packages/v2/core/src/domain/formula/functions/text.ts create mode 100644 packages/v2/core/src/domain/formula/index.ts create mode 100644 packages/v2/core/src/domain/formula/typed-value-converter.ts create mode 100644 packages/v2/core/src/domain/formula/typed-value.ts create mode 100644 packages/v2/core/src/domain/formula/visitor.ts create mode 100644 packages/v2/core/src/domain/shared/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/shared/ActorId.ts create mode 100644 packages/v2/core/src/domain/shared/AggregateRoot.ts create mode 100644 packages/v2/core/src/domain/shared/DomainBasics.spec.ts create mode 100644 packages/v2/core/src/domain/shared/DomainError.ts create mode 100644 packages/v2/core/src/domain/shared/DomainEvent.ts create mode 100644 packages/v2/core/src/domain/shared/DomainEventName.ts create mode 100644 packages/v2/core/src/domain/shared/Entity.ts create mode 100644 packages/v2/core/src/domain/shared/IdGenerator.spec.ts create mode 100644 packages/v2/core/src/domain/shared/IdGenerator.ts create mode 100644 packages/v2/core/src/domain/shared/OccurredAt.ts create mode 100644 packages/v2/core/src/domain/shared/RehydratedValueObject.ts create mode 100644 packages/v2/core/src/domain/shared/ValueObject.ts create mode 100644 packages/v2/core/src/domain/shared/graph/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/shared/graph/topologicalSort.spec.ts create mode 100644 packages/v2/core/src/domain/shared/graph/topologicalSort.ts create mode 100644 packages/v2/core/src/domain/shared/pagination/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/shared/pagination/OffsetPagination.ts create mode 100644 packages/v2/core/src/domain/shared/pagination/PageLimit.ts create mode 100644 packages/v2/core/src/domain/shared/pagination/PageOffset.ts create mode 100644 packages/v2/core/src/domain/shared/pagination/Pagination.spec.ts create mode 100644 packages/v2/core/src/domain/shared/sort/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/shared/sort/Sort.spec.ts create mode 100644 packages/v2/core/src/domain/shared/sort/Sort.ts create mode 100644 packages/v2/core/src/domain/shared/sort/SortDirection.ts create mode 100644 packages/v2/core/src/domain/shared/specification/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/shared/specification/AndSpec.ts create mode 100644 packages/v2/core/src/domain/shared/specification/ISpecVisitor.ts create mode 100644 packages/v2/core/src/domain/shared/specification/ISpecification.ts create mode 100644 packages/v2/core/src/domain/shared/specification/MutateOnlySpec.ts create mode 100644 packages/v2/core/src/domain/shared/specification/NotSpec.ts create mode 100644 packages/v2/core/src/domain/shared/specification/OrSpec.ts create mode 100644 packages/v2/core/src/domain/shared/specification/SpecBasics.spec.ts create mode 100644 packages/v2/core/src/domain/shared/specification/SpecBuilder.ts create mode 100644 packages/v2/core/src/domain/shared/specification/visitors/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/shared/specification/visitors/AbstractSpecFilterVisitor.ts create mode 100644 packages/v2/core/src/domain/shared/specification/visitors/ISpecFilterVisitor.ts create mode 100644 packages/v2/core/src/domain/shared/specification/visitors/NoopSpecVisitor.ts create mode 100644 packages/v2/core/src/domain/table/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/table/DbTableName.spec.ts create mode 100644 packages/v2/core/src/domain/table/DbTableName.ts create mode 100644 packages/v2/core/src/domain/table/ForeignTable.ts create mode 100644 packages/v2/core/src/domain/table/IdValueObjects.spec.ts create mode 100644 packages/v2/core/src/domain/table/NoopVisitors.spec.ts create mode 100644 packages/v2/core/src/domain/table/Table.createRecordInputSchema.spec.ts create mode 100644 packages/v2/core/src/domain/table/Table.spec.ts create mode 100644 packages/v2/core/src/domain/table/Table.ts create mode 100644 packages/v2/core/src/domain/table/TableBuilder.spec.ts create mode 100644 packages/v2/core/src/domain/table/TableBuilder.ts create mode 100644 packages/v2/core/src/domain/table/TableId.ts create mode 100644 packages/v2/core/src/domain/table/TableMutator.ts create mode 100644 packages/v2/core/src/domain/table/TableName.ts create mode 100644 packages/v2/core/src/domain/table/TableSortKey.spec.ts create mode 100644 packages/v2/core/src/domain/table/TableSortKey.ts create mode 100644 packages/v2/core/src/domain/table/events/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/table/events/AbstractTableUpdatedEvent.ts create mode 100644 packages/v2/core/src/domain/table/events/FieldCreated.ts create mode 100644 packages/v2/core/src/domain/table/events/FieldDeleted.ts create mode 100644 packages/v2/core/src/domain/table/events/FieldDuplicated.ts create mode 100644 packages/v2/core/src/domain/table/events/FieldOptionsAdded.ts create mode 100644 packages/v2/core/src/domain/table/events/RecordCreated.ts create mode 100644 packages/v2/core/src/domain/table/events/RecordFieldValuesDTO.ts create mode 100644 packages/v2/core/src/domain/table/events/RecordReordered.ts create mode 100644 packages/v2/core/src/domain/table/events/RecordUpdated.ts create mode 100644 packages/v2/core/src/domain/table/events/RecordsBatchCreated.ts create mode 100644 packages/v2/core/src/domain/table/events/RecordsBatchUpdated.ts create mode 100644 packages/v2/core/src/domain/table/events/RecordsDeleted.ts create mode 100644 packages/v2/core/src/domain/table/events/TableCreated.ts create mode 100644 packages/v2/core/src/domain/table/events/TableDeleted.ts create mode 100644 packages/v2/core/src/domain/table/events/TableRenamed.ts create mode 100644 packages/v2/core/src/domain/table/events/ViewColumnMetaUpdated.ts create mode 100644 packages/v2/core/src/domain/table/fields/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/table/fields/DbFieldName.ts create mode 100644 packages/v2/core/src/domain/table/fields/DbFieldType.ts create mode 100644 packages/v2/core/src/domain/table/fields/Field.ts create mode 100644 packages/v2/core/src/domain/table/fields/FieldBasics.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/FieldFactory.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/FieldFactory.ts create mode 100644 packages/v2/core/src/domain/table/fields/FieldId.ts create mode 100644 packages/v2/core/src/domain/table/fields/FieldKeyType.ts create mode 100644 packages/v2/core/src/domain/table/fields/FieldName.ts create mode 100644 packages/v2/core/src/domain/table/fields/FieldType.ts create mode 100644 packages/v2/core/src/domain/table/fields/ForeignTableRelatedField.ts create mode 100644 packages/v2/core/src/domain/table/fields/ForeignTableValidation.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/fieldPredicates.ts create mode 100644 packages/v2/core/src/domain/table/fields/selectOptionAutoCreate.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldByIdSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldByKeySpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldByNameSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsAttachmentSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsBooleanValueSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsButtonSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsCheckboxSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsComputedSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsDateLikeSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsDateSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsDateTimeValueSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsFormulaSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsJsonSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsLinkSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsLongTextSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsLookupSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsMultipleSelectSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsNumberFieldSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsNumberLikeSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsNumberSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsNumberValueSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsPrimarySpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsRatingSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsRollupSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsSingleSelectSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsSingleTextSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsStringValueSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldIsUserSpec.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldSpecBuilder.ts create mode 100644 packages/v2/core/src/domain/table/fields/specs/FieldSpecs.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/table/fields/types/AttachmentField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/AutoNumberField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/ButtonField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/ButtonLabel.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/ButtonMaxCount.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/ButtonResetCount.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/ButtonWorkflow.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/CellValueMultiplicity.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/CellValueType.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/CheckboxDefaultValue.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/CheckboxField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/ConditionalLookupField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/ConditionalLookupOptions.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/ConditionalRollupConfig.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/ConditionalRollupField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/CreatedByField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/CreatedTimeField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/DateDefaultValue.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/DateField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/DateFormat.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/DateFormat.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/DateTimeFormatting.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/DateTimeFormatting.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/FieldColor.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/FieldComputed.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/FieldCondition.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/FieldHasError.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/FieldNotNull.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/FieldTypes.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/FieldUnique.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/FieldValueObjects.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/FormulaExpression.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/FormulaExpression.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/FormulaField.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/FormulaField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/FormulaMeta.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/FormulaMeta.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/GeneratedColumnMeta.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/LastModifiedByField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/LastModifiedTimeField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/LinkField.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/LinkField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/LinkFieldConfig.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/LinkFieldConfig.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/LinkFieldMeta.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/LinkRelationship.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/LongTextField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/LookupField.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/LookupField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/LookupOptions.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/LookupOptions.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/MultipleSelectField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/NumberDefaultValue.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/NumberField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/NumberFormatting.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/NumberFormatting.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/NumberShowAs.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/NumberShowAs.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/NumericPrecision.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/NumericPrecision.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/RatingColor.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/RatingField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/RatingIcon.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/RatingMax.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/RollupExpression.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/RollupField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/RollupFieldConfig.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/SelectAutoNewOptions.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/SelectDefaultValue.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/SelectOption.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/SelectOptionId.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/SelectOptionName.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/SelectOptions.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/SingleLineTextField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/SingleLineTextShowAs.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/SingleLineTextShowAs.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/SingleSelectField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/TextDefaultValue.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/TimeZone.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/UserDefaultValue.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/UserField.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/UserId.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/UserMultiplicity.ts create mode 100644 packages/v2/core/src/domain/table/fields/types/UserNotification.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/table/fields/visitors/AbstractFieldVisitor.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/FieldCellValueSchemaVisitor.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/FieldCellValueSchemaVisitor.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/FieldCreationSideEffectVisitor.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/FieldCreationSideEffectVisitor.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/FieldDefaultValueVisitor.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/FieldDefaultValueVisitor.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/FieldDeletionSideEffectVisitor.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/FieldDeletionSideEffectVisitor.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/FieldFormVisibilityVisitor.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/FieldFormVisibilityVisitor.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/FieldToSpecVisitor.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/FieldToSpecVisitor.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/FieldValueTypeVisitor.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/FieldValueTypeVisitor.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/IFieldVisitor.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/LinkForeignTableReferenceVisitor.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/LinkForeignTableReferenceVisitor.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/NoopFieldVisitor.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/NoopFieldVisitor.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/RecordWriteSideEffectVisitor.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/SetFieldValueSpecFactoryVisitor.spec.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/SetFieldValueSpecFactoryVisitor.ts create mode 100644 packages/v2/core/src/domain/table/fields/visitors/dateValueParser.ts create mode 100644 packages/v2/core/src/domain/table/methods/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/table/methods/getOrderedVisibleFieldIds.ts create mode 100644 packages/v2/core/src/domain/table/methods/records/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/table/methods/records/calculateBatchSize.ts create mode 100644 packages/v2/core/src/domain/table/methods/records/createRecord.ts create mode 100644 packages/v2/core/src/domain/table/methods/records/createRecords.ts create mode 100644 packages/v2/core/src/domain/table/methods/records/createRecordsStream.ts create mode 100644 packages/v2/core/src/domain/table/methods/records/createRecordsStreamAsync.ts create mode 100644 packages/v2/core/src/domain/table/methods/records/index.ts create mode 100644 packages/v2/core/src/domain/table/methods/records/recordBuilders.ts create mode 100644 packages/v2/core/src/domain/table/methods/records/updateRecord.ts create mode 100644 packages/v2/core/src/domain/table/methods/records/updateRecordsStream.ts create mode 100644 packages/v2/core/src/domain/table/methods/rename.ts create mode 100644 packages/v2/core/src/domain/table/records/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/table/records/RecordCreateResult.ts create mode 100644 packages/v2/core/src/domain/table/records/RecordId.ts create mode 100644 packages/v2/core/src/domain/table/records/RecordInsertOrder.ts create mode 100644 packages/v2/core/src/domain/table/records/RecordMutationSpecBuilder.spec.ts create mode 100644 packages/v2/core/src/domain/table/records/RecordMutationSpecBuilder.ts create mode 100644 packages/v2/core/src/domain/table/records/RecordUpdateResult.ts create mode 100644 packages/v2/core/src/domain/table/records/TableRecord.ts create mode 100644 packages/v2/core/src/domain/table/records/TableRecordFields.ts create mode 100644 packages/v2/core/src/domain/table/records/recordToFieldValues.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/table/records/specs/AttachmentConditionSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/ButtonConditionSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/CheckboxConditionSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/ConditionalLookupConditionSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/ConditionalRollupConditionSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/DateConditionSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/FieldConditionSpecBuilder.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/FormulaConditionSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/ITableRecordConditionSpecVisitor.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/LinkConditionSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/LongTextConditionSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/MultipleSelectConditionSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/NumberConditionSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/RatingConditionSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/RecordByIdSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/RecordByIdsSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/RecordConditionOperators.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/RecordConditionSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/RecordConditionSpecAccept.spec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/RecordConditionSpecBuilder.spec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/RecordConditionSpecBuilder.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/RecordConditionSpecEvaluation.spec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/RecordConditionSpecFactory.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/RecordConditionSpecs.spec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/RecordConditionValues.spec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/RecordConditionValues.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/RollupConditionSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/SingleLineTextConditionSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/SingleSelectConditionSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/UserConditionSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/values/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/table/records/specs/values/ICellValueSpecVisitor.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/values/NoopCellValueSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/values/SetAttachmentValueSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/values/SetCheckboxValueSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/values/SetDateValueSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/values/SetFieldValueSpecFactory.spec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/values/SetFieldValueSpecFactory.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/values/SetLinkValueByTitleSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/values/SetLinkValueSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/values/SetLongTextValueSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/values/SetMultipleSelectValueSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/values/SetNumberValueSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/values/SetRatingValueSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/values/SetRowOrderValueSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/values/SetSingleLineTextValueSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/values/SetSingleSelectValueSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/values/SetUserValueByIdentifierSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/values/SetUserValueSpec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/visitors/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/table/records/specs/visitors/NoopRecordConditionSpecVisitor.spec.ts create mode 100644 packages/v2/core/src/domain/table/records/specs/visitors/NoopRecordConditionSpecVisitor.ts create mode 100644 packages/v2/core/src/domain/table/records/values/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/table/records/values/CellValue.spec.ts create mode 100644 packages/v2/core/src/domain/table/records/values/CellValue.ts create mode 100644 packages/v2/core/src/domain/table/resolveFormulaFields.spec.ts create mode 100644 packages/v2/core/src/domain/table/resolveFormulaFields.ts create mode 100644 packages/v2/core/src/domain/table/specs/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/table/specs/ITableSpecVisitor.ts create mode 100644 packages/v2/core/src/domain/table/specs/TableAddFieldSpec.ts create mode 100644 packages/v2/core/src/domain/table/specs/TableAddSelectOptionsSpec.ts create mode 100644 packages/v2/core/src/domain/table/specs/TableByBaseIdSpec.ts create mode 100644 packages/v2/core/src/domain/table/specs/TableByIdSpec.ts create mode 100644 packages/v2/core/src/domain/table/specs/TableByIdsSpec.ts create mode 100644 packages/v2/core/src/domain/table/specs/TableByNameLikeSpec.ts create mode 100644 packages/v2/core/src/domain/table/specs/TableByNameSpec.ts create mode 100644 packages/v2/core/src/domain/table/specs/TableDuplicateFieldSpec.ts create mode 100644 packages/v2/core/src/domain/table/specs/TableRemoveFieldSpec.ts create mode 100644 packages/v2/core/src/domain/table/specs/TableRenameSpec.ts create mode 100644 packages/v2/core/src/domain/table/specs/TableSpecBuilder.spec.ts create mode 100644 packages/v2/core/src/domain/table/specs/TableSpecBuilder.ts create mode 100644 packages/v2/core/src/domain/table/specs/TableSpecs.spec.ts create mode 100644 packages/v2/core/src/domain/table/specs/TableUpdateViewColumnMetaSpec.ts create mode 100644 packages/v2/core/src/domain/table/specs/visitors/TableEventGeneratingSpecVisitor.ts create mode 100644 packages/v2/core/src/domain/table/specs/visitors/TableSpecEventVisitor.ts create mode 100644 packages/v2/core/src/domain/table/views/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/table/views/View.ts create mode 100644 packages/v2/core/src/domain/table/views/ViewBasics.spec.ts create mode 100644 packages/v2/core/src/domain/table/views/ViewColumnMeta.ts create mode 100644 packages/v2/core/src/domain/table/views/ViewFactory.ts create mode 100644 packages/v2/core/src/domain/table/views/ViewId.ts create mode 100644 packages/v2/core/src/domain/table/views/ViewName.ts create mode 100644 packages/v2/core/src/domain/table/views/ViewQueryDefaults.spec.ts create mode 100644 packages/v2/core/src/domain/table/views/ViewQueryDefaults.ts create mode 100644 packages/v2/core/src/domain/table/views/ViewType.spec.ts create mode 100644 packages/v2/core/src/domain/table/views/ViewType.ts create mode 100644 packages/v2/core/src/domain/table/views/types/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/table/views/types/CalendarView.ts create mode 100644 packages/v2/core/src/domain/table/views/types/FormView.ts create mode 100644 packages/v2/core/src/domain/table/views/types/GalleryView.ts create mode 100644 packages/v2/core/src/domain/table/views/types/GridView.ts create mode 100644 packages/v2/core/src/domain/table/views/types/KanbanView.ts create mode 100644 packages/v2/core/src/domain/table/views/types/PluginView.ts create mode 100644 packages/v2/core/src/domain/table/views/visitors/ARCHITECTURE.md create mode 100644 packages/v2/core/src/domain/table/views/visitors/CloneViewVisitor.ts create mode 100644 packages/v2/core/src/domain/table/views/visitors/IViewVisitor.ts create mode 100644 packages/v2/core/src/domain/table/views/visitors/NoopViewVisitor.ts create mode 100644 packages/v2/core/src/index.spec.ts create mode 100644 packages/v2/core/src/index.ts create mode 100644 packages/v2/core/src/ports/ARCHITECTURE.md create mode 100644 packages/v2/core/src/ports/AttachmentLookupService.ts create mode 100644 packages/v2/core/src/ports/BaseRepository.ts create mode 100644 packages/v2/core/src/ports/CommandBus.ts create mode 100644 packages/v2/core/src/ports/CsvParser.ts create mode 100644 packages/v2/core/src/ports/DotTeaParser.ts create mode 100644 packages/v2/core/src/ports/EventBus.ts create mode 100644 packages/v2/core/src/ports/EventHandler.ts create mode 100644 packages/v2/core/src/ports/ExecutionContext.ts create mode 100644 packages/v2/core/src/ports/HandlerResolver.ts create mode 100644 packages/v2/core/src/ports/Hasher.ts create mode 100644 packages/v2/core/src/ports/Logger.ts create mode 100644 packages/v2/core/src/ports/QueryBus.ts create mode 100644 packages/v2/core/src/ports/RealtimeChange.ts create mode 100644 packages/v2/core/src/ports/RealtimeDocId.spec.ts create mode 100644 packages/v2/core/src/ports/RealtimeDocId.ts create mode 100644 packages/v2/core/src/ports/RealtimeEngine.ts create mode 100644 packages/v2/core/src/ports/RecordCreateConstraintService.ts create mode 100644 packages/v2/core/src/ports/RecordOrderCalculator.ts create mode 100644 packages/v2/core/src/ports/RepositoryQuery.ts create mode 100644 packages/v2/core/src/ports/TableRecordQueryRepository.ts create mode 100644 packages/v2/core/src/ports/TableRecordReadModel.ts create mode 100644 packages/v2/core/src/ports/TableRecordRepository.ts create mode 100644 packages/v2/core/src/ports/TableRepository.ts create mode 100644 packages/v2/core/src/ports/TableSchemaRepository.ts create mode 100644 packages/v2/core/src/ports/TraceSpan.spec.ts create mode 100644 packages/v2/core/src/ports/TraceSpan.ts create mode 100644 packages/v2/core/src/ports/Tracer.ts create mode 100644 packages/v2/core/src/ports/UndoRedoStore.ts create mode 100644 packages/v2/core/src/ports/UnitOfWork.ts create mode 100644 packages/v2/core/src/ports/UserLookupService.ts create mode 100644 packages/v2/core/src/ports/defaults/ARCHITECTURE.md create mode 100644 packages/v2/core/src/ports/defaults/NoopCsvParser.ts create mode 100644 packages/v2/core/src/ports/defaults/NoopEventBus.ts create mode 100644 packages/v2/core/src/ports/defaults/NoopHasher.ts create mode 100644 packages/v2/core/src/ports/defaults/NoopLogger.ts create mode 100644 packages/v2/core/src/ports/defaults/NoopPorts.spec.ts create mode 100644 packages/v2/core/src/ports/defaults/NoopRealtimeEngine.ts create mode 100644 packages/v2/core/src/ports/defaults/NoopRecordOrderCalculator.ts create mode 100644 packages/v2/core/src/ports/defaults/NoopTableRecordQueryRepository.ts create mode 100644 packages/v2/core/src/ports/defaults/NoopTableRecordRepository.ts create mode 100644 packages/v2/core/src/ports/defaults/NoopTableRepository.ts create mode 100644 packages/v2/core/src/ports/defaults/NoopTableSchemaRepository.ts create mode 100644 packages/v2/core/src/ports/defaults/NoopTracer.ts create mode 100644 packages/v2/core/src/ports/defaults/NoopUndoRedoStore.ts create mode 100644 packages/v2/core/src/ports/defaults/NoopUnitOfWork.ts create mode 100644 packages/v2/core/src/ports/defaults/index.spec.ts create mode 100644 packages/v2/core/src/ports/defaults/index.ts create mode 100644 packages/v2/core/src/ports/import/IImportSource.ts create mode 100644 packages/v2/core/src/ports/import/IImportSourceAdapter.ts create mode 100644 packages/v2/core/src/ports/import/IImportSourceRegistry.ts create mode 100644 packages/v2/core/src/ports/import/index.ts create mode 100644 packages/v2/core/src/ports/mappers/ARCHITECTURE.md create mode 100644 packages/v2/core/src/ports/mappers/TableMapper.ts create mode 100644 packages/v2/core/src/ports/mappers/defaults/ARCHITECTURE.md create mode 100644 packages/v2/core/src/ports/mappers/defaults/DefaultTableMapper.spec.ts create mode 100644 packages/v2/core/src/ports/mappers/defaults/DefaultTableMapper.ts create mode 100644 packages/v2/core/src/ports/mappers/defaults/index.spec.ts create mode 100644 packages/v2/core/src/ports/mappers/defaults/index.ts create mode 100644 packages/v2/core/src/ports/memory/ARCHITECTURE.md create mode 100644 packages/v2/core/src/ports/memory/AsyncMemoryEventBus.ts create mode 100644 packages/v2/core/src/ports/memory/MemoryCommandBus.ts create mode 100644 packages/v2/core/src/ports/memory/MemoryEventBus.ts create mode 100644 packages/v2/core/src/ports/memory/MemoryPorts.spec.ts create mode 100644 packages/v2/core/src/ports/memory/MemoryQueryBus.ts create mode 100644 packages/v2/core/src/ports/memory/MemoryTableRepository.ts create mode 100644 packages/v2/core/src/ports/memory/MemoryUndoRedoStore.spec.ts create mode 100644 packages/v2/core/src/ports/memory/MemoryUndoRedoStore.ts create mode 100644 packages/v2/core/src/ports/memory/index.spec.ts create mode 100644 packages/v2/core/src/ports/memory/index.ts create mode 100644 packages/v2/core/src/ports/tokens.spec.ts create mode 100644 packages/v2/core/src/ports/tokens.ts create mode 100644 packages/v2/core/src/queries/ARCHITECTURE.md create mode 100644 packages/v2/core/src/queries/GetRecordByIdHandler.ts create mode 100644 packages/v2/core/src/queries/GetRecordByIdQuery.ts create mode 100644 packages/v2/core/src/queries/GetTableByIdHandler.spec.ts create mode 100644 packages/v2/core/src/queries/GetTableByIdHandler.ts create mode 100644 packages/v2/core/src/queries/GetTableByIdQuery.spec.ts create mode 100644 packages/v2/core/src/queries/GetTableByIdQuery.ts create mode 100644 packages/v2/core/src/queries/ListBasesHandler.spec.ts create mode 100644 packages/v2/core/src/queries/ListBasesHandler.ts create mode 100644 packages/v2/core/src/queries/ListBasesQuery.ts create mode 100644 packages/v2/core/src/queries/ListTableRecordsHandler.spec.ts create mode 100644 packages/v2/core/src/queries/ListTableRecordsHandler.ts create mode 100644 packages/v2/core/src/queries/ListTableRecordsQuery.spec.ts create mode 100644 packages/v2/core/src/queries/ListTableRecordsQuery.ts create mode 100644 packages/v2/core/src/queries/ListTablesHandler.spec.ts create mode 100644 packages/v2/core/src/queries/ListTablesHandler.ts create mode 100644 packages/v2/core/src/queries/ListTablesQuery.spec.ts create mode 100644 packages/v2/core/src/queries/ListTablesQuery.ts create mode 100644 packages/v2/core/src/queries/QueryHandler.ts create mode 100644 packages/v2/core/src/queries/RecordFilterDto.spec.ts create mode 100644 packages/v2/core/src/queries/RecordFilterDto.ts create mode 100644 packages/v2/core/src/queries/RecordFilterMapper.spec.ts create mode 100644 packages/v2/core/src/queries/RecordFilterMapper.ts create mode 100644 packages/v2/core/src/schemas/field/common.schema.ts create mode 100644 packages/v2/core/src/schemas/field/index.ts create mode 100644 packages/v2/core/src/schemas/field/tableField.schema.ts create mode 100644 packages/v2/core/src/schemas/index.ts create mode 100644 packages/v2/core/src/schemas/table/createTable.schema.ts create mode 100644 packages/v2/core/src/schemas/table/index.ts create mode 100644 packages/v2/core/src/schemas/table/view.schema.ts create mode 100644 packages/v2/core/tsconfig.build.json create mode 100644 packages/v2/core/tsconfig.eslint.json create mode 100644 packages/v2/core/tsconfig.json create mode 100644 packages/v2/core/tsdown.config.ts create mode 100644 packages/v2/core/vitest.config.ts create mode 100644 packages/v2/debug-data/.eslintrc.cjs create mode 100644 packages/v2/debug-data/ARCHITECTURE.md create mode 100644 packages/v2/debug-data/package.json create mode 100644 packages/v2/debug-data/src/adapters/postgres/PostgresDebugMetaStore.ts create mode 100644 packages/v2/debug-data/src/adapters/postgres/PostgresDebugRecordStore.ts create mode 100644 packages/v2/debug-data/src/adapters/postgres/PostgresFieldRelationGraph.ts create mode 100644 packages/v2/debug-data/src/di/register.ts create mode 100644 packages/v2/debug-data/src/di/tokens.ts create mode 100644 packages/v2/debug-data/src/index.ts create mode 100644 packages/v2/debug-data/src/ports/DebugMetaStore.ts create mode 100644 packages/v2/debug-data/src/ports/DebugRecordStore.ts create mode 100644 packages/v2/debug-data/src/ports/FieldRelationGraph.ts create mode 100644 packages/v2/debug-data/src/service/DebugDataService.ts create mode 100644 packages/v2/debug-data/src/types.ts create mode 100644 packages/v2/debug-data/tsconfig.build.json create mode 100644 packages/v2/debug-data/tsconfig.eslint.json create mode 100644 packages/v2/debug-data/tsconfig.json create mode 100644 packages/v2/debug-data/tsdown.config.ts create mode 100644 packages/v2/devtools/.eslintrc.cjs create mode 100644 packages/v2/devtools/.gitignore create mode 100644 packages/v2/devtools/bin/run.ts create mode 100644 packages/v2/devtools/package.json create mode 100644 packages/v2/devtools/src/commands/dottea/import.ts create mode 100644 packages/v2/devtools/src/commands/dottea/index.ts create mode 100644 packages/v2/devtools/src/commands/explain/create.ts create mode 100644 packages/v2/devtools/src/commands/explain/delete.ts create mode 100644 packages/v2/devtools/src/commands/explain/index.ts create mode 100644 packages/v2/devtools/src/commands/explain/paste.ts create mode 100644 packages/v2/devtools/src/commands/explain/update.ts create mode 100644 packages/v2/devtools/src/commands/index.ts create mode 100644 packages/v2/devtools/src/commands/mock/generate.ts create mode 100644 packages/v2/devtools/src/commands/mock/index.ts create mode 100644 packages/v2/devtools/src/commands/records/create.ts create mode 100644 packages/v2/devtools/src/commands/records/delete.ts create mode 100644 packages/v2/devtools/src/commands/records/get.ts create mode 100644 packages/v2/devtools/src/commands/records/index.ts create mode 100644 packages/v2/devtools/src/commands/records/list.ts create mode 100644 packages/v2/devtools/src/commands/records/update.ts create mode 100644 packages/v2/devtools/src/commands/relations.ts create mode 100644 packages/v2/devtools/src/commands/schema/field.ts create mode 100644 packages/v2/devtools/src/commands/schema/index.ts create mode 100644 packages/v2/devtools/src/commands/schema/table.ts create mode 100644 packages/v2/devtools/src/commands/shared.ts create mode 100644 packages/v2/devtools/src/commands/tables/create.ts create mode 100644 packages/v2/devtools/src/commands/tables/describe-schema.ts create mode 100644 packages/v2/devtools/src/commands/tables/index.ts create mode 100644 packages/v2/devtools/src/commands/underlying/field.ts create mode 100644 packages/v2/devtools/src/commands/underlying/fields.ts create mode 100644 packages/v2/devtools/src/commands/underlying/index.ts create mode 100644 packages/v2/devtools/src/commands/underlying/record.ts create mode 100644 packages/v2/devtools/src/commands/underlying/records.ts create mode 100644 packages/v2/devtools/src/commands/underlying/table.ts create mode 100644 packages/v2/devtools/src/commands/underlying/tables.ts create mode 100644 packages/v2/devtools/src/errors/CliError.ts create mode 100644 packages/v2/devtools/src/errors/index.ts create mode 100644 packages/v2/devtools/src/index.ts create mode 100644 packages/v2/devtools/src/layers/AppLayer.ts create mode 100644 packages/v2/devtools/src/layers/CommandExplainLive.ts create mode 100644 packages/v2/devtools/src/layers/DatabaseLive.ts create mode 100644 packages/v2/devtools/src/layers/DatabasePgliteLive.ts create mode 100644 packages/v2/devtools/src/layers/DebugDataLive.ts create mode 100644 packages/v2/devtools/src/layers/DotTeaImporterLive.ts create mode 100644 packages/v2/devtools/src/layers/MockRecordsLive.ts create mode 100644 packages/v2/devtools/src/layers/NodeCryptoHasher.ts create mode 100644 packages/v2/devtools/src/layers/OutputLive.ts create mode 100644 packages/v2/devtools/src/layers/RecordMutationLive.ts create mode 100644 packages/v2/devtools/src/layers/SchemaCheckerLive.ts create mode 100644 packages/v2/devtools/src/layers/TableCreatorLive.ts create mode 100644 packages/v2/devtools/src/layers/index.ts create mode 100644 packages/v2/devtools/src/services/CommandExplain.ts create mode 100644 packages/v2/devtools/src/services/Database.ts create mode 100644 packages/v2/devtools/src/services/DebugData.ts create mode 100644 packages/v2/devtools/src/services/DotTeaImporter.ts create mode 100644 packages/v2/devtools/src/services/MockRecords.ts create mode 100644 packages/v2/devtools/src/services/Output.ts create mode 100644 packages/v2/devtools/src/services/RecordMutation.ts create mode 100644 packages/v2/devtools/src/services/SchemaChecker.ts create mode 100644 packages/v2/devtools/src/services/TableCreator.ts create mode 100644 packages/v2/devtools/src/services/index.ts create mode 100644 packages/v2/devtools/src/utils/connection.ts create mode 100644 packages/v2/devtools/src/utils/index.ts create mode 100644 packages/v2/devtools/tsconfig.build.json create mode 100644 packages/v2/devtools/tsconfig.eslint.json create mode 100644 packages/v2/devtools/tsconfig.json create mode 100644 packages/v2/devtools/tsdown.config.ts create mode 100644 packages/v2/di/.eslintrc.cjs create mode 100644 packages/v2/di/.gitignore create mode 100644 packages/v2/di/package.json create mode 100644 packages/v2/di/src/index.ts create mode 100644 packages/v2/di/tsconfig.build.json create mode 100644 packages/v2/di/tsconfig.eslint.json create mode 100644 packages/v2/di/tsconfig.json create mode 100644 packages/v2/di/tsdown.config.ts create mode 100644 packages/v2/di/vitest.config.ts create mode 100644 packages/v2/dottea/.eslintrc.cjs create mode 100644 packages/v2/dottea/ARCHITECTURE.md create mode 100644 packages/v2/dottea/package.json create mode 100644 packages/v2/dottea/src/index.ts create mode 100644 packages/v2/dottea/src/normalizer/DotTeaFieldNormalizer.ts create mode 100644 packages/v2/dottea/src/normalizer/index.ts create mode 100644 packages/v2/dottea/tsconfig.build.json create mode 100644 packages/v2/dottea/tsconfig.eslint.json create mode 100644 packages/v2/dottea/tsconfig.json create mode 100644 packages/v2/dottea/tsdown.config.ts create mode 100644 packages/v2/e2e/.eslintrc.cjs create mode 100644 packages/v2/e2e/.gitignore create mode 100644 packages/v2/e2e/package.json create mode 100644 packages/v2/e2e/src/clear.e2e.spec.ts create mode 100644 packages/v2/e2e/src/computed-matrix/chain.matrix.spec.ts create mode 100644 packages/v2/e2e/src/computed-matrix/conditional.matrix.spec.ts create mode 100644 packages/v2/e2e/src/computed-matrix/formula.matrix.spec.ts create mode 100644 packages/v2/e2e/src/computed-matrix/link-ops.matrix.spec.ts create mode 100644 packages/v2/e2e/src/computed-matrix/lookup.matrix.spec.ts create mode 100644 packages/v2/e2e/src/computed-matrix/rollup.matrix.spec.ts create mode 100644 packages/v2/e2e/src/computed-matrix/self-ref.matrix.spec.ts create mode 100644 packages/v2/e2e/src/computed-matrix/shared/generators.ts create mode 100644 packages/v2/e2e/src/computed-matrix/shared/index.ts create mode 100644 packages/v2/e2e/src/computed-matrix/shared/setup.ts create mode 100644 packages/v2/e2e/src/computed-matrix/shared/types.ts create mode 100644 packages/v2/e2e/src/computed-matrix/shared/validators.ts create mode 100644 packages/v2/e2e/src/computed.e2e.spec.ts create mode 100644 packages/v2/e2e/src/conditional-lookup.e2e.spec.ts create mode 100644 packages/v2/e2e/src/conditionalFieldDirtyPropagation.e2e.spec.ts create mode 100644 packages/v2/e2e/src/conditionalFields.e2e.spec.ts create mode 100644 packages/v2/e2e/src/createField.e2e.spec.ts create mode 100644 packages/v2/e2e/src/createRecord.e2e.spec.ts create mode 100644 packages/v2/e2e/src/createRecordLink.e2e.spec.ts create mode 100644 packages/v2/e2e/src/createRecords.e2e.spec.ts create mode 100644 packages/v2/e2e/src/createTable.e2e.spec.ts create mode 100644 packages/v2/e2e/src/credit-limit.e2e.spec.ts create mode 100644 packages/v2/e2e/src/date-time.e2e.spec.ts create mode 100644 packages/v2/e2e/src/deleteByRange.e2e.spec.ts create mode 100644 packages/v2/e2e/src/deleteField.e2e.spec.ts create mode 100644 packages/v2/e2e/src/deleteRecords-with-links.e2e.spec.ts create mode 100644 packages/v2/e2e/src/deleteRecords.e2e.spec.ts create mode 100644 packages/v2/e2e/src/deleteTable.e2e.spec.ts create mode 100644 packages/v2/e2e/src/duplicateField.e2e.spec.ts create mode 100644 packages/v2/e2e/src/duplicateRecord.e2e.spec.ts create mode 100644 packages/v2/e2e/src/field-condition-isSymbol.e2e.spec.ts create mode 100644 packages/v2/e2e/src/formula-datetime-format.e2e.spec.ts create mode 100644 packages/v2/e2e/src/formula-lookup-empty-text-if.e2e.spec.ts create mode 100644 packages/v2/e2e/src/formula.e2e.spec.ts create mode 100644 packages/v2/e2e/src/getTableById.e2e.spec.ts create mode 100644 packages/v2/e2e/src/importCsv.e2e.spec.ts create mode 100644 packages/v2/e2e/src/importRecords.e2e.spec.ts create mode 100644 packages/v2/e2e/src/index.ts create mode 100644 packages/v2/e2e/src/link-advisory-lock.e2e.spec.ts create mode 100644 packages/v2/e2e/src/link-exclusivity-constraints.e2e.spec.ts create mode 100644 packages/v2/e2e/src/link-formula-lookup-single.e2e.spec.ts create mode 100644 packages/v2/e2e/src/link-one-one-duplicate.e2e.spec.ts create mode 100644 packages/v2/e2e/src/link-order.e2e.spec.ts create mode 100644 packages/v2/e2e/src/link-ordering.e2e.spec.ts create mode 100644 packages/v2/e2e/src/link-title-multivalue-lookup.e2e.spec.ts create mode 100644 packages/v2/e2e/src/listTables.e2e.spec.ts create mode 100644 packages/v2/e2e/src/lookup-cross-base.e2e.spec.ts create mode 100644 packages/v2/e2e/src/numeric-coercion.e2e.spec.ts create mode 100644 packages/v2/e2e/src/paste.e2e.spec.ts create mode 100644 packages/v2/e2e/src/realtimeShareDb.e2e.spec.ts create mode 100644 packages/v2/e2e/src/record-constraint-violations.e2e.spec.ts create mode 100644 packages/v2/e2e/src/record-create-update-chain.e2e.spec.ts create mode 100644 packages/v2/e2e/src/record-cycle-ops.e2e.spec.ts create mode 100644 packages/v2/e2e/src/record-field-key.e2e.spec.ts create mode 100644 packages/v2/e2e/src/record-http-compat.e2e.spec.ts create mode 100644 packages/v2/e2e/src/record-ordering.e2e.spec.ts create mode 100644 packages/v2/e2e/src/renameTable.e2e.spec.ts create mode 100644 packages/v2/e2e/src/shared/globalTestContext.ts create mode 100644 packages/v2/e2e/src/shared/vitest.setup.ts create mode 100644 packages/v2/e2e/src/undoRedoComplex.e2e.spec.ts create mode 100644 packages/v2/e2e/src/updateRecord.e2e.spec.ts create mode 100644 packages/v2/e2e/src/updateRecordUndoRedo.e2e.spec.ts create mode 100644 packages/v2/e2e/tsconfig.build.json create mode 100644 packages/v2/e2e/tsconfig.eslint.json create mode 100644 packages/v2/e2e/tsconfig.json create mode 100644 packages/v2/e2e/tsdown.config.ts create mode 100644 packages/v2/e2e/vitest.config.ts create mode 100644 packages/v2/field-dependency-core/package.json create mode 100644 packages/v2/field-dependency-core/src/edge-builder.spec.ts create mode 100644 packages/v2/field-dependency-core/src/edge-builder.ts create mode 100644 packages/v2/field-dependency-core/src/index.ts create mode 100644 packages/v2/field-dependency-core/src/parsers.spec.ts create mode 100644 packages/v2/field-dependency-core/src/parsers.ts create mode 100644 packages/v2/field-dependency-core/src/types.ts create mode 100644 packages/v2/field-dependency-core/tsconfig.build.json create mode 100644 packages/v2/field-dependency-core/tsconfig.json create mode 100644 packages/v2/field-dependency-core/tsdown.config.ts create mode 100644 packages/v2/field-dependency-core/vitest.config.ts create mode 100644 packages/v2/formula-sql-pg/.eslintrc.cjs create mode 100644 packages/v2/formula-sql-pg/.gitignore create mode 100644 packages/v2/formula-sql-pg/ARCHITECTURE.md create mode 100644 packages/v2/formula-sql-pg/package.json create mode 100644 packages/v2/formula-sql-pg/src/ArrayFunctions.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/BinaryOperators.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/DateFunctions.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/DirectFieldReferences.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/EdgeCases.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/ErrorHandling.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/FieldFormattingSql.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/FieldFormattingSql.ts create mode 100644 packages/v2/formula-sql-pg/src/FieldSqlCoercionVisitor.ts create mode 100644 packages/v2/formula-sql-pg/src/FormattingMatrix.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/FormulaSqlPgExpressionBuilder.ts create mode 100644 packages/v2/formula-sql-pg/src/FormulaSqlPgFunctions.ts create mode 100644 packages/v2/formula-sql-pg/src/FormulaSqlPgTranslator.ts create mode 100644 packages/v2/formula-sql-pg/src/FormulaSqlPgVisitor.ts create mode 100644 packages/v2/formula-sql-pg/src/IfBranchNormalization.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/LogicalFunctions.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/LookupArrayNormalization.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/NumericFunctions.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/ParameterlessFunctions.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/PgSqlHelpers.ts create mode 100644 packages/v2/formula-sql-pg/src/PgTypeValidation.pg-integration.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/PgTypeValidation.pg-smoke.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/PgTypeValidationStrategy.ts create mode 100644 packages/v2/formula-sql-pg/src/SqlExpression.ts create mode 100644 packages/v2/formula-sql-pg/src/TextFunctions.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/TimeZonePgMapping.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/TimeZonePgMapping.ts create mode 100644 packages/v2/formula-sql-pg/src/TranslatorEdgeCases.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/TypeConsistency.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/VisitorEdgeCases.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/__snapshots__/ArrayFunctions.spec.ts.snap create mode 100644 packages/v2/formula-sql-pg/src/__snapshots__/BinaryOperators.spec.ts.snap create mode 100644 packages/v2/formula-sql-pg/src/__snapshots__/DateFunctions.spec.ts.snap create mode 100644 packages/v2/formula-sql-pg/src/__snapshots__/DirectFieldReferences.spec.ts.snap create mode 100644 packages/v2/formula-sql-pg/src/__snapshots__/EdgeCases.spec.ts.snap create mode 100644 packages/v2/formula-sql-pg/src/__snapshots__/ErrorHandling.spec.ts.snap create mode 100644 packages/v2/formula-sql-pg/src/__snapshots__/IfBranchNormalization.spec.ts.snap create mode 100644 packages/v2/formula-sql-pg/src/__snapshots__/LogicalFunctions.spec.ts.snap create mode 100644 packages/v2/formula-sql-pg/src/__snapshots__/NumericFunctions.spec.ts.snap create mode 100644 packages/v2/formula-sql-pg/src/__snapshots__/ParameterlessFunctions.spec.ts.snap create mode 100644 packages/v2/formula-sql-pg/src/__snapshots__/TextFunctions.spec.ts.snap create mode 100644 packages/v2/formula-sql-pg/src/__tests__/PgSqlHelpers.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/__tests__/datetime-format.util.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/__tests__/strategies/PgTypeValidationStrategy.spec.ts create mode 100644 packages/v2/formula-sql-pg/src/index.ts create mode 100644 packages/v2/formula-sql-pg/src/strategies/Pg16TypeValidationStrategy.ts create mode 100644 packages/v2/formula-sql-pg/src/strategies/PgLegacyTypeValidationStrategy.ts create mode 100644 packages/v2/formula-sql-pg/src/strategies/index.ts create mode 100644 packages/v2/formula-sql-pg/src/testkit/FormulaSqlPgTestkit.ts create mode 100644 packages/v2/formula-sql-pg/src/testkit/vitest.setup.ts create mode 100644 packages/v2/formula-sql-pg/src/tokens.ts create mode 100644 packages/v2/formula-sql-pg/src/utils/datetime-format.util.ts create mode 100644 packages/v2/formula-sql-pg/tsconfig.build.json create mode 100644 packages/v2/formula-sql-pg/tsconfig.eslint.json create mode 100644 packages/v2/formula-sql-pg/tsconfig.json create mode 100644 packages/v2/formula-sql-pg/tsdown.config.ts create mode 100644 packages/v2/formula-sql-pg/vitest.config.ts create mode 100644 packages/v2/import/package.json create mode 100644 packages/v2/import/src/adapters/CsvImportAdapter.spec.ts create mode 100644 packages/v2/import/src/adapters/CsvImportAdapter.ts create mode 100644 packages/v2/import/src/adapters/ExcelImportAdapter.spec.ts create mode 100644 packages/v2/import/src/adapters/ExcelImportAdapter.ts create mode 100644 packages/v2/import/src/adapters/index.ts create mode 100644 packages/v2/import/src/di/index.ts create mode 100644 packages/v2/import/src/di/registerImportServices.ts create mode 100644 packages/v2/import/src/index.ts create mode 100644 packages/v2/import/src/ports/ImportSourceRegistry.spec.ts create mode 100644 packages/v2/import/src/ports/ImportSourceRegistry.ts create mode 100644 packages/v2/import/src/ports/index.ts create mode 100644 packages/v2/import/tsconfig.build.json create mode 100644 packages/v2/import/tsconfig.json create mode 100644 packages/v2/import/vitest.config.ts create mode 100644 packages/v2/mock-records/.eslintrc.cjs create mode 100644 packages/v2/mock-records/package.json create mode 100644 packages/v2/mock-records/src/MockRecordGenerator.ts create mode 100644 packages/v2/mock-records/src/TableDependencyAnalyzer.ts create mode 100644 packages/v2/mock-records/src/index.ts create mode 100644 packages/v2/mock-records/src/types.ts create mode 100644 packages/v2/mock-records/src/visitors/FieldMockValueVisitor.ts create mode 100644 packages/v2/mock-records/tsconfig.build.json create mode 100644 packages/v2/mock-records/tsconfig.eslint.json create mode 100644 packages/v2/mock-records/tsconfig.json create mode 100644 packages/v2/mock-records/tsdown.config.ts create mode 100644 packages/v2/postgres-schema/.eslintrc.cjs create mode 100644 packages/v2/postgres-schema/.gitignore create mode 100644 packages/v2/postgres-schema/package.json create mode 100644 packages/v2/postgres-schema/src/index.ts create mode 100644 packages/v2/postgres-schema/src/v1/types.ts create mode 100644 packages/v2/postgres-schema/tsconfig.build.json create mode 100644 packages/v2/postgres-schema/tsconfig.eslint.json create mode 100644 packages/v2/postgres-schema/tsconfig.json create mode 100644 packages/v2/postgres-schema/tsdown.config.ts create mode 100644 packages/v2/table-templates/.eslintrc.cjs create mode 100644 packages/v2/table-templates/.gitignore create mode 100644 packages/v2/table-templates/ARCHITECTURE.md create mode 100644 packages/v2/table-templates/package.json create mode 100644 packages/v2/table-templates/src/index.ts create mode 100644 packages/v2/table-templates/src/templates/all-base-fields.ts create mode 100644 packages/v2/table-templates/src/templates/all-field-types.ts create mode 100644 packages/v2/table-templates/src/templates/bug-triage.ts create mode 100644 packages/v2/table-templates/src/templates/content-calendar.ts create mode 100644 packages/v2/table-templates/src/templates/crm.ts create mode 100644 packages/v2/table-templates/src/templates/hr-management.ts create mode 100644 packages/v2/table-templates/src/templates/index.ts create mode 100644 packages/v2/table-templates/src/templates/personal-finance.ts create mode 100644 packages/v2/table-templates/src/templates/project-tracker.ts create mode 100644 packages/v2/table-templates/src/templates/simple.ts create mode 100644 packages/v2/table-templates/src/templates/todo.ts create mode 100644 packages/v2/table-templates/src/types.ts create mode 100644 packages/v2/table-templates/src/utils.ts create mode 100644 packages/v2/table-templates/tsconfig.build.json create mode 100644 packages/v2/table-templates/tsconfig.eslint.json create mode 100644 packages/v2/table-templates/tsconfig.json create mode 100644 packages/v2/table-templates/tsdown.config.ts create mode 100644 packages/v2/table-templates/vitest.config.ts create mode 100644 packages/v2/test-node/.eslintrc.cjs create mode 100644 packages/v2/test-node/.gitignore create mode 100644 packages/v2/test-node/package.json create mode 100644 packages/v2/test-node/src/commands/CreateFieldHandler.db.spec.ts create mode 100644 packages/v2/test-node/src/commands/CreateFieldHandler.spec.ts create mode 100644 packages/v2/test-node/src/commands/CreateRecordHandler.db.spec.ts create mode 100644 packages/v2/test-node/src/commands/CreateRecordsHandler.db.spec.ts create mode 100644 packages/v2/test-node/src/commands/CreateRecordsStream.bench.spec.ts create mode 100644 packages/v2/test-node/src/commands/CreateTableCommand.spec.ts create mode 100644 packages/v2/test-node/src/commands/CreateTableHandler.db.spec.ts create mode 100644 packages/v2/test-node/src/commands/CreateTableHandler.spec.ts create mode 100644 packages/v2/test-node/src/commands/DeleteFieldHandler.spec.ts create mode 100644 packages/v2/test-node/src/commands/RenameTableHandler.spec.ts create mode 100644 packages/v2/test-node/src/commands/UpdateRecordHandler.db.spec.ts create mode 100644 packages/v2/test-node/src/commands/UpdateRecordUndoRedo.db.spec.ts create mode 100644 packages/v2/test-node/src/computed/ComputedFieldVersionIncrement.db.spec.ts create mode 100644 packages/v2/test-node/src/computed/ComputedFormulaTypeCompatibility.db.spec.ts create mode 100644 packages/v2/test-node/src/computed/ComputedOptimization.e2e.spec.ts create mode 100644 packages/v2/test-node/src/computed/ComputedUpdatePlanner.db.spec.ts create mode 100644 packages/v2/test-node/src/computed/FieldDependencyChain.integration.spec.ts create mode 100644 packages/v2/test-node/src/computed/FieldDependencyGraph.db.spec.ts create mode 100644 packages/v2/test-node/src/index.ts create mode 100644 packages/v2/test-node/src/queries/GetTableByIdHandler.spec.ts create mode 100644 packages/v2/test-node/src/queries/GetTableByIdQuery.spec.ts create mode 100644 packages/v2/test-node/src/queries/ListTablesHandler.spec.ts create mode 100644 packages/v2/test-node/src/testkit/createV2NodeTestContainer.ts create mode 100644 packages/v2/test-node/src/testkit/v2NodeTestContainer.ts create mode 100644 packages/v2/test-node/tsconfig.build.json create mode 100644 packages/v2/test-node/tsconfig.eslint.json create mode 100644 packages/v2/test-node/tsconfig.json create mode 100644 packages/v2/test-node/tsdown.config.ts create mode 100644 packages/v2/test-node/vitest.config.ts create mode 100644 packages/v2/test-node/vitest.setup.ts create mode 100644 packages/v2/tsdown-config/package.json create mode 100644 packages/v2/tsdown-config/src/index.cjs create mode 100644 packages/v2/tsdown-config/src/index.d.ts create mode 100644 packages/v2/tsdown-config/src/index.js create mode 100644 packages/v2/utils/.eslintrc.cjs create mode 100644 packages/v2/utils/.gitignore create mode 100644 packages/v2/utils/ARCHITECTURE.md create mode 100644 packages/v2/utils/package.json create mode 100644 packages/v2/utils/src/index.ts create mode 100644 packages/v2/utils/src/printTable.spec.ts create mode 100644 packages/v2/utils/src/printTable.ts create mode 100644 packages/v2/utils/tsconfig.build.json create mode 100644 packages/v2/utils/tsconfig.eslint.json create mode 100644 packages/v2/utils/tsconfig.json create mode 100644 packages/v2/utils/tsdown.config.ts create mode 100644 packages/v2/utils/vitest.config.ts diff --git a/.dockerignore b/.dockerignore index b94aea2e46..133209d7cf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -45,8 +45,10 @@ tmp # other **/db +!packages/v2/adapter-postgres-state/src/db +!packages/v2/adapter-postgres-state/src/db/** **/.assets **/.temporary **.DS_Store docs -**/*.md \ No newline at end of file +**/*.md diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 0dae4515f2..ffc6c2187d 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -17,14 +17,28 @@ concurrency: jobs: test: runs-on: ubuntu-latest - name: Integration Tests + name: Integration Tests - ${{ matrix.e2e.database-type }} ${{ matrix.e2e.shard }} ${{ matrix.runtime.mode }} strategy: fail-fast: false matrix: node-version: [22.18.0] - database-type: [postgres] - shard: [1/4, 2/4, 3/4, 4/4] + runtime: + - mode: v1 + force-v2-all: '' + computed-update-mode: '' + - mode: v2 + force-v2-all: 'true' + computed-update-mode: 'sync' + e2e: + - database-type: postgres + shard: 1/4 + - database-type: postgres + shard: 2/4 + - database-type: postgres + shard: 3/4 + - database-type: postgres + shard: 4/4 env: CI: 1 @@ -42,12 +56,14 @@ jobs: - name: 🧪 Run Tests env: CI: 1 + FORCE_V2_ALL: ${{ matrix.runtime.force-v2-all }} + V2_COMPUTED_UPDATE_MODE: ${{ matrix.runtime.computed-update-mode }} VITEST_MAX_THREADS: 2 VITEST_MIN_THREADS: 1 - VITEST_SHARD: ${{ matrix.shard }} + VITEST_SHARD: ${{ matrix.e2e.shard }} VITEST_REPORTER: blob run: | - make ${{ matrix.database-type }}.integration.test + make ${{ matrix.e2e.database-type }}.integration.test pnpm -F "@teable/backend" test-unit-cover pnpm -F "@teable/backend" merge-cover pnpm -F "@teable/backend" generate-cover diff --git a/.github/workflows/issue-id-check.yml b/.github/workflows/issue-id-check.yml index caea189d38..0c0f62b72e 100644 --- a/.github/workflows/issue-id-check.yml +++ b/.github/workflows/issue-id-check.yml @@ -32,27 +32,27 @@ jobs: HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | echo "🔍 Checking for Issue IDs (pattern: T followed by numbers)..." - + # Extract Issue IDs from PR title echo "📝 PR Title: $PR_TITLE" TITLE_ISSUES=$(echo "$PR_TITLE" | grep -oE 'T[0-9]+' || true) - + # Extract Issue IDs from PR body/description echo "📝 PR Body:" echo "$PR_BODY" BODY_ISSUES=$(echo "$PR_BODY" | grep -oE 'T[0-9]+' || true) - + # Extract Issue IDs from all commit messages (including body) echo "📝 Commit Messages:" COMMIT_MESSAGES=$(git log --format="%B" $BASE_SHA..$HEAD_SHA 2>/dev/null || git log --format="%B" -n 20) echo "$COMMIT_MESSAGES" COMMIT_ISSUES=$(echo "$COMMIT_MESSAGES" | grep -oE 'T[0-9]+' || true) - + # Combine all Issue IDs and remove duplicates ALL_ISSUES=$(echo -e "$TITLE_ISSUES\n$BODY_ISSUES\n$COMMIT_ISSUES" | grep -E '^T[0-9]+$' | sort -u | tr '\n' ' ' | xargs) - + echo "📋 Found Issue IDs: $ALL_ISSUES" - + if [ -z "$ALL_ISSUES" ]; then echo "❌ No Issue IDs found!" echo "issue_ids=" >> $GITHUB_OUTPUT @@ -82,7 +82,7 @@ jobs: PR_URL: ${{ github.event.pull_request.html_url }} run: | echo "🔗 Verifying Issue IDs in Teable: $ISSUE_IDS" - + # Build filter for multiple Issue IDs FILTER_SET="" for ISSUE_ID in $ISSUE_IDS; do @@ -91,49 +91,49 @@ jobs: fi FILTER_SET="$FILTER_SET{\"fieldId\":\"Issue_ID\",\"operator\":\"is\",\"value\":\"$ISSUE_ID\"}" done - + FILTER="{\"conjunction\":\"or\",\"filterSet\":[$FILTER_SET]}" ENCODED_FILTER=$(echo "$FILTER" | jq -sRr @uri) - + echo "📤 Querying Teable API..." - + RESPONSE=$(curl -s -w "\n%{http_code}" -X GET \ "https://app.teable.ai/api/table/tblNHimLUhUDtC3K7Jk/record?fieldKeyType=dbFieldName&viewId=viwBK7iTy1604XbFdYh&filter=$ENCODED_FILTER" \ -H "Authorization: Bearer $TEABLE_API_TOKEN" \ -H "Accept: application/json") - + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) BODY=$(echo "$RESPONSE" | sed '$d') - + echo "📥 API Response Code: $HTTP_CODE" - + if [ "$HTTP_CODE" != "200" ]; then echo "::error::Failed to query Teable API. HTTP Code: $HTTP_CODE" echo "Response: $BODY" exit 1 fi - + # Check if records exist RECORD_COUNT=$(echo "$BODY" | jq '.records | length') echo "📊 Found $RECORD_COUNT matching records in Teable" - + if [ "$RECORD_COUNT" -eq 0 ]; then echo "::error::No matching Issue IDs found in Teable. Please ensure the Issue IDs ($ISSUE_IDS) exist." exit 1 fi - + # Extract record IDs and their statuses for updating echo "$BODY" | jq -c '.records[] | {id: .id, status: .fields.status}' > /tmp/records.json - + RECORD_IDS=$(echo "$BODY" | jq -r '.records[].id') echo "record_ids<> $GITHUB_OUTPUT echo "$RECORD_IDS" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT echo "record_count=$RECORD_COUNT" >> $GITHUB_OUTPUT - + # Save full response for status checking echo "$BODY" > /tmp/teable_response.json - + echo "✅ All Issue IDs verified successfully!" - name: 📝 Update Teable records (Community_PR & Status) @@ -143,10 +143,10 @@ jobs: PR_URL: ${{ github.event.pull_request.html_url }} run: | echo "📝 Updating Teable records..." - + # Status values that should be updated to "Entered development workflow" STATUSES_TO_UPDATE=("" "Need more information" "Added to backlog") - + # Read records from saved response cat /tmp/teable_response.json | jq -c '.records[]' | while read -r record; do RECORD_ID=$(echo "$record" | jq -r '.id') @@ -190,7 +190,7 @@ jobs: echo "✅ Successfully updated record $RECORD_ID" fi done - + echo "✅ Teable records update completed!" - name: 📝 Append Issue IDs to PR description @@ -202,10 +202,10 @@ jobs: PR_BODY: ${{ github.event.pull_request.body }} run: | echo "📝 Checking if Issue IDs need to be appended to PR description..." - + # Create Issue IDs reference line ISSUE_IDS_LINE="**Related Issues:** $ISSUE_IDS" - + # Check if Issue IDs are already in the PR body ISSUES_ALREADY_IN_BODY="true" for ISSUE_ID in $ISSUE_IDS; do @@ -214,31 +214,31 @@ jobs: break fi done - + # Check if the reference line already exists if echo "$PR_BODY" | grep -q "^\*\*Related Issues:\*\*"; then echo "✅ Related Issues line already exists in PR description, skipping update" exit 0 fi - + # If all Issue IDs are already in the body but not in the reference format, we still want to add the reference line # This ensures consistency and makes it easier to parse - + echo "📝 Appending Issue IDs to PR description..." - + # Append Issue IDs to PR body if [ -z "$PR_BODY" ]; then NEW_BODY="$ISSUE_IDS_LINE" else NEW_BODY="$PR_BODY - + --- $ISSUE_IDS_LINE" fi - + # Update PR description using GitHub CLI gh pr edit "$PR_NUMBER" --body "$NEW_BODY" - + echo "✅ PR description updated with Issue IDs!" - name: ✅ Check Complete diff --git a/.github/workflows/v2-benchmark-tests.yml b/.github/workflows/v2-benchmark-tests.yml new file mode 100644 index 0000000000..9965d80563 --- /dev/null +++ b/.github/workflows/v2-benchmark-tests.yml @@ -0,0 +1,72 @@ +name: V2 Benchmarks + +on: + workflow_dispatch: + pull_request: + branches: + - develop + paths: + - 'packages/v2/**' + - '.github/workflows/v2-benchmark-tests.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + bench: + runs-on: ubuntu-latest + name: V2 Benchmarks + env: + CI: 1 + TESTCONTAINERS_REUSE_ENABLE: 'false' + + strategy: + matrix: + node-version: [22.18.0] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: 📥 Monorepo install + uses: ./.github/actions/pnpm-install + + - name: 🧪 Run v2 benchmarks + run: | + pnpm -C packages/v2/benchmark-node bench + + bench-bun: + runs-on: ubuntu-latest + name: V2 Benchmarks (Bun) + env: + CI: 1 + TESTCONTAINERS_REUSE_ENABLE: 'false' + + strategy: + matrix: + node-version: [22.18.0] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Use Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: 'latest' + + - name: 📥 Monorepo install + uses: ./.github/actions/pnpm-install + + - name: 🧪 Run v2 bun benchmarks + run: | + pnpm -C packages/v2/benchmark-bun bench diff --git a/.github/workflows/v2-core-tests.yml b/.github/workflows/v2-core-tests.yml new file mode 100644 index 0000000000..388f41e004 --- /dev/null +++ b/.github/workflows/v2-core-tests.yml @@ -0,0 +1,143 @@ +name: V2 Tests + +on: + pull_request: + branches: + - develop + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + # Unit tests - run each package in parallel + unit-tests: + runs-on: ubuntu-latest + name: V2 Unit Tests (${{ matrix.package }}) + env: + CI: 1 + TESTCONTAINERS_REUSE_ENABLE: 'false' + + strategy: + fail-fast: false + max-parallel: 6 + matrix: + package: + - '@teable/v2-adapter-db-postgres-pg' + - '@teable/v2-adapter-repository-postgres' + - '@teable/v2-adapter-table-repository-postgres' + - '@teable/v2-core' + - '@teable/v2-formula-sql-pg' + - '@teable/v2-test-node' + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 22.18.0 + uses: actions/setup-node@v4 + with: + node-version: 22.18.0 + + - name: 📥 Monorepo install + uses: ./.github/actions/pnpm-install + with: + filter: ${{ matrix.package }} + + - name: 🧪 Run unit tests (${{ matrix.package }}) + run: | + pnpm -F "${{ matrix.package }}" --if-present test-unit-cover + + # E2E tests - use sharding for parallel execution (the slowest tests) + e2e-tests: + runs-on: ubuntu-latest + name: V2 E2E Tests (Shard ${{ matrix.shard }}/4) + env: + CI: 1 + TESTCONTAINERS_REUSE_ENABLE: 'false' + + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 22.18.0 + uses: actions/setup-node@v4 + with: + node-version: 22.18.0 + + - name: 📥 Monorepo install + uses: ./.github/actions/pnpm-install + with: + filter: '@teable/v2-e2e' + + - name: 🧪 Run E2E tests with coverage (shard ${{ matrix.shard }}/4) + run: | + pnpm -C packages/v2/e2e test-unit-cover -- --shard=${{ matrix.shard }}/4 --reporter=json --reporter=default --outputFile=e2e-report-${{ matrix.shard }}.json + + - name: 📊 Upload test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-report-shard-${{ matrix.shard }} + path: packages/v2/e2e/e2e-report-${{ matrix.shard }}.json + retention-days: 7 + + - name: 📈 Upload coverage artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-coverage-shard-${{ matrix.shard }} + path: packages/v2/e2e/coverage/ + retention-days: 7 + + # Merge coverage from all e2e shards + e2e-coverage-merge: + needs: e2e-tests + runs-on: ubuntu-latest + name: V2 E2E Coverage Report + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 22.18.0 + uses: actions/setup-node@v4 + with: + node-version: 22.18.0 + + - name: 📥 Download all coverage artifacts + uses: actions/download-artifact@v4 + with: + pattern: e2e-coverage-shard-* + path: coverage-parts + merge-multiple: false + + - name: 📥 Install nyc for merging coverage + run: npm install -g nyc + + - name: 📊 Merge coverage reports + run: | + mkdir -p merged-coverage + # Copy all lcov.info files to merged-coverage with unique names + for dir in coverage-parts/e2e-coverage-shard-*; do + shard=$(basename $dir | sed 's/e2e-coverage-shard-//') + if [ -f "$dir/lcov.info" ]; then + cp "$dir/lcov.info" "merged-coverage/lcov-$shard.info" + fi + done + # Merge lcov files using lcov command (available on ubuntu) + sudo apt-get install -y lcov + lcov -a merged-coverage/lcov-1.info \ + -a merged-coverage/lcov-2.info \ + -a merged-coverage/lcov-3.info \ + -a merged-coverage/lcov-4.info \ + -o merged-coverage/lcov.info || true + + - name: 📈 Upload merged coverage to Coveralls + uses: coverallsapp/github-action@v2 + with: + file: merged-coverage/lcov.info + flag-name: v2-e2e + parallel: false diff --git a/.gitignore b/.gitignore index 055a167816..c6415d3f54 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ node_modules /build /dist/ +# v2 packages build output +packages/v2/**/dist/ + # Next.js auto-generated type definitions **/next-env.d.ts @@ -37,10 +40,12 @@ node_modules **/.eslintcache .cache/* .swc/ +apps/playground/src/routeTree.gen.ts # Misc .DS_Store *.pem +.worktrees/ # Debug npm-debug.log* @@ -73,4 +78,4 @@ pnpm-debug.log* # LocalStorage assets -**/.assets \ No newline at end of file +**/.assets diff --git a/.prettierignore b/.prettierignore index c69d52b86f..4cdf73f11c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,4 @@ pnpm-lock.yaml **/build **/.tmp **/.cache +apps/playground/src/routeTree.gen.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 2d00bb34a4..19ac4015e3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -56,7 +56,15 @@ }, { "pattern": "./packages/*/" + }, + { + "pattern": "./packages/v2/*/" } ], - "vitest.maximumConfigs": 10 + "vitest.maximumConfigs": 50, + "vitest.nodeEnv": { + "DOCKER_HOST": "unix:///Users/nichenqin/.colima/default/docker.sock", + "TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE": "/var/run/docker.sock", + "TESTCONTAINERS_HOST_OVERRIDE": "127.0.0.1" + } } \ No newline at end of file diff --git a/agents.md b/agents.md new file mode 100644 index 0000000000..873f70b821 --- /dev/null +++ b/agents.md @@ -0,0 +1,73 @@ +# Teable v2 agent guide + +DDD/domain-model guidance has moved to the skill `teable-ddd-domain-model` in `.codex/skills/teable-ddd-domain-model`. Use that skill for any v2/core domain, specification, or aggregate changes. + +## Git hygiene + +- Ignore git changes that you did not make by default; never revert unknown/unrelated modifications unless explicitly instructed. + +## v2 API contracts (HTTP) + +For HTTP-ish integrations, keep framework-independent contracts/mappers in `packages/v2/contract-http`: + +- Define API paths (e.g. `/tables`) as constants. +- Use action-style paths with camelCase action names (e.g. `/tables/create`, `/tables/get`, `/tables/rename`); avoid RESTful nested resources like `/bases/{baseId}/tables/{tableId}`. +- Re-export command input schemas (zod) for route-level validation if needed. +- Keep DTO types + domain-to-DTO mappers here. +- Router packages (e.g. `@teable/v2-contract-http-express`, `@teable/v2-contract-http-fastify`) should be thin adapters that only: + - parse JSON/body + - create a container + - resolve handlers + - call the endpoint executor/mappers from `@teable/v2-contract-http` +- OpenAPI is generated from the ts-rest contract via `@teable/v2-contract-http-openapi`. + +## UI components (frontend) + +- In app UIs (e.g. `apps/playground`), use shadcn wrappers from `apps/playground/src/components/ui/*` (or `@teable/ui-lib`) instead of importing Radix primitives directly. +- If a shadcn wrapper is missing, add it under `apps/playground/src/components/ui` before using the primitive. + +## Dependency injection (DI) + +- Do not import `tsyringe` / `reflect-metadata` directly anywhere; use `@teable/v2-di`. +- Do not use DI inside `v2/core/src/domain/**`; DI is only for application wiring (e.g. `v2/core/src/commands/**`). +- Prefer constructor injection with explicit tokens for ports (interfaces). +- Provide environment-level composition roots as separate packages (e.g. `@teable/v2-container-node`, `@teable/v2-container-browser`) that register all port implementations. + +## Build tooling (v2) + +- v2 packages build with `tsdown` (not `tsc` emit). `tsc` is used only for `typecheck` (`--noEmit`). +- Each v2 package has a local `tsdown.config.ts` that extends the shared base config from `@teable/v2-tsdown-config`. +- Outputs are written to `dist/` (ESM `.js` + `.d.ts`), and workspace deps (`@teable/v2-*`) are kept external (no bundling across packages). + +## Source visibility (v2 packages) + +**All v2 packages must support source visibility** to allow consumers to reference TypeScript sources without building `dist/` outputs. This is required for development workflows, testing, and tools like Vitest/Vite that can consume TypeScript directly. + +**Required configuration:** + +- In `package.json`: + - Set `types` field to `"src/index.ts"` (not `"dist/index.d.ts"`) + - Set `exports["."].types` to `"./src/index.ts"` (not `"./dist/index.d.ts"`) + - Set `exports["."].import` to `"./src/index.ts"` (not `"./dist/index.js"`) to allow Vite/Vitest to use source files directly + - Keep `exports["."].require` pointing to `"./dist/index.cjs"` for CommonJS compatibility + - Include `"src"` in the `files` array (in addition to `"dist"`) +- In `tsconfig.json`: + - Map workspace dependencies to their `src` paths in `compilerOptions.paths` (e.g. `"@teable/v2-core": ["../core/src"]`) + - Include those source paths in the `include` array + +**Example `package.json` configuration:** +```json +{ + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts", + "require": "./dist/index.cjs" + } + }, + "files": ["dist", "src"] +} +``` + +**Note:** Since v2 packages are workspace-only (`"private": true`) and not published to npm, pointing `import` to source files is safe. Vite/Vitest can process TypeScript files directly, enabling faster development cycles without requiring `dist/` to be built first. diff --git a/apps/nestjs-backend/.eslintrc.js b/apps/nestjs-backend/.eslintrc.js index 0fc6a80460..9b9de3453e 100644 --- a/apps/nestjs-backend/.eslintrc.js +++ b/apps/nestjs-backend/.eslintrc.js @@ -34,5 +34,13 @@ module.exports = { '@typescript-eslint/naming-convention': 'off', }, }, + { + // Disable consistent-type-imports for files with decorators (NestJS controllers/services) + // See: https://typescript-eslint.io/blog/changes-to-consistent-type-imports-with-decorators + files: ['src/**/*.controller.ts'], + rules: { + '@typescript-eslint/consistent-type-imports': 'off', + }, + }, ], }; diff --git a/apps/nestjs-backend/package.json b/apps/nestjs-backend/package.json index 7c7f8a8063..d17af9ddbb 100644 --- a/apps/nestjs-backend/package.json +++ b/apps/nestjs-backend/package.json @@ -84,9 +84,11 @@ "@types/passport-oauth2-client-password": "0.1.5", "@types/passport-openidconnect": "0.1.3", "@types/pause": "0.1.3", + "@types/pg": "8.16.0", "@types/sharedb": "3.3.10", "@types/sockjs": "0.3.36", "@types/sockjs-client": "1.5.4", + "@types/ws": "8.18.1", "sockjs-client": "1.6.1", "@types/stream-json": "1.7.8", "@types/through2": "2.0.41", @@ -151,6 +153,7 @@ "@nestjs/terminus": "10.2.3", "@nestjs/websockets": "10.3.5", "@openrouter/ai-sdk-provider": "2.1.1", + "@orpc/nest": "1.13.0", "@opentelemetry/api": "1.9.0", "@opentelemetry/exporter-logs-otlp-http": "0.201.1", "@opentelemetry/exporter-metrics-otlp-http": "0.201.1", @@ -159,6 +162,7 @@ "@opentelemetry/instrumentation-http": "0.201.1", "@opentelemetry/instrumentation-ioredis": "0.49.0", "@opentelemetry/instrumentation-nestjs-core": "0.49.0", + "@opentelemetry/instrumentation-pg": "0.49.0", "@opentelemetry/instrumentation-pino": "0.49.0", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-node": "0.201.1", @@ -174,7 +178,18 @@ "@teable/core": "workspace:^", "@teable/db-main-prisma": "workspace:^", "@teable/openapi": "workspace:^", + "@teable/v2-container-node": "workspace:*", + "@teable/v2-contract-http": "workspace:*", + "@teable/v2-contract-http-openapi": "workspace:*", + "@teable/v2-contract-http-implementation": "workspace:*", + "@teable/v2-core": "workspace:*", + "@teable/v2-adapter-db-postgres-pg": "workspace:*", + "@teable/v2-adapter-realtime-sharedb": "workspace:*", + "@teable/v2-di": "workspace:*", + "@teable/v2-import": "workspace:*", + "@teamwork/websocket-json-stream": "2.0.0", "@an-epiphany/websocket-json-stream": "1.2.0", + "ws": "8.18.3", "@valibot/to-json-schema": "1.3.0", "ai": "6.0.62", "ajv": "8.12.0", @@ -234,6 +249,8 @@ "pg": "8.11.5", "pino-http": "10.5.0", "pino-pretty": "11.0.0", + "react": "18.3.1", + "react-dom": "18.3.1", "redlock": "5.0.0-beta.2", "reflect-metadata": "0.2.1", "rxjs": "7.8.1", diff --git a/apps/nestjs-backend/src/app.module.ts b/apps/nestjs-backend/src/app.module.ts index d34eed387c..d33adc63a8 100644 --- a/apps/nestjs-backend/src/app.module.ts +++ b/apps/nestjs-backend/src/app.module.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import { BullModule } from '@nestjs/bullmq'; import type { ModuleMetadata } from '@nestjs/common'; import { Module } from '@nestjs/common'; @@ -45,12 +46,18 @@ import { TemplateOpenApiModule } from './features/template/template-open-api.mod import { TrashModule } from './features/trash/trash.module'; import { UndoRedoModule } from './features/undo-redo/open-api/undo-redo.module'; import { UserModule } from './features/user/user.module'; +import { V2Module } from './features/v2/v2.module'; import { GlobalModule } from './global/global.module'; import { InitBootstrapProvider } from './global/init-bootstrap.provider'; import { LoggerModule } from './logger/logger.module'; import { ObservabilityModule } from './observability/observability.module'; import { WsModule } from './ws/ws.module'; +// In CI or test environments, use a longer timeout for ConditionalModule +// to avoid sporadic timeout errors when resources are under pressure +const isTestOrCI = process.env.CI || process.env.NODE_ENV === 'test' || process.env.VITEST; +const CONDITIONAL_MODULE_TIMEOUT = isTestOrCI ? 60000 : 5000; + export const appModules = { imports: [ SentryModule.forRoot(), @@ -96,6 +103,7 @@ export const appModules = { PluginChartModule, ObservabilityModule, BuiltinAssetsInitModule, + V2Module, ], providers: [InitBootstrapProvider], }; @@ -124,7 +132,8 @@ export const appModules = { }), (env) => { return Boolean(env.BACKEND_CACHE_REDIS_URI); - } + }, + { timeout: CONDITIONAL_MODULE_TIMEOUT } ), ], controllers: [], diff --git a/apps/nestjs-backend/src/bootstrap.ts b/apps/nestjs-backend/src/bootstrap.ts index df394afd57..4cb00fc90b 100644 --- a/apps/nestjs-backend/src/bootstrap.ts +++ b/apps/nestjs-backend/src/bootstrap.ts @@ -13,7 +13,6 @@ import type { IBaseConfig } from './configs/base.config'; import type { ISecurityWebConfig, IApiDocConfig } from './configs/bootstrap.config'; import { GlobalExceptionFilter } from './filter/global-exception.filter'; import { setupSwagger } from './swagger'; -import otelSDK from './tracing'; const host = 'localhost'; @@ -41,7 +40,8 @@ export async function setUpAppMiddleware(app: INestApplication, configService: C } export async function bootstrap() { - otelSDK.start(); + // OTEL SDK is now started in tracing.ts when the module is imported. + // This ensures instrumentation is applied before any instrumented modules are loaded. const app = await NestFactory.create(AppModule, { bufferLogs: true }); const configService = app.get(ConfigService); diff --git a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/boolean-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/boolean-cell-value-filter.adapter.ts index c326b31ce5..ad9bde5f54 100644 --- a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/boolean-cell-value-filter.adapter.ts +++ b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/boolean-cell-value-filter.adapter.ts @@ -1,5 +1,4 @@ -import { isFieldReferenceValue } from '@teable/core'; -import type { IFilterOperator, IFilterValue } from '@teable/core'; +import { isFieldReferenceValue, type IFilterOperator, type IFilterValue } from '@teable/core'; import type { Knex } from 'knex'; import type { IDbProvider } from '../../../../db.provider.interface'; import { CellValueFilterPostgres } from '../cell-value-filter.postgres'; diff --git a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/boolean-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/boolean-cell-value-filter.adapter.ts index a8969f8a0a..98177dbc9b 100644 --- a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/boolean-cell-value-filter.adapter.ts +++ b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/boolean-cell-value-filter.adapter.ts @@ -1,5 +1,4 @@ -import { isFieldReferenceValue } from '@teable/core'; -import type { IFilterOperator, IFilterValue } from '@teable/core'; +import { isFieldReferenceValue, type IFilterOperator, type IFilterValue } from '@teable/core'; import type { Knex } from 'knex'; import type { IDbProvider } from '../../../../db.provider.interface'; import { CellValueFilterSqlite } from '../cell-value-filter.sqlite'; diff --git a/apps/nestjs-backend/src/db-provider/postgres.provider.ts b/apps/nestjs-backend/src/db-provider/postgres.provider.ts index 8a120be1d8..fb035c4236 100644 --- a/apps/nestjs-backend/src/db-provider/postgres.provider.ts +++ b/apps/nestjs-backend/src/db-provider/postgres.provider.ts @@ -720,15 +720,21 @@ WHERE tc.constraint_type = 'FOREIGN KEY' ) .where((builder) => { builder - .where(`${dbTableName}.${startField.dbFieldName}`, '<', endDate) - .andWhere( - this.knex.raw(`COALESCE(??.??::timestamptz, ??.??)::timestamptz >= ?::timestamptz`, [ + .whereRaw( + `(??.??::timestamptz AT TIME ZONE ?)::date <= (?::timestamptz AT TIME ZONE ?)::date`, + [dbTableName, startField.dbFieldName, timezone, endDate, timezone] + ) + .andWhereRaw( + `(COALESCE(??.??::timestamptz, ??.??)::timestamptz AT TIME ZONE ?)::date >= (?::timestamptz AT TIME ZONE ?)::date`, + [ dbTableName, endField.dbFieldName, dbTableName, startField.dbFieldName, + timezone, startDate, - ]) + timezone, + ] ) .andWhere((subBuilder) => { subBuilder diff --git a/apps/nestjs-backend/src/db-provider/sqlite.provider.ts b/apps/nestjs-backend/src/db-provider/sqlite.provider.ts index df0fdee9d0..4532ec63a6 100644 --- a/apps/nestjs-backend/src/db-provider/sqlite.provider.ts +++ b/apps/nestjs-backend/src/db-provider/sqlite.provider.ts @@ -589,15 +589,20 @@ export class SqliteProvider implements IDbProvider { .crossJoin(datesSubquery.wrap('(', ') as d')) .where((builder) => { builder - .where(this.knex.raw(`datetime(??, ?)`, [endField.dbFieldName, offsetStr]), '<', endDate) + .whereRaw(`date(datetime(??, ?)) <= date(datetime(?, ?))`, [ + startField.dbFieldName, + offsetStr, + endDate, + offsetStr, + ]) .andWhere( - this.knex.raw(`datetime(COALESCE(??, ??), ?)`, [ + this.knex.raw(`date(datetime(COALESCE(??, ??), ?))`, [ endField.dbFieldName, startField.dbFieldName, offsetStr, ]), '>=', - startDate + this.knex.raw(`date(datetime(?, ?))`, [startDate, offsetStr]) ); }) .andWhere((builder) => { diff --git a/apps/nestjs-backend/src/db-provider/utils/default-datetime-parse-pattern.spec.ts b/apps/nestjs-backend/src/db-provider/utils/default-datetime-parse-pattern.spec.ts index e8e322e2aa..a85f1d595c 100644 --- a/apps/nestjs-backend/src/db-provider/utils/default-datetime-parse-pattern.spec.ts +++ b/apps/nestjs-backend/src/db-provider/utils/default-datetime-parse-pattern.spec.ts @@ -9,6 +9,22 @@ describe('default datetime parse pattern', () => { expect(pattern.test('2025-11-01 08:40')).toBe(true); }); + it('accepts single-digit month and day', () => { + const pattern = new RegExp(getDefaultDatetimeParsePattern()); + // Single-digit month + expect(pattern.test('2026-9-15')).toBe(true); + expect(pattern.test('2026-1-15')).toBe(true); + // Single-digit day + expect(pattern.test('2026-09-5')).toBe(true); + expect(pattern.test('2026-12-1')).toBe(true); + // Both single-digit + expect(pattern.test('2026-9-5')).toBe(true); + expect(pattern.test('2026-1-1')).toBe(true); + // Double-digit (still works) + expect(pattern.test('2026-09-15')).toBe(true); + expect(pattern.test('2026-12-31')).toBe(true); + }); + it('treats blank strings as invalid', () => { const pattern = new RegExp(getDefaultDatetimeParsePattern()); expect(pattern.test('')).toBe(false); diff --git a/apps/nestjs-backend/src/db-provider/utils/default-datetime-parse-pattern.ts b/apps/nestjs-backend/src/db-provider/utils/default-datetime-parse-pattern.ts index 7b3a733148..35e1b77e0a 100644 --- a/apps/nestjs-backend/src/db-provider/utils/default-datetime-parse-pattern.ts +++ b/apps/nestjs-backend/src/db-provider/utils/default-datetime-parse-pattern.ts @@ -12,7 +12,8 @@ export const DEFAULT_DATETIME_PARSE_PATTERN = (() => { const timeZoneSegment = `(Z|[+-]${digitPair}|[+-]${digitPair}${digitPair}|[+-]${digitPair}:${digitPair})`; const timePart = `[ T]${hour}:${digitPair}` + optional(secondSegment) + optional(timeZoneSegment); - return '^' + '[0-9]{4}-[0-9]{2}-[0-9]{2}' + optional(timePart) + '$'; + // Support both single-digit (e.g., 2026-9-15) and double-digit (e.g., 2026-09-15) month/day + return '^' + '[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}' + optional(timePart) + '$'; })(); export const getDefaultDatetimeParsePattern = (): string => DEFAULT_DATETIME_PARSE_PATTERN; diff --git a/apps/nestjs-backend/src/features/auth/auth.module.ts b/apps/nestjs-backend/src/features/auth/auth.module.ts index 53784a6e4e..cc7e487608 100644 --- a/apps/nestjs-backend/src/features/auth/auth.module.ts +++ b/apps/nestjs-backend/src/features/auth/auth.module.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import { Module } from '@nestjs/common'; import { ConditionalModule } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; @@ -20,15 +21,22 @@ import { AnonymousStrategy } from './strategies/anonymous/anonymous.strategy'; import { JwtStrategy } from './strategies/jwt.strategy'; import { SessionStrategy } from './strategies/session.strategy'; import { TurnstileModule } from './turnstile/turnstile.module'; + +const CONDITIONAL_MODULE_TIMEOUT = process.env.CI ? 30000 : 5000; + @Module({ imports: [ UserModule, PassportModule.register({ session: true }), SessionModule, AccessTokenModule, - ConditionalModule.registerWhen(LocalAuthModule, (env) => { - return Boolean(env.PASSWORD_LOGIN_DISABLED !== 'true'); - }), + ConditionalModule.registerWhen( + LocalAuthModule, + (env) => { + return Boolean(env.PASSWORD_LOGIN_DISABLED !== 'true'); + }, + { timeout: CONDITIONAL_MODULE_TIMEOUT } + ), SocialModule, PermissionModule, TurnstileModule, diff --git a/apps/nestjs-backend/src/features/auth/social/social.module.ts b/apps/nestjs-backend/src/features/auth/social/social.module.ts index 55710f8483..576127c222 100644 --- a/apps/nestjs-backend/src/features/auth/social/social.module.ts +++ b/apps/nestjs-backend/src/features/auth/social/social.module.ts @@ -1,20 +1,35 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import { Module } from '@nestjs/common'; import { ConditionalModule } from '@nestjs/config'; import { GithubModule } from './github/github.module'; import { GoogleModule } from './google/google.module'; import { OIDCModule } from './oidc/oidc.module'; +const CONDITIONAL_MODULE_TIMEOUT = process.env.CI ? 30000 : 5000; + @Module({ imports: [ - ConditionalModule.registerWhen(GithubModule, (env) => { - return Boolean(env.SOCIAL_AUTH_PROVIDERS?.split(',')?.includes('github')); - }), - ConditionalModule.registerWhen(GoogleModule, (env) => { - return Boolean(env.SOCIAL_AUTH_PROVIDERS?.split(',')?.includes('google')); - }), - ConditionalModule.registerWhen(OIDCModule, (env) => { - return Boolean(env.SOCIAL_AUTH_PROVIDERS?.split(',')?.includes('oidc')); - }), + ConditionalModule.registerWhen( + GithubModule, + (env) => { + return Boolean(env.SOCIAL_AUTH_PROVIDERS?.split(',')?.includes('github')); + }, + { timeout: CONDITIONAL_MODULE_TIMEOUT } + ), + ConditionalModule.registerWhen( + GoogleModule, + (env) => { + return Boolean(env.SOCIAL_AUTH_PROVIDERS?.split(',')?.includes('google')); + }, + { timeout: CONDITIONAL_MODULE_TIMEOUT } + ), + ConditionalModule.registerWhen( + OIDCModule, + (env) => { + return Boolean(env.SOCIAL_AUTH_PROVIDERS?.split(',')?.includes('oidc')); + }, + { timeout: CONDITIONAL_MODULE_TIMEOUT } + ), ], }) export class SocialModule {} diff --git a/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts b/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts index 773d470f0a..3e38764316 100644 --- a/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts +++ b/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts @@ -248,7 +248,21 @@ export class BaseSqlExecutorService { } const roleName = this.getReadOnlyRoleName(baseId); if (!(await this.roleExits(roleName))) { - await this.createReadOnlyRole(baseId); + try { + await this.createReadOnlyRole(baseId); + } catch (error) { + // Handle race condition: another concurrent request may have already created the role + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + (error?.meta?.code === '42710' || error?.meta?.code === '23505') + ) { + this.logger.warn( + `read only role ${roleName} already exists (concurrent creation), skipping` + ); + return; + } + throw error; + } } } diff --git a/apps/nestjs-backend/src/features/builtin-assets-init/builtin-assets-init.service.ts b/apps/nestjs-backend/src/features/builtin-assets-init/builtin-assets-init.service.ts index 2871902b18..bff1d3cd94 100644 --- a/apps/nestjs-backend/src/features/builtin-assets-init/builtin-assets-init.service.ts +++ b/apps/nestjs-backend/src/features/builtin-assets-init/builtin-assets-init.service.ts @@ -45,6 +45,10 @@ const LOCK_TTL = 300; // 5 minutes const AUTOMATION_ROBOT_AVATAR_PATH = 'static/system/automation-robot.png'; // eslint-disable-next-line @typescript-eslint/naming-convention const ANONYMOUS_USER_AVATAR_PATH = 'static/system/anonymous.png'; +// eslint-disable-next-line @typescript-eslint/naming-convention +const EMAIL_LOGO_PATH = 'static/system/email-logo.png'; +// eslint-disable-next-line @typescript-eslint/naming-convention +export const EMAIL_LOGO_TOKEN = 'email-logo'; /** * BuiltinAssetsInitService @@ -211,6 +215,11 @@ export class BuiltinAssetsInitService implements OnModuleInit { filePath: ANONYMOUS_USER_AVATAR_PATH, uploadType: UploadType.Avatar, }, + { + id: EMAIL_LOGO_TOKEN, + filePath: EMAIL_LOGO_PATH, + uploadType: UploadType.Logo, + }, { id: 'actTestImage', filePath: 'static/test/test-image.png', diff --git a/apps/nestjs-backend/src/features/calculation/reference.service.ts b/apps/nestjs-backend/src/features/calculation/reference.service.ts index 95498d8a36..563c599556 100644 --- a/apps/nestjs-backend/src/features/calculation/reference.service.ts +++ b/apps/nestjs-backend/src/features/calculation/reference.service.ts @@ -66,7 +66,11 @@ export class ReferenceService { private async getLookupFilterFieldMap(fieldMap: IFieldMap) { const fieldIds = Object.keys(fieldMap) .map((fieldId) => { - const lookupOptions = fieldMap[fieldId].lookupOptions; + const field = fieldMap[fieldId]; + if (!field) { + return []; + } + const lookupOptions = field.lookupOptions; if (lookupOptions && lookupOptions.filter) { return extractFieldIdsFromFilter(lookupOptions.filter, true); } diff --git a/apps/nestjs-backend/src/features/canary/canary.module.ts b/apps/nestjs-backend/src/features/canary/canary.module.ts index 47e0dbc961..58b2d5b2a3 100644 --- a/apps/nestjs-backend/src/features/canary/canary.module.ts +++ b/apps/nestjs-backend/src/features/canary/canary.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; import { SettingModule } from '../setting/setting.module'; import { CanaryService } from './canary.service'; +import { V2FeatureGuard } from './guards/v2-feature.guard'; +import { V2IndicatorInterceptor } from './interceptors/v2-indicator.interceptor'; @Module({ imports: [SettingModule], - exports: [CanaryService], - providers: [CanaryService], + exports: [CanaryService, V2FeatureGuard, V2IndicatorInterceptor], + providers: [CanaryService, V2FeatureGuard, V2IndicatorInterceptor], }) export class CanaryModule {} diff --git a/apps/nestjs-backend/src/features/canary/canary.service.ts b/apps/nestjs-backend/src/features/canary/canary.service.ts index 4c754bf1f9..fdbf9dbd20 100644 --- a/apps/nestjs-backend/src/features/canary/canary.service.ts +++ b/apps/nestjs-backend/src/features/canary/canary.service.ts @@ -1,10 +1,15 @@ import { Injectable } from '@nestjs/common'; -import type { ICanaryConfig } from '@teable/openapi'; +import type { ICanaryConfig, V2Feature } from '@teable/openapi'; import { SettingKey } from '@teable/openapi'; import { ClsService } from 'nestjs-cls'; -import type { IClsStore } from '../../types/cls'; +import type { IClsStore, V2Reason } from '../../types/cls'; import { SettingService } from '../setting/setting.service'; +export interface IV2Decision { + useV2: boolean; + reason: V2Reason; +} + @Injectable() export class CanaryService { constructor( @@ -27,6 +32,14 @@ export class CanaryService { return process.env.ENABLE_CANARY_FEATURE === 'true'; } + /** + * Check if V2 is forced globally via environment variable (FORCE_V2_ALL=true) + * This has the highest priority over all other settings + */ + isForceV2AllEnabled(): boolean { + return process.env.FORCE_V2_ALL === 'true'; + } + /** * Check if canary is forced via request header (x-canary: true/false) * Returns: true = force enable, false = force disable, undefined = no override @@ -69,4 +82,100 @@ export class CanaryService { // Check if space is in the canary list return config.spaceIds?.includes(spaceId) ?? false; } + + /** + * Determine if V2 implementation should be used for a specific feature + * Priority: + * 1. FORCE_V2_ALL env var (highest priority, bypasses all checks) + * 2. If canary feature is disabled globally, return false + * 3. forceV2All in config (database setting) + * 4. x-canary header override + * 5. Space in canary list (all V2 features enabled for canary spaces) + * + * @param spaceId - The space ID to check + * @param feature - The V2 feature name (e.g., 'createRecord', 'updateRecord') + */ + async shouldUseV2(spaceId: string, _feature: V2Feature): Promise { + // Priority 1: Environment variable FORCE_V2_ALL (highest priority) + if (this.isForceV2AllEnabled()) { + return true; + } + + // Check if canary feature is enabled globally + if (!this.isCanaryFeatureEnabled()) { + return false; + } + + const config = await this.getCanaryConfig(); + + // Priority 2: forceV2All in config (database) + if (config?.forceV2All) { + return true; + } + + // Priority 3: Header override + const headerOverride = this.getHeaderCanaryOverride(); + if (headerOverride !== undefined) { + return headerOverride; + } + + // Priority 4: Space in canary list (all V2 features enabled for canary spaces) + if (!config?.enabled) { + return false; + } + + return config.spaceIds?.includes(spaceId) ?? false; + } + + /** + * 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) + * 2. If canary feature is disabled globally, return false + * 3. forceV2All in config (database setting) + * 4. x-canary header override + * 5. Space in canary list (all V2 features enabled for canary spaces) + * + * @param spaceId - The space ID to check + * @param feature - The V2 feature name (e.g., 'createRecord', 'updateRecord') + */ + async shouldUseV2WithReason(spaceId: string, _feature: V2Feature): Promise { + // Priority 1: Environment variable FORCE_V2_ALL (highest priority) + if (this.isForceV2AllEnabled()) { + return { useV2: true, reason: 'env_force_v2_all' }; + } + + // Check if canary feature is enabled globally + if (!this.isCanaryFeatureEnabled()) { + return { useV2: false, reason: 'disabled' }; + } + + const config = await this.getCanaryConfig(); + + // Priority 2: forceV2All in config (database) + if (config?.forceV2All) { + return { useV2: true, reason: 'config_force_v2_all' }; + } + + // Priority 3: Header override + const headerOverride = this.getHeaderCanaryOverride(); + if (headerOverride !== undefined) { + return { useV2: headerOverride, reason: 'header_override' }; + } + + // Priority 4: Space in canary list (all V2 features enabled for canary spaces) + if (!config?.enabled) { + return { useV2: false, reason: 'disabled' }; + } + + const inCanarySpace = config.spaceIds?.includes(spaceId) ?? false; + + if (inCanarySpace) { + return { useV2: true, reason: 'space_feature' }; + } + + return { useV2: false, reason: 'feature_not_enabled' }; + } } diff --git a/apps/nestjs-backend/src/features/canary/decorators/use-v2-feature.decorator.ts b/apps/nestjs-backend/src/features/canary/decorators/use-v2-feature.decorator.ts new file mode 100644 index 0000000000..00df84dcad --- /dev/null +++ b/apps/nestjs-backend/src/features/canary/decorators/use-v2-feature.decorator.ts @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { SetMetadata } from '@nestjs/common'; +import type { V2Feature } from '@teable/openapi'; + +export const USE_V2_FEATURE_KEY = 'useV2Feature'; + +/** + * Decorator to mark a controller method as supporting V2 implementation. + * Used with V2FeatureGuard to determine if V2 should be used based on canary config. + * + * @param feature - The V2 feature name (e.g., 'createRecord', 'updateRecord') + * + * @example + * ```typescript + * @UseV2Feature('createRecord') + * @Post() + * async createRecords(...) {} + * ``` + */ +export const UseV2Feature = (feature: V2Feature) => SetMetadata(USE_V2_FEATURE_KEY, feature); 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 new file mode 100644 index 0000000000..5669ba2b6e --- /dev/null +++ b/apps/nestjs-backend/src/features/canary/guards/v2-feature.guard.ts @@ -0,0 +1,139 @@ +import { Injectable, type CanActivate, type ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { IdPrefix } from '@teable/core'; +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 { USE_V2_FEATURE_KEY } from '../decorators/use-v2-feature.decorator'; + +/** + * Guard that determines if V2 implementation should be used. + * Works with @UseV2Feature decorator to enable V2 based on canary configuration. + * + * The guard: + * 1. Reads the feature name from @UseV2Feature decorator + * 2. Extracts spaceId from request (via tableId -> baseId -> spaceId) + * 3. Calls CanaryService.shouldUseV2() to determine if V2 should be used + * 4. Stores the result in CLS for the controller to use + * + * @example + * ```typescript + * @UseGuards(V2FeatureGuard) + * @Controller('api/table/:tableId/record') + * export class RecordController { + * @UseV2Feature('createRecord') + * @Post() + * async createRecords(...) { + * if (this.cls.get('useV2')) { + * return this.v2Service.createRecords(...); + * } + * return this.v1Service.createRecords(...); + * } + * } + * ``` + */ +@Injectable() +export class V2FeatureGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly cls: ClsService, + private readonly canaryService: CanaryService, + private readonly prismaService: PrismaService + ) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + + // Store windowId from header for undo/redo tracking + const windowId = req.headers['x-window-id'] as string | undefined; + if (windowId) { + this.cls.set('windowId', windowId); + } + + // 1. Get the feature name from decorator + const feature = this.reflector.getAllAndOverride(USE_V2_FEATURE_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + // No feature marked, default to V1 + if (!feature) { + this.cls.set('useV2', false); + this.cls.set('v2Reason', 'no_feature'); + 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) { + this.cls.set('useV2', false); + this.cls.set('v2Feature', feature); + this.cls.set('v2Reason', 'disabled'); + return true; + } + + // 4. Determine if V2 should be used with reason + const decision = await this.canaryService.shouldUseV2WithReason(spaceId, feature); + this.cls.set('useV2', decision.useV2); + this.cls.set('v2Feature', feature); + this.cls.set('v2Reason', decision.reason); + + return true; + } + + /** + * Extract spaceId from request context. + * Supports: spaceId (direct), baseId (lookup), tableId (lookup via base) + */ + private async getSpaceIdFromContext(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + const resourceId = req.params.spaceId || req.params.baseId || req.params.tableId; + + if (!resourceId) { + return undefined; + } + + // Direct spaceId + if (resourceId.startsWith(IdPrefix.Space)) { + return 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 }, + }); + return base?.spaceId; + } + + // TableId -> lookup baseId -> lookup spaceId + if (resourceId.startsWith(IdPrefix.Table)) { + const table = await this.prismaService.txClient().tableMeta.findUnique({ + where: { id: resourceId, deletedTime: null }, + select: { baseId: true }, + }); + + if (!table) return undefined; + + const base = await this.prismaService.txClient().base.findUnique({ + where: { id: table.baseId, deletedTime: null }, + select: { spaceId: true }, + }); + return base?.spaceId; + } + + return undefined; + } +} diff --git a/apps/nestjs-backend/src/features/canary/index.ts b/apps/nestjs-backend/src/features/canary/index.ts index 5ce0438d83..85fd6128ba 100644 --- a/apps/nestjs-backend/src/features/canary/index.ts +++ b/apps/nestjs-backend/src/features/canary/index.ts @@ -1,2 +1,5 @@ export * from './canary.module'; export * from './canary.service'; +export * from './decorators/use-v2-feature.decorator'; +export * from './guards/v2-feature.guard'; +export * from './interceptors/v2-indicator.interceptor'; diff --git a/apps/nestjs-backend/src/features/canary/interceptors/v2-indicator.interceptor.ts b/apps/nestjs-backend/src/features/canary/interceptors/v2-indicator.interceptor.ts new file mode 100644 index 0000000000..2bab609d45 --- /dev/null +++ b/apps/nestjs-backend/src/features/canary/interceptors/v2-indicator.interceptor.ts @@ -0,0 +1,83 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { + Injectable, + type NestInterceptor, + type ExecutionContext, + type CallHandler, + Logger, +} from '@nestjs/common'; +import { trace } from '@opentelemetry/api'; +import type { Response } from 'express'; +import { ClsService } from 'nestjs-cls'; +import type { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import type { IClsStore } from '../../../types/cls'; + +export const X_TEABLE_V2_HEADER = 'x-teable-v2'; +export const X_TEABLE_V2_REASON_HEADER = 'x-teable-v2-reason'; +export const X_TEABLE_V2_FEATURE_HEADER = 'x-teable-v2-feature'; + +/** + * Interceptor that adds V2 indicator to response headers and logs. + * When a request uses V2 implementation (determined by V2FeatureGuard), + * this interceptor adds: + * - Response header: x-teable-v2: true + * - Response header: x-teable-v2-reason: + * - Response header: x-teable-v2-feature: + * - Log entry with V2 indicator for tracing + * - Span attributes for OpenTelemetry tracing + */ +@Injectable() +export class V2IndicatorInterceptor implements NestInterceptor { + private readonly logger = new Logger(V2IndicatorInterceptor.name); + + constructor(private readonly cls: ClsService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const useV2 = this.cls.get('useV2'); + const v2Reason = this.cls.get('v2Reason'); + const v2Feature = this.cls.get('v2Feature'); + + const response = context.switchToHttp().getResponse(); + const request = context.switchToHttp().getRequest(); + + // Add V2 indicator headers regardless of useV2 value + // This allows clients to understand why V2 was or wasn't used + response.setHeader(X_TEABLE_V2_HEADER, useV2 ? 'true' : 'false'); + if (v2Reason) { + response.setHeader(X_TEABLE_V2_REASON_HEADER, v2Reason); + } + if (v2Feature) { + response.setHeader(X_TEABLE_V2_FEATURE_HEADER, v2Feature); + } + + // Add span attributes for tracing + const span = trace.getActiveSpan(); + if (span) { + span.setAttributes({ + 'teable.v2.enabled': useV2 ?? false, + ...(v2Reason && { 'teable.v2.reason': v2Reason }), + ...(v2Feature && { 'teable.v2.feature': v2Feature }), + }); + } + + if (!useV2) { + return next.handle(); + } + + return next.handle().pipe( + tap(() => { + // Log V2 usage for tracing + this.logger.debug({ + message: 'V2 implementation used', + method: request.method, + path: request.path, + tableId: request.params?.tableId, + useV2: true, + v2Reason, + v2Feature, + }); + }) + ); + } +} 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 a00c1a76a6..061540b389 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 @@ -105,6 +105,10 @@ export class FieldSupplementService { return `__fk_${fieldId}`; } + private getDefaultTimeZone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } + private async getJunctionTableName( tableId: string, fieldId: string, @@ -774,8 +778,7 @@ export class FieldSupplementService { const formatting = (fieldRo.options as IFormulaFieldOptions)?.formatting ?? getDefaultFormatting(cellValueType); const timeZone = - (fieldRo.options as IFormulaFieldOptions)?.timeZone ?? - Intl.DateTimeFormat().resolvedOptions().timeZone; + (fieldRo.options as IFormulaFieldOptions)?.timeZone ?? this.getDefaultTimeZone(); return { ...fieldRo, @@ -966,7 +969,7 @@ export class FieldSupplementService { const { cellValueType, isMultipleCellValue } = valueType; const formatting = options.formatting ?? getDefaultFormatting(cellValueType); - const timeZone = options.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + const timeZone = options.timeZone ?? this.getDefaultTimeZone(); const foreignTable = await this.prismaService.txClient().tableMeta.findUnique({ where: { id: foreignTableId }, diff --git a/apps/nestjs-backend/src/features/field/model/field-dto/created-time-field.dto.ts b/apps/nestjs-backend/src/features/field/model/field-dto/created-time-field.dto.ts index 71275ba68e..0e4f6b404f 100644 --- a/apps/nestjs-backend/src/features/field/model/field-dto/created-time-field.dto.ts +++ b/apps/nestjs-backend/src/features/field/model/field-dto/created-time-field.dto.ts @@ -15,13 +15,30 @@ export class CreatedTimeFieldDto extends CreatedTimeFieldCore implements FieldBa } convertDBValue2CellValue(value: unknown): unknown { + const normalizeDateValue = (input: unknown) => { + if (input instanceof Date) { + return input.toISOString(); + } + if (typeof input === 'string') { + const hasTimezone = /[zZ]|[+-]\d{2}:\d{2}$/.test(input); + const parsed = new Date(hasTimezone ? input : `${input}Z`); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString(); + } + } + return input; + }; + if (this.isMultipleCellValue) { - return value == null || typeof value === 'object' ? value : JSON.parse(value as string); - } - if (value instanceof Date) { - return value.toISOString(); + if (value == null) return value; + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + if (Array.isArray(parsed)) { + return parsed.map(normalizeDateValue); + } + return parsed; } - return value; + + return normalizeDateValue(value); } setMetadata(meta: IFormulaFieldMeta) { diff --git a/apps/nestjs-backend/src/features/field/model/field-dto/last-modified-time-field.dto.ts b/apps/nestjs-backend/src/features/field/model/field-dto/last-modified-time-field.dto.ts index e2a56e033e..d3d6f5fa02 100644 --- a/apps/nestjs-backend/src/features/field/model/field-dto/last-modified-time-field.dto.ts +++ b/apps/nestjs-backend/src/features/field/model/field-dto/last-modified-time-field.dto.ts @@ -15,13 +15,30 @@ export class LastModifiedTimeFieldDto extends LastModifiedTimeFieldCore implemen } convertDBValue2CellValue(value: unknown): unknown { + const normalizeDateValue = (input: unknown) => { + if (input instanceof Date) { + return input.toISOString(); + } + if (typeof input === 'string') { + const hasTimezone = /[zZ]|[+-]\d{2}:\d{2}$/.test(input); + const parsed = new Date(hasTimezone ? input : `${input}Z`); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString(); + } + } + return input; + }; + if (this.isMultipleCellValue) { - return value == null || typeof value === 'object' ? value : JSON.parse(value as string); - } - if (value instanceof Date) { - return value.toISOString(); + if (value == null) return value; + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + if (Array.isArray(parsed)) { + return parsed.map(normalizeDateValue); + } + return parsed; } - return value; + + return normalizeDateValue(value); } setMetadata(meta: IFormulaFieldMeta) { diff --git a/apps/nestjs-backend/src/features/import/open-api/import-open-api-v2.service.ts b/apps/nestjs-backend/src/features/import/open-api/import-open-api-v2.service.ts new file mode 100644 index 0000000000..c48712d70b --- /dev/null +++ b/apps/nestjs-backend/src/features/import/open-api/import-open-api-v2.service.ts @@ -0,0 +1,204 @@ +import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpErrorCode } from '@teable/core'; +import { CreateRecordAction, type IInplaceImportOptionRo } from '@teable/openapi'; +import { + v2CoreTokens, + type ICommandBus, + ImportRecordsCommand, + type ImportRecordsResult, +} from '@teable/v2-core'; +import { difference } from 'lodash'; +import { ClsService } from 'nestjs-cls'; +import { z } from 'zod'; +import { BaseConfig, type IBaseConfig } from '../../../configs/base.config'; +import { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exception'; +import { EventEmitterService } from '../../../event-emitter/event-emitter.service'; +import { Events } from '../../../event-emitter/events'; +import type { IClsStore } from '../../../types/cls'; +import { V2ContainerService } from '../../v2/v2-container.service'; +import { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory'; + +/** + * V2 Import Open API Service + * + * Handles import operations using the V2 architecture via CommandBus. + */ +@Injectable() +export class ImportOpenApiV2Service { + private readonly logger = new Logger(ImportOpenApiV2Service.name); + + constructor( + private readonly v2ContainerService: V2ContainerService, + private readonly v2ContextFactory: V2ExecutionContextFactory, + private readonly cls: ClsService, + private readonly configService: ConfigService, + private readonly eventEmitterService: EventEmitterService, + @BaseConfig() private readonly baseConfig: IBaseConfig + ) {} + + /** + * Resolve a relative URL to an absolute URL. + * If the URL is already absolute, return as-is. + */ + private resolveUrl(url: string): string { + const trimmedUrl = url.trim(); + if (z.string().url().safeParse(trimmedUrl).success) { + return trimmedUrl; + } + const storagePrefix = + this.baseConfig.storagePrefix ?? process.env.STORAGE_PREFIX ?? process.env.PUBLIC_ORIGIN; + if (storagePrefix) { + const normalizedPrefix = storagePrefix.replace(/\/$/, ''); + const normalizedPath = trimmedUrl.startsWith('/') ? trimmedUrl : `/${trimmedUrl}`; + return `${normalizedPrefix}${normalizedPath}`; + } + // For relative URLs, use localhost with the configured port + const port = this.configService.get('PORT') || 3000; + return `http://localhost:${port}${trimmedUrl}`; + } + + private throwV2Error( + error: { + code: string; + message: string; + tags?: ReadonlyArray; + details?: Readonly>; + }, + status: number + ): never { + throw new CustomHttpException(error.message, getDefaultCodeByStatus(status), { + domainCode: error.code, + domainTags: error.tags, + details: error.details, + }); + } + + private emitImportAuditLog(tableId: string, recordCount: number, fileType?: string) { + const userId = this.cls.get('user.id'); + const origin = this.cls.get('origin'); + const appId = this.cls.get('appId'); + + // Defer emission to ensure consumers can attach event listeners after the request returns. + setImmediate(() => { + void this.cls.run(async () => { + if (userId) this.cls.set('user.id', userId); + if (origin) this.cls.set('origin', origin); + if (appId) this.cls.set('appId', appId); + + await this.eventEmitterService.emitAsync(Events.TABLE_RECORD_CREATE_RELATIVE, { + action: CreateRecordAction.InplaceImport, + resourceId: tableId, + recordCount, + params: { fileType }, + }); + }); + }); + } + + /** + * Import records using V2 architecture via CommandBus. + * Appends records from a file (CSV/Excel) to an existing table. + * + * The ImportRecordsCommand handler is responsible for: + * - Finding the table by ID + * - Parsing the import source + * - Handling typecast and side effects (new select options) + * - Resolving link fields + * - Streaming record insertion + * + * @param baseId - The base ID + * @param tableId - The table ID to import into + * @param importOptions - Import options (V1 API type for compatibility) + * @param maxRowCount - Optional max row count limit + * @param projection - Optional field projection for permission check + */ + async importRecords( + baseId: string, + tableId: string, + importOptions: IInplaceImportOptionRo, + maxRowCount?: number, + projection?: string[] + ): Promise<{ totalImported: number }> { + const container = await this.v2ContainerService.getContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + + const context = await this.v2ContextFactory.createContext(); + + const { attachmentUrl, fileType, insertConfig } = importOptions; + const { sourceColumnMap, sourceWorkSheetKey, excludeFirstRow } = insertConfig; + + // Validate field permissions if projection is provided + if (projection) { + const fieldIds = Object.keys(sourceColumnMap); + const noUpdateFields = difference(fieldIds, projection); + if (noUpdateFields.length !== 0) { + const tips = noUpdateFields.join(','); + throw new CustomHttpException( + `There is no permission to update these fields: ${tips}`, + HttpErrorCode.RESTRICTED_RESOURCE, + { + localization: { + i18nKey: 'httpErrors.permission.updateRecordWithDeniedFields', + context: { + fields: tips, + }, + }, + } + ); + } + } + + // Resolve relative URL to absolute URL + const resolvedUrl = this.resolveUrl(attachmentUrl); + + // Align with v1 behavior: treat 0 (or negative) as no limit + const normalizedMaxRowCount = + maxRowCount !== undefined && maxRowCount > 0 ? maxRowCount : undefined; + + // Create command + const commandResult = ImportRecordsCommand.createFromUrl({ + tableId, + url: resolvedUrl, + fileType, + sourceColumnMap, + options: { + skipFirstNLines: excludeFirstRow ? 1 : 0, + sheetName: sourceWorkSheetKey, + typecast: true, + batchSize: normalizedMaxRowCount ? Math.min(normalizedMaxRowCount, 500) : 500, + maxRowCount: normalizedMaxRowCount, + }, + }); + + if (commandResult.isErr()) { + throw new HttpException(commandResult.error.message, HttpStatus.BAD_REQUEST); + } + + // Execute via CommandBus + const result = await commandBus.execute( + context, + commandResult.value + ); + + if (result.isErr()) { + this.logger.error('V2 import records failed', result.error); + + // Map domain error to HTTP status + const status = + result.error.code === 'import.field_not_found' || + result.error.code === 'import.column_index_out_of_range' || + result.error.tags?.includes('validation') + ? HttpStatus.BAD_REQUEST + : result.error.tags?.includes('not-found') + ? HttpStatus.NOT_FOUND + : HttpStatus.INTERNAL_SERVER_ERROR; + + this.throwV2Error(result.error, status); + } + + this.emitImportAuditLog(tableId, result.value.totalImported, fileType); + + return { totalImported: result.value.totalImported }; + } +} diff --git a/apps/nestjs-backend/src/features/import/open-api/import-open-api.controller.ts b/apps/nestjs-backend/src/features/import/open-api/import-open-api.controller.ts index 7f9a4b4a6a..e28273e058 100644 --- a/apps/nestjs-backend/src/features/import/open-api/import-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/import/open-api/import-open-api.controller.ts @@ -1,4 +1,14 @@ -import { Controller, Get, UseGuards, Query, Post, Body, Param, Patch } from '@nestjs/common'; +import { + Controller, + Get, + UseGuards, + Query, + Post, + Body, + Param, + Patch, + UseInterceptors, +} from '@nestjs/common'; import { analyzeRoSchema, IAnalyzeRo, @@ -8,17 +18,27 @@ import { inplaceImportOptionRoSchema, } from '@teable/openapi'; import type { ITableFullVo, IAnalyzeVo } from '@teable/openapi'; +import { ClsService } from 'nestjs-cls'; +import type { IClsStore } from '../../../types/cls'; import { ZodValidationPipe } from '../../../zod.validation.pipe'; import { Permissions } from '../../auth/decorators/permissions.decorator'; import { TokenAccess } from '../../auth/decorators/token.decorator'; import { PermissionGuard } from '../../auth/guard/permission.guard'; - +import { UseV2Feature } from '../../canary/decorators/use-v2-feature.decorator'; +import { V2FeatureGuard } from '../../canary/guards/v2-feature.guard'; +import { V2IndicatorInterceptor } from '../../canary/interceptors/v2-indicator.interceptor'; +import { ImportOpenApiV2Service } from './import-open-api-v2.service'; import { ImportOpenApiService } from './import-open-api.service'; @Controller('api/import') -@UseGuards(PermissionGuard) +@UseGuards(PermissionGuard, V2FeatureGuard) +@UseInterceptors(V2IndicatorInterceptor) export class ImportController { - constructor(private readonly importOpenService: ImportOpenApiService) {} + constructor( + protected readonly importOpenService: ImportOpenApiService, + protected readonly importOpenApiV2Service: ImportOpenApiV2Service, + protected readonly cls: ClsService + ) {} @Get('/analyze') @TokenAccess() async analyzeSheetFromFile( @@ -36,6 +56,7 @@ export class ImportController { return await this.importOpenService.createTableFromImport(baseId, importRo); } + @UseV2Feature('importRecords') @Patch(':baseId/:tableId') @Permissions('table|import') async inplaceImportTable( @@ -44,6 +65,12 @@ export class ImportController { @Body(new ZodValidationPipe(inplaceImportOptionRoSchema)) inplaceImportRo: IInplaceImportOptionRo ): Promise { + // Use V2 logic when canary config enables it for this space + feature + if (this.cls.get('useV2')) { + await this.importOpenApiV2Service.importRecords(baseId, tableId, inplaceImportRo); + return; + } + return await this.importOpenService.inplaceImportTable(baseId, tableId, inplaceImportRo); } } diff --git a/apps/nestjs-backend/src/features/import/open-api/import-open-api.module.ts b/apps/nestjs-backend/src/features/import/open-api/import-open-api.module.ts index 09ef7ad7c3..5bc6461296 100644 --- a/apps/nestjs-backend/src/features/import/open-api/import-open-api.module.ts +++ b/apps/nestjs-backend/src/features/import/open-api/import-open-api.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; import { ShareDbModule } from '../../../share-db/share-db.module'; +import { CanaryModule } from '../../canary/canary.module'; import { FieldOpenApiModule } from '../../field/open-api/field-open-api.module'; import { NotificationModule } from '../../notification/notification.module'; import { RecordOpenApiModule } from '../../record/open-api/record-open-api.module'; import { TableOpenApiModule } from '../../table/open-api/table-open-api.module'; +import { V2Module } from '../../v2/v2.module'; import { ImportCsvChunkModule } from './import-csv-chunk.module'; +import { ImportOpenApiV2Service } from './import-open-api-v2.service'; import { ImportController } from './import-open-api.controller'; import { ImportOpenApiService } from './import-open-api.service'; @@ -16,9 +19,11 @@ import { ImportOpenApiService } from './import-open-api.service'; ShareDbModule, ImportCsvChunkModule, FieldOpenApiModule, + V2Module, + CanaryModule, ], controllers: [ImportController], - providers: [ImportOpenApiService], - exports: [ImportOpenApiService], + providers: [ImportOpenApiService, ImportOpenApiV2Service], + exports: [ImportOpenApiService, ImportOpenApiV2Service], }) export class ImportOpenApiModule {} diff --git a/apps/nestjs-backend/src/features/invitation/invitation.service.ts b/apps/nestjs-backend/src/features/invitation/invitation.service.ts index 85834b503a..97b2bba706 100644 --- a/apps/nestjs-backend/src/features/invitation/invitation.service.ts +++ b/apps/nestjs-backend/src/features/invitation/invitation.service.ts @@ -157,11 +157,9 @@ export class InvitationService { invitationId: id, }, }); - const { brandName } = await this.settingOpenApiService.getServerBrand(); // get email info const inviteEmailOptions = await this.mailSenderService.inviteEmailOptions({ - brandName, name: user.name, email: user.email, resourceName, diff --git a/apps/nestjs-backend/src/features/mail-sender/mail-sender.service.ts b/apps/nestjs-backend/src/features/mail-sender/mail-sender.service.ts index b27c05639d..91ef9da4ca 100644 --- a/apps/nestjs-backend/src/features/mail-sender/mail-sender.service.ts +++ b/apps/nestjs-backend/src/features/mail-sender/mail-sender.service.ts @@ -158,7 +158,11 @@ export class MailSenderService { return config; } - async notifyMergeOptions(list: ISendMailOptions & { mailType: MailType }[], brandName: string) { + async notifyMergeOptions( + list: ISendMailOptions & { mailType: MailType }[], + brandName: string, + brandLogo: string + ) { return { subject: this.i18n.t('common.email.templates.notify.subject', { args: { brandName }, @@ -167,6 +171,7 @@ export class MailSenderService { context: { partialBody: 'notify-merge-body', brandName, + brandLogo, list: list.map((item) => ({ ...item, mailType: item.mailType, @@ -249,15 +254,15 @@ export class MailSenderService { }); } - inviteEmailOptions(info: { + async inviteEmailOptions(info: { name: string; - brandName: string; email: string; resourceName: string; resourceType: CollaboratorType; inviteUrl: string; }) { - const { name, email, inviteUrl, resourceName, resourceType, brandName } = info; + const { name, email, inviteUrl, resourceName, resourceType } = info; + const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand(); const resourceAlias = resourceType === CollaboratorType.Space ? 'Space' : 'Base'; return { @@ -273,6 +278,7 @@ export class MailSenderService { inviteUrl, partialBody: 'invite', brandName, + brandLogo, title: this.i18n.t('common.email.templates.invite.title'), message: this.i18n.t('common.email.templates.invite.message', { args: { name, email, resourceAlias, resourceName }, @@ -303,7 +309,7 @@ export class MailSenderService { const refLength = recordIds.length; const viewRecordUrlPrefix = `${this.mailConfig.origin}/base/${baseId}/table/${tableId}`; - const { brandName } = await this.settingOpenApiService.getServerBrand(); + const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand(); if (refLength <= 1) { subject = this.i18n.t('common.email.templates.collaboratorCellTag.subject', { args: { fromUserName, fieldName, tableName }, @@ -336,6 +342,7 @@ export class MailSenderService { viewRecordUrlPrefix, partialBody, brandName, + brandLogo, title: this.i18n.t('common.email.templates.collaboratorCellTag.title', { args: { fromUserName, fieldName, tableName }, }), @@ -352,7 +359,7 @@ export class MailSenderService { buttonText: string; }) { const { title, message } = info; - const { brandName } = await this.settingOpenApiService.getServerBrand(); + const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand(); return { notifyMessage: message, subject: `${title} - ${brandName}`, @@ -360,6 +367,7 @@ export class MailSenderService { context: { partialBody: 'html-body', brandName, + brandLogo, ...info, }, }; @@ -373,7 +381,7 @@ export class MailSenderService { buttonText: string; }) { const { title, message } = info; - const { brandName } = await this.settingOpenApiService.getServerBrand(); + const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand(); return { notifyMessage: message, subject: `${title} - ${brandName}`, @@ -381,6 +389,7 @@ export class MailSenderService { context: { partialBody: 'common-body', brandName, + brandLogo, ...info, }, }; @@ -388,7 +397,7 @@ export class MailSenderService { async sendTestEmailOptions(info: { message?: string }) { const { message } = info; - const { brandName } = await this.settingOpenApiService.getServerBrand(); + const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand(); return { subject: this.i18n.t('common.email.templates.test.subject', { args: { brandName }, @@ -397,6 +406,7 @@ export class MailSenderService { context: { partialBody: 'html-body', brandName, + brandLogo, title: this.i18n.t('common.email.templates.test.title'), message: message || this.i18n.t('common.email.templates.test.message'), }, @@ -411,7 +421,7 @@ export class MailSenderService { waitlistInviteUrl: string; }) { const { code, times, name, email, waitlistInviteUrl } = info; - const { brandName } = await this.settingOpenApiService.getServerBrand(); + const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand(); return { subject: this.i18n.t('common.email.templates.waitlistInvite.subject', { args: { name, email, brandName }, @@ -421,6 +431,7 @@ export class MailSenderService { ...info, partialBody: 'common-body', brandName, + brandLogo, title: this.i18n.t('common.email.templates.waitlistInvite.title'), message: this.i18n.t('common.email.templates.waitlistInvite.message', { args: { brandName, code, times }, @@ -433,7 +444,7 @@ export class MailSenderService { async resetPasswordEmailOptions(info: { name: string; email: string; resetPasswordUrl: string }) { const { resetPasswordUrl } = info; - const { brandName } = await this.settingOpenApiService.getServerBrand(); + const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand(); return { subject: this.i18n.t('common.email.templates.resetPassword.subject', { @@ -445,6 +456,7 @@ export class MailSenderService { context: { partialBody: 'reset-password', brandName, + brandLogo, title: this.i18n.t('common.email.templates.resetPassword.title'), message: this.i18n.t('common.email.templates.resetPassword.message'), buttonText: this.i18n.t('common.email.templates.resetPassword.buttonText'), @@ -484,7 +496,7 @@ export class MailSenderService { private async sendSignupVerificationEmailOptions(payload: { code: string; expiresIn: string }) { const { code, expiresIn } = payload; - const { brandName } = await this.settingOpenApiService.getServerBrand(); + const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand(); return { subject: this.i18n.t('common.email.templates.emailVerifyCode.signupVerification.subject', { args: { @@ -495,6 +507,7 @@ export class MailSenderService { context: { partialBody: 'email-verify-code', brandName, + brandLogo, title: this.i18n.t('common.email.templates.emailVerifyCode.signupVerification.title'), message: this.i18n.t('common.email.templates.emailVerifyCode.signupVerification.message', { args: { @@ -508,7 +521,7 @@ export class MailSenderService { private async sendChangeEmailCodeEmailOptions(payload: { code: string; expiresIn: string }) { const { code, expiresIn } = payload; - const { brandName } = await this.settingOpenApiService.getServerBrand(); + const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand(); return { subject: this.i18n.t( 'common.email.templates.emailVerifyCode.changeEmailVerification.subject', @@ -520,6 +533,7 @@ export class MailSenderService { context: { partialBody: 'email-verify-code', brandName, + brandLogo, title: this.i18n.t('common.email.templates.emailVerifyCode.changeEmailVerification.title'), message: this.i18n.t( 'common.email.templates.emailVerifyCode.changeEmailVerification.message', @@ -541,7 +555,7 @@ export class MailSenderService { expiresIn: string; }) { const { domain, name, code, expiresIn } = payload; - const { brandName } = await this.settingOpenApiService.getServerBrand(); + const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand(); return { subject: this.i18n.t('common.email.templates.emailVerifyCode.domainVerification.subject', { args: { @@ -552,6 +566,7 @@ export class MailSenderService { context: { partialBody: 'email-verify-code', brandName, + brandLogo, title: this.i18n.t('common.email.templates.emailVerifyCode.domainVerification.title', { args: { domain, name }, }), diff --git a/apps/nestjs-backend/src/features/mail-sender/open-api/mail-sender.merge.processor.ts b/apps/nestjs-backend/src/features/mail-sender/open-api/mail-sender.merge.processor.ts index 1f599aa472..3c51759ef8 100644 --- a/apps/nestjs-backend/src/features/mail-sender/open-api/mail-sender.merge.processor.ts +++ b/apps/nestjs-backend/src/features/mail-sender/open-api/mail-sender.merge.processor.ts @@ -114,8 +114,8 @@ export class MailSenderMergeProcessor extends WorkerHost { return; } - const { brandName } = await this.settingOpenApiService.getServerBrand(); - const mailOptions = await this.mailSenderService.notifyMergeOptions(list, brandName); + const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand(); + const mailOptions = await this.mailSenderService.notifyMergeOptions(list, brandName, brandLogo); this.mailSenderService.sendMailByTransporterName( { ...mailOptions, diff --git a/apps/nestjs-backend/src/features/mail-sender/templates/partials/header.hbs b/apps/nestjs-backend/src/features/mail-sender/templates/partials/header.hbs index 9d093f0d71..4c4c221d32 100644 --- a/apps/nestjs-backend/src/features/mail-sender/templates/partials/header.hbs +++ b/apps/nestjs-backend/src/features/mail-sender/templates/partials/header.hbs @@ -1,7 +1,6 @@ - {{brandName}} Logo + {{brandName}} Logo \ No newline at end of file diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.ts new file mode 100644 index 0000000000..84f1df1d24 --- /dev/null +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.ts @@ -0,0 +1,947 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable sonarjs/cognitive-complexity */ +import { Injectable, HttpException, HttpStatus, Inject, forwardRef } from '@nestjs/common'; +import { trace } from '@opentelemetry/api'; +import { FieldKeyType, generateOperationId, parseClipboardText } from '@teable/core'; +import type { IFilterSet } from '@teable/core'; +import type { + IUpdateRecordRo, + IRecord, + ICreateRecordsRo, + ICreateRecordsVo, + IPasteRo, + IPasteVo, + IRangesRo, + IRecordsVo, + IRecordInsertOrderRo, + IUpdateRecordsRo, +} from '@teable/openapi'; +import { RangeType } from '@teable/openapi'; +import { + executeCreateRecordsEndpoint, + executeDeleteRecordsEndpoint, + executeDeleteByRangeEndpoint, + executePasteEndpoint, + executeClearEndpoint, + executeUpdateRecordEndpoint, + executeDuplicateRecordEndpoint, + executeReorderRecordsEndpoint, +} from '@teable/v2-contract-http-implementation/handlers'; +import { v2CoreTokens } from '@teable/v2-core'; +import type { + ICommandBus, + RecordFilter, + RecordFilterGroup, + RecordFilterNode, + RecordFilterOperator, + RecordFilterValue, +} from '@teable/v2-core'; +import { ClsService } from 'nestjs-cls'; +import { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exception'; +import { EventEmitterService } from '../../../event-emitter/event-emitter.service'; +import { Events } from '../../../event-emitter/events'; +import type { IClsStore } from '../../../types/cls'; +import { AggregationService } from '../../aggregation/aggregation.service'; +import { FieldService } from '../../field/field.service'; +import { SelectionService } from '../../selection/selection.service'; +import { TableService } from '../../table/table.service'; +import { TableDomainQueryService } from '../../table-domain'; +import { V2ContainerService } from '../../v2/v2-container.service'; +import { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory'; +import { RecordService } from '../record.service'; +import { RecordOpenApiService } from './record-open-api.service'; + +const internalServerError = 'Internal server error'; +const v1SymbolOperatorMap: Record = { + '=': 'is', + '!=': 'isNot', + '>': 'isGreater', + '>=': 'isGreaterEqual', + '<': 'isLess', + '<=': 'isLessEqual', + LIKE: 'contains', + 'NOT LIKE': 'doesNotContain', + IN: 'isAnyOf', + 'NOT IN': 'isNoneOf', + HAS: 'hasAllOf', + 'IS NULL': 'isEmpty', + 'IS NOT NULL': 'isNotEmpty', + 'IS WITH IN': 'isWithIn', +}; + +@Injectable() +export class RecordOpenApiV2Service { + constructor( + private readonly v2ContainerService: V2ContainerService, + private readonly v2ContextFactory: V2ExecutionContextFactory, + private readonly recordService: RecordService, + private readonly recordOpenApiService: RecordOpenApiService, + private readonly tableService: TableService, + private readonly cls: ClsService, + private readonly fieldService: FieldService, + private readonly aggregationService: AggregationService, + private readonly eventEmitterService: EventEmitterService, + private readonly tableDomainQueryService: TableDomainQueryService, + @Inject(forwardRef(() => SelectionService)) + private readonly selectionService: SelectionService + ) {} + + private throwV2Error( + error: { + code: string; + message: string; + tags?: ReadonlyArray; + details?: Readonly>; + }, + status: number + ): never { + throw new CustomHttpException(error.message, getDefaultCodeByStatus(status), { + domainCode: error.code, + domainTags: error.tags, + details: error.details, + }); + } + + async updateRecord( + tableId: string, + recordId: string, + updateRecordRo: IUpdateRecordRo, + windowId?: string, + isAiInternal?: string + ): Promise { + const order = updateRecordRo.order; + const hasOrder = Boolean(order); + const fields = updateRecordRo.record.fields ?? {}; + const hasFields = Object.keys(fields).length > 0; + + const container = await this.v2ContainerService.getContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(); + + if (hasFields) { + // Convert v1 input format to v2 format + // v1: { record: { fields: { fieldKey: value } } } + // v2: { tableId, recordId, fields: { fieldId: value } } + // v1 stores select field values by name, v2 stores by id + // Preserve v1's default typecast behavior (false) to ensure proper validation + const v2Input = { + tableId, + recordId, + fields, + typecast: updateRecordRo.typecast ?? false, + fieldKeyType: updateRecordRo.fieldKeyType, + }; + + const result = await executeUpdateRecordEndpoint(context, v2Input, commandBus); + if (!(result.status === 200 && result.body.ok)) { + if (!result.body.ok) { + this.throwV2Error(result.body.error, result.status); + } + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + if (hasOrder && order) { + const reorderResult = await executeReorderRecordsEndpoint( + context, + { + tableId, + recordIds: [recordId], + order: { + viewId: order.viewId, + anchorId: order.anchorId, + position: order.position, + }, + }, + commandBus + ); + if (!(reorderResult.status === 200 && reorderResult.body.ok)) { + if (!reorderResult.body.ok) { + this.throwV2Error(reorderResult.body.error, reorderResult.status); + } + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + if (hasFields || hasOrder) { + const snapshots = await this.recordService.getSnapshotBulkWithPermission( + tableId, + [recordId], + undefined, + updateRecordRo.fieldKeyType || FieldKeyType.Name, + undefined, + true + ); + + if (snapshots.length === 1) { + return snapshots[0].data as IRecord; + } + + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + + async updateRecords( + tableId: string, + updateRecordsRo: IUpdateRecordsRo, + windowId?: string, + isAiInternal?: string + ): Promise { + const order = updateRecordsRo.order; + const records = updateRecordsRo.records ?? []; + const recordIds = records.map((record) => record.id); + const hasOrder = Boolean(order); + const hasFields = records.some( + (record) => record.fields && Object.keys(record.fields).length > 0 + ); + + if (!hasOrder || hasFields) { + return ( + await this.recordOpenApiService.updateRecords( + tableId, + updateRecordsRo, + windowId, + isAiInternal + ) + ).records; + } + + const container = await this.v2ContainerService.getContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(); + + if (hasOrder && order) { + const reorderResult = await executeReorderRecordsEndpoint( + context, + { + tableId, + recordIds, + order: { + viewId: order.viewId, + anchorId: order.anchorId, + position: order.position, + }, + }, + commandBus + ); + if (!(reorderResult.status === 200 && reorderResult.body.ok)) { + if (!reorderResult.body.ok) { + this.throwV2Error(reorderResult.body.error, reorderResult.status); + } + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + if (recordIds.length === 0) { + return []; + } + + const snapshots = await this.recordService.getSnapshotBulkWithPermission( + tableId, + recordIds, + undefined, + updateRecordsRo.fieldKeyType || FieldKeyType.Name, + undefined, + true + ); + + if (snapshots.length !== recordIds.length) { + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + + const snapshotMap = new Map(snapshots.map((snapshot) => [snapshot.data.id, snapshot.data])); + const resultRecords = recordIds + .map((recordId) => snapshotMap.get(recordId)) + .filter((record): record is IRecord => Boolean(record)); + + if (resultRecords.length !== recordIds.length) { + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + + return resultRecords; + } + + async createRecords( + tableId: string, + createRecordsRo: ICreateRecordsRo, + isAiInternal?: string + ): Promise { + const container = await this.v2ContainerService.getContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(); + + // Preserve v1's default typecast behavior (false) to ensure proper validation + const records = createRecordsRo.records; + + const result = await executeCreateRecordsEndpoint( + context, + { + tableId, + records, + typecast: createRecordsRo.typecast ?? false, + fieldKeyType: createRecordsRo.fieldKeyType, + order: createRecordsRo.order, + }, + commandBus + ); + + if (result.status === 201 && result.body.ok) { + const recordIds = result.body.data.records.map((record) => record.id); + if (recordIds.length === 0) { + return { records: [] }; + } + + const snapshots = await this.recordService.getSnapshotBulkWithPermission( + tableId, + recordIds, + undefined, + createRecordsRo.fieldKeyType || FieldKeyType.Name, + undefined, + true + ); + + if (snapshots.length !== recordIds.length) { + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + + const snapshotMap = new Map(snapshots.map((snapshot) => [snapshot.data.id, snapshot.data])); + const resultRecords = recordIds + .map((recordId) => snapshotMap.get(recordId)) + .filter((record): record is IRecord => Boolean(record)); + + if (resultRecords.length !== recordIds.length) { + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + + return { records: resultRecords }; + } + + if (!result.body.ok) { + this.throwV2Error(result.body.error, result.status); + } + + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + + async paste( + tableId: string, + pasteRo: IPasteRo, + options?: { updateFilter?: IFilterSet | null; windowId?: string } + ): Promise { + const container = await this.v2ContainerService.getContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(); + const userId = this.cls.get('user.id'); + const windowId = options?.windowId; + const tracer = trace.getTracer('default'); + + // Convert v1 input format to v2 format + // v1 ranges format depends on type: + // - default (cell range): [[startCol, startRow], [endCol, endRow]] + // - columns: [[startCol, endCol]] - single element array + // - rows: [[startRow, endRow]] - single element array + // v2 now supports type parameter directly and handles the conversion internally + const { ranges, content, viewId, header, type, projection, filter, orderBy } = pasteRo; + + let fallbackRanges: IPasteVo['ranges'] | null = null; + let v2Input: unknown; + let finalContent: unknown[][] = []; + let startCol = 0; + let startRow = 0; + let truncatedRows = 0; + + await tracer.startActiveSpan('teable.paste.v2.prepare', async (span) => { + try { + // Parse content if it's a string (tab-separated values) + let parsedContent: unknown[][] = + typeof content === 'string' ? this.parseCopyContent(content) : content; + + // Get permissions to check for field|create and record|create + const permissions = this.cls.get('permissions') ?? []; + const hasFieldCreatePermission = permissions.includes('field|create'); + const hasRecordCreatePermission = permissions.includes('record|create'); + + // Get table size to calculate expansion needs + const resolvedViewId = await this.resolveViewId(tableId, viewId); + const queryRo = { viewId: resolvedViewId, filter, projection, orderBy }; + + const fields = await this.fieldService.getFieldInstances(tableId, { + viewId: resolvedViewId, + filterHidden: true, + projection, + }); + const { rowCount: rowCountInView } = await this.aggregationService.performRowCount( + tableId, + queryRo + ); + + const tableSize: [number, number] = [fields.length, rowCountInView]; + + // Calculate start cell based on range type + if (type === 'columns') { + startCol = ranges[0]![0]; + startRow = 0; + } else if (type === 'rows') { + startCol = 0; + startRow = ranges[0]![0]; + } else { + startCol = ranges[0]![0]; + startRow = ranges[0]![1]; + } + + // Expand paste content to fill selection (matches V1 behavior) + parsedContent = this.expandPasteContent( + parsedContent, + type, + ranges, + tableSize[0], + tableSize[1], + startCol, + startRow + ); + + const contentCols = parsedContent[0]?.length ?? 0; + const contentRows = parsedContent.length; + + // Calculate expansion needs + const numColsToExpand = Math.max(0, startCol + contentCols - tableSize[0]); + const numRowsToExpand = Math.max(0, startRow + contentRows - tableSize[1]); + + // Apply permission-based limits (like V1's calculateExpansion) + const effectiveColsToExpand = hasFieldCreatePermission ? numColsToExpand : 0; + const effectiveRowsToExpand = hasRecordCreatePermission ? numRowsToExpand : 0; + + // When paste needs to create new fields, fall back to V1's paste implementation. + // V2's paste doesn't support field creation, and mixing V2 record operations with + // V1 field operations causes database lock conflicts during undo. + if (effectiveColsToExpand > 0) { + fallbackRanges = await this.selectionService.paste(tableId, pasteRo, { + windowId, + }); + return; + } + + // Truncate content if expansion is not allowed + finalContent = parsedContent; + const maxCols = tableSize[0] - startCol + effectiveColsToExpand; + const maxRows = tableSize[1] - startRow + effectiveRowsToExpand; + + // Track if we need to adjust ranges due to truncation + let truncatedCols = contentCols; + truncatedRows = contentRows; + + if (contentCols > maxCols || contentRows > maxRows) { + truncatedRows = Math.min(contentRows, maxRows); + truncatedCols = Math.min(contentCols, maxCols); + finalContent = parsedContent + .slice(0, truncatedRows) + .map((row) => row.slice(0, truncatedCols)); + } + + // Adjust ranges to match truncated content (prevents V2 core from re-expanding) + let adjustedRanges = ranges; + if (type === undefined && finalContent.length > 0 && finalContent[0]?.length > 0) { + // For cell type, adjust end position to match truncated content + const adjustedEndCol = startCol + truncatedCols - 1; + const adjustedEndRow = startRow + truncatedRows - 1; + adjustedRanges = [ + [startCol, startRow], + [adjustedEndCol, adjustedEndRow], + ]; + } + + // Convert header to sourceFields format if provided + const sourceFields = header?.map((field) => ({ + name: field.name, + type: field.type, + cellValueType: field.cellValueType, + isComputed: field.isComputed, + isLookup: field.isLookup, + isMultipleCellValue: field.isMultipleCellValue, + options: field.options, + })); + + const normalizedFilter = this.mapV1FilterToV2(filter); + const normalizedUpdateFilter = options?.updateFilter + ? this.mapV1FilterToV2(options.updateFilter) + : undefined; + v2Input = { + tableId, + viewId: resolvedViewId, + ranges: adjustedRanges, + content: finalContent, + typecast: true, + sourceFields, + type, // Pass type to v2 for internal handling + projection, + filter: normalizedFilter, + updateFilter: normalizedUpdateFilter, + sort: orderBy, + }; + } finally { + span.end(); + } + }); + + if (fallbackRanges) { + return { ranges: fallbackRanges }; + } + + if (!v2Input) { + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + + const result = await executePasteEndpoint(context, v2Input, commandBus); + + if (result.status === 200 && result.body.ok) { + // V2 returns { updatedCount, createdCount, createdRecordIds } + // V1 expects { ranges: [[startCol, startRow], [endCol, endRow]] } + // Use truncatedRows (content size) for range calculation, not operation count, + // because some rows may be skipped due to permission filters + const finalCols = finalContent[0]?.length ?? 1; + + // Note: Record creation undo/redo is handled by V2's RecordsBatchCreated projection handler + // Field creation case is handled by V1 fallback above + + // Best-effort: normalize v1 range formats (cell/rows/columns) into a cell range. + // v1 "ranges" uses `cellSchema` for all modes: + // - default: [col, row] + // - columns: [startCol, endCol] + // - rows: [startRow, endRow] + if (type === 'columns') { + const endCol = startCol + finalCols - 1; + return { + ranges: [ + [startCol, 0], + [endCol, Math.max(truncatedRows - 1, 0)], + ], + }; + } + + if (type === 'rows') { + const endRow = ranges[0]![1]; + return { + ranges: [ + [0, startRow], + [Math.max(finalCols - 1, 0), endRow], + ], + }; + } + + const endRow = startRow + Math.max(truncatedRows - 1, 0); + const endCol = startCol + finalCols - 1; + return { + ranges: [ + [startCol, startRow], + [endCol, Math.max(endRow, startRow)], + ], + }; + } + + if (!result.body.ok) { + this.throwV2Error(result.body.error, result.status); + } + + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + + /** + * Expand paste content to fill target selection (matches V1 behavior). + * If the selection is a multiple of the content size, the content is tiled. + */ + private expandPasteContent( + content: unknown[][], + type: 'columns' | 'rows' | undefined, + ranges: [number, number][], + totalCols: number, + totalRows: number, + startCol: number, + startRow: number + ): unknown[][] { + if (content.length === 0 || content[0]?.length === 0) { + return content; + } + + const contentRows = content.length; + const contentCols = content[0]!.length; + + // Calculate target range size + let targetRows: number; + let targetCols: number; + + if (type === 'columns') { + const endCol = ranges[0]![1]; + targetCols = endCol - startCol + 1; + targetRows = totalRows; + } else if (type === 'rows') { + const endRow = ranges[0]![1]; + targetRows = endRow - startRow + 1; + targetCols = totalCols; + } else { + // Cell range: [[startCol, startRow], [endCol, endRow]] + const endCol = ranges[1]?.[0] ?? startCol; + const endRow = ranges[1]?.[1] ?? startRow; + targetCols = endCol - startCol + 1; + targetRows = endRow - startRow + 1; + } + + // If target equals content size, no expansion needed + if (targetRows === contentRows && targetCols === contentCols) { + return content; + } + + // Only expand if target is an exact multiple of content dimensions + if (targetRows % contentRows !== 0 || targetCols % contentCols !== 0) { + return content; + } + + // Tile content to fill the target range + return Array.from({ length: targetRows }, (_, rowIdx) => + Array.from( + { length: targetCols }, + (_, colIdx) => content[rowIdx % contentRows]![colIdx % contentCols] + ) + ); + } + + async clear(tableId: string, rangesRo: IRangesRo): Promise { + const container = await this.v2ContainerService.getContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(); + + // Convert v1 input format to v2 format + const { ranges, viewId, type, filter } = rangesRo; + + const resolvedViewId = await this.resolveViewId(tableId, viewId); + const normalizedFilter = this.mapV1FilterToV2(filter); + const v2Input = { + tableId, + viewId: resolvedViewId, + ranges, + type, + filter: normalizedFilter, + }; + + const result = await executeClearEndpoint(context, v2Input, commandBus); + + if (result.status === 200 && result.body.ok) { + // V1 clear returns null + return null; + } + + if (!result.body.ok) { + this.throwV2Error(result.body.error, result.status); + } + + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + + /** + * Get record IDs from ranges for undo/redo support and permission checks. + * This method queries the record IDs that will be affected by a range-based operation. + */ + async getRecordIdsFromRanges(tableId: string, rangesRo: IRangesRo): Promise { + const { ranges, type, viewId, filter, orderBy, search, groupBy, collapsedGroupIds } = rangesRo; + + const baseQuery = { + viewId, + filter, + orderBy, + search, + groupBy, + collapsedGroupIds, + fieldKeyType: FieldKeyType.Id, + }; + + if (type === RangeType.Columns) { + // For columns selection, get all record IDs + const result = await this.recordService.getDocIdsByQuery( + tableId, + { ...baseQuery, skip: 0, take: -1 }, + true + ); + return result.ids; + } + + if (type === RangeType.Rows) { + // For rows selection, iterate through each range [start, end] + let recordIds: string[] = []; + for (const [start, end] of ranges) { + const result = await this.recordService.getDocIdsByQuery( + tableId, + { ...baseQuery, skip: start, take: end - start + 1 }, + true + ); + recordIds = recordIds.concat(result.ids); + } + return recordIds; + } + + // Default: cell range - ranges is [[startCol, startRow], [endCol, endRow]] + const [start, end] = ranges; + const result = await this.recordService.getDocIdsByQuery( + tableId, + { ...baseQuery, skip: start[1], take: end[1] - start[1] + 1 }, + true + ); + return result.ids; + } + + async deleteByRange( + tableId: string, + rangesRo: IRangesRo, + _windowId?: string + ): Promise<{ ids: string[] }> { + const container = await this.v2ContainerService.getContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(); + + // Resolve viewId (required for v2 deleteByRange) + const viewId = await this.resolveViewId(tableId, rangesRo.viewId); + + // Build v2 deleteByRange input + const v2Input = { + tableId, + viewId, + ranges: rangesRo.ranges, + type: rangesRo.type, + filter: this.mapV1FilterToV2(rangesRo.filter), + sort: rangesRo.orderBy?.map((item) => ({ + fieldId: item.fieldId, + order: item.order, + })), + search: rangesRo.search, + groupBy: rangesRo.groupBy?.map((item) => ({ + fieldId: item.fieldId, + order: item.order, + })), + }; + + const result = await executeDeleteByRangeEndpoint(context, v2Input, commandBus); + + if (result.status === 200 && result.body.ok) { + // V2's DeleteByRangeHandler captures snapshots and emits RecordsDeleted event. + // Undo/redo is handled by V2RecordsDeletedUndoRedoProjection in v2-undo-redo.service.ts + return { ids: [...result.body.data.deletedRecordIds] }; + } + + if (!result.body.ok) { + this.throwV2Error(result.body.error, result.status); + } + + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + + async deleteRecords( + tableId: string, + recordIds: string[], + windowId?: string + ): Promise { + const container = await this.v2ContainerService.getContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(); + const userId = this.cls.get('user.id'); + + // Query records before deletion to return them in V1 format + const recordSnapshots = await this.recordService.getSnapshotBulkWithPermission( + tableId, + recordIds, + undefined, + FieldKeyType.Id, + undefined, + true + ); + + // Get record orders for undo/redo support (only if windowId is provided) + let orders: Record[] | undefined; + if (windowId) { + const table = await this.tableDomainQueryService.getTableDomainById(tableId); + orders = await this.recordService.getRecordIndexes(table, recordIds); + } + + const v2Input = { + tableId, + recordIds, + }; + + const result = await executeDeleteRecordsEndpoint(context, v2Input, commandBus); + + if (result.status === 200 && result.body.ok) { + // TODO: Migrate to pure V2 undo/redo - see v2-undo-redo.service.ts for details. + // + // Currently emitting V1 event because V2's RecordsDeleted projection cannot + // handle undo/redo correctly: + // 1. V2's stored query returns incomplete field data (primary field value missing) + // 2. V2 doesn't track record order in views (required for restoring position) + // 3. V1's getSnapshotBulkWithPermission + getRecordIndexes provides complete data + // + // When V2 stored query is fixed and order tracking is added, this should be + // replaced by proper V2 projection handling in V2RecordsDeletedUndoRedoProjection. + const records = recordSnapshots.map((snapshot, index) => ({ + ...(snapshot.data as IRecord), + order: orders?.[index], + })); + + this.eventEmitterService.emitAsync(Events.OPERATION_RECORDS_DELETE, { + operationId: generateOperationId(), + windowId, + tableId, + userId, + records, + }); + + // Return records that were deleted (V1 format) + return { + records: recordSnapshots.map((snapshot) => snapshot.data as IRecord), + }; + } + + if (!result.body.ok) { + this.throwV2Error(result.body.error, result.status); + } + + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + + /** + * Parse tab-separated content string into 2D array + */ + private parseCopyContent(content: string): unknown[][] { + return parseClipboardText(content); + } + + private async resolveViewId(tableId: string, viewId?: string | null): Promise { + if (viewId) { + return viewId; + } + const defaultView = await this.tableService.getDefaultViewId(tableId); + return defaultView.id; + } + + private mapV1FilterToV2(filter: unknown): RecordFilter | undefined | null { + if (filter === undefined) return undefined; + if (filter === null) return null; + if (this.isV2FilterNode(filter)) return filter as RecordFilter; + if (this.isV1FilterGroup(filter)) return this.mapV1FilterGroup(filter); + if (this.isV1FilterItem(filter)) return this.mapV1FilterItem(filter); + return undefined; + } + + private isV2FilterNode(value: unknown): value is RecordFilterNode { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + if (Array.isArray(record.items)) return true; + if (record.not && typeof record.not === 'object') return true; + if (typeof record.fieldId === 'string' && typeof record.operator === 'string') return true; + return false; + } + + private isV1FilterGroup( + value: unknown + ): value is { conjunction: 'and' | 'or'; filterSet: unknown[] } { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + return Array.isArray(record.filterSet); + } + + private isV1FilterItem( + value: unknown + ): value is { fieldId: string; operator: string; value?: unknown; isSymbol?: boolean } { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + return typeof record.fieldId === 'string' && typeof record.operator === 'string'; + } + + private mapV1FilterGroup(filter: { + conjunction: 'and' | 'or'; + filterSet: unknown[]; + }): RecordFilterGroup | null { + const items = filter.filterSet + .map((entry) => this.mapV1FilterEntry(entry)) + .filter((entry): entry is RecordFilterNode => Boolean(entry)); + if (items.length === 0) return null; + return { + conjunction: filter.conjunction === 'or' ? 'or' : 'and', + items, + }; + } + + private mapV1FilterEntry(entry: unknown): RecordFilterNode | null { + if (entry === null || entry === undefined) return null; + if (this.isV2FilterNode(entry)) return entry as RecordFilterNode; + if (this.isV1FilterGroup(entry)) return this.mapV1FilterGroup(entry); + if (this.isV1FilterItem(entry)) return this.mapV1FilterItem(entry); + return null; + } + + private mapV1FilterItem(filter: { + fieldId: string; + operator: string; + value?: unknown; + isSymbol?: boolean; + }): RecordFilterNode { + const operator = this.normalizeV1Operator( + filter.operator, + filter.isSymbol + ) as RecordFilterOperator; + return { + fieldId: filter.fieldId, + operator, + value: ('value' in filter ? filter.value ?? null : null) as RecordFilterValue, + }; + } + + private normalizeV1Operator(operator: string, isSymbol?: boolean): string { + const mapped = v1SymbolOperatorMap[operator]; + if (mapped) return mapped; + if (isSymbol) return operator; + return operator; + } + + async duplicateRecord( + tableId: string, + recordId: string, + order?: IRecordInsertOrderRo + ): Promise { + const container = await this.v2ContainerService.getContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(); + + const result = await executeDuplicateRecordEndpoint( + context, + { + tableId, + recordId, + order, + }, + commandBus + ); + + if (result.status === 201 && result.body.ok) { + const duplicatedRecordId = result.body.data.record.id; + + // Use V1 to get the full record with proper field key mapping + const snapshots = await this.recordService.getSnapshotBulkWithPermission( + tableId, + [duplicatedRecordId], + undefined, + FieldKeyType.Name, + undefined, + true + ); + + if (snapshots.length !== 1 || !snapshots[0]) { + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } + + return snapshots[0].data as IRecord; + } + + if (!result.body.ok) { + this.throwV2Error(result.body.error, result.status); + } + + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts index fa49f23a49..90b42d7a95 100644 --- a/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts @@ -11,10 +11,25 @@ import { Query, Req, UploadedFile, + UseGuards, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { PrismaService } from '@teable/db-main-prisma'; +import { + createRecordsRoSchema, + getRecordQuerySchema, + getRecordsRoSchema, + updateRecordRoSchema, + deleteRecordsQuerySchema, + getRecordHistoryQuerySchema, + updateRecordsRoSchema, + recordInsertOrderRoSchema, + recordGetCollaboratorsRoSchema, + formSubmitRoSchema, + optionalRecordOrderSchema, + insertAttachmentRoSchema, +} from '@teable/openapi'; import type { IAutoFillCellVo, IButtonClickVo, @@ -23,29 +38,16 @@ import type { IRecordGetCollaboratorsVo, IRecordStatusVo, IRecordsVo, -} from '@teable/openapi'; -import { - createRecordsRoSchema, - getRecordQuerySchema, - getRecordsRoSchema, - IGetRecordsRo, ICreateRecordsRo, - IGetRecordQuery, - IUpdateRecordRo, - updateRecordRoSchema, - deleteRecordsQuerySchema, IDeleteRecordsQuery, - getRecordHistoryQuerySchema, + IGetRecordQuery, IGetRecordHistoryQuery, - updateRecordsRoSchema, - IUpdateRecordsRo, - IRecordInsertOrderRo, - recordGetCollaboratorsRoSchema, + IGetRecordsRo, IRecordGetCollaboratorsRo, - formSubmitRoSchema, + IRecordInsertOrderRo, + IUpdateRecordRo, + IUpdateRecordsRo, IFormSubmitRo, - optionalRecordOrderSchema, - insertAttachmentRoSchema, IInsertAttachmentRo, } from '@teable/openapi'; import { ClsService } from 'nestjs-cls'; @@ -58,11 +60,17 @@ import { filterHasMe } from '../../../utils/filter-has-me'; import { ZodValidationPipe } from '../../../zod.validation.pipe'; import { AllowAnonymous } from '../../auth/decorators/allow-anonymous.decorator'; import { Permissions } from '../../auth/decorators/permissions.decorator'; +import { UseV2Feature } from '../../canary/decorators/use-v2-feature.decorator'; +import { V2FeatureGuard } from '../../canary/guards/v2-feature.guard'; +import { V2IndicatorInterceptor } from '../../canary/interceptors/v2-indicator.interceptor'; import { RecordService } from '../record.service'; import { FieldKeyPipe } from './field-key.pipe'; +import { RecordOpenApiV2Service } from './record-open-api-v2.service'; import { RecordOpenApiService } from './record-open-api.service'; import { TqlPipe } from './tql.pipe'; +@UseGuards(V2FeatureGuard) +@UseInterceptors(V2IndicatorInterceptor) @Controller('api/table/:tableId/record') @AllowAnonymous() export class RecordOpenApiController { @@ -71,7 +79,8 @@ export class RecordOpenApiController { private readonly recordOpenApiService: RecordOpenApiService, private readonly performanceCacheService: PerformanceCacheService, private readonly prismaService: PrismaService, - private readonly cls: ClsService + private readonly cls: ClsService, + private readonly recordOpenApiV2Service: RecordOpenApiV2Service ) {} @Permissions('record|update') @@ -121,6 +130,7 @@ export class RecordOpenApiController { return await this.recordService.getRecord(tableId, recordId, query, true, true); } + @UseV2Feature('updateRecord') @Permissions('record|update') @Patch(':recordId') async updateRecord( @@ -130,6 +140,17 @@ export class RecordOpenApiController { @Headers('x-window-id') windowId?: string, @Headers('x-ai-internal') isAiInternal?: string ): Promise { + // Use V2 logic when canary config enables it for this space + feature + if (this.cls.get('useV2')) { + return this.recordOpenApiV2Service.updateRecord( + tableId, + recordId, + updateRecordRo, + windowId, + isAiInternal + ); + } + return await this.recordOpenApiService.updateRecord( tableId, recordId, @@ -176,6 +197,7 @@ export class RecordOpenApiController { } @Permissions('record|update') + @UseV2Feature('updateRecords') @Patch() async updateRecords( @Param('tableId') tableId: string, @@ -183,6 +205,15 @@ export class RecordOpenApiController { @Headers('x-window-id') windowId?: string, @Headers('x-ai-internal') isAiInternal?: string ): Promise { + if (this.cls.get('useV2')) { + return await this.recordOpenApiV2Service.updateRecords( + tableId, + updateRecordsRo, + windowId, + isAiInternal + ); + } + return ( await this.recordOpenApiService.updateRecords( tableId, @@ -193,6 +224,7 @@ export class RecordOpenApiController { ).records; } + @UseV2Feature('createRecord') @Permissions('record|create') @Post() @EmitControllerEvent(Events.OPERATION_RECORDS_CREATE) @@ -201,6 +233,15 @@ export class RecordOpenApiController { @Body(new ZodValidationPipe(createRecordsRoSchema)) createRecordsRo: ICreateRecordsRo, @Headers('x-ai-internal') isAiInternal?: string ): Promise { + // Use V2 logic when canary config enables it for this space + feature + if (this.cls.get('useV2')) { + return await this.recordOpenApiV2Service.createRecords( + tableId, + createRecordsRo, + isAiInternal + ); + } + return await this.recordOpenApiService.multipleCreateRecords( tableId, createRecordsRo, @@ -218,6 +259,7 @@ export class RecordOpenApiController { return await this.recordOpenApiService.formSubmit(tableId, formSubmitRo); } + @UseV2Feature('duplicateRecord') @Permissions('record|create', 'record|read') @Post(':recordId/duplicate') @EmitControllerEvent(Events.OPERATION_RECORDS_CREATE) @@ -226,9 +268,13 @@ export class RecordOpenApiController { @Param('recordId') recordId: string, @Body(new ZodValidationPipe(optionalRecordOrderSchema)) order?: IRecordInsertOrderRo ) { + if (this.cls.get('useV2')) { + return await this.recordOpenApiV2Service.duplicateRecord(tableId, recordId, order); + } return await this.recordOpenApiService.duplicateRecord(tableId, recordId, order); } + @UseV2Feature('deleteRecord') @Permissions('record|delete') @Delete(':recordId') async deleteRecord( @@ -236,9 +282,16 @@ export class RecordOpenApiController { @Param('recordId') recordId: string, @Headers('x-window-id') windowId?: string ): Promise { + // Use V2 logic when canary config enables it for this space + feature + if (this.cls.get('useV2')) { + const result = await this.recordOpenApiV2Service.deleteRecords(tableId, [recordId], windowId); + return result.records[0]; + } + return await this.recordOpenApiService.deleteRecord(tableId, recordId, windowId); } + @UseV2Feature('deleteRecord') @Permissions('record|delete') @Delete() async deleteRecords( @@ -246,6 +299,11 @@ export class RecordOpenApiController { @Query(new ZodValidationPipe(deleteRecordsQuerySchema)) query: IDeleteRecordsQuery, @Headers('x-window-id') windowId?: string ): Promise { + // Use V2 logic when canary config enables it for this space + feature + if (this.cls.get('useV2')) { + return this.recordOpenApiV2Service.deleteRecords(tableId, query.recordIds, windowId); + } + return await this.recordOpenApiService.deleteRecords(tableId, query.recordIds, windowId); } diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api.module.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api.module.ts index a8200b521c..e509424cf6 100644 --- a/apps/nestjs-backend/src/features/record/open-api/record-open-api.module.ts +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api.module.ts @@ -1,15 +1,21 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; +import { AggregationModule } from '../../aggregation/aggregation.module'; import { AttachmentsStorageModule } from '../../attachments/attachments-storage.module'; import { AttachmentsModule } from '../../attachments/attachments.module'; import { CalculationModule } from '../../calculation/calculation.module'; +import { CanaryModule } from '../../canary/canary.module'; import { CollaboratorModule } from '../../collaborator/collaborator.module'; import { FieldCalculateModule } from '../../field/field-calculate/field-calculate.module'; import { FieldModule } from '../../field/field.module'; +import { SelectionModule } from '../../selection/selection.module'; +import { TableModule } from '../../table/table.module'; import { TableDomainQueryModule } from '../../table-domain'; +import { V2Module } from '../../v2/v2.module'; import { ViewOpenApiModule } from '../../view/open-api/view-open-api.module'; import { ViewModule } from '../../view/view.module'; import { RecordModifyModule } from '../record-modify/record-modify.module'; import { RecordModule } from '../record.module'; +import { RecordOpenApiV2Service } from './record-open-api-v2.service'; import { RecordOpenApiController } from './record-open-api.controller'; import { RecordOpenApiService } from './record-open-api.service'; @@ -20,15 +26,20 @@ import { RecordOpenApiService } from './record-open-api.service'; FieldCalculateModule, FieldModule, CalculationModule, + AggregationModule, AttachmentsStorageModule, AttachmentsModule, CollaboratorModule, ViewModule, ViewOpenApiModule, + TableModule, TableDomainQueryModule, + V2Module, + CanaryModule, + forwardRef(() => SelectionModule), ], controllers: [RecordOpenApiController], - providers: [RecordOpenApiService], - exports: [RecordOpenApiService], + providers: [RecordOpenApiService, RecordOpenApiV2Service], + exports: [RecordOpenApiService, RecordOpenApiV2Service], }) export class RecordOpenApiModule {} 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 186303a68f..28d1deab58 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 @@ -56,7 +56,7 @@ import type { IDatetimeFormatting, } from '@teable/core'; import type { ITeableToDbFunctionConverter } from '@teable/core/src/formula/function-convertor.interface'; -import type { RootContext, UnaryOpContext } from '@teable/core/src/formula/parser/Formula'; +import type { RootContext, UnaryOpContext } from '@teable/formula'; import type { Knex } from 'knex'; import { match } from 'ts-pattern'; import type { IFieldSelectName } from './field-select.type'; diff --git a/apps/nestjs-backend/src/features/record/record.service.ts b/apps/nestjs-backend/src/features/record/record.service.ts index 55a0e33c7d..4cb959904b 100644 --- a/apps/nestjs-backend/src/features/record/record.service.ts +++ b/apps/nestjs-backend/src/features/record/record.service.ts @@ -1426,7 +1426,18 @@ export class RecordService { snapshots.map((s) => { return Object.entries(s).reduce( (acc, [key, value]) => { - acc[key] = Array.isArray(value) ? JSON.stringify(value) : value; + if (Array.isArray(value)) { + acc[key] = JSON.stringify(value); + return acc; + } + if (value && typeof value === 'object') { + const isDate = (value as Date) instanceof Date; + if (!isDate) { + acc[key] = JSON.stringify(value); + return acc; + } + } + acc[key] = value; return acc; }, {} as Record diff --git a/apps/nestjs-backend/src/features/selection/selection.controller.ts b/apps/nestjs-backend/src/features/selection/selection.controller.ts index db2e423ab4..a925d12de7 100644 --- a/apps/nestjs-backend/src/features/selection/selection.controller.ts +++ b/apps/nestjs-backend/src/features/selection/selection.controller.ts @@ -1,5 +1,16 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { Body, Controller, Delete, Get, Param, Patch, Query, Headers } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Query, + Headers, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; import type { ICopyVo, IRangesToIdVo, @@ -18,14 +29,26 @@ import { temporaryPasteRoSchema, ITemporaryPasteRo, } from '@teable/openapi'; +import { ClsService } from 'nestjs-cls'; +import type { IClsStore } from '../../types/cls'; import { ZodValidationPipe } from '../../zod.validation.pipe'; import { Permissions } from '../auth/decorators/permissions.decorator'; +import { UseV2Feature } from '../canary/decorators/use-v2-feature.decorator'; +import { V2FeatureGuard } from '../canary/guards/v2-feature.guard'; +import { V2IndicatorInterceptor } from '../canary/interceptors/v2-indicator.interceptor'; +import { RecordOpenApiV2Service } from '../record/open-api/record-open-api-v2.service'; import { TqlPipe } from '../record/open-api/tql.pipe'; import { SelectionService } from './selection.service'; +@UseGuards(V2FeatureGuard) +@UseInterceptors(V2IndicatorInterceptor) @Controller('api/table/:tableId/selection') export class SelectionController { - constructor(private selectionService: SelectionService) {} + constructor( + private selectionService: SelectionService, + private readonly recordOpenApiV2Service: RecordOpenApiV2Service, + private readonly cls: ClsService + ) {} @Permissions('record|read') @Get('/range-to-id') @@ -45,6 +68,7 @@ export class SelectionController { return this.selectionService.copy(tableId, query); } + @UseV2Feature('paste') @Permissions('record|update') @Patch('/paste') async paste( @@ -52,6 +76,11 @@ export class SelectionController { @Body(new ZodValidationPipe(pasteRoSchema), TqlPipe) pasteRo: IPasteRo, @Headers('x-window-id') windowId?: string ): Promise { + // Use V2 logic when canary config enables it for this space + feature + if (this.cls.get('useV2')) { + return this.recordOpenApiV2Service.paste(tableId, pasteRo, { windowId }); + } + const ranges = await this.selectionService.paste(tableId, pasteRo, { windowId, }); @@ -68,6 +97,7 @@ export class SelectionController { return await this.selectionService.temporaryPaste(tableId, temporaryPasteRo); } + @UseV2Feature('clear') @Permissions('record|update') @Patch('/clear') async clear( @@ -75,12 +105,18 @@ export class SelectionController { @Body(new ZodValidationPipe(rangesRoSchema), TqlPipe) rangesRo: IRangesRo, @Headers('x-window-id') windowId?: string ) { + // Use V2 logic when canary config enables it for this space + feature + if (this.cls.get('useV2')) { + return this.recordOpenApiV2Service.clear(tableId, rangesRo); + } + await this.selectionService.clear(tableId, rangesRo, { windowId, }); return null; } + @UseV2Feature('deleteRecord') @Permissions('record|delete') @Delete('/delete') async delete( @@ -88,6 +124,11 @@ export class SelectionController { @Query(new ZodValidationPipe(rangesQuerySchema), TqlPipe) rangesRo: IRangesRo, @Headers('x-window-id') windowId?: string ): Promise { + // Use V2 logic when canary config enables it for this space + feature + if (this.cls.get('useV2')) { + return this.recordOpenApiV2Service.deleteByRange(tableId, rangesRo); + } + return this.selectionService.delete(tableId, rangesRo, { windowId, }); diff --git a/apps/nestjs-backend/src/features/selection/selection.module.ts b/apps/nestjs-backend/src/features/selection/selection.module.ts index fcdc2729e6..2365feb129 100644 --- a/apps/nestjs-backend/src/features/selection/selection.module.ts +++ b/apps/nestjs-backend/src/features/selection/selection.module.ts @@ -1,5 +1,6 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { AggregationModule } from '../aggregation/aggregation.module'; +import { CanaryModule } from '../canary/canary.module'; import { FieldCalculateModule } from '../field/field-calculate/field-calculate.module'; import { FieldModule } from '../field/field.module'; import { RecordOpenApiModule } from '../record/open-api/record-open-api.module'; @@ -12,8 +13,9 @@ import { SelectionService } from './selection.service'; RecordModule, FieldModule, AggregationModule, - RecordOpenApiModule, + forwardRef(() => RecordOpenApiModule), FieldCalculateModule, + CanaryModule, ], controllers: [SelectionController], providers: [SelectionService], diff --git a/apps/nestjs-backend/src/features/selection/selection.service.ts b/apps/nestjs-backend/src/features/selection/selection.service.ts index f4098bc488..1ac6010031 100644 --- a/apps/nestjs-backend/src/features/selection/selection.service.ts +++ b/apps/nestjs-backend/src/features/selection/selection.service.ts @@ -902,10 +902,7 @@ export class SelectionService { ).records; } - updateRange[1] = [ - col + updateFields.length - 1 + newFields.length, - row + updateFields.length - 1 + (newRecords?.length ?? 0), - ]; + updateRange[1] = [col + updateFields.length - 1, row + tableRowCount - 1]; return { updateRecords: { cellContexts, diff --git a/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.ts b/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.ts index cfba286d6f..cd9ec4b69d 100644 --- a/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.ts +++ b/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.ts @@ -37,6 +37,7 @@ import { AttachmentsStorageService } from '../../attachments/attachments-storage import StorageAdapter from '../../attachments/plugins/adapter'; import { InjectStorageAdapter } from '../../attachments/plugins/storage'; import { getPublicFullStorageUrl } from '../../attachments/plugins/utils'; +import { EMAIL_LOGO_TOKEN } from '../../builtin-assets-init/builtin-assets-init.service'; import { verifyTransport } from '../../mail-sender/mail-helpers'; import { SettingService } from '../setting.service'; @@ -73,9 +74,10 @@ export class SettingOpenApiService { } async getServerBrand(): Promise<{ brandName: string; brandLogo: string }> { + const logoPath = join(StorageAdapter.getDir(UploadType.Logo), EMAIL_LOGO_TOKEN); return { brandName: 'Teable', - brandLogo: `${this.baseConfig.publicOrigin}/images/favicon/apple-touch-icon.png`, + brandLogo: getPublicFullStorageUrl(logoPath), }; } 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 ac9e57b55d..feff865a94 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 @@ -203,9 +203,20 @@ export class TableOpenApiService { // create teable should not set computed field isPending, because noting need to calculate when create preparedFields.forEach((field) => delete field.isPending); - const fieldVos = await this.createFields(tableId, preparedFields); + await this.createFields(tableId, preparedFields); const viewVos = await this.createView(tableId, tableRo.views); + const allFieldVos = await this.fieldOpenApiService.getFields(tableId, { + filterHidden: false, + }); + + // Maintain original field order from input to ensure consistent API response + const fieldIdOrder = new Map(preparedFields.map((f, i) => [f.id, i])); + const fieldVos = allFieldVos.sort((a, b) => { + const orderA = fieldIdOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER; + const orderB = fieldIdOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; + return orderA - orderB; + }); return { ...tableVo, diff --git a/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.ts b/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.ts index 01da8e539b..1b175b3d11 100644 --- a/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.ts +++ b/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.ts @@ -19,7 +19,7 @@ export class TableTrashListener { @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig ) {} - @OnEvent(Events.OPERATION_RECORDS_DELETE, { async: true }) + @OnEvent(Events.OPERATION_RECORDS_DELETE) async recordDeleteListener(payload: IDeleteRecordsPayload) { const { operationId, userId, tableId, records } = payload; diff --git a/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-stack.module.ts b/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-stack.module.ts index 0266d57d02..262433de77 100644 --- a/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-stack.module.ts +++ b/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-stack.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { FieldOpenApiModule } from '../../field/open-api/field-open-api.module'; import { RecordOpenApiModule } from '../../record/open-api/record-open-api.module'; import { RecordModule } from '../../record/record.module'; @@ -11,10 +11,10 @@ import { UndoRedoStackService } from './undo-redo-stack.service'; @Module({ imports: [ RecordModule, - RecordOpenApiModule, + forwardRef(() => RecordOpenApiModule), ViewModule, ViewOpenApiModule, - FieldOpenApiModule, + forwardRef(() => FieldOpenApiModule), TableDomainQueryModule, ], providers: [UndoRedoStackService, UndoRedoOperationService], diff --git a/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-stack.service.ts b/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-stack.service.ts index 613e15d7c4..f58c88cf31 100644 --- a/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-stack.service.ts +++ b/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-stack.service.ts @@ -72,6 +72,28 @@ export class UndoRedoStackService { this.eventEmitterService.emit(Events.OPERATION_PUSH, operation); } + async mergeLastOperation( + userId: string, + tableId: string, + windowId: string, + merge: (operation: IUndoRedoOperation) => IUndoRedoOperation | null + ): Promise { + const undoStack = await this.getUndoStack(userId, tableId, windowId); + if (!undoStack.length) { + return false; + } + + const lastIndex = undoStack.length - 1; + const merged = merge(undoStack[lastIndex]); + if (!merged) { + return false; + } + + undoStack[lastIndex] = merged; + await this.setUndoStack(userId, tableId, windowId, undoStack); + return true; + } + async popUndo(tableId: string, windowId: string) { const userId = this.cls.get('user.id'); const undoStack = await this.getUndoStack(userId, tableId, windowId); diff --git a/apps/nestjs-backend/src/features/v2/v2-action-trigger.service.ts b/apps/nestjs-backend/src/features/v2/v2-action-trigger.service.ts new file mode 100644 index 0000000000..7e3a9e9905 --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-action-trigger.service.ts @@ -0,0 +1,166 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { getActionTriggerChannel } from '@teable/core'; +import type { ITableActionKey } from '@teable/core'; +import { + RecordCreated, + RecordUpdated, + RecordsBatchCreated, + RecordsBatchUpdated, + RecordsDeleted, + ProjectionHandler, + ok, +} from '@teable/v2-core'; +import type { IExecutionContext, IEventHandler, DomainError, Result } from '@teable/v2-core'; +import type { DependencyContainer } from '@teable/v2-di'; +import { ShareDbService } from '../../share-db/share-db.service'; + +export interface IActionTriggerData { + actionKey: ITableActionKey; + payload?: Record; +} + +/** + * Helper to emit action triggers via ShareDB presence. + */ +const emitActionTrigger = ( + shareDbService: ShareDbService, + tableId: string, + data: IActionTriggerData[] +) => { + const channel = getActionTriggerChannel(tableId); + const presence = shareDbService.connect().getPresence(channel); + const localPresence = presence.create(tableId); + localPresence.submit(data, (error) => { + if (error) console.error('Action trigger error:', error); + }); +}; + +/** + * V2 projection handler that emits action triggers for record create events. + * This enables V1 frontend features like row count refresh. + */ +@ProjectionHandler(RecordCreated) +class V2RecordCreatedActionTriggerProjection implements IEventHandler { + constructor(private readonly shareDbService: ShareDbService) {} + + async handle( + _context: IExecutionContext, + event: RecordCreated + ): Promise> { + emitActionTrigger(this.shareDbService, event.tableId.toString(), [{ actionKey: 'addRecord' }]); + return ok(undefined); + } +} + +/** + * V2 projection handler that emits action triggers for batch record create events. + */ +@ProjectionHandler(RecordsBatchCreated) +class V2RecordsBatchCreatedActionTriggerProjection implements IEventHandler { + constructor(private readonly shareDbService: ShareDbService) {} + + async handle( + _context: IExecutionContext, + event: RecordsBatchCreated + ): Promise> { + emitActionTrigger(this.shareDbService, event.tableId.toString(), [{ actionKey: 'addRecord' }]); + return ok(undefined); + } +} + +/** + * V2 projection handler that emits action triggers for record update events. + */ +@ProjectionHandler(RecordUpdated) +class V2RecordUpdatedActionTriggerProjection implements IEventHandler { + constructor(private readonly shareDbService: ShareDbService) {} + + async handle( + _context: IExecutionContext, + event: RecordUpdated + ): Promise> { + emitActionTrigger(this.shareDbService, event.tableId.toString(), [{ actionKey: 'setRecord' }]); + return ok(undefined); + } +} + +/** + * V2 projection handler that emits action triggers for batch record update events. + */ +@ProjectionHandler(RecordsBatchUpdated) +class V2RecordsBatchUpdatedActionTriggerProjection implements IEventHandler { + constructor(private readonly shareDbService: ShareDbService) {} + + async handle( + _context: IExecutionContext, + event: RecordsBatchUpdated + ): Promise> { + emitActionTrigger(this.shareDbService, event.tableId.toString(), [{ actionKey: 'setRecord' }]); + return ok(undefined); + } +} + +/** + * V2 projection handler that emits action triggers for record delete events. + */ +@ProjectionHandler(RecordsDeleted) +class V2RecordsDeletedActionTriggerProjection implements IEventHandler { + constructor(private readonly shareDbService: ShareDbService) {} + + async handle( + _context: IExecutionContext, + event: RecordsDeleted + ): Promise> { + emitActionTrigger(this.shareDbService, event.tableId.toString(), [ + { actionKey: 'deleteRecord' }, + ]); + return ok(undefined); + } +} + +/** + * Service that registers V2 action trigger projections with the V2 container. + * These projections emit ShareDB presence events for V1 frontend compatibility. + */ +@Injectable() +export class V2ActionTriggerService { + private readonly logger = new Logger(V2ActionTriggerService.name); + + constructor(private readonly shareDbService: ShareDbService) {} + + /** + * Register action trigger projections with the V2 container. + * Call this after the V2 container is created. + */ + registerProjections(container: DependencyContainer): void { + this.logger.log('Registering V2 action trigger projections'); + + const shareDbService = this.shareDbService; + + // Register projection instances directly since they depend on NestJS ShareDbService + container.registerInstance( + V2RecordCreatedActionTriggerProjection, + new V2RecordCreatedActionTriggerProjection(shareDbService) + ); + + container.registerInstance( + V2RecordsBatchCreatedActionTriggerProjection, + new V2RecordsBatchCreatedActionTriggerProjection(shareDbService) + ); + + container.registerInstance( + V2RecordUpdatedActionTriggerProjection, + new V2RecordUpdatedActionTriggerProjection(shareDbService) + ); + + container.registerInstance( + V2RecordsBatchUpdatedActionTriggerProjection, + new V2RecordsBatchUpdatedActionTriggerProjection(shareDbService) + ); + + container.registerInstance( + V2RecordsDeletedActionTriggerProjection, + new V2RecordsDeletedActionTriggerProjection(shareDbService) + ); + } +} diff --git a/apps/nestjs-backend/src/features/v2/v2-command-bus-tracing.middleware.ts b/apps/nestjs-backend/src/features/v2/v2-command-bus-tracing.middleware.ts new file mode 100644 index 0000000000..2911aeebd6 --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-command-bus-tracing.middleware.ts @@ -0,0 +1,84 @@ +import { TeableSpanAttributes } from '@teable/v2-core'; +import type { + CommandBusNext, + ICommandBusMiddleware, + IExecutionContext, +} from '@teable/v2-core' with { 'resolution-mode': 'import' }; + +const describeError = (error: unknown): string => { + if (error instanceof Error) return error.message || error.name; + if (typeof error === 'string') return error; + try { + return JSON.stringify(error) ?? String(error); + } catch { + return String(error); + } +}; + +/** + * Extract relevant IDs from command for tracing. + * Safely extracts tableId, recordId, fieldId if present. + */ +const extractCommandIds = ( + command: unknown +): { tableId?: string; recordId?: string; fieldId?: string } => { + if (!command || typeof command !== 'object') return {}; + + const cmd = command as Record; + return { + tableId: typeof cmd.tableId === 'string' ? cmd.tableId : undefined, + recordId: typeof cmd.recordId === 'string' ? cmd.recordId : undefined, + fieldId: typeof cmd.fieldId === 'string' ? cmd.fieldId : undefined, + }; +}; + +export class CommandBusTracingMiddleware implements ICommandBusMiddleware { + async handle( + context: IExecutionContext, + command: TCommand, + next: CommandBusNext + ) { + const tracer = context.tracer; + if (!tracer) { + return next(context, command); + } + + const commandName = + (command as { constructor?: { name?: string } }).constructor?.name ?? 'UnknownCommand'; + const ids = extractCommandIds(command); + + // Build span attributes with teable prefix + const attributes: Record = { + [TeableSpanAttributes.VERSION]: 'v2', + [TeableSpanAttributes.COMPONENT]: 'command', + [TeableSpanAttributes.COMMAND]: commandName, + [TeableSpanAttributes.OPERATION]: `command.${commandName}`, + }; + + // Add entity IDs if present + if (ids.tableId) { + attributes[TeableSpanAttributes.TABLE_ID] = ids.tableId; + } + if (ids.recordId) { + attributes[TeableSpanAttributes.RECORD_ID] = ids.recordId; + } + if (ids.fieldId) { + attributes[TeableSpanAttributes.FIELD_ID] = ids.fieldId; + } + + const span = tracer.startSpan(`teable.command.${commandName}`, attributes); + + try { + const result = await next(context, command); + if (result.isErr()) { + span.recordError(result.error.message ?? 'Unknown error'); + } + return result; + } catch (error) { + span.recordError(describeError(error)); + throw error; + } finally { + span.end(); + } + } +} diff --git a/apps/nestjs-backend/src/features/v2/v2-container.service.ts b/apps/nestjs-backend/src/features/v2/v2-container.service.ts new file mode 100644 index 0000000000..15296c1346 --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-container.service.ts @@ -0,0 +1,87 @@ +import type { OnModuleDestroy } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import { + ShareDbPubSubPublisher, + registerV2ShareDbRealtime, +} from '@teable/v2-adapter-realtime-sharedb'; +import { createV2NodePgContainer } from '@teable/v2-container-node'; +import type { DependencyContainer } from '@teable/v2-di' with { 'resolution-mode': 'import' }; +import { registerV2ImportServices } from '@teable/v2-import'; +import { PinoLogger } from 'nestjs-pino'; +import { ShareDbService } from '../../share-db/share-db.service'; +import { V2ActionTriggerService } from './v2-action-trigger.service'; +import { CommandBusTracingMiddleware } from './v2-command-bus-tracing.middleware'; +import { PinoLoggerAdapter } from './v2-logger.adapter'; +import type { IV2ProjectionRegistrar } from './v2-projection-registrar'; +import { QueryBusTracingMiddleware } from './v2-query-bus-tracing.middleware'; +import { OpenTelemetryTracer } from './v2-tracer.adapter'; +import { V2UndoRedoService } from './v2-undo-redo.service'; + +@Injectable() +export class V2ContainerService implements OnModuleDestroy { + private containerPromise?: Promise; + private readonly dynamicRegistrars: IV2ProjectionRegistrar[] = []; + + constructor( + private readonly configService: ConfigService, + private readonly pinoLogger: PinoLogger, + private readonly shareDbService: ShareDbService, + private readonly actionTriggerService: V2ActionTriggerService, + private readonly undoRedoService: V2UndoRedoService + ) {} + + /** + * Add a projection registrar dynamically. + * Must be called during module initialization (onModuleInit), before getContainer() is called. + */ + addProjectionRegistrar(registrar: IV2ProjectionRegistrar): void { + this.dynamicRegistrars.push(registrar); + } + + async getContainer(): Promise { + if (!this.containerPromise) { + const connectionString = this.configService.getOrThrow('PRISMA_DATABASE_URL'); + const logger = new PinoLoggerAdapter(this.pinoLogger); + const tracer = new OpenTelemetryTracer(); + const commandBusMiddlewares = [new CommandBusTracingMiddleware()]; + const queryBusMiddlewares = [new QueryBusTracingMiddleware()]; + const computedUpdateMode = process.env.V2_COMPUTED_UPDATE_MODE; + this.containerPromise = createV2NodePgContainer({ + connectionString, + logger, + tracer, + commandBusMiddlewares, + queryBusMiddlewares, + computedUpdate: computedUpdateMode === 'sync' ? { mode: 'sync' } : undefined, + maxFreeRowLimit: this.configService.get('MAX_FREE_ROW_LIMIT'), + }).then((container) => { + registerV2ShareDbRealtime(container, { + publisher: new ShareDbPubSubPublisher(this.shareDbService.pubsub), + }); + // Register V2 import services (csv, excel adapters) + registerV2ImportServices(container); + // Register V2 action trigger projections for V1 frontend compatibility + this.actionTriggerService.registerProjections(container); + // Register V2 undo/redo projections for V1 undo/redo stack compatibility + this.undoRedoService.registerProjections(container); + // Register dynamically added projections (audit-log, automation, task, etc.) + for (const registrar of this.dynamicRegistrars) { + registrar.registerProjections(container); + } + return container; + }); + } + + return this.containerPromise; + } + + async onModuleDestroy(): Promise { + if (!this.containerPromise) return; + + const container = await this.containerPromise; + const db = container.resolve<{ destroy(): Promise }>(v2PostgresDbTokens.db); + await db.destroy(); + } +} diff --git a/apps/nestjs-backend/src/features/v2/v2-execution-context.factory.ts b/apps/nestjs-backend/src/features/v2/v2-execution-context.factory.ts new file mode 100644 index 0000000000..600df8fd77 --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-execution-context.factory.ts @@ -0,0 +1,60 @@ +import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { ActorId, v2CoreTokens } from '@teable/v2-core'; +import type { IExecutionContext, ITracer } from '@teable/v2-core'; +import { ClsService } from 'nestjs-cls'; +import type { IClsStore } from '../../types/cls'; +import { V2ContainerService } from './v2-container.service'; + +/** + * Factory for creating V2 execution contexts with proper tracer and requestId injection. + * Centralizes the context creation logic to ensure consistent tracing across all V2 operations. + */ +@Injectable() +export class V2ExecutionContextFactory { + constructor( + private readonly v2ContainerService: V2ContainerService, + private readonly cls: ClsService + ) {} + + /** + * Creates a complete execution context with actorId, tracer, and requestId. + * @throws HttpException if user.id is not available or ActorId creation fails + */ + async createContext(): Promise { + const container = await this.v2ContainerService.getContainer(); + const tracer = container.resolve(v2CoreTokens.tracer); + + const userId = this.cls.get('user.id'); + if (!userId) { + throw new HttpException('User not authenticated', HttpStatus.UNAUTHORIZED); + } + + const userName = this.cls.get('user.name'); + const userEmail = this.cls.get('user.email'); + + const actorIdResult = ActorId.create(userId); + if (actorIdResult.isErr()) { + throw new HttpException(actorIdResult.error.message, HttpStatus.INTERNAL_SERVER_ERROR); + } + + // Use CLS ID as requestId for ShareDB src matching (consistent with V1 batch.service) + // This ensures the client that initiated the request can identify its own ops + const requestId = this.cls.getId(); + + // Get windowId from CLS for undo/redo tracking + const windowId = this.cls.get('windowId'); + + const context: IExecutionContext = { + actorId: actorIdResult.value, + tracer, + requestId, + windowId, + }; + + return { + ...context, + actorName: userName, + actorEmail: userEmail, + } as IExecutionContext; + } +} diff --git a/apps/nestjs-backend/src/features/v2/v2-logger.adapter.ts b/apps/nestjs-backend/src/features/v2/v2-logger.adapter.ts new file mode 100644 index 0000000000..95f60a4b2e --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-logger.adapter.ts @@ -0,0 +1,47 @@ +import { createLogScopeContext, type ILogger, type LogContext } from '@teable/v2-core'; +import type { PinoLogger } from 'nestjs-pino'; + +export class PinoLoggerAdapter implements ILogger { + constructor(private readonly logger: PinoLogger) {} + + debug(message: string, context?: LogContext): void { + if (context) { + this.logger.debug(context, message); + return; + } + this.logger.debug(message); + } + + info(message: string, context?: LogContext): void { + if (context) { + this.logger.info(context, message); + return; + } + this.logger.info(message); + } + + warn(message: string, context?: LogContext): void { + if (context) { + this.logger.warn(context, message); + return; + } + this.logger.warn(message); + } + + error(message: string, context?: LogContext): void { + if (context) { + this.logger.error(context, message); + return; + } + this.logger.error(message); + } + + child(context: LogContext): ILogger { + this.logger.logger.child(context); + return this; + } + + scope(scope: string, context?: LogContext): ILogger { + return this.child(createLogScopeContext(scope, context ?? {})); + } +} diff --git a/apps/nestjs-backend/src/features/v2/v2-openapi.controller.ts b/apps/nestjs-backend/src/features/v2/v2-openapi.controller.ts new file mode 100644 index 0000000000..a8ae2f9d09 --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-openapi.controller.ts @@ -0,0 +1,84 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { randomBytes } from 'crypto'; +import { Controller, Get, Header, Req, Res } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { generateV2OpenApiDocument } from '@teable/v2-contract-http-openapi'; +import { Request, Response } from 'express'; +import type { IBaseConfig } from '../../configs/base.config'; +import { Public } from '../auth/decorators/public.decorator'; + +const V2_BASE_PATH = 'api/v2'; +const OPENAPI_SPEC_PATH = `/${V2_BASE_PATH}/openapi.json`; +const SCALAR_CDN_ORIGIN = 'https://cdn.jsdelivr.net'; + +const buildServerUrl = (baseConfig: IBaseConfig | undefined, req: Request): string | undefined => { + const publicOrigin = baseConfig?.publicOrigin; + if (publicOrigin) return publicOrigin; + + const host = req.get('host'); + if (!host) return undefined; + + return `${req.protocol}://${host}`; +}; + +const buildDocsCsp = (nonce: string): string => + [ + "default-src 'self'", + "base-uri 'self'", + "frame-ancestors 'self'", + "object-src 'none'", + "img-src 'self' data: https:", + "font-src 'self' data: https:", + "style-src 'self' https: 'unsafe-inline'", + "connect-src 'self'", + `script-src 'self' ${SCALAR_CDN_ORIGIN} 'nonce-${nonce}'`, + `script-src-elem 'self' ${SCALAR_CDN_ORIGIN} 'nonce-${nonce}'`, + "script-src-attr 'none'", + ].join('; '); + +const buildScalarHtml = (specUrl: string, nonce: string): string => ` + + + Teable v2 API + + + + +
+ + + + + +`; + +@Public() +@Controller(V2_BASE_PATH) +export class V2OpenApiController { + constructor(private readonly configService: ConfigService) {} + + @Get('openapi.json') + @Header('Content-Type', 'application/json') + async openapi(@Req() req: Request) { + const baseConfig = this.configService.get('base'); + const serverUrl = buildServerUrl(baseConfig, req); + + const serverBaseUrl = serverUrl ? `${serverUrl.replace(/\/$/, '')}/${V2_BASE_PATH}` : undefined; + + return generateV2OpenApiDocument({ + servers: serverBaseUrl ? [{ url: serverBaseUrl }] : undefined, + }); + } + + @Get('docs') + @Header('Content-Type', 'text/html; charset=utf-8') + docs(@Res({ passthrough: true }) res: Response) { + const nonce = randomBytes(16).toString('base64'); + res.setHeader('Content-Security-Policy', buildDocsCsp(nonce)); + return buildScalarHtml(OPENAPI_SPEC_PATH, nonce); + } +} diff --git a/apps/nestjs-backend/src/features/v2/v2-projection-registrar.ts b/apps/nestjs-backend/src/features/v2/v2-projection-registrar.ts new file mode 100644 index 0000000000..0d804c5862 --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-projection-registrar.ts @@ -0,0 +1,10 @@ +import type { DependencyContainer } from '@teable/v2-di'; + +/** + * Interface for services that register projections with the V2 container. + * Enterprise modules can implement this interface and call + * `V2ContainerService.addProjectionRegistrar(this)` in their `onModuleInit` hook. + */ +export interface IV2ProjectionRegistrar { + registerProjections(container: DependencyContainer): void; +} diff --git a/apps/nestjs-backend/src/features/v2/v2-query-bus-tracing.middleware.ts b/apps/nestjs-backend/src/features/v2/v2-query-bus-tracing.middleware.ts new file mode 100644 index 0000000000..616667fc25 --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-query-bus-tracing.middleware.ts @@ -0,0 +1,43 @@ +import type { QueryBusNext, IQueryBusMiddleware, IExecutionContext } from '@teable/v2-core'; + +const describeError = (error: unknown): string => { + if (error instanceof Error) return error.message || error.name; + if (typeof error === 'string') return error; + try { + return JSON.stringify(error) ?? String(error); + } catch { + return String(error); + } +}; + +export class QueryBusTracingMiddleware implements IQueryBusMiddleware { + async handle( + context: IExecutionContext, + query: TQuery, + next: QueryBusNext + ) { + const tracer = context.tracer; + if (!tracer) { + return next(context, query); + } + + const queryName = + (query as { constructor?: { name?: string } }).constructor?.name ?? 'UnknownQuery'; + const span = tracer.startSpan(`teable.query.${queryName}`, { + query: queryName, + }); + + try { + const result = await next(context, query); + if (result.isErr()) { + span.recordError(result.error.message ?? 'Unknown error'); + } + return result; + } catch (error) { + span.recordError(describeError(error)); + throw error; + } finally { + span.end(); + } + } +} diff --git a/apps/nestjs-backend/src/features/v2/v2-record-history.service.ts b/apps/nestjs-backend/src/features/v2/v2-record-history.service.ts new file mode 100644 index 0000000000..f2be26234c --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-record-history.service.ts @@ -0,0 +1,483 @@ +/* eslint-disable sonarjs/cognitive-complexity */ +/* eslint-disable sonarjs/no-identical-functions */ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { OnModuleInit } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import type { ISelectFieldOptions } from '@teable/core'; +import { FieldType as CoreFieldType, generateRecordHistoryId } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import { + FieldId, + FieldValueTypeVisitor, + ProjectionHandler, + RecordUpdated, + RecordsBatchUpdated, + TableQueryService, + ok, + v2CoreTokens, +} from '@teable/v2-core'; +import type { + DomainError, + Field, + IEventHandler, + IExecutionContext, + IFieldVisitor, + MultipleSelectField, + Result, + SingleSelectField, +} from '@teable/v2-core'; +import type { DependencyContainer } from '@teable/v2-di'; +import { Knex } from 'knex'; +import { isEqual, isString } from 'lodash'; +import { InjectModel } from 'nest-knexjs'; +import { ClsService } from 'nestjs-cls'; +import { BaseConfig, IBaseConfig } from '../../configs/base.config'; +import { EventEmitterService } from '../../event-emitter/event-emitter.service'; +import { Events } from '../../event-emitter/events'; +import type { IClsStore } from '../../types/cls'; +import { V2ContainerService } from './v2-container.service'; +import type { IV2ProjectionRegistrar } from './v2-projection-registrar'; + +const SELECT_FIELD_TYPE_SET = new Set([CoreFieldType.SingleSelect, CoreFieldType.MultipleSelect]); + +interface IRecordHistoryEntry { + id: string; + table_id: string; + record_id: string; + field_id: string; + before: string; + after: string; + created_by: string; +} + +interface IFieldHistoryMeta { + type: string; + name: string; + options: Record | null | undefined; + cellValueType: string; + isComputed: boolean; +} + +/** + * Visitor to extract field options for record history. + * Returns options in a format compatible with V1 record history. + */ +class FieldOptionsVisitor implements IFieldVisitor | null> { + visitSingleLineTextField(): Result | null, DomainError> { + return ok(null); + } + visitLongTextField(): Result | null, DomainError> { + return ok(null); + } + visitNumberField(): Result | null, DomainError> { + return ok(null); + } + visitRatingField(): Result | null, DomainError> { + return ok(null); + } + visitFormulaField(): Result | null, DomainError> { + return ok(null); + } + visitRollupField(): Result | null, DomainError> { + return ok(null); + } + visitSingleSelectField( + field: SingleSelectField + ): Result | null, DomainError> { + const choices = field.selectOptions().map((opt) => ({ + id: opt.id().toString(), + name: opt.name().toString(), + color: opt.color().toString(), + })); + return ok({ choices }); + } + visitMultipleSelectField( + field: MultipleSelectField + ): Result | null, DomainError> { + const choices = field.selectOptions().map((opt) => ({ + id: opt.id().toString(), + name: opt.name().toString(), + color: opt.color().toString(), + })); + return ok({ choices }); + } + visitCheckboxField(): Result | null, DomainError> { + return ok(null); + } + visitAttachmentField(): Result | null, DomainError> { + return ok(null); + } + visitDateField(): Result | null, DomainError> { + return ok(null); + } + visitCreatedTimeField(): Result | null, DomainError> { + return ok(null); + } + visitLastModifiedTimeField(): Result | null, DomainError> { + return ok(null); + } + visitUserField(): Result | null, DomainError> { + return ok(null); + } + visitCreatedByField(): Result | null, DomainError> { + return ok(null); + } + visitLastModifiedByField(): Result | null, DomainError> { + return ok(null); + } + visitAutoNumberField(): Result | null, DomainError> { + return ok(null); + } + visitButtonField(): Result | null, DomainError> { + return ok(null); + } + visitLinkField(): Result | null, DomainError> { + return ok(null); + } + visitLookupField(): Result | null, DomainError> { + return ok(null); + } + visitConditionalRollupField(): Result | null, DomainError> { + return ok(null); + } + visitConditionalLookupField(): Result | null, DomainError> { + return ok(null); + } +} + +/** + * Extracts field metadata from V2 Field domain object. + */ +const extractFieldMeta = (field: Field): IFieldHistoryMeta => { + const type = field.type().toString(); + const name = field.name().toString(); + const isComputed = field.computed().toBoolean(); + + // Get cellValueType via visitor + const valueTypeResult = field.accept(new FieldValueTypeVisitor()); + const cellValueType = valueTypeResult.isOk() + ? valueTypeResult.value.cellValueType.toString() + : 'string'; + + // Get options via visitor + const optionsResult = field.accept(new FieldOptionsVisitor()); + const options = optionsResult.isOk() ? optionsResult.value : null; + + return { type, name, options, cellValueType, isComputed }; +}; + +/** + * Minimizes field options for select fields to only include choices that match the value. + */ +const minimizeFieldOptions = ( + value: unknown, + meta: IFieldHistoryMeta +): Record | null | undefined => { + const { type, options: _options } = meta; + + if (SELECT_FIELD_TYPE_SET.has(type as CoreFieldType) && _options) { + const options = _options as ISelectFieldOptions; + const { choices } = options; + + if (value == null) { + return { ...options, choices: [] }; + } + + if (isString(value)) { + return { ...options, choices: choices.filter(({ name }) => name === value) }; + } + + if (Array.isArray(value)) { + const valueSet = new Set(value); + return { ...options, choices: choices.filter(({ name }) => valueSet.has(name)) }; + } + } + + return _options; +}; + +/** + * Builds the history entry JSON structure for before/after values. + */ +const buildHistoryValue = ( + value: unknown, + meta: IFieldHistoryMeta +): { meta: object; data: unknown } => ({ + meta: { + type: meta.type, + name: meta.name, + options: minimizeFieldOptions(value, meta), + cellValueType: meta.cellValueType, + }, + data: value, +}); + +/** + * V2 projection handler that writes record history for individual record update events. + */ +@ProjectionHandler(RecordUpdated) +class V2RecordUpdatedHistoryProjection implements IEventHandler { + constructor( + private readonly prisma: PrismaService, + private readonly cls: ClsService, + private readonly baseConfig: IBaseConfig, + private readonly knex: Knex, + private readonly tableQueryService: TableQueryService, + private readonly eventEmitterService: EventEmitterService + ) {} + + async handle( + context: IExecutionContext, + event: RecordUpdated + ): Promise> { + // Check if record history is disabled + if (this.baseConfig.recordHistoryDisabled) { + return ok(undefined); + } + + // Skip computed updates - we only track user-initiated changes + if (event.source === 'computed') { + return ok(undefined); + } + + const tableIdStr = event.tableId.toString(); + const recordId = event.recordId.toString(); + const userId = this.cls.get('user.id'); + + // Get field IDs from changes + if (event.changes.length === 0) { + return ok(undefined); + } + + // Load table from V2 domain + const tableResult = await this.tableQueryService.getById(context, event.tableId); + if (tableResult.isErr()) { + return ok(undefined); // Silently skip if table not found + } + const table = tableResult.value; + + // Build field metadata map + const fieldMetaMap = new Map(); + for (const change of event.changes) { + const fieldIdResult = FieldId.create(change.fieldId); + if (fieldIdResult.isErr()) continue; + + const fieldResult = table.getField((f) => f.id().equals(fieldIdResult.value)); + if (fieldResult.isOk()) { + fieldMetaMap.set(change.fieldId, extractFieldMeta(fieldResult.value)); + } + } + + // Build history entries + const recordHistoryList: IRecordHistoryEntry[] = []; + + for (const change of event.changes) { + const meta = fieldMetaMap.get(change.fieldId); + if (!meta) continue; + + // Skip no-op changes + if (isEqual(change.oldValue, change.newValue)) continue; + + // Skip computed fields + if (meta.isComputed) continue; + + recordHistoryList.push({ + id: generateRecordHistoryId(), + table_id: tableIdStr, + record_id: recordId, + field_id: change.fieldId, + before: JSON.stringify(buildHistoryValue(change.oldValue, meta)), + after: JSON.stringify(buildHistoryValue(change.newValue, meta)), + created_by: userId as string, + }); + } + + // Insert history records + if (recordHistoryList.length > 0) { + const query = this.knex.insert(recordHistoryList).into('record_history').toQuery(); + await this.prisma.$executeRawUnsafe(query); + } + + // Emit RECORD_HISTORY_CREATE event for compatibility + this.eventEmitterService.emit(Events.RECORD_HISTORY_CREATE, { + recordIds: [recordId], + }); + + return ok(undefined); + } +} + +/** + * V2 projection handler that writes record history for batch record update events. + * RecordsBatchUpdated is used by paste operations. + */ +@ProjectionHandler(RecordsBatchUpdated) +class V2RecordsBatchUpdatedHistoryProjection implements IEventHandler { + constructor( + private readonly prisma: PrismaService, + private readonly cls: ClsService, + private readonly baseConfig: IBaseConfig, + private readonly knex: Knex, + private readonly tableQueryService: TableQueryService, + private readonly eventEmitterService: EventEmitterService + ) {} + + async handle( + context: IExecutionContext, + event: RecordsBatchUpdated + ): Promise> { + // Check if record history is disabled + if (this.baseConfig.recordHistoryDisabled) { + return ok(undefined); + } + + // Skip computed updates + if (event.source === 'computed') { + return ok(undefined); + } + + const tableIdStr = event.tableId.toString(); + const userId = this.cls.get('user.id'); + + // Collect all field IDs from all updates + const fieldIdSet = new Set(); + for (const update of event.updates) { + for (const change of update.changes) { + fieldIdSet.add(change.fieldId); + } + } + + if (fieldIdSet.size === 0) { + return ok(undefined); + } + + // Load table from V2 domain + const tableResult = await this.tableQueryService.getById(context, event.tableId); + if (tableResult.isErr()) { + return ok(undefined); // Silently skip if table not found + } + const table = tableResult.value; + + // Build field metadata map + const fieldMetaMap = new Map(); + for (const fieldIdStr of fieldIdSet) { + const fieldIdResult = FieldId.create(fieldIdStr); + if (fieldIdResult.isErr()) continue; + + const fieldResult = table.getField((f) => f.id().equals(fieldIdResult.value)); + if (fieldResult.isOk()) { + fieldMetaMap.set(fieldIdStr, extractFieldMeta(fieldResult.value)); + } + } + + // Build history entries for all updates + const recordHistoryList: IRecordHistoryEntry[] = []; + const recordIds: string[] = []; + + const batchSize = 5000; + + for (const update of event.updates) { + const recordId = update.recordId; + recordIds.push(recordId); + + for (const change of update.changes) { + const meta = fieldMetaMap.get(change.fieldId); + if (!meta) continue; + + // Skip no-op changes + if (isEqual(change.oldValue, change.newValue)) continue; + + // Skip computed fields + if (meta.isComputed) continue; + + recordHistoryList.push({ + id: generateRecordHistoryId(), + table_id: tableIdStr, + record_id: recordId, + field_id: change.fieldId, + before: JSON.stringify(buildHistoryValue(change.oldValue, meta)), + after: JSON.stringify(buildHistoryValue(change.newValue, meta)), + created_by: userId as string, + }); + } + } + + // Insert history records in batches + for (let i = 0; i < recordHistoryList.length; i += batchSize) { + const batch = recordHistoryList.slice(i, i + batchSize); + if (batch.length > 0) { + const query = this.knex.insert(batch).into('record_history').toQuery(); + await this.prisma.$executeRawUnsafe(query); + } + } + + // Emit RECORD_HISTORY_CREATE event for compatibility + if (recordIds.length > 0) { + this.eventEmitterService.emit(Events.RECORD_HISTORY_CREATE, { + recordIds, + }); + } + + return ok(undefined); + } +} + +/** + * Service that registers V2 record history projections with the V2 container. + * These projections write record history to the database when records are updated. + */ +@Injectable() +export class V2RecordHistoryService implements IV2ProjectionRegistrar, OnModuleInit { + private readonly logger = new Logger(V2RecordHistoryService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly cls: ClsService, + @BaseConfig() private readonly baseConfig: IBaseConfig, + @InjectModel('CUSTOM_KNEX') private readonly knex: Knex, + private readonly eventEmitterService: EventEmitterService, + private readonly v2ContainerService: V2ContainerService + ) {} + + /** + * Register this service with V2ContainerService on module initialization. + */ + onModuleInit(): void { + this.v2ContainerService.addProjectionRegistrar(this); + } + + /** + * Register record history projections with the V2 container. + */ + registerProjections(container: DependencyContainer): void { + this.logger.log('Registering V2 record history projections'); + + // Resolve TableQueryService from V2 container + const tableQueryService = container.resolve(v2CoreTokens.tableQueryService); + + // Register projection instances with services + container.registerInstance( + V2RecordUpdatedHistoryProjection, + new V2RecordUpdatedHistoryProjection( + this.prisma, + this.cls, + this.baseConfig, + this.knex, + tableQueryService, + this.eventEmitterService + ) + ); + + container.registerInstance( + V2RecordsBatchUpdatedHistoryProjection, + new V2RecordsBatchUpdatedHistoryProjection( + this.prisma, + this.cls, + this.baseConfig, + this.knex, + tableQueryService, + this.eventEmitterService + ) + ); + } +} diff --git a/apps/nestjs-backend/src/features/v2/v2-tracer.adapter.ts b/apps/nestjs-backend/src/features/v2/v2-tracer.adapter.ts new file mode 100644 index 0000000000..349ad62d66 --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-tracer.adapter.ts @@ -0,0 +1,47 @@ +import type { Span as ApiSpan } from '@opentelemetry/api'; +import { SpanStatusCode, context as otelContext, trace } from '@opentelemetry/api'; +import type { ISpan, ITracer, SpanAttributeValue, SpanAttributes } from '@teable/v2-core'; + +class OpenTelemetrySpan implements ISpan { + constructor(public readonly span: ApiSpan) {} + + setAttribute(key: string, value: SpanAttributeValue): void { + this.span.setAttribute(key, value); + } + + setAttributes(attributes: SpanAttributes): void { + this.span.setAttributes(attributes); + } + + recordError(message: string): void { + this.span.recordException(message); + this.span.setStatus({ code: SpanStatusCode.ERROR, message }); + } + + end(): void { + this.span.end(); + } +} + +export class OpenTelemetryTracer implements ITracer { + constructor(private readonly name = 'v2-core') {} + + startSpan(name: string, attributes?: SpanAttributes): ISpan { + const tracer = trace.getTracer(this.name); + const span = tracer.startSpan(name, { attributes }, otelContext.active()); + return new OpenTelemetrySpan(span); + } + + async withSpan(span: ISpan, callback: () => Promise): Promise { + if (span instanceof OpenTelemetrySpan) { + return otelContext.with(trace.setSpan(otelContext.active(), span.span), callback); + } + return callback(); + } + + getActiveSpan(): ISpan | undefined { + const span = trace.getActiveSpan(); + if (!span) return undefined; + return new OpenTelemetrySpan(span); + } +} diff --git a/apps/nestjs-backend/src/features/v2/v2-undo-redo.service.ts b/apps/nestjs-backend/src/features/v2/v2-undo-redo.service.ts new file mode 100644 index 0000000000..e249af5a92 --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2-undo-redo.service.ts @@ -0,0 +1,392 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { IRecord } from '@teable/core'; +import { + RecordUpdated, + RecordsBatchUpdated, + RecordsBatchCreated, + RecordsDeleted, + RecordReordered, + ProjectionHandler, + ok, +} from '@teable/v2-core'; +import type { IExecutionContext, IEventHandler, DomainError, Result } from '@teable/v2-core'; +import type { DependencyContainer } from '@teable/v2-di'; +import { + OperationName, + type IUpdateRecordsOperation, + type IUpdateRecordsOrderOperation, + type ICreateRecordsOperation, + type IDeleteRecordsOperation, +} from '../../cache/types'; +import type { ICellContext } from '../calculation/utils/changes'; +import { UndoRedoStackService } from '../undo-redo/stack/undo-redo-stack.service'; + +/** + * V2 projection handler that pushes update operations to undo/redo stack + * for single record updates. + */ +@ProjectionHandler(RecordUpdated) +class V2RecordUpdatedUndoRedoProjection implements IEventHandler { + constructor(private readonly undoRedoStackService: UndoRedoStackService) {} + + async handle( + context: IExecutionContext, + event: RecordUpdated + ): Promise> { + const { windowId, actorId } = context; + + // Skip if no windowId - undo/redo requires window context + if (!windowId) { + return ok(undefined); + } + + // Skip computed changes - they are derived, not user-initiated + if (event.source === 'computed') { + return ok(undefined); + } + + const userId = actorId.toString(); + const tableId = event.tableId.toString(); + const recordId = event.recordId.toString(); + + // Convert V2 changes to V1 cell contexts + const cellContexts: ICellContext[] = event.changes.map((change) => ({ + recordId, + fieldId: change.fieldId, + oldValue: change.oldValue, + newValue: change.newValue, + })); + + const fieldIds = event.changes.map((c) => c.fieldId); + + const operation: IUpdateRecordsOperation = { + name: OperationName.UpdateRecords, + params: { + tableId, + recordIds: [recordId], + fieldIds, + }, + result: { + cellContexts, + }, + }; + + await this.undoRedoStackService.push(userId, tableId, windowId, operation); + return ok(undefined); + } +} + +/** + * V2 projection handler that pushes batch update operations to undo/redo stack. + */ +@ProjectionHandler(RecordsBatchUpdated) +class V2RecordsBatchUpdatedUndoRedoProjection implements IEventHandler { + constructor(private readonly undoRedoStackService: UndoRedoStackService) {} + + async handle( + context: IExecutionContext, + event: RecordsBatchUpdated + ): Promise> { + const { windowId, actorId } = context; + + // Skip if no windowId - undo/redo requires window context + if (!windowId) { + return ok(undefined); + } + + // Skip computed changes - they are derived, not user-initiated + if (event.source === 'computed') { + return ok(undefined); + } + + const userId = actorId.toString(); + const tableId = event.tableId.toString(); + + // Collect all record IDs, field IDs, and cell contexts + const recordIds: string[] = []; + const fieldIdSet = new Set(); + const cellContexts: ICellContext[] = []; + + for (const update of event.updates) { + const recordId = update.recordId; + recordIds.push(recordId); + + for (const change of update.changes) { + fieldIdSet.add(change.fieldId); + cellContexts.push({ + recordId, + fieldId: change.fieldId, + oldValue: change.oldValue, + newValue: change.newValue, + }); + } + } + + const fieldIds = Array.from(fieldIdSet); + + const operation: IUpdateRecordsOperation = { + name: OperationName.UpdateRecords, + params: { + tableId, + recordIds, + fieldIds, + }, + result: { + cellContexts, + }, + }; + + await this.undoRedoStackService.push(userId, tableId, windowId, operation); + return ok(undefined); + } +} + +/** + * V2 projection handler that pushes record reorder operations to undo/redo stack. + */ +@ProjectionHandler(RecordReordered) +class V2RecordReorderedUndoRedoProjection implements IEventHandler { + constructor(private readonly undoRedoStackService: UndoRedoStackService) {} + + async handle( + context: IExecutionContext, + event: RecordReordered + ): Promise> { + const { windowId, actorId } = context; + + // Skip if no windowId - undo/redo requires window context + if (!windowId) { + return ok(undefined); + } + + const userId = actorId.toString(); + const tableId = event.tableId.toString(); + const viewId = event.viewId.toString(); + const recordIds = event.recordIds.map((recordId) => recordId.toString()); + + const ordersMap = recordIds.reduce< + NonNullable + >((acc, recordId) => { + const oldOrder = event.previousOrdersByRecordId[recordId]; + const newOrder = event.ordersByRecordId[recordId]; + if (oldOrder === undefined && newOrder === undefined) { + return acc; + } + + if (oldOrder === newOrder) { + return acc; + } + + acc[recordId] = { + oldOrder: oldOrder !== undefined ? { [viewId]: oldOrder } : undefined, + newOrder: newOrder !== undefined ? { [viewId]: newOrder } : undefined, + }; + return acc; + }, {}); + + const merged = await this.undoRedoStackService.mergeLastOperation( + userId, + tableId, + windowId, + (operation) => { + if (operation.name !== OperationName.UpdateRecords) { + return null; + } + if (operation.params.tableId !== tableId) { + return null; + } + + const sameRecordIds = + operation.params.recordIds.length === recordIds.length && + operation.params.recordIds.every((id) => recordIds.includes(id)); + if (!sameRecordIds) { + return null; + } + + return { + ...operation, + result: { + ...operation.result, + ordersMap: { + ...(operation.result.ordersMap ?? {}), + ...ordersMap, + }, + }, + }; + } + ); + if (merged) { + return ok(undefined); + } + + const operation: IUpdateRecordsOrderOperation = { + name: OperationName.UpdateRecordsOrder, + params: { + tableId, + viewId, + recordIds, + }, + result: { + ordersMap, + }, + }; + + await this.undoRedoStackService.push(userId, tableId, windowId, operation); + return ok(undefined); + } +} + +/** + * V2 projection handler for delete operations. + * Pushes delete operations to undo/redo stack for record restoration. + */ +@ProjectionHandler(RecordsDeleted) +class V2RecordsDeletedUndoRedoProjection implements IEventHandler { + constructor(private readonly undoRedoStackService: UndoRedoStackService) {} + + async handle( + context: IExecutionContext, + event: RecordsDeleted + ): Promise> { + const { windowId, actorId } = context; + + // Skip if no windowId - undo/redo requires window context + if (!windowId) { + return ok(undefined); + } + + // Skip if no snapshots - nothing to undo + if (event.recordSnapshots.length === 0) { + return ok(undefined); + } + + const userId = actorId.toString(); + const tableId = event.tableId.toString(); + + // Convert V2 record snapshots to V1 IRecord format with orders + const records: (IRecord & { order?: Record })[] = event.recordSnapshots.map( + (snapshot) => ({ + id: snapshot.id, + fields: snapshot.fields, + autoNumber: snapshot.autoNumber, + createdTime: snapshot.createdTime, + createdBy: snapshot.createdBy, + lastModifiedTime: snapshot.lastModifiedTime, + lastModifiedBy: snapshot.lastModifiedBy, + order: snapshot.orders, + }) + ); + + const operation: IDeleteRecordsOperation = { + name: OperationName.DeleteRecords, + params: { + tableId, + }, + result: { + records, + }, + }; + + await this.undoRedoStackService.push(userId, tableId, windowId, operation); + return ok(undefined); + } +} + +// Note: Create operations are not yet implemented because: +// - RecordCreated/RecordsBatchCreated events don't include the full record data with order +// These would require fetching additional data from the database, which adds complexity. +// For now, V2 create operations won't support undo/redo until we enhance the events +// or add data fetching in the projection handlers. + +/** + * V2 projection handler that pushes batch create operations to undo/redo stack. + * Enables undo (delete created records) and redo (recreate records) for batch creates. + */ +@ProjectionHandler(RecordsBatchCreated) +class V2RecordsBatchCreatedUndoRedoProjection implements IEventHandler { + constructor(private readonly undoRedoStackService: UndoRedoStackService) {} + + async handle( + context: IExecutionContext, + event: RecordsBatchCreated + ): Promise> { + const { windowId, actorId } = context; + + // Skip if no windowId - undo/redo requires window context + if (!windowId) { + return ok(undefined); + } + + const userId = actorId.toString(); + const tableId = event.tableId.toString(); + + // Convert V2 event records to V1 IRecord format with orders + const records: (IRecord & { order?: Record })[] = event.records.map( + (record) => ({ + id: record.recordId, + fields: Object.fromEntries(record.fields.map((f) => [f.fieldId, f.value])), + order: record.orders, + }) + ); + + const operation: ICreateRecordsOperation = { + name: OperationName.CreateRecords, + params: { + tableId, + }, + result: { + records, + }, + }; + + await this.undoRedoStackService.push(userId, tableId, windowId, operation); + return ok(undefined); + } +} + +/** + * Service that registers V2 undo/redo projections with the V2 container. + * These projections push operations to the V1 undo/redo stack for V2 record updates. + */ +@Injectable() +export class V2UndoRedoService { + private readonly logger = new Logger(V2UndoRedoService.name); + + constructor(private readonly undoRedoStackService: UndoRedoStackService) {} + + /** + * Register undo/redo projections with the V2 container. + * Call this after the V2 container is created. + */ + registerProjections(container: DependencyContainer): void { + this.logger.log('Registering V2 undo/redo projections'); + + const undoRedoStackService = this.undoRedoStackService; + + // Register projection instances directly since they depend on NestJS UndoRedoStackService + container.registerInstance( + V2RecordUpdatedUndoRedoProjection, + new V2RecordUpdatedUndoRedoProjection(undoRedoStackService) + ); + + container.registerInstance( + V2RecordsBatchUpdatedUndoRedoProjection, + new V2RecordsBatchUpdatedUndoRedoProjection(undoRedoStackService) + ); + + container.registerInstance( + V2RecordReorderedUndoRedoProjection, + new V2RecordReorderedUndoRedoProjection(undoRedoStackService) + ); + + container.registerInstance( + V2RecordsBatchCreatedUndoRedoProjection, + new V2RecordsBatchCreatedUndoRedoProjection(undoRedoStackService) + ); + + container.registerInstance( + V2RecordsDeletedUndoRedoProjection, + new V2RecordsDeletedUndoRedoProjection(undoRedoStackService) + ); + } +} diff --git a/apps/nestjs-backend/src/features/v2/v2.controller.ts b/apps/nestjs-backend/src/features/v2/v2.controller.ts new file mode 100644 index 0000000000..f5f24841fb --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2.controller.ts @@ -0,0 +1,81 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-nocheck +import { Controller } from '@nestjs/common'; +import { Implement, implement, ORPCError } from '@orpc/nest'; +import { v2Contract } from '@teable/v2-contract-http'; +import { + executeCreateTableEndpoint, + executeDeleteRecordsEndpoint, + executeGetTableByIdEndpoint, +} from '@teable/v2-contract-http-implementation/handlers'; +import { v2CoreTokens } from '@teable/v2-core'; +import type { IQueryBus, ICommandBus } from '@teable/v2-core' with { 'resolution-mode': 'import' }; +import { V2ContainerService } from './v2-container.service'; +import { V2ExecutionContextFactory } from './v2-execution-context.factory'; + +@Controller('api/v2') +export class V2Controller { + constructor( + private readonly v2Container: V2ContainerService, + private readonly v2ContextFactory: V2ExecutionContextFactory + ) {} + + @Implement(v2Contract.tables) + tables() { + return { + create: implement(v2Contract.tables.create).handler(async ({ input }) => { + const container = await this.v2Container.getContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(); + + const result = await executeCreateTableEndpoint(context, input, commandBus); + + if (result.status === 201) return result.body; + + if (result.status === 400) { + throw new ORPCError('BAD_REQUEST', { message: result.body.error }); + } + + throw new ORPCError('INTERNAL_SERVER_ERROR', { message: result.body.error }); + }), + getById: implement(v2Contract.tables.getById).handler(async ({ input }) => { + const container = await this.v2Container.getContainer(); + const queryBus = container.resolve(v2CoreTokens.queryBus); + const context = await this.v2ContextFactory.createContext(); + + const result = await executeGetTableByIdEndpoint(context, input, queryBus); + if (result.status === 200) return result.body; + + if (result.status === 400) { + throw new ORPCError('BAD_REQUEST', { message: result.body.error }); + } + + if (result.status === 404) { + throw new ORPCError('NOT_FOUND', { message: result.body.error }); + } + + // Placeholder for actual implementation + throw new ORPCError('NOT_IMPLEMENTED', { message: 'Not implemented yet' }); + }), + deleteRecords: implement(v2Contract.tables.deleteRecords).handler(async ({ input }) => { + const container = await this.v2Container.getContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(); + + const result = await executeDeleteRecordsEndpoint(context, input, commandBus); + + if (result.status === 200) return result.body; + + if (result.status === 400) { + throw new ORPCError('BAD_REQUEST', { message: result.body.error }); + } + + if (result.status === 404) { + throw new ORPCError('NOT_FOUND', { message: result.body.error }); + } + + throw new ORPCError('INTERNAL_SERVER_ERROR', { message: result.body.error }); + }), + }; + } +} diff --git a/apps/nestjs-backend/src/features/v2/v2.module.ts b/apps/nestjs-backend/src/features/v2/v2.module.ts new file mode 100644 index 0000000000..fe42667f3c --- /dev/null +++ b/apps/nestjs-backend/src/features/v2/v2.module.ts @@ -0,0 +1,100 @@ +import { Module } from '@nestjs/common'; +import { ORPCModule } from '@orpc/nest'; +import type { Response } from 'express'; +import { ShareDbModule } from '../../share-db/share-db.module'; +import { UndoRedoStackService } from '../undo-redo/stack/undo-redo-stack.service'; +import { V2ActionTriggerService } from './v2-action-trigger.service'; +import { V2ContainerService } from './v2-container.service'; +import { V2ExecutionContextFactory } from './v2-execution-context.factory'; +import { V2OpenApiController } from './v2-openapi.controller'; +import { V2RecordHistoryService } from './v2-record-history.service'; +import { V2UndoRedoService } from './v2-undo-redo.service'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const formatIssuePath = (path: unknown): string => { + if (typeof path === 'string') return path; + if (!Array.isArray(path) || path.length === 0) return ''; + + let formatted = ''; + for (const segment of path) { + if (typeof segment === 'number') { + formatted += `[${segment}]`; + continue; + } + const text = String(segment); + formatted = formatted ? `${formatted}.${text}` : text; + } + + return formatted; +}; + +const formatIssue = (issue: unknown): string | null => { + if (!isRecord(issue)) return null; + + const message = typeof issue.message === 'string' ? issue.message : ''; + const path = formatIssuePath(issue.path); + + if (message && path) return `${path}: ${message}`; + if (message) return message; + if (path) return path; + return null; +}; + +const formatIssues = (data: unknown): string[] => { + if (!isRecord(data)) return []; + const issues = data.issues; + if (!Array.isArray(issues)) return []; + + return issues.map(formatIssue).filter((issue): issue is string => Boolean(issue)); +}; + +const toErrorMessage = (body: unknown): string => { + if (typeof body === 'string') return body; + if (!isRecord(body)) return 'Unexpected error'; + + const message = typeof body.message === 'string' ? body.message : 'Unexpected error'; + const issues = formatIssues(body.data); + if (issues.length > 0) return `${message}: ${issues.join('; ')}`; + + return message; +}; + +@Module({ + imports: [ + ORPCModule.forRoot({ + sendResponseInterceptors: [ + async (options: any) => { + const { response, standardResponse, next } = options as any; + if (standardResponse.status < 400) return next(); + + const expressResponse = response as Response; + expressResponse.status(standardResponse.status); + for (const [key, value] of Object.entries(standardResponse.headers)) { + if (value != null) { + expressResponse.setHeader( + key, + value as unknown as string | number | readonly string[] + ); + } + } + + return { ok: false as const, error: toErrorMessage(standardResponse.body) }; + }, + ], + }), + ShareDbModule, + ], + controllers: [V2OpenApiController], + providers: [ + V2ContainerService, + V2ExecutionContextFactory, + V2ActionTriggerService, + V2RecordHistoryService, + V2UndoRedoService, + UndoRedoStackService, + ], + exports: [V2ContainerService, V2ExecutionContextFactory], +}) +export class V2Module {} diff --git a/apps/nestjs-backend/src/features/view/open-api/view-open-api-v2.service.ts b/apps/nestjs-backend/src/features/view/open-api/view-open-api-v2.service.ts new file mode 100644 index 0000000000..7b90f9f141 --- /dev/null +++ b/apps/nestjs-backend/src/features/view/open-api/view-open-api-v2.service.ts @@ -0,0 +1,66 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import type { IUpdateRecordOrdersRo } from '@teable/openapi'; +import { executeReorderRecordsEndpoint } from '@teable/v2-contract-http-implementation/handlers'; +import type { ICommandBus } from '@teable/v2-core'; +import { v2CoreTokens } from '@teable/v2-core'; + +import { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exception'; +import { V2ContainerService } from '../../v2/v2-container.service'; +import { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory'; + +const internalServerError = 'Internal server error'; + +@Injectable() +export class ViewOpenApiV2Service { + constructor( + private readonly v2ContainerService: V2ContainerService, + private readonly v2ContextFactory: V2ExecutionContextFactory + ) {} + + private throwV2Error( + error: { + code: string; + message: string; + tags?: ReadonlyArray; + details?: Readonly>; + }, + status: number + ): never { + throw new CustomHttpException(error.message, getDefaultCodeByStatus(status), { + domainCode: error.code, + domainTags: error.tags, + details: error.details, + }); + } + + async updateRecordOrders( + tableId: string, + viewId: string, + updateRecordOrdersRo: IUpdateRecordOrdersRo + ): Promise { + const container = await this.v2ContainerService.getContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(); + + const v2Input = { + tableId, + recordIds: updateRecordOrdersRo.recordIds, + order: { + viewId, + anchorId: updateRecordOrdersRo.anchorId, + position: updateRecordOrdersRo.position, + }, + }; + + const result = await executeReorderRecordsEndpoint(context, v2Input, commandBus); + if (result.status === 200 && result.body.ok) { + return; + } + + if (!result.body.ok) { + this.throwV2Error(result.body.error, result.status); + } + + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/apps/nestjs-backend/src/features/view/open-api/view-open-api.controller.ts b/apps/nestjs-backend/src/features/view/open-api/view-open-api.controller.ts index 3ca862d19c..b91ee166e1 100644 --- a/apps/nestjs-backend/src/features/view/open-api/view-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/view/open-api/view-open-api.controller.ts @@ -10,6 +10,8 @@ import { Put, Query, Headers, + UseGuards, + UseInterceptors, } from '@nestjs/common'; import type { IViewVo } from '@teable/core'; import { @@ -52,13 +54,19 @@ import type { IGetViewInstallPluginVo, IViewInstallPluginVo, } from '@teable/openapi'; +import { ClsService } from 'nestjs-cls'; import { ZodValidationPipe } from '../../..//zod.validation.pipe'; import { EmitControllerEvent } from '../../../event-emitter/decorators/emit-controller-event.decorator'; import { Events } from '../../../event-emitter/events'; +import type { IClsStore } from '../../../types/cls'; import { AllowAnonymous } from '../../auth/decorators/allow-anonymous.decorator'; import { Permissions } from '../../auth/decorators/permissions.decorator'; +import { UseV2Feature } from '../../canary/decorators/use-v2-feature.decorator'; +import { V2FeatureGuard } from '../../canary/guards/v2-feature.guard'; +import { V2IndicatorInterceptor } from '../../canary/interceptors/v2-indicator.interceptor'; import { TableDomainQueryService } from '../../table-domain'; import { ViewService } from '../view.service'; +import { ViewOpenApiV2Service } from './view-open-api-v2.service'; import { ViewOpenApiService } from './view-open-api.service'; @Controller('api/table/:tableId/view') @@ -67,7 +75,9 @@ export class ViewOpenApiController { constructor( private readonly viewService: ViewService, private readonly viewOpenApiService: ViewOpenApiService, - protected readonly tableDomainQueryService: TableDomainQueryService + private readonly viewOpenApiV2Service: ViewOpenApiV2Service, + protected readonly tableDomainQueryService: TableDomainQueryService, + private readonly cls: ClsService ) {} @Permissions('view|read') @@ -272,6 +282,9 @@ export class ViewOpenApiController { @Permissions('view|update') @Put('/:viewId/record-order') + @UseV2Feature('reorderRecords') + @UseGuards(V2FeatureGuard) + @UseInterceptors(V2IndicatorInterceptor) async updateRecordOrders( @Param('tableId') tableId: string, @Param('viewId') viewId: string, @@ -279,6 +292,11 @@ export class ViewOpenApiController { updateRecordOrdersRo: IUpdateRecordOrdersRo, @Headers('x-window-id') windowId?: string ): Promise { + if (this.cls.get('useV2')) { + await this.viewOpenApiV2Service.updateRecordOrders(tableId, viewId, updateRecordOrdersRo); + return; + } + const table = await this.tableDomainQueryService.getTableDomainById(tableId); return await this.viewOpenApiService.updateRecordOrders( table, diff --git a/apps/nestjs-backend/src/features/view/open-api/view-open-api.module.ts b/apps/nestjs-backend/src/features/view/open-api/view-open-api.module.ts index 73cce16091..0eadb254b2 100644 --- a/apps/nestjs-backend/src/features/view/open-api/view-open-api.module.ts +++ b/apps/nestjs-backend/src/features/view/open-api/view-open-api.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; import { ShareDbModule } from '../../../share-db/share-db.module'; +import { CanaryModule } from '../../canary/canary.module'; import { FieldCalculateModule } from '../../field/field-calculate/field-calculate.module'; import { FieldModule } from '../../field/field.module'; import { RecordModule } from '../../record/record.module'; import { TableDomainQueryModule } from '../../table-domain'; +import { V2Module } from '../../v2/v2.module'; import { ViewModule } from '../view.module'; +import { ViewOpenApiV2Service } from './view-open-api-v2.service'; import { ViewOpenApiController } from './view-open-api.controller'; import { ViewOpenApiService } from './view-open-api.service'; @@ -16,9 +19,11 @@ import { ViewOpenApiService } from './view-open-api.service'; FieldModule, FieldCalculateModule, TableDomainQueryModule, + V2Module, + CanaryModule, ], controllers: [ViewOpenApiController], - providers: [ViewOpenApiService], - exports: [ViewOpenApiService], + providers: [ViewOpenApiService, ViewOpenApiV2Service], + exports: [ViewOpenApiService, ViewOpenApiV2Service], }) export class ViewOpenApiModule {} diff --git a/apps/nestjs-backend/src/global/global.module.ts b/apps/nestjs-backend/src/global/global.module.ts index 882bbc9b1d..0bf06a7fb0 100644 --- a/apps/nestjs-backend/src/global/global.module.ts +++ b/apps/nestjs-backend/src/global/global.module.ts @@ -35,7 +35,7 @@ const globalModules = { ClsModule.forRoot({ global: true, middleware: { - mount: true, + mount: false, generateId: true, idGenerator: (req: Request) => { const existingID = req.headers[X_REQUEST_ID] as string; diff --git a/apps/nestjs-backend/src/tracing.ts b/apps/nestjs-backend/src/tracing.ts index 0002ed95a1..03e55c530f 100644 --- a/apps/nestjs-backend/src/tracing.ts +++ b/apps/nestjs-backend/src/tracing.ts @@ -34,6 +34,7 @@ import { ExpressInstrumentation, ExpressLayerType } from '@opentelemetry/instrum import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis'; import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core'; +import { PgInstrumentation } from '@opentelemetry/instrumentation-pg'; import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino'; import { resourceFromAttributes } from '@opentelemetry/resources'; import * as opentelemetry from '@opentelemetry/sdk-node'; @@ -47,6 +48,13 @@ import { import { PrismaInstrumentation } from '@prisma/instrumentation'; import { SentrySpanProcessor } from '@sentry/opentelemetry'; +// Use webpack's special require that bypasses bundling, falling back to standard require +// This is needed because webpack transforms import.meta.url and createRequire in ways +// that can break module resolution for native Node.js modules like pg. +declare const __non_webpack_require__: NodeRequire | undefined; +const nativeRequire: NodeRequire = + typeof __non_webpack_require__ !== 'undefined' ? __non_webpack_require__ : require; + const { BatchLogRecordProcessor } = opentelemetry.logs; const { PeriodicExportingMetricReader } = opentelemetry.metrics; const { AlwaysOnSampler } = opentelemetry.node; @@ -221,6 +229,10 @@ const otelSDK = new opentelemetry.NodeSDK({ }), new NestInstrumentation(), new PrismaInstrumentation(), + new PgInstrumentation({ + enhancedDatabaseReporting: true, // Records SQL; ensure sensitive data is scrubbed. + requireParentSpan: false, // Create spans even without parent, ensures v2 Kysely queries are traced + }), new PinoInstrumentation(), new IORedisInstrumentation({ requireParentSpan: true, @@ -242,6 +254,33 @@ otelLogger.log( export default otelSDK; +// Start SDK immediately when imported (if Sentry is not enabled). +// This ensures instrumentation is applied BEFORE any instrumented modules (like pg) are loaded. +if (!process.env.BACKEND_SENTRY_DSN) { + try { + otelSDK.start(); + // Force load pg after SDK start to ensure it is instrumented. + // OpenTelemetry instruments modules by patching their exports when they're first required. + // If pg is loaded before SDK.start(), the instrumentation won't work. + // + // Use nativeRequire to bypass webpack bundling and ensure we're loading + // the actual pg module from node_modules, not a bundled version. + try { + nativeRequire('pg'); + } catch { + // pg might not be available, that's ok + } + + // Also force load via ESM import to ensure ESM module cache is populated + // This is important because v2 adapter uses `await import('pg')` + void import('pg').catch(() => { + // pg might not be available via ESM, that's ok + }); + } catch (err) { + console.error('OTEL SDK start error:', err); + } +} + let isShuttingDown = false; const shutdownHandler = () => { if (isShuttingDown) return Promise.resolve(); diff --git a/apps/nestjs-backend/src/tracing/route-tracing.interceptor.ts b/apps/nestjs-backend/src/tracing/route-tracing.interceptor.ts index 0ad90c9a17..48681c5c88 100644 --- a/apps/nestjs-backend/src/tracing/route-tracing.interceptor.ts +++ b/apps/nestjs-backend/src/tracing/route-tracing.interceptor.ts @@ -1,12 +1,31 @@ /* eslint-disable @typescript-eslint/naming-convention */ import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common'; -import { Injectable } from '@nestjs/common'; -import { trace } from '@opentelemetry/api'; +import { Inject, Injectable, Optional } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { trace, TraceFlags } from '@opentelemetry/api'; import type { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; +const buildTraceLink = (traceId: string, baseUrl?: string) => { + const normalizedBaseUrl = baseUrl?.replace(/\/+$/, ''); + if (!normalizedBaseUrl) return null; + return `${normalizedBaseUrl}/trace/${traceId}?uiEmbed=v0`; +}; + +const buildTraceparent = (traceId: string, spanId: string, traceFlags: TraceFlags) => { + const sampled = (traceFlags & TraceFlags.SAMPLED) === TraceFlags.SAMPLED; + return `00-${traceId}-${spanId}-${sampled ? '01' : '00'}`; +}; + @Injectable() export class RouteTracingInterceptor implements NestInterceptor { + private readonly traceLinkBaseUrl?: string; + + constructor(@Optional() @Inject(ConfigService) configService?: ConfigService) { + this.traceLinkBaseUrl = + configService?.get('TRACE_LINK_BASE_URL') ?? process.env.TRACE_LINK_BASE_URL; + } + intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); const response = context.switchToHttp().getResponse(); @@ -34,6 +53,17 @@ export class RouteTracingInterceptor implements NestInterceptor { const spanName = `${httpMethod} ${route}`; span.updateName(spanName); + + // Set trace response headers + const spanContext = span.spanContext(); + response.setHeader( + 'traceparent', + buildTraceparent(spanContext.traceId, spanContext.spanId, spanContext.traceFlags) + ); + const traceLink = buildTraceLink(spanContext.traceId, this.traceLinkBaseUrl); + if (traceLink) { + response.setHeader('Link', `<${traceLink}>; rel="trace"`); + } } return next.handle().pipe( diff --git a/apps/nestjs-backend/src/types/cls.ts b/apps/nestjs-backend/src/types/cls.ts index 5960077fc8..0101279f16 100644 --- a/apps/nestjs-backend/src/types/cls.ts +++ b/apps/nestjs-backend/src/types/cls.ts @@ -1,11 +1,21 @@ import type { Action, IFieldVo } from '@teable/core'; import type { Prisma } from '@teable/db-main-prisma'; +import type { V2Feature } from '@teable/openapi'; import type { ClsStore } from 'nestjs-cls'; import type { IWorkflowContext } from '../features/auth/strategies/types'; import type { IPerformanceCacheStore } from '../performance-cache'; import type { IRawOpMap } from '../share-db/interface'; import type { IDataLoaderCache } from './data-loader'; +export type V2Reason = + | 'env_force_v2_all' + | 'config_force_v2_all' + | 'header_override' + | 'space_feature' + | 'disabled' + | 'feature_not_enabled' + | 'no_feature'; + export interface IClsStore extends ClsStore { user: { id: string; @@ -58,4 +68,8 @@ export interface IClsStore extends ClsStore { dataLoaderCache?: IDataLoaderCache; clearCacheKeys?: (keyof IPerformanceCacheStore)[]; canaryHeader?: string; // x-canary header value for canary release override + useV2?: boolean; // Flag to indicate if V2 implementation should be used (set by V2FeatureGuard) + v2Reason?: V2Reason; // Reason why V2 was enabled or disabled + v2Feature?: V2Feature; // The feature name that triggered V2 check + windowId?: string; // Window ID from x-window-id header for undo/redo tracking } diff --git a/apps/nestjs-backend/src/types/i18n.generated.ts b/apps/nestjs-backend/src/types/i18n.generated.ts index cb813f2dc6..d15bffb065 100644 --- a/apps/nestjs-backend/src/types/i18n.generated.ts +++ b/apps/nestjs-backend/src/types/i18n.generated.ts @@ -1508,6 +1508,27 @@ export type I18nTranslations = { }; }; }; + "system": { + "notFound": { + "title": string; + "description": string; + }; + "links": { + "backToHome": string; + }; + "forbidden": { + "title": string; + "description": string; + }; + "paymentRequired": { + "title": string; + "description": string; + }; + "error": { + "title": string; + "description": string; + }; + }; "noPermissionToCreateBase": string; "app": { "title": string; @@ -3387,27 +3408,6 @@ export type I18nTranslations = { "collaborators": string; "more": string; }; - "system": { - "notFound": { - "title": string; - "description": string; - }; - "links": { - "backToHome": string; - }; - "forbidden": { - "title": string; - "description": string; - }; - "paymentRequired": { - "title": string; - "description": string; - }; - "error": { - "title": string; - "description": string; - }; - }; "table": { "toolbar": { "comingSoon": string; @@ -4703,7 +4703,9 @@ export type I18nTranslations = { "error": string; "errorPartial": string; "requireHttps": string; + "advancedOptions": string; "namingFieldLabel": string; + "selectField": string; "groupByRow": string; "groupByRowTip": string; }; diff --git a/apps/nestjs-backend/static/system/email-logo.png b/apps/nestjs-backend/static/system/email-logo.png new file mode 100755 index 0000000000000000000000000000000000000000..87bcdcc7ddf3f276e36f9856ede22ec1b448b452 GIT binary patch literal 3463 zcmb`K`#;nF`^R4!=D5k3 zIUhzMhjfryW*r<>4)IyH@4xW<;d)-T=XHA?Z;$77f8MT47ql%@2rdKw0My>j%8lz= z|C#`V+nSC-^tlcc>1JyIG>yrBa!ciYp+lfU#X|G0C8Yn zK${ZT4h=g3jly7x@Wr{u0UT^CY7oC|en0Gp;D+p{IZr0^a8RiS@frQWbb+D9XFDP0 zLmE@6I#f|XV<<}vgg6x;{z(QDvxMsPRs%)86=+f7jcGHgapfLOB|b&a$IUShUKr9A z%1gMmydiLJ;y>-Gn=pj7jkKP#QA=4NU#3u$c1d${Ge1J5+xz?Y>W}ctVeRvYsH*Jj zY-joG-`D*Msns9_Z~*uY$rOo%L%+?=;&OU6L8{nUigP9Go@u<%HkiA>tCvUFs={^E z02OMG()HxYY!LCwL|j6`0!6#2yr(b&X~OW*n2O{$T~B4Y8;(S|PA zkHHH`1RkBW(W1McNCC&#Xia~XO_e8)S7`;vK(vD~@x+n}ZXFkY%kB=T>KE8FkQ$H+ zk}9p4m2-%6oK#8wB1AK05KpN1-*UD-cn|IDoOoo#)ihcarD|ZS{%R@ei8-6E6>+lH zIR3yi%9XFKI)sn~L$pNlSZS8)%hV;N1W-@%1n{-M8!bR9?|1IML^-TNh@ts5WOoXT zCdwEXJCpy((`m>hn^=4?(2+N?mov}C5Rn5B-jbvXhC``OzUAsp8KH^`!O%`u_3Bmc zSPLq>T!xPc_*k|5IC=mL8Xx{tucdVP@6&uKyWY@n>cPRLGs_BJ1Zky}Fc>0~GIY=b z%`zoCQ+>2PCw+aEtZ*VbLy%3Dvm zabSuvx`ak#GAC{?ZX*^R+z3)Z2vNh-LsYi)5+F&W|NTSF9`5@#J6 zoTP%_daML3vWqa4KR)6OoOI<^7%S&!kXT@ld2xU<>>M{&9__V<#mASKx)xo$)Wklj zNN5aT_Bt9aE-aMXxWN~*oT~k&g2vIEk&qHaG^T3_?V4m(N?-iS7tGELW@SM-W>^?4 zEv?nl9a$MBevdmfK&||Sy&oQ|L?MAW%idUPV`Jmy#qRiagl_0sWG(ymqSsDF@SoM5 zrh3h?QG2csvI9$FJFfcOK&BIzusG(v9cGv!EHh4H=?J=_8E1M%q}XQuZhV z+}tN?bA4LKju_xA#LrMOBHmml^Dx4rAdFjo?K=99rINgSNpEj&bKLI7m)R5hy1Kff z!*_H?t8MdCG{iq60Ax z1mlXXYM{0KI$8=K9J#xj2DCm+KV}FJWNu6P0J?_b6% ztJxF?13*3oA5TOOVW-P|(2UO_P?B8!^4cdgfW0rRC8M2Rh4*m#_mTla$t(%seD9R2 zOL&qL5J$Js6bpgC3oRfP59Gi3zNEX^>0~F;4#>?_@HY>L>mz=V??k!YIM?TJ(NBcP z!7v^sd_yfYeeX=^>xDaD+jtnjcEduh2>W-#h=E9PuRChyBQS^2cxnQ$DV33z0Jiq+ zv&uwH!FabGF=l4yh&XDeuK%boNzOO+I+!5()5IEtu+@pxCKd$xv(we+To(D?*zz0s^!pT3-S?}-AL9N5> zvp;-RPvdmBy8B#!41f4dT^G^niP-hc*?Nu1pwVbOT&zbaakaJ`&~4%+CF}R?&SM|| zyMAgW{DJ&WZGK^9t$O+ZqeWM|-Gd`niad|Oy!?7iqFn_<7!w%ua{!gzGh9xnOps_( zy-ND(S-;)=zUlGAd|C+(iQ{%uO+stuWzZG+PLOB(4z40QPjB< zn{ydvUFzUa&Q=hYZXf-0<`L$?J( zOx<{pkg11{A_q~A9z_vYw^SHYfpL~nA$*J^cZ|gB6D+A8S4unPu1ujympYo^m;IV(|GqKI(CG9cT%jq0kUvo~a<-*f*x`l!ns#s1(NGl=4E3)7* zjfP==|IW{7|K1WJTrN&hx%k zD3^tr-h8?JTpOFXJ?6D@CwM<~A6dN6gfaFGUgmM>{P8F~ja8QRrQ0(36YC@Nn>%bRp``YAMmPBLCunPXwcC^{3hgBCZes_n?9e$VjkKo~dJe7pFg z*s1L;L4<%}SDvt(M&V+wUOmgc?_99z=uCV+mur)vXsJUluv@07XBo;Dj#tI+{tmYb zC}=EGuC-kM>2FbD9a9}na=(tWpVSX6dKTnz#X3e+kVGSKejuJb3aJVYOOz**7ml zGGP8@>OH5C$T(tjup)CvxuLiGlV*RScyR2>*X>KzykX;>6+o@?sUz{fp!b6U#xyNw zayOrtO&pe)SUix$+Kfx?cyUMa6+1?0>d#N?{jl3=0wXworiPCWy>I~&{({e4S$1R= z>;x6{H&2DCYQjF=rTCBfy2;L;O*4(=V;X4=jWo{RSECNVnc=my=Q^0LBQ}h0Zb&|U zFl`fG5z0Y1JIZ!nWs&2;N(Cn2aFe+0C)5OUg18Rg{d#1apX|)tz;*7asmOuZ3r7xzWH(J@2RK3gL%8 zC5LYooGV&3PFklI5fm5(Ex*i;Byk=cPv2|(`FqBS!b*PkXT8g12N@lWdi0KXh_rvLx| literal 0 HcmV?d00001 diff --git a/apps/nestjs-backend/test/attachment.e2e-spec.ts b/apps/nestjs-backend/test/attachment.e2e-spec.ts index 51281c32a1..38a110ad36 100644 --- a/apps/nestjs-backend/test/attachment.e2e-spec.ts +++ b/apps/nestjs-backend/test/attachment.e2e-spec.ts @@ -72,11 +72,16 @@ describe('OpenAPI AttachmentController (e2e)', () => { console.log('record1.data.fields[field.id]', record1.data.fields[field.id]); expect((record1.data.fields[field.id] as Array)[0]!.name).toEqual('😀1 2.txt'); + const existingAttachment = (record1.data.fields[field.id] as IAttachmentCellValue)[0]!; + const presignedUrl = existingAttachment.presignedUrl || ''; + const localAttachmentUrl = presignedUrl.startsWith('http') + ? presignedUrl + : `${appUrl}${presignedUrl}`; const record2 = await uploadAttachment( table.id, table.records[0].id, field.id, - 'https://app.teable.ai/favicon.ico' + localAttachmentUrl ); expect(record2.status).toBe(201); expect((record2.data.fields[field.id] as Array).length).toEqual(2); diff --git a/apps/nestjs-backend/test/auto-number.e2e-spec.ts b/apps/nestjs-backend/test/auto-number.e2e-spec.ts index 9848b86588..d23dd0f7a9 100644 --- a/apps/nestjs-backend/test/auto-number.e2e-spec.ts +++ b/apps/nestjs-backend/test/auto-number.e2e-spec.ts @@ -1,8 +1,11 @@ import type { INestApplication } from '@nestjs/common'; import { FieldKeyType, FieldType } from '@teable/core'; import type { ITableFullVo } from '@teable/openapi'; +import { domainError, err, v2CoreTokens } from '@teable/v2-core'; +import type { ITableRecordRepository } from '@teable/v2-core'; import { vi } from 'vitest'; import { RecordService } from '../src/features/record/record.service'; +import { V2ContainerService } from '../src/features/v2/v2-container.service'; import { createField, createRecords, @@ -17,6 +20,7 @@ import { describe('Auto number continuity (e2e)', () => { let app: INestApplication; const baseId = globalThis.testConfig.baseId; + const isForceV2 = process.env.FORCE_V2_ALL === 'true'; beforeAll(async () => { const appCtx = await initApp(); @@ -39,15 +43,27 @@ describe('Auto number continuity (e2e)', () => { }); it('should not advance autoNumber if the request fails before hitting the database', async () => { - const recordService = app.get(RecordService); const initial = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id }); const initialCount = initial.records.length; const maxAutoNumber = initial.records.reduce((max, r) => Math.max(max, r.autoNumber ?? 0), 0) || 0; - const spy = vi.spyOn(recordService, 'batchCreateRecords').mockImplementationOnce(async () => { - throw new Error('mocked-create-failure'); - }); + const spy = isForceV2 + ? vi + .spyOn( + (await app.get(V2ContainerService).getContainer()).resolve( + v2CoreTokens.tableRecordRepository + ), + 'insertMany' + ) + .mockResolvedValueOnce( + err(domainError.unexpected({ message: 'mocked-create-failure' })) + ) + : vi + .spyOn(app.get(RecordService), 'batchCreateRecords') + .mockImplementationOnce(async () => { + throw new Error('mocked-create-failure'); + }); await createRecords( table.id, diff --git a/apps/nestjs-backend/test/collaboration.e2e-spec.ts b/apps/nestjs-backend/test/collaboration.e2e-spec.ts index 855b852e35..60b8d693ac 100644 --- a/apps/nestjs-backend/test/collaboration.e2e-spec.ts +++ b/apps/nestjs-backend/test/collaboration.e2e-spec.ts @@ -44,6 +44,9 @@ const defaultTransportChain: ISockJSTransport[] = [transportWebsocket, transport const defaultTimeout = 5000; const eventTimeout = 3000; +const isForceV2 = process.env.FORCE_V2_ALL === 'true'; +const describeWhenV1 = isForceV2 ? describe.skip : describe; +const describeSockJS = isSockJSAvailable ? describeWhenV1 : describe.skip; /** * Helper: Wait for ShareDB query to be ready @@ -162,7 +165,7 @@ describe('Collaboration (e2e)', () => { await app.close(); }); - describe('Real-time subscription', () => { + describeWhenV1('Real-time subscription', () => { let connection: Connection; beforeEach(() => { @@ -410,7 +413,7 @@ describe('Collaboration (e2e)', () => { }); }); - describe('SockJS transport compatibility', () => { + describeWhenV1('SockJS transport compatibility', () => { it('should successfully establish connection via SockJS endpoint', async () => { const conn = createConnection(shareDbService, cookie, port); @@ -714,7 +717,9 @@ describe('Collaboration (e2e)', () => { conn2.close(); }); - it('should maintain data consistency after reconnection', async () => { + // V2 uses caching for ShareDB queries, so fresh connections may not immediately see + // records created via API until the cache is invalidated + it.skipIf(isForceV2)('should maintain data consistency after reconnection', async () => { const collection = `${IdPrefix.Record}_${tableId}`; // First connection - get initial state @@ -791,7 +796,7 @@ describe('Collaboration (e2e)', () => { * These tests verify that all SockJS transports work correctly. * Skipped if sockjs-client package is not available. */ - (isSockJSAvailable ? describe : describe.skip)('SockJS transport fallback (real client)', () => { + describeSockJS('SockJS transport fallback (real client)', () => { /** * Helper: Create SockJS socket connection with specific transports * Note: This tests the transport layer only, not ShareDB operations diff --git a/apps/nestjs-backend/test/computed-orchestrator.e2e-spec.ts b/apps/nestjs-backend/test/computed-orchestrator.e2e-spec.ts index 13ae6e502d..eded9948d2 100644 --- a/apps/nestjs-backend/test/computed-orchestrator.e2e-spec.ts +++ b/apps/nestjs-backend/test/computed-orchestrator.e2e-spec.ts @@ -20,6 +20,8 @@ import { } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import { duplicateField, convertField } from '@teable/openapi'; +import { v2RecordRepositoryPostgresTokens } from '@teable/v2-adapter-table-repository-postgres'; +import type { ComputedUpdateWorker } from '@teable/v2-adapter-table-repository-postgres'; import dayjs from 'dayjs'; import timezone from 'dayjs/plugin/timezone'; import utc from 'dayjs/plugin/utc'; @@ -35,6 +37,7 @@ import { RECORD_QUERY_DIALECT_SYMBOL, } from '../src/features/record/query-builder/record-query-dialect.interface'; import { TableDomainQueryService } from '../src/features/table-domain/table-domain-query.service'; +import { V2ContainerService } from '../src/features/v2/v2-container.service'; import { createAwaitWithEventWithResultWithCount } from './utils/event-promise'; import { deleteField, @@ -53,6 +56,8 @@ import { dayjs.extend(utc); dayjs.extend(timezone); +const isForceV2 = process.env.FORCE_V2_ALL === 'true'; + describe('Computed Orchestrator (e2e)', () => { let app: INestApplication; let eventEmitterService: EventEmitterService; @@ -61,6 +66,7 @@ describe('Computed Orchestrator (e2e)', () => { let db: IDbProvider; let tableDomainQueryService: TableDomainQueryService; let recordDialect: IRecordQueryDialectProvider; + let v2ContainerService: V2ContainerService; const baseId = (globalThis as any).testConfig.baseId as string; beforeAll(async () => { @@ -72,16 +78,83 @@ describe('Computed Orchestrator (e2e)', () => { db = app.get(DB_PROVIDER_SYMBOL as any); tableDomainQueryService = app.get(TableDomainQueryService); recordDialect = app.get(RECORD_QUERY_DIALECT_SYMBOL as any); + v2ContainerService = app.get(V2ContainerService); }); afterAll(async () => { await app.close(); }); + /** + * Process v2 computed update outbox tasks. + * This ensures all async computed updates are completed before assertions. + */ + async function processV2Outbox(times = 1): Promise { + if (!isForceV2) return; + + const container = await v2ContainerService.getContainer(); + const worker = container.resolve( + v2RecordRepositoryPostgresTokens.computedUpdateWorker + ); + + for (let i = 0; i < times; i++) { + const maxIterations = 100; + let iterations = 0; + + while (iterations < maxIterations) { + const result = await worker.runOnce({ + workerId: 'test-worker', + limit: 100, + }); + + if (result.isErr()) { + throw new Error(`Outbox processing failed: ${result.error.message}`); + } + + // result.value is the number of processed tasks + if (result.value === 0) { + break; + } + iterations++; + } + } + } + + /** + * V2-compatible wrapper for createAwaitWithEventWithResultWithCount. + * In v2 mode, events are handled differently, so we execute the function + * and process the outbox to ensure async updates complete, returning empty payloads. + * Tests that need to verify event payloads should be skipped in v2 mode. + */ + function createAwaitWithEventV2Compatible( + _eventEmitterService: EventEmitterService, + _event: Events, + _count: number = 1 + ) { + return async function fn(fn: () => Promise) { + if (isForceV2) { + // In v2 mode, execute and process outbox to ensure async updates complete + const result = await fn(); + await processV2Outbox(); + return { result, payloads: [] }; + } + // In v1 mode, use the original event-based waiting + return createAwaitWithEventWithResultWithCount(_eventEmitterService, _event, _count)(fn); + }; + } + async function runAndCaptureRecordUpdates(fn: () => Promise): Promise<{ result: T; events: any[]; }> { + if (isForceV2) { + // 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(); + await processV2Outbox(); + return { result, events: [] }; + } + const events: any[] = []; const handler = (payload: any) => events.push(payload); eventEmitterService.eventEmitter.on(Events.TABLE_RECORD_UPDATE, handler); @@ -177,7 +250,7 @@ describe('Computed Orchestrator (e2e)', () => { await updateRecordByApi(table.id, table.records[0].id, aId, 1); // Expect a single record.update event; assert old/new for formula field - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 1 @@ -185,16 +258,19 @@ describe('Computed Orchestrator (e2e)', () => { await updateRecordByApi(table.id, table.records[0].id, aId, 2); })) as any; - const event = payloads[0] as any; // RecordUpdateEvent - expect(event.payload.tableId).toBe(table.id); - const changes = event.payload.record.fields as Record< - string, - { oldValue: unknown; newValue: unknown } - >; - // Formula F1 should move from 1 -> 2 - const f1Change = assertChange(changes[f1.id]); - expectNoOldValue(f1Change); - expect(f1Change.newValue).toEqual(2); + // Event payload verification only in v1 mode + if (!isForceV2) { + const event = payloads[0] as any; // RecordUpdateEvent + expect(event.payload.tableId).toBe(table.id); + const changes = event.payload.record.fields as Record< + string, + { oldValue: unknown; newValue: unknown } + >; + // Formula F1 should move from 1 -> 2 + const f1Change = assertChange(changes[f1.id]); + expectNoOldValue(f1Change); + expect(f1Change.newValue).toEqual(2); + } // Assert physical column for formula (non-generated) reflects new value const tblName = await getDbTableName(table.id); @@ -393,7 +469,7 @@ IF( await updateRecordByApi(table.id, table.records[0].id, aId, 1); // Expect a single update event, and it should NOT include a change entry for F - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 1 @@ -401,14 +477,17 @@ IF( await updateRecordByApi(table.id, table.records[0].id, aId, -1); })) as any; - const event = payloads[0] as any; - const recs = Array.isArray(event.payload.record) - ? event.payload.record - : [event.payload.record]; - const change = recs[0]?.fields?.[f.id] as FieldChangePayload | undefined; - const formulaChange = assertChange(change); - expectNoOldValue(formulaChange); - expect(formulaChange.newValue).toEqual(1); + // Event payload verification only in v1 mode + if (!isForceV2) { + const event = payloads[0] as any; + const recs = Array.isArray(event.payload.record) + ? event.payload.record + : [event.payload.record]; + const change = recs[0]?.fields?.[f.id] as FieldChangePayload | undefined; + const formulaChange = assertChange(change); + expectNoOldValue(formulaChange); + expect(formulaChange.newValue).toEqual(1); + } // DB: F should remain 1 const tblName = await getDbTableName(table.id); @@ -448,7 +527,7 @@ IF( await updateRecordByApi(table.id, table.records[0].id, aId, 2); // Expect a single update event on this table; verify B,C,D old/new - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 1 @@ -456,25 +535,28 @@ IF( await updateRecordByApi(table.id, table.records[0].id, aId, 3); })) as any; - const event = payloads[0] as any; - expect(event.payload.tableId).toBe(table.id); - const rec = Array.isArray(event.payload.record) - ? event.payload.record[0] - : event.payload.record; - const changes = rec.fields as FieldChangeMap; - - // A: 2 -> 3, so B: 3 -> 4, C: 6 -> 8, D: 4 -> 5 - const bChange = assertChange(changes[b.id]); - expectNoOldValue(bChange); - expect(bChange.newValue).toEqual(4); + // Event payload verification only in v1 mode + if (!isForceV2) { + const event = payloads[0] as any; + expect(event.payload.tableId).toBe(table.id); + const rec = Array.isArray(event.payload.record) + ? event.payload.record[0] + : event.payload.record; + const changes = rec.fields as FieldChangeMap; + + // A: 2 -> 3, so B: 3 -> 4, C: 6 -> 8, D: 4 -> 5 + const bChange = assertChange(changes[b.id]); + expectNoOldValue(bChange); + expect(bChange.newValue).toEqual(4); - const cChange = assertChange(changes[c.id]); - expectNoOldValue(cChange); - expect(cChange.newValue).toEqual(8); + const cChange = assertChange(changes[c.id]); + expectNoOldValue(cChange); + expect(cChange.newValue).toEqual(8); - const dChange = assertChange(changes[d.id]); - expectNoOldValue(dChange); - expect(dChange.newValue).toEqual(5); + const dChange = assertChange(changes[d.id]); + expectNoOldValue(dChange); + expect(dChange.newValue).toEqual(5); + } // DB: B=4, C=8, D=5 const dbName = await getDbTableName(table.id); @@ -873,12 +955,15 @@ IF( await updateRecordByApi(t2.id, t2.records[0].id, link.id, { id: t1.records[1].id }); }); - const evt = events.find((e) => e.payload.tableId === t2.id)!; - const rec = Array.isArray(evt.payload.record) ? evt.payload.record[0] : evt.payload.record; - const changes = rec.fields as FieldChangeMap; - const lkpChange = assertChange(changes[lkp.id]); - expectNoOldValue(lkpChange); - expect(lkpChange.newValue).toEqual(456); + // Event payload verification only in v1 mode + if (!isForceV2) { + const evt = events.find((e) => e.payload.tableId === t2.id)!; + const rec = Array.isArray(evt.payload.record) ? evt.payload.record[0] : evt.payload.record; + const changes = rec.fields as FieldChangeMap; + const lkpChange = assertChange(changes[lkp.id]); + expectNoOldValue(lkpChange); + expect(lkpChange.newValue).toEqual(456); + } const t2Db = await getDbTableName(t2.id); const t2Row = await getRow(t2Db, t2.records[0].id); @@ -935,17 +1020,20 @@ IF( )!; const symmetricFieldId = symmetric.id; - const evtOnT2 = events.find((e) => e.payload?.tableId === t2.id); - expect(evtOnT2).toBeDefined(); - const recT2 = Array.isArray(evtOnT2!.payload.record) - ? evtOnT2!.payload.record.find((r: any) => r.id === t2.records[0].id) - : evtOnT2!.payload.record; - const changeOnT2 = recT2.fields?.[symmetricFieldId!]; - expect(changeOnT2).toBeDefined(); - expect( - changeOnT2.newValue?.id || - (Array.isArray(changeOnT2.newValue) ? changeOnT2.newValue[0]?.id : undefined) - ).toBe(t1.records[0].id); + // Event payload verification only in v1 mode + if (!isForceV2) { + const evtOnT2 = events.find((e) => e.payload?.tableId === t2.id); + expect(evtOnT2).toBeDefined(); + const recT2 = Array.isArray(evtOnT2!.payload.record) + ? evtOnT2!.payload.record.find((r: any) => r.id === t2.records[0].id) + : evtOnT2!.payload.record; + const changeOnT2 = recT2.fields?.[symmetricFieldId!]; + expect(changeOnT2).toBeDefined(); + expect( + changeOnT2.newValue?.id || + (Array.isArray(changeOnT2.newValue) ? changeOnT2.newValue[0]?.id : undefined) + ).toBe(t1.records[0].id); + } // DB: the symmetric physical column on T2[B1] should be populated with {id: A1} const t2Db = await getDbTableName(t2.id); @@ -998,12 +1086,15 @@ IF( await updateRecordByApi(t1.id, t1.records[0].id, link.id, [{ id: t2.records[0].id }]); }); - const evt = events.find((e) => e.payload.tableId === t1.id)!; - const rec = Array.isArray(evt.payload.record) ? evt.payload.record[0] : evt.payload.record; - const changes = rec.fields as FieldChangeMap; - const lkpChange = assertChange(changes[lkp.id]); - expectNoOldValue(lkpChange); - expect(lkpChange.newValue).toEqual([123]); + // Event payload verification only in v1 mode + if (!isForceV2) { + const evt = events.find((e) => e.payload.tableId === t1.id)!; + const rec = Array.isArray(evt.payload.record) ? evt.payload.record[0] : evt.payload.record; + const changes = rec.fields as FieldChangeMap; + const lkpChange = assertChange(changes[lkp.id]); + expectNoOldValue(lkpChange); + expect(lkpChange.newValue).toEqual([123]); + } const t1Db = await getDbTableName(t1.id); const t1Row = await getRow(t1Db, t1.records[0].id); @@ -1052,12 +1143,15 @@ IF( await updateRecordByApi(t1.id, t1.records[0].id, link.id, null); }); - const evt = events.find((e) => e.payload.tableId === t1.id)!; - const rec = Array.isArray(evt.payload.record) ? evt.payload.record[0] : evt.payload.record; - const changes = rec.fields as FieldChangeMap; - const lkpChange = assertChange(changes[lkp.id]); - expectNoOldValue(lkpChange); - expect(lkpChange.newValue).toBeNull(); + // Event payload verification only in v1 mode + if (!isForceV2) { + const evt = events.find((e) => e.payload.tableId === t1.id)!; + const rec = Array.isArray(evt.payload.record) ? evt.payload.record[0] : evt.payload.record; + const changes = rec.fields as FieldChangeMap; + const lkpChange = assertChange(changes[lkp.id]); + expectNoOldValue(lkpChange); + expect(lkpChange.newValue).toBeNull(); + } const t1Db = await getDbTableName(t1.id); const t1Row = await getRow(t1Db, t1.records[0].id); @@ -1103,12 +1197,15 @@ IF( await updateRecordByApi(t2.id, t2.records[0].id, link.id, [{ id: t1.records[1].id }]); }); - const evt = events.find((e) => e.payload.tableId === t2.id)!; - const rec = Array.isArray(evt.payload.record) ? evt.payload.record[0] : evt.payload.record; - const changes = rec.fields as FieldChangeMap; - const lkpChange = assertChange(changes[lkp.id]); - expectNoOldValue(lkpChange); - expect(lkpChange.newValue).toEqual([7]); + // Event payload verification only in v1 mode + if (!isForceV2) { + const evt = events.find((e) => e.payload.tableId === t2.id)!; + const rec = Array.isArray(evt.payload.record) ? evt.payload.record[0] : evt.payload.record; + const changes = rec.fields as FieldChangeMap; + const lkpChange = assertChange(changes[lkp.id]); + expectNoOldValue(lkpChange); + expect(lkpChange.newValue).toEqual([7]); + } const t2Db = await getDbTableName(t2.id); const t2Row = await getRow(t2Db, t2.records[0].id); @@ -1152,7 +1249,7 @@ IF( await updateRecordByApi(t2.id, t2.records[0].id, link2.id, [{ id: t1.records[0].id }]); // Expect two record.update events (T1 base, T2 lookup). Assert T2 lookup old/new - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 2 @@ -1160,15 +1257,18 @@ IF( await updateRecordByApi(t1.id, t1.records[0].id, t1A, 20); })) as any; - // Find T2 event - const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; - const changes = t2Event.payload.record.fields as Record< - string, - { oldValue: unknown; newValue: unknown } - >; - const lkpChange = assertChange(changes[lkp2.id]); - expectNoOldValue(lkpChange); - expect(lkpChange.newValue).toEqual([20]); + // Event payload verification only in v1 mode + if (!isForceV2) { + // Find T2 event + const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; + const changes = t2Event.payload.record.fields as Record< + string, + { oldValue: unknown; newValue: unknown } + >; + const lkpChange = assertChange(changes[lkp2.id]); + expectNoOldValue(lkpChange); + expect(lkpChange.newValue).toEqual([20]); + } // DB: lookup column should be [20] const t2Db = await getDbTableName(t2.id); @@ -1217,7 +1317,7 @@ IF( ]); // Change one A: 3 -> 4; rollup 10 -> 11 - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 2 @@ -1225,15 +1325,18 @@ IF( await updateRecordByApi(t1.id, t1.records[0].id, t1A, 4); })) as any; - // Find T2 event - const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; - const changes = t2Event.payload.record.fields as Record< - string, - { oldValue: unknown; newValue: unknown } - >; - const rollChange = assertChange(changes[roll2.id]); - expectNoOldValue(rollChange); - expect(rollChange.newValue).toEqual(11); + // Event payload verification only in v1 mode + if (!isForceV2) { + // Find T2 event + const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; + const changes = t2Event.payload.record.fields as Record< + string, + { oldValue: unknown; newValue: unknown } + >; + const rollChange = assertChange(changes[roll2.id]); + expectNoOldValue(rollChange); + expect(rollChange.newValue).toEqual(11); + } // DB: rollup column should be 11 const t2Db = await getDbTableName(t2.id); @@ -1304,7 +1407,7 @@ IF( await updateRecordByApi(t3.id, t3.records[0].id, l23.id, [{ id: t2.records[0].id }]); // Change A: 4 -> 5; then F: 12 -> 15; LKP2: [12] -> [15]; LKP3: [12] -> [15] - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 3 @@ -1312,32 +1415,35 @@ IF( await updateRecordByApi(t1.id, t1.records[0].id, aId, 5); })) as any; - // T1 - const t1Event = (payloads as any[]).find((e) => e.payload.tableId === t1.id)!; - const t1Changes = ( - Array.isArray(t1Event.payload.record) ? t1Event.payload.record[0] : t1Event.payload.record - ).fields as FieldChangeMap; - const t1Change = assertChange(t1Changes[f1.id]); - expectNoOldValue(t1Change); - expect(t1Change.newValue).toEqual(15); - - // T2 - 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 - ).fields as FieldChangeMap; - const t2Change = assertChange(t2Changes[lkp2.id]); - expectNoOldValue(t2Change); - expect(t2Change.newValue).toEqual([15]); - - // T3 - const t3Event = (payloads as any[]).find((e) => e.payload.tableId === t3.id)!; - const t3Changes = ( - Array.isArray(t3Event.payload.record) ? t3Event.payload.record[0] : t3Event.payload.record - ).fields as FieldChangeMap; - const t3Change = assertChange(t3Changes[lkp3.id]); - expectNoOldValue(t3Change); - expect(t3Change.newValue).toEqual([15]); + // Event payload verification only in v1 mode + if (!isForceV2) { + // T1 + const t1Event = (payloads as any[]).find((e) => e.payload.tableId === t1.id)!; + const t1Changes = ( + Array.isArray(t1Event.payload.record) ? t1Event.payload.record[0] : t1Event.payload.record + ).fields as FieldChangeMap; + const t1Change = assertChange(t1Changes[f1.id]); + expectNoOldValue(t1Change); + expect(t1Change.newValue).toEqual(15); + + // T2 + 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 + ).fields as FieldChangeMap; + const t2Change = assertChange(t2Changes[lkp2.id]); + expectNoOldValue(t2Change); + expect(t2Change.newValue).toEqual([15]); + + // T3 + const t3Event = (payloads as any[]).find((e) => e.payload.tableId === t3.id)!; + const t3Changes = ( + Array.isArray(t3Event.payload.record) ? t3Event.payload.record[0] : t3Event.payload.record + ).fields as FieldChangeMap; + const t3Change = assertChange(t3Changes[lkp3.id]); + expectNoOldValue(t3Change); + expect(t3Change.newValue).toEqual([15]); + } // DB: T1.F=15, T2.LKP2=[15], T3.LKP3=[15] const t1Db = await getDbTableName(t1.id); @@ -1430,7 +1536,7 @@ IF( await updateRecordByApi(t2.id, t2.records[0].id, linkT3.id, [{ id: t3.records[0].id }]); await updateRecordByApi(t3.id, t3.records[0].id, linkT2.id, [{ id: t2.records[0].id }]); - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 3 @@ -1438,21 +1544,24 @@ IF( await updateRecordByApi(t1.id, t1.records[0].id, aId, 7); })) as any; - 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 - ).fields as FieldChangeMap; - const t2Change = assertChange(t2Changes[lkpA.id]); - expectNoOldValue(t2Change); - expect(t2Change.newValue).toEqual([7]); - - const t3Event = (payloads as any[]).find((e) => e.payload.tableId === t3.id)!; - const t3Changes = ( - Array.isArray(t3Event.payload.record) ? t3Event.payload.record[0] : t3Event.payload.record - ).fields as FieldChangeMap; - const t3Change = assertChange(t3Changes[lkpFromT2.id]); - expectNoOldValue(t3Change); - expect(t3Change.newValue).toEqual([7]); + // Event payload verification only in v1 mode + if (!isForceV2) { + 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 + ).fields as FieldChangeMap; + const t2Change = assertChange(t2Changes[lkpA.id]); + expectNoOldValue(t2Change); + expect(t2Change.newValue).toEqual([7]); + + const t3Event = (payloads as any[]).find((e) => e.payload.tableId === t3.id)!; + const t3Changes = ( + Array.isArray(t3Event.payload.record) ? t3Event.payload.record[0] : t3Event.payload.record + ).fields as FieldChangeMap; + const t3Change = assertChange(t3Changes[lkpFromT2.id]); + expectNoOldValue(t3Change); + expect(t3Change.newValue).toEqual([7]); + } const t2Db = await getDbTableName(t2.id); const t3Db = await getDbTableName(t3.id); @@ -1540,7 +1649,7 @@ IF( } as any); await updateRecordByApi(t4.id, t4.records[0].id, l34.id, [{ id: t3.records[0].id }]); - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 4 @@ -1548,29 +1657,32 @@ IF( await updateRecordByApi(t1.id, t1.records[0].id, aId, 9); })) as any; - 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 - ).fields as FieldChangeMap; - const t2Change = assertChange(t2Changes[l2.id]); - expectNoOldValue(t2Change); - expect(t2Change.newValue).toEqual([9]); - - const t3Event = (payloads as any[]).find((e) => e.payload.tableId === t3.id)!; - const t3Changes = ( - Array.isArray(t3Event.payload.record) ? t3Event.payload.record[0] : t3Event.payload.record - ).fields as FieldChangeMap; - const t3Change = assertChange(t3Changes[l3.id]); - expectNoOldValue(t3Change); - expect(t3Change.newValue).toEqual([9]); - - const t4Event = (payloads as any[]).find((e) => e.payload.tableId === t4.id)!; - const t4Changes = ( - Array.isArray(t4Event.payload.record) ? t4Event.payload.record[0] : t4Event.payload.record - ).fields as FieldChangeMap; - const t4Change = assertChange(t4Changes[l4.id]); - expectNoOldValue(t4Change); - expect(t4Change.newValue).toEqual([9]); + // Event payload verification only in v1 mode + if (!isForceV2) { + 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 + ).fields as FieldChangeMap; + const t2Change = assertChange(t2Changes[l2.id]); + expectNoOldValue(t2Change); + expect(t2Change.newValue).toEqual([9]); + + const t3Event = (payloads as any[]).find((e) => e.payload.tableId === t3.id)!; + const t3Changes = ( + Array.isArray(t3Event.payload.record) ? t3Event.payload.record[0] : t3Event.payload.record + ).fields as FieldChangeMap; + const t3Change = assertChange(t3Changes[l3.id]); + expectNoOldValue(t3Change); + expect(t3Change.newValue).toEqual([9]); + + const t4Event = (payloads as any[]).find((e) => e.payload.tableId === t4.id)!; + const t4Changes = ( + Array.isArray(t4Event.payload.record) ? t4Event.payload.record[0] : t4Event.payload.record + ).fields as FieldChangeMap; + const t4Change = assertChange(t4Changes[l4.id]); + expectNoOldValue(t4Change); + expect(t4Change.newValue).toEqual([9]); + } const t2Db = await getDbTableName(t2.id); const t3Db = await getDbTableName(t3.id); @@ -1641,17 +1753,19 @@ IF( } as IFieldRo); }); - const hostCreateEvent = creationEvents.find((e) => e.payload.tableId === host.id); - expect(hostCreateEvent).toBeDefined(); - const createRecordPayload = Array.isArray(hostCreateEvent!.payload.record) - ? hostCreateEvent!.payload.record[0] - : hostCreateEvent!.payload.record; - const createChanges = createRecordPayload.fields as Record< - string, - { oldValue: unknown; newValue: unknown } - >; - expect(createChanges[conditionalRollupField.id]).toBeDefined(); - expect(createChanges[conditionalRollupField.id].newValue).toEqual(1); + if (!isForceV2) { + const hostCreateEvent = creationEvents.find((e) => e.payload.tableId === host.id); + expect(hostCreateEvent).toBeDefined(); + const createRecordPayload = Array.isArray(hostCreateEvent!.payload.record) + ? hostCreateEvent!.payload.record[0] + : hostCreateEvent!.payload.record; + const createChanges = createRecordPayload.fields as Record< + string, + { oldValue: unknown; newValue: unknown } + >; + expect(createChanges[conditionalRollupField.id]).toBeDefined(); + expect(createChanges[conditionalRollupField.id].newValue).toEqual(1); + } const referenceEdges = await prisma.reference.findMany({ where: { toFieldId: conditionalRollupField.id }, @@ -1681,17 +1795,19 @@ IF( (await getRow(hostDbTable, host.records[0].id))[hostFieldVo.dbFieldName] ); expect(valueAfterStatus).toEqual(2); - const hostFilterEvent = filterEvents.find((e) => e.payload.tableId === host.id); - expect(hostFilterEvent).toBeDefined(); - const filterRecordPayload = Array.isArray(hostFilterEvent!.payload.record) - ? hostFilterEvent!.payload.record[0] - : hostFilterEvent!.payload.record; - const filterChanges = filterRecordPayload.fields as Record< - string, - { oldValue: unknown; newValue: unknown } - >; - expect(filterChanges[conditionalRollupField.id]).toBeDefined(); - expect(filterChanges[conditionalRollupField.id].newValue).toEqual(2); + if (!isForceV2) { + const hostFilterEvent = filterEvents.find((e) => e.payload.tableId === host.id); + expect(hostFilterEvent).toBeDefined(); + const filterRecordPayload = Array.isArray(hostFilterEvent!.payload.record) + ? hostFilterEvent!.payload.record[0] + : hostFilterEvent!.payload.record; + const filterChanges = filterRecordPayload.fields as Record< + string, + { oldValue: unknown; newValue: unknown } + >; + expect(filterChanges[conditionalRollupField.id]).toBeDefined(); + expect(filterChanges[conditionalRollupField.id].newValue).toEqual(2); + } const { events: lookupColumnEvents } = await runAndCaptureRecordUpdates(async () => { await updateRecordByApi(foreign.id, foreign.records[0].id, titleId, null); @@ -1700,17 +1816,19 @@ IF( (await getRow(hostDbTable, host.records[0].id))[hostFieldVo.dbFieldName] ); expect(valueAfterLookupColumnChange).toEqual(1); - const hostLookupEvent = lookupColumnEvents.find((e) => e.payload.tableId === host.id); - expect(hostLookupEvent).toBeDefined(); - const lookupRecordPayload = Array.isArray(hostLookupEvent!.payload.record) - ? hostLookupEvent!.payload.record[0] - : hostLookupEvent!.payload.record; - const lookupChanges = lookupRecordPayload.fields as Record< - string, - { oldValue: unknown; newValue: unknown } - >; - expect(lookupChanges[conditionalRollupField.id]).toBeDefined(); - expect(lookupChanges[conditionalRollupField.id].newValue).toEqual(1); + if (!isForceV2) { + const hostLookupEvent = lookupColumnEvents.find((e) => e.payload.tableId === host.id); + expect(hostLookupEvent).toBeDefined(); + const lookupRecordPayload = Array.isArray(hostLookupEvent!.payload.record) + ? hostLookupEvent!.payload.record[0] + : hostLookupEvent!.payload.record; + const lookupChanges = lookupRecordPayload.fields as Record< + string, + { oldValue: unknown; newValue: unknown } + >; + expect(lookupChanges[conditionalRollupField.id]).toBeDefined(); + expect(lookupChanges[conditionalRollupField.id].newValue).toEqual(1); + } expect( parseMaybe((await getRow(hostDbTable, host.records[0].id))[hostFieldVo.dbFieldName]) @@ -1979,29 +2097,31 @@ IF( const ctx = await setupEqualityConditionalRollup(expression); const { cleanup } = ctx; try { - const createAliceChange = findRecordChangeMap( - ctx.creationEvents, - ctx.host.id, - ctx.aliceRecordId - ); - expect(createAliceChange).toBeDefined(); - expectAggregateValue( - createAliceChange?.[ctx.rollupField.id]?.newValue, - initialAlice, - compareMode - ); - - const createNobodyChange = findRecordChangeMap( - ctx.creationEvents, - ctx.host.id, - ctx.nobodyRecordId - ); - expect(createNobodyChange).toBeDefined(); - expectAggregateValue( - createNobodyChange?.[ctx.rollupField.id]?.newValue, - initialNobody, - compareMode - ); + if (!isForceV2) { + const createAliceChange = findRecordChangeMap( + ctx.creationEvents, + ctx.host.id, + ctx.aliceRecordId + ); + expect(createAliceChange).toBeDefined(); + expectAggregateValue( + createAliceChange?.[ctx.rollupField.id]?.newValue, + initialAlice, + compareMode + ); + + const createNobodyChange = findRecordChangeMap( + ctx.creationEvents, + ctx.host.id, + ctx.nobodyRecordId + ); + expect(createNobodyChange).toBeDefined(); + expectAggregateValue( + createNobodyChange?.[ctx.rollupField.id]?.newValue, + initialNobody, + compareMode + ); + } const initialAliceValue = parseMaybe( (await getRow(ctx.hostDbTable, ctx.aliceRecordId))[ctx.hostFieldVo.dbFieldName] @@ -2017,17 +2137,19 @@ IF( await update(ctx); }); - const updateAliceChange = findRecordChangeMap( - updateEvents, - ctx.host.id, - ctx.aliceRecordId - ); - expect(updateAliceChange).toBeDefined(); - expectAggregateValue( - updateAliceChange?.[ctx.rollupField.id]?.newValue, - updatedAlice, - compareMode - ); + if (!isForceV2) { + const updateAliceChange = findRecordChangeMap( + updateEvents, + ctx.host.id, + ctx.aliceRecordId + ); + expect(updateAliceChange).toBeDefined(); + expectAggregateValue( + updateAliceChange?.[ctx.rollupField.id]?.newValue, + updatedAlice, + compareMode + ); + } const updatedAliceValue = parseMaybe( (await getRow(ctx.hostDbTable, ctx.aliceRecordId))[ctx.hostFieldVo.dbFieldName] @@ -2061,21 +2183,23 @@ IF( }); const { cleanup } = ctx; try { - const createAliceChange = findRecordChangeMap( - ctx.creationEvents, - ctx.host.id, - ctx.aliceRecordId - ); - expect(createAliceChange).toBeDefined(); - expectAggregateValue(createAliceChange?.[ctx.rollupField.id]?.newValue, 20, 'equal'); + if (!isForceV2) { + const createAliceChange = findRecordChangeMap( + ctx.creationEvents, + ctx.host.id, + ctx.aliceRecordId + ); + expect(createAliceChange).toBeDefined(); + expectAggregateValue(createAliceChange?.[ctx.rollupField.id]?.newValue, 20, 'equal'); - const createNobodyChange = findRecordChangeMap( - ctx.creationEvents, - ctx.host.id, - ctx.nobodyRecordId - ); - expect(createNobodyChange).toBeDefined(); - expectAggregateValue(createNobodyChange?.[ctx.rollupField.id]?.newValue, 0, 'equal'); + const createNobodyChange = findRecordChangeMap( + ctx.creationEvents, + ctx.host.id, + ctx.nobodyRecordId + ); + expect(createNobodyChange).toBeDefined(); + expectAggregateValue(createNobodyChange?.[ctx.rollupField.id]?.newValue, 0, 'equal'); + } const initialAliceValue = parseMaybe( (await getRow(ctx.hostDbTable, ctx.aliceRecordId))[ctx.hostFieldVo.dbFieldName] @@ -2100,13 +2224,15 @@ IF( }); }); - const updateAliceChange = findRecordChangeMap( - updateEvents, - ctx.host.id, - ctx.aliceRecordId - ); - expect(updateAliceChange).toBeDefined(); - expectAggregateValue(updateAliceChange?.[ctx.rollupField.id]?.newValue, 35, 'equal'); + if (!isForceV2) { + const updateAliceChange = findRecordChangeMap( + updateEvents, + ctx.host.id, + ctx.aliceRecordId + ); + expect(updateAliceChange).toBeDefined(); + expectAggregateValue(updateAliceChange?.[ctx.rollupField.id]?.newValue, 35, 'equal'); + } const updatedAliceValue = parseMaybe( (await getRow(ctx.hostDbTable, ctx.aliceRecordId))[ctx.hostFieldVo.dbFieldName] @@ -2175,12 +2301,14 @@ IF( } ); - const createAliceChange = findRecordChangeMap(creationEvents, host.id, aliceId); - expect(createAliceChange).toBeDefined(); - expect(createAliceChange?.[rollupField.id]?.newValue).toEqual(30); - const createNobodyChange = findRecordChangeMap(creationEvents, host.id, nobodyId); - expect(createNobodyChange).toBeDefined(); - expect(createNobodyChange?.[rollupField.id]?.newValue).toEqual(0); + if (!isForceV2) { + const createAliceChange = findRecordChangeMap(creationEvents, host.id, aliceId); + expect(createAliceChange).toBeDefined(); + expect(createAliceChange?.[rollupField.id]?.newValue).toEqual(30); + const createNobodyChange = findRecordChangeMap(creationEvents, host.id, nobodyId); + expect(createNobodyChange).toBeDefined(); + expect(createNobodyChange?.[rollupField.id]?.newValue).toEqual(0); + } const hostDbTable = await getDbTableName(host.id); const hostFieldVo = (await getFields(host.id)).find((f) => f.id === rollupField.id)! as any; @@ -2190,11 +2318,13 @@ IF( const { events: updateEvents } = await runAndCaptureRecordUpdates(async () => { await updateRecordByApi(foreign.id, foreign.records[0].id, foreignAmountId, 15); }); - const updateAliceChange = findRecordChangeMap(updateEvents, host.id, aliceId); - expect(updateAliceChange).toBeDefined(); - expect(updateAliceChange?.[rollupField.id]?.newValue).toEqual(35); - const updateNobodyChange = findRecordChangeMap(updateEvents, host.id, nobodyId); - expect(updateNobodyChange?.[rollupField.id]).toBeUndefined(); + if (!isForceV2) { + const updateAliceChange = findRecordChangeMap(updateEvents, host.id, aliceId); + expect(updateAliceChange).toBeDefined(); + expect(updateAliceChange?.[rollupField.id]?.newValue).toEqual(35); + const updateNobodyChange = findRecordChangeMap(updateEvents, host.id, nobodyId); + expect(updateNobodyChange?.[rollupField.id]).toBeUndefined(); + } expect(parseMaybe((await getRow(hostDbTable, aliceId))[hostFieldVo.dbFieldName])).toEqual(35); expect(parseMaybe((await getRow(hostDbTable, nobodyId))[hostFieldVo.dbFieldName])).toEqual(0); @@ -2283,17 +2413,19 @@ IF( } ); - const createAChange = findRecordChangeMap(creationEvents, host.id, hostAId); - expect(createAChange).toBeDefined(); - expect(createAChange?.[rollupField.id]?.newValue).toEqual(15); + if (!isForceV2) { + const createAChange = findRecordChangeMap(creationEvents, host.id, hostAId); + expect(createAChange).toBeDefined(); + expect(createAChange?.[rollupField.id]?.newValue).toEqual(15); - const createBChange = findRecordChangeMap(creationEvents, host.id, hostBId); - expect(createBChange).toBeDefined(); - expect(createBChange?.[rollupField.id]?.newValue).toEqual(25); + const createBChange = findRecordChangeMap(creationEvents, host.id, hostBId); + expect(createBChange).toBeDefined(); + expect(createBChange?.[rollupField.id]?.newValue).toEqual(25); - const createCChange = findRecordChangeMap(creationEvents, host.id, hostCId); - expect(createCChange).toBeDefined(); - expect(createCChange?.[rollupField.id]?.newValue).toEqual(0); + const createCChange = findRecordChangeMap(creationEvents, host.id, hostCId); + expect(createCChange).toBeDefined(); + expect(createCChange?.[rollupField.id]?.newValue).toEqual(0); + } const hostDbTable = await getDbTableName(host.id); const hostFieldVo = (await getFields(host.id)).find((f) => f.id === rollupField.id)! as any; @@ -2430,9 +2562,11 @@ IF( } as IFieldRo); }); - const createChange = findRecordChangeMap(creationEvents, host.id, hostRecordId); - expect(createChange).toBeDefined(); - expect(createChange?.[conditionalRollupField.id]?.newValue).toEqual(1); + if (!isForceV2) { + const createChange = findRecordChangeMap(creationEvents, host.id, hostRecordId); + expect(createChange).toBeDefined(); + expect(createChange?.[conditionalRollupField.id]?.newValue).toEqual(1); + } const hostDbTable = await getDbTableName(host.id); const hostFieldVo = (await getFields(host.id)).find( @@ -2445,11 +2579,13 @@ IF( const { events: hostFieldChangeEvents } = await runAndCaptureRecordUpdates(async () => { await updateRecordByApi(host.id, hostRecordId, targetFieldId, 'B'); }); - const hostFieldChange = findRecordChangeMap(hostFieldChangeEvents, host.id, hostRecordId); - expect(hostFieldChange).toBeDefined(); - const hostFieldLookupChange = assertChange(hostFieldChange?.[conditionalRollupField.id]); - expectNoOldValue(hostFieldLookupChange); - expect(hostFieldLookupChange.newValue).toEqual(0); + if (!isForceV2) { + const hostFieldChange = findRecordChangeMap(hostFieldChangeEvents, host.id, hostRecordId); + expect(hostFieldChange).toBeDefined(); + const hostFieldLookupChange = assertChange(hostFieldChange?.[conditionalRollupField.id]); + expectNoOldValue(hostFieldLookupChange); + expect(hostFieldLookupChange.newValue).toEqual(0); + } expect( parseMaybe((await getRow(hostDbTable, hostRecordId))[hostFieldVo.dbFieldName]) @@ -2458,15 +2594,17 @@ IF( const { events: foreignFieldChangeEvents } = await runAndCaptureRecordUpdates(async () => { await updateRecordByApi(foreign.id, foreign.records[1].id, statusId, 'B'); }); - const foreignDrivenChange = findRecordChangeMap( - foreignFieldChangeEvents, - host.id, - hostRecordId - ); - expect(foreignDrivenChange).toBeDefined(); - const foreignLookupChange = assertChange(foreignDrivenChange?.[conditionalRollupField.id]); - expectNoOldValue(foreignLookupChange); - expect(foreignLookupChange.newValue).toEqual(1); + if (!isForceV2) { + const foreignDrivenChange = findRecordChangeMap( + foreignFieldChangeEvents, + host.id, + hostRecordId + ); + expect(foreignDrivenChange).toBeDefined(); + const foreignLookupChange = assertChange(foreignDrivenChange?.[conditionalRollupField.id]); + expectNoOldValue(foreignLookupChange); + expect(foreignLookupChange.newValue).toEqual(1); + } expect( parseMaybe((await getRow(hostDbTable, hostRecordId))[hostFieldVo.dbFieldName]) @@ -2540,13 +2678,15 @@ IF( (f) => f.id === conditionalRollupField.id )! as any; - const createChangeA = findRecordChangeMap(createEvents, host.id, hostRecordAId); - expect(createChangeA).toBeDefined(); - expect(createChangeA?.[conditionalRollupField.id]?.newValue).toEqual(1); + if (!isForceV2) { + const createChangeA = findRecordChangeMap(createEvents, host.id, hostRecordAId); + expect(createChangeA).toBeDefined(); + expect(createChangeA?.[conditionalRollupField.id]?.newValue).toEqual(1); - const createChangeB = findRecordChangeMap(createEvents, host.id, hostRecordBId); - expect(createChangeB).toBeDefined(); - expect(createChangeB?.[conditionalRollupField.id]?.newValue).toEqual(0); + const createChangeB = findRecordChangeMap(createEvents, host.id, hostRecordBId); + expect(createChangeB).toBeDefined(); + expect(createChangeB?.[conditionalRollupField.id]?.newValue).toEqual(0); + } expect( parseMaybe((await getRow(hostDbTable, hostRecordAId))[hostFieldVo.dbFieldName]) @@ -2580,18 +2720,20 @@ IF( } as IFieldRo); }); - const updatedChangeA = findRecordChangeMap(filterChangeEvents, host.id, hostRecordAId); - if (updatedChangeA?.[conditionalRollupField.id]) { - const change = assertChange(updatedChangeA[conditionalRollupField.id]); - expectNoOldValue(change); - expect(change.newValue).toEqual(1); - } + if (!isForceV2) { + const updatedChangeA = findRecordChangeMap(filterChangeEvents, host.id, hostRecordAId); + if (updatedChangeA?.[conditionalRollupField.id]) { + const change = assertChange(updatedChangeA[conditionalRollupField.id]); + expectNoOldValue(change); + expect(change.newValue).toEqual(1); + } - const updatedChangeB = findRecordChangeMap(filterChangeEvents, host.id, hostRecordBId); - expect(updatedChangeB).toBeDefined(); - const updatedLookupChangeB = assertChange(updatedChangeB?.[conditionalRollupField.id]); - expectNoOldValue(updatedLookupChangeB); - expect(updatedLookupChangeB.newValue).toEqual(1); + const updatedChangeB = findRecordChangeMap(filterChangeEvents, host.id, hostRecordBId); + expect(updatedChangeB).toBeDefined(); + const updatedLookupChangeB = assertChange(updatedChangeB?.[conditionalRollupField.id]); + expectNoOldValue(updatedLookupChangeB); + expect(updatedLookupChangeB.newValue).toEqual(1); + } const valueAfterFilterChangeA = parseMaybe( (await getRow(hostDbTable, hostRecordAId))[hostFieldVo.dbFieldName] @@ -2710,7 +2852,7 @@ IF( // Prime record value await updateRecordByApi(table.id, table.records[0].id, aId, 5); - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 1 @@ -2718,15 +2860,18 @@ IF( await deleteField(table.id, aId); })) as any; - const event = payloads[0] as any; - expect(event.payload.tableId).toBe(table.id); - const rec = Array.isArray(event.payload.record) - ? event.payload.record[0] - : event.payload.record; - const changes = rec.fields as FieldChangeMap; - const formulaChange = assertChange(changes[f.id]); - expectNoOldValue(formulaChange); - expect(formulaChange.newValue).toBeNull(); + // Event payload verification only in v1 mode + if (!isForceV2) { + const event = payloads[0] as any; + expect(event.payload.tableId).toBe(table.id); + const rec = Array.isArray(event.payload.record) + ? event.payload.record[0] + : event.payload.record; + const changes = rec.fields as FieldChangeMap; + const formulaChange = assertChange(changes[f.id]); + expectNoOldValue(formulaChange); + expect(formulaChange.newValue).toBeNull(); + } // DB: F should be null after delete of dependency const dbName = await getDbTableName(table.id); @@ -2762,7 +2907,7 @@ IF( // Prime values await updateRecordByApi(table.id, table.records[0].id, aId, 2); - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 1 @@ -2770,17 +2915,20 @@ IF( await deleteField(table.id, aId); })) as any; - const evt = payloads[0]; - const rec = Array.isArray(evt.payload.record) ? evt.payload.record[0] : evt.payload.record; - const changes = rec.fields as FieldChangeMap; + // Event payload verification only in v1 mode + if (!isForceV2) { + const evt = payloads[0]; + const rec = Array.isArray(evt.payload.record) ? evt.payload.record[0] : evt.payload.record; + const changes = rec.fields as FieldChangeMap; - // A: 2; B: 3; C: 6 -> null after delete - const bChange = assertChange(changes[b.id]); - expectNoOldValue(bChange); - expect(bChange.newValue).toBeNull(); - const cChange = assertChange(changes[c.id]); - expectNoOldValue(cChange); - expect(cChange.newValue).toBeNull(); + // A: 2; B: 3; C: 6 -> null after delete + const bChange = assertChange(changes[b.id]); + expectNoOldValue(bChange); + expect(bChange.newValue).toBeNull(); + const cChange = assertChange(changes[c.id]); + expectNoOldValue(cChange); + expect(cChange.newValue).toBeNull(); + } // DB: B and C should be null const dbName = await getDbTableName(table.id); @@ -2845,7 +2993,7 @@ IF( } as any); await updateRecordByApi(t3.id, t3.records[0].id, l23.id, [{ id: t2.records[0].id }]); - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 2 @@ -2853,23 +3001,25 @@ IF( await deleteField(t1.id, aId); })) as any; - // T2 - 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 - ).fields as FieldChangeMap; - const t2Change = assertChange(t2Changes[l2.id]); - expectNoOldValue(t2Change); - expect(t2Change.newValue).toBeNull(); - - // T3 - const t3Event = (payloads as any[]).find((e) => e.payload.tableId === t3.id)!; - const t3Changes = ( - Array.isArray(t3Event.payload.record) ? t3Event.payload.record[0] : t3Event.payload.record - ).fields as FieldChangeMap; - const t3Change = assertChange(t3Changes[l3.id]); - expectNoOldValue(t3Change); - expect(t3Change.newValue).toBeNull(); + if (!isForceV2) { + // T2 + 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 + ).fields as FieldChangeMap; + const t2Change = assertChange(t2Changes[l2.id]); + expectNoOldValue(t2Change); + expect(t2Change.newValue).toBeNull(); + + // T3 + const t3Event = (payloads as any[]).find((e) => e.payload.tableId === t3.id)!; + const t3Changes = ( + Array.isArray(t3Event.payload.record) ? t3Event.payload.record[0] : t3Event.payload.record + ).fields as FieldChangeMap; + const t3Change = assertChange(t3Changes[l3.id]); + expectNoOldValue(t3Change); + expect(t3Change.newValue).toBeNull(); + } // DB: L2 and L3 should be null const t2Db = await getDbTableName(t2.id); @@ -2919,7 +3069,7 @@ IF( await updateRecordByApi(t2.id, t2.records[0].id, link.id, [{ id: t1.records[0].id }]); - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 1 @@ -2927,13 +3077,15 @@ IF( await deleteField(t1.id, aId); })) as any; - 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 - ).fields as FieldChangeMap; - const lkpChange = assertChange(changes[lkp.id]); - expectNoOldValue(lkpChange); - expect(lkpChange.newValue).toBeNull(); + if (!isForceV2) { + 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 + ).fields as FieldChangeMap; + const lkpChange = assertChange(changes[lkp.id]); + expectNoOldValue(lkpChange); + expect(lkpChange.newValue).toBeNull(); + } // DB: LKP should be null const t2Db = await getDbTableName(t2.id); @@ -2980,7 +3132,7 @@ IF( { id: t1.records[1].id }, ]); - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 1 @@ -3018,12 +3170,14 @@ IF( const { events } = await runAndCaptureRecordUpdates(async () => { await createField(table.id, { name: 'B', type: FieldType.SingleLineText } as IFieldRo); }); - expect(events.length).toBe(1); - const baseField = (await getFields(table.id)).find((f) => f.name === 'B')!; - const changeMap = toChangeMap(events[0]); - const bChange = assertChange(changeMap[baseField.id]); - expectNoOldValue(bChange); - expect(bChange.newValue).toBeNull(); + if (!isForceV2) { + expect(events.length).toBe(1); + const baseField = (await getFields(table.id)).find((f) => f.name === 'B')!; + const changeMap = toChangeMap(events[0]); + const bChange = assertChange(changeMap[baseField.id]); + expectNoOldValue(bChange); + expect(bChange.newValue).toBeNull(); + } } // 2) formula referencing A -> expect 1 update with newValue @@ -3035,12 +3189,14 @@ IF( options: { expression: `{${aId}} + 1` }, } as IFieldRo); }); - expect(events.length).toBe(1); - const changeMap = toChangeMap(events[0]); const fId = (await getFields(table.id)).find((f) => f.name === 'F')!.id; - const fChange = assertChange(changeMap[fId]); - expectNoOldValue(fChange); - expect(fChange.newValue).toEqual(2); + if (!isForceV2) { + expect(events.length).toBe(1); + const changeMap = toChangeMap(events[0]); + const fChange = assertChange(changeMap[fId]); + expectNoOldValue(fChange); + expect(fChange.newValue).toEqual(2); + } // DB: F should equal 2 const tbl = await getDbTableName(table.id); @@ -3088,12 +3244,14 @@ IF( } as any, } as any); }); - expect(events.length).toBe(1); const lkpField = (await getFields(t2.id)).find((f) => f.name === 'LK')!; - const changeMap = toChangeMap(events[0]); - const lkpChange = assertChange(changeMap[lkpField.id]); - expectNoOldValue(lkpChange); - expect(lkpChange.newValue).toBeNull(); + if (!isForceV2) { + expect(events.length).toBe(1); + const changeMap = toChangeMap(events[0]); + const lkpChange = assertChange(changeMap[lkpField.id]); + expectNoOldValue(lkpChange); + expect(lkpChange.newValue).toBeNull(); + } // DB: LK should be null when there is no link const t2Db = await getDbTableName(t2.id); @@ -3117,12 +3275,14 @@ IF( options: { expression: 'sum({values})' } as any, } as any); }); - expect(events.length).toBe(1); - const changeMap = toChangeMap(events[0]); const rId = (await getFields(t2.id)).find((f) => f.name === 'R')!.id; - const rChange = assertChange(changeMap[rId]); - expectNoOldValue(rChange); - expect(rChange.newValue).toEqual(10); + if (!isForceV2) { + expect(events.length).toBe(1); + const changeMap = toChangeMap(events[0]); + const rChange = assertChange(changeMap[rId]); + expectNoOldValue(rChange); + expect(rChange.newValue).toEqual(10); + } // DB: R should equal 10 const t2Db = await getDbTableName(t2.id); @@ -3158,11 +3318,13 @@ IF( options: { expression: `{${aId}} + 5` }, } as any); }); - expect(events.length).toBe(1); - const changeMap = toChangeMap(events[0]); - const fChange = assertChange(changeMap[f.id]); - expectNoOldValue(fChange); - expect(fChange.newValue).toEqual(7); + if (!isForceV2) { + expect(events.length).toBe(1); + const changeMap = toChangeMap(events[0]); + const fChange = assertChange(changeMap[f.id]); + expectNoOldValue(fChange); + expect(fChange.newValue).toEqual(7); + } // DB: F should be 7 after convert const tbl = await getDbTableName(table.id); @@ -3191,12 +3353,14 @@ IF( const { events } = await runAndCaptureRecordUpdates(async () => { await duplicateField(table.id, textField.id, { name: 'Text_copy' }); }); - expect(events.length).toBe(1); - const textCopyField = (await getFields(table.id)).find((f) => f.name === 'Text_copy')!; - const changeMap = toChangeMap(events[0]); - const textCopyChange = assertChange(changeMap[textCopyField.id]); - expectNoOldValue(textCopyChange); - expect(textCopyChange.newValue).toBeNull(); + if (!isForceV2) { + expect(events.length).toBe(1); + const textCopyField = (await getFields(table.id)).find((f) => f.name === 'Text_copy')!; + const changeMap = toChangeMap(events[0]); + const textCopyChange = assertChange(changeMap[textCopyField.id]); + expectNoOldValue(textCopyChange); + expect(textCopyChange.newValue).toBeNull(); + } } // Add formula F = Num + 1; duplicate it -> expect updates for computed values @@ -3209,12 +3373,14 @@ IF( const { events } = await runAndCaptureRecordUpdates(async () => { await duplicateField(table.id, f.id, { name: 'F_copy' }); }); - expect(events.length).toBe(1); - const changeMap = toChangeMap(events[0]); const fCopyId = (await getFields(table.id)).find((x) => x.name === 'F_copy')!.id; - const fCopyChange = assertChange(changeMap[fCopyId]); - expectNoOldValue(fCopyChange); - expect(fCopyChange.newValue).toEqual(4); + if (!isForceV2) { + expect(events.length).toBe(1); + const changeMap = toChangeMap(events[0]); + const fCopyChange = assertChange(changeMap[fCopyId]); + expectNoOldValue(fCopyChange); + expect(fCopyChange.newValue).toEqual(4); + } // DB: F_copy should equal 4 const tbl = await getDbTableName(table.id); @@ -3254,7 +3420,7 @@ IF( await updateRecordByApi(t2.id, t2.records[0].id, link2.id, [{ id: t1.records[0].id }]); // Change title in T1, expect T2 link cell title updated in event - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 2 @@ -3262,12 +3428,14 @@ IF( await updateRecordByApi(t1.id, t1.records[0].id, titleId, 'Bar'); })) as any; - // Find T2 event - const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; - const changes = t2Event.payload.record.fields as FieldChangeMap; - const linkChange = assertChange(changes[link2.id]); - expectNoOldValue(linkChange); - expect([linkChange.newValue]?.flat()?.[0]?.title).toEqual('Bar'); + if (!isForceV2) { + // Find T2 event + const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; + const changes = t2Event.payload.record.fields as FieldChangeMap; + const linkChange = assertChange(changes[link2.id]); + expectNoOldValue(linkChange); + expect([linkChange.newValue]?.flat()?.[0]?.title).toEqual('Bar'); + } // DB: link cell title should be updated to 'Bar' const t2Db = await getDbTableName(t2.id); @@ -3306,24 +3474,15 @@ IF( // Initially set link to [r1] await updateRecordByApi(t2.id, t2r, link2.id, [{ id: r1 }]); + await processV2Outbox(); - // Add r2: expect two updates (T2 link; T1[r2] symmetric) - await createAwaitWithEventWithResultWithCount( - eventEmitterService, - Events.TABLE_RECORD_UPDATE, - 2 - )(async () => { - await updateRecordByApi(t2.id, t2r, link2.id, [{ id: r1 }, { id: r2 }]); - }); + // Add r2: updates T2 link and T1[r2] symmetric + await updateRecordByApi(t2.id, t2r, link2.id, [{ id: r1 }, { id: r2 }]); + await processV2Outbox(); - // Remove r1: expect two updates (T2 link; T1[r1] symmetric) - await createAwaitWithEventWithResultWithCount( - eventEmitterService, - Events.TABLE_RECORD_UPDATE, - 2 - )(async () => { - await updateRecordByApi(t2.id, t2r, link2.id, [{ id: r2 }]); - }); + // Remove r1: updates T2 link and T1[r1] symmetric + await updateRecordByApi(t2.id, t2r, link2.id, [{ id: r2 }]); + await processV2Outbox(); // Verify symmetric link fields on T1 via field discovery const t1Fields = await getFields(t1.id); @@ -3333,7 +3492,6 @@ IF( expect(symOnT1).toBeDefined(); // After removal, r1 should not link back; r2 should link back to T2r - // Use events already asserted for presence; here we could also fetch records if needed. // DB: verify physical link columns const t2Db = await getDbTableName(t2.id); @@ -3399,7 +3557,7 @@ IF( const r2_1 = t2.records[0].id; // 2-1 // Perform: set T1[1-1].Link_T2 = [2-1] - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 2 @@ -3414,21 +3572,23 @@ IF( .map((x: any) => x?.id) .filter(Boolean); - // 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)!; - - // Assert T1 event: linkOnT1 newValue [2-1] - const t1Changes = t1Event.payload.record.fields as FieldChangeMap; - const t1Change = assertChange(t1Changes[linkOnT1.id]); - expectNoOldValue(t1Change); - expect(new Set(idsOf(t1Change.newValue))).toEqual(new Set([r2_1])); + if (!isForceV2) { + // 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)!; - // Assert T2 event: symmetric link newValue [1-1] - const t2Changes = t2Event.payload.record.fields as FieldChangeMap; - const t2Change = assertChange(t2Changes[linkOnT2.id]); - expectNoOldValue(t2Change); - expect(new Set(idsOf(t2Change.newValue))).toEqual(new Set([r1_1])); + // Assert T1 event: linkOnT1 newValue [2-1] + const t1Changes = t1Event.payload.record.fields as FieldChangeMap; + const t1Change = assertChange(t1Changes[linkOnT1.id]); + expectNoOldValue(t1Change); + expect(new Set(idsOf(t1Change.newValue))).toEqual(new Set([r2_1])); + + // Assert T2 event: symmetric link newValue [1-1] + const t2Changes = t2Event.payload.record.fields as FieldChangeMap; + const t2Change = assertChange(t2Changes[linkOnT2.id]); + expectNoOldValue(t2Change); + expect(new Set(idsOf(t2Change.newValue))).toEqual(new Set([r1_1])); + } // DB: verify both sides persisted const t1Db = await getDbTableName(t1.id); @@ -3502,7 +3662,7 @@ IF( // Step 1: set T1[A1] = [B1]; expect symmetric event on T2[B1] { - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 2 @@ -3510,15 +3670,17 @@ IF( await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB1 }]); })) as any; - const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; - const change = assertChange(getChangeFromEvent(t2Event, linkOnT2.id, rB1)); - expectNoOldValue(change); - expect(new Set(idsOf(change.newValue))).toEqual(new Set([rA1])); + if (!isForceV2) { + const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; + const change = assertChange(getChangeFromEvent(t2Event, linkOnT2.id, rB1)); + expectNoOldValue(change); + expect(new Set(idsOf(change.newValue))).toEqual(new Set([rA1])); + } } // Step 2: add B2 -> [B1, B2]; expect symmetric event for T2[B2] { - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 2 @@ -3526,15 +3688,17 @@ IF( await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB1 }, { id: rB2 }]); })) as any; - const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; - const change = assertChange(getChangeFromEvent(t2Event, linkOnT2.id, rB2)); - expectNoOldValue(change); - expect(new Set(idsOf(change.newValue))).toEqual(new Set([rA1])); + if (!isForceV2) { + const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; + const change = assertChange(getChangeFromEvent(t2Event, linkOnT2.id, rB2)); + expectNoOldValue(change); + expect(new Set(idsOf(change.newValue))).toEqual(new Set([rA1])); + } } // Step 3: remove B1 -> [B2]; expect symmetric removal event on T2[B1] { - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 2 @@ -3542,12 +3706,15 @@ IF( await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB2 }]); })) as any; - const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; - const change = assertChange( - getChangeFromEvent(t2Event, linkOnT2.id, rB1) || getChangeFromEvent(t2Event, linkOnT2.id) - ); - expectNoOldValue(change); - expect(norm(change.newValue).length).toBe(0); + if (!isForceV2) { + const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; + const change = assertChange( + getChangeFromEvent(t2Event, linkOnT2.id, rB1) || + getChangeFromEvent(t2Event, linkOnT2.id) + ); + expectNoOldValue(change); + expect(norm(change.newValue).length).toBe(0); + } } // DB: final state T1[A1] -> [B2] and symmetric T2[B2] -> [A1] @@ -3608,49 +3775,53 @@ IF( // Set A1 -> B1 { - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 2 )(async () => { await updateRecordByApi(t1.id, rA1, linkOnT1.id, { id: rB1 }); })) as any; - const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; - const recs = Array.isArray(t2Event.payload.record) - ? t2Event.payload.record - : [t2Event.payload.record]; - const change = recs.find((r: any) => r.id === rB1)?.fields?.[linkOnT2.id] as - | FieldChangePayload - | undefined; - const linkChange = assertChange(change); - expectNoOldValue(linkChange); - expect(new Set(idsOf(linkChange.newValue))).toEqual(new Set([rA1])); + if (!isForceV2) { + const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; + const recs = Array.isArray(t2Event.payload.record) + ? t2Event.payload.record + : [t2Event.payload.record]; + const change = recs.find((r: any) => r.id === rB1)?.fields?.[linkOnT2.id] as + | FieldChangePayload + | undefined; + const linkChange = assertChange(change); + expectNoOldValue(linkChange); + expect(new Set(idsOf(linkChange.newValue))).toEqual(new Set([rA1])); + } } // Switch A1 -> B2 (removes from B1, adds to B2) { - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 2 )(async () => { await updateRecordByApi(t1.id, rA1, linkOnT1.id, { id: rB2 }); })) as any; - const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; - const recs = Array.isArray(t2Event.payload.record) - ? t2Event.payload.record - : [t2Event.payload.record]; - const changeFor = (recordId: string) => - recs.find((r: any) => r.id === recordId)?.fields?.[linkOnT2.id] as - | FieldChangePayload - | undefined; - const removal = assertChange(changeFor(rB1)); - expectNoOldValue(removal); - expect(norm(removal.newValue).length).toBe(0); - - const addition = assertChange(changeFor(rB2)); - expectNoOldValue(addition); - expect(new Set(idsOf(addition.newValue))).toEqual(new Set([rA1])); + if (!isForceV2) { + const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; + const recs = Array.isArray(t2Event.payload.record) + ? t2Event.payload.record + : [t2Event.payload.record]; + const changeFor = (recordId: string) => + recs.find((r: any) => r.id === recordId)?.fields?.[linkOnT2.id] as + | FieldChangePayload + | undefined; + const removal = assertChange(changeFor(rB1)); + expectNoOldValue(removal); + expect(norm(removal.newValue).length).toBe(0); + + const addition = assertChange(changeFor(rB2)); + expectNoOldValue(addition); + expect(new Set(idsOf(addition.newValue))).toEqual(new Set([rA1])); + } } // DB: final state T1[A1] -> {id: B2} and symmetric on T2 @@ -3705,65 +3876,71 @@ IF( // Set [B1] { - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 2 )(async () => { await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB1 }]); })) as any; - const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; - const recs = Array.isArray(t2Event.payload.record) - ? t2Event.payload.record - : [t2Event.payload.record]; - const change = recs.find((r: any) => r.id === rB1)?.fields?.[linkOnT2.id] as - | FieldChangePayload - | undefined; - const addChange = assertChange(change); - expectNoOldValue(addChange); - expect(addChange.newValue?.id).toBe(rA1); + if (!isForceV2) { + const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; + const recs = Array.isArray(t2Event.payload.record) + ? t2Event.payload.record + : [t2Event.payload.record]; + const change = recs.find((r: any) => r.id === rB1)?.fields?.[linkOnT2.id] as + | FieldChangePayload + | undefined; + const addChange = assertChange(change); + expectNoOldValue(addChange); + expect(addChange.newValue?.id).toBe(rA1); + } } // Add B2 -> [B1, B2]; expect symmetric add on B2 { - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 2 )(async () => { await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB1 }, { id: rB2 }]); })) as any; - const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; - const recs = Array.isArray(t2Event.payload.record) - ? t2Event.payload.record - : [t2Event.payload.record]; - const change = recs.find((r: any) => r.id === rB2)?.fields?.[linkOnT2.id] as - | FieldChangePayload - | undefined; - const addChange = assertChange(change); - expectNoOldValue(addChange); - expect(addChange.newValue?.id).toBe(rA1); + if (!isForceV2) { + const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; + const recs = Array.isArray(t2Event.payload.record) + ? t2Event.payload.record + : [t2Event.payload.record]; + const change = recs.find((r: any) => r.id === rB2)?.fields?.[linkOnT2.id] as + | FieldChangePayload + | undefined; + const addChange = assertChange(change); + expectNoOldValue(addChange); + expect(addChange.newValue?.id).toBe(rA1); + } } // Remove B1 -> [B2]; expect symmetric removal on B1 { - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 2 )(async () => { await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB2 }]); })) as any; - const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; - const recs = Array.isArray(t2Event.payload.record) - ? t2Event.payload.record - : [t2Event.payload.record]; - const change = recs.find((r: any) => r.id === rB1)?.fields?.[linkOnT2.id] as - | FieldChangePayload - | undefined; - const removalChange = assertChange(change); - expectNoOldValue(removalChange); - expect(removalChange.newValue).toBeNull(); + if (!isForceV2) { + const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!; + const recs = Array.isArray(t2Event.payload.record) + ? t2Event.payload.record + : [t2Event.payload.record]; + const change = recs.find((r: any) => r.id === rB1)?.fields?.[linkOnT2.id] as + | FieldChangePayload + | undefined; + const removalChange = assertChange(change); + expectNoOldValue(removalChange); + expect(removalChange.newValue).toBeNull(); + } } // DB: final state T1[A1] -> [B2] and symmetric T2[B2] -> {id: A1} @@ -3822,7 +3999,7 @@ IF( const r2_1 = t2.records[0].id; // 1) Establish mutual link 1-1 <-> 2-1 - await createAwaitWithEventWithResultWithCount( + await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 2 @@ -3831,7 +4008,7 @@ IF( }); // 2) Add 1-2 to 2-1, now 2-1 links [1-1, 1-2] - await createAwaitWithEventWithResultWithCount( + await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 2 @@ -3843,7 +4020,7 @@ IF( // - T2[2-1] changed // - T1[1-2] changed (removed) // - T1[1-1] re-published with same newValue (oldValue missing) - const { payloads } = (await createAwaitWithEventWithResultWithCount( + const { payloads } = (await createAwaitWithEventV2Compatible( eventEmitterService, Events.TABLE_RECORD_UPDATE, 2 @@ -3851,27 +4028,29 @@ IF( await updateRecordByApi(t2.id, r2_1, linkOnT2.id, [{ id: r1_1 }]); })) as any; - const t1Event = (payloads as any[]).find((e) => e.payload.tableId === t1.id)!; - const recs = Array.isArray(t1Event.payload.record) - ? t1Event.payload.record - : [t1Event.payload.record]; + if (!isForceV2) { + const t1Event = (payloads as any[]).find((e) => e.payload.tableId === t1.id)!; + const recs = Array.isArray(t1Event.payload.record) + ? t1Event.payload.record + : [t1Event.payload.record]; - const changeOn11 = recs.find((r: any) => r.id === r1_1)?.fields?.[linkOnT1.id] as - | FieldChangePayload - | undefined; - const changeOn12 = recs.find((r: any) => r.id === r1_2)?.fields?.[linkOnT1.id] as - | FieldChangePayload - | undefined; + const changeOn11 = recs.find((r: any) => r.id === r1_1)?.fields?.[linkOnT1.id] as + | FieldChangePayload + | undefined; + const changeOn12 = recs.find((r: any) => r.id === r1_2)?.fields?.[linkOnT1.id] as + | FieldChangePayload + | undefined; - const removalChange = assertChange(changeOn12); // 1-2 removed 2-1 - expectNoOldValue(removalChange); - expect(removalChange.newValue).toBeNull(); + const removalChange = assertChange(changeOn12); // 1-2 removed 2-1 + expectNoOldValue(removalChange); + expect(removalChange.newValue).toBeNull(); - const unchangedRepublish = assertChange(changeOn11); - expectNoOldValue(unchangedRepublish); - const idsOf = (v: any) => - (Array.isArray(v) ? v : v ? [v] : []).map((item: any) => item?.id).filter(Boolean); - expect(new Set(idsOf(unchangedRepublish.newValue))).toEqual(new Set([r2_1])); + const unchangedRepublish = assertChange(changeOn11); + expectNoOldValue(unchangedRepublish); + const idsOf = (v: any) => + (Array.isArray(v) ? v : v ? [v] : []).map((item: any) => item?.id).filter(Boolean); + expect(new Set(idsOf(unchangedRepublish.newValue))).toEqual(new Set([r2_1])); + } await permanentDeleteTable(baseId, t2.id); await permanentDeleteTable(baseId, t1.id); diff --git a/apps/nestjs-backend/test/computed-version-regression.e2e-spec.ts b/apps/nestjs-backend/test/computed-version-regression.e2e-spec.ts index ad779ed112..4b82cfa529 100644 --- a/apps/nestjs-backend/test/computed-version-regression.e2e-spec.ts +++ b/apps/nestjs-backend/test/computed-version-regression.e2e-spec.ts @@ -10,6 +10,8 @@ import { updateRecordByApi, } from './utils/init-app'; +const isForceV2 = process.env.FORCE_V2_ALL === 'true'; + describe('Computed ops version alignment (e2e)', () => { let app: INestApplication; let eventEmitterService: EventEmitterService; @@ -35,56 +37,63 @@ describe('Computed ops version alignment (e2e)', () => { eventEmitterService.eventEmitter.on(Events.TABLE_RECORD_UPDATE, handler); }); - it('emits non-null new values for track-all last modified fields and formulas', async () => { - let table: Awaited> | undefined; - try { - table = await createTable(baseId, { - name: 'computed_version_alignment', - fields: [{ name: 'Title', type: FieldType.SingleLineText }], - records: [{ fields: { Title: 'before' } }], - }); + // Skip in v2 mode - this test verifies v1 event payload format + // v2 uses different event system (RecordUpdated/RecordsBatchUpdated) + const itWhenV1 = isForceV2 ? it.skip : it; + + itWhenV1( + 'emits non-null new values for track-all last modified fields and formulas', + async () => { + let table: Awaited> | undefined; + try { + table = await createTable(baseId, { + name: 'computed_version_alignment', + fields: [{ name: 'Title', type: FieldType.SingleLineText }], + records: [{ fields: { Title: 'before' } }], + }); - const titleId = table.fields.find((f) => f.name === 'Title')!.id; - const lmtField = await createField(table.id, { - name: 'LMT', - type: FieldType.LastModifiedTime, - }); - const lmbField = await createField(table.id, { - name: 'LMB', - type: FieldType.LastModifiedBy, - }); - const formulaField = await createField(table.id, { - name: 'UpperTitle', - type: FieldType.Formula, - options: { expression: `UPPER({${titleId}})` }, - }); + const titleId = table.fields.find((f) => f.name === 'Title')!.id; + const lmtField = await createField(table.id, { + name: 'LMT', + type: FieldType.LastModifiedTime, + }); + const lmbField = await createField(table.id, { + name: 'LMB', + type: FieldType.LastModifiedBy, + }); + const formulaField = await createField(table.id, { + name: 'UpperTitle', + type: FieldType.Formula, + options: { expression: `UPPER({${titleId}})` }, + }); - const waitForUpdate = waitForRecordUpdateOnTable(table.id); - await updateRecordByApi(table.id, table.records[0].id, titleId, 'after'); - const event = await waitForUpdate; + const waitForUpdate = waitForRecordUpdateOnTable(table.id); + await updateRecordByApi(table.id, table.records[0].id, titleId, 'after'); + const event = await waitForUpdate; - const recordPayload = Array.isArray(event.payload.record) - ? event.payload.record[0] - : event.payload.record; - const changes = recordPayload.fields as Record< - string, - { oldValue: unknown; newValue: unknown } - >; + const recordPayload = Array.isArray(event.payload.record) + ? event.payload.record[0] + : event.payload.record; + const changes = recordPayload.fields as Record< + string, + { oldValue: unknown; newValue: unknown } + >; - expect(changes[lmtField.id]).toBeDefined(); - expect(typeof changes[lmtField.id].newValue).toBe('string'); + expect(changes[lmtField.id]).toBeDefined(); + expect(typeof changes[lmtField.id].newValue).toBe('string'); - expect(changes[lmbField.id]).toBeDefined(); - expect(changes[lmbField.id].newValue).toMatchObject({ - id: globalThis.testConfig.userId, - }); + expect(changes[lmbField.id]).toBeDefined(); + expect(changes[lmbField.id].newValue).toMatchObject({ + id: globalThis.testConfig.userId, + }); - expect(changes[formulaField.id]).toBeDefined(); - expect(changes[formulaField.id].newValue).toBe('AFTER'); - } finally { - if (table) { - await permanentDeleteTable(baseId, table.id); + expect(changes[formulaField.id]).toBeDefined(); + expect(changes[formulaField.id].newValue).toBe('AFTER'); + } finally { + if (table) { + await permanentDeleteTable(baseId, table.id); + } } } - }); + ); }); diff --git a/apps/nestjs-backend/test/conditional-lookup.e2e-spec.ts b/apps/nestjs-backend/test/conditional-lookup.e2e-spec.ts index 1832be105f..bf4dc66cbe 100644 --- a/apps/nestjs-backend/test/conditional-lookup.e2e-spec.ts +++ b/apps/nestjs-backend/test/conditional-lookup.e2e-spec.ts @@ -3420,6 +3420,93 @@ describe('OpenAPI Conditional Lookup field (e2e)', () => { }); }); + describe('user field filters with multi host field', () => { + let foreign: ITableFullVo; + let host: ITableFullVo; + let lookupField: IFieldVo; + let titleId: string; + let foreignOwnerId: string; + let hostAssigneesId: string; + let assignedRecordId: string; + let emptyRecordId: string; + + beforeAll(async () => { + const { userId, userName, email } = globalThis.testConfig; + const userCell = { id: userId, title: userName, email }; + + foreign = await createTable(baseId, { + name: 'ConditionalLookup_User_Foreign_MultiHost', + fields: [ + { name: 'Task', type: FieldType.SingleLineText } as IFieldRo, + { name: 'Owner', type: FieldType.User } as IFieldRo, + ], + records: [ + { fields: { Task: 'Task Alpha', Owner: userCell } }, + { fields: { Task: 'Task Beta', Owner: userCell } }, + { fields: { Task: 'Task Gamma' } }, + ], + }); + + titleId = foreign.fields.find((field) => field.name === 'Task')!.id; + foreignOwnerId = foreign.fields.find((field) => field.name === 'Owner')!.id; + + host = await createTable(baseId, { + name: 'ConditionalLookup_User_Host_Multi', + fields: [ + { + name: 'Assignees', + type: FieldType.User, + options: { isMultiple: true }, + } as IFieldRo, + ], + records: [{ fields: { Assignees: [userCell] } }, { fields: { Assignees: null } }], + }); + + hostAssigneesId = host.fields.find((field) => field.name === 'Assignees')!.id; + assignedRecordId = host.records[0].id; + emptyRecordId = host.records[1].id; + + const ownerMatchFilter: IFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: foreignOwnerId, + operator: 'is', + value: { type: 'field', fieldId: hostAssigneesId }, + }, + ], + }; + + lookupField = await createField(host.id, { + name: 'Owned Tasks', + type: FieldType.SingleLineText, + isLookup: true, + isConditionalLookup: true, + lookupOptions: { + foreignTableId: foreign.id, + lookupFieldId: titleId, + filter: ownerMatchFilter, + } as ILookupOptionsRo, + } as IFieldRo); + }); + + afterAll(async () => { + await permanentDeleteTable(baseId, host.id); + await permanentDeleteTable(baseId, foreign.id); + }); + + it('should match single user against multi user reference', async () => { + expect(lookupField.id).toBeDefined(); + + const assignedRecord = await getRecord(host.id, assignedRecordId); + const ownedTasks = [...((assignedRecord.fields[lookupField.id] as string[]) ?? [])].sort(); + expect(ownedTasks).toEqual(['Task Alpha', 'Task Beta']); + + const emptyRecord = await getRecord(host.id, emptyRecordId); + expect((emptyRecord.fields[lookupField.id] as string[] | undefined) ?? []).toEqual([]); + }); + }); + describe('field reference compatibility validation', () => { it('marks lookup field as errored when reference field type changes', async () => { const { userId, userName, email } = globalThis.testConfig; diff --git a/apps/nestjs-backend/test/formula-datetime-parse-update.e2e-spec.ts b/apps/nestjs-backend/test/formula-datetime-parse-update.e2e-spec.ts new file mode 100644 index 0000000000..baa20bb0cc --- /dev/null +++ b/apps/nestjs-backend/test/formula-datetime-parse-update.e2e-spec.ts @@ -0,0 +1,285 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import type { INestApplication } from '@nestjs/common'; +import { FieldKeyType, FieldType, generateFieldId } from '@teable/core'; +import { + createRecords, + createTable, + getRecord, + initApp, + permanentDeleteTable, + updateRecordByApi, +} from './utils/init-app'; + +/** + * Tests for DATETIME_PARSE formula parsing and updates. + * + * This test suite verifies: + * 1. DATETIME_PARSE correctly parses both single-digit (e.g., "2026-9-15") and + * double-digit (e.g., "2026-09-15") month/day formats. + * 2. Formula fields using DATETIME_PARSE correctly recalculate when source fields change. + * + * Related fix: DEFAULT_DATETIME_PARSE_PATTERN was updated to accept [0-9]{1,2} + * for month and day instead of requiring [0-9]{2}. + */ +describe('Formula DATETIME_PARSE update semantics (e2e)', () => { + let app: INestApplication; + const baseId = globalThis.testConfig.baseId; + + beforeAll(async () => { + const appCtx = await initApp(); + app = appCtx.app; + }); + + afterAll(async () => { + await app.close(); + }); + + /** + * Test basic DATETIME_PARSE functionality with zero-padded format. + * This should work in both v1 and v2. + */ + it('parses zero-padded date format correctly', async () => { + let tableId: string | undefined; + const textFieldId = generateFieldId(); + + try { + const table = await createTable(baseId, { + name: 'formula-datetime-parse-basic', + fields: [ + { id: textFieldId, name: 'TextDate', type: FieldType.SingleLineText }, + { + name: 'ParsedDate', + type: FieldType.Formula, + options: { + expression: `DATETIME_PARSE({${textFieldId}})`, + timeZone: 'Asia/Shanghai', + }, + }, + ], + }); + tableId = table.id; + + const formulaFieldId = + table.fields.find((f) => f.name === 'ParsedDate')?.id ?? + (() => { + throw new Error('ParsedDate field not found'); + })(); + + const { records } = await createRecords(tableId, { + fieldKeyType: FieldKeyType.Name, + typecast: true, + records: [{ fields: { TextDate: '2024-06-15' } }], + }); + + const record = await getRecord(tableId, records[0].id); + const formulaValue = record.fields?.[formulaFieldId as string]; + + expect(formulaValue).not.toBeNull(); + expect(formulaValue).not.toBeUndefined(); + expect(new Date(formulaValue as string).toISOString()).toBe('2024-06-15T00:00:00.000Z'); + } finally { + if (tableId) { + await permanentDeleteTable(baseId, tableId); + } + } + }); + + /** + * Test DATETIME_PARSE with single-digit month format. + * This test verifies that single-digit months are correctly parsed. + */ + it('parses single-digit month format correctly', async () => { + let tableId: string | undefined; + const singleDigitFieldId = generateFieldId(); + const doubleDigitFieldId = generateFieldId(); + + try { + const table = await createTable(baseId, { + name: 'formula-datetime-parse-format-compare', + fields: [ + { id: singleDigitFieldId, name: 'SingleDigitDate', type: FieldType.SingleLineText }, + { id: doubleDigitFieldId, name: 'DoubleDigitDate', type: FieldType.SingleLineText }, + { + name: 'ParsedSingle', + type: FieldType.Formula, + options: { + expression: `DATETIME_PARSE({${singleDigitFieldId}})`, + timeZone: 'Asia/Shanghai', + }, + }, + { + name: 'ParsedDouble', + type: FieldType.Formula, + options: { + expression: `DATETIME_PARSE({${doubleDigitFieldId}})`, + timeZone: 'Asia/Shanghai', + }, + }, + ], + }); + tableId = table.id; + + const { records } = await createRecords(tableId, { + fieldKeyType: FieldKeyType.Name, + typecast: true, + records: [ + { + fields: { + SingleDigitDate: '2026-9-15', // Single digit month + DoubleDigitDate: '2026-09-15', // Double digit month + }, + }, + ], + }); + + const record = await getRecord(tableId, records[0].id); + + const parsedSingleField = table.fields.find((f) => f.name === 'ParsedSingle')!; + const parsedDoubleField = table.fields.find((f) => f.name === 'ParsedDouble')!; + + // Double digit format should work + const parsedDouble = record.fields?.[parsedDoubleField.id]; + expect(parsedDouble).not.toBeNull(); + expect(parsedDouble).not.toBeUndefined(); + + // Single digit format should also work + const parsedSingle = record.fields?.[parsedSingleField.id]; + expect(parsedSingle).not.toBeNull(); + expect(parsedSingle).not.toBeUndefined(); + } finally { + if (tableId) { + await permanentDeleteTable(baseId, tableId); + } + } + }); + + /** + * Test DATETIME_PARSE with YEAR/MONTH/DAY concatenation. + * This test verifies the real-world scenario where MONTH() returns single-digit values. + */ + it('DATETIME_PARSE with MONTH/DAY concatenation works', async () => { + let tableId: string | undefined; + const dateFieldId = generateFieldId(); + + try { + const table = await createTable(baseId, { + name: 'formula-datetime-parse-concat', + fields: [ + { id: dateFieldId, name: 'Date', type: FieldType.Date }, + { + name: 'ConcatFormula', + type: FieldType.Formula, + options: { + expression: `YEAR(TODAY()) & "-" & MONTH({${dateFieldId}}) & "-" & DAY({${dateFieldId}})`, + timeZone: 'Asia/Shanghai', + }, + }, + { + name: 'ParsedDate', + type: FieldType.Formula, + options: { + expression: `DATETIME_PARSE(YEAR(TODAY()) & "-" & MONTH({${dateFieldId}}) & "-" & DAY({${dateFieldId}}))`, + timeZone: 'Asia/Shanghai', + }, + }, + ], + }); + tableId = table.id; + + // September 15 will generate "2026-9-15" (single digit month) + const { records } = await createRecords(tableId, { + fieldKeyType: FieldKeyType.Name, + typecast: true, + records: [{ fields: { Date: '2025-09-15T09:47:06.000Z' } }], + }); + + const record = await getRecord(tableId, records[0].id); + + const concatField = table.fields.find((f) => f.name === 'ConcatFormula')!; + const parsedField = table.fields.find((f) => f.name === 'ParsedDate')!; + + // ConcatFormula should produce "2026-9-15" + const concatValue = record.fields?.[concatField.id]; + expect(concatValue).toMatch(/^\d{4}-9-15$/); // e.g., "2026-9-15" + + // ParsedDate should parse the single-digit format correctly + const parsedValue = record.fields?.[parsedField.id]; + expect(parsedValue).not.toBeNull(); + expect(parsedValue).not.toBeUndefined(); + } finally { + if (tableId) { + await permanentDeleteTable(baseId, tableId); + } + } + }); + + /** + * Test formula update with double-digit months (this should work in v1). + * Uses December (month 12) which doesn't have the single-digit issue. + */ + it('updates DATETIME_PARSE formula when date field changes (double-digit month)', async () => { + let tableId: string | undefined; + const dateFieldId = generateFieldId(); + + try { + const table = await createTable(baseId, { + name: 'formula-datetime-parse-update-double', + fields: [ + { id: dateFieldId, name: 'Date', type: FieldType.Date }, + { + name: 'ParsedDate', + type: FieldType.Formula, + options: { + // Use a formula that always produces zero-padded format + expression: `DATETIME_PARSE(YEAR(TODAY()) & "-12-" & DAY({${dateFieldId}}))`, + timeZone: 'Asia/Shanghai', + }, + }, + ], + }); + tableId = table.id; + + const formulaFieldId = + table.fields.find((f) => f.name === 'ParsedDate')?.id ?? + (() => { + throw new Error('ParsedDate field not found'); + })(); + + // Create record with initial date + const { records } = await createRecords(tableId, { + fieldKeyType: FieldKeyType.Name, + typecast: true, + records: [{ fields: { Date: '2025-12-15T09:47:06.000Z' } }], + }); + + // Verify formula computed correctly after creation + const recordAfterCreate = await getRecord(tableId, records[0].id); + const formulaValueAfterCreate = recordAfterCreate.fields?.[formulaFieldId as string]; + + expect(formulaValueAfterCreate).not.toBeNull(); + expect(formulaValueAfterCreate).not.toBeUndefined(); + + // Verify the parsed date contains day 15 + const parsedAfterCreate = new Date(formulaValueAfterCreate as string); + expect(parsedAfterCreate.getUTCDate()).toBe(15); + + // Update the date to change the day + await updateRecordByApi(tableId, records[0].id, dateFieldId, '2025-12-28T09:48:15.000Z'); + + // Verify formula recalculated correctly after update + const recordAfterUpdate = await getRecord(tableId, records[0].id); + const formulaValueAfterUpdate = recordAfterUpdate.fields?.[formulaFieldId as string]; + + expect(formulaValueAfterUpdate).not.toBeNull(); + expect(formulaValueAfterUpdate).not.toBeUndefined(); + + // Verify the parsed date now contains day 28 + const parsedAfterUpdate = new Date(formulaValueAfterUpdate as string); + expect(parsedAfterUpdate.getUTCDate()).toBe(28); + } finally { + if (tableId) { + await permanentDeleteTable(baseId, tableId); + } + } + }); +}); diff --git a/apps/nestjs-backend/test/formula.e2e-spec.ts b/apps/nestjs-backend/test/formula.e2e-spec.ts index 320090a78d..43ba0ff136 100644 --- a/apps/nestjs-backend/test/formula.e2e-spec.ts +++ b/apps/nestjs-backend/test/formula.e2e-spec.ts @@ -285,6 +285,10 @@ describe('OpenAPI formula (e2e)', () => { }); beforeEach(async () => { + // Ensure real timers are active before any API calls + // This prevents Keyv cache issues caused by vi.useFakeTimers() + vi.useRealTimers(); + numberFieldRo = { id: generateFieldId(), name: 'Number field', @@ -344,6 +348,10 @@ describe('OpenAPI formula (e2e)', () => { }); afterEach(async () => { + // IMPORTANT: Restore real timers before any API calls to prevent Keyv cache issues. + // vi.useFakeTimers() interferes with Keyv's Date.now()-based TTL checks, + // causing session data to be incorrectly treated as expired or deleted. + vi.useRealTimers(); await permanentDeleteTable(baseId, table1Id); }); @@ -363,7 +371,8 @@ describe('OpenAPI formula (e2e)', () => { const record = recordResult.records[0]; expect(record.fields[numberFieldRo.name]).toEqual(1); expect(record.fields[textFieldRo.name]).toEqual('x'); - expect(record.fields[formulaFieldRo.name]).toEqual('1x'); + // V1 returns '1x', V2 returns '1.0x' (applies number formatting) + expect(record.fields[formulaFieldRo.name]).toMatch(/^1(\.0)?x$/); }); it('should response calculate record after update multi record field', async () => { @@ -383,7 +392,8 @@ describe('OpenAPI formula (e2e)', () => { expect(record.fields[numberFieldRo.name]).toEqual(1); expect(record.fields[textFieldRo.name]).toEqual('x'); - expect(record.fields[formulaFieldRo.name]).toEqual('1x'); + // V1 returns '1x', V2 returns '1.0x' (applies number formatting) + expect(record.fields[formulaFieldRo.name]).toMatch(/^1(\.0)?x$/); }); it('should response calculate record after update single record field', async () => { @@ -402,7 +412,8 @@ describe('OpenAPI formula (e2e)', () => { expect(record1.fields[numberFieldRo.name]).toEqual(1); expect(record1.fields[textFieldRo.name]).toBeUndefined(); - expect(record1.fields[formulaFieldRo.name]).toEqual('1'); + // V1 returns '1', V2 returns '1.0' (applies number formatting) + expect(record1.fields[formulaFieldRo.name]).toMatch(/^1(\.0)?$/); const record2 = await updateRecord(table1Id, existRecord.id, { fieldKeyType: FieldKeyType.Name, @@ -413,9 +424,12 @@ describe('OpenAPI formula (e2e)', () => { }, }); - expect(record2.fields[numberFieldRo.name]).toEqual(1); + // V1 returns all fields, V2 only returns updated fields + computed fields + // So numberFieldRo may be 1 (V1) or undefined (V2) + expect([1, undefined]).toContain(record2.fields[numberFieldRo.name]); expect(record2.fields[textFieldRo.name]).toEqual('x'); - expect(record2.fields[formulaFieldRo.name]).toEqual('1x'); + // V1 returns '1x', V2 returns '1.0x' (applies number formatting) + expect(record2.fields[formulaFieldRo.name]).toMatch(/^1(\.0)?x$/); }); it('should batch update records referencing spaced curly field identifiers', async () => { @@ -519,7 +533,7 @@ describe('OpenAPI formula (e2e)', () => { expect(createdRecord.fields[plusTextPrefixField.name]).toEqual(''); expect(createdRecord.fields[plusMixedField.name]).toEqual('1'); - const updatedRecord = await updateRecord(table1Id, createdRecord.id, { + await updateRecord(table1Id, createdRecord.id, { fieldKeyType: FieldKeyType.Name, record: { fields: { @@ -528,11 +542,16 @@ describe('OpenAPI formula (e2e)', () => { }, }); - expect(updatedRecord.fields[plusNumberSuffixField.name]).toEqual('1'); - expect(updatedRecord.fields[plusNumberPrefixField.name]).toEqual('1'); - expect(updatedRecord.fields[plusTextSuffixField.name]).toEqual('x'); - expect(updatedRecord.fields[plusTextPrefixField.name]).toEqual('x'); - expect(updatedRecord.fields[plusMixedField.name]).toEqual('1x'); + // Fetch the full record to verify all computed field values + const updatedRecord = await getRecord(table1Id, createdRecord.id, { + fieldKeyType: FieldKeyType.Name, + }); + + expect(updatedRecord.data.fields[plusNumberSuffixField.name]).toEqual('1'); + expect(updatedRecord.data.fields[plusNumberPrefixField.name]).toEqual('1'); + expect(updatedRecord.data.fields[plusTextSuffixField.name]).toEqual('x'); + expect(updatedRecord.data.fields[plusTextPrefixField.name]).toEqual('x'); + expect(updatedRecord.data.fields[plusMixedField.name]).toEqual('1x'); }); it('should safely update numeric formulas that add multi-value fields', async () => { @@ -1089,6 +1108,11 @@ describe('OpenAPI formula (e2e)', () => { }); describe('LAST_MODIFIED_TIME field parameter', () => { + // Helper to ensure time advances between operations (real time, not fake timers) + // Note: vi.useFakeTimers() is incompatible with Keyv cache - it uses Date.now() + // to check TTL, causing session data to be incorrectly deleted when fake time is set to the past. + const waitForTimestamp = () => new Promise((resolve) => setTimeout(resolve, 100)); + it('should update when any referenced field changes', async () => { const multiTrackedFormulaField = await createField(table1Id, { name: 'multi-tracked-last-modified', @@ -1116,6 +1140,9 @@ describe('OpenAPI formula (e2e)', () => { const initialFormulaValue = initialRecord.data.fields[multiTrackedFormulaField.name]; expect(initialFormulaValue).toEqual(initialRecord.data.lastModifiedTime); + // Wait for time to advance before untracked field update + await waitForTimestamp(); + // Untracked field change should NOT update the formula await updateRecord(table1Id, recordId, { fieldKeyType: FieldKeyType.Name, @@ -1134,6 +1161,9 @@ describe('OpenAPI formula (e2e)', () => { initialFormulaValue ); + // Wait for time to advance before tracked field update + await waitForTimestamp(); + // Any tracked field change should update the formula await updateRecord(table1Id, recordId, { fieldKeyType: FieldKeyType.Name, @@ -1179,6 +1209,9 @@ describe('OpenAPI formula (e2e)', () => { const initialFormulaValue = initialRecord.data.fields[lastModifiedFormulaField.name]; expect(initialFormulaValue).toEqual(initialRecord.data.lastModifiedTime); + // Wait for time to advance before unrelated field update + await waitForTimestamp(); + await updateRecord(table1Id, recordId, { fieldKeyType: FieldKeyType.Name, record: { @@ -1196,6 +1229,9 @@ describe('OpenAPI formula (e2e)', () => { initialFormulaValue ); + // Wait for time to advance before tracked field update + await waitForTimestamp(); + await updateRecord(table1Id, recordId, { fieldKeyType: FieldKeyType.Name, record: { @@ -1239,6 +1275,9 @@ describe('OpenAPI formula (e2e)', () => { const initialFormulaValue = initialRecord.data.fields[defaultLastModifiedField.name]; expect(initialFormulaValue).toEqual(initialRecord.data.lastModifiedTime); + // Wait for time to advance before first update + await waitForTimestamp(); + // Any field change should update the default tracking formula await updateRecord(table1Id, recordId, { fieldKeyType: FieldKeyType.Name, @@ -1257,6 +1296,9 @@ describe('OpenAPI formula (e2e)', () => { afterAnyUpdate.data.lastModifiedTime ); + // Wait for time to advance before second update + await waitForTimestamp(); + await updateRecord(table1Id, recordId, { fieldKeyType: FieldKeyType.Name, record: { @@ -1306,6 +1348,9 @@ describe('OpenAPI formula (e2e)', () => { const initialLmt = initialRecord.data.fields[specificLmt.name]; expect(initialLmt).toEqual(initialRecord.data.lastModifiedTime); + // Wait for time to advance before untracked field update + await waitForTimestamp(); + await updateRecord(table1Id, recordId, { fieldKeyType: FieldKeyType.Name, record: { @@ -1318,6 +1363,9 @@ describe('OpenAPI formula (e2e)', () => { const afterUntrackedUpdate = await getRecord(table1Id, recordId); expect(afterUntrackedUpdate.data.fields[specificLmt.name]).toEqual(initialLmt); + // Wait for time to advance before tracked field update + await waitForTimestamp(); + await updateRecord(table1Id, recordId, { fieldKeyType: FieldKeyType.Name, record: { @@ -5638,7 +5686,8 @@ describe('OpenAPI formula (e2e)', () => { const formulaField = await createField(table1Id, { name: `datetime-component-${name.toLowerCase()}`, type: FieldType.Formula, - options: { expression }, + // Use UTC timezone to ensure deterministic results across different local timezones + options: { expression, timeZone: 'UTC' }, }); const recordAfterFormula = await getRecord(table1Id, recordId); @@ -5678,7 +5727,8 @@ describe('OpenAPI formula (e2e)', () => { const formulaField = await createField(table1Id, { name: `datetime-format-${name.toLowerCase()}`, type: FieldType.Formula, - options: { expression }, + // Use UTC timezone to ensure deterministic results across different local timezones + options: { expression, timeZone: 'UTC' }, }); const recordAfterFormula = await getRecord(table1Id, recordId); @@ -6089,6 +6139,8 @@ describe('OpenAPI formula (e2e)', () => { type: FieldType.Formula, options: { expression: `DATETIME_PARSE(DATE_ADD({${dateField.id}}, 1 - DAY({${dateField.id}}), 'day'), 'YYYY-MM-DD 00:00')`, + // Use UTC timezone to ensure deterministic results across different local timezones + timeZone: 'UTC', }, }); @@ -6498,6 +6550,7 @@ describe('OpenAPI formula (e2e)', () => { it('should default formula timeZone when missing', async () => { const inputIso = '2024-02-28T00:00:00+09:00'; + // Use system default timezone instead of hardcoded 'UTC' const defaultTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; const field = await createField(table.id, { @@ -6902,7 +6955,9 @@ describe('OpenAPI formula (e2e)', () => { }, }); - const record = await getRecord(table.id, table.records[0].id); + const record = await getRecord(table.id, table.records[0].id, { + fieldKeyType: FieldKeyType.Name, + }); expect(record.data.fields[table.fields[0].name]).toEqual('1'); }); }); diff --git a/apps/nestjs-backend/test/link-api.e2e-spec.ts b/apps/nestjs-backend/test/link-api.e2e-spec.ts index bcea1640b9..dc16d56c65 100644 --- a/apps/nestjs-backend/test/link-api.e2e-spec.ts +++ b/apps/nestjs-backend/test/link-api.e2e-spec.ts @@ -3547,6 +3547,74 @@ describe('OpenAPI link (e2e)', () => { }); }); + it('clears link when primary formula embeds lookup value', async () => { + const tableB = await createTable(baseId, { + name: 'link-formula-lookup-b', + fields: [ + { name: 'Name', type: FieldType.SingleLineText } as IFieldRo, + { name: 'Code', type: FieldType.SingleLineText } as IFieldRo, + ], + records: [{ fields: { Name: 'B1', Code: 'C1' } }], + }); + + const tableA = await createTable(baseId, { + name: 'link-formula-lookup-a', + fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo], + records: [{ fields: { Title: 'A1' } }], + }); + + try { + const linkField = await createField(tableA.id, { + name: 'A->B', + type: FieldType.Link, + options: { + relationship: Relationship.ManyOne, + foreignTableId: tableB.id, + }, + } as IFieldRo); + + const lookupField = await createField(tableA.id, { + name: 'B Code', + type: FieldType.SingleLineText, + isLookup: true, + lookupOptions: { + foreignTableId: tableB.id, + lookupFieldId: tableB.fields[1].id, + linkFieldId: linkField.id, + }, + } as IFieldRo); + + const primaryField = tableA.fields.find((field) => field.isPrimary)!; + await convertField(tableA.id, primaryField.id, { + type: FieldType.Formula, + options: { + expression: `{${lookupField.id}}`, + }, + }); + + await updateRecordByApi(tableA.id, tableA.records[0].id, linkField.id, { + id: tableB.records[0].id, + }); + + const linked = await getRecord(tableA.id, tableA.records[0].id); + expect((linked.fields[linkField.id] as { id: string } | undefined)?.id).toBe( + tableB.records[0].id + ); + expect(linked.fields[lookupField.id]).toBe('C1'); + expect(linked.fields[primaryField.id]).toBe('C1'); + + await updateRecordByApi(tableA.id, tableA.records[0].id, linkField.id, null); + + const cleared = await getRecord(tableA.id, tableA.records[0].id); + expect(cleared.fields[linkField.id]).toBeUndefined(); + expect(cleared.fields[lookupField.id]).toBeUndefined(); + expect(cleared.fields[primaryField.id]).toBeUndefined(); + } finally { + await permanentDeleteTable(baseId, tableA.id); + await permanentDeleteTable(baseId, tableB.id); + } + }); + describe('Create two bi-link for two tables', () => { let table1: ITableFullVo; let table2: ITableFullVo; diff --git a/apps/nestjs-backend/test/link-events.e2e-spec.ts b/apps/nestjs-backend/test/link-events.e2e-spec.ts index 169d75dca5..e85eff87fb 100644 --- a/apps/nestjs-backend/test/link-events.e2e-spec.ts +++ b/apps/nestjs-backend/test/link-events.e2e-spec.ts @@ -17,6 +17,8 @@ import { updateRecordByApi, } from './utils/init-app'; +const isForceV2 = process.env.FORCE_V2_ALL === 'true'; + describe('Link events (e2e)', () => { let app: INestApplication; let eventEmitterService: EventEmitterService; @@ -45,7 +47,11 @@ describe('Link events (e2e)', () => { }); }; - it('emits formatted link titles in record update events', async () => { + // Skip in v2 mode - this test verifies v1 event payload format + // v2 uses different event system (RecordUpdated/RecordsBatchUpdated) + const itWhenV1 = isForceV2 ? it.skip : it; + + itWhenV1('emits formatted link titles in record update events', async () => { const releaseFormatting = { date: DateFormattingPreset.Asian, time: TimeFormatting.Hour24, diff --git a/apps/nestjs-backend/test/link-formula-if-boolean-context.e2e-spec.ts b/apps/nestjs-backend/test/link-formula-if-boolean-context.e2e-spec.ts index e582e859e6..b7e9098266 100644 --- a/apps/nestjs-backend/test/link-formula-if-boolean-context.e2e-spec.ts +++ b/apps/nestjs-backend/test/link-formula-if-boolean-context.e2e-spec.ts @@ -43,12 +43,17 @@ describe('Formula IF link boolean context (e2e)', () => { fields: [ { name: 'B Primary', type: FieldType.SingleLineText }, { name: 'Active', type: FieldType.Checkbox }, + { name: 'Empty Text', type: FieldType.SingleLineText }, + ], + records: [ + { fields: { 'B Primary': 'Row-1', Active: true, 'Empty Text': 'ignore' } }, + { fields: { 'B Primary': 'Row-2', Active: false, 'Empty Text': '' } }, ], - records: [{ fields: { 'B Primary': 'Row-1', Active: true } }], }); const primaryFieldB = tableB.fields[0]; const activeField = tableB.fields.find((field) => field.name === 'Active') as IFieldVo; + const emptyTextField = tableB.fields.find((field) => field.name === 'Empty Text') as IFieldVo; const linkAtoB = await createField(tableA.id, { name: 'Link to B', @@ -67,7 +72,7 @@ describe('Formula IF link boolean context (e2e)', () => { await convertField(tableB.id, primaryFieldB.id, { type: FieldType.Formula, options: { - expression: `IF({${activeField.id}}, {${symmetricLinkId}}, '')`, + expression: `IF({${activeField.id}}, {${symmetricLinkId}}, {${emptyTextField.id}})`, }, }); @@ -97,8 +102,9 @@ describe('Formula IF link boolean context (e2e)', () => { projection: [primaryFieldB.id], }); - expect(tableBRecords.records).toHaveLength(1); - expect(tableBRecords.records[0].fields[primaryFieldB.id]).toBe('Alpha'); + expect(tableBRecords.records).toHaveLength(2); + const row1 = tableBRecords.records.find((record) => record.id === tableB!.records[0].id); + expect(row1?.fields[primaryFieldB.id]).toBe('Alpha'); } finally { if (tableA) { await permanentDeleteTable(baseId, tableA.id); diff --git a/apps/nestjs-backend/test/oauth-server.e2e-spec.ts b/apps/nestjs-backend/test/oauth-server.e2e-spec.ts index a5ee8cdf51..4bf92543a6 100644 --- a/apps/nestjs-backend/test/oauth-server.e2e-spec.ts +++ b/apps/nestjs-backend/test/oauth-server.e2e-spec.ts @@ -241,13 +241,13 @@ describe('OpenAPI OAuthController (e2e)', () => { const tokenRes = await anonymousAxios.post( `/oauth/access_token`, - { + new URLSearchParams({ grant_type: 'authorization_code', - code, + code: code ?? '', client_id: oauth.clientId, client_secret: secret.data.secret, redirect_uri: oauth.redirectUris[0], - }, + }), { maxRedirects: 0, headers: { @@ -282,12 +282,12 @@ describe('OpenAPI OAuthController (e2e)', () => { const tokenRes = await anonymousAxios.post( `/oauth/access_token`, - { + new URLSearchParams({ grant_type: 'authorization_code', - code, + code: code ?? '', client_id: oauth.clientId, client_secret: secret.data.secret, - }, + }), { maxRedirects: 0, headers: { @@ -321,13 +321,13 @@ describe('OpenAPI OAuthController (e2e)', () => { const tokenRes = await anonymousAxios.post( `/oauth/access_token`, - { + new URLSearchParams({ grant_type: 'authorization_code', - code, + code: code ?? '', client_id: oauthRes.data.clientId, client_secret: secret.data.secret, redirect_uri: oauthRes.data.redirectUris[0], - }, + }), { maxRedirects: 0, headers: { @@ -382,13 +382,13 @@ describe('OpenAPI OAuthController (e2e)', () => { const tokenRes = await anonymousAxios.post( `/oauth/access_token`, - { + new URLSearchParams({ grant_type: 'authorization_code', - code, + code: code ?? '', client_id: oauthRes.data.clientId, client_secret: secret.data.secret, redirect_uri: oauthRes.data.redirectUris[0], - }, + }), { maxRedirects: 0, headers: { @@ -437,13 +437,13 @@ describe('OpenAPI OAuthController (e2e)', () => { const tokenRes = await anonymousAxios.post( `/oauth/access_token`, - { + new URLSearchParams({ grant_type: 'authorization_code', - code, + code: code ?? '', client_id: oauth.clientId, client_secret: secret.data.secret, redirect_uri: oauth.redirectUris[0], - }, + }), { maxRedirects: 0, headers: { @@ -455,12 +455,12 @@ describe('OpenAPI OAuthController (e2e)', () => { const refreshTokenRes = await anonymousAxios.post( `/oauth/access_token`, - { + new URLSearchParams({ grant_type: 'refresh_token', - refresh_token: tokenRes.data.refresh_token, + refresh_token: `${tokenRes.data.refresh_token}`, client_id: oauth.clientId, client_secret: secret.data.secret, - }, + }), { maxRedirects: 0, headers: { @@ -483,12 +483,12 @@ describe('OpenAPI OAuthController (e2e)', () => { const error = await getError(() => anonymousAxios.post( `/oauth/access_token`, - { + new URLSearchParams({ grant_type: 'refresh_token', - refresh_token: tokenRes.data.refresh_token, + refresh_token: `${tokenRes.data.refresh_token}`, client_id: oauth.clientId, client_secret: secret.data.secret, - }, + }), { maxRedirects: 0, headers: { @@ -515,13 +515,13 @@ describe('OpenAPI OAuthController (e2e)', () => { const tokenRes = await anonymousAxios.post( `/oauth/access_token`, - { + new URLSearchParams({ grant_type: 'authorization_code', - code, + code: code ?? '', client_id: oauth.clientId, client_secret: secret.data.secret, redirect_uri: oauth.redirectUris[0], - }, + }), { maxRedirects: 0, headers: { diff --git a/apps/nestjs-backend/test/selection.e2e-spec.ts b/apps/nestjs-backend/test/selection.e2e-spec.ts index cc2003a5e8..1455654f9c 100644 --- a/apps/nestjs-backend/test/selection.e2e-spec.ts +++ b/apps/nestjs-backend/test/selection.e2e-spec.ts @@ -1169,4 +1169,146 @@ describe('OpenAPI SelectionController (e2e)', () => { expect(firstRecord.fields[fieldD.id]).toBe('D1'); }); }); + + describe('paste with orderBy (view row order)', () => { + /** + * Critical test for ensuring paste operations target the correct rows + * when a view has custom sort order. + * + * Without the orderBy parameter, paste would use the default __auto_number order, + * causing updates to go to the wrong records. + */ + let sortTable: ITableFullVo; + + beforeEach(async () => { + // Create a table for sort tests with explicit records + // Creation order: A(100), B(200), C(300), D(400), E(500) + // Default order (by auto_number): A, B, C, D, E + // Descending by Value: E(500), D(400), C(300), B(200), A(100) + sortTable = await createTable(baseId, { + name: 'sort-paste-table', + fields: [ + { name: 'Name', type: FieldType.SingleLineText }, + { name: 'Value', type: FieldType.Number }, + ], + records: [ + { fields: { Name: 'RecordA', Value: 100 } }, + { fields: { Name: 'RecordB', Value: 200 } }, + { fields: { Name: 'RecordC', Value: 300 } }, + { fields: { Name: 'RecordD', Value: 400 } }, + { fields: { Name: 'RecordE', Value: 500 } }, + ], + }); + }); + + afterEach(async () => { + await permanentDeleteTable(baseId, sortTable.id); + }); + + it('should paste to correct rows when orderBy is specified (descending)', async () => { + /** + * Test scenario: + * - Records in creation order: A(100), B(200), C(300), D(400), E(500) + * - View sorted by Value DESC: E(500), D(400), C(300), B(200), A(100) + * - Paste "Updated" to row 0 with orderBy=[{fieldId: valueFieldId, order: 'desc'}] + * - Should update E (first in DESC order), NOT A (first in creation order) + */ + const nameField = sortTable.fields.find((f) => f.name === 'Name')!; + const valueField = sortTable.fields.find((f) => f.name === 'Value')!; + + await apiPaste(sortTable.id, { + viewId: sortTable.views[0].id, + content: 'SortTestUpdated', + ranges: [ + [0, 0], + [0, 0], + ], + orderBy: [{ fieldId: valueField.id, order: SortFunc.Desc }], + }); + + // Verify E was updated (not A) + const records = await getRecords(sortTable.id, { + viewId: sortTable.views[0].id, + fieldKeyType: FieldKeyType.Id, + }); + + const recordE = records.data.records.find((r) => r.fields[valueField.id] === 500); + const recordA = records.data.records.find((r) => r.fields[valueField.id] === 100); + + expect(recordE?.fields[nameField.id]).toBe('SortTestUpdated'); + expect(recordA?.fields[nameField.id]).toBe('RecordA'); // Should remain unchanged + }); + + it('should paste multiple rows in correct sort order', async () => { + /** + * Test scenario: + * - View sorted by Value DESC: E(500), D(400), C(300), B(200), A(100) + * - Paste to rows 1-3 with orderBy DESC + * - Should update D, C, B (rows 1-3 in DESC order) + */ + const nameField = sortTable.fields.find((f) => f.name === 'Name')!; + const valueField = sortTable.fields.find((f) => f.name === 'Value')!; + + await apiPaste(sortTable.id, { + viewId: sortTable.views[0].id, + content: 'SortRow1\nSortRow2\nSortRow3', + ranges: [ + [0, 1], + [0, 3], + ], + orderBy: [{ fieldId: valueField.id, order: SortFunc.Desc }], + }); + + // Verify D, C, B were updated in order + const records = await getRecords(sortTable.id, { + viewId: sortTable.views[0].id, + fieldKeyType: FieldKeyType.Id, + }); + + const recordD = records.data.records.find((r) => r.fields[valueField.id] === 400); + const recordC = records.data.records.find((r) => r.fields[valueField.id] === 300); + const recordB = records.data.records.find((r) => r.fields[valueField.id] === 200); + const recordE = records.data.records.find((r) => r.fields[valueField.id] === 500); + const recordA = records.data.records.find((r) => r.fields[valueField.id] === 100); + + expect(recordD?.fields[nameField.id]).toBe('SortRow1'); // First in paste range (row 1 in DESC) + expect(recordC?.fields[nameField.id]).toBe('SortRow2'); // Second in paste range (row 2 in DESC) + expect(recordB?.fields[nameField.id]).toBe('SortRow3'); // Third in paste range (row 3 in DESC) + expect(recordE?.fields[nameField.id]).toBe('RecordE'); // Row 0, not in paste range + expect(recordA?.fields[nameField.id]).toBe('RecordA'); // Row 4, not in paste range + }); + + it('should paste to correct rows with ascending sort', async () => { + /** + * Test scenario: + * - View sorted by Value ASC: A(100), B(200), C(300), D(400), E(500) + * - This matches creation order, so row 0 should be A + * - Paste to row 0 with orderBy ASC + * - Should update A (first in ASC order) + */ + const nameField = sortTable.fields.find((f) => f.name === 'Name')!; + const valueField = sortTable.fields.find((f) => f.name === 'Value')!; + + await apiPaste(sortTable.id, { + viewId: sortTable.views[0].id, + content: 'AscTestUpdated', + ranges: [ + [0, 0], + [0, 0], + ], + orderBy: [{ fieldId: valueField.id, order: SortFunc.Asc }], + }); + + const records = await getRecords(sortTable.id, { + viewId: sortTable.views[0].id, + fieldKeyType: FieldKeyType.Id, + }); + + const recordA = records.data.records.find((r) => r.fields[valueField.id] === 100); + const recordE = records.data.records.find((r) => r.fields[valueField.id] === 500); + + expect(recordA?.fields[nameField.id]).toBe('AscTestUpdated'); + expect(recordE?.fields[nameField.id]).toBe('RecordE'); // Should remain unchanged + }); + }); }); diff --git a/apps/nestjs-backend/test/table-import.e2e-spec.ts b/apps/nestjs-backend/test/table-import.e2e-spec.ts index e07ed66b8a..5011adaa4b 100644 --- a/apps/nestjs-backend/test/table-import.e2e-spec.ts +++ b/apps/nestjs-backend/test/table-import.e2e-spec.ts @@ -357,18 +357,16 @@ describe('OpenAPI ImportController (e2e)', () => { }); // import data into table - await apiInplaceImportTableFromFile(baseId, tableId, { - attachmentUrl, - fileType, - insertConfig: { - sourceWorkSheetKey: CsvImporter.DEFAULT_SHEETKEY, - excludeFirstRow: true, - sourceColumnMap, - }, - }); - await awaitWithEvent(async () => { - noop(); + await apiInplaceImportTableFromFile(baseId, tableId, { + attachmentUrl, + fileType, + insertConfig: { + sourceWorkSheetKey: CsvImporter.DEFAULT_SHEETKEY, + excludeFirstRow: true, + sourceColumnMap, + }, + }); }); const { records } = await apiGetTableById(baseId, tableId, { diff --git a/apps/nestjs-backend/test/utils/init-app.ts b/apps/nestjs-backend/test/utils/init-app.ts index 2f1d0ec809..7749218b81 100644 --- a/apps/nestjs-backend/test/utils/init-app.ts +++ b/apps/nestjs-backend/test/utils/init-app.ts @@ -2,6 +2,7 @@ import type { INestApplication } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { WsAdapter } from '@nestjs/platform-ws'; import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; import type { @@ -100,6 +101,7 @@ export async function initApp() { const configService = app.get(ConfigService); app.useGlobalFilters(new GlobalExceptionFilter(configService)); + app.useWebSocketAdapter(new WsAdapter(app)); app.useGlobalPipes( new ValidationPipe({ transform: true, stopAtFirstError: true, forbidUnknownValues: false }) ); @@ -133,6 +135,8 @@ export async function initApp() { const now = new Date(); const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; console.log(`> Test NODE_ENV is ${process.env.NODE_ENV}`); + console.log(`> Test V2_COMPUTED_UPDATE_MODE is ${process.env.V2_COMPUTED_UPDATE_MODE}`); + console.log(`> Test FORCE_V2_ALL is ${process.env.FORCE_V2_ALL}`); console.log(`> Test Ready on ${url}`); console.log('> Test System Time Zone:', timeZone); console.log('> Test Current System Time:', now.toString()); diff --git a/apps/nestjs-backend/tsconfig.eslint.json b/apps/nestjs-backend/tsconfig.eslint.json index 49d4ca92e9..aa1f6f3b77 100644 --- a/apps/nestjs-backend/tsconfig.eslint.json +++ b/apps/nestjs-backend/tsconfig.eslint.json @@ -3,10 +3,11 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "target": "es6", - "moduleResolution": "Node", - "module": "CommonJS", + "module": "ESNext", + "moduleResolution": "bundler", "emitDecoratorMetadata": true, "experimentalDecorators": true, + "isolatedModules": false, "noEmit": false, "allowJs": false }, diff --git a/apps/nestjs-backend/tsconfig.json b/apps/nestjs-backend/tsconfig.json index 67e07dc802..9de6f7989f 100644 --- a/apps/nestjs-backend/tsconfig.json +++ b/apps/nestjs-backend/tsconfig.json @@ -2,13 +2,14 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "../../tsconfig.base.json", "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", "emitDecoratorMetadata": true, "experimentalDecorators": true, + "isolatedModules": false, "target": "es2022", - "moduleResolution": "Node", "declaration": true, "declarationDir": "./dist", - "module": "CommonJS", "noEmit": false, "sourceMap": true, "allowJs": false, @@ -16,7 +17,13 @@ "paths": { "@teable/core": ["../../packages/core/src"], "@teable/openapi": ["../../packages/openapi/src"], - "@teable/db-main-prisma": ["../../packages/db-main-prisma/src"] + "@teable/db-main-prisma": ["../../packages/db-main-prisma/src"], + "@teable/v2-*": ["../../packages/v2/*/src/index"], + "@teable/v2-contract-http-implementation/handlers": [ + "../../packages/v2/contract-http-implementation/src/handlers/index.ts" + ], + "@teable/formula": ["../../packages/formula/src"], + "@teable/i18n-keys": ["../../packages/i18n-keys/src"] }, "types": ["vitest/globals", "node"] }, diff --git a/apps/nestjs-backend/vitest-bench.config.ts b/apps/nestjs-backend/vitest-bench.config.ts index 56bc66438e..3ab878ec0c 100644 --- a/apps/nestjs-backend/vitest-bench.config.ts +++ b/apps/nestjs-backend/vitest-bench.config.ts @@ -1,5 +1,7 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import swc from 'unplugin-swc'; import tsconfigPaths from 'vite-tsconfig-paths'; +import type { Plugin } from 'vitest/config'; import { configDefaults, defineConfig } from 'vitest/config'; const benchFiles = ['**/test/**/*.bench.{js,ts}']; @@ -10,7 +12,7 @@ export default defineConfig({ jsc: { target: 'es2022', }, - }), + }) as unknown as Plugin, tsconfigPaths(), ], cacheDir: '../../.cache/vitest/nestjs-backend/bench', diff --git a/apps/nestjs-backend/vitest-e2e.config.ts b/apps/nestjs-backend/vitest-e2e.config.ts index b507eb04ed..0ff0618e59 100644 --- a/apps/nestjs-backend/vitest-e2e.config.ts +++ b/apps/nestjs-backend/vitest-e2e.config.ts @@ -2,6 +2,10 @@ import swc from 'unplugin-swc'; import tsconfigPaths from 'vite-tsconfig-paths'; import { configDefaults, defineConfig } from 'vitest/config'; +// Set timezone to UTC for deterministic datetime test results +// This must be set before any datetime operations +process.env.TZ = 'UTC'; + if (!process.env.CONDITIONAL_QUERY_MAX_LIMIT) { process.env.CONDITIONAL_QUERY_MAX_LIMIT = '7'; } diff --git a/apps/nestjs-backend/vitest-e2e.setup.ts b/apps/nestjs-backend/vitest-e2e.setup.ts index fe2ec1ac13..05a3e6c06e 100644 --- a/apps/nestjs-backend/vitest-e2e.setup.ts +++ b/apps/nestjs-backend/vitest-e2e.setup.ts @@ -5,6 +5,30 @@ import { DriverClient, getRandomString, parseDsn } from '@teable/core'; import dotenv from 'dotenv-flow'; import { buildSync } from 'esbuild'; +// Handle ConditionalModule timeout errors that occur sporadically in CI +// These errors are thrown from setTimeout callbacks and cannot be caught normally +// See: @nestjs/config ConditionalModule.registerWhen +const originalUncaughtExceptionListeners = process.listeners('uncaughtException'); +process.removeAllListeners('uncaughtException'); +process.on('uncaughtException', (error: Error) => { + // Ignore ConditionalModule timeout errors - they are sporadic in CI and don't affect test results + if ( + error.message?.includes('Nest was not able to resolve the config variables') && + error.message?.includes('ConditionalModule') + ) { + console.warn('[vitest-e2e.setup] Ignoring ConditionalModule timeout error:', error.message); + return; + } + // Re-throw other uncaught exceptions + for (const listener of originalUncaughtExceptionListeners) { + listener.call(process, error, 'uncaughtException'); + } + // If no original listeners, throw the error + if (originalUncaughtExceptionListeners.length === 0) { + throw error; + } +}); + interface ITestConfig { driver: string; email: string; @@ -78,6 +102,9 @@ function compileWorkerFile() { async function setup() { dotenv.config({ path: '../nextjs-app' }); + // Use sync mode for v2 computed updates in tests + process.env.V2_COMPUTED_UPDATE_MODE = 'sync'; + if (!process.env.CONDITIONAL_QUERY_MAX_LIMIT) { process.env.CONDITIONAL_QUERY_MAX_LIMIT = '7'; } diff --git a/apps/nestjs-backend/webpack.swc.js b/apps/nestjs-backend/webpack.swc.js index 87bdd2dd4f..9e265edcdd 100644 --- a/apps/nestjs-backend/webpack.swc.js +++ b/apps/nestjs-backend/webpack.swc.js @@ -15,6 +15,21 @@ module.exports = function (options, webpack) { return { ...options, + resolve: { + ...options.resolve, + conditionNames: (() => { + const base = options.resolve?.conditionNames ?? ['require', 'node', 'default']; + if (base.includes('import')) return base; + const next = [...base]; + const defaultIndex = next.indexOf('default'); + if (defaultIndex === -1) { + next.push('import'); + } else { + next.splice(defaultIndex, 0, 'import'); + } + return next; + })(), + }, entry: { index: ['webpack/hot/poll?100', options.entry], ...workerEntries, @@ -27,7 +42,7 @@ module.exports = function (options, webpack) { devtool: 'eval-cheap-module-source-map', externals: [ nodeExternals({ - allowlist: ['webpack/hot/poll?100', /^@teable/], + allowlist: ['webpack/hot/poll?100', /^@teable/, /^@orpc/], }), ], // ignore tests hot reload diff --git a/apps/nextjs-app/config/tests/I18nextTestStubProvider.tsx b/apps/nextjs-app/config/tests/I18nextTestStubProvider.tsx index 9f17dae723..a243fa87e3 100644 --- a/apps/nextjs-app/config/tests/I18nextTestStubProvider.tsx +++ b/apps/nextjs-app/config/tests/I18nextTestStubProvider.tsx @@ -18,7 +18,7 @@ i18n.use(initReactI18next).init({ }, // Let empty so you can test on translation keys rather than translated strings resources: { - en: { common: {}, system: {} } as Record>, + en: { common: {} } as Record>, }, }); diff --git a/apps/nextjs-app/e2e/pages/index/index-chinese.spec.ts b/apps/nextjs-app/e2e/pages/index/index-chinese.spec.ts index d98232f542..aa9bdc9736 100644 --- a/apps/nextjs-app/e2e/pages/index/index-chinese.spec.ts +++ b/apps/nextjs-app/e2e/pages/index/index-chinese.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import page404JsonZh from '@teable/common-i18n/locales/zh/system.json'; +import commonJsonZh from '@teable/common-i18n/locales/zh/common.json'; test.use({ locale: 'zh', @@ -9,6 +9,6 @@ test.describe('Demo page', () => { test('should have the title in english by default', async ({ page }) => { await page.goto('/'); const title = await page.title(); - expect(title).toBe(page404JsonZh.notFound.title); + expect(title).toBe(commonJsonZh.system.notFound.title); }); }); diff --git a/apps/nextjs-app/e2e/pages/index/index.spec.ts b/apps/nextjs-app/e2e/pages/index/index.spec.ts index 893bccdb35..d306a835c8 100644 --- a/apps/nextjs-app/e2e/pages/index/index.spec.ts +++ b/apps/nextjs-app/e2e/pages/index/index.spec.ts @@ -1,10 +1,10 @@ import { test, expect } from '@playwright/test'; -import page404JsonEn from '@teable/common-i18n/locales/en/system.json'; +import commonJsonEn from '@teable/common-i18n/locales/en/common.json'; test.describe('404 page', () => { test('should have the title in english by default', async ({ page }) => { await page.goto('/404'); const title = await page.title(); - expect(title).toBe(page404JsonEn.notFound.title); + expect(title).toBe(commonJsonEn.system.notFound.title); }); }); diff --git a/apps/nextjs-app/e2e/pages/system/404.spec.ts b/apps/nextjs-app/e2e/pages/system/404.spec.ts index 5bf624abdb..fc2513fc6b 100644 --- a/apps/nextjs-app/e2e/pages/system/404.spec.ts +++ b/apps/nextjs-app/e2e/pages/system/404.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import systemJsonEn from '@teable/common-i18n/locales/en/system.json'; +import commonJsonEn from '@teable/common-i18n/locales/en/common.json'; const pageSlug = 'this-page-does-not-exist'; @@ -7,6 +7,6 @@ test.describe('404 not found page', () => { test('should have the title in english any way', async ({ page }) => { await page.goto(`/${pageSlug}`); const title = await page.title(); - expect(title).toBe(systemJsonEn.notFound.title); + expect(title).toBe(commonJsonEn.system.notFound.title); }); }); diff --git a/apps/nextjs-app/src/AppProviders.tsx b/apps/nextjs-app/src/AppProviders.tsx index 6ba2f34fb3..3dfdd26724 100644 --- a/apps/nextjs-app/src/AppProviders.tsx +++ b/apps/nextjs-app/src/AppProviders.tsx @@ -2,7 +2,7 @@ import { ThemeProvider } from '@teable/next-themes'; import { ConfirmModalProvider } from '@teable/ui-lib'; import { Toaster as SoonerToaster } from '@teable/ui-lib/shadcn/ui/sonner'; import { Toaster } from '@teable/ui-lib/shadcn/ui/toaster'; -import { useRouter } from 'next/router'; +import { useSearchParams } from 'next/navigation'; import type { FC, PropsWithChildren } from 'react'; import type { IServerEnv } from './lib/server-env'; import { EnvContext } from './lib/server-env'; @@ -11,8 +11,8 @@ type Props = PropsWithChildren; export const AppProviders: FC = (props) => { const { children, env } = props; - const { query } = useRouter(); - const theme = query.theme as string; + const searchParams = useSearchParams(); + const theme = searchParams?.get('theme') ?? undefined; return ( { {t('table:kanban.toolbar.imageSetting')} {fieldId && (
-