Conversation
|
There was a problem hiding this comment.
Code Review
This pull request implements a contextual error enrichment system for the Firebase JS SDK, primarily focusing on Firestore. It allows errors to carry operational metadata such as resource paths, operation types, and authentication state to improve debuggability. Key changes include refactoring error classes to support generic custom data, capturing ID tokens at request time, and implementing observers to wrap errors with context. The PR also adds a documentation guide and updates internal scripts. Review feedback identified several critical issues, including an accidental IDE import of the TypeScript compiler API in browser code, a circular dependency introduced in the utility package, and the loss of original stack traces during error enrichment. Additionally, the reviewer noted the presence of debug logs, unresolved TODOs, and a regression in script error handling that should be resolved.
| import { logDebug, logWarn } from '../../util/log'; | ||
| import { Rejecter, Resolver } from '../../util/promise'; | ||
| import { StringMap } from '../../util/types'; | ||
| import { HighlightSpanKind } from 'typescript'; |
| "dependencies": { | ||
| "tslib": "^2.1.0" | ||
| "tslib": "^2.1.0", | ||
| "@firebase/auth": "1.12.2" |
There was a problem hiding this comment.
Adding @firebase/auth as a dependency to @firebase/util is problematic as it likely creates a circular dependency (since auth depends on util). Additionally, util is intended to be a low-level package and should not depend on high-level SDKs. The current changes in util/src/errors.ts use internal JWT utilities and do not appear to require the full auth package.
| copyWithAuthInfo(idToken: string | null): FirestoreError { | ||
| if (idToken === null) { | ||
| return this; | ||
| } | ||
| const authInfo = parseIdTokenToAuthInfo(idToken); | ||
| return new FirestoreError(this.code, this.message, { | ||
| ...this.customData, | ||
| authInfo | ||
| }); | ||
| } |
There was a problem hiding this comment.
The copyWithAuthInfo method creates a new FirestoreError instance, which causes the original stack trace to be lost. To maintain debuggability, the stack trace should be preserved by copying it from the current instance to the new one.
copyWithAuthInfo(idToken: string | null): FirestoreError {
if (idToken === null) {
return this;
}
const authInfo = parseIdTokenToAuthInfo(idToken);
const error = new FirestoreError(this.code, this.message, {
...this.customData,
authInfo
});
if (this.stack) {
Object.defineProperty(error, 'stack', {
value: this.stack,
writable: false,
configurable: true
});
}
return error;
}| const newFirestoreErr = new FirestoreError( | ||
| firestoreErr.code, | ||
| firestoreErr.message, | ||
| customData | ||
| ); |
There was a problem hiding this comment.
In firestoreToContextualError, creating a new FirestoreError loses the original stack trace of the input err. The stack trace should be explicitly preserved to ensure that error reports remain useful for debugging.
| const newFirestoreErr = new FirestoreError( | |
| firestoreErr.code, | |
| firestoreErr.message, | |
| customData | |
| ); | |
| const newFirestoreErr = new FirestoreError( | |
| firestoreErr.code, | |
| firestoreErr.message, | |
| customData | |
| ); | |
| if (firestoreErr.stack) { | |
| Object.defineProperty(newFirestoreErr, 'stack', { | |
| value: firestoreErr.stack, | |
| writable: false, | |
| configurable: true | |
| }); | |
| } |
| } | ||
| // Mark closed so no further events are propagated | ||
| closed = true; | ||
| console.log('CALL ON CLOSE'); |
| // TODO: Make sure that the error used here parses FirestoreError. | ||
| // TODO: Make sure that the stack trace is correct. | ||
| processUserCallback(syncEngineImpl, batchId, error); | ||
| // Attach the fact that it was a write here. | ||
| // syncEngineImpl. | ||
| triggerPendingWritesCallbacks(syncEngineImpl, batchId); |
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */var __awaiter=this&&this.__awaiter||function(thisArg,_arguments,P,generator){function adopt(value){return value instanceof P?value:new P((function(resolve){resolve(value)}))}return new(P||(P=Promise))((function(resolve,reject){function fulfilled(value){try{step(generator.next(value))}catch(e){reject(e)}}function rejected(value){try{step(generator["throw"](value))}catch(e){reject(e)}}function step(result){result.done?resolve(result.value):adopt(result.value).then(fulfilled,rejected)}step((generator=generator.apply(thisArg,_arguments||[])).next())}))};var __generator=this&&this.__generator||function(thisArg,body){var _={label:0,sent:function(){if(t[0]&1)throw t[1];return t[1]},trys:[],ops:[]},f,y,t,g;return g={next:verb(0),throw:verb(1),return:verb(2)},typeof Symbol==="function"&&(g[Symbol.iterator]=function(){return this}),g;function verb(n){return function(v){return step([n,v])}}function step(op){if(f)throw new TypeError("Generator is already executing.");while(g&&(g=0,op[0]&&(_=0)),_)try{if(f=1,y&&(t=op[0]&2?y["return"]:op[0]?y["throw"]||((t=y["return"])&&t.call(y),0):y.next)&&!(t=t.call(y,op[1])).done)return t;if(y=0,t)op=[op[0]&2,t.value];switch(op[0]){case 0:case 1:t=op;break;case 4:_.label++;return{value:op[1],done:false};case 5:_.label++;y=op[1];op=[0];continue;case 7:op=_.ops.pop();_.trys.pop();continue;default:if(!(t=_.trys,t=t.length>0&&t[t.length-1])&&(op[0]===6||op[0]===2)){_=0;continue}if(op[0]===3&&(!t||op[1]>t[0]&&op[1]<t[3])){_.label=op[1];break}if(op[0]===6&&_.label<t[1]){_.label=t[1];t=op;break}if(t&&_.label<t[2]){_.label=t[2];_.ops.push(op);break}if(t[2])_.ops.pop();_.trys.pop();continue}op=body.call(thisArg,_)}catch(e){op=[6,e];y=0}finally{f=t=0}if(op[0]&5)throw op[1];return{value:op[0]?op[1]:void 0,done:true}}};Object.defineProperty(exports,"__esModule",{value:true});var fs=require("fs");var path=require("path");var ts=require("typescript");var yargs_1=require("yargs");var helpers_1=require("yargs/helpers");var isVerbose=false;function log(message){if(isVerbose){console.log(message)}}var targetFunctionNames=new Set(["fail","hardAssert"]);function getTsFilesRecursive(dirPath){var tsFiles=[];try{var entries=fs.readdirSync(dirPath,{withFileTypes:true});for(var _i=0,entries_1=entries;_i<entries_1.length;_i++){var entry=entries_1[_i];var fullPath=path.join(dirPath,entry.name);if(entry.isDirectory()){if(entry.name==="node_modules"){continue}tsFiles=tsFiles.concat(getTsFilesRecursive(fullPath))}else if(entry.isFile()&&entry.name.endsWith(".ts")){if(!entry.name.endsWith(".d.ts")){tsFiles.push(fullPath)}}}}catch(error){console.error("Error reading directory ".concat(dirPath,": ").concat(error.message))}return tsFiles}function findFunctionCalls(filePaths){var foundCalls=[];var _loop_1=function(filePath){try{var sourceText=fs.readFileSync(filePath,"utf8");var sourceFile_1=ts.createSourceFile(path.basename(filePath),sourceText,ts.ScriptTarget.ESNext,true,ts.ScriptKind.Unknown);var visit_1=function(node){if(ts.isCallExpression(node)){var functionName=null;var expression=node.expression;if(ts.isIdentifier(expression)){functionName=expression.text}if(functionName&&targetFunctionNames.has(functionName)){var _a=ts.getLineAndCharacterOfPosition(sourceFile_1,node.getStart()),line=_a.line,character=_a.character;var argsText_1=[];var errorMessage_1;var assertionId_1;if(node.arguments&&node.arguments.length>0){node.arguments.forEach((function(arg){argsText_1.push(arg.getText(sourceFile_1));if(ts.isStringLiteral(arg)){errorMessage_1=arg.getText(sourceFile_1)}else if(ts.isNumericLiteral(arg)){assertionId_1=parseInt(arg.getText(sourceFile_1),10)}}))}foundCalls.push({fileName:filePath,functionName:functionName,line:line+1,character:character+1,argumentsText:argsText_1,errorMessage:errorMessage_1,assertionId:assertionId_1!==null&&assertionId_1!==void 0?assertionId_1:-1})}}ts.forEachChild(node,visit_1)};visit_1(sourceFile_1)}catch(error){console.error("Error processing file ".concat(filePath,": ").concat(error.message))}};for(var _i=0,filePaths_1=filePaths;_i<filePaths_1.length;_i++){var filePath=filePaths_1[_i];_loop_1(filePath)}return foundCalls}function handleList(occurrences){if(occurrences.length===0){log("No assertion ids found.");return}occurrences.sort((function(a,b){return a.assertionId-b.assertionId})).forEach((function(call){console.log("ID: ".concat(call.assertionId,"; MESSAGE: ").concat(call.errorMessage,"; SOURCE: '").concat(call.functionName,"' call at ").concat(path.relative(process.cwd(),call.fileName),":").concat(call.line,":").concat(call.character))}))}function handleFind(occurrences,targetId){var target=typeof targetId==="number"?targetId:targetId.toString();var foundLocations=occurrences.filter((function(o){return String(o.assertionId)===String(target)}));if(foundLocations.length===0){log('Assertion id "'.concat(targetId,'" not found.'));process.exit(1)}handleList(foundLocations)}function handleCheck(occurrences){if(occurrences.length===0){log("No assertion ids found to check for duplicates.");return}var idCounts={};occurrences.forEach((function(occ){var codeStr=String(occ.assertionId);if(!idCounts[codeStr]){idCounts[codeStr]=[]}idCounts[codeStr].push(occ)}));var duplicatesFound=false;log("Checking for duplicate assertion id usage:");Object.entries(idCounts).forEach((function(_a){var code=_a[0],locations=_a[1];if(locations.length>1){duplicatesFound=true;console.error('\nDuplicate assertion id "'.concat(code,'" found at ').concat(locations.length," locations:"));locations.forEach((function(loc){var relativePath=path.relative(process.cwd(),loc.fileName);console.error("- ".concat(relativePath,":").concat(loc.line,":").concat(loc.character))}))}}));if(!duplicatesFound){log("No duplicate assertion ids found.")}else{process.exit(1)}}function handleNew(occurrences){var maxCode=0;occurrences.forEach((function(occ){if(occ.assertionId>maxCode){maxCode=occ.assertionId}}));if(occurrences.length===0){log("0");return}var newCode=maxCode+1;console.log(newCode)}function main(){return __awaiter(this,void 0,void 0,(function(){var argv,targetDirectory,stats,filesToScan,allOccurrences;return __generator(this,(function(_a){argv=(0,yargs_1.default)((0,helpers_1.hideBin)(process.argv)).usage("Usage: $0 [options]").option("dir",{alias:"D",describe:"Directory to scan recursively for TS files",type:"string",demandOption:true}).option("verbose",{alias:"V",describe:"verbose",type:"boolean"}).option("find",{alias:"F",describe:"Find locations of a specific {assertionId}",type:"string",nargs:1}).option("list",{alias:"L",describe:"List all unique assertion ids found (default action)",type:"boolean"}).option("new",{alias:"N",describe:"Suggest a new assertion id based on existing ones",type:"boolean"}).option("check",{alias:"C",describe:"Check for duplicate usage of assertion ids",type:"boolean"}).check((function(argv){var options=[argv.F,argv.L,argv.N,argv.C].filter(Boolean).length;if(options>1){throw new Error("Options -F, -L, -N, -C are mutually exclusive.")}return true})).help().alias("help","h").strict().parse();targetDirectory=path.resolve(argv["dir"]);isVerbose=!!argv["verbose"];try{stats=fs.statSync(targetDirectory);if(!stats.isDirectory()){console.error("Error: Provided path is not a directory: ".concat(targetDirectory));process.exit(1)}}catch(error){console.error("Error accessing directory ".concat(targetDirectory,": ").concat(error.message));process.exit(1)}log("Scanning directory: ".concat(targetDirectory));filesToScan=getTsFilesRecursive(targetDirectory);if(filesToScan.length===0){log("No relevant .ts or .tsx files found.");process.exit(0)}log("Found ".concat(filesToScan.length," files. Analyzing for assertion ids..."));allOccurrences=findFunctionCalls(filesToScan);log("Scan complete. Found ".concat(allOccurrences.length," potential assertion id occurrences."));if(argv["find"]){handleFind(allOccurrences,argv["find"])}else if(argv["new"]){handleNew(allOccurrences)}else if(argv["check"]){handleCheck(allOccurrences)}else{handleList(allOccurrences)}return[2]}))}))}main().catch((function(error){console.error("\nAn unexpected error occurred:");console.error(error);process.exit(1)})); No newline at end of file | ||
| */var __awaiter=this&&this.__awaiter||function(thisArg,_arguments,P,generator){function adopt(value){return value instanceof P?value:new P((function(resolve){resolve(value)}))}return new(P||(P=Promise))((function(resolve,reject){function fulfilled(value){try{step(generator.next(value))}catch(e){reject(e)}}function rejected(value){try{step(generator["throw"](value))}catch(e){reject(e)}}function step(result){result.done?resolve(result.value):adopt(result.value).then(fulfilled,rejected)}step((generator=generator.apply(thisArg,_arguments||[])).next())}))};var __generator=this&&this.__generator||function(thisArg,body){var _={label:0,sent:function(){if(t[0]&1)throw t[1];return t[1]},trys:[],ops:[]},f,y,t,g;return g={next:verb(0),throw:verb(1),return:verb(2)},typeof Symbol==="function"&&(g[Symbol.iterator]=function(){return this}),g;function verb(n){return function(v){return step([n,v])}}function step(op){if(f)throw new TypeError("Generator is already executing.");while(g&&(g=0,op[0]&&(_=0)),_)try{if(f=1,y&&(t=op[0]&2?y["return"]:op[0]?y["throw"]||((t=y["return"])&&t.call(y),0):y.next)&&!(t=t.call(y,op[1])).done)return t;if(y=0,t)op=[op[0]&2,t.value];switch(op[0]){case 0:case 1:t=op;break;case 4:_.label++;return{value:op[1],done:false};case 5:_.label++;y=op[1];op=[0];continue;case 7:op=_.ops.pop();_.trys.pop();continue;default:if(!(t=_.trys,t=t.length>0&&t[t.length-1])&&(op[0]===6||op[0]===2)){_=0;continue}if(op[0]===3&&(!t||op[1]>t[0]&&op[1]<t[3])){_.label=op[1];break}if(op[0]===6&&_.label<t[1]){_.label=t[1];t=op;break}if(t&&_.label<t[2]){_.label=t[2];_.ops.push(op);break}if(t[2])_.ops.pop();_.trys.pop();continue}op=body.call(thisArg,_)}catch(e){op=[6,e];y=0}finally{f=t=0}if(op[0]&5)throw op[1];return{value:op[0]?op[1]:void 0,done:true}}};Object.defineProperty(exports,"__esModule",{value:true});var fs=require("fs");var node_crypto_1=require("node:crypto");var path=require("path");var ts=require("typescript");var yargs_1=require("yargs");var helpers_1=require("yargs/helpers");var isVerbose=false;function log(message){if(isVerbose){console.log(message)}}var targetFunctionNames=new Set(["fail","hardAssert"]);function getTsFilesRecursive(dirPath){var tsFiles=[];try{var entries=fs.readdirSync(dirPath,{withFileTypes:true});for(var _i=0,entries_1=entries;_i<entries_1.length;_i++){var entry=entries_1[_i];var fullPath=path.join(dirPath,entry.name);if(entry.isDirectory()){if(entry.name==="node_modules"){continue}tsFiles=tsFiles.concat(getTsFilesRecursive(fullPath))}else if(entry.isFile()&&entry.name.endsWith(".ts")){if(!entry.name.endsWith(".d.ts")){tsFiles.push(fullPath)}}}}catch(error){console.error("Error reading directory ".concat(dirPath,": ").concat(error.message));throw error}return tsFiles}function findFunctionCalls(filePaths){var foundCalls=[];var _loop_1=function(filePath){var sourceText=fs.readFileSync(filePath,"utf8");var sourceFile=ts.createSourceFile(path.basename(filePath),sourceText,ts.ScriptTarget.ESNext,true,ts.ScriptKind.Unknown);var visit=function(node){if(ts.isCallExpression(node)){var functionName=null;var expression=node.expression;if(ts.isIdentifier(expression)){functionName=expression.text}if(functionName&&targetFunctionNames.has(functionName)){var _a=ts.getLineAndCharacterOfPosition(sourceFile,node.getStart()),line=_a.line,character=_a.character;var argsText_1=[];var errorMessage_1;var assertionId_1;if(node.arguments&&node.arguments.length>0){node.arguments.forEach((function(arg){argsText_1.push(arg.getText(sourceFile));if(ts.isStringLiteral(arg)){errorMessage_1=arg.getText(sourceFile)}else if(ts.isNumericLiteral(arg)){assertionId_1=arg.getText(sourceFile)}}))}foundCalls.push({fileName:filePath,functionName:functionName,line:line+1,character:character+1,argumentsText:argsText_1,errorMessage:errorMessage_1,assertionId:assertionId_1!==null&&assertionId_1!==void 0?assertionId_1:"INVALID"})}}ts.forEachChild(node,visit)};visit(sourceFile)};for(var _i=0,filePaths_1=filePaths;_i<filePaths_1.length;_i++){var filePath=filePaths_1[_i];_loop_1(filePath)}return foundCalls}function handleList(occurrences){if(occurrences.length===0){log("No assertion ids found.");return}occurrences.sort((function(a,b){return a.assertionId.localeCompare(b.assertionId)})).forEach((function(call){console.log("ID: ".concat(call.assertionId,"; MESSAGE: ").concat(call.errorMessage,"; SOURCE: '").concat(call.functionName,"' call at ").concat(path.relative(process.cwd(),call.fileName),":").concat(call.line,":").concat(call.character))}))}function find(occurrences,targetId){var target=typeof targetId==="number"?targetId.toString(16):targetId;return occurrences.filter((function(o){return String(o.assertionId)===String(target)}))}function handleFind(occurrences,targetId){var foundLocations=find(occurrences,targetId);if(foundLocations.length===0){log('Assertion id "'.concat(targetId,'" not found.'));process.exit(1)}handleList(foundLocations)}function handleCheck(occurrences){if(occurrences.length===0){log("No assertion ids found to check for duplicates.");return}var idCounts={};occurrences.forEach((function(occ){var codeStr=String(occ.assertionId);if(!idCounts[codeStr]){idCounts[codeStr]=[]}idCounts[codeStr].push(occ);if(!/^0x[0-9a-f]{4}$/.test(occ.assertionId)){console.error("Invalid assertion ID '".concat(occ.assertionId,"'. Must match /^0x[0-9a-f]{4}$/"));var relativePath=path.relative(process.cwd(),occ.fileName);console.error("- at '".concat(relativePath,":").concat(occ.line,":").concat(occ.character))}}));var duplicatesFound=false;log("Checking for duplicate assertion id usage:");Object.entries(idCounts).forEach((function(_a){var code=_a[0],locations=_a[1];if(locations.length>1){duplicatesFound=true;console.error('\nDuplicate assertion id "'.concat(code,'" found at ').concat(locations.length," locations:"));locations.forEach((function(loc){var relativePath=path.relative(process.cwd(),loc.fileName);console.error("- ".concat(relativePath,":").concat(loc.line,":").concat(loc.character))}))}}));if(!duplicatesFound){log("No duplicate assertion ids found.")}else{process.exit(1)}}function randomId(){var randomBytes=new Uint8Array(2);(0,node_crypto_1.getRandomValues)(randomBytes);return"0x"+Array.from(randomBytes).map((function(byte){return byte.toString(16).padStart(2,"0")})).join("")}function handleNew(occurrences){var newCode=randomId();while(find(occurrences,newCode).length>0){newCode=randomId()}console.log(newCode)}function main(){return __awaiter(this,void 0,void 0,(function(){var argv,targetDirectory,stats,filesToScan,allOccurrences;return __generator(this,(function(_a){argv=(0,yargs_1.default)((0,helpers_1.hideBin)(process.argv)).usage("Usage: $0 [options]").option("dir",{alias:"D",describe:"Directory to scan recursively for TS files",type:"string",demandOption:true}).option("verbose",{alias:"V",describe:"verbose",type:"boolean"}).option("find",{alias:"F",describe:"Find locations of a specific {assertionId}",type:"string",nargs:1}).option("list",{alias:"L",describe:"List all unique assertion ids found (default action)",type:"boolean"}).option("new",{alias:"N",describe:"Suggest a new assertion id based on existing ones",type:"boolean"}).option("check",{alias:"C",describe:"Check for duplicate usage of assertion ids",type:"boolean"}).check((function(argv){var options=[argv.F,argv.L,argv.N,argv.C].filter(Boolean).length;if(options>1){throw new Error("Options -F, -L, -N, -C are mutually exclusive.")}return true})).help().alias("help","h").strict().parse();targetDirectory=path.resolve(argv["dir"]);isVerbose=!!argv["verbose"];try{stats=fs.statSync(targetDirectory);if(!stats.isDirectory()){console.error("Error: Provided path is not a directory: ".concat(targetDirectory));process.exit(1)}}catch(error){console.error("Error accessing directory ".concat(targetDirectory,": ").concat(error.message));process.exit(1)}log("Scanning directory: ".concat(targetDirectory));filesToScan=getTsFilesRecursive(targetDirectory);if(filesToScan.length===0){log("No relevant .ts or .tsx files found.");process.exit(0)}log("Found ".concat(filesToScan.length," files. Analyzing for assertion ids..."));allOccurrences=findFunctionCalls(filesToScan);log("Scan complete. Found ".concat(allOccurrences.length," potential assertion id occurrences."));if(argv["find"]){handleFind(allOccurrences,argv["find"])}else if(argv["new"]){handleNew(allOccurrences)}else if(argv["check"]){handleCheck(allOccurrences)}else{handleList(allOccurrences)}return[2]}))}))}void main(); No newline at end of file |
| return syncEngineWrite( | ||
| syncEngine, | ||
| mutations, | ||
| mutations.length === 1 ? wrappedDeferred : deferred |
There was a problem hiding this comment.
Contextual error enrichment is currently disabled for batch writes containing more than one mutation (mutations.length === 1 ? wrappedDeferred : deferred). This limits the effectiveness of the feature for transactions and bulk operations. As noted in the adjacent comment, this gap should be addressed to provide context even for multi-mutation batches.
Hey there! So you want to contribute to a Firebase SDK?
Before you file this pull request, please read these guidelines:
Discussion
If not, go file an issue about this before creating a pull request to discuss.
Testing
API Changes
PR that changes the public API, we would suggest first proposing your change in an
issue so that we can discuss it together.