@pbvision/firestore-orm 0.0.2

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/src/context.js ADDED
@@ -0,0 +1,677 @@
1
+ const assert = require('assert')
2
+
3
+ const { Transaction } = require('@google-cloud/firestore')
4
+ const { detailedDiff } = require('deep-object-diff')
5
+
6
+ const AsyncEmitter = require('./async-emitter')
7
+ const { Data } = require('./data')
8
+ const DBError = require('./db-error')
9
+ const {
10
+ InvalidOptionsError,
11
+ InvalidParameterError,
12
+ DeletedTwiceError,
13
+ TransactionFailedError,
14
+ WriteAttemptedInReadOnlyTxError,
15
+ ModelTrackedTwiceError,
16
+ ModelAlreadyExistsError,
17
+ TransactionLockTimeoutError
18
+ } = require('./errors')
19
+ const { Key } = require('./key')
20
+ const { Model } = require('./models')
21
+ const { sleep, loadOptionDefaults } = require('./utils')
22
+
23
+ async function getWithArgs (args, callback) {
24
+ if (!args || !(args instanceof Array) || args.length === 0) {
25
+ throw new InvalidParameterError('args', 'must be a non-empty array')
26
+ }
27
+ const [first, ...args1] = args
28
+ if (first && first.prototype instanceof Model) {
29
+ if (args1.length === 1 || args1.length === 2) {
30
+ let handle
31
+ if (args1.length === 2 && args1[1].createIfMissing) {
32
+ handle = first.data(args1[0])
33
+ } else {
34
+ handle = first.key(args1[0])
35
+ }
36
+ return getWithArgs([handle, ...args1.slice(1)], callback)
37
+ } else {
38
+ throw new InvalidParameterError('args',
39
+ 'Expecting args to have a tuple of (Model, values, optionalOpt).')
40
+ }
41
+ } else if (first && first instanceof Key) {
42
+ if (args1.length > 1) {
43
+ throw new InvalidParameterError('args',
44
+ 'Expecting args to have a tuple of (key, optionalOpt).')
45
+ }
46
+ return callback(first, args1.length === 1 ? args1[0] : undefined)
47
+ } else if (first && first instanceof Array && first.length !== 0) {
48
+ const nonKeys = first.filter(obj => !(obj instanceof Key))
49
+ if (nonKeys.length !== 0) {
50
+ throw new InvalidParameterError('args',
51
+ 'Expecting args to have a tuple of ([key], optionalOpt).')
52
+ }
53
+ if (args1.length > 1) {
54
+ throw new InvalidParameterError('args',
55
+ 'Expecting args to have a tuple of ([key], optionalOpt).')
56
+ }
57
+
58
+ const params = args1.length === 1 ? args1[0] : undefined
59
+ return callback(first, params)
60
+ } else {
61
+ console.log(JSON.stringify(args))
62
+ throw new InvalidParameterError('args',
63
+ 'Expecting Model or Key or [Key] as the first argument')
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Context provides a context for interacting with Firestore. It will use a
69
+ * transaction if requested (e.g., we're making changes OR we need consistent
70
+ * reads across multiple docs).
71
+ * @public
72
+ */
73
+ class Context {
74
+ /**
75
+ * Options for interacting with Firestore.
76
+ * @typedef {Object} ContextOptions
77
+ * @property {Boolean} [readOnly=false] whether writes are allowed (we could
78
+ * allow writes outside of tx for performance too, but omitting it for
79
+ * safety for now)
80
+ * @property {Boolean} [consistentReads=true] whether multiple reads are
81
+ * guaranteed to be from a consistent snapshot
82
+ * @property {Number} [retries=4] The number of times to retry after the
83
+ * initial attempt fails.
84
+ * @property {Number} [initialBackoff=500] In milliseconds, delay
85
+ * after the first attempt fails and before first retry happens.
86
+ * @property {Number} [maxBackoff=10000] In milliseconds, max delay
87
+ * between retries. Must be larger than 200.
88
+ * @property {Number} [cacheModels=false] Whether to cache models already
89
+ * retrieved from the database. When off, getting a model with the same key
90
+ * the second time in the same transaction results in an error. When on,
91
+ * `get`ting the same key simply returns the cached model. Previous
92
+ * modifications done to the model are reflected in the returned model. If
93
+ * the model key was used in some API other than "get", an error will
94
+ * result.
95
+ * @public
96
+ */
97
+
98
+ /**
99
+ * Returns the default [options]{@link ContextOptions} for a db context.
100
+ */
101
+ get defaultOptions () {
102
+ // istanbul ignore next
103
+ return {
104
+ readOnly: false,
105
+ consistentReads: true,
106
+ initialBackoff: process.env.INDEBUGGER ? 1 : 500,
107
+ maxBackoff: 10000,
108
+ retries: process.env.INDEBUGGER ? 0 : 4, // no retry by default in unit tests for speed
109
+ cacheModels: false
110
+ }
111
+ }
112
+
113
+ /**
114
+ * @param {ContextOptions} [options] Options for this context
115
+ */
116
+ constructor (options) {
117
+ this.__eventEmitter = new AsyncEmitter()
118
+ // watch for changes in models we access through this context
119
+ this.__trackedModelsMap = {} // document path -> index of model in the list
120
+
121
+ // null elements = deleted
122
+ // undefined elements = fetched but did not exist
123
+ // other elements will be the model we're tracking
124
+ this.__trackedModelsList = []
125
+
126
+ // our reference to the db client changes to a transaction ref if needed
127
+ this.__dbCtx = Key.firestoreDB
128
+
129
+ const defaults = this.defaultOptions
130
+ this.options = loadOptionDefaults(options, defaults)
131
+
132
+ if (this.options.retries < 0) {
133
+ throw new InvalidOptionsError('retries',
134
+ 'Retry count must be non-negative')
135
+ }
136
+ if (this.options.initialBackoff < 1) {
137
+ throw new InvalidOptionsError('initialBackoff',
138
+ 'Initial back off must be larger than 1ms.')
139
+ }
140
+ if (this.options.maxBackoff < 200) {
141
+ // A transactWrite would take some where between 100~200ms.
142
+ // Max of less than 200 is too aggressive.
143
+ throw new InvalidOptionsError('maxBackoff',
144
+ 'Max back off must be larger than 200ms.')
145
+ }
146
+ // read only context use transactions which get a consistent snapshot; to
147
+ // read data inconsistently use another context
148
+ if (!this.options.readOnly) {
149
+ if (!this.options.consistentReads) {
150
+ throw new InvalidOptionsError('consistentReads',
151
+ 'read only contexts use a transaction to read data which results ' +
152
+ 'in a consistent snapshot; use another context to read data ' +
153
+ 'without consistency guarantees (and the locks they may acquire)')
154
+ }
155
+ }
156
+ this.isUsingTx = !this.options.readOnly || this.options.consistentReads
157
+ }
158
+
159
+ /**
160
+ * Track models which have been accessed.
161
+ * @param {Model} model A model to track.
162
+ * @private
163
+ */
164
+ __watchForChangesToSave (model, key) {
165
+ assert.ok(model || key, 'must provide model or key to __watch')
166
+ assert.ok(!(model && key), 'cannot provide both model and key to __watch')
167
+ const keyToUse = model ? model.__key : key
168
+ const path = keyToUse.docRef.path
169
+ const trackedModelIdx = this.__trackedModelsMap[path]
170
+ if (trackedModelIdx !== undefined) {
171
+ const trackedModel = this.__trackedModelsList[trackedModelIdx]
172
+ if (trackedModel) {
173
+ throw new ModelTrackedTwiceError(keyToUse, trackedModel)
174
+ } else {
175
+ // model is undefined if we tried to get() it (without createIfMissing)
176
+ // and it did not yet exist
177
+ this.__trackedModelsList[trackedModelIdx] = model
178
+ }
179
+ } else {
180
+ this.__trackedModelsMap[path] = this.__trackedModelsList.length
181
+ this.__trackedModelsList.push(model)
182
+ }
183
+ }
184
+
185
+ async __saveChangedModels () {
186
+ for (const model of this.__trackedModelsList) {
187
+ if (model && (model.isNew || model.__isMutated(!this.options.readOnly))) {
188
+ this.__throwIfWritesNotAllowed(model)
189
+ await model.__write(this)
190
+ }
191
+ }
192
+ }
193
+
194
+ /**
195
+ * All events a context may emit.
196
+ *
197
+ * POST_COMMIT: When a transaction is committed. Do clean up,
198
+ * summery, post process here.
199
+ * TX_FAILED: When a transaction failed permanently (either by failing all
200
+ * retries, or getting a non-retryable error). Handler has the
201
+ * signature of (error) => {}.
202
+ */
203
+ static EVENTS = {
204
+ POST_COMMIT: 'postCommit',
205
+ TX_FAILED: 'txFailed'
206
+ }
207
+
208
+ addEventHandler (event, handler, name = undefined) {
209
+ if (!Object.values(this.constructor.EVENTS).includes(event)) {
210
+ throw new Error(`Unsupported event ${event}`)
211
+ }
212
+ // istanbul ignore next
213
+ assert(name === undefined || !name.startsWith('_'),
214
+ 'Event name must not start with "_"')
215
+ this.__eventIndex = (this.__eventIndex ?? 0) + 1
216
+ this.__eventEmitter.once(event, handler,
217
+ name ?? `_generatedName${this.__eventIndex}`)
218
+ }
219
+
220
+ /**
221
+ * Parameters for fetching a model and options to control how a model is
222
+ * fetched from database.
223
+ * @typedef {Object} GetParams
224
+ * @property {Boolean} [createIfMissing=false] If true, a model is returned
225
+ * regardless of whether the model exists on server. This behavior is the
226
+ * same as calling create when get(..., { createIfMissing: false }) returns
227
+ * undefined
228
+ * @property {*} [*] Besides the predefined options, custom key-value pairs
229
+ * can be added. These values will be made available to the Model's
230
+ * constructor as an argument.
231
+ */
232
+
233
+ /**
234
+ * Get one document.
235
+ *
236
+ * @param {Key} key A key for the doc
237
+ * @param {GetParams} params Params for how to get the doc
238
+ * @private
239
+ */
240
+ async __getDoc (key, params) {
241
+ const docRef = key.docRef
242
+ const doc = await this.__dbCtx.get(docRef)
243
+ .catch(
244
+ // istanbul ignore next
245
+ e => {
246
+ throw new DBError('get', e)
247
+ })
248
+ return this.__gotDoc(key, params, doc)
249
+ }
250
+
251
+ /**
252
+ * Gets multiple docs in a single call.
253
+ * @param {Array<Key>} keys A list of keys to get.
254
+ * @param {GetParams} params Params used to get docs, all docs will be
255
+ * fetched using the same params.
256
+ * @private
257
+ */
258
+ async __getDocs (keys, params) {
259
+ const docRefs = keys.map(key => key.docRef)
260
+ const docs = await this.__dbCtx.getAll(...docRefs)
261
+ .catch(
262
+ // istanbul ignore next
263
+ e => {
264
+ throw new DBError('getAll', e)
265
+ })
266
+ const promises = docs.map((doc, i) => this.__gotDoc(keys[i], params, doc))
267
+ return Promise.all(promises)
268
+ }
269
+
270
+ async __gotDoc (key, params, doc) {
271
+ const isNew = !doc.exists
272
+ if (!params.createIfMissing && isNew) {
273
+ this.__watchForChangesToSave(undefined, key)
274
+ return undefined
275
+ }
276
+ const vals = isNew ? key.vals : (await doc.data())
277
+ if (!isNew) {
278
+ // doc.data() only has data values; vals needs to include key values too
279
+ Object.assign(vals, key.keyComponents)
280
+ }
281
+ const model = new key.Cls(isNew, vals)
282
+ this.__watchForChangesToSave(model)
283
+ return model
284
+ }
285
+
286
+ /**
287
+ * Fetches model(s) from database.
288
+ * This method supports 3 different signatures.
289
+ * get(Cls, keyOrDataValues, params)
290
+ * get(Key|Data, params)
291
+ * get([Key|Data], params)
292
+ *
293
+ * Must use a Key when createIfMissing is not true, and Data otherwise.
294
+ *
295
+ * When a list of docs is fetched:
296
+ * Firestore getAll API is called.
297
+ * Batched fetches are more efficient than calling get with 1 key many
298
+ * times, since there is less HTTP request overhead.
299
+ *
300
+ * @param {Class} Cls a Model class.
301
+ * @param {String|CompositeID} key Key or keyValues
302
+ * @param {GetParams} [params]
303
+ * @returns Model(s) associated with provided key
304
+ */
305
+ async get (...args) {
306
+ return getWithArgs(args, async (arg, params) => {
307
+ // make sure we have a Key or Data depending on createIfMissing
308
+ params = params || {}
309
+ const argIsArray = arg instanceof Array
310
+ const arr = argIsArray ? arg : [arg]
311
+ for (let i = 0; i < arr.length; i++) {
312
+ if (params.createIfMissing) {
313
+ if (!(arr[i] instanceof Data)) {
314
+ throw new InvalidParameterError('args',
315
+ 'must pass a Data to tx.get() when createIfMissing is true')
316
+ }
317
+ } else if (arr[i] instanceof Data) {
318
+ throw new InvalidParameterError('args',
319
+ 'must pass a Key to tx.get() when createIfMissing is not true')
320
+ }
321
+ }
322
+ const cachedModels = []
323
+ const keysOrDataToGet = []
324
+ for (const keyOrData of arr) {
325
+ const key = keyOrData.key ?? keyOrData
326
+ const cachedModelIdx = this.__trackedModelsMap[key.docRef.path]
327
+ if (cachedModelIdx !== undefined) {
328
+ const cachedModel = this.__trackedModelsList[cachedModelIdx]
329
+ if (!this.options.cacheModels) {
330
+ throw new ModelTrackedTwiceError(key, cachedModel)
331
+ }
332
+ if (cachedModel === undefined && params.createIfMissing) {
333
+ keysOrDataToGet.push(keyOrData)
334
+ } else {
335
+ cachedModels.push({ key, model: cachedModel })
336
+ }
337
+ } else {
338
+ keysOrDataToGet.push(keyOrData)
339
+ }
340
+ }
341
+
342
+ // fetch the data in bulk if more than 1 doc was requested
343
+ const fetchedModels = []
344
+ if (keysOrDataToGet.length > 0) {
345
+ if (argIsArray) {
346
+ fetchedModels.push(
347
+ ...await this.__getDocs(keysOrDataToGet, params))
348
+ } else {
349
+ // just fetch the one doc that was requested
350
+ const key = keysOrDataToGet[0]
351
+ const fut = this.__getDoc(key, params)
352
+ const newModel = await fut
353
+ fetchedModels.push(newModel)
354
+ }
355
+ }
356
+
357
+ let ret = []
358
+ if (this.options.cacheModels) {
359
+ const findModel = (collectionName, id) => {
360
+ for (let index = 0; index < keysOrDataToGet.length; index++) {
361
+ const toGetKeyOrData = keysOrDataToGet[index]
362
+ if (collectionName === toGetKeyOrData.Cls.collectionName &&
363
+ id === toGetKeyOrData.encodedKey) {
364
+ return fetchedModels[index]
365
+ }
366
+ }
367
+
368
+ for (const { key, model } of cachedModels) {
369
+ // istanbul ignore else
370
+ if (collectionName === key.Cls.collectionName && id === key.encodedKey) {
371
+ // undefined if previous get() found nothing
372
+ // null if previously delete()
373
+ // otherwise a model will be here
374
+ return model
375
+ }
376
+ }
377
+ }
378
+ for (const keyOrData of arr) {
379
+ ret.push(findModel(
380
+ keyOrData.Cls.collectionName,
381
+ keyOrData.encodedKey
382
+ ))
383
+ }
384
+ } else {
385
+ // UnorderedModels is really ordered when cacheModels is disabled
386
+ // don't sort to save time
387
+ ret = fetchedModels
388
+ }
389
+
390
+ return argIsArray ? ret : ret[0]
391
+ })
392
+ }
393
+
394
+ /**
395
+ * Updates an doc without reading from DB. Fails if doc is not in the db.
396
+ *
397
+ * @param {CompositeID} key The key to update
398
+ * @param {Object} data Updated fields for the doc
399
+ */
400
+ async updateWithoutRead (Cls, data) {
401
+ const model = new Cls(false, data, true)
402
+ this.__throwIfWritesNotAllowed(model)
403
+ // TODO: should really track this too
404
+ await model.__write(this)
405
+ }
406
+
407
+ __throwIfWritesNotAllowed (data) {
408
+ if (this.options.readOnly) {
409
+ throw new WriteAttemptedInReadOnlyTxError(data)
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Creates a model without accessing DB. Write will make sure the doc does
415
+ * not exist.
416
+ *
417
+ * @param {Model} Cls A Model class.
418
+ * @param {CompositeID|Object} data A superset of CompositeID of the model,
419
+ * plus any data for Fields on the Model.
420
+ */
421
+ create (Cls, data) {
422
+ const model = new Cls(true, { ...data })
423
+ this.__watchForChangesToSave(model)
424
+ return model
425
+ }
426
+
427
+ /**
428
+ * Sets a model's data. Will overwrite the existing data, if any.
429
+ *
430
+ * @param {Model} Cls A Model class.
431
+ * @param {CompositeID|Object} data A superset of CompositeID of the model,
432
+ * plus any data for Fields on the Model.
433
+ */
434
+ createOrOverwrite (Cls, data) {
435
+ const model = new Cls(true, { ...data }, false, true)
436
+ this.__watchForChangesToSave(model)
437
+ return model
438
+ }
439
+
440
+ /**
441
+ * Deletes model(s) from database.
442
+ *
443
+ * If a model is read from database, but it did not exist when deleting the
444
+ * doc, an exception is raised.
445
+ *
446
+ * @param {List<Key|Model>} args Keys and Models
447
+ */
448
+ async delete (...args) {
449
+ this.__throwIfWritesNotAllowed(args[0])
450
+ for (const a of args) {
451
+ let key = a
452
+ if (a instanceof Model) {
453
+ key = a.__key
454
+ }
455
+ if (key instanceof Key) {
456
+ const path = key.docRef.path
457
+ const trackedModelIdx = this.__trackedModelsMap[path]
458
+ if (trackedModelIdx !== undefined) {
459
+ const trackedModel = this.__trackedModelsList[trackedModelIdx]
460
+ if (trackedModel === null) {
461
+ // already asked to delete it
462
+ throw new DeletedTwiceError(key.Cls.collectionName, key.encodedKey)
463
+ }
464
+ this.__trackedModelsList[trackedModelIdx] = null
465
+ } else {
466
+ this.__trackedModelsMap[path] = this.__trackedModelsList.length
467
+ this.__trackedModelsList.push(null)
468
+ }
469
+ const ret = this.__dbCtx.delete(key.docRef)
470
+ // depending on whether __dbCtx is a Firestore or Transaction object,
471
+ // we need to wait the result
472
+ if (!(ret instanceof Transaction)) {
473
+ await ret
474
+ }
475
+ } else {
476
+ throw new InvalidParameterError('args', 'Must be models and keys')
477
+ }
478
+ }
479
+ }
480
+
481
+ __reset () {
482
+ this.__eventEmitter = new AsyncEmitter()
483
+ this.__trackedModelsList = []
484
+ this.__trackedModelsMap = {}
485
+ }
486
+
487
+ static __isRetryable (err) {
488
+ if (err.retryable) {
489
+ return true
490
+ }
491
+ return false
492
+ }
493
+
494
+ /** Marks a transaction as read-only. */
495
+ makeReadOnly () {
496
+ this.options.readOnly = true
497
+ }
498
+
499
+ /** Enables model cache */
500
+ enableModelCache () {
501
+ this.options.cacheModels = true
502
+ }
503
+
504
+ async __tryToRun (func) {
505
+ const ctx = this
506
+ if (ctx.isUsingTx) {
507
+ return await Key.firestoreDB.runTransaction(async tx => {
508
+ ctx.__reset()
509
+ ctx.__dbCtx = tx
510
+ try {
511
+ const ret = await func(ctx)
512
+ await this.__saveChangedModels()
513
+ return ret
514
+ } finally {
515
+ ctx.__dbCtx = Key.firestoreDB
516
+ }
517
+ }, {
518
+ readOnly: ctx.options.readOnly,
519
+ maxAttempts: 1
520
+ })
521
+ } else {
522
+ return await func(ctx)
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Runs a closure in transaction.
528
+ * @param {Function} func the closure to run
529
+ * @access private
530
+ */
531
+ async __run (func) {
532
+ if (!(func instanceof Function || typeof func === 'function')) {
533
+ throw new InvalidParameterError('func', 'must be a function / closure')
534
+ }
535
+
536
+ let millisBackOff = this.options.initialBackoff
537
+ const maxBackoff = this.options.maxBackoff
538
+ for (let tryCnt = 0; tryCnt <= this.options.retries; tryCnt++) {
539
+ let err
540
+ try {
541
+ err = undefined
542
+ const ret = await this.__tryToRun(func)
543
+ await this.__eventEmitter.emit(this.constructor.EVENTS.POST_COMMIT)
544
+ return ret
545
+ } catch (originalErr) {
546
+ const firestoreError = parseFirestoreError(originalErr)
547
+ err = firestoreError ?? originalErr
548
+
549
+ // make sure EVERY error is retryable
550
+ if (!this.constructor.__isRetryable(err)) {
551
+ await this.__eventEmitter.emit(this.constructor.EVENTS.TX_FAILED,
552
+ err)
553
+ throw err
554
+ } else {
555
+ console.log(`ctx commit attempt ${tryCnt} failed with error ${err}`)
556
+ }
557
+ }
558
+ if (tryCnt >= this.options.retries) {
559
+ const retryMsg = `Giving up after ${tryCnt + 1} attempt${tryCnt ? 's' : ''}`
560
+ const newErrorMsg = `${retryMsg}: ${err.message}`
561
+ const errToThrow = new TransactionFailedError(newErrorMsg, err)
562
+ await this.__eventEmitter.emit(this.constructor.EVENTS.TX_FAILED, errToThrow)
563
+ throw errToThrow
564
+ }
565
+ const offset = Math.floor(Math.random() * millisBackOff * 0.2) -
566
+ millisBackOff * 0.1 // +-0.1 backoff as jitter to spread out conflicts
567
+ await sleep(millisBackOff + offset)
568
+ millisBackOff = Math.min(maxBackoff, millisBackOff * 2)
569
+ }
570
+ }
571
+
572
+ /**
573
+ * Runs a function in transaction if needed, using specified parameters.
574
+ *
575
+ * If a non-retryable error is thrown while running the transaction, it will
576
+ * be re-raised.
577
+ *
578
+ * @param {ContextOptions} [options]
579
+ * @param {Function} func the closure to run.
580
+ * @public
581
+ *
582
+ * @example
583
+ * // Can be called in 2 ways:
584
+ * Context.run(async (tx) => {
585
+ * // Do something
586
+ * })
587
+ *
588
+ * // Or
589
+ * Context.run({ retryCount: 2 }, async (tx) => {
590
+ * // Do something
591
+ * })
592
+ */
593
+ static async run (...args) {
594
+ const opts = (args.length === 1) ? {} : args[0]
595
+ const func = args[args.length - 1]
596
+ if (args.length <= 0 || args.length > 2) {
597
+ throw new InvalidParameterError('args', 'should be ([options,] func)')
598
+ }
599
+ return new Context(opts).__run(func)
600
+ }
601
+
602
+ /**
603
+ * Return before and after snapshots of all relevant models.
604
+ *
605
+ * Does not include deleted models.
606
+ */
607
+ getModelDiffs (filter = () => true) {
608
+ const allBefore = []
609
+ const allAfter = []
610
+ const allDiff = []
611
+ for (const model of this.__trackedModelsList) {
612
+ // istanbul ignore if
613
+ if (!filter(model) || !model) {
614
+ continue
615
+ }
616
+ const before = model.getSnapshot({ initial: true, dbKeys: true })
617
+ const after = model.getSnapshot({ initial: false, dbKeys: true })
618
+ const modelName = model.constructor.collectionName
619
+ const key = model.__key.encodedKey
620
+ allBefore.push({ [modelName]: { _id: key, data: before } })
621
+ allAfter.push({ [modelName]: { _id: key, data: after } })
622
+ const diff = detailedDiff(before, after)
623
+ allDiff.push({ [modelName]: { ...key, data: diff } })
624
+ }
625
+ return {
626
+ before: allBefore,
627
+ after: allAfter,
628
+ diff: allDiff
629
+ }
630
+ }
631
+ }
632
+
633
+ function parseFirestoreError (err) {
634
+ // probably a firestore error if it has these fields
635
+ if (err.code && err.details) {
636
+ // error 10 is the "Transaction lock timeout" error; retryable if separate
637
+ // transactions got in each other's way but could succeed if ordered
638
+ // differently (this error happens with pessimistic locking, the default
639
+ // Firestore setting)
640
+ if (err.code === 10 && err.details.indexOf('lock') !== -1) {
641
+ return new TransactionLockTimeoutError(err.message, err)
642
+ }
643
+
644
+ // error 6 is if you try to create a model that already exists
645
+ const docInfo = parseFirestoreErrorPath(err)
646
+ if (docInfo) {
647
+ if (err.code === 6) {
648
+ return new ModelAlreadyExistsError(docInfo.collection, docInfo.id)
649
+ }
650
+ }
651
+ }
652
+ }
653
+
654
+ function parseFirestoreErrorPath (err) {
655
+ const startIdx = err.message.indexOf('Element {')
656
+ if (startIdx >= 0) {
657
+ const endIdx = err.message.indexOf('}', startIdx)
658
+ if (endIdx > startIdx) {
659
+ try {
660
+ // hacky way to turn the error info into JSON
661
+ const eltStr = err.message.substring(startIdx + 8, endIdx + 1)
662
+ .replace('type', '"type"')
663
+ .replace('name', ', "name"')
664
+ const elt = JSON.parse(eltStr)
665
+ if (elt.type && elt.name) {
666
+ return { collection: elt.type, id: elt.name }
667
+ }
668
+ return elt
669
+ } catch { /* no-op */ }
670
+ }
671
+ }
672
+ }
673
+
674
+ module.exports = {
675
+ Context,
676
+ getWithArgs
677
+ }
package/src/data.js ADDED
@@ -0,0 +1,24 @@
1
+ const { Key } = require('./key')
2
+
3
+ /**
4
+ * Data includes a model's key and non-key fields.
5
+ * @param {Object} [fields] field (non-key) values
6
+ */
7
+ class Data extends Key {
8
+ constructor (Cls, encodedKey, keyComponents, fields) {
9
+ super(Cls, encodedKey, keyComponents)
10
+ this.data = fields
11
+ }
12
+
13
+ get key () {
14
+ return new Key(this.Cls, this.encodedKey, this.keyComponents)
15
+ }
16
+
17
+ get vals () {
18
+ return { ...this.keyComponents, ...this.data }
19
+ }
20
+ }
21
+
22
+ module.exports = {
23
+ Data
24
+ }
@@ -0,0 +1,12 @@
1
+ // istanbul ignore file
2
+ class DBError extends Error {
3
+ constructor (action, e) {
4
+ super(`Failed to ${action} with error ${e}`)
5
+ this.code = e.code
6
+ this.message = e.message
7
+ this.retryable = e.retryable
8
+ this.allErrors = e.allErrors
9
+ }
10
+ }
11
+
12
+ module.exports = DBError