snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,285 @@
# Proposal: Migrate the Apps-Engine converters to Zod codecs
## Status
In progress (PR #41205). Phases 0–5 landed; `transformMappedData` removed. See the per-phase
notes below, including the messages/threads deviation in Phase 4.
## Problem
`apps/meteor/app/apps/server/converters/` translates data between two models in both
directions:
- **Rocket.Chat → Apps-Engine** ("to app"): Mongo documents (`IUser`, `IRoom`, `IMessage`, …)
become Apps-Engine objects (`IAppsUser`, `IAppsRoom`, `IAppsMessage`, …). Methods such as
`convertToApp` / `convertRoom` / `convertMessage`.
- **Apps-Engine → Rocket.Chat** ("from app"): the reverse. Methods such as `convertToRocketChat` /
`convertApp*`.
Today the two directions are implemented by two unrelated mechanisms:
- The **to-app** direction is driven by `transformMappedData`, a declarative field remapper
(`{ to: 'from' | fn | { from, map, list } }`) that also collects everything it did not map into an
`_unmappedProperties_` bucket.
- The **from-app** direction is hand-written object construction with long chains of
`...(cond && { … })` spreads, re-merging `_unmappedProperties_` at the end.
This split has real costs:
1. **Two sources of truth per entity.** The field mapping is expressed once in a `map` and again,
inverted, in a hand-written builder. They drift.
2. **No runtime validation at the boundary.** The from-app path trusts whatever an app sends and
spreads it onto a Mongo document.
3. **Weak typing.** Several converters were untyped `.js`; the mapping is stringly-typed and the
relationship between the two directions is invisible to the compiler.
### Inventory
| Converter | Was | To-app | From-app | Async / DB | Notable hazards |
|--------------------|-----------|:------:|:--------:|:----------:|-----------------|
| `settings` | js → ts | ✅ | — | sync | `SettingType` enum |
| `roles` | ts | ✅ | — | sync | trivial |
| `videoConferences` | ts | ✅ | ✅ | sync | pass-through clone |
| `visitors` | js → ts | ✅ | ✅ | sync | `_unmappedProperties_` bucket |
| `departments` | js → ts | ✅ | ✅ | sync | `_unmappedProperties_` bucket |
| `users` | js → ts | ✅ | ✅ | sync | `UserType` / `UserStatusConnection` enums, contextual `console.warn` |
| `uploads` | js → ts | ✅ | ✅ | **async** | cross-converter (rooms/users/visitors) |
| `rooms` | js → ts | ✅ | ✅ | **async** | cross-converter fan-out, `isPartial`, `secureFieldsMapper`, `RoomType` enum |
| `messages` | js → ts | ✅ | ✅ | **async** | `WeakMap` + `cachedFunction` memoization, `isPartial`, attachment sub-map, visitor-sender fallback |
| `threads` | ts | ✅ | — | **async** | duplicates the messages attachment map |
| `contacts` | ts | ✅ | ✅ | sync | deep nested `list` reverse map |
Infrastructure: `transformMappedData.ts`, `cachedFunction.ts`, `convertMessageFiles.ts`.
## Proposed Solution
Model each entity as a **Zod codec** — a single bidirectional artifact that replaces the split
"map + hand-written inverse" pair and adds runtime validation.
Zod 4.3 (`~4.3.6`) is already a dependency, and the repo already ships a codec —
`TimestampSchema` in `packages/core-typings/src/utils.ts`:
```ts
export const TimestampSchema = z.codec(z.iso.datetime(), z.date(), {
encode: (date) => date.toISOString(),
decode: (str) => new Date(str),
});
```
`z.codec(RcSchema, AppSchema, { decode, encode })` maps directly onto our two directions:
- `decode` : Rocket.Chat → Apps-Engine (today's `convertToApp` / `convertRoom` / …)
- `encode` : Apps-Engine → Rocket.Chat (today's `convertToRocketChat` / `convertApp*`)
`z.decode(codec, x)` / `z.encode(codec, x)` run them synchronously; `z.decodeAsync` / `z.encodeAsync`
run them when the transforms are async.
The converter **classes stay** as the public façade (they implement the `IAppXConverter` interfaces
and are reached via `orch.getConverters().get('x')`, used in ~25 files). Codecs are an internal
implementation detail those methods delegate to.
## Key design decisions
1. **Async transforms → `z.decodeAsync` / `z.encodeAsync`.** rooms/messages/uploads/threads perform
DB lookups inside the mapping. Their codec transforms are async and must be driven with the async
entry points; the sync `z.decode` throws `$ZodAsyncError` on an async codec. Sync converters
(settings, roles, users, visitors, departments, contacts, videoConferences) use plain
`z.decode` / `z.encode`. *Validated against the installed Zod build during Phase 0.*
2. **Orchestrator dependency → codec factories.** Cross-converter converters cannot be static
singletons — they need `orch`. Expose `createRoomCodec(orch)`, `createUploadsCodec(orch)`, etc.,
returning a closure-bound codec. DB-free converters export a static codec constant.
3. **Preserve the `_unmappedProperties_` contract — do not drop it.** It is load-bearing: the reverse
converters merge it back, the EE redactor (`ee/server/apps/lib/redactor.ts`) references the path,
and `RoomBridge` reads it. A plain `z.object` strips unknown keys; `z.looseObject` keeps them
inline but *without* the bucket. We will build a small reusable helper (working name
`mappedCodec`) that reproduces `transformMappedData`'s bucket semantics exactly, so output is
byte-identical. This keeps the migration behaviour-preserving rather than a behaviour change.
The helper is intentionally **not** built up-front — it will be co-designed with the first
bidirectional converter (Phase 2) to avoid guessing the abstraction.
4. **`isPartial` (rooms/messages from-app) stays in the class method, not the codec.** Partial mode
skips required-field generation, skips the unmapped merge, and strips `undefined`. Model it as the
class calling either `z.encode` (full) or a partial path; do not encode the flag into the schema.
5. **Enum conversions → shared codec constants.** `UserType`, `UserStatusConnection`, `RoomType`,
`SettingType` become small codecs (like `TimestampSchema`) in `converters/codecs/enums.ts`,
reproducing the current `switch` logic including the pass-through/upper-case fallbacks.
Contextual `console.warn` calls that need data unavailable to a pure enum mapping (e.g. the
affected user's id/username in the status-connection warning) stay in the converter layer.
6. **Memoization (messages/threads) stays.** The `WeakMap` + `cachedFunction` dedup of user/room
lookups within a single conversion is preserved by constructing the message codec per-conversion
through the factory, passing in the memoized lookups.
7. **Loose validation on the from-app path.** Apps send arbitrary data today; strict schemas would
reject payloads that currently pass. Use `.loose()` / `.optional()` generously on the app-side
schemas so the migration introduces no rejections. Tightening is a deliberate, separate follow-up.
8. **Preserve the public surface.** The `IAppXConverter` interfaces and the
`orch.getConverters().get('x')` usage must not change.
### Direction convention (illustrative)
```ts
const UserCodec = z.codec(UserRocketChatSchema, AppsUserSchema, {
decode: (user) => ({ id: user._id, /* … */ }), // convertToApp
encode: (appUser) => removeEmpty({ _id: appUser.id, /* … */ }), // convertToRocketChat
});
// RC -> App: z.decode(UserCodec, user)
// App -> RC: z.encode(UserCodec, appUser)
```
## Migration phases
Each phase is independently shippable, keeps the class façade, and is gated by the golden tests.
### Phase 0 — Scaffolding & de-risk (done, PR #41205)
- Converted the 7 remaining `.js` converters to `.ts`, behaviour-preserving. `rooms`/`messages` keep
loose typing on their transform maps for now; that tightens when each is codec-ified.
- Fixed three pre-existing latent bugs that surfaced once the converters were typed (see
[Incidental fixes](#incidental-fixes)). These are the only intentional behaviour changes in the
phase and are each pinned by a golden/focused test.
- Added `converters/codecs/` with the first shared primitives: bidirectional enum codecs
(`UserType`, `UserStatusConnection`, `RoomType`, `SettingType`).
- Added the **behavioural safety net**: enum-codec unit tests (asserting parity with the legacy
helpers) and **golden-snapshot tests** locking the current RC ↔ Apps-Engine field mapping,
including `_unmappedProperties_` bucketing, for settings, users, visitors, departments, roles,
videoConferences, contacts and uploads.
- Validated the async-codec approach (`z.decodeAsync` / `z.encodeAsync`, `$ZodAsyncError`) that
Phases 3–4 depend on.
#### Incidental fixes
Typing the converters exposed three latent bugs in the legacy code. They are small, self-contained,
and fixed in Phase 0 rather than carried forward as "behaviour to preserve":
1. **`users` — `utfOffset` typo (from-app).** `convertToRocketChat` read the misspelled
`appUser.utfOffset`, so a round-tripped user always lost its `utcOffset`. It now reads
`utcOffset` first and keeps `utfOffset` only as a compatibility fallback for any app still
emitting the old key. The golden round-trip fixture uses `utcOffset`; a focused test pins the
legacy fallback.
2. **`messages` — dead visitor-sender fallback (to-app).** The `sender` resolver deleted
`message.u` *before* the "old system message without token" fallback re-read it, so the fallback
always received `undefined`. It now captures the sender before deletion. A focused test drives a
missing primary lookup and asserts the fallback resolves the original sender.
3. **`messages` — null editor deref (from-app).** `convertAppMessage` dereferenced
`Users.findOneById(editor.id)` through a non-null assertion, throwing if the editor no longer
exists. It now falls back to the editor data carried on the app payload, mirroring the adjacent
sender handling.
Alongside these, a few defensive guards were added where a destructuring or lookup could hit an
optional value that the types claimed was always present (`uploads` `rid`/`room`, `users`
`convertByUsername`, `rooms` `visitorChannelInfo`). These only turn a latent `TypeError` into the
already-intended empty result and match the guards their sibling fields already had, so they change
no covered output.
### Phase 1 — Trivial, one-way, sync
`settings`, `roles`, `videoConferences`. Proves the codec + enum-codec pattern end-to-end at the
lowest risk.
### Phase 2 — Self-contained, bidirectional, sync
`visitors`, `departments`, `users`. Introduces and hardens the `mappedCodec` unmapped-bucket helper
and exercises the enum codecs with the `console.warn` side effects preserved in both directions.
### Phase 3 — Async, cross-converter
`uploads`, then `rooms`. Introduces the codec-factory pattern, `z.decodeAsync` / `z.encodeAsync`,
`isPartial` handled in the class layer, and `secureFieldsMapper` integration.
### Phase 4 — Async + memoized + nested (done)
`messages`, `threads` and `contacts`. `contacts` became a clean bidirectional `ContactCodec`
(`decode` clones; `encode` maps every attribute via the nested field map). `mappedDecodeAsync` was
extended with the nested `{ from, map, list }` branch so it fully reproduces `transformMappedData`.
**Deviation — messages and threads are not standalone codecs.** Unlike every other converter, these
two were migrated by swapping their `transformMappedData` calls for the shared `mappedDecodeAsync`
helper *in place*, rather than being wrapped in `create*Codec` factories. Reasons:
1. **Test harness.** `messages.tests.js` uses `proxyquire` to stub `@rocket.chat/models`,
`@rocket.chat/random` and `@rocket.chat/core-typings` (`isMessageFromVisitor`) on the `messages.ts`
module. `proxyquire` only replaces a module's *own* `require`s, so moving `convertAppMessage`'s
model/`Random` usage into a separate `codecs/messages.ts` module would bypass the stubs and break
the test (real `Rooms.findOneById` → no `GENERAL` room → the "proper schema"/"invalid room" cases
fail). Keeping that usage in the class preserves the harness without editing it.
2. **Poor fit for the codec shape.** `convertMessage` builds its field map per call, closing over a
per-message memoization cache (`WeakMap` + `cachedFunction`), the source `msgObj`
(`isMessageFromVisitor`) and `mainFile`; `convertAppMessage` adds an `isPartial` flag and
`Random.id`/`new Date()` defaults. None of this expresses cleanly as a static bidirectional
`z.codec`, and forcing it would add casts and risk for no real gain.
The migration goal for these two is therefore narrower but still met: the `transformMappedData`
dependency is removed and the mapping logic is the shared, tested `mappedDecodeAsync`. The
attachment sub-maps stayed per-converter because messages' and threads' variants differ (threads has
extra `author`/`timestamp`/`fileId` guards) — sharing them would change behaviour. Wrapping messages
in a codec later remains possible (e.g. via `proxyquire`'s `@global` stubs) if desired.
### Phase 5 — Cleanup (done)
Every converter is off `transformMappedData`, so `transformMappedData.ts` was deleted and its
(previously importer-located) spec was retired — its coverage now lives in the `mappedDecodeAsync`
tests under `tests/unit/app/apps/server/codecs/`. `cachedFunction` stays: it is still used by the
messages and threads memoization, which remained in the class layer (see the Phase 4 deviation).
## Testing strategy
- **Golden snapshots** (added in Phase 0) are the equivalence oracle: each phase must keep them green
while swapping internals. Output is compared after `JSON.parse(JSON.stringify(...))` so Dates
normalise to ISO strings and `undefined` fields drop — matching how payloads cross the app bridge.
- **Enum-codec unit tests** assert `decode`/`encode` match the legacy `_convert*` helpers for every
input, including fallbacks.
- The existing `rooms.tests.ts` and `messages.tests.js` remain the oracle for the two hardest
converters.
- New codecs get their own focused tests as they are introduced.
## Risks
- **`_unmappedProperties_` fidelity** — the single biggest behavioural trap; the `mappedCodec` helper
plus golden tests are the mitigation.
- **From-app validation rejecting live app payloads** — mitigated by loose schemas; do not tighten
during the migration.
- **Async codec ergonomics** — de-risked in Phase 0, but the factory wiring for rooms/messages should
still be spiked before committing to Phase 3.
- **Test rewrite** — the existing tests use `proxyquire.noCallThru()` against module paths; codec-ing
each converter means updating those loaders.
## Type strategy for the mapping helpers
The mapping helpers in `converters/codecs/mappedData.ts` are split by how well the source type is
known, which is what makes generic typing worthwhile in some cases and not others:
- **Sync string maps → fully generic and inferred.** `mappedDecode<Source>(fieldMap)` and
`createMappedCodec<Source>(fieldMap)` are generic over the source document type. The field map is
typed `FieldMap<Source> = Record<string, Extract<keyof Source, string>>`, so a renamed or
misspelled source field is a **compile error** (a `@ts-expect-error` test locks this). The decode
result is the inferred `Decoded<Source, Map>` — each renamed target as an optional property plus the
`_unmappedProperties_` bucket typed as `Omit<Source, mappedKeys>` — rather than `Record<string, any>`.
`mappedEncode` is the inverse and returns `Partial<Source>`. `Source` defaults to a loose record, so
untyped call sites (e.g. tests) still work. Consumers with clean document types (`visitors`,
`departments`, `roles`) pass their `Source` and get the typo-safety for free.
- **Async/function/nested maps → loose map, generic result.** `mappedDecodeAsync` keeps its
`AsyncFieldMap` loosely typed on purpose: its consumers (`rooms`, `messages`, `uploads`, `contacts`)
map many optional/livechat-only fields that are *not* on the base document types (which is why the
original converters used dynamic access), and the return shape depends on arbitrary function/nested
entries. Fully inferring that would resurrect the large conditional type the old helper avoided. It
does take a `Result` type parameter — `mappedDecodeAsync<IAppsUpload>(data, map)` — so call sites
assert the produced shape once instead of trailing every call with `as unknown as Promise<…>`.
- **Each apps-type boundary keeps a single, documented cast.** The Apps-Engine interfaces
(`IAppsVisitor`, …) are hand-authored and don't structurally equal the produced `Decoded`/mapped
shape, so each affected codec boundary — supplied role, department, user and visitor — retains one
localized `as unknown as <AppsType>` where the codec meets that interface. Those casts are the
deliberate loose-validation seams (see design decision 7), not incidental looseness.
## Non-goals
- Changing the `IAppXConverter` interfaces or any app-facing behaviour.
- Tightening the from-app validation surface (tracked as a separate follow-up).
- Migrating converters that are not in the directory above.