|
| 1 | +import * as z from 'zod'; |
| 2 | + |
| 3 | +type Loose = Record<string, any>; |
| 4 | + |
| 5 | +/** |
| 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. |
| 9 | + */ |
| 10 | +export type FieldMap<Source> = Record<string, Extract<keyof Source, string>>; |
| 11 | + |
| 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 | +}; |
| 22 | + |
| 23 | +/** |
| 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_`. |
| 28 | + */ |
| 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][]; |
| 33 | + |
| 34 | + return (data: Source): Decoded<Source, Map> => { |
| 35 | + const clone: Loose = structuredClone(data); |
| 36 | + const result: Loose = {}; |
| 37 | + |
| 38 | + for (const [target, sourceKey] of entries) { |
| 39 | + if (typeof clone[sourceKey] !== 'undefined') { |
| 40 | + result[target] = clone[sourceKey]; |
| 41 | + } |
| 42 | + |
| 43 | + delete clone[sourceKey]; |
| 44 | + } |
| 45 | + |
| 46 | + result._unmappedProperties_ = clone; |
| 47 | + |
| 48 | + return result as Decoded<Source, Map>; |
| 49 | + }; |
| 50 | +} |
| 51 | + |
| 52 | +/** |
| 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`. |
| 59 | + */ |
| 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][]; |
| 64 | + |
| 65 | + return (app: Decoded<Source, Map>): Partial<Source> => { |
| 66 | + const { _unmappedProperties_ = {}, ...rest } = app as Loose; |
| 67 | + const result: Loose = {}; |
| 68 | + |
| 69 | + for (const [target, sourceKey] of entries) { |
| 70 | + if (typeof rest[target] !== 'undefined') { |
| 71 | + result[sourceKey] = rest[target]; |
| 72 | + } |
| 73 | + } |
| 74 | + |
| 75 | + return { ...result, ..._unmappedProperties_ } as Partial<Source>; |
| 76 | + }; |
| 77 | +} |
| 78 | + |
| 79 | +/** |
| 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. |
| 86 | + */ |
| 87 | +export type AsyncFieldMap = Record< |
| 88 | + string, |
| 89 | + string | ((data: Record<string, any>) => unknown | Promise<unknown>) | { from: string; map?: AsyncFieldMap; list?: boolean } |
| 90 | +>; |
| 91 | + |
| 92 | +/** |
| 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: |
| 96 | + * |
| 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; |
| 99 | + * - string entries copy the source field to the target name when defined and always drop the source |
| 100 | + * key from the bucket; |
| 101 | + * - function entries receive the clone and set the target only when they return a defined value |
| 102 | + * (functions are responsible for deleting any source keys they consume); |
| 103 | + * - nested entries recurse when they carry a `map` (producing a `_unmappedProperties_` bucket at each |
| 104 | + * level) or copy the cloned source value verbatim when they do not, and `list` entries map arrays |
| 105 | + * element-by-element (or wrap a lone value into a single-element array); |
| 106 | + * - absent (`undefined`/`null`) nested sources are skipped, so the target stays unset; |
| 107 | + * - everything left in the clone becomes `_unmappedProperties_`. |
| 108 | + * |
| 109 | + * The result shape is caller-asserted via the `Result` type parameter, since it depends on the map's |
| 110 | + * function/nested entries in ways that are not worth expressing in the type system. |
| 111 | + * |
| 112 | + * `dropUnmapped` omits the `_unmappedProperties_` bucket at the root and every nested level, for |
| 113 | + * callers (e.g. contacts) that map every attribute individually and must not let extra app data leak |
| 114 | + * through. It defaults to `false`, preserving the "bucket the rest" behaviour for other callers. |
| 115 | + */ |
| 116 | +export async function mappedDecodeAsync<Result = Loose>( |
| 117 | + data: Loose, |
| 118 | + map: AsyncFieldMap, |
| 119 | + options: { dropUnmapped?: boolean } = {}, |
| 120 | +): Promise<Result> { |
| 121 | + const clone: Loose = structuredClone(data); |
| 122 | + const result: Loose = {}; |
| 123 | + |
| 124 | + for (const [to, from] of Object.entries(map)) { |
| 125 | + if (typeof from === 'function') { |
| 126 | + const value = await from(clone); |
| 127 | + |
| 128 | + if (typeof value !== 'undefined') { |
| 129 | + result[to] = value; |
| 130 | + } |
| 131 | + } else if (typeof from === 'string') { |
| 132 | + if (typeof clone[from] !== 'undefined') { |
| 133 | + result[to] = clone[from]; |
| 134 | + } |
| 135 | + |
| 136 | + delete clone[from]; |
| 137 | + } else { |
| 138 | + const { from: fromName } = from; |
| 139 | + |
| 140 | + if (from.list) { |
| 141 | + if (Array.isArray(clone[fromName])) { |
| 142 | + if ('map' in from && from.map) { |
| 143 | + result[to] = await Promise.all( |
| 144 | + clone[fromName].map((item: Loose) => mappedDecodeAsync(item, from.map as AsyncFieldMap, options)), |
| 145 | + ); |
| 146 | + } else { |
| 147 | + result[to] = [...clone[fromName]]; |
| 148 | + } |
| 149 | + } else if (clone[fromName] !== undefined && clone[fromName] !== null) { |
| 150 | + result[to] = [clone[fromName]]; |
| 151 | + } |
| 152 | + } else if (clone[fromName] !== undefined && clone[fromName] !== null) { |
| 153 | + result[to] = from.map |
| 154 | + ? await mappedDecodeAsync(clone[fromName], from.map as AsyncFieldMap, options) |
| 155 | + : structuredClone(clone[fromName]); |
| 156 | + } |
| 157 | + |
| 158 | + delete clone[fromName]; |
| 159 | + } |
| 160 | + } |
| 161 | + |
| 162 | + if (!options.dropUnmapped) { |
| 163 | + result._unmappedProperties_ = clone; |
| 164 | + } |
| 165 | + |
| 166 | + return result as Result; |
| 167 | +} |
| 168 | + |
| 169 | +/** |
| 170 | + * Builds a Zod codec from a plain string field map, using {@link mappedDecode} / {@link mappedEncode}. |
| 171 | + * `decode` produces {@link Decoded}; `encode` is its inverse. |
| 172 | + * |
| 173 | + * The endpoints are typed with `z.custom` so no runtime validation is added yet (behaviour-preserving); |
| 174 | + * schemas can be tightened later without changing the transform logic. |
| 175 | + */ |
| 176 | +export function createMappedCodec<Source extends Loose = Loose, const Map extends FieldMap<Source> = FieldMap<Source>>(fieldMap: Map) { |
| 177 | + return z.codec(z.custom<Source>(), z.custom<Decoded<Source, Map>>(), { |
| 178 | + decode: mappedDecode<Source, Map>(fieldMap), |
| 179 | + encode: (app) => mappedEncode<Source, Map>(fieldMap)(app) as Source, |
| 180 | + }); |
| 181 | +} |
0 commit comments