@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.
@@ -0,0 +1,21 @@
1
+ // Convenient helper to setup our db connection using environment variables.
2
+ // The constructed db instance will be cached by NodeJS.
3
+ const Firestore = require('@google-cloud/firestore')
4
+
5
+ const setup = require('./firestore')
6
+
7
+ // make Firestore look like the Transaction object (it already has getAll but
8
+ // does not have these others)
9
+ Firestore.prototype.get = async (docRef) => docRef.get()
10
+ Firestore.prototype.delete = async (docRef) => docRef.delete()
11
+ Firestore.prototype.create = async (docRef, data) => docRef.create(data)
12
+ Firestore.prototype.set = async (docRef, data, options) => docRef.set(data, options)
13
+ Firestore.prototype.update = async (docRef, data) => docRef.update(data)
14
+
15
+ // TODO: get "host" field based on environment (e.g., dev or prod)
16
+ // automatically uses the emulator when FIRESTORE_EMULATOR_HOST is set
17
+ const firestoreClient = new Firestore({
18
+ ignoreUndefinedProperties: true
19
+ })
20
+
21
+ module.exports = setup(firestoreClient)
package/src/errors.js ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @namespace Errors
3
+ */
4
+
5
+ /**
6
+ * Thrown when supplied option is invalid.
7
+ *
8
+ * @access public
9
+ * @memberof Errors
10
+ */
11
+ class InvalidOptionsError extends Error {
12
+ constructor (option, expectation) {
13
+ super(`Invalid option value for ${option}. ${expectation}.`)
14
+ this.name = this.constructor.name
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Thrown when some parameter is invalid.
20
+ *
21
+ * @access public
22
+ * @memberof Errors
23
+ */
24
+ class InvalidParameterError extends Error {
25
+ constructor (param, expectation) {
26
+ super(`Invalid parameter ${param}. ${expectation}.`)
27
+ this.name = this.constructor.name
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Thrown when the library detects a field to be in an invalid state.
33
+ *
34
+ * @access public
35
+ * @memberof Errors
36
+ */
37
+ class InvalidFieldError extends Error {
38
+ constructor (field, reason) {
39
+ super(`${field} ${reason}`)
40
+ this.name = this.constructor.name
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Thrown when a transaction fails.
46
+ * Original exception is attached to property `original`
47
+ * Original stack is appended to current stack.
48
+ *
49
+ * @arg {string} msg the error message
50
+ * @arg {Error} [originalException] the original error which led to this
51
+ * @access public
52
+ * @memberof Errors
53
+ */
54
+ class TransactionFailedError extends Error {
55
+ constructor (msg, originalException) {
56
+ super(msg)
57
+ this.name = this.constructor.name
58
+ this.original = originalException
59
+ if (originalException instanceof Error) {
60
+ this.stack += '\n' + originalException.stack
61
+ }
62
+ }
63
+ }
64
+
65
+ class TransactionLockTimeoutError extends TransactionFailedError {
66
+ constructor (reason, original) {
67
+ super(reason, original)
68
+ this.name = this.constructor.name
69
+ this.retryable = true
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Thrown when there's some error with a particular model.
75
+ * @memberof Errors
76
+ */
77
+ class GenericModelError extends Error {
78
+ constructor (msg, collection, _id) {
79
+ super(`${msg}: ${collection} _id=${_id}`)
80
+ this.name = this.constructor.name
81
+ this.retryable = false
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Thrown when a model is to be created, but DB already has an doc with the
87
+ * same key.
88
+ * @memberof Errors
89
+ */
90
+ class ModelAlreadyExistsError extends GenericModelError {
91
+ constructor (collection, _id) {
92
+ super('Tried to recreate an existing model', collection, _id)
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Thrown when model is tracked more than once inside a transaction.
98
+ */
99
+ class ModelTrackedTwiceError extends GenericModelError {
100
+ constructor (key, trackedModel) {
101
+ const msg = 'Model tracked twice'
102
+ super(msg, key.Cls.collectionName, key.encodedKey)
103
+ this.newKey = key
104
+ this.trackedModel = trackedModel
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Thrown when a model is being deleted more than once.
110
+ * @memberof Errors
111
+ */
112
+ class DeletedTwiceError extends GenericModelError {
113
+ constructor (collectionName, _id) {
114
+ super('Tried to delete model twice in the same transaction',
115
+ collectionName, _id)
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Thrown when a tx tries to write when it was marked read-only.
121
+ * @memberof Errors
122
+ */
123
+ class WriteAttemptedInReadOnlyTxError extends Error {
124
+ constructor (dataToWrite) {
125
+ super(`Tried to write model in a read-only transaction with the following
126
+ changes ${JSON.stringify(dataToWrite)}`)
127
+ }
128
+ }
129
+
130
+ class NotImplementedError extends Error {
131
+ constructor (reason) {
132
+ super(reason)
133
+ this.name = this.constructor.name
134
+ }
135
+ }
136
+
137
+ module.exports = {
138
+ DeletedTwiceError,
139
+ GenericModelError,
140
+ InvalidFieldError,
141
+ InvalidOptionsError,
142
+ InvalidParameterError,
143
+ ModelAlreadyExistsError,
144
+ ModelTrackedTwiceError,
145
+ NotImplementedError,
146
+ TransactionFailedError,
147
+ TransactionLockTimeoutError,
148
+ WriteAttemptedInReadOnlyTxError
149
+ }
package/src/fields.js ADDED
@@ -0,0 +1,526 @@
1
+ const assert = require('assert')
2
+
3
+ const { FieldValue } = require('@google-cloud/firestore')
4
+ const deepeq = require('fast-deep-equal')
5
+ const deepcopy = require('rfdc')()
6
+
7
+ const { InvalidFieldError, InvalidOptionsError, NotImplementedError } = require('./errors')
8
+ const { validateValue } = require('./utils')
9
+
10
+ /**
11
+ * Abstract class representing a field / property of a Model.
12
+ *
13
+ * @private
14
+ * @memberof Internal
15
+ */
16
+ class __FieldInterface {
17
+ constructor () {
18
+ if (this.constructor === __FieldInterface) {
19
+ throw new Error('Can not instantiate abstract class')
20
+ }
21
+ }
22
+
23
+ get mutated () {
24
+ throw new NotImplementedError()
25
+ }
26
+
27
+ get __mayHaveMutated () {
28
+ throw new NotImplementedError()
29
+ }
30
+
31
+ get accessed () {
32
+ throw new NotImplementedError()
33
+ }
34
+
35
+ get () {
36
+ throw new NotImplementedError()
37
+ }
38
+
39
+ set (val) {
40
+ throw new NotImplementedError()
41
+ }
42
+
43
+ __valueForFirestoreWrite () {
44
+ throw new NotImplementedError()
45
+ }
46
+
47
+ validate () {
48
+ throw new NotImplementedError()
49
+ }
50
+
51
+ hasChangesToCommit (expectWrites = true) {
52
+ throw new NotImplementedError()
53
+ }
54
+ }
55
+
56
+ /**
57
+ * @namespace Fields
58
+ * @memberof Internal
59
+ * @private
60
+ */
61
+
62
+ /**
63
+ * @namespace Internal
64
+ * @private
65
+ */
66
+
67
+ /**
68
+ * Base class representing a field / property of a Model.
69
+ *
70
+ * @private
71
+ * @memberof Internal
72
+ */
73
+ class __BaseField extends __FieldInterface {
74
+ /*
75
+ * Indicates if any changes need to be committed in the field.
76
+ * @param {Boolean} expectWrites whether the model will be updated,
77
+ * default is true.
78
+ * @type {Boolean}
79
+ */
80
+ hasChangesToCommit (expectWrites = true) {
81
+ // If a field is changed due to being initialized with the default
82
+ // value within a read-only transaction, the change shouldn't be committed.
83
+ // Otherwise, an error will occur when attempting to update in a
84
+ // read-only transaction.
85
+ if (this.__isForUpdate) {
86
+ return true
87
+ }
88
+ const initDefault = !expectWrites && this.__useDefault && !this.__written
89
+ return this.mutated && !initDefault
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Internal object representing a field / property of a Model.
95
+ *
96
+ * @private
97
+ * @memberof Internal
98
+ */
99
+ class __Field extends __BaseField {
100
+ static __validateFieldOptions (modelName, isKey, fieldName, schema) {
101
+ if (fieldName.startsWith('_')) {
102
+ throw new InvalidFieldError(
103
+ fieldName, 'property names may not start with "_"')
104
+ }
105
+
106
+ if (isKey === undefined) {
107
+ isKey = false
108
+ }
109
+ assert([true, false].includes(isKey), 'isKey must be true or false')
110
+ assert(schema.isSchema, 'must be Schema object')
111
+
112
+ const compiledSchema = schema.getValidatorAndJSONSchema(
113
+ `${modelName}.${fieldName}`)
114
+ const jsonSchema = compiledSchema.jsonSchema
115
+ const options = {
116
+ isKey,
117
+ schema: jsonSchema,
118
+ optional: schema.required === false,
119
+ immutable: isKey || jsonSchema.readOnly === true,
120
+ default: jsonSchema.default,
121
+ assertValid: compiledSchema.assertValid
122
+ }
123
+ const FieldCls = SCHEMA_TYPE_TO_FIELD_CLASS_MAP[options.schema.type]
124
+ assert.ok(FieldCls, `unsupported field type ${options.schema.type}`)
125
+
126
+ const hasDefault = Object.prototype.hasOwnProperty.call(jsonSchema, 'default')
127
+ if (isKey) {
128
+ if (hasDefault && isKey) {
129
+ throw new InvalidOptionsError('default',
130
+ 'No defaults for keys.') // It just doesn\'t make sense.
131
+ }
132
+ if (jsonSchema.readOnly === false) {
133
+ throw new InvalidOptionsError('immutable',
134
+ 'Keys must be immutable.')
135
+ }
136
+ if (options.optional) {
137
+ throw new InvalidOptionsError('optional',
138
+ 'Keys must never be optional.')
139
+ }
140
+ }
141
+ if (hasDefault) {
142
+ validateValue(this.name, options, options.default)
143
+ }
144
+ return options
145
+ }
146
+
147
+ /**
148
+ * @typedef {Object} FieldOptions
149
+ * @property {Boolean} [isKey=false] If true, the field is
150
+ * a key. When specified, other options are forced to be
151
+ * { optional: false, immutable: true, default: undefined }. If user
152
+ * supplied values that conflicts with those values, InvalidOptionsError
153
+ * will be thrown.
154
+ * @property {Boolean} [optional=false] If field can be left undefined.
155
+ * @property {Boolean} [immutable=false] If field can be changed again after
156
+ * value is set to anything except undefined.
157
+ * @property {*} [default=undefined] Default value to use. IMPORTANT: Value
158
+ * is deeply copied, so additional modifications to the parameter will
159
+ * not reflect in the field.
160
+ * @property {schema} [schema=undefined] An optional JSON schema
161
+ * to validate Field's value.
162
+ */
163
+
164
+ /**
165
+ * @param {String} name the field's name (also the name of the underlying
166
+ * attribute in the database where this field is stored [except key
167
+ * components which are not stored in the db in their own attribute])
168
+ * @param {FieldOptions} opts
169
+ * @param {*} val the initial value of the field
170
+ * @param {boolean} valIsFromDB whether val is from (or is expected to be
171
+ * from) the database
172
+ * @param {boolean} valSpecified whether val was specified (if this is
173
+ * true, then the field was present)
174
+ * @param {boolean} isForUpdate whether this field is part of an update
175
+ */
176
+ constructor ({
177
+ name,
178
+ opts,
179
+ val,
180
+ valIsFromDB,
181
+ valSpecified,
182
+ isForUpdate
183
+ }) {
184
+ super()
185
+ for (const [key, value] of Object.entries(opts)) {
186
+ Object.defineProperty(this, key, { value, writable: false })
187
+ }
188
+
189
+ // Setup states
190
+ /**
191
+ * @memberof Internal.__Field
192
+ * @instance
193
+ * @member {String} name The name of the owning property.
194
+ */
195
+ this.name = name
196
+ this.__value = undefined
197
+ this.__readInitialValue = false // If get is called
198
+ this.__written = false // If set is called
199
+ this.__default = opts.default // only used for new docs!
200
+
201
+ // determine whether to use the default value, or the given val
202
+ let useDefault
203
+ if (valSpecified) {
204
+ // if val was specified, then use it
205
+ useDefault = false
206
+ } else {
207
+ assert.ok(val === undefined,
208
+ 'valSpecified can only be false if val is undefined')
209
+ if (this.__default === undefined) {
210
+ // can't use the default if there is no default value
211
+ useDefault = false
212
+ } else if (valIsFromDB && this.optional) {
213
+ // if the field is optional and the value is not in the db then we
214
+ // can't use the default (we have to assume the field was omitted)
215
+ useDefault = false
216
+ } else {
217
+ useDefault = true
218
+ }
219
+ }
220
+
221
+ this.__useDefault = useDefault
222
+ this.__value = useDefault ? deepcopy(this.__default) : val
223
+
224
+ if (valIsFromDB) {
225
+ // The field's current value is the value stored in the database. Track
226
+ // that value so that we can detect if it changes, and write that
227
+ // change to the database.
228
+ // Note: val is undefined whenever useDefault is true
229
+ this.__initialValue = deepcopy(val)
230
+ } else {
231
+ this.__initialValue = undefined
232
+ }
233
+
234
+ // validate the value, if needed
235
+ if (!this.isKey) { // keys are validated elsewhere; don't re-validate
236
+ if (!useDefault) { // default was validated by __validateFieldOptions
237
+ // validate everything except values omitted from an update() call
238
+ if (valSpecified || !isForUpdate) {
239
+ this.validate()
240
+ }
241
+ }
242
+ }
243
+ if (valSpecified && isForUpdate) {
244
+ this.__isForUpdate = true
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Returns the update value to give to Firestore. Assumes the value has been
250
+ * mutated!
251
+ */
252
+ __valueForFirestoreWrite () {
253
+ if (this.__value === undefined) {
254
+ if (this.__initialValue !== undefined || this.__isForUpdate) {
255
+ return FieldValue.delete()
256
+ }
257
+ }
258
+ // complex types (objects and arrays) can be modified without calling set()
259
+ // so we need to double-check the validation for them at this point
260
+ this.validate()
261
+ return deepcopy(this.__value)
262
+ }
263
+
264
+ /**
265
+ * This method compares initialValue against the current value.
266
+ *
267
+ * @returns if value was changed.
268
+ */
269
+ get mutated () {
270
+ return this.__value !== this.__initialValue
271
+ }
272
+
273
+ /**
274
+ * A quick heuristic on if current value may differ from db. When this value
275
+ * is true, use comparison / deep equality to confirm if the value has been
276
+ * mutated. If this value is false, then the field must not have been
277
+ * mutated, and there is no need to confirm mutation further.
278
+ */
279
+ get __mayHaveMutated () {
280
+ // A field may be mutated when it's written or if the value is a complex
281
+ // structure when it is read.
282
+ if (this.accessed) {
283
+ return true
284
+ }
285
+ // A field may still be mutated when it's never read or written, since the
286
+ // field may be initialized with a mutated value (default value from schema
287
+ // or default value upon creation).
288
+ if (this.__initialValue === undefined && this.__value !== undefined) {
289
+ return true
290
+ }
291
+ // Else the field must not be different from what's in database.
292
+ return false
293
+ }
294
+
295
+ /**
296
+ * This is primarily used for optimistic locking.
297
+ * @returns {Boolean} if the field was accessed (read / write) by users of
298
+ * this library
299
+ */
300
+ get accessed () {
301
+ return this.__readInitialValue || this.__written
302
+ }
303
+
304
+ /**
305
+ * Gets the field's current value. Calling this method will mark the field as
306
+ * "{@link accessed}".
307
+ *
308
+ * @see {@link __value} for accessing value within the library without
309
+ * "accessing" the field
310
+ * @access public
311
+ */
312
+ get () {
313
+ if (!this.__written) {
314
+ this.__readInitialValue = true
315
+ }
316
+ return this.__value
317
+ }
318
+
319
+ /**
320
+ * If the value passed in is valid, update field's current value, mark the
321
+ * field as "{@link accessed}". If the value is not valid, throws
322
+ * InvalidFieldError.
323
+ *
324
+ * @param {*} value New value for the field.
325
+ * @affects {@link __Field#accessed}
326
+ * @access public
327
+ */
328
+ set (val) {
329
+ if (this.immutable) {
330
+ throw new InvalidFieldError(
331
+ this.name,
332
+ 'is immutable so value cannot be changed after first initialized.')
333
+ }
334
+
335
+ const prev = [this.__value, this.__written]
336
+ this.__value = val
337
+ this.__written = true
338
+
339
+ try {
340
+ this.validate()
341
+ } catch (e) {
342
+ [this.__value, this.__written] = prev
343
+ throw e
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Checks if the field's current value is valid. Throws InvalidFieldError if
349
+ * check fails.
350
+ */
351
+ validate () {
352
+ validateValue(this.name, this, this.__value)
353
+ }
354
+ }
355
+
356
+ /**
357
+ * @extends Internal.__Field
358
+ * @memberof Internal.Fields
359
+ * @private
360
+ */
361
+ class NumberField extends __Field {
362
+ constructor (options) {
363
+ super(options)
364
+ this.__diff = undefined
365
+ this.__mustUseSet = false
366
+
367
+ // figure out what value the diff will be added to
368
+ if (this.__initialValue !== undefined) {
369
+ // this occurs when the field is on an existing doc AND the doc already
370
+ // had a value for the field
371
+ this.__base = this.__initialValue
372
+ } else if (this.__value !== undefined) {
373
+ // this case occurs when the field is on a new doc
374
+ this.__base = this.__value
375
+ } else {
376
+ // this case occurs if the field is not currently present on the doc;
377
+ // in this case increment cannot be used to update the doc
378
+ this.__base = undefined
379
+ }
380
+ }
381
+
382
+ set (val) {
383
+ super.set(val)
384
+ // don't change any state unless set() succeeds
385
+ this.__diff = undefined // no longer computed as a diff
386
+ this.__mustUseSet = true
387
+ }
388
+
389
+ /**
390
+ * Updates the field's value by an unconditioned increment IF the field is
391
+ * never read (reduces contention). If the field is ever read, there's no
392
+ * reason to use this.
393
+ * @param {Number} diff The diff amount.
394
+ */
395
+ incrementBy (diff) {
396
+ // add the new diff to our current diff, if any
397
+ // wait to set __diff until after super.set() succeeds to ensure no
398
+ // changes are made if set() fails!
399
+ const newDiff = (this.__diff === undefined) ? diff : this.__diff + diff
400
+
401
+ // if we've already read the value, there's no point in generating an
402
+ // increment update expression as we must lock on the original value anyway
403
+ if (this.__readInitialValue || this.__mustUseSet) {
404
+ this.set(this.__sumIfValid(false, newDiff))
405
+ // this.__diff isn't set because we can't not using diff updates now
406
+ return
407
+ }
408
+
409
+ // call directly on super to avoid clearing the diff value
410
+ super.set(this.__sumIfValid(true, newDiff))
411
+ this.__diff = newDiff
412
+ }
413
+
414
+ /**
415
+ * Returns the sum of diff and some value. Throws if the latter is undefined.
416
+ * @param {boolean} fromBase whether to add __base (else __value)
417
+ * @param {number} diff how much to add
418
+ * @returns {number} sum
419
+ */
420
+ __sumIfValid (fromBase, diff) {
421
+ const base = fromBase ? this.__base : this.__value
422
+ if (base === undefined) {
423
+ throw new InvalidFieldError(
424
+ this.name, 'cannot increment a field whose value is undefined')
425
+ }
426
+ return base + diff
427
+ }
428
+
429
+ /**
430
+ * Whether this field can be updated with an increment expression.
431
+ */
432
+ get canUpdateWithIncrement () {
433
+ return (
434
+ // if there's no diff, we cannot use increment
435
+ this.__diff !== undefined &&
436
+ // if the field didn't have an old value, we can't increment it
437
+ this.__initialValue !== undefined &&
438
+ // if we read the value, then it's not a blind increment
439
+ !this.__readInitialValue)
440
+ }
441
+
442
+ __valueForFirestoreWrite () {
443
+ // if we're locking, there's no point in doing an increment
444
+ if (this.canUpdateWithIncrement) {
445
+ return FieldValue.increment(this.__diff)
446
+ }
447
+ return super.__valueForFirestoreWrite()
448
+ }
449
+ }
450
+
451
+ /**
452
+ * @extends Internal.__Field
453
+ * @memberof Internal.Fields
454
+ * @private
455
+ */
456
+ class StringField extends __Field {
457
+ get mutated () {
458
+ return this.__mayHaveMutated && super.mutated
459
+ }
460
+ }
461
+
462
+ /**
463
+ * @extends Internal.__Field
464
+ * @memberof Internal.Fields
465
+ * @private
466
+ */
467
+ class ObjectField extends __Field {
468
+ /**
469
+ * This method checks for equality deeply against the initial
470
+ * value so use it as sparsely as possible. It is primarily meant to be
471
+ * used internally for deciding whether a field needs to be transmitted to
472
+ * the server.
473
+ *
474
+ * @returns if value was changed.
475
+ */
476
+ get mutated () {
477
+ return this.__mayHaveMutated && !deepeq(this.__value, this.__initialValue)
478
+ }
479
+ }
480
+
481
+ /**
482
+ * @extends Internal.__Field
483
+ * @memberof Internal.Fields
484
+ * @private
485
+ */
486
+ class BooleanField extends __Field {}
487
+
488
+ /**
489
+ * @extends Internal.__Field
490
+ * @memberof Internal.Fields
491
+ * @private
492
+ */
493
+ class ArrayField extends __Field {
494
+ /**
495
+ * This method checks for equality deeply against the initial
496
+ * value so use it as sparsely as possible. It is primarily meant to be
497
+ * used internally for deciding whether a field needs to be transmitted to
498
+ * the server.
499
+ *
500
+ * @returns if value was changed.
501
+ */
502
+ get mutated () {
503
+ return this.__mayHaveMutated && !deepeq(this.__value, this.__initialValue)
504
+ }
505
+ }
506
+
507
+ const SCHEMA_TYPE_TO_FIELD_CLASS_MAP = {
508
+ array: ArrayField,
509
+ boolean: BooleanField,
510
+ float: NumberField,
511
+ integer: NumberField,
512
+ number: NumberField,
513
+ object: ObjectField,
514
+ string: StringField
515
+ }
516
+
517
+ module.exports = {
518
+ __FieldInterface,
519
+ __Field,
520
+ NumberField,
521
+ ArrayField,
522
+ BooleanField,
523
+ StringField,
524
+ ObjectField,
525
+ SCHEMA_TYPE_TO_FIELD_CLASS_MAP
526
+ }