Skip to content

Commit 8a03143

Browse files
Fix encoding of CSI keys in Safari (#8993)
1 parent 88584fd commit 8a03143

File tree

10 files changed

+204
-59
lines changed

10 files changed

+204
-59
lines changed
+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@firebase/firestore": patch
3+
"@firebase/util": minor
4+
---
5+
6+
Fix Safari/WebKit cache issues when client-side indexing is used.

common/api-review/util.api.md

+5
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,11 @@ export function isReactNative(): boolean;
317317
// @public
318318
export function isSafari(): boolean;
319319

320+
// Warning: (ae-missing-release-tag) "isSafariOrWebkit" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
321+
//
322+
// @public
323+
export function isSafariOrWebkit(): boolean;
324+
320325
// Warning: (ae-missing-release-tag) "issuedAtTime" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
321326
//
322327
// @public

config/karma.base.js

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const config = {
5353

5454
// Doing 65 seconds to allow for the 20 second firestore tests
5555
browserNoActivityTimeout: 65000,
56+
browserDisconnectTimeout: 65000,
5657

5758
// Preprocess matching files before serving them to the browser.
5859
// Available preprocessors:

packages/firestore/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
"test:browser:emulator": "karma start --targetBackend=emulator",
3737
"test:browser:nightly": "karma start --targetBackend=nightly",
3838
"test:browser:prod": "karma start --targetBackend=prod",
39+
"test:webkit:prod": "BROWSERS=WebkitHeadless karma start --targetBackend=prod",
40+
"test:webkit:unit": "BROWSERS=WebkitHeadless karma start --unit --targetBackend=prod",
3941
"test:browser:prod:nameddb": "karma start --targetBackend=prod --databaseId=test-db",
4042
"test:browser:unit": "karma start --unit",
4143
"test:browser:debug": "karma start --browsers=Chrome --auto-watch",

packages/firestore/src/index/index_entry.ts

+106-15
Original file line numberDiff line numberDiff line change
@@ -15,65 +15,102 @@
1515
* limitations under the License.
1616
*/
1717

18+
import { isSafariOrWebkit } from '@firebase/util';
19+
20+
import { DbIndexEntry } from '../local/indexeddb_schema';
21+
import { DbIndexEntryKey, KeySafeBytes } from '../local/indexeddb_sentinels';
1822
import { DocumentKey } from '../model/document_key';
1923

2024
/** Represents an index entry saved by the SDK in persisted storage. */
2125
export class IndexEntry {
2226
constructor(
23-
readonly indexId: number,
24-
readonly documentKey: DocumentKey,
25-
readonly arrayValue: Uint8Array,
26-
readonly directionalValue: Uint8Array
27+
readonly _indexId: number,
28+
readonly _documentKey: DocumentKey,
29+
readonly _arrayValue: Uint8Array,
30+
readonly _directionalValue: Uint8Array
2731
) {}
2832

2933
/**
3034
* Returns an IndexEntry entry that sorts immediately after the current
3135
* directional value.
3236
*/
3337
successor(): IndexEntry {
34-
const currentLength = this.directionalValue.length;
38+
const currentLength = this._directionalValue.length;
3539
const newLength =
36-
currentLength === 0 || this.directionalValue[currentLength - 1] === 255
40+
currentLength === 0 || this._directionalValue[currentLength - 1] === 255
3741
? currentLength + 1
3842
: currentLength;
3943

4044
const successor = new Uint8Array(newLength);
41-
successor.set(this.directionalValue, 0);
45+
successor.set(this._directionalValue, 0);
4246
if (newLength !== currentLength) {
43-
successor.set([0], this.directionalValue.length);
47+
successor.set([0], this._directionalValue.length);
4448
} else {
4549
++successor[successor.length - 1];
4650
}
4751

4852
return new IndexEntry(
49-
this.indexId,
50-
this.documentKey,
51-
this.arrayValue,
53+
this._indexId,
54+
this._documentKey,
55+
this._arrayValue,
5256
successor
5357
);
5458
}
59+
60+
// Create a representation of the Index Entry as a DbIndexEntry
61+
dbIndexEntry(
62+
uid: string,
63+
orderedDocumentKey: Uint8Array,
64+
documentKey: DocumentKey
65+
): DbIndexEntry {
66+
return {
67+
indexId: this._indexId,
68+
uid,
69+
arrayValue: encodeKeySafeBytes(this._arrayValue),
70+
directionalValue: encodeKeySafeBytes(this._directionalValue),
71+
orderedDocumentKey: encodeKeySafeBytes(orderedDocumentKey),
72+
documentKey: documentKey.path.toArray()
73+
};
74+
}
75+
76+
// Create a representation of the Index Entry as a DbIndexEntryKey
77+
dbIndexEntryKey(
78+
uid: string,
79+
orderedDocumentKey: Uint8Array,
80+
documentKey: DocumentKey
81+
): DbIndexEntryKey {
82+
const entry = this.dbIndexEntry(uid, orderedDocumentKey, documentKey);
83+
return [
84+
entry.indexId,
85+
entry.uid,
86+
entry.arrayValue,
87+
entry.directionalValue,
88+
entry.orderedDocumentKey,
89+
entry.documentKey
90+
];
91+
}
5592
}
5693

5794
export function indexEntryComparator(
5895
left: IndexEntry,
5996
right: IndexEntry
6097
): number {
61-
let cmp = left.indexId - right.indexId;
98+
let cmp = left._indexId - right._indexId;
6299
if (cmp !== 0) {
63100
return cmp;
64101
}
65102

66-
cmp = compareByteArrays(left.arrayValue, right.arrayValue);
103+
cmp = compareByteArrays(left._arrayValue, right._arrayValue);
67104
if (cmp !== 0) {
68105
return cmp;
69106
}
70107

71-
cmp = compareByteArrays(left.directionalValue, right.directionalValue);
108+
cmp = compareByteArrays(left._directionalValue, right._directionalValue);
72109
if (cmp !== 0) {
73110
return cmp;
74111
}
75112

76-
return DocumentKey.comparator(left.documentKey, right.documentKey);
113+
return DocumentKey.comparator(left._documentKey, right._documentKey);
77114
}
78115

79116
export function compareByteArrays(left: Uint8Array, right: Uint8Array): number {
@@ -85,3 +122,57 @@ export function compareByteArrays(left: Uint8Array, right: Uint8Array): number {
85122
}
86123
return left.length - right.length;
87124
}
125+
126+
/**
127+
* Workaround for WebKit bug: https://bugs.webkit.org/show_bug.cgi?id=292721
128+
* Create a key safe representation of Uint8Array values.
129+
* If the browser is detected as Safari or WebKit, then
130+
* the input array will be converted to "sortable byte string".
131+
* Otherwise, the input array will be returned in its original type.
132+
*/
133+
export function encodeKeySafeBytes(array: Uint8Array): KeySafeBytes {
134+
if (isSafariOrWebkit()) {
135+
return encodeUint8ArrayToSortableString(array);
136+
}
137+
return array;
138+
}
139+
140+
/**
141+
* Reverts the key safe representation of Uint8Array (created by
142+
* encodeKeySafeBytes) to a normal Uint8Array.
143+
*/
144+
export function decodeKeySafeBytes(input: KeySafeBytes): Uint8Array {
145+
if (typeof input !== 'string') {
146+
return input;
147+
}
148+
return decodeSortableStringToUint8Array(input);
149+
}
150+
151+
/**
152+
* Encodes a Uint8Array into a "sortable byte string".
153+
* A "sortable byte string" sorts in the same order as the Uint8Array.
154+
* This works because JS string comparison sorts strings based on code points.
155+
*/
156+
function encodeUint8ArrayToSortableString(array: Uint8Array): string {
157+
let byteString = '';
158+
for (let i = 0; i < array.length; i++) {
159+
byteString += String.fromCharCode(array[i]);
160+
}
161+
162+
return byteString;
163+
}
164+
165+
/**
166+
* Decodes a "sortable byte string" back into a Uint8Array.
167+
* A "sortable byte string" is assumed to be created where each character's
168+
* Unicode code point directly corresponds to a single byte value (0-255).
169+
*/
170+
function decodeSortableStringToUint8Array(byteString: string): Uint8Array {
171+
const uint8array = new Uint8Array(byteString.length);
172+
173+
for (let i = 0; i < byteString.length; i++) {
174+
uint8array[i] = byteString.charCodeAt(i);
175+
}
176+
177+
return uint8array;
178+
}

packages/firestore/src/local/indexeddb_index_manager.ts

+31-34
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ import {
3939
} from '../core/target';
4040
import { FirestoreIndexValueWriter } from '../index/firestore_index_value_writer';
4141
import { IndexByteEncoder } from '../index/index_byte_encoder';
42-
import { IndexEntry, indexEntryComparator } from '../index/index_entry';
42+
import {
43+
IndexEntry,
44+
indexEntryComparator,
45+
encodeKeySafeBytes,
46+
decodeKeySafeBytes
47+
} from '../index/index_entry';
4348
import { documentKeySet, DocumentMap } from '../model/collections';
4449
import { Document } from '../model/document';
4550
import { DocumentKey } from '../model/document_key';
@@ -817,14 +822,13 @@ export class IndexedDbIndexManager implements IndexManager {
817822
indexEntry: IndexEntry
818823
): PersistencePromise<void> {
819824
const indexEntries = indexEntriesStore(transaction);
820-
return indexEntries.put({
821-
indexId: indexEntry.indexId,
822-
uid: this.uid,
823-
arrayValue: indexEntry.arrayValue,
824-
directionalValue: indexEntry.directionalValue,
825-
orderedDocumentKey: this.encodeDirectionalKey(fieldIndex, document.key),
826-
documentKey: document.key.path.toArray()
827-
});
825+
return indexEntries.put(
826+
indexEntry.dbIndexEntry(
827+
this.uid,
828+
this.encodeDirectionalKey(fieldIndex, document.key),
829+
document.key
830+
)
831+
);
828832
}
829833

830834
private deleteIndexEntry(
@@ -834,14 +838,13 @@ export class IndexedDbIndexManager implements IndexManager {
834838
indexEntry: IndexEntry
835839
): PersistencePromise<void> {
836840
const indexEntries = indexEntriesStore(transaction);
837-
return indexEntries.delete([
838-
indexEntry.indexId,
839-
this.uid,
840-
indexEntry.arrayValue,
841-
indexEntry.directionalValue,
842-
this.encodeDirectionalKey(fieldIndex, document.key),
843-
document.key.path.toArray()
844-
]);
841+
return indexEntries.delete(
842+
indexEntry.dbIndexEntryKey(
843+
this.uid,
844+
this.encodeDirectionalKey(fieldIndex, document.key),
845+
document.key
846+
)
847+
);
845848
}
846849

847850
private getExistingIndexEntries(
@@ -858,16 +861,18 @@ export class IndexedDbIndexManager implements IndexManager {
858861
range: IDBKeyRange.only([
859862
fieldIndex.indexId,
860863
this.uid,
861-
this.encodeDirectionalKey(fieldIndex, documentKey)
864+
encodeKeySafeBytes(
865+
this.encodeDirectionalKey(fieldIndex, documentKey)
866+
)
862867
])
863868
},
864869
(_, entry) => {
865870
results = results.add(
866871
new IndexEntry(
867872
fieldIndex.indexId,
868873
documentKey,
869-
entry.arrayValue,
870-
entry.directionalValue
874+
decodeKeySafeBytes(entry.arrayValue),
875+
decodeKeySafeBytes(entry.directionalValue)
871876
)
872877
);
873878
}
@@ -1020,24 +1025,16 @@ export class IndexedDbIndexManager implements IndexManager {
10201025
return [];
10211026
}
10221027

1023-
const lowerBound = [
1024-
bounds[i].indexId,
1028+
const lowerBound = bounds[i].dbIndexEntryKey(
10251029
this.uid,
1026-
bounds[i].arrayValue,
1027-
bounds[i].directionalValue,
10281030
EMPTY_VALUE,
1029-
[]
1030-
] as DbIndexEntryKey;
1031-
1032-
const upperBound = [
1033-
bounds[i + 1].indexId,
1031+
DocumentKey.empty()
1032+
);
1033+
const upperBound = bounds[i + 1].dbIndexEntryKey(
10341034
this.uid,
1035-
bounds[i + 1].arrayValue,
1036-
bounds[i + 1].directionalValue,
10371035
EMPTY_VALUE,
1038-
[]
1039-
] as DbIndexEntryKey;
1040-
1036+
DocumentKey.empty()
1037+
);
10411038
ranges.push(IDBKeyRange.bound(lowerBound, upperBound));
10421039
}
10431040
return ranges;

packages/firestore/src/local/indexeddb_schema.ts

+7-5
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
} from '../protos/firestore_proto_api';
2727

2828
import { EncodedResourcePath } from './encoded_resource_path';
29-
import { DbTimestampKey } from './indexeddb_sentinels';
29+
import { DbTimestampKey, KeySafeBytes } from './indexeddb_sentinels';
3030

3131
/**
3232
* Schema Version for the Web client:
@@ -52,9 +52,11 @@ import { DbTimestampKey } from './indexeddb_sentinels';
5252
* 14. Add overlays.
5353
* 15. Add indexing support.
5454
* 16. Parse timestamp strings before creating index entries.
55+
* 17. TODO(tomandersen)
56+
* 18. Encode key safe representations of IndexEntry in DbIndexEntryStore.
5557
*/
5658

57-
export const SCHEMA_VERSION = 17;
59+
export const SCHEMA_VERSION = 18;
5860

5961
/**
6062
* Wrapper class to store timestamps (seconds and nanos) in IndexedDb objects.
@@ -507,14 +509,14 @@ export interface DbIndexEntry {
507509
/** The user id for this entry. */
508510
uid: string;
509511
/** The encoded array index value for this entry. */
510-
arrayValue: Uint8Array;
512+
arrayValue: KeySafeBytes;
511513
/** The encoded directional value for equality and inequality filters. */
512-
directionalValue: Uint8Array;
514+
directionalValue: KeySafeBytes;
513515
/**
514516
* The document key this entry points to. This entry is encoded by an ordered
515517
* encoder to match the key order of the index.
516518
*/
517-
orderedDocumentKey: Uint8Array;
519+
orderedDocumentKey: KeySafeBytes;
518520
/** The segments of the document key this entry points to. */
519521
documentKey: string[];
520522
}

packages/firestore/src/local/indexeddb_schema_converter.ts

+18
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
* limitations under the License.
1616
*/
1717

18+
import { isSafariOrWebkit } from '@firebase/util';
19+
1820
import { User } from '../auth/user';
1921
import { ListenSequence } from '../core/listen_sequence';
2022
import { SnapshotVersion } from '../core/snapshot_version';
@@ -277,6 +279,22 @@ export class SchemaConverter implements SimpleDbSchemaConverter {
277279
});
278280
}
279281

282+
if (fromVersion < 18 && toVersion >= 18) {
283+
// Clear the IndexEntryStores on WebKit and Safari to remove possibly
284+
// corrupted index entries
285+
if (isSafariOrWebkit()) {
286+
p = p
287+
.next(() => {
288+
const indexStateStore = txn.objectStore(DbIndexStateStore);
289+
indexStateStore.clear();
290+
})
291+
.next(() => {
292+
const indexEntryStore = txn.objectStore(DbIndexEntryStore);
293+
indexEntryStore.clear();
294+
});
295+
}
296+
}
297+
280298
return p;
281299
}
282300

0 commit comments

Comments
 (0)