firestore-proto-codec 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +76 -0
- package/README.md +172 -0
- package/dist/codec.d.ts +23 -0
- package/dist/codec.js +484 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +13 -0
- package/dist/generated/codebinge/firestore/codec/v1/options_pb.d.ts +105 -0
- package/dist/generated/codebinge/firestore/codec/v1/options_pb.js +66 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/values.d.ts +52 -0
- package/dist/values.js +48 -0
- package/package.json +44 -0
- package/proto/codebinge/firestore/codec/v1/options.proto +66 -0
package/dist/codec.js
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { create, getOption, hasOption, isMessage, ScalarType, } from "@bufbuild/protobuf";
|
|
2
|
+
import { FeatureSet_FieldPresence } from "@bufbuild/protobuf/wkt";
|
|
3
|
+
import { CodecError } from "./errors.js";
|
|
4
|
+
import { DefaultFirestoreTypes, } from "./values.js";
|
|
5
|
+
import { EnumEncoding, Kind, field as fieldOption, } from "./generated/codebinge/firestore/codec/v1/options_pb.js";
|
|
6
|
+
/** Firestore's limit on the depth of fields in a map or array. */
|
|
7
|
+
export const MAX_NESTING_DEPTH = 20;
|
|
8
|
+
const TIMESTAMP = "google.protobuf.Timestamp";
|
|
9
|
+
const DURATION = "google.protobuf.Duration";
|
|
10
|
+
const LAT_LNG = "google.type.LatLng";
|
|
11
|
+
const UNSUPPORTED = new Set([
|
|
12
|
+
"google.protobuf.Any",
|
|
13
|
+
"google.protobuf.Struct",
|
|
14
|
+
"google.protobuf.Value",
|
|
15
|
+
"google.protobuf.ListValue",
|
|
16
|
+
"google.protobuf.FieldMask",
|
|
17
|
+
]);
|
|
18
|
+
const CANONICAL_UNSIGNED = /^(0|[1-9][0-9]*)$/;
|
|
19
|
+
const MAX_UINT64 = 2n ** 64n - 1n;
|
|
20
|
+
const MAX_INT64 = 2n ** 63n - 1n;
|
|
21
|
+
const DEFAULT_RULES = {
|
|
22
|
+
skip: false,
|
|
23
|
+
enumAsNumber: false,
|
|
24
|
+
omitWhenDefault: true,
|
|
25
|
+
unsignedAsInteger: false,
|
|
26
|
+
geoPoint: false,
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Reads the custom options straight off the descriptor. Unlike the Dart
|
|
30
|
+
* runtime, protobuf-es keeps them, so no registration step is needed.
|
|
31
|
+
*/
|
|
32
|
+
function rulesFor(field) {
|
|
33
|
+
if (!hasOption(field, fieldOption))
|
|
34
|
+
return DEFAULT_RULES;
|
|
35
|
+
const o = getOption(field, fieldOption);
|
|
36
|
+
return {
|
|
37
|
+
skip: o.skip,
|
|
38
|
+
name: o.name !== "" ? o.name : undefined,
|
|
39
|
+
enumAsNumber: o.enumAs === EnumEncoding.NUMBER,
|
|
40
|
+
// Explicit presence: the intended default is true, which an implicit
|
|
41
|
+
// proto3 bool could not express.
|
|
42
|
+
omitWhenDefault: o.omitWhenDefault ?? true,
|
|
43
|
+
unsignedAsInteger: o.kind === Kind.UNSIGNED_AS_INTEGER,
|
|
44
|
+
geoPoint: o.kind === Kind.GEO_POINT,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const storedName = (field, rules) => rules.name ?? field.name;
|
|
48
|
+
const isExplicit = (field) => field.presence !== FeatureSet_FieldPresence.IMPLICIT;
|
|
49
|
+
/** Encodes a protobuf message as a Firestore value, and decodes it back. */
|
|
50
|
+
export class FirestoreProtoCodec {
|
|
51
|
+
types;
|
|
52
|
+
constructor(types = new DefaultFirestoreTypes()) {
|
|
53
|
+
this.types = types;
|
|
54
|
+
}
|
|
55
|
+
encode(schema, message) {
|
|
56
|
+
requireMessage(schema, message);
|
|
57
|
+
return this.encodeMessage(schema, message, 1, "");
|
|
58
|
+
}
|
|
59
|
+
decode(schema, document) {
|
|
60
|
+
return this.decodeMessage(schema, document, "");
|
|
61
|
+
}
|
|
62
|
+
/** Throws if this type, or any type it reaches, cannot be encoded. */
|
|
63
|
+
validateSchema(schema) {
|
|
64
|
+
this.validate(schema, new Set(), schema.typeName);
|
|
65
|
+
}
|
|
66
|
+
// ---------------------------------------------------------------- encoding
|
|
67
|
+
encodeMessage(schema, message, depth, path) {
|
|
68
|
+
checkDepth(depth, path);
|
|
69
|
+
const out = {};
|
|
70
|
+
for (const field of schema.fields) {
|
|
71
|
+
const rules = rulesFor(field);
|
|
72
|
+
if (rules.skip)
|
|
73
|
+
continue;
|
|
74
|
+
const name = storedName(field, rules);
|
|
75
|
+
const fieldPath = path === "" ? name : `${path}.${name}`;
|
|
76
|
+
const value = readField(message, field);
|
|
77
|
+
if (field.fieldKind === "map") {
|
|
78
|
+
requireStringKeys(field, fieldPath);
|
|
79
|
+
const entries = Object.entries((value ?? {}));
|
|
80
|
+
if (entries.length === 0)
|
|
81
|
+
continue;
|
|
82
|
+
// The map is a level of its own, and each value sits inside it.
|
|
83
|
+
checkDepth(depth + 1, fieldPath);
|
|
84
|
+
out[name] = Object.fromEntries(entries.map(([k, v]) => [
|
|
85
|
+
k,
|
|
86
|
+
this.encodeElement(field.scalar, field.enum, field.message, v, DEFAULT_RULES, depth + 1, `${fieldPath}.${k}`),
|
|
87
|
+
]));
|
|
88
|
+
}
|
|
89
|
+
else if (field.fieldKind === "list") {
|
|
90
|
+
const items = (value ?? []);
|
|
91
|
+
if (items.length === 0)
|
|
92
|
+
continue;
|
|
93
|
+
// The array is a level of its own, and each element sits inside it.
|
|
94
|
+
checkDepth(depth + 1, fieldPath);
|
|
95
|
+
out[name] = items.map((item, i) => this.encodeElement(field.scalar, field.enum, field.message, item, rules, depth + 1, `${fieldPath}[${i}]`));
|
|
96
|
+
}
|
|
97
|
+
else if (isExplicit(field)) {
|
|
98
|
+
if (value === undefined)
|
|
99
|
+
continue;
|
|
100
|
+
out[name] = this.encodeElement(field.scalar, field.enum, field.message, value, rules, depth, fieldPath);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
if (rules.omitWhenDefault && isDefault(normalize64(field.scalar, value))) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
out[name] = this.encodeElement(field.scalar, field.enum, field.message, value, rules, depth, fieldPath);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
encodeElement(scalar, enumDesc, messageDesc, value, rules, depth, path) {
|
|
112
|
+
if (messageDesc !== undefined) {
|
|
113
|
+
return this.encodeMessageValue(messageDesc, value, rules, depth, path);
|
|
114
|
+
}
|
|
115
|
+
if (enumDesc !== undefined) {
|
|
116
|
+
const number = value;
|
|
117
|
+
if (rules.enumAsNumber)
|
|
118
|
+
return number;
|
|
119
|
+
const match = enumDesc.values.find((v) => v.number === number);
|
|
120
|
+
if (match === undefined) {
|
|
121
|
+
// A number with no declared name, relayed from a newer writer. Writing
|
|
122
|
+
// the integer would mix types on a String field; writing a synthetic
|
|
123
|
+
// name would decode to zero. Both are silent, so refuse (§4).
|
|
124
|
+
throw new CodecError("ENUM_VALUE_UNKNOWN", `${enumDesc.typeName} has no name for value ${number}`, path);
|
|
125
|
+
}
|
|
126
|
+
return match.name;
|
|
127
|
+
}
|
|
128
|
+
// A field annotated `jstype = JS_STRING` holds its int64 as a string;
|
|
129
|
+
// the Firestore type is still Integer (§8).
|
|
130
|
+
value = normalize64(scalar, value);
|
|
131
|
+
switch (scalar) {
|
|
132
|
+
case ScalarType.STRING:
|
|
133
|
+
case ScalarType.BOOL:
|
|
134
|
+
case ScalarType.DOUBLE:
|
|
135
|
+
case ScalarType.INT32:
|
|
136
|
+
case ScalarType.SINT32:
|
|
137
|
+
case ScalarType.SFIXED32:
|
|
138
|
+
case ScalarType.UINT32:
|
|
139
|
+
case ScalarType.FIXED32:
|
|
140
|
+
return value;
|
|
141
|
+
case ScalarType.FLOAT:
|
|
142
|
+
// protobuf-es holds a float in a number without rounding, so the same
|
|
143
|
+
// value would encode differently before and after a wire trip, and
|
|
144
|
+
// differently from Java. Round to binary32 so every path agrees.
|
|
145
|
+
return Math.fround(value);
|
|
146
|
+
case ScalarType.BYTES:
|
|
147
|
+
return this.types.blob(value);
|
|
148
|
+
case ScalarType.INT64:
|
|
149
|
+
case ScalarType.SINT64:
|
|
150
|
+
case ScalarType.SFIXED64:
|
|
151
|
+
return value;
|
|
152
|
+
case ScalarType.UINT64:
|
|
153
|
+
case ScalarType.FIXED64:
|
|
154
|
+
return this.encodeUnsigned(value, rules, path);
|
|
155
|
+
default:
|
|
156
|
+
throw new CodecError("UNSUPPORTED_TYPE", `no encoding for scalar type ${String(scalar)}`, path);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
encodeUnsigned(value, rules, path) {
|
|
160
|
+
if (!rules.unsignedAsInteger)
|
|
161
|
+
return value.toString();
|
|
162
|
+
if (value > MAX_INT64) {
|
|
163
|
+
throw new CodecError("UNSIGNED_NOT_REPRESENTABLE", `KIND_UNSIGNED_AS_INTEGER cannot represent ${value}; ` +
|
|
164
|
+
"Firestore integers are signed 64-bit", path);
|
|
165
|
+
}
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
encodeMessageValue(schema, value, rules, depth, path) {
|
|
169
|
+
if (UNSUPPORTED.has(schema.typeName)) {
|
|
170
|
+
throw new CodecError("UNSUPPORTED_TYPE", `${schema.typeName} has no Firestore representation`, path);
|
|
171
|
+
}
|
|
172
|
+
requireMessage(schema, value, path);
|
|
173
|
+
const sub = value;
|
|
174
|
+
if (schema.typeName === TIMESTAMP) {
|
|
175
|
+
return this.types.timestamp(sub.seconds ?? 0n, sub.nanos ?? 0);
|
|
176
|
+
}
|
|
177
|
+
if (schema.typeName === DURATION) {
|
|
178
|
+
const seconds = sub.seconds ?? 0n;
|
|
179
|
+
const nanos = sub.nanos ?? 0;
|
|
180
|
+
return seconds * 1000000n + BigInt(Math.trunc(nanos / 1000));
|
|
181
|
+
}
|
|
182
|
+
if (schema.typeName === LAT_LNG || rules.geoPoint) {
|
|
183
|
+
const { latitude, longitude } = readLatLng(schema, sub, path);
|
|
184
|
+
return this.types.geoPoint(latitude, longitude);
|
|
185
|
+
}
|
|
186
|
+
return this.encodeMessage(schema, sub, depth + 1, path);
|
|
187
|
+
}
|
|
188
|
+
// ---------------------------------------------------------------- decoding
|
|
189
|
+
decodeMessage(schema, document, path) {
|
|
190
|
+
const message = create(schema);
|
|
191
|
+
for (const field of schema.fields) {
|
|
192
|
+
const rules = rulesFor(field);
|
|
193
|
+
if (rules.skip)
|
|
194
|
+
continue;
|
|
195
|
+
const name = storedName(field, rules);
|
|
196
|
+
const fieldPath = path === "" ? name : `${path}.${name}`;
|
|
197
|
+
if (field.fieldKind === "map")
|
|
198
|
+
requireStringKeys(field, fieldPath);
|
|
199
|
+
if (!(name in document))
|
|
200
|
+
continue;
|
|
201
|
+
const raw = document[name];
|
|
202
|
+
if (raw === undefined || raw === null)
|
|
203
|
+
continue;
|
|
204
|
+
if (field.fieldKind === "map") {
|
|
205
|
+
message[field.localName] = Object.fromEntries(Object.entries(raw).map(([k, v]) => [
|
|
206
|
+
k,
|
|
207
|
+
toDeclared(field, this.decodeElement(field.scalar, field.enum, field.message, v, DEFAULT_RULES, `${fieldPath}.${k}`)),
|
|
208
|
+
]));
|
|
209
|
+
}
|
|
210
|
+
else if (field.fieldKind === "list") {
|
|
211
|
+
message[field.localName] = raw.map((item, i) => toDeclared(field, this.decodeElement(field.scalar, field.enum, field.message, item, rules, `${fieldPath}[${i}]`)));
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
const decoded = this.decodeElement(field.scalar, field.enum, field.message, raw, rules, fieldPath);
|
|
215
|
+
// Leaving an implicit field at its default keeps a decoded message
|
|
216
|
+
// equal to one that never had it set.
|
|
217
|
+
if (!isExplicit(field) && isDefault(decoded))
|
|
218
|
+
continue;
|
|
219
|
+
writeField(message, field, toDeclared(field, decoded));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return message;
|
|
223
|
+
}
|
|
224
|
+
decodeElement(scalar, enumDesc, messageDesc, raw, rules, path) {
|
|
225
|
+
if (messageDesc !== undefined) {
|
|
226
|
+
return this.decodeMessageValue(messageDesc, raw, rules, path);
|
|
227
|
+
}
|
|
228
|
+
if (enumDesc !== undefined) {
|
|
229
|
+
// An unknown name decodes to the zero value rather than throwing, so an
|
|
230
|
+
// old reader survives a document written by a newer writer.
|
|
231
|
+
const zero = enumDesc.values.find((v) => v.number === 0)?.number ?? 0;
|
|
232
|
+
// An admin SDK configured with `useBigInt: true` hands back every
|
|
233
|
+
// integer as a bigint, so a numerically-encoded enum arrives that way.
|
|
234
|
+
if (typeof raw === "number" || typeof raw === "bigint") {
|
|
235
|
+
const number = Number(raw);
|
|
236
|
+
return enumDesc.values.some((v) => v.number === number) ? number : zero;
|
|
237
|
+
}
|
|
238
|
+
return enumDesc.values.find((v) => v.name === raw)?.number ?? zero;
|
|
239
|
+
}
|
|
240
|
+
switch (scalar) {
|
|
241
|
+
case ScalarType.STRING:
|
|
242
|
+
case ScalarType.BOOL:
|
|
243
|
+
return raw;
|
|
244
|
+
case ScalarType.DOUBLE:
|
|
245
|
+
return Number(raw);
|
|
246
|
+
case ScalarType.FLOAT:
|
|
247
|
+
return Math.fround(Number(raw));
|
|
248
|
+
case ScalarType.INT32:
|
|
249
|
+
case ScalarType.SINT32:
|
|
250
|
+
case ScalarType.SFIXED32:
|
|
251
|
+
case ScalarType.UINT32:
|
|
252
|
+
case ScalarType.FIXED32:
|
|
253
|
+
return Number(raw);
|
|
254
|
+
case ScalarType.BYTES: {
|
|
255
|
+
const bytes = this.types.readBlob(raw);
|
|
256
|
+
if (bytes === undefined) {
|
|
257
|
+
throw new CodecError("UNSUPPORTED_TYPE", "expected a blob", path);
|
|
258
|
+
}
|
|
259
|
+
return bytes;
|
|
260
|
+
}
|
|
261
|
+
case ScalarType.INT64:
|
|
262
|
+
case ScalarType.SINT64:
|
|
263
|
+
case ScalarType.SFIXED64:
|
|
264
|
+
return BigInt(raw);
|
|
265
|
+
case ScalarType.UINT64:
|
|
266
|
+
case ScalarType.FIXED64:
|
|
267
|
+
return rules.unsignedAsInteger
|
|
268
|
+
? BigInt(raw)
|
|
269
|
+
: decodeUnsigned(raw, path);
|
|
270
|
+
default:
|
|
271
|
+
throw new CodecError("UNSUPPORTED_TYPE", `no decoding for scalar type ${String(scalar)}`, path);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
decodeMessageValue(schema, raw, rules, path) {
|
|
275
|
+
if (UNSUPPORTED.has(schema.typeName)) {
|
|
276
|
+
throw new CodecError("UNSUPPORTED_TYPE", `${schema.typeName} has no Firestore representation`, path);
|
|
277
|
+
}
|
|
278
|
+
if (schema.typeName === TIMESTAMP) {
|
|
279
|
+
const ts = this.types.readTimestamp(raw);
|
|
280
|
+
if (ts === undefined) {
|
|
281
|
+
throw new CodecError("UNSUPPORTED_TYPE", "expected a timestamp", path);
|
|
282
|
+
}
|
|
283
|
+
const sub = create(schema);
|
|
284
|
+
sub.seconds = ts.seconds;
|
|
285
|
+
sub.nanos = ts.nanos;
|
|
286
|
+
return sub;
|
|
287
|
+
}
|
|
288
|
+
if (schema.typeName === DURATION) {
|
|
289
|
+
const micros = BigInt(raw);
|
|
290
|
+
const sub = create(schema);
|
|
291
|
+
sub.seconds = micros / 1000000n;
|
|
292
|
+
sub.nanos = Number(micros % 1000000n) * 1000;
|
|
293
|
+
return sub;
|
|
294
|
+
}
|
|
295
|
+
if (schema.typeName === LAT_LNG || rules.geoPoint) {
|
|
296
|
+
const point = this.types.readGeoPoint(raw);
|
|
297
|
+
if (point === undefined) {
|
|
298
|
+
throw new CodecError("UNSUPPORTED_TYPE", "expected a geo point", path);
|
|
299
|
+
}
|
|
300
|
+
const sub = create(schema);
|
|
301
|
+
for (const f of schema.fields) {
|
|
302
|
+
if (f.name === "latitude")
|
|
303
|
+
sub[f.localName] = point.latitude;
|
|
304
|
+
if (f.name === "longitude")
|
|
305
|
+
sub[f.localName] = point.longitude;
|
|
306
|
+
}
|
|
307
|
+
return sub;
|
|
308
|
+
}
|
|
309
|
+
return this.decodeMessage(schema, raw, path);
|
|
310
|
+
}
|
|
311
|
+
// -------------------------------------------------------------- validation
|
|
312
|
+
validate(schema, seen, path) {
|
|
313
|
+
if (seen.has(schema.typeName))
|
|
314
|
+
return;
|
|
315
|
+
seen.add(schema.typeName);
|
|
316
|
+
for (const field of schema.fields) {
|
|
317
|
+
const rules = rulesFor(field);
|
|
318
|
+
if (rules.skip)
|
|
319
|
+
continue;
|
|
320
|
+
const fieldPath = `${path}.${field.name}`;
|
|
321
|
+
if (field.fieldKind === "map")
|
|
322
|
+
requireStringKeys(field, fieldPath);
|
|
323
|
+
if (field.message !== undefined) {
|
|
324
|
+
this.validateSub(field.message, seen, fieldPath);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
validateSub(schema, seen, path) {
|
|
329
|
+
if (UNSUPPORTED.has(schema.typeName)) {
|
|
330
|
+
throw new CodecError("UNSUPPORTED_TYPE", `${schema.typeName} has no Firestore representation`, path);
|
|
331
|
+
}
|
|
332
|
+
if (schema.typeName === TIMESTAMP ||
|
|
333
|
+
schema.typeName === DURATION ||
|
|
334
|
+
schema.typeName === LAT_LNG) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
this.validate(schema, seen, path);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/** Every map and array is a level; the document itself is level 1. */
|
|
341
|
+
function checkDepth(depth, path) {
|
|
342
|
+
if (depth > MAX_NESTING_DEPTH) {
|
|
343
|
+
throw new CodecError("NESTING_TOO_DEEP", `nesting exceeds Firestore's limit of ${MAX_NESTING_DEPTH} levels`, path);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/** Checked on every encode and decode, not only in validateSchema, so a
|
|
347
|
+
* caller who skips validation still cannot write stringified keys. */
|
|
348
|
+
function requireStringKeys(field, path) {
|
|
349
|
+
if (field.mapKey !== ScalarType.STRING) {
|
|
350
|
+
throw new CodecError("UNSUPPORTED_MAP_KEY", "map keys must be strings; Firestore has no other key type", path);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const is64 = (scalar) => scalar === ScalarType.INT64 ||
|
|
354
|
+
scalar === ScalarType.SINT64 ||
|
|
355
|
+
scalar === ScalarType.SFIXED64 ||
|
|
356
|
+
scalar === ScalarType.UINT64 ||
|
|
357
|
+
scalar === ScalarType.FIXED64;
|
|
358
|
+
/** protobuf-es holds a `jstype = JS_STRING` 64-bit field as a decimal string.
|
|
359
|
+
* The encoding is defined on the integer, so coerce before doing anything. */
|
|
360
|
+
function normalize64(scalar, value) {
|
|
361
|
+
return is64(scalar) && typeof value === "string" ? BigInt(value) : value;
|
|
362
|
+
}
|
|
363
|
+
/** The inverse: hand a decoded 64-bit value back in the type the generated
|
|
364
|
+
* code declares, so the message compares and serializes like a native one. */
|
|
365
|
+
function toDeclared(field, value) {
|
|
366
|
+
// longAsString is only declared on the scalar-valued DescField variants.
|
|
367
|
+
const asString = field.longAsString === true;
|
|
368
|
+
return asString && typeof value === "bigint" ? value.toString() : value;
|
|
369
|
+
}
|
|
370
|
+
/** protobuf-es models a oneof as one tagged `{ case, value }` property named
|
|
371
|
+
* after the oneof; the member has no property of its own. Assigning the member
|
|
372
|
+
* name directly leaves the oneof unset and the value invisible. */
|
|
373
|
+
function writeField(message, field, value) {
|
|
374
|
+
if (field.oneof !== undefined) {
|
|
375
|
+
message[field.oneof.localName] = { case: field.localName, value };
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
message[field.localName] = value;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* protobuf-es stamps every message with `$typeName`. A message from another
|
|
383
|
+
* runtime has none: `protoc-gen-js` keeps its fields in a private array behind
|
|
384
|
+
* getters, so every `readField` would return undefined and the message would
|
|
385
|
+
* encode to an empty document without a word. Refuse it instead -- see the
|
|
386
|
+
* README on bridging from google-protobuf.
|
|
387
|
+
*/
|
|
388
|
+
function requireMessage(schema, value, path) {
|
|
389
|
+
if (isMessage(value, schema))
|
|
390
|
+
return;
|
|
391
|
+
// Naming the remedy is the point of the guard: this is the case it exists
|
|
392
|
+
// for, and it is the one case where the fix is known.
|
|
393
|
+
const remedy = isJspbMessage(value)
|
|
394
|
+
? '; convert it first -- see "Bridging from google-protobuf" in the README'
|
|
395
|
+
: "";
|
|
396
|
+
throw new CodecError("UNSUPPORTED_TYPE", `expected a protobuf-es ${schema.typeName}, got ${describeValue(value)}${remedy}`, path);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* A `protoc-gen-js` message, detected positively. The generated classes are
|
|
400
|
+
* anonymous, so there is no name to fall back on, and `displayName` is set
|
|
401
|
+
* only under `goog.DEBUG && !COMPILED`. These two methods are on every one.
|
|
402
|
+
*/
|
|
403
|
+
function isJspbMessage(value) {
|
|
404
|
+
if (typeof value !== "object" || value === null)
|
|
405
|
+
return false;
|
|
406
|
+
const v = value;
|
|
407
|
+
return (typeof v.serializeBinary === "function" && typeof v.toObject === "function");
|
|
408
|
+
}
|
|
409
|
+
function describeValue(value) {
|
|
410
|
+
if (value === null)
|
|
411
|
+
return "null";
|
|
412
|
+
if (typeof value !== "object")
|
|
413
|
+
return typeof value;
|
|
414
|
+
if (isMessage(value))
|
|
415
|
+
return value.$typeName;
|
|
416
|
+
if (isJspbMessage(value))
|
|
417
|
+
return "a google-protobuf message";
|
|
418
|
+
// An anonymous class has a name, and it is the empty string -- as
|
|
419
|
+
// uninformative as having none, and it would read as a dangling "got a ".
|
|
420
|
+
const name = value.constructor?.name;
|
|
421
|
+
return name === undefined || name === "" || name === "Object"
|
|
422
|
+
? "a plain object"
|
|
423
|
+
: `a ${name}`;
|
|
424
|
+
}
|
|
425
|
+
function readField(message, field) {
|
|
426
|
+
if (field.oneof !== undefined) {
|
|
427
|
+
const holder = message[field.oneof.localName];
|
|
428
|
+
return holder?.case === field.localName ? holder.value : undefined;
|
|
429
|
+
}
|
|
430
|
+
return message[field.localName];
|
|
431
|
+
}
|
|
432
|
+
function readLatLng(schema, message, path) {
|
|
433
|
+
let latitude;
|
|
434
|
+
let longitude;
|
|
435
|
+
for (const f of schema.fields) {
|
|
436
|
+
if (f.name === "latitude")
|
|
437
|
+
latitude = message[f.localName] ?? 0;
|
|
438
|
+
if (f.name === "longitude")
|
|
439
|
+
longitude = message[f.localName] ?? 0;
|
|
440
|
+
}
|
|
441
|
+
if (latitude === undefined || longitude === undefined) {
|
|
442
|
+
throw new CodecError("UNSUPPORTED_TYPE", "KIND_GEO_POINT requires double fields named latitude and longitude", path);
|
|
443
|
+
}
|
|
444
|
+
if (Number.isNaN(latitude) ||
|
|
445
|
+
Number.isNaN(longitude) ||
|
|
446
|
+
latitude < -90 ||
|
|
447
|
+
latitude > 90 ||
|
|
448
|
+
longitude < -180 ||
|
|
449
|
+
longitude > 180) {
|
|
450
|
+
throw new CodecError("LATLNG_OUT_OF_RANGE", "latitude must be within [-90, 90] and longitude within [-180, 180], " +
|
|
451
|
+
`got (${latitude}, ${longitude})`, path);
|
|
452
|
+
}
|
|
453
|
+
return { latitude, longitude };
|
|
454
|
+
}
|
|
455
|
+
function decodeUnsigned(raw, path) {
|
|
456
|
+
if (typeof raw !== "string") {
|
|
457
|
+
throw new CodecError("UNSIGNED_MALFORMED", `expected a decimal string, got ${typeof raw}`, path);
|
|
458
|
+
}
|
|
459
|
+
if (!CANONICAL_UNSIGNED.test(raw)) {
|
|
460
|
+
throw new CodecError("UNSIGNED_MALFORMED", `not a canonical unsigned decimal (no sign, no leading zeros): "${raw}"`, path);
|
|
461
|
+
}
|
|
462
|
+
const value = BigInt(raw);
|
|
463
|
+
if (value > MAX_UINT64) {
|
|
464
|
+
throw new CodecError("UNSIGNED_OUT_OF_RANGE", `${raw} exceeds the maximum unsigned 64-bit value`, path);
|
|
465
|
+
}
|
|
466
|
+
return value;
|
|
467
|
+
}
|
|
468
|
+
function isDefault(value) {
|
|
469
|
+
if (value === undefined || value === null)
|
|
470
|
+
return true;
|
|
471
|
+
if (typeof value === "string")
|
|
472
|
+
return value === "";
|
|
473
|
+
if (typeof value === "boolean")
|
|
474
|
+
return !value;
|
|
475
|
+
if (typeof value === "number")
|
|
476
|
+
return value === 0;
|
|
477
|
+
if (typeof value === "bigint")
|
|
478
|
+
return value === 0n;
|
|
479
|
+
if (value instanceof Uint8Array)
|
|
480
|
+
return value.length === 0;
|
|
481
|
+
if (Array.isArray(value))
|
|
482
|
+
return value.length === 0;
|
|
483
|
+
return false;
|
|
484
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Error codes shared with the conformance suite (`testdata/manifest.json`). */
|
|
2
|
+
export type CodecErrorCode = "UNSIGNED_MALFORMED" | "UNSIGNED_OUT_OF_RANGE" | "UNSIGNED_NOT_REPRESENTABLE" | "LATLNG_OUT_OF_RANGE" | "NESTING_TOO_DEEP" | "UNSUPPORTED_MAP_KEY" | "UNSUPPORTED_TYPE" | "ENUM_VALUE_UNKNOWN";
|
|
3
|
+
/** Thrown for anything the encoding refuses to represent. */
|
|
4
|
+
export declare class CodecError extends Error {
|
|
5
|
+
readonly code: CodecErrorCode;
|
|
6
|
+
/** Dotted field path to the offending value, when there is one. */
|
|
7
|
+
readonly path?: string | undefined;
|
|
8
|
+
constructor(code: CodecErrorCode, message: string,
|
|
9
|
+
/** Dotted field path to the offending value, when there is one. */
|
|
10
|
+
path?: string | undefined);
|
|
11
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Thrown for anything the encoding refuses to represent. */
|
|
2
|
+
export class CodecError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
path;
|
|
5
|
+
constructor(code, message,
|
|
6
|
+
/** Dotted field path to the offending value, when there is one. */
|
|
7
|
+
path) {
|
|
8
|
+
super(path === undefined ? message : `${message} (at ${path})`);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.path = path;
|
|
11
|
+
this.name = "CodecError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { GenEnum, GenExtension, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
|
|
2
|
+
import type { FieldOptions } from "@bufbuild/protobuf/wkt";
|
|
3
|
+
import type { Message } from "@bufbuild/protobuf";
|
|
4
|
+
/**
|
|
5
|
+
* Describes the file codebinge/firestore/codec/v1/options.proto.
|
|
6
|
+
*/
|
|
7
|
+
export declare const file_codebinge_firestore_codec_v1_options: GenFile;
|
|
8
|
+
/**
|
|
9
|
+
* @generated from message codebinge.firestore.codec.v1.Field
|
|
10
|
+
*/
|
|
11
|
+
export type Field = Message<"codebinge.firestore.codec.v1.Field"> & {
|
|
12
|
+
/**
|
|
13
|
+
* Never encoded.
|
|
14
|
+
*
|
|
15
|
+
* @generated from field: bool skip = 1;
|
|
16
|
+
*/
|
|
17
|
+
skip: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Stored-name override. Exists only for adopting an existing collection;
|
|
20
|
+
* the default is the proto field name verbatim (§1).
|
|
21
|
+
*
|
|
22
|
+
* @generated from field: string name = 2;
|
|
23
|
+
*/
|
|
24
|
+
name: string;
|
|
25
|
+
/**
|
|
26
|
+
* Default: encode enum values as their name string (§4).
|
|
27
|
+
*
|
|
28
|
+
* @generated from field: codebinge.firestore.codec.v1.EnumEncoding enum_as = 3;
|
|
29
|
+
*/
|
|
30
|
+
enumAs: EnumEncoding;
|
|
31
|
+
/**
|
|
32
|
+
* Default true: omit singular scalar fields holding their default value (§6).
|
|
33
|
+
* Set false where an index or security rule depends on the field existing.
|
|
34
|
+
*
|
|
35
|
+
* Explicit presence is required. The intended default is true, and a proto3
|
|
36
|
+
* implicit-presence bool would not serialize `false`, leaving "set to false"
|
|
37
|
+
* indistinguishable from "not set".
|
|
38
|
+
*
|
|
39
|
+
* @generated from field: optional bool omit_when_default = 4;
|
|
40
|
+
*/
|
|
41
|
+
omitWhenDefault?: boolean | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Overrides the encoding inferred from the proto type.
|
|
44
|
+
*
|
|
45
|
+
* @generated from field: codebinge.firestore.codec.v1.Kind kind = 5;
|
|
46
|
+
*/
|
|
47
|
+
kind: Kind;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Describes the message codebinge.firestore.codec.v1.Field.
|
|
51
|
+
* Use `create(FieldSchema)` to create a new message.
|
|
52
|
+
*/
|
|
53
|
+
export declare const FieldSchema: GenMessage<Field>;
|
|
54
|
+
/**
|
|
55
|
+
* @generated from enum codebinge.firestore.codec.v1.Kind
|
|
56
|
+
*/
|
|
57
|
+
export declare enum Kind {
|
|
58
|
+
/**
|
|
59
|
+
* Infer from the proto type. The default for every field.
|
|
60
|
+
*
|
|
61
|
+
* @generated from enum value: KIND_UNSPECIFIED = 0;
|
|
62
|
+
*/
|
|
63
|
+
UNSPECIFIED = 0,
|
|
64
|
+
/**
|
|
65
|
+
* A project's own lat/lng message -> GeoPoint (§3.3). Not needed for
|
|
66
|
+
* google.type.LatLng, which is recognized automatically. The annotated
|
|
67
|
+
* message must have exactly two double fields named `latitude` and
|
|
68
|
+
* `longitude`.
|
|
69
|
+
*
|
|
70
|
+
* @generated from enum value: KIND_GEO_POINT = 1;
|
|
71
|
+
*/
|
|
72
|
+
GEO_POINT = 1,
|
|
73
|
+
/**
|
|
74
|
+
* uint64/fixed64 -> Integer rather than String, keeping the field ordered
|
|
75
|
+
* and queryable. Throws at encode time above 2^63-1 (§2.1).
|
|
76
|
+
*
|
|
77
|
+
* @generated from enum value: KIND_UNSIGNED_AS_INTEGER = 2;
|
|
78
|
+
*/
|
|
79
|
+
UNSIGNED_AS_INTEGER = 2
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Describes the enum codebinge.firestore.codec.v1.Kind.
|
|
83
|
+
*/
|
|
84
|
+
export declare const KindSchema: GenEnum<Kind>;
|
|
85
|
+
/**
|
|
86
|
+
* @generated from enum codebinge.firestore.codec.v1.EnumEncoding
|
|
87
|
+
*/
|
|
88
|
+
export declare enum EnumEncoding {
|
|
89
|
+
/**
|
|
90
|
+
* @generated from enum value: ENUM_ENCODING_NAME = 0;
|
|
91
|
+
*/
|
|
92
|
+
NAME = 0,
|
|
93
|
+
/**
|
|
94
|
+
* @generated from enum value: ENUM_ENCODING_NUMBER = 1;
|
|
95
|
+
*/
|
|
96
|
+
NUMBER = 1
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Describes the enum codebinge.firestore.codec.v1.EnumEncoding.
|
|
100
|
+
*/
|
|
101
|
+
export declare const EnumEncodingSchema: GenEnum<EnumEncoding>;
|
|
102
|
+
/**
|
|
103
|
+
* @generated from extension: codebinge.firestore.codec.v1.Field field = 50000;
|
|
104
|
+
*/
|
|
105
|
+
export declare const field: GenExtension<FieldOptions, Field>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Copyright 2026 Code Binge LLC
|
|
2
|
+
import { enumDesc, extDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
|
|
3
|
+
import { file_google_protobuf_descriptor } from "@bufbuild/protobuf/wkt";
|
|
4
|
+
/**
|
|
5
|
+
* Describes the file codebinge/firestore/codec/v1/options.proto.
|
|
6
|
+
*/
|
|
7
|
+
export const file_codebinge_firestore_codec_v1_options = /*@__PURE__*/ fileDesc("Cipjb2RlYmluZ2UvZmlyZXN0b3JlL2NvZGVjL3YxL29wdGlvbnMucHJvdG8SHGNvZGViaW5nZS5maXJlc3RvcmUuY29kZWMudjEiyAEKBUZpZWxkEgwKBHNraXAYASABKAgSDAoEbmFtZRgCIAEoCRI7CgdlbnVtX2FzGAMgASgOMiouY29kZWJpbmdlLmZpcmVzdG9yZS5jb2RlYy52MS5FbnVtRW5jb2RpbmcSHgoRb21pdF93aGVuX2RlZmF1bHQYBCABKAhIAIgBARIwCgRraW5kGAUgASgOMiIuY29kZWJpbmdlLmZpcmVzdG9yZS5jb2RlYy52MS5LaW5kQhQKEl9vbWl0X3doZW5fZGVmYXVsdCpOCgRLaW5kEhQKEEtJTkRfVU5TUEVDSUZJRUQQABISCg5LSU5EX0dFT19QT0lOVBABEhwKGEtJTkRfVU5TSUdORURfQVNfSU5URUdFUhACKkAKDEVudW1FbmNvZGluZxIWChJFTlVNX0VOQ09ESU5HX05BTUUQABIYChRFTlVNX0VOQ09ESU5HX05VTUJFUhABOloKBWZpZWxkEh0uZ29vZ2xlLnByb3RvYnVmLkZpZWxkT3B0aW9ucxjQhgMgASgLMiMuY29kZWJpbmdlLmZpcmVzdG9yZS5jb2RlYy52MS5GaWVsZFIFZmllbGRCJAogY29tLmNvZGViaW5nZS5maXJlc3RvcmUuY29kZWMudjFQAWIGcHJvdG8z", [file_google_protobuf_descriptor]);
|
|
8
|
+
/**
|
|
9
|
+
* Describes the message codebinge.firestore.codec.v1.Field.
|
|
10
|
+
* Use `create(FieldSchema)` to create a new message.
|
|
11
|
+
*/
|
|
12
|
+
export const FieldSchema = /*@__PURE__*/ messageDesc(file_codebinge_firestore_codec_v1_options, 0);
|
|
13
|
+
/**
|
|
14
|
+
* @generated from enum codebinge.firestore.codec.v1.Kind
|
|
15
|
+
*/
|
|
16
|
+
export var Kind;
|
|
17
|
+
(function (Kind) {
|
|
18
|
+
/**
|
|
19
|
+
* Infer from the proto type. The default for every field.
|
|
20
|
+
*
|
|
21
|
+
* @generated from enum value: KIND_UNSPECIFIED = 0;
|
|
22
|
+
*/
|
|
23
|
+
Kind[Kind["UNSPECIFIED"] = 0] = "UNSPECIFIED";
|
|
24
|
+
/**
|
|
25
|
+
* A project's own lat/lng message -> GeoPoint (§3.3). Not needed for
|
|
26
|
+
* google.type.LatLng, which is recognized automatically. The annotated
|
|
27
|
+
* message must have exactly two double fields named `latitude` and
|
|
28
|
+
* `longitude`.
|
|
29
|
+
*
|
|
30
|
+
* @generated from enum value: KIND_GEO_POINT = 1;
|
|
31
|
+
*/
|
|
32
|
+
Kind[Kind["GEO_POINT"] = 1] = "GEO_POINT";
|
|
33
|
+
/**
|
|
34
|
+
* uint64/fixed64 -> Integer rather than String, keeping the field ordered
|
|
35
|
+
* and queryable. Throws at encode time above 2^63-1 (§2.1).
|
|
36
|
+
*
|
|
37
|
+
* @generated from enum value: KIND_UNSIGNED_AS_INTEGER = 2;
|
|
38
|
+
*/
|
|
39
|
+
Kind[Kind["UNSIGNED_AS_INTEGER"] = 2] = "UNSIGNED_AS_INTEGER";
|
|
40
|
+
})(Kind || (Kind = {}));
|
|
41
|
+
/**
|
|
42
|
+
* Describes the enum codebinge.firestore.codec.v1.Kind.
|
|
43
|
+
*/
|
|
44
|
+
export const KindSchema = /*@__PURE__*/ enumDesc(file_codebinge_firestore_codec_v1_options, 0);
|
|
45
|
+
/**
|
|
46
|
+
* @generated from enum codebinge.firestore.codec.v1.EnumEncoding
|
|
47
|
+
*/
|
|
48
|
+
export var EnumEncoding;
|
|
49
|
+
(function (EnumEncoding) {
|
|
50
|
+
/**
|
|
51
|
+
* @generated from enum value: ENUM_ENCODING_NAME = 0;
|
|
52
|
+
*/
|
|
53
|
+
EnumEncoding[EnumEncoding["NAME"] = 0] = "NAME";
|
|
54
|
+
/**
|
|
55
|
+
* @generated from enum value: ENUM_ENCODING_NUMBER = 1;
|
|
56
|
+
*/
|
|
57
|
+
EnumEncoding[EnumEncoding["NUMBER"] = 1] = "NUMBER";
|
|
58
|
+
})(EnumEncoding || (EnumEncoding = {}));
|
|
59
|
+
/**
|
|
60
|
+
* Describes the enum codebinge.firestore.codec.v1.EnumEncoding.
|
|
61
|
+
*/
|
|
62
|
+
export const EnumEncodingSchema = /*@__PURE__*/ enumDesc(file_codebinge_firestore_codec_v1_options, 1);
|
|
63
|
+
/**
|
|
64
|
+
* @generated from extension: codebinge.firestore.codec.v1.Field field = 50000;
|
|
65
|
+
*/
|
|
66
|
+
export const field = /*@__PURE__*/ extDesc(file_codebinge_firestore_codec_v1_options, 0);
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { FirestoreProtoCodec, MAX_NESTING_DEPTH } from "./codec.js";
|
|
2
|
+
export type { FirestoreDocument } from "./codec.js";
|
|
3
|
+
export { CodecError } from "./errors.js";
|
|
4
|
+
export type { CodecErrorCode } from "./errors.js";
|
|
5
|
+
export { DefaultFirestoreTypes, FsBlob, FsGeoPoint, FsTimestamp, } from "./values.js";
|
|
6
|
+
export type { FirestoreTypes } from "./values.js";
|