Architecture
CRM — Architecture
The CRM is a dynamic object system: objects and fields are data, and each object gets its own physical table. A facade coordinates typed validation, persistence, and domain events so every write is consistent regardless of who made it.
The dynamic schema
- An object row (
objects) owns a dedicated table (object.table_name), built and altered at runtime by the object infrastructure (objects/infrastructure/:SchemaBuilder,SchemaGenerator,TypeMapper,DynamicQueryBuilder). - A field row (
fields) maps to a column. Itstype(one of 17) selects a field-type handler at runtime. - A record is a row in that table, with values keyed by field slug.
Because the schema is data, adding a field is a row insert plus an ALTER TABLE
— never a code change.
Request flow (write)
App ──REST──▶ DataService (facade) ──▶ Mutation service ──▶ dedicated table
│ │
│ └──▶ RecordEventsService.emit(data.*)
└──▶ DynamicValidationService ──▶ FieldTypeRegistry (per-type)POST/PUT /object/data/:objectSlug runs field validation, persists to the
object's table in a transaction, then emits a data.* event. Listeners
(audit changelog, real-time gateway, workflow engine, webhook fanout) pick it
up asynchronously. See Events.
Write locks
Some fields' values belong to the platform rather than to whoever is posting.
After validation and before the write, every mutation path drops values the
caller isn't entitled to set (records/mutations/helpers/write-lock.util.ts):
| Lock | Applies to | Who may still write it |
|---|---|---|
| Computed | calculation, serial_number | Nobody — the recalculation engine owns them |
| API-only | any field with api_only = true | Only an api origin (an API-key / OAuth caller) |
| Static system | source_name, session_name, provider_id | Trusted system origins (automation, system, api); never a person |
The origin is asserted server-side by write-origin.util.ts — from the
caller's own tag (ai, automation) or, failing that, from the API credential
the auth guard stamped on the request. It is never read from the request body,
so a payload cannot claim to be an API write. An explicit tag always wins, so an
AI tool running inside an API-key request stays ai and does not inherit that
credential's authority.
The locks are silent: the rest of the payload saves normally, matching the long-standing behaviour for computed fields. They apply on create, update, and bulk-update — bulk writes straight to the table without the validation pipeline, so it calls the same helpers explicitly.
A client renders the lock from api_only on the field, which every field-
carrying response exposes: GET /object/field/:objectSlug, GET /object/field/active/:objectSlug, and the fields[] array embedded in record
list / detail / create / update responses. Each of those reads must select the
column — FieldResponseDto coerces a missing one to false so the wire shape
stays stable, which means an omitted column reads as "not locked" rather than
as "unknown".
Workflow actions write via raw SQL and so enforce the same policy through a
separate predicate, workflow/constants/field-writability.ts. It backs both
the writable flag the fields endpoint exposes (what the builder UI disables)
and the executor's server-side guard (what stops a client that ignores it) — a
workflow may not write a platform-owned type, nor an api_only field, since an
automation is not the integration that owns it.
Import runs its own variant: an upload is never an API write, chat-linkage identifiers are dropped, but a deliberately mapped Source column is honoured.
source is also auto-stamped so it is never blank — manual on a record a
person created, import on an imported row with no Source column mapped, and
the provider value on messaging/lead-ads auto-create. manual and import are
permanent entries in every Source field's option catalog
(shared/seed-data/source-field-options.ts); an options edit may rename them
but not remove them.
Building blocks
| Layer | Where | Job |
|---|---|---|
| Facade | records/services/data.service.ts | The one interface controllers call for records |
| Query services | records/queries/ (list, search, lookup, single, facets) | Read paths, pagination, filtering |
| Mutation services | records/mutations/ (create, update, delete, bulk) | Write paths + event emit |
| Validation | fields/services/validation/ | DynamicValidationService + per-type validators |
| Field-type registry | _shared/field-types/ | Auto-discovered FieldType → handler map (validate + serialize) |
| Object infrastructure | objects/infrastructure/ | Build/alter the dynamic tables |
| Access filter | records/queries/helpers/access-filter.builder.ts | Row-level A/G/M/D scoping |
| Realtime | records/realtime/ (DataEventsGateway) | Push live record changes to the UI |
Dependency direction
Controllers → DataService (facade) → Query / Mutation services → dedicated table
→ DynamicValidationService → FieldTypeRegistry
→ RecordEventsService → EventEmitter → listenersControllers never touch a repository directly — they call the facade, which delegates. New read/write concerns get a new query/mutation service, not more controller logic.
Design principles
- Slug-first — every inbound/outbound reference is a slug; numeric ids stay internal. See the Data Model.
- Field-type registry — per-type behaviour (validation, serialization) is
auto-discovered via
@HandlesFieldType(...), not a hand-wired switch. A newFieldTypewithout a handler fails fast at boot. - Facade over services — one
DataServiceentry point keeps circular deps out and gives every caller the same validation + events. - Events, not inline side-effects — audit, realtime, workflow, and webhooks
all hang off
data.*events, so a mutation path doesn't know its consumers. - Response DTOs via
@Exclude/@Expose+plainToInstance;paginationis always top-level.
The module is organized by domain subfolder (records/, fields/, objects/,
relations/, views/, comments/, export-import/, shared/), each owning
its own controllers, services, and DTOs.