@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/README.md ADDED
@@ -0,0 +1,860 @@
1
+ # Firestore ORM Library <!-- omit in toc -->
2
+ This library is used to interact with the Firestore NoSQL database. It provides
3
+ high-level abstractions to structure data and prevent race conditions.
4
+
5
+ [![JSDoc](https://img.shields.io/badge/Documentation-JSDoc-green.svg?logo=githubpages)](https://pbv-public.github.io/firestore-orm)
6
+
7
+ - [Core Concepts](#core-concepts)
8
+ - [Minimal Example](#minimal-example)
9
+ - [Collections](#collections)
10
+ - [Keys](#keys)
11
+ - [Fields](#fields)
12
+ - [Schema Enforcement](#schema-enforcement)
13
+ - [Custom Methods](#custom-methods)
14
+ - [Transactions](#transactions)
15
+ - [ACID Properties](#acid-properties)
16
+ - [Retries](#retries)
17
+ - [Read-Only](#read-only)
18
+ - [Pre-Commit Hook](#pre-commit-hook)
19
+ - [Warning: Race Conditions](#warning-race-conditions)
20
+ - [Warning: Side Effects](#warning-side-effects)
21
+ - [Operations](#operations)
22
+ - [Addressing Documents](#addressing-documents)
23
+ - [Create](#create)
24
+ - [Read](#read)
25
+ - [Create if Missing](#create-if-missing)
26
+ - [Read Consistency](#read-consistency)
27
+ - [Batch Read](#batch-read)
28
+ - [Write](#write)
29
+ - [Delete](#delete)
30
+ - [Performance](#performance)
31
+ - [Blind Writes](#blind-writes)
32
+ - [incrementBy()](#incrementby)
33
+ - [Locking](#locking)
34
+ - [Niche Concepts](#niche-concepts)
35
+ - [Key Encoding](#key-encoding)
36
+ - [Nested Transactions are NOT Nested](#nested-transactions-are-not-nested)
37
+ - [Collection Creation \& Persistence](#collection-creation--persistence)
38
+ - [Repeated Reads](#repeated-reads)
39
+ - [Key Collection](#key-collection)
40
+ - [Library Collaborator's Guide](#library-collaborators-guide)
41
+ - [Transactions](#transactions-1)
42
+ - [Appendix](#appendix)
43
+ - [Useful Links](#useful-links)
44
+ - [Not Yet Implemented](#not-yet-implemented)
45
+
46
+
47
+ # Core Concepts
48
+ Data is organized into Firestore Collections (similar to what's often called
49
+ tables in other databases). Each contains Firestore Documents (similar to rows
50
+ in other databases). Each document ("doc") is uniquely identified by a
51
+ [_Key_](#keys).
52
+
53
+ ## Minimal Example
54
+ Define a new collection like this, using the
55
+ [Schema library](https://github.com/pbv-public/js-schema) to enforce a
56
+ schema:
57
+ ```javascript <!-- embed:./test/unit-test-doc.js:scope:Order -->
58
+ class OrderWithNoPrice extends db.Model {
59
+ static FIELDS = {
60
+ product: S.str,
61
+ quantity: S.int
62
+ }
63
+ }
64
+ ```
65
+
66
+ Then we can create a new doc:
67
+ ```javascript
68
+ const id = uuidv4()
69
+ tx.create(Order, { id, product: 'coffee', quantity: 1 })
70
+ ```
71
+
72
+ Later, we can retrieve it from the database and modify it:
73
+ ```javascript <!-- embed:./test/unit-test-doc.js:scope:DBReadmeTest:testMinimalExample:Example -->
74
+ // Example
75
+ await db.Context.run(async tx => {
76
+ const order = await tx.get(OrderWithNoPrice, id)
77
+ expect(order.id).toBe(id)
78
+ expect(order.product).toBe('coffee')
79
+ expect(order.quantity).toBe(1)
80
+ order.quantity = 2
81
+ })
82
+ ```
83
+
84
+
85
+ ## Collections
86
+
87
+ ### Keys
88
+ Each doc is uniquely identified by a key. By default, the key is composed of a
89
+ single field named `id` which has the format of a UUIDv4 string (e.g.,
90
+ `"c40ef065-4034-4be8-8a1d-0959695b213e"`) typically produced by calling
91
+ `uuidv4()`, as shown in the minimal example above. A doc's key cannot be
92
+ changed.
93
+
94
+ You can override the default and define your key to be composed of one _or
95
+ more_ fields with an arbitrary
96
+ [Schema](https://github.com/pbv-public/js-schema)s (`S`):
97
+ ```javascript <!-- embed:./test/unit-test-doc.js:scope:RaceResult -->
98
+ class RaceResult extends db.Model {
99
+ static KEY = {
100
+ raceID: S.int,
101
+ runnerName: S.str
102
+ }
103
+ }
104
+ ```
105
+
106
+ Access each component of a key just like any other field:
107
+ ```javascript <!-- embed:./test/unit-test-doc.js:scope:DBReadmeTest:testKeys -->
108
+ async testKeys () {
109
+ await db.Context.run(async tx => {
110
+ const raceResult = await tx.get(
111
+ RaceResult,
112
+ { raceID: 99, runnerName: 'Bo' },
113
+ { createIfMissing: true })
114
+ expect(raceResult.raceID).toBe(99)
115
+ expect(raceResult.runnerName).toBe('Bo')
116
+ })
117
+ }
118
+ ```
119
+
120
+ It is best practice for keys to have semantic meaning whenever possible. In
121
+ this example, each runner finishes each race just one time so making the key a
122
+ combination of those values is ideal. This is better than a meaningless random
123
+ value because this:
124
+ 1. Enforces the constraint that each runner finishes each race no more than
125
+ once. If the ID was a random value, we could accidentally create two race
126
+ results for one runner in the same race.
127
+ 1. Enables us efficiently construct the ID from relevant information (e.g.,
128
+ to check if a runner finished a specific race). If the ID was was a random
129
+ value, we'd have to do some sort of search to figure out the ID associated
130
+ with a given race ID and runner name (slow because this would involve a
131
+ database query instead of a simple local computation!).
132
+
133
+ Note: Keys are collection-specific. Two different docs in different collections may have
134
+ the same key.
135
+
136
+
137
+ ### Fields
138
+ Fields are pieces of data attached to a doc. They are defined similar to
139
+ `KEY` -- a doc can have one _or more_ fields with arbitrary
140
+ [Schema](https://github.com/pbv-public/js-schema)s:
141
+ ```javascript <!-- embed:./test/unit-test-doc.js:scope:ModelWithFields -->
142
+ class ModelWithFieldsExample extends db.Model {
143
+ static FIELDS = {
144
+ someInt: S.int.min(0),
145
+ someBool: S.bool,
146
+ someObj: S.obj().prop('arr', S.arr(S.str))
147
+ }
148
+ }
149
+ ```
150
+
151
+ * Field names are serialized and stored in the database.
152
+ Avoid having fields with long verbose names, especially for repeated values.
153
+ * If you change the schema, existing data isn't changed.
154
+ That includes docs with now missing field names. [Schema Enforcement](#schema-enforcement)
155
+
156
+ Fields can be configured to be optional, immutable and/or have default values:
157
+ * `optional()` - unless a field is marked as optional, a value must be
158
+ provided (i.e., it cannot be omitted or set to `undefined`)
159
+ * `readOnly()` - if a field is marked as read only, it cannot be changed once
160
+ the doc has been created
161
+ * `default()` - the default value for a field
162
+ * This value gets deep copied so you can safely use non-primitive types
163
+ like objects as a default value.
164
+ * The default value is assigned to a field when:
165
+ * A doc is created and no value is specified for the value.
166
+ * A doc is fetched and is is missing the specified field _AND_ the
167
+ field is required.
168
+ * The default value is _not_ assigned to an optional field that is missing
169
+ when it is fetched from the database.
170
+ ```javascript <!-- embed:./test/unit-test-doc.js:scope:ComplexFieldsExample -->
171
+ class ComplexFieldsExample extends db.Model {
172
+ static FIELDS = {
173
+ aNonNegInt: S.int.min(0),
174
+ anOptBool: S.bool.optional(), // default value is undefined
175
+ // this field defaults to 5; once it is set, it cannot be changed (though
176
+ // it won't always be 5 since it can be created with a non-default value)
177
+ immutableInt: S.int.readOnly().default(5)
178
+ }
179
+ }
180
+ ```
181
+ ```javascript <!-- embed:./test/unit-test-doc.js:section:example1122start:example1122end -->
182
+ // can omit the optional field
183
+ const doc = tx.create(ComplexFieldsExample, {
184
+ id: uuidv4(),
185
+ aNonNegInt: 0,
186
+ immutableInt: 3
187
+ })
188
+ expect(doc.aNonNegInt).toBe(0)
189
+ // omitted optional field => undefined
190
+ expect(doc.anOptBool).toBe(undefined)
191
+ expect(doc.immutableInt).toBe(3)
192
+
193
+ // can override the default value
194
+ const doc2 = tx.create(ComplexFieldsExample, {
195
+ id: uuidv4(),
196
+ aNonNegInt: 1,
197
+ anOptBool: true
198
+ })
199
+ expect(doc2.aNonNegInt).toBe(1)
200
+ expect(doc2.anOptBool).toBe(true)
201
+ expect(doc2.immutableInt).toBe(5) // the default value
202
+ // can't change read only fields:
203
+ expect(() => { doc2.immutableInt = 3 }).toThrow(
204
+ 'immutableInt is immutable so value cannot be changed')
205
+ ```
206
+
207
+
208
+ ### Schema Enforcement
209
+ A model's schema (i.e., the structure of its data) is enforced by this library
210
+ — _NOT_ the underlying database! Firestore, like most NoSQL databases, is
211
+ effectively schemaless. This means each doc may
212
+ theoretically contain completely different data. This normally won't be the
213
+ case because `db.Model` enforces a consistent schema on documents in a collection.
214
+
215
+ However, it's important to understand that this schema is _only_ enforced by
216
+ `db.Model` and not the underlying database. This means **changing the model
217
+ does not change any underlying data** in the database. For example, if we make
218
+ a previously optional field required, old documents which omitted the value will
219
+ still be missing the value.
220
+
221
+ The schema is checked as follows:
222
+ 1. When a field's value is changed, it is validated. If a value is a
223
+ reference (e.g., an object or array), then changing a value inside the
224
+ reference does _not_ trigger a validation check.
225
+ ```javascript
226
+ // fields are checked immediately when creating a new doc; this throws
227
+ // S.ValidationError because someInt should be an integer
228
+ const data = {
229
+ id: uuidv4(),
230
+ someInt: '1', // does not match the schema S.int)!
231
+ someBool: true,
232
+ someObj: { arr: [] }
233
+ }
234
+ tx.create(ModelWithFields, data) // throws because someInt is invalid
235
+
236
+ data.someInt = 1
237
+ const x = tx.create(ModelWithFields, data)
238
+
239
+ // fields are checked when set too
240
+ x.someBool = 1 // throws because the type should be boolean not int
241
+ x.someObj = {} // throws because the required "arr" key is missing
242
+ x.someObj = { arr: [5] } // throws b/c this arr must contain strings
243
+ x.someObj = { arr: ['ok'] } // ok!
244
+
245
+ // changes within a non-primitive type aren't detected or validated
246
+ // until we try to write the change so this next line won't throw!
247
+ x.someObj.arr.push(5)
248
+ ```
249
+
250
+ 2. Any fields that will be written to the database are validated prior to
251
+ writing them. This occurs when a [transaction](#transactions) commit
252
+ starts. This catches schema validation errors like the one on the last
253
+ line of the previous example.
254
+
255
+ 3. Keys are validated whenever they are created or read, like these examples:
256
+ ```javascript
257
+ const compoundID = { raceID: 1, runnerName: 'Alice' }
258
+ // each of these three trigger a validation check (to verify that
259
+ // compoundID contains every key component and that each of them meet
260
+ // their respective schemas requirements)
261
+ RaceResult.key(compoundID)
262
+ tx.create(RaceResult, compoundID)
263
+ await tx.get(RaceResult, compoundID)
264
+ ```
265
+
266
+ 4. Fields validation can be manually triggered:
267
+ ```javascript
268
+ x.getField('someObj').validate()
269
+ ```
270
+
271
+ ### Custom Methods
272
+ As you've noticed, key components and fields are simply accessed by their names
273
+ (e.g., `raceResult.runnerName` or `order.product`). You can also define
274
+ instance methods on your models to provide additional functionality:
275
+ ```javascript <!-- embed:./test/unit-test-doc.js:scope:OrderWithPrice -->
276
+ class OrderWithPrice extends db.Model {
277
+ static FIELDS = {
278
+ quantity: S.int,
279
+ unitPrice: S.int.desc('price per unit in cents')
280
+ }
281
+
282
+ totalPrice (salesTax = 0.1) {
283
+ const subTotal = this.quantity * this.unitPrice
284
+ return subTotal * (1 + salesTax)
285
+ }
286
+ }
287
+ ```
288
+ And use them like you'd expect:
289
+ ```javascript
290
+ const order = tx.create(OrderWithPrice, { id, quantity: 2 , unitPrice: 200 })
291
+ expect(order.totalPrice(0.1)).toBeCloseTo(440)
292
+ ```
293
+
294
+
295
+ ## Transactions
296
+ A transaction is a function which contains logic and database operations. A
297
+ transaction guarantees that all _database_ side effects (e.g., updating a
298
+ doc) execute in an all-or-nothing manner, providing [ACID](#acid-properties) properties.
299
+
300
+
301
+ ### ACID Properties
302
+ [ACID](https://en.wikipedia.org/wiki/ACID) properties are commonly provided by
303
+ traditional, transaction-processing databases:
304
+
305
+ * _Atomicity_ - every database operation (e.g., an update) will succeed, or
306
+ none will succeed. The database will never be partially updated.
307
+ * _Consistency_ - data written to the database will always be consistent with
308
+ the constraints specified by the models (e.g., it is not possible to store a
309
+ string in an integer field).
310
+ * _Isolation_ - each transaction will appear to operate sequentially;
311
+ uncommitted data cannot be read.
312
+ * _Durability_ - if a transaction succeeds, any data that is changed will be
313
+ remembered. There is no chance of it being lost (e.g., due to a power
314
+ outage).
315
+
316
+
317
+ ### Retries
318
+ When a transaction fails due to contention, it will retry after a short, random
319
+ delay. Randomness helps prevent conflicting transactions from conflicting again
320
+ when they retry. Context retry behaviors can be customized:
321
+ ```javascript
322
+ const retryOptions = {
323
+ retries: 4, // 1 initial run + up to 4 retry attempts = max 5 total attempts
324
+ initialBackoff: 100, // 100 milliseconds (+/- a small random offset)
325
+ maxBackoff: 500 // no more than 500 milliseconds
326
+ }
327
+ await db.Context.run(retryOptions, async tx => {
328
+ // you can also manually force your transaction to retry by throwing a
329
+ // custom exception with the "retryable" property set to true
330
+ const error = new Error()
331
+ error.retryable = true
332
+ throw error
333
+ })
334
+ // Exponential backoff function doubles the backoff each time (up to the max)
335
+ // t=0ms, initial run
336
+ // t=100ms, retry 1 (backed off for 100ms)
337
+ // t=300ms, retry 2 (backed off again, this time for 200ms)
338
+ // t=700ms, retry 3 (backed off again, this time for 400ms)
339
+ // t=1200ms, retry 4 (backed off for 500ms this time; was capped by maxBackoff)
340
+ // fail
341
+ ```
342
+
343
+ ### Read-Only
344
+ You can ensure a transaction does not make any database changes by setting the
345
+ `readOnly` option to true, or calling `tx.makeReadOnly()`:
346
+ ```javascript
347
+ const readOnlyOption = { readOnly: true }
348
+ await db.Context.run(readOnlyOption, async tx => { /* ... */ })
349
+ await db.Context.run(async tx => {
350
+ tx.makeReadOnly()
351
+ // ...
352
+ })
353
+ ```
354
+
355
+ ### Pre-Commit Hook
356
+ A model might need to execute some logic before it is committed. For example, a `Ledger` model may want to update a `ver` field any time it is updated. Such logic can be achieved through the `Model.finalize` hook.
357
+
358
+ ```javascript <!-- embed:./test/unit-test-transaction.js:scope:HookExample -->
359
+ class HookExample extends db.Model {
360
+ static KEY = { id: S.str.min(1) }
361
+ static FIELDS = {
362
+ field1: S.int.default(0),
363
+ latestUpdateEpoch: S.int.default(0)
364
+ .desc('latest update epoch in milliseconds')
365
+ }
366
+
367
+ async finalize () {
368
+ this.latestUpdateEpoch = Date.now()
369
+ }
370
+ }
371
+ ```
372
+
373
+
374
+ ### Warning: Race Conditions
375
+ Race conditions are still possible! If your context doesn't use a transaction,
376
+ individual reads may not be consistent with one another. A transaction is used
377
+ if your context is not read only or if using consistent reads (the default).
378
+ Consider a ski resort which records some stats about skiers and lifts:
379
+ ```javascript
380
+ class SkierStats extends db.Model {
381
+ static KEY = { resort: S.str }
382
+ static FIELDS = { numSkiers: S.int.min(0).default(0) }
383
+ }
384
+ class LiftStats extends db.Model {
385
+ static KEY = { resort: S.str }
386
+ static FIELDS = { numLiftRides: S.int.min(0).default(0) }
387
+ }
388
+ ```
389
+
390
+ We can correctly update these numbers in a transaction like this:
391
+ ```javascript
392
+ async function liftRideTaken(resort, isNewSkier) {
393
+ await db.Context.run(async tx => {
394
+ const opts = { createIfMissing: true }
395
+ const [skierStats, liftStats] = await Promise.all([
396
+ !isNewSkier ? Promise.resolve() : tx.get(SkierStats, resort, opts),
397
+ tx.get(LiftStats, resort, opts)])
398
+ if (isNewSkier) {
399
+ skierStats.numSkiers += 1
400
+ }
401
+ liftStats.numLiftRides += 1
402
+ })
403
+ }
404
+ ```
405
+
406
+ However, if we try to read them individually from different transactions or
407
+ without setting the consistent read option (the default) we can't guarantee a
408
+ consistent snapshot.
409
+ ```javascript
410
+ const skierStats = await tx.get(SkierStats, resort)
411
+ const liftStats = await tx.get(LiftStats, resort)
412
+ ```
413
+
414
+ This sequence is possible:
415
+ 1. We issue requests to read SkierStats and LiftStats, as above.
416
+ 1. We call `liftRideTaken('someResort', true)`
417
+ 1. The request to read skier stats complete: `numSkiers=0`
418
+ 1. The `liftRideTaken('someResort', true)` completes, transactionally
419
+ updating the database to `numSkiers=1` and `numLiftRides=1`.
420
+ 1. The request to read lift stats complete: `numLiftRides=1` _!!!_
421
+ 1. Our application code thinks there was one lift ride taken, but no skiers.
422
+
423
+ To ensure this does not occur, use a transaction (readOnly=false) and/or stick
424
+ with the default consistentReads=true.
425
+
426
+
427
+ ### Warning: Side Effects
428
+ Keep in mind that transactions only guarantee all-or-nothing (or more
429
+ precisely, exactly once or not at all semantics) for _database_ operations. If
430
+ the application code which defines the transaction has side effects, those side
431
+ effects may occur even if the transaction doesn't commit. They could even occur
432
+ multiple times (if your transaction retries).
433
+ ```javascript
434
+ await db.Context.run(async tx => {
435
+ const doc = await tx.get(...)
436
+ doc.someInt += 1
437
+ if (doc.someInt > 10) {
438
+ // making an HTTP request is a side effect!
439
+ await got('https://example.com/gotSomeIntBiggerThan10')
440
+ }
441
+ })
442
+ ```
443
+
444
+ In this example, the HTTP request might be completed one or more times, even if
445
+ the transaction never completes successfully!
446
+
447
+
448
+ ## Operations
449
+ _All_ databases operations occur in the scope of a transaction (unless you
450
+ specifically disable it by setting readOnly=true _and_ consistentRead=false).
451
+ We typically name the transaction object `tx` in code. This section discusses
452
+ the operations supported by `tx`.
453
+
454
+
455
+ ### Addressing Documents
456
+ Database operations always occur on a particular doc. The canonical way to identify a particular doc is:
457
+ ```javascript
458
+ MyModel.key({ /* a map of key component names to their values */ })
459
+ Order.key({ id: uuidv4() })
460
+ RaceResult.key({ raceID: 1, runnerName: 'Dave' })
461
+ ```
462
+
463
+ For models which have only a single key field, you _may_ omit the field name:
464
+ ```javascript
465
+ Order.key(uuid4())
466
+ ```
467
+
468
+ The `db.Key` object produced by this `key()` method is used as the first
469
+ argument for retrieving data:
470
+ ```javascript
471
+ tx.get(Order.key(id))
472
+ ```
473
+
474
+ For convenience, you may also split the model class and key values up into two
475
+ arguments:
476
+ ```javascript
477
+ tx.get(Order, id)
478
+ tx.get(RaceResult, { raceID, runnerName })
479
+ ```
480
+
481
+
482
+ ### Create
483
+ `tx.create()` instantiates a new doc in local memory. This method is a local,
484
+ **synchronous** method (no network traffic is generated). If a doc with the
485
+ same key already exists, a `db.ModelAlreadyExistsError` is thrown when the
486
+ transaction attempts to commit (without retries, as we don't expect docs to be
487
+ deleted).
488
+
489
+ To create a doc, you need to supply the model (the type of data you're
490
+ creating) and a map of its initial values:
491
+ ```javascript
492
+ tx.create(Order, { id, product: 'coffee', quantity: 1 })
493
+ ```
494
+
495
+
496
+ ### Read
497
+ `tx.get()` **asynchronously** retrieves data from the database. Network traffic
498
+ is generated to ask the database for the data as soon as the method is called,
499
+ but other work can be done while waiting.
500
+ ```javascript
501
+ const orderPromise = tx.get(Order, id)
502
+ // do some other work
503
+ const order = await orderPromise // block until the data has been retrieved
504
+ ```
505
+
506
+ `tx.get()` accepts an additional options to configure its behavior:
507
+ * `createIfMissing` - see [Create if Missing](#create-if-missing)
508
+
509
+ Firestore transactions require all reads to have been completed before issuing
510
+ write requests.
511
+
512
+
513
+ #### Create if Missing
514
+ If the doc does not exist in the database, then by default the returned value
515
+ will be `undefined`. You may ask for it to instead be created if it does not
516
+ exist. To do this, you need to supply not only the doc's key, but also the
517
+ data you want it to have _if_ it does not yet exist:
518
+ ```javascript
519
+ const dataIfOrderIsNew = { id, product: 'coffee', quantity: 1 }
520
+ const order = await tx.get(Order, dataIfOrderIsNew, { createIfMissing: true })
521
+ if (order.isNew) { // you can check if the doc already existed or not
522
+ // ...
523
+ }
524
+ ```
525
+
526
+ The `isNew` property is set when the model is instantiated (after receiving the
527
+ database's response to our data request). When the transaction commits, it will
528
+ ensure that the doc will be created if `isNew=true` (i.e., the doc
529
+ wasn't created by someone else in the meantime) or still exists if
530
+ `isNew=false` (i.e., the doc hasn't been deleted in the meantime).
531
+
532
+
533
+ #### Read Consistency
534
+ Each individual get is strongly consistent (you'll get the latest data as of
535
+ that moment). In transactions, all reads will be consistent with each other
536
+ too.
537
+
538
+ #### Batch Read
539
+ It is also possible to call `tx.get()` with an array of keys in order to fetch
540
+ many things at once:
541
+ ```javascript
542
+ const [order1, order2, raceResult] = await tx.get([
543
+ Order.key(id),
544
+ Order.key(anotherID),
545
+ RaceResult.key({ raceID, runnerName })
546
+ ])
547
+ ```
548
+
549
+ This can also be combined with `createIfMissing`:
550
+ ```javascript
551
+ const [order1, order2, raceResult] = await tx.get([
552
+ Order.data({ id, product: 'coffee', quantity: 1 }),
553
+ Order.data({ id: anotherID, product: 'spoon', quantity: 10 }),
554
+ RaceResult.data({ raceID, runnerName })
555
+ ], { createIfMissing: true })
556
+ ```
557
+
558
+ * Data is fetched transactionally and will be a consistent snapshot
559
+ (see [race conditions](#warning-race-conditions) for more about this).
560
+
561
+ ### Write
562
+ To modify data in the database, simply modify fields on a doc created by
563
+ `tx.create()` or fetched by `tx.get()`. When the transaction commits, all
564
+ changes will be written to the database automatically.
565
+
566
+ For improved performance, data can be updated without being read from database
567
+ first. See details in [blind writes](#blind-writes).
568
+
569
+ ### Delete
570
+ Documents can be deleted from the database via `tx.delete()`. The delete method
571
+ accepts models or keys as parameters. For example,
572
+ `tx.delete(model1, key1, model2, ...keys, key2)`.
573
+
574
+ For models that were read from server via `tx.get()`, if the model turns out to
575
+ be missing on server when the transaction commits, an exception is thrown.
576
+ Otherwise, deletion on missing docs will be treated as a no-op.
577
+
578
+ ## Performance
579
+ ### Blind Writes
580
+ Blind updates write a doc to the DB without reading it first. This is useful
581
+ when we wish to update them without the overhead of an unnecessary read (in
582
+ theory, the update can have preconditions but this isn't supported yet... just
583
+ do a read in that case to verify them):
584
+ ```javascript
585
+ // this updates the specified order doc to quantity=2
586
+ tx.updateWithoutRead(Order, { id, quantity: 2 })
587
+ ```
588
+
589
+ Similarly, docs can be blindly created or overwritten with the
590
+ `createOrOverwrite` method. This is useful when we don't care about the
591
+ previous value (if any). For example, maybe we're tracking whether a customer
592
+ has used a particular feature or not. When they use it, we may just want to
593
+ blindly record it:
594
+ ```javascript <!-- embed:./test/unit-test-doc.js:scope:testBlindWritesCreateOrUpdate -->
595
+ async testBlindWritesCreateOrUpdate () {
596
+ class LastUsedFeature extends db.Model {
597
+ static KEY = {
598
+ user: S.str,
599
+ feature: S.str
600
+ }
601
+
602
+ static FIELDS = { epoch: S.int }
603
+ }
604
+ await db.Context.run(async tx => {
605
+ // Overwrite the doc regardless of the content
606
+ const ret = tx.createOrOverwrite(LastUsedFeature,
607
+ { user: 'Bob', feature: 'refer a friend', epoch: 234 })
608
+ expect(ret).not.toBe(undefined) // should return a modal, like create()
609
+ })
610
+
611
+ await db.Context.run(tx => {
612
+ tx.createOrOverwrite(LastUsedFeature,
613
+ // this contains the new value(s) and the doc's key; if a value is
614
+ // undefined then the field will be deleted (it must be optional for
615
+ // this to be allowed)
616
+ { user: 'Bob', feature: 'refer a friend', epoch: 123 },
617
+ // these are the current values we expect; this call fails if the data
618
+ // exists AND it doesn't match these values
619
+ { epoch: 234 }
620
+ )
621
+ })
622
+ await db.Context.run(async tx => {
623
+ const doc = await tx.get(LastUsedFeature,
624
+ { user: 'Bob', feature: 'refer a friend' })
625
+ expect(doc.epoch).toBe(123)
626
+ })
627
+ }
628
+ ```
629
+
630
+ Both of these methods are synchronous, local methods like `tx.create()`. They
631
+ return immediately and do not perform any network traffic. All network traffic
632
+ related to these are generated as part of any writes processed when the
633
+ transaction commits.
634
+
635
+
636
+ ### incrementBy()
637
+ To achieve higher write throughput and reduce contention, you can use
638
+ `incrementBy()` to mutate numeric fields. This can be used when you want to
639
+ increment (or decrement) a number's value but don't care about its old value:
640
+ ```javascript
641
+ class WebsiteHitCounter extends db.Model {
642
+ static FIELDS = { count: S.int.min(0) }
643
+ }
644
+
645
+ async function slowlyIncrement(id) {
646
+ const counter = await tx.get(WebsiteHitCounter, id)
647
+ // here we read and write the data, so the library will generate an
648
+ // update like "if count was N then set count to N + 1"
649
+ counter.count += 1
650
+ }
651
+
652
+ async function quicklyIncrement(id) {
653
+ const counter = await tx.get(WebsiteHitCounter, id)
654
+ // since we only increment the number and never read it, the library will
655
+ // generate an update like "increment quantity by 1" which will succeed no
656
+ // matter what the original value was
657
+ counter.getField('count').incrementBy(1)
658
+ }
659
+ ```
660
+
661
+ Using the `incrementBy()` only helps if you're not going to read the field
662
+ being incremented (though it never hurts to use it):
663
+ ```javascript
664
+ async function bothAreJustAsFast(id) {
665
+ const counter = await tx.get(WebsiteHitCounter, id)
666
+ if (counter.count < 100) { // stop counting after reaching 100
667
+ // this is preferred here b/c it is simpler and just as fast in this case
668
+ // counter.count += 1
669
+
670
+ // isn't any faster because we have to generate the condition
671
+ // expression due to the above if condition which read the count var
672
+ counter.getField('count').incrementBy(1)
673
+ }
674
+ }
675
+ ```
676
+
677
+ Using `incrementBy()` on a field whose value is `undefined` is invalid and will
678
+ throw an exception.
679
+
680
+ ### Locking
681
+
682
+ By default, Firestore uses [pessimistic concurrency](https://cloud.google.com/datastore/docs/concepts/transactions#concurrency_modes). Review their documentation to decide which works best for your use case. Unit tests and the
683
+ local emulator use the default pessimistic concurrency.
684
+
685
+
686
+ # Niche Concepts
687
+
688
+ ## Key Encoding
689
+ Under the hood, a database key can only be a single attribute. We always store
690
+ that attribute as a string because Firestore only supports string keys (not integer keys, etc.). We compute this string's value by first sorting the
691
+ names of the components of the key. Then we compute the string representation
692
+ of each component's value (with `JSON.stringify()`, except for string values
693
+ which don't need to be encoded like that). Finally, we concatenate these values
694
+ (in order of their keys) and separate them with null characters. An encoded key
695
+ would look like this:
696
+ ```javascript
697
+ const doc = tx.create(RaceResult, { raceID: 123, runnerName: 'Joe' })
698
+ expect(doc._id).toBe('123\0Joe')
699
+
700
+ // the encoded key is also contained in the output of Model.key():
701
+ const key = RaceResult.key({ runnerName: 'Mel', raceID: 123 })
702
+ expect(key.Cls).toBe(RaceResult)
703
+ expect(key.encodedKey).toBe('123\0Mel')
704
+ ```
705
+
706
+ For this reason, string values cannot contain the null character. If you need
707
+ to store a string with this value, your best option is to probably nest it
708
+ inside of an object:
709
+ ```javascript
710
+ class StringKeyWithNullBytes extends db.Model {
711
+ static KEY = { id: S.obj().prop('raw', S.str) }
712
+ }
713
+ tx.create(StringKeyWithNullBytes, {
714
+ id: {
715
+ raw: 'I can contain \0, no pr\0bl\0em!'
716
+ }
717
+ })
718
+ ```
719
+
720
+ ## Nested Transactions are NOT Nested
721
+ Nested transactions like this should be avoided:
722
+ ```javascript
723
+ await Context.run(async outerTx => {
724
+ // ...
725
+ await Context.run(async innerTx => {
726
+ // ...
727
+ }
728
+ }
729
+ ```
730
+ The inner transaction, if it commits, will commit first. If the outer
731
+ transaction is retried, the inner transaction _will be run additional times_.
732
+
733
+
734
+ ## Collection Creation & Persistence
735
+ On localhost, the data persists until you shut down the service. If you add new
736
+ models or change a model (particularly its key structure), you will need to
737
+ restart your Firestore emulator to clear out the old data.
738
+
739
+ Along the same lines, keep in mind that the localhost database is _not_ cleared
740
+ in between test runs. Any data added to the localhost database will remain
741
+ until the emulator is restarted. This can help you debug issues, but it also
742
+ means you should not create docs with a fixed ID as part of a unit test (use
743
+ `uuidv4()` to get a random ID value so it won't clash with a future run of the
744
+ unit tests.)
745
+
746
+ Be careful about changing your models: remember that changing the model does
747
+ _not_ change anything in the database. Be especially wary about changing the
748
+ key structure — it will probably cause serious problems.
749
+
750
+
751
+ ## Repeated Reads
752
+ By default, reading a doc twice in a single transaction is treated as an
753
+ exception.
754
+ ```javascript
755
+ await db.Context.run(async tx => {
756
+ await tx.get(SomeModel, "model id")
757
+ // await tx.get(SomeModel, "model id") // throws exception
758
+ })
759
+ ```
760
+
761
+ In some occasions, we may need to allow the same doc to be read more than
762
+ once. For example, a transaction may be handling a batch of operations (action
763
+ pattern with batching), where individual operation might read and update the
764
+ same doc.
765
+ ```javascript
766
+ const operation = async (tx) => {
767
+ const model = await tx.get(SomeModel, "some id")
768
+ model.intField += 1
769
+ }
770
+
771
+ const operations = [operation, operation]
772
+
773
+ await db.Context.run(async tx => {
774
+ for (const op of operations) {
775
+ // Second iteration will throw
776
+ await op(tx)
777
+ }
778
+ })
779
+ ```
780
+
781
+ To allow reading the same doc more than once, a `cacheModels` option can be
782
+ toggled on. In this mode, when a doc is first read, it is cached by the
783
+ transaction, and the transaction will return the cached model for any
784
+ subsequent reads.
785
+ ```javascript
786
+ await db.Context.run({ cacheModels: true },async tx => {
787
+ // This transaction will complete ok
788
+ for (const op of operations) {
789
+ await op(tx)
790
+ }
791
+ })
792
+ ```
793
+
794
+ Any modifications made to the cached doc will be stored along with the doc,
795
+ so subsequent reads will see the previous updates.
796
+ ```javascript
797
+ await db.Context.run({ cacheModels: true },async tx => {
798
+ const model = await tx.get(SomeModel, "some id")
799
+ model.intField = 123
800
+
801
+ const cachedModel = await tx.get(SomeModel, "some id")
802
+ expect(cachedModel.intField).toBe(123)
803
+ })
804
+ ```
805
+
806
+ Repeated reads can be enabled during a transaction because transactions track
807
+ all referenced docs. Call `enableModelCache` to turn it on.
808
+ ```javascript
809
+ await db.Context.run(async tx => {
810
+ ...
811
+ tx.enableModelCache()
812
+ ...
813
+ })
814
+ ```
815
+
816
+ If [an operation other than read](#operations) was done on the doc (e.g.
817
+ delete, or create, etc.), a subsequent attempt to read the doc will result in
818
+ an exception regardless of the cacheModels flag value.
819
+
820
+ ## Key Collection
821
+ When duplicated keys are passed to `tx.get()`, an error will result, even if
822
+ [model cache](#repeated-reads) is enabled, because it is more likely to be a
823
+ coding error in common use cases. Keys must be de-duplicated by removing
824
+ repeated class and key combinations. The `db.UniqueKeyList` class
825
+ provides an `Array` like interface to simplify the deduplication process.
826
+ ```javascript
827
+ const keys = new db.UniqueKeyList(MyModel.key('123'))
828
+ keys.push(MyModel.key('123'), ...[MyModel.key('123')])
829
+ const docs = await tx.get(keys)
830
+ ```
831
+
832
+ # Library Collaborator's Guide
833
+
834
+ ## Transactions
835
+ Our `Context` class:
836
+
837
+ * Models read are tracked by the transaction context.
838
+ * Models mutated are written to DB on commit
839
+ * Context commits when the transaction context / scope is exited
840
+ * If a `retryable` error is thrown during transactWrite operation,
841
+ the transaction will be retried.
842
+ * If all retries fail, a `TransactionFailedError` will be thrown.
843
+
844
+
845
+ # Appendix
846
+ The samples in this readme can be found in the APIs defined for unit testing
847
+ this library in `test/unit-test-doc.js` in the
848
+ `DBReadmeTest` class.
849
+
850
+ ## Useful Links
851
+ * Firestore NodeJS Reference Docs
852
+ * https://cloud.google.com/nodejs/docs/reference/firestore/latest/overview
853
+ * https://googleapis.dev/nodejs/firestore/latest/index.html
854
+ * Firestore [how-to guides](https://cloud.google.com/firestore/docs/how-to) -
855
+ these are very partial and lack many details, but provide a good overview
856
+
857
+ ## Not Yet Implemented
858
+ * Preconditions for updating, etc.
859
+ * Indexes, filtering and scans
860
+ * Nested collections