@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/LICENSE +204 -0
- package/README.md +860 -0
- package/environment.js +2 -0
- package/package.json +81 -0
- package/requirements.txt +1 -0
- package/scripts/ci/lint-format-js.sh +13 -0
- package/scripts/ci/markdown-link.sh +8 -0
- package/scripts/start-local-db.sh +2 -0
- package/src/async-emitter.js +50 -0
- package/src/context.js +677 -0
- package/src/data.js +24 -0
- package/src/db-error.js +12 -0
- package/src/default-db.js +21 -0
- package/src/errors.js +149 -0
- package/src/fields.js +526 -0
- package/src/firestore.js +75 -0
- package/src/key.js +66 -0
- package/src/models.js +542 -0
- package/src/utils.js +75 -0
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
|
+
}
|
package/src/db-error.js
ADDED
|
@@ -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
|