Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/firestore/RNFBFirestore.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Pod::Spec.new do |s|
'ios/RNFBFirestore/RNFBFirestoreTransactionModule.h',
'ios/RNFBFirestore/RNFBFirestoreListenerRegistry.h',
'ios/RNFBFirestore/RNFBFirestoreTransactionRegistry.h',
'ios/RNFBFirestore/RNFBFirestoreTransactionAttempt.h',
'ios/generated/**/*.h',
]
s.exclude_files = 'ios/generated/RCTThirdPartyComponentsProvider.*', 'ios/generated/RCTAppDependencyProvider.*', 'ios/generated/RCTModuleProviders.*', 'ios/generated/RCTModulesConformingToProtocolsProvider.*', 'ios/generated/RCTUnstableModulesRequiringMainQueueSetupProvider.*', 'ios/*UnitTests/**'
Expand Down
164 changes: 164 additions & 0 deletions packages/firestore/__tests__/runTransaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,167 @@ describe('runTransaction options passthrough', function () {
expect(transactionBegin).not.toHaveBeenCalled();
});
});

describe('runTransaction dead-id guard', function () {
function nativeMocks(handler: FirestoreTransactionHandler) {
return handler._firestore.native as unknown as {
transactionApplyBuffer: jest.Mock;
transactionDispose: jest.Mock;
};
}

it('does not applyBuffer or reject again after the id is already finished', async function () {
const handler = createHandler(jest.fn());
const { transactionApplyBuffer } = nativeMocks(handler);

let releaseUpdate: (value?: unknown) => void = () => undefined;
const pending = handler._add(
() =>
new Promise(resolve => {
releaseUpdate = resolve;
}),
);

const id = Number(Object.keys(handler._pending)[0]);
const updateWork = handler._handleUpdate({ listenerId: id, body: { type: 'update' } });

handler._handleError({
listenerId: id,
body: {
type: 'error',
error: { code: 'deadline-exceeded', message: 'timeout' },
},
});

await expect(pending).rejects.toMatchObject({
code: 'firestore/deadline-exceeded',
});

releaseUpdate('late');
await updateWork;

expect(transactionApplyBuffer).not.toHaveBeenCalled();
});

it('does not applyBuffer after complete has already settled the id', async function () {
const handler = createHandler(jest.fn());
const { transactionApplyBuffer } = nativeMocks(handler);

let releaseUpdate: (value?: unknown) => void = () => undefined;
const pending = handler._add(
() =>
new Promise(resolve => {
releaseUpdate = resolve;
}),
);

const id = Number(Object.keys(handler._pending)[0]);
const updateWork = handler._handleUpdate({ listenerId: id, body: { type: 'update' } });

handler._handleComplete({ listenerId: id, body: { type: 'complete' } });
await expect(pending).resolves.toBeUndefined();

releaseUpdate('late');
await updateWork;

expect(transactionApplyBuffer).not.toHaveBeenCalled();
});

it('still applyBuffers when a second update event arrives while the id is pending', async function () {
const handler = createHandler(jest.fn());
const { transactionApplyBuffer } = nativeMocks(handler);

void handler._add(async () => 'ok');
const id = Number(Object.keys(handler._pending)[0]);

await handler._handleUpdate({ listenerId: id, body: { type: 'update' } });
await handler._handleUpdate({ listenerId: id, body: { type: 'update' } });

expect(transactionApplyBuffer).toHaveBeenCalledTimes(2);
expect(transactionApplyBuffer).toHaveBeenNthCalledWith(1, id, []);
expect(transactionApplyBuffer).toHaveBeenNthCalledWith(2, id, []);
});

it('rejects when updateFunction does not return a Promise', async function () {
const handler = createHandler(jest.fn());
const pending = handler._add((() => 123) as never);
const id = Number(Object.keys(handler._pending)[0]);

await handler._handleUpdate({ listenerId: id, body: { type: 'update' } });

await expect(pending).rejects.toThrow("'updateFunction' must return a Promise");
expect(nativeMocks(handler).transactionApplyBuffer).not.toHaveBeenCalled();
});

it('rejects when updateFunction throws while the id is still pending', async function () {
const handler = createHandler(jest.fn());
const pending = handler._add(async () => {
throw new Error('user boom');
});
const id = Number(Object.keys(handler._pending)[0]);

await handler._handleUpdate({ listenerId: id, body: { type: 'update' } });

await expect(pending).rejects.toThrow('user boom');
expect(nativeMocks(handler).transactionApplyBuffer).not.toHaveBeenCalled();
});

it('ignores update events for unknown ids', async function () {
const handler = createHandler(jest.fn());
const { transactionApplyBuffer, transactionDispose } = nativeMocks(handler);

await handler._handleUpdate({ listenerId: 99, body: { type: 'update' } });

expect(transactionApplyBuffer).not.toHaveBeenCalled();
expect(transactionDispose).toHaveBeenCalledWith(99);
});

it('ignores update events without a listener id', async function () {
const handler = createHandler(jest.fn());
await handler._handleUpdate({ body: { type: 'update' } });
expect(nativeMocks(handler).transactionApplyBuffer).not.toHaveBeenCalled();
});

it('ignores error events for unknown ids', function () {
const handler = createHandler(jest.fn());
const { transactionDispose } = nativeMocks(handler);

handler._handleError({
listenerId: 99,
body: {
type: 'error',
error: { code: 'deadline-exceeded', message: 'timeout' },
},
});

expect(transactionDispose).not.toHaveBeenCalled();
expect(handler._pending).toEqual({});
});

it('ignores complete events for unknown ids', function () {
const handler = createHandler(jest.fn());
const { transactionDispose } = nativeMocks(handler);

handler._handleComplete({ listenerId: 99, body: { type: 'complete' } });

expect(transactionDispose).not.toHaveBeenCalled();
expect(handler._pending).toEqual({});
});

it('ignores error events without a listener id', function () {
const handler = createHandler(jest.fn());
handler._handleError({
body: {
type: 'error',
error: { code: 'deadline-exceeded', message: 'timeout' },
},
});
expect(nativeMocks(handler).transactionDispose).not.toHaveBeenCalled();
});

it('ignores complete events without a listener id', function () {
const handler = createHandler(jest.fn());
handler._handleComplete({ body: { type: 'complete' } });
expect(nativeMocks(handler).transactionDispose).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Copyright (c) 2016-present Invertase Limited & Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this library except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

/**
* Timeout NSError domain for the JS-bridge wait. Must not be FIRFirestoreErrorDomain so the iOS
* SDK treats the failure as permanent (no transaction retry). Code matches
* FIRFirestoreErrorCodeDeadlineExceeded (4) so JS maps via getCodeAndMessage:.
*/
FOUNDATION_EXPORT NSString *const RNFBFirestoreTransactionTimeoutErrorDomain;
FOUNDATION_EXPORT const NSInteger RNFBFirestoreTransactionTimeoutErrorCode;
FOUNDATION_EXPORT const int64_t RNFBFirestoreTransactionWaitTimeoutNSec;

FOUNDATION_EXPORT NSString *const RNFBFirestoreTransactionRejectCodeAborted;
FOUNDATION_EXPORT NSString *const RNFBFirestoreTransactionRejectCodeDeadlineExceeded;
FOUNDATION_EXPORT NSString *const RNFBFirestoreTransactionRejectCodeInternalError;
FOUNDATION_EXPORT NSString *const RNFBFirestoreTransactionMissingIdMessage;

typedef NS_ENUM(NSInteger, RNFBFirestoreTransactionWaitResult) {
RNFBFirestoreTransactionWaitResultSignaled = 0,
RNFBFirestoreTransactionWaitResultTimeout = 1,
RNFBFirestoreTransactionWaitResultAborted = 2,
RNFBFirestoreTransactionWaitResultStale = 3,
};

/**
* One native transaction id's attempt state. Foundation-only so in-package XCTest can compile it
* without Firebase or TurboModules. FIRTransaction is stored as an opaque id; the module casts.
*/
@interface RNFBFirestoreTransactionAttempt : NSObject

@property(nonatomic, readonly) BOOL aborted;
@property(nonatomic, readonly) BOOL updateBlockReturned;
@property(nonatomic, readonly, nullable) NSArray *commandBuffer;
@property(nonatomic, readonly, nullable) id nativeTransaction;

+ (dispatch_time_t)defaultWaitTimeout;
- (NSError *)timeoutError;

/**
* Arms a new semaphore and native transaction, and resets `updateBlockReturned` so get is eligible
* again. Timeout must stay a non-FIRFirestoreErrorDomain error so the SDK does not retry; a retry
* would make a leftover JS get look live on the new FIRTransaction.
*/
- (void)prepareForUpdateBlockWithNativeTransaction:(nullable id)nativeTransaction;
- (RNFBFirestoreTransactionWaitResult)waitUntilSignaledWithTimeout:(dispatch_time_t)timeout;
- (RNFBFirestoreTransactionWaitResult)completeWaitForSemaphore:(dispatch_semaphore_t)semaphore
timedOut:(BOOL)timedOut;

- (BOOL)isEligibleForGet;
/// JS reject payload when get is not eligible. aborted, else leftover after the update block
/// returned (`deadline-exceeded`), else `internal-error`. Missing registry id is the module.
- (NSDictionary *)ineligibleGetRejectUserInfo;
/// nil when get is still live. Otherwise the same payload as `ineligibleGetRejectUserInfo`,
/// chosen under the same lock as the eligibility check.
- (nullable NSDictionary *)rejectUserInfoIfIneligibleForGet;
- (BOOL)applyCommandBuffer:(nullable NSArray *)commandBuffer;
- (void)abort;

@end

NS_ASSUME_NONNULL_END
Loading
Loading