Skip to content

Commit 5b2db8a

Browse files
clauded-gubert
authored andcommitted
refactor(apps): make the mapped-codec helpers generic and type-safe
Improves type declarativeness of the shared mapping helpers; no runtime change (type-only), all converter tests still pass. - `mappedDecode` / `mappedEncode` / `createMappedCodec` are now generic over the source document type. The field map is typed `FieldMap<Source>` (values constrained to `keyof Source`), so a misspelled/renamed source field is a compile error, and the result is the inferred `Decoded<Source, Map>` (renamed optional targets + typed `_unmappedProperties_` bucket) instead of `Record<string, any>`. `Source` defaults to a loose record so untyped call sites still work. - `visitors`/`departments`/`roles` pass their `Source` type to get the typo-safety; a `@ts-expect-error` test locks the constraint. - `mappedDecodeAsync` stays loose on the map (its consumers map dynamic livechat-only fields) but gains a `Result` type parameter, removing the `as unknown as Promise<…>` casts at uploads/rooms/messages/threads/contacts. - Document the type strategy in the proposal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185hwJYnx3nzP4pcrYM2XXj
1 parent 6f87262 commit 5b2db8a

11 files changed

Lines changed: 126 additions & 57 deletions

File tree

apps/meteor/app/apps/server/converters/codecs/contacts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,6 @@ export const ContactCodec = z.codec(z.custom<ILivechatContact>(), z.custom<IApps
9898
importIds: 'importIds',
9999
};
100100

101-
return mappedDecodeAsync(contact, map) as unknown as Promise<ILivechatContact>;
101+
return mappedDecodeAsync<ILivechatContact>(contact, map);
102102
},
103103
});

apps/meteor/app/apps/server/converters/codecs/departments.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { mappedDecode } from './mappedData';
1212
* unconditionally, then `_unmappedProperties_` merged on top.
1313
*/
1414
export const DepartmentCodec = z.codec(z.custom<ILivechatDepartment>(), z.custom<IAppsDepartment>(), {
15-
decode: mappedDecode({
15+
decode: mappedDecode<ILivechatDepartment>({
1616
id: '_id',
1717
name: 'name',
1818
email: 'email',
Lines changed: 68 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,113 @@
11
import * as z from 'zod';
22

3+
type Loose = Record<string, any>;
4+
35
/**
4-
* A declarative field map: keys are the Apps-Engine (target) property names, values are the
5-
* Rocket.Chat (source) property names. This is the "string" entry form supported by
6-
* {@link mappedDecodeAsync}.
6+
* A declarative field map from Apps-Engine (target) property names to Rocket.Chat (source) property
7+
* names. Values are constrained to the keys of `Source`, so mapping a renamed or misspelled source
8+
* field is a compile error.
79
*/
8-
export type FieldMap = Record<string, string>;
10+
export type FieldMap<Source> = Record<string, Extract<keyof Source, string>>;
911

10-
type Loose = Record<string, any>;
12+
/**
13+
* The Rocket.Chat -> Apps-Engine result of decoding `Source` through `Map`: each target property
14+
* holds its source value (optional, since it is only set when the source value is defined), and
15+
* everything the map did not name is collected under `_unmappedProperties_`.
16+
*/
17+
export type Decoded<Source, Map extends FieldMap<Source>> = {
18+
-readonly [Target in keyof Map]?: Source[Map[Target]];
19+
} & {
20+
_unmappedProperties_: Omit<Source, Map[keyof Map]>;
21+
};
1122

1223
/**
13-
* The Rocket.Chat -> Apps-Engine transform for a plain string field map, reproducing the behaviour
14-
* of the legacy `transformMappedData` for the common "rename these fields, bucket the rest" case:
15-
* the input is deep-cloned (so the app can never mutate the stored document), each mapped source
16-
* field is copied to its target name when defined, and every remaining property is collected into
17-
* `_unmappedProperties_`.
24+
* Rocket.Chat -> Apps-Engine transform for a plain string field map, reproducing the "rename these
25+
* fields, bucket the rest" behaviour of the original mapping helper: the input is deep-cloned (so the
26+
* app can never mutate the stored document), each mapped source field is copied to its target name
27+
* when defined, and every remaining property is collected into `_unmappedProperties_`.
1828
*/
19-
export function mappedDecode(fieldMap: FieldMap): (data: Loose) => Loose {
20-
const appKeys = Object.keys(fieldMap);
29+
export function mappedDecode<Source extends Loose = Loose, const Map extends FieldMap<Source> = FieldMap<Source>>(
30+
fieldMap: Map,
31+
): (data: Source) => Decoded<Source, Map> {
32+
const entries = Object.entries(fieldMap) as [string, string][];
2133

22-
return (data: Loose): Loose => {
34+
return (data: Source): Decoded<Source, Map> => {
2335
const clone: Loose = structuredClone(data);
2436
const result: Loose = {};
2537

26-
for (const appKey of appKeys) {
27-
const sourceKey = fieldMap[appKey];
28-
38+
for (const [target, sourceKey] of entries) {
2939
if (typeof clone[sourceKey] !== 'undefined') {
30-
result[appKey] = clone[sourceKey];
40+
result[target] = clone[sourceKey];
3141
}
3242

3343
delete clone[sourceKey];
3444
}
3545

3646
result._unmappedProperties_ = clone;
3747

38-
return result;
48+
return result as Decoded<Source, Map>;
3949
};
4050
}
4151

4252
/**
43-
* The Apps-Engine -> Rocket.Chat transform for a plain string field map: the inverse rename (target
44-
* fields copied back to their source names when defined) with `_unmappedProperties_` merged onto the
45-
* result. This is the symmetric counterpart to {@link mappedDecode}; converters whose reverse
46-
* direction has defaults, conditional fields or asymmetric mappings provide their own `encode`.
53+
* The Apps-Engine -> Rocket.Chat inverse of {@link mappedDecode}: target fields are copied back to
54+
* their source names when defined and `_unmappedProperties_` is merged onto the result. Because both
55+
* the renamed keys and the bucket originate from `Source`, the result is a `Partial<Source>`.
56+
*
57+
* This is the symmetric counterpart to {@link mappedDecode}; converters whose reverse direction has
58+
* defaults, conditional fields or asymmetric mappings provide their own `encode`.
4759
*/
48-
export function mappedEncode(fieldMap: FieldMap): (app: Loose) => Loose {
49-
const appKeys = Object.keys(fieldMap);
60+
export function mappedEncode<Source extends Loose = Loose, const Map extends FieldMap<Source> = FieldMap<Source>>(
61+
fieldMap: Map,
62+
): (app: Decoded<Source, Map>) => Partial<Source> {
63+
const entries = Object.entries(fieldMap) as [string, string][];
5064

51-
return (app: Loose): Loose => {
52-
const { _unmappedProperties_ = {}, ...rest } = app;
65+
return (app: Decoded<Source, Map>): Partial<Source> => {
66+
const { _unmappedProperties_ = {}, ...rest } = app as Loose;
5367
const result: Loose = {};
5468

55-
for (const appKey of appKeys) {
56-
if (typeof rest[appKey] !== 'undefined') {
57-
result[fieldMap[appKey]] = rest[appKey];
69+
for (const [target, sourceKey] of entries) {
70+
if (typeof rest[target] !== 'undefined') {
71+
result[sourceKey] = rest[target];
5872
}
5973
}
6074

61-
return { ...result, ..._unmappedProperties_ };
75+
return { ...result, ..._unmappedProperties_ } as Partial<Source>;
6276
};
6377
}
6478

6579
/**
66-
* A map whose entries are one of the three forms the legacy `transformMappedData` supports:
67-
* a source property name (string), a function that derives the target value from the (cloned)
68-
* source data, or a nested `{ from, map, list }` descriptor for sub-objects and arrays.
80+
* A map whose entries are one of the three forms the original mapping helper supports: a source
81+
* property name (string), a function that derives the target value from the (cloned) source data,
82+
* or a nested `{ from, map, list }` descriptor for sub-objects and arrays.
83+
*
84+
* Unlike {@link FieldMap}, this is intentionally loosely typed: its consumers (rooms, messages,
85+
* uploads) map many optional/livechat-only fields that are not part of the base document types.
6986
*/
7087
export type AsyncFieldMap = Record<
7188
string,
7289
string | ((data: Record<string, any>) => unknown | Promise<unknown>) | { from: string; map?: AsyncFieldMap; list?: boolean }
7390
>;
7491

7592
/**
76-
* Rocket.Chat -> Apps-Engine transform for a map mixing string renames and (possibly async)
77-
* derived-value functions. This reproduces the string and function branches of the legacy
78-
* `transformMappedData` exactly:
93+
* Rocket.Chat -> Apps-Engine transform for a map mixing string renames, (possibly async) derived-value
94+
* functions and nested `{ from, map, list }` descriptors, reproducing the original mapping helper
95+
* exactly:
7996
*
80-
* - the input is deep-cloned up front, so functions may freely mutate the clone (e.g. `delete`
81-
* fields they consume) and the app can never mutate the stored document;
97+
* - the input is deep-cloned up front, so functions may freely mutate the clone (e.g. `delete` fields
98+
* they consume) and the app can never mutate the stored document;
8299
* - string entries copy the source field to the target name when defined and always drop the source
83100
* key from the bucket;
84101
* - function entries receive the clone and set the target only when they return a defined value
85-
* (functions are responsible for deleting any source keys they consume, as before);
102+
* (functions are responsible for deleting any source keys they consume);
103+
* - nested entries recurse (producing a `_unmappedProperties_` bucket at each level), and `list`
104+
* entries map arrays element-by-element (or wrap a lone value into a single-element array);
86105
* - everything left in the clone becomes `_unmappedProperties_`.
106+
*
107+
* The result shape is caller-asserted via the `Result` type parameter, since it depends on the map's
108+
* function/nested entries in ways that are not worth expressing in the type system.
87109
*/
88-
export async function mappedDecodeAsync(data: Loose, map: AsyncFieldMap): Promise<Loose> {
110+
export async function mappedDecodeAsync<Result = Loose>(data: Loose, map: AsyncFieldMap): Promise<Result> {
89111
const clone: Loose = structuredClone(data);
90112
const result: Loose = {};
91113

@@ -125,18 +147,19 @@ export async function mappedDecodeAsync(data: Loose, map: AsyncFieldMap): Promis
125147

126148
result._unmappedProperties_ = clone;
127149

128-
return result;
150+
return result as Result;
129151
}
130152

131153
/**
132154
* Builds a Zod codec from a plain string field map, using {@link mappedDecode} / {@link mappedEncode}.
155+
* `decode` produces {@link Decoded}; `encode` is its inverse.
133156
*
134157
* The endpoints are typed with `z.custom` so no runtime validation is added yet (behaviour-preserving);
135158
* schemas can be tightened later without changing the transform logic.
136159
*/
137-
export function createMappedCodec(fieldMap: FieldMap) {
138-
return z.codec(z.custom<Loose>(), z.custom<Loose>(), {
139-
decode: mappedDecode(fieldMap),
140-
encode: mappedEncode(fieldMap),
160+
export function createMappedCodec<Source extends Loose = Loose, const Map extends FieldMap<Source> = FieldMap<Source>>(fieldMap: Map) {
161+
return z.codec(z.custom<Source>(), z.custom<Decoded<Source, Map>>(), {
162+
decode: mappedDecode<Source, Map>(fieldMap),
163+
encode: (app) => mappedEncode<Source, Map>(fieldMap)(app) as Source,
141164
});
142165
}

apps/meteor/app/apps/server/converters/codecs/roles.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
import type { IRole } from '@rocket.chat/core-typings';
2+
13
import { createMappedCodec } from './mappedData';
24

35
/**
46
* Rocket.Chat `IRole` <-> Apps-Engine role.
57
*
68
* A pure field-rename with an `_unmappedProperties_` bucket, so it is expressed directly via
7-
* {@link createMappedCodec}. Keys are the Apps-Engine names, values the Rocket.Chat names.
9+
* {@link createMappedCodec}. Keys are the Apps-Engine names, values the Rocket.Chat names (checked
10+
* against `keyof IRole`).
811
*/
9-
export const RoleCodec = createMappedCodec({
12+
export const RoleCodec = createMappedCodec<IRole>({
1013
id: '_id',
1114
name: 'name',
1215
description: 'description',

apps/meteor/app/apps/server/converters/codecs/rooms.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export async function decodeRoomRaw(room: IRoom): Promise<IAppsRoomRaw> {
110110
},
111111
};
112112

113-
return mappedDecodeAsync(room, map) as unknown as Promise<IAppsRoomRaw>;
113+
return mappedDecodeAsync<IAppsRoomRaw>(room, map);
114114
}
115115

116116
async function getCreator(user: string | undefined) {
@@ -455,7 +455,7 @@ export function createRoomCodec(orch: IAppServerOrchestrator) {
455455
}),
456456
};
457457

458-
return mappedDecodeAsync(originalRoom, map) as unknown as Promise<IAppsRoom | IAppsLivechatRoom>;
458+
return mappedDecodeAsync<IAppsRoom | IAppsLivechatRoom>(originalRoom, map);
459459
},
460460
encode: (room): Promise<IRoom> => appRoomToRocketChat(room, false) as unknown as Promise<IRoom>,
461461
});

apps/meteor/app/apps/server/converters/codecs/uploads.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export function createUploadsCodec(orch: IAppServerOrchestrator) {
6060
},
6161
};
6262

63-
return mappedDecodeAsync(upload, map) as unknown as Promise<IAppsUpload>;
63+
return mappedDecodeAsync<IAppsUpload>(upload, map);
6464
},
6565
encode: (upload): IUpload => {
6666
const { id: userId } = upload.user || {};

apps/meteor/app/apps/server/converters/codecs/visitors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { mappedDecode } from './mappedData';
1313
* write `_updatedAt`/`activity` back, and merges `_unmappedProperties_`.
1414
*/
1515
export const VisitorCodec = z.codec(z.custom<ILivechatVisitor>(), z.custom<IAppsVisitor>(), {
16-
decode: mappedDecode({
16+
decode: mappedDecode<ILivechatVisitor>({
1717
id: '_id',
1818
username: 'username',
1919
name: 'name',

apps/meteor/app/apps/server/converters/messages.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export class AppMessagesConverter implements IAppMessagesConverter {
6666
type: 't',
6767
} as const;
6868

69-
return mappedDecodeAsync(message, map) as unknown as Promise<IAppsMesssageRaw>;
69+
return mappedDecodeAsync<IAppsMesssageRaw>(message, map);
7070
}
7171

7272
convertMessage(msgObj: undefined | null): Promise<undefined>;
@@ -161,7 +161,7 @@ export class AppMessagesConverter implements IAppMessagesConverter {
161161
},
162162
} as const;
163163

164-
return mappedDecodeAsync(msgObj, map) as unknown as Promise<IAppsMessage>;
164+
return mappedDecodeAsync<IAppsMessage>(msgObj, map);
165165
}
166166

167167
convertAppMessage(message: undefined | null): Promise<undefined>;

apps/meteor/app/apps/server/converters/threads.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ export class AppThreadsConverter implements IAppThreadsConverter {
131131
reactions: msgObj.reactions as unknown as AppsEngineMessage['reactions'],
132132
} as IMessage & { reactions?: AppsEngineMessage['reactions'] };
133133

134-
return mappedDecodeAsync(msgData, map as unknown as AsyncFieldMap) as unknown as Promise<AppsEngineMessage>;
134+
return mappedDecodeAsync<AppsEngineMessage>(msgData, map as unknown as AsyncFieldMap);
135135
}
136136

137137
async _convertAttachmentsToApp(

apps/meteor/tests/unit/app/apps/server/codecs/mappedData.spec.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { expect } from 'chai';
22
import { describe, it } from 'mocha';
33
import * as z from 'zod';
44

5-
import { createMappedCodec, mappedDecodeAsync } from '../../../../../../app/apps/server/converters/codecs/mappedData';
5+
import { createMappedCodec, mappedDecode, mappedDecodeAsync } from '../../../../../../app/apps/server/converters/codecs/mappedData';
66

77
describe('createMappedCodec', () => {
88
const codec = createMappedCodec({
@@ -153,3 +153,18 @@ describe('mappedDecodeAsync', () => {
153153
expect(result).to.deep.equal({ _unmappedProperties_: { os: 'android', version: '1.9', lan: 'en' } });
154154
});
155155
});
156+
157+
describe('mappedDecode type constraints', () => {
158+
type Source = { _id: string; name: string };
159+
160+
it('constrains map values to keys of the source type and infers the decoded shape', () => {
161+
const decode = mappedDecode<Source>({ id: '_id', label: 'name' });
162+
const result = decode({ _id: 'a', name: 'b' });
163+
164+
// `result` is typed `Decoded<Source, ...>`: renamed targets plus the bucket.
165+
expect(result).to.deep.equal({ id: 'a', label: 'b', _unmappedProperties_: {} });
166+
167+
// @ts-expect-error - 'missing' is not a key of Source, so the field map is rejected.
168+
mappedDecode<Source>({ id: 'missing' });
169+
});
170+
});

0 commit comments

Comments
 (0)