Skip to content

Commit 6a15cd7

Browse files
author
Brian Chen
authored
Standardize Firestore transaction errors (#1937)
1 parent ca8d829 commit 6a15cd7

File tree

2 files changed

+96
-37
lines changed

2 files changed

+96
-37
lines changed

packages/firestore/src/core/transaction.ts

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { Document, NoDocument, MaybeDocument } from '../model/document';
2222
import { DocumentKey } from '../model/document_key';
2323
import { DeleteMutation, Mutation, Precondition } from '../model/mutation';
2424
import { Datastore } from '../remote/datastore';
25-
import { fail } from '../util/assert';
25+
import { fail, assert } from '../util/assert';
2626
import { Code, FirestoreError } from '../util/error';
2727
import { SnapshotVersion } from './snapshot_version';
2828

@@ -36,6 +36,12 @@ export class Transaction {
3636
private mutations: Mutation[] = [];
3737
private committed = false;
3838

39+
/**
40+
* A deferred usage error that occurred previously in this transaction that
41+
* will cause the transaction to fail once it actually commits.
42+
*/
43+
private lastWriteError: FirestoreError;
44+
3945
constructor(private datastore: Datastore) {}
4046

4147
private recordVersion(doc: MaybeDocument): void {
@@ -65,14 +71,12 @@ export class Transaction {
6571
}
6672

6773
lookup(keys: DocumentKey[]): Promise<MaybeDocument[]> {
68-
if (this.committed) {
69-
return Promise.reject<MaybeDocument[]>(
70-
'Transaction has already completed.'
71-
);
72-
}
74+
this.ensureCommitNotCalled();
75+
7376
if (this.mutations.length > 0) {
74-
return Promise.reject<MaybeDocument[]>(
75-
'Transactions lookups are invalid after writes.'
77+
throw new FirestoreError(
78+
Code.INVALID_ARGUMENT,
79+
'Firestore transactions require all reads to be executed before all writes.'
7680
);
7781
}
7882
return this.datastore.lookup(keys).then(docs => {
@@ -88,12 +92,7 @@ export class Transaction {
8892
}
8993

9094
private write(mutations: Mutation[]): void {
91-
if (this.committed) {
92-
throw new FirestoreError(
93-
Code.FAILED_PRECONDITION,
94-
'Transaction has already completed.'
95-
);
96-
}
95+
this.ensureCommitNotCalled();
9796
this.mutations = this.mutations.concat(mutations);
9897
}
9998

@@ -117,8 +116,17 @@ export class Transaction {
117116
const version = this.readVersions.get(key);
118117
if (version && version.isEqual(SnapshotVersion.forDeletedDoc())) {
119118
// The document doesn't exist, so fail the transaction.
119+
120+
// This has to be validated locally because you can't send a precondition
121+
// that a document does not exist without changing the semantics of the
122+
// backend write to be an insert. This is the reverse of what we want,
123+
// since we want to assert that the document doesn't exist but then send
124+
// the update and have it fail. Since we can't express that to the backend,
125+
// we have to validate locally.
126+
127+
// Note: this can change once we can send separate verify writes in the transaction.
120128
throw new FirestoreError(
121-
Code.FAILED_PRECONDITION,
129+
Code.INVALID_ARGUMENT,
122130
"Can't update a document that doesn't exist."
123131
);
124132
} else if (version) {
@@ -136,7 +144,11 @@ export class Transaction {
136144
}
137145

138146
update(key: DocumentKey, data: ParsedUpdateData): void {
139-
this.write(data.toMutations(key, this.preconditionForUpdate(key)));
147+
try {
148+
this.write(data.toMutations(key, this.preconditionForUpdate(key)));
149+
} catch (e) {
150+
this.lastWriteError = e;
151+
}
140152
}
141153

142154
delete(key: DocumentKey): void {
@@ -150,18 +162,31 @@ export class Transaction {
150162
}
151163

152164
commit(): Promise<void> {
165+
this.ensureCommitNotCalled();
166+
167+
if (this.lastWriteError) {
168+
throw this.lastWriteError;
169+
}
153170
let unwritten = this.readVersions;
154171
// For each mutation, note that the doc was written.
155172
this.mutations.forEach(mutation => {
156173
unwritten = unwritten.remove(mutation.key);
157174
});
158175
if (!unwritten.isEmpty()) {
159-
return Promise.reject(
160-
Error('Every document read in a transaction must also be written.')
176+
throw new FirestoreError(
177+
Code.INVALID_ARGUMENT,
178+
'Every document read in a transaction must also be written.'
161179
);
162180
}
163181
return this.datastore.commit(this.mutations).then(() => {
164182
this.committed = true;
165183
});
166184
}
185+
186+
private ensureCommitNotCalled(): void {
187+
assert(
188+
!this.committed,
189+
'A transaction object cannot be used after its update callback has been invoked.'
190+
);
191+
}
167192
}

packages/firestore/test/integration/api/transactions.test.ts

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ apiDescribe('Database transactions', (persistence: boolean) => {
5050
// expect(snapshot.data()['owner']).to.equal('Jonny');
5151
// });
5252
.then(() => expect.fail('transaction should fail'))
53-
.catch(err => {
53+
.catch((err: firestore.FirestoreError) => {
5454
expect(err).to.exist;
55+
expect(err.code).to.equal('invalid-argument');
5556
expect(err.message).to.contain(
5657
'Every document read in a transaction must also be' +
5758
' written'
@@ -117,11 +118,9 @@ apiDescribe('Database transactions', (persistence: boolean) => {
117118
});
118119
})
119120
.then(() => expect.fail('transaction should fail'))
120-
.catch(err => {
121+
.catch((err: firestore.FirestoreError) => {
121122
expect(err).to.exist;
122-
expect(err.message).to.equal(
123-
"Can't update a document that doesn't exist."
124-
);
123+
expect(err.code).to.equal('invalid-argument');
125124
});
126125
});
127126
});
@@ -139,16 +138,10 @@ apiDescribe('Database transactions', (persistence: boolean) => {
139138
transaction.update(docRef, { foo: 'new-bar' });
140139
});
141140
})
142-
.then(() => expect.fail('transaction should fail'))
143-
.catch(err => {
141+
.catch((err: firestore.FirestoreError) => {
144142
expect(err).to.exist;
145-
// TODO(b/65409268): This is likely the wrong error code
146-
expect((err as firestore.FirestoreError).code).to.equal(
147-
'failed-precondition'
148-
);
149-
expect(err.message).to.equal(
150-
"Can't update a document that doesn't exist."
151-
);
143+
// This is the error surfaced by the backend.
144+
expect(err.code).to.equal('invalid-argument');
152145
});
153146
});
154147
});
@@ -172,7 +165,7 @@ apiDescribe('Database transactions', (persistence: boolean) => {
172165
.then(() => expect.fail('transaction should fail'))
173166
.catch(err => {
174167
expect(err).to.exist;
175-
// TODO(b/65409268): This is likely the wrong error code
168+
// This is the error surfaced by the backend.
176169
expect((err as firestore.FirestoreError).code).to.equal(
177170
'invalid-argument'
178171
);
@@ -412,8 +405,9 @@ apiDescribe('Database transactions', (persistence: boolean) => {
412405
// })
413406
// .then(snapshot => expect(snapshot.data()['count']).to.equal(16));
414407
.then(() => expect.fail('transaction should fail'))
415-
.catch(err => {
408+
.catch((err: firestore.FirestoreError) => {
416409
expect(err).to.exist;
410+
expect(err.code).to.equal('invalid-argument');
417411
expect(err.message).to.contain(
418412
'Every document read in a transaction must also be ' + 'written'
419413
);
@@ -447,7 +441,10 @@ apiDescribe('Database transactions', (persistence: boolean) => {
447441
});
448442
})
449443
.then(() => expect.fail('transaction should fail'))
450-
.catch(err => expect(err).to.exist)
444+
.catch(err => {
445+
expect(err).to.exist;
446+
expect((err as firestore.FirestoreError).code).to.equal('aborted');
447+
})
451448
.then(() => doc.get())
452449
.then(snapshot => {
453450
expect(snapshot.data()!['count']).to.equal(1234);
@@ -466,12 +463,48 @@ apiDescribe('Database transactions', (persistence: boolean) => {
466463
.then(() => {
467464
expect.fail('transaction should fail');
468465
})
469-
.catch(err => {
466+
.catch((err: firestore.FirestoreError) => {
470467
expect(err).to.exist;
468+
expect(err.code).to.equal('invalid-argument');
469+
expect(err.message).to.contain(
470+
'Firestore transactions require all reads to be executed'
471+
);
471472
});
472473
});
473474
});
474475

476+
it(
477+
'cannot read non-existent document then update, even if ' +
478+
'document is written after the read',
479+
() => {
480+
return integrationHelpers.withTestDb(persistence, db => {
481+
return db
482+
.runTransaction(transaction => {
483+
const doc = db.collection('nonexistent').doc();
484+
return (
485+
transaction
486+
// Get and update a document that doesn't exist so that the transaction fails.
487+
.get(doc)
488+
// Do a write outside of the transaction.
489+
.then(() => doc.set({ count: 1234 }))
490+
// Now try to update the doc from within the transaction. This
491+
// should fail, because the document didn't exist at the start
492+
// of the transaction.
493+
.then(() => transaction.update(doc, { count: 16 }))
494+
);
495+
})
496+
.then(() => expect.fail('transaction should fail'))
497+
.catch((err: firestore.FirestoreError) => {
498+
expect(err).to.exist;
499+
expect(err.code).to.equal('invalid-argument');
500+
expect(err.message).to.contain(
501+
"Can't update a document that doesn't exist."
502+
);
503+
});
504+
});
505+
}
506+
);
507+
475508
describe('must return a promise:', () => {
476509
const noop = (): void => {
477510
/* -_- */
@@ -522,8 +555,9 @@ apiDescribe('Database transactions', (persistence: boolean) => {
522555
// expect(snapshot.data()['foo']).to.equal('bar');
523556
// });
524557
.then(() => expect.fail('transaction should fail'))
525-
.catch(err => {
558+
.catch((err: firestore.FirestoreError) => {
526559
expect(err).to.exist;
560+
expect(err.code).to.equal('invalid-argument');
527561
expect(err.message).to.contain(
528562
'Every document read in a transaction must also be ' + 'written'
529563
);

0 commit comments

Comments
 (0)