|
1 | 1 | import * as z from 'zod'; |
2 | 2 |
|
| 3 | +type Loose = Record<string, any>; |
| 4 | + |
3 | 5 | /** |
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. |
7 | 9 | */ |
8 | | -export type FieldMap = Record<string, string>; |
| 10 | +export type FieldMap<Source> = Record<string, Extract<keyof Source, string>>; |
9 | 11 |
|
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 | +}; |
11 | 22 |
|
12 | 23 | /** |
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_`. |
18 | 28 | */ |
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][]; |
21 | 33 |
|
22 | | - return (data: Loose): Loose => { |
| 34 | + return (data: Source): Decoded<Source, Map> => { |
23 | 35 | const clone: Loose = structuredClone(data); |
24 | 36 | const result: Loose = {}; |
25 | 37 |
|
26 | | - for (const appKey of appKeys) { |
27 | | - const sourceKey = fieldMap[appKey]; |
28 | | - |
| 38 | + for (const [target, sourceKey] of entries) { |
29 | 39 | if (typeof clone[sourceKey] !== 'undefined') { |
30 | | - result[appKey] = clone[sourceKey]; |
| 40 | + result[target] = clone[sourceKey]; |
31 | 41 | } |
32 | 42 |
|
33 | 43 | delete clone[sourceKey]; |
34 | 44 | } |
35 | 45 |
|
36 | 46 | result._unmappedProperties_ = clone; |
37 | 47 |
|
38 | | - return result; |
| 48 | + return result as Decoded<Source, Map>; |
39 | 49 | }; |
40 | 50 | } |
41 | 51 |
|
42 | 52 | /** |
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`. |
47 | 59 | */ |
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][]; |
50 | 64 |
|
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; |
53 | 67 | const result: Loose = {}; |
54 | 68 |
|
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]; |
58 | 72 | } |
59 | 73 | } |
60 | 74 |
|
61 | | - return { ...result, ..._unmappedProperties_ }; |
| 75 | + return { ...result, ..._unmappedProperties_ } as Partial<Source>; |
62 | 76 | }; |
63 | 77 | } |
64 | 78 |
|
65 | 79 | /** |
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. |
69 | 86 | */ |
70 | 87 | export type AsyncFieldMap = Record< |
71 | 88 | string, |
72 | 89 | string | ((data: Record<string, any>) => unknown | Promise<unknown>) | { from: string; map?: AsyncFieldMap; list?: boolean } |
73 | 90 | >; |
74 | 91 |
|
75 | 92 | /** |
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: |
79 | 96 | * |
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; |
82 | 99 | * - string entries copy the source field to the target name when defined and always drop the source |
83 | 100 | * key from the bucket; |
84 | 101 | * - 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); |
86 | 105 | * - 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. |
87 | 109 | */ |
88 | | -export async function mappedDecodeAsync(data: Loose, map: AsyncFieldMap): Promise<Loose> { |
| 110 | +export async function mappedDecodeAsync<Result = Loose>(data: Loose, map: AsyncFieldMap): Promise<Result> { |
89 | 111 | const clone: Loose = structuredClone(data); |
90 | 112 | const result: Loose = {}; |
91 | 113 |
|
@@ -125,18 +147,19 @@ export async function mappedDecodeAsync(data: Loose, map: AsyncFieldMap): Promis |
125 | 147 |
|
126 | 148 | result._unmappedProperties_ = clone; |
127 | 149 |
|
128 | | - return result; |
| 150 | + return result as Result; |
129 | 151 | } |
130 | 152 |
|
131 | 153 | /** |
132 | 154 | * Builds a Zod codec from a plain string field map, using {@link mappedDecode} / {@link mappedEncode}. |
| 155 | + * `decode` produces {@link Decoded}; `encode` is its inverse. |
133 | 156 | * |
134 | 157 | * The endpoints are typed with `z.custom` so no runtime validation is added yet (behaviour-preserving); |
135 | 158 | * schemas can be tightened later without changing the transform logic. |
136 | 159 | */ |
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, |
141 | 164 | }); |
142 | 165 | } |
0 commit comments