1import 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
16const 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
24const 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
31export 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. */
53export 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
61const decodeDouble = (value: number | string): number =>
62 typeof value === "number" ? value : Number(value);
63
64export 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
94export 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. */
105export const fieldPath = (key: string): string =>
106 /^[_a-zA-Z][_a-zA-Z0-9]*$/.test(key)
107 ? key
108 : `\`${key.replace(/\\/g, "\\\\").replace(/`/g, "\\`")}\``;