- Updated RecordCreateDialog and RecordUpdateDialog components to improve layout responsiveness and user experience. - Modified dialog content to ensure proper flexbox usage, allowing for better handling of dynamic content. - Enhanced normalization logic for select field values in RecordUpdateDialog, improving data handling for both single and multiple select fields. - Updated instructional text for clarity in RecordCreateDialog. These changes enhance the usability and functionality of the record creation and update dialogs in the playground.
20 KiB
Teable v2 (DDD) agent guide
This repo is introducing a new packages/v2/* architecture. Keep v2 strict and boring: domain first, interfaces first, Result-only errors, specifications for querying, builders/factories for creation.
Git hygiene
- Ignore git changes that you did not make by default; never revert unknown/unrelated modifications unless explicitly instructed.
v2 layering (strict)
packages/v2/core is the domain/core.
- Allowed dependencies (inside
v2/core)neverthrowforResultzodfor validation (safeParseonly)nanoidfor ID generationts-patternfor match pattern@teable/formulafor formula parsing utilities@teable/v2-diis allowed only insrc/commands/**(application wiring), not in domain- Pure TS/JS standard library
- Forbidden inside
v2/core- No NestJS, Prisma, HTTP, queues, DB clients, file system, env access
- No direct infrastructure code
- No dependency on v1 core (
packages/core/@teable/core) - No direct
antlr4tsimports (use@teable/formulare-exports) - No
throw/ exceptions for control flow
Future adapters live in their own workspace packages under packages/v2/* and depend on @teable/v2-core (never the other way around).
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 fromapps/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/uibefore using the primitive.
Dependency injection (DI)
- Do not import
tsyringe/reflect-metadatadirectly 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.
Tracing (DDD)
- Tracing is an application/port concern; never use tracing decorators or tracer interfaces in
packages/v2/core/src/domain/**. - Use
@TraceSpan(...)only inpackages/v2/core/src/commands/**(and other app handlers) to wrap spans. - Real tracing happens in adapters by supplying an
ITracerimplementation; core defaults should be no-op. - Keep span attributes minimal and avoid PII unless explicitly required.
Command/Event mediator (bus)
- Command handlers are registered via
@CommandHandler(Command)and invoked throughICommandBus.execute(...). - Event handlers are registered via
@EventHandler(Event)and invoked throughIEventBus.publish(...)/publishMany(...). - Do not resolve handlers directly from the container in business code or adapters; always go through the bus.
ICommandBus/IEventBusare ports; default in-memory implementations live inv2/core/src/ports/memoryand can be swapped by adapters (RxJS/Kafka/etc).- Containers must register
v2CoreTokens.commandBusandv2CoreTokens.eventBus.
Query mediator (bus)
- Query handlers are registered via
@QueryHandler(Query)and invoked throughIQueryBus.execute(...). - Do not resolve query handlers directly from the container in business code or adapters; always go through the bus.
IQueryBusis a port; default in-memory implementations live inv2/core/src/ports/memory.- Containers must register
v2CoreTokens.queryBus.
Unit of work (transactions)
- Cross-repository workflows in commands must be wrapped in
IUnitOfWork.withTransaction(...). - Repositories should reuse
IExecutionContext.transactionwhen present (do not start nested transactions). - Postgres implementation lives in
@teable/v2-adapter-db-postgres-pg(PostgresUnitOfWork,PostgresUnitOfWorkTransaction); register it in containers. - Publish domain events only after transactional work succeeds.
Build tooling (v2)
- v2 packages build with
tsdown(nottscemit).tscis used only fortypecheck(--noEmit). - Each v2 package has a local
tsdown.config.tsthat 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
typesfield to"src/index.ts"(not"dist/index.d.ts") - Set
exports["."].typesto"./src/index.ts"(not"./dist/index.d.ts") - Set
exports["."].importto"./src/index.ts"(not"./dist/index.js") to allow Vite/Vitest to use source files directly - Keep
exports["."].requirepointing to"./dist/index.cjs"for CommonJS compatibility - Include
"src"in thefilesarray (in addition to"dist")
- Set
- In
tsconfig.json:- Map workspace dependencies to their
srcpaths incompilerOptions.paths(e.g."@teable/v2-core": ["../core/src"]) - Include those source paths in the
includearray
- Map workspace dependencies to their
Example package.json configuration:
{
"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.
Error handling (non-negotiable)
- Never throw in
v2/core. - Use
neverthrowResulteverywhere. - If something isn’t implemented yet: return
err('Not implemented')(or a typed error string). - Use
zod.safeParse(...)and convert failures intoerr(...)(noparse()).
Type system rules (non-negotiable)
Inside v2/core domain APIs:
- Do not use raw primitives (
string,number,boolean) as domain parameters/returns for domain concepts. - Use Value Objects / branded types for IDs, names, and key concepts.
- IDs are nominal (not structurally compatible):
FieldIdmust not be assignable toViewId. - Raw primitives are allowed only at the outer boundary (DTOs) and must be immediately validated and converted via factories/builders.
Practical exceptions that are required by the architecture:
neverthrowerror side uses strings (e.g.Result<T, string>).- The Specification interface requires
isSatisfiedBy(...): boolean. - Value Objects may expose
toString()/toDate()/toNumber()for adapter/serialization boundaries (avoid using these in domain logic). - Rehydration-only Value Objects (e.g.
DbTableName,DbFieldName) must extendRehydratedValueObject; create empty placeholders in domain, set real values only via repository rehydrate, and returnerr(...)when accessed before rehydrate.
Builders/factories (non-negotiable)
- Do not
new Table()/new Field()/new View()outside factories/builders. - Table creation must go through the TableBuilder (the public creation API).
- Value Objects are created via static factory methods that validate with
zod. - Builder configuration methods should be fluent (return the builder) and must not throw; validation/creation errors are surfaced via
build(): Result<...>.
Specification pattern (required)
Repositories query via specifications, not ad-hoc filters.
- Implement
ISpecificationexactly as defined inv2/core. - Provide composable specs (
AndSpec,OrSpec,NotSpec). accept(visitor)is wired for future translation into persistence queries.- Build specs via entity spec builders (e.g.
Table.specs(baseId)); do notnewspec classes directly. - Each spec targets a single attribute (e.g.
TableByNameSpeconly checks name).BaseIdis its own spec and is composed viaand/or/not. andandormust be separated by nesting (useandGroup/orGroup); never mix them at the same level. BaseId specs are auto-included by the builder unless explicitly disabled.- Spec visitors rely on
visit(spec)+ type narrowing inside the visitor; avoid per-spec visitor interfaces orisWith*guards.
Visitor pattern (preferred)
- For multi-type logic that already has a visitor (fields, specs, etc.), prefer a dedicated visitor file over switch/if chains.
- Keep type-to-value mappings in visitors so new types require explicit visitor updates.
Condition/Filter handling (non-negotiable)
Record conditions are a core domain concept. All condition/filter logic MUST use the specification + visitor pattern. Never directly parse or interpret condition objects with switch/if/match chains.
Required pattern
- Domain: Conditions are modeled as
RecordConditionSpec(specification pattern) - Creation: Use
FieldConditionSpecBuilderorRecordConditionSpecBuilderto create specs - Translation: Use
ITableRecordConditionSpecVisitorimplementations to translate specs (e.g. to SQL WHERE clauses)
// ✅ CORRECT: Use spec + visitor pattern
const spec = yield* condition.toRecordConditionSpec(table);
const visitor = new TableRecordConditionWhereVisitor();
const whereClause = yield* spec.accept(visitor);
// ❌ WRONG: Direct parsing with match/switch
for (const item of condition.filterItems()) {
match(item.operator)
.with('is', () => sql`${col} = ${val}`)
.with('isNot', () => sql`${col} != ${val}`)
// ... duplicates visitor logic
}
Why this matters
- Single source of truth: All operator logic lives in one visitor
- Type safety: Adding new operators requires updating the visitor interface (compile-time errors)
- Consistency: Same behavior across all condition consumers (views, computed fields, API filters)
- Testability: Visitor behavior is tested once with full coverage
Key files
v2/core/src/domain/table/records/specs/RecordConditionSpec.ts- Base spec classesv2/core/src/domain/table/records/specs/ITableRecordConditionSpecVisitor.ts- Visitor interfacev2/core/src/domain/table/records/specs/FieldConditionSpecBuilder.ts- Creates specs from field + operator + value- Adapter visitors (e.g.
TableRecordConditionWhereVisitor) - Translate to SQL
Folder conventions (recommended)
Inside packages/v2/core/src:
domain/— aggregates, entities, value objects, domain eventsspecification/— spec framework + visitorsports/— interfaces/ports (repositories, event bus/publisher, mappers)commands/— commands + handlers (application use-cases over domain)queries/— queries + handlers (application read use-cases over domain)
Architecture docs (required)
- When adding or changing a significant folder/module, create or update its
ARCHITECTURE.md. - If subfolders change, also update the parent
ARCHITECTURE.mdto keep the folder map accurate.
Naming conventions
- Value Objects:
*Id,*Name(e.g.TableId,FieldName) - Commands:
*Command - Queries:
*Query - Handlers/use-cases:
*Handler - Domain events: past tense (e.g.
TableCreated) - Specifications:
*Spec(e.g.TableByIdSpec)
Adding a new field type
- Add a new field subtype under
domain/table/fields/types/. - Add any new value objects/config under the same subtree.
- Extend the table builder with a new field child-builder:
- add
TableFieldBuilder.<newType>()(indomain/table/TableBuilder.ts) - implement a
<NewType>FieldBuilderwith fluentwith...()methods anddone(): TableBuilder
- add
- Update
IFieldVisitor(and any visitors likeNoopFieldVisitor) to support the new field subtype. - Update
CreateTableCommandinput validation to allow the new type.
Adding a repository adapter later
- Keep the port in
v2/core/src/ports/TableRepository.ts. - Implement the adapter in a separate package (e.g.
packages/v2/adapter-repository-postgres). - Translate Specifications via a visitor (start with the stub visitor in
v2/core). - Map persistence DTOs <-> domain using mapper interfaces from
v2/core/src/ports/mappers.
Testing expectations (minimal)
Type-exhaustive testing with it.each (required)
When testing behavior that varies by type (e.g. field types, cell value types, view types), use it.each with a type-safe exhaustive matrix. This ensures:
- All current types are tested
- TypeScript errors when new types are added but not covered in tests
- Clear per-type test output
Pattern
// 1. Define the type literal from the source of truth
type FieldTypeLiteral = (typeof fieldTypeValues)[number];
// 2. Define test case interface
interface InnerFieldTestCase {
type: FieldTypeLiteral;
factory: (id: FieldId, name: FieldName) => Result<Field, DomainError>;
expectedCellValueType: 'string' | 'number' | 'boolean' | 'dateTime';
}
// 3. Create exhaustive map - TypeScript errors if any type is missing
const createTestCases = (): Record<FieldTypeLiteral, InnerFieldTestCase> => ({
singleLineText: { type: 'singleLineText', factory: ..., expectedCellValueType: 'string' },
number: { type: 'number', factory: ..., expectedCellValueType: 'number' },
// ... ALL other types must be listed
});
// 4. Compile-time exhaustiveness check
const _exhaustiveCheck: Record<FieldTypeLiteral, InnerFieldTestCase> = createTestCases();
void _exhaustiveCheck;
// 5. Use it.each for matrix testing
describe('inner field types matrix', () => {
const testCases = Object.values(createTestCases());
it.each(testCases)(
'creates lookup field with $type inner field',
({ type, factory, expectedCellValueType }) => {
// Test implementation
}
);
});
When to use
- Testing field type-specific behavior (e.g. LookupField with different inner field types)
- Testing view type-specific rendering/behavior
- Testing cell value type conversions
- Any scenario where behavior varies across a finite set of types
Benefits
- Type safety: Adding a new field type to
fieldTypeValueswill cause TypeScript to error in tests until the new type is added to the test matrix - Completeness: Every type variant is explicitly tested
- Maintainability: Clear structure for adding new types
- Readability: Test output shows which specific type failed
See LookupField.spec.ts for a complete example.
Testing strategy (domain → e2e)
v2 uses a layered test strategy. The same behavior should usually be asserted once at the most appropriate layer (avoid duplicating identical assertions across many layers).
1) Domain unit tests (v2/core domain)
Where
packages/v2/core/src/domain/**/*.spec.ts
Focus
- Value Object validation (
.create(...)+zod.safeParse) - Aggregate/entity behavior and invariants
- Builder behavior (
Table.builder()...build()), including default view behavior - Domain event creation/recording (e.g.
TableCreated) - Specification correctness for in-memory satisfaction (
isSatisfiedBy)
Must NOT do
- No DI/container, no repositories/ports, no DB, no HTTP, no filesystem, no timeouts
- No infrastructure DTOs (HTTP/persistence) and no framework code
What to assert
Resultisok/err(never exceptions)- Invariants on returned domain objects (counts, names, IDs are nominal types, etc.)
- Domain events are produced and contain essential info (do not snapshot the entire object)
2) Application/use-case tests (v2/core commands + DI)
Where
- Prefer
packages/v2/test-node/src/**/*.spec.ts(a dedicated test package)
Focus
- Handler orchestration (build aggregate, call repository, publish events)
- Correct
Resultbehavior for ok/err paths - Command-level validation (invalid input →
err(...)) - Correct wiring via DI (handlers resolved from container; do not
new Handler(...)in tests)
Allowed
- Fakes/in-memory ports (recommended) OR the node-test container (pglite-backed) when you want a slightly higher-confidence integration without HTTP.
What to assert
- Handler returns expected status (
ok/err) and minimal returned data (e.g. created table name) - Domain events were published (e.g. contains
TableCreated) - Repository side-effect happened (either “save called” via fake, or “can be queried back” via
findOne(spec))
3) Adapter integration tests (persistence/infra adapters)
Where
packages/v2/adapter-*/src/**/*.spec.ts
Focus
- Spec → query translation via Spec Visitors (no ad-hoc where parsing)
- Mapper correctness (persistence DTO ↔︎ domain)
- Repository behavior against a real DB driver
Allowed
pglitefor tests (fast, hermetic)
What to assert
- Round-trips: save → query by spec → domain object matches essentials
- Visitor builds the expected query constraints (at least for supported specs)
4) Contract tests (contract-http)
Where
packages/v2/contract-http/src/**/*.spec.ts(optional but recommended for mapping-heavy endpoints)
Focus
- DTO mappers and endpoint executors
- Contract response shapes and status codes
What to assert
execute*Endpoint(...)returns only the status codes declared in the contract- Response DTO structure matches schema intent (avoid deep snapshots)
5) Router adapter tests (Express/Fastify)
Where
packages/v2/contract-http-express/src/**/*.spec.tspackages/v2/contract-http-fastify/src/**/*.spec.ts
Focus
- Framework glue: request parsing, ts-rest integration, error mapping
- Container creation is correct and lazy (don’t eagerly connect to PG when a custom container is injected)
What to assert
- Valid request → expected status/result
- Invalid request → 400 (schema validation)
6) E2E tests (v2/e2e)
Where
packages/v2/e2e/src/**/*.e2e.spec.ts
Focus
- “Over-the-wire” HTTP behavior using the generated ts-rest client
- Cross-package integration: router + contract + container + repository adapter
Allowed
- Start an in-process server on an ephemeral port (no fixed ports)
- Use the node-test container with
pgliteand ensure proper cleanup (dispose)
What to assert
- HTTP status codes and response DTOs (validate shape, not internal domain objects)
- Minimal business outcome (e.g. table created, includes
TableCreatedevent)
Quality checks (required for big changes)
- After substantial changes, run
pnpm -C packages/v2/<pkg> typecheckandpnpm -C packages/v2/<pkg> lintfor each affected package and report any failures. - Resolve any TypeScript errors before marking the task complete.
- Run eslint with auto-fix (
pnpm -C packages/v2/<pkg> lint -- --fixorpnpm -C packages/v2/<pkg> fix-all-files) and format the touched files before completion. - Every task completion must include formatting/fix steps for touched v2 packages (eslint auto-fix or equivalent).
- When working with libraries, check the docs with Ref mcp