| 1 | import type * as firestore from "@distilled.cloud/gcp/firestore_v1"; |
| 2 | |
| 3 | /** |
| 4 | * Codec between plain JavaScript values and Firestore REST `Value`s. |
| 5 | * NOT exported from `index.ts`. |
| 6 | * |
| 7 | * Encoding: `null`/`undefined` → `nullValue`, booleans → `booleanValue`, |
| 8 | * integral numbers and bigints → `integerValue`, other numbers → |
| 9 | * `doubleValue`, strings → `stringValue`, `Date` → `timestampValue`, |
| 10 | * `Uint8Array` → `bytesValue`, arrays → `arrayValue`, plain objects → |
| 11 | * `mapValue`. Decoding reverses this; `integerValue`s outside the safe |
| 12 | * integer range decode to `bigint`, `geoPointValue` to |
| 13 | * `{ latitude, longitude }`, and `referenceValue` to its resource name. |
| 14 | */ |
| 15 | |
| 16 | const toBase64 = (bytes: Uint8Array): string => { |
| 17 | let binary = ""; |
| 18 | for (let i = 0; i < bytes.length; i++) { |
| 19 | binary += String.fromCharCode(bytes[i]!); |
| 20 | } |
| 21 | return btoa(binary); |
| 22 | }; |
| 23 | |
| 24 | const fromBase64 = (value: string): Uint8Array => { |
| 25 | const binary = atob(value); |
| 26 | const bytes = new Uint8Array(binary.length); |
| 27 | for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); |
| 28 | return bytes; |
| 29 | }; |
| 30 | |
| 31 | export const encodeValue = (value: unknown): firestore.Value => { |
| 32 | if (value === null || value === undefined) return { nullValue: "NULL_VALUE" }; |
| 33 | if (typeof value === "boolean") return { booleanValue: value }; |
| 34 | if (typeof value === "bigint") return { integerValue: value.toString() }; |
| 35 | if (typeof value === "number") { |
| 36 | return Number.isSafeInteger(value) |
| 37 | ? { integerValue: String(value) } |
| 38 | : { doubleValue: value }; |
| 39 | } |
| 40 | if (typeof value === "string") return { stringValue: value }; |
| 41 | if (value instanceof Date) return { timestampValue: value.toISOString() }; |
| 42 | if (value instanceof Uint8Array) return { bytesValue: toBase64(value) }; |
| 43 | if (Array.isArray(value)) { |
| 44 | return { arrayValue: { values: value.map(encodeValue) } }; |
| 45 | } |
| 46 | if (typeof value === "object") { |
| 47 | return { mapValue: { fields: encodeFields(value as object) } }; |
| 48 | } |
| 49 | return { stringValue: String(value) }; |
| 50 | }; |
| 51 | |
| 52 | /** Encode a plain object's own enumerable keys; `undefined` keys are dropped. */ |
| 53 | export const encodeFields = (fields: object): firestore.ValueMap => { |
| 54 | const out: Record<string, firestore.Value> = {}; |
| 55 | for (const [key, value] of Object.entries(fields)) { |
| 56 | if (value !== undefined) out[key] = encodeValue(value); |
| 57 | } |
| 58 | return out; |
| 59 | }; |
| 60 | |
| 61 | const decodeDouble = (value: number | string): number => |
| 62 | typeof value === "number" ? value : Number(value); |
| 63 | |
| 64 | export const decodeValue = (value: firestore.Value): unknown => { |
| 65 | if (value.nullValue !== undefined) return null; |
| 66 | if (value.booleanValue !== undefined) return value.booleanValue; |
| 67 | if (value.integerValue !== undefined) { |
| 68 | const big = BigInt(value.integerValue); |
| 69 | return big >= BigInt(Number.MIN_SAFE_INTEGER) && |
| 70 | big <= BigInt(Number.MAX_SAFE_INTEGER) |
| 71 | ? Number(big) |
| 72 | : big; |
| 73 | } |
| 74 | if (value.doubleValue !== undefined) return decodeDouble(value.doubleValue); |
| 75 | if (value.stringValue !== undefined) return value.stringValue; |
| 76 | if (value.timestampValue !== undefined) return new Date(value.timestampValue); |
| 77 | if (value.bytesValue !== undefined) return fromBase64(value.bytesValue); |
| 78 | if (value.referenceValue !== undefined) return value.referenceValue; |
| 79 | if (value.geoPointValue !== undefined) { |
| 80 | return { |
| 81 | latitude: value.geoPointValue.latitude ?? 0, |
| 82 | longitude: value.geoPointValue.longitude ?? 0, |
| 83 | }; |
| 84 | } |
| 85 | if (value.arrayValue !== undefined) { |
| 86 | return (value.arrayValue.values ?? []).map(decodeValue); |
| 87 | } |
| 88 | if (value.mapValue !== undefined) { |
| 89 | return decodeFields(value.mapValue.fields); |
| 90 | } |
| 91 | return null; |
| 92 | }; |
| 93 | |
| 94 | export const decodeFields = ( |
| 95 | fields: firestore.ValueMap | undefined, |
| 96 | ): Record<string, unknown> => { |
| 97 | const out: Record<string, unknown> = {}; |
| 98 | for (const [key, value] of Object.entries(fields ?? {})) { |
| 99 | if (value !== undefined) out[key] = decodeValue(value); |
| 100 | } |
| 101 | return out; |
| 102 | }; |
| 103 | |
| 104 | /** Quote a field name for an update mask when it is not a simple identifier. */ |
| 105 | export const fieldPath = (key: string): string => |
| 106 | /^[_a-zA-Z][_a-zA-Z0-9]*$/.test(key) |
| 107 | ? key |
| 108 | : `\`${key.replace(/\\/g, "\\\\").replace(/`/g, "\\`")}\``; |